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,5 @@
|
||||
# Forge web console. Copy to apps/web/.env.local for local dev.
|
||||
|
||||
# Backend URL the Next server proxies /api/forge/* to (server-side rewrite) and exposes
|
||||
# as NEXT_PUBLIC_FORGE_API_URL. Defaults to http://127.0.0.1:8000 in dev.
|
||||
FORGE_API_URL=http://127.0.0.1:8000
|
||||
@@ -0,0 +1,40 @@
|
||||
# Forge web console (Next.js 14, standalone output). BUILD CONTEXT = repo root, because
|
||||
# this is a pnpm workspace (apps/web + packages/*). Build with:
|
||||
# docker build -f apps/web/Dockerfile -t forge-web .
|
||||
FROM node:22-alpine AS deps
|
||||
WORKDIR /repo
|
||||
RUN corepack enable
|
||||
# Lockfile + workspace manifests first for cached installs.
|
||||
COPY pnpm-lock.yaml pnpm-workspace.yaml ./
|
||||
COPY apps/web/package.json apps/web/package.json
|
||||
COPY packages ./packages
|
||||
RUN pnpm install --frozen-lockfile || pnpm install
|
||||
|
||||
FROM node:22-alpine AS builder
|
||||
WORKDIR /repo
|
||||
RUN corepack enable
|
||||
COPY --from=deps /repo/node_modules ./node_modules
|
||||
COPY --from=deps /repo/apps/web/node_modules ./apps/web/node_modules
|
||||
COPY . .
|
||||
# Server-side rewrite target, baked at build time (standalone freezes rewrite destinations).
|
||||
# Default keeps plain `docker build` / host behavior; compose overrides it to http://api:8000.
|
||||
# NOT exported as NEXT_PUBLIC_* so it never leaks into the browser bundle (see next.config.mjs).
|
||||
ARG FORGE_API_URL=http://127.0.0.1:8000
|
||||
ENV FORGE_API_URL=${FORGE_API_URL}
|
||||
# Clear any stale incremental cache (see memory: stale .next can break the build).
|
||||
# NEXT_OUTPUT=standalone emits the self-contained server bundle (symlinks work on Linux).
|
||||
ENV NEXT_OUTPUT=standalone
|
||||
RUN rm -rf apps/web/.next && pnpm --filter web build
|
||||
|
||||
FROM node:22-alpine AS runner
|
||||
WORKDIR /repo
|
||||
ENV NODE_ENV=production
|
||||
RUN addgroup -g 10002 nodejs && adduser -u 10002 -G nodejs -S nextjs
|
||||
# Standalone bundle preserves the workspace layout (apps/web/server.js).
|
||||
COPY --from=builder /repo/apps/web/.next/standalone ./
|
||||
COPY --from=builder /repo/apps/web/.next/static ./apps/web/.next/static
|
||||
COPY --from=builder /repo/apps/web/public ./apps/web/public
|
||||
USER nextjs
|
||||
EXPOSE 3000
|
||||
ENV PORT=3000 HOSTNAME=0.0.0.0
|
||||
CMD ["node", "apps/web/server.js"]
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,458 @@
|
||||
"use client";
|
||||
/* Agent configuration + the middleware-stack signature. Controlled component. */
|
||||
import { useState } from "react";
|
||||
import { Icon } from "../icons";
|
||||
import { Field, Modal, Segmented, Tile, Toggle } from "../primitives";
|
||||
import { FieldsForm, MW_FIELDS, MultiSelectChips } from "./ConfigForm";
|
||||
import { MIDDLEWARE_CATALOG, MW_META } from "@/lib/data";
|
||||
import { useModels } from "@/lib/models";
|
||||
import type { Agent, ComponentT, McpClientT, Tool, ToolSet } from "@/lib/api";
|
||||
|
||||
type Cfg = Record<string, any>;
|
||||
type MW = { type: string; enabled?: boolean; config?: Record<string, any> };
|
||||
|
||||
export function AgentConfig({ config, onChange, tools = [], toolSets = [], agents = [], folders = [], kinds = [], mcpServers = [], components = [] }: { config: Cfg; onChange: (c: Cfg) => void; tools?: Tool[]; toolSets?: ToolSet[]; agents?: Agent[]; folders?: string[]; kinds?: string[]; mcpServers?: McpClientT[]; components?: ComponentT[] }) {
|
||||
const set = (patch: Cfg) => onChange({ ...config, ...patch });
|
||||
const MODELS = useModels();
|
||||
const flavor = config.flavor || "agent";
|
||||
const selectedTools: string[] = config.tools || [];
|
||||
const selectedComponents: string[] = config.components || [];
|
||||
const mwCount = (config.middleware || []).filter((m: MW) => m.enabled !== false).length;
|
||||
|
||||
const toggleTool = (id: string) =>
|
||||
set({ tools: selectedTools.includes(id) ? selectedTools.filter((t) => t !== id) : [...selectedTools, id] });
|
||||
const selectedToolSets: string[] = config.toolsets || [];
|
||||
const toggleToolSet = (id: string) =>
|
||||
set({ toolsets: selectedToolSets.includes(id) ? selectedToolSets.filter((s) => s !== id) : [...selectedToolSets, id] });
|
||||
// Tools are picked BY tool set (accordion), not from a flat list.
|
||||
const [openSets, setOpenSets] = useState<Set<string>>(new Set());
|
||||
const toggleOpen = (id: string) =>
|
||||
setOpenSets((prev) => { const n = new Set(prev); if (n.has(id)) n.delete(id); else n.add(id); return n; });
|
||||
const toolById = new Map(tools.map((t) => [t.id, t]));
|
||||
const groupedIds = new Set(toolSets.flatMap((s) => s.tool_ids));
|
||||
const ungrouped = tools.filter((t) => !groupedIds.has(t.id));
|
||||
// Effective count = DISTINCT TOOL NAMES bound to the model: individually-picked tools ∪ every
|
||||
// tool of a whole-set grant, then collapsed BY NAME. The backend binds one function per name
|
||||
// (a tool shared across sets — or two records that share a name — reaches the model once), so
|
||||
// the badge matches what the model actually receives rather than counting the same name twice.
|
||||
const grantedIds = new Set([
|
||||
...selectedTools,
|
||||
...toolSets.filter((s) => selectedToolSets.includes(s.id)).flatMap((s) => s.tool_ids),
|
||||
]);
|
||||
const grantedCount = new Set([...grantedIds].map((id) => toolById.get(id)?.name ?? id)).size;
|
||||
const toggleComponent = (id: string) =>
|
||||
set({ components: selectedComponents.includes(id) ? selectedComponents.filter((c) => c !== id) : [...selectedComponents, id] });
|
||||
|
||||
// Built-in knowledge access - compiles to agent-callable RAG / Q&A tools (see
|
||||
// tools/builtin.py build_knowledge_capability_tools). Each capability is independent.
|
||||
const knowledge = config.knowledge || {};
|
||||
const setKnowledge = (key: "rag" | "qa", patch: Cfg) =>
|
||||
set({ knowledge: { ...knowledge, [key]: { ...(knowledge[key] || {}), ...patch } } });
|
||||
|
||||
// Bind this node to a saved agent (from the Agents tab). When bound, the saved agent's
|
||||
// config drives the node LIVE - the backend resolves `agent_ref` at compile time, so
|
||||
// editing the agent once updates every node that uses it. A snapshot of its config is
|
||||
// also copied in so the canvas card + validation stay populated. "None" detaches and
|
||||
// keeps the current fields for inline editing.
|
||||
const boundId: string = config.agent_ref || "";
|
||||
const boundAgent = boundId ? agents.find((a) => a.id === boundId) : undefined;
|
||||
const bindTo = (id: string) => {
|
||||
if (!id) {
|
||||
const { agent_ref: _drop, ...rest } = config;
|
||||
onChange(rest);
|
||||
return;
|
||||
}
|
||||
const a = agents.find((x) => x.id === id);
|
||||
if (!a) return;
|
||||
const { name: _name, ...cfg } = a.config || {};
|
||||
onChange({ flavor: "agent", ...cfg, agent_ref: id });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="col" style={{ gap: 18 }}>
|
||||
{agents.length > 0 && (
|
||||
<Section label="Saved agent" hint={boundId ? undefined : "Bind this node to a saved agent - it mirrors that agent live, so editing the agent once updates every node that uses it."}>
|
||||
<select className="select" value={boundId} onChange={(e) => bindTo(e.target.value)}>
|
||||
<option value="">None - configure inline</option>
|
||||
{agents.map((a) => <option key={a.id} value={a.id}>{a.name}</option>)}
|
||||
</select>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{boundId && (
|
||||
<div className="card col gap2" style={{ padding: 12 }}>
|
||||
{boundAgent ? (
|
||||
<>
|
||||
<div className="row gap2" style={{ alignItems: "center", minWidth: 0 }}>
|
||||
<Tile icon={boundAgent.config?.flavor === "deep_agent" ? "n_deepagent" : "n_agent"} color="var(--accent)" size={26} />
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<div className="t-h3 truncate">{boundAgent.name}</div>
|
||||
<div className="t-caption fg-2 truncate">{boundAgent.config?.model || "-"} · {(boundAgent.config?.tools || []).length} tools · {(boundAgent.config?.middleware || []).length} middleware</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="field-help">This node mirrors the saved agent. Edit its model, instructions, tools, and middleware in the Agents tab - changes apply everywhere it's used.</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="field-help" style={{ color: "var(--warn)" }}>The saved agent this node referenced wasn’t found (it may have been deleted). Pick another above, or detach to configure inline.</div>
|
||||
)}
|
||||
<button className="btn btn-secondary btn-sm" style={{ alignSelf: "flex-start" }} onClick={() => bindTo("")}>Detach & edit inline</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!boundId && (
|
||||
<>
|
||||
<Section label="Flavor">
|
||||
<Segmented options={[{ value: "agent", label: "Agent" }, { value: "deep_agent", label: "Deep Agent" }]} value={flavor} onChange={(v) => set({ flavor: v })} />
|
||||
</Section>
|
||||
|
||||
<Section label="Model">
|
||||
<select className="select" value={config.model || ""} onChange={(e) => set({ model: e.target.value })}>
|
||||
<option value="">Select a model…</option>
|
||||
{MODELS.map((m) => <option key={m.id} value={m.id}>{m.name} · {m.provider}</option>)}
|
||||
{config.model && !MODELS.some((m) => m.id === config.model) && <option value={config.model}>{config.model}</option>}
|
||||
</select>
|
||||
</Section>
|
||||
|
||||
<Section label="Instructions" hint="The model reads this as its system prompt.">
|
||||
<textarea className="textarea" rows={4} value={config.system_prompt || ""} placeholder="You are a helpful support agent…" onChange={(e) => set({ system_prompt: e.target.value })} />
|
||||
</Section>
|
||||
|
||||
<CollapsibleSection label="Tools" badge={grantedCount ? `${grantedCount} selected` : undefined}
|
||||
hint="Tools are organized by tool set — open a set and tick the tools this agent should use, or grant the whole set. Manage sets on the Tools screen.">
|
||||
<div className="col gap1">
|
||||
{toolSets.map((s) => {
|
||||
const open = openSets.has(s.id);
|
||||
const whole = selectedToolSets.includes(s.id);
|
||||
const members = s.tool_ids.map((id) => toolById.get(id)).filter(Boolean) as Tool[];
|
||||
const sel = whole ? members.length : members.filter((t) => selectedTools.includes(t.id)).length;
|
||||
return (
|
||||
<div key={s.id} className="card" style={{ padding: 0, overflow: "hidden" }}>
|
||||
<div className="row spread" style={{ padding: "8px 10px", cursor: "pointer" }} onClick={() => toggleOpen(s.id)}>
|
||||
<div className="row gap2" style={{ alignItems: "center", minWidth: 0 }}>
|
||||
<Icon name={open ? "chevdown" : "chevright"} size={14} style={{ color: "var(--fg-2)", flex: "none" }} />
|
||||
<span className="mono-sm truncate">{s.name}</span>
|
||||
<span className="t-caption fg-2">{sel}/{members.length}</span>
|
||||
</div>
|
||||
<label className="row gap1" style={{ alignItems: "center", cursor: "pointer", flex: "none" }} onClick={(e) => e.stopPropagation()} title="Grant the whole set (auto-includes tools added to it later)">
|
||||
<input type="checkbox" checked={whole} onChange={() => toggleToolSet(s.id)} />
|
||||
<span className="t-caption fg-2">Whole set</span>
|
||||
</label>
|
||||
</div>
|
||||
{open && (
|
||||
<div className="col gap1" style={{ padding: "6px 12px 10px 30px", borderTop: "1px solid var(--line)" }}>
|
||||
{members.length === 0 && <div className="t-caption fg-2">No tools in this set yet.</div>}
|
||||
{members.map((t) => (
|
||||
<label key={t.id} className="row gap2" style={{ alignItems: "center", cursor: whole ? "default" : "pointer", opacity: whole ? 0.55 : 1 }}>
|
||||
<input type="checkbox" disabled={whole} checked={whole || selectedTools.includes(t.id)} onChange={() => toggleTool(t.id)} />
|
||||
<span className="mono-sm">{t.name}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{ungrouped.length > 0 && (
|
||||
<div className="card" style={{ padding: 0, overflow: "hidden" }}>
|
||||
<div className="row spread" style={{ padding: "8px 10px", cursor: "pointer" }} onClick={() => toggleOpen("__ungrouped")}>
|
||||
<div className="row gap2" style={{ alignItems: "center" }}>
|
||||
<Icon name={openSets.has("__ungrouped") ? "chevdown" : "chevright"} size={14} style={{ color: "var(--fg-2)", flex: "none" }} />
|
||||
<span className="mono-sm fg-2">Ungrouped</span>
|
||||
<span className="t-caption fg-2">{ungrouped.filter((t) => selectedTools.includes(t.id)).length}/{ungrouped.length}</span>
|
||||
</div>
|
||||
</div>
|
||||
{openSets.has("__ungrouped") && (
|
||||
<div className="col gap1" style={{ padding: "6px 12px 10px 30px", borderTop: "1px solid var(--line)" }}>
|
||||
{ungrouped.map((t) => (
|
||||
<label key={t.id} className="row gap2" style={{ alignItems: "center", cursor: "pointer" }}>
|
||||
<input type="checkbox" checked={selectedTools.includes(t.id)} onChange={() => toggleTool(t.id)} />
|
||||
<span className="mono-sm">{t.name}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{toolSets.length === 0 && ungrouped.length === 0 && (
|
||||
<div className="t-caption fg-2">No tools yet — create some on the Tools screen.</div>
|
||||
)}
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
|
||||
{components.length > 0 && (
|
||||
<CollapsibleSection label="Components" badge={selectedComponents.length ? `${selectedComponents.length} selected` : undefined}
|
||||
hint="UI widgets this agent can render in chat - it calls one like a tool and the client draws the saved template.">
|
||||
<div className="row gap2 wrap">
|
||||
{components.map((c) => {
|
||||
const on = selectedComponents.includes(c.id);
|
||||
return (
|
||||
<button key={c.id} className="chip" onClick={() => toggleComponent(c.id)}
|
||||
style={{ cursor: "pointer", borderColor: on ? "var(--accent)" : "var(--line)", color: on ? "var(--accent)" : "var(--fg-1)", background: on ? "var(--accent-glow)" : "var(--bg-3)" }}>
|
||||
{on && <Icon name="check" size={12} />}<span className="mono-sm">{c.name}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
)}
|
||||
|
||||
<CollapsibleSection label="Knowledge" badge={knowledge.rag?.enabled ? "enabled" : undefined}
|
||||
hint="Give this agent built-in RAG over your documents - it searches per sub-question, so one agent can answer multi-part questions.">
|
||||
<div className="col gap2">
|
||||
<label className="row gap2" style={{ cursor: "pointer" }}>
|
||||
<Toggle on={!!knowledge.rag?.enabled} onChange={(on) => setKnowledge("rag", { enabled: on })} />
|
||||
<span className="t-body-sm">Search knowledge base (RAG over documents)</span>
|
||||
</label>
|
||||
{knowledge.rag?.enabled && (
|
||||
<div className="col gap2" style={{ paddingLeft: 6 }}>
|
||||
<Field label="Folders" help="Limit document search to these folders (none selected = all).">
|
||||
<MultiSelectChips value={knowledge.rag?.folders || []} options={folders} onChange={(items) => setKnowledge("rag", { folders: items })} />
|
||||
</Field>
|
||||
<div className="row gap3 wrap">
|
||||
<Field label="Documents (top K)" help="Chunks returned per search.">
|
||||
<input className="input" type="number" min={1} max={20} step={1} style={{ width: 92 }}
|
||||
value={knowledge.rag?.top_k ?? 4} onChange={(e) => setKnowledge("rag", { top_k: Number(e.target.value) || 1 })} />
|
||||
</Field>
|
||||
<Field label="Min score" help="Drop chunks below this similarity (0–1).">
|
||||
<input className="input" type="number" min={0} max={1} step={0.02} style={{ width: 92 }}
|
||||
value={knowledge.rag?.min_score ?? 0.18} onChange={(e) => setKnowledge("rag", { min_score: Number(e.target.value) })} />
|
||||
</Field>
|
||||
</div>
|
||||
<label className="row gap2" style={{ cursor: "pointer" }}>
|
||||
<Toggle on={!!knowledge.rag?.hybrid} onChange={(on) => setKnowledge("rag", { hybrid: on })} />
|
||||
<span className="t-body-sm">Hybrid search (BM25 + vector)</span>
|
||||
</label>
|
||||
<div className="field-help">Blend lexical keyword (BM25) ranking with semantic vectors so exact terms - codes, names, SKUs - aren’t missed.</div>
|
||||
<label className="row gap2" style={{ cursor: "pointer" }}>
|
||||
<Toggle on={!!knowledge.rag?.rerank} onChange={(on) => setKnowledge("rag", { rerank: on })} />
|
||||
<span className="t-body-sm">Rerank (cross-encoder)</span>
|
||||
</label>
|
||||
<div className="field-help">Two-stage retrieval: a local cross-encoder re-scores the shortlist and keeps only the best matches. Big accuracy boost; adds some latency. Runs offline on CPU (no extra cost). Min score is ignored while this is on (the reranker score is on a different scale).</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
|
||||
<CollapsibleSection label="FAQs / Q&A" badge={knowledge.qa?.enabled ? "enabled" : undefined}
|
||||
hint="Let the agent look up curated FAQ / Q&A pairs and prefer those approved answers.">
|
||||
<div className="col gap2">
|
||||
<label className="row gap2" style={{ cursor: "pointer" }}>
|
||||
<Toggle on={!!knowledge.qa?.enabled} onChange={(on) => setKnowledge("qa", { enabled: on })} />
|
||||
<span className="t-body-sm">Look up FAQ / Q&A answers</span>
|
||||
</label>
|
||||
{knowledge.qa?.enabled && (
|
||||
<div className="col gap2" style={{ paddingLeft: 6 }}>
|
||||
<Field label="Kinds" help="Limit Q&A lookup to these kinds/categories (none selected = all).">
|
||||
<MultiSelectChips value={knowledge.qa?.kinds || []} options={kinds} onChange={(items) => setKnowledge("qa", { kinds: items })} />
|
||||
</Field>
|
||||
<div className="row gap3 wrap">
|
||||
<Field label="Pairs (top K)" help="Q&A pairs returned per lookup.">
|
||||
<input className="input" type="number" min={1} max={20} step={1} style={{ width: 92 }}
|
||||
value={knowledge.qa?.top_k ?? 3} onChange={(e) => setKnowledge("qa", { top_k: Number(e.target.value) || 1 })} />
|
||||
</Field>
|
||||
<Field label="Match threshold" help="Min similarity for a Q&A pair (0–1).">
|
||||
<input className="input" type="number" min={0} max={1} step={0.05} style={{ width: 92 }}
|
||||
value={knowledge.qa?.threshold ?? 0.3} onChange={(e) => setKnowledge("qa", { threshold: Number(e.target.value) })} />
|
||||
</Field>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
|
||||
{mcpServers.length > 0 && (
|
||||
<Section label="MCP servers" hint="Grant this agent the enabled tools from these MCP servers. Manage servers + per-tool toggles in Build → External MCP.">
|
||||
<div className="row gap2 wrap">
|
||||
{mcpServers.map((m) => {
|
||||
const on = (config.mcp_servers || []).includes(m.id);
|
||||
return (
|
||||
<button key={m.id} className="chip" onClick={() => {
|
||||
const cur: string[] = config.mcp_servers || [];
|
||||
set({ mcp_servers: on ? cur.filter((x) => x !== m.id) : [...cur, m.id] });
|
||||
}} style={{ cursor: "pointer", borderColor: on ? "var(--accent)" : "var(--line)", color: on ? "var(--accent)" : "var(--fg-1)", background: on ? "var(--accent-glow)" : "var(--bg-3)" }}>
|
||||
{on && <Icon name="check" size={12} />}<span className="mono-sm">{m.name}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
<CollapsibleSection label="Middleware stack" badge={mwCount ? `${mwCount} active` : undefined}
|
||||
hint="Order = execution order (the onion). Drag-free reorder with the arrows.">
|
||||
<MiddlewareStack stack={config.middleware || []} onChange={(mw) => set({ middleware: mw })} />
|
||||
</CollapsibleSection>
|
||||
|
||||
{flavor === "deep_agent" && <DeepAgentPanel config={config} set={set} />}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* Deep Agent harness config: planning + filesystem + sandbox, plus a JSON escape hatch for
|
||||
subagents. Replaces the old dead-end hint that pointed at a non-existent panel. */
|
||||
function DeepAgentPanel({ config, set }: { config: Cfg; set: (patch: Cfg) => void }) {
|
||||
const fs = config.filesystem || {};
|
||||
const sandbox = config.sandbox || {};
|
||||
return (
|
||||
<Section label="Deep Agent">
|
||||
<label className="row gap2" style={{ cursor: "pointer" }}>
|
||||
<Toggle on={config.planning !== false} onChange={(v) => set({ planning: v })} />
|
||||
<span className="t-body-sm">Planning (write_todos)</span>
|
||||
</label>
|
||||
|
||||
<CollapsibleSection label="Filesystem" hint="A virtual filesystem the agent can read/write across steps within a run.">
|
||||
<Field label="Backend">
|
||||
<select className="select" value={fs.backend || "memory"} onChange={(e) => set({ filesystem: { ...fs, backend: e.target.value } })}>
|
||||
<option value="memory">In-memory (per run)</option>
|
||||
<option value="none">None</option>
|
||||
</select>
|
||||
</Field>
|
||||
</CollapsibleSection>
|
||||
|
||||
<CollapsibleSection label="Sandbox" hint="Run code steps in an isolated sandbox. Requires the remote-sandbox feature to be enabled in Settings → Advanced.">
|
||||
<label className="row gap2" style={{ cursor: "pointer" }}>
|
||||
<Toggle on={!!sandbox.enabled} onChange={(v) => set({ sandbox: { ...sandbox, enabled: v } })} />
|
||||
<span className="t-body-sm">Enable sandbox for code execution</span>
|
||||
</label>
|
||||
</CollapsibleSection>
|
||||
|
||||
<CollapsibleSection label="Subagents (JSON)" hint='Named subagents the planner can delegate to. Each: { "name", "description", "prompt", "tools" }.'>
|
||||
<textarea
|
||||
className="textarea mono" rows={8} style={{ fontSize: 12 }}
|
||||
defaultValue={JSON.stringify(config.subagents ?? [], null, 2)}
|
||||
placeholder={'[\n { "name": "researcher", "description": "Digs up facts", "prompt": "…", "tools": [] }\n]'}
|
||||
onChange={(e) => { try { const v = e.target.value.trim(); set({ subagents: v ? JSON.parse(v) : undefined }); } catch { /* keep last valid */ } }}
|
||||
/>
|
||||
</CollapsibleSection>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
||||
function Section({ label, hint, children }: { label: string; hint?: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="col" style={{ gap: 8 }}>
|
||||
<div className="t-micro">{label}</div>
|
||||
{children}
|
||||
{hint && <div className="field-help">{hint}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* Collapsible section for the agent panel only (Tools / Knowledge / FAQs / Middleware),
|
||||
so a dense agent config can be folded down to just the parts being edited. Open/closed
|
||||
is transient UI state kept local - it never flows into the config via onChange. */
|
||||
export function CollapsibleSection({ label, hint, badge, defaultOpen = false, children }: { label: string; hint?: string; badge?: string; defaultOpen?: boolean; children: React.ReactNode }) {
|
||||
const [open, setOpen] = useState(defaultOpen);
|
||||
return (
|
||||
<div className="col" style={{ gap: 8 }}>
|
||||
<button type="button" onClick={() => setOpen((o) => !o)} aria-expanded={open}
|
||||
className="row gap2" style={{ alignItems: "center", background: "none", border: "none", padding: 0, margin: 0, cursor: "pointer", textAlign: "left", color: "inherit", width: "100%" }}>
|
||||
<Icon name="chevdown" size={12} style={{ color: "var(--fg-2)", flex: "none", transform: open ? "none" : "rotate(-90deg)", transition: "transform .12s" }} />
|
||||
<span className="t-micro">{label}</span>
|
||||
{badge && <span className="t-caption fg-2" style={{ fontWeight: 400 }}>· {badge}</span>}
|
||||
</button>
|
||||
{open && children}
|
||||
{open && hint && <div className="field-help">{hint}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const CAT_KEYS: Record<string, string> = {}; // (reserved)
|
||||
|
||||
function MiddlewareStack({ stack, onChange }: { stack: MW[]; onChange: (s: MW[]) => void }) {
|
||||
const [adding, setAdding] = useState(false);
|
||||
const [openIdx, setOpenIdx] = useState<number | null>(null);
|
||||
|
||||
const update = (i: number, patch: Partial<MW>) => onChange(stack.map((m, j) => (j === i ? { ...m, ...patch } : m)));
|
||||
const remove = (i: number) => onChange(stack.filter((_, j) => j !== i));
|
||||
const move = (i: number, d: number) => {
|
||||
const j = i + d;
|
||||
if (j < 0 || j >= stack.length) return;
|
||||
const next = [...stack];
|
||||
[next[i], next[j]] = [next[j], next[i]];
|
||||
onChange(next);
|
||||
};
|
||||
const add = (type: string) => { onChange([...stack, { type, enabled: true, config: {} }]); setAdding(false); };
|
||||
|
||||
return (
|
||||
<div className="col gap2">
|
||||
{stack.length === 0 && <div className="field-help">No middleware yet. Add summarization, limits, guardrails, HITL…</div>}
|
||||
{stack.map((m, i) => {
|
||||
const meta = MW_META[m.type] || { name: m.type, desc: "", color: "var(--fg-2)" };
|
||||
const on = m.enabled !== false;
|
||||
const open = openIdx === i;
|
||||
return (
|
||||
<div key={i} className="card" style={{ padding: 0, overflow: "hidden", borderLeft: `3px solid ${meta.color}`, opacity: on ? 1 : 0.6 }}>
|
||||
{/* Compact single-line header: name + type + (truncated) description, reorder, toggle, delete.
|
||||
Icon sizes are set via inline style because `.iconbtn svg` in globals.css pins svgs to
|
||||
17px and would otherwise override the Icon `size` prop. Full description shows on hover. */}
|
||||
<div className="row gap2" style={{ padding: "5px 9px" }}>
|
||||
<div className="col" style={{ gap: 0, flex: "none" }}>
|
||||
<button className="iconbtn" style={{ width: 16, height: 13, padding: 0 }} onClick={() => move(i, -1)} disabled={i === 0}><Icon name="chevup" style={{ width: 12, height: 12 }} /></button>
|
||||
<button className="iconbtn" style={{ width: 16, height: 13, padding: 0 }} onClick={() => move(i, 1)} disabled={i === stack.length - 1}><Icon name="chevdown" style={{ width: 12, height: 12 }} /></button>
|
||||
</div>
|
||||
{/* One truncating line (name + type + desc) clipped inside the grow box, so a long
|
||||
type key like `openai_moderation` can never spill under the toggle/trash. */}
|
||||
<div className="grow" style={{ minWidth: 0, overflow: "hidden", cursor: "pointer" }} onClick={() => setOpenIdx(open ? null : i)} title={`${meta.name} · ${m.type}${meta.desc ? ` · ${meta.desc}` : ""}`}>
|
||||
<div className="truncate">
|
||||
<span className="t-h3">{meta.name}</span>
|
||||
<span className="t-caption fg-2" style={{ marginLeft: 8 }}>{m.type}</span>
|
||||
{meta.desc && <span className="t-caption fg-2" style={{ marginLeft: 8 }}>{meta.desc}</span>}
|
||||
</div>
|
||||
</div>
|
||||
<Toggle on={on} onChange={(v) => update(i, { enabled: v })} />
|
||||
<button className="iconbtn" style={{ width: 24, height: 24, flex: "none" }} onClick={() => remove(i)}><Icon name="trash" style={{ width: 15, height: 15 }} /></button>
|
||||
</div>
|
||||
{open && (
|
||||
<div style={{ padding: "0 11px 11px" }}>
|
||||
{MW_FIELDS[m.type] ? (
|
||||
<>
|
||||
<FieldsForm
|
||||
specs={MW_FIELDS[m.type]}
|
||||
config={m.config || {}}
|
||||
onPatch={(patch) => update(i, { config: { ...(m.config || {}), ...patch } })}
|
||||
/>
|
||||
<details style={{ marginTop: 8 }}>
|
||||
<summary className="t-caption fg-2" style={{ cursor: "pointer" }}>Advanced (JSON)</summary>
|
||||
<textarea className="textarea mono" rows={4} style={{ fontSize: 12, marginTop: 6 }} defaultValue={JSON.stringify(m.config || {}, null, 2)}
|
||||
onChange={(e) => { try { update(i, { config: JSON.parse(e.target.value || "{}") }); } catch { /* keep last valid */ } }} />
|
||||
</details>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="field-label">Config (JSON)</div>
|
||||
<textarea className="textarea mono" rows={4} style={{ fontSize: 12 }} defaultValue={JSON.stringify(m.config || {}, null, 2)}
|
||||
onChange={(e) => { try { update(i, { config: JSON.parse(e.target.value || "{}") }); } catch { /* keep last valid */ } }} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<button className="btn btn-secondary btn-sm" style={{ alignSelf: "flex-start" }} onClick={() => setAdding(true)}>
|
||||
<Icon name="plus" size={14} />Add middleware
|
||||
</button>
|
||||
|
||||
<Modal open={adding} onClose={() => setAdding(false)} title="Add middleware" width={560}>
|
||||
<div className="col gap4">
|
||||
{MIDDLEWARE_CATALOG.map((cat) => (
|
||||
<div key={cat.cat}>
|
||||
<div className="t-micro" style={{ marginBottom: 8, color: cat.color }}>{cat.cat}</div>
|
||||
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 8 }}>
|
||||
{cat.items.map((it) => (
|
||||
<button key={it.type} className="card card-hover" style={{ padding: 10, textAlign: "left" }} onClick={() => add(it.type)}>
|
||||
<div className="t-h3">{it.name}</div>
|
||||
<div className="t-caption fg-2" style={{ marginTop: 2 }}>{it.desc}</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,274 @@
|
||||
"use client";
|
||||
/* Declarative config forms - FieldSpec lists render as friendly widgets (toggle/number/
|
||||
select/model/csv/textarea) instead of raw JSON. Shared by the canvas node inspector
|
||||
(workflows.tsx) and the middleware stack (AgentConfig.tsx). A form merges only its own
|
||||
keys into the config, so unknown/advanced keys are preserved. */
|
||||
import { Field, Toggle } from "../primitives";
|
||||
import { useModels } from "@/lib/models";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
export type FieldSpec = {
|
||||
key: string;
|
||||
label: string;
|
||||
widget: "toggle" | "number" | "text" | "textarea" | "select" | "model" | "csv" | "multiselect";
|
||||
help?: string;
|
||||
placeholder?: string;
|
||||
/** Override the "model" widget's empty-value label (default "Project default"). Use when an
|
||||
* empty model doesn't resolve to the project default - e.g. the classifier falls back to the
|
||||
* cheapest model, so showing "Project default" there would misrepresent what actually runs. */
|
||||
emptyLabel?: string;
|
||||
options?: { value: string; label: string }[];
|
||||
/** Pull options at render time from a live source (e.g. the project's KB folders or
|
||||
* Q&A kinds) passed via FieldsForm's `dynamic` prop. Keyed by source name. */
|
||||
dynamicOptions?: "kb_folders" | "qa_kinds";
|
||||
/** First option for a dynamic select (e.g. "any"/"all"). */
|
||||
emptyOption?: { value: string; label: string };
|
||||
min?: number;
|
||||
max?: number;
|
||||
step?: number;
|
||||
/** Convert the stored config value to the widget's display value. */
|
||||
format?: (v: any) => any;
|
||||
/** Convert the widget's raw value to the stored config value. */
|
||||
parse?: (raw: any) => any;
|
||||
};
|
||||
|
||||
/** Compact multi-select rendered as toggle chips (used for folder selection). Stores an
|
||||
* array; empty array means "all". When there's nothing to choose yet, shows a plain
|
||||
* message instead of an empty input (any already-stored values stay as removable chips). */
|
||||
export function MultiSelectChips({ value, options, placeholder, onChange }: { value: any; options: string[]; placeholder?: string; onChange: (items: string[]) => void }) {
|
||||
const selected: string[] = Array.isArray(value) ? value.map(String) : (typeof value === "string" && value ? value.split(",").map((s) => s.trim()).filter(Boolean) : []);
|
||||
const toggle = (opt: string) => {
|
||||
const next = selected.includes(opt) ? selected.filter((s) => s !== opt) : [...selected, opt];
|
||||
onChange(next);
|
||||
};
|
||||
// No live options: don't render an input - just a hint. (Keep any stored values visible
|
||||
// as removable chips so they aren't silently lost.)
|
||||
const chipBtn = (opt: string, on: boolean) => (
|
||||
<button key={opt} type="button" onClick={() => toggle(opt)} className="chip"
|
||||
style={{ cursor: "pointer", background: on ? "var(--accent)" : "var(--bg-3)", color: on ? "var(--fg-on-accent)" : "var(--fg-1)", borderColor: on ? "var(--accent)" : "var(--line)" }}>
|
||||
{on ? "✓ " : ""}{opt}
|
||||
</button>
|
||||
);
|
||||
if (!options.length) {
|
||||
if (!selected.length) {
|
||||
return <div className="field-help" style={{ marginTop: 0 }}>{placeholder || "Nothing to choose from yet - manage these on the Knowledge screen."}</div>;
|
||||
}
|
||||
return <div className="row gap2 wrap">{selected.map((opt) => chipBtn(opt, true))}</div>;
|
||||
}
|
||||
return (
|
||||
<div className="row gap2 wrap">
|
||||
{options.map((opt) => {
|
||||
const on = selected.includes(opt);
|
||||
return (
|
||||
<button
|
||||
key={opt}
|
||||
type="button"
|
||||
onClick={() => toggle(opt)}
|
||||
className="chip"
|
||||
style={{ cursor: "pointer", background: on ? "var(--accent)" : "var(--bg-3)", color: on ? "var(--fg-on-accent)" : "var(--fg-1)", borderColor: on ? "var(--accent)" : "var(--line)" }}
|
||||
>
|
||||
{on ? "✓ " : ""}{opt}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ModelSelect({ value, onChange, emptyLabel = "Project default" }: { value: string; onChange: (v: string) => void; emptyLabel?: string }) {
|
||||
const MODELS = useModels();
|
||||
return (
|
||||
<select className="select" value={value || ""} onChange={(e) => onChange(e.target.value)}>
|
||||
<option value="">{emptyLabel}</option>
|
||||
{MODELS.map((m) => <option key={m.id} value={m.id}>{m.name} · {m.provider}</option>)}
|
||||
{value && !MODELS.some((m) => m.id === value) && <option value={value}>{value}</option>}
|
||||
</select>
|
||||
);
|
||||
}
|
||||
|
||||
function CsvInput({ value, placeholder, onChange }: { value: any; placeholder?: string; onChange: (items: string[]) => void }) {
|
||||
const display = Array.isArray(value) ? value.join(", ") : String(value ?? "");
|
||||
const [draft, setDraft] = useState(display);
|
||||
|
||||
useEffect(() => {
|
||||
setDraft(display);
|
||||
}, [display]);
|
||||
|
||||
const commit = () => {
|
||||
onChange(draft.split(",").map((s) => s.trim()).filter(Boolean));
|
||||
};
|
||||
|
||||
return (
|
||||
<input
|
||||
className="input mono"
|
||||
value={draft}
|
||||
placeholder={placeholder}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
onBlur={commit}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") e.currentTarget.blur();
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function FieldsForm({ specs, config, onPatch, dynamic }: { specs: FieldSpec[]; config: Record<string, any>; onPatch: (patch: Record<string, any>) => void; dynamic?: Record<string, string[]> }) {
|
||||
const read = (f: FieldSpec) => {
|
||||
const v = config[f.key];
|
||||
return f.format ? f.format(v) : v;
|
||||
};
|
||||
const write = (f: FieldSpec, raw: any) => {
|
||||
onPatch({ [f.key]: f.parse ? f.parse(raw) : raw });
|
||||
};
|
||||
const dynOptions = (f: FieldSpec): string[] => (f.dynamicOptions && dynamic?.[f.dynamicOptions]) || [];
|
||||
|
||||
return (
|
||||
<div className="col gap3">
|
||||
{specs.map((f) => {
|
||||
const v = read(f);
|
||||
if (f.widget === "toggle") {
|
||||
return (
|
||||
<div key={f.key} className="col gap1">
|
||||
<label className="row gap2" style={{ cursor: "pointer" }}>
|
||||
<Toggle on={!!v} onChange={(on) => write(f, on)} />
|
||||
<span className="t-body-sm">{f.label}</span>
|
||||
</label>
|
||||
{f.help && <div className="field-help">{f.help}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Field key={f.key} label={f.label} help={f.help}>
|
||||
{f.widget === "number" ? (
|
||||
<input className="input mono" type="number" min={f.min} max={f.max} step={f.step ?? 1}
|
||||
value={v ?? ""} placeholder={f.placeholder}
|
||||
onChange={(e) => write(f, e.target.value === "" ? undefined : Number(e.target.value))} />
|
||||
) : f.widget === "select" ? (
|
||||
(() => {
|
||||
// Options come from the static list OR a live source (e.g. Q&A kinds).
|
||||
const opts = f.dynamicOptions
|
||||
? [...(f.emptyOption ? [f.emptyOption] : []), ...dynOptions(f).map((o) => ({ value: o, label: o }))]
|
||||
: (f.options || []);
|
||||
// Keep a current value that isn't in the live list selectable (don't lose it).
|
||||
const hasV = v == null || v === "" || opts.some((o) => o.value === v);
|
||||
return (
|
||||
<select className="select" value={v ?? (opts[0]?.value || "")} onChange={(e) => write(f, e.target.value)}>
|
||||
{opts.map((o) => <option key={o.value} value={o.value}>{o.label}</option>)}
|
||||
{!hasV && <option value={v}>{v}</option>}
|
||||
</select>
|
||||
);
|
||||
})()
|
||||
) : f.widget === "multiselect" ? (
|
||||
<MultiSelectChips value={v} options={dynOptions(f)} placeholder={f.placeholder} onChange={(items) => write(f, items)} />
|
||||
) : f.widget === "model" ? (
|
||||
<ModelSelect value={v || ""} emptyLabel={f.emptyLabel} onChange={(val) => write(f, val || undefined)} />
|
||||
) : f.widget === "csv" ? (
|
||||
<CsvInput value={v} placeholder={f.placeholder} onChange={(items) => write(f, items)} />
|
||||
) : f.widget === "textarea" ? (
|
||||
<textarea className="textarea mono" rows={3} value={v ?? ""} placeholder={f.placeholder} onChange={(e) => write(f, e.target.value)} />
|
||||
) : (
|
||||
<input className="input mono" value={v ?? ""} placeholder={f.placeholder} onChange={(e) => write(f, e.target.value || undefined)} />
|
||||
)}
|
||||
</Field>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---- Middleware config field specs (mirror forge/engine/middleware_compiler.py) ---- */
|
||||
|
||||
const ctxSize = {
|
||||
// ContextSize is ["messages", N] in config; surface just N in the UI.
|
||||
format: (v: any) => (Array.isArray(v) ? v[1] : v),
|
||||
parse: (n: any) => (n == null ? undefined : ["messages", n]),
|
||||
};
|
||||
|
||||
export const MW_FIELDS: Record<string, FieldSpec[]> = {
|
||||
summarization: [
|
||||
{ key: "model", label: "Summarizer model", widget: "model", help: "Model used to write the summary." },
|
||||
{ key: "trigger", label: "Summarize after N messages", widget: "number", min: 4, step: 1, ...ctxSize },
|
||||
{ key: "keep", label: "Keep last N messages", widget: "number", min: 1, step: 1, ...ctxSize },
|
||||
{ key: "summary_prompt", label: "Summary prompt (optional)", widget: "textarea" },
|
||||
],
|
||||
human_in_the_loop: [
|
||||
{
|
||||
key: "interrupt_on", label: "Tools requiring approval", widget: "csv", placeholder: "send_email, delete_record",
|
||||
help: "Comma-separated tool names - the run pauses for approval before these execute.",
|
||||
format: (v: any) => (v && typeof v === "object" ? Object.keys(v) : []),
|
||||
parse: (arr: string[]) => Object.fromEntries((arr || []).map((n) => [n, true])),
|
||||
},
|
||||
],
|
||||
model_call_limit: [
|
||||
{ key: "run_limit", label: "Max model calls per run", widget: "number", min: 1 },
|
||||
{ key: "thread_limit", label: "Max model calls per thread", widget: "number", min: 1 },
|
||||
{ key: "exit_behavior", label: "When the limit hits", widget: "select", options: [{ value: "end", label: "End gracefully" }, { value: "error", label: "Raise an error" }] },
|
||||
],
|
||||
tool_call_limit: [
|
||||
{ key: "tool_name", label: "Tool (empty = all tools)", widget: "text", placeholder: "web_fetch" },
|
||||
{ key: "run_limit", label: "Max calls per run", widget: "number", min: 1 },
|
||||
{ key: "thread_limit", label: "Max calls per thread", widget: "number", min: 1 },
|
||||
{ key: "exit_behavior", label: "When the limit hits", widget: "select", options: [{ value: "continue", label: "Continue without the tool" }, { value: "end", label: "End gracefully" }, { value: "error", label: "Raise an error" }] },
|
||||
],
|
||||
model_fallback: [
|
||||
{ key: "models", label: "Fallback models (in order)", widget: "csv", placeholder: "openai:gpt-4o-mini, anthropic:claude-sonnet-4-6", help: "Tried left to right when the primary model errors." },
|
||||
],
|
||||
pii: [
|
||||
{ key: "pii_type", label: "PII type", widget: "select", options: ["email", "credit_card", "ip", "mac_address", "url"].map((v) => ({ value: v, label: v })) },
|
||||
{ key: "strategy", label: "Strategy", widget: "select", options: ["redact", "mask", "hash", "block"].map((v) => ({ value: v, label: v })) },
|
||||
{ key: "apply_to_input", label: "Apply to user input", widget: "toggle" },
|
||||
{ key: "apply_to_output", label: "Apply to model output", widget: "toggle" },
|
||||
{ key: "apply_to_tool_results", label: "Apply to tool results", widget: "toggle" },
|
||||
],
|
||||
todo: [
|
||||
{ key: "system_prompt", label: "Planning prompt (optional)", widget: "textarea" },
|
||||
{ key: "tool_description", label: "write_todos tool description (optional)", widget: "textarea" },
|
||||
],
|
||||
llm_tool_selector: [
|
||||
{ key: "model", label: "Selector model", widget: "model" },
|
||||
{ key: "max_tools", label: "Max tools exposed per call", widget: "number", min: 1 },
|
||||
{ key: "always_include", label: "Always include tools", widget: "csv", placeholder: "lookup_kb" },
|
||||
],
|
||||
tool_retry: [
|
||||
{ key: "max_retries", label: "Max retries", widget: "number", min: 1 },
|
||||
{ key: "tools", label: "Only these tools (empty = all)", widget: "csv" },
|
||||
{ key: "backoff_factor", label: "Backoff factor", widget: "number", step: 0.1 },
|
||||
{ key: "initial_delay", label: "Initial delay (s)", widget: "number", step: 0.1 },
|
||||
{ key: "max_delay", label: "Max delay (s)", widget: "number", step: 0.5 },
|
||||
{ key: "jitter", label: "Add jitter", widget: "toggle" },
|
||||
],
|
||||
model_retry: [
|
||||
{ key: "max_retries", label: "Max retries", widget: "number", min: 1 },
|
||||
{ key: "backoff_factor", label: "Backoff factor", widget: "number", step: 0.1 },
|
||||
{ key: "initial_delay", label: "Initial delay (s)", widget: "number", step: 0.1 },
|
||||
{ key: "max_delay", label: "Max delay (s)", widget: "number", step: 0.5 },
|
||||
{ key: "jitter", label: "Add jitter", widget: "toggle" },
|
||||
],
|
||||
tool_emulator: [
|
||||
{ key: "model", label: "Emulator model", widget: "model" },
|
||||
{ key: "tools", label: "Tools to emulate (empty = all)", widget: "csv", help: "Emulated tools return LLM-invented results - for testing flows without live APIs." },
|
||||
],
|
||||
context_editing: [
|
||||
{
|
||||
key: "edits", label: "Clear old tool results after N tokens", widget: "number", min: 1000, step: 1000,
|
||||
help: "When the context exceeds this, older tool outputs are cleared.",
|
||||
format: (v: any) => (Array.isArray(v) && v[0] ? v[0].trigger : undefined),
|
||||
parse: (n: any) => (n == null ? undefined : [{ trigger: n }]),
|
||||
},
|
||||
],
|
||||
anthropic_prompt_caching: [
|
||||
{ key: "ttl", label: "Cache TTL", widget: "select", options: [{ value: "5m", label: "5 minutes" }, { value: "1h", label: "1 hour" }] },
|
||||
],
|
||||
openai_moderation: [
|
||||
{ key: "apply_to_input", label: "Moderate user input", widget: "toggle" },
|
||||
{ key: "apply_to_output", label: "Moderate model output", widget: "toggle" },
|
||||
],
|
||||
guardrail_regex: [
|
||||
{ key: "patterns", label: "Blocked patterns (regex)", widget: "csv", placeholder: "(?i)password, secret_key" },
|
||||
{ key: "on_match", label: "On match", widget: "select", options: [{ value: "block", label: "Block the reply" }, { value: "flag", label: "Flag only" }] },
|
||||
],
|
||||
tenant_budget: [
|
||||
{ key: "max_tokens_per_run", label: "Max tokens per run", widget: "number", min: 100, step: 100 },
|
||||
{ key: "on_exceed", label: "When exceeded", widget: "select", options: [{ value: "end", label: "End gracefully" }, { value: "error", label: "Raise an error" }] },
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,108 @@
|
||||
"use client";
|
||||
/* Self-rendered canvas connections.
|
||||
|
||||
React Flow v12's own edge renderer doesn't draw loaded edges reliably in this dev
|
||||
setup (handle-bounds race), so we draw the wires ourselves inside the ViewportPortal -
|
||||
which lives in flow coordinate space, so it pans/zooms with the canvas. Native edges
|
||||
are hidden via CSS to avoid double-draw. New connections (onConnect) land in the same
|
||||
`edges` array, so hand-drawn wires show here too. */
|
||||
import { ViewportPortal } from "@xyflow/react";
|
||||
import { useMemo } from "react";
|
||||
import { Icon } from "../icons";
|
||||
import { type FlowEdge, type FlowNode } from "@/lib/graph";
|
||||
|
||||
const NODE_HEIGHT: Record<string, number> = {
|
||||
start: 36, end: 36,
|
||||
agent: 90, deep_agent: 90, llm: 80, human_input: 80, classifier: 80,
|
||||
transform: 64, tool_call: 64, webhook_out: 64, emit_event: 64, retrieval: 64,
|
||||
};
|
||||
const NODE_WIDTH = 230;
|
||||
// Router case-row geometry - must mirror ForgeNode's router body (header + expr line + rows).
|
||||
const ROUTER_GEOM = { header: 37, exprLine: 22, rowH: 24, pad: 6 };
|
||||
|
||||
function routerHeight(cfg: any): number {
|
||||
const rows = Object.keys(cfg?.cases || {}).length + 1; // + Else
|
||||
return ROUTER_GEOM.header + ROUTER_GEOM.exprLine + rows * ROUTER_GEOM.rowH + ROUTER_GEOM.pad;
|
||||
}
|
||||
|
||||
/** Y offset (within the router node) of the case row an edge leaves from. */
|
||||
function routerCaseY(cfg: any, edge: FlowEdge): number {
|
||||
const keys = [...Object.keys(cfg?.cases || {}), "__default__"];
|
||||
let key: string | undefined = edge.sourceHandle?.startsWith("case:") ? edge.sourceHandle.slice(5) : undefined;
|
||||
if (!key || !keys.includes(key)) {
|
||||
// Infer from routing config: which case (or default) points at this edge's target?
|
||||
key = Object.entries(cfg?.cases || {}).find(([, tgt]) => tgt === edge.target)?.[0]
|
||||
?? (cfg?.default === edge.target ? "__default__" : undefined);
|
||||
}
|
||||
const idx = key ? keys.indexOf(key) : 0;
|
||||
return ROUTER_GEOM.header + ROUTER_GEOM.exprLine + (idx < 0 ? 0 : idx) * ROUTER_GEOM.rowH + ROUTER_GEOM.rowH / 2;
|
||||
}
|
||||
|
||||
export function EdgeOverlay({ nodes, edges, onRemove }: { nodes: FlowNode[]; edges: FlowEdge[]; onRemove: (edge: FlowEdge) => void }) {
|
||||
const byId = useMemo(() => Object.fromEntries(nodes.map((n) => [n.id, n])), [nodes]);
|
||||
const h = (n: any) => (n?.data?.nodeType === "router" ? routerHeight(n?.data?.config) : NODE_HEIGHT[n?.data?.nodeType] ?? 36);
|
||||
const edgeGeometry = (e: FlowEdge) => {
|
||||
const s = byId[e.source]; const t = byId[e.target];
|
||||
if (!s || !t) return null;
|
||||
const tx = t.position.x, ty = t.position.y + h(t) / 2;
|
||||
// Sub-agent edge: an org-chart branch from the deep_agent's BOTTOM handle down into the
|
||||
// specialist's TOP-center (agent-as-tool), drawn dashed/accent and distinct from flow edges.
|
||||
if (e.sourceHandle === "subagents") {
|
||||
const sx = s.position.x + NODE_WIDTH / 2;
|
||||
const sy = s.position.y + h(s);
|
||||
const ttx = t.position.x + NODE_WIDTH / 2;
|
||||
const tty = t.position.y;
|
||||
const dy = Math.max(28, Math.abs(tty - sy) / 2);
|
||||
const d = `M ${sx},${sy} C ${sx},${sy + dy} ${ttx},${tty - dy} ${ttx},${tty}`;
|
||||
return { d, mx: (sx + ttx) / 2, my: (sy + tty) / 2, sub: true };
|
||||
}
|
||||
const fromRouter = s.data?.nodeType === "router";
|
||||
const sx = s.position.x + NODE_WIDTH;
|
||||
const sy = s.position.y + (fromRouter ? routerCaseY(s.data?.config, e) : h(s) / 2);
|
||||
const dx = Math.max(40, Math.abs(tx - sx) / 2);
|
||||
const d = `M ${sx},${sy} C ${sx + dx},${sy} ${tx - dx},${ty} ${tx},${ty}`;
|
||||
return { d, mx: (sx + tx) / 2, my: (sy + ty) / 2, sub: false };
|
||||
};
|
||||
return (
|
||||
<ViewportPortal>
|
||||
<svg style={{ position: "absolute", left: 0, top: 0, width: 1, height: 1, overflow: "visible", pointerEvents: "none", zIndex: -1 }}>
|
||||
<defs>
|
||||
<marker id="forge-arrow" markerWidth="9" markerHeight="9" refX="7" refY="3" orient="auto">
|
||||
<path d="M0,0 L7,3 L0,6 Z" fill="var(--line-strong)" />
|
||||
</marker>
|
||||
<marker id="forge-arrow-sub" markerWidth="9" markerHeight="9" refX="7" refY="3" orient="auto">
|
||||
<path d="M0,0 L7,3 L0,6 Z" fill="var(--accent)" />
|
||||
</marker>
|
||||
</defs>
|
||||
{edges.map((e) => {
|
||||
const geo = edgeGeometry(e);
|
||||
if (!geo) return null;
|
||||
return <path key={e.id} d={geo.d} fill="none" stroke={geo.sub ? "var(--accent)" : "var(--line-strong)"} strokeWidth={2} strokeDasharray={geo.sub ? "6 5" : undefined} markerEnd={geo.sub ? "url(#forge-arrow-sub)" : "url(#forge-arrow)"} />;
|
||||
})}
|
||||
</svg>
|
||||
{edges.map((e) => {
|
||||
const geo = edgeGeometry(e);
|
||||
if (!geo) return null;
|
||||
return (
|
||||
<button
|
||||
key={`${e.id}-detach`}
|
||||
title="Detach edge"
|
||||
aria-label={`Detach edge from ${e.source} to ${e.target}`}
|
||||
onPointerDown={(ev) => ev.stopPropagation()}
|
||||
onClick={(ev) => { ev.stopPropagation(); onRemove(e); }}
|
||||
style={{
|
||||
position: "absolute", left: geo.mx - 9, top: geo.my - 9,
|
||||
width: 18, height: 18, borderRadius: 999,
|
||||
border: "1px solid var(--line-strong)", background: "var(--bg-1)",
|
||||
color: "var(--fg-2)", boxShadow: "var(--sh-1)",
|
||||
display: "flex", alignItems: "center", justifyContent: "center",
|
||||
padding: 0, cursor: "pointer", pointerEvents: "auto", zIndex: 8,
|
||||
}}
|
||||
>
|
||||
<Icon name="x" size={10} />
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</ViewportPortal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
"use client";
|
||||
/* Forge canvas node - a chip on a circuit board, with IOType-colored typed handles.
|
||||
Ports come from the backend Node Type Registry (/v1/node-types) via context. */
|
||||
import { Handle, Position, useUpdateNodeInternals, type NodeProps } from "@xyflow/react";
|
||||
import { createContext, useContext, useEffect, useState } from "react";
|
||||
import { Icon } from "../icons";
|
||||
import { NODE_META, IO_COLOR, fmtUSD } from "@/lib/data";
|
||||
import type { NodeType } from "@/lib/api";
|
||||
|
||||
export const NodeTypesContext = createContext<Record<string, NodeType>>({});
|
||||
|
||||
const CAT_COLOR: Record<string, string> = {
|
||||
control: "var(--io-control)", agent: "var(--accent)", json: "var(--io-json)",
|
||||
vector: "var(--io-vector)", human: "var(--warn)", signal: "var(--signal)",
|
||||
};
|
||||
|
||||
function summarize(type: string, c: Record<string, any>): string[] {
|
||||
switch (type) {
|
||||
case "agent":
|
||||
case "deep_agent":
|
||||
// Show only the model on the card - tools / middleware / components / knowledge are
|
||||
// all partial here; the right-hand inspector shows the full config.
|
||||
return [String(c.model || "-")];
|
||||
case "router":
|
||||
return [`expr · ${c.expression || "-"}`, Object.keys(c.cases || {}).concat(c.default ? ["default"] : []).join(" · ")];
|
||||
case "llm":
|
||||
return [String(c.model || "-"), "single call"];
|
||||
case "classifier":
|
||||
return [`→ ${c.output_key || "intent"}`, (c.labels || []).slice(0, 4).join(" · ") || "no labels"];
|
||||
case "transform":
|
||||
return [`${c.engine || "jmespath"} → ${c.output_key || "data"}`];
|
||||
case "tool_call":
|
||||
return [String(c.tool_id || "-")];
|
||||
case "retrieval": {
|
||||
const lines: string[] = [];
|
||||
if (c.include_docs !== false) lines.push(`docs top_k ${c.top_k ?? 5}${c.hybrid ? " · hybrid" : ""}${c.rerank ? " · rerank" : ""}`);
|
||||
if (c.include_qa) lines.push(`Q&A top_k ${c.qa_top_k ?? 3}`);
|
||||
return lines.length ? lines : ["no sources"];
|
||||
}
|
||||
case "human_input":
|
||||
return [(c.prompt || "").slice(0, 34), (c.allowed_decisions || ["approve", "reject"]).join(" · ")];
|
||||
case "webhook_out":
|
||||
return [`${c.method || "POST"} ${String(c.url || "").slice(0, 26)}`];
|
||||
case "emit_event":
|
||||
return [`channel · ${c.channel || ""}`];
|
||||
default:
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function debugPreview(value: any): string {
|
||||
if (value == null || value === "") return "";
|
||||
if (typeof value === "string") return value;
|
||||
try { return JSON.stringify(value, null, 2); } catch { return String(value); }
|
||||
}
|
||||
|
||||
function HandleStack({ ports, dir }: { ports: { id: string; io_type: string }[]; dir: "in" | "out" }) {
|
||||
const n = ports.length;
|
||||
return (
|
||||
<>
|
||||
{ports.map((p, i) => (
|
||||
<Handle
|
||||
key={p.id}
|
||||
// With a single port per side (every Forge node today), omit the id so this is
|
||||
// React Flow's *default* handle - then loaded edges (which carry no handle id)
|
||||
// always attach, with no id-matching that can silently drop the edge.
|
||||
id={n > 1 ? p.id : undefined}
|
||||
type={dir === "in" ? "target" : "source"}
|
||||
position={dir === "in" ? Position.Left : Position.Right}
|
||||
style={{
|
||||
top: `${((i + 1) / (n + 1)) * 100}%`,
|
||||
width: 11, height: 11, border: "2px solid var(--bg-1)",
|
||||
background: IO_COLOR[p.io_type] || "var(--io-any)",
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function ForgeNode({ id, data, selected }: NodeProps) {
|
||||
const registry = useContext(NodeTypesContext);
|
||||
const type = (data as any).nodeType as string;
|
||||
const isSubagent = !!(data as any).isSubagent; // wired as a deep_agent sub-agent (folded, not a flow node)
|
||||
const config = (data as any).config || {};
|
||||
const status = (data as any).status as string | undefined;
|
||||
const debug = (data as any).debug || {};
|
||||
const [showDebug, setShowDebug] = useState(false);
|
||||
const meta = NODE_META[type] || { icon: "n_agent", color: "var(--fg-2)", label: type };
|
||||
const spec = registry[type];
|
||||
const inPorts = spec?.input_ports || [];
|
||||
const outPorts = spec?.output_ports || [];
|
||||
const lines = summarize(type, config);
|
||||
const title = config.name || meta.label || type;
|
||||
const hasDebug =
|
||||
debug.output !== undefined ||
|
||||
Number(debug.cost_usd || 0) > 0 ||
|
||||
Number(debug.tokens || 0) > 0;
|
||||
const outputPreview = debugPreview(debug.output);
|
||||
|
||||
// The port registry loads after the node first mounts, so the handles appear later.
|
||||
// Tell React Flow to re-measure this node's handle bounds when its port count changes -
|
||||
// without this, edges that connect to those handles never get drawn. Router case rows
|
||||
// each carry a handle, so case-count changes also need a re-measure.
|
||||
const caseKeys = type === "router" ? Object.keys(config.cases || {}).join("|") : "";
|
||||
const updateNodeInternals = useUpdateNodeInternals();
|
||||
const portKey = `${inPorts.length}:${outPorts.length}:${caseKeys}:${type === "deep_agent" ? "sub" : ""}`;
|
||||
useEffect(() => { updateNodeInternals(id); }, [id, portKey, updateNodeInternals]);
|
||||
|
||||
const border =
|
||||
status === "running" ? "0 0 0 2px var(--ok), 0 0 0 6px var(--ok-bg)" :
|
||||
status === "done" ? "0 0 0 1px var(--ok)" :
|
||||
status === "error" ? "0 0 0 1px var(--err)" :
|
||||
selected ? "var(--glow-accent)" : "0 0 0 1px var(--line)";
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
width: 230, background: "var(--bg-2)", borderRadius: "var(--r-lg)",
|
||||
boxShadow: `${border}, var(--node-shadow)`, position: "relative",
|
||||
}}
|
||||
>
|
||||
{/* A folded sub-agent isn't a flow node, so its in/out flow handles have no function -
|
||||
hide them for a clean "leaf" look (the sub-agent edge attaches to the node's top). */}
|
||||
{!isSubagent && <HandleStack ports={inPorts} dir="in" />}
|
||||
{/* header */}
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 8, padding: "8px 10px", borderBottom: "1px solid var(--line)", background: "var(--bg-3)", borderRadius: "var(--r-lg) var(--r-lg) 0 0", borderLeft: `3px solid ${meta.color}` }}>
|
||||
<Icon name={meta.icon} size={16} style={{ color: meta.color, flexShrink: 0 }} />
|
||||
<span className="t-h3 truncate" style={{ flex: 1, fontFamily: "var(--font-display)" }}>{title}</span>
|
||||
<span className="typechip">{type}</span>
|
||||
</div>
|
||||
{/* body */}
|
||||
{type === "router" ? (
|
||||
// OpenAI-style if/else: one labeled output row + connector per case, plus Else.
|
||||
// Dragging from a row's handle wires that case (see onConnect in workflows.tsx).
|
||||
// Geometry must stay in sync with ROUTER_GEOM in workflows.tsx (EdgeOverlay).
|
||||
<div className="col" style={{ paddingBottom: 6 }}>
|
||||
<div className="t-caption fg-2 truncate" style={{ padding: "4px 11px", height: 22 }}>expr · {config.expression || "-"}</div>
|
||||
{[...Object.keys(config.cases || {}), "__default__"].map((k) => (
|
||||
<div key={k} style={{ position: "relative", height: 24, display: "flex", alignItems: "center", justifyContent: "flex-end", padding: "0 12px", borderTop: "1px solid var(--line)", background: "var(--bg-3)" }}>
|
||||
<span className="mono-sm truncate" style={{ color: k === "__default__" ? "var(--fg-2)" : "var(--fg-1)" }}>{k === "__default__" ? "Else" : k}</span>
|
||||
<Handle
|
||||
id={`case:${k}`} type="source" position={Position.Right}
|
||||
style={{ top: "50%", right: -6, transform: "translateY(-50%)", width: 10, height: 10, border: "2px solid var(--bg-1)", background: k === "__default__" ? "var(--fg-2)" : "var(--io-control)", position: "absolute" }}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : lines.length > 0 && (
|
||||
<div className="col" style={{ padding: "9px 11px", gap: 3 }}>
|
||||
{lines.map((l, i) => (
|
||||
<div key={i} className={i === 0 ? "mono-sm truncate" : "t-caption fg-2 truncate"} style={{ color: i === 0 ? "var(--fg-1)" : undefined }}>{l}</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{type !== "router" && !isSubagent && <HandleStack ports={outPorts} dir="out" />}
|
||||
{/* Deep agent's third port: a bottom "subagents" handle. Drag from here to specialist
|
||||
agent nodes to make them sub-agents the supervisor calls (see onConnect / compiler). */}
|
||||
{type === "deep_agent" && (
|
||||
<Handle
|
||||
id="subagents"
|
||||
type="source"
|
||||
position={Position.Bottom}
|
||||
title="Sub-agents — wire to specialist agents this supervisor can call"
|
||||
style={{ left: "50%", bottom: -6, transform: "translateX(-50%)", width: 12, height: 12, border: "2px solid var(--bg-1)", background: "var(--accent)", position: "absolute" }}
|
||||
/>
|
||||
)}
|
||||
{hasDebug && (
|
||||
<div
|
||||
onMouseEnter={() => setShowDebug(true)}
|
||||
onMouseLeave={() => setShowDebug(false)}
|
||||
style={{ position: "absolute", right: 8, bottom: -13, zIndex: 25 }}
|
||||
>
|
||||
<span
|
||||
className="chip chip-mono"
|
||||
style={{
|
||||
height: 24,
|
||||
borderColor: "var(--accent)",
|
||||
background: "var(--bg-1)",
|
||||
boxShadow: "var(--sh-1)",
|
||||
color: "var(--fg-0)",
|
||||
}}
|
||||
>
|
||||
<Icon name={Number(debug.cost_usd || 0) > 0 ? "bolt" : "eye"} size={11} />
|
||||
{Number(debug.cost_usd || 0) > 0 ? fmtUSD(Number(debug.cost_usd || 0)) : "output"}
|
||||
</span>
|
||||
{showDebug && (
|
||||
<div
|
||||
className="card"
|
||||
style={{
|
||||
position: "absolute",
|
||||
right: 0,
|
||||
top: 28,
|
||||
width: 290,
|
||||
padding: 11,
|
||||
boxShadow: "var(--sh-pop)",
|
||||
zIndex: 80,
|
||||
}}
|
||||
>
|
||||
<div className="row spread" style={{ marginBottom: 8 }}>
|
||||
<div className="t-micro">Node output</div>
|
||||
{(Number(debug.tokens || 0) > 0 || Number(debug.cost_usd || 0) > 0) && (
|
||||
<span className="chip chip-mono">
|
||||
<Icon name="bolt" size={11} />
|
||||
{Number(debug.tokens || 0)} tok · {fmtUSD(Number(debug.cost_usd || 0))}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<pre
|
||||
className="mono-sm"
|
||||
style={{
|
||||
margin: 0,
|
||||
maxHeight: 150,
|
||||
overflow: "auto",
|
||||
whiteSpace: "pre-wrap",
|
||||
overflowWrap: "anywhere",
|
||||
color: "var(--fg-1)",
|
||||
background: "var(--bg-3)",
|
||||
border: "1px solid var(--line)",
|
||||
borderRadius: 7,
|
||||
padding: 8,
|
||||
}}
|
||||
>
|
||||
{(outputPreview || "(no state delta)").slice(0, 1400)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,277 @@
|
||||
"use client";
|
||||
/* The canvas Test panel: messages a workflow over SSE and lights up nodes as the graph
|
||||
executes (one backend thread per session so the checkpointer holds prior turns). */
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Icon } from "../icons";
|
||||
import { Tile } from "../primitives";
|
||||
import { api, openSSE, type Workflow, type ComponentT } from "@/lib/api";
|
||||
import { fmtUSD } from "@/lib/data";
|
||||
import { ComponentRenderer } from "../component-renderer";
|
||||
import { Markdown } from "../markdown";
|
||||
import { ReplyAccumulator, type Part, type ComponentInstance } from "@/lib/chat-parts";
|
||||
import Mustache from "mustache";
|
||||
|
||||
interface TestMsg { role: "user" | "assistant"; content?: string; parts?: Part[] }
|
||||
|
||||
export function WorkflowTestPanel({
|
||||
project,
|
||||
workflow,
|
||||
running,
|
||||
onRunningChange,
|
||||
onBeforeRun,
|
||||
onClose,
|
||||
onResetRun,
|
||||
onNodeStep,
|
||||
onFinalDebug,
|
||||
}: {
|
||||
project: any;
|
||||
workflow: Workflow;
|
||||
running: boolean;
|
||||
onRunningChange: (running: boolean) => void;
|
||||
onBeforeRun: () => Promise<void>;
|
||||
onClose: () => void;
|
||||
onResetRun: () => void;
|
||||
onNodeStep: (nodeId: string, status?: "idle" | "running" | "done" | "error", output?: any) => void;
|
||||
onFinalDebug: (debugNodes: Record<string, any>) => void;
|
||||
}) {
|
||||
const [input, setInput] = useState("");
|
||||
const [msgs, setMsgs] = useState<TestMsg[]>([]);
|
||||
const [streaming, setStreaming] = useState("");
|
||||
const [meter, setMeter] = useState<{ tokens: number; cost: number } | null>(null);
|
||||
const [compDefs, setCompDefs] = useState<Record<string, ComponentT>>({});
|
||||
const [liveParts, setLiveParts] = useState<Part[]>([]); // in-flight assistant reply parts (audit H3)
|
||||
const activeRef = useRef(true);
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
// Folded sub-agents aren't graph nodes, so node_start never fires for them - map each
|
||||
// sub-agent NAME to its canvas node id so the live `activity` stream can light it up.
|
||||
const subNameToId = useMemo(() => {
|
||||
const m: Record<string, string> = {};
|
||||
const nodes = (workflow as any)?.executable?.nodes || (workflow as any)?.canvas?.nodes || [];
|
||||
for (const n of nodes) {
|
||||
const name = n?.config?.name ?? n?.data?.config?.name;
|
||||
const type = n?.type ?? n?.data?.nodeType;
|
||||
if (type === "agent" || type === "deep_agent") {
|
||||
if (name) m[name] = n.id;
|
||||
m[n.id] = n.id; // sub-agent name falls back to the node id when the agent is unnamed
|
||||
}
|
||||
}
|
||||
return m;
|
||||
}, [workflow]);
|
||||
const actNameRef = useRef<Record<string, string>>({}); // activity id -> sub-agent name
|
||||
// One backend thread per test session - the checkpointer holds prior turns.
|
||||
const threadRef = useRef<string | null>(null);
|
||||
useEffect(() => {
|
||||
threadRef.current = null; setMsgs([]);
|
||||
if (project?.id) api.listComponents(project.id).then((cs) => setCompDefs(Object.fromEntries(cs.map((c) => [c.id, c])))).catch(() => {});
|
||||
}, [workflow?.id, project?.id]);
|
||||
|
||||
useEffect(() => {
|
||||
activeRef.current = true;
|
||||
return () => {
|
||||
activeRef.current = false;
|
||||
onRunningChange(false);
|
||||
};
|
||||
}, [onRunningChange]);
|
||||
|
||||
useEffect(() => {
|
||||
scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight, behavior: "smooth" });
|
||||
}, [msgs, streaming, meter, liveParts]);
|
||||
|
||||
async function send(textArg?: string) {
|
||||
const text = (typeof textArg === "string" ? textArg : input).trim();
|
||||
if (!text || running || !project?.id || !workflow?.id) return;
|
||||
if (typeof textArg !== "string") setInput("");
|
||||
setMsgs((m) => [...m, { role: "user", content: text }]);
|
||||
setStreaming("");
|
||||
setMeter(null);
|
||||
setLiveParts([]);
|
||||
actNameRef.current = {};
|
||||
onResetRun();
|
||||
onRunningChange(true);
|
||||
let finalAnswer = "";
|
||||
let lastNode: string | null = null;
|
||||
// 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 {
|
||||
await onBeforeRun();
|
||||
if (!activeRef.current) return;
|
||||
const run = await api.createRun(
|
||||
project.id, workflow.id,
|
||||
{ messages: [{ role: "user", content: text }] },
|
||||
threadRef.current || undefined,
|
||||
);
|
||||
threadRef.current = run.thread_id;
|
||||
await openSSE(api.runStreamUrl(project.id, workflow.id, run.id), (f) => {
|
||||
if (!activeRef.current) return;
|
||||
if (f.event === "messages" && f.data?.content) {
|
||||
acc.addText(f.data.content);
|
||||
setStreaming(acc.text);
|
||||
setLiveParts(acc.parts({ streaming: true }));
|
||||
} else if (f.event === "node_start" && f.data?.node) {
|
||||
const node = f.data.node;
|
||||
lastNode = node;
|
||||
onNodeStep(node, "running");
|
||||
} else if (f.event === "node_error" && f.data?.node) {
|
||||
const node = f.data.node;
|
||||
onNodeStep(node, "error");
|
||||
finalAnswer = `⚠ ${f.data?.message || `${node} failed`}`;
|
||||
} else if (f.event === "updates" && f.data && typeof f.data === "object") {
|
||||
const node = Object.keys(f.data)[0];
|
||||
if (!node) return;
|
||||
const output = f.data[node];
|
||||
onNodeStep(node, "done", output);
|
||||
if (lastNode === node) lastNode = null;
|
||||
} else if (f.event === "activity" && f.data?.id) {
|
||||
// Light up a folded sub-agent's canvas node from the live activity stream (it has no
|
||||
// graph node_start of its own). Tools inside the sub-agent aren't nodes, so skip them.
|
||||
const a = f.data;
|
||||
if (a.phase === "start" && a.kind === "subagent") {
|
||||
actNameRef.current[a.id] = a.name;
|
||||
const nid = subNameToId[a.name];
|
||||
if (nid) onNodeStep(nid, "running");
|
||||
} else if (a.phase === "end") {
|
||||
const nid = subNameToId[actNameRef.current[a.id]];
|
||||
if (nid) onNodeStep(nid, "done", a.output);
|
||||
}
|
||||
} else if (f.event === "custom" && f.data?.channel === "component" && f.data?.payload) {
|
||||
acc.addComponent(f.data.payload as ComponentInstance);
|
||||
setLiveParts(acc.parts({ streaming: true }));
|
||||
} else if (f.event === "done") {
|
||||
finalAnswer = f.data?.answer || "";
|
||||
setMeter({ tokens: f.data?.total_tokens ?? 0, cost: f.data?.total_cost_usd ?? 0 });
|
||||
onFinalDebug(f.data?.debug?.nodes || {});
|
||||
if (lastNode) onNodeStep(lastNode, "done");
|
||||
} else if (f.event === "interrupt") {
|
||||
finalAnswer = "⏸ This run paused for approval. Open the full Playground to resume it.";
|
||||
if (lastNode) onNodeStep(lastNode, "done");
|
||||
} else if (f.event === "error") {
|
||||
finalAnswer = `⚠ ${f.data?.message || "run failed"}`;
|
||||
if (lastNode) onNodeStep(lastNode, "error");
|
||||
}
|
||||
});
|
||||
} catch (e: any) {
|
||||
if (!activeRef.current) return;
|
||||
finalAnswer = `⚠ ${e.message || e}`;
|
||||
if (lastNode) onNodeStep(lastNode, "error");
|
||||
} finally {
|
||||
if (activeRef.current) {
|
||||
setStreaming("");
|
||||
onRunningChange(false);
|
||||
setLiveParts([]);
|
||||
}
|
||||
}
|
||||
if (activeRef.current) {
|
||||
// resolveText reconciles the streamed buffer with the authoritative answer / error so it's
|
||||
// never dropped (audit H2); markers in it splice components into place.
|
||||
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)" }]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handleComponentAction(inst: ComponentInstance, action: string, fields: Record<string, string>) {
|
||||
const def = (inst.actions || []).find((a: any) => a.id === action) || {};
|
||||
let msg = (def as any).message || (def as any).label || action;
|
||||
try { msg = Mustache.render(String(msg), { props: inst.props || {}, fields, action }); } catch {}
|
||||
if (msg) send(msg);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="col" style={{ flex: 1, minHeight: 0 }}>
|
||||
<div className="row spread" style={{ padding: "12px 14px", borderBottom: "1px solid var(--line)", flex: "none" }}>
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<div className="t-h2">Test</div>
|
||||
<div className="fg-2 t-caption mono truncate">{workflow.name}</div>
|
||||
</div>
|
||||
<button className="iconbtn" onClick={onClose}><Icon name="x" size={15} /></button>
|
||||
</div>
|
||||
<div ref={scrollRef} className="scroll-y col gap3" style={{ flex: 1, minHeight: 0, padding: 12, overflowX: "hidden" }}>
|
||||
{msgs.length === 0 && !streaming && !running && (
|
||||
<div className="col center" style={{ minHeight: 160, textAlign: "center", gap: 8, color: "var(--fg-2)" }}>
|
||||
<Tile icon="playground" color="var(--io-json)" size={38} />
|
||||
<div className="t-h3" style={{ color: "var(--fg-1)" }}>Message this workflow</div>
|
||||
<div className="t-caption">Nodes highlight as the graph executes. Hover the debug chips on nodes for output and cost.</div>
|
||||
</div>
|
||||
)}
|
||||
{msgs.map((m, i) => (
|
||||
<TestMessage key={i} role={m.role} content={m.content} parts={m.parts} compDefs={compDefs} onAction={handleComponentAction} />
|
||||
))}
|
||||
{(running || liveParts.length > 0) && (
|
||||
<TestMessage role="assistant" parts={liveParts} streaming compDefs={compDefs} onAction={handleComponentAction} />
|
||||
)}
|
||||
{meter && <span className="chip chip-mono" style={{ alignSelf: "flex-start" }}><Icon name="bolt" size={12} />{meter.tokens} tok · {fmtUSD(meter.cost)}</span>}
|
||||
</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 10px" }}>
|
||||
<input
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && send()}
|
||||
placeholder="Ask this workflow..."
|
||||
disabled={running}
|
||||
style={{ flex: 1, minWidth: 0, border: "none", background: "none", outline: "none", fontSize: 13, color: "var(--fg-0)", fontFamily: "var(--font-ui)" }}
|
||||
/>
|
||||
<button className="btn btn-primary btn-sm" onClick={() => send()} disabled={running || !input.trim()}>
|
||||
<Icon name={running ? "refresh" : "play"} size={13} style={running ? { animation: "spin 1s linear infinite" } : {}} />
|
||||
{running ? "Testing" : "Send"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* One test-panel turn: a single avatar + a column. The assistant reply is an ordered list
|
||||
of parts (text + components interleaved in produced order), matching the Playground so a
|
||||
rendered component sits in its correct place within the reply. */
|
||||
function TestMessage({ role, content, streaming, parts, compDefs, onAction }: {
|
||||
role: "user" | "assistant";
|
||||
content?: string;
|
||||
streaming?: boolean;
|
||||
parts?: Part[];
|
||||
compDefs: Record<string, ComponentT>;
|
||||
onAction: (inst: ComponentInstance, action: string, fields: Record<string, string>) => void;
|
||||
}) {
|
||||
const user = role === "user";
|
||||
if (user) {
|
||||
return (
|
||||
<div className="row" style={{ gap: 8, alignItems: "flex-start", flexDirection: "row-reverse" }}>
|
||||
<Tile icon="user" color="var(--signal)" size={24} />
|
||||
<div style={{ maxWidth: 260, padding: "8px 10px", borderRadius: 10, borderTopRightRadius: 3, fontSize: 13, lineHeight: "19px", whiteSpace: "pre-wrap", overflowWrap: "anywhere", background: "var(--accent)", color: "var(--fg-on-accent)" }}>
|
||||
{content}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const renderComp = (inst: ComponentInstance, key: number | string) => {
|
||||
const def = compDefs[inst.component_id];
|
||||
if (!def) {
|
||||
return <div key={key} className="card" style={{ padding: "6px 9px", fontSize: 12, color: "var(--fg-2)" }}>Component “{inst.name || inst.component_id}” unavailable.</div>;
|
||||
}
|
||||
return (
|
||||
<ComponentRenderer key={key} def={{ id: def.id, name: def.name, html: def.html, css: def.css, actions: inst.actions || def.actions }} props={inst.props || {}} onAction={(a, f) => onAction(inst, a, f)} />
|
||||
);
|
||||
};
|
||||
const list: Part[] = parts && parts.length ? parts : (content && content.trim() ? [{ kind: "text", text: content }] : []);
|
||||
let lastText = -1;
|
||||
for (let k = list.length - 1; k >= 0; k--) { if (list[k].kind === "text") { lastText = k; break; } }
|
||||
return (
|
||||
<div className="row" style={{ gap: 8, alignItems: "flex-start" }}>
|
||||
<Tile icon="sparkles" color="var(--accent)" size={24} />
|
||||
<div className="col gap2" style={{ minWidth: 0, flex: 1 }}>
|
||||
{list.map((p, j) => (p.kind === "text" ? (
|
||||
<div key={j} style={{ fontSize: 13, lineHeight: "19px", color: "var(--fg-0)", overflowWrap: "anywhere" }}>
|
||||
<Markdown>{p.text}</Markdown>
|
||||
{streaming && j === lastText && <span style={{ display: "inline-block", width: 6, height: 13, background: "var(--accent)", verticalAlign: "-2px", animation: "blink 1s steps(1) infinite" }} />}
|
||||
</div>
|
||||
) : renderComp(p.inst, j)))}
|
||||
{streaming && lastText === -1 && <span className="fg-2" style={{ fontSize: 13 }}>…</span>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
"use client";
|
||||
/* Renders a user-authored UI component (Feature 2 - generative UI) inside a SANDBOXED
|
||||
iframe. Mustache interpolates the agent-supplied props into the saved HTML (values are
|
||||
HTML-escaped by Mustache), the CSS is scoped to the iframe document, and a tiny injected
|
||||
bridge reports content height (for auto-sizing) and posts button/form actions back to the
|
||||
host. sandbox="allow-scripts" (NO allow-same-origin) means the component can never reach
|
||||
the parent DOM, cookies, or storage - only postMessage. */
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import Mustache from "mustache";
|
||||
|
||||
export interface ComponentDef {
|
||||
id?: string;
|
||||
name?: string;
|
||||
html: string;
|
||||
css: string;
|
||||
actions?: Record<string, any>[];
|
||||
}
|
||||
|
||||
// Runs INSIDE the sandboxed iframe. No Date/Math.random (kept deterministic). Reports size
|
||||
// and forwards clicks on [data-forge-action] elements (with any named field values).
|
||||
const BRIDGE =
|
||||
"(function(){function post(m){try{parent.postMessage(Object.assign({__forge:true},m),(window.__FO||'*'))}catch(e){}}" +
|
||||
"function size(){post({type:'size',height:(document.documentElement.scrollHeight||document.body.scrollHeight)})}" +
|
||||
"window.addEventListener('load',function(){size();setTimeout(size,60)});" +
|
||||
"try{new ResizeObserver(size).observe(document.documentElement)}catch(e){}" +
|
||||
"document.addEventListener('click',function(e){var el=e.target&&e.target.closest?e.target.closest('[data-forge-action]'):null;if(!el)return;e.preventDefault();" +
|
||||
"var scope=el.closest('form')||document;var fields={};scope.querySelectorAll('[name]').forEach(function(i){var t=(i.type||'').toLowerCase();if((t==='checkbox'||t==='radio')&&!i.checked)return;if(Object.prototype.hasOwnProperty.call(fields,i.name)){fields[i.name]=[].concat(fields[i.name],i.value)}else{fields[i.name]=i.value}});" +
|
||||
"post({type:'action',action:el.getAttribute('data-forge-action'),fields:fields})});})();";
|
||||
|
||||
export function ComponentRenderer({
|
||||
def,
|
||||
props,
|
||||
onAction,
|
||||
}: {
|
||||
def: ComponentDef;
|
||||
props: Record<string, any>;
|
||||
onAction?: (action: string, fields: Record<string, string>, def: ComponentDef) => void;
|
||||
}) {
|
||||
const ref = useRef<HTMLIFrameElement>(null);
|
||||
const [height, setHeight] = useState(60);
|
||||
// Keep latest def/onAction in refs so the message listener registers ONCE - new def/onAction
|
||||
// object identities on each parent render would otherwise re-bind it every render (F19/F33).
|
||||
const defRef = useRef(def);
|
||||
defRef.current = def;
|
||||
const actionRef = useRef(onAction);
|
||||
actionRef.current = onAction;
|
||||
|
||||
// Rebuild the iframe document only when the template or props actually change - not on every
|
||||
// parent re-render (e.g. while a sibling message streams), which would reload the iframe (F19).
|
||||
const srcDoc = useMemo(() => {
|
||||
let body = "";
|
||||
try {
|
||||
body = Mustache.render(def.html || "", props || {});
|
||||
} catch (e: any) {
|
||||
body = `<pre style="color:#b00020;font:12px monospace;white-space:pre-wrap">template error: ${String(e?.message || e)}</pre>`;
|
||||
}
|
||||
// Bake our origin in so the sandboxed (opaque-origin) frame can postMessage back with a
|
||||
// concrete targetOrigin instead of "*" (review hardening). The parent is same-origin as us.
|
||||
const fo = typeof window !== "undefined" ? window.location.origin : "*";
|
||||
return (
|
||||
`<!DOCTYPE html><html><head><meta charset="utf-8">` +
|
||||
`<style>html,body{margin:0;padding:0;background:transparent}${def.css || ""}</style></head>` +
|
||||
`<body>${body}<script>window.__FO=${JSON.stringify(fo)};${BRIDGE}</script></body></html>`
|
||||
);
|
||||
}, [def.html, def.css, props]);
|
||||
|
||||
useEffect(() => {
|
||||
function onMsg(e: MessageEvent) {
|
||||
if (!ref.current || e.source !== ref.current.contentWindow) return;
|
||||
// Sandboxed (allow-scripts, no same-origin) iframes post from the opaque "null" origin;
|
||||
// accept that or our own origin, reject anything else (audit F27).
|
||||
if (e.origin !== "null" && e.origin !== window.location.origin) return;
|
||||
const d: any = e.data || {};
|
||||
if (!d.__forge) return;
|
||||
if (d.type === "size" && typeof d.height === "number") {
|
||||
setHeight(Math.min(2000, Math.max(40, Math.ceil(d.height))));
|
||||
} else if (d.type === "action") {
|
||||
actionRef.current?.(String(d.action || ""), d.fields || {}, defRef.current);
|
||||
}
|
||||
}
|
||||
window.addEventListener("message", onMsg);
|
||||
return () => window.removeEventListener("message", onMsg);
|
||||
}, []);
|
||||
|
||||
const actions = def.actions || [];
|
||||
return (
|
||||
<div>
|
||||
<iframe
|
||||
ref={ref}
|
||||
sandbox="allow-scripts"
|
||||
srcDoc={srcDoc}
|
||||
title={def.name || "component"}
|
||||
style={{ width: "100%", height, border: "none", display: "block", background: "transparent" }}
|
||||
/>
|
||||
{actions.length > 0 && (
|
||||
<div className="row gap2 wrap" style={{ marginTop: 8 }}>
|
||||
{actions.map((a: any, i: number) => (
|
||||
<button
|
||||
key={a.id || i}
|
||||
className="btn btn-secondary btn-sm"
|
||||
onClick={() => onAction?.(String(a.id || a.label || ""), {}, def)}
|
||||
>
|
||||
{a.label || a.id || "action"}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
/* Forge icon set - lucide-style, 1.5px stroke, 24x24 viewBox. Ported from the design handoff. */
|
||||
import type { CSSProperties } from "react";
|
||||
|
||||
const P: Record<string, string> = {
|
||||
// nav / shell
|
||||
dashboard: '<rect x="3" y="3" width="7" height="9" rx="1"/><rect x="14" y="3" width="7" height="5" rx="1"/><rect x="14" y="12" width="7" height="9" rx="1"/><rect x="3" y="16" width="7" height="5" rx="1"/>',
|
||||
search: '<circle cx="11" cy="11" r="7"/><path d="m21 21-4.3-4.3"/>',
|
||||
sparkles: '<path d="M12 3l1.6 4.6L18 9l-4.4 1.4L12 15l-1.6-4.6L6 9l4.4-1.4z"/><path d="M19 14l.8 2.2L22 17l-2.2.8L19 20l-.8-2.2L16 17l2.2-.8z"/>',
|
||||
help: '<circle cx="12" cy="12" r="9"/><path d="M9.5 9a2.5 2.5 0 1 1 3.4 2.3c-.9.4-1.4 1-1.4 2"/><circle cx="12" cy="16.5" r=".6" fill="currentColor" stroke="none"/>',
|
||||
bell: '<path d="M18 8a6 6 0 1 0-12 0c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.7 21a2 2 0 0 1-3.4 0"/>',
|
||||
overview: '<path d="M3 12a9 9 0 1 0 18 0 9 9 0 0 0-18 0"/><path d="M12 7v5l3 2"/>',
|
||||
workflows: '<rect x="3" y="3" width="6" height="6" rx="1"/><rect x="15" y="15" width="6" height="6" rx="1"/><rect x="15" y="3" width="6" height="6" rx="1"/><path d="M9 6h3a3 3 0 0 1 3 3v0M6 9v3a3 3 0 0 0 3 3h6"/>',
|
||||
agents: '<circle cx="12" cy="8" r="4"/><path d="M5 21a7 7 0 0 1 14 0"/>',
|
||||
tools: '<path d="M14.7 6.3a4 4 0 0 0-5.4 5.4L3 18v3h3l6.3-6.3a4 4 0 0 0 5.4-5.4l-2.5 2.5-2-2z"/>',
|
||||
auth: '<rect x="4" y="10" width="16" height="11" rx="2"/><path d="M8 10V7a4 4 0 0 1 8 0v3"/><circle cx="12" cy="15.5" r="1.4"/>',
|
||||
knowledge: '<path d="M4 5.5A2.5 2.5 0 0 1 6.5 3H19v15H6.5A2.5 2.5 0 0 0 4 20.5z"/><path d="M4 20.5A2.5 2.5 0 0 1 6.5 18H19v3H6.5A2.5 2.5 0 0 1 4 20.5z"/>',
|
||||
playground: '<path d="M5 4v16l13-8z"/>',
|
||||
traces: '<path d="M4 5h16M4 5v6a2 2 0 0 0 2 2h4M10 13v6M14 5v2a2 2 0 0 0 2 2h2a2 2 0 0 1 2 2v2"/><circle cx="10" cy="19" r="1.6"/><circle cx="20" cy="19" r="1.6"/>',
|
||||
widget: '<rect x="3" y="4" width="18" height="13" rx="2"/><path d="M8 21h8M12 17v4"/><path d="M7 9h6M7 12h4"/>',
|
||||
connect: '<path d="M9 12h6"/><rect x="3" y="9" width="4" height="6" rx="1"/><rect x="17" y="9" width="4" height="6" rx="1"/>',
|
||||
settings: '<circle cx="12" cy="12" r="3"/><path d="M12 2v3M12 19v3M2 12h3M19 12h3M5 5l2 2M17 17l2 2M19 5l-2 2M7 17l-2 2"/>',
|
||||
secret: '<rect x="3" y="11" width="18" height="10" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/>',
|
||||
logout: '<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><path d="m16 17 5-5-5-5"/><path d="M21 12H9"/>',
|
||||
// sidebar nav — lucide glyphs from the Forge Console design
|
||||
'layout-dashboard': '<rect width="7" height="9" x="3" y="3" rx="1"/><rect width="7" height="5" x="14" y="3" rx="1"/><rect width="7" height="9" x="14" y="12" rx="1"/><rect width="7" height="5" x="3" y="16" rx="1"/>',
|
||||
workflow: '<rect width="8" height="8" x="3" y="3" rx="2"/><path d="M7 11v4a2 2 0 0 0 2 2h4"/><rect width="8" height="8" x="13" y="13" rx="2"/>',
|
||||
bot: '<path d="M12 8V4H8"/><rect width="16" height="12" x="4" y="8" rx="2"/><path d="M2 14h2"/><path d="M20 14h2"/><path d="M15 13v2"/><path d="M9 13v2"/>',
|
||||
'book-open': '<path d="M2 3h6a4 4 0 0 1 4 4v14a3 3 0 0 0-3-3H2z"/><path d="M22 3h-6a4 4 0 0 0-4 4v14a3 3 0 0 1 3-3h7z"/>',
|
||||
'shield-check': '<path d="M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z"/><path d="m9 12 2 2 4-4"/>',
|
||||
server: '<rect width="20" height="8" x="2" y="2" rx="2"/><rect width="20" height="8" x="2" y="14" rx="2"/><path d="M6 6h.01"/><path d="M6 18h.01"/>',
|
||||
'plug-zap': '<path d="M6.3 20.3a2.4 2.4 0 0 0 3.4 0L12 18l-6-6-2.3 2.3a2.4 2.4 0 0 0 0 3.4Z"/><path d="m2 22 3-3"/><path d="M7.5 13.5 10 11"/><path d="M10.5 16.5 13 14"/><path d="m18 3-4 4h6l-4 4"/>',
|
||||
activity: '<path d="M22 12h-4l-3 9L9 3l-3 9H2"/>',
|
||||
mail: '<rect width="20" height="16" x="2" y="4" rx="2"/><path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7"/>',
|
||||
inbox: '<path d="M22 12h-6l-2 3h-4l-2-3H2"/><path d="M5.45 5.11 2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z"/>',
|
||||
// node types
|
||||
n_start: '<circle cx="12" cy="12" r="9"/><path d="M10 8l6 4-6 4z"/>',
|
||||
n_end: '<circle cx="12" cy="12" r="9"/><rect x="9" y="9" width="6" height="6" rx="1"/>',
|
||||
n_agent: '<rect x="5" y="7" width="14" height="11" rx="2"/><path d="M12 7V4M9 12h.01M15 12h.01"/><path d="M2 12h3M19 12h3"/>',
|
||||
n_deepagent: '<rect x="4" y="8" width="16" height="11" rx="2"/><path d="M12 8V5M8 13h.01M16 13h.01"/><path d="M7 5h10"/><path d="M2 13h2M20 13h2"/>',
|
||||
n_llm: '<path d="M12 3a4 4 0 0 0-4 4 4 4 0 0 0-1 7.9A3.5 3.5 0 0 0 12 19a3.5 3.5 0 0 0 5-4.1A4 4 0 0 0 16 7a4 4 0 0 0-4-4z"/>',
|
||||
n_tool: '<path d="M14.7 6.3a4 4 0 0 0-5.4 5.4L3 18v3h3l6.3-6.3a4 4 0 0 0 5.4-5.4l-2.5 2.5-2-2z"/>',
|
||||
n_router: '<path d="M4 12h6l3-5h7M13 17h7"/><circle cx="4" cy="12" r="1.6"/><path d="M18 4l3 3-3 3M17 14l3 3-3 3"/>',
|
||||
n_retrieval: '<circle cx="11" cy="11" r="6"/><path d="m20 20-3.5-3.5M11 8v6M8 11h6"/>',
|
||||
n_qa: '<path d="M21 12a8 8 0 1 1-3-6.2"/><path d="M21 4v4h-4"/><path d="M9.5 10a2.5 2.5 0 1 1 3 2.4V14"/>',
|
||||
n_human: '<circle cx="12" cy="7" r="3"/><path d="M6 21a6 6 0 0 1 12 0"/><path d="M19 4l1 1 2-2"/>',
|
||||
n_transform: '<path d="M4 7h11M11 3l4 4-4 4M20 17H9M13 21l-4-4 4-4"/>',
|
||||
n_subworkflow: '<rect x="3" y="3" width="18" height="18" rx="2"/><rect x="7" y="7" width="5" height="5" rx="1"/><rect x="13" y="12" width="4" height="4" rx="1"/><path d="M12 9.5h1a2 2 0 0 1 2 2v.5"/>',
|
||||
n_fanout: '<circle cx="5" cy="12" r="2"/><circle cx="19" cy="5" r="2"/><circle cx="19" cy="12" r="2"/><circle cx="19" cy="19" r="2"/><path d="M7 12h4M11 12l6-6M11 12h6M11 12l6 6"/>',
|
||||
n_join: '<circle cx="19" cy="12" r="2"/><circle cx="5" cy="5" r="2"/><circle cx="5" cy="12" r="2"/><circle cx="5" cy="19" r="2"/><path d="M7 5l6 6M7 12h6M7 19l6-6M13 12h4"/>',
|
||||
n_loop: '<path d="M4 9a8 8 0 0 1 14-3M20 6V2M20 6h-4"/><path d="M20 15a8 8 0 0 1-14 3M4 18v4M4 18h4"/>',
|
||||
n_webhook: '<circle cx="12" cy="6" r="3"/><path d="M12 9v4l-3 5M9 18a3 3 0 1 1-3-3M15 13a3 3 0 1 1 0 6h-6"/>',
|
||||
n_emit: '<path d="M3 11l18-7-7 18-2.5-7.5z"/>',
|
||||
// tool kinds
|
||||
k_rest: '<path d="M3 12h18M12 3a15 15 0 0 1 0 18M12 3a15 15 0 0 0 0 18"/><circle cx="12" cy="12" r="9"/>',
|
||||
k_graphql: '<path d="M12 3l8 4.6v8.8L12 21l-8-4.6V7.6z"/><circle cx="12" cy="3" r="1.4"/><circle cx="20" cy="7.6" r="1.4"/><circle cx="20" cy="16.4" r="1.4"/><circle cx="12" cy="21" r="1.4"/><circle cx="4" cy="16.4" r="1.4"/><circle cx="4" cy="7.6" r="1.4"/>',
|
||||
k_code: '<path d="M8 8l-4 4 4 4M16 8l4 4-4 4"/>',
|
||||
k_mcp: '<rect x="3" y="9" width="4" height="6" rx="1"/><rect x="17" y="9" width="4" height="6" rx="1"/><path d="M9 12h6"/>',
|
||||
k_builtin: '<rect x="4" y="4" width="16" height="16" rx="3"/><path d="M9 9h6v6H9z"/>',
|
||||
// actions
|
||||
plus: '<path d="M12 5v14M5 12h14"/>',
|
||||
minus: '<path d="M5 12h14"/>',
|
||||
x: '<path d="M6 6l12 12M18 6L6 18"/>',
|
||||
check: '<path d="M5 12l5 5L20 7"/>',
|
||||
play: '<path d="M6 4v16l13-8z"/>',
|
||||
stop: '<rect x="6" y="6" width="12" height="12" rx="2"/>',
|
||||
save: '<path d="M5 4h11l3 3v13H5z"/><path d="M8 4v5h7M8 20v-6h8v6"/>',
|
||||
validate: '<path d="M9 12l2 2 4-4"/><circle cx="12" cy="12" r="9"/>',
|
||||
undo: '<path d="M9 7L4 12l5 5"/><path d="M4 12h11a5 5 0 0 1 0 10h-1"/>',
|
||||
redo: '<path d="M15 7l5 5-5 5"/><path d="M20 12H9a5 5 0 0 0 0 10h1"/>',
|
||||
tidy: '<path d="M4 6h16M4 12h10M4 18h7"/>',
|
||||
zoomin: '<circle cx="11" cy="11" r="7"/><path d="m21 21-4.3-4.3M11 8v6M8 11h6"/>',
|
||||
zoomout: '<circle cx="11" cy="11" r="7"/><path d="m21 21-4.3-4.3M8 11h6"/>',
|
||||
fit: '<path d="M4 9V5h4M20 9V5h-4M4 15v4h4M20 15v4h-4"/>',
|
||||
chevdown: '<path d="M6 9l6 6 6-6"/>',
|
||||
chevright: '<path d="M9 6l6 6-6 6"/>',
|
||||
chevleft: '<path d="M15 6l-6 6 6 6"/>',
|
||||
chevup: '<path d="M6 15l6-6 6 6"/>',
|
||||
more: '<circle cx="5" cy="12" r="1.6"/><circle cx="12" cy="12" r="1.6"/><circle cx="19" cy="12" r="1.6"/>',
|
||||
drag: '<circle cx="9" cy="6" r="1.4"/><circle cx="9" cy="12" r="1.4"/><circle cx="9" cy="18" r="1.4"/><circle cx="15" cy="6" r="1.4"/><circle cx="15" cy="12" r="1.4"/><circle cx="15" cy="18" r="1.4"/>',
|
||||
copy: '<rect x="9" y="9" width="11" height="11" rx="2"/><path d="M5 15V5a2 2 0 0 1 2-2h8"/>',
|
||||
trash: '<path d="M4 7h16M9 7V4h6v3M6 7l1 13h10l1-13"/>',
|
||||
edit: '<path d="M4 20h4L18 10l-4-4L4 16z"/><path d="M13.5 6.5l4 4"/>',
|
||||
external: '<path d="M14 4h6v6M20 4l-9 9M19 14v5a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V6a1 1 0 0 1 1-1h5"/>',
|
||||
eye: '<path d="M2 12s4-7 10-7 10 7 10 7-4 7-10 7-10-7-10-7z"/><circle cx="12" cy="12" r="3"/>',
|
||||
eyeoff: '<path d="M4 4l16 16M10 5.2A9 9 0 0 1 12 5c6 0 10 7 10 7a18 18 0 0 1-3 3.5M6 6.5A18 18 0 0 0 2 12s4 7 10 7a9 9 0 0 0 3-.5"/>',
|
||||
refresh: '<path d="M20 11a8 8 0 0 0-14-4M4 5v4h4M4 13a8 8 0 0 0 14 4M20 19v-4h-4"/>',
|
||||
theme: '<circle cx="12" cy="12" r="4.5"/><path d="M12 3v2M12 19v2M3 12h2M19 12h2M5 5l1.5 1.5M17.5 17.5L19 19M19 5l-1.5 1.5M6.5 17.5L5 19"/>',
|
||||
filter: '<path d="M4 5h16l-6 8v5l-4 2v-7z"/>',
|
||||
upload: '<path d="M12 16V4M8 8l4-4 4 4M5 20h14"/>',
|
||||
file: '<path d="M7 3h7l5 5v13H7z"/><path d="M14 3v5h5"/>',
|
||||
link: '<path d="M9 12h6"/><path d="M10 8H7a4 4 0 0 0 0 8h3M14 8h3a4 4 0 0 1 0 8h-3"/>',
|
||||
clock: '<circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/>',
|
||||
lock: '<rect x="5" y="11" width="14" height="9" rx="2"/><path d="M8 11V7a4 4 0 0 1 8 0v4"/>',
|
||||
coins: '<ellipse cx="9" cy="7" rx="6" ry="3"/><path d="M3 7v5c0 1.7 2.7 3 6 3s6-1.3 6-3V7"/><path d="M15 11.5c2.5.4 6 1.5 6 3.5 0 1.7-2.7 3-6 3s-6-1.3-6-3"/>',
|
||||
bolt: '<path d="M13 3L4 14h7l-1 7 9-11h-7z"/>',
|
||||
db: '<ellipse cx="12" cy="5" rx="8" ry="3"/><path d="M4 5v6c0 1.7 3.6 3 8 3s8-1.3 8-3V5M4 11v6c0 1.7 3.6 3 8 3s8-1.3 8-3v-6"/>',
|
||||
globe: '<circle cx="12" cy="12" r="9"/><path d="M3 12h18M12 3a15 15 0 0 1 0 18M12 3a15 15 0 0 0 0 18"/>',
|
||||
layers: '<path d="M12 3l9 5-9 5-9-5z"/><path d="M3 13l9 5 9-5M3 17l9 5 9-5"/>',
|
||||
msg: '<path d="M4 5h16v11H9l-5 4z"/>',
|
||||
user: '<circle cx="12" cy="8" r="4"/><path d="M5 21a7 7 0 0 1 14 0"/>',
|
||||
logo: '<path d="M12 2l3 5 5.5 1-4 4 1 5.5L12 20l-5.5 2.5 1-5.5-4-4L9 7z"/>',
|
||||
grid: '<rect x="3" y="3" width="7" height="7" rx="1"/><rect x="14" y="3" width="7" height="7" rx="1"/><rect x="3" y="14" width="7" height="7" rx="1"/><rect x="14" y="14" width="7" height="7" rx="1"/>',
|
||||
list: '<path d="M8 6h13M8 12h13M8 18h13M3 6h.01M3 12h.01M3 18h.01"/>',
|
||||
flame: '<path d="M12 3c1 3 4 4 4 8a4 4 0 0 1-8 0c0-1 .5-2 1-2.5C9 11 9.5 13 11 13c-.5-2 .5-3.5 1-4 .3 1 .5 1.5 1.5 2 .2-3-1.5-5-1.5-8z"/>',
|
||||
sliders: '<path d="M4 21v-7M4 10V3M12 21v-9M12 8V3M20 21v-5M20 12V3M1 14h6M9 8h6M17 16h6"/>',
|
||||
download: '<path d="M12 4v12M8 12l4 4 4-4M5 20h14"/>',
|
||||
rotate: '<path d="M20 11a8 8 0 0 0-14-4M4 5v4h4"/><path d="M4 13a8 8 0 0 0 14 4M20 19v-4h-4"/>',
|
||||
};
|
||||
|
||||
export type IconName = keyof typeof P | string;
|
||||
|
||||
export function Icon({
|
||||
name,
|
||||
size = 18,
|
||||
strokeWidth = 1.5,
|
||||
style,
|
||||
className,
|
||||
}: {
|
||||
name: IconName;
|
||||
size?: number;
|
||||
strokeWidth?: number;
|
||||
style?: CSSProperties;
|
||||
className?: string;
|
||||
}) {
|
||||
const d = P[name];
|
||||
if (!d) return null;
|
||||
return (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={strokeWidth}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
style={style}
|
||||
className={className}
|
||||
aria-hidden
|
||||
dangerouslySetInnerHTML={{ __html: d }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export const ICON_NAMES = Object.keys(P);
|
||||
@@ -0,0 +1,217 @@
|
||||
"use client";
|
||||
/* Reusable Export / Import controls for the four authorable entity types (tool, workflow,
|
||||
component, agent). Export opens a select-all picker and downloads a single-type JSON
|
||||
bundle; Import uploads such a bundle and re-creates its items IN THE CURRENT PROJECT
|
||||
(new ids, auto-renamed on collision - never overwrites). Dropped into each list screen's
|
||||
header; the same bundle format works across projects. */
|
||||
import { useRef, useState } from "react";
|
||||
import { Icon } from "./icons";
|
||||
import { Modal } from "./primitives";
|
||||
import { api, ImportReport, PortableType } from "@/lib/api";
|
||||
|
||||
export interface PortableItem {
|
||||
id: string;
|
||||
name: string;
|
||||
sub?: string; // optional secondary line (e.g. kind / model)
|
||||
}
|
||||
|
||||
function downloadJson(filename: string, data: unknown) {
|
||||
const blob = new Blob([JSON.stringify(data, null, 2)], { type: "application/json" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
function Check({ checked, indeterminate }: { checked: boolean; indeterminate?: boolean }) {
|
||||
return (
|
||||
<span
|
||||
aria-checked={indeterminate ? "mixed" : checked}
|
||||
role="checkbox"
|
||||
style={{
|
||||
width: 16, height: 16, flex: "none", borderRadius: 4, display: "inline-flex", alignItems: "center", justifyContent: "center",
|
||||
border: "1.5px solid " + (checked || indeterminate ? "var(--accent)" : "var(--line-strong)"),
|
||||
background: checked || indeterminate ? "var(--accent)" : "transparent", color: "var(--fg-on-accent)",
|
||||
}}
|
||||
>
|
||||
{indeterminate ? <Icon name="minus" size={11} /> : checked ? <Icon name="check" size={11} /> : null}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function ImportExport({
|
||||
project, type, typeLabel, items, onImported, size = "sm",
|
||||
}: {
|
||||
project: { id: string; name?: string; slug?: string } | null | undefined;
|
||||
type: PortableType;
|
||||
typeLabel: string; // singular, lowercase (e.g. "tool")
|
||||
items: PortableItem[];
|
||||
onImported: () => void;
|
||||
size?: "sm" | "md";
|
||||
}) {
|
||||
const [exportOpen, setExportOpen] = useState(false);
|
||||
const [sel, setSel] = useState<Set<string>>(new Set());
|
||||
const [downloading, setDownloading] = useState(false);
|
||||
const [importing, setImporting] = useState(false);
|
||||
const [report, setReport] = useState<ImportReport | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const fileRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const btnCls = "btn btn-secondary " + (size === "sm" ? "btn-sm" : "");
|
||||
const plural = `${typeLabel}s`;
|
||||
|
||||
const allSelected = items.length > 0 && sel.size === items.length;
|
||||
const someSelected = sel.size > 0 && !allSelected;
|
||||
|
||||
function openExport() {
|
||||
setSel(new Set(items.map((i) => i.id))); // default to "select all"
|
||||
setExportOpen(true);
|
||||
}
|
||||
function toggle(id: string) {
|
||||
setSel((prev) => { const n = new Set(prev); n.has(id) ? n.delete(id) : n.add(id); return n; });
|
||||
}
|
||||
function toggleAll() {
|
||||
setSel(allSelected ? new Set() : new Set(items.map((i) => i.id)));
|
||||
}
|
||||
|
||||
async function doDownload() {
|
||||
if (!project || sel.size === 0) return;
|
||||
setDownloading(true);
|
||||
try {
|
||||
const ids = items.filter((i) => sel.has(i.id)).map((i) => i.id); // preserve list order
|
||||
const bundle = await api.exportBundle(project.id, type, ids);
|
||||
const stamp = new Date().toISOString().slice(0, 10).replace(/-/g, "");
|
||||
const base = (project.slug || project.name || "forge").toString().toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "") || "forge";
|
||||
downloadJson(`${base}-${plural}-${stamp}.json`, bundle);
|
||||
setExportOpen(false);
|
||||
} catch (e: any) {
|
||||
setError(String(e?.message || e));
|
||||
} finally {
|
||||
setDownloading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function onFile(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
const file = e.target.files?.[0];
|
||||
e.target.value = ""; // allow re-selecting the same file
|
||||
if (!file || !project) return;
|
||||
setImporting(true);
|
||||
setError(null);
|
||||
setReport(null);
|
||||
try {
|
||||
const text = await file.text();
|
||||
let bundle: unknown;
|
||||
try {
|
||||
bundle = JSON.parse(text);
|
||||
} catch {
|
||||
throw new Error("That file isn't valid JSON. Choose a bundle exported from Forge.");
|
||||
}
|
||||
const r = await api.importBundle(project.id, type, bundle);
|
||||
setReport(r);
|
||||
onImported();
|
||||
} catch (e: any) {
|
||||
setError(String(e?.message || e));
|
||||
} finally {
|
||||
setImporting(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<button className={btnCls} onClick={openExport} disabled={!project || items.length === 0} title={items.length === 0 ? `No ${plural} to export` : `Export ${plural} to a file`}>
|
||||
<Icon name="download" size={14} />Export
|
||||
</button>
|
||||
<button className={btnCls} onClick={() => fileRef.current?.click()} disabled={!project || importing} title={`Import ${plural} from a file`}>
|
||||
<Icon name={importing ? "refresh" : "upload"} size={14} style={importing ? { animation: "spin 1s linear infinite" } : undefined} />
|
||||
{importing ? "Importing…" : "Import"}
|
||||
</button>
|
||||
<input ref={fileRef} type="file" accept=".json,application/json" style={{ display: "none" }} onChange={onFile} />
|
||||
|
||||
{/* Export picker: choose which rows go into the bundle (defaults to all). */}
|
||||
<Modal
|
||||
open={exportOpen}
|
||||
onClose={() => setExportOpen(false)}
|
||||
title={`Export ${plural}`}
|
||||
width={520}
|
||||
footer={
|
||||
<>
|
||||
<button className="btn btn-secondary btn-sm" onClick={() => setExportOpen(false)}>Cancel</button>
|
||||
<button className="btn btn-primary btn-sm" onClick={doDownload} disabled={sel.size === 0 || downloading}>
|
||||
<Icon name={downloading ? "refresh" : "download"} size={14} style={downloading ? { animation: "spin 1s linear infinite" } : undefined} />
|
||||
{downloading ? "Preparing…" : `Download ${sel.size} ${sel.size === 1 ? typeLabel : plural}`}
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="col gap2">
|
||||
<button className="row gap2" onClick={toggleAll} style={{ alignItems: "center", background: "none", border: "none", cursor: "pointer", padding: "4px 2px", width: "100%", textAlign: "left" }}>
|
||||
<Check checked={allSelected} indeterminate={someSelected} />
|
||||
<span className="t-body-sm" style={{ fontWeight: 600 }}>Select all</span>
|
||||
<span className="fg-2 t-caption" style={{ marginLeft: "auto" }}>{sel.size}/{items.length} selected</span>
|
||||
</button>
|
||||
<div className="divider" />
|
||||
<div className="col" style={{ gap: 1, maxHeight: 380, overflowY: "auto" }}>
|
||||
{items.map((it) => {
|
||||
const on = sel.has(it.id);
|
||||
return (
|
||||
<button key={it.id} className="row gap2" onClick={() => toggle(it.id)}
|
||||
style={{ alignItems: "center", background: on ? "var(--bg-3)" : "none", border: "none", cursor: "pointer", padding: "8px 8px", borderRadius: 7, width: "100%", textAlign: "left" }}>
|
||||
<Check checked={on} />
|
||||
<div className="grow" style={{ minWidth: 0 }}>
|
||||
<div className="mono-sm truncate" style={{ fontWeight: 600 }}>{it.name}</div>
|
||||
{it.sub && <div className="fg-2 t-caption truncate">{it.sub}</div>}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
{/* Import result. */}
|
||||
<Modal
|
||||
open={!!report || !!error}
|
||||
onClose={() => { setReport(null); setError(null); }}
|
||||
title={error ? "Import failed" : "Import complete"}
|
||||
width={520}
|
||||
footer={<button className="btn btn-primary btn-sm" onClick={() => { setReport(null); setError(null); }}>Done</button>}
|
||||
>
|
||||
{error ? (
|
||||
<div className="t-body-sm" style={{ color: "var(--err)", overflowWrap: "anywhere" }}>{error}</div>
|
||||
) : report ? (
|
||||
<div className="col gap3">
|
||||
<div className="t-body-sm">
|
||||
Imported <b>{report.imported}</b> {report.imported === 1 ? typeLabel : plural}
|
||||
{report.skipped > 0 && <> · skipped <b>{report.skipped}</b></>}
|
||||
{(report.toolsets_imported ?? 0) > 0 && <> · <b>{report.toolsets_imported}</b> tool set{report.toolsets_imported === 1 ? "" : "s"}</>} into <b>{project?.name}</b>.
|
||||
</div>
|
||||
{report.items.some((i) => i.renamed) && (
|
||||
<div className="card" style={{ padding: 10 }}>
|
||||
<div className="t-micro" style={{ marginBottom: 6 }}>Renamed to avoid clashes</div>
|
||||
<div className="col gap1">
|
||||
{report.items.filter((i) => i.renamed).map((i, n) => (
|
||||
<div key={n} className="mono-sm fg-1 truncate">{i.original_name} → {i.name}</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{report.warnings.length > 0 && (
|
||||
<div className="card" style={{ padding: 10, borderColor: "var(--warn)" }}>
|
||||
<div className="t-micro" style={{ marginBottom: 6, color: "var(--warn)" }}>Heads up</div>
|
||||
<div className="col gap1">
|
||||
{report.warnings.map((w, n) => (
|
||||
<div key={n} className="t-caption fg-1" style={{ overflowWrap: "anywhere" }}>{w}</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,278 @@
|
||||
"use client";
|
||||
/* Login / register screen + AuthGate.
|
||||
|
||||
AuthGate calls /v1/auth/me on mount: in the default (auth-not-required) mode the
|
||||
backend returns the seeded owner, so the gate passes straight through and the console
|
||||
works as before. When FORGE_AUTH_REQUIRED=true, an unauthenticated /me returns 401 and
|
||||
the gate shows this screen. */
|
||||
import { ReactNode, useCallback, useEffect, useState } from "react";
|
||||
import { api, clearTokens, setTokens, UNAUTHORIZED_EVENT } from "@/lib/api";
|
||||
import type { MeResult, MyConnection, Project } from "@/lib/api";
|
||||
|
||||
function AcceptInviteScreen({ token, onAuthed, onCancel }: { token: string; onAuthed: () => void; onCancel: () => void }) {
|
||||
const [info, setInfo] = useState<{ email: string; role: string } | null>(null);
|
||||
const [loadErr, setLoadErr] = useState<string | null>(null);
|
||||
const [password, setPassword] = useState("");
|
||||
const [confirm, setConfirm] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
api.inviteInfo(token)
|
||||
.then(setInfo)
|
||||
.catch(() => setLoadErr("This invite link is invalid or has expired. Ask an admin to send a new one."));
|
||||
}, [token]);
|
||||
|
||||
async function submit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (password !== confirm) { setError("Passwords don't match."); return; }
|
||||
setBusy(true); setError(null);
|
||||
try {
|
||||
const res = await api.acceptInvite(token, password);
|
||||
setTokens(res.access_token, res.refresh_token);
|
||||
onAuthed();
|
||||
} catch {
|
||||
setError("Could not set your password. The link may have expired (minimum 8 characters).");
|
||||
} finally { setBusy(false); }
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ display: "flex", height: "100vh", alignItems: "center", justifyContent: "center", background: "var(--bg-0)" }}>
|
||||
<form onSubmit={submit} className="card" style={{ width: 380, padding: 28, boxShadow: "var(--sh-pop)" }}>
|
||||
<div className="t-display" style={{ marginBottom: 4 }}>Forge</div>
|
||||
{loadErr ? (
|
||||
<>
|
||||
<div className="t-caption" style={{ color: "var(--danger, #d33)", margin: "12px 0 16px" }}>{loadErr}</div>
|
||||
<button type="button" className="btn btn-secondary" style={{ width: "100%", justifyContent: "center" }} onClick={onCancel}>Go to sign in</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="fg-1" style={{ marginBottom: 20 }}>
|
||||
{info ? <>Set a password for <b>{info.email}</b> to join as a <b>{info.role}</b>.</> : "Loading your invite…"}
|
||||
</div>
|
||||
<label className="col gap1" style={{ marginBottom: 12 }}>
|
||||
<span className="t-micro">New password</span>
|
||||
<input className="input" type="password" required minLength={8} value={password} onChange={(e) => setPassword(e.target.value)} placeholder="At least 8 characters" disabled={!info} />
|
||||
</label>
|
||||
<label className="col gap1" style={{ marginBottom: 16 }}>
|
||||
<span className="t-micro">Confirm password</span>
|
||||
<input className="input" type="password" required minLength={8} value={confirm} onChange={(e) => setConfirm(e.target.value)} disabled={!info} />
|
||||
</label>
|
||||
{error && <div className="t-caption" style={{ color: "var(--danger, #d33)", marginBottom: 12 }}>{error}</div>}
|
||||
<button className="btn btn-primary" type="submit" disabled={busy || !info} style={{ width: "100%", justifyContent: "center" }}>
|
||||
{busy ? "…" : "Set password & continue"}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LoginScreen({ onAuthed }: { onAuthed: () => void }) {
|
||||
const [mode, setMode] = useState<"login" | "register">("login");
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [workspace, setWorkspace] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
async function submit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = mode === "login"
|
||||
? await api.login(email.trim(), password)
|
||||
: await api.register(email.trim(), password, workspace.trim() || undefined);
|
||||
setTokens(res.access_token, res.refresh_token);
|
||||
onAuthed();
|
||||
} catch (err: any) {
|
||||
setError(mode === "login" ? "Invalid email or password." : "Could not create the account (email may already exist, or password is too short).");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ display: "flex", height: "100vh", alignItems: "center", justifyContent: "center", background: "var(--bg-0)" }}>
|
||||
<form onSubmit={submit} className="card" style={{ width: 380, padding: 28, boxShadow: "var(--sh-pop)" }}>
|
||||
<div className="t-display" style={{ marginBottom: 4 }}>Forge</div>
|
||||
<div className="fg-1" style={{ marginBottom: 20 }}>
|
||||
{mode === "login" ? "Sign in to your workspace" : "Create your workspace"}
|
||||
</div>
|
||||
{mode === "register" && (
|
||||
<label className="col gap1" style={{ marginBottom: 12 }}>
|
||||
<span className="t-micro">Workspace name</span>
|
||||
<input className="input" value={workspace} onChange={(e) => setWorkspace(e.target.value)} placeholder="Acme Inc" />
|
||||
</label>
|
||||
)}
|
||||
<label className="col gap1" style={{ marginBottom: 12 }}>
|
||||
<span className="t-micro">Email</span>
|
||||
<input className="input" type="email" required value={email} onChange={(e) => setEmail(e.target.value)} placeholder="you@company.com" />
|
||||
</label>
|
||||
<label className="col gap1" style={{ marginBottom: 16 }}>
|
||||
<span className="t-micro">Password</span>
|
||||
<input className="input" type="password" required minLength={8} value={password} onChange={(e) => setPassword(e.target.value)} placeholder={mode === "register" ? "At least 8 characters" : ""} />
|
||||
</label>
|
||||
{error && <div className="t-caption" style={{ color: "var(--danger, #d33)", marginBottom: 12 }}>{error}</div>}
|
||||
<button className="btn btn-primary" type="submit" disabled={busy} style={{ width: "100%", justifyContent: "center", marginBottom: 12 }}>
|
||||
{busy ? "…" : mode === "login" ? "Sign in" : "Create workspace"}
|
||||
</button>
|
||||
<button type="button" className="btn btn-ghost btn-sm" style={{ width: "100%", justifyContent: "center" }}
|
||||
onClick={() => { setError(null); setMode(mode === "login" ? "register" : "login"); }}>
|
||||
{mode === "login" ? "Need an account? Create a workspace" : "Already have an account? Sign in"}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function AuthGate({ children }: { children: ReactNode }) {
|
||||
const [state, setState] = useState<"loading" | "authed" | "login">("loading");
|
||||
const [me, setMe] = useState<MeResult | null>(null);
|
||||
const [invite, setInvite] = useState<string | null>(null);
|
||||
// An invite link (?invite=<token>) takes over the gate so a new teammate can set their
|
||||
// password even if there's a stale session in this browser.
|
||||
const clearInviteParam = useCallback(() => {
|
||||
if (typeof window !== "undefined") window.history.replaceState({}, "", window.location.pathname);
|
||||
setInvite(null);
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined") return;
|
||||
const t = new URLSearchParams(window.location.search).get("invite");
|
||||
if (t) setInvite(t);
|
||||
}, []);
|
||||
const check = useCallback(() => {
|
||||
api.me()
|
||||
.then((m) => { setMe(m); setState("authed"); })
|
||||
.catch((e: any) => {
|
||||
// Fail OPEN: only an explicit 401 means auth is enforced and we must log in.
|
||||
// A 404/network error (e.g. an older backend without /auth/me, or auth disabled)
|
||||
// must NOT lock the user out of the console.
|
||||
const is401 = typeof e?.message === "string" && e.message.startsWith("401");
|
||||
setState(is401 ? "login" : "authed");
|
||||
});
|
||||
}, []);
|
||||
useEffect(() => { check(); }, [check]);
|
||||
useEffect(() => {
|
||||
const h = () => setState("login");
|
||||
window.addEventListener(UNAUTHORIZED_EVENT, h);
|
||||
return () => window.removeEventListener(UNAUTHORIZED_EVENT, h);
|
||||
}, []);
|
||||
|
||||
if (invite)
|
||||
return <AcceptInviteScreen token={invite} onAuthed={() => { clearInviteParam(); check(); }} onCancel={() => { clearInviteParam(); setState("login"); }} />;
|
||||
if (state === "loading")
|
||||
return <div style={{ display: "flex", height: "100vh", alignItems: "center", justifyContent: "center" }} className="fg-2">Loading…</div>;
|
||||
if (state === "login") return <LoginScreen onAuthed={() => check()} />;
|
||||
// MCP-only users (connector role) get just their token page, not the full console.
|
||||
if (me?.role === "connector") return <ConnectorHome me={me} />;
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
|
||||
/* Minimal console for an MCP-only (connector) user: pick a project, generate a personal access
|
||||
token, copy the endpoint, and connect any per-user credentials the project needs - no
|
||||
projects/tools/settings management. */
|
||||
function ConnectorHome({ me }: { me: MeResult }) {
|
||||
const [projects, setProjects] = useState<Project[]>([]);
|
||||
useEffect(() => { api.listProjects().then(setProjects).catch(() => {}); }, []);
|
||||
|
||||
return (
|
||||
<div style={{ maxWidth: 640, margin: "48px auto", padding: "0 20px", fontFamily: "var(--font-ui)" }}>
|
||||
<div className="row spread" style={{ marginBottom: 14, alignItems: "center" }}>
|
||||
<div className="t-display" style={{ fontSize: 20 }}>Your MCP access</div>
|
||||
<button className="btn btn-secondary btn-sm" onClick={() => { clearTokens(); window.location.reload(); }}>Sign out</button>
|
||||
</div>
|
||||
<div className="fg-1" style={{ marginBottom: 20 }}>
|
||||
Signed in as <b>{me.email}</b>. Generate a personal token for a project and paste it into your MCP
|
||||
client (Claude, Cursor, …) as <span className="mono-sm">Authorization: Bearer <token></span>.
|
||||
If a project needs you to connect your own accounts, set those below too.
|
||||
</div>
|
||||
{projects.length === 0 && <div className="fg-2 t-caption">No projects available yet.</div>}
|
||||
<div className="col gap3">
|
||||
{projects.map((p) => <ProjectConnector key={p.id} project={p} />)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* One project card on the connector home: MCP endpoint + PAT generation + any per-user
|
||||
("external") credentials this project requires the user to connect for on-behalf-of tool calls. */
|
||||
function ProjectConnector({ project }: { project: Project }) {
|
||||
const [token, setToken] = useState("");
|
||||
const [aps, setAps] = useState<MyConnection[]>([]);
|
||||
const origin = typeof window !== "undefined" ? window.location.origin : "";
|
||||
const box: React.CSSProperties = { background: "var(--bg-2)", padding: 8, borderRadius: 6, overflowX: "auto", margin: "4px 0" };
|
||||
useEffect(() => {
|
||||
api.listMyConnections(project.id).then(setAps).catch(() => {});
|
||||
}, [project.id]);
|
||||
|
||||
async function gen() { const t = await api.createMcpToken(project.id, {}); setToken(t.token || ""); }
|
||||
|
||||
return (
|
||||
<div className="card" style={{ padding: 16 }}>
|
||||
<div className="t-h3" style={{ marginBottom: 6 }}>{project.name}</div>
|
||||
<div className="t-caption fg-2">MCP endpoint</div>
|
||||
<pre className="mono-sm" style={box}>{`${origin}/api/forge/v1/mcp/${project.id}`}</pre>
|
||||
{token ? (
|
||||
<>
|
||||
<div className="t-caption fg-2" style={{ marginTop: 6 }}>Access token — copy now, shown once:</div>
|
||||
<pre className="mono-sm" style={box}>{token}</pre>
|
||||
</>
|
||||
) : (
|
||||
<button className="btn btn-primary btn-sm" style={{ marginTop: 8 }} onClick={gen}>Generate access token</button>
|
||||
)}
|
||||
{aps.length > 0 && (
|
||||
<div style={{ marginTop: 16, paddingTop: 14, borderTop: "1px solid var(--line)" }}>
|
||||
<div className="t-body-sm" style={{ fontWeight: 600, marginBottom: 2 }}>Connect your accounts</div>
|
||||
<div className="fg-2 t-caption" style={{ marginBottom: 8 }}>
|
||||
These tools call downstream systems <b>as you</b>. Paste your own token for each — stored per-user and encrypted, used only for your calls.
|
||||
</div>
|
||||
<div className="col gap2">
|
||||
{aps.map((ap) => <MyConnectionRow key={ap.id} project={project} ap={ap} />)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* The current user's own downstream token for one per-user provider (see auth.tsx PerUserConnect /
|
||||
deploy.tsx MyConnectionCard - same 3 endpoints). Keyed server-side by the caller's user id. */
|
||||
function MyConnectionRow({ project, ap }: { project: Project; ap: MyConnection }) {
|
||||
const [status, setStatus] = useState<{ connected: boolean } | null>(null);
|
||||
const [token, setToken] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
const refresh = useCallback(() => { api.getMyConnection(project.id, ap.id).then(setStatus).catch(() => setStatus(null)); }, [project.id, ap.id]);
|
||||
useEffect(() => { refresh(); }, [refresh]);
|
||||
|
||||
async function save() {
|
||||
setErr(null); setBusy(true);
|
||||
try {
|
||||
const res = await api.setMyConnection(project.id, ap.id, token.trim());
|
||||
if (!res.ok) throw new Error("Could not save token.");
|
||||
setToken(""); refresh();
|
||||
} catch (e: any) { setErr(e?.message || String(e)); } finally { setBusy(false); }
|
||||
}
|
||||
async function clear() { try { await api.clearMyConnection(project.id, ap.id); } catch { /* best-effort */ } refresh(); }
|
||||
|
||||
return (
|
||||
<div className="card" style={{ padding: 10, background: "var(--bg-2)" }}>
|
||||
<div className="row spread" style={{ marginBottom: 6, alignItems: "center" }}>
|
||||
<div className="row gap2" style={{ alignItems: "center", minWidth: 0 }}>
|
||||
<span className="mono-sm truncate">{ap.name}</span>
|
||||
<span className={"pill " + (status?.connected ? "pill-ok" : "pill-muted")} style={{ height: 16 }}>{status?.connected ? "connected" : "not connected"}</span>
|
||||
</div>
|
||||
{status?.connected && <button className="btn btn-ghost btn-sm" onClick={clear}>Clear</button>}
|
||||
</div>
|
||||
<div className="row gap2">
|
||||
<input className="input mono" type="password" value={token} onChange={(e) => setToken(e.target.value)} placeholder="paste your token…" style={{ flex: 1 }} />
|
||||
<button className="btn btn-primary btn-sm" onClick={save} disabled={busy || !token.trim()}>{busy ? "Saving…" : "Save"}</button>
|
||||
</div>
|
||||
{err && <div className="t-caption" style={{ color: "var(--err)", marginTop: 6 }}>{err}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
"use client";
|
||||
/* Renders an assistant reply as GitHub-Flavored Markdown (Feature 1 - structured responses).
|
||||
Safe by default: react-markdown does NOT render raw HTML (no rehype-raw), so agent output
|
||||
cannot inject markup. Visual styling lives in the `.md` block in app/globals.css and uses
|
||||
the app's design tokens, so it adapts to light/dark automatically. Memoized so finalized
|
||||
messages don't re-parse while a newer message is still streaming. */
|
||||
import { memo } from "react";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
|
||||
export const Markdown = memo(function Markdown({ children }: { children: string }) {
|
||||
return (
|
||||
<div className="md">
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
components={{
|
||||
a: ({ node, ...props }: any) => (
|
||||
<a {...props} target="_blank" rel="noopener noreferrer" />
|
||||
),
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,317 @@
|
||||
"use client";
|
||||
/* Forge shared UI primitives - ported from the design handoff (primitives.jsx). */
|
||||
import { CSSProperties, ReactNode, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { Icon } from "./icons";
|
||||
|
||||
/* ---------------- Sparkline ---------------- */
|
||||
export function Sparkline({
|
||||
data, w = 80, h = 24, color = "var(--accent)", fill = true, strokeW = 1.5,
|
||||
}: { data: number[]; w?: number; h?: number; color?: string; fill?: boolean; strokeW?: number }) {
|
||||
const max = Math.max(...data, 1), min = Math.min(...data, 0);
|
||||
const rng = max - min || 1;
|
||||
const pts = data.map((v, i) => [(i / (data.length - 1)) * w, h - 2 - ((v - min) / rng) * (h - 4)]);
|
||||
const d = pts.map((p, i) => (i ? "L" : "M") + p[0].toFixed(1) + " " + p[1].toFixed(1)).join(" ");
|
||||
const area = d + ` L${w} ${h} L0 ${h} Z`;
|
||||
const gid = useMemo(() => "sg" + Math.random().toString(36).slice(2, 7), []);
|
||||
return (
|
||||
<svg width={w} height={h} style={{ display: "block", overflow: "visible" }}>
|
||||
{fill && (
|
||||
<defs>
|
||||
<linearGradient id={gid} x1={0} y1={0} x2={0} y2={1}>
|
||||
<stop offset={0} stopColor={color} stopOpacity={0.22} />
|
||||
<stop offset={1} stopColor={color} stopOpacity={0} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
)}
|
||||
{fill && <path d={area} fill={`url(#${gid})`} stroke="none" />}
|
||||
<path d={d} fill="none" stroke={color} strokeWidth={strokeW} strokeLinejoin="round" strokeLinecap="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------------- Donut ---------------- */
|
||||
export function Donut({
|
||||
segments, size = 120, thickness = 16, center,
|
||||
}: { segments: { value: number; color: string }[]; size?: number; thickness?: number; center?: ReactNode }) {
|
||||
const total = segments.reduce((a, s) => a + s.value, 0) || 1;
|
||||
const R = (size - thickness) / 2, C = 2 * Math.PI * R;
|
||||
let off = 0;
|
||||
return (
|
||||
<div style={{ position: "relative", width: size, height: size }}>
|
||||
<svg width={size} height={size} style={{ transform: "rotate(-90deg)" }}>
|
||||
<circle cx={size / 2} cy={size / 2} r={R} fill="none" stroke="var(--bg-3)" strokeWidth={thickness} />
|
||||
{segments.map((s, i) => {
|
||||
const len = (s.value / total) * C;
|
||||
const el = (
|
||||
<circle key={i} cx={size / 2} cy={size / 2} r={R} fill="none" stroke={s.color}
|
||||
strokeWidth={thickness} strokeDasharray={`${len} ${C - len}`} strokeDashoffset={-off}
|
||||
style={{ transition: "stroke-dasharray .6s var(--ease)" }} />
|
||||
);
|
||||
off += len;
|
||||
return el;
|
||||
})}
|
||||
</svg>
|
||||
{center && (
|
||||
<div style={{ position: "absolute", inset: 0, display: "flex", flexDirection: "column", alignItems: "center", justifyContent: "center" }}>
|
||||
{center}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------------- TokenMeter (signature) ---------------- */
|
||||
export function TokenMeter({
|
||||
raw, projected, max, compact = false, animateKey,
|
||||
}: { raw: number; projected: number; max?: number; compact?: boolean; animateKey?: any }) {
|
||||
const cap = max || Math.max(raw, projected, 1) * 1.1;
|
||||
const [shown, setShown] = useState<"raw" | "proj">("raw");
|
||||
useEffect(() => {
|
||||
setShown("raw");
|
||||
const t = setTimeout(() => setShown("proj"), 420);
|
||||
return () => clearTimeout(t);
|
||||
}, [animateKey]);
|
||||
const pctRaw = Math.min(100, (raw / cap) * 100);
|
||||
const pctProj = Math.min(100, (projected / cap) * 100);
|
||||
const saved = raw > 0 ? Math.round((1 - projected / raw) * 100) : 0;
|
||||
if (compact) {
|
||||
return (
|
||||
<div className="row gap2" style={{ fontSize: 11 }}>
|
||||
<div style={{ position: "relative", width: 64, height: 6, borderRadius: 999, background: "var(--bg-3)", overflow: "hidden" }}>
|
||||
<i style={{ position: "absolute", inset: 0, width: pctProj + "%", background: "var(--signal)", borderRadius: 999, transition: "width .5s var(--ease)" }} />
|
||||
</div>
|
||||
<span className="mono-sm" style={{ color: "var(--fg-1)" }}>{projected}</span>
|
||||
{saved > 0 && <span className="pill pill-ok" style={{ height: 16 }}>{"−" + saved + "%"}</span>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="col gap2">
|
||||
<div className="row spread" style={{ fontSize: 11 }}>
|
||||
<span className="t-micro">Context cost</span>
|
||||
<span className="mono-sm" style={{ color: shown === "proj" ? "var(--signal)" : "var(--fg-1)" }}>
|
||||
{(shown === "proj" ? projected : raw).toLocaleString()} tok
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ position: "relative", height: 10, borderRadius: 999, background: "var(--bg-3)", overflow: "hidden" }}>
|
||||
<div style={{ position: "absolute", top: 0, bottom: 0, left: 0, width: pctRaw + "%", background: "var(--line-strong)", borderRadius: 999 }} />
|
||||
<div style={{ position: "absolute", top: 0, bottom: 0, left: 0, width: (shown === "proj" ? pctProj : pctRaw) + "%", background: shown === "proj" ? "var(--signal)" : "var(--accent)", borderRadius: 999, transition: "width .55s var(--ease), background .3s", boxShadow: shown === "proj" ? "0 0 12px var(--signal-glow)" : "none" }} />
|
||||
</div>
|
||||
<div className="row spread" style={{ fontSize: 11, color: "var(--fg-2)" }}>
|
||||
<span>raw <b className="mono-sm" style={{ color: "var(--fg-1)" }}>{raw.toLocaleString()}</b></span>
|
||||
<span>→ projected <b className="mono-sm" style={{ color: "var(--signal)" }}>{projected.toLocaleString()}</b></span>
|
||||
{saved > 0 && <span className="pill pill-ok"><Icon name="bolt" size={11} />{saved + "% saved"}</span>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------------- StatusPill ---------------- */
|
||||
export function StatusPill({ status, label }: { status: string; label?: string }) {
|
||||
// Minimal, professional statuses: neutral pills with no colour dots. Only problem
|
||||
// states (error / fail / interrupted) keep a subtle tint so they still stand out.
|
||||
const map: Record<string, [string, string]> = {
|
||||
done: ["pill-muted", "Done"], pass: ["pill-muted", "Passing"], active: ["pill-muted", "Active"], ready: ["pill-muted", "Ready"],
|
||||
running: ["pill-muted", "Running"], processing: ["pill-muted", "Processing"], draft: ["pill-muted", "Draft"], untested: ["pill-muted", "Untested"],
|
||||
error: ["pill-err", "Error"], fail: ["pill-err", "Failing"], interrupted: ["pill-warn", "Interrupted"],
|
||||
};
|
||||
const [cls, def] = map[status] || ["pill-muted", status];
|
||||
return <span className={"pill " + cls}>{label || def}</span>;
|
||||
}
|
||||
|
||||
/* ---------------- Avatar ---------------- */
|
||||
export function Avatar({ name, size = 26, color }: { name: string; size?: number; color?: string }) {
|
||||
const init = (name || "?").split(/\s|_|-/).filter(Boolean).slice(0, 2).map((s) => s[0]).join("").toUpperCase();
|
||||
const hue = useMemo(() => { let h = 0; for (const c of name || "") h = (h * 31 + c.charCodeAt(0)) % 360; return h; }, [name]);
|
||||
return (
|
||||
<div style={{ width: size, height: size, borderRadius: "50%", flex: "none", background: color || `oklch(0.58 0.045 ${hue})`, color: "#fff", display: "flex", alignItems: "center", justifyContent: "center", fontSize: size * 0.4, fontWeight: 700, fontFamily: "var(--font-display)", letterSpacing: "-.02em" }}>
|
||||
{init}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------------- Toggle ---------------- */
|
||||
export function Toggle({ on, onChange, signal }: { on: boolean; onChange?: (v: boolean) => void; signal?: boolean }) {
|
||||
return (
|
||||
<button className={"toggle" + (on ? " on" : "") + (signal ? " signal" : "")}
|
||||
onClick={(e) => { e.stopPropagation(); onChange && onChange(!on); }} aria-pressed={on} />
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------------- Segmented ---------------- */
|
||||
export function Segmented({ options, value, onChange }: { options: (string | { value: string; label: string; icon?: string })[]; value: string; onChange: (v: string) => void }) {
|
||||
return (
|
||||
<div className="segmented">
|
||||
{options.map((o) => {
|
||||
const val = typeof o === "string" ? o : o.value;
|
||||
const lab = typeof o === "string" ? o : o.label;
|
||||
return (
|
||||
<button key={val} className={value === val ? "active" : ""} onClick={() => onChange(val)}>
|
||||
{typeof o === "object" && o.icon && <Icon name={o.icon} size={13} style={{ marginRight: 5, verticalAlign: "-2px" }} />}
|
||||
{lab}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------------- Modal ---------------- */
|
||||
export function Modal({ open, onClose, children, width = 520, title, footer }: { open: boolean; onClose: () => void; children: ReactNode; width?: number; title?: string; footer?: ReactNode }) {
|
||||
// Portal to <body> so the fixed overlay escapes any transformed ancestor (e.g. the
|
||||
// `.fade-up` screen wrapper). A transformed ancestor becomes the containing block for
|
||||
// `position:fixed`, which otherwise traps the modal inside the content column and clips
|
||||
// its header. `mounted` avoids touching `document` during SSR / first hydration.
|
||||
const [mounted, setMounted] = useState(false);
|
||||
useEffect(() => setMounted(true), []);
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const h = (e: KeyboardEvent) => e.key === "Escape" && onClose();
|
||||
window.addEventListener("keydown", h);
|
||||
return () => window.removeEventListener("keydown", h);
|
||||
}, [open, onClose]);
|
||||
if (!open || !mounted) return null;
|
||||
return createPortal(
|
||||
<div className="fade-in" style={{ position: "fixed", inset: 0, zIndex: 8000, background: "rgba(8,10,14,.5)", backdropFilter: "blur(3px)", display: "flex", alignItems: "center", justifyContent: "center", padding: 24 }} onMouseDown={onClose}>
|
||||
<div className="card fade-up" style={{ width, maxWidth: "94vw", maxHeight: "88vh", boxShadow: "var(--sh-pop)", display: "flex", flexDirection: "column" }} onMouseDown={(e) => e.stopPropagation()}>
|
||||
{title && (
|
||||
<div className="row spread" style={{ padding: "14px 18px", borderBottom: "1px solid var(--line)" }}>
|
||||
<div className="t-h1">{title}</div>
|
||||
<button className="iconbtn" onClick={onClose}><Icon name="x" size={17} /></button>
|
||||
</div>
|
||||
)}
|
||||
<div className="scroll-y" style={{ padding: 18, flex: 1 }}>{children}</div>
|
||||
{footer && <div className="row gap2" style={{ padding: "12px 18px", borderTop: "1px solid var(--line)", justifyContent: "flex-end" }}>{footer}</div>}
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------------- Drawer ---------------- */
|
||||
export function Drawer({ open, onClose, children, width = 440, title, sub }: { open: boolean; onClose: () => void; children: ReactNode; width?: number; title?: string; sub?: string }) {
|
||||
return (
|
||||
<div style={{ position: "fixed", inset: 0, zIndex: 7000, pointerEvents: open ? "auto" : "none" }}>
|
||||
<div onClick={onClose} style={{ position: "absolute", inset: 0, background: "rgba(8,10,14,.4)", opacity: open ? 1 : 0, transition: "opacity var(--dur)" }} />
|
||||
<div style={{ position: "absolute", top: 0, right: 0, bottom: 0, width, maxWidth: "94vw", background: "var(--bg-1)", borderLeft: "1px solid var(--line)", boxShadow: "var(--sh-pop)", transform: open ? "none" : "translateX(100%)", transition: "transform var(--dur-slow) var(--ease)", display: "flex", flexDirection: "column" }}>
|
||||
{title && (
|
||||
<div className="row spread" style={{ padding: "14px 18px", borderBottom: "1px solid var(--line)" }}>
|
||||
<div>
|
||||
<div className="t-h1">{title}</div>
|
||||
{sub && <div className="fg-2 t-caption" style={{ marginTop: 2 }}>{sub}</div>}
|
||||
</div>
|
||||
<button className="iconbtn" onClick={onClose}><Icon name="x" size={17} /></button>
|
||||
</div>
|
||||
)}
|
||||
<div className="scroll-y" style={{ flex: 1 }}>{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------------- EmptyState ---------------- */
|
||||
export function EmptyState({ icon, title, sub, action }: { icon: string; title: string; sub?: string; action?: ReactNode }) {
|
||||
return (
|
||||
<div className="col center" style={{ padding: "48px 24px", textAlign: "center", gap: 10 }}>
|
||||
<div style={{ width: 48, height: 48, borderRadius: 12, background: "var(--bg-3)", display: "flex", alignItems: "center", justifyContent: "center", color: "var(--fg-2)" }}>
|
||||
<Icon name={icon} size={24} />
|
||||
</div>
|
||||
<div className="t-h1">{title}</div>
|
||||
{sub && <div className="fg-2" style={{ maxWidth: 320 }}>{sub}</div>}
|
||||
{action}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------------- Field ---------------- */
|
||||
export function Field({ label, help, children, required }: { label?: string; help?: string; children: ReactNode; required?: boolean }) {
|
||||
return (
|
||||
<div className="col" style={{ marginBottom: 14 }}>
|
||||
{label && <label className="field-label">{label}{required && <span style={{ color: "var(--accent)", marginLeft: 3 }}>*</span>}</label>}
|
||||
{children}
|
||||
{help && <div className="field-help">{help}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------------- Tabs ---------------- */
|
||||
export function Tabs({ tabs, value, onChange, equal }: { tabs: (string | { value: string; label: string; count?: number })[]; value: string; onChange: (v: string) => void; equal?: boolean }) {
|
||||
return (
|
||||
<div className="row" style={{ gap: 2, borderBottom: "1px solid var(--line)" }}>
|
||||
{tabs.map((t) => {
|
||||
const val = typeof t === "string" ? t : t.value;
|
||||
const lab = typeof t === "string" ? t : t.label;
|
||||
const active = value === val;
|
||||
return (
|
||||
<button key={val} onClick={() => onChange(val)}
|
||||
style={{ flex: equal ? "1 1 0" : "0 0 auto", minWidth: 0, textAlign: "center", background: "none", border: "none", cursor: "pointer", padding: "9px 12px", fontSize: 13, fontWeight: 600, fontFamily: "var(--font-ui)", color: active ? "var(--fg-0)" : "var(--fg-2)", borderBottom: "2px solid " + (active ? "var(--accent)" : "transparent"), marginBottom: -1, transition: "color var(--dur-fast)" }}>
|
||||
{lab}
|
||||
{typeof t === "object" && t.count != null && <span className="badge" style={{ marginLeft: 6 }}>{t.count}</span>}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------------- CodeBlock ---------------- */
|
||||
export function CodeBlock({ code, copyable = true, maxHeight }: { code: string; lang?: string; copyable?: boolean; maxHeight?: number }) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
return (
|
||||
<div style={{ position: "relative", background: "var(--bg-0)", border: "1px solid var(--line)", borderRadius: "var(--r-md)", overflow: "hidden" }}>
|
||||
{copyable && (
|
||||
<button className="iconbtn" style={{ position: "absolute", top: 6, right: 6, zIndex: 2, background: "var(--bg-1)" }}
|
||||
onClick={() => { navigator.clipboard?.writeText(code); setCopied(true); setTimeout(() => setCopied(false), 1200); }}>
|
||||
<Icon name={copied ? "check" : "copy"} size={14} />
|
||||
</button>
|
||||
)}
|
||||
<pre className="mono no-scrollbar" style={{ margin: 0, padding: "12px 14px", overflow: "auto", maxHeight, color: "var(--fg-1)", fontSize: 12 }}>
|
||||
<code>{code}</code>
|
||||
</pre>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------------- Tile ---------------- */
|
||||
export function Tile({ icon, color = "var(--accent)", size = 36 }: { icon: string; color?: string; size?: number; glow?: boolean }) {
|
||||
// Bare icon - no background, no border (minimal look, applied app-wide).
|
||||
return (
|
||||
<div style={{ width: size, height: size, flex: "none", display: "flex", alignItems: "center", justifyContent: "center", color }}>
|
||||
<Icon name={icon} size={Math.round(size * 0.58)} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------------- Menu ---------------- */
|
||||
export function Menu({ trigger, items, align = "right" }: { trigger: ReactNode; items: { label?: string; icon?: string; onClick?: () => void; danger?: boolean; divider?: boolean }[]; align?: "left" | "right" }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const h = (e: MouseEvent) => { if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false); };
|
||||
window.addEventListener("mousedown", h);
|
||||
return () => window.removeEventListener("mousedown", h);
|
||||
}, [open]);
|
||||
const alignStyle: CSSProperties = align === "right" ? { right: 0 } : { left: 0 };
|
||||
return (
|
||||
<div ref={ref} style={{ position: "relative" }}>
|
||||
<div onClick={() => setOpen((o) => !o)}>{trigger}</div>
|
||||
{open && (
|
||||
<div className="card fade-in" style={{ position: "absolute", top: "100%", marginTop: 4, ...alignStyle, zIndex: 6000, minWidth: 168, padding: 4, boxShadow: "var(--sh-pop)" }}>
|
||||
{items.map((it, i) =>
|
||||
it.divider ? (
|
||||
<div key={i} className="divider" style={{ margin: "4px 0" }} />
|
||||
) : (
|
||||
<button key={i} onClick={() => { setOpen(false); it.onClick && it.onClick(); }}
|
||||
style={{ display: "flex", alignItems: "center", gap: 9, width: "100%", textAlign: "left", padding: "7px 9px", border: "none", background: "none", cursor: "pointer", borderRadius: 5, fontSize: 13, fontFamily: "var(--font-ui)", color: it.danger ? "var(--err)" : "var(--fg-1)" }}>
|
||||
{it.icon && <Icon name={it.icon} size={15} />}
|
||||
{it.label}
|
||||
</button>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
"use client";
|
||||
/* Agents: preset list + the Agent config (flavor · model · tools · middleware stack). */
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { Icon } from "../icons";
|
||||
import { StatusPill, Tile } from "../primitives";
|
||||
import { AgentConfig } from "../canvas/AgentConfig";
|
||||
import { VersionHistory } from "../version-history";
|
||||
import { ImportExport } from "../import-export";
|
||||
import { api, Agent, ComponentT, McpClientT, Tool, ToolSet } from "@/lib/api";
|
||||
|
||||
const NEW_AGENT_CONFIG = { flavor: "agent", model: "openai:gpt-4o-mini", system_prompt: "", tools: [], components: [], middleware: [] };
|
||||
|
||||
/* ============ AGENTS LIST ============ */
|
||||
export function AgentsScreen({ project, onOpen }: { project: any; onOpen: (a: Agent) => void }) {
|
||||
const [agents, setAgents] = useState<Agent[]>([]);
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const reload = useCallback(() => {
|
||||
if (!project?.id) return;
|
||||
api.listAgents(project.id).then((a) => { setAgents(a); setLoaded(true); }).catch(() => setLoaded(true));
|
||||
}, [project?.id]);
|
||||
useEffect(() => { reload(); }, [reload]);
|
||||
|
||||
async function create() {
|
||||
setBusy(true);
|
||||
try {
|
||||
const a = await api.createAgent(project.id, { name: "new_agent", config: NEW_AGENT_CONFIG });
|
||||
onOpen(a);
|
||||
} finally { setBusy(false); }
|
||||
}
|
||||
|
||||
const [deleting, setDeleting] = useState<string | null>(null);
|
||||
async function del(e: React.MouseEvent, a: Agent) {
|
||||
e.stopPropagation();
|
||||
if (!window.confirm(`Delete agent "${a.name}"? This cannot be undone.`)) return;
|
||||
setDeleting(a.id);
|
||||
try {
|
||||
setAgents((prev) => prev.filter((x) => x.id !== a.id)); // optimistic
|
||||
await api.deleteAgent(project.id, a.id);
|
||||
} catch {
|
||||
reload();
|
||||
} finally {
|
||||
setDeleting(null);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="scroll-y" style={{ flex: 1, padding: "24px 28px" }}>
|
||||
<div className="fade-up" style={{ maxWidth: 1600, margin: "0 auto" }}>
|
||||
<div className="row spread" style={{ marginBottom: 18 }}>
|
||||
<div>
|
||||
<div className="t-display">Agents</div>
|
||||
<div className="fg-1" style={{ marginTop: 3 }}>Reusable agent presets - model, tools, and a middleware stack. Drop them into workflows.</div>
|
||||
</div>
|
||||
<div className="row gap2">
|
||||
<ImportExport project={project} type="agent" typeLabel="agent" size="md" onImported={reload}
|
||||
items={agents.map((a) => ({ id: a.id, name: a.name, sub: `${a.config?.flavor || "agent"} · ${a.config?.model || "-"}` }))} />
|
||||
<button className="btn btn-primary" onClick={create} disabled={busy}><Icon name="plus" size={15} />{busy ? "Creating…" : "New agent"}</button>
|
||||
</div>
|
||||
</div>
|
||||
{loaded && agents.length === 0 ? (
|
||||
<div className="card col center" style={{ padding: 48, gap: 12, textAlign: "center" }}>
|
||||
<Tile icon="agents" color="var(--accent)" size={52} glow />
|
||||
<div className="t-h1">No agent presets yet</div>
|
||||
<div className="fg-1" style={{ maxWidth: 360 }}>Create a reusable agent with its own model, tools, and middleware stack.</div>
|
||||
<button className="btn btn-primary btn-lg" onClick={create} disabled={busy}><Icon name="plus" size={16} />New agent</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="col gap3">
|
||||
{agents.map((a) => {
|
||||
const c = a.config || {};
|
||||
const tools = (c.tools || []).length;
|
||||
const mw = (c.middleware || []).filter((m: any) => m.enabled !== false).length;
|
||||
return (
|
||||
<div key={a.id} className="card card-hover" style={{ padding: 14 }} onClick={() => onOpen(a)}>
|
||||
<div className="row gap3">
|
||||
<Tile icon={c.flavor === "deep_agent" ? "n_deepagent" : "n_agent"} color="var(--accent)" size={38} />
|
||||
<div className="grow">
|
||||
<div className="row gap2"><span className="t-h2 mono">{a.name}</span><span className="typechip">{c.flavor || "agent"}</span></div>
|
||||
<div className="fg-2 t-caption mono" style={{ marginTop: 3 }}>{c.model || "-"} · {tools} tools · {mw} middleware{a.created_by_email ? ` · by ${a.created_by_email}` : ""}</div>
|
||||
</div>
|
||||
<button className="iconbtn" title="Delete agent" disabled={deleting === a.id} onClick={(e) => del(e, a)}><Icon name="trash" size={15} /></button>
|
||||
<Icon name="chevright" size={16} style={{ color: "var(--fg-2)" }} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ============ AGENT CONFIG ============ */
|
||||
export function AgentConfigScreen({ project, agentId, onBack }: { project: any; agentId?: string; onBack: () => void }) {
|
||||
const [agent, setAgent] = useState<Agent | null>(null);
|
||||
const [config, setConfig] = useState<Record<string, any>>(NEW_AGENT_CONFIG);
|
||||
const [name, setName] = useState("");
|
||||
const [tools, setTools] = useState<Tool[]>([]);
|
||||
const [toolSets, setToolSets] = useState<ToolSet[]>([]);
|
||||
const [mcpServers, setMcpServers] = useState<McpClientT[]>([]);
|
||||
const [components, setComponents] = useState<ComponentT[]>([]);
|
||||
const [folders, setFolders] = useState<string[]>([]);
|
||||
const [kinds, setKinds] = useState<string[]>([]);
|
||||
const [save, setSave] = useState<"idle" | "saving" | "saved">("idle");
|
||||
const [reloadKey, setReloadKey] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (project?.id) api.listTools(project.id).then(setTools).catch(() => {});
|
||||
if (project?.id) api.listToolSets(project.id).then(setToolSets).catch(() => {});
|
||||
if (project?.id) api.listMcpClients(project.id).then(setMcpServers).catch(() => {});
|
||||
if (project?.id) api.listComponents(project.id).then(setComponents).catch(() => {});
|
||||
if (project?.id) api.listFolders(project.id).then(setFolders).catch(() => {});
|
||||
if (project?.id) api.listQaKinds(project.id).then(setKinds).catch(() => {});
|
||||
if (project?.id && agentId) api.getAgent(project.id, agentId).then((a) => { setAgent(a); setConfig(a.config || NEW_AGENT_CONFIG); setName(a.name); }).catch(() => {});
|
||||
}, [project?.id, agentId, reloadKey]);
|
||||
|
||||
async function persist() {
|
||||
if (!agent) return;
|
||||
setSave("saving");
|
||||
await api.updateAgent(project.id, agent.id, { name, config });
|
||||
setSave("saved");
|
||||
setTimeout(() => setSave("idle"), 1400);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="col" style={{ flex: 1, minHeight: 0 }}>
|
||||
<div className="row spread" style={{ padding: "12px 20px", borderBottom: "1px solid var(--line)", background: "var(--bg-1)" }}>
|
||||
<div className="row gap2">
|
||||
<button className="iconbtn" onClick={onBack}><Icon name="chevleft" size={18} /></button>
|
||||
<Tile icon={config.flavor === "deep_agent" ? "n_deepagent" : "n_agent"} color="var(--accent)" size={30} />
|
||||
<input className="input mono" style={{ width: 220 }} value={name} onChange={(e) => setName(e.target.value)} placeholder="agent_name" />
|
||||
{agent?.created_by_email && (
|
||||
<span className="fg-2 t-caption row gap1" title={`Created by ${agent.created_by_email}`}>
|
||||
<Icon name="user" size={13} />Created by {agent.created_by_email}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="row gap2">
|
||||
<VersionHistory entityType="agent" entityId={agent?.id} entityLabel={name} onRestored={() => setReloadKey((k) => k + 1)} />
|
||||
<button className="btn btn-primary btn-sm" onClick={persist} disabled={save === "saving"}>
|
||||
<Icon name={save === "saved" ? "check" : "save"} size={14} />{save === "saving" ? "Saving…" : save === "saved" ? "Saved" : "Save"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="row" style={{ flex: 1, minHeight: 0, alignItems: "stretch" }}>
|
||||
<div className="scroll-y grow" style={{ padding: 24 }}>
|
||||
<div style={{ maxWidth: 960, margin: "0 auto" }}>
|
||||
<AgentConfig config={config} onChange={setConfig} tools={tools} toolSets={toolSets} mcpServers={mcpServers} components={components} folders={folders} kinds={kinds} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="scroll-y" style={{ width: 300, flex: "none", borderLeft: "1px solid var(--line)", background: "var(--bg-1)", padding: 16 }}>
|
||||
<div className="t-micro" style={{ marginBottom: 10 }}>What the model sees</div>
|
||||
<div className="card" style={{ padding: 12, marginBottom: 12 }}>
|
||||
<div className="t-caption fg-2">System prompt</div>
|
||||
<div className="t-body-sm" style={{ marginTop: 4, whiteSpace: "pre-wrap" }}>{config.system_prompt || <span className="fg-2">- none -</span>}</div>
|
||||
</div>
|
||||
<div className="card" style={{ padding: 12 }}>
|
||||
<div className="t-caption fg-2">Compiled stack (execution order)</div>
|
||||
<div className="col gap1" style={{ marginTop: 6 }}>
|
||||
{(config.middleware || []).filter((m: any) => m.enabled !== false).map((m: any, i: number) => (
|
||||
<div key={i} className="row gap2"><span className="badge">{i + 1}</span><span className="mono-sm">{m.type}</span></div>
|
||||
))}
|
||||
{(config.middleware || []).filter((m: any) => m.enabled !== false).length === 0 && <div className="fg-2 t-caption">No middleware</div>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,489 @@
|
||||
"use client";
|
||||
/* Analytics: the project's observability dashboard. Time-series volume/cost/latency/token
|
||||
graphs, per-source & per-tool/model breakdowns, a latency distribution, the usage table,
|
||||
and quick links - all over a selectable date range. Charts are Recharts, themed from the
|
||||
app's CSS variables (resolved to concrete colors so gradients + dark mode work). */
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
Area, AreaChart, Bar, BarChart, CartesianGrid, Cell, Line, LineChart,
|
||||
Pie, PieChart, ResponsiveContainer, Tooltip, XAxis, YAxis,
|
||||
} from "recharts";
|
||||
import { Icon } from "../icons";
|
||||
import { Sparkline, StatusPill, EmptyState } from "../primitives";
|
||||
import { api, Analytics, ProjectCounts, StatRollup, Workflow } from "@/lib/api";
|
||||
import { fmtUSD } from "@/lib/data";
|
||||
|
||||
/* ---------------- formatters ---------------- */
|
||||
const fmtLatency = (ms: number) => (ms >= 1000 ? `${(ms / 1000).toFixed(ms >= 10000 ? 0 : 1)}s` : `${Math.round(ms)}ms`);
|
||||
const fmtCompact = (n: number) => {
|
||||
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
|
||||
if (n >= 1_000) return `${(n / 1_000).toFixed(1)}k`;
|
||||
return `${Math.round(n)}`;
|
||||
};
|
||||
const fmtInt = (n: number) => Math.round(n).toLocaleString();
|
||||
// "2026-07-24" -> "Jul 24"
|
||||
const fmtAxisDate = (iso: string) => {
|
||||
const d = new Date(iso + "T00:00:00");
|
||||
return d.toLocaleDateString(undefined, { month: "short", day: "numeric" });
|
||||
};
|
||||
|
||||
const SOURCE_LABEL: Record<string, string> = {
|
||||
playground: "Playground", api: "API", embed: "Embed", assistant: "Forge Assistant",
|
||||
channel_email: "Email", webhook: "Webhook", schedule: "Schedule", app_event: "App event", "-": "Other",
|
||||
};
|
||||
const srcLabel = (s: string) => SOURCE_LABEL[s] || s || "Other";
|
||||
|
||||
const RANGES = [
|
||||
{ value: "7", label: "7d" }, { value: "14", label: "14d" },
|
||||
{ value: "30", label: "30d" }, { value: "90", label: "90d" },
|
||||
];
|
||||
|
||||
/* ---------------- theme-aware palette ----------------
|
||||
Recharts draws into SVG; gradient <stop> colors and dark-mode switches need concrete
|
||||
values, not `var(--x)` strings. Resolve the tokens from the document once, and again
|
||||
whenever the app flips data-theme on <html>. */
|
||||
interface Palette {
|
||||
accent: string; signal: string; ok: string; warn: string; err: string; info: string;
|
||||
purple: string; teal: string; fg0: string; fg1: string; fg2: string; line: string; bg1: string; bg3: string;
|
||||
}
|
||||
function readPalette(): Palette {
|
||||
const cs = getComputedStyle(document.documentElement);
|
||||
const v = (n: string, fb: string) => (cs.getPropertyValue(n).trim() || fb);
|
||||
return {
|
||||
accent: v("--accent", "#4F46E5"), signal: v("--signal", "#4F46E5"),
|
||||
ok: v("--ok", "#16A34A"), warn: v("--warn", "#D97706"), err: v("--err", "#DC2626"),
|
||||
info: v("--info", "#2563EB"), purple: v("--io-json", "#7C3AED"), teal: v("--io-messages", "#0D9488"),
|
||||
fg0: v("--fg-0", "#111"), fg1: v("--fg-1", "#444"), fg2: v("--fg-2", "#888"),
|
||||
line: v("--line", "#E5E5E5"), bg1: v("--bg-1", "#fff"), bg3: v("--bg-3", "#f0f0f0"),
|
||||
};
|
||||
}
|
||||
function useThemeColors(): Palette {
|
||||
const [pal, setPal] = useState<Palette | null>(null);
|
||||
useEffect(() => {
|
||||
setPal(readPalette());
|
||||
const obs = new MutationObserver(() => setPal(readPalette()));
|
||||
obs.observe(document.documentElement, { attributes: true, attributeFilter: ["data-theme"] });
|
||||
return () => obs.disconnect();
|
||||
}, []);
|
||||
return pal || readPaletteFallback();
|
||||
}
|
||||
// SSR / first paint before the effect runs: sensible light defaults so charts don't flash.
|
||||
function readPaletteFallback(): Palette {
|
||||
return {
|
||||
accent: "#4F46E5", signal: "#4F46E5", ok: "#16A34A", warn: "#D97706", err: "#DC2626",
|
||||
info: "#2563EB", purple: "#7C3AED", teal: "#0D9488", fg0: "#111", fg1: "#444", fg2: "#888",
|
||||
line: "#E5E5E5", bg1: "#fff", bg3: "#f0f0f0",
|
||||
};
|
||||
}
|
||||
|
||||
/* ---------------- small building blocks ---------------- */
|
||||
function DeltaBadge({ cur, prev, goodUp = true, fmt }: { cur: number; prev: number; goodUp?: boolean; fmt?: (n: number) => string }) {
|
||||
if (prev === 0 && cur === 0) return <span className="t-caption fg-2">no change</span>;
|
||||
if (prev === 0) return <span className="pill pill-muted" style={{ height: 16 }}>new</span>;
|
||||
const pct = ((cur - prev) / Math.abs(prev)) * 100;
|
||||
const up = pct > 0;
|
||||
const flat = Math.abs(pct) < 0.05;
|
||||
const good = flat ? null : up === goodUp;
|
||||
const color = flat ? "var(--fg-2)" : good ? "var(--ok)" : "var(--err)";
|
||||
return (
|
||||
<span className="row gap1" style={{ color, fontSize: 11.5, fontWeight: 600, alignItems: "center" }} title={fmt ? `${fmt(prev)} → ${fmt(cur)}` : undefined}>
|
||||
{!flat && <Icon name={up ? "chevup" : "chevdown"} size={12} />}
|
||||
{flat ? "±0%" : `${up ? "+" : ""}${pct.toFixed(pct >= 100 || pct <= -100 ? 0 : 1)}%`}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function KpiTile({ label, value, sub, spark, sparkColor, delta }: {
|
||||
label: string; value: string; sub?: string; spark?: number[]; sparkColor?: string; delta?: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="card" style={{ padding: 16 }}>
|
||||
<div className="row spread" style={{ marginBottom: 8, alignItems: "flex-start" }}>
|
||||
<div className="t-micro">{label}</div>
|
||||
{delta}
|
||||
</div>
|
||||
<div className="row spread" style={{ alignItems: "flex-end" }}>
|
||||
<div>
|
||||
<div className="t-display" style={{ fontSize: 24, lineHeight: 1.1 }}>{value}</div>
|
||||
{sub && <div className="fg-2 t-caption" style={{ marginTop: 2 }}>{sub}</div>}
|
||||
</div>
|
||||
{spark && spark.some((n) => n > 0) && <Sparkline data={spark} w={78} h={30} color={sparkColor || "var(--accent)"} />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ChartCard({ title, sub, right, children, height = 232 }: {
|
||||
title: string; sub?: string; right?: React.ReactNode; children: React.ReactNode; height?: number;
|
||||
}) {
|
||||
return (
|
||||
<div className="card" style={{ padding: 16, display: "flex", flexDirection: "column" }}>
|
||||
<div className="row spread" style={{ marginBottom: 10 }}>
|
||||
<div>
|
||||
<div className="t-h3" style={{ fontSize: 13.5, fontWeight: 650 }}>{title}</div>
|
||||
{sub && <div className="fg-2 t-caption" style={{ marginTop: 1 }}>{sub}</div>}
|
||||
</div>
|
||||
{right}
|
||||
</div>
|
||||
<div style={{ width: "100%", height }}>{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// One shared tooltip for every chart: dark card, the point label, then each series.
|
||||
function ChartTooltip({ active, payload, label, pal, labelFmt, valueFmt }: any) {
|
||||
if (!active || !payload?.length) return null;
|
||||
return (
|
||||
<div style={{ background: pal.bg1, border: `1px solid ${pal.line}`, borderRadius: 8, padding: "8px 10px", boxShadow: "var(--sh-2)", fontSize: 12 }}>
|
||||
{label != null && <div style={{ color: pal.fg2, marginBottom: 4, fontWeight: 600 }}>{labelFmt ? labelFmt(label) : label}</div>}
|
||||
{payload.map((p: any, i: number) => (
|
||||
<div key={i} className="row gap2" style={{ alignItems: "center", justifyContent: "space-between", gap: 14 }}>
|
||||
<span className="row gap1" style={{ alignItems: "center", color: pal.fg1 }}>
|
||||
<span style={{ width: 8, height: 8, borderRadius: 2, background: p.color || p.fill || p.stroke, display: "inline-block" }} />
|
||||
{p.name}
|
||||
</span>
|
||||
<b style={{ color: pal.fg0, fontFamily: "var(--font-mono)" }}>{valueFmt ? valueFmt(p.value, p.dataKey) : p.value}</b>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const axisProps = (pal: Palette) => ({ stroke: pal.line, tick: { fill: pal.fg2, fontSize: 11 }, tickLine: false, axisLine: { stroke: pal.line } });
|
||||
|
||||
/* ================= ANALYTICS SCREEN ================= */
|
||||
export function AnalyticsScreen({ project, onNav }: { project: any; onNav: (s: string) => void }) {
|
||||
const pal = useThemeColors();
|
||||
const [days, setDays] = useState("30");
|
||||
const [data, setData] = useState<Analytics | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [counts, setCounts] = useState<ProjectCounts | null>(null);
|
||||
const [workflows, setWorkflows] = useState<Workflow[]>([]);
|
||||
const reqId = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (!project?.id) return;
|
||||
const id = ++reqId.current;
|
||||
setLoading(true);
|
||||
api.projectAnalytics(project.id, Number(days))
|
||||
.then((d) => { if (id === reqId.current) { setData(d); setLoading(false); } })
|
||||
.catch(() => { if (id === reqId.current) { setData(null); setLoading(false); } });
|
||||
}, [project?.id, days]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!project?.id) return;
|
||||
api.projectCounts(project.id).then(setCounts).catch(() => setCounts(null));
|
||||
api.listWorkflows(project.id).then(setWorkflows).catch(() => setWorkflows([]));
|
||||
}, [project?.id]);
|
||||
|
||||
const ts = data?.timeseries || [];
|
||||
const spark = (key: keyof Analytics["timeseries"][number]) => ts.map((p) => Number(p[key]) || 0);
|
||||
const t = data?.totals || ({} as StatRollup);
|
||||
const pv = data?.prev_totals || ({} as StatRollup);
|
||||
const successRate = (r: StatRollup) => (r.runs ? Math.round((100 - (r.error_rate || 0)) * 10) / 10 : 0);
|
||||
|
||||
// Merge success+error per day for the stacked volume chart; label sources for the pie.
|
||||
const volume = useMemo(() => ts.map((p) => ({ date: p.date, Success: p.success, Errors: p.errors })), [ts]);
|
||||
const sourcePie = useMemo(
|
||||
() => (data?.by_source || []).filter((s) => s.cost_usd > 0 || s.runs > 0).map((s) => ({ name: srcLabel(s.source), value: Math.round(s.cost_usd * 1e6) / 1e6, runs: s.runs })),
|
||||
[data],
|
||||
);
|
||||
const pieColors = [pal.accent, pal.teal, pal.warn, pal.purple, pal.info, pal.ok, pal.err];
|
||||
const hasRuns = (data?.totals?.runs || 0) > 0;
|
||||
|
||||
const health = [
|
||||
{ label: "Workflows", value: counts?.workflows ?? workflows.length, icon: "workflows", screen: "workflows" },
|
||||
{ label: "Agents", value: counts?.agents ?? "-", icon: "agents", screen: "agents" },
|
||||
{ label: "Tools", value: counts?.tools ?? "-", icon: "tools", screen: "tools" },
|
||||
{ label: "Knowledge", value: counts?.knowledge ?? "-", icon: "knowledge", screen: "knowledge" },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="scroll-y" style={{ flex: 1, padding: "24px 28px" }}>
|
||||
<div className="fade-up" style={{ maxWidth: 1600, margin: "0 auto" }}>
|
||||
{/* header + range picker */}
|
||||
<div className="row spread" style={{ marginBottom: 18, alignItems: "flex-end" }}>
|
||||
<div>
|
||||
<div className="t-display">{project?.name}</div>
|
||||
<div className="fg-1" style={{ marginTop: 3 }}>Analytics · {project?.slug}</div>
|
||||
</div>
|
||||
<div className="row gap2" style={{ alignItems: "center" }}>
|
||||
<Icon name="clock" size={15} style={{ color: "var(--fg-2)" }} />
|
||||
<div className="segmented">
|
||||
{RANGES.map((r) => (
|
||||
<button key={r.value} className={days === r.value ? "active" : ""} onClick={() => setDays(r.value)}>{r.label}</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading && !data ? (
|
||||
<div className="fg-2" style={{ padding: 60, textAlign: "center" }}>Loading analytics…</div>
|
||||
) : !hasRuns ? (
|
||||
<>
|
||||
<div className="card" style={{ padding: 8, marginBottom: 22 }}>
|
||||
<EmptyState icon="activity" title="No activity in this window"
|
||||
sub="Run a workflow in the Playground, from the API, or chat with the Forge Assistant - metrics will appear here."
|
||||
action={<button className="btn btn-primary" style={{ marginTop: 6 }} onClick={() => onNav("playground")}><Icon name="playground" size={15} />Open Playground</button>} />
|
||||
</div>
|
||||
<QuickLinks health={health} workflows={workflows} onNav={onNav} />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{/* KPI row */}
|
||||
<div style={{ display: "grid", gridTemplateColumns: "repeat(6,1fr)", gap: 14, marginBottom: 20 }}>
|
||||
<KpiTile label="Runs" value={fmtInt(t.runs)} sub={`over ${days}d`} spark={spark("runs")} sparkColor="var(--accent)" delta={<DeltaBadge cur={t.runs} prev={pv.runs} fmt={fmtInt} />} />
|
||||
<KpiTile label="Success rate" value={`${successRate(t)}%`} sub={`${t.errors || 0} errors`} spark={spark("success")} sparkColor="var(--ok)" delta={<DeltaBadge cur={successRate(t)} prev={successRate(pv)} fmt={(n) => `${n}%`} />} />
|
||||
<KpiTile label="Avg latency" value={fmtLatency(t.avg_latency_ms)} sub="per run" spark={spark("avg_latency_ms")} sparkColor="var(--info)" delta={<DeltaBadge cur={t.avg_latency_ms} prev={pv.avg_latency_ms} goodUp={false} fmt={fmtLatency} />} />
|
||||
<KpiTile label="Spend" value={fmtUSD(t.cost_usd)} sub="tracked cost" spark={spark("cost_usd")} sparkColor="var(--warn)" delta={<DeltaBadge cur={t.cost_usd} prev={pv.cost_usd} goodUp={false} fmt={fmtUSD} />} />
|
||||
<KpiTile label="Tokens" value={fmtCompact(t.tokens)} sub="in + out" spark={spark("tokens")} sparkColor="var(--io-json)" delta={<DeltaBadge cur={t.tokens} prev={pv.tokens} fmt={fmtCompact} />} />
|
||||
<KpiTile label="Error rate" value={`${t.error_rate || 0}%`} sub={`${t.errors || 0} of ${fmtInt(t.runs)}`} spark={spark("errors")} sparkColor="var(--err)" delta={<DeltaBadge cur={t.error_rate || 0} prev={pv.error_rate || 0} goodUp={false} fmt={(n) => `${n}%`} />} />
|
||||
</div>
|
||||
|
||||
{/* time-series: volume + cost */}
|
||||
<div style={{ display: "grid", gridTemplateColumns: "1.5fr 1fr", gap: 16, marginBottom: 16 }}>
|
||||
<ChartCard title="Run volume" sub="Successful vs errored runs per day">
|
||||
<ResponsiveContainer>
|
||||
<AreaChart data={volume} margin={{ top: 4, right: 8, left: -18, bottom: 0 }}>
|
||||
<defs>
|
||||
<linearGradient id="gSuccess" x1="0" y1="0" x2="0" y2="1"><stop offset="0%" stopColor={pal.accent} stopOpacity={0.35} /><stop offset="100%" stopColor={pal.accent} stopOpacity={0.02} /></linearGradient>
|
||||
<linearGradient id="gErr" x1="0" y1="0" x2="0" y2="1"><stop offset="0%" stopColor={pal.err} stopOpacity={0.35} /><stop offset="100%" stopColor={pal.err} stopOpacity={0.02} /></linearGradient>
|
||||
</defs>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke={pal.line} vertical={false} />
|
||||
<XAxis dataKey="date" tickFormatter={fmtAxisDate} minTickGap={28} {...axisProps(pal)} />
|
||||
<YAxis allowDecimals={false} width={40} {...axisProps(pal)} />
|
||||
<Tooltip content={<ChartTooltip pal={pal} labelFmt={fmtAxisDate} />} />
|
||||
<Area type="monotone" dataKey="Success" stackId="1" stroke={pal.accent} strokeWidth={2} fill="url(#gSuccess)" />
|
||||
<Area type="monotone" dataKey="Errors" stackId="1" stroke={pal.err} strokeWidth={2} fill="url(#gErr)" />
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</ChartCard>
|
||||
<ChartCard title="Cost" sub="Tracked spend per day">
|
||||
<ResponsiveContainer>
|
||||
<BarChart data={ts} margin={{ top: 4, right: 8, left: -12, bottom: 0 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke={pal.line} vertical={false} />
|
||||
<XAxis dataKey="date" tickFormatter={fmtAxisDate} minTickGap={28} {...axisProps(pal)} />
|
||||
<YAxis width={48} tickFormatter={(v) => `$${v < 1 ? v.toFixed(2) : fmtCompact(v)}`} {...axisProps(pal)} />
|
||||
<Tooltip cursor={{ fill: pal.bg3, opacity: 0.5 }} content={<ChartTooltip pal={pal} labelFmt={fmtAxisDate} valueFmt={(v: number) => fmtUSD(v)} />} />
|
||||
<Bar dataKey="cost_usd" name="Cost" fill={pal.warn} radius={[3, 3, 0, 0]} maxBarSize={26} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</ChartCard>
|
||||
</div>
|
||||
|
||||
{/* time-series: latency + tokens */}
|
||||
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 16, marginBottom: 16 }}>
|
||||
<ChartCard title="Latency" sub="Average run latency per day">
|
||||
<ResponsiveContainer>
|
||||
<LineChart data={ts} margin={{ top: 4, right: 8, left: -12, bottom: 0 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke={pal.line} vertical={false} />
|
||||
<XAxis dataKey="date" tickFormatter={fmtAxisDate} minTickGap={28} {...axisProps(pal)} />
|
||||
<YAxis width={44} tickFormatter={(v) => fmtLatency(v)} {...axisProps(pal)} />
|
||||
<Tooltip content={<ChartTooltip pal={pal} labelFmt={fmtAxisDate} valueFmt={(v: number) => fmtLatency(v)} />} />
|
||||
<Line type="monotone" dataKey="avg_latency_ms" name="Avg latency" stroke={pal.info} strokeWidth={2} dot={false} activeDot={{ r: 4 }} />
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</ChartCard>
|
||||
<ChartCard title="Token usage" sub="Total tokens per day">
|
||||
<ResponsiveContainer>
|
||||
<AreaChart data={ts} margin={{ top: 4, right: 8, left: -8, bottom: 0 }}>
|
||||
<defs><linearGradient id="gTok" x1="0" y1="0" x2="0" y2="1"><stop offset="0%" stopColor={pal.purple} stopOpacity={0.35} /><stop offset="100%" stopColor={pal.purple} stopOpacity={0.02} /></linearGradient></defs>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke={pal.line} vertical={false} />
|
||||
<XAxis dataKey="date" tickFormatter={fmtAxisDate} minTickGap={28} {...axisProps(pal)} />
|
||||
<YAxis width={44} tickFormatter={(v) => fmtCompact(v)} {...axisProps(pal)} />
|
||||
<Tooltip content={<ChartTooltip pal={pal} labelFmt={fmtAxisDate} valueFmt={(v: number) => fmtInt(v)} />} />
|
||||
<Area type="monotone" dataKey="tokens" name="Tokens" stroke={pal.purple} strokeWidth={2} fill="url(#gTok)" />
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</ChartCard>
|
||||
</div>
|
||||
|
||||
{/* breakdowns: cost by source (pie) + tool calls (bar) + latency distribution (bar) */}
|
||||
<div style={{ display: "grid", gridTemplateColumns: "1fr 1.2fr 1fr", gap: 16, marginBottom: 16 }}>
|
||||
<ChartCard title="Cost by source">
|
||||
{sourcePie.length === 0 ? <NoData /> : (
|
||||
<ResponsiveContainer>
|
||||
<PieChart>
|
||||
<Pie data={sourcePie} dataKey="value" nameKey="name" innerRadius={52} outerRadius={82} paddingAngle={2} stroke={pal.bg1} strokeWidth={2}>
|
||||
{sourcePie.map((_, i) => <Cell key={i} fill={pieColors[i % pieColors.length]} />)}
|
||||
</Pie>
|
||||
<Tooltip content={<ChartTooltip pal={pal} valueFmt={(v: number) => fmtUSD(v)} />} />
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
)}
|
||||
<PieLegend items={sourcePie} colors={pieColors} />
|
||||
</ChartCard>
|
||||
|
||||
<ChartCard title="Top tool calls" sub="Calls in the selected window">
|
||||
{(data?.tools?.length || 0) === 0 ? <NoData label="No tool calls recorded" /> : (
|
||||
<ResponsiveContainer>
|
||||
<BarChart layout="vertical" data={data!.tools.slice(0, 6)} margin={{ top: 0, right: 12, left: 8, bottom: 0 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke={pal.line} horizontal={false} />
|
||||
<XAxis type="number" allowDecimals={false} {...axisProps(pal)} />
|
||||
<YAxis type="category" dataKey="name" width={104} tick={{ fill: pal.fg1, fontSize: 11 }} tickLine={false} axisLine={{ stroke: pal.line }} />
|
||||
<Tooltip cursor={{ fill: pal.bg3, opacity: 0.5 }} content={<ChartTooltip pal={pal} valueFmt={(v: number, k: string) => (k === "calls" ? fmtInt(v) : v)} />} />
|
||||
<Bar dataKey="calls" name="Calls" fill={pal.teal} radius={[0, 3, 3, 0]} maxBarSize={22} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
)}
|
||||
</ChartCard>
|
||||
|
||||
<ChartCard title="Latency distribution" sub="Runs by response time">
|
||||
<ResponsiveContainer>
|
||||
<BarChart data={data?.latency_histogram || []} margin={{ top: 4, right: 8, left: -18, bottom: 0 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke={pal.line} vertical={false} />
|
||||
<XAxis dataKey="label" interval={0} angle={-30} textAnchor="end" height={48} tick={{ fill: pal.fg2, fontSize: 9.5 }} tickLine={false} axisLine={{ stroke: pal.line }} />
|
||||
<YAxis allowDecimals={false} width={34} {...axisProps(pal)} />
|
||||
<Tooltip cursor={{ fill: pal.bg3, opacity: 0.5 }} content={<ChartTooltip pal={pal} valueFmt={(v: number) => `${fmtInt(v)} runs`} />} />
|
||||
<Bar dataKey="count" name="Runs" fill={pal.accent} radius={[3, 3, 0, 0]} maxBarSize={40} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</ChartCard>
|
||||
</div>
|
||||
|
||||
{/* usage-by-source table + models + recent */}
|
||||
<div style={{ display: "grid", gridTemplateColumns: "1.6fr 1fr", gap: 16, marginBottom: 16 }}>
|
||||
<div className="card" style={{ overflow: "hidden" }}>
|
||||
<div className="row spread" style={{ padding: "14px 16px 10px" }}><div className="t-h3" style={{ fontSize: 13.5, fontWeight: 650 }}>Usage by source</div></div>
|
||||
<table className="tbl">
|
||||
<thead><tr><th>Source</th><th>Runs</th><th>Tokens</th><th>Avg latency</th><th>Errors</th><th>Cost</th></tr></thead>
|
||||
<tbody>
|
||||
{(data?.by_workflow || []).map((r, i) => (
|
||||
<tr key={i}>
|
||||
<td>
|
||||
<div className="row gap2">
|
||||
<Icon name={r.kind === "assistant" ? "sparkles" : r.kind === "workflow" ? "workflows" : "activity"} size={16} style={{ color: "var(--accent)", flex: "none" }} />
|
||||
<span style={{ fontWeight: 600, fontSize: 13 }}>{r.label}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="mono-sm">{fmtInt(r.runs)}</td>
|
||||
<td className="mono-sm">{fmtInt(r.tokens)}</td>
|
||||
<td className="mono-sm">{fmtLatency(r.avg_latency_ms)}</td>
|
||||
<td className="mono-sm">{r.errors ? <span className="pill pill-err" style={{ height: 16 }}>{r.errors}</span> : <span className="fg-2">0</span>}</td>
|
||||
<td className="mono-sm">{fmtUSD(r.cost_usd)}</td>
|
||||
</tr>
|
||||
))}
|
||||
{(data?.by_workflow?.length || 0) === 0 && <tr><td colSpan={6}><div className="fg-2 t-caption" style={{ padding: 22, textAlign: "center" }}>No usage in this window.</div></td></tr>}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div className="card" style={{ overflow: "hidden" }}>
|
||||
<div className="row spread" style={{ padding: "14px 16px 10px" }}><div className="t-h3" style={{ fontSize: 13.5, fontWeight: 650 }}>Model spend</div></div>
|
||||
{(data?.models?.length || 0) === 0 ? (
|
||||
<div className="fg-2 t-caption" style={{ padding: 22, textAlign: "center" }}>No model calls recorded.</div>
|
||||
) : (
|
||||
<table className="tbl">
|
||||
<thead><tr><th>Model</th><th>Calls</th><th>Tokens</th><th>Cost</th></tr></thead>
|
||||
<tbody>
|
||||
{data!.models.map((m, i) => (
|
||||
<tr key={i}>
|
||||
<td><span className="mono-sm" style={{ fontWeight: 600 }}>{m.model}</span></td>
|
||||
<td className="mono-sm">{fmtInt(m.calls)}</td>
|
||||
<td className="mono-sm">{fmtCompact(m.tokens)}</td>
|
||||
<td className="mono-sm">{fmtUSD(m.cost_usd)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* recent activity */}
|
||||
<div className="card" style={{ overflow: "hidden", marginBottom: 20 }}>
|
||||
<div className="row spread" style={{ padding: "14px 16px 10px" }}>
|
||||
<div className="t-h3" style={{ fontSize: 13.5, fontWeight: 650 }}>Recent runs</div>
|
||||
<button className="btn btn-ghost btn-sm" onClick={() => onNav("traces")}>View traces<Icon name="chevright" size={14} /></button>
|
||||
</div>
|
||||
{(data?.recent || []).map((r, i, arr) => (
|
||||
<div key={r.id} className="row gap3" style={{ padding: "10px 16px", borderTop: "1px solid var(--line)" }}>
|
||||
<StatusPill status={r.status} />
|
||||
<div className="grow truncate" style={{ fontWeight: 600, fontSize: 13 }}>{r.workflow}</div>
|
||||
<span className="mono-sm fg-2">{fmtInt(r.tokens)} tok</span>
|
||||
<span className="mono-sm fg-2">{fmtLatency(r.latency_ms)}</span>
|
||||
<span className="mono-sm" style={{ color: "var(--fg-1)" }}>{fmtUSD(r.cost_usd)}</span>
|
||||
<span className="fg-2 t-caption" style={{ width: 44, textAlign: "right" }}>{r.started_at ? r.started_at.slice(11, 16) : ""}</span>
|
||||
</div>
|
||||
))}
|
||||
{(data?.recent?.length || 0) === 0 && <div className="fg-2 t-caption" style={{ padding: 22, textAlign: "center" }}>No recent runs.</div>}
|
||||
</div>
|
||||
|
||||
<QuickLinks health={health} workflows={workflows} onNav={onNav} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function NoData({ label = "No data" }: { label?: string }) {
|
||||
return <div className="col center" style={{ height: "100%", color: "var(--fg-2)", fontSize: 12.5 }}>{label}</div>;
|
||||
}
|
||||
|
||||
function PieLegend({ items, colors }: { items: { name: string; value: number }[]; colors: string[] }) {
|
||||
if (!items.length) return null;
|
||||
return (
|
||||
<div className="col gap1" style={{ marginTop: 6 }}>
|
||||
{items.slice(0, 5).map((s, i) => (
|
||||
<div key={i} className="row spread" style={{ fontSize: 12 }}>
|
||||
<span className="row gap2" style={{ alignItems: "center", color: "var(--fg-1)" }}>
|
||||
<span style={{ width: 8, height: 8, borderRadius: 2, background: colors[i % colors.length] }} />{s.name}
|
||||
</span>
|
||||
<b className="mono-sm">{fmtUSD(s.value)}</b>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* Quick links kept from the old Overview so this stays the project landing screen:
|
||||
resource counts (deep-link into each builder) + the workflow list + deployment shortcuts. */
|
||||
function QuickLinks({ health, workflows, onNav }: { health: { label: string; value: any; icon: string; screen: string }[]; workflows: Workflow[]; onNav: (s: string) => void }) {
|
||||
return (
|
||||
<>
|
||||
<div style={{ display: "grid", gridTemplateColumns: "repeat(4,1fr)", gap: 14, marginBottom: 16 }}>
|
||||
{health.map((h, i) => (
|
||||
<button key={i} className="card card-hover" style={{ padding: 16, textAlign: "left", background: "var(--bg-1)" }} onClick={() => onNav(h.screen)}>
|
||||
<div className="row spread"><Icon name={h.icon} size={20} style={{ color: "var(--fg-2)" }} /><Icon name="chevright" size={16} style={{ color: "var(--fg-2)" }} /></div>
|
||||
<div className="t-display" style={{ fontSize: 26, marginTop: 12 }}>{h.value}</div>
|
||||
<div className="fg-2 t-caption">{h.label}</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div style={{ display: "grid", gridTemplateColumns: "1.4fr 1fr", gap: 16 }}>
|
||||
<div className="card" style={{ padding: 18 }}>
|
||||
<div className="row spread" style={{ marginBottom: 14 }}>
|
||||
<div className="t-h3" style={{ fontSize: 13.5, fontWeight: 650 }}>Workflows</div>
|
||||
<button className="btn btn-ghost btn-sm" onClick={() => onNav("workflow-canvas")}><Icon name="plus" size={14} />New</button>
|
||||
</div>
|
||||
{workflows.length === 0 ? (
|
||||
<div className="fg-2 t-caption" style={{ padding: "18px 0", textAlign: "center" }}>No workflows yet. Open the canvas to build one.</div>
|
||||
) : (
|
||||
<div className="col gap2">
|
||||
{workflows.map((w) => (
|
||||
<button key={w.id} className="row gap3" onClick={() => onNav("workflows")} style={{ padding: "10px 12px", borderRadius: 8, border: "1px solid var(--line)", background: "var(--bg-1)", cursor: "pointer", textAlign: "left" }}>
|
||||
<Icon name="workflows" size={18} style={{ color: "var(--accent)", flex: "none" }} />
|
||||
<div className="grow"><div style={{ fontWeight: 600, fontSize: 13 }}>{w.name}</div><div className="fg-2 t-caption">v{w.active_version}</div></div>
|
||||
<StatusPill status={w.status === "active" ? "active" : "draft"} />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="card" style={{ padding: 18 }}>
|
||||
<div className="t-h3" style={{ fontSize: 13.5, fontWeight: 650, marginBottom: 14 }}>Deployment</div>
|
||||
<div className="col gap3">
|
||||
{[["msg", "Channels", "Email", "channels"], ["connect", "Connect", "Run API · MCP · widget", "connect"], ["playground", "Playground", "Test your workflow", "playground"]].map((d, i) => (
|
||||
<button key={i} className="row gap3" onClick={() => onNav(d[3])} style={{ padding: "10px 12px", borderRadius: 8, border: "1px solid var(--line)", background: "var(--bg-1)", cursor: "pointer", textAlign: "left" }}>
|
||||
<Icon name={d[0]} size={18} style={{ color: "var(--fg-2)", flex: "none" }} />
|
||||
<div className="grow"><div style={{ fontWeight: 600, fontSize: 13 }}>{d[1]}</div><div className="fg-2 t-caption">{d[2]}</div></div>
|
||||
<Icon name="chevright" size={16} style={{ color: "var(--fg-2)" }} />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,429 @@
|
||||
"use client";
|
||||
/* Auth Providers - master/detail: left list, right Strategy + Credentials forms + masked test. */
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { Icon } from "../icons";
|
||||
import { Field, Modal, StatusPill, Tile, Toggle } from "../primitives";
|
||||
import { VersionHistory } from "../version-history";
|
||||
import { api, AuthProviderT, Tool } from "@/lib/api";
|
||||
|
||||
const KIND_LABEL: Record<string, string> = {
|
||||
csrf_session: "CSRF + session", oauth2_client_credentials: "OAuth2 client-creds", oauth2_authorization_code: "OAuth2 (user login)", bearer: "Bearer token", basic: "Basic auth", api_key: "API key", custom_script: "Custom script",
|
||||
};
|
||||
|
||||
// One-line "use this when…" per strategy - shown in the create picker so the choice is legible.
|
||||
const KIND_DESC: Record<string, string> = {
|
||||
bearer: "A static token sent in a header. The simplest option.",
|
||||
api_key: "A key sent as a header or query param.",
|
||||
basic: "Username + password (HTTP Basic).",
|
||||
oauth2_client_credentials: "Machine-to-machine - Forge trades a client id/secret for a short-lived token.",
|
||||
oauth2_authorization_code: "A user signs in on the provider's consent page; tokens auto-refresh.",
|
||||
csrf_session: "Log in to a web app, capture its CSRF token + session cookie, and replay them. For targets with no real API auth.",
|
||||
};
|
||||
|
||||
const TEMPLATES: Record<string, any> = {
|
||||
bearer: { kind: "bearer", token_ref: "secret://proj/token", header_name: "Authorization", prefix: "Bearer " },
|
||||
api_key: { kind: "api_key", in: "header", name: "X-API-Key", value_ref: "secret://proj/api_key" },
|
||||
basic: { kind: "basic", username_ref: "secret://proj/user", password_ref: "secret://proj/pass" },
|
||||
oauth2_client_credentials: { kind: "oauth2_client_credentials", token_url: "https://idp.example.com/oauth/token", scope: "read", client_id_ref: "secret://proj/client_id", client_secret_ref: "secret://proj/client_secret" },
|
||||
oauth2_authorization_code: { kind: "oauth2_authorization_code", authorize_url: "https://accounts.example.com/o/oauth2/v2/auth", token_url: "https://oauth2.example.com/token", scope: "openid email", client_id_ref: "secret://proj/client_id", client_secret_ref: "secret://proj/client_secret" },
|
||||
csrf_session: {
|
||||
kind: "csrf_session", credentials_ref: "secret://proj/creds",
|
||||
token_fetch: { method: "POST", url: "https://app.example.com/login", headers: { "Content-Type": "application/json" }, body: { username: "{{cred.username}}", password: "{{cred.password}}" } },
|
||||
extract: [{ name: "csrf", from: "header", header: "X-CSRF-Token" }, { name: "session", from: "cookie", cookie: "SESSIONID" }],
|
||||
inject: [{ to: "header", name: "X-CSRF-Token", value: "{{extracted.csrf}}" }, { to: "cookie", name: "SESSIONID", value: "{{extracted.session}}" }],
|
||||
cache_ttl_seconds: 1800, refresh_on: [401, 403],
|
||||
},
|
||||
};
|
||||
|
||||
export function AuthProvidersScreen({ project }: { project: any }) {
|
||||
const [rows, setRows] = useState<AuthProviderT[]>([]);
|
||||
const [tools, setTools] = useState<Tool[]>([]);
|
||||
const [selId, setSelId] = useState<string | null>(null);
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
|
||||
const reload = useCallback(() => {
|
||||
if (!project?.id) return;
|
||||
api.listAuthProviders(project.id).then((r) => { setRows(r); setSelId((s) => s && r.some((x) => x.id === s) ? s : (r[0]?.id ?? null)); }).catch(() => {});
|
||||
api.listTools(project.id).then(setTools).catch(() => {});
|
||||
}, [project?.id]);
|
||||
useEffect(() => { reload(); }, [reload]);
|
||||
|
||||
const toolCount = useMemo(() => {
|
||||
const m: Record<string, number> = {};
|
||||
tools.forEach((t) => { if (t.auth_provider_id) m[t.auth_provider_id] = (m[t.auth_provider_id] || 0) + 1; });
|
||||
return m;
|
||||
}, [tools]);
|
||||
|
||||
const sel = rows.find((r) => r.id === selId) || null;
|
||||
|
||||
return (
|
||||
<div style={{ flex: 1, display: "flex", minHeight: 0 }}>
|
||||
{/* LEFT list */}
|
||||
<div style={{ width: 280, flex: "none", borderRight: "1px solid var(--line)", display: "flex", flexDirection: "column", background: "var(--bg-1)" }}>
|
||||
<div className="row spread" style={{ padding: "14px 16px", borderBottom: "1px solid var(--line)" }}>
|
||||
<div className="t-display">Auth Providers</div>
|
||||
<button className="btn btn-primary btn-sm" onClick={() => setCreateOpen(true)}><Icon name="plus" size={14} /></button>
|
||||
</div>
|
||||
<div className="scroll-y" style={{ flex: 1, padding: 8 }}>
|
||||
{rows.length === 0 && <div className="fg-2 t-caption" style={{ padding: 12 }}>No providers yet. Click + to add one.</div>}
|
||||
{rows.map((p) => {
|
||||
const on = selId === p.id;
|
||||
return (
|
||||
<button key={p.id} onClick={() => setSelId(p.id)} className="col" style={{ width: "100%", textAlign: "left", padding: "11px 12px", borderRadius: 9, marginBottom: 4, border: "1px solid " + (on ? "var(--accent)" : "transparent"), background: on ? "var(--accent-glow)" : "transparent", cursor: "pointer", gap: 4 }}>
|
||||
<div className="row spread"><span className="mono-sm" style={{ fontWeight: 700, color: "var(--fg-0)" }}>{p.name}</span><StatusPill status={(() => { const lt = (p.config as any)?._last_test; return lt ? (lt.ok ? "pass" : "fail") : "untested"; })()} /></div>
|
||||
<div className="row spread">
|
||||
<div className="row gap2" style={{ fontSize: 11, color: "var(--fg-2)" }}><span>{KIND_LABEL[p.kind] || p.kind}</span><span>· {toolCount[p.id] || 0} tools</span></div>
|
||||
<span
|
||||
className="iconbtn" role="button" title="Delete provider"
|
||||
onClick={async (e) => {
|
||||
e.stopPropagation();
|
||||
const used = toolCount[p.id] || 0;
|
||||
const warn = used ? ` ${used} tool(s) reference it and will lose auth.` : "";
|
||||
if (!window.confirm(`Delete auth provider “${p.name}”?${warn}`)) return;
|
||||
await api.deleteAuthProvider(project.id, p.id);
|
||||
reload();
|
||||
}}
|
||||
><Icon name="trash" size={13} /></span>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* RIGHT detail */}
|
||||
<div className="grow scroll-y" style={{ padding: 24, minWidth: 0 }}>
|
||||
{sel ? <ProviderDetail key={sel.id} project={project} provider={sel} onSaved={reload} /> : (
|
||||
<div className="col center" style={{ height: "100%", gap: 8, color: "var(--fg-2)" }}><Tile icon="auth" color="var(--accent)" size={48} glow /><div className="t-h2">Select or add a provider</div></div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<CreateModal open={createOpen} onClose={() => setCreateOpen(false)} onCreate={async (name, kind) => {
|
||||
const cfg = TEMPLATES[kind] || { kind };
|
||||
const ap = await api.createAuthProvider(project.id, { name: name || kind, kind, config: cfg, credentials_ref: cfg.credentials_ref });
|
||||
setCreateOpen(false); reload(); setSelId(ap.id);
|
||||
}} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CreateModal({ open, onClose, onCreate }: { open: boolean; onClose: () => void; onCreate: (name: string, kind: string) => void }) {
|
||||
const [name, setName] = useState("");
|
||||
const [kind, setKind] = useState("bearer");
|
||||
return (
|
||||
<Modal open={open} onClose={onClose} title="New auth provider" width={520}
|
||||
footer={<><button className="btn btn-ghost" onClick={onClose}>Cancel</button><button className="btn btn-primary" onClick={() => onCreate(name.trim().replace(/\s+/g, "_"), kind)}>Create</button></>}>
|
||||
<Field label="Strategy" help="How the target API expects to be authenticated - pick whichever scheme it requires.">
|
||||
<div className="col gap2">
|
||||
{Object.keys(TEMPLATES).map((k) => {
|
||||
const on = kind === k;
|
||||
return (
|
||||
<button key={k} type="button" onClick={() => setKind(k)} className="col"
|
||||
style={{ width: "100%", textAlign: "left", padding: "10px 12px", borderRadius: 9, gap: 3, cursor: "pointer", border: "1px solid " + (on ? "var(--accent)" : "var(--line)"), background: on ? "var(--accent-glow)" : "var(--bg-1)" }}>
|
||||
<span style={{ fontWeight: 700, color: "var(--fg-0)" }}>{KIND_LABEL[k]}</span>
|
||||
<span className="t-caption fg-2">{KIND_DESC[k]}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Field>
|
||||
<Field label="Name"><input className="input mono" value={name} onChange={(e) => setName(e.target.value)} placeholder="orders_api" /></Field>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function ProviderDetail({ project, provider, onSaved }: { project: any; provider: AuthProviderT; onSaved: () => void }) {
|
||||
const [cfg, setCfg] = useState<any>(() => ({ ...(provider.config || {}), kind: provider.kind }));
|
||||
const [name, setName] = useState(provider.name);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [saved, setSaved] = useState(false);
|
||||
const [test, setTest] = useState<any>(null);
|
||||
const [reveal, setReveal] = useState(false);
|
||||
|
||||
const kind = cfg.kind;
|
||||
function setPath(path: string[], value: any) {
|
||||
setCfg((c: any) => {
|
||||
const next = structuredClone(c); let o = next;
|
||||
for (let i = 0; i < path.length - 1; i++) { o[path[i]] = o[path[i]] ?? {}; o = o[path[i]]; }
|
||||
o[path[path.length - 1]] = value; return next;
|
||||
});
|
||||
setSaved(false);
|
||||
}
|
||||
const get = (path: string[], dflt: any = "") => path.reduce((o, k) => (o == null ? o : o[k]), cfg) ?? dflt;
|
||||
|
||||
// Per-user ("external") auth: bearer/api_key providers can be marked so EACH user supplies their
|
||||
// own token (stored per-user) instead of one shared secret - the mechanism behind per-user MCP +
|
||||
// on-behalf-of calls. Marked by per_user_context_keys containing "end_user_id".
|
||||
const supportsPerUser = kind === "bearer" || kind === "api_key";
|
||||
const perUser = ((cfg.per_user_context_keys as string[]) || []).includes("end_user_id");
|
||||
function setPerUser(on: boolean) {
|
||||
setCfg((c: any) => {
|
||||
const next = structuredClone(c);
|
||||
if (on) next.per_user_context_keys = ["end_user_id"];
|
||||
else delete next.per_user_context_keys;
|
||||
return next;
|
||||
});
|
||||
setSaved(false);
|
||||
}
|
||||
|
||||
async function save() {
|
||||
setSaving(true);
|
||||
try {
|
||||
const updated = await api.updateAuthProvider(project.id, provider.id, { name, kind, config: cfg, credentials_ref: cfg.credentials_ref });
|
||||
setSaved(true); onSaved();
|
||||
} catch { /* */ } finally { setSaving(false); }
|
||||
}
|
||||
async function runTest() {
|
||||
try {
|
||||
const r = await api.testAuthProvider(project.id, provider.id, {});
|
||||
setTest(r);
|
||||
// Persist a lightweight pass/fail marker (merged into the last-saved config, not the
|
||||
// in-progress edits) so the list StatusPill reflects the real result - mirrors how
|
||||
// tools store _last_test. Best-effort: the test result still shows regardless.
|
||||
try {
|
||||
await api.updateAuthProvider(project.id, provider.id, { config: { ...(provider.config || {}), _last_test: { ok: !!r?.ok, at: Date.now() } } });
|
||||
onSaved();
|
||||
} catch { /* marker is best-effort */ }
|
||||
} catch (e: any) {
|
||||
setTest({ ok: false, error: e?.message || String(e) });
|
||||
}
|
||||
}
|
||||
|
||||
// csrf_session uses extract/inject arrays; surface the first CSRF rule for editing.
|
||||
const extract = cfg.extract || [];
|
||||
const csrfRule = extract.find((e: any) => e.from === "header") || extract[0] || {};
|
||||
const jsonRule = extract.find((e: any) => e.from === "json") || {};
|
||||
function setExtractField(field: string, value: string) {
|
||||
const ext = [...(cfg.extract || [])];
|
||||
const idx = ext.findIndex((e: any) => e === csrfRule);
|
||||
if (idx >= 0) ext[idx] = { ...ext[idx], [field]: value };
|
||||
setPath(["extract"], ext);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ maxWidth: 960 }}>
|
||||
<div className="row spread" style={{ marginBottom: 18 }}>
|
||||
<div className="row gap3">
|
||||
<Tile icon="auth" color="var(--accent)" size={40} glow />
|
||||
<div><div className="t-display mono" style={{ fontSize: 18 }}>{provider.name}</div><div className="fg-2 t-caption">{KIND_LABEL[kind] || kind} · ttl {cfg.cache_ttl_seconds || 1800}s</div></div>
|
||||
</div>
|
||||
<div className="row gap2">
|
||||
<VersionHistory entityType="auth_provider" entityId={provider.id} entityLabel={provider.name} buttonClassName="btn btn-secondary" onRestored={onSaved} />
|
||||
<button className="btn btn-secondary" onClick={runTest}><Icon name="validate" size={15} />Test connection</button>
|
||||
<button className="btn btn-primary" onClick={save} disabled={saving}><Icon name="save" size={15} />{saving ? "Saving…" : saved ? "Saved ✓" : "Save"}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{test && (
|
||||
<div className="card" style={{ padding: 12, marginBottom: 16, background: "var(--bg-3)" }}>
|
||||
{test.ok
|
||||
? <div className="col gap1"><div className="t-caption fg-2">Would inject (masked):</div><pre className="mono-sm" style={{ margin: 0 }}>{JSON.stringify({ headers: test.headers, cookies: test.cookies, params: test.params }, null, 2)}</pre></div>
|
||||
: <div className="t-caption" style={{ color: "var(--err)" }}>{test.error}</div>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Strategy */}
|
||||
<div className="card" style={{ padding: 18, marginBottom: 16 }}>
|
||||
<div className="t-h2" style={{ marginBottom: 14 }}>Strategy</div>
|
||||
<Field label="Name"><input className="input mono" value={name} onChange={(e) => { setName(e.target.value); setSaved(false); }} /></Field>
|
||||
<Field label="Type">
|
||||
<div style={{ position: "relative" }}>
|
||||
<select className="select" value={kind} onChange={(e) => { const k = e.target.value; setCfg({ ...(TEMPLATES[k] || { kind: k }) }); setSaved(false); }}>
|
||||
{Object.entries(KIND_LABEL).map(([k, v]) => <option key={k} value={k}>{v}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
</Field>
|
||||
|
||||
{kind === "csrf_session" && (
|
||||
<>
|
||||
<div className="row gap4">
|
||||
<Field label="Login URL"><input className="input mono" value={get(["token_fetch", "url"])} onChange={(e) => setPath(["token_fetch", "url"], e.target.value)} /></Field>
|
||||
<Field label="Method"><input className="input mono" value={get(["token_fetch", "method"], "POST")} onChange={(e) => setPath(["token_fetch", "method"], e.target.value)} /></Field>
|
||||
</div>
|
||||
<div className="row gap4">
|
||||
<Field label="CSRF header"><input className="input mono" value={csrfRule.header || ""} onChange={(e) => setExtractField("header", e.target.value)} /></Field>
|
||||
<Field label="CSRF JSON path"><input className="input mono" value={jsonRule.json_path || ""} onChange={(e) => setExtractField("json_path", e.target.value)} placeholder="data.csrfToken" /></Field>
|
||||
</div>
|
||||
<Field label="Session TTL" help="Auto re-login on expiry or 401."><input className="input mono" value={get(["cache_ttl_seconds"], 1800)} onChange={(e) => setPath(["cache_ttl_seconds"], Number(e.target.value) || 0)} /></Field>
|
||||
</>
|
||||
)}
|
||||
{kind === "oauth2_client_credentials" && (
|
||||
<>
|
||||
<div className="row gap4">
|
||||
<Field label="Token URL"><input className="input mono" value={get(["token_url"])} onChange={(e) => setPath(["token_url"], e.target.value)} /></Field>
|
||||
<Field label="Scope"><input className="input mono" value={get(["scope"])} onChange={(e) => setPath(["scope"], e.target.value)} /></Field>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{kind === "oauth2_authorization_code" && (
|
||||
<>
|
||||
<Field label="Authorize URL" help="The provider's consent page the user is redirected to."><input className="input mono" value={get(["authorize_url"])} onChange={(e) => setPath(["authorize_url"], e.target.value)} /></Field>
|
||||
<div className="row gap4">
|
||||
<Field label="Token URL"><input className="input mono" value={get(["token_url"])} onChange={(e) => setPath(["token_url"], e.target.value)} /></Field>
|
||||
<Field label="Scope"><input className="input mono" value={get(["scope"])} onChange={(e) => setPath(["scope"], e.target.value)} /></Field>
|
||||
</div>
|
||||
<OAuthConnect project={project} provider={provider} />
|
||||
</>
|
||||
)}
|
||||
{kind === "api_key" && (
|
||||
<div className="row gap4">
|
||||
<Field label="In"><div style={{ position: "relative" }}><select className="select" value={get(["in"], "header")} onChange={(e) => setPath(["in"], e.target.value)}><option value="header">header</option><option value="query">query</option></select></div></Field>
|
||||
<Field label="Param name"><input className="input mono" value={get(["name"])} onChange={(e) => setPath(["name"], e.target.value)} /></Field>
|
||||
</div>
|
||||
)}
|
||||
{kind === "bearer" && (
|
||||
<div className="row gap4">
|
||||
<Field label="Header name"><input className="input mono" value={get(["header_name"], "Authorization")} onChange={(e) => setPath(["header_name"], e.target.value)} /></Field>
|
||||
<Field label="Prefix"><input className="input mono" value={get(["prefix"], "Bearer ")} onChange={(e) => setPath(["prefix"], e.target.value)} /></Field>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{supportsPerUser && (
|
||||
<label className="card row spread" style={{ padding: 12, marginTop: 12, background: "var(--bg-3)", cursor: "pointer" }}>
|
||||
<div className="col gap1" style={{ maxWidth: 640 }}>
|
||||
<span className="t-body-sm" style={{ fontWeight: 600 }}>Per-user credential</span>
|
||||
<span className="t-caption fg-2">Each user supplies their OWN token instead of one shared secret — tools then act as the calling user downstream (works for MCP and on-behalf-of runs). Users set theirs on their token page; you can set yours below.</span>
|
||||
</div>
|
||||
<Toggle on={perUser} onChange={setPerUser} />
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Credentials */}
|
||||
<div className="card" style={{ padding: 18 }}>
|
||||
<div className="row spread" style={{ marginBottom: 12 }}><div className="t-h2">Credentials</div><span className="chip" style={{ color: "var(--fg-2)" }}><Icon name="secret" size={12} />{perUser ? "per-user (each user connects)" : "from secret store"}</span></div>
|
||||
{perUser ? (
|
||||
<PerUserConnect project={project} provider={provider} />
|
||||
) : (
|
||||
<>
|
||||
{credentialFields(kind).map((cf) => (
|
||||
<Field key={cf.path} label={cf.label} help={cf.help}>
|
||||
<div className="row gap2">
|
||||
<input className="input mono" type={reveal ? "text" : "password"} value={get([cf.path])} onChange={(e) => setPath([cf.path], e.target.value)} style={{ flex: 1 }} placeholder="secret://proj/…" />
|
||||
<button className="iconbtn" style={{ border: "1px solid var(--line-strong)" }} title={reveal ? "Hide" : "Reveal"} onClick={() => setReveal((r) => !r)}><Icon name={reveal ? "eyeoff" : "eye"} size={15} /></button>
|
||||
</div>
|
||||
</Field>
|
||||
))}
|
||||
<div className="fg-2 t-caption" style={{ marginTop: 4 }}>Secret values live in Settings → Secrets. Reference them as <span className="mono-sm">secret://proj/<name></span>.</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Extra headers: fixed headers stamped on EVERY call, alongside the auth header. Values may
|
||||
be a literal or a secret:// ref, so a shared service token stays in the store (not per-tool). */}
|
||||
<div className="card" style={{ padding: 18, marginTop: 16 }}>
|
||||
<div className="row spread" style={{ marginBottom: 8 }}><div className="t-h2">Extra headers</div><span className="chip" style={{ color: "var(--fg-2)" }}><Icon name="secret" size={12} />literal or secret://</span></div>
|
||||
<div className="fg-2 t-caption" style={{ marginBottom: 12 }}>Sent on every call alongside the auth header — use for fixed service / attestation headers (e.g. a client id, a service token). Each value is a literal <em>or</em> a <span className="mono-sm">secret://proj/<name></span> ref, so a shared secret stays in Settings → Secrets instead of hardcoded per tool.</div>
|
||||
<ExtraHeadersEditor value={cfg.extra_headers || {}} onChange={(v) => setPath(["extra_headers"], v)} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* Rows editor for a provider's extra_headers (name -> value). Values are literals or secret:// refs;
|
||||
the resolver stamps them on every call and resolves any secret ref from the store. */
|
||||
function ExtraHeadersEditor({ value, onChange }: { value: Record<string, string>; onChange: (v: Record<string, string>) => void }) {
|
||||
const rows = Object.entries(value || {});
|
||||
const rebuild = (next: [string, string][]) => onChange(Object.fromEntries(next.filter(([k]) => k.trim())));
|
||||
return (
|
||||
<div className="col gap2">
|
||||
{rows.map(([k, v], i) => (
|
||||
<div key={i} className="row gap2">
|
||||
<input className="input mono" placeholder="Header name" value={k} style={{ flex: 1 }}
|
||||
onChange={(e) => rebuild(rows.map((r, idx): [string, string] => (idx === i ? [e.target.value, r[1]] : r)))} />
|
||||
<input className="input mono" placeholder="value or secret://proj/name" value={v} style={{ flex: 2 }}
|
||||
onChange={(e) => rebuild(rows.map((r, idx): [string, string] => (idx === i ? [r[0], e.target.value] : r)))} />
|
||||
<button className="iconbtn" style={{ border: "1px solid var(--line-strong)" }} title="Remove"
|
||||
onClick={() => rebuild(rows.filter((_, idx) => idx !== i))}><Icon name="trash" size={14} /></button>
|
||||
</div>
|
||||
))}
|
||||
<button className="btn btn-ghost btn-sm" style={{ alignSelf: "flex-start" }}
|
||||
onClick={() => onChange({ ...(value || {}), "": "" })}><Icon name="plus" size={13} />Add header</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function OAuthConnect({ project, provider }: { project: any; provider: AuthProviderT }) {
|
||||
const [status, setStatus] = useState<{ connected: boolean; scope?: string | null } | null>(null);
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
const refresh = useCallback(() => { api.oauthStatus(project.id, provider.id).then(setStatus).catch(() => setStatus(null)); }, [project.id, provider.id]);
|
||||
useEffect(() => { refresh(); }, [refresh]);
|
||||
|
||||
async function connect() {
|
||||
setErr(null);
|
||||
try {
|
||||
const { authorize_url } = await api.oauthStart(project.id, provider.id);
|
||||
const w = window.open(authorize_url, "_blank", "width=620,height=760");
|
||||
// Poll status a few times after the popup so the badge flips to "connected".
|
||||
const t = setInterval(() => refresh(), 2500);
|
||||
setTimeout(() => { clearInterval(t); try { w?.close(); } catch { /* ignore */ } }, 60000);
|
||||
} catch {
|
||||
setErr("Could not start OAuth - save the provider first and set the client_id secret in Settings → Secrets.");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="card" style={{ padding: 14, marginTop: 8, background: "var(--bg-3)" }}>
|
||||
<div className="row spread">
|
||||
<div className="row gap2">
|
||||
<Icon name="link" size={15} />
|
||||
<span className="t-body-sm" style={{ fontWeight: 600 }}>User authorization</span>
|
||||
{status?.connected ? <span className="pill pill-ok">connected</span> : <span className="pill pill-muted">not connected</span>}
|
||||
</div>
|
||||
<button className="btn btn-primary btn-sm" onClick={connect}><Icon name="external" size={13} />{status?.connected ? "Reconnect" : "Connect"}</button>
|
||||
</div>
|
||||
{status?.scope && <div className="fg-2 t-caption" style={{ marginTop: 6 }}>scope: {status.scope}</div>}
|
||||
{err && <div className="t-caption" style={{ color: "var(--err)", marginTop: 6 }}>{err}</div>}
|
||||
<div className="fg-2 t-caption" style={{ marginTop: 6 }}>Save the provider, set the client_id/secret secrets, then Connect. A popup completes the grant; tokens auto-refresh.</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* Per-user credential: the CURRENT user's own downstream token for a per-user provider. Stored
|
||||
server-side keyed by their user id (same identity the MCP PAT resolves to), so tools act as them
|
||||
without a shared secret. The same box appears on the connector token page. */
|
||||
function PerUserConnect({ project, provider }: { project: any; provider: AuthProviderT }) {
|
||||
const [status, setStatus] = useState<{ connected: boolean } | null>(null);
|
||||
const [token, setToken] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
const refresh = useCallback(() => { api.getMyConnection(project.id, provider.id).then(setStatus).catch(() => setStatus(null)); }, [project.id, provider.id]);
|
||||
useEffect(() => { refresh(); }, [refresh]);
|
||||
|
||||
async function save() {
|
||||
setErr(null); setBusy(true);
|
||||
try {
|
||||
const res = await api.setMyConnection(project.id, provider.id, token.trim());
|
||||
if (!res.ok) throw new Error(res.status === 400 ? "Save this provider as per-user first, then set your token." : "Could not save token.");
|
||||
setToken(""); refresh();
|
||||
} catch (e: any) { setErr(e?.message || String(e)); } finally { setBusy(false); }
|
||||
}
|
||||
async function clear() { setErr(null); try { await api.clearMyConnection(project.id, provider.id); } catch { /* best-effort */ } refresh(); }
|
||||
|
||||
return (
|
||||
<div className="card" style={{ padding: 14, background: "var(--bg-3)" }}>
|
||||
<div className="row spread" style={{ marginBottom: 8 }}>
|
||||
<div className="row gap2"><Icon name="link" size={15} /><span className="t-body-sm" style={{ fontWeight: 600 }}>Your token</span>{status?.connected ? <span className="pill pill-ok">connected</span> : <span className="pill pill-muted">not connected</span>}</div>
|
||||
{status?.connected && <button className="btn btn-ghost btn-sm" onClick={clear}>Clear</button>}
|
||||
</div>
|
||||
<div className="row gap2">
|
||||
<input className="input mono" type="password" value={token} onChange={(e) => setToken(e.target.value)} placeholder="paste your token…" style={{ flex: 1 }} />
|
||||
<button className="btn btn-primary" onClick={save} disabled={busy || !token.trim()}>{busy ? "Saving…" : "Save"}</button>
|
||||
</div>
|
||||
{err && <div className="t-caption" style={{ color: "var(--err)", marginTop: 6 }}>{err}</div>}
|
||||
<div className="fg-2 t-caption" style={{ marginTop: 6 }}>Stored per-user and encrypted; used only for calls made as you, and never shown again after saving.</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function credentialFields(kind: string): { path: string; label: string; help?: string }[] {
|
||||
switch (kind) {
|
||||
case "csrf_session": return [{ path: "credentials_ref", label: "Credentials secret ref", help: "Holds { username, password } for the login call." }];
|
||||
case "bearer": return [{ path: "token_ref", label: "Token secret ref" }];
|
||||
case "api_key": return [{ path: "value_ref", label: "API key secret ref" }];
|
||||
case "basic": return [{ path: "username_ref", label: "Username secret ref" }, { path: "password_ref", label: "Password secret ref" }];
|
||||
case "oauth2_client_credentials":
|
||||
case "oauth2_authorization_code": return [{ path: "client_id_ref", label: "Client ID secret ref" }, { path: "client_secret_ref", label: "Client secret ref" }];
|
||||
default: return [{ path: "credentials_ref", label: "Credentials secret ref" }];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,293 @@
|
||||
"use client";
|
||||
/* Chunk map: a 2-D (PCA) view of the project's stored chunk vectors so you can SEE how your
|
||||
knowledge base is laid out - which chunks cluster, which source each belongs to, and (with a
|
||||
query) exactly what retrieval returns and how it connects to the query point. Reuses the same
|
||||
React Flow canvas the workflow builder uses. Read-only: it never mutates the store. */
|
||||
import "@xyflow/react/dist/style.css";
|
||||
import {
|
||||
Background, BackgroundVariant, Controls, Handle, MiniMap, Position, ReactFlow, ReactFlowProvider,
|
||||
useEdgesState, useNodesState, useReactFlow, type Edge, type Node, type NodeProps,
|
||||
} from "@xyflow/react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Icon } from "../icons";
|
||||
import { Segmented } from "../primitives";
|
||||
import { api, ChunkDetail, ChunkMapResult, ChunkPoint } from "@/lib/api";
|
||||
|
||||
// How many chunks to plot. More points = a fuller picture but slower to project (PCA/SVD); the
|
||||
// backend clamps to a hard ceiling regardless of what's requested here.
|
||||
const POINT_LIMITS = [200, 400, 800, 1500] as const;
|
||||
|
||||
// Distinct, canvas-friendly colors assigned to sources in order. Wraps if a project has more
|
||||
// sources than colors (fine - the legend still disambiguates).
|
||||
const PALETTE = ["#6ea8fe", "#f7a072", "#8fd694", "#c792ea", "#ffd166", "#6dd0d6", "#f78fb3", "#a0d995", "#c0a2f0", "#e0b0ff"];
|
||||
const RETRIEVED = "#ffcc33"; // highlight for the query point + retrieved chunks
|
||||
|
||||
const HIDDEN_HANDLE = { opacity: 0, width: 1, height: 1, minWidth: 0, minHeight: 0, border: "none", pointerEvents: "none" as const };
|
||||
|
||||
// --- custom nodes (dots on the map) ---
|
||||
|
||||
function ChunkDot({ data }: NodeProps) {
|
||||
const d = data as any;
|
||||
const size = d.retrieved ? 17 : 12;
|
||||
const ring = d.retrieved
|
||||
? `0 0 0 2px var(--bg-1), 0 0 0 4px ${RETRIEVED}`
|
||||
: d.selected
|
||||
? "0 0 0 2px var(--fg-0)"
|
||||
: "0 0 1px rgba(0,0,0,.45)";
|
||||
return (
|
||||
<div style={{ position: "relative" }} title={d.preview}>
|
||||
{/* Hidden handles so the query->hit / parent-group edges have anchor points. */}
|
||||
<Handle type="target" id="t" position={Position.Top} style={HIDDEN_HANDLE} isConnectable={false} />
|
||||
<Handle type="source" id="s" position={Position.Top} style={HIDDEN_HANDLE} isConnectable={false} />
|
||||
<div style={{ width: size, height: size, borderRadius: "50%", background: d.color, border: "1.5px solid var(--bg-1)", boxShadow: ring, cursor: "pointer" }} />
|
||||
{d.retrieved && (
|
||||
<span className="mono" style={{ position: "absolute", top: -9, right: -9, fontSize: 10, fontWeight: 700, color: "#1a1400", background: RETRIEVED, borderRadius: 8, minWidth: 15, height: 15, lineHeight: "15px", textAlign: "center", padding: "0 3px" }}>{d.retrieved}</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function QueryMarker({ data }: NodeProps) {
|
||||
return (
|
||||
<div style={{ position: "relative" }} title={(data as any).label}>
|
||||
<Handle type="source" id="s" position={Position.Top} style={HIDDEN_HANDLE} isConnectable={false} />
|
||||
<div style={{ width: 18, height: 18, background: RETRIEVED, transform: "rotate(45deg)", border: "2px solid var(--bg-1)", boxShadow: `0 0 10px ${RETRIEVED}` }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const NODE_TYPES = { chunk: ChunkDot, query: QueryMarker };
|
||||
|
||||
// --- main ---
|
||||
|
||||
export function ChunkMap({ project }: { project: any }) {
|
||||
return (
|
||||
<ReactFlowProvider>
|
||||
<ChunkMapInner project={project} />
|
||||
</ReactFlowProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function ChunkMapInner({ project }: { project: any }) {
|
||||
const [q, setQ] = useState("");
|
||||
const [mode, setMode] = useState<"vector" | "hybrid">("vector");
|
||||
const [rerank, setRerank] = useState(false);
|
||||
const [limit, setLimit] = useState<number>(400);
|
||||
const [folders, setFolders] = useState<string[]>([]);
|
||||
const [folder, setFolder] = useState("");
|
||||
const [res, setRes] = useState<ChunkMapResult | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
const [selId, setSelId] = useState<string | null>(null);
|
||||
// Full text of the selected chunk, fetched on demand (the map payload carries only a preview).
|
||||
const [detail, setDetail] = useState<ChunkDetail | null>(null);
|
||||
const [detailBusy, setDetailBusy] = useState(false);
|
||||
const [detailErr, setDetailErr] = useState(false);
|
||||
|
||||
const [nodes, setNodes, onNodesChange] = useNodesState<Node>([]);
|
||||
const [edges, setEdges, onEdgesChange] = useEdgesState<Edge>([]);
|
||||
const { fitView } = useReactFlow();
|
||||
|
||||
// Color per source id (stable within a load, keyed off the legend order).
|
||||
const colorOf = useMemo(() => {
|
||||
const m = new Map<string, string>();
|
||||
(res?.sources || []).forEach((s, i) => m.set(s.id, PALETTE[i % PALETTE.length]));
|
||||
return (sid?: string | null) => (sid && m.get(sid)) || "var(--fg-2)";
|
||||
}, [res]);
|
||||
|
||||
const load = useCallback(async (query?: string) => {
|
||||
if (!project?.id) return;
|
||||
setLoading(true);
|
||||
setErr(null);
|
||||
try {
|
||||
const r = await api.chunkMap(project.id, {
|
||||
query: query?.trim() || undefined,
|
||||
folders: folder ? [folder] : undefined,
|
||||
hybrid: mode === "hybrid",
|
||||
rerank,
|
||||
limit,
|
||||
});
|
||||
setRes(r);
|
||||
setSelId(null);
|
||||
} catch (e: any) {
|
||||
setErr(e?.message || "Failed to build the chunk map.");
|
||||
setRes(null);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [project?.id, folder, mode, rerank, limit]);
|
||||
|
||||
useEffect(() => { if (project?.id) api.listFolders(project.id).then(setFolders).catch(() => {}); }, [project?.id]);
|
||||
// Load the full map once on open (no query overlay yet).
|
||||
useEffect(() => { load(); /* eslint-disable-next-line react-hooks/exhaustive-deps */ }, [project?.id]);
|
||||
// Changing the point budget re-fetches immediately (keeping any applied query overlay). Skip the
|
||||
// first run so this doesn't double-load on mount alongside the effect above.
|
||||
const didMountLimit = useRef(false);
|
||||
useEffect(() => {
|
||||
if (!didMountLimit.current) { didMountLimit.current = true; return; }
|
||||
if (project?.id) load(res?.query || undefined);
|
||||
/* eslint-disable-next-line react-hooks/exhaustive-deps */
|
||||
}, [limit]);
|
||||
|
||||
// Build the React Flow graph whenever the map RESULT changes (fresh load, query overlay, or a
|
||||
// new point budget). Framing (fitView) lives ONLY here: re-fitting on selection would yank the
|
||||
// user back out of whatever zoom they'd dialed in. Nodes start unselected — the selection effect
|
||||
// below toggles the highlight ring in place without rebuilding or re-framing the graph.
|
||||
useEffect(() => {
|
||||
if (!res) { setNodes([]); setEdges([]); return; }
|
||||
const ns: Node[] = res.points.map((p) => ({
|
||||
id: p.id,
|
||||
type: "chunk",
|
||||
position: { x: p.x, y: p.y },
|
||||
draggable: false,
|
||||
data: { ...p, color: colorOf(p.source_id), selected: false },
|
||||
}));
|
||||
const es: Edge[] = [];
|
||||
// Parent-group "constellation": link a parent's children to the group's first child so you
|
||||
// can see which small chunks belong to the same parent window (parent_child mode only).
|
||||
const byParent = new Map<string, ChunkPoint[]>();
|
||||
for (const p of res.points) {
|
||||
if (!p.parent_id) continue;
|
||||
let arr = byParent.get(p.parent_id);
|
||||
if (!arr) { arr = []; byParent.set(p.parent_id, arr); }
|
||||
arr.push(p);
|
||||
}
|
||||
for (const [pid, kids] of byParent) {
|
||||
if (kids.length < 2) continue;
|
||||
const hub = kids[0].id;
|
||||
for (const k of kids.slice(1)) {
|
||||
es.push({ id: `pc-${pid}-${k.id}`, source: hub, target: k.id, sourceHandle: "s", targetHandle: "t", selectable: false, style: { stroke: "var(--line)", strokeWidth: 1, opacity: 0.5 } });
|
||||
}
|
||||
}
|
||||
// Query -> retrieved links (only when a query overlay is present).
|
||||
if (res.query_point) {
|
||||
ns.push({ id: "__query__", type: "query", position: { x: res.query_point[0], y: res.query_point[1] }, draggable: false, data: { label: `query: ${res.query}` } });
|
||||
for (const p of res.points) if (p.retrieved) {
|
||||
es.push({ id: `q-${p.id}`, source: "__query__", target: p.id, sourceHandle: "s", targetHandle: "t", animated: true, selectable: false, style: { stroke: RETRIEVED, strokeWidth: 1.5 } });
|
||||
}
|
||||
}
|
||||
setNodes(ns);
|
||||
setEdges(es);
|
||||
// Frame the new layout after React Flow measures the nodes.
|
||||
setTimeout(() => fitView({ padding: 0.15, duration: 250 }).catch?.(() => {}), 60);
|
||||
}, [res, colorOf, setNodes, setEdges, fitView]);
|
||||
|
||||
// Selection just flips the highlight ring on the affected dots. It must NOT re-run the builder
|
||||
// above (which re-fits the view and was zooming the map out on every click) — patch the
|
||||
// `selected` flag in place, leaving unchanged nodes untouched so React Flow does minimal work.
|
||||
useEffect(() => {
|
||||
setNodes((nds) => nds.map((n) => {
|
||||
if (n.type !== "chunk") return n;
|
||||
const sel = n.id === selId;
|
||||
return (n.data as any).selected === sel ? n : { ...n, data: { ...n.data, selected: sel } };
|
||||
}));
|
||||
}, [selId, setNodes]);
|
||||
|
||||
// Pull the FULL chunk text on demand when a dot is selected. The panel shows the short preview
|
||||
// from the map payload instantly, then swaps in the full text once it arrives (or keeps the
|
||||
// preview if the fetch fails). `cancelled` guards against a slow response for a stale selection.
|
||||
useEffect(() => {
|
||||
if (!selId || !project?.id) { setDetail(null); setDetailErr(false); setDetailBusy(false); return; }
|
||||
let cancelled = false;
|
||||
setDetail(null); setDetailErr(false); setDetailBusy(true);
|
||||
api.chunkDetail(project.id, selId)
|
||||
.then((d) => { if (!cancelled) setDetail(d); })
|
||||
.catch(() => { if (!cancelled) setDetailErr(true); })
|
||||
.finally(() => { if (!cancelled) setDetailBusy(false); });
|
||||
return () => { cancelled = true; };
|
||||
}, [selId, project?.id]);
|
||||
|
||||
const selected = res?.points.find((p) => p.id === selId) || null;
|
||||
const empty = res && res.points.length === 0;
|
||||
|
||||
return (
|
||||
<div className="col" style={{ gap: 10 }}>
|
||||
{/* controls */}
|
||||
<div className="row gap2" style={{ alignItems: "center", flexWrap: "wrap" }}>
|
||||
<input className="input" style={{ flex: 1, minWidth: 220 }} placeholder="Overlay a query to see what retrieval returns…" value={q}
|
||||
onChange={(e) => setQ(e.target.value)} onKeyDown={(e) => e.key === "Enter" && load(q)} />
|
||||
{folders.length > 0 && (
|
||||
<select className="select" style={{ width: 150 }} value={folder} onChange={(e) => setFolder(e.target.value)}>
|
||||
<option value="">All folders</option>
|
||||
{folders.map((f) => <option key={f} value={f}>{f}</option>)}
|
||||
</select>
|
||||
)}
|
||||
<button className="btn btn-primary" onClick={() => load(q)} disabled={loading}><Icon name="search" size={14} />{loading ? "Mapping…" : "Map query"}</button>
|
||||
{res?.query && <button className="btn btn-ghost btn-sm" onClick={() => { setQ(""); load(); }}>Clear overlay</button>}
|
||||
</div>
|
||||
<div className="row gap2" style={{ alignItems: "center", flexWrap: "wrap" }}>
|
||||
<Segmented options={[{ value: "vector", label: "Vector" }, { value: "hybrid", label: "Hybrid" }]} value={mode} onChange={(v) => setMode(v as any)} />
|
||||
<label className="row gap1" style={{ alignItems: "center", cursor: "pointer", fontSize: 13 }} title="Two-stage cross-encoder rerank for the query overlay.">
|
||||
<input type="checkbox" checked={rerank} onChange={(e) => setRerank(e.target.checked)} />Rerank
|
||||
</label>
|
||||
<label className="row gap1" style={{ alignItems: "center", fontSize: 13 }} title="How many chunks to plot. More points give a fuller picture but take longer to project.">
|
||||
Max points
|
||||
<select className="select" style={{ width: 84 }} value={limit} onChange={(e) => setLimit(Number(e.target.value))}>
|
||||
{POINT_LIMITS.map((n) => <option key={n} value={n}>{n}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<span className="t-caption fg-2">Dots are chunks placed by semantic similarity (PCA), colored by source. Overlay a query to mark retrieved chunks (◆ = query).</span>
|
||||
</div>
|
||||
|
||||
{/* legend + truncation note */}
|
||||
{res && res.sources.length > 0 && (
|
||||
<div className="row gap2" style={{ alignItems: "center", flexWrap: "wrap" }}>
|
||||
{res.sources.map((s, i) => (
|
||||
<span key={s.id} className="row gap1 t-caption" style={{ alignItems: "center" }}>
|
||||
<span style={{ width: 10, height: 10, borderRadius: "50%", background: PALETTE[i % PALETTE.length], display: "inline-block" }} />
|
||||
<span className="truncate" style={{ maxWidth: 160 }}>{s.name}</span>
|
||||
</span>
|
||||
))}
|
||||
<span className="t-caption fg-2" style={{ marginLeft: "auto" }}>
|
||||
{res.truncated ? `showing ${res.points.length} of ${res.total} chunks` : `${res.total} chunks`}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{err && <div className="t-caption" style={{ color: "var(--danger, #c00)" }}>⚠ {err}</div>}
|
||||
|
||||
{/* canvas + detail panel */}
|
||||
<div className="card" style={{ position: "relative", height: 560, overflow: "hidden", padding: 0 }}>
|
||||
{empty ? (
|
||||
<div className="col center" style={{ width: "100%", height: "100%", color: "var(--fg-2)", gap: 6 }}>
|
||||
<Icon name="layers" size={22} />
|
||||
<div>No chunks yet. Add sources in the Files tab, then map them here.</div>
|
||||
</div>
|
||||
) : (
|
||||
<ReactFlow
|
||||
style={{ width: "100%", height: "100%" }}
|
||||
nodes={nodes} edges={edges} nodeTypes={NODE_TYPES}
|
||||
onNodesChange={onNodesChange} onEdgesChange={onEdgesChange}
|
||||
onNodeClick={(_, n) => setSelId(n.id === "__query__" ? null : n.id)} onPaneClick={() => setSelId(null)}
|
||||
nodesDraggable={false} nodesConnectable={false} minZoom={0.15}
|
||||
fitView proOptions={{ hideAttribution: true }}
|
||||
>
|
||||
<Background variant={BackgroundVariant.Dots} gap={18} size={1} color="var(--canvas-grid)" />
|
||||
<Controls showInteractive={false} />
|
||||
<MiniMap pannable zoomable style={{ background: "var(--bg-1)" }} nodeColor={(n) => (n.data as any)?.color || RETRIEVED} />
|
||||
</ReactFlow>
|
||||
)}
|
||||
|
||||
{selected && (
|
||||
<div className="card" style={{ position: "absolute", top: 10, right: 10, width: 300, padding: 12, boxShadow: "var(--sh-pop)", zIndex: 20 }}>
|
||||
<div className="row spread" style={{ marginBottom: 6 }}>
|
||||
<span className="t-micro">Chunk</span>
|
||||
<button className="iconbtn" onClick={() => setSelId(null)}><Icon name="x" size={14} /></button>
|
||||
</div>
|
||||
<div className="row gap2 t-caption fg-2" style={{ marginBottom: 8, flexWrap: "wrap" }}>
|
||||
<span className="chip">{res?.sources.find((s) => s.id === selected.source_id)?.name || selected.source_id || "-"}</span>
|
||||
{selected.chunk_idx != null && <span className="chip chip-mono">#{selected.chunk_idx}</span>}
|
||||
{selected.retrieved && <span className="chip chip-mono" style={{ color: RETRIEVED }}>rank {selected.retrieved}</span>}
|
||||
{selected.parent_id && <span className="chip chip-mono" title={selected.parent_id}>parent</span>}
|
||||
</div>
|
||||
<div className="t-body-sm" style={{ maxHeight: 360, overflow: "auto", whiteSpace: "pre-wrap" }}>
|
||||
{detail && detail.id === selId ? detail.text : selected.preview}
|
||||
</div>
|
||||
{detailBusy && <div className="t-caption fg-2" style={{ marginTop: 6 }}>Loading full chunk…</div>}
|
||||
{detailErr && <div className="t-caption fg-2" style={{ marginTop: 6 }}>Showing preview — full text unavailable.</div>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,371 @@
|
||||
"use client";
|
||||
/* Components screen (Feature 2 - generative UI): author UI widgets (HTML + CSS + props +
|
||||
button actions) that an agent can render in chat. Code-first editor (no visual builder,
|
||||
per product direction) with a live sandboxed preview. A new component pre-loads with a
|
||||
simple 2-column table example. */
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { Icon } from "../icons";
|
||||
import { Tile } from "../primitives";
|
||||
import { VersionHistory } from "../version-history";
|
||||
import { ImportExport } from "../import-export";
|
||||
import { api, ComponentT } from "@/lib/api";
|
||||
import { ComponentRenderer } from "../component-renderer";
|
||||
|
||||
const DEFAULT_HTML = `<div class="card">
|
||||
<div class="title">{{title}}</div>
|
||||
<table>
|
||||
{{#col1}}<thead><tr><th>{{col1}}</th><th>{{col2}}</th></tr></thead>{{/col1}}
|
||||
<tbody>
|
||||
{{#rows}}
|
||||
<tr><td>{{label}}</td><td>{{value}}</td></tr>
|
||||
{{/rows}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>`;
|
||||
|
||||
const DEFAULT_CSS = `.card { font-family: system-ui, -apple-system, sans-serif; border: 1px solid #e3e3e8; border-radius: 12px; overflow: hidden; max-width: 420px; background: #fff; color: #1a1a1f; }
|
||||
.title { font-weight: 600; font-size: 14px; padding: 10px 14px; border-bottom: 1px solid #e3e3e8; }
|
||||
table { width: 100%; border-collapse: collapse; font-size: 13px; }
|
||||
th, td { text-align: left; padding: 8px 14px; border-bottom: 1px solid #f0f0f3; }
|
||||
th { color: #6b6b76; font-weight: 600; background: #fafafb; }
|
||||
tbody tr:last-child td { border-bottom: none; }`;
|
||||
|
||||
const DEFAULT_PROPS_SCHEMA = {
|
||||
type: "object",
|
||||
properties: {
|
||||
title: { type: "string", description: "Card title" },
|
||||
col1: { type: "string", description: "First column header" },
|
||||
col2: { type: "string", description: "Second column header" },
|
||||
rows: { type: "array", description: "Rows, each an object with label and value" },
|
||||
},
|
||||
required: ["title"],
|
||||
};
|
||||
|
||||
const DEFAULT_SAMPLE = {
|
||||
title: "Weather - London",
|
||||
col1: "Day",
|
||||
col2: "Forecast",
|
||||
rows: [
|
||||
{ label: "Mon", value: "Sunny · 24°C" },
|
||||
{ label: "Tue", value: "Cloudy · 21°C" },
|
||||
{ label: "Wed", value: "Rain · 18°C" },
|
||||
],
|
||||
};
|
||||
|
||||
const NEW_COMPONENT = {
|
||||
name: "new_component",
|
||||
title: "New component",
|
||||
description: "A UI component the agent can render for the user.",
|
||||
html: DEFAULT_HTML,
|
||||
css: DEFAULT_CSS,
|
||||
props_schema: DEFAULT_PROPS_SCHEMA,
|
||||
sample_props: DEFAULT_SAMPLE,
|
||||
actions: [],
|
||||
};
|
||||
|
||||
export function ComponentsScreen({ project, onOpen }: { project: any; onOpen: (c: ComponentT) => void }) {
|
||||
const [items, setItems] = useState<ComponentT[]>([]);
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
const [creating, setCreating] = useState(false);
|
||||
|
||||
const reload = useCallback(() => {
|
||||
if (!project?.id) return;
|
||||
setLoaded(false);
|
||||
setErr(null);
|
||||
api
|
||||
.listComponents(project.id)
|
||||
.then((c) => {
|
||||
setItems(c);
|
||||
setLoaded(true);
|
||||
})
|
||||
.catch((e) => {
|
||||
setErr(String(e.message || e));
|
||||
setLoaded(true);
|
||||
});
|
||||
}, [project?.id]);
|
||||
useEffect(() => { reload(); }, [reload]);
|
||||
|
||||
async function create() {
|
||||
if (creating) return;
|
||||
setCreating(true);
|
||||
try {
|
||||
const c = await api.createComponent(project.id, NEW_COMPONENT as any);
|
||||
onOpen(c);
|
||||
} catch (e: any) {
|
||||
setErr(String(e.message || e));
|
||||
} finally {
|
||||
setCreating(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="col grow scroll-y" style={{ minHeight: 0 }}>
|
||||
<div className="row spread" style={{ padding: "16px 20px", borderBottom: "1px solid var(--line)" }}>
|
||||
<div className="row gap2">
|
||||
<div>
|
||||
<div className="t-display">Components</div>
|
||||
<div className="fg-2 t-caption">UI widgets the agent can render in chat - attached to agents like tools.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="row gap2">
|
||||
<ImportExport project={project} type="component" typeLabel="component" onImported={reload}
|
||||
items={items.map((c) => ({ id: c.id, name: c.name, sub: c.title || c.description || undefined }))} />
|
||||
<button className="btn btn-primary btn-sm" onClick={create} disabled={creating || !project}>
|
||||
<Icon name="plus" size={14} />
|
||||
New component
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ padding: 20 }}>
|
||||
{err && <div className="card" style={{ padding: 14, color: "var(--err)", marginBottom: 12 }}>{err}</div>}
|
||||
{!loaded && <div className="fg-2 t-body-sm">Loading…</div>}
|
||||
{loaded && items.length === 0 && !err && (
|
||||
<div className="col center" style={{ minHeight: 220, gap: 8, color: "var(--fg-2)", textAlign: "center" }}>
|
||||
<Tile icon="grid" color="var(--accent)" size={40} />
|
||||
<div className="t-h3" style={{ color: "var(--fg-1)" }}>No components yet</div>
|
||||
<div className="t-caption" style={{ maxWidth: 380 }}>
|
||||
Author an HTML/CSS widget - a table, product card, or form - then attach it to an agent. The agent renders it in chat when relevant.
|
||||
</div>
|
||||
<button className="btn btn-secondary btn-sm" onClick={create} disabled={creating} style={{ marginTop: 6 }}>
|
||||
<Icon name="plus" size={14} />
|
||||
New component
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(280px, 1fr))", gap: 12 }}>
|
||||
{items.map((c) => (
|
||||
<button
|
||||
key={c.id}
|
||||
className="card card-hover col"
|
||||
style={{ padding: 14, textAlign: "left", alignItems: "stretch", gap: 6 }}
|
||||
onClick={() => onOpen(c)}
|
||||
>
|
||||
<div className="row gap2" style={{ alignItems: "center" }}>
|
||||
<Tile icon="grid" color="var(--accent)" size={26} />
|
||||
<div className="grow" style={{ minWidth: 0 }}>
|
||||
<div className="t-h3 truncate">{c.title || c.name}</div>
|
||||
<div className="mono-sm fg-2 truncate">{c.name}</div>
|
||||
</div>
|
||||
{!c.enabled && (
|
||||
<span className="pill pill-muted">
|
||||
off
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
className="t-caption fg-2"
|
||||
style={{ overflow: "hidden", display: "-webkit-box", WebkitLineClamp: 2, WebkitBoxOrient: "vertical" }}
|
||||
>
|
||||
{c.description || "-"}
|
||||
</div>
|
||||
<div className="row gap2" style={{ marginTop: 2 }}>
|
||||
<span className="typechip">{c.kind}</span>
|
||||
<span className="typechip">v{c.version}</span>
|
||||
{Array.isArray(c.actions) && c.actions.length > 0 && (
|
||||
<span className="typechip">
|
||||
{c.actions.length} action{c.actions.length !== 1 ? "s" : ""}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function tryParse(text: string): { value: any; error: string | null } {
|
||||
if (!text.trim()) return { value: undefined, error: null };
|
||||
try {
|
||||
return { value: JSON.parse(text), error: null };
|
||||
} catch (e: any) {
|
||||
return { value: undefined, error: String(e.message || e) };
|
||||
}
|
||||
}
|
||||
|
||||
export function ComponentBuilderScreen({
|
||||
project,
|
||||
componentId,
|
||||
onBack,
|
||||
}: {
|
||||
project: any;
|
||||
componentId?: string;
|
||||
onBack: () => void;
|
||||
}) {
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
const [name, setName] = useState("new_component");
|
||||
const [title, setTitle] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [html, setHtml] = useState(DEFAULT_HTML);
|
||||
const [css, setCss] = useState(DEFAULT_CSS);
|
||||
const [propsText, setPropsText] = useState(JSON.stringify(DEFAULT_PROPS_SCHEMA, null, 2));
|
||||
const [sampleText, setSampleText] = useState(JSON.stringify(DEFAULT_SAMPLE, null, 2));
|
||||
const [actionsText, setActionsText] = useState("[]");
|
||||
const [enabled, setEnabled] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [status, setStatus] = useState<string | null>(null);
|
||||
const [reloadKey, setReloadKey] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (!project?.id || !componentId) {
|
||||
setLoaded(true);
|
||||
return;
|
||||
}
|
||||
setLoaded(false);
|
||||
api
|
||||
.getComponent(project.id, componentId)
|
||||
.then((c) => {
|
||||
setName(c.name);
|
||||
setTitle(c.title || "");
|
||||
setDescription(c.description || "");
|
||||
setHtml(c.html || "");
|
||||
setCss(c.css || "");
|
||||
setPropsText(JSON.stringify(c.props_schema || {}, null, 2));
|
||||
setSampleText(JSON.stringify(c.sample_props || {}, null, 2));
|
||||
setActionsText(JSON.stringify(c.actions || [], null, 2));
|
||||
setEnabled(c.enabled);
|
||||
setLoaded(true);
|
||||
})
|
||||
.catch(() => setLoaded(true));
|
||||
}, [project?.id, componentId, reloadKey]);
|
||||
|
||||
const sample = useMemo(() => tryParse(sampleText), [sampleText]);
|
||||
const actionsParsed = useMemo(() => tryParse(actionsText), [actionsText]);
|
||||
const propsParsed = useMemo(() => tryParse(propsText), [propsText]);
|
||||
const previewProps = sample.error ? {} : sample.value || {};
|
||||
const previewActions = actionsParsed.error ? [] : actionsParsed.value || [];
|
||||
|
||||
async function save() {
|
||||
if (saving) return;
|
||||
if (propsParsed.error) return setStatus("Props schema is not valid JSON.");
|
||||
if (sample.error) return setStatus("Sample props is not valid JSON.");
|
||||
if (actionsParsed.error) return setStatus("Actions is not valid JSON.");
|
||||
setSaving(true);
|
||||
setStatus(null);
|
||||
const body = {
|
||||
name: name.trim().replace(/\s+/g, "_") || "component",
|
||||
title: title || null,
|
||||
description,
|
||||
html,
|
||||
css,
|
||||
props_schema: propsParsed.value || {},
|
||||
sample_props: sample.value || {},
|
||||
actions: previewActions,
|
||||
enabled,
|
||||
};
|
||||
try {
|
||||
if (componentId) await api.updateComponent(project.id, componentId, body);
|
||||
else await api.createComponent(project.id, body as any);
|
||||
setStatus("Saved.");
|
||||
setTimeout(() => setStatus(null), 1600);
|
||||
} catch (e: any) {
|
||||
setStatus(`Save failed: ${e.message || e}`);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function del() {
|
||||
if (!componentId) return;
|
||||
if (!window.confirm(`Delete component "${name}"?`)) return;
|
||||
await api.deleteComponent(project.id, componentId);
|
||||
onBack();
|
||||
}
|
||||
|
||||
const codeStyle: any = { fontFamily: "var(--font-mono)", fontSize: 12, lineHeight: "18px", minHeight: 120, resize: "vertical" };
|
||||
const field = (label: string, node: any, help?: string, helpErr?: boolean) => (
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<label className="field-label">{label}</label>
|
||||
{node}
|
||||
{help && <div className="field-help" style={helpErr ? { color: "var(--err)" } : undefined}>{help}</div>}
|
||||
</div>
|
||||
);
|
||||
|
||||
if (!loaded) return <div className="col center grow" style={{ color: "var(--fg-2)" }}>Loading…</div>;
|
||||
|
||||
return (
|
||||
<div className="col grow" style={{ minHeight: 0 }}>
|
||||
<div className="row spread" style={{ padding: "12px 20px", borderBottom: "1px solid var(--line)", flex: "none" }}>
|
||||
<div className="row gap2">
|
||||
<button className="iconbtn" onClick={onBack} title="Back to Components">
|
||||
<Icon name="chevleft" size={17} />
|
||||
</button>
|
||||
<div>
|
||||
<div className="t-h2">{title || name}</div>
|
||||
<div className="mono-sm fg-2">{name}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="row gap2" style={{ alignItems: "center" }}>
|
||||
{status && (
|
||||
<span
|
||||
className="t-caption"
|
||||
style={{ color: status.includes("fail") || status.includes("not valid") ? "var(--err)" : "var(--ok)" }}
|
||||
>
|
||||
{status}
|
||||
</span>
|
||||
)}
|
||||
<label className="row gap2" style={{ alignItems: "center", fontSize: 12, color: "var(--fg-1)", cursor: "pointer" }}>
|
||||
<span className={"toggle" + (enabled ? " on" : "")} onClick={() => setEnabled((v) => !v)} role="switch" aria-checked={enabled} />
|
||||
enabled
|
||||
</label>
|
||||
{componentId && <VersionHistory entityType="component" entityId={componentId} entityLabel={name} onRestored={() => setReloadKey((k) => k + 1)} />}
|
||||
{componentId && (
|
||||
<button className="btn btn-danger btn-sm" onClick={del}>
|
||||
Delete
|
||||
</button>
|
||||
)}
|
||||
<button className="btn btn-primary btn-sm" onClick={save} disabled={saving}>
|
||||
<Icon name={saving ? "refresh" : "check"} size={14} style={saving ? { animation: "spin 1s linear infinite" } : {}} />
|
||||
{saving ? "Saving…" : "Save"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="row" style={{ flex: 1, minHeight: 0, alignItems: "stretch" }}>
|
||||
<div className="scroll-y" style={{ flex: 1, minWidth: 0, padding: 20, borderRight: "1px solid var(--line)" }}>
|
||||
{field("Name", <input className="input mono" value={name} onChange={(e) => setName(e.target.value)} placeholder="product_card" />, "Machine name - this is the tool name the agent calls.")}
|
||||
{field("Title", <input className="input" value={title} onChange={(e) => setTitle(e.target.value)} placeholder="Product card" />)}
|
||||
{field(
|
||||
"Description",
|
||||
<textarea className="textarea" value={description} onChange={(e) => setDescription(e.target.value)} placeholder="When should the agent render this?" style={{ minHeight: 56 }} />,
|
||||
"Model-facing: tells the agent when to use this component.",
|
||||
)}
|
||||
{field(
|
||||
"HTML",
|
||||
<textarea className="textarea" value={html} onChange={(e) => setHtml(e.target.value)} style={codeStyle} spellCheck={false} />,
|
||||
'Mustache: {{prop}} for values, {{#rows}}…{{/rows}} to loop. Buttons: add data-forge-action="id".',
|
||||
)}
|
||||
{field("CSS", <textarea className="textarea" value={css} onChange={(e) => setCss(e.target.value)} style={codeStyle} spellCheck={false} />)}
|
||||
{field(
|
||||
"Props schema (JSON)",
|
||||
<textarea className="textarea" value={propsText} onChange={(e) => setPropsText(e.target.value)} style={{ ...codeStyle, borderColor: propsParsed.error ? "var(--err)" : undefined }} spellCheck={false} />,
|
||||
propsParsed.error ? `Invalid JSON: ${propsParsed.error}` : "JSON Schema for the props the agent supplies.",
|
||||
!!propsParsed.error,
|
||||
)}
|
||||
{field(
|
||||
"Sample props (JSON)",
|
||||
<textarea className="textarea" value={sampleText} onChange={(e) => setSampleText(e.target.value)} style={{ ...codeStyle, borderColor: sample.error ? "var(--err)" : undefined }} spellCheck={false} />,
|
||||
sample.error ? `Invalid JSON: ${sample.error}` : "Drives the live preview.",
|
||||
!!sample.error,
|
||||
)}
|
||||
{field(
|
||||
"Actions (JSON)",
|
||||
<textarea className="textarea" value={actionsText} onChange={(e) => setActionsText(e.target.value)} style={{ ...codeStyle, minHeight: 80, borderColor: actionsParsed.error ? "var(--err)" : undefined }} spellCheck={false} />,
|
||||
actionsParsed.error ? `Invalid JSON: ${actionsParsed.error}` : 'Buttons: [{ "id": "add", "label": "Add", "message": "Add {{props.title}} to cart" }]',
|
||||
!!actionsParsed.error,
|
||||
)}
|
||||
</div>
|
||||
<div className="scroll-y" style={{ width: 460, flex: "none", padding: 20, background: "var(--bg-0)" }}>
|
||||
<div className="t-micro" style={{ marginBottom: 10 }}>Live preview</div>
|
||||
<div className="card" style={{ padding: 14 }}>
|
||||
<ComponentRenderer def={{ name, html, css, actions: previewActions }} props={previewProps} onAction={(a, f) => setStatus(`Action "${a}" → ${JSON.stringify(f)}`)} />
|
||||
</div>
|
||||
<div className="field-help" style={{ marginTop: 10 }}>
|
||||
Rendered in a sandboxed iframe with your sample props. Clicking a button shows what it would send back to the agent.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,583 @@
|
||||
"use client";
|
||||
/* Connect (MCP) screen - expose this project's tools as an MCP server for external clients.
|
||||
(Consuming external MCP servers lives in the BUILD → External MCP tab.) */
|
||||
import { ReactNode, useCallback, useEffect, useState } from "react";
|
||||
import { Icon } from "../icons";
|
||||
import { CodeBlock, Field, Segmented, Toggle } from "../primitives";
|
||||
import { api, type McpToken, type MyConnection, type ToolSet } from "@/lib/api";
|
||||
import { EmbedPanel } from "./embed";
|
||||
|
||||
/* Collapsible detail section - keeps the deep integration reference tucked away so the
|
||||
Connect screen stays scannable; expand only what you need. */
|
||||
function Collapse({ title, sub, defaultOpen = false, children }: { title: string; sub?: string; defaultOpen?: boolean; children: ReactNode }) {
|
||||
const [open, setOpen] = useState(defaultOpen);
|
||||
return (
|
||||
<div className="card" style={{ padding: 0, marginBottom: 10, overflow: "hidden" }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
aria-expanded={open}
|
||||
className="row spread"
|
||||
style={{ width: "100%", background: "none", border: "none", cursor: "pointer", padding: "12px 16px", textAlign: "left", fontFamily: "var(--font-ui)", color: "inherit" }}
|
||||
>
|
||||
<div>
|
||||
<div className="t-h3">{title}</div>
|
||||
{sub && <div className="t-caption fg-2" style={{ marginTop: 2 }}>{sub}</div>}
|
||||
</div>
|
||||
<Icon name={open ? "chevdown" : "chevright"} size={16} style={{ color: "var(--fg-2)", flex: "none", marginLeft: 12 }} />
|
||||
</button>
|
||||
{open && <div style={{ padding: "0 16px 16px" }}>{children}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* One SSE frame documented in the streaming reference: event name + what its data carries. */
|
||||
function FrameRow({ event, children }: { event: string; children: ReactNode }) {
|
||||
return (
|
||||
<div className="row gap2" style={{ alignItems: "baseline", padding: "5px 0", borderTop: "1px solid var(--line)" }}>
|
||||
<span className="mono-sm" style={{ minWidth: 92, flex: "none", color: "var(--fg-2)" }}>{event}</span>
|
||||
<span className="t-caption fg-1">{children}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* Left secondary-nav sections (mirrors the Settings screen layout) so the Connect screen
|
||||
is navigable instead of one long scroll. */
|
||||
type ConnSection = "run" | "reference" | "mcp" | "embed";
|
||||
const CONN_SECTIONS: { id: ConnSection; label: string; icon: string; sub: string; child?: boolean }[] = [
|
||||
{ id: "run", label: "Run API", icon: "bolt", sub: "Call this project's workflow from your backend over one endpoint." },
|
||||
{ id: "reference", label: "Integration reference", icon: "traces", sub: "The wire format for streaming and non-streaming responses.", child: true },
|
||||
{ id: "mcp", label: "MCP server", icon: "connect", sub: "Expose this project's tools to Claude Desktop, Cursor, or VS Code." },
|
||||
{ id: "embed", label: "Embed", icon: "grid", sub: "Drop this project's chatbot into any website as a widget." },
|
||||
];
|
||||
|
||||
/* A per-user credential connect row: the CURRENT user pastes their own downstream token for a
|
||||
per-user auth provider. Stored server-side keyed by their user id (the identity their MCP PAT
|
||||
resolves to), so tool calls act as them without a shared secret. */
|
||||
function MyConnectionCard({ project, ap }: { project: any; ap: MyConnection }) {
|
||||
const [status, setStatus] = useState<{ connected: boolean } | null>(null);
|
||||
const [token, setToken] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
const refresh = useCallback(() => { api.getMyConnection(project.id, ap.id).then(setStatus).catch(() => setStatus(null)); }, [project.id, ap.id]);
|
||||
useEffect(() => { refresh(); }, [refresh]);
|
||||
|
||||
async function save() {
|
||||
setErr(null); setBusy(true);
|
||||
try {
|
||||
const res = await api.setMyConnection(project.id, ap.id, token.trim());
|
||||
if (!res.ok) throw new Error("Could not save token.");
|
||||
setToken(""); refresh();
|
||||
} catch (e: any) { setErr(e?.message || String(e)); } finally { setBusy(false); }
|
||||
}
|
||||
async function clear() { try { await api.clearMyConnection(project.id, ap.id); } catch { /* best-effort */ } refresh(); }
|
||||
|
||||
return (
|
||||
<div className="card" style={{ padding: 12, background: "var(--bg-2)" }}>
|
||||
<div className="row spread" style={{ marginBottom: 8 }}>
|
||||
<div className="row gap2" style={{ alignItems: "center", minWidth: 0 }}>
|
||||
<Icon name="auth" size={13} style={{ color: "var(--fg-2)" }} />
|
||||
<span className="mono-sm truncate">{ap.name}</span>
|
||||
{status?.connected ? <span className="pill pill-ok" style={{ height: 16 }}>connected</span> : <span className="pill pill-muted" style={{ height: 16 }}>not connected</span>}
|
||||
</div>
|
||||
{status?.connected && <button className="btn btn-ghost btn-sm" onClick={clear}>Clear</button>}
|
||||
</div>
|
||||
<div className="row gap2">
|
||||
<input className="input mono" type="password" value={token} onChange={(e) => setToken(e.target.value)} placeholder="paste your token…" style={{ flex: 1 }} />
|
||||
<button className="btn btn-primary btn-sm" onClick={save} disabled={busy || !token.trim()}>{busy ? "Saving…" : "Save"}</button>
|
||||
</div>
|
||||
{err && <div className="t-caption" style={{ color: "var(--err)", marginTop: 6 }}>{err}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ============ CONNECT (MCP) ============ */
|
||||
export function ConnectScreen({ project }: { project: any }) {
|
||||
const [section, setSection] = useState<ConnSection>("run");
|
||||
const [tools, setTools] = useState<any[]>([]);
|
||||
const [toolSets, setToolSets] = useState<ToolSet[]>([]);
|
||||
const [tsSave, setTsSave] = useState<"idle" | "saving" | "saved">("idle");
|
||||
const [excluded, setExcluded] = useState<string[]>([]);
|
||||
const [openSets, setOpenSets] = useState<Set<string>>(new Set());
|
||||
const [mcpTokens, setMcpTokens] = useState<McpToken[]>([]);
|
||||
// Per-user ("external") auth providers for this project - each lets the current user connect their
|
||||
// own downstream token so MCP tool calls act as them (see the "Connect your accounts" card).
|
||||
const [perUserAps, setPerUserAps] = useState<MyConnection[]>([]);
|
||||
const [newToken, setNewToken] = useState("");
|
||||
const [apiKey, setApiKey] = useState("");
|
||||
const [save, setSave] = useState<"idle" | "saving" | "saved">("idle");
|
||||
const [credTab, setCredTab] = useState<"key" | "pat">("key"); // which credential to paste - it's either/or
|
||||
// Project-level MCP tools (mirror the server's project.config flags in mcp_server.py): the whole
|
||||
// workflow as one tool, plus knowledge-base + curated-Q&A search. Independent of toolsets.
|
||||
const [exposeWf, setExposeWf] = useState(false);
|
||||
const [wfToolName, setWfToolName] = useState("run_workflow");
|
||||
const [exposeKnowledge, setExposeKnowledge] = useState(false);
|
||||
const [exposeFaq, setExposeFaq] = useState(false);
|
||||
const [cfgSave, setCfgSave] = useState<"idle" | "saving" | "saved">("idle");
|
||||
// Run-API panel: pick the workflow this project's API runs (a saved setting) + the
|
||||
// backend-facing base URL, then show the one ready-to-copy endpoint.
|
||||
const [workflows, setWorkflows] = useState<any[]>([]);
|
||||
const [wfId, setWfId] = useState("");
|
||||
const [apiSave, setApiSave] = useState<"idle" | "saving" | "saved">("idle");
|
||||
const [apiBase, setApiBase] = useState(
|
||||
(process.env.NEXT_PUBLIC_FORGE_API_URL || "http://localhost:8000").replace(/\/$/, ""),
|
||||
);
|
||||
useEffect(() => {
|
||||
if (!project?.id) return;
|
||||
api.listTools(project.id).then(setTools).catch(() => {});
|
||||
api.listToolSets(project.id).then(setToolSets).catch(() => {});
|
||||
api.listMcpTokens(project.id).then(setMcpTokens).catch(() => {});
|
||||
api.listMyConnections(project.id).then(setPerUserAps).catch(() => {});
|
||||
}, [project?.id]);
|
||||
useEffect(() => {
|
||||
if (!project?.id) return;
|
||||
Promise.all([api.getProject(project.id), api.listWorkflows(project.id)]).then(([p, ws]) => {
|
||||
setApiKey((p.config as any)?.mcp_api_key || "");
|
||||
setExcluded(((p.config as any)?.mcp_excluded_tools as string[]) || []);
|
||||
const cfg = (p.config as any) || {};
|
||||
setExposeWf(!!cfg.mcp_expose_workflow);
|
||||
setWfToolName(cfg.mcp_workflow_tool_name || "run_workflow");
|
||||
setExposeKnowledge(!!cfg.mcp_expose_knowledge);
|
||||
setExposeFaq(!!cfg.mcp_expose_faq);
|
||||
setWorkflows(ws);
|
||||
// Default the picker to the saved API workflow; else the active one, else the first.
|
||||
const saved = (p.config as any)?.api_workflow_id;
|
||||
const chosen = ws.find((w: any) => w.id === saved) || ws.find((w: any) => w.status === "active") || ws[0];
|
||||
setWfId(chosen ? chosen.id : "");
|
||||
}).catch(() => {});
|
||||
}, [project?.id]);
|
||||
|
||||
// MCP clients must reach the API DIRECTLY, not the same-origin /api/forge console proxy: the
|
||||
// OAuth discovery + dynamic-registration endpoints live at the API root (/.well-known/*,
|
||||
// /v1/oauth/*), which the proxy does NOT forward. A proxied :3000 URL works for API-key/PAT
|
||||
// (the JSON-RPC tunnels through the proxy) but breaks the OAuth sign-in flow - the client
|
||||
// tries to discover the auth server at :3000 and gives up. Use the same base as the Run API.
|
||||
const mcpBase = (apiBase || "http://localhost:8000").replace(/\/$/, "");
|
||||
const url = `${mcpBase}/v1/mcp/${project?.id || "<project>"}`;
|
||||
const claudeConfig = JSON.stringify({ mcpServers: { [project?.slug || "forge"]: { url, headers: { Authorization: "Bearer <PAT or API key>" } } } }, null, 2);
|
||||
// The MCP surface = enabled tools of EXPOSED sets, minus individually excluded ones (mirrors
|
||||
// the server; see mcp_server.py._exposed_names).
|
||||
const toolById = new Map(tools.map((t) => [t.id, t]));
|
||||
const exposedToolIds = new Set(toolSets.filter((s) => s.exposed).flatMap((s) => s.tool_ids).filter((id) => !excluded.includes(id)));
|
||||
const exposedTools = tools.filter((t) => t.enabled && exposedToolIds.has(t.id));
|
||||
// Project-level tools published alongside the toolset tools (see mcp_server.py._capability_tools
|
||||
// + _workflow_tool_name). Shown in the "Currently exposed" summary so the whole surface is visible.
|
||||
const projectTools = [
|
||||
...(exposeWf ? [{ name: wfToolName || "run_workflow", kind: "workflow" }] : []),
|
||||
...(exposeKnowledge ? [{ name: "search_knowledge_base", kind: "knowledge" }] : []),
|
||||
...(exposeFaq ? [{ name: "lookup_faq", kind: "qa" }] : []),
|
||||
];
|
||||
|
||||
// Run API (server-to-server): a backend hits the Forge API DIRECTLY, not the web proxy.
|
||||
// ONE endpoint per project - it runs the workflow chosen above; `stream` is the only
|
||||
// per-request knob, and HITL flows through the same call (workflow-driven).
|
||||
const base = (apiBase || "http://localhost:8000").replace(/\/$/, "");
|
||||
const pid = project?.id || "<projectId>";
|
||||
const runUrl = `${base}/v1/projects/${pid}/run`;
|
||||
const curl = [
|
||||
"# ONE endpoint. Auth with the service token; pass the caller's per-user secrets in",
|
||||
"# X-Forge-Context (used by tools as {{ctx.*}}) - never put secrets in the body.",
|
||||
`curl -sN "${runUrl}" \\`,
|
||||
` -H "Authorization: Bearer $FORGE_SERVICE_API_TOKEN" \\`,
|
||||
` -H "Content-Type: application/json" \\`,
|
||||
` -H 'X-Forge-Context: {"jsessionid":"<user session>","csrf":"<user csrf>"}' \\`,
|
||||
` -H "Accept: text/event-stream" \\`,
|
||||
` -d '{"input":{"messages":[{"role":"user","content":"hello"}]},"end_user":{"id":"user-123"},"stream":true}'`,
|
||||
'# -> SSE frames; the "ready" frame gives you a thread_id to continue the conversation.',
|
||||
"",
|
||||
"# stream:false returns a single JSON reply instead of SSE.",
|
||||
"# answer a human-in-the-loop step the workflow raised (reuse the thread_id):",
|
||||
`# -d '{"thread_id":"<thread>","resume":{"value":"approve"}}'`,
|
||||
].join("\n");
|
||||
|
||||
// --- Reference payloads for the "How the integration works" section ---
|
||||
// A trimmed SSE transcript: one `ready` frame, a couple of token deltas, then `done`.
|
||||
const sampleStream = [
|
||||
"event: ready",
|
||||
'data: {"run_id":"run_a1b2","thread_id":"thr_x9"}',
|
||||
"",
|
||||
"event: node_start",
|
||||
'data: {"node":"assistant"}',
|
||||
"",
|
||||
"event: messages",
|
||||
'data: {"content":"Hi","type":"AIMessageChunk","node":"assistant"}',
|
||||
"",
|
||||
"event: messages",
|
||||
'data: {"content":" there!","type":"AIMessageChunk","node":"assistant"}',
|
||||
"",
|
||||
"event: done",
|
||||
'data: {"status":"done","answer":"Hi there!","total_tokens":812,"total_cost_usd":0.0021}',
|
||||
].join("\n");
|
||||
// The single object returned when stream:false (thread_id is added by the endpoint).
|
||||
const sampleJson = JSON.stringify(
|
||||
{
|
||||
run_id: "run_a1b2", thread_id: "thr_x9", status: "done",
|
||||
answer: "Hi there!", components: [], interrupted: false, interrupts: [],
|
||||
total_tokens: 812, total_cost_usd: 0.0021,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
);
|
||||
|
||||
async function saveApiWorkflow(next: string) {
|
||||
setWfId(next);
|
||||
setApiSave("saving");
|
||||
const p = await api.getProject(project.id);
|
||||
await api.updateProject(project.id, { config: { ...(p.config || {}), api_workflow_id: next || undefined } });
|
||||
setApiSave("saved");
|
||||
setTimeout(() => setApiSave("idle"), 1200);
|
||||
}
|
||||
|
||||
async function saveKey(next: string) {
|
||||
setApiKey(next); setSave("saving");
|
||||
const p = await api.getProject(project.id);
|
||||
await api.updateProject(project.id, { config: { ...(p.config || {}), mcp_api_key: next || undefined } });
|
||||
setSave("saved"); setTimeout(() => setSave("idle"), 1200);
|
||||
}
|
||||
// Merge a patch into project.config (undefined values drop the key). Used by the project-tool
|
||||
// toggles; re-reads config first so concurrent edits to other keys aren't clobbered.
|
||||
async function saveCfg(patch: Record<string, unknown>) {
|
||||
setCfgSave("saving");
|
||||
const p = await api.getProject(project.id);
|
||||
await api.updateProject(project.id, { config: { ...(p.config || {}), ...patch } });
|
||||
setCfgSave("saved"); setTimeout(() => setCfgSave("idle"), 1200);
|
||||
}
|
||||
const genKey = () => {
|
||||
// A shared MCP API key is a credential, so use the CSPRNG (crypto.getRandomValues),
|
||||
// never Math.random() - the latter is predictable and unsafe for secret material.
|
||||
const bytes = new Uint8Array(24);
|
||||
crypto.getRandomValues(bytes);
|
||||
saveKey("fmcp_" + Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join(""));
|
||||
};
|
||||
|
||||
// Publish a subset of tool sets on the base MCP endpoint (project.config.mcp_published_toolsets,
|
||||
// a list of set slugs). Empty => the base endpoint exposes every enabled tool (prior behavior).
|
||||
async function toggleExposed(ts: ToolSet) {
|
||||
setTsSave("saving");
|
||||
const updated = await api.updateToolSet(project.id, ts.id, { exposed: !ts.exposed });
|
||||
setToolSets((prev) => prev.map((x) => (x.id === ts.id ? updated : x)));
|
||||
setTsSave("saved");
|
||||
setTimeout(() => setTsSave("idle"), 1200);
|
||||
}
|
||||
async function saveExcluded(next: string[]) {
|
||||
setExcluded(next);
|
||||
setTsSave("saving");
|
||||
const p = await api.getProject(project.id);
|
||||
await api.updateProject(project.id, { config: { ...(p.config || {}), mcp_excluded_tools: next.length ? next : undefined } });
|
||||
setTsSave("saved");
|
||||
setTimeout(() => setTsSave("idle"), 1200);
|
||||
}
|
||||
const toggleToolExcluded = (tid: string) =>
|
||||
saveExcluded(excluded.includes(tid) ? excluded.filter((x) => x !== tid) : [...excluded, tid]);
|
||||
const toggleOpenSet = (id: string) =>
|
||||
setOpenSets((prev) => { const n = new Set(prev); if (n.has(id)) n.delete(id); else n.add(id); return n; });
|
||||
|
||||
async function genToken() {
|
||||
const t = await api.createMcpToken(project.id, {});
|
||||
setNewToken(t.token || "");
|
||||
api.listMcpTokens(project.id).then(setMcpTokens).catch(() => {});
|
||||
}
|
||||
async function revokeToken(id: string) {
|
||||
await api.revokeMcpToken(project.id, id);
|
||||
setMcpTokens((prev) => prev.filter((t) => t.id !== id));
|
||||
}
|
||||
|
||||
const activeMeta = CONN_SECTIONS.find((s) => s.id === section)!;
|
||||
return (
|
||||
<div className="col" style={{ flex: 1, minHeight: 0 }}>
|
||||
<div className="row" style={{ flex: 1, minHeight: 0, alignItems: "stretch" }}>
|
||||
{/* secondary nav (same pattern as Settings) */}
|
||||
<nav className="scroll-y" style={{ width: 224, flex: "none", borderRight: "1px solid var(--line)", background: "var(--bg-1)", padding: 10 }}>
|
||||
<div className="t-micro" style={{ padding: "6px 8px 8px" }}>Connect</div>
|
||||
{CONN_SECTIONS.map((s) => {
|
||||
const on = section === s.id;
|
||||
return (
|
||||
<button key={s.id} onClick={() => setSection(s.id)} className={"sidenav-item" + (on ? " active" : "")}
|
||||
style={{ display: "flex", alignItems: "center", gap: 10, width: "100%", height: 34, padding: "0 10px", paddingLeft: s.child ? 20 : 10, marginBottom: 1, borderRadius: 7, border: "none", cursor: "pointer", textAlign: "left", color: on ? "var(--accent)" : "var(--fg-1)", fontSize: 13, fontWeight: on ? 600 : 500, fontFamily: "var(--font-ui)" }}>
|
||||
{s.child && <span aria-hidden style={{ color: "var(--fg-2)", fontSize: 13, lineHeight: 1, marginRight: -4, flex: "none" }}>└</span>}
|
||||
<Icon name={s.icon as any} size={16} style={{ flex: "none" }} />
|
||||
<span className="grow truncate">{s.label}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
|
||||
{/* content */}
|
||||
<div className="scroll-y grow" style={{ minWidth: 0 }}>
|
||||
<div className="fade-up" style={{ maxWidth: 960, margin: "0 auto", padding: "24px 28px" }}>
|
||||
<div style={{ marginBottom: 18 }}>
|
||||
<div className="t-display">{activeMeta.label}</div>
|
||||
<div className="fg-1" style={{ marginTop: 3 }}>{activeMeta.sub}</div>
|
||||
</div>
|
||||
|
||||
{section === "run" && (
|
||||
<>
|
||||
<div className="card" style={{ padding: 16, marginBottom: 14 }}>
|
||||
<div className="row gap3" style={{ flexWrap: "wrap" }}>
|
||||
<div style={{ flex: 1, minWidth: 240 }}>
|
||||
<Field label="Forge API base URL" help="Where your backend reaches the Forge API directly (NOT the web console). Dev: http://localhost:8000. From another container on Forge's network: http://api:8000.">
|
||||
<input className="input mono" value={apiBase} onChange={(e) => setApiBase(e.target.value)} placeholder="http://localhost:8000" />
|
||||
</Field>
|
||||
</div>
|
||||
<div style={{ flex: 1, minWidth: 240 }}>
|
||||
<Field label="Workflow this API runs" help="Saved on the project. The /run endpoint always executes this workflow — callers never pick one.">
|
||||
<div className="row gap2" style={{ alignItems: "center" }}>
|
||||
<select className="select" style={{ flex: 1 }} value={wfId} onChange={(e) => saveApiWorkflow(e.target.value)}>
|
||||
{workflows.length === 0 && <option value="">No workflows yet</option>}
|
||||
{workflows.map((w) => <option key={w.id} value={w.id}>{w.name}{w.status !== "active" ? ` (${w.status})` : ""}</option>)}
|
||||
</select>
|
||||
<span className="t-caption fg-2" style={{ minWidth: 52 }}>{apiSave === "saving" ? "Saving…" : apiSave === "saved" ? "Saved ✓" : ""}</span>
|
||||
</div>
|
||||
</Field>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="card" style={{ padding: 16, marginBottom: 14 }}>
|
||||
<div className="t-h3" style={{ marginBottom: 8 }}>Endpoint · POST</div>
|
||||
<CodeBlock code={runUrl} />
|
||||
<div className="field-help" style={{ marginTop: 8 }}>
|
||||
One call does everything. Send <span className="mono-sm">{"{ input, stream }"}</span> for a new turn (reuse the returned <span className="mono-sm">thread_id</span> to continue a conversation), or <span className="mono-sm">{"{ thread_id, resume }"}</span> to answer a human-in-the-loop step the workflow raised. <span className="mono-sm">stream: true</span> streams SSE (tokens, steps, tools); <span className="mono-sm">false</span> returns one JSON reply. Authenticate with <span className="mono-sm">Authorization: Bearer <FORGE_SERVICE_API_TOKEN></span>; pass the caller's per-user session/CSRF as <span className="mono-sm">X-Forge-Context</span> so tools act on their behalf — never put secrets in the body.
|
||||
</div>
|
||||
</div>
|
||||
<div className="card" style={{ padding: 16, marginBottom: 14 }}>
|
||||
<div className="t-h3" style={{ marginBottom: 8 }}>Example (curl)</div>
|
||||
<CodeBlock code={curl} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{section === "reference" && (
|
||||
<>
|
||||
<div className="fg-2 t-caption" style={{ marginBottom: 10 }}>The same endpoint responds two ways depending on the <span className="mono-sm">stream</span> flag. Expand a section for the wire format.</div>
|
||||
|
||||
<Collapse title="Streaming — stream: true" sub="Server-Sent Events (text/event-stream): tokens, steps and tool activity as they happen." defaultOpen>
|
||||
<div className="field-help" style={{ margin: "10px 0" }}>
|
||||
The response stays open and emits <span className="mono-sm">event:</span> / <span className="mono-sm">data:</span> frames (data is JSON). Read it with any SSE client and keep the connection until a <span className="mono-sm">done</span>, <span className="mono-sm">error</span> or <span className="mono-sm">interrupt</span> frame arrives. Build the reply by concatenating each <span className="mono-sm">messages</span> frame's <span className="mono-sm">content</span> in order; the final <span className="mono-sm">done</span> frame also carries the whole <span className="mono-sm">answer</span> (authoritative — it covers non-LLM steps that never stream tokens).
|
||||
</div>
|
||||
<div style={{ margin: "10px 0" }}>
|
||||
<FrameRow event="ready">First frame. <span className="mono-sm">{"{ run_id, thread_id }"}</span> — save <span className="mono-sm">thread_id</span> to continue this conversation.</FrameRow>
|
||||
<FrameRow event="node_start">A workflow step began. <span className="mono-sm">{"{ node }"}</span>.</FrameRow>
|
||||
<FrameRow event="messages">Assistant answer token delta. <span className="mono-sm">{"{ content, type, node }"}</span> — concatenate <span className="mono-sm">content</span>.</FrameRow>
|
||||
<FrameRow event="updates">Top-level step output/state change (nested sub-steps are omitted to keep it clean).</FrameRow>
|
||||
<FrameRow event="custom">App-emitted data a node chose to stream (e.g. rich components).</FrameRow>
|
||||
<FrameRow event="interrupt">A human-in-the-loop step is waiting. Answer it with <span className="mono-sm">{"{ thread_id, resume }"}</span>.</FrameRow>
|
||||
<FrameRow event="node_error">A step failed. <span className="mono-sm">{"{ node, message }"}</span>.</FrameRow>
|
||||
<FrameRow event="done">Terminal. <span className="mono-sm">{"{ status, answer, total_tokens, total_cost_usd }"}</span>.</FrameRow>
|
||||
<FrameRow event="error">Terminal error. <span className="mono-sm">{"{ message }"}</span>.</FrameRow>
|
||||
</div>
|
||||
<CodeBlock code={sampleStream} />
|
||||
</Collapse>
|
||||
|
||||
<Collapse title="Non-streaming — stream: false" sub="One JSON object, returned once the run finishes.">
|
||||
<div className="field-help" style={{ margin: "10px 0" }}>
|
||||
Simplest to consume: a normal <span className="mono-sm">application/json</span> response after the run completes. <span className="mono-sm">status</span> is one of <span className="mono-sm">done · interrupted · error · busy</span>. When <span className="mono-sm">interrupted</span>, <span className="mono-sm">interrupts</span> holds the human-in-the-loop payload — answer it by re-calling with <span className="mono-sm">{"{ thread_id, resume }"}</span>. <span className="mono-sm">answer</span> is the full reply; <span className="mono-sm">components</span> carries any structured UI a node produced.
|
||||
</div>
|
||||
<CodeBlock code={sampleJson} />
|
||||
</Collapse>
|
||||
|
||||
<Collapse title="Continue a conversation & human-in-the-loop" sub="Reuse thread_id across turns; resume interrupts on the same thread.">
|
||||
<div className="field-help" style={{ margin: "10px 0" }}>
|
||||
Chat memory is keyed by <span className="mono-sm">thread_id</span>. Take it from the <span className="mono-sm">ready</span> frame (streaming) or the JSON reply (non-streaming) and send it back in the next request's body to keep context across turns — omit it to start fresh. To answer an interrupt the workflow raised, POST the same endpoint with the interrupted thread and a resume value:
|
||||
</div>
|
||||
<CodeBlock code={'{ "thread_id": "thr_x9", "resume": { "value": "approve" } }'} />
|
||||
</Collapse>
|
||||
|
||||
<Collapse title="Per-user identity — X-Forge-Context" sub="Pass the caller's secrets out-of-band; tools read them as {{ctx.*}}.">
|
||||
<div className="field-help" style={{ margin: "10px 0" }}>
|
||||
Authenticate the call itself with the service token (<span className="mono-sm">Authorization: Bearer <FORGE_SERVICE_API_TOKEN></span>). Anything the workflow's tools need to act <em>as the end user</em> — a session cookie, CSRF token, downstream bearer — goes in the <span className="mono-sm">X-Forge-Context</span> header as a JSON object, and tools reference it with <span className="mono-sm">{"{{ctx.*}}"}</span>. It is never written to the body, never persisted, and never echoed back.
|
||||
</div>
|
||||
<CodeBlock code={"X-Forge-Context: {\"jsessionid\":\"<user session>\",\"csrf\":\"<user csrf>\"}"} />
|
||||
<div className="field-help" style={{ marginTop: 8 }}>Identify the end user for quotas/analytics with <span className="mono-sm">{"{ \"end_user\": { \"id\": \"user-123\" } }"}</span> in the body.</div>
|
||||
</Collapse>
|
||||
|
||||
</>
|
||||
)}
|
||||
|
||||
{section === "mcp" && (
|
||||
<>
|
||||
<Collapse title="How to use this MCP server" sub="Publish toolsets, connect a client, and pick how each user authenticates.">
|
||||
<ol className="field-help" style={{ margin: "10px 0", paddingLeft: 18, lineHeight: 1.75 }}>
|
||||
<li><b>Curate what's exposed.</b> Everything is published by default over the single endpoint below — under <b>Toolsets</b>, untick a whole set, or open a set and untick individual tools, to leave them out. (MCP shows the client a flat tool list; toolsets are just how you organize it.)</li>
|
||||
<li><b>Set an API key</b> below — the shared server-to-server credential (<span className="mono-sm">Authorization: Bearer <key></span>). Without a key the server is closed.</li>
|
||||
<li><b>Add the endpoint</b> to your MCP client (Claude Desktop, Cursor, VS Code) with the config block below — that one URL is all a client needs.</li>
|
||||
<li><b>Authenticate each user</b> — pick one: a <b>personal access token</b> (each user generates one below and pastes it into their client); <b>OAuth 2.1</b> (when enabled, the client discovers Forge and the user logs in — nothing to copy); or <b>your own backend</b> (mint a session token / use Connect and pass the user's session in <span className="mono-sm">X-Forge-Context</span>).</li>
|
||||
<li><b>Act as the user downstream.</b> So a tool calls <em>your</em> app as that user, connect each user's account under <b>Auth providers</b> (Forge stores a per-user credential) or inject their session via <span className="mono-sm">{"{{ctx.*}}"}</span>. The MCP token itself is never forwarded. Your app owns its users & sessions; Forge only carries the identity.</li>
|
||||
</ol>
|
||||
</Collapse>
|
||||
<div className="card" style={{ padding: 16, marginBottom: 14 }}>
|
||||
<div className="t-h3" style={{ marginBottom: 8 }}>MCP endpoint</div>
|
||||
<CodeBlock code={url} />
|
||||
<div className="field-help">JSON-RPC over HTTP (initialize / tools/list / tools/call) · this is the Forge API host directly (not the console) so OAuth sign-in / auto-registration work · authenticate with the API key or a personal token below.</div>
|
||||
</div>
|
||||
{/* Authentication: the API key and personal access token are alternatives - a client
|
||||
pastes ONE of them as its Bearer token - so they live behind a two-tab switch. */}
|
||||
<div className="card" style={{ padding: 16, marginBottom: 14 }}>
|
||||
<div className="row spread" style={{ marginBottom: 10, alignItems: "center" }}>
|
||||
<div className="t-h3">Authentication</div>
|
||||
<Segmented options={[{ value: "key", label: "API key" }, { value: "pat", label: "Personal access token" }]} value={credTab} onChange={(v) => setCredTab(v as "key" | "pat")} />
|
||||
</div>
|
||||
<div className="field-help" style={{ marginBottom: 12 }}>Use <b>one</b> of these as the <span className="mono-sm">Bearer</span> token in the config below — the shared <b>API key</b> (server-to-server, one identity) <b>or</b> your own <b>personal access token</b> (acts as you).</div>
|
||||
{credTab === "key" ? (
|
||||
<div className="fade-in">
|
||||
<div className="row gap2">
|
||||
<input className="input mono" style={{ flex: 1 }} type="text" value={apiKey} onChange={(e) => setApiKey(e.target.value)} onBlur={(e) => saveKey(e.target.value)} placeholder="Set a key to expose the server" />
|
||||
<button className="btn btn-secondary btn-sm" onClick={genKey}><Icon name="refresh" size={13} />Generate</button>
|
||||
<span className="t-caption fg-2" style={{ alignSelf: "center", whiteSpace: "nowrap" }}>{save === "saving" ? "Saving…" : save === "saved" ? "Saved ✓" : ""}</span>
|
||||
</div>
|
||||
<div className="field-help">Shared server-to-server credential. Without a key the endpoint is closed to everyone.</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="fade-in">
|
||||
<div className="field-help" style={{ marginBottom: 10 }}>
|
||||
A per-user token to paste into your own MCP client instead of the shared key — the server then acts as <b>you</b> (your entitlements). Shown once on creation; store it safely.
|
||||
</div>
|
||||
{newToken && (
|
||||
<div className="card" style={{ padding: 10, marginBottom: 10, background: "var(--bg-2)" }}>
|
||||
<div className="t-caption fg-2" style={{ marginBottom: 4 }}>New token — copy now, it won't be shown again:</div>
|
||||
<CodeBlock code={newToken} />
|
||||
</div>
|
||||
)}
|
||||
<div className="row gap2" style={{ marginBottom: 10 }}>
|
||||
<button className="btn btn-secondary btn-sm" onClick={genToken}><Icon name="plus" size={13} />Generate token</button>
|
||||
</div>
|
||||
<div className="col gap1">
|
||||
{mcpTokens.map((t) => (
|
||||
<div key={t.id} className="row spread" style={{ padding: "6px 0", borderTop: "1px solid var(--line)" }}>
|
||||
<div className="row gap2" style={{ alignItems: "center", minWidth: 0 }}>
|
||||
<Icon name="auth" size={13} style={{ color: "var(--fg-2)" }} />
|
||||
<span className="mono-sm truncate">{t.name}</span>
|
||||
<span className="typechip">{t.prefix}…</span>
|
||||
{t.status !== "active" && <span className="pill pill-muted" style={{ height: 16 }}>{t.status}</span>}
|
||||
</div>
|
||||
{t.status === "active" && <button className="iconbtn" title="Revoke token" onClick={() => revokeToken(t.id)}><Icon name="trash" size={14} /></button>}
|
||||
</div>
|
||||
))}
|
||||
{mcpTokens.length === 0 && <div className="fg-2 t-caption">No personal tokens yet.</div>}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{perUserAps.length > 0 && (
|
||||
<div className="card" style={{ padding: 16, marginBottom: 14 }}>
|
||||
<div className="t-h3" style={{ marginBottom: 6 }}>Connect your accounts</div>
|
||||
<div className="field-help" style={{ marginBottom: 12 }}>
|
||||
These tools call downstream systems <b>as you</b>. Paste your own token for each — stored per-user and encrypted, used only for your calls. This is what lets your MCP tool calls act as you, with no shared secret and no admin setup.
|
||||
</div>
|
||||
<div className="col gap2">
|
||||
{perUserAps.map((ap) => <MyConnectionCard key={ap.id} project={project} ap={ap} />)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="card" style={{ padding: 16, marginBottom: 14 }}>
|
||||
<div className="t-h3" style={{ marginBottom: 8 }}>Claude Desktop / Cursor config</div>
|
||||
<CodeBlock code={claudeConfig} />
|
||||
</div>
|
||||
{/* Project-level tools: the whole workflow, knowledge-base search, and curated Q&A lookup,
|
||||
each a project.config flag on the server (mcp_server.py). Independent of toolsets. */}
|
||||
<div className="card" style={{ padding: 16, marginBottom: 14 }}>
|
||||
<div className="row spread" style={{ marginBottom: 8 }}>
|
||||
<div className="t-h3">Project tools</div>
|
||||
<span className="t-caption fg-2">{cfgSave === "saving" ? "Saving…" : cfgSave === "saved" ? "Saved ✓" : ""}</span>
|
||||
</div>
|
||||
<div className="field-help" style={{ marginBottom: 12 }}>
|
||||
Publish this project's built-in capabilities as MCP tools, independent of toolsets. Knowledge and Q&A search this project's knowledge base; the workflow tool runs the workflow chosen under <b>Run API</b>.
|
||||
</div>
|
||||
<div className="col gap1">
|
||||
<div className="row spread" style={{ padding: "10px 12px", border: "1px solid var(--line)", borderRadius: 9, gap: 12, alignItems: "flex-start" }}>
|
||||
<div className="col" style={{ gap: 2, minWidth: 0 }}>
|
||||
<span className="mono-sm" style={{ fontWeight: 700, color: exposeWf ? "var(--fg-0)" : "var(--fg-2)" }}>{wfToolName || "run_workflow"}</span>
|
||||
<span className="t-caption fg-2">Run the whole configured workflow as a single tool.</span>
|
||||
{exposeWf && (
|
||||
<div className="row gap2" style={{ marginTop: 6, alignItems: "center" }}>
|
||||
<span className="t-caption fg-2">Tool name</span>
|
||||
<input className="input mono" style={{ maxWidth: 240, height: 30 }} value={wfToolName}
|
||||
onChange={(e) => setWfToolName(e.target.value)}
|
||||
onBlur={(e) => { const v = e.target.value.trim().replace(/[^a-zA-Z0-9_-]/g, "_"); setWfToolName(v || "run_workflow"); saveCfg({ mcp_workflow_tool_name: v && v !== "run_workflow" ? v : undefined }); }}
|
||||
placeholder="run_workflow" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Toggle on={exposeWf} onChange={(v) => { setExposeWf(v); saveCfg({ mcp_expose_workflow: v || undefined }); }} />
|
||||
</div>
|
||||
<div className="row spread" style={{ padding: "10px 12px", border: "1px solid var(--line)", borderRadius: 9, gap: 12, alignItems: "flex-start" }}>
|
||||
<div className="col" style={{ gap: 2, minWidth: 0 }}>
|
||||
<span className="mono-sm" style={{ fontWeight: 700, color: exposeKnowledge ? "var(--fg-0)" : "var(--fg-2)" }}>search_knowledge_base</span>
|
||||
<span className="t-caption fg-2">Vector search over this project's knowledge-base documents.</span>
|
||||
</div>
|
||||
<Toggle on={exposeKnowledge} onChange={(v) => { setExposeKnowledge(v); saveCfg({ mcp_expose_knowledge: v || undefined }); }} />
|
||||
</div>
|
||||
<div className="row spread" style={{ padding: "10px 12px", border: "1px solid var(--line)", borderRadius: 9, gap: 12, alignItems: "flex-start" }}>
|
||||
<div className="col" style={{ gap: 2, minWidth: 0 }}>
|
||||
<span className="mono-sm" style={{ fontWeight: 700, color: exposeFaq ? "var(--fg-0)" : "var(--fg-2)" }}>lookup_faq</span>
|
||||
<span className="t-caption fg-2">Semantic match over this project's curated Q&A / FAQ pairs.</span>
|
||||
</div>
|
||||
<Toggle on={exposeFaq} onChange={(v) => { setExposeFaq(v); saveCfg({ mcp_expose_faq: v || undefined }); }} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="card" style={{ padding: 16, marginBottom: 14 }}>
|
||||
<div className="row spread" style={{ marginBottom: 8 }}>
|
||||
<div className="t-h3">Toolsets</div>
|
||||
<span className="t-caption fg-2">{tsSave === "saving" ? "Saving…" : tsSave === "saved" ? "Saved ✓" : ""}</span>
|
||||
</div>
|
||||
<div className="field-help" style={{ marginBottom: 10 }}>
|
||||
Everything is exposed by default over the single MCP endpoint above — untick a toolset, or open one and untick individual tools, to leave them out. Create and fill sets on the <b>Tools</b> screen.
|
||||
</div>
|
||||
{toolSets.length === 0 && <div className="fg-2 t-caption">No tool sets yet — create one on the Tools screen.</div>}
|
||||
<div className="col gap1">
|
||||
{toolSets.map((ts) => {
|
||||
const open = openSets.has(ts.id);
|
||||
const members = ts.tool_ids.map((id) => toolById.get(id)).filter(Boolean) as typeof tools;
|
||||
const shown = ts.exposed ? members.filter((t) => !excluded.includes(t.id)).length : 0;
|
||||
return (
|
||||
<div key={ts.id} className="card" style={{ padding: 0, overflow: "hidden" }}>
|
||||
<div className="row spread" style={{ padding: "8px 10px", cursor: "pointer" }} onClick={() => toggleOpenSet(ts.id)}>
|
||||
<div className="row gap2" style={{ alignItems: "center", minWidth: 0 }}>
|
||||
<Icon name={open ? "chevdown" : "chevright"} size={14} style={{ color: "var(--fg-2)", flex: "none" }} />
|
||||
<span className="mono-sm truncate">{ts.name}</span>
|
||||
<span className="t-caption fg-2">{shown}/{members.length}</span>
|
||||
{!ts.exposed && <span className="pill pill-muted" style={{ height: 16 }}>excluded</span>}
|
||||
</div>
|
||||
<label className="row gap1" style={{ alignItems: "center", cursor: "pointer", flex: "none" }} onClick={(e) => e.stopPropagation()} title="Expose this whole toolset over MCP">
|
||||
<input type="checkbox" checked={ts.exposed} onChange={() => toggleExposed(ts)} />
|
||||
<span className="t-caption fg-2">Expose</span>
|
||||
</label>
|
||||
</div>
|
||||
{open && (
|
||||
<div className="col gap1" style={{ padding: "6px 12px 10px 30px", borderTop: "1px solid var(--line)" }}>
|
||||
{members.length === 0 && <div className="t-caption fg-2">No tools in this set yet.</div>}
|
||||
{members.map((t) => (
|
||||
<label key={t.id} className="row gap2" style={{ alignItems: "center", cursor: ts.exposed ? "pointer" : "default", opacity: ts.exposed ? 1 : 0.5 }}>
|
||||
<input type="checkbox" disabled={!ts.exposed} checked={ts.exposed && !excluded.includes(t.id)} onChange={() => toggleToolExcluded(t.id)} />
|
||||
<span className="mono-sm">{t.name}</span><span className="typechip">{t.kind}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<div className="card" style={{ padding: 16 }}>
|
||||
<div className="t-h3" style={{ marginBottom: 10 }}>Currently exposed tools ({exposedTools.length + projectTools.length})</div>
|
||||
<div className="col gap2">
|
||||
{projectTools.map((t) => (
|
||||
<div key={t.name} className="row gap2"><Icon name="connect" size={14} style={{ color: "var(--accent)" }} /><span className="mono-sm">{t.name}</span><span className="typechip">{t.kind}</span></div>
|
||||
))}
|
||||
{exposedTools.map((t) => (
|
||||
<div key={t.id} className="row gap2"><Icon name="tools" size={14} style={{ color: "var(--fg-2)" }} /><span className="mono-sm">{t.name}</span><span className="typechip">{t.kind}</span></div>
|
||||
))}
|
||||
{exposedTools.length === 0 && projectTools.length === 0 && <div className="fg-2 t-caption">Nothing exposed yet — enable a project tool above, or put tools in a set and toggle it on.</div>}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{section === "embed" && <EmbedPanel project={project} />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
"use client";
|
||||
/* Console screen to manage the embeddable chat widget (Phase 3b/4): enable it, choose the
|
||||
workflow, allow-list embedding origins, and copy the iframe snippet + publishable key. */
|
||||
import { useEffect, useState } from "react";
|
||||
import { Icon } from "../icons";
|
||||
import { api, Workflow } from "@/lib/api";
|
||||
|
||||
/* Reusable settings body (no page chrome) so it can live standalone OR as a section inside
|
||||
the Connect screen's secondary nav. The container is expected to supply the header. */
|
||||
export function EmbedPanel({ project }: { project: any }) {
|
||||
const [enabled, setEnabled] = useState(false);
|
||||
const [origins, setOrigins] = useState("");
|
||||
const [workflowId, setWorkflowId] = useState("");
|
||||
const [pubKey, setPubKey] = useState<string | null>(null);
|
||||
const [wfs, setWfs] = useState<Workflow[]>([]);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [status, setStatus] = useState<string | null>(null);
|
||||
const [origin, setOrigin] = useState("");
|
||||
|
||||
useEffect(() => { if (typeof window !== "undefined") setOrigin(window.location.origin); }, []);
|
||||
useEffect(() => {
|
||||
if (!project?.id) return;
|
||||
api.getEmbed(project.id).then((e) => {
|
||||
setEnabled(e.enabled);
|
||||
setOrigins((e.allowed_origins || []).join("\n"));
|
||||
setWorkflowId(e.workflow_id || "");
|
||||
setPubKey(e.publishable_key || null);
|
||||
}).catch(() => {});
|
||||
api.listWorkflows(project.id).then(setWfs).catch(() => {});
|
||||
}, [project?.id]);
|
||||
|
||||
async function save() {
|
||||
setSaving(true);
|
||||
setStatus(null);
|
||||
try {
|
||||
const e = await api.setEmbed(project.id, {
|
||||
enabled,
|
||||
allowed_origins: origins.split(/\s+/).map((s) => s.trim()).filter(Boolean),
|
||||
workflow_id: workflowId || null,
|
||||
});
|
||||
setPubKey(e.publishable_key || null);
|
||||
setStatus("Saved.");
|
||||
setTimeout(() => setStatus(null), 1600);
|
||||
} catch (e: any) {
|
||||
setStatus(`Save failed: ${e.message || e}`);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
const src = pubKey && enabled ? `${origin}/embed?key=${pubKey}` : null;
|
||||
const launcherSnippet = src
|
||||
? `<script src="${origin}/launcher.js"\n data-forge-key="${pubKey}"\n data-forge-origin="${origin}"\n data-forge-title="${(project?.name || "Chat").replace(/"/g, """)}"\n defer></script>`
|
||||
: null;
|
||||
const iframeSnippet = src ? `<iframe src="${src}" style="border:0;width:400px;height:600px"></iframe>` : null;
|
||||
|
||||
return (
|
||||
<div className="col gap4">
|
||||
<div className="card col gap3" style={{ padding: 18 }}>
|
||||
<label className="row gap2" style={{ alignItems: "center", cursor: "pointer" }}>
|
||||
<span className={"toggle" + (enabled ? " on" : "")} onClick={() => setEnabled((v) => !v)} role="switch" aria-checked={enabled} />
|
||||
<span className="t-body-sm">Enable the embeddable widget</span>
|
||||
</label>
|
||||
<div>
|
||||
<label className="field-label">Workflow</label>
|
||||
<select className="select" value={workflowId} onChange={(e) => setWorkflowId(e.target.value)}>
|
||||
<option value="">Active workflow</option>
|
||||
{wfs.map((w) => <option key={w.id} value={w.id}>{w.name}</option>)}
|
||||
</select>
|
||||
<div className="field-help">Which workflow the widget runs.</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="field-label">Allowed origins</label>
|
||||
<textarea className="textarea mono" value={origins} onChange={(e) => setOrigins(e.target.value)} placeholder={"https://yoursite.com\nhttps://app.yoursite.com"} style={{ minHeight: 70, fontSize: 12 }} spellCheck={false} />
|
||||
<div className="field-help">Sites permitted to embed the widget, one per line. Empty = only this Forge origin (external embedding blocked). Enforced via the page's frame-ancestors policy.</div>
|
||||
</div>
|
||||
<div className="row gap2" style={{ alignItems: "center" }}>
|
||||
<button className="btn btn-primary btn-sm" onClick={save} disabled={saving}>
|
||||
<Icon name={saving ? "refresh" : "check"} size={14} style={saving ? { animation: "spin 1s linear infinite" } : {}} />
|
||||
{saving ? "Saving…" : "Save"}
|
||||
</button>
|
||||
{status && <span className="t-caption" style={{ color: status.includes("fail") ? "var(--err)" : "var(--ok)" }}>{status}</span>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{launcherSnippet ? (
|
||||
<div className="card col gap3" style={{ padding: 18 }}>
|
||||
<div>
|
||||
<div className="t-h3">Floating chat bubble (recommended)</div>
|
||||
<div className="field-help">Paste once before <span className="mono-sm"></body></span>. Adds a launcher button that opens the chat.</div>
|
||||
</div>
|
||||
<pre className="mono-sm" style={{ background: "var(--bg-3)", padding: 12, borderRadius: 8, overflowX: "auto", whiteSpace: "pre-wrap" }}>{launcherSnippet}</pre>
|
||||
<div className="row gap2">
|
||||
<button className="btn btn-primary btn-sm" onClick={() => navigator.clipboard?.writeText(launcherSnippet)}>Copy bubble snippet</button>
|
||||
<a className="btn btn-ghost btn-sm" href={src!} target="_blank" rel="noreferrer">Open widget</a>
|
||||
</div>
|
||||
<div className="t-h3" style={{ marginTop: 6 }}>Inline iframe (advanced)</div>
|
||||
<pre className="mono-sm" style={{ background: "var(--bg-3)", padding: 12, borderRadius: 8, overflowX: "auto", whiteSpace: "pre-wrap" }}>{iframeSnippet}</pre>
|
||||
<button className="btn btn-secondary btn-sm" style={{ alignSelf: "flex-start" }} onClick={() => navigator.clipboard?.writeText(iframeSnippet!)}>Copy iframe</button>
|
||||
<div className="field-help">
|
||||
Publishable key: <span className="mono-sm">{pubKey}</span>. For logged-in users, have your backend mint a session token (POST /v1/projects/{project?.id}/session-tokens) and add <span className="mono-sm">data-forge-token="…"</span> (bubble) or <span className="mono-sm">&session_token=…</span> (iframe) so the agent knows who is chatting and can honor their entitlements.
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="fg-2 t-body-sm">Enable and save to get a publishable key + embed snippet.</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* Standalone screen (kept for direct/deep-link navigation): page chrome + the panel. */
|
||||
export function EmbedScreen({ project }: { project: any }) {
|
||||
return (
|
||||
<div className="scroll-y" style={{ flex: 1, padding: "24px 28px" }}>
|
||||
<div style={{ maxWidth: 960, margin: "0 auto" }} className="col gap4">
|
||||
<div className="row gap2">
|
||||
<div>
|
||||
<div className="t-display">Embed</div>
|
||||
<div className="fg-1" style={{ marginTop: 3 }}>Drop this project's chatbot into any website as a widget.</div>
|
||||
</div>
|
||||
</div>
|
||||
<EmbedPanel project={project} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
"use client";
|
||||
/* Forge home screens: Dashboard, Project Overview, Onboarding wizard. */
|
||||
import { useState } from "react";
|
||||
import { Icon } from "../icons";
|
||||
import { Sparkline, StatusPill, Tile, Field, Toggle, EmptyState } from "../primitives";
|
||||
import { DashboardStats } from "@/lib/api";
|
||||
import { fmtUSD } from "@/lib/data";
|
||||
|
||||
const fmtLatencyMs = (ms: number) => (ms >= 1000 ? `${(ms / 1000).toFixed(1)}s` : `${ms}ms`);
|
||||
|
||||
export interface ProjectCard {
|
||||
id: string; name: string; slug: string; status: string;
|
||||
workflows: number; tools: number; runs7d: number; spark: number[]; edited: string;
|
||||
}
|
||||
|
||||
/* ============ DASHBOARD ============ */
|
||||
export function DashboardScreen({
|
||||
projects = [],
|
||||
loaded = false,
|
||||
stats = null,
|
||||
onOpenProject,
|
||||
onNewProject,
|
||||
onDeleteProject,
|
||||
}: {
|
||||
projects?: ProjectCard[];
|
||||
loaded?: boolean;
|
||||
// Fetched once by the parent (App) and shared - avoids a second /stats/dashboard call.
|
||||
stats?: DashboardStats | null;
|
||||
onOpenProject: (id: string) => void;
|
||||
onNewProject: () => void;
|
||||
onDeleteProject?: (project: { id: string; name: string }) => Promise<void> | void;
|
||||
}) {
|
||||
const empty = loaded && projects.length === 0;
|
||||
const [deletingId, setDeletingId] = useState<string | null>(null);
|
||||
|
||||
const fmtLatency = (ms: number) => (ms >= 1000 ? `${(ms / 1000).toFixed(1)}s` : `${ms}ms`);
|
||||
const kpis = [
|
||||
{ label: "Runs · 7 days", value: stats ? stats.runs_7d.toLocaleString() : "-", sub: stats ? `${stats.total_runs.toLocaleString()} all-time` : "" },
|
||||
{ label: "Success rate", value: stats && stats.runs_7d ? `${stats.success_rate}%` : "-", sub: "completed runs" },
|
||||
{ label: "Avg latency", value: stats && stats.runs_7d ? fmtLatency(stats.avg_latency_ms) : "-", sub: "per run" },
|
||||
{ label: "Spend · 7 days", value: stats ? fmtUSD(stats.spend_7d) : "-", sub: "tracked cost" },
|
||||
];
|
||||
return (
|
||||
<div className="scroll-y" style={{ flex: 1, padding: "28px 32px" }}>
|
||||
<div className="fade-up" style={{ maxWidth: 1600, margin: "0 auto" }}>
|
||||
<div className="row spread" style={{ marginBottom: 22, alignItems: "flex-end" }}>
|
||||
<div>
|
||||
<div className="t-display-lg">Welcome to Forge</div>
|
||||
<div className="fg-1" style={{ marginTop: 4 }}>Self-hosted agent platform · {projects.length} project{projects.length === 1 ? "" : "s"}</div>
|
||||
</div>
|
||||
<div className="row gap2">
|
||||
<button className="btn btn-primary" onClick={onNewProject}><Icon name="plus" size={15} />New project</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{empty ? (
|
||||
<div className="card" style={{ padding: 8 }}>
|
||||
<EmptyState
|
||||
icon="layers"
|
||||
title="Forge your first project"
|
||||
sub="A project is a workspace for agents, tools, knowledge, and workflows. Create one to begin building."
|
||||
action={<button className="btn btn-primary btn-lg" onClick={onNewProject} style={{ marginTop: 6 }}><Icon name="plus" size={16} />New project</button>}
|
||||
/>
|
||||
</div>
|
||||
) : !loaded ? (
|
||||
<div className="fg-2" style={{ padding: 40, textAlign: "center" }}>Loading…</div>
|
||||
) : (
|
||||
<>
|
||||
<div style={{ display: "grid", gridTemplateColumns: "repeat(4,1fr)", gap: 16, marginBottom: 24 }}>
|
||||
{kpis.map((k, i) => (
|
||||
<div key={i} className="card" style={{ padding: 16 }}>
|
||||
<div className="t-micro" style={{ marginBottom: 8 }}>{k.label}</div>
|
||||
<div className="t-display" style={{ fontSize: 26 }}>{k.value}</div>
|
||||
<div className="fg-2 t-caption" style={{ marginTop: 2 }}>{k.sub}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div style={{ display: "grid", gridTemplateColumns: "1.5fr 1fr", gap: 20 }}>
|
||||
<div>
|
||||
<div className="row spread" style={{ marginBottom: 12 }}>
|
||||
<div className="t-h1">Projects</div>
|
||||
</div>
|
||||
<div className="col gap3">
|
||||
{projects.map((p) => (
|
||||
<div key={p.id} className="card card-hover" style={{ padding: 14 }} onClick={() => onOpenProject(p.id)}>
|
||||
<div className="row gap3">
|
||||
<Tile icon="layers" color={p.status === "draft" ? "var(--fg-2)" : "var(--accent)"} size={40} />
|
||||
<div className="grow" style={{ minWidth: 0 }}>
|
||||
<div className="row gap2"><span className="t-h2">{p.name}</span><StatusPill status={p.status} /></div>
|
||||
<div className="fg-2 t-caption row gap3" style={{ marginTop: 3 }}>
|
||||
<span>{p.workflows} workflows</span><span>{p.tools} tools</span><span>edited {p.edited}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col" style={{ alignItems: "flex-end", gap: 4 }}>
|
||||
<Sparkline data={p.spark} w={92} h={26} color="var(--accent)" />
|
||||
<div className="fg-2 t-caption">{p.runs7d.toLocaleString()} runs / 7d</div>
|
||||
</div>
|
||||
{onDeleteProject && (
|
||||
<button
|
||||
className="iconbtn"
|
||||
title="Delete project"
|
||||
disabled={deletingId === p.id}
|
||||
onClick={async (e) => {
|
||||
e.stopPropagation();
|
||||
setDeletingId(p.id);
|
||||
try { await onDeleteProject({ id: p.id, name: p.name }); }
|
||||
finally { setDeletingId(null); }
|
||||
}}
|
||||
>
|
||||
<Icon name="trash" size={15} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="row spread" style={{ marginBottom: 12 }}>
|
||||
<div className="t-h1">Recent runs</div>
|
||||
<span className="pill pill-muted" style={{ height: 18 }}>live</span>
|
||||
</div>
|
||||
<div className="card" style={{ overflow: "hidden" }}>
|
||||
{(stats?.recent || []).map((r, i, arr) => (
|
||||
<div key={r.id} className="row gap3" style={{ padding: "11px 14px", borderBottom: i < arr.length - 1 ? "1px solid var(--line)" : "none" }}>
|
||||
<div className="grow" style={{ minWidth: 0 }}>
|
||||
<div className="truncate" style={{ fontSize: 13, fontWeight: 600 }}>{r.workflow}</div>
|
||||
<div className="fg-2 t-caption truncate">{r.project} · {r.status}</div>
|
||||
</div>
|
||||
<div className="col" style={{ alignItems: "flex-end" }}>
|
||||
<div className="mono-sm" style={{ color: "var(--fg-1)" }}>{r.tokens.toLocaleString()} tok</div>
|
||||
<div className="fg-2 t-caption">{r.latency_ms}ms{r.started_at ? " · " + r.started_at.slice(11, 16) : ""}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{(!stats || stats.recent.length === 0) && (
|
||||
<div className="fg-2 t-caption" style={{ padding: 22, textAlign: "center" }}>No runs yet. Run a workflow in the Playground.</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* All-time usage by project (incl. Forge Assistant share) */}
|
||||
<div className="row spread" style={{ margin: "26px 0 12px" }}>
|
||||
<div className="t-h1">Reports</div>
|
||||
</div>
|
||||
<div className="card" style={{ overflow: "hidden" }}>
|
||||
<table className="tbl">
|
||||
<thead><tr><th>Project</th><th>Runs</th><th>Tokens</th><th>Avg latency</th><th>Assistant</th><th>Total cost</th></tr></thead>
|
||||
<tbody>
|
||||
{(stats?.reports || []).map((r, i) => (
|
||||
<tr key={i}>
|
||||
<td>
|
||||
<div className="row gap2">
|
||||
<Tile icon="layers" color="var(--accent)" size={24} />
|
||||
<span style={{ fontWeight: 600, fontSize: 13 }}>{r.project}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="mono-sm">{r.runs.toLocaleString()}</td>
|
||||
<td className="mono-sm">{r.tokens.toLocaleString()}</td>
|
||||
<td className="mono-sm">{fmtLatencyMs(r.avg_latency_ms)}</td>
|
||||
<td className="mono-sm">{r.assistant_turns ? `${fmtUSD(r.assistant_cost_usd)} · ${r.assistant_turns} turns` : "-"}</td>
|
||||
<td className="mono-sm">{fmtUSD(r.cost_usd)}</td>
|
||||
</tr>
|
||||
))}
|
||||
{(!stats || !stats.reports?.length) && <tr><td colSpan={6}><div className="fg-2 t-caption" style={{ padding: 22, textAlign: "center" }}>No usage yet.</div></td></tr>}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ============ ONBOARDING WIZARD ============ */
|
||||
export function OnboardingScreen({ onCreate, onCancel }: { onCreate: (p: { name: string; template: string; keys: Record<string, string> }) => void; onCancel: () => void }) {
|
||||
const [step, setStep] = useState(0);
|
||||
const [name, setName] = useState("");
|
||||
const [tmpl, setTmpl] = useState("blank");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [models, setModels] = useState<Record<string, boolean>>({ anthropic: true, openai: false, google: false });
|
||||
const [keys, setKeys] = useState<Record<string, string>>({});
|
||||
const provId: Record<string, string> = { anthropic: "anthropic", openai: "openai", google: "google_genai" };
|
||||
const steps = ["Project", "Models", "Create"];
|
||||
const templates = [
|
||||
{ id: "blank", name: "Blank canvas", desc: "Start from an empty graph", icon: "workflows" },
|
||||
{ id: "support", name: "Support agent", desc: "Router → agent → tools → HITL", icon: "agents" },
|
||||
{ id: "rag", name: "RAG Q&A", desc: "Retrieval + grounded answers", icon: "knowledge" },
|
||||
{ id: "mcp", name: "MCP toolbox", desc: "Expose tools over MCP", icon: "connect" },
|
||||
];
|
||||
return (
|
||||
<div className="col center" style={{ flex: 1, padding: 24, background: "var(--bg-0)" }}>
|
||||
<div className="card fade-up" style={{ width: 640, maxWidth: "94vw", overflow: "hidden" }}>
|
||||
<div style={{ padding: "18px 22px", borderBottom: "1px solid var(--line)" }}>
|
||||
<div className="row spread" style={{ marginBottom: 14 }}>
|
||||
<div className="t-h1">New project</div>
|
||||
<button className="iconbtn" onClick={onCancel}><Icon name="x" size={17} /></button>
|
||||
</div>
|
||||
<div className="row gap2">
|
||||
{steps.map((s, i) => (
|
||||
<div key={i} className="row gap2 grow">
|
||||
<div style={{ width: 22, height: 22, borderRadius: "50%", flex: "none", display: "flex", alignItems: "center", justifyContent: "center", fontSize: 11, fontWeight: 700, fontFamily: "var(--font-mono)", background: i < step ? "var(--accent)" : i === step ? "var(--accent-glow)" : "var(--bg-3)", color: i < step ? "var(--fg-on-accent)" : i === step ? "var(--accent)" : "var(--fg-2)", border: i === step ? "1px solid var(--accent)" : "none" }}>
|
||||
{i < step ? <Icon name="check" size={13} /> : i + 1}
|
||||
</div>
|
||||
<span style={{ fontSize: 12.5, fontWeight: 600, color: i <= step ? "var(--fg-0)" : "var(--fg-2)" }}>{s}</span>
|
||||
{i < steps.length - 1 && <div className="grow" style={{ height: 1, background: i < step ? "var(--accent)" : "var(--line)" }} />}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ padding: 22, minHeight: 280 }}>
|
||||
{step === 0 && (
|
||||
<div className="fade-in">
|
||||
<Field label="Project name" help="A workspace for related workflows, tools, and knowledge." required>
|
||||
<input className="input" autoFocus value={name} onChange={(e) => setName(e.target.value)} placeholder="e.g. Customer Support" />
|
||||
</Field>
|
||||
<div className="field-label">Start from</div>
|
||||
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 10, marginTop: 6 }}>
|
||||
{templates.map((t) => (
|
||||
<button key={t.id} onClick={() => setTmpl(t.id)} style={{ textAlign: "left", padding: 12, borderRadius: 10, cursor: "pointer", background: "var(--bg-1)", border: "1px solid " + (tmpl === t.id ? "var(--accent)" : "var(--line)"), boxShadow: tmpl === t.id ? "0 0 0 3px var(--accent-glow)" : "none" }}>
|
||||
<div className="row gap2" style={{ marginBottom: 6 }}><Tile icon={t.icon} color="var(--accent)" size={28} />{tmpl === t.id && <Icon name="check" size={16} style={{ color: "var(--accent)", marginLeft: "auto" }} />}</div>
|
||||
<div style={{ fontWeight: 600, fontSize: 13 }}>{t.name}</div>
|
||||
<div className="fg-2 t-caption" style={{ marginTop: 2 }}>{t.desc}</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{step === 1 && (
|
||||
<div className="fade-in">
|
||||
<div className="fg-1" style={{ marginBottom: 14 }}>Connect at least one model provider. Keys are stored encrypted in your secret store - they never leave your instance.</div>
|
||||
{[["anthropic", "Anthropic", "claude-sonnet-4-6, haiku-4-2"], ["openai", "OpenAI", "gpt-5.4, gpt-5.4-mini"], ["google", "Google", "gemini-3.1-pro, 3.5-flash"]].map((p) => (
|
||||
<div key={p[0]} className="row gap3" style={{ padding: "12px 14px", borderRadius: 10, border: "1px solid var(--line)", marginBottom: 10 }}>
|
||||
<Tile icon="n_llm" color="var(--fg-2)" size={32} />
|
||||
<div className="grow"><div style={{ fontWeight: 600 }}>{p[1]}</div><div className="fg-2 t-caption">{p[2]}</div></div>
|
||||
{models[p[0]] && <input className="input mono" style={{ width: 200 }} type="password" placeholder="sk-… (optional, encrypted)" value={keys[provId[p[0]]] || ""} onChange={(e) => setKeys((k) => ({ ...k, [provId[p[0]]]: e.target.value }))} />}
|
||||
<Toggle on={models[p[0]]} onChange={(v) => setModels((m) => ({ ...m, [p[0]]: v }))} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{step === 2 && (
|
||||
<div className="fade-in col center" style={{ textAlign: "center", gap: 10, paddingTop: 16 }}>
|
||||
<Tile icon="check" color="var(--ok)" size={52} glow />
|
||||
<div className="t-h1">Create “{name || "Untitled"}”</div>
|
||||
<div className="fg-1" style={{ maxWidth: 380 }}>We’ll create an empty project so you can register tools, add knowledge, and build your first workflow from scratch.</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="row spread" style={{ padding: "14px 22px", borderTop: "1px solid var(--line)" }}>
|
||||
<button className="btn btn-ghost" onClick={() => (step === 0 ? onCancel() : setStep(step - 1))}>{step === 0 ? "Cancel" : "Back"}</button>
|
||||
<button className="btn btn-primary" disabled={(step === 0 && !name) || busy} onClick={() => { if (step < 2) { setStep(step + 1); } else { setBusy(true); onCreate({ name, template: tmpl, keys }); } }}>
|
||||
{step < 2 ? "Continue" : busy ? "Creating…" : "Create project"}<Icon name="chevright" size={15} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,714 @@
|
||||
"use client";
|
||||
/* Knowledge: vertical-tab layout - Files (sources organized in folders), Q&A pairs
|
||||
(free-form kinds/categories + tags), and the search debugger. */
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { Icon } from "../icons";
|
||||
import { Drawer, Field, Modal, Segmented, StatusPill } from "../primitives";
|
||||
import { api, ActivityEntry, KbSource, QaPair, SearchHit } from "@/lib/api";
|
||||
import { ChunkMap } from "./chunk-map";
|
||||
|
||||
const VTABS = [
|
||||
{ value: "files", label: "Files", icon: "knowledge" },
|
||||
{ value: "qa", label: "Q&A pairs", icon: "n_qa" },
|
||||
{ value: "search", label: "Search debugger", icon: "search" },
|
||||
{ value: "map", label: "Chunk map", icon: "layers" },
|
||||
] as const;
|
||||
|
||||
// Chunking strategies offered in the UI - mirrors CHUNK_STRATEGIES in the backend splitter.
|
||||
const CHUNK_OPTIONS = [
|
||||
{ value: "recursive", label: "Recursive" },
|
||||
{ value: "section", label: "By section" },
|
||||
{ value: "sentence", label: "By sentence" },
|
||||
{ value: "semantic", label: "Semantic" },
|
||||
] as const;
|
||||
const CHUNK_HELP = "Recursive suits most documents; By section keeps each Markdown heading’s content together; By sentence groups whole sentences (good for FAQs and transcripts); Semantic splits where the meaning shifts (uses the embedder, slower to ingest).";
|
||||
|
||||
export function KnowledgeScreen({ project }: { project: any }) {
|
||||
const [tab, setTab] = useState<string>("files");
|
||||
const [histOpen, setHistOpen] = useState(false);
|
||||
return (
|
||||
<div className="col" style={{ flex: 1, minHeight: 0 }}>
|
||||
<div className="row spread" style={{ padding: "20px 28px 14px", alignItems: "flex-start" }}>
|
||||
<div>
|
||||
<div className="t-display">Knowledge</div>
|
||||
<div className="fg-1" style={{ marginTop: 3 }}>Ground agents in your docs (Chroma vectors, organized in folders) and deflect FAQs with categorized Q&A pairs.</div>
|
||||
</div>
|
||||
<button className="btn btn-secondary btn-sm" style={{ flex: "none" }} onClick={() => setHistOpen(true)} disabled={!project?.id} title="What was added, changed or removed">
|
||||
<Icon name="clock" size={14} />History
|
||||
</button>
|
||||
</div>
|
||||
<KnowledgeHistory project={project} open={histOpen} onClose={() => setHistOpen(false)} />
|
||||
<div className="row" style={{ flex: 1, minHeight: 0, alignItems: "stretch" }}>
|
||||
{/* vertical tab rail */}
|
||||
<nav className="col" style={{ width: 184, flex: "none", padding: "4px 0 16px 20px", gap: 2 }}>
|
||||
{VTABS.map((t) => (
|
||||
<button
|
||||
key={t.value}
|
||||
onClick={() => setTab(t.value)}
|
||||
className="row gap2"
|
||||
style={{
|
||||
alignItems: "center", textAlign: "left", padding: "8px 12px", borderRadius: 8,
|
||||
border: "none", cursor: "pointer", fontSize: 13, fontWeight: tab === t.value ? 650 : 450,
|
||||
background: tab === t.value ? "var(--bg-3)" : "transparent",
|
||||
color: tab === t.value ? "var(--fg-0)" : "var(--fg-1)",
|
||||
}}
|
||||
>
|
||||
<Icon name={t.icon as any} size={15} />
|
||||
{t.label}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
<div className="scroll-y" style={{ flex: 1, minWidth: 0, padding: "4px 28px 24px 16px" }}>
|
||||
<div style={{ maxWidth: 1400 }}>
|
||||
{tab === "files" && <Files project={project} />}
|
||||
{tab === "qa" && <QA project={project} />}
|
||||
{tab === "search" && <SearchDebugger project={project} />}
|
||||
{tab === "map" && <ChunkMap project={project} />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* Compact "3m ago" / "2d ago" relative time, falling back to a locale date. */
|
||||
function knRelTime(iso?: string | null): string {
|
||||
if (!iso) return "";
|
||||
const t = new Date(iso).getTime();
|
||||
if (Number.isNaN(t)) return String(iso);
|
||||
const s = Math.round((Date.now() - t) / 1000);
|
||||
if (s < 60) return "just now";
|
||||
const m = Math.round(s / 60);
|
||||
if (m < 60) return `${m}m ago`;
|
||||
const h = Math.round(m / 60);
|
||||
if (h < 24) return `${h}h ago`;
|
||||
const d = Math.round(h / 24);
|
||||
if (d < 30) return `${d}d ago`;
|
||||
return new Date(iso).toLocaleDateString();
|
||||
}
|
||||
|
||||
/* Read-only, project-wide activity: what files / Q&A pairs were added, changed or removed.
|
||||
Opened from the Knowledge header - no per-row clutter, no restore (content lives in the
|
||||
vector store, so there's nothing to roll back). */
|
||||
function KnowledgeHistory({ project, open, onClose }: { project: any; open: boolean; onClose: () => void }) {
|
||||
const [rows, setRows] = useState<ActivityEntry[] | null>(null);
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
useEffect(() => {
|
||||
if (!open || !project?.id) return;
|
||||
setRows(null); setErr(null);
|
||||
api.knowledgeActivity(project.id).then(setRows).catch((e) => setErr(String(e?.message || e)));
|
||||
}, [open, project?.id]);
|
||||
|
||||
const ACTION: Record<string, { label: string; cls: string; icon: string }> = {
|
||||
added: { label: "Added", cls: "pill-ok", icon: "plus" },
|
||||
changed: { label: "Changed", cls: "pill-muted", icon: "edit" },
|
||||
removed: { label: "Removed", cls: "pill-err", icon: "trash" },
|
||||
};
|
||||
return (
|
||||
<Drawer open={open} onClose={onClose} title="Knowledge history" sub={project?.name} width={440}>
|
||||
<div className="col" style={{ padding: 14, gap: 8 }}>
|
||||
{err && <div className="card" style={{ padding: 12, color: "var(--err)" }}>{err}</div>}
|
||||
{!err && rows === null && <div className="fg-2 t-caption" style={{ padding: "8px 2px" }}>Loading…</div>}
|
||||
{!err && rows?.length === 0 && (
|
||||
<div className="col center" style={{ padding: "40px 16px", textAlign: "center", gap: 8, color: "var(--fg-2)" }}>
|
||||
<Icon name="clock" size={22} />
|
||||
<div className="t-body-sm">No changes yet.</div>
|
||||
<div className="t-caption">Adding, editing or removing files and Q&A pairs shows up here.</div>
|
||||
</div>
|
||||
)}
|
||||
{rows?.map((r) => {
|
||||
const a = ACTION[r.action || ""] || { label: r.action || "changed", cls: "pill-muted", icon: "minus" };
|
||||
return (
|
||||
<div key={r.id} className="card" style={{ padding: "10px 12px" }}>
|
||||
<div className="row gap2" style={{ alignItems: "center", minWidth: 0 }}>
|
||||
<span className={"pill " + a.cls} style={{ height: 18, flex: "none" }}><Icon name={a.icon as any} size={11} />{a.label}</span>
|
||||
<span className="typechip" style={{ flex: "none" }}>{r.entity_type === "qa_pair" ? "Q&A" : "File"}</span>
|
||||
<span className="t-body-sm truncate" style={{ minWidth: 0 }}>{r.title}</span>
|
||||
</div>
|
||||
<div className="t-caption fg-2 truncate" style={{ marginTop: 4 }}>
|
||||
{r.author_email || "unknown"}{r.created_at ? ` · ${knRelTime(r.created_at)}` : ""}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Drawer>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------------- Files (sources in folders) ---------------- */
|
||||
|
||||
const UNFILED = "";
|
||||
|
||||
// Fallback chunking shown/used when a source (or the project) hasn't set its own. Mirrors
|
||||
// the backend defaults in services/knowledge.py; the server stays authoritative for what's
|
||||
// actually applied at ingest.
|
||||
const DEFAULT_CHUNK_STRATEGY = "recursive";
|
||||
const DEFAULT_CHUNK_SIZE = 1000;
|
||||
const DEFAULT_CHUNK_OVERLAP = 200;
|
||||
|
||||
function Files({ project }: { project: any }) {
|
||||
const [rows, setRows] = useState<KbSource[]>([]);
|
||||
const [folder, setFolder] = useState<string | null>(null); // null = All files
|
||||
const [open, setOpen] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [addErr, setAddErr] = useState<string | null>(null);
|
||||
const [newFolder, setNewFolder] = useState<string | null>(null); // non-null = naming a new folder
|
||||
// The modal never asks for a folder - sources land in the folder open at the time
|
||||
// ("" = Unfiled, e.g. from the All files view).
|
||||
const [targetFolder, setTargetFolder] = useState<string>("");
|
||||
const [form, setForm] = useState<{ kind: string; name: string; text: string; uri: string; file: globalThis.File | null; chunkStrategy: string }>({ kind: "text", name: "", text: "", uri: "", file: null, chunkStrategy: "recursive" });
|
||||
|
||||
// Multi-select + re-chunk: select sources (across any folder) and re-split/re-embed
|
||||
// them with a shared strategy / size / overlap.
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||
const [rechunkOpen, setRechunkOpen] = useState(false);
|
||||
const [rechunkTargets, setRechunkTargets] = useState<string[]>([]);
|
||||
const [rechunkBusy, setRechunkBusy] = useState(false);
|
||||
const [rechunkErr, setRechunkErr] = useState<string | null>(null);
|
||||
const [rechunkForm, setRechunkForm] = useState<{ strategy: string; size: number; overlap: number }>({ strategy: DEFAULT_CHUNK_STRATEGY, size: DEFAULT_CHUNK_SIZE, overlap: DEFAULT_CHUNK_OVERLAP });
|
||||
|
||||
const [dedupeBusy, setDedupeBusy] = useState(false);
|
||||
const [dedupeMsg, setDedupeMsg] = useState<string | null>(null);
|
||||
const [health, setHealth] = useState<{ needs_reembed: boolean; current_model: string; mismatched: { id: string; name: string }[] } | null>(null);
|
||||
const reload = useCallback(() => {
|
||||
if (!project?.id) return;
|
||||
api.listSources(project.id).then(setRows).catch(() => {});
|
||||
api.embeddingHealth(project.id).then(setHealth).catch(() => setHealth(null));
|
||||
}, [project?.id]);
|
||||
useEffect(() => { reload(); }, [reload]);
|
||||
|
||||
// Ingestion (chunk + embed) runs in the background, so a new/re-chunked source starts as
|
||||
// "queued"/"processing". Poll until every source settles (ready/error) so the table, chunk
|
||||
// counts, and dim-mismatch banner update without a manual refresh.
|
||||
useEffect(() => {
|
||||
const pending = rows.some((s) => s.status === "queued" || s.status === "processing");
|
||||
if (!pending || !project?.id) return;
|
||||
const t = setTimeout(async () => {
|
||||
const next = await api.listSources(project.id).catch(() => null);
|
||||
if (!next) return;
|
||||
setRows(next);
|
||||
if (!next.some((s) => s.status === "queued" || s.status === "processing")) {
|
||||
api.embeddingHealth(project.id).then(setHealth).catch(() => {});
|
||||
}
|
||||
}, 1500);
|
||||
return () => clearTimeout(t);
|
||||
}, [rows, project?.id]);
|
||||
|
||||
async function reingest(id: string) { await api.reingestSource(project.id, id).catch(() => {}); reload(); }
|
||||
|
||||
async function dedupe() {
|
||||
if (!window.confirm("Remove exact-duplicate chunks (identical text) across this project, keeping one copy of each?\n\nIf duplicates come from the same document added twice, delete the duplicate source instead — re-ingesting regenerates the chunks.")) return;
|
||||
setDedupeBusy(true);
|
||||
setDedupeMsg(null);
|
||||
try {
|
||||
const r = await api.dedupeChunks(project.id);
|
||||
setDedupeMsg(r.removed === 0
|
||||
? "No duplicate chunks found."
|
||||
: `Removed ${r.removed} duplicate chunk${r.removed === 1 ? "" : "s"} (${r.groups} group${r.groups === 1 ? "" : "s"}) across ${r.sources_affected} source${r.sources_affected === 1 ? "" : "s"}. ${r.remaining} remain.`);
|
||||
reload();
|
||||
} catch (e: any) {
|
||||
setDedupeMsg(`Dedupe failed: ${e?.message || e}`);
|
||||
} finally { setDedupeBusy(false); }
|
||||
}
|
||||
|
||||
const folders = useMemo(() => {
|
||||
const set = new Set<string>();
|
||||
rows.forEach((s) => { if (s.folder) set.add(s.folder); });
|
||||
return [...set].sort();
|
||||
}, [rows]);
|
||||
|
||||
const visible = folder === null ? rows : rows.filter((s) => (s.folder || UNFILED) === folder);
|
||||
const hasUnfiled = rows.some((s) => !s.folder);
|
||||
// Folder column is redundant when already viewing one named folder - show it only in
|
||||
// the All-files and Unfiled views (where moving files between folders is useful).
|
||||
const showFolderCol = folder === null || folder === UNFILED;
|
||||
const inNamedFolder = folder !== null && folder !== UNFILED;
|
||||
|
||||
const allVisibleSelected = visible.length > 0 && visible.every((s) => selected.has(s.id));
|
||||
function toggleSel(id: string) {
|
||||
setSelected((prev) => { const n = new Set(prev); if (n.has(id)) n.delete(id); else n.add(id); return n; });
|
||||
}
|
||||
function toggleAllVisible() {
|
||||
setSelected((prev) => {
|
||||
const n = new Set(prev);
|
||||
if (allVisibleSelected) visible.forEach((s) => n.delete(s.id));
|
||||
else visible.forEach((s) => n.add(s.id));
|
||||
return n;
|
||||
});
|
||||
}
|
||||
function openRechunk(ids: string[]) {
|
||||
if (!ids.length) return;
|
||||
const first = rows.find((r) => r.id === ids[0]);
|
||||
setRechunkForm({ strategy: first?.chunking_strategy || DEFAULT_CHUNK_STRATEGY, size: first?.chunk_size || DEFAULT_CHUNK_SIZE, overlap: first?.chunk_overlap ?? DEFAULT_CHUNK_OVERLAP });
|
||||
setRechunkErr(null);
|
||||
setRechunkTargets(ids);
|
||||
setRechunkOpen(true);
|
||||
}
|
||||
async function doRechunk() {
|
||||
setRechunkBusy(true);
|
||||
setRechunkErr(null);
|
||||
try {
|
||||
await api.rechunkSources(project.id, rechunkTargets, {
|
||||
chunking_strategy: rechunkForm.strategy,
|
||||
chunk_size: Number(rechunkForm.size) || undefined,
|
||||
chunk_overlap: Number.isFinite(rechunkForm.overlap) ? Number(rechunkForm.overlap) : undefined,
|
||||
});
|
||||
setRechunkOpen(false);
|
||||
setSelected(new Set());
|
||||
reload();
|
||||
} catch (e: any) {
|
||||
setRechunkErr(e?.message || "Re-chunk failed. Please try again.");
|
||||
} finally { setRechunkBusy(false); }
|
||||
}
|
||||
|
||||
function openAdd(forFolder?: string) {
|
||||
setTargetFolder(forFolder ?? (inNamedFolder ? folder! : ""));
|
||||
setAddErr(null);
|
||||
setOpen(true);
|
||||
}
|
||||
|
||||
async function add() {
|
||||
setBusy(true);
|
||||
setAddErr(null);
|
||||
try {
|
||||
if (form.kind === "file") {
|
||||
if (!form.file) { setAddErr("Choose a file to upload."); return; }
|
||||
await api.uploadSource(project.id, form.file, targetFolder || undefined, form.chunkStrategy);
|
||||
} else {
|
||||
await api.addSource(project.id, {
|
||||
kind: form.kind, name: form.name || "Untitled", folder: targetFolder || undefined,
|
||||
text: form.kind === "text" ? form.text : undefined, uri: (form.kind === "url" || form.kind === "crawl") ? form.uri : undefined,
|
||||
chunking_strategy: form.chunkStrategy,
|
||||
});
|
||||
}
|
||||
setOpen(false); setForm({ kind: "text", name: "", text: "", uri: "", file: null, chunkStrategy: "recursive" }); reload();
|
||||
} catch (e: any) {
|
||||
setAddErr(String(e?.message || e));
|
||||
} finally { setBusy(false); }
|
||||
}
|
||||
|
||||
function FolderRow({ value, label, icon, count }: { value: string | null; label: string; icon: string; count: number }) {
|
||||
const active = folder === value;
|
||||
return (
|
||||
<button onClick={() => setFolder(value)} className="row spread" style={{
|
||||
width: "100%", alignItems: "center", padding: "7px 10px", borderRadius: 7, border: "none", cursor: "pointer",
|
||||
background: active ? "var(--bg-3)" : "transparent", color: active ? "var(--fg-0)" : "var(--fg-1)", fontSize: 13,
|
||||
}}>
|
||||
<span className="row gap2" style={{ alignItems: "center", minWidth: 0 }}><Icon name={icon as any} size={14} /><span className="truncate">{label}</span></span>
|
||||
<span className="t-caption fg-2 mono">{count}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="col" style={{ gap: 12 }}>
|
||||
{health?.needs_reembed && (
|
||||
<div className="card row spread" style={{ padding: "10px 14px", background: "var(--warn-bg)", borderColor: "transparent" }}>
|
||||
<div className="row gap2" style={{ minWidth: 0 }}><Icon name="bolt" size={15} style={{ color: "var(--warn)" }} />
|
||||
<span className="t-body-sm">{health.mismatched.length} source(s) were embedded with a different model than the current one ({health.current_model}) - they won't appear in search until re-embedded.</span>
|
||||
</div>
|
||||
<button className="btn btn-secondary btn-sm" style={{ flex: "none" }} onClick={async () => { for (const m of health.mismatched) await reingest(m.id); }}><Icon name="refresh" size={13} />Re-embed all</button>
|
||||
</div>
|
||||
)}
|
||||
<div className="row" style={{ gap: 18, alignItems: "flex-start" }}>
|
||||
{/* folder list */}
|
||||
<div className="card col" style={{ width: 218, flex: "none", padding: 10, gap: 2 }}>
|
||||
<FolderRow value={null} label="All files" icon="list" count={rows.length} />
|
||||
{hasUnfiled && <FolderRow value={UNFILED} label="Unfiled" icon="file" count={rows.filter((s) => !s.folder).length} />}
|
||||
{folders.map((f) => (
|
||||
<FolderRow key={f} value={f} label={f} icon="layers" count={rows.filter((s) => s.folder === f).length} />
|
||||
))}
|
||||
{newFolder === null ? (
|
||||
<button className="btn btn-ghost btn-sm" style={{ justifyContent: "flex-start", marginTop: 4 }} onClick={() => setNewFolder("")}>
|
||||
<Icon name="plus" size={13} />New folder
|
||||
</button>
|
||||
) : (
|
||||
<input
|
||||
autoFocus className="input" style={{ marginTop: 4, fontSize: 13 }} placeholder="Folder name…" value={newFolder}
|
||||
onChange={(e) => setNewFolder(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && newFolder.trim()) {
|
||||
// Folders exist through their files: open the add modal locked to the new folder.
|
||||
const nf = newFolder.trim();
|
||||
setNewFolder(null);
|
||||
openAdd(nf);
|
||||
}
|
||||
if (e.key === "Escape") setNewFolder(null);
|
||||
}}
|
||||
onBlur={() => setNewFolder(null)}
|
||||
/>
|
||||
)}
|
||||
<div className="t-caption fg-2" style={{ padding: "6px 10px 2px" }}>
|
||||
Retrieval nodes and knowledge_search tools can filter by folder.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* sources table */}
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div className="row spread" style={{ marginBottom: 12 }}>
|
||||
<div className="t-h2">{folder === null ? "All files" : folder === UNFILED ? "Unfiled" : folder}</div>
|
||||
<div className="row gap2">
|
||||
<button className="btn btn-ghost btn-sm" onClick={dedupe} disabled={dedupeBusy} title="Remove exact-duplicate chunks (identical text) so the same passage never fills two retrieval slots.">
|
||||
<Icon name={dedupeBusy ? "refresh" : "layers"} size={14} style={dedupeBusy ? { animation: "spin 1s linear infinite" } : {}} />{dedupeBusy ? "Removing…" : "Remove duplicates"}
|
||||
</button>
|
||||
<button className="btn btn-primary btn-sm" onClick={() => openAdd()}>
|
||||
<Icon name="plus" size={14} />Add source
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{dedupeMsg && (
|
||||
<div className="card row spread" style={{ padding: "8px 12px", marginBottom: 10, alignItems: "center" }}>
|
||||
<span className="t-body-sm">{dedupeMsg}</span>
|
||||
<button className="iconbtn" title="Dismiss" onClick={() => setDedupeMsg(null)}><Icon name="x" size={13} /></button>
|
||||
</div>
|
||||
)}
|
||||
{selected.size > 0 && (
|
||||
<div className="card row spread" style={{ padding: "8px 12px", marginBottom: 10, alignItems: "center" }}>
|
||||
<span className="t-body-sm"><b>{selected.size}</b> selected</span>
|
||||
<div className="row gap2">
|
||||
<button className="btn btn-secondary btn-sm" onClick={() => openRechunk([...selected])}><Icon name="refresh" size={13} />Re-chunk selected</button>
|
||||
<button className="btn btn-ghost btn-sm" onClick={() => setSelected(new Set())}>Clear</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="card" style={{ overflow: "hidden" }}>
|
||||
<table className="tbl">
|
||||
<thead><tr>
|
||||
<th style={{ width: 30 }}><input type="checkbox" aria-label="Select all" checked={allVisibleSelected} onChange={toggleAllVisible} /></th>
|
||||
<th>Name</th><th>Kind</th>{showFolderCol && <th>Folder</th>}<th>Status</th><th>Chunks</th><th>Chunking</th><th /></tr></thead>
|
||||
<tbody>
|
||||
{visible.map((s) => (
|
||||
<tr key={s.id} style={selected.has(s.id) ? { background: "var(--bg-3)" } : undefined}>
|
||||
<td><input type="checkbox" aria-label={`Select ${s.name}`} checked={selected.has(s.id)} onChange={() => toggleSel(s.id)} /></td>
|
||||
<td style={{ fontWeight: 600 }}>{s.name}</td>
|
||||
<td><span className="typechip">{s.kind}</span></td>
|
||||
{showFolderCol && (
|
||||
<td>
|
||||
<select
|
||||
className="select" style={{ fontSize: 12, padding: "3px 6px", maxWidth: 140 }}
|
||||
value={s.folder || UNFILED}
|
||||
onChange={async (e) => { await api.moveSource(project.id, s.id, e.target.value); reload(); }}
|
||||
>
|
||||
<option value={UNFILED}>Unfiled</option>
|
||||
{folders.map((f) => <option key={f} value={f}>{f}</option>)}
|
||||
{s.folder && !folders.includes(s.folder) && <option value={s.folder}>{s.folder}</option>}
|
||||
</select>
|
||||
</td>
|
||||
)}
|
||||
<td><StatusPill status={s.status} /></td>
|
||||
<td className="mono-sm">{s.chunks}</td>
|
||||
<td>
|
||||
<span className="typechip">{s.chunking_strategy || DEFAULT_CHUNK_STRATEGY}</span>
|
||||
<div className="t-caption fg-2 mono" style={{ marginTop: 2 }}>{s.chunk_size || DEFAULT_CHUNK_SIZE}/{s.chunk_overlap ?? DEFAULT_CHUNK_OVERLAP}</div>
|
||||
</td>
|
||||
<td style={{ textAlign: "right" }}>
|
||||
<div className="row gap1" style={{ justifyContent: "flex-end" }}>
|
||||
<button className="iconbtn" title="Re-chunk & re-embed" onClick={() => openRechunk([s.id])}><Icon name="layers" size={14} /></button>
|
||||
<button className="iconbtn" title="Re-embed (reuse current chunking)" onClick={() => reingest(s.id)}><Icon name="refresh" size={14} /></button>
|
||||
<button className="iconbtn" title="Delete" onClick={async () => { await api.deleteSource(project.id, s.id); reload(); }}><Icon name="trash" size={15} /></button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{visible.length === 0 && <tr><td colSpan={showFolderCol ? 8 : 7}><div className="fg-2" style={{ padding: 22, textAlign: "center" }}>{rows.length === 0 ? "No sources yet. Add text or a URL to feed your agents." : "No files in this folder yet."}</div></td></tr>}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Modal open={open} onClose={() => setOpen(false)} title={`Add source to “${targetFolder || "Unfiled"}”`} width={560}
|
||||
footer={<><button className="btn btn-ghost" onClick={() => setOpen(false)}>Cancel</button><button className="btn btn-primary" onClick={add} disabled={busy}>{busy ? "Ingesting…" : "Add & ingest"}</button></>}>
|
||||
<Field label="Kind"><Segmented options={[{ value: "text", label: "Paste text" }, { value: "url", label: "URL" }, { value: "crawl", label: "Crawl site" }, { value: "file", label: "Upload file" }]} value={form.kind} onChange={(v) => setForm((f) => ({ ...f, kind: v }))} /></Field>
|
||||
{form.kind !== "file" && (
|
||||
<Field label="Name"><input className="input" value={form.name} onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))} placeholder="Help Center FAQ" /></Field>
|
||||
)}
|
||||
{form.kind === "text" && (
|
||||
<Field label="Text" help="Split into ~1000-char chunks and embedded into Chroma."><textarea className="textarea" rows={7} value={form.text} onChange={(e) => setForm((f) => ({ ...f, text: e.target.value }))} placeholder="Paste documentation, policies, FAQs…" /></Field>
|
||||
)}
|
||||
{form.kind === "crawl" && (
|
||||
<Field label="Start URL" help="Crawls same-domain pages from here (up to ~10), strips HTML, chunks + embeds. Re-crawl anytime with the ↻ button."><input className="input mono" value={form.uri} onChange={(e) => setForm((f) => ({ ...f, uri: e.target.value }))} placeholder="https://docs.example.com" /></Field>
|
||||
)}
|
||||
{form.kind === "url" && (
|
||||
<Field label="URL" help="Fetched, stripped of HTML, chunked, embedded."><input className="input mono" value={form.uri} onChange={(e) => setForm((f) => ({ ...f, uri: e.target.value }))} placeholder="https://docs.example.com/help" /></Field>
|
||||
)}
|
||||
{form.kind === "file" && (
|
||||
<Field label="File" help="Text formats (.txt, .md, .csv, .json…) and PDF. Named after the file; extracted, chunked, embedded.">
|
||||
<input className="input" type="file" accept=".txt,.md,.markdown,.csv,.json,.html,.pdf,text/*,application/pdf"
|
||||
onChange={(e) => setForm((f) => ({ ...f, file: e.target.files?.[0] || null }))} />
|
||||
{form.file && <div className="t-caption fg-2" style={{ marginTop: 6 }}>{form.file.name} · {(form.file.size / 1024).toFixed(1)} KB</div>}
|
||||
</Field>
|
||||
)}
|
||||
<Field label="Chunking" help={`How this source is split before embedding. ${CHUNK_HELP}`}>
|
||||
<Segmented
|
||||
options={CHUNK_OPTIONS as any}
|
||||
value={form.chunkStrategy}
|
||||
onChange={(v) => setForm((f) => ({ ...f, chunkStrategy: v }))}
|
||||
/>
|
||||
</Field>
|
||||
{addErr && <div className="t-caption" style={{ color: "var(--err)", marginTop: 4 }}>⚠ {addErr}</div>}
|
||||
</Modal>
|
||||
|
||||
<Modal open={rechunkOpen} onClose={() => setRechunkOpen(false)} width={520}
|
||||
title={`Re-chunk ${rechunkTargets.length} source${rechunkTargets.length === 1 ? "" : "s"}`}
|
||||
footer={<><button className="btn btn-ghost" onClick={() => setRechunkOpen(false)}>Cancel</button><button className="btn btn-primary" onClick={doRechunk} disabled={rechunkBusy}>{rechunkBusy ? "Re-chunking…" : "Apply & re-embed"}</button></>}>
|
||||
<div className="t-caption fg-2" style={{ marginBottom: 12 }}>Re-splits and re-embeds the selected source(s) with these settings. Existing chunks are replaced. Text & file sources reuse their stored content; URLs & crawls are re-fetched.</div>
|
||||
<Field label="Chunking strategy" help={CHUNK_HELP}>
|
||||
<Segmented
|
||||
options={CHUNK_OPTIONS as any}
|
||||
value={rechunkForm.strategy}
|
||||
onChange={(v) => setRechunkForm((f) => ({ ...f, strategy: v }))}
|
||||
/>
|
||||
</Field>
|
||||
<div className="row gap2">
|
||||
<div style={{ flex: 1 }}>
|
||||
<Field label="Chunk size (chars)" help="Target characters per chunk."><input className="input" type="number" min={100} step={100} value={rechunkForm.size} onChange={(e) => setRechunkForm((f) => ({ ...f, size: Number(e.target.value) }))} /></Field>
|
||||
</div>
|
||||
<div style={{ flex: 1 }}>
|
||||
<Field label="Overlap (chars)" help="Characters shared between adjacent chunks."><input className="input" type="number" min={0} step={20} value={rechunkForm.overlap} onChange={(e) => setRechunkForm((f) => ({ ...f, overlap: Number(e.target.value) }))} /></Field>
|
||||
</div>
|
||||
</div>
|
||||
{rechunkErr && <div className="t-caption" style={{ color: "var(--err)", marginTop: 8 }}>⚠ {rechunkErr}</div>}
|
||||
</Modal>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------------- Q&A pairs (custom kinds + tags) ---------------- */
|
||||
|
||||
const BUILTIN_KINDS = ["faq", "error_workaround"];
|
||||
|
||||
function QA({ project }: { project: any }) {
|
||||
const [rows, setRows] = useState<QaPair[]>([]);
|
||||
const [kind, setKind] = useState<string | null>(null); // null = All pairs
|
||||
const [newKind, setNewKind] = useState<string | null>(null); // non-null = naming a new kind
|
||||
const [form, setForm] = useState({ question: "", answer: "", kind: "faq", tags: "" });
|
||||
const [editing, setEditing] = useState<string | null>(null);
|
||||
const [edit, setEdit] = useState({ question: "", answer: "", kind: "faq", tags: "" });
|
||||
const [saving, setSaving] = useState(false);
|
||||
const reload = useCallback(() => { if (project?.id) api.listQa(project.id).then(setRows).catch(() => {}); }, [project?.id]);
|
||||
useEffect(() => { reload(); }, [reload]);
|
||||
|
||||
const kinds = useMemo(() => {
|
||||
const set = new Set<string>(BUILTIN_KINDS);
|
||||
rows.forEach((q) => { if (q.kind) set.add(q.kind); });
|
||||
return [...set].sort();
|
||||
}, [rows]);
|
||||
|
||||
// Selecting a kind in the rail locks the add-form kind to it (mirrors Files/folders).
|
||||
const lockedKind = kind;
|
||||
const effectiveKind = lockedKind ?? (form.kind.trim() || "faq");
|
||||
const visible = kind === null ? rows : rows.filter((q) => q.kind === kind);
|
||||
// Hide the Kind column when viewing one kind - the rail already says which.
|
||||
const showKindCol = kind === null;
|
||||
|
||||
async function add() {
|
||||
if (!form.question.trim()) return;
|
||||
const tags = form.tags.split(",").map((t) => t.trim()).filter(Boolean);
|
||||
await api.addQa(project.id, { question: form.question, answer: form.answer, kind: effectiveKind, tags });
|
||||
setForm({ question: "", answer: "", kind: form.kind, tags: "" }); reload();
|
||||
}
|
||||
|
||||
function startEdit(q: QaPair) {
|
||||
setEditing(q.id);
|
||||
setEdit({ question: q.question, answer: q.answer, kind: q.kind || "faq", tags: (q.tags || []).join(", ") });
|
||||
}
|
||||
|
||||
async function saveEdit() {
|
||||
if (!editing || !edit.question.trim() || saving) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
const updated = await api.updateQa(project.id, editing, {
|
||||
question: edit.question.trim(),
|
||||
answer: edit.answer,
|
||||
kind: edit.kind.trim() || "faq",
|
||||
tags: edit.tags.split(",").map((tag) => tag.trim()).filter(Boolean),
|
||||
});
|
||||
setRows((current) => current.map((row) => row.id === updated.id ? updated : row));
|
||||
setEditing(null);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
function KindRow({ value, label, count }: { value: string | null; label: string; count: number }) {
|
||||
const active = kind === value;
|
||||
return (
|
||||
<button onClick={() => setKind(value)} className="row spread" style={{
|
||||
width: "100%", alignItems: "center", padding: "7px 10px", borderRadius: 7, border: "none", cursor: "pointer",
|
||||
background: active ? "var(--bg-3)" : "transparent", color: active ? "var(--fg-0)" : "var(--fg-1)", fontSize: 13,
|
||||
}}>
|
||||
<span className="row gap2" style={{ alignItems: "center", minWidth: 0 }}><Icon name={value === null ? "list" : "n_qa"} size={14} /><span className="truncate">{label}</span></span>
|
||||
<span className="t-caption fg-2 mono">{count}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="row" style={{ gap: 18, alignItems: "flex-start" }}>
|
||||
{/* kind list */}
|
||||
<div className="card col" style={{ width: 218, flex: "none", padding: 10, gap: 2 }}>
|
||||
<KindRow value={null} label="All pairs" count={rows.length} />
|
||||
{kinds.map((k) => {
|
||||
const count = rows.filter((q) => q.kind === k).length;
|
||||
if (!count && kind !== k) return null;
|
||||
return <KindRow key={k} value={k} label={k} count={count} />;
|
||||
})}
|
||||
{newKind === null ? (
|
||||
<button className="btn btn-ghost btn-sm" style={{ justifyContent: "flex-start", marginTop: 4 }} onClick={() => setNewKind("")}>
|
||||
<Icon name="plus" size={13} />New kind
|
||||
</button>
|
||||
) : (
|
||||
<input
|
||||
autoFocus className="input" style={{ marginTop: 4, fontSize: 13 }} placeholder="Kind name…" value={newKind}
|
||||
onChange={(e) => setNewKind(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && newKind.trim()) {
|
||||
// Kinds exist through their pairs: select it + prime the add form.
|
||||
const nk = newKind.trim();
|
||||
setForm((f) => ({ ...f, kind: nk })); setKind(nk); setNewKind(null);
|
||||
}
|
||||
if (e.key === "Escape") setNewKind(null);
|
||||
}}
|
||||
onBlur={() => setNewKind(null)}
|
||||
/>
|
||||
)}
|
||||
<div className="t-caption fg-2" style={{ padding: "6px 10px 2px" }}>
|
||||
Kinds are free-form categories. Retrieval nodes (and agent Q&A) can filter by kind.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* add form + table */}
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div className="card" style={{ padding: 14, marginBottom: 16 }}>
|
||||
<div className="row spread" style={{ marginBottom: 10 }}>
|
||||
<div className="t-h3">Add Q&A pair</div>
|
||||
{lockedKind ? (
|
||||
<span className="row gap2 t-caption fg-1" style={{ alignItems: "center" }}><Icon name="n_qa" size={13} />kind: <b>{lockedKind}</b></span>
|
||||
) : (
|
||||
<div style={{ width: 200 }}>
|
||||
<input className="input" list="qa-kinds" value={form.kind} placeholder="Kind (e.g. faq, billing)" onChange={(e) => setForm((f) => ({ ...f, kind: e.target.value }))} title="Category. Pick an existing kind or type a new one." />
|
||||
<datalist id="qa-kinds">{kinds.map((k) => <option key={k} value={k} />)}</datalist>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="row gap2" style={{ marginBottom: 8 }}>
|
||||
<input className="input" style={{ flex: 1 }} placeholder="Question" value={form.question} onChange={(e) => setForm((f) => ({ ...f, question: e.target.value }))} />
|
||||
</div>
|
||||
<div className="row gap2" style={{ marginBottom: 8 }}>
|
||||
<textarea className="textarea" style={{ flex: 1, minHeight: 44 }} rows={2} placeholder="Answer" value={form.answer} onChange={(e) => setForm((f) => ({ ...f, answer: e.target.value }))} />
|
||||
</div>
|
||||
<div className="row gap2">
|
||||
<input className="input" style={{ flex: 1 }} placeholder="Tags (comma separated, optional)" value={form.tags} onChange={(e) => setForm((f) => ({ ...f, tags: e.target.value }))} />
|
||||
<button className="btn btn-primary" onClick={add}><Icon name="plus" size={14} />Add</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card" style={{ overflow: "hidden" }}>
|
||||
<table className="tbl"><thead><tr><th>Question</th><th>Answer</th>{showKindCol && <th>Kind</th>}<th>Tags</th><th /></tr></thead>
|
||||
<tbody>
|
||||
{visible.map((q) => editing === q.id ? (
|
||||
<tr key={q.id}>
|
||||
<td><input autoFocus className="input" value={edit.question} onChange={(e) => setEdit((v) => ({ ...v, question: e.target.value }))} /></td>
|
||||
<td><textarea className="textarea" rows={2} value={edit.answer} onChange={(e) => setEdit((v) => ({ ...v, answer: e.target.value }))} /></td>
|
||||
{showKindCol && <td><input className="input" list="qa-kinds" value={edit.kind} onChange={(e) => setEdit((v) => ({ ...v, kind: e.target.value }))} /></td>}
|
||||
<td><input className="input" value={edit.tags} placeholder="tag, tag" onChange={(e) => setEdit((v) => ({ ...v, tags: e.target.value }))} /></td>
|
||||
<td style={{ textAlign: "right", whiteSpace: "nowrap" }}>
|
||||
<button className="iconbtn" title="Save" onClick={saveEdit} disabled={!edit.question.trim() || saving}><Icon name="check" size={15} /></button>
|
||||
<button className="iconbtn" title="Cancel" onClick={() => setEditing(null)} disabled={saving}><Icon name="x" size={15} /></button>
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
<tr key={q.id}>
|
||||
<td style={{ fontWeight: 600, maxWidth: 260 }} className="truncate">{q.question}</td>
|
||||
<td className="fg-1 truncate" style={{ maxWidth: 280 }}>{q.answer}</td>
|
||||
{showKindCol && <td><span className="typechip">{q.kind}</span></td>}
|
||||
<td className="fg-2 t-caption truncate" style={{ maxWidth: 140 }}>{(q.tags || []).join(", ") || "-"}</td>
|
||||
<td style={{ textAlign: "right", whiteSpace: "nowrap" }}>
|
||||
<button className="iconbtn" title="Edit" onClick={() => startEdit(q)}><Icon name="edit" size={15} /></button>
|
||||
<button className="iconbtn" title="Delete" onClick={async () => { await api.deleteQa(project.id, q.id); reload(); }}><Icon name="trash" size={15} /></button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{visible.length === 0 && <tr><td colSpan={showKindCol ? 5 : 4}><div className="fg-2" style={{ padding: 22, textAlign: "center" }}>{rows.length === 0 ? "No Q&A pairs yet." : "No pairs of this kind yet."}</div></td></tr>}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------------- Search debugger ---------------- */
|
||||
|
||||
function SearchDebugger({ project }: { project: any }) {
|
||||
const [q, setQ] = useState("");
|
||||
const [hits, setHits] = useState<SearchHit[]>([]);
|
||||
const [searched, setSearched] = useState(false);
|
||||
const [folders, setFolders] = useState<string[]>([]);
|
||||
const [folder, setFolder] = useState("");
|
||||
const [mode, setMode] = useState<"vector" | "hybrid">("vector");
|
||||
const [rerank, setRerank] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
useEffect(() => { if (project?.id) api.listFolders(project.id).then(setFolders).catch(() => {}); }, [project?.id]);
|
||||
async function run() {
|
||||
if (busy || !q.trim()) return;
|
||||
setBusy(true); setErr(null);
|
||||
try {
|
||||
const h = await api.searchKnowledge(project.id, q, 8, folder ? [folder] : undefined, mode === "hybrid", rerank);
|
||||
setHits(h); setSearched(true);
|
||||
} catch (e: any) {
|
||||
setErr(e?.message || "Search failed."); setHits([]); setSearched(true);
|
||||
} finally { setBusy(false); }
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<div className="row gap2" style={{ marginBottom: 8, alignItems: "center" }}>
|
||||
<input className="input" style={{ flex: 1 }} placeholder="Query the knowledge base…" value={q} onChange={(e) => setQ(e.target.value)} onKeyDown={(e) => e.key === "Enter" && run()} />
|
||||
{folders.length > 0 && (
|
||||
<select className="select" style={{ width: 160 }} value={folder} onChange={(e) => setFolder(e.target.value)}>
|
||||
<option value="">All folders</option>
|
||||
{folders.map((f) => <option key={f} value={f}>{f}</option>)}
|
||||
</select>
|
||||
)}
|
||||
<button className="btn btn-primary" onClick={run} disabled={busy || !q.trim()}>
|
||||
<Icon name={busy ? "refresh" : "search"} size={14} style={busy ? { animation: "spin 1s linear infinite" } : {}} />{busy ? "Searching…" : "Search"}
|
||||
</button>
|
||||
</div>
|
||||
{err && <div className="t-caption" style={{ color: "var(--err)", marginBottom: 8 }}>{err}</div>}
|
||||
<div className="row gap2" style={{ marginBottom: 6, alignItems: "center" }}>
|
||||
<Segmented
|
||||
options={[{ value: "vector", label: "Vector" }, { value: "hybrid", label: "Hybrid" }]}
|
||||
value={mode}
|
||||
onChange={(v) => setMode(v as "vector" | "hybrid")}
|
||||
/>
|
||||
<label className="row gap1" style={{ alignItems: "center", cursor: "pointer", fontSize: 13 }} title="Two-stage retrieval: a local cross-encoder re-scores the shortlist and keeps the best matches. Runs offline; adds some latency.">
|
||||
<input type="checkbox" checked={rerank} onChange={(e) => setRerank(e.target.checked)} />
|
||||
Rerank
|
||||
</label>
|
||||
<span className="t-caption fg-2">
|
||||
{rerank
|
||||
? "Cross-encoder rerank on. Score is the reranker’s relevance (0–1)."
|
||||
: mode === "hybrid"
|
||||
? "BM25 lexical + vector, fused via RRF. Score is a normalized fusion rank (0–1), not cosine."
|
||||
: "Vector-only. Score is cosine similarity (0–1) between the query and each chunk."}
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ marginBottom: 14 }} />
|
||||
<div className="col gap2">
|
||||
{hits.map((h, i) => (
|
||||
<div key={i} className="card" style={{ padding: 12 }}>
|
||||
<div className="row spread" style={{ marginBottom: 4 }}>
|
||||
<span className="t-caption fg-2 mono">{h.source_id?.slice(0, 12) || "-"}</span>
|
||||
<span className="chip chip-mono">score {h.score.toFixed(3)}</span>
|
||||
</div>
|
||||
<div className="t-body-sm" style={{ whiteSpace: "pre-wrap" }}>{h.text}</div>
|
||||
</div>
|
||||
))}
|
||||
{searched && hits.length === 0 && <div className="fg-2" style={{ padding: 22, textAlign: "center" }}>No matches. Add sources first.</div>}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
"use client";
|
||||
/* External MCP - register MCP servers, discover their tools, toggle which are live.
|
||||
Server-scoped: agents and workflow nodes consume a server's *enabled* tools. */
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { Icon } from "../icons";
|
||||
import { Field, Modal, Tile, Toggle } from "../primitives";
|
||||
import { api, McpClientT } from "@/lib/api";
|
||||
|
||||
export function McpClientsScreen({ project }: { project: any }) {
|
||||
const [rows, setRows] = useState<McpClientT[]>([]);
|
||||
const [selId, setSelId] = useState<string | null>(null);
|
||||
const [addOpen, setAddOpen] = useState(false);
|
||||
// Session cache of discovered tools per server, so re-selecting one is instant
|
||||
// (the server is the source of truth - "Re-discover" forces a fresh fetch).
|
||||
const [toolCache, setToolCache] = useState<Record<string, { name: string; description?: string }[]>>({});
|
||||
|
||||
const reload = useCallback(() => {
|
||||
if (!project?.id) return;
|
||||
api.listMcpClients(project.id).then((r) => { setRows(r); setSelId((s) => (s && r.some((x) => x.id === s) ? s : (r[0]?.id ?? null))); }).catch(() => {});
|
||||
}, [project?.id]);
|
||||
useEffect(() => { reload(); }, [reload]);
|
||||
|
||||
const sel = rows.find((r) => r.id === selId) || null;
|
||||
|
||||
return (
|
||||
<div style={{ flex: 1, display: "flex", minHeight: 0 }}>
|
||||
{/* LEFT list */}
|
||||
<div style={{ width: 280, flex: "none", borderRight: "1px solid var(--line)", display: "flex", flexDirection: "column", background: "var(--bg-1)" }}>
|
||||
<div className="row spread" style={{ padding: "14px 16px", borderBottom: "1px solid var(--line)" }}>
|
||||
<div className="t-display">External MCP</div>
|
||||
<button className="btn btn-primary btn-sm" onClick={() => setAddOpen(true)}><Icon name="plus" size={14} /></button>
|
||||
</div>
|
||||
<div className="scroll-y" style={{ flex: 1, padding: 8 }}>
|
||||
{rows.length === 0 && <div className="fg-2 t-caption" style={{ padding: 12 }}>No MCP servers yet. Click + to connect one (e.g. GitHub).</div>}
|
||||
{rows.map((m) => {
|
||||
const on = selId === m.id;
|
||||
return (
|
||||
<button key={m.id} onClick={() => setSelId(m.id)} className="col" style={{ width: "100%", textAlign: "left", padding: "11px 12px", borderRadius: 9, marginBottom: 4, border: "1px solid " + (on ? "var(--accent)" : "transparent"), background: on ? "var(--accent-glow)" : "transparent", cursor: "pointer", gap: 4 }}>
|
||||
<div className="row spread"><span className="mono-sm" style={{ fontWeight: 700, color: "var(--fg-0)" }}>{m.name}</span><span className="typechip">{m.transport}</span></div>
|
||||
<div className="row spread">
|
||||
<span className="truncate" style={{ fontSize: 11, color: "var(--fg-2)" }}>{m.url}</span>
|
||||
<span
|
||||
className="iconbtn" role="button" title="Remove server"
|
||||
onClick={async (e) => {
|
||||
e.stopPropagation();
|
||||
if (!window.confirm(`Remove MCP server “${m.name}”? Agents and workflows using it will lose those tools.`)) return;
|
||||
await api.deleteMcpClient(project.id, m.id);
|
||||
reload();
|
||||
}}
|
||||
><Icon name="trash" size={13} /></span>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* RIGHT detail */}
|
||||
<div className="grow scroll-y" style={{ padding: 24, minWidth: 0 }}>
|
||||
{sel ? <ServerDetail key={sel.id} project={project} server={sel} onChanged={reload} cached={toolCache[sel.id] ?? null} onLoaded={(list) => setToolCache((c) => ({ ...c, [sel.id]: list }))} /> : (
|
||||
<div className="col center" style={{ height: "100%", gap: 8, color: "var(--fg-2)" }}><Tile icon="connect" color="var(--accent)" size={48} glow /><div className="t-h2">Connect an MCP server</div></div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<AddServerModal open={addOpen} project={project} onClose={() => setAddOpen(false)} onAdded={(id) => { setAddOpen(false); reload(); setSelId(id); }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ServerDetail({ project, server, onChanged, cached, onLoaded }: { project: any; server: McpClientT; onChanged: () => void; cached: { name: string; description?: string }[] | null; onLoaded: (list: { name: string; description?: string }[]) => void }) {
|
||||
const [tools, setTools] = useState<{ name: string; description?: string }[] | null>(cached);
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [disabled, setDisabled] = useState<Set<string>>(new Set(server.disabled_tools || []));
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const discover = useCallback(() => {
|
||||
setLoading(true); setErr(null);
|
||||
api.discoverMcpTools(project.id, server.id)
|
||||
.then((r) => { if (r.ok) { const list = r.tools || []; setTools(list); onLoaded(list); } else setErr(r.error || "Could not list tools from that server."); })
|
||||
.catch((e) => setErr(String(e?.message || e)))
|
||||
.finally(() => setLoading(false));
|
||||
}, [project.id, server.id, onLoaded]);
|
||||
// Only fetch when this server's tools aren't already cached this session; the server is
|
||||
// the source of truth, so the "Re-discover" button forces a fresh fetch on demand.
|
||||
useEffect(() => { if (cached === null) discover(); }, [server.id]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
async function toggle(name: string) {
|
||||
const next = new Set(disabled);
|
||||
if (next.has(name)) next.delete(name); else next.add(name);
|
||||
setDisabled(next); setSaving(true);
|
||||
try { await api.updateMcpClient(project.id, server.id, { disabled_tools: [...next] }); onChanged(); }
|
||||
catch { setDisabled(new Set(server.disabled_tools || [])); }
|
||||
finally { setSaving(false); }
|
||||
}
|
||||
|
||||
const enabledCount = tools ? tools.filter((t) => !disabled.has(t.name)).length : 0;
|
||||
|
||||
return (
|
||||
<div style={{ maxWidth: 960 }}>
|
||||
<div className="row spread" style={{ marginBottom: 18 }}>
|
||||
<div className="row gap3">
|
||||
<Tile icon="connect" color="var(--accent)" size={40} glow />
|
||||
<div>
|
||||
<div className="t-display mono" style={{ fontSize: 18 }}>{server.name}</div>
|
||||
<div className="fg-2 t-caption">{server.transport} · {server.url}</div>
|
||||
</div>
|
||||
</div>
|
||||
<button className="btn btn-secondary" onClick={discover} disabled={loading}><Icon name="refresh" size={15} style={loading ? { animation: "spin 1s linear infinite" } : {}} />{loading ? "Connecting…" : "Re-discover"}</button>
|
||||
</div>
|
||||
|
||||
<div className="card" style={{ padding: 18 }}>
|
||||
<div className="row spread" style={{ marginBottom: 12 }}>
|
||||
<div className="t-h2">Tools{tools ? ` · ${enabledCount}/${tools.length} enabled` : ""}</div>
|
||||
{saving && <span className="t-caption fg-2">Saving…</span>}
|
||||
</div>
|
||||
{err && <div className="card" style={{ padding: 12, color: "var(--err)", background: "var(--bg-3)" }}>{err}</div>}
|
||||
{!err && tools === null && <div className="fg-2 t-caption">Connecting to the server…</div>}
|
||||
{!err && tools && tools.length === 0 && <div className="fg-2 t-caption">This server exposes no tools.</div>}
|
||||
{!err && tools && tools.length > 0 && (
|
||||
<div className="col gap2">
|
||||
{tools.map((t) => {
|
||||
const on = !disabled.has(t.name);
|
||||
return (
|
||||
<div key={t.name} className="row spread" style={{ padding: "10px 12px", border: "1px solid var(--line)", borderRadius: 9, gap: 12, alignItems: "flex-start" }}>
|
||||
<div className="col" style={{ gap: 2, minWidth: 0 }}>
|
||||
<span className="mono-sm" style={{ fontWeight: 700, color: on ? "var(--fg-0)" : "var(--fg-2)" }}>{t.name}</span>
|
||||
{t.description && <span className="t-caption fg-2" style={{ whiteSpace: "normal" }}>{t.description}</span>}
|
||||
</div>
|
||||
<Toggle on={on} onChange={() => toggle(t.name)} />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
<div className="fg-2 t-caption" style={{ marginTop: 12 }}>Disabled tools stay hidden from agents and workflow nodes that use this server.</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AddServerModal({ open, onClose, project, onAdded }: { open: boolean; onClose: () => void; project: any; onAdded: (id: string) => void }) {
|
||||
const [form, setForm] = useState({ name: "github", transport: "streamable_http", url: "", token: "" });
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => { if (open) { setForm({ name: "github", transport: "streamable_http", url: "", token: "" }); setErr(null); } }, [open]);
|
||||
|
||||
async function add() {
|
||||
if (!form.url.trim()) { setErr("Enter the server URL."); return; }
|
||||
setBusy(true); setErr(null);
|
||||
try {
|
||||
const name = (form.name || "mcp_server").trim().replace(/[^a-zA-Z0-9_-]/g, "_");
|
||||
let headers_ref: string | undefined;
|
||||
if (form.token.trim()) {
|
||||
const secName = `${name}_mcp_headers`;
|
||||
await api.createSecret(project.id, { name: secName, value: { Authorization: `Bearer ${form.token.trim()}` }, kind: "mcp_headers" });
|
||||
headers_ref = `secret://proj/${secName}`;
|
||||
}
|
||||
const created = await api.createMcpClient(project.id, { name, transport: form.transport, url: form.url.trim(), headers_ref });
|
||||
onAdded(created.id);
|
||||
} catch (e: any) { setErr(String(e?.message || e)); } finally { setBusy(false); }
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal open={open} onClose={onClose} title="Connect MCP server" width={520}
|
||||
footer={<><button className="btn btn-ghost" onClick={onClose}>Cancel</button><button className="btn btn-primary" onClick={add} disabled={busy}>{busy ? "Connecting…" : "Add server"}</button></>}>
|
||||
<div className="row gap2">
|
||||
<Field label="Name"><input className="input mono" value={form.name} onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))} placeholder="github" /></Field>
|
||||
<Field label="Transport">
|
||||
<select className="select" value={form.transport} onChange={(e) => setForm((f) => ({ ...f, transport: e.target.value }))}>
|
||||
{["streamable_http", "sse", "stdio"].map((t) => <option key={t}>{t}</option>)}
|
||||
</select>
|
||||
</Field>
|
||||
</div>
|
||||
<Field label="Server URL" help="The MCP endpoint. GitHub's hosted server is https://api.githubcopilot.com/mcp/">
|
||||
<input className="input mono" value={form.url} onChange={(e) => setForm((f) => ({ ...f, url: e.target.value }))} placeholder="https://api.githubcopilot.com/mcp/" />
|
||||
</Field>
|
||||
<Field label="Bearer token" help="Optional. For servers that need auth (e.g. a GitHub PAT). Saved to Settings → Secrets (encrypted) and sent as the Authorization header.">
|
||||
<input className="input mono" type="password" value={form.token} onChange={(e) => setForm((f) => ({ ...f, token: e.target.value }))} placeholder="ghp_…" />
|
||||
</Field>
|
||||
{err && <div className="card" style={{ padding: 12, color: "var(--err)", marginTop: 4 }}>{err}</div>}
|
||||
<div className="fg-2 t-caption" style={{ marginTop: 8 }}>Forge connects and lists the server's tools; toggle which ones agents and workflows can use.</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,408 @@
|
||||
"use client";
|
||||
/* Screens for the platform features: Channels, Triggers, Datasets (eval),
|
||||
and the live-agent Handoff inbox. */
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { Icon } from "../icons";
|
||||
import { Field, Modal } from "../primitives";
|
||||
import { api, Channel, Dataset, EvalReport, EvalResult, Handoff, Trigger, Workflow, openSSE } from "@/lib/api";
|
||||
|
||||
/* Validate the Cases JSON before it is POSTed: it must be a non-empty array of objects
|
||||
that each carry a string `input`. Returns the parsed cases (or null) + a human error so
|
||||
the modal can block Create instead of silently saving an empty / un-runnable dataset. */
|
||||
function parseCases(text: string): { cases: any[] | null; error: string | null } {
|
||||
let v: unknown;
|
||||
try { v = JSON.parse(text); } catch { return { cases: null, error: "Not valid JSON." }; }
|
||||
if (!Array.isArray(v)) return { cases: null, error: "Expected a JSON array of cases." };
|
||||
if (v.length === 0) return { cases: null, error: "Add at least one case." };
|
||||
for (const c of v) {
|
||||
if (typeof c !== "object" || c === null || typeof (c as any).input !== "string")
|
||||
return { cases: null, error: 'Each case needs a string "input" field.' };
|
||||
}
|
||||
return { cases: v as any[], error: null };
|
||||
}
|
||||
|
||||
const EMPTY_DATASET_FORM = { name: "", workflow_id: "", score_mode: "contains", items: '[\n {"input": "what are your hours?", "expected": "9am"}\n]' };
|
||||
|
||||
/* One labelled block in an expanded eval result (input / expected / answer / reason). */
|
||||
function ResultField({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="col" style={{ gap: 3 }}>
|
||||
<div className="t-micro">{label}</div>
|
||||
<div className="t-caption" style={{ whiteSpace: "pre-wrap", wordBreak: "break-word", color: "var(--fg-1)", maxHeight: 220, overflowY: "auto" }}>{value}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* Per-case run metrics, shown as chips once a case finishes. */
|
||||
const fmtLatency = (ms?: number) => (ms == null ? "" : ms >= 1000 ? `${(ms / 1000).toFixed(1)}s` : `${ms}ms`);
|
||||
const fmtTokens = (t?: number) => (t == null ? "" : t >= 1000 ? `${(t / 1000).toFixed(1)}k tok` : `${t} tok`);
|
||||
|
||||
/* Status pill for one case: running (spinner), pass, fail, or an inconclusive status
|
||||
(run_failed / unavailable / error) rendered as a warning rather than a plain fail. */
|
||||
function CaseStatus({ result }: { result?: EvalResult }) {
|
||||
if (!result) return <span className="pill pill-muted row gap1" style={{ alignItems: "center" }}><Icon name="refresh" size={11} className="spin" />running…</span>;
|
||||
if (result.passed) return <span className="pill pill-ok">pass</span>;
|
||||
const inconclusive = result.status && result.status !== "scored";
|
||||
if (inconclusive) return <span className="pill pill-warn" title={result.reason || undefined}>{result.status === "run_failed" ? "run failed" : result.status}</span>;
|
||||
return <span className="pill pill-err">fail</span>;
|
||||
}
|
||||
|
||||
const SCORING_HELP: Record<string, string> = {
|
||||
contains: "Pass if the answer contains the expected text (case-insensitive).",
|
||||
exact: "Pass if the answer exactly equals the expected text.",
|
||||
regex: "Pass if the expected pattern (regex) matches the answer.",
|
||||
judge: "An LLM grades whether the answer satisfies the expected behavior.",
|
||||
};
|
||||
|
||||
function Header({ title, subtitle, action }: { title: string; subtitle?: string; action?: React.ReactNode }) {
|
||||
return (
|
||||
<div className="row spread" style={{ marginBottom: 18 }}>
|
||||
<div>
|
||||
<div className="t-display">{title}</div>
|
||||
{subtitle && <div className="fg-1" style={{ marginTop: 2 }}>{subtitle}</div>}
|
||||
</div>
|
||||
{action}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Shell({ children }: { children: React.ReactNode }) {
|
||||
return <div className="scroll-y" style={{ flex: 1, padding: "24px 28px" }}><div className="fade-up" style={{ maxWidth: 1600, margin: "0 auto" }}>{children}</div></div>;
|
||||
}
|
||||
|
||||
function useWorkflows(pid?: string) {
|
||||
const [wfs, setWfs] = useState<Workflow[]>([]);
|
||||
useEffect(() => { if (pid) api.listWorkflows(pid).then(setWfs).catch(() => setWfs([])); }, [pid]);
|
||||
return wfs;
|
||||
}
|
||||
|
||||
/* ============ CHANNELS ============ */
|
||||
type ChannelForm = { id?: string; type: string; name: string; workflow_id: string; config: any };
|
||||
const BLANK_CHANNEL: ChannelForm = { type: "email", name: "", workflow_id: "", config: {} };
|
||||
|
||||
export function ChannelsScreen({ project }: { project: any }) {
|
||||
const [channels, setChannels] = useState<Channel[]>([]);
|
||||
const wfs = useWorkflows(project?.id);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [form, setForm] = useState<ChannelForm>(BLANK_CHANNEL);
|
||||
const reload = useCallback(() => { if (project?.id) api.listChannels(project.id).then(setChannels).catch(() => setChannels([])); }, [project?.id]);
|
||||
useEffect(() => { reload(); }, [reload]);
|
||||
|
||||
const setSmtp = (patch: any) => setForm((f) => ({ ...f, config: { ...f.config, smtp: { ...(f.config.smtp || {}), ...patch } } }));
|
||||
const smtp = (form.config || {}).smtp || {};
|
||||
|
||||
async function save() {
|
||||
if (!form.name.trim()) return;
|
||||
if (form.id) await api.updateChannel(project.id, form.id, { name: form.name, workflow_id: form.workflow_id || undefined, config: form.config });
|
||||
else await api.createChannel(project.id, { type: form.type, name: form.name, workflow_id: form.workflow_id || undefined, config: form.config });
|
||||
setOpen(false); setForm(BLANK_CHANNEL); reload();
|
||||
}
|
||||
function edit(ch: Channel) { setForm({ id: ch.id, type: ch.type, name: ch.name, workflow_id: ch.workflow_id || "", config: ch.config || {} }); setOpen(true); }
|
||||
async function remove(id: string) { if (window.confirm("Delete this channel?")) { await api.deleteChannel(project.id, id); reload(); } }
|
||||
const urlOf = (ch: Channel) => ch.inbound_url;
|
||||
|
||||
return (
|
||||
<Shell>
|
||||
<Header title="Channels" subtitle="Deploy a workflow to an email surface."
|
||||
action={<button className="btn btn-primary btn-sm" onClick={() => { setForm(BLANK_CHANNEL); setOpen(true); }}><Icon name="plus" size={14} />New channel</button>} />
|
||||
<div className="col gap2">
|
||||
{channels.map((ch) => (
|
||||
<div key={ch.id} className="card" style={{ padding: 14 }}>
|
||||
<div className="row spread">
|
||||
<div className="row gap2"><Icon name="msg" size={16} /><span className="t-h3">{ch.name}</span><span className="typechip">{ch.type}</span>{!ch.enabled && <span className="pill pill-muted">disabled</span>}</div>
|
||||
<div className="row gap2"><button className="btn btn-secondary btn-sm" onClick={() => edit(ch)}><Icon name="edit" size={13} />Configure</button><button className="iconbtn" title="Delete" onClick={() => remove(ch.id)}><Icon name="trash" size={14} /></button></div>
|
||||
</div>
|
||||
{urlOf(ch) && <div className="mono-sm fg-2" style={{ marginTop: 8, wordBreak: "break-all" }}>{urlOf(ch)}</div>}
|
||||
</div>
|
||||
))}
|
||||
{channels.length === 0 && <div className="fg-2 t-caption">No channels yet. Create one to deploy this project's workflow.</div>}
|
||||
</div>
|
||||
<Modal open={open} onClose={() => setOpen(false)} title={form.id ? "Configure channel" : "New channel"} width={500}
|
||||
footer={<><button className="btn btn-ghost" onClick={() => setOpen(false)}>Cancel</button><button className="btn btn-primary" onClick={save}>{form.id ? "Save" : "Create"}</button></>}>
|
||||
<Field label="Name"><input className="input" value={form.name} onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))} placeholder="Support channel" /></Field>
|
||||
<Field label="Workflow" help="Which workflow handles messages on this channel."><select className="select" value={form.workflow_id} onChange={(e) => setForm((f) => ({ ...f, workflow_id: e.target.value }))}><option value="">First active workflow</option>{wfs.map((w) => <option key={w.id} value={w.id}>{w.name}</option>)}</select></Field>
|
||||
|
||||
{form.type === "email" && (
|
||||
<>
|
||||
<div className="field-help" style={{ marginTop: 0 }}>Outbound SMTP for replies. Inbound mail is posted to the channel's inbound URL by your provider (Mailgun/SendGrid/Postmark) or an IMAP relay.</div>
|
||||
<div className="row gap3">
|
||||
<Field label="SMTP host"><input className="input mono" value={smtp.host || ""} onChange={(e) => setSmtp({ host: e.target.value })} placeholder="smtp.sendgrid.net" /></Field>
|
||||
<Field label="Port"><input className="input mono" type="number" value={smtp.port ?? 587} onChange={(e) => setSmtp({ port: Number(e.target.value) })} /></Field>
|
||||
</div>
|
||||
<div className="row gap3">
|
||||
<Field label="Username"><input className="input mono" value={smtp.username || ""} onChange={(e) => setSmtp({ username: e.target.value })} /></Field>
|
||||
<Field label="From address"><input className="input mono" value={smtp.from || ""} onChange={(e) => setSmtp({ from: e.target.value })} placeholder="support@yourco.com" /></Field>
|
||||
</div>
|
||||
<Field label="Password secret ref" help="A secret holding the SMTP password (Settings → Secrets)."><input className="input mono" value={smtp.password_ref || ""} onChange={(e) => setSmtp({ password_ref: e.target.value })} placeholder="secret://proj/smtp_password" /></Field>
|
||||
</>
|
||||
)}
|
||||
</Modal>
|
||||
</Shell>
|
||||
);
|
||||
}
|
||||
|
||||
/* ============ TRIGGERS ============ */
|
||||
export function TriggersScreen({ project }: { project: any }) {
|
||||
const [triggers, setTriggers] = useState<Trigger[]>([]);
|
||||
useEffect(() => { if (project?.id) api.listTriggers(project.id).then(setTriggers).catch(() => setTriggers([])); }, [project?.id]);
|
||||
return (
|
||||
<Shell>
|
||||
<Header title="Triggers" subtitle="Event entry points, synced from your workflows' trigger nodes (Webhook / Schedule / Email / Chat / App Event)." />
|
||||
<div className="col gap2">
|
||||
{triggers.map((t) => (
|
||||
<div key={t.id} className="card" style={{ padding: 14 }}>
|
||||
<div className="row spread">
|
||||
<div className="row gap2"><Icon name="bolt" size={15} /><span className="t-h3" style={{ textTransform: "capitalize" }}>{t.kind.replace("_", " ")}</span><span className="typechip">{t.node_id}</span>{!t.enabled && <span className="pill pill-muted">disabled</span>}</div>
|
||||
{t.last_fired_at && <span className="fg-2 t-caption">last fired {new Date(t.last_fired_at).toLocaleString()}</span>}
|
||||
</div>
|
||||
{t.webhook_url && <div className="mono-sm fg-2" style={{ marginTop: 8, wordBreak: "break-all" }}>POST {t.webhook_url}</div>}
|
||||
{t.config?.cron && <div className="mono-sm fg-2" style={{ marginTop: 8 }}>cron: {t.config.cron}</div>}
|
||||
{t.config?.every_minutes && <div className="mono-sm fg-2" style={{ marginTop: 8 }}>every {t.config.every_minutes} min</div>}
|
||||
{t.config?.poll_url && <div className="mono-sm fg-2" style={{ marginTop: 8, wordBreak: "break-all" }}>polls {t.config.poll_url}</div>}
|
||||
</div>
|
||||
))}
|
||||
{triggers.length === 0 && <div className="fg-2 t-caption">No triggers. Add a trigger node (Webhook / Schedule / …) to a workflow and publish it.</div>}
|
||||
</div>
|
||||
</Shell>
|
||||
);
|
||||
}
|
||||
|
||||
/* ============ DATASETS / EVAL ============ */
|
||||
export function DatasetsScreen({ project }: { project: any }) {
|
||||
const [datasets, setDatasets] = useState<Dataset[]>([]);
|
||||
const wfs = useWorkflows(project?.id);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [editing, setEditing] = useState<Dataset | null>(null);
|
||||
const [form, setForm] = useState(EMPTY_DATASET_FORM);
|
||||
const [report, setReport] = useState<EvalReport | null>(null);
|
||||
const [ranDataset, setRanDataset] = useState<Dataset | null>(null);
|
||||
const [expanded, setExpanded] = useState<Set<number>>(new Set());
|
||||
const [runError, setRunError] = useState<string | null>(null);
|
||||
const [running, setRunning] = useState<string | null>(null);
|
||||
// Live streaming state: the ordered case list (from the `start` frame) and each case's result
|
||||
// as it finishes (`item` frames), so the result view renders immediately and fills in live.
|
||||
const [liveItems, setLiveItems] = useState<{ index: number; input: string; expected: string }[] | null>(null);
|
||||
const [liveResults, setLiveResults] = useState<Record<number, EvalResult>>({});
|
||||
const reload = useCallback(() => { if (project?.id) api.listDatasets(project.id).then(setDatasets).catch(() => setDatasets([])); }, [project?.id]);
|
||||
useEffect(() => { reload(); }, [reload]);
|
||||
|
||||
// Guard the form so we never save a dataset that can't be run: a name, a bound
|
||||
// workflow, and a valid non-empty case list are all required.
|
||||
const { cases, error: casesError } = parseCases(form.items);
|
||||
const canSave = form.name.trim() !== "" && form.workflow_id !== "" && !!cases;
|
||||
|
||||
function openCreate() { setEditing(null); setForm(EMPTY_DATASET_FORM); setOpen(true); }
|
||||
function openEdit(d: Dataset) {
|
||||
setEditing(d);
|
||||
setForm({ name: d.name, workflow_id: d.workflow_id || "", score_mode: d.score_mode, items: JSON.stringify(d.items ?? [], null, 2) });
|
||||
setOpen(true);
|
||||
}
|
||||
async function save() {
|
||||
if (!canSave || !cases) return;
|
||||
const body = { name: form.name.trim(), workflow_id: form.workflow_id, score_mode: form.score_mode, items: cases };
|
||||
if (editing) await api.updateDataset(project.id, editing.id, body);
|
||||
else await api.createDataset(project.id, body);
|
||||
setOpen(false); reload();
|
||||
}
|
||||
async function remove(d: Dataset) {
|
||||
if (!window.confirm(`Delete dataset "${d.name}"?\n\nThis removes its cases and last run result. This cannot be undone.`)) return;
|
||||
await api.deleteDataset(project.id, d.id);
|
||||
if (editing?.id === d.id) setOpen(false);
|
||||
reload();
|
||||
}
|
||||
async function run(d: Dataset) {
|
||||
// Seed the live view from the dataset's own cases so every row shows up the instant Run is
|
||||
// clicked (before the first server frame), then stream results in as each case finishes.
|
||||
const seed = (d.items || []).map((it: any, i: number) => ({ index: i, input: it?.input ?? "", expected: it?.expected ?? "" }));
|
||||
setRunning(d.id); setReport(null); setRunError(null); setRanDataset(d); setExpanded(new Set());
|
||||
setLiveItems(seed); setLiveResults({});
|
||||
try {
|
||||
await openSSE(api.runDatasetStreamUrl(project.id, d.id), (frame) => {
|
||||
if (frame.event === "start") setLiveItems(frame.data.items);
|
||||
else if (frame.event === "item") { const r = frame.data as EvalResult & { index: number }; setLiveResults((prev) => ({ ...prev, [r.index]: r })); }
|
||||
else if (frame.event === "done") setReport(frame.data as EvalReport);
|
||||
else if (frame.event === "error") setRunError(frame.data?.error || "The run failed.");
|
||||
}, { method: "POST" });
|
||||
} catch (e: any) {
|
||||
setRunError(e?.message || "The run request failed.");
|
||||
} finally {
|
||||
setRunning(null); reload();
|
||||
}
|
||||
}
|
||||
|
||||
// Live-view derived numbers (used by the result card below).
|
||||
const rows = liveItems;
|
||||
const total = rows?.length ?? 0;
|
||||
const doneResults = Object.values(liveResults);
|
||||
const doneCount = doneResults.length;
|
||||
const passSoFar = doneResults.filter((r) => r.passed).length;
|
||||
const isRunningNow = running !== null;
|
||||
const summary = report?.summary;
|
||||
const totalTokens = summary?.tokens ?? doneResults.reduce((a, r) => a + (r.tokens || 0), 0);
|
||||
const progressPct = total ? Math.round((doneCount / total) * 100) : 0;
|
||||
|
||||
return (
|
||||
<Shell>
|
||||
<Header title="Evaluations" subtitle="Run input → expected-output datasets against a workflow to score quality and catch regressions."
|
||||
action={<button className="btn btn-primary btn-sm" onClick={openCreate}><Icon name="plus" size={14} />New dataset</button>} />
|
||||
<div className="col gap2">
|
||||
{datasets.map((d) => (
|
||||
<div key={d.id} className="card" style={{ padding: 14 }}>
|
||||
<div className="row spread">
|
||||
<div className="row gap2"><Icon name="validate" size={15} /><span className="t-h3">{d.name}</span><span className="typechip">{d.score_mode}</span><span className="fg-2 t-caption">{d.n_items} cases</span>{!d.workflow_id && <span className="pill pill-warn">no workflow</span>}</div>
|
||||
<div className="row gap2">
|
||||
{d.last_pass_rate != null && <span className={`pill ${d.last_pass_rate >= 0.8 ? "pill-ok" : "pill-muted"}`}>{Math.round(d.last_pass_rate * 100)}% pass</span>}
|
||||
<button className="btn btn-secondary btn-sm" disabled={running === d.id || !d.workflow_id} title={!d.workflow_id ? "Bind a workflow to this dataset before running it" : undefined} onClick={() => run(d)}>{running === d.id ? <Icon name="refresh" size={13} className="spin" /> : <Icon name="play" size={13} />}{running === d.id ? (total ? `Running ${doneCount}/${total}` : "Running…") : "Run"}</button>
|
||||
<button className="iconbtn" title="Edit dataset" onClick={() => openEdit(d)}><Icon name="edit" size={15} /></button>
|
||||
<button className="iconbtn" title="Delete dataset" onClick={() => remove(d)}><Icon name="trash" size={15} /></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{datasets.length === 0 && <div className="fg-2 t-caption">No datasets yet.</div>}
|
||||
</div>
|
||||
{runError && (
|
||||
<div className="card" style={{ padding: 14, marginTop: 16, borderColor: "var(--err)", background: "var(--err-bg)" }}>
|
||||
<div className="t-h3" style={{ color: "var(--err)", marginBottom: 2 }}>Run failed</div>
|
||||
<div className="t-caption" style={{ color: "var(--fg-1)" }}>{runError}</div>
|
||||
</div>
|
||||
)}
|
||||
{rows && (
|
||||
<div className="card" style={{ padding: 16, marginTop: 16 }}>
|
||||
<div className="row spread" style={{ marginBottom: 4 }}>
|
||||
<div className="t-h3">
|
||||
{summary
|
||||
? <>Last run{ranDataset ? ` · ${ranDataset.name}` : ""} · {summary.passed}/{summary.total} passed ({Math.round(summary.pass_rate * 100)}%)</>
|
||||
: <>Running{ranDataset ? ` · ${ranDataset.name}` : ""} · {doneCount}/{total} done{doneCount > 0 ? ` · ${passSoFar} passed` : ""}</>}
|
||||
</div>
|
||||
<div className="row gap2">
|
||||
{totalTokens > 0 && <span className="chip chip-mono"><Icon name="bolt" size={12} />{fmtTokens(totalTokens)}</span>}
|
||||
{isRunningNow && <Icon name="refresh" size={14} className="spin" />}
|
||||
</div>
|
||||
</div>
|
||||
{/* Progress bar: fills as cases finish so the run is visibly in motion. */}
|
||||
<div style={{ height: 4, borderRadius: 2, background: "var(--bg-3)", overflow: "hidden", margin: "6px 0 10px" }}>
|
||||
<div style={{ height: "100%", width: `${progressPct}%`, background: summary ? "var(--ok)" : "var(--accent)", transition: "width .3s var(--ease)" }} />
|
||||
</div>
|
||||
<div className="fg-2 t-caption" style={{ marginBottom: 4 }}>Select a finished case to see its output{ranDataset?.score_mode === "judge" ? " and the judge's reason" : ""}.</div>
|
||||
{rows.map((row) => {
|
||||
const i = row.index;
|
||||
const r = liveResults[i];
|
||||
const done = !!r;
|
||||
const isOpen = expanded.has(i);
|
||||
return (
|
||||
<div key={i} style={{ borderTop: "1px solid var(--line)" }}>
|
||||
<button className="row spread" style={{ width: "100%", padding: "8px 0", background: "none", border: "none", cursor: done ? "pointer" : "default", textAlign: "left", color: "inherit", opacity: done ? 1 : 0.7 }}
|
||||
onClick={() => { if (done) setExpanded((prev) => { const n = new Set(prev); if (n.has(i)) n.delete(i); else n.add(i); return n; }); }}>
|
||||
<span className="row gap2" style={{ flex: 1, minWidth: 0 }}>
|
||||
<Icon name={isOpen ? "chevdown" : "chevright"} size={14} style={{ opacity: done ? 1 : 0.3 }} />
|
||||
<span className="t-caption truncate">{(r?.input ?? row.input) || "(empty input)"}</span>
|
||||
</span>
|
||||
<span className="row gap2" style={{ alignItems: "center", flexShrink: 0 }}>
|
||||
{done && r.latency_ms != null && <span className="fg-2 t-micro row gap1" style={{ alignItems: "center" }}><Icon name="clock" size={11} />{fmtLatency(r.latency_ms)}</span>}
|
||||
{done && r.tokens != null && r.tokens > 0 && <span className="fg-2 t-micro">{fmtTokens(r.tokens)}</span>}
|
||||
<CaseStatus result={r} />
|
||||
</span>
|
||||
</button>
|
||||
{isOpen && done && (
|
||||
<div className="col" style={{ gap: 10, padding: "2px 0 12px 22px" }}>
|
||||
{r.expected && <ResultField label="Expected" value={r.expected} />}
|
||||
<ResultField label="Output" value={r.answer || "(no output)"} />
|
||||
{r.reason && <ResultField label={ranDataset?.score_mode === "judge" ? "Judge reason" : "Reason"} value={r.reason} />}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
<Modal open={open} onClose={() => setOpen(false)} title={editing ? "Edit dataset" : "New dataset"} width={520}
|
||||
footer={<><button className="btn btn-ghost" onClick={() => setOpen(false)}>Cancel</button><button className="btn btn-primary" onClick={save} disabled={!canSave}>{editing ? "Save" : "Create"}</button></>}>
|
||||
<Field label="Name" required><input className="input" value={form.name} onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))} placeholder="Smoke tests" /></Field>
|
||||
<Field label="Workflow" required help={wfs.length === 0 ? "No workflows yet — create and publish one first." : "The workflow each case is run against."}><select className="select" value={form.workflow_id} onChange={(e) => setForm((f) => ({ ...f, workflow_id: e.target.value }))}><option value="">Select…</option>{wfs.map((w) => <option key={w.id} value={w.id}>{w.name}</option>)}</select></Field>
|
||||
<Field label="Scoring" help={SCORING_HELP[form.score_mode]}><select className="select" value={form.score_mode} onChange={(e) => setForm((f) => ({ ...f, score_mode: e.target.value }))}><option value="contains">contains</option><option value="exact">exact</option><option value="regex">regex</option><option value="judge">LLM judge</option></select></Field>
|
||||
<Field label="Cases (JSON)" help='Array of {"input": "...", "expected": "..."}'>
|
||||
<textarea className="textarea mono" rows={6} style={{ fontSize: 12 }} value={form.items} onChange={(e) => setForm((f) => ({ ...f, items: e.target.value }))} />
|
||||
{casesError && <div className="t-caption" style={{ color: "var(--err)", marginTop: 6 }}>{casesError}</div>}
|
||||
</Field>
|
||||
</Modal>
|
||||
</Shell>
|
||||
);
|
||||
}
|
||||
|
||||
/* ============ HANDOFF INBOX ============ */
|
||||
export function HandoffScreen({ project }: { project: any }) {
|
||||
const [items, setItems] = useState<Handoff[]>([]);
|
||||
const [reply, setReply] = useState<Record<string, string>>({});
|
||||
const [filter, setFilter] = useState<"open" | "closed">("open");
|
||||
const [openCount, setOpenCount] = useState(0);
|
||||
const reload = useCallback(async () => {
|
||||
if (!project?.id) return;
|
||||
try {
|
||||
const current = await api.listHandoffs(project.id, filter);
|
||||
setItems(current);
|
||||
if (filter === "open") setOpenCount(current.length);
|
||||
else setOpenCount((await api.listHandoffs(project.id, "open")).length);
|
||||
} catch { /* keep the last good queue visible during a transient refresh failure */ }
|
||||
}, [project?.id, filter]);
|
||||
useEffect(() => {
|
||||
reload();
|
||||
const timer = window.setInterval(reload, 10_000);
|
||||
return () => window.clearInterval(timer);
|
||||
}, [reload]);
|
||||
|
||||
async function send(h: Handoff) {
|
||||
const msg = (reply[h.id] || "").trim();
|
||||
if (!msg) return;
|
||||
await api.replyHandoff(project.id, h.id, msg);
|
||||
setReply((r) => ({ ...r, [h.id]: "" }));
|
||||
window.dispatchEvent(new CustomEvent("forge:counts-changed"));
|
||||
reload();
|
||||
}
|
||||
|
||||
return (
|
||||
<Shell>
|
||||
<Header
|
||||
title="Agent inbox"
|
||||
subtitle="Conversations escalated to a human. Replying resumes the paused run and delivers your message over its channel."
|
||||
action={(
|
||||
<div className="row gap2">
|
||||
<span className="badge" title="Open handoffs">{openCount} unread</span>
|
||||
{(["open", "closed"] as const).map((value) => (
|
||||
<button
|
||||
key={value}
|
||||
className={filter === value ? "btn btn-secondary btn-sm" : "btn btn-ghost btn-sm"}
|
||||
onClick={() => setFilter(value)}
|
||||
>
|
||||
{value === "open" ? "Open" : "Closed"}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
<div className="col gap2">
|
||||
{items.map((h) => (
|
||||
<div key={h.id} className="card" style={{ padding: 14 }}>
|
||||
<div className="row spread" style={{ marginBottom: 8 }}>
|
||||
<div className="row gap2"><Icon name="user" size={15} /><span className="t-h3">{h.customer || "Customer"}</span></div>
|
||||
<div className="row gap2">
|
||||
<span className="fg-2 t-caption">{h.reason}</span>
|
||||
{filter === "closed" && <span className="pill">{h.status}</span>}
|
||||
</div>
|
||||
</div>
|
||||
{h.customer_message && <div style={{ background: "var(--bg-3)", padding: "8px 11px", borderRadius: 10, fontSize: 13, marginBottom: 8 }}>{h.customer_message}</div>}
|
||||
{filter === "open" ? (
|
||||
<div className="row gap2">
|
||||
<input className="input" placeholder="Type your reply…" value={reply[h.id] || ""} onChange={(e) => setReply((r) => ({ ...r, [h.id]: e.target.value }))} onKeyDown={(e) => e.key === "Enter" && send(h)} style={{ flex: 1 }} />
|
||||
<button className="btn btn-primary btn-sm" onClick={() => send(h)} disabled={!(reply[h.id] || "").trim()}><Icon name="bolt" size={13} />Reply & resume</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="t-caption fg-2">Closed {h.at ? `· opened ${new Date(h.at).toLocaleString()}` : ""}</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{items.length === 0 && <div className="fg-2 t-caption">{filter === "open" ? "No open handoffs. Add a Human Handoff node to a workflow to route conversations here." : "No closed handoffs yet."}</div>}
|
||||
</div>
|
||||
</Shell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,418 @@
|
||||
"use client";
|
||||
/* Playground - chat that runs a real workflow over SSE with token-by-token streaming. */
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Icon } from "../icons";
|
||||
import { Tile } from "../primitives";
|
||||
import { api, openSSE, Workflow, ComponentT } from "@/lib/api";
|
||||
import { fmtUSD, buildNodeLabels, nodeLabel } from "@/lib/data";
|
||||
import { Markdown } from "../markdown";
|
||||
import { ComponentRenderer } from "../component-renderer";
|
||||
import { ReplyAccumulator, type Part, type ComponentInstance } from "@/lib/chat-parts";
|
||||
import Mustache from "mustache";
|
||||
|
||||
interface ChatMsg { role: "user" | "assistant"; content?: string; parts?: Part[] }
|
||||
interface Step { node: string }
|
||||
interface Activity { id: string; kind: string; name: string; done: boolean; error?: boolean }
|
||||
|
||||
export function PlaygroundScreen({ project }: { project: any }) {
|
||||
const [wf, setWf] = useState<Workflow | null>(null);
|
||||
const [wfs, setWfs] = useState<Workflow[]>([]);
|
||||
const [loadErr, setLoadErr] = useState<string | null>(null);
|
||||
const [input, setInput] = useState("");
|
||||
const [msgs, setMsgs] = useState<ChatMsg[]>([]);
|
||||
const [streaming, setStreaming] = useState("");
|
||||
const [steps, setSteps] = useState<Step[]>([]);
|
||||
const [activity, setActivity] = useState<Activity[]>([]);
|
||||
const [running, setRunning] = useState(false);
|
||||
const [meter, setMeter] = useState<{ tokens: number; cost: number } | null>(null);
|
||||
const [pendingInterrupt, setPendingInterrupt] = useState<{ runId: string; payload: any } | null>(null);
|
||||
const [resuming, setResuming] = useState(false);
|
||||
const [compDefs, setCompDefs] = useState<Record<string, ComponentT>>({});
|
||||
const [liveParts, setLiveParts] = useState<Part[]>([]); // in-flight assistant reply parts, rendered live (audit H3)
|
||||
// Resolve node ids (what the run stream emits) to the friendly names shown on the canvas, so the
|
||||
// Run steps read like the graph the operator built rather than raw ids.
|
||||
const nodeLabels = useMemo(() => buildNodeLabels(wf), [wf]);
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
// One backend thread per chat session: the checkpointer holds the conversation, so
|
||||
// each turn sends ONLY the new message (no full-transcript replay).
|
||||
const threadRef = useRef<string | null>(null);
|
||||
// Aborts the in-flight SSE stream when the user hits Stop.
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!project?.id) return;
|
||||
threadRef.current = null;
|
||||
setWf(null); setLoadErr(null); setMsgs([]); setSteps([]); setMeter(null);
|
||||
api.listComponents(project.id).then((cs) => setCompDefs(Object.fromEntries(cs.map((c) => [c.id, c])))).catch(() => {});
|
||||
api.listWorkflows(project.id)
|
||||
.then((ws) => {
|
||||
setWfs(ws);
|
||||
const active = ws.find((w) => w.status === "active") || ws[0] || null;
|
||||
setWf(active);
|
||||
if (!active) setLoadErr("No workflows in this project yet. Create one in Workflows, or ask the Forge Assistant to build one.");
|
||||
})
|
||||
.catch((e) => setLoadErr(String(e.message || e)));
|
||||
}, [project?.id]);
|
||||
|
||||
useEffect(() => { scrollRef.current?.scrollTo({ top: 1e9, behavior: "smooth" }); }, [msgs, streaming, steps, liveParts]);
|
||||
|
||||
async function send(textArg?: string) {
|
||||
const text = (typeof textArg === "string" ? textArg : input).trim();
|
||||
if (!text || !wf || running) return;
|
||||
if (typeof textArg !== "string") setInput("");
|
||||
setMsgs((m) => [...m, { role: "user", content: text }]);
|
||||
setStreaming(""); setSteps([]); setActivity([]); setMeter(null); setRunning(true); setLiveParts([]);
|
||||
let finalAnswer = "";
|
||||
// The reply is an ordered list of parts (text + components). Components are positioned by the
|
||||
// [[forge:component:ID]] markers the agent writes into its text - NOT by the order their
|
||||
// frames arrive - so a widget lands in its natural place instead of always at the top.
|
||||
const acc = new ReplyAccumulator();
|
||||
try {
|
||||
// The thread's checkpointer holds prior turns, so send only the new message when a
|
||||
// thread exists; the first turn establishes the thread. The run always acts as the logged-in
|
||||
// operator (the backend sets end_user from the session), so per-user auth providers resolve
|
||||
// this user's own connected credential — exactly like the Workflow test panel.
|
||||
const run = await api.createRun(
|
||||
project.id, wf.id,
|
||||
{ messages: [{ role: "user", content: text }] },
|
||||
threadRef.current || undefined,
|
||||
);
|
||||
threadRef.current = run.thread_id;
|
||||
let interrupted = false;
|
||||
const url = api.runStreamUrl(project.id, wf.id, run.id);
|
||||
const controller = new AbortController();
|
||||
abortRef.current = controller;
|
||||
await openSSE(url, (f) => {
|
||||
if (f.event === "messages" && f.data?.content) {
|
||||
acc.addText(f.data.content);
|
||||
setStreaming(acc.text);
|
||||
setLiveParts(acc.parts({ streaming: true }));
|
||||
} else if ((f.event === "node_start" || f.event === "updates") && f.data) {
|
||||
const node = f.event === "node_start" ? f.data.node : Object.keys(f.data || {})[0];
|
||||
if (node) setSteps((s) => (s.some((x) => x.node === node) ? s : [...s, { node }]));
|
||||
} else if (f.event === "activity" && f.data?.id) {
|
||||
const a = f.data;
|
||||
if (a.phase === "start") {
|
||||
setActivity((xs) => xs.some((x) => x.id === a.id) ? xs : [...xs, { id: a.id, kind: a.kind, name: a.name, done: false }]);
|
||||
} else if (a.phase === "end") {
|
||||
setActivity((xs) => xs.map((x) => (x.id === a.id ? { ...x, done: true, error: !!a.error } : x)));
|
||||
}
|
||||
} else if (f.event === "custom" && f.data?.channel === "component" && f.data?.payload) {
|
||||
acc.addComponent(f.data.payload as ComponentInstance);
|
||||
setLiveParts(acc.parts({ streaming: true }));
|
||||
} else if (f.event === "done") {
|
||||
finalAnswer = f.data?.answer || "";
|
||||
setMeter({ tokens: f.data?.total_tokens ?? 0, cost: f.data?.total_cost_usd ?? 0 });
|
||||
} else if (f.event === "interrupt") {
|
||||
interrupted = true;
|
||||
setPendingInterrupt({ runId: run.id, payload: f.data });
|
||||
} else if (f.event === "error") {
|
||||
finalAnswer = `⚠ ${f.data?.message || "run failed"}`;
|
||||
}
|
||||
}, { signal: controller.signal });
|
||||
if (interrupted) {
|
||||
// Preserve anything streamed before the pause (audit M4) - mirror the finalize commit.
|
||||
if (acc.hasComponents() || acc.text.trim()) {
|
||||
setMsgs((m) => [...m, acc.hasComponents()
|
||||
? { role: "assistant", parts: acc.parts() }
|
||||
: { role: "assistant", content: acc.text }]);
|
||||
}
|
||||
setStreaming(""); setRunning(false); setLiveParts([]);
|
||||
return; // approval card takes over
|
||||
}
|
||||
} catch (e: any) {
|
||||
// A user-initiated Stop aborts the fetch - commit whatever streamed, no error banner.
|
||||
if (e?.name !== "AbortError") finalAnswer = `⚠ ${e.message || e}`;
|
||||
} finally {
|
||||
abortRef.current = null;
|
||||
setStreaming("");
|
||||
setRunning(false);
|
||||
setLiveParts([]);
|
||||
}
|
||||
// resolveText reconciles the streamed buffer with the authoritative final answer / error
|
||||
// (covers non-LLM nodes that don't stream tokens) without dropping it (audit H2).
|
||||
const finalText = acc.resolveText(finalAnswer);
|
||||
if (acc.hasComponents()) {
|
||||
setMsgs((m) => [...m, { role: "assistant", parts: acc.parts({ finalText }) }]);
|
||||
} else {
|
||||
setMsgs((m) => [...m, { role: "assistant", content: finalText || "(no output)" }]);
|
||||
}
|
||||
}
|
||||
|
||||
/** Pull the human-facing prompt + decision options out of the interrupt payload.
|
||||
Handles both shapes: the human_input node ({prompt, allowed_decisions}) and
|
||||
HumanInTheLoopMiddleware (action requests; resume wants {decisions:[{type}]}). */
|
||||
function parseInterrupt(payload: any): { prompt: string; decisions: string[]; middleware: boolean } {
|
||||
const flat = (x: any): any[] => (Array.isArray(x) ? x.flatMap(flat) : [x]);
|
||||
const items = flat(payload).filter(Boolean);
|
||||
const values = items.map((i) => (i && typeof i === "object" && "value" in i ? i.value : i));
|
||||
for (const v of values) {
|
||||
if (v && typeof v === "object" && v.prompt) {
|
||||
return { prompt: String(v.prompt), decisions: v.allowed_decisions || ["approve", "reject"], middleware: false };
|
||||
}
|
||||
if (v && typeof v === "object" && (v.action_requests || v.action_request || v.action)) {
|
||||
const reqs = v.action_requests || [v.action_request || v];
|
||||
const desc = reqs.map((r: any) => r.description || `${r.action || r.name || "tool"}(${JSON.stringify(r.args || {}).slice(0, 80)})`).join("; ");
|
||||
return { prompt: `Approve tool call: ${desc}`, decisions: ["approve", "reject"], middleware: true };
|
||||
}
|
||||
}
|
||||
return { prompt: "This run paused for your approval.", decisions: ["approve", "reject"], middleware: false };
|
||||
}
|
||||
|
||||
async function resume(decision: string) {
|
||||
if (!pendingInterrupt || !wf || resuming) return;
|
||||
const { middleware } = parseInterrupt(pendingInterrupt.payload);
|
||||
setResuming(true);
|
||||
try {
|
||||
const value = middleware ? { decisions: [{ type: decision }] } : decision;
|
||||
const res = await api.resumeRun(project.id, wf.id, pendingInterrupt.runId, value);
|
||||
const msgsOut = res.messages || [];
|
||||
const last = [...msgsOut].reverse().find((m: any) => (m.type === "ai" || m.role === "assistant") && m.content);
|
||||
const content = last ? (typeof last.content === "string" ? last.content : JSON.stringify(last.content)) : (res.error || "(resumed)");
|
||||
setMsgs((m) => [...m, { role: "assistant", content: res.interrupted ? content + "\n⏸ paused again for another approval - check Traces." : content }]);
|
||||
} catch (e: any) {
|
||||
setMsgs((m) => [...m, { role: "assistant", content: `⚠ resume failed: ${e.message || e}` }]);
|
||||
} finally {
|
||||
setPendingInterrupt(null);
|
||||
setResuming(false);
|
||||
}
|
||||
}
|
||||
|
||||
// Abort the in-flight SSE run. The stream reader rejects with AbortError, which send()
|
||||
// treats as a graceful stop (partial output is committed, no error banner).
|
||||
function stop() {
|
||||
abortRef.current?.abort();
|
||||
}
|
||||
// Reset clears the UI AND starts a fresh conversation thread, so the next message has no
|
||||
// server-side history (nulling threadRef) - not just a visual clear.
|
||||
function reset() {
|
||||
threadRef.current = null;
|
||||
setMsgs([]); setSteps([]); setMeter(null); setStreaming(""); setLiveParts([]); setPendingInterrupt(null);
|
||||
}
|
||||
|
||||
function handleComponentAction(inst: ComponentInstance, action: string, fields: Record<string, string>) {
|
||||
const def = (inst.actions || []).find((a: any) => a.id === action) || {};
|
||||
let msg = (def as any).message || (def as any).label || action;
|
||||
try { msg = Mustache.render(String(msg), { props: inst.props || {}, fields, action }); } catch {}
|
||||
if (msg) send(msg);
|
||||
}
|
||||
|
||||
const samples = ["How do I reset my password?", "What can you help me with?"];
|
||||
|
||||
return (
|
||||
<div className="col" style={{ flex: 1, minHeight: 0 }}>
|
||||
{/* header */}
|
||||
<div className="row spread" style={{ padding: "12px 20px", borderBottom: "1px solid var(--line)", flex: "none" }}>
|
||||
<div className="row gap2">
|
||||
<div>
|
||||
<div className="t-display">Playground</div>
|
||||
{wfs.length > 1 ? (
|
||||
<select
|
||||
className="select" disabled={running}
|
||||
value={wf?.id || ""}
|
||||
style={{ marginTop: 2, height: 24, fontSize: 12, padding: "0 6px", maxWidth: 280 }}
|
||||
onChange={(e) => {
|
||||
const next = wfs.find((w) => w.id === e.target.value) || null;
|
||||
threadRef.current = null;
|
||||
setWf(next); setMsgs([]); setSteps([]); setMeter(null); setStreaming("");
|
||||
}}>
|
||||
{wfs.map((w) => (
|
||||
<option key={w.id} value={w.id}>{w.name}{w.status === "active" ? " · active" : " · draft"}</option>
|
||||
))}
|
||||
</select>
|
||||
) : (
|
||||
<div className="fg-2 t-caption mono">{wf ? wf.name : "loading…"}{running && " · running"}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="row gap2">
|
||||
{meter && (
|
||||
<span className="chip chip-mono"><Icon name="bolt" size={13} />{meter.tokens} tok · {fmtUSD(meter.cost)}</span>
|
||||
)}
|
||||
<span className="chip chip-mono"><Icon name="knowledge" size={12} />grounded</span>
|
||||
<button className="btn btn-ghost btn-sm" onClick={reset} disabled={running}>
|
||||
<Icon name="refresh" size={14} />Reset
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* alignItems:stretch - the global .row centers children, which stops the chat column
|
||||
from filling the height: it then sizes to content, overflows the viewport, and the
|
||||
scroll area never scrolls. Stretch restores the fixed-height column + inner scroll. */}
|
||||
<div className="row" style={{ flex: 1, minHeight: 0, alignItems: "stretch" }}>
|
||||
<div className="col grow" style={{ minWidth: 0, minHeight: 0, borderRight: "1px solid var(--line)" }}>
|
||||
<div ref={scrollRef} className="scroll-y" style={{ flex: 1, minHeight: 0, padding: "20px 0" }}>
|
||||
{/* minHeight:100% + justify-end pins a short conversation to the bottom (chat-style);
|
||||
once it outgrows the viewport it scrolls normally. */}
|
||||
<div style={{ maxWidth: 720, margin: "0 auto", padding: "0 24px", width: "100%", minHeight: "100%", display: "flex", flexDirection: "column", justifyContent: "flex-end" }}>
|
||||
{loadErr && <div className="card" style={{ padding: 14, color: "var(--err)", marginBottom: 12 }}>{loadErr}</div>}
|
||||
{msgs.length === 0 && !running && !loadErr && (
|
||||
<div className="col center" style={{ minHeight: 300, gap: 10, color: "var(--fg-2)", textAlign: "center", margin: "auto 0" }}>
|
||||
<Tile icon="sparkles" color="var(--accent)" size={44} glow />
|
||||
<div className="t-h2" style={{ color: "var(--fg-1)" }}>Run “{wf?.name || "your workflow"}” live</div>
|
||||
<div className="t-caption">Answers are grounded in this project’s knowledge base & Q&A - it streams token by token.</div>
|
||||
<div className="row gap2 wrap center" style={{ maxWidth: 460, marginTop: 6 }}>
|
||||
{samples.map((s) => (
|
||||
<button key={s} className="chip" style={{ cursor: "pointer" }} onClick={() => setInput(s)}>{s}</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="col gap4">
|
||||
{msgs.map((m, i) => (
|
||||
<MessageBlock key={i} role={m.role} content={m.content} parts={m.parts} compDefs={compDefs} onAction={handleComponentAction} />
|
||||
))}
|
||||
{(running || liveParts.length > 0) && (
|
||||
<MessageBlock role="assistant" parts={liveParts} streaming compDefs={compDefs} onAction={handleComponentAction} />
|
||||
)}
|
||||
{pendingInterrupt && (() => {
|
||||
const info = parseInterrupt(pendingInterrupt.payload);
|
||||
return (
|
||||
<div className="card fade-up" style={{ padding: 14, borderLeft: "3px solid var(--warn)" }}>
|
||||
<div className="row gap2" style={{ marginBottom: 6 }}>
|
||||
<Icon name="user" size={15} style={{ color: "var(--warn)" }} />
|
||||
<span className="t-h3">Approval required</span>
|
||||
</div>
|
||||
<div className="t-body-sm fg-1" style={{ marginBottom: 10, whiteSpace: "pre-wrap" }}>{info.prompt}</div>
|
||||
<div className="row gap2">
|
||||
{info.decisions.map((d) => (
|
||||
<button key={d}
|
||||
className={d === "approve" ? "btn btn-primary btn-sm" : "btn btn-secondary btn-sm"}
|
||||
disabled={resuming} onClick={() => resume(d)}>
|
||||
{resuming ? "…" : d}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* composer */}
|
||||
<div style={{ padding: "14px 24px", borderTop: "1px solid var(--line)", flex: "none" }}>
|
||||
<div style={{ maxWidth: 720, margin: "0 auto" }}>
|
||||
<div className="row gap2" style={{ background: "var(--bg-1)", border: "1px solid var(--line-strong)", borderRadius: 12, padding: "7px 7px 7px 14px", boxShadow: "var(--sh-1)" }}>
|
||||
<input value={input} onChange={(e) => setInput(e.target.value)} onKeyDown={(e) => e.key === "Enter" && send()}
|
||||
placeholder="Message the workflow…" disabled={!wf || running}
|
||||
style={{ flex: 1, minWidth: 0, border: "none", background: "none", outline: "none", fontSize: 14, color: "var(--fg-0)", fontFamily: "var(--font-ui)" }} />
|
||||
{running ? (
|
||||
<button className="btn btn-secondary" onClick={stop} title="Stop the run">
|
||||
<Icon name="stop" size={15} />Stop
|
||||
</button>
|
||||
) : (
|
||||
<button className="btn btn-primary" onClick={() => send()} disabled={!wf}>
|
||||
<Icon name="play" size={15} />Run
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="fg-2 t-caption" style={{ textAlign: "center", marginTop: 7 }}>Runs against the active workflow · interrupts surface for approval</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Steps column */}
|
||||
<div style={{ width: 280, flex: "none", background: "var(--bg-1)", minHeight: 0 }} className="scroll-y">
|
||||
<div className="t-micro" style={{ padding: "14px 16px 8px" }}>Run steps</div>
|
||||
<div className="col" style={{ padding: "0 12px 12px" }}>
|
||||
{steps.length === 0 && <div className="fg-2 t-caption" style={{ padding: "4px 8px" }}>Nodes light up as the graph executes.</div>}
|
||||
{steps.map((s, i) => (
|
||||
<div key={i} className="row gap2 fade-in" style={{ padding: "8px 8px", borderRadius: 7 }}>
|
||||
<div style={{ width: 18, height: 18, borderRadius: "50%", background: "var(--ok-bg)", color: "var(--ok)", display: "flex", alignItems: "center", justifyContent: "center", flex: "none" }}><Icon name="check" size={12} /></div>
|
||||
<div className="grow" style={{ minWidth: 0 }}>
|
||||
<div className="t-body-sm truncate" style={{ color: "var(--fg-1)" }}>{nodeLabel(s.node, nodeLabels)}</div>
|
||||
{nodeLabels[s.node] && nodeLabels[s.node].label !== s.node && (
|
||||
<div className="t-caption fg-2 mono truncate">{s.node}</div>
|
||||
)}
|
||||
</div>
|
||||
<span className="t-caption fg-2">{i + 1}</span>
|
||||
</div>
|
||||
))}
|
||||
{running && (
|
||||
<div className="row gap2" style={{ padding: "8px", color: "var(--accent)" }}>
|
||||
<div style={{ width: 8, height: 8, borderRadius: "50%", background: "var(--accent)", animation: "pulse 1s infinite" }} />
|
||||
<span className="t-caption">streaming…</span>
|
||||
</div>
|
||||
)}
|
||||
{/* Live agent activity: sub-agent dispatches (prominent) and tool calls (dimmed), so a
|
||||
deep_agent's routing is visible as it happens - not just after the run in Traces. */}
|
||||
{activity.length > 0 && (
|
||||
<>
|
||||
<div className="t-micro" style={{ padding: "12px 4px 6px" }}>Agent activity</div>
|
||||
{activity.map((a) => {
|
||||
const isSub = a.kind === "subagent";
|
||||
return (
|
||||
<div key={a.id} className="row gap2 fade-in" style={{ padding: "6px 8px", borderRadius: 7 }}>
|
||||
<div style={{ width: 16, height: 16, display: "flex", alignItems: "center", justifyContent: "center", flex: "none" }}>
|
||||
{a.error ? <Icon name="x" size={12} style={{ color: "var(--err)" }} />
|
||||
: a.done ? <Icon name="check" size={12} style={{ color: isSub ? "var(--accent)" : "var(--ok)" }} />
|
||||
: <div style={{ width: 8, height: 8, borderRadius: "50%", background: "var(--accent)", animation: "pulse 1s infinite" }} />}
|
||||
</div>
|
||||
<Icon name={isSub ? "layers" : "sliders"} size={13} style={{ color: isSub ? "var(--accent)" : "var(--fg-2)", flex: "none" }} />
|
||||
<span className="mono-sm grow truncate" style={{ color: isSub ? "var(--fg-0)" : "var(--fg-2)", fontWeight: isSub ? 600 : 400 }} title={a.name}>{a.name}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* One chat turn. User turns keep the colored bubble. An assistant turn is ONE flowing reply
|
||||
under a single avatar - bare markdown text and inline components in order, with NO bubble
|
||||
chrome - so a rendered component reads as part of the reply, not a detached card below it
|
||||
(audit Priority C). Missing component defs degrade to a visible notice (audit M5). */
|
||||
function MessageBlock({ role, content, streaming, parts, compDefs, onAction }: {
|
||||
role: "user" | "assistant";
|
||||
content?: string;
|
||||
streaming?: boolean;
|
||||
parts?: Part[];
|
||||
compDefs: Record<string, ComponentT>;
|
||||
onAction: (inst: ComponentInstance, action: string, fields: Record<string, string>) => void;
|
||||
}) {
|
||||
const user = role === "user";
|
||||
if (user) {
|
||||
return (
|
||||
<div className="row" style={{ gap: 9, alignItems: "flex-start", flexDirection: "row-reverse" }}>
|
||||
<Tile icon="user" color="var(--fg-2)" size={28} />
|
||||
<div style={{ maxWidth: 560, padding: "10px 13px", borderRadius: 12, borderTopRightRadius: 3, fontSize: 14, lineHeight: "21px", whiteSpace: "pre-wrap", overflowWrap: "anywhere", wordBreak: "break-word", background: "var(--accent)", color: "var(--fg-on-accent)" }}>
|
||||
{content}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const renderComp = (inst: ComponentInstance, key: number | string) => {
|
||||
const def = compDefs[inst.component_id];
|
||||
if (!def) {
|
||||
return (
|
||||
<div key={key} className="card" style={{ padding: "8px 11px", fontSize: 12.5, color: "var(--fg-2)" }}>
|
||||
Component “{inst.name || inst.component_id}” is unavailable.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<ComponentRenderer key={key} def={{ id: def.id, name: def.name, html: def.html, css: def.css, actions: inst.actions || def.actions }} props={inst.props || {}} onAction={(a, f) => onAction(inst, a, f)} />
|
||||
);
|
||||
};
|
||||
const list: Part[] = parts && parts.length ? parts : (content && content.trim() ? [{ kind: "text", text: content }] : []);
|
||||
let lastText = -1;
|
||||
for (let k = list.length - 1; k >= 0; k--) { if (list[k].kind === "text") { lastText = k; break; } }
|
||||
return (
|
||||
<div className="row" style={{ gap: 9, alignItems: "flex-start" }}>
|
||||
<Tile icon="sparkles" color="var(--accent)" size={28} />
|
||||
<div className="col gap2" style={{ minWidth: 0, maxWidth: 620, flex: 1 }}>
|
||||
{list.map((p, j) => (p.kind === "text" ? (
|
||||
<div key={j} style={{ fontSize: 14, lineHeight: "21px", color: "var(--fg-0)", overflowWrap: "anywhere" }}>
|
||||
<Markdown>{p.text}</Markdown>
|
||||
{streaming && j === lastText && <span style={{ display: "inline-block", width: 7, height: 14, background: "var(--accent)", verticalAlign: "-2px", animation: "blink 1s steps(1) infinite" }} />}
|
||||
</div>
|
||||
) : renderComp(p.inst, j)))}
|
||||
{streaming && lastText === -1 && <span className="fg-2" style={{ fontSize: 14 }}>…</span>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,853 @@
|
||||
"use client";
|
||||
/* Settings: project config, split into a secondary-nav of focused sections
|
||||
(General · Members · API Keys · Model Pricing · Budgets · Knowledge · Versioning ·
|
||||
Observability · Advanced). Config-backed sections share one Save; Members, API Keys,
|
||||
Model Pricing and Secrets manage their own persistence. */
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { Icon } from "../icons";
|
||||
import { EmptyState, Field, Modal, Segmented, Tabs, Toggle } from "../primitives";
|
||||
import { api, clearTokens, InviteResult, MeResult, ProjectVersion, Secret, TeamMember } from "@/lib/api";
|
||||
import { useEmbeddingModels, useModels, useRerankerModels } from "@/lib/models";
|
||||
|
||||
const ROLES = ["owner", "admin", "editor", "viewer", "connector"];
|
||||
|
||||
// Embedder + reranker models (and which one is default) come from the backend catalog now
|
||||
// (useEmbeddingModels / useRerankerModels) - the frontend hardcodes no model lists.
|
||||
const DEFAULT_CHILD_CHUNK_SIZE = 300;
|
||||
|
||||
type SectionId =
|
||||
| "general" | "members" | "apikeys" | "pricing" | "budgets"
|
||||
| "guardrails" | "knowledge" | "versioning" | "observability" | "advanced" | "history";
|
||||
|
||||
const SECTIONS: { id: SectionId; label: string; icon: string; savesConfig?: boolean }[] = [
|
||||
{ id: "general", label: "General", icon: "sliders", savesConfig: true },
|
||||
{ id: "members", label: "Members & Roles", icon: "user" },
|
||||
{ id: "apikeys", label: "API Keys", icon: "secret" },
|
||||
{ id: "pricing", label: "Model Pricing", icon: "coins" },
|
||||
{ id: "budgets", label: "Budgets & Quotas", icon: "bolt", savesConfig: true },
|
||||
{ id: "guardrails", label: "Guardrails & Egress", icon: "shield-check", savesConfig: true },
|
||||
{ id: "knowledge", label: "Knowledge & Embeddings", icon: "knowledge", savesConfig: true },
|
||||
{ id: "versioning", label: "Versioning", icon: "clock", savesConfig: true },
|
||||
{ id: "observability", label: "Observability & Retention", icon: "traces", savesConfig: true },
|
||||
{ id: "advanced", label: "Advanced", icon: "settings", savesConfig: true },
|
||||
{ id: "history", label: "History", icon: "clock" },
|
||||
];
|
||||
|
||||
// Which settings section each project-config field belongs to, so History can group changes
|
||||
// under the same tab names as the nav. Top-level snapshot fields + config.* keys. Anything not
|
||||
// listed falls through to "advanced". Members/API-key values are intentionally NOT diffed here
|
||||
// (Members isn't config-backed; secrets are masked below).
|
||||
const FIELD_SECTION: Record<string, SectionId> = {
|
||||
name: "general", description: "general", slug: "general", status: "general", default_model: "general",
|
||||
budgets: "budgets",
|
||||
default_middleware: "guardrails", egress: "guardrails",
|
||||
rag_defaults: "knowledge",
|
||||
version_history_limit: "versioning", versioning: "versioning",
|
||||
observability: "observability",
|
||||
scheduler: "advanced",
|
||||
model_pricing: "pricing",
|
||||
provider_credentials: "apikeys",
|
||||
};
|
||||
// The config-backed sections that get a History tab (Members isn't config-backed → skipped).
|
||||
const HISTORY_TABS: SectionId[] = ["general", "budgets", "guardrails", "knowledge", "versioning", "observability", "apikeys", "pricing", "advanced"];
|
||||
// Field paths whose values must never be shown in a diff (secrets / credentials).
|
||||
const MASK_RE = /credential|secret|token|api[_-]?key|password/i;
|
||||
|
||||
export function SettingsScreen({ project, onDeleteProject }: { project: any; onDeleteProject?: (project: { id: string; name: string }) => Promise<void> | void }) {
|
||||
const [section, setSection] = useState<SectionId>("general");
|
||||
const MODELS = useModels();
|
||||
const embeddingModels = useEmbeddingModels();
|
||||
const rerankerModels = useRerankerModels();
|
||||
const [config, setConfig] = useState<Record<string, any>>({});
|
||||
const [meta, setMeta] = useState<{ name: string; description: string }>({ name: "", description: "" });
|
||||
const [secrets, setSecrets] = useState<Secret[]>([]);
|
||||
const [save, setSave] = useState<"idle" | "saving" | "saved">("idle");
|
||||
const [open, setOpen] = useState(false);
|
||||
const [secForm, setSecForm] = useState({ name: "", value: "", kind: "api_key" });
|
||||
const [pkeys, setPkeys] = useState<Record<string, string>>({});
|
||||
const [keySave, setKeySave] = useState<"idle" | "saving" | "saved">("idle");
|
||||
const [confirmDelete, setConfirmDelete] = useState(false);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
const [tenantId, setTenantId] = useState("");
|
||||
const [copiedWs, setCopiedWs] = useState(false);
|
||||
const [copiedPid, setCopiedPid] = useState(false);
|
||||
|
||||
const reloadSecrets = useCallback(() => { if (project?.id) api.listSecrets(project.id).then(setSecrets).catch(() => {}); }, [project?.id]);
|
||||
useEffect(() => {
|
||||
if (!project?.id) return;
|
||||
api.getProject(project.id).then((p) => {
|
||||
setConfig(p.config || {});
|
||||
setMeta({ name: p.name || "", description: p.description || "" });
|
||||
}).catch(() => {});
|
||||
reloadSecrets();
|
||||
}, [project?.id, reloadSecrets]);
|
||||
// The workspace (tenant) id — surfaced in General so a user whose email spans multiple
|
||||
// workspaces can supply it at MCP OAuth login. Account-level, so it's independent of project.
|
||||
useEffect(() => { api.me().then((m) => setTenantId(m.tenant_id || "")).catch(() => {}); }, []);
|
||||
|
||||
const setCfg = (patch: Record<string, any>) => setConfig((c) => ({ ...c, ...patch }));
|
||||
const features = config.features || {};
|
||||
const budgets = config.budgets || {};
|
||||
const rag = config.rag_defaults || {};
|
||||
const versioning = config.versioning || {};
|
||||
const observability = config.observability || {};
|
||||
const scheduler = config.scheduler || {};
|
||||
const defaultEmbedding = embeddingModels.find((m) => m.default)?.id ?? "";
|
||||
const defaultReranker = rerankerModels.find((m) => m.default)?.id ?? "";
|
||||
const embeddingModel = rag.embedding_model || defaultEmbedding;
|
||||
const setRag = (patch: Record<string, any>) => setCfg({ rag_defaults: { ...rag, ...patch } });
|
||||
|
||||
async function persist() {
|
||||
setSave("saving");
|
||||
try {
|
||||
await api.updateProject(project.id, { name: meta.name || undefined, description: meta.description, config });
|
||||
setSave("saved"); setTimeout(() => setSave("idle"), 1400);
|
||||
} catch { setSave("idle"); }
|
||||
}
|
||||
async function addSecret() {
|
||||
if (!secForm.name.trim()) return;
|
||||
await api.createSecret(project.id, { name: secForm.name, value: secForm.value, kind: secForm.kind });
|
||||
setOpen(false); setSecForm({ name: "", value: "", kind: "api_key" }); reloadSecrets();
|
||||
}
|
||||
async function removeSecret(name: string) {
|
||||
let used: { type: string; label: string }[] = [];
|
||||
try { used = (await api.secretUsage(project.id, name)).references; } catch { /* fall back to a plain confirm */ }
|
||||
const detail = used.length
|
||||
? `\n\nIn use by ${used.length}:\n` + used.map((r) => `• ${r.label} - ${r.type.replace(/_/g, " ")}`).join("\n") + `\n\nDeleting will break these.`
|
||||
: "";
|
||||
if (!window.confirm(`Delete secret "${name}"?${detail}`)) return;
|
||||
await api.deleteSecret(project.id, name, true); reloadSecrets();
|
||||
}
|
||||
async function saveKeys() {
|
||||
setKeySave("saving");
|
||||
const pc = { ...(config.provider_credentials || {}) };
|
||||
for (const [prov, val] of Object.entries(pkeys)) {
|
||||
if (!val.trim()) continue;
|
||||
const name = `${prov}_key`;
|
||||
await api.createSecret(project.id, { name, value: val, kind: "api_key" });
|
||||
pc[prov] = `secret://proj/${name}`;
|
||||
}
|
||||
const newConfig = { ...config, provider_credentials: pc };
|
||||
setConfig(newConfig);
|
||||
await api.updateProject(project.id, { config: newConfig });
|
||||
setPkeys({}); reloadSecrets();
|
||||
setKeySave("saved"); setTimeout(() => setKeySave("idle"), 1400);
|
||||
}
|
||||
|
||||
const PROVIDERS: [string, string][] = [["openai", "OpenAI"], ["anthropic", "Anthropic"], ["google_genai", "Google"]];
|
||||
const pcreds = config.provider_credentials || {};
|
||||
const activeMeta = SECTIONS.find((s) => s.id === section)!;
|
||||
|
||||
return (
|
||||
<div className="col" style={{ flex: 1, minHeight: 0 }}>
|
||||
<div className="row" style={{ flex: 1, minHeight: 0, alignItems: "stretch" }}>
|
||||
{/* secondary nav */}
|
||||
<nav className="scroll-y" style={{ width: 224, flex: "none", borderRight: "1px solid var(--line)", background: "var(--bg-1)", padding: 10 }}>
|
||||
<div className="t-micro" style={{ padding: "6px 8px 8px" }}>Settings</div>
|
||||
{SECTIONS.map((s) => {
|
||||
const on = section === s.id;
|
||||
return (
|
||||
<button key={s.id} onClick={() => setSection(s.id)} className={"sidenav-item" + (on ? " active" : "")}
|
||||
style={{ display: "flex", alignItems: "center", gap: 10, width: "100%", height: 34, padding: "0 10px", marginBottom: 1, borderRadius: 7, border: "none", cursor: "pointer", textAlign: "left", color: on ? "var(--accent)" : "var(--fg-1)", fontSize: 13, fontWeight: on ? 600 : 500, fontFamily: "var(--font-ui)" }}>
|
||||
<Icon name={s.icon} size={16} style={{ flex: "none" }} />
|
||||
<span className="grow truncate">{s.label}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
|
||||
{/* content */}
|
||||
<div className="scroll-y grow" style={{ minWidth: 0 }}>
|
||||
<div className="fade-up" style={{ maxWidth: 960, margin: "0 auto", padding: "24px 28px" }}>
|
||||
<div className="row spread" style={{ marginBottom: 18 }}>
|
||||
<div className="t-display">{activeMeta.label}</div>
|
||||
{activeMeta.savesConfig && (
|
||||
<button className="btn btn-primary btn-sm" onClick={persist} disabled={save === "saving"}>
|
||||
<Icon name={save === "saved" ? "check" : "save"} size={14} />{save === "saving" ? "Saving…" : save === "saved" ? "Saved" : "Save"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{section === "general" && (
|
||||
<>
|
||||
<Card title="Project">
|
||||
<Field label="Name"><input className="input" value={meta.name} onChange={(e) => setMeta((m) => ({ ...m, name: e.target.value }))} placeholder="Project name" /></Field>
|
||||
<Field label="Description" help="Shown on the dashboard and in the project header."><textarea className="textarea" rows={2} value={meta.description} onChange={(e) => setMeta((m) => ({ ...m, description: e.target.value }))} /></Field>
|
||||
<Field label="Project ID" help="This project's identifier — use it in the Run API path (/v1/projects/<id>/run) and integration config (e.g. forge.projectId). This is NOT the Workspace ID below.">
|
||||
<div className="row gap2">
|
||||
<input className="input mono" readOnly value={project.id} placeholder="…" onFocus={(e) => e.currentTarget.select()} style={{ flex: 1 }} />
|
||||
<button className="btn btn-secondary btn-sm" disabled={!project?.id} onClick={() => { if (project?.id) { navigator.clipboard?.writeText(project.id); setCopiedPid(true); setTimeout(() => setCopiedPid(false), 1400); } }}>
|
||||
<Icon name={copiedPid ? "check" : "copy"} size={13} />{copiedPid ? "Copied" : "Copy"}
|
||||
</button>
|
||||
</div>
|
||||
</Field>
|
||||
</Card>
|
||||
<Card title="Default model">
|
||||
<Field label="Default model" help="Used by new agents and single model-call nodes unless they override it.">
|
||||
<select className="select" value={config.default_model || ""} onChange={(e) => setCfg({ default_model: e.target.value })}>
|
||||
<option value="">Select a model…</option>
|
||||
{MODELS.map((m) => <option key={m.id} value={m.id}>{m.name} · {m.provider}</option>)}
|
||||
</select>
|
||||
</Field>
|
||||
</Card>
|
||||
<Card title="Workspace">
|
||||
<Field label="Workspace ID" help="Your workspace (tenant) identifier — account-level, NOT the project ID. You normally don't need it, but if you sign in over MCP OAuth and your email belongs to more than one workspace, paste this into the login screen's “Workspace id” field.">
|
||||
<div className="row gap2">
|
||||
<input className="input mono" readOnly value={tenantId} placeholder="…" onFocus={(e) => e.currentTarget.select()} style={{ flex: 1 }} />
|
||||
<button className="btn btn-secondary btn-sm" disabled={!tenantId} onClick={() => { if (tenantId) { navigator.clipboard?.writeText(tenantId); setCopiedWs(true); setTimeout(() => setCopiedWs(false), 1400); } }}>
|
||||
<Icon name={copiedWs ? "check" : "copy"} size={13} />{copiedWs ? "Copied" : "Copy"}
|
||||
</button>
|
||||
</div>
|
||||
</Field>
|
||||
</Card>
|
||||
</>
|
||||
)}
|
||||
|
||||
{section === "members" && <TeamCard />}
|
||||
|
||||
{section === "apikeys" && (
|
||||
<>
|
||||
<Card title="Model providers" action={<button className="btn btn-primary btn-sm" onClick={saveKeys} disabled={keySave === "saving"}><Icon name={keySave === "saved" ? "check" : "save"} size={14} />{keySave === "saving" ? "Saving…" : keySave === "saved" ? "Saved" : "Save keys"}</button>}>
|
||||
<div className="field-help" style={{ marginTop: 0, marginBottom: 6 }}>Keys are encrypted (Fernet), bound to this project's models, and never returned. They fall back to the server's env var if unset.</div>
|
||||
{PROVIDERS.map(([prov, label]) => {
|
||||
const configured = !!pcreds[prov];
|
||||
return (
|
||||
<div key={prov} className="row gap2" style={{ padding: "7px 0" }}>
|
||||
<div style={{ width: 120, flex: "none" }} className="row gap2">
|
||||
<Icon name="n_llm" size={15} style={{ color: configured ? "var(--ok)" : "var(--fg-2)" }} />
|
||||
<span className="t-body-sm" style={{ fontWeight: 600 }}>{label}</span>
|
||||
</div>
|
||||
<input className="input mono" type="password" style={{ flex: 1 }}
|
||||
placeholder={configured ? "•••• configured - re-enter to replace" : "sk-…"}
|
||||
value={pkeys[prov] || ""} onChange={(e) => setPkeys((k) => ({ ...k, [prov]: e.target.value }))} />
|
||||
{configured && <span className="pill pill-ok" style={{ height: 18 }}>set</span>}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</Card>
|
||||
<Card title="Secrets" action={<button className="btn btn-secondary btn-sm" onClick={() => setOpen(true)}><Icon name="plus" size={14} />Add secret</button>}>
|
||||
<div className="field-help" style={{ marginTop: 0, marginBottom: 8 }}>Write-only - values are encrypted (Fernet) and never returned. Reference as <span className="mono-sm">secret://proj/<name></span>.</div>
|
||||
{secrets.map((s) => (
|
||||
<div key={s.id} className="row spread" style={{ padding: "8px 0", borderTop: "1px solid var(--line)" }}>
|
||||
<div className="row gap2"><Icon name="secret" size={15} style={{ color: "var(--fg-2)" }} /><span className="mono-sm">{s.name}</span><span className="typechip">{s.kind}</span></div>
|
||||
<div className="row gap2"><span className="mono-sm fg-2">••••••</span><span className="t-caption fg-2">v{s.version}</span><span className="iconbtn" role="button" title="Delete secret" onClick={() => removeSecret(s.name)}><Icon name="trash" size={13} /></span></div>
|
||||
</div>
|
||||
))}
|
||||
{secrets.length === 0 && <div className="fg-2 t-caption">No secrets yet.</div>}
|
||||
</Card>
|
||||
</>
|
||||
)}
|
||||
|
||||
{section === "pricing" && <PricingCard />}
|
||||
|
||||
{section === "budgets" && (
|
||||
<Card title="Budgets & quotas">
|
||||
<div className="field-help" style={{ marginTop: 0, marginBottom: 10 }}>Hard limits on model spend. A run is stopped if it would exceed the per-run cap; the monthly cap gates new runs once reached.</div>
|
||||
<div className="row gap3 wrap">
|
||||
<Field label="Max $ / run"><input className="input mono" type="number" min={0} step={0.01} value={budgets.max_usd_per_run ?? ""} onChange={(e) => setCfg({ budgets: { ...budgets, max_usd_per_run: parseFloat(e.target.value) || undefined } })} /></Field>
|
||||
<Field label="Monthly $ cap"><input className="input mono" type="number" min={0} step={1} value={budgets.monthly_usd_cap ?? ""} onChange={(e) => setCfg({ budgets: { ...budgets, monthly_usd_cap: parseFloat(e.target.value) || undefined } })} /></Field>
|
||||
</div>
|
||||
<Field label="Max tokens / run" help="Optional cap on total tokens for a single run."><input className="input mono" type="number" min={0} step={1000} value={budgets.max_tokens_per_run ?? ""} onChange={(e) => setCfg({ budgets: { ...budgets, max_tokens_per_run: parseInt(e.target.value, 10) || undefined } })} /></Field>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{section === "guardrails" && <GuardrailsCard config={config} setCfg={setCfg} />}
|
||||
|
||||
{section === "knowledge" && (
|
||||
<Card title="Knowledge & embeddings">
|
||||
<Field label="Embedding model" help="Used to embed knowledge sources and search queries. Applies to the whole project - you can't mix embedders across files. Changing it changes the vector dimension, so re-embed existing sources afterward (the Knowledge tab flags mismatches).">
|
||||
<select className="select" value={embeddingModel} onChange={(e) => setRag({ embedding_model: e.target.value })}>
|
||||
{Array.from(new Set(embeddingModels.map((m) => m.provider))).map((prov) => {
|
||||
const items = embeddingModels.filter((m) => m.provider === prov);
|
||||
const label = items.some((m) => m.billed)
|
||||
? `${prov} · billed per token (ingest + every query)`
|
||||
: `${prov} · open-source · free (offline)`;
|
||||
return (
|
||||
<optgroup key={prov} label={label}>
|
||||
{items.map((m) => (
|
||||
<option key={m.id} value={m.id}>{m.name} · {m.billed ? "billed" : "local, free"} ({m.dim}-dim)</option>
|
||||
))}
|
||||
</optgroup>
|
||||
);
|
||||
})}
|
||||
{embeddingModel && !embeddingModels.some((m) => m.id === embeddingModel) && <option value={embeddingModel}>{embeddingModel}</option>}
|
||||
</select>
|
||||
</Field>
|
||||
{embeddingModel.startsWith("openai:") && (
|
||||
<div className="field-help" style={{ marginTop: 0 }}>
|
||||
{pcreds.openai
|
||||
? "OpenAI key configured under API Keys. Embeddings are billed per token at ingest and on every search."
|
||||
: "⚠ No OpenAI key set — add one under API Keys, or embeddings fall back to the local model."}
|
||||
</div>
|
||||
)}
|
||||
<Field label="Retrieval mode" help="How chunks are matched vs. handed to the agent. Chunk: search and return the same chunks. Parent/child: embed small child chunks for precise matching but feed the agent the larger parent passage for context. Changing this requires re-ingesting existing sources.">
|
||||
<Segmented
|
||||
options={[{ value: "chunk", label: "Chunk" }, { value: "parent_child", label: "Parent / child" }]}
|
||||
value={rag.retrieval_mode || "chunk"}
|
||||
onChange={(v) => setRag({ retrieval_mode: v })}
|
||||
/>
|
||||
</Field>
|
||||
{rag.retrieval_mode === "parent_child" && (
|
||||
<Field label="Child chunk size" help="Size (chars) of the small child chunks that get embedded in parent/child mode. The parent window uses the chunk size set per source. Smaller children = more precise matches.">
|
||||
<input className="input mono" type="number" placeholder={String(DEFAULT_CHILD_CHUNK_SIZE)}
|
||||
value={rag.child_chunk_size ?? ""}
|
||||
onChange={(e) => setRag({ child_chunk_size: parseInt(e.target.value, 10) || undefined })} />
|
||||
</Field>
|
||||
)}
|
||||
<Field label="Reranker model" help="Local cross-encoder used when a retrieval node (or the search debugger) has rerank on. Runs offline on CPU, no API cost. Ignored unless rerank is enabled.">
|
||||
<select className="select" value={rag.reranker_model || defaultReranker} onChange={(e) => setRag({ reranker_model: e.target.value })}>
|
||||
{rerankerModels.map((m) => <option key={m.id} value={m.id}>{m.name} · {m.note}{m.default ? " (default)" : ""}</option>)}
|
||||
{rag.reranker_model && !rerankerModels.some((m) => m.id === rag.reranker_model) && <option value={rag.reranker_model}>{rag.reranker_model}</option>}
|
||||
</select>
|
||||
</Field>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{section === "versioning" && (
|
||||
<Card title="Version history">
|
||||
<div className="field-help" style={{ marginTop: 0, marginBottom: 10 }}>Every save/publish of a workflow, agent, tool, component, or auth provider captures a version you can inspect and restore from the editor's History panel.</div>
|
||||
<Field label="Versions kept per entity" help="Older versions beyond this count are pruned. Leave blank to keep all.">
|
||||
<input className="input mono" type="number" min={1} step={1} style={{ width: 140 }} placeholder="unlimited"
|
||||
value={config.version_history_limit ?? ""}
|
||||
onChange={(e) => setCfg({ version_history_limit: parseInt(e.target.value, 10) || undefined })} />
|
||||
</Field>
|
||||
<label className="row spread" style={{ padding: "8px 0" }}>
|
||||
<div><div className="t-body-sm" style={{ fontWeight: 600 }}>Snapshot on publish</div><div className="field-help" style={{ marginTop: 0 }}>Capture a labeled version each time a workflow is published.</div></div>
|
||||
<Toggle on={versioning.snapshot_on_publish !== false} onChange={(v) => setCfg({ versioning: { ...versioning, snapshot_on_publish: v } })} />
|
||||
</label>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{section === "observability" && (
|
||||
<>
|
||||
<Card title="Trace redaction">
|
||||
<label className="row spread" style={{ padding: "8px 0" }}>
|
||||
<div><div className="t-body-sm" style={{ fontWeight: 600 }}>Redact PII in traces</div><div className="field-help" style={{ marginTop: 0 }}>Mask emails, phone numbers, and card-like values in stored span inputs/outputs.</div></div>
|
||||
<Toggle on={!!observability.redact_pii} onChange={(v) => setCfg({ observability: { ...observability, redact_pii: v } })} />
|
||||
</label>
|
||||
<label className="row spread" style={{ padding: "8px 0" }}>
|
||||
<div><div className="t-body-sm" style={{ fontWeight: 600 }}>Store message bodies</div><div className="field-help" style={{ marginTop: 0 }}>Persist full user/assistant text on traces. Turn off to keep only metrics (tokens, latency, cost).</div></div>
|
||||
<Toggle on={observability.store_message_bodies !== false} onChange={(v) => setCfg({ observability: { ...observability, store_message_bodies: v } })} />
|
||||
</label>
|
||||
</Card>
|
||||
<Card title="Retention">
|
||||
<Field label="Trace retention (days)" help="Traces and conversations older than this are eligible for purge. Leave blank to keep indefinitely.">
|
||||
<input className="input mono" type="number" min={1} step={1} style={{ width: 140 }} placeholder="keep all"
|
||||
value={observability.retention_days ?? ""}
|
||||
onChange={(e) => setCfg({ observability: { ...observability, retention_days: parseInt(e.target.value, 10) || undefined } })} />
|
||||
</Field>
|
||||
<label className="row spread" style={{ padding: "8px 0" }}>
|
||||
<div><div className="t-body-sm" style={{ fontWeight: 600 }}>Scheduled cleanup</div><div className="field-help" style={{ marginTop: 0 }}>Run a periodic job to purge data past the retention window.</div></div>
|
||||
<Toggle on={!!scheduler.cleanup_enabled} onChange={(v) => setCfg({ scheduler: { ...scheduler, cleanup_enabled: v } })} />
|
||||
</label>
|
||||
</Card>
|
||||
</>
|
||||
)}
|
||||
|
||||
{section === "advanced" && (
|
||||
<>
|
||||
<Card title="Feature flags">
|
||||
{[["code_nodes", "Code nodes", "Allow sandboxed code execution"], ["remote_sandbox", "Remote sandbox", "Use E2B/Modal/Daytona for code"], ["advanced_scripts", "Advanced scripts", "RestrictedPython custom auth scripts"]].map(([k, label, desc]) => (
|
||||
<label key={k} className="row spread" style={{ padding: "8px 0" }}>
|
||||
<div><div className="t-body-sm" style={{ fontWeight: 600 }}>{label}</div><div className="field-help" style={{ marginTop: 0 }}>{desc}</div></div>
|
||||
<Toggle on={!!features[k]} onChange={(v) => setCfg({ features: { ...features, [k]: v } })} />
|
||||
</label>
|
||||
))}
|
||||
</Card>
|
||||
{onDeleteProject && project?.id && (
|
||||
<div className="card" style={{ padding: 18, marginBottom: 16, borderColor: "var(--err)" }}>
|
||||
<div className="row spread" style={{ marginBottom: 12 }}><div className="t-h2" style={{ color: "var(--err)" }}>Danger zone</div></div>
|
||||
<div className="field-help" style={{ marginTop: 0, marginBottom: 12 }}>Deleting this project removes its workflows, agents, tools, auth providers, knowledge, secrets, runs, and traces. This cannot be undone.</div>
|
||||
{!confirmDelete ? (
|
||||
<button className="btn btn-danger btn-sm" onClick={() => setConfirmDelete(true)}><Icon name="trash" size={14} />Delete project</button>
|
||||
) : (
|
||||
<div className="row gap2 wrap" style={{ alignItems: "center" }}>
|
||||
<span className="t-body-sm">Permanently delete <b>{project.name}</b>?</span>
|
||||
<button className="btn btn-danger btn-sm" disabled={deleting}
|
||||
onClick={async () => {
|
||||
setDeleting(true);
|
||||
try { await onDeleteProject({ id: project.id, name: project.name }); }
|
||||
finally { setDeleting(false); setConfirmDelete(false); }
|
||||
}}>
|
||||
<Icon name="trash" size={14} />{deleting ? "Deleting…" : "Confirm delete"}
|
||||
</button>
|
||||
<button className="btn btn-ghost btn-sm" onClick={() => setConfirmDelete(false)} disabled={deleting}>Cancel</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{section === "history" && <SettingsHistory project={project} />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Modal open={open} onClose={() => setOpen(false)} title="Add secret" width={460}
|
||||
footer={<><button className="btn btn-ghost" onClick={() => setOpen(false)}>Cancel</button><button className="btn btn-primary" onClick={addSecret}>Save secret</button></>}>
|
||||
<Field label="Name"><input className="input mono" value={secForm.name} onChange={(e) => setSecForm((f) => ({ ...f, name: e.target.value }))} placeholder="openai_key" /></Field>
|
||||
<Field label="Value" help="Encrypted at rest; never shown again."><input className="input mono" type="password" value={secForm.value} onChange={(e) => setSecForm((f) => ({ ...f, value: e.target.value }))} /></Field>
|
||||
<Field label="Kind"><input className="input" value={secForm.kind} onChange={(e) => setSecForm((f) => ({ ...f, kind: e.target.value }))} /></Field>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* Settings > History: read-only per-section change log. Diffs consecutive project snapshots
|
||||
(captured on every settings save) and buckets each changed field under the matching settings
|
||||
tab. No restore - it's a log. Secret values are masked. */
|
||||
function fmtVal(v: any): string {
|
||||
if (v === undefined || v === null || v === "") return "—";
|
||||
if (typeof v === "object") { try { return JSON.stringify(v); } catch { return String(v); } }
|
||||
return String(v);
|
||||
}
|
||||
function flattenConfig(snap: Record<string, any>): Record<string, any> {
|
||||
// Merge the top-level snapshot fields with config.* so section bucketing keys off the
|
||||
// outermost key (e.g. "budgets.max_usd" -> budgets), then flatten nested objects to dot-paths.
|
||||
const src = { name: snap.name, slug: snap.slug, description: snap.description, status: snap.status, ...(snap.config || {}) };
|
||||
const out: Record<string, any> = {};
|
||||
const walk = (o: any, prefix: string) => {
|
||||
for (const [k, v] of Object.entries(o || {})) {
|
||||
const path = prefix ? `${prefix}.${k}` : k;
|
||||
if (v && typeof v === "object" && !Array.isArray(v)) walk(v, path);
|
||||
else out[path] = v;
|
||||
}
|
||||
};
|
||||
walk(src, "");
|
||||
return out;
|
||||
}
|
||||
type FieldChange = { path: string; from: any; to: any };
|
||||
type ChangeSet = { id: string; author?: string | null; at?: string | null; changes: FieldChange[] };
|
||||
|
||||
function SettingsHistory({ project }: { project: any }) {
|
||||
const [tab, setTab] = useState<SectionId>("general");
|
||||
const [versions, setVersions] = useState<ProjectVersion[] | null>(null);
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
useEffect(() => {
|
||||
if (!project?.id) return;
|
||||
setVersions(null); setErr(null);
|
||||
api.projectConfigHistory(project.id).then(setVersions).catch((e) => setErr(String(e?.message || e)));
|
||||
}, [project?.id]);
|
||||
|
||||
// The list is newest-first, so [i+1] is the older snapshot each version is diffed against.
|
||||
const changeSets: ChangeSet[] = useMemo(() => {
|
||||
if (!versions) return [];
|
||||
const out: ChangeSet[] = [];
|
||||
for (let i = 0; i < versions.length - 1; i++) {
|
||||
const newer = flattenConfig(versions[i].snapshot || {});
|
||||
const older = flattenConfig(versions[i + 1].snapshot || {});
|
||||
const changes: FieldChange[] = [];
|
||||
new Set([...Object.keys(newer), ...Object.keys(older)]).forEach((k) => {
|
||||
if (JSON.stringify(newer[k]) !== JSON.stringify(older[k])) changes.push({ path: k, from: older[k], to: newer[k] });
|
||||
});
|
||||
if (changes.length) out.push({ id: versions[i].id, author: versions[i].author_email, at: versions[i].created_at, changes });
|
||||
}
|
||||
return out;
|
||||
}, [versions]);
|
||||
|
||||
const sectionOf = (path: string): SectionId => FIELD_SECTION[path.split(".")[0]] || "advanced";
|
||||
const forTab = changeSets
|
||||
.map((cs) => ({ ...cs, changes: cs.changes.filter((c) => sectionOf(c.path) === tab) }))
|
||||
.filter((cs) => cs.changes.length > 0);
|
||||
|
||||
return (
|
||||
<div className="col" style={{ gap: 14 }}>
|
||||
<div className="field-help" style={{ marginTop: 0 }}>A read-only log of what changed in each settings section, newest first — captured on every save. Pick a section:</div>
|
||||
<Tabs
|
||||
equal
|
||||
tabs={HISTORY_TABS.map((id) => ({ value: id, label: SECTIONS.find((s) => s.id === id)!.label }))}
|
||||
value={tab}
|
||||
onChange={(v) => setTab(v as SectionId)}
|
||||
/>
|
||||
{err && <div className="card" style={{ padding: 12, color: "var(--err)" }}>{err}</div>}
|
||||
{!err && versions === null && <div className="fg-2 t-caption">Loading history…</div>}
|
||||
{!err && versions !== null && forTab.length === 0 && (
|
||||
<div className="card col center" style={{ padding: 34, gap: 6, color: "var(--fg-2)" }}>
|
||||
<Icon name="clock" size={20} />
|
||||
<div className="t-body-sm">No changes recorded for this section.</div>
|
||||
</div>
|
||||
)}
|
||||
{forTab.map((cs) => (
|
||||
<div key={cs.id} className="card" style={{ padding: "12px 14px" }}>
|
||||
<div className="t-caption fg-2" style={{ marginBottom: 8 }}>{cs.author || "unknown"}{cs.at ? ` · ${new Date(cs.at).toLocaleString()}` : ""}</div>
|
||||
<div className="col" style={{ gap: 6 }}>
|
||||
{cs.changes.map((c) => {
|
||||
const mask = MASK_RE.test(c.path);
|
||||
return (
|
||||
<div key={c.path} className="row gap2" style={{ alignItems: "baseline", fontSize: 12 }}>
|
||||
<span className="mono-sm" style={{ minWidth: 150, flex: "none", color: "var(--fg-1)" }}>{c.path}</span>
|
||||
<span className="mono-sm fg-2 truncate" style={{ textDecoration: "line-through", minWidth: 0 }}>{mask ? "••••" : fmtVal(c.from)}</span>
|
||||
<Icon name="chevright" size={12} style={{ color: "var(--fg-2)", flex: "none" }} />
|
||||
<span className="mono-sm truncate" style={{ color: "var(--fg-0)", minWidth: 0 }}>{mask ? "••••" : fmtVal(c.to)}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TeamCard() {
|
||||
const [me, setMe] = useState<MeResult | null>(null);
|
||||
const [members, setMembers] = useState<TeamMember[]>([]);
|
||||
const [invite, setInvite] = useState({ email: "", role: "editor" });
|
||||
const [msg, setMsg] = useState<string | null>(null);
|
||||
const [result, setResult] = useState<InviteResult | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const isAdmin = me ? me.role === "owner" || me.role === "admin" : false;
|
||||
const reload = useCallback(() => { api.listTeam().then(setMembers).catch(() => setMembers([])); }, []);
|
||||
useEffect(() => { api.me().then(setMe).catch(() => {}); }, []);
|
||||
useEffect(() => { if (isAdmin) reload(); }, [isAdmin, reload]);
|
||||
|
||||
async function doInvite() {
|
||||
setMsg(null); setResult(null); setCopied(false);
|
||||
if (!invite.email.trim()) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
const r = await api.inviteMember({ email: invite.email.trim(), role: invite.role });
|
||||
setResult(r);
|
||||
setInvite({ email: "", role: "editor" });
|
||||
reload();
|
||||
} catch { setMsg("Could not invite (that email may already be on the team)."); }
|
||||
finally { setBusy(false); }
|
||||
}
|
||||
function copyInvite() {
|
||||
if (!result?.invite_url) return;
|
||||
navigator.clipboard?.writeText(result.invite_url).then(() => { setCopied(true); setTimeout(() => setCopied(false), 1800); }).catch(() => {});
|
||||
}
|
||||
async function setRole(uid: string, role: string) { try { await api.updateMember(uid, { role }); reload(); } catch { setMsg("Could not update role."); } }
|
||||
async function deactivate(uid: string) { if (!window.confirm("Deactivate this user?")) return; try { await api.deactivateMember(uid); reload(); } catch { setMsg("Could not deactivate."); } }
|
||||
function logout() { clearTokens(); window.location.reload(); }
|
||||
|
||||
return (
|
||||
<Card title="Team & account" action={<button className="btn btn-secondary btn-sm" onClick={logout}><Icon name="external" size={14} />Sign out</button>}>
|
||||
{me && (
|
||||
<div className="field-help" style={{ marginTop: 0, marginBottom: 10 }}>
|
||||
Signed in as <b>{me.email || "(dev)"}</b> · role <span className="typechip">{me.role}</span>
|
||||
{me.is_fallback && <span className="pill pill-muted" style={{ height: 16, marginLeft: 8 }}>auth disabled (dev)</span>}
|
||||
</div>
|
||||
)}
|
||||
{isAdmin && (
|
||||
<>
|
||||
<div style={{ border: "1px solid var(--line)", borderRadius: 10, padding: 12, marginBottom: 12, background: "var(--bg-1)" }}>
|
||||
<div className="t-body-sm" style={{ fontWeight: 600, marginBottom: 8 }}>Invite a teammate</div>
|
||||
<div className="row gap2" style={{ alignItems: "flex-end", flexWrap: "wrap" }}>
|
||||
<label className="col gap1" style={{ flex: "2 1 220px", minWidth: 200 }}>
|
||||
<span className="t-micro">Email</span>
|
||||
<input className="input" type="email" placeholder="teammate@company.com" value={invite.email}
|
||||
onChange={(e) => setInvite((i) => ({ ...i, email: e.target.value }))} onKeyDown={(e) => { if (e.key === "Enter") doInvite(); }} />
|
||||
</label>
|
||||
<label className="col gap1" style={{ flex: "0 0 140px" }}>
|
||||
<span className="t-micro">Role</span>
|
||||
<select className="select" value={invite.role} onChange={(e) => setInvite((i) => ({ ...i, role: e.target.value }))}>
|
||||
{ROLES.filter((r) => r !== "owner").map((r) => <option key={r} value={r}>{r}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<button className="btn btn-primary btn-sm" onClick={doInvite} disabled={busy || !invite.email.trim()} style={{ height: 34 }}>
|
||||
<Icon name="plus" size={14} />{busy ? "Inviting…" : "Send invite"}
|
||||
</button>
|
||||
</div>
|
||||
<div className="field-help" style={{ marginTop: 8, marginBottom: 0 }}>
|
||||
They'll get an email with a secure link to set their own password and join the workspace.
|
||||
</div>
|
||||
</div>
|
||||
{result && (
|
||||
<div className="card" style={{ padding: 10, marginBottom: 12, background: "var(--bg-2)" }}>
|
||||
{result.email_sent ? (
|
||||
<div className="t-body-sm"><Icon name="check" size={13} style={{ color: "var(--ok)" }} /> Invitation emailed to <b>{result.email}</b>. They'll set their own password from the link.</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="t-body-sm" style={{ marginBottom: 6 }}>Invite created for <b>{result.email}</b>. Email isn't configured on this server, so share this link - it lets them set their password and join:</div>
|
||||
<div className="row gap2">
|
||||
<input className="input mono" readOnly value={result.invite_url || ""} style={{ flex: 1, fontSize: 12 }} onFocus={(e) => e.currentTarget.select()} />
|
||||
<button className="btn btn-secondary btn-sm" onClick={copyInvite}><Icon name={copied ? "check" : "copy"} size={13} />{copied ? "Copied" : "Copy"}</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{members.map((m) => (
|
||||
<div key={m.id} className="row spread" style={{ padding: "7px 0", borderTop: "1px solid var(--line)" }}>
|
||||
<div className="row gap2"><Icon name="user" size={15} style={{ color: "var(--fg-2)" }} /><span className="t-body-sm">{m.email}</span>{m.status !== "active" && <span className="pill pill-muted" style={{ height: 16 }}>{m.status}</span>}</div>
|
||||
<div className="row gap2">
|
||||
<select className="select" value={m.role} disabled={m.id === me?.id} onChange={(e) => setRole(m.id, e.target.value)}>
|
||||
{ROLES.map((r) => <option key={r} value={r}>{r}</option>)}
|
||||
</select>
|
||||
{m.id !== me?.id && <button className="iconbtn" title="Deactivate" onClick={() => deactivate(m.id)}><Icon name="trash" size={14} /></button>}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
{msg && <div className="t-caption" style={{ color: "var(--err)", marginTop: 8 }}>{msg}</div>}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
/* Model pricing editor - per-1M input/output token rates used to cost runs. Backed by
|
||||
GET/PUT /v1/pricing. Rows come from the known model catalog merged with any custom
|
||||
models already priced on the server. */
|
||||
function PricingCard() {
|
||||
const [pricing, setPricing] = useState<Record<string, { input_per_1m: number; output_per_1m: number }>>({});
|
||||
const MODELS = useModels();
|
||||
const [draft, setDraft] = useState<Record<string, { input_per_1m: string; output_per_1m: string }>>({});
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
const [save, setSave] = useState<"idle" | "saving" | "saved">("idle");
|
||||
|
||||
const reload = useCallback(() => {
|
||||
api.listPricing().then((p) => { setPricing(p || {}); setLoaded(true); }).catch((e) => { setErr(String(e?.message || e)); setLoaded(true); });
|
||||
}, []);
|
||||
useEffect(() => { reload(); }, [reload]);
|
||||
|
||||
// Show every catalog model (minus the offline fake) plus any extra priced models.
|
||||
const rows = useMemo(() => {
|
||||
const ids = new Set<string>([...MODELS.filter((m) => m.id !== "fake:echo").map((m) => m.id), ...Object.keys(pricing)]);
|
||||
return Array.from(ids).sort();
|
||||
}, [pricing]);
|
||||
|
||||
const val = (model: string, key: "input_per_1m" | "output_per_1m"): string => {
|
||||
const d = draft[model];
|
||||
if (d && d[key] !== undefined) return d[key];
|
||||
const p = pricing[model];
|
||||
return p && p[key] != null ? String(p[key]) : "";
|
||||
};
|
||||
const edit = (model: string, key: "input_per_1m" | "output_per_1m", v: string) =>
|
||||
setDraft((d) => ({ ...d, [model]: { input_per_1m: val(model, "input_per_1m"), output_per_1m: val(model, "output_per_1m"), [key]: v } }));
|
||||
|
||||
async function saveAll() {
|
||||
setSave("saving");
|
||||
try {
|
||||
for (const [model, d] of Object.entries(draft)) {
|
||||
const input_per_1m = parseFloat(d.input_per_1m);
|
||||
const output_per_1m = parseFloat(d.output_per_1m);
|
||||
if (Number.isNaN(input_per_1m) && Number.isNaN(output_per_1m)) continue;
|
||||
await api.setPricing(model, { input_per_1m: input_per_1m || 0, output_per_1m: output_per_1m || 0 });
|
||||
}
|
||||
setDraft({});
|
||||
reload();
|
||||
setSave("saved"); setTimeout(() => setSave("idle"), 1400);
|
||||
} catch (e) { setErr(String((e as any)?.message || e)); setSave("idle"); }
|
||||
}
|
||||
|
||||
const dirty = Object.keys(draft).length > 0;
|
||||
|
||||
return (
|
||||
<Card title="Model pricing" action={<button className="btn btn-primary btn-sm" onClick={saveAll} disabled={!dirty || save === "saving"}><Icon name={save === "saved" ? "check" : "save"} size={14} />{save === "saving" ? "Saving…" : save === "saved" ? "Saved" : "Save pricing"}</button>}>
|
||||
<div className="field-help" style={{ marginTop: 0, marginBottom: 10 }}>Rates in USD per 1M tokens. Used to compute run cost in Traces and to enforce budgets.</div>
|
||||
{err && <div className="card" style={{ padding: 10, color: "var(--err)", marginBottom: 10 }}>{err}</div>}
|
||||
{!loaded ? (
|
||||
<div className="fg-2 t-caption">Loading pricing…</div>
|
||||
) : rows.length === 0 ? (
|
||||
<EmptyState icon="coins" title="No models to price" sub="Add a model to the catalog to set its rates." />
|
||||
) : (
|
||||
<table className="tbl tbl-dense">
|
||||
<thead><tr><th>Model</th><th style={{ textAlign: "right" }}>Input $/1M</th><th style={{ textAlign: "right" }}>Output $/1M</th></tr></thead>
|
||||
<tbody>
|
||||
{rows.map((model) => (
|
||||
<tr key={model}>
|
||||
<td><span className="mono-sm">{model}</span></td>
|
||||
<td style={{ textAlign: "right" }}>
|
||||
<input className="input mono" type="number" min={0} step={0.01} style={{ width: 110, textAlign: "right", display: "inline-block" }}
|
||||
value={val(model, "input_per_1m")} placeholder="—" onChange={(e) => edit(model, "input_per_1m", e.target.value)} />
|
||||
</td>
|
||||
<td style={{ textAlign: "right" }}>
|
||||
<input className="input mono" type="number" min={0} step={0.01} style={{ width: 110, textAlign: "right", display: "inline-block" }}
|
||||
value={val(model, "output_per_1m")} placeholder="—" onChange={(e) => edit(model, "output_per_1m", e.target.value)} />
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
/* Guardrails & Egress: one project-level I/O policy enforced on EVERY agent, plus the
|
||||
outbound-network egress scope. Content guardrails compile to `config.default_middleware`
|
||||
(the engine prepends this to every agent's own middleware stack — see agent_node); the
|
||||
network scope writes `config.egress` (the SSRF EgressPolicy, applied to every tool/webhook/
|
||||
web_fetch call). Entries this screen owns are tagged `_managed`, so any middleware authored
|
||||
elsewhere is preserved on save.
|
||||
|
||||
Textareas keep their raw text in `config._guardrails_ui` (a UI-only sidecar the backend
|
||||
ignores) — deriving the value from the compiled arrays instead would strip a trailing newline
|
||||
on every keystroke, making it impossible to type a second line. Built-in PII compiles to a
|
||||
`pii` entry per type; custom patterns add a `detector` regex (label = its pii_type); blocked
|
||||
terms compile to one case-insensitive `guardrail_regex` of escaped literals (no runaway-regex
|
||||
risk). Only patterns that parse as a valid regex are compiled, so a half-typed one can't break
|
||||
every agent's compile. */
|
||||
const PII_TYPES: [string, string][] = [
|
||||
["email", "Email"], ["credit_card", "Credit card"], ["ip", "IP address"], ["mac_address", "MAC address"], ["url", "URL"],
|
||||
];
|
||||
function escapeRegex(s: string): string { return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); }
|
||||
function isValidRegex(p: string): boolean { try { new RegExp(p); return true; } catch { return false; } }
|
||||
const splitLines = (s: string): string[] => (s || "").split("\n").map((x) => x.trim()).filter(Boolean);
|
||||
// Parse "Label = regex" lines into {name, pattern}. Split on the first "=" so a pattern may
|
||||
// itself contain "="; lines without both parts are skipped (e.g. a blank in-progress row).
|
||||
function parseCustoms(text: string): { name: string; pattern: string }[] {
|
||||
const out: { name: string; pattern: string }[] = [];
|
||||
for (const line of (text || "").split("\n")) {
|
||||
const t = line.trim();
|
||||
const i = t.indexOf("=");
|
||||
if (i <= 0) continue;
|
||||
const name = t.slice(0, i).trim();
|
||||
const pattern = t.slice(i + 1).trim();
|
||||
if (name && pattern) out.push({ name, pattern });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function GuardrailsCard({ config, setCfg }: { config: Record<string, any>; setCfg: (patch: Record<string, any>) => void }) {
|
||||
const mw: any[] = Array.isArray(config.default_middleware) ? config.default_middleware : [];
|
||||
const isManagedPii = (m: any) => m?.type === "pii" && m?.config?._managed;
|
||||
const isManagedTerms = (m: any) => m?.type === "guardrail_regex" && m?.config?._managed;
|
||||
const unmanaged = mw.filter((m) => !isManagedPii(m) && !isManagedTerms(m));
|
||||
const pii = mw.filter(isManagedPii);
|
||||
const termsEntry = mw.find(isManagedTerms);
|
||||
const egress = config.egress || {};
|
||||
const ui = config._guardrails_ui || {};
|
||||
|
||||
// Discrete controls read straight from the compiled entries (no typing, so no newline issue).
|
||||
const builtinTypes = new Set<string>(pii.filter((m) => !m.config?.detector).map((m) => m.config?.pii_type));
|
||||
const strategy: string = pii[0]?.config?.strategy || "redact";
|
||||
const scanIn: boolean = pii[0]?.config?.apply_to_input ?? true;
|
||||
const scanOut: boolean = pii[0]?.config?.apply_to_output ?? true;
|
||||
const onMatch: string = termsEntry?.config?.on_match || "block";
|
||||
const blockPrivate = !!egress.block_private;
|
||||
// Free-text controls read their raw text from the sidecar, falling back to reconstructing it
|
||||
// from the compiled config (for policies created via the API or before this UI existed).
|
||||
const termsText: string = ui.terms_text ?? (termsEntry?.config?._terms || []).join("\n");
|
||||
const customsText: string = ui.customs_text ?? pii.filter((m) => m.config?.detector).map((m) => `${m.config.pii_type} = ${m.config.detector}`).join("\n");
|
||||
const allowText: string = ui.allow_text ?? (egress.allow_hosts || []).join("\n");
|
||||
const denyText: string = ui.deny_text ?? (egress.deny_hosts || []).join("\n");
|
||||
|
||||
const invalidCustoms = parseCustoms(customsText).filter((c) => !isValidRegex(c.pattern)).map((c) => c.name);
|
||||
const hasPii = builtinTypes.size > 0 || parseCustoms(customsText).length > 0;
|
||||
|
||||
// Recompile the whole managed policy from a snapshot of UI state, preserving unmanaged entries.
|
||||
function commit(next: Partial<{ types: Set<string>; strategy: string; scanIn: boolean; scanOut: boolean; termsText: string; customsText: string; onMatch: string; blockPrivate: boolean; allowText: string; denyText: string }>) {
|
||||
const types = next.types ?? builtinTypes;
|
||||
const strat = next.strategy ?? strategy;
|
||||
const sIn = next.scanIn ?? scanIn;
|
||||
const sOut = next.scanOut ?? scanOut;
|
||||
const tText = next.termsText ?? termsText;
|
||||
const cText = next.customsText ?? customsText;
|
||||
const oMatch = next.onMatch ?? onMatch;
|
||||
const bPriv = next.blockPrivate ?? blockPrivate;
|
||||
const aText = next.allowText ?? allowText;
|
||||
const dText = next.denyText ?? denyText;
|
||||
|
||||
const mkPii = (pii_type: string, detector?: string) => ({
|
||||
type: "pii", config: { _managed: true, pii_type, ...(detector ? { detector } : {}), strategy: strat, apply_to_input: sIn, apply_to_output: sOut },
|
||||
});
|
||||
const builtin = [...types].map((t) => mkPii(t));
|
||||
const customs = parseCustoms(cText).filter((c) => isValidRegex(c.pattern)).map((c) => mkPii(c.name, c.pattern));
|
||||
const termList = splitLines(tText);
|
||||
const termEntry = termList.length
|
||||
? [{ type: "guardrail_regex", config: { _managed: true, _terms: termList, patterns: termList.map((t) => "(?i)" + escapeRegex(t)), on_match: oMatch, apply_to: "both" } }]
|
||||
: [];
|
||||
|
||||
const allow = splitLines(aText);
|
||||
const deny = splitLines(dText);
|
||||
const nextEgress: Record<string, any> = { ...egress };
|
||||
if (bPriv) nextEgress.block_private = true; else delete nextEgress.block_private;
|
||||
if (allow.length) nextEgress.allow_hosts = allow; else delete nextEgress.allow_hosts;
|
||||
if (deny.length) nextEgress.deny_hosts = deny; else delete nextEgress.deny_hosts;
|
||||
|
||||
setCfg({
|
||||
default_middleware: [...unmanaged, ...builtin, ...customs, ...termEntry],
|
||||
egress: Object.keys(nextEgress).length ? nextEgress : undefined,
|
||||
_guardrails_ui: { terms_text: tText, customs_text: cText, allow_text: aText, deny_text: dText },
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card title="Content guardrails">
|
||||
<div className="field-help" style={{ marginTop: 0, marginBottom: 12 }}>
|
||||
Enforced on <b>every agent</b> in this project, ahead of any per-agent middleware. These run locally (regex) on each turn — no added network latency. Use an agent's own middleware for exceptions.
|
||||
</div>
|
||||
<Field label="Redact / block PII" help="Detect these entities in messages and apply the strategy below. Each type is matched with a built-in detector.">
|
||||
<div className="row gap2 wrap">
|
||||
{PII_TYPES.map(([id, label]) => {
|
||||
const on = builtinTypes.has(id);
|
||||
return (
|
||||
<button key={id} type="button"
|
||||
onClick={() => { const t = new Set(builtinTypes); if (on) t.delete(id); else t.add(id); commit({ types: t }); }}
|
||||
style={{ padding: "5px 11px", borderRadius: 8, border: "1px solid var(--line)", cursor: "pointer", fontSize: 13, fontWeight: 600, background: on ? "var(--accent)" : "transparent", color: on ? "#fff" : "var(--fg-1)" }}>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Field>
|
||||
<Field label="Custom patterns" help="One per line as `Label = regex` — e.g. a phone or national-ID format. Matched with your regex and handled by the strategy below. Unlike blocked terms, a regex you write here is your responsibility (keep it simple to avoid slow matching).">
|
||||
<textarea className="textarea mono" rows={2} placeholder={"Phone = \\d{3}[- ]?\\d{3}[- ]?\\d{4}\nUS SSN = \\d{3}-\\d{2}-\\d{4}"}
|
||||
value={customsText} onChange={(e) => commit({ customsText: e.target.value })} />
|
||||
</Field>
|
||||
{invalidCustoms.length > 0 && (
|
||||
<div className="field-help" style={{ marginTop: -6, color: "var(--err)" }}>⚠ Not a valid regex (ignored until fixed): {invalidCustoms.join(", ")}</div>
|
||||
)}
|
||||
{hasPii && (
|
||||
<>
|
||||
<Field label="Strategy" help="Applies to every PII match above (built-in and custom). Redact removes the value, Mask shows only the last few chars, Hash substitutes a stable hash, Block refuses the whole message.">
|
||||
<Segmented
|
||||
options={[{ value: "redact", label: "Redact" }, { value: "mask", label: "Mask" }, { value: "hash", label: "Hash" }, { value: "block", label: "Block" }]}
|
||||
value={strategy} onChange={(v) => commit({ strategy: v })} />
|
||||
</Field>
|
||||
<div className="row gap3 wrap">
|
||||
<label className="row gap2" style={{ alignItems: "center" }}><Toggle on={scanIn} onChange={(v) => commit({ scanIn: v })} /><span className="t-body-sm">Scan input (what comes in)</span></label>
|
||||
<label className="row gap2" style={{ alignItems: "center" }}><Toggle on={scanOut} onChange={(v) => commit({ scanOut: v })} /><span className="t-body-sm">Scan output (what leaves to the model / user)</span></label>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<div style={{ height: 14 }} />
|
||||
<Field label="Blocked terms" help="One term per line. Matched case-insensitively in both input and output. Kept as literal keywords — safe from regex pitfalls.">
|
||||
<textarea className="textarea mono" rows={3} placeholder={"internal-codename\nproject-atlas"}
|
||||
value={termsText} onChange={(e) => commit({ termsText: e.target.value })} />
|
||||
</Field>
|
||||
{splitLines(termsText).length > 0 && (
|
||||
<Field label="On a blocked term" help="Block replaces the message with a notice; Redact masks just the term; Flag keeps it but tags the trace for review.">
|
||||
<Segmented
|
||||
options={[{ value: "block", label: "Block" }, { value: "redact", label: "Redact" }, { value: "flag", label: "Flag" }]}
|
||||
value={onMatch} onChange={(v) => commit({ onMatch: v })} />
|
||||
</Field>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<Card title="Network egress">
|
||||
<div className="field-help" style={{ marginTop: 0, marginBottom: 12 }}>
|
||||
Scopes where this project's tools, webhooks, and web-fetch can connect. It can only <b>tighten</b> the server's egress guard for this project — never loosen it.
|
||||
</div>
|
||||
<label className="row spread" style={{ padding: "8px 0" }}>
|
||||
<div><div className="t-body-sm" style={{ fontWeight: 600 }}>Block private / internal addresses</div><div className="field-help" style={{ marginTop: 0 }}>Refuse outbound calls that resolve to private, loopback, or cloud-metadata addresses (SSRF guard).</div></div>
|
||||
<Toggle on={blockPrivate} onChange={(v) => commit({ blockPrivate: v })} />
|
||||
</label>
|
||||
<Field label="Allowed domains" help="One host per line. If any are listed, ONLY these hosts (and their subdomains) are reachable — everything else is blocked.">
|
||||
<textarea className="textarea mono" rows={2} placeholder={"api.example.com\nhooks.slack.com"}
|
||||
value={allowText} onChange={(e) => commit({ allowText: e.target.value })} />
|
||||
</Field>
|
||||
<Field label="Blocked domains" help="One host per line. These hosts (and their subdomains) are always refused.">
|
||||
<textarea className="textarea mono" rows={2} placeholder={"pastebin.com"}
|
||||
value={denyText} onChange={(e) => commit({ denyText: e.target.value })} />
|
||||
</Field>
|
||||
</Card>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function Card({ title, action, children }: { title: string; action?: React.ReactNode; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="card" style={{ padding: 18, marginBottom: 16 }}>
|
||||
<div className="row spread" style={{ marginBottom: 12 }}><div className="t-h2">{title}</div>{action}</div>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,574 @@
|
||||
"use client";
|
||||
/* Traces: conversations (chat sessions) grouped by end user, their user<->AI turns, and a
|
||||
drill-in to the per-turn span waterfall (tool + LLM request/response). */
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Icon } from "../icons";
|
||||
import { Avatar, StatusPill } from "../primitives";
|
||||
import { api, Conversation, ConversationDetail, Facets, openSSE, Span, Turn } from "@/lib/api";
|
||||
import { fmtUSD, buildNodeLabels, NODE_META, type NodeLabel } from "@/lib/data";
|
||||
|
||||
// Conversations are paged in on scroll (newest-activity first) so the Traces view never
|
||||
// pulls a project's entire history in one shot.
|
||||
const PAGE = 20;
|
||||
|
||||
// Friendly labels for the raw run source.
|
||||
const SOURCE_LABEL: Record<string, string> = {
|
||||
playground: "Playground", api: "API", embed: "Embed", assistant: "Forge Assistant",
|
||||
channel_email: "Email", webhook: "Webhook", schedule: "Schedule", app_event: "App event",
|
||||
};
|
||||
const srcLabel = (s: string) => SOURCE_LABEL[s] || s || "—";
|
||||
const fmtWhen = (iso?: string | null) => (iso ? iso.slice(0, 16).replace("T", " ") : "");
|
||||
|
||||
// Trigger a client-side file download of `data` as pretty JSON (used by the Traces export).
|
||||
function downloadJSON(filename: string, data: unknown) {
|
||||
const blob = new Blob([JSON.stringify(data, null, 2)], { type: "application/json" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
export function TracesScreen({ project }: { project: any }) {
|
||||
const [convos, setConvos] = useState<Conversation[]>([]);
|
||||
const [facets, setFacets] = useState<Facets>({ actors: [], sources: [] });
|
||||
const [actor, setActor] = useState("");
|
||||
const [source, setSource] = useState("");
|
||||
const [status, setStatus] = useState("");
|
||||
const [searchInput, setSearchInput] = useState("");
|
||||
const [search, setSearch] = useState("");
|
||||
const [sel, setSel] = useState<string | null>(null);
|
||||
const [detail, setDetail] = useState<ConversationDetail | null>(null);
|
||||
const [nextOffset, setNextOffset] = useState(0);
|
||||
const [hasMore, setHasMore] = useState(false);
|
||||
const [loadingMore, setLoadingMore] = useState(false);
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Distinct actors for the filter dropdown. A separate call because the paged list below
|
||||
// only holds one 20-row window - the full actor set can't be derived from it.
|
||||
useEffect(() => { if (project?.id) api.conversationFacets(project.id).then(setFacets).catch(() => {}); }, [project?.id]);
|
||||
|
||||
// Debounce the search box so we don't fire a request per keystroke.
|
||||
useEffect(() => { const t = setTimeout(() => setSearch(searchInput.trim()), 350); return () => clearTimeout(t); }, [searchInput]);
|
||||
|
||||
// First page - and a reload whenever the project or a filter changes.
|
||||
useEffect(() => {
|
||||
if (!project?.id) { setConvos([]); setHasMore(false); setNextOffset(0); return; }
|
||||
let live = true;
|
||||
api.listConversations(project.id, { actor: actor || undefined, source: source || undefined, status: status || undefined, search: search || undefined, limit: PAGE, offset: 0 })
|
||||
.then((c) => {
|
||||
if (!live) return;
|
||||
setConvos(c);
|
||||
setNextOffset(c.length);
|
||||
setHasMore(c.length === PAGE);
|
||||
setSel((cur) => (cur && c.some((x) => x.thread_id === cur) ? cur : c[0]?.thread_id ?? null));
|
||||
if (listRef.current) listRef.current.scrollTop = 0;
|
||||
})
|
||||
.catch(() => { if (live) { setConvos([]); setHasMore(false); setNextOffset(0); } });
|
||||
return () => { live = false; };
|
||||
}, [project?.id, actor, source, status, search]);
|
||||
|
||||
const loadMore = useCallback(async () => {
|
||||
if (!project?.id || loadingMore || !hasMore) return;
|
||||
setLoadingMore(true);
|
||||
try {
|
||||
const next = await api.listConversations(project.id, { actor: actor || undefined, source: source || undefined, status: status || undefined, search: search || undefined, limit: PAGE, offset: nextOffset });
|
||||
// De-dupe by thread_id in case the scan window shifted between pages.
|
||||
setConvos((prev) => {
|
||||
const seen = new Set(prev.map((x) => x.thread_id));
|
||||
return [...prev, ...next.filter((x) => !seen.has(x.thread_id))];
|
||||
});
|
||||
setNextOffset((o) => o + next.length);
|
||||
setHasMore(next.length === PAGE);
|
||||
} catch { /* keep what we have */ } finally { setLoadingMore(false); }
|
||||
}, [project?.id, actor, source, status, search, nextOffset, hasMore, loadingMore]);
|
||||
|
||||
const onListScroll = () => {
|
||||
const el = listRef.current;
|
||||
if (el && el.scrollHeight - el.scrollTop - el.clientHeight < 120) loadMore();
|
||||
};
|
||||
|
||||
useEffect(() => { if (project?.id && sel) api.getConversation(project.id, sel).then(setDetail).catch(() => setDetail(null)); else setDetail(null); }, [project?.id, sel]);
|
||||
|
||||
const purge = async () => {
|
||||
const days = window.prompt("Delete conversations older than how many days? (admin only)", "30");
|
||||
if (days == null) return;
|
||||
const n = parseInt(days, 10);
|
||||
if (!Number.isFinite(n) || n < 0) return;
|
||||
try {
|
||||
const { removed } = await api.purgeConversations(project.id, n);
|
||||
window.alert(`Removed ${removed} conversation turn(s) older than ${n} days.`);
|
||||
// Reset back to the first page after a purge.
|
||||
const first = await api.listConversations(project.id, { actor: actor || undefined, source: source || undefined, status: status || undefined, search: search || undefined, limit: PAGE, offset: 0 });
|
||||
setConvos(first);
|
||||
setNextOffset(first.length);
|
||||
setHasMore(first.length === PAGE);
|
||||
} catch { window.alert("Purge failed — this action requires an admin role."); }
|
||||
};
|
||||
|
||||
// Export the loaded conversation summaries as JSON (respects the active filters/search).
|
||||
const exportConvos = () => {
|
||||
if (!convos.length) return;
|
||||
downloadJSON(`conversations-${project?.slug || project?.id || "export"}.json`, convos);
|
||||
};
|
||||
|
||||
return (
|
||||
// alignItems:stretch - .row centers children, which gives the list its content height.
|
||||
<div className="row" style={{ flex: 1, minHeight: 0, height: "100%", overflow: "hidden", alignItems: "stretch" }}>
|
||||
{/* conversations list + filters */}
|
||||
<div className="col" style={{ width: 340, flex: "none", background: "var(--bg-1)", borderRight: "1px solid var(--line)", minHeight: 0, height: "100%" }}>
|
||||
<div className="row spread" style={{ padding: "16px 16px 8px", flex: "none" }}>
|
||||
<div className="t-h2">Conversations</div>
|
||||
<div className="row gap2">
|
||||
<button className="t-caption fg-2" onClick={exportConvos} disabled={convos.length === 0} title="Download the loaded conversations as JSON" style={{ background: "none", border: "none", cursor: convos.length ? "pointer" : "default", opacity: convos.length ? 1 : 0.5 }}>Export</button>
|
||||
<button className="t-caption fg-2" onClick={purge} title="Delete old conversations" style={{ background: "none", border: "none", cursor: "pointer" }}>Clean up…</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col gap2" style={{ padding: "0 16px 10px", flex: "none" }}>
|
||||
<input value={searchInput} onChange={(e) => setSearchInput(e.target.value)} placeholder="Search messages…" className="input" style={{ width: "100%", fontSize: 13 }} />
|
||||
<div className="row gap1">
|
||||
<select value={actor} onChange={(e) => setActor(e.target.value)} className="input" style={{ flex: 1, minWidth: 0, fontSize: 13 }}>
|
||||
<option value="">All users</option>
|
||||
{facets.actors.map((a) => <option key={a} value={a}>{a}</option>)}
|
||||
</select>
|
||||
<select value={source} onChange={(e) => setSource(e.target.value)} className="input" style={{ flex: 1, minWidth: 0, fontSize: 13 }}>
|
||||
<option value="">All sources</option>
|
||||
{facets.sources.map((s) => <option key={s} value={s}>{srcLabel(s)}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div className="row gap1">
|
||||
{[["", "All"], ["success", "Success"], ["error", "Error"]].map(([v, label]) => (
|
||||
<button key={v} onClick={() => setStatus(v)} className="t-caption"
|
||||
style={{ flex: 1, padding: "5px 0", borderRadius: 6, cursor: "pointer", border: "1px solid var(--line)", background: status === v ? "var(--bg-3)" : "transparent", color: status === v ? "var(--fg-0)" : "var(--fg-2)" }}>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div ref={listRef} onScroll={onListScroll} className="scroll-y" style={{ minHeight: 0, flex: 1 }}>
|
||||
{convos.length === 0 && <div className="fg-2 t-caption" style={{ padding: "8px 16px" }}>No conversations yet. Run a workflow in the Playground or from your app.</div>}
|
||||
{convos.map((c) => (
|
||||
<button key={c.thread_id} onClick={() => setSel(c.thread_id)} className="row gap2" style={{ width: "100%", textAlign: "left", padding: "11px 16px", border: "none", borderBottom: "1px solid var(--line)", background: sel === c.thread_id ? "var(--bg-3)" : "transparent", cursor: "pointer" }}>
|
||||
<div className="grow" style={{ minWidth: 0 }}>
|
||||
<div className="row gap2" style={{ minWidth: 0 }}>
|
||||
<StatusPill status={c.status} />
|
||||
<span className="t-body-sm truncate" style={{ fontWeight: 600 }}>{c.actor}</span>
|
||||
</div>
|
||||
<div className="truncate fg-2 t-caption" style={{ marginTop: 3 }}>{c.preview || "(no message)"}</div>
|
||||
<div className="fg-2 t-caption mono" style={{ marginTop: 3 }}>{srcLabel(c.source)} · {c.turns} turn{c.turns === 1 ? "" : "s"} · {fmtWhen(c.last_activity)}</div>
|
||||
</div>
|
||||
<Icon name="chevright" size={15} style={{ color: "var(--fg-2)" }} />
|
||||
</button>
|
||||
))}
|
||||
{loadingMore && <div className="fg-2 t-caption" style={{ padding: "10px 16px", textAlign: "center" }}>Loading more…</div>}
|
||||
{hasMore && !loadingMore && (
|
||||
<button onClick={loadMore} className="t-caption fg-2" style={{ width: "100%", padding: "10px 16px", background: "none", border: "none", borderTop: "1px solid var(--line)", cursor: "pointer" }}>Load more</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{/* transcript */}
|
||||
<div className="scroll-y grow" style={{ minWidth: 0, minHeight: 0, height: "100%", padding: 24, overflowX: "hidden", background: "var(--bg-0)" }}>
|
||||
{detail ? <ConversationView key={detail.conversation.thread_id} project={project} detail={detail} />
|
||||
: <div className="fg-2" style={{ padding: 40, textAlign: "center" }}>Select a conversation to see its messages.</div>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* One user message can produce several Trace rows under the SAME run_id: a HITL pause writes an
|
||||
`interrupted` trace, then the resume writes a `done` trace (both carry the same user_message,
|
||||
since run.input is unchanged). Grouping by run_id folds those segments back into one turn, so a
|
||||
single message reads as one turn that paused - not two. Separate submissions get fresh run_ids
|
||||
and stay distinct. Traces arrive oldest-first (started_at asc), so the last segment is final. */
|
||||
type GroupedTurn = {
|
||||
runId: string;
|
||||
segments: Turn[];
|
||||
userMessage: string | null;
|
||||
aiResponse: string | null;
|
||||
status: string;
|
||||
error: string | null;
|
||||
latencyMs: number;
|
||||
tokens: number;
|
||||
costUsd: number;
|
||||
paused: boolean;
|
||||
};
|
||||
|
||||
function groupTurns(turns: Turn[]): GroupedTurn[] {
|
||||
const byRun = new Map<string, GroupedTurn>();
|
||||
const order: string[] = [];
|
||||
for (const t of turns) {
|
||||
let g = byRun.get(t.run_id);
|
||||
if (!g) {
|
||||
g = { runId: t.run_id, segments: [], userMessage: null, aiResponse: null, status: t.status, error: null, latencyMs: 0, tokens: 0, costUsd: 0, paused: false };
|
||||
byRun.set(t.run_id, g);
|
||||
order.push(t.run_id);
|
||||
}
|
||||
g.segments.push(t);
|
||||
g.latencyMs += t.latency_ms || 0;
|
||||
g.tokens += t.total_tokens || 0;
|
||||
g.costUsd += t.total_cost_usd || 0;
|
||||
g.userMessage = g.userMessage ?? (t.user_message || null);
|
||||
if (t.ai_response) g.aiResponse = t.ai_response; // last non-empty wins (the final segment)
|
||||
g.status = t.status; // last segment = the run's final status
|
||||
g.error = t.error ?? null;
|
||||
if (t.status === "interrupted") g.paused = true;
|
||||
}
|
||||
return order.map((r) => byRun.get(r)!);
|
||||
}
|
||||
|
||||
function ConversationView({ project, detail }: { project: any; detail: ConversationDetail }) {
|
||||
const c = detail.conversation;
|
||||
const [openTurn, setOpenTurn] = useState<string | null>(null);
|
||||
const [traces, setTraces] = useState<Record<string, { spans: Span[] }>>({});
|
||||
const [rerunning, setRerunning] = useState<string | null>(null);
|
||||
// Resolve the workflow's node ids to their friendly (canvas) names so the span tree reads like
|
||||
// the graph the operator built (e.g. "support_supervisor", not "supervisor"). Best-effort: if the
|
||||
// workflow was deleted the map is empty and spans fall back to their raw id (still traceable).
|
||||
const [nodeLabels, setNodeLabels] = useState<Record<string, NodeLabel>>({});
|
||||
useEffect(() => {
|
||||
if (!project?.id || !c.workflow_id) { setNodeLabels({}); return; }
|
||||
let live = true;
|
||||
api.getWorkflow(project.id, c.workflow_id)
|
||||
.then((wf) => { if (live) setNodeLabels(buildNodeLabels(wf)); })
|
||||
.catch(() => { if (live) setNodeLabels({}); });
|
||||
return () => { live = false; };
|
||||
}, [project?.id, c.workflow_id]);
|
||||
|
||||
// Keyed by run_id (a group), not trace_id: a paused run has an interrupt + a resume trace, and
|
||||
// expanding the turn lazy-loads the spans for every segment so the waterfall shows the whole run.
|
||||
const toggle = async (group: GroupedTurn) => {
|
||||
if (openTurn === group.runId) { setOpenTurn(null); return; }
|
||||
setOpenTurn(group.runId);
|
||||
for (const seg of group.segments) {
|
||||
if (traces[seg.trace_id]) continue;
|
||||
try { const d = await api.getTrace(project.id, seg.trace_id); setTraces((t) => ({ ...t, [seg.trace_id]: { spans: d.spans } })); } catch { /* ignore */ }
|
||||
}
|
||||
};
|
||||
|
||||
const rerun = async (runId: string) => {
|
||||
if (!c.workflow_id || rerunning) return;
|
||||
setRerunning(runId);
|
||||
try {
|
||||
const run = await api.rerunRun(project.id, c.workflow_id, runId);
|
||||
let outcome = "completed";
|
||||
await openSSE(api.runStreamUrl(project.id, c.workflow_id, run.id), (frame) => {
|
||||
if (frame.event === "error") outcome = "failed";
|
||||
else if (frame.event === "interrupt") outcome = "paused for input";
|
||||
});
|
||||
window.alert(`Re-run ${outcome}. Open its new conversation from the list.`);
|
||||
} catch (error) {
|
||||
window.alert(error instanceof Error ? `Re-run failed: ${error.message}` : "Re-run failed.");
|
||||
} finally {
|
||||
setRerunning(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fade-up col" style={{ maxWidth: 860, margin: "0 auto", gap: 4 }}>
|
||||
{/* high-level rollup */}
|
||||
<div className="card" style={{ padding: "14px 18px", marginBottom: 12, borderLeft: "3px solid var(--accent)" }}>
|
||||
<div className="row spread">
|
||||
<div>
|
||||
<div className="t-h2" style={{ marginBottom: 2 }}>{c.actor}</div>
|
||||
<div className="fg-2 t-caption mono">{srcLabel(c.source)} · {c.turns} turn{c.turns === 1 ? "" : "s"} · started {fmtWhen(c.started_at)}</div>
|
||||
</div>
|
||||
<div className="row gap3">
|
||||
<Metric label="Turns" value={String(c.turns)} />
|
||||
<Metric label="Tokens" value={String(c.total_tokens)} />
|
||||
<Metric label="Cost" value={fmtUSD(c.total_cost_usd)} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* transcript */}
|
||||
{groupTurns(detail.turns).map((group) => (
|
||||
<div key={group.runId} style={{ marginBottom: 18 }}>
|
||||
{group.userMessage && (
|
||||
<div className="row" style={{ flexDirection: "row-reverse", gap: 10, marginBottom: 12, alignItems: "flex-start" }}>
|
||||
<Avatar name={c.actor} size={28} />
|
||||
<div style={{ maxWidth: "74%", background: "var(--accent)", color: "var(--fg-on-accent)", padding: "9px 13px", borderRadius: "14px 14px 4px 14px", whiteSpace: "pre-wrap", wordBreak: "break-word", fontSize: 13.5, lineHeight: "20px" }}>{group.userMessage}</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="row" style={{ gap: 10, alignItems: "flex-start" }}>
|
||||
<div style={{ width: 28, height: 28, flex: "none", borderRadius: 8, background: "var(--accent)", display: "flex", alignItems: "center", justifyContent: "center", color: "#fff" }}><Icon name="sparkles" size={15} /></div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<AITurn
|
||||
group={group}
|
||||
open={openTurn === group.runId}
|
||||
segmentSpans={group.segments.map((s) => traces[s.trace_id]?.spans)}
|
||||
nodeLabels={nodeLabels}
|
||||
onToggle={() => toggle(group)}
|
||||
onRerun={() => rerun(group.runId)}
|
||||
rerunning={rerunning === group.runId}
|
||||
canRerun={!!c.workflow_id}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AITurn({ group, open, segmentSpans, nodeLabels, onToggle, onRerun, rerunning, canRerun }: {
|
||||
group: GroupedTurn;
|
||||
open: boolean;
|
||||
segmentSpans: (Span[] | undefined)[];
|
||||
nodeLabels: Record<string, NodeLabel>;
|
||||
onToggle: () => void;
|
||||
onRerun: () => void;
|
||||
rerunning: boolean;
|
||||
canRerun: boolean;
|
||||
}) {
|
||||
const errored = group.status === "error" || !!group.error;
|
||||
const awaiting = group.status === "interrupted"; // still paused, not yet resumed
|
||||
const multi = group.segments.length > 1;
|
||||
const placeholder = errored ? "(no response — this turn errored)"
|
||||
: awaiting ? "(paused — awaiting approval)"
|
||||
: "(no text response)";
|
||||
return (
|
||||
<div className="row" style={{ justifyContent: "flex-start" }}>
|
||||
<div style={{ maxWidth: "88%", width: "100%" }}>
|
||||
<button onClick={onToggle} className="col" style={{ width: "100%", textAlign: "left", cursor: "pointer", background: "var(--bg-2)", border: "1px solid var(--line)", borderRadius: "12px 12px 12px 3px", padding: "11px 14px" }}>
|
||||
<div style={{ whiteSpace: "pre-wrap", wordBreak: "break-word" }}>
|
||||
{group.aiResponse || <span className="fg-2">{placeholder}</span>}
|
||||
</div>
|
||||
<div className="row gap2" style={{ marginTop: 8, alignItems: "center" }}>
|
||||
{errored && <span className="pill pill-err" style={{ height: 16 }}>error</span>}
|
||||
{!errored && group.paused && <span className="pill pill-warn" style={{ height: 16 }}>{awaiting ? "awaiting approval" : "paused · resumed"}</span>}
|
||||
<span className="t-caption fg-2 mono">{group.latencyMs}ms · {group.tokens} tok · {fmtUSD(group.costUsd)}</span>
|
||||
<span className="grow" />
|
||||
<span className="row gap1 t-caption" style={{ color: "var(--accent)" }}>
|
||||
<Icon name="chevright" size={12} style={{ transform: open ? "rotate(90deg)" : "none", transition: "transform .12s" }} />
|
||||
{open ? "Hide trace" : "View trace"}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
<div className="row" style={{ justifyContent: "flex-end", padding: "5px 4px 0" }}>
|
||||
<button
|
||||
className="t-caption"
|
||||
onClick={onRerun}
|
||||
disabled={!canRerun || rerunning}
|
||||
title={canRerun ? "Run this turn again with the same input" : "The original workflow is unavailable"}
|
||||
style={{ color: "var(--accent)", background: "none", border: "none", cursor: canRerun && !rerunning ? "pointer" : "default", opacity: canRerun ? 1 : 0.5 }}
|
||||
>
|
||||
{rerunning ? "Running again…" : "Run again"}
|
||||
</button>
|
||||
</div>
|
||||
{group.error && <div className="mono-sm" style={{ color: "var(--err)", padding: "6px 4px", wordBreak: "break-word" }}>{group.error}</div>}
|
||||
{open && (
|
||||
<div className="col gap2" style={{ marginTop: 8 }}>
|
||||
{group.segments.map((seg, i) => {
|
||||
const spans = segmentSpans[i];
|
||||
return (
|
||||
<div key={seg.trace_id}>
|
||||
{/* Label each segment only when a pause split the run into more than one. */}
|
||||
{multi && (
|
||||
<div className="t-caption fg-2" style={{ margin: "2px 2px 6px", fontWeight: 600 }}>
|
||||
{seg.status === "interrupted" ? "Paused for approval" : i > 0 ? "Resumed" : "Started"} · {seg.latency_ms}ms
|
||||
</div>
|
||||
)}
|
||||
{spans ? <SpanWaterfall spans={spans} nodeLabels={nodeLabels} /> : <div className="fg-2 t-caption" style={{ padding: "10px 4px" }}>Loading trace…</div>}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function hasDetail(s: Span): boolean {
|
||||
return s.input != null || s.output != null || !!s.error;
|
||||
}
|
||||
|
||||
// A colored dot per span kind so the tree scans quickly. Node-level (chain) spans borrow their
|
||||
// node type's canvas color; the rest are keyed by kind. Mirrors the canvas IOType palette.
|
||||
function spanColor(s: Span, labels: Record<string, NodeLabel>): string {
|
||||
const t = labels[s.name]?.type;
|
||||
if (t && NODE_META[t]?.color) return NODE_META[t].color;
|
||||
const byKind: Record<string, string> = {
|
||||
llm: "var(--accent)", subagent: "var(--accent)", agent: "var(--accent)",
|
||||
tool: "var(--io-json)", retriever: "var(--io-vector)", embedding: "var(--io-vector)",
|
||||
};
|
||||
return byKind[s.kind] || "var(--fg-2)";
|
||||
}
|
||||
|
||||
// A span's two-line label: the friendly (canvas) NAME on top, and a mono sub-line carrying the
|
||||
// raw node id (when it differs from the name, so a big trace stays traceable) + kind + model.
|
||||
function spanLabel(s: Span, labels: Record<string, NodeLabel>): { primary: string; sub: string } {
|
||||
const hit = labels[s.name];
|
||||
const primary = hit?.label || s.name;
|
||||
const idPart = hit && hit.label !== s.name ? s.name : null; // show the id only if it adds info
|
||||
const modelPart = s.model && !s.name.includes(s.model) ? s.model : null;
|
||||
const sub = [idPart, s.kind, modelPart].filter(Boolean).join(" · ");
|
||||
return { primary, sub };
|
||||
}
|
||||
|
||||
// The per-turn span tree (graph nodes -> their model/tool/parser/subagent spans), rendered like an
|
||||
// IDE / DevTools element tree: fold arrows, indent guide-lines, and a click-to-open I/O panel per
|
||||
// span. Nesting comes straight from `parent_span_id`; the node id is resolved to its canvas name.
|
||||
function SpanWaterfall({ spans, nodeLabels }: { spans: Span[]; nodeLabels: Record<string, NodeLabel> }) {
|
||||
const [openDetail, setOpenDetail] = useState<Record<string, boolean>>({});
|
||||
const [collapsed, setCollapsed] = useState<Record<string, boolean>>({});
|
||||
const maxLatency = useMemo(() => Math.max(1, ...spans.map((s) => s.latency_ms)), [spans]);
|
||||
|
||||
// Build the parent -> children index and the root list (a span whose parent isn't in this set is
|
||||
// a root). Insertion order is preserved, so children stay in the order they were recorded.
|
||||
const { roots, byParent } = useMemo(() => {
|
||||
const ids = new Set(spans.map((s) => s.id));
|
||||
const byParent: Record<string, Span[]> = {};
|
||||
const roots: Span[] = [];
|
||||
for (const s of spans) {
|
||||
if (s.parent_span_id && ids.has(s.parent_span_id)) (byParent[s.parent_span_id] ||= []).push(s);
|
||||
else roots.push(s);
|
||||
}
|
||||
return { roots, byParent };
|
||||
}, [spans]);
|
||||
|
||||
const allWithKids = useMemo(() => Object.keys(byParent), [byParent]);
|
||||
const anyCollapsed = allWithKids.some((id) => collapsed[id]);
|
||||
const toggleAll = () =>
|
||||
setCollapsed(anyCollapsed ? {} : Object.fromEntries(allWithKids.map((id) => [id, true])));
|
||||
|
||||
const renderNode = (s: Span, depth: number) => {
|
||||
const kids = byParent[s.id] || [];
|
||||
const hasKids = kids.length > 0;
|
||||
const isCollapsed = !!collapsed[s.id];
|
||||
const expandable = hasDetail(s);
|
||||
const isOpen = !!openDetail[s.id];
|
||||
const { primary, sub } = spanLabel(s, nodeLabels);
|
||||
const dot = spanColor(s, nodeLabels);
|
||||
return (
|
||||
<div key={s.id}>
|
||||
<div
|
||||
className="row gap2"
|
||||
onClick={expandable ? () => setOpenDetail((o) => ({ ...o, [s.id]: !o[s.id] })) : undefined}
|
||||
style={{ padding: "7px 12px", borderBottom: "1px solid var(--line)", cursor: expandable ? "pointer" : "default", background: isOpen ? "var(--bg-3)" : "transparent" }}
|
||||
>
|
||||
{/* fold control (or a leaf dot) */}
|
||||
{hasKids ? (
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); setCollapsed((c) => ({ ...c, [s.id]: !c[s.id] })); }}
|
||||
title={isCollapsed ? "Expand" : "Collapse"}
|
||||
style={{ background: "none", border: "none", padding: 0, margin: 0, cursor: "pointer", flex: "none", display: "flex", alignItems: "center", color: "var(--fg-2)" }}
|
||||
>
|
||||
<Icon name="chevright" size={13} style={{ transform: isCollapsed ? "none" : "rotate(90deg)", transition: "transform .12s" }} />
|
||||
</button>
|
||||
) : (
|
||||
<span style={{ width: 13, flex: "none", display: "flex", justifyContent: "center" }}>
|
||||
<span style={{ width: 7, height: 7, borderRadius: "50%", background: dot, flex: "none" }} />
|
||||
</span>
|
||||
)}
|
||||
{/* label (indents with depth; shrinks naturally) */}
|
||||
<div className="grow" style={{ minWidth: 0 }}>
|
||||
<div className="row gap2" style={{ minWidth: 0 }}>
|
||||
{hasKids && <span style={{ width: 7, height: 7, borderRadius: "50%", background: dot, flex: "none" }} />}
|
||||
<span className="t-body-sm truncate" style={{ fontWeight: nodeLabels[s.name] ? 600 : 400 }}>{primary}</span>
|
||||
{expandable && <Icon name="chevright" size={11} style={{ color: "var(--fg-2)", flex: "none", transform: isOpen ? "rotate(90deg)" : "none", transition: "transform .12s" }} />}
|
||||
</div>
|
||||
{sub && <div className="t-caption fg-2 mono truncate" style={{ marginLeft: hasKids ? 15 : 0 }}>{sub}</div>}
|
||||
</div>
|
||||
{/* right-aligned metrics: a slim latency bar + tokens + cost (aligned regardless of depth) */}
|
||||
<div className="row gap2" style={{ flex: "none", alignItems: "center", justifyContent: "flex-end" }}>
|
||||
<div style={{ width: 60, height: 6, borderRadius: 3, background: "var(--bg-3)", overflow: "hidden", flex: "none" }} title={`${s.latency_ms}ms`}>
|
||||
<div style={{ height: "100%", borderRadius: 3, background: s.error ? "var(--err)" : dot, width: `${Math.max(4, (s.latency_ms / maxLatency) * 100)}%`, opacity: 0.85 }} />
|
||||
</div>
|
||||
<span className="mono-sm fg-2" style={{ width: 58, textAlign: "right" }}>{s.latency_ms}ms</span>
|
||||
<span className="mono-sm" style={{ width: 62, textAlign: "right" }}>{(s.input_tokens + s.output_tokens) > 0 ? `${s.input_tokens + s.output_tokens} tok` : ""}</span>
|
||||
<span className="t-caption fg-2" style={{ width: 60, textAlign: "right" }}>{s.cost_usd > 0 ? fmtUSD(s.cost_usd) : ""}</span>
|
||||
{s.error && <span className="pill pill-err" style={{ height: 16 }}>error</span>}
|
||||
</div>
|
||||
</div>
|
||||
{isOpen && <SpanDetail span={s} />}
|
||||
{/* children: a nested block with a left guide-line, like a code/HTML tree */}
|
||||
{hasKids && !isCollapsed && (
|
||||
<div style={{ marginLeft: 20, borderLeft: "1px solid var(--line)" }}>
|
||||
{kids.map((k) => renderNode(k, depth + 1))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="card" style={{ overflow: "hidden" }}>
|
||||
{spans.length === 0 ? (
|
||||
<div className="fg-2" style={{ padding: 22, textAlign: "center" }}>No spans recorded.</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="row spread" style={{ padding: "6px 12px", borderBottom: "1px solid var(--line)", background: "var(--bg-1)" }}>
|
||||
<span className="t-caption fg-2 mono">{spans.length} span{spans.length === 1 ? "" : "s"}</span>
|
||||
{allWithKids.length > 0 && (
|
||||
<button onClick={toggleAll} className="t-caption" style={{ background: "none", border: "none", cursor: "pointer", color: "var(--accent)" }}>
|
||||
{anyCollapsed ? "Expand all" : "Collapse all"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ maxHeight: 460, overflowY: "auto" }}>
|
||||
{roots.map((r) => renderNode(r, 0))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Metric({ label, value }: { label: string; value: string }) {
|
||||
return <div className="col" style={{ alignItems: "flex-end" }}><span className="t-display" style={{ fontSize: 18 }}>{value}</span><span className="t-micro">{label}</span></div>;
|
||||
}
|
||||
|
||||
// Is this a framed REST request/response envelope (vs a generic tool's raw args/return)?
|
||||
const isRestReq = (v: any) => v && typeof v === "object" && "method" in v && "url" in v;
|
||||
const isRestRes = (v: any) => v && typeof v === "object" && ("status" in v || "final_url" in v);
|
||||
const nonEmpty = (v: any) => v != null && !(typeof v === "object" && Object.keys(v).length === 0) && v !== "";
|
||||
|
||||
function Code({ value }: { value: any }) {
|
||||
const text = typeof value === "string" ? value : JSON.stringify(value, null, 2);
|
||||
return <pre className="mono-sm" style={{ margin: "2px 0 0", padding: "8px 10px", background: "var(--bg-2)", border: "1px solid var(--line)", borderRadius: 6, whiteSpace: "pre-wrap", wordBreak: "break-word", overflowX: "auto", maxHeight: 260, overflowY: "auto" }}>{text}</pre>;
|
||||
}
|
||||
|
||||
function Row({ label, value }: { label: string; value: any }) {
|
||||
if (!nonEmpty(value)) return null;
|
||||
return <div style={{ marginTop: 10 }}><div className="t-caption fg-2" style={{ marginBottom: 2, textTransform: "uppercase", letterSpacing: 0.4 }}>{label}</div><Code value={value} /></div>;
|
||||
}
|
||||
|
||||
function SpanDetail({ span }: { span: Span }) {
|
||||
const inp = span.input, out = span.output;
|
||||
return (
|
||||
<div style={{ padding: "12px 16px 16px 30px", borderBottom: "1px solid var(--line)", background: "var(--bg-3)" }}>
|
||||
{isRestReq(inp) ? (
|
||||
<>
|
||||
<div className="t-caption fg-2" style={{ textTransform: "uppercase", letterSpacing: 0.4, marginBottom: 4 }}>Request</div>
|
||||
<div className="mono-sm" style={{ wordBreak: "break-all" }}><span className="pill" style={{ marginRight: 6 }}>{inp.method}</span>{inp.url}</div>
|
||||
<Row label="Agent args" value={inp.args} />
|
||||
<Row label="Query" value={inp.query} />
|
||||
<Row label="Headers" value={inp.headers} />
|
||||
<Row label="Cookies" value={inp.cookies} />
|
||||
<Row label={`Body${inp.body_encoding ? ` · ${inp.body_encoding}` : ""}`} value={inp.body} />
|
||||
</>
|
||||
) : (
|
||||
<Row label="Agent input" value={inp} />
|
||||
)}
|
||||
|
||||
{isRestRes(out) ? (
|
||||
<div style={{ marginTop: 14 }}>
|
||||
<div className="row gap2" style={{ marginBottom: 4 }}>
|
||||
<span className="t-caption fg-2" style={{ textTransform: "uppercase", letterSpacing: 0.4 }}>Response</span>
|
||||
{out.status != null && <span className={out.status >= 400 ? "pill pill-err" : "pill"} style={{ height: 18 }}>{out.status}</span>}
|
||||
{out.latency_ms != null && <span className="mono-sm fg-2">{out.latency_ms}ms</span>}
|
||||
</div>
|
||||
{out.final_url && out.final_url !== inp?.url && <div className="mono-sm fg-2" style={{ wordBreak: "break-all", marginBottom: 4 }}>→ {out.final_url}</div>}
|
||||
<Row label="Body" value={out.response} />
|
||||
{out.error && <Row label="Error" value={out.error} />}
|
||||
</div>
|
||||
) : (
|
||||
<Row label="Output" value={out} />
|
||||
)}
|
||||
|
||||
{span.error && <div style={{ marginTop: 12 }}><div className="t-caption" style={{ color: "var(--err)", textTransform: "uppercase", letterSpacing: 0.4, marginBottom: 2 }}>Error</div><Code value={span.error} /></div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,590 @@
|
||||
"use client";
|
||||
/* Forge app shell: topbar, project sidebar, command palette, assistant. */
|
||||
import { ReactNode, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Icon } from "./icons";
|
||||
import { Avatar, Tile } from "./primitives";
|
||||
import { PROJECT_NAV, NavLeaf } from "@/lib/data";
|
||||
import { Markdown } from "./markdown";
|
||||
|
||||
/* ---------------- Theme hook ---------------- */
|
||||
export function useTheme(): [string, (t: string) => void] {
|
||||
const [theme, setTheme] = useState("light");
|
||||
useEffect(() => {
|
||||
const cur = document.documentElement.getAttribute("data-theme") || "light";
|
||||
setTheme(cur);
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
document.documentElement.setAttribute("data-theme", theme);
|
||||
}, [theme]);
|
||||
return [theme, setTheme];
|
||||
}
|
||||
|
||||
/* ---------------- Global rail ---------------- */
|
||||
function RailBtn({ icon, label, onClick, active }: { icon: string; label: string; onClick?: () => void; active?: boolean }) {
|
||||
const [hv, setHv] = useState(false);
|
||||
return (
|
||||
<div style={{ position: "relative" }} onMouseEnter={() => setHv(true)} onMouseLeave={() => setHv(false)}>
|
||||
<button className={"iconbtn" + (active ? " active" : "")} style={{ width: 38, height: 38 }} onClick={onClick}>
|
||||
<Icon name={icon} size={19} />
|
||||
</button>
|
||||
{hv && <div className="tooltip-pop" style={{ left: 46, top: 9 }}>{label}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function GlobalRail({ theme, setTheme, onCommand, onAssistant, onHome }: { theme: string; setTheme: (t: string) => void; onCommand: () => void; onAssistant: () => void; onHome: () => void }) {
|
||||
return (
|
||||
<div style={{ width: 56, flex: "none", background: "var(--bg-1)", borderRight: "1px solid var(--line)", display: "flex", flexDirection: "column", alignItems: "center", padding: "10px 0", gap: 4 }}>
|
||||
<button onClick={onHome} style={{ width: 34, height: 34, borderRadius: 9, background: "linear-gradient(140deg,var(--accent-bright),var(--accent-dim))", display: "flex", alignItems: "center", justifyContent: "center", color: "#fff", marginBottom: 8, boxShadow: "0 2px 10px var(--accent-glow)", border: "none", cursor: "pointer" }}>
|
||||
<Icon name="flame" size={20} />
|
||||
</button>
|
||||
<RailBtn icon="search" label="Search ⌘K" onClick={onCommand} />
|
||||
<RailBtn icon="sparkles" label="Forge Assistant" onClick={onAssistant} />
|
||||
<div style={{ flex: 1 }} />
|
||||
<RailBtn icon="theme" label={theme === "dark" ? "Light mode" : "Dark console"} onClick={() => setTheme(theme === "dark" ? "light" : "dark")} />
|
||||
<RailBtn icon="help" label="Ask the Forge Assistant" onClick={onAssistant} />
|
||||
<div style={{ marginTop: 6 }}><Avatar name="Riley Cho" size={30} /></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------------- Account menu (avatar + sign out) ---------------- */
|
||||
function AccountMenu() {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [me, setMe] = useState<{ email: string; role: string } | null>(null);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
useEffect(() => {
|
||||
let live = true;
|
||||
import("@/lib/api")
|
||||
.then(({ api }) => api.me())
|
||||
.then((m: any) => { if (live) setMe({ email: m.email, role: m.role }); })
|
||||
.catch(() => {});
|
||||
return () => { live = false; };
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const h = (e: MouseEvent) => { if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false); };
|
||||
window.addEventListener("mousedown", h);
|
||||
return () => window.removeEventListener("mousedown", h);
|
||||
}, [open]);
|
||||
async function signOut() {
|
||||
const { clearTokens } = await import("@/lib/api");
|
||||
clearTokens();
|
||||
window.location.reload();
|
||||
}
|
||||
return (
|
||||
<div ref={ref} style={{ position: "relative", flex: "none" }}>
|
||||
<button onClick={() => setOpen((o) => !o)} title="Account" aria-label="Account"
|
||||
style={{ border: "none", background: "none", cursor: "pointer", padding: 0, borderRadius: "50%", display: "flex" }}>
|
||||
<Avatar name={me?.email || "You"} size={30} />
|
||||
</button>
|
||||
{open && (
|
||||
<div className="card fade-in" style={{ position: "absolute", top: "100%", right: 0, marginTop: 6, zIndex: 6000, minWidth: 210, padding: 6, boxShadow: "var(--sh-pop)" }}>
|
||||
<div style={{ padding: "6px 9px 8px" }}>
|
||||
<div className="t-body-sm truncate" style={{ fontWeight: 600 }}>{me?.email || "Signed in"}</div>
|
||||
{me?.role && <div className="t-caption fg-2" style={{ textTransform: "capitalize", marginTop: 1 }}>{me.role}</div>}
|
||||
</div>
|
||||
<div className="divider" style={{ margin: "2px 0 4px" }} />
|
||||
<button onClick={signOut}
|
||||
style={{ display: "flex", alignItems: "center", gap: 9, width: "100%", textAlign: "left", padding: "7px 9px", border: "none", background: "none", cursor: "pointer", borderRadius: 6, fontSize: 13, fontFamily: "var(--font-ui)", color: "var(--err)" }}>
|
||||
<Icon name="logout" size={15} />Sign out
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------------- Topbar ---------------- */
|
||||
export interface Crumb { label: string; onClick?: () => void }
|
||||
export function Topbar({ crumbs, right, left, onCommand }: { crumbs: Crumb[]; right?: ReactNode; left?: ReactNode; onCommand: () => void }) {
|
||||
return (
|
||||
<div style={{ height: 52, flex: "none", borderBottom: "1px solid var(--line)", display: "flex", alignItems: "center", padding: "0 16px", gap: 12, background: "var(--bg-1)" }}>
|
||||
{left}
|
||||
<div className="row gap2" style={{ minWidth: 0 }}>
|
||||
{crumbs.map((c, i) => (
|
||||
<div key={i} className="row gap2" style={{ minWidth: 0 }}>
|
||||
{i > 0 && <Icon name="chevright" size={15} style={{ color: "var(--fg-2)", flex: "none" }} />}
|
||||
<button onClick={c.onClick} disabled={!c.onClick}
|
||||
style={{ background: "none", border: "none", cursor: c.onClick ? "pointer" : "default", padding: 0, fontFamily: i === crumbs.length - 1 ? "var(--font-display)" : "var(--font-ui)", fontSize: i === crumbs.length - 1 ? 16 : 13, fontWeight: 600, color: i === crumbs.length - 1 ? "var(--fg-0)" : "var(--fg-2)", whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>
|
||||
{c.label}
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div style={{ flex: 1 }} />
|
||||
<button className="row gap2" onClick={onCommand}
|
||||
style={{ height: 32, padding: "0 10px", border: "1px solid var(--line-strong)", borderRadius: 6, background: "var(--bg-1)", cursor: "pointer", color: "var(--fg-2)", fontSize: 12.5 }}>
|
||||
<Icon name="search" size={15} />
|
||||
<span style={{ width: 110, textAlign: "left" }}>Search…</span>
|
||||
<span className="kbd">⌘K</span>
|
||||
</button>
|
||||
{right}
|
||||
<AccountMenu />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------------- Project sidebar ---------------- */
|
||||
export function ProjectSidebar({ project, active, onNav, onBack, refreshKey }: { project: any; active: string; onNav: (id: string) => void; onBack: () => void; refreshKey?: any }) {
|
||||
const [counts, setCounts] = useState<Record<string, number>>({});
|
||||
// api.ts fires this after any create/delete of a counted resource, so the badges
|
||||
// refresh immediately instead of waiting for a page reload.
|
||||
const [countsBump, setCountsBump] = useState(0);
|
||||
useEffect(() => {
|
||||
const onChange = () => setCountsBump((n) => n + 1);
|
||||
window.addEventListener("forge:counts-changed", onChange);
|
||||
return () => window.removeEventListener("forge:counts-changed", onChange);
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
const pid = project?.id;
|
||||
if (!pid) return;
|
||||
let live = true;
|
||||
const refresh = async () => {
|
||||
// One cheap counts call (COUNT(*) per resource) instead of fetching six full lists
|
||||
// just to read their `.length`. Re-runs on create/delete via countsBump so badges
|
||||
// stay in sync. A short poll also keeps the agent-inbox badge current when a workflow
|
||||
// opens a handoff while the operator is on another screen.
|
||||
const { api } = await import("@/lib/api");
|
||||
try {
|
||||
const c = await api.projectCounts(pid);
|
||||
if (live) setCounts(c as unknown as Record<string, number>);
|
||||
} catch {
|
||||
if (live) setCounts({});
|
||||
}
|
||||
};
|
||||
refresh();
|
||||
const timer = window.setInterval(refresh, 15_000);
|
||||
return () => { live = false; window.clearInterval(timer); };
|
||||
}, [project?.id, refreshKey, countsBump]);
|
||||
const renderLeaf = (n: NavLeaf) => {
|
||||
const on = active === n.id;
|
||||
const count = n.countKey ? counts[n.countKey] : undefined;
|
||||
return (
|
||||
<button key={n.id} onClick={() => onNav(n.id)} title={n.help || n.label} className={"sidenav-item" + (on ? " active" : "")}
|
||||
style={{ display: "flex", alignItems: "center", gap: 10, width: "100%", height: 34, padding: "0 10px", marginBottom: 1, borderRadius: 8, border: "none", cursor: "pointer", textAlign: "left", color: on ? "var(--accent)" : "var(--fg-1)", fontSize: 13, fontWeight: on ? 600 : 500, fontFamily: "var(--font-ui)", transition: "color var(--dur-fast)" }}>
|
||||
<Icon name={n.icon} size={16} style={{ flex: "none" }} />
|
||||
<span className="grow truncate">{n.label}</span>
|
||||
{count != null && count > 0 && <span className="badge" style={on ? { background: "var(--accent-glow)", color: "var(--accent)" } : {}}>{count}</span>}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
// Settings is pinned to the bottom (rendered in the footer below), so drop it from the scroll list.
|
||||
const settingsLeaf = PROJECT_NAV.find((e): e is NavLeaf => "id" in e && e.id === "settings");
|
||||
return (
|
||||
<div style={{ width: 224, flex: "none", background: "var(--bg-1)", borderRight: "1px solid var(--line)", display: "flex", flexDirection: "column", minHeight: 0 }}>
|
||||
<button onClick={onBack} className="row gap2" style={{ height: 52, flex: "none", padding: "0 14px", background: "none", border: "none", borderBottom: "1px solid var(--line)", cursor: "pointer", textAlign: "left", alignItems: "center" }}>
|
||||
<div className="t-h2 truncate" style={{ minWidth: 0, flex: 1 }}>{project?.name}</div>
|
||||
<Icon name="chevdown" size={15} style={{ color: "var(--fg-2)", flex: "none" }} />
|
||||
</button>
|
||||
<nav className="scroll-y" style={{ flex: 1, minHeight: 0, padding: 8 }}>
|
||||
{PROJECT_NAV.map((entry) => {
|
||||
if ("id" in entry && entry.id === "settings") return null; // pinned to the footer
|
||||
if ("section" in entry) {
|
||||
// Static section heading (like the design) - no collapse toggle.
|
||||
return (
|
||||
<div key={entry.section}>
|
||||
<div className="t-micro" style={{ letterSpacing: ".06em", textTransform: "uppercase", padding: "14px 10px 5px" }}>{entry.section}</div>
|
||||
{entry.items.map(renderLeaf)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return renderLeaf(entry);
|
||||
})}
|
||||
</nav>
|
||||
{/* Settings pinned to the bottom: sits above the scrolling nav (z-index + solid bg + top
|
||||
shadow) so nav items scroll behind it on short viewports. */}
|
||||
{settingsLeaf && (
|
||||
<div style={{ flex: "none", position: "relative", zIndex: 2, padding: 8, borderTop: "1px solid var(--line)", background: "var(--bg-1)", boxShadow: "0 -6px 12px -8px rgba(0,0,0,.18)" }}>
|
||||
{renderLeaf(settingsLeaf)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------------- Command palette ---------------- */
|
||||
export function CommandPalette({ open, onClose, onGo, projects }: { open: boolean; onClose: () => void; onGo: (v: any) => void; projects: { id: string; name: string }[] }) {
|
||||
const [q, setQ] = useState("");
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
useEffect(() => {
|
||||
if (open) { setQ(""); setTimeout(() => inputRef.current?.focus(), 30); }
|
||||
}, [open]);
|
||||
const cmds = useMemo(() => {
|
||||
const first = projects[0]?.id || "p_support";
|
||||
const list = [
|
||||
{ sec: "Go to", label: "Home / Dashboard", icon: "dashboard", go: { name: "dashboard" } },
|
||||
{ sec: "Go to", label: "Workflow Canvas - Support Router", icon: "workflows", go: { name: "project", project: first, screen: "workflow-canvas" } },
|
||||
{ sec: "Go to", label: "Tool Builder", icon: "tools", go: { name: "project", project: first, screen: "tool-builder" } },
|
||||
{ sec: "Go to", label: "Agent Config", icon: "agents", go: { name: "project", project: first, screen: "agent-config" } },
|
||||
{ sec: "Go to", label: "Playground", icon: "playground", go: { name: "project", project: first, screen: "playground" } },
|
||||
{ sec: "Go to", label: "Traces", icon: "traces", go: { name: "project", project: first, screen: "traces" } },
|
||||
{ sec: "Go to", label: "Knowledge", icon: "knowledge", go: { name: "project", project: first, screen: "knowledge" } },
|
||||
{ sec: "Go to", label: "Settings & Secrets", icon: "secret", go: { name: "project", project: first, screen: "settings" } },
|
||||
{ sec: "Actions", label: "New project…", icon: "plus", go: { name: "onboarding" } },
|
||||
];
|
||||
projects.forEach((p) => list.push({ sec: "Projects", label: p.name, icon: "layers", go: { name: "project", project: p.id, screen: "overview" } }));
|
||||
if (!q) return list;
|
||||
return list.filter((c) => c.label.toLowerCase().includes(q.toLowerCase()));
|
||||
}, [q, projects]);
|
||||
const groups = useMemo(() => { const g: Record<string, any[]> = {}; cmds.forEach((c) => (g[c.sec] = g[c.sec] || []).push(c)); return g; }, [cmds]);
|
||||
if (!open) return null;
|
||||
return (
|
||||
<div className="fade-in" style={{ position: "fixed", inset: 0, zIndex: 8500, background: "rgba(8,10,14,.45)", backdropFilter: "blur(3px)", display: "flex", justifyContent: "center", paddingTop: "12vh" }} onMouseDown={onClose}>
|
||||
<div className="card fade-up" style={{ width: 600, maxWidth: "92vw", height: "fit-content", maxHeight: "70vh", boxShadow: "var(--sh-pop)", display: "flex", flexDirection: "column", overflow: "hidden" }} onMouseDown={(e) => e.stopPropagation()}>
|
||||
<div className="row gap2" style={{ padding: "12px 14px", borderBottom: "1px solid var(--line)" }}>
|
||||
<Icon name="search" size={18} style={{ color: "var(--fg-2)" }} />
|
||||
<input ref={inputRef} value={q} onChange={(e) => setQ(e.target.value)} placeholder="Search projects, workflows, tools, actions…"
|
||||
style={{ flex: 1, border: "none", outline: "none", background: "none", fontSize: 15, color: "var(--fg-0)", fontFamily: "var(--font-ui)" }} />
|
||||
<span className="kbd">esc</span>
|
||||
</div>
|
||||
<div className="scroll-y" style={{ padding: 8 }}>
|
||||
{Object.entries(groups).map(([sec, items]) => (
|
||||
<div key={sec} style={{ marginBottom: 6 }}>
|
||||
<div className="t-micro" style={{ padding: "6px 8px 4px" }}>{sec}</div>
|
||||
{items.map((c, i) => (
|
||||
<button key={i} onClick={() => { onGo(c.go); onClose(); }} className="row gap3"
|
||||
style={{ width: "100%", padding: "8px", border: "none", background: "none", cursor: "pointer", borderRadius: 7, textAlign: "left", color: "var(--fg-1)" }}
|
||||
onMouseEnter={(e) => (e.currentTarget.style.background = "var(--bg-3)")}
|
||||
onMouseLeave={(e) => (e.currentTarget.style.background = "none")}>
|
||||
<Icon name={c.icon} size={16} style={{ color: "var(--fg-2)" }} />
|
||||
<span style={{ fontSize: 13.5, color: "var(--fg-0)" }}>{c.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------------- Forge Assistant ---------------- */
|
||||
interface AsstMsg { role: "user" | "assistant"; content: string; thinking?: string; thinkSecs?: number }
|
||||
interface AsstStep { name: string; result?: string; turn: number }
|
||||
interface AsstTodo { content?: string; status?: string; [k: string]: any }
|
||||
|
||||
// Friendly labels for the inline "current step" line (the Steps drawer keeps raw names).
|
||||
const TOOL_LABELS: Record<string, string> = {
|
||||
write_todos: "Planning the steps",
|
||||
list_resources: "Reviewing the project",
|
||||
describe_workflow: "Reading the workflow",
|
||||
list_node_types: "Checking available nodes",
|
||||
get_node_schema: "Checking node options",
|
||||
list_middleware_types: "Checking middleware",
|
||||
read_file: "Reading the platform guide",
|
||||
create_agent_preset: "Creating an agent",
|
||||
create_builtin_tool: "Adding a tool",
|
||||
create_rest_tool: "Adding a REST tool",
|
||||
create_auth_provider: "Adding an auth provider",
|
||||
add_qa_pair: "Adding a Q&A pair",
|
||||
add_knowledge_text: "Adding knowledge",
|
||||
create_grounded_workflow: "Building the workflow",
|
||||
create_intent_router_workflow: "Building the workflow",
|
||||
create_custom_workflow: "Building the workflow",
|
||||
add_human_review: "Adding a human-approval step",
|
||||
test_workflow: "Testing the workflow",
|
||||
evaluate_build: "Reviewing the result",
|
||||
delete_workflow: "Removing a workflow",
|
||||
};
|
||||
const prettyTool = (name: string) => TOOL_LABELS[name] || name.replace(/_/g, " ");
|
||||
|
||||
function newThreadId() {
|
||||
return `panel-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
}
|
||||
|
||||
export function AssistantPanel({ open, onClose, project, onMutate }: { open: boolean; onClose: () => void; project?: { id: string; name: string } | null; onMutate?: () => void }) {
|
||||
const [msgs, setMsgs] = useState<AsstMsg[]>([]);
|
||||
const [input, setInput] = useState("");
|
||||
const [streaming, setStreaming] = useState("");
|
||||
// GPT-style thinking: while the agent works through intermediate narration, the text
|
||||
// streams as dim auto-scrolling lines under a "Thinking" label; on finish it collapses
|
||||
// to "Thought for Xs" and only the final segment stays as the answer bubble.
|
||||
const [liveThink, setLiveThink] = useState<string | null>(null);
|
||||
const [openThought, setOpenThought] = useState<number | null>(null);
|
||||
// Tool/plan activity lives in the collapsible Steps drawer above the composer
|
||||
// (not as chips in the transcript).
|
||||
const [steps, setSteps] = useState<AsstStep[]>([]);
|
||||
const [currentTool, setCurrentTool] = useState<string | null>(null);
|
||||
const [stepsOpen, setStepsOpen] = useState(false);
|
||||
const [expandedStep, setExpandedStep] = useState<number | null>(null);
|
||||
const [todos, setTodos] = useState<AsstTodo[]>([]);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [pendingApproval, setPendingApproval] = useState<string | null>(null); // human-readable prompt
|
||||
const turnRef = useRef(0);
|
||||
// Server-side conversation: the backend checkpointer holds the thread (history,
|
||||
// plan, files), so each turn sends ONLY the new message under this thread id.
|
||||
const threadRef = useRef<string>(newThreadId());
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const thinkRef = useRef<HTMLDivElement>(null);
|
||||
const taRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
useEffect(() => { scrollRef.current?.scrollTo({ top: 1e9, behavior: "smooth" }); }, [msgs, streaming, currentTool, pendingApproval, liveThink !== null]);
|
||||
// Auto-grow the composer up to ~6 lines, then scroll inside it.
|
||||
useEffect(() => {
|
||||
const ta = taRef.current;
|
||||
if (!ta) return;
|
||||
ta.style.height = "auto";
|
||||
ta.style.height = `${Math.min(ta.scrollHeight, 140)}px`;
|
||||
}, [input]);
|
||||
// The thinking ticker auto-scrolls its own little window as text streams.
|
||||
useEffect(() => { if (thinkRef.current) thinkRef.current.scrollTop = thinkRef.current.scrollHeight; }, [liveThink]);
|
||||
// New project = new conversation thread.
|
||||
useEffect(() => { threadRef.current = newThreadId(); setMsgs([]); setSteps([]); setTodos([]); setPendingApproval(null); setLiveThink(null); }, [project?.id]);
|
||||
|
||||
function describeInterrupt(data: any): string {
|
||||
const flat = (x: any): any[] => (Array.isArray(x) ? x.flatMap(flat) : [x]);
|
||||
for (const item of flat(data?.interrupts ?? data ?? [])) {
|
||||
const v = item && typeof item === "object" && "value" in item ? item.value : item;
|
||||
const reqs = v?.action_requests || (v?.action_request ? [v.action_request] : v?.action ? [v] : null);
|
||||
if (reqs) {
|
||||
return reqs.map((r: any) => r.description || `${r.action || r.name || "action"}(${JSON.stringify(r.args || {}).slice(0, 100)})`).join("; ");
|
||||
}
|
||||
if (v?.prompt) return String(v.prompt);
|
||||
}
|
||||
return "The assistant wants to perform a sensitive action.";
|
||||
}
|
||||
|
||||
async function streamTurn(body: Record<string, unknown>, url: string) {
|
||||
if (!project) return;
|
||||
setStreaming(""); setLiveThink(null); setBusy(true); setPendingApproval(null);
|
||||
const turn = ++turnRef.current;
|
||||
const turnStart = Date.now();
|
||||
let mutated = false; let interruptPrompt: string | null = null; let acted = false;
|
||||
// Segmentation: each AI message in the agent loop is one segment (the backend tags
|
||||
// tokens with the message id; a tool call also closes the segment). Everything
|
||||
// before the LAST segment is "thinking"; the last segment is the answer.
|
||||
let cur = ""; // current segment
|
||||
let segId: string | null = null;
|
||||
const done: string[] = []; // completed (thinking) segments
|
||||
let thinking = false; // becomes true on first tool call / segment change
|
||||
|
||||
const render = () => {
|
||||
if (thinking) {
|
||||
setStreaming("");
|
||||
setLiveThink([...done, cur].filter(Boolean).join("\n\n"));
|
||||
} else {
|
||||
setStreaming(cur);
|
||||
}
|
||||
};
|
||||
const closeSegment = () => {
|
||||
if (cur.trim()) done.push(cur.trim());
|
||||
cur = "";
|
||||
};
|
||||
const activateThinking = () => { thinking = true; };
|
||||
|
||||
try {
|
||||
const { openSSE } = await import("@/lib/api");
|
||||
await openSSE(url, (f) => {
|
||||
if (f.event === "messages" && f.data?.content) {
|
||||
const id = f.data.id || null;
|
||||
if (segId && id && id !== segId) { closeSegment(); activateThinking(); }
|
||||
if (id) segId = id;
|
||||
cur += f.data.content;
|
||||
render();
|
||||
}
|
||||
else if (f.event === "tool" && f.data?.name) {
|
||||
acted = true;
|
||||
closeSegment(); activateThinking(); render();
|
||||
setCurrentTool(f.data.name);
|
||||
setSteps((s) => [...s, { name: f.data.name, result: f.data.result, turn }]);
|
||||
}
|
||||
else if (f.event === "todos" && Array.isArray(f.data?.todos)) { acted = true; setTodos(f.data.todos); }
|
||||
else if (f.event === "interrupt") { interruptPrompt = describeInterrupt(f.data); }
|
||||
else if (f.event === "done") { mutated = (f.data?.mutated || []).length > 0; }
|
||||
else if (f.event === "error") { cur += `\n⚠ ${f.data?.message || "assistant error"}`; render(); }
|
||||
}, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) });
|
||||
} catch (e: any) {
|
||||
cur += `\n⚠ ${e.message || e}`;
|
||||
} finally {
|
||||
const answer = cur.trim();
|
||||
const thought = thinking ? done.join("\n\n") : "";
|
||||
const secs = Math.max(1, Math.round((Date.now() - turnStart) / 1000));
|
||||
if (answer || thought || acted) {
|
||||
setMsgs((m) => [...m, {
|
||||
role: "assistant",
|
||||
content: answer || (interruptPrompt ? "(paused for your approval)" : "(done - see Steps for details)"),
|
||||
...(thought ? { thinking: thought, thinkSecs: secs } : {}),
|
||||
}]);
|
||||
}
|
||||
setStreaming(""); setLiveThink(null); setCurrentTool(null); setBusy(false);
|
||||
setPendingApproval(interruptPrompt);
|
||||
if (mutated) onMutate?.();
|
||||
}
|
||||
}
|
||||
|
||||
async function send(text: string) {
|
||||
const q = text.trim();
|
||||
if (!q || busy || !project) return;
|
||||
setMsgs((m) => [...m, { role: "user", content: q }]); setInput("");
|
||||
const { api } = await import("@/lib/api");
|
||||
await streamTurn({ message: q, thread_id: threadRef.current }, api.assistantStreamUrl(project.id));
|
||||
}
|
||||
|
||||
async function decide(decision: "approve" | "reject") {
|
||||
if (!project || busy) return;
|
||||
setMsgs((m) => [...m, { role: "user", content: decision === "approve" ? "✓ Approved" : "✕ Rejected" }]);
|
||||
const { api } = await import("@/lib/api");
|
||||
await streamTurn({ thread_id: threadRef.current, decision }, api.assistantResumeUrl(project.id));
|
||||
}
|
||||
|
||||
function resetChat() {
|
||||
threadRef.current = newThreadId();
|
||||
setMsgs([]); setSteps([]); setTodos([]); setPendingApproval(null); setStreaming(""); setCurrentTool(null); setStepsOpen(false);
|
||||
}
|
||||
|
||||
const suggestions = ["How does my workflow work?", "Build a grounded support workflow", "What's in this project?"];
|
||||
|
||||
if (!open) return null;
|
||||
return (
|
||||
<aside style={{ width: 380, maxWidth: "38vw", flex: "none", background: "var(--bg-1)", borderRight: "1px solid var(--line)", display: "flex", flexDirection: "column", minHeight: 0 }}>
|
||||
<div className="row spread" style={{ padding: "12px 14px", borderBottom: "1px solid var(--line)", flex: "none" }}>
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<div className="t-h2">Forge Assistant</div>
|
||||
<div className="fg-2 t-caption truncate">{project ? `Building in ${project.name}` : "Open a project to build"}</div>
|
||||
</div>
|
||||
<div className="row gap1">
|
||||
<button className="iconbtn" title="New conversation" onClick={resetChat} disabled={busy}><Icon name="refresh" size={15} /></button>
|
||||
<button className="iconbtn" onClick={onClose}><Icon name="x" size={16} /></button>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ flex: 1, display: "flex", flexDirection: "column", minHeight: 0 }}>
|
||||
<div ref={scrollRef} className="scroll-y col gap4" style={{ padding: 16, flex: 1, minHeight: 0 }}>
|
||||
{msgs.length === 0 && !streaming && (
|
||||
<div className="col gap2" style={{ color: "var(--fg-2)" }}>
|
||||
<div className="row gap2"><Tile icon="sparkles" color="var(--accent)" size={28} /><div style={{ fontSize: 13, lineHeight: "19px", color: "var(--fg-1)" }}>I can build tools, auth providers, Q&A, knowledge, and whole workflows - and explain how Forge works. What should we build?</div></div>
|
||||
</div>
|
||||
)}
|
||||
{msgs.map((m, i) => (
|
||||
<div key={i} className="col" style={{ gap: 4 }}>
|
||||
{m.thinking && (
|
||||
<div style={{ paddingLeft: 37 }}>
|
||||
<button onClick={() => setOpenThought(openThought === i ? null : i)}
|
||||
style={{ border: "none", background: "none", padding: 0, cursor: "pointer", fontSize: 12, color: "var(--fg-2)", display: "inline-flex", alignItems: "center", gap: 4 }}>
|
||||
Thought for {m.thinkSecs}s
|
||||
<Icon name={openThought === i ? "chevdown" : "chevright"} size={11} />
|
||||
</button>
|
||||
{openThought === i && (
|
||||
<div style={{ marginTop: 4, fontSize: 12, lineHeight: "18px", color: "var(--fg-2)", whiteSpace: "pre-wrap", overflowWrap: "anywhere", maxHeight: 260, overflowY: "auto" }} className="no-scrollbar">
|
||||
{m.thinking}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="row" style={{ gap: 9, alignItems: "flex-start", flexDirection: m.role === "user" ? "row-reverse" : "row" }}>
|
||||
{m.role === "assistant" ? <Tile icon="sparkles" color="var(--accent)" size={28} /> : <Avatar name="You" size={28} />}
|
||||
<div style={{ maxWidth: 280, padding: "9px 12px", borderRadius: 11, fontSize: 13, lineHeight: "19px", overflowWrap: "anywhere", whiteSpace: m.role === "user" ? "pre-wrap" : "normal", background: m.role === "user" ? "var(--accent)" : "var(--bg-3)", color: m.role === "user" ? "var(--fg-on-accent)" : "var(--fg-0)", borderTopRightRadius: m.role === "user" ? 3 : 11, borderTopLeftRadius: m.role === "assistant" ? 3 : 11 }}>{m.role === "assistant" ? <Markdown>{m.content}</Markdown> : m.content}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{pendingApproval && !busy && (
|
||||
<div className="card col gap2" style={{ padding: 12, borderColor: "var(--warn)" }}>
|
||||
<div className="row gap2" style={{ alignItems: "center" }}><Icon name="bolt" size={14} style={{ color: "var(--warn)" }} /><span className="t-h3">Approval required</span></div>
|
||||
<div className="t-body-sm fg-1" style={{ overflowWrap: "anywhere" }}>{pendingApproval}</div>
|
||||
<div className="row gap2">
|
||||
<button className="btn btn-primary btn-sm" onClick={() => decide("approve")}>Approve</button>
|
||||
<button className="btn btn-secondary btn-sm" onClick={() => decide("reject")}>Reject</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{busy && (
|
||||
(liveThink !== null || currentTool) ? (
|
||||
/* Working: dim reasoning streams up in a small auto-scrolling window AND the
|
||||
current step shows on a single line that updates in place as the run moves
|
||||
from one tool to the next. Collapses to "Thought for Xs" on finish. */
|
||||
<div style={{ paddingLeft: 37 }} className="col gap1">
|
||||
<div style={{ fontSize: 12, color: "var(--fg-2)", display: "inline-flex", alignItems: "center", gap: 5 }}>
|
||||
Thinking
|
||||
<span style={{ display: "inline-block", width: 5, height: 5, borderRadius: "50%", background: "var(--fg-2)", animation: "blink 1s steps(1) infinite" }} />
|
||||
</div>
|
||||
{liveThink && (
|
||||
<div ref={thinkRef} className="no-scrollbar" style={{
|
||||
maxHeight: 72, overflowY: "auto", fontSize: 12, lineHeight: "18px",
|
||||
color: "var(--fg-2)", opacity: 0.72, whiteSpace: "pre-wrap", overflowWrap: "anywhere",
|
||||
WebkitMaskImage: "linear-gradient(to bottom, transparent 0, black 22px)",
|
||||
maskImage: "linear-gradient(to bottom, transparent 0, black 22px)",
|
||||
}}>
|
||||
{liveThink}
|
||||
</div>
|
||||
)}
|
||||
{currentTool && (
|
||||
<div className="row gap2" style={{ alignItems: "center", fontSize: 12, color: "var(--fg-1)" }}>
|
||||
<Icon name="refresh" size={11} style={{ color: "var(--accent)", animation: "spin 1s linear infinite" }} />
|
||||
<span>{prettyTool(currentTool)}…</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="row" style={{ gap: 9, alignItems: "flex-start" }}>
|
||||
<Tile icon="sparkles" color="var(--accent)" size={28} />
|
||||
<div style={{ maxWidth: 280, padding: "9px 12px", borderRadius: 11, fontSize: 13, lineHeight: "19px", overflowWrap: "anywhere", background: "var(--bg-3)", color: "var(--fg-0)", borderTopLeftRadius: 3 }}>
|
||||
{streaming ? <Markdown>{streaming}</Markdown> : "…"}
|
||||
<span style={{ display: "inline-block", width: 6, height: 13, background: "var(--accent)", marginLeft: 2, verticalAlign: "-2px", animation: "blink 1s steps(1) infinite" }} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
{msgs.length === 0 && (
|
||||
<div className="row gap2 wrap" style={{ marginTop: 4 }}>
|
||||
{suggestions.map((s) => (
|
||||
<button key={s} className="chip" style={{ cursor: project ? "pointer" : "not-allowed", opacity: project ? 1 : 0.5 }} onClick={() => send(s)} disabled={!project}>{s}</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* Sticky, togglable Steps drawer: plan + every tool call, out of the chat flow. */}
|
||||
{(steps.length > 0 || todos.length > 0) && (
|
||||
<div style={{ flex: "none", borderTop: "1px solid var(--line)", background: "var(--bg-1)" }}>
|
||||
<button className="row spread" onClick={() => setStepsOpen((o) => !o)}
|
||||
style={{ width: "100%", padding: "8px 14px", border: "none", background: "none", cursor: "pointer", alignItems: "center" }}>
|
||||
<span className="row gap2" style={{ alignItems: "center", fontSize: 12.5, fontWeight: 650, color: "var(--fg-1)" }}>
|
||||
<Icon name="list" size={13} />
|
||||
Steps ({steps.length}){todos.length > 0 ? ` · plan ${todos.filter((t) => t.status === "completed").length}/${todos.length}` : ""}
|
||||
{busy && currentTool && <span className="mono-sm" style={{ color: "var(--accent)", fontWeight: 450 }}> · {currentTool}…</span>}
|
||||
</span>
|
||||
<Icon name={stepsOpen ? "chevdown" : "chevup"} size={14} style={{ color: "var(--fg-2)" }} />
|
||||
</button>
|
||||
{stepsOpen && (
|
||||
<div className="scroll-y col gap1" style={{ maxHeight: 220, padding: "0 14px 10px" }}>
|
||||
{todos.length > 0 && (
|
||||
<div className="col gap1" style={{ paddingBottom: 6, borderBottom: "1px solid var(--line)", marginBottom: 4 }}>
|
||||
<div className="t-micro">Plan</div>
|
||||
{todos.map((t, i) => (
|
||||
<div key={i} className="row gap2" style={{ alignItems: "center", fontSize: 12, color: t.status === "completed" ? "var(--fg-2)" : "var(--fg-0)" }}>
|
||||
<Icon name={t.status === "completed" ? "check" : t.status === "in_progress" ? "refresh" : "minus"} size={11}
|
||||
style={{ color: t.status === "completed" ? "var(--ok)" : t.status === "in_progress" ? "var(--accent)" : "var(--fg-2)" }} />
|
||||
<span style={{ textDecoration: t.status === "completed" ? "line-through" : "none" }}>{t.content || JSON.stringify(t)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{steps.map((s, i) => (
|
||||
<div key={i} className="col" style={{ gap: 2 }}>
|
||||
<button className="row gap2" onClick={() => setExpandedStep(expandedStep === i ? null : i)}
|
||||
style={{ border: "none", background: "none", cursor: s.result ? "pointer" : "default", padding: "2px 0", alignItems: "center", textAlign: "left" }}>
|
||||
<Icon name="check" size={11} style={{ color: "var(--ok)" }} />
|
||||
<span className="mono-sm" style={{ color: "var(--fg-1)" }}>{s.name}</span>
|
||||
{s.result && <Icon name={expandedStep === i ? "chevdown" : "chevright"} size={11} style={{ color: "var(--fg-2)" }} />}
|
||||
</button>
|
||||
{expandedStep === i && s.result && (
|
||||
<pre className="mono-sm" style={{ margin: "0 0 4px 18px", padding: "8px 10px", borderRadius: 8, background: "var(--bg-3)", fontSize: 11, lineHeight: "16px", whiteSpace: "pre-wrap", overflowWrap: "anywhere", maxHeight: 160, overflow: "auto" }}>{s.result}</pre>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div style={{ padding: 12, borderTop: "1px solid var(--line)", background: "var(--bg-1)", flex: "none" }}>
|
||||
<div className="row gap2" style={{ alignItems: "flex-end", background: "var(--bg-3)", border: "1px solid var(--line)", borderRadius: 10, padding: "6px 6px 6px 12px" }}>
|
||||
<textarea ref={taRef} value={input} onChange={(e) => setInput(e.target.value)} rows={1}
|
||||
onKeyDown={(e) => { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); send(input); } }}
|
||||
placeholder={project ? "Ask or instruct… (Shift+Enter for a new line)" : "Open a project first"} disabled={!project || busy}
|
||||
style={{ flex: 1, minWidth: 0, border: "none", background: "none", outline: "none", resize: "none", overflowY: "auto", maxHeight: 140, fontSize: 13, lineHeight: "19px", color: "var(--fg-0)", fontFamily: "var(--font-ui)" }} />
|
||||
<button className="btn btn-primary btn-sm" onClick={() => send(input)} disabled={!project || busy} style={{ flex: "none" }}><Icon name={busy ? "refresh" : "bolt"} size={14} style={busy ? { animation: "spin 1s linear infinite" } : {}} /></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
"use client";
|
||||
/* Reusable version-history drawer: lists recent versions of an entity (workflow, agent,
|
||||
tool, …) with author + timestamp + label and a Restore action. Wired to the
|
||||
/v1/versions/{entity_type}/{entity_id} endpoints. Drop <VersionHistory .../> into any
|
||||
editor toolbar. */
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { Icon } from "./icons";
|
||||
import { Drawer } from "./primitives";
|
||||
import { api, EntityType, EntityVersion } from "@/lib/api";
|
||||
|
||||
/** Compact "3m ago" / "2d ago" relative time, falling back to a locale date. */
|
||||
function relTime(iso?: string | null): string {
|
||||
if (!iso) return "";
|
||||
const t = new Date(iso).getTime();
|
||||
if (Number.isNaN(t)) return String(iso);
|
||||
const s = Math.round((Date.now() - t) / 1000);
|
||||
if (s < 60) return "just now";
|
||||
const m = Math.round(s / 60);
|
||||
if (m < 60) return `${m}m ago`;
|
||||
const h = Math.round(m / 60);
|
||||
if (h < 24) return `${h}h ago`;
|
||||
const d = Math.round(h / 24);
|
||||
if (d < 30) return `${d}d ago`;
|
||||
return new Date(iso).toLocaleDateString();
|
||||
}
|
||||
|
||||
export function VersionHistory({
|
||||
entityType,
|
||||
entityId,
|
||||
entityLabel,
|
||||
onRestored,
|
||||
buttonClassName = "btn btn-secondary btn-sm",
|
||||
buttonLabel = "History",
|
||||
allowRestore = true,
|
||||
}: {
|
||||
entityType: EntityType;
|
||||
entityId?: string | null;
|
||||
entityLabel?: string;
|
||||
onRestored?: () => void;
|
||||
buttonClassName?: string;
|
||||
buttonLabel?: string;
|
||||
// Some entities (e.g. knowledge sources) version only their config metadata, not the
|
||||
// embedded content, so a "restore" would be misleading - show read-only history instead.
|
||||
allowRestore?: boolean;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [rows, setRows] = useState<EntityVersion[] | null>(null);
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
const [restoring, setRestoring] = useState<number | null>(null);
|
||||
const [expanded, setExpanded] = useState<number | null>(null);
|
||||
const [snapshots, setSnapshots] = useState<Record<number, string>>({});
|
||||
|
||||
const load = useCallback(() => {
|
||||
if (!entityId) return;
|
||||
setRows(null);
|
||||
setErr(null);
|
||||
api
|
||||
.listVersions(entityType, entityId)
|
||||
.then((v) => setRows(v))
|
||||
.catch((e) => setErr(String(e?.message || e)));
|
||||
}, [entityType, entityId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) load();
|
||||
}, [open, load]);
|
||||
|
||||
async function peek(versionNo: number) {
|
||||
if (expanded === versionNo) {
|
||||
setExpanded(null);
|
||||
return;
|
||||
}
|
||||
setExpanded(versionNo);
|
||||
if (snapshots[versionNo] || !entityId) return;
|
||||
try {
|
||||
const v = await api.getVersion(entityType, entityId, versionNo);
|
||||
setSnapshots((s) => ({ ...s, [versionNo]: JSON.stringify(v.snapshot ?? {}, null, 2) }));
|
||||
} catch {
|
||||
setSnapshots((s) => ({ ...s, [versionNo]: "(could not load snapshot)" }));
|
||||
}
|
||||
}
|
||||
|
||||
async function restore(versionNo: number) {
|
||||
if (!entityId) return;
|
||||
if (!window.confirm(`Restore version ${versionNo}? The current state is saved as a new version first, so this is reversible.`)) return;
|
||||
setRestoring(versionNo);
|
||||
try {
|
||||
await api.restoreVersion(entityType, entityId, versionNo);
|
||||
setOpen(false);
|
||||
onRestored?.();
|
||||
} catch (e: any) {
|
||||
setErr(String(e?.message || e));
|
||||
} finally {
|
||||
setRestoring(null);
|
||||
}
|
||||
}
|
||||
|
||||
const latest = rows && rows.length ? Math.max(...rows.map((r) => r.version_no)) : null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<button className={buttonClassName} onClick={() => setOpen(true)} disabled={!entityId} title="Version history">
|
||||
<Icon name="clock" size={14} />
|
||||
{buttonLabel}
|
||||
</button>
|
||||
<Drawer open={open} onClose={() => setOpen(false)} title="Version history" sub={entityLabel} width={420}>
|
||||
<div className="col" style={{ padding: 14, gap: 8 }}>
|
||||
{err && (
|
||||
<div className="card" style={{ padding: 12, color: "var(--err)" }}>{err}</div>
|
||||
)}
|
||||
{!err && rows === null && (
|
||||
<div className="fg-2 t-caption" style={{ padding: "8px 2px" }}>Loading versions…</div>
|
||||
)}
|
||||
{!err && rows !== null && rows.length === 0 && (
|
||||
<div className="col center" style={{ padding: "40px 16px", textAlign: "center", gap: 8, color: "var(--fg-2)" }}>
|
||||
<Icon name="clock" size={22} />
|
||||
<div className="t-body-sm">No saved versions yet.</div>
|
||||
<div className="t-caption">Versions are captured each time you save or publish.</div>
|
||||
</div>
|
||||
)}
|
||||
{rows?.map((v) => {
|
||||
const isLatest = v.version_no === latest;
|
||||
const isOpen = expanded === v.version_no;
|
||||
return (
|
||||
<div key={v.id || v.version_no} className="card" style={{ padding: "10px 12px" }}>
|
||||
<div className="row spread" style={{ alignItems: "flex-start", gap: 8 }}>
|
||||
<div className="col" style={{ gap: 3, minWidth: 0 }}>
|
||||
<div className="row gap2" style={{ alignItems: "center" }}>
|
||||
<span className="mono-sm" style={{ fontWeight: 600 }}>v{v.version_no}</span>
|
||||
{isLatest && <span className="pill pill-muted" style={{ height: 18 }}>current</span>}
|
||||
{v.label && <span className="t-body-sm truncate">{v.label}</span>}
|
||||
</div>
|
||||
<div className="t-caption fg-2 truncate">
|
||||
{v.author_email || "unknown"}
|
||||
{v.created_at ? ` · ${relTime(v.created_at)}` : ""}
|
||||
</div>
|
||||
</div>
|
||||
<div className="row gap1" style={{ flex: "none" }}>
|
||||
<button className="iconbtn" title="Inspect snapshot" onClick={() => peek(v.version_no)}>
|
||||
<Icon name={isOpen ? "eyeoff" : "eye"} size={14} />
|
||||
</button>
|
||||
{allowRestore && (
|
||||
<button
|
||||
className="btn btn-secondary btn-sm"
|
||||
disabled={isLatest || restoring != null}
|
||||
title={isLatest ? "This is the current version" : `Restore v${v.version_no}`}
|
||||
onClick={() => restore(v.version_no)}
|
||||
>
|
||||
<Icon name="rotate" size={13} />
|
||||
{restoring === v.version_no ? "…" : "Restore"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{isOpen && (
|
||||
<pre
|
||||
className="mono no-scrollbar"
|
||||
style={{ margin: "8px 0 0", padding: 10, background: "var(--bg-0)", border: "1px solid var(--line)", borderRadius: "var(--r-sm)", fontSize: 11, lineHeight: "16px", maxHeight: 220, overflow: "auto", color: "var(--fg-1)", whiteSpace: "pre-wrap", overflowWrap: "anywhere" }}
|
||||
>
|
||||
{snapshots[v.version_no] ?? "Loading…"}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Drawer>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,726 @@
|
||||
/* Forge API client. Calls are proxied through Next (/api/forge/* -> backend) so the
|
||||
app and API share an origin in dev (see next.config.mjs). */
|
||||
|
||||
const BASE = "/api/forge";
|
||||
const DIRECT_API = (process.env.NEXT_PUBLIC_FORGE_API_URL || "").replace(/\/$/, "");
|
||||
|
||||
function isLocalWebHost(hostname: string) {
|
||||
return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1";
|
||||
}
|
||||
|
||||
function sseBase() {
|
||||
if (DIRECT_API) return DIRECT_API;
|
||||
if (typeof window !== "undefined" && isLocalWebHost(window.location.hostname)) {
|
||||
return "http://127.0.0.1:8000";
|
||||
}
|
||||
return BASE;
|
||||
}
|
||||
|
||||
function sseUrl(path: string) {
|
||||
return `${sseBase()}${path}`;
|
||||
}
|
||||
|
||||
export interface Project {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
description?: string | null;
|
||||
status: string;
|
||||
config?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface Workflow {
|
||||
id: string;
|
||||
project_id: string;
|
||||
name: string;
|
||||
description?: string | null;
|
||||
status: string;
|
||||
active_version: number;
|
||||
executable: Record<string, any>;
|
||||
canvas: Record<string, any>;
|
||||
}
|
||||
|
||||
export interface ValidateResult {
|
||||
valid: boolean;
|
||||
errors: { pointer: string; message: string; node_id?: string }[];
|
||||
}
|
||||
|
||||
/** Model catalog served from the backend (GET /v1/models) - the single source of truth for
|
||||
* every model picker, so the frontend hardcodes no model lists. See forge/model_catalog.py. */
|
||||
export interface ModelInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
provider: string;
|
||||
ctx: string;
|
||||
tools: boolean;
|
||||
vision: boolean;
|
||||
}
|
||||
export interface EmbeddingModelInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
provider: string;
|
||||
dim: number;
|
||||
billed: boolean;
|
||||
default: boolean;
|
||||
}
|
||||
export interface RerankerModelInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
note: string;
|
||||
default: boolean;
|
||||
}
|
||||
export interface ModelCatalog {
|
||||
chat: ModelInfo[];
|
||||
embedding: EmbeddingModelInfo[];
|
||||
reranker: RerankerModelInfo[];
|
||||
}
|
||||
|
||||
export interface Tool {
|
||||
id: string;
|
||||
project_id: string;
|
||||
name: string;
|
||||
kind: string;
|
||||
enabled: boolean;
|
||||
auth_provider_id?: string | null;
|
||||
last_tested?: string | null;
|
||||
config: Record<string, any>;
|
||||
}
|
||||
|
||||
export interface ToolSet {
|
||||
id: string;
|
||||
project_id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
description: string;
|
||||
icon?: string | null;
|
||||
is_default: boolean;
|
||||
exposed: boolean;
|
||||
tool_ids: string[];
|
||||
}
|
||||
|
||||
export interface McpToken {
|
||||
id: string;
|
||||
name: string;
|
||||
prefix: string;
|
||||
project_id?: string | null;
|
||||
status: string;
|
||||
created_at?: string | null;
|
||||
last_used_at?: string | null;
|
||||
expires_at?: string | null;
|
||||
token?: string | null;
|
||||
}
|
||||
|
||||
export interface ComponentT {
|
||||
id: string;
|
||||
name: string;
|
||||
title?: string | null;
|
||||
description: string;
|
||||
props_schema: Record<string, any>;
|
||||
html: string;
|
||||
css: string;
|
||||
actions: Record<string, any>[];
|
||||
sample_props: Record<string, any>;
|
||||
kind: string;
|
||||
enabled: boolean;
|
||||
version: number;
|
||||
}
|
||||
|
||||
export interface RedirectInfo {
|
||||
followed: boolean;
|
||||
status?: number;
|
||||
final_status?: number;
|
||||
requested_url?: string;
|
||||
final_url?: string;
|
||||
location?: string | null;
|
||||
chain?: string[];
|
||||
note?: string;
|
||||
}
|
||||
|
||||
export interface ToolTestResult {
|
||||
ok: boolean;
|
||||
error?: string;
|
||||
status?: number;
|
||||
latency_ms?: number;
|
||||
raw?: any;
|
||||
projected?: any;
|
||||
raw_tokens?: number;
|
||||
projected_tokens?: number;
|
||||
final_url?: string;
|
||||
redirect?: RedirectInfo | null;
|
||||
}
|
||||
|
||||
export interface AuthProviderT {
|
||||
id: string;
|
||||
project_id: string;
|
||||
name: string;
|
||||
kind: string;
|
||||
credentials_ref?: string | null;
|
||||
config: Record<string, any>;
|
||||
}
|
||||
|
||||
/** A per-user ("external") credential the current user connects themselves. Minimal, connector-safe
|
||||
* shape from GET /v1/projects/{id}/connections (no provider config / secret refs). */
|
||||
export interface MyConnection {
|
||||
id: string;
|
||||
name: string;
|
||||
kind: string;
|
||||
connected: boolean;
|
||||
}
|
||||
|
||||
export interface Agent {
|
||||
id: string;
|
||||
project_id: string;
|
||||
name: string;
|
||||
config: Record<string, any>;
|
||||
created_by?: string | null;
|
||||
created_by_email?: string | null;
|
||||
}
|
||||
|
||||
export interface KbSource { id: string; project_id: string; kind: string; name: string; folder?: string; uri?: string | null; status: string; chunks: number; embedding_model?: string | null; chunking_strategy?: string | null; chunk_size?: number | null; chunk_overlap?: number | null; }
|
||||
export interface RechunkSettings { chunking_strategy?: string; chunk_size?: number; chunk_overlap?: number; }
|
||||
export interface QaPair { id: string; question: string; answer: string; kind: string; tags: string[]; upvotes: number; }
|
||||
export interface SearchHit { text: string; score: number; source_id?: string; }
|
||||
// Chunk-map visualizer (POST /knowledge/map): a 2-D (PCA) projection of the stored chunk vectors.
|
||||
export interface ChunkPoint { id: string; x: number; y: number; source_id?: string | null; chunk_idx?: number | null; parent_id?: string | null; preview: string; retrieved?: number; }
|
||||
export interface ChunkMapResult { points: ChunkPoint[]; sources: { id: string; name: string }[]; query_point: [number, number] | null; query: string | null; total: number; truncated: boolean; }
|
||||
export interface ChunkDetail { id: string; text: string; source_id?: string | null; chunk_idx?: number | null; parent_id?: string | null; }
|
||||
export interface Trace { id: string; run_id: string; workflow_id?: string | null; name: string; status: string; started_at?: string | null; ended_at?: string | null; latency_ms: number; total_tokens: number; total_cost_usd: number; }
|
||||
export interface Span { id: string; parent_span_id?: string | null; name: string; kind: string; latency_ms: number; input?: any; output?: any; model?: string | null; input_tokens: number; output_tokens: number; cost_usd: number; error?: string | null; }
|
||||
export interface Conversation { thread_id: string; actor: string; source: string; end_user_id?: string | null; workflow_id?: string | null; turns: number; total_tokens: number; total_cost_usd: number; started_at?: string | null; last_activity?: string | null; status: string; preview: string; }
|
||||
export interface Turn { trace_id: string; run_id: string; source: string; user_message?: string | null; ai_response?: string | null; status: string; error?: string | null; latency_ms: number; total_tokens: number; total_cost_usd: number; started_at?: string | null; }
|
||||
export interface ConversationDetail { conversation: Conversation; turns: Turn[]; }
|
||||
export interface Facets { actors: string[]; sources: string[]; }
|
||||
export interface Secret { id: string; name: string; kind: string; version: number; }
|
||||
|
||||
export interface StatRollup { runs: number; tokens: number; cost_usd: number; avg_latency_ms: number; errors?: number; error_rate?: number; }
|
||||
export interface ReportRow extends StatRollup { label: string; kind: "workflow" | "assistant" | "other"; }
|
||||
|
||||
/* ---- analytics dashboard (time-series + breakdowns over a date range) ---- */
|
||||
export interface TimeBucket { date: string; runs: number; tokens: number; cost_usd: number; avg_latency_ms: number; errors: number; success: number; }
|
||||
export interface SourceRollup extends StatRollup { source: string; }
|
||||
export interface ToolStat { name: string; calls: number; avg_latency_ms: number; errors: number; cost_usd: number; tokens: number; }
|
||||
export interface ModelStat { model: string; calls: number; tokens: number; cost_usd: number; avg_latency_ms: number; }
|
||||
export interface LatencyBucket { label: string; count: number; }
|
||||
export interface AnalyticsRange { days: number; since: string; until: string; bucket: string; }
|
||||
export interface AnalyticsRecentRun { id: string; workflow: string; project: string; status: string; tokens: number; latency_ms: number; cost_usd: number; started_at: string | null; }
|
||||
export interface Analytics {
|
||||
range: AnalyticsRange;
|
||||
totals: StatRollup;
|
||||
prev_totals: StatRollup;
|
||||
timeseries: TimeBucket[];
|
||||
by_source: SourceRollup[];
|
||||
by_workflow: ReportRow[];
|
||||
tools: ToolStat[];
|
||||
models: ModelStat[];
|
||||
latency_histogram: LatencyBucket[];
|
||||
recent: AnalyticsRecentRun[];
|
||||
}
|
||||
export interface ProjectStats {
|
||||
totals: StatRollup;
|
||||
last_7d: StatRollup;
|
||||
assistant: StatRollup & { turns: number };
|
||||
reports: ReportRow[];
|
||||
}
|
||||
|
||||
// Sidebar badge counts (keys match countKey in data.ts PROJECT_NAV). One cheap call
|
||||
// replaces six full-list fetches that were only ever read for their `.length`.
|
||||
export interface ProjectCounts {
|
||||
workflows: number; agents: number; tools: number; components: number; knowledge: number; auth: number; handoffs: number;
|
||||
}
|
||||
|
||||
export interface DashboardStats {
|
||||
runs_7d: number;
|
||||
total_runs: number;
|
||||
success_rate: number;
|
||||
avg_latency_ms: number;
|
||||
spend_7d: number;
|
||||
recent: { id: string; workflow: string; project: string; status: string; tokens: number; latency_ms: number; cost_usd: number; started_at: string | null }[];
|
||||
projects: Record<string, { workflows: number; tools: number; runs_7d: number }>;
|
||||
reports: (StatRollup & { project_id: string; project: string; assistant_cost_usd: number; assistant_turns: number })[];
|
||||
totals: StatRollup;
|
||||
}
|
||||
|
||||
/* ---- import / export (portable single-type bundles) ---- */
|
||||
export type PortableType = "tool" | "workflow" | "component" | "agent";
|
||||
const PORTABLE_PLURAL: Record<PortableType, string> = {
|
||||
tool: "tools", workflow: "workflows", component: "components", agent: "agents",
|
||||
};
|
||||
export interface ExportBundle {
|
||||
format?: string;
|
||||
type: string;
|
||||
exported_at?: string;
|
||||
source?: { project_id?: string; project_name?: string | null };
|
||||
items: Record<string, any>[];
|
||||
}
|
||||
export interface ImportReportItem {
|
||||
id?: string;
|
||||
name?: string;
|
||||
original_name?: string;
|
||||
renamed?: boolean;
|
||||
skipped?: boolean;
|
||||
}
|
||||
export interface ImportReport {
|
||||
type: string;
|
||||
imported: number;
|
||||
skipped: number;
|
||||
items: ImportReportItem[];
|
||||
warnings: string[];
|
||||
toolsets_imported?: number;
|
||||
}
|
||||
|
||||
export interface NodeType {
|
||||
type: string;
|
||||
category: string;
|
||||
label: string;
|
||||
description: string;
|
||||
schema_id: string;
|
||||
allows_cycle: boolean;
|
||||
input_ports: { id: string; io_type: string; direction: string }[];
|
||||
output_ports: { id: string; io_type: string; direction: string }[];
|
||||
}
|
||||
|
||||
/* ---- auth token storage (JWT). Sent as a Bearer header on every request. ---- */
|
||||
const TOKEN_KEY = "forge_access_token";
|
||||
const REFRESH_KEY = "forge_refresh_token";
|
||||
|
||||
export function getToken(): string | null {
|
||||
return typeof window !== "undefined" ? window.localStorage.getItem(TOKEN_KEY) : null;
|
||||
}
|
||||
export function setTokens(access: string, refresh?: string) {
|
||||
if (typeof window === "undefined") return;
|
||||
window.localStorage.setItem(TOKEN_KEY, access);
|
||||
if (refresh) window.localStorage.setItem(REFRESH_KEY, refresh);
|
||||
}
|
||||
export function clearTokens() {
|
||||
if (typeof window === "undefined") return;
|
||||
window.localStorage.removeItem(TOKEN_KEY);
|
||||
window.localStorage.removeItem(REFRESH_KEY);
|
||||
}
|
||||
export function authHeader(): Record<string, string> {
|
||||
const t = getToken();
|
||||
return t ? { Authorization: `Bearer ${t}` } : {};
|
||||
}
|
||||
export const UNAUTHORIZED_EVENT = "forge:unauthorized";
|
||||
|
||||
function on401() {
|
||||
if (typeof window !== "undefined") {
|
||||
clearTokens();
|
||||
window.dispatchEvent(new CustomEvent(UNAUTHORIZED_EVENT));
|
||||
}
|
||||
}
|
||||
|
||||
// In-flight GET de-duplication: identical GET requests issued while one is still pending
|
||||
// share a single promise (and thus one network round-trip). This collapses React
|
||||
// StrictMode's double-invoked effects in dev AND any accidental concurrent duplicate
|
||||
// fetches (e.g. sidebar + overview both asking for counts). There is NO time-based cache -
|
||||
// the entry is dropped the moment the request settles, so data is never served stale.
|
||||
const _inflight = new Map<string, Promise<any>>();
|
||||
|
||||
async function json<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
const method = (init?.method || "GET").toUpperCase();
|
||||
const key = method === "GET" ? path : null;
|
||||
if (key && _inflight.has(key)) return _inflight.get(key) as Promise<T>;
|
||||
const p = (async () => {
|
||||
const res = await fetch(`${BASE}${path}`, {
|
||||
headers: { "Content-Type": "application/json", ...authHeader(), ...(init?.headers || {}) },
|
||||
...init,
|
||||
});
|
||||
if (res.status === 401) on401();
|
||||
if (!res.ok) throw new Error(`${res.status} ${res.statusText} on ${path}`);
|
||||
return res.json() as Promise<T>;
|
||||
})();
|
||||
if (key) {
|
||||
_inflight.set(key, p);
|
||||
const done = () => _inflight.delete(key);
|
||||
p.then(done, done); // clear on settle (both fulfil + reject); never itself rejects
|
||||
}
|
||||
return p;
|
||||
}
|
||||
|
||||
/** Fired after any create/delete of a counted resource so the project sidebar can
|
||||
* refresh its badge counts without a page reload. */
|
||||
export const COUNTS_CHANGED_EVENT = "forge:counts-changed";
|
||||
|
||||
function notifyCounts<T>(p: Promise<T>): Promise<T> {
|
||||
return p.then((v) => {
|
||||
if (typeof window !== "undefined") window.dispatchEvent(new CustomEvent(COUNTS_CHANGED_EVENT));
|
||||
return v;
|
||||
});
|
||||
}
|
||||
|
||||
export const api = {
|
||||
listProjects: () => json<Project[]>("/v1/projects"),
|
||||
getProject: (id: string) => json<Project>(`/v1/projects/${id}`),
|
||||
projectCounts: (pid: string) => json<ProjectCounts>(`/v1/projects/${pid}/counts`),
|
||||
createProject: (body: { name: string; slug?: string; description?: string; config?: Record<string, unknown> }) =>
|
||||
json<Project>("/v1/projects", { method: "POST", body: JSON.stringify(body) }),
|
||||
listWorkflows: (pid: string) => json<Workflow[]>(`/v1/projects/${pid}/workflows`),
|
||||
getWorkflow: (pid: string, wid: string) => json<Workflow>(`/v1/projects/${pid}/workflows/${wid}`),
|
||||
validateExecutable: (pid: string, executable: Record<string, unknown>) =>
|
||||
json<ValidateResult>(`/v1/projects/${pid}/workflows/validate`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ executable }),
|
||||
}),
|
||||
createRun: (pid: string, wid: string, input: Record<string, unknown>, threadId?: string, endUser?: Record<string, unknown> | null) =>
|
||||
json<{ id: string; status: string; thread_id: string }>(
|
||||
`/v1/projects/${pid}/workflows/${wid}/runs`,
|
||||
{ method: "POST", body: JSON.stringify({ input, ...(threadId ? { thread_id: threadId } : {}), ...(endUser ? { end_user: endUser } : {}) }) },
|
||||
),
|
||||
resumeRun: (pid: string, wid: string, rid: string, value: unknown) =>
|
||||
json<{ status?: string; messages?: any[]; interrupted?: boolean; error?: string }>(
|
||||
`/v1/projects/${pid}/workflows/${wid}/runs/${rid}/resume`,
|
||||
{ method: "POST", body: JSON.stringify({ value }) },
|
||||
),
|
||||
createWorkflow: (pid: string, body: { name: string; description?: string; executable?: Record<string, unknown>; canvas?: Record<string, unknown> }) =>
|
||||
notifyCounts(json<Workflow>(`/v1/projects/${pid}/workflows`, { method: "POST", body: JSON.stringify(body) })),
|
||||
updateWorkflow: (pid: string, wid: string, body: { name?: string; description?: string }) =>
|
||||
json<Workflow>(`/v1/projects/${pid}/workflows/${wid}`, { method: "PATCH", body: JSON.stringify(body) }),
|
||||
saveCanvas: (pid: string, wid: string, canvas: Record<string, unknown>, executable: Record<string, unknown>) =>
|
||||
json<ValidateResult>(`/v1/projects/${pid}/workflows/${wid}/canvas`, { method: "PUT", body: JSON.stringify({ canvas, executable }) }),
|
||||
publishWorkflow: (pid: string, wid: string) =>
|
||||
json<Workflow>(`/v1/projects/${pid}/workflows/${wid}/publish`, { method: "POST" }),
|
||||
deleteWorkflow: (pid: string, wid: string) =>
|
||||
notifyCounts(fetch(`${BASE}/v1/projects/${pid}/workflows/${wid}`, { method: "DELETE", headers: authHeader() })),
|
||||
dashboardStats: () => json<DashboardStats>("/v1/stats/dashboard"),
|
||||
projectStats: (pid: string) => json<ProjectStats>(`/v1/stats/projects/${pid}`),
|
||||
projectAnalytics: (pid: string, days = 30) => json<Analytics>(`/v1/stats/projects/${pid}/analytics?days=${days}`),
|
||||
listAgents: (pid: string) => json<Agent[]>(`/v1/projects/${pid}/agents`),
|
||||
getAgent: (pid: string, aid: string) => json<Agent>(`/v1/projects/${pid}/agents/${aid}`),
|
||||
createAgent: (pid: string, body: { name: string; config: Record<string, unknown> }) =>
|
||||
notifyCounts(json<Agent>(`/v1/projects/${pid}/agents`, { method: "POST", body: JSON.stringify(body) })),
|
||||
updateAgent: (pid: string, aid: string, body: { name?: string; config?: Record<string, unknown> }) =>
|
||||
json<Agent>(`/v1/projects/${pid}/agents/${aid}`, { method: "PATCH", body: JSON.stringify(body) }),
|
||||
deleteAgent: (pid: string, aid: string) =>
|
||||
notifyCounts(fetch(`${BASE}/v1/projects/${pid}/agents/${aid}`, { method: "DELETE", headers: authHeader() })),
|
||||
listTools: (pid: string) => json<Tool[]>(`/v1/projects/${pid}/tools`),
|
||||
getTool: (pid: string, tid: string) => json<Tool>(`/v1/projects/${pid}/tools/${tid}`),
|
||||
createTool: (pid: string, body: { name: string; kind: string; config: Record<string, unknown>; auth_provider_id?: string }) =>
|
||||
notifyCounts(json<Tool>(`/v1/projects/${pid}/tools`, { method: "POST", body: JSON.stringify(body) })),
|
||||
updateTool: (pid: string, tid: string, body: { name?: string; config?: Record<string, unknown>; auth_provider_id?: string | null; enabled?: boolean }) =>
|
||||
json<Tool>(`/v1/projects/${pid}/tools/${tid}`, { method: "PATCH", body: JSON.stringify(body) }),
|
||||
deleteTool: (pid: string, tid: string) =>
|
||||
notifyCounts(fetch(`${BASE}/v1/projects/${pid}/tools/${tid}`, { method: "DELETE", headers: authHeader() })),
|
||||
testTool: (pid: string, tid: string, args: Record<string, unknown>, context?: Record<string, unknown>) =>
|
||||
json<ToolTestResult>(`/v1/projects/${pid}/tools/${tid}/test`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ args, context }),
|
||||
}),
|
||||
// tool sets (describable groups of tools; organize the Tools screen + publish over MCP)
|
||||
listToolSets: (pid: string) => json<ToolSet[]>(`/v1/projects/${pid}/tool-sets`),
|
||||
createToolSet: (pid: string, body: { name: string; description?: string; icon?: string | null; is_default?: boolean; exposed?: boolean; tool_ids?: string[] }) =>
|
||||
json<ToolSet>(`/v1/projects/${pid}/tool-sets`, { method: "POST", body: JSON.stringify(body) }),
|
||||
updateToolSet: (pid: string, sid: string, body: Partial<{ name: string; description: string; icon: string | null; is_default: boolean; exposed: boolean; tool_ids: string[] }>) =>
|
||||
json<ToolSet>(`/v1/projects/${pid}/tool-sets/${sid}`, { method: "PATCH", body: JSON.stringify(body) }),
|
||||
deleteToolSet: (pid: string, sid: string) =>
|
||||
fetch(`${BASE}/v1/projects/${pid}/tool-sets/${sid}`, { method: "DELETE", headers: authHeader() }),
|
||||
addToolToSet: (pid: string, sid: string, tid: string) =>
|
||||
fetch(`${BASE}/v1/projects/${pid}/tool-sets/${sid}/tools/${tid}`, { method: "POST", headers: authHeader() }),
|
||||
removeToolFromSet: (pid: string, sid: string, tid: string) =>
|
||||
fetch(`${BASE}/v1/projects/${pid}/tool-sets/${sid}/tools/${tid}`, { method: "DELETE", headers: authHeader() }),
|
||||
// MCP personal access tokens (per-user MCP auth; the plaintext is returned once on create)
|
||||
listMcpTokens: (pid: string) => json<McpToken[]>(`/v1/projects/${pid}/mcp-tokens`),
|
||||
createMcpToken: (pid: string, body: { name?: string; ttl_days?: number }) =>
|
||||
json<McpToken>(`/v1/projects/${pid}/mcp-tokens`, { method: "POST", body: JSON.stringify(body) }),
|
||||
revokeMcpToken: (pid: string, tid: string) =>
|
||||
fetch(`${BASE}/v1/projects/${pid}/mcp-tokens/${tid}`, { method: "DELETE", headers: authHeader() }),
|
||||
// components (Feature 2 - generative UI widgets)
|
||||
listComponents: (pid: string) => json<ComponentT[]>(`/v1/projects/${pid}/components`),
|
||||
getComponent: (pid: string, cid: string) => json<ComponentT>(`/v1/projects/${pid}/components/${cid}`),
|
||||
createComponent: (pid: string, body: Record<string, unknown> & { name: string }) =>
|
||||
notifyCounts(json<ComponentT>(`/v1/projects/${pid}/components`, { method: "POST", body: JSON.stringify(body) })),
|
||||
updateComponent: (pid: string, cid: string, body: Record<string, unknown>) =>
|
||||
json<ComponentT>(`/v1/projects/${pid}/components/${cid}`, { method: "PATCH", body: JSON.stringify(body) }),
|
||||
deleteComponent: (pid: string, cid: string) =>
|
||||
notifyCounts(fetch(`${BASE}/v1/projects/${pid}/components/${cid}`, { method: "DELETE", headers: authHeader() })),
|
||||
// import / export - serialize the selected rows of one type into a downloadable bundle,
|
||||
// and re-create a bundle's items in a target project (new ids, auto-renamed on collision).
|
||||
exportBundle: (pid: string, type: PortableType, ids: string[]) =>
|
||||
json<ExportBundle>(`/v1/projects/${pid}/${PORTABLE_PLURAL[type]}/export`, { method: "POST", body: JSON.stringify({ ids }) }),
|
||||
importBundle: async (pid: string, type: PortableType, bundle: unknown): Promise<ImportReport> => {
|
||||
// Direct fetch (not the shared `json` helper) so the backend's error `detail` - e.g. a
|
||||
// wrong-type file or validation message - surfaces to the user instead of a bare status.
|
||||
const res = await fetch(`${BASE}/v1/projects/${pid}/${PORTABLE_PLURAL[type]}/import`, {
|
||||
method: "POST", headers: { "Content-Type": "application/json", ...authHeader() }, body: JSON.stringify(bundle),
|
||||
});
|
||||
if (res.status === 401) on401();
|
||||
if (!res.ok) {
|
||||
const detail = await res.json().then((d) => d?.detail).catch(() => null);
|
||||
throw new Error(typeof detail === "string" ? detail : `${res.status} ${res.statusText}`);
|
||||
}
|
||||
if (typeof window !== "undefined") window.dispatchEvent(new CustomEvent(COUNTS_CHANGED_EVENT));
|
||||
return res.json() as Promise<ImportReport>;
|
||||
},
|
||||
listAuthProviders: (pid: string) => json<AuthProviderT[]>(`/v1/projects/${pid}/auth-providers`),
|
||||
createAuthProvider: (pid: string, body: { name: string; kind: string; config: Record<string, unknown>; credentials_ref?: string }) =>
|
||||
notifyCounts(json<AuthProviderT>(`/v1/projects/${pid}/auth-providers`, { method: "POST", body: JSON.stringify(body) })),
|
||||
updateAuthProvider: (pid: string, aid: string, body: { name?: string; kind?: string; config?: Record<string, unknown>; credentials_ref?: string }) =>
|
||||
json<AuthProviderT>(`/v1/projects/${pid}/auth-providers/${aid}`, { method: "PATCH", body: JSON.stringify(body) }),
|
||||
testAuthProvider: (pid: string, aid: string, context?: Record<string, unknown>) =>
|
||||
json<any>(`/v1/projects/${pid}/auth-providers/${aid}/test`, { method: "POST", body: JSON.stringify({ context }) }),
|
||||
listMcpClients: (pid: string) => json<McpClientT[]>(`/v1/projects/${pid}/mcp-clients`),
|
||||
createMcpClient: (pid: string, body: { name: string; transport?: string; url?: string; command?: string; args?: any; headers_ref?: string }) =>
|
||||
json<McpClientT>(`/v1/projects/${pid}/mcp-clients`, { method: "POST", body: JSON.stringify(body) }),
|
||||
updateMcpClient: (pid: string, cid: string, body: Partial<{ name: string; enabled: boolean; disabled_tools: string[]; url: string; headers_ref: string }>) =>
|
||||
json<McpClientT>(`/v1/projects/${pid}/mcp-clients/${cid}`, { method: "PATCH", body: JSON.stringify(body) }),
|
||||
deleteMcpClient: (pid: string, cid: string) =>
|
||||
json<{ ok: boolean }>(`/v1/projects/${pid}/mcp-clients/${cid}`, { method: "DELETE" }),
|
||||
discoverMcpTools: (pid: string, cid: string) =>
|
||||
json<{ ok: boolean; tools?: { name: string; description?: string }[]; error?: string }>(`/v1/projects/${pid}/mcp-clients/${cid}/tools`),
|
||||
oauthStart: (pid: string, aid: string) =>
|
||||
json<{ authorize_url: string }>(`/v1/projects/${pid}/auth-providers/${aid}/oauth/start`, { method: "POST" }),
|
||||
oauthStatus: (pid: string, aid: string) =>
|
||||
json<{ connected: boolean; expires_at?: number | null; scope?: string | null; has_refresh?: boolean }>(`/v1/projects/${pid}/auth-providers/${aid}/oauth/status`),
|
||||
deleteAuthProvider: (pid: string, aid: string) =>
|
||||
notifyCounts(fetch(`${BASE}/v1/projects/${pid}/auth-providers/${aid}`, { method: "DELETE", headers: authHeader() })),
|
||||
// Per-user ("external") auth via the connector-safe /connections router (NOT the auth-providers
|
||||
// admin surface). The current user's own downstream credential for a per-user provider, keyed
|
||||
// server-side by their user id (the same id the MCP PAT resolves to), so a tool acts as them
|
||||
// without a shared secret. Used by owners (Auth screen) and connectors (their token page) alike.
|
||||
listMyConnections: (pid: string) => json<MyConnection[]>(`/v1/projects/${pid}/connections`),
|
||||
getMyConnection: (pid: string, aid: string) =>
|
||||
json<{ connected: boolean; expires_at?: number | null }>(`/v1/projects/${pid}/connections/${aid}`),
|
||||
setMyConnection: (pid: string, aid: string, access_token: string) =>
|
||||
fetch(`${BASE}/v1/projects/${pid}/connections/${aid}`, {
|
||||
method: "PUT", headers: { "Content-Type": "application/json", ...authHeader() }, body: JSON.stringify({ access_token }),
|
||||
}),
|
||||
clearMyConnection: (pid: string, aid: string) =>
|
||||
fetch(`${BASE}/v1/projects/${pid}/connections/${aid}`, { method: "DELETE", headers: authHeader() }),
|
||||
// knowledge
|
||||
listSources: (pid: string) => json<KbSource[]>(`/v1/projects/${pid}/knowledge/sources`),
|
||||
listFolders: (pid: string) => json<string[]>(`/v1/projects/${pid}/knowledge/folders`),
|
||||
addSource: (pid: string, body: { kind: string; name: string; folder?: string; uri?: string; text?: string; chunking_strategy?: string }) =>
|
||||
notifyCounts(json<KbSource>(`/v1/projects/${pid}/knowledge/sources`, { method: "POST", body: JSON.stringify(body) })),
|
||||
uploadSource: async (pid: string, file: globalThis.File, folder?: string, chunkingStrategy?: string) => {
|
||||
const fd = new FormData();
|
||||
fd.append("file", file);
|
||||
if (folder) fd.append("folder", folder);
|
||||
if (chunkingStrategy) fd.append("chunking_strategy", chunkingStrategy);
|
||||
const res = await fetch(`${BASE}/v1/projects/${pid}/knowledge/sources/upload`, { method: "POST", body: fd, headers: authHeader() });
|
||||
if (!res.ok) {
|
||||
const detail = await res.json().then((d) => d?.detail).catch(() => null);
|
||||
throw new Error(detail || `${res.status} ${res.statusText} on upload`);
|
||||
}
|
||||
if (typeof window !== "undefined") window.dispatchEvent(new CustomEvent(COUNTS_CHANGED_EVENT));
|
||||
return res.json() as Promise<KbSource>;
|
||||
},
|
||||
moveSource: (pid: string, sid: string, folder: string) =>
|
||||
json<KbSource>(`/v1/projects/${pid}/knowledge/sources/${sid}`, { method: "PATCH", body: JSON.stringify({ folder }) }),
|
||||
reingestSource: (pid: string, sid: string, settings?: RechunkSettings) =>
|
||||
notifyCounts(json<{ id: string; status: string; chunks: number }>(`/v1/projects/${pid}/knowledge/sources/${sid}/reingest`, { method: "POST", body: JSON.stringify(settings || {}) })),
|
||||
rechunkSources: (pid: string, source_ids: string[], settings: RechunkSettings) =>
|
||||
notifyCounts(json<{ id: string; status: string; chunks: number }[]>(`/v1/projects/${pid}/knowledge/sources/rechunk`, { method: "POST", body: JSON.stringify({ source_ids, ...settings }) })),
|
||||
embeddingHealth: (pid: string) =>
|
||||
json<{ current_model: string; current_dim: number; sources: number; needs_reembed: boolean; mismatched: { id: string; name: string; embedded_with: string; dim: number }[] }>(`/v1/projects/${pid}/knowledge/health`),
|
||||
deleteSource: (pid: string, sid: string) => notifyCounts(fetch(`${BASE}/v1/projects/${pid}/knowledge/sources/${sid}`, { method: "DELETE", headers: authHeader() })),
|
||||
searchKnowledge: (pid: string, query: string, top_k = 5, folders?: string[], hybrid = false, rerank = false) =>
|
||||
json<SearchHit[]>(`/v1/projects/${pid}/knowledge/search`, { method: "POST", body: JSON.stringify({ query, top_k, hybrid, rerank, ...(folders?.length ? { folders } : {}) }) }),
|
||||
chunkMap: (pid: string, body: { query?: string; folders?: string[]; source_ids?: string[]; limit?: number; hybrid?: boolean; rerank?: boolean; top_k?: number }) =>
|
||||
json<ChunkMapResult>(`/v1/projects/${pid}/knowledge/map`, { method: "POST", body: JSON.stringify(body) }),
|
||||
// Full text of one chunk, fetched on demand for the chunk-map detail panel (the map response
|
||||
// itself carries only a short preview, so the payload stays lean at large point budgets).
|
||||
chunkDetail: (pid: string, chunkId: string) =>
|
||||
json<ChunkDetail>(`/v1/projects/${pid}/knowledge/chunk?chunk_id=${encodeURIComponent(chunkId)}`),
|
||||
dedupeChunks: (pid: string) =>
|
||||
json<{ removed: number; groups: number; sources_affected: number; remaining: number }>(`/v1/projects/${pid}/knowledge/dedupe`, { method: "POST" }),
|
||||
listQa: (pid: string) => json<QaPair[]>(`/v1/projects/${pid}/qa-pairs`),
|
||||
listQaKinds: (pid: string) => json<string[]>(`/v1/projects/${pid}/qa-pairs/kinds`),
|
||||
addQa: (pid: string, body: { question: string; answer: string; kind?: string; tags?: string[] }) =>
|
||||
json<QaPair>(`/v1/projects/${pid}/qa-pairs`, { method: "POST", body: JSON.stringify(body) }),
|
||||
updateQa: (pid: string, qid: string, body: { question?: string; answer?: string; kind?: string; tags?: string[] }) =>
|
||||
json<QaPair>(`/v1/projects/${pid}/qa-pairs/${qid}`, { method: "PATCH", body: JSON.stringify(body) }),
|
||||
deleteQa: (pid: string, qid: string) => fetch(`${BASE}/v1/projects/${pid}/qa-pairs/${qid}`, { method: "DELETE", headers: authHeader() }),
|
||||
// traces + conversations (Traces view)
|
||||
listTraces: (pid: string) => json<Trace[]>(`/v1/projects/${pid}/traces`),
|
||||
getTrace: (pid: string, trid: string) => json<{ trace: Trace; spans: Span[] }>(`/v1/projects/${pid}/traces/${trid}`),
|
||||
listConversations: (pid: string, opts?: { actor?: string; source?: string; status?: string; search?: string; limit?: number; offset?: number }) => {
|
||||
const q = new URLSearchParams();
|
||||
if (opts?.actor) q.set("actor", opts.actor);
|
||||
if (opts?.source) q.set("source", opts.source);
|
||||
if (opts?.status) q.set("status", opts.status);
|
||||
if (opts?.search) q.set("search", opts.search);
|
||||
if (opts?.limit != null) q.set("limit", String(opts.limit));
|
||||
if (opts?.offset != null) q.set("offset", String(opts.offset));
|
||||
const qs = q.toString();
|
||||
return json<Conversation[]>(`/v1/projects/${pid}/conversations${qs ? `?${qs}` : ""}`);
|
||||
},
|
||||
// Re-run a past run with the same input (fresh thread). Used by the Traces "re-run" button.
|
||||
rerunRun: (pid: string, wid: string, rid: string) =>
|
||||
json<{ id: string; status: string; thread_id: string }>(
|
||||
`/v1/projects/${pid}/workflows/${wid}/runs/${rid}/rerun`, { method: "POST" },
|
||||
),
|
||||
getConversation: (pid: string, threadId: string) =>
|
||||
json<ConversationDetail>(`/v1/projects/${pid}/conversations/${encodeURIComponent(threadId)}`),
|
||||
conversationFacets: (pid: string) => json<Facets>(`/v1/projects/${pid}/conversations/facets`),
|
||||
purgeConversations: (pid: string, olderThanDays: number) =>
|
||||
json<{ removed: number }>(`/v1/projects/${pid}/conversations/purge?older_than_days=${olderThanDays}`, { method: "POST" }),
|
||||
// secrets
|
||||
listSecrets: (pid: string) => json<Secret[]>(`/v1/projects/${pid}/secrets`),
|
||||
createSecret: (pid: string, body: { name: string; value: unknown; kind?: string }) =>
|
||||
json<Secret>(`/v1/projects/${pid}/secrets`, { method: "POST", body: JSON.stringify(body) }),
|
||||
secretUsage: (pid: string, name: string) =>
|
||||
json<{ count: number; references: { type: string; label: string }[] }>(`/v1/projects/${pid}/secrets/${encodeURIComponent(name)}/usage`),
|
||||
deleteSecret: (pid: string, name: string, force = false) =>
|
||||
fetch(`${BASE}/v1/projects/${pid}/secrets/${encodeURIComponent(name)}${force ? "?force=true" : ""}`, { method: "DELETE", headers: authHeader() }),
|
||||
// project
|
||||
updateProject: (pid: string, body: { name?: string; description?: string; config?: Record<string, unknown> }) =>
|
||||
json<Project>(`/v1/projects/${pid}`, { method: "PATCH", body: JSON.stringify(body) }),
|
||||
deleteProject: async (pid: string) => {
|
||||
const res = await fetch(`${BASE}/v1/projects/${pid}`, { method: "DELETE", headers: authHeader() });
|
||||
if (!res.ok) throw new Error(`${res.status} ${res.statusText} on /v1/projects/${pid}`);
|
||||
},
|
||||
listNodeTypes: () => json<NodeType[]>("/v1/node-types"),
|
||||
runStreamUrl: (pid: string, wid: string, runId: string) =>
|
||||
sseUrl(`/v1/projects/${pid}/workflows/${wid}/runs/${runId}/stream`),
|
||||
assistantStreamUrl: (pid: string) => sseUrl(`/v1/projects/${pid}/assistant/stream`),
|
||||
assistantResumeUrl: (pid: string) => sseUrl(`/v1/projects/${pid}/assistant/resume`),
|
||||
// auth + team
|
||||
register: (email: string, password: string, workspace_name?: string) =>
|
||||
json<AuthResult>("/v1/auth/register", { method: "POST", body: JSON.stringify({ email, password, workspace_name }) }),
|
||||
login: (email: string, password: string) =>
|
||||
json<AuthResult>("/v1/auth/login", { method: "POST", body: JSON.stringify({ email, password }) }),
|
||||
me: () => json<MeResult>("/v1/auth/me"),
|
||||
inviteInfo: (token: string) => json<{ email: string; role: string }>(`/v1/auth/invite-info?token=${encodeURIComponent(token)}`),
|
||||
acceptInvite: (token: string, password: string) =>
|
||||
json<AuthResult>("/v1/auth/accept-invite", { method: "POST", body: JSON.stringify({ token, password }) }),
|
||||
listTeam: () => json<TeamMember[]>("/v1/team/members"),
|
||||
listModels: () => json<ModelCatalog>("/v1/models"),
|
||||
listPricing: () => json<Record<string, { input_per_1m: number; output_per_1m: number }>>("/v1/pricing"),
|
||||
setPricing: (model: string, body: { input_per_1m: number; output_per_1m: number }) =>
|
||||
json<any>(`/v1/pricing/${encodeURIComponent(model)}`, { method: "PUT", body: JSON.stringify(body) }),
|
||||
// version history (workflow | agent | tool | component | auth_provider | kb_source | project)
|
||||
listVersions: (entityType: EntityType, entityId: string) =>
|
||||
json<EntityVersion[]>(`/v1/versions/${entityType}/${entityId}`),
|
||||
getVersion: (entityType: EntityType, entityId: string, versionNo: number) =>
|
||||
json<EntityVersion & { snapshot: Record<string, any> }>(`/v1/versions/${entityType}/${entityId}/${versionNo}`),
|
||||
restoreVersion: (entityType: EntityType, entityId: string, versionNo: number) =>
|
||||
json<{ ok?: boolean; version_no?: number }>(`/v1/versions/${entityType}/${entityId}/restore`, { method: "POST", body: JSON.stringify({ version_no: versionNo }) }),
|
||||
// read-only project-wide knowledge activity (added | changed | removed) for the Knowledge History
|
||||
knowledgeActivity: (projectId: string) =>
|
||||
json<ActivityEntry[]>(`/v1/versions/project/${projectId}/activity`),
|
||||
// full project config snapshots (newest first) so Settings > History can diff by section
|
||||
projectConfigHistory: (projectId: string) =>
|
||||
json<ProjectVersion[]>(`/v1/versions/project/${projectId}/config-history`),
|
||||
inviteMember: (body: { email: string; role?: string; password?: string }) =>
|
||||
json<InviteResult>("/v1/team/members", { method: "POST", body: JSON.stringify(body) }),
|
||||
updateMember: (uid: string, body: { role?: string; status?: string }) =>
|
||||
json<TeamMember>(`/v1/team/members/${uid}`, { method: "PATCH", body: JSON.stringify(body) }),
|
||||
deactivateMember: (uid: string) =>
|
||||
json<{ ok: boolean }>(`/v1/team/members/${uid}`, { method: "DELETE" }),
|
||||
// channels
|
||||
listChannels: (pid: string) => json<Channel[]>(`/v1/projects/${pid}/channels`),
|
||||
createChannel: (pid: string, body: { type: string; name: string; workflow_id?: string; config?: Record<string, any> }) =>
|
||||
json<Channel>(`/v1/projects/${pid}/channels`, { method: "POST", body: JSON.stringify(body) }),
|
||||
updateChannel: (pid: string, cid: string, body: { name?: string; workflow_id?: string; config?: Record<string, any>; enabled?: boolean }) =>
|
||||
json<Channel>(`/v1/projects/${pid}/channels/${cid}`, { method: "PATCH", body: JSON.stringify(body) }),
|
||||
deleteChannel: (pid: string, cid: string) =>
|
||||
json<{ ok: boolean }>(`/v1/projects/${pid}/channels/${cid}`, { method: "DELETE" }),
|
||||
// triggers
|
||||
listTriggers: (pid: string) => json<Trigger[]>(`/v1/projects/${pid}/triggers`),
|
||||
// datasets / eval
|
||||
listDatasets: (pid: string) => json<Dataset[]>(`/v1/projects/${pid}/datasets`),
|
||||
createDataset: (pid: string, body: { name: string; workflow_id?: string; score_mode?: string; items?: any[] }) =>
|
||||
json<Dataset>(`/v1/projects/${pid}/datasets`, { method: "POST", body: JSON.stringify(body) }),
|
||||
updateDataset: (pid: string, did: string, body: { name: string; workflow_id?: string; score_mode?: string; items?: any[] }) =>
|
||||
json<Dataset>(`/v1/projects/${pid}/datasets/${did}`, { method: "PATCH", body: JSON.stringify(body) }),
|
||||
runDataset: (pid: string, did: string) =>
|
||||
json<EvalRunResult>(`/v1/projects/${pid}/datasets/${did}/run`, { method: "POST" }),
|
||||
runDatasetStreamUrl: (pid: string, did: string) =>
|
||||
sseUrl(`/v1/projects/${pid}/datasets/${did}/run/stream`),
|
||||
deleteDataset: (pid: string, did: string) =>
|
||||
json<{ ok: boolean }>(`/v1/projects/${pid}/datasets/${did}`, { method: "DELETE" }),
|
||||
// handoff inbox
|
||||
listHandoffs: (pid: string, status = "open") => json<Handoff[]>(`/v1/projects/${pid}/handoffs?status=${status}`),
|
||||
replyHandoff: (pid: string, hid: string, message: string) =>
|
||||
json<{ ok: boolean }>(`/v1/projects/${pid}/handoffs/${hid}/reply`, { method: "POST", body: JSON.stringify({ message }) }),
|
||||
// embed (widget)
|
||||
getEmbed: (pid: string) => json<EmbedSettings>(`/v1/projects/${pid}/embed`),
|
||||
setEmbed: (pid: string, body: { enabled: boolean; allowed_origins: string[]; workflow_id?: string | null }) =>
|
||||
json<EmbedSettings>(`/v1/projects/${pid}/embed`, { method: "PUT", body: JSON.stringify(body) }),
|
||||
};
|
||||
|
||||
export interface InviteResult extends TeamMember { email_sent: boolean; invite_url?: string; }
|
||||
export interface Channel { id: string; type: string; name: string; workflow_id?: string | null; enabled: boolean; config: Record<string, any>; key?: string | null; inbound_url?: string; }
|
||||
export interface Trigger { id: string; workflow_id: string; node_id: string; kind: string; enabled: boolean; config: Record<string, any>; webhook_url?: string; last_fired_at?: string | null; }
|
||||
export interface Dataset { id: string; name: string; workflow_id?: string | null; score_mode: string; items: any[]; n_items: number; last_pass_rate?: number | null; }
|
||||
/** One scored case. `status` is "scored" for a normal pass/fail, else an inconclusive outcome
|
||||
* ("run_failed" / "unavailable" / "error"). tokens/cost/latency_ms are per-case run metrics
|
||||
* (absent on old runs). */
|
||||
export interface EvalResult {
|
||||
input: string; expected: string; answer: string; passed: boolean; reason?: string | null;
|
||||
status?: string; score?: number | null; tokens?: number; cost?: number; latency_ms?: number;
|
||||
}
|
||||
export interface EvalSummary { total: number; passed: number; pass_rate: number; inconclusive?: number; tokens?: number; cost_usd?: number; eval_run_id?: string | null; }
|
||||
export interface EvalReport { summary: EvalSummary; results: EvalResult[]; }
|
||||
/** A dataset run either scores (EvalReport) or fails before scoring (no workflow bound,
|
||||
* quota exceeded, …) - the backend returns a bare `{error}` for the latter, so callers
|
||||
* must narrow before reading `summary`/`results`. */
|
||||
export type EvalRunResult = EvalReport | { error: string };
|
||||
/** SSE frames from the streaming run endpoint (run/stream). */
|
||||
export interface EvalStreamStart { total: number; truncated?: boolean; items: { index: number; input: string; expected: string }[]; }
|
||||
export interface EvalStreamItem extends EvalResult { index: number; }
|
||||
export interface EvalStreamDone { summary: EvalSummary; results: EvalResult[]; }
|
||||
export interface Handoff { id: string; run_id: string; workflow_id?: string | null; customer?: string | null; customer_message?: string | null; reason?: string | null; status: string; at?: string | null; }
|
||||
export interface EmbedSettings { enabled: boolean; allowed_origins: string[]; workflow_id?: string | null; publishable_key?: string | null; embed_src?: string | null; }
|
||||
|
||||
export type EntityType = "workflow" | "agent" | "tool" | "component" | "auth_provider" | "kb_source" | "project";
|
||||
export interface EntityVersion { id: string; version_no: number; label?: string | null; author_email?: string | null; created_at?: string | null; }
|
||||
// One row of the read-only Knowledge activity feed (a file or Q&A pair added/changed/removed).
|
||||
export interface ActivityEntry { id: string; entity_type: "kb_source" | "qa_pair"; entity_id: string; action?: string | null; title: string; author_email?: string | null; created_at?: string | null; }
|
||||
// A full project config snapshot (Settings > History diffs consecutive ones per section).
|
||||
export interface ProjectVersion { id: string; version_no: number; author_email?: string | null; created_at?: string | null; snapshot: Record<string, any>; }
|
||||
|
||||
export interface MeResult { id: string; email: string | null; role: string; tenant_id: string; is_fallback: boolean; }
|
||||
export interface AuthResult { access_token: string; refresh_token: string; user: { id: string; email: string; role: string }; }
|
||||
export interface TeamMember { id: string; email: string; role: string; status: string; tenant_id: string; }
|
||||
export interface McpClientT { id: string; name: string; transport: string; url?: string | null; command?: string | null; args?: any; headers_ref?: string | null; enabled: boolean; disabled_tools?: string[]; }
|
||||
|
||||
export interface SSEFrame {
|
||||
event: string;
|
||||
data: any;
|
||||
}
|
||||
|
||||
/** Open an SSE stream and invoke `onFrame` per event. Supports GET (default) or POST
|
||||
* (pass init.method/body) - the backend assistant endpoint streams over POST. */
|
||||
export async function openSSE(
|
||||
url: string,
|
||||
onFrame: (frame: SSEFrame) => void,
|
||||
init?: RequestInit,
|
||||
): Promise<void> {
|
||||
const res = await fetch(url, {
|
||||
...init,
|
||||
cache: "no-store",
|
||||
headers: { Accept: "text/event-stream", "Cache-Control": "no-cache", ...authHeader(), ...(init?.headers || {}) },
|
||||
});
|
||||
if (!res.ok) throw new Error(`${res.status} ${res.statusText} on ${url}`);
|
||||
if (!res.body) throw new Error("No response body for SSE");
|
||||
const reader = res.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = "";
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
// SSE frames are separated by a blank line - handle both \n\n and \r\n\r\n.
|
||||
const chunks = buffer.split(/\r?\n\r?\n/);
|
||||
buffer = chunks.pop() || "";
|
||||
for (const chunk of chunks) {
|
||||
let event = "message";
|
||||
const dataLines: string[] = [];
|
||||
for (const line of chunk.split(/\r?\n/)) {
|
||||
if (line.startsWith("event:")) event = line.slice(6).trim();
|
||||
else if (line.startsWith("data:")) dataLines.push(line.slice(5).trim());
|
||||
}
|
||||
if (dataLines.length) {
|
||||
const raw = dataLines.join("\n");
|
||||
let data: any = raw;
|
||||
try {
|
||||
data = JSON.parse(raw);
|
||||
} catch {
|
||||
/* keep raw string */
|
||||
}
|
||||
onFrame({ event, data });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
/* Assembles an assistant reply into ordered parts (markdown text + inline UI components).
|
||||
|
||||
The problem this solves: a component is rendered as a side-effect of a tool call, which the
|
||||
agent runs BEFORE it writes its prose (the prose is generated in the final turn, after every
|
||||
tool returns). So if we placed components in frame-arrival order, every widget would jump to
|
||||
the TOP of the reply, ahead of all text - never in the middle, never at the end.
|
||||
|
||||
The fix (how ChatGPT/Claude interleave widgets): the component tool returns a tiny placeholder
|
||||
marker - [[forge:component:<instance_id>]] - and the model copies it into its reply text at the
|
||||
exact spot the widget belongs. Here we split the text on those markers and splice each
|
||||
already-received component instance into its place. The heavy props/markup never enter the
|
||||
token stream (only the ~10-token marker does); the model gets full control over ordering. */
|
||||
|
||||
export interface ComponentInstance {
|
||||
component_id: string;
|
||||
instance_id?: string;
|
||||
name?: string;
|
||||
version?: number;
|
||||
props?: Record<string, any>;
|
||||
actions?: Record<string, any>[];
|
||||
}
|
||||
|
||||
export type Part =
|
||||
| { kind: "text"; text: string }
|
||||
| { kind: "component"; inst: ComponentInstance };
|
||||
|
||||
// The literal a component tool's ack tells the model to place. Keep in sync with the backend
|
||||
// (tools/components.py COMPONENT_MARKER). Instance ids are uuid4().hex, but we accept any
|
||||
// url-safe token so a slightly-mangled id still matches.
|
||||
const MARKER_PREFIX = "[[forge:component:";
|
||||
const MARKER_RE = /\[\[forge:component:([A-Za-z0-9_-]+)\]\]/g;
|
||||
|
||||
/* While streaming, the closing `]]` of a marker may not have arrived yet (e.g. the buffer ends
|
||||
with "…[[forge:comp"). Return the index where such a half-typed marker begins so the caller can
|
||||
hide it until it completes - otherwise it flashes as raw text. -1 when there's no partial tail. */
|
||||
function trailingPartialMarkerStart(text: string): number {
|
||||
// 1) the "[[forge:component:" prefix itself is still being typed - longest suffix of `text`
|
||||
// that equals a non-empty prefix of MARKER_PREFIX.
|
||||
for (let n = Math.min(MARKER_PREFIX.length, text.length); n > 0; n--) {
|
||||
if (text.slice(text.length - n) === MARKER_PREFIX.slice(0, n)) return text.length - n;
|
||||
}
|
||||
// 2) the prefix is complete and the id is streaming, but the closing `]]` hasn't arrived.
|
||||
const m = /\[\[forge:component:[A-Za-z0-9_-]*$/.exec(text);
|
||||
return m ? m.index : -1;
|
||||
}
|
||||
|
||||
/* Split `raw` on component markers and produce ordered parts, resolving each marker to its
|
||||
component instance. Unknown markers (no matching instance) are dropped from the text. When
|
||||
not streaming, any rendered component the model never referenced with a marker is appended at
|
||||
the end (in arrival order) so a widget is never lost - and never jumps ahead of the prose. */
|
||||
function assembleParts(
|
||||
raw: string,
|
||||
instances: Record<string, ComponentInstance>,
|
||||
order: string[],
|
||||
streaming: boolean,
|
||||
): Part[] {
|
||||
let text = raw || "";
|
||||
if (streaming) {
|
||||
const cut = trailingPartialMarkerStart(text);
|
||||
if (cut >= 0) text = text.slice(0, cut);
|
||||
}
|
||||
const parts: Part[] = [];
|
||||
const used = new Set<string>();
|
||||
const push = (s: string) => {
|
||||
if (!s) return;
|
||||
const last = parts[parts.length - 1];
|
||||
if (last && last.kind === "text") last.text += s;
|
||||
else parts.push({ kind: "text", text: s });
|
||||
};
|
||||
const re = new RegExp(MARKER_RE.source, "g");
|
||||
let last = 0;
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = re.exec(text)) !== null) {
|
||||
push(text.slice(last, m.index));
|
||||
const inst = instances[m[1]];
|
||||
// Render each instance at most once (a duplicated marker is ignored); an unknown id is
|
||||
// simply dropped so a stray/hallucinated marker never shows as literal text.
|
||||
if (inst && !used.has(m[1])) {
|
||||
parts.push({ kind: "component", inst });
|
||||
used.add(m[1]);
|
||||
}
|
||||
last = m.index + m[0].length;
|
||||
}
|
||||
push(text.slice(last));
|
||||
if (!streaming) {
|
||||
for (const id of order) {
|
||||
if (!used.has(id) && instances[id]) parts.push({ kind: "component", inst: instances[id] });
|
||||
}
|
||||
}
|
||||
// Drop whitespace-only text parts (e.g. the blank line between two adjacent components) - each
|
||||
// text part renders as its own markdown block, so leading/trailing whitespace is meaningless.
|
||||
return parts.filter((p) => p.kind === "component" || p.text.trim().length > 0);
|
||||
}
|
||||
|
||||
/* Accumulates a streaming assistant reply: token text plus the component instances the agent
|
||||
rendered (keyed by instance_id). `parts()` produces the ordered render list at any point.
|
||||
|
||||
Shared by every chat surface (Playground, embed widget, workflow test panel) so they stay
|
||||
consistent. */
|
||||
export class ReplyAccumulator {
|
||||
text = "";
|
||||
private instances: Record<string, ComponentInstance> = {};
|
||||
private order: string[] = [];
|
||||
|
||||
addText(s: string) {
|
||||
this.text += s;
|
||||
}
|
||||
|
||||
addComponent(inst: ComponentInstance) {
|
||||
const id = String(inst?.instance_id || `c${this.order.length}`);
|
||||
if (!(id in this.instances)) this.order.push(id);
|
||||
this.instances[id] = inst;
|
||||
}
|
||||
|
||||
hasComponents() {
|
||||
return this.order.length > 0;
|
||||
}
|
||||
|
||||
/** Ordered parts for rendering. `streaming` hides a half-arrived marker and withholds any
|
||||
component whose marker hasn't streamed in yet (it appears the moment the marker does, in
|
||||
its proper place). Pass `finalText` to render the reconciled final text instead of the
|
||||
live token buffer. */
|
||||
parts(opts?: { streaming?: boolean; finalText?: string }): Part[] {
|
||||
const text = opts?.finalText ?? this.text;
|
||||
return assembleParts(text, this.instances, this.order, !!opts?.streaming);
|
||||
}
|
||||
|
||||
/** Reconcile the streamed token buffer with the run's authoritative final answer. Prefer the
|
||||
streamed text (it spans every turn, in order, and carries the markers); fall back to - or
|
||||
append - the final answer only when it adds something the stream didn't (a non-LLM node's
|
||||
output, or an error message). */
|
||||
resolveText(finalAnswer?: string): string {
|
||||
const buf = this.text;
|
||||
const fa = (finalAnswer || "").trim();
|
||||
if (!fa) return buf;
|
||||
if (!buf.trim()) return finalAnswer || "";
|
||||
if (buf.includes(fa)) return buf;
|
||||
return `${buf}\n\n${finalAnswer}`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,393 @@
|
||||
/* Forge mock data - generic, plausible SaaS content. Ported from the design handoff.
|
||||
Screens not yet wired to the live API render from this; the Dashboard merges live
|
||||
projects from the backend when available (see lib/api.ts). */
|
||||
|
||||
export const spark = (n: number, base: number, amp: number): number[] =>
|
||||
Array.from({ length: n }, (_, i) =>
|
||||
Math.max(0, Math.round(base + Math.sin(i * 0.8) * amp + (Math.random() - 0.5) * amp * 0.8)),
|
||||
);
|
||||
|
||||
/* Format a USD cost. Cheap models cost fractions of a cent per run, so a flat
|
||||
$0.00 reads as "broken" even when tracking works - show small amounts with
|
||||
enough precision (down to a sub-cent floor) so non-zero cost is visible. */
|
||||
export const fmtUSD = (v: number | null | undefined): string => {
|
||||
const n = v || 0;
|
||||
if (n <= 0) return "$0.00";
|
||||
if (n >= 0.01) return `$${n.toFixed(2)}`;
|
||||
if (n >= 0.0001) return `$${n.toFixed(4)}`;
|
||||
return "<$0.0001";
|
||||
};
|
||||
|
||||
// The model catalog now lives on the backend (single source of truth so the picker and cost
|
||||
// engine can't disagree). Fetch it with `useModels()` from lib/models.ts.
|
||||
|
||||
export const NODE_CATALOG = [
|
||||
{ group: "Flow", color: "var(--io-control)", items: [
|
||||
{ type: "start", icon: "n_start", label: "Start", desc: "Entry marker" },
|
||||
{ type: "end", icon: "n_end", label: "End", desc: "Terminal node" },
|
||||
{ type: "router", icon: "n_router", label: "Router", desc: "Conditional branch" },
|
||||
{ type: "loop", icon: "n_loop", label: "Loop", desc: "Bounded iteration" },
|
||||
{ type: "parallel_fanout", icon: "n_fanout", label: "Fan-out", desc: "Map over a list" },
|
||||
{ type: "join", icon: "n_join", label: "Join", desc: "Wait-for-all / reduce" },
|
||||
]},
|
||||
{ group: "Agents", color: "var(--accent)", items: [
|
||||
{ type: "agent", icon: "n_agent", label: "Agent", desc: "ReAct tool loop" },
|
||||
{ type: "deep_agent", icon: "n_deepagent", label: "Deep Agent", desc: "Planning + subagents harness" },
|
||||
]},
|
||||
{ group: "Model & Tools", color: "var(--io-json)", items: [
|
||||
{ type: "llm", icon: "n_llm", label: "LLM", desc: "Single model call" },
|
||||
{ type: "classifier", icon: "n_router", label: "Classifier", desc: "Intent classification" },
|
||||
{ type: "tool_call", icon: "n_tool", label: "Tool Call", desc: "Run a specific tool" },
|
||||
{ type: "transform", icon: "n_transform", label: "Transform", desc: "JMESPath data map" },
|
||||
]},
|
||||
{ group: "Knowledge", color: "var(--io-vector)", items: [
|
||||
{ type: "retrieval", icon: "n_retrieval", label: "Retrieval", desc: "RAG + Q&A" },
|
||||
]},
|
||||
{ group: "Human", color: "var(--warn)", items: [
|
||||
{ type: "human_input", icon: "n_human", label: "Human Input", desc: "HITL pause via interrupt" },
|
||||
]},
|
||||
{ group: "Integrations", color: "var(--signal)", items: [
|
||||
{ type: "subworkflow", icon: "n_subworkflow", label: "Subworkflow", desc: "Embed another graph" },
|
||||
{ type: "webhook_out", icon: "n_webhook", label: "Webhook", desc: "Call external URL" },
|
||||
{ type: "emit_event", icon: "n_emit", label: "Emit Event", desc: "Push custom SSE frame" },
|
||||
]},
|
||||
];
|
||||
|
||||
export const NODE_META: Record<string, any> = {};
|
||||
NODE_CATALOG.forEach((g) => g.items.forEach((it) => (NODE_META[it.type] = { ...it, group: g.group, color: g.color })));
|
||||
|
||||
/* The friendly, human-readable name for a node - the SAME string the canvas shows on the node
|
||||
card (ForgeNode): the operator-set `config.name`, else the node type's catalog label, else the
|
||||
raw type. Runs, traces and the RUN STEPS list key everything on the node `id` (needed for the
|
||||
graph and the per-node cost rollup), so the id stays canonical - this is a DISPLAY layer that
|
||||
makes a bigger workflow readable without losing traceability (the id is still shown alongside). */
|
||||
export type NodeLabel = { label: string; type: string };
|
||||
|
||||
// Build an `id -> {label, type}` map from a workflow definition. Accepts either the executable
|
||||
// (`{id,type,config}`) or the canvas / React-Flow (`{id, data:{nodeType, config}}`) node shape, so
|
||||
// callers can pass whichever they have (mirrors WorkflowTestPanel's dual-shape read).
|
||||
export function buildNodeLabels(workflow: any): Record<string, NodeLabel> {
|
||||
const nodes = workflow?.executable?.nodes || workflow?.canvas?.nodes || workflow?.nodes || [];
|
||||
const map: Record<string, NodeLabel> = {};
|
||||
for (const n of nodes) {
|
||||
if (!n?.id) continue;
|
||||
const type = n.type ?? n?.data?.nodeType ?? "";
|
||||
const name = n?.config?.name ?? n?.data?.config?.name;
|
||||
map[n.id] = { label: name || NODE_META[type]?.label || type || n.id, type };
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
// The label to show for a run step / span whose canonical name is a node id. Falls back to the
|
||||
// raw name for spans that aren't graph nodes (e.g. "model · …", "embedding · …", a subagent).
|
||||
export function nodeLabel(name: string, labels: Record<string, NodeLabel>): string {
|
||||
return labels[name]?.label || name;
|
||||
}
|
||||
|
||||
/* Hover help for the canvas palette: what each node is for + a tiny concrete example.
|
||||
Keep these in plain product language - they're the first thing a new user reads. */
|
||||
export const NODE_HELP: Record<string, { what: string; example: string }> = {
|
||||
start: {
|
||||
what: "The entry point. Every run begins here - wire it to your first real step.",
|
||||
example: "Start → Retrieval → Agent → End",
|
||||
},
|
||||
end: {
|
||||
what: "Marks where the run finishes. A workflow can have several Ends (one per branch).",
|
||||
example: "FAQ hit → End (answered early), miss → Agent → End",
|
||||
},
|
||||
router: {
|
||||
what: "Branches the flow based on a value already in state - no model call. One labeled connector per case, plus Else. With 'multi' on, a list value (multi-label Classifier) runs EVERY matching branch in parallel. Always set a Default - without one, an unmatched value ends the run silently.",
|
||||
example: "intent = 'refund' → refund_agent · 'cancel' → retention_agent · Else → general_agent",
|
||||
},
|
||||
classifier: {
|
||||
what: "Calls the model once (structured output) to pick a label from your list and writes it to state (default: intent). Multi-label mode writes EVERY applicable label (a list) - pair with a multi Router so two-part questions reach both specialists. Put a Router after it to branch.",
|
||||
example: "labels: return_item, cancel, question - “I want my money back” → return_item",
|
||||
},
|
||||
agent: {
|
||||
what: "A model with tools and a system prompt that loops reason → act until it can answer (ReAct). The workhorse node for answering users.",
|
||||
example: "Support agent with a weather tool + knowledge-base grounding",
|
||||
},
|
||||
deep_agent: {
|
||||
what: "An agent plus planning (write_todos), a virtual filesystem, and subagents - for long, multi-step tasks that need decomposition.",
|
||||
example: "“Research our top 3 competitors and draft a comparison”",
|
||||
},
|
||||
llm: {
|
||||
what: "One single model call - no tools, no loop. A cheap text step for rewriting, summarizing, or extracting.",
|
||||
example: "“Summarize the conversation so far into 2 sentences”",
|
||||
},
|
||||
transform: {
|
||||
what: "Reshapes state with a JMESPath expression - pure data, no model. Reads input_key, writes output_key.",
|
||||
example: "messages[-1].content → question",
|
||||
},
|
||||
tool_call: {
|
||||
what: "Invokes one specific project tool directly with fixed arguments - no model deciding whether to call it. Result lands in a state key.",
|
||||
example: "Always fetch get_weather before the agent answers",
|
||||
},
|
||||
human_input: {
|
||||
what: "PAUSES the run with a real interrupt until a person approves or rejects in the Playground. Use for irreversible or sensitive steps.",
|
||||
example: "Agent drafts a refund email → human approves → it goes out",
|
||||
},
|
||||
webhook_out: {
|
||||
what: "Sends data from the run to an external URL (POST/PUT/…) - push results into your own systems.",
|
||||
example: "POST the final answer to your Slack webhook",
|
||||
},
|
||||
emit_event: {
|
||||
what: "Emits a named custom event into the run's live stream - for UI badges, metrics, or integrations listening to the run.",
|
||||
example: "Emit 'escalated' when the agent hands off to a human",
|
||||
},
|
||||
retrieval: {
|
||||
what: "Pulls the most relevant knowledge into context for the user's question - place it right before a grounded agent. Toggle DOCUMENTS (RAG over your chunks) and Q&A PAIRS independently: use either or both. Tip: for multi-part questions, give the agent a knowledge_search TOOL instead, so it can search per sub-question.",
|
||||
example: "KB says “returns within 30 days” → agent answers with that policy",
|
||||
},
|
||||
// --- flow ---
|
||||
loop: {
|
||||
what: "Repeats a section of the graph until a condition is false or a max-iteration cap is hit. It increments _loop_count and writes _loop = continue/done - wire a Router on _loop and point the 'continue' branch back to this node.",
|
||||
example: "Refine a draft up to 3 times: loop → agent → loop (until good enough)",
|
||||
},
|
||||
parallel_fanout: {
|
||||
what: "Maps over a list in state: runs a child node ONCE PER ITEM, all in parallel (LangGraph Send). Each child reads its item from the chosen state key. Children write to an add-reducer key so results aggregate.",
|
||||
example: "over: tickets → run a summarizer per ticket, in parallel",
|
||||
},
|
||||
join: {
|
||||
what: "A convergence point where parallel branches (e.g. a Parallel Fanout's children) meet before the flow continues. Results aggregate via an add-reducer state key.",
|
||||
example: "Fanout → (summarize each) → Join → final agent composes the digest",
|
||||
},
|
||||
subworkflow: {
|
||||
what: "Runs ANOTHER workflow in this project as a reusable component (shares the messages state). Build a flow once - 'verify identity', 'look up order' - and drop it into many workflows.",
|
||||
example: "Support flow → Subworkflow: 'verify_identity' → continue",
|
||||
},
|
||||
handoff: {
|
||||
what: "Escalates the conversation to a HUMAN: pauses the run and opens a ticket in the Agent inbox. When an agent replies there, their message becomes the assistant's answer and is delivered over the channel.",
|
||||
example: "Agent can't resolve → Handoff → a person replies from the inbox",
|
||||
},
|
||||
// --- triggers (entry points) ---
|
||||
webhook_in: {
|
||||
what: "Starts the workflow when an external system POSTs to this workflow's hook URL (shown on the Triggers screen after publish). Optionally verify an HMAC signature. Map the JSON body to the message with a JMESPath.",
|
||||
example: "Your app POSTs {text: '…'} → the workflow runs and replies",
|
||||
},
|
||||
schedule: {
|
||||
what: "Runs the workflow on a recurring schedule - every N minutes or a cron expression. Sends a fixed message into the flow each time.",
|
||||
example: "Every weekday 9am → 'Summarize overnight tickets'",
|
||||
},
|
||||
email_in: {
|
||||
what: "Starts the workflow when an email arrives in the connected mailbox (configure the Email channel under Connect → Channels). Optionally replies to the sender with the answer.",
|
||||
example: "support@yourco.com receives a question → agent replies by email",
|
||||
},
|
||||
app_event: {
|
||||
what: "Polls an external source on an interval and runs the workflow once PER NEW item (deduped by a key you choose). Turns any API/feed into an event source.",
|
||||
example: "Poll the issues API every 5 min → triage each new issue",
|
||||
},
|
||||
};
|
||||
|
||||
export const CAT_BY_TYPE: Record<string, string> = {
|
||||
start: "control", end: "control", router: "control", loop: "control", parallel_fanout: "control", join: "control",
|
||||
agent: "agent", deep_agent: "agent",
|
||||
llm: "json", tool_call: "json", transform: "json", code: "json",
|
||||
retrieval: "vector",
|
||||
human_input: "human",
|
||||
subworkflow: "signal", webhook_out: "signal", emit_event: "signal",
|
||||
};
|
||||
|
||||
export const workflowNodes = [
|
||||
{ id: "start", type: "start", position: { x: 40, y: 300 }, data: {}, summary: [] as string[] },
|
||||
{ id: "faq_deflect", type: "retrieval", position: { x: 220, y: 286 }, data: { top_k: 5, include_qa: true }, title: "Knowledge", summary: ["docs top_k 5", "+ Q&A"] },
|
||||
{ id: "intent_router", type: "router", position: { x: 430, y: 280 }, data: {}, title: "Intent Router", summary: ["expression · state.intent", "billing · technical · default"], cases: ["billing", "technical", "default"] },
|
||||
{ id: "kb_search", type: "retrieval", position: { x: 700, y: 110 }, data: {}, title: "Help Docs", summary: ["3 sources · top_k 5", "hybrid + rerank"] },
|
||||
{ id: "billing_agent", type: "agent", position: { x: 690, y: 270 }, data: {}, title: "Billing Agent", summary: ["claude-sonnet-4-6", "2 tools · 4 middleware"], mw: ["summarization", "tool_call_limit", "human_in_the_loop", "pii"] },
|
||||
{ id: "tech_agent", type: "deep_agent", position: { x: 690, y: 470 }, data: {}, title: "Tech Agent", summary: ["gpt-5.4 · deep agent", "subagents 2 · planning on"], mw: ["summarization", "context_editing"] },
|
||||
{ id: "approve_refund", type: "human_input", position: { x: 980, y: 270 }, data: {}, title: "Approve Refund", summary: ['"Approve this refund?"', "approve · edit · reject"] },
|
||||
{ id: "end", type: "end", position: { x: 1210, y: 320 }, data: {}, summary: [] as string[] },
|
||||
];
|
||||
export const workflowEdges = [
|
||||
{ id: "e1", source: "start", target: "faq_deflect", io: "control" },
|
||||
{ id: "e2", source: "faq_deflect", target: "intent_router", io: "messages" },
|
||||
{ id: "e3", source: "intent_router", target: "billing_agent", io: "control", label: "billing" },
|
||||
{ id: "e4", source: "intent_router", target: "tech_agent", io: "control", label: "technical" },
|
||||
{ id: "e5", source: "kb_search", target: "billing_agent", io: "json" },
|
||||
{ id: "e6", source: "billing_agent", target: "approve_refund", io: "messages" },
|
||||
{ id: "e7", source: "approve_refund", target: "end", io: "messages" },
|
||||
{ id: "e8", source: "tech_agent", target: "end", io: "messages" },
|
||||
];
|
||||
export const runOrder = ["start", "faq_deflect", "intent_router", "kb_search", "billing_agent", "approve_refund", "end"];
|
||||
|
||||
export const TOOLS = [
|
||||
{ id: "t_get_order", name: "get_order", kind: "rest_api", auth: "orders_session", enabled: true, tested: "pass", version: 4, desc: "Fetch an order by ID from the commerce API, including line items and totals.", method: "GET", url: "https://api.acme.dev/v2/orders/{order_id}", rawTok: 1240, projTok: 92 },
|
||||
{ id: "t_get_invoice", name: "get_invoice", kind: "rest_api", auth: "orders_session", enabled: true, tested: "pass", version: 2, desc: "Retrieve a customer invoice and its payment status.", method: "GET", url: "https://api.acme.dev/v2/invoices/{invoice_id}", rawTok: 880, projTok: 64 },
|
||||
{ id: "t_search_kb", name: "search_catalog", kind: "graphql", auth: null, enabled: true, tested: "pass", version: 1, desc: "Query the product catalog via GraphQL.", method: "POST", url: "https://api.acme.dev/graphql", rawTok: 2100, projTok: 140 },
|
||||
{ id: "t_refund", name: "submit_refund", kind: "rest_api", auth: "orders_session", enabled: true, tested: "fail", version: 3, desc: "Issue a refund against an order. Requires human approval.", method: "POST", url: "https://api.acme.dev/v2/orders/{order_id}/refunds", rawTok: 320, projTok: 48 },
|
||||
{ id: "t_geo", name: "geocode_address", kind: "code", auth: null, enabled: true, tested: "untested", version: 1, desc: "Normalize and geocode a postal address using a sandboxed Python function.", rawTok: 0, projTok: 0 },
|
||||
{ id: "t_jira", name: "create_ticket", kind: "mcp", auth: "jira_oauth", enabled: true, tested: "pass", version: 1, desc: "Create an issue in the project tracker over MCP.", rawTok: 540, projTok: 70 },
|
||||
{ id: "t_web", name: "web_search", kind: "builtin", auth: null, enabled: false, tested: "untested", version: 1, desc: "Search the public web (Tavily).", rawTok: 0, projTok: 0 },
|
||||
];
|
||||
|
||||
export const AUTH_PROVIDERS = [
|
||||
{ id: "orders_session", name: "orders_session", kind: "csrf_session", tested: "pass", usedBy: 3, ttl: 1800 },
|
||||
{ id: "jira_oauth", name: "jira_oauth", kind: "oauth2_client_credentials", tested: "pass", usedBy: 1, ttl: 3600 },
|
||||
{ id: "stripe_bearer", name: "stripe_bearer", kind: "bearer", tested: "pass", usedBy: 2, ttl: 0 },
|
||||
{ id: "legacy_basic", name: "legacy_basic", kind: "basic", tested: "untested", usedBy: 0, ttl: 0 },
|
||||
];
|
||||
|
||||
export const AGENTS = [
|
||||
{ id: "a_billing", name: "billing_agent", flavor: "agent", model: "anthropic:claude-sonnet-4-6", tools: 2, mw: 4, updated: "2h ago" },
|
||||
{ id: "a_tech", name: "tech_agent", flavor: "deep_agent", model: "openai:gpt-5.4", tools: 5, mw: 3, updated: "1d ago" },
|
||||
{ id: "a_triage", name: "triage_agent", flavor: "agent", model: "openai:gpt-5.4-mini", tools: 1, mw: 2, updated: "3d ago" },
|
||||
{ id: "a_research", name: "research_agent", flavor: "deep_agent", model: "google_genai:gemini-3.1-pro-preview", tools: 3, mw: 5, updated: "5d ago" },
|
||||
];
|
||||
|
||||
export const MIDDLEWARE_CATALOG = [
|
||||
{ cat: "Memory & Context", color: "var(--signal)", items: [
|
||||
{ type: "summarization", name: "Summarization", desc: "Summarize older messages near a token limit." },
|
||||
{ type: "context_editing", name: "Context Editing", desc: "Clear old tool outputs past a threshold." },
|
||||
{ type: "todo", name: "Planning (To-do)", desc: "Add a write_todos planning tool." },
|
||||
]},
|
||||
{ cat: "Safety & Guardrails", color: "var(--err)", items: [
|
||||
{ type: "pii", name: "PII Handling", desc: "Detect & redact/mask/block PII." },
|
||||
{ type: "guardrail_regex", name: "Regex Guardrail", desc: "Block or flag matched patterns." },
|
||||
{ type: "openai_moderation", name: "Moderation", desc: "OpenAI moderation on input/output." },
|
||||
]},
|
||||
{ cat: "Reliability", color: "var(--info)", items: [
|
||||
{ type: "tool_retry", name: "Tool Retry", desc: "Retry failed tool calls with backoff." },
|
||||
{ type: "model_retry", name: "Model Retry", desc: "Retry failed model calls." },
|
||||
{ type: "model_fallback", name: "Model Fallback", desc: "Failover across providers." },
|
||||
]},
|
||||
{ cat: "Cost & Limits", color: "var(--warn)", items: [
|
||||
{ type: "model_call_limit", name: "Model Call Limit", desc: "Cap model calls per run/thread." },
|
||||
{ type: "tool_call_limit", name: "Tool Call Limit", desc: "Cap tool calls, global or per-tool." },
|
||||
{ type: "tenant_budget", name: "Budget Cap", desc: "Stop when cost/tokens exceed a cap." },
|
||||
{ type: "llm_tool_selector", name: "Tool Selector", desc: "Pre-select relevant tools (saves tokens)." },
|
||||
]},
|
||||
{ cat: "Human Oversight", color: "var(--accent)", items: [
|
||||
{ type: "human_in_the_loop", name: "Human-in-the-loop", desc: "Pause for approval on sensitive tools." },
|
||||
]},
|
||||
{ cat: "Provider-specific", color: "var(--io-json)", items: [
|
||||
{ type: "anthropic_prompt_caching", name: "Prompt Caching", desc: "Cache the system prompt (Anthropic)." },
|
||||
]},
|
||||
{ cat: "Advanced", color: "var(--io-vector)", items: [
|
||||
{ type: "dynamic_model_by_state", name: "Dynamic Model", desc: "Switch model at runtime by state." },
|
||||
{ type: "tool_filter_by_context", name: "Tool Filter", desc: "Show/hide tools by context/role." },
|
||||
]},
|
||||
];
|
||||
export const MW_META: Record<string, any> = {};
|
||||
MIDDLEWARE_CATALOG.forEach((c) => c.items.forEach((it) => (MW_META[it.type] = { ...it, cat: c.cat, color: c.color })));
|
||||
|
||||
export const AGENT_MW_STACK = [
|
||||
{ type: "summarization", enabled: true, summary: "Summarize when > 4,000 tok · keep last 20 msgs" },
|
||||
{ type: "tool_call_limit", enabled: true, summary: "get_order · max 3 calls per run" },
|
||||
{ type: "human_in_the_loop", enabled: true, summary: "submit_refund → approve · edit · reject" },
|
||||
{ type: "pii", enabled: false, summary: "email → redact (input)" },
|
||||
];
|
||||
|
||||
export const PROJECTS = [
|
||||
{ id: "p_support", name: "Customer Support", slug: "customer-support", status: "active", workflows: 4, tools: 7, runs7d: 1840, spark: spark(14, 60, 30), edited: "12m ago" },
|
||||
{ id: "p_ops", name: "Internal Ops Bot", slug: "internal-ops-bot", status: "active", workflows: 2, tools: 5, runs7d: 620, spark: spark(14, 28, 16), edited: "3h ago" },
|
||||
{ id: "p_sales", name: "Sales Assistant", slug: "sales-assistant", status: "active", workflows: 3, tools: 9, runs7d: 980, spark: spark(14, 40, 22), edited: "1d ago" },
|
||||
{ id: "p_research", name: "Research Copilot", slug: "research-copilot", status: "draft", workflows: 1, tools: 3, runs7d: 40, spark: spark(14, 6, 6), edited: "2d ago" },
|
||||
{ id: "p_data", name: "Data Q&A", slug: "data-qa", status: "active", workflows: 2, tools: 4, runs7d: 410, spark: spark(14, 20, 12), edited: "4d ago" },
|
||||
{ id: "p_archived", name: "Legacy Triage", slug: "legacy-triage", status: "draft", workflows: 1, tools: 2, runs7d: 0, spark: spark(14, 2, 2), edited: "3w ago" },
|
||||
];
|
||||
|
||||
export const RECENT_RUNS = [
|
||||
{ id: "r1", project: "Customer Support", workflow: "Support Router", status: "done", dur: "4.2s", tokens: "12.4k", trigger: "email", time: "2m ago" },
|
||||
{ id: "r2", project: "Sales Assistant", workflow: "Lead Qualifier", status: "done", dur: "2.1s", tokens: "6.1k", trigger: "api", time: "5m ago" },
|
||||
{ id: "r3", project: "Customer Support", workflow: "Support Router", status: "interrupted", dur: "1.8s", tokens: "3.2k", trigger: "email", time: "8m ago" },
|
||||
{ id: "r4", project: "Internal Ops Bot", workflow: "PR Summarizer", status: "error", dur: "0.9s", tokens: "1.1k", trigger: "mcp", time: "14m ago" },
|
||||
{ id: "r5", project: "Data Q&A", workflow: "Metrics Explainer", status: "done", dur: "3.6s", tokens: "9.8k", trigger: "playground", time: "21m ago" },
|
||||
{ id: "r6", project: "Customer Support", workflow: "Refund Flow", status: "done", dur: "5.5s", tokens: "15.2k", trigger: "email", time: "33m ago" },
|
||||
];
|
||||
|
||||
export const KB_SOURCES = [
|
||||
{ id: "k1", name: "Help Center (acme.dev/help)", kind: "url", status: "ready", chunks: 482, size: "3.1 MB", model: "text-embedding-3-small", updated: "1h ago" },
|
||||
{ id: "k2", name: "Billing FAQ.pdf", kind: "file", status: "ready", chunks: 96, size: "740 KB", model: "text-embedding-3-small", updated: "1d ago" },
|
||||
{ id: "k3", name: "API Reference.pdf", kind: "file", status: "processing", prog: 62, chunks: 210, size: "2.4 MB", model: "text-embedding-3-small", updated: "now" },
|
||||
{ id: "k4", name: "s3://acme-docs/policies", kind: "s3", status: "ready", chunks: 154, size: "1.2 MB", model: "text-embedding-3-small", updated: "3d ago" },
|
||||
{ id: "k5", name: "Onboarding notes", kind: "text", status: "error", chunks: 0, size: "12 KB", model: "-", updated: "5d ago" },
|
||||
];
|
||||
export const QA_PAIRS = [
|
||||
{ id: "q1", q: "How do I reset my password?", a: "Go to Settings → Security → Reset password. A link is emailed to you.", kind: "faq", tags: ["account"], upvotes: 42, used: "3m ago" },
|
||||
{ id: "q2", q: 'Why is my order stuck in "processing"?', a: "Processing clears within 30 min. If longer, the payment hold failed - retry the card.", kind: "error_workaround", tags: ["orders", "billing"], upvotes: 31, used: "18m ago" },
|
||||
{ id: "q3", q: "Can I change my plan mid-cycle?", a: "Yes. Upgrades are prorated immediately; downgrades apply next cycle.", kind: "faq", tags: ["billing"], upvotes: 27, used: "1h ago" },
|
||||
{ id: "q4", q: "Error E-4012 on checkout", a: "E-4012 means an expired CSRF token. Refresh the page and retry.", kind: "error_workaround", tags: ["errors"], upvotes: 19, used: "2h ago" },
|
||||
];
|
||||
export const SEARCH_HITS = [
|
||||
{ title: "Billing FAQ.pdf · §3 Refunds", vec: 0.91, fts: 0.74, fused: 0.88, text: "Refunds are issued to the original payment method within 5–7 business days…" },
|
||||
{ title: "Help Center · Cancel an order", vec: 0.86, fts: 0.81, fused: 0.85, text: "You can cancel an order before it ships from the Orders page…" },
|
||||
{ title: "API Reference.pdf · POST /refunds", vec: 0.83, fts: 0.62, fused: 0.79, text: "Creates a refund object. Requires an order in a refundable state…" },
|
||||
{ title: "Policies · Returns window", vec: 0.71, fts: 0.55, fused: 0.68, text: "Items may be returned within 30 days of delivery for a full refund…" },
|
||||
];
|
||||
|
||||
export const TRACE_RUNS = [
|
||||
{ id: "tr1", workflow: "Support Router", status: "done", started: "14:22:08", dur: "4.2s", tokens: "12.4k", cost: "$0.038", trigger: "email" },
|
||||
{ id: "tr2", workflow: "Support Router", status: "interrupted", started: "14:18:51", dur: "1.8s", tokens: "3.2k", cost: "$0.009", trigger: "email" },
|
||||
{ id: "tr3", workflow: "Refund Flow", status: "done", started: "14:10:33", dur: "5.5s", tokens: "15.2k", cost: "$0.047", trigger: "api" },
|
||||
{ id: "tr4", workflow: "PR Summarizer", status: "error", started: "13:58:02", dur: "0.9s", tokens: "1.1k", cost: "$0.003", trigger: "mcp" },
|
||||
{ id: "tr5", workflow: "Metrics Explainer", status: "done", started: "13:44:19", dur: "3.6s", tokens: "9.8k", cost: "$0.030", trigger: "playground" },
|
||||
];
|
||||
export const SPANS = [
|
||||
{ id: "s0", name: "Support Router", kind: "chain", depth: 0, start: 0, dur: 4200, tokens: "12.4k", cost: "$0.038" },
|
||||
{ id: "s1", name: "faq_deflect", kind: "retriever", depth: 1, start: 40, dur: 210, tokens: "-", cost: "$0.000" },
|
||||
{ id: "s2", name: "intent_router", kind: "node", depth: 1, start: 260, dur: 60, tokens: "-", cost: "$0.000" },
|
||||
{ id: "s3", name: "billing_agent", kind: "agent", depth: 1, start: 330, dur: 3600, tokens: "11.9k", cost: "$0.036" },
|
||||
{ id: "s4", name: "model · claude-sonnet-4-6", kind: "llm", depth: 2, start: 360, dur: 1400, tokens: "4.1k", cost: "$0.013" },
|
||||
{ id: "s5", name: "tool · get_order", kind: "tool", depth: 2, start: 1780, dur: 320, tokens: "92", cost: "$0.000" },
|
||||
{ id: "s6", name: "model · claude-sonnet-4-6", kind: "llm", depth: 2, start: 2130, dur: 1700, tokens: "7.7k", cost: "$0.023" },
|
||||
{ id: "s7", name: "approve_refund", kind: "node", depth: 1, start: 3950, dur: 250, tokens: "-", cost: "$0.000" },
|
||||
];
|
||||
export const COST_BY_NODE = [
|
||||
{ name: "billing_agent", cost: 0.036, color: "var(--accent)" },
|
||||
{ name: "tech_agent", cost: 0.0, color: "var(--io-json)" },
|
||||
{ name: "kb_search", cost: 0.001, color: "var(--io-vector)" },
|
||||
{ name: "router", cost: 0.0005, color: "var(--io-control)" },
|
||||
];
|
||||
|
||||
export const SECRETS = [
|
||||
{ id: "sec1", name: "orders_api_creds", kind: "csrf_session", version: 3, used: "2m ago" },
|
||||
{ id: "sec2", name: "openai_key", kind: "api_key", version: 1, used: "1m ago" },
|
||||
{ id: "sec3", name: "anthropic_key", kind: "api_key", version: 1, used: "1m ago" },
|
||||
{ id: "sec4", name: "jira_client_secret", kind: "oauth2", version: 2, used: "1h ago" },
|
||||
{ id: "sec5", name: "stripe_secret", kind: "bearer", version: 1, used: "3h ago" },
|
||||
];
|
||||
export const AUDIT = [
|
||||
{ action: "secret.read", actor: "orders_session", resource: "orders_api_creds", at: "14:22:09" },
|
||||
{ action: "workflow.publish", actor: "you@acme.dev", resource: "Support Router v7", at: "13:40:11" },
|
||||
{ action: "tool.test", actor: "you@acme.dev", resource: "submit_refund", at: "13:38:55" },
|
||||
{ action: "secret.write", actor: "you@acme.dev", resource: "stripe_secret", at: "11:02:30" },
|
||||
];
|
||||
|
||||
// Sectioned nav: top-level leaves (Overview, Settings) plus collapsible groups
|
||||
// (Build / Deploy / Observe). The sidebar renders a leaf as a button and a group as a
|
||||
// labeled, collapsible section. `countKey` shows a live badge.
|
||||
export type NavLeaf = { id: string; label: string; icon: string; help?: string; countKey?: string };
|
||||
export type NavGroup = { section: string; items: NavLeaf[] };
|
||||
export type NavEntry = NavLeaf | NavGroup;
|
||||
|
||||
export const PROJECT_NAV: NavEntry[] = [
|
||||
{ id: "overview", label: "Analytics", icon: "layout-dashboard", help: "Observability dashboard - volume, latency, cost, tokens, and per-source/tool breakdowns over time." },
|
||||
{ section: "Build", items: [
|
||||
{ id: "playground", label: "Playground", icon: "playground", help: "Chat with a workflow to test it live, with token + cost metering." },
|
||||
{ id: "workflows", label: "Workflows", icon: "workflow", countKey: "workflows", help: "The visual canvas - wire nodes (agents, tools, routers, triggers) into a graph." },
|
||||
{ id: "agents", label: "Agents", icon: "bot", countKey: "agents", help: "Reusable agent presets (model + prompt + tools + middleware) to drop into workflows." },
|
||||
{ id: "tools", label: "Tools", icon: "tools", countKey: "tools", help: "Capabilities an agent can call: REST, GraphQL, Code, SQL, or built-ins." },
|
||||
{ id: "components", label: "Components", icon: "grid", countKey: "components", help: "User-defined UI widgets (HTML/CSS) an agent can render in chat - tables, cards, forms, actions." },
|
||||
{ id: "knowledge", label: "Knowledge", icon: "book-open", countKey: "knowledge", help: "Documents + Q&A pairs that ground answers (RAG). Add text, URLs, files, or crawl a site." },
|
||||
{ id: "auth", label: "Auth Providers", icon: "shield-check", countKey: "auth", help: "Reusable credential strategies (Bearer, API key, OAuth, CSRF) that tools attach to." },
|
||||
{ id: "mcp", label: "External MCP", icon: "server", help: "Connect external MCP servers (GitHub, Slack, …) and toggle which of their tools agents and workflows can use." },
|
||||
] },
|
||||
{ section: "Deploy", items: [
|
||||
{ id: "channels", label: "Channels", icon: "mail", help: "Deploy a workflow to an email surface." },
|
||||
{ id: "triggers", label: "Triggers", icon: "bolt", help: "Event-driven entry points - webhook URLs, schedules, and pollers that start runs." },
|
||||
{ id: "connect", label: "Connect", icon: "plug-zap", help: "Connect this project to external systems: the Run API, integration reference, MCP server, and the embeddable chat widget." },
|
||||
] },
|
||||
{ section: "Observe", items: [
|
||||
{ id: "traces", label: "Traces", icon: "activity", help: "Per-run span waterfall with model calls, tokens, latency, and cost." },
|
||||
{ id: "datasets", label: "Evaluations", icon: "validate", help: "Test datasets (input + expected) scored against a workflow to catch regressions." },
|
||||
{ id: "handoff", label: "Agent inbox", icon: "inbox", countKey: "handoffs", help: "Live conversations escalated to a human - reply here to resume the run." },
|
||||
] },
|
||||
{ id: "settings", label: "Settings", icon: "settings", help: "Model defaults, provider keys, secrets, team & roles, and the audit log." },
|
||||
];
|
||||
|
||||
export const IO_COLOR: Record<string, string> = {
|
||||
messages: "var(--io-messages)", text: "var(--io-text)", json: "var(--io-json)", tool: "var(--io-tool)",
|
||||
embedding: "var(--io-vector)", vector: "var(--io-vector)", any: "var(--io-any)", control: "var(--io-control)",
|
||||
};
|
||||
export const KIND_LABEL: Record<string, string> = { rest_api: "REST", graphql: "GraphQL", code: "Code", sql: "SQL", builtin: "Builtin" };
|
||||
export const KIND_ICON: Record<string, string> = { rest_api: "k_rest", graphql: "k_graphql", code: "k_code", sql: "db", builtin: "k_builtin" };
|
||||
@@ -0,0 +1,191 @@
|
||||
/* Canvas (React Flow) <-> executable JSON translation + IOType rules.
|
||||
Canvas JSON is React-Flow-shaped (UI owns it); executable is the compiler input. */
|
||||
import type { Edge, Node } from "@xyflow/react";
|
||||
|
||||
export interface ForgeNodeData {
|
||||
nodeType: string;
|
||||
config: Record<string, any>;
|
||||
status?: "idle" | "running" | "done" | "error";
|
||||
[k: string]: any;
|
||||
}
|
||||
export type FlowNode = Node<ForgeNodeData>;
|
||||
export type FlowEdge = Edge;
|
||||
|
||||
export const DEFAULT_STATE: Record<string, any> = {
|
||||
messages: { type: "list[message]", reducer: "add_messages" },
|
||||
intent: { type: "str", reducer: "last" },
|
||||
};
|
||||
|
||||
/** `any` matches all; `control` only connects to `control`; else exact match. */
|
||||
export function ioCompatible(a: string, b: string): boolean {
|
||||
if (a === "control" || b === "control") return a === "control" && b === "control";
|
||||
if (a === "any" || b === "any") return true;
|
||||
return a === b;
|
||||
}
|
||||
|
||||
export function newNodeId(type: string, existing: Iterable<string>): string {
|
||||
const ids = new Set(existing);
|
||||
let i = 1;
|
||||
while (ids.has(`${type}_${i}`)) i++;
|
||||
return `${type}_${i}`;
|
||||
}
|
||||
|
||||
/** Ensure a node's config carries schema-required fields the UI only defaults visually,
|
||||
* so the saved executable always validates. Agent/deep_agent need an explicit `flavor`
|
||||
* derived from the node type - a deep_agent must never silently compile as a plain agent. */
|
||||
export function normalizeNodeConfig(nodeType: string, config: Record<string, any>): Record<string, any> {
|
||||
if (nodeType === "agent" || nodeType === "deep_agent") {
|
||||
return { ...config, flavor: config.flavor || nodeType };
|
||||
}
|
||||
return config;
|
||||
}
|
||||
|
||||
const ROUTER_CASE_HANDLE = "case:";
|
||||
|
||||
function routerCaseFromEdge(node: FlowNode | undefined, edge: FlowEdge): string | undefined {
|
||||
const rawHandle = edge.sourceHandle ?? (edge as any).source_handle ?? null;
|
||||
if (typeof rawHandle === "string" && rawHandle.startsWith(ROUTER_CASE_HANDLE)) {
|
||||
return rawHandle.slice(ROUTER_CASE_HANDLE.length);
|
||||
}
|
||||
if (!node || node.data.nodeType !== "router") return undefined;
|
||||
|
||||
const cfg = node.data.config || {};
|
||||
const label = (edge as any).label;
|
||||
if (label != null && Object.prototype.hasOwnProperty.call(cfg.cases || {}, String(label))) {
|
||||
return String(label);
|
||||
}
|
||||
|
||||
const caseMatch = Object.entries(cfg.cases || {}).find(([, target]) => target === edge.target);
|
||||
if (caseMatch) return caseMatch[0];
|
||||
if (cfg.default === edge.target) return "__default__";
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function normalizeRouterConfigFromEdges(node: FlowNode, edges: FlowEdge[]): Record<string, any> {
|
||||
const cfg = { ...(node.data.config || {}) };
|
||||
if (node.data.nodeType !== "router") return cfg;
|
||||
|
||||
const cases: Record<string, string> = { ...(cfg.cases || {}) };
|
||||
for (const edge of edges) {
|
||||
if (edge.source !== node.id) continue;
|
||||
const key = routerCaseFromEdge(node, edge);
|
||||
if (!key) continue;
|
||||
if (key === "__default__") cfg.default = edge.target;
|
||||
else if (Object.prototype.hasOwnProperty.call(cases, key)) cases[key] = edge.target;
|
||||
}
|
||||
return { ...cfg, cases };
|
||||
}
|
||||
|
||||
/** State keys each node type writes (from its config), so the workflow state can declare
|
||||
* them automatically - LangGraph rejects writes to undeclared keys, which would silently
|
||||
* break any router branching on a classifier label, qa/retrieval route flag, or human
|
||||
* decision in a canvas-built workflow. */
|
||||
function nodeWrittenKeys(nodeType: string, c: Record<string, any>): [string, string][] {
|
||||
switch (nodeType) {
|
||||
case "classifier": return [[c.output_key || "intent", c.multi_label ? "list[str]" : "str"]];
|
||||
case "retrieval": return c.route_key ? [[c.route_key, "str"]] : [];
|
||||
case "human_input": return c.output_key ? [[c.output_key, "str"]] : [];
|
||||
case "transform": return [[c.output_key || "data", "json"]];
|
||||
case "tool_call": return c.output_key ? [[c.output_key, "json"]] : [];
|
||||
case "webhook_out": return [[c.output_key || "webhook_result", "json"]];
|
||||
default: return [];
|
||||
}
|
||||
}
|
||||
|
||||
export function canvasToExecutable(
|
||||
nodes: FlowNode[],
|
||||
edges: FlowEdge[],
|
||||
meta: { id: string; version?: number; state?: Record<string, any> },
|
||||
): Record<string, any> {
|
||||
// Entry: a Start marker, else a trigger node (webhook/schedule/email/app_event),
|
||||
// else a node with no incoming edge, else the first node.
|
||||
const TRIGGERS = new Set(["webhook_in", "schedule", "email_in", "app_event"]);
|
||||
const hasIncoming = new Set(edges.map((e) => e.target));
|
||||
const start =
|
||||
nodes.find((n) => n.data.nodeType === "start") ||
|
||||
nodes.find((n) => TRIGGERS.has(n.data.nodeType)) ||
|
||||
nodes.find((n) => !hasIncoming.has(n.id));
|
||||
const state: Record<string, any> = { ...(meta.state || DEFAULT_STATE) };
|
||||
for (const n of nodes) {
|
||||
for (const [key, type] of nodeWrittenKeys(n.data.nodeType, n.data.config || {})) {
|
||||
if (key && !state[key]) state[key] = { type, reducer: "last" };
|
||||
}
|
||||
}
|
||||
return {
|
||||
id: meta.id,
|
||||
version: meta.version || 1,
|
||||
state,
|
||||
entry_node: start?.id || nodes[0]?.id || "start",
|
||||
nodes: nodes.map((n) => ({
|
||||
id: n.id,
|
||||
type: n.data.nodeType,
|
||||
config: normalizeNodeConfig(n.data.nodeType, normalizeRouterConfigFromEdges(n, edges)),
|
||||
position: { x: Math.round(n.position.x), y: Math.round(n.position.y) },
|
||||
})),
|
||||
edges: edges.map((e) => ({
|
||||
source: e.source,
|
||||
target: e.target,
|
||||
source_handle: e.sourceHandle || undefined,
|
||||
target_handle: e.targetHandle || undefined,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
export function canvasToFlow(canvas: any): { nodes: FlowNode[]; edges: FlowEdge[] } {
|
||||
const nodes: FlowNode[] = (canvas?.nodes || []).map((n: any) => ({
|
||||
id: n.id,
|
||||
type: "forge",
|
||||
position: n.position || { x: 0, y: 0 },
|
||||
data: { nodeType: n.data?.nodeType || n.type, config: n.data?.config || {} },
|
||||
}));
|
||||
const byId: Record<string, FlowNode> = Object.fromEntries(nodes.map((n) => [n.id, n]));
|
||||
// Forge nodes use React Flow's default handle (one in/out per node), so edges carry no
|
||||
// handle id - they attach to the default handle. Any stored handle id is still honored.
|
||||
const edges: FlowEdge[] = (canvas?.edges || []).map((e: any, i: number) => ({
|
||||
id: e.id || `e${i}`,
|
||||
source: e.source,
|
||||
target: e.target,
|
||||
sourceHandle: e.sourceHandle ?? e.source_handle ?? (
|
||||
routerCaseFromEdge(byId[e.source], e as FlowEdge)
|
||||
? `${ROUTER_CASE_HANDLE}${routerCaseFromEdge(byId[e.source], e as FlowEdge)}`
|
||||
: null
|
||||
),
|
||||
targetHandle: e.targetHandle ?? e.target_handle ?? null,
|
||||
label: e.label,
|
||||
}));
|
||||
return { nodes, edges };
|
||||
}
|
||||
|
||||
/** A minimal runnable starter: start -> end. */
|
||||
export function starterWorkflow(): { canvas: any; nodes: FlowNode[]; edges: FlowEdge[] } {
|
||||
const nodes: FlowNode[] = [
|
||||
{ id: "start", type: "forge", position: { x: 80, y: 220 }, data: { nodeType: "start", config: {} } },
|
||||
{ id: "end", type: "forge", position: { x: 520, y: 220 }, data: { nodeType: "end", config: {} } },
|
||||
];
|
||||
const edges: FlowEdge[] = [{ id: "e0", source: "start", target: "end" }];
|
||||
return { canvas: { nodes, edges, viewport: { x: 0, y: 0, zoom: 1 } }, nodes, edges };
|
||||
}
|
||||
|
||||
const GROUNDING_PROMPT =
|
||||
"You are the support assistant for this project. Be friendly, natural, and concise. " +
|
||||
"For greetings, thanks, or small talk (e.g. 'hi', 'thanks'), reply naturally and briefly and invite " +
|
||||
"the user's question - do NOT refuse these. For questions about this project/product, answer using ONLY " +
|
||||
"the KNOWLEDGE BASE context provided in the conversation (documents and FAQs); if it doesn't contain the " +
|
||||
"answer, say you don't have that information and offer to connect them with a human. Never invent facts " +
|
||||
"or use outside knowledge for such questions. Use the prior conversation turns for context.";
|
||||
|
||||
/** A complete grounded support flow: start -> retrieval (RAG over KB + Q&A) -> agent -> end. */
|
||||
export function groundedWorkflow(model = "openai:gpt-4o-mini"): { canvas: any; executable: Record<string, any> } {
|
||||
const nodes: FlowNode[] = [
|
||||
{ id: "start", type: "forge", position: { x: 60, y: 180 }, data: { nodeType: "start", config: {} } },
|
||||
{ id: "retrieval_1", type: "forge", position: { x: 300, y: 180 }, data: { nodeType: "retrieval", config: { top_k: 4, include_qa: true, announce_empty: true, min_score: 0.18 } } },
|
||||
{ id: "agent_1", type: "forge", position: { x: 560, y: 180 }, data: { nodeType: "agent", config: { flavor: "agent", name: "support_agent", model, system_prompt: GROUNDING_PROMPT, tools: [], middleware: [] } } },
|
||||
{ id: "end", type: "forge", position: { x: 820, y: 180 }, data: { nodeType: "end", config: {} } },
|
||||
];
|
||||
const edges: FlowEdge[] = [
|
||||
{ id: "e0", source: "start", target: "retrieval_1" },
|
||||
{ id: "e1", source: "retrieval_1", target: "agent_1" },
|
||||
{ id: "e2", source: "agent_1", target: "end" },
|
||||
];
|
||||
return { canvas: { nodes, edges, viewport: { x: 0, y: 0, zoom: 1 } }, executable: canvasToExecutable(nodes, edges, { id: "grounded_support" }) };
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
"use client";
|
||||
/* Model picker data. The catalog (chat + embedding + reranker) is served by the backend
|
||||
(GET /v1/models) from its canonical lists, so no model dropdown hardcodes options in the
|
||||
frontend and the picker can only offer models the backend actually runs (and, for chat,
|
||||
prices). See forge/model_catalog.py. */
|
||||
import { useEffect, useState } from "react";
|
||||
import { api, type EmbeddingModelInfo, type ModelCatalog, type ModelInfo, type RerankerModelInfo } from "./api";
|
||||
|
||||
const EMPTY: ModelCatalog = { chat: [], embedding: [], reranker: [] };
|
||||
|
||||
// Fetched once, shared across every picker. api.json() also de-dupes concurrent GETs, so even
|
||||
// a cold cache is a single round-trip.
|
||||
let _cache: ModelCatalog | null = null;
|
||||
|
||||
function useModelCatalog(): ModelCatalog {
|
||||
const [catalog, setCatalog] = useState<ModelCatalog>(() => _cache ?? EMPTY);
|
||||
useEffect(() => {
|
||||
if (_cache) return;
|
||||
let alive = true;
|
||||
api
|
||||
.listModels()
|
||||
.then((c) => {
|
||||
_cache = c;
|
||||
if (alive) setCatalog(c);
|
||||
})
|
||||
.catch(() => {
|
||||
/* leave empty: selects still show the current value + any hardcoded default option */
|
||||
});
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, []);
|
||||
return catalog;
|
||||
}
|
||||
|
||||
export function useModels(): ModelInfo[] {
|
||||
return useModelCatalog().chat;
|
||||
}
|
||||
|
||||
export function useEmbeddingModels(): EmbeddingModelInfo[] {
|
||||
return useModelCatalog().embedding;
|
||||
}
|
||||
|
||||
export function useRerankerModels(): RerankerModelInfo[] {
|
||||
return useModelCatalog().reranker;
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import type { NextRequest } from "next/server";
|
||||
|
||||
/* Security headers for every app route. The /embed widget is intentionally framable by the
|
||||
project's configured allowed_origins (Phase 3b); every OTHER route - the authenticated operator
|
||||
dashboard - is never framable, to stop clickjacking of one-click destructive actions (audit M7).
|
||||
Static assets are excluded from the matcher. */
|
||||
export const config = { matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"] };
|
||||
|
||||
export async function middleware(req: NextRequest) {
|
||||
const res = NextResponse.next();
|
||||
res.headers.set("X-Content-Type-Options", "nosniff");
|
||||
res.headers.set("Referrer-Policy", "strict-origin-when-cross-origin");
|
||||
|
||||
if (req.nextUrl.pathname === "/embed") {
|
||||
// Widget: restrict which sites may embed it via `frame-ancestors`, derived from the project's
|
||||
// configured allowed_origins. Default 'self' blocks external embedding until a project
|
||||
// explicitly allow-lists an origin - a secure default.
|
||||
const key = req.nextUrl.searchParams.get("key");
|
||||
let origins: string[] = [];
|
||||
if (key) {
|
||||
try {
|
||||
// Resolve the config from the backend via a BUILD-time-trusted base, never the request's
|
||||
// own origin: req.nextUrl.origin reflects the (spoofable) Host header, so using it to build
|
||||
// the fetch target is an SSRF vector. FORGE_API_URL is the same internal API address the
|
||||
// Next rewrite proxies to (container-internal in compose, 127.0.0.1:8000 on host dev).
|
||||
const apiBase = (process.env.FORGE_API_URL || "http://127.0.0.1:8000").replace(/\/$/, "");
|
||||
const r = await fetch(`${apiBase}/v1/embed/${encodeURIComponent(key)}/config`, { cache: "no-store" });
|
||||
if (r.ok) origins = (await r.json())?.allowed_origins || [];
|
||||
} catch {
|
||||
/* fall back to the secure default below */
|
||||
}
|
||||
}
|
||||
const ancestors = ["'self'", ...origins].join(" ");
|
||||
res.headers.set("Content-Security-Policy", `frame-ancestors ${ancestors}`);
|
||||
} else {
|
||||
// Operator dashboard + everything else: never framable (clickjacking defense, audit M7).
|
||||
res.headers.set("Content-Security-Policy", "frame-ancestors 'none'");
|
||||
res.headers.set("X-Frame-Options", "DENY");
|
||||
}
|
||||
return res;
|
||||
}
|
||||
Vendored
+5
@@ -0,0 +1,5 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/app/building-your-application/configuring/typescript for more information.
|
||||
@@ -0,0 +1,24 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
reactStrictMode: true,
|
||||
// Emit a minimal self-contained server bundle (.next/standalone) for a lean prod image.
|
||||
// Gated on an env var: the standalone copy step uses symlinks that fail on Windows dev
|
||||
// (EPERM) - the Docker build sets NEXT_OUTPUT=standalone; local `pnpm build` stays plain.
|
||||
output: process.env.NEXT_OUTPUT === "standalone" ? "standalone" : undefined,
|
||||
env: {
|
||||
// Browser-facing base for DIRECT client calls (e.g. split-origin SSE). Leave EMPTY for
|
||||
// container / single-origin deploys so the browser uses the same-origin /api/forge proxy.
|
||||
// NOT derived from FORGE_API_URL: that is the container-internal rewrite host (e.g.
|
||||
// http://api:8000) which the browser cannot resolve. Set only for a split-origin deploy.
|
||||
NEXT_PUBLIC_FORGE_API_URL: process.env.NEXT_PUBLIC_FORGE_API_URL || "",
|
||||
},
|
||||
async rewrites() {
|
||||
// Server-side proxy target, baked at BUILD time for standalone output (the destination is
|
||||
// frozen into the routes manifest, so the runtime env cannot change it). In compose this is
|
||||
// the container-internal API address, passed via the Dockerfile ARG (build.args
|
||||
// FORGE_API_URL=http://api:8000). Host `pnpm dev` leaves FORGE_API_URL unset -> 127.0.0.1:8000.
|
||||
const api = process.env.FORGE_API_URL || "http://127.0.0.1:8000";
|
||||
return [{ source: "/api/forge/:path*", destination: `${api}/:path*` }];
|
||||
},
|
||||
};
|
||||
export default nextConfig;
|
||||
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"name": "web",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
"dev": "next dev -p 3000",
|
||||
"build": "next build",
|
||||
"start": "next start -p 3000",
|
||||
"lint": "next lint",
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@xyflow/react": "^12.3.5",
|
||||
"jmespath": "^0.16.0",
|
||||
"mustache": "^4.2.0",
|
||||
"next": "14.2.18",
|
||||
"react": "18.3.1",
|
||||
"react-dom": "18.3.1",
|
||||
"react-markdown": "^9.0.1",
|
||||
"recharts": "^3.10.0",
|
||||
"remark-gfm": "^4.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/jmespath": "^0.15.2",
|
||||
"@types/mustache": "^4.2.5",
|
||||
"@types/node": "^22.9.0",
|
||||
"@types/react": "^18.3.12",
|
||||
"@types/react-dom": "^18.3.1",
|
||||
"@vitejs/plugin-react": "^4.7.0",
|
||||
"jsdom": "^25.0.1",
|
||||
"typescript": "^5.6.3",
|
||||
"vitest": "^2.1.9"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
/* Forge chat launcher - a self-contained, dependency-free floating chat bubble that embeds the
|
||||
* Forge widget (/embed) in an iframe. Drop one <script> tag on any allowed site:
|
||||
*
|
||||
* <script src="https://YOUR-FORGE/launcher.js"
|
||||
* data-forge-key="pk_…"
|
||||
* data-forge-origin="https://YOUR-FORGE"
|
||||
* data-forge-title="Chat with us"
|
||||
* data-forge-color="#4f46e5"
|
||||
* data-forge-token="OPTIONAL_SERVER_MINTED_SESSION_TOKEN"
|
||||
* defer></script>
|
||||
*
|
||||
* The host site must be in the project's allowed_origins (the /embed page sets a
|
||||
* frame-ancestors CSP) - the launcher cannot bypass that. The publishable key is safe to ship;
|
||||
* the optional session_token must be minted server-side and injected per request.
|
||||
*/
|
||||
(function () {
|
||||
"use strict";
|
||||
var me = document.currentScript || document.querySelector("script[data-forge-key]");
|
||||
if (!me) return;
|
||||
var ds = me.dataset;
|
||||
var KEY = ds.forgeKey;
|
||||
var TOKEN = ds.forgeToken || "";
|
||||
var TITLE = ds.forgeTitle || "Chat";
|
||||
var COLOR = ds.forgeColor || "#4f46e5";
|
||||
var ORIGIN = ds.forgeOrigin || new URL(me.src, location.href).origin;
|
||||
if (!KEY) { console.warn("[forge] launcher: missing data-forge-key"); return; }
|
||||
if (window.__forgeLauncher) return; // idempotent
|
||||
window.__forgeLauncher = true;
|
||||
|
||||
// Pass our (the host page's) origin so the widget can validate the postMessage channel
|
||||
// strictly, without depending on the referrer (which strict Referrer-Policy can strip).
|
||||
var src = ORIGIN + "/embed?key=" + encodeURIComponent(KEY) + "&host=" + encodeURIComponent(location.origin);
|
||||
if (TOKEN) src += "&session_token=" + encodeURIComponent(TOKEN);
|
||||
|
||||
var REDUCED = (window.matchMedia && matchMedia("(prefers-reduced-motion: reduce)").matches);
|
||||
|
||||
var css =
|
||||
".forge-fab{position:fixed;bottom:20px;right:20px;width:56px;height:56px;border-radius:50%;" +
|
||||
"border:0;cursor:pointer;z-index:2147483000;background:" + COLOR + ";color:#fff;" +
|
||||
"box-shadow:0 4px 14px rgba(0,0,0,.25);display:flex;align-items:center;justify-content:center;" +
|
||||
"padding:0;transition:" + (REDUCED ? "none" : "transform .15s ease") + "}" +
|
||||
".forge-fab:hover{transform:" + (REDUCED ? "none" : "scale(1.05)") + "}" +
|
||||
".forge-fab:focus-visible{outline:3px solid rgba(0,0,0,.35);outline-offset:2px}" +
|
||||
".forge-fab svg{width:26px;height:26px;display:block}" +
|
||||
".forge-panel{position:fixed;bottom:88px;right:20px;width:400px;height:600px;" +
|
||||
"max-height:calc(100vh - 108px);border:0;border-radius:16px;overflow:hidden;z-index:2147483000;" +
|
||||
"background:#fff;box-shadow:0 12px 40px rgba(0,0,0,.28);display:none}" +
|
||||
".forge-panel.forge-open{display:block}" +
|
||||
".forge-panel iframe{width:100%;height:100%;border:0;display:block}" +
|
||||
"@media (max-width:480px){.forge-panel{bottom:0;right:0;left:0;top:0;width:100%;height:100%;" +
|
||||
"max-height:100%;border-radius:0}.forge-fab.forge-hidden{display:none}}";
|
||||
var style = document.createElement("style");
|
||||
style.textContent = css;
|
||||
document.head.appendChild(style);
|
||||
|
||||
var openIcon = '<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M12 3C6.5 3 2 6.7 2 11.2c0 2.5 1.4 4.7 3.5 6.2-.1.9-.5 2.1-1.3 3.1 1.6-.2 3-.8 4.1-1.6 1.1.3 2.3.5 3.7.5 5.5 0 10-3.7 10-8.2S17.5 3 12 3z"/></svg>';
|
||||
var closeIcon = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" aria-hidden="true"><path d="M6 6l12 12M18 6L6 18"/></svg>';
|
||||
|
||||
var fab = document.createElement("button");
|
||||
fab.type = "button";
|
||||
fab.className = "forge-fab";
|
||||
fab.setAttribute("aria-haspopup", "dialog");
|
||||
fab.setAttribute("aria-expanded", "false");
|
||||
fab.setAttribute("aria-label", "Open " + TITLE);
|
||||
fab.innerHTML = openIcon;
|
||||
|
||||
var panel = document.createElement("div");
|
||||
panel.className = "forge-panel";
|
||||
panel.setAttribute("role", "dialog");
|
||||
panel.setAttribute("aria-label", TITLE);
|
||||
var iframe = document.createElement("iframe");
|
||||
iframe.title = TITLE;
|
||||
iframe.setAttribute("allow", "clipboard-write");
|
||||
panel.appendChild(iframe);
|
||||
|
||||
var open = false, loaded = false;
|
||||
function setOpen(next) {
|
||||
open = next;
|
||||
if (open && !loaded) { iframe.src = src; loaded = true; } // lazy-load on first open
|
||||
panel.classList.toggle("forge-open", open);
|
||||
fab.classList.toggle("forge-hidden", open);
|
||||
fab.setAttribute("aria-expanded", String(open));
|
||||
fab.setAttribute("aria-label", (open ? "Close " : "Open ") + TITLE);
|
||||
fab.innerHTML = open ? closeIcon : openIcon;
|
||||
if (open) { try { iframe.focus(); } catch (e) {} }
|
||||
else { try { fab.focus(); } catch (e) {} }
|
||||
}
|
||||
fab.addEventListener("click", function () { setOpen(!open); });
|
||||
document.addEventListener("keydown", function (e) { if (e.key === "Escape" && open) setOpen(false); });
|
||||
|
||||
document.body.appendChild(panel);
|
||||
document.body.appendChild(fab);
|
||||
|
||||
// postMessage: accept ONLY forge:-namespaced messages from OUR iframe at the Forge ORIGIN.
|
||||
window.addEventListener("message", function (e) {
|
||||
if (e.origin !== ORIGIN) return;
|
||||
if (e.source !== iframe.contentWindow) return;
|
||||
var m = e.data;
|
||||
if (!m || typeof m !== "object" || typeof m.type !== "string" || m.type.indexOf("forge:") !== 0) return;
|
||||
if (m.type === "forge:ready") {
|
||||
iframe.contentWindow.postMessage({ type: "forge:host", host: location.origin, title: TITLE, color: COLOR }, ORIGIN);
|
||||
} else if (m.type === "forge:close") {
|
||||
setOpen(false);
|
||||
} else if (m.type === "forge:open") {
|
||||
setOpen(true);
|
||||
}
|
||||
});
|
||||
})();
|
||||
@@ -0,0 +1,115 @@
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@/lib/api", async () => {
|
||||
const actual = await vi.importActual<typeof import("@/lib/api")>("@/lib/api");
|
||||
return {
|
||||
...actual,
|
||||
api: {
|
||||
...actual.api,
|
||||
getProject: vi.fn(),
|
||||
listSecrets: vi.fn(),
|
||||
listComponents: vi.fn(),
|
||||
listWorkflows: vi.fn(),
|
||||
createRun: vi.fn(),
|
||||
runStreamUrl: vi.fn(),
|
||||
listVersions: vi.fn(),
|
||||
restoreVersion: vi.fn(),
|
||||
},
|
||||
openSSE: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
import { SettingsScreen } from "@/components/screens/settings";
|
||||
import { PlaygroundScreen } from "@/components/screens/playground";
|
||||
import { guardCanvasBeforeUnload } from "@/components/screens/workflows";
|
||||
import { VersionHistory } from "@/components/version-history";
|
||||
import { api, openSSE } from "@/lib/api";
|
||||
|
||||
const apiMock = vi.mocked(api);
|
||||
const openSSEMock = vi.mocked(openSSE);
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
apiMock.getProject.mockResolvedValue({
|
||||
id: "p1", name: "Forge", slug: "forge", description: "", status: "active", config: {},
|
||||
});
|
||||
apiMock.listSecrets.mockResolvedValue([]);
|
||||
apiMock.listComponents.mockResolvedValue([]);
|
||||
apiMock.listWorkflows.mockResolvedValue([
|
||||
{ id: "wf1", project_id: "p1", name: "Support", status: "active", active_version: 1, executable: {}, canvas: {} },
|
||||
]);
|
||||
apiMock.createRun
|
||||
.mockResolvedValueOnce({ id: "run1", status: "queued", thread_id: "thread-1" })
|
||||
.mockResolvedValueOnce({ id: "run2", status: "queued", thread_id: "thread-2" });
|
||||
apiMock.runStreamUrl.mockImplementation((_pid, _wid, runId) => `/stream/${runId}`);
|
||||
openSSEMock.mockImplementation(async (_url, onFrame) => {
|
||||
onFrame({ event: "done", data: { answer: "Done", total_tokens: 1, total_cost_usd: 0 } });
|
||||
});
|
||||
});
|
||||
|
||||
describe("Forge console smoke coverage", () => {
|
||||
it("navigates to the Versioning settings section", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<SettingsScreen project={{ id: "p1" }} />);
|
||||
|
||||
await waitFor(() => expect(apiMock.getProject).toHaveBeenCalledWith("p1"));
|
||||
await user.click(screen.getByRole("button", { name: "Versioning" }));
|
||||
|
||||
expect(screen.getByText("Version history")).toBeInTheDocument();
|
||||
expect(screen.getByText("Versions kept per entity")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("Playground reset clears the server-side thread handle", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<PlaygroundScreen project={{ id: "p1" }} />);
|
||||
|
||||
const composer = await screen.findByPlaceholderText("Message the workflow…");
|
||||
await user.type(composer, "first turn");
|
||||
await user.click(screen.getByRole("button", { name: "Run" }));
|
||||
await waitFor(() => expect(apiMock.createRun).toHaveBeenCalledTimes(1));
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Reset" }));
|
||||
await user.type(composer, "fresh turn");
|
||||
await user.click(screen.getByRole("button", { name: "Run" }));
|
||||
await waitFor(() => expect(apiMock.createRun).toHaveBeenCalledTimes(2));
|
||||
|
||||
expect(apiMock.createRun.mock.calls[1][3]).toBeUndefined();
|
||||
});
|
||||
|
||||
it("lists and restores a prior entity version", async () => {
|
||||
const user = userEvent.setup();
|
||||
apiMock.listVersions.mockResolvedValue([
|
||||
{ id: "v2", version_no: 2, label: "Current", author_email: "dev@forge.test" },
|
||||
{ id: "v1", version_no: 1, label: "Before prompt edit", author_email: "dev@forge.test" },
|
||||
]);
|
||||
apiMock.restoreVersion.mockResolvedValue({ ok: true } as never);
|
||||
const onRestored = vi.fn();
|
||||
render(<VersionHistory entityType="workflow" entityId="wf1" onRestored={onRestored} />);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "History" }));
|
||||
expect(await screen.findByText("Before prompt edit")).toBeInTheDocument();
|
||||
await user.click(screen.getByTitle("Restore v1"));
|
||||
|
||||
await waitFor(() => expect(apiMock.restoreVersion).toHaveBeenCalledWith("workflow", "wf1", 1));
|
||||
expect(onRestored).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("blocks tab unload only when the canvas is dirty", () => {
|
||||
const clean = new Event("beforeunload", { cancelable: true }) as BeforeUnloadEvent;
|
||||
const dirty = new Event("beforeunload", { cancelable: true }) as BeforeUnloadEvent;
|
||||
|
||||
expect(guardCanvasBeforeUnload(false, clean)).toBe(false);
|
||||
expect(clean.defaultPrevented).toBe(false);
|
||||
expect(guardCanvasBeforeUnload(true, dirty)).toBe(true);
|
||||
expect(dirty.defaultPrevented).toBe(true);
|
||||
|
||||
// The helper used by WorkflowCanvas also behaves correctly as an actual event listener.
|
||||
const listener = (event: Event) => guardCanvasBeforeUnload(true, event as BeforeUnloadEvent);
|
||||
window.addEventListener("beforeunload", listener);
|
||||
const dispatched = fireEvent(window, new Event("beforeunload", { cancelable: true }));
|
||||
window.removeEventListener("beforeunload", listener);
|
||||
expect(dispatched).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,15 @@
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import { cleanup } from "@testing-library/react";
|
||||
import { afterEach, vi } from "vitest";
|
||||
|
||||
afterEach(() => cleanup());
|
||||
|
||||
Object.defineProperty(HTMLElement.prototype, "scrollTo", {
|
||||
configurable: true,
|
||||
value: vi.fn(),
|
||||
});
|
||||
|
||||
Object.defineProperty(window, "confirm", {
|
||||
configurable: true,
|
||||
value: vi.fn(() => true),
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "preserve",
|
||||
"incremental": true,
|
||||
"plugins": [{ "name": "next" }],
|
||||
"paths": { "@/*": ["./*"] }
|
||||
},
|
||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { fileURLToPath } from "node:url";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
const appRoot = fileURLToPath(new URL(".", import.meta.url));
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
resolve: {
|
||||
alias: { "@": appRoot },
|
||||
},
|
||||
test: {
|
||||
environment: "jsdom",
|
||||
setupFiles: ["./test/setup.ts"],
|
||||
include: ["test/**/*.test.ts", "test/**/*.test.tsx"],
|
||||
restoreMocks: true,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user