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:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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); }
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user