From b78731cb3c692d505313f302f16aa6d7420b7f40 Mon Sep 17 00:00:00 2001 From: Tatsuro Shibamura Date: Sat, 29 Aug 2026 14:29:19 +0900 Subject: [PATCH 1/9] Add a browser example MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add examples/05-browser: a Vite + vanilla TypeScript chat page that runs the agent loop entirely in the browser — streaming into the DOM, client-side tools that reach browser APIs, and session persistence in localStorage. The example's build script runs vite build, so the workspace build gate keeps verifying that the runtime-agnostic packages bundle for a browser target. Closes #96 Co-Authored-By: Claude Fable 5 --- examples/05-browser/README.md | 28 +++ examples/05-browser/index.html | 47 +++++ examples/05-browser/package.json | 24 +++ examples/05-browser/src/main.ts | 205 ++++++++++++++++++++ examples/05-browser/src/style.css | 149 +++++++++++++++ examples/05-browser/tsconfig.json | 8 + examples/README.md | 11 ++ pnpm-lock.yaml | 300 ++++++++++++++---------------- 8 files changed, 614 insertions(+), 158 deletions(-) create mode 100644 examples/05-browser/README.md create mode 100644 examples/05-browser/index.html create mode 100644 examples/05-browser/package.json create mode 100644 examples/05-browser/src/main.ts create mode 100644 examples/05-browser/src/style.css create mode 100644 examples/05-browser/tsconfig.json diff --git a/examples/05-browser/README.md b/examples/05-browser/README.md new file mode 100644 index 0000000..4156e54 --- /dev/null +++ b/examples/05-browser/README.md @@ -0,0 +1,28 @@ +# Browser example + +A Vite + vanilla TypeScript chat page that runs the agent loop entirely in the browser. It +demonstrates what only makes sense client-side: + +- **Streaming into the DOM** — the page iterates the run stream and appends text as it arrives. +- **Client-side tools** — the function-calling loop executes in the page, so tools reach browser + APIs directly: `set_theme` restyles the page, `get_local_time` reads the visitor's clock. +- **Session persistence in `localStorage`** — the session is plain JSON, so persisting it is + `JSON.stringify(session)` and resuming after a reload is `agent.deserializeSession(...)`. + +Run the dev server from the repository root (after `pnpm install` and `pnpm -r build`): + +```bash +pnpm --filter example-05-browser dev +``` + +Open the printed URL, paste an OpenAI API key into the page, and chat. Try: + +> Switch the page to dark mode, then tell me my time zone. + +## A note on API keys + +The key you paste stays in the tab's memory; the page never stores it. But any key that reaches a +browser is readable by whoever uses that browser, which is why the OpenAI SDK requires the +explicit `dangerouslyAllowBrowser` opt-in this example sets. For anything beyond a local demo, run +agents server-side and keep provider credentials there — or point the **Base URL** field at a +proxy that holds the real key. diff --git a/examples/05-browser/index.html b/examples/05-browser/index.html new file mode 100644 index 0000000..430e376 --- /dev/null +++ b/examples/05-browser/index.html @@ -0,0 +1,47 @@ + + + + + + Agent Framework — browser example + + + +
+
+

Agent in the browser

+

+ The whole agent loop runs in this page. The key below stays in memory, but anything shipped to + a browser is readable by its user — in production, run agents server-side and keep provider + credentials there. +

