| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| import { runAlice } from "./alice.js"; |
| import { runBob } from "./bob.js"; |
| import { runCarol } from "./carol.js"; |
| import { resetDoc } from "./lib/editor-actions.js"; |
| import { PERSONAS } from "./personas.js"; |
| import { |
| killLeftoverChromiums, |
| launchPersona, |
| shutdownAllPersonas, |
| type PersonaHandle, |
| } from "./lib/chromium.js"; |
| import { |
| buildRecordingPath, |
| recordingCountdown, |
| startScreenRecording, |
| type ScreenRecording, |
| } from "./lib/recording.js"; |
| import { connectPersona, type PersonaHandle as YjsPersona } from "./lib/yjs-client.js"; |
| import type { Page } from "playwright"; |
|
|
| |
| |
| |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| const SLOT_EXPECTATIONS: Array<{ |
| heading: string; |
| slot: number; |
| mustInclude: string; |
| owner: "alice" | "bob" | "carol"; |
| }> = [ |
| |
| { |
| heading: "The recipe", |
| slot: 0, |
| mustInclude: "Attention lets a token", |
| owner: "bob", |
| }, |
| |
| { |
| heading: "The recipe", |
| slot: 2, |
| mustInclude: "softmax turns those similarity scores", |
| owner: "bob", |
| }, |
| { |
| heading: "The recipe", |
| slot: 3, |
| mustInclude: "multi-head", |
| owner: "bob", |
| }, |
| |
| { |
| heading: "Intuition", |
| slot: 0, |
| mustInclude: "Imagine each token asking", |
| owner: "alice", |
| }, |
| { |
| heading: "Intuition", |
| slot: 1, |
| |
| |
| |
| mustInclude: "every token", |
| owner: "alice", |
| }, |
| { |
| heading: "Intuition", |
| slot: 2, |
| mustInclude: "fully differentiable", |
| owner: "alice", |
| }, |
| |
| { |
| heading: "In Python", |
| slot: 0, |
| mustInclude: "eight lines of PyTorch", |
| owner: "carol", |
| }, |
| |
| { |
| heading: "In Python", |
| slot: 2, |
| mustInclude: "Every modern LLM", |
| owner: "carol", |
| }, |
| ]; |
|
|
| |
| |
| |
| |
| |
| |
| async function verifyFinalDoc(bob: YjsPersona): Promise<void> { |
| |
| |
| |
| const Y = await import("yjs"); |
|
|
| const sections: Array<{ |
| heading: string; |
| slots: string[]; |
| slotTags: string[]; |
| }> = []; |
| let current: { |
| heading: string; |
| slots: string[]; |
| slotTags: string[]; |
| } | null = null; |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| const xmlTextToPlain = (t: Y.XmlText): string => { |
| const delta = t.toDelta() as Array<{ insert?: unknown }>; |
| let out = ""; |
| for (const op of delta) { |
| if (typeof op.insert === "string") out += op.insert; |
| } |
| return out; |
| }; |
| const textOf = (el: Y.XmlElement): string => { |
| |
| |
| |
| |
| if (el.nodeName === "blockMath" || el.nodeName === "inlineMath") { |
| const latex = el.getAttribute("latex"); |
| return typeof latex === "string" ? latex.replace(/\s+/g, " ").trim() : ""; |
| } |
|
|
| let out = ""; |
| const visit = (node: Y.XmlElement | Y.XmlText) => { |
| if (node instanceof Y.XmlText) { |
| out += xmlTextToPlain(node); |
| } else if (node instanceof Y.XmlElement) { |
| if (node.nodeName === "citation") { |
| const label = node.getAttribute("label"); |
| if (typeof label === "string") out += label; |
| } else if (node.nodeName === "inlineMath" || node.nodeName === "blockMath") { |
| const latex = node.getAttribute("latex"); |
| if (typeof latex === "string") out += latex; |
| } else { |
| for (const child of node.toArray()) visit(child as Y.XmlElement | Y.XmlText); |
| } |
| } |
| }; |
| for (const child of el.toArray()) { |
| visit(child as Y.XmlElement | Y.XmlText); |
| } |
| return out.replace(/\s+/g, " ").trim(); |
| }; |
|
|
| for (const child of bob.fragment.toArray()) { |
| if (!(child instanceof Y.XmlElement)) continue; |
| if (child.nodeName === "heading" && Number(child.getAttribute("level")) === 2) { |
| if (current) sections.push(current); |
| current = { |
| heading: textOf(child), |
| slots: [], |
| slotTags: [], |
| }; |
| } else if (current) { |
| const tagMap: Record<string, string> = { |
| paragraph: "P", |
| codeBlock: "PRE", |
| heading: "H", |
| bibliography: "BIB", |
| blockMath: "MATH", |
| }; |
| current.slots.push(textOf(child)); |
| current.slotTags.push(tagMap[child.nodeName] ?? child.nodeName.toUpperCase()); |
| } |
| } |
| if (current) sections.push(current); |
|
|
| const flatText = sections |
| .flatMap((s) => [s.heading, ...s.slots]) |
| .join(" ") |
| .replace(/\s+/g, " ") |
| .trim(); |
|
|
| const report = { sections, flatText }; |
|
|
| if (!report.sections.length) { |
| console.warn("[verify] empty fragment, nothing to verify"); |
| return; |
| } |
|
|
| console.log("[verify] ----- doc layout -----"); |
| for (const s of report.sections) { |
| console.log(`[verify] ## ${s.heading} (${s.slots.length} slots)`); |
| s.slots.forEach((txt, i) => { |
| const tag = s.slotTags[i]; |
| const trimmed = txt.length > 80 ? txt.slice(0, 80) + "..." : txt; |
| console.log(`[verify] [${i}] <${tag}> ${trimmed || "(empty)"}`); |
| }); |
| } |
|
|
| const anomalies: string[] = []; |
|
|
| for (const exp of SLOT_EXPECTATIONS) { |
| const section = report.sections.find((s) => |
| s.heading.toLowerCase().includes(exp.heading.toLowerCase()), |
| ); |
| if (!section) { |
| anomalies.push(`missing section: "${exp.heading}"`); |
| continue; |
| } |
| const slotText = section.slots[exp.slot] ?? ""; |
| if (!slotText.includes(exp.mustInclude)) { |
| |
| const leakedInto = report.sections |
| .flatMap((s) => |
| s.slots.map((txt, i) => ({ |
| heading: s.heading, |
| slot: i, |
| text: txt, |
| })), |
| ) |
| .find( |
| (loc) => |
| loc.text.includes(exp.mustInclude) && |
| !(loc.heading === section.heading && loc.slot === exp.slot), |
| ); |
| if (leakedInto) { |
| anomalies.push( |
| `${exp.owner}: "${exp.mustInclude}" expected in ` + |
| `${exp.heading}[${exp.slot}] but landed in ` + |
| `${leakedInto.heading}[${leakedInto.slot}] ` + |
| `- collision with neighbour section`, |
| ); |
| } else if (report.flatText.includes(exp.mustInclude)) { |
| anomalies.push( |
| `${exp.owner}: "${exp.mustInclude}" present in doc but not in ` + |
| `${exp.heading}[${exp.slot}] (slot has "${slotText.slice(0, 40)}...")`, |
| ); |
| } else { |
| anomalies.push( |
| `${exp.owner}: "${exp.mustInclude}" missing from the doc entirely`, |
| ); |
| } |
| } |
| } |
|
|
| |
| |
| |
| const inPython = report.sections.find((s) => |
| s.heading.toLowerCase().includes("in python"), |
| ); |
| if (inPython) { |
| const codeTag = inPython.slotTags[1]; |
| const codeText = inPython.slots[1] ?? ""; |
| if (codeTag !== "PRE" && !codeText.includes("def attention")) { |
| anomalies.push( |
| `carol: code block in "In Python"[1] not recognized ` + |
| `(got tag=${codeTag}, text="${codeText.slice(0, 40)}...")`, |
| ); |
| } |
| } |
|
|
| |
| |
| |
| |
| |
| const recipe = report.sections.find((s) => |
| s.heading.toLowerCase().includes("the recipe"), |
| ); |
| if (recipe) { |
| const mathTag = recipe.slotTags[1]; |
| const mathText = recipe.slots[1] ?? ""; |
| const looksLikeFormula = |
| mathText.includes("Attention") && mathText.includes("softmax"); |
| if (mathTag !== "MATH" || !looksLikeFormula) { |
| anomalies.push( |
| `bob: block math in "The recipe"[1] not recognized ` + |
| `(got tag=${mathTag}, latex="${mathText.slice(0, 60)}...")`, |
| ); |
| } |
| } |
|
|
| |
| if (!report.flatText.includes("Vaswani")) { |
| anomalies.push("bob: citation 'Vaswani' never landed in the doc"); |
| } |
|
|
| if (anomalies.length === 0) { |
| console.log("[verify] OK - no slot collisions detected"); |
| } else { |
| console.log(`[verify] ${anomalies.length} anomalie(s) detected:`); |
| for (const a of anomalies) console.log(`[verify] - ${a}`); |
| } |
| } |
|
|
| interface CliArgs { |
| url: string; |
| speed: number; |
| allVisible: boolean; |
| |
| |
| |
| |
| |
| |
| |
| record: boolean; |
| |
| |
| |
| |
| |
| capture: boolean; |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| captureDuration: number; |
| } |
|
|
| function parseArgs(argv: string[]): CliArgs { |
| const args: CliArgs = { |
| url: "http://localhost:5678", |
| speed: 2, |
| allVisible: false, |
| record: false, |
| capture: false, |
| captureDuration: 50, |
| }; |
| for (let i = 0; i < argv.length; i++) { |
| const a = argv[i]; |
| if (a === "--url") args.url = argv[++i]; |
| else if (a === "--speed") args.speed = Number(argv[++i]); |
| else if (a === "--all-visible") args.allVisible = true; |
| else if (a === "--record") args.record = true; |
| else if (a === "--capture") args.capture = true; |
| else if (a === "--capture-duration") |
| args.captureDuration = Number(argv[++i]); |
| } |
| |
| if (args.capture) args.record = true; |
| return args; |
| } |
|
|
| async function run() { |
| const args = parseArgs(process.argv.slice(2)); |
| console.log( |
| `[showcase] target=${args.url} speed=${args.speed}x allVisible=${args.allVisible} record=${args.record}`, |
| ); |
|
|
| |
| |
| |
| |
| killLeftoverChromiums(); |
|
|
| |
| |
| |
| |
| const handles: Array<PersonaHandle | null> = []; |
| |
| |
| |
| const yjsPeers: YjsPersona[] = []; |
| let recording: ScreenRecording | null = null; |
| let alreadyCleanedUp = false; |
|
|
| const cleanup = async (reason: string) => { |
| if (alreadyCleanedUp) return; |
| alreadyCleanedUp = true; |
| console.log(`[showcase] cleanup (${reason})`); |
| if (recording) { |
| try { |
| recording.abort(); |
| } catch { |
| |
| } |
| recording = null; |
| } |
| for (const peer of yjsPeers) { |
| try { |
| peer.destroy(); |
| } catch { |
| |
| } |
| } |
| await shutdownAllPersonas(handles); |
| console.log("[showcase] done."); |
| }; |
|
|
| |
| |
| |
| |
| const onSignal = (sig: string) => { |
| cleanup(sig) |
| .catch(() => {}) |
| .finally(() => process.exit(sig === "SIGINT" ? 130 : 143)); |
| }; |
| process.once("SIGINT", onSignal); |
| process.once("SIGTERM", onSignal); |
|
|
| try { |
| |
| |
| |
| |
| |
| const alicePos = args.allVisible ? { x: 0, y: 0 } : undefined; |
|
|
| const alice = await launchPersona(args.url, { |
| persona: PERSONAS.alice, |
| headless: false, |
| windowPosition: alicePos, |
| fullscreen: !args.allVisible && !args.record, |
| recordMode: args.record, |
| }); |
| handles.push(alice); |
|
|
| |
| |
| |
| |
| alice.page.on("console", (msg) => { |
| const text = msg.text(); |
| if (/__demo-chat|\[alice\]|agentRewrite/.test(text)) { |
| console.log(`[alice:console ${msg.type()}] ${text}`); |
| } |
| }); |
| console.log("[showcase] alice up"); |
|
|
| |
| |
| |
| |
| console.log("[showcase] resetting shared document"); |
| await resetDoc(alice.page); |
|
|
| |
| |
| |
| |
| |
| const wsUrl = args.url.replace(/^http/, "ws") + "/collab"; |
| const bob = await connectPersona({ |
| url: wsUrl, |
| docName: "default", |
| user: { name: PERSONAS.bob.name, color: PERSONAS.bob.color }, |
| }); |
| yjsPeers.push(bob); |
| console.log("[showcase] bob up (yjs peer)"); |
|
|
| const carol = await connectPersona({ |
| url: wsUrl, |
| docName: "default", |
| user: { name: PERSONAS.carol.name, color: PERSONAS.carol.color }, |
| }); |
| yjsPeers.push(carol); |
| console.log("[showcase] carol up (yjs peer)"); |
|
|
| |
| |
| |
| |
| if (args.capture) { |
| const outputPath = buildRecordingPath(); |
| recording = await startScreenRecording(outputPath, args.captureDuration); |
| if (recording) { |
| console.log( |
| `[showcase] screen recording started (${args.captureDuration}s max) -> ${recording.outputPath}`, |
| ); |
| await new Promise((r) => setTimeout(r, 1500)); |
| } else { |
| console.warn("[showcase] falling back to manual countdown"); |
| } |
| } |
| if (args.record && !recording) { |
| await recordingCountdown(5); |
| } |
|
|
| |
| |
| |
| const T0 = Date.now(); |
| const stamp = (tag: string) => |
| console.log( |
| `[showcase] +${((Date.now() - T0) / 1000).toFixed(2)}s ${tag}`, |
| ); |
| stamp("demo phase start"); |
|
|
| |
| |
| |
| |
| |
| |
| const alicePromise = runAlice(alice.page, args.speed) |
| .then(() => stamp("alice done")) |
| .catch((err) => console.error("[alice] error:", err)); |
| const bobPromise = runBob(bob, args.speed) |
| .then(() => stamp("bob done")) |
| .catch((err) => console.error("[bob] error:", err)); |
| const carolPromise = runCarol(carol, args.speed) |
| .then(() => stamp("carol done")) |
| .catch((err) => console.error("[carol] error:", err)); |
|
|
| await Promise.all([bobPromise, carolPromise]); |
|
|
| |
| |
| |
| |
| await alicePromise; |
| stamp("all personas finished"); |
|
|
| |
| |
| |
| |
| |
| try { |
| await verifyFinalDoc(bob); |
| } catch (err) { |
| console.warn( |
| "[verify] aborted:", |
| err instanceof Error ? err.message : err, |
| ); |
| } |
|
|
| |
| |
| |
| |
| |
| if (recording) { |
| const remainingMs = Math.max(0, recording.finishesAt - Date.now()); |
| if (remainingMs > 0) { |
| const secs = (remainingMs / 1000).toFixed(1); |
| console.log( |
| `[showcase] waiting ${secs}s for screencapture to flush the .mov...`, |
| ); |
| } |
| await recording.waitForFinish(); |
| console.log(`[showcase] recording saved: ${recording.outputPath}`); |
| |
| |
| recording = null; |
| } |
| } finally { |
| |
| |
| |
| |
| await cleanup("run complete"); |
| } |
| } |
|
|
| run().catch(async (err) => { |
| console.error("[showcase] fatal:", err); |
| |
| |
| try { |
| killLeftoverChromiums(); |
| } catch { |
| |
| } |
| process.exit(1); |
| }); |
|
|