"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 (
{label}
{value}
); } /* 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
{children}
; } 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 (
{ setForm(BLANK_CHANNEL); setOpen(true); }}>New channel} />
{channels.map((ch) => (
{ch.name}{ch.type}{!ch.enabled && disabled}
{urlOf(ch) &&
{urlOf(ch)}
}
))} {channels.length === 0 &&
No channels yet. Create one to deploy this project's workflow.
}
setOpen(false)} title={form.id ? "Configure channel" : "New channel"} width={500} footer={<>}> setForm((f) => ({ ...f, name: e.target.value }))} placeholder="Support channel" /> {form.type === "email" && ( <>
Outbound SMTP for replies. Inbound mail is posted to the channel's inbound URL by your provider (Mailgun/SendGrid/Postmark) or an IMAP relay.
setSmtp({ host: e.target.value })} placeholder="smtp.sendgrid.net" /> setSmtp({ port: Number(e.target.value) })} />
setSmtp({ username: e.target.value })} /> setSmtp({ from: e.target.value })} placeholder="support@yourco.com" />
setSmtp({ password_ref: e.target.value })} placeholder="secret://proj/smtp_password" /> )}
); } /* ============ 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}
))} {datasets.length === 0 &&
No datasets yet.
}
{runError && (
Run failed
{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 (
{isOpen && done && (
{r.expected && } {r.reason && }
)}
); })}
)} setOpen(false)} title={editing ? "Edit dataset" : "New dataset"} width={520} footer={<>}> setForm((f) => ({ ...f, name: e.target.value }))} placeholder="Smoke tests" />