Skip to content

Commit 19c94b5

Browse files
authored
Make every docs example runnable in the browser, with a Linux-VM terminal for CLI blocks (#157)
1 parent 415bf61 commit 19c94b5

16 files changed

Lines changed: 4719 additions & 353 deletions

.gitignore

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,9 +65,11 @@ node_modules/
6565
# Docs index for the /playground chat agent (regenerate with
6666
# website/scripts/build-playground-context.mjs; runs as part of npm run build)
6767
/website/public/playground-docs.json
68-
# Runnable docs-example index for the example-runner panel (regenerate with
68+
# Runnable docs-example index for the example-runner panel and the docs VM's
69+
# seeded filesystem (regenerate with
6970
# website/scripts/build-runnable-examples.mjs; runs as part of npm run build)
7071
/website/public/runnable-examples.json
72+
/website/public/vm-seed.json
7173

7274
# Secrets / local env
7375
.env

website/app/docs/runner/assets.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
'use client';
2+
3+
/**
4+
* Shared loaders for the runnable-docs machinery: the wasm engine + browser
5+
* SDK (static assets built by scripts/build-wasm.sh) and the playground's
6+
* docs search index. One promise each — the panel, the VM terminal, and the
7+
* fake session server all share the same instances.
8+
*/
9+
10+
import { type DocsIndex, prepareDocsIndex } from '../../(home)/playground/brain';
11+
import type { EngineAssets } from './run-host';
12+
13+
const BASE = process.env.NEXT_PUBLIC_BASE_PATH ?? '';
14+
const ASSETS = `${BASE}/chidori-wasm`;
15+
16+
let assetsPromise: Promise<EngineAssets> | null = null;
17+
18+
export function loadEngine(): Promise<EngineAssets> {
19+
// Runtime imports on purpose (same as the playground): the wasm module and
20+
// SDK are static assets, not bundle modules.
21+
assetsPromise ??= (async () => {
22+
const wasm = await import(/* webpackIgnore: true */ `${ASSETS}/chidori_wasm.js`);
23+
await wasm.default();
24+
const sdk = await import(/* webpackIgnore: true */ `${ASSETS}/chidori-browser.js`);
25+
return { wasm, sdk } as EngineAssets;
26+
})();
27+
// Don't cache a failure: callers fall back to the faked CLI for this
28+
// action, and a later attempt (assets finished deploying, flaky network
29+
// recovered) gets a fresh try.
30+
assetsPromise.catch(() => {
31+
assetsPromise = null;
32+
});
33+
return assetsPromise;
34+
}
35+
36+
let docsIndexPromise: Promise<DocsIndex | null> | null = null;
37+
38+
export function loadDocsIndex(): Promise<DocsIndex | null> {
39+
docsIndexPromise ??= fetch(`${BASE}/playground-docs.json`)
40+
.then((res) => (res.ok ? res.json() : null))
41+
.then((json) => (json ? prepareDocsIndex(json) : null))
42+
.catch(() => null);
43+
return docsIndexPromise;
44+
}

website/app/docs/runner/harness.ts

Lines changed: 676 additions & 60 deletions
Large diffs are not rendered by default.

website/app/docs/runner/host.ts

Lines changed: 52 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -30,12 +30,15 @@ export type RunEvent =
3030
| { k: 'tool'; name: string; args: Json; result: Json }
3131
| { k: 'input'; prompt: string; answer: string }
3232
| { k: 'fetch'; url: string; status: number; ok: boolean; simulated: boolean }
33+
| { k: 'signal'; phase: 'waiting' | 'receiving' | 'received' | 'timeout' | 'poll-empty'; names: string[]; timeoutMs?: number | null; result?: Json }
34+
| { k: 'op'; op: string; label: string; data?: Json }
35+
| { k: 'dom'; html: string; ops: number }
3336
| { k: 'result'; value: Json }
3437
| { k: 'error'; text: string }
3538
| { k: 'done' }
3639
| { k: 'note'; text: string };
3740

38-
const KINDS = new Set(['log', 'prompt', 'tool', 'input', 'fetch', 'result', 'error', 'done', 'note']);
41+
const KINDS = new Set(['log', 'prompt', 'tool', 'input', 'fetch', 'signal', 'op', 'dom', 'result', 'error', 'done', 'note']);
3942

4043
/** Journaled console lines (one JSON event per line) → renderable feed. */
4144
export function parseRunFeed(lines: string[]): RunEvent[] {
@@ -157,7 +160,9 @@ export async function decidePrompt(payload: { text: string; opts?: unknown }): P
157160
}
158161
return JSON.stringify({ reply: String(message.content ?? '') } satisfies ToolLoopDecision);
159162
}
160-
if (!key) return OFFLINE_REPLY;
163+
// Honor format:"json" in the offline stand-in: the harness parses the
164+
// reply, so hand it a valid JSON string literal instead of bare prose.
165+
if (!key) return opts.format === 'json' ? JSON.stringify(OFFLINE_REPLY) : OFFLINE_REPLY;
161166
const message = await chatCompletion(key, {
162167
model: getOpenRouterModel(),
163168
messages: [...systemMessages(opts), { role: 'user', content: payload.text }],
@@ -171,9 +176,11 @@ const asObj = (kwargs: Json): Record<string, Json> =>
171176
kwargs && typeof kwargs === 'object' && !Array.isArray(kwargs) ? kwargs : {};
172177

173178
/**
174-
* The registry behind `chidori.tool()` calls in docs examples. Small on
175-
* purpose: docs search (both spellings the docs use), plus the playground's
176-
* deterministic calculator.
179+
* The registry behind `chidori.tool()` calls in docs examples: docs search
180+
* (both spellings the docs use), the playground's deterministic calculator,
181+
* and the Hacker News research tools the usability-review walkthroughs use
182+
* (Algolia's API is CORS-enabled, so they work live from the browser; when
183+
* the network is unavailable they degrade to a labelled simulated result).
177184
*/
178185
export function makeDocsTools(
179186
getIndex: () => DocsIndex | null,
@@ -187,13 +194,53 @@ export function makeDocsTools(
187194
...(index ? {} : { note: 'docs index not loaded' }),
188195
};
189196
};
197+
const hnFetch = async (url: string): Promise<Json> => {
198+
const res = await fetchWithSimulatedFallback(url);
199+
return (await res.json()) as Json;
200+
};
190201
return {
191202
docs_search: search,
192203
search_docs: search,
193204
calculate: (kwargs) => {
194205
const expression = String(asObj(kwargs).expression ?? '');
195206
return { expression, value: evaluateExpression(expression) };
196207
},
208+
hn_search: async (kwargs) => {
209+
const args = asObj(kwargs);
210+
const endpoint = args.sortBy === 'date' ? 'search_by_date' : 'search';
211+
const data = asObj(
212+
await hnFetch(
213+
`https://hn.algolia.com/api/v1/${endpoint}?tags=story&hitsPerPage=8&query=${encodeURIComponent(String(args.query ?? ''))}`,
214+
),
215+
);
216+
if (data.__simulated) return { query: args.query ?? '', hits: [], note: 'offline — simulated empty result' } as Json;
217+
const hits = (Array.isArray(data.hits) ? data.hits : []).map((h) => {
218+
const hit = asObj(h);
219+
return {
220+
objectID: hit.objectID ?? null,
221+
title: hit.title ?? null,
222+
url: hit.url ?? null,
223+
points: hit.points ?? null,
224+
numComments: hit.num_comments ?? null,
225+
createdAt: hit.created_at ?? null,
226+
};
227+
});
228+
return { query: args.query ?? '', hits } as Json;
229+
},
230+
hn_thread: async (kwargs) => {
231+
const args = asObj(kwargs);
232+
const data = asObj(await hnFetch(`https://hn.algolia.com/api/v1/items/${encodeURIComponent(String(args.objectID ?? ''))}`));
233+
if (data.__simulated) return { objectID: args.objectID ?? '', note: 'offline — simulated empty thread', comments: [] } as Json;
234+
const strip = (html: unknown) => String(html ?? '').replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim();
235+
const comments = (Array.isArray(data.children) ? data.children : [])
236+
.slice(0, 12)
237+
.map((c) => {
238+
const comment = asObj(c);
239+
return { author: comment.author ?? null, text: strip(comment.text).slice(0, 600) };
240+
})
241+
.filter((c) => c.text);
242+
return { objectID: args.objectID ?? '', title: data.title ?? null, points: data.points ?? null, comments } as Json;
243+
},
197244
};
198245
}
199246

0 commit comments

Comments
 (0)