Mike0021 commited on
Commit
39371ea
·
verified ·
1 Parent(s): 959e272

Enable browser HTTP commands with CORS-aware errors and bounded requests

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. README.md +15 -64
  2. app/README.md +137 -0
  3. app/index.html +100 -0
  4. app/package-lock.json +0 -0
  5. app/package.json +49 -0
  6. app/public/cat-meow-v1.mp3 +0 -0
  7. app/public/empty-cat-v1.png +3 -0
  8. app/public/favicon.svg +21 -0
  9. app/public/file-icons/config.svg +1 -0
  10. app/public/file-icons/css.svg +1 -0
  11. app/public/file-icons/csv.svg +1 -0
  12. app/public/file-icons/db.svg +1 -0
  13. app/public/file-icons/default.svg +1 -0
  14. app/public/file-icons/git.svg +1 -0
  15. app/public/file-icons/html.svg +1 -0
  16. app/public/file-icons/image.svg +1 -0
  17. app/public/file-icons/javascript.svg +1 -0
  18. app/public/file-icons/json.svg +1 -0
  19. app/public/file-icons/markdown.svg +1 -0
  20. app/public/file-icons/pdf.svg +1 -0
  21. app/public/file-icons/python.svg +1 -0
  22. app/public/file-icons/react.svg +1 -0
  23. app/public/file-icons/shell.svg +1 -0
  24. app/public/file-icons/svg.svg +1 -0
  25. app/public/file-icons/typescript.svg +1 -0
  26. app/public/file-icons/zip.svg +1 -0
  27. app/public/http-help.html +17 -0
  28. app/public/library-icons/huggingface.svg +37 -0
  29. app/public/library-icons/onnxruntime.ico +0 -0
  30. app/public/licenses/library-icons-NOTICE.txt +12 -0
  31. app/public/licenses/onnxruntime-LICENSE +21 -0
  32. app/public/licenses/onnxruntime-ThirdPartyNotices.txt +0 -0
  33. app/public/licenses/openbmb-avatar-NOTICE.txt +6 -0
  34. app/public/licenses/pi-LICENSE +21 -0
  35. app/public/licenses/pi-logo-NOTICE.txt +9 -0
  36. app/public/licenses/seti-icons-LICENSE.txt +20 -0
  37. app/public/licenses/seti-icons-NOTICE.txt +6 -0
  38. app/public/licenses/seti-icons-sources.json +74 -0
  39. app/public/modal-cat-v1.png +3 -0
  40. app/public/openbmb-avatar.webp +0 -0
  41. app/public/social-thumbnail-v3.png +3 -0
  42. app/scripts/licenses.mjs +27 -0
  43. app/scripts/pi-browser.mjs +34 -0
  44. app/src/agent.worker.mjs +102 -0
  45. app/src/browser-http.mjs +67 -0
  46. app/src/cat-sound.mjs +12 -0
  47. app/src/column-resizer.mjs +84 -0
  48. app/src/context-usage.mjs +16 -0
  49. app/src/download.mjs +144 -0
  50. app/src/editor-highlight.worker.mjs +39 -0
README.md CHANGED
@@ -1,5 +1,5 @@
1
  ---
2
- title: MiniCPM5-2B WebGPU Pi
3
  emoji: 🥧
4
  colorFrom: green
5
  colorTo: blue
@@ -7,86 +7,37 @@ sdk: static
7
  app_file: index.html
8
  pinned: false
9
  license: apache-2.0
10
- short_description: A coding agent running entirely in your browser
11
- thumbnail: https://huggingface.co/spaces/townbox/MiniCPM5-2B-WebGPU-Pi/resolve/main/social-thumbnail-v3.png
12
  models:
13
  - openbmb/MiniCPM5-2B
14
  - Mike0021/MiniCPM5-2B-ONNX
15
  tags:
16
  - transformers.js
17
- - onnx
18
  - webgpu
19
- - text-generation
20
  - agents
21
  - pi
22
  - just-bash
23
  ---
24
 
25
- # Pi browser workspace · MiniCPM5-2B
26
 
27
- Run **Pi's real agent core**, **just-bash**, and **MiniCPM5-2B** entirely in your browser. Chat with Pi, inspect and edit files, and execute shell commands in a persistent virtual workspace. A browser worker runs the agent loop, local WebGPU inference, and every tool. No inference API, API key, or shell server is involved.
28
 
29
- Send your first message. If the complete verified model is already cached in this browser, Pi restores it and sends your message automatically, without a confirmation dialog. Otherwise, a **Load model & send** dialog explains the download. Confirming closes the dialog and shows progress in the top bar, then sends your message automatically. Cancel or Stop keeps your unsent draft; loading errors offer a retry. If the cache is incomplete or has changed, downloading missing files requires confirmation. You can also click **Load model** ahead of time. The first download is about **1.84 GB**; files are SHA-256 verified and cached in OPFS. The loader uses bounded byte ranges, retries, cancellation, and separate download / GPU setup / warm-up phases. Chat and virtual files are saved in IndexedDB on this site. Cached weights can be reused after reloading. The entire workspace uses a dark theme.
30
 
