fabro/apps/fabro-web/app/components/playground/chat/runtime.ts
Scott Werner d590122531
feat: chat-driven workflow builder at /playground (#450)
## Summary

Adds a new `/playground` route where users build a Fabro workflow by
chatting with Ask Fabro on the right while watching a live canvas
re-render on the left. The workflow can be downloaded as a `.fabro.zip`
or — eventually — launched as a real Fabro run; today the "Run for
real" button POSTs to `/api/v1/runs` and redirects to the resulting
`/runs/{id}` page, with a placeholder project/repo/folder picker.

The feature is built as a standalone component subtree under
`apps/fabro-web/app/components/playground/` with no `AppShell` or
`react-router` dependencies, so it can be re-embedded in other contexts
later by passing `chatEndpoint`, `authMode`, and an optional
`realRunRedirect` prop.

## What changed

**Frontend (`apps/fabro-web/`)**

- New `/playground` route + `<Playground>` component tree.
- Live SVG canvas via `@viz-js/viz` with click-to-inspect (read-only
  node detail panel), pan, zoom, fit-to-window, and a simulated walk
  through the graph driven by a Play button.
- Docked chat sidebar (assistant-ui) wired to the new
  `/api/v1/playground/chat` endpoint, with auto-retry on parse failure
  and a playground-specific tool-call summary that reads
  `Wrote workflow.fabro (N nodes, M edges)`.
- File tabs (`workflow.fabro` / `workflow.toml` / `README.md`),
  `.fabro.zip` download via `fflate`, and a "Run for real" toolbar
  button that POSTs an inline `RunManifest` to `/api/v1/runs`.
- Draft persists across page refreshes via `localStorage`.

**Backend (`lib/crates/fabro-server/`)**

- New `POST /api/v1/playground/chat` SSE endpoint. Server is stateless
  across turns: each request carries the full draft, the server runs
  the LLM with a single `write_workflow_file` tool, streams
  `StreamEvent` frames back, and lets the client own diffing/animating
  the result into the canvas.
- Request-size caps before the LLM call (50 messages, 100 nodes, 200
  edges) so a misbehaving or malicious client can't drag multi-MB
  transcripts through token billing.

**Spec / wire contract**

- OpenAPI: new `playground/chat` operation + four new schemas
  (`CreatePlaygroundChatRequest`, `PlaygroundWorkflowDraft`,
  `PlaygroundWorkflowNode`, `PlaygroundWorkflowEdge`).
- `lib/packages/fabro-api-client` not regenerated yet (the playground
  uses raw `fetch`); reviewers who want the TS client to pick up the
  new types can run `bun run generate` in that package.

## Key design decisions

1. **Single `write_workflow_file` tool, not six per-op tools.** The
   first cut exposed `add_node`/`update_node`/`connect`/etc. as
   discrete tool calls. The model would routinely add nodes without
   wiring them up, leaving the canvas in a broken half-state. Pivoted
   to a single tool that takes the full new `workflow.fabro` content;
   the browser parses the DOT, diffs it against the local draft, and
   animates the resulting reducer ops in. The model only has to "get
   the file right", and the canvas still paints node-by-node thanks
   to the client-side animator.

2. **Stateless server.** Each chat turn POSTs the full current draft;
   nothing is persisted server-side. Keeps the endpoint cheap, makes
   refresh-resumption trivial (browser owns the truth), and means the
   same endpoint can later sit behind a rate-limited anonymous variant
   without growing per-session state.

3. **Standalone component subtree.** `<Playground>` has no
   `AppShell`/router/store dependencies. All cross-cutting concerns
   flow in as props (`chatEndpoint`, `authMode`, `realRunRedirect`).
   This is the structural hook that makes future re-embedding possible
   without a refactor.

4. **Chat is the only mutation path.** Click-to-inspect on the canvas
   is read-only. Bi-directional canvas editing was explicitly cut from
   scope to keep one source of truth for "how the workflow changed."

5. **Inline `RunManifest` instead of temp-dir-then-clone.** The
   playground has no project to run against, so the `Run for real`
   modal builds a `RunManifest` that carries the full DOT and
   `workflow.toml` source inline (`workflows[key].{source, config}`).
   `cwd` is pinned to a fixed `/tmp/fabro-playground` constant — no
   LLM-controlled segment in a filesystem-looking field.

6. **React effects policy compliance.** All `useEffect` calls in
   playground component code go through the existing primitives in
   `app/hooks/effects.ts` (`useDocumentEvent`, `useInterval`) or a
   purpose-named hook (`useCanvasRender`).

## Still outstanding (planned follow-ups)

- [ ] **Actually kicking off the ad-hoc run.** "Run for real" today
      POSTs a manifest with a placeholder project/repo/folder
      fieldset. The intent is to reuse the project-picker pattern
      being introduced on the in-flight automations branch — once
      that pattern lands, the disabled inputs in
      `run-for-real-modal.tsx` become the live surface.
- [ ] **Header link to `/playground`.** No nav entry yet; users have
      to type the URL directly.
- [ ] **Live SSE-driven canvas overlay** via
      `GET /api/v1/runs/{id}/attach` — currently the modal redirects
      to the standard run-view page; the "watch it build on the
      playground canvas" experience comes when the `stage.*` events
      are wired through.
- [ ] **Regenerate `lib/packages/fabro-api-client`** so the new types
      ship to TS consumers.
- [ ] **Smoke test:** end-to-end download → unzip →
      `fabro run <name>` round-trip.
- [ ] **`scripts/build.ts` dist-symlink bug:** `pruneOldBuilds` can
      delete the directory `apps/fabro-web/dist` points at, which
      pins the dev server in 503 "build in progress" forever.
      Workaround documented; the real fix is a separate PR.

## Test plan

- [ ] `cd apps/fabro-web && bun run test app/components/playground/` —
111 tests pass
- [ ] `cd apps/fabro-web && bun run typecheck` — clean
- [ ] `cargo test -p fabro-server playground` — 6 tests pass
- [ ] Visit `/playground`; the canvas renders the welcome `start → ??? →
exit` ghost.
- [ ] Type "build me a release-notes workflow" in chat; nodes/edges
animate in; ack reads `Wrote workflow.fabro (N nodes, M edges)`.
- [ ] Click a node → inspector panel populates; click empty canvas →
deselects.
- [ ] Click `Simulate`; nodes light up `start → ... → exit` along the
resolved path.
- [ ] Click `Download .fabro`; unzip; `cd <unzipped> && fabro run
<name>` runs locally.
- [ ] Click `Run for real` → modal opens → confirm → POST succeeds →
redirected to `/runs/{id}` → run executes.
- [ ] Refresh the page; the draft persists from localStorage.
- [ ] Click `Start over` → `Yes`; canvas resets to welcome state.

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-09 11:24:56 -04:00

361 lines
11 KiB
TypeScript

/**
* 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");
}