tfrere's picture
tfrere HF Staff
feat(demo): full showcase polish - mouse cursor, robust selection, HF affiliations
f469961
Raw
History Blame Contribute Delete
10.5 kB
/**
* Bob - Yjs-only collaborator for the showcase demo.
*
* Bob is NOT a browser. He's a Node process that speaks the same
* Yjs CRDT protocol as the editor tabs and manipulates the shared
* document directly via Y.XmlElement / Y.XmlText. Every write shows
* up in Alice's Tiptap editor in real time without any DOM typing,
* input-rule surprises, or PM position races.
*
* Bob owns the "The recipe" section (top of doc). His flow:
* 1. Wait for Alice's first author to land, then push two
* university co-authors (MIT, ETH Zurich).
* 2. Wait for the "The recipe" H2 to appear in the shared
* fragment (Alice writes the scaffold from her browser).
* 3. Fill slot 0 with the definition paragraph + inline Q/K/V
* math atoms.
* 4. Register the Vaswani 2017 paper in the shared citations
* map, append a citation chip at the end of slot 0, and
* spawn a Bibliography block at the bottom of the doc if
* none exists.
* 5. Drop the canonical attention formula as a `<blockMath>`
* atom into slot 1. That's a different agent (not Alice)
* contributing a display-math equation to the article.
* 6. Fill slot 2 with the softmax explanation.
* 7. Fill slot 3 with the multi-head teaser.
*/
import * as Y from "yjs";
import {
addAuthorRecord,
appendInline,
broadcastCursorAtEnd,
claimSlotAfterHeading,
clearAwarenessCursor,
findHeadingIndex,
insertInlineNode,
registerCitation,
setAffiliationUrl,
sleep,
typeIntoParagraph,
waitForAffiliationCount,
waitForAuthorCount,
waitForCondition,
writeBlockMathAtSlot,
type PersonaHandle,
} from "./lib/yjs-client.js";
function jitter(minMs: number, maxMs: number): number {
return minMs + Math.random() * (maxMs - minMs);
}
/**
* Ensure a `<bibliography />` atom exists at the end of the shared
* fragment. If one is already there (e.g. from a previous run that
* wasn't reset) we keep it; otherwise we push a fresh, empty one
* and let `BibliographyView` on Alice's side fill `renderedHtml`
* via the formatting endpoint.
*/
function ensureBibliographySection(handle: PersonaHandle): void {
const existing = handle.fragment
.toArray()
.find((c) => c instanceof Y.XmlElement && c.nodeName === "bibliography");
if (existing) return;
handle.ydoc.transact(() => {
const bib = new Y.XmlElement("bibliography");
handle.fragment.push([bib]);
bib.setAttribute("renderedHtml", "");
// Tiptap doesn't require an explicit trailing paragraph after
// the bibliography, but the original doc shape has one so the
// cursor always has a landing spot past the biblio in Alice's
// editor.
const trailing = new Y.XmlElement("paragraph");
handle.fragment.push([trailing]);
});
}
export async function runBob(
bob: PersonaHandle,
speed: number,
): Promise<void> {
console.log("[bob] connected as Yjs peer, waiting for Alice's first author");
// Give Alice a beat to start typing the title before we pile on
// the author list; looks more natural on camera.
await sleep(jitter(800, 1200));
// 1) Authors -----------------------------------------------------
// Alice seeds the byline with herself + the "Hugging Face"
// affiliation (index 1) but the modal she drives doesn't expose a
// URL field for the affiliation. Bob then:
// - waits for that affiliation to land in the shared Y.Array,
// - patches its `url` to https://huggingface.co so the byline
// renders the affiliation as a real link,
// - appends two more authors that REUSE affiliation index 1
// instead of creating fresh institutions, each with a personal
// huggingface.co profile URL.
// Net effect on the byline: three co-authors (Alice + Bob's two)
// all under the same Hugging Face affiliation, with both the
// affiliation chip and each author name linkable.
const seeded = await waitForAuthorCount(bob, 1, 30_000);
if (!seeded) {
console.warn("[bob] Alice's first author never appeared, proceeding");
}
const affSeeded = await waitForAffiliationCount(bob, 1, 30_000);
if (!affSeeded) {
console.warn("[bob] Hugging Face affiliation never appeared, proceeding");
}
await sleep(jitter(250, 500));
setAffiliationUrl(bob, 1, "https://huggingface.co");
await sleep(jitter(180, 320));
addAuthorRecord(bob, {
name: "Bob Mercier",
url: "https://huggingface.co/bmercier",
existingAffiliationIndices: [1],
});
await sleep(jitter(300, 550));
addAuthorRecord(bob, {
name: "Mira Patel",
url: "https://huggingface.co/mpatel",
existingAffiliationIndices: [1],
});
console.log("[bob] authors added (both at Hugging Face)");
// 2) Wait for scaffold ------------------------------------------
// Alice's Playwright scaffold emits the H2 headings + empty
// paragraph slots. We gate on the "The recipe" heading showing
// up in our synced fragment before we start writing body text
// so `claimSlotAfterHeading` finds real anchors.
const hasHeading = await waitForCondition(
bob.fragment,
() => findHeadingIndex(bob.fragment, "The recipe") !== -1,
60_000,
);
if (!hasHeading) {
console.warn("[bob] 'The recipe' heading never arrived, skipping body");
return;
}
await sleep(jitter(150, 300));
// 3) Slot 0 - definition + inline math --------------------------
// We type the prose into the paragraph via `typeIntoParagraph`
// (chunked Y.XmlText inserts with human cadence so Alice sees
// text stream in), then splice in Y.XmlElement("inlineMath")
// atoms for Q, K, V. Each insert is a single atomic CRDT op,
// no input-rule interpretation involved.
const slot0 = claimSlotAfterHeading(bob.fragment, "The recipe", 0);
if (!slot0) {
console.warn("[bob] could not locate slot 0 of 'The recipe'");
return;
}
await sleep(jitter(80, 160));
const bodyCps = 38 / Math.max(0.5, speed / 2);
const fastCps = 48 / Math.max(0.5, speed / 2);
await typeIntoParagraph(
slot0,
"Attention lets a token look at every other token, scoring each pair through queries ",
{ cps: bodyCps, chunkSize: 2 },
undefined,
bob,
);
await insertInlineNode(slot0, { type: "inlineMath", latex: "Q" }, 200, bob);
await typeIntoParagraph(slot0, ", keys ", { cps: bodyCps }, undefined, bob);
await insertInlineNode(slot0, { type: "inlineMath", latex: "K" }, 160, bob);
await typeIntoParagraph(
slot0,
" and values ",
{ cps: bodyCps },
undefined,
bob,
);
await insertInlineNode(slot0, { type: "inlineMath", latex: "V" }, 160, bob);
await typeIntoParagraph(slot0, ".", { cps: bodyCps }, undefined, bob);
await sleep(jitter(240, 400));
// 4) Citation + bibliography -----------------------------------
// Register the entry in the shared Y.Map("citations"). The
// frontend's citation NodeView listens on that map, so any chip
// with matching `key` auto-resolves its label from the entry.
//
// We still write the `label` attr ourselves so the chip renders
// "(Vaswani et al., 2017)" as soon as it lands even if the
// NodeView observer hasn't fired yet (APA-ish placeholder).
const citationKey = "vaswani2017attention";
registerCitation(bob, citationKey, {
id: citationKey,
"citation-key": citationKey,
type: "article-journal",
title: "Attention Is All You Need",
author: [
{ family: "Vaswani", given: "Ashish" },
{ family: "Shazeer", given: "Noam" },
{ family: "Parmar", given: "Niki" },
{ family: "Uszkoreit", given: "Jakob" },
{ family: "Jones", given: "Llion" },
{ family: "Gomez", given: "Aidan N." },
{ family: "Kaiser", given: "Lukasz" },
{ family: "Polosukhin", given: "Illia" },
],
issued: { "date-parts": [[2017]] },
"container-title": "Advances in Neural Information Processing Systems",
volume: "30",
URL: "https://arxiv.org/abs/1706.03762",
DOI: "10.48550/arXiv.1706.03762",
});
appendInline(slot0, [
{
type: "citation",
key: citationKey,
label: "(Vaswani et al., 2017)",
},
]);
broadcastCursorAtEnd(bob, slot0);
ensureBibliographySection(bob);
console.log("[bob] citation + bibliography inserted");
await sleep(jitter(320, 520));
// 5) Slot 1 - block math (the attention formula) --------------
// Atomic CRDT replace: the empty scaffold paragraph is swapped
// for a `<blockMath>` atom carrying the canonical scaled-dot-
// product-attention formula. Alice sees the KaTeX render pop
// in the moment the sync hits her editor - crisp "another
// agent dropped an equation" moment on camera. Short beat
// before and after so the viewer registers the transition
// from prose to display math.
await sleep(jitter(220, 380));
const mathNode = writeBlockMathAtSlot(
bob.fragment,
"The recipe",
1,
"\\text{Attention}(Q, K, V) = \\text{softmax}\\!\\left(\\frac{QK^\\top}{\\sqrt{d_k}}\\right) V",
);
if (mathNode) {
console.log("[bob] block math equation inserted");
} else {
console.warn("[bob] could not insert block math");
}
await sleep(jitter(320, 500));
// 6) Slot 2 - softmax explanation ------------------------------
const slot2 = claimSlotAfterHeading(bob.fragment, "The recipe", 2);
if (slot2) {
await sleep(jitter(80, 150));
await typeIntoParagraph(
slot2,
"The softmax turns those similarity scores into weights, and the weighted sum over values produces one contextual vector per token.",
{ cps: fastCps, chunkSize: 2 },
undefined,
bob,
);
} else {
console.warn("[bob] could not locate slot 2");
}
await sleep(jitter(200, 320));
// 7) Slot 3 - multi-head teaser --------------------------------
const slot3 = claimSlotAfterHeading(bob.fragment, "The recipe", 3);
if (slot3) {
await sleep(jitter(80, 150));
await typeIntoParagraph(
slot3,
"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.",
{ cps: fastCps, chunkSize: 2 },
undefined,
bob,
);
} else {
console.warn("[bob] could not locate slot 3");
}
await sleep(jitter(200, 320));
// Clear Bob's caret so it doesn't linger past the last paragraph
// he wrote; Alice gets a clean frame for the hue drag / publish.
clearAwarenessCursor(bob);
console.log("[bob] scenario complete");
}