/** * Embed Studio scene engine for the showcase demo. * * Builds an "agent-picks-a-chart" beat that the viewer sees as: * - Alice clicks "Create chart" in the empty banner placeholder * - Studio opens with a real data file already in the sidebar * - She types a prompt, fake agent streams text + tool bubbles * (`listDataFiles`, `createEmbed`) through `__demo-embed-chat` * - A pre-built neural network banner is dropped into the doc via * `__demo-set-banner` * - Studio closes * * The whole thing is orchestrated through dev-only window events * (`__demo-embed-data`, `__demo-embed-chat`, `__demo-set-banner`) * that `App.tsx` subscribes to in dev mode. */ import type { Page } from "playwright"; import { humanType, pause } from "../human-typing.js"; import { humanClick } from "./mouse-cursor.js"; import { ARCHITECTURE_JSON, ARCHITECTURE_JSON_META, MODEL_ACCURACY_CSV, MODEL_ACCURACY_CSV_META, NEURAL_NETWORK_BANNER_HTML, } from "../banners.js"; /** * A single beat of an Embed Studio chat scene. The scene engine builds * the `messages` array incrementally and re-dispatches the whole tree * through `__demo-embed-chat` after every mutation so the UI animates * streaming text, running spinners and fulfilled tool bubbles exactly * like a real agent round-trip. */ type EmbedSceneStep = | { kind: "user-text"; text: string } | { kind: "assistant-text"; text: string; chunkMs?: [number, number]; chunkSize?: [number, number]; } | { kind: "assistant-tool"; toolName: string; input?: Record; output?: string; thinkMs?: [number, number]; } | { kind: "pause"; ms: [number, number] }; /** * Seed a file in the EmbedDataStore through the dev-only * `__demo-embed-data` window event. Used before opening the Embed * Studio so the FilesSidebar shows a realistic dataset the agent * can then "read" via tool calls. */ async function seedEmbedDataFile( page: Page, file: { name: string; content: string; ext?: string; columns?: string[]; rowCount?: number; uploader?: string; }, ) { await page.evaluate((f) => { window.dispatchEvent( new CustomEvent("__demo-embed-data", { detail: { file: f } }), ); }, file); } async function closeEmbedStudio(page: Page) { const closeBtn = page.getByRole("button", { name: /^Save & Close$/i }).first(); if (await closeBtn.isVisible().catch(() => false)) { await humanClick(page, closeBtn); } else { const xBtn = page.getByRole("button", { name: /Close Embed Studio/i }).first(); if (await xBtn.isVisible().catch(() => false)) { await humanClick(page, xBtn); } else { await page.keyboard.press("Escape"); } } } /** * Play a series of `EmbedSceneStep` beats against the Embed Studio * chat. Streams assistant text token-by-token and flips tool parts * from `input-available` (spinner) to `output-available` (done check) * so the viewer sees live tool-call bubbles, not just a text blob. */ async function runEmbedScene(page: Page, steps: EmbedSceneStep[]) { const nowTag = Date.now().toString(36); const userMessage: { id: string; role: "user"; parts: Array<{ type: "text"; text: string }>; } = { id: `demo-eu-${nowTag}`, role: "user", parts: [], }; const assistantMessage: { id: string; role: "assistant"; parts: Array>; } = { id: `demo-ea-${nowTag}`, role: "assistant", parts: [], }; const messages: Array = []; const ensureAssistant = () => { if (!messages.includes(assistantMessage)) messages.push(assistantMessage); }; const dispatch = async () => { const snapshot = JSON.parse(JSON.stringify(messages)); await page.evaluate((msgs) => { window.dispatchEvent( new CustomEvent("__demo-embed-chat", { detail: { messages: msgs } }), ); }, snapshot); }; for (const step of steps) { if (step.kind === "user-text") { userMessage.parts.push({ type: "text", text: step.text }); if (!messages.includes(userMessage)) messages.unshift(userMessage); await dispatch(); await pause(260, 420); } else if (step.kind === "assistant-text") { ensureAssistant(); const textPart: { type: "text"; text: string } = { type: "text", text: "", }; assistantMessage.parts.push(textPart); const tokens = step.text.split(/(\s+)/).filter((t) => t.length > 0); const [chunkMin, chunkMax] = step.chunkSize ?? [1, 2]; const [delayMin, delayMax] = step.chunkMs ?? [22, 55]; let i = 0; while (i < tokens.length) { const take = chunkMin + Math.floor(Math.random() * Math.max(1, chunkMax - chunkMin + 1)); textPart.text += tokens.slice(i, i + take).join(""); i += take; await dispatch(); await pause(delayMin, delayMax); } } else if (step.kind === "assistant-tool") { ensureAssistant(); const callId = `demo-tc-${nowTag}-${Math.random().toString(36).slice(2, 6)}`; const toolPart: Record = { type: `tool-${step.toolName}`, toolCallId: callId, state: "input-available", input: step.input ?? {}, }; assistantMessage.parts.push(toolPart); await dispatch(); await pause(step.thinkMs?.[0] ?? 520, step.thinkMs?.[1] ?? 900); toolPart.state = "output-available"; toolPart.output = step.output ?? "done"; await dispatch(); await pause(160, 280); } else if (step.kind === "pause") { await pause(step.ms[0], step.ms[1]); } } } /** * Click "Create chart" in the empty banner placeholder, seed a small * CSV into the EmbedDataStore so the FilesSidebar shows a live file, * type a prompt in the Embed Studio, then play a tool-aware fake * agent scene (`listDataFiles` + `createEmbed`) that drops a pre- * built neural network banner once the tool bubbles animate through. * * The banner filename stays `banner.html` (protected by the agent), * but the tool chips, subtitle and FilesSidebar make the whole beat * match the current Embed Studio interface instead of feeling like * a two-line placeholder. */ export async function createNeuralNetworkBanner(page: Page, speed: number) { const createBtn = page.getByRole("button", { name: /Create chart/i }).first(); try { await createBtn.waitFor({ state: "visible", timeout: 5_000 }); } catch { console.warn('[alice] "Create chart" button not found, skipping banner'); return; } // Seed two data files BEFORE opening the studio so the FilesSidebar // shows existing datasets the agent can reference. Three purposes: // 1. visually establishes that the studio now handles data files; // 2. gives the listDataFiles tool call a real output to surface; // 3. one of the files (architecture.json) is directly relevant to // the banner the agent is about to build, so the demo can show // the agent reading the data, extracting the layer topology, // and feeding it back into the visualization rather than just // ignoring the dataset and dropping a hardcoded picture. await seedEmbedDataFile(page, { name: ARCHITECTURE_JSON_META.name, ext: ARCHITECTURE_JSON_META.ext, content: ARCHITECTURE_JSON, columns: ARCHITECTURE_JSON_META.columns, rowCount: ARCHITECTURE_JSON_META.rowCount, uploader: "alice", }); await seedEmbedDataFile(page, { name: MODEL_ACCURACY_CSV_META.name, ext: MODEL_ACCURACY_CSV_META.ext, content: MODEL_ACCURACY_CSV, columns: MODEL_ACCURACY_CSV_META.columns, rowCount: MODEL_ACCURACY_CSV_META.rowCount, uploader: "alice", }); await createBtn.scrollIntoViewIfNeeded(); await pause(120, 220); await humanClick(page, createBtn); const promptArea = page.getByPlaceholder("Describe your chart..."); const promptText = "A neural network banner that captures the spirit of the article. Use the architecture I uploaded to drive the layout - layered graph with soft pulses flowing along the synapses, like attention scores rippling between tokens."; try { await promptArea.waitFor({ state: "visible", timeout: 4_000 }); await pause(260, 420); await humanClick(page, promptArea); await pause(180, 280); await humanType(page, promptText, { speed: speed * 1.6, typoRate: 0, thinkRate: 0.02, }); await pause(180, 320); await promptArea.fill(""); } catch { console.warn("[alice] embed studio textarea not found, faking anyway"); } // The scene mirrors a realistic agent round-trip and narrates // the artifact we're actually about to inject, so when the // viewer flips to the preview everything the agent promised is // visibly there: 5 layers with the exact counts, pulses on a // random synapse, transparent background, retinted via // `--primary-color`. // // The sequence is: // readEmbed - look at what's already in the banner slot // (placeholder HTML from "Create chart" click) // listDataFiles + readDataFile - confirm there IS a dataset // but decide it's better saved for inline charts // createEmbed - write the real banner with a descriptive // filename and title, and a spec that matches // the actual NEURAL_NETWORK_BANNER_HTML we ship const csvPreviewOutput = `${MODEL_ACCURACY_CSV_META.columns.join(",")}\n` + MODEL_ACCURACY_CSV.split("\n").slice(1, 4).join("\n") + "\n... 5 more rows"; await runEmbedScene(page, [ { kind: "user-text", text: promptText }, { kind: "assistant-text", text: "Good - a hero banner. Let me look at what's already in the slot and what data you've uploaded so I can pick the right shape.", chunkMs: [22, 48], }, { kind: "assistant-tool", toolName: "readEmbed", input: { src: "banner.html" }, output: "banner.html is the placeholder stub (14 lines). Safe to replace.", thinkMs: [420, 640], }, { kind: "assistant-tool", toolName: "listDataFiles", input: {}, output: `- ${ARCHITECTURE_JSON_META.name} (json, ${ARCHITECTURE_JSON.length} bytes) ` + `keys=[${ARCHITECTURE_JSON_META.columns.join(", ")}]\n` + `- ${MODEL_ACCURACY_CSV_META.name} (csv, ${MODEL_ACCURACY_CSV.length} bytes) ` + `rows=${MODEL_ACCURACY_CSV_META.rowCount} ` + `columns=[${MODEL_ACCURACY_CSV_META.columns.join(", ")}]`, thinkMs: [440, 680], }, { kind: "assistant-text", text: ` Two files. \`${ARCHITECTURE_JSON_META.name}\` looks directly relevant - that's what should drive the topology. Reading it first.`, chunkMs: [20, 46], }, { kind: "assistant-tool", toolName: "readDataFile", input: { name: ARCHITECTURE_JSON_META.name }, output: ARCHITECTURE_JSON, thinkMs: [520, 780], }, { kind: "assistant-text", text: " Got it - five layers (tokens 6 → embedding 10 → attention 14 → values 10 → logits 6), fully-connected, softmax activation. That maps perfectly to a layered graph.", chunkMs: [22, 50], chunkSize: [1, 3], }, { kind: "assistant-tool", toolName: "readDataFile", input: { name: MODEL_ACCURACY_CSV_META.name, limit: 4 }, output: csvPreviewOutput, thinkMs: [380, 560], }, { kind: "assistant-text", text: " The accuracy CSV is great for an inline chart later in the article, but for the hero I'll stick with the architecture diagram.", chunkMs: [22, 48], }, { kind: "assistant-text", text: " Plan: nodes spaced per layer count from the JSON, edges tracing every fully-connected pair, soft dots traveling a random synapse every ~200ms so the network feels active without competing with the title. Pure SVG, transparent background, respects `prefers-reduced-motion`.", chunkMs: [22, 48], chunkSize: [1, 3], }, { kind: "assistant-tool", toolName: "createEmbed", input: { filename: "banner.html", title: "Neural network banner", description: "Layered graph derived from architecture.json (5 layers: 6/10/14/10/6, fully-connected). Pulses flow along random synapses; pure SVG, transparent background.", }, output: "wrote banner.html (182 lines, 3.8 KB, 0 external deps)", thinkMs: [720, 1_080], }, { kind: "assistant-text", text: " Done - layer counts come from the JSON, the hero container drives the size, and the network rerolls a new burst pattern every cycle.", chunkMs: [22, 50], }, ]); // Drop the real banner HTML right after the tool bubbles resolve so // the iframe flips to the final illustration in sync with the // agent announcing "Done". await page.evaluate( (html) => { window.dispatchEvent( new CustomEvent("__demo-set-banner", { detail: { html } }), ); }, NEURAL_NETWORK_BANNER_HTML, ); await pause(600, 900); await closeEmbedStudio(page); await pause(150, 280); }