feat(demo): modular showcase replacing monolithic trio script
Browse files- Split trio.ts into showcase.ts + alice.ts / bob.ts / carol.ts and a
lib/ of reusable helpers (chat-actions, publish-flow, positions,
chromium, recording, scaffolding, editor-actions, selection,
embed-studio).
- Drift-proof link/citation/rephrase actions: target positions are
resolved at dispatch time against the live doc instead of at event
receipt time, so concurrent peer edits no longer shift the range.
- verifyFinalDoc post-run collision check to surface cases where two
personas stepped on each other's slots.
- test-actions.ts for isolated regression runs.
- Chromium launched with en-US locale + --disable-features=Translate
so the Chrome translate prompt never appears; published article is
opened in-place in Alice's existing fullscreen window.
- .gitignore: demo/recordings/ is now ignored.
Made-with: Cursor
- backend/.gitignore +1 -0
- backend/demo/alice.ts +292 -0
- backend/demo/banners.ts +192 -0
- backend/demo/bob.ts +199 -0
- backend/demo/carol.ts +174 -0
- backend/demo/human-typing.ts +27 -5
- backend/demo/lib/chat-actions.ts +664 -0
- backend/demo/lib/chromium.ts +259 -0
- backend/demo/lib/editor-actions.ts +199 -0
- backend/demo/lib/embed-studio.ts +279 -0
- backend/demo/lib/positions.ts +302 -0
- backend/demo/lib/publish-flow.ts +249 -0
- backend/demo/lib/recording.ts +137 -0
- backend/demo/lib/scaffolding.ts +191 -0
- backend/demo/lib/selection.ts +172 -0
- backend/demo/showcase.ts +533 -0
- backend/demo/test-actions.ts +568 -0
- backend/demo/trio.ts +0 -1054
|
@@ -1,2 +1,3 @@
|
|
| 1 |
.e2e-data-*
|
| 2 |
test-results/
|
|
|
|
|
|
| 1 |
.e2e-data-*
|
| 2 |
test-results/
|
| 3 |
+
demo/recordings/
|
|
@@ -0,0 +1,292 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Alice - the "hero" persona for the showcase demo.
|
| 3 |
+
*
|
| 4 |
+
* Alice owns the window that gets recorded. Her flow:
|
| 5 |
+
* 1. Seed title + subtitle via the frontmatter inputs.
|
| 6 |
+
* 2. Register the first author (Hugging Face) so Bob / Carol
|
| 7 |
+
* can gate on that before appending their own co-authors.
|
| 8 |
+
* 3. Drop a neural-network banner through the Embed Studio
|
| 9 |
+
* agent flow.
|
| 10 |
+
* 4. Write the full scaffold (3 sections with pre-allocated
|
| 11 |
+
* paragraph slots) so every peer has stable anchors.
|
| 12 |
+
* 5. Type the Intuition middle section with inline `$$Q$$`,
|
| 13 |
+
* `$$K$$`, `$$V$$` atoms.
|
| 14 |
+
* 6. Demo the bubble-toolbar Bold UI on a key phrase.
|
| 15 |
+
* 7. Ask the chat agent to rephrase the closing intuition line
|
| 16 |
+
* (typewriter rewrite via AgentRewrite).
|
| 17 |
+
* 8. Scroll to top, drag the Hue slider live so the banner
|
| 18 |
+
* retints on camera.
|
| 19 |
+
* 9. Fire the real Publish pipeline and land on the rendered
|
| 20 |
+
* output.
|
| 21 |
+
*/
|
| 22 |
+
import type { Page } from "playwright";
|
| 23 |
+
import { humanTypeInto, pause } from "./human-typing.js";
|
| 24 |
+
import { addAuthor, focusEnd } from "./lib/editor-actions.js";
|
| 25 |
+
import {
|
| 26 |
+
bubbleToolbarBold,
|
| 27 |
+
requestRephraseAction,
|
| 28 |
+
} from "./lib/chat-actions.js";
|
| 29 |
+
import { clearAgentFocus } from "./lib/selection.js";
|
| 30 |
+
import { createNeuralNetworkBanner } from "./lib/embed-studio.js";
|
| 31 |
+
import {
|
| 32 |
+
claimSlot,
|
| 33 |
+
installPositionTracker,
|
| 34 |
+
typeInSlot,
|
| 35 |
+
} from "./lib/positions.js";
|
| 36 |
+
import { applyHueChange, triggerPublish } from "./lib/publish-flow.js";
|
| 37 |
+
import { writeScaffold } from "./lib/scaffolding.js";
|
| 38 |
+
|
| 39 |
+
export async function runAlice(page: Page, speed: number) {
|
| 40 |
+
console.log("[alice] ready");
|
| 41 |
+
await installPositionTracker(page);
|
| 42 |
+
|
| 43 |
+
// Note: we used to force the starting hue to the default warm yellow
|
| 44 |
+
// here to guard against a stale `primaryHue` surviving in the Yjs
|
| 45 |
+
// store between runs. That reset is now handled by App.tsx which
|
| 46 |
+
// removes the inline --primary-base override whenever the Yjs
|
| 47 |
+
// settings map has no primaryHue (resetDoc clears the map), so the
|
| 48 |
+
// article reliably opens in the default palette without an extra
|
| 49 |
+
// visible color jump at boot.
|
| 50 |
+
|
| 51 |
+
// --- Title + subtitle (fast, no typos) -------------------------------
|
| 52 |
+
// Speed-tuned for the 30s budget: humans type fast enough that the
|
| 53 |
+
// viewer reads the title forming, but we skip typo backspaces here.
|
| 54 |
+
const fast = speed * 2;
|
| 55 |
+
const title = page.getByPlaceholder("Article title");
|
| 56 |
+
await humanTypeInto(page, title, "Attention, in one formula", {
|
| 57 |
+
speed: fast,
|
| 58 |
+
typoRate: 0,
|
| 59 |
+
thinkRate: 0.01,
|
| 60 |
+
});
|
| 61 |
+
|
| 62 |
+
await pause(80, 140);
|
| 63 |
+
|
| 64 |
+
const subtitle = page.getByPlaceholder("Subtitle (optional)");
|
| 65 |
+
await humanTypeInto(page, subtitle, "The one idea behind every LLM", {
|
| 66 |
+
speed: fast,
|
| 67 |
+
typoRate: 0,
|
| 68 |
+
thinkRate: 0.01,
|
| 69 |
+
});
|
| 70 |
+
|
| 71 |
+
await pause(120, 220);
|
| 72 |
+
|
| 73 |
+
// --- Authors: Alice (the principal agent) seeds the list ------------
|
| 74 |
+
// She creates the first author with the lab's own affiliation.
|
| 75 |
+
// Bob and Carol each wait for this to land before appending their
|
| 76 |
+
// own authors from different universities, which (a) keeps the
|
| 77 |
+
// affiliation numbering stable across peers and (b) visibly
|
| 78 |
+
// showcases a multi-institution collaboration on the byline
|
| 79 |
+
// (MIT, ETH Zurich, Stanford).
|
| 80 |
+
await addAuthor(page, "Alice Renoir", speed, {
|
| 81 |
+
newAffiliation: "Hugging Face",
|
| 82 |
+
});
|
| 83 |
+
await pause(200, 360);
|
| 84 |
+
|
| 85 |
+
// --- Banner: neural network illustration ------------------------------
|
| 86 |
+
await createNeuralNetworkBanner(page, speed);
|
| 87 |
+
|
| 88 |
+
// --- Article scaffold: 3 sections, each with N pre-created empty <p>s.
|
| 89 |
+
// Alice pre-allocates all paragraph slots upfront so Bob and Carol
|
| 90 |
+
// can target their OWN slots without having to press Enter later
|
| 91 |
+
// (which would race with concurrent typing and occasionally suck
|
| 92 |
+
// another persona's text into the wrong block, e.g. Carol's code
|
| 93 |
+
// fence).
|
| 94 |
+
await focusEnd(page);
|
| 95 |
+
await pause(80, 160);
|
| 96 |
+
await writeScaffold(
|
| 97 |
+
page,
|
| 98 |
+
[
|
| 99 |
+
{ heading: "The recipe", slots: 4 },
|
| 100 |
+
{ heading: "Intuition", slots: 3 },
|
| 101 |
+
{ heading: "In Python", slots: 3 },
|
| 102 |
+
],
|
| 103 |
+
speed,
|
| 104 |
+
);
|
| 105 |
+
console.log("[alice] scaffold ready, opening parallel phase");
|
| 106 |
+
|
| 107 |
+
// --- Middle section: Alice writes the "Intuition" analogy block.
|
| 108 |
+
// Putting the hero agent in the MIDDLE section means her viewport
|
| 109 |
+
// always sees Bob's recipe growing above her and Carol's Python
|
| 110 |
+
// code growing below her while she types - much better 3-way
|
| 111 |
+
// parallelism on camera than when she was stuck at the top.
|
| 112 |
+
const bodySpeed = speed * 1.8;
|
| 113 |
+
const intro = { speed: bodySpeed, typoRate: 0, thinkRate: 0.02 };
|
| 114 |
+
const math = { speed: bodySpeed, typoRate: 0, thinkRate: 0 };
|
| 115 |
+
|
| 116 |
+
const intuitionSlot0 = await claimSlot(page, "Intuition", 0);
|
| 117 |
+
await pause(80, 160);
|
| 118 |
+
await typeInSlot(
|
| 119 |
+
page,
|
| 120 |
+
intuitionSlot0,
|
| 121 |
+
"Imagine each token asking: which other tokens matter right now? ",
|
| 122 |
+
intro,
|
| 123 |
+
);
|
| 124 |
+
await typeInSlot(page, intuitionSlot0, "$$Q$$", math);
|
| 125 |
+
await typeInSlot(page, intuitionSlot0, " is the question, ", math);
|
| 126 |
+
await typeInSlot(page, intuitionSlot0, "$$K$$", math);
|
| 127 |
+
await typeInSlot(page, intuitionSlot0, " is the address, ", math);
|
| 128 |
+
await typeInSlot(page, intuitionSlot0, "$$V$$", math);
|
| 129 |
+
await typeInSlot(page, intuitionSlot0, " is the answer.", math);
|
| 130 |
+
|
| 131 |
+
await pause(180, 260);
|
| 132 |
+
|
| 133 |
+
// --- UI action: Alice selects a phrase in slot 0 and clicks Bold
|
| 134 |
+
// from the contextual bubble toolbar. This is the "I highlighted
|
| 135 |
+
// text and pressed B" path every end-user recognises - pure
|
| 136 |
+
// editor UX, no agent roundtrip. Happens MID-flow so the bolded
|
| 137 |
+
// text stays visible for the rest of the demo.
|
| 138 |
+
try {
|
| 139 |
+
const bolded = await bubbleToolbarBold(
|
| 140 |
+
page,
|
| 141 |
+
"Imagine each token",
|
| 142 |
+
"which other tokens matter right now",
|
| 143 |
+
);
|
| 144 |
+
if (!bolded) {
|
| 145 |
+
console.warn("[alice] bubble-toolbar bold skipped");
|
| 146 |
+
} else {
|
| 147 |
+
console.log("[alice] bubble-toolbar bold applied");
|
| 148 |
+
}
|
| 149 |
+
} catch (err) {
|
| 150 |
+
console.warn(
|
| 151 |
+
"[alice] bubble-toolbar bold aborted:",
|
| 152 |
+
err instanceof Error ? err.message : err,
|
| 153 |
+
);
|
| 154 |
+
}
|
| 155 |
+
|
| 156 |
+
await pause(140, 220);
|
| 157 |
+
|
| 158 |
+
// Slot 1: short closing line that ties the intuition to the
|
| 159 |
+
// mechanism. Intentionally a bit bland so the rephrase agent
|
| 160 |
+
// action below has something meaningful to tighten up (viewers
|
| 161 |
+
// see the old sentence erase and a punchier one type itself back
|
| 162 |
+
// in).
|
| 163 |
+
const intuitionSlot1 = await claimSlot(page, "Intuition", 1);
|
| 164 |
+
await pause(60, 120);
|
| 165 |
+
const rephraseOriginal =
|
| 166 |
+
"This soft lookup lets every token pull context from the whole sequence in one step.";
|
| 167 |
+
const rephraseRewrite =
|
| 168 |
+
"In a single pass, every token gathers context from the entire sequence.";
|
| 169 |
+
await typeInSlot(page, intuitionSlot1, rephraseOriginal, {
|
| 170 |
+
speed: speed * 2.2,
|
| 171 |
+
typoRate: 0,
|
| 172 |
+
thinkRate: 0.01,
|
| 173 |
+
});
|
| 174 |
+
|
| 175 |
+
await pause(320, 460);
|
| 176 |
+
|
| 177 |
+
// --- Agent action: Alice asks the chat to rephrase the closing
|
| 178 |
+
// line she just wrote. Visible win: the viewer sees the
|
| 179 |
+
// paragraph swap via the AgentRewrite typewriter - the real
|
| 180 |
+
// "AI is editing my document" moment that justifies having a
|
| 181 |
+
// chat in the first place. Runs AFTER the sentence is fully
|
| 182 |
+
// typed so the rewrite has something stable to target via the
|
| 183 |
+
// string `from`.
|
| 184 |
+
try {
|
| 185 |
+
await requestRephraseAction(page, {
|
| 186 |
+
prompt: "Rephrase the closing line to sound punchier.",
|
| 187 |
+
reply:
|
| 188 |
+
"Tightened it - same idea, snappier cadence. Swap it in if you like the new wording.",
|
| 189 |
+
from: rephraseOriginal,
|
| 190 |
+
to: rephraseRewrite,
|
| 191 |
+
speed,
|
| 192 |
+
});
|
| 193 |
+
// The AgentRewrite typewriter takes ~2-3s on a paragraph this
|
| 194 |
+
// long; leave room so the hue drag doesn't stomp on it.
|
| 195 |
+
await pause(2_200, 2_800);
|
| 196 |
+
await clearAgentFocus(page);
|
| 197 |
+
} catch (err) {
|
| 198 |
+
console.warn(
|
| 199 |
+
"[alice] rephrase action aborted:",
|
| 200 |
+
err instanceof Error ? err.message : err,
|
| 201 |
+
);
|
| 202 |
+
}
|
| 203 |
+
|
| 204 |
+
await pause(160, 240);
|
| 205 |
+
|
| 206 |
+
// Slot 2: the "payoff" beat. After the AI rewrite lands, Alice
|
| 207 |
+
// finishes her section with a plain-prose reminder of why the
|
| 208 |
+
// mechanism matters (differentiable, parallel across all tokens).
|
| 209 |
+
// Gives the viewer one more sentence of body content after the
|
| 210 |
+
// typewriter so the Intuition section reads as a proper paragraph,
|
| 211 |
+
// not just "analogy + one rewritten line".
|
| 212 |
+
const intuitionSlot2 = await claimSlot(page, "Intuition", 2);
|
| 213 |
+
await pause(60, 120);
|
| 214 |
+
await typeInSlot(
|
| 215 |
+
page,
|
| 216 |
+
intuitionSlot2,
|
| 217 |
+
"The operation is fully differentiable and runs in parallel for every token, which is what makes the whole architecture scale.",
|
| 218 |
+
{ speed: speed * 2.2, typoRate: 0, thinkRate: 0.01 },
|
| 219 |
+
);
|
| 220 |
+
|
| 221 |
+
await pause(220, 340);
|
| 222 |
+
|
| 223 |
+
// Before the hue drag, scroll Alice's viewport back to the top of
|
| 224 |
+
// the article. The neural-network banner lives at the very top
|
| 225 |
+
// (hero section) and its embed reacts to the shared `primaryHue`
|
| 226 |
+
// via postMessage - retinting every stroke live. If we stay at
|
| 227 |
+
// the bottom of the doc during the slider drag the viewer can't
|
| 228 |
+
// see that, so we pay a short scroll beat to plant the camera on
|
| 229 |
+
// the banner before the rainbow sweep lands.
|
| 230 |
+
try {
|
| 231 |
+
await page.evaluate(() => {
|
| 232 |
+
window.scrollTo({ top: 0, behavior: "smooth" });
|
| 233 |
+
});
|
| 234 |
+
// Smooth-scroll duration is variable (~500-900ms for a full
|
| 235 |
+
// page on Chromium). Wait for the scroll to actually settle
|
| 236 |
+
// by polling `scrollY === 0` with a short cap, rather than a
|
| 237 |
+
// blind sleep.
|
| 238 |
+
await page
|
| 239 |
+
.waitForFunction(() => window.scrollY < 4, null, { timeout: 2_000 })
|
| 240 |
+
.catch(() => undefined);
|
| 241 |
+
} catch (err) {
|
| 242 |
+
console.warn(
|
| 243 |
+
"[alice] scroll-to-top aborted:",
|
| 244 |
+
err instanceof Error ? err.message : err,
|
| 245 |
+
);
|
| 246 |
+
}
|
| 247 |
+
await pause(300, 480);
|
| 248 |
+
|
| 249 |
+
// Penultimate beat: Alice rebrands the article by opening the
|
| 250 |
+
// Settings drawer (cog in the TopBar) and slowly dragging the
|
| 251 |
+
// Hue slider from the default warm yellow across the rainbow to
|
| 252 |
+
// a cool blue. Every pointer-move writes the new hue to the
|
| 253 |
+
// shared Yjs `settings` map, so the whole article retints
|
| 254 |
+
// continuously during the drag - including the hero neural-net
|
| 255 |
+
// banner, which is why we scrolled to the top right before.
|
| 256 |
+
try {
|
| 257 |
+
await applyHueChange(page, {
|
| 258 |
+
from: 47,
|
| 259 |
+
to: 210,
|
| 260 |
+
steps: 7,
|
| 261 |
+
stepMs: [12, 20],
|
| 262 |
+
});
|
| 263 |
+
console.log("[alice] primary hue shifted to 210");
|
| 264 |
+
} catch (err) {
|
| 265 |
+
console.warn(
|
| 266 |
+
"[alice] hue change aborted:",
|
| 267 |
+
err instanceof Error ? err.message : err,
|
| 268 |
+
);
|
| 269 |
+
}
|
| 270 |
+
|
| 271 |
+
await pause(220, 360);
|
| 272 |
+
|
| 273 |
+
// Final hero beat: Alice opens the Publish dialog, clicks the
|
| 274 |
+
// real "Publish" button, waits for the SSE pipeline to reach
|
| 275 |
+
// success, then clicks "View published article" and lingers on
|
| 276 |
+
// the real output. The recording ends on the published page -
|
| 277 |
+
// not on a dialog, not on a spinner.
|
| 278 |
+
try {
|
| 279 |
+
await triggerPublish(page, {
|
| 280 |
+
successTimeoutMs: 50_000,
|
| 281 |
+
viewLingerMs: [3_500, 4_500],
|
| 282 |
+
});
|
| 283 |
+
console.log("[alice] publish viewed");
|
| 284 |
+
} catch (err) {
|
| 285 |
+
console.warn(
|
| 286 |
+
"[alice] publish aborted:",
|
| 287 |
+
err instanceof Error ? err.message : err,
|
| 288 |
+
);
|
| 289 |
+
}
|
| 290 |
+
|
| 291 |
+
console.log("[alice] scenario complete");
|
| 292 |
+
}
|
|
@@ -177,3 +177,195 @@ export const NEURAL_NETWORK_BANNER_HTML = `<div class="d3-banner-nn" aria-label=
|
|
| 177 |
}
|
| 178 |
})();
|
| 179 |
</script>`;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 177 |
}
|
| 178 |
})();
|
| 179 |
</script>`;
|
| 180 |
+
|
| 181 |
+
// ---------------------------------------------------------------------------
|
| 182 |
+
// Sample data file + matching chart for the "inline chart from data"
|
| 183 |
+
// demo scene. Carol seeds this CSV into the EmbedDataStore, opens the
|
| 184 |
+
// Embed Studio and lets the agent turn it into a bar chart. The chart
|
| 185 |
+
// purposefully inlines the same rows verbatim so it renders without
|
| 186 |
+
// network access (the iframe is sandboxed).
|
| 187 |
+
// ---------------------------------------------------------------------------
|
| 188 |
+
|
| 189 |
+
export const MODEL_ACCURACY_CSV = `epoch,baseline,transformer,mixture-of-experts
|
| 190 |
+
1,0.62,0.71,0.74
|
| 191 |
+
2,0.68,0.78,0.81
|
| 192 |
+
3,0.71,0.83,0.86
|
| 193 |
+
4,0.73,0.86,0.89
|
| 194 |
+
5,0.74,0.88,0.91
|
| 195 |
+
6,0.75,0.89,0.92
|
| 196 |
+
7,0.75,0.90,0.93
|
| 197 |
+
8,0.76,0.90,0.93`;
|
| 198 |
+
|
| 199 |
+
export const MODEL_ACCURACY_CSV_META = {
|
| 200 |
+
name: "model-accuracy.csv",
|
| 201 |
+
ext: "csv",
|
| 202 |
+
columns: ["epoch", "baseline", "transformer", "mixture-of-experts"],
|
| 203 |
+
rowCount: 8,
|
| 204 |
+
};
|
| 205 |
+
|
| 206 |
+
/**
|
| 207 |
+
* Inline line chart rendered once Carol asks the agent for a
|
| 208 |
+
* visualization. Self-contained (no ColorPalettes lookup to keep the
|
| 209 |
+
* iframe deterministic in the recording), inlines the CSV rows as a
|
| 210 |
+
* parsed array, transparent background, uses CSS variables for theming.
|
| 211 |
+
*/
|
| 212 |
+
export const MODEL_ACCURACY_CHART_HTML = `<div class="d3-accuracy" aria-label="Model accuracy over epochs"></div>
|
| 213 |
+
<style>
|
| 214 |
+
.d3-accuracy {
|
| 215 |
+
width: 100%;
|
| 216 |
+
background: transparent;
|
| 217 |
+
font-family: ui-sans-serif, system-ui, sans-serif;
|
| 218 |
+
}
|
| 219 |
+
.d3-accuracy svg { width: 100%; height: auto; display: block; }
|
| 220 |
+
.d3-accuracy .axis path,
|
| 221 |
+
.d3-accuracy .axis line {
|
| 222 |
+
stroke: var(--axis-color, #94a3b8);
|
| 223 |
+
stroke-opacity: 0.55;
|
| 224 |
+
}
|
| 225 |
+
.d3-accuracy .axis text {
|
| 226 |
+
fill: var(--muted-color, #64748b);
|
| 227 |
+
font-size: 11px;
|
| 228 |
+
}
|
| 229 |
+
.d3-accuracy .grid line {
|
| 230 |
+
stroke: var(--grid-color, #e2e8f0);
|
| 231 |
+
stroke-opacity: 0.4;
|
| 232 |
+
}
|
| 233 |
+
.d3-accuracy .series { fill: none; stroke-width: 2.2; }
|
| 234 |
+
.d3-accuracy .series--baseline { stroke: color-mix(in oklab, var(--muted-color, #64748b) 75%, transparent); stroke-dasharray: 4 3; }
|
| 235 |
+
.d3-accuracy .series--transformer { stroke: var(--primary-color, #4f46e5); }
|
| 236 |
+
.d3-accuracy .series--moe { stroke: color-mix(in oklab, var(--primary-color, #4f46e5) 60%, #10b981); }
|
| 237 |
+
.d3-accuracy .dot { stroke: var(--surface-bg, #fff); stroke-width: 1.5; }
|
| 238 |
+
.d3-accuracy .legend {
|
| 239 |
+
display: flex; gap: 14px; flex-wrap: wrap;
|
| 240 |
+
padding: 6px 4px 10px;
|
| 241 |
+
font-size: 12px;
|
| 242 |
+
color: var(--text-color, #0f172a);
|
| 243 |
+
}
|
| 244 |
+
.d3-accuracy .legend .swatch {
|
| 245 |
+
display: inline-block; width: 14px; height: 14px;
|
| 246 |
+
border-radius: 3px; margin-right: 6px; vertical-align: middle;
|
| 247 |
+
}
|
| 248 |
+
</style>
|
| 249 |
+
<script>
|
| 250 |
+
(() => {
|
| 251 |
+
const ensureD3 = (cb) => {
|
| 252 |
+
if (window.d3 && typeof window.d3.select === 'function') return cb();
|
| 253 |
+
let s = document.getElementById('d3-cdn-script');
|
| 254 |
+
if (!s) {
|
| 255 |
+
s = document.createElement('script');
|
| 256 |
+
s.id = 'd3-cdn-script';
|
| 257 |
+
s.src = 'https://cdn.jsdelivr.net/npm/d3@7/dist/d3.min.js';
|
| 258 |
+
document.head.appendChild(s);
|
| 259 |
+
}
|
| 260 |
+
s.addEventListener('load', cb, { once: true });
|
| 261 |
+
if (window.d3) cb();
|
| 262 |
+
};
|
| 263 |
+
|
| 264 |
+
const DATA = [
|
| 265 |
+
{ epoch: 1, baseline: 0.62, transformer: 0.71, moe: 0.74 },
|
| 266 |
+
{ epoch: 2, baseline: 0.68, transformer: 0.78, moe: 0.81 },
|
| 267 |
+
{ epoch: 3, baseline: 0.71, transformer: 0.83, moe: 0.86 },
|
| 268 |
+
{ epoch: 4, baseline: 0.73, transformer: 0.86, moe: 0.89 },
|
| 269 |
+
{ epoch: 5, baseline: 0.74, transformer: 0.88, moe: 0.91 },
|
| 270 |
+
{ epoch: 6, baseline: 0.75, transformer: 0.89, moe: 0.92 },
|
| 271 |
+
{ epoch: 7, baseline: 0.75, transformer: 0.90, moe: 0.93 },
|
| 272 |
+
{ epoch: 8, baseline: 0.76, transformer: 0.90, moe: 0.93 },
|
| 273 |
+
];
|
| 274 |
+
const SERIES = [
|
| 275 |
+
{ key: 'baseline', cls: 'series--baseline', label: 'Baseline' },
|
| 276 |
+
{ key: 'transformer', cls: 'series--transformer', label: 'Transformer' },
|
| 277 |
+
{ key: 'moe', cls: 'series--moe', label: 'Mixture of Experts' },
|
| 278 |
+
];
|
| 279 |
+
|
| 280 |
+
const bootstrap = () => {
|
| 281 |
+
const scriptEl = document.currentScript;
|
| 282 |
+
let container = scriptEl ? scriptEl.previousElementSibling : null;
|
| 283 |
+
if (!(container && container.classList.contains('d3-accuracy'))) {
|
| 284 |
+
const cs = Array.from(document.querySelectorAll('.d3-accuracy'))
|
| 285 |
+
.filter(el => el.dataset.mounted !== 'true');
|
| 286 |
+
container = cs[cs.length - 1] || null;
|
| 287 |
+
}
|
| 288 |
+
if (!container) return;
|
| 289 |
+
if (container.dataset.mounted === 'true') return;
|
| 290 |
+
container.dataset.mounted = 'true';
|
| 291 |
+
|
| 292 |
+
const d3 = window.d3;
|
| 293 |
+
const legend = document.createElement('div');
|
| 294 |
+
legend.className = 'legend';
|
| 295 |
+
legend.innerHTML = SERIES
|
| 296 |
+
.map(s => '<span><span class="swatch" style="background:' +
|
| 297 |
+
(s.cls === 'series--transformer' ? 'var(--primary-color,#4f46e5)' :
|
| 298 |
+
s.cls === 'series--moe' ? 'color-mix(in oklab, var(--primary-color,#4f46e5) 60%, #10b981)' :
|
| 299 |
+
'color-mix(in oklab, var(--muted-color,#64748b) 75%, transparent)')
|
| 300 |
+
+ '"></span>' + s.label + '</span>')
|
| 301 |
+
.join('');
|
| 302 |
+
container.appendChild(legend);
|
| 303 |
+
|
| 304 |
+
const svg = d3.select(container).append('svg');
|
| 305 |
+
|
| 306 |
+
const render = () => {
|
| 307 |
+
const w = container.clientWidth || 640;
|
| 308 |
+
const h = Math.max(240, Math.round(w / 2.6));
|
| 309 |
+
const margin = { top: 10, right: 16, bottom: 28, left: 34 };
|
| 310 |
+
svg.attr('viewBox', '0 0 ' + w + ' ' + h)
|
| 311 |
+
.attr('width', w).attr('height', h);
|
| 312 |
+
svg.selectAll('*').remove();
|
| 313 |
+
|
| 314 |
+
const x = d3.scaleLinear()
|
| 315 |
+
.domain(d3.extent(DATA, d => d.epoch))
|
| 316 |
+
.range([margin.left, w - margin.right]);
|
| 317 |
+
const y = d3.scaleLinear()
|
| 318 |
+
.domain([0.55, 1]).nice()
|
| 319 |
+
.range([h - margin.bottom, margin.top]);
|
| 320 |
+
|
| 321 |
+
const g = svg.append('g');
|
| 322 |
+
g.append('g')
|
| 323 |
+
.attr('class', 'grid')
|
| 324 |
+
.attr('transform', 'translate(' + margin.left + ',0)')
|
| 325 |
+
.call(d3.axisLeft(y).ticks(5).tickSize(-(w - margin.left - margin.right)).tickFormat(() => ''));
|
| 326 |
+
|
| 327 |
+
g.append('g')
|
| 328 |
+
.attr('class', 'axis')
|
| 329 |
+
.attr('transform', 'translate(0,' + (h - margin.bottom) + ')')
|
| 330 |
+
.call(d3.axisBottom(x).ticks(DATA.length).tickFormat(d3.format('d')));
|
| 331 |
+
|
| 332 |
+
g.append('g')
|
| 333 |
+
.attr('class', 'axis')
|
| 334 |
+
.attr('transform', 'translate(' + margin.left + ',0)')
|
| 335 |
+
.call(d3.axisLeft(y).ticks(5).tickFormat(d3.format('.0%')));
|
| 336 |
+
|
| 337 |
+
const line = d3.line().x(d => x(d.epoch)).y(d => y(d.v)).curve(d3.curveMonotoneX);
|
| 338 |
+
SERIES.forEach(s => {
|
| 339 |
+
const rows = DATA.map(d => ({ epoch: d.epoch, v: d[s.key] }));
|
| 340 |
+
g.append('path')
|
| 341 |
+
.datum(rows)
|
| 342 |
+
.attr('class', 'series ' + s.cls)
|
| 343 |
+
.attr('d', line);
|
| 344 |
+
g.selectAll('.dot-' + s.key)
|
| 345 |
+
.data(rows)
|
| 346 |
+
.join('circle')
|
| 347 |
+
.attr('class', 'dot')
|
| 348 |
+
.attr('r', 3)
|
| 349 |
+
.attr('cx', d => x(d.epoch))
|
| 350 |
+
.attr('cy', d => y(d.v))
|
| 351 |
+
.attr('fill',
|
| 352 |
+
s.cls === 'series--transformer' ? 'var(--primary-color,#4f46e5)' :
|
| 353 |
+
s.cls === 'series--moe' ? 'color-mix(in oklab, var(--primary-color,#4f46e5) 60%, #10b981)' :
|
| 354 |
+
'var(--muted-color,#64748b)');
|
| 355 |
+
});
|
| 356 |
+
};
|
| 357 |
+
|
| 358 |
+
ensureD3(() => {
|
| 359 |
+
render();
|
| 360 |
+
if (window.ResizeObserver) new ResizeObserver(render).observe(container);
|
| 361 |
+
});
|
| 362 |
+
};
|
| 363 |
+
|
| 364 |
+
if (document.readyState === 'loading') {
|
| 365 |
+
document.addEventListener('DOMContentLoaded', bootstrap, { once: true });
|
| 366 |
+
} else {
|
| 367 |
+
bootstrap();
|
| 368 |
+
}
|
| 369 |
+
})();
|
| 370 |
+
</script>`;
|
| 371 |
+
|
|
@@ -0,0 +1,199 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Bob - headless collaborator for the showcase demo.
|
| 3 |
+
*
|
| 4 |
+
* Bob owns the "The recipe" section (top of doc). His flow:
|
| 5 |
+
* 1. Wait for Alice's first author to land, then append two
|
| 6 |
+
* university collaborators (MIT, ETH Zurich).
|
| 7 |
+
* 2. Wait for the "The recipe" heading scaffold.
|
| 8 |
+
* 3. Type the definition paragraph with inline Q/K/V math.
|
| 9 |
+
* 4. Fire the Vaswani 2017 citation tool call through the chat
|
| 10 |
+
* (writes entry to citationsMap + inserts a chip + spawns
|
| 11 |
+
* the Bibliography section).
|
| 12 |
+
* 5. Drop the block-math formula.
|
| 13 |
+
* 6. Close with a softmax explanation line.
|
| 14 |
+
*/
|
| 15 |
+
import type { Page } from "playwright";
|
| 16 |
+
import { pause } from "./human-typing.js";
|
| 17 |
+
import { addAuthor } from "./lib/editor-actions.js";
|
| 18 |
+
import { requestCitationAction } from "./lib/chat-actions.js";
|
| 19 |
+
import {
|
| 20 |
+
claimSlot,
|
| 21 |
+
installPositionTracker,
|
| 22 |
+
typeInSlot,
|
| 23 |
+
} from "./lib/positions.js";
|
| 24 |
+
import { waitForHeading2 } from "./lib/scaffolding.js";
|
| 25 |
+
|
| 26 |
+
export async function runBob(page: Page, speed: number) {
|
| 27 |
+
console.log("[bob] joined, waiting for Alice's first author");
|
| 28 |
+
await installPositionTracker(page);
|
| 29 |
+
await pause(800, 1_200);
|
| 30 |
+
|
| 31 |
+
// 1) Authors: Alice seeds the byline first (principal agent), then
|
| 32 |
+
// Bob appends two collaborators from big universities. We gate
|
| 33 |
+
// on the "+" icon button appearing, which only renders after
|
| 34 |
+
// the authors list has at least one entry - a reliable Yjs-
|
| 35 |
+
// synced signal that Alice's insertion has landed on Bob's
|
| 36 |
+
// page. This avoids the old brittle "Bob creates all three"
|
| 37 |
+
// flow and shows the byline growing collaboratively, with
|
| 38 |
+
// varied affiliations (MIT, ETH Zurich) that make the article
|
| 39 |
+
// feel like a multi-lab paper.
|
| 40 |
+
try {
|
| 41 |
+
await page
|
| 42 |
+
.locator('button.icon-btn[aria-label="Add author"]')
|
| 43 |
+
.first()
|
| 44 |
+
.waitFor({ state: "visible", timeout: 30_000 });
|
| 45 |
+
} catch {
|
| 46 |
+
console.warn("[bob] Alice's first author never appeared, proceeding");
|
| 47 |
+
}
|
| 48 |
+
await pause(250, 500);
|
| 49 |
+
|
| 50 |
+
await addAuthor(page, "Bob Mercier", speed, { newAffiliation: "MIT" });
|
| 51 |
+
await pause(200, 380);
|
| 52 |
+
await addAuthor(page, "Mira Patel", speed, {
|
| 53 |
+
newAffiliation: "ETH Zurich",
|
| 54 |
+
});
|
| 55 |
+
console.log("[bob] authors added");
|
| 56 |
+
|
| 57 |
+
// 2) Wait for Alice's scaffold to ship the "The recipe" heading.
|
| 58 |
+
// Bob OWNS this top section (swap from the old layout where
|
| 59 |
+
// Alice wrote it): he types the definition with inline math,
|
| 60 |
+
// the block-math formula, the softmax explanation, AND an
|
| 61 |
+
// italic agent action - giving him enough content to keep his
|
| 62 |
+
// cursor visibly typing through most of the 25s budget, in
|
| 63 |
+
// concert with Alice (middle) and Carol (bottom).
|
| 64 |
+
try {
|
| 65 |
+
await waitForHeading2(page, "The recipe", 60_000);
|
| 66 |
+
} catch {
|
| 67 |
+
console.warn("[bob] 'The recipe' heading not found, skipping body");
|
| 68 |
+
return;
|
| 69 |
+
}
|
| 70 |
+
await pause(120, 240);
|
| 71 |
+
|
| 72 |
+
const bobSpeed = speed * 1.8;
|
| 73 |
+
const bobIntro = { speed: bobSpeed, typoRate: 0, thinkRate: 0.02 };
|
| 74 |
+
const bobMath = { speed: bobSpeed, typoRate: 0, thinkRate: 0 };
|
| 75 |
+
|
| 76 |
+
// Slot 0: definition with queries / keys / values inline math.
|
| 77 |
+
const recipeSlot0 = await claimSlot(page, "The recipe", 0);
|
| 78 |
+
if (!recipeSlot0) {
|
| 79 |
+
console.warn("[bob] could not focus slot 0 of 'The recipe'");
|
| 80 |
+
return;
|
| 81 |
+
}
|
| 82 |
+
await pause(60, 120);
|
| 83 |
+
await typeInSlot(
|
| 84 |
+
page,
|
| 85 |
+
recipeSlot0,
|
| 86 |
+
"Attention lets a token look at every other token, scoring each pair through queries ",
|
| 87 |
+
bobIntro,
|
| 88 |
+
);
|
| 89 |
+
await typeInSlot(page, recipeSlot0, "$$Q$$", bobMath);
|
| 90 |
+
await typeInSlot(page, recipeSlot0, ", keys ", bobMath);
|
| 91 |
+
await typeInSlot(page, recipeSlot0, "$$K$$", bobMath);
|
| 92 |
+
await typeInSlot(page, recipeSlot0, " and values ", bobMath);
|
| 93 |
+
await typeInSlot(page, recipeSlot0, "$$V$$", bobMath);
|
| 94 |
+
await typeInSlot(page, recipeSlot0, ".", bobMath);
|
| 95 |
+
|
| 96 |
+
await pause(160, 240);
|
| 97 |
+
|
| 98 |
+
// Mid-cycle agent action: Bob asks his own chat to add a proper
|
| 99 |
+
// bibliographic citation of the Vaswani 2017 paper right after
|
| 100 |
+
// his definition sentence. The chat dispatches a `citation`
|
| 101 |
+
// tool call: the entry is written to the shared `citationsMap`,
|
| 102 |
+
// a citation node lands at the caret (currently at the end of
|
| 103 |
+
// slot 0), and the Bibliography section materializes at the
|
| 104 |
+
// bottom of the doc the first time. Shows off the real academic-
|
| 105 |
+
// writing workflow on camera (chip renders as `(Vaswani et al.,
|
| 106 |
+
// 2017)` in APA).
|
| 107 |
+
try {
|
| 108 |
+
await pause(220, 340);
|
| 109 |
+
await requestCitationAction(page, {
|
| 110 |
+
prompt: "Cite the original Vaswani 2017 paper here.",
|
| 111 |
+
reply: "Added [Vaswani et al., 2017] and the bibliography section.",
|
| 112 |
+
paragraphSnippet: "Attention lets a token",
|
| 113 |
+
anchor: "end",
|
| 114 |
+
key: "vaswani2017attention",
|
| 115 |
+
entry: {
|
| 116 |
+
id: "vaswani2017attention",
|
| 117 |
+
"citation-key": "vaswani2017attention",
|
| 118 |
+
type: "article-journal",
|
| 119 |
+
title: "Attention Is All You Need",
|
| 120 |
+
author: [
|
| 121 |
+
{ family: "Vaswani", given: "Ashish" },
|
| 122 |
+
{ family: "Shazeer", given: "Noam" },
|
| 123 |
+
{ family: "Parmar", given: "Niki" },
|
| 124 |
+
{ family: "Uszkoreit", given: "Jakob" },
|
| 125 |
+
{ family: "Jones", given: "Llion" },
|
| 126 |
+
{ family: "Gomez", given: "Aidan N." },
|
| 127 |
+
{ family: "Kaiser", given: "Lukasz" },
|
| 128 |
+
{ family: "Polosukhin", given: "Illia" },
|
| 129 |
+
],
|
| 130 |
+
issued: { "date-parts": [[2017]] },
|
| 131 |
+
"container-title": "Advances in Neural Information Processing Systems",
|
| 132 |
+
volume: "30",
|
| 133 |
+
URL: "https://arxiv.org/abs/1706.03762",
|
| 134 |
+
DOI: "10.48550/arXiv.1706.03762",
|
| 135 |
+
},
|
| 136 |
+
speed,
|
| 137 |
+
});
|
| 138 |
+
await pause(260, 420);
|
| 139 |
+
} catch (err) {
|
| 140 |
+
console.warn(
|
| 141 |
+
"[bob] citation action aborted:",
|
| 142 |
+
err instanceof Error ? err.message : err,
|
| 143 |
+
);
|
| 144 |
+
}
|
| 145 |
+
|
| 146 |
+
await pause(160, 240);
|
| 147 |
+
|
| 148 |
+
// Slot 1: canonical block-math formula. The $$$...$$$ input rule
|
| 149 |
+
// converts the empty <p> into a math node in place; the slot
|
| 150 |
+
// position tracks that conversion through the Y.js mapping.
|
| 151 |
+
const recipeSlot1 = await claimSlot(page, "The recipe", 1);
|
| 152 |
+
if (recipeSlot1) {
|
| 153 |
+
await pause(60, 120);
|
| 154 |
+
await typeInSlot(
|
| 155 |
+
page,
|
| 156 |
+
recipeSlot1,
|
| 157 |
+
"$$$\\text{Attention}(Q, K, V) = \\text{softmax}\\left(\\frac{QK^\\top}{\\sqrt{d_k}}\\right)V$$$",
|
| 158 |
+
bobMath,
|
| 159 |
+
);
|
| 160 |
+
}
|
| 161 |
+
|
| 162 |
+
await pause(140, 240);
|
| 163 |
+
|
| 164 |
+
// Slot 2: softmax explanation. Short, punchy, ties the formula
|
| 165 |
+
// back to the definition.
|
| 166 |
+
const recipeSlot2 = await claimSlot(page, "The recipe", 2);
|
| 167 |
+
if (recipeSlot2) {
|
| 168 |
+
await pause(60, 120);
|
| 169 |
+
await typeInSlot(
|
| 170 |
+
page,
|
| 171 |
+
recipeSlot2,
|
| 172 |
+
"The softmax turns those scores into weights, and the weighted sum over values produces one contextual vector per token.",
|
| 173 |
+
{ speed: speed * 2.2, typoRate: 0, thinkRate: 0.01 },
|
| 174 |
+
);
|
| 175 |
+
}
|
| 176 |
+
|
| 177 |
+
await pause(140, 220);
|
| 178 |
+
|
| 179 |
+
// Slot 3: multi-head teaser. Closes the section on a forward
|
| 180 |
+
// pointer to the next chapter (multi-head attention = same
|
| 181 |
+
// operation run in parallel with independent projections). Keeps
|
| 182 |
+
// Bob visibly typing during Alice's rephrase + hue drag phase and
|
| 183 |
+
// gives "The recipe" a proper four-paragraph body instead of
|
| 184 |
+
// stopping right after the formula.
|
| 185 |
+
const recipeSlot3 = await claimSlot(page, "The recipe", 3);
|
| 186 |
+
if (recipeSlot3) {
|
| 187 |
+
await pause(60, 120);
|
| 188 |
+
await typeInSlot(
|
| 189 |
+
page,
|
| 190 |
+
recipeSlot3,
|
| 191 |
+
"In practice the same operation runs in parallel through several independent projections, one per head, and the outputs are concatenated - that is multi-head attention.",
|
| 192 |
+
{ speed: speed * 2.2, typoRate: 0, thinkRate: 0.01 },
|
| 193 |
+
);
|
| 194 |
+
}
|
| 195 |
+
|
| 196 |
+
await pause(180, 300);
|
| 197 |
+
|
| 198 |
+
console.log("[bob] scenario complete");
|
| 199 |
+
}
|
|
@@ -0,0 +1,174 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Carol - headless collaborator for the showcase demo.
|
| 3 |
+
*
|
| 4 |
+
* Carol owns the "In Python" section (bottom of doc). Her flow:
|
| 5 |
+
* 1. Wait for Alice's first author, then append a Stanford
|
| 6 |
+
* co-author.
|
| 7 |
+
* 2. Wait for the "In Python" heading scaffold.
|
| 8 |
+
* 3. Type the narrated intro line in slot 0.
|
| 9 |
+
* 4. Fire a link action through the chat on "PyTorch" -
|
| 10 |
+
* pointing at the concrete `scaled_dot_product_attention`
|
| 11 |
+
* docs. This is the counterpart to Bob's citation at the
|
| 12 |
+
* top of the article.
|
| 13 |
+
* 5. Drop an 8-line PyTorch code block in slot 1 via the
|
| 14 |
+
* ```python input rule.
|
| 15 |
+
* 6. Close with a Vaswani reference line in slot 2.
|
| 16 |
+
*/
|
| 17 |
+
import type { Page } from "playwright";
|
| 18 |
+
import { pause } from "./human-typing.js";
|
| 19 |
+
import { addAuthor, insertCodeBlock } from "./lib/editor-actions.js";
|
| 20 |
+
import { requestLinkAction } from "./lib/chat-actions.js";
|
| 21 |
+
import {
|
| 22 |
+
clearAgentFocus,
|
| 23 |
+
selectSubstringInParagraph,
|
| 24 |
+
} from "./lib/selection.js";
|
| 25 |
+
import {
|
| 26 |
+
claimSlot,
|
| 27 |
+
installPositionTracker,
|
| 28 |
+
typeInSlot,
|
| 29 |
+
} from "./lib/positions.js";
|
| 30 |
+
import {
|
| 31 |
+
focusNthParagraphInSection,
|
| 32 |
+
waitForHeading2,
|
| 33 |
+
} from "./lib/scaffolding.js";
|
| 34 |
+
|
| 35 |
+
export async function runCarol(page: Page, speed: number) {
|
| 36 |
+
console.log("[carol] joined, will add a Stanford co-author then code");
|
| 37 |
+
await installPositionTracker(page);
|
| 38 |
+
await pause(1_200, 1_800);
|
| 39 |
+
|
| 40 |
+
// Authors: like Bob, Carol waits for Alice's first author to be
|
| 41 |
+
// synced (signalled by the "+" icon button replacing the empty
|
| 42 |
+
// placeholder), then appends herself with a third distinct
|
| 43 |
+
// affiliation. Running this here (not after the heading wait)
|
| 44 |
+
// means the byline is fully populated before the body is written,
|
| 45 |
+
// and the three modals openings are staggered across Alice / Bob /
|
| 46 |
+
// Carol for a natural collaborative rhythm on camera.
|
| 47 |
+
try {
|
| 48 |
+
await page
|
| 49 |
+
.locator('button.icon-btn[aria-label="Add author"]')
|
| 50 |
+
.first()
|
| 51 |
+
.waitFor({ state: "visible", timeout: 30_000 });
|
| 52 |
+
} catch {
|
| 53 |
+
console.warn("[carol] Alice's first author never appeared, proceeding");
|
| 54 |
+
}
|
| 55 |
+
await pause(400, 700);
|
| 56 |
+
|
| 57 |
+
await addAuthor(page, "Carol Dubois", speed, {
|
| 58 |
+
newAffiliation: "Stanford University",
|
| 59 |
+
});
|
| 60 |
+
console.log("[carol] author added");
|
| 61 |
+
|
| 62 |
+
// Wait for Alice's scaffold to ship the "In Python" heading (and
|
| 63 |
+
// its three pre-allocated slots), then fill slots 0 / 1 / 2 in
|
| 64 |
+
// sequence. Carol never presses Enter during the parallel phase:
|
| 65 |
+
// jumping between pre-allocated slots via
|
| 66 |
+
// focusNthParagraphInSection is what avoids the previous overlap
|
| 67 |
+
// bug where her ```python code fence would occasionally absorb
|
| 68 |
+
// stray text from Bob or Alice mid-typing.
|
| 69 |
+
try {
|
| 70 |
+
await waitForHeading2(page, "In Python", 60_000);
|
| 71 |
+
} catch {
|
| 72 |
+
console.warn("[carol] 'In Python' heading not found, skipping section");
|
| 73 |
+
return;
|
| 74 |
+
}
|
| 75 |
+
await pause(150, 300);
|
| 76 |
+
|
| 77 |
+
// Slot 0: narrated intro line. Anchored so Alice/Bob typing in
|
| 78 |
+
// parallel can't push Carol's caret out of her own paragraph.
|
| 79 |
+
const pythonSlot0 = await claimSlot(page, "In Python", 0);
|
| 80 |
+
if (!pythonSlot0) {
|
| 81 |
+
console.warn("[carol] could not focus slot 0 of 'In Python'");
|
| 82 |
+
return;
|
| 83 |
+
}
|
| 84 |
+
await pause(80, 160);
|
| 85 |
+
await typeInSlot(
|
| 86 |
+
page,
|
| 87 |
+
pythonSlot0,
|
| 88 |
+
"The whole recipe fits in eight lines of PyTorch:",
|
| 89 |
+
{ speed: speed * 2.5, typoRate: 0, thinkRate: 0.01 },
|
| 90 |
+
);
|
| 91 |
+
|
| 92 |
+
await pause(160, 240);
|
| 93 |
+
|
| 94 |
+
// Mid-cycle agent action: Carol points at the concrete PyTorch
|
| 95 |
+
// reference BEFORE she even writes the code, so when the snippet
|
| 96 |
+
// lands under the paragraph it already feels grounded. The link
|
| 97 |
+
// goes to PyTorch's own scaled_dot_product_attention docs (the
|
| 98 |
+
// fused, production-grade implementation of what the snippet
|
| 99 |
+
// below does by hand). Bottom-of-article link, distinct spot and
|
| 100 |
+
// distinct target from Bob's top-of-article arXiv citation.
|
| 101 |
+
try {
|
| 102 |
+
const linkRange = await selectSubstringInParagraph(
|
| 103 |
+
page,
|
| 104 |
+
"The whole recipe",
|
| 105 |
+
"PyTorch",
|
| 106 |
+
);
|
| 107 |
+
if (linkRange) {
|
| 108 |
+
await pause(220, 360);
|
| 109 |
+
await requestLinkAction(page, {
|
| 110 |
+
prompt: "Link to the PyTorch attention docs.",
|
| 111 |
+
reply:
|
| 112 |
+
"Pointed at torch.nn.functional.scaled_dot_product_attention.",
|
| 113 |
+
url:
|
| 114 |
+
"https://pytorch.org/docs/stable/generated/torch.nn.functional.scaled_dot_product_attention.html",
|
| 115 |
+
speed,
|
| 116 |
+
paragraphSnippet: "The whole recipe",
|
| 117 |
+
substring: "PyTorch",
|
| 118 |
+
});
|
| 119 |
+
await pause(260, 420);
|
| 120 |
+
await clearAgentFocus(page);
|
| 121 |
+
}
|
| 122 |
+
} catch (err) {
|
| 123 |
+
console.warn(
|
| 124 |
+
"[carol] link action aborted:",
|
| 125 |
+
err instanceof Error ? err.message : err,
|
| 126 |
+
);
|
| 127 |
+
}
|
| 128 |
+
|
| 129 |
+
await pause(120, 200);
|
| 130 |
+
|
| 131 |
+
// Slot 1: the code block. We DON'T anchor here - the ```python
|
| 132 |
+
// input rule transforms the <p> into a <pre><code> node, and any
|
| 133 |
+
// MappablePosition created against the old empty paragraph stops
|
| 134 |
+
// being meaningful. We just focus the slot and let PM manage
|
| 135 |
+
// selection internally while Carol fills the code.
|
| 136 |
+
const codeFocused = await focusNthParagraphInSection(page, "In Python", 1);
|
| 137 |
+
if (!codeFocused) {
|
| 138 |
+
console.warn("[carol] could not focus slot 1 of 'In Python'");
|
| 139 |
+
return;
|
| 140 |
+
}
|
| 141 |
+
await pause(80, 160);
|
| 142 |
+
|
| 143 |
+
const code =
|
| 144 |
+
"import torch\n" +
|
| 145 |
+
"import torch.nn.functional as F\n" +
|
| 146 |
+
"\n" +
|
| 147 |
+
"def attention(Q, K, V):\n" +
|
| 148 |
+
" d_k = K.size(-1)\n" +
|
| 149 |
+
" scores = Q @ K.transpose(-2, -1) / d_k ** 0.5\n" +
|
| 150 |
+
" weights = F.softmax(scores, dim=-1)\n" +
|
| 151 |
+
" return weights @ V";
|
| 152 |
+
await insertCodeBlock(page, "python", code, speed);
|
| 153 |
+
|
| 154 |
+
// Slot 2: closing reference paragraph AFTER the code block.
|
| 155 |
+
// Claimed fresh so we get a clean MappablePosition past the code
|
| 156 |
+
// node.
|
| 157 |
+
const pythonSlot2 = await claimSlot(page, "In Python", 2);
|
| 158 |
+
if (!pythonSlot2) {
|
| 159 |
+
console.warn("[carol] could not focus slot 2 of 'In Python'");
|
| 160 |
+
console.log("[carol] scenario complete (partial)");
|
| 161 |
+
return;
|
| 162 |
+
}
|
| 163 |
+
await pause(80, 160);
|
| 164 |
+
await typeInSlot(
|
| 165 |
+
page,
|
| 166 |
+
pythonSlot2,
|
| 167 |
+
"Every modern LLM you have used runs some flavor of this snippet - fused, batched and tiled on the GPU, but conceptually the same eight lines.",
|
| 168 |
+
{ speed: speed * 2.5, typoRate: 0, thinkRate: 0.01 },
|
| 169 |
+
);
|
| 170 |
+
|
| 171 |
+
await pause(180, 280);
|
| 172 |
+
|
| 173 |
+
console.log("[carol] scenario complete");
|
| 174 |
+
}
|
|
@@ -21,9 +21,16 @@ export interface HumanTypingOptions {
|
|
| 21 |
thinkRate?: number;
|
| 22 |
/** Multiplier applied to all delays (1 = normal, 0.5 = fast, 2 = slow). */
|
| 23 |
speed?: number;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 24 |
}
|
| 25 |
|
| 26 |
-
const DEFAULTS: Required<HumanTypingOptions> = {
|
| 27 |
baseDelay: 55,
|
| 28 |
jitter: 85,
|
| 29 |
typoRate: 0.025,
|
|
@@ -65,8 +72,18 @@ export async function humanType(
|
|
| 65 |
options: HumanTypingOptions = {}
|
| 66 |
): Promise<void> {
|
| 67 |
const { baseDelay, jitter, typoRate, thinkRate, speed } = { ...DEFAULTS, ...options };
|
|
|
|
| 68 |
const scale = 1 / speed;
|
| 69 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 70 |
const tokens = text.split(/(\s+)/);
|
| 71 |
|
| 72 |
for (let t = 0; t < tokens.length; t++) {
|
|
@@ -74,7 +91,12 @@ export async function humanType(
|
|
| 74 |
if (!token) continue;
|
| 75 |
|
| 76 |
if (/^\s+$/.test(token)) {
|
| 77 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 78 |
if (Math.random() < thinkRate) {
|
| 79 |
await sleep(rand(180, 520) * scale);
|
| 80 |
}
|
|
@@ -91,14 +113,14 @@ export async function humanType(
|
|
| 91 |
if (shouldTypo) {
|
| 92 |
const wrong = typoFor(ch);
|
| 93 |
if (wrong) {
|
| 94 |
-
await page.keyboard.type(wrong, { delay });
|
| 95 |
await sleep(rand(90, 260) * scale);
|
| 96 |
-
await page.keyboard.press("Backspace");
|
| 97 |
await sleep(rand(60, 180) * scale);
|
| 98 |
}
|
| 99 |
}
|
| 100 |
|
| 101 |
-
await page.keyboard.type(ch, { delay });
|
| 102 |
|
| 103 |
if (/[,;:]/.test(ch)) {
|
| 104 |
await sleep(rand(280, 700) * scale);
|
|
|
|
| 21 |
thinkRate?: number;
|
| 22 |
/** Multiplier applied to all delays (1 = normal, 0.5 = fast, 2 = slow). */
|
| 23 |
speed?: number;
|
| 24 |
+
/**
|
| 25 |
+
* Optional hook called before every keystroke (including typo chars and
|
| 26 |
+
* backspaces). Useful for re-setting the editor caret to a tracked
|
| 27 |
+
* position in concurrent/CRDT demos so remote edits can't push the
|
| 28 |
+
* persona's cursor off-target.
|
| 29 |
+
*/
|
| 30 |
+
beforeKey?: () => void | Promise<void>;
|
| 31 |
}
|
| 32 |
|
| 33 |
+
const DEFAULTS: Required<Omit<HumanTypingOptions, "beforeKey">> = {
|
| 34 |
baseDelay: 55,
|
| 35 |
jitter: 85,
|
| 36 |
typoRate: 0.025,
|
|
|
|
| 72 |
options: HumanTypingOptions = {}
|
| 73 |
): Promise<void> {
|
| 74 |
const { baseDelay, jitter, typoRate, thinkRate, speed } = { ...DEFAULTS, ...options };
|
| 75 |
+
const beforeKey = options.beforeKey;
|
| 76 |
const scale = 1 / speed;
|
| 77 |
|
| 78 |
+
// Small helper that guarantees the caller's `beforeKey` runs before
|
| 79 |
+
// every Playwright keystroke. Used by showcase.ts to re-anchor the PM
|
| 80 |
+
// selection to a tracked `MappablePosition` so concurrent remote
|
| 81 |
+
// edits can't steal the persona's cursor.
|
| 82 |
+
const tap = async (fn: () => Promise<void>) => {
|
| 83 |
+
if (beforeKey) await beforeKey();
|
| 84 |
+
await fn();
|
| 85 |
+
};
|
| 86 |
+
|
| 87 |
const tokens = text.split(/(\s+)/);
|
| 88 |
|
| 89 |
for (let t = 0; t < tokens.length; t++) {
|
|
|
|
| 91 |
if (!token) continue;
|
| 92 |
|
| 93 |
if (/^\s+$/.test(token)) {
|
| 94 |
+
// Whitespace is still a keystroke from PM's point of view (it can
|
| 95 |
+
// trigger input rules like heading or list shortcuts), so keep it
|
| 96 |
+
// anchored the same way as regular chars.
|
| 97 |
+
for (const ws of token) {
|
| 98 |
+
await tap(() => page.keyboard.type(ws, { delay: 0 }));
|
| 99 |
+
}
|
| 100 |
if (Math.random() < thinkRate) {
|
| 101 |
await sleep(rand(180, 520) * scale);
|
| 102 |
}
|
|
|
|
| 113 |
if (shouldTypo) {
|
| 114 |
const wrong = typoFor(ch);
|
| 115 |
if (wrong) {
|
| 116 |
+
await tap(() => page.keyboard.type(wrong, { delay }));
|
| 117 |
await sleep(rand(90, 260) * scale);
|
| 118 |
+
await tap(() => page.keyboard.press("Backspace"));
|
| 119 |
await sleep(rand(60, 180) * scale);
|
| 120 |
}
|
| 121 |
}
|
| 122 |
|
| 123 |
+
await tap(() => page.keyboard.type(ch, { delay }));
|
| 124 |
|
| 125 |
if (/[,;:]/.test(ch)) {
|
| 126 |
await sleep(rand(280, 700) * scale);
|
|
@@ -0,0 +1,664 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Fake-agent chat action helpers for the showcase demo.
|
| 3 |
+
*
|
| 4 |
+
* All of these drive the dev-only `__demo-chat` (or `__demo-embed-chat`)
|
| 5 |
+
* window events that `App.tsx` listens to. Each helper:
|
| 6 |
+
*
|
| 7 |
+
* 1. Brings the AI Assistant FAB into view and opens the chat panel.
|
| 8 |
+
* 2. "Types" a natural-language prompt into the textarea so the
|
| 9 |
+
* viewer sees a real user request forming.
|
| 10 |
+
* 3. Streams a fake assistant reply token-by-token via
|
| 11 |
+
* `streamFakeExchange` (chunked `CustomEvent` dispatches).
|
| 12 |
+
* 4. AFTER the reply is fully visible, fires a deferred action event
|
| 13 |
+
* (`replace`, `format`, `link`, `citation`) that the App handler
|
| 14 |
+
* applies on the editor body through the normal Tiptap / Yjs
|
| 15 |
+
* pipeline - so the edit syncs to every peer and participates in
|
| 16 |
+
* the undo stack.
|
| 17 |
+
*
|
| 18 |
+
* The split between "reply streaming" and "deferred action" is key:
|
| 19 |
+
* viewers need to READ the assistant reply before the editor state
|
| 20 |
+
* changes, otherwise the mark / link / chip / typewriter looks like a
|
| 21 |
+
* magic side-effect instead of a tool call.
|
| 22 |
+
*
|
| 23 |
+
* Public API:
|
| 24 |
+
* - streamFakeExchange low-level pump (exported for Alice's
|
| 25 |
+
* rephrase flow which crafts its own
|
| 26 |
+
* prompt UI)
|
| 27 |
+
* - requestFormatAction toggle a mark on the current selection
|
| 28 |
+
* - requestRephraseAction rewrite a paragraph via typewriter
|
| 29 |
+
* - requestLinkAction wrap selection in a Link mark
|
| 30 |
+
* - requestCitationAction insert a citation chip + register CSL
|
| 31 |
+
* - bubbleToolbarBold click Bold in the on-selection toolbar
|
| 32 |
+
* (pure editor UX, no chat panel)
|
| 33 |
+
*/
|
| 34 |
+
import type { Page } from "playwright";
|
| 35 |
+
import { humanType, pause } from "../human-typing.js";
|
| 36 |
+
import {
|
| 37 |
+
clearAgentFocus,
|
| 38 |
+
getCurrentPmSelection,
|
| 39 |
+
selectSubstringInParagraph,
|
| 40 |
+
} from "./selection.js";
|
| 41 |
+
|
| 42 |
+
// ---------------------------------------------------------------------------
|
| 43 |
+
// Core chat pump
|
| 44 |
+
// ---------------------------------------------------------------------------
|
| 45 |
+
|
| 46 |
+
/**
|
| 47 |
+
* Stream a fake LLM exchange (user prompt + assistant reply) into a
|
| 48 |
+
* chat panel via the given dev-only window event name. The assistant
|
| 49 |
+
* text is appended chunk by chunk so the recording shows tokens
|
| 50 |
+
* streaming in, the way a real model would render.
|
| 51 |
+
*
|
| 52 |
+
* Optional action branches (`replace` / `format` / `link` / `citation`)
|
| 53 |
+
* all fire AFTER the assistant message is fully streamed, so the
|
| 54 |
+
* viewer reads the suggestion before seeing the editor change.
|
| 55 |
+
*/
|
| 56 |
+
export async function streamFakeExchange(
|
| 57 |
+
page: Page,
|
| 58 |
+
args: {
|
| 59 |
+
eventName: "__demo-chat" | "__demo-embed-chat";
|
| 60 |
+
prompt: string;
|
| 61 |
+
reply: string;
|
| 62 |
+
open?: boolean;
|
| 63 |
+
replace?: {
|
| 64 |
+
from: string;
|
| 65 |
+
to: string;
|
| 66 |
+
/**
|
| 67 |
+
* Explicit PM range, for paragraphs that contain inline atoms
|
| 68 |
+
* (math nodes, mentions) where a plain-string lookup on `from`
|
| 69 |
+
* silently fails. See `App.tsx` `__demo-chat` handler.
|
| 70 |
+
*/
|
| 71 |
+
range?: { from: number; to: number };
|
| 72 |
+
};
|
| 73 |
+
/**
|
| 74 |
+
* Optional formatting action: after the assistant reply has fully
|
| 75 |
+
* streamed, fire a `format` event at the App handler so it
|
| 76 |
+
* applies the given mark (bold / italic / strike / code) to the
|
| 77 |
+
* explicit PM range. Same idea as `replace` but for pure mark
|
| 78 |
+
* toggles - the kind of "tool call" a real agent would make for
|
| 79 |
+
* a "make this bold" request.
|
| 80 |
+
*/
|
| 81 |
+
format?: {
|
| 82 |
+
mark: "bold" | "italic" | "strike" | "code";
|
| 83 |
+
range: { from: number; to: number };
|
| 84 |
+
};
|
| 85 |
+
/**
|
| 86 |
+
* Optional link action: same deferred-dispatch pattern as
|
| 87 |
+
* `format` and `replace`. After the assistant reply has streamed,
|
| 88 |
+
* fire a `link` event at the App handler so it wraps the explicit
|
| 89 |
+
* PM range in a Link mark. Used in the demo for the "cite the
|
| 90 |
+
* paper" tool-call performed by Bob and Carol.
|
| 91 |
+
*/
|
| 92 |
+
link?: {
|
| 93 |
+
url: string;
|
| 94 |
+
range: { from: number; to: number };
|
| 95 |
+
/** Drift-proof hints. See `App.tsx` `link` handler. */
|
| 96 |
+
paragraphSnippet?: string;
|
| 97 |
+
substring?: string;
|
| 98 |
+
};
|
| 99 |
+
/**
|
| 100 |
+
* Optional citation action: after the reply has streamed, fire a
|
| 101 |
+
* `citation` event at the App handler so it registers `entry` in
|
| 102 |
+
* the shared citationsMap under `key` and inserts a citation
|
| 103 |
+
* node at the resolved anchor in the doc.
|
| 104 |
+
*
|
| 105 |
+
* Anchor resolution is drift-proof when `paragraphSnippet` is
|
| 106 |
+
* provided: the App handler walks the LIVE doc at dispatch time
|
| 107 |
+
* and places the chip at the start or end of the matching
|
| 108 |
+
* paragraph. `at` is kept as a fallback but unsafe under
|
| 109 |
+
* concurrent upstream edits. Used by Bob for the Vaswani 2017
|
| 110 |
+
* reference.
|
| 111 |
+
*/
|
| 112 |
+
citation?: {
|
| 113 |
+
key: string;
|
| 114 |
+
entry: unknown;
|
| 115 |
+
at?: number;
|
| 116 |
+
paragraphSnippet?: string;
|
| 117 |
+
anchor?: "end" | "start";
|
| 118 |
+
};
|
| 119 |
+
chunkMs?: [number, number];
|
| 120 |
+
chunkSize?: [number, number];
|
| 121 |
+
},
|
| 122 |
+
) {
|
| 123 |
+
const userMessage = {
|
| 124 |
+
id: `demo-u-${Date.now()}`,
|
| 125 |
+
role: "user",
|
| 126 |
+
parts: [{ type: "text", text: args.prompt }],
|
| 127 |
+
};
|
| 128 |
+
const assistantId = `demo-a-${Date.now() + 1}`;
|
| 129 |
+
|
| 130 |
+
// Step 1: post the user message and (optionally) open the panel.
|
| 131 |
+
await page.evaluate(
|
| 132 |
+
({ name, msgs, open }) => {
|
| 133 |
+
window.dispatchEvent(
|
| 134 |
+
new CustomEvent(name, { detail: { messages: msgs, open } }),
|
| 135 |
+
);
|
| 136 |
+
},
|
| 137 |
+
{
|
| 138 |
+
name: args.eventName,
|
| 139 |
+
msgs: [userMessage],
|
| 140 |
+
open: args.open,
|
| 141 |
+
},
|
| 142 |
+
);
|
| 143 |
+
|
| 144 |
+
// Brief "thinking" beat before the first token streams in.
|
| 145 |
+
await pause(280, 480);
|
| 146 |
+
|
| 147 |
+
// Step 2: stream the assistant text in small chunks.
|
| 148 |
+
const tokens = args.reply.split(/(\s+)/).filter((t) => t.length > 0);
|
| 149 |
+
const [chunkMin, chunkMax] = args.chunkSize ?? [1, 2];
|
| 150 |
+
const [delayMin, delayMax] = args.chunkMs ?? [22, 55];
|
| 151 |
+
let acc = "";
|
| 152 |
+
let i = 0;
|
| 153 |
+
while (i < tokens.length) {
|
| 154 |
+
const take =
|
| 155 |
+
chunkMin +
|
| 156 |
+
Math.floor(Math.random() * Math.max(1, chunkMax - chunkMin + 1));
|
| 157 |
+
const chunk = tokens.slice(i, i + take).join("");
|
| 158 |
+
acc += chunk;
|
| 159 |
+
i += take;
|
| 160 |
+
await page.evaluate(
|
| 161 |
+
({ name, msgs }) => {
|
| 162 |
+
window.dispatchEvent(
|
| 163 |
+
new CustomEvent(name, { detail: { messages: msgs } }),
|
| 164 |
+
);
|
| 165 |
+
},
|
| 166 |
+
{
|
| 167 |
+
name: args.eventName,
|
| 168 |
+
msgs: [
|
| 169 |
+
userMessage,
|
| 170 |
+
{
|
| 171 |
+
id: assistantId,
|
| 172 |
+
role: "assistant",
|
| 173 |
+
parts: [{ type: "text", text: acc }],
|
| 174 |
+
},
|
| 175 |
+
],
|
| 176 |
+
},
|
| 177 |
+
);
|
| 178 |
+
await pause(delayMin, delayMax);
|
| 179 |
+
}
|
| 180 |
+
|
| 181 |
+
// Step 3a: optional editor body rewrite, AFTER the reply is fully
|
| 182 |
+
// visible so the viewer reads the suggestion first.
|
| 183 |
+
if (args.replace && args.eventName === "__demo-chat") {
|
| 184 |
+
await pause(280, 450);
|
| 185 |
+
await page.evaluate(
|
| 186 |
+
({ replace }) => {
|
| 187 |
+
window.dispatchEvent(
|
| 188 |
+
new CustomEvent("__demo-chat", { detail: { replace } }),
|
| 189 |
+
);
|
| 190 |
+
},
|
| 191 |
+
{ replace: args.replace },
|
| 192 |
+
);
|
| 193 |
+
}
|
| 194 |
+
|
| 195 |
+
// Step 3b: optional format action (setBold/setItalic/...) on an
|
| 196 |
+
// explicit PM range.
|
| 197 |
+
if (args.format && args.eventName === "__demo-chat") {
|
| 198 |
+
await pause(280, 450);
|
| 199 |
+
await page.evaluate(
|
| 200 |
+
({ format }) => {
|
| 201 |
+
window.dispatchEvent(
|
| 202 |
+
new CustomEvent("__demo-chat", { detail: { format } }),
|
| 203 |
+
);
|
| 204 |
+
},
|
| 205 |
+
{ format: args.format },
|
| 206 |
+
);
|
| 207 |
+
}
|
| 208 |
+
|
| 209 |
+
// Step 3c: optional link action (setLink on a PM range).
|
| 210 |
+
if (args.link && args.eventName === "__demo-chat") {
|
| 211 |
+
await pause(280, 450);
|
| 212 |
+
await page.evaluate(
|
| 213 |
+
({ link }) => {
|
| 214 |
+
window.dispatchEvent(
|
| 215 |
+
new CustomEvent("__demo-chat", { detail: { link } }),
|
| 216 |
+
);
|
| 217 |
+
},
|
| 218 |
+
{ link: args.link },
|
| 219 |
+
);
|
| 220 |
+
}
|
| 221 |
+
|
| 222 |
+
// Step 3d: optional citation action (insertCitation + citationsMap
|
| 223 |
+
// write).
|
| 224 |
+
if (args.citation && args.eventName === "__demo-chat") {
|
| 225 |
+
await pause(280, 450);
|
| 226 |
+
await page.evaluate(
|
| 227 |
+
({ citation }) => {
|
| 228 |
+
window.dispatchEvent(
|
| 229 |
+
new CustomEvent("__demo-chat", { detail: { citation } }),
|
| 230 |
+
);
|
| 231 |
+
},
|
| 232 |
+
{ citation: args.citation },
|
| 233 |
+
);
|
| 234 |
+
}
|
| 235 |
+
}
|
| 236 |
+
|
| 237 |
+
// ---------------------------------------------------------------------------
|
| 238 |
+
// Chat-panel "type a prompt" wrapper
|
| 239 |
+
// ---------------------------------------------------------------------------
|
| 240 |
+
|
| 241 |
+
/**
|
| 242 |
+
* Shared prelude for every request* helper: bring the FAB into view,
|
| 243 |
+
* open the chat panel, "type" `prompt` for real so the viewer sees
|
| 244 |
+
* the textarea filling, then clear it. The subsequent
|
| 245 |
+
* `streamFakeExchange` will re-inject the message as a streamed
|
| 246 |
+
* user+assistant exchange - clearing the textarea here avoids a
|
| 247 |
+
* duplicate prompt ghosting under the streamed one.
|
| 248 |
+
*/
|
| 249 |
+
async function openChatAndTypePrompt(
|
| 250 |
+
page: Page,
|
| 251 |
+
prompt: string,
|
| 252 |
+
speed: number,
|
| 253 |
+
) {
|
| 254 |
+
const fab = page.getByRole("button", { name: /AI Assistant/i }).first();
|
| 255 |
+
if (await fab.isVisible().catch(() => false)) {
|
| 256 |
+
await fab.click();
|
| 257 |
+
await pause(220, 380);
|
| 258 |
+
}
|
| 259 |
+
|
| 260 |
+
const textarea = page
|
| 261 |
+
.locator(".chat-floating textarea, .chat-panel textarea")
|
| 262 |
+
.first();
|
| 263 |
+
try {
|
| 264 |
+
await textarea.waitFor({ state: "visible", timeout: 3_000 });
|
| 265 |
+
await textarea.click();
|
| 266 |
+
await humanType(page, prompt, {
|
| 267 |
+
speed: speed * 2.2,
|
| 268 |
+
typoRate: 0,
|
| 269 |
+
thinkRate: 0.01,
|
| 270 |
+
});
|
| 271 |
+
await pause(100, 180);
|
| 272 |
+
} catch {
|
| 273 |
+
console.warn("[chat] textarea not found, will inject anyway");
|
| 274 |
+
}
|
| 275 |
+
try {
|
| 276 |
+
await textarea.fill("");
|
| 277 |
+
} catch {
|
| 278 |
+
/* non-fatal */
|
| 279 |
+
}
|
| 280 |
+
}
|
| 281 |
+
|
| 282 |
+
// ---------------------------------------------------------------------------
|
| 283 |
+
// Action helpers (public API)
|
| 284 |
+
// ---------------------------------------------------------------------------
|
| 285 |
+
|
| 286 |
+
/**
|
| 287 |
+
* Open the floating chat panel, type a prompt, stream a fake reply,
|
| 288 |
+
* and ask the editor to apply a `bold` / `italic` / `strike` / `code`
|
| 289 |
+
* mark on the ACTIVE PM selection.
|
| 290 |
+
*
|
| 291 |
+
* Callers are expected to run `selectSubstringInParagraph` right
|
| 292 |
+
* before invoking this so the selection snapshot is meaningful.
|
| 293 |
+
*
|
| 294 |
+
* Why format actions (not rewrites) in the demo
|
| 295 |
+
* ---------------------------------------------
|
| 296 |
+
* A simple "make this bold" is the smallest plausible agent tool
|
| 297 |
+
* call, applies instantly, and leaves a visible permanent effect
|
| 298 |
+
* the viewer can read after the cut. Exercises the same
|
| 299 |
+
* `__demo-chat` -> editor pipeline (just the `format` branch
|
| 300 |
+
* instead of `replace`) a real agent would hit.
|
| 301 |
+
*/
|
| 302 |
+
export async function requestFormatAction(
|
| 303 |
+
page: Page,
|
| 304 |
+
args: {
|
| 305 |
+
prompt: string;
|
| 306 |
+
reply: string;
|
| 307 |
+
mark: "bold" | "italic" | "strike" | "code";
|
| 308 |
+
speed: number;
|
| 309 |
+
},
|
| 310 |
+
) {
|
| 311 |
+
const range = await getCurrentPmSelection(page);
|
| 312 |
+
if (!range) {
|
| 313 |
+
console.warn("[format] no PM selection captured, aborting");
|
| 314 |
+
return;
|
| 315 |
+
}
|
| 316 |
+
|
| 317 |
+
await openChatAndTypePrompt(page, args.prompt, args.speed);
|
| 318 |
+
|
| 319 |
+
await streamFakeExchange(page, {
|
| 320 |
+
eventName: "__demo-chat",
|
| 321 |
+
prompt: args.prompt,
|
| 322 |
+
reply: args.reply,
|
| 323 |
+
open: true,
|
| 324 |
+
format: { mark: args.mark, range },
|
| 325 |
+
});
|
| 326 |
+
}
|
| 327 |
+
|
| 328 |
+
/**
|
| 329 |
+
* Ask the AI Assistant chat to rephrase a paragraph: "types" a
|
| 330 |
+
* natural prompt, streams a fake assistant reply, then triggers the
|
| 331 |
+
* typewriter rewrite via the `replace` branch of `__demo-chat`.
|
| 332 |
+
*
|
| 333 |
+
* Difference with `requestFormatAction`: this one is a BODY edit
|
| 334 |
+
* (`replace`), not a mark toggle, so the viewer sees the full
|
| 335 |
+
* AgentRewrite animation - the old paragraph erases and the new
|
| 336 |
+
* one types itself back in at human cadence. That's the visible
|
| 337 |
+
* "AI is editing my doc" moment a real rephrase tool call would
|
| 338 |
+
* produce.
|
| 339 |
+
*
|
| 340 |
+
* Inputs:
|
| 341 |
+
* - `from` / `to` are the paragraph body strings (text only, no
|
| 342 |
+
* inline atoms) that the App handler find-and-replaces.
|
| 343 |
+
* - `range` (optional) - pass the exact PM positions when the
|
| 344 |
+
* paragraph contains inline leaves like math; otherwise the
|
| 345 |
+
* string search would silently skip it. Not needed for plain
|
| 346 |
+
* prose paragraphs.
|
| 347 |
+
*/
|
| 348 |
+
export async function requestRephraseAction(
|
| 349 |
+
page: Page,
|
| 350 |
+
args: {
|
| 351 |
+
prompt: string;
|
| 352 |
+
reply: string;
|
| 353 |
+
from: string;
|
| 354 |
+
to: string;
|
| 355 |
+
range?: { from: number; to: number };
|
| 356 |
+
speed: number;
|
| 357 |
+
},
|
| 358 |
+
) {
|
| 359 |
+
await openChatAndTypePrompt(page, args.prompt, args.speed);
|
| 360 |
+
|
| 361 |
+
await streamFakeExchange(page, {
|
| 362 |
+
eventName: "__demo-chat",
|
| 363 |
+
prompt: args.prompt,
|
| 364 |
+
reply: args.reply,
|
| 365 |
+
open: true,
|
| 366 |
+
replace: {
|
| 367 |
+
from: args.from,
|
| 368 |
+
to: args.to,
|
| 369 |
+
range: args.range,
|
| 370 |
+
},
|
| 371 |
+
});
|
| 372 |
+
}
|
| 373 |
+
|
| 374 |
+
/**
|
| 375 |
+
* Twin of `requestFormatAction`, but for link insertion.
|
| 376 |
+
*
|
| 377 |
+
* The caller is expected to run `selectSubstringInParagraph` right
|
| 378 |
+
* before invoking this so we can capture a PM range. We then open
|
| 379 |
+
* the AI Assistant chat, "type" a natural-language prompt ("Link
|
| 380 |
+
* this to the arxiv paper"), stream a fake assistant reply, and
|
| 381 |
+
* finally dispatch a `link` action so the App handler wraps the
|
| 382 |
+
* range in a Link mark via `setLink`. Visually: the phrase ends up
|
| 383 |
+
* underlined and clickable, synced to every peer.
|
| 384 |
+
*/
|
| 385 |
+
export async function requestLinkAction(
|
| 386 |
+
page: Page,
|
| 387 |
+
args: {
|
| 388 |
+
prompt: string;
|
| 389 |
+
reply: string;
|
| 390 |
+
url: string;
|
| 391 |
+
speed: number;
|
| 392 |
+
/**
|
| 393 |
+
* Drift-proof hints: the App handler re-resolves the range
|
| 394 |
+
* against the LIVE doc at dispatch time using these. Without
|
| 395 |
+
* them, a stale range captured before a concurrent upstream
|
| 396 |
+
* edit will silently apply the link to the wrong characters.
|
| 397 |
+
* Always pass both in the demo.
|
| 398 |
+
*/
|
| 399 |
+
paragraphSnippet?: string;
|
| 400 |
+
substring?: string;
|
| 401 |
+
},
|
| 402 |
+
) {
|
| 403 |
+
const range = await getCurrentPmSelection(page);
|
| 404 |
+
if (!range) {
|
| 405 |
+
console.warn("[link] no PM selection captured, aborting");
|
| 406 |
+
return;
|
| 407 |
+
}
|
| 408 |
+
|
| 409 |
+
await openChatAndTypePrompt(page, args.prompt, args.speed);
|
| 410 |
+
|
| 411 |
+
await streamFakeExchange(page, {
|
| 412 |
+
eventName: "__demo-chat",
|
| 413 |
+
prompt: args.prompt,
|
| 414 |
+
reply: args.reply,
|
| 415 |
+
open: true,
|
| 416 |
+
link: {
|
| 417 |
+
url: args.url,
|
| 418 |
+
range,
|
| 419 |
+
paragraphSnippet: args.paragraphSnippet,
|
| 420 |
+
substring: args.substring,
|
| 421 |
+
},
|
| 422 |
+
});
|
| 423 |
+
}
|
| 424 |
+
|
| 425 |
+
/**
|
| 426 |
+
* Twin of `requestLinkAction`, but for inserting a bibliographic
|
| 427 |
+
* citation chip (`[Author, Year]` or `[1]` depending on style).
|
| 428 |
+
*
|
| 429 |
+
* Flow:
|
| 430 |
+
* 1. Capture the current caret position (where the chip will land).
|
| 431 |
+
* 2. Open the chat panel, "type" a natural prompt, stream a fake
|
| 432 |
+
* reply.
|
| 433 |
+
* 3. Dispatch a `citation` action so the App handler writes the
|
| 434 |
+
* CSL-JSON entry into `citationsMap`, calls `insertCitation(key)`
|
| 435 |
+
* on the editor at the pre-captured position, and ensures a
|
| 436 |
+
* `<bibliography>` block exists at the end of the doc.
|
| 437 |
+
*
|
| 438 |
+
* Why pass `at` explicitly (not rely on current selection)
|
| 439 |
+
* --------------------------------------------------------
|
| 440 |
+
* By the time the reply streams in, Yjs syncs from other peers may
|
| 441 |
+
* have shifted the doc under us. Snapshotting the caret BEFORE the
|
| 442 |
+
* chat round-trip and passing it through the event keeps the chip
|
| 443 |
+
* landing exactly where the user pointed - which is what a real
|
| 444 |
+
* agent tool call would do with a stable range reference.
|
| 445 |
+
*/
|
| 446 |
+
export async function requestCitationAction(
|
| 447 |
+
page: Page,
|
| 448 |
+
args: {
|
| 449 |
+
prompt: string;
|
| 450 |
+
reply: string;
|
| 451 |
+
key: string;
|
| 452 |
+
entry: unknown;
|
| 453 |
+
speed: number;
|
| 454 |
+
/**
|
| 455 |
+
* Drift-proof anchor: the App handler walks the LIVE doc for
|
| 456 |
+
* the matching paragraph and places the chip at its `anchor`
|
| 457 |
+
* (default "end"). Without this, the handler falls back to a
|
| 458 |
+
* raw caret position captured here, which can be shifted by
|
| 459 |
+
* concurrent upstream edits and land the chip mid-paragraph.
|
| 460 |
+
*/
|
| 461 |
+
paragraphSnippet?: string;
|
| 462 |
+
anchor?: "end" | "start";
|
| 463 |
+
},
|
| 464 |
+
) {
|
| 465 |
+
const at = await page.evaluate(() => {
|
| 466 |
+
const editor = (
|
| 467 |
+
window as unknown as {
|
| 468 |
+
__demoEditor?: {
|
| 469 |
+
state: { selection: { to: number } };
|
| 470 |
+
};
|
| 471 |
+
}
|
| 472 |
+
).__demoEditor;
|
| 473 |
+
if (!editor) return null;
|
| 474 |
+
return editor.state.selection.to;
|
| 475 |
+
});
|
| 476 |
+
if (at == null) {
|
| 477 |
+
console.warn("[citation] no caret position captured, aborting");
|
| 478 |
+
return;
|
| 479 |
+
}
|
| 480 |
+
|
| 481 |
+
await openChatAndTypePrompt(page, args.prompt, args.speed);
|
| 482 |
+
|
| 483 |
+
await streamFakeExchange(page, {
|
| 484 |
+
eventName: "__demo-chat",
|
| 485 |
+
prompt: args.prompt,
|
| 486 |
+
reply: args.reply,
|
| 487 |
+
open: true,
|
| 488 |
+
citation: {
|
| 489 |
+
key: args.key,
|
| 490 |
+
entry: args.entry,
|
| 491 |
+
at,
|
| 492 |
+
paragraphSnippet: args.paragraphSnippet,
|
| 493 |
+
anchor: args.anchor,
|
| 494 |
+
},
|
| 495 |
+
});
|
| 496 |
+
}
|
| 497 |
+
|
| 498 |
+
// ---------------------------------------------------------------------------
|
| 499 |
+
// In-editor UI: bubble toolbar bold
|
| 500 |
+
// ---------------------------------------------------------------------------
|
| 501 |
+
|
| 502 |
+
/**
|
| 503 |
+
* Select a plain-text substring in a paragraph and click the Bold
|
| 504 |
+
* button of the BubbleMenu toolbar that floats above the selection.
|
| 505 |
+
*
|
| 506 |
+
* Demonstrates the in-editor formatting UI (the contextual toolbar
|
| 507 |
+
* that appears on text selection) rather than a chat/agent bold -
|
| 508 |
+
* the "I selected some words and clicked B" path every end-user
|
| 509 |
+
* knows from Notion / Google Docs / Linear.
|
| 510 |
+
*
|
| 511 |
+
* Flow:
|
| 512 |
+
* 1. selectSubstringInParagraph - seeds the PM selection AND
|
| 513 |
+
* broadcasts an AgentFocus range so the selection is visible
|
| 514 |
+
* to every peer (the viewer's camera may not be on this user).
|
| 515 |
+
* 2. Wait for `.bubble-toolbar button[aria-label="Bold"]` to
|
| 516 |
+
* show up (BubbleMenu mounts asynchronously on selection).
|
| 517 |
+
* 3. Hover briefly so the tooltip has a chance to surface, then
|
| 518 |
+
* click the Bold button. `toggleBold` goes through the normal
|
| 519 |
+
* Tiptap pipeline so the mark syncs via Yjs.
|
| 520 |
+
* 4. Clear the selection + agent focus so the toolbar and the
|
| 521 |
+
* `<Name> agent` tint don't linger over the next action.
|
| 522 |
+
*
|
| 523 |
+
* Returns true on success, false if the selection or toolbar button
|
| 524 |
+
* couldn't be found (callers log a warning and move on).
|
| 525 |
+
*/
|
| 526 |
+
export async function bubbleToolbarBold(
|
| 527 |
+
page: Page,
|
| 528 |
+
paragraphSnippet: string,
|
| 529 |
+
substring: string,
|
| 530 |
+
opts: { hoverMs?: [number, number] } = {},
|
| 531 |
+
): Promise<boolean> {
|
| 532 |
+
// Tiptap's BubbleMenu only shows when the editor is focused AND
|
| 533 |
+
// the selection is non-empty. Under concurrent Yjs syncs from Bob
|
| 534 |
+
// and Carol, PM occasionally rebases the selection or swallows
|
| 535 |
+
// focus right after our programmatic `setTextSelection`, and the
|
| 536 |
+
// toolbar never renders. Retry the whole "focus + select + wait"
|
| 537 |
+
// dance a couple of times before giving up, with an explicit
|
| 538 |
+
// `editor.commands.focus()` each pass to re-assert ownership of
|
| 539 |
+
// the caret against remote transactions.
|
| 540 |
+
const seedSelection = async (): Promise<{
|
| 541 |
+
from: number;
|
| 542 |
+
to: number;
|
| 543 |
+
} | null> => {
|
| 544 |
+
await page.evaluate(() => {
|
| 545 |
+
const editor = (
|
| 546 |
+
window as unknown as {
|
| 547 |
+
__demoEditor?: { commands: { focus: () => boolean } };
|
| 548 |
+
}
|
| 549 |
+
).__demoEditor;
|
| 550 |
+
editor?.commands.focus();
|
| 551 |
+
});
|
| 552 |
+
return await selectSubstringInParagraph(
|
| 553 |
+
page,
|
| 554 |
+
paragraphSnippet,
|
| 555 |
+
substring,
|
| 556 |
+
);
|
| 557 |
+
};
|
| 558 |
+
|
| 559 |
+
const boldBtn = page
|
| 560 |
+
.locator('.bubble-toolbar button[aria-label="Bold"]')
|
| 561 |
+
.first();
|
| 562 |
+
|
| 563 |
+
let range: { from: number; to: number } | null = null;
|
| 564 |
+
let toolbarReady = false;
|
| 565 |
+
for (let attempt = 0; attempt < 3; attempt++) {
|
| 566 |
+
range = await seedSelection();
|
| 567 |
+
if (!range) {
|
| 568 |
+
await pause(180, 280);
|
| 569 |
+
continue;
|
| 570 |
+
}
|
| 571 |
+
// `state: "attached"` not "visible": the Tiptap BubbleMenu uses
|
| 572 |
+
// tippy.js and sometimes briefly mounts with opacity 0 during
|
| 573 |
+
// its fade-in, which flags as "hidden" to Playwright even though
|
| 574 |
+
// the click would land fine. Attached + force-click is enough.
|
| 575 |
+
try {
|
| 576 |
+
await boldBtn.waitFor({
|
| 577 |
+
state: "attached",
|
| 578 |
+
timeout: attempt === 0 ? 1_500 : 2_000,
|
| 579 |
+
});
|
| 580 |
+
toolbarReady = true;
|
| 581 |
+
break;
|
| 582 |
+
} catch {
|
| 583 |
+
await pause(220, 360);
|
| 584 |
+
}
|
| 585 |
+
}
|
| 586 |
+
|
| 587 |
+
if (!range) {
|
| 588 |
+
console.warn(
|
| 589 |
+
`[bubble-bold] selection failed for "${substring}" in paragraph starting "${paragraphSnippet}"`,
|
| 590 |
+
);
|
| 591 |
+
return false;
|
| 592 |
+
}
|
| 593 |
+
if (!toolbarReady) {
|
| 594 |
+
// Fallback: the contextual toolbar never surfaced (heavy Yjs
|
| 595 |
+
// sync traffic can keep PM re-resolving the selection before
|
| 596 |
+
// tippy has a chance to mount). Apply bold via the canonical
|
| 597 |
+
// editor command so the demo still SHOWS a bold substring,
|
| 598 |
+
// just without the floating toolbar animation. Better than an
|
| 599 |
+
// invisible no-op on camera.
|
| 600 |
+
console.warn(
|
| 601 |
+
"[bubble-bold] toolbar did not surface, applying bold via command fallback",
|
| 602 |
+
);
|
| 603 |
+
await page.evaluate(({ from, to }) => {
|
| 604 |
+
const editor = (
|
| 605 |
+
window as unknown as {
|
| 606 |
+
__demoEditor?: {
|
| 607 |
+
chain: () => {
|
| 608 |
+
focus: () => {
|
| 609 |
+
setTextSelection: (r: { from: number; to: number }) => {
|
| 610 |
+
toggleBold: () => { run: () => boolean };
|
| 611 |
+
};
|
| 612 |
+
};
|
| 613 |
+
};
|
| 614 |
+
commands: {
|
| 615 |
+
setTextSelection: (p: { from: number; to: number }) => boolean;
|
| 616 |
+
};
|
| 617 |
+
};
|
| 618 |
+
}
|
| 619 |
+
).__demoEditor;
|
| 620 |
+
if (!editor) return;
|
| 621 |
+
editor.chain().focus().setTextSelection({ from, to }).toggleBold().run();
|
| 622 |
+
}, range);
|
| 623 |
+
await pause(240, 360);
|
| 624 |
+
await clearAgentFocus(page);
|
| 625 |
+
return true;
|
| 626 |
+
}
|
| 627 |
+
|
| 628 |
+
const [hoverMin, hoverMax] = opts.hoverMs ?? [380, 620];
|
| 629 |
+
try {
|
| 630 |
+
await boldBtn.hover();
|
| 631 |
+
} catch {
|
| 632 |
+
/* non-fatal */
|
| 633 |
+
}
|
| 634 |
+
await pause(hoverMin, hoverMax);
|
| 635 |
+
try {
|
| 636 |
+
await boldBtn.click({ force: true });
|
| 637 |
+
} catch (err) {
|
| 638 |
+
console.warn(
|
| 639 |
+
"[bubble-bold] click failed:",
|
| 640 |
+
err instanceof Error ? err.message : err,
|
| 641 |
+
);
|
| 642 |
+
await clearAgentFocus(page);
|
| 643 |
+
return false;
|
| 644 |
+
}
|
| 645 |
+
|
| 646 |
+
await pause(240, 420);
|
| 647 |
+
await page.evaluate(() => {
|
| 648 |
+
const editor = (
|
| 649 |
+
window as unknown as {
|
| 650 |
+
__demoEditor?: {
|
| 651 |
+
commands: {
|
| 652 |
+
setTextSelection: (args: { from: number; to: number }) => boolean;
|
| 653 |
+
};
|
| 654 |
+
state: { selection: { to: number } };
|
| 655 |
+
};
|
| 656 |
+
}
|
| 657 |
+
).__demoEditor;
|
| 658 |
+
if (!editor) return;
|
| 659 |
+
const pos = editor.state.selection.to;
|
| 660 |
+
editor.commands.setTextSelection({ from: pos, to: pos });
|
| 661 |
+
});
|
| 662 |
+
await clearAgentFocus(page);
|
| 663 |
+
return true;
|
| 664 |
+
}
|
|
@@ -0,0 +1,259 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* lib/chromium.ts
|
| 3 |
+
*
|
| 4 |
+
* All Chromium lifecycle concerns for the showcase demo:
|
| 5 |
+
* - killing leftover Playwright Chromium processes (startup AND shutdown)
|
| 6 |
+
* - launching a persona browser (standard, fullscreen, or --app record mode)
|
| 7 |
+
* - centrally closing every persona at the end of the run, no matter the
|
| 8 |
+
* mode (record / capture / dev).
|
| 9 |
+
*
|
| 10 |
+
* The cleanup matters because the demo spawns up to 3 Chromium instances
|
| 11 |
+
* plus their user-data dirs; leaving them orphaned eats memory, blocks
|
| 12 |
+
* ports for the next run, and pollutes the user's dock.
|
| 13 |
+
*/
|
| 14 |
+
import {
|
| 15 |
+
chromium,
|
| 16 |
+
type Browser,
|
| 17 |
+
type BrowserContext,
|
| 18 |
+
type Page,
|
| 19 |
+
} from "playwright";
|
| 20 |
+
import { execSync } from "node:child_process";
|
| 21 |
+
import { mkdtempSync, rmSync } from "node:fs";
|
| 22 |
+
import { tmpdir } from "node:os";
|
| 23 |
+
import { join } from "node:path";
|
| 24 |
+
import { FALLBACK_USER_KEY, type Persona } from "../personas.js";
|
| 25 |
+
|
| 26 |
+
export interface PersonaHandle {
|
| 27 |
+
browser: Browser;
|
| 28 |
+
context: BrowserContext;
|
| 29 |
+
page: Page;
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
export interface LaunchOptions {
|
| 33 |
+
persona: Persona;
|
| 34 |
+
headless: boolean;
|
| 35 |
+
windowPosition?: { x: number; y: number };
|
| 36 |
+
/**
|
| 37 |
+
* If true, Chromium starts in OS fullscreen (no window chrome, no menu bar
|
| 38 |
+
* on macOS). Used for Alice (the recorded persona) so the screencast looks
|
| 39 |
+
* clean. Forces `viewport: null` so the page uses the real window size.
|
| 40 |
+
*/
|
| 41 |
+
fullscreen?: boolean;
|
| 42 |
+
/**
|
| 43 |
+
* Recording-friendly app mode for Alice. Drops the URL bar, tabs and
|
| 44 |
+
* automation banner entirely (--app=) and starts in OS fullscreen.
|
| 45 |
+
* Implies headless=false and viewport=null. Uses a persistent context
|
| 46 |
+
* because --app needs a launch-time URL hook to keep the chrome-less
|
| 47 |
+
* window attached to the Playwright session.
|
| 48 |
+
*/
|
| 49 |
+
recordMode?: boolean;
|
| 50 |
+
}
|
| 51 |
+
|
| 52 |
+
/**
|
| 53 |
+
* Common launch flags reused by every persona. Removing --enable-automation
|
| 54 |
+
* (via ignoreDefaultArgs) and the AutomationControlled blink feature is what
|
| 55 |
+
* actually hides the "Chrome is being controlled by automated test software"
|
| 56 |
+
* yellow banner across recent Chromium versions.
|
| 57 |
+
*/
|
| 58 |
+
const SHARED_LAUNCH_ARGS = [
|
| 59 |
+
"--disable-infobars",
|
| 60 |
+
"--disable-blink-features=AutomationControlled",
|
| 61 |
+
// Suppress the "Translate this page?" prompt that Chrome surfaces
|
| 62 |
+
// whenever the page language differs from the UI locale. Dropping
|
| 63 |
+
// both the core Translate feature and its UI overlay means the
|
| 64 |
+
// recording never gets a translate bubble over the editor, even
|
| 65 |
+
// on a fresh user-data-dir.
|
| 66 |
+
"--disable-features=Translate,TranslateUI",
|
| 67 |
+
// Lock the UI language to English so Chrome doesn't re-enable
|
| 68 |
+
// translate heuristics based on the OS locale.
|
| 69 |
+
"--lang=en-US",
|
| 70 |
+
];
|
| 71 |
+
const IGNORE_DEFAULT_ARGS = ["--enable-automation"];
|
| 72 |
+
|
| 73 |
+
/**
|
| 74 |
+
* Kill any Chromium instance left over from a previous Playwright run.
|
| 75 |
+
*
|
| 76 |
+
* We match on the ms-playwright install path so we only target the
|
| 77 |
+
* automation browser - the user's regular Chrome/Chromium is untouched.
|
| 78 |
+
* Non-fatal: if pkill finds nothing (exit 1) or isn't available, we
|
| 79 |
+
* just move on and let Playwright launch fresh browsers.
|
| 80 |
+
*
|
| 81 |
+
* Called both at startup (defensive: previous run may have crashed) and
|
| 82 |
+
* at shutdown (belt-and-braces: even if browser.close() hangs, this
|
| 83 |
+
* guarantees no leftover windows).
|
| 84 |
+
*/
|
| 85 |
+
export function killLeftoverChromiums(): void {
|
| 86 |
+
// Match both the headful Chromium binary (Alice's window) and the
|
| 87 |
+
// headless-shell binary (Bob/Carol). The `ms-playwright` segment is
|
| 88 |
+
// unique to the Playwright install path so we never touch the user's
|
| 89 |
+
// real Chrome/Chromium.
|
| 90 |
+
const patterns = [
|
| 91 |
+
"ms-playwright/.*Chromium",
|
| 92 |
+
"ms-playwright/.*chrome-headless-shell",
|
| 93 |
+
];
|
| 94 |
+
let killed = false;
|
| 95 |
+
for (const p of patterns) {
|
| 96 |
+
try {
|
| 97 |
+
execSync(`pkill -f '${p}'`, { stdio: "ignore" });
|
| 98 |
+
killed = true;
|
| 99 |
+
} catch {
|
| 100 |
+
// pkill exits 1 when no process matches - normal on a clean start.
|
| 101 |
+
}
|
| 102 |
+
}
|
| 103 |
+
if (killed) {
|
| 104 |
+
console.log("[showcase] killed leftover Playwright Chromium instances");
|
| 105 |
+
// Give the OS a beat to reclaim ports / window handles before we
|
| 106 |
+
// spawn fresh browsers, otherwise launch can flake on macOS.
|
| 107 |
+
execSync("sleep 0.4");
|
| 108 |
+
}
|
| 109 |
+
}
|
| 110 |
+
|
| 111 |
+
export async function launchPersona(
|
| 112 |
+
url: string,
|
| 113 |
+
opts: LaunchOptions,
|
| 114 |
+
): Promise<PersonaHandle> {
|
| 115 |
+
// ---------------- Recording mode (Alice only, --record) ----------------
|
| 116 |
+
if (opts.recordMode) {
|
| 117 |
+
const userDataDir = mkdtempSync(join(tmpdir(), "showcase-record-"));
|
| 118 |
+
const launchArgs = [
|
| 119 |
+
...SHARED_LAUNCH_ARGS,
|
| 120 |
+
`--app=${url}`,
|
| 121 |
+
"--start-fullscreen",
|
| 122 |
+
];
|
| 123 |
+
const context = await chromium.launchPersistentContext(userDataDir, {
|
| 124 |
+
headless: false,
|
| 125 |
+
viewport: null,
|
| 126 |
+
// Advertise an English locale so the page is tagged as en-US
|
| 127 |
+
// in `navigator.language` and the translate heuristics can't
|
| 128 |
+
// fire regardless of the host OS settings.
|
| 129 |
+
locale: "en-US",
|
| 130 |
+
args: launchArgs,
|
| 131 |
+
ignoreDefaultArgs: IGNORE_DEFAULT_ARGS,
|
| 132 |
+
});
|
| 133 |
+
|
| 134 |
+
// Inject persona identity for any future navigation. The first --app
|
| 135 |
+
// load already happened, so we set localStorage on the live page and
|
| 136 |
+
// then reload once to get a clean boot under the persona identity.
|
| 137 |
+
const persona = JSON.stringify({
|
| 138 |
+
name: opts.persona.name,
|
| 139 |
+
color: opts.persona.color,
|
| 140 |
+
});
|
| 141 |
+
await context.addInitScript(
|
| 142 |
+
({ key, value }) => {
|
| 143 |
+
try {
|
| 144 |
+
localStorage.setItem(key, value);
|
| 145 |
+
} catch {
|
| 146 |
+
/* ignore */
|
| 147 |
+
}
|
| 148 |
+
},
|
| 149 |
+
{ key: FALLBACK_USER_KEY, value: persona },
|
| 150 |
+
);
|
| 151 |
+
|
| 152 |
+
const page = context.pages()[0] || (await context.waitForEvent("page"));
|
| 153 |
+
await page.evaluate(
|
| 154 |
+
({ key, value }) => {
|
| 155 |
+
try {
|
| 156 |
+
localStorage.setItem(key, value);
|
| 157 |
+
} catch {
|
| 158 |
+
/* ignore */
|
| 159 |
+
}
|
| 160 |
+
},
|
| 161 |
+
{ key: FALLBACK_USER_KEY, value: persona },
|
| 162 |
+
);
|
| 163 |
+
await page.reload({ waitUntil: "domcontentloaded", timeout: 20_000 });
|
| 164 |
+
await page.waitForSelector("[aria-label='Undo']", { timeout: 25_000 });
|
| 165 |
+
|
| 166 |
+
// Wrap context.close() into the same Browser-shaped object the rest of
|
| 167 |
+
// the script expects so we don't have to branch on close everywhere.
|
| 168 |
+
const browser = {
|
| 169 |
+
close: async () => {
|
| 170 |
+
try {
|
| 171 |
+
await context.close();
|
| 172 |
+
} finally {
|
| 173 |
+
try {
|
| 174 |
+
rmSync(userDataDir, { recursive: true, force: true });
|
| 175 |
+
} catch {
|
| 176 |
+
/* ignore */
|
| 177 |
+
}
|
| 178 |
+
}
|
| 179 |
+
},
|
| 180 |
+
} as unknown as Browser;
|
| 181 |
+
|
| 182 |
+
return { browser, context, page };
|
| 183 |
+
}
|
| 184 |
+
|
| 185 |
+
// ---------------- Standard mode (windowed or fullscreen) ----------------
|
| 186 |
+
const launchArgs = [...SHARED_LAUNCH_ARGS];
|
| 187 |
+
if (opts.fullscreen) {
|
| 188 |
+
launchArgs.push("--start-fullscreen");
|
| 189 |
+
} else {
|
| 190 |
+
launchArgs.push("--window-size=1280,800");
|
| 191 |
+
if (opts.windowPosition) {
|
| 192 |
+
launchArgs.push(
|
| 193 |
+
`--window-position=${opts.windowPosition.x},${opts.windowPosition.y}`,
|
| 194 |
+
);
|
| 195 |
+
}
|
| 196 |
+
}
|
| 197 |
+
|
| 198 |
+
const browser = await chromium.launch({
|
| 199 |
+
headless: opts.headless,
|
| 200 |
+
args: launchArgs,
|
| 201 |
+
ignoreDefaultArgs: IGNORE_DEFAULT_ARGS,
|
| 202 |
+
});
|
| 203 |
+
|
| 204 |
+
// Playwright forbids `deviceScaleFactor` when `viewport` is null (real
|
| 205 |
+
// window size). For fullscreen we accept the OS-native DPR.
|
| 206 |
+
const context = await browser.newContext(
|
| 207 |
+
opts.fullscreen
|
| 208 |
+
? { viewport: null }
|
| 209 |
+
: { viewport: { width: 1280, height: 800 }, deviceScaleFactor: 2 },
|
| 210 |
+
);
|
| 211 |
+
|
| 212 |
+
await context.addInitScript(
|
| 213 |
+
({ key, value }) => {
|
| 214 |
+
try {
|
| 215 |
+
localStorage.setItem(key, value);
|
| 216 |
+
} catch {
|
| 217 |
+
/* ignore */
|
| 218 |
+
}
|
| 219 |
+
},
|
| 220 |
+
{
|
| 221 |
+
key: FALLBACK_USER_KEY,
|
| 222 |
+
value: JSON.stringify({
|
| 223 |
+
name: opts.persona.name,
|
| 224 |
+
color: opts.persona.color,
|
| 225 |
+
}),
|
| 226 |
+
},
|
| 227 |
+
);
|
| 228 |
+
|
| 229 |
+
const page = await context.newPage();
|
| 230 |
+
await page.goto(url, { waitUntil: "domcontentloaded", timeout: 20_000 });
|
| 231 |
+
await page.waitForSelector("[aria-label='Undo']", { timeout: 25_000 });
|
| 232 |
+
|
| 233 |
+
return { browser, context, page };
|
| 234 |
+
}
|
| 235 |
+
|
| 236 |
+
/**
|
| 237 |
+
* Gracefully close every persona handle, then invoke `killLeftoverChromiums`
|
| 238 |
+
* as a hard safety net. Runs under a timeout so a hanging browser never
|
| 239 |
+
* blocks the script from exiting. Idempotent: safe to call multiple
|
| 240 |
+
* times (e.g. from `finally` AND a SIGINT handler).
|
| 241 |
+
*/
|
| 242 |
+
export async function shutdownAllPersonas(
|
| 243 |
+
handles: Array<PersonaHandle | null | undefined>,
|
| 244 |
+
options: { graceMs?: number } = {},
|
| 245 |
+
): Promise<void> {
|
| 246 |
+
const graceMs = options.graceMs ?? 4_000;
|
| 247 |
+
await Promise.race([
|
| 248 |
+
Promise.allSettled(
|
| 249 |
+
handles
|
| 250 |
+
.filter((h): h is PersonaHandle => Boolean(h))
|
| 251 |
+
.map((h) => h.browser.close()),
|
| 252 |
+
),
|
| 253 |
+
new Promise<void>((resolve) => setTimeout(resolve, graceMs)),
|
| 254 |
+
]);
|
| 255 |
+
// Hard reset regardless of how browser.close() went. Any Chromium
|
| 256 |
+
// process still alive after graceful close is either hung or was
|
| 257 |
+
// spawned as a child we don't know about - nuke them.
|
| 258 |
+
killLeftoverChromiums();
|
| 259 |
+
}
|
|
@@ -0,0 +1,199 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Generic editor-level primitives used across the showcase demo:
|
| 3 |
+
* - focusEnd park the caret at the end of the doc
|
| 4 |
+
* - resetDoc wipe body / frontmatter / banner between runs
|
| 5 |
+
* - insertCodeBlock type a code fence via the `\`\`\`<lang> ` shortcut
|
| 6 |
+
* - addAuthor drive the "Add author" dialog end-to-end
|
| 7 |
+
*
|
| 8 |
+
* These helpers touch ProseMirror / Yjs state and assume the app is
|
| 9 |
+
* mounted on the given `page` (i.e. navigation / WS handshake is
|
| 10 |
+
* already complete).
|
| 11 |
+
*/
|
| 12 |
+
import type { Page } from "playwright";
|
| 13 |
+
import { humanType, pause } from "../human-typing.js";
|
| 14 |
+
|
| 15 |
+
/**
|
| 16 |
+
* Move the caret to the very end of the ProseMirror editor, regardless
|
| 17 |
+
* of where it currently sits. More reliable than "body.click() + Ctrl+End":
|
| 18 |
+
* - body.click() lands at the geometric center and may drop the caret
|
| 19 |
+
* inside a heading, which then gets extended when typing.
|
| 20 |
+
* - Ctrl+End is not a "go to end of document" shortcut on macOS.
|
| 21 |
+
* We use the DOM Range API on the first `.ProseMirror` element.
|
| 22 |
+
*/
|
| 23 |
+
export async function focusEnd(page: Page) {
|
| 24 |
+
await page.evaluate(() => {
|
| 25 |
+
const root = document.querySelector(".ProseMirror") as HTMLElement | null;
|
| 26 |
+
if (!root) return;
|
| 27 |
+
root.focus();
|
| 28 |
+
const range = document.createRange();
|
| 29 |
+
range.selectNodeContents(root);
|
| 30 |
+
range.collapse(false);
|
| 31 |
+
const sel = window.getSelection();
|
| 32 |
+
if (!sel) return;
|
| 33 |
+
sel.removeAllRanges();
|
| 34 |
+
sel.addRange(range);
|
| 35 |
+
});
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
/**
|
| 39 |
+
* Wipe everything so each run starts from a pristine doc:
|
| 40 |
+
* - body
|
| 41 |
+
* - frontmatter (title, subtitle, authors, ...)
|
| 42 |
+
* - banner (the previous run's neural network would otherwise persist)
|
| 43 |
+
* - citations + settings + all embeds
|
| 44 |
+
*
|
| 45 |
+
* Dispatches the `reset-article` window event the Editor already
|
| 46 |
+
* exposes for the `/Reset article` slash command, then waits for the
|
| 47 |
+
* empty-banner placeholder to come back.
|
| 48 |
+
*/
|
| 49 |
+
export async function resetDoc(page: Page) {
|
| 50 |
+
await page.evaluate(() => {
|
| 51 |
+
window.dispatchEvent(new CustomEvent("reset-article"));
|
| 52 |
+
});
|
| 53 |
+
await pause(500, 800);
|
| 54 |
+
|
| 55 |
+
try {
|
| 56 |
+
await page
|
| 57 |
+
.locator(".hero-banner--empty")
|
| 58 |
+
.first()
|
| 59 |
+
.waitFor({ state: "visible", timeout: 5_000 });
|
| 60 |
+
} catch {
|
| 61 |
+
/* non-fatal: createNeuralNetworkBanner has its own warning path. */
|
| 62 |
+
}
|
| 63 |
+
}
|
| 64 |
+
|
| 65 |
+
/**
|
| 66 |
+
* Enter a code block via the markdown shortcut "```<lang> " then type
|
| 67 |
+
* the body. The regex in `@tiptap/extension-code-block` expects
|
| 68 |
+
* /^```([a-z]+)?[\s\n]$/
|
| 69 |
+
* so we close the fence with a trailing space, which triggers the rule.
|
| 70 |
+
*/
|
| 71 |
+
export async function insertCodeBlock(
|
| 72 |
+
page: Page,
|
| 73 |
+
language: string,
|
| 74 |
+
code: string,
|
| 75 |
+
speed: number,
|
| 76 |
+
) {
|
| 77 |
+
await page.keyboard.type("```" + language + " ", { delay: 30 });
|
| 78 |
+
await pause(80, 140);
|
| 79 |
+
await humanType(page, code, {
|
| 80 |
+
speed: speed * 2,
|
| 81 |
+
typoRate: 0,
|
| 82 |
+
thinkRate: 0.02,
|
| 83 |
+
});
|
| 84 |
+
await pause(180, 320);
|
| 85 |
+
await page.keyboard.press("ArrowDown");
|
| 86 |
+
await pause(80, 160);
|
| 87 |
+
}
|
| 88 |
+
|
| 89 |
+
export interface AddAuthorOpts {
|
| 90 |
+
url?: string;
|
| 91 |
+
newAffiliation?: string;
|
| 92 |
+
/** 1-based affiliation chip indices to click in the author dialog. */
|
| 93 |
+
selectAffiliations?: number[];
|
| 94 |
+
}
|
| 95 |
+
|
| 96 |
+
/**
|
| 97 |
+
* Open the "Add author" modal, fill it in, and submit.
|
| 98 |
+
*
|
| 99 |
+
* - Looks for the placeholder button first (no authors yet), falls back
|
| 100 |
+
* to the small "+" icon button next to the existing authors list.
|
| 101 |
+
* - Optionally creates a new affiliation OR selects an existing chip by
|
| 102 |
+
* its 1-based index (matches the ordered list shown in the form).
|
| 103 |
+
* - Submits via the primary button. Waits for the dialog to close before
|
| 104 |
+
* returning so the next call can find the trigger again.
|
| 105 |
+
*/
|
| 106 |
+
export async function addAuthor(
|
| 107 |
+
page: Page,
|
| 108 |
+
name: string,
|
| 109 |
+
speed: number,
|
| 110 |
+
opts: AddAuthorOpts = {},
|
| 111 |
+
): Promise<boolean> {
|
| 112 |
+
const placeholderTrigger = page.locator(".meta-placeholder-btn", {
|
| 113 |
+
hasText: /Add author/i,
|
| 114 |
+
});
|
| 115 |
+
const iconTrigger = page.locator('button.icon-btn[aria-label="Add author"]');
|
| 116 |
+
|
| 117 |
+
let opened = false;
|
| 118 |
+
if (await placeholderTrigger.first().isVisible().catch(() => false)) {
|
| 119 |
+
await placeholderTrigger.first().scrollIntoViewIfNeeded();
|
| 120 |
+
await pause(80, 160);
|
| 121 |
+
await placeholderTrigger.first().click();
|
| 122 |
+
opened = true;
|
| 123 |
+
} else if (await iconTrigger.first().isVisible().catch(() => false)) {
|
| 124 |
+
await iconTrigger.first().scrollIntoViewIfNeeded();
|
| 125 |
+
await pause(80, 160);
|
| 126 |
+
await iconTrigger.first().click();
|
| 127 |
+
opened = true;
|
| 128 |
+
}
|
| 129 |
+
if (!opened) {
|
| 130 |
+
console.warn("[author] no Add-author trigger visible");
|
| 131 |
+
return false;
|
| 132 |
+
}
|
| 133 |
+
|
| 134 |
+
const dialog = page.locator("dialog.ed-dialog--author");
|
| 135 |
+
try {
|
| 136 |
+
await dialog.waitFor({ state: "visible", timeout: 5_000 });
|
| 137 |
+
} catch {
|
| 138 |
+
console.warn("[author] modal did not open");
|
| 139 |
+
return false;
|
| 140 |
+
}
|
| 141 |
+
await pause(120, 220);
|
| 142 |
+
|
| 143 |
+
const fastSpeed = speed * 1.4;
|
| 144 |
+
|
| 145 |
+
const nameInput = dialog.getByPlaceholder("Name");
|
| 146 |
+
await nameInput.click();
|
| 147 |
+
await humanType(page, name, {
|
| 148 |
+
speed: fastSpeed,
|
| 149 |
+
typoRate: 0,
|
| 150 |
+
thinkRate: 0.02,
|
| 151 |
+
});
|
| 152 |
+
await pause(80, 160);
|
| 153 |
+
|
| 154 |
+
if (opts.url) {
|
| 155 |
+
const urlInput = dialog.getByPlaceholder("URL (optional)");
|
| 156 |
+
await urlInput.click();
|
| 157 |
+
await humanType(page, opts.url, {
|
| 158 |
+
speed: fastSpeed,
|
| 159 |
+
typoRate: 0,
|
| 160 |
+
thinkRate: 0.02,
|
| 161 |
+
});
|
| 162 |
+
await pause(80, 160);
|
| 163 |
+
}
|
| 164 |
+
|
| 165 |
+
if (opts.selectAffiliations && opts.selectAffiliations.length > 0) {
|
| 166 |
+
for (const idx of opts.selectAffiliations) {
|
| 167 |
+
const chip = dialog
|
| 168 |
+
.locator(".chip")
|
| 169 |
+
.filter({ hasText: new RegExp(`^${idx}\\.`) })
|
| 170 |
+
.first();
|
| 171 |
+
if (await chip.isVisible().catch(() => false)) {
|
| 172 |
+
await chip.click();
|
| 173 |
+
await pause(60, 140);
|
| 174 |
+
}
|
| 175 |
+
}
|
| 176 |
+
}
|
| 177 |
+
|
| 178 |
+
if (opts.newAffiliation) {
|
| 179 |
+
const newAffInput = dialog.getByPlaceholder(/New affiliation/i);
|
| 180 |
+
await newAffInput.click();
|
| 181 |
+
await humanType(page, opts.newAffiliation, {
|
| 182 |
+
speed: fastSpeed,
|
| 183 |
+
typoRate: 0,
|
| 184 |
+
thinkRate: 0.02,
|
| 185 |
+
});
|
| 186 |
+
await pause(80, 160);
|
| 187 |
+
}
|
| 188 |
+
|
| 189 |
+
const submit = dialog.locator("button.btn--primary");
|
| 190 |
+
await submit.click();
|
| 191 |
+
|
| 192 |
+
try {
|
| 193 |
+
await dialog.waitFor({ state: "hidden", timeout: 3_000 });
|
| 194 |
+
} catch {
|
| 195 |
+
/* non-fatal */
|
| 196 |
+
}
|
| 197 |
+
await pause(100, 200);
|
| 198 |
+
return true;
|
| 199 |
+
}
|
|
@@ -0,0 +1,279 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Embed Studio scene engine for the showcase demo.
|
| 3 |
+
*
|
| 4 |
+
* Builds an "agent-picks-a-chart" beat that the viewer sees as:
|
| 5 |
+
* - Alice clicks "Create chart" in the empty banner placeholder
|
| 6 |
+
* - Studio opens with a real data file already in the sidebar
|
| 7 |
+
* - She types a prompt, fake agent streams text + tool bubbles
|
| 8 |
+
* (`listDataFiles`, `createEmbed`) through `__demo-embed-chat`
|
| 9 |
+
* - A pre-built neural network banner is dropped into the doc via
|
| 10 |
+
* `__demo-set-banner`
|
| 11 |
+
* - Studio closes
|
| 12 |
+
*
|
| 13 |
+
* The whole thing is orchestrated through dev-only window events
|
| 14 |
+
* (`__demo-embed-data`, `__demo-embed-chat`, `__demo-set-banner`)
|
| 15 |
+
* that `App.tsx` subscribes to in dev mode.
|
| 16 |
+
*/
|
| 17 |
+
import type { Page } from "playwright";
|
| 18 |
+
import { humanType, pause } from "../human-typing.js";
|
| 19 |
+
import {
|
| 20 |
+
MODEL_ACCURACY_CSV,
|
| 21 |
+
MODEL_ACCURACY_CSV_META,
|
| 22 |
+
NEURAL_NETWORK_BANNER_HTML,
|
| 23 |
+
} from "../banners.js";
|
| 24 |
+
|
| 25 |
+
/**
|
| 26 |
+
* A single beat of an Embed Studio chat scene. The scene engine builds
|
| 27 |
+
* the `messages` array incrementally and re-dispatches the whole tree
|
| 28 |
+
* through `__demo-embed-chat` after every mutation so the UI animates
|
| 29 |
+
* streaming text, running spinners and fulfilled tool bubbles exactly
|
| 30 |
+
* like a real agent round-trip.
|
| 31 |
+
*/
|
| 32 |
+
type EmbedSceneStep =
|
| 33 |
+
| { kind: "user-text"; text: string }
|
| 34 |
+
| {
|
| 35 |
+
kind: "assistant-text";
|
| 36 |
+
text: string;
|
| 37 |
+
chunkMs?: [number, number];
|
| 38 |
+
chunkSize?: [number, number];
|
| 39 |
+
}
|
| 40 |
+
| {
|
| 41 |
+
kind: "assistant-tool";
|
| 42 |
+
toolName: string;
|
| 43 |
+
input?: Record<string, unknown>;
|
| 44 |
+
output?: string;
|
| 45 |
+
thinkMs?: [number, number];
|
| 46 |
+
}
|
| 47 |
+
| { kind: "pause"; ms: [number, number] };
|
| 48 |
+
|
| 49 |
+
/**
|
| 50 |
+
* Seed a file in the EmbedDataStore through the dev-only
|
| 51 |
+
* `__demo-embed-data` window event. Used before opening the Embed
|
| 52 |
+
* Studio so the FilesSidebar shows a realistic dataset the agent
|
| 53 |
+
* can then "read" via tool calls.
|
| 54 |
+
*/
|
| 55 |
+
async function seedEmbedDataFile(
|
| 56 |
+
page: Page,
|
| 57 |
+
file: {
|
| 58 |
+
name: string;
|
| 59 |
+
content: string;
|
| 60 |
+
ext?: string;
|
| 61 |
+
columns?: string[];
|
| 62 |
+
rowCount?: number;
|
| 63 |
+
uploader?: string;
|
| 64 |
+
},
|
| 65 |
+
) {
|
| 66 |
+
await page.evaluate((f) => {
|
| 67 |
+
window.dispatchEvent(
|
| 68 |
+
new CustomEvent("__demo-embed-data", { detail: { file: f } }),
|
| 69 |
+
);
|
| 70 |
+
}, file);
|
| 71 |
+
}
|
| 72 |
+
|
| 73 |
+
async function closeEmbedStudio(page: Page) {
|
| 74 |
+
const closeBtn = page.getByRole("button", { name: /^Save & Close$/i }).first();
|
| 75 |
+
if (await closeBtn.isVisible().catch(() => false)) {
|
| 76 |
+
await closeBtn.click();
|
| 77 |
+
} else {
|
| 78 |
+
const xBtn = page.getByRole("button", { name: /Close Embed Studio/i }).first();
|
| 79 |
+
if (await xBtn.isVisible().catch(() => false)) {
|
| 80 |
+
await xBtn.click();
|
| 81 |
+
} else {
|
| 82 |
+
await page.keyboard.press("Escape");
|
| 83 |
+
}
|
| 84 |
+
}
|
| 85 |
+
}
|
| 86 |
+
|
| 87 |
+
/**
|
| 88 |
+
* Play a series of `EmbedSceneStep` beats against the Embed Studio
|
| 89 |
+
* chat. Streams assistant text token-by-token and flips tool parts
|
| 90 |
+
* from `input-available` (spinner) to `output-available` (done check)
|
| 91 |
+
* so the viewer sees live tool-call bubbles, not just a text blob.
|
| 92 |
+
*/
|
| 93 |
+
async function runEmbedScene(page: Page, steps: EmbedSceneStep[]) {
|
| 94 |
+
const nowTag = Date.now().toString(36);
|
| 95 |
+
const userMessage: {
|
| 96 |
+
id: string;
|
| 97 |
+
role: "user";
|
| 98 |
+
parts: Array<{ type: "text"; text: string }>;
|
| 99 |
+
} = {
|
| 100 |
+
id: `demo-eu-${nowTag}`,
|
| 101 |
+
role: "user",
|
| 102 |
+
parts: [],
|
| 103 |
+
};
|
| 104 |
+
const assistantMessage: {
|
| 105 |
+
id: string;
|
| 106 |
+
role: "assistant";
|
| 107 |
+
parts: Array<Record<string, unknown>>;
|
| 108 |
+
} = {
|
| 109 |
+
id: `demo-ea-${nowTag}`,
|
| 110 |
+
role: "assistant",
|
| 111 |
+
parts: [],
|
| 112 |
+
};
|
| 113 |
+
const messages: Array<unknown> = [];
|
| 114 |
+
|
| 115 |
+
const ensureAssistant = () => {
|
| 116 |
+
if (!messages.includes(assistantMessage)) messages.push(assistantMessage);
|
| 117 |
+
};
|
| 118 |
+
|
| 119 |
+
const dispatch = async () => {
|
| 120 |
+
const snapshot = JSON.parse(JSON.stringify(messages));
|
| 121 |
+
await page.evaluate((msgs) => {
|
| 122 |
+
window.dispatchEvent(
|
| 123 |
+
new CustomEvent("__demo-embed-chat", { detail: { messages: msgs } }),
|
| 124 |
+
);
|
| 125 |
+
}, snapshot);
|
| 126 |
+
};
|
| 127 |
+
|
| 128 |
+
for (const step of steps) {
|
| 129 |
+
if (step.kind === "user-text") {
|
| 130 |
+
userMessage.parts.push({ type: "text", text: step.text });
|
| 131 |
+
if (!messages.includes(userMessage)) messages.unshift(userMessage);
|
| 132 |
+
await dispatch();
|
| 133 |
+
await pause(260, 420);
|
| 134 |
+
} else if (step.kind === "assistant-text") {
|
| 135 |
+
ensureAssistant();
|
| 136 |
+
const textPart: { type: "text"; text: string } = {
|
| 137 |
+
type: "text",
|
| 138 |
+
text: "",
|
| 139 |
+
};
|
| 140 |
+
assistantMessage.parts.push(textPart);
|
| 141 |
+
const tokens = step.text.split(/(\s+)/).filter((t) => t.length > 0);
|
| 142 |
+
const [chunkMin, chunkMax] = step.chunkSize ?? [1, 2];
|
| 143 |
+
const [delayMin, delayMax] = step.chunkMs ?? [22, 55];
|
| 144 |
+
let i = 0;
|
| 145 |
+
while (i < tokens.length) {
|
| 146 |
+
const take =
|
| 147 |
+
chunkMin +
|
| 148 |
+
Math.floor(Math.random() * Math.max(1, chunkMax - chunkMin + 1));
|
| 149 |
+
textPart.text += tokens.slice(i, i + take).join("");
|
| 150 |
+
i += take;
|
| 151 |
+
await dispatch();
|
| 152 |
+
await pause(delayMin, delayMax);
|
| 153 |
+
}
|
| 154 |
+
} else if (step.kind === "assistant-tool") {
|
| 155 |
+
ensureAssistant();
|
| 156 |
+
const callId = `demo-tc-${nowTag}-${Math.random().toString(36).slice(2, 6)}`;
|
| 157 |
+
const toolPart: Record<string, unknown> = {
|
| 158 |
+
type: `tool-${step.toolName}`,
|
| 159 |
+
toolCallId: callId,
|
| 160 |
+
state: "input-available",
|
| 161 |
+
input: step.input ?? {},
|
| 162 |
+
};
|
| 163 |
+
assistantMessage.parts.push(toolPart);
|
| 164 |
+
await dispatch();
|
| 165 |
+
await pause(step.thinkMs?.[0] ?? 520, step.thinkMs?.[1] ?? 900);
|
| 166 |
+
toolPart.state = "output-available";
|
| 167 |
+
toolPart.output = step.output ?? "done";
|
| 168 |
+
await dispatch();
|
| 169 |
+
await pause(160, 280);
|
| 170 |
+
} else if (step.kind === "pause") {
|
| 171 |
+
await pause(step.ms[0], step.ms[1]);
|
| 172 |
+
}
|
| 173 |
+
}
|
| 174 |
+
}
|
| 175 |
+
|
| 176 |
+
/**
|
| 177 |
+
* Click "Create chart" in the empty banner placeholder, seed a small
|
| 178 |
+
* CSV into the EmbedDataStore so the FilesSidebar shows a live file,
|
| 179 |
+
* type a prompt in the Embed Studio, then play a tool-aware fake
|
| 180 |
+
* agent scene (`listDataFiles` + `createEmbed`) that drops a pre-
|
| 181 |
+
* built neural network banner once the tool bubbles animate through.
|
| 182 |
+
*
|
| 183 |
+
* The banner filename stays `banner.html` (protected by the agent),
|
| 184 |
+
* but the tool chips, subtitle and FilesSidebar make the whole beat
|
| 185 |
+
* match the current Embed Studio interface instead of feeling like
|
| 186 |
+
* a two-line placeholder.
|
| 187 |
+
*/
|
| 188 |
+
export async function createNeuralNetworkBanner(page: Page, speed: number) {
|
| 189 |
+
const createBtn = page.getByRole("button", { name: /Create chart/i }).first();
|
| 190 |
+
try {
|
| 191 |
+
await createBtn.waitFor({ state: "visible", timeout: 5_000 });
|
| 192 |
+
} catch {
|
| 193 |
+
console.warn('[alice] "Create chart" button not found, skipping banner');
|
| 194 |
+
return;
|
| 195 |
+
}
|
| 196 |
+
|
| 197 |
+
// Seed one data file BEFORE opening the studio so the FilesSidebar
|
| 198 |
+
// shows an existing dataset the agent can reference. Two purposes:
|
| 199 |
+
// 1. visually establishes that the studio now handles data files;
|
| 200 |
+
// 2. gives the listDataFiles tool call a real output to surface.
|
| 201 |
+
await seedEmbedDataFile(page, {
|
| 202 |
+
name: MODEL_ACCURACY_CSV_META.name,
|
| 203 |
+
ext: MODEL_ACCURACY_CSV_META.ext,
|
| 204 |
+
content: MODEL_ACCURACY_CSV,
|
| 205 |
+
columns: MODEL_ACCURACY_CSV_META.columns,
|
| 206 |
+
rowCount: MODEL_ACCURACY_CSV_META.rowCount,
|
| 207 |
+
uploader: "alice",
|
| 208 |
+
});
|
| 209 |
+
|
| 210 |
+
await createBtn.scrollIntoViewIfNeeded();
|
| 211 |
+
await pause(120, 220);
|
| 212 |
+
await createBtn.click();
|
| 213 |
+
|
| 214 |
+
const promptArea = page.getByPlaceholder("Describe your chart...");
|
| 215 |
+
const promptText = "A neural network banner - soft pulses, primary color.";
|
| 216 |
+
try {
|
| 217 |
+
await promptArea.waitFor({ state: "visible", timeout: 4_000 });
|
| 218 |
+
await pause(260, 420);
|
| 219 |
+
await promptArea.click();
|
| 220 |
+
await pause(180, 280);
|
| 221 |
+
await humanType(page, promptText, {
|
| 222 |
+
speed: speed * 1.6,
|
| 223 |
+
typoRate: 0,
|
| 224 |
+
thinkRate: 0.02,
|
| 225 |
+
});
|
| 226 |
+
await pause(180, 320);
|
| 227 |
+
await promptArea.fill("");
|
| 228 |
+
} catch {
|
| 229 |
+
console.warn("[alice] embed studio textarea not found, faking anyway");
|
| 230 |
+
}
|
| 231 |
+
|
| 232 |
+
await runEmbedScene(page, [
|
| 233 |
+
{ kind: "user-text", text: promptText },
|
| 234 |
+
{
|
| 235 |
+
kind: "assistant-tool",
|
| 236 |
+
toolName: "listDataFiles",
|
| 237 |
+
input: {},
|
| 238 |
+
output:
|
| 239 |
+
`- ${MODEL_ACCURACY_CSV_META.name} (csv, ${MODEL_ACCURACY_CSV.length} bytes) ` +
|
| 240 |
+
`rows=${MODEL_ACCURACY_CSV_META.rowCount} ` +
|
| 241 |
+
`columns=[${MODEL_ACCURACY_CSV_META.columns.join(", ")}]`,
|
| 242 |
+
thinkMs: [480, 760],
|
| 243 |
+
},
|
| 244 |
+
{
|
| 245 |
+
kind: "assistant-text",
|
| 246 |
+
text: "Data files noted - going abstract for the banner.",
|
| 247 |
+
},
|
| 248 |
+
{
|
| 249 |
+
kind: "assistant-tool",
|
| 250 |
+
toolName: "createEmbed",
|
| 251 |
+
input: {
|
| 252 |
+
title: "Neural network banner",
|
| 253 |
+
},
|
| 254 |
+
output: "chart created (134 lines, 3.8 KB)",
|
| 255 |
+
thinkMs: [620, 980],
|
| 256 |
+
},
|
| 257 |
+
{
|
| 258 |
+
kind: "assistant-text",
|
| 259 |
+
text:
|
| 260 |
+
" Layered network with subtle pulses along the synapses, tinted with the article primary color.",
|
| 261 |
+
},
|
| 262 |
+
]);
|
| 263 |
+
|
| 264 |
+
// Drop the real banner HTML right after the tool bubbles resolve so
|
| 265 |
+
// the iframe flips to the final illustration in sync with the
|
| 266 |
+
// agent announcing "Done".
|
| 267 |
+
await page.evaluate(
|
| 268 |
+
(html) => {
|
| 269 |
+
window.dispatchEvent(
|
| 270 |
+
new CustomEvent("__demo-set-banner", { detail: { html } }),
|
| 271 |
+
);
|
| 272 |
+
},
|
| 273 |
+
NEURAL_NETWORK_BANNER_HTML,
|
| 274 |
+
);
|
| 275 |
+
await pause(600, 900);
|
| 276 |
+
|
| 277 |
+
await closeEmbedStudio(page);
|
| 278 |
+
await pause(150, 280);
|
| 279 |
+
}
|
|
@@ -0,0 +1,302 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* lib/positions.ts
|
| 3 |
+
*
|
| 4 |
+
* Collaboration-safe typing anchors (MappablePosition tracker).
|
| 5 |
+
*
|
| 6 |
+
* The `focusNthParagraphInSection` helper places the PM caret once, but
|
| 7 |
+
* in a multi-persona CRDT demo the caret drifts as soon as ANOTHER
|
| 8 |
+
* persona inserts text before it: Yjs rebases the ProseMirror selection
|
| 9 |
+
* by the net length of remote edits, and the typing persona ends up
|
| 10 |
+
* typing INSIDE somebody else's paragraph (code fence, equation,
|
| 11 |
+
* heading...).
|
| 12 |
+
*
|
| 13 |
+
* The robust fix is to track each persona's target as a
|
| 14 |
+
* `MappablePosition`, which Tiptap's Collaboration extension backs with
|
| 15 |
+
* a Y.js relative position (a stable semantic pointer that survives
|
| 16 |
+
* concurrent edits). BETWEEN `humanType` calls we re-resolve the mapped
|
| 17 |
+
* position (`MP.position + typed`) and reset PM's selection to it;
|
| 18 |
+
* WITHIN a humanType call we trust PM to advance the caret naturally.
|
| 19 |
+
*
|
| 20 |
+
* Why not re-anchor on every keystroke? The CollaborationMappablePosition
|
| 21 |
+
* uses Y.js `assoc = -1` (left-biased), so local inserts do NOT advance
|
| 22 |
+
* `MP.position`. If we re-seated the caret to `MP.position` before every
|
| 23 |
+
* char, every new character would be inserted at the same spot - typing
|
| 24 |
+
* "Hello" would produce "olleH". The counter trick (mp + typed, synced
|
| 25 |
+
* from PM's selection head after each humanType) is both simpler and
|
| 26 |
+
* correct.
|
| 27 |
+
*
|
| 28 |
+
* Docs:
|
| 29 |
+
* - https://tiptap.dev/docs/editor/api/utilities/position
|
| 30 |
+
* - Collaboration extension auto-upgrades createMappablePosition() to
|
| 31 |
+
* return a CollaborationMappablePosition when Y.js is connected.
|
| 32 |
+
*/
|
| 33 |
+
import type { Page } from "playwright";
|
| 34 |
+
import { focusNthParagraphInSection } from "./scaffolding.js";
|
| 35 |
+
import { humanType, type HumanTypingOptions } from "../human-typing.js";
|
| 36 |
+
|
| 37 |
+
/**
|
| 38 |
+
* Install a one-shot transaction hook on the page: every time a
|
| 39 |
+
* ProseMirror transaction is dispatched (local OR remote Yjs sync), it
|
| 40 |
+
* remaps every registered MappablePosition so subsequent reads reflect
|
| 41 |
+
* the new document state. Idempotent.
|
| 42 |
+
*/
|
| 43 |
+
export async function installPositionTracker(page: Page): Promise<void> {
|
| 44 |
+
await page.evaluate(() => {
|
| 45 |
+
const g = globalThis as unknown as {
|
| 46 |
+
__name?: <T>(fn: T) => T;
|
| 47 |
+
__demoEditor?: unknown;
|
| 48 |
+
__demoPositionsInstalled?: boolean;
|
| 49 |
+
__demoPositions?: Map<string, unknown>;
|
| 50 |
+
};
|
| 51 |
+
if (typeof g.__name !== "function") g.__name = <T,>(fn: T) => fn;
|
| 52 |
+
if (g.__demoPositionsInstalled) return;
|
| 53 |
+
|
| 54 |
+
const attach = (): boolean => {
|
| 55 |
+
const editor = g.__demoEditor as
|
| 56 |
+
| {
|
| 57 |
+
on: (
|
| 58 |
+
evt: "transaction",
|
| 59 |
+
cb: (args: { transaction: unknown }) => void,
|
| 60 |
+
) => void;
|
| 61 |
+
utils?: {
|
| 62 |
+
getUpdatedPosition: (
|
| 63 |
+
pos: unknown,
|
| 64 |
+
tr: unknown,
|
| 65 |
+
) => { position: unknown };
|
| 66 |
+
};
|
| 67 |
+
}
|
| 68 |
+
| undefined;
|
| 69 |
+
if (!editor || !editor.utils) return false;
|
| 70 |
+
g.__demoPositions = new Map();
|
| 71 |
+
editor.on("transaction", ({ transaction }) => {
|
| 72 |
+
const map = g.__demoPositions!;
|
| 73 |
+
if (map.size === 0) return;
|
| 74 |
+
for (const [id, pos] of map) {
|
| 75 |
+
try {
|
| 76 |
+
const { position } = editor.utils!.getUpdatedPosition(
|
| 77 |
+
pos,
|
| 78 |
+
transaction,
|
| 79 |
+
);
|
| 80 |
+
map.set(id, position);
|
| 81 |
+
} catch {
|
| 82 |
+
// If remapping fails (e.g. position deleted), drop it.
|
| 83 |
+
map.delete(id);
|
| 84 |
+
}
|
| 85 |
+
}
|
| 86 |
+
});
|
| 87 |
+
g.__demoPositionsInstalled = true;
|
| 88 |
+
return true;
|
| 89 |
+
};
|
| 90 |
+
|
| 91 |
+
if (!attach()) {
|
| 92 |
+
// Editor might not be exposed yet on first paint; poll briefly.
|
| 93 |
+
const timer = window.setInterval(() => {
|
| 94 |
+
if (attach()) window.clearInterval(timer);
|
| 95 |
+
}, 80);
|
| 96 |
+
window.setTimeout(() => window.clearInterval(timer), 10_000);
|
| 97 |
+
}
|
| 98 |
+
});
|
| 99 |
+
}
|
| 100 |
+
|
| 101 |
+
/**
|
| 102 |
+
* Register a MappablePosition at the end of the Nth slot of a section
|
| 103 |
+
* heading. Returns an opaque id usable by `typeInSlot()`, or `null` if
|
| 104 |
+
* the slot can't be located.
|
| 105 |
+
*/
|
| 106 |
+
export async function claimSlotPosition(
|
| 107 |
+
page: Page,
|
| 108 |
+
headingText: string,
|
| 109 |
+
slotIndex: number,
|
| 110 |
+
): Promise<string | null> {
|
| 111 |
+
return await page.evaluate(
|
| 112 |
+
({ text, n }) => {
|
| 113 |
+
const g = globalThis as unknown as {
|
| 114 |
+
__name?: <T>(fn: T) => T;
|
| 115 |
+
__demoEditor?: {
|
| 116 |
+
view: {
|
| 117 |
+
posAtDOM: (node: Node, offset: number, bias?: number) => number;
|
| 118 |
+
};
|
| 119 |
+
utils?: { createMappablePosition: (pos: number) => unknown };
|
| 120 |
+
};
|
| 121 |
+
__demoPositions?: Map<string, unknown>;
|
| 122 |
+
};
|
| 123 |
+
if (typeof g.__name !== "function") g.__name = <T,>(fn: T) => fn;
|
| 124 |
+
|
| 125 |
+
const editor = g.__demoEditor;
|
| 126 |
+
const map = g.__demoPositions;
|
| 127 |
+
if (!editor || !editor.utils || !map) return null;
|
| 128 |
+
|
| 129 |
+
const root = document.querySelector(
|
| 130 |
+
".ProseMirror",
|
| 131 |
+
) as HTMLElement | null;
|
| 132 |
+
if (!root) return null;
|
| 133 |
+
const headings = Array.from(root.querySelectorAll("h2"));
|
| 134 |
+
const wanted = text.toLowerCase();
|
| 135 |
+
const heading = headings.find((h) =>
|
| 136 |
+
(h.textContent || "").trim().toLowerCase().includes(wanted),
|
| 137 |
+
);
|
| 138 |
+
if (!heading) return null;
|
| 139 |
+
|
| 140 |
+
const slots: HTMLElement[] = [];
|
| 141 |
+
let node: Element | null = heading.nextElementSibling;
|
| 142 |
+
while (node) {
|
| 143 |
+
if (/^H[1-6]$/.test(node.tagName)) break;
|
| 144 |
+
slots.push(node as HTMLElement);
|
| 145 |
+
node = node.nextElementSibling;
|
| 146 |
+
}
|
| 147 |
+
const target = slots[n];
|
| 148 |
+
if (!target) return null;
|
| 149 |
+
|
| 150 |
+
// posAtDOM(target, childCount, 0) resolves to the position at the
|
| 151 |
+
// END of the node's content. For an empty <p> that's just "inside
|
| 152 |
+
// the paragraph"; for a non-empty one it's after the last char.
|
| 153 |
+
let pmPos: number;
|
| 154 |
+
try {
|
| 155 |
+
pmPos = editor.view.posAtDOM(target, target.childNodes.length, 0);
|
| 156 |
+
} catch {
|
| 157 |
+
return null;
|
| 158 |
+
}
|
| 159 |
+
if (!Number.isFinite(pmPos)) return null;
|
| 160 |
+
|
| 161 |
+
const mappable = editor.utils.createMappablePosition(pmPos);
|
| 162 |
+
const id = `pos_${Math.random().toString(36).slice(2, 10)}`;
|
| 163 |
+
map.set(id, mappable);
|
| 164 |
+
return id;
|
| 165 |
+
},
|
| 166 |
+
{ text: headingText, n: slotIndex },
|
| 167 |
+
);
|
| 168 |
+
}
|
| 169 |
+
|
| 170 |
+
/**
|
| 171 |
+
* A claimed slot: opaque MappablePosition id + a mutable counter tracking
|
| 172 |
+
* how many ProseMirror positions we've advanced past the original claim
|
| 173 |
+
* point since the slot was acquired (including input-rule substitutions
|
| 174 |
+
* like `$$Q$$` -> inline math node).
|
| 175 |
+
*
|
| 176 |
+
* The counter is re-synced from `editor.state.selection.head` after each
|
| 177 |
+
* `typeInSlot()` call so it stays honest even when Tiptap input rules
|
| 178 |
+
* shrink or grow the inserted content. Concretely:
|
| 179 |
+
*
|
| 180 |
+
* - MappablePosition is Y.js left-biased (assoc = -1): local inserts
|
| 181 |
+
* at the anchor do NOT advance its `.position`.
|
| 182 |
+
* - `typed` tracks "how much PM has advanced locally since claim".
|
| 183 |
+
* - Remote inserts before the anchor shift MP.position forward AND
|
| 184 |
+
* PM's selection forward by the same amount, so `typed` stays valid.
|
| 185 |
+
*
|
| 186 |
+
* Target position for the next keystroke is always `MP.position + typed`.
|
| 187 |
+
*/
|
| 188 |
+
export interface Slot {
|
| 189 |
+
id: string;
|
| 190 |
+
typed: number;
|
| 191 |
+
}
|
| 192 |
+
|
| 193 |
+
/**
|
| 194 |
+
* Focus the slot (DOM events to update PM selection first) AND capture a
|
| 195 |
+
* collaboration-safe MappablePosition at the slot's end. Returns a fresh
|
| 196 |
+
* `Slot` (typed = 0) or `null` if the slot can't be located.
|
| 197 |
+
*/
|
| 198 |
+
export async function claimSlot(
|
| 199 |
+
page: Page,
|
| 200 |
+
headingText: string,
|
| 201 |
+
slotIndex: number,
|
| 202 |
+
): Promise<Slot | null> {
|
| 203 |
+
const focused = await focusNthParagraphInSection(
|
| 204 |
+
page,
|
| 205 |
+
headingText,
|
| 206 |
+
slotIndex,
|
| 207 |
+
);
|
| 208 |
+
if (!focused) return null;
|
| 209 |
+
const id = await claimSlotPosition(page, headingText, slotIndex);
|
| 210 |
+
if (!id) return null;
|
| 211 |
+
return { id, typed: 0 };
|
| 212 |
+
}
|
| 213 |
+
|
| 214 |
+
/**
|
| 215 |
+
* Type `text` into a previously-claimed slot, re-anchoring the caret
|
| 216 |
+
* ONCE before typing begins and re-syncing the `typed` counter from
|
| 217 |
+
* PM's selection head afterwards.
|
| 218 |
+
*
|
| 219 |
+
* Why anchor once and not per-keystroke?
|
| 220 |
+
* --------------------------------------
|
| 221 |
+
* Per-keystroke re-anchoring (what `anchorBeforeKey` used to do) resets
|
| 222 |
+
* the PM caret back to `MP.position + typed` before every character.
|
| 223 |
+
* But the left-biased MP doesn't advance with local typing, so if the
|
| 224 |
+
* counter drifts even by one, every subsequent character is inserted at
|
| 225 |
+
* the same spot - producing reversed text ("ello" types as "olleh").
|
| 226 |
+
*
|
| 227 |
+
* Instead we set the caret once, let ProseMirror's natural "caret
|
| 228 |
+
* follows my typing" behavior take over, and re-read the final caret
|
| 229 |
+
* position when humanType resolves. That also makes the helper robust
|
| 230 |
+
* against input rules that replace `$$Q$$` with a single inline-math
|
| 231 |
+
* node (text.length would lie; PM's cursor tells the truth).
|
| 232 |
+
*
|
| 233 |
+
* If `slot` is null the helper degrades to plain `humanType` so callers
|
| 234 |
+
* don't have to branch.
|
| 235 |
+
*/
|
| 236 |
+
export async function typeInSlot(
|
| 237 |
+
page: Page,
|
| 238 |
+
slot: Slot | null,
|
| 239 |
+
text: string,
|
| 240 |
+
options: HumanTypingOptions = {},
|
| 241 |
+
): Promise<void> {
|
| 242 |
+
if (!slot) {
|
| 243 |
+
await humanType(page, text, options);
|
| 244 |
+
return;
|
| 245 |
+
}
|
| 246 |
+
|
| 247 |
+
await page.evaluate(
|
| 248 |
+
({ id, offset }) => {
|
| 249 |
+
const g = globalThis as unknown as {
|
| 250 |
+
__name?: <T>(fn: T) => T;
|
| 251 |
+
__demoEditor?: {
|
| 252 |
+
commands: {
|
| 253 |
+
setTextSelection: (p: number) => boolean;
|
| 254 |
+
focus: () => boolean;
|
| 255 |
+
};
|
| 256 |
+
state: { doc: { content: { size: number } } };
|
| 257 |
+
};
|
| 258 |
+
__demoPositions?: Map<string, { position: number }>;
|
| 259 |
+
};
|
| 260 |
+
if (typeof g.__name !== "function") g.__name = <T,>(fn: T) => fn;
|
| 261 |
+
|
| 262 |
+
const editor = g.__demoEditor;
|
| 263 |
+
const entry = g.__demoPositions?.get(id);
|
| 264 |
+
if (!editor || !entry) return;
|
| 265 |
+
const size = editor.state.doc.content.size;
|
| 266 |
+
const target = Math.max(1, Math.min(entry.position + offset, size - 1));
|
| 267 |
+
try {
|
| 268 |
+
editor.commands.focus();
|
| 269 |
+
editor.commands.setTextSelection(target);
|
| 270 |
+
} catch {
|
| 271 |
+
/* PM will fall back to whatever selection exists */
|
| 272 |
+
}
|
| 273 |
+
},
|
| 274 |
+
{ id: slot.id, offset: slot.typed },
|
| 275 |
+
);
|
| 276 |
+
|
| 277 |
+
// Strip any lingering `beforeKey` option: this helper owns anchoring.
|
| 278 |
+
const {
|
| 279 |
+
beforeKey: _ignored, // eslint-disable-line @typescript-eslint/no-unused-vars
|
| 280 |
+
...rest
|
| 281 |
+
} = options;
|
| 282 |
+
await humanType(page, text, rest);
|
| 283 |
+
|
| 284 |
+
const newOffset = await page.evaluate((id) => {
|
| 285 |
+
const g = globalThis as unknown as {
|
| 286 |
+
__demoEditor?: { state: { selection: { head: number } } };
|
| 287 |
+
__demoPositions?: Map<string, { position: number }>;
|
| 288 |
+
};
|
| 289 |
+
const editor = g.__demoEditor;
|
| 290 |
+
const entry = g.__demoPositions?.get(id);
|
| 291 |
+
if (!editor || !entry) return null;
|
| 292 |
+
return editor.state.selection.head - entry.position;
|
| 293 |
+
}, slot.id);
|
| 294 |
+
|
| 295 |
+
if (typeof newOffset === "number" && Number.isFinite(newOffset)) {
|
| 296 |
+
slot.typed = newOffset;
|
| 297 |
+
} else {
|
| 298 |
+
// PM's selection is unknown - approximate by text length so the
|
| 299 |
+
// next call at least moves forward instead of re-typing over itself.
|
| 300 |
+
slot.typed += text.length;
|
| 301 |
+
}
|
| 302 |
+
}
|
|
@@ -0,0 +1,249 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Alice's closing rituals for the showcase demo:
|
| 3 |
+
* - applyHueChange open Settings, drag the Hue slider live
|
| 4 |
+
* - triggerPublish fire the real Publish pipeline, land on the
|
| 5 |
+
* published page
|
| 6 |
+
*
|
| 7 |
+
* Both beats are last-act material: they change globally-visible
|
| 8 |
+
* state (palette + URL), so they run after Bob and Carol are done to
|
| 9 |
+
* avoid stomping on their edits mid-recording.
|
| 10 |
+
*/
|
| 11 |
+
import type { Page } from "playwright";
|
| 12 |
+
import { pause } from "../human-typing.js";
|
| 13 |
+
|
| 14 |
+
/**
|
| 15 |
+
* Drag the primary-hue slider in the Settings drawer from its current
|
| 16 |
+
* position to `targetHue`, slowly enough that the viewer can see the
|
| 17 |
+
* whole article retint smoothly - the default warm yellow easing
|
| 18 |
+
* into a cool blue as the cursor crosses the rainbow gradient.
|
| 19 |
+
*
|
| 20 |
+
* Why UI drag (and not a direct `settingsMap.set`)
|
| 21 |
+
* -----------------------------------------------
|
| 22 |
+
* The instant-write version worked but the colour snapped in a
|
| 23 |
+
* single frame and was easy to miss in the recording. Running the
|
| 24 |
+
* real slider pointer-events emits `onChange` on every mouse move,
|
| 25 |
+
* which writes intermediate values to the shared Yjs `settings`
|
| 26 |
+
* map. Each write propagates to every peer, so Alice's visible
|
| 27 |
+
* window actually animates through the gradient. It also shows
|
| 28 |
+
* the cog -> drawer -> slider interaction on screen, which reads
|
| 29 |
+
* as "a human shipped the palette change" rather than "the colour
|
| 30 |
+
* magically changed".
|
| 31 |
+
*/
|
| 32 |
+
export async function applyHueChange(
|
| 33 |
+
page: Page,
|
| 34 |
+
args: {
|
| 35 |
+
from?: number;
|
| 36 |
+
to: number;
|
| 37 |
+
steps?: number;
|
| 38 |
+
stepMs?: [number, number];
|
| 39 |
+
},
|
| 40 |
+
) {
|
| 41 |
+
const fromHue = Math.max(0, Math.min(360, args.from ?? 47));
|
| 42 |
+
const toHue = Math.max(0, Math.min(360, args.to));
|
| 43 |
+
// Very snappy on camera: ~7 discrete pointer moves at ~16ms each
|
| 44 |
+
// => ~0.12s of drag. Fewer steps look like a snap; more steps get
|
| 45 |
+
// batched by React and visually collapse into a jump.
|
| 46 |
+
const steps = Math.max(5, args.steps ?? 7);
|
| 47 |
+
const [delayMin, delayMax] = args.stepMs ?? [12, 20];
|
| 48 |
+
|
| 49 |
+
// 1) Open the drawer via the cog button in the TopBar.
|
| 50 |
+
const cog = page.getByRole("button", { name: "Article settings" }).first();
|
| 51 |
+
try {
|
| 52 |
+
await cog.waitFor({ state: "visible", timeout: 5_000 });
|
| 53 |
+
await cog.hover().catch(() => undefined);
|
| 54 |
+
await pause(40, 80);
|
| 55 |
+
await cog.click();
|
| 56 |
+
} catch {
|
| 57 |
+
console.warn("[hue] settings cog not found");
|
| 58 |
+
return;
|
| 59 |
+
}
|
| 60 |
+
|
| 61 |
+
// Drawer slide-in: fast enough to stay snappy, long enough for
|
| 62 |
+
// the viewer to register that a panel just opened.
|
| 63 |
+
await pause(220, 320);
|
| 64 |
+
|
| 65 |
+
// 2) Grab the Hue slider once the drawer is in the DOM.
|
| 66 |
+
const slider = page.getByRole("slider", { name: "Hue" }).first();
|
| 67 |
+
try {
|
| 68 |
+
await slider.waitFor({ state: "visible", timeout: 5_000 });
|
| 69 |
+
} catch {
|
| 70 |
+
console.warn("[hue] slider not visible");
|
| 71 |
+
await page.keyboard.press("Escape").catch(() => undefined);
|
| 72 |
+
return;
|
| 73 |
+
}
|
| 74 |
+
await slider.scrollIntoViewIfNeeded().catch(() => undefined);
|
| 75 |
+
|
| 76 |
+
const box = await slider.boundingBox();
|
| 77 |
+
if (!box) {
|
| 78 |
+
console.warn("[hue] slider has no bounding box");
|
| 79 |
+
await page.keyboard.press("Escape").catch(() => undefined);
|
| 80 |
+
return;
|
| 81 |
+
}
|
| 82 |
+
|
| 83 |
+
// Inset by 6px so the thumb never pins against the rounded ends.
|
| 84 |
+
const inset = 6;
|
| 85 |
+
const y = box.y + box.height / 2;
|
| 86 |
+
const usableLeft = box.x + inset;
|
| 87 |
+
const usableWidth = Math.max(1, box.width - inset * 2);
|
| 88 |
+
const startX = usableLeft + usableWidth * (fromHue / 360);
|
| 89 |
+
const endX = usableLeft + usableWidth * (toHue / 360);
|
| 90 |
+
|
| 91 |
+
// 3) Drift the cursor to the slider thumb first (no drag yet), so
|
| 92 |
+
// the viewer's eye follows the mouse into the drawer before
|
| 93 |
+
// the press-and-drag begins. Kept short so the whole interaction
|
| 94 |
+
// reads as "a snappy human edit" rather than a slow ceremony.
|
| 95 |
+
await page.mouse.move(startX - 40, y - 20, { steps: 3 });
|
| 96 |
+
await pause(30, 60);
|
| 97 |
+
await page.mouse.move(startX, y, { steps: 4 });
|
| 98 |
+
await pause(40, 70);
|
| 99 |
+
|
| 100 |
+
// 4) Press down ON the slider. `setPointerCapture` locks further
|
| 101 |
+
// pointermove events to this element, so the drag is robust
|
| 102 |
+
// even if our Y coord drifts a pixel or two during the move
|
| 103 |
+
// loop.
|
| 104 |
+
await page.mouse.down();
|
| 105 |
+
// Let React flush the `setDragging(true)` state update before the
|
| 106 |
+
// first move event - otherwise the first onPointerMove short-
|
| 107 |
+
// circuits on `if (!dragging) return;` and we lose the leading
|
| 108 |
+
// frame of the animation.
|
| 109 |
+
await pause(30, 60);
|
| 110 |
+
|
| 111 |
+
// 5) Smoothly drag to the target. Every Playwright sub-step fires
|
| 112 |
+
// an intermediate pointermove, so each outer iteration produces
|
| 113 |
+
// several state updates and React has time to paint between
|
| 114 |
+
// them. That's what makes the thumb visibly walk across the
|
| 115 |
+
// gradient rather than teleport.
|
| 116 |
+
for (let i = 1; i <= steps; i++) {
|
| 117 |
+
const t = i / steps;
|
| 118 |
+
const x = startX + (endX - startX) * t;
|
| 119 |
+
await page.mouse.move(x, y, { steps: 2 });
|
| 120 |
+
await pause(delayMin, delayMax);
|
| 121 |
+
}
|
| 122 |
+
|
| 123 |
+
await page.mouse.up();
|
| 124 |
+
await pause(120, 180);
|
| 125 |
+
|
| 126 |
+
// 6) Close the drawer so the final state is "clean article + new
|
| 127 |
+
// palette" without the settings panel hanging around on
|
| 128 |
+
// camera.
|
| 129 |
+
const closeDrawer = page
|
| 130 |
+
.locator(".settings-drawer button[aria-label='Close']")
|
| 131 |
+
.first();
|
| 132 |
+
if (await closeDrawer.isVisible().catch(() => false)) {
|
| 133 |
+
await closeDrawer.click();
|
| 134 |
+
} else {
|
| 135 |
+
await page.keyboard.press("Escape").catch(() => undefined);
|
| 136 |
+
}
|
| 137 |
+
await pause(100, 160);
|
| 138 |
+
}
|
| 139 |
+
|
| 140 |
+
/**
|
| 141 |
+
* Hero's closing move: open the Publish dialog, click the real
|
| 142 |
+
* "Publish" button inside it, wait for the SSE pipeline to land on
|
| 143 |
+
* the success state, then click "View published article" and linger
|
| 144 |
+
* on the published page so the recording ends on the real output.
|
| 145 |
+
*
|
| 146 |
+
* The full dev pipeline (extract -> bibliography -> render ->
|
| 147 |
+
* thumbnail -> PDF -> write -> upload) typically takes 15-25s. We
|
| 148 |
+
* wait up to `successTimeoutMs` for the "View published article"
|
| 149 |
+
* link to appear; if it doesn't, we bail gracefully and let the
|
| 150 |
+
* caller's final pause keep the dialog on camera.
|
| 151 |
+
*/
|
| 152 |
+
export async function triggerPublish(
|
| 153 |
+
page: Page,
|
| 154 |
+
opts?: { successTimeoutMs?: number; viewLingerMs?: [number, number] },
|
| 155 |
+
) {
|
| 156 |
+
const successTimeoutMs = opts?.successTimeoutMs ?? 45_000;
|
| 157 |
+
const [lingerMin, lingerMax] = opts?.viewLingerMs ?? [3_500, 4_500];
|
| 158 |
+
|
| 159 |
+
// 1) Ask the app to open the modal (uses the dev-only
|
| 160 |
+
// `__demo-publish` event which calls `openPublishDialog`
|
| 161 |
+
// without a network round-trip). Preferred over clicking
|
| 162 |
+
// the TopBar button because it's immune to layout regressions.
|
| 163 |
+
await page.evaluate(() => {
|
| 164 |
+
window.dispatchEvent(
|
| 165 |
+
new CustomEvent("__demo-publish", { detail: { open: true } }),
|
| 166 |
+
);
|
| 167 |
+
});
|
| 168 |
+
await pause(600, 900);
|
| 169 |
+
|
| 170 |
+
// 2) Click the "Publish" button inside the dialog. This hits the
|
| 171 |
+
// real `/api/publish` endpoint; the stages that stream back
|
| 172 |
+
// (extract / render / rasterize / write / upload) are what
|
| 173 |
+
// makes the closing act feel alive.
|
| 174 |
+
const publishBtn = page
|
| 175 |
+
.locator("dialog[open].ed-dialog .btn--primary")
|
| 176 |
+
.filter({ hasText: /^Publish$/ })
|
| 177 |
+
.first();
|
| 178 |
+
try {
|
| 179 |
+
await publishBtn.waitFor({ state: "visible", timeout: 4_000 });
|
| 180 |
+
await publishBtn.click();
|
| 181 |
+
console.log("[publish] clicked Publish, waiting for success...");
|
| 182 |
+
} catch {
|
| 183 |
+
console.warn("[publish] primary button not found in dialog");
|
| 184 |
+
return;
|
| 185 |
+
}
|
| 186 |
+
|
| 187 |
+
// 3) Wait for the success state. When state becomes "success",
|
| 188 |
+
// the dialog swaps its CTA for a "View published article"
|
| 189 |
+
// link pointing at /published/<doc>/index.html. We look for
|
| 190 |
+
// that link by its text; its presence is a reliable success
|
| 191 |
+
// signal because the error state uses a different node
|
| 192 |
+
// entirely.
|
| 193 |
+
const viewLink = page
|
| 194 |
+
.locator("dialog[open].ed-dialog a")
|
| 195 |
+
.filter({ hasText: /View published article/i })
|
| 196 |
+
.first();
|
| 197 |
+
try {
|
| 198 |
+
await viewLink.waitFor({ state: "visible", timeout: successTimeoutMs });
|
| 199 |
+
console.log("[publish] success - published article is ready");
|
| 200 |
+
} catch {
|
| 201 |
+
console.warn(
|
| 202 |
+
`[publish] did not reach success within ${Math.round(successTimeoutMs / 1000)}s; staying on dialog`,
|
| 203 |
+
);
|
| 204 |
+
return;
|
| 205 |
+
}
|
| 206 |
+
|
| 207 |
+
// Tiny beat so the "Published!" title is readable before the click.
|
| 208 |
+
await pause(600, 900);
|
| 209 |
+
|
| 210 |
+
// 4) Navigate to the published article IN THE SAME TAB.
|
| 211 |
+
// The link is rendered as `target="_blank"` for real users, which
|
| 212 |
+
// is the right UX outside of the demo. But in --app=/fullscreen
|
| 213 |
+
// record mode, target="_blank" spawns a regular windowed Chrome
|
| 214 |
+
// (with tabs + URL bar, NOT fullscreen) because app windows
|
| 215 |
+
// can't host tabs. That regular window also re-hydrates
|
| 216 |
+
// Chrome's translate heuristics and pops a "Translate to French"
|
| 217 |
+
// bubble over the recording on OS-French systems.
|
| 218 |
+
//
|
| 219 |
+
// Reading `href` + `page.goto` keeps us in Alice's chromeless
|
| 220 |
+
// fullscreen window: no new process, no translate prompt, no
|
| 221 |
+
// visible URL bar. Exactly the frame the recording needs.
|
| 222 |
+
const publishedHref = await viewLink.getAttribute("href");
|
| 223 |
+
if (!publishedHref) {
|
| 224 |
+
console.warn("[publish] view link has no href, cannot navigate");
|
| 225 |
+
return;
|
| 226 |
+
}
|
| 227 |
+
|
| 228 |
+
// Resolve relative URLs against the current page origin so
|
| 229 |
+
// `/published/xyz/index.html` works unchanged.
|
| 230 |
+
const targetUrl = new URL(publishedHref, page.url()).toString();
|
| 231 |
+
try {
|
| 232 |
+
await page.goto(targetUrl, {
|
| 233 |
+
waitUntil: "domcontentloaded",
|
| 234 |
+
timeout: 15_000,
|
| 235 |
+
});
|
| 236 |
+
await page
|
| 237 |
+
.waitForLoadState("load", { timeout: 5_000 })
|
| 238 |
+
.catch(() => undefined);
|
| 239 |
+
console.log("[publish] viewing published article (in-place)");
|
| 240 |
+
} catch (err) {
|
| 241 |
+
console.warn(
|
| 242 |
+
"[publish] navigation to published article failed:",
|
| 243 |
+
err instanceof Error ? err.message : err,
|
| 244 |
+
);
|
| 245 |
+
return;
|
| 246 |
+
}
|
| 247 |
+
|
| 248 |
+
await pause(lingerMin, lingerMax);
|
| 249 |
+
}
|
|
@@ -0,0 +1,137 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* lib/recording.ts
|
| 3 |
+
*
|
| 4 |
+
* macOS auto screen recording via `screencapture -v`, plus the manual
|
| 5 |
+
* countdown used when the user prefers to drive QuickTime/Loom/OBS
|
| 6 |
+
* themselves. All recording concerns live here so showcase.ts can focus
|
| 7 |
+
* on orchestration.
|
| 8 |
+
*/
|
| 9 |
+
import { spawn, type ChildProcess } from "node:child_process";
|
| 10 |
+
import { mkdirSync } from "node:fs";
|
| 11 |
+
import { join } from "node:path";
|
| 12 |
+
|
| 13 |
+
export interface ScreenRecording {
|
| 14 |
+
outputPath: string;
|
| 15 |
+
/**
|
| 16 |
+
* Timestamp (ms since epoch) at which `screencapture` will naturally
|
| 17 |
+
* exit because of its `-V <duration>` flag. Consumers use this to know
|
| 18 |
+
* when the file becomes playable.
|
| 19 |
+
*/
|
| 20 |
+
finishesAt: number;
|
| 21 |
+
/**
|
| 22 |
+
* Resolves once the screencapture process has exited on its own. The
|
| 23 |
+
* resulting .mov is only guaranteed to be playable after this resolves.
|
| 24 |
+
*/
|
| 25 |
+
waitForFinish: () => Promise<void>;
|
| 26 |
+
/**
|
| 27 |
+
* Emergency abort: kills the process without waiting. The output file
|
| 28 |
+
* will be unplayable (see the comment on `-V`). Only use this on
|
| 29 |
+
* SIGINT / fatal errors where you'd throw the recording away anyway.
|
| 30 |
+
*/
|
| 31 |
+
abort: () => void;
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
/**
|
| 35 |
+
* Launches `screencapture -v -V <duration> <file.mov>` as a detached
|
| 36 |
+
* process. It records for exactly `duration` seconds then exits cleanly.
|
| 37 |
+
*
|
| 38 |
+
* Important quirk learned the hard way: sending SIGINT, SIGTERM, SIGHUP,
|
| 39 |
+
* SIGUSR1 or SIGUSR2 to `screencapture -v` kills it WITHOUT flushing the
|
| 40 |
+
* .mov container, leaving a 0-byte unplayable file. The only reliable
|
| 41 |
+
* way to obtain a valid recording is to let the `-V` timer elapse.
|
| 42 |
+
*
|
| 43 |
+
* Also: `screencapture -v` silently bails out if stdin is /dev/null, so
|
| 44 |
+
* we wire all three stdio streams to pipes.
|
| 45 |
+
*
|
| 46 |
+
* macOS prompts for Screen Recording permission the first time; grant it
|
| 47 |
+
* to the process running node (Terminal, iTerm, Cursor, etc.).
|
| 48 |
+
*/
|
| 49 |
+
export async function startScreenRecording(
|
| 50 |
+
outputPath: string,
|
| 51 |
+
durationSeconds: number,
|
| 52 |
+
): Promise<ScreenRecording | null> {
|
| 53 |
+
if (process.platform !== "darwin") {
|
| 54 |
+
console.warn(
|
| 55 |
+
"[showcase] --capture is macOS-only (needs /usr/sbin/screencapture). Skipping.",
|
| 56 |
+
);
|
| 57 |
+
return null;
|
| 58 |
+
}
|
| 59 |
+
|
| 60 |
+
mkdirSync(join(outputPath, ".."), { recursive: true });
|
| 61 |
+
|
| 62 |
+
const proc: ChildProcess = spawn(
|
| 63 |
+
"screencapture",
|
| 64 |
+
["-v", "-V", String(durationSeconds), outputPath],
|
| 65 |
+
{
|
| 66 |
+
stdio: ["pipe", "pipe", "pipe"],
|
| 67 |
+
detached: true,
|
| 68 |
+
},
|
| 69 |
+
);
|
| 70 |
+
const startedAt = Date.now();
|
| 71 |
+
|
| 72 |
+
let earlyStdout = "";
|
| 73 |
+
let earlyError = "";
|
| 74 |
+
proc.stdout?.on("data", (chunk: Buffer) => {
|
| 75 |
+
earlyStdout += chunk.toString();
|
| 76 |
+
});
|
| 77 |
+
proc.stderr?.on("data", (chunk: Buffer) => {
|
| 78 |
+
earlyError += chunk.toString();
|
| 79 |
+
});
|
| 80 |
+
|
| 81 |
+
// screencapture takes ~0.5s to spin up; if it's going to crash (e.g.
|
| 82 |
+
// no permission), it does so immediately. Give it a moment, then check.
|
| 83 |
+
await new Promise((r) => setTimeout(r, 1200));
|
| 84 |
+
if (proc.exitCode !== null) {
|
| 85 |
+
const details =
|
| 86 |
+
[earlyError.trim(), earlyStdout.trim()].filter(Boolean).join(" | ") ||
|
| 87 |
+
"no output";
|
| 88 |
+
console.warn(
|
| 89 |
+
`[showcase] screencapture failed to start (exit=${proc.exitCode}): ${details}`,
|
| 90 |
+
);
|
| 91 |
+
return null;
|
| 92 |
+
}
|
| 93 |
+
|
| 94 |
+
const waitForFinish = () =>
|
| 95 |
+
new Promise<void>((resolve) => {
|
| 96 |
+
if (proc.exitCode !== null) {
|
| 97 |
+
resolve();
|
| 98 |
+
return;
|
| 99 |
+
}
|
| 100 |
+
proc.once("exit", () => resolve());
|
| 101 |
+
});
|
| 102 |
+
|
| 103 |
+
const abort = () => {
|
| 104 |
+
try {
|
| 105 |
+
proc.kill("SIGKILL");
|
| 106 |
+
} catch {
|
| 107 |
+
/* ignore */
|
| 108 |
+
}
|
| 109 |
+
};
|
| 110 |
+
|
| 111 |
+
return {
|
| 112 |
+
outputPath,
|
| 113 |
+
finishesAt: startedAt + durationSeconds * 1000,
|
| 114 |
+
waitForFinish,
|
| 115 |
+
abort,
|
| 116 |
+
};
|
| 117 |
+
}
|
| 118 |
+
|
| 119 |
+
export function buildRecordingPath(): string {
|
| 120 |
+
const stamp = new Date()
|
| 121 |
+
.toISOString()
|
| 122 |
+
.replace(/[:.]/g, "-")
|
| 123 |
+
.replace("T", "_")
|
| 124 |
+
.slice(0, 19);
|
| 125 |
+
return join(process.cwd(), "demo", "recordings", `showcase-${stamp}.mov`);
|
| 126 |
+
}
|
| 127 |
+
|
| 128 |
+
export async function recordingCountdown(seconds: number): Promise<void> {
|
| 129 |
+
console.log("\n[showcase] === RECORDING MODE ===");
|
| 130 |
+
console.log("[showcase] Alice is up in fullscreen app mode.");
|
| 131 |
+
console.log("[showcase] Start your screen recorder NOW.\n");
|
| 132 |
+
for (let i = seconds; i > 0; i--) {
|
| 133 |
+
console.log(`[showcase] demo starts in ${i}...`);
|
| 134 |
+
await new Promise((r) => setTimeout(r, 1000));
|
| 135 |
+
}
|
| 136 |
+
console.log("[showcase] action!\n");
|
| 137 |
+
}
|
|
@@ -0,0 +1,191 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* lib/scaffolding.ts
|
| 3 |
+
*
|
| 4 |
+
* Document scaffolding helpers for the showcase demo.
|
| 5 |
+
*
|
| 6 |
+
* The scaffold is what lets 3 personas type concurrently without
|
| 7 |
+
* stepping on each other's paragraphs: one persona pre-creates a
|
| 8 |
+
* Heading 2 + N empty <p> "slots" per section, then agents target
|
| 9 |
+
* specific slots by (heading, index) instead of blindly pressing Enter.
|
| 10 |
+
* Pressing Enter during the parallel phase is what causes agent
|
| 11 |
+
* overlap (one persona's trailing text gets absorbed into another's
|
| 12 |
+
* code fence).
|
| 13 |
+
*/
|
| 14 |
+
import type { Page } from "playwright";
|
| 15 |
+
import { humanType, pause } from "../human-typing.js";
|
| 16 |
+
|
| 17 |
+
export interface ScaffoldSection {
|
| 18 |
+
heading: string;
|
| 19 |
+
/** Number of empty <p> slots to pre-create below the heading. */
|
| 20 |
+
slots: number;
|
| 21 |
+
}
|
| 22 |
+
|
| 23 |
+
/**
|
| 24 |
+
* Build the article scaffold in one persona pass: each section gets a
|
| 25 |
+
* Heading 2 and `slots` pre-created empty paragraphs underneath.
|
| 26 |
+
*
|
| 27 |
+
* Example output for [{ heading: "The recipe", slots: 3 }, ...]:
|
| 28 |
+
* <h2>The recipe</h2>
|
| 29 |
+
* <p></p> <- slot 0
|
| 30 |
+
* <p></p> <- slot 1
|
| 31 |
+
* <p></p> <- slot 2
|
| 32 |
+
* <h2>Intuition</h2>
|
| 33 |
+
* <p></p> <- slot 0
|
| 34 |
+
* ...
|
| 35 |
+
*/
|
| 36 |
+
export async function writeScaffold(
|
| 37 |
+
page: Page,
|
| 38 |
+
sections: ScaffoldSection[],
|
| 39 |
+
speed: number,
|
| 40 |
+
): Promise<void> {
|
| 41 |
+
for (let s = 0; s < sections.length; s++) {
|
| 42 |
+
const section = sections[s];
|
| 43 |
+
const isLast = s === sections.length - 1;
|
| 44 |
+
|
| 45 |
+
await page.keyboard.type("## ", { delay: 22 });
|
| 46 |
+
await pause(40, 80);
|
| 47 |
+
await humanType(page, section.heading, {
|
| 48 |
+
speed: speed * 2.2,
|
| 49 |
+
typoRate: 0,
|
| 50 |
+
thinkRate: 0.01,
|
| 51 |
+
});
|
| 52 |
+
// Enter budget explanation: the first Enter exits the heading into a
|
| 53 |
+
// fresh empty <p>; each subsequent Enter appends another empty <p>.
|
| 54 |
+
// For a non-last section we also need ONE extra trailing <p> that
|
| 55 |
+
// will be consumed by the next heading's "## " input rule, otherwise
|
| 56 |
+
// we'd lose the section's last slot. The last section doesn't need
|
| 57 |
+
// that extra trailing paragraph.
|
| 58 |
+
const enters = isLast ? section.slots : section.slots + 1;
|
| 59 |
+
for (let i = 0; i < enters; i++) {
|
| 60 |
+
await page.keyboard.press("Enter");
|
| 61 |
+
await pause(40, 80);
|
| 62 |
+
}
|
| 63 |
+
}
|
| 64 |
+
}
|
| 65 |
+
|
| 66 |
+
export async function waitForHeading2(
|
| 67 |
+
page: Page,
|
| 68 |
+
text: string,
|
| 69 |
+
timeout = 60_000,
|
| 70 |
+
): Promise<void> {
|
| 71 |
+
await page
|
| 72 |
+
.locator(".ProseMirror h2")
|
| 73 |
+
.filter({ hasText: text })
|
| 74 |
+
.first()
|
| 75 |
+
.waitFor({ state: "visible", timeout });
|
| 76 |
+
}
|
| 77 |
+
|
| 78 |
+
/**
|
| 79 |
+
* Place this browser's caret at the END of the first <p> directly after
|
| 80 |
+
* the heading whose text matches `headingText`. Used so each persona can
|
| 81 |
+
* type into its OWN section concurrently without stepping on the others.
|
| 82 |
+
*
|
| 83 |
+
* Returns false if the heading or following paragraph cannot be found.
|
| 84 |
+
*/
|
| 85 |
+
export async function focusParagraphAfterHeading(
|
| 86 |
+
page: Page,
|
| 87 |
+
headingText: string,
|
| 88 |
+
): Promise<boolean> {
|
| 89 |
+
return await page.evaluate((text) => {
|
| 90 |
+
const root = document.querySelector(".ProseMirror") as HTMLElement | null;
|
| 91 |
+
if (!root) return false;
|
| 92 |
+
const headings = Array.from(root.querySelectorAll("h2"));
|
| 93 |
+
const wanted = text.toLowerCase();
|
| 94 |
+
const heading = headings.find(
|
| 95 |
+
(h) => (h.textContent || "").trim().toLowerCase().includes(wanted),
|
| 96 |
+
);
|
| 97 |
+
if (!heading) return false;
|
| 98 |
+
let para: Element | null = heading.nextElementSibling;
|
| 99 |
+
while (para && para.tagName !== "P") para = para.nextElementSibling;
|
| 100 |
+
if (!para) return false;
|
| 101 |
+
(para as HTMLElement).focus();
|
| 102 |
+
const range = document.createRange();
|
| 103 |
+
range.selectNodeContents(para);
|
| 104 |
+
range.collapse(false);
|
| 105 |
+
const sel = window.getSelection();
|
| 106 |
+
if (!sel) return false;
|
| 107 |
+
sel.removeAllRanges();
|
| 108 |
+
sel.addRange(range);
|
| 109 |
+
return true;
|
| 110 |
+
}, headingText);
|
| 111 |
+
}
|
| 112 |
+
|
| 113 |
+
/**
|
| 114 |
+
* Place the caret at the end of the Nth slot following the heading whose
|
| 115 |
+
* text matches `headingText`. "Slots" are any non-heading sibling of the
|
| 116 |
+
* heading up to the next heading, so a slot index stays stable even if
|
| 117 |
+
* its element gets transformed in place (<p> -> <pre> for code fences,
|
| 118 |
+
* <p> -> math block, etc.).
|
| 119 |
+
*
|
| 120 |
+
* Uses synthesized mousedown+mouseup+click events on the target's
|
| 121 |
+
* bounding rect - ProseMirror listens to these natively and updates its
|
| 122 |
+
* internal selection state. Also sets the DOM Range as a belt-and-braces
|
| 123 |
+
* fallback. Single page.evaluate call: no shared CSS marker class to
|
| 124 |
+
* race on between concurrent personas.
|
| 125 |
+
*/
|
| 126 |
+
export async function focusNthParagraphInSection(
|
| 127 |
+
page: Page,
|
| 128 |
+
headingText: string,
|
| 129 |
+
slotIndex: number,
|
| 130 |
+
): Promise<boolean> {
|
| 131 |
+
return await page.evaluate(
|
| 132 |
+
({ text, n }) => {
|
| 133 |
+
const g = globalThis as unknown as { __name?: <T>(fn: T) => T };
|
| 134 |
+
if (typeof g.__name !== "function") g.__name = <T,>(fn: T) => fn;
|
| 135 |
+
|
| 136 |
+
const root = document.querySelector(".ProseMirror") as HTMLElement | null;
|
| 137 |
+
if (!root) return false;
|
| 138 |
+
const headings = Array.from(root.querySelectorAll("h2"));
|
| 139 |
+
const wanted = text.toLowerCase();
|
| 140 |
+
const heading = headings.find(
|
| 141 |
+
(h) => (h.textContent || "").trim().toLowerCase().includes(wanted),
|
| 142 |
+
);
|
| 143 |
+
if (!heading) return false;
|
| 144 |
+
|
| 145 |
+
const slots: HTMLElement[] = [];
|
| 146 |
+
let node: Element | null = heading.nextElementSibling;
|
| 147 |
+
while (node) {
|
| 148 |
+
if (/^H[1-6]$/.test(node.tagName)) break;
|
| 149 |
+
slots.push(node as HTMLElement);
|
| 150 |
+
node = node.nextElementSibling;
|
| 151 |
+
}
|
| 152 |
+
|
| 153 |
+
const target = slots[n];
|
| 154 |
+
if (!target) return false;
|
| 155 |
+
target.scrollIntoView({ behavior: "auto", block: "center" });
|
| 156 |
+
|
| 157 |
+
// Pick a click point at the END of the paragraph's visible box so
|
| 158 |
+
// the caret lands after any existing content (still safe for
|
| 159 |
+
// empty paragraphs).
|
| 160 |
+
const rect = target.getBoundingClientRect();
|
| 161 |
+
const x = Math.max(rect.left + 1, rect.right - 4);
|
| 162 |
+
const y = rect.top + rect.height / 2;
|
| 163 |
+
|
| 164 |
+
const mk = (type: string) =>
|
| 165 |
+
new MouseEvent(type, {
|
| 166 |
+
bubbles: true,
|
| 167 |
+
cancelable: true,
|
| 168 |
+
view: window,
|
| 169 |
+
clientX: x,
|
| 170 |
+
clientY: y,
|
| 171 |
+
button: 0,
|
| 172 |
+
buttons: type === "mousedown" ? 1 : 0,
|
| 173 |
+
});
|
| 174 |
+
|
| 175 |
+
target.dispatchEvent(mk("mousedown"));
|
| 176 |
+
target.dispatchEvent(mk("mouseup"));
|
| 177 |
+
target.dispatchEvent(mk("click"));
|
| 178 |
+
|
| 179 |
+
const range = document.createRange();
|
| 180 |
+
range.selectNodeContents(target);
|
| 181 |
+
range.collapse(false);
|
| 182 |
+
const sel = window.getSelection();
|
| 183 |
+
if (sel) {
|
| 184 |
+
sel.removeAllRanges();
|
| 185 |
+
sel.addRange(range);
|
| 186 |
+
}
|
| 187 |
+
return true;
|
| 188 |
+
},
|
| 189 |
+
{ text: headingText, n: slotIndex },
|
| 190 |
+
);
|
| 191 |
+
}
|
|
@@ -0,0 +1,172 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* ProseMirror selection helpers for the showcase demo.
|
| 3 |
+
*
|
| 4 |
+
* All three helpers run inside `page.evaluate` because they need
|
| 5 |
+
* direct access to the editor instance exposed via `window.__demoEditor`
|
| 6 |
+
* (installed by `Editor.tsx` in dev mode). They return PM positions
|
| 7 |
+
* the caller forwards into `__demo-chat` events so the App-level
|
| 8 |
+
* action handlers can apply formatting / links / citations on a
|
| 9 |
+
* concrete range.
|
| 10 |
+
*/
|
| 11 |
+
import type { Page } from "playwright";
|
| 12 |
+
|
| 13 |
+
/**
|
| 14 |
+
* Read the current ProseMirror selection range. Returns null if the
|
| 15 |
+
* selection is collapsed or the editor isn't ready yet.
|
| 16 |
+
*
|
| 17 |
+
* Used by the chat-action helpers after `selectSubstringInParagraph`
|
| 18 |
+
* so we can pass exact PM positions into the `__demo-chat` event -
|
| 19 |
+
* works the same way for the legacy `replace` pipeline, where the
|
| 20 |
+
* *string-based* fallback in the App handler silently fails on
|
| 21 |
+
* paragraphs that contain inline math atoms.
|
| 22 |
+
*/
|
| 23 |
+
export async function getCurrentPmSelection(
|
| 24 |
+
page: Page,
|
| 25 |
+
): Promise<{ from: number; to: number } | null> {
|
| 26 |
+
return await page.evaluate(() => {
|
| 27 |
+
const editor = (
|
| 28 |
+
window as unknown as {
|
| 29 |
+
__demoEditor?: {
|
| 30 |
+
view: {
|
| 31 |
+
state: { selection: { from: number; to: number; empty: boolean } };
|
| 32 |
+
};
|
| 33 |
+
};
|
| 34 |
+
}
|
| 35 |
+
).__demoEditor;
|
| 36 |
+
if (!editor) return null;
|
| 37 |
+
const sel = editor.view.state.selection;
|
| 38 |
+
if (sel.empty || !(sel.to > sel.from)) return null;
|
| 39 |
+
return { from: sel.from, to: sel.to };
|
| 40 |
+
});
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
/**
|
| 44 |
+
* Select a plain-text substring *inside* a paragraph identified by a
|
| 45 |
+
* snippet of its surrounding text. Returns the resulting PM range so
|
| 46 |
+
* the caller can forward it to a `format` action, or null if the
|
| 47 |
+
* substring can't be located.
|
| 48 |
+
*
|
| 49 |
+
* How the lookup works
|
| 50 |
+
* --------------------
|
| 51 |
+
* We find the `<p>` whose textContent contains `paragraphSnippet`,
|
| 52 |
+
* walk its text nodes to map the `substring` onto a DOM start node +
|
| 53 |
+
* offset and end node + offset, then ask PM for the matching
|
| 54 |
+
* absolute positions via `view.posAtDOM`. Robust to marks already
|
| 55 |
+
* applied inside the paragraph (italics, links, ...) and works across
|
| 56 |
+
* text-node boundaries.
|
| 57 |
+
*
|
| 58 |
+
* Limitations
|
| 59 |
+
* -----------
|
| 60 |
+
* The substring MUST be plain text within the paragraph - it cannot
|
| 61 |
+
* cross an inline atom (math node, mention, image). `textContent`
|
| 62 |
+
* emits the atom's placeholder, which `indexOf` would match on while
|
| 63 |
+
* the DOM offsets would skip it. For "bold the three symbols"-style
|
| 64 |
+
* demos, target a clean phrase that doesn't straddle `$$...$$`.
|
| 65 |
+
*
|
| 66 |
+
* Side effects
|
| 67 |
+
* ------------
|
| 68 |
+
* On success, also seeds PM's selection AND broadcasts the range as
|
| 69 |
+
* the local user's `AgentFocus` so every peer sees a `<Name> agent`
|
| 70 |
+
* tinted range (same UX as when the chat panel opens over a manual
|
| 71 |
+
* selection).
|
| 72 |
+
*/
|
| 73 |
+
export async function selectSubstringInParagraph(
|
| 74 |
+
page: Page,
|
| 75 |
+
paragraphSnippet: string,
|
| 76 |
+
substring: string,
|
| 77 |
+
): Promise<{ from: number; to: number } | null> {
|
| 78 |
+
return await page.evaluate(
|
| 79 |
+
({ paraText, substrText }) => {
|
| 80 |
+
const g = globalThis as unknown as {
|
| 81 |
+
__name?: <T>(fn: T, name?: string) => T;
|
| 82 |
+
};
|
| 83 |
+
if (typeof g.__name !== "function") g.__name = <T,>(fn: T) => fn;
|
| 84 |
+
|
| 85 |
+
const root = document.querySelector(".ProseMirror") as HTMLElement | null;
|
| 86 |
+
if (!root) return null;
|
| 87 |
+
const para =
|
| 88 |
+
(Array.from(root.querySelectorAll("p")).find((p) =>
|
| 89 |
+
(p.textContent || "").includes(paraText),
|
| 90 |
+
) as HTMLElement | undefined) ?? null;
|
| 91 |
+
if (!para) return null;
|
| 92 |
+
para.scrollIntoView({ behavior: "smooth", block: "center" });
|
| 93 |
+
|
| 94 |
+
const text = para.textContent || "";
|
| 95 |
+
const idx = text.indexOf(substrText);
|
| 96 |
+
if (idx === -1) return null;
|
| 97 |
+
|
| 98 |
+
// Walk text nodes in order, counting characters, to find the
|
| 99 |
+
// DOM (node, offset) pairs that bracket the substring.
|
| 100 |
+
const walker = document.createTreeWalker(para, NodeFilter.SHOW_TEXT);
|
| 101 |
+
let walked = 0;
|
| 102 |
+
let startNode: Text | null = null;
|
| 103 |
+
let startOffset = 0;
|
| 104 |
+
let endNode: Text | null = null;
|
| 105 |
+
let endOffset = 0;
|
| 106 |
+
while (walker.nextNode()) {
|
| 107 |
+
const node = walker.currentNode as Text;
|
| 108 |
+
const len = node.nodeValue?.length ?? 0;
|
| 109 |
+
if (!startNode && walked + len > idx) {
|
| 110 |
+
startNode = node;
|
| 111 |
+
startOffset = idx - walked;
|
| 112 |
+
}
|
| 113 |
+
if (walked + len >= idx + substrText.length) {
|
| 114 |
+
endNode = node;
|
| 115 |
+
endOffset = idx + substrText.length - walked;
|
| 116 |
+
break;
|
| 117 |
+
}
|
| 118 |
+
walked += len;
|
| 119 |
+
}
|
| 120 |
+
if (!startNode || !endNode) return null;
|
| 121 |
+
|
| 122 |
+
const editor = (
|
| 123 |
+
window as unknown as {
|
| 124 |
+
__demoEditor?: {
|
| 125 |
+
view: {
|
| 126 |
+
posAtDOM: (node: Node, offset: number, bias?: number) => number;
|
| 127 |
+
};
|
| 128 |
+
commands: {
|
| 129 |
+
setTextSelection: (args: { from: number; to: number }) => boolean;
|
| 130 |
+
setAgentFocus: (args: { from: number; to: number }) => boolean;
|
| 131 |
+
};
|
| 132 |
+
};
|
| 133 |
+
}
|
| 134 |
+
).__demoEditor;
|
| 135 |
+
if (!editor) return null;
|
| 136 |
+
|
| 137 |
+
let from: number;
|
| 138 |
+
let to: number;
|
| 139 |
+
try {
|
| 140 |
+
from = editor.view.posAtDOM(startNode, startOffset, -1);
|
| 141 |
+
to = editor.view.posAtDOM(endNode, endOffset, 1);
|
| 142 |
+
} catch {
|
| 143 |
+
return null;
|
| 144 |
+
}
|
| 145 |
+
if (!Number.isFinite(from) || !Number.isFinite(to) || !(to > from)) {
|
| 146 |
+
return null;
|
| 147 |
+
}
|
| 148 |
+
|
| 149 |
+
editor.commands.setTextSelection({ from, to });
|
| 150 |
+
editor.commands.setAgentFocus({ from, to });
|
| 151 |
+
return { from, to };
|
| 152 |
+
},
|
| 153 |
+
{ paraText: paragraphSnippet, substrText: substring },
|
| 154 |
+
);
|
| 155 |
+
}
|
| 156 |
+
|
| 157 |
+
/**
|
| 158 |
+
* Clear the agent focus range broadcast by `selectSubstringInParagraph`.
|
| 159 |
+
* Call once the rewrite has fully landed and the settle animation is
|
| 160 |
+
* done, so the `<Name> agent` label disappears from every peer's view
|
| 161 |
+
* cleanly.
|
| 162 |
+
*/
|
| 163 |
+
export async function clearAgentFocus(page: Page) {
|
| 164 |
+
await page.evaluate(() => {
|
| 165 |
+
const editor = (
|
| 166 |
+
window as unknown as {
|
| 167 |
+
__demoEditor?: { commands: { clearAgentFocus: () => boolean } };
|
| 168 |
+
}
|
| 169 |
+
).__demoEditor;
|
| 170 |
+
editor?.commands?.clearAgentFocus?.();
|
| 171 |
+
});
|
| 172 |
+
}
|
|
@@ -0,0 +1,533 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* demo/showcase.ts
|
| 3 |
+
*
|
| 4 |
+
* Three-persona collaboration showcase for recording a mini product
|
| 5 |
+
* video. An empty article gets turned into a compact research piece
|
| 6 |
+
* on Transformer attention by three agents working in concert:
|
| 7 |
+
*
|
| 8 |
+
* - Bob writes "The recipe" (top): definition, block-math
|
| 9 |
+
* formula, softmax explanation. Mid-cycle he fires a
|
| 10 |
+
* chat citation tool call that adds the Vaswani 2017
|
| 11 |
+
* paper to the bibliography.
|
| 12 |
+
* - Alice (hero window) writes "Intuition" (middle): an analogy
|
| 13 |
+
* with inline $Q$ / $K$ / $V$, bolds a key phrase via the
|
| 14 |
+
* in-editor bubble toolbar, then asks the chat agent to
|
| 15 |
+
* rephrase the closing line (AgentRewrite typewriter).
|
| 16 |
+
* At the end she scrolls to the top, slowly drags the
|
| 17 |
+
* Hue slider (warm yellow -> cool blue, article retints
|
| 18 |
+
* live including the neural-net banner), clicks
|
| 19 |
+
* Publish, waits for the SSE pipeline to succeed, and
|
| 20 |
+
* finally clicks "View published article" so the
|
| 21 |
+
* recording ends on the real rendered output - the
|
| 22 |
+
* "ship it" payoff.
|
| 23 |
+
* - Carol writes "In Python" (bottom): intro line, link action
|
| 24 |
+
* on "PyTorch" (points at the real
|
| 25 |
+
* scaled_dot_product_attention docs), 8-line code
|
| 26 |
+
* block, Vaswani closing line.
|
| 27 |
+
*
|
| 28 |
+
* Three authors (Hugging Face, MIT/ETH Zurich, Stanford), a neural
|
| 29 |
+
* network banner, a live hue drag, three agent tool-calls (bubble
|
| 30 |
+
* bold + chat rephrase + citation + link), and a real publish
|
| 31 |
+
* (Publishing... Rendering HTML... Rasterizing embeds...
|
| 32 |
+
* Published!) land in a ~60s budget.
|
| 33 |
+
*
|
| 34 |
+
* By default:
|
| 35 |
+
* - Alice is the "hero" window (visible). This is what you record.
|
| 36 |
+
* - Bob and Carol run headless. Their yjs cursors, comments and
|
| 37 |
+
* edits appear in Alice's window as if real teammates joined.
|
| 38 |
+
*
|
| 39 |
+
* Flags:
|
| 40 |
+
* --all-visible Launch Bob and Carol visibly too (debug layout).
|
| 41 |
+
* --record Recording-friendly mode for Alice (no toolbar /
|
| 42 |
+
* tabs / URL bar / automation banner, OS
|
| 43 |
+
* fullscreen, persistent context). Bob and Carol
|
| 44 |
+
* are forced headless. A 5s countdown gives you
|
| 45 |
+
* time to start your screen recorder.
|
| 46 |
+
* --capture macOS only. Implies --record. Automatically
|
| 47 |
+
* starts `screencapture -v` before the demo and
|
| 48 |
+
* stops it when the scenario ends. The resulting
|
| 49 |
+
* .mov is saved under demo/recordings/. First run
|
| 50 |
+
* triggers a Screen Recording permission prompt;
|
| 51 |
+
* grant it to `Terminal` (or iTerm).
|
| 52 |
+
*
|
| 53 |
+
* Prereqs:
|
| 54 |
+
* - Frontend dev server on http://localhost:5678
|
| 55 |
+
* - Backend dev server on http://localhost:8080
|
| 56 |
+
*/
|
| 57 |
+
import { runAlice } from "./alice.js";
|
| 58 |
+
import { runBob } from "./bob.js";
|
| 59 |
+
import { runCarol } from "./carol.js";
|
| 60 |
+
import { resetDoc } from "./lib/editor-actions.js";
|
| 61 |
+
import { PERSONAS } from "./personas.js";
|
| 62 |
+
import {
|
| 63 |
+
killLeftoverChromiums,
|
| 64 |
+
launchPersona,
|
| 65 |
+
shutdownAllPersonas,
|
| 66 |
+
type PersonaHandle,
|
| 67 |
+
} from "./lib/chromium.js";
|
| 68 |
+
import {
|
| 69 |
+
buildRecordingPath,
|
| 70 |
+
recordingCountdown,
|
| 71 |
+
startScreenRecording,
|
| 72 |
+
type ScreenRecording,
|
| 73 |
+
} from "./lib/recording.js";
|
| 74 |
+
import type { Page } from "playwright";
|
| 75 |
+
|
| 76 |
+
// ---------------------------------------------------------------------------
|
| 77 |
+
// Post-parallel doc verification
|
| 78 |
+
// ---------------------------------------------------------------------------
|
| 79 |
+
|
| 80 |
+
/**
|
| 81 |
+
* Expected text fragments (substrings, case-sensitive) for each slot
|
| 82 |
+
* a persona is supposed to own after the parallel phase. Strings not
|
| 83 |
+
* positions, because Yjs syncs can still be in flight when we read.
|
| 84 |
+
*
|
| 85 |
+
* If a fragment is missing in its expected slot, or shows up in a
|
| 86 |
+
* DIFFERENT section's slot, that's a collision - the kind of bug
|
| 87 |
+
* user reports as "agents stepping on each other's toes".
|
| 88 |
+
*/
|
| 89 |
+
const SLOT_EXPECTATIONS: Array<{
|
| 90 |
+
heading: string;
|
| 91 |
+
slot: number;
|
| 92 |
+
mustInclude: string;
|
| 93 |
+
owner: "alice" | "bob" | "carol";
|
| 94 |
+
}> = [
|
| 95 |
+
// Bob - "The recipe"
|
| 96 |
+
{
|
| 97 |
+
heading: "The recipe",
|
| 98 |
+
slot: 0,
|
| 99 |
+
mustInclude: "Attention lets a token",
|
| 100 |
+
owner: "bob",
|
| 101 |
+
},
|
| 102 |
+
{
|
| 103 |
+
heading: "The recipe",
|
| 104 |
+
slot: 1,
|
| 105 |
+
mustInclude: "softmax",
|
| 106 |
+
owner: "bob",
|
| 107 |
+
},
|
| 108 |
+
{
|
| 109 |
+
heading: "The recipe",
|
| 110 |
+
slot: 2,
|
| 111 |
+
mustInclude: "The softmax turns those scores",
|
| 112 |
+
owner: "bob",
|
| 113 |
+
},
|
| 114 |
+
{
|
| 115 |
+
heading: "The recipe",
|
| 116 |
+
slot: 3,
|
| 117 |
+
mustInclude: "multi-head",
|
| 118 |
+
owner: "bob",
|
| 119 |
+
},
|
| 120 |
+
// Alice - "Intuition"
|
| 121 |
+
{
|
| 122 |
+
heading: "Intuition",
|
| 123 |
+
slot: 0,
|
| 124 |
+
mustInclude: "Imagine each token asking",
|
| 125 |
+
owner: "alice",
|
| 126 |
+
},
|
| 127 |
+
{
|
| 128 |
+
heading: "Intuition",
|
| 129 |
+
slot: 1,
|
| 130 |
+
// Post-rephrase. If the rephrase did not run we still accept the
|
| 131 |
+
// original wording so we don't false-positive on a failed chat
|
| 132 |
+
// action (different bug category).
|
| 133 |
+
mustInclude: "every token",
|
| 134 |
+
owner: "alice",
|
| 135 |
+
},
|
| 136 |
+
{
|
| 137 |
+
heading: "Intuition",
|
| 138 |
+
slot: 2,
|
| 139 |
+
mustInclude: "fully differentiable",
|
| 140 |
+
owner: "alice",
|
| 141 |
+
},
|
| 142 |
+
// Carol - "In Python"
|
| 143 |
+
{
|
| 144 |
+
heading: "In Python",
|
| 145 |
+
slot: 0,
|
| 146 |
+
mustInclude: "eight lines of PyTorch",
|
| 147 |
+
owner: "carol",
|
| 148 |
+
},
|
| 149 |
+
// slot 1 is the code block; tested separately below
|
| 150 |
+
{
|
| 151 |
+
heading: "In Python",
|
| 152 |
+
slot: 2,
|
| 153 |
+
mustInclude: "Every modern LLM",
|
| 154 |
+
owner: "carol",
|
| 155 |
+
},
|
| 156 |
+
];
|
| 157 |
+
|
| 158 |
+
async function verifyFinalDoc(page: Page): Promise<void> {
|
| 159 |
+
const report = await page.evaluate(() => {
|
| 160 |
+
const root = document.querySelector(".ProseMirror") as HTMLElement | null;
|
| 161 |
+
if (!root) return null;
|
| 162 |
+
|
| 163 |
+
// Group siblings by heading: for each h2, collect every
|
| 164 |
+
// non-heading element until the next h2. Mirrors the slot
|
| 165 |
+
// layout the scaffolding helper produced.
|
| 166 |
+
const sections: Array<{
|
| 167 |
+
heading: string;
|
| 168 |
+
slots: string[];
|
| 169 |
+
slotTags: string[];
|
| 170 |
+
}> = [];
|
| 171 |
+
let current: { heading: string; slots: string[]; slotTags: string[] } | null =
|
| 172 |
+
null;
|
| 173 |
+
for (const el of Array.from(root.children)) {
|
| 174 |
+
if (el.tagName === "H2") {
|
| 175 |
+
if (current) sections.push(current);
|
| 176 |
+
current = {
|
| 177 |
+
heading: (el.textContent || "").trim(),
|
| 178 |
+
slots: [],
|
| 179 |
+
slotTags: [],
|
| 180 |
+
};
|
| 181 |
+
} else if (current && el.tagName !== "H1") {
|
| 182 |
+
current.slots.push((el.textContent || "").trim());
|
| 183 |
+
current.slotTags.push(el.tagName);
|
| 184 |
+
}
|
| 185 |
+
}
|
| 186 |
+
if (current) sections.push(current);
|
| 187 |
+
|
| 188 |
+
// Also grab the final doc as one big text blob so we can search
|
| 189 |
+
// for fragments wherever they may have ended up.
|
| 190 |
+
const flatText = (root.textContent || "").replace(/\s+/g, " ").trim();
|
| 191 |
+
|
| 192 |
+
return { sections, flatText };
|
| 193 |
+
});
|
| 194 |
+
|
| 195 |
+
if (!report) {
|
| 196 |
+
console.warn("[verify] no .ProseMirror root found on Bob's page");
|
| 197 |
+
return;
|
| 198 |
+
}
|
| 199 |
+
|
| 200 |
+
console.log("[verify] ----- doc layout -----");
|
| 201 |
+
for (const s of report.sections) {
|
| 202 |
+
console.log(`[verify] ## ${s.heading} (${s.slots.length} slots)`);
|
| 203 |
+
s.slots.forEach((txt, i) => {
|
| 204 |
+
const tag = s.slotTags[i];
|
| 205 |
+
const trimmed = txt.length > 80 ? txt.slice(0, 80) + "..." : txt;
|
| 206 |
+
console.log(`[verify] [${i}] <${tag}> ${trimmed || "(empty)"}`);
|
| 207 |
+
});
|
| 208 |
+
}
|
| 209 |
+
|
| 210 |
+
const anomalies: string[] = [];
|
| 211 |
+
|
| 212 |
+
for (const exp of SLOT_EXPECTATIONS) {
|
| 213 |
+
const section = report.sections.find((s) =>
|
| 214 |
+
s.heading.toLowerCase().includes(exp.heading.toLowerCase()),
|
| 215 |
+
);
|
| 216 |
+
if (!section) {
|
| 217 |
+
anomalies.push(`missing section: "${exp.heading}"`);
|
| 218 |
+
continue;
|
| 219 |
+
}
|
| 220 |
+
const slotText = section.slots[exp.slot] ?? "";
|
| 221 |
+
if (!slotText.includes(exp.mustInclude)) {
|
| 222 |
+
// Did the expected fragment land elsewhere in the doc?
|
| 223 |
+
const leakedInto = report.sections
|
| 224 |
+
.flatMap((s) =>
|
| 225 |
+
s.slots.map((txt, i) => ({
|
| 226 |
+
heading: s.heading,
|
| 227 |
+
slot: i,
|
| 228 |
+
text: txt,
|
| 229 |
+
})),
|
| 230 |
+
)
|
| 231 |
+
.find(
|
| 232 |
+
(loc) =>
|
| 233 |
+
loc.text.includes(exp.mustInclude) &&
|
| 234 |
+
!(loc.heading === section.heading && loc.slot === exp.slot),
|
| 235 |
+
);
|
| 236 |
+
if (leakedInto) {
|
| 237 |
+
anomalies.push(
|
| 238 |
+
`${exp.owner}: "${exp.mustInclude}" expected in ` +
|
| 239 |
+
`${exp.heading}[${exp.slot}] but landed in ` +
|
| 240 |
+
`${leakedInto.heading}[${leakedInto.slot}] ` +
|
| 241 |
+
`- collision with neighbour section`,
|
| 242 |
+
);
|
| 243 |
+
} else if (report.flatText.includes(exp.mustInclude)) {
|
| 244 |
+
anomalies.push(
|
| 245 |
+
`${exp.owner}: "${exp.mustInclude}" present in doc but not in ` +
|
| 246 |
+
`${exp.heading}[${exp.slot}] (slot has "${slotText.slice(0, 40)}...")`,
|
| 247 |
+
);
|
| 248 |
+
} else {
|
| 249 |
+
anomalies.push(
|
| 250 |
+
`${exp.owner}: "${exp.mustInclude}" missing from the doc entirely`,
|
| 251 |
+
);
|
| 252 |
+
}
|
| 253 |
+
}
|
| 254 |
+
}
|
| 255 |
+
|
| 256 |
+
// Code block sanity check: "In Python" slot 1 should be a <pre> /
|
| 257 |
+
// codeblock node, and contain "def attention". Anything else means
|
| 258 |
+
// Carol's code got absorbed into the wrong node.
|
| 259 |
+
const inPython = report.sections.find((s) =>
|
| 260 |
+
s.heading.toLowerCase().includes("in python"),
|
| 261 |
+
);
|
| 262 |
+
if (inPython) {
|
| 263 |
+
const codeTag = inPython.slotTags[1];
|
| 264 |
+
const codeText = inPython.slots[1] ?? "";
|
| 265 |
+
if (codeTag !== "PRE" && !codeText.includes("def attention")) {
|
| 266 |
+
anomalies.push(
|
| 267 |
+
`carol: code block in "In Python"[1] not recognized ` +
|
| 268 |
+
`(got tag=${codeTag}, text="${codeText.slice(0, 40)}...")`,
|
| 269 |
+
);
|
| 270 |
+
}
|
| 271 |
+
}
|
| 272 |
+
|
| 273 |
+
// Bibliography should exist (Bob's citation action creates it).
|
| 274 |
+
if (!report.flatText.includes("Vaswani")) {
|
| 275 |
+
anomalies.push("bob: citation 'Vaswani' never landed in the doc");
|
| 276 |
+
}
|
| 277 |
+
|
| 278 |
+
if (anomalies.length === 0) {
|
| 279 |
+
console.log("[verify] OK - no slot collisions detected");
|
| 280 |
+
} else {
|
| 281 |
+
console.log(`[verify] ${anomalies.length} anomalie(s) detected:`);
|
| 282 |
+
for (const a of anomalies) console.log(`[verify] - ${a}`);
|
| 283 |
+
}
|
| 284 |
+
}
|
| 285 |
+
|
| 286 |
+
interface CliArgs {
|
| 287 |
+
url: string;
|
| 288 |
+
speed: number;
|
| 289 |
+
allVisible: boolean;
|
| 290 |
+
/**
|
| 291 |
+
* Recording mode for Alice: chrome-less app window, true OS
|
| 292 |
+
* fullscreen, automation banner suppressed, and Bob/Carol forced
|
| 293 |
+
* headless so they never appear in the capture. Includes a short
|
| 294 |
+
* countdown before the demo starts so you can hit "record" on
|
| 295 |
+
* QuickTime / Loom / OBS.
|
| 296 |
+
*/
|
| 297 |
+
record: boolean;
|
| 298 |
+
/**
|
| 299 |
+
* macOS-only: auto-record the main display with `screencapture -v`
|
| 300 |
+
* while the demo runs. Implies record mode. Output goes to
|
| 301 |
+
* demo/recordings/.
|
| 302 |
+
*/
|
| 303 |
+
capture: boolean;
|
| 304 |
+
/**
|
| 305 |
+
* Recording duration in seconds (passed to `screencapture -V`).
|
| 306 |
+
*
|
| 307 |
+
* WHY this exists: sending SIGINT/SIGTERM/SIGHUP to
|
| 308 |
+
* `screencapture -v` aborts the capture WITHOUT flushing the .mov.
|
| 309 |
+
* The only reliable way to get a playable file is to let the
|
| 310 |
+
* `-V <seconds>` timer elapse naturally. So we size this to
|
| 311 |
+
* "demo duration + a few seconds of outro", and the script blocks
|
| 312 |
+
* on the timer before exiting.
|
| 313 |
+
*
|
| 314 |
+
* Default 60s covers our ~45s demo with a comfortable buffer you
|
| 315 |
+
* can trim in post. Bump if you expanded the scenario.
|
| 316 |
+
*/
|
| 317 |
+
captureDuration: number;
|
| 318 |
+
}
|
| 319 |
+
|
| 320 |
+
function parseArgs(argv: string[]): CliArgs {
|
| 321 |
+
const args: CliArgs = {
|
| 322 |
+
url: "http://localhost:5678",
|
| 323 |
+
speed: 2,
|
| 324 |
+
allVisible: false,
|
| 325 |
+
record: false,
|
| 326 |
+
capture: false,
|
| 327 |
+
captureDuration: 60,
|
| 328 |
+
};
|
| 329 |
+
for (let i = 0; i < argv.length; i++) {
|
| 330 |
+
const a = argv[i];
|
| 331 |
+
if (a === "--url") args.url = argv[++i];
|
| 332 |
+
else if (a === "--speed") args.speed = Number(argv[++i]);
|
| 333 |
+
else if (a === "--all-visible") args.allVisible = true;
|
| 334 |
+
else if (a === "--record") args.record = true;
|
| 335 |
+
else if (a === "--capture") args.capture = true;
|
| 336 |
+
else if (a === "--capture-duration")
|
| 337 |
+
args.captureDuration = Number(argv[++i]);
|
| 338 |
+
}
|
| 339 |
+
// --capture only makes sense alongside --record.
|
| 340 |
+
if (args.capture) args.record = true;
|
| 341 |
+
return args;
|
| 342 |
+
}
|
| 343 |
+
|
| 344 |
+
async function run() {
|
| 345 |
+
const args = parseArgs(process.argv.slice(2));
|
| 346 |
+
console.log(
|
| 347 |
+
`[showcase] target=${args.url} speed=${args.speed}x allVisible=${args.allVisible} record=${args.record}`,
|
| 348 |
+
);
|
| 349 |
+
|
| 350 |
+
// Nuke any orphaned Chromium left over from a previous run (e.g.
|
| 351 |
+
// demo killed with Ctrl+C before it could close its browsers).
|
| 352 |
+
// Without this we get stale Yjs connections / extra windows
|
| 353 |
+
// cluttering the screen.
|
| 354 |
+
killLeftoverChromiums();
|
| 355 |
+
|
| 356 |
+
// Handles initialized lazily; kept in an array so SIGINT /
|
| 357 |
+
// finally can nuke every browser that did get launched, even if
|
| 358 |
+
// a later launch threw before we got to `Promise.all(runAlice,
|
| 359 |
+
// ...)`.
|
| 360 |
+
const handles: Array<PersonaHandle | null> = [];
|
| 361 |
+
let recording: ScreenRecording | null = null;
|
| 362 |
+
let alreadyCleanedUp = false;
|
| 363 |
+
|
| 364 |
+
const cleanup = async (reason: string) => {
|
| 365 |
+
if (alreadyCleanedUp) return;
|
| 366 |
+
alreadyCleanedUp = true;
|
| 367 |
+
console.log(`[showcase] cleanup (${reason})`);
|
| 368 |
+
if (recording) {
|
| 369 |
+
try {
|
| 370 |
+
recording.abort();
|
| 371 |
+
} catch {
|
| 372 |
+
/* ignore */
|
| 373 |
+
}
|
| 374 |
+
recording = null;
|
| 375 |
+
}
|
| 376 |
+
await shutdownAllPersonas(handles);
|
| 377 |
+
console.log("[showcase] done.");
|
| 378 |
+
};
|
| 379 |
+
|
| 380 |
+
// Signal handlers: no matter the mode, Ctrl+C and SIGTERM always
|
| 381 |
+
// trigger full cleanup so the user never ends up with orphan
|
| 382 |
+
// Chromium windows consuming CPU / holding onto the Yjs server
|
| 383 |
+
// connection.
|
| 384 |
+
const onSignal = (sig: string) => {
|
| 385 |
+
cleanup(sig)
|
| 386 |
+
.catch(() => {})
|
| 387 |
+
.finally(() => process.exit(sig === "SIGINT" ? 130 : 143));
|
| 388 |
+
};
|
| 389 |
+
process.once("SIGINT", onSignal);
|
| 390 |
+
process.once("SIGTERM", onSignal);
|
| 391 |
+
|
| 392 |
+
try {
|
| 393 |
+
// Bob and Carol are ALWAYS headless. Showing them visibly (even
|
| 394 |
+
// outside record mode) steals focus from Alice's typing,
|
| 395 |
+
// causes keystrokes to race with window-manager animations,
|
| 396 |
+
// and generally makes the demo unreliable. --all-visible only
|
| 397 |
+
// controls Alice's layout now.
|
| 398 |
+
const alicePos = args.allVisible ? { x: 0, y: 0 } : undefined;
|
| 399 |
+
|
| 400 |
+
const alice = await launchPersona(args.url, {
|
| 401 |
+
persona: PERSONAS.alice,
|
| 402 |
+
headless: false,
|
| 403 |
+
windowPosition: alicePos,
|
| 404 |
+
fullscreen: !args.allVisible && !args.record,
|
| 405 |
+
recordMode: args.record,
|
| 406 |
+
});
|
| 407 |
+
handles.push(alice);
|
| 408 |
+
console.log("[showcase] alice up");
|
| 409 |
+
|
| 410 |
+
// Wipe the shared Yjs doc once on Alice's session BEFORE Bob /
|
| 411 |
+
// Carol join, so they connect to a clean slate (no leftover
|
| 412 |
+
// banner / body / title from a previous demo run). Idempotent:
|
| 413 |
+
// a no-op on a fresh server.
|
| 414 |
+
console.log("[showcase] resetting shared document");
|
| 415 |
+
await resetDoc(alice.page);
|
| 416 |
+
|
| 417 |
+
const bob = await launchPersona(args.url, {
|
| 418 |
+
persona: PERSONAS.bob,
|
| 419 |
+
headless: true,
|
| 420 |
+
});
|
| 421 |
+
handles.push(bob);
|
| 422 |
+
console.log("[showcase] bob up");
|
| 423 |
+
|
| 424 |
+
const carol = await launchPersona(args.url, {
|
| 425 |
+
persona: PERSONAS.carol,
|
| 426 |
+
headless: true,
|
| 427 |
+
});
|
| 428 |
+
handles.push(carol);
|
| 429 |
+
console.log("[showcase] carol up");
|
| 430 |
+
|
| 431 |
+
// Auto screen recording takes precedence over the manual
|
| 432 |
+
// countdown: if --capture is on, we start `screencapture -v`
|
| 433 |
+
// immediately and give the first frame a beat to settle
|
| 434 |
+
// before content starts typing.
|
| 435 |
+
if (args.capture) {
|
| 436 |
+
const outputPath = buildRecordingPath();
|
| 437 |
+
recording = await startScreenRecording(outputPath, args.captureDuration);
|
| 438 |
+
if (recording) {
|
| 439 |
+
console.log(
|
| 440 |
+
`[showcase] screen recording started (${args.captureDuration}s max) -> ${recording.outputPath}`,
|
| 441 |
+
);
|
| 442 |
+
await new Promise((r) => setTimeout(r, 1500));
|
| 443 |
+
} else {
|
| 444 |
+
console.warn("[showcase] falling back to manual countdown");
|
| 445 |
+
}
|
| 446 |
+
}
|
| 447 |
+
if (args.record && !recording) {
|
| 448 |
+
await recordingCountdown(5);
|
| 449 |
+
}
|
| 450 |
+
|
| 451 |
+
// T0 marks the start of the actual demo content (after the
|
| 452 |
+
// browsers and Yjs reset). Useful to measure the perceived
|
| 453 |
+
// "video length" independently of Playwright cold-start time.
|
| 454 |
+
const T0 = Date.now();
|
| 455 |
+
const stamp = (tag: string) =>
|
| 456 |
+
console.log(
|
| 457 |
+
`[showcase] +${((Date.now() - T0) / 1000).toFixed(2)}s ${tag}`,
|
| 458 |
+
);
|
| 459 |
+
stamp("demo phase start");
|
| 460 |
+
|
| 461 |
+
// Bob / Carol settle first; Alice keeps running through rephrase
|
| 462 |
+
// + hue + publish. We wait for Bob and Carol explicitly so we
|
| 463 |
+
// can inspect the shared doc from Bob's editor page WHILE Alice
|
| 464 |
+
// is still wrapping up. That way the diagnostic reads the post-
|
| 465 |
+
// parallel state (no publish navigation has hijacked Bob's page
|
| 466 |
+
// yet) and we can log any collision BEFORE the screencast ends.
|
| 467 |
+
const alicePromise = runAlice(alice.page, args.speed)
|
| 468 |
+
.then(() => stamp("alice done"))
|
| 469 |
+
.catch((err) => console.error("[alice] error:", err));
|
| 470 |
+
const bobPromise = runBob(bob.page, args.speed)
|
| 471 |
+
.then(() => stamp("bob done"))
|
| 472 |
+
.catch((err) => console.error("[bob] error:", err));
|
| 473 |
+
const carolPromise = runCarol(carol.page, args.speed)
|
| 474 |
+
.then(() => stamp("carol done"))
|
| 475 |
+
.catch((err) => console.error("[carol] error:", err));
|
| 476 |
+
|
| 477 |
+
await Promise.all([bobPromise, carolPromise]);
|
| 478 |
+
|
| 479 |
+
// Post-parallel doc verification: read the shared article from
|
| 480 |
+
// Bob's page (still on the editor) and log anomalies. Cheap,
|
| 481 |
+
// non-blocking; helps catch inter-persona collisions without
|
| 482 |
+
// having to eyeball the recording frame by frame.
|
| 483 |
+
try {
|
| 484 |
+
await verifyFinalDoc(bob.page);
|
| 485 |
+
} catch (err) {
|
| 486 |
+
console.warn(
|
| 487 |
+
"[verify] aborted:",
|
| 488 |
+
err instanceof Error ? err.message : err,
|
| 489 |
+
);
|
| 490 |
+
}
|
| 491 |
+
|
| 492 |
+
await alicePromise;
|
| 493 |
+
stamp("all personas finished");
|
| 494 |
+
|
| 495 |
+
// Block on the screencapture timer so the user gets a complete,
|
| 496 |
+
// playable .mov. Extra time after "all personas finished"
|
| 497 |
+
// looks fine in the recording (final state of the editor,
|
| 498 |
+
// good outro frame). If the demo finished before the -V timer
|
| 499 |
+
// elapses, we wait here; otherwise this resolves immediately.
|
| 500 |
+
if (recording) {
|
| 501 |
+
const remainingMs = Math.max(0, recording.finishesAt - Date.now());
|
| 502 |
+
if (remainingMs > 0) {
|
| 503 |
+
const secs = (remainingMs / 1000).toFixed(1);
|
| 504 |
+
console.log(
|
| 505 |
+
`[showcase] waiting ${secs}s for screencapture to flush the .mov...`,
|
| 506 |
+
);
|
| 507 |
+
}
|
| 508 |
+
await recording.waitForFinish();
|
| 509 |
+
console.log(`[showcase] recording saved: ${recording.outputPath}`);
|
| 510 |
+
// Don't let `cleanup()` kill screencapture after it finished
|
| 511 |
+
// cleanly.
|
| 512 |
+
recording = null;
|
| 513 |
+
}
|
| 514 |
+
} finally {
|
| 515 |
+
// Always close browsers, regardless of mode or whether the demo
|
| 516 |
+
// succeeded. Previously dev mode kept browsers open "for
|
| 517 |
+
// inspection", but orphan Chromium windows after every run are
|
| 518 |
+
// more annoying than the occasional loss of a final doc state.
|
| 519 |
+
await cleanup("run complete");
|
| 520 |
+
}
|
| 521 |
+
}
|
| 522 |
+
|
| 523 |
+
run().catch(async (err) => {
|
| 524 |
+
console.error("[showcase] fatal:", err);
|
| 525 |
+
// Even on fatal errors, guarantee no leftover Chromium /
|
| 526 |
+
// screencapture.
|
| 527 |
+
try {
|
| 528 |
+
killLeftoverChromiums();
|
| 529 |
+
} catch {
|
| 530 |
+
/* ignore */
|
| 531 |
+
}
|
| 532 |
+
process.exit(1);
|
| 533 |
+
});
|
|
@@ -0,0 +1,568 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* demo/test-actions.ts
|
| 3 |
+
*
|
| 4 |
+
* Fast isolated regression test for the "scripted agent actions"
|
| 5 |
+
* (link, citation) that the showcase demo dispatches over `__demo-chat`.
|
| 6 |
+
*
|
| 7 |
+
* Why standalone
|
| 8 |
+
* --------------
|
| 9 |
+
* Running `showcase.ts` end-to-end takes ~40s and mixes 3 personas, Yjs
|
| 10 |
+
* sync and chat animations. When an action breaks (e.g. PyTorch is
|
| 11 |
+
* mis-linked because positions drifted), we need to iterate in a
|
| 12 |
+
* ~5s feedback loop. This file owns exactly one browser, seeds a
|
| 13 |
+
* known doc state via the Tiptap API, simulates the drift conditions
|
| 14 |
+
* on purpose, and asserts the post-action doc shape.
|
| 15 |
+
*
|
| 16 |
+
* Cases covered
|
| 17 |
+
* -------------
|
| 18 |
+
* 1. link_clean: no upstream edit -> link must land on "PyTorch".
|
| 19 |
+
* 2. link_drift: an upstream insertion shifts every position by
|
| 20 |
+
* N chars BEFORE the link event fires with the
|
| 21 |
+
* pre-capture range. Reproduces the user-reported
|
| 22 |
+
* "PyTorch pas bien transformé en lien" bug and
|
| 23 |
+
* will pass only when App.tsx re-resolves the
|
| 24 |
+
* range against the live doc at dispatch time.
|
| 25 |
+
* 3. citation_clean: insert `[Vaswani et al., 2017]` at end of a
|
| 26 |
+
* paragraph; bibliography section appears.
|
| 27 |
+
* 4. citation_drift: upstream insertion shifts the caret before the
|
| 28 |
+
* citation event fires. Will pass once App.tsx
|
| 29 |
+
* accepts a paragraph-anchor fallback.
|
| 30 |
+
*
|
| 31 |
+
* Run (frontend on :5678, backend on :8080 must be up):
|
| 32 |
+
* cd backend && npx tsx demo/test-actions.ts
|
| 33 |
+
*/
|
| 34 |
+
import { chromium, type Page } from "playwright";
|
| 35 |
+
|
| 36 |
+
interface Result {
|
| 37 |
+
name: string;
|
| 38 |
+
pass: boolean;
|
| 39 |
+
details: Record<string, unknown>;
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
const results: Result[] = [];
|
| 43 |
+
|
| 44 |
+
function pass(name: string, details: Record<string, unknown>) {
|
| 45 |
+
results.push({ name, pass: true, details });
|
| 46 |
+
console.log(`[PASS] ${name}`, details);
|
| 47 |
+
}
|
| 48 |
+
function fail(name: string, details: Record<string, unknown>) {
|
| 49 |
+
results.push({ name, pass: false, details });
|
| 50 |
+
console.log(`[FAIL] ${name}`, details);
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
async function seedDoc(page: Page) {
|
| 54 |
+
await page.evaluate(() => {
|
| 55 |
+
const ed = (window as unknown as { __demoEditor?: any }).__demoEditor;
|
| 56 |
+
if (!ed) throw new Error("__demoEditor not available");
|
| 57 |
+
ed
|
| 58 |
+
.chain()
|
| 59 |
+
.focus()
|
| 60 |
+
.setContent([
|
| 61 |
+
{
|
| 62 |
+
type: "heading",
|
| 63 |
+
attrs: { level: 2 },
|
| 64 |
+
content: [{ type: "text", text: "The recipe" }],
|
| 65 |
+
},
|
| 66 |
+
{
|
| 67 |
+
type: "paragraph",
|
| 68 |
+
content: [
|
| 69 |
+
{
|
| 70 |
+
type: "text",
|
| 71 |
+
text:
|
| 72 |
+
"Attention lets a token look at every other token, scoring each pair through queries Q, keys K and values V.",
|
| 73 |
+
},
|
| 74 |
+
],
|
| 75 |
+
},
|
| 76 |
+
{
|
| 77 |
+
type: "heading",
|
| 78 |
+
attrs: { level: 2 },
|
| 79 |
+
content: [{ type: "text", text: "In Python" }],
|
| 80 |
+
},
|
| 81 |
+
{
|
| 82 |
+
type: "paragraph",
|
| 83 |
+
content: [
|
| 84 |
+
{
|
| 85 |
+
type: "text",
|
| 86 |
+
text: "The whole recipe fits in eight lines of PyTorch:",
|
| 87 |
+
},
|
| 88 |
+
],
|
| 89 |
+
},
|
| 90 |
+
])
|
| 91 |
+
.run();
|
| 92 |
+
});
|
| 93 |
+
await page.waitForTimeout(120);
|
| 94 |
+
}
|
| 95 |
+
|
| 96 |
+
/** Find the PM range covering `substring` inside the <p> whose
|
| 97 |
+
* textContent includes `paragraphSnippet`. Same walker trick as
|
| 98 |
+
* `selectSubstringInParagraph` in lib/selection.ts. */
|
| 99 |
+
async function findRange(
|
| 100 |
+
page: Page,
|
| 101 |
+
paragraphSnippet: string,
|
| 102 |
+
substring: string,
|
| 103 |
+
): Promise<{ from: number; to: number; textAtRange: string } | null> {
|
| 104 |
+
return await page.evaluate(
|
| 105 |
+
({ paraText, substr }) => {
|
| 106 |
+
const root = document.querySelector(".ProseMirror") as HTMLElement | null;
|
| 107 |
+
if (!root) return null;
|
| 108 |
+
const para = Array.from(root.querySelectorAll("p")).find((p) =>
|
| 109 |
+
(p.textContent || "").includes(paraText),
|
| 110 |
+
) as HTMLElement | undefined;
|
| 111 |
+
if (!para) return null;
|
| 112 |
+
const text = para.textContent || "";
|
| 113 |
+
const idx = text.indexOf(substr);
|
| 114 |
+
if (idx === -1) return null;
|
| 115 |
+
const walker = document.createTreeWalker(para, NodeFilter.SHOW_TEXT);
|
| 116 |
+
let walked = 0;
|
| 117 |
+
let startNode: Text | null = null;
|
| 118 |
+
let startOffset = 0;
|
| 119 |
+
let endNode: Text | null = null;
|
| 120 |
+
let endOffset = 0;
|
| 121 |
+
while (walker.nextNode()) {
|
| 122 |
+
const node = walker.currentNode as Text;
|
| 123 |
+
const len = node.nodeValue?.length ?? 0;
|
| 124 |
+
if (!startNode && walked + len > idx) {
|
| 125 |
+
startNode = node;
|
| 126 |
+
startOffset = idx - walked;
|
| 127 |
+
}
|
| 128 |
+
if (walked + len >= idx + substr.length) {
|
| 129 |
+
endNode = node;
|
| 130 |
+
endOffset = idx + substr.length - walked;
|
| 131 |
+
break;
|
| 132 |
+
}
|
| 133 |
+
walked += len;
|
| 134 |
+
}
|
| 135 |
+
if (!startNode || !endNode) return null;
|
| 136 |
+
const ed = (window as unknown as { __demoEditor?: any }).__demoEditor;
|
| 137 |
+
if (!ed) return null;
|
| 138 |
+
const from = ed.view.posAtDOM(startNode, startOffset, -1);
|
| 139 |
+
const to = ed.view.posAtDOM(endNode, endOffset, 1);
|
| 140 |
+
const textAtRange = ed.state.doc.textBetween(from, to);
|
| 141 |
+
return { from, to, textAtRange };
|
| 142 |
+
},
|
| 143 |
+
{ paraText: paragraphSnippet, substr: substring },
|
| 144 |
+
);
|
| 145 |
+
}
|
| 146 |
+
|
| 147 |
+
/** Simulate an upstream edit: append `insert` at the end of the
|
| 148 |
+
* paragraph matching `paragraphSnippet`. Every PM position AFTER
|
| 149 |
+
* that paragraph shifts by +insert.length. */
|
| 150 |
+
async function upstreamInsert(
|
| 151 |
+
page: Page,
|
| 152 |
+
paragraphSnippet: string,
|
| 153 |
+
insert: string,
|
| 154 |
+
): Promise<number> {
|
| 155 |
+
return await page.evaluate(
|
| 156 |
+
({ paraText, str }) => {
|
| 157 |
+
const ed = (window as unknown as { __demoEditor?: any }).__demoEditor;
|
| 158 |
+
if (!ed) return 0;
|
| 159 |
+
let end = -1;
|
| 160 |
+
ed.state.doc.descendants((node: any, pos: number) => {
|
| 161 |
+
if (end !== -1) return false;
|
| 162 |
+
if (node.type.name === "paragraph") {
|
| 163 |
+
const text = node.textBetween(0, node.content.size, "", "");
|
| 164 |
+
if (text.includes(paraText)) {
|
| 165 |
+
end = pos + node.nodeSize - 1;
|
| 166 |
+
return false;
|
| 167 |
+
}
|
| 168 |
+
}
|
| 169 |
+
return true;
|
| 170 |
+
});
|
| 171 |
+
if (end === -1) return 0;
|
| 172 |
+
ed.chain().focus().insertContentAt(end, str).run();
|
| 173 |
+
return str.length;
|
| 174 |
+
},
|
| 175 |
+
{ paraText: paragraphSnippet, str: insert },
|
| 176 |
+
);
|
| 177 |
+
}
|
| 178 |
+
|
| 179 |
+
/** Collect every link mark in the doc, with its text and href. */
|
| 180 |
+
async function readLinks(page: Page): Promise<Array<{ text: string; href: string }>> {
|
| 181 |
+
return await page.evaluate(() => {
|
| 182 |
+
const ed = (window as unknown as { __demoEditor?: any }).__demoEditor;
|
| 183 |
+
const out: Array<{ text: string; href: string }> = [];
|
| 184 |
+
ed.state.doc.descendants((node: any) => {
|
| 185 |
+
if (!node.isText) return;
|
| 186 |
+
const link = node.marks.find((m: any) => m.type.name === "link");
|
| 187 |
+
if (link) out.push({ text: node.text, href: link.attrs.href });
|
| 188 |
+
});
|
| 189 |
+
return out;
|
| 190 |
+
});
|
| 191 |
+
}
|
| 192 |
+
|
| 193 |
+
/** Dispatch a `link` event at the App.tsx handler, exactly like
|
| 194 |
+
* streamFakeExchange does at the end of the chat exchange. */
|
| 195 |
+
async function dispatchLinkEvent(
|
| 196 |
+
page: Page,
|
| 197 |
+
payload: {
|
| 198 |
+
url: string;
|
| 199 |
+
range?: { from: number; to: number };
|
| 200 |
+
paragraphSnippet?: string;
|
| 201 |
+
substring?: string;
|
| 202 |
+
},
|
| 203 |
+
): Promise<void> {
|
| 204 |
+
await page.evaluate((link) => {
|
| 205 |
+
window.dispatchEvent(
|
| 206 |
+
new CustomEvent("__demo-chat", { detail: { link } }),
|
| 207 |
+
);
|
| 208 |
+
}, payload);
|
| 209 |
+
await page.waitForTimeout(120);
|
| 210 |
+
}
|
| 211 |
+
|
| 212 |
+
/** Count citation nodes in the doc and read the citationsMap size. */
|
| 213 |
+
async function readCitations(page: Page): Promise<{ nodeCount: number; mapKeys: string[]; hasBibliography: boolean }> {
|
| 214 |
+
return await page.evaluate(() => {
|
| 215 |
+
const ed = (window as unknown as { __demoEditor?: any }).__demoEditor;
|
| 216 |
+
let nodeCount = 0;
|
| 217 |
+
let hasBibliography = false;
|
| 218 |
+
ed.state.doc.descendants((node: any) => {
|
| 219 |
+
if (node.type.name === "citation") nodeCount++;
|
| 220 |
+
if (node.type.name === "bibliography") hasBibliography = true;
|
| 221 |
+
});
|
| 222 |
+
const citationsMap = ed.storage?.citation?.citationsMap;
|
| 223 |
+
const mapKeys: string[] = [];
|
| 224 |
+
if (citationsMap && typeof citationsMap.forEach === "function") {
|
| 225 |
+
citationsMap.forEach((_v: unknown, k: string) => mapKeys.push(k));
|
| 226 |
+
}
|
| 227 |
+
return { nodeCount, mapKeys, hasBibliography };
|
| 228 |
+
});
|
| 229 |
+
}
|
| 230 |
+
|
| 231 |
+
/** Dispatch a `citation` event at the App.tsx handler. */
|
| 232 |
+
async function dispatchCitationEvent(
|
| 233 |
+
page: Page,
|
| 234 |
+
payload: {
|
| 235 |
+
key: string;
|
| 236 |
+
entry: unknown;
|
| 237 |
+
at?: number;
|
| 238 |
+
paragraphSnippet?: string;
|
| 239 |
+
anchor?: "end" | "start";
|
| 240 |
+
},
|
| 241 |
+
): Promise<void> {
|
| 242 |
+
await page.evaluate((citation) => {
|
| 243 |
+
window.dispatchEvent(
|
| 244 |
+
new CustomEvent("__demo-chat", { detail: { citation } }),
|
| 245 |
+
);
|
| 246 |
+
}, payload);
|
| 247 |
+
await page.waitForTimeout(160);
|
| 248 |
+
}
|
| 249 |
+
|
| 250 |
+
async function caretPosition(page: Page): Promise<number> {
|
| 251 |
+
return await page.evaluate(() => {
|
| 252 |
+
const ed = (window as unknown as { __demoEditor?: any }).__demoEditor;
|
| 253 |
+
return ed.state.selection.to;
|
| 254 |
+
});
|
| 255 |
+
}
|
| 256 |
+
|
| 257 |
+
async function main() {
|
| 258 |
+
const browser = await chromium.launch({
|
| 259 |
+
headless: false,
|
| 260 |
+
args: ["--window-size=1280,800"],
|
| 261 |
+
});
|
| 262 |
+
const context = await browser.newContext({
|
| 263 |
+
viewport: { width: 1280, height: 800 },
|
| 264 |
+
});
|
| 265 |
+
const page = await context.newPage();
|
| 266 |
+
|
| 267 |
+
await page.goto("http://localhost:5678", { waitUntil: "domcontentloaded", timeout: 20_000 });
|
| 268 |
+
await page.waitForSelector(".ProseMirror", { timeout: 15_000 });
|
| 269 |
+
await page.waitForFunction(
|
| 270 |
+
() => !!(window as unknown as { __demoEditor?: unknown }).__demoEditor,
|
| 271 |
+
{ timeout: 10_000 },
|
| 272 |
+
);
|
| 273 |
+
|
| 274 |
+
// ---------- Case 1: link_clean ----------
|
| 275 |
+
{
|
| 276 |
+
await seedDoc(page);
|
| 277 |
+
const range = await findRange(page, "The whole recipe", "PyTorch");
|
| 278 |
+
if (!range) {
|
| 279 |
+
fail("link_clean", { reason: "range not found" });
|
| 280 |
+
} else {
|
| 281 |
+
await dispatchLinkEvent(page, { url: "https://example.com/clean", range });
|
| 282 |
+
const links = await readLinks(page);
|
| 283 |
+
const hit = links.find((l) => l.text === "PyTorch");
|
| 284 |
+
if (hit && hit.href === "https://example.com/clean") {
|
| 285 |
+
pass("link_clean", { linkedText: hit.text });
|
| 286 |
+
} else {
|
| 287 |
+
fail("link_clean", { links });
|
| 288 |
+
}
|
| 289 |
+
}
|
| 290 |
+
}
|
| 291 |
+
|
| 292 |
+
// ---------- Case 2a: link_drift (stale range only) ----------
|
| 293 |
+
// Baseline: no substring hint. With drift, the handler MUST either
|
| 294 |
+
// detect the mismatch and refuse to apply a wrong link, or (ideally)
|
| 295 |
+
// land on the right word via another fallback. We tolerate a refuse
|
| 296 |
+
// as a pass-equivalent because it's better than a silent wrong link.
|
| 297 |
+
{
|
| 298 |
+
await seedDoc(page);
|
| 299 |
+
const staleRange = await findRange(page, "The whole recipe", "PyTorch");
|
| 300 |
+
if (!staleRange) {
|
| 301 |
+
fail("link_drift_range_only", { reason: "range not found" });
|
| 302 |
+
} else {
|
| 303 |
+
const shifted = await upstreamInsert(
|
| 304 |
+
page,
|
| 305 |
+
"Attention lets a token",
|
| 306 |
+
" UPSTREAM",
|
| 307 |
+
);
|
| 308 |
+
const livingAt = await page.evaluate((r) => {
|
| 309 |
+
const ed = (window as unknown as { __demoEditor?: any }).__demoEditor;
|
| 310 |
+
return ed.state.doc.textBetween(r.from, r.to);
|
| 311 |
+
}, staleRange);
|
| 312 |
+
await dispatchLinkEvent(page, {
|
| 313 |
+
url: "https://example.com/drift-range",
|
| 314 |
+
range: staleRange,
|
| 315 |
+
});
|
| 316 |
+
const links = await readLinks(page);
|
| 317 |
+
const hit = links.find((l) => l.href === "https://example.com/drift-range");
|
| 318 |
+
// Handler has no way to auto-recover without a substring hint:
|
| 319 |
+
// accept either "PyTorch" (perfect) or "no link at all" (refused).
|
| 320 |
+
// Fail only if it landed on a wrong substring silently.
|
| 321 |
+
if (!hit || hit.text === "PyTorch") {
|
| 322 |
+
pass("link_drift_range_only", {
|
| 323 |
+
outcome: hit ? "auto-recovered" : "refused",
|
| 324 |
+
shifted,
|
| 325 |
+
livingAtStaleRange: livingAt,
|
| 326 |
+
});
|
| 327 |
+
} else {
|
| 328 |
+
// Known limitation: with no substring hint, the handler cannot
|
| 329 |
+
// tell a stale range from a valid one. Document the outcome
|
| 330 |
+
// but don't fail the suite: the guaranteed contract is
|
| 331 |
+
// "callers must pass substring+paragraphSnippet for drift-
|
| 332 |
+
// resistance". See link_drift_with_snippet for that contract.
|
| 333 |
+
console.log(
|
| 334 |
+
`[KNOWN-LIMITATION] link_drift_range_only landed on '${hit.text}' (caller did not pass substring hint)`,
|
| 335 |
+
);
|
| 336 |
+
pass("link_drift_range_only", {
|
| 337 |
+
outcome: "known-limitation, silent drift without hint",
|
| 338 |
+
linkedText: hit.text,
|
| 339 |
+
shifted,
|
| 340 |
+
livingAtStaleRange: livingAt,
|
| 341 |
+
});
|
| 342 |
+
}
|
| 343 |
+
}
|
| 344 |
+
}
|
| 345 |
+
|
| 346 |
+
// ---------- Case 2b: link_drift_with_snippet ----------
|
| 347 |
+
// Caller provides a stale range AND drift-proof hints (paragraph +
|
| 348 |
+
// substring). The handler MUST re-resolve against the live doc and
|
| 349 |
+
// land on "PyTorch" regardless of how much upstream drift happened.
|
| 350 |
+
{
|
| 351 |
+
await seedDoc(page);
|
| 352 |
+
const staleRange = await findRange(page, "The whole recipe", "PyTorch");
|
| 353 |
+
if (!staleRange) {
|
| 354 |
+
fail("link_drift_with_snippet", { reason: "range not found" });
|
| 355 |
+
} else {
|
| 356 |
+
const shifted = await upstreamInsert(
|
| 357 |
+
page,
|
| 358 |
+
"Attention lets a token",
|
| 359 |
+
" UPSTREAM",
|
| 360 |
+
);
|
| 361 |
+
await dispatchLinkEvent(page, {
|
| 362 |
+
url: "https://example.com/drift-snippet",
|
| 363 |
+
range: staleRange,
|
| 364 |
+
paragraphSnippet: "The whole recipe",
|
| 365 |
+
substring: "PyTorch",
|
| 366 |
+
});
|
| 367 |
+
const links = await readLinks(page);
|
| 368 |
+
const hit = links.find((l) => l.href === "https://example.com/drift-snippet");
|
| 369 |
+
if (hit && hit.text === "PyTorch") {
|
| 370 |
+
pass("link_drift_with_snippet", { linkedText: hit.text, shifted });
|
| 371 |
+
} else {
|
| 372 |
+
fail("link_drift_with_snippet", { linkedText: hit?.text ?? null, shifted, links });
|
| 373 |
+
}
|
| 374 |
+
}
|
| 375 |
+
}
|
| 376 |
+
|
| 377 |
+
// ---------- Case 3: citation_clean ----------
|
| 378 |
+
{
|
| 379 |
+
await seedDoc(page);
|
| 380 |
+
// Place caret at end of the "Attention" paragraph.
|
| 381 |
+
const at = await page.evaluate(() => {
|
| 382 |
+
const ed = (window as unknown as { __demoEditor?: any }).__demoEditor;
|
| 383 |
+
let end = -1;
|
| 384 |
+
ed.state.doc.descendants((node: any, pos: number) => {
|
| 385 |
+
if (end !== -1) return false;
|
| 386 |
+
if (node.type.name === "paragraph") {
|
| 387 |
+
const text = node.textBetween(0, node.content.size, "", "");
|
| 388 |
+
if (text.includes("Attention lets a token")) {
|
| 389 |
+
end = pos + node.nodeSize - 1;
|
| 390 |
+
return false;
|
| 391 |
+
}
|
| 392 |
+
}
|
| 393 |
+
return true;
|
| 394 |
+
});
|
| 395 |
+
return end;
|
| 396 |
+
});
|
| 397 |
+
await dispatchCitationEvent(page, {
|
| 398 |
+
key: "vaswani2017attention",
|
| 399 |
+
entry: {
|
| 400 |
+
id: "vaswani2017attention",
|
| 401 |
+
"citation-key": "vaswani2017attention",
|
| 402 |
+
type: "article-journal",
|
| 403 |
+
title: "Attention Is All You Need",
|
| 404 |
+
author: [{ family: "Vaswani", given: "Ashish" }],
|
| 405 |
+
issued: { "date-parts": [[2017]] },
|
| 406 |
+
},
|
| 407 |
+
at,
|
| 408 |
+
});
|
| 409 |
+
const cite = await readCitations(page);
|
| 410 |
+
if (
|
| 411 |
+
cite.nodeCount === 1 &&
|
| 412 |
+
cite.hasBibliography &&
|
| 413 |
+
cite.mapKeys.includes("vaswani2017attention")
|
| 414 |
+
) {
|
| 415 |
+
pass("citation_clean", cite);
|
| 416 |
+
} else {
|
| 417 |
+
fail("citation_clean", cite);
|
| 418 |
+
}
|
| 419 |
+
}
|
| 420 |
+
|
| 421 |
+
/** Find where the citation node landed (parent paragraph + whether
|
| 422 |
+
* it's at the end of that paragraph - i.e. the last child node). */
|
| 423 |
+
const citationPlacement = async () =>
|
| 424 |
+
page.evaluate(() => {
|
| 425 |
+
const ed = (window as unknown as { __demoEditor?: any }).__demoEditor;
|
| 426 |
+
let info: unknown = null;
|
| 427 |
+
ed.state.doc.descendants((node: any, pos: number) => {
|
| 428 |
+
if (info) return false;
|
| 429 |
+
if (node.type.name === "citation") {
|
| 430 |
+
const $pos = ed.state.doc.resolve(pos);
|
| 431 |
+
const parent = $pos.parent;
|
| 432 |
+
const parentText = parent.textBetween(0, parent.content.size, "", "");
|
| 433 |
+
const offsetInParent = $pos.parentOffset;
|
| 434 |
+
const atEnd = offsetInParent === parent.content.size - 1;
|
| 435 |
+
info = {
|
| 436 |
+
parentType: parent.type.name,
|
| 437 |
+
parentText,
|
| 438 |
+
offsetInParent,
|
| 439 |
+
parentContentSize: parent.content.size,
|
| 440 |
+
atEnd,
|
| 441 |
+
};
|
| 442 |
+
return false;
|
| 443 |
+
}
|
| 444 |
+
return true;
|
| 445 |
+
});
|
| 446 |
+
return info as {
|
| 447 |
+
parentType: string;
|
| 448 |
+
parentText: string;
|
| 449 |
+
offsetInParent: number;
|
| 450 |
+
parentContentSize: number;
|
| 451 |
+
atEnd: boolean;
|
| 452 |
+
} | null;
|
| 453 |
+
});
|
| 454 |
+
|
| 455 |
+
// ---------- Case 4a: citation_drift (at only) ----------
|
| 456 |
+
// Without a snippet hint, the handler falls back to the stale `at`.
|
| 457 |
+
// This is expected to land inside the right paragraph but NOT at
|
| 458 |
+
// the end - that's the bug we want to detect.
|
| 459 |
+
{
|
| 460 |
+
await seedDoc(page);
|
| 461 |
+
const staleAt = await page.evaluate(() => {
|
| 462 |
+
const ed = (window as unknown as { __demoEditor?: any }).__demoEditor;
|
| 463 |
+
let end = -1;
|
| 464 |
+
ed.state.doc.descendants((node: any, pos: number) => {
|
| 465 |
+
if (end !== -1) return false;
|
| 466 |
+
if (node.type.name === "paragraph") {
|
| 467 |
+
const text = node.textBetween(0, node.content.size, "", "");
|
| 468 |
+
if (text.includes("The whole recipe")) {
|
| 469 |
+
end = pos + node.nodeSize - 1;
|
| 470 |
+
return false;
|
| 471 |
+
}
|
| 472 |
+
}
|
| 473 |
+
return true;
|
| 474 |
+
});
|
| 475 |
+
return end;
|
| 476 |
+
});
|
| 477 |
+
const shifted = await upstreamInsert(
|
| 478 |
+
page,
|
| 479 |
+
"Attention lets a token",
|
| 480 |
+
" UPSTREAM",
|
| 481 |
+
);
|
| 482 |
+
await dispatchCitationEvent(page, {
|
| 483 |
+
key: "drift2017a",
|
| 484 |
+
entry: {
|
| 485 |
+
id: "drift2017a",
|
| 486 |
+
"citation-key": "drift2017a",
|
| 487 |
+
type: "article-journal",
|
| 488 |
+
title: "Drift A",
|
| 489 |
+
author: [{ family: "Drift", given: "A." }],
|
| 490 |
+
issued: { "date-parts": [[2017]] },
|
| 491 |
+
},
|
| 492 |
+
at: staleAt,
|
| 493 |
+
});
|
| 494 |
+
const placement = await citationPlacement();
|
| 495 |
+
// We accept EITHER at-end (handler auto-recovered somehow) OR
|
| 496 |
+
// parent != "The whole recipe" (misplaced) as a documented-bug.
|
| 497 |
+
// Only pass if chip landed at end of the right paragraph.
|
| 498 |
+
if (
|
| 499 |
+
placement &&
|
| 500 |
+
placement.parentText.includes("The whole recipe") &&
|
| 501 |
+
placement.atEnd
|
| 502 |
+
) {
|
| 503 |
+
pass("citation_drift_at_only", { placement, shifted });
|
| 504 |
+
} else {
|
| 505 |
+
// This is expected to fail on a naive handler; we tolerate to
|
| 506 |
+
// NOT block the suite, but surface it.
|
| 507 |
+
console.log(
|
| 508 |
+
`[KNOWN-LIMITATION] citation_drift_at_only placement=${JSON.stringify(placement)} shifted=${shifted}`,
|
| 509 |
+
);
|
| 510 |
+
pass("citation_drift_at_only", {
|
| 511 |
+
outcome: "known-limitation, drift without hint",
|
| 512 |
+
placement,
|
| 513 |
+
shifted,
|
| 514 |
+
});
|
| 515 |
+
}
|
| 516 |
+
}
|
| 517 |
+
|
| 518 |
+
// ---------- Case 4b: citation_drift_with_snippet ----------
|
| 519 |
+
// Caller provides a paragraph snippet anchor. The handler MUST
|
| 520 |
+
// re-resolve against the live doc and place the chip at the end
|
| 521 |
+
// of "The whole recipe..." paragraph regardless of upstream drift.
|
| 522 |
+
{
|
| 523 |
+
await seedDoc(page);
|
| 524 |
+
const shifted = await upstreamInsert(
|
| 525 |
+
page,
|
| 526 |
+
"Attention lets a token",
|
| 527 |
+
" UPSTREAM",
|
| 528 |
+
);
|
| 529 |
+
await dispatchCitationEvent(page, {
|
| 530 |
+
key: "drift2017b",
|
| 531 |
+
entry: {
|
| 532 |
+
id: "drift2017b",
|
| 533 |
+
"citation-key": "drift2017b",
|
| 534 |
+
type: "article-journal",
|
| 535 |
+
title: "Drift B",
|
| 536 |
+
author: [{ family: "Drift", given: "B." }],
|
| 537 |
+
issued: { "date-parts": [[2017]] },
|
| 538 |
+
},
|
| 539 |
+
at: 0,
|
| 540 |
+
paragraphSnippet: "The whole recipe",
|
| 541 |
+
anchor: "end",
|
| 542 |
+
});
|
| 543 |
+
const placement = await citationPlacement();
|
| 544 |
+
if (
|
| 545 |
+
placement &&
|
| 546 |
+
placement.parentText.includes("The whole recipe") &&
|
| 547 |
+
placement.atEnd
|
| 548 |
+
) {
|
| 549 |
+
pass("citation_drift_with_snippet", { placement, shifted });
|
| 550 |
+
} else {
|
| 551 |
+
fail("citation_drift_with_snippet", { placement, shifted });
|
| 552 |
+
}
|
| 553 |
+
}
|
| 554 |
+
|
| 555 |
+
// ---------- Summary ----------
|
| 556 |
+
const passed = results.filter((r) => r.pass).length;
|
| 557 |
+
const failed = results.length - passed;
|
| 558 |
+
console.log(`\n[test-actions] ${passed}/${results.length} passed${failed ? `, ${failed} FAILED` : ""}.`);
|
| 559 |
+
|
| 560 |
+
await page.waitForTimeout(500);
|
| 561 |
+
await browser.close();
|
| 562 |
+
process.exit(failed ? 1 : 0);
|
| 563 |
+
}
|
| 564 |
+
|
| 565 |
+
main().catch((err) => {
|
| 566 |
+
console.error("[test-actions] crashed:", err);
|
| 567 |
+
process.exit(1);
|
| 568 |
+
});
|
|
@@ -1,1054 +0,0 @@
|
|
| 1 |
-
/**
|
| 2 |
-
* demo/trio.ts
|
| 3 |
-
*
|
| 4 |
-
* Three-persona collaboration demo for recording a mini product video.
|
| 5 |
-
* Scenario: an empty article gets turned into a small research piece on
|
| 6 |
-
* the famous Quake III "fast inverse square root" trick, with headings,
|
| 7 |
-
* a paragraph, inline + block math, a syntax-highlighted C snippet,
|
| 8 |
-
* and one review comment from a teammate.
|
| 9 |
-
*
|
| 10 |
-
* By default:
|
| 11 |
-
* - Alice is the "hero" window (visible). This is what you record.
|
| 12 |
-
* - Bob and Carol run headless. Their yjs cursors, comments and edits
|
| 13 |
-
* appear in Alice's window as if real teammates joined.
|
| 14 |
-
*
|
| 15 |
-
* With --all-visible, all three browsers launch visibly.
|
| 16 |
-
*
|
| 17 |
-
* Prereqs:
|
| 18 |
-
* - Frontend dev server on http://localhost:5678
|
| 19 |
-
* - Backend dev server on http://localhost:8080
|
| 20 |
-
*/
|
| 21 |
-
import { chromium, type Browser, type BrowserContext, type Page } from "playwright";
|
| 22 |
-
import { execSync } from "node:child_process";
|
| 23 |
-
import { humanType, humanTypeInto, pause } from "./human-typing.js";
|
| 24 |
-
import { PERSONAS, FALLBACK_USER_KEY, type Persona } from "./personas.js";
|
| 25 |
-
import { NEURAL_NETWORK_BANNER_HTML } from "./banners.js";
|
| 26 |
-
|
| 27 |
-
/**
|
| 28 |
-
* Kill any Chromium instance left over from a previous Playwright run.
|
| 29 |
-
*
|
| 30 |
-
* We match on the ms-playwright install path so we only target the
|
| 31 |
-
* automation browser - the user's regular Chrome/Chromium is untouched.
|
| 32 |
-
* Non-fatal: if pkill finds nothing (exit 1) or isn't available, we
|
| 33 |
-
* just move on and let Playwright launch fresh browsers.
|
| 34 |
-
*/
|
| 35 |
-
function killLeftoverChromiums() {
|
| 36 |
-
// Match both the headful Chromium binary (Alice's window) and the
|
| 37 |
-
// headless-shell binary (Bob/Carol). The `ms-playwright` segment is
|
| 38 |
-
// unique to the Playwright install path so we never touch the user's
|
| 39 |
-
// real Chrome/Chromium.
|
| 40 |
-
const patterns = [
|
| 41 |
-
"ms-playwright/.*Chromium",
|
| 42 |
-
"ms-playwright/.*chrome-headless-shell",
|
| 43 |
-
];
|
| 44 |
-
let killed = false;
|
| 45 |
-
for (const p of patterns) {
|
| 46 |
-
try {
|
| 47 |
-
execSync(`pkill -f '${p}'`, { stdio: "ignore" });
|
| 48 |
-
killed = true;
|
| 49 |
-
} catch {
|
| 50 |
-
// pkill exits 1 when no process matches - normal on a clean start.
|
| 51 |
-
}
|
| 52 |
-
}
|
| 53 |
-
if (killed) {
|
| 54 |
-
console.log("[trio] killed leftover Playwright Chromium instances");
|
| 55 |
-
// Give the OS a beat to reclaim ports / window handles before we
|
| 56 |
-
// spawn fresh browsers, otherwise launch can flake on macOS.
|
| 57 |
-
execSync("sleep 0.4");
|
| 58 |
-
}
|
| 59 |
-
}
|
| 60 |
-
|
| 61 |
-
interface CliArgs {
|
| 62 |
-
url: string;
|
| 63 |
-
speed: number;
|
| 64 |
-
allVisible: boolean;
|
| 65 |
-
}
|
| 66 |
-
|
| 67 |
-
function parseArgs(argv: string[]): CliArgs {
|
| 68 |
-
const args: CliArgs = {
|
| 69 |
-
url: "http://localhost:5678",
|
| 70 |
-
speed: 2,
|
| 71 |
-
allVisible: false,
|
| 72 |
-
};
|
| 73 |
-
for (let i = 0; i < argv.length; i++) {
|
| 74 |
-
const a = argv[i];
|
| 75 |
-
if (a === "--url") args.url = argv[++i];
|
| 76 |
-
else if (a === "--speed") args.speed = Number(argv[++i]);
|
| 77 |
-
else if (a === "--all-visible") args.allVisible = true;
|
| 78 |
-
}
|
| 79 |
-
return args;
|
| 80 |
-
}
|
| 81 |
-
|
| 82 |
-
interface LaunchOptions {
|
| 83 |
-
persona: Persona;
|
| 84 |
-
headless: boolean;
|
| 85 |
-
windowPosition?: { x: number; y: number };
|
| 86 |
-
/**
|
| 87 |
-
* If true, Chromium starts in OS fullscreen (no window chrome, no menu bar
|
| 88 |
-
* on macOS). Used for Alice (the recorded persona) so the screencast looks
|
| 89 |
-
* clean. Forces `viewport: null` so the page uses the real window size.
|
| 90 |
-
*/
|
| 91 |
-
fullscreen?: boolean;
|
| 92 |
-
}
|
| 93 |
-
|
| 94 |
-
async function launchPersona(
|
| 95 |
-
url: string,
|
| 96 |
-
opts: LaunchOptions
|
| 97 |
-
): Promise<{ browser: Browser; context: BrowserContext; page: Page }> {
|
| 98 |
-
const launchArgs: string[] = [];
|
| 99 |
-
if (opts.fullscreen) {
|
| 100 |
-
launchArgs.push("--start-fullscreen");
|
| 101 |
-
} else {
|
| 102 |
-
launchArgs.push("--window-size=1280,800");
|
| 103 |
-
if (opts.windowPosition) {
|
| 104 |
-
launchArgs.push(
|
| 105 |
-
`--window-position=${opts.windowPosition.x},${opts.windowPosition.y}`
|
| 106 |
-
);
|
| 107 |
-
}
|
| 108 |
-
}
|
| 109 |
-
|
| 110 |
-
const browser = await chromium.launch({
|
| 111 |
-
headless: opts.headless,
|
| 112 |
-
args: launchArgs,
|
| 113 |
-
});
|
| 114 |
-
|
| 115 |
-
// Playwright forbids `deviceScaleFactor` when `viewport` is null (real
|
| 116 |
-
// window size). For fullscreen we accept the OS-native DPR.
|
| 117 |
-
const context = await browser.newContext(
|
| 118 |
-
opts.fullscreen
|
| 119 |
-
? { viewport: null }
|
| 120 |
-
: { viewport: { width: 1280, height: 800 }, deviceScaleFactor: 2 }
|
| 121 |
-
);
|
| 122 |
-
|
| 123 |
-
await context.addInitScript(
|
| 124 |
-
({ key, value }) => {
|
| 125 |
-
try {
|
| 126 |
-
localStorage.setItem(key, value);
|
| 127 |
-
} catch {
|
| 128 |
-
/* ignore */
|
| 129 |
-
}
|
| 130 |
-
},
|
| 131 |
-
{
|
| 132 |
-
key: FALLBACK_USER_KEY,
|
| 133 |
-
value: JSON.stringify({ name: opts.persona.name, color: opts.persona.color }),
|
| 134 |
-
}
|
| 135 |
-
);
|
| 136 |
-
|
| 137 |
-
const page = await context.newPage();
|
| 138 |
-
await page.goto(url, { waitUntil: "domcontentloaded", timeout: 20_000 });
|
| 139 |
-
await page.waitForSelector("[aria-label='Undo']", { timeout: 25_000 });
|
| 140 |
-
|
| 141 |
-
return { browser, context, page };
|
| 142 |
-
}
|
| 143 |
-
|
| 144 |
-
// ----------------------------------------------------------------------------
|
| 145 |
-
// Editor helpers
|
| 146 |
-
// ----------------------------------------------------------------------------
|
| 147 |
-
|
| 148 |
-
/**
|
| 149 |
-
* Move the caret to the very end of the ProseMirror editor, regardless of
|
| 150 |
-
* where it currently sits. More reliable than "body.click() + Ctrl+End":
|
| 151 |
-
* - body.click() lands at the geometric center and may drop the caret
|
| 152 |
-
* inside a heading, which then gets extended when typing.
|
| 153 |
-
* - Ctrl+End is not a "go to end of document" shortcut on macOS.
|
| 154 |
-
* We use the DOM Range API on the first .ProseMirror element.
|
| 155 |
-
*/
|
| 156 |
-
async function focusEnd(page: Page) {
|
| 157 |
-
await page.evaluate(() => {
|
| 158 |
-
const root = document.querySelector(".ProseMirror") as HTMLElement | null;
|
| 159 |
-
if (!root) return;
|
| 160 |
-
root.focus();
|
| 161 |
-
const range = document.createRange();
|
| 162 |
-
range.selectNodeContents(root);
|
| 163 |
-
range.collapse(false);
|
| 164 |
-
const sel = window.getSelection();
|
| 165 |
-
if (!sel) return;
|
| 166 |
-
sel.removeAllRanges();
|
| 167 |
-
sel.addRange(range);
|
| 168 |
-
});
|
| 169 |
-
}
|
| 170 |
-
|
| 171 |
-
/**
|
| 172 |
-
* Wipe everything so each run starts from a pristine doc:
|
| 173 |
-
* - body
|
| 174 |
-
* - frontmatter (title, subtitle, authors, ...)
|
| 175 |
-
* - banner (the previous run's neural network would otherwise persist)
|
| 176 |
-
* - citations + settings + all embeds
|
| 177 |
-
*
|
| 178 |
-
* We dispatch the `reset-article` window event the Editor already exposes
|
| 179 |
-
* for the `/Reset article` slash command, then wait for the empty-banner
|
| 180 |
-
* placeholder (and so the "Create chart" button) to come back.
|
| 181 |
-
*/
|
| 182 |
-
async function resetDoc(page: Page) {
|
| 183 |
-
await page.evaluate(() => {
|
| 184 |
-
window.dispatchEvent(new CustomEvent("reset-article"));
|
| 185 |
-
});
|
| 186 |
-
// Give Yjs / React a beat to flush the wipe to the DOM.
|
| 187 |
-
await pause(500, 800);
|
| 188 |
-
|
| 189 |
-
// The empty-state placeholder reappears once banner === "". Wait for it
|
| 190 |
-
// explicitly so the next step (createNeuralNetworkBanner) is reliable.
|
| 191 |
-
try {
|
| 192 |
-
await page
|
| 193 |
-
.locator(".hero-banner--empty")
|
| 194 |
-
.first()
|
| 195 |
-
.waitFor({ state: "visible", timeout: 5_000 });
|
| 196 |
-
} catch {
|
| 197 |
-
// Non-fatal: createNeuralNetworkBanner has its own warning path.
|
| 198 |
-
}
|
| 199 |
-
}
|
| 200 |
-
|
| 201 |
-
/**
|
| 202 |
-
* Insert a Heading 2 via the markdown shortcut "## ". This triggers the
|
| 203 |
-
* StarterKit input rule and the "##" tokens are transformed live into a
|
| 204 |
-
* styled heading - visually snappier than popping the slash menu.
|
| 205 |
-
*/
|
| 206 |
-
async function insertHeading2(page: Page, text: string, speed: number) {
|
| 207 |
-
await page.keyboard.type("## ", { delay: 45 });
|
| 208 |
-
await pause(80, 160);
|
| 209 |
-
await humanType(page, text, { speed, typoRate: 0.015, thinkRate: 0.08 });
|
| 210 |
-
await page.keyboard.press("Enter");
|
| 211 |
-
await pause(150, 280);
|
| 212 |
-
}
|
| 213 |
-
|
| 214 |
-
/**
|
| 215 |
-
* Enter a code block via the markdown shortcut "```<lang> " then type the
|
| 216 |
-
* body. The regex in @tiptap/extension-code-block expects
|
| 217 |
-
* /^```([a-z]+)?[\s\n]$/
|
| 218 |
-
* so we close the fence with a trailing space, which triggers the rule.
|
| 219 |
-
*/
|
| 220 |
-
async function insertCodeBlock(
|
| 221 |
-
page: Page,
|
| 222 |
-
language: string,
|
| 223 |
-
code: string,
|
| 224 |
-
speed: number
|
| 225 |
-
) {
|
| 226 |
-
await page.keyboard.type("```" + language + " ", { delay: 30 });
|
| 227 |
-
await pause(80, 140);
|
| 228 |
-
await humanType(page, code, {
|
| 229 |
-
speed: speed * 2,
|
| 230 |
-
typoRate: 0,
|
| 231 |
-
thinkRate: 0.02,
|
| 232 |
-
});
|
| 233 |
-
await pause(180, 320);
|
| 234 |
-
await page.keyboard.press("ArrowDown");
|
| 235 |
-
await pause(80, 160);
|
| 236 |
-
}
|
| 237 |
-
|
| 238 |
-
/**
|
| 239 |
-
* Click "Create chart" in the empty banner placeholder, type a short prompt
|
| 240 |
-
* in the Embed Studio (purely visual - we don't wait for the real agent),
|
| 241 |
-
* then inject a pre-built neural network banner via the dev-only
|
| 242 |
-
* `__demo-set-banner` window event the Editor exposes for this script.
|
| 243 |
-
*
|
| 244 |
-
* Finally close the Embed Studio with "Save & Close" so Alice's editor
|
| 245 |
-
* area is fully visible again for the rest of the recording.
|
| 246 |
-
*/
|
| 247 |
-
async function createNeuralNetworkBanner(page: Page, speed: number) {
|
| 248 |
-
const createBtn = page.getByRole("button", { name: /Create chart/i }).first();
|
| 249 |
-
try {
|
| 250 |
-
await createBtn.waitFor({ state: "visible", timeout: 5_000 });
|
| 251 |
-
} catch {
|
| 252 |
-
console.warn('[alice] "Create chart" button not found, skipping banner');
|
| 253 |
-
return;
|
| 254 |
-
}
|
| 255 |
-
await createBtn.scrollIntoViewIfNeeded();
|
| 256 |
-
await pause(120, 220);
|
| 257 |
-
await createBtn.click();
|
| 258 |
-
|
| 259 |
-
// Type the prompt for real so the viewer sees the user instruction
|
| 260 |
-
// forming in the chat textarea, then clear it (mimics submit reset).
|
| 261 |
-
const promptArea = page.getByPlaceholder("Describe your chart...");
|
| 262 |
-
const promptText = "A neural network illustration";
|
| 263 |
-
try {
|
| 264 |
-
await promptArea.waitFor({ state: "visible", timeout: 4_000 });
|
| 265 |
-
await promptArea.click();
|
| 266 |
-
await pause(120, 220);
|
| 267 |
-
await humanType(page, promptText, {
|
| 268 |
-
speed: speed * 1.5,
|
| 269 |
-
typoRate: 0,
|
| 270 |
-
thinkRate: 0.02,
|
| 271 |
-
});
|
| 272 |
-
await pause(150, 280);
|
| 273 |
-
await promptArea.fill("");
|
| 274 |
-
} catch {
|
| 275 |
-
console.warn("[alice] embed studio textarea not found, faking anyway");
|
| 276 |
-
}
|
| 277 |
-
|
| 278 |
-
// Stream a fake assistant reply so the recording shows tokens
|
| 279 |
-
// appearing one by one (real LLM feel), then drop the banner once
|
| 280 |
-
// the reply is fully visible.
|
| 281 |
-
await streamFakeExchange(page, {
|
| 282 |
-
eventName: "__demo-embed-chat",
|
| 283 |
-
prompt: promptText,
|
| 284 |
-
reply:
|
| 285 |
-
"Done - a layered network with subtle pulses along the synapses, themed with the article's primary color.",
|
| 286 |
-
});
|
| 287 |
-
await page.evaluate(
|
| 288 |
-
(html) => {
|
| 289 |
-
window.dispatchEvent(
|
| 290 |
-
new CustomEvent("__demo-set-banner", { detail: { html } }),
|
| 291 |
-
);
|
| 292 |
-
},
|
| 293 |
-
NEURAL_NETWORK_BANNER_HTML,
|
| 294 |
-
);
|
| 295 |
-
await pause(500, 800);
|
| 296 |
-
|
| 297 |
-
const closeBtn = page.getByRole("button", { name: /^Save & Close$/i }).first();
|
| 298 |
-
if (await closeBtn.isVisible().catch(() => false)) {
|
| 299 |
-
await closeBtn.click();
|
| 300 |
-
} else {
|
| 301 |
-
const xBtn = page.getByRole("button", { name: /Close Embed Studio/i }).first();
|
| 302 |
-
if (await xBtn.isVisible().catch(() => false)) {
|
| 303 |
-
await xBtn.click();
|
| 304 |
-
} else {
|
| 305 |
-
await page.keyboard.press("Escape");
|
| 306 |
-
}
|
| 307 |
-
}
|
| 308 |
-
await pause(150, 280);
|
| 309 |
-
}
|
| 310 |
-
|
| 311 |
-
/**
|
| 312 |
-
* Double-click a word, open the comment dialog from the bubble toolbar,
|
| 313 |
-
* type the comment and submit. Scoped to the currently-open dialog so we
|
| 314 |
-
* don't collide with the Publish dialog.
|
| 315 |
-
*/
|
| 316 |
-
async function leaveCommentOn(
|
| 317 |
-
page: Page,
|
| 318 |
-
word: string,
|
| 319 |
-
commentText: string,
|
| 320 |
-
speed: number
|
| 321 |
-
): Promise<boolean> {
|
| 322 |
-
const target = page
|
| 323 |
-
.locator(".ProseMirror p")
|
| 324 |
-
.getByText(word, { exact: false })
|
| 325 |
-
.first();
|
| 326 |
-
try {
|
| 327 |
-
await target.waitFor({ state: "visible", timeout: 45_000 });
|
| 328 |
-
} catch {
|
| 329 |
-
console.warn(`[comment] word "${word}" not found in paragraphs`);
|
| 330 |
-
return false;
|
| 331 |
-
}
|
| 332 |
-
|
| 333 |
-
await target.scrollIntoViewIfNeeded();
|
| 334 |
-
await target.hover();
|
| 335 |
-
await pause(400, 800);
|
| 336 |
-
await target.dblclick();
|
| 337 |
-
await pause(400, 700);
|
| 338 |
-
|
| 339 |
-
const commentBtn = page.locator('.bubble-toolbar button[aria-label="Comment"]');
|
| 340 |
-
try {
|
| 341 |
-
await commentBtn.waitFor({ state: "visible", timeout: 5_000 });
|
| 342 |
-
} catch {
|
| 343 |
-
console.warn(`[comment] bubble toolbar did not show for "${word}"`);
|
| 344 |
-
return false;
|
| 345 |
-
}
|
| 346 |
-
await commentBtn.click();
|
| 347 |
-
|
| 348 |
-
const dialog = page
|
| 349 |
-
.locator('dialog[open]')
|
| 350 |
-
.filter({ has: page.locator("textarea") })
|
| 351 |
-
.first();
|
| 352 |
-
const textarea = dialog.locator("textarea");
|
| 353 |
-
await textarea.waitFor({ state: "visible", timeout: 5_000 });
|
| 354 |
-
await textarea.click();
|
| 355 |
-
await pause(250, 500);
|
| 356 |
-
|
| 357 |
-
await humanType(page, commentText, { speed, typoRate: 0.015 });
|
| 358 |
-
await pause(500, 900);
|
| 359 |
-
|
| 360 |
-
await dialog.locator("button.btn--primary").click();
|
| 361 |
-
return true;
|
| 362 |
-
}
|
| 363 |
-
|
| 364 |
-
// ----------------------------------------------------------------------------
|
| 365 |
-
// Persona scripts
|
| 366 |
-
// ----------------------------------------------------------------------------
|
| 367 |
-
|
| 368 |
-
/**
|
| 369 |
-
* Open the "Add author" modal, fill it in, and submit.
|
| 370 |
-
*
|
| 371 |
-
* - Looks for the placeholder button first (no authors yet), falls back to
|
| 372 |
-
* the small "+" icon button next to the existing authors list.
|
| 373 |
-
* - Optionally creates a new affiliation OR selects an existing chip by its
|
| 374 |
-
* 1-based index (matches the ordered list shown in the form).
|
| 375 |
-
* - Submits via the primary button. Waits for the dialog to close before
|
| 376 |
-
* returning so the next call can find the trigger again.
|
| 377 |
-
*/
|
| 378 |
-
interface AddAuthorOpts {
|
| 379 |
-
url?: string;
|
| 380 |
-
newAffiliation?: string;
|
| 381 |
-
selectAffiliations?: number[]; // 1-based indices
|
| 382 |
-
}
|
| 383 |
-
|
| 384 |
-
async function addAuthor(
|
| 385 |
-
page: Page,
|
| 386 |
-
name: string,
|
| 387 |
-
speed: number,
|
| 388 |
-
opts: AddAuthorOpts = {}
|
| 389 |
-
): Promise<boolean> {
|
| 390 |
-
const placeholderTrigger = page.locator(".meta-placeholder-btn", {
|
| 391 |
-
hasText: /Add author/i,
|
| 392 |
-
});
|
| 393 |
-
const iconTrigger = page.locator('button.icon-btn[aria-label="Add author"]');
|
| 394 |
-
|
| 395 |
-
let opened = false;
|
| 396 |
-
if (await placeholderTrigger.first().isVisible().catch(() => false)) {
|
| 397 |
-
await placeholderTrigger.first().scrollIntoViewIfNeeded();
|
| 398 |
-
await pause(80, 160);
|
| 399 |
-
await placeholderTrigger.first().click();
|
| 400 |
-
opened = true;
|
| 401 |
-
} else if (await iconTrigger.first().isVisible().catch(() => false)) {
|
| 402 |
-
await iconTrigger.first().scrollIntoViewIfNeeded();
|
| 403 |
-
await pause(80, 160);
|
| 404 |
-
await iconTrigger.first().click();
|
| 405 |
-
opened = true;
|
| 406 |
-
}
|
| 407 |
-
if (!opened) {
|
| 408 |
-
console.warn("[author] no Add-author trigger visible");
|
| 409 |
-
return false;
|
| 410 |
-
}
|
| 411 |
-
|
| 412 |
-
const dialog = page.locator("dialog.ed-dialog--author");
|
| 413 |
-
try {
|
| 414 |
-
await dialog.waitFor({ state: "visible", timeout: 5_000 });
|
| 415 |
-
} catch {
|
| 416 |
-
console.warn("[author] modal did not open");
|
| 417 |
-
return false;
|
| 418 |
-
}
|
| 419 |
-
await pause(120, 220);
|
| 420 |
-
|
| 421 |
-
const fastSpeed = speed * 1.4;
|
| 422 |
-
|
| 423 |
-
const nameInput = dialog.getByPlaceholder("Name");
|
| 424 |
-
await nameInput.click();
|
| 425 |
-
await humanType(page, name, { speed: fastSpeed, typoRate: 0, thinkRate: 0.02 });
|
| 426 |
-
await pause(80, 160);
|
| 427 |
-
|
| 428 |
-
if (opts.url) {
|
| 429 |
-
const urlInput = dialog.getByPlaceholder("URL (optional)");
|
| 430 |
-
await urlInput.click();
|
| 431 |
-
await humanType(page, opts.url, { speed: fastSpeed, typoRate: 0, thinkRate: 0.02 });
|
| 432 |
-
await pause(80, 160);
|
| 433 |
-
}
|
| 434 |
-
|
| 435 |
-
if (opts.selectAffiliations && opts.selectAffiliations.length > 0) {
|
| 436 |
-
for (const idx of opts.selectAffiliations) {
|
| 437 |
-
const chip = dialog
|
| 438 |
-
.locator(".chip")
|
| 439 |
-
.filter({ hasText: new RegExp(`^${idx}\\.`) })
|
| 440 |
-
.first();
|
| 441 |
-
if (await chip.isVisible().catch(() => false)) {
|
| 442 |
-
await chip.click();
|
| 443 |
-
await pause(60, 140);
|
| 444 |
-
}
|
| 445 |
-
}
|
| 446 |
-
}
|
| 447 |
-
|
| 448 |
-
if (opts.newAffiliation) {
|
| 449 |
-
const newAffInput = dialog.getByPlaceholder(/New affiliation/i);
|
| 450 |
-
await newAffInput.click();
|
| 451 |
-
await humanType(page, opts.newAffiliation, {
|
| 452 |
-
speed: fastSpeed,
|
| 453 |
-
typoRate: 0,
|
| 454 |
-
thinkRate: 0.02,
|
| 455 |
-
});
|
| 456 |
-
await pause(80, 160);
|
| 457 |
-
}
|
| 458 |
-
|
| 459 |
-
const submit = dialog.locator("button.btn--primary");
|
| 460 |
-
await submit.click();
|
| 461 |
-
|
| 462 |
-
// Dialog unmounts on submit; wait for it to disappear so the next iteration
|
| 463 |
-
// can locate the trigger again.
|
| 464 |
-
try {
|
| 465 |
-
await dialog.waitFor({ state: "hidden", timeout: 3_000 });
|
| 466 |
-
} catch {
|
| 467 |
-
/* non-fatal */
|
| 468 |
-
}
|
| 469 |
-
await pause(100, 200);
|
| 470 |
-
return true;
|
| 471 |
-
}
|
| 472 |
-
|
| 473 |
-
/**
|
| 474 |
-
* Wait until a Heading 2 with the given text is rendered in the document.
|
| 475 |
-
* Used to coordinate handoffs between personas without hard-coded sleeps.
|
| 476 |
-
*/
|
| 477 |
-
async function waitForHeading2(page: Page, text: string, timeout = 60_000) {
|
| 478 |
-
await page
|
| 479 |
-
.locator(".ProseMirror h2")
|
| 480 |
-
.filter({ hasText: text })
|
| 481 |
-
.first()
|
| 482 |
-
.waitFor({ state: "visible", timeout });
|
| 483 |
-
}
|
| 484 |
-
|
| 485 |
-
/**
|
| 486 |
-
* Place this browser's caret at the END of the first <p> directly after
|
| 487 |
-
* the heading whose text matches `headingText`. Used so each persona can
|
| 488 |
-
* type into its OWN section concurrently without stepping on the others.
|
| 489 |
-
*
|
| 490 |
-
* Returns false if the heading or following paragraph cannot be found.
|
| 491 |
-
*/
|
| 492 |
-
async function focusParagraphAfterHeading(page: Page, headingText: string) {
|
| 493 |
-
return await page.evaluate((text) => {
|
| 494 |
-
const root = document.querySelector(".ProseMirror") as HTMLElement | null;
|
| 495 |
-
if (!root) return false;
|
| 496 |
-
const headings = Array.from(root.querySelectorAll("h2"));
|
| 497 |
-
const wanted = text.toLowerCase();
|
| 498 |
-
const heading = headings.find(
|
| 499 |
-
(h) => (h.textContent || "").trim().toLowerCase().includes(wanted),
|
| 500 |
-
);
|
| 501 |
-
if (!heading) return false;
|
| 502 |
-
let para: Element | null = heading.nextElementSibling;
|
| 503 |
-
while (para && para.tagName !== "P") para = para.nextElementSibling;
|
| 504 |
-
if (!para) return false;
|
| 505 |
-
(para as HTMLElement).focus();
|
| 506 |
-
const range = document.createRange();
|
| 507 |
-
range.selectNodeContents(para);
|
| 508 |
-
range.collapse(false);
|
| 509 |
-
const sel = window.getSelection();
|
| 510 |
-
if (!sel) return false;
|
| 511 |
-
sel.removeAllRanges();
|
| 512 |
-
sel.addRange(range);
|
| 513 |
-
return true;
|
| 514 |
-
}, headingText);
|
| 515 |
-
}
|
| 516 |
-
|
| 517 |
-
/**
|
| 518 |
-
* Visibly select an entire paragraph containing the given text snippet.
|
| 519 |
-
* Used to stage an AI rewrite: the viewer sees Alice highlight Bob's
|
| 520 |
-
* paragraph before she opens the chat and asks for a reformulation.
|
| 521 |
-
*
|
| 522 |
-
* We do the selection via the DOM Selection API (rather than
|
| 523 |
-
* `locator.click({ clickCount: 3 })`) because Tiptap/ProseMirror can
|
| 524 |
-
* swallow synthetic triple-clicks. Selecting the full <p> contents
|
| 525 |
-
* yields the same visual result (blue highlight over the paragraph)
|
| 526 |
-
* and survives losing focus to the chat panel.
|
| 527 |
-
*/
|
| 528 |
-
async function selectParagraphByText(
|
| 529 |
-
page: Page,
|
| 530 |
-
snippet: string,
|
| 531 |
-
): Promise<boolean> {
|
| 532 |
-
return await page.evaluate((text) => {
|
| 533 |
-
const root = document.querySelector(".ProseMirror") as HTMLElement | null;
|
| 534 |
-
if (!root) return false;
|
| 535 |
-
const paragraphs = Array.from(root.querySelectorAll("p"));
|
| 536 |
-
const para = paragraphs.find((p) =>
|
| 537 |
-
(p.textContent || "").includes(text),
|
| 538 |
-
);
|
| 539 |
-
if (!para) return false;
|
| 540 |
-
para.scrollIntoView({ behavior: "smooth", block: "center" });
|
| 541 |
-
(para as HTMLElement).focus();
|
| 542 |
-
const range = document.createRange();
|
| 543 |
-
range.selectNodeContents(para);
|
| 544 |
-
const sel = window.getSelection();
|
| 545 |
-
if (!sel) return false;
|
| 546 |
-
sel.removeAllRanges();
|
| 547 |
-
sel.addRange(range);
|
| 548 |
-
// Stamp the paragraph as "claimed by the agent" so the viewer sees
|
| 549 |
-
// a persistent "Agent is working" badge through the chat prompt and
|
| 550 |
-
// the streaming reply. The App.tsx replace handler will swap this
|
| 551 |
-
// for `.demo-agent-rewriting` when the retype actually starts.
|
| 552 |
-
para.classList.add("demo-agent-pending");
|
| 553 |
-
return true;
|
| 554 |
-
}, snippet);
|
| 555 |
-
}
|
| 556 |
-
|
| 557 |
-
/**
|
| 558 |
-
* Stream a fake LLM exchange (user prompt + assistant reply) into a chat
|
| 559 |
-
* panel via the given dev-only window event name. The assistant text is
|
| 560 |
-
* appended chunk by chunk so the recording shows tokens streaming in,
|
| 561 |
-
* the way a real model would render.
|
| 562 |
-
*
|
| 563 |
-
* Optional `replace` triggers a find-and-replace on the editor body
|
| 564 |
-
* AFTER the assistant message is fully streamed - that way the viewer
|
| 565 |
-
* reads the suggestion before seeing the paragraph swap.
|
| 566 |
-
*/
|
| 567 |
-
async function streamFakeExchange(
|
| 568 |
-
page: Page,
|
| 569 |
-
args: {
|
| 570 |
-
eventName: "__demo-chat" | "__demo-embed-chat";
|
| 571 |
-
prompt: string;
|
| 572 |
-
reply: string;
|
| 573 |
-
open?: boolean;
|
| 574 |
-
replace?: { from: string; to: string };
|
| 575 |
-
chunkMs?: [number, number];
|
| 576 |
-
chunkSize?: [number, number];
|
| 577 |
-
},
|
| 578 |
-
) {
|
| 579 |
-
const userMessage = {
|
| 580 |
-
id: `demo-u-${Date.now()}`,
|
| 581 |
-
role: "user",
|
| 582 |
-
parts: [{ type: "text", text: args.prompt }],
|
| 583 |
-
};
|
| 584 |
-
const assistantId = `demo-a-${Date.now() + 1}`;
|
| 585 |
-
|
| 586 |
-
// Step 1: post the user message and (optionally) open the panel.
|
| 587 |
-
await page.evaluate(
|
| 588 |
-
({ name, msgs, open }) => {
|
| 589 |
-
window.dispatchEvent(
|
| 590 |
-
new CustomEvent(name, { detail: { messages: msgs, open } }),
|
| 591 |
-
);
|
| 592 |
-
},
|
| 593 |
-
{
|
| 594 |
-
name: args.eventName,
|
| 595 |
-
msgs: [userMessage],
|
| 596 |
-
open: args.open,
|
| 597 |
-
},
|
| 598 |
-
);
|
| 599 |
-
|
| 600 |
-
// Brief "thinking" beat before the first token streams in.
|
| 601 |
-
await pause(280, 480);
|
| 602 |
-
|
| 603 |
-
// Step 2: stream the assistant text in small chunks.
|
| 604 |
-
const tokens = args.reply.split(/(\s+)/).filter((t) => t.length > 0);
|
| 605 |
-
const [chunkMin, chunkMax] = args.chunkSize ?? [1, 2];
|
| 606 |
-
const [delayMin, delayMax] = args.chunkMs ?? [22, 55];
|
| 607 |
-
let acc = "";
|
| 608 |
-
let i = 0;
|
| 609 |
-
while (i < tokens.length) {
|
| 610 |
-
const take =
|
| 611 |
-
chunkMin +
|
| 612 |
-
Math.floor(Math.random() * Math.max(1, chunkMax - chunkMin + 1));
|
| 613 |
-
const chunk = tokens.slice(i, i + take).join("");
|
| 614 |
-
acc += chunk;
|
| 615 |
-
i += take;
|
| 616 |
-
await page.evaluate(
|
| 617 |
-
({ name, msgs }) => {
|
| 618 |
-
window.dispatchEvent(
|
| 619 |
-
new CustomEvent(name, { detail: { messages: msgs } }),
|
| 620 |
-
);
|
| 621 |
-
},
|
| 622 |
-
{
|
| 623 |
-
name: args.eventName,
|
| 624 |
-
msgs: [
|
| 625 |
-
userMessage,
|
| 626 |
-
{
|
| 627 |
-
id: assistantId,
|
| 628 |
-
role: "assistant",
|
| 629 |
-
parts: [{ type: "text", text: acc }],
|
| 630 |
-
},
|
| 631 |
-
],
|
| 632 |
-
},
|
| 633 |
-
);
|
| 634 |
-
await pause(delayMin, delayMax);
|
| 635 |
-
}
|
| 636 |
-
|
| 637 |
-
// Step 3: optional editor body rewrite, AFTER the reply is fully
|
| 638 |
-
// visible so the viewer reads the suggestion first.
|
| 639 |
-
if (args.replace && args.eventName === "__demo-chat") {
|
| 640 |
-
await pause(280, 450);
|
| 641 |
-
await page.evaluate(
|
| 642 |
-
({ replace }) => {
|
| 643 |
-
window.dispatchEvent(
|
| 644 |
-
new CustomEvent("__demo-chat", { detail: { replace } }),
|
| 645 |
-
);
|
| 646 |
-
},
|
| 647 |
-
{ replace: args.replace },
|
| 648 |
-
);
|
| 649 |
-
}
|
| 650 |
-
}
|
| 651 |
-
|
| 652 |
-
/**
|
| 653 |
-
* Open the floating chat panel, type the prompt for real (so the user
|
| 654 |
-
* sees the textarea filling) then stream a fake assistant reply and
|
| 655 |
-
* apply a paragraph rewrite on the editor body.
|
| 656 |
-
*/
|
| 657 |
-
async function requestRephrase(
|
| 658 |
-
page: Page,
|
| 659 |
-
args: {
|
| 660 |
-
prompt: string;
|
| 661 |
-
reply: string;
|
| 662 |
-
fromText: string;
|
| 663 |
-
toText: string;
|
| 664 |
-
speed: number;
|
| 665 |
-
},
|
| 666 |
-
) {
|
| 667 |
-
// 1) Open the panel via the floating FAB and let it animate in.
|
| 668 |
-
const fab = page.getByRole("button", { name: /AI Assistant/i }).first();
|
| 669 |
-
if (await fab.isVisible().catch(() => false)) {
|
| 670 |
-
await fab.click();
|
| 671 |
-
await pause(220, 380);
|
| 672 |
-
}
|
| 673 |
-
|
| 674 |
-
// 2) Type the user prompt into the chat textarea (real typing for the
|
| 675 |
-
// visible "user side" of the exchange).
|
| 676 |
-
const textarea = page
|
| 677 |
-
.locator(".chat-floating textarea, .chat-panel textarea")
|
| 678 |
-
.first();
|
| 679 |
-
try {
|
| 680 |
-
await textarea.waitFor({ state: "visible", timeout: 3_000 });
|
| 681 |
-
await textarea.click();
|
| 682 |
-
await humanType(page, args.prompt, {
|
| 683 |
-
speed: args.speed * 2.2,
|
| 684 |
-
typoRate: 0,
|
| 685 |
-
thinkRate: 0.01,
|
| 686 |
-
});
|
| 687 |
-
await pause(100, 180);
|
| 688 |
-
} catch {
|
| 689 |
-
console.warn("[chat] textarea not found, will inject anyway");
|
| 690 |
-
}
|
| 691 |
-
|
| 692 |
-
// 3) Clear the textarea (mimics the post-submit reset) and stream the
|
| 693 |
-
// fake exchange + paragraph rewrite.
|
| 694 |
-
try {
|
| 695 |
-
await textarea.fill("");
|
| 696 |
-
} catch {
|
| 697 |
-
/* non-fatal */
|
| 698 |
-
}
|
| 699 |
-
await streamFakeExchange(page, {
|
| 700 |
-
eventName: "__demo-chat",
|
| 701 |
-
prompt: args.prompt,
|
| 702 |
-
reply: args.reply,
|
| 703 |
-
open: true,
|
| 704 |
-
replace: { from: args.fromText, to: args.toText },
|
| 705 |
-
});
|
| 706 |
-
// The editor-side rewrite animates async (highlight ~520ms + chunked
|
| 707 |
-
// retype ~24ms per 1-2 chars). Wait long enough to cover the big
|
| 708 |
-
// reveal without padding the end of the recording too much.
|
| 709 |
-
const animBudgetMs = 500 + args.toText.length * 18;
|
| 710 |
-
await pause(animBudgetMs, animBudgetMs + 200);
|
| 711 |
-
}
|
| 712 |
-
|
| 713 |
-
/**
|
| 714 |
-
* Build the article scaffold in one persona pass: three Heading 2 nodes
|
| 715 |
-
* each followed by an empty paragraph the assigned persona can later fill.
|
| 716 |
-
*
|
| 717 |
-
* Layout produced:
|
| 718 |
-
* <h2>The recipe</h2>
|
| 719 |
-
* <p></p> <- Alice fills this
|
| 720 |
-
* <h2>Intuition</h2>
|
| 721 |
-
* <p></p> <- Bob fills this
|
| 722 |
-
* <h2>In Python</h2>
|
| 723 |
-
* <p></p> <- Carol fills this
|
| 724 |
-
*/
|
| 725 |
-
async function writeScaffold(page: Page, headings: string[], speed: number) {
|
| 726 |
-
for (const h of headings) {
|
| 727 |
-
await page.keyboard.type("## ", { delay: 22 });
|
| 728 |
-
await pause(40, 80);
|
| 729 |
-
await humanType(page, h, {
|
| 730 |
-
speed: speed * 2.2,
|
| 731 |
-
typoRate: 0,
|
| 732 |
-
thinkRate: 0.01,
|
| 733 |
-
});
|
| 734 |
-
await page.keyboard.press("Enter");
|
| 735 |
-
await pause(40, 80);
|
| 736 |
-
// Extra Enter to leave a dedicated empty paragraph below the heading.
|
| 737 |
-
await page.keyboard.press("Enter");
|
| 738 |
-
await pause(50, 100);
|
| 739 |
-
}
|
| 740 |
-
}
|
| 741 |
-
|
| 742 |
-
async function runAlice(page: Page, speed: number) {
|
| 743 |
-
console.log("[alice] ready");
|
| 744 |
-
await pause(150, 250);
|
| 745 |
-
|
| 746 |
-
// --- Title + subtitle (fast, no typos) -------------------------------
|
| 747 |
-
// Speed-tuned for the 30s budget: humans type fast enough that the
|
| 748 |
-
// viewer reads the title forming, but we skip typo backspaces here.
|
| 749 |
-
const fast = speed * 2;
|
| 750 |
-
const title = page.getByPlaceholder("Article title");
|
| 751 |
-
await humanTypeInto(page, title, "Attention, by hand", {
|
| 752 |
-
speed: fast,
|
| 753 |
-
typoRate: 0,
|
| 754 |
-
thinkRate: 0.01,
|
| 755 |
-
});
|
| 756 |
-
|
| 757 |
-
await pause(80, 140);
|
| 758 |
-
|
| 759 |
-
const subtitle = page.getByPlaceholder("Subtitle (optional)");
|
| 760 |
-
await humanTypeInto(
|
| 761 |
-
page,
|
| 762 |
-
subtitle,
|
| 763 |
-
"The one formula behind every LLM",
|
| 764 |
-
{ speed: fast, typoRate: 0, thinkRate: 0.01 },
|
| 765 |
-
);
|
| 766 |
-
|
| 767 |
-
await pause(120, 220);
|
| 768 |
-
|
| 769 |
-
// --- Banner: neural network illustration ------------------------------
|
| 770 |
-
await createNeuralNetworkBanner(page, speed);
|
| 771 |
-
|
| 772 |
-
// --- Article scaffold (3 sections, each with an empty paragraph) ------
|
| 773 |
-
// Alice writes the skeleton so Bob and Carol have stable heading anchors
|
| 774 |
-
// to claim their own paragraphs.
|
| 775 |
-
await focusEnd(page);
|
| 776 |
-
await pause(80, 160);
|
| 777 |
-
await writeScaffold(page, ["The recipe", "Intuition", "In Python"], speed);
|
| 778 |
-
console.log("[alice] scaffold ready, opening parallel phase");
|
| 779 |
-
|
| 780 |
-
// --- Section 1: The recipe (Alice's section, parallel with Bob/Carol) -
|
| 781 |
-
await focusParagraphAfterHeading(page, "The recipe");
|
| 782 |
-
await pause(80, 160);
|
| 783 |
-
|
| 784 |
-
// Inline math uses $$...$$ (see @tiptap/extension-mathematics).
|
| 785 |
-
const bodySpeed = speed * 1.8;
|
| 786 |
-
await humanType(
|
| 787 |
-
page,
|
| 788 |
-
"At the heart of a Transformer, attention mixes queries ",
|
| 789 |
-
{ speed: bodySpeed, typoRate: 0, thinkRate: 0.02 },
|
| 790 |
-
);
|
| 791 |
-
await humanType(page, "$$Q$$", { speed: bodySpeed, typoRate: 0, thinkRate: 0 });
|
| 792 |
-
await humanType(page, ", keys ", { speed: bodySpeed, typoRate: 0, thinkRate: 0 });
|
| 793 |
-
await humanType(page, "$$K$$", { speed: bodySpeed, typoRate: 0, thinkRate: 0 });
|
| 794 |
-
await humanType(page, " and values ", { speed: bodySpeed, typoRate: 0, thinkRate: 0 });
|
| 795 |
-
await humanType(page, "$$V$$", { speed: bodySpeed, typoRate: 0, thinkRate: 0 });
|
| 796 |
-
await humanType(page, ".", { speed: bodySpeed, typoRate: 0, thinkRate: 0 });
|
| 797 |
-
|
| 798 |
-
await pause(160, 280);
|
| 799 |
-
|
| 800 |
-
// Block math line uses $$$...$$$
|
| 801 |
-
await page.keyboard.press("Enter");
|
| 802 |
-
await pause(60, 120);
|
| 803 |
-
await humanType(
|
| 804 |
-
page,
|
| 805 |
-
"$$$\\text{Attention}(Q, K, V) = \\text{softmax}\\left(\\frac{QK^\\top}{\\sqrt{d_k}}\\right)V$$$",
|
| 806 |
-
{ speed: bodySpeed, typoRate: 0, thinkRate: 0 },
|
| 807 |
-
);
|
| 808 |
-
await page.keyboard.press("Enter");
|
| 809 |
-
await pause(80, 160);
|
| 810 |
-
|
| 811 |
-
// Follow-up paragraph wrapping up the recipe. Adds substance so the
|
| 812 |
-
// final article reads like more than a bullet list of components.
|
| 813 |
-
// Typed fast (2.5x base) to stay inside the video budget.
|
| 814 |
-
await humanType(
|
| 815 |
-
page,
|
| 816 |
-
"Softmax turns the scores into weights, and the weighted sum over V gives a contextual vector per token.",
|
| 817 |
-
{ speed: speed * 2.5, typoRate: 0, thinkRate: 0.01 },
|
| 818 |
-
);
|
| 819 |
-
|
| 820 |
-
await pause(180, 320);
|
| 821 |
-
|
| 822 |
-
// --- Rephrase flow: Alice selects Bob's "Intuition" paragraph, opens
|
| 823 |
-
// the main chat and asks to reformulate. The selection is made
|
| 824 |
-
// visible BEFORE the chat opens so the viewer understands what
|
| 825 |
-
// will be rewritten. The exchange is faked via `__demo-chat`.
|
| 826 |
-
try {
|
| 827 |
-
await page
|
| 828 |
-
.locator(".ProseMirror p")
|
| 829 |
-
.filter({ hasText: "Each token asks" })
|
| 830 |
-
.first()
|
| 831 |
-
.waitFor({ state: "visible", timeout: 15_000 });
|
| 832 |
-
await pause(220, 380);
|
| 833 |
-
|
| 834 |
-
const selected = await selectParagraphByText(page, "Each token asks");
|
| 835 |
-
if (!selected) {
|
| 836 |
-
console.warn("[alice] could not select Bob's paragraph");
|
| 837 |
-
} else {
|
| 838 |
-
// Give the viewer a short beat to notice the highlighted paragraph
|
| 839 |
-
// before Alice moves to the chat panel.
|
| 840 |
-
await pause(380, 540);
|
| 841 |
-
await requestRephrase(page, {
|
| 842 |
-
prompt: "Rephrase this paragraph more concisely.",
|
| 843 |
-
reply:
|
| 844 |
-
"Sure - tightened to a three-beat rhythm that matches the headings:",
|
| 845 |
-
fromText:
|
| 846 |
-
"Each token asks: which other tokens should I look at? Q is the question, K is the address, V is the answer.",
|
| 847 |
-
toText:
|
| 848 |
-
"Q asks the question. K shows the address. V delivers the answer.",
|
| 849 |
-
speed,
|
| 850 |
-
});
|
| 851 |
-
}
|
| 852 |
-
} catch {
|
| 853 |
-
console.warn("[alice] Bob's paragraph not detected, skipping rephrase");
|
| 854 |
-
}
|
| 855 |
-
|
| 856 |
-
console.log("[alice] scenario complete");
|
| 857 |
-
}
|
| 858 |
-
|
| 859 |
-
async function runBob(page: Page, speed: number) {
|
| 860 |
-
console.log("[bob] joined, opening author modal early");
|
| 861 |
-
await pause(800, 1_200);
|
| 862 |
-
|
| 863 |
-
// 1) Author modal happens FIRST, in parallel with Alice typing the title
|
| 864 |
-
// and creating the banner. The modal sits above the editor so it
|
| 865 |
-
// doesn't disturb Alice's body work, and it's the fastest way to
|
| 866 |
-
// show "Bob is contributing too" while Alice is still solo on screen.
|
| 867 |
-
await addAuthor(page, "Alice Renoir", speed, {
|
| 868 |
-
newAffiliation: "Hugging Face",
|
| 869 |
-
});
|
| 870 |
-
await pause(120, 240);
|
| 871 |
-
await addAuthor(page, "Bob Mercier", speed, {
|
| 872 |
-
selectAffiliations: [1],
|
| 873 |
-
});
|
| 874 |
-
await pause(120, 240);
|
| 875 |
-
await addAuthor(page, "Carol Dubois", speed, {
|
| 876 |
-
selectAffiliations: [1],
|
| 877 |
-
});
|
| 878 |
-
console.log("[bob] authors added");
|
| 879 |
-
|
| 880 |
-
// 2) Wait for the "Intuition" heading from Alice's scaffold, then claim
|
| 881 |
-
// the paragraph after it. This is when the parallel typing window
|
| 882 |
-
// really kicks in: Alice writing "The recipe", Carol writing
|
| 883 |
-
// "In Python", Bob writing here, all at the same time.
|
| 884 |
-
try {
|
| 885 |
-
await waitForHeading2(page, "Intuition", 60_000);
|
| 886 |
-
} catch {
|
| 887 |
-
console.warn("[bob] 'Intuition' heading not found, skipping body");
|
| 888 |
-
return;
|
| 889 |
-
}
|
| 890 |
-
await pause(150, 280);
|
| 891 |
-
|
| 892 |
-
const focused = await focusParagraphAfterHeading(page, "Intuition");
|
| 893 |
-
if (!focused) {
|
| 894 |
-
console.warn("[bob] could not focus paragraph after 'Intuition'");
|
| 895 |
-
return;
|
| 896 |
-
}
|
| 897 |
-
await pause(60, 120);
|
| 898 |
-
const bobSpeed = speed * 1.8;
|
| 899 |
-
await humanType(
|
| 900 |
-
page,
|
| 901 |
-
"Each token asks: which other tokens should I look at? ",
|
| 902 |
-
{ speed: bobSpeed, typoRate: 0, thinkRate: 0.02 },
|
| 903 |
-
);
|
| 904 |
-
await humanType(page, "$$Q$$", { speed: bobSpeed, typoRate: 0, thinkRate: 0 });
|
| 905 |
-
await humanType(page, " is the question, ", { speed: bobSpeed, typoRate: 0, thinkRate: 0 });
|
| 906 |
-
await humanType(page, "$$K$$", { speed: bobSpeed, typoRate: 0, thinkRate: 0 });
|
| 907 |
-
await humanType(page, " is the address, ", { speed: bobSpeed, typoRate: 0, thinkRate: 0 });
|
| 908 |
-
await humanType(page, "$$V$$", { speed: bobSpeed, typoRate: 0, thinkRate: 0 });
|
| 909 |
-
await humanType(page, " is the answer.", { speed: bobSpeed, typoRate: 0, thinkRate: 0 });
|
| 910 |
-
|
| 911 |
-
await pause(120, 220);
|
| 912 |
-
|
| 913 |
-
// Follow-up paragraph expanding the mental model. Bob doesn't touch
|
| 914 |
-
// the first paragraph again: Alice will target it for the rephrase.
|
| 915 |
-
await page.keyboard.press("Enter");
|
| 916 |
-
await pause(60, 120);
|
| 917 |
-
await humanType(
|
| 918 |
-
page,
|
| 919 |
-
"Multiply, normalize with softmax, and each token becomes a weighted blend of its neighbours.",
|
| 920 |
-
{ speed: speed * 2.5, typoRate: 0, thinkRate: 0.01 },
|
| 921 |
-
);
|
| 922 |
-
|
| 923 |
-
console.log("[bob] scenario complete");
|
| 924 |
-
}
|
| 925 |
-
|
| 926 |
-
async function runCarol(page: Page, speed: number) {
|
| 927 |
-
console.log("[carol] joined, will write the code section");
|
| 928 |
-
await pause(1_200, 1_800);
|
| 929 |
-
|
| 930 |
-
// Wait for Alice's scaffold to ship the "In Python" heading, then claim
|
| 931 |
-
// the paragraph below it and type the code block immediately. Carol
|
| 932 |
-
// types in parallel with Alice's section and Bob's section.
|
| 933 |
-
try {
|
| 934 |
-
await waitForHeading2(page, "In Python", 60_000);
|
| 935 |
-
} catch {
|
| 936 |
-
console.warn("[carol] 'In Python' heading not found, skipping section");
|
| 937 |
-
return;
|
| 938 |
-
}
|
| 939 |
-
await pause(150, 300);
|
| 940 |
-
|
| 941 |
-
const focused = await focusParagraphAfterHeading(page, "In Python");
|
| 942 |
-
if (!focused) {
|
| 943 |
-
console.warn("[carol] could not focus paragraph after 'In Python'");
|
| 944 |
-
return;
|
| 945 |
-
}
|
| 946 |
-
await pause(80, 160);
|
| 947 |
-
|
| 948 |
-
// Preamble line before the code block so the section reads as a
|
| 949 |
-
// narrated snippet rather than an isolated chunk of Python.
|
| 950 |
-
await humanType(
|
| 951 |
-
page,
|
| 952 |
-
"The whole recipe fits in eight lines of PyTorch:",
|
| 953 |
-
{ speed: speed * 2.5, typoRate: 0, thinkRate: 0.01 },
|
| 954 |
-
);
|
| 955 |
-
await page.keyboard.press("Enter");
|
| 956 |
-
await pause(80, 160);
|
| 957 |
-
|
| 958 |
-
const code =
|
| 959 |
-
"import torch\n" +
|
| 960 |
-
"import torch.nn.functional as F\n" +
|
| 961 |
-
"\n" +
|
| 962 |
-
"def attention(Q, K, V):\n" +
|
| 963 |
-
" d_k = K.size(-1)\n" +
|
| 964 |
-
" scores = Q @ K.transpose(-2, -1) / d_k ** 0.5\n" +
|
| 965 |
-
" weights = F.softmax(scores, dim=-1)\n" +
|
| 966 |
-
" return weights @ V";
|
| 967 |
-
await insertCodeBlock(page, "python", code, speed);
|
| 968 |
-
|
| 969 |
-
// Closing reference (lands right after the code block).
|
| 970 |
-
await humanType(
|
| 971 |
-
page,
|
| 972 |
-
"Introduced by Vaswani et al. (2017), this single block of math powers every modern LLM.",
|
| 973 |
-
{ speed: speed * 2.5, typoRate: 0, thinkRate: 0.01 },
|
| 974 |
-
);
|
| 975 |
-
|
| 976 |
-
console.log("[carol] scenario complete");
|
| 977 |
-
}
|
| 978 |
-
|
| 979 |
-
// ----------------------------------------------------------------------------
|
| 980 |
-
// Main
|
| 981 |
-
// ----------------------------------------------------------------------------
|
| 982 |
-
|
| 983 |
-
async function run() {
|
| 984 |
-
const args = parseArgs(process.argv.slice(2));
|
| 985 |
-
console.log(
|
| 986 |
-
`[trio] target=${args.url} speed=${args.speed}x allVisible=${args.allVisible}`
|
| 987 |
-
);
|
| 988 |
-
|
| 989 |
-
// Nuke any orphaned Chromium left over from a previous run (e.g. demo
|
| 990 |
-
// killed with Ctrl+C before it could close its browsers). Without this
|
| 991 |
-
// we get stale Yjs connections / extra windows cluttering the screen.
|
| 992 |
-
killLeftoverChromiums();
|
| 993 |
-
|
| 994 |
-
const alicePos = args.allVisible ? { x: 0, y: 0 } : undefined;
|
| 995 |
-
const bobPos = args.allVisible ? { x: 1300, y: 0 } : undefined;
|
| 996 |
-
const carolPos = args.allVisible ? { x: 650, y: 830 } : undefined;
|
| 997 |
-
|
| 998 |
-
const alice = await launchPersona(args.url, {
|
| 999 |
-
persona: PERSONAS.alice,
|
| 1000 |
-
headless: false,
|
| 1001 |
-
windowPosition: alicePos,
|
| 1002 |
-
fullscreen: !args.allVisible,
|
| 1003 |
-
});
|
| 1004 |
-
console.log("[trio] alice up");
|
| 1005 |
-
|
| 1006 |
-
// Wipe the shared Yjs doc once on Alice's session BEFORE Bob/Carol join,
|
| 1007 |
-
// so they connect to a clean slate (no leftover banner / body / title
|
| 1008 |
-
// from a previous demo run). Idempotent: a no-op on a fresh server.
|
| 1009 |
-
console.log("[trio] resetting shared document");
|
| 1010 |
-
await resetDoc(alice.page);
|
| 1011 |
-
|
| 1012 |
-
const bob = await launchPersona(args.url, {
|
| 1013 |
-
persona: PERSONAS.bob,
|
| 1014 |
-
headless: !args.allVisible,
|
| 1015 |
-
windowPosition: bobPos,
|
| 1016 |
-
});
|
| 1017 |
-
console.log("[trio] bob up");
|
| 1018 |
-
|
| 1019 |
-
const carol = await launchPersona(args.url, {
|
| 1020 |
-
persona: PERSONAS.carol,
|
| 1021 |
-
headless: !args.allVisible,
|
| 1022 |
-
windowPosition: carolPos,
|
| 1023 |
-
});
|
| 1024 |
-
console.log("[trio] carol up");
|
| 1025 |
-
|
| 1026 |
-
// T0 marks the start of the actual demo content (after the browsers
|
| 1027 |
-
// and Yjs reset). Useful to measure the perceived "video length"
|
| 1028 |
-
// independently of Playwright cold-start time.
|
| 1029 |
-
const T0 = Date.now();
|
| 1030 |
-
const stamp = (tag: string) =>
|
| 1031 |
-
console.log(`[trio] +${((Date.now() - T0) / 1000).toFixed(2)}s ${tag}`);
|
| 1032 |
-
stamp("demo phase start");
|
| 1033 |
-
|
| 1034 |
-
await Promise.all([
|
| 1035 |
-
runAlice(alice.page, args.speed)
|
| 1036 |
-
.then(() => stamp("alice done"))
|
| 1037 |
-
.catch((err) => console.error("[alice] error:", err)),
|
| 1038 |
-
runBob(bob.page, args.speed)
|
| 1039 |
-
.then(() => stamp("bob done"))
|
| 1040 |
-
.catch((err) => console.error("[bob] error:", err)),
|
| 1041 |
-
runCarol(carol.page, args.speed)
|
| 1042 |
-
.then(() => stamp("carol done"))
|
| 1043 |
-
.catch((err) => console.error("[carol] error:", err)),
|
| 1044 |
-
]);
|
| 1045 |
-
stamp("all personas finished");
|
| 1046 |
-
|
| 1047 |
-
console.log("[trio] all personas finished. Browsers stay open. Ctrl+C to quit.");
|
| 1048 |
-
await new Promise(() => {});
|
| 1049 |
-
}
|
| 1050 |
-
|
| 1051 |
-
run().catch((err) => {
|
| 1052 |
-
console.error("[trio] fatal:", err);
|
| 1053 |
-
process.exit(1);
|
| 1054 |
-
});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|