diff --git a/apps/fabro-web/app/components/playground/canvas/canvas.tsx b/apps/fabro-web/app/components/playground/canvas/canvas.tsx new file mode 100644 index 000000000..54347c190 --- /dev/null +++ b/apps/fabro-web/app/components/playground/canvas/canvas.tsx @@ -0,0 +1,253 @@ +import { useCallback, useMemo, useRef, useState } from "react"; +import { MinusIcon, PlusIcon } from "@heroicons/react/20/solid"; + +import type { WorkflowDraft } from "../state/draft"; +import { renderCanvasDot } from "./render-canvas"; +import type { SimulationState } from "./simulation"; +import { useCanvasRender } from "./use-canvas-render"; + +const ZOOM_STEPS = [25, 50, 75, 100, 150, 200]; +const DEFAULT_ZOOM_INDEX = 3; // 100% + +/** + * Read a node's id from its SVG `` element. Graphviz writes the + * node's DOT identifier into a child `<title>` inside each `<g + * class="node">`, which we keep around precisely so click handlers can + * recover the id from a hit-test target. + */ +function nodeIdFromGroup(group: Element): string | null { + const title = group.querySelector(":scope > title"); + return title?.textContent?.trim() || null; +} + +/** Inline-style selection highlight. We do this via inline styles + * (rather than a CSS class) because Graphviz writes `stroke=...` + * attributes directly on each shape element and CSS classes alone + * can't override them without `!important`. */ +const SELECT_STROKE = "rgb(45 212 191)"; // teal-400, matches fabro-web accent +const SELECT_STROKE_WIDTH = "2"; + +function applySelectionHighlight(svg: SVGSVGElement, selectedId: string | null) { + for (const group of svg.querySelectorAll<SVGGElement>("g.node")) { + const id = nodeIdFromGroup(group); + const isSelected = id !== null && id === selectedId; + group.classList.toggle("is-selected", isSelected); + group.style.cursor = "pointer"; + const shapes = group.querySelectorAll<SVGElement>("polygon, ellipse, path"); + for (const shape of shapes) { + if (isSelected) { + shape.style.stroke = SELECT_STROKE; + shape.style.strokeWidth = SELECT_STROKE_WIDTH; + shape.style.filter = "drop-shadow(0 0 6px rgb(20 184 166 / 0.45))"; + } else { + shape.style.stroke = ""; + shape.style.strokeWidth = ""; + shape.style.filter = ""; + } + } + } +} + +/** + * Canvas for the playground. Re-renders whenever the draft changes by piping + * a themed DOT (see `render-canvas`) through `@viz-js/viz` — the same + * Graphviz layout engine Fabro uses, so what the user sees here is exactly + * what their downloaded `.fabro` graph will lay out as. + */ +export default function PlaygroundCanvas({ + draft, + simulation, + selectedNodeId, + onSelectNode, +}: { + draft: WorkflowDraft; + simulation?: SimulationState; + /** Currently-inspected node id, or null. Drives the SVG highlight. */ + selectedNodeId?: string | null; + /** Click-to-inspect callback. Null means the user clicked empty canvas (deselect). */ + onSelectNode?: (id: string | null) => void; +}) { + const dot = useMemo( + () => renderCanvasDot(draft, simulation), + [draft, simulation], + ); + + const containerRef = useRef<HTMLDivElement>(null); + const innerRef = useRef<HTMLDivElement>(null); + + const [zoomIndex, setZoomIndex] = useState(DEFAULT_ZOOM_INDEX); + const [pan, setPan] = useState({ x: 0, y: 0 }); + const dragState = useRef<{ + startX: number; + startY: number; + startPanX: number; + startPanY: number; + moved: boolean; + } | null>(null); + const zoom = ZOOM_STEPS[zoomIndex]!; + + const { svgRef, error } = useCanvasRender( + innerRef, + dot, + selectedNodeId ?? null, + applySelectionHighlight, + ); + + const onPointerDown = useCallback( + (event: React.PointerEvent) => { + if ((event.target as HTMLElement).closest("button")) return; + event.currentTarget.setPointerCapture(event.pointerId); + dragState.current = { + startX: event.clientX, + startY: event.clientY, + startPanX: pan.x, + startPanY: pan.y, + moved: false, + }; + }, + [pan], + ); + + const onPointerMove = useCallback((event: React.PointerEvent) => { + const drag = dragState.current; + if (!drag) return; + const dx = event.clientX - drag.startX; + const dy = event.clientY - drag.startY; + if (!drag.moved && Math.abs(dx) + Math.abs(dy) > 3) { + drag.moved = true; + } + setPan({ + x: drag.startPanX + dx, + y: drag.startPanY + dy, + }); + }, []); + + const onPointerUp = useCallback( + (event: React.PointerEvent) => { + const drag = dragState.current; + dragState.current = null; + if (!onSelectNode || !drag || drag.moved) return; + // `event.target` is the pointer-capture target (the container div), + // not the element under the cursor. `elementFromPoint` does a fresh + // hit-test that ignores capture. + const hit = document.elementFromPoint(event.clientX, event.clientY); + const group = hit?.closest("g.node"); + if (group) { + const id = nodeIdFromGroup(group); + if (id) onSelectNode(id); + return; + } + // Clicked empty canvas — deselect. + onSelectNode(null); + }, + [onSelectNode], + ); + + const fitToWindow = useCallback(() => { + const svg = svgRef.current; + const container = containerRef.current; + if (!svg || !container) return; + const svgW = svg.viewBox.baseVal.width || svg.getBoundingClientRect().width; + const svgH = svg.viewBox.baseVal.height || svg.getBoundingClientRect().height; + const padPx = 48; + const containerW = container.clientWidth - padPx; + const containerH = container.clientHeight - padPx; + const fitPct = Math.min(containerW / svgW, containerH / svgH) * 100; + let best = 0; + for (let i = ZOOM_STEPS.length - 1; i >= 0; i--) { + if (ZOOM_STEPS[i]! <= fitPct) { + best = i; + break; + } + } + setZoomIndex(best); + setPan({ x: 0, y: 0 }); + }, []); + + return ( + <div className="relative isolate flex h-full min-h-0 flex-1 flex-col overflow-hidden rounded-md border border-line bg-panel-alt/40"> + <div className="absolute right-3 top-3 z-10 flex items-center gap-2"> + <div className="flex items-center rounded-md border border-line bg-panel/90 p-0.5"> + <button + type="button" + title="Fit to window" + aria-label="Fit diagram to window" + onClick={fitToWindow} + className="flex size-7 items-center justify-center rounded text-fg-muted transition-colors hover:bg-overlay hover:text-fg-3" + > + <svg + viewBox="0 0 14 14" + fill="none" + stroke="currentColor" + className="size-3.5" + aria-hidden="true" + > + <rect + x="1" + y="1" + width="12" + height="12" + rx="1.5" + strokeWidth="1.5" + strokeDasharray="3 2" + /> + </svg> + </button> + </div> + + <div className="flex items-center gap-0.5 rounded-md border border-line bg-panel/90 p-0.5"> + <button + type="button" + title="Zoom out" + aria-label="Zoom out" + onClick={() => setZoomIndex((i) => Math.max(0, i - 1))} + disabled={zoomIndex === 0} + className="flex size-7 items-center justify-center rounded text-fg-muted transition-colors hover:bg-overlay hover:text-fg-3 disabled:opacity-30 disabled:hover:bg-transparent disabled:hover:text-fg-muted" + > + <MinusIcon className="size-4" /> + </button> + <span className="px-1 font-mono text-[11px] tabular-nums text-fg-muted"> + {zoom}% + </span> + <button + type="button" + title="Zoom in" + aria-label="Zoom in" + onClick={() => + setZoomIndex((i) => Math.min(ZOOM_STEPS.length - 1, i + 1)) + } + disabled={zoomIndex === ZOOM_STEPS.length - 1} + className="flex size-7 items-center justify-center rounded text-fg-muted transition-colors hover:bg-overlay hover:text-fg-3 disabled:opacity-30 disabled:hover:bg-transparent disabled:hover:text-fg-muted" + > + <PlusIcon className="size-4" /> + </button> + </div> + </div> + + {error ? ( + <p className="m-6 text-sm text-coral">{error}</p> + ) : ( + <div + ref={containerRef} + className="flex flex-1 overflow-hidden p-6" + style={{ cursor: dragState.current ? "grabbing" : "grab" }} + onPointerDown={onPointerDown} + onPointerMove={onPointerMove} + onPointerUp={onPointerUp} + onPointerCancel={onPointerUp} + > + <div + ref={innerRef} + className="m-auto" + style={{ + transform: `translate(${pan.x}px, ${pan.y}px) scale(${zoom / 100})`, + transformOrigin: "center center", + }} + > + <p className="text-sm text-fg-muted">Loading canvas…</p> + </div> + </div> + )} + </div> + ); +} diff --git a/apps/fabro-web/app/components/playground/canvas/render-canvas.test.ts b/apps/fabro-web/app/components/playground/canvas/render-canvas.test.ts new file mode 100644 index 000000000..da6f172fd --- /dev/null +++ b/apps/fabro-web/app/components/playground/canvas/render-canvas.test.ts @@ -0,0 +1,97 @@ +import { describe, expect, test } from "bun:test"; + +import { createInitialDraft } from "../state/draft"; +import { applyToolCalls } from "../state/reducer"; +import { renderCanvasDot } from "./render-canvas"; + +describe("renderCanvasDot — welcome state", () => { + test("includes a ghost placeholder between start and exit", () => { + const dot = renderCanvasDot(createInitialDraft()); + expect(dot).toContain("__ghost__"); + expect(dot).toContain("start -> __ghost__"); + expect(dot).toContain("__ghost__ -> exit"); + expect(dot).toContain("your workflow goes here"); + }); + + test("does not include the user's start -> exit fallback edge", () => { + const dot = renderCanvasDot(createInitialDraft()); + // We render start -> ghost -> exit instead — the implicit start->exit + // edge in the welcome draft is suppressed in the canvas view. + expect(dot).not.toMatch(/^\s*start -> exit\s*$/m); + }); +}); + +describe("renderCanvasDot — populated draft", () => { + test("drops the ghost the moment a user node lands", () => { + const { draft } = applyToolCalls(createInitialDraft(), [ + { + name: "add_node", + args: { id: "plan", label: "Plan", shape: "box" }, + }, + ]); + const dot = renderCanvasDot(draft); + expect(dot).not.toContain("__ghost__"); + expect(dot).toContain("plan "); + }); + + test("renders user-added nodes and edges with theme attrs around them", () => { + const { draft } = applyToolCalls(createInitialDraft(), [ + { + name: "set_workflow_meta", + args: { name: "release_notes", goal: "Generate release notes" }, + }, + { + name: "add_node", + args: { id: "plan", label: "Plan", shape: "box" }, + }, + { name: "connect", args: { from: "start", to: "plan" } }, + { name: "connect", args: { from: "plan", to: "exit" } }, + ]); + const dot = renderCanvasDot(draft); + + expect(dot).toContain('graph [goal="Generate release notes"]'); + expect(dot).toContain('shape=box, label="Plan"'); + expect(dot).toContain("start -> plan"); + expect(dot).toContain("plan -> exit"); + // Theme bits we inject so the canvas matches fabro-web styling. + expect(dot).toContain('bgcolor="transparent"'); + expect(dot).toContain('node ['); + expect(dot).toContain('edge ['); + }); + + test("omits prompts from node bodies (they're surfaced in the chat trace)", () => { + const { draft } = applyToolCalls(createInitialDraft(), [ + { + name: "add_node", + args: { + id: "plan", + label: "Plan", + shape: "box", + prompt: "Long winded prompt that would clutter the canvas", + }, + }, + ]); + expect(renderCanvasDot(draft)).not.toContain("Long winded prompt"); + }); + + test("renders edge labels and conditions", () => { + const { draft } = applyToolCalls(createInitialDraft(), [ + { + name: "add_node", + args: { id: "gate", label: "Pass?", shape: "diamond" }, + }, + { + name: "connect", + args: { + from: "gate", + to: "exit", + label: "Yes", + condition: "outcome=succeeded", + }, + }, + ]); + const dot = renderCanvasDot(draft); + expect(dot).toContain('label="Yes"'); + expect(dot).toContain('condition="outcome=succeeded"'); + }); +}); diff --git a/apps/fabro-web/app/components/playground/canvas/render-canvas.ts b/apps/fabro-web/app/components/playground/canvas/render-canvas.ts new file mode 100644 index 000000000..2bba07943 --- /dev/null +++ b/apps/fabro-web/app/components/playground/canvas/render-canvas.ts @@ -0,0 +1,170 @@ +/** + * Canvas-flavoured DOT renderer. + * + * Layers on top of `files/render-fabro.ts` to: + * + * 1. Inject the playground's graph theme (`graphTheme` from `lib/graph-theme`) + * so the rendered SVG matches the rest of fabro-web rather than + * Graphviz's defaults. + * 2. In the welcome state (`start → exit` with no user nodes), splice in a + * dashed ghost `???` node so the canvas doesn't look like an empty page. + * + * The canvas-only decorations live here so the download artifact + * (`render-fabro.ts`) stays a clean, vendor-neutral `.fabro` file with no + * theme attributes baked in. + */ + +import { graphTheme } from "../../../lib/graph-theme"; +import { isWelcomeState, type WorkflowDraft } from "../state/draft"; +import type { SimulationState } from "./simulation"; + +const GHOST_ID = "__ghost__"; + +/** Highlight overlay derived from the live simulation state. */ +function simulationOverlay(node: string, sim?: SimulationState): string | null { + if (!sim) return null; + if (sim.active === node) { + return ` ${node} [fillcolor="${graphTheme.runningFill}", color="${graphTheme.runningBorder}", fontcolor="${graphTheme.runningText}", penwidth=2]`; + } + if (sim.done.includes(node)) { + return ` ${node} [fillcolor="${graphTheme.completedFill}", color="${graphTheme.completedBorder}", fontcolor="${graphTheme.completedText}"]`; + } + return null; +} + +/** Defaults injected at the top of the DOT, mirroring `automation-diagram`. */ +function styleHeader(): string { + return [ + " bgcolor=\"transparent\"", + " pad=0.5", + "", + " node [", + " fontname=\"ui-sans-serif, system-ui\"", + " fontsize=12", + ` fontcolor="${graphTheme.nodeText}"`, + ` color="${graphTheme.edgeColor}"`, + ` fillcolor="${graphTheme.nodeFill}"`, + " style=filled", + " penwidth=1.2", + " ]", + " edge [", + " fontname=\"ui-monospace, monospace\"", + " fontsize=10", + ` fontcolor="${graphTheme.fontcolor}"`, + ` color="${graphTheme.edgeColor}"`, + " arrowsize=0.7", + " penwidth=1.2", + " ]", + ].join("\n"); +} + +/** Per-shape theming applied to specific node ids. */ +function styleNode(id: string, kind: "start" | "exit" | "ghost"): string { + if (kind === "start") { + return ` ${id} [fillcolor="${graphTheme.startFill}", color="${graphTheme.startBorder}", fontcolor="${graphTheme.startText}"]`; + } + if (kind === "exit") { + return ` ${id} [fillcolor="${graphTheme.completedFill}", color="${graphTheme.completedBorder}", fontcolor="${graphTheme.completedText}"]`; + } + // ghost + return ` ${id} [shape=box, label="your workflow goes here", style="dashed,filled", fillcolor="${graphTheme.nodeFill}", color="${graphTheme.fontcolor}", fontcolor="${graphTheme.fontcolor}"]`; +} + +/** + * Build the DOT shown in the canvas. Behaviour: + * + * - Welcome state → emits a small `start → ghost → exit` flow with the + * ghost styled as a dashed placeholder. + * - Non-welcome state → emits the user's actual graph, themed for fabro-web. + * + * The output is deliberately not 1:1 with `render-fabro` — themed attrs and + * the welcome ghost are canvas-only concerns and must never leak into the + * downloaded zip. + */ +export function renderCanvasDot( + draft: WorkflowDraft, + sim?: SimulationState, +): string { + const lines: string[] = []; + lines.push("digraph Playground {"); + if (draft.goal.length > 0) { + lines.push(` graph [goal="${escapeDot(draft.goal)}"]`); + } + lines.push(" rankdir=LR"); + lines.push(styleHeader()); + lines.push(""); + + // Terminals. + lines.push(' start [shape=Mdiamond, label="Start"]'); + lines.push(' exit [shape=Msquare, label="Exit"]'); + lines.push(styleNode("start", "start")); + lines.push(styleNode("exit", "exit")); + lines.push(""); + + if (isWelcomeState(draft)) { + // Replace the implicit start → exit with start → ghost → exit so the + // canvas has something to look at on first load. + lines.push(styleNode(GHOST_ID, "ghost")); + lines.push(` start -> ${GHOST_ID} -> exit`); + } else { + for (const node of draft.nodes) { + if (node.id === "start" || node.id === "exit") continue; + lines.push(` ${node.id} [${nodeBody(node)}]`); + } + if (draft.nodes.length > 2) lines.push(""); + for (const edge of draft.edges) { + lines.push(` ${edge.from} -> ${edge.to}${edgeBody(edge)}`); + } + } + + // Apply simulation overlays last so they win over the base theming. + if (sim) { + lines.push(""); + for (const node of draft.nodes) { + const overlay = simulationOverlay(node.id, sim); + if (overlay) lines.push(overlay); + } + } + + lines.push("}"); + return lines.join("\n"); +} + +function escapeDot(value: string): string { + return value.replace(/\\/g, "\\\\").replace(/"/g, '\\"'); +} + +function renderAttrValue(value: string | number | boolean): string { + if (typeof value === "boolean") return value ? "true" : "false"; + if (typeof value === "number") { + return Number.isFinite(value) ? String(value) : '"NaN"'; + } + return `"${escapeDot(value)}"`; +} + +function nodeBody(node: WorkflowDraft["nodes"][number]): string { + const parts: string[] = [`shape=${node.shape}`]; + if (node.label !== undefined) parts.push(`label=${renderAttrValue(node.label)}`); + if (node.attrs) { + for (const [k, v] of Object.entries(node.attrs)) { + // Skip prompts in the canvas — they often run multi-line and clutter + // node bodies. The chat trace already shows what each node is for. + parts.push(`${k}=${renderAttrValue(v)}`); + } + } + return parts.join(", "); +} + +function edgeBody(edge: WorkflowDraft["edges"][number]): string { + const parts: string[] = []; + if (edge.label !== undefined) parts.push(`label=${renderAttrValue(edge.label)}`); + if (edge.condition !== undefined) { + parts.push(`condition=${renderAttrValue(edge.condition)}`); + } + if (edge.attrs) { + for (const [k, v] of Object.entries(edge.attrs)) { + parts.push(`${k}=${renderAttrValue(v)}`); + } + } + return parts.length === 0 ? "" : ` [${parts.join(", ")}]`; +} diff --git a/apps/fabro-web/app/components/playground/canvas/simulation.test.ts b/apps/fabro-web/app/components/playground/canvas/simulation.test.ts new file mode 100644 index 000000000..1930e2a58 --- /dev/null +++ b/apps/fabro-web/app/components/playground/canvas/simulation.test.ts @@ -0,0 +1,115 @@ +import { describe, expect, test } from "bun:test"; + +import { createInitialDraft } from "../state/draft"; +import { applyToolCalls } from "../state/reducer"; +import { + advance, + initialSimulation, + startSimulation, + type SimulationState, +} from "./simulation"; + +function walkToCompletion( + state: SimulationState, + draft: ReturnType<typeof createInitialDraft>, + stepLimit = 50, +): SimulationState { + let cur = state; + let now = 0; + while (!cur.finished && stepLimit-- > 0) { + now += 100; + cur = advance(cur, draft, now, 0); + } + return cur; +} + +describe("simulation", () => { + test("startSimulation lights up `start` and records a trace entry", () => { + const draft = createInitialDraft(); + const state = startSimulation(draft, 0); + expect(state.active).toBe("start"); + expect(state.trace).toHaveLength(1); + expect(state.trace[0]).toMatchObject({ nodeId: "start", index: 0 }); + }); + + test("walks linear start -> plan -> exit", () => { + const { draft } = applyToolCalls(createInitialDraft(), [ + { name: "add_node", args: { id: "plan", label: "Plan", shape: "box" } }, + { name: "connect", args: { from: "start", to: "plan" } }, + { name: "connect", args: { from: "plan", to: "exit" } }, + { name: "disconnect", args: { from: "start", to: "exit" } }, + ]); + const end = walkToCompletion(startSimulation(draft, 0), draft); + expect(end.finished).toBe(true); + expect(end.trace.map((s) => s.nodeId)).toEqual(["start", "plan", "exit"]); + }); + + test("prefers conditional edges on a diamond branch", () => { + const { draft } = applyToolCalls(createInitialDraft(), [ + { + name: "add_node", + args: { id: "gate", label: "Pass?", shape: "diamond" }, + }, + { name: "add_node", args: { id: "fix", label: "Fix", shape: "box" } }, + { name: "connect", args: { from: "start", to: "gate" } }, + // Non-conditional retry edge from gate to fix; conditional happy path + // to exit. Simulator should prefer the conditional one. + { name: "connect", args: { from: "gate", to: "fix" } }, + { name: "connect", args: { from: "fix", to: "gate" } }, + { + name: "connect", + args: { from: "gate", to: "exit", condition: "outcome=ok" }, + }, + { name: "disconnect", args: { from: "start", to: "exit" } }, + ]); + const end = walkToCompletion(startSimulation(draft, 0), draft); + expect(end.finished).toBe(true); + expect(end.trace.map((s) => s.nodeId)).toEqual(["start", "gate", "exit"]); + }); + + test("honours max_visits on a loop", () => { + const { draft } = applyToolCalls(createInitialDraft(), [ + { + name: "add_node", + args: { + id: "impl", + label: "Implement", + shape: "box", + attrs: { max_visits: 2 }, + }, + }, + { + name: "add_node", + args: { id: "test", label: "Test", shape: "parallelogram" }, + }, + { name: "connect", args: { from: "start", to: "impl" } }, + { name: "connect", args: { from: "impl", to: "test" } }, + { name: "connect", args: { from: "test", to: "impl", label: "retry" } }, + { name: "connect", args: { from: "test", to: "exit", label: "done" } }, + { name: "disconnect", args: { from: "start", to: "exit" } }, + ]); + const end = walkToCompletion(startSimulation(draft, 0), draft); + const visits = end.trace.filter((s) => s.nodeId === "impl").length; + expect(visits).toBeLessThanOrEqual(2); + expect(end.finished).toBe(true); + expect(end.trace[end.trace.length - 1]?.nodeId).toBe("exit"); + }); + + test("halts when active is null (no walk in progress)", () => { + const draft = createInitialDraft(); + const idle = initialSimulation(); + const after = advance(idle, draft, 100, 0); + expect(after).toBe(idle); + }); + + test("safety cap: pathological cycle still halts", () => { + const { draft } = applyToolCalls(createInitialDraft(), [ + { name: "add_node", args: { id: "a", label: "A", shape: "box" } }, + { name: "connect", args: { from: "start", to: "a" } }, + { name: "connect", args: { from: "a", to: "a" } }, // rejected (self-loop) + ]); + // Self-loop was rejected, but make a cycle through start as the only path. + const end = walkToCompletion(startSimulation(draft, 0), draft, 200); + expect(end.finished).toBe(true); + }); +}); diff --git a/apps/fabro-web/app/components/playground/canvas/simulation.ts b/apps/fabro-web/app/components/playground/canvas/simulation.ts new file mode 100644 index 000000000..b786fe31d --- /dev/null +++ b/apps/fabro-web/app/components/playground/canvas/simulation.ts @@ -0,0 +1,163 @@ +/** + * Pure-frontend simulation of a workflow walk. + * + * Given a draft + a cursor (which node is "active" right now and which have + * been "done"), `nextStep` returns the next node to visit. The semantics + * are intentionally lightweight — the goal is to give the canvas something + * to animate, not to faithfully replay every Fabro engine behaviour: + * + * - Diamond / multiple-out branches → pick the first outgoing edge with a + * `condition`, falling back to the first non-self-loop edge. + * - Hexagon (human gate) → walk through, no pause. Pause UX lives in v2. + * - Loop edges (a node visited more than once via the same edge) → take + * the first outgoing edge whose target hasn't hit `max_visits` yet. + * - Cycle break → stop after `MAX_TOTAL_STEPS` total visits in case the + * graph has no path to `exit`. + * + * Pure function; the React layer's play-button drives the cadence with + * `setInterval`. + */ + +import { EXIT_ID, START_ID, type WorkflowDraft } from "../state/draft"; + +/** A single recorded step in the simulation trace. */ +export interface SimulationStep { + /** Monotonic id within a single run. */ + index: number; + /** Node visited at this step. */ + nodeId: string; + /** Human-friendly node label, for the RUN TRACE pane. */ + label: string; + /** Wall-clock ms since simulation start. */ + elapsedMs: number; +} + +export interface SimulationState { + /** Node currently lit on the canvas, or `null` if not running. */ + active: string | null; + /** Nodes already walked through, used for `is-done` styling + visit counts. */ + done: string[]; + /** Trace lines for the RUN TRACE pane. */ + trace: SimulationStep[]; + /** Whether the walk has reached `exit` or otherwise halted. */ + finished: boolean; +} + +/** Safety cap so a pathological graph can't lock the simulator. */ +const MAX_TOTAL_STEPS = 64; + +export function initialSimulation(): SimulationState { + return { active: null, done: [], trace: [], finished: false }; +} + +/** Drop simulation state and re-arm at the start. */ +export function resetSimulation(): SimulationState { + return initialSimulation(); +} + +/** + * Start a fresh run. Returns the state immediately after lighting up + * `start`. Subsequent steps come from `advance`. + */ +export function startSimulation( + draft: WorkflowDraft, + startedAtMs: number, +): SimulationState { + const startNode = draft.nodes.find((n) => n.id === START_ID); + const label = startNode?.label ?? "Start"; + return { + active: START_ID, + done: [], + trace: [{ index: 0, nodeId: START_ID, label, elapsedMs: 0 }], + finished: false, + }; +} + +/** + * Advance one step. Picks the next node from the current `active` node's + * outgoing edges, retires `active` to `done`, lights the next node. + * + * If `active` is `exit`, marks the run finished and returns unchanged. + */ +export function advance( + state: SimulationState, + draft: WorkflowDraft, + nowMs: number, + startedAtMs: number, +): SimulationState { + if (state.finished || state.active == null) return state; + if (state.active === EXIT_ID) { + return { ...state, finished: true }; + } + if (state.trace.length >= MAX_TOTAL_STEPS) { + return { ...state, finished: true }; + } + + const visitCounts = countVisits(state); + const next = pickNext(draft, state.active, visitCounts); + if (next === null) { + return { ...state, finished: true }; + } + const nextNode = draft.nodes.find((n) => n.id === next); + const label = nextNode?.label ?? next; + const done = state.done.includes(state.active) + ? state.done + : [...state.done, state.active]; + return { + active: next, + done, + trace: [ + ...state.trace, + { + index: state.trace.length, + nodeId: next, + label, + elapsedMs: Math.max(0, nowMs - startedAtMs), + }, + ], + finished: next === EXIT_ID, + }; +} + +function countVisits(state: SimulationState): Map<string, number> { + const counts = new Map<string, number>(); + for (const step of state.trace) { + counts.set(step.nodeId, (counts.get(step.nodeId) ?? 0) + 1); + } + return counts; +} + +function pickNext( + draft: WorkflowDraft, + from: string, + visitCounts: Map<string, number>, +): string | null { + const outgoing = draft.edges.filter((e) => e.from === from && e.to !== from); + if (outgoing.length === 0) return null; + + // First try edges with a `condition` (diamond / branch) — they're the + // intentional path the user defined. If none of the outgoing edges has + // a condition, every outgoing edge is a candidate. + const conditional = outgoing.filter((e) => e.condition !== undefined); + const candidates = conditional.length > 0 ? conditional : outgoing; + + // Skip any candidate whose target node has been visited at or past its + // declared `max_visits`. This implements the common Fabro pattern + // `impl [max_visits=3]` for bounded retry loops. + for (const edge of candidates) { + if (!isTargetCapped(draft, edge.to, visitCounts)) return edge.to; + } + // Every candidate is past its cap — pick the first one anyway; the + // top-level MAX_TOTAL_STEPS guard will eventually halt. + return candidates[0]?.to ?? null; +} + +function isTargetCapped( + draft: WorkflowDraft, + target: string, + visitCounts: Map<string, number>, +): boolean { + const attr = draft.nodes.find((n) => n.id === target)?.attrs?.max_visits; + if (typeof attr !== "number" || !Number.isFinite(attr)) return false; + return (visitCounts.get(target) ?? 0) >= attr; +} diff --git a/apps/fabro-web/app/components/playground/canvas/use-canvas-render.ts b/apps/fabro-web/app/components/playground/canvas/use-canvas-render.ts new file mode 100644 index 000000000..688f27d8b --- /dev/null +++ b/apps/fabro-web/app/components/playground/canvas/use-canvas-render.ts @@ -0,0 +1,71 @@ +import { useEffect, useRef, useState, type RefObject } from "react"; + +/** + * Synchronizes a DOM container with a Graphviz-rendered SVG. Pipes the + * supplied DOT string through `@viz-js/viz` (the same layout engine Fabro + * uses on the server) and mounts the resulting `<svg>` into `containerRef`, + * stripping Graphviz's auto-inserted graph `<title>` so it doesn't surface + * as a browser tooltip. Re-renders whenever `dot` changes; re-applies the + * selection highlight on `selectedNodeId` change without paying the layout + * cost again. + * + * Returns the current `<svg>` element via a ref (handy for fit-to-window + * measurements) and an error string when rendering fails. + */ +export function useCanvasRender( + containerRef: RefObject<HTMLDivElement | null>, + dot: string, + selectedNodeId: string | null, + applyHighlight: (svg: SVGSVGElement, selectedId: string | null) => void, +): { + svgRef: RefObject<SVGSVGElement | null>; + error: string | null; +} { + const svgRef = useRef<SVGSVGElement | null>(null); + const [error, setError] = useState<string | null>(null); + + useEffect(() => { + let cancelled = false; + (async () => { + try { + const { instance } = await import("@viz-js/viz"); + const viz = await instance(); + if (cancelled) return; + const svg = viz.renderSVGElement(dot); + stripGraphTitle(svg); + svgRef.current = svg; + if (containerRef.current) { + containerRef.current.replaceChildren(svg); + } + applyHighlight(svg, selectedNodeId); + setError(null); + } catch (e) { + if (!cancelled) { + setError(e instanceof Error ? e.message : "Failed to render canvas"); + } + } + })(); + return () => { + cancelled = true; + }; + }, [dot]); + + useEffect(() => { + const svg = svgRef.current; + if (svg) applyHighlight(svg, selectedNodeId); + }, [selectedNodeId, applyHighlight]); + + return { svgRef, error }; +} + +function stripGraphTitle(svg: SVGSVGElement) { + const title = svg.querySelector(".graph > title"); + if (!title) return; + let sibling = title.nextElementSibling; + while (sibling && sibling.tagName === "text") { + const next = sibling.nextElementSibling; + sibling.remove(); + sibling = next; + } + title.remove(); +} diff --git a/apps/fabro-web/app/components/playground/canvas/use-simulation.ts b/apps/fabro-web/app/components/playground/canvas/use-simulation.ts new file mode 100644 index 000000000..b5c0f8612 --- /dev/null +++ b/apps/fabro-web/app/components/playground/canvas/use-simulation.ts @@ -0,0 +1,79 @@ +/** + * React hook that drives a workflow simulation at a fixed cadence. + * + * Pure simulation math lives in `./simulation`; this hook handles the + * `setInterval` plumbing, exposes Play / Reset actions, and re-arms the + * cursor when the underlying draft changes (so a paused or finished run + * doesn't paint stale node highlights when the user keeps editing). + */ + +import { useCallback, useEffect, useRef, useState } from "react"; + +import { isWelcomeState, type WorkflowDraft } from "../state/draft"; +import { + advance, + initialSimulation, + startSimulation, + type SimulationState, +} from "./simulation"; + +export const DEFAULT_STEP_MS = 1200; +export const MIN_STEP_MS = 500; +export const MAX_STEP_MS = 3000; + +export interface PlaygroundSimulation { + state: SimulationState; + isRunning: boolean; + isPlayable: boolean; + stepMs: number; + setStepMs: (ms: number) => void; + play: () => void; + reset: () => void; +} + +export function useSimulation(draft: WorkflowDraft): PlaygroundSimulation { + const [state, setState] = useState<SimulationState>(initialSimulation); + const [stepMs, setStepMs] = useState(DEFAULT_STEP_MS); + const isPlayable = !isWelcomeState(draft); + + const draftRef = useRef(draft); + draftRef.current = draft; + const startedAtRef = useRef(0); + + const reset = useCallback(() => setState(initialSimulation()), []); + + const play = useCallback(() => { + if (!isPlayable) return; + startedAtRef.current = performance.now(); + setState(startSimulation(draftRef.current, startedAtRef.current)); + }, [isPlayable]); + + const isRunning = state.active !== null && !state.finished; + + // Reset whenever the draft mutates underneath a paused/finished run, so + // we never display node highlights on a graph that no longer matches the + // last walked path. + useEffect(() => { + setState(initialSimulation()); + }, [draft]); + + useEffect(() => { + if (!isRunning) return undefined; + const id = window.setInterval(() => { + setState((prev) => + advance(prev, draftRef.current, performance.now(), startedAtRef.current), + ); + }, stepMs); + return () => window.clearInterval(id); + }, [isRunning, stepMs]); + + return { + state, + isRunning, + isPlayable, + stepMs, + setStepMs, + play, + reset, + }; +} diff --git a/apps/fabro-web/app/components/playground/chat/runtime.test.ts b/apps/fabro-web/app/components/playground/chat/runtime.test.ts new file mode 100644 index 000000000..ea299e392 --- /dev/null +++ b/apps/fabro-web/app/components/playground/chat/runtime.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, test } from "bun:test"; + +import { createInitialDraft } from "../state/draft"; +import { renderFabro } from "../files/render-fabro"; +import { createPlaygroundAdapter } from "./runtime"; + +type AdapterRunInput = Parameters< + ReturnType<typeof createPlaygroundAdapter>["run"] +>[0]; + +function runInput(text: string): AdapterRunInput { + return { + messages: [ + { + role: "user", + content: [{ type: "text", text }], + }, + ], + abortSignal: new AbortController().signal, + } as unknown as AdapterRunInput; +} + +async function drain(iter: AsyncGenerator<unknown>): Promise<void> { + // eslint-disable-next-line no-empty + for await (const _ of iter) { + } +} + +describe("createPlaygroundAdapter request body", () => { + test("posts the rendered workflow.fabro under workflow_fabro", async () => { + const draft = createInitialDraft(); + let captured: { url: string; body: unknown } | null = null; + + const adapter = createPlaygroundAdapter({ + chatEndpoint: "/api/v1/playground/chat", + getWorkflow: () => draft, + dispatch: () => {}, + fetchImpl: (async (url: string | URL | Request, init?: RequestInit) => { + captured = { + url: String(url), + body: JSON.parse(String(init?.body)), + }; + return new Response("", { + status: 200, + headers: { "Content-Type": "text/event-stream" }, + }); + }) as typeof fetch, + }); + + await drain(adapter.run(runInput("build me a workflow"))); + + expect(captured).not.toBeNull(); + const { url, body } = captured! as { + url: string; + body: { + workflow_fabro: string; + workflow?: unknown; + messages: unknown[]; + }; + }; + expect(url).toBe("/api/v1/playground/chat"); + expect(body.workflow_fabro).toBe(renderFabro(draft)); + expect(body.workflow_fabro).toContain("digraph Untitled"); + // The structured draft no longer rides in the request. + expect(body.workflow).toBeUndefined(); + expect(body.messages).toHaveLength(1); + }); +}); diff --git a/apps/fabro-web/app/components/playground/chat/runtime.ts b/apps/fabro-web/app/components/playground/chat/runtime.ts new file mode 100644 index 000000000..4517e2560 --- /dev/null +++ b/apps/fabro-web/app/components/playground/chat/runtime.ts @@ -0,0 +1,361 @@ +/** + * assistant-ui adapter for the playground chat. + * + * Posts the rendered `workflow.fabro` contents alongside the message + * history to `POST /api/v1/playground/chat` on each turn (the server is + * stateless and embeds the file verbatim in its system prompt), then + * streams the resulting SSE: text deltas accumulate into the assistant + * transcript, and the model's `write_workflow_file` tool call carries + * the full new `workflow.fabro` content. We parse the content, diff it + * against the current draft, and animate the resulting reducer ops into + * the canvas so the user sees the new graph build in node-by-node + * instead of replacing instantly. + */ + +import type { + ChatModelAdapter, + ChatModelRunResult, + ThreadAssistantMessagePart, +} from "@assistant-ui/react"; + +import type { WorkflowDraft } from "../state/draft"; +import { renderFabro } from "../files/render-fabro"; +import { animateOps } from "../state/animate"; +import { diffDrafts } from "../state/diff"; +import { parseFabro } from "../state/parse-fabro"; +import type { ToolCall } from "../state/reducer"; + +type AdapterMessage = Parameters<ChatModelAdapter["run"]>[0]["messages"][number]; + +type StreamEvent = + | { type: "stream_start" } + | { type: "text_delta"; delta: string; text_id?: string | null } + | { type: "tool_call_end"; tool_call: WireToolCall } + | { type: "finish" } + | { type: "error"; error: unknown }; + +interface WireToolCall { + id: string; + name: string; + arguments: Record<string, unknown> | string; +} + +interface WriteWorkflowFileArgs { + file_name?: string; + content?: string; +} + +export interface PlaygroundAdapterOptions { + chatEndpoint: string; + /** + * Reads the latest draft. Rendered to `workflow.fabro` text for the + * request body, and read again per `write_workflow_file` to compute + * the diff. + */ + getWorkflow: () => WorkflowDraft; + /** Apply a single reducer op. Called repeatedly as the animation runs. */ + dispatch: (call: ToolCall) => void; + /** + * Called when the model's emitted DOT cannot be parsed. The caller is + * expected to inform the user and optionally submit a synthetic + * follow-up turn asking the model to re-emit a valid file. + */ + onParseFailure?: (info: { message: string; rawContent: string }) => void; + /** Called when the model's DOT parses successfully — handy for resetting auto-retry counters. */ + onParseSuccess?: () => void; + /** Milliseconds between animation steps. Default 220ms. */ + stepDelayMs?: number; + /** Override fetch for tests. */ + fetchImpl?: typeof fetch; +} + +export function createPlaygroundAdapter( + options: PlaygroundAdapterOptions, +): ChatModelAdapter { + const fetchImpl = options.fetchImpl ?? fetch; + + return { + async *run({ messages, abortSignal }) { + const body = { + messages: serializeMessages(messages), + workflow_fabro: renderFabro(options.getWorkflow()), + }; + + const response = await fetchImpl(options.chatEndpoint, { + method: "POST", + credentials: "same-origin", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + signal: abortSignal, + }); + + if (!response.ok) { + throw new Error( + `playground chat failed: ${response.status} ${response.statusText}`, + ); + } + + const parts: ThreadAssistantMessagePart[] = []; + let activeTextIndex: number | null = null; + + const snapshot = (): ChatModelRunResult => ({ content: parts.slice() }); + + const reader = response.body?.getReader(); + if (!reader) { + yield snapshot(); + return; + } + const decoder = new TextDecoder(); + let buffer = ""; + + while (true) { + // react-doctor-disable-next-line react-doctor/async-await-in-loop -- SSE chunks must be drained sequentially to preserve event order. + const { value, done } = await reader.read(); + if (done) break; + buffer += decoder.decode(value, { stream: true }); + + let cursor = 0; + while (true) { + const match = /\r?\n\r?\n/g.exec(buffer.slice(cursor)); + if (!match) break; + const next = cursor + match.index; + const frame = buffer.slice(cursor, next); + cursor = next + match[0].length; + const event = parseFrame(frame); + if (!event) continue; + + if (event.type === "text_delta") { + const delta = event.delta ?? ""; + if (!delta) continue; + if (activeTextIndex === null) { + parts.push({ type: "text", text: delta }); + activeTextIndex = parts.length - 1; + } else { + const part = parts[activeTextIndex]; + if (part && part.type === "text") { + parts[activeTextIndex] = { ...part, text: part.text + delta }; + } + } + yield snapshot(); + } else if (event.type === "tool_call_end") { + const handled = handleToolCallEnd(event.tool_call, options); + parts.push({ + type: "tool-call", + toolCallId: event.tool_call.id, + toolName: event.tool_call.name, + args: handled.args as never, + argsText: JSON.stringify(handled.args), + isError: handled.isError, + }); + activeTextIndex = null; + yield snapshot(); + } else if (event.type === "error") { + throw new Error( + `playground chat stream error: ${JSON.stringify(event.error)}`, + ); + } + } + buffer = buffer.slice(cursor); + } + + // Surface a non-empty result even on an empty turn so assistant-ui + // doesn't get stuck waiting for one. + yield snapshot(); + }, + }; +} + +interface HandledToolCall { + args: Record<string, unknown>; + isError: boolean; +} + +function handleToolCallEnd( + wire: WireToolCall, + options: PlaygroundAdapterOptions, +): HandledToolCall { + const args = parseArgs(wire.arguments); + if (wire.name !== "write_workflow_file") { + // Ignore unrecognised tools — log to console for diagnostic but + // don't crash the turn. + console.warn(`playground: ignoring unknown tool call "${wire.name}"`); + return { args, isError: true }; + } + + const writeArgs = args as WriteWorkflowFileArgs; + const content = typeof writeArgs.content === "string" ? writeArgs.content : ""; + if (!content) { + options.onParseFailure?.({ + message: "write_workflow_file emitted with no `content` argument.", + rawContent: "", + }); + return { args, isError: true }; + } + + const parsed = parseFabro(content); + if (parsed.ok === false) { + options.onParseFailure?.({ + message: parsed.error, + rawContent: content, + }); + return { args, isError: true }; + } + + options.onParseSuccess?.(); + const prev = options.getWorkflow(); + const ops = diffDrafts(prev, parsed.draft); + if (ops.length === 0) { + // Model wrote a workflow identical to the current state — nothing + // to animate, just surface the ack. + return { args, isError: false }; + } + + animateOps(ops, { + dispatch: options.dispatch, + stepDelayMs: options.stepDelayMs, + }); + return { args, isError: false }; +} + +function parseArgs(raw: Record<string, unknown> | string): Record<string, unknown> { + if (typeof raw === "string") { + try { + return JSON.parse(raw) as Record<string, unknown>; + } catch { + return {}; + } + } + if (raw && typeof raw === "object") return raw; + return {}; +} + +function parseFrame(frame: string): StreamEvent | null { + const dataLine = frame + .split(/\r?\n/) + .filter((line) => line.startsWith("data:")) + .map((line) => line.slice("data:".length).trimStart()) + .join("\n"); + if (!dataLine) return null; + try { + return JSON.parse(dataLine) as StreamEvent; + } catch { + return null; + } +} + +type SerializedPart = + | { kind: "text"; data: string } + | { + kind: "tool_call"; + data: { + id: string; + name: string; + type: string; + arguments: Record<string, unknown>; + }; + } + | { + kind: "tool_result"; + data: { + tool_call_id: string; + content: unknown; + is_error: boolean; + }; + }; + +interface SerializedMessage { + role: "user" | "assistant" | "system"; + content: SerializedPart[]; +} + +/** + * Stateful pass over the assistant-ui message history to produce the + * Anthropic-friendly wire format. + * + * Two non-obvious things this handles: + * + * 1. Assistant turns with `tool-call` parts are serialized as + * proper `kind: "tool_call"` content blocks (carrying id, name, + * arguments) so the model gets to see what it actually wrote + * last turn instead of just the surrounding text. + * + * 2. Anthropic requires every `tool_use` block in an assistant + * message to be matched by a `tool_result` block in the next + * user message. The playground reducer doesn't surface real + * tool results (everything is pure-write client-side), so we + * synthesize `{ok: true, applied: true}` results and prepend + * them to the next user message's content array. + */ +function serializeMessages(messages: readonly AdapterMessage[]): SerializedMessage[] { + const out: SerializedMessage[] = []; + let pendingToolResults: SerializedPart[] = []; + + for (const msg of messages) { + if (msg.role === "assistant") { + const content: SerializedPart[] = []; + const toolCallIds: string[] = []; + for (const part of msg.content as readonly { type: string; [k: string]: unknown }[]) { + if ( + part.type === "text" && + typeof part.text === "string" && + part.text.length > 0 + ) { + content.push({ kind: "text", data: part.text }); + } else if (part.type === "tool-call") { + const id = String(part.toolCallId ?? ""); + const name = String(part.toolName ?? ""); + const rawArgs = part.args; + const args = + rawArgs && typeof rawArgs === "object" + ? (rawArgs as Record<string, unknown>) + : {}; + content.push({ + kind: "tool_call", + data: { id, name, type: "function", arguments: args }, + }); + toolCallIds.push(id); + } + } + if (content.length === 0) continue; + out.push({ role: "assistant", content }); + + pendingToolResults = toolCallIds.map((id) => ({ + kind: "tool_result", + data: { + tool_call_id: id, + content: { ok: true, applied: true }, + is_error: false, + }, + })); + continue; + } + + if (msg.role === "user") { + const text = extractText(msg); + const content: SerializedPart[] = [...pendingToolResults]; + if (text.length > 0) content.push({ kind: "text", data: text }); + pendingToolResults = []; + if (content.length === 0) continue; + out.push({ role: "user", content }); + continue; + } + + // system / fallback + const text = extractText(msg); + if (text.length > 0) { + out.push({ role: msg.role, content: [{ kind: "text", data: text }] }); + } + } + + return out; +} + +function extractText(message: AdapterMessage): string { + const segments: string[] = []; + for (const part of message.content) { + if (part.type === "text" && typeof part.text === "string") { + segments.push(part.text); + } + } + return segments.join("\n"); +} diff --git a/apps/fabro-web/app/components/playground/chat/sidebar.tsx b/apps/fabro-web/app/components/playground/chat/sidebar.tsx new file mode 100644 index 000000000..1e094c4b7 --- /dev/null +++ b/apps/fabro-web/app/components/playground/chat/sidebar.tsx @@ -0,0 +1,201 @@ +import { useCallback, useMemo, useRef, useState } from "react"; +import type { AssistantRuntime } from "@assistant-ui/react"; +import { + AssistantRuntimeProvider, + useLocalRuntime, +} from "@assistant-ui/react"; +import { Thread, makeMarkdownText } from "@assistant-ui/react-ui"; +import { XMarkIcon } from "@heroicons/react/24/outline"; +import remarkGfm from "remark-gfm"; + +import SidebarComposer from "../../chats/sidebar-composer"; +import type { WorkflowDraft } from "../state/draft"; +import type { ToolCall } from "../state/reducer"; +import { createPlaygroundAdapter } from "./runtime"; +import PlaygroundToolCallSummary from "./tool-call-summary"; +import PlaygroundWelcome from "./welcome"; + +const MarkdownText = makeMarkdownText({ remarkPlugins: [remarkGfm] }); + +const SIDEBAR_WIDTH = 420; +const SIDEBAR_MAX_WIDTH = SIDEBAR_WIDTH * 2; + +/** + * Playground-flavoured Ask Fabro sidebar. Mirrors `AskFabroSidebar`'s + * look and feel (left-edge drag handle, animated width, stripped composer), + * but talks to `/api/v1/playground/chat` via `createPlaygroundAdapter` + * instead of the session-scoped Ask Fabro runtime. + * + * Each turn the model emits one `write_workflow_file` tool call with the + * full new DOT contents; the adapter parses, diffs against the current + * draft, and animates the resulting reducer ops into the canvas. `dispatch` + * is called once per animated op; `onParseFailure` fires if the model's + * DOT couldn't be parsed. + */ +/** Max consecutive auto-retries when the model's DOT fails to parse. */ +const MAX_AUTO_RETRIES = 2; + +export default function PlaygroundChatSidebar({ + isOpen, + onClose, + chatEndpoint, + getWorkflow, + dispatch, + onParseFailure, + width, + onWidthChange, +}: { + isOpen: boolean; + onClose: () => void; + chatEndpoint: string; + getWorkflow: () => WorkflowDraft; + dispatch: (call: ToolCall) => void; + onParseFailure?: (info: { message: string; rawContent: string }) => void; + width: number; + onWidthChange: (width: number) => void; +}) { + // The runtime is created from the adapter (chicken-and-egg) so we stash + // it in a ref after creation. The adapter's onParseFailure callback + // reads from this ref to call `runtime.thread.append` for auto-retry. + const runtimeRef = useRef<AssistantRuntime | null>(null); + const autoRetriesRef = useRef(0); + + const handleParseFailure = useCallback( + (info: { message: string; rawContent: string }) => { + if ( + runtimeRef.current !== null && + autoRetriesRef.current < MAX_AUTO_RETRIES + ) { + autoRetriesRef.current++; + runtimeRef.current.thread.append({ + role: "user", + content: [ + { + type: "text", + text: + `The DOT you wrote couldn't be parsed: ${info.message}. ` + + `Please re-emit a complete \`workflow.fabro\` with valid syntax.`, + }, + ], + }); + } + onParseFailure?.(info); + }, + [onParseFailure], + ); + + const handleParseSuccess = useCallback(() => { + autoRetriesRef.current = 0; + }, []); + + // The adapter is referentially-stable across renders because it reads the + // draft via `getWorkflow` on each turn — no need to memoise on draft. + const adapter = useMemo( + () => + createPlaygroundAdapter({ + chatEndpoint, + getWorkflow, + dispatch, + onParseFailure: handleParseFailure, + onParseSuccess: handleParseSuccess, + }), + [chatEndpoint, getWorkflow, dispatch, handleParseFailure, handleParseSuccess], + ); + const runtime = useLocalRuntime(adapter); + runtimeRef.current = runtime; + + const [isDragging, setIsDragging] = useState(false); + const dragOrigin = useRef<{ x: number; width: number } | null>(null); + + const handlePointerDown = (event: React.PointerEvent<HTMLDivElement>) => { + event.preventDefault(); + event.currentTarget.setPointerCapture(event.pointerId); + dragOrigin.current = { x: event.clientX, width }; + setIsDragging(true); + }; + + const handlePointerMove = (event: React.PointerEvent<HTMLDivElement>) => { + const origin = dragOrigin.current; + if (!origin) return; + const next = origin.width + (origin.x - event.clientX); + onWidthChange(Math.min(SIDEBAR_MAX_WIDTH, Math.max(SIDEBAR_WIDTH, next))); + }; + + const endDrag = (event: React.PointerEvent<HTMLDivElement>) => { + if (!dragOrigin.current) return; + event.currentTarget.releasePointerCapture(event.pointerId); + dragOrigin.current = null; + setIsDragging(false); + }; + + return ( + <aside + aria-label="Ask Fabro" + aria-hidden={!isOpen} + style={{ width: isOpen ? width : 0 }} + className={`h-full shrink-0 overflow-hidden ${ + isDragging + ? "" + : "transition-[width] duration-300 ease-[cubic-bezier(0.16,1,0.3,1)]" + }`} + > + <div + className={`fabro-chat ask-fabro-sidebar relative isolate flex h-full flex-col border-l border-line bg-panel/40 backdrop-blur-sm ${ + isDragging ? "select-none" : "" + }`} + style={{ width }} + > + {/* react-doctor-disable-next-line react-doctor/prefer-tag-over-role -- Interactive draggable splitter; <hr> wouldn't convey resize. */} + <div + role="separator" + aria-orientation="vertical" + aria-label="Resize Ask Fabro panel" + onPointerDown={handlePointerDown} + onPointerMove={handlePointerMove} + onPointerUp={endDrag} + onPointerCancel={endDrag} + className="group absolute inset-y-0 left-0 z-20 w-2 cursor-col-resize touch-none" + > + <span + aria-hidden + className={`absolute inset-y-0 left-0 w-0.5 transition-colors ${ + isDragging + ? "bg-teal-500" + : "bg-transparent group-hover:bg-teal-500/60" + }`} + /> + </div> + <header className="flex h-12 shrink-0 items-center justify-end px-2"> + <button + type="button" + onClick={onClose} + aria-label="Close assistant" + className="inline-flex size-8 items-center justify-center rounded-md text-fg-3 transition-colors hover:bg-overlay hover:text-fg focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-teal-500" + > + <XMarkIcon className="size-4" /> + </button> + </header> + <div className="min-h-0 flex-1"> + <AssistantRuntimeProvider runtime={runtime}> + <Thread + components={{ + Composer: SidebarComposer, + ThreadWelcome: PlaygroundWelcome, + }} + assistantMessage={{ + components: { Text: MarkdownText, ToolFallback: PlaygroundToolCallSummary }, + allowCopy: false, + allowReload: false, + allowSpeak: false, + allowFeedbackPositive: false, + allowFeedbackNegative: false, + }} + /> + </AssistantRuntimeProvider> + </div> + </div> + </aside> + ); +} + +export { SIDEBAR_WIDTH }; diff --git a/apps/fabro-web/app/components/playground/chat/tool-call-summary.tsx b/apps/fabro-web/app/components/playground/chat/tool-call-summary.tsx new file mode 100644 index 000000000..7806203e3 --- /dev/null +++ b/apps/fabro-web/app/components/playground/chat/tool-call-summary.tsx @@ -0,0 +1,119 @@ +import type { + ThreadAssistantMessagePart, + ToolCallMessagePart, + ToolCallMessagePartProps, +} from "@assistant-ui/react"; +import { useMessage } from "@assistant-ui/react"; +import { + DocumentTextIcon, + ExclamationTriangleIcon, +} from "@heroicons/react/24/outline"; + +import { parseFabro } from "../state/parse-fabro"; + +const EMPTY_PARTS: readonly ThreadAssistantMessagePart[] = []; + +/** + * Playground-flavoured tool-call renderer. Each assistant turn emits + * exactly one `write_workflow_file` tool call carrying the full new + * `workflow.fabro` DOT; this renderer parses that content at render + * time and surfaces compact, informative counts — + * `Wrote workflow.fabro (6 nodes, 7 edges)` — instead of the generic + * "N tool calls" aggregate the Ask Fabro chat uses. + * + * Like `app/components/chats/tool-call-summary.tsx`, this renders once + * per assistant message anchored to the first tool-call part, so the + * whole message contributes one compact line. + */ +export default function PlaygroundToolCallSummary(props: ToolCallMessagePartProps) { + const content = useMessage((message) => + message.role === "assistant" ? message.content : EMPTY_PARTS, + ); + const toolCalls = content.filter( + (part): part is ToolCallMessagePart => part.type === "tool-call", + ); + + // Anchor render to the first tool-call part so we only emit one line per message. + if (toolCalls[0]?.toolCallId !== props.toolCallId) { + return null; + } + + const writeCall = toolCalls.find((tc) => tc.toolName === "write_workflow_file"); + const erroredCall = toolCalls.find((tc) => tc.isError); + + if (erroredCall) { + return ( + <SummaryChip + icon={<ExclamationTriangleIcon className="size-3.5" aria-hidden="true" />} + tone="error" + > + Couldn't apply workflow update + </SummaryChip> + ); + } + + if (!writeCall) { + // Unknown / unhandled tool — fall back to the bare count so the message + // doesn't disappear entirely. + return <SummaryChip>{toolCalls.length} tool calls</SummaryChip>; + } + + const counts = countsFromContent(writeCall.args); + if (!counts) { + return <SummaryChip>Wrote workflow.fabro</SummaryChip>; + } + + return ( + <SummaryChip + icon={<DocumentTextIcon className="size-3.5" aria-hidden="true" />} + > + Wrote workflow.fabro ({counts.nodes} {pluralize(counts.nodes, "node")},{" "} + {counts.edges} {pluralize(counts.edges, "edge")}) + </SummaryChip> + ); +} + +function SummaryChip({ + icon, + tone, + children, +}: { + icon?: React.ReactNode; + tone?: "default" | "error"; + children: React.ReactNode; +}) { + const palette = + tone === "error" + ? "border-rose-500/30 bg-rose-500/10 text-rose-200" + : "border-line bg-overlay/60 text-fg-muted"; + return ( + <div + className={`my-2 inline-flex items-center gap-1.5 rounded-md border px-2 py-1 text-xs ${palette}`} + > + {icon} + <span>{children}</span> + </div> + ); +} + +function pluralize(n: number, singular: string): string { + return n === 1 ? singular : `${singular}s`; +} + +/** + * Extract user-visible node and edge counts from a `write_workflow_file` + * tool call's args. Counts exclude the reserved `start` / `exit` + * terminals so the number matches what the user thinks of as "their + * workflow" — a six-node pipeline counts as 6, not 8. + */ +function countsFromContent(args: unknown): { nodes: number; edges: number } | null { + if (!args || typeof args !== "object") return null; + const content = (args as { content?: unknown }).content; + if (typeof content !== "string" || content.length === 0) return null; + const result = parseFabro(content); + if (result.ok === false) return null; + const userNodes = result.draft.nodes.filter( + (n) => n.id !== "start" && n.id !== "exit", + ).length; + return { nodes: userNodes, edges: result.draft.edges.length }; +} diff --git a/apps/fabro-web/app/components/playground/chat/welcome.tsx b/apps/fabro-web/app/components/playground/chat/welcome.tsx new file mode 100644 index 000000000..6a0cdd352 --- /dev/null +++ b/apps/fabro-web/app/components/playground/chat/welcome.tsx @@ -0,0 +1,84 @@ +import { ThreadPrimitive } from "@assistant-ui/react"; +import { + ArrowPathIcon, + ClipboardDocumentCheckIcon, + DocumentTextIcon, + WrenchScrewdriverIcon, +} from "@heroicons/react/16/solid"; + +/** + * Suggestion chips on the empty playground thread. Per the spec, clicking + * a chip *fills the composer* rather than auto-sending — the user sees + * their prompt go in and can tweak before hitting send. + * + * `ThreadPrimitive.Suggestion` without the `send` prop does exactly that. + */ +const SUGGESTIONS = [ + { + Icon: ClipboardDocumentCheckIcon, + heading: "Daily standup summary", + description: "Summarize yesterday's commits into a standup post.", + prompt: + "Build a workflow that pulls yesterday's git commits and summarises them into a Slack-ready daily standup post.", + }, + { + Icon: WrenchScrewdriverIcon, + heading: "Lint, test, and open a PR", + description: "Test the diff and ship it as a draft PR if it passes.", + prompt: + "Build a workflow that lints, runs the tests, and opens a draft pull request only if everything passes.", + }, + { + Icon: DocumentTextIcon, + heading: "Release notes", + description: "Generate notes from git log between two tags.", + prompt: + "Build a workflow that takes two git tags and produces release notes from the commits between them.", + }, + { + Icon: ArrowPathIcon, + heading: "Triage a GitHub issue", + description: "Label, summarise, and assign new issues.", + prompt: + "Build a workflow that takes a fresh GitHub issue and triages it: choose labels, summarise the report, and assign an owner.", + }, +]; + +export default function PlaygroundWelcome() { + return ( + <ThreadPrimitive.Empty> + <div className="flex flex-col gap-6 px-4 py-8"> + <div> + <h2 className="text-base font-semibold text-fg"> + What workflow are you trying to build? + </h2> + <p className="mt-1 text-xs text-fg-3"> + Describe it and I'll sketch the graph on the canvas. Pick one + below to start, or write your own. + </p> + </div> + <ul className="flex flex-col gap-3"> + {SUGGESTIONS.map((s) => ( + <li key={s.heading}> + <ThreadPrimitive.Suggestion asChild prompt={s.prompt}> + <button + type="button" + className="flex w-full items-start gap-3 rounded-xl bg-panel-alt/60 px-4 py-3.5 text-left ring-1 ring-line transition-colors hover:bg-panel-alt hover:ring-line-strong focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-teal-500" + > + <s.Icon + aria-hidden="true" + className="size-4 h-lh shrink-0 fill-teal-300" + /> + <div className="flex flex-col gap-1"> + <p className="text-sm font-medium text-fg">{s.heading}</p> + <p className="text-xs text-fg-3">{s.description}</p> + </div> + </button> + </ThreadPrimitive.Suggestion> + </li> + ))} + </ul> + </div> + </ThreadPrimitive.Empty> + ); +} diff --git a/apps/fabro-web/app/components/playground/files/download.test.ts b/apps/fabro-web/app/components/playground/files/download.test.ts new file mode 100644 index 000000000..062c23350 --- /dev/null +++ b/apps/fabro-web/app/components/playground/files/download.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, test } from "bun:test"; +import { unzipSync, strFromU8 } from "fflate"; + +import { createInitialDraft } from "../state/draft"; +import { applyToolCalls } from "../state/reducer"; +import { buildDownloadBundle, resolveWorkflowName } from "./download"; + +describe("resolveWorkflowName", () => { + test("returns the fallback when the draft is still 'untitled'", () => { + expect(resolveWorkflowName(createInitialDraft())).toBe( + "playground-workflow", + ); + }); + + test("returns the draft name when it's valid snake_case", () => { + const { draft } = applyToolCalls(createInitialDraft(), [ + { name: "set_workflow_meta", args: { name: "release_notes" } }, + ]); + expect(resolveWorkflowName(draft)).toBe("release_notes"); + }); +}); + +describe("buildDownloadBundle", () => { + test("welcome state still produces a runnable artifact", () => { + const bundle = buildDownloadBundle(createInitialDraft()); + expect(bundle.workflowName).toBe("playground-workflow"); + expect(bundle.zipFilename).toBe("playground-workflow.fabro.zip"); + expect(bundle.bytes.length).toBeGreaterThan(0); + + const entries = unzipSync(bundle.bytes); + expect( + strFromU8( + entries[".fabro/workflows/playground-workflow/workflow.fabro"]!, + ), + ).toContain("digraph"); + expect( + strFromU8( + entries[".fabro/workflows/playground-workflow/workflow.toml"]!, + ), + ).toContain('graph = "workflow.fabro"'); + expect(strFromU8(entries[".fabro/project.toml"]!)).toContain( + "[run.pull_request]", + ); + expect(strFromU8(entries["README.md"]!)).toContain( + "fabro run playground-workflow", + ); + }); + + test("named workflow zip layout uses the snake_case name", () => { + const { draft } = applyToolCalls(createInitialDraft(), [ + { name: "set_workflow_meta", args: { name: "release_notes" } }, + { + name: "add_node", + args: { id: "plan", label: "Plan", shape: "box", prompt: "Plan it" }, + }, + ]); + const bundle = buildDownloadBundle(draft); + expect(bundle.workflowName).toBe("release_notes"); + expect(bundle.zipFilename).toBe("release_notes.fabro.zip"); + + const entries = unzipSync(bundle.bytes); + const expectedPaths = [ + ".fabro/project.toml", + ".fabro/workflows/release_notes/workflow.fabro", + ".fabro/workflows/release_notes/workflow.toml", + "README.md", + ]; + for (const path of expectedPaths) { + expect(entries[path]).toBeDefined(); + } + + expect( + strFromU8(entries[".fabro/workflows/release_notes/workflow.fabro"]!), + ).toContain("plan [shape=box, label=\"Plan\", prompt=\"Plan it\"]"); + }); +}); diff --git a/apps/fabro-web/app/components/playground/files/download.ts b/apps/fabro-web/app/components/playground/files/download.ts new file mode 100644 index 000000000..e7b4f40d3 --- /dev/null +++ b/apps/fabro-web/app/components/playground/files/download.ts @@ -0,0 +1,102 @@ +/** + * Build the downloadable `.fabro.zip` from a `WorkflowDraft`. + * + * Layout matches the dot-fabro contract — drop the unzipped folder into any + * repo and `fabro run <name>` against it locally: + * + * .fabro/ + * project.toml + * workflows/<name>/ + * workflow.fabro + * workflow.toml + * README.md + * + * `<name>` falls back to `playground-workflow` while the draft is still + * `untitled`, so a user who downloads before the model has named anything + * still gets a runnable artifact. + */ + +import { zipSync, strToU8 } from "fflate"; + +import { + DEFAULT_NAME, + FALLBACK_DOWNLOAD_NAME, + isValidWorkflowName, + type WorkflowDraft, +} from "../state/draft"; +import { renderFabro } from "./render-fabro"; +import { renderProjectToml, renderWorkflowToml } from "./render-toml"; +import { renderReadme } from "./render-readme"; + +export type DownloadBundle = { + /** Snake_case workflow name used in the zip layout and the filename. */ + workflowName: string; + /** Suggested filename for the download (e.g. `release_notes.fabro.zip`). */ + zipFilename: string; + /** Zip body as a `Uint8Array`, ready to wrap in a `Blob`. */ + bytes: Uint8Array; +}; + +/** + * Resolve a safe workflow name for the zip layout and filename. Strict + * snake_case names are kept as-is; anything else (including the default + * `"untitled"`) collapses to a stable fallback. + */ +export function resolveWorkflowName(draft: WorkflowDraft): string { + if (draft.name !== DEFAULT_NAME && isValidWorkflowName(draft.name)) { + return draft.name; + } + return FALLBACK_DOWNLOAD_NAME; +} + +/** + * Synchronously build the zip. Done on the main thread because the four + * files are tiny (a few KB total) and `zipSync` finishes in microseconds — + * the async variant + worker plumbing would dwarf the actual work. + */ +export function buildDownloadBundle(draft: WorkflowDraft): DownloadBundle { + const workflowName = resolveWorkflowName(draft); + + const bytes = zipSync({ + ".fabro": { + "project.toml": strToU8(renderProjectToml(draft)), + workflows: { + [workflowName]: { + "workflow.fabro": strToU8(renderFabro(draft)), + "workflow.toml": strToU8(renderWorkflowToml(draft)), + }, + }, + }, + "README.md": strToU8(renderReadme(draft)), + }); + + return { + workflowName, + zipFilename: `${workflowName}.fabro.zip`, + bytes, + }; +} + +/** + * Browser-side: trigger a download for the given bundle by creating a + * one-shot blob URL and clicking a synthetic `<a download>`. + * + * Split from `buildDownloadBundle` so the bundle can be tested without a + * DOM, and so a future server-side or CLI flow can reuse the bytes. + */ +export function triggerDownload(bundle: DownloadBundle): void { + if (typeof window === "undefined") return; + // Copy into a fresh ArrayBuffer so we never hand the underlying SharedArrayBuffer + // (or stale slab) to Blob; defensive but cheap. + const buffer = new Uint8Array(bundle.bytes); + const blob = new Blob([buffer.buffer], { type: "application/zip" }); + const url = URL.createObjectURL(blob); + const anchor = document.createElement("a"); + anchor.href = url; + anchor.download = bundle.zipFilename; + document.body.appendChild(anchor); + anchor.click(); + document.body.removeChild(anchor); + // Give the browser a tick to start the download before revoking the URL. + setTimeout(() => URL.revokeObjectURL(url), 0); +} diff --git a/apps/fabro-web/app/components/playground/files/render-fabro.test.ts b/apps/fabro-web/app/components/playground/files/render-fabro.test.ts new file mode 100644 index 000000000..ec8a4efd5 --- /dev/null +++ b/apps/fabro-web/app/components/playground/files/render-fabro.test.ts @@ -0,0 +1,164 @@ +import { describe, expect, test } from "bun:test"; + +import { createInitialDraft } from "../state/draft"; +import { applyToolCalls } from "../state/reducer"; +import { renderFabro } from "./render-fabro"; + +describe("renderFabro", () => { + test("welcome state renders start and exit with the implicit edge", () => { + expect(renderFabro(createInitialDraft())).toBe( + [ + "digraph Untitled {", + " rankdir=LR", + "", + ' start [shape=Mdiamond, label="Start"]', + ' exit [shape=Msquare, label="Exit"]', + "", + " start -> exit", + "}", + "", + ].join("\n"), + ); + }); + + test("linear workflow with prompt + goal", () => { + const { draft } = applyToolCalls(createInitialDraft(), [ + { + name: "set_workflow_meta", + args: { name: "release_notes", goal: "Generate release notes" }, + }, + { + name: "add_node", + args: { + id: "plan", + label: "Plan", + shape: "box", + prompt: "Plan the work.", + }, + }, + { name: "connect", args: { from: "start", to: "plan" } }, + { name: "connect", args: { from: "plan", to: "exit" } }, + { name: "disconnect", args: { from: "start", to: "exit" } }, + ]); + + expect(renderFabro(draft)).toBe( + [ + "digraph ReleaseNotes {", + ' graph [goal="Generate release notes"]', + " rankdir=LR", + "", + ' start [shape=Mdiamond, label="Start"]', + ' exit [shape=Msquare, label="Exit"]', + "", + ' plan [shape=box, label="Plan", prompt="Plan the work."]', + "", + " start -> plan", + " plan -> exit", + "}", + "", + ].join("\n"), + ); + }); + + test("branch with diamond + edge labels and conditions", () => { + const { draft } = applyToolCalls(createInitialDraft(), [ + { name: "set_workflow_meta", args: { name: "branch_demo" } }, + { + name: "add_node", + args: { id: "validate", label: "Validate", shape: "box" }, + }, + { + name: "add_node", + args: { id: "gate", label: "Tests passing?", shape: "diamond" }, + }, + { name: "connect", args: { from: "start", to: "validate" } }, + { name: "connect", args: { from: "validate", to: "gate" } }, + { + name: "connect", + args: { + from: "gate", + to: "exit", + condition: "outcome=succeeded", + label: "Yes", + }, + }, + { + name: "connect", + args: { from: "gate", to: "validate", label: "No" }, + }, + { name: "disconnect", args: { from: "start", to: "exit" } }, + ]); + + expect(renderFabro(draft)).toBe( + [ + "digraph BranchDemo {", + " rankdir=LR", + "", + ' start [shape=Mdiamond, label="Start"]', + ' exit [shape=Msquare, label="Exit"]', + "", + ' validate [shape=box, label="Validate"]', + ' gate [shape=diamond, label="Tests passing?"]', + "", + " start -> validate", + " validate -> gate", + ' gate -> exit [label="Yes", condition="outcome=succeeded"]', + ' gate -> validate [label="No"]', + "}", + "", + ].join("\n"), + ); + }); + + test("node attrs render with their declared types", () => { + const { draft } = applyToolCalls(createInitialDraft(), [ + { + name: "add_node", + args: { + id: "implement", + label: "Implement", + shape: "box", + attrs: { max_visits: 3, goal_gate: true, timeout: "900s" }, + }, + }, + ]); + + const dot = renderFabro(draft); + expect(dot).toContain("max_visits=3"); + expect(dot).toContain("goal_gate=true"); + expect(dot).toContain('timeout="900s"'); + }); + + test("escapes embedded quotes and backslashes in attribute strings", () => { + const { draft } = applyToolCalls(createInitialDraft(), [ + { + name: "add_node", + args: { + id: "tricky", + label: 'Say "hi" \\o/', + shape: "box", + prompt: 'Write: "hello"', + }, + }, + ]); + + const dot = renderFabro(draft); + expect(dot).toContain('label="Say \\"hi\\" \\\\o/"'); + expect(dot).toContain('prompt="Write: \\"hello\\""'); + }); + + test("preserves literal newlines inside prompt strings", () => { + const { draft } = applyToolCalls(createInitialDraft(), [ + { + name: "add_node", + args: { + id: "multiline", + label: "Multi", + shape: "box", + prompt: "line one\nline two", + }, + }, + ]); + expect(renderFabro(draft)).toContain('prompt="line one\nline two"'); + }); +}); diff --git a/apps/fabro-web/app/components/playground/files/render-fabro.ts b/apps/fabro-web/app/components/playground/files/render-fabro.ts new file mode 100644 index 000000000..ae3db7495 --- /dev/null +++ b/apps/fabro-web/app/components/playground/files/render-fabro.ts @@ -0,0 +1,142 @@ +/** + * Render a `WorkflowDraft` to a `.fabro` (Graphviz DOT) document. + * + * Output mirrors the canonical style used in this repo's + * `.fabro/workflows/<name>/workflow.fabro` files: `Mdiamond` / `Msquare` for + * the start / exit terminals (capital M), lowercase names for all other + * shapes, attributes inside a single `[ ... ]` bracket, one edge per line. + */ + +import type { + AttrValue, + Edge, + Node, + Shape, + WorkflowDraft, +} from "../state/draft"; + +/** Map our internal `Shape` literal to the on-disk Graphviz spelling. */ +function dotShape(shape: Shape): string { + switch (shape) { + case "mdiamond": + return "Mdiamond"; + case "msquare": + return "Msquare"; + default: + return shape; + } +} + +/** Escape a string for inclusion inside a double-quoted DOT attribute. */ +function escapeDot(value: string): string { + return value.replace(/\\/g, "\\\\").replace(/"/g, '\\"'); +} + +/** Render a single attribute value with DOT's quoting rules. */ +function renderAttrValue(value: AttrValue): string { + if (typeof value === "boolean") return value ? "true" : "false"; + if (typeof value === "number") return Number.isFinite(value) ? String(value) : '"NaN"'; + return `"${escapeDot(value)}"`; +} + +function renderAttrs(entries: [string, AttrValue | undefined][]): string { + const present = entries.filter( + (entry): entry is [string, AttrValue] => entry[1] !== undefined, + ); + if (present.length === 0) return ""; + return present.map(([k, v]) => `${k}=${renderAttrValue(v)}`).join(", "); +} + +function renderNode(node: Node): string { + // Order is chosen to match the canonical .fabro/workflows/* style: + // shape first (and unquoted — it's a DOT identifier, not a string), + // then label, then prompt, then user attrs. + const parts: string[] = [`shape=${dotShape(node.shape)}`]; + if (node.label !== undefined) parts.push(`label=${renderAttrValue(node.label)}`); + if (node.prompt !== undefined) parts.push(`prompt=${renderAttrValue(node.prompt)}`); + if (node.attrs) { + for (const [k, v] of Object.entries(node.attrs)) { + parts.push(`${k}=${renderAttrValue(v)}`); + } + } + return `${node.id} [${parts.join(", ")}]`; +} + +function renderEdge(edge: Edge): string { + const entries: [string, AttrValue | undefined][] = []; + if (edge.label !== undefined) entries.push(["label", edge.label]); + if (edge.condition !== undefined) entries.push(["condition", edge.condition]); + if (edge.attrs) { + for (const [k, v] of Object.entries(edge.attrs)) { + entries.push([k, v]); + } + } + const body = renderAttrs(entries); + const base = `${edge.from} -> ${edge.to}`; + return body.length === 0 ? base : `${base} [${body}]`; +} + +/** Convert a snake_case workflow name to a Pascal-case DOT digraph id. */ +function pascalCase(snake: string): string { + return snake + .split("_") + .filter((part) => part.length > 0) + .map((part) => part[0]!.toUpperCase() + part.slice(1)) + .join(""); +} + +/** Pad an array of node lines so attribute lists left-align. */ +function alignAfterId(lines: string[]): string[] { + if (lines.length === 0) return lines; + // Each line looks like `id [...]`; align the `[` column. + const widest = lines.reduce((max, line) => { + const idEnd = line.indexOf(" ["); + return idEnd > max ? idEnd : max; + }, 0); + return lines.map((line) => { + const idEnd = line.indexOf(" ["); + if (idEnd === -1) return line; + const pad = " ".repeat(widest - idEnd); + return line.slice(0, idEnd) + pad + line.slice(idEnd); + }); +} + +export function renderFabro(draft: WorkflowDraft): string { + const lines: string[] = []; + const digraphName = pascalCase(draft.name) || "Workflow"; + + lines.push(`digraph ${digraphName} {`); + if (draft.goal.length > 0) { + lines.push(` graph [goal="${escapeDot(draft.goal)}"]`); + } + lines.push(" rankdir=LR"); + lines.push(""); + + const terminalLines = draft.nodes + .filter((n) => n.shape === "mdiamond" || n.shape === "msquare") + .map(renderNode); + for (const line of alignAfterId(terminalLines)) { + lines.push(` ${line}`); + } + + const otherLines = draft.nodes + .filter((n) => n.shape !== "mdiamond" && n.shape !== "msquare") + .map(renderNode); + if (otherLines.length > 0) { + lines.push(""); + for (const line of alignAfterId(otherLines)) { + lines.push(` ${line}`); + } + } + + if (draft.edges.length > 0) { + lines.push(""); + for (const edge of draft.edges) { + lines.push(` ${renderEdge(edge)}`); + } + } + + lines.push("}"); + lines.push(""); + return lines.join("\n"); +} diff --git a/apps/fabro-web/app/components/playground/files/render-readme.test.ts b/apps/fabro-web/app/components/playground/files/render-readme.test.ts new file mode 100644 index 000000000..4f3731184 --- /dev/null +++ b/apps/fabro-web/app/components/playground/files/render-readme.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, test } from "bun:test"; + +import { createInitialDraft } from "../state/draft"; +import { applyToolCalls } from "../state/reducer"; +import { renderReadme } from "./render-readme"; + +describe("renderReadme", () => { + test("uses fallback name and omits goal block for the welcome state", () => { + const md = renderReadme(createInitialDraft()); + expect(md).toContain("# Playground workflow"); + expect(md).toContain("fabro run playground-workflow"); + expect(md).not.toContain(">"); // no goal line + }); + + test("renders the goal as a markdown quote when present", () => { + const { draft } = applyToolCalls(createInitialDraft(), [ + { + name: "set_workflow_meta", + args: { name: "release_notes", goal: "Generate release notes." }, + }, + ]); + const md = renderReadme(draft); + expect(md).toContain("# Release notes"); + expect(md).toContain("> Generate release notes."); + expect(md).toContain("fabro run release_notes"); + }); + + test("includes the Fabro blurb and learn-more link", () => { + const md = renderReadme(createInitialDraft()); + expect(md).toContain("Fabro turns a Graphviz file"); + expect(md).toContain("https://fabro.sh"); + }); +}); diff --git a/apps/fabro-web/app/components/playground/files/render-readme.ts b/apps/fabro-web/app/components/playground/files/render-readme.ts new file mode 100644 index 000000000..2a864edbf --- /dev/null +++ b/apps/fabro-web/app/components/playground/files/render-readme.ts @@ -0,0 +1,53 @@ +/** + * Render the README that ships in the downloaded zip. + * + * The downloaded artifact is meant to be dropped straight into any repo, so + * the README explains what the user got, how to run it, and what Fabro is + * (in case they're handing the folder to a teammate who hasn't seen Fabro + * yet). No link back to the playground session, by design. + */ + +import { + DEFAULT_NAME, + FALLBACK_DOWNLOAD_NAME, + type WorkflowDraft, +} from "../state/draft"; + +const FABRO_BLURB = [ + "Fabro turns a Graphviz file into a runnable AI workflow. The shape of", + "each node picks the handler (agent / shell / human / branch / sub-", + "workflow). Edit the `.fabro/` directory, commit it to git, re-run forever.", +].join("\n"); + +export function renderReadme(draft: WorkflowDraft): string { + const runName = draft.name === DEFAULT_NAME ? FALLBACK_DOWNLOAD_NAME : draft.name; + const title = humanTitle(runName); + const goalLine = draft.goal.length > 0 ? `> ${draft.goal}\n` : ""; + + return [ + `# ${title}`, + "", + `${goalLine}Generated with the Fabro playground.`, + "", + "## Run it", + "", + "```bash", + `fabro run ${runName}`, + "```", + "", + "## What this is", + "", + FABRO_BLURB, + "", + "Learn more: https://fabro.sh", + "", + ].join("\n"); +} + +/** `release_notes` -> `Release notes`. Used only for the README heading. */ +function humanTitle(slug: string): string { + const words = slug.split(/[_-]+/).filter((p) => p.length > 0); + if (words.length === 0) return "Workflow"; + const [first, ...rest] = words; + return [first![0]!.toUpperCase() + first!.slice(1), ...rest].join(" "); +} diff --git a/apps/fabro-web/app/components/playground/files/render-toml.test.ts b/apps/fabro-web/app/components/playground/files/render-toml.test.ts new file mode 100644 index 000000000..22b143711 --- /dev/null +++ b/apps/fabro-web/app/components/playground/files/render-toml.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, test } from "bun:test"; + +import { createInitialDraft } from "../state/draft"; +import { renderProjectToml, renderWorkflowToml } from "./render-toml"; + +describe("renderWorkflowToml", () => { + test("points the workflow at workflow.fabro and pins sandbox to local", () => { + expect(renderWorkflowToml(createInitialDraft())).toBe( + [ + "_version = 1", + "", + "[workflow]", + 'graph = "workflow.fabro"', + "", + "[run.sandbox]", + 'provider = "local"', + "", + ].join("\n"), + ); + }); +}); + +describe("renderProjectToml", () => { + test("enables draft PRs by default", () => { + expect(renderProjectToml(createInitialDraft())).toBe( + [ + "_version = 1", + "", + "[run.pull_request]", + "enabled = true", + "draft = true", + "", + ].join("\n"), + ); + }); +}); diff --git a/apps/fabro-web/app/components/playground/files/render-toml.ts b/apps/fabro-web/app/components/playground/files/render-toml.ts new file mode 100644 index 000000000..7218f82e4 --- /dev/null +++ b/apps/fabro-web/app/components/playground/files/render-toml.ts @@ -0,0 +1,47 @@ +/** + * Render the two TOML companion files that ship alongside the `.fabro` + * graph: `workflow.toml` (per-workflow run config) and `project.toml` + * (project-wide defaults). + * + * Both files are largely static at the MVP stage — they reflect the + * playground's defaults rather than draft-derived configuration. + */ + +import type { WorkflowDraft } from "../state/draft"; + +/** + * The contents of `.fabro/workflows/<name>/workflow.toml`. + * + * Points the workflow at its `.fabro` graph and pins the sandbox provider to + * `local` so the downloaded artifact runs against the user's own machine + * without any further setup. + */ +export function renderWorkflowToml(_draft: WorkflowDraft): string { + return [ + "_version = 1", + "", + "[workflow]", + 'graph = "workflow.fabro"', + "", + "[run.sandbox]", + 'provider = "local"', + "", + ].join("\n"); +} + +/** + * The contents of `.fabro/project.toml`. + * + * Mirrors the defaults shown in the explainer: PRs enabled and draft, so + * a successful run opens a draft PR the user can review. + */ +export function renderProjectToml(_draft: WorkflowDraft): string { + return [ + "_version = 1", + "", + "[run.pull_request]", + "enabled = true", + "draft = true", + "", + ].join("\n"); +} diff --git a/apps/fabro-web/app/components/playground/playground.tsx b/apps/fabro-web/app/components/playground/playground.tsx new file mode 100644 index 000000000..98a6808c1 --- /dev/null +++ b/apps/fabro-web/app/components/playground/playground.tsx @@ -0,0 +1,178 @@ +import { useCallback, useMemo, useRef, useState } from "react"; +import { SparklesIcon } from "@heroicons/react/24/solid"; + +import PlaygroundCanvas from "./canvas/canvas"; +import { useSimulation } from "./canvas/use-simulation"; +import PlaygroundChatSidebar, { + SIDEBAR_WIDTH, +} from "./chat/sidebar"; +import { usePlaygroundDraft } from "./state/persist"; +import type { WorkflowDraft } from "./state/draft"; +import type { ToolCall } from "./state/reducer"; +import FileTabs from "./ui/file-tabs"; +import DownloadButton from "./ui/download-button"; +import NodeInspector from "./ui/node-inspector"; +import ResetButton from "./ui/reset-button"; +import RunForRealButton, { type RealRunRedirect } from "./ui/run-for-real-button"; +import RunTrace from "./ui/run-trace"; +import SimulationControls from "./ui/simulation-controls"; +import WorkflowHeader from "./ui/workflow-header"; + +export type PlaygroundAuthMode = "required" | "anonymous"; + +export type PlaygroundProps = { + /** + * URL the chat adapter posts each turn against. Externalised so the same + * component tree can re-embed against a public, rate-limited variant of + * the endpoint later. + */ + chatEndpoint: string; + /** + * `required` — assume the parent shell has already enforced authentication + * (current fabro-web routes do this via `AppShell`). + * `anonymous` — anonymous embed mode for non-authenticated contexts; not + * used by fabro-web today. + */ + authMode: PlaygroundAuthMode; + /** + * Override the "Run for real" button to redirect somewhere instead of + * opening the in-page modal that POSTs to `/api/v1/runs`. Set this in + * embed contexts where the visitor has no project to run against, to + * send them to a CTA URL such as `/download`. When unset, the button + * uses the default in-page launch flow. + */ + realRunRedirect?: RealRunRedirect; +}; + +/** + * The playground feature surface, deliberately framed as a standalone + * component tree so it can later be re-embedded as a self-contained React + * subtree in other contexts. It must not depend on `AppShell`, react-router + * context, or any of fabro-web's app-wide stores; any cross-cutting concern + * (chat endpoint, auth mode, theme) flows in through props. + * + * Layout mirrors `/ask-fabro`: workspace on the left, a docked chat + * column on the right that drives the canvas via streamed tool calls. + */ +export default function Playground({ + chatEndpoint, + authMode: _authMode, + realRunRedirect, +}: PlaygroundProps) { + const { draft, applyCall, reset } = usePlaygroundDraft(); + const [isChatOpen, setChatOpen] = useState(true); + const [sidebarWidth, setSidebarWidth] = useState(SIDEBAR_WIDTH); + const [selectedNodeId, setSelectedNodeId] = useState<string | null>(null); + const sim = useSimulation(draft); + + // The selected node still exists in the draft, right? After a write + // we may have deleted it — keep selection consistent. + const selectedNode = useMemo( + () => + selectedNodeId + ? draft.nodes.find((n) => n.id === selectedNodeId) ?? null + : null, + [draft.nodes, selectedNodeId], + ); + + const handleReset = useCallback(() => { + setSelectedNodeId(null); + reset(); + }, [reset]); + + // The chat adapter is memoized on (chatEndpoint, getWorkflow, dispatch, + // onParseFailure); we need every callback to be referentially stable + // across draft mutations so the adapter doesn't get rebuilt mid-turn. + // `draftRef` lets `getWorkflow` read the latest draft without becoming a + // dependency. + const draftRef = useRef<WorkflowDraft>(draft); + draftRef.current = draft; + const getWorkflow = useCallback(() => draftRef.current, []); + const dispatch = useCallback( + (call: ToolCall) => applyCall(call), + [applyCall], + ); + const onParseFailure = useCallback( + (info: { message: string; rawContent: string }) => { + // For now: log to the dev console. The chat itself already + // surfaces "1 tool call, 1 with an error" via the tool-call + // summary's `isError` flag, which is the user-visible signal + // that something went wrong. A future iteration could auto- + // submit a follow-up turn that nudges the model to re-emit + // valid DOT. + console.warn("[playground] failed to parse workflow file:", info.message); + }, + [], + ); + + return ( + <div className="relative isolate -mx-4 -my-6 flex h-[calc(100%+3rem)] sm:-mx-6 lg:-mx-8"> + <main className="flex h-full min-h-0 flex-1 flex-col gap-3 p-3"> + <header className="flex items-center gap-3 px-2"> + <WorkflowHeader draft={draft} /> + <div className="ml-auto flex items-center gap-2"> + <ResetButton onReset={handleReset} /> + <DownloadButton draft={draft} /> + <RunForRealButton draft={draft} redirect={realRunRedirect} /> + {!isChatOpen && ( + <button + type="button" + onClick={() => setChatOpen(true)} + className="inline-flex items-center gap-1.5 rounded-md bg-overlay px-2.5 py-1.5 text-sm font-medium text-fg-2 ring-1 ring-line-strong transition-colors hover:bg-overlay-strong hover:text-fg focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-teal-500" + > + <SparklesIcon className="size-4 text-teal-300" /> + Ask Fabro + </button> + )} + </div> + </header> + + <div className="grid min-h-0 flex-1 grid-rows-[3fr_2fr] gap-3"> + <div className="grid min-h-0 grid-cols-[1fr_320px] gap-3"> + <PlaygroundCanvas + draft={draft} + simulation={sim.state} + selectedNodeId={selectedNodeId} + onSelectNode={setSelectedNodeId} + /> + <aside className="flex h-full min-h-0 flex-col overflow-hidden rounded-md border border-line bg-panel-alt/40"> + {selectedNode ? ( + <NodeInspector + node={selectedNode} + draft={draft} + onClose={() => setSelectedNodeId(null)} + /> + ) : ( + <> + <div className="flex shrink-0 items-center justify-between border-b border-line px-3 py-2"> + <span className="font-mono text-[10.5px] uppercase tracking-wider text-fg-muted"> + Run trace + </span> + </div> + <div className="min-h-0 flex-1 overflow-auto"> + <RunTrace state={sim.state} /> + </div> + <div className="shrink-0 border-t border-line p-2"> + <SimulationControls sim={sim} /> + </div> + </> + )} + </aside> + </div> + <FileTabs draft={draft} /> + </div> + </main> + + <PlaygroundChatSidebar + isOpen={isChatOpen} + onClose={() => setChatOpen(false)} + chatEndpoint={chatEndpoint} + getWorkflow={getWorkflow} + dispatch={dispatch} + onParseFailure={onParseFailure} + width={sidebarWidth} + onWidthChange={setSidebarWidth} + /> + </div> + ); +} diff --git a/apps/fabro-web/app/components/playground/state/animate.test.ts b/apps/fabro-web/app/components/playground/state/animate.test.ts new file mode 100644 index 000000000..47fb6ef3d --- /dev/null +++ b/apps/fabro-web/app/components/playground/state/animate.test.ts @@ -0,0 +1,123 @@ +import { describe, expect, test } from "bun:test"; + +import { animateOps } from "./animate"; +import type { ToolCall } from "./reducer"; + +interface ScheduledTimer { + fire: () => void; + ms: number; +} + +function makeFakeTimers() { + const queue: ScheduledTimer[] = []; + const setTimeoutImpl = (handler: () => void, ms: number) => { + const timer: ScheduledTimer = { fire: handler, ms }; + queue.push(timer); + return timer; + }; + const clearTimeoutImpl = (handle: unknown) => { + const idx = queue.indexOf(handle as ScheduledTimer); + if (idx >= 0) queue.splice(idx, 1); + }; + const advance = () => { + const next = queue.shift(); + next?.fire(); + }; + return { setTimeoutImpl, clearTimeoutImpl, advance, queue }; +} + +const sampleOps: ToolCall[] = [ + { name: "set_workflow_meta", args: { name: "demo" } }, + { + name: "add_node", + args: { id: "plan", label: "Plan", shape: "box" }, + }, + { name: "connect", args: { from: "start", to: "plan" } }, +]; + +describe("animateOps", () => { + test("dispatches first op immediately and queues the rest", () => { + const timers = makeFakeTimers(); + const dispatched: ToolCall[] = []; + animateOps(sampleOps, { + dispatch: (c) => dispatched.push(c), + setTimeoutImpl: timers.setTimeoutImpl, + clearTimeoutImpl: timers.clearTimeoutImpl, + }); + expect(dispatched).toHaveLength(1); + expect(timers.queue).toHaveLength(1); + + timers.advance(); + expect(dispatched).toHaveLength(2); + timers.advance(); + expect(dispatched).toEqual(sampleOps); + }); + + test("onComplete fires after the last op", () => { + const timers = makeFakeTimers(); + let completed = false; + animateOps(sampleOps, { + dispatch: () => {}, + onComplete: () => { + completed = true; + }, + setTimeoutImpl: timers.setTimeoutImpl, + clearTimeoutImpl: timers.clearTimeoutImpl, + }); + expect(completed).toBe(false); + timers.advance(); + expect(completed).toBe(false); + timers.advance(); + expect(completed).toBe(true); + }); + + test("empty ops list completes synchronously", () => { + const timers = makeFakeTimers(); + let completed = false; + animateOps([], { + dispatch: () => {}, + onComplete: () => { + completed = true; + }, + setTimeoutImpl: timers.setTimeoutImpl, + clearTimeoutImpl: timers.clearTimeoutImpl, + }); + expect(completed).toBe(true); + expect(timers.queue).toHaveLength(0); + }); + + test("cancel stops further dispatch without applying remaining ops", () => { + const timers = makeFakeTimers(); + const dispatched: ToolCall[] = []; + const handle = animateOps(sampleOps, { + dispatch: (c) => dispatched.push(c), + setTimeoutImpl: timers.setTimeoutImpl, + clearTimeoutImpl: timers.clearTimeoutImpl, + }); + expect(dispatched).toHaveLength(1); + handle.cancel(); + expect(timers.queue).toHaveLength(0); + // Trying to advance a cleared timer is a no-op; queue is empty. + timers.advance(); + expect(dispatched).toHaveLength(1); + }); + + test("finish applies remaining ops immediately and fires onComplete", () => { + const timers = makeFakeTimers(); + const dispatched: ToolCall[] = []; + let completed = false; + const handle = animateOps(sampleOps, { + dispatch: (c) => dispatched.push(c), + onComplete: () => { + completed = true; + }, + setTimeoutImpl: timers.setTimeoutImpl, + clearTimeoutImpl: timers.clearTimeoutImpl, + }); + expect(dispatched).toHaveLength(1); + handle.finish(); + expect(dispatched).toEqual(sampleOps); + expect(completed).toBe(true); + expect(timers.queue).toHaveLength(0); + }); +}); diff --git a/apps/fabro-web/app/components/playground/state/animate.ts b/apps/fabro-web/app/components/playground/state/animate.ts new file mode 100644 index 000000000..a97d7b9f8 --- /dev/null +++ b/apps/fabro-web/app/components/playground/state/animate.ts @@ -0,0 +1,102 @@ +/** + * Scheduler that walks a `ToolCall[]` with a delay between each op, + * dispatching to the reducer so the canvas paints in node by node + * instead of replacing the whole graph at once. Used by the chat + * runtime after parsing the model's `write_workflow_file` content and + * diffing it against the current draft. + * + * The animation is purely visual — the resulting draft is identical to + * what we'd get by applying the ops in one shot. Skipping or + * cancelling animation is therefore safe: the user just sees the new + * state without the intermediate frames. + */ +import type { ToolCall } from "./reducer"; + +export interface AnimateOptions { + /** Apply one op. Implementation typically dispatches to the reducer. */ + dispatch: (call: ToolCall) => void; + /** Milliseconds between consecutive ops. Default 220ms. */ + stepDelayMs?: number; + /** Called once after the last op runs. */ + onComplete?: () => void; + /** Test seam: defaults to `globalThis.setTimeout`. */ + setTimeoutImpl?: (handler: () => void, ms: number) => unknown; + /** Test seam: defaults to `globalThis.clearTimeout`. */ + clearTimeoutImpl?: (handle: unknown) => void; +} + +export interface AnimationHandle { + /** Cancel the schedule. Ops already dispatched stay applied. */ + cancel: () => void; + /** + * Apply every remaining op immediately and clear the schedule. + * The reducer sees the same final state either way; this just skips + * the visual cadence. + */ + finish: () => void; +} + +const DEFAULT_STEP_DELAY_MS = 220; + +export function animateOps(ops: ToolCall[], options: AnimateOptions): AnimationHandle { + const stepMs = options.stepDelayMs ?? DEFAULT_STEP_DELAY_MS; + const setT = options.setTimeoutImpl ?? globalThis.setTimeout.bind(globalThis); + const clearT = options.clearTimeoutImpl ?? globalThis.clearTimeout.bind(globalThis); + + if (ops.length === 0) { + options.onComplete?.(); + return { cancel: noop, finish: noop }; + } + + let index = 0; + let pendingHandle: unknown = null; + let stopped = false; + + const tick = () => { + pendingHandle = null; + if (stopped) return; + const op = ops[index++]; + if (!op) { + stopped = true; + options.onComplete?.(); + return; + } + options.dispatch(op); + if (index >= ops.length) { + stopped = true; + options.onComplete?.(); + return; + } + pendingHandle = setT(tick, stepMs); + }; + + // First op fires immediately so users get a fast acknowledgement that + // something is happening; subsequent ops are paced. + tick(); + + return { + cancel: () => { + if (stopped) return; + stopped = true; + if (pendingHandle !== null) { + clearT(pendingHandle); + pendingHandle = null; + } + }, + finish: () => { + if (stopped) return; + if (pendingHandle !== null) { + clearT(pendingHandle); + pendingHandle = null; + } + while (index < ops.length) { + const op = ops[index++]; + if (op) options.dispatch(op); + } + stopped = true; + options.onComplete?.(); + }, + }; +} + +function noop() {} diff --git a/apps/fabro-web/app/components/playground/state/build-manifest.test.ts b/apps/fabro-web/app/components/playground/state/build-manifest.test.ts new file mode 100644 index 000000000..25410b994 --- /dev/null +++ b/apps/fabro-web/app/components/playground/state/build-manifest.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, test } from "bun:test"; + +import { createInitialDraft } from "./draft"; +import { buildRunManifest, resolveWorkflowName } from "./build-manifest"; + +describe("resolveWorkflowName", () => { + test("uses the draft name when set and valid", () => { + const draft = { ...createInitialDraft(), name: "release_notes" }; + expect(resolveWorkflowName(draft)).toBe("release_notes"); + }); + + test("falls back when the draft is still the default 'untitled'", () => { + expect(resolveWorkflowName(createInitialDraft())).toBe( + "playground_workflow", + ); + }); + + test("falls back for invalid names (snake_case rule)", () => { + const draft = { ...createInitialDraft(), name: "Bad-Name!" }; + expect(resolveWorkflowName(draft)).toBe("playground_workflow"); + }); +}); + +describe("buildRunManifest", () => { + test("welcome draft → minimal manifest with inline DOT + TOML", () => { + const manifest = buildRunManifest(createInitialDraft()); + expect(manifest.version).toBe(1); + expect(manifest.target.identifier).toBe("playground_workflow"); + expect(manifest.target.path).toBe( + ".fabro/workflows/playground_workflow/workflow.fabro", + ); + const workflow = + manifest.workflows[".fabro/workflows/playground_workflow/workflow.fabro"]; + expect(workflow).toBeDefined(); + expect(workflow!.source).toContain("digraph"); + expect(workflow!.source).toContain("start ->"); + expect(workflow!.config?.path).toBe("workflow.toml"); + expect(workflow!.config?.source).toContain("[run.sandbox]"); + }); + + test("named draft → title and identifier use the snake_case name", () => { + const draft = { + ...createInitialDraft(), + name: "release_notes", + goal: "Generate release notes.", + }; + const manifest = buildRunManifest(draft); + expect(manifest.target.identifier).toBe("release_notes"); + expect(manifest.target.path).toBe( + ".fabro/workflows/release_notes/workflow.fabro", + ); + expect(manifest.title).toBe("Generate release notes."); + expect(manifest.cwd).toBe("/tmp/fabro-playground"); + }); + + test("title falls back when goal is empty", () => { + const draft = { ...createInitialDraft(), name: "release_notes" }; + const manifest = buildRunManifest(draft); + expect(manifest.title).toBe("Playground: release_notes"); + }); +}); diff --git a/apps/fabro-web/app/components/playground/state/build-manifest.ts b/apps/fabro-web/app/components/playground/state/build-manifest.ts new file mode 100644 index 000000000..6da804913 --- /dev/null +++ b/apps/fabro-web/app/components/playground/state/build-manifest.ts @@ -0,0 +1,83 @@ +/** + * Build a `RunManifest` from the current `WorkflowDraft`. + * + * Inline-everything style: the manifest carries the full DOT and + * `workflow.toml` source in `workflows[key].{source, config}`, so the + * server doesn't need a temp dir or git commit. + */ +import { renderFabro } from "../files/render-fabro"; +import { renderWorkflowToml } from "../files/render-toml"; +import { + DEFAULT_NAME, + FALLBACK_DOWNLOAD_NAME, + isValidWorkflowName, + type WorkflowDraft, +} from "./draft"; + +/** + * `cwd` placeholder. Playground manifests carry no GitHub origin, so the + * sandbox provider creates an empty workspace inside the container and + * never touches this path on the host. We pin it to a fixed string so + * nothing the LLM emits influences a filesystem-looking field. + */ +const PLAYGROUND_CWD = "/tmp/fabro-playground"; + +/** + * Minimal subset of `RunManifest` the playground needs to send. The + * generated `RunManifest` type from `@qltysh/fabro-api-client` accepts + * the same shape; we keep this lightweight so the playground subtree + * doesn't pick up an extra dep. + */ +export interface PlaygroundRunManifest { + version: 1; + cwd: string; + title?: string; + target: { + identifier: string; + path: string; + }; + workflows: { + [path: string]: { + source: string; + config?: { + path: string; + source: string; + }; + }; + }; +} + +/** + * Resolve the workflow identifier used in the manifest paths. Mirrors + * the download-zip filename logic so a user who downloaded and a user + * who hit "Run for real" end up with the same artifact name. + */ +export function resolveWorkflowName(draft: WorkflowDraft): string { + if (draft.name && draft.name !== DEFAULT_NAME && isValidWorkflowName(draft.name)) { + return draft.name; + } + return FALLBACK_DOWNLOAD_NAME.replace(/-/g, "_"); +} + +export function buildRunManifest(draft: WorkflowDraft): PlaygroundRunManifest { + const name = resolveWorkflowName(draft); + const workflowPath = `.fabro/workflows/${name}/workflow.fabro`; + return { + version: 1, + cwd: PLAYGROUND_CWD, + title: draft.goal && draft.goal.length > 0 ? draft.goal : `Playground: ${name}`, + target: { + identifier: name, + path: workflowPath, + }, + workflows: { + [workflowPath]: { + source: renderFabro(draft), + config: { + path: "workflow.toml", + source: renderWorkflowToml(draft), + }, + }, + }, + }; +} diff --git a/apps/fabro-web/app/components/playground/state/diff.test.ts b/apps/fabro-web/app/components/playground/state/diff.test.ts new file mode 100644 index 000000000..0ec63da80 --- /dev/null +++ b/apps/fabro-web/app/components/playground/state/diff.test.ts @@ -0,0 +1,169 @@ +import { describe, expect, test } from "bun:test"; + +import { createInitialDraft, type WorkflowDraft } from "./draft"; +import { diffDrafts } from "./diff"; +import { applyToolCalls } from "./reducer"; + +function welcome(): WorkflowDraft { + return createInitialDraft(); +} + +function withPlan(): WorkflowDraft { + return { + name: "release_notes", + goal: "Generate release notes.", + nodes: [ + { id: "start", label: "Start", shape: "mdiamond" }, + { id: "exit", label: "Exit", shape: "msquare" }, + { id: "plan", label: "Plan", shape: "box", prompt: "Plan it." }, + ], + edges: [ + { from: "start", to: "plan" }, + { from: "plan", to: "exit" }, + ], + }; +} + +describe("diffDrafts", () => { + test("identical drafts produce no ops", () => { + expect(diffDrafts(welcome(), welcome())).toEqual([]); + }); + + test("welcome → with-plan emits meta, disconnect placeholder, add, connects", () => { + const ops = diffDrafts(welcome(), withPlan()); + const names = ops.map((o) => o.name); + // meta first, disconnect before delete/add, add before update/connect. + expect(names).toEqual([ + "set_workflow_meta", + "disconnect", // start -> exit placeholder is gone in next + "add_node", // plan + "connect", // start -> plan + "connect", // plan -> exit + ]); + const meta = ops[0]; + expect(meta).toMatchObject({ + name: "set_workflow_meta", + args: { name: "release_notes", goal: "Generate release notes." }, + }); + const addNode = ops[2]; + expect(addNode).toMatchObject({ + name: "add_node", + args: { id: "plan", label: "Plan", shape: "box", prompt: "Plan it." }, + }); + }); + + test("only goal changed → single set_workflow_meta with only goal", () => { + const a = withPlan(); + const b = { ...withPlan(), goal: "Different goal." }; + const ops = diffDrafts(a, b); + expect(ops).toEqual([ + { name: "set_workflow_meta", args: { goal: "Different goal." } }, + ]); + }); + + test("node deleted → disconnect any edges first, then delete_node", () => { + const a = withPlan(); + const b: WorkflowDraft = { + ...welcome(), + name: a.name, + goal: a.goal, + }; + const ops = diffDrafts(a, b); + // No meta change. Both edges (start→plan, plan→exit) gone; placeholder + // (start→exit) added; plan deleted. + expect(ops.map((o) => o.name)).toEqual([ + "disconnect", + "disconnect", + "delete_node", + "connect", + ]); + expect(ops[2]).toMatchObject({ name: "delete_node", args: { id: "plan" } }); + }); + + test("node updated emits update_node with only changed fields", () => { + const a = withPlan(); + const b = withPlan(); + const planIdx = b.nodes.findIndex((n) => n.id === "plan"); + b.nodes[planIdx] = { + ...b.nodes[planIdx]!, + label: "Planning", + prompt: "Plan it carefully.", + }; + const ops = diffDrafts(a, b); + expect(ops).toEqual([ + { + name: "update_node", + args: { id: "plan", label: "Planning", prompt: "Plan it carefully." }, + }, + ]); + }); + + test("edge attrs change → disconnect + reconnect", () => { + const a = withPlan(); + const b = withPlan(); + b.edges[0] = { ...b.edges[0]!, condition: "outcome=approved" }; + const ops = diffDrafts(a, b); + expect(ops).toEqual([ + { name: "disconnect", args: { from: "start", to: "plan" } }, + { + name: "connect", + args: { from: "start", to: "plan", condition: "outcome=approved" }, + }, + ]); + }); + + test("reserved nodes never get add/delete ops", () => { + const a = welcome(); + const b: WorkflowDraft = { + ...welcome(), + // Pretend `next` somehow omitted start/exit; we should still not emit + // delete_node for them. + nodes: [], + edges: [], + }; + const ops = diffDrafts(a, b); + const names = ops.map((o) => o.name); + expect(names).not.toContain("delete_node"); + }); + + test("diff ops replay cleanly on the reducer", () => { + const a = welcome(); + const b = withPlan(); + const ops = diffDrafts(a, b); + const replayed = applyToolCalls(a, ops); + expect(replayed.ok).toBe(true); + expect(replayed.draft.name).toBe(b.name); + expect(replayed.draft.goal).toBe(b.goal); + expect(replayed.draft.nodes).toEqual(b.nodes); + expect(replayed.draft.edges).toEqual(b.edges); + }); + + test("complex pipeline replays cleanly", () => { + const a = welcome(); + const b: WorkflowDraft = { + name: "ci", + goal: "Lint, test, then PR.", + nodes: [ + { id: "start", label: "Start", shape: "mdiamond" }, + { id: "exit", label: "Exit", shape: "msquare" }, + { id: "lint", label: "Lint", shape: "parallelogram", prompt: "lint" }, + { id: "test", label: "Test", shape: "parallelogram", prompt: "test" }, + { id: "gate", label: "Passed?", shape: "diamond" }, + { id: "pr", label: "Open PR", shape: "box" }, + ], + edges: [ + { from: "start", to: "lint" }, + { from: "lint", to: "test" }, + { from: "test", to: "gate" }, + { from: "gate", to: "pr", condition: "outcome=pass" }, + { from: "gate", to: "exit", condition: "outcome=fail" }, + { from: "pr", to: "exit" }, + ], + }; + const ops = diffDrafts(a, b); + const replayed = applyToolCalls(a, ops); + expect(replayed.ok).toBe(true); + expect(replayed.draft.nodes).toEqual(b.nodes); + expect(replayed.draft.edges).toEqual(b.edges); + }); +}); diff --git a/apps/fabro-web/app/components/playground/state/diff.ts b/apps/fabro-web/app/components/playground/state/diff.ts new file mode 100644 index 000000000..d17553648 --- /dev/null +++ b/apps/fabro-web/app/components/playground/state/diff.ts @@ -0,0 +1,171 @@ +/** + * Semantic diff between two `WorkflowDraft`s, producing the same + * `ToolCall` shapes the reducer already takes. The chat endpoint asks + * the model to emit a full new draft each turn; this function turns + * "old state vs new state" back into the granular ops that the + * reducer + canvas already know how to apply. + * + * The op order is chosen so that every intermediate draft state is + * valid for the reducer: + * 1. set_workflow_meta (if name or goal changed) + * 2. disconnect — removed or modified edges + * 3. delete_node — nodes no longer present (post-disconnect, so no + * dangling edge refs) + * 4. add_node — newly introduced nodes (before any new edges touch them) + * 5. update_node — nodes whose props changed (label/shape/prompt/attrs) + * 6. connect — added or modified edges (now that all endpoints exist) + * + * Reserved ids (`start`, `exit`) are never added/deleted; we trust that + * both drafts agree on them. + */ +import { EXIT_ID, START_ID, type Edge, type Node, type WorkflowDraft } from "./draft"; +import type { ToolCall } from "./reducer"; + +type AddNodeArgs = Extract<ToolCall, { name: "add_node" }>["args"]; +type UpdateNodeArgs = Extract<ToolCall, { name: "update_node" }>["args"]; +type ConnectArgs = Extract<ToolCall, { name: "connect" }>["args"]; + +export function diffDrafts(prev: WorkflowDraft, next: WorkflowDraft): ToolCall[] { + const ops: ToolCall[] = []; + + // 1. set_workflow_meta — only emit if at least one field changed. + if (prev.name !== next.name || prev.goal !== next.goal) { + const args: { name?: string; goal?: string } = {}; + if (prev.name !== next.name) args.name = next.name; + if (prev.goal !== next.goal) args.goal = next.goal; + ops.push({ name: "set_workflow_meta", args }); + } + + const prevNodesById = new Map(prev.nodes.map((n) => [n.id, n] as const)); + const nextNodesById = new Map(next.nodes.map((n) => [n.id, n] as const)); + + const edgeKey = (e: Edge) => `${e.from}->${e.to}`; + const prevEdgesByKey = new Map(prev.edges.map((e) => [edgeKey(e), e] as const)); + const nextEdgesByKey = new Map(next.edges.map((e) => [edgeKey(e), e] as const)); + + // 2. disconnect — edges that are removed OR whose attributes + // changed. Modified edges get disconnected here and re-emitted in + // step 6 with the new attrs. + for (const [key, edge] of prevEdgesByKey) { + const nextEdge = nextEdgesByKey.get(key); + if (!nextEdge || !edgesEqual(edge, nextEdge)) { + ops.push({ + name: "disconnect", + args: { from: edge.from, to: edge.to }, + }); + } + } + + // 3. delete_node — nodes in prev but not in next. Skip reserved ids + // (they're never deleted; if next somehow drops one, the reducer + // would refuse anyway). + for (const [id] of prevNodesById) { + if (id === START_ID || id === EXIT_ID) continue; + if (!nextNodesById.has(id)) { + ops.push({ name: "delete_node", args: { id } }); + } + } + + // 4. add_node — nodes in next but not in prev. Skip reserved ids. + for (const node of next.nodes) { + if (node.id === START_ID || node.id === EXIT_ID) continue; + if (!prevNodesById.has(node.id)) { + ops.push({ + name: "add_node", + args: addNodeArgs(node), + }); + } + } + + // 5. update_node — same id in both drafts but at least one property + // changed. Reserved ids are skipped to match the reducer's stance. + for (const node of next.nodes) { + if (node.id === START_ID || node.id === EXIT_ID) continue; + const prevNode = prevNodesById.get(node.id); + if (!prevNode) continue; // already handled as add + if (nodesEqual(prevNode, node)) continue; + ops.push({ + name: "update_node", + args: updateNodeArgs(prevNode, node), + }); + } + + // 6. connect — edges in next that are new or have updated attrs. + for (const [key, edge] of nextEdgesByKey) { + const prevEdge = prevEdgesByKey.get(key); + if (!prevEdge || !edgesEqual(prevEdge, edge)) { + ops.push({ + name: "connect", + args: connectArgs(edge), + }); + } + } + + return ops; +} + +function addNodeArgs(node: Node): AddNodeArgs { + const args: AddNodeArgs = { + id: node.id, + label: node.label, + shape: node.shape, + }; + if (node.prompt !== undefined) args.prompt = node.prompt; + if (node.attrs !== undefined) args.attrs = { ...node.attrs }; + return args; +} + +function updateNodeArgs(prev: Node, next: Node): UpdateNodeArgs { + const args: UpdateNodeArgs = { id: next.id }; + if (prev.label !== next.label) args.label = next.label; + if (prev.shape !== next.shape) args.shape = next.shape; + if (prev.prompt !== next.prompt) args.prompt = next.prompt; + if (!attrsEqual(prev.attrs, next.attrs)) { + args.attrs = next.attrs ? { ...next.attrs } : {}; + } + return args; +} + +function connectArgs(edge: Edge): ConnectArgs { + const args: ConnectArgs = { + from: edge.from, + to: edge.to, + }; + if (edge.condition !== undefined) args.condition = edge.condition; + if (edge.label !== undefined) args.label = edge.label; + if (edge.attrs !== undefined) args.attrs = { ...edge.attrs }; + return args; +} + +function nodesEqual(a: Node, b: Node): boolean { + return ( + a.label === b.label && + a.shape === b.shape && + a.prompt === b.prompt && + attrsEqual(a.attrs, b.attrs) + ); +} + +function edgesEqual(a: Edge, b: Edge): boolean { + return ( + a.from === b.from && + a.to === b.to && + a.condition === b.condition && + a.label === b.label && + attrsEqual(a.attrs, b.attrs) + ); +} + +function attrsEqual( + a: Record<string, unknown> | undefined, + b: Record<string, unknown> | undefined, +): boolean { + if (a === undefined && b === undefined) return true; + const aEntries = a ? Object.entries(a) : []; + const bEntries = b ? Object.entries(b) : []; + if (aEntries.length !== bEntries.length) return false; + for (const [k, v] of aEntries) { + if (b?.[k] !== v) return false; + } + return true; +} diff --git a/apps/fabro-web/app/components/playground/state/draft.test.ts b/apps/fabro-web/app/components/playground/state/draft.test.ts new file mode 100644 index 000000000..48e30300f --- /dev/null +++ b/apps/fabro-web/app/components/playground/state/draft.test.ts @@ -0,0 +1,96 @@ +import { describe, expect, test } from "bun:test"; + +import { + ALL_SHAPES, + EXIT_ID, + RESERVED_IDS, + START_ID, + createInitialDraft, + isValidNodeId, + isValidShape, + isValidWorkflowName, + isWelcomeState, +} from "./draft"; + +describe("createInitialDraft", () => { + test("welcome state has only start and exit", () => { + const draft = createInitialDraft(); + expect(draft.nodes).toHaveLength(2); + expect(draft.nodes.map((n) => n.id).sort()).toEqual([EXIT_ID, START_ID].sort()); + expect(draft.edges).toEqual([{ from: START_ID, to: EXIT_ID }]); + }); + + test("uses reserved shapes for terminals", () => { + const draft = createInitialDraft(); + const start = draft.nodes.find((n) => n.id === START_ID); + const exit = draft.nodes.find((n) => n.id === EXIT_ID); + expect(start?.shape).toBe("mdiamond"); + expect(exit?.shape).toBe("msquare"); + }); + + test("default name is 'untitled' and goal is empty", () => { + const draft = createInitialDraft(); + expect(draft.name).toBe("untitled"); + expect(draft.goal).toBe(""); + }); +}); + +describe("isWelcomeState", () => { + test("true on a fresh draft", () => { + expect(isWelcomeState(createInitialDraft())).toBe(true); + }); + + test("false once any user node is added", () => { + const draft = createInitialDraft(); + draft.nodes.push({ id: "plan", label: "Plan", shape: "box" }); + expect(isWelcomeState(draft)).toBe(false); + }); +}); + +describe("isValidNodeId", () => { + test.each([ + ["plan", true], + ["run_tests", true], + ["step_42", true], + ["a", true], + ["Plan", false], // uppercase + ["1step", false], // leading digit + ["_hidden", false], // leading underscore + ["run-tests", false], // hyphen + ["", false], + ])("`%s` -> %s", (input, expected) => { + expect(isValidNodeId(input)).toBe(expected); + }); +}); + +describe("isValidWorkflowName", () => { + test("snake_case ok", () => { + expect(isValidWorkflowName("release_notes")).toBe(true); + }); + + test("rejects uppercase", () => { + expect(isValidWorkflowName("ReleaseNotes")).toBe(false); + }); +}); + +describe("isValidShape", () => { + test("accepts every shape in ALL_SHAPES", () => { + for (const shape of ALL_SHAPES) { + expect(isValidShape(shape)).toBe(true); + } + }); + + test("rejects unknown shapes and non-strings", () => { + expect(isValidShape("circle")).toBe(false); + expect(isValidShape(42)).toBe(false); + expect(isValidShape(null)).toBe(false); + expect(isValidShape(undefined)).toBe(false); + }); +}); + +describe("RESERVED_IDS", () => { + test("covers start and exit", () => { + expect(RESERVED_IDS).toContain(START_ID); + expect(RESERVED_IDS).toContain(EXIT_ID); + }); +}); diff --git a/apps/fabro-web/app/components/playground/state/draft.ts b/apps/fabro-web/app/components/playground/state/draft.ts new file mode 100644 index 000000000..07c21592a --- /dev/null +++ b/apps/fabro-web/app/components/playground/state/draft.ts @@ -0,0 +1,114 @@ +/** + * Playground draft schema — the entire workflow the user is composing lives + * in this single object. The reducer in `./reducer` mutates it via tool + * calls; `./persist` writes it to `localStorage` so a refresh doesn't nuke + * the user's work. + * + * Kept deliberately small and self-contained so the playground component + * subtree can be re-embedded elsewhere as a standalone island without + * dragging fabro-web's app shell along. + */ + +/** All Graphviz shape names Fabro recognises. Each shape picks a handler. */ +export type Shape = + | "box" // default agent (multi-turn LLM with tools) + | "tab" // single LLM call + | "parallelogram" // shell script + | "hexagon" // human gate + | "diamond" // conditional branch + | "component" // fan-out parallel + | "tripleoctagon" // merge parallel + | "house" // sub-workflow + | "mdiamond" // start (terminal) + | "msquare"; // exit (terminal) + +export const ALL_SHAPES: readonly Shape[] = [ + "box", + "tab", + "parallelogram", + "hexagon", + "diamond", + "component", + "tripleoctagon", + "house", + "mdiamond", + "msquare", +] as const; + +/** Reserved node ids. The reducer refuses to add/delete/rename these. */ +export const START_ID = "start"; +export const EXIT_ID = "exit"; +export const RESERVED_IDS: readonly string[] = [START_ID, EXIT_ID]; + +/** Primitive types we accept inside Node/Edge `attrs` bags. */ +export type AttrValue = string | number | boolean; + +export type Node = { + /** Unique within the draft; snake_case. */ + id: string; + label: string; + shape: Shape; + /** Prose body for agent / tab / parallelogram nodes. */ + prompt?: string; + attrs?: Record<string, AttrValue>; +}; + +export type Edge = { + from: string; + to: string; + /** For diamond branches, e.g. `pass`, `fail`. */ + condition?: string; + label?: string; + attrs?: Record<string, AttrValue>; +}; + +export type WorkflowDraft = { + /** snake_case identifier used in `fabro run <name>` and the zip filename. */ + name: string; + goal: string; + nodes: Node[]; + edges: Edge[]; +}; + +/** Default workflow name until the model picks one via `set_workflow_meta`. */ +export const DEFAULT_NAME = "untitled"; + +/** Filename stem used when downloading from a draft with the default name. */ +export const FALLBACK_DOWNLOAD_NAME = "playground-workflow"; + +/** + * The welcome canvas — a `start → exit` skeleton with no user-added nodes + * between them. A ghost `???` placeholder is rendered in the canvas layer + * whenever a draft `isWelcomeState`. + */ +export function createInitialDraft(): WorkflowDraft { + return { + name: DEFAULT_NAME, + goal: "", + nodes: [ + { id: START_ID, label: "Start", shape: "mdiamond" }, + { id: EXIT_ID, label: "Exit", shape: "msquare" }, + ], + edges: [{ from: START_ID, to: EXIT_ID }], + }; +} + +/** A draft is in the welcome state iff there are no nodes other than start/exit. */ +export function isWelcomeState(draft: WorkflowDraft): boolean { + return draft.nodes.every((n) => RESERVED_IDS.includes(n.id)); +} + +/** Whether a node id is allowed for `add_node` / `update_node`. */ +export function isValidNodeId(id: string): boolean { + return /^[a-z][a-z0-9_]*$/.test(id); +} + +/** Whether a string is a valid workflow name (drives the zip filename). */ +export function isValidWorkflowName(name: string): boolean { + return /^[a-z][a-z0-9_]*$/.test(name); +} + +/** Whether a value is one of Fabro's recognised shapes. */ +export function isValidShape(value: unknown): value is Shape { + return typeof value === "string" && (ALL_SHAPES as readonly string[]).includes(value); +} diff --git a/apps/fabro-web/app/components/playground/state/parse-fabro.test.ts b/apps/fabro-web/app/components/playground/state/parse-fabro.test.ts new file mode 100644 index 000000000..1a16e878d --- /dev/null +++ b/apps/fabro-web/app/components/playground/state/parse-fabro.test.ts @@ -0,0 +1,205 @@ +import { describe, expect, test } from "bun:test"; + +import { renderFabro } from "../files/render-fabro"; +import { createInitialDraft, type WorkflowDraft } from "./draft"; +import { parseFabro } from "./parse-fabro"; + +function expectOk(result: ReturnType<typeof parseFabro>): WorkflowDraft { + if (!result.ok) throw new Error(`expected ok, got: ${result.error}`); + return result.draft; +} + +describe("parseFabro", () => { + test("welcome state", () => { + const draft = expectOk( + parseFabro(`digraph Workflow { + rankdir=LR + start [shape=Mdiamond, label="Start"] + exit [shape=Msquare, label="Exit"] + start -> exit + }`), + ); + expect(draft.name).toBe("workflow"); + expect(draft.nodes).toHaveLength(2); + expect(draft.nodes[0]).toMatchObject({ id: "start", shape: "mdiamond" }); + expect(draft.nodes[1]).toMatchObject({ id: "exit", shape: "msquare" }); + expect(draft.edges).toEqual([{ from: "start", to: "exit" }]); + }); + + test("captures graph goal attribute", () => { + const draft = expectOk( + parseFabro(`digraph ReleaseNotes { + graph [goal="Generate release notes from git log."] + start [shape=Mdiamond, label="Start"] + exit [shape=Msquare, label="Exit"] + start -> exit + }`), + ); + expect(draft.name).toBe("release_notes"); + expect(draft.goal).toBe("Generate release notes from git log."); + }); + + test("parses node with prompt and extra attrs", () => { + const draft = expectOk( + parseFabro(`digraph Run { + start [shape=Mdiamond, label="Start"] + exit [shape=Msquare, label="Exit"] + run_tests [shape=parallelogram, label="Run Tests", prompt="Execute the suite.", script="npm test", timeout=60] + start -> run_tests -> exit + }`), + ); + const tests = draft.nodes.find((n) => n.id === "run_tests"); + expect(tests).toBeDefined(); + expect(tests!.shape).toBe("parallelogram"); + expect(tests!.label).toBe("Run Tests"); + expect(tests!.prompt).toBe("Execute the suite."); + expect(tests!.attrs).toEqual({ script: "npm test", timeout: 60 }); + }); + + test("edge chain `a -> b -> c` produces multiple edges", () => { + const draft = expectOk( + parseFabro(`digraph Linear { + start [shape=Mdiamond, label="Start"] + exit [shape=Msquare, label="Exit"] + a [shape=box, label="A"] + b [shape=box, label="B"] + c [shape=box, label="C"] + start -> a -> b -> c -> exit + }`), + ); + const edges = draft.edges.map((e) => `${e.from}->${e.to}`); + expect(edges).toEqual(["start->a", "a->b", "b->c", "c->exit"]); + }); + + test("edge with condition and label attributes", () => { + const draft = expectOk( + parseFabro(`digraph Branch { + start [shape=Mdiamond, label="Start"] + exit [shape=Msquare, label="Exit"] + gate [shape=diamond, label="Gate"] + happy [shape=box, label="Happy"] + start -> gate + gate -> happy [condition="outcome=approved", label="approved"] + happy -> exit + }`), + ); + const edge = draft.edges.find((e) => e.from === "gate" && e.to === "happy"); + expect(edge).toBeDefined(); + expect(edge!.condition).toBe("outcome=approved"); + expect(edge!.label).toBe("approved"); + }); + + test("escapes inside strings", () => { + const draft = expectOk( + parseFabro(`digraph Esc { + start [shape=Mdiamond, label="Start"] + exit [shape=Msquare, label="Exit"] + plan [shape=box, label="Plan", prompt="He said \\"hi\\" and added a \\\\ slash."] + start -> plan -> exit + }`), + ); + expect(draft.nodes.find((n) => n.id === "plan")?.prompt).toBe( + 'He said "hi" and added a \\ slash.', + ); + }); + + test("comments and trailing semicolons are tolerated", () => { + const draft = expectOk( + parseFabro(`// top of file + digraph Cmt { + // inline comment + start [shape=Mdiamond, label="Start"]; + exit [shape=Msquare, label="Exit"]; + /* block + comment */ + start -> exit; + }`), + ); + expect(draft.nodes).toHaveLength(2); + }); + + test("ignores global node/edge defaults and rankdir", () => { + const draft = expectOk( + parseFabro(`digraph G { + rankdir=LR + node [shape=box] + edge [color=gray] + start [shape=Mdiamond, label="Start"] + exit [shape=Msquare, label="Exit"] + start -> exit + }`), + ); + expect(draft.edges).toEqual([{ from: "start", to: "exit" }]); + }); + + test("round-trips renderFabro output", () => { + const initial = createInitialDraft(); + initial.name = "release_notes"; + initial.goal = "Generate release notes."; + initial.nodes.push({ + id: "plan", + label: "Plan", + shape: "box", + prompt: "Plan it.", + }); + initial.nodes.push({ + id: "implement", + label: "Implement", + shape: "box", + }); + initial.edges = [ + { from: "start", to: "plan" }, + { from: "plan", to: "implement" }, + { from: "implement", to: "exit" }, + ]; + + const dot = renderFabro(initial); + const parsed = expectOk(parseFabro(dot)); + + expect(parsed.name).toBe(initial.name); + expect(parsed.goal).toBe(initial.goal); + expect(parsed.nodes).toEqual(initial.nodes); + expect(parsed.edges).toEqual(initial.edges); + }); + + test("missing shape on non-terminal node defaults to box", () => { + const draft = expectOk( + parseFabro(`digraph G { + start [shape=Mdiamond, label="Start"] + exit [shape=Msquare, label="Exit"] + plain [label="No Shape"] + start -> plain -> exit + }`), + ); + expect(draft.nodes.find((n) => n.id === "plain")?.shape).toBe("box"); + }); + + test("unknown shape falls back to box", () => { + const draft = expectOk( + parseFabro(`digraph G { + start [shape=Mdiamond, label="Start"] + exit [shape=Msquare, label="Exit"] + weird [shape=ellipse, label="Weird"] + start -> weird -> exit + }`), + ); + expect(draft.nodes.find((n) => n.id === "weird")?.shape).toBe("box"); + }); + + test("missing digraph header is a parse error", () => { + const result = parseFabro(`{ start -> exit }`); + expect(result.ok).toBe(false); + }); + + test("missing closing brace is a parse error", () => { + const result = parseFabro(`digraph G { start -> exit`); + expect(result.ok).toBe(false); + }); + + test("unterminated string is a parse error", () => { + const result = parseFabro( + `digraph G { plan [label="unterminated...] start -> plan }`, + ); + expect(result.ok).toBe(false); + }); +}); diff --git a/apps/fabro-web/app/components/playground/state/parse-fabro.ts b/apps/fabro-web/app/components/playground/state/parse-fabro.ts new file mode 100644 index 000000000..3478093dd --- /dev/null +++ b/apps/fabro-web/app/components/playground/state/parse-fabro.ts @@ -0,0 +1,405 @@ +/** + * Parse a constrained subset of Graphviz DOT into a `WorkflowDraft`. + * + * The playground's chat endpoint asks the model to emit the full + * `workflow.fabro` each turn via the `write_workflow_file` tool. This + * parser turns that DOT string back into the same draft schema the + * reducer operates on, so the new state can be diffed against the + * previous state and animated into the canvas. + * + * The grammar is intentionally limited to what `render-fabro.ts` emits: + * a single top-level `digraph` block, plain identifiers, quoted-string + * attribute values, `graph [goal=...]` for the workflow goal, simple + * `<id> [<attrs>]` node declarations, and `<from> -> <to>` edges with + * optional `[<attrs>]` lists. Edge chains (`a -> b -> c`) are + * supported because the model is encouraged to write them. + */ +import { + ALL_SHAPES, + DEFAULT_NAME, + type AttrValue, + type Edge, + type Node, + type Shape, + type WorkflowDraft, +} from "./draft"; + +export type ParseResult = + | { ok: true; draft: WorkflowDraft } + | { ok: false; error: string }; + +interface State { + src: string; + pos: number; +} + +const SHAPE_SET = new Set(ALL_SHAPES as readonly string[]); + +export function parseFabro(src: string): ParseResult { + const state: State = { src, pos: 0 }; + skipWs(state); + if (!tryConsume(state, "digraph")) { + return fail("expected `digraph` keyword at start of file", state); + } + + skipWs(state); + // The digraph name is optional but our renderer always writes one. + let digraphName: string | null = null; + if (state.src[state.pos] !== "{") { + digraphName = parseIdent(state) ?? parseString(state); + } + skipWs(state); + if (!consumeChar(state, "{")) { + return fail("expected `{` after digraph header", state); + } + + const draft: WorkflowDraft = { + name: digraphName ? toSnakeCase(digraphName) : DEFAULT_NAME, + goal: "", + nodes: [], + edges: [], + }; + const seenNodeIds = new Set<string>(); + + while (true) { + skipWs(state); + if (state.pos >= state.src.length) { + return fail("unexpected end of input before closing `}`", state); + } + if (consumeChar(state, "}")) { + // Trailing junk after `}` is tolerated — model may add prose + // after the closing brace and we don't care for parsing. + return { ok: true, draft }; + } + + // `graph [goal=...]` carries the workflow goal. + if (tryConsume(state, "graph") && isAttrOrEnd(state)) { + const attrs = parseAttrList(state); + if (attrs && typeof attrs.goal === "string") { + draft.goal = attrs.goal; + } + consumeStatementEnd(state); + continue; + } + // `node [...]` / `edge [...]` set defaults that we ignore. + // `rankdir=LR` and similar bare attribute assignments are layout + // hints — also ignored. + if ( + (tryConsume(state, "node") && isAttrOrEnd(state)) || + (tryConsume(state, "edge") && isAttrOrEnd(state)) + ) { + parseAttrList(state); + consumeStatementEnd(state); + continue; + } + if (tryConsumeBareAssignment(state)) { + continue; + } + + const id = parseIdent(state) ?? parseString(state); + if (!id) { + return fail(`unexpected token`, state); + } + skipWs(state); + + // Edge (possibly chained). + if (peek(state, "->")) { + let from = id; + while (peek(state, "->")) { + state.pos += 2; + skipWs(state); + const to = parseIdent(state) ?? parseString(state); + if (!to) { + return fail("expected target node after `->`", state); + } + const edge: Edge = { from, to }; + skipWs(state); + // The attribute list (if present) binds to the terminal edge + // in the chain. While there's another `->`, we have more + // edges to emit before any attrs apply. + if (!peek(state, "->") && state.src[state.pos] === "[") { + const attrs = parseAttrList(state) ?? {}; + applyEdgeAttrs(edge, attrs); + } + draft.edges.push(edge); + from = to; + skipWs(state); + } + consumeStatementEnd(state); + continue; + } + + // Node declaration. + if (seenNodeIds.has(id)) { + // Duplicate node decl — last write wins. Drop the previous. + const idx = draft.nodes.findIndex((n) => n.id === id); + if (idx >= 0) draft.nodes.splice(idx, 1); + } + let attrs: Record<string, AttrValue> = {}; + if (state.src[state.pos] === "[") { + attrs = parseAttrList(state) ?? {}; + } + const node = buildNode(id, attrs); + draft.nodes.push(node); + seenNodeIds.add(id); + consumeStatementEnd(state); + } +} + +function buildNode(id: string, attrs: Record<string, AttrValue>): Node { + const shape = coerceShape(attrs.shape, id); + const label = typeof attrs.label === "string" ? attrs.label : id; + const node: Node = { id, label, shape }; + if (typeof attrs.prompt === "string") node.prompt = attrs.prompt; + const rest = { ...attrs }; + delete rest.shape; + delete rest.label; + delete rest.prompt; + if (Object.keys(rest).length > 0) node.attrs = rest; + return node; +} + +function coerceShape(raw: AttrValue | undefined, nodeId: string): Shape { + if (typeof raw !== "string") { + // Shape omitted: default to start/exit terminals if id matches, + // otherwise `box` (Fabro's agent default). + if (nodeId === "start") return "mdiamond"; + if (nodeId === "exit") return "msquare"; + return "box"; + } + const lower = raw.toLowerCase(); + if (SHAPE_SET.has(lower)) return lower as Shape; + return "box"; +} + +function applyEdgeAttrs(edge: Edge, attrs: Record<string, AttrValue>): void { + if (typeof attrs.condition === "string") edge.condition = attrs.condition; + if (typeof attrs.label === "string") edge.label = attrs.label; + const rest = { ...attrs }; + delete rest.condition; + delete rest.label; + if (Object.keys(rest).length > 0) edge.attrs = rest; +} + +function skipWs(state: State): void { + while (state.pos < state.src.length) { + const c = state.src[state.pos]!; + if (c === " " || c === "\t" || c === "\n" || c === "\r") { + state.pos++; + continue; + } + if (c === "/" && state.src[state.pos + 1] === "/") { + while (state.pos < state.src.length && state.src[state.pos] !== "\n") { + state.pos++; + } + continue; + } + if (c === "/" && state.src[state.pos + 1] === "*") { + state.pos += 2; + while ( + state.pos + 1 < state.src.length && + !(state.src[state.pos] === "*" && state.src[state.pos + 1] === "/") + ) { + state.pos++; + } + state.pos += 2; + continue; + } + if (c === "#") { + // Some DOT writers use `#` for line comments. + while (state.pos < state.src.length && state.src[state.pos] !== "\n") { + state.pos++; + } + continue; + } + break; + } +} + +function parseIdent(state: State): string | null { + skipWs(state); + const start = state.pos; + // DOT identifiers: [a-zA-Z_€-￿][\w€-￿]* + const first = state.src[state.pos]; + if (!first || !/[a-zA-Z_]/.test(first)) return null; + state.pos++; + while ( + state.pos < state.src.length && + /[a-zA-Z0-9_]/.test(state.src[state.pos]!) + ) { + state.pos++; + } + return state.src.slice(start, state.pos); +} + +function parseString(state: State): string | null { + skipWs(state); + if (state.src[state.pos] !== '"') return null; + state.pos++; + let result = ""; + while (state.pos < state.src.length) { + const c = state.src[state.pos]!; + if (c === "\\") { + const next = state.src[state.pos + 1]; + if (next === '"') { + result += '"'; + state.pos += 2; + } else if (next === "\\") { + result += "\\"; + state.pos += 2; + } else if (next === "n") { + result += "\n"; + state.pos += 2; + } else if (next === "t") { + result += "\t"; + state.pos += 2; + } else if (next === "r") { + result += "\r"; + state.pos += 2; + } else { + // Unknown escape: pass through verbatim. + result += c; + state.pos += 1; + } + } else if (c === '"') { + state.pos++; + // DOT supports string concatenation with `+`. Splice if present. + const save = state.pos; + skipWs(state); + if (state.src[state.pos] === "+") { + state.pos++; + const more = parseString(state); + if (more === null) { + state.pos = save; + return result; + } + return result + more; + } + state.pos = save; + return result; + } else { + result += c; + state.pos++; + } + } + return null; +} + +function parseAttrValue(state: State): AttrValue | null { + skipWs(state); + if (state.src[state.pos] === '"') { + return parseString(state); + } + const start = state.pos; + while (state.pos < state.src.length && /[a-zA-Z0-9_\-.]/.test(state.src[state.pos]!)) { + state.pos++; + } + if (state.pos === start) return null; + const raw = state.src.slice(start, state.pos); + if (/^-?\d+$/.test(raw)) return Number.parseInt(raw, 10); + if (/^-?\d+\.\d+$/.test(raw)) return Number.parseFloat(raw); + if (raw === "true") return true; + if (raw === "false") return false; + return raw; +} + +function parseAttrList(state: State): Record<string, AttrValue> | null { + skipWs(state); + if (state.src[state.pos] !== "[") return null; + state.pos++; + const out: Record<string, AttrValue> = {}; + while (true) { + skipWs(state); + if (state.pos >= state.src.length) return null; + if (state.src[state.pos] === "]") { + state.pos++; + return out; + } + const key = parseIdent(state); + if (!key) return null; + skipWs(state); + if (state.src[state.pos] !== "=") return null; + state.pos++; + const value = parseAttrValue(state); + if (value === null) return null; + out[key] = value; + skipWs(state); + if (state.src[state.pos] === "," || state.src[state.pos] === ";") { + state.pos++; + } + } +} + +function tryConsume(state: State, word: string): boolean { + skipWs(state); + if (state.src.slice(state.pos, state.pos + word.length) !== word) return false; + // Word-boundary check so `nodename` doesn't match `node`. + const after = state.src[state.pos + word.length]; + if (after && /[a-zA-Z0-9_]/.test(after)) return false; + state.pos += word.length; + return true; +} + +function consumeChar(state: State, ch: string): boolean { + skipWs(state); + if (state.src[state.pos] !== ch) return false; + state.pos++; + return true; +} + +function consumeStatementEnd(state: State): void { + skipWs(state); + if (state.src[state.pos] === ";") state.pos++; +} + +function peek(state: State, str: string): boolean { + skipWs(state); + return state.src.slice(state.pos, state.pos + str.length) === str; +} + +function isAttrOrEnd(state: State): boolean { + skipWs(state); + const c = state.src[state.pos]; + return c === "[" || c === ";" || c === "}" || c === undefined; +} + +/** + * Handle bare `rankdir=LR` style assignments at digraph scope. Returns + * true if one was consumed. + */ +function tryConsumeBareAssignment(state: State): boolean { + const save = state.pos; + skipWs(state); + const ident = parseIdent(state); + if (!ident) { + state.pos = save; + return false; + } + skipWs(state); + if (state.src[state.pos] !== "=") { + state.pos = save; + return false; + } + state.pos++; + parseAttrValue(state); + consumeStatementEnd(state); + return true; +} + +function fail(message: string, state: State): ParseResult { + const lines = state.src.slice(0, state.pos).split("\n"); + const line = lines.length; + const col = lines[lines.length - 1]!.length + 1; + return { ok: false, error: `${message} (line ${line}, col ${col})` }; +} + +/** + * Convert PascalCase or camelCase back to snake_case so `digraph + * ReleaseNotes` round-trips with `release_notes`. + */ +function toSnakeCase(name: string): string { + return name + .replace(/([a-z0-9])([A-Z])/g, "$1_$2") + .replace(/([A-Z]+)([A-Z][a-z])/g, "$1_$2") + .toLowerCase(); +} diff --git a/apps/fabro-web/app/components/playground/state/persist.test.tsx b/apps/fabro-web/app/components/playground/state/persist.test.tsx new file mode 100644 index 000000000..eae5f5c37 --- /dev/null +++ b/apps/fabro-web/app/components/playground/state/persist.test.tsx @@ -0,0 +1,151 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { act } from "react-test-renderer"; + +import { renderHook, setupReactTestEnv } from "../../../lib/test-utils"; +import { STORAGE_KEY, usePlaygroundDraft } from "./persist"; +import { createInitialDraft } from "./draft"; + +type LocalStorageLike = { + getItem(key: string): string | null; + setItem(key: string, value: string): void; + removeItem(key: string): void; + clear(): void; +}; + +function installFakeStorage(): { storage: LocalStorageLike; restore: () => void } { + const map = new Map<string, string>(); + const fake: LocalStorageLike = { + getItem: (key) => map.get(key) ?? null, + setItem: (key, value) => { + map.set(key, value); + }, + removeItem: (key) => { + map.delete(key); + }, + clear: () => { + map.clear(); + }, + }; + const original = (globalThis as { window?: { localStorage?: LocalStorageLike } }) + .window; + (globalThis as { window: { localStorage: LocalStorageLike } }).window = { + localStorage: fake, + }; + return { + storage: fake, + restore: () => { + if (original === undefined) { + delete (globalThis as { window?: unknown }).window; + } else { + (globalThis as { window: unknown }).window = original; + } + }, + }; +} + +describe("usePlaygroundDraft", () => { + let teardownReact: () => void = () => {}; + let restoreStorage: () => void = () => {}; + let storage: LocalStorageLike; + + beforeEach(() => { + teardownReact = setupReactTestEnv(); + const installed = installFakeStorage(); + storage = installed.storage; + restoreStorage = installed.restore; + }); + + afterEach(() => { + restoreStorage(); + teardownReact(); + }); + + function wrapper({ children }: { children: React.ReactNode }) { + return <>{children}</>; + } + + test("starts in welcome state when storage is empty", () => { + const { result } = renderHook(() => usePlaygroundDraft(), { wrapper }); + expect(result.current.draft).toEqual(createInitialDraft()); + }); + + test("applyCall mutates draft and writes to localStorage", () => { + const { result } = renderHook(() => usePlaygroundDraft(), { wrapper }); + act(() => { + result.current.applyCall({ + name: "add_node", + args: { id: "plan", label: "Plan", shape: "box" }, + }); + }); + expect(result.current.draft.nodes.some((n) => n.id === "plan")).toBe(true); + + const stored = storage.getItem(STORAGE_KEY); + expect(stored).not.toBeNull(); + const parsed = JSON.parse(stored!); + expect(parsed.nodes.some((n: { id: string }) => n.id === "plan")).toBe(true); + }); + + test("invalid tool calls are silently dropped (state stays the same)", () => { + const { result } = renderHook(() => usePlaygroundDraft(), { wrapper }); + const before = result.current.draft; + act(() => { + result.current.applyCall({ + name: "add_node", + args: { id: "start", label: "Start again", shape: "box" }, + }); + }); + expect(result.current.draft).toBe(before); // same reference, no mutation + }); + + test("reset returns to welcome state (and persists it)", () => { + const { result } = renderHook(() => usePlaygroundDraft(), { wrapper }); + act(() => { + result.current.applyCall({ + name: "add_node", + args: { id: "plan", label: "Plan", shape: "box" }, + }); + }); + expect( + JSON.parse(storage.getItem(STORAGE_KEY)!).nodes.some( + (n: { id: string }) => n.id === "plan", + ), + ).toBe(true); + + act(() => { + result.current.reset(); + }); + expect(result.current.draft).toEqual(createInitialDraft()); + // The persist effect re-writes the welcome state on the next render — + // semantically equivalent to an empty slot since `loadInitial` would + // produce the same draft for either. + const persisted = JSON.parse(storage.getItem(STORAGE_KEY)!); + expect(persisted).toEqual(createInitialDraft()); + }); + + test("hydrates from existing localStorage on mount", () => { + const stashed = { + name: "release_notes", + goal: "Generate notes", + nodes: [ + { id: "start", label: "Start", shape: "mdiamond" }, + { id: "exit", label: "Exit", shape: "msquare" }, + { id: "plan", label: "Plan", shape: "box" }, + ], + edges: [ + { from: "start", to: "plan" }, + { from: "plan", to: "exit" }, + ], + }; + storage.setItem(STORAGE_KEY, JSON.stringify(stashed)); + + const { result } = renderHook(() => usePlaygroundDraft(), { wrapper }); + expect(result.current.draft.name).toBe("release_notes"); + expect(result.current.draft.nodes.some((n) => n.id === "plan")).toBe(true); + }); + + test("falls back to welcome state on corrupt localStorage", () => { + storage.setItem(STORAGE_KEY, "{not valid json"); + const { result } = renderHook(() => usePlaygroundDraft(), { wrapper }); + expect(result.current.draft).toEqual(createInitialDraft()); + }); +}); diff --git a/apps/fabro-web/app/components/playground/state/persist.ts b/apps/fabro-web/app/components/playground/state/persist.ts new file mode 100644 index 000000000..e36810d95 --- /dev/null +++ b/apps/fabro-web/app/components/playground/state/persist.ts @@ -0,0 +1,112 @@ +/** + * React hook layer over the playground reducer plus localStorage persistence. + * + * The playground deliberately keeps state in the browser: the server is + * stateless across chat turns (each turn POSTs the full draft and gets back + * text + tool calls). That makes the same component tree trivially + * re-embeddable in other contexts later, and means a refresh just re- + * hydrates from `localStorage` rather than hitting any API. + */ + +import { useCallback, useEffect, useReducer } from "react"; + +import { applyToolCall, type ToolCall } from "./reducer"; +import { createInitialDraft, type WorkflowDraft } from "./draft"; + +/** `localStorage` key. Versioned so we can bump on a breaking schema change. */ +export const STORAGE_KEY = "fabro:playground:draft:v1"; + +type Action = + | { type: "tool_call"; call: ToolCall } + | { type: "reset" } + | { type: "hydrate"; draft: WorkflowDraft }; + +export type PlaygroundDraftHandle = { + draft: WorkflowDraft; + /** Apply a single tool call. Invalid calls are silently dropped here; the + * caller already had a chance to surface the error from `applyToolCall`. */ + applyCall: (call: ToolCall) => void; + /** Wipe the draft back to the welcome state and clear localStorage. */ + reset: () => void; +}; + +function reducer(state: WorkflowDraft, action: Action): WorkflowDraft { + switch (action.type) { + case "tool_call": { + const result = applyToolCall(state, action.call); + // Silent on failure: validation errors are surfaced via the chat ack + // pane upstream, not the reducer. + return result.ok ? result.draft : state; + } + case "reset": + return createInitialDraft(); + case "hydrate": + return action.draft; + } +} + +/** + * Lazy initial state: read once from localStorage on mount, fall through to + * a fresh welcome draft if storage is empty or corrupt. + */ +function loadInitial(): WorkflowDraft { + if (typeof window === "undefined") return createInitialDraft(); + try { + const raw = window.localStorage.getItem(STORAGE_KEY); + if (!raw) return createInitialDraft(); + const parsed = JSON.parse(raw) as WorkflowDraft; + if ( + typeof parsed === "object" && + parsed !== null && + Array.isArray(parsed.nodes) && + Array.isArray(parsed.edges) && + typeof parsed.name === "string" && + typeof parsed.goal === "string" + ) { + return parsed; + } + return createInitialDraft(); + } catch { + // Corrupt JSON, blocked storage, anything else — fall back to fresh. + return createInitialDraft(); + } +} + +/** + * Drives the playground draft. State is owned by a `useReducer` so the chat + * adapter can `applyCall(...)` for each tool call streamed in over SSE, and + * the canvas just re-renders. + * + * Returns a stable handle whose methods are referentially stable across + * renders. + */ +export function usePlaygroundDraft(): PlaygroundDraftHandle { + const [draft, dispatch] = useReducer(reducer, undefined, loadInitial); + + useEffect(() => { + if (typeof window === "undefined") return; + try { + window.localStorage.setItem(STORAGE_KEY, JSON.stringify(draft)); + } catch { + // Storage quota / privacy mode — non-fatal; the user just loses + // refresh persistence for this session. + } + }, [draft]); + + const applyCall = useCallback((call: ToolCall) => { + dispatch({ type: "tool_call", call }); + }, []); + + const reset = useCallback(() => { + if (typeof window !== "undefined") { + try { + window.localStorage.removeItem(STORAGE_KEY); + } catch { + // see above + } + } + dispatch({ type: "reset" }); + }, []); + + return { draft, applyCall, reset }; +} diff --git a/apps/fabro-web/app/components/playground/state/reducer.test.ts b/apps/fabro-web/app/components/playground/state/reducer.test.ts new file mode 100644 index 000000000..236236db6 --- /dev/null +++ b/apps/fabro-web/app/components/playground/state/reducer.test.ts @@ -0,0 +1,323 @@ +import { describe, expect, test } from "bun:test"; + +import { createInitialDraft, type WorkflowDraft } from "./draft"; +import { applyToolCall, applyToolCalls, type ToolCall } from "./reducer"; + +function withPlanAndExit(): WorkflowDraft { + return applyToolCalls(createInitialDraft(), [ + { + name: "add_node", + args: { id: "plan", label: "Plan", shape: "box", prompt: "Plan it." }, + }, + { name: "connect", args: { from: "start", to: "plan" } }, + { name: "connect", args: { from: "plan", to: "exit" } }, + { name: "disconnect", args: { from: "start", to: "exit" } }, + ]).draft; +} + +describe("set_workflow_meta", () => { + test("sets name and goal", () => { + const result = applyToolCall(createInitialDraft(), { + name: "set_workflow_meta", + args: { name: "release_notes", goal: "Generate release notes." }, + }); + expect(result.ok).toBe(true); + expect(result.draft.name).toBe("release_notes"); + expect(result.draft.goal).toBe("Generate release notes."); + }); + + test("setting only goal leaves name alone", () => { + const result = applyToolCall(createInitialDraft(), { + name: "set_workflow_meta", + args: { goal: "Do the thing." }, + }); + expect(result.ok).toBe(true); + expect(result.draft.name).toBe("untitled"); + expect(result.draft.goal).toBe("Do the thing."); + }); + + test("rejects bad workflow name", () => { + const result = applyToolCall(createInitialDraft(), { + name: "set_workflow_meta", + args: { name: "Release Notes" }, + }); + expect(result.ok).toBe(false); + expect(result.error).toContain("snake_case"); + expect(result.draft.name).toBe("untitled"); // unchanged + }); +}); + +describe("add_node", () => { + test("adds a node with all optional fields", () => { + const result = applyToolCall(createInitialDraft(), { + name: "add_node", + args: { + id: "plan", + label: "Plan", + shape: "box", + prompt: "Plan the work.", + attrs: { max_visits: 3 }, + }, + }); + expect(result.ok).toBe(true); + const plan = result.draft.nodes.find((n) => n.id === "plan"); + expect(plan).toEqual({ + id: "plan", + label: "Plan", + shape: "box", + prompt: "Plan the work.", + attrs: { max_visits: 3 }, + }); + }); + + test("rejects reserved id", () => { + const result = applyToolCall(createInitialDraft(), { + name: "add_node", + args: { id: "start", label: "Start again", shape: "box" }, + }); + expect(result.ok).toBe(false); + expect(result.error).toContain("reserved"); + }); + + test("rejects duplicate id", () => { + const seeded = applyToolCall(createInitialDraft(), { + name: "add_node", + args: { id: "plan", label: "Plan", shape: "box" }, + }).draft; + const result = applyToolCall(seeded, { + name: "add_node", + args: { id: "plan", label: "Plan again", shape: "tab" }, + }); + expect(result.ok).toBe(false); + expect(result.error).toContain("already exists"); + }); + + test("rejects invalid id format", () => { + const result = applyToolCall(createInitialDraft(), { + name: "add_node", + args: { id: "PlanIt", label: "Plan", shape: "box" }, + }); + expect(result.ok).toBe(false); + expect(result.error).toContain("snake_case"); + }); + + test("rejects unknown shape", () => { + const result = applyToolCall(createInitialDraft(), { + name: "add_node", + // @ts-expect-error — testing runtime validation of a bad shape + args: { id: "plan", label: "Plan", shape: "circle" }, + }); + expect(result.ok).toBe(false); + expect(result.error).toContain("shape"); + }); + + test("rejects terminal-only shapes (mdiamond, msquare)", () => { + const a = applyToolCall(createInitialDraft(), { + name: "add_node", + args: { id: "alt_start", label: "Alt", shape: "mdiamond" }, + }); + expect(a.ok).toBe(false); + const b = applyToolCall(createInitialDraft(), { + name: "add_node", + args: { id: "alt_exit", label: "Alt", shape: "msquare" }, + }); + expect(b.ok).toBe(false); + }); +}); + +describe("update_node", () => { + test("updates label, shape, prompt", () => { + const seeded = applyToolCall(createInitialDraft(), { + name: "add_node", + args: { id: "plan", label: "Plan", shape: "box" }, + }).draft; + const result = applyToolCall(seeded, { + name: "update_node", + args: { id: "plan", label: "Plan v2", shape: "tab", prompt: "New prompt" }, + }); + expect(result.ok).toBe(true); + const plan = result.draft.nodes.find((n) => n.id === "plan"); + expect(plan?.label).toBe("Plan v2"); + expect(plan?.shape).toBe("tab"); + expect(plan?.prompt).toBe("New prompt"); + }); + + test("rejects nonexistent id", () => { + const result = applyToolCall(createInitialDraft(), { + name: "update_node", + args: { id: "ghost", label: "x" }, + }); + expect(result.ok).toBe(false); + expect(result.error).toContain("does not exist"); + }); + + test("rejects modifying reserved nodes", () => { + const result = applyToolCall(createInitialDraft(), { + name: "update_node", + args: { id: "start", label: "Not start" }, + }); + expect(result.ok).toBe(false); + expect(result.error).toContain("reserved"); + }); +}); + +describe("delete_node", () => { + test("removes the node and any incident edges", () => { + const seeded = withPlanAndExit(); + const result = applyToolCall(seeded, { + name: "delete_node", + args: { id: "plan" }, + }); + expect(result.ok).toBe(true); + expect(result.draft.nodes.find((n) => n.id === "plan")).toBeUndefined(); + expect( + result.draft.edges.some((e) => e.from === "plan" || e.to === "plan"), + ).toBe(false); + }); + + test("rejects reserved id", () => { + const result = applyToolCall(createInitialDraft(), { + name: "delete_node", + args: { id: "exit" }, + }); + expect(result.ok).toBe(false); + expect(result.error).toContain("reserved"); + }); + + test("rejects nonexistent id", () => { + const result = applyToolCall(createInitialDraft(), { + name: "delete_node", + args: { id: "ghost" }, + }); + expect(result.ok).toBe(false); + expect(result.error).toContain("does not exist"); + }); +}); + +describe("connect", () => { + test("adds an edge between two existing nodes", () => { + const seeded = applyToolCall(createInitialDraft(), { + name: "add_node", + args: { id: "plan", label: "Plan", shape: "box" }, + }).draft; + const result = applyToolCall(seeded, { + name: "connect", + args: { from: "plan", to: "exit", condition: "ok", label: "done" }, + }); + expect(result.ok).toBe(true); + const edge = result.draft.edges.find( + (e) => e.from === "plan" && e.to === "exit", + ); + expect(edge).toEqual({ + from: "plan", + to: "exit", + condition: "ok", + label: "done", + }); + }); + + test("rejects when either endpoint is missing", () => { + const a = applyToolCall(createInitialDraft(), { + name: "connect", + args: { from: "ghost", to: "exit" }, + }); + expect(a.ok).toBe(false); + + const b = applyToolCall(createInitialDraft(), { + name: "connect", + args: { from: "start", to: "ghost" }, + }); + expect(b.ok).toBe(false); + }); + + test("rejects self-loop", () => { + const result = applyToolCall(createInitialDraft(), { + name: "connect", + args: { from: "start", to: "start" }, + }); + expect(result.ok).toBe(false); + }); + + test("rejects outgoing edges from exit", () => { + const result = applyToolCall(createInitialDraft(), { + name: "connect", + args: { from: "exit", to: "start" }, + }); + expect(result.ok).toBe(false); + expect(result.error).toContain("exit"); + }); + + test("rejects incoming edges to start", () => { + const seeded = applyToolCall(createInitialDraft(), { + name: "add_node", + args: { id: "plan", label: "Plan", shape: "box" }, + }).draft; + const result = applyToolCall(seeded, { + name: "connect", + args: { from: "plan", to: "start" }, + }); + expect(result.ok).toBe(false); + expect(result.error).toContain("start"); + }); + + test("rejects duplicate edges", () => { + const seeded = applyToolCall(createInitialDraft(), { + name: "add_node", + args: { id: "plan", label: "Plan", shape: "box" }, + }).draft; + const seeded2 = applyToolCall(seeded, { + name: "connect", + args: { from: "plan", to: "exit" }, + }).draft; + const result = applyToolCall(seeded2, { + name: "connect", + args: { from: "plan", to: "exit" }, + }); + expect(result.ok).toBe(false); + expect(result.error).toContain("already exists"); + }); +}); + +describe("disconnect", () => { + test("removes the edge", () => { + const result = applyToolCall(createInitialDraft(), { + name: "disconnect", + args: { from: "start", to: "exit" }, + }); + expect(result.ok).toBe(true); + expect(result.draft.edges).toHaveLength(0); + }); + + test("rejects nonexistent edges", () => { + const result = applyToolCall(createInitialDraft(), { + name: "disconnect", + args: { from: "start", to: "ghost" }, + }); + expect(result.ok).toBe(false); + }); +}); + +describe("applyToolCalls (batch)", () => { + test("applies a sequence end to end", () => { + const draft = withPlanAndExit(); + expect(draft.nodes.map((n) => n.id)).toEqual(["start", "exit", "plan"]); + expect(draft.edges).toEqual([ + { from: "start", to: "plan" }, + { from: "plan", to: "exit" }, + ]); + }); + + test("short-circuits and reports the first failing call", () => { + const calls: ToolCall[] = [ + { name: "add_node", args: { id: "plan", label: "Plan", shape: "box" } }, + { name: "connect", args: { from: "plan", to: "ghost" } }, // fails + { name: "add_node", args: { id: "after", label: "After", shape: "tab" } }, // skipped + ]; + const result = applyToolCalls(createInitialDraft(), calls); + expect(result.ok).toBe(false); + expect(result.error).toContain("ghost"); + // The "plan" mutation from the first successful call is preserved in + // the returned draft; the caller can decide whether to commit it. + expect(result.draft.nodes.some((n) => n.id === "after")).toBe(false); + }); +}); diff --git a/apps/fabro-web/app/components/playground/state/reducer.ts b/apps/fabro-web/app/components/playground/state/reducer.ts new file mode 100644 index 000000000..79c3a59c3 --- /dev/null +++ b/apps/fabro-web/app/components/playground/state/reducer.ts @@ -0,0 +1,301 @@ +/** + * Pure reducer for playground tool calls. + * + * The model emits a stream of tool calls (see the OpenAPI-defined playground + * chat endpoint); each call is applied to the current `WorkflowDraft` via + * `applyToolCall`. The reducer never throws and never partially mutates: on + * validation failure it returns the original draft unchanged along with a + * single-line error string that the chat surface can render as a soft + * apology before the model retries. + * + * The tool-call shapes here are the wire format: the same JSON the server + * streams over SSE, the same JSON the model's tool definitions describe. + */ + +import { + EXIT_ID, + RESERVED_IDS, + START_ID, + isValidNodeId, + isValidShape, + isValidWorkflowName, + type AttrValue, + type Edge, + type Node, + type Shape, + type WorkflowDraft, +} from "./draft"; + +export type ToolCall = + | { + name: "set_workflow_meta"; + args: { name?: string; goal?: string }; + } + | { + name: "add_node"; + args: { + id: string; + label: string; + shape: Shape; + prompt?: string; + attrs?: Record<string, AttrValue>; + }; + } + | { + name: "update_node"; + args: { + id: string; + label?: string; + shape?: Shape; + prompt?: string; + attrs?: Record<string, AttrValue>; + }; + } + | { + name: "delete_node"; + args: { id: string }; + } + | { + name: "connect"; + args: { + from: string; + to: string; + condition?: string; + label?: string; + attrs?: Record<string, AttrValue>; + }; + } + | { + name: "disconnect"; + args: { from: string; to: string }; + }; + +export type ToolCallName = ToolCall["name"]; + +export type ApplyResult = { + /** The draft after applying the call. Same reference as input on failure. */ + draft: WorkflowDraft; + ok: boolean; + /** Single-line, human-readable on validation failure. Omitted on success. */ + error?: string; +}; + +/** Apply a single tool call to a draft. Pure; never throws. */ +export function applyToolCall(draft: WorkflowDraft, call: ToolCall): ApplyResult { + switch (call.name) { + case "set_workflow_meta": + return applySetMeta(draft, call.args); + case "add_node": + return applyAddNode(draft, call.args); + case "update_node": + return applyUpdateNode(draft, call.args); + case "delete_node": + return applyDeleteNode(draft, call.args); + case "connect": + return applyConnect(draft, call.args); + case "disconnect": + return applyDisconnect(draft, call.args); + } +} + +/** Apply a batch of calls in order, short-circuiting on the first failure. */ +export function applyToolCalls( + draft: WorkflowDraft, + calls: ToolCall[], +): ApplyResult { + let current = draft; + for (const call of calls) { + const result = applyToolCall(current, call); + if (!result.ok) return result; + current = result.draft; + } + return { draft: current, ok: true }; +} + +function ok(draft: WorkflowDraft): ApplyResult { + return { draft, ok: true }; +} + +function fail(draft: WorkflowDraft, error: string): ApplyResult { + return { draft, ok: false, error }; +} + +function findNode(draft: WorkflowDraft, id: string): Node | undefined { + return draft.nodes.find((n) => n.id === id); +} + +function applySetMeta( + draft: WorkflowDraft, + args: { name?: string; goal?: string }, +): ApplyResult { + let next = draft; + if (args.name !== undefined) { + if (!isValidWorkflowName(args.name)) { + return fail( + draft, + `Workflow name "${args.name}" must be snake_case (lowercase + underscores).`, + ); + } + next = { ...next, name: args.name }; + } + if (args.goal !== undefined) { + next = { ...next, goal: args.goal }; + } + return ok(next); +} + +function applyAddNode( + draft: WorkflowDraft, + args: { + id: string; + label: string; + shape: Shape; + prompt?: string; + attrs?: Record<string, AttrValue>; + }, +): ApplyResult { + if (RESERVED_IDS.includes(args.id)) { + return fail(draft, `Node id "${args.id}" is reserved (start/exit).`); + } + if (!isValidNodeId(args.id)) { + return fail( + draft, + `Node id "${args.id}" must be snake_case (lowercase + underscores).`, + ); + } + if (findNode(draft, args.id)) { + return fail(draft, `Node "${args.id}" already exists.`); + } + if (!isValidShape(args.shape)) { + return fail(draft, `Unknown shape "${String(args.shape)}".`); + } + if (args.shape === "mdiamond" || args.shape === "msquare") { + return fail( + draft, + `Shape "${args.shape}" is reserved for start/exit nodes.`, + ); + } + const node: Node = { + id: args.id, + label: args.label, + shape: args.shape, + }; + if (args.prompt !== undefined) node.prompt = args.prompt; + if (args.attrs !== undefined) node.attrs = { ...args.attrs }; + return ok({ ...draft, nodes: [...draft.nodes, node] }); +} + +function applyUpdateNode( + draft: WorkflowDraft, + args: { + id: string; + label?: string; + shape?: Shape; + prompt?: string; + attrs?: Record<string, AttrValue>; + }, +): ApplyResult { + const existing = findNode(draft, args.id); + if (!existing) { + return fail(draft, `Node "${args.id}" does not exist.`); + } + if (RESERVED_IDS.includes(args.id)) { + return fail(draft, `Node "${args.id}" cannot be modified (reserved).`); + } + if (args.shape !== undefined) { + if (!isValidShape(args.shape)) { + return fail(draft, `Unknown shape "${String(args.shape)}".`); + } + if (args.shape === "mdiamond" || args.shape === "msquare") { + return fail( + draft, + `Shape "${args.shape}" is reserved for start/exit nodes.`, + ); + } + } + const updated: Node = { ...existing }; + if (args.label !== undefined) updated.label = args.label; + if (args.shape !== undefined) updated.shape = args.shape; + if (args.prompt !== undefined) updated.prompt = args.prompt; + if (args.attrs !== undefined) updated.attrs = { ...args.attrs }; + return ok({ + ...draft, + nodes: draft.nodes.map((n) => (n.id === args.id ? updated : n)), + }); +} + +function applyDeleteNode( + draft: WorkflowDraft, + args: { id: string }, +): ApplyResult { + if (RESERVED_IDS.includes(args.id)) { + return fail(draft, `Node "${args.id}" cannot be deleted (reserved).`); + } + if (!findNode(draft, args.id)) { + return fail(draft, `Node "${args.id}" does not exist.`); + } + return ok({ + ...draft, + nodes: draft.nodes.filter((n) => n.id !== args.id), + edges: draft.edges.filter((e) => e.from !== args.id && e.to !== args.id), + }); +} + +function applyConnect( + draft: WorkflowDraft, + args: { + from: string; + to: string; + condition?: string; + label?: string; + attrs?: Record<string, AttrValue>; + }, +): ApplyResult { + if (!findNode(draft, args.from)) { + return fail(draft, `Cannot connect: node "${args.from}" does not exist.`); + } + if (!findNode(draft, args.to)) { + return fail(draft, `Cannot connect: node "${args.to}" does not exist.`); + } + if (args.from === args.to) { + return fail(draft, `Cannot connect node "${args.from}" to itself.`); + } + if (args.from === EXIT_ID) { + return fail(draft, `"exit" cannot have outgoing edges.`); + } + if (args.to === START_ID) { + return fail(draft, `"start" cannot have incoming edges.`); + } + if (draft.edges.some((e) => e.from === args.from && e.to === args.to)) { + return fail( + draft, + `Edge "${args.from}" → "${args.to}" already exists.`, + ); + } + const edge: Edge = { from: args.from, to: args.to }; + if (args.condition !== undefined) edge.condition = args.condition; + if (args.label !== undefined) edge.label = args.label; + if (args.attrs !== undefined) edge.attrs = { ...args.attrs }; + return ok({ ...draft, edges: [...draft.edges, edge] }); +} + +function applyDisconnect( + draft: WorkflowDraft, + args: { from: string; to: string }, +): ApplyResult { + const exists = draft.edges.some( + (e) => e.from === args.from && e.to === args.to, + ); + if (!exists) { + return fail( + draft, + `Edge "${args.from}" → "${args.to}" does not exist.`, + ); + } + return ok({ + ...draft, + edges: draft.edges.filter( + (e) => !(e.from === args.from && e.to === args.to), + ), + }); +} diff --git a/apps/fabro-web/app/components/playground/ui/download-button.tsx b/apps/fabro-web/app/components/playground/ui/download-button.tsx new file mode 100644 index 000000000..604faccd8 --- /dev/null +++ b/apps/fabro-web/app/components/playground/ui/download-button.tsx @@ -0,0 +1,28 @@ +import { ArrowDownTrayIcon } from "@heroicons/react/24/outline"; + +import type { WorkflowDraft } from "../state/draft"; +import { + buildDownloadBundle, + triggerDownload, +} from "../files/download"; + +/** + * Bundles the draft into the `.fabro.zip` layout and kicks a browser + * download. The pure parts of that flow live in `files/download.ts`; this + * component only owns the click handler and the visual treatment. + * + * Enabled even in the welcome state — the artifact is still a runnable, + * minimal workflow worth taking away. + */ +export default function DownloadButton({ draft }: { draft: WorkflowDraft }) { + return ( + <button + type="button" + onClick={() => triggerDownload(buildDownloadBundle(draft))} + className="inline-flex items-center gap-1.5 rounded-md bg-teal-500/10 px-3 py-1.5 text-sm font-medium text-teal-200 ring-1 ring-teal-500/30 transition-colors hover:bg-teal-500/20 hover:text-teal-100 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-teal-500" + > + <ArrowDownTrayIcon className="size-4" /> + Download .fabro.zip + </button> + ); +} diff --git a/apps/fabro-web/app/components/playground/ui/file-tabs.tsx b/apps/fabro-web/app/components/playground/ui/file-tabs.tsx new file mode 100644 index 000000000..c940e683f --- /dev/null +++ b/apps/fabro-web/app/components/playground/ui/file-tabs.tsx @@ -0,0 +1,96 @@ +import { useMemo, useState } from "react"; + +import type { WorkflowDraft } from "../state/draft"; +import { renderFabro } from "../files/render-fabro"; +import { renderProjectToml, renderWorkflowToml } from "../files/render-toml"; +import { renderReadme } from "../files/render-readme"; + +/** A single file the user can preview before downloading the zip. */ +type FileTab = { + id: string; + label: string; + language: string; + render: (draft: WorkflowDraft) => string; +}; + +const TABS: FileTab[] = [ + { + id: "workflow.fabro", + label: "workflow.fabro", + language: "dot", + render: (draft) => renderFabro(draft), + }, + { + id: "workflow.toml", + label: "workflow.toml", + language: "toml", + render: (draft) => renderWorkflowToml(draft), + }, + { + id: "project.toml", + label: "project.toml", + language: "toml", + render: (draft) => renderProjectToml(draft), + }, + { + id: "README.md", + label: "README.md", + language: "markdown", + render: (draft) => renderReadme(draft), + }, +]; + +/** + * Tabbed preview of the four files that ship in the downloaded zip. + * + * All four are live-rendered from the draft (no debounce — file generation + * is microseconds at the sizes a playground workflow reaches). The active + * tab persists in local component state, not the draft, so switching tabs + * never alters what gets downloaded. + */ +export default function FileTabs({ draft }: { draft: WorkflowDraft }) { + const [activeId, setActiveId] = useState(TABS[0]!.id); + const active = TABS.find((t) => t.id === activeId) ?? TABS[0]!; + + const body = useMemo(() => active.render(draft), [active, draft]); + + return ( + <div className="flex h-full min-h-0 flex-col overflow-hidden rounded-md border border-line bg-panel-alt/40"> + <div + role="tablist" + aria-label="Generated files" + className="flex shrink-0 items-center gap-0.5 border-b border-line px-1.5 py-1.5" + > + {TABS.map((tab) => { + const isActive = tab.id === activeId; + return ( + <button + type="button" + key={tab.id} + role="tab" + aria-selected={isActive} + onClick={() => setActiveId(tab.id)} + className={[ + "rounded px-2.5 py-1 font-mono text-[11.5px] transition-colors", + isActive + ? "bg-teal-500/10 text-teal-200 ring-1 ring-teal-500/30" + : "text-fg-muted hover:bg-overlay hover:text-fg-3", + ].join(" ")} + > + {tab.label} + </button> + ); + })} + </div> + + <pre + role="tabpanel" + aria-label={active.label} + data-language={active.language} + className="m-0 min-h-0 flex-1 overflow-auto p-4 font-mono text-[12px] leading-relaxed text-fg-2" + > + {body} + </pre> + </div> + ); +} diff --git a/apps/fabro-web/app/components/playground/ui/node-inspector.tsx b/apps/fabro-web/app/components/playground/ui/node-inspector.tsx new file mode 100644 index 000000000..57fcf5adb --- /dev/null +++ b/apps/fabro-web/app/components/playground/ui/node-inspector.tsx @@ -0,0 +1,146 @@ +import { XMarkIcon } from "@heroicons/react/24/outline"; + +import type { Edge, Node, Shape, WorkflowDraft } from "../state/draft"; + +const SHAPE_KIND_LABELS: Record<Shape, string> = { + box: "agent", + tab: "single LLM call", + parallelogram: "shell script", + hexagon: "human gate", + diamond: "conditional branch", + component: "fan-out parallel", + tripleoctagon: "merge parallel", + house: "sub-workflow", + mdiamond: "start (terminal)", + msquare: "exit (terminal)", +}; + +/** + * Read-only node inspector. Lives in the right pane while a node is + * selected on the canvas; replaces the RUN TRACE log when active. + * Mirrors the explainer's node-detail panel, scaled to fit the + * narrow column. + */ +export default function NodeInspector({ + node, + draft, + onClose, +}: { + node: Node; + draft: WorkflowDraft; + onClose: () => void; +}) { + const incoming = draft.edges.filter((e) => e.to === node.id); + const outgoing = draft.edges.filter((e) => e.from === node.id); + + return ( + <div className="flex h-full min-h-0 flex-col"> + <header className="flex shrink-0 items-start justify-between gap-2 border-b border-line px-3 py-2"> + <div className="min-w-0"> + <div className="font-mono text-[10.5px] uppercase tracking-wider text-fg-muted"> + Inspector + </div> + <div className="mt-0.5 truncate text-sm font-semibold text-fg"> + {node.label} + </div> + </div> + <button + type="button" + aria-label="Close inspector" + onClick={onClose} + className="inline-flex size-6 shrink-0 items-center justify-center rounded text-fg-muted transition-colors hover:bg-overlay hover:text-fg focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-teal-500" + > + <XMarkIcon className="size-4" /> + </button> + </header> + + <div className="flex-1 space-y-3 overflow-auto p-3 text-xs"> + <Field label="id"> + <span className="font-mono text-fg-2">{node.id}</span> + </Field> + <Field label="shape"> + <span className="font-mono text-fg-2">{node.shape}</span> + <span className="ml-1.5 text-fg-muted"> + · {SHAPE_KIND_LABELS[node.shape]} + </span> + </Field> + + {node.prompt && ( + <Field label="prompt"> + <p className="whitespace-pre-wrap text-fg-2">{node.prompt}</p> + </Field> + )} + + {node.attrs && Object.keys(node.attrs).length > 0 && ( + <Field label="attrs"> + <dl className="space-y-1"> + {Object.entries(node.attrs).map(([k, v]) => ( + <div key={k} className="grid grid-cols-[auto_1fr] gap-2"> + <dt className="font-mono text-fg-muted">{k}</dt> + <dd className="break-words font-mono text-fg-2"> + {formatAttrValue(v)} + </dd> + </div> + ))} + </dl> + </Field> + )} + + <Field label="edges in"> + <EdgeList edges={incoming} idKey="from" emptyMsg="(none)" /> + </Field> + <Field label="edges out"> + <EdgeList edges={outgoing} idKey="to" emptyMsg="(none)" /> + </Field> + </div> + </div> + ); +} + +function Field({ label, children }: { label: string; children: React.ReactNode }) { + return ( + <div className="space-y-1"> + <div className="font-mono text-[10px] uppercase tracking-wider text-fg-muted"> + {label} + </div> + <div>{children}</div> + </div> + ); +} + +function EdgeList({ + edges, + idKey, + emptyMsg, +}: { + edges: Edge[]; + idKey: "from" | "to"; + emptyMsg: string; +}) { + if (edges.length === 0) { + return <span className="italic text-fg-muted">{emptyMsg}</span>; + } + return ( + <ul className="space-y-1"> + {edges.map((edge, i) => { + const cond = edge.condition ? ` (condition: ${edge.condition})` : ""; + const label = edge.label ? ` "${edge.label}"` : ""; + return ( + <li key={`${edge.from}-${edge.to}-${i}`} className="font-mono text-fg-2"> + {edge[idKey]} + <span className="text-fg-muted"> + {label} + {cond} + </span> + </li> + ); + })} + </ul> + ); +} + +function formatAttrValue(v: unknown): string { + if (typeof v === "string") return v; + if (typeof v === "number" || typeof v === "boolean") return String(v); + return JSON.stringify(v); +} diff --git a/apps/fabro-web/app/components/playground/ui/reset-button.tsx b/apps/fabro-web/app/components/playground/ui/reset-button.tsx new file mode 100644 index 000000000..e37edc8da --- /dev/null +++ b/apps/fabro-web/app/components/playground/ui/reset-button.tsx @@ -0,0 +1,49 @@ +import { useState } from "react"; +import { ArrowPathIcon } from "@heroicons/react/24/outline"; + +/** + * "Start over" — wipes the localStorage draft and resets the canvas + * back to the welcome state. Confirms inline before firing so a + * misclick on an actively-built graph doesn't silently torch the + * user's work. + */ +export default function ResetButton({ onReset }: { onReset: () => void }) { + const [confirming, setConfirming] = useState(false); + + if (confirming) { + return ( + <span className="inline-flex items-center gap-1 rounded-md bg-coral/10 px-2 py-1 text-sm text-coral ring-1 ring-coral/30"> + <span>Start over?</span> + <button + type="button" + onClick={() => { + onReset(); + setConfirming(false); + }} + className="rounded px-1.5 py-0.5 font-medium ring-1 ring-coral/40 hover:bg-coral/20" + > + Yes + </button> + <button + type="button" + onClick={() => setConfirming(false)} + className="rounded px-1.5 py-0.5 text-fg-muted hover:bg-overlay hover:text-fg-2" + > + Cancel + </button> + </span> + ); + } + + return ( + <button + type="button" + onClick={() => setConfirming(true)} + title="Wipe the canvas and start a new workflow" + className="inline-flex items-center gap-1.5 rounded-md bg-overlay px-3 py-1.5 text-sm font-medium text-fg-2 ring-1 ring-line-strong transition-colors hover:bg-overlay-strong hover:text-fg focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-teal-500" + > + <ArrowPathIcon className="size-4" /> + Start over + </button> + ); +} diff --git a/apps/fabro-web/app/components/playground/ui/run-for-real-button.tsx b/apps/fabro-web/app/components/playground/ui/run-for-real-button.tsx new file mode 100644 index 000000000..47805d424 --- /dev/null +++ b/apps/fabro-web/app/components/playground/ui/run-for-real-button.tsx @@ -0,0 +1,68 @@ +import { useState } from "react"; +import { RocketLaunchIcon } from "@heroicons/react/24/outline"; + +import type { WorkflowDraft } from "../state/draft"; +import { isWelcomeState } from "../state/draft"; +import RunForRealModal from "./run-for-real-modal"; + +export interface RealRunRedirect { + href: string; + /** Button label override. Defaults to "Run for real". */ + label?: string; +} + +/** + * "Run for real" toolbar button. Two modes: + * + * - **Default** (fabro-web): the button opens a confirmation modal that + * POSTs the workflow to `/api/v1/runs` and redirects to the resulting + * run page. + * - **Redirect** (custom embed): when `redirect` is supplied, the button + * renders as a plain anchor pointing at the configured href. Use this + * in embed contexts that have no backend to launch against, to send + * visitors to a CTA URL (e.g. `/download`) instead. + * + * Disabled in the welcome state for the default mode — running an empty + * workflow is pointless. The redirect mode stays enabled because the + * destination is informational, not a real run. + */ +export default function RunForRealButton({ + draft, + redirect, +}: { + draft: WorkflowDraft; + redirect?: RealRunRedirect; +}) { + const [isOpen, setIsOpen] = useState(false); + + const classes = + "inline-flex items-center gap-1.5 rounded-md bg-fuchsia-500/10 px-3 py-1.5 text-sm font-medium text-fuchsia-200 ring-1 ring-fuchsia-500/30 transition-colors hover:bg-fuchsia-500/20 hover:text-fuchsia-100 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-fuchsia-500 disabled:cursor-not-allowed disabled:opacity-40 disabled:hover:bg-fuchsia-500/10 disabled:hover:text-fuchsia-200"; + + if (redirect) { + return ( + <a href={redirect.href} className={classes}> + <RocketLaunchIcon className="size-4" /> + {redirect.label ?? "Run for real"} + </a> + ); + } + + const disabled = isWelcomeState(draft); + return ( + <> + <button + type="button" + disabled={disabled} + title={disabled ? "Add at least one node first" : undefined} + onClick={() => setIsOpen(true)} + className={classes} + > + <RocketLaunchIcon className="size-4" /> + Run for real + </button> + {isOpen && ( + <RunForRealModal draft={draft} onClose={() => setIsOpen(false)} /> + )} + </> + ); +} diff --git a/apps/fabro-web/app/components/playground/ui/run-for-real-modal.tsx b/apps/fabro-web/app/components/playground/ui/run-for-real-modal.tsx new file mode 100644 index 000000000..d31c57cae --- /dev/null +++ b/apps/fabro-web/app/components/playground/ui/run-for-real-modal.tsx @@ -0,0 +1,178 @@ +import { useState } from "react"; +import { + CommandLineIcon, + ExclamationTriangleIcon, + XMarkIcon, +} from "@heroicons/react/24/outline"; + +import { useDocumentEvent } from "../../../hooks/effects"; +import { buildRunManifest } from "../state/build-manifest"; +import type { WorkflowDraft } from "../state/draft"; + +/** + * "Run for real" confirmation modal. Placeholder project/repo/folder + * selection — the run is hard-wired to a fresh local sandbox for the + * initial release. When the automation branch lands the established + * project-picker pattern, the disabled inputs below become the live + * surface. + */ +export default function RunForRealModal({ + draft, + onClose, +}: { + draft: WorkflowDraft; + onClose: () => void; +}) { + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState<string | null>(null); + + useDocumentEvent("keydown", (event) => { + if (event.key === "Escape" && !submitting) onClose(); + }); + + const launch = async () => { + setSubmitting(true); + setError(null); + try { + const manifest = buildRunManifest(draft); + const response = await fetch("/api/v1/runs", { + method: "POST", + credentials: "same-origin", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(manifest), + }); + if (!response.ok) { + const detail = await readErrorDetail(response); + throw new Error(detail ?? `${response.status} ${response.statusText}`); + } + const body = (await response.json()) as { id?: string }; + if (!body.id) { + throw new Error("Server did not return a run id."); + } + window.location.assign(`/runs/${body.id}`); + } catch (e) { + setError(e instanceof Error ? e.message : String(e)); + setSubmitting(false); + } + }; + + return ( + <div + role="dialog" + aria-modal="true" + aria-labelledby="run-for-real-title" + className="fixed inset-0 z-50 flex items-center justify-center bg-bg/80 backdrop-blur-sm" + onClick={(event) => { + if (event.target === event.currentTarget && !submitting) onClose(); + }} + > + <div className="w-full max-w-md rounded-lg border border-line bg-panel p-5 shadow-2xl"> + <header className="mb-4 flex items-start justify-between gap-3"> + <div className="flex items-center gap-2"> + <CommandLineIcon className="size-5 text-teal-300" aria-hidden="true" /> + <h2 + id="run-for-real-title" + className="text-base font-semibold text-fg" + > + Run for real + </h2> + </div> + <button + type="button" + aria-label="Close" + onClick={onClose} + disabled={submitting} + className="inline-flex size-7 items-center justify-center rounded text-fg-muted transition-colors hover:bg-overlay hover:text-fg disabled:opacity-50" + > + <XMarkIcon className="size-4" /> + </button> + </header> + + <p className="mb-4 text-sm text-fg-2"> + This launches your workflow as a real Fabro run, redirecting you to + its run page when it starts. The run executes in a fresh local + sandbox for now — project, repo, and folder selection are coming + soon. + </p> + + <fieldset + disabled + aria-disabled="true" + className="mb-4 space-y-3 rounded-md border border-line bg-overlay/30 p-3 opacity-60" + > + <legend className="px-1 font-mono text-[10px] uppercase tracking-wider text-fg-muted"> + Where to run (coming soon) + </legend> + <label className="block text-xs"> + <span className="mb-1 block text-fg-muted">Project</span> + <input + type="text" + placeholder="No connected projects yet" + className="w-full rounded border border-line bg-bg/60 px-2 py-1.5 font-mono text-xs text-fg-muted" + /> + </label> + <label className="block text-xs"> + <span className="mb-1 block text-fg-muted">GitHub repo</span> + <input + type="text" + placeholder="github.com/owner/repo" + className="w-full rounded border border-line bg-bg/60 px-2 py-1.5 font-mono text-xs text-fg-muted" + /> + </label> + <label className="block text-xs"> + <span className="mb-1 block text-fg-muted">Local folder</span> + <input + type="text" + placeholder="/path/to/repo" + className="w-full rounded border border-line bg-bg/60 px-2 py-1.5 font-mono text-xs text-fg-muted" + /> + </label> + </fieldset> + + {error && ( + <div className="mb-4 flex items-start gap-2 rounded-md border border-rose-500/30 bg-rose-500/10 p-3 text-xs text-rose-200"> + <ExclamationTriangleIcon + className="mt-0.5 size-4 shrink-0" + aria-hidden="true" + /> + <div> + <div className="mb-0.5 font-semibold">Couldn't launch the run</div> + <div className="break-words">{error}</div> + </div> + </div> + )} + + <div className="flex items-center justify-end gap-2"> + <button + type="button" + onClick={onClose} + disabled={submitting} + className="rounded-md px-3 py-1.5 text-sm font-medium text-fg-2 transition-colors hover:bg-overlay disabled:opacity-50" + > + Cancel + </button> + <button + type="button" + onClick={launch} + disabled={submitting} + className="inline-flex items-center gap-1.5 rounded-md bg-teal-500/10 px-3 py-1.5 text-sm font-medium text-teal-200 ring-1 ring-teal-500/30 transition-colors hover:bg-teal-500/20 hover:text-teal-100 disabled:opacity-50" + > + {submitting ? "Launching…" : "Run in sandbox"} + </button> + </div> + </div> + </div> + ); +} + +async function readErrorDetail(response: Response): Promise<string | null> { + try { + const body = (await response.clone().json()) as { + errors?: { detail?: string; title?: string }[]; + }; + const first = body.errors?.[0]; + return first?.detail ?? first?.title ?? null; + } catch { + return null; + } +} diff --git a/apps/fabro-web/app/components/playground/ui/run-trace.tsx b/apps/fabro-web/app/components/playground/ui/run-trace.tsx new file mode 100644 index 000000000..e094249c8 --- /dev/null +++ b/apps/fabro-web/app/components/playground/ui/run-trace.tsx @@ -0,0 +1,54 @@ +import type { SimulationState } from "../canvas/simulation"; + +/** + * Tiny live log next to the canvas during a simulated run. Mirrors the + * cadence of the explainer's "RUN TRACE" pane: one mono line per step, + * timestamped, with the active one highlighted. + */ +export default function RunTrace({ state }: { state: SimulationState }) { + if (state.trace.length === 0) { + return ( + <p className="px-3 py-2 font-mono text-[11px] text-fg-muted"> + Press <span className="text-fg-2">Simulate</span> to walk this graph. + </p> + ); + } + return ( + <ul className="flex flex-col gap-0.5 px-2 py-2 font-mono text-[11px]"> + {state.trace.map((step) => { + const isActive = state.active === step.nodeId && !state.finished; + const isDone = !isActive; + return ( + <li + key={step.index} + className={[ + "flex items-baseline gap-2 rounded px-2 py-1 transition-colors", + isActive && "bg-teal-500/10 text-teal-200 ring-1 ring-teal-500/30", + isDone && "text-fg-3", + ] + .filter(Boolean) + .join(" ")} + > + <span className="text-fg-muted tabular-nums"> + {formatElapsed(step.elapsedMs)} + </span> + <span className="truncate"> + <span className="text-fg-muted">{step.nodeId}</span> + {step.label !== step.nodeId && ( + <span className="ml-1.5 text-fg-2">{step.label}</span> + )} + </span> + </li> + ); + })} + {state.finished && ( + <li className="px-2 py-1 text-mint">— done —</li> + )} + </ul> + ); +} + +function formatElapsed(ms: number): string { + const s = (ms / 1000).toFixed(1); + return `${s.padStart(5, " ")}s`; +} diff --git a/apps/fabro-web/app/components/playground/ui/simulation-controls.tsx b/apps/fabro-web/app/components/playground/ui/simulation-controls.tsx new file mode 100644 index 000000000..324d3f36d --- /dev/null +++ b/apps/fabro-web/app/components/playground/ui/simulation-controls.tsx @@ -0,0 +1,69 @@ +import { + ArrowPathIcon, + PlayIcon, +} from "@heroicons/react/24/solid"; + +import { + MAX_STEP_MS, + MIN_STEP_MS, + type PlaygroundSimulation, +} from "../canvas/use-simulation"; + +/** + * Play / Reset buttons + speed slider. The Play button is disabled when + * the draft is the welcome state (start → ??? → exit) — there's nothing + * to walk yet. The slider is the explainer's "speed" control: 500ms + * (snappy) to 3000ms (slow tour). + */ +export default function SimulationControls({ + sim, +}: { + sim: PlaygroundSimulation; +}) { + return ( + <div className="flex items-center gap-3"> + <button + type="button" + onClick={sim.play} + disabled={!sim.isPlayable || sim.isRunning} + title={ + sim.isPlayable + ? "Simulate a walk through the workflow" + : "Add a node first" + } + className="inline-flex items-center gap-1.5 rounded-md bg-overlay px-2.5 py-1.5 text-sm font-medium text-fg-2 ring-1 ring-line-strong transition-colors hover:bg-overlay-strong hover:text-fg focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-teal-500 disabled:cursor-not-allowed disabled:opacity-50" + > + <PlayIcon className="size-3.5 text-teal-300" /> + {sim.isRunning ? "Running…" : "Simulate"} + </button> + <button + type="button" + onClick={sim.reset} + disabled={sim.state.trace.length === 0} + title="Reset simulation" + className="inline-flex size-7 items-center justify-center rounded-md text-fg-muted transition-colors hover:bg-overlay hover:text-fg-3 disabled:cursor-not-allowed disabled:opacity-40" + > + <ArrowPathIcon className="size-3.5" /> + </button> + + <div className="flex items-center gap-2 text-[11px] text-fg-muted"> + <span className="font-mono">speed</span> + <input + type="range" + min={MIN_STEP_MS} + max={MAX_STEP_MS} + step={250} + // Slider feels natural when "right = fast"; invert via max+min-value. + value={MAX_STEP_MS + MIN_STEP_MS - sim.stepMs} + onChange={(e) => + sim.setStepMs( + MAX_STEP_MS + MIN_STEP_MS - Number.parseInt(e.currentTarget.value, 10), + ) + } + className="h-1 w-24 cursor-pointer accent-teal-500" + aria-label="Simulation speed" + /> + </div> + </div> + ); +} diff --git a/apps/fabro-web/app/components/playground/ui/workflow-header.tsx b/apps/fabro-web/app/components/playground/ui/workflow-header.tsx new file mode 100644 index 000000000..12d3bd855 --- /dev/null +++ b/apps/fabro-web/app/components/playground/ui/workflow-header.tsx @@ -0,0 +1,32 @@ +import type { WorkflowDraft } from "../state/draft"; + +/** + * Read-only display of the workflow's auto-generated name and goal. + * + * Per the spec, neither field is user-editable in v1 — the model sets + * both via `set_workflow_meta`. Until it does, we show muted placeholder + * copy so the canvas header has a stable shape rather than collapsing. + */ +export default function WorkflowHeader({ draft }: { draft: WorkflowDraft }) { + const named = draft.name !== "untitled" && draft.name.length > 0; + const hasGoal = draft.goal.length > 0; + return ( + <div className="flex min-w-0 flex-col gap-0.5"> + <div className="flex items-baseline gap-2"> + <h1 className="truncate text-sm font-semibold text-fg"> + {named ? draft.name : "untitled workflow"} + </h1> + {!named && ( + <span className="font-mono text-[10.5px] uppercase tracking-wider text-fg-muted"> + (Ask Fabro will name it) + </span> + )} + </div> + <p + className={`truncate text-xs ${hasGoal ? "text-fg-3" : "text-fg-muted italic"}`} + > + {hasGoal ? draft.goal : "Describe a workflow in the chat to get going."} + </p> + </div> + ); +} diff --git a/apps/fabro-web/app/router.tsx b/apps/fabro-web/app/router.tsx index 3ad211ed4..4047ce346 100644 --- a/apps/fabro-web/app/router.tsx +++ b/apps/fabro-web/app/router.tsx @@ -10,6 +10,7 @@ import * as ChatsLayout from "./routes/chats-layout"; import * as ChatsNew from "./routes/chats-new"; import * as ChatsDetail from "./routes/chats-detail"; import * as AskFabro from "./routes/ask-fabro"; +import * as Playground from "./routes/playground"; import * as Automations from "./routes/automations"; import * as AutomationsNew from "./routes/automations-new"; import * as AutomationsEdit from "./routes/automations-edit"; @@ -110,6 +111,7 @@ export const routes: RouteObject[] = [ ], }), route("ask-fabro", AskFabro), + route("playground", Playground), route("automations", Automations), route("automations/new", AutomationsNew), route("automations/:id/edit", AutomationsEdit), diff --git a/apps/fabro-web/app/routes/playground.tsx b/apps/fabro-web/app/routes/playground.tsx new file mode 100644 index 000000000..46217ad81 --- /dev/null +++ b/apps/fabro-web/app/routes/playground.tsx @@ -0,0 +1,21 @@ +import Playground from "../components/playground/playground"; + +// hideHeader/fullHeight/wide mirror the chats / ask-fabro routes so the +// workspace and the docked playground sidebar bleed edge-to-edge below the +// top nav. AppShell already auth-gates this tree, so `authMode="required"` +// here is descriptive (it tells the Playground component what guarantee +// its parent provides), not enforced inside the playground itself. +export const handle = { hideHeader: true, fullHeight: true, wide: true }; + +export function meta() { + return [{ title: "Playground — Fabro" }]; +} + +export default function PlaygroundRoute() { + return ( + <Playground + chatEndpoint="/api/v1/playground/chat" + authMode="required" + /> + ); +} diff --git a/apps/fabro-web/package.json b/apps/fabro-web/package.json index ed95f5298..4c3bdda71 100644 --- a/apps/fabro-web/package.json +++ b/apps/fabro-web/package.json @@ -28,6 +28,7 @@ "@xterm/addon-fit": "^0.11.0", "@xterm/xterm": "^6.0.0", "axios": "^1.7.0", + "fflate": "^0.8.3", "marked": "^18.0.0", "react": "^19.2.4", "react-dom": "^19.2.4", diff --git a/bun.lock b/bun.lock index c0fb7a68a..4bb92d378 100644 --- a/bun.lock +++ b/bun.lock @@ -25,6 +25,7 @@ "@xterm/addon-fit": "^0.11.0", "@xterm/xterm": "^6.0.0", "axios": "^1.7.0", + "fflate": "^0.8.3", "marked": "^18.0.0", "react": "^19.2.4", "react-dom": "^19.2.4", diff --git a/docs/public/api-reference/fabro-api.yaml b/docs/public/api-reference/fabro-api.yaml index 35d7511e5..b6d816ad3 100644 --- a/docs/public/api-reference/fabro-api.yaml +++ b/docs/public/api-reference/fabro-api.yaml @@ -5317,6 +5317,44 @@ paths: schema: $ref: "#/components/schemas/ErrorResponse" + # ── Playground ──────────────────────────────────────────────────────── + + /api/v1/playground/chat: + post: + operationId: createPlaygroundChat + tags: [Playground] + summary: Chat with the playground assistant + description: | + Drives a single turn of the playground chat that builds a workflow + graph incrementally. The server is stateless: each request includes + the full current draft, and the response streams text deltas plus a + single `write_workflow_file` tool call carrying the full new contents + of `workflow.fabro` for the client to parse, diff against its local + draft, and animate into the canvas. + + Responses are always SSE. Frames use `event: stream_event` with a + JSON-serialized StreamEvent payload — see /api/v1/completions for the + StreamEvent shape. The tool call arrives on a `tool_call_end` event + with the tool name and parsed JSON arguments. + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/CreatePlaygroundChatRequest" + responses: + "200": + description: SSE stream of text deltas and tool calls. + "400": + description: Invalid request + headers: + x-request-id: + $ref: "#/components/headers/XRequestId" + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + # ── Settings ────────────────────────────────────────────────────────── /api/v1/settings: @@ -7858,6 +7896,36 @@ components: output: description: Parsed structured output when schema was provided. + # ── Playground ──────────────────────────────────────────────────────── + + CreatePlaygroundChatRequest: + description: > + Body of POST /api/v1/playground/chat. The server is stateless across + turns: the browser owns the draft and submits it as the literal + `workflow.fabro` contents with every turn. The server embeds the + file in the model's system prompt and exposes a single + `write_workflow_file` tool that emits the full new contents of + `workflow.fabro`; the browser parses, diffs, and animates the + result. + type: object + required: [messages, workflow_fabro] + properties: + messages: + type: array + description: assistant-ui-style message history for the turn. + items: + $ref: "#/components/schemas/CompletionMessage" + workflow_fabro: + type: string + description: > + Full current `workflow.fabro` (Graphviz DOT) contents as + rendered by the client — a complete `digraph <name> { ... }` + block including the `start` / `exit` terminals. This is the + same format the model writes back via `write_workflow_file`. + model: + type: string + description: Model id or alias. Server picks the default if omitted. + PaginatedSavedQueryList: description: Paginated list of saved queries. type: object diff --git a/lib/crates/fabro-api/build.rs b/lib/crates/fabro-api/build.rs index c87635923..8cf2c4204 100644 --- a/lib/crates/fabro-api/build.rs +++ b/lib/crates/fabro-api/build.rs @@ -644,6 +644,9 @@ fn main() { ("SessionRecord", "fabro_types::SessionRecord", &[]), ("SessionSummary", "fabro_types::SessionSummary", &[]), ("SessionDetail", "fabro_types::SessionDetail", &[]), + ("CompletionMessage", "fabro_types::Message", &[]), + ("CompletionMessageRole", "fabro_types::Role", &[]), + ("CompletionContentPart", "fabro_types::ContentPart", &[]), ]; for (name, path, impls) in replacements { settings.with_replacement(*name, *path, impls.iter().copied()); diff --git a/lib/crates/fabro-api/src/lib.rs b/lib/crates/fabro-api/src/lib.rs index 815883853..33d6e1d73 100644 --- a/lib/crates/fabro-api/src/lib.rs +++ b/lib/crates/fabro-api/src/lib.rs @@ -40,17 +40,17 @@ pub mod types { pub use fabro_types::{ ActivatedSkill, AgentMcpToolSummary, AgentSkillActivationSource, AgentSkillSummary, AgentToolCategory, AgentToolSource, AgentToolSummary, AgentToolsAvailableProps, AskFabro, - AuthMethod, AutomationRef, BilledTokenCounts, CommandTermination, Conclusion, + AuthMethod, AutomationRef, BilledTokenCounts, CommandTermination, Conclusion, ContentPart, CreateVariableRequest, DiffStats, DiffSummary, DirtyStatus, EventEnvelope, ExecOutputTail, FailureCategory, FailureDetail, FailureSignature, GitContext, IdpIdentity, IntegrationConnectionKind, IntegrationConnectionState, IntegrationConnectionStatus, IntegrationProvider, IntegrationStatus, InterviewOption, InterviewQuestionRecord, - McpServerProjection, McpServerStatus, PairId, PairMessageId, PairMessageRecord, + McpServerProjection, McpServerStatus, Message, PairId, PairMessageId, PairMessageRecord, PairMessageRequest, PairRecord, PairStartRequest, PairStatus, PairTarget, PairTranscriptEntry, PairTranscriptResponse, PendingInterviewRecord, PermissionLevel, PreRunPushOutcome, Principal, PullRequest, PullRequestDetails, PullRequestDetailsStatus, PullRequestDetailsUnavailableReason, PullRequestLink, PullRequestMeta, PullRequestResponse, - QuestionType, RepositoryRef, Run, RunApproval, RunApprovalState, RunClientProvenance, + QuestionType, RepositoryRef, Role, Run, RunApproval, RunApprovalState, RunClientProvenance, RunEvent, RunEventDetailContentKind, RunEventDetailResponse, RunFailure, RunPairStatusResponse, RunProjection, RunProvenance, RunRunnableSource, RunSandbox, RunSandboxFailure, RunSandboxInstance, RunSandboxKind, RunSandboxPlan, RunSandboxRuntime, diff --git a/lib/crates/fabro-api/tests/completion_message_round_trip.rs b/lib/crates/fabro-api/tests/completion_message_round_trip.rs new file mode 100644 index 000000000..8e73a41e5 --- /dev/null +++ b/lib/crates/fabro-api/tests/completion_message_round_trip.rs @@ -0,0 +1,153 @@ +//! Proves the `CompletionMessage` / `CompletionMessageRole` / +//! `CompletionContentPart` OpenAPI schemas are served by the canonical +//! `fabro_types::{Message, Role, ContentPart}` via build.rs +//! `with_replacement`, and that the canonical serde output matches the +//! wire shape the spec describes. + +use std::any::{TypeId, type_name}; + +use fabro_api::types::{ContentPart as ApiContentPart, Message as ApiMessage, Role as ApiRole}; +use fabro_types::{ContentPart, Message, Role, ToolCall, ToolResult}; +use serde_json::json; + +#[test] +fn completion_message_reuses_domain_types() { + assert_same_type::<ApiMessage, Message>(); + assert_same_type::<ApiRole, Role>(); + assert_same_type::<ApiContentPart, ContentPart>(); +} + +#[test] +fn role_json_matches_openapi_enum() { + for (role, wire) in [ + (Role::System, "system"), + (Role::User, "user"), + (Role::Assistant, "assistant"), + (Role::Tool, "tool"), + (Role::Developer, "developer"), + ] { + assert_eq!(serde_json::to_value(role).unwrap(), json!(wire)); + assert_eq!( + serde_json::from_value::<Role>(json!(wire)).unwrap(), + role, + "round trip for {wire}" + ); + } +} + +#[test] +fn message_json_matches_openapi_shape() { + // Optional fields are omitted, not serialized as null. + assert_eq!( + serde_json::to_value(Message::user("hello")).unwrap(), + json!({ + "role": "user", + "content": [{"kind": "text", "data": "hello"}] + }) + ); + + // Populated optionals appear under the spec's property names. + let mut message = Message::tool_result("call_1", json!("ok"), false); + message.name = Some("checker".to_string()); + assert_eq!( + serde_json::to_value(message).unwrap(), + json!({ + "role": "tool", + "content": [{ + "kind": "tool_result", + "data": { + "tool_call_id": "call_1", + "content": "ok", + "is_error": false + } + }], + "name": "checker", + "tool_call_id": "call_1" + }) + ); +} + +#[test] +fn message_accepts_explicit_nulls_for_optionals() { + // The previously generated API type serialized absent optionals as + // explicit nulls; inbound payloads in that older shape must keep + // parsing. + let message: Message = serde_json::from_value(json!({ + "role": "assistant", + "content": [{"kind": "text", "data": "hi"}], + "name": null, + "tool_call_id": null + })) + .unwrap(); + assert_eq!(message.role, Role::Assistant); + assert_eq!(message.name, None); + assert_eq!(message.tool_call_id, None); +} + +#[test] +fn content_part_json_matches_openapi_envelope() { + // The spec describes a `{kind, data}` envelope; every variant must + // serialize into it. + assert_eq!( + serde_json::to_value(ContentPart::text("hi")).unwrap(), + json!({"kind": "text", "data": "hi"}) + ); + + assert_eq!( + serde_json::to_value(ContentPart::ToolCall(ToolCall::new( + "call_1", + "write_workflow_file", + json!({"file_name": "workflow.fabro"}), + ))) + .unwrap(), + json!({ + "kind": "tool_call", + "data": { + "id": "call_1", + "name": "write_workflow_file", + "type": "function", + "arguments": {"file_name": "workflow.fabro"}, + "raw_arguments": null + } + }) + ); + + assert_eq!( + serde_json::to_value(ContentPart::ToolResult(ToolResult::success( + "call_1", + json!("done"), + ))) + .unwrap(), + json!({ + "kind": "tool_result", + "data": { + "tool_call_id": "call_1", + "content": "done", + "is_error": false + } + }) + ); +} + +#[test] +fn content_part_preserves_unknown_kinds() { + // The spec leaves `kind` open-ended; unknown kinds must round-trip + // (previously the handler conversion silently dropped them). + let wire = json!({"kind": "mystery", "data": {"x": 1}}); + let part: ContentPart = serde_json::from_value(wire.clone()).unwrap(); + assert_eq!(part, ContentPart::Other { + kind: "mystery".to_string(), + data: json!({"x": 1}), + }); + assert_eq!(serde_json::to_value(part).unwrap(), wire); +} + +fn assert_same_type<T: 'static, U: 'static>() { + assert_eq!( + TypeId::of::<T>(), + TypeId::of::<U>(), + "{} should be the same type as {}", + type_name::<T>(), + type_name::<U>() + ); +} diff --git a/lib/crates/fabro-llm/src/types.rs b/lib/crates/fabro-llm/src/types.rs index e09989ab3..cc84e616c 100644 --- a/lib/crates/fabro-llm/src/types.rs +++ b/lib/crates/fabro-llm/src/types.rs @@ -1,105 +1,23 @@ use std::collections::HashMap; use std::sync::Arc; +// --- 3.1 / 3.2 / 3.5 Canonical chat + content data structures --- +// +// `Message`, `Role`, `ContentPart`, `ImageData`, `AudioData`, +// `DocumentData`, `ThinkingData`, `ToolCall`, and `ToolResult` are the +// canonical provider-neutral replay primitives. They live in `fabro-types` +// so the event stream, API responses, and runtime history can share one +// model. They are re-exported here so existing `fabro_llm::types::*` +// imports keep working. +pub use fabro_types::{ + AudioData, ContentPart, DocumentData, ImageData, Message, Role, ThinkingData, ToolCall, + ToolResult, +}; use fabro_util::backoff::BackoffPolicy; use serde::{Deserialize, Serialize}; use crate::error::Error; -// --- 3.2 Role --- - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum Role { - System, - User, - Assistant, - Tool, - Developer, -} - -// --- 3.5 Content Data Structures --- -// -// `ContentPart`, `ImageData`, `AudioData`, `DocumentData`, `ThinkingData`, -// `ToolCall`, and `ToolResult` are the canonical provider-neutral replay -// primitives. They live in `fabro-types` so the event stream, API responses, -// and runtime history can share one model. They are re-exported here so -// existing `fabro_llm::types::*` imports keep working. -pub use fabro_types::{ - AudioData, ContentPart, DocumentData, ImageData, ThinkingData, ToolCall, ToolResult, -}; - -// --- 3.1 Message --- - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct Message { - pub role: Role, - pub content: Vec<ContentPart>, - pub name: Option<String>, - pub tool_call_id: Option<String>, -} - -impl Message { - pub fn system(text: impl Into<String>) -> Self { - Self { - role: Role::System, - content: vec![ContentPart::text(text)], - name: None, - tool_call_id: None, - } - } - - pub fn user(text: impl Into<String>) -> Self { - Self { - role: Role::User, - content: vec![ContentPart::text(text)], - name: None, - tool_call_id: None, - } - } - - pub fn assistant(text: impl Into<String>) -> Self { - Self { - role: Role::Assistant, - content: vec![ContentPart::text(text)], - name: None, - tool_call_id: None, - } - } - - pub fn tool_result( - tool_call_id: impl Into<String>, - content: serde_json::Value, - is_error: bool, - ) -> Self { - let id = tool_call_id.into(); - Self { - role: Role::Tool, - content: vec![ContentPart::ToolResult(ToolResult { - tool_call_id: id.clone(), - content, - is_error, - image_data: None, - image_media_type: None, - })], - name: None, - tool_call_id: Some(id), - } - } - - /// Concatenates text from all text content parts. - #[must_use] - pub fn text(&self) -> String { - self.content - .iter() - .filter_map(|part| match part { - ContentPart::Text(text) => Some(text.as_str()), - _ => None, - }) - .collect() - } -} - // --- 3.8 FinishReason --- #[derive(Debug, Clone, PartialEq, Eq)] diff --git a/lib/crates/fabro-server/src/server.rs b/lib/crates/fabro-server/src/server.rs index 7bff4968f..8413a4579 100644 --- a/lib/crates/fabro-server/src/server.rs +++ b/lib/crates/fabro-server/src/server.rs @@ -28,21 +28,20 @@ pub use fabro_api::types::{ BatchDeleteRunsResultOutcome, BatchDeleteRunsSummary, BatchRunLifecycleRequest, BatchRunLifecycleResponse, BatchRunLifecycleResult, BatchRunLifecycleResultOutcome, BatchRunLifecycleSummary, BillingByModel, BillingStageRef, CloseRunPullRequestResponse, - CompletionContentPart, CompletionMessage, CompletionMessageRole, CompletionResponse, - CompletionToolChoiceMode, CompletionUsage, CreateCompletionRequest, - CreateRunPullRequestRequest, CreateSecretRequest, CreateVariableRequest, DeleteRunResponse, - DeleteRunSandbox, DeleteSecretRequest, DenyRunRequest, DiskUsageResponse, DiskUsageRunRow, - DiskUsageSummaryRow, ErrorResponseEntry, ForkRequest, ForkResponse, IntegrationConnectionKind, - IntegrationConnectionState, IntegrationConnectionStatus, IntegrationProvider, - IntegrationStatus, LinkRunPullRequestRequest, MergeRunPullRequestRequest, - MergeRunPullRequestResponse, ModelReference, PaginatedEventList, PaginatedRunList, - PaginationMeta, PreflightResponse, PreviewUrlRequest, PreviewUrlResponse, Provider, - ProviderList, PruneRunEntry, PruneRunsRequest, PruneRunsResponse, RenderWorkflowGraphDirection, - RenderWorkflowGraphRequest, RewindRequest, RewindResponse, Run, RunArtifactEntry, - RunArtifactListResponse, RunBilling, RunBillingStage, RunBillingTotals, RunError, RunManifest, - RunStage, SandboxDetails, SandboxFileEntry, SandboxFileListResponse, SandboxService, - SandboxServiceListResponse, SshAccessRequest, SshAccessResponse, StageHandler, StageState, - StartRunRequest, SubmitAnswerRequest, SystemCpuResourceScope, SystemCpuResources, + CompletionResponse, CompletionToolChoiceMode, CompletionUsage, CreateCompletionRequest, + CreatePlaygroundChatRequest, CreateRunPullRequestRequest, CreateSecretRequest, + CreateVariableRequest, DeleteRunResponse, DeleteRunSandbox, DeleteSecretRequest, + DenyRunRequest, DiskUsageResponse, DiskUsageRunRow, DiskUsageSummaryRow, ErrorResponseEntry, + ForkRequest, ForkResponse, IntegrationConnectionKind, IntegrationConnectionState, + IntegrationConnectionStatus, IntegrationProvider, IntegrationStatus, LinkRunPullRequestRequest, + MergeRunPullRequestRequest, MergeRunPullRequestResponse, ModelReference, PaginatedEventList, + PaginatedRunList, PaginationMeta, PreflightResponse, PreviewUrlRequest, PreviewUrlResponse, + Provider, ProviderList, PruneRunEntry, PruneRunsRequest, PruneRunsResponse, + RenderWorkflowGraphDirection, RenderWorkflowGraphRequest, RewindRequest, RewindResponse, Run, + RunArtifactEntry, RunArtifactListResponse, RunBilling, RunBillingStage, RunBillingTotals, + RunError, RunManifest, RunStage, SandboxDetails, SandboxFileEntry, SandboxFileListResponse, + SandboxService, SandboxServiceListResponse, SshAccessRequest, SshAccessResponse, StageHandler, + StageState, StartRunRequest, SubmitAnswerRequest, SystemCpuResourceScope, SystemCpuResources, SystemDiskResourceScope, SystemDiskResources, SystemInfoResponse, SystemIntegrationStatus, SystemIntegrationsResponse, SystemMemoryResourceScope, SystemMemoryResources, SystemRepairRunIssue, SystemRepairRunsResponse, SystemResourcesResponse, SystemRunCounts, @@ -61,8 +60,7 @@ use fabro_llm::client::Client as LlmClient; use fabro_llm::generate::{GenerateParams, generate_object}; use fabro_llm::model_test::run_model_test; use fabro_llm::types::{ - ContentPart, FinishReason, Message as LlmMessage, Request as LlmRequest, Role, ToolChoice, - ToolDefinition, + FinishReason, Message as LlmMessage, Request as LlmRequest, ToolChoice, ToolDefinition, }; use fabro_model::catalog::LlmCatalogSettings; use fabro_model::{BilledTokenCounts, Catalog, ModelRef, ModelTestMode, ProviderId}; diff --git a/lib/crates/fabro-server/src/server/handler/completions.rs b/lib/crates/fabro-server/src/server/handler/completions.rs index 5b884a64a..1785dfd01 100644 --- a/lib/crates/fabro-server/src/server/handler/completions.rs +++ b/lib/crates/fabro-server/src/server/handler/completions.rs @@ -1,12 +1,12 @@ use std::sync::Arc; use super::super::{ - ApiError, AppState, CompletionContentPart, CompletionMessage, CompletionMessageRole, - CompletionResponse, CompletionToolChoiceMode, CompletionUsage, ContentPart, - CreateCompletionRequest, Duration, Event, FinishReason, GenerateParams, IntoResponse, Json, - KeepAlive, LlmMessage, LlmRequest, RequiredUser, Response, Role, Router, Sse, State, - StatusCode, ToolChoice, ToolDefinition, Ulid, error, generate_object, info, post, warn, + ApiError, AppState, CompletionResponse, CompletionToolChoiceMode, CompletionUsage, + CreateCompletionRequest, FinishReason, GenerateParams, IntoResponse, Json, LlmMessage, + LlmRequest, RequiredUser, Response, Router, State, StatusCode, ToolChoice, ToolDefinition, + Ulid, error, generate_object, info, post, warn, }; +use super::llm_sse; pub(super) fn routes() -> Router<Arc<AppState>> { Router::new().route("/completions", post(create_completion)) @@ -23,54 +23,6 @@ fn finish_reason_to_api_stop_reason(reason: &FinishReason) -> String { } } -fn convert_api_message(msg: &CompletionMessage) -> LlmMessage { - let role = match msg.role { - CompletionMessageRole::System => Role::System, - CompletionMessageRole::User => Role::User, - CompletionMessageRole::Assistant => Role::Assistant, - CompletionMessageRole::Tool => Role::Tool, - CompletionMessageRole::Developer => Role::Developer, - }; - let content: Vec<ContentPart> = msg - .content - .iter() - .filter_map(|part| { - let json = serde_json::to_value(part).ok()?; - serde_json::from_value(json).ok() - }) - .collect(); - LlmMessage { - role, - content, - name: msg.name.clone(), - tool_call_id: msg.tool_call_id.clone(), - } -} - -fn convert_llm_message(msg: &LlmMessage) -> CompletionMessage { - let role = match msg.role { - Role::System => CompletionMessageRole::System, - Role::User => CompletionMessageRole::User, - Role::Assistant => CompletionMessageRole::Assistant, - Role::Tool => CompletionMessageRole::Tool, - Role::Developer => CompletionMessageRole::Developer, - }; - let content: Vec<CompletionContentPart> = msg - .content - .iter() - .filter_map(|part| { - let json = serde_json::to_value(part).ok()?; - serde_json::from_value(json).ok() - }) - .collect(); - CompletionMessage { - role, - content, - name: msg.name.clone(), - tool_call_id: msg.tool_call_id.clone(), - } -} - async fn create_completion( _auth: RequiredUser, State(state): State<Arc<AppState>>, @@ -92,14 +44,14 @@ async fn create_completion( info!(model = %model_id, provider = ?provider_name, "Completion request received"); - // Build messages list + // Build messages list. Request messages are already the canonical + // `fabro_types::Message` — the API schema reuses it via build.rs + // `with_replacement`, so no conversion is needed. let mut messages: Vec<LlmMessage> = Vec::new(); if let Some(system) = req.system { messages.push(LlmMessage::system(system)); } - for msg in &req.messages { - messages.push(convert_api_message(msg)); - } + messages.extend(req.messages); // Convert tools let tools: Option<Vec<ToolDefinition>> = if req.tools.is_empty() { @@ -185,43 +137,7 @@ async fn create_completion( } }; - let sse_stream = tokio_stream::StreamExt::filter_map(stream_result, |event| match event { - Ok(ref evt) => match serde_json::to_string(evt) { - Ok(json) => Some(Ok::<_, std::convert::Infallible>( - Event::default().event("stream_event").data(json), - )), - Err(e) => Some(Ok(Event::default().event("stream_event").data( - serde_json::json!({ - "type": "error", - "error": {"Stream": {"message": format!("failed to serialize event: {e}")}}, - "raw": null - }) - .to_string(), - ))), - }, - Err(e) => Some(Ok(Event::default().event("stream_event").data( - serde_json::json!({ - "type": "error", - "error": {"Stream": {"message": e.to_string()}}, - "raw": null - }) - .to_string(), - ))), - }); - let sse_stream = futures_util::StreamExt::take_until( - sse_stream, - state.shutdown_token().cancelled_owned(), - ); - - Sse::new(sse_stream) - .keep_alive( - KeepAlive::new().interval(Duration::from_secs(15)).event( - Event::default() - .event("ping") - .data(serde_json::json!({"type": "ping"}).to_string()), - ), - ) - .into_response() + llm_sse::stream_response(stream_result, state.shutdown_token()) } else { // Non-streaming path let msg_id = Ulid::new().to_string(); @@ -244,35 +160,46 @@ async fn create_completion( params = params.top_p(top_p); } match generate_object(params, schema).await { - Ok(result) => Json(CompletionResponse { - id: msg_id, - model: model_id, - message: convert_llm_message(&result.response.message), - stop_reason: finish_reason_to_api_stop_reason(&result.finish_reason), - usage: CompletionUsage { - input_tokens: result.usage.input_tokens, - output_tokens: result.usage.output_tokens, - }, - output: result.output, - }) - .into_response(), + Ok(result) => { + // `result.finish_reason` / `result.usage` resolve through + // GenerateResult's Deref to the inner Response; move the + // Response out once so `message` can be taken by value. + let output = result.output; + let response = result.response; + let stop_reason = finish_reason_to_api_stop_reason(&response.finish_reason); + Json(CompletionResponse { + id: msg_id, + model: model_id, + message: response.message, + stop_reason, + usage: CompletionUsage { + input_tokens: response.usage.input_tokens, + output_tokens: response.usage.output_tokens, + }, + output, + }) + .into_response() + } Err(e) => ApiError::new(StatusCode::BAD_GATEWAY, format!("LLM error: {e}")) .into_response(), } } else { match client.complete(&request).await { - Ok(response) => Json(CompletionResponse { - id: response.id, - model: response.model, - message: convert_llm_message(&response.message), - stop_reason: finish_reason_to_api_stop_reason(&response.finish_reason), - usage: CompletionUsage { - input_tokens: response.usage.input_tokens, - output_tokens: response.usage.output_tokens, - }, - output: None, - }) - .into_response(), + Ok(response) => { + let stop_reason = finish_reason_to_api_stop_reason(&response.finish_reason); + Json(CompletionResponse { + id: response.id, + model: response.model, + message: response.message, + stop_reason, + usage: CompletionUsage { + input_tokens: response.usage.input_tokens, + output_tokens: response.usage.output_tokens, + }, + output: None, + }) + .into_response() + } Err(e) => ApiError::new(StatusCode::BAD_GATEWAY, format!("LLM error: {e}")) .into_response(), } diff --git a/lib/crates/fabro-server/src/server/handler/llm_sse.rs b/lib/crates/fabro-server/src/server/handler/llm_sse.rs new file mode 100644 index 000000000..01c6fbdd6 --- /dev/null +++ b/lib/crates/fabro-server/src/server/handler/llm_sse.rs @@ -0,0 +1,118 @@ +//! Shared SSE plumbing for endpoints that proxy LLM `StreamEvent`s. +//! +//! `POST /api/v1/completions` and `POST /api/v1/playground/chat` both run an +//! LLM stream and forward every `StreamEvent` to the browser as a +//! `stream_event` SSE frame. This module owns that framing so the two +//! endpoints cannot drift: serialization failures and stream errors are +//! shaped into the same `{"type": "error", ...}` frame vocabulary, the +//! stream ends when the LLM stream ends or the server shuts down, and a +//! `ping` keep-alive frame goes out every 15 seconds. + +use std::convert::Infallible; +use std::time::Duration; + +use axum::response::sse::{Event, KeepAlive, Sse}; +use axum::response::{IntoResponse, Response}; +use fabro_llm::types::StreamEvent; +use futures_util::{Stream, StreamExt}; +use serde_json::json; +use tokio_util::sync::CancellationToken; +use tracing::error; + +/// Forward LLM `StreamEvent`s as `stream_event` SSE frames until the LLM +/// stream ends or `shutdown` fires. +pub(super) fn stream_response( + stream: impl Stream<Item = Result<StreamEvent, fabro_llm::Error>> + Send + 'static, + shutdown: CancellationToken, +) -> Response { + let sse_stream = stream.map(|event| match event { + Ok(ref evt) => match serde_json::to_string(evt) { + Ok(json) => Ok::<_, Infallible>(Event::default().event("stream_event").data(json)), + Err(e) => Ok(Event::default().event("stream_event").data( + json!({ + "type": "error", + "error": {"Stream": {"message": format!("failed to serialize event: {e}")}}, + "raw": null + }) + .to_string(), + )), + }, + Err(e) => { + error!(error = %e, "LLM stream event error"); + Ok(Event::default().event("stream_event").data( + json!({ + "type": "error", + "error": {"Stream": {"message": e.to_string()}}, + "raw": null + }) + .to_string(), + )) + } + }); + let sse_stream = sse_stream.take_until(shutdown.cancelled_owned()); + + Sse::new(sse_stream) + .keep_alive( + KeepAlive::new().interval(Duration::from_secs(15)).event( + Event::default() + .event("ping") + .data(json!({"type": "ping"}).to_string()), + ), + ) + .into_response() +} + +#[cfg(test)] +mod tests { + use axum::body::to_bytes; + + use super::*; + + async fn body_text(response: Response) -> String { + let bytes = to_bytes(response.into_body(), usize::MAX) + .await + .expect("read SSE body"); + String::from_utf8(bytes.to_vec()).expect("SSE body is UTF-8") + } + + #[tokio::test] + async fn forwards_events_as_stream_event_frames() { + let stream = futures_util::stream::iter(vec![ + Ok(StreamEvent::StreamStart), + Ok(StreamEvent::TextDelta { + delta: "hi".to_string(), + text_id: None, + }), + ]); + let body = body_text(stream_response(stream, CancellationToken::new())).await; + + assert!(body.contains("event: stream_event"), "body: {body}"); + assert!(body.contains(r#""type":"stream_start""#), "body: {body}"); + assert!(body.contains(r#""delta":"hi""#), "body: {body}"); + } + + #[tokio::test] + async fn shapes_stream_errors_into_error_frames() { + let stream = futures_util::stream::iter(vec![Err(fabro_llm::Error::Interrupt { + message: "boom".to_string(), + })]); + let body = body_text(stream_response(stream, CancellationToken::new())).await; + + assert!(body.contains("event: stream_event"), "body: {body}"); + assert!(body.contains(r#""type":"error""#), "body: {body}"); + assert!(body.contains("boom"), "body: {body}"); + } + + #[tokio::test] + async fn shutdown_token_ends_the_stream() { + let shutdown = CancellationToken::new(); + shutdown.cancel(); + let stream = futures_util::stream::pending::<Result<StreamEvent, fabro_llm::Error>>(); + let body = body_text(stream_response(stream, shutdown)).await; + + assert!( + !body.contains("stream_event"), + "cancelled stream should emit no frames, body: {body}" + ); + } +} diff --git a/lib/crates/fabro-server/src/server/handler/mod.rs b/lib/crates/fabro-server/src/server/handler/mod.rs index 4dd5aa8dc..c1a5b35ce 100644 --- a/lib/crates/fabro-server/src/server/handler/mod.rs +++ b/lib/crates/fabro-server/src/server/handler/mod.rs @@ -15,8 +15,10 @@ mod environments; pub(in crate::server) mod events; pub(in crate::server) mod graph; pub(in crate::server) mod lifecycle; +mod llm_sse; mod models; mod pair; +mod playground; mod pull_requests; pub(in crate::server) mod runs; mod sandbox; @@ -182,6 +184,7 @@ pub(super) fn demo_routes() -> Router<Arc<AppState>> { .merge(graph::manifest_routes()) .merge(models::routes()) .merge(completions::routes()) + .merge(playground::routes()) } pub(super) fn real_routes() -> Router<Arc<AppState>> { @@ -223,4 +226,5 @@ pub(super) fn real_routes() -> Router<Arc<AppState>> { .merge(sessions::routes()) .merge(system::routes()) .merge(completions::routes()) + .merge(playground::routes()) } diff --git a/lib/crates/fabro-server/src/server/handler/playground.rs b/lib/crates/fabro-server/src/server/handler/playground.rs new file mode 100644 index 000000000..0b3111d90 --- /dev/null +++ b/lib/crates/fabro-server/src/server/handler/playground.rs @@ -0,0 +1,279 @@ +//! Playground chat endpoint. +//! +//! POST /api/v1/playground/chat drives a single turn of the chat-driven +//! workflow builder at /playground in fabro-web. The server is stateless +//! across turns: the browser owns the workflow draft and submits it as +//! the literal `workflow.fabro` contents with every request; the server +//! embeds the file in the system prompt, runs the LLM with a single +//! file-write tool surface, and streams the result back over SSE. The +//! browser parses the emitted `workflow.fabro` content, diffs it against +//! its current draft, and animates the resulting changes into the canvas. + +use std::sync::Arc; + +use serde_json::json; + +use super::super::{ + ApiError, AppState, CreatePlaygroundChatRequest, IntoResponse, Json, LlmMessage, LlmRequest, + RequiredUser, Response, Router, State, StatusCode, ToolChoice, ToolDefinition, error, info, + post, warn, +}; +use super::llm_sse; + +/// Sanity caps on a playground chat request. Axum's default 2 MB body +/// limit already catches gigabyte payloads at the framework layer; these +/// add cheap, descriptive 400s before we touch the LLM so a misbehaving +/// or malicious client can't drag a multi-megabyte transcript through +/// streaming + token-billing. +const MAX_MESSAGES_PER_TURN: usize = 50; +/// Generous ceiling for the submitted `workflow.fabro` text: the canvas +/// caps out around a hundred nodes, which renders to roughly 12 KB of +/// DOT. +const MAX_WORKFLOW_FABRO_BYTES: usize = 32 * 1024; + +fn validate_request(req: &CreatePlaygroundChatRequest) -> Result<(), ApiError> { + if req.messages.len() > MAX_MESSAGES_PER_TURN { + return Err(ApiError::new( + StatusCode::BAD_REQUEST, + format!( + "Conversation too long: {} messages (limit {MAX_MESSAGES_PER_TURN}). \ + Start a new playground session.", + req.messages.len(), + ), + )); + } + if req.workflow_fabro.len() > MAX_WORKFLOW_FABRO_BYTES { + return Err(ApiError::new( + StatusCode::BAD_REQUEST, + format!( + "Workflow file too large: {} bytes (limit {MAX_WORKFLOW_FABRO_BYTES}).", + req.workflow_fabro.len(), + ), + )); + } + Ok(()) +} + +pub(super) fn routes() -> Router<Arc<AppState>> { + Router::new().route("/playground/chat", post(create_playground_chat)) +} + +/// System prompt template. The `{workflow_fabro}` placeholder receives +/// the literal `workflow.fabro` contents submitted with the request. +const SYSTEM_PROMPT_TEMPLATE: &str = include_str!("prompts/playground_system.md"); + +const WORKFLOW_FABRO_PLACEHOLDER: &str = "{workflow_fabro}"; + +fn build_system_prompt(workflow_fabro: &str) -> String { + SYSTEM_PROMPT_TEMPLATE.replace(WORKFLOW_FABRO_PLACEHOLDER, workflow_fabro) +} + +/// The single file-write tool the model uses to update the workflow. +/// Each turn the model emits one call with the full new contents of +/// `workflow.fabro`. The browser parses the content, diffs it against +/// its current draft, and animates the resulting changes into the +/// canvas. +fn playground_tools() -> Vec<ToolDefinition> { + vec![ToolDefinition { + name: "write_workflow_file".into(), + description: "Write the full new contents of a workflow file. For the playground, only \ + `workflow.fabro` is meaningful — the model emits the complete DOT for the \ + current desired state of the workflow. The previous file is replaced \ + atomically; always include every node and edge, not just changes." + .into(), + parameters: json!({ + "type": "object", + "required": ["file_name", "content"], + "properties": { + "file_name": { + "type": "string", + "enum": ["workflow.fabro"], + "description": "Target file name. Currently only `workflow.fabro` is supported." + }, + "content": { + "type": "string", + "description": "Full DOT contents of the workflow file. Must be a complete `digraph <name> { ... }` block including `start` and `exit` terminals and every desired node and edge." + } + } + }), + }] +} + +async fn create_playground_chat( + _auth: RequiredUser, + State(state): State<Arc<AppState>>, + Json(req): Json<CreatePlaygroundChatRequest>, +) -> Response { + if let Err(e) = validate_request(&req) { + return e.into_response(); + } + + let catalog = state.catalog(); + let model_id = req + .model + .unwrap_or_else(|| catalog.default_model().id.clone()); + + info!(model = %model_id, "Playground chat turn"); + + // Request messages are already the canonical `fabro_types::Message` — + // the API schema reuses it via build.rs `with_replacement`. + let mut messages: Vec<LlmMessage> = Vec::new(); + messages.push(LlmMessage::system(build_system_prompt(&req.workflow_fabro))); + messages.extend(req.messages); + + let request = LlmRequest { + model: model_id, + messages, + provider: None, + tools: Some(playground_tools()), + tool_choice: Some(ToolChoice::Auto), + response_format: None, + temperature: None, + top_p: None, + max_tokens: None, + stop_sequences: None, + reasoning_effort: None, + speed: None, + metadata: None, + provider_options: None, + }; + + let llm_result = match state.resolve_llm_client().await { + Ok(r) => r, + Err(err) => { + error!(error = ?err, "playground: failed to create LLM client"); + return ApiError::new( + StatusCode::INTERNAL_SERVER_ERROR, + format!("Failed to create LLM client: {err}"), + ) + .into_response(); + } + }; + for (provider, issue) in &llm_result.auth_issues { + warn!(provider = %provider, error = %issue, "playground: provider auth issue"); + } + let client = llm_result.client; + + let stream_result = match client.stream(&request).await { + Ok(s) => s, + Err(e) => { + error!(error = ?e, "playground: LLM stream call failed"); + return ApiError::new(StatusCode::BAD_GATEWAY, format!("LLM error: {e}")) + .into_response(); + } + }; + + // Forward StreamEvents as `stream_event` SSE frames. The browser-side + // adapter listens for the `tool_call_end` event carrying the + // `write_workflow_file` arguments, parses the DOT, diffs it against + // its current draft, and animates the diff into the canvas. + llm_sse::stream_response(stream_result, state.shutdown_token()) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Welcome-state DOT, matching the client renderer's canonical style. + const WELCOME_DOT: &str = r#"digraph untitled { + start [shape=Mdiamond, label="Start"] + exit [shape=Msquare, label="Exit"] + + start -> exit +} +"#; + + #[test] + fn system_prompt_embeds_workflow_file_verbatim() { + let dot = r#"digraph release_notes { + graph [goal="Generate release notes"] + start [shape=Mdiamond, label="Start"] + exit [shape=Msquare, label="Exit"] + plan [shape=box, label="Plan", prompt="Plan it"] + + start -> plan + plan -> exit +} +"#; + let prompt = build_system_prompt(dot); + assert!(prompt.contains(dot), "prompt should embed the DOT verbatim"); + assert!(prompt.contains("digraph release_notes")); + assert!(prompt.contains("write_workflow_file")); + } + + #[test] + fn system_prompt_explains_empty_canvas_convention() { + let prompt = build_system_prompt(WELCOME_DOT); + assert!(prompt.contains("the canvas is empty")); + assert!(prompt.contains("digraph snake_case_name")); + assert!(prompt.contains(WELCOME_DOT)); + } + + #[test] + fn system_prompt_template_substitutes_its_placeholder() { + assert_eq!( + SYSTEM_PROMPT_TEMPLATE + .matches(WORKFLOW_FABRO_PLACEHOLDER) + .count(), + 1, + "template must contain the placeholder exactly once" + ); + let prompt = build_system_prompt(WELCOME_DOT); + assert!( + !prompt.contains(WORKFLOW_FABRO_PLACEHOLDER), + "placeholder must be substituted away" + ); + } + + fn make_request(messages_len: usize, workflow_fabro: String) -> CreatePlaygroundChatRequest { + let messages = (0..messages_len) + .map(|_| fabro_types::Message { + role: fabro_types::Role::User, + content: Vec::new(), + name: None, + tool_call_id: None, + }) + .collect(); + CreatePlaygroundChatRequest { + messages, + workflow_fabro, + model: None, + } + } + + #[test] + fn validate_rejects_oversize_message_history() { + let req = make_request(MAX_MESSAGES_PER_TURN + 1, WELCOME_DOT.to_string()); + let err = validate_request(&req).expect_err("expected too-many-messages error"); + assert_eq!(err.status(), StatusCode::BAD_REQUEST); + } + + #[test] + fn validate_rejects_oversize_workflow_file() { + let req = make_request(1, "x".repeat(MAX_WORKFLOW_FABRO_BYTES + 1)); + let err = validate_request(&req).expect_err("expected too-large-file error"); + assert_eq!(err.status(), StatusCode::BAD_REQUEST); + } + + #[test] + fn validate_accepts_normal_sized_requests() { + let req = make_request(10, WELCOME_DOT.to_string()); + assert!(validate_request(&req).is_ok()); + } + + #[test] + fn tool_surface_is_single_file_write_tool() { + let tools = playground_tools(); + assert_eq!(tools.len(), 1, "expected exactly one tool"); + let tool = &tools[0]; + assert_eq!(tool.name, "write_workflow_file"); + let params = serde_json::to_value(&tool.parameters).expect("serialize params"); + let required = params + .get("required") + .and_then(|r| r.as_array()) + .expect("required array"); + let required_names: Vec<&str> = required.iter().filter_map(|v| v.as_str()).collect(); + assert!(required_names.contains(&"file_name")); + assert!(required_names.contains(&"content")); + } +} diff --git a/lib/crates/fabro-server/src/server/handler/prompts/playground_system.md b/lib/crates/fabro-server/src/server/handler/prompts/playground_system.md new file mode 100644 index 000000000..7ccf5b4c1 --- /dev/null +++ b/lib/crates/fabro-server/src/server/handler/prompts/playground_system.md @@ -0,0 +1,49 @@ +You are Ask Fabro, helping the user build a Fabro workflow inside the /playground builder. Fabro workflows are Graphviz digraphs where each node's `shape` picks the handler: + +- box: agent (multi-turn LLM with tools — the default) +- tab: a single LLM call +- parallelogram: a shell script (use a `script` attribute) +- hexagon: a human gate (pause for review) +- diamond: a conditional branch (multiple outgoing edges with a `condition`) +- component: fan-out parallel +- tripleoctagon: merge parallel +- house: a sub-workflow + +To update the workflow, call the `write_workflow_file` tool exactly once per turn with the full new contents of `workflow.fabro`. The file you write REPLACES the previous one — always emit the complete workflow, even nodes and edges that didn't change. + +Always include a brief one-line acknowledgement before the tool call so the chat doesn't feel silent — something like "Built the lint/test/PR pipeline." or "Added the fix-and-retry loop.". Keep it to one sentence; the canvas shows the details. + +DOT template: + +``` +digraph snake_case_name { + graph [goal="One-sentence goal."] + rankdir=LR + + start [shape=Mdiamond, label="Start"] + exit [shape=Msquare, label="Exit"] + + plan [shape=box, label="Plan", prompt="Plan the work."] + implement [shape=box, label="Implement", prompt="..."] + + start -> plan + plan -> implement + implement -> exit +} +``` + +Rules: + +- snake_case node ids (e.g. `run_tests`, `open_pr`). +- `start` (shape=Mdiamond) and `exit` (shape=Msquare) are reserved terminals — always present, never renamed, never have prompts. +- Pick a clear snake_case name for the digraph (the `digraph <name>` token) as soon as the user's intent is obvious. +- Preserve existing node ids across turns. Only invent a new id for a genuinely new node — don't rename `lint` to `lint_step` just because you're regenerating the file. +- Every user-added node must be on a path from `start` to `exit`. +- For `diamond` branches, give each outgoing edge a `condition` attribute (e.g. `gate -> happy_path [condition="outcome=approved"]`). +- Escape `\` and `"` inside attribute strings. + +Current `workflow.fabro` (exactly the file you are rewriting; if it only contains the `start` and `exit` terminals, the canvas is empty and you are building the user's first nodes): + +``` +{workflow_fabro} +``` diff --git a/lib/crates/fabro-types/src/lib.rs b/lib/crates/fabro-types/src/lib.rs index f1a8c8b39..86f1abbee 100644 --- a/lib/crates/fabro-types/src/lib.rs +++ b/lib/crates/fabro-types/src/lib.rs @@ -158,8 +158,8 @@ pub use system_integrations::{ pub use timing::{RunTiming, StageTiming}; pub use todo::{TodoListKind, TodoListProjection, TodoPatch, TodoProjection, TodoStatus}; pub use transcript::{ - AudioData, ContentPart, DocumentData, ImageData, MessageId, MessageKind, MessageSource, - PairMessageRef, ThinkingData, ToolCall, ToolResult, TranscriptMessage, + AudioData, ContentPart, DocumentData, ImageData, Message, MessageId, MessageKind, + MessageSource, PairMessageRef, Role, ThinkingData, ToolCall, ToolResult, TranscriptMessage, }; pub use variable::{ CreateVariableRequest, UpdateVariableRequest, Variable, VariableListResponse, is_env_style_name, diff --git a/lib/crates/fabro-types/src/transcript.rs b/lib/crates/fabro-types/src/transcript.rs index 8dcd3971b..a9969e34e 100644 --- a/lib/crates/fabro-types/src/transcript.rs +++ b/lib/crates/fabro-types/src/transcript.rs @@ -253,6 +253,98 @@ impl ContentPart { } } +// --- Role / Message +// ----------------------------------------------------------- + +/// Author role of a chat [`Message`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum Role { + System, + User, + Assistant, + Tool, + Developer, +} + +/// Provider-neutral chat message exchanged with an LLM. +/// +/// This is the request/response message shape shared by `fabro-llm` +/// requests and the completions API wire contract. The durable +/// session-transcript record is [`TranscriptMessage`], which carries +/// identity, provenance, and usage on top of the same [`ContentPart`] +/// vocabulary. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Message { + pub role: Role, + pub content: Vec<ContentPart>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub name: Option<String>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub tool_call_id: Option<String>, +} + +impl Message { + pub fn system(text: impl Into<String>) -> Self { + Self { + role: Role::System, + content: vec![ContentPart::text(text)], + name: None, + tool_call_id: None, + } + } + + pub fn user(text: impl Into<String>) -> Self { + Self { + role: Role::User, + content: vec![ContentPart::text(text)], + name: None, + tool_call_id: None, + } + } + + pub fn assistant(text: impl Into<String>) -> Self { + Self { + role: Role::Assistant, + content: vec![ContentPart::text(text)], + name: None, + tool_call_id: None, + } + } + + pub fn tool_result( + tool_call_id: impl Into<String>, + content: serde_json::Value, + is_error: bool, + ) -> Self { + let id = tool_call_id.into(); + Self { + role: Role::Tool, + content: vec![ContentPart::ToolResult(ToolResult { + tool_call_id: id.clone(), + content, + is_error, + image_data: None, + image_media_type: None, + })], + name: None, + tool_call_id: Some(id), + } + } + + /// Concatenates text from all text content parts. + #[must_use] + pub fn text(&self) -> String { + self.content + .iter() + .filter_map(|part| match part { + ContentPart::Text(text) => Some(text.as_str()), + _ => None, + }) + .collect() + } +} + // --- TranscriptMessage ------------------------------------------------------ /// Provider/model-role semantics for a committed transcript message. diff --git a/lib/packages/fabro-api-client/src/.openapi-generator/FILES b/lib/packages/fabro-api-client/src/.openapi-generator/FILES index 5e97116d9..086b3ef9a 100644 --- a/lib/packages/fabro-api-client/src/.openapi-generator/FILES +++ b/lib/packages/fabro-api-client/src/.openapi-generator/FILES @@ -10,6 +10,7 @@ api/insights-api.ts api/install-api.ts api/integrations-api.ts api/models-api.ts +api/playground-api.ts api/repos-api.ts api/run-internals-api.ts api/run-outputs-api.ts @@ -96,6 +97,7 @@ models/conclusion.ts models/create-automation-request.ts models/create-completion-request.ts models/create-environment-request.ts +models/create-playground-chat-request.ts models/create-run-pull-request-request.ts models/create-run-session-request.ts models/create-secret-request.ts diff --git a/lib/packages/fabro-api-client/src/api.ts b/lib/packages/fabro-api-client/src/api.ts index 3dd9c3391..a7bc99437 100644 --- a/lib/packages/fabro-api-client/src/api.ts +++ b/lib/packages/fabro-api-client/src/api.ts @@ -25,6 +25,7 @@ export * from './api/insights-api'; export * from './api/install-api'; export * from './api/integrations-api'; export * from './api/models-api'; +export * from './api/playground-api'; export * from './api/repos-api'; export * from './api/run-internals-api'; export * from './api/run-outputs-api'; diff --git a/lib/packages/fabro-api-client/src/api/playground-api.ts b/lib/packages/fabro-api-client/src/api/playground-api.ts new file mode 100644 index 000000000..c6843ab4f --- /dev/null +++ b/lib/packages/fabro-api-client/src/api/playground-api.ts @@ -0,0 +1,132 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * Fabro Run API + * HTTP API for managing Fabro workflow run executions. + * + * The version of the OpenAPI document: 0.1.0 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + + +import type { Configuration } from '../configuration'; +import type { AxiosPromise, AxiosInstance, RawAxiosRequestConfig } from 'axios'; +import globalAxios from 'axios'; +// Some imports not used depending on template conditions +// @ts-ignore +import { DUMMY_BASE_URL, assertParamExists, setApiKeyToObject, setBasicAuthToObject, setBearerAuthToObject, setOAuthToObject, setSearchParams, serializeDataIfNeeded, toPathString, createRequestFunction, replaceWithSerializableTypeIfNeeded } from '../common'; +// @ts-ignore +import { BASE_PATH, COLLECTION_FORMATS, type RequestArgs, BaseAPI, RequiredError, operationServerMap } from '../base'; +// @ts-ignore +import type { CreatePlaygroundChatRequest } from '../models'; +// @ts-ignore +import type { ErrorResponse } from '../models'; +/** + * PlaygroundApi - axios parameter creator + */ +export const PlaygroundApiAxiosParamCreator = function (configuration?: Configuration) { + return { + /** + * Drives a single turn of the playground chat that builds a workflow graph incrementally. The server is stateless: each request includes the full current draft, and the response streams text deltas plus a single `write_workflow_file` tool call carrying the full new contents of `workflow.fabro` for the client to parse, diff against its local draft, and animate into the canvas. Responses are always SSE. Frames use `event: stream_event` with a JSON-serialized StreamEvent payload — see /api/v1/completions for the StreamEvent shape. The tool call arrives on a `tool_call_end` event with the tool name and parsed JSON arguments. + * @summary Chat with the playground assistant + * @param {CreatePlaygroundChatRequest} createPlaygroundChatRequest + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + createPlaygroundChat: async (createPlaygroundChatRequest: CreatePlaygroundChatRequest, options: RawAxiosRequestConfig = {}): Promise<RequestArgs> => { + // verify required parameter 'createPlaygroundChatRequest' is not null or undefined + assertParamExists('createPlaygroundChat', 'createPlaygroundChatRequest', createPlaygroundChatRequest) + const localVarPath = `/api/v1/playground/chat`; + // use dummy base URL string because the URL constructor only accepts absolute URLs. + const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); + let baseOptions; + if (configuration) { + baseOptions = configuration.baseOptions; + } + + const localVarRequestOptions = { method: 'POST', ...baseOptions, ...options}; + const localVarHeaderParameter = {} as any; + const localVarQueryParameter = {} as any; + + // authentication SessionCookie required + + // authentication BearerAuth required + // http bearer authentication required + await setBearerAuthToObject(localVarHeaderParameter, configuration) + + localVarHeaderParameter['Content-Type'] = 'application/json'; + localVarHeaderParameter['Accept'] = 'application/json'; + + setSearchParams(localVarUrlObj, localVarQueryParameter); + let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; + localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; + localVarRequestOptions.data = serializeDataIfNeeded(createPlaygroundChatRequest, localVarRequestOptions, configuration) + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, + } +}; + +/** + * PlaygroundApi - functional programming interface + */ +export const PlaygroundApiFp = function(configuration?: Configuration) { + const localVarAxiosParamCreator = PlaygroundApiAxiosParamCreator(configuration) + return { + /** + * Drives a single turn of the playground chat that builds a workflow graph incrementally. The server is stateless: each request includes the full current draft, and the response streams text deltas plus a single `write_workflow_file` tool call carrying the full new contents of `workflow.fabro` for the client to parse, diff against its local draft, and animate into the canvas. Responses are always SSE. Frames use `event: stream_event` with a JSON-serialized StreamEvent payload — see /api/v1/completions for the StreamEvent shape. The tool call arrives on a `tool_call_end` event with the tool name and parsed JSON arguments. + * @summary Chat with the playground assistant + * @param {CreatePlaygroundChatRequest} createPlaygroundChatRequest + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async createPlaygroundChat(createPlaygroundChatRequest: CreatePlaygroundChatRequest, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<void>> { + const localVarAxiosArgs = await localVarAxiosParamCreator.createPlaygroundChat(createPlaygroundChatRequest, options); + const localVarOperationServerIndex = configuration?.serverIndex ?? 0; + const localVarOperationServerBasePath = operationServerMap['PlaygroundApi.createPlaygroundChat']?.[localVarOperationServerIndex]?.url; + return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath); + }, + } +}; + +/** + * PlaygroundApi - factory interface + */ +export const PlaygroundApiFactory = function (configuration?: Configuration, basePath?: string, axios?: AxiosInstance) { + const localVarFp = PlaygroundApiFp(configuration) + return { + /** + * Drives a single turn of the playground chat that builds a workflow graph incrementally. The server is stateless: each request includes the full current draft, and the response streams text deltas plus a single `write_workflow_file` tool call carrying the full new contents of `workflow.fabro` for the client to parse, diff against its local draft, and animate into the canvas. Responses are always SSE. Frames use `event: stream_event` with a JSON-serialized StreamEvent payload — see /api/v1/completions for the StreamEvent shape. The tool call arrives on a `tool_call_end` event with the tool name and parsed JSON arguments. + * @summary Chat with the playground assistant + * @param {CreatePlaygroundChatRequest} createPlaygroundChatRequest + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + createPlaygroundChat(createPlaygroundChatRequest: CreatePlaygroundChatRequest, options?: RawAxiosRequestConfig): AxiosPromise<void> { + return localVarFp.createPlaygroundChat(createPlaygroundChatRequest, options).then((request) => request(axios, basePath)); + }, + }; +}; + +/** + * PlaygroundApi - object-oriented interface + */ +export class PlaygroundApi extends BaseAPI { + /** + * Drives a single turn of the playground chat that builds a workflow graph incrementally. The server is stateless: each request includes the full current draft, and the response streams text deltas plus a single `write_workflow_file` tool call carrying the full new contents of `workflow.fabro` for the client to parse, diff against its local draft, and animate into the canvas. Responses are always SSE. Frames use `event: stream_event` with a JSON-serialized StreamEvent payload — see /api/v1/completions for the StreamEvent shape. The tool call arrives on a `tool_call_end` event with the tool name and parsed JSON arguments. + * @summary Chat with the playground assistant + * @param {CreatePlaygroundChatRequest} createPlaygroundChatRequest + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + public createPlaygroundChat(createPlaygroundChatRequest: CreatePlaygroundChatRequest, options?: RawAxiosRequestConfig) { + return PlaygroundApiFp(this.configuration).createPlaygroundChat(createPlaygroundChatRequest, options).then((request) => request(this.axios, this.basePath)); + } +} diff --git a/lib/packages/fabro-api-client/src/models/create-playground-chat-request.ts b/lib/packages/fabro-api-client/src/models/create-playground-chat-request.ts new file mode 100644 index 000000000..d0a12f33c --- /dev/null +++ b/lib/packages/fabro-api-client/src/models/create-playground-chat-request.ts @@ -0,0 +1,36 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * Fabro Run API + * HTTP API for managing Fabro workflow run executions. + * + * The version of the OpenAPI document: 0.1.0 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + + +// May contain unused imports in some cases +// @ts-ignore +import type { CompletionMessage } from './completion-message'; + +/** + * Body of POST /api/v1/playground/chat. The server is stateless across turns: the browser owns the draft and submits it as the literal `workflow.fabro` contents with every turn. The server embeds the file in the model\'s system prompt and exposes a single `write_workflow_file` tool that emits the full new contents of `workflow.fabro`; the browser parses, diffs, and animates the result. + */ +export interface CreatePlaygroundChatRequest { + /** + * assistant-ui-style message history for the turn. + */ + 'messages': Array<CompletionMessage>; + /** + * Full current `workflow.fabro` (Graphviz DOT) contents as rendered by the client — a complete `digraph <name> { ... }` block including the `start` / `exit` terminals. This is the same format the model writes back via `write_workflow_file`. + */ + 'workflow_fabro': string; + /** + * Model id or alias. Server picks the default if omitted. + */ + 'model'?: string; +} diff --git a/lib/packages/fabro-api-client/src/models/index.ts b/lib/packages/fabro-api-client/src/models/index.ts index 87b9c189c..d0d41577c 100644 --- a/lib/packages/fabro-api-client/src/models/index.ts +++ b/lib/packages/fabro-api-client/src/models/index.ts @@ -69,6 +69,7 @@ export * from './conclusion'; export * from './create-automation-request'; export * from './create-completion-request'; export * from './create-environment-request'; +export * from './create-playground-chat-request'; export * from './create-run-pull-request-request'; export * from './create-run-session-request'; export * from './create-secret-request';