31
- If your browser blocks storage inside Hugging Face's embedded view, use the app's **Open workspace in a new tab** button or [open the app directly](https://townbox-minicpm5-2b-webgpu-pi.static.hf.space/). This keeps inference and files in your browser and does not require changing browser privacy settings.
32
 
33
- Requires WebGPU with FP16 (`shader-f16`) in a browser worker, OPFS, and sufficient storage and device memory. There is an 8,192-token prompt-plus-output budget, up to 2,048 generated tokens per model call, and a 12-turn limit per request. Desktop and narrow-screen layouts are included. Physical Android/iOS devices have not been tested.
 
 
 
34
 
35
- Try: **What's our total sales revenue?**
36
 
37
- Pi has `bash`, `read`, `write`, and `edit` tools. just-bash supports shell scripts, pipes, redirects, `awk`, `jq`, `sed`, `grep`, and other text commands. Node.js, Python, npm, native binaries, SQLite, and network commands are unavailable. The left column uses xterm.js with Pi's actual terminal renderer, editor, and message components. MiniCPM and the tools run in the browser worker. This reuses Pi's TUI; the full Node CLI and its extensions are not included. Files are virtual and remain on your device. Export work you want to keep.
38
 
39
- The top bar shows the model, its load status, and a live token-speed sparkline labeled **WebGPU** for inference or **JavaScript** for tool execution. Speed is measured from generated tokens, including thinking; prompt processing and tool execution have their own statuses. The chart clears when generation stops. Successful file reads open the corresponding workspace file while preserving unsaved edits. Common file types have Seti icons. The shell includes live Bash syntax coloring, multiline commands, session history, colored errors/exit states, and a resizable output area. Panels use background shading and rounded corners sized to their nested spacing throughout the dark workspace.
40
 
41
- The terminal uses Pi's dark palette and native message/editor padding. Recent checks cover seven viewport sizes, Unicode alignment, long tool output, a simulated 60-turn transcript, twelve keyboard interactions, five actual-model workflows, and a ten-turn conversation with twelve tool results. Mobile sizes were simulated on desktop Chromium.
42
 
43
- ## Our conversion
44
-
45
- The included `q4f16` model was converted directly from the official [OpenBMB MiniCPM5-2B BF16 checkpoint](https://huggingface.co/openbmb/MiniCPM5-2B/tree/abe115e887989b14f05e64a3b260648329324c3f), not from community-converted weights.
46
-
47
- - Source revision: `abe115e887989b14f05e64a3b260648329324c3f`.
48
- - Official weight SHA-256, verified before conversion: `14fb8e7f0a18d53d1f239773758bf581cee7e456a4523a54622c3a245b64402c`.
49
- - ONNX Runtime GenAI 0.15.2; symmetric int4, block size 32; FP16 activations and KV cache.
50
- - Fused QKV, GQA, RoPE, and normalization operations.
51
- - Last-position logits selected before the output projection.
52
- - Lossless splitting of the 510 MiB embedding allocation into four 127.5 MiB tensors.
53
- - Seven external-data shards; corrected tool-history serialization and Jinja compatibility.
54
-
55
- The [package manifest](https://huggingface.co/Mike0021/MiniCPM5-2B-ONNX/blob/04a6c49fcba3a65a0351c92644c3a7e9d4343059/manifest.json) contains file sizes and SHA-256 hashes. The weights are hosted in the companion [model repository](https://huggingface.co/Mike0021/MiniCPM5-2B-ONNX). The app pins model revision `04a6c49fcba3a65a0351c92644c3a7e9d4343059`; the Space hosts the app and browser runtime.
56
-
57
- ## Validation
58
-
59
- Six prompts with prefill and cached decoding give 41 tested prediction positions. These are scoped conversion checks, not a broad quality evaluation.
60
-
61
- | Comparison | Top-token agreement | Mean KL divergence |
62
- |---|---:|---:|
63
- | FP16 ONNX vs FP16 PyTorch | 41/41 | 0.00000815 |
64
- | Int4 ONNX vs official BF16 PyTorch | 36/41 | 0.125916 |
65
- | Packaged int4 vs our original int4 export | 41/41; all logits bit-identical | 0 |
66
- | Browser WebGPU vs native packaged ONNX | 41/41 | 0.002517 |
67
-
68
- Local Chromium tests on an AMD Radeon 8060S covered streaming, stopping, thinking mode, XML tool-call generation, and one retrieval fixture at 3,888 prompt tokens. Inference also passed with the GPU device capped at a 128 MiB storage binding limit and 256 MiB buffer limit. This desktop allocation test does not emulate a phone's memory pressure or thermals.
69
-
70
- Warm local benchmark medians were about 35–42 decode tokens/s. At 1,890 prompt tokens, first-token latency improved about 9.5% against our own identical-weight control with all-position logits and unsplit embeddings. Timings depend on device, browser, prompt, and thermal state.
71
-
72
- Four-bit quantization has measurable quality loss. A multi-turn audit reproduced ineffective HTML/CDN edits and long, inconclusive reasoning with this export, while the original BF16 model completed the reference workflow. The base model also makes mistakes: one Chinese arithmetic prompt produced an incorrect answer in both the official BF16 model and the conversion. Pi translates validated XML tool calls into actions in the virtual filesystem. Integration tests do not establish broad coding-agent reliability.
73
-
74
- The agent now uses the 2B model's supported thinking mode and actual nucleus sampling (temperature 1, top-p .95, no top-k/min-p filter, repetition penalty 1). Completed thinking is retained separately in history and collapsed in the UI. The audit documents the earlier configuration mistakes, exact-prompt comparisons, parser/sampling corrections, and unresolved quality limits. It does not claim the 4-bit model matches BF16 or that multi-turn inference is fully optimized.
75
-
76
- ## Browser-agent verification
77
-
78
- The production browser bundle passed five real-model agent workflows with networking disabled: reading an unseen random file value, computing CSV revenue and writing a report, creating and executing a shell script, editing that script, and recovering from a missing-file error. It made zero network requests during these tasks. Tests also cover generation cancellation, editor/import/shell controls, persisted files and chat, and cached reloads with zero weight requests. Eight unit tests cover XML/CDATA, tool-shaped code examples, context/history conversion, filesystem restoration, and inert Markdown.
79
-
80
- The same workflows passed with an actual desktop GPU capped at 128 MiB storage bindings and 256 MiB buffers. This is not mobile hardware emulation. Public-Hub download cancellation/retry was tested locally; cached reloads took about 1.8–1.9 seconds on the tested desktop.
81
-
82
- The deployed browser agent also passed all five workflows from the public Space URL, with zero requests during offline tasks and zero weight downloads on reload. The actual Hugging Face embedded page successfully loaded the model and used Pi's bash and file tools to produce and verify the $144 report.
83
-
84
- The xterm.js terminal build also passed all 12 terminal interaction checks from the public app URL, including keyboard editing, search, history, Unicode/large clipboard paste, shell execution, and a 390px layout. In the actual Hugging Face embed, it loaded MiniCPM from cache in 3.644 seconds on the tested desktop and created a fresh, verified $144 sales report through Pi's tools.
85
-
86
- ## Runtime
87
-
88
- Browser runtime versions: Pi Agent / Pi AI **0.85.1**, just-bash **3.4.2**, Transformers.js **4.2.0**, and ONNX Runtime Web **1.26.0-dev.20260416-b7804b056c**. JavaScript/WASM assets are hosted in this Space. Model caching is keyed to the pinned model revision and per-file verification receipts.
89
-
90
- ## Attribution
91
-
92
- Base model: **OpenBMB**, Apache-2.0. Agent: **Pi / earendil-works**. Shell: **just-bash / Vercel**. Inference: **Hugging Face Transformers.js** and **Microsoft ONNX Runtime**. Bundled license texts and notices are in [licenses](licenses). This Space contains an independently converted and quantized derivative; no weights were retrained.
 
1
  ---
2
+ title: MiniCPM5-2B Pi HTTP experiment
3
  emoji: 🥧
4
  colorFrom: green
5
  colorTo: blue
 
7
  app_file: index.html
8
  pinned: false
9
  license: apache-2.0
10
+ short_description: Browser coding agent with HTTP requests (experimental)
 
11
  models:
12
  - openbmb/MiniCPM5-2B
13
  - Mike0021/MiniCPM5-2B-ONNX
14
  tags:
15
  - transformers.js
 
16
  - webgpu
 
17
  - agents
18
  - pi
19
  - just-bash
20
  ---
21
 
22
+ # Pi browser HTTP experiment
23
 
24
+ An independent duplicate of [victor/MiniCPM5-2B-WebGPU-Pi](https://huggingface.co/spaces/victor/MiniCPM5-2B-WebGPU-Pi) at revision `959e272adeb7931e32d3eb2177dc1d5e96c3e967`. The original Space is unchanged.
25
 
26
+ MiniCPM5-2B inference and just-bash run in a browser worker. This copy adds real `curl`/`wget` requests through browser `fetch`, with no backend or proxy. The first model download is about 1.84 GB, verified and cached locally. WebGPU with shader-f16 is required for inference; the Shell can run HTTP commands without loading the model.
27
 
28
+ Try in Shell:
29
 
30
+ ```bash
31
+ curl -fsS https://jsonplaceholder.typicode.com/todos/1 -o todo.json
32
+ jq '{id, title, completed}' todo.json
33
+ ```
34
 
35
+ Or ask Pi: **Fetch https://jsonplaceholder.typicode.com/todos/1, save it as todo.json, and tell me its title and whether it is completed.**
36
 
37
+ Allowed HTTPS hosts: `api.github.com`, `raw.githubusercontent.com`, `huggingface.co`, `jsonplaceholder.typicode.com`, and `httpbin.org`. GET, HEAD, and POST are enabled, with an 8-second deadline and 1 MiB response limit. Use `set -o pipefail` before pipelines to retain download failures.
38
 
39
+ Browser CORS applies. Redirects require the final URL directly: browsers hide redirect destinations, so this copy reports an error instead of silently returning empty output or following unchecked hosts. Cookies are omitted. Explicit request URLs, headers, and bodies are sent to the destination; inference and workspace storage remain local. Node.js, Python, npm, and native programs remain unavailable.
40
 
41
+ Source is under `app/`. Rebuild with `cd app && npm ci && npm test && npm run build`, then `python3 scripts/stage.py`. The HTTP implementation is in `app/src/browser-http.mjs`; it uses just-bash's existing curl/wget commands and custom transport hook. Tests and results are in `validation/`.
42
 
43
+ The small 4-bit model can make mistakes. Shell transport checks and actual-model trials are reported separately; they do not establish general agent reliability or physical-phone performance.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
app/README.md ADDED
@@ -0,0 +1,137 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Pi in the browser
2
+
3
+ A static web app running **Pi’s actual terminal UI in xterm.js**, **the real Pi Agent**, **just-bash**, and **our verified MiniCPM5-2B q4f16 conversion** in the browser. A dedicated worker owns the agent loop, WebGPU inference, and virtual shell. No inference API, API key, shell server, or Pi RPC server is involved.
4
+
5
+ ```sh
6
+ cd app
7
+ npm ci
8
+ npm run build
9
+ npm run preview
10
+ ```
11
+
12
+ Open the printed localhost URL and send your first message. If the complete verified model is already cached, Pi restores it and sends your message automatically without a confirmation dialog. Otherwise, a **Load model & send** dialog explains the download and closes immediately on confirmation. Progress appears in the top bar while your message stays visible as queued; it sends automatically once the model is ready. Closing the dialog or pressing Stop returns the unsent message to the editor. Loading errors reopen the dialog for retry. Automatic restoration never downloads missing model files: an incomplete or changed cache requires confirmation. You can also load ahead of time with **Load model**. The default downloads our pinned model from Hugging Face. The app needs a secure context (HTTPS or localhost), WebGPU with `shader-f16` in a worker, and OPFS storage. Allow about 2 GB of free browser storage plus device memory for inference. Actual memory availability matters on phones.
13
+
14
+ For development, use `npm run dev`. To use the already converted weights on this computer instead of downloading them, run `python scripts/serve.py` from the repository root in another terminal, then open `http://127.0.0.1:5173/?source=local`. The development and preview servers forward `/models` to that static file server. The local source option is accepted only on localhost/127.0.0.1.
15
+
16
+ ## Features
17
+
18
+ - Pi’s upstream fullscreen renderer, multiline editor, user/assistant message components, Markdown, prompt history, and autocomplete. xterm.js renders their ANSI output.
19
+ - `/load`, `/help`, `/new`, and `!command` browser commands. Enter sends, Shift+Enter inserts a newline, Escape/Ctrl+C stops, Ctrl+O expands tools. Ctrl+P/N recalls prompts; Ctrl+Shift+F searches the transcript. The Send and Newline buttons also support touch input.
20
+ - Streaming conversation with genuine `@earendil-works/pi-agent-core` tool execution and automatic follow-up turns.
21
+ - `bash`, `read`, `write`, and exact-match `edit` tools connected to the same just-bash filesystem.
22
+ - File editor with Prism syntax colors for HTML/XML/SVG, CSS, JavaScript/TypeScript (including JSX/TSX), Python, Bash, JSON, Markdown, YAML, TOML, SQL, CSV, C/C++, C#, Java, Go, Rust, Dockerfiles, and diffs. Highlighting runs in a separate, lazily started worker and is debounced while typing. Native textarea editing, selection, undo, and scrolling are preserved. Unknown formats, files above 200,000 characters or 5,000 lines, excessive token output, and grammar work exceeding 1.5 seconds fall back to plain text. Text-file import, workspace export, and an interactive command entry are also included.
23
+ - The starter workspace pairs `sales.csv` (products and units sold) with `products.json` (product prices). Both are needed for total sales revenue. The cat suggests “What's our total sales revenue?” without naming files or tools; the workspace README contains no calculation instructions. Only an untouched earlier pair is upgraded together; custom data and prior deletions are preserved.
24
+ - Successful `read` tool calls open and highlight the resolved file in the workspace, including relative paths and symlinks. Following waits for newly created files, preserves unsaved edits, and keeps the conversation’s focus and page scroll position. Common file types use a local subset of 18 Seti icons from VS Code’s built-in theme, totaling about 12 KB of SVGs.
25
+ - The shell highlights Bash as you type, accepts multiline commands (Shift+Enter), recalls session history (↑/↓), and separates command/output blocks with colored exit states and errors. Clear or Ctrl+L clears the output. Its output area can be resized vertically.
26
+ - Model name, loading progress, and loaded status live in a rounded, inset top bar aligned with the workspace width. New chat sits at the bottom left of the terminal, opposite the send controls. Panels use background shading and rounded corners instead of separator borders.
27
+ - The top bar’s activity indicator labels model inference **WebGPU** and tool execution **JavaScript**. It shows loading, prompt processing, tool execution, and live generation speed. Its sparkline uses actual generated-token counts, including thinking, over a rolling second; prompt-processing time is excluded. Rates refresh four times per second and clear when generation stops. The capsule fits its current contents with a 220ms width transition, disabled when reduced motion is preferred. Nested corner radii account for their insets: the header uses a 16px radius around 8px-radius controls with 8px padding.
28
+ - The first-message dialog explains that the model, inference, and shell run entirely in the browser. Pi’s input receives focus when the workspace starts; cancelling setup returns focus and the unsent draft to Pi.
29
+ - The download dialog has a matching peeking cat with transparent paws over its top edge. The sprite preloads and decodes on page startup, stays decorative for keyboard/screen-reader users, and shrinks on short screens to keep the download note and actions visible. Subtle paw prints appear around the overlay whenever the dialog is open, including before confirmation and on retry. They do not intercept clicks, pause in hidden tabs, and stay still with reduced motion.
30
+ - After 7 seconds of inactivity, a faint paw trail occasionally crosses Pi’s transcript, with 45–60 seconds between trails. Typing, clicking, or scrolling clears it immediately; pointer movement neither resets the delay nor stops the trail. It stays above the input, never takes focus, and pauses during model activity, open dialogs, hidden or unfocused tabs, and reduced motion.
31
+ - An empty chat has a small blocky cat with a clickable starter prompt. The bubble and cat appear together once the image is decoded. The suggestion fills Pi’s editor without sending, adapts to the workspace files, and disappears after the first user message. The 104px mascot makes one gentle hop shortly after appearing, then occasionally blinks; clicking or tapping the cat replays the hop and a short meow. Both cats are decorative and never take keyboard focus. Motion pauses while hidden and is disabled for reduced-motion preferences. Its paws align with the input divider, with room reserved beside it for the status text. Its position follows the terminal’s available blank rows, keeping the input and transcript unobstructed; short viewports omit it when there is no room.
32
+ - IndexedDB persistence for chat, file bytes, executable modes, directories, and symbolic links. New chat keeps the workspace.
33
+ - Verified model downloading: six concurrent files, 32 MiB byte ranges, three attempts per chunk, cancellation, incremental SHA-256 checks, and atomic OPFS writes. Completed files survive an interrupted load; an incomplete file restarts on retry.
34
+ - Cached reloads use file size, modification time, and the stored verification receipt. Model files are hashed during download; they are not rehashed on every reload.
35
+ - Visible download / WebGPU setup / shader warm-up phases. No image requests from model-generated Markdown.
36
+ - MiniCPM5-2B's supported thinking mode, with completed reasoning retained separately in conversation history. Thoughts are collapsed in the terminal; click to expand. Tool-shaped text inside reasoning is never executed.
37
+ - Temperature 1, actual top-p 0.95 sampling, no top-k/min-p filtering, and repetition penalty 1. A tested adapter applies nucleus filtering because Transformers.js 4.2.0 does not implement its exposed `top_p` option in generation.
38
+ - An 8,192-token combined prompt/output budget, up to 2,048 generated tokens (reasoning plus answer/tool XML) per model call, and a 12-turn limit per user request. Older complete user turns can be omitted from inference context; the full saved conversation is retained. An oversized current turn produces an explicit error.
39
+ - The terminal footer shows used/total context tokens and remaining capacity for the latest model call. It includes the formatted prompt (instructions, tool definitions, and retained history) plus generated tokens, updates during streaming, survives reloads, and resets with New chat. Unsent draft text is counted when submitted; saved turns excluded from the model context are not counted. On narrow screens the counter gets its own line.
40
+
41
+ ## Model quality
42
+
43
+ The [September 14 multi-turn audit](../research/multiturn-audit/REPORT.md) found that the previous no-thinking/greedy profile was not faithful to the model's documented configuration. The corrected adapter handles reasoning and sampling, but the current 4-bit conversion still fails some HTML/CDN follow-ups that the original BF16 model completed. It can produce ineffective edits or exhaust its reasoning budget. Numerical conversion checks and basic tool tests do not establish general coding reliability. Cross-call KV reuse and physical-phone validation remain outstanding.
44
+ - Responsive desktop and mobile layouts. The file workspace follows the chat on narrow screens.
45
+ - A consistent dark theme across the terminal, header, model controls, file editor, shell, and setup dialog, including native form controls and scrollbars.
46
+
47
+ ## Runtime boundaries
48
+
49
+ This uses Pi's actual TUI and agent core with browser host adapters. The npm `pi-tui` package supplies the renderer, editor, input parser, layout, Markdown, search, and keybindings. Unmodified CLI presentation components are in [vendor/pi-coding-agent](vendor/pi-coding-agent/README.md), with provenance checksums and the upstream dark palette. Secondary text colors are lifted for legibility at small sizes. [The build adapter](scripts/pi-browser.mjs) replaces terminal host services; [the terminal adapter](src/pi/xterm-terminal.mjs) connects xterm input/output/resize to Pi. Browser command dispatch and just-bash tool cards are app code.
50
+
51
+ Message padding follows Pi 0.85.1 defaults: one column of output padding, one inner row above/below user and tool boxes, one separator row between turns, and zero editor column padding. The browser panel adds an 8px outer inset, with no right padding on desktop. Pi owns transcript scrolling, so xterm's unused native scrollbar gutter is disabled. The pinned `@xterm/addon-unicode-graphemes@0.4.0` addon matches Pi's grapheme widths for CJK, emoji sequences and combining accents. It uses xterm's proposed Unicode API and is covered by rendering assertions.
52
+
53
+ The full Node CLI, host filesystem, OAuth providers, extensions, package installation, and RPC server are not included. xterm.js is the terminal emulator; it does not add Node.js or a native shell. Model inference and tool execution remain in the browser worker. Terminal control sequences from model/file output are stripped before rendering, and Markdown images do not trigger downloads.
54
+
55
+ just-bash is a simulated shell, not a Linux VM. Shell scripts, pipes, redirects, `awk`, `jq`, `sed`, `grep`, `find`, and other supported text commands execute in JavaScript. Node.js, Python, npm, native executables, and SQLite are unavailable. This HTTP experiment enables curl/wget for selected HTTPS APIs through a browser fetch adapter; see public/http-help.html for hosts, examples, and limits. The installed browser bundle also excludes working gzip/zlib commands. Shell cwd and variables reset between commands; filesystem changes persist. Commands have a 10-second deadline, 3,000-command cap, 16 MiB filesystem budget, and 256 KiB output budget. Tool output passed to the model is capped at 6,000 characters.
56
+
57
+ Local model and filesystem tool calls can run with the browser network disabled after loading. HTTP commands require a connection and CORS permission from the destination. Reopening the app itself still requires its static HTML/JS/WASM assets; there is no offline service worker. Browser storage belongs to this site's origin and may be evicted by the browser. Export important work.
58
+
59
+ The small 4-bit model can make mistakes. These tests establish working integration on the tested device, not broad coding-agent reliability or support for every mobile browser. No physical phone has been tested.
60
+
61
+ ## Verification
62
+
63
+ ```sh
64
+ npm test
65
+ npm run build
66
+ # Start npm run preview, then run the terminal checks (no model needed):
67
+ node tests/terminal.mjs
68
+ # Deterministic presentation fixtures, including narrow and short viewports:
69
+ npm run test:render
70
+ # Ten consecutive actual-model turns (local model server required):
71
+ npm run test:conversation
72
+ # First message, modal cancellation/retry, and actual model auto-send:
73
+ npm run test:first-message
74
+ # Download confirmation, top-bar progress, queued messages and cancellation (controlled worker fixtures):
75
+ npm run test:load-flow
76
+ # Cached auto-start, invalidated cache, Stop and GPU failure recovery:
77
+ npm run test:cached-start
78
+ # Live token speed, generation/tool/idle phases and eight header layouts:
79
+ npm run test:runtime-meter
80
+ # Automatic read following, unsaved edits, file icons and mobile layout:
81
+ npm run test:follow-read
82
+ # File highlighting, native editing, saving, scrolling and large-file fallback:
83
+ npm run test:editor
84
+ # Companion product data and preserving existing workspaces during its addition:
85
+ npm run test:starter-products
86
+ # Opt-in real WebGPU model evaluation; requires the dedicated cached test profile:
87
+ npm run test:sales-demo
88
+ # Syntax-colored command input, actual shell execution, history and layouts:
89
+ npm run test:shell
90
+ # Actual token usage, streaming, cancellation, reload and input focus:
91
+ npm run test:context
92
+ # Start npm run preview -- --port 4173 and the root static model server first:
93
+ npm run test:browser
94
+ ```
95
+
96
+ The terminal suite checks real keyboard events, multiline editing, autocomplete, history, Unicode and large clipboard pastes, shell execution, and live resize. The browser suite uses real Chromium, the actual model, and the production bundle; prompts are submitted through xterm and Pi’s editor. It disables networking while Pi reads a random value from an unseen file, calculates CSV revenue, writes and verifies a report, creates and executes a shell script, edits it, and recovers from a missing-file tool error. It also checks stopping generation, file import/edit/save, manual shell execution, workspace persistence, cached reloads, and a 390 px layout.
97
+
98
+ To exercise the public Hugging Face download in a separate browser profile:
99
+
100
+ ```sh
101
+ APP_URL=http://127.0.0.1:4173/ \
102
+ BROWSER_PROFILE=../validation/agent-browser-profile/remote \
103
+ TEST_LABEL=agent-browser-hub TEST_CANCEL_LOAD=1 npm run test:browser
104
+ ```
105
+
106
+ To exercise smaller GPU buffer allocations on the desktop GPU:
107
+
108
+ ```sh
109
+ APP_URL='http://127.0.0.1:4173/?source=local&limit128' \
110
+ TEST_LABEL=agent-browser-limit128 npm run test:browser
111
+ ```
112
+
113
+ `limit128` sets the actual WebGPU device to 128 MiB storage bindings and 256 MiB buffers. It is an allocation compatibility test; viewport resizing and GPU limits do not emulate mobile hardware performance.
114
+
115
+ Results and screenshots are in the repository's `validation/agent-browser*` files. Test logs include complete model tool calls and results. The browser profile and downloaded weights are ignored by Git.
116
+
117
+ ## Static hosting
118
+
119
+ Upload **the contents of `app/dist`** to an HTTPS static host. It contains the app and local ONNX Runtime assets; weights are fetched from the pinned public Hugging Face repository and cached in OPFS. No build or inference server is needed after deployment.
120
+
121
+ The public deployment is [townbox/MiniCPM5-2B-WebGPU-Pi](https://huggingface.co/spaces/townbox/MiniCPM5-2B-WebGPU-Pi). From the repository root, `python scripts/prepare_space.py` stages the built app, source, licenses, and selected validation files in `.space-agent/` for upload.
122
+
123
+ Browsers that deny storage in third-party embeds get an **Open workspace in a new tab** link. The top-level Space app can then use its own storage without changing browser privacy settings. Embedded-page tests restore normal Chromium storage partitioning because Playwright 1.58 disables it by default; the restricted-storage path is tested separately.
124
+
125
+ ## Pinned components
126
+
127
+ | Component | Version / revision |
128
+ |---|---|
129
+ | Pi agent core and Pi AI types/event stream | `@earendil-works/*` 0.85.1 |
130
+ | Pi TUI / CLI presentation components | 0.85.1 |
131
+ | xterm.js / fit addon | 6.0.0 / 0.11.0 |
132
+ | just-bash | 3.4.2, explicit `just-bash/browser` import |
133
+ | Transformers.js | 4.2.0 |
134
+ | Model | `Mike0021/MiniCPM5-2B-ONNX` at `04a6c49fcba3a65a0351c92644c3a7e9d4343059` |
135
+ | Model provenance | official OpenBMB weights; see root README and package manifest |
136
+
137
+ Architecture decisions and primary-source research are in [the investigation](../research/browser-agent/REPORT.md).
app/index.html ADDED
@@ -0,0 +1,100 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
5
+ <meta name="theme-color" content="#18181e">
6
+ <meta name="color-scheme" content="dark">
7
+ <link rel="icon" href="./favicon.svg" type="image/svg+xml">
8
+ <link rel="preload" href="./modal-cat-v1.png" as="image" type="image/png" fetchpriority="high">
9
+ <meta name="description" content="Pi, MiniCPM5 and a bash workspace, running entirely in your browser.">
10
+ <meta property="og:type" content="website">
11
+ <meta property="og:title" content="Pi, entirely in your browser.">
12
+ <meta property="og:description" content="MiniCPM5-2B, WebGPU inference, and a local shell. A coding agent running entirely in your browser.">
13
+ <meta property="og:url" content="https://mike0021-minicpm5-2b-webgpu-pi-http.static.hf.space/">
14
+ <meta property="og:image" content="https://huggingface.co/spaces/Mike0021/MiniCPM5-2B-WebGPU-Pi-HTTP/resolve/main/social-thumbnail-v3.png">
15
+ <meta property="og:image:width" content="3136">
16
+ <meta property="og:image:height" content="1984">
17
+ <meta property="og:image:alt" content="A coding agent running entirely in your browser, above the Pi terminal and its pixel cat mascot.">
18
+ <meta name="twitter:card" content="summary_large_image">
19
+ <meta name="twitter:title" content="Pi, entirely in your browser.">
20
+ <meta name="twitter:description" content="MiniCPM5-2B, WebGPU inference, and a local shell. A coding agent running entirely in your browser.">
21
+ <meta name="twitter:image" content="https://huggingface.co/spaces/Mike0021/MiniCPM5-2B-WebGPU-Pi-HTTP/resolve/main/social-thumbnail-v3.png">
22
+ <meta name="twitter:image:alt" content="A coding agent running entirely in your browser, above the Pi terminal and its pixel cat mascot.">
23
+ <title>Pi · Coding agent in your browser</title>
24
+ </head>
25
+ <body>
26
+ <header><a class="brand" href="./" aria-label="Pi"><svg class="pi-logo" viewBox="165.29 165.29 469.43 469.43" width="18" height="18" aria-hidden="true" focusable="false"><path fill="currentColor" fill-rule="evenodd" d="M165.29 165.29H517.36V400H400V517.36H282.65V634.72H165.29ZM282.65 282.65V400H400V282.65Z"/><path fill="currentColor" d="M517.36 400H634.72V634.72H517.36Z"/></svg></a>
27
+ <div id="runtime-meter" class="runtime-meter" role="group" aria-label="Execution in this browser" data-state="idle">
28
+ <div class="runtime-content">
29
+ <span class="runtime-location"><span class="runtime-dot" aria-hidden="true"></span><span class="runtime-engine">WebGPU</span></span>
30
+ <span class="runtime-divider" aria-hidden="true"></span>
31
+ <svg class="runtime-sparkline" width="48" height="18" viewBox="0 0 48 18" aria-hidden="true" hidden><g class="runtime-bars" fill="currentColor"></g></svg>
32
+ <span class="runtime-value">Idle</span>
33
+ </div>
34
+ <span class="sr-only" role="status" aria-live="polite"></span>
35
+ </div>
36
+ <div class="model-panel">
37
+ <div class="model-heading"><div><a class="header-model-source" href="https://huggingface.co/openbmb/MiniCPM5-2B" target="_blank" rel="noopener noreferrer" aria-label="MiniCPM5-2B by OpenBMB on Hugging Face (opens in a new tab)" title="View MiniCPM5-2B on Hugging Face"><img src="./openbmb-avatar.webp" width="16" height="16" alt=""><strong>MiniCPM5-2B</strong></a><span class="badge">4-bit</span></div><button id="load" class="primary" aria-describedby="load-status load-detail">Load model</button></div>
38
+ <p id="load-status" class="sr-only" role="status">One download, about 1.84 GB. Cached in this browser for next time.</p>
39
+ <progress id="progress" hidden max="100" value="0" aria-label="Model download"></progress>
40
+ <div id="load-detail" class="sr-only"></div>
41
+ </div>
42
+ </header>
43
+ <main>
44
+ <section id="conversation-panel" class="conversation" aria-label="Agent conversation">
45
+ <div class="terminal-panel">
46
+ <div id="pi-terminal" aria-label="Pi terminal"></div>
47
+ <div id="notice" role="status" hidden></div>
48
+ <a id="open-tab" class="button" target="_blank" rel="noopener noreferrer" hidden>Open workspace in a new tab ↗</a>
49
+ <div class="terminal-controls"><button id="new-chat" type="button">New chat</button><span id="activity" class="sr-only" role="status">Files and chat are saved on this device.</span><button id="newline" type="button" title="Insert a newline (Shift+Enter)">↵ Newline</button><button id="stop" type="button" hidden>Stop</button><button id="send" class="primary">Send ↑</button></div>
50
+ <footer>Pi TUI · xterm.js <span>Model and tools run in your browser</span></footer>
51
+ </div>
52
+ </section>
53
+ <div id="column-resizer" class="column-resizer" role="separator" tabindex="0" aria-label="Resize chat and workspace" aria-orientation="vertical" aria-controls="conversation-panel workspace-panel" aria-valuemin="0" aria-valuemax="100" aria-valuenow="55" title="Drag to resize · Arrow keys to adjust · Double-click or Enter to reset"></div>
54
+ <aside id="workspace-panel" class="workspace" aria-label="Workspace">
55
+ <div class="workspace-heading"><h2>Workspace</h2><div><button id="add-file" class="quiet">+ File</button><label class="button quiet" for="upload">Import</label><input id="upload" type="file" multiple hidden><button id="export" class="quiet">Export</button></div></div>
56
+ <div class="file-area"><nav id="files" aria-label="Files"></nav><div class="editor-area"><div class="editor-heading"><span class="editor-file"><span id="filetype"></span><span id="filename">Select a file</span></span><button id="save" class="quiet" disabled>Save</button></div><label for="editor" class="sr-only">File contents</label><div class="editor-input"><pre id="editor-highlight" aria-hidden="true"></pre><textarea id="editor" wrap="off" autocomplete="off" autocapitalize="off" spellcheck="false" disabled placeholder="Select a file to inspect or edit it."></textarea></div></div></div>
57
+ <details class="terminal" open>
58
+ <summary>Shell <span>just-bash</span></summary>
59
+ <div class="shell-toolbar"><span>/workspace</span><button id="shell-clear" type="button" class="quiet" title="Clear shell output (Ctrl+L)">Clear</button></div>
60
+ <div id="terminal-output" role="log" aria-label="Shell output" aria-live="polite" tabindex="0"><div class="shell-empty"><strong>Ready in your workspace</strong><span>Try <code>ls -la</code> or <code>cat README.md</code>.</span></div></div>
61
+ <form id="shell-form">
62
+ <span class="shell-prompt" aria-hidden="true">$</span><label for="command" class="sr-only">Shell command</label>
63
+ <div class="shell-input"><pre id="command-highlight" aria-hidden="true"></pre><textarea id="command" rows="1" autocomplete="off" autocapitalize="off" spellcheck="false" placeholder="Enter a shell command…" aria-describedby="shell-hint"></textarea></div>
64
+ <button id="shell-run" class="primary">Run</button>
65
+ </form>
66
+ <div id="shell-hint"><span>Enter to run · Shift+Enter for newline</span><span>↑↓ history</span></div>
67
+ </details>
68
+ <p class="workspace-note">HTTP experiment: curl / wget can reach selected APIs. Browser CORS applies. <a href="./http-help.html" target="_blank" rel="noopener">Hosts &amp; examples ↗</a></p>
69
+ </aside>
70
+ </main>
71
+ <dialog id="model-dialog" aria-labelledby="model-title" aria-describedby="model-description">
72
+ <div class="setup-paws" aria-hidden="true"><i></i><i></i><i></i><i></i><i></i><i></i></div>
73
+ <div class="setup-cat" aria-hidden="true"><span class="setup-cat-tail"></span><img src="./modal-cat-v1.png" width="1254" height="1254" alt="" draggable="false" loading="eager" fetchpriority="high" decoding="async"><span class="setup-cat-eyelids"></span></div>
74
+ <div class="setup-heading">
75
+ <h2 id="model-title">Download model weights</h2>
76
+ <button id="model-cancel" type="button" aria-label="Close model setup" title="Close model setup"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" aria-hidden="true" focusable="false"><path d="m6 6 12 12M18 6 6 18"/></svg></button>
77
+ </div>
78
+ <div class="setup-body">
79
+ <p id="model-description">The model and shell run in your browser. HTTP commands can send requests and data to external services.</p>
80
+ <dl class="setup-runtime">
81
+ <div><dt>Model</dt><dd class="setup-model"><a class="model-org" href="https://huggingface.co/openbmb" target="_blank" rel="noopener noreferrer" aria-label="OpenBMB on Hugging Face (opens in a new tab)" title="OpenBMB on Hugging Face"><img src="./openbmb-avatar.webp" width="18" height="18" alt=""></a><a class="model-source" href="https://huggingface.co/openbmb/MiniCPM5-2B" target="_blank" rel="noopener noreferrer" title="View MiniCPM5-2B on Hugging Face">MiniCPM5-2B</a></dd></div>
82
+ <div><dt>Inference</dt><dd>On your GPU</dd></div>
83
+ <div><dt>Shell</dt><dd>Bash in the browser</dd></div>
84
+ </dl>
85
+ <div class="setup-facts"><strong>About 1.84 GB on the first download</strong><span>Saved in this browser for faster starts next time.</span></div>
86
+ <p class="setup-label">Your message</p><div id="model-preview"></div>
87
+ <p id="model-error" role="alert" hidden></p>
88
+ <a id="model-open-tab" class="button" target="_blank" rel="noopener noreferrer" hidden>Open workspace in a new tab ↗</a>
89
+ <p class="setup-credits"><span>Powered by</span>
90
+ <a href="https://huggingface.co/docs/transformers.js/index" target="_blank" rel="noopener noreferrer" title="Transformers.js documentation (opens in a new tab)"><img src="./library-icons/huggingface.svg" width="14" height="14" alt="">Transformers.js</a>
91
+ <a href="https://onnxruntime.ai/docs/" target="_blank" rel="noopener noreferrer" title="ONNX Runtime documentation (opens in a new tab)"><img src="./library-icons/onnxruntime.ico" width="14" height="14" alt="">ONNX Runtime</a>
92
+ <a href="https://github.com/earendil-works/pi" target="_blank" rel="noopener noreferrer" title="Pi agent toolkit (opens in a new tab)"><svg class="credit-pi" viewBox="165.29 165.29 469.43 469.43" width="14" height="14" aria-hidden="true" focusable="false"><path fill="currentColor" fill-rule="evenodd" d="M165.29 165.29H517.36V400H400V517.36H282.65V634.72H165.29ZM282.65 282.65V400H400V282.65Z"/><path fill="currentColor" d="M517.36 400H634.72V634.72H517.36Z"/></svg>Pi</a>
93
+ <a href="https://github.com/vercel-labs/just-bash" target="_blank" rel="noopener noreferrer" title="just-bash browser shell (opens in a new tab)"><svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><rect x="2.5" y="4" width="19" height="16" rx="3"/><path d="m6 9 3 3-3 3m7 0h5"/></svg>just-bash</a>
94
+ </p>
95
+ </div>
96
+ <div class="setup-actions"><button id="model-confirm" type="button" class="primary" autofocus>Load model &amp; send</button></div>
97
+ </dialog>
98
+ <script type="module" src="./src/main.mjs"></script>
99
+ </body>
100
+ </html>
app/package-lock.json ADDED
The diff for this file is too large to render. See raw diff
 
app/package.json ADDED
@@ -0,0 +1,49 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "minicpm5-pi-browser",
3
+ "version": "0.1.0",
4
+ "private": true,
5
+ "type": "module",
6
+ "scripts": {
7
+ "dev": "vite --host 127.0.0.1",
8
+ "build": "vite build",
9
+ "preview": "vite preview --host 127.0.0.1",
10
+ "test": "node --test tests/*.test.mjs",
11
+ "test:browser": "node tests/browser.mjs",
12
+ "test:terminal": "node tests/terminal.mjs",
13
+ "test:render": "node tests/render.mjs",
14
+ "test:conversation": "node tests/conversation.mjs",
15
+ "test:first-message": "node tests/first-message.mjs",
16
+ "test:cached-start": "node tests/cached-start.mjs",
17
+ "test:runtime-meter": "node tests/runtime-meter.mjs",
18
+ "test:mascot": "node tests/mascot.mjs",
19
+ "test:modal-cat": "node tests/modal-cat.mjs",
20
+ "test:column-resizer": "node tests/column-resizer.mjs",
21
+ "test:load-flow": "node tests/load-flow.mjs",
22
+ "test:follow-read": "node tests/follow-read.mjs",
23
+ "test:editor": "node tests/editor.mjs",
24
+ "test:starter-products": "node tests/starter-products.mjs",
25
+ "test:sales-demo": "node tests/sales-demo.mjs",
26
+ "test:shell": "node tests/shell.mjs",
27
+ "test:context": "node tests/context.mjs"
28
+ },
29
+ "dependencies": {
30
+ "@earendil-works/pi-agent-core": "0.85.1",
31
+ "@earendil-works/pi-ai": "0.85.1",
32
+ "@earendil-works/pi-tui": "0.85.1",
33
+ "@huggingface/transformers": "4.2.0",
34
+ "@xmldom/xmldom": "0.9.12",
35
+ "@xterm/addon-fit": "0.11.0",
36
+ "@xterm/addon-unicode-graphemes": "0.4.0",
37
+ "@xterm/xterm": "6.0.0",
38
+ "buffer": "6.0.3",
39
+ "events": "3.3.0",
40
+ "hash-wasm": "4.12.0",
41
+ "just-bash": "3.4.2",
42
+ "markdown-it": "15.0.2",
43
+ "prismjs": "1.30.0"
44
+ },
45
+ "devDependencies": {
46
+ "playwright": "1.58.2",
47
+ "vite": "8.3.0"
48
+ }
49
+ }
app/public/cat-meow-v1.mp3 ADDED
Binary file (10.7 kB). View file
 
app/public/empty-cat-v1.png ADDED

Git LFS Details

  • SHA256: 592558545d0ef05a4d29fd27eeeb5b63468f1f378bb1c50359a60f8d2a88f668
  • Pointer size: 131 Bytes
  • Size of remote file: 669 kB
app/public/favicon.svg ADDED
app/public/file-icons/config.svg ADDED
app/public/file-icons/css.svg ADDED
app/public/file-icons/csv.svg ADDED
app/public/file-icons/db.svg ADDED
app/public/file-icons/default.svg ADDED
app/public/file-icons/git.svg ADDED
app/public/file-icons/html.svg ADDED
app/public/file-icons/image.svg ADDED
app/public/file-icons/javascript.svg ADDED
app/public/file-icons/json.svg ADDED
app/public/file-icons/markdown.svg ADDED
app/public/file-icons/pdf.svg ADDED
app/public/file-icons/python.svg ADDED
app/public/file-icons/react.svg ADDED
app/public/file-icons/shell.svg ADDED
app/public/file-icons/svg.svg ADDED
app/public/file-icons/typescript.svg ADDED
app/public/file-icons/zip.svg ADDED
app/public/http-help.html ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!doctype html>
2
+ <html lang="en"><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
3
+ <title>Browser HTTP experiment</title>
4
+ <style>html{color-scheme:dark;background:#18181e;color:#e4e4eb;font:17px/1.6 system-ui}body{max-width:760px;margin:40px auto;padding:0 20px}a{color:#a4e2d4}pre{background:#262630;padding:16px;overflow:auto;border-radius:12px}code{font-size:14px}</style>
5
+ <h1>HTTP from the browser shell</h1>
6
+ <p>This experimental copy enables real <code>curl</code> and <code>wget</code> requests. The model and shell run on your device. Requests go directly from your browser to the destination; there is no proxy. Request URLs, headers, and bodies are sent to that service.</p>
7
+ <p>Allowed HTTPS hosts: <code>api.github.com</code>, <code>raw.githubusercontent.com</code>, <code>huggingface.co</code>, <code>jsonplaceholder.typicode.com</code>, and <code>httpbin.org</code>. GET, HEAD, and POST are enabled. Cookies are omitted. Requests have an 8-second deadline and a 1 MiB response limit.</p>
8
+ <h2>Try in Shell</h2>
9
+ <pre>curl -fsS https://jsonplaceholder.typicode.com/todos/1 -o todo.json
10
+ jq '{id, title, completed}' todo.json</pre>
11
+ <pre>set -o pipefail
12
+ curl -fsS https://api.github.com/repos/huggingface/transformers.js | jq '{full_name, stargazers_count}'</pre>
13
+ <h2>Ask Pi</h2>
14
+ <p>“Fetch https://jsonplaceholder.typicode.com/todos/1, save it as todo.json, and tell me its title and whether it is completed.”</p>
15
+ <p>The destination must allow browser access with CORS. Redirects are reported as errors: use the final URL directly, with the exact capitalization. A plain curl pipeline can hide an error unless you enable <code>set -o pipefail</code>. Python, Node.js, npm, and native binaries remain unavailable.</p>
16
+ <p><a href="./">Back to the workspace</a> · <a href="https://huggingface.co/spaces/victor/MiniCPM5-2B-WebGPU-Pi">Original Space</a></p>
17
+ </html>
app/public/library-icons/huggingface.svg ADDED
app/public/library-icons/onnxruntime.ico ADDED
app/public/licenses/library-icons-NOTICE.txt ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ Library credits icons, obtained 2026-09-14.
2
+
3
+ Hugging Face logo (unmodified):
4
+ https://huggingface.co/front/assets/huggingface_logo-noborder.svg
5
+
6
+ ONNX Runtime favicon (unmodified):
7
+ https://onnxruntime.ai/favicon.ico
8
+
9
+ Pi mark: official paths reused from the header; see pi-logo-NOTICE.txt.
10
+ The terminal beside just-bash is an app-drawn generic shell icon.
11
+
12
+ Names and marks identify the upstream projects used by this independent demo.
app/public/licenses/onnxruntime-LICENSE ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ MIT License
2
+
3
+ Copyright (c) Microsoft Corporation
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
app/public/licenses/onnxruntime-ThirdPartyNotices.txt ADDED
The diff for this file is too large to render. See raw diff
 
app/public/licenses/openbmb-avatar-NOTICE.txt ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ OpenBMB avatar obtained from its Hugging Face organization on 2026-09-14.
2
+ Organization: https://huggingface.co/openbmb
3
+ Source: https://cdn-avatars.huggingface.co/v1/production/uploads/1670387859384-633fe7784b362488336bbfad.png
4
+
5
+ The unmodified WebP image returned by the CDN is hosted locally and identifies
6
+ the model's publisher.
app/public/licenses/pi-LICENSE ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Mario Zechner
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
app/public/licenses/pi-logo-NOTICE.txt ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ Pi logo: official Pi / Earendil mark, obtained 2026-09-14.
2
+ Source and brand files: https://pi.dev/press-kit
3
+ Primary logo: https://pi.dev/logo-auto.svg
4
+ Favicon: https://pi.dev/favicon.svg
5
+
6
+ The header and setup dialog preserve the official SVG paths, with a tighter
7
+ viewBox and currentColor for sizing and the app's dark palette. The favicon
8
+ is the unmodified official badge. Pi's name and logo identify the upstream
9
+ project; this browser demo is an independent integration.
app/public/licenses/seti-icons-LICENSE.txt ADDED
@@ -0,0 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ Copyright (c) 2014 Jesse Weed
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining
4
+ a copy of this software and associated documentation files (the
5
+ "Software"), to deal in the Software without restriction, including
6
+ without limitation the rights to use, copy, modify, merge, publish,
7
+ distribute, sublicense, and/or sell copies of the Software, and to
8
+ permit persons to whom the Software is furnished to do so, subject to
9
+ the following conditions:
10
+
11
+ The above copyright notice and this permission notice shall be
12
+ included in all copies or substantial portions of the Software.
13
+
14
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
15
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
16
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
17
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
18
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
19
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
20
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
app/public/licenses/seti-icons-NOTICE.txt ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ Seti UI file icons by Jesse Weed and contributors, MIT license.
2
+ Upstream: https://github.com/jesseweed/seti-ui
3
+ Revision: 2d6c5e68b4ded73c92dac291845ee44e1182d511
4
+ Used by VS Code’s built-in Seti file icon theme.
5
+ Only 18 icons are included. SVG paths are preserved; viewBoxes are tightened for consistent 16px sizing. Colors use the Seti palette.
6
+ The ZIP icon’s white knockout shapes are encoded as a luminance mask so its zipper remains visible when tinted.
app/public/licenses/seti-icons-sources.json ADDED
@@ -0,0 +1,74 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "default": {
3
+ "source": "https://raw.githubusercontent.com/jesseweed/seti-ui/2d6c5e68b4ded73c92dac291845ee44e1182d511/icons/default.svg",
4
+ "upstream_sha256": "91cd6ad5e03ba15b5f763cb7f0a7eef4f5e95c72c6e3192b9187c703ed9e7dd6"
5
+ },
6
+ "markdown": {
7
+ "source": "https://raw.githubusercontent.com/jesseweed/seti-ui/2d6c5e68b4ded73c92dac291845ee44e1182d511/icons/markdown.svg",
8
+ "upstream_sha256": "d6e4b4d8b4b32dcaf0defcb0858236326a17a50931dcd30926c8b047dadc0d6c"
9
+ },
10
+ "csv": {
11
+ "source": "https://raw.githubusercontent.com/jesseweed/seti-ui/2d6c5e68b4ded73c92dac291845ee44e1182d511/icons/csv.svg",
12
+ "upstream_sha256": "77adc99b60bfef400b7619484e8646b5b89fcf7f5a28c50563006327f7c4d0e6"
13
+ },
14
+ "json": {
15
+ "source": "https://raw.githubusercontent.com/jesseweed/seti-ui/2d6c5e68b4ded73c92dac291845ee44e1182d511/icons/json.svg",
16
+ "upstream_sha256": "24429711d8ac39d70e0fd3398b2137d82d2637786781c8ed10f3de0b81a0e547"
17
+ },
18
+ "javascript": {
19
+ "source": "https://raw.githubusercontent.com/jesseweed/seti-ui/2d6c5e68b4ded73c92dac291845ee44e1182d511/icons/javascript.svg",
20
+ "upstream_sha256": "dfac57ebdebfea573cd92b04178feee655452e24021a6b5ad4da84c3f5cf8615"
21
+ },
22
+ "typescript": {
23
+ "source": "https://raw.githubusercontent.com/jesseweed/seti-ui/2d6c5e68b4ded73c92dac291845ee44e1182d511/icons/typescript.svg",
24
+ "upstream_sha256": "af103ea54f8ac748c4c785a8074d0ba13c71df614669af4739575ceb000f191d"
25
+ },
26
+ "react": {
27
+ "source": "https://raw.githubusercontent.com/jesseweed/seti-ui/2d6c5e68b4ded73c92dac291845ee44e1182d511/icons/react.svg",
28
+ "upstream_sha256": "ef9d06d0487eac79e90d3c186fb73310231a331896482643ab33931c05073e5e"
29
+ },
30
+ "html": {
31
+ "source": "https://raw.githubusercontent.com/jesseweed/seti-ui/2d6c5e68b4ded73c92dac291845ee44e1182d511/icons/html.svg",
32
+ "upstream_sha256": "96c58a9a1dd9467412383caaf94d1f2afdceca7ea3a86c0da799219482e14709"
33
+ },
34
+ "css": {
35
+ "source": "https://raw.githubusercontent.com/jesseweed/seti-ui/2d6c5e68b4ded73c92dac291845ee44e1182d511/icons/css.svg",
36
+ "upstream_sha256": "710485c53da43d6ca2b24c653feb38e2fe8826437293c1db433e212e14c127fc"
37
+ },
38
+ "python": {
39
+ "source": "https://raw.githubusercontent.com/jesseweed/seti-ui/2d6c5e68b4ded73c92dac291845ee44e1182d511/icons/python.svg",
40
+ "upstream_sha256": "366e9a8b7ef95ec16d876e893703f8aa2150935a4798a6f8e4ed3838bd8be7a1"
41
+ },
42
+ "shell": {
43
+ "source": "https://raw.githubusercontent.com/jesseweed/seti-ui/2d6c5e68b4ded73c92dac291845ee44e1182d511/icons/shell.svg",
44
+ "upstream_sha256": "6451507061f339c16d7e04793bb2531245498dc1e2d8ede1c385337f1e847a2c"
45
+ },
46
+ "config": {
47
+ "source": "https://raw.githubusercontent.com/jesseweed/seti-ui/2d6c5e68b4ded73c92dac291845ee44e1182d511/icons/config.svg",
48
+ "upstream_sha256": "217cf6c3ccc4e8b4bfb852541c39346f02e0697079175ec7302bc22a30bd59ca"
49
+ },
50
+ "image": {
51
+ "source": "https://raw.githubusercontent.com/jesseweed/seti-ui/2d6c5e68b4ded73c92dac291845ee44e1182d511/icons/image.svg",
52
+ "upstream_sha256": "cc80f50862719e8873e28e7d02aa72f31fafe136b80edcdb2717a7d4e2200439"
53
+ },
54
+ "svg": {
55
+ "source": "https://raw.githubusercontent.com/jesseweed/seti-ui/2d6c5e68b4ded73c92dac291845ee44e1182d511/icons/svg.svg",
56
+ "upstream_sha256": "1008d60478d5ee5c8b9ce557c589ecce69da48c899a94760c52ef5ea0b5b7555"
57
+ },
58
+ "git": {
59
+ "source": "https://raw.githubusercontent.com/jesseweed/seti-ui/2d6c5e68b4ded73c92dac291845ee44e1182d511/icons/git.svg",
60
+ "upstream_sha256": "d489688dde724ee4ba28182ba21185ee9cd8524a6bab40bae72c82c5cafd9af1"
61
+ },
62
+ "pdf": {
63
+ "source": "https://raw.githubusercontent.com/jesseweed/seti-ui/2d6c5e68b4ded73c92dac291845ee44e1182d511/icons/pdf.svg",
64
+ "upstream_sha256": "c1f3f09b2283f797f5e977cca244eba8e6d1ec50b3e7944f8d75348efed4a3c8"
65
+ },
66
+ "zip": {
67
+ "source": "https://raw.githubusercontent.com/jesseweed/seti-ui/2d6c5e68b4ded73c92dac291845ee44e1182d511/icons/zip.svg",
68
+ "upstream_sha256": "ef76ed8cac7f1203def4fdd138037601d6b96272ac8b833ab91439ec582bd3ee"
69
+ },
70
+ "db": {
71
+ "source": "https://raw.githubusercontent.com/jesseweed/seti-ui/2d6c5e68b4ded73c92dac291845ee44e1182d511/icons/db.svg",
72
+ "upstream_sha256": "12d620a9c1d8da4207867a9c595d0acb267f59e6b4dca2500b3ac8de26dcd28c"
73
+ }
74
+ }
app/public/modal-cat-v1.png ADDED

Git LFS Details

  • SHA256: a766512083268f7d7bb31f37171d6cd05a5af81e38c2612ac41cbb473a2c5f6c
  • Pointer size: 131 Bytes
  • Size of remote file: 513 kB
app/public/openbmb-avatar.webp ADDED
app/public/social-thumbnail-v3.png ADDED

Git LFS Details

  • SHA256: eec706e1875b722ec2147a6edc0a0abc562d6f9a7a1158e356aad421a21ebb48
  • Pointer size: 132 Bytes
  • Size of remote file: 2.12 MB
app/scripts/licenses.mjs ADDED
@@ -0,0 +1,27 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { readdir, readFile, mkdir, writeFile } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+
4
+ // Include dependency notices with the static distributable, without fetching
5
+ // anything at build time. Some upstream Pi packages omit a LICENSE file;
6
+ // their repository license is included in public/licenses/pi-LICENSE.
7
+ export async function collectLicenses() {
8
+ const notices = [];
9
+ async function scan(root) {
10
+ for (const entry of await readdir(root, { withFileTypes: true })) {
11
+ if (!entry.isDirectory() || entry.name.startsWith('.')) continue;
12
+ const directory = path.join(root, entry.name);
13
+ if (entry.name.startsWith('@')) { await scan(directory); continue; }
14
+ let pkg;
15
+ try { pkg = JSON.parse(await readFile(path.join(directory, 'package.json'), 'utf8')); } catch { continue; }
16
+ const texts = [];
17
+ for (const file of await readdir(directory, { withFileTypes: true })) {
18
+ if (file.isFile() && /^(licen[cs]e|copying|notice|thirdpartynotices)/i.test(file.name)) texts.push(await readFile(path.join(directory, file.name), 'utf8'));
19
+ }
20
+ notices.push(`${pkg.name}@${pkg.version} (${pkg.license ?? 'see upstream'})\n${texts.join('\n')}\n`);
21
+ }
22
+ }
23
+ await scan('node_modules');
24
+ await mkdir('dist/licenses', { recursive: true });
25
+ await writeFile('dist/licenses/dependencies.txt', notices.sort().join('\n----------------------------------------\n\n'));
26
+ await writeFile('dist/licenses/pi-components.json', await readFile('vendor/pi-coding-agent/provenance.json'));
27
+ }
app/scripts/pi-browser.mjs ADDED
@@ -0,0 +1,34 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { resolve } from 'node:path';
2
+ import { readFileSync } from 'node:fs';
3
+ import { createHash } from 'node:crypto';
4
+
5
+ // Adapt host services at build time; never edit installed or vendored Pi code.
6
+ // Exact package pin + component checksums make this boundary reviewable.
7
+ export function piBrowser() {
8
+ const sourceRoot = resolve('vendor/pi-coding-agent');
9
+ const provenance = JSON.parse(readFileSync(resolve(sourceRoot, 'provenance.json'), 'utf8'));
10
+ for (const [file, expected] of Object.entries(provenance.files)) {
11
+ const actual = createHash('sha256').update(readFileSync(resolve(sourceRoot, file))).digest('hex');
12
+ if (actual !== expected) throw Error(`Vendored Pi component differs from its upstream checksum: ${file}`);
13
+ }
14
+ const host = resolve('src/pi/browser-host.mjs');
15
+ return {
16
+ name: 'pi-browser-host', enforce: 'pre',
17
+ resolveId(source, importer = '') {
18
+ if (source === '@earendil-works/pi-tui') return resolve('src/pi/entry.mjs');
19
+ if (importer.includes('/@earendil-works/pi-tui/')) {
20
+ if (source.endsWith('/terminal-image.js')) return resolve('src/pi/capabilities.mjs');
21
+ if (source === 'node:perf_hooks') return host;
22
+ }
23
+ if (importer.includes('/vendor/pi-coding-agent/') && source === '../theme/theme.js') return resolve('src/pi/theme.mjs');
24
+ },
25
+ transform(code, id) {
26
+ if (!id.includes('/@earendil-works/pi-tui/') || !id.split('?')[0].endsWith('.js')) return;
27
+ // Browser timer IDs don't have Node's event-loop keepalive methods.
28
+ // Scrolling, selection autoscroll and flash timers still run normally.
29
+ let adapted = code.replace(/\.unref\(\)/g, '.unref?.()');
30
+ if (/\b(?:process|Buffer)\./.test(code)) adapted = `import { browserProcess as process, Buffer } from ${JSON.stringify(host)};\n${adapted}`;
31
+ if (adapted !== code) return { code: adapted, map: null };
32
+ },
33
+ };
34
+ }
app/src/agent.worker.mjs ADDED
@@ -0,0 +1,102 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Agent } from '@earendil-works/pi-agent-core';
2
+ import { createInference, localModel } from './inference.mjs';
3
+ import { createWorkspace, initialFiles, legacyProductsCsv, legacyProductsJson, legacyCostProductsJson, legacySalesCsv, legacyWorkspaceReadme, legacyProfitReadme } from './workspace.mjs';
4
+ import { openStorage } from './storage.mjs';
5
+ import { isModelCached } from './download.mjs';
6
+
7
+ const emit = data => postMessage(data);
8
+ const inference = createInference(emit);
9
+ let workspace, agent, storage, busy = false, operation, turnCount = 0;
10
+ const systemPrompt = `You are Pi, a coding assistant powered by MiniCPM5-2B, a language model from OpenBMB.
11
+ The model, inference, tools, and workspace run entirely in the user's browser. Workspace files are stored in this browser, not on the user's operating-system filesystem.
12
+ Use your tools to inspect and change actual files. Never claim a command ran or a file changed without a successful tool result.
13
+ When the user explicitly asks you to use a tool, call that tool even if you already know the answer.
14
+ Working directory: /workspace. Use read, write, edit, and bash. Keep tool calls and answers concise.
15
+ The bash tool supports cat, ls, find, grep, sed, awk, jq, sort, wc, printf, pipes, redirects, and shell scripts.
16
+ Use jq for JSON and awk for CSV and calculations; python, python3, node, npm, and native binaries are unavailable.
17
+ Use curl -fsS for HTTP requests to the hosts listed by the bash tool. To save a response, use curl -fsS URL -o file.json, then inspect it with jq or read. For pipelines use set -o pipefail so failed downloads are not mistaken for success. CORS and redirect errors are browser limitations: report them accurately; do not invent fetched content or repeatedly retry blocked requests.
18
+ You can create and edit text files, including HTML, CSS, JavaScript, and shell scripts. External URLs may be referenced without fetching them. Only claim an asset was fetched or tested after a successful tool result. HTTP requests send the requested URL and any explicit request data to that external server; model inference stays local.
19
+ Interpret short follow-ups using the conversation and inspect the relevant files when needed.
20
+ Inspect files before editing them. After changing files, verify the result with a tool. Finish with a short factual answer.`;
21
+
22
+ async function snapshot() { return { version: 1, demoVersion: 4, files: await workspace.snapshot(), entries: await workspace.serialize(), messages: agent.state.messages }; }
23
+ async function persist() {
24
+ const state = await snapshot();
25
+ try { await storage?.save(state); } catch (e) { emit({ type: 'persistence_error', error: 'Changes are in memory, but browser storage failed: ' + e.message }); }
26
+ emit({ type: 'workspace', files: state.files });
27
+ return state;
28
+ }
29
+ const initialized = (async () => {
30
+ let saved;
31
+ try { storage = await openStorage(); saved = await storage.load(); }
32
+ catch (e) { emit({ type: 'persistence_error', error: 'Workspace saving is unavailable: ' + e.message }); }
33
+ workspace = createWorkspace(saved?.files, saved?.entries);
34
+ await workspace.ready;
35
+ const upgradeDemo = saved && (saved.demoVersion ?? 0) < 4;
36
+ if (upgradeDemo) {
37
+ // Move prices and quantities together only for a recognized untouched demo.
38
+ // Never remove a price column if its companion file was edited or deleted.
39
+ const paths = workspace.bash.fs.getAllPaths();
40
+ const csv = '/workspace/products.csv', json = '/workspace/products.json', sales = '/workspace/sales.csv';
41
+ const untouched = async (file, versions) => paths.includes(file) && (await workspace.bash.fs.lstat(file)).isFile && versions.includes(await workspace.read(file));
42
+ if (await untouched(sales, [legacySalesCsv])) {
43
+ const oldJson = await untouched(json, [legacyProductsJson, legacyCostProductsJson]);
44
+ const oldCsv = !paths.includes(json) && await untouched(csv, [legacyProductsCsv]);
45
+ const originalDemo = (saved.demoVersion ?? 0) < 1 && !paths.includes(json) && !paths.includes(csv);
46
+ if (oldJson || oldCsv || originalDemo) {
47
+ await workspace.write(sales, initialFiles[sales]);
48
+ await workspace.write(json, initialFiles[json]);
49
+ if (oldCsv) await workspace.bash.fs.rm(csv);
50
+ }
51
+ }
52
+ const readme = '/workspace/README.md';
53
+ if (await untouched(readme, [legacyWorkspaceReadme, legacyProfitReadme])) {
54
+ await workspace.write(readme, initialFiles[readme]);
55
+ }
56
+ }
57
+ agent = new Agent({ initialState: { model: localModel, thinkingLevel: 'medium', systemPrompt, tools: workspace.tools, messages: saved?.messages ?? [] },
58
+ streamFn: inference.streamFn, toolExecution: 'sequential',
59
+ afterToolCall: ({ result, isError }) => ({ isError: isError || (result.details?.exitCode !== undefined && result.details.exitCode !== 0) }),
60
+ shouldStopAfterTurn: ({ toolResults }) => {
61
+ const stop = ++turnCount >= 12 && toolResults.length > 0;
62
+ if (stop) emit({ type: 'notice', text: 'Paused after 12 agent turns. Send a follow-up to continue.' });
63
+ return stop;
64
+ },
65
+ });
66
+ agent.subscribe(async event => {
67
+ emit({ type: 'agent_event', event });
68
+ if (event.type === 'tool_execution_end' || event.type === 'agent_end') await persist();
69
+ });
70
+ const state = await snapshot();
71
+ if (upgradeDemo) {
72
+ try { await storage?.save(state); }
73
+ catch (e) { emit({ type: 'persistence_error', error: 'Changes are in memory, but browser storage failed: ' + e.message }); }
74
+ }
75
+ emit({ type: 'initialized', ...state });
76
+ })();
77
+
78
+ onmessage = async ({ data }) => {
79
+ const { id, action, args = {} } = data;
80
+ const reply = value => emit({ type: 'reply', id, value });
81
+ try {
82
+ await initialized;
83
+ if (action === 'stop') { operation?.abort(); agent.abort(); inference.stop(); return reply(true); }
84
+ if (action === 'snapshot') return reply(await snapshot());
85
+ if (action === 'cache_status') return reply(await isModelCached());
86
+ if (busy) throw Error('Wait for the current operation or stop it first.');
87
+ busy = true; operation = new AbortController(); emit({ type: 'busy', action, cachedOnly: args.cachedOnly === true });
88
+ try {
89
+ let value;
90
+ if (action === 'load') { await inference.load(args, operation.signal); value = inference.device; }
91
+ else if (action === 'prompt') {
92
+ turnCount = 0; await agent.prompt(args.text); value = await snapshot();
93
+ } else if (action === 'shell') {
94
+ value = await workspace.bash.exec(args.command, { signal: operation.signal }); await persist();
95
+ } else if (action === 'write') { await workspace.write(args.path, args.content); value = await persist(); }
96
+ else if (action === 'new_chat') { agent.reset(); value = await persist(); }
97
+ else if (action === 'read') value = await workspace.read(args.path);
98
+ else throw Error('Unknown action: ' + action);
99
+ reply(value);
100
+ } finally { busy = false; operation = undefined; emit({ type: 'idle' }); }
101
+ } catch (error) { emit({ type: 'reply', id, error: error?.name === 'AbortError' ? 'Stopped.' : String(error?.message ?? error), errorName: error?.name, errorCode: error?.code }); }
102
+ };
app/src/browser-http.mjs ADDED
@@ -0,0 +1,67 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // just-bash's curl/wget use this browser transport. There is no proxy/server.
2
+ export const HTTP_ORIGINS = Object.freeze([
3
+ 'https://api.github.com',
4
+ 'https://raw.githubusercontent.com',
5
+ 'https://huggingface.co',
6
+ 'https://jsonplaceholder.typicode.com',
7
+ 'https://httpbin.org',
8
+ ]);
9
+ export const HTTP_METHODS = Object.freeze(['GET', 'HEAD', 'POST']);
10
+ export const HTTP_LIMIT = 1024 * 1024;
11
+ export const HTTP_TIMEOUT = 8_000;
12
+
13
+ export function createBrowserFetch({ transport = globalThis.fetch, origins = HTTP_ORIGINS,
14
+ timeoutMs = HTTP_TIMEOUT, maxBytes = HTTP_LIMIT } = {}) {
15
+ const allowed = new Set(origins);
16
+ return async (input, options = {}) => {
17
+ const url = new URL(input);
18
+ if (url.protocol !== 'https:' || !allowed.has(url.origin)) {
19
+ throw Error(`HTTP destination is not enabled in this experiment. Allowed hosts: ${[...allowed].map(x => new URL(x).host).join(', ')}.`);
20
+ }
21
+ if (url.username || url.password) throw Error('Credentials in URLs are unsupported. Use an explicit Authorization header if needed.');
22
+ const method = (options.method ?? 'GET').toUpperCase();
23
+ if (!HTTP_METHODS.includes(method)) throw Error(`HTTP method ${method} is not enabled. Use GET, HEAD, or POST.`);
24
+ const requestedTimeout = options.timeoutMs > 0 ? options.timeoutMs : timeoutMs;
25
+ const controller = new AbortController();
26
+ const deadline = Math.min(requestedTimeout, timeoutMs);
27
+ const timer = setTimeout(() => controller.abort(new DOMException(`HTTP request timed out after ${deadline} ms.`, 'TimeoutError')), deadline);
28
+ const signal = options.signal ? AbortSignal.any([options.signal, controller.signal]) : controller.signal;
29
+ let reader;
30
+ try {
31
+ signal.throwIfAborted();
32
+ const response = await transport(url.href, { method, headers: options.headers,
33
+ body: ['GET', 'HEAD'].includes(method) ? undefined : options.body,
34
+ mode: 'cors', credentials: 'omit', redirect: 'manual', referrerPolicy: 'no-referrer', signal });
35
+ // Browsers hide both the Location header and status on manual redirects.
36
+ // Following automatically would skip the host check at intermediate hops.
37
+ if (response.type === 'opaqueredirect' || (response.status >= 300 && response.status < 400 && response.status !== 304)) {
38
+ throw Error('HTTP redirect cannot be followed safely in this browser shell. Use the final HTTPS URL directly (including exact capitalization).');
39
+ }
40
+ if (response.status === 0 || response.type === 'opaque') throw Error('HTTP response is unreadable. The destination must allow browser access with CORS.');
41
+ if (Number(response.headers.get('content-length')) > maxBytes) {
42
+ await response.body?.cancel();
43
+ throw Error(`HTTP response exceeds the ${maxBytes}-byte limit.`);
44
+ }
45
+ const chunks = []; let size = 0;
46
+ if (response.body) {
47
+ reader = response.body.getReader();
48
+ while (true) {
49
+ const { done, value } = await reader.read();
50
+ signal.throwIfAborted();
51
+ if (done) break;
52
+ size += value.byteLength;
53
+ if (size > maxBytes) { await reader.cancel(); throw Error(`HTTP response exceeds the ${maxBytes}-byte limit.`); }
54
+ chunks.push(value);
55
+ }
56
+ }
57
+ const body = new Uint8Array(size); let offset = 0;
58
+ for (const chunk of chunks) { body.set(chunk, offset); offset += chunk.byteLength; }
59
+ return { status: response.status, statusText: response.statusText,
60
+ headers: Object.fromEntries(response.headers.entries()), body, url: response.url || url.href };
61
+ } catch (error) {
62
+ if (signal.aborted) throw signal.reason;
63
+ if (error instanceof TypeError) throw Error('HTTP request failed: the server may block browser requests (CORS), or the connection failed. No proxy is used.');
64
+ throw error;
65
+ } finally { clearTimeout(timer); reader?.releaseLock(); }
66
+ };
67
+ }
app/src/cat-sound.mjs ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Share one preloaded clip so rapid clicks replay it without stacking meows.
2
+ const meow = new Audio(new URL('../public/cat-meow-v1.mp3', import.meta.url).href);
3
+ meow.preload = 'auto';
4
+ meow.volume = .65;
5
+
6
+ export function playCatSound() {
7
+ // Call directly from the click handler so mobile browsers allow playback.
8
+ try {
9
+ meow.currentTime = 0;
10
+ void meow.play().catch(() => {});
11
+ } catch { /* A failed audio download must not interrupt the cat interaction. */ }
12
+ }
app/src/column-resizer.mjs ADDED
@@ -0,0 +1,84 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const STORAGE_KEY = 'pi-column-ratio';
2
+ const DEFAULT_RATIO = 1 / 1.83;
3
+
4
+ export function createColumnResizer(layout, handle) {
5
+ const desktop = matchMedia('(min-width: 851px)');
6
+ const style = getComputedStyle(layout);
7
+ const minimumChat = parseFloat(style.getPropertyValue('--conversation-min'));
8
+ const minimumWorkspace = parseFloat(style.getPropertyValue('--workspace-min'));
9
+ let ratio = DEFAULT_RATIO, drag, frame = 0;
10
+ try {
11
+ const saved = Number(localStorage.getItem(STORAGE_KEY));
12
+ if (Number.isFinite(saved) && saved > 0 && saved < 1) ratio = saved;
13
+ } catch { /* The divider also works when browser storage is unavailable. */ }
14
+
15
+ function apply() {
16
+ if (!desktop.matches) return;
17
+ const width = layout.getBoundingClientRect().width;
18
+ if (!width) return;
19
+ const chat = Math.max(minimumChat, Math.min(width - minimumWorkspace, width * ratio));
20
+ layout.style.setProperty('--conversation-width', `${chat}px`);
21
+ const percent = Math.round(chat / width * 100);
22
+ handle.setAttribute('aria-valuemin', String(Math.round(minimumChat / width * 100)));
23
+ handle.setAttribute('aria-valuemax', String(Math.round((width - minimumWorkspace) / width * 100)));
24
+ handle.setAttribute('aria-valuenow', String(percent));
25
+ handle.setAttribute('aria-valuetext', `Chat ${percent}%, workspace ${100 - percent}%`);
26
+ }
27
+ function save() { try { localStorage.setItem(STORAGE_KEY, String(ratio)); } catch {} }
28
+ function setWidth(pixels) {
29
+ const width = layout.getBoundingClientRect().width;
30
+ ratio = Math.max(minimumChat, Math.min(width - minimumWorkspace, pixels)) / width;
31
+ apply();
32
+ }
33
+ function flushDrag() {
34
+ cancelAnimationFrame(frame); frame = 0;
35
+ if (drag) setWidth(drag.x - layout.getBoundingClientRect().left - drag.offset);
36
+ }
37
+ function finish(commit) {
38
+ if (!drag) return;
39
+ if (commit) flushDrag();
40
+ else { cancelAnimationFrame(frame); frame = 0; ratio = drag.initialRatio; }
41
+ const pointer = drag.pointer; drag = undefined;
42
+ if (handle.hasPointerCapture(pointer)) handle.releasePointerCapture(pointer);
43
+ document.documentElement.classList.remove('is-resizing-columns');
44
+ apply(); if (commit) save();
45
+ }
46
+ function reset() { finish(false); ratio = DEFAULT_RATIO; apply(); save(); }
47
+ handle.addEventListener('pointerdown', event => {
48
+ if (!desktop.matches || event.button !== 0 || !event.isPrimary || drag) return;
49
+ event.preventDefault(); handle.focus({ preventScroll: true });
50
+ const boundary = layout.querySelector('.conversation').getBoundingClientRect().right;
51
+ drag = { pointer: event.pointerId, initialRatio: ratio, x: event.clientX, offset: event.clientX - boundary };
52
+ handle.setPointerCapture(event.pointerId);
53
+ document.documentElement.classList.add('is-resizing-columns');
54
+ });
55
+ handle.addEventListener('pointermove', event => {
56
+ if (!drag || event.pointerId !== drag.pointer) return;
57
+ drag.x = event.clientX;
58
+ if (!frame) frame = requestAnimationFrame(flushDrag);
59
+ });
60
+ handle.addEventListener('pointerup', event => {
61
+ if (!drag || event.pointerId !== drag.pointer) return;
62
+ drag.x = event.clientX; finish(true);
63
+ });
64
+ handle.addEventListener('pointercancel', () => finish(false));
65
+ handle.addEventListener('lostpointercapture', () => finish(false));
66
+ handle.addEventListener('dblclick', reset);
67
+ handle.addEventListener('keydown', event => {
68
+ if (!desktop.matches) return;
69
+ if (event.key === 'Escape' && drag) { event.preventDefault(); finish(false); return; }
70
+ if (!['ArrowLeft', 'ArrowRight', 'Home', 'End', 'Enter'].includes(event.key)) return;
71
+ event.preventDefault();
72
+ if (event.key === 'Enter') { reset(); return; }
73
+ finish(false);
74
+ const width = layout.querySelector('.conversation').getBoundingClientRect().width;
75
+ const step = event.shiftKey ? 48 : 16;
76
+ setWidth(event.key === 'Home' ? 0 : event.key === 'End' ? Infinity : width + (event.key === 'ArrowLeft' ? -step : step));
77
+ save();
78
+ });
79
+ window.addEventListener('blur', () => finish(false));
80
+ desktop.addEventListener('change', () => { finish(false); apply(); });
81
+ // Keep the preferred ratio on window resize, clamping only its displayed size.
82
+ new ResizeObserver(apply).observe(layout);
83
+ apply();
84
+ }
app/src/context-usage.mjs ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ export const CONTEXT_LIMIT = 8192;
2
+
3
+ // Usage describes the latest model call, not unsent draft text or the full
4
+ // saved transcript (which can contain turns excluded from model context).
5
+ export function restoredContextUsage(messages) {
6
+ const last = messages.findLast(message => message.role === 'assistant' && message.usage?.input > 0);
7
+ if (last) return { inputTokens: last.usage.input, outputTokens: last.usage.output ?? 0 };
8
+ return messages.length ? null : { inputTokens: 0, outputTokens: 0 };
9
+ }
10
+
11
+ export function contextLabel(usage) {
12
+ if (!usage) return `—/${CONTEXT_LIMIT.toLocaleString('en-US')} context`;
13
+ const used = usage.inputTokens + usage.outputTokens;
14
+ const remaining = Math.max(0, CONTEXT_LIMIT - used);
15
+ return `${used.toLocaleString('en-US')}/${CONTEXT_LIMIT.toLocaleString('en-US')} context · ${remaining.toLocaleString('en-US')} free`;
16
+ }
app/src/download.mjs ADDED
@@ -0,0 +1,144 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { createSHA256 } from 'hash-wasm';
2
+ import manifest from './model-manifest.json';
3
+
4
+ export const REVISION = '04a6c49fcba3a65a0351c92644c3a7e9d4343059';
5
+ export const MODEL_ID = 'Mike0021/MiniCPM5-2B-ONNX';
6
+ export const MODEL_BYTES = Object.values(manifest.files).reduce((n, f) => n + f.bytes, 0);
7
+ const CHUNK = 32 * 1024 ** 2;
8
+ const sleep = (ms, signal) => new Promise((resolve, reject) => {
9
+ signal?.throwIfAborted();
10
+ const abort = () => { clearTimeout(timer); reject(signal.reason); };
11
+ const timer = setTimeout(() => { signal?.removeEventListener('abort', abort); resolve(); }, ms);
12
+ signal?.addEventListener('abort', abort, { once: true });
13
+ });
14
+
15
+ async function fetchPart(url, start, end, total, signal) {
16
+ for (let attempt = 0; ; attempt++) {
17
+ try {
18
+ const part = total > CHUNK;
19
+ const response = await fetch(url, { headers: part ? { Range: `bytes=${start}-${end}` } : {},
20
+ signal: AbortSignal.any([signal, AbortSignal.timeout(90_000)]), cache: 'no-store' });
21
+ if (!response.ok) throw Error(`Download returned HTTP ${response.status}`);
22
+ if (part && response.status !== 206) {
23
+ await response.body?.cancel();
24
+ throw Error('The model host did not honor byte ranges.');
25
+ }
26
+ const range = response.headers.get('Content-Range');
27
+ if (range && range !== `bytes ${start}-${end}/${total}`) {
28
+ await response.body?.cancel(); throw Error('Incorrect download range.');
29
+ }
30
+ const data = new Uint8Array(await response.arrayBuffer());
31
+ if (data.length !== end - start + 1) throw Error('Incomplete download chunk.');
32
+ return data;
33
+ } catch (error) {
34
+ signal.throwIfAborted();
35
+ if (attempt === 2) throw error;
36
+ await sleep(500 * 2 ** attempt, signal);
37
+ }
38
+ }
39
+ }
40
+
41
+ export async function prepareModelCache(baseURL, options) {
42
+ if (navigator.locks) return navigator.locks.request('pi-minicpm5-download-' + REVISION,
43
+ { signal: options.signal }, () => downloadModel(baseURL, options));
44
+ return downloadModel(baseURL, options);
45
+ }
46
+
47
+ async function readCacheState(create = false) {
48
+ if (!navigator.storage?.getDirectory) throw Error('This app needs browser file storage (OPFS) to cache the 1.84 GB model. Open it in a regular browser tab.');
49
+ const root = await (await navigator.storage.getDirectory()).getDirectoryHandle('pi-minicpm5-' + REVISION, { create });
50
+ const receiptsFile = await root.getFileHandle('verified.json', { create });
51
+ let receipts;
52
+ try { receipts = JSON.parse(await (await receiptsFile.getFile()).text()); } catch { receipts = {}; }
53
+ if (!receipts || typeof receipts !== 'object' || Array.isArray(receipts)) receipts = {};
54
+ let cached = 0;
55
+ const pending = [];
56
+ const key = file => encodeURIComponent(file);
57
+ for (const [file, spec] of Object.entries(manifest.files)) {
58
+ try {
59
+ const stored = await (await root.getFileHandle(key(file))).getFile();
60
+ const receipt = receipts[file];
61
+ if (stored.size === spec.bytes && receipt?.sha256 === spec.sha256 && receipt.modified === stored.lastModified) {
62
+ cached += spec.bytes; continue;
63
+ }
64
+ } catch (e) { if (e.name !== 'NotFoundError') throw e; }
65
+ pending.push([file, spec]);
66
+ }
67
+ return { root, receiptsFile, receipts, pending, cached };
68
+ }
69
+
70
+ export async function isModelCached() {
71
+ try { return (await readCacheState()).pending.length === 0; }
72
+ catch { return false; }
73
+ }
74
+
75
+ function cacheMiss() {
76
+ return Object.assign(Error('The saved model is incomplete. Confirm the download to restore it.'), { code: 'MODEL_CACHE_MISS' });
77
+ }
78
+
79
+ async function downloadModel(baseURL, { signal, cachedOnly = false, onProgress = () => {} }) {
80
+ let state;
81
+ try { state = await readCacheState(!cachedOnly); }
82
+ catch (error) { if (cachedOnly && error.name === 'NotFoundError') throw cacheMiss(); throw error; }
83
+ const { root, receiptsFile, receipts, pending, cached } = state;
84
+ // Recheck under the download lock: another tab or eviction may have changed
85
+ // the cache after the UI checked it. Automatic restores never fetch weights.
86
+ if (cachedOnly && pending.length) throw cacheMiss();
87
+ let completed = cached;
88
+ const key = file => encodeURIComponent(file);
89
+ onProgress({ phase: 'download', loaded: completed, total: MODEL_BYTES, cached });
90
+ if (pending.length) {
91
+ const estimate = await navigator.storage.estimate();
92
+ if (estimate.quota && estimate.quota - estimate.usage < MODEL_BYTES - completed + CHUNK) {
93
+ throw Error('Not enough browser storage for the model. Free at least 2 GB and retry.');
94
+ }
95
+ }
96
+ const controller = new AbortController();
97
+ const combined = AbortSignal.any([signal, controller.signal]);
98
+ let receiptWrites = Promise.resolve();
99
+ const saveReceipts = () => {
100
+ const serialized = JSON.stringify(receipts);
101
+ const write = receiptWrites.then(async () => {
102
+ const stream = await receiptsFile.createWritable(); await stream.write(serialized); await stream.close();
103
+ });
104
+ receiptWrites = write.catch(() => {}); return write;
105
+ };
106
+ async function worker() {
107
+ while (pending.length) {
108
+ combined.throwIfAborted();
109
+ const [file, spec] = pending.shift();
110
+ const handle = await root.getFileHandle(key(file), { create: true });
111
+ const output = await handle.createWritable();
112
+ try {
113
+ const hash = await createSHA256(); hash.init();
114
+ for (let start = 0; start < spec.bytes; start += CHUNK) {
115
+ const end = Math.min(start + CHUNK, spec.bytes) - 1;
116
+ const data = await fetchPart(baseURL + file, start, end, spec.bytes, combined);
117
+ combined.throwIfAborted();
118
+ hash.update(data); await output.write(data); completed += data.length;
119
+ onProgress({ phase: 'download', file, loaded: completed, total: MODEL_BYTES, cached });
120
+ }
121
+ if (hash.digest('hex') !== spec.sha256) throw Error('SHA-256 verification failed: ' + file);
122
+ await output.close();
123
+ receipts[file] = { sha256: spec.sha256, modified: (await handle.getFile()).lastModified };
124
+ await saveReceipts();
125
+ } catch (error) { await output.abort().catch(() => {}); controller.abort(error); throw error; }
126
+ }
127
+ }
128
+ const outcomes = await Promise.allSettled(Array.from({ length: 6 }, worker));
129
+ const failed = outcomes.find(r => r.status === 'rejected');
130
+ if (failed) throw failed.reason;
131
+ await receiptWrites;
132
+ return {
133
+ cachedBytes: cached,
134
+ async match(request) {
135
+ const url = typeof request === 'string' ? request : request.url;
136
+ if (!url.startsWith(baseURL)) return undefined;
137
+ const file = url.slice(baseURL.length);
138
+ if (!Object.hasOwn(manifest.files, file)) return undefined;
139
+ const stored = await (await root.getFileHandle(key(file))).getFile();
140
+ return new Response(stored, { headers: { 'Content-Length': String(stored.size) } });
141
+ },
142
+ async put() { /* All supported files are already downloaded and verified. */ },
143
+ };
144
+ }
app/src/editor-highlight.worker.mjs ADDED
@@ -0,0 +1,39 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import './prism-worker-setup.mjs';
2
+ import Prism from 'prismjs/components/prism-core.js';
3
+ import 'prismjs/components/prism-markup.js';
4
+ import 'prismjs/components/prism-css.js';
5
+ import 'prismjs/components/prism-clike.js';
6
+ import 'prismjs/components/prism-javascript.js';
7
+ import 'prismjs/components/prism-typescript.js';
8
+ import 'prismjs/components/prism-jsx.js';
9
+ import 'prismjs/components/prism-tsx.js';
10
+ import 'prismjs/components/prism-json.js';
11
+ import 'prismjs/components/prism-yaml.js';
12
+ import 'prismjs/components/prism-markdown.js';
13
+ import 'prismjs/components/prism-python.js';
14
+ import 'prismjs/components/prism-bash.js';
15
+ import 'prismjs/components/prism-toml.js';
16
+ import 'prismjs/components/prism-sql.js';
17
+ import 'prismjs/components/prism-csv.js';
18
+ import 'prismjs/components/prism-c.js';
19
+ import 'prismjs/components/prism-cpp.js';
20
+ import 'prismjs/components/prism-csharp.js';
21
+ import 'prismjs/components/prism-java.js';
22
+ import 'prismjs/components/prism-go.js';
23
+ import 'prismjs/components/prism-rust.js';
24
+ import 'prismjs/components/prism-diff.js';
25
+ import 'prismjs/components/prism-docker.js';
26
+ import { canHighlight } from './editor-language.mjs';
27
+
28
+ Prism.manual = true;
29
+ self.onmessage = ({ data: { id, text, language } }) => {
30
+ let html = null;
31
+ try {
32
+ if (canHighlight(text) && Prism.languages[language]) {
33
+ const highlighted = Prism.highlight(text, Prism.languages[language], language);
34
+ // Avoid building a huge DOM even for short, unusually token-dense files.
35
+ if (highlighted.length < 1_000_000 && (highlighted.match(/<span /g)?.length ?? 0) < 12_000) html = highlighted;
36
+ }
37
+ } catch { /* A grammar failure must never prevent editing a file. */ }
38
+ self.postMessage({ id, html });
39
+ };