Mike0021 commited on
Commit
21e6b9b
·
verified ·
1 Parent(s): e1d0067

Deploy static pi web agent

Browse files
.gitignore ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ node_modules/
2
+ dist/
3
+ .vite/
4
+ .cache/
5
+ output/
6
+ refs/
7
+ source/
8
+ .swapfile
9
+ *.log
README.md CHANGED
@@ -1,10 +1,120 @@
1
  ---
2
  title: MiniCPM5 Pi Web Agent
3
- emoji: 💻
4
- colorFrom: blue
5
- colorTo: indigo
6
  sdk: static
7
- pinned: false
 
 
 
 
 
 
 
 
8
  ---
9
 
10
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
  title: MiniCPM5 Pi Web Agent
 
 
 
3
  sdk: static
4
+ app_build_command: npm ci --omit=dev && npm run build
5
+ app_file: dist/index.html
6
+ fullWidth: true
7
+ models:
8
+ - Mike0021/MiniCPM5-1B-ONNX-Web
9
+ custom_headers:
10
+ cross-origin-embedder-policy: credentialless
11
+ cross-origin-opener-policy: same-origin
12
+ cross-origin-resource-policy: cross-origin
13
  ---
14
 
15
+ # MiniCPM5-1B Pi Web Agent
16
+
17
+ This workspace converts `openbmb/MiniCPM5-1B` into a browser-loadable Transformers.js model and ships a browser-only pi agent app.
18
+
19
+ Published artifact: https://huggingface.co/Mike0021/MiniCPM5-1B-ONNX-Web
20
+
21
+ The required runtime layout is:
22
+
23
+ - `config.json`, `generation_config.json`, tokenizer files, and `chat_template.jinja` at the repo root
24
+ - q4 ONNX weights at `onnx/model_q4.onnx`
25
+ - `config.json` includes `transformers.js_config.dtype = "q4"` so the default loader selects the web-sized artifact
26
+
27
+ The conversion uses an ONNX export with KV cache (`text-generation-with-past`) and then applies ONNX Runtime 4-bit MatMul quantization. A generic ONNX export without KV cache is not enough for normal Transformers.js autoregressive generation.
28
+
29
+ ## Run the Web App
30
+
31
+ ```bash
32
+ npm install
33
+ npm run dev
34
+ ```
35
+
36
+ Open http://localhost:5173/.
37
+
38
+ The app uses:
39
+
40
+ - `@earendil-works/pi-agent-core` for the agent loop, transcript state, and tool execution.
41
+ - `@huggingface/transformers` with `Mike0021/MiniCPM5-1B-ONNX-Web` for the local browser model.
42
+ - `@webcontainer/api` for the client-only sandbox with a virtual filesystem and browser-contained Node.js processes.
43
+
44
+ Vite serves the app with COOP/COEP headers and boots WebContainers with `coep: "credentialless"`. The deterministic test model is available at `http://localhost:5173/?mode=mock&device=wasm` for fast harness and sandbox smoke tests without downloading the full ONNX model.
45
+
46
+ The Static Space uses the same isolation policy through `custom_headers` in this README frontmatter.
47
+
48
+ ## Test the Agent App
49
+
50
+ Start the dev server, then run:
51
+
52
+ ```bash
53
+ npm run smoke:web
54
+ ```
55
+
56
+ The smoke test opens Chromium, confirms `crossOriginIsolated`, boots the WebContainer sandbox, runs the pi agent in deterministic mode, writes `hello.js`, spawns `node hello.js`, and checks for `pi sandbox result: 42` in the transcript.
57
+
58
+ For the heavier end-to-end check with the real MiniCPM5 ONNX model in browser WASM mode:
59
+
60
+ ```bash
61
+ npm run smoke:local-model
62
+ ```
63
+
64
+ This downloads/loads the q4 ONNX artifact in Chromium, runs the same pi/WebContainer task, and checks that the model reaches `Model ready` before the sandbox result is accepted.
65
+
66
+ ## Verify the Published Artifact
67
+
68
+ ```bash
69
+ npm install
70
+ node scripts/verify_tjs_model.mjs Mike0021/MiniCPM5-1B-ONNX-Web
71
+ ```
72
+
73
+ The verifier asks Transformers.js for the `text-generation` file plan, checks for `onnx/model_q4.onnx`, then loads the model and generates a short completion.
74
+
75
+ ## Convert and Upload
76
+
77
+ The published repo was produced locally with a CPU fp16 export followed by q4 ONNX quantization:
78
+
79
+ ```bash
80
+ uv run --python 3.12 \
81
+ --with "numpy<2" \
82
+ --with "transformers==4.57.6" \
83
+ --with "optimum[onnx]" \
84
+ --with "onnxruntime==1.20.1" \
85
+ --with onnxslim \
86
+ --with "huggingface_hub>=0.33" \
87
+ --with accelerate \
88
+ --with sentencepiece \
89
+ --with protobuf \
90
+ scripts/convert_minicpm5_tjs.py \
91
+ --source-model openbmb/MiniCPM5-1B \
92
+ --target-repo Mike0021/MiniCPM5-1B-ONNX-Web \
93
+ --output-dir output/MiniCPM5-1B-ONNX-Web \
94
+ --work-dir output/minicpm5-work \
95
+ --device cpu \
96
+ --export-dtype fp16
97
+ ```
98
+
99
+ For a clean remote conversion, the same script can be run on Hugging Face Jobs with a configured Hub token:
100
+
101
+ ```bash
102
+ hf repos create Mike0021/MiniCPM5-1B-ONNX-Web --repo-type model --exist-ok
103
+ hf jobs uv run scripts/convert_minicpm5_tjs.py \
104
+ --flavor l4x1 \
105
+ --timeout 6h \
106
+ --secrets HF_TOKEN \
107
+ --with "numpy<2" \
108
+ --with "transformers==4.57.6" \
109
+ --with "optimum[onnx]" \
110
+ --with "onnxruntime==1.20.1" \
111
+ --with onnxslim \
112
+ --with "huggingface_hub>=0.33" \
113
+ --with accelerate \
114
+ --with sentencepiece \
115
+ --with protobuf \
116
+ --python 3.12 \
117
+ -- \
118
+ --target-repo Mike0021/MiniCPM5-1B-ONNX-Web \
119
+ --export-dtype fp16
120
+ ```
index.html CHANGED
@@ -1,19 +1,81 @@
1
  <!doctype html>
2
- <html>
3
- <head>
4
- <meta charset="utf-8" />
5
- <meta name="viewport" content="width=device-width" />
6
- <title>My static Space</title>
7
- <link rel="stylesheet" href="style.css" />
8
- </head>
9
- <body>
10
- <div class="card">
11
- <h1>Welcome to your static Space!</h1>
12
- <p>You can modify this app directly by editing <i>index.html</i> in the Files and versions tab.</p>
13
- <p>
14
- Also don't forget to check the
15
- <a href="https://huggingface.co/docs/hub/spaces" target="_blank">Spaces documentation</a>.
16
- </p>
17
- </div>
18
- </body>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
19
  </html>
 
 
1
  <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <title>MiniCPM5 Pi Web Agent</title>