+
+ + + +
+
+
+
+ + + +
+
+ + + diff --git a/examples/05-browser/package.json b/examples/05-browser/package.json new file mode 100644 index 0000000..9c15672 --- /dev/null +++ b/examples/05-browser/package.json @@ -0,0 +1,24 @@ +{ + "name": "example-05-browser", + "version": "0.0.0", + "private": true, + "type": "module", + "engines": { + "node": ">=24" + }, + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview", + "typecheck": "tsc -p tsconfig.json --noEmit" + }, + "dependencies": { + "@polymind-inc/agent-framework": "workspace:^", + "openai": "catalog:", + "zod": "^4.4.3" + }, + "devDependencies": { + "typescript": "catalog:", + "vite": "^8.2.2" + } +} diff --git a/examples/05-browser/src/main.ts b/examples/05-browser/src/main.ts new file mode 100644 index 0000000..d7a73e5 --- /dev/null +++ b/examples/05-browser/src/main.ts @@ -0,0 +1,205 @@ +/** + * 05 — Running an agent in the browser. + * + * The whole agent loop — model calls, the function-calling loop, streaming — runs in the page. + * Tools can therefore touch browser APIs directly: one restyles the page, another reads the + * visitor's clock. The session is plain JSON, persisted to `localStorage` across reloads. + * + * The API key entered in the page stays in this tab's memory, but anything shipped to a browser + * is readable by its user — in production, run agents server-side and keep credentials there. + * + * Run: `pnpm --filter example-05-browser dev` + */ +import { Agent, type AgentSession, tool } from '@polymind-inc/agent-framework'; +import { OpenAIChatClient } from '@polymind-inc/agent-framework/openai'; +import OpenAI from 'openai'; +import { z } from 'zod'; + +const SESSION_KEY = 'agent-framework-example.session'; +const TRANSCRIPT_KEY = 'agent-framework-example.transcript'; + +// These tools run inside the page, so they can reach browser APIs the model cannot. +const setTheme = tool({ + name: 'set_theme', + description: 'Switch the page between the light and dark theme', + parameters: z.object({ theme: z.enum(['light', 'dark']) }), + execute: ({ theme }) => { + document.documentElement.dataset.theme = theme; + return `The page is now in ${theme} mode.`; + }, +}); + +const getLocalTime = tool({ + name: 'get_local_time', + description: "Read the visitor's current local time and time zone from the browser", + parameters: z.object({}), + execute: () => ({ + timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone, + localTime: new Date().toString(), + }), +}); + +function element(selector: string): T { + const found = document.querySelector(selector); + if (found === null) { + throw new Error(`Missing element: ${selector}`); + } + return found; +} + +const apiKeyInput = element('#api-key'); +const baseUrlInput = element('#base-url'); +const modelInput = element('#model'); +const log = element('#log'); +const composer = element('#composer'); +const promptInput = element('#prompt'); +const sendButton = element('#send'); +const clearButton = element('#clear'); + +type TranscriptEntry = { role: 'user' | 'assistant'; text: string }; + +function bubble(kind: TranscriptEntry['role'] | 'error', text = ''): HTMLElement { + const el = document.createElement('div'); + el.className = `bubble ${kind}`; + el.textContent = text; + log.append(el); + el.scrollIntoView({ block: 'end' }); + return el; +} + +function chip(text: string): void { + const el = document.createElement('div'); + el.className = 'chip'; + el.textContent = text; + log.append(el); +} + +function loadJson(key: string): T | undefined { + try { + const raw = localStorage.getItem(key); + return raw === null ? undefined : (JSON.parse(raw) as T); + } catch { + return undefined; + } +} + +function saveJson(key: string, value: unknown): void { + try { + localStorage.setItem(key, JSON.stringify(value)); + } catch { + // Storage can be unavailable (private windows, quota); the page works without persistence. + } +} + +// The session is the source of truth the model sees; the transcript only redraws past bubbles. +const transcript: TranscriptEntry[] = loadJson(TRANSCRIPT_KEY) ?? []; +for (const entry of transcript) { + bubble(entry.role, entry.text); +} +if (transcript.length > 0) { + chip('conversation restored from localStorage'); +} + +let agent: Agent | undefined; +let session: AgentSession | undefined; +let agentSettings = ''; + +function currentAgent(): Agent { + const apiKey = apiKeyInput.value.trim(); + if (apiKey === '') { + apiKeyInput.focus(); + throw new Error('Enter an API key first.'); + } + const baseURL = baseUrlInput.value.trim(); + const model = modelInput.value.trim() || 'gpt-4o-mini'; + const settings = JSON.stringify([apiKey, baseURL, model]); + if (agent === undefined || settings !== agentSettings) { + agent = new Agent({ + client: new OpenAIChatClient({ + model, + // The OpenAI SDK refuses to run in a browser unless the risk of exposing the key is + // acknowledged explicitly. Here the key is typed into the page and kept in memory only. + client: new OpenAI({ + apiKey, + dangerouslyAllowBrowser: true, + ...(baseURL === '' ? {} : { baseURL }), + }), + }), + name: 'BrowserAssistant', + instructions: + 'You are a cheerful assistant living inside a web page. Your tools can restyle the page ' + + "and read the visitor's clock. Answer briefly.", + tools: [setTheme, getLocalTime], + }); + agentSettings = settings; + } + return agent; +} + +function currentSession(active: Agent): AgentSession { + if (session === undefined) { + const saved = loadJson(SESSION_KEY); + session = saved === undefined ? active.createSession() : active.deserializeSession(saved); + } + return session; +} + +async function send(): Promise { + const text = promptInput.value.trim(); + if (text === '' || sendButton.disabled) { + return; + } + let active: Agent; + try { + active = currentAgent(); + } catch (error) { + bubble('error', error instanceof Error ? error.message : String(error)); + return; + } + promptInput.value = ''; + sendButton.disabled = true; + bubble('user', text); + transcript.push({ role: 'user', text }); + let reply: HTMLElement | undefined; + try { + const stream = active.run(text, { session: currentSession(active) }); + for await (const update of stream) { + for (const content of update.contents) { + if (content.type === 'function_call') { + chip(`tool: ${content.name}`); + } + } + if (update.text !== '') { + reply ??= bubble('assistant'); + reply.textContent += update.text; + reply.scrollIntoView({ block: 'end' }); + } + } + transcript.push({ role: 'assistant', text: reply?.textContent ?? '' }); + saveJson(SESSION_KEY, session); + saveJson(TRANSCRIPT_KEY, transcript); + } catch (error) { + reply?.remove(); + bubble('error', error instanceof Error ? error.message : String(error)); + } finally { + sendButton.disabled = false; + promptInput.focus(); + } +} + +composer.addEventListener('submit', (event) => { + event.preventDefault(); + void send(); +}); + +clearButton.addEventListener('click', () => { + session = undefined; + transcript.length = 0; + log.replaceChildren(); + try { + localStorage.removeItem(SESSION_KEY); + localStorage.removeItem(TRANSCRIPT_KEY); + } catch { + // Storage can be unavailable; there is then nothing to clear. + } +}); diff --git a/examples/05-browser/src/style.css b/examples/05-browser/src/style.css new file mode 100644 index 0000000..42d31bf --- /dev/null +++ b/examples/05-browser/src/style.css @@ -0,0 +1,149 @@ +:root { + color-scheme: light; + --bg: #f6f7f9; + --panel: #ffffff; + --text: #1f2328; + --muted: #59636e; + --accent: #0969da; + --border: #d1d9e0; +} + +:root[data-theme="dark"] { + color-scheme: dark; + --bg: #0d1117; + --panel: #161b22; + --text: #e6edf3; + --muted: #9198a1; + --accent: #4493f8; + --border: #30363d; +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + font-family: system-ui, sans-serif; + background: var(--bg); + color: var(--text); +} + +main { + max-width: 720px; + min-height: 100dvh; + margin: 0 auto; + padding: 1rem; + display: flex; + flex-direction: column; + gap: 1rem; +} + +h1 { + margin: 0 0 0.5rem; + font-size: 1.4rem; +} + +.warning { + margin: 0 0 0.75rem; + padding: 0.5rem 0.75rem; + border: 1px solid var(--border); + border-left: 3px solid var(--accent); + border-radius: 6px; + background: var(--panel); + color: var(--muted); + font-size: 0.85rem; +} + +.settings { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 0.5rem; +} + +.settings label { + display: flex; + flex-direction: column; + gap: 0.25rem; + font-size: 0.8rem; + color: var(--muted); +} + +input, +button { + font: inherit; + padding: 0.5rem 0.65rem; + border: 1px solid var(--border); + border-radius: 6px; + background: var(--panel); + color: var(--text); +} + +input:focus { + outline: 2px solid var(--accent); + outline-offset: -1px; +} + +#log { + flex: 1; + display: flex; + flex-direction: column; + gap: 0.5rem; + padding: 0.25rem 0; + overflow-y: auto; +} + +.bubble { + max-width: 85%; + padding: 0.5rem 0.75rem; + border-radius: 10px; + white-space: pre-wrap; + overflow-wrap: anywhere; +} + +.bubble.user { + align-self: flex-end; + background: var(--accent); + color: #ffffff; +} + +.bubble.assistant { + align-self: flex-start; + background: var(--panel); + border: 1px solid var(--border); +} + +.bubble.error { + align-self: stretch; + border: 1px solid #d1242f; + color: #d1242f; + background: var(--panel); +} + +.chip { + align-self: flex-start; + padding: 0.15rem 0.6rem; + border: 1px dashed var(--border); + border-radius: 999px; + color: var(--muted); + font-size: 0.75rem; +} + +#composer { + display: flex; + gap: 0.5rem; +} + +#prompt { + flex: 1; +} + +#send { + background: var(--accent); + border-color: var(--accent); + color: #ffffff; +} + +#send:disabled { + opacity: 0.6; +} diff --git a/examples/05-browser/tsconfig.json b/examples/05-browser/tsconfig.json new file mode 100644 index 0000000..41386a2 --- /dev/null +++ b/examples/05-browser/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.neutral.json", + "compilerOptions": { + "isolatedDeclarations": false, + "noEmit": true + }, + "include": ["src/**/*.ts"] +} diff --git a/examples/README.md b/examples/README.md index 3fb1fe4..7accb76 100644 --- a/examples/README.md +++ b/examples/README.md @@ -73,3 +73,14 @@ pnpm --filter example-04-a2a server | Multi-turn | A question from the agent, task linking, and session persistence | `pnpm --filter example-04-a2a multi-turn` | | Background | Continuation tokens: resuming a task, and re-subscribing to a live one | `pnpm --filter example-04-a2a background` | | Authentication | A static token, and one that expires and is refreshed on a 401 | Start the agent with `A2A_TOKEN=invoice-secret`, then run `A2A_TOKEN=invoice-secret pnpm --filter example-04-a2a authentication` | + +## Browser + +A chat page that runs the agent loop entirely in the browser: streaming into the DOM, client-side +tools that reach browser APIs, and session persistence in `localStorage`. The OpenAI API key is +pasted into the page and kept in memory — see [`05-browser/README.md`](05-browser/README.md) for +why production agents belong server-side. + +| Example | What it demonstrates | Command | +| --- | --- | --- | +| Browser chat | The agent loop, streaming, tools, and sessions running in a web page | `pnpm --filter example-05-browser dev` | diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d6e70ec..18e998a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -52,7 +52,7 @@ importers: version: 7.0.2 vitest: specifier: 'catalog:' - version: 4.1.11(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(@vitest/coverage-v8@4.1.11)(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(tsx@4.23.12)) + version: 4.1.11(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(@vitest/coverage-v8@4.1.11)(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.1)(tsx@4.23.12)) examples/01-get-started: dependencies: @@ -139,6 +139,25 @@ importers: specifier: 'catalog:' version: 7.0.2 + examples/05-browser: + dependencies: + '@polymind-inc/agent-framework': + specifier: workspace:^ + version: link:../../packages/meta + openai: + specifier: 'catalog:' + version: 7.5.0(zod@4.4.3) + zod: + specifier: ^4.4.3 + version: 4.4.3 + devDependencies: + typescript: + specifier: 'catalog:' + version: 7.0.2 + vite: + specifier: ^8.2.2 + version: 8.2.2(@types/node@24.13.3)(esbuild@0.28.1)(tsx@4.23.12) + packages/a2a: dependencies: '@a2a-js/sdk': @@ -156,7 +175,7 @@ importers: version: 7.0.2 vitest: specifier: 'catalog:' - version: 4.1.11(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(@vitest/coverage-v8@4.1.11)(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(tsx@4.23.12)) + version: 4.1.11(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(@vitest/coverage-v8@4.1.11)(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.1)(tsx@4.23.12)) packages/agentserver: dependencies: @@ -208,7 +227,7 @@ importers: version: 7.0.2 vitest: specifier: 'catalog:' - version: 4.1.11(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(@vitest/coverage-v8@4.1.11)(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(tsx@4.23.12)) + version: 4.1.11(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(@vitest/coverage-v8@4.1.11)(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.1)(tsx@4.23.12)) packages/anthropic: dependencies: @@ -227,7 +246,7 @@ importers: version: 7.0.2 vitest: specifier: 'catalog:' - version: 4.1.11(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(@vitest/coverage-v8@4.1.11)(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(tsx@4.23.12)) + version: 4.1.11(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(@vitest/coverage-v8@4.1.11)(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.1)(tsx@4.23.12)) packages/core: dependencies: @@ -258,7 +277,7 @@ importers: version: 7.0.2 vitest: specifier: 'catalog:' - version: 4.1.11(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(@vitest/coverage-v8@4.1.11)(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(tsx@4.23.12)) + version: 4.1.11(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(@vitest/coverage-v8@4.1.11)(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.1)(tsx@4.23.12)) packages/foundry: dependencies: @@ -301,7 +320,7 @@ importers: version: 7.0.2 vitest: specifier: 'catalog:' - version: 4.1.11(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(@vitest/coverage-v8@4.1.11)(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(tsx@4.23.12)) + version: 4.1.11(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(@vitest/coverage-v8@4.1.11)(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.1)(tsx@4.23.12)) packages/mcp: dependencies: @@ -329,7 +348,7 @@ importers: version: 7.0.2 vitest: specifier: 'catalog:' - version: 4.1.11(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(@vitest/coverage-v8@4.1.11)(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(tsx@4.23.12)) + version: 4.1.11(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(@vitest/coverage-v8@4.1.11)(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.1)(tsx@4.23.12)) packages/meta: dependencies: @@ -366,7 +385,7 @@ importers: version: 7.0.2 vitest: specifier: 'catalog:' - version: 4.1.11(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(@vitest/coverage-v8@4.1.11)(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(tsx@4.23.12)) + version: 4.1.11(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(@vitest/coverage-v8@4.1.11)(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.1)(tsx@4.23.12)) packages/openai: dependencies: @@ -385,7 +404,7 @@ importers: version: 7.0.2 vitest: specifier: 'catalog:' - version: 4.1.11(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(@vitest/coverage-v8@4.1.11)(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(tsx@4.23.12)) + version: 4.1.11(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(@vitest/coverage-v8@4.1.11)(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.1)(tsx@4.23.12)) scripts/type-resolution: dependencies: @@ -582,21 +601,12 @@ packages: cpu: [x64] os: [win32] - '@emnapi/core@1.11.1': - resolution: {integrity: sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==} - '@emnapi/core@2.0.0-alpha.3': resolution: {integrity: sha512-AZypUeJ/yByuxyS7BlSNRDOMLMlROYtjYdIAuBmJssVz1UJDSeYxLrdizhXCFYhedC5bqd/ASy8EuNXbVVXp9g==} - '@emnapi/runtime@1.11.1': - resolution: {integrity: sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==} - '@emnapi/runtime@2.0.0-alpha.3': resolution: {integrity: sha512-hFPAhMUjJD9BSyCANEISPOogeXC9Zo9ZQl7L6vKnaVsMkCtzznaW/naYypeyl0Gv5rYfWYsZbpixTMpjDJzQeA==} - '@emnapi/wasi-threads@1.2.2': - resolution: {integrity: sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==} - '@emnapi/wasi-threads@2.0.1': resolution: {integrity: sha512-9DsSk+o5NBX0CCJT8s0EROGSGxjR/tKu6aBTaVyq+SjAEQH4XcdcRxPBRzsBLizTTJ49MJjF+jgu3qnO9GLQcQ==} @@ -929,12 +939,12 @@ packages: resolution: {integrity: sha512-eSYWTm620tTk45EKSedaUL8MFYI8hW164hIXsgIHyxu3VobUB3fFCu5t0hQby6OoWRPsG1KkKUG2M5UadiLiVg==} engines: {node: '>=14'} - '@oxc-project/types@0.139.0': - resolution: {integrity: sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw==} - '@oxc-project/types@0.142.0': resolution: {integrity: sha512-7W+2q5AKQVU36fkaryontrHn3YDt1RyUYXatw9i5H8ocYe2sPKSFB6eS8WNPeRKiN1qAWWZUPm7gwFzJGrccqQ==} + '@oxc-project/types@0.147.0': + resolution: {integrity: sha512-IJ3s6ltHLp45S0bh7phkX+gJO7A1Wuz2EaqpAhb8WjqDwbzMiWKHhyyT42tskaWjEYXtHtVCPpnBJVT9+dcRLg==} + '@protobufjs/aspromise@1.1.2': resolution: {integrity: sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==} @@ -965,10 +975,10 @@ packages: '@quansync/fs@1.0.0': resolution: {integrity: sha512-4TJ3DFtlf1L5LDMaM6CanJ/0lckGNtJcMjQ1NAV6zDmA0tEHKZtxNKin8EgPaVX1YzljbxckyT2tJrpQKAtngQ==} - '@rolldown/binding-android-arm64@1.1.5': - resolution: {integrity: sha512-lZg8fqIv2v7FF237bwMgzGZEJvGL79/s5knJ/i6FmsGF4XXlzccZ4jb+TrFIxtSSxFtIpdsgrPZeMk1I9AFcyQ==} + '@rolldown/binding-android-arm-eabi@1.2.6': + resolution: {integrity: sha512-b+jTcARdTiFLI6jB4a5XjTm0RWd6KcRfQj/I2356fxUZemiho9zQLxo0RtCuMDAyKcLo6cEltkgbQp6d1+sjjQ==} engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm64] + cpu: [arm] os: [android] '@rolldown/binding-android-arm64@1.2.1': @@ -977,11 +987,11 @@ packages: cpu: [arm64] os: [android] - '@rolldown/binding-darwin-arm64@1.1.5': - resolution: {integrity: sha512-51Bnx9pNiMRKSUNtBfySkNJ9vMU9Hh3I1ozDd6gyPPYzaXCfnptUcEZxXGYFn+ul2dtcMUiqGR1Yai2K10uoTw==} + '@rolldown/binding-android-arm64@1.2.6': + resolution: {integrity: sha512-lkWU8ZJaRk9q3CIEY1Tc7vIFALp3Xw5NfGJo2hQg5oIqNgxWi1zI+IiDEK3r70BF5Dzol1tcXsnzsRc8NLhG+Q==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] - os: [darwin] + os: [android] '@rolldown/binding-darwin-arm64@1.2.1': resolution: {integrity: sha512-fMsTOnN0OjFm3CyppWPitKnc8UlliVARUULW6cfU6AIqjdtgmSFWSk9vecHzZduv/yMWIHDlRhM1e8Iff9uAfA==} @@ -989,10 +999,10 @@ packages: cpu: [arm64] os: [darwin] - '@rolldown/binding-darwin-x64@1.1.5': - resolution: {integrity: sha512-Tm+gbfC0aHu1tBA/JvKQh32S0K6YgCHkiAF4/W6xX0K0RmNuc94VeK419dJoE65R5aRxmo+noZQSWrAMF6yb6g==} + '@rolldown/binding-darwin-arm64@1.2.6': + resolution: {integrity: sha512-dgR56NYnvAszm7Ob1B2/Vn0e8bUQYZH2UjVaMMtMVOCKFSfjhfLmuA/9+O+F+ajUdG6B/bSssrKW6JJYASa8jA==} engines: {node: ^20.19.0 || >=22.12.0} - cpu: [x64] + cpu: [arm64] os: [darwin] '@rolldown/binding-darwin-x64@1.2.1': @@ -1001,11 +1011,11 @@ packages: cpu: [x64] os: [darwin] - '@rolldown/binding-freebsd-x64@1.1.5': - resolution: {integrity: sha512-JMzDKCCXq93YccG5gz3hvOs1oXRKAf0XYpfOS88e+wZrC8Iugj6j68867vrYZkvpDDpKn/KoKORThmchMpF6TA==} + '@rolldown/binding-darwin-x64@1.2.6': + resolution: {integrity: sha512-vpVxFvUCFioJqug7OTvqptkc4yb8UX0AwfDmJpaR/0sWz+BUmqSVAf7c8JkUgnN8YLspb4a/N6NhTyMAmdyQ7Q==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] - os: [freebsd] + os: [darwin] '@rolldown/binding-freebsd-x64@1.2.1': resolution: {integrity: sha512-Fa0jHR07E7YBN4vOEsbVf2briYNsuOowfLJaXULZM0ldMlaCaj2LJgLMbMe4iacRyZmvR8efFhgR9wKuGclQUg==} @@ -1013,11 +1023,11 @@ packages: cpu: [x64] os: [freebsd] - '@rolldown/binding-linux-arm-gnueabihf@1.1.5': - resolution: {integrity: sha512-uML21j2K5TfPGutKxub+M+nLjZIrWjXQ5Grx4lCe/nimTj9B4L63zHpjXLl4y0L3mcm2htEQIb06oCG/szerNw==} + '@rolldown/binding-freebsd-x64@1.2.6': + resolution: {integrity: sha512-h1wG6Y6K3JlRswxsI64qQJqBAy4vrLuHgRbc8CZMGSWTOFRY6ghMApM1NKzB2I0n5xV1fjkE18SuVl2QpLeNpA==} engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm] - os: [linux] + cpu: [x64] + os: [freebsd] '@rolldown/binding-linux-arm-gnueabihf@1.2.1': resolution: {integrity: sha512-pzkgu1SSHGgRRyRZ4fbmSgmajbVt+epaLP99NDjFft69v/ypfTi6swBMiVdh2EkQ0OSnHE1lZDM7DRGkyAzUpA==} @@ -1025,12 +1035,11 @@ packages: cpu: [arm] os: [linux] - '@rolldown/binding-linux-arm64-gnu@1.1.5': - resolution: {integrity: sha512-navSiuTMogvnQoZoM/v+l3ZWo50/NTwSHSzheABx/RCnmUPaKwq9qSo4Br2OYRs21+Fz8uFqITZM3H4opOB0/Q==} + '@rolldown/binding-linux-arm-gnueabihf@1.2.6': + resolution: {integrity: sha512-tbCiqub0q2MVWJKgF5PoAlNWCtQydiOYSLIkd8sByqK/6MMYLJRcSXSYodqYtd0O+Fw7QaVmKKlS4oL94YRZ0w==} engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm64] + cpu: [arm] os: [linux] - libc: [glibc] '@rolldown/binding-linux-arm64-gnu@1.2.1': resolution: {integrity: sha512-QI5SEDY8cbiYWHx0VO4vIc3UlS6a32vXHjU8Qy/17adEmZIPuByJg13UEvo9c/UCiUkdcVWY83C+b+JrwnNyUg==} @@ -1039,12 +1048,12 @@ packages: os: [linux] libc: [glibc] - '@rolldown/binding-linux-arm64-musl@1.1.5': - resolution: {integrity: sha512-lAryqH7IteztmCXQXk0etKj4wBQ7Gx5S6LjKhsgp9zb8I5bsuvU/2llH1hDQcjsFeqIsovMVN339/8pUDDBXxA==} + '@rolldown/binding-linux-arm64-gnu@1.2.6': + resolution: {integrity: sha512-oxK9+baEBPhZG5HB4URY+uU04zJWeZlH6Tb9rB5DK4DF9XR1uXNLXt5Q5ZsugTKayNCNLhkcwz/ye74hRI98dg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] - libc: [musl] + libc: [glibc] '@rolldown/binding-linux-arm64-musl@1.2.1': resolution: {integrity: sha512-Sm41FyCeXqmYcERoYOCbGIL5hNfd8w9LQ7Y61Bev48HkcjaJqV/iiVOaiDxjVTRMS+QKrZmD8cfPt4uMVnvM+A==} @@ -1053,12 +1062,12 @@ packages: os: [linux] libc: [musl] - '@rolldown/binding-linux-ppc64-gnu@1.1.5': - resolution: {integrity: sha512-fsK/sNBnxzBlL4O1JNrZakVQxPspqpED5dLtNsZS9oOKmtSpdNIzxH2kkol5HYTWJN47sE20ztMJPxfZ89qGOg==} + '@rolldown/binding-linux-arm64-musl@1.2.6': + resolution: {integrity: sha512-muWCk27FVBEZtv0MsK8gnfSmgczA8KQ0uRVJbTABKhkRfQc38aUrcb7fhi3BNiyseFmgcRsoMfQsSNJ+DbZdSw==} engines: {node: ^20.19.0 || >=22.12.0} - cpu: [ppc64] + cpu: [arm64] os: [linux] - libc: [glibc] + libc: [musl] '@rolldown/binding-linux-ppc64-gnu@1.2.1': resolution: {integrity: sha512-2x+WhXTGl9yJYPbltW/BSEPTVz9OIWQyER4N+gJEDWkkn904eRcBzELqh/Hf7K0w/ubGbKNMv0ZC+94QK/IFEg==} @@ -1067,10 +1076,10 @@ packages: os: [linux] libc: [glibc] - '@rolldown/binding-linux-s390x-gnu@1.1.5': - resolution: {integrity: sha512-gLYb4BIadlfTOYT5gO503n8zQjXflgzpD0FcyKh0Mzx3rqCZKnHoJWV9xe1KXUJ5lx2JfcSHr/mhzS0PC/McAA==} + '@rolldown/binding-linux-ppc64-gnu@1.2.6': + resolution: {integrity: sha512-eWDoSfU7Co2qj3vgB3Dt4lj1mG6CoWbcJQkRMP3XJplyCMtuaq3LHvPFjS9QIPvMGWVadJC04Xiy0IdcVPtnwQ==} engines: {node: ^20.19.0 || >=22.12.0} - cpu: [s390x] + cpu: [ppc64] os: [linux] libc: [glibc] @@ -1081,10 +1090,10 @@ packages: os: [linux] libc: [glibc] - '@rolldown/binding-linux-x64-gnu@1.1.5': - resolution: {integrity: sha512-FjcpEKUyJygHgs1o50VYNvkt5+7Le/VEdYt0AkRpkL33MnyQfwr8l5mXwMmfmTbyMPr5vJLC+8/Gd9gXnwU1QQ==} + '@rolldown/binding-linux-s390x-gnu@1.2.6': + resolution: {integrity: sha512-2bWNjRSIayvupRKxXUY2tWG9fYdoUlTqWywHRvE8Eq3GvuQ+f2HeIkve697fIt+IQs/PV8yFsdWuhp1aJ1PdnA==} engines: {node: ^20.19.0 || >=22.12.0} - cpu: [x64] + cpu: [s390x] os: [linux] libc: [glibc] @@ -1095,12 +1104,12 @@ packages: os: [linux] libc: [glibc] - '@rolldown/binding-linux-x64-musl@1.1.5': - resolution: {integrity: sha512-Me+PfPI2TMeOQk0gYWfLQZtTktrmzbr8cDboqX83XKc7UrgAi55gF+2dUkWdxd19n55Essp2yeca+O9N5rBxHg==} + '@rolldown/binding-linux-x64-gnu@1.2.6': + resolution: {integrity: sha512-KekI0gS0wLxe1UBSQSjenBVwou/JkcQPDzBPICGZjxUv9k3RteHDPBQaiOicZUFKRIH2wKEimGwVpnJsbPzu7w==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] - libc: [musl] + libc: [glibc] '@rolldown/binding-linux-x64-musl@1.2.1': resolution: {integrity: sha512-xxBJRL+0q0Kce7orznGWLuylHDY65vuARXZRpX+hPdv+DqK2c3NlCsVA98tlWzWNEE7yPqA/1NQ5nnCrj49Y5A==} @@ -1109,11 +1118,12 @@ packages: os: [linux] libc: [musl] - '@rolldown/binding-openharmony-arm64@1.1.5': - resolution: {integrity: sha512-yc5WrLzXks6zCQfn9Oxr8pORKyl/pF+QjHmW/Qx3qu0oyrrNC+y2JLTU1E2rcWYAmzlnqngWXHQjy51VzW70Vw==} + '@rolldown/binding-linux-x64-musl@1.2.6': + resolution: {integrity: sha512-TvtPnfVr+HtyGiDmPK4VWmlNm7QhNNAcK5Q9A7aOXsI8545yCyaoMaicXrFZ72JzeYjaUVk7yT243zT0jzjFKQ==} engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm64] - os: [openharmony] + cpu: [x64] + os: [linux] + libc: [musl] '@rolldown/binding-openharmony-arm64@1.2.1': resolution: {integrity: sha512-M6AdXIXw3s+/8XpKMzdGDEXGS1S7kwUsy+rcTIUIOx5Ge4nXKCtAFHFV9YKkXvGcC5WMoTjAteLzlsQROVI0Yw==} @@ -1121,35 +1131,36 @@ packages: cpu: [arm64] os: [openharmony] - '@rolldown/binding-wasm32-wasi@1.1.5': - resolution: {integrity: sha512-VbQGPX2b4r48TAMIM2cjgluIM1HYutm4pcTEJsle7iEP7sB1dFqtPLBVbdLAZCxy1txCcPxf4QFf4v8uvltPqA==} + '@rolldown/binding-openharmony-arm64@1.2.6': + resolution: {integrity: sha512-iOo0VEay2XFhaCcH0sps5XIimkSuOnNaZrf6+ZkoSOQBJPKNU48RkmJv0/lSpipexu5P+ouFgafe5IGr/DiQfg==} engines: {node: ^20.19.0 || >=22.12.0} - cpu: [wasm32] + cpu: [arm64] + os: [openharmony] '@rolldown/binding-wasm32-wasi@1.2.1': resolution: {integrity: sha512-/TX0SoRGojHzSAHpfVBbavRVSazg5U3h3Y3VXfcc0cdugq6kxdqw8LPGFiPr+/7gE/60zRcsOY2Vi9b9eT0jww==} engines: {node: ^20.19.0 || ^22.13.0 || >=23.5.0} - '@rolldown/binding-win32-arm64-msvc@1.1.5': - resolution: {integrity: sha512-gHv82k63z4qpV5+Q1y/12KrK0ltWBukVDI8nZcbT7Tt/ZlOIVwppazneq0F93oDxTo3IgAMEDIoQh3E2n6mVsw==} + '@rolldown/binding-win32-arm64-msvc@1.2.1': + resolution: {integrity: sha512-EvRrivJieyHG+AO9lleZWgq+g0+S7oV2C51yuqlcyU/R9net+sI4Pj0F+lUoP2bEr6TWX3SqFaaS0SzfLxSzkw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [win32] - '@rolldown/binding-win32-arm64-msvc@1.2.1': - resolution: {integrity: sha512-EvRrivJieyHG+AO9lleZWgq+g0+S7oV2C51yuqlcyU/R9net+sI4Pj0F+lUoP2bEr6TWX3SqFaaS0SzfLxSzkw==} + '@rolldown/binding-win32-arm64-msvc@1.2.6': + resolution: {integrity: sha512-y5NTmmasMS455JlOCO4ZM9krIchv3Mvm1crL1iUPGOPgEzSkves9n0SdC5Sjz6+qWDFhd8/JpfWMH8NSWNHe+A==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [win32] - '@rolldown/binding-win32-x64-msvc@1.1.5': - resolution: {integrity: sha512-tTZuDBPw85tEN5PQi1pnEBzDy0Z49HtScLAbD5t6hyeU92A95pRWaSMw1GZZi/RwgSgUIl0xrSlXIT/9QzvYSA==} + '@rolldown/binding-win32-x64-msvc@1.2.1': + resolution: {integrity: sha512-Z4eCmn5QJ/5+azF9knpLWKfVd9aidn0mAe9TpJgvBLId9Ax3t0+JVxBmT25Bv7NBbVW1TZyKjQjQReouMeH5UQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [win32] - '@rolldown/binding-win32-x64-msvc@1.2.1': - resolution: {integrity: sha512-Z4eCmn5QJ/5+azF9knpLWKfVd9aidn0mAe9TpJgvBLId9Ax3t0+JVxBmT25Bv7NBbVW1TZyKjQjQReouMeH5UQ==} + '@rolldown/binding-win32-x64-msvc@1.2.6': + resolution: {integrity: sha512-np8iZSLfXlAD4kWhiyq/u0Yt8oZDtRQ8lGhQaCXo2rl37KNjeU0GjJuwr4P3oeZ++ROfofsKNBqR5LTO8aXyWQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [win32] @@ -2074,8 +2085,8 @@ packages: resolution: {integrity: sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==} engines: {node: '>=16.20.0'} - postcss@8.5.25: - resolution: {integrity: sha512-DTPx3RWSSnWyzLxQnlH0rJP+EW5ekl16ZU4/psbIhA0e53kJfdgaN5vKM+xP7yJtXVu+nfdVFmlgFDEKAe4Pyw==} + postcss@8.5.26: + resolution: {integrity: sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==} engines: {node: ^10 || ^12 || >=14} protobufjs@7.6.5: @@ -2131,13 +2142,13 @@ packages: vue-tsc: optional: true - rolldown@1.1.5: - resolution: {integrity: sha512-t9z29cJjXf/vxQ8dyhCSpt6H6aSwHTk8cT5I3iy6SMXuFpk5mB6PL6XfC8PCwrPTx93udwKUm9HRteAlTGBLiA==} + rolldown@1.2.1: + resolution: {integrity: sha512-4FKJhg8d3OiyQOA6Q1Q0hoFFpW9/OoX+VsHzpECsdsIZoOArrAK90gl59YK/Z+gnDel45bgJZK03ozH/9bCqEw==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true - rolldown@1.2.1: - resolution: {integrity: sha512-4FKJhg8d3OiyQOA6Q1Q0hoFFpW9/OoX+VsHzpECsdsIZoOArrAK90gl59YK/Z+gnDel45bgJZK03ozH/9bCqEw==} + rolldown@1.2.6: + resolution: {integrity: sha512-vMM4q3aixf46GiF1Kok8jDPFsEpXgFWGjUHXNkNHNm+Y2adXAG2dbX91jkti3i0ZRsOlcmbuzAz1poObSHCmUA==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true @@ -2330,13 +2341,13 @@ packages: resolution: {integrity: sha512-w2Eo8LSIIoW7qxNBzT7/17k+bh8plXo7G3dHjEIDqPlnluhzaxr9JX8F28VSYEtDvc1/a3WBDih6xNUZseebXg==} engines: {node: '>=18.12.0'} - vite@8.1.5: - resolution: {integrity: sha512-7ULLwsCdYx/nRyrpiEwvqb5TFHrMVZyBt+rg/OAXT7rgj/z+DtTDyKFeLAdDkubDVDKD8jOsndmy7m55XcfUsw==} + vite@8.2.2: + resolution: {integrity: sha512-cFKLV/PRgAUlIRm5WjMjJ86jrftzpqcgH+Us+DS8mI3CDNiH30Whrz8uHL3+MOLPAgqbMBAqWdAHAphOAM+z/Q==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true peerDependencies: '@types/node': ^20.19.0 || >=22.12.0 - '@vitejs/devtools': ^0.3.0 + '@vitejs/devtools': ^0.4.0 || ^0.5.0 esbuild: ^0.27.0 || ^0.28.0 jiti: '>=1.21.0' less: ^4.0.0 @@ -2643,33 +2654,17 @@ snapshots: '@biomejs/cli-win32-x64@2.5.9': optional: true - '@emnapi/core@1.11.1': - dependencies: - '@emnapi/wasi-threads': 1.2.2 - tslib: 2.8.1 - optional: true - '@emnapi/core@2.0.0-alpha.3': dependencies: '@emnapi/wasi-threads': 2.0.1 tslib: 2.8.1 optional: true - '@emnapi/runtime@1.11.1': - dependencies: - tslib: 2.8.1 - optional: true - '@emnapi/runtime@2.0.0-alpha.3': dependencies: tslib: 2.8.1 optional: true - '@emnapi/wasi-threads@1.2.2': - dependencies: - tslib: 2.8.1 - optional: true - '@emnapi/wasi-threads@2.0.1': dependencies: tslib: 2.8.1 @@ -2790,13 +2785,6 @@ snapshots: dependencies: zod: 4.4.3 - '@napi-rs/wasm-runtime@1.2.1(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)': - dependencies: - '@emnapi/core': 1.11.1 - '@emnapi/runtime': 1.11.1 - '@tybys/wasm-util': 0.10.3 - optional: true - '@napi-rs/wasm-runtime@1.2.1(@emnapi/core@2.0.0-alpha.3)(@emnapi/runtime@2.0.0-alpha.3)': dependencies: '@emnapi/core': 2.0.0-alpha.3 @@ -2959,10 +2947,10 @@ snapshots: '@opentelemetry/semantic-conventions@1.43.0': {} - '@oxc-project/types@0.139.0': {} - '@oxc-project/types@0.142.0': {} + '@oxc-project/types@0.147.0': {} + '@protobufjs/aspromise@1.1.2': {} '@protobufjs/base64@1.1.2': {} @@ -2987,83 +2975,79 @@ snapshots: dependencies: quansync: 1.0.0 - '@rolldown/binding-android-arm64@1.1.5': + '@rolldown/binding-android-arm-eabi@1.2.6': optional: true '@rolldown/binding-android-arm64@1.2.1': optional: true - '@rolldown/binding-darwin-arm64@1.1.5': + '@rolldown/binding-android-arm64@1.2.6': optional: true '@rolldown/binding-darwin-arm64@1.2.1': optional: true - '@rolldown/binding-darwin-x64@1.1.5': + '@rolldown/binding-darwin-arm64@1.2.6': optional: true '@rolldown/binding-darwin-x64@1.2.1': optional: true - '@rolldown/binding-freebsd-x64@1.1.5': + '@rolldown/binding-darwin-x64@1.2.6': optional: true '@rolldown/binding-freebsd-x64@1.2.1': optional: true - '@rolldown/binding-linux-arm-gnueabihf@1.1.5': + '@rolldown/binding-freebsd-x64@1.2.6': optional: true '@rolldown/binding-linux-arm-gnueabihf@1.2.1': optional: true - '@rolldown/binding-linux-arm64-gnu@1.1.5': + '@rolldown/binding-linux-arm-gnueabihf@1.2.6': optional: true '@rolldown/binding-linux-arm64-gnu@1.2.1': optional: true - '@rolldown/binding-linux-arm64-musl@1.1.5': + '@rolldown/binding-linux-arm64-gnu@1.2.6': optional: true '@rolldown/binding-linux-arm64-musl@1.2.1': optional: true - '@rolldown/binding-linux-ppc64-gnu@1.1.5': + '@rolldown/binding-linux-arm64-musl@1.2.6': optional: true '@rolldown/binding-linux-ppc64-gnu@1.2.1': optional: true - '@rolldown/binding-linux-s390x-gnu@1.1.5': + '@rolldown/binding-linux-ppc64-gnu@1.2.6': optional: true '@rolldown/binding-linux-s390x-gnu@1.2.1': optional: true - '@rolldown/binding-linux-x64-gnu@1.1.5': + '@rolldown/binding-linux-s390x-gnu@1.2.6': optional: true '@rolldown/binding-linux-x64-gnu@1.2.1': optional: true - '@rolldown/binding-linux-x64-musl@1.1.5': + '@rolldown/binding-linux-x64-gnu@1.2.6': optional: true '@rolldown/binding-linux-x64-musl@1.2.1': optional: true - '@rolldown/binding-openharmony-arm64@1.1.5': + '@rolldown/binding-linux-x64-musl@1.2.6': optional: true '@rolldown/binding-openharmony-arm64@1.2.1': optional: true - '@rolldown/binding-wasm32-wasi@1.1.5': - dependencies: - '@emnapi/core': 1.11.1 - '@emnapi/runtime': 1.11.1 - '@napi-rs/wasm-runtime': 1.2.1(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1) + '@rolldown/binding-openharmony-arm64@1.2.6': optional: true '@rolldown/binding-wasm32-wasi@1.2.1': @@ -3073,18 +3057,18 @@ snapshots: '@napi-rs/wasm-runtime': 1.2.1(@emnapi/core@2.0.0-alpha.3)(@emnapi/runtime@2.0.0-alpha.3) optional: true - '@rolldown/binding-win32-arm64-msvc@1.1.5': - optional: true - '@rolldown/binding-win32-arm64-msvc@1.2.1': optional: true - '@rolldown/binding-win32-x64-msvc@1.1.5': + '@rolldown/binding-win32-arm64-msvc@1.2.6': optional: true '@rolldown/binding-win32-x64-msvc@1.2.1': optional: true + '@rolldown/binding-win32-x64-msvc@1.2.6': + optional: true + '@rolldown/pluginutils@1.0.1': {} '@stablelib/base64@1.0.1': {} @@ -3226,7 +3210,7 @@ snapshots: obug: 2.1.4 std-env: 4.2.0 tinyrainbow: 3.1.1 - vitest: 4.1.11(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(@vitest/coverage-v8@4.1.11)(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(tsx@4.23.12)) + vitest: 4.1.11(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(@vitest/coverage-v8@4.1.11)(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.1)(tsx@4.23.12)) '@vitest/expect@4.1.11': dependencies: @@ -3237,13 +3221,13 @@ snapshots: chai: 6.2.2 tinyrainbow: 3.1.1 - '@vitest/mocker@4.1.11(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(tsx@4.23.12))': + '@vitest/mocker@4.1.11(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.1)(tsx@4.23.12))': dependencies: '@vitest/spy': 4.1.11 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(tsx@4.23.12) + vite: 8.2.2(@types/node@24.13.3)(esbuild@0.28.1)(tsx@4.23.12) '@vitest/pretty-format@4.1.11': dependencies: @@ -3863,7 +3847,7 @@ snapshots: pkce-challenge@5.0.1: {} - postcss@8.5.25: + postcss@8.5.26: dependencies: nanoid: 3.3.18 picocolors: 1.1.1 @@ -3929,27 +3913,6 @@ snapshots: transitivePeerDependencies: - oxc-resolver - rolldown@1.1.5: - dependencies: - '@oxc-project/types': 0.139.0 - '@rolldown/pluginutils': 1.0.1 - optionalDependencies: - '@rolldown/binding-android-arm64': 1.1.5 - '@rolldown/binding-darwin-arm64': 1.1.5 - '@rolldown/binding-darwin-x64': 1.1.5 - '@rolldown/binding-freebsd-x64': 1.1.5 - '@rolldown/binding-linux-arm-gnueabihf': 1.1.5 - '@rolldown/binding-linux-arm64-gnu': 1.1.5 - '@rolldown/binding-linux-arm64-musl': 1.1.5 - '@rolldown/binding-linux-ppc64-gnu': 1.1.5 - '@rolldown/binding-linux-s390x-gnu': 1.1.5 - '@rolldown/binding-linux-x64-gnu': 1.1.5 - '@rolldown/binding-linux-x64-musl': 1.1.5 - '@rolldown/binding-openharmony-arm64': 1.1.5 - '@rolldown/binding-wasm32-wasi': 1.1.5 - '@rolldown/binding-win32-arm64-msvc': 1.1.5 - '@rolldown/binding-win32-x64-msvc': 1.1.5 - rolldown@1.2.1: dependencies: '@oxc-project/types': 0.142.0 @@ -3971,6 +3934,27 @@ snapshots: '@rolldown/binding-win32-arm64-msvc': 1.2.1 '@rolldown/binding-win32-x64-msvc': 1.2.1 + rolldown@1.2.6: + dependencies: + '@oxc-project/types': 0.147.0 + '@rolldown/pluginutils': 1.0.1 + optionalDependencies: + '@rolldown/binding-android-arm-eabi': 1.2.6 + '@rolldown/binding-android-arm64': 1.2.6 + '@rolldown/binding-darwin-arm64': 1.2.6 + '@rolldown/binding-darwin-x64': 1.2.6 + '@rolldown/binding-freebsd-x64': 1.2.6 + '@rolldown/binding-linux-arm-gnueabihf': 1.2.6 + '@rolldown/binding-linux-arm64-gnu': 1.2.6 + '@rolldown/binding-linux-arm64-musl': 1.2.6 + '@rolldown/binding-linux-ppc64-gnu': 1.2.6 + '@rolldown/binding-linux-s390x-gnu': 1.2.6 + '@rolldown/binding-linux-x64-gnu': 1.2.6 + '@rolldown/binding-linux-x64-musl': 1.2.6 + '@rolldown/binding-openharmony-arm64': 1.2.6 + '@rolldown/binding-win32-arm64-msvc': 1.2.6 + '@rolldown/binding-win32-x64-msvc': 1.2.6 + router@2.2.0(supports-color@7.2.0): dependencies: debug: 4.4.3(supports-color@7.2.0) @@ -4176,12 +4160,12 @@ snapshots: verkit@0.3.1: {} - vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(tsx@4.23.12): + vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.1)(tsx@4.23.12): dependencies: lightningcss: 1.33.0 picomatch: 4.0.5 - postcss: 8.5.25 - rolldown: 1.1.5 + postcss: 8.5.26 + rolldown: 1.2.6 tinyglobby: 0.2.17 optionalDependencies: '@types/node': 24.13.3 @@ -4189,10 +4173,10 @@ snapshots: fsevents: 2.3.3 tsx: 4.23.12 - vitest@4.1.11(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(@vitest/coverage-v8@4.1.11)(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(tsx@4.23.12)): + vitest@4.1.11(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(@vitest/coverage-v8@4.1.11)(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.1)(tsx@4.23.12)): dependencies: '@vitest/expect': 4.1.11 - '@vitest/mocker': 4.1.11(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(tsx@4.23.12)) + '@vitest/mocker': 4.1.11(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.1)(tsx@4.23.12)) '@vitest/pretty-format': 4.1.11 '@vitest/runner': 4.1.11 '@vitest/snapshot': 4.1.11 @@ -4209,7 +4193,7 @@ snapshots: tinyexec: 1.3.0 tinyglobby: 0.2.17 tinyrainbow: 3.1.1 - vite: 8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(tsx@4.23.12) + vite: 8.2.2(@types/node@24.13.3)(esbuild@0.28.1)(tsx@4.23.12) why-is-node-running: 2.3.0 optionalDependencies: '@opentelemetry/api': 1.9.1 From b3654d944d554ba768eceef2f7169ab206693d67 Mon Sep 17 00:00:00 2001 From: Tatsuro Shibamura Date: Sat, 29 Aug 2026 14:56:11 +0900 Subject: [PATCH 2/9] Expand the browser example into four pages Turn examples/05-browser into a multi-page Vite app: the existing chat page, a canvas page whose drawing primitives are client-side tools, a structured-output page that fills the page's fields from the typed response.value, and an Anthropic page that swaps AnthropicChatClient into the otherwise identical chat code. Shared DOM and storage helpers move to src/ui.ts. Co-Authored-By: Claude Fable 5 --- examples/05-browser/README.md | 37 +++--- examples/05-browser/anthropic.html | 52 ++++++++ examples/05-browser/canvas.html | 52 ++++++++ examples/05-browser/index.html | 6 + examples/05-browser/package.json | 1 + examples/05-browser/src/anthropic.ts | 112 ++++++++++++++++++ examples/05-browser/src/canvas.ts | 164 ++++++++++++++++++++++++++ examples/05-browser/src/main.ts | 95 ++++----------- examples/05-browser/src/structured.ts | 88 ++++++++++++++ examples/05-browser/src/style.css | 70 ++++++++++- examples/05-browser/src/ui.ts | 77 ++++++++++++ examples/05-browser/structured.html | 49 ++++++++ examples/05-browser/vite.config.ts | 15 +++ examples/README.md | 17 +-- pnpm-lock.yaml | 3 + 15 files changed, 738 insertions(+), 100 deletions(-) create mode 100644 examples/05-browser/anthropic.html create mode 100644 examples/05-browser/canvas.html create mode 100644 examples/05-browser/src/anthropic.ts create mode 100644 examples/05-browser/src/canvas.ts create mode 100644 examples/05-browser/src/structured.ts create mode 100644 examples/05-browser/src/ui.ts create mode 100644 examples/05-browser/structured.html create mode 100644 examples/05-browser/vite.config.ts diff --git a/examples/05-browser/README.md b/examples/05-browser/README.md index 4156e54..a1529ef 100644 --- a/examples/05-browser/README.md +++ b/examples/05-browser/README.md @@ -1,28 +1,31 @@ -# Browser example +# Browser examples -A Vite + vanilla TypeScript chat page that runs the agent loop entirely in the browser. It -demonstrates what only makes sense client-side: - -- **Streaming into the DOM** — the page iterates the run stream and appends text as it arrives. -- **Client-side tools** — the function-calling loop executes in the page, so tools reach browser - APIs directly: `set_theme` restyles the page, `get_local_time` reads the visitor's clock. -- **Session persistence in `localStorage`** — the session is plain JSON, so persisting it is - `JSON.stringify(session)` and resuming after a reload is `agent.deserializeSession(...)`. - -Run the dev server from the repository root (after `pnpm install` and `pnpm -r build`): +A Vite + vanilla TypeScript app whose pages each run the agent loop entirely in the browser. +Start the dev server from the repository root (after `pnpm install` and `pnpm -r build`): ```bash pnpm --filter example-05-browser dev ``` -Open the printed URL, paste an OpenAI API key into the page, and chat. Try: +Open the printed URL, paste an API key into the page, and try the pages — they link to each other: + +| Page | What it demonstrates | +| --- | --- | +| [`/`](index.html) — Chat | Streaming into the DOM, client-side tools (`set_theme`, `get_local_time`), and session persistence in `localStorage`: the session is plain JSON, so persisting it is `JSON.stringify(session)` and resuming after a reload is `agent.deserializeSession(...)` | +| [`/canvas.html`](canvas.html) — Canvas | An agent that paints: every drawing primitive is a `tool()` executing against a 2D canvas context, so one instruction becomes a series of tool calls rendered as they run | +| [`/structured.html`](structured.html) — Structured output | A schema passed as `responseFormat`; the typed, validated `response.value` fills the page's fields directly | +| [`/anthropic.html`](anthropic.html) — Anthropic | The chat page with `AnthropicChatClient` swapped in — the rest of the code is identical, which is the point | + +Suggested first prompts: > Switch the page to dark mode, then tell me my time zone. +> Draw a snowman on a blue background. + ## A note on API keys -The key you paste stays in the tab's memory; the page never stores it. But any key that reaches a -browser is readable by whoever uses that browser, which is why the OpenAI SDK requires the -explicit `dangerouslyAllowBrowser` opt-in this example sets. For anything beyond a local demo, run -agents server-side and keep provider credentials there — or point the **Base URL** field at a -proxy that holds the real key. +The key you paste stays in the tab's memory; the pages never store it. But any key that reaches a +browser is readable by whoever uses that browser, which is why both the OpenAI and Anthropic SDKs +require the explicit `dangerouslyAllowBrowser` opt-in these pages set. For anything beyond a local +demo, run agents server-side and keep provider credentials there — or point the **Base URL** field +at a proxy that holds the real key. diff --git a/examples/05-browser/anthropic.html b/examples/05-browser/anthropic.html new file mode 100644 index 0000000..5833a77 --- /dev/null +++ b/examples/05-browser/anthropic.html @@ -0,0 +1,52 @@ + + + + + + Agent Framework — Anthropic example + + + +
+
+ +

Same page, different provider

+

+ This page is the chat example with AnthropicChatClient swapped in — the rest + of the code is identical. Keys pasted here stay in memory — in production, run agents + server-side. +

+
+ + + +
+
+
+
+ + +
+
+ + + diff --git a/examples/05-browser/canvas.html b/examples/05-browser/canvas.html new file mode 100644 index 0000000..b220d00 --- /dev/null +++ b/examples/05-browser/canvas.html @@ -0,0 +1,52 @@ + + + + + + Agent Framework — canvas example + + + +
+
+ +

An agent that paints

+

+ Each drawing primitive is a client-side tool, so the function-calling loop renders on the + canvas as it runs. Keys pasted here stay in memory — in production, run agents server-side. +

+
+ + + +
+
+ +
+
+ + +
+
+ + + diff --git a/examples/05-browser/index.html b/examples/05-browser/index.html index 430e376..44ea054 100644 --- a/examples/05-browser/index.html +++ b/examples/05-browser/index.html @@ -9,6 +9,12 @@
+

Agent in the browser

The whole agent loop runs in this page. The key below stays in memory, but anything shipped to diff --git a/examples/05-browser/package.json b/examples/05-browser/package.json index 9c15672..e813fa3 100644 --- a/examples/05-browser/package.json +++ b/examples/05-browser/package.json @@ -13,6 +13,7 @@ "typecheck": "tsc -p tsconfig.json --noEmit" }, "dependencies": { + "@anthropic-ai/sdk": "^0.120.0", "@polymind-inc/agent-framework": "workspace:^", "openai": "catalog:", "zod": "^4.4.3" diff --git a/examples/05-browser/src/anthropic.ts b/examples/05-browser/src/anthropic.ts new file mode 100644 index 0000000..dafe819 --- /dev/null +++ b/examples/05-browser/src/anthropic.ts @@ -0,0 +1,112 @@ +/** + * Anthropic — the same browser chat, different provider. + * + * `AnthropicChatClient` is a `ChatClient` like any other, so the page code is identical to the + * OpenAI chat — only the client construction differs. The Anthropic SDK has the same explicit + * browser opt-in as OpenAI's, and CORS on the Anthropic API allows direct calls from a page. + * + * Run: `pnpm --filter example-05-browser dev`, then open /anthropic.html + */ +import Anthropic from '@anthropic-ai/sdk'; +import { Agent, type AgentSession, tool } from '@polymind-inc/agent-framework'; +import { AnthropicChatClient } from '@polymind-inc/agent-framework/anthropic'; +import { z } from 'zod'; +import { bubble, chip, element, errorText, readSettings } from './ui.js'; + +const setTheme = tool({ + name: 'set_theme', + description: 'Switch the page between the light and dark theme', + parameters: z.object({ theme: z.enum(['light', 'dark']) }), + execute: ({ theme }) => { + document.documentElement.dataset.theme = theme; + return `The page is now in ${theme} mode.`; + }, +}); + +const getLocalTime = tool({ + name: 'get_local_time', + description: "Read the visitor's current local time and time zone from the browser", + parameters: z.object({}), + execute: () => ({ + timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone, + localTime: new Date().toString(), + }), +}); + +const log = element('#log'); +const composer = element('#composer'); +const promptInput = element('#prompt'); +const sendButton = element('#send'); + +let agent: Agent | undefined; +let session: AgentSession | undefined; +let agentSettings = ''; + +function currentAgent(): Agent { + const settings = readSettings('claude-sonnet-4-5'); + const fingerprint = JSON.stringify(settings); + if (agent === undefined || fingerprint !== agentSettings) { + agent = new Agent({ + client: new AnthropicChatClient({ + model: settings.model, + client: new Anthropic({ + apiKey: settings.apiKey, + dangerouslyAllowBrowser: true, + ...(settings.baseURL === '' ? {} : { baseURL: settings.baseURL }), + }), + }), + name: 'BrowserAssistant', + instructions: + 'You are a cheerful assistant living inside a web page. Your tools can restyle the page ' + + "and read the visitor's clock. Answer briefly.", + tools: [setTheme, getLocalTime], + }); + agentSettings = fingerprint; + } + return agent; +} + +async function send(): Promise { + const text = promptInput.value.trim(); + if (text === '' || sendButton.disabled) { + return; + } + let active: Agent; + try { + active = currentAgent(); + } catch (error) { + bubble(log, 'error', errorText(error)); + return; + } + promptInput.value = ''; + sendButton.disabled = true; + bubble(log, 'user', text); + let reply: HTMLElement | undefined; + try { + session ??= active.createSession(); + const stream = active.run(text, { session }); + for await (const update of stream) { + for (const content of update.contents) { + if (content.type === 'function_call') { + chip(log, `tool: ${content.name}`); + } + } + if (update.text !== '') { + reply ??= bubble(log, 'assistant'); + reply.textContent += update.text; + reply.scrollIntoView({ block: 'end' }); + } + } + } catch (error) { + reply?.remove(); + bubble(log, 'error', errorText(error)); + } finally { + sendButton.disabled = false; + promptInput.focus(); + } +} + +composer.addEventListener('submit', (event) => { + event.preventDefault(); + void send(); +}); diff --git a/examples/05-browser/src/canvas.ts b/examples/05-browser/src/canvas.ts new file mode 100644 index 0000000..6fe5989 --- /dev/null +++ b/examples/05-browser/src/canvas.ts @@ -0,0 +1,164 @@ +/** + * Canvas — an agent that paints with client-side tools. + * + * Every drawing primitive is a `tool()` whose `execute` runs in the page against a 2D canvas + * context. The function-calling loop turns one instruction ("draw a snowman") into a series of + * tool calls, each rendered the moment it executes. The session lives in memory, so follow-up + * instructions ("now add a hat") keep building on the same picture. + * + * Run: `pnpm --filter example-05-browser dev`, then open /canvas.html + */ +import { Agent, type AgentSession, tool } from '@polymind-inc/agent-framework'; +import { OpenAIChatClient } from '@polymind-inc/agent-framework/openai'; +import OpenAI from 'openai'; +import { z } from 'zod'; +import { bubble, chip, element, errorText, readSettings } from './ui.js'; + +const canvas = element('#canvas'); +const context = canvas.getContext('2d'); +if (context === null) { + throw new Error('This browser does not provide a 2D canvas context.'); +} + +const color = z.string().describe('Any CSS color, e.g. "tomato" or "#1d76db"').default('#1d76db'); + +const drawCircle = tool({ + name: 'draw_circle', + description: 'Draw a filled circle on the canvas', + parameters: z.object({ x: z.number(), y: z.number(), radius: z.number().positive(), color }), + execute: (args) => { + context.fillStyle = args.color; + context.beginPath(); + context.arc(args.x, args.y, args.radius, 0, 2 * Math.PI); + context.fill(); + return `Circle at (${args.x}, ${args.y}).`; + }, +}); + +const drawRect = tool({ + name: 'draw_rect', + description: 'Draw a filled rectangle on the canvas', + parameters: z.object({ + x: z.number(), + y: z.number(), + width: z.number().positive(), + height: z.number().positive(), + color, + }), + execute: (args) => { + context.fillStyle = args.color; + context.fillRect(args.x, args.y, args.width, args.height); + return `Rectangle at (${args.x}, ${args.y}).`; + }, +}); + +const drawLine = tool({ + name: 'draw_line', + description: 'Draw a straight line on the canvas', + parameters: z.object({ + x1: z.number(), + y1: z.number(), + x2: z.number(), + y2: z.number(), + color, + lineWidth: z.number().positive().default(2), + }), + execute: (args) => { + context.strokeStyle = args.color; + context.lineWidth = args.lineWidth; + context.beginPath(); + context.moveTo(args.x1, args.y1); + context.lineTo(args.x2, args.y2); + context.stroke(); + return `Line from (${args.x1}, ${args.y1}) to (${args.x2}, ${args.y2}).`; + }, +}); + +const clearCanvas = tool({ + name: 'clear_canvas', + description: 'Erase everything on the canvas', + parameters: z.object({}), + execute: () => { + context.clearRect(0, 0, canvas.width, canvas.height); + return 'The canvas is blank.'; + }, +}); + +const log = element('#log'); +const composer = element('#composer'); +const promptInput = element('#prompt'); +const sendButton = element('#send'); + +let agent: Agent | undefined; +let session: AgentSession | undefined; +let agentSettings = ''; + +function currentAgent(): Agent { + const settings = readSettings('gpt-4o-mini'); + const fingerprint = JSON.stringify(settings); + if (agent === undefined || fingerprint !== agentSettings) { + agent = new Agent({ + client: new OpenAIChatClient({ + model: settings.model, + client: new OpenAI({ + apiKey: settings.apiKey, + dangerouslyAllowBrowser: true, + ...(settings.baseURL === '' ? {} : { baseURL: settings.baseURL }), + }), + }), + name: 'CanvasPainter', + instructions: + `You paint on a ${canvas.width}×${canvas.height} canvas whose origin is the top-left ` + + 'corner. Compose pictures from your drawing tools — several calls per request is normal. ' + + 'After drawing, describe what you made in one short sentence.', + tools: [drawCircle, drawRect, drawLine, clearCanvas], + }); + agentSettings = fingerprint; + } + return agent; +} + +async function send(): Promise { + const text = promptInput.value.trim(); + if (text === '' || sendButton.disabled) { + return; + } + let active: Agent; + try { + active = currentAgent(); + } catch (error) { + bubble(log, 'error', errorText(error)); + return; + } + promptInput.value = ''; + sendButton.disabled = true; + bubble(log, 'user', text); + let reply: HTMLElement | undefined; + try { + session ??= active.createSession(); + const stream = active.run(text, { session }); + for await (const update of stream) { + for (const content of update.contents) { + if (content.type === 'function_call') { + chip(log, `tool: ${content.name}`); + } + } + if (update.text !== '') { + reply ??= bubble(log, 'assistant'); + reply.textContent += update.text; + reply.scrollIntoView({ block: 'end' }); + } + } + } catch (error) { + reply?.remove(); + bubble(log, 'error', errorText(error)); + } finally { + sendButton.disabled = false; + promptInput.focus(); + } +} + +composer.addEventListener('submit', (event) => { + event.preventDefault(); + void send(); +}); diff --git a/examples/05-browser/src/main.ts b/examples/05-browser/src/main.ts index d7a73e5..98dd393 100644 --- a/examples/05-browser/src/main.ts +++ b/examples/05-browser/src/main.ts @@ -1,9 +1,9 @@ /** - * 05 — Running an agent in the browser. + * Chat — the agent loop running in the browser. * - * The whole agent loop — model calls, the function-calling loop, streaming — runs in the page. - * Tools can therefore touch browser APIs directly: one restyles the page, another reads the - * visitor's clock. The session is plain JSON, persisted to `localStorage` across reloads. + * The whole loop — model calls, function calling, streaming — runs in the page. Tools can + * therefore touch browser APIs directly: one restyles the page, another reads the visitor's + * clock. The session is plain JSON, persisted to `localStorage` across reloads. * * The API key entered in the page stays in this tab's memory, but anything shipped to a browser * is readable by its user — in production, run agents server-side and keep credentials there. @@ -14,6 +14,7 @@ import { Agent, type AgentSession, tool } from '@polymind-inc/agent-framework'; import { OpenAIChatClient } from '@polymind-inc/agent-framework/openai'; import OpenAI from 'openai'; import { z } from 'zod'; +import { bubble, chip, element, errorText, loadJson, readSettings, removeStored, saveJson } from './ui.js'; const SESSION_KEY = 'agent-framework-example.session'; const TRANSCRIPT_KEY = 'agent-framework-example.transcript'; @@ -39,17 +40,6 @@ const getLocalTime = tool({ }), }); -function element(selector: string): T { - const found = document.querySelector(selector); - if (found === null) { - throw new Error(`Missing element: ${selector}`); - } - return found; -} - -const apiKeyInput = element('#api-key'); -const baseUrlInput = element('#base-url'); -const modelInput = element('#model'); const log = element('#log'); const composer = element('#composer'); const promptInput = element('#prompt'); @@ -58,46 +48,13 @@ const clearButton = element('#clear'); type TranscriptEntry = { role: 'user' | 'assistant'; text: string }; -function bubble(kind: TranscriptEntry['role'] | 'error', text = ''): HTMLElement { - const el = document.createElement('div'); - el.className = `bubble ${kind}`; - el.textContent = text; - log.append(el); - el.scrollIntoView({ block: 'end' }); - return el; -} - -function chip(text: string): void { - const el = document.createElement('div'); - el.className = 'chip'; - el.textContent = text; - log.append(el); -} - -function loadJson(key: string): T | undefined { - try { - const raw = localStorage.getItem(key); - return raw === null ? undefined : (JSON.parse(raw) as T); - } catch { - return undefined; - } -} - -function saveJson(key: string, value: unknown): void { - try { - localStorage.setItem(key, JSON.stringify(value)); - } catch { - // Storage can be unavailable (private windows, quota); the page works without persistence. - } -} - // The session is the source of truth the model sees; the transcript only redraws past bubbles. const transcript: TranscriptEntry[] = loadJson(TRANSCRIPT_KEY) ?? []; for (const entry of transcript) { - bubble(entry.role, entry.text); + bubble(log, entry.role, entry.text); } if (transcript.length > 0) { - chip('conversation restored from localStorage'); + chip(log, 'conversation restored from localStorage'); } let agent: Agent | undefined; @@ -105,24 +62,18 @@ let session: AgentSession | undefined; let agentSettings = ''; function currentAgent(): Agent { - const apiKey = apiKeyInput.value.trim(); - if (apiKey === '') { - apiKeyInput.focus(); - throw new Error('Enter an API key first.'); - } - const baseURL = baseUrlInput.value.trim(); - const model = modelInput.value.trim() || 'gpt-4o-mini'; - const settings = JSON.stringify([apiKey, baseURL, model]); - if (agent === undefined || settings !== agentSettings) { + const settings = readSettings('gpt-4o-mini'); + const fingerprint = JSON.stringify(settings); + if (agent === undefined || fingerprint !== agentSettings) { agent = new Agent({ client: new OpenAIChatClient({ - model, + model: settings.model, // The OpenAI SDK refuses to run in a browser unless the risk of exposing the key is // acknowledged explicitly. Here the key is typed into the page and kept in memory only. client: new OpenAI({ - apiKey, + apiKey: settings.apiKey, dangerouslyAllowBrowser: true, - ...(baseURL === '' ? {} : { baseURL }), + ...(settings.baseURL === '' ? {} : { baseURL: settings.baseURL }), }), }), name: 'BrowserAssistant', @@ -131,7 +82,7 @@ function currentAgent(): Agent { "and read the visitor's clock. Answer briefly.", tools: [setTheme, getLocalTime], }); - agentSettings = settings; + agentSettings = fingerprint; } return agent; } @@ -153,12 +104,12 @@ async function send(): Promise { try { active = currentAgent(); } catch (error) { - bubble('error', error instanceof Error ? error.message : String(error)); + bubble(log, 'error', errorText(error)); return; } promptInput.value = ''; sendButton.disabled = true; - bubble('user', text); + bubble(log, 'user', text); transcript.push({ role: 'user', text }); let reply: HTMLElement | undefined; try { @@ -166,11 +117,11 @@ async function send(): Promise { for await (const update of stream) { for (const content of update.contents) { if (content.type === 'function_call') { - chip(`tool: ${content.name}`); + chip(log, `tool: ${content.name}`); } } if (update.text !== '') { - reply ??= bubble('assistant'); + reply ??= bubble(log, 'assistant'); reply.textContent += update.text; reply.scrollIntoView({ block: 'end' }); } @@ -180,7 +131,7 @@ async function send(): Promise { saveJson(TRANSCRIPT_KEY, transcript); } catch (error) { reply?.remove(); - bubble('error', error instanceof Error ? error.message : String(error)); + bubble(log, 'error', errorText(error)); } finally { sendButton.disabled = false; promptInput.focus(); @@ -196,10 +147,6 @@ clearButton.addEventListener('click', () => { session = undefined; transcript.length = 0; log.replaceChildren(); - try { - localStorage.removeItem(SESSION_KEY); - localStorage.removeItem(TRANSCRIPT_KEY); - } catch { - // Storage can be unavailable; there is then nothing to clear. - } + removeStored(SESSION_KEY); + removeStored(TRANSCRIPT_KEY); }); diff --git a/examples/05-browser/src/structured.ts b/examples/05-browser/src/structured.ts new file mode 100644 index 0000000..2ac1503 --- /dev/null +++ b/examples/05-browser/src/structured.ts @@ -0,0 +1,88 @@ +/** + * Structured output — typed extraction rendered straight into the page. + * + * Pass a schema as `responseFormat` and the parsed, validated value lands on `response.value`, + * typed from the schema — no JSON.parse, no manual checks. Here the value fills a definition + * list, which is exactly the kind of UI code that benefits from the value already being typed. + * + * Run: `pnpm --filter example-05-browser dev`, then open /structured.html + */ +import { Agent } from '@polymind-inc/agent-framework'; +import { OpenAIChatClient } from '@polymind-inc/agent-framework/openai'; +import OpenAI from 'openai'; +import { z } from 'zod'; +import { bubble, element, errorText, readSettings } from './ui.js'; + +const EventInfo = z.object({ + title: z.string(), + date: z.string().describe('ISO 8601 date'), + location: z.string(), + attendees: z.array(z.string()), +}); + +const log = element('#log'); +const form = element('#composer'); +const input = element('#source'); +const extractButton = element('#extract'); +const result = element('#result'); + +function renderValue(value: z.infer): void { + const rows: [string, string][] = [ + ['Title', value.title], + ['Date', value.date], + ['Location', value.location], + ['Attendees', value.attendees.join(', ')], + ]; + const dl = document.createElement('dl'); + for (const [term, detail] of rows) { + const dt = document.createElement('dt'); + dt.textContent = term; + const dd = document.createElement('dd'); + dd.textContent = detail; + dl.append(dt, dd); + } + result.replaceChildren(dl); +} + +async function extract(): Promise { + const text = input.value.trim(); + if (text === '' || extractButton.disabled) { + return; + } + let agent: Agent; + try { + const settings = readSettings('gpt-4o-mini'); + agent = new Agent({ + client: new OpenAIChatClient({ + model: settings.model, + client: new OpenAI({ + apiKey: settings.apiKey, + dangerouslyAllowBrowser: true, + ...(settings.baseURL === '' ? {} : { baseURL: settings.baseURL }), + }), + }), + instructions: 'Extract the event described in the user text.', + }); + } catch (error) { + bubble(log, 'error', errorText(error)); + return; + } + extractButton.disabled = true; + try { + const response = await agent.run(text, { responseFormat: EventInfo }); + // A suspended response has no value yet, so narrow it before use. + if (response.value === undefined) { + throw new Error('The run stopped before producing its structured output.'); + } + renderValue(response.value); + } catch (error) { + bubble(log, 'error', errorText(error)); + } finally { + extractButton.disabled = false; + } +} + +form.addEventListener('submit', (event) => { + event.preventDefault(); + void extract(); +}); diff --git a/examples/05-browser/src/style.css b/examples/05-browser/src/style.css index 42d31bf..1adbdd6 100644 --- a/examples/05-browser/src/style.css +++ b/examples/05-browser/src/style.css @@ -44,6 +44,27 @@ h1 { font-size: 1.4rem; } +nav { + display: flex; + gap: 1rem; + margin-bottom: 0.75rem; + font-size: 0.85rem; +} + +nav a { + color: var(--muted); + text-decoration: none; +} + +nav a[aria-current="page"] { + color: var(--accent); + font-weight: 600; +} + +nav a:hover { + color: var(--accent); +} + .warning { margin: 0 0 0.75rem; padding: 0.5rem 0.75rem; @@ -129,21 +150,66 @@ input:focus { font-size: 0.75rem; } +#canvas { + align-self: center; + border: 1px solid var(--border); + border-radius: 6px; + background: #ffffff; + max-width: 100%; +} + +textarea { + font: inherit; + width: 100%; + padding: 0.5rem 0.65rem; + border: 1px solid var(--border); + border-radius: 6px; + background: var(--panel); + color: var(--text); + resize: vertical; +} + +#result dl { + display: grid; + grid-template-columns: auto 1fr; + gap: 0.35rem 1rem; + margin: 0; + padding: 0.75rem 1rem; + border: 1px solid var(--border); + border-radius: 10px; + background: var(--panel); +} + +#result dt { + color: var(--muted); +} + +#result dd { + margin: 0; +} + #composer { display: flex; gap: 0.5rem; } +#composer:has(textarea) { + flex-direction: column; + align-items: flex-start; +} + #prompt { flex: 1; } -#send { +#send, +#extract { background: var(--accent); border-color: var(--accent); color: #ffffff; } -#send:disabled { +#send:disabled, +#extract:disabled { opacity: 0.6; } diff --git a/examples/05-browser/src/ui.ts b/examples/05-browser/src/ui.ts new file mode 100644 index 0000000..3623ed9 --- /dev/null +++ b/examples/05-browser/src/ui.ts @@ -0,0 +1,77 @@ +/** Small DOM and storage helpers shared by every page of this example. */ + +export function element(selector: string): T { + const found = document.querySelector(selector); + if (found === null) { + throw new Error(`Missing element: ${selector}`); + } + return found; +} + +export type BubbleKind = 'user' | 'assistant' | 'error'; + +export function bubble(log: HTMLElement, kind: BubbleKind, text = ''): HTMLElement { + const el = document.createElement('div'); + el.className = `bubble ${kind}`; + el.textContent = text; + log.append(el); + el.scrollIntoView({ block: 'end' }); + return el; +} + +export function chip(log: HTMLElement, text: string): void { + const el = document.createElement('div'); + el.className = 'chip'; + el.textContent = text; + log.append(el); +} + +export function loadJson(key: string): T | undefined { + try { + const raw = localStorage.getItem(key); + return raw === null ? undefined : (JSON.parse(raw) as T); + } catch { + return undefined; + } +} + +export function saveJson(key: string, value: unknown): void { + try { + localStorage.setItem(key, JSON.stringify(value)); + } catch { + // Storage can be unavailable (private windows, quota); the page works without persistence. + } +} + +export function removeStored(key: string): void { + try { + localStorage.removeItem(key); + } catch { + // Storage can be unavailable; there is then nothing to remove. + } +} + +export interface Settings { + apiKey: string; + baseURL: string; + model: string; +} + +/** Read the API key / base URL / model row every page shares; throws until a key is entered. */ +export function readSettings(defaultModel: string): Settings { + const apiKeyInput = element('#api-key'); + const apiKey = apiKeyInput.value.trim(); + if (apiKey === '') { + apiKeyInput.focus(); + throw new Error('Enter an API key first.'); + } + return { + apiKey, + baseURL: element('#base-url').value.trim(), + model: element('#model').value.trim() || defaultModel, + }; +} + +export function errorText(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} diff --git a/examples/05-browser/structured.html b/examples/05-browser/structured.html new file mode 100644 index 0000000..936bb37 --- /dev/null +++ b/examples/05-browser/structured.html @@ -0,0 +1,49 @@ + + + + + + Agent Framework — structured output example + + + +

+
+ +

Typed extraction into the page

+

+ The schema-validated response.value fills the fields below directly — no + JSON.parse in sight. Keys pasted here stay in memory — in production, run agents server-side. +

+
+ + + +
+
+
+ + +
+
+
+
+ + + diff --git a/examples/05-browser/vite.config.ts b/examples/05-browser/vite.config.ts new file mode 100644 index 0000000..8e44e8d --- /dev/null +++ b/examples/05-browser/vite.config.ts @@ -0,0 +1,15 @@ +import { resolve } from 'node:path'; +import { defineConfig } from 'vite'; + +export default defineConfig({ + build: { + rollupOptions: { + input: { + index: resolve(import.meta.dirname, 'index.html'), + canvas: resolve(import.meta.dirname, 'canvas.html'), + structured: resolve(import.meta.dirname, 'structured.html'), + anthropic: resolve(import.meta.dirname, 'anthropic.html'), + }, + }, + }, +}); diff --git a/examples/README.md b/examples/README.md index 7accb76..7a9936a 100644 --- a/examples/README.md +++ b/examples/README.md @@ -76,11 +76,14 @@ pnpm --filter example-04-a2a server ## Browser -A chat page that runs the agent loop entirely in the browser: streaming into the DOM, client-side -tools that reach browser APIs, and session persistence in `localStorage`. The OpenAI API key is +One Vite app, four pages, each running the agent loop entirely in the browser. API keys are pasted into the page and kept in memory — see [`05-browser/README.md`](05-browser/README.md) for -why production agents belong server-side. - -| Example | What it demonstrates | Command | -| --- | --- | --- | -| Browser chat | The agent loop, streaming, tools, and sessions running in a web page | `pnpm --filter example-05-browser dev` | +why production agents belong server-side. Start with `pnpm --filter example-05-browser dev`, then +open the page: + +| Page | What it demonstrates | +| --- | --- | +| `/` | Chat: streaming into the DOM, client-side tools, session persistence in `localStorage` | +| `/canvas.html` | An agent that paints — every drawing primitive is a tool executing against a canvas | +| `/structured.html` | Typed extraction: the schema-validated `response.value` fills the page's fields | +| `/anthropic.html` | The chat page with `AnthropicChatClient` swapped in; the page code is otherwise identical | diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 18e998a..4a7654b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -141,6 +141,9 @@ importers: examples/05-browser: dependencies: + '@anthropic-ai/sdk': + specifier: ^0.120.0 + version: 0.120.0(zod@4.4.3) '@polymind-inc/agent-framework': specifier: workspace:^ version: link:../../packages/meta From 140ccf56706563d816252e79926ecd4d2e12f4ed Mon Sep 17 00:00:00 2001 From: Tatsuro Shibamura Date: Sat, 29 Aug 2026 15:01:20 +0900 Subject: [PATCH 3/9] Address browser-example review feedback Label the chat, drawing and extraction inputs for screen readers, recover from an unrestorable saved session by starting a fresh one, and skip persisting an assistant transcript entry when a run produced no text. Co-Authored-By: Claude Fable 5 --- examples/05-browser/anthropic.html | 1 + examples/05-browser/canvas.html | 1 + examples/05-browser/index.html | 1 + examples/05-browser/src/main.ts | 16 ++++++++++++++-- examples/05-browser/structured.html | 2 +- 5 files changed, 18 insertions(+), 3 deletions(-) diff --git a/examples/05-browser/anthropic.html b/examples/05-browser/anthropic.html index 5833a77..bdda2d8 100644 --- a/examples/05-browser/anthropic.html +++ b/examples/05-browser/anthropic.html @@ -41,6 +41,7 @@

Same page, different provider

diff --git a/examples/05-browser/canvas.html b/examples/05-browser/canvas.html index b220d00..4aa3a49 100644 --- a/examples/05-browser/canvas.html +++ b/examples/05-browser/canvas.html @@ -41,6 +41,7 @@

An agent that paints

diff --git a/examples/05-browser/index.html b/examples/05-browser/index.html index 44ea054..317dcb4 100644 --- a/examples/05-browser/index.html +++ b/examples/05-browser/index.html @@ -41,6 +41,7 @@

Agent in the browser

diff --git a/examples/05-browser/src/main.ts b/examples/05-browser/src/main.ts index 98dd393..44933e7 100644 --- a/examples/05-browser/src/main.ts +++ b/examples/05-browser/src/main.ts @@ -90,7 +90,16 @@ function currentAgent(): Agent { function currentSession(active: Agent): AgentSession { if (session === undefined) { const saved = loadJson(SESSION_KEY); - session = saved === undefined ? active.createSession() : active.deserializeSession(saved); + if (saved !== undefined) { + try { + session = active.deserializeSession(saved); + } catch { + // Corrupted or incompatible saved state would fail every send; drop it and start over. + removeStored(SESSION_KEY); + chip(log, 'saved session could not be restored; starting fresh'); + } + } + session ??= active.createSession(); } return session; } @@ -126,7 +135,10 @@ async function send(): Promise { reply.scrollIntoView({ block: 'end' }); } } - transcript.push({ role: 'assistant', text: reply?.textContent ?? '' }); + const replyText = reply?.textContent ?? ''; + if (replyText !== '') { + transcript.push({ role: 'assistant', text: replyText }); + } saveJson(SESSION_KEY, session); saveJson(TRANSCRIPT_KEY, transcript); } catch (error) { diff --git a/examples/05-browser/structured.html b/examples/05-browser/structured.html index 936bb37..8465c3c 100644 --- a/examples/05-browser/structured.html +++ b/examples/05-browser/structured.html @@ -36,7 +36,7 @@

Typed extraction into the page

- From 5cc6eb4246c3c121e0365f203fd5a15ce623144f Mon Sep 17 00:00:00 2001 From: Tatsuro Shibamura Date: Sat, 29 Aug 2026 15:05:42 +0900 Subject: [PATCH 4/9] State precisely what the Anthropic browser page shares with the chat page The page intentionally omits the chat page's localStorage persistence and Clear button, so the docs no longer call the code identical: the agent, tools and streaming code are what carries over unchanged. Co-Authored-By: Claude Fable 5 --- examples/05-browser/README.md | 2 +- examples/05-browser/anthropic.html | 6 +++--- examples/05-browser/src/anthropic.ts | 8 +++++--- examples/README.md | 2 +- 4 files changed, 10 insertions(+), 8 deletions(-) diff --git a/examples/05-browser/README.md b/examples/05-browser/README.md index a1529ef..21d08af 100644 --- a/examples/05-browser/README.md +++ b/examples/05-browser/README.md @@ -14,7 +14,7 @@ Open the printed URL, paste an API key into the page, and try the pages — they | [`/`](index.html) — Chat | Streaming into the DOM, client-side tools (`set_theme`, `get_local_time`), and session persistence in `localStorage`: the session is plain JSON, so persisting it is `JSON.stringify(session)` and resuming after a reload is `agent.deserializeSession(...)` | | [`/canvas.html`](canvas.html) — Canvas | An agent that paints: every drawing primitive is a `tool()` executing against a 2D canvas context, so one instruction becomes a series of tool calls rendered as they run | | [`/structured.html`](structured.html) — Structured output | A schema passed as `responseFormat`; the typed, validated `response.value` fills the page's fields directly | -| [`/anthropic.html`](anthropic.html) — Anthropic | The chat page with `AnthropicChatClient` swapped in — the rest of the code is identical, which is the point | +| [`/anthropic.html`](anthropic.html) — Anthropic | The chat page with `AnthropicChatClient` swapped in — the agent, tools and streaming code carry over unchanged, which is the point (it skips the chat page's persistence to stay small) | Suggested first prompts: diff --git a/examples/05-browser/anthropic.html b/examples/05-browser/anthropic.html index bdda2d8..e7cb03a 100644 --- a/examples/05-browser/anthropic.html +++ b/examples/05-browser/anthropic.html @@ -17,9 +17,9 @@

Same page, different provider

- This page is the chat example with AnthropicChatClient swapped in — the rest - of the code is identical. Keys pasted here stay in memory — in production, run agents - server-side. + This page is the chat example with AnthropicChatClient swapped in — the + agent, tools and streaming code are unchanged. Keys pasted here stay in memory — in + production, run agents server-side.

-
+
An agent that paints -
+
Agent in the browser -
+
Typed extraction into the page
-
+
From 85199717535504bf6d31bbf54d0f16159ccc55b6 Mon Sep 17 00:00:00 2001 From: Tatsuro Shibamura Date: Sat, 29 Aug 2026 15:15:58 +0900 Subject: [PATCH 6/9] Streamline log rendering during streamed runs Append each streamed chunk as a text node instead of rewriting the bubble's whole text content per chunk, and scroll newly added tool chips into view so tool-only turns stay visible. Co-Authored-By: Claude Fable 5 --- examples/05-browser/src/anthropic.ts | 2 +- examples/05-browser/src/canvas.ts | 2 +- examples/05-browser/src/main.ts | 2 +- examples/05-browser/src/ui.ts | 1 + 4 files changed, 4 insertions(+), 3 deletions(-) diff --git a/examples/05-browser/src/anthropic.ts b/examples/05-browser/src/anthropic.ts index 5506800..2601fb2 100644 --- a/examples/05-browser/src/anthropic.ts +++ b/examples/05-browser/src/anthropic.ts @@ -95,7 +95,7 @@ async function send(): Promise { } if (update.text !== '') { reply ??= bubble(log, 'assistant'); - reply.textContent += update.text; + reply.append(update.text); reply.scrollIntoView({ block: 'end' }); } } diff --git a/examples/05-browser/src/canvas.ts b/examples/05-browser/src/canvas.ts index 6fe5989..0d83303 100644 --- a/examples/05-browser/src/canvas.ts +++ b/examples/05-browser/src/canvas.ts @@ -145,7 +145,7 @@ async function send(): Promise { } if (update.text !== '') { reply ??= bubble(log, 'assistant'); - reply.textContent += update.text; + reply.append(update.text); reply.scrollIntoView({ block: 'end' }); } } diff --git a/examples/05-browser/src/main.ts b/examples/05-browser/src/main.ts index 44933e7..06f643a 100644 --- a/examples/05-browser/src/main.ts +++ b/examples/05-browser/src/main.ts @@ -131,7 +131,7 @@ async function send(): Promise { } if (update.text !== '') { reply ??= bubble(log, 'assistant'); - reply.textContent += update.text; + reply.append(update.text); reply.scrollIntoView({ block: 'end' }); } } diff --git a/examples/05-browser/src/ui.ts b/examples/05-browser/src/ui.ts index 3623ed9..473760f 100644 --- a/examples/05-browser/src/ui.ts +++ b/examples/05-browser/src/ui.ts @@ -24,6 +24,7 @@ export function chip(log: HTMLElement, text: string): void { el.className = 'chip'; el.textContent = text; log.append(el); + el.scrollIntoView({ block: 'end' }); } export function loadJson(key: string): T | undefined { From ac889210e16943304f806c7f67e4ce31f0f2eac3 Mon Sep 17 00:00:00 2001 From: Tatsuro Shibamura Date: Sat, 29 Aug 2026 15:22:16 +0900 Subject: [PATCH 7/9] Harden the chat page against stored-state edge cases Validate the transcript restored from localStorage and drop it when it is not the shape this page writes, and disable the Clear button while a send is in flight so clearing cannot race the streaming loop that repopulates the log and storage. Co-Authored-By: Claude Fable 5 --- examples/05-browser/src/main.ts | 26 +++++++++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/examples/05-browser/src/main.ts b/examples/05-browser/src/main.ts index 06f643a..6a86356 100644 --- a/examples/05-browser/src/main.ts +++ b/examples/05-browser/src/main.ts @@ -48,8 +48,29 @@ const clearButton = element('#clear'); type TranscriptEntry = { role: 'user' | 'assistant'; text: string }; +function isTranscriptEntry(value: unknown): value is TranscriptEntry { + if (typeof value !== 'object' || value === null) { + return false; + } + const entry = value as Record; + return (entry.role === 'user' || entry.role === 'assistant') && typeof entry.text === 'string'; +} + +function restoreTranscript(): TranscriptEntry[] { + const stored = loadJson(TRANSCRIPT_KEY); + if (stored === undefined) { + return []; + } + if (Array.isArray(stored) && stored.every(isTranscriptEntry)) { + return stored; + } + // Anything else parses but is not what this page wrote; drop it rather than render garbage. + removeStored(TRANSCRIPT_KEY); + return []; +} + // The session is the source of truth the model sees; the transcript only redraws past bubbles. -const transcript: TranscriptEntry[] = loadJson(TRANSCRIPT_KEY) ?? []; +const transcript: TranscriptEntry[] = restoreTranscript(); for (const entry of transcript) { bubble(log, entry.role, entry.text); } @@ -118,6 +139,8 @@ async function send(): Promise { } promptInput.value = ''; sendButton.disabled = true; + // Clearing mid-run would race the in-flight loop, which repopulates the log and storage. + clearButton.disabled = true; bubble(log, 'user', text); transcript.push({ role: 'user', text }); let reply: HTMLElement | undefined; @@ -146,6 +169,7 @@ async function send(): Promise { bubble(log, 'error', errorText(error)); } finally { sendButton.disabled = false; + clearButton.disabled = false; promptInput.focus(); } } From 647f481faebb1a4c4f4f1e73f331e8559735bf1b Mon Sep 17 00:00:00 2001 From: Tatsuro Shibamura Date: Sat, 29 Aug 2026 15:26:34 +0900 Subject: [PATCH 8/9] Declare @types/node for the browser example's Vite peer dependency Vite lists @types/node as an optional peer; declaring it matches the other examples instead of relying on workspace hoisting. The example's sources still typecheck without Node types via the neutral tsconfig. Co-Authored-By: Claude Fable 5 --- examples/05-browser/package.json | 1 + pnpm-lock.yaml | 3 +++ 2 files changed, 4 insertions(+) diff --git a/examples/05-browser/package.json b/examples/05-browser/package.json index e813fa3..b93a879 100644 --- a/examples/05-browser/package.json +++ b/examples/05-browser/package.json @@ -19,6 +19,7 @@ "zod": "^4.4.3" }, "devDependencies": { + "@types/node": "catalog:", "typescript": "catalog:", "vite": "^8.2.2" } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4a7654b..80379a0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -154,6 +154,9 @@ importers: specifier: ^4.4.3 version: 4.4.3 devDependencies: + '@types/node': + specifier: 'catalog:' + version: 24.13.3 typescript: specifier: 'catalog:' version: 7.0.2 From 90132067d9d4b49c6a5998b9fdd1fcb55acbeab0 Mon Sep 17 00:00:00 2001 From: Tatsuro Shibamura Date: Sat, 29 Aug 2026 15:33:45 +0900 Subject: [PATCH 9/9] Stream reply text into a single mutated text node Appending one text node per streamed chunk builds up thousands of DOM nodes on long responses. A shared streamingBubble helper now creates the bubble on the first chunk and grows one Text node via appendData, replacing the hand-rolled reply handling on all three streaming pages. Co-Authored-By: Claude Fable 5 --- examples/05-browser/src/anthropic.ts | 8 +++----- examples/05-browser/src/canvas.ts | 8 +++----- examples/05-browser/src/main.ts | 20 ++++++++++++++------ examples/05-browser/src/ui.ts | 26 ++++++++++++++++++++++++++ 4 files changed, 46 insertions(+), 16 deletions(-) diff --git a/examples/05-browser/src/anthropic.ts b/examples/05-browser/src/anthropic.ts index 2601fb2..3980c0e 100644 --- a/examples/05-browser/src/anthropic.ts +++ b/examples/05-browser/src/anthropic.ts @@ -13,7 +13,7 @@ import Anthropic from '@anthropic-ai/sdk'; import { Agent, type AgentSession, tool } from '@polymind-inc/agent-framework'; import { AnthropicChatClient } from '@polymind-inc/agent-framework/anthropic'; import { z } from 'zod'; -import { bubble, chip, element, errorText, readSettings } from './ui.js'; +import { bubble, chip, element, errorText, readSettings, streamingBubble } from './ui.js'; const setTheme = tool({ name: 'set_theme', @@ -83,7 +83,7 @@ async function send(): Promise { promptInput.value = ''; sendButton.disabled = true; bubble(log, 'user', text); - let reply: HTMLElement | undefined; + const reply = streamingBubble(log); try { session ??= active.createSession(); const stream = active.run(text, { session }); @@ -94,13 +94,11 @@ async function send(): Promise { } } if (update.text !== '') { - reply ??= bubble(log, 'assistant'); reply.append(update.text); - reply.scrollIntoView({ block: 'end' }); } } } catch (error) { - reply?.remove(); + reply.remove(); bubble(log, 'error', errorText(error)); } finally { sendButton.disabled = false; diff --git a/examples/05-browser/src/canvas.ts b/examples/05-browser/src/canvas.ts index 0d83303..3e13979 100644 --- a/examples/05-browser/src/canvas.ts +++ b/examples/05-browser/src/canvas.ts @@ -12,7 +12,7 @@ import { Agent, type AgentSession, tool } from '@polymind-inc/agent-framework'; import { OpenAIChatClient } from '@polymind-inc/agent-framework/openai'; import OpenAI from 'openai'; import { z } from 'zod'; -import { bubble, chip, element, errorText, readSettings } from './ui.js'; +import { bubble, chip, element, errorText, readSettings, streamingBubble } from './ui.js'; const canvas = element('#canvas'); const context = canvas.getContext('2d'); @@ -133,7 +133,7 @@ async function send(): Promise { promptInput.value = ''; sendButton.disabled = true; bubble(log, 'user', text); - let reply: HTMLElement | undefined; + const reply = streamingBubble(log); try { session ??= active.createSession(); const stream = active.run(text, { session }); @@ -144,13 +144,11 @@ async function send(): Promise { } } if (update.text !== '') { - reply ??= bubble(log, 'assistant'); reply.append(update.text); - reply.scrollIntoView({ block: 'end' }); } } } catch (error) { - reply?.remove(); + reply.remove(); bubble(log, 'error', errorText(error)); } finally { sendButton.disabled = false; diff --git a/examples/05-browser/src/main.ts b/examples/05-browser/src/main.ts index 6a86356..5e0c5e4 100644 --- a/examples/05-browser/src/main.ts +++ b/examples/05-browser/src/main.ts @@ -14,7 +14,17 @@ import { Agent, type AgentSession, tool } from '@polymind-inc/agent-framework'; import { OpenAIChatClient } from '@polymind-inc/agent-framework/openai'; import OpenAI from 'openai'; import { z } from 'zod'; -import { bubble, chip, element, errorText, loadJson, readSettings, removeStored, saveJson } from './ui.js'; +import { + bubble, + chip, + element, + errorText, + loadJson, + readSettings, + removeStored, + saveJson, + streamingBubble, +} from './ui.js'; const SESSION_KEY = 'agent-framework-example.session'; const TRANSCRIPT_KEY = 'agent-framework-example.transcript'; @@ -143,7 +153,7 @@ async function send(): Promise { clearButton.disabled = true; bubble(log, 'user', text); transcript.push({ role: 'user', text }); - let reply: HTMLElement | undefined; + const reply = streamingBubble(log); try { const stream = active.run(text, { session: currentSession(active) }); for await (const update of stream) { @@ -153,19 +163,17 @@ async function send(): Promise { } } if (update.text !== '') { - reply ??= bubble(log, 'assistant'); reply.append(update.text); - reply.scrollIntoView({ block: 'end' }); } } - const replyText = reply?.textContent ?? ''; + const replyText = reply.text(); if (replyText !== '') { transcript.push({ role: 'assistant', text: replyText }); } saveJson(SESSION_KEY, session); saveJson(TRANSCRIPT_KEY, transcript); } catch (error) { - reply?.remove(); + reply.remove(); bubble(log, 'error', errorText(error)); } finally { sendButton.disabled = false; diff --git a/examples/05-browser/src/ui.ts b/examples/05-browser/src/ui.ts index 473760f..b90750b 100644 --- a/examples/05-browser/src/ui.ts +++ b/examples/05-browser/src/ui.ts @@ -19,6 +19,32 @@ export function bubble(log: HTMLElement, kind: BubbleKind, text = ''): HTMLEleme return el; } +/** + * An assistant bubble that appears on the first streamed chunk and grows by mutating a single + * text node, so a long response stays one DOM node instead of one per chunk. + */ +export function streamingBubble(log: HTMLElement): { + append(chunk: string): void; + text(): string; + remove(): void; +} { + let el: HTMLElement | undefined; + let node: Text | undefined; + return { + append(chunk: string): void { + if (el === undefined || node === undefined) { + el = bubble(log, 'assistant'); + node = document.createTextNode(''); + el.append(node); + } + node.appendData(chunk); + el.scrollIntoView({ block: 'end' }); + }, + text: (): string => node?.data ?? '', + remove: (): void => el?.remove(), + }; +} + export function chip(log: HTMLElement, text: string): void { const el = document.createElement('div'); el.className = 'chip';