"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 (
);
}
/* 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 running… ;
if (result.passed) return pass ;
const inconclusive = result.status && result.status !== "scored";
if (inconclusive) return {result.status === "run_failed" ? "run failed" : result.status} ;
return fail ;
}
const SCORING_HELP: Record = {
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 (
{title}
{subtitle &&
{subtitle}
}
{action}
);
}
function Shell({ children }: { children: React.ReactNode }) {
return ;
}
function useWorkflows(pid?: string) {
const [wfs, setWfs] = useState([]);
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([]);
const wfs = useWorkflows(project?.id);
const [open, setOpen] = useState(false);
const [form, setForm] = useState(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 (
);
}
/* ============ TRIGGERS ============ */
export function TriggersScreen({ project }: { project: any }) {
const [triggers, setTriggers] = useState([]);
useEffect(() => { if (project?.id) api.listTriggers(project.id).then(setTriggers).catch(() => setTriggers([])); }, [project?.id]);
return (
{triggers.map((t) => (
{t.kind.replace("_", " ")} {t.node_id} {!t.enabled && disabled }
{t.last_fired_at &&
last fired {new Date(t.last_fired_at).toLocaleString()} }
{t.webhook_url &&
POST {t.webhook_url}
}
{t.config?.cron &&
cron: {t.config.cron}
}
{t.config?.every_minutes &&
every {t.config.every_minutes} min
}
{t.config?.poll_url &&
polls {t.config.poll_url}
}
))}
{triggers.length === 0 &&
No triggers. Add a trigger node (Webhook / Schedule / …) to a workflow and publish it.
}
);
}
/* ============ DATASETS / EVAL ============ */
export function DatasetsScreen({ project }: { project: any }) {
const [datasets, setDatasets] = useState([]);
const wfs = useWorkflows(project?.id);
const [open, setOpen] = useState(false);
const [editing, setEditing] = useState(null);
const [form, setForm] = useState(EMPTY_DATASET_FORM);
const [report, setReport] = useState(null);
const [ranDataset, setRanDataset] = useState(null);
const [expanded, setExpanded] = useState>(new Set());
const [runError, setRunError] = useState(null);
const [running, setRunning] = useState(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>({});
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 (
New dataset} />
{datasets.map((d) => (
{d.name} {d.score_mode} {d.n_items} cases {!d.workflow_id && no workflow }
{d.last_pass_rate != null && = 0.8 ? "pill-ok" : "pill-muted"}`}>{Math.round(d.last_pass_rate * 100)}% pass }
run(d)}>{running === d.id ? : }{running === d.id ? (total ? `Running ${doneCount}/${total}` : "Running…") : "Run"}
openEdit(d)}>
remove(d)}>
))}
{datasets.length === 0 &&
No datasets yet.
}
{runError && (
)}
{rows && (
{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` : ""}>}
{totalTokens > 0 && {fmtTokens(totalTokens)} }
{isRunningNow && }
{/* Progress bar: fills as cases finish so the run is visibly in motion. */}
Select a finished case to see its output{ranDataset?.score_mode === "judge" ? " and the judge's reason" : ""}.
{rows.map((row) => {
const i = row.index;
const r = liveResults[i];
const done = !!r;
const isOpen = expanded.has(i);
return (
{ if (done) setExpanded((prev) => { const n = new Set(prev); if (n.has(i)) n.delete(i); else n.add(i); return n; }); }}>
{(r?.input ?? row.input) || "(empty input)"}
{done && r.latency_ms != null && {fmtLatency(r.latency_ms)} }
{done && r.tokens != null && r.tokens > 0 && {fmtTokens(r.tokens)} }
{isOpen && done && (
{r.expected && }
{r.reason && }
)}
);
})}
)}
setOpen(false)} title={editing ? "Edit dataset" : "New dataset"} width={520}
footer={<> setOpen(false)}>Cancel {editing ? "Save" : "Create"} >}>
setForm((f) => ({ ...f, name: e.target.value }))} placeholder="Smoke tests" />
setForm((f) => ({ ...f, workflow_id: e.target.value }))}>Select… {wfs.map((w) => {w.name} )}
setForm((f) => ({ ...f, score_mode: e.target.value }))}>contains exact regex LLM judge
);
}
/* ============ HANDOFF INBOX ============ */
export function HandoffScreen({ project }: { project: any }) {
const [items, setItems] = useState([]);
const [reply, setReply] = useState>({});
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 (
);
}