7
+ </head>
8
+ <body>
9
+ <main id="app">
10
+ <section class="shell">
11
+ <header class="topbar">
12
+ <div>
13
+ <h1>Pi Web Agent</h1>
14
+ <p id="model-label"></p>
15
+ </div>
16
+ <div class="status-stack" aria-label="Runtime status">
17
+ <span class="status" id="status">Idle</span>
18
+ <span class="status" id="model-status">Model idle</span>
19
+ <span class="status" id="sandbox-status">Sandbox idle</span>
20
+ </div>
21
+ </header>
22
+
23
+ <section class="controls" aria-label="Agent controls">
24
+ <label>
25
+ Model
26
+ <select id="mode">
27
+ <option value="local">MiniCPM5 q4</option>
28
+ <option value="mock">Deterministic test</option>
29
+ </select>
30
+ </label>
31
+ <label>
32
+ Device
33
+ <select id="device">
34
+ <option value="webgpu">WebGPU</option>
35
+ <option value="wasm">WASM</option>
36
+ </select>
37
+ </label>
38
+ <label>
39
+ Max tokens
40
+ <input id="max-new-tokens" type="number" min="16" max="512" step="1" value="160" />
41
+ </label>
42
+ <label>
43
+ Temperature
44
+ <input id="temperature" type="number" min="0" max="1.5" step="0.05" value="0" />
45
+ </label>
46
+ </section>
47
+
48
+ <section class="command-row" aria-label="Sandbox actions">
49
+ <button id="boot-sandbox" type="button">Boot Sandbox</button>
50
+ <button id="reset-sandbox" type="button">Reset</button>
51
+ <button id="demo-prompt" type="button">Demo Prompt</button>
52
+ </section>
53
+
54
+ <section class="workspace">
55
+ <div class="prompt-pane">
56
+ <label class="prompt-label" for="prompt">Task</label>
57
+ <textarea id="prompt" spellcheck="true">Create hello.js that prints the result of 21 * 2, run it with Node, and tell me the command output.</textarea>
58
+ <button id="run" type="button">Run Agent</button>
59
+ </div>
60
+
61
+ <div class="panes">
62
+ <section class="pane">
63
+ <h2>Transcript</h2>
64
+ <pre id="transcript"></pre>
65
+ </section>
66
+ <section class="pane">
67
+ <h2>Sandbox Files</h2>
68
+ <pre id="files"></pre>
69
+ </section>
70
+ <section class="pane wide">
71
+ <h2>Events</h2>
72
+ <pre id="event-log"></pre>
73
+ </section>
74
+ </div>
75
+ </section>
76
+ </section>
77
+ </main>
78
+ <script type="module" src="/src/main.js"></script>
79
+ </body>
80
  </html>
81
+
package-lock.json ADDED
The diff for this file is too large to render. See raw diff
 
