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
+269
View File
@@ -0,0 +1,269 @@
"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>
);
}
+264
View File
@@ -0,0 +1,264 @@
/* ============================================================
FORGE - Design tokens (Doc 3 §3) + base component styles.
Ported verbatim from the design handoff (assets/tokens.css).
Light is the default theme; dark is the "console" toggle.
============================================================ */
:root,
:root[data-theme="light"] {
/* shadcn/ui (new-york, Neutral) — page is muted gray, cards are white */
--bg-0:#F5F5F5; --bg-1:#FFFFFF; --bg-2:#FFFFFF; --bg-3:#F0F0F0; --bg-hover:#E8E8E8;
--line:#E5E5E5; --line-strong:#D4D4D4;
--fg-0:#0A0A0A; --fg-1:#404040; --fg-2:#737373; --fg-on-accent:#FAFAFA;
/* primary action = solid near-black (shadcn primary) */
--primary:#0A0A0A; --primary-fg:#FAFAFA; --primary-hover:#262626;
/* brand accent = indigo */
--accent:#4F46E5; --accent-bright:#6366F1; --accent-dim:#4338CA;
--accent-glow:rgba(79,70,229,0.10);
--signal:#4F46E5; --signal-dim:#4338CA; --signal-glow:rgba(79,70,229,0.14);
--ok:#16A34A; --ok-bg:rgba(22,163,74,0.10);
--warn:#D97706; --warn-bg:rgba(217,119,6,0.12);
--err:#DC2626; --err-bg:rgba(220,38,38,0.10);
--info:#2563EB; --info-bg:rgba(37,99,235,0.10);
--io-messages:#0D9488; --io-text:#6B7280; --io-json:#7C3AED;
--io-tool:#4F46E5; --io-vector:#C026D3; --io-control:#737373; --io-any:#A3A3A3;
--canvas-bg:#FAFAFA; --canvas-grid:rgba(10,10,10,0.05); --canvas-grid-strong:rgba(10,10,10,0.09);
--node-shadow:0 1px 3px rgba(0,0,0,.1), 0 1px 2px -1px rgba(0,0,0,.1);
--sh-1:0 1px 2px 0 rgba(0,0,0,.05);
--sh-2:0 4px 6px -1px rgba(0,0,0,.1), 0 2px 4px -2px rgba(0,0,0,.1);
--sh-pop:0 20px 25px -5px rgba(0,0,0,.1), 0 8px 10px -6px rgba(0,0,0,.1);
}
:root[data-theme="dark"] {
--bg-0:#0A0A0A; --bg-1:#171717; --bg-2:#1F1F1F; --bg-3:#262626; --bg-hover:#2E2E2E;
--line:rgba(255,255,255,0.10); --line-strong:rgba(255,255,255,0.16);
--fg-0:#FAFAFA; --fg-1:#D4D4D4; --fg-2:#A3A3A3; --fg-on-accent:#FFFFFF;
/* dark: primary flips to light with dark text (shadcn convention) */
--primary:#FAFAFA; --primary-fg:#171717; --primary-hover:#E5E5E5;
--accent:#818CF8; --accent-bright:#A5B4FC; --accent-dim:#6366F1;
--accent-glow:rgba(129,140,248,0.18);
--signal:#818CF8; --signal-dim:#6366F1; --signal-glow:rgba(129,140,248,0.18);
--ok:#4ADE80; --ok-bg:rgba(74,222,128,0.12);
--warn:#FBBF24; --warn-bg:rgba(251,191,36,0.12);
--err:#F87171; --err-bg:rgba(248,113,113,0.12);
--info:#60A5FA; --info-bg:rgba(96,165,250,0.12);
--io-messages:#2DD4BF; --io-text:#9CA3AF; --io-json:#A78BFA;
--io-tool:#818CF8; --io-vector:#E879F9; --io-control:#A3A3A3; --io-any:#C0C7D0;
--canvas-bg:#0A0A0A; --canvas-grid:rgba(255,255,255,0.05); --canvas-grid-strong:rgba(255,255,255,0.09);
--node-shadow:0 1px 3px rgba(0,0,0,.5), 0 1px 2px -1px rgba(0,0,0,.4);
--sh-1:0 1px 2px 0 rgba(0,0,0,.4);
--sh-2:0 4px 6px -1px rgba(0,0,0,.5), 0 2px 4px -2px rgba(0,0,0,.5);
--sh-pop:0 20px 25px -5px rgba(0,0,0,.6), 0 8px 10px -6px rgba(0,0,0,.5);
}
:root {
--font-display:"Geist",ui-sans-serif,system-ui,-apple-system,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;
--font-ui:"Geist",ui-sans-serif,system-ui,-apple-system,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;
--font-mono:"Geist Mono",ui-monospace,SFMono-Regular,"Menlo","Consolas","Liberation Mono",monospace;
--s1:4px; --s2:8px; --s3:12px; --s4:16px; --s5:20px; --s6:24px; --s8:32px; --s10:40px; --s12:48px;
--r-xs:4px; --r-sm:8px; --r-md:8px; --r-lg:12px; --r-xl:14px;
--ease:cubic-bezier(.2,.8,.2,1); --dur-fast:120ms; --dur:180ms; --dur-slow:260ms;
--glow-accent:0 0 0 1px var(--accent), 0 0 0 3px var(--accent-glow);
--glow-signal:0 0 0 3px var(--signal-glow);
}
* { box-sizing:border-box; }
html,body { margin:0; height:100%; }
body {
font-family:var(--font-ui);
background:var(--bg-0); color:var(--fg-0);
font-size:14px; line-height:20px;
-webkit-font-smoothing:antialiased; text-rendering:optimizeLegibility;
overflow:hidden;
}
#root, #__next { height:100vh; }
::selection { background:var(--accent-glow); }
::-webkit-scrollbar { width:10px; height:10px; }
::-webkit-scrollbar-thumb { background:var(--line-strong); border-radius:8px; border:2px solid transparent; background-clip:padding-box; }
::-webkit-scrollbar-thumb:hover { background:var(--fg-2); background-clip:padding-box; }
::-webkit-scrollbar-track { background:transparent; }
/* ---------- type scale ---------- */
.t-display-lg { font-family:var(--font-display); font-size:28px; line-height:34px; letter-spacing:-.02em; font-weight:600; }
.t-display { font-family:var(--font-display); font-size:22px; line-height:28px; letter-spacing:-.01em; font-weight:600; }
.t-h1 { font-family:var(--font-display); font-size:18px; line-height:24px; font-weight:600; letter-spacing:-.01em; }
.t-h2 { font-family:var(--font-display); font-size:15px; line-height:20px; font-weight:600; }
.t-h3 { font-family:var(--font-display); font-size:13px; line-height:18px; font-weight:600; }
.t-body { font-size:14px; line-height:20px; }
.t-body-sm { font-size:13px; line-height:18px; }
.t-caption { font-size:12px; line-height:16px; }
.t-micro { font-size:11px; line-height:14px; text-transform:uppercase; letter-spacing:.06em; color:var(--fg-2); font-weight:600; }
.mono { font-family:var(--font-mono); font-size:12.5px; line-height:18px; font-feature-settings:"ss01"; }
.mono-sm { font-family:var(--font-mono); font-size:12px; line-height:18px; }
.fg-1 { color:var(--fg-1); } .fg-2 { color:var(--fg-2); }
.accent { color:var(--accent); } .signal { color:var(--signal); }
/* ---------- buttons ---------- */
.btn {
display:inline-flex; align-items:center; gap:var(--s2);
font-family:var(--font-ui); font-size:13px; font-weight:600;
height:32px; padding:0 var(--s3); border-radius:var(--r-sm);
border:1px solid transparent; cursor:pointer; white-space:nowrap;
transition:background var(--dur-fast) var(--ease), border-color var(--dur-fast), box-shadow var(--dur-fast), transform var(--dur-fast);
user-select:none;
}
.btn:active { transform:translateY(.5px); }
.btn svg { width:15px; height:15px; }
.btn-primary { background:var(--primary); color:var(--primary-fg); border-color:transparent; box-shadow:var(--sh-1); }
.btn-primary:hover { background:var(--primary-hover); box-shadow:var(--sh-1); }
.btn-secondary { background:var(--bg-1); color:var(--fg-0); border-color:var(--line-strong); }
.btn-secondary:hover { background:var(--bg-hover); border-color:var(--fg-2); }
.btn-ghost { background:transparent; color:var(--fg-1); }
.btn-ghost:hover { background:var(--bg-hover); color:var(--fg-0); }
.btn-danger { background:var(--err-bg); color:var(--err); border-color:transparent; }
.btn-danger:hover { background:var(--err); color:#fff; }
.btn-sm { height:26px; font-size:12px; padding:0 var(--s2); }
.btn-lg { height:40px; font-size:14px; padding:0 var(--s5); }
.btn:disabled { opacity:.45; cursor:not-allowed; }
.iconbtn {
display:inline-flex; align-items:center; justify-content:center;
width:30px; height:30px; border-radius:var(--r-sm);
border:1px solid transparent; background:transparent; color:var(--fg-1); cursor:pointer;
transition:background var(--dur-fast), color var(--dur-fast), border-color var(--dur-fast);
}
.iconbtn:hover { background:var(--bg-hover); color:var(--fg-0); }
.iconbtn svg { width:17px; height:17px; }
.iconbtn.active { color:var(--accent); }
/* ---------- inputs ---------- */
.input, .textarea, .select {
width:100%; font-family:var(--font-ui); font-size:13px; color:var(--fg-0);
background:var(--bg-1); border:1px solid var(--line-strong); border-radius:var(--r-sm);
height:32px; padding:0 var(--s3); outline:none;
transition:border-color var(--dur-fast), box-shadow var(--dur-fast);
}
.input::placeholder, .textarea::placeholder { color:var(--fg-2); }
.input:focus, .textarea:focus, .select:focus { border-color:var(--signal); box-shadow:0 0 0 3px var(--signal-glow); }
.input.mono { font-family:var(--font-mono); font-size:12.5px; }
.textarea { height:auto; padding:var(--s2) var(--s3); line-height:19px; resize:vertical; min-height:64px; }
.select { appearance:none; padding-right:var(--s8); cursor:pointer;
background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='%237A848F' stroke-width='2.2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'/%3E%3C/svg%3E");
background-repeat:no-repeat; background-position:right var(--s3) center; }
.field-label { display:block; font-size:12px; font-weight:600; color:var(--fg-1); margin-bottom:6px; }
.field-help { font-size:11.5px; color:var(--fg-2); margin-top:5px; line-height:15px; }
/* ---------- native checkbox / radio (brand accent instead of browser blue) ---------- */
input[type="checkbox"], input[type="radio"] { accent-color:var(--accent); cursor:pointer; width:15px; height:15px; }
input[type="checkbox"]:disabled, input[type="radio"]:disabled { cursor:not-allowed; opacity:.5; }
/* ---------- toggle ---------- */
.toggle { position:relative; width:34px; height:20px; border-radius:999px; background:var(--line-strong); border:none; cursor:pointer; transition:background var(--dur); flex:none; }
.toggle::after { content:""; position:absolute; top:2px; left:2px; width:16px; height:16px; border-radius:50%; background:#fff; box-shadow:0 1px 2px rgba(0,0,0,.3); transition:transform var(--dur) var(--ease); }
.toggle.on { background:var(--accent); }
.toggle.on::after { transform:translateX(14px); }
.toggle.signal.on { background:var(--signal); }
/* ---------- segmented ---------- */
.segmented { display:inline-flex; padding:2px; background:var(--bg-3); border:1px solid var(--line); border-radius:var(--r-sm); gap:2px; }
.segmented button { font-family:var(--font-ui); font-size:12px; font-weight:600; color:var(--fg-1); background:transparent; border:none; height:26px; padding:0 var(--s3); border-radius:6px; cursor:pointer; white-space:nowrap; transition:all var(--dur-fast); }
.segmented button.active { background:var(--bg-1); color:var(--fg-0); box-shadow:var(--sh-1); }
.segmented button:hover:not(.active) { color:var(--fg-0); }
/* ---------- chips / pills / badges ---------- */
.chip { display:inline-flex; align-items:center; gap:5px; height:24px; padding:0 8px; border-radius:var(--r-sm); background:var(--bg-3); border:1px solid var(--line); font-size:12px; font-weight:500; color:var(--fg-1); }
.chip .dot { width:7px; height:7px; border-radius:50%; }
.chip-mono { font-family:var(--font-mono); font-size:11.5px; }
.chip-removable { padding-right:4px; }
.pill { display:inline-flex; align-items:center; gap:5px; height:20px; padding:0 8px; border-radius:999px; font-size:11.5px; font-weight:600; }
.pill .dot { width:6px; height:6px; border-radius:50%; }
.pill-ok { background:var(--ok-bg); color:var(--ok); } .pill-ok .dot { background:var(--ok); }
.pill-warn { background:var(--warn-bg); color:var(--warn); } .pill-warn .dot { background:var(--warn); }
.pill-err { background:var(--err-bg); color:var(--err); } .pill-err .dot { background:var(--err); }
.pill-info { background:var(--info-bg); color:var(--info); } .pill-info .dot { background:var(--info); }
.pill-muted { background:var(--bg-3); color:var(--fg-2); } .pill-muted .dot { background:var(--fg-2); }
.typechip { font-family:var(--font-mono); font-size:10px; line-height:1; text-transform:uppercase; letter-spacing:.05em; font-weight:600; padding:3px 5px; border-radius:4px; background:var(--bg-3); color:var(--fg-2); border:1px solid var(--line); }
.badge { display:inline-flex; align-items:center; justify-content:center; min-width:18px; height:18px; padding:0 5px; border-radius:999px; font-size:11px; font-weight:600; font-family:var(--font-mono); background:var(--bg-3); color:var(--fg-1); }
.badge-accent { background:var(--accent-glow); color:var(--accent); }
/* ---------- cards ---------- */
.card { background:var(--bg-1); border:1px solid var(--line); border-radius:var(--r-lg); }
.card-hover { transition:transform var(--dur) var(--ease), border-color var(--dur), box-shadow var(--dur); cursor:pointer; }
.card-hover:hover { transform:translateY(-2px); border-color:var(--accent); box-shadow:var(--sh-2); }
/* ---------- table ---------- */
.tbl { width:100%; border-collapse:collapse; }
.tbl th { font-size:11px; text-transform:uppercase; letter-spacing:.05em; color:var(--fg-2); font-weight:600; text-align:left; padding:0 var(--s3); height:34px; border-bottom:1px solid var(--line); white-space:nowrap; }
.tbl td { padding:0 var(--s3); height:44px; border-bottom:1px solid var(--line); font-size:13px; vertical-align:middle; }
.tbl tr.row { display:table-row; transition:background var(--dur-fast); cursor:pointer; }
.tbl tr.row:hover { background:var(--bg-3); }
.tbl-dense td { height:36px; }
/* ---------- misc utility ---------- */
.row { display:flex; align-items:center; }
.col { display:flex; flex-direction:column; }
.gap1{gap:4px}.gap2{gap:8px}.gap3{gap:12px}.gap4{gap:16px}.gap5{gap:20px}.gap6{gap:24px}
.spread { justify-content:space-between; }
.center { align-items:center; justify-content:center; }
.wrap { flex-wrap:wrap; }
.grow { flex:1; min-width:0; }
.scroll-y { overflow-y:auto; }
.divider { height:1px; background:var(--line); }
.vdivider { width:1px; background:var(--line); align-self:stretch; }
.dimmed { opacity:.5; }
.hairline { border:1px solid var(--line); }
.truncate { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.kbd { font-family:var(--font-mono); font-size:11px; background:var(--bg-3); border:1px solid var(--line-strong); border-bottom-width:2px; border-radius:4px; padding:1px 5px; color:var(--fg-1); }
.tooltip-pop { position:absolute; background:var(--fg-0); color:var(--bg-1); font-size:11.5px; font-weight:500; padding:4px 8px; border-radius:5px; white-space:nowrap; pointer-events:none; z-index:9000; box-shadow:var(--sh-2); }
.progress { height:6px; border-radius:999px; background:var(--bg-3); overflow:hidden; }
.progress > i { display:block; height:100%; border-radius:999px; background:var(--accent); transition:width var(--dur-slow) var(--ease); }
:focus-visible { outline:none; box-shadow:var(--glow-signal); border-radius:var(--r-xs); }
/* entrance + live-run animations */
@keyframes fadeUp { from { transform:translateY(7px); } to { transform:none; } }
@keyframes fadeIn { from { transform:translateY(2px); } to { transform:none; } }
@keyframes pulse { 0%,100% { transform: scale(1); opacity: 1; } 50% { transform: scale(.6); opacity: .5; } }
@keyframes spin { to { transform: rotate(360deg); } }
@keyframes blink { 50% { opacity: 0; } }
@keyframes dash { to { stroke-dashoffset: -16; } }
.fade-up, .fade-in { opacity:1; }
@media (prefers-reduced-motion: no-preference) {
.fade-up { animation:fadeUp var(--dur-slow) var(--ease) both; }
.fade-in { animation:fadeIn var(--dur) var(--ease) both; }
.spin { animation:spin 0.9s linear infinite; transform-origin:center; }
}
.no-scrollbar::-webkit-scrollbar { display:none; }
.sidenav-item { background:transparent; font-weight:500; }
.sidenav-item:not(.active):hover { background:var(--bg-3); }
.sidenav-item.active { background:var(--accent-glow); color:var(--accent); font-weight:600; }
/* ---------- markdown (assistant replies: Feature 1 - structured responses) ----------
Inherits font-size from the chat bubble; only sets structure + theme-aware colors. */
.md { line-height:1.6; overflow-wrap:anywhere; }
.md > :first-child { margin-top:0; }
.md > :last-child { margin-bottom:0; }
.md p { margin:0 0 8px; }
.md h1,.md h2,.md h3,.md h4 { font-family:var(--font-display); font-weight:600; line-height:1.3; margin:14px 0 6px; }
.md h1 { font-size:17px; } .md h2 { font-size:15px; } .md h3 { font-size:13.5px; } .md h4 { font-size:13px; }
.md ul,.md ol { margin:0 0 8px; padding-left:20px; }
.md li { margin:2px 0; }
.md li > ul,.md li > ol { margin:2px 0; }
.md a { color:var(--accent); text-decoration:none; }
.md a:hover { text-decoration:underline; }
.md strong { font-weight:600; color:var(--fg-0); }
.md em { font-style:italic; }
.md code { font-family:var(--font-mono); font-size:.86em; background:var(--bg-3); border:1px solid var(--line); border-radius:var(--r-xs); padding:1px 4px; }
.md pre { margin:0 0 8px; padding:10px 12px; background:var(--bg-3); border:1px solid var(--line); border-radius:var(--r-md); overflow:auto; }
.md pre code { background:none; border:none; padding:0; font-size:12px; line-height:1.5; }
.md blockquote { margin:0 0 8px; padding:2px 12px; border-left:3px solid var(--line-strong); color:var(--fg-1); }
.md hr { border:none; border-top:1px solid var(--line); margin:12px 0; }
.md table { border-collapse:collapse; margin:0 0 8px; font-size:12.5px; display:block; max-width:100%; overflow-x:auto; }
.md th,.md td { border:1px solid var(--line); padding:6px 10px; text-align:left; vertical-align:top; }
.md th { background:var(--bg-3); font-weight:600; color:var(--fg-1); white-space:nowrap; }
.md img { max-width:100%; border-radius:var(--r-sm); }
+24
View File
@@ -0,0 +1,24 @@
import type { Metadata } from "next";
import "@xyflow/react/dist/style.css";
import "./globals.css";
export const metadata: Metadata = {
title: "Forge - AI Agent Platform",
description: "Self-hosted platform for building, testing, and shipping LangChain/LangGraph agents.",
};
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en" data-theme="light">
<head>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="anonymous" />
<link
href="https://fonts.googleapis.com/css2?family=Geist:wght@100..900&family=Geist+Mono:wght@100..900&display=swap"
rel="stylesheet"
/>
</head>
<body>{children}</body>
</html>
);
}
+258
View File
@@ -0,0 +1,258 @@
"use client";
/* Forge - root app: routing + chrome assembly (a single navigable SPA, like the handoff). */
import { ReactNode, useEffect, useMemo, useRef, useState } from "react";
import { Topbar, ProjectSidebar, CommandPalette, AssistantPanel, Crumb } from "@/components/shell";
import { DashboardScreen, OnboardingScreen, ProjectCard } from "@/components/screens/home";
import { AnalyticsScreen } from "@/components/screens/analytics";
import { PlaygroundScreen } from "@/components/screens/playground";
import { ToolsScreen, ToolBuilderScreen } from "@/components/screens/tools";
import { WorkflowsScreen, WorkflowCanvas } from "@/components/screens/workflows";
import { AgentsScreen, AgentConfigScreen } from "@/components/screens/agents";
import { ComponentsScreen, ComponentBuilderScreen } from "@/components/screens/components";
import { EmbedScreen } from "@/components/screens/embed";
import { KnowledgeScreen } from "@/components/screens/knowledge";
import { TracesScreen } from "@/components/screens/traces";
import { AuthProvidersScreen } from "@/components/screens/auth";
import { SettingsScreen } from "@/components/screens/settings";
import { ConnectScreen } from "@/components/screens/deploy";
import { McpClientsScreen } from "@/components/screens/mcp";
import { ChannelsScreen, TriggersScreen, DatasetsScreen, HandoffScreen } from "@/components/screens/platform";
import { Icon } from "@/components/icons";
import { api, Agent, ComponentT, DashboardStats, Project, Tool, Workflow } from "@/lib/api";
import { groundedWorkflow } from "@/lib/graph";
import { spark } from "@/lib/data";
import { AuthGate } from "@/components/login";
type View = { name: "dashboard" | "onboarding" | "project"; project?: string; screen?: string };
const SCREEN_LABEL: Record<string, string> = {
overview: "Overview", workflows: "Workflows", "workflow-canvas": "Support Router",
agents: "Agents", "agent-config": "billing_agent", tools: "Tools", "tool-builder": "Tool", components: "Components", "component-builder": "Component",
auth: "Auth Providers", knowledge: "Knowledge", playground: "Playground", traces: "Traces",
connect: "Connect", mcp: "External MCP", settings: "Settings",
channels: "Channels", triggers: "Triggers", datasets: "Evaluations", handoff: "Agent inbox", embed: "Embed",
};
const PARENT: Record<string, [string, string]> = {
"workflow-canvas": ["workflows", "Workflows"], "agent-config": ["agents", "Agents"], "tool-builder": ["tools", "Tools"], "component-builder": ["components", "Components"],
};
function App() {
const [view, setView] = useState<View>({ name: "dashboard" });
const [cmdOpen, setCmdOpen] = useState(false);
const [assistantOpen, setAssistantOpen] = useState(false);
const [sidebarOpen, setSidebarOpen] = useState(true);
const [projects, setProjects] = useState<Project[]>([]);
const [loaded, setLoaded] = useState(false);
const [selTool, setSelTool] = useState<Tool | null>(null);
const [selWorkflow, setSelWorkflow] = useState<Workflow | null>(null);
const [selAgent, setSelAgent] = useState<Agent | null>(null);
const [selComponent, setSelComponent] = useState<ComponentT | null>(null);
const [refreshNonce, setRefreshNonce] = useState(0);
// Dashboard stats are fetched ONCE here and shared: the project cards read `.projects`
// (per-project counts) and the DashboardScreen reuses the same object for its KPIs -
// no second /stats/dashboard call.
const [dashboard, setDashboard] = useState<DashboardStats | null>(null);
const projStats = useMemo(() => dashboard?.projects || {}, [dashboard]);
const reloadProjects = () =>
api.listProjects().then((p) => { setProjects(p); setLoaded(true); }).catch(() => setLoaded(true));
useEffect(() => { reloadProjects(); }, []);
useEffect(() => { api.dashboardStats().then(setDashboard).catch(() => setDashboard(null)); }, [refreshNonce]);
useEffect(() => {
const h = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k") { e.preventDefault(); setCmdOpen((o) => !o); }
};
window.addEventListener("keydown", h);
return () => window.removeEventListener("keydown", h);
}, []);
const cards: ProjectCard[] = useMemo(
() =>
projects.map((p, i) => {
const s = projStats[p.id] || { workflows: 0, tools: 0, runs_7d: 0 };
return {
id: p.id, name: p.name, slug: p.slug, status: p.status,
workflows: s.workflows, tools: s.tools, runs7d: s.runs_7d,
spark: spark(14, 24 + i * 6, 16), edited: "recently",
};
}),
[projects, projStats],
);
const project = view.project ? cards.find((c) => c.id === view.project) || projects.find((p) => p.id === view.project) : null;
const go = (v: View) => setView(v);
const navScreen = (screen: string) => setView((v) => ({ ...v, name: "project", screen }));
async function deleteProject(projectToDelete: { id: string; name: string }, opts?: { skipConfirm?: boolean }) {
if (!opts?.skipConfirm && !window.confirm(`Delete project "${projectToDelete.name}"?\n\nThis removes its workflows, agents, tools, auth providers, knowledge, secrets, runs, and traces. This cannot be undone.`)) return;
await api.deleteProject(projectToDelete.id);
setProjects((prev) => prev.filter((p) => p.id !== projectToDelete.id));
setRefreshNonce((n) => n + 1); // refetches dashboard stats (drops the deleted project's counts)
if (view.project === projectToDelete.id) {
setSelWorkflow(null);
setSelAgent(null);
setSelTool(null);
go({ name: "dashboard" });
}
await reloadProjects();
}
// breadcrumbs
let crumbs: Crumb[] = [{ label: "Forge", onClick: () => go({ name: "dashboard" }) }];
if (view.name === "dashboard") crumbs = [{ label: "Home" }];
else if (view.name === "onboarding") crumbs.push({ label: "New project" });
else if (view.name === "project" && project) {
crumbs.push({ label: (project as any).name, onClick: () => navScreen("overview") });
if (view.screen && view.screen !== "overview") {
const parent = PARENT[view.screen];
if (parent) crumbs.push({ label: parent[1], onClick: () => navScreen(parent[0]) });
const leaf =
view.screen === "tool-builder" && selTool ? selTool.name :
view.screen === "workflow-canvas" && selWorkflow ? selWorkflow.name :
view.screen === "agent-config" && selAgent ? selAgent.name :
view.screen === "component-builder" && selComponent ? selComponent.name :
SCREEN_LABEL[view.screen] || view.screen;
crumbs.push({ label: leaf });
}
}
// The canvas registers its save() here so the top-bar Publish can flush unsaved edits
// before publishing (otherwise Publish would ship the last-saved version, not the canvas).
const canvasFlushRef = useRef<(() => Promise<void>) | null>(null);
const [publishState, setPublishState] = useState<"idle" | "publishing" | "published" | "error">("idle");
async function publishWorkflow() {
if (!project || !selWorkflow) return;
setPublishState("publishing");
try {
if (canvasFlushRef.current) await canvasFlushRef.current();
await api.publishWorkflow((project as any).id, selWorkflow.id);
setPublishState("published");
setRefreshNonce((n) => n + 1);
setTimeout(() => setPublishState("idle"), 1600);
} catch {
setPublishState("error");
setTimeout(() => setPublishState("idle"), 2400);
}
}
const topRight: ReactNode =
view.name === "project" && view.screen === "workflow-canvas" ? (
<div className="row gap2">
<button className="btn btn-secondary btn-sm" onClick={() => setAssistantOpen(true)}><Icon name="sparkles" size={14} />Assistant</button>
<button className="btn btn-primary btn-sm" onClick={publishWorkflow} disabled={!selWorkflow || publishState === "publishing"}>
<Icon name={publishState === "published" ? "check" : "bolt"} size={14} />
{publishState === "publishing" ? "Publishing…" : publishState === "published" ? "Published" : publishState === "error" ? "Invalid - fix problems" : "Publish"}
</button>
</div>
) : (
<div className="row gap2">
<button className="btn btn-secondary" onClick={() => setAssistantOpen(true)} style={{ height: 32 }}><Icon name="sparkles" size={14} />Forge Assistant</button>
</div>
);
const body = (() => {
if (view.name === "dashboard")
return <DashboardScreen projects={cards} loaded={loaded} stats={dashboard} onOpenProject={(id) => go({ name: "project", project: id, screen: "overview" })} onNewProject={() => go({ name: "onboarding" })} onDeleteProject={deleteProject} />;
if (view.name === "onboarding")
return (
<OnboardingScreen
onCreate={async ({ name, template, keys }) => {
try {
const p = await api.createProject({ name });
// Persist provider keys as encrypted secrets + provider_credentials.
const entered = Object.entries(keys || {}).filter(([, v]) => v && v.trim());
if (entered.length) {
const pc: Record<string, string> = {};
for (const [prov, val] of entered) {
const secretName = `${prov}_key`;
await api.createSecret(p.id, { name: secretName, value: val, kind: "api_key" }).catch(() => {});
pc[prov] = `secret://proj/${secretName}`;
}
await api.updateProject(p.id, { config: { ...(p.config || {}), provider_credentials: pc } }).catch(() => {});
}
if (template === "support" || template === "rag") {
const g = groundedWorkflow();
const wf = await api.createWorkflow(p.id, { name: template === "rag" ? "RAG Q&A" : "Support agent", canvas: g.canvas, executable: g.executable });
await api.publishWorkflow(p.id, wf.id).catch(() => {});
}
await reloadProjects();
setRefreshNonce((n) => n + 1);
go({ name: "project", project: p.id, screen: template === "blank" || template === "mcp" ? "overview" : "workflow-canvas" });
} catch {
go({ name: "dashboard" });
}
}}
onCancel={() => go({ name: "dashboard" })}
/>
);
if (view.name === "project") {
switch (view.screen) {
case "overview": return <AnalyticsScreen project={project} onNav={navScreen} />;
case "playground": return <PlaygroundScreen project={project} />;
case "workflows": return <WorkflowsScreen project={project} onOpen={(w) => { setSelWorkflow(w); navScreen("workflow-canvas"); }} />;
case "workflow-canvas": return <WorkflowCanvas project={project} workflowId={selWorkflow?.id} onWorkflowChange={setSelWorkflow} onBack={() => navScreen("workflows")} onRun={() => navScreen("playground")} onRegisterFlush={(fn) => { canvasFlushRef.current = fn; }} />;
case "agents": return <AgentsScreen project={project} onOpen={(a) => { setSelAgent(a); navScreen("agent-config"); }} />;
case "agent-config": return <AgentConfigScreen project={project} agentId={selAgent?.id} onBack={() => navScreen("agents")} />;
case "tools": return <ToolsScreen project={project} onOpen={(t) => { setSelTool(t); navScreen("tool-builder"); }} />;
case "tool-builder": return <ToolBuilderScreen project={project} toolId={selTool?.id} onBack={() => navScreen("tools")} />;
case "components": return <ComponentsScreen project={project} onOpen={(c) => { setSelComponent(c); navScreen("component-builder"); }} />;
case "component-builder": return <ComponentBuilderScreen project={project} componentId={selComponent?.id} onBack={() => navScreen("components")} />;
case "auth": return <AuthProvidersScreen project={project} />;
case "mcp": return <McpClientsScreen project={project} />;
case "knowledge": return <KnowledgeScreen project={project} />;
case "channels": return <ChannelsScreen project={project} />;
case "triggers": return <TriggersScreen project={project} />;
case "datasets": return <DatasetsScreen project={project} />;
case "handoff": return <HandoffScreen project={project} />;
case "traces": return <TracesScreen project={project} />;
case "connect": return <ConnectScreen project={project} />;
case "embed": return <EmbedScreen project={project} />;
case "settings": return <SettingsScreen project={project} onDeleteProject={(p) => deleteProject(p, { skipConfirm: true })} />;
default: return <AnalyticsScreen project={project} onNav={navScreen} />;
}
}
return null;
})();
const showSidebar = view.name === "project";
const sidebarActive =
view.screen === "workflow-canvas" ? "workflows" :
view.screen === "agent-config" ? "agents" :
view.screen === "tool-builder" ? "tools" :
view.screen === "component-builder" ? "components" : (view.screen || "overview");
return (
<div style={{ display: "flex", height: "100vh", overflow: "hidden" }}>
{showSidebar && project && sidebarOpen && <ProjectSidebar project={project} active={sidebarActive} onNav={navScreen} onBack={() => go({ name: "dashboard" })} refreshKey={refreshNonce} />}
<AssistantPanel
open={assistantOpen}
onClose={() => setAssistantOpen(false)}
project={project ? { id: (project as any).id, name: (project as any).name } : null}
onMutate={() => { setRefreshNonce((n) => n + 1); reloadProjects(); }}
/>
<div className="col grow" style={{ minWidth: 0 }}>
<Topbar
crumbs={crumbs}
right={topRight}
left={showSidebar ? (
<button className="iconbtn" title={sidebarOpen ? "Hide project panel" : "Show project panel"} onClick={() => setSidebarOpen((s) => !s)}>
<Icon name={sidebarOpen ? "chevleft" : "chevright"} size={17} />
</button>
) : null}
onCommand={() => setCmdOpen(true)}
/>
<div key={view.name + (view.screen || "") + (view.project || "") + refreshNonce} className="col grow" style={{ minHeight: 0 }}>
{body}
</div>
</div>
<CommandPalette open={cmdOpen} onClose={() => setCmdOpen(false)} onGo={go} projects={cards} />
</div>
);
}
export default function Page() {
return (
<AuthGate>
<App />
</AuthGate>
);
}