| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| 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"; |
|
|
| |
| |
| |
| |
| |
| |
| |
| 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<string, unknown>; |
| output?: string; |
| thinkMs?: [number, number]; |
| } |
| | { kind: "pause"; ms: [number, number] }; |
|
|
| |
| |
| |
| |
| |
| |
| 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"); |
| } |
| } |
| } |
|
|
| |
| |
| |
| |
| |
| |
| 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<Record<string, unknown>>; |
| } = { |
| id: `demo-ea-${nowTag}`, |
| role: "assistant", |
| parts: [], |
| }; |
| const messages: Array<unknown> = []; |
|
|
| 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<string, unknown> = { |
| 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]); |
| } |
| } |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| 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; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| 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"); |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| 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], |
| }, |
| ]); |
|
|
| |
| |
| |
| 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); |
| } |
|
|