package.json ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "minicpm5-transformersjs-web",
3
+ "private": true,
4
+ "type": "module",
5
+ "scripts": {
6
+ "dev": "vite --host 0.0.0.0",
7
+ "build": "vite build",
8
+ "preview": "vite preview --host 0.0.0.0",
9
+ "smoke:local-model": "node scripts/smoke_local_model_web_agent.mjs",
10
+ "smoke:web": "node scripts/smoke_web_agent.mjs",
11
+ "verify": "node scripts/verify_tjs_model.mjs"
12
+ },
13
+ "dependencies": {
14
+ "@earendil-works/pi-agent-core": "^0.75.5",
15
+ "@earendil-works/pi-ai": "^0.75.5",
16
+ "@huggingface/transformers": "^4.2.0",
17
+ "@webcontainer/api": "^1.6.4",
18
+ "vite": "^7.2.0"
19
+ },
20
+ "devDependencies": {
21
+ "@playwright/test": "^1.60.0"
22
+ }
23
+ }
scripts/convert_minicpm5_tjs.py ADDED
@@ -0,0 +1,229 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Convert openbmb/MiniCPM5-1B to a Transformers.js q4 ONNX repo."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+ import json
8
+ import logging
9
+ import os
10
+ import shutil
11
+ import subprocess
12
+ import sys
13
+ import tarfile
14
+ import tempfile
15
+ import urllib.request
16
+ from dataclasses import asdict
17
+ from pathlib import Path
18
+
19
+ from huggingface_hub import HfApi, create_repo
20
+ from optimum.exporters.onnx import main_export
21
+ from transformers import AutoConfig
22
+
23
+
24
+ SOURCE_MODEL = "openbmb/MiniCPM5-1B"
25
+ TRANSFORMERS_JS_TAG = "3.8.1"
26
+ TRANSFORMERS_JS_TARBALL = (
27
+ f"https://github.com/huggingface/transformers.js/archive/refs/tags/{TRANSFORMERS_JS_TAG}.tar.gz"
28
+ )
29
+
30
+
31
+ def run(cmd: list[str], cwd: Path | None = None) -> None:
32
+ print("+", " ".join(cmd), flush=True)
33
+ subprocess.run(cmd, cwd=cwd, check=True)
34
+
35
+
36
+ def download_transformers_js(work_dir: Path) -> Path:
37
+ archive_path = work_dir / "transformers.js.tar.gz"
38
+ urllib.request.urlretrieve(TRANSFORMERS_JS_TARBALL, archive_path)
39
+ with tarfile.open(archive_path) as archive:
40
+ archive.extractall(work_dir)
41
+ return work_dir / f"transformers.js-{TRANSFORMERS_JS_TAG}"
42
+
43
+
44
+ def patch_config(config_path: Path, dtype: str, q4_external_chunks: int) -> None:
45
+ config = json.loads(config_path.read_text())
46
+ config.setdefault("transformers.js_config", {})
47
+ config["transformers.js_config"]["dtype"] = dtype
48
+ if q4_external_chunks:
49
+ config["transformers.js_config"]["use_external_data_format"] = {
50
+ "model_q4.onnx": q4_external_chunks,
51
+ }
52
+ else:
53
+ config["transformers.js_config"].pop("use_external_data_format", None)
54
+ config_path.write_text(json.dumps(config, indent=2) + "\n")
55
+
56
+
57
+ def write_readme(output_dir: Path, source_model: str, target_repo: str) -> None:
58
+ readme = f"""---
59
+ license: apache-2.0
60
+ library_name: transformers.js
61
+ pipeline_tag: text-generation
62
+ base_model: {source_model}
63
+ tags:
64
+ - transformers.js
65
+ - onnx
66
+ - onnxruntime-web
67
+ - llama
68
+ - minicpm5
69
+ - text-generation
70
+ - browser
71
+ - webgpu
72
+ ---
73
+
74
+ # MiniCPM5-1B ONNX Web
75
+
76
+ Transformers.js q4 ONNX export of `{source_model}` for browser text generation.
77
+
78
+ ## Files
79
+
80
+ - `onnx/model_q4.onnx`: ONNX Runtime 4-bit MatMul quantized decoder with KV cache.
81
+ - `config.json`: includes `transformers.js_config.dtype = "q4"` so Transformers.js loads the q4 artifact by default.
82
+ - tokenizer and generation config files copied from the source model export.
83
+
84
+ ## Usage
85
+
86
+ ```js
87
+ import {{ pipeline }} from "@huggingface/transformers";
88
+
89
+ const generator = await pipeline("text-generation", "{target_repo}", {{
90
+ dtype: "q4",
91
+ device: "webgpu",
92
+ }});
93
+
94
+ const output = await generator("Briefly introduce yourself.", {{
95
+ max_new_tokens: 64,
96
+ temperature: 0.2,
97
+ do_sample: true,
98
+ }});
99
+ console.log(output[0].generated_text);
100
+ ```
101
+
102
+ If WebGPU is unavailable, use `device: "wasm"` in the browser.
103
+ """
104
+ (output_dir / "README.md").write_text(readme)
105
+
106
+
107
+ def convert(args: argparse.Namespace) -> Path:
108
+ work_dir = Path(args.work_dir or tempfile.mkdtemp(prefix="minicpm5-tjs-")).resolve()
109
+ work_dir.mkdir(parents=True, exist_ok=True)
110
+ print(f"Working directory: {work_dir}", flush=True)
111
+
112
+ transformers_js_dir = download_transformers_js(work_dir)
113
+ scripts_dir = transformers_js_dir / "scripts"
114
+ sys.path.insert(0, str(transformers_js_dir))
115
+
116
+ from scripts.quantize import QuantizationArguments, quantize
117
+ logging.getLogger("onnxruntime.quantization.matmul_4bits_quantizer").setLevel(logging.WARNING)
118
+
119
+ export_root = work_dir / "export"
120
+ model_dir = export_root / args.source_model
121
+ model_dir.mkdir(parents=True, exist_ok=True)
122
+
123
+ device = args.device
124
+ if device == "auto":
125
+ try:
126
+ import torch
127
+
128
+ device = "cuda" if torch.cuda.is_available() else "cpu"
129
+ except Exception:
130
+ device = "cpu"
131
+ print(f"Export device: {device}", flush=True)
132
+
133
+ config = AutoConfig.from_pretrained(args.source_model)
134
+ print(
135
+ "Source config:",
136
+ json.dumps(
137
+ {
138
+ "model_type": config.model_type,
139
+ "architectures": getattr(config, "architectures", None),
140
+ "hidden_size": getattr(config, "hidden_size", None),
141
+ "num_hidden_layers": getattr(config, "num_hidden_layers", None),
142
+ "torch_dtype": str(getattr(config, "torch_dtype", None)),
143
+ },
144
+ indent=2,
145
+ ),
146
+ flush=True,
147
+ )
148
+
149
+ main_export(
150
+ model_name_or_path=args.source_model,
151
+ output=model_dir,
152
+ task="text-generation-with-past",
153
+ opset=args.opset,
154
+ device=device,
155
+ dtype=args.export_dtype,
156
+ do_validation=False,
157
+ trust_remote_code=False,
158
+ library_name="transformers",
159
+ slim=False,
160
+ )
161
+
162
+ onnx_dir = model_dir / "onnx"
163
+ onnx_dir.mkdir(exist_ok=True)
164
+
165
+ quant_args = QuantizationArguments(
166
+ modes=["q4"],
167
+ per_channel=False,
168
+ reduce_range=False,
169
+ block_size=args.block_size,
170
+ is_symmetric=True,
171
+ accuracy_level=None,
172
+ op_block_list=None,
173
+ )
174
+ quantize(str(model_dir), str(onnx_dir), quant_args)
175
+ (model_dir / "quantize_config.json").write_text(json.dumps(asdict(quant_args), indent=2) + "\n")
176
+
177
+ for path in model_dir.glob("*.onnx*"):
178
+ path.unlink()
179
+
180
+ q4_model = onnx_dir / "model_q4.onnx"
181
+ if not q4_model.exists():
182
+ raise FileNotFoundError(f"Missing expected quantized model: {q4_model}")
183
+
184
+ q4_external_chunks = 1 if (onnx_dir / "model_q4.onnx_data").exists() else 0
185
+ patch_config(model_dir / "config.json", "q4", q4_external_chunks)
186
+ write_readme(model_dir, args.source_model, args.target_repo)
187
+
188
+ if args.output_dir:
189
+ final_dir = Path(args.output_dir).resolve()
190
+ if final_dir.exists():
191
+ shutil.rmtree(final_dir)
192
+ shutil.copytree(model_dir, final_dir)
193
+ model_dir = final_dir
194
+
195
+ print("Final files:", flush=True)
196
+ for file in sorted(p.relative_to(model_dir).as_posix() for p in model_dir.rglob("*") if p.is_file()):
197
+ print(file, flush=True)
198
+
199
+ if args.target_repo:
200
+ token = os.environ.get("HF_TOKEN") or True
201
+ create_repo(args.target_repo, repo_type="model", private=args.private, exist_ok=True, token=token)
202
+ api = HfApi(token=token)
203
+ api.upload_folder(
204
+ repo_id=args.target_repo,
205
+ repo_type="model",
206
+ folder_path=str(model_dir),
207
+ commit_message=f"Add q4 Transformers.js export of {args.source_model}",
208
+ )
209
+ print(f"Uploaded to https://huggingface.co/{args.target_repo}", flush=True)
210
+
211
+ return model_dir
212
+
213
+
214
+ def parse_args() -> argparse.Namespace:
215
+ parser = argparse.ArgumentParser()
216
+ parser.add_argument("--source-model", default=SOURCE_MODEL)
217
+ parser.add_argument("--target-repo", default="")
218
+ parser.add_argument("--output-dir", default="")
219
+ parser.add_argument("--work-dir", default="")
220
+ parser.add_argument("--device", default="auto", choices=["auto", "cpu", "cuda"])
221
+ parser.add_argument("--export-dtype", default="fp32", choices=["fp32", "fp16"])
222
+ parser.add_argument("--opset", type=int, default=18)
223
+ parser.add_argument("--block-size", type=int, default=32)
224
+ parser.add_argument("--private", action="store_true")
225
+ return parser.parse_args()
226
+
227
+
228
+ if __name__ == "__main__":
229
+ convert(parse_args())
scripts/smoke_local_model_web_agent.mjs ADDED
@@ -0,0 +1,61 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { chromium } from "@playwright/test";
2
+
3
+ const baseUrl = process.argv[2] || "http://localhost:5173/?device=wasm";
4
+ const executablePath = process.env.CHROMIUM_PATH || "/snap/bin/chromium";
5
+
6
+ const browser = await chromium.launch({
7
+ executablePath,
8
+ headless: true,
9
+ args: ["--no-sandbox", "--disable-dev-shm-usage"],
10
+ });
11
+
12
+ try {
13
+ const page = await browser.newPage();
14
+ page.setDefaultTimeout(600000);
15
+ const consoleLines = [];
16
+ page.on("console", (message) => {
17
+ consoleLines.push(`${message.type()}: ${message.text()}`);
18
+ });
19
+ page.on("pageerror", (error) => {
20
+ consoleLines.push(`pageerror: ${error.stack || error.message}`);
21
+ });
22
+
23
+ await page.goto(baseUrl, { waitUntil: "networkidle" });
24
+ await page.waitForFunction(() => document.querySelector("#status")?.textContent === "Ready");
25
+
26
+ const isolated = await page.evaluate(() => globalThis.crossOriginIsolated);
27
+ if (!isolated) throw new Error("Page is not cross-origin isolated.");
28
+
29
+ await page.fill("#max-new-tokens", "1");
30
+ await page.fill("#temperature", "0");
31
+ await page.click("#run");
32
+ await page.waitForFunction(() => document.querySelector("#status")?.textContent === "Agent running", null, {
33
+ timeout: 10000,
34
+ });
35
+ await page.waitForFunction(() => document.querySelector("#status")?.textContent === "Ready", null, {
36
+ timeout: 600000,
37
+ });
38
+
39
+ const transcript = await page.textContent("#transcript");
40
+ const files = await page.textContent("#files");
41
+ const events = await page.textContent("#event-log");
42
+ const modelStatus = await page.textContent("#model-status");
43
+
44
+ if (!transcript?.includes("pi sandbox result: 42")) {
45
+ throw new Error(`Expected command output in transcript.\n\nTranscript:\n${transcript}\n\nEvents:\n${events}`);
46
+ }
47
+ if (!files?.includes("hello.js")) {
48
+ throw new Error(`Expected hello.js in file listing.\n\nFiles:\n${files}`);
49
+ }
50
+ if (modelStatus !== "Model ready") {
51
+ throw new Error(`Expected MiniCPM model status to be ready, got: ${modelStatus}`);
52
+ }
53
+
54
+ console.log(JSON.stringify({ ok: true, isolated, modelStatus, transcript, files }, null, 2));
55
+ } catch (error) {
56
+ console.error(error instanceof Error ? error.stack || error.message : String(error));
57
+ throw error;
58
+ } finally {
59
+ await browser.close();
60
+ }
61
+
scripts/smoke_web_agent.mjs ADDED
@@ -0,0 +1,60 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { chromium } from "@playwright/test";
2
+
3
+ const baseUrl = process.argv[2] || "http://localhost:5173/?mode=mock&device=wasm";
4
+ const executablePath = process.env.CHROMIUM_PATH || "/snap/bin/chromium";
5
+
6
+ const browser = await chromium.launch({
7
+ executablePath,
8
+ headless: true,
9
+ args: ["--no-sandbox", "--disable-dev-shm-usage"],
10
+ });
11
+
12
+ try {
13
+ const page = await browser.newPage();
14
+ page.setDefaultTimeout(90000);
15
+
16
+ const consoleLines = [];
17
+ page.on("console", (message) => {
18
+ consoleLines.push(`${message.type()}: ${message.text()}`);
19
+ });
20
+ page.on("pageerror", (error) => {
21
+ consoleLines.push(`pageerror: ${error.stack || error.message}`);
22
+ });
23
+
24
+ await page.goto(baseUrl, { waitUntil: "domcontentloaded" });
25
+ const isolated = await page.evaluate(() => globalThis.crossOriginIsolated);
26
+ if (!isolated) {
27
+ throw new Error("Page is not cross-origin isolated.");
28
+ }
29
+
30
+ await page.click("#boot-sandbox");
31
+ await page.waitForFunction(() => document.querySelector("#sandbox-status")?.textContent === "Sandbox ready");
32
+ await page.waitForFunction(() => document.querySelector("#files")?.textContent?.includes("hello.js"));
33
+
34
+ await page.click("#run");
35
+ await page.waitForFunction(() => document.querySelector("#status")?.textContent === "Ready", null, {
36
+ timeout: 120000,
37
+ });
38
+
39
+ const transcript = await page.textContent("#transcript");
40
+ const files = await page.textContent("#files");
41
+ const events = await page.textContent("#event-log");
42
+
43
+ if (!transcript?.includes("pi sandbox result: 42")) {
44
+ throw new Error(`Expected command output in transcript.\n\nTranscript:\n${transcript}\n\nEvents:\n${events}`);
45
+ }
46
+ if (!files?.includes("hello.js")) {
47
+ throw new Error(`Expected hello.js in file listing.\n\nFiles:\n${files}`);
48
+ }
49
+ if (!events?.includes("run_command finished")) {
50
+ throw new Error(`Expected pi tool execution events.\n\nEvents:\n${events}`);
51
+ }
52
+
53
+ console.log(JSON.stringify({ ok: true, isolated, transcript, files }, null, 2));
54
+ } catch (error) {
55
+ console.error(error instanceof Error ? error.stack || error.message : String(error));
56
+ throw error;
57
+ } finally {
58
+ await browser.close();
59
+ }
60
+
scripts/verify_tjs_model.mjs ADDED
@@ -0,0 +1,31 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { env, ModelRegistry, pipeline } from "@huggingface/transformers";
2
+
3
+ const modelId = process.argv[2] || "Mike0021/MiniCPM5-1B-ONNX-Web";
4
+ const prompt = process.argv[3] || "Hello";
5
+
6
+ env.allowLocalModels = true;
7
+ env.allowRemoteModels = true;
8
+
9
+ const files = await ModelRegistry.get_pipeline_files("text-generation", modelId, {
10
+ dtype: "q4",
11
+ device: "cpu",
12
+ });
13
+ console.log("files", JSON.stringify(files, null, 2));
14
+
15
+ if (!files.includes("onnx/model_q4.onnx")) {
16
+ throw new Error("Expected onnx/model_q4.onnx in Transformers.js file plan.");
17
+ }
18
+
19
+ const generator = await pipeline("text-generation", modelId, {
20
+ dtype: "q4",
21
+ device: "cpu",
22
+ });
23
+
24
+ const result = await generator(prompt, {
25
+ max_new_tokens: 2,
26
+ do_sample: false,
27
+ return_full_text: false,
28
+ });
29
+
30
+ console.log("generation", JSON.stringify(result, null, 2));
31
+
src/main.js ADDED
@@ -0,0 +1,192 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { MODEL_ID, createPiAgent } from "./piAgent.js";
2
+ import { createSandbox } from "./sandbox.js";
3
+ import "./styles.css";
4
+
5
+ const nodes = {
6
+ status: document.querySelector("#status"),
7
+ modelStatus: document.querySelector("#model-status"),
8
+ sandboxStatus: document.querySelector("#sandbox-status"),
9
+ transcript: document.querySelector("#transcript"),
10
+ eventLog: document.querySelector("#event-log"),
11
+ files: document.querySelector("#files"),
12
+ prompt: document.querySelector("#prompt"),
13
+ run: document.querySelector("#run"),
14
+ boot: document.querySelector("#boot-sandbox"),
15
+ reset: document.querySelector("#reset-sandbox"),
16
+ demo: document.querySelector("#demo-prompt"),
17
+ mode: document.querySelector("#mode"),
18
+ device: document.querySelector("#device"),
19
+ maxTokens: document.querySelector("#max-new-tokens"),
20
+ temperature: document.querySelector("#temperature"),
21
+ modelLabel: document.querySelector("#model-label"),
22
+ };
23
+
24
+ nodes.modelLabel.textContent = MODEL_ID;
25
+ if (!navigator.gpu) nodes.device.value = "wasm";
26
+
27
+ const params = new URLSearchParams(window.location.search);
28
+ if (params.get("mode") === "mock") nodes.mode.value = "mock";
29
+ if (params.get("device")) nodes.device.value = params.get("device");
30
+
31
+ const sandbox = createSandbox({
32
+ onStatus: (text) => setSandboxStatus(text),
33
+ onLog: (text) => logEvent("sandbox", text),
34
+ });
35
+
36
+ let agent = createAgent();
37
+
38
+ function textFromContent(content) {
39
+ if (typeof content === "string") return content;
40
+ if (!Array.isArray(content)) return "";
41
+ return content
42
+ .filter((part) => part.type === "text")
43
+ .map((part) => part.text)
44
+ .join("\n");
45
+ }
46
+
47
+ function setStatus(text) {
48
+ nodes.status.textContent = text;
49
+ }
50
+
51
+ function setSandboxStatus(text) {
52
+ nodes.sandboxStatus.textContent = text;
53
+ }
54
+
55
+ function setModelStatus(text) {
56
+ nodes.modelStatus.textContent = text;
57
+ }
58
+
59
+ function logEvent(kind, text) {
60
+ const line = `[${new Date().toLocaleTimeString()}] ${kind}: ${text}`;
61
+ nodes.eventLog.textContent = `${nodes.eventLog.textContent}${line}\n`;
62
+ nodes.eventLog.scrollTop = nodes.eventLog.scrollHeight;
63
+ }
64
+
65
+ function createAgent() {
66
+ const next = createPiAgent({
67
+ sandbox,
68
+ modelMode: () => nodes.mode.value,
69
+ device: () => nodes.device.value,
70
+ maxTokens: () => nodes.maxTokens.value,
71
+ temperature: () => nodes.temperature.value,
72
+ onModelStatus: setModelStatus,
73
+ });
74
+ next.subscribe((event) => {
75
+ switch (event.type) {
76
+ case "agent_start":
77
+ setStatus("Agent running");
78
+ logEvent("agent", "start");
79
+ break;
80
+ case "message_end":
81
+ renderTranscript();
82
+ break;
83
+ case "tool_execution_start":
84
+ logEvent("tool", `${event.toolName} started`);
85
+ break;
86
+ case "tool_execution_end":
87
+ logEvent("tool", `${event.toolName} finished`);
88
+ break;
89
+ case "agent_end":
90
+ setStatus("Ready");
91
+ renderTranscript();
92
+ refreshFiles().catch((error) => logEvent("files", error.message));
93
+ break;
94
+ default:
95
+ break;
96
+ }
97
+ });
98
+ return next;
99
+ }
100
+
101
+ function resetAgent() {
102
+ agent.abort();
103
+ agent = createAgent();
104
+ renderTranscript();
105
+ }
106
+
107
+ function renderTranscript() {
108
+ const rendered = agent.state.messages
109
+ .map((message) => {
110
+ if (message.role === "toolResult") {
111
+ return `TOOL ${message.toolName}${message.isError ? " ERROR" : ""}\n${textFromContent(message.content)}`;
112
+ }
113
+ const heading = message.role.toUpperCase();
114
+ const toolCalls =
115
+ message.role === "assistant"
116
+ ? message.content
117
+ .filter((part) => part.type === "toolCall")
118
+ .map((part) => `\nTOOL CALL ${part.name} ${JSON.stringify(part.arguments)}`)
119
+ .join("")
120
+ : "";
121
+ return `${heading}\n${textFromContent(message.content)}${toolCalls}`;
122
+ })
123
+ .join("\n\n");
124
+ nodes.transcript.textContent = rendered || "No messages yet.";
125
+ nodes.transcript.scrollTop = nodes.transcript.scrollHeight;
126
+ }
127
+
128
+ async function refreshFiles() {
129
+ if (!sandbox.isReady) {
130
+ nodes.files.textContent = "Sandbox not booted.";
131
+ return;
132
+ }
133
+ nodes.files.textContent = await sandbox.listFiles(".");
134
+ }
135
+
136
+ async function bootSandbox() {
137
+ nodes.boot.disabled = true;
138
+ try {
139
+ await sandbox.boot();
140
+ await refreshFiles();
141
+ } catch (error) {
142
+ setSandboxStatus("Sandbox error");
143
+ logEvent("sandbox", error.stack || error.message || String(error));
144
+ } finally {
145
+ nodes.boot.disabled = false;
146
+ }
147
+ }
148
+
149
+ nodes.boot.addEventListener("click", bootSandbox);
150
+
151
+ nodes.reset.addEventListener("click", async () => {
152
+ nodes.reset.disabled = true;
153
+ try {
154
+ await sandbox.reset();
155
+ resetAgent();
156
+ await refreshFiles();
157
+ } catch (error) {
158
+ logEvent("reset", error.stack || error.message || String(error));
159
+ } finally {
160
+ nodes.reset.disabled = false;
161
+ }
162
+ });
163
+
164
+ nodes.demo.addEventListener("click", () => {
165
+ nodes.prompt.value =
166
+ "Create hello.js that prints the result of 21 * 2, run it with Node, and tell me the command output.";
167
+ });
168
+
169
+ nodes.run.addEventListener("click", async () => {
170
+ nodes.run.disabled = true;
171
+ try {
172
+ await bootSandbox();
173
+ await agent.prompt(nodes.prompt.value);
174
+ } catch (error) {
175
+ setStatus("Error");
176
+ logEvent("agent", error.stack || error.message || String(error));
177
+ } finally {
178
+ nodes.run.disabled = false;
179
+ }
180
+ });
181
+
182
+ nodes.mode.addEventListener("change", () => {
183
+ resetAgent();
184
+ setModelStatus(nodes.mode.value === "mock" ? "Deterministic test model" : "Model idle");
185
+ });
186
+
187
+ setStatus("Ready");
188
+ setSandboxStatus("Not booted");
189
+ setModelStatus(nodes.mode.value === "mock" ? "Deterministic test model" : "Model idle");
190
+ renderTranscript();
191
+ refreshFiles().catch(() => {});
192
+
src/piAgent.js ADDED
@@ -0,0 +1,348 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Agent } from "@earendil-works/pi-agent-core";
2
+ import { createAssistantMessageEventStream, Type } from "@earendil-works/pi-ai";
3
+ import { env, pipeline } from "@huggingface/transformers";
4
+
5
+ const MODEL_ID = "Mike0021/MiniCPM5-1B-ONNX-Web";
6
+
7
+ const LOCAL_MODEL = {
8
+ id: MODEL_ID,
9
+ name: "MiniCPM5-1B ONNX Web",
10
+ api: "transformers-js",
11
+ provider: "huggingface-transformers-js",
12
+ baseUrl: "https://huggingface.co",
13
+ reasoning: false,
14
+ input: ["text"],
15
+ cost: {
16
+ input: 0,
17
+ output: 0,
18
+ cacheRead: 0,
19
+ cacheWrite: 0,
20
+ },
21
+ contextWindow: 4096,
22
+ maxTokens: 512,
23
+ };
24
+
25
+ const EMPTY_USAGE = {
26
+ input: 0,
27
+ output: 0,
28
+ cacheRead: 0,
29
+ cacheWrite: 0,
30
+ totalTokens: 0,
31
+ cost: {
32
+ input: 0,
33
+ output: 0,
34
+ cacheRead: 0,
35
+ cacheWrite: 0,
36
+ total: 0,
37
+ },
38
+ };
39
+
40
+ function textFromContent(content) {
41
+ if (typeof content === "string") return content;
42
+ if (!Array.isArray(content)) return "";
43
+ return content
44
+ .filter((part) => part.type === "text")
45
+ .map((part) => part.text)
46
+ .join("\n");
47
+ }
48
+
49
+ function now() {
50
+ return Date.now();
51
+ }
52
+
53
+ function createMessage(content, stopReason = "stop") {
54
+ return {
55
+ role: "assistant",
56
+ content,
57
+ api: LOCAL_MODEL.api,
58
+ provider: LOCAL_MODEL.provider,
59
+ model: LOCAL_MODEL.id,
60
+ usage: EMPTY_USAGE,
61
+ stopReason,
62
+ timestamp: now(),
63
+ };
64
+ }
65
+
66
+ function stringifyToolResult(message) {
67
+ const text = textFromContent(message.content);
68
+ return `${message.toolName}(${message.toolCallId}) ${message.isError ? "failed" : "succeeded"}:\n${text}`;
69
+ }
70
+
71
+ function buildPrompt(context) {
72
+ const tools = (context.tools || []).map((tool) => ({
73
+ name: tool.name,
74
+ description: tool.description,
75
+ parameters: tool.parameters,
76
+ }));
77
+ const transcript = context.messages
78
+ .slice(-8)
79
+ .map((message) => {
80
+ if (message.role === "toolResult") return `TOOL_RESULT:\n${stringifyToolResult(message)}`;
81
+ return `${message.role.toUpperCase()}:\n${textFromContent(message.content)}`;
82
+ })
83
+ .join("\n\n");
84
+
85
+ return `${context.systemPrompt || ""}
86
+
87
+ You are running as a pi Agent inside a browser-only app. The sandbox is a WebContainer: it has a virtual filesystem and can spawn browser-contained Node.js processes.
88
+
89
+ Use tools by returning strict JSON only. Do not use markdown.
90
+
91
+ To call tools:
92
+ {"toolCalls":[{"tool":"write_file","args":{"path":"hello.js","content":"console.log(2 + 2)\\n"}},{"tool":"run_command","args":{"command":"node","args":["hello.js"]}}]}
93
+
94
+ To answer the user after tool results:
95
+ {"final":"Short answer that explains what happened."}
96
+
97
+ Available tools:
98
+ ${JSON.stringify(tools, null, 2)}
99
+
100
+ Conversation:
101
+ ${transcript}
102
+
103
+ Return JSON now.`;
104
+ }
105
+
106
+ function extractJsonPayload(text) {
107
+ const trimmed = String(text || "").trim();
108
+ const fence = trimmed.match(/```(?:json)?\s*([\s\S]*?)```/i);
109
+ const candidate = fence ? fence[1].trim() : trimmed;
110
+ const firstBrace = candidate.indexOf("{");
111
+ const firstBracket = candidate.indexOf("[");
112
+ const starts = [firstBrace, firstBracket].filter((index) => index >= 0);
113
+ if (starts.length === 0) return null;
114
+ const start = Math.min(...starts);
115
+ const lastBrace = candidate.lastIndexOf("}");
116
+ const lastBracket = candidate.lastIndexOf("]");
117
+ const end = Math.max(lastBrace, lastBracket);
118
+ if (end <= start) return null;
119
+ try {
120
+ return JSON.parse(candidate.slice(start, end + 1));
121
+ } catch {
122
+ return null;
123
+ }
124
+ }
125
+
126
+ function normalizeToolCalls(payload) {
127
+ if (!payload) return [];
128
+ const rawCalls = Array.isArray(payload) ? payload : payload.toolCalls || payload.tools || payload.actions || [];
129
+ if (!Array.isArray(rawCalls)) return [];
130
+ return rawCalls
131
+ .map((call, index) => ({
132
+ type: "toolCall",
133
+ id: `tool-${now()}-${index}`,
134
+ name: String(call.tool || call.name || ""),
135
+ arguments: call.args || call.arguments || {},
136
+ }))
137
+ .filter((call) => call.name);
138
+ }
139
+
140
+ function normalizeFinalText(payload, fallback) {
141
+ if (payload && typeof payload.final === "string") return payload.final;
142
+ if (payload && typeof payload.message === "string") return payload.message;
143
+ if (payload && typeof payload.answer === "string") return payload.answer;
144
+ return String(fallback || "").trim() || "Done.";
145
+ }
146
+
147
+ function mockPlan(context) {
148
+ const last = context.messages[context.messages.length - 1];
149
+ if (last?.role === "toolResult") {
150
+ const results = context.messages.filter((message) => message.role === "toolResult").slice(-4).map(stringifyToolResult);
151
+ return {
152
+ final: `The sandbox work completed.\n\n${results.join("\n\n")}`,
153
+ };
154
+ }
155
+
156
+ const userText = textFromContent(last?.content || "").toLowerCase();
157
+ if (userText.includes("read")) {
158
+ return {
159
+ toolCalls: [{ tool: "read_file", args: { path: "hello.js" } }],
160
+ };
161
+ }
162
+ if (userText.includes("list")) {
163
+ return {
164
+ toolCalls: [{ tool: "list_files", args: { path: "." } }],
165
+ };
166
+ }
167
+ return {
168
+ toolCalls: [
169
+ {
170
+ tool: "write_file",
171
+ args: {
172
+ path: "hello.js",
173
+ content: 'const value = 21 * 2;\nconsole.log(`pi sandbox result: ${value}`);\n',
174
+ },
175
+ },
176
+ {
177
+ tool: "run_command",
178
+ args: {
179
+ command: "node",
180
+ args: ["hello.js"],
181
+ },
182
+ },
183
+ ],
184
+ };
185
+ }
186
+
187
+ function emitFinal(stream, message, text = "") {
188
+ stream.push({ type: "start", partial: { ...message, content: [{ type: "text", text: "" }] } });
189
+ if (text) {
190
+ const partial = { ...message, content: [{ type: "text", text }] };
191
+ stream.push({ type: "text_start", contentIndex: 0, partial: { ...message, content: [{ type: "text", text: "" }] } });
192
+ stream.push({ type: "text_delta", contentIndex: 0, delta: text, partial });
193
+ stream.push({ type: "text_end", contentIndex: 0, content: text, partial });
194
+ }
195
+ if (message.stopReason === "error" || message.stopReason === "aborted") {
196
+ stream.push({ type: "error", reason: message.stopReason, error: message });
197
+ } else {
198
+ stream.push({ type: "done", reason: message.stopReason, message });
199
+ }
200
+ }
201
+
202
+ export function createPiAgent({ sandbox, modelMode, device, maxTokens, temperature, onModelStatus = () => {} }) {
203
+ env.allowLocalModels = false;
204
+ env.allowRemoteModels = true;
205
+ env.backends.onnx.wasm.numThreads = Math.min(4, navigator.hardwareConcurrency || 4);
206
+
207
+ let generatorPromise = null;
208
+ let generatorKey = "";
209
+
210
+ async function getGenerator() {
211
+ const key = `${MODEL_ID}:${device()}`;
212
+ if (!generatorPromise || generatorKey !== key) {
213
+ generatorKey = key;
214
+ onModelStatus(`Loading ${device()}`);
215
+ generatorPromise = pipeline("text-generation", MODEL_ID, {
216
+ dtype: "q4",
217
+ device: device(),
218
+ progress_callback: (event) => {
219
+ if (event.status === "progress") {
220
+ onModelStatus(`${event.file} ${Math.round(event.progress)}%`);
221
+ } else if (event.status) {
222
+ onModelStatus(event.status);
223
+ }
224
+ },
225
+ });
226
+ }
227
+ return generatorPromise;
228
+ }
229
+
230
+ async function producePlan(context, signal) {
231
+ if (modelMode() === "mock") {
232
+ return JSON.stringify(mockPlan(context));
233
+ }
234
+
235
+ const generator = await getGenerator();
236
+ if (signal?.aborted) throw new Error("Aborted");
237
+ const result = await generator(buildPrompt(context), {
238
+ max_new_tokens: Number(maxTokens()) || 128,
239
+ temperature: Number(temperature()) || 0,
240
+ do_sample: Number(temperature()) > 0,
241
+ return_full_text: false,
242
+ });
243
+ onModelStatus("Model ready");
244
+ return result?.[0]?.generated_text ?? "";
245
+ }
246
+
247
+ function streamFn(_model, context, options = {}) {
248
+ const stream = createAssistantMessageEventStream();
249
+ queueMicrotask(async () => {
250
+ try {
251
+ const generated = await producePlan(context, options.signal);
252
+ const payload = extractJsonPayload(generated);
253
+ const lastMessage = context.messages[context.messages.length - 1];
254
+ const forceFinal = lastMessage?.role === "toolResult";
255
+ const fallbackPayload = payload ? null : mockPlan(context);
256
+ const toolCalls = forceFinal ? [] : normalizeToolCalls(payload || fallbackPayload);
257
+ if (toolCalls.length > 0) {
258
+ const message = createMessage([{ type: "text", text: "Using sandbox tools." }, ...toolCalls], "toolUse");
259
+ emitFinal(stream, message, "Using sandbox tools.");
260
+ return;
261
+ }
262
+ const text = normalizeFinalText(payload || fallbackPayload, generated);
263
+ const message = createMessage([{ type: "text", text }], "stop");
264
+ emitFinal(stream, message, text);
265
+ } catch (error) {
266
+ const text = error instanceof Error ? error.message : String(error);
267
+ const message = {
268
+ ...createMessage([{ type: "text", text }], options.signal?.aborted ? "aborted" : "error"),
269
+ errorMessage: text,
270
+ };
271
+ emitFinal(stream, message, text);
272
+ }
273
+ });
274
+ return stream;
275
+ }
276
+
277
+ const tools = [
278
+ {
279
+ name: "list_files",
280
+ label: "List files",
281
+ description: "List files in the sandbox workspace.",
282
+ parameters: Type.Object({
283
+ path: Type.Optional(Type.String()),
284
+ }),
285
+ execute: async (_id, args) => {
286
+ const output = await sandbox.listFiles(args.path || ".");
287
+ return { content: [{ type: "text", text: output }], details: { output } };
288
+ },
289
+ },
290
+ {
291
+ name: "read_file",
292
+ label: "Read file",
293
+ description: "Read a UTF-8 file from the sandbox workspace.",
294
+ parameters: Type.Object({
295
+ path: Type.String(),
296
+ }),
297
+ execute: async (_id, args) => {
298
+ const output = await sandbox.readFile(args.path);
299
+ return { content: [{ type: "text", text: output }], details: { path: args.path, output } };
300
+ },
301
+ },
302
+ {
303
+ name: "write_file",
304
+ label: "Write file",
305
+ description: "Create or replace a UTF-8 file inside the sandbox workspace.",
306
+ parameters: Type.Object({
307
+ path: Type.String(),
308
+ content: Type.String(),
309
+ }),
310
+ execute: async (_id, args) => {
311
+ const output = await sandbox.writeFile(args.path, args.content);
312
+ return { content: [{ type: "text", text: output }], details: { path: args.path } };
313
+ },
314
+ },
315
+ {
316
+ name: "run_command",
317
+ label: "Run command",
318
+ description: "Spawn a process inside the browser-only WebContainer sandbox.",
319
+ parameters: Type.Object({
320
+ command: Type.String(),
321
+ args: Type.Optional(Type.Array(Type.String())),
322
+ timeoutMs: Type.Optional(Type.Number()),
323
+ }),
324
+ execute: async (_id, args) => {
325
+ const result = await sandbox.runCommand(args.command, args.args || [], args.timeoutMs || 10000);
326
+ const text = `$ ${result.command}\nexit ${result.exitCode}\n${result.output}`;
327
+ return {
328
+ content: [{ type: "text", text }],
329
+ details: result,
330
+ };
331
+ },
332
+ executionMode: "sequential",
333
+ },
334
+ ];
335
+
336
+ return new Agent({
337
+ initialState: {
338
+ model: LOCAL_MODEL,
339
+ systemPrompt:
340
+ "You are Pi Web Agent. Use the sandbox tools for filesystem or command tasks, then give concise results.",
341
+ tools,
342
+ },
343
+ streamFn,
344
+ toolExecution: "sequential",
345
+ });
346
+ }
347
+
348
+ export { MODEL_ID };
src/sandbox.js ADDED
@@ -0,0 +1,183 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const BOOT_FILES = {
2
+ "package.json": {
3
+ file: {
4
+ contents: JSON.stringify(
5
+ {
6
+ type: "module",
7
+ scripts: {
8
+ demo: "node hello.js",
9
+ },
10
+ dependencies: {},
11
+ devDependencies: {},
12
+ },
13
+ null,
14
+ 2,
15
+ ),
16
+ },
17
+ },
18
+ "README.md": {
19
+ file: {
20
+ contents:
21
+ "# Pi web sandbox\n\nThis filesystem and every spawned process run inside WebContainers in this browser tab.\n",
22
+ },
23
+ },
24
+ "hello.js": {
25
+ file: {
26
+ contents: 'console.log("hello from the browser sandbox");\nconsole.log(2 + 2);\n',
27
+ },
28
+ },
29
+ };
30
+
31
+ const MAX_OUTPUT_CHARS = 16000;
32
+ const DEFAULT_TIMEOUT_MS = 10000;
33
+
34
+ export function createSandbox({ onLog = () => {}, onStatus = () => {} } = {}) {
35
+ let instance = null;
36
+ let booting = null;
37
+ let root = "";
38
+
39
+ async function boot() {
40
+ if (instance) return instance;
41
+ if (booting) return booting;
42
+
43
+ booting = (async () => {
44
+ if (!globalThis.crossOriginIsolated) {
45
+ throw new Error("WebContainers need cross-origin isolation. Start this app through Vite or another server that sends COOP/COEP headers.");
46
+ }
47
+
48
+ onStatus("Booting WebContainer");
49
+ const { WebContainer } = await import("@webcontainer/api");
50
+ instance = await WebContainer.boot({
51
+ coep: "credentialless",
52
+ workdirName: "workspace",
53
+ });
54
+ root = instance.workdir;
55
+ await instance.mount(BOOT_FILES);
56
+ onLog(`sandbox booted at ${root}`);
57
+ onStatus("Sandbox ready");
58
+ return instance;
59
+ })();
60
+
61
+ try {
62
+ return await booting;
63
+ } finally {
64
+ booting = null;
65
+ }
66
+ }
67
+
68
+ function assertRelativePath(path) {
69
+ const value = String(path || "").trim().replace(/^\/+/, "");
70
+ if (!value || value.includes("\0")) {
71
+ throw new Error("Path is required.");
72
+ }
73
+ const segments = value.split("/").filter(Boolean);
74
+ if (segments.some((segment) => segment === "." || segment === "..")) {
75
+ throw new Error("Paths must stay inside the sandbox workspace.");
76
+ }
77
+ return segments.join("/");
78
+ }
79
+
80
+ function toWorkspacePath(path) {
81
+ return assertRelativePath(path);
82
+ }
83
+
84
+ async function reset() {
85
+ const wc = await boot();
86
+ const entries = await wc.fs.readdir(".");
87
+ await Promise.all(
88
+ entries.map((entry) =>
89
+ wc.fs.rm(entry, {
90
+ force: true,
91
+ recursive: true,
92
+ }),
93
+ ),
94
+ );
95
+ await wc.mount(BOOT_FILES);
96
+ onLog("sandbox reset");
97
+ return "Sandbox reset to the starter project.";
98
+ }
99
+
100
+ async function listFiles(path = ".") {
101
+ const wc = await boot();
102
+ const target = path === "." || path === "" ? "." : toWorkspacePath(path);
103
+ const entries = await wc.fs.readdir(target, { withFileTypes: true });
104
+ return entries.map((entry) => `${entry.isDirectory() ? "dir " : "file"} ${entry.name}`).join("\n") || "(empty)";
105
+ }
106
+
107
+ async function readFile(path) {
108
+ const wc = await boot();
109
+ return await wc.fs.readFile(toWorkspacePath(path), "utf-8");
110
+ }
111
+
112
+ async function writeFile(path, content) {
113
+ const wc = await boot();
114
+ const relative = assertRelativePath(path);
115
+ const parent = relative.split("/").slice(0, -1).join("/");
116
+ if (parent) {
117
+ await wc.fs.mkdir(parent, { recursive: true });
118
+ }
119
+ await wc.fs.writeFile(relative, String(content ?? ""));
120
+ onLog(`wrote ${relative}`);
121
+ return `Wrote ${relative}`;
122
+ }
123
+
124
+ async function runCommand(command, args = [], timeoutMs = DEFAULT_TIMEOUT_MS) {
125
+ const wc = await boot();
126
+ const cmd = String(command || "").trim();
127
+ if (!cmd) throw new Error("Command is required.");
128
+ const cleanArgs = Array.isArray(args) ? args.map((arg) => String(arg)) : [];
129
+
130
+ onLog(`$ ${[cmd, ...cleanArgs].join(" ")}`);
131
+ const process = await wc.spawn(cmd, cleanArgs, {
132
+ terminal: {
133
+ cols: 96,
134
+ rows: 28,
135
+ },
136
+ });
137
+
138
+ let output = "";
139
+ const reader = process.output.getReader();
140
+ const pump = (async () => {
141
+ while (true) {
142
+ const { done, value } = await reader.read();
143
+ if (done) break;
144
+ output += value;
145
+ if (output.length > MAX_OUTPUT_CHARS) {
146
+ output = `${output.slice(0, MAX_OUTPUT_CHARS)}\n[output truncated]`;
147
+ process.kill();
148
+ break;
149
+ }
150
+ }
151
+ })();
152
+
153
+ const timeout = new Promise((resolve) => {
154
+ setTimeout(() => {
155
+ process.kill();
156
+ resolve("timeout");
157
+ }, Number(timeoutMs) || DEFAULT_TIMEOUT_MS);
158
+ });
159
+
160
+ const exitCode = await Promise.race([process.exit, timeout]);
161
+ await pump.catch(() => {});
162
+ const normalizedExitCode = exitCode === "timeout" ? 124 : exitCode;
163
+ const result = {
164
+ command: [cmd, ...cleanArgs].join(" "),
165
+ exitCode: normalizedExitCode,
166
+ output: output.trimEnd(),
167
+ };
168
+ onLog(`exit ${normalizedExitCode}`);
169
+ return result;
170
+ }
171
+
172
+ return {
173
+ boot,
174
+ reset,
175
+ listFiles,
176
+ readFile,
177
+ writeFile,
178
+ runCommand,
179
+ get isReady() {
180
+ return Boolean(instance);
181
+ },
182
+ };
183
+ }
src/styles.css ADDED
@@ -0,0 +1,225 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ :root {
2
+ color: #172033;
3
+ background: #f3f6f9;
4
+ font-family:
5
+ Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
6
+ }
7
+
8
+ * {
9
+ box-sizing: border-box;
10
+ }
11
+
12
+ body {
13
+ margin: 0;
14
+ min-width: 320px;
15
+ }
16
+
17
+ button,
18
+ input,
19
+ select,
20
+ textarea {
21
+ font: inherit;
22
+ }
23
+
24
+ #app {
25
+ min-height: 100vh;
26
+ padding: 18px;
27
+ }
28
+
29
+ .shell {
30
+ width: min(1280px, 100%);
31
+ margin: 0 auto;
32
+ }
33
+
34
+ .topbar {
35
+ display: flex;
36
+ align-items: end;
37
+ justify-content: space-between;
38
+ gap: 16px;
39
+ padding: 8px 0 18px;
40
+ border-bottom: 1px solid #c7d2df;
41
+ }
42
+
43
+ h1,
44
+ h2,
45
+ p {
46
+ margin: 0;
47
+ }
48
+
49
+ h1 {
50
+ font-size: 38px;
51
+ line-height: 1;
52
+ letter-spacing: 0;
53
+ }
54
+
55
+ h2 {
56
+ font-size: 15px;
57
+ line-height: 1.2;
58
+ color: #405064;
59
+ }
60
+
61
+ #model-label {
62
+ margin-top: 8px;
63
+ color: #5d6c7f;
64
+ overflow-wrap: anywhere;
65
+ }
66
+
67
+ .status-stack {
68
+ display: flex;
69
+ flex-wrap: wrap;
70
+ justify-content: end;
71
+ gap: 8px;
72
+ max-width: 620px;
73
+ }
74
+
75
+ .status {
76
+ min-height: 34px;
77
+ max-width: 260px;
78
+ padding: 7px 10px;
79
+ border: 1px solid #b7c4d3;
80
+ border-radius: 6px;
81
+ background: #ffffff;
82
+ color: #314155;
83
+ overflow-wrap: anywhere;
84
+ }
85
+
86
+ .controls {
87
+ display: grid;
88
+ grid-template-columns: 1.2fr 1fr 1fr 1fr;
89
+ gap: 12px;
90
+ margin: 18px 0 12px;
91
+ }
92
+
93
+ label {
94
+ display: grid;
95
+ gap: 6px;
96
+ color: #526173;
97
+ font-size: 14px;
98
+ font-weight: 650;
99
+ }
100
+
101
+ select,
102
+ input,
103
+ textarea {
104
+ width: 100%;
105
+ border: 1px solid #b8c5d4;
106
+ border-radius: 6px;
107
+ background: #ffffff;
108
+ color: #152033;
109
+ }
110
+
111
+ select,
112
+ input {
113
+ height: 42px;
114
+ padding: 0 10px;
115
+ }
116
+
117
+ .command-row {
118
+ display: flex;
119
+ flex-wrap: wrap;
120
+ gap: 10px;
121
+ margin-bottom: 14px;
122
+ }
123
+
124
+ button {
125
+ min-width: 116px;
126
+ height: 42px;
127
+ padding: 0 16px;
128
+ border: 0;
129
+ border-radius: 6px;
130
+ background: #176b6c;
131
+ color: #ffffff;
132
+ font-weight: 750;
133
+ cursor: pointer;
134
+ }
135
+
136
+ button:nth-child(2) {
137
+ background: #4f5f73;
138
+ }
139
+
140
+ button:nth-child(3) {
141
+ background: #7a4d18;
142
+ }
143
+
144
+ button:disabled {
145
+ cursor: wait;
146
+ opacity: 0.64;
147
+ }
148
+
149
+ .workspace {
150
+ display: grid;
151
+ gap: 14px;
152
+ }
153
+
154
+ .prompt-pane {
155
+ display: grid;
156
+ gap: 10px;
157
+ }
158
+
159
+ textarea {
160
+ min-height: 116px;
161
+ resize: vertical;
162
+ padding: 13px;
163
+ line-height: 1.45;
164
+ }
165
+
166
+ #run {
167
+ width: fit-content;
168
+ }
169
+
170
+ .panes {
171
+ display: grid;
172
+ grid-template-columns: 1.35fr 0.9fr;
173
+ gap: 12px;
174
+ }
175
+
176
+ .pane {
177
+ display: grid;
178
+ gap: 8px;
179
+ min-width: 0;
180
+ }
181
+
182
+ .pane.wide {
183
+ grid-column: 1 / -1;
184
+ }
185
+
186
+ pre {
187
+ min-height: 220px;
188
+ max-height: 360px;
189
+ margin: 0;
190
+ padding: 14px;
191
+ border: 1px solid #b8c5d4;
192
+ border-radius: 6px;
193
+ background: #fbfcfd;
194
+ color: #162033;
195
+ line-height: 1.45;
196
+ white-space: pre-wrap;
197
+ overflow: auto;
198
+ overflow-wrap: anywhere;
199
+ }
200
+
201
+ #event-log {
202
+ min-height: 150px;
203
+ max-height: 220px;
204
+ }
205
+
206
+ @media (max-width: 780px) {
207
+ #app {
208
+ padding: 12px;
209
+ }
210
+
211
+ .topbar {
212
+ align-items: stretch;
213
+ flex-direction: column;
214
+ }
215
+
216
+ .status-stack {
217
+ justify-content: start;
218
+ }
219
+
220
+ .controls,
221
+ .panes {
222
+ grid-template-columns: 1fr;
223
+ }
224
+ }
225
+
vite.config.js ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { defineConfig } from "vite";
2
+
3
+ const headers = {
4
+ "Cross-Origin-Embedder-Policy": "credentialless",
5
+ "Cross-Origin-Opener-Policy": "same-origin",
6
+ };
7
+
8
+ export default defineConfig({
9
+ server: {
10
+ headers,
11
+ },
12
+ preview: {
13
+ headers,
14
+ },
15
+ });
16
+