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,458 @@
|
||||
"use client";
|
||||
/* Agent configuration + the middleware-stack signature. Controlled component. */
|
||||
import { useState } from "react";
|
||||
import { Icon } from "../icons";
|
||||
import { Field, Modal, Segmented, Tile, Toggle } from "../primitives";
|
||||
import { FieldsForm, MW_FIELDS, MultiSelectChips } from "./ConfigForm";
|
||||
import { MIDDLEWARE_CATALOG, MW_META } from "@/lib/data";
|
||||
import { useModels } from "@/lib/models";
|
||||
import type { Agent, ComponentT, McpClientT, Tool, ToolSet } from "@/lib/api";
|
||||
|
||||
type Cfg = Record<string, any>;
|
||||
type MW = { type: string; enabled?: boolean; config?: Record<string, any> };
|
||||
|
||||
export function AgentConfig({ config, onChange, tools = [], toolSets = [], agents = [], folders = [], kinds = [], mcpServers = [], components = [] }: { config: Cfg; onChange: (c: Cfg) => void; tools?: Tool[]; toolSets?: ToolSet[]; agents?: Agent[]; folders?: string[]; kinds?: string[]; mcpServers?: McpClientT[]; components?: ComponentT[] }) {
|
||||
const set = (patch: Cfg) => onChange({ ...config, ...patch });
|
||||
const MODELS = useModels();
|
||||
const flavor = config.flavor || "agent";
|
||||
const selectedTools: string[] = config.tools || [];
|
||||
const selectedComponents: string[] = config.components || [];
|
||||
const mwCount = (config.middleware || []).filter((m: MW) => m.enabled !== false).length;
|
||||
|
||||
const toggleTool = (id: string) =>
|
||||
set({ tools: selectedTools.includes(id) ? selectedTools.filter((t) => t !== id) : [...selectedTools, id] });
|
||||
const selectedToolSets: string[] = config.toolsets || [];
|
||||
const toggleToolSet = (id: string) =>
|
||||
set({ toolsets: selectedToolSets.includes(id) ? selectedToolSets.filter((s) => s !== id) : [...selectedToolSets, id] });
|
||||
// Tools are picked BY tool set (accordion), not from a flat list.
|
||||
const [openSets, setOpenSets] = useState<Set<string>>(new Set());
|
||||
const toggleOpen = (id: string) =>
|
||||
setOpenSets((prev) => { const n = new Set(prev); if (n.has(id)) n.delete(id); else n.add(id); return n; });
|
||||
const toolById = new Map(tools.map((t) => [t.id, t]));
|
||||
const groupedIds = new Set(toolSets.flatMap((s) => s.tool_ids));
|
||||
const ungrouped = tools.filter((t) => !groupedIds.has(t.id));
|
||||
// Effective count = DISTINCT TOOL NAMES bound to the model: individually-picked tools ∪ every
|
||||
// tool of a whole-set grant, then collapsed BY NAME. The backend binds one function per name
|
||||
// (a tool shared across sets — or two records that share a name — reaches the model once), so
|
||||
// the badge matches what the model actually receives rather than counting the same name twice.
|
||||
const grantedIds = new Set([
|
||||
...selectedTools,
|
||||
...toolSets.filter((s) => selectedToolSets.includes(s.id)).flatMap((s) => s.tool_ids),
|
||||
]);
|
||||
const grantedCount = new Set([...grantedIds].map((id) => toolById.get(id)?.name ?? id)).size;
|
||||
const toggleComponent = (id: string) =>
|
||||
set({ components: selectedComponents.includes(id) ? selectedComponents.filter((c) => c !== id) : [...selectedComponents, id] });
|
||||
|
||||
// Built-in knowledge access - compiles to agent-callable RAG / Q&A tools (see
|
||||
// tools/builtin.py build_knowledge_capability_tools). Each capability is independent.
|
||||
const knowledge = config.knowledge || {};
|
||||
const setKnowledge = (key: "rag" | "qa", patch: Cfg) =>
|
||||
set({ knowledge: { ...knowledge, [key]: { ...(knowledge[key] || {}), ...patch } } });
|
||||
|
||||
// Bind this node to a saved agent (from the Agents tab). When bound, the saved agent's
|
||||
// config drives the node LIVE - the backend resolves `agent_ref` at compile time, so
|
||||
// editing the agent once updates every node that uses it. A snapshot of its config is
|
||||
// also copied in so the canvas card + validation stay populated. "None" detaches and
|
||||
// keeps the current fields for inline editing.
|
||||
const boundId: string = config.agent_ref || "";
|
||||
const boundAgent = boundId ? agents.find((a) => a.id === boundId) : undefined;
|
||||
const bindTo = (id: string) => {
|
||||
if (!id) {
|
||||
const { agent_ref: _drop, ...rest } = config;
|
||||
onChange(rest);
|
||||
return;
|
||||
}
|
||||
const a = agents.find((x) => x.id === id);
|
||||
if (!a) return;
|
||||
const { name: _name, ...cfg } = a.config || {};
|
||||
onChange({ flavor: "agent", ...cfg, agent_ref: id });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="col" style={{ gap: 18 }}>
|
||||
{agents.length > 0 && (
|
||||
<Section label="Saved agent" hint={boundId ? undefined : "Bind this node to a saved agent - it mirrors that agent live, so editing the agent once updates every node that uses it."}>
|
||||
<select className="select" value={boundId} onChange={(e) => bindTo(e.target.value)}>
|
||||
<option value="">None - configure inline</option>
|
||||
{agents.map((a) => <option key={a.id} value={a.id}>{a.name}</option>)}
|
||||
</select>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{boundId && (
|
||||
<div className="card col gap2" style={{ padding: 12 }}>
|
||||
{boundAgent ? (
|
||||
<>
|
||||
<div className="row gap2" style={{ alignItems: "center", minWidth: 0 }}>
|
||||
<Tile icon={boundAgent.config?.flavor === "deep_agent" ? "n_deepagent" : "n_agent"} color="var(--accent)" size={26} />
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<div className="t-h3 truncate">{boundAgent.name}</div>
|
||||
<div className="t-caption fg-2 truncate">{boundAgent.config?.model || "-"} · {(boundAgent.config?.tools || []).length} tools · {(boundAgent.config?.middleware || []).length} middleware</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="field-help">This node mirrors the saved agent. Edit its model, instructions, tools, and middleware in the Agents tab - changes apply everywhere it's used.</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="field-help" style={{ color: "var(--warn)" }}>The saved agent this node referenced wasn’t found (it may have been deleted). Pick another above, or detach to configure inline.</div>
|
||||
)}
|
||||
<button className="btn btn-secondary btn-sm" style={{ alignSelf: "flex-start" }} onClick={() => bindTo("")}>Detach & edit inline</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!boundId && (
|
||||
<>
|
||||
<Section label="Flavor">
|
||||
<Segmented options={[{ value: "agent", label: "Agent" }, { value: "deep_agent", label: "Deep Agent" }]} value={flavor} onChange={(v) => set({ flavor: v })} />
|
||||
</Section>
|
||||
|
||||
<Section label="Model">
|
||||
<select className="select" value={config.model || ""} onChange={(e) => set({ model: e.target.value })}>
|
||||
<option value="">Select a model…</option>
|
||||
{MODELS.map((m) => <option key={m.id} value={m.id}>{m.name} · {m.provider}</option>)}
|
||||
{config.model && !MODELS.some((m) => m.id === config.model) && <option value={config.model}>{config.model}</option>}
|
||||
</select>
|
||||
</Section>
|
||||
|
||||
<Section label="Instructions" hint="The model reads this as its system prompt.">
|
||||
<textarea className="textarea" rows={4} value={config.system_prompt || ""} placeholder="You are a helpful support agent…" onChange={(e) => set({ system_prompt: e.target.value })} />
|
||||
</Section>
|
||||
|
||||
<CollapsibleSection label="Tools" badge={grantedCount ? `${grantedCount} selected` : undefined}
|
||||
hint="Tools are organized by tool set — open a set and tick the tools this agent should use, or grant the whole set. Manage sets on the Tools screen.">
|
||||
<div className="col gap1">
|
||||
{toolSets.map((s) => {
|
||||
const open = openSets.has(s.id);
|
||||
const whole = selectedToolSets.includes(s.id);
|
||||
const members = s.tool_ids.map((id) => toolById.get(id)).filter(Boolean) as Tool[];
|
||||
const sel = whole ? members.length : members.filter((t) => selectedTools.includes(t.id)).length;
|
||||
return (
|
||||
<div key={s.id} className="card" style={{ padding: 0, overflow: "hidden" }}>
|
||||
<div className="row spread" style={{ padding: "8px 10px", cursor: "pointer" }} onClick={() => toggleOpen(s.id)}>
|
||||
<div className="row gap2" style={{ alignItems: "center", minWidth: 0 }}>
|
||||
<Icon name={open ? "chevdown" : "chevright"} size={14} style={{ color: "var(--fg-2)", flex: "none" }} />
|
||||
<span className="mono-sm truncate">{s.name}</span>
|
||||
<span className="t-caption fg-2">{sel}/{members.length}</span>
|
||||
</div>
|
||||
<label className="row gap1" style={{ alignItems: "center", cursor: "pointer", flex: "none" }} onClick={(e) => e.stopPropagation()} title="Grant the whole set (auto-includes tools added to it later)">
|
||||
<input type="checkbox" checked={whole} onChange={() => toggleToolSet(s.id)} />
|
||||
<span className="t-caption fg-2">Whole set</span>
|
||||
</label>
|
||||
</div>
|
||||
{open && (
|
||||
<div className="col gap1" style={{ padding: "6px 12px 10px 30px", borderTop: "1px solid var(--line)" }}>
|
||||
{members.length === 0 && <div className="t-caption fg-2">No tools in this set yet.</div>}
|
||||
{members.map((t) => (
|
||||
<label key={t.id} className="row gap2" style={{ alignItems: "center", cursor: whole ? "default" : "pointer", opacity: whole ? 0.55 : 1 }}>
|
||||
<input type="checkbox" disabled={whole} checked={whole || selectedTools.includes(t.id)} onChange={() => toggleTool(t.id)} />
|
||||
<span className="mono-sm">{t.name}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{ungrouped.length > 0 && (
|
||||
<div className="card" style={{ padding: 0, overflow: "hidden" }}>
|
||||
<div className="row spread" style={{ padding: "8px 10px", cursor: "pointer" }} onClick={() => toggleOpen("__ungrouped")}>
|
||||
<div className="row gap2" style={{ alignItems: "center" }}>
|
||||
<Icon name={openSets.has("__ungrouped") ? "chevdown" : "chevright"} size={14} style={{ color: "var(--fg-2)", flex: "none" }} />
|
||||
<span className="mono-sm fg-2">Ungrouped</span>
|
||||
<span className="t-caption fg-2">{ungrouped.filter((t) => selectedTools.includes(t.id)).length}/{ungrouped.length}</span>
|
||||
</div>
|
||||
</div>
|
||||
{openSets.has("__ungrouped") && (
|
||||
<div className="col gap1" style={{ padding: "6px 12px 10px 30px", borderTop: "1px solid var(--line)" }}>
|
||||
{ungrouped.map((t) => (
|
||||
<label key={t.id} className="row gap2" style={{ alignItems: "center", cursor: "pointer" }}>
|
||||
<input type="checkbox" checked={selectedTools.includes(t.id)} onChange={() => toggleTool(t.id)} />
|
||||
<span className="mono-sm">{t.name}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{toolSets.length === 0 && ungrouped.length === 0 && (
|
||||
<div className="t-caption fg-2">No tools yet — create some on the Tools screen.</div>
|
||||
)}
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
|
||||
{components.length > 0 && (
|
||||
<CollapsibleSection label="Components" badge={selectedComponents.length ? `${selectedComponents.length} selected` : undefined}
|
||||
hint="UI widgets this agent can render in chat - it calls one like a tool and the client draws the saved template.">
|
||||
<div className="row gap2 wrap">
|
||||
{components.map((c) => {
|
||||
const on = selectedComponents.includes(c.id);
|
||||
return (
|
||||
<button key={c.id} className="chip" onClick={() => toggleComponent(c.id)}
|
||||
style={{ cursor: "pointer", borderColor: on ? "var(--accent)" : "var(--line)", color: on ? "var(--accent)" : "var(--fg-1)", background: on ? "var(--accent-glow)" : "var(--bg-3)" }}>
|
||||
{on && <Icon name="check" size={12} />}<span className="mono-sm">{c.name}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
)}
|
||||
|
||||
<CollapsibleSection label="Knowledge" badge={knowledge.rag?.enabled ? "enabled" : undefined}
|
||||
hint="Give this agent built-in RAG over your documents - it searches per sub-question, so one agent can answer multi-part questions.">
|
||||
<div className="col gap2">
|
||||
<label className="row gap2" style={{ cursor: "pointer" }}>
|
||||
<Toggle on={!!knowledge.rag?.enabled} onChange={(on) => setKnowledge("rag", { enabled: on })} />
|
||||
<span className="t-body-sm">Search knowledge base (RAG over documents)</span>
|
||||
</label>
|
||||
{knowledge.rag?.enabled && (
|
||||
<div className="col gap2" style={{ paddingLeft: 6 }}>
|
||||
<Field label="Folders" help="Limit document search to these folders (none selected = all).">
|
||||
<MultiSelectChips value={knowledge.rag?.folders || []} options={folders} onChange={(items) => setKnowledge("rag", { folders: items })} />
|
||||
</Field>
|
||||
<div className="row gap3 wrap">
|
||||
<Field label="Documents (top K)" help="Chunks returned per search.">
|
||||
<input className="input" type="number" min={1} max={20} step={1} style={{ width: 92 }}
|
||||
value={knowledge.rag?.top_k ?? 4} onChange={(e) => setKnowledge("rag", { top_k: Number(e.target.value) || 1 })} />
|
||||
</Field>
|
||||
<Field label="Min score" help="Drop chunks below this similarity (0–1).">
|
||||
<input className="input" type="number" min={0} max={1} step={0.02} style={{ width: 92 }}
|
||||
value={knowledge.rag?.min_score ?? 0.18} onChange={(e) => setKnowledge("rag", { min_score: Number(e.target.value) })} />
|
||||
</Field>
|
||||
</div>
|
||||
<label className="row gap2" style={{ cursor: "pointer" }}>
|
||||
<Toggle on={!!knowledge.rag?.hybrid} onChange={(on) => setKnowledge("rag", { hybrid: on })} />
|
||||
<span className="t-body-sm">Hybrid search (BM25 + vector)</span>
|
||||
</label>
|
||||
<div className="field-help">Blend lexical keyword (BM25) ranking with semantic vectors so exact terms - codes, names, SKUs - aren’t missed.</div>
|
||||
<label className="row gap2" style={{ cursor: "pointer" }}>
|
||||
<Toggle on={!!knowledge.rag?.rerank} onChange={(on) => setKnowledge("rag", { rerank: on })} />
|
||||
<span className="t-body-sm">Rerank (cross-encoder)</span>
|
||||
</label>
|
||||
<div className="field-help">Two-stage retrieval: a local cross-encoder re-scores the shortlist and keeps only the best matches. Big accuracy boost; adds some latency. Runs offline on CPU (no extra cost). Min score is ignored while this is on (the reranker score is on a different scale).</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
|
||||
<CollapsibleSection label="FAQs / Q&A" badge={knowledge.qa?.enabled ? "enabled" : undefined}
|
||||
hint="Let the agent look up curated FAQ / Q&A pairs and prefer those approved answers.">
|
||||
<div className="col gap2">
|
||||
<label className="row gap2" style={{ cursor: "pointer" }}>
|
||||
<Toggle on={!!knowledge.qa?.enabled} onChange={(on) => setKnowledge("qa", { enabled: on })} />
|
||||
<span className="t-body-sm">Look up FAQ / Q&A answers</span>
|
||||
</label>
|
||||
{knowledge.qa?.enabled && (
|
||||
<div className="col gap2" style={{ paddingLeft: 6 }}>
|
||||
<Field label="Kinds" help="Limit Q&A lookup to these kinds/categories (none selected = all).">
|
||||
<MultiSelectChips value={knowledge.qa?.kinds || []} options={kinds} onChange={(items) => setKnowledge("qa", { kinds: items })} />
|
||||
</Field>
|
||||
<div className="row gap3 wrap">
|
||||
<Field label="Pairs (top K)" help="Q&A pairs returned per lookup.">
|
||||
<input className="input" type="number" min={1} max={20} step={1} style={{ width: 92 }}
|
||||
value={knowledge.qa?.top_k ?? 3} onChange={(e) => setKnowledge("qa", { top_k: Number(e.target.value) || 1 })} />
|
||||
</Field>
|
||||
<Field label="Match threshold" help="Min similarity for a Q&A pair (0–1).">
|
||||
<input className="input" type="number" min={0} max={1} step={0.05} style={{ width: 92 }}
|
||||
value={knowledge.qa?.threshold ?? 0.3} onChange={(e) => setKnowledge("qa", { threshold: Number(e.target.value) })} />
|
||||
</Field>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
|
||||
{mcpServers.length > 0 && (
|
||||
<Section label="MCP servers" hint="Grant this agent the enabled tools from these MCP servers. Manage servers + per-tool toggles in Build → External MCP.">
|
||||
<div className="row gap2 wrap">
|
||||
{mcpServers.map((m) => {
|
||||
const on = (config.mcp_servers || []).includes(m.id);
|
||||
return (
|
||||
<button key={m.id} className="chip" onClick={() => {
|
||||
const cur: string[] = config.mcp_servers || [];
|
||||
set({ mcp_servers: on ? cur.filter((x) => x !== m.id) : [...cur, m.id] });
|
||||
}} style={{ cursor: "pointer", borderColor: on ? "var(--accent)" : "var(--line)", color: on ? "var(--accent)" : "var(--fg-1)", background: on ? "var(--accent-glow)" : "var(--bg-3)" }}>
|
||||
{on && <Icon name="check" size={12} />}<span className="mono-sm">{m.name}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
<CollapsibleSection label="Middleware stack" badge={mwCount ? `${mwCount} active` : undefined}
|
||||
hint="Order = execution order (the onion). Drag-free reorder with the arrows.">
|
||||
<MiddlewareStack stack={config.middleware || []} onChange={(mw) => set({ middleware: mw })} />
|
||||
</CollapsibleSection>
|
||||
|
||||
{flavor === "deep_agent" && <DeepAgentPanel config={config} set={set} />}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* Deep Agent harness config: planning + filesystem + sandbox, plus a JSON escape hatch for
|
||||
subagents. Replaces the old dead-end hint that pointed at a non-existent panel. */
|
||||
function DeepAgentPanel({ config, set }: { config: Cfg; set: (patch: Cfg) => void }) {
|
||||
const fs = config.filesystem || {};
|
||||
const sandbox = config.sandbox || {};
|
||||
return (
|
||||
<Section label="Deep Agent">
|
||||
<label className="row gap2" style={{ cursor: "pointer" }}>
|
||||
<Toggle on={config.planning !== false} onChange={(v) => set({ planning: v })} />
|
||||
<span className="t-body-sm">Planning (write_todos)</span>
|
||||
</label>
|
||||
|
||||
<CollapsibleSection label="Filesystem" hint="A virtual filesystem the agent can read/write across steps within a run.">
|
||||
<Field label="Backend">
|
||||
<select className="select" value={fs.backend || "memory"} onChange={(e) => set({ filesystem: { ...fs, backend: e.target.value } })}>
|
||||
<option value="memory">In-memory (per run)</option>
|
||||
<option value="none">None</option>
|
||||
</select>
|
||||
</Field>
|
||||
</CollapsibleSection>
|
||||
|
||||
<CollapsibleSection label="Sandbox" hint="Run code steps in an isolated sandbox. Requires the remote-sandbox feature to be enabled in Settings → Advanced.">
|
||||
<label className="row gap2" style={{ cursor: "pointer" }}>
|
||||
<Toggle on={!!sandbox.enabled} onChange={(v) => set({ sandbox: { ...sandbox, enabled: v } })} />
|
||||
<span className="t-body-sm">Enable sandbox for code execution</span>
|
||||
</label>
|
||||
</CollapsibleSection>
|
||||
|
||||
<CollapsibleSection label="Subagents (JSON)" hint='Named subagents the planner can delegate to. Each: { "name", "description", "prompt", "tools" }.'>
|
||||
<textarea
|
||||
className="textarea mono" rows={8} style={{ fontSize: 12 }}
|
||||
defaultValue={JSON.stringify(config.subagents ?? [], null, 2)}
|
||||
placeholder={'[\n { "name": "researcher", "description": "Digs up facts", "prompt": "…", "tools": [] }\n]'}
|
||||
onChange={(e) => { try { const v = e.target.value.trim(); set({ subagents: v ? JSON.parse(v) : undefined }); } catch { /* keep last valid */ } }}
|
||||
/>
|
||||
</CollapsibleSection>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
||||
function Section({ label, hint, children }: { label: string; hint?: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="col" style={{ gap: 8 }}>
|
||||
<div className="t-micro">{label}</div>
|
||||
{children}
|
||||
{hint && <div className="field-help">{hint}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* Collapsible section for the agent panel only (Tools / Knowledge / FAQs / Middleware),
|
||||
so a dense agent config can be folded down to just the parts being edited. Open/closed
|
||||
is transient UI state kept local - it never flows into the config via onChange. */
|
||||
export function CollapsibleSection({ label, hint, badge, defaultOpen = false, children }: { label: string; hint?: string; badge?: string; defaultOpen?: boolean; children: React.ReactNode }) {
|
||||
const [open, setOpen] = useState(defaultOpen);
|
||||
return (
|
||||
<div className="col" style={{ gap: 8 }}>
|
||||
<button type="button" onClick={() => setOpen((o) => !o)} aria-expanded={open}
|
||||
className="row gap2" style={{ alignItems: "center", background: "none", border: "none", padding: 0, margin: 0, cursor: "pointer", textAlign: "left", color: "inherit", width: "100%" }}>
|
||||
<Icon name="chevdown" size={12} style={{ color: "var(--fg-2)", flex: "none", transform: open ? "none" : "rotate(-90deg)", transition: "transform .12s" }} />
|
||||
<span className="t-micro">{label}</span>
|
||||
{badge && <span className="t-caption fg-2" style={{ fontWeight: 400 }}>· {badge}</span>}
|
||||
</button>
|
||||
{open && children}
|
||||
{open && hint && <div className="field-help">{hint}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const CAT_KEYS: Record<string, string> = {}; // (reserved)
|
||||
|
||||
function MiddlewareStack({ stack, onChange }: { stack: MW[]; onChange: (s: MW[]) => void }) {
|
||||
const [adding, setAdding] = useState(false);
|
||||
const [openIdx, setOpenIdx] = useState<number | null>(null);
|
||||
|
||||
const update = (i: number, patch: Partial<MW>) => onChange(stack.map((m, j) => (j === i ? { ...m, ...patch } : m)));
|
||||
const remove = (i: number) => onChange(stack.filter((_, j) => j !== i));
|
||||
const move = (i: number, d: number) => {
|
||||
const j = i + d;
|
||||
if (j < 0 || j >= stack.length) return;
|
||||
const next = [...stack];
|
||||
[next[i], next[j]] = [next[j], next[i]];
|
||||
onChange(next);
|
||||
};
|
||||
const add = (type: string) => { onChange([...stack, { type, enabled: true, config: {} }]); setAdding(false); };
|
||||
|
||||
return (
|
||||
<div className="col gap2">
|
||||
{stack.length === 0 && <div className="field-help">No middleware yet. Add summarization, limits, guardrails, HITL…</div>}
|
||||
{stack.map((m, i) => {
|
||||
const meta = MW_META[m.type] || { name: m.type, desc: "", color: "var(--fg-2)" };
|
||||
const on = m.enabled !== false;
|
||||
const open = openIdx === i;
|
||||
return (
|
||||
<div key={i} className="card" style={{ padding: 0, overflow: "hidden", borderLeft: `3px solid ${meta.color}`, opacity: on ? 1 : 0.6 }}>
|
||||
{/* Compact single-line header: name + type + (truncated) description, reorder, toggle, delete.
|
||||
Icon sizes are set via inline style because `.iconbtn svg` in globals.css pins svgs to
|
||||
17px and would otherwise override the Icon `size` prop. Full description shows on hover. */}
|
||||
<div className="row gap2" style={{ padding: "5px 9px" }}>
|
||||
<div className="col" style={{ gap: 0, flex: "none" }}>
|
||||
<button className="iconbtn" style={{ width: 16, height: 13, padding: 0 }} onClick={() => move(i, -1)} disabled={i === 0}><Icon name="chevup" style={{ width: 12, height: 12 }} /></button>
|
||||
<button className="iconbtn" style={{ width: 16, height: 13, padding: 0 }} onClick={() => move(i, 1)} disabled={i === stack.length - 1}><Icon name="chevdown" style={{ width: 12, height: 12 }} /></button>
|
||||
</div>
|
||||
{/* One truncating line (name + type + desc) clipped inside the grow box, so a long
|
||||
type key like `openai_moderation` can never spill under the toggle/trash. */}
|
||||
<div className="grow" style={{ minWidth: 0, overflow: "hidden", cursor: "pointer" }} onClick={() => setOpenIdx(open ? null : i)} title={`${meta.name} · ${m.type}${meta.desc ? ` · ${meta.desc}` : ""}`}>
|
||||
<div className="truncate">
|
||||
<span className="t-h3">{meta.name}</span>
|
||||
<span className="t-caption fg-2" style={{ marginLeft: 8 }}>{m.type}</span>
|
||||
{meta.desc && <span className="t-caption fg-2" style={{ marginLeft: 8 }}>{meta.desc}</span>}
|
||||
</div>
|
||||
</div>
|
||||
<Toggle on={on} onChange={(v) => update(i, { enabled: v })} />
|
||||
<button className="iconbtn" style={{ width: 24, height: 24, flex: "none" }} onClick={() => remove(i)}><Icon name="trash" style={{ width: 15, height: 15 }} /></button>
|
||||
</div>
|
||||
{open && (
|
||||
<div style={{ padding: "0 11px 11px" }}>
|
||||
{MW_FIELDS[m.type] ? (
|
||||
<>
|
||||
<FieldsForm
|
||||
specs={MW_FIELDS[m.type]}
|
||||
config={m.config || {}}
|
||||
onPatch={(patch) => update(i, { config: { ...(m.config || {}), ...patch } })}
|
||||
/>
|
||||
<details style={{ marginTop: 8 }}>
|
||||
<summary className="t-caption fg-2" style={{ cursor: "pointer" }}>Advanced (JSON)</summary>
|
||||
<textarea className="textarea mono" rows={4} style={{ fontSize: 12, marginTop: 6 }} defaultValue={JSON.stringify(m.config || {}, null, 2)}
|
||||
onChange={(e) => { try { update(i, { config: JSON.parse(e.target.value || "{}") }); } catch { /* keep last valid */ } }} />
|
||||
</details>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="field-label">Config (JSON)</div>
|
||||
<textarea className="textarea mono" rows={4} style={{ fontSize: 12 }} defaultValue={JSON.stringify(m.config || {}, null, 2)}
|
||||
onChange={(e) => { try { update(i, { config: JSON.parse(e.target.value || "{}") }); } catch { /* keep last valid */ } }} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<button className="btn btn-secondary btn-sm" style={{ alignSelf: "flex-start" }} onClick={() => setAdding(true)}>
|
||||
<Icon name="plus" size={14} />Add middleware
|
||||
</button>
|
||||
|
||||
<Modal open={adding} onClose={() => setAdding(false)} title="Add middleware" width={560}>
|
||||
<div className="col gap4">
|
||||
{MIDDLEWARE_CATALOG.map((cat) => (
|
||||
<div key={cat.cat}>
|
||||
<div className="t-micro" style={{ marginBottom: 8, color: cat.color }}>{cat.cat}</div>
|
||||
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 8 }}>
|
||||
{cat.items.map((it) => (
|
||||
<button key={it.type} className="card card-hover" style={{ padding: 10, textAlign: "left" }} onClick={() => add(it.type)}>
|
||||
<div className="t-h3">{it.name}</div>
|
||||
<div className="t-caption fg-2" style={{ marginTop: 2 }}>{it.desc}</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,274 @@
|
||||
"use client";
|
||||
/* Declarative config forms - FieldSpec lists render as friendly widgets (toggle/number/
|
||||
select/model/csv/textarea) instead of raw JSON. Shared by the canvas node inspector
|
||||
(workflows.tsx) and the middleware stack (AgentConfig.tsx). A form merges only its own
|
||||
keys into the config, so unknown/advanced keys are preserved. */
|
||||
import { Field, Toggle } from "../primitives";
|
||||
import { useModels } from "@/lib/models";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
export type FieldSpec = {
|
||||
key: string;
|
||||
label: string;
|
||||
widget: "toggle" | "number" | "text" | "textarea" | "select" | "model" | "csv" | "multiselect";
|
||||
help?: string;
|
||||
placeholder?: string;
|
||||
/** Override the "model" widget's empty-value label (default "Project default"). Use when an
|
||||
* empty model doesn't resolve to the project default - e.g. the classifier falls back to the
|
||||
* cheapest model, so showing "Project default" there would misrepresent what actually runs. */
|
||||
emptyLabel?: string;
|
||||
options?: { value: string; label: string }[];
|
||||
/** Pull options at render time from a live source (e.g. the project's KB folders or
|
||||
* Q&A kinds) passed via FieldsForm's `dynamic` prop. Keyed by source name. */
|
||||
dynamicOptions?: "kb_folders" | "qa_kinds";
|
||||
/** First option for a dynamic select (e.g. "any"/"all"). */
|
||||
emptyOption?: { value: string; label: string };
|
||||
min?: number;
|
||||
max?: number;
|
||||
step?: number;
|
||||
/** Convert the stored config value to the widget's display value. */
|
||||
format?: (v: any) => any;
|
||||
/** Convert the widget's raw value to the stored config value. */
|
||||
parse?: (raw: any) => any;
|
||||
};
|
||||
|
||||
/** Compact multi-select rendered as toggle chips (used for folder selection). Stores an
|
||||
* array; empty array means "all". When there's nothing to choose yet, shows a plain
|
||||
* message instead of an empty input (any already-stored values stay as removable chips). */
|
||||
export function MultiSelectChips({ value, options, placeholder, onChange }: { value: any; options: string[]; placeholder?: string; onChange: (items: string[]) => void }) {
|
||||
const selected: string[] = Array.isArray(value) ? value.map(String) : (typeof value === "string" && value ? value.split(",").map((s) => s.trim()).filter(Boolean) : []);
|
||||
const toggle = (opt: string) => {
|
||||
const next = selected.includes(opt) ? selected.filter((s) => s !== opt) : [...selected, opt];
|
||||
onChange(next);
|
||||
};
|
||||
// No live options: don't render an input - just a hint. (Keep any stored values visible
|
||||
// as removable chips so they aren't silently lost.)
|
||||
const chipBtn = (opt: string, on: boolean) => (
|
||||
<button key={opt} type="button" onClick={() => toggle(opt)} className="chip"
|
||||
style={{ cursor: "pointer", background: on ? "var(--accent)" : "var(--bg-3)", color: on ? "var(--fg-on-accent)" : "var(--fg-1)", borderColor: on ? "var(--accent)" : "var(--line)" }}>
|
||||
{on ? "✓ " : ""}{opt}
|
||||
</button>
|
||||
);
|
||||
if (!options.length) {
|
||||
if (!selected.length) {
|
||||
return <div className="field-help" style={{ marginTop: 0 }}>{placeholder || "Nothing to choose from yet - manage these on the Knowledge screen."}</div>;
|
||||
}
|
||||
return <div className="row gap2 wrap">{selected.map((opt) => chipBtn(opt, true))}</div>;
|
||||
}
|
||||
return (
|
||||
<div className="row gap2 wrap">
|
||||
{options.map((opt) => {
|
||||
const on = selected.includes(opt);
|
||||
return (
|
||||
<button
|
||||
key={opt}
|
||||
type="button"
|
||||
onClick={() => toggle(opt)}
|
||||
className="chip"
|
||||
style={{ cursor: "pointer", background: on ? "var(--accent)" : "var(--bg-3)", color: on ? "var(--fg-on-accent)" : "var(--fg-1)", borderColor: on ? "var(--accent)" : "var(--line)" }}
|
||||
>
|
||||
{on ? "✓ " : ""}{opt}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ModelSelect({ value, onChange, emptyLabel = "Project default" }: { value: string; onChange: (v: string) => void; emptyLabel?: string }) {
|
||||
const MODELS = useModels();
|
||||
return (
|
||||
<select className="select" value={value || ""} onChange={(e) => onChange(e.target.value)}>
|
||||
<option value="">{emptyLabel}</option>
|
||||
{MODELS.map((m) => <option key={m.id} value={m.id}>{m.name} · {m.provider}</option>)}
|
||||
{value && !MODELS.some((m) => m.id === value) && <option value={value}>{value}</option>}
|
||||
</select>
|
||||
);
|
||||
}
|
||||
|
||||
function CsvInput({ value, placeholder, onChange }: { value: any; placeholder?: string; onChange: (items: string[]) => void }) {
|
||||
const display = Array.isArray(value) ? value.join(", ") : String(value ?? "");
|
||||
const [draft, setDraft] = useState(display);
|
||||
|
||||
useEffect(() => {
|
||||
setDraft(display);
|
||||
}, [display]);
|
||||
|
||||
const commit = () => {
|
||||
onChange(draft.split(",").map((s) => s.trim()).filter(Boolean));
|
||||
};
|
||||
|
||||
return (
|
||||
<input
|
||||
className="input mono"
|
||||
value={draft}
|
||||
placeholder={placeholder}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
onBlur={commit}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") e.currentTarget.blur();
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function FieldsForm({ specs, config, onPatch, dynamic }: { specs: FieldSpec[]; config: Record<string, any>; onPatch: (patch: Record<string, any>) => void; dynamic?: Record<string, string[]> }) {
|
||||
const read = (f: FieldSpec) => {
|
||||
const v = config[f.key];
|
||||
return f.format ? f.format(v) : v;
|
||||
};
|
||||
const write = (f: FieldSpec, raw: any) => {
|
||||
onPatch({ [f.key]: f.parse ? f.parse(raw) : raw });
|
||||
};
|
||||
const dynOptions = (f: FieldSpec): string[] => (f.dynamicOptions && dynamic?.[f.dynamicOptions]) || [];
|
||||
|
||||
return (
|
||||
<div className="col gap3">
|
||||
{specs.map((f) => {
|
||||
const v = read(f);
|
||||
if (f.widget === "toggle") {
|
||||
return (
|
||||
<div key={f.key} className="col gap1">
|
||||
<label className="row gap2" style={{ cursor: "pointer" }}>
|
||||
<Toggle on={!!v} onChange={(on) => write(f, on)} />
|
||||
<span className="t-body-sm">{f.label}</span>
|
||||
</label>
|
||||
{f.help && <div className="field-help">{f.help}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Field key={f.key} label={f.label} help={f.help}>
|
||||
{f.widget === "number" ? (
|
||||
<input className="input mono" type="number" min={f.min} max={f.max} step={f.step ?? 1}
|
||||
value={v ?? ""} placeholder={f.placeholder}
|
||||
onChange={(e) => write(f, e.target.value === "" ? undefined : Number(e.target.value))} />
|
||||
) : f.widget === "select" ? (
|
||||
(() => {
|
||||
// Options come from the static list OR a live source (e.g. Q&A kinds).
|
||||
const opts = f.dynamicOptions
|
||||
? [...(f.emptyOption ? [f.emptyOption] : []), ...dynOptions(f).map((o) => ({ value: o, label: o }))]
|
||||
: (f.options || []);
|
||||
// Keep a current value that isn't in the live list selectable (don't lose it).
|
||||
const hasV = v == null || v === "" || opts.some((o) => o.value === v);
|
||||
return (
|
||||
<select className="select" value={v ?? (opts[0]?.value || "")} onChange={(e) => write(f, e.target.value)}>
|
||||
{opts.map((o) => <option key={o.value} value={o.value}>{o.label}</option>)}
|
||||
{!hasV && <option value={v}>{v}</option>}
|
||||
</select>
|
||||
);
|
||||
})()
|
||||
) : f.widget === "multiselect" ? (
|
||||
<MultiSelectChips value={v} options={dynOptions(f)} placeholder={f.placeholder} onChange={(items) => write(f, items)} />
|
||||
) : f.widget === "model" ? (
|
||||
<ModelSelect value={v || ""} emptyLabel={f.emptyLabel} onChange={(val) => write(f, val || undefined)} />
|
||||
) : f.widget === "csv" ? (
|
||||
<CsvInput value={v} placeholder={f.placeholder} onChange={(items) => write(f, items)} />
|
||||
) : f.widget === "textarea" ? (
|
||||
<textarea className="textarea mono" rows={3} value={v ?? ""} placeholder={f.placeholder} onChange={(e) => write(f, e.target.value)} />
|
||||
) : (
|
||||
<input className="input mono" value={v ?? ""} placeholder={f.placeholder} onChange={(e) => write(f, e.target.value || undefined)} />
|
||||
)}
|
||||
</Field>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---- Middleware config field specs (mirror forge/engine/middleware_compiler.py) ---- */
|
||||
|
||||
const ctxSize = {
|
||||
// ContextSize is ["messages", N] in config; surface just N in the UI.
|
||||
format: (v: any) => (Array.isArray(v) ? v[1] : v),
|
||||
parse: (n: any) => (n == null ? undefined : ["messages", n]),
|
||||
};
|
||||
|
||||
export const MW_FIELDS: Record<string, FieldSpec[]> = {
|
||||
summarization: [
|
||||
{ key: "model", label: "Summarizer model", widget: "model", help: "Model used to write the summary." },
|
||||
{ key: "trigger", label: "Summarize after N messages", widget: "number", min: 4, step: 1, ...ctxSize },
|
||||
{ key: "keep", label: "Keep last N messages", widget: "number", min: 1, step: 1, ...ctxSize },
|
||||
{ key: "summary_prompt", label: "Summary prompt (optional)", widget: "textarea" },
|
||||
],
|
||||
human_in_the_loop: [
|
||||
{
|
||||
key: "interrupt_on", label: "Tools requiring approval", widget: "csv", placeholder: "send_email, delete_record",
|
||||
help: "Comma-separated tool names - the run pauses for approval before these execute.",
|
||||
format: (v: any) => (v && typeof v === "object" ? Object.keys(v) : []),
|
||||
parse: (arr: string[]) => Object.fromEntries((arr || []).map((n) => [n, true])),
|
||||
},
|
||||
],
|
||||
model_call_limit: [
|
||||
{ key: "run_limit", label: "Max model calls per run", widget: "number", min: 1 },
|
||||
{ key: "thread_limit", label: "Max model calls per thread", widget: "number", min: 1 },
|
||||
{ key: "exit_behavior", label: "When the limit hits", widget: "select", options: [{ value: "end", label: "End gracefully" }, { value: "error", label: "Raise an error" }] },
|
||||
],
|
||||
tool_call_limit: [
|
||||
{ key: "tool_name", label: "Tool (empty = all tools)", widget: "text", placeholder: "web_fetch" },
|
||||
{ key: "run_limit", label: "Max calls per run", widget: "number", min: 1 },
|
||||
{ key: "thread_limit", label: "Max calls per thread", widget: "number", min: 1 },
|
||||
{ key: "exit_behavior", label: "When the limit hits", widget: "select", options: [{ value: "continue", label: "Continue without the tool" }, { value: "end", label: "End gracefully" }, { value: "error", label: "Raise an error" }] },
|
||||
],
|
||||
model_fallback: [
|
||||
{ key: "models", label: "Fallback models (in order)", widget: "csv", placeholder: "openai:gpt-4o-mini, anthropic:claude-sonnet-4-6", help: "Tried left to right when the primary model errors." },
|
||||
],
|
||||
pii: [
|
||||
{ key: "pii_type", label: "PII type", widget: "select", options: ["email", "credit_card", "ip", "mac_address", "url"].map((v) => ({ value: v, label: v })) },
|
||||
{ key: "strategy", label: "Strategy", widget: "select", options: ["redact", "mask", "hash", "block"].map((v) => ({ value: v, label: v })) },
|
||||
{ key: "apply_to_input", label: "Apply to user input", widget: "toggle" },
|
||||
{ key: "apply_to_output", label: "Apply to model output", widget: "toggle" },
|
||||
{ key: "apply_to_tool_results", label: "Apply to tool results", widget: "toggle" },
|
||||
],
|
||||
todo: [
|
||||
{ key: "system_prompt", label: "Planning prompt (optional)", widget: "textarea" },
|
||||
{ key: "tool_description", label: "write_todos tool description (optional)", widget: "textarea" },
|
||||
],
|
||||
llm_tool_selector: [
|
||||
{ key: "model", label: "Selector model", widget: "model" },
|
||||
{ key: "max_tools", label: "Max tools exposed per call", widget: "number", min: 1 },
|
||||
{ key: "always_include", label: "Always include tools", widget: "csv", placeholder: "lookup_kb" },
|
||||
],
|
||||
tool_retry: [
|
||||
{ key: "max_retries", label: "Max retries", widget: "number", min: 1 },
|
||||
{ key: "tools", label: "Only these tools (empty = all)", widget: "csv" },
|
||||
{ key: "backoff_factor", label: "Backoff factor", widget: "number", step: 0.1 },
|
||||
{ key: "initial_delay", label: "Initial delay (s)", widget: "number", step: 0.1 },
|
||||
{ key: "max_delay", label: "Max delay (s)", widget: "number", step: 0.5 },
|
||||
{ key: "jitter", label: "Add jitter", widget: "toggle" },
|
||||
],
|
||||
model_retry: [
|
||||
{ key: "max_retries", label: "Max retries", widget: "number", min: 1 },
|
||||
{ key: "backoff_factor", label: "Backoff factor", widget: "number", step: 0.1 },
|
||||
{ key: "initial_delay", label: "Initial delay (s)", widget: "number", step: 0.1 },
|
||||
{ key: "max_delay", label: "Max delay (s)", widget: "number", step: 0.5 },
|
||||
{ key: "jitter", label: "Add jitter", widget: "toggle" },
|
||||
],
|
||||
tool_emulator: [
|
||||
{ key: "model", label: "Emulator model", widget: "model" },
|
||||
{ key: "tools", label: "Tools to emulate (empty = all)", widget: "csv", help: "Emulated tools return LLM-invented results - for testing flows without live APIs." },
|
||||
],
|
||||
context_editing: [
|
||||
{
|
||||
key: "edits", label: "Clear old tool results after N tokens", widget: "number", min: 1000, step: 1000,
|
||||
help: "When the context exceeds this, older tool outputs are cleared.",
|
||||
format: (v: any) => (Array.isArray(v) && v[0] ? v[0].trigger : undefined),
|
||||
parse: (n: any) => (n == null ? undefined : [{ trigger: n }]),
|
||||
},
|
||||
],
|
||||
anthropic_prompt_caching: [
|
||||
{ key: "ttl", label: "Cache TTL", widget: "select", options: [{ value: "5m", label: "5 minutes" }, { value: "1h", label: "1 hour" }] },
|
||||
],
|
||||
openai_moderation: [
|
||||
{ key: "apply_to_input", label: "Moderate user input", widget: "toggle" },
|
||||
{ key: "apply_to_output", label: "Moderate model output", widget: "toggle" },
|
||||
],
|
||||
guardrail_regex: [
|
||||
{ key: "patterns", label: "Blocked patterns (regex)", widget: "csv", placeholder: "(?i)password, secret_key" },
|
||||
{ key: "on_match", label: "On match", widget: "select", options: [{ value: "block", label: "Block the reply" }, { value: "flag", label: "Flag only" }] },
|
||||
],
|
||||
tenant_budget: [
|
||||
{ key: "max_tokens_per_run", label: "Max tokens per run", widget: "number", min: 100, step: 100 },
|
||||
{ key: "on_exceed", label: "When exceeded", widget: "select", options: [{ value: "end", label: "End gracefully" }, { value: "error", label: "Raise an error" }] },
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,108 @@
|
||||
"use client";
|
||||
/* Self-rendered canvas connections.
|
||||
|
||||
React Flow v12's own edge renderer doesn't draw loaded edges reliably in this dev
|
||||
setup (handle-bounds race), so we draw the wires ourselves inside the ViewportPortal -
|
||||
which lives in flow coordinate space, so it pans/zooms with the canvas. Native edges
|
||||
are hidden via CSS to avoid double-draw. New connections (onConnect) land in the same
|
||||
`edges` array, so hand-drawn wires show here too. */
|
||||
import { ViewportPortal } from "@xyflow/react";
|
||||
import { useMemo } from "react";
|
||||
import { Icon } from "../icons";
|
||||
import { type FlowEdge, type FlowNode } from "@/lib/graph";
|
||||
|
||||
const NODE_HEIGHT: Record<string, number> = {
|
||||
start: 36, end: 36,
|
||||
agent: 90, deep_agent: 90, llm: 80, human_input: 80, classifier: 80,
|
||||
transform: 64, tool_call: 64, webhook_out: 64, emit_event: 64, retrieval: 64,
|
||||
};
|
||||
const NODE_WIDTH = 230;
|
||||
// Router case-row geometry - must mirror ForgeNode's router body (header + expr line + rows).
|
||||
const ROUTER_GEOM = { header: 37, exprLine: 22, rowH: 24, pad: 6 };
|
||||
|
||||
function routerHeight(cfg: any): number {
|
||||
const rows = Object.keys(cfg?.cases || {}).length + 1; // + Else
|
||||
return ROUTER_GEOM.header + ROUTER_GEOM.exprLine + rows * ROUTER_GEOM.rowH + ROUTER_GEOM.pad;
|
||||
}
|
||||
|
||||
/** Y offset (within the router node) of the case row an edge leaves from. */
|
||||
function routerCaseY(cfg: any, edge: FlowEdge): number {
|
||||
const keys = [...Object.keys(cfg?.cases || {}), "__default__"];
|
||||
let key: string | undefined = edge.sourceHandle?.startsWith("case:") ? edge.sourceHandle.slice(5) : undefined;
|
||||
if (!key || !keys.includes(key)) {
|
||||
// Infer from routing config: which case (or default) points at this edge's target?
|
||||
key = Object.entries(cfg?.cases || {}).find(([, tgt]) => tgt === edge.target)?.[0]
|
||||
?? (cfg?.default === edge.target ? "__default__" : undefined);
|
||||
}
|
||||
const idx = key ? keys.indexOf(key) : 0;
|
||||
return ROUTER_GEOM.header + ROUTER_GEOM.exprLine + (idx < 0 ? 0 : idx) * ROUTER_GEOM.rowH + ROUTER_GEOM.rowH / 2;
|
||||
}
|
||||
|
||||
export function EdgeOverlay({ nodes, edges, onRemove }: { nodes: FlowNode[]; edges: FlowEdge[]; onRemove: (edge: FlowEdge) => void }) {
|
||||
const byId = useMemo(() => Object.fromEntries(nodes.map((n) => [n.id, n])), [nodes]);
|
||||
const h = (n: any) => (n?.data?.nodeType === "router" ? routerHeight(n?.data?.config) : NODE_HEIGHT[n?.data?.nodeType] ?? 36);
|
||||
const edgeGeometry = (e: FlowEdge) => {
|
||||
const s = byId[e.source]; const t = byId[e.target];
|
||||
if (!s || !t) return null;
|
||||
const tx = t.position.x, ty = t.position.y + h(t) / 2;
|
||||
// Sub-agent edge: an org-chart branch from the deep_agent's BOTTOM handle down into the
|
||||
// specialist's TOP-center (agent-as-tool), drawn dashed/accent and distinct from flow edges.
|
||||
if (e.sourceHandle === "subagents") {
|
||||
const sx = s.position.x + NODE_WIDTH / 2;
|
||||
const sy = s.position.y + h(s);
|
||||
const ttx = t.position.x + NODE_WIDTH / 2;
|
||||
const tty = t.position.y;
|
||||
const dy = Math.max(28, Math.abs(tty - sy) / 2);
|
||||
const d = `M ${sx},${sy} C ${sx},${sy + dy} ${ttx},${tty - dy} ${ttx},${tty}`;
|
||||
return { d, mx: (sx + ttx) / 2, my: (sy + tty) / 2, sub: true };
|
||||
}
|
||||
const fromRouter = s.data?.nodeType === "router";
|
||||
const sx = s.position.x + NODE_WIDTH;
|
||||
const sy = s.position.y + (fromRouter ? routerCaseY(s.data?.config, e) : h(s) / 2);
|
||||
const dx = Math.max(40, Math.abs(tx - sx) / 2);
|
||||
const d = `M ${sx},${sy} C ${sx + dx},${sy} ${tx - dx},${ty} ${tx},${ty}`;
|
||||
return { d, mx: (sx + tx) / 2, my: (sy + ty) / 2, sub: false };
|
||||
};
|
||||
return (
|
||||
<ViewportPortal>
|
||||
<svg style={{ position: "absolute", left: 0, top: 0, width: 1, height: 1, overflow: "visible", pointerEvents: "none", zIndex: -1 }}>
|
||||
<defs>
|
||||
<marker id="forge-arrow" markerWidth="9" markerHeight="9" refX="7" refY="3" orient="auto">
|
||||
<path d="M0,0 L7,3 L0,6 Z" fill="var(--line-strong)" />
|
||||
</marker>
|
||||
<marker id="forge-arrow-sub" markerWidth="9" markerHeight="9" refX="7" refY="3" orient="auto">
|
||||
<path d="M0,0 L7,3 L0,6 Z" fill="var(--accent)" />
|
||||
</marker>
|
||||
</defs>
|
||||
{edges.map((e) => {
|
||||
const geo = edgeGeometry(e);
|
||||
if (!geo) return null;
|
||||
return <path key={e.id} d={geo.d} fill="none" stroke={geo.sub ? "var(--accent)" : "var(--line-strong)"} strokeWidth={2} strokeDasharray={geo.sub ? "6 5" : undefined} markerEnd={geo.sub ? "url(#forge-arrow-sub)" : "url(#forge-arrow)"} />;
|
||||
})}
|
||||
</svg>
|
||||
{edges.map((e) => {
|
||||
const geo = edgeGeometry(e);
|
||||
if (!geo) return null;
|
||||
return (
|
||||
<button
|
||||
key={`${e.id}-detach`}
|
||||
title="Detach edge"
|
||||
aria-label={`Detach edge from ${e.source} to ${e.target}`}
|
||||
onPointerDown={(ev) => ev.stopPropagation()}
|
||||
onClick={(ev) => { ev.stopPropagation(); onRemove(e); }}
|
||||
style={{
|
||||
position: "absolute", left: geo.mx - 9, top: geo.my - 9,
|
||||
width: 18, height: 18, borderRadius: 999,
|
||||
border: "1px solid var(--line-strong)", background: "var(--bg-1)",
|
||||
color: "var(--fg-2)", boxShadow: "var(--sh-1)",
|
||||
display: "flex", alignItems: "center", justifyContent: "center",
|
||||
padding: 0, cursor: "pointer", pointerEvents: "auto", zIndex: 8,
|
||||
}}
|
||||
>
|
||||
<Icon name="x" size={10} />
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</ViewportPortal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
"use client";
|
||||
/* Forge canvas node - a chip on a circuit board, with IOType-colored typed handles.
|
||||
Ports come from the backend Node Type Registry (/v1/node-types) via context. */
|
||||
import { Handle, Position, useUpdateNodeInternals, type NodeProps } from "@xyflow/react";
|
||||
import { createContext, useContext, useEffect, useState } from "react";
|
||||
import { Icon } from "../icons";
|
||||
import { NODE_META, IO_COLOR, fmtUSD } from "@/lib/data";
|
||||
import type { NodeType } from "@/lib/api";
|
||||
|
||||
export const NodeTypesContext = createContext<Record<string, NodeType>>({});
|
||||
|
||||
const CAT_COLOR: Record<string, string> = {
|
||||
control: "var(--io-control)", agent: "var(--accent)", json: "var(--io-json)",
|
||||
vector: "var(--io-vector)", human: "var(--warn)", signal: "var(--signal)",
|
||||
};
|
||||
|
||||
function summarize(type: string, c: Record<string, any>): string[] {
|
||||
switch (type) {
|
||||
case "agent":
|
||||
case "deep_agent":
|
||||
// Show only the model on the card - tools / middleware / components / knowledge are
|
||||
// all partial here; the right-hand inspector shows the full config.
|
||||
return [String(c.model || "-")];
|
||||
case "router":
|
||||
return [`expr · ${c.expression || "-"}`, Object.keys(c.cases || {}).concat(c.default ? ["default"] : []).join(" · ")];
|
||||
case "llm":
|
||||
return [String(c.model || "-"), "single call"];
|
||||
case "classifier":
|
||||
return [`→ ${c.output_key || "intent"}`, (c.labels || []).slice(0, 4).join(" · ") || "no labels"];
|
||||
case "transform":
|
||||
return [`${c.engine || "jmespath"} → ${c.output_key || "data"}`];
|
||||
case "tool_call":
|
||||
return [String(c.tool_id || "-")];
|
||||
case "retrieval": {
|
||||
const lines: string[] = [];
|
||||
if (c.include_docs !== false) lines.push(`docs top_k ${c.top_k ?? 5}${c.hybrid ? " · hybrid" : ""}${c.rerank ? " · rerank" : ""}`);
|
||||
if (c.include_qa) lines.push(`Q&A top_k ${c.qa_top_k ?? 3}`);
|
||||
return lines.length ? lines : ["no sources"];
|
||||
}
|
||||
case "human_input":
|
||||
return [(c.prompt || "").slice(0, 34), (c.allowed_decisions || ["approve", "reject"]).join(" · ")];
|
||||
case "webhook_out":
|
||||
return [`${c.method || "POST"} ${String(c.url || "").slice(0, 26)}`];
|
||||
case "emit_event":
|
||||
return [`channel · ${c.channel || ""}`];
|
||||
default:
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function debugPreview(value: any): string {
|
||||
if (value == null || value === "") return "";
|
||||
if (typeof value === "string") return value;
|
||||
try { return JSON.stringify(value, null, 2); } catch { return String(value); }
|
||||
}
|
||||
|
||||
function HandleStack({ ports, dir }: { ports: { id: string; io_type: string }[]; dir: "in" | "out" }) {
|
||||
const n = ports.length;
|
||||
return (
|
||||
<>
|
||||
{ports.map((p, i) => (
|
||||
<Handle
|
||||
key={p.id}
|
||||
// With a single port per side (every Forge node today), omit the id so this is
|
||||
// React Flow's *default* handle - then loaded edges (which carry no handle id)
|
||||
// always attach, with no id-matching that can silently drop the edge.
|
||||
id={n > 1 ? p.id : undefined}
|
||||
type={dir === "in" ? "target" : "source"}
|
||||
position={dir === "in" ? Position.Left : Position.Right}
|
||||
style={{
|
||||
top: `${((i + 1) / (n + 1)) * 100}%`,
|
||||
width: 11, height: 11, border: "2px solid var(--bg-1)",
|
||||
background: IO_COLOR[p.io_type] || "var(--io-any)",
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function ForgeNode({ id, data, selected }: NodeProps) {
|
||||
const registry = useContext(NodeTypesContext);
|
||||
const type = (data as any).nodeType as string;
|
||||
const isSubagent = !!(data as any).isSubagent; // wired as a deep_agent sub-agent (folded, not a flow node)
|
||||
const config = (data as any).config || {};
|
||||
const status = (data as any).status as string | undefined;
|
||||
const debug = (data as any).debug || {};
|
||||
const [showDebug, setShowDebug] = useState(false);
|
||||
const meta = NODE_META[type] || { icon: "n_agent", color: "var(--fg-2)", label: type };
|
||||
const spec = registry[type];
|
||||
const inPorts = spec?.input_ports || [];
|
||||
const outPorts = spec?.output_ports || [];
|
||||
const lines = summarize(type, config);
|
||||
const title = config.name || meta.label || type;
|
||||
const hasDebug =
|
||||
debug.output !== undefined ||
|
||||
Number(debug.cost_usd || 0) > 0 ||
|
||||
Number(debug.tokens || 0) > 0;
|
||||
const outputPreview = debugPreview(debug.output);
|
||||
|
||||
// The port registry loads after the node first mounts, so the handles appear later.
|
||||
// Tell React Flow to re-measure this node's handle bounds when its port count changes -
|
||||
// without this, edges that connect to those handles never get drawn. Router case rows
|
||||
// each carry a handle, so case-count changes also need a re-measure.
|
||||
const caseKeys = type === "router" ? Object.keys(config.cases || {}).join("|") : "";
|
||||
const updateNodeInternals = useUpdateNodeInternals();
|
||||
const portKey = `${inPorts.length}:${outPorts.length}:${caseKeys}:${type === "deep_agent" ? "sub" : ""}`;
|
||||
useEffect(() => { updateNodeInternals(id); }, [id, portKey, updateNodeInternals]);
|
||||
|
||||
const border =
|
||||
status === "running" ? "0 0 0 2px var(--ok), 0 0 0 6px var(--ok-bg)" :
|
||||
status === "done" ? "0 0 0 1px var(--ok)" :
|
||||
status === "error" ? "0 0 0 1px var(--err)" :
|
||||
selected ? "var(--glow-accent)" : "0 0 0 1px var(--line)";
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
width: 230, background: "var(--bg-2)", borderRadius: "var(--r-lg)",
|
||||
boxShadow: `${border}, var(--node-shadow)`, position: "relative",
|
||||
}}
|
||||
>
|
||||
{/* A folded sub-agent isn't a flow node, so its in/out flow handles have no function -
|
||||
hide them for a clean "leaf" look (the sub-agent edge attaches to the node's top). */}
|
||||
{!isSubagent && <HandleStack ports={inPorts} dir="in" />}
|
||||
{/* header */}
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 8, padding: "8px 10px", borderBottom: "1px solid var(--line)", background: "var(--bg-3)", borderRadius: "var(--r-lg) var(--r-lg) 0 0", borderLeft: `3px solid ${meta.color}` }}>
|
||||
<Icon name={meta.icon} size={16} style={{ color: meta.color, flexShrink: 0 }} />
|
||||
<span className="t-h3 truncate" style={{ flex: 1, fontFamily: "var(--font-display)" }}>{title}</span>
|
||||
<span className="typechip">{type}</span>
|
||||
</div>
|
||||
{/* body */}
|
||||
{type === "router" ? (
|
||||
// OpenAI-style if/else: one labeled output row + connector per case, plus Else.
|
||||
// Dragging from a row's handle wires that case (see onConnect in workflows.tsx).
|
||||
// Geometry must stay in sync with ROUTER_GEOM in workflows.tsx (EdgeOverlay).
|
||||
<div className="col" style={{ paddingBottom: 6 }}>
|
||||
<div className="t-caption fg-2 truncate" style={{ padding: "4px 11px", height: 22 }}>expr · {config.expression || "-"}</div>
|
||||
{[...Object.keys(config.cases || {}), "__default__"].map((k) => (
|
||||
<div key={k} style={{ position: "relative", height: 24, display: "flex", alignItems: "center", justifyContent: "flex-end", padding: "0 12px", borderTop: "1px solid var(--line)", background: "var(--bg-3)" }}>
|
||||
<span className="mono-sm truncate" style={{ color: k === "__default__" ? "var(--fg-2)" : "var(--fg-1)" }}>{k === "__default__" ? "Else" : k}</span>
|
||||
<Handle
|
||||
id={`case:${k}`} type="source" position={Position.Right}
|
||||
style={{ top: "50%", right: -6, transform: "translateY(-50%)", width: 10, height: 10, border: "2px solid var(--bg-1)", background: k === "__default__" ? "var(--fg-2)" : "var(--io-control)", position: "absolute" }}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : lines.length > 0 && (
|
||||
<div className="col" style={{ padding: "9px 11px", gap: 3 }}>
|
||||
{lines.map((l, i) => (
|
||||
<div key={i} className={i === 0 ? "mono-sm truncate" : "t-caption fg-2 truncate"} style={{ color: i === 0 ? "var(--fg-1)" : undefined }}>{l}</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{type !== "router" && !isSubagent && <HandleStack ports={outPorts} dir="out" />}
|
||||
{/* Deep agent's third port: a bottom "subagents" handle. Drag from here to specialist
|
||||
agent nodes to make them sub-agents the supervisor calls (see onConnect / compiler). */}
|
||||
{type === "deep_agent" && (
|
||||
<Handle
|
||||
id="subagents"
|
||||
type="source"
|
||||
position={Position.Bottom}
|
||||
title="Sub-agents — wire to specialist agents this supervisor can call"
|
||||
style={{ left: "50%", bottom: -6, transform: "translateX(-50%)", width: 12, height: 12, border: "2px solid var(--bg-1)", background: "var(--accent)", position: "absolute" }}
|
||||
/>
|
||||
)}
|
||||
{hasDebug && (
|
||||
<div
|
||||
onMouseEnter={() => setShowDebug(true)}
|
||||
onMouseLeave={() => setShowDebug(false)}
|
||||
style={{ position: "absolute", right: 8, bottom: -13, zIndex: 25 }}
|
||||
>
|
||||
<span
|
||||
className="chip chip-mono"
|
||||
style={{
|
||||
height: 24,
|
||||
borderColor: "var(--accent)",
|
||||
background: "var(--bg-1)",
|
||||
boxShadow: "var(--sh-1)",
|
||||
color: "var(--fg-0)",
|
||||
}}
|
||||
>
|
||||
<Icon name={Number(debug.cost_usd || 0) > 0 ? "bolt" : "eye"} size={11} />
|
||||
{Number(debug.cost_usd || 0) > 0 ? fmtUSD(Number(debug.cost_usd || 0)) : "output"}
|
||||
</span>
|
||||
{showDebug && (
|
||||
<div
|
||||
className="card"
|
||||
style={{
|
||||
position: "absolute",
|
||||
right: 0,
|
||||
top: 28,
|
||||
width: 290,
|
||||
padding: 11,
|
||||
boxShadow: "var(--sh-pop)",
|
||||
zIndex: 80,
|
||||
}}
|
||||
>
|
||||
<div className="row spread" style={{ marginBottom: 8 }}>
|
||||
<div className="t-micro">Node output</div>
|
||||
{(Number(debug.tokens || 0) > 0 || Number(debug.cost_usd || 0) > 0) && (
|
||||
<span className="chip chip-mono">
|
||||
<Icon name="bolt" size={11} />
|
||||
{Number(debug.tokens || 0)} tok · {fmtUSD(Number(debug.cost_usd || 0))}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<pre
|
||||
className="mono-sm"
|
||||
style={{
|
||||
margin: 0,
|
||||
maxHeight: 150,
|
||||
overflow: "auto",
|
||||
whiteSpace: "pre-wrap",
|
||||
overflowWrap: "anywhere",
|
||||
color: "var(--fg-1)",
|
||||
background: "var(--bg-3)",
|
||||
border: "1px solid var(--line)",
|
||||
borderRadius: 7,
|
||||
padding: 8,
|
||||
}}
|
||||
>
|
||||
{(outputPreview || "(no state delta)").slice(0, 1400)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,277 @@
|
||||
"use client";
|
||||
/* The canvas Test panel: messages a workflow over SSE and lights up nodes as the graph
|
||||
executes (one backend thread per session so the checkpointer holds prior turns). */
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Icon } from "../icons";
|
||||
import { Tile } from "../primitives";
|
||||
import { api, openSSE, type Workflow, type ComponentT } from "@/lib/api";
|
||||
import { fmtUSD } from "@/lib/data";
|
||||
import { ComponentRenderer } from "../component-renderer";
|
||||
import { Markdown } from "../markdown";
|
||||
import { ReplyAccumulator, type Part, type ComponentInstance } from "@/lib/chat-parts";
|
||||
import Mustache from "mustache";
|
||||
|
||||
interface TestMsg { role: "user" | "assistant"; content?: string; parts?: Part[] }
|
||||
|
||||
export function WorkflowTestPanel({
|
||||
project,
|
||||
workflow,
|
||||
running,
|
||||
onRunningChange,
|
||||
onBeforeRun,
|
||||
onClose,
|
||||
onResetRun,
|
||||
onNodeStep,
|
||||
onFinalDebug,
|
||||
}: {
|
||||
project: any;
|
||||
workflow: Workflow;
|
||||
running: boolean;
|
||||
onRunningChange: (running: boolean) => void;
|
||||
onBeforeRun: () => Promise<void>;
|
||||
onClose: () => void;
|
||||
onResetRun: () => void;
|
||||
onNodeStep: (nodeId: string, status?: "idle" | "running" | "done" | "error", output?: any) => void;
|
||||
onFinalDebug: (debugNodes: Record<string, any>) => void;
|
||||
}) {
|
||||
const [input, setInput] = useState("");
|
||||
const [msgs, setMsgs] = useState<TestMsg[]>([]);
|
||||
const [streaming, setStreaming] = useState("");
|
||||
const [meter, setMeter] = useState<{ tokens: number; cost: number } | null>(null);
|
||||
const [compDefs, setCompDefs] = useState<Record<string, ComponentT>>({});
|
||||
const [liveParts, setLiveParts] = useState<Part[]>([]); // in-flight assistant reply parts (audit H3)
|
||||
const activeRef = useRef(true);
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
// Folded sub-agents aren't graph nodes, so node_start never fires for them - map each
|
||||
// sub-agent NAME to its canvas node id so the live `activity` stream can light it up.
|
||||
const subNameToId = useMemo(() => {
|
||||
const m: Record<string, string> = {};
|
||||
const nodes = (workflow as any)?.executable?.nodes || (workflow as any)?.canvas?.nodes || [];
|
||||
for (const n of nodes) {
|
||||
const name = n?.config?.name ?? n?.data?.config?.name;
|
||||
const type = n?.type ?? n?.data?.nodeType;
|
||||
if (type === "agent" || type === "deep_agent") {
|
||||
if (name) m[name] = n.id;
|
||||
m[n.id] = n.id; // sub-agent name falls back to the node id when the agent is unnamed
|
||||
}
|
||||
}
|
||||
return m;
|
||||
}, [workflow]);
|
||||
const actNameRef = useRef<Record<string, string>>({}); // activity id -> sub-agent name
|
||||
// One backend thread per test session - the checkpointer holds prior turns.
|
||||
const threadRef = useRef<string | null>(null);
|
||||
useEffect(() => {
|
||||
threadRef.current = null; setMsgs([]);
|
||||
if (project?.id) api.listComponents(project.id).then((cs) => setCompDefs(Object.fromEntries(cs.map((c) => [c.id, c])))).catch(() => {});
|
||||
}, [workflow?.id, project?.id]);
|
||||
|
||||
useEffect(() => {
|
||||
activeRef.current = true;
|
||||
return () => {
|
||||
activeRef.current = false;
|
||||
onRunningChange(false);
|
||||
};
|
||||
}, [onRunningChange]);
|
||||
|
||||
useEffect(() => {
|
||||
scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight, behavior: "smooth" });
|
||||
}, [msgs, streaming, meter, liveParts]);
|
||||
|
||||
async function send(textArg?: string) {
|
||||
const text = (typeof textArg === "string" ? textArg : input).trim();
|
||||
if (!text || running || !project?.id || !workflow?.id) return;
|
||||
if (typeof textArg !== "string") setInput("");
|
||||
setMsgs((m) => [...m, { role: "user", content: text }]);
|
||||
setStreaming("");
|
||||
setMeter(null);
|
||||
setLiveParts([]);
|
||||
actNameRef.current = {};
|
||||
onResetRun();
|
||||
onRunningChange(true);
|
||||
let finalAnswer = "";
|
||||
let lastNode: string | null = null;
|
||||
// Components are positioned by the [[forge:component:ID]] markers the agent writes into its
|
||||
// reply (not by frame-arrival order), so a widget lands in its natural place, not at the top.
|
||||
const acc = new ReplyAccumulator();
|
||||
|
||||
try {
|
||||
await onBeforeRun();
|
||||
if (!activeRef.current) return;
|
||||
const run = await api.createRun(
|
||||
project.id, workflow.id,
|
||||
{ messages: [{ role: "user", content: text }] },
|
||||
threadRef.current || undefined,
|
||||
);
|
||||
threadRef.current = run.thread_id;
|
||||
await openSSE(api.runStreamUrl(project.id, workflow.id, run.id), (f) => {
|
||||
if (!activeRef.current) return;
|
||||
if (f.event === "messages" && f.data?.content) {
|
||||
acc.addText(f.data.content);
|
||||
setStreaming(acc.text);
|
||||
setLiveParts(acc.parts({ streaming: true }));
|
||||
} else if (f.event === "node_start" && f.data?.node) {
|
||||
const node = f.data.node;
|
||||
lastNode = node;
|
||||
onNodeStep(node, "running");
|
||||
} else if (f.event === "node_error" && f.data?.node) {
|
||||
const node = f.data.node;
|
||||
onNodeStep(node, "error");
|
||||
finalAnswer = `⚠ ${f.data?.message || `${node} failed`}`;
|
||||
} else if (f.event === "updates" && f.data && typeof f.data === "object") {
|
||||
const node = Object.keys(f.data)[0];
|
||||
if (!node) return;
|
||||
const output = f.data[node];
|
||||
onNodeStep(node, "done", output);
|
||||
if (lastNode === node) lastNode = null;
|
||||
} else if (f.event === "activity" && f.data?.id) {
|
||||
// Light up a folded sub-agent's canvas node from the live activity stream (it has no
|
||||
// graph node_start of its own). Tools inside the sub-agent aren't nodes, so skip them.
|
||||
const a = f.data;
|
||||
if (a.phase === "start" && a.kind === "subagent") {
|
||||
actNameRef.current[a.id] = a.name;
|
||||
const nid = subNameToId[a.name];
|
||||
if (nid) onNodeStep(nid, "running");
|
||||
} else if (a.phase === "end") {
|
||||
const nid = subNameToId[actNameRef.current[a.id]];
|
||||
if (nid) onNodeStep(nid, "done", a.output);
|
||||
}
|
||||
} else if (f.event === "custom" && f.data?.channel === "component" && f.data?.payload) {
|
||||
acc.addComponent(f.data.payload as ComponentInstance);
|
||||
setLiveParts(acc.parts({ streaming: true }));
|
||||
} else if (f.event === "done") {
|
||||
finalAnswer = f.data?.answer || "";
|
||||
setMeter({ tokens: f.data?.total_tokens ?? 0, cost: f.data?.total_cost_usd ?? 0 });
|
||||
onFinalDebug(f.data?.debug?.nodes || {});
|
||||
if (lastNode) onNodeStep(lastNode, "done");
|
||||
} else if (f.event === "interrupt") {
|
||||
finalAnswer = "⏸ This run paused for approval. Open the full Playground to resume it.";
|
||||
if (lastNode) onNodeStep(lastNode, "done");
|
||||
} else if (f.event === "error") {
|
||||
finalAnswer = `⚠ ${f.data?.message || "run failed"}`;
|
||||
if (lastNode) onNodeStep(lastNode, "error");
|
||||
}
|
||||
});
|
||||
} catch (e: any) {
|
||||
if (!activeRef.current) return;
|
||||
finalAnswer = `⚠ ${e.message || e}`;
|
||||
if (lastNode) onNodeStep(lastNode, "error");
|
||||
} finally {
|
||||
if (activeRef.current) {
|
||||
setStreaming("");
|
||||
onRunningChange(false);
|
||||
setLiveParts([]);
|
||||
}
|
||||
}
|
||||
if (activeRef.current) {
|
||||
// resolveText reconciles the streamed buffer with the authoritative answer / error so it's
|
||||
// never dropped (audit H2); markers in it splice components into place.
|
||||
const finalText = acc.resolveText(finalAnswer);
|
||||
if (acc.hasComponents()) {
|
||||
setMsgs((m) => [...m, { role: "assistant", parts: acc.parts({ finalText }) }]);
|
||||
} else {
|
||||
setMsgs((m) => [...m, { role: "assistant", content: finalText || "(no output)" }]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handleComponentAction(inst: ComponentInstance, action: string, fields: Record<string, string>) {
|
||||
const def = (inst.actions || []).find((a: any) => a.id === action) || {};
|
||||
let msg = (def as any).message || (def as any).label || action;
|
||||
try { msg = Mustache.render(String(msg), { props: inst.props || {}, fields, action }); } catch {}
|
||||
if (msg) send(msg);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="col" style={{ flex: 1, minHeight: 0 }}>
|
||||
<div className="row spread" style={{ padding: "12px 14px", borderBottom: "1px solid var(--line)", flex: "none" }}>
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<div className="t-h2">Test</div>
|
||||
<div className="fg-2 t-caption mono truncate">{workflow.name}</div>
|
||||
</div>
|
||||
<button className="iconbtn" onClick={onClose}><Icon name="x" size={15} /></button>
|
||||
</div>
|
||||
<div ref={scrollRef} className="scroll-y col gap3" style={{ flex: 1, minHeight: 0, padding: 12, overflowX: "hidden" }}>
|
||||
{msgs.length === 0 && !streaming && !running && (
|
||||
<div className="col center" style={{ minHeight: 160, textAlign: "center", gap: 8, color: "var(--fg-2)" }}>
|
||||
<Tile icon="playground" color="var(--io-json)" size={38} />
|
||||
<div className="t-h3" style={{ color: "var(--fg-1)" }}>Message this workflow</div>
|
||||
<div className="t-caption">Nodes highlight as the graph executes. Hover the debug chips on nodes for output and cost.</div>
|
||||
</div>
|
||||
)}
|
||||
{msgs.map((m, i) => (
|
||||
<TestMessage key={i} role={m.role} content={m.content} parts={m.parts} compDefs={compDefs} onAction={handleComponentAction} />
|
||||
))}
|
||||
{(running || liveParts.length > 0) && (
|
||||
<TestMessage role="assistant" parts={liveParts} streaming compDefs={compDefs} onAction={handleComponentAction} />
|
||||
)}
|
||||
{meter && <span className="chip chip-mono" style={{ alignSelf: "flex-start" }}><Icon name="bolt" size={12} />{meter.tokens} tok · {fmtUSD(meter.cost)}</span>}
|
||||
</div>
|
||||
<div style={{ padding: 12, borderTop: "1px solid var(--line)", flex: "none" }}>
|
||||
<div className="row gap2" style={{ background: "var(--bg-3)", border: "1px solid var(--line)", borderRadius: 10, padding: "6px 6px 6px 10px" }}>
|
||||
<input
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && send()}
|
||||
placeholder="Ask this workflow..."
|
||||
disabled={running}
|
||||
style={{ flex: 1, minWidth: 0, border: "none", background: "none", outline: "none", fontSize: 13, color: "var(--fg-0)", fontFamily: "var(--font-ui)" }}
|
||||
/>
|
||||
<button className="btn btn-primary btn-sm" onClick={() => send()} disabled={running || !input.trim()}>
|
||||
<Icon name={running ? "refresh" : "play"} size={13} style={running ? { animation: "spin 1s linear infinite" } : {}} />
|
||||
{running ? "Testing" : "Send"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* One test-panel turn: a single avatar + a column. The assistant reply is an ordered list
|
||||
of parts (text + components interleaved in produced order), matching the Playground so a
|
||||
rendered component sits in its correct place within the reply. */
|
||||
function TestMessage({ role, content, streaming, parts, compDefs, onAction }: {
|
||||
role: "user" | "assistant";
|
||||
content?: string;
|
||||
streaming?: boolean;
|
||||
parts?: Part[];
|
||||
compDefs: Record<string, ComponentT>;
|
||||
onAction: (inst: ComponentInstance, action: string, fields: Record<string, string>) => void;
|
||||
}) {
|
||||
const user = role === "user";
|
||||
if (user) {
|
||||
return (
|
||||
<div className="row" style={{ gap: 8, alignItems: "flex-start", flexDirection: "row-reverse" }}>
|
||||
<Tile icon="user" color="var(--signal)" size={24} />
|
||||
<div style={{ maxWidth: 260, padding: "8px 10px", borderRadius: 10, borderTopRightRadius: 3, fontSize: 13, lineHeight: "19px", whiteSpace: "pre-wrap", overflowWrap: "anywhere", background: "var(--accent)", color: "var(--fg-on-accent)" }}>
|
||||
{content}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const renderComp = (inst: ComponentInstance, key: number | string) => {
|
||||
const def = compDefs[inst.component_id];
|
||||
if (!def) {
|
||||
return <div key={key} className="card" style={{ padding: "6px 9px", fontSize: 12, color: "var(--fg-2)" }}>Component “{inst.name || inst.component_id}” unavailable.</div>;
|
||||
}
|
||||
return (
|
||||
<ComponentRenderer key={key} def={{ id: def.id, name: def.name, html: def.html, css: def.css, actions: inst.actions || def.actions }} props={inst.props || {}} onAction={(a, f) => onAction(inst, a, f)} />
|
||||
);
|
||||
};
|
||||
const list: Part[] = parts && parts.length ? parts : (content && content.trim() ? [{ kind: "text", text: content }] : []);
|
||||
let lastText = -1;
|
||||
for (let k = list.length - 1; k >= 0; k--) { if (list[k].kind === "text") { lastText = k; break; } }
|
||||
return (
|
||||
<div className="row" style={{ gap: 8, alignItems: "flex-start" }}>
|
||||
<Tile icon="sparkles" color="var(--accent)" size={24} />
|
||||
<div className="col gap2" style={{ minWidth: 0, flex: 1 }}>
|
||||
{list.map((p, j) => (p.kind === "text" ? (
|
||||
<div key={j} style={{ fontSize: 13, lineHeight: "19px", color: "var(--fg-0)", overflowWrap: "anywhere" }}>
|
||||
<Markdown>{p.text}</Markdown>
|
||||
{streaming && j === lastText && <span style={{ display: "inline-block", width: 6, height: 13, background: "var(--accent)", verticalAlign: "-2px", animation: "blink 1s steps(1) infinite" }} />}
|
||||
</div>
|
||||
) : renderComp(p.inst, j)))}
|
||||
{streaming && lastText === -1 && <span className="fg-2" style={{ fontSize: 13 }}>…</span>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user