Commit 0943ca547 for llama.cpp
commit 0943ca5472f32774375c1648912357b20f57a1c6
Author: Aleksander Grygier <aleksander.grygier@gmail.com>
Date: Tue Nov 3 12:30:49 2026 +0100
ui: add Playwright harness for recording demo videos (#31318)
* ui: add Playwright harness for recording demo videos
Scripted walkthroughs under tools/ui/demo are recorded with Playwright and
exported as H.264 mp4 for pull requests (demo/pr) and docs (demo/docs).
Fixture MCP servers and seeded settings keep recordings deterministic, a
drawn cursor and captions make the flow readable, and DEMO_UI_DIR points
the recording at another checkout such as a PR worktree.
Assisted-by: Claude (Opus 5.5 Medium)
* ui: record demo scripts from PR descriptions and a configurable storage
Demo scripts no longer live in the repo. `npm run demo:pr -- <number>`
records the `ts demo` block from a PR description against the PR head,
uploads the mp4, a GIF preview and the script to DEMO_STORAGE, and links
the preview in a `## Demo` section of the PR description.
`npm run demo:docs` records the docs scripts kept in DEMO_STORAGE.
DEMO_STORAGE (hf:// buckets or file://) and the public URL are read
from tools/ui/.env.
Assisted-by: Claude (Opus 5.5 Medium)
* ui: import generated conversations into demo recordings
Demos can now start with existing chats: `test.use({ traces })` imports
llama.app JSONL traces through Settings > Import/Export before the video
starts. Built-in traces are generated deterministically, other names are
downloaded from DEMO_TRACES_DATASET, and `demo:traces` pushes the built-in
ones there. Adds `demo.scroll` for wheel scrolling.
Assisted-by: Claude (Opus 5.5 Medium)
* ui: keep demo captions clear of the chat form and isolate concurrent runs
Captions move to the top of the frame, since at the bottom they covered
the chat form a demo is often about. Each recording run gets its own
Playwright output folder, so a run starting elsewhere no longer clears
another run's video before it is saved.
Assisted-by: Claude (Opus 5.5 Medium)
* ui: record demos at 2x and 60 fps with human-like timing
Recording goes through Chromium's screencast instead of Playwright's
video, which encoded VP8 at about 1 Mbit/s at 1x. Frames are captured at
the device pixel ratio (DEMO_SCALE, default 2) and encoded into a 60 fps
H.264 mp4, starting once the page has settled.
The cursor now glides faster along a slight arc with Fitts-like
durations, settles briefly before each click, and typing and pauses vary
a little. The randomness is seeded per demo, so re-recording gives the
same motion.
Assisted-by: Claude (Opus 5.5 Medium)
* ui: document the demo harness prerequisites
The demo README now lists the tools the harness needs and what each is
for, and walks through creating the Hugging Face bucket and the optional
traces dataset, with the matching .env settings.
Assisted-by: Claude (Opus 5.5 Medium)
* ui: version linked demo URLs so re-recorded previews refresh
GitHub's image proxy caches a preview by URL, so a demo re-recorded at
the same PR head kept showing the old GIF. The Demo section now links
the files with a content hash in the query string.
Assisted-by: Claude (Opus 5.5 Medium)
* ui: label PRs that feature a video demo
Adds a `video demo` label and a workflow that applies it when a PR
description has a demo written by the UI demo pipeline (the managed
demo:start/demo:end markers, or a preview linking to a demo video),
and removes it when that section is gone.
Assisted-by: Claude (Opus 5.5 Medium)
* ci: label video demos with gh instead of github-script
The label workflow now runs the gh CLI in a shell step, like
pr-draft-label.yml, so it needs no action outside the repository's
allowed list. The video link check only counts videos uploaded for the
PR itself, so a description linking other PRs' demos isn't labeled.
Assisted-by: Claude (Opus 5.5 Medium)
* ui: zoom demos in on details with a two-finger pinch
demo.zoom pinches in on a control or a point, holds, and pinches back out
to 1x, so a detail too small to read in the frame can be shown up close.
It drives Chromium's own pinch gesture, so the page's fixed bars stay put,
and works at desktop and phone sizes alike.
The pinch is evened out where Chromium misses: one anchored near the top
does not zoom at all, so it starts lower, and one near the bottom
overshoots its ratio, so a small pinch from the middle corrects it. The
way out reads the real scale until the page is back at 1x, which the next
gesture needs.
The overlay also draws a fingertip per touch, so a pinch on a touch
device shows as two fingers.
Assisted-by: Claude (Opus 5.5 Medium)
* ui: wait out a cold dev server before a demo starts
A fresh checkout's dev server optimizes its dependencies on the first
load and reloads the page, which can take well past an action's timeout,
so a demo failed before it started. The demo now waits up to 90 seconds
for the app; the recording only starts once it is ready.
Assisted-by: Claude (Opus 5.5 Medium)
* ui: record a video per test in a PR demo, shown in a table
A large PR can split its demo into several shorter videos: each test()
in the ts demo block is recorded as its own video (<sha>-1.mp4, ...),
and the Demo section lays their previews out in a table, each column
headed by its test's title.
GIF previews now keep their longer side at DEMO_PREVIEW_WIDTH and shrink
until they are under 5 MB, since GitHub's image proxy refuses anything
larger and shows a broken image instead.
Importing traces works on a phone viewport too, where Settings sits in
the sidebar under the page until the menu key slides it aside.
Assisted-by: Claude (Opus 5.5 Medium)
* ui: fix demo trace imports on desktop and lay landscape demos out two to a row
Importing traces failed on a desktop whose collapsed sidebar sits under
the page: it now expands the sidebar from the page's menu key on a phone
or from the sidebar's logo on a desktop, whichever makes Settings
reachable. Afterwards it forgets the expanded sidebar, so a demo starts
with it collapsed, as on a first visit.
A PR demo with several videos shows up to four portrait (phone) videos
to a row, or two landscape ones, so each preview stays readable.
Assisted-by: Claude (Opus 5.5 Medium)
* ui: only record other people's PR demos at a reviewed head commit
demo:pr runs the PR's code and demo script on this machine, with the
gh and hf logins, so a PR by someone else now needs --trust <sha>
naming the head commit that was read, and is refused again after new
pushes. Recordings also launch Chromium with its sandbox on, which
Playwright turns off by default.
Assisted-by: Claude (Opus 5.5 Medium)
diff --git a/.github/workflows/pr-video-demo-label.yml b/.github/workflows/pr-video-demo-label.yml
new file mode 100644
index 000000000..9b3165fa2
--- /dev/null
+++ b/.github/workflows/pr-video-demo-label.yml
@@ -0,0 +1,37 @@
+name: "PR video demo label"
+
+on:
+ pull_request_target:
+ types: [opened, edited, reopened, synchronize]
+
+cache-mode: none
+permissions:
+ pull-requests: write
+ issues: write
+
+jobs:
+ video-demo-label:
+ runs-on: ubuntu-slim
+ steps:
+ - name: Add or remove the video demo label
+ env:
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ PR_URL: ${{ github.event.pull_request.html_url }}
+ PR_NUMBER: ${{ github.event.pull_request.number }}
+ # passed through env, so the body is only matched, never run, and is safe to read from a fork
+ BODY: ${{ github.event.pull_request.body }}
+ HAS_LABEL: ${{ contains(github.event.pull_request.labels.*.name, 'video demo') }}
+ run: |
+ # sections written by demo:pr (see tools/ui/demo/cli.ts): the managed comment
+ # markers, or a preview linking to a demo video uploaded for this PR
+ has_demo=false
+ if { grep -qF '<!-- demo:start -->' <<< "$BODY" && grep -qF '<!-- demo:end -->' <<< "$BODY"; } ||
+ grep -qE "/resolve/videos/pr/$PR_NUMBER/[0-9a-f]{10}(-[0-9]+)?\.mp4" <<< "$BODY"; then
+ has_demo=true
+ fi
+
+ if [ "$has_demo" = true ] && [ "$HAS_LABEL" = false ]; then
+ gh pr edit "$PR_URL" --add-label 'video demo'
+ elif [ "$has_demo" = false ] && [ "$HAS_LABEL" = true ]; then
+ gh pr edit "$PR_URL" --remove-label 'video demo'
+ fi
diff --git a/tools/ui/.env.example b/tools/ui/.env.example
index a1e6c5491..a561a68a6 100644
--- a/tools/ui/.env.example
+++ b/tools/ui/.env.example
@@ -1,3 +1,21 @@
VITE_PUBLIC_APP_NAME='llama-ui'
VITE_DEBUG='true'
VITE_PUBLIC_SERVER_ORIGIN='http://localhost:8033'
+
+# Demo videos (npm run demo:pr / demo:docs), see demo/README.md
+# Where demo scripts and videos are stored; unset keeps videos in demo/videos
+# DEMO_STORAGE='hf://buckets/<owner>/<bucket>'
+# DEMO_STORAGE='file:///path/to/dir'
+# Public base URL of DEMO_STORAGE, used to link videos in PR descriptions
+# DEMO_STORAGE_PUBLIC_URL='https://...'
+# HF dataset for demo conversations (llama.app traces): `demo:traces` pushes the
+# built-in ones there, and demos can import any <name>.jsonl from it
+# DEMO_TRACES_DATASET='<owner>/<dataset>'
+# DEMO_GITHUB_REPO='ggml-org/llama.cpp'
+# DEMO_SERVER_ORIGIN='http://localhost:9931'
+# DEMO_LLAMA_SERVER='../../build/bin/llama-server'
+# DEMO_COLOR_SCHEME='light'
+# Device pixel ratio demos render and record at (1440x900 viewport)
+# DEMO_SCALE='2'
+# Longer side of the GIF preview, in pixels; it shrinks further to stay under 5 MB
+# DEMO_PREVIEW_WIDTH='800'
diff --git a/tools/ui/demo/.gitignore b/tools/ui/demo/.gitignore
new file mode 100644
index 000000000..5a02216c4
--- /dev/null
+++ b/tools/ui/demo/.gitignore
@@ -0,0 +1,5 @@
+.runs/
+test-results/
+videos/
+*.demo.ts
+/traces/
diff --git a/tools/ui/demo/README.md b/tools/ui/demo/README.md
new file mode 100644
index 000000000..70617780d
--- /dev/null
+++ b/tools/ui/demo/README.md
@@ -0,0 +1,133 @@
+# Demo videos
+
+Scripted Playwright walkthroughs of the UI, exported as mp4 with an animated GIF preview, for pull requests and documentation. The harness lives here; the demo scripts don't:
+
+- **PR demos** live in the PR description, as a `ts demo` code block.
+- **Docs demos** live in the storage target, under `scripts/docs/`.
+
+```
+demo/
+├── cli.ts demo:pr, demo:docs and demo:traces
+├── storage.ts storage targets (hf://, file://)
+├── helpers.ts `test` with a `demo` fixture: cursor, captions, human-like timing
+├── recorder.ts screencast recording and mp4 export
+├── fixtures/ fixture MCP servers, seeded settings and demo conversations
+└── videos/ output (git-ignored)
+```
+
+## Setup
+
+### Tools
+
+| Tool | Used for |
+| ---------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| Node 22.18+ | runs `demo/cli.ts` directly (TypeScript type stripping) |
+| `npx playwright install chromium` | the browser demos record in, with its sandbox on |
+| `ffmpeg` | mp4 encoding and the GIF preview |
+| `gh`, logged in | reads the PR description and writes the `## Demo` section, so you need edit rights on the PR |
+| `hf`, logged in with a write token (`hf auth login`) | uploads to the bucket and the traces dataset |
+| `llama-server` | `build/bin/llama-server`, or one already running at `DEMO_SERVER_ORIGIN` (default `:9931`). It runs in router mode, so demos can pick any model in the local cache; scripts that send messages need at least one. |
+
+`gh` is only needed for `demo:pr`, and `hf` only with the bucket or dataset below. Without them, the video stays in `demo/videos/`.
+
+Videos are recorded through Chromium's screencast at `DEMO_SCALE` (2x by default, so 2880x1800) and 60 fps, starting once the page has settled.
+
+### Hugging Face bucket (videos, previews, scripts)
+
+The bucket must be public, since GitHub loads the preview from it:
+
+```bash
+hf buckets create <owner>/llama-ui-demos
+```
+
+Then set it in `tools/ui/.env` (see `.env.example`):
+
+```bash
+DEMO_STORAGE='hf://buckets/<owner>/llama-ui-demos'
+DEMO_STORAGE_PUBLIC_URL='https://huggingface.co/buckets/<owner>/llama-ui-demos/resolve'
+```
+
+Layout: `videos/pr/<number>/<sha>.{mp4,gif,demo.ts}`, `videos/docs/<name>.{mp4,gif}` and `scripts/docs/*.demo.ts` (the docs scripts). Without `DEMO_STORAGE_PUBLIC_URL`, videos are uploaded but not linked in the PR. `DEMO_STORAGE='file:///some/dir'` stores them in a local folder instead.
+
+### Hugging Face dataset (conversations, optional)
+
+Only needed for demos that import conversations other than the built-in ones, or to publish the built-in ones with `npm run demo:traces`. It can be private: it is read with your `hf` login.
+
+```bash
+hf repos create <owner>/llama-ui-demo-traces --repo-type dataset --private
+```
+
+```bash
+DEMO_TRACES_DATASET='<owner>/llama-ui-demo-traces'
+```
+
+### Other storage
+
+Only Hugging Face buckets and `file://` folders are supported for now. S3, R2 or similar would be one more class in `storage.ts` (`upload` and `downloadDir`) plus the matching `DEMO_STORAGE_PUBLIC_URL`.
+
+## Recording a pull request
+
+Add the script to the PR description:
+
+````md
+<details>
+<summary>Demo script</summary>
+
+```ts demo
+test('demo', async ({ demo, page }) => {
+ await demo.click(page.getByRole('button', { exact: true, name: 'Settings' }));
+ await demo.caption('What this shows');
+});
+```
+
+</details>
+````
+
+Then run:
+
+```bash
+npm run demo:pr -- 31303
+```
+
+A PR by someone else runs its code and demo script on your machine, with your `gh` and `hf` logins, so `demo:pr` refuses it until you've read its diff and demo block and pass the head commit you read: `npm run demo:pr -- 31303 --trust <sha>`. If the PR gets new commits, it refuses again.
+
+It checks out the PR head in a temporary worktree, records the script against it, uploads `videos/pr/<number>/<sha>.{mp4,gif,demo.ts}` and adds or updates a `## Demo` section in the PR description with the preview linking to the video.
+
+A large PR can show its changes in several shorter videos: each `test()` in the block is recorded as its own video (`<sha>-1.mp4`, `<sha>-2.mp4`, ...), and the `## Demo` section shows their previews in a table, each headed by its test's title: up to four portrait (phone) videos to a row, or two landscape ones. Helpers and `test.use` at the top of the block are shared by all of them.
+
+````ts
+test('Header and safe areas', async ({ demo, page }) => {
+ // ...
+});
+
+test('Sidebar gestures', async ({ demo, page }) => {
+ // ...
+});
+``` GitHub has no API for attachments, so for an inline player drag `demo/videos/pr-<number>.mp4` into the description instead.
+
+## Recording docs
+
+```bash
+npm run demo:docs
+````
+
+Records every `scripts/docs/*.demo.ts` from the storage target and uploads `videos/docs/<name>.{mp4,gif}`.
+
+## Writing a demo
+
+Scripts get `test` and `expect` from `helpers.ts` without importing them. `demo.click`, `demo.hover` and `demo.type` glide a visible cursor to the target before acting, with timing varied like a person's (seeded per demo, so re-takes match); `demo.scroll` turns the mouse wheel over a target; `demo.zoom` pinches in on a control or a point to show a detail too small to read in the frame, holds, and pinches back out (with the fingertips drawn on a touch device); `demo.caption` shows a caption at the top of the frame. Use `test.use({ settings, startPath })` to seed other settings or start on another route.
+
+## Conversations
+
+Demos that need existing chats import them with `test.use({ traces: ['long-chat'] })`, through Settings > Import/Export before the video starts. Traces use the export format (llama.app JSONL), so the Hub shows them as Llama traces:
+
+- `fixtures/traces.ts` generates the built-in ones: `long-chat` (36 tall exchanges) and `agentic-debugging` (long reasoning, shell output and code blocks).
+- Any other name is downloaded as `<name>.jsonl` from `DEMO_TRACES_DATASET`, e.g. an exported conversation uploaded there.
+
+`npm run demo:traces` writes the built-in traces to `demo/traces/` and pushes them to `DEMO_TRACES_DATASET`.
+
+## Trying a script locally
+
+To try a script locally, put it in `demo/` as `<name>.demo.ts`, importing from `./helpers.ts`, and run `npm run demo:record -- demo/<name>`. `npm run demo:codegen` opens Playwright codegen against `:5180` for a first draft.
+
+The config starts, or reuses when already running, the fixture MCP servers on `:8765`, `llama-server` on `:9931` (router mode, `--tools all`) and the UI's vite dev server.
diff --git a/tools/ui/demo/cli.ts b/tools/ui/demo/cli.ts
new file mode 100644
index 000000000..a7835013b
--- /dev/null
+++ b/tools/ui/demo/cli.ts
@@ -0,0 +1,411 @@
+/**
+ * Records demo videos for pull requests and docs.
+ *
+ * node demo/cli.ts pr <number> record the ```ts demo block from a PR description, a video per test
+ * (--trust <sha> to record someone else's PR at that head commit)
+ * node demo/cli.ts docs record every docs/*.demo.ts script in DEMO_STORAGE
+ * node demo/cli.ts traces write the built-in traces, and push them to DEMO_TRACES_DATASET
+ *
+ * Settings come from tools/ui/.env (see .env.example).
+ */
+
+import { DEMO_TRACES, generateTrace } from './fixtures/traces.ts';
+import { type DemoStorage, getDemoStorage, getPublicUrl } from './storage.ts';
+import { execFileSync } from 'node:child_process';
+import { createHash } from 'node:crypto';
+import {
+ existsSync,
+ mkdirSync,
+ readdirSync,
+ readFileSync,
+ rmSync,
+ statSync,
+ symlinkSync,
+ writeFileSync
+} from 'node:fs';
+import { createServer } from 'node:net';
+import { tmpdir } from 'node:os';
+import { basename, join, relative, resolve } from 'node:path';
+
+const DEMO_DIR = import.meta.dirname;
+const UI_DIR = resolve(DEMO_DIR, '..');
+const RUNS_DIR = join(DEMO_DIR, '.runs');
+const VIDEOS_DIR = join(DEMO_DIR, 'videos');
+const TRACES_DIR = join(DEMO_DIR, 'traces');
+const DEMO_BLOCK = /```(?:ts|typescript)[ \t]+demo[ \t]*\r?\n([\s\S]*?)```/;
+const HELPERS_IMPORT = /^import\s[^;]*from\s+['"][^'"]*helpers(?:\.ts)?['"];?\s*$/gm;
+const SECTION_START = '<!-- demo:start -->';
+const SECTION_END = '<!-- demo:end -->';
+/** GitHub's image proxy refuses larger images, so a bigger preview shows as a broken image. */
+const PREVIEW_MAX_BYTES = 5 * 1024 * 1024;
+const envFile = join(UI_DIR, '.env');
+
+if (existsSync(envFile)) process.loadEnvFile(envFile);
+
+const REPO = process.env.DEMO_GITHUB_REPO ?? 'ggml-org/llama.cpp';
+
+function run(
+ cmd: string,
+ args: string[],
+ opts: { cwd?: string; env?: NodeJS.ProcessEnv } = {}
+): string {
+ return execFileSync(cmd, args, {
+ cwd: opts.cwd ?? UI_DIR,
+ encoding: 'utf8',
+ env: opts.env
+ }).trim();
+}
+
+/** Writes a demo script into .runs/, importing the harness helpers. */
+function writeDemoScript(name: string, source: string): string {
+ const file = join(RUNS_DIR, `${name}.demo.ts`);
+ const helpers = relative(RUNS_DIR, join(DEMO_DIR, 'helpers.ts'));
+ const body = source.replace(HELPERS_IMPORT, '').trim();
+
+ mkdirSync(RUNS_DIR, { recursive: true });
+ writeFileSync(file, `import { expect, test } from './${helpers}';\n\n${body}\n`);
+
+ return file;
+}
+
+async function freePort(): Promise<number> {
+ return new Promise((done, fail) => {
+ const server = createServer();
+
+ server.once('error', fail);
+ server.listen(0, () => {
+ const { port } = server.address() as { port: number };
+
+ server.close(() => done(port));
+ });
+ });
+}
+
+/** A video recorded from one `test()` of a script. */
+interface Video {
+ mp4: string;
+ title: string;
+}
+
+/** Records the given scripts; returns a video for each of their tests, in order. */
+async function record(scripts: string[], uiDir = UI_DIR): Promise<Video[]> {
+ const port = await freePort();
+ const manifests = scripts.map((s) => join(VIDEOS_DIR, `${basename(s, '.demo.ts')}.videos.jsonl`));
+
+ for (const manifest of manifests) rmSync(manifest, { force: true });
+
+ execFileSync(
+ 'npx',
+ [
+ 'playwright',
+ 'test',
+ '--config=demo/playwright.config.ts',
+ ...scripts.map((s) => relative(UI_DIR, s))
+ ],
+ {
+ cwd: UI_DIR,
+ env: { ...process.env, DEMO_UI_DIR: uiDir, DEMO_UI_PORT: String(port) },
+ stdio: 'inherit'
+ }
+ );
+
+ // helpers.ts lists each video it encodes in the script's manifest
+ return manifests.flatMap((manifest) =>
+ readFileSync(manifest, 'utf8')
+ .trim()
+ .split('\n')
+ .map((line) => JSON.parse(line) as Video)
+ );
+}
+
+/**
+ * Animated GIF preview that GitHub renders inline, its longer side DEMO_PREVIEW_WIDTH (so a
+ * phone's portrait video is that tall), shrunk until it fits under PREVIEW_MAX_BYTES.
+ */
+function makePreview(mp4: string): string {
+ const gif = mp4.replace(/\.mp4$/, '.gif');
+
+ for (let side = Number(process.env.DEMO_PREVIEW_WIDTH ?? 800); ; side = Math.round(side * 0.8)) {
+ execFileSync('ffmpeg', [
+ '-y',
+ '-loglevel',
+ 'error',
+ '-i',
+ mp4,
+ '-vf',
+ `fps=10,scale='if(gt(iw,ih),${side},-2)':'if(gt(iw,ih),-2,${side})':flags=lanczos,split[a][b];[a]palettegen=stats_mode=diff[p];[b][p]paletteuse=dither=bayer:bayer_scale=5:diff_mode=rectangle`,
+ '-loop',
+ '0',
+ gif
+ ]);
+
+ if (statSync(gif).size <= PREVIEW_MAX_BYTES || side < 320) return gif;
+ }
+}
+
+function upload(storage: DemoStorage, files: Record<string, string>): void {
+ for (const [key, file] of Object.entries(files)) {
+ storage.upload(file, key);
+ console.log(`uploaded ${key}`);
+ }
+}
+
+/**
+ * Public URL with a content hash, so a re-recorded file at the same key gets a new
+ * URL: GitHub's image proxy caches previews by URL.
+ */
+function versionedUrl(key: string, file: string): null | string {
+ const url = getPublicUrl(key);
+ const version = createHash('sha1').update(readFileSync(file)).digest('hex').slice(0, 10);
+
+ return url && `${url}?v=${version}`;
+}
+
+/** Whether a video is taller than wide, like a phone's. */
+function isPortrait(mp4: string): boolean {
+ const [width, height] = run('ffprobe', [
+ '-v',
+ 'error',
+ '-select_streams',
+ 'v:0',
+ '-show_entries',
+ 'stream=width,height',
+ '-of',
+ 'csv=p=0',
+ mp4
+ ])
+ .split(',')
+ .map(Number);
+
+ return height > width;
+}
+
+/**
+ * One preview, or a table of them when the script has several tests, each headed by its
+ * test's title: up to four portrait videos to a row, or two landscape ones, so each stays
+ * large enough to read.
+ */
+function demoSection(
+ links: { gif: string; mp4: string; title: string }[],
+ sha: string,
+ columns: number
+): string {
+ const preview = ({ gif, mp4, title }: (typeof links)[number]) =>
+ `[![${title.replace(/[[\]|]/g, '')}](${gif})](${mp4})`;
+ const cell = (text: string) => text.replace(/\|/g, '\\|');
+ const rows = Array.from({ length: Math.ceil(links.length / columns) }, (_, i) =>
+ links.slice(i * columns, (i + 1) * columns)
+ );
+ const pad = (cells: string[]) => [...cells, ...Array(columns - cells.length).fill(' ')];
+ const body =
+ links.length === 1
+ ? [preview({ ...links[0], title: 'Demo' })]
+ : rows.flatMap((row, i) => [
+ // a table has one header row, so later rows title their videos in bold
+ `| ${pad(row.map((l) => (i === 0 ? cell(l.title) : `**${cell(l.title)}**`))).join(' | ')} |`,
+ ...(i === 0 ? [`|${Array(columns).fill('---').join('|')}|`] : []),
+ `| ${pad(row.map(preview)).join(' | ')} |`
+ ]);
+ const note =
+ links.length === 1 ? 'the preview for the full video' : 'a preview for its full video';
+
+ return [
+ SECTION_START,
+ ...body,
+ '',
+ `Recorded at ${sha.slice(0, 10)}; click ${note}.`,
+ SECTION_END
+ ].join('\n');
+}
+
+/** Replaces the managed demo section, or adds a `## Demo` section before the details. */
+function withDemoSection(body: string, section: string): string {
+ const start = body.indexOf(SECTION_START);
+ const end = body.indexOf(SECTION_END);
+
+ if (start !== -1 && end > start) {
+ return body.slice(0, start) + section + body.slice(end + SECTION_END.length);
+ }
+
+ const block = `## Demo\n\n${section}\n\n`;
+ const anchor = body.search(/^## (Additional information|Requirements)\b/m);
+
+ return anchor === -1
+ ? `${body.trimEnd()}\n\n${block}`
+ : body.slice(0, anchor) + block + body.slice(anchor);
+}
+
+async function recordPr(number: string, trustedSha?: string): Promise<void> {
+ const pr = JSON.parse(
+ run('gh', ['pr', 'view', number, '--repo', REPO, '--json', 'author,body,headRefOid'])
+ ) as { author: { login: string }; body: string; headRefOid: string };
+ const source = DEMO_BLOCK.exec(pr.body)?.[1];
+
+ if (!source) throw new Error(`PR #${number} has no \`\`\`ts demo block in its description`);
+
+ const sha = pr.headRefOid;
+
+ // the PR's code and demo script run on this machine, with the gh and hf logins,
+ // so someone else's PR only runs at a head commit you've read and passed to --trust
+ if (pr.author.login !== run('gh', ['api', 'user', '--jq', '.login'])) {
+ if (!trustedSha) {
+ throw new Error(
+ `PR #${number} is by ${pr.author.login}, and recording it runs its code here: ` +
+ `read its diff and demo block, then run again with --trust ${sha.slice(0, 10)}`
+ );
+ }
+
+ if (trustedSha.length < 7 || !sha.startsWith(trustedSha)) {
+ throw new Error(`PR #${number} head is ${sha.slice(0, 10)}, not the trusted ${trustedSha}`);
+ }
+ }
+
+ const worktree = join(tmpdir(), `llama-ui-demo-pr-${number}`);
+
+ run('git', ['fetch', '--no-tags', `https://github.com/${REPO}.git`, `pull/${number}/head`]);
+
+ if (run('git', ['rev-parse', 'FETCH_HEAD']) !== sha) {
+ throw new Error(`PR #${number} head moved while fetching, run again`);
+ }
+
+ rmSync(worktree, { force: true, recursive: true });
+ run('git', ['worktree', 'prune']);
+ run('git', ['worktree', 'add', '--detach', worktree, sha]);
+ symlinkSync(join(UI_DIR, 'node_modules'), join(worktree, 'tools/ui/node_modules'));
+
+ const name = `pr-${number}`;
+ const script = writeDemoScript(name, source);
+
+ try {
+ const videos = (await record([script], join(worktree, 'tools/ui'))).map((video) => ({
+ ...video,
+ gif: makePreview(video.mp4)
+ }));
+ const storage = getDemoStorage();
+
+ for (const { gif, mp4 } of videos) console.log(`\nvideo: ${mp4}\npreview: ${gif}`);
+
+ if (!storage) {
+ console.log('DEMO_STORAGE is not set: drag the mp4 into the PR description.');
+
+ return;
+ }
+
+ const prefix = `videos/pr/${number}/${sha.slice(0, 10)}`;
+ // a script with several tests gets <sha>-1.mp4, <sha>-2.mp4 and so on
+ const keys = videos.map((_, i) => (videos.length === 1 ? prefix : `${prefix}-${i + 1}`));
+
+ upload(storage, { [`${prefix}.demo.ts`]: script });
+ videos.forEach(({ gif, mp4 }, i) => {
+ upload(storage, { [`${keys[i]}.gif`]: gif, [`${keys[i]}.mp4`]: mp4 });
+ });
+
+ if (!getPublicUrl(prefix)) {
+ console.log('DEMO_STORAGE_PUBLIC_URL is not set: not linking the video in the PR.');
+
+ return;
+ }
+
+ const links = videos.map(({ gif, mp4, title }, i) => ({
+ gif: versionedUrl(`${keys[i]}.gif`, gif) ?? '',
+ mp4: versionedUrl(`${keys[i]}.mp4`, mp4) ?? '',
+ title
+ }));
+
+ execFileSync('gh', ['pr', 'edit', number, '--repo', REPO, '--body-file', '-'], {
+ cwd: UI_DIR,
+ input: withDemoSection(
+ pr.body,
+ demoSection(links, sha, Math.min(links.length, isPortrait(videos[0].mp4) ? 4 : 2))
+ ),
+ stdio: ['pipe', 'inherit', 'inherit']
+ });
+ console.log(`linked the demo in https://github.com/${REPO}/pull/${number}`);
+ } finally {
+ rmSync(script, { force: true });
+ run('git', ['worktree', 'remove', '--force', worktree]);
+ }
+}
+
+async function recordDocs(): Promise<void> {
+ const storage = getDemoStorage();
+
+ if (!storage) throw new Error('DEMO_STORAGE is not set: docs demo scripts are read from it');
+
+ const downloaded = join(RUNS_DIR, 'docs-source');
+
+ rmSync(downloaded, { force: true, recursive: true });
+
+ if (!storage.downloadDir('scripts/docs', downloaded)) {
+ throw new Error('no scripts/docs/*.demo.ts in DEMO_STORAGE');
+ }
+
+ const scripts = readdirSync(downloaded)
+ .filter((f) => f.endsWith('.demo.ts'))
+ .map((f) =>
+ writeDemoScript(basename(f, '.demo.ts'), readFileSync(join(downloaded, f), 'utf8'))
+ );
+
+ try {
+ const videos = await record(scripts);
+
+ for (const { mp4 } of videos) {
+ const name = basename(mp4, '.mp4');
+
+ upload(storage, {
+ [`videos/docs/${name}.gif`]: makePreview(mp4),
+ [`videos/docs/${name}.mp4`]: mp4
+ });
+ }
+ } finally {
+ rmSync(RUNS_DIR, { force: true, recursive: true });
+ }
+}
+
+/** Writes the built-in traces to demo/traces/ and uploads them to the traces dataset. */
+function writeTraces(): void {
+ rmSync(TRACES_DIR, { force: true, recursive: true });
+ mkdirSync(TRACES_DIR, { recursive: true });
+
+ for (const name of Object.keys(DEMO_TRACES)) {
+ writeFileSync(join(TRACES_DIR, `${name}.jsonl`), `${generateTrace(name)}\n`);
+ console.log(`wrote demo/traces/${name}.jsonl`);
+ }
+
+ const dataset = process.env.DEMO_TRACES_DATASET;
+
+ if (!dataset) {
+ console.log('DEMO_TRACES_DATASET is not set: not uploading.');
+
+ return;
+ }
+
+ execFileSync(
+ 'hf',
+ [
+ 'upload',
+ dataset,
+ TRACES_DIR,
+ '.',
+ '--repo-type',
+ 'dataset',
+ '--commit-message',
+ 'Update demo traces'
+ ],
+ { stdio: 'inherit' }
+ );
+}
+
+const [command, arg, ...flags] = process.argv.slice(2);
+const trustIndex = flags.indexOf('--trust');
+
+if (command === 'pr' && arg) {
+ await recordPr(arg, trustIndex === -1 ? undefined : flags[trustIndex + 1]);
+} else if (command === 'docs') {
+ await recordDocs();
+} else if (command === 'traces') {
+ writeTraces();
+} else {
+ console.error('usage: node demo/cli.ts pr <number> [--trust <sha>] | docs | traces');
+ process.exit(1);
+}
diff --git a/tools/ui/demo/fixtures/demo-config.ts b/tools/ui/demo/fixtures/demo-config.ts
new file mode 100644
index 000000000..d4a272f6c
--- /dev/null
+++ b/tools/ui/demo/fixtures/demo-config.ts
@@ -0,0 +1,49 @@
+/** Shared fixture data for demo recordings (used by both Node and the browser seed). */
+
+export const DEMO_MCP_PORT = 8765;
+
+export interface DemoMcpServer {
+ id: string;
+ name: string;
+ title: string;
+ tools: { name: string; description: string; result: string }[];
+}
+
+export const DEMO_MCP_SERVERS: DemoMcpServer[] = [
+ {
+ id: 'weather',
+ name: 'weather',
+ title: 'Weather',
+ tools: [
+ {
+ description: 'Get the weather forecast for a city',
+ name: 'get_forecast',
+ result: 'Sunny, 22°C'
+ },
+ {
+ description: 'List active weather alerts for a region',
+ name: 'get_alerts',
+ result: 'No active alerts'
+ }
+ ]
+ },
+ {
+ id: 'notes',
+ name: 'notes',
+ title: 'Notes',
+ tools: [
+ { description: 'Search notes by keyword', name: 'search_notes', result: 'No notes found' },
+ { description: 'Create a new note', name: 'create_note', result: 'Note created' },
+ { description: 'List recent notes', name: 'list_notes', result: 'No notes yet' }
+ ]
+ }
+];
+
+/** `mcpServers` setting value pointing the UI at the fixture servers. */
+export function demoMcpServerSettings(): object[] {
+ return DEMO_MCP_SERVERS.map((server) => ({
+ enabled: true,
+ id: `demo-${server.id}`,
+ url: `http://localhost:${DEMO_MCP_PORT}/${server.id}/mcp`
+ }));
+}
diff --git a/tools/ui/demo/fixtures/mcp-servers.ts b/tools/ui/demo/fixtures/mcp-servers.ts
new file mode 100644
index 000000000..15529e4a3
--- /dev/null
+++ b/tools/ui/demo/fixtures/mcp-servers.ts
@@ -0,0 +1,91 @@
+/**
+ * Fixture MCP servers for demo recordings.
+ *
+ * Serves a few stateless Streamable HTTP MCP servers with canned tools, so
+ * demos show MCP groups without depending on real third-party servers.
+ *
+ * Usage: node demo/fixtures/mcp-servers.ts
+ */
+
+import { DEMO_MCP_PORT, DEMO_MCP_SERVERS, type DemoMcpServer } from './demo-config.ts';
+import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
+import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
+import { createServer, type IncomingMessage, type ServerResponse } from 'node:http';
+import { z } from 'zod';
+
+function buildServer(fixture: DemoMcpServer): McpServer {
+ const server = new McpServer({ name: fixture.name, title: fixture.title, version: '1.0.0' });
+
+ for (const tool of fixture.tools) {
+ server.registerTool(
+ tool.name,
+ { description: tool.description, inputSchema: { query: z.string().optional() } },
+ async () => ({ content: [{ text: tool.result, type: 'text' }] })
+ );
+ }
+
+ return server;
+}
+
+function setCorsHeaders(res: ServerResponse): void {
+ res.setHeader('Access-Control-Allow-Origin', '*');
+ res.setHeader('Access-Control-Allow-Headers', '*');
+ res.setHeader('Access-Control-Allow-Methods', 'GET, POST, DELETE, OPTIONS');
+ res.setHeader('Access-Control-Expose-Headers', 'mcp-session-id, mcp-protocol-version');
+ // the UI runs with COEP require-corp
+ res.setHeader('Cross-Origin-Resource-Policy', 'cross-origin');
+}
+
+async function readBody(req: IncomingMessage): Promise<unknown> {
+ const chunks: Buffer[] = [];
+
+ for await (const chunk of req) chunks.push(chunk as Buffer);
+
+ const raw = Buffer.concat(chunks).toString();
+
+ return raw ? JSON.parse(raw) : undefined;
+}
+
+const httpServer = createServer(async (req, res) => {
+ setCorsHeaders(res);
+
+ if (req.method === 'OPTIONS') {
+ res.writeHead(204).end();
+
+ return;
+ }
+
+ const path = new URL(req.url ?? '/', 'http://localhost').pathname;
+ const fixture = DEMO_MCP_SERVERS.find((s) => path === `/${s.id}/mcp`);
+
+ if (!fixture) {
+ res.writeHead(path === '/health' ? 200 : 404).end();
+
+ return;
+ }
+
+ if (req.method !== 'POST') {
+ // stateless: no standalone SSE stream or session teardown
+ res.writeHead(405).end();
+
+ return;
+ }
+
+ const server = buildServer(fixture);
+ const transport = new StreamableHTTPServerTransport({
+ enableJsonResponse: true,
+ sessionIdGenerator: undefined
+ });
+
+ res.on('close', () => {
+ transport.close();
+ server.close();
+ });
+
+ await server.connect(transport);
+ await transport.handleRequest(req, res, await readBody(req));
+});
+
+httpServer.listen(DEMO_MCP_PORT, () => {
+ console.log(`demo MCP servers listening on http://localhost:${DEMO_MCP_PORT}`);
+});
diff --git a/tools/ui/demo/fixtures/traces.ts b/tools/ui/demo/fixtures/traces.ts
new file mode 100644
index 000000000..cc7ed8979
--- /dev/null
+++ b/tools/ui/demo/fixtures/traces.ts
@@ -0,0 +1,312 @@
+/**
+ * Synthetic conversations for demos, in the UI's export format (llama.app JSONL
+ * traces): a `session` record followed by one `message` record per message.
+ * Deterministic, so a trace and the demos recorded from it don't drift. Messages
+ * carry no model, so the UI doesn't flag one the server doesn't have.
+ */
+
+import { createHash } from 'node:crypto';
+
+const HARNESS = 'llama.app';
+const START = Date.UTC(2026, 9, 1, 9, 0, 0);
+
+interface ToolCall {
+ function: { arguments: string; name: string };
+ id: string;
+ type: 'function';
+}
+
+interface TraceMessage {
+ children: string[];
+ content: string;
+ convId: string;
+ id: string;
+ parent: null | string;
+ reasoningContent?: string;
+ role: 'assistant' | 'system' | 'tool' | 'user';
+ timestamp: number;
+ toolCallId?: string;
+ toolCalls?: ToolCall[];
+ type: 'root' | 'text';
+}
+
+/** Builds one linear conversation, message by message. */
+class Trace {
+ private calls = 0;
+ private readonly id: string;
+ private readonly messages: TraceMessage[] = [];
+ private readonly name: string;
+
+ assistant(content: string, reasoning?: string): this {
+ return this.push({ content, reasoningContent: reasoning, role: 'assistant' });
+ }
+
+ constructor(name: string) {
+ this.name = name;
+ this.id = uuid(name, 'conv');
+ this.push({ content: '', role: 'system', type: 'root' });
+ }
+
+ toJsonl(): string {
+ const last = this.messages.at(-1)!;
+ const session = {
+ currNode: last.id,
+ harness: HARNESS,
+ id: this.id,
+ lastModified: last.timestamp,
+ name: this.name,
+ type: 'session'
+ };
+
+ return [session, ...this.messages.map((message) => ({ message, type: 'message' }))]
+ .map((record) => JSON.stringify(record))
+ .join('\n');
+ }
+
+ /** An assistant turn calling one tool, followed by the tool's result. */
+ tool(name: string, args: Record<string, unknown>, result: string, reasoning?: string): this {
+ const id = uuid(this.name, `call-${this.calls++}`).replaceAll('-', '');
+
+ this.push({
+ content: '',
+ reasoningContent: reasoning,
+ role: 'assistant',
+ toolCalls: [{ function: { arguments: JSON.stringify(args), name }, id, type: 'function' }]
+ });
+
+ return this.push({ content: result, role: 'tool', toolCallId: id });
+ }
+
+ user(content: string): this {
+ return this.push({ content, role: 'user' });
+ }
+
+ private push(message: Partial<TraceMessage> & Pick<TraceMessage, 'content' | 'role'>): this {
+ const parent = this.messages.at(-1);
+ const index = this.messages.length;
+ const entry: TraceMessage = {
+ children: [],
+ convId: this.id,
+ id: uuid(this.name, `msg-${index}`),
+ parent: parent?.id ?? null,
+ timestamp: START + index * 20_000,
+ type: 'text',
+ ...message
+ };
+
+ parent?.children.push(entry.id);
+ this.messages.push(entry);
+
+ return this;
+ }
+}
+
+function uuid(...parts: string[]): string {
+ const hex = createHash('sha1').update(parts.join(':')).digest('hex');
+
+ return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-4${hex.slice(13, 16)}-a${hex.slice(17, 20)}-${hex.slice(20, 32)}`;
+}
+
+const STEPS = [
+ ['set up the project', 'cargo new forecast && cd forecast', 'Cargo.toml', 'toml'],
+ ['parse command line arguments', 'cargo add clap --features derive', 'src/cli.rs', 'rust'],
+ ['call the weather API', 'cargo add reqwest --features json,blocking', 'src/api.rs', 'rust'],
+ ['deserialize the response', 'cargo add serde --features derive', 'src/model.rs', 'rust'],
+ ['cache responses on disk', 'cargo add directories', 'src/cache.rs', 'rust'],
+ ['print a table', 'cargo add comfy-table', 'src/table.rs', 'rust'],
+ ['handle errors', 'cargo add anyhow', 'src/main.rs', 'rust'],
+ ['add colors', 'cargo add owo-colors', 'src/style.rs', 'rust'],
+ ['read a config file', 'cargo add toml', 'src/config.rs', 'rust'],
+ ['write tests', 'cargo add --dev assert_cmd predicates', 'tests/cli.rs', 'rust'],
+ ['set up CI', 'mkdir -p .github/workflows', '.github/workflows/ci.yml', 'yaml'],
+ ['publish a release', 'cargo install cargo-dist', 'dist-workspace.toml', 'toml']
+] as const;
+
+function codeFor(step: number, file: string, lang: string): string {
+ const lines =
+ lang === 'rust'
+ ? [
+ `// ${file}`,
+ 'use anyhow::{Context, Result};',
+ '',
+ `pub fn step_${step}(input: &str) -> Result<Vec<String>> {`,
+ ' let mut out = Vec::new();',
+ ' for (i, line) in input.lines().enumerate() {',
+ ' let trimmed = line.trim();',
+ ' if trimmed.is_empty() {',
+ ' continue;',
+ ' }',
+ ' out.push(format!("{i:>3}: {trimmed}"));',
+ ' }',
+ ` Ok(out).context("step ${step} failed")`,
+ '}'
+ ]
+ : lang === 'yaml'
+ ? [
+ `# ${file}`,
+ 'on: [push, pull_request]',
+ 'jobs:',
+ ' test:',
+ ' runs-on: ubuntu-latest'
+ ]
+ : [`# ${file}`, `name = "forecast"`, `version = "0.${step}.0"`, 'edition = "2024"'];
+
+ return `\`\`\`${lang}\n${lines.join('\n')}\n\`\`\``;
+}
+
+/** A long chat with tall messages, for scrolling and landing demos. */
+function longChat(): Trace {
+ const trace = new Trace('Building a weather CLI in Rust');
+
+ for (let i = 0; i < 36; i++) {
+ const [goal, command, file, lang] = STEPS[i % STEPS.length];
+ const round = Math.floor(i / STEPS.length);
+ const ask =
+ round === 0 ? `How do I ${goal}?` : `Can we revisit how we ${goal}? (pass ${round + 1})`;
+
+ trace.user(ask);
+ trace.assistant(
+ [
+ `To ${goal}, start from a clean working tree so the change stays easy to review.`,
+ '',
+ '```sh',
+ command,
+ '```',
+ '',
+ `Then add \`${file}\`:`,
+ '',
+ codeFor(i + 1, file, lang),
+ '',
+ 'A few things to keep in mind:',
+ '',
+ `- Keep \`${file}\` focused on one job, so the next step only touches what it needs.`,
+ '- Run `cargo clippy` after each step; it catches most mistakes early.',
+ '- Commit once it builds, so you can always go back to a working version.',
+ '',
+ i % 3 === 0
+ ? `Next we can look at how to ${STEPS[(i + 1) % STEPS.length][0]}.`
+ : 'Let me know if anything here does not build.'
+ ].join('\n'),
+ `The user wants to ${goal}. Give the command, the file and a short checklist.`
+ );
+ }
+
+ return trace;
+}
+
+function testLog(failing: boolean): string {
+ const lines = ['running 48 tests'];
+
+ for (let i = 1; i <= 48; i++) {
+ const ok = !failing || (i !== 17 && i !== 31);
+
+ lines.push(
+ `test cache::tests::entry_${String(i).padStart(2, '0')} ... ${ok ? 'ok' : 'FAILED'}`
+ );
+ }
+
+ if (failing) {
+ lines.push(
+ '',
+ 'failures:',
+ '',
+ '---- cache::tests::entry_17 stdout ----',
+ "thread 'cache::tests::entry_17' panicked at src/cache.rs:88:9:",
+ 'assertion `left == right` failed: entry expired too early',
+ ' left: Some(1759312800)',
+ ' right: Some(1759316400)',
+ '',
+ '---- cache::tests::entry_31 stdout ----',
+ "thread 'cache::tests::entry_31' panicked at src/cache.rs:88:9:",
+ 'assertion `left == right` failed: entry expired too early',
+ '',
+ 'test result: FAILED. 46 passed; 2 failed; 0 ignored; 0 measured; 0 filtered out'
+ );
+ } else {
+ lines.push('', 'test result: ok. 48 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out');
+ }
+
+ return `${lines.join('\n')}\n\n[exit code: ${failing ? 101 : 0}]`;
+}
+
+function reasoning(topic: string, paragraphs: number): string {
+ const out: string[] = [];
+
+ for (let i = 0; i < paragraphs; i++) {
+ out.push(
+ `${i === 0 ? `Looking at ${topic}.` : 'Another angle:'} The cache stores an expiry timestamp per entry, ` +
+ 'and the failing tests are the ones created right at an hour boundary. If the expiry is computed ' +
+ 'from local time while the check uses UTC, entries created near the boundary would expire an hour ' +
+ `early. Step ${i + 1}: confirm by reading how \`expires_at\` is built, then compare with \`is_fresh\`.`
+ );
+ }
+
+ return out.join('\n\n');
+}
+
+/** An agentic session with long reasoning, shell output and code, for nested scroller demos. */
+function agenticDebugging(): Trace {
+ const trace = new Trace('Fix the flaky cache tests');
+ const fixed = Array.from({ length: 60 }, (_, i) =>
+ i === 0
+ ? 'use chrono::{DateTime, Duration, Utc};'
+ : i === 30
+ ? ' let expires_at = Utc::now() + self.ttl; // was Local::now()'
+ : ` // cache.rs line ${i + 1}: entry handling stays the same`
+ ).join('\n');
+
+ trace.user('The cache tests fail on CI every few runs. Can you find out why and fix it?');
+
+ for (let round = 0; round < 3; round++) {
+ trace.tool(
+ 'exec_shell_command',
+ { command: 'cargo test cache -- --test-threads=1' },
+ testLog(true),
+ reasoning('the failing cache tests', 4 + round)
+ );
+ trace.tool(
+ 'exec_shell_command',
+ { command: "grep -n 'expires_at\\|is_fresh' src/cache.rs" },
+ Array.from(
+ { length: 30 },
+ (_, i) => `${80 + i}: let expires_at = Local::now() + self.ttl; // entry ${i}`
+ ).join('\n') + '\n\n[exit code: 0]',
+ reasoning('where the expiry is computed', 3)
+ );
+ trace.assistant(
+ [
+ `Round ${round + 1}: the expiry uses local time while the freshness check uses UTC.`,
+ '',
+ '```rust',
+ fixed,
+ '```',
+ '',
+ 'I switched the expiry to `Utc::now()` so both sides use the same clock.'
+ ].join('\n'),
+ reasoning('the fix', 5)
+ );
+ trace.user(round < 2 ? 'Still flaky on CI, can you check again?' : 'Run the tests once more.');
+ }
+
+ trace.tool(
+ 'exec_shell_command',
+ { command: 'cargo test cache' },
+ testLog(false),
+ reasoning('the final test run', 2)
+ );
+ trace.assistant(
+ 'All 48 cache tests pass now, including the two that failed at the hour boundary.'
+ );
+
+ return trace;
+}
+
+/** Built-in traces, by name. */
+export const DEMO_TRACES: Record<string, () => Trace> = {
+ 'agentic-debugging': agenticDebugging,
+ 'long-chat': longChat
+};
+
+export function generateTrace(name: string): null | string {
+ return DEMO_TRACES[name]?.().toJsonl() ?? null;
+}
diff --git a/tools/ui/demo/helpers.ts b/tools/ui/demo/helpers.ts
new file mode 100644
index 000000000..c82b27069
--- /dev/null
+++ b/tools/ui/demo/helpers.ts
@@ -0,0 +1,500 @@
+import { demoMcpServerSettings } from './fixtures/demo-config.ts';
+import { generateTrace } from './fixtures/traces.ts';
+import { ScreencastRecorder } from './recorder.ts';
+import { type CDPSession, type Locator, type Page, test as base } from '@playwright/test';
+import { execFileSync } from 'node:child_process';
+import { appendFileSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
+import { basename, join, resolve } from 'node:path';
+
+const VIDEOS_DIR = resolve(import.meta.dirname, 'videos');
+/** How long a demo waits for the app to be ready before it starts recording. */
+const APP_READY_TIMEOUT_MS = 90000;
+/** How far below the viewport's top a pinch has to start for Chromium to zoom at all. */
+const ZOOM_TOP_INSET_PX = 64;
+
+/**
+ * Draws a fake cursor and click ripples, since recorded videos don't include
+ * the OS pointer, and a fingertip per touch, so a pinch shows as two fingers.
+ * Runs in the page via addInitScript.
+ */
+function installCursorOverlay(): void {
+ const mount = () => {
+ const style = document.createElement('style');
+
+ style.textContent = `
+ #demo-cursor { position: fixed; top: 0; left: 0; z-index: 2147483647; pointer-events: none;
+ width: 22px; height: 22px; margin: -3px 0 0 -3px; transition: transform 80ms ease-out; }
+ #demo-cursor.down { transform: scale(0.85); }
+ .demo-ripple { position: fixed; z-index: 2147483646; pointer-events: none; width: 36px; height: 36px;
+ margin: -18px 0 0 -18px; border-radius: 50%; background: rgba(59, 130, 246, 0.35);
+ animation: demo-ripple 450ms ease-out forwards; }
+ @keyframes demo-ripple { from { transform: scale(0.3); opacity: 1; } to { transform: scale(1.4); opacity: 0; } }
+ #demo-caption { position: fixed; left: 50%; top: 72px; z-index: 2147483647; pointer-events: none;
+ transform: translate(-50%, -8px); opacity: 0; transition: opacity 200ms ease, transform 200ms ease;
+ max-width: 80%; padding: 10px 18px; border-radius: 10px; background: rgba(17, 17, 17, 0.88);
+ color: #fff; font: 500 18px/1.4 system-ui, sans-serif; text-align: center; }
+ #demo-caption.visible { opacity: 1; transform: translate(-50%, 0); }
+ .demo-finger { position: fixed; z-index: 2147483646; pointer-events: none; width: 40px; height: 40px;
+ margin: -20px 0 0 -20px; border-radius: 50%; background: rgba(59, 130, 246, 0.3);
+ border: 2px solid rgba(59, 130, 246, 0.55); transition: opacity 200ms ease; }
+ `;
+ document.head.appendChild(style);
+
+ const cursor = document.createElement('div');
+
+ cursor.id = 'demo-cursor';
+ cursor.innerHTML = `<svg viewBox="0 0 24 24" width="22" height="22"><path d="M4 2l16 10.5-7 1.5-3.5 6.5z"
+ fill="#111" stroke="#fff" stroke-width="1.5" stroke-linejoin="round"/></svg>`;
+ document.documentElement.appendChild(cursor);
+
+ const caption = document.createElement('div');
+
+ caption.id = 'demo-caption';
+ document.documentElement.appendChild(caption);
+
+ document.addEventListener(
+ 'mousemove',
+ (e) => (cursor.style.translate = `${e.clientX}px ${e.clientY}px`),
+ true
+ );
+ document.addEventListener(
+ 'mousedown',
+ (e) => {
+ cursor.classList.add('down');
+ const ripple = document.createElement('div');
+
+ ripple.className = 'demo-ripple';
+ ripple.style.left = `${e.clientX}px`;
+ ripple.style.top = `${e.clientY}px`;
+ document.documentElement.appendChild(ripple);
+ setTimeout(() => ripple.remove(), 500);
+ },
+ true
+ );
+ document.addEventListener('mouseup', () => cursor.classList.remove('down'), true);
+
+ const fingers: HTMLDivElement[] = [];
+ const placeFingers = (e: TouchEvent) => {
+ Array.from(e.touches).forEach((touch, i) => {
+ const finger = (fingers[i] ??= Object.assign(document.createElement('div'), {
+ className: 'demo-finger'
+ }));
+
+ finger.style.left = `${touch.clientX}px`;
+ finger.style.top = `${touch.clientY}px`;
+ finger.style.opacity = '1';
+ document.documentElement.appendChild(finger);
+ });
+ fingers.slice(e.touches.length).forEach((finger) => (finger.style.opacity = '0'));
+ };
+
+ for (const type of ['touchstart', 'touchmove', 'touchend', 'touchcancel']) {
+ document.addEventListener(type, placeFingers as EventListener, {
+ capture: true,
+ passive: true
+ });
+ }
+ };
+
+ if (document.readyState === 'loading') {
+ document.addEventListener('DOMContentLoaded', mount);
+ } else {
+ mount();
+ }
+}
+
+/** Seeds UI settings once per demo, so later reloads keep what the demo changed. */
+function seedSettings(config: Record<string, unknown>): void {
+ const key = 'LlamaUi.config';
+
+ if (localStorage.getItem(key) === null) {
+ localStorage.setItem(key, JSON.stringify(config));
+ }
+}
+
+/** Seeded randomness, so a demo moves the same way on every recording. */
+function createRandom(seed: string): () => number {
+ let state = [...seed].reduce((h, c) => Math.imul(h ^ c.charCodeAt(0), 2654435761), 1);
+
+ return () => {
+ state = (state + 0x6d2b79f5) | 0;
+
+ let t = Math.imul(state ^ (state >>> 15), 1 | state);
+
+ t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
+
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
+ };
+}
+
+const easeInOut = (t: number) => (t < 0.5 ? 4 * t * t * t : 1 - (-2 * t + 2) ** 3 / 2);
+
+/**
+ * Drives the page like a person: the cursor glides along a slight arc with
+ * Fitts-like durations, settles briefly before pressing, and typing and pauses
+ * vary a little.
+ */
+export class Demo {
+ private cdp: CDPSession | undefined;
+ private readonly page: Page;
+ private readonly random: () => number;
+ private x = 0;
+ private y = 0;
+
+ /** Shows a caption at the top of the frame for `ms`, clear of the chat form. */
+ async caption(text: string, ms = 2200): Promise<void> {
+ await this.page.evaluate((t) => {
+ const el = document.getElementById('demo-caption');
+
+ if (!el) return;
+
+ el.textContent = t;
+ el.classList.add('visible');
+ }, text);
+ await this.page.waitForTimeout(ms);
+ await this.page.evaluate(() =>
+ document.getElementById('demo-caption')?.classList.remove('visible')
+ );
+ await this.page.waitForTimeout(250);
+ }
+
+ async click(target: Locator, ms = 400): Promise<void> {
+ await this.moveTo(target);
+ await this.page.waitForTimeout(this.between(80, 160));
+ await this.page.mouse.down();
+ await this.page.waitForTimeout(this.between(50, 90));
+ await this.page.mouse.up();
+ await this.pause(ms);
+ }
+
+ constructor(page: Page, seed: string) {
+ this.page = page;
+ this.random = createRandom(seed);
+
+ const viewport = page.viewportSize();
+
+ this.x = (viewport?.width ?? 0) / 2;
+ this.y = (viewport?.height ?? 0) / 2;
+ }
+
+ async hover(target: Locator, ms = 350): Promise<void> {
+ await this.moveTo(target);
+ await this.pause(ms);
+ }
+
+ /** Glides the cursor to a point near the center of `target`. */
+ async moveTo(target: Locator): Promise<void> {
+ await target.scrollIntoViewIfNeeded();
+ const box = await target.boundingBox();
+
+ if (!box) throw new Error(`demo: ${target} has no bounding box`);
+
+ const x = box.x + box.width * this.between(0.4, 0.6);
+ const y = box.y + box.height * this.between(0.4, 0.6);
+ const dx = x - this.x;
+ const dy = y - this.y;
+ const distance = Math.hypot(dx, dy);
+ const duration = 110 + 55 * Math.log2(distance / 40 + 1);
+ // bow the path a little to one side, like a wrist turning
+ const bow = distance * this.between(-0.08, 0.08);
+ const fromX = this.x;
+ const fromY = this.y;
+ const started = Date.now();
+
+ for (;;) {
+ const t = Math.min((Date.now() - started) / duration, 1);
+ const p = easeInOut(t);
+ const arc = Math.sin(Math.PI * p) * bow;
+
+ this.x = fromX + dx * p - (dy / (distance || 1)) * arc;
+ this.y = fromY + dy * p + (dx / (distance || 1)) * arc;
+ await this.page.mouse.move(this.x, this.y);
+
+ if (t === 1) break;
+
+ await this.page.waitForTimeout(8);
+ }
+ }
+
+ /** Waits about `ms`, varied by up to 15% so pauses don't feel metronomic. */
+ async pause(ms = 800): Promise<void> {
+ await this.page.waitForTimeout(ms * this.between(0.85, 1.15));
+ }
+
+ async press(key: string, ms = 600): Promise<void> {
+ await this.page.keyboard.press(key);
+ await this.pause(ms);
+ }
+
+ /** Scrolls the wheel over `target` by `dy` pixels (negative scrolls up), in small steps like a trackpad. */
+ async scroll(target: Locator, dy: number, ms = 500): Promise<void> {
+ await this.moveTo(target);
+
+ const step = Math.sign(dy) * 40;
+
+ for (let done = 0; Math.abs(done) < Math.abs(dy); done += step) {
+ await this.page.mouse.wheel(0, step);
+ await this.page.waitForTimeout(16);
+ }
+
+ await this.pause(ms);
+ }
+
+ /** Types quickly with uneven key timing, a little slower after spaces and punctuation. */
+ async type(target: Locator, text: string, ms = 500): Promise<void> {
+ await this.click(target, 200);
+
+ for (const char of text) {
+ await this.page.keyboard.type(char);
+ await this.page.waitForTimeout(
+ this.between(15, 45) + (/[\s.,!?]/.test(char) ? this.between(20, 60) : 0)
+ );
+ }
+
+ await this.pause(ms);
+ }
+
+ /**
+ * Pinches in on `target`, a control or a point in the viewport, holds about `ms`, and pinches
+ * back out to 1x: a two-finger zoom onto a detail too small to read in the frame. On a touch
+ * device the fingertips show.
+ */
+ async zoom(target: Locator | { x: number; y: number }, scale = 2.5, ms = 1400): Promise<void> {
+ const point = 'x' in target ? target : await this.center(target);
+ // a pinch anchored in the viewport's top band does not zoom at all
+ const at = { x: point.x, y: Math.max(point.y, ZOOM_TOP_INSET_PX) };
+ const viewport = this.page.viewportSize() ?? { height: 0, width: 0 };
+ const middle = { x: viewport.width / 2, y: viewport.height / 2 };
+
+ await this.pinch(at, scale, 1400);
+
+ // One near the bottom edge overshoots its ratio, and a pinch there again misses too. A small
+ // pinch from the middle evens it out, drawing the detail toward the middle, so it stays in view
+ const reached = await this.pageScale();
+
+ if (Math.abs(reached / scale - 1) > 0.03) await this.pinch(middle, scale / reached, 1400);
+
+ await this.liftFingers();
+ await this.pause(ms);
+
+ // back to exactly 1x, which the next gesture needs, in one quick pinch from the middle of
+ // the viewport, and another for what is left when one lands short
+ for (let tries = 0; ; tries++) {
+ const current = await this.pageScale();
+
+ if (current <= 1.001) break;
+
+ if (tries === 4) throw new Error(`demo: zoom stuck at ${current.toFixed(3)}x`);
+
+ await this.pinch(middle, 1 / current, 1800);
+ }
+
+ await this.liftFingers();
+ await this.pause(200);
+ }
+
+ private between(min: number, max: number): number {
+ return min + (max - min) * this.random();
+ }
+
+ private async center(target: Locator): Promise<{ x: number; y: number }> {
+ const box = await target.boundingBox();
+
+ if (!box) throw new Error(`demo: ${target} has no bounding box`);
+
+ return { x: box.x + box.width / 2, y: box.y + box.height / 2 };
+ }
+
+ /** A pinch that reaches the screen's edge can end without its last touchend reaching the page. */
+ private async liftFingers(): Promise<void> {
+ await this.page.evaluate(() =>
+ document
+ .querySelectorAll<HTMLElement>('.demo-finger')
+ .forEach((finger) => (finger.style.opacity = '0'))
+ );
+ }
+
+ private pageScale(): Promise<number> {
+ return this.page.evaluate(() => window.visualViewport?.scale ?? 1);
+ }
+
+ /**
+ * A two-finger pinch by `factor` around `at`, in viewport pixels. While zoomed in, the gesture
+ * reads its position in page-scale units, so it is divided by the scale the page is at.
+ */
+ private async pinch(at: { x: number; y: number }, factor: number, speed: number): Promise<void> {
+ const cdp = await this.session();
+ const current = await this.pageScale();
+
+ await cdp.send('Input.synthesizePinchGesture', {
+ gestureSourceType: 'touch',
+ relativeSpeed: speed,
+ scaleFactor: factor,
+ x: at.x / current,
+ y: at.y / current
+ });
+ }
+
+ private async session(): Promise<CDPSession> {
+ this.cdp ??= await this.page.context().newCDPSession(this.page);
+
+ return this.cdp;
+ }
+}
+
+interface DemoOptions {
+ /** Path the demo starts from. */
+ startPath: string;
+ /** Extra UI settings merged into the seeded config. */
+ settings: Record<string, unknown>;
+ /**
+ * Conversations imported before the demo starts: built-in traces from
+ * fixtures/traces.ts, or `<name>.jsonl` files from DEMO_TRACES_DATASET.
+ */
+ traces: string[];
+}
+
+/** Reads a trace by name, generating built-in ones and downloading the rest. */
+function loadTrace(name: string, dir: string): string {
+ const generated = generateTrace(name);
+
+ if (generated) return generated;
+
+ const dataset = process.env.DEMO_TRACES_DATASET;
+
+ if (!dataset)
+ throw new Error(`demo: no built-in trace "${name}" and DEMO_TRACES_DATASET is not set`);
+
+ execFileSync(
+ 'hf',
+ ['download', dataset, `${name}.jsonl`, '--repo-type', 'dataset', '--local-dir', dir],
+ { stdio: 'inherit' }
+ );
+
+ return readFileSync(join(dir, `${name}.jsonl`), 'utf8');
+}
+
+/** Imports conversations through Settings > Import/Export, like a user would. */
+async function importTraces(page: Page, file: string): Promise<void> {
+ // the expanded sidebar adds the shortcut to the name
+ const settings = page.getByRole('button', { name: /^Settings(\s|$)/ }).first();
+ const reachable = (target: Locator) =>
+ target.click({ timeout: 2000, trial: true }).then(
+ () => true,
+ () => false
+ );
+ // a collapsed sidebar can sit under the page: on a phone the page's menu key slides it aside,
+ // on a desktop its logo expands it
+ const keys = [
+ page.locator('xpath=//button[@aria-label="Expand navigation"][not(ancestor::aside)]'),
+ page.locator('aside button[aria-label="Expand navigation"]')
+ ];
+
+ for (const key of keys) {
+ if (await reachable(settings)) break;
+
+ if ((await key.count()) > 0 && (await reachable(key.first()))) await key.first().click();
+ }
+
+ await settings.click();
+
+ const dialog = page.getByRole('dialog');
+
+ await dialog.getByText('Import/Export', { exact: true }).filter({ visible: true }).click();
+
+ const chooser = page.waitForEvent('filechooser');
+
+ await dialog.getByRole('button', { name: 'Import conversations' }).click();
+ await (await chooser).setFiles(file);
+ await page.getByRole('button', { name: /^Import \(\d+\)$/ }).click();
+ await page.getByRole('heading', { name: /^Imported \d+ conversation/ }).waitFor();
+
+ // the app remembers the sidebar the import expanded; a demo starts with it collapsed, as on a
+ // first visit
+ await page.evaluate(() => {
+ for (const key of Object.keys(localStorage)) {
+ if (key.endsWith('.sidebarExpanded')) localStorage.removeItem(key);
+ }
+ });
+}
+
+export const test = base.extend<DemoOptions & { demo: Demo }>({
+ demo: async ({ page }, use, testInfo) => {
+ await use(new Demo(page, `${testInfo.file}:${testInfo.title}`));
+ },
+ page: async ({ page, settings, startPath, traces }, use, testInfo) => {
+ await page.addInitScript(installCursorOverlay);
+ await page.addInitScript(seedSettings, {
+ mcpServers: JSON.stringify(demoMcpServerSettings()),
+ ...settings
+ });
+
+ if (traces.length > 0) {
+ const dir = testInfo.outputPath('traces');
+ const file = join(dir, 'import.jsonl');
+
+ mkdirSync(dir, { recursive: true });
+ writeFileSync(file, traces.map((name) => loadTrace(name, dir)).join('\n'));
+ await page.goto('/');
+ await page
+ .getByRole('textbox', { name: 'Type a message...' })
+ .waitFor({ timeout: APP_READY_TIMEOUT_MS });
+ await importTraces(page, file);
+ }
+
+ await page.goto(startPath);
+
+ // a hash route after the import doesn't reload, which the conversations list needs
+ if (traces.length > 0) await page.reload();
+
+ // a fresh checkout's dev server optimizes its dependencies on the first load and reloads the
+ // page, which can take well past an action's timeout; the recording has not started yet
+ await page
+ .getByRole('textbox', { name: 'Type a message...' })
+ .waitFor({ timeout: APP_READY_TIMEOUT_MS });
+
+ const viewport = page.viewportSize();
+
+ if (viewport) await page.mouse.move(viewport.width / 2, viewport.height / 2);
+
+ // let MCP connections and fonts settle before the recording starts
+ await page.waitForTimeout(1500);
+
+ const recorder = new ScreencastRecorder(testInfo.outputPath('frames'));
+
+ await recorder.start(page);
+ await use(page);
+ await recorder.stop();
+ await page.close();
+
+ if (testInfo.status !== testInfo.expectedStatus) return;
+
+ const file = basename(testInfo.file).replace(/\.demo\.ts$/, '');
+ const name = testInfo.title === 'demo' ? file : `${file}--${slugify(testInfo.title)}`;
+ const mp4 = resolve(VIDEOS_DIR, `${name}.mp4`);
+
+ mkdirSync(VIDEOS_DIR, { recursive: true });
+ recorder.encode(mp4);
+ // the CLI reads which videos a script made, in order, with their titles
+ appendFileSync(
+ resolve(VIDEOS_DIR, `${file}.videos.jsonl`),
+ `${JSON.stringify({ mp4, title: testInfo.title })}\n`
+ );
+ console.log(`demo video: ${mp4}`);
+ },
+
+ settings: [{}, { option: true }],
+
+ startPath: ['/', { option: true }],
+
+ traces: [[], { option: true }]
+});
+
+export { expect } from '@playwright/test';
+
+function slugify(text: string): string {
+ return text
+ .toLowerCase()
+ .replace(/[^a-z0-9]+/g, '-')
+ .replace(/^-|-$/g, '');
+}
diff --git a/tools/ui/demo/playwright.config.ts b/tools/ui/demo/playwright.config.ts
new file mode 100644
index 000000000..2ac9c4c4b
--- /dev/null
+++ b/tools/ui/demo/playwright.config.ts
@@ -0,0 +1,87 @@
+import { DEMO_MCP_PORT } from './fixtures/demo-config.ts';
+import { defineConfig, devices } from '@playwright/test';
+import { existsSync } from 'node:fs';
+import { resolve } from 'node:path';
+
+const ENV_FILE = resolve(import.meta.dirname, '../.env');
+
+if (existsSync(ENV_FILE)) process.loadEnvFile(ENV_FILE);
+
+/**
+ * Demo video recordings.
+ *
+ * Env (also read from tools/ui/.env):
+ * DEMO_UI_DIR tools/ui checkout to record (default: this one), e.g. a PR worktree
+ * DEMO_UI_PORT port for that checkout's vite dev server (default: 5180)
+ * DEMO_SERVER_ORIGIN llama-server the UI proxies to (default: http://localhost:9931)
+ * DEMO_LLAMA_SERVER llama-server binary started when none runs there (default: build/bin)
+ * DEMO_COLOR_SCHEME light | dark (default: light)
+ */
+
+// Playwright clears outputDir when a run starts, so concurrent recordings each get
+// their own; workers inherit the env, so they resolve the same directory
+process.env.DEMO_RUN_ID ??= String(process.pid);
+
+const UI_DIR = resolve(process.env.DEMO_UI_DIR ?? resolve(import.meta.dirname, '..'));
+const UI_PORT = Number(process.env.DEMO_UI_PORT ?? 5180);
+const SERVER_ORIGIN = process.env.DEMO_SERVER_ORIGIN ?? 'http://localhost:9931';
+const SERVER_PORT = Number(new URL(SERVER_ORIGIN).port || 80);
+const LLAMA_SERVER =
+ process.env.DEMO_LLAMA_SERVER ?? resolve(import.meta.dirname, '../../../build/bin/llama-server');
+
+export const VIEWPORT = { height: 900, width: 1440 };
+/** Device pixel ratio the demos render and record at. */
+const SCALE = Number(process.env.DEMO_SCALE ?? 2);
+
+export default defineConfig({
+ expect: {
+ timeout: 10000
+ },
+ fullyParallel: false,
+ outputDir: `test-results/${process.env.DEMO_RUN_ID}`,
+ projects: [
+ {
+ name: 'chromium',
+ use: { ...devices['Desktop Chrome'], deviceScaleFactor: SCALE, viewport: VIEWPORT }
+ }
+ ],
+ reporter: 'line',
+ retries: 0,
+ testDir: '.',
+ testMatch: ['**/*.demo.ts'],
+ timeout: 120000,
+ use: {
+ actionTimeout: 10000,
+ baseURL: `http://localhost:${UI_PORT}`,
+ colorScheme: process.env.DEMO_COLOR_SCHEME === 'dark' ? 'dark' : 'light',
+ // headless Chromium screencasts at 1x unless the scale is forced at launch; the sandbox
+ // is on since a PR demo runs that PR's code (Playwright turns it off by default). Not the
+ // installed Chrome: its headless mode screencasts a window that ignores the viewport
+ launchOptions: { args: [`--force-device-scale-factor=${SCALE}`], chromiumSandbox: true },
+ // recorded through the screencast in recorder.ts, at the device pixel ratio
+ video: 'off',
+ viewport: VIEWPORT
+ },
+ webServer: [
+ {
+ command: `node ${resolve(import.meta.dirname, 'fixtures/mcp-servers.ts')}`,
+ reuseExistingServer: true,
+ url: `http://localhost:${DEMO_MCP_PORT}/health`
+ },
+ {
+ command: `${LLAMA_SERVER} --host 127.0.0.1 --port ${SERVER_PORT} --tools all`,
+ reuseExistingServer: true,
+ timeout: 60000,
+ url: `${SERVER_ORIGIN}/health`
+ },
+ {
+ command: `npx vite dev --port ${UI_PORT} --strictPort`,
+ cwd: UI_DIR,
+ env: { VITE_PUBLIC_SERVER_ORIGIN: SERVER_ORIGIN },
+ reuseExistingServer: true,
+ timeout: 120000,
+ url: `http://localhost:${UI_PORT}`
+ }
+ ],
+ workers: 1
+});
diff --git a/tools/ui/demo/recorder.ts b/tools/ui/demo/recorder.ts
new file mode 100644
index 000000000..3d1faa9a4
--- /dev/null
+++ b/tools/ui/demo/recorder.ts
@@ -0,0 +1,109 @@
+/**
+ * Records a page through Chromium's screencast instead of Playwright's video,
+ * which encodes VP8 at about 1 Mbit/s at 1x scale. Frames arrive at the page's
+ * device pixel ratio, each with its timestamp, and are encoded once into an
+ * H.264 mp4 (what GitHub accepts in PR descriptions).
+ */
+
+import type { CDPSession, Page } from '@playwright/test';
+import { execFileSync } from 'node:child_process';
+import { mkdirSync, rmSync, writeFileSync } from 'node:fs';
+import { writeFile } from 'node:fs/promises';
+import { join } from 'node:path';
+
+const FPS = 60;
+const JPEG_QUALITY = 92;
+
+interface Frame {
+ file: string;
+ /** Seconds, on the same clock as Date.now(). */
+ time: number;
+}
+
+export class ScreencastRecorder {
+ private readonly dir: string;
+ private readonly frames: Frame[] = [];
+ private readonly pending: Promise<void>[] = [];
+ private session: CDPSession | undefined;
+ private stoppedAt = 0;
+
+ constructor(dir: string) {
+ this.dir = dir;
+ }
+
+ /** Encodes the frames, each held until the next one arrived (the screencast only sends changes). */
+ encode(mp4: string): void {
+ if (this.frames.length === 0) throw new Error('demo: the screencast captured no frames');
+
+ const list = this.frames.flatMap((frame, i) => {
+ const end = this.frames[i + 1]?.time ?? this.stoppedAt;
+
+ return [`file '${frame.file}'`, `duration ${Math.max(end - frame.time, 1 / FPS).toFixed(4)}`];
+ });
+ const listFile = join(this.dir, 'frames.txt');
+
+ // the concat demuxer ignores the last entry's duration unless the file is repeated
+ writeFileSync(listFile, [...list, `file '${this.frames.at(-1)!.file}'`, ''].join('\n'));
+ execFileSync(
+ 'ffmpeg',
+ [
+ '-y',
+ '-loglevel',
+ 'error',
+ '-f',
+ 'concat',
+ '-safe',
+ '0',
+ '-i',
+ listFile,
+ '-vf',
+ `fps=${FPS},scale=trunc(iw/2)*2:trunc(ih/2)*2`,
+ '-c:v',
+ 'libx264',
+ '-preset',
+ 'slow',
+ '-crf',
+ '18',
+ '-pix_fmt',
+ 'yuv420p',
+ '-movflags',
+ '+faststart',
+ mp4
+ ],
+ { stdio: 'inherit' }
+ );
+ rmSync(this.dir, { force: true, recursive: true });
+ }
+
+ async start(page: Page): Promise<void> {
+ const size = page.viewportSize();
+ const scale = await page.evaluate(() => window.devicePixelRatio);
+
+ rmSync(this.dir, { force: true, recursive: true });
+ mkdirSync(this.dir, { recursive: true });
+
+ this.session = await page.context().newCDPSession(page);
+ this.session.on('Page.screencastFrame', ({ data, metadata, sessionId }) => {
+ const file = join(this.dir, `${String(this.frames.length).padStart(6, '0')}.jpg`);
+
+ // acknowledged first: Chromium sends the next frame only after the ack
+ this.session?.send('Page.screencastFrameAck', { sessionId }).catch(() => {});
+ this.frames.push({ file, time: metadata.timestamp ?? Date.now() / 1000 });
+ this.pending.push(writeFile(file, Buffer.from(data, 'base64')));
+ });
+ await this.session.send('Page.startScreencast', {
+ everyNthFrame: 1,
+ format: 'jpeg',
+ maxHeight: size ? size.height * scale : undefined,
+ maxWidth: size ? size.width * scale : undefined,
+ quality: JPEG_QUALITY
+ });
+ }
+
+ async stop(): Promise<void> {
+ this.stoppedAt = Date.now() / 1000;
+ await this.session?.send('Page.stopScreencast').catch(() => {});
+ await this.session?.detach().catch(() => {});
+ await Promise.all(this.pending);
+ }
+}
diff --git a/tools/ui/demo/storage.ts b/tools/ui/demo/storage.ts
new file mode 100644
index 000000000..def006d7b
--- /dev/null
+++ b/tools/ui/demo/storage.ts
@@ -0,0 +1,92 @@
+/**
+ * Storage targets for demo scripts and videos, picked by the scheme of the
+ * DEMO_STORAGE URI:
+ *
+ * hf://buckets/<owner>/<bucket>[/prefix] Hugging Face bucket, via the `hf` CLI login
+ * file:///some/dir local or mounted directory
+ */
+
+import { execFileSync } from 'node:child_process';
+import { copyFileSync, cpSync, existsSync, mkdirSync } from 'node:fs';
+import { dirname, join } from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+export interface DemoStorage {
+ /** Copies every file under remote `dir` into local `target`; returns false when it has none. */
+ downloadDir(dir: string, target: string): boolean;
+ /** Copies local `file` to remote `key`. */
+ upload(file: string, key: string): void;
+}
+
+function joinKey(base: string, key: string): string {
+ return `${base.replace(/\/+$/, '')}/${key}`;
+}
+
+// plain fields: the CLI runs under Node's type stripping, which has no parameter properties
+class HfStorage implements DemoStorage {
+ private readonly base: string;
+
+ constructor(base: string) {
+ this.base = base;
+ }
+
+ downloadDir(dir: string, target: string): boolean {
+ mkdirSync(target, { recursive: true });
+
+ try {
+ execFileSync('hf', ['buckets', 'sync', joinKey(this.base, dir), target], {
+ stdio: 'inherit'
+ });
+ } catch {
+ return false;
+ }
+
+ return true;
+ }
+
+ upload(file: string, key: string): void {
+ execFileSync('hf', ['buckets', 'cp', file, joinKey(this.base, key)], { stdio: 'inherit' });
+ }
+}
+
+class FileStorage implements DemoStorage {
+ private readonly root: string;
+
+ constructor(root: string) {
+ this.root = root;
+ }
+
+ downloadDir(dir: string, target: string): boolean {
+ const source = join(this.root, dir);
+
+ if (!existsSync(source)) return false;
+
+ cpSync(source, target, { recursive: true });
+
+ return true;
+ }
+
+ upload(file: string, key: string): void {
+ const target = join(this.root, key);
+
+ mkdirSync(dirname(target), { recursive: true });
+ copyFileSync(file, target);
+ }
+}
+
+export function getDemoStorage(uri = process.env.DEMO_STORAGE): DemoStorage | null {
+ if (!uri) return null;
+
+ if (uri.startsWith('hf://')) return new HfStorage(uri);
+
+ if (uri.startsWith('file://')) return new FileStorage(fileURLToPath(uri));
+
+ throw new Error(`DEMO_STORAGE: unsupported scheme in "${uri}" (use hf:// or file://)`);
+}
+
+/** Public URL of a stored key, when DEMO_STORAGE_PUBLIC_URL is set. */
+export function getPublicUrl(key: string): string | null {
+ const base = process.env.DEMO_STORAGE_PUBLIC_URL;
+
+ return base ? joinKey(base, key) : null;
+}
diff --git a/tools/ui/package.json b/tools/ui/package.json
index e87d40aa0..c29469d2d 100644
--- a/tools/ui/package.json
+++ b/tools/ui/package.json
@@ -18,6 +18,11 @@
"test": "npm run test:ui -- --run && npm run test:client -- --run && npm run test:unit -- --run && npm run test:e2e",
"test:e2e": "playwright test",
"test:e2e:pwa": "playwright test tests/e2e/pwa.e2e.ts",
+ "demo:codegen": "playwright codegen --target=playwright-test --viewport-size=1440,900 --output=demo/recording.demo.ts http://localhost:5180/",
+ "demo:docs": "node demo/cli.ts docs",
+ "demo:pr": "node demo/cli.ts pr",
+ "demo:record": "playwright test --config=demo/playwright.config.ts",
+ "demo:traces": "node demo/cli.ts traces",
"test:client": "vitest --project=client",
"test:unit": "vitest --project=unit",
"test:unit:pwa": "vitest --project=unit --run tests/unit/pwa.spec.ts",