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,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
Reference in New Issue
Block a user