Spaces:
Configuration error
Configuration error
Deploy static pi web agent
Browse files- .gitignore +9 -0
- README.md +115 -5
- index.html +79 -17
- package-lock.json +0 -0
- package.json +23 -0
- scripts/convert_minicpm5_tjs.py +229 -0
- scripts/smoke_local_model_web_agent.mjs +61 -0
- scripts/smoke_web_agent.mjs +60 -0
- scripts/verify_tjs_model.mjs +31 -0
- src/main.js +192 -0
- src/piAgent.js +348 -0
- src/sandbox.js +183 -0
- src/styles.css +225 -0
- vite.config.js +16 -0
.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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 8 |
---
|
| 9 |
|
| 10 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
+
|