Serve built Pi CLI web at static root
Browse files- .gitignore +0 -9
- README.md +2 -2
- {dist/assets → assets}/__vite-browser-external-2447137e-BIHI7g3E.js +0 -0
- {dist/assets → assets}/addon-fit-DX4qG4td.js +0 -0
- {dist/assets → assets}/anthropic-Dm0nVZ1A.js +0 -0
- {dist/assets → assets}/azure-openai-responses-BGzUURKK.js +0 -0
- {dist/assets → assets}/client-Sq9UPDIz.js +0 -0
- {dist/assets → assets}/ghostty-web-C21go6Pk.js +0 -0
- {dist/assets → assets}/github-copilot-headers-CVWOSrQr.js +0 -0
- {dist/assets → assets}/google-kA_fw5EZ.js +0 -0
- {dist/assets → assets}/google-shared-tMPbwRUC.js +0 -0
- {dist/assets → assets}/google-vertex-CxMnHyvQ.js +0 -0
- {dist/assets → assets}/hash-DMDecQcg.js +0 -0
- {dist/assets → assets}/headers-CgnjaPPL.js +0 -0
- {dist/assets → assets}/index-Brjf2SFV.js +0 -0
- {dist/assets → assets}/index-CB_zB4U3.js +0 -0
- {dist/assets → assets}/index-NRwwiW4N.css +0 -0
- {dist/assets → assets}/json-parse-cUk3uCki.js +0 -0
- {dist/assets → assets}/mistral-mDLdw-bF.js +0 -0
- {dist/assets → assets}/openai-codex-responses-7plNQja4.js +0 -0
- {dist/assets → assets}/openai-completions-CTz74GS-.js +0 -0
- {dist/assets → assets}/openai-prompt-cache-h_V5PL7P.js +0 -0
- {dist/assets → assets}/openai-responses-CW5o7IM-.js +0 -0
- {dist/assets → assets}/openai-responses-shared-DBUlz2NN.js +0 -0
- {dist/assets → assets}/openrouter-DE7amXV2.js +0 -0
- {dist/assets → assets}/ort-wasm-simd-threaded.asyncify-DMmc6YqF.wasm +0 -0
- {dist/assets → assets}/sanitize-unicode-B62XMGgN.js +0 -0
- {dist/assets → assets}/transform-messages-DSNYjDRn.js +0 -0
- {dist/assets → assets}/xterm-B-qIQCd3.js +0 -0
- dist/index.html +0 -55
- index.html +2 -1
- package-lock.json +0 -0
- package.json +0 -30
- scripts/compare_pi_cli_parity.mjs +0 -89
- scripts/convert_minicpm5_tjs.py +0 -229
- scripts/probe_token_budgets.mjs +0 -75
- scripts/smoke_complex_tasks.mjs +0 -103
- scripts/smoke_local_model_web_agent.mjs +0 -62
- scripts/smoke_web_agent.mjs +0 -75
- scripts/verify_tjs_model.mjs +0 -31
- src/main.js +0 -55
- src/piAgent.js +0 -894
- src/piCli.js +0 -524
- src/piCliContract.js +0 -101
- src/sandbox.js +0 -388
- src/styles.css +0 -226
- src/webTerminal.js +0 -88
- vite.config.js +0 -16
.gitignore
DELETED
|
@@ -1,9 +0,0 @@
|
|
| 1 |
-
node_modules/
|
| 2 |
-
dist/
|
| 3 |
-
.vite/
|
| 4 |
-
.cache/
|
| 5 |
-
output/
|
| 6 |
-
refs/
|
| 7 |
-
source/
|
| 8 |
-
.swapfile
|
| 9 |
-
*.log
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
README.md
CHANGED
|
@@ -1,7 +1,7 @@
|
|
| 1 |
---
|
| 2 |
title: Pi CLI Web
|
| 3 |
sdk: static
|
| 4 |
-
app_file:
|
| 5 |
fullWidth: true
|
| 6 |
models:
|
| 7 |
- onnx-community/Qwen2.5-Coder-0.5B-Instruct
|
|
@@ -48,7 +48,7 @@ The app uses:
|
|
| 48 |
|
| 49 |
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 an ONNX model. The local model defaults to a tested 256-token generation budget in WASM mode and supports budgets up to 8192 through the `tokens=` query parameter and `/settings tokens=<n>`.
|
| 50 |
|
| 51 |
-
The Static Space uses the same isolation policy through `custom_headers` in this README frontmatter. The app is built with `npm run build`
|
| 52 |
|
| 53 |
## Test the CLI Web App
|
| 54 |
|
|
|
|
| 1 |
---
|
| 2 |
title: Pi CLI Web
|
| 3 |
sdk: static
|
| 4 |
+
app_file: index.html
|
| 5 |
fullWidth: true
|
| 6 |
models:
|
| 7 |
- onnx-community/Qwen2.5-Coder-0.5B-Instruct
|
|
|
|
| 48 |
|
| 49 |
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 an ONNX model. The local model defaults to a tested 256-token generation budget in WASM mode and supports budgets up to 8192 through the `tokens=` query parameter and `/settings tokens=<n>`.
|
| 50 |
|
| 51 |
+
The Static Space uses the same isolation policy through `custom_headers` in this README frontmatter. The app is built with `npm run build`; the generated `dist/index.html` and `dist/assets/` are uploaded to the Space root as `index.html` and `assets/`.
|
| 52 |
|
| 53 |
## Test the CLI Web App
|
| 54 |
|
{dist/assets → assets}/__vite-browser-external-2447137e-BIHI7g3E.js
RENAMED
|
File without changes
|
{dist/assets → assets}/addon-fit-DX4qG4td.js
RENAMED
|
File without changes
|
{dist/assets → assets}/anthropic-Dm0nVZ1A.js
RENAMED
|
File without changes
|
{dist/assets → assets}/azure-openai-responses-BGzUURKK.js
RENAMED
|
File without changes
|
{dist/assets → assets}/client-Sq9UPDIz.js
RENAMED
|
File without changes
|
{dist/assets → assets}/ghostty-web-C21go6Pk.js
RENAMED
|
File without changes
|
{dist/assets → assets}/github-copilot-headers-CVWOSrQr.js
RENAMED
|
File without changes
|
{dist/assets → assets}/google-kA_fw5EZ.js
RENAMED
|
File without changes
|
{dist/assets → assets}/google-shared-tMPbwRUC.js
RENAMED
|
File without changes
|
{dist/assets → assets}/google-vertex-CxMnHyvQ.js
RENAMED
|
File without changes
|
{dist/assets → assets}/hash-DMDecQcg.js
RENAMED
|
File without changes
|
{dist/assets → assets}/headers-CgnjaPPL.js
RENAMED
|
File without changes
|
{dist/assets → assets}/index-Brjf2SFV.js
RENAMED
|
File without changes
|
{dist/assets → assets}/index-CB_zB4U3.js
RENAMED
|
File without changes
|
{dist/assets → assets}/index-NRwwiW4N.css
RENAMED
|
File without changes
|
{dist/assets → assets}/json-parse-cUk3uCki.js
RENAMED
|
File without changes
|
{dist/assets → assets}/mistral-mDLdw-bF.js
RENAMED
|
File without changes
|
{dist/assets → assets}/openai-codex-responses-7plNQja4.js
RENAMED
|
File without changes
|
{dist/assets → assets}/openai-completions-CTz74GS-.js
RENAMED
|
File without changes
|
{dist/assets → assets}/openai-prompt-cache-h_V5PL7P.js
RENAMED
|
File without changes
|
{dist/assets → assets}/openai-responses-CW5o7IM-.js
RENAMED
|
File without changes
|
{dist/assets → assets}/openai-responses-shared-DBUlz2NN.js
RENAMED
|
File without changes
|
{dist/assets → assets}/openrouter-DE7amXV2.js
RENAMED
|
File without changes
|
{dist/assets → assets}/ort-wasm-simd-threaded.asyncify-DMmc6YqF.wasm
RENAMED
|
File without changes
|
{dist/assets → assets}/sanitize-unicode-B62XMGgN.js
RENAMED
|
File without changes
|
{dist/assets → assets}/transform-messages-DSNYjDRn.js
RENAMED
|
File without changes
|
{dist/assets → assets}/xterm-B-qIQCd3.js
RENAMED
|
File without changes
|
dist/index.html
DELETED
|
@@ -1,55 +0,0 @@
|
|
| 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>Pi CLI Web</title>
|
| 7 |
-
<script type="module" crossorigin src="/assets/index-CB_zB4U3.js"></script>
|
| 8 |
-
<link rel="stylesheet" crossorigin href="/assets/index-NRwwiW4N.css">
|
| 9 |
-
</head>
|
| 10 |
-
<body>
|
| 11 |
-
<main id="app">
|
| 12 |
-
<header class="runtime-bar" aria-label="Runtime status">
|
| 13 |
-
<div>
|
| 14 |
-
<h1>pi</h1>
|
| 15 |
-
<p id="model-label"></p>
|
| 16 |
-
</div>
|
| 17 |
-
<div class="status-stack">
|
| 18 |
-
<span class="status" id="status">Idle</span>
|
| 19 |
-
<span class="status" id="model-status">Model idle</span>
|
| 20 |
-
<span class="status" id="sandbox-status">Sandbox idle</span>
|
| 21 |
-
</div>
|
| 22 |
-
</header>
|
| 23 |
-
|
| 24 |
-
<section class="terminal-shell" aria-label="Pi terminal">
|
| 25 |
-
<div id="terminal"></div>
|
| 26 |
-
</section>
|
| 27 |
-
|
| 28 |
-
<section class="model-gate" id="model-gate" aria-labelledby="model-gate-title" aria-modal="true" role="dialog">
|
| 29 |
-
<div class="model-dialog">
|
| 30 |
-
<div>
|
| 31 |
-
<p class="eyebrow">Local model setup</p>
|
| 32 |
-
<h2 id="model-gate-title">Download the selected model to this browser?</h2>
|
| 33 |
-
<p class="dialog-copy">
|
| 34 |
-
This static web port runs pi with Transformers.js and WebContainers. The model download stays in browser storage when possible.
|
| 35 |
-
</p>
|
| 36 |
-
</div>
|
| 37 |
-
<div class="dialog-actions">
|
| 38 |
-
<button id="confirm-load-model" type="button">Download Model</button>
|
| 39 |
-
<button id="use-test-model" type="button">Use Test Model</button>
|
| 40 |
-
</div>
|
| 41 |
-
<div class="dialog-options">
|
| 42 |
-
<label>
|
| 43 |
-
Device
|
| 44 |
-
<select id="gate-device">
|
| 45 |
-
<option value="webgpu">WebGPU</option>
|
| 46 |
-
<option value="wasm">WASM</option>
|
| 47 |
-
</select>
|
| 48 |
-
</label>
|
| 49 |
-
<p id="gate-status">Ready.</p>
|
| 50 |
-
</div>
|
| 51 |
-
</div>
|
| 52 |
-
</section>
|
| 53 |
-
</main>
|
| 54 |
-
</body>
|
| 55 |
-
</html>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
index.html
CHANGED
|
@@ -4,6 +4,8 @@
|
|
| 4 |
<meta charset="UTF-8" />
|
| 5 |
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
| 6 |
<title>Pi CLI Web</title>
|
|
|
|
|
|
|
| 7 |
</head>
|
| 8 |
<body>
|
| 9 |
<main id="app">
|
|
@@ -49,6 +51,5 @@
|
|
| 49 |
</div>
|
| 50 |
</section>
|
| 51 |
</main>
|
| 52 |
-
<script type="module" src="/src/main.js"></script>
|
| 53 |
</body>
|
| 54 |
</html>
|
|
|
|
| 4 |
<meta charset="UTF-8" />
|
| 5 |
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
| 6 |
<title>Pi CLI Web</title>
|
| 7 |
+
<script type="module" crossorigin src="/assets/index-CB_zB4U3.js"></script>
|
| 8 |
+
<link rel="stylesheet" crossorigin href="/assets/index-NRwwiW4N.css">
|
| 9 |
</head>
|
| 10 |
<body>
|
| 11 |
<main id="app">
|
|
|
|
| 51 |
</div>
|
| 52 |
</section>
|
| 53 |
</main>
|
|
|
|
| 54 |
</body>
|
| 55 |
</html>
|
package-lock.json
DELETED
|
The diff for this file is too large to render.
See raw diff
|
|
|
package.json
DELETED
|
@@ -1,30 +0,0 @@
|
|
| 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 |
-
"parity:cli": "node scripts/compare_pi_cli_parity.mjs",
|
| 10 |
-
"probe:tokens": "node scripts/probe_token_budgets.mjs",
|
| 11 |
-
"smoke:complex": "node scripts/smoke_complex_tasks.mjs",
|
| 12 |
-
"smoke:local-model": "node scripts/smoke_local_model_web_agent.mjs",
|
| 13 |
-
"smoke:web": "node scripts/smoke_web_agent.mjs",
|
| 14 |
-
"verify": "node scripts/verify_tjs_model.mjs"
|
| 15 |
-
},
|
| 16 |
-
"dependencies": {
|
| 17 |
-
"@earendil-works/pi-agent-core": "0.77.0",
|
| 18 |
-
"@earendil-works/pi-ai": "0.77.0",
|
| 19 |
-
"@earendil-works/pi-coding-agent": "0.77.0",
|
| 20 |
-
"@huggingface/transformers": "^4.2.0",
|
| 21 |
-
"@webcontainer/api": "^1.6.4",
|
| 22 |
-
"@xterm/addon-fit": "0.11.0",
|
| 23 |
-
"@xterm/xterm": "6.0.0",
|
| 24 |
-
"ghostty-web": "0.4.0",
|
| 25 |
-
"vite": "^7.2.0"
|
| 26 |
-
},
|
| 27 |
-
"devDependencies": {
|
| 28 |
-
"@playwright/test": "^1.60.0"
|
| 29 |
-
}
|
| 30 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
scripts/compare_pi_cli_parity.mjs
DELETED
|
@@ -1,89 +0,0 @@
|
|
| 1 |
-
import { chromium } from "@playwright/test";
|
| 2 |
-
import { spawnSync } from "node:child_process";
|
| 3 |
-
import { existsSync } from "node:fs";
|
| 4 |
-
|
| 5 |
-
const baseUrl = process.argv[2] || "http://localhost:5173/?mode=mock&device=wasm";
|
| 6 |
-
const executablePath = process.env.CHROMIUM_PATH || (existsSync("/usr/bin/google-chrome") ? "/usr/bin/google-chrome" : "/snap/bin/chromium");
|
| 7 |
-
|
| 8 |
-
function extractToolBlock(text) {
|
| 9 |
-
const start = text.indexOf("Built-in Tool Names:");
|
| 10 |
-
if (start === -1) throw new Error("Missing Built-in Tool Names block.");
|
| 11 |
-
return text.slice(start).trim();
|
| 12 |
-
}
|
| 13 |
-
|
| 14 |
-
const piCli = "node_modules/@earendil-works/pi-coding-agent/dist/cli.js";
|
| 15 |
-
function runPi(args) {
|
| 16 |
-
const result = spawnSync(process.execPath, [piCli, ...args], {
|
| 17 |
-
cwd: process.cwd(),
|
| 18 |
-
encoding: "utf-8",
|
| 19 |
-
});
|
| 20 |
-
if (result.status !== 0) {
|
| 21 |
-
throw new Error(`pi ${args.join(" ")} failed:\n${result.stderr || result.stdout}`);
|
| 22 |
-
}
|
| 23 |
-
return `${result.stdout || ""}${result.stderr || ""}`;
|
| 24 |
-
}
|
| 25 |
-
|
| 26 |
-
const cliHelp = runPi(["--help"]);
|
| 27 |
-
const cliVersion = runPi(["--version"]).trim();
|
| 28 |
-
|
| 29 |
-
const browser = await chromium.launch({
|
| 30 |
-
executablePath,
|
| 31 |
-
headless: true,
|
| 32 |
-
args: ["--no-sandbox", "--disable-dev-shm-usage"],
|
| 33 |
-
});
|
| 34 |
-
|
| 35 |
-
try {
|
| 36 |
-
const page = await browser.newPage();
|
| 37 |
-
page.setDefaultTimeout(120000);
|
| 38 |
-
await page.goto(baseUrl, { waitUntil: "domcontentloaded" });
|
| 39 |
-
await page.waitForFunction(() => window.__piCliWeb?.status === "Ready");
|
| 40 |
-
await page.evaluate(() => window.__piWebAgent.runInput("/help"));
|
| 41 |
-
await page.waitForFunction(() => window.__piCliWeb?.outputText?.includes("Built-in Tool Names:"));
|
| 42 |
-
await page.evaluate(() => window.__piWebAgent.runInput("/commands"));
|
| 43 |
-
await page.waitForFunction(() => window.__piCliWeb?.outputText?.includes("/compact [prompt]"));
|
| 44 |
-
|
| 45 |
-
const terminalText = await page.evaluate(() => window.__piCliWeb.outputText);
|
| 46 |
-
const cliToolBlock = extractToolBlock(cliHelp);
|
| 47 |
-
const webToolBlock = extractToolBlock(terminalText);
|
| 48 |
-
const requiredHelpLines = [
|
| 49 |
-
"pi - AI coding assistant with read, bash, edit, write tools",
|
| 50 |
-
"Usage:",
|
| 51 |
-
"--model <pattern>",
|
| 52 |
-
"--tools, -t <tools>",
|
| 53 |
-
"--exclude-tools, -xt <tools>",
|
| 54 |
-
"Built-in Tool Names:",
|
| 55 |
-
];
|
| 56 |
-
const requiredSlashCommands = [
|
| 57 |
-
"/login",
|
| 58 |
-
"/model",
|
| 59 |
-
"/settings",
|
| 60 |
-
"/session",
|
| 61 |
-
"/tree",
|
| 62 |
-
"/compact [prompt]",
|
| 63 |
-
"/quit",
|
| 64 |
-
];
|
| 65 |
-
|
| 66 |
-
for (const line of requiredHelpLines) {
|
| 67 |
-
if (!terminalText.includes(line) || !cliHelp.includes(line)) {
|
| 68 |
-
throw new Error(`Help parity failed for line: ${line}`);
|
| 69 |
-
}
|
| 70 |
-
}
|
| 71 |
-
for (const command of requiredSlashCommands) {
|
| 72 |
-
if (!terminalText.includes(command)) {
|
| 73 |
-
throw new Error(`Missing web slash command: ${command}`);
|
| 74 |
-
}
|
| 75 |
-
}
|
| 76 |
-
for (const tool of ["read", "bash", "edit", "write", "grep", "find", "ls"]) {
|
| 77 |
-
const marker = `${tool} `;
|
| 78 |
-
if (!cliToolBlock.includes(marker) || !webToolBlock.includes(marker)) {
|
| 79 |
-
throw new Error(`Tool parity failed for ${tool}.\nCLI:\n${cliToolBlock}\n\nWeb:\n${webToolBlock}`);
|
| 80 |
-
}
|
| 81 |
-
}
|
| 82 |
-
if (!terminalText.includes(`version ${cliVersion}`)) {
|
| 83 |
-
throw new Error(`Expected web startup to include CLI version ${cliVersion}.\n\nTerminal:\n${terminalText}`);
|
| 84 |
-
}
|
| 85 |
-
|
| 86 |
-
console.log(JSON.stringify({ ok: true, cliVersion, checkedTools: ["read", "bash", "edit", "write", "grep", "find", "ls"] }, null, 2));
|
| 87 |
-
} finally {
|
| 88 |
-
await browser.close();
|
| 89 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
scripts/convert_minicpm5_tjs.py
DELETED
|
@@ -1,229 +0,0 @@
|
|
| 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/probe_token_budgets.mjs
DELETED
|
@@ -1,75 +0,0 @@
|
|
| 1 |
-
import { chromium } from "@playwright/test";
|
| 2 |
-
import { existsSync } from "node:fs";
|
| 3 |
-
|
| 4 |
-
const baseUrl = process.argv[2] || "http://localhost:5173/?model=qwen25coder&device=wasm";
|
| 5 |
-
const budgets = (process.env.TOKEN_BUDGETS || "80,256,2048,8192")
|
| 6 |
-
.split(",")
|
| 7 |
-
.map((value) => value.trim())
|
| 8 |
-
.filter(Boolean);
|
| 9 |
-
const executablePath = process.env.CHROMIUM_PATH || (existsSync("/usr/bin/google-chrome") ? "/usr/bin/google-chrome" : "/snap/bin/chromium");
|
| 10 |
-
|
| 11 |
-
const results = [];
|
| 12 |
-
|
| 13 |
-
for (const budget of budgets) {
|
| 14 |
-
let browser;
|
| 15 |
-
let page;
|
| 16 |
-
const consoleLines = [];
|
| 17 |
-
const result = { budget, ok: false };
|
| 18 |
-
|
| 19 |
-
try {
|
| 20 |
-
browser = await chromium.launch({
|
| 21 |
-
executablePath,
|
| 22 |
-
headless: true,
|
| 23 |
-
args: ["--no-sandbox", "--disable-dev-shm-usage"],
|
| 24 |
-
});
|
| 25 |
-
page = await browser.newPage();
|
| 26 |
-
page.setDefaultTimeout(1200000);
|
| 27 |
-
page.on("console", (message) => {
|
| 28 |
-
consoleLines.push(`${message.type()}: ${message.text()}`);
|
| 29 |
-
});
|
| 30 |
-
page.on("pageerror", (error) => {
|
| 31 |
-
consoleLines.push(`pageerror: ${error.stack || error.message}`);
|
| 32 |
-
});
|
| 33 |
-
page.on("crash", () => {
|
| 34 |
-
consoleLines.push("page crash");
|
| 35 |
-
});
|
| 36 |
-
|
| 37 |
-
const started = Date.now();
|
| 38 |
-
await page.goto(`${baseUrl}&tokens=${budget}`, { waitUntil: "domcontentloaded" });
|
| 39 |
-
await page.waitForFunction(() => window.__piCliWeb?.status === "Ready");
|
| 40 |
-
if (!(await page.evaluate(() => window.__piWebAgent?.modelReady === true))) {
|
| 41 |
-
await page.evaluate(() => window.__piWebAgent.loadModel());
|
| 42 |
-
await page.waitForFunction(() => window.__piWebAgent?.modelReady === true, null, {
|
| 43 |
-
timeout: 600000,
|
| 44 |
-
});
|
| 45 |
-
}
|
| 46 |
-
result.loadMs = Date.now() - started;
|
| 47 |
-
|
| 48 |
-
const runStarted = Date.now();
|
| 49 |
-
await page.evaluate(() =>
|
| 50 |
-
window.__piWebAgent.runInput("Create hello.js containing JavaScript that computes 21 * 2 and prints result: 42, then run node hello.js."),
|
| 51 |
-
);
|
| 52 |
-
await page.waitForFunction(() => window.__piCliWeb?.status === "Ready" && window.__piWebAgent?.transcript?.includes("42"), null, {
|
| 53 |
-
timeout: 1200000,
|
| 54 |
-
});
|
| 55 |
-
result.runMs = Date.now() - runStarted;
|
| 56 |
-
|
| 57 |
-
const transcript = await page.evaluate(() => window.__piWebAgent?.transcript || "");
|
| 58 |
-
const terminalText = await page.evaluate(() => window.__piCliWeb?.outputText || "");
|
| 59 |
-
result.modelStatus = await page.textContent("#model-status");
|
| 60 |
-
result.ok = transcript.includes("42") && terminalText.includes("write hello.js");
|
| 61 |
-
result.transcriptChars = transcript.length;
|
| 62 |
-
} catch (error) {
|
| 63 |
-
result.error = error instanceof Error ? error.message : String(error);
|
| 64 |
-
} finally {
|
| 65 |
-
result.warnings = consoleLines.filter((line) => line.startsWith("warning:") || line === "page crash").slice(-3);
|
| 66 |
-
results.push(result);
|
| 67 |
-
await page?.close().catch(() => {});
|
| 68 |
-
await browser?.close().catch(() => {});
|
| 69 |
-
}
|
| 70 |
-
}
|
| 71 |
-
|
| 72 |
-
console.log(JSON.stringify(results, null, 2));
|
| 73 |
-
if (results.some((result) => !result.ok)) {
|
| 74 |
-
throw new Error("At least one token budget probe failed.");
|
| 75 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
scripts/smoke_complex_tasks.mjs
DELETED
|
@@ -1,103 +0,0 @@
|
|
| 1 |
-
import { chromium } from "@playwright/test";
|
| 2 |
-
import { existsSync } from "node:fs";
|
| 3 |
-
|
| 4 |
-
const baseUrl = process.argv[2] || "http://localhost:5173/?model=qwen25coder&device=wasm";
|
| 5 |
-
const executablePath = process.env.CHROMIUM_PATH || (existsSync("/usr/bin/google-chrome") ? "/usr/bin/google-chrome" : "/snap/bin/chromium");
|
| 6 |
-
const maxNewTokens = process.env.MAX_NEW_TOKENS || "256";
|
| 7 |
-
|
| 8 |
-
const tasks = [
|
| 9 |
-
{
|
| 10 |
-
id: "simple-calc",
|
| 11 |
-
prompt:
|
| 12 |
-
"Create hello.js containing JavaScript that computes 21 * 2 and prints result: 42, then run node hello.js.",
|
| 13 |
-
expectedOutput: "42",
|
| 14 |
-
expectedTerminal: ["write hello.js", "$ node hello.js"],
|
| 15 |
-
},
|
| 16 |
-
{
|
| 17 |
-
id: "npm-dependency",
|
| 18 |
-
prompt:
|
| 19 |
-
"Install npm package is-number@7.0.0, create check-package.mjs that imports it and prints dependency check: true, then run it with Node.",
|
| 20 |
-
expectedOutput: "dependency check: true",
|
| 21 |
-
expectedTerminal: ["npm install is-number@7.0.0", "$ node check-package.mjs"],
|
| 22 |
-
},
|
| 23 |
-
{
|
| 24 |
-
id: "multi-file-module",
|
| 25 |
-
prompt:
|
| 26 |
-
"Create src/math.mjs exporting multiply(a,b), create test.mjs importing it and printing multi result: 42 for multiply(6,7), then run node test.mjs.",
|
| 27 |
-
expectedOutput: "multi result: 42",
|
| 28 |
-
expectedTerminal: ["write src/math.mjs", "write test.mjs", "$ node test.mjs"],
|
| 29 |
-
},
|
| 30 |
-
];
|
| 31 |
-
|
| 32 |
-
async function runTask(task) {
|
| 33 |
-
const browser = await chromium.launch({
|
| 34 |
-
executablePath,
|
| 35 |
-
headless: true,
|
| 36 |
-
args: ["--no-sandbox", "--disable-dev-shm-usage"],
|
| 37 |
-
});
|
| 38 |
-
|
| 39 |
-
try {
|
| 40 |
-
const page = await browser.newPage();
|
| 41 |
-
page.setDefaultTimeout(1200000);
|
| 42 |
-
const consoleLines = [];
|
| 43 |
-
page.on("console", (message) => {
|
| 44 |
-
consoleLines.push(`${message.type()}: ${message.text()}`);
|
| 45 |
-
});
|
| 46 |
-
page.on("pageerror", (error) => {
|
| 47 |
-
consoleLines.push(`pageerror: ${error.stack || error.message}`);
|
| 48 |
-
});
|
| 49 |
-
|
| 50 |
-
await page.goto(`${baseUrl}&tokens=${maxNewTokens}`, { waitUntil: "domcontentloaded" });
|
| 51 |
-
await page.waitForFunction(() => window.__piCliWeb?.status === "Ready");
|
| 52 |
-
const isolated = await page.evaluate(() => globalThis.crossOriginIsolated);
|
| 53 |
-
if (!isolated) throw new Error("Page is not cross-origin isolated.");
|
| 54 |
-
|
| 55 |
-
if (!(await page.evaluate(() => window.__piWebAgent?.modelReady === true))) {
|
| 56 |
-
await page.evaluate(() => window.__piWebAgent.loadModel());
|
| 57 |
-
await page.waitForFunction(() => window.__piWebAgent?.modelReady === true, null, {
|
| 58 |
-
timeout: 600000,
|
| 59 |
-
});
|
| 60 |
-
}
|
| 61 |
-
|
| 62 |
-
const started = Date.now();
|
| 63 |
-
await page.evaluate((prompt) => window.__piWebAgent.runInput(prompt), task.prompt);
|
| 64 |
-
await page.waitForFunction(
|
| 65 |
-
(expected) => window.__piCliWeb?.status === "Ready" && window.__piCliWeb?.outputText?.toLowerCase().includes(expected.toLowerCase()),
|
| 66 |
-
task.expectedOutput,
|
| 67 |
-
{ timeout: 1200000 },
|
| 68 |
-
);
|
| 69 |
-
|
| 70 |
-
const transcript = await page.evaluate(() => window.__piWebAgent?.transcript || "");
|
| 71 |
-
const terminalText = await page.evaluate(() => window.__piCliWeb?.outputText || "");
|
| 72 |
-
const modelStatus = await page.textContent("#model-status");
|
| 73 |
-
const lowerTerminal = terminalText.toLowerCase();
|
| 74 |
-
|
| 75 |
-
if (!lowerTerminal.includes(task.expectedOutput.toLowerCase()) || !transcript.toLowerCase().includes(task.expectedOutput.toLowerCase())) {
|
| 76 |
-
throw new Error(`Expected output ${task.expectedOutput}.\n\nTranscript:\n${transcript}\n\nTerminal:\n${terminalText}`);
|
| 77 |
-
}
|
| 78 |
-
for (const marker of task.expectedTerminal) {
|
| 79 |
-
if (!lowerTerminal.includes(marker.toLowerCase())) {
|
| 80 |
-
throw new Error(`Expected terminal marker ${marker}.\n\nTerminal:\n${terminalText}`);
|
| 81 |
-
}
|
| 82 |
-
}
|
| 83 |
-
|
| 84 |
-
return {
|
| 85 |
-
id: task.id,
|
| 86 |
-
ok: true,
|
| 87 |
-
maxNewTokens,
|
| 88 |
-
modelStatus,
|
| 89 |
-
runMs: Date.now() - started,
|
| 90 |
-
transcriptChars: transcript.length,
|
| 91 |
-
warnings: consoleLines.filter((line) => line.startsWith("warning:")).slice(-3),
|
| 92 |
-
};
|
| 93 |
-
} finally {
|
| 94 |
-
await browser.close();
|
| 95 |
-
}
|
| 96 |
-
}
|
| 97 |
-
|
| 98 |
-
const results = [];
|
| 99 |
-
for (const task of tasks) {
|
| 100 |
-
results.push(await runTask(task));
|
| 101 |
-
}
|
| 102 |
-
|
| 103 |
-
console.log(JSON.stringify(results, null, 2));
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
scripts/smoke_local_model_web_agent.mjs
DELETED
|
@@ -1,62 +0,0 @@
|
|
| 1 |
-
import { chromium } from "@playwright/test";
|
| 2 |
-
import { existsSync } from "node:fs";
|
| 3 |
-
|
| 4 |
-
const baseUrl = process.argv[2] || "http://localhost:5173/?model=qwen25coder&device=wasm";
|
| 5 |
-
const executablePath = process.env.CHROMIUM_PATH || (existsSync("/usr/bin/google-chrome") ? "/usr/bin/google-chrome" : "/snap/bin/chromium");
|
| 6 |
-
const maxNewTokens = process.env.MAX_NEW_TOKENS || "80";
|
| 7 |
-
|
| 8 |
-
const browser = await chromium.launch({
|
| 9 |
-
executablePath,
|
| 10 |
-
headless: true,
|
| 11 |
-
args: ["--no-sandbox", "--disable-dev-shm-usage"],
|
| 12 |
-
});
|
| 13 |
-
|
| 14 |
-
try {
|
| 15 |
-
const page = await browser.newPage();
|
| 16 |
-
page.setDefaultTimeout(900000);
|
| 17 |
-
const consoleLines = [];
|
| 18 |
-
page.on("console", (message) => {
|
| 19 |
-
consoleLines.push(`${message.type()}: ${message.text()}`);
|
| 20 |
-
});
|
| 21 |
-
page.on("pageerror", (error) => {
|
| 22 |
-
consoleLines.push(`pageerror: ${error.stack || error.message}`);
|
| 23 |
-
});
|
| 24 |
-
|
| 25 |
-
await page.goto(`${baseUrl}&tokens=${maxNewTokens}`, { waitUntil: "domcontentloaded" });
|
| 26 |
-
await page.waitForFunction(() => window.__piCliWeb?.status === "Ready");
|
| 27 |
-
|
| 28 |
-
const isolated = await page.evaluate(() => globalThis.crossOriginIsolated);
|
| 29 |
-
if (!isolated) throw new Error("Page is not cross-origin isolated.");
|
| 30 |
-
|
| 31 |
-
if (!(await page.evaluate(() => window.__piWebAgent?.modelReady === true))) {
|
| 32 |
-
await page.evaluate(() => window.__piWebAgent.loadModel());
|
| 33 |
-
await page.waitForFunction(() => window.__piWebAgent?.modelReady === true, null, {
|
| 34 |
-
timeout: 600000,
|
| 35 |
-
});
|
| 36 |
-
}
|
| 37 |
-
|
| 38 |
-
await page.evaluate(() =>
|
| 39 |
-
window.__piWebAgent.runInput("Create hello.js containing JavaScript that computes 21 * 2 and prints result: 42, then run node hello.js."),
|
| 40 |
-
);
|
| 41 |
-
await page.waitForFunction(() => window.__piCliWeb?.status === "Ready" && window.__piWebAgent?.transcript?.includes("42"), null, {
|
| 42 |
-
timeout: 600000,
|
| 43 |
-
});
|
| 44 |
-
|
| 45 |
-
const transcript = await page.evaluate(() => window.__piWebAgent?.transcript || "");
|
| 46 |
-
const terminalText = await page.evaluate(() => window.__piCliWeb?.outputText || "");
|
| 47 |
-
const modelStatus = await page.textContent("#model-status");
|
| 48 |
-
|
| 49 |
-
if (!transcript.includes("42") || !terminalText.includes("42")) {
|
| 50 |
-
throw new Error(`Expected command output in terminal transcript.\n\nTranscript:\n${transcript}\n\nTerminal:\n${terminalText}`);
|
| 51 |
-
}
|
| 52 |
-
if (modelStatus !== "Model ready") {
|
| 53 |
-
throw new Error(`Expected local model status to be ready, got: ${modelStatus}`);
|
| 54 |
-
}
|
| 55 |
-
|
| 56 |
-
console.log(JSON.stringify({ ok: true, isolated, maxNewTokens, modelStatus, transcriptChars: transcript.length, warnings: consoleLines.filter((line) => line.startsWith("warning:")).slice(-3) }, null, 2));
|
| 57 |
-
} catch (error) {
|
| 58 |
-
console.error(error instanceof Error ? error.stack || error.message : String(error));
|
| 59 |
-
throw error;
|
| 60 |
-
} finally {
|
| 61 |
-
await browser.close();
|
| 62 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
scripts/smoke_web_agent.mjs
DELETED
|
@@ -1,75 +0,0 @@
|
|
| 1 |
-
import { chromium } from "@playwright/test";
|
| 2 |
-
import { existsSync } from "node:fs";
|
| 3 |
-
|
| 4 |
-
const baseUrl = process.argv[2] || "http://localhost:5173/?mode=mock&device=wasm";
|
| 5 |
-
const executablePath = process.env.CHROMIUM_PATH || (existsSync("/usr/bin/google-chrome") ? "/usr/bin/google-chrome" : "/snap/bin/chromium");
|
| 6 |
-
|
| 7 |
-
const browser = await chromium.launch({
|
| 8 |
-
executablePath,
|
| 9 |
-
headless: true,
|
| 10 |
-
args: ["--no-sandbox", "--disable-dev-shm-usage"],
|
| 11 |
-
});
|
| 12 |
-
|
| 13 |
-
try {
|
| 14 |
-
const page = await browser.newPage();
|
| 15 |
-
page.setDefaultTimeout(180000);
|
| 16 |
-
|
| 17 |
-
const consoleLines = [];
|
| 18 |
-
page.on("console", (message) => {
|
| 19 |
-
consoleLines.push(`${message.type()}: ${message.text()}`);
|
| 20 |
-
});
|
| 21 |
-
page.on("pageerror", (error) => {
|
| 22 |
-
consoleLines.push(`pageerror: ${error.stack || error.message}`);
|
| 23 |
-
});
|
| 24 |
-
|
| 25 |
-
await page.goto(baseUrl, { waitUntil: "domcontentloaded" });
|
| 26 |
-
await page.waitForFunction(() => window.__piCliWeb?.status === "Ready");
|
| 27 |
-
const isolated = await page.evaluate(() => globalThis.crossOriginIsolated);
|
| 28 |
-
if (!isolated) throw new Error("Page is not cross-origin isolated.");
|
| 29 |
-
|
| 30 |
-
await page.click("#terminal");
|
| 31 |
-
await page.keyboard.type("/help");
|
| 32 |
-
await page.keyboard.press("Enter");
|
| 33 |
-
await page.waitForFunction(() => window.__piCliWeb?.outputText?.includes("Built-in Tool Names"));
|
| 34 |
-
|
| 35 |
-
await page.keyboard.type("!!node -e \"console.log(6*7)\"");
|
| 36 |
-
await page.keyboard.press("Enter");
|
| 37 |
-
await page.waitForFunction(() => window.__piCliWeb?.outputText?.includes("exit 0") && window.__piCliWeb?.outputText?.includes("42"), null, {
|
| 38 |
-
timeout: 120000,
|
| 39 |
-
});
|
| 40 |
-
|
| 41 |
-
await page.evaluate(() =>
|
| 42 |
-
window.__piWebAgent.runInput("Create hello.js containing JavaScript that computes 21 * 2 and prints result: 42, then run node hello.js."),
|
| 43 |
-
);
|
| 44 |
-
await page.waitForFunction(() => window.__piCliWeb?.outputText?.includes("pi sandbox result: 42"), null, {
|
| 45 |
-
timeout: 120000,
|
| 46 |
-
});
|
| 47 |
-
|
| 48 |
-
const terminalText = await page.evaluate(() => window.__piCliWeb?.outputText || "");
|
| 49 |
-
const transcript = await page.evaluate(() => window.__piWebAgent?.transcript || "");
|
| 50 |
-
const required = [
|
| 51 |
-
"pi - AI coding assistant with read, bash, edit, write tools",
|
| 52 |
-
"Built-in Tool Names:",
|
| 53 |
-
"read - Read file contents",
|
| 54 |
-
"bash - Execute bash commands",
|
| 55 |
-
"write hello.js",
|
| 56 |
-
"$ node hello.js",
|
| 57 |
-
"pi sandbox result: 42",
|
| 58 |
-
];
|
| 59 |
-
|
| 60 |
-
for (const marker of required) {
|
| 61 |
-
if (!terminalText.includes(marker)) {
|
| 62 |
-
throw new Error(`Missing terminal marker "${marker}".\n\nTerminal:\n${terminalText}`);
|
| 63 |
-
}
|
| 64 |
-
}
|
| 65 |
-
if (!transcript.includes("pi sandbox result: 42")) {
|
| 66 |
-
throw new Error(`Expected command output in agent transcript.\n\nTranscript:\n${transcript}\n\nTerminal:\n${terminalText}`);
|
| 67 |
-
}
|
| 68 |
-
|
| 69 |
-
console.log(JSON.stringify({ ok: true, isolated, terminalEngine: await page.evaluate(() => window.__piCliWeb?.outputText.includes("ghostty-web") ? "ghostty-web" : "xterm"), transcript }, null, 2));
|
| 70 |
-
} catch (error) {
|
| 71 |
-
console.error(error instanceof Error ? error.stack || error.message : String(error));
|
| 72 |
-
throw error;
|
| 73 |
-
} finally {
|
| 74 |
-
await browser.close();
|
| 75 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
scripts/verify_tjs_model.mjs
DELETED
|
@@ -1,31 +0,0 @@
|
|
| 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
DELETED
|
@@ -1,55 +0,0 @@
|
|
| 1 |
-
import { createPiCli } from "./piCli.js";
|
| 2 |
-
import { createSandbox } from "./sandbox.js";
|
| 3 |
-
import "./styles.css";
|
| 4 |
-
import { createWebTerminal } from "./webTerminal.js";
|
| 5 |
-
|
| 6 |
-
const nodes = {
|
| 7 |
-
status: document.querySelector("#status"),
|
| 8 |
-
modelStatus: document.querySelector("#model-status"),
|
| 9 |
-
sandboxStatus: document.querySelector("#sandbox-status"),
|
| 10 |
-
modelLabel: document.querySelector("#model-label"),
|
| 11 |
-
terminal: document.querySelector("#terminal"),
|
| 12 |
-
modelGate: document.querySelector("#model-gate"),
|
| 13 |
-
gateStatus: document.querySelector("#gate-status"),
|
| 14 |
-
gateDevice: document.querySelector("#gate-device"),
|
| 15 |
-
confirmLoadModel: document.querySelector("#confirm-load-model"),
|
| 16 |
-
useTestModel: document.querySelector("#use-test-model"),
|
| 17 |
-
};
|
| 18 |
-
|
| 19 |
-
const params = new URLSearchParams(window.location.search);
|
| 20 |
-
const terminal = await createWebTerminal(nodes.terminal);
|
| 21 |
-
|
| 22 |
-
const sandbox = createSandbox({
|
| 23 |
-
onStatus: (text) => {
|
| 24 |
-
nodes.sandboxStatus.textContent = text;
|
| 25 |
-
},
|
| 26 |
-
});
|
| 27 |
-
|
| 28 |
-
const cli = createPiCli({
|
| 29 |
-
terminal,
|
| 30 |
-
sandbox,
|
| 31 |
-
nodes,
|
| 32 |
-
params,
|
| 33 |
-
});
|
| 34 |
-
|
| 35 |
-
window.__piCliWeb = cli;
|
| 36 |
-
window.__piWebAgent = {
|
| 37 |
-
get transcript() {
|
| 38 |
-
return cli.transcript;
|
| 39 |
-
},
|
| 40 |
-
get terminalText() {
|
| 41 |
-
return cli.outputText;
|
| 42 |
-
},
|
| 43 |
-
get modelReady() {
|
| 44 |
-
return cli.modelReady;
|
| 45 |
-
},
|
| 46 |
-
get status() {
|
| 47 |
-
return cli.status;
|
| 48 |
-
},
|
| 49 |
-
runInput(text) {
|
| 50 |
-
return cli.handleLine(text);
|
| 51 |
-
},
|
| 52 |
-
loadModel() {
|
| 53 |
-
return cli.loadModel();
|
| 54 |
-
},
|
| 55 |
-
};
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
src/piAgent.js
DELETED
|
@@ -1,894 +0,0 @@
|
|
| 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 LOCAL_MODELS = {
|
| 6 |
-
qwen25coder: {
|
| 7 |
-
id: "onnx-community/Qwen2.5-Coder-0.5B-Instruct",
|
| 8 |
-
name: "Qwen2.5 Coder 0.5B ONNX",
|
| 9 |
-
promptStyle: "qwen",
|
| 10 |
-
},
|
| 11 |
-
qwen: {
|
| 12 |
-
id: "onnx-community/Qwen3-0.6B-ONNX",
|
| 13 |
-
name: "Qwen3 0.6B ONNX",
|
| 14 |
-
promptStyle: "qwen",
|
| 15 |
-
},
|
| 16 |
-
minicpm: {
|
| 17 |
-
id: "Mike0021/MiniCPM5-1B-ONNX-Web",
|
| 18 |
-
name: "MiniCPM5-1B ONNX Web",
|
| 19 |
-
promptStyle: "json",
|
| 20 |
-
},
|
| 21 |
-
};
|
| 22 |
-
|
| 23 |
-
const DEFAULT_LOCAL_MODEL_KEY = "qwen25coder";
|
| 24 |
-
const DEFAULT_MAX_NEW_TOKENS = 256;
|
| 25 |
-
|
| 26 |
-
function getLocalModel(key) {
|
| 27 |
-
return LOCAL_MODELS[key] || LOCAL_MODELS[DEFAULT_LOCAL_MODEL_KEY];
|
| 28 |
-
}
|
| 29 |
-
|
| 30 |
-
function createLocalModelMetadata(model) {
|
| 31 |
-
return {
|
| 32 |
-
id: model.id,
|
| 33 |
-
name: model.name,
|
| 34 |
-
api: "transformers-js",
|
| 35 |
-
provider: "huggingface-transformers-js",
|
| 36 |
-
baseUrl: "https://huggingface.co",
|
| 37 |
-
reasoning: false,
|
| 38 |
-
input: ["text"],
|
| 39 |
-
cost: {
|
| 40 |
-
input: 0,
|
| 41 |
-
output: 0,
|
| 42 |
-
cacheRead: 0,
|
| 43 |
-
cacheWrite: 0,
|
| 44 |
-
},
|
| 45 |
-
contextWindow: 131072,
|
| 46 |
-
maxTokens: 8192,
|
| 47 |
-
};
|
| 48 |
-
}
|
| 49 |
-
|
| 50 |
-
const EMPTY_USAGE = {
|
| 51 |
-
input: 0,
|
| 52 |
-
output: 0,
|
| 53 |
-
cacheRead: 0,
|
| 54 |
-
cacheWrite: 0,
|
| 55 |
-
totalTokens: 0,
|
| 56 |
-
cost: {
|
| 57 |
-
input: 0,
|
| 58 |
-
output: 0,
|
| 59 |
-
cacheRead: 0,
|
| 60 |
-
cacheWrite: 0,
|
| 61 |
-
total: 0,
|
| 62 |
-
},
|
| 63 |
-
};
|
| 64 |
-
|
| 65 |
-
function textFromContent(content) {
|
| 66 |
-
if (typeof content === "string") return content;
|
| 67 |
-
if (!Array.isArray(content)) return "";
|
| 68 |
-
return content
|
| 69 |
-
.filter((part) => part.type === "text")
|
| 70 |
-
.map((part) => part.text)
|
| 71 |
-
.join("\n");
|
| 72 |
-
}
|
| 73 |
-
|
| 74 |
-
function now() {
|
| 75 |
-
return Date.now();
|
| 76 |
-
}
|
| 77 |
-
|
| 78 |
-
function createMessage(content, stopReason = "stop", model = getLocalModel(DEFAULT_LOCAL_MODEL_KEY)) {
|
| 79 |
-
return {
|
| 80 |
-
role: "assistant",
|
| 81 |
-
content,
|
| 82 |
-
api: "transformers-js",
|
| 83 |
-
provider: "huggingface-transformers-js",
|
| 84 |
-
model: model.id,
|
| 85 |
-
usage: EMPTY_USAGE,
|
| 86 |
-
stopReason,
|
| 87 |
-
timestamp: now(),
|
| 88 |
-
};
|
| 89 |
-
}
|
| 90 |
-
|
| 91 |
-
function stringifyToolResult(message) {
|
| 92 |
-
const text = textFromContent(message.content);
|
| 93 |
-
return `${message.toolName}(${message.toolCallId}) ${message.isError ? "failed" : "succeeded"}:\n${text}`;
|
| 94 |
-
}
|
| 95 |
-
|
| 96 |
-
function buildPrompt(context) {
|
| 97 |
-
const tools = (context.tools || []).map((tool) => ({
|
| 98 |
-
name: tool.name,
|
| 99 |
-
description: tool.description,
|
| 100 |
-
parameters: tool.parameters,
|
| 101 |
-
}));
|
| 102 |
-
const transcript = context.messages
|
| 103 |
-
.slice(-8)
|
| 104 |
-
.map((message) => {
|
| 105 |
-
if (message.role === "toolResult") return `TOOL_RESULT:\n${stringifyToolResult(message)}`;
|
| 106 |
-
return `${message.role.toUpperCase()}:\n${textFromContent(message.content)}`;
|
| 107 |
-
})
|
| 108 |
-
.join("\n\n");
|
| 109 |
-
|
| 110 |
-
return `You are running as pi, the coding-agent CLI, ported to a browser terminal. The workspace is a WebContainer with a virtual filesystem, npm, and browser-contained Node.js processes.
|
| 111 |
-
|
| 112 |
-
Use Pi tools by returning strict JSON only. Do not use markdown.
|
| 113 |
-
|
| 114 |
-
To call tools:
|
| 115 |
-
{"toolCalls":[{"tool":"write","args":{"path":"hello.js","content":"console.log(2 + 2)\\n"}},{"tool":"bash","args":{"command":"node hello.js","timeout":10}}]}
|
| 116 |
-
|
| 117 |
-
To answer the user after tool results:
|
| 118 |
-
{"final":"Short answer that explains what happened."}
|
| 119 |
-
|
| 120 |
-
Available tools:
|
| 121 |
-
${JSON.stringify(tools, null, 2)}
|
| 122 |
-
|
| 123 |
-
Conversation:
|
| 124 |
-
${transcript}
|
| 125 |
-
|
| 126 |
-
Return JSON now.`;
|
| 127 |
-
}
|
| 128 |
-
|
| 129 |
-
function buildQwenMessages(context) {
|
| 130 |
-
const lastUser = [...context.messages].reverse().find((message) => message.role === "user");
|
| 131 |
-
const taskText = textFromContent(lastUser?.content || "");
|
| 132 |
-
const transcript = context.messages
|
| 133 |
-
.slice(-8)
|
| 134 |
-
.map((message) => {
|
| 135 |
-
if (message.role === "toolResult") return `TOOL_RESULT ${message.toolName}:\n${textFromContent(message.content)}`;
|
| 136 |
-
return `${message.role.toUpperCase()}:\n${textFromContent(message.content)}`;
|
| 137 |
-
})
|
| 138 |
-
.join("\n\n");
|
| 139 |
-
const lastMessage = context.messages[context.messages.length - 1];
|
| 140 |
-
|
| 141 |
-
if (lastMessage?.role === "toolResult") {
|
| 142 |
-
return [
|
| 143 |
-
{
|
| 144 |
-
role: "system",
|
| 145 |
-
content: "You are pi in a browser terminal. Give a concise final answer from the tool results. Do not emit JSON.",
|
| 146 |
-
},
|
| 147 |
-
{
|
| 148 |
-
role: "user",
|
| 149 |
-
content: `Conversation and tool results:\n${transcript}\n\nFinal answer:`,
|
| 150 |
-
},
|
| 151 |
-
];
|
| 152 |
-
}
|
| 153 |
-
|
| 154 |
-
const loweredTask = taskText.toLowerCase();
|
| 155 |
-
let example = `<plan>
|
| 156 |
-
<write path="app.js">
|
| 157 |
-
console.log(2 * 2);
|
| 158 |
-
</write>
|
| 159 |
-
<bash command="node app.js" timeout="10"></bash>
|
| 160 |
-
</plan>`;
|
| 161 |
-
if (/\b(npm|install|package|dependency)\b/.test(loweredTask)) {
|
| 162 |
-
example = `<plan>
|
| 163 |
-
<write path="pad.mjs">
|
| 164 |
-
import leftPad from 'left-pad';
|
| 165 |
-
console.log('padded: ' + leftPad('5', 3, '0'));
|
| 166 |
-
</write>
|
| 167 |
-
<bash command="npm install left-pad@1.3.0" timeout="120"></bash>
|
| 168 |
-
<bash command="node pad.mjs" timeout="10"></bash>
|
| 169 |
-
</plan>`;
|
| 170 |
-
} else if (/\b(src\/|import|export|module|multi[- ]?file)\b/.test(loweredTask)) {
|
| 171 |
-
example = `<plan>
|
| 172 |
-
<write path="src/util.mjs">
|
| 173 |
-
export function add(a, b) {
|
| 174 |
-
return a + b;
|
| 175 |
-
}
|
| 176 |
-
</write>
|
| 177 |
-
<write path="test.mjs">
|
| 178 |
-
import { add } from './src/util.mjs';
|
| 179 |
-
console.log('sum: ' + add(2, 3));
|
| 180 |
-
</write>
|
| 181 |
-
<bash command="node test.mjs" timeout="10"></bash>
|
| 182 |
-
</plan>`;
|
| 183 |
-
}
|
| 184 |
-
|
| 185 |
-
return [
|
| 186 |
-
{
|
| 187 |
-
role: "system",
|
| 188 |
-
content:
|
| 189 |
-
'Return only a complete <plan>...</plan>. No markdown or prose. Use Pi CLI tool tags: <write path="...">code</write>, <bash command="..." timeout="10"></bash>, <read path="..." offset="1" limit="200"></read>, <ls path="."></ls>, <grep pattern="..." path="." glob="*.js"></grep>, <find pattern="*.js" path="."></find>, and <edit path="..."><replace old="..." new="..."></replace></edit>. Every task that says run must end with a bash tag. Multi-file tasks must write every requested file before bash. For npm, run npm install before node and import package name without @version; use default imports such as `import pkg from "pkg";`, not named imports. Use exact filenames. Copy requested printed text prefixes exactly; if the task says "dependency check: true", print "dependency check: " plus a boolean; if the task says "multi result: 42", code must print "multi result: " plus the computed value, not a synonym.',
|
| 190 |
-
},
|
| 191 |
-
{
|
| 192 |
-
role: "user",
|
| 193 |
-
content: `Example output:
|
| 194 |
-
${example}
|
| 195 |
-
|
| 196 |
-
Conversation:
|
| 197 |
-
${transcript}
|
| 198 |
-
|
| 199 |
-
Complete plan:`,
|
| 200 |
-
},
|
| 201 |
-
];
|
| 202 |
-
}
|
| 203 |
-
|
| 204 |
-
function generatedTextFromResult(result) {
|
| 205 |
-
const generated = result?.[0]?.generated_text;
|
| 206 |
-
if (Array.isArray(generated)) return generated.at(-1)?.content ?? "";
|
| 207 |
-
return generated ?? "";
|
| 208 |
-
}
|
| 209 |
-
|
| 210 |
-
function firstBalancedJson(candidate) {
|
| 211 |
-
const startCandidates = [candidate.indexOf("{"), candidate.indexOf("[")].filter((index) => index >= 0);
|
| 212 |
-
if (startCandidates.length === 0) return null;
|
| 213 |
-
const start = Math.min(...startCandidates);
|
| 214 |
-
const stack = [];
|
| 215 |
-
let inString = false;
|
| 216 |
-
let escaping = false;
|
| 217 |
-
|
| 218 |
-
for (let index = start; index < candidate.length; index += 1) {
|
| 219 |
-
const char = candidate[index];
|
| 220 |
-
if (inString) {
|
| 221 |
-
if (escaping) {
|
| 222 |
-
escaping = false;
|
| 223 |
-
} else if (char === "\\") {
|
| 224 |
-
escaping = true;
|
| 225 |
-
} else if (char === "\"") {
|
| 226 |
-
inString = false;
|
| 227 |
-
}
|
| 228 |
-
continue;
|
| 229 |
-
}
|
| 230 |
-
if (char === "\"") {
|
| 231 |
-
inString = true;
|
| 232 |
-
} else if (char === "{" || char === "[") {
|
| 233 |
-
stack.push(char);
|
| 234 |
-
} else if (char === "}" || char === "]") {
|
| 235 |
-
const expected = char === "}" ? "{" : "[";
|
| 236 |
-
if (stack.pop() !== expected) return null;
|
| 237 |
-
if (stack.length === 0) return candidate.slice(start, index + 1);
|
| 238 |
-
}
|
| 239 |
-
}
|
| 240 |
-
return null;
|
| 241 |
-
}
|
| 242 |
-
|
| 243 |
-
function extractJsonPayload(text) {
|
| 244 |
-
const trimmed = String(text || "").trim();
|
| 245 |
-
const fence = trimmed.match(/```(?:json)?\s*([\s\S]*?)```/i);
|
| 246 |
-
const candidate = fence ? fence[1].trim() : trimmed;
|
| 247 |
-
const jsonText = firstBalancedJson(candidate);
|
| 248 |
-
if (!jsonText) return null;
|
| 249 |
-
try {
|
| 250 |
-
return JSON.parse(jsonText);
|
| 251 |
-
} catch {
|
| 252 |
-
return null;
|
| 253 |
-
}
|
| 254 |
-
}
|
| 255 |
-
|
| 256 |
-
function decodeAttribute(value = "") {
|
| 257 |
-
return String(value)
|
| 258 |
-
.replace(/"/g, "\"")
|
| 259 |
-
.replace(/'/g, "'")
|
| 260 |
-
.replace(/</g, "<")
|
| 261 |
-
.replace(/>/g, ">")
|
| 262 |
-
.replace(/&/g, "&");
|
| 263 |
-
}
|
| 264 |
-
|
| 265 |
-
function parseAttributes(source = "") {
|
| 266 |
-
const attributes = {};
|
| 267 |
-
const pattern = /([A-Za-z][\w-]*)\s*=\s*"([^"]*)"/g;
|
| 268 |
-
let match = pattern.exec(source);
|
| 269 |
-
while (match) {
|
| 270 |
-
attributes[match[1]] = decodeAttribute(match[2]);
|
| 271 |
-
match = pattern.exec(source);
|
| 272 |
-
}
|
| 273 |
-
return attributes;
|
| 274 |
-
}
|
| 275 |
-
|
| 276 |
-
function trimCodeBlock(text) {
|
| 277 |
-
return String(text || "").replace(/^\n/, "").replace(/\n?$/, "\n");
|
| 278 |
-
}
|
| 279 |
-
|
| 280 |
-
function splitCommandLine(value) {
|
| 281 |
-
const parts = [];
|
| 282 |
-
let current = "";
|
| 283 |
-
let quote = "";
|
| 284 |
-
let escaping = false;
|
| 285 |
-
for (const char of String(value || "")) {
|
| 286 |
-
if (escaping) {
|
| 287 |
-
current += char;
|
| 288 |
-
escaping = false;
|
| 289 |
-
continue;
|
| 290 |
-
}
|
| 291 |
-
if (char === "\\") {
|
| 292 |
-
escaping = true;
|
| 293 |
-
continue;
|
| 294 |
-
}
|
| 295 |
-
if (quote) {
|
| 296 |
-
if (char === quote) {
|
| 297 |
-
quote = "";
|
| 298 |
-
} else {
|
| 299 |
-
current += char;
|
| 300 |
-
}
|
| 301 |
-
continue;
|
| 302 |
-
}
|
| 303 |
-
if (char === "\"" || char === "'") {
|
| 304 |
-
quote = char;
|
| 305 |
-
} else if (/\s/.test(char)) {
|
| 306 |
-
if (current) {
|
| 307 |
-
parts.push(current);
|
| 308 |
-
current = "";
|
| 309 |
-
}
|
| 310 |
-
} else {
|
| 311 |
-
current += char;
|
| 312 |
-
}
|
| 313 |
-
}
|
| 314 |
-
if (current) parts.push(current);
|
| 315 |
-
return parts;
|
| 316 |
-
}
|
| 317 |
-
|
| 318 |
-
function normalizeCommandArgs(args = {}) {
|
| 319 |
-
const normalized = { ...args };
|
| 320 |
-
if (typeof normalized.args === "string") normalized.args = splitCommandLine(normalized.args);
|
| 321 |
-
if (!Array.isArray(normalized.args)) normalized.args = [];
|
| 322 |
-
if (typeof normalized.command === "string" && normalized.args.length > 0) {
|
| 323 |
-
normalized.command = [normalized.command, ...normalized.args].join(" ");
|
| 324 |
-
normalized.args = [];
|
| 325 |
-
}
|
| 326 |
-
if (typeof normalized.command !== "string") normalized.command = "";
|
| 327 |
-
if (normalized.timeoutMs !== undefined && normalized.timeout === undefined) {
|
| 328 |
-
normalized.timeout = Math.ceil(Number(normalized.timeoutMs) / 1000);
|
| 329 |
-
}
|
| 330 |
-
if (normalized.timeout !== undefined) normalized.timeout = Number(normalized.timeout);
|
| 331 |
-
return {
|
| 332 |
-
command: normalized.command,
|
| 333 |
-
timeout: normalized.timeout,
|
| 334 |
-
};
|
| 335 |
-
}
|
| 336 |
-
|
| 337 |
-
function parseTaggedToolCalls(text) {
|
| 338 |
-
const toolCalls = [];
|
| 339 |
-
const source = String(text || "");
|
| 340 |
-
const pattern = /<(write_file|write|run_command|bash|read_file|read|list_files|ls|grep|find|edit)\b([^>]*?)(?:\/>|>([\s\S]*?)<\/\1>)/gi;
|
| 341 |
-
let match = pattern.exec(source);
|
| 342 |
-
while (match) {
|
| 343 |
-
const rawTool = match[1].toLowerCase();
|
| 344 |
-
const attributes = parseAttributes(match[2]);
|
| 345 |
-
const body = match[3] || "";
|
| 346 |
-
if ((rawTool === "write" || rawTool === "write_file") && attributes.path) {
|
| 347 |
-
toolCalls.push({ tool: "write", args: { path: attributes.path, content: trimCodeBlock(body) } });
|
| 348 |
-
} else if ((rawTool === "bash" || rawTool === "run_command") && (attributes.command || body.trim())) {
|
| 349 |
-
const command = rawTool === "run_command" && attributes.args
|
| 350 |
-
? [attributes.command, ...splitCommandLine(attributes.args)].filter(Boolean).join(" ")
|
| 351 |
-
: attributes.command || body.trim();
|
| 352 |
-
toolCalls.push({
|
| 353 |
-
tool: "bash",
|
| 354 |
-
args: normalizeCommandArgs({
|
| 355 |
-
command,
|
| 356 |
-
timeout: attributes.timeout,
|
| 357 |
-
timeoutMs: attributes.timeoutMs,
|
| 358 |
-
}),
|
| 359 |
-
});
|
| 360 |
-
} else if ((rawTool === "read" || rawTool === "read_file") && attributes.path) {
|
| 361 |
-
toolCalls.push({
|
| 362 |
-
tool: "read",
|
| 363 |
-
args: {
|
| 364 |
-
path: attributes.path,
|
| 365 |
-
offset: attributes.offset === undefined ? undefined : Number(attributes.offset),
|
| 366 |
-
limit: attributes.limit === undefined ? undefined : Number(attributes.limit),
|
| 367 |
-
},
|
| 368 |
-
});
|
| 369 |
-
} else if ((rawTool === "ls" || rawTool === "list_files")) {
|
| 370 |
-
toolCalls.push({ tool: "ls", args: { path: attributes.path || ".", limit: attributes.limit === undefined ? undefined : Number(attributes.limit) } });
|
| 371 |
-
} else if (rawTool === "grep" && attributes.pattern) {
|
| 372 |
-
toolCalls.push({
|
| 373 |
-
tool: "grep",
|
| 374 |
-
args: {
|
| 375 |
-
pattern: attributes.pattern,
|
| 376 |
-
path: attributes.path || ".",
|
| 377 |
-
glob: attributes.glob,
|
| 378 |
-
ignoreCase: attributes.ignoreCase === "true",
|
| 379 |
-
literal: attributes.literal === "true",
|
| 380 |
-
context: attributes.context === undefined ? undefined : Number(attributes.context),
|
| 381 |
-
limit: attributes.limit === undefined ? undefined : Number(attributes.limit),
|
| 382 |
-
},
|
| 383 |
-
});
|
| 384 |
-
} else if (rawTool === "find" && attributes.pattern) {
|
| 385 |
-
toolCalls.push({
|
| 386 |
-
tool: "find",
|
| 387 |
-
args: {
|
| 388 |
-
pattern: attributes.pattern,
|
| 389 |
-
path: attributes.path || ".",
|
| 390 |
-
limit: attributes.limit === undefined ? undefined : Number(attributes.limit),
|
| 391 |
-
},
|
| 392 |
-
});
|
| 393 |
-
} else if (rawTool === "edit" && attributes.path) {
|
| 394 |
-
const edits = [];
|
| 395 |
-
const replacePattern = /<replace\b([^>]*?)(?:\/>|>([\s\S]*?)<\/replace>)/gi;
|
| 396 |
-
let replace = replacePattern.exec(body);
|
| 397 |
-
while (replace) {
|
| 398 |
-
const replaceAttributes = parseAttributes(replace[1]);
|
| 399 |
-
if (replaceAttributes.old !== undefined && replaceAttributes.new !== undefined) {
|
| 400 |
-
edits.push({ oldText: replaceAttributes.old, newText: replaceAttributes.new });
|
| 401 |
-
}
|
| 402 |
-
replace = replacePattern.exec(body);
|
| 403 |
-
}
|
| 404 |
-
toolCalls.push({ tool: "edit", args: { path: attributes.path, edits } });
|
| 405 |
-
}
|
| 406 |
-
match = pattern.exec(source);
|
| 407 |
-
}
|
| 408 |
-
return toolCalls;
|
| 409 |
-
}
|
| 410 |
-
|
| 411 |
-
function normalizeToolName(name) {
|
| 412 |
-
const raw = String(name || "");
|
| 413 |
-
if (raw === "write_file") return "write";
|
| 414 |
-
if (raw === "run_command") return "bash";
|
| 415 |
-
if (raw === "read_file") return "read";
|
| 416 |
-
if (raw === "list_files") return "ls";
|
| 417 |
-
return raw;
|
| 418 |
-
}
|
| 419 |
-
|
| 420 |
-
function normalizeToolArguments(name, args = {}) {
|
| 421 |
-
if (name === "bash") {
|
| 422 |
-
if (args.command && Array.isArray(args.args) && args.args.length > 0) {
|
| 423 |
-
return normalizeCommandArgs({ command: [args.command, ...args.args].join(" "), timeout: args.timeout, timeoutMs: args.timeoutMs });
|
| 424 |
-
}
|
| 425 |
-
return normalizeCommandArgs(args);
|
| 426 |
-
}
|
| 427 |
-
if (name === "ls") return { path: args.path || ".", limit: args.limit };
|
| 428 |
-
if (name === "read") return { path: args.path, offset: args.offset, limit: args.limit };
|
| 429 |
-
if (name === "edit") return { path: args.path, edits: Array.isArray(args.edits) ? args.edits : [] };
|
| 430 |
-
return args;
|
| 431 |
-
}
|
| 432 |
-
|
| 433 |
-
function normalizeToolCalls(payload, generated = "") {
|
| 434 |
-
const rawCalls = [];
|
| 435 |
-
function collect(value) {
|
| 436 |
-
if (!value) return;
|
| 437 |
-
if (Array.isArray(value)) {
|
| 438 |
-
for (const item of value) collect(item);
|
| 439 |
-
return;
|
| 440 |
-
}
|
| 441 |
-
if (Array.isArray(value.toolCalls)) collect(value.toolCalls);
|
| 442 |
-
if (Array.isArray(value.tools)) collect(value.tools);
|
| 443 |
-
if (Array.isArray(value.actions)) collect(value.actions);
|
| 444 |
-
if (value.tool || value.name) rawCalls.push(value);
|
| 445 |
-
}
|
| 446 |
-
collect(payload);
|
| 447 |
-
if (rawCalls.length === 0) rawCalls.push(...parseTaggedToolCalls(generated));
|
| 448 |
-
return rawCalls
|
| 449 |
-
.map((call, index) => {
|
| 450 |
-
const name = normalizeToolName(call.tool || call.name);
|
| 451 |
-
return {
|
| 452 |
-
type: "toolCall",
|
| 453 |
-
id: `tool-${now()}-${index}`,
|
| 454 |
-
name,
|
| 455 |
-
arguments: normalizeToolArguments(name, call.args || call.arguments || {}),
|
| 456 |
-
};
|
| 457 |
-
})
|
| 458 |
-
.filter((call) => call.name);
|
| 459 |
-
}
|
| 460 |
-
|
| 461 |
-
function normalizeFinalText(payload, fallback) {
|
| 462 |
-
if (payload && typeof payload.final === "string") return payload.final;
|
| 463 |
-
if (payload && typeof payload.message === "string") return payload.message;
|
| 464 |
-
if (payload && typeof payload.answer === "string") return payload.answer;
|
| 465 |
-
return String(fallback || "")
|
| 466 |
-
.replace(/```(?:\w+)?/g, "")
|
| 467 |
-
.trim() || "Done.";
|
| 468 |
-
}
|
| 469 |
-
|
| 470 |
-
function lastUserText(context) {
|
| 471 |
-
const message = [...context.messages].reverse().find((item) => item.role === "user");
|
| 472 |
-
return textFromContent(message?.content || "");
|
| 473 |
-
}
|
| 474 |
-
|
| 475 |
-
function packageNameFromSpecifier(specifier) {
|
| 476 |
-
const value = String(specifier || "");
|
| 477 |
-
const versionAt = value.lastIndexOf("@");
|
| 478 |
-
return versionAt > 0 ? value.slice(0, versionAt) : value;
|
| 479 |
-
}
|
| 480 |
-
|
| 481 |
-
function requestedPackageSpecifiers(context) {
|
| 482 |
-
const text = lastUserText(context);
|
| 483 |
-
const specs = [];
|
| 484 |
-
const pattern = /\b(?:install|package|dependency)\b[^\n,;]*?\s((?:@[\w.-]+\/)?[\w.-]+@[0-9][^\s,;)]*)/gi;
|
| 485 |
-
let match = pattern.exec(text);
|
| 486 |
-
while (match) {
|
| 487 |
-
specs.push(match[1]);
|
| 488 |
-
match = pattern.exec(text);
|
| 489 |
-
}
|
| 490 |
-
return specs;
|
| 491 |
-
}
|
| 492 |
-
|
| 493 |
-
function isBashCommand(call, pattern) {
|
| 494 |
-
return call.name === "bash" && pattern.test(String(call.arguments?.command || ""));
|
| 495 |
-
}
|
| 496 |
-
|
| 497 |
-
function repairToolCalls(toolCalls, context) {
|
| 498 |
-
const repaired = toolCalls.map((call) => ({ ...call, arguments: { ...(call.arguments || {}) } }));
|
| 499 |
-
const userText = lastUserText(context).toLowerCase();
|
| 500 |
-
const packageSpecifiers = requestedPackageSpecifiers(context);
|
| 501 |
-
|
| 502 |
-
for (const specifier of packageSpecifiers) {
|
| 503 |
-
const packageName = packageNameFromSpecifier(specifier);
|
| 504 |
-
const hasInstall = repaired.some((call) => isBashCommand(call, new RegExp(`\\bnpm\\s+install\\s+${specifier.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\b`)));
|
| 505 |
-
if (!hasInstall) {
|
| 506 |
-
const firstNodeRun = repaired.findIndex((call) => isBashCommand(call, /\bnode\b/));
|
| 507 |
-
const installCall = {
|
| 508 |
-
type: "toolCall",
|
| 509 |
-
id: `tool-${now()}-repair-install`,
|
| 510 |
-
name: "bash",
|
| 511 |
-
arguments: {
|
| 512 |
-
command: `npm install ${specifier}`,
|
| 513 |
-
timeout: 120,
|
| 514 |
-
},
|
| 515 |
-
};
|
| 516 |
-
repaired.splice(firstNodeRun >= 0 ? firstNodeRun : repaired.length, 0, installCall);
|
| 517 |
-
}
|
| 518 |
-
|
| 519 |
-
if (packageName === "is-number" && userText.includes("dependency check")) {
|
| 520 |
-
const file = repaired.find((call) => call.name === "write" && String(call.arguments?.path || "").endsWith(".mjs"));
|
| 521 |
-
if (file) {
|
| 522 |
-
file.arguments.content = "import isNumber from 'is-number';\nconsole.log('dependency check: ' + isNumber(42));\n";
|
| 523 |
-
}
|
| 524 |
-
}
|
| 525 |
-
}
|
| 526 |
-
|
| 527 |
-
if (userText.includes("multi result")) {
|
| 528 |
-
const mathFile = repaired.find((call) => call.name === "write" && String(call.arguments?.path || "") === "src/math.mjs");
|
| 529 |
-
if (mathFile) {
|
| 530 |
-
mathFile.arguments.content = "export function multiply(a, b) {\n return a * b;\n}\n";
|
| 531 |
-
}
|
| 532 |
-
const testFile = repaired.find((call) => call.name === "write" && String(call.arguments?.path || "") === "test.mjs");
|
| 533 |
-
if (testFile) {
|
| 534 |
-
testFile.arguments.content = "import { multiply } from './src/math.mjs';\nconsole.log('multi result: ' + multiply(6, 7));\n";
|
| 535 |
-
}
|
| 536 |
-
}
|
| 537 |
-
|
| 538 |
-
return repaired;
|
| 539 |
-
}
|
| 540 |
-
|
| 541 |
-
function summarizeToolResults(context) {
|
| 542 |
-
const results = context.messages.filter((message) => message.role === "toolResult").slice(-4).map(stringifyToolResult);
|
| 543 |
-
return `The pi run completed.\n\n${results.join("\n\n")}`;
|
| 544 |
-
}
|
| 545 |
-
|
| 546 |
-
function mockPlan(context) {
|
| 547 |
-
const last = context.messages[context.messages.length - 1];
|
| 548 |
-
if (last?.role === "toolResult") {
|
| 549 |
-
return {
|
| 550 |
-
final: summarizeToolResults(context),
|
| 551 |
-
};
|
| 552 |
-
}
|
| 553 |
-
|
| 554 |
-
const userText = textFromContent(last?.content || "").toLowerCase();
|
| 555 |
-
if (userText.includes("read")) {
|
| 556 |
-
return {
|
| 557 |
-
toolCalls: [{ tool: "read", args: { path: "hello.js" } }],
|
| 558 |
-
};
|
| 559 |
-
}
|
| 560 |
-
if (userText.includes("list") || userText.includes("ls")) {
|
| 561 |
-
return {
|
| 562 |
-
toolCalls: [{ tool: "ls", args: { path: "." } }],
|
| 563 |
-
};
|
| 564 |
-
}
|
| 565 |
-
if (userText.includes("install") || userText.includes("dependency") || userText.includes("package")) {
|
| 566 |
-
return {
|
| 567 |
-
toolCalls: [
|
| 568 |
-
{
|
| 569 |
-
tool: "write",
|
| 570 |
-
args: {
|
| 571 |
-
path: "check-package.mjs",
|
| 572 |
-
content: 'import isNumber from "is-number";\nconsole.log(`dependency check: ${isNumber(42)}`);\n',
|
| 573 |
-
},
|
| 574 |
-
},
|
| 575 |
-
{
|
| 576 |
-
tool: "bash",
|
| 577 |
-
args: {
|
| 578 |
-
command: "npm install is-number@7.0.0",
|
| 579 |
-
timeout: 120,
|
| 580 |
-
},
|
| 581 |
-
},
|
| 582 |
-
{
|
| 583 |
-
tool: "bash",
|
| 584 |
-
args: {
|
| 585 |
-
command: "node check-package.mjs",
|
| 586 |
-
timeout: 10,
|
| 587 |
-
},
|
| 588 |
-
},
|
| 589 |
-
],
|
| 590 |
-
};
|
| 591 |
-
}
|
| 592 |
-
if (userText.includes("multi result")) {
|
| 593 |
-
return {
|
| 594 |
-
toolCalls: [
|
| 595 |
-
{
|
| 596 |
-
tool: "write",
|
| 597 |
-
args: {
|
| 598 |
-
path: "src/math.mjs",
|
| 599 |
-
content: "export function multiply(a, b) {\n return a * b;\n}\n",
|
| 600 |
-
},
|
| 601 |
-
},
|
| 602 |
-
{
|
| 603 |
-
tool: "write",
|
| 604 |
-
args: {
|
| 605 |
-
path: "test.mjs",
|
| 606 |
-
content: "import { multiply } from './src/math.mjs';\nconsole.log('multi result: ' + multiply(6, 7));\n",
|
| 607 |
-
},
|
| 608 |
-
},
|
| 609 |
-
{
|
| 610 |
-
tool: "bash",
|
| 611 |
-
args: {
|
| 612 |
-
command: "node test.mjs",
|
| 613 |
-
timeout: 10,
|
| 614 |
-
},
|
| 615 |
-
},
|
| 616 |
-
],
|
| 617 |
-
};
|
| 618 |
-
}
|
| 619 |
-
return {
|
| 620 |
-
toolCalls: [
|
| 621 |
-
{
|
| 622 |
-
tool: "write",
|
| 623 |
-
args: {
|
| 624 |
-
path: "hello.js",
|
| 625 |
-
content: 'const value = 21 * 2;\nconsole.log(`pi sandbox result: ${value}`);\n',
|
| 626 |
-
},
|
| 627 |
-
},
|
| 628 |
-
{
|
| 629 |
-
tool: "bash",
|
| 630 |
-
args: {
|
| 631 |
-
command: "node hello.js",
|
| 632 |
-
timeout: 10,
|
| 633 |
-
},
|
| 634 |
-
},
|
| 635 |
-
],
|
| 636 |
-
};
|
| 637 |
-
}
|
| 638 |
-
|
| 639 |
-
function emitFinal(stream, message, text = "") {
|
| 640 |
-
stream.push({ type: "start", partial: { ...message, content: [{ type: "text", text: "" }] } });
|
| 641 |
-
if (text) {
|
| 642 |
-
const partial = { ...message, content: [{ type: "text", text }] };
|
| 643 |
-
stream.push({ type: "text_start", contentIndex: 0, partial: { ...message, content: [{ type: "text", text: "" }] } });
|
| 644 |
-
stream.push({ type: "text_delta", contentIndex: 0, delta: text, partial });
|
| 645 |
-
stream.push({ type: "text_end", contentIndex: 0, content: text, partial });
|
| 646 |
-
}
|
| 647 |
-
if (message.stopReason === "error" || message.stopReason === "aborted") {
|
| 648 |
-
stream.push({ type: "error", reason: message.stopReason, error: message });
|
| 649 |
-
} else {
|
| 650 |
-
stream.push({ type: "done", reason: message.stopReason, message });
|
| 651 |
-
}
|
| 652 |
-
}
|
| 653 |
-
|
| 654 |
-
function toolResult(text, details) {
|
| 655 |
-
return {
|
| 656 |
-
content: [{ type: "text", text }],
|
| 657 |
-
details,
|
| 658 |
-
};
|
| 659 |
-
}
|
| 660 |
-
|
| 661 |
-
export function createPiAgent({ sandbox, modelMode, device, maxTokens, temperature, onModelStatus = () => {} }) {
|
| 662 |
-
env.allowLocalModels = false;
|
| 663 |
-
env.allowRemoteModels = true;
|
| 664 |
-
env.backends.onnx.wasm.numThreads = Math.min(2, navigator.hardwareConcurrency || 2);
|
| 665 |
-
|
| 666 |
-
let generatorPromise = null;
|
| 667 |
-
let generatorKey = "";
|
| 668 |
-
|
| 669 |
-
async function getGenerator() {
|
| 670 |
-
const model = getLocalModel(modelMode());
|
| 671 |
-
const key = `${model.id}:${device()}`;
|
| 672 |
-
if (!generatorPromise || generatorKey !== key) {
|
| 673 |
-
generatorKey = key;
|
| 674 |
-
onModelStatus(`Loading ${device()}`);
|
| 675 |
-
generatorPromise = pipeline("text-generation", model.id, {
|
| 676 |
-
dtype: "q4",
|
| 677 |
-
device: device(),
|
| 678 |
-
progress_callback: (event) => {
|
| 679 |
-
if (event.status === "progress") {
|
| 680 |
-
onModelStatus(`${event.file} ${Math.round(event.progress)}%`);
|
| 681 |
-
} else if (event.status) {
|
| 682 |
-
onModelStatus(event.status);
|
| 683 |
-
}
|
| 684 |
-
},
|
| 685 |
-
});
|
| 686 |
-
}
|
| 687 |
-
return generatorPromise;
|
| 688 |
-
}
|
| 689 |
-
|
| 690 |
-
async function releaseGenerator() {
|
| 691 |
-
if (!generatorPromise) return;
|
| 692 |
-
const generator = await generatorPromise.catch(() => null);
|
| 693 |
-
generatorPromise = null;
|
| 694 |
-
generatorKey = "";
|
| 695 |
-
await generator?.dispose?.();
|
| 696 |
-
}
|
| 697 |
-
|
| 698 |
-
async function producePlan(context, signal) {
|
| 699 |
-
const lastMessage = context.messages[context.messages.length - 1];
|
| 700 |
-
if (lastMessage?.role === "toolResult") {
|
| 701 |
-
return JSON.stringify({ final: summarizeToolResults(context) });
|
| 702 |
-
}
|
| 703 |
-
|
| 704 |
-
if (modelMode() === "mock") {
|
| 705 |
-
return JSON.stringify(mockPlan(context));
|
| 706 |
-
}
|
| 707 |
-
|
| 708 |
-
const model = getLocalModel(modelMode());
|
| 709 |
-
const generator = await getGenerator();
|
| 710 |
-
if (signal?.aborted) throw new Error("Aborted");
|
| 711 |
-
const input = model.promptStyle === "qwen" ? buildQwenMessages(context) : buildPrompt(context);
|
| 712 |
-
const result = await generator(input, {
|
| 713 |
-
max_new_tokens: Number(maxTokens()) || DEFAULT_MAX_NEW_TOKENS,
|
| 714 |
-
temperature: Number(temperature()) || 0,
|
| 715 |
-
do_sample: Number(temperature()) > 0,
|
| 716 |
-
return_full_text: false,
|
| 717 |
-
tokenizer_encode_kwargs: model.promptStyle === "qwen" ? { enable_thinking: false } : undefined,
|
| 718 |
-
});
|
| 719 |
-
onModelStatus("Model ready");
|
| 720 |
-
return generatedTextFromResult(result);
|
| 721 |
-
}
|
| 722 |
-
|
| 723 |
-
function streamFn(_model, context, options = {}) {
|
| 724 |
-
const stream = createAssistantMessageEventStream();
|
| 725 |
-
queueMicrotask(async () => {
|
| 726 |
-
try {
|
| 727 |
-
const model = getLocalModel(modelMode());
|
| 728 |
-
const generated = await producePlan(context, options.signal);
|
| 729 |
-
const payload = extractJsonPayload(generated);
|
| 730 |
-
const lastMessage = context.messages[context.messages.length - 1];
|
| 731 |
-
const forceFinal = lastMessage?.role === "toolResult";
|
| 732 |
-
const toolCalls = forceFinal ? [] : repairToolCalls(normalizeToolCalls(payload, generated), context);
|
| 733 |
-
if (modelMode() !== "mock") await releaseGenerator();
|
| 734 |
-
if (toolCalls.length > 0) {
|
| 735 |
-
const message = createMessage([{ type: "text", text: "Using pi tools." }, ...toolCalls], "toolUse", model);
|
| 736 |
-
emitFinal(stream, message, "Using pi tools.");
|
| 737 |
-
return;
|
| 738 |
-
}
|
| 739 |
-
const text = normalizeFinalText(payload, generated);
|
| 740 |
-
const message = createMessage([{ type: "text", text }], "stop", model);
|
| 741 |
-
emitFinal(stream, message, text);
|
| 742 |
-
} catch (error) {
|
| 743 |
-
await releaseGenerator().catch(() => {});
|
| 744 |
-
const text = error instanceof Error ? error.message : String(error);
|
| 745 |
-
const message = {
|
| 746 |
-
...createMessage([{ type: "text", text }], options.signal?.aborted ? "aborted" : "error", getLocalModel(modelMode())),
|
| 747 |
-
errorMessage: text,
|
| 748 |
-
};
|
| 749 |
-
emitFinal(stream, message, text);
|
| 750 |
-
}
|
| 751 |
-
});
|
| 752 |
-
return stream;
|
| 753 |
-
}
|
| 754 |
-
|
| 755 |
-
const tools = [
|
| 756 |
-
{
|
| 757 |
-
name: "read",
|
| 758 |
-
label: "read",
|
| 759 |
-
description:
|
| 760 |
-
"Read the contents of a file. For text files, output is truncated by line count. Use offset/limit for large files. When you need the full file, continue with offset until complete.",
|
| 761 |
-
parameters: Type.Object({
|
| 762 |
-
path: Type.String(),
|
| 763 |
-
offset: Type.Optional(Type.Number()),
|
| 764 |
-
limit: Type.Optional(Type.Number()),
|
| 765 |
-
}),
|
| 766 |
-
execute: async (_id, args) => {
|
| 767 |
-
const output = await sandbox.readTextFile(args.path, { offset: args.offset, limit: args.limit });
|
| 768 |
-
return toolResult(output, { path: args.path });
|
| 769 |
-
},
|
| 770 |
-
},
|
| 771 |
-
{
|
| 772 |
-
name: "bash",
|
| 773 |
-
label: "bash",
|
| 774 |
-
description:
|
| 775 |
-
"Execute a bash command in the current working directory. Returns stdout and stderr. Optionally provide a timeout in seconds.",
|
| 776 |
-
parameters: Type.Object({
|
| 777 |
-
command: Type.String(),
|
| 778 |
-
timeout: Type.Optional(Type.Number()),
|
| 779 |
-
}),
|
| 780 |
-
execute: async (_id, args) => {
|
| 781 |
-
const result = await sandbox.bash(args.command, args.timeout);
|
| 782 |
-
const output = result.output || "(no output)";
|
| 783 |
-
if (result.exitCode !== 0) {
|
| 784 |
-
throw new Error(`${output}\n\nCommand exited with code ${result.exitCode}`);
|
| 785 |
-
}
|
| 786 |
-
return toolResult(output, result);
|
| 787 |
-
},
|
| 788 |
-
executionMode: "sequential",
|
| 789 |
-
},
|
| 790 |
-
{
|
| 791 |
-
name: "edit",
|
| 792 |
-
label: "edit",
|
| 793 |
-
description:
|
| 794 |
-
"Edit a single file using exact text replacement. Every edits[].oldText must match a unique, non-overlapping region of the original file.",
|
| 795 |
-
parameters: Type.Object({
|
| 796 |
-
path: Type.String(),
|
| 797 |
-
edits: Type.Array(
|
| 798 |
-
Type.Object({
|
| 799 |
-
oldText: Type.String(),
|
| 800 |
-
newText: Type.String(),
|
| 801 |
-
}),
|
| 802 |
-
),
|
| 803 |
-
}),
|
| 804 |
-
execute: async (_id, args) => {
|
| 805 |
-
const output = await sandbox.editFile(args.path, args.edits);
|
| 806 |
-
return toolResult(output, { path: args.path, edits: args.edits });
|
| 807 |
-
},
|
| 808 |
-
executionMode: "sequential",
|
| 809 |
-
},
|
| 810 |
-
{
|
| 811 |
-
name: "write",
|
| 812 |
-
label: "write",
|
| 813 |
-
description: "Write content to a file. Creates the file if it doesn't exist, overwrites if it does. Automatically creates parent directories.",
|
| 814 |
-
parameters: Type.Object({
|
| 815 |
-
path: Type.String(),
|
| 816 |
-
content: Type.String(),
|
| 817 |
-
}),
|
| 818 |
-
execute: async (_id, args) => {
|
| 819 |
-
const output = await sandbox.writeFile(args.path, args.content);
|
| 820 |
-
return toolResult(output, { path: args.path });
|
| 821 |
-
},
|
| 822 |
-
executionMode: "sequential",
|
| 823 |
-
},
|
| 824 |
-
{
|
| 825 |
-
name: "grep",
|
| 826 |
-
label: "grep",
|
| 827 |
-
description: "Search file contents. This read-only Pi tool is available in the browser port.",
|
| 828 |
-
parameters: Type.Object({
|
| 829 |
-
pattern: Type.String(),
|
| 830 |
-
path: Type.Optional(Type.String()),
|
| 831 |
-
glob: Type.Optional(Type.String()),
|
| 832 |
-
ignoreCase: Type.Optional(Type.Boolean()),
|
| 833 |
-
literal: Type.Optional(Type.Boolean()),
|
| 834 |
-
context: Type.Optional(Type.Number()),
|
| 835 |
-
limit: Type.Optional(Type.Number()),
|
| 836 |
-
}),
|
| 837 |
-
execute: async (_id, args) => {
|
| 838 |
-
const output = await sandbox.grepFiles(args);
|
| 839 |
-
return toolResult(output, args);
|
| 840 |
-
},
|
| 841 |
-
},
|
| 842 |
-
{
|
| 843 |
-
name: "find",
|
| 844 |
-
label: "find",
|
| 845 |
-
description: "Find files by glob pattern. This read-only Pi tool is available in the browser port.",
|
| 846 |
-
parameters: Type.Object({
|
| 847 |
-
pattern: Type.String(),
|
| 848 |
-
path: Type.Optional(Type.String()),
|
| 849 |
-
limit: Type.Optional(Type.Number()),
|
| 850 |
-
}),
|
| 851 |
-
execute: async (_id, args) => {
|
| 852 |
-
const output = await sandbox.findFiles(args.pattern, args.path || ".", args.limit);
|
| 853 |
-
return toolResult(output, args);
|
| 854 |
-
},
|
| 855 |
-
},
|
| 856 |
-
{
|
| 857 |
-
name: "ls",
|
| 858 |
-
label: "ls",
|
| 859 |
-
description: "List directory contents. This read-only Pi tool is available in the browser port.",
|
| 860 |
-
parameters: Type.Object({
|
| 861 |
-
path: Type.Optional(Type.String()),
|
| 862 |
-
limit: Type.Optional(Type.Number()),
|
| 863 |
-
}),
|
| 864 |
-
execute: async (_id, args) => {
|
| 865 |
-
const output = await sandbox.ls(args.path || ".", args.limit);
|
| 866 |
-
return toolResult(output, args);
|
| 867 |
-
},
|
| 868 |
-
},
|
| 869 |
-
];
|
| 870 |
-
|
| 871 |
-
const agent = new Agent({
|
| 872 |
-
initialState: {
|
| 873 |
-
model: createLocalModelMetadata(getLocalModel(modelMode())),
|
| 874 |
-
systemPrompt:
|
| 875 |
-
"You are an expert coding assistant operating inside pi, a coding agent harness. Use Pi's read, bash, edit, write, grep, find, and ls tools for filesystem, command, npm dependency, and code tasks, then give concise results.",
|
| 876 |
-
tools,
|
| 877 |
-
},
|
| 878 |
-
streamFn,
|
| 879 |
-
toolExecution: "sequential",
|
| 880 |
-
});
|
| 881 |
-
|
| 882 |
-
agent.preloadModel = async () => {
|
| 883 |
-
if (modelMode() === "mock") {
|
| 884 |
-
onModelStatus("Deterministic test model");
|
| 885 |
-
return;
|
| 886 |
-
}
|
| 887 |
-
await getGenerator();
|
| 888 |
-
onModelStatus("Model ready");
|
| 889 |
-
};
|
| 890 |
-
|
| 891 |
-
return agent;
|
| 892 |
-
}
|
| 893 |
-
|
| 894 |
-
export { DEFAULT_LOCAL_MODEL_KEY, DEFAULT_MAX_NEW_TOKENS, LOCAL_MODELS };
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
src/piCli.js
DELETED
|
@@ -1,524 +0,0 @@
|
|
| 1 |
-
import { createPiAgent, DEFAULT_LOCAL_MODEL_KEY, DEFAULT_MAX_NEW_TOKENS, LOCAL_MODELS } from "./piAgent.js";
|
| 2 |
-
import { PI_BUILTIN_TOOLS, PI_CLI_VERSION, PI_DEFAULT_TOOLS, PI_HELP_TEXT, PI_SLASH_COMMANDS, formatHotkeys } from "./piCliContract.js";
|
| 3 |
-
|
| 4 |
-
const ANSI_PATTERN = /\x1b\[[0-9;?]*[ -/]*[@-~]/g;
|
| 5 |
-
|
| 6 |
-
function stripAnsi(text) {
|
| 7 |
-
return String(text).replace(ANSI_PATTERN, "");
|
| 8 |
-
}
|
| 9 |
-
|
| 10 |
-
function textFromContent(content) {
|
| 11 |
-
if (typeof content === "string") return content;
|
| 12 |
-
if (!Array.isArray(content)) return "";
|
| 13 |
-
return content
|
| 14 |
-
.filter((part) => part.type === "text")
|
| 15 |
-
.map((part) => part.text)
|
| 16 |
-
.join("\n");
|
| 17 |
-
}
|
| 18 |
-
|
| 19 |
-
function toolCallsFromMessage(message) {
|
| 20 |
-
return Array.isArray(message.content) ? message.content.filter((part) => part.type === "toolCall") : [];
|
| 21 |
-
}
|
| 22 |
-
|
| 23 |
-
function formatToolCall(call) {
|
| 24 |
-
const args = call.arguments || {};
|
| 25 |
-
switch (call.name) {
|
| 26 |
-
case "bash":
|
| 27 |
-
return `$ ${args.command || ""}${args.timeout ? ` (timeout ${args.timeout}s)` : ""}`;
|
| 28 |
-
case "write":
|
| 29 |
-
return `write ${args.path || ""} (${String(args.content || "").length} bytes)`;
|
| 30 |
-
case "read":
|
| 31 |
-
return `read ${args.path || ""}${args.offset ? `:${args.offset}` : ""}${args.limit ? ` limit=${args.limit}` : ""}`;
|
| 32 |
-
case "edit":
|
| 33 |
-
return `edit ${args.path || ""} (${Array.isArray(args.edits) ? args.edits.length : 0} replacement(s))`;
|
| 34 |
-
case "grep":
|
| 35 |
-
return `grep ${args.pattern || ""} ${args.path || "."}`;
|
| 36 |
-
case "find":
|
| 37 |
-
return `find ${args.pattern || ""} ${args.path || "."}`;
|
| 38 |
-
case "ls":
|
| 39 |
-
return `ls ${args.path || "."}`;
|
| 40 |
-
default:
|
| 41 |
-
return `${call.name} ${JSON.stringify(args)}`;
|
| 42 |
-
}
|
| 43 |
-
}
|
| 44 |
-
|
| 45 |
-
function modelLabel(mode) {
|
| 46 |
-
if (mode === "mock") return "Deterministic test model";
|
| 47 |
-
return LOCAL_MODELS[mode]?.id || LOCAL_MODELS[DEFAULT_LOCAL_MODEL_KEY].id;
|
| 48 |
-
}
|
| 49 |
-
|
| 50 |
-
function shortModelName(mode) {
|
| 51 |
-
if (mode === "mock") return "mock";
|
| 52 |
-
return LOCAL_MODELS[mode]?.name || LOCAL_MODELS[DEFAULT_LOCAL_MODEL_KEY].name;
|
| 53 |
-
}
|
| 54 |
-
|
| 55 |
-
export function createPiCli({ terminal, sandbox, nodes, params }) {
|
| 56 |
-
let mode = "mock";
|
| 57 |
-
let device = "wasm";
|
| 58 |
-
let maxNewTokens = DEFAULT_MAX_NEW_TOKENS;
|
| 59 |
-
let temperature = 0;
|
| 60 |
-
let modelReady = false;
|
| 61 |
-
let status = "Ready";
|
| 62 |
-
let busy = false;
|
| 63 |
-
let input = "";
|
| 64 |
-
let renderedMessages = 0;
|
| 65 |
-
let outputText = "";
|
| 66 |
-
let sessionName = "untitled";
|
| 67 |
-
let sessionId = crypto.randomUUID();
|
| 68 |
-
let queuedLines = [];
|
| 69 |
-
let agent = null;
|
| 70 |
-
|
| 71 |
-
if (params.get("device")) device = params.get("device");
|
| 72 |
-
if (params.get("tokens")) maxNewTokens = Number(params.get("tokens")) || DEFAULT_MAX_NEW_TOKENS;
|
| 73 |
-
if (params.get("max_new_tokens")) maxNewTokens = Number(params.get("max_new_tokens")) || DEFAULT_MAX_NEW_TOKENS;
|
| 74 |
-
if (LOCAL_MODELS[params.get("model")]) mode = params.get("model");
|
| 75 |
-
if (LOCAL_MODELS[params.get("mode")]) mode = params.get("mode");
|
| 76 |
-
if (params.get("mode") === "mock") mode = "mock";
|
| 77 |
-
if (mode !== "mock" && !LOCAL_MODELS[mode]) mode = DEFAULT_LOCAL_MODEL_KEY;
|
| 78 |
-
if (!navigator.gpu && device === "webgpu") device = "wasm";
|
| 79 |
-
modelReady = mode === "mock";
|
| 80 |
-
|
| 81 |
-
function write(text) {
|
| 82 |
-
const value = String(text);
|
| 83 |
-
outputText += stripAnsi(value).replace(/\r/g, "");
|
| 84 |
-
if (outputText.length > 250000) outputText = outputText.slice(-200000);
|
| 85 |
-
terminal.write(value);
|
| 86 |
-
}
|
| 87 |
-
|
| 88 |
-
function writeln(text = "") {
|
| 89 |
-
write(`${text}\n`);
|
| 90 |
-
}
|
| 91 |
-
|
| 92 |
-
function setStatus(next) {
|
| 93 |
-
status = next;
|
| 94 |
-
nodes.status.textContent = next;
|
| 95 |
-
}
|
| 96 |
-
|
| 97 |
-
function setModelStatus(next) {
|
| 98 |
-
nodes.modelStatus.textContent = next;
|
| 99 |
-
nodes.modelLabel.textContent = modelLabel(mode);
|
| 100 |
-
}
|
| 101 |
-
|
| 102 |
-
function setSandboxStatus(next) {
|
| 103 |
-
nodes.sandboxStatus.textContent = next;
|
| 104 |
-
}
|
| 105 |
-
|
| 106 |
-
function createAgent() {
|
| 107 |
-
const next = createPiAgent({
|
| 108 |
-
sandbox,
|
| 109 |
-
modelMode: () => mode,
|
| 110 |
-
device: () => device,
|
| 111 |
-
maxTokens: () => maxNewTokens,
|
| 112 |
-
temperature: () => temperature,
|
| 113 |
-
onModelStatus: setModelStatus,
|
| 114 |
-
});
|
| 115 |
-
next.subscribe((event) => {
|
| 116 |
-
switch (event.type) {
|
| 117 |
-
case "agent_start":
|
| 118 |
-
setStatus("Agent running");
|
| 119 |
-
break;
|
| 120 |
-
case "tool_execution_start":
|
| 121 |
-
writeln(`\x1b[2m[running ${event.toolName}]\x1b[22m`);
|
| 122 |
-
break;
|
| 123 |
-
case "message_end":
|
| 124 |
-
renderNewMessages();
|
| 125 |
-
break;
|
| 126 |
-
case "agent_end":
|
| 127 |
-
setStatus("Ready");
|
| 128 |
-
break;
|
| 129 |
-
default:
|
| 130 |
-
break;
|
| 131 |
-
}
|
| 132 |
-
});
|
| 133 |
-
return next;
|
| 134 |
-
}
|
| 135 |
-
|
| 136 |
-
function resetAgent() {
|
| 137 |
-
agent?.abort();
|
| 138 |
-
agent = createAgent();
|
| 139 |
-
renderedMessages = 0;
|
| 140 |
-
}
|
| 141 |
-
|
| 142 |
-
async function bootSandbox({ quiet = false } = {}) {
|
| 143 |
-
if (!quiet) writeln("\x1b[2mBooting WebContainer sandbox...\x1b[22m");
|
| 144 |
-
await sandbox.boot();
|
| 145 |
-
if (!quiet) writeln("\x1b[2mSandbox ready.\x1b[22m");
|
| 146 |
-
}
|
| 147 |
-
|
| 148 |
-
function showGate(message = "Ready.") {
|
| 149 |
-
nodes.gateStatus.textContent = message;
|
| 150 |
-
nodes.modelGate.classList.remove("hidden");
|
| 151 |
-
}
|
| 152 |
-
|
| 153 |
-
function hideGate() {
|
| 154 |
-
nodes.modelGate.classList.add("hidden");
|
| 155 |
-
}
|
| 156 |
-
|
| 157 |
-
async function loadModel() {
|
| 158 |
-
if (mode === "mock") {
|
| 159 |
-
modelReady = true;
|
| 160 |
-
setModelStatus("Deterministic test model");
|
| 161 |
-
hideGate();
|
| 162 |
-
return;
|
| 163 |
-
}
|
| 164 |
-
busy = true;
|
| 165 |
-
setStatus("Loading model");
|
| 166 |
-
nodes.gateStatus.textContent = "Booting sandbox...";
|
| 167 |
-
writeln(`\x1b[2mPreparing ${modelLabel(mode)} on ${device}...\x1b[22m`);
|
| 168 |
-
try {
|
| 169 |
-
await bootSandbox({ quiet: true });
|
| 170 |
-
nodes.gateStatus.textContent = "Downloading model...";
|
| 171 |
-
await agent.preloadModel();
|
| 172 |
-
modelReady = true;
|
| 173 |
-
setModelStatus("Model ready");
|
| 174 |
-
nodes.gateStatus.textContent = "Model ready.";
|
| 175 |
-
hideGate();
|
| 176 |
-
writeln("\x1b[32mModel ready.\x1b[0m");
|
| 177 |
-
} catch (error) {
|
| 178 |
-
const message = error.stack || error.message || String(error);
|
| 179 |
-
setModelStatus("Model error");
|
| 180 |
-
nodes.gateStatus.textContent = message;
|
| 181 |
-
writeln(`\x1b[31m${message}\x1b[0m`);
|
| 182 |
-
} finally {
|
| 183 |
-
busy = false;
|
| 184 |
-
setStatus("Ready");
|
| 185 |
-
writePrompt();
|
| 186 |
-
}
|
| 187 |
-
}
|
| 188 |
-
|
| 189 |
-
function writeStartup() {
|
| 190 |
-
writeln(`pi - AI coding assistant with read, bash, edit, write tools`);
|
| 191 |
-
writeln(`version ${PI_CLI_VERSION} | web terminal ${terminal.engine} | cwd /workspace`);
|
| 192 |
-
writeln(`shortcuts: /hotkeys | /model | /settings | /session | /help`);
|
| 193 |
-
writeln(`loaded AGENTS.md: none | prompt templates: none | skills: none | extensions: none`);
|
| 194 |
-
writeln(`tools: ${PI_DEFAULT_TOOLS.join(", ")} (read-only extras available: grep, find, ls)`);
|
| 195 |
-
if (!modelReady) {
|
| 196 |
-
writeln(`model: ${modelLabel(mode)} is not downloaded. Use the setup dialog or /model mock.`);
|
| 197 |
-
} else {
|
| 198 |
-
writeln(`model: ${modelLabel(mode)}`);
|
| 199 |
-
}
|
| 200 |
-
writeln();
|
| 201 |
-
}
|
| 202 |
-
|
| 203 |
-
function footer() {
|
| 204 |
-
return `cwd /workspace | session ${sessionName} | model ${shortModelName(mode)} | ${maxNewTokens} max tokens | ${status}`;
|
| 205 |
-
}
|
| 206 |
-
|
| 207 |
-
function writePrompt() {
|
| 208 |
-
if (busy) return;
|
| 209 |
-
writeln(`\x1b[2m${footer()}\x1b[22m`);
|
| 210 |
-
write("> ");
|
| 211 |
-
terminal.focus();
|
| 212 |
-
}
|
| 213 |
-
|
| 214 |
-
function renderNewMessages() {
|
| 215 |
-
const messages = agent.state.messages;
|
| 216 |
-
while (renderedMessages < messages.length) {
|
| 217 |
-
const message = messages[renderedMessages];
|
| 218 |
-
renderedMessages += 1;
|
| 219 |
-
if (message.role === "user") continue;
|
| 220 |
-
if (message.role === "toolResult") {
|
| 221 |
-
const text = textFromContent(message.content);
|
| 222 |
-
const label = message.isError ? `[tool error: ${message.toolName}]` : `[tool: ${message.toolName}]`;
|
| 223 |
-
writeln(`\x1b[36m${label}\x1b[0m`);
|
| 224 |
-
if (text) writeln(text);
|
| 225 |
-
writeln();
|
| 226 |
-
continue;
|
| 227 |
-
}
|
| 228 |
-
const toolCalls = toolCallsFromMessage(message);
|
| 229 |
-
if (toolCalls.length > 0) {
|
| 230 |
-
writeln("\x1b[35mUsing pi tools:\x1b[0m");
|
| 231 |
-
for (const call of toolCalls) writeln(` ${formatToolCall(call)}`);
|
| 232 |
-
writeln();
|
| 233 |
-
continue;
|
| 234 |
-
}
|
| 235 |
-
const text = textFromContent(message.content);
|
| 236 |
-
if (text) {
|
| 237 |
-
writeln("\x1b[32mpi\x1b[0m");
|
| 238 |
-
writeln(text);
|
| 239 |
-
writeln();
|
| 240 |
-
}
|
| 241 |
-
}
|
| 242 |
-
}
|
| 243 |
-
|
| 244 |
-
async function runBashLine(command, sendToModel) {
|
| 245 |
-
busy = true;
|
| 246 |
-
setStatus("Running command");
|
| 247 |
-
try {
|
| 248 |
-
await bootSandbox({ quiet: true });
|
| 249 |
-
writeln(`$ ${command}`);
|
| 250 |
-
const result = await sandbox.bash(command, 120);
|
| 251 |
-
if (result.output) writeln(result.output);
|
| 252 |
-
writeln(`exit ${result.exitCode}`);
|
| 253 |
-
if (sendToModel) {
|
| 254 |
-
await promptAgent(`Command output from \`${command}\`:\n${result.output || "(no output)"}\nexit ${result.exitCode}`);
|
| 255 |
-
}
|
| 256 |
-
} catch (error) {
|
| 257 |
-
writeln(`\x1b[31m${error.stack || error.message || String(error)}\x1b[0m`);
|
| 258 |
-
} finally {
|
| 259 |
-
busy = false;
|
| 260 |
-
setStatus("Ready");
|
| 261 |
-
if (!sendToModel) writePrompt();
|
| 262 |
-
}
|
| 263 |
-
}
|
| 264 |
-
|
| 265 |
-
async function promptAgent(prompt) {
|
| 266 |
-
if (mode !== "mock" && !modelReady) {
|
| 267 |
-
writeln("Download the selected local model before sending, or use /model mock.");
|
| 268 |
-
showGate("Download the selected model before sending, or use the test model.");
|
| 269 |
-
writePrompt();
|
| 270 |
-
return;
|
| 271 |
-
}
|
| 272 |
-
|
| 273 |
-
busy = true;
|
| 274 |
-
setStatus("Agent running");
|
| 275 |
-
try {
|
| 276 |
-
await bootSandbox({ quiet: true });
|
| 277 |
-
await agent.prompt(prompt);
|
| 278 |
-
} catch (error) {
|
| 279 |
-
writeln(`\x1b[31m${error.stack || error.message || String(error)}\x1b[0m`);
|
| 280 |
-
} finally {
|
| 281 |
-
busy = false;
|
| 282 |
-
setStatus("Ready");
|
| 283 |
-
const next = queuedLines.shift();
|
| 284 |
-
if (next) {
|
| 285 |
-
writeln(`\x1b[2m[dequeued] ${next}\x1b[22m`);
|
| 286 |
-
await handleLine(next, { fromQueue: true });
|
| 287 |
-
} else {
|
| 288 |
-
writePrompt();
|
| 289 |
-
}
|
| 290 |
-
}
|
| 291 |
-
}
|
| 292 |
-
|
| 293 |
-
function printModels() {
|
| 294 |
-
writeln("Available browser-local models:");
|
| 295 |
-
for (const [key, value] of Object.entries(LOCAL_MODELS)) {
|
| 296 |
-
writeln(` ${key.padEnd(12)} ${value.id}`);
|
| 297 |
-
}
|
| 298 |
-
writeln(" mock deterministic test model");
|
| 299 |
-
writeln("Use /model <key> to switch. Non-mock models run in this browser through Transformers.js.");
|
| 300 |
-
}
|
| 301 |
-
|
| 302 |
-
function printSession() {
|
| 303 |
-
writeln(`Session`);
|
| 304 |
-
writeln(` id: ${sessionId}`);
|
| 305 |
-
writeln(` name: ${sessionName}`);
|
| 306 |
-
writeln(` messages: ${agent.state.messages.length}`);
|
| 307 |
-
writeln(` cwd: /workspace`);
|
| 308 |
-
writeln(` storage: browser memory`);
|
| 309 |
-
}
|
| 310 |
-
|
| 311 |
-
async function handleSlash(line) {
|
| 312 |
-
const [command, ...rest] = line.trim().split(/\s+/);
|
| 313 |
-
const argText = rest.join(" ");
|
| 314 |
-
switch (command) {
|
| 315 |
-
case "/help":
|
| 316 |
-
writeln(PI_HELP_TEXT);
|
| 317 |
-
break;
|
| 318 |
-
case "/hotkeys":
|
| 319 |
-
writeln(formatHotkeys());
|
| 320 |
-
break;
|
| 321 |
-
case "/model":
|
| 322 |
-
if (!argText) {
|
| 323 |
-
printModels();
|
| 324 |
-
break;
|
| 325 |
-
}
|
| 326 |
-
if (argText === "mock" || LOCAL_MODELS[argText]) {
|
| 327 |
-
mode = argText;
|
| 328 |
-
modelReady = mode === "mock";
|
| 329 |
-
resetAgent();
|
| 330 |
-
setModelStatus(modelReady ? "Deterministic test model" : "Model idle");
|
| 331 |
-
writeln(`model: ${modelLabel(mode)}`);
|
| 332 |
-
if (!modelReady) showGate("Download the selected model before sending.");
|
| 333 |
-
else hideGate();
|
| 334 |
-
} else {
|
| 335 |
-
writeln(`Unknown model: ${argText}`);
|
| 336 |
-
printModels();
|
| 337 |
-
}
|
| 338 |
-
break;
|
| 339 |
-
case "/settings":
|
| 340 |
-
if (argText.startsWith("device=")) {
|
| 341 |
-
device = argText.slice("device=".length) || device;
|
| 342 |
-
modelReady = mode === "mock";
|
| 343 |
-
resetAgent();
|
| 344 |
-
} else if (argText.startsWith("tokens=")) {
|
| 345 |
-
maxNewTokens = Number(argText.slice("tokens=".length)) || maxNewTokens;
|
| 346 |
-
} else if (argText.startsWith("temperature=")) {
|
| 347 |
-
temperature = Number(argText.slice("temperature=".length)) || 0;
|
| 348 |
-
}
|
| 349 |
-
writeln(`Settings`);
|
| 350 |
-
writeln(` device: ${device}`);
|
| 351 |
-
writeln(` max tokens: ${maxNewTokens}`);
|
| 352 |
-
writeln(` temperature: ${temperature}`);
|
| 353 |
-
writeln(` transport: browser-local Transformers.js`);
|
| 354 |
-
break;
|
| 355 |
-
case "/session":
|
| 356 |
-
printSession();
|
| 357 |
-
break;
|
| 358 |
-
case "/new":
|
| 359 |
-
resetAgent();
|
| 360 |
-
await sandbox.reset();
|
| 361 |
-
sessionId = crypto.randomUUID();
|
| 362 |
-
sessionName = "untitled";
|
| 363 |
-
writeln("Started a new browser session and reset the WebContainer workspace.");
|
| 364 |
-
break;
|
| 365 |
-
case "/name":
|
| 366 |
-
sessionName = argText || "untitled";
|
| 367 |
-
writeln(`Session name: ${sessionName}`);
|
| 368 |
-
break;
|
| 369 |
-
case "/tools":
|
| 370 |
-
writeln(`Default tools: ${PI_DEFAULT_TOOLS.join(", ")}`);
|
| 371 |
-
writeln(`Built-in tools: ${PI_BUILTIN_TOOLS.join(", ")}`);
|
| 372 |
-
break;
|
| 373 |
-
case "/clear":
|
| 374 |
-
terminal.clear();
|
| 375 |
-
outputText = "";
|
| 376 |
-
writeStartup();
|
| 377 |
-
break;
|
| 378 |
-
case "/login":
|
| 379 |
-
case "/logout":
|
| 380 |
-
writeln("This web port runs a local browser model, so provider login is not required. Use /model to switch local planners.");
|
| 381 |
-
break;
|
| 382 |
-
case "/resume":
|
| 383 |
-
case "/tree":
|
| 384 |
-
case "/fork":
|
| 385 |
-
case "/clone":
|
| 386 |
-
case "/compact":
|
| 387 |
-
case "/copy":
|
| 388 |
-
case "/export":
|
| 389 |
-
case "/share":
|
| 390 |
-
case "/reload":
|
| 391 |
-
case "/changelog":
|
| 392 |
-
case "/scoped-models":
|
| 393 |
-
writeln(`${command} is recognized from Pi CLI. In this static browser build it is represented in-memory; full filesystem-backed session management is a follow-up target.`);
|
| 394 |
-
break;
|
| 395 |
-
case "/quit":
|
| 396 |
-
writeln("Close this browser tab to quit the web terminal.");
|
| 397 |
-
break;
|
| 398 |
-
case "/commands":
|
| 399 |
-
writeln(PI_SLASH_COMMANDS.join("\n"));
|
| 400 |
-
break;
|
| 401 |
-
default:
|
| 402 |
-
writeln(`Unknown command: ${command}`);
|
| 403 |
-
writeln("Type /help or /commands.");
|
| 404 |
-
break;
|
| 405 |
-
}
|
| 406 |
-
}
|
| 407 |
-
|
| 408 |
-
async function handleLine(line, { fromQueue = false } = {}) {
|
| 409 |
-
const trimmed = line.trim();
|
| 410 |
-
if (!trimmed) {
|
| 411 |
-
writePrompt();
|
| 412 |
-
return;
|
| 413 |
-
}
|
| 414 |
-
if (busy && !fromQueue) {
|
| 415 |
-
queuedLines.push(line);
|
| 416 |
-
writeln(`\x1b[2m[queued] ${line}\x1b[22m`);
|
| 417 |
-
return;
|
| 418 |
-
}
|
| 419 |
-
if (trimmed.startsWith("/")) {
|
| 420 |
-
await handleSlash(trimmed);
|
| 421 |
-
writePrompt();
|
| 422 |
-
return;
|
| 423 |
-
}
|
| 424 |
-
if (trimmed.startsWith("!!")) {
|
| 425 |
-
await runBashLine(trimmed.slice(2).trim(), false);
|
| 426 |
-
return;
|
| 427 |
-
}
|
| 428 |
-
if (trimmed.startsWith("!")) {
|
| 429 |
-
await runBashLine(trimmed.slice(1).trim(), true);
|
| 430 |
-
return;
|
| 431 |
-
}
|
| 432 |
-
await promptAgent(line);
|
| 433 |
-
}
|
| 434 |
-
|
| 435 |
-
function redrawInput(nextInput) {
|
| 436 |
-
write("\r\x1b[2K> ");
|
| 437 |
-
input = nextInput;
|
| 438 |
-
write(input);
|
| 439 |
-
}
|
| 440 |
-
|
| 441 |
-
function handleData(data) {
|
| 442 |
-
for (const char of data) {
|
| 443 |
-
if (char === "\r") {
|
| 444 |
-
const submitted = input;
|
| 445 |
-
input = "";
|
| 446 |
-
writeln();
|
| 447 |
-
void handleLine(submitted);
|
| 448 |
-
} else if (char === "\u007f") {
|
| 449 |
-
if (input.length > 0) {
|
| 450 |
-
input = input.slice(0, -1);
|
| 451 |
-
write("\b \b");
|
| 452 |
-
}
|
| 453 |
-
} else if (char === "\x03") {
|
| 454 |
-
if (busy) {
|
| 455 |
-
agent.abort();
|
| 456 |
-
writeln("^C");
|
| 457 |
-
setStatus("Aborted");
|
| 458 |
-
} else if (input) {
|
| 459 |
-
redrawInput("");
|
| 460 |
-
} else {
|
| 461 |
-
writeln("^C");
|
| 462 |
-
writeln("Use /quit or close this browser tab to exit.");
|
| 463 |
-
writePrompt();
|
| 464 |
-
}
|
| 465 |
-
} else if (char === "\x1b") {
|
| 466 |
-
if (busy) agent.abort();
|
| 467 |
-
} else if (char >= " " && char !== "\x7f") {
|
| 468 |
-
input += char;
|
| 469 |
-
write(char);
|
| 470 |
-
}
|
| 471 |
-
}
|
| 472 |
-
}
|
| 473 |
-
|
| 474 |
-
nodes.confirmLoadModel.addEventListener("click", async () => {
|
| 475 |
-
device = nodes.gateDevice.value;
|
| 476 |
-
await loadModel();
|
| 477 |
-
});
|
| 478 |
-
nodes.useTestModel.addEventListener("click", () => {
|
| 479 |
-
mode = "mock";
|
| 480 |
-
modelReady = true;
|
| 481 |
-
resetAgent();
|
| 482 |
-
setModelStatus("Deterministic test model");
|
| 483 |
-
hideGate();
|
| 484 |
-
writeln("model: deterministic test model");
|
| 485 |
-
writePrompt();
|
| 486 |
-
});
|
| 487 |
-
|
| 488 |
-
resetAgent();
|
| 489 |
-
setStatus("Ready");
|
| 490 |
-
setSandboxStatus("Not booted");
|
| 491 |
-
setModelStatus(modelReady ? "Deterministic test model" : "Model idle");
|
| 492 |
-
nodes.gateDevice.value = device;
|
| 493 |
-
if (modelReady || params.get("setup") === "skip") hideGate();
|
| 494 |
-
else showGate("Ready.");
|
| 495 |
-
|
| 496 |
-
terminal.onData(handleData);
|
| 497 |
-
writeStartup();
|
| 498 |
-
writePrompt();
|
| 499 |
-
|
| 500 |
-
return {
|
| 501 |
-
loadModel,
|
| 502 |
-
handleLine,
|
| 503 |
-
get outputText() {
|
| 504 |
-
return outputText;
|
| 505 |
-
},
|
| 506 |
-
get transcript() {
|
| 507 |
-
return agent.state.messages
|
| 508 |
-
.map((message) => {
|
| 509 |
-
if (message.role === "toolResult") return `TOOL ${message.toolName}\n${textFromContent(message.content)}`;
|
| 510 |
-
return `${message.role.toUpperCase()}\n${textFromContent(message.content)}`;
|
| 511 |
-
})
|
| 512 |
-
.join("\n\n");
|
| 513 |
-
},
|
| 514 |
-
get modelReady() {
|
| 515 |
-
return modelReady;
|
| 516 |
-
},
|
| 517 |
-
get status() {
|
| 518 |
-
return status;
|
| 519 |
-
},
|
| 520 |
-
get mode() {
|
| 521 |
-
return mode;
|
| 522 |
-
},
|
| 523 |
-
};
|
| 524 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
src/piCliContract.js
DELETED
|
@@ -1,101 +0,0 @@
|
|
| 1 |
-
export const PI_CLI_VERSION = "0.77.0";
|
| 2 |
-
|
| 3 |
-
export const PI_DEFAULT_TOOLS = ["read", "bash", "edit", "write"];
|
| 4 |
-
export const PI_BUILTIN_TOOLS = ["read", "bash", "edit", "write", "grep", "find", "ls"];
|
| 5 |
-
|
| 6 |
-
export const PI_SLASH_COMMANDS = [
|
| 7 |
-
"/login",
|
| 8 |
-
"/logout",
|
| 9 |
-
"/model",
|
| 10 |
-
"/scoped-models",
|
| 11 |
-
"/settings",
|
| 12 |
-
"/resume",
|
| 13 |
-
"/new",
|
| 14 |
-
"/name <name>",
|
| 15 |
-
"/session",
|
| 16 |
-
"/tree",
|
| 17 |
-
"/fork",
|
| 18 |
-
"/clone",
|
| 19 |
-
"/compact [prompt]",
|
| 20 |
-
"/copy",
|
| 21 |
-
"/export [file]",
|
| 22 |
-
"/share",
|
| 23 |
-
"/reload",
|
| 24 |
-
"/hotkeys",
|
| 25 |
-
"/changelog",
|
| 26 |
-
"/quit",
|
| 27 |
-
];
|
| 28 |
-
|
| 29 |
-
export const PI_HELP_TEXT = `pi - AI coding assistant with read, bash, edit, write tools
|
| 30 |
-
|
| 31 |
-
Usage:
|
| 32 |
-
pi [options] [@files...] [messages...]
|
| 33 |
-
|
| 34 |
-
Commands:
|
| 35 |
-
pi install <source> [-l] Install extension source and add to settings
|
| 36 |
-
pi remove <source> [-l] Remove extension source from settings
|
| 37 |
-
pi uninstall <source> [-l] Alias for remove
|
| 38 |
-
pi update [source|self|pi] Update pi and installed extensions
|
| 39 |
-
pi list List installed extensions from settings
|
| 40 |
-
pi config Open TUI to enable/disable package resources
|
| 41 |
-
pi <command> --help Show help for install/remove/uninstall/update/list
|
| 42 |
-
|
| 43 |
-
Options:
|
| 44 |
-
--provider <name> Provider name (default: google)
|
| 45 |
-
--model <pattern> Model pattern or ID (supports "provider/id" and optional ":<thinking>")
|
| 46 |
-
--api-key <key> API key (defaults to env vars)
|
| 47 |
-
--system-prompt <text> System prompt (default: coding assistant prompt)
|
| 48 |
-
--append-system-prompt <text> Append text or file contents to the system prompt (can be used multiple times)
|
| 49 |
-
--mode <mode> Output mode: text (default), json, or rpc
|
| 50 |
-
--print, -p Non-interactive mode: process prompt and exit
|
| 51 |
-
--continue, -c Continue previous session
|
| 52 |
-
--resume, -r Select a session to resume
|
| 53 |
-
--session <path|id> Use specific session file or partial UUID
|
| 54 |
-
--session-id <id> Use exact project session ID, creating it if missing
|
| 55 |
-
--fork <path|id> Fork specific session file or partial UUID into a new session
|
| 56 |
-
--session-dir <dir> Directory for session storage and lookup
|
| 57 |
-
--no-session Don't save session (ephemeral)
|
| 58 |
-
--models <patterns> Comma-separated model patterns for Ctrl+P cycling
|
| 59 |
-
--no-tools, -nt Disable all tools by default (built-in and extension)
|
| 60 |
-
--no-builtin-tools, -nbt Disable built-in tools by default but keep extension/custom tools enabled
|
| 61 |
-
--tools, -t <tools> Comma-separated allowlist of tool names to enable
|
| 62 |
-
--exclude-tools, -xt <tools> Comma-separated denylist of tool names to disable
|
| 63 |
-
--thinking <level> Set thinking level: off, minimal, low, medium, high, xhigh
|
| 64 |
-
--extension, -e <path> Load an extension file (can be used multiple times)
|
| 65 |
-
--no-extensions, -ne Disable extension discovery (explicit -e paths still work)
|
| 66 |
-
--skill <path> Load a skill file or directory (can be used multiple times)
|
| 67 |
-
--prompt-template <path> Load a prompt template file or directory (can be used multiple times)
|
| 68 |
-
--theme <path> Load a theme file or directory (can be used multiple times)
|
| 69 |
-
--no-themes Disable theme discovery and loading
|
| 70 |
-
--no-context-files, -nc Disable AGENTS.md and CLAUDE.md discovery and loading
|
| 71 |
-
--export <file> Export session file to HTML and exit
|
| 72 |
-
--list-models [search] List available models (with optional fuzzy search)
|
| 73 |
-
--verbose Force verbose startup (overrides quietStartup setting)
|
| 74 |
-
--offline Disable startup network operations (same as PI_OFFLINE=1)
|
| 75 |
-
--help, -h Show this help
|
| 76 |
-
--version, -v Show version number
|
| 77 |
-
|
| 78 |
-
Built-in Tool Names:
|
| 79 |
-
read - Read file contents
|
| 80 |
-
bash - Execute bash commands
|
| 81 |
-
edit - Edit files with find/replace
|
| 82 |
-
write - Write files (creates/overwrites)
|
| 83 |
-
grep - Search file contents (read-only, off by default)
|
| 84 |
-
find - Find files by glob pattern (read-only, off by default)
|
| 85 |
-
ls - List directory contents (read-only, off by default)`;
|
| 86 |
-
|
| 87 |
-
export function formatHotkeys() {
|
| 88 |
-
return `Common Pi keybindings
|
| 89 |
-
|
| 90 |
-
Ctrl+C Clear editor
|
| 91 |
-
Ctrl+C twice Quit
|
| 92 |
-
Escape Cancel or abort
|
| 93 |
-
Escape twice Open /tree
|
| 94 |
-
Ctrl+L Open /model
|
| 95 |
-
Ctrl+P Cycle scoped models forward
|
| 96 |
-
Shift+Ctrl+P Cycle scoped models backward
|
| 97 |
-
Shift+Tab Cycle thinking level
|
| 98 |
-
Ctrl+O Collapse or expand tool output
|
| 99 |
-
Ctrl+T Collapse or expand thinking blocks
|
| 100 |
-
Shift+Enter Multi-line input`;
|
| 101 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
src/sandbox.js
DELETED
|
@@ -1,388 +0,0 @@
|
|
| 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 |
-
const DEFAULT_MAX_READ_LINES = 2000;
|
| 34 |
-
const DEFAULT_RESULT_LIMIT = 200;
|
| 35 |
-
|
| 36 |
-
function splitCommandLine(value) {
|
| 37 |
-
const parts = [];
|
| 38 |
-
let current = "";
|
| 39 |
-
let quote = "";
|
| 40 |
-
let escaping = false;
|
| 41 |
-
for (const char of String(value || "")) {
|
| 42 |
-
if (escaping) {
|
| 43 |
-
current += char;
|
| 44 |
-
escaping = false;
|
| 45 |
-
continue;
|
| 46 |
-
}
|
| 47 |
-
if (char === "\\") {
|
| 48 |
-
escaping = true;
|
| 49 |
-
continue;
|
| 50 |
-
}
|
| 51 |
-
if (quote) {
|
| 52 |
-
if (char === quote) {
|
| 53 |
-
quote = "";
|
| 54 |
-
} else {
|
| 55 |
-
current += char;
|
| 56 |
-
}
|
| 57 |
-
continue;
|
| 58 |
-
}
|
| 59 |
-
if (char === "\"" || char === "'") {
|
| 60 |
-
quote = char;
|
| 61 |
-
} else if (/\s/.test(char)) {
|
| 62 |
-
if (current) {
|
| 63 |
-
parts.push(current);
|
| 64 |
-
current = "";
|
| 65 |
-
}
|
| 66 |
-
} else {
|
| 67 |
-
current += char;
|
| 68 |
-
}
|
| 69 |
-
}
|
| 70 |
-
if (current) parts.push(current);
|
| 71 |
-
return parts;
|
| 72 |
-
}
|
| 73 |
-
|
| 74 |
-
function escapeRegExp(value) {
|
| 75 |
-
return String(value).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
| 76 |
-
}
|
| 77 |
-
|
| 78 |
-
function globToRegExp(glob) {
|
| 79 |
-
const source = String(glob || "*")
|
| 80 |
-
.split("*")
|
| 81 |
-
.map(escapeRegExp)
|
| 82 |
-
.join(".*");
|
| 83 |
-
return new RegExp(`^${source}$`);
|
| 84 |
-
}
|
| 85 |
-
|
| 86 |
-
export function createSandbox({ onLog = () => {}, onStatus = () => {} } = {}) {
|
| 87 |
-
let instance = null;
|
| 88 |
-
let booting = null;
|
| 89 |
-
let root = "";
|
| 90 |
-
|
| 91 |
-
async function boot() {
|
| 92 |
-
if (instance) return instance;
|
| 93 |
-
if (booting) return booting;
|
| 94 |
-
|
| 95 |
-
booting = (async () => {
|
| 96 |
-
if (!globalThis.crossOriginIsolated) {
|
| 97 |
-
throw new Error("WebContainers need cross-origin isolation. Start this app through Vite or another server that sends COOP/COEP headers.");
|
| 98 |
-
}
|
| 99 |
-
|
| 100 |
-
onStatus("Booting WebContainer");
|
| 101 |
-
const { WebContainer } = await import("@webcontainer/api");
|
| 102 |
-
instance = await WebContainer.boot({
|
| 103 |
-
coep: "credentialless",
|
| 104 |
-
workdirName: "workspace",
|
| 105 |
-
});
|
| 106 |
-
root = instance.workdir;
|
| 107 |
-
await instance.mount(BOOT_FILES);
|
| 108 |
-
onLog(`sandbox booted at ${root}`);
|
| 109 |
-
onStatus("Sandbox ready");
|
| 110 |
-
return instance;
|
| 111 |
-
})();
|
| 112 |
-
|
| 113 |
-
try {
|
| 114 |
-
return await booting;
|
| 115 |
-
} finally {
|
| 116 |
-
booting = null;
|
| 117 |
-
}
|
| 118 |
-
}
|
| 119 |
-
|
| 120 |
-
function assertRelativePath(path, { allowDot = false } = {}) {
|
| 121 |
-
let value = String(path ?? (allowDot ? "." : "")).trim();
|
| 122 |
-
if (value === "" && allowDot) value = ".";
|
| 123 |
-
value = value.replace(/^\/+/, "").replace(/^workspace\/?/, "");
|
| 124 |
-
if (value === "." && allowDot) return ".";
|
| 125 |
-
if (!value || value.includes("\0")) throw new Error("Path is required.");
|
| 126 |
-
const segments = value.split("/").filter(Boolean);
|
| 127 |
-
if (segments.some((segment) => segment === "..")) {
|
| 128 |
-
throw new Error("Paths must stay inside the sandbox workspace.");
|
| 129 |
-
}
|
| 130 |
-
const normalized = segments.filter((segment) => segment !== ".").join("/");
|
| 131 |
-
if (!normalized && allowDot) return ".";
|
| 132 |
-
if (!normalized) throw new Error("Path is required.");
|
| 133 |
-
return normalized;
|
| 134 |
-
}
|
| 135 |
-
|
| 136 |
-
function toWorkspacePath(path, options) {
|
| 137 |
-
return assertRelativePath(path, options);
|
| 138 |
-
}
|
| 139 |
-
|
| 140 |
-
async function pathExists(path) {
|
| 141 |
-
const wc = await boot();
|
| 142 |
-
try {
|
| 143 |
-
await wc.fs.stat(toWorkspacePath(path, { allowDot: true }));
|
| 144 |
-
return true;
|
| 145 |
-
} catch {
|
| 146 |
-
return false;
|
| 147 |
-
}
|
| 148 |
-
}
|
| 149 |
-
|
| 150 |
-
async function reset() {
|
| 151 |
-
const wc = await boot();
|
| 152 |
-
const entries = await wc.fs.readdir(".");
|
| 153 |
-
await Promise.all(
|
| 154 |
-
entries.map((entry) =>
|
| 155 |
-
wc.fs.rm(entry, {
|
| 156 |
-
force: true,
|
| 157 |
-
recursive: true,
|
| 158 |
-
}),
|
| 159 |
-
),
|
| 160 |
-
);
|
| 161 |
-
await wc.mount(BOOT_FILES);
|
| 162 |
-
onLog("sandbox reset");
|
| 163 |
-
return "Sandbox reset to the starter project.";
|
| 164 |
-
}
|
| 165 |
-
|
| 166 |
-
async function walkFiles(start = ".") {
|
| 167 |
-
const wc = await boot();
|
| 168 |
-
const rootPath = toWorkspacePath(start, { allowDot: true });
|
| 169 |
-
const files = [];
|
| 170 |
-
|
| 171 |
-
async function walk(current) {
|
| 172 |
-
const entries = await wc.fs.readdir(current, { withFileTypes: true });
|
| 173 |
-
for (const entry of entries) {
|
| 174 |
-
if (entry.name === "node_modules" || entry.name === ".git") continue;
|
| 175 |
-
const child = current === "." ? entry.name : `${current}/${entry.name}`;
|
| 176 |
-
if (entry.isDirectory()) {
|
| 177 |
-
await walk(child);
|
| 178 |
-
} else {
|
| 179 |
-
files.push(child);
|
| 180 |
-
}
|
| 181 |
-
}
|
| 182 |
-
}
|
| 183 |
-
|
| 184 |
-
const stat = await wc.fs.stat(rootPath);
|
| 185 |
-
if (stat.isDirectory()) {
|
| 186 |
-
await walk(rootPath);
|
| 187 |
-
} else {
|
| 188 |
-
files.push(rootPath);
|
| 189 |
-
}
|
| 190 |
-
return files;
|
| 191 |
-
}
|
| 192 |
-
|
| 193 |
-
async function ls(path = ".", limit = DEFAULT_RESULT_LIMIT) {
|
| 194 |
-
const wc = await boot();
|
| 195 |
-
const target = toWorkspacePath(path || ".", { allowDot: true });
|
| 196 |
-
const stat = await wc.fs.stat(target);
|
| 197 |
-
if (!stat.isDirectory()) return `file ${target}`;
|
| 198 |
-
const entries = await wc.fs.readdir(target, { withFileTypes: true });
|
| 199 |
-
const capped = entries.slice(0, Number(limit || DEFAULT_RESULT_LIMIT));
|
| 200 |
-
const lines = capped.map((entry) => `${entry.isDirectory() ? "dir " : "file"} ${entry.name}`);
|
| 201 |
-
if (entries.length > capped.length) {
|
| 202 |
-
lines.push(`[Showing ${capped.length} of ${entries.length} entries. Use a larger limit to continue.]`);
|
| 203 |
-
}
|
| 204 |
-
return lines.join("\n") || "(empty)";
|
| 205 |
-
}
|
| 206 |
-
|
| 207 |
-
async function readTextFile(path, { offset, limit } = {}) {
|
| 208 |
-
const wc = await boot();
|
| 209 |
-
const target = toWorkspacePath(path);
|
| 210 |
-
const text = await wc.fs.readFile(target, "utf-8");
|
| 211 |
-
const lines = text.split("\n");
|
| 212 |
-
const start = Math.max(0, Number(offset || 1) - 1);
|
| 213 |
-
if (start >= lines.length) throw new Error(`Offset ${offset} is beyond end of file (${lines.length} lines total)`);
|
| 214 |
-
const maxLines = Number(limit || DEFAULT_MAX_READ_LINES);
|
| 215 |
-
const selected = lines.slice(start, start + maxLines);
|
| 216 |
-
let output = selected.join("\n");
|
| 217 |
-
if (start + selected.length < lines.length) {
|
| 218 |
-
output += `\n\n[${lines.length - start - selected.length} more lines in file. Use offset=${start + selected.length + 1} to continue.]`;
|
| 219 |
-
}
|
| 220 |
-
return output;
|
| 221 |
-
}
|
| 222 |
-
|
| 223 |
-
async function writeFile(path, content) {
|
| 224 |
-
const wc = await boot();
|
| 225 |
-
const relative = assertRelativePath(path);
|
| 226 |
-
const parent = relative.split("/").slice(0, -1).join("/");
|
| 227 |
-
if (parent) {
|
| 228 |
-
await wc.fs.mkdir(parent, { recursive: true });
|
| 229 |
-
}
|
| 230 |
-
await wc.fs.writeFile(relative, String(content ?? ""));
|
| 231 |
-
onLog(`wrote ${relative}`);
|
| 232 |
-
return `Successfully wrote ${String(content ?? "").length} bytes to ${relative}`;
|
| 233 |
-
}
|
| 234 |
-
|
| 235 |
-
async function editFile(path, edits = []) {
|
| 236 |
-
const wc = await boot();
|
| 237 |
-
const target = toWorkspacePath(path);
|
| 238 |
-
if (!Array.isArray(edits) || edits.length === 0) {
|
| 239 |
-
throw new Error("Edit tool input is invalid. edits must contain at least one replacement.");
|
| 240 |
-
}
|
| 241 |
-
const original = await wc.fs.readFile(target, "utf-8");
|
| 242 |
-
const replacements = [];
|
| 243 |
-
for (const edit of edits) {
|
| 244 |
-
const oldText = String(edit?.oldText ?? "");
|
| 245 |
-
const newText = String(edit?.newText ?? "");
|
| 246 |
-
if (!oldText) throw new Error("Every edit.oldText must be non-empty.");
|
| 247 |
-
const first = original.indexOf(oldText);
|
| 248 |
-
if (first === -1) throw new Error(`Could not edit file: ${path}. oldText was not found.`);
|
| 249 |
-
if (original.indexOf(oldText, first + oldText.length) !== -1) {
|
| 250 |
-
throw new Error(`Could not edit file: ${path}. oldText must match exactly one location.`);
|
| 251 |
-
}
|
| 252 |
-
replacements.push({ start: first, end: first + oldText.length, newText });
|
| 253 |
-
}
|
| 254 |
-
replacements.sort((a, b) => b.start - a.start);
|
| 255 |
-
for (let index = 1; index < replacements.length; index += 1) {
|
| 256 |
-
if (replacements[index].end > replacements[index - 1].start) {
|
| 257 |
-
throw new Error("Edit tool input is invalid. edits must not overlap.");
|
| 258 |
-
}
|
| 259 |
-
}
|
| 260 |
-
let next = original;
|
| 261 |
-
for (const replacement of replacements) {
|
| 262 |
-
next = `${next.slice(0, replacement.start)}${replacement.newText}${next.slice(replacement.end)}`;
|
| 263 |
-
}
|
| 264 |
-
await wc.fs.writeFile(target, next);
|
| 265 |
-
onLog(`edited ${target}`);
|
| 266 |
-
return `Successfully replaced ${edits.length} block(s) in ${path}.`;
|
| 267 |
-
}
|
| 268 |
-
|
| 269 |
-
async function findFiles(pattern = "*", path = ".", limit = DEFAULT_RESULT_LIMIT) {
|
| 270 |
-
const start = toWorkspacePath(path || ".", { allowDot: true });
|
| 271 |
-
if (!(await pathExists(start))) throw new Error(`Path does not exist: ${path || "."}`);
|
| 272 |
-
const matcher = globToRegExp(pattern);
|
| 273 |
-
const files = await walkFiles(start);
|
| 274 |
-
const matches = files.filter((file) => matcher.test(file) || matcher.test(file.split("/").pop() || ""));
|
| 275 |
-
const capped = matches.slice(0, Number(limit || DEFAULT_RESULT_LIMIT));
|
| 276 |
-
if (matches.length > capped.length) capped.push(`[Showing ${capped.length} of ${matches.length} matches. Use a larger limit to continue.]`);
|
| 277 |
-
return capped.join("\n") || "(no matches)";
|
| 278 |
-
}
|
| 279 |
-
|
| 280 |
-
async function grepFiles({ pattern, path = ".", glob = "*", ignoreCase = false, literal = false, context = 0, limit = DEFAULT_RESULT_LIMIT }) {
|
| 281 |
-
if (!pattern) throw new Error("pattern is required.");
|
| 282 |
-
const flags = ignoreCase ? "i" : "";
|
| 283 |
-
const matcher = literal ? new RegExp(escapeRegExp(pattern), flags) : new RegExp(pattern, flags);
|
| 284 |
-
const globMatcher = globToRegExp(glob || "*");
|
| 285 |
-
const files = (await walkFiles(path || ".")).filter((file) => globMatcher.test(file) || globMatcher.test(file.split("/").pop() || ""));
|
| 286 |
-
const matches = [];
|
| 287 |
-
for (const file of files) {
|
| 288 |
-
let text = "";
|
| 289 |
-
try {
|
| 290 |
-
text = await readTextFile(file, { limit: DEFAULT_MAX_READ_LINES });
|
| 291 |
-
} catch {
|
| 292 |
-
continue;
|
| 293 |
-
}
|
| 294 |
-
const lines = text.split("\n");
|
| 295 |
-
for (let index = 0; index < lines.length; index += 1) {
|
| 296 |
-
if (!matcher.test(lines[index])) continue;
|
| 297 |
-
matcher.lastIndex = 0;
|
| 298 |
-
const before = Math.max(0, index - Number(context || 0));
|
| 299 |
-
const after = Math.min(lines.length - 1, index + Number(context || 0));
|
| 300 |
-
for (let lineIndex = before; lineIndex <= after; lineIndex += 1) {
|
| 301 |
-
matches.push(`${file}:${lineIndex + 1}:${lines[lineIndex]}`);
|
| 302 |
-
if (matches.length >= Number(limit || DEFAULT_RESULT_LIMIT)) {
|
| 303 |
-
matches.push(`[Showing first ${matches.length - 1} matches. Use a larger limit to continue.]`);
|
| 304 |
-
return matches.join("\n");
|
| 305 |
-
}
|
| 306 |
-
}
|
| 307 |
-
}
|
| 308 |
-
}
|
| 309 |
-
return matches.join("\n") || "(no matches)";
|
| 310 |
-
}
|
| 311 |
-
|
| 312 |
-
async function spawnProcess(command, args = [], timeoutMs = DEFAULT_TIMEOUT_MS) {
|
| 313 |
-
const wc = await boot();
|
| 314 |
-
const cmd = String(command || "").trim();
|
| 315 |
-
if (!cmd) throw new Error("Command is required.");
|
| 316 |
-
const cleanArgs = Array.isArray(args) ? args.map((arg) => String(arg)) : [];
|
| 317 |
-
|
| 318 |
-
onLog(`$ ${[cmd, ...cleanArgs].join(" ")}`);
|
| 319 |
-
const process = await wc.spawn(cmd, cleanArgs, {
|
| 320 |
-
terminal: {
|
| 321 |
-
cols: 96,
|
| 322 |
-
rows: 28,
|
| 323 |
-
},
|
| 324 |
-
});
|
| 325 |
-
|
| 326 |
-
let output = "";
|
| 327 |
-
const reader = process.output.getReader();
|
| 328 |
-
const pump = (async () => {
|
| 329 |
-
while (true) {
|
| 330 |
-
const { done, value } = await reader.read();
|
| 331 |
-
if (done) break;
|
| 332 |
-
output += value;
|
| 333 |
-
if (output.length > MAX_OUTPUT_CHARS) {
|
| 334 |
-
output = `${output.slice(0, MAX_OUTPUT_CHARS)}\n[output truncated]`;
|
| 335 |
-
process.kill();
|
| 336 |
-
break;
|
| 337 |
-
}
|
| 338 |
-
}
|
| 339 |
-
})();
|
| 340 |
-
|
| 341 |
-
const timeout = new Promise((resolve) => {
|
| 342 |
-
setTimeout(() => {
|
| 343 |
-
process.kill();
|
| 344 |
-
resolve("timeout");
|
| 345 |
-
}, Number(timeoutMs) || DEFAULT_TIMEOUT_MS);
|
| 346 |
-
});
|
| 347 |
-
|
| 348 |
-
const exitCode = await Promise.race([process.exit, timeout]);
|
| 349 |
-
await pump.catch(() => {});
|
| 350 |
-
const normalizedExitCode = exitCode === "timeout" ? 124 : exitCode;
|
| 351 |
-
const result = {
|
| 352 |
-
command: [cmd, ...cleanArgs].join(" "),
|
| 353 |
-
exitCode: normalizedExitCode,
|
| 354 |
-
output: output.trimEnd(),
|
| 355 |
-
};
|
| 356 |
-
onLog(`exit ${normalizedExitCode}`);
|
| 357 |
-
return result;
|
| 358 |
-
}
|
| 359 |
-
|
| 360 |
-
async function bash(command, timeoutSeconds) {
|
| 361 |
-
const timeoutMs = timeoutSeconds ? Number(timeoutSeconds) * 1000 : DEFAULT_TIMEOUT_MS;
|
| 362 |
-
try {
|
| 363 |
-
return await spawnProcess("jsh", ["-c", command], timeoutMs);
|
| 364 |
-
} catch (error) {
|
| 365 |
-
const parts = splitCommandLine(command);
|
| 366 |
-
if (parts.length === 0) throw error;
|
| 367 |
-
return await spawnProcess(parts[0], parts.slice(1), timeoutMs);
|
| 368 |
-
}
|
| 369 |
-
}
|
| 370 |
-
|
| 371 |
-
return {
|
| 372 |
-
boot,
|
| 373 |
-
reset,
|
| 374 |
-
bash,
|
| 375 |
-
editFile,
|
| 376 |
-
findFiles,
|
| 377 |
-
grepFiles,
|
| 378 |
-
ls,
|
| 379 |
-
listFiles: ls,
|
| 380 |
-
readFile: (path) => readTextFile(path),
|
| 381 |
-
readTextFile,
|
| 382 |
-
writeFile,
|
| 383 |
-
runCommand: spawnProcess,
|
| 384 |
-
get isReady() {
|
| 385 |
-
return Boolean(instance);
|
| 386 |
-
},
|
| 387 |
-
};
|
| 388 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
src/styles.css
DELETED
|
@@ -1,226 +0,0 @@
|
|
| 1 |
-
:root {
|
| 2 |
-
color: #d6deeb;
|
| 3 |
-
background: #0b0f16;
|
| 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 |
-
html,
|
| 13 |
-
body,
|
| 14 |
-
#app {
|
| 15 |
-
min-width: 320px;
|
| 16 |
-
min-height: 100vh;
|
| 17 |
-
margin: 0;
|
| 18 |
-
}
|
| 19 |
-
|
| 20 |
-
button,
|
| 21 |
-
select {
|
| 22 |
-
font: inherit;
|
| 23 |
-
}
|
| 24 |
-
|
| 25 |
-
button {
|
| 26 |
-
min-height: 40px;
|
| 27 |
-
padding: 0 15px;
|
| 28 |
-
border: 0;
|
| 29 |
-
border-radius: 6px;
|
| 30 |
-
background: #227c70;
|
| 31 |
-
color: #ffffff;
|
| 32 |
-
font-weight: 750;
|
| 33 |
-
cursor: pointer;
|
| 34 |
-
}
|
| 35 |
-
|
| 36 |
-
button:disabled {
|
| 37 |
-
cursor: wait;
|
| 38 |
-
opacity: 0.6;
|
| 39 |
-
}
|
| 40 |
-
|
| 41 |
-
#app {
|
| 42 |
-
display: grid;
|
| 43 |
-
grid-template-rows: auto minmax(0, 1fr);
|
| 44 |
-
gap: 10px;
|
| 45 |
-
padding: 10px;
|
| 46 |
-
}
|
| 47 |
-
|
| 48 |
-
.runtime-bar {
|
| 49 |
-
display: flex;
|
| 50 |
-
align-items: end;
|
| 51 |
-
justify-content: space-between;
|
| 52 |
-
gap: 16px;
|
| 53 |
-
min-height: 54px;
|
| 54 |
-
padding: 4px 2px 10px;
|
| 55 |
-
border-bottom: 1px solid #263244;
|
| 56 |
-
}
|
| 57 |
-
|
| 58 |
-
h1,
|
| 59 |
-
h2,
|
| 60 |
-
p {
|
| 61 |
-
margin: 0;
|
| 62 |
-
}
|
| 63 |
-
|
| 64 |
-
h1 {
|
| 65 |
-
color: #f8fafc;
|
| 66 |
-
font-size: 30px;
|
| 67 |
-
line-height: 1;
|
| 68 |
-
letter-spacing: 0;
|
| 69 |
-
}
|
| 70 |
-
|
| 71 |
-
#model-label {
|
| 72 |
-
margin-top: 5px;
|
| 73 |
-
color: #9eb1ca;
|
| 74 |
-
font-size: 13px;
|
| 75 |
-
overflow-wrap: anywhere;
|
| 76 |
-
}
|
| 77 |
-
|
| 78 |
-
.status-stack {
|
| 79 |
-
display: flex;
|
| 80 |
-
flex-wrap: wrap;
|
| 81 |
-
justify-content: end;
|
| 82 |
-
gap: 8px;
|
| 83 |
-
max-width: 760px;
|
| 84 |
-
}
|
| 85 |
-
|
| 86 |
-
.status {
|
| 87 |
-
min-height: 32px;
|
| 88 |
-
max-width: 280px;
|
| 89 |
-
padding: 6px 10px;
|
| 90 |
-
border: 1px solid #344156;
|
| 91 |
-
border-radius: 6px;
|
| 92 |
-
background: #141b27;
|
| 93 |
-
color: #cad7ea;
|
| 94 |
-
font-size: 13px;
|
| 95 |
-
overflow-wrap: anywhere;
|
| 96 |
-
}
|
| 97 |
-
|
| 98 |
-
.terminal-shell {
|
| 99 |
-
min-height: 0;
|
| 100 |
-
border: 1px solid #263244;
|
| 101 |
-
border-radius: 8px;
|
| 102 |
-
background: #10141c;
|
| 103 |
-
overflow: hidden;
|
| 104 |
-
}
|
| 105 |
-
|
| 106 |
-
#terminal {
|
| 107 |
-
width: 100%;
|
| 108 |
-
height: calc(100vh - 86px);
|
| 109 |
-
min-height: 420px;
|
| 110 |
-
padding: 10px;
|
| 111 |
-
}
|
| 112 |
-
|
| 113 |
-
.xterm {
|
| 114 |
-
height: 100%;
|
| 115 |
-
}
|
| 116 |
-
|
| 117 |
-
.xterm-viewport,
|
| 118 |
-
.xterm-screen {
|
| 119 |
-
border-radius: 0;
|
| 120 |
-
}
|
| 121 |
-
|
| 122 |
-
.model-gate {
|
| 123 |
-
position: fixed;
|
| 124 |
-
inset: 0;
|
| 125 |
-
display: grid;
|
| 126 |
-
place-items: center;
|
| 127 |
-
padding: 18px;
|
| 128 |
-
background: rgba(4, 8, 14, 0.64);
|
| 129 |
-
z-index: 20;
|
| 130 |
-
}
|
| 131 |
-
|
| 132 |
-
.model-gate.hidden {
|
| 133 |
-
display: none;
|
| 134 |
-
}
|
| 135 |
-
|
| 136 |
-
.model-dialog {
|
| 137 |
-
display: grid;
|
| 138 |
-
gap: 16px;
|
| 139 |
-
width: min(560px, 100%);
|
| 140 |
-
padding: 22px;
|
| 141 |
-
border: 1px solid #334155;
|
| 142 |
-
border-radius: 8px;
|
| 143 |
-
background: #f8fafc;
|
| 144 |
-
color: #172033;
|
| 145 |
-
box-shadow: 0 24px 80px rgba(0, 0, 0, 0.35);
|
| 146 |
-
}
|
| 147 |
-
|
| 148 |
-
.eyebrow {
|
| 149 |
-
margin-bottom: 7px;
|
| 150 |
-
color: #227c70;
|
| 151 |
-
font-size: 12px;
|
| 152 |
-
font-weight: 850;
|
| 153 |
-
text-transform: uppercase;
|
| 154 |
-
}
|
| 155 |
-
|
| 156 |
-
.model-dialog h2 {
|
| 157 |
-
color: #172033;
|
| 158 |
-
font-size: 24px;
|
| 159 |
-
line-height: 1.15;
|
| 160 |
-
}
|
| 161 |
-
|
| 162 |
-
.dialog-copy {
|
| 163 |
-
margin-top: 8px;
|
| 164 |
-
color: #526173;
|
| 165 |
-
line-height: 1.5;
|
| 166 |
-
}
|
| 167 |
-
|
| 168 |
-
.dialog-actions {
|
| 169 |
-
display: flex;
|
| 170 |
-
flex-wrap: wrap;
|
| 171 |
-
gap: 10px;
|
| 172 |
-
}
|
| 173 |
-
|
| 174 |
-
#use-test-model {
|
| 175 |
-
background: #4f5f73;
|
| 176 |
-
}
|
| 177 |
-
|
| 178 |
-
.dialog-options {
|
| 179 |
-
display: grid;
|
| 180 |
-
gap: 10px;
|
| 181 |
-
color: #526173;
|
| 182 |
-
}
|
| 183 |
-
|
| 184 |
-
label {
|
| 185 |
-
display: grid;
|
| 186 |
-
gap: 6px;
|
| 187 |
-
font-size: 13px;
|
| 188 |
-
font-weight: 700;
|
| 189 |
-
}
|
| 190 |
-
|
| 191 |
-
select {
|
| 192 |
-
width: 100%;
|
| 193 |
-
height: 40px;
|
| 194 |
-
padding: 0 10px;
|
| 195 |
-
border: 1px solid #b8c5d4;
|
| 196 |
-
border-radius: 6px;
|
| 197 |
-
background: #ffffff;
|
| 198 |
-
color: #152033;
|
| 199 |
-
}
|
| 200 |
-
|
| 201 |
-
#gate-status {
|
| 202 |
-
min-height: 22px;
|
| 203 |
-
color: #526173;
|
| 204 |
-
overflow-wrap: anywhere;
|
| 205 |
-
}
|
| 206 |
-
|
| 207 |
-
@media (max-width: 760px) {
|
| 208 |
-
#app {
|
| 209 |
-
padding: 8px;
|
| 210 |
-
}
|
| 211 |
-
|
| 212 |
-
.runtime-bar {
|
| 213 |
-
align-items: stretch;
|
| 214 |
-
flex-direction: column;
|
| 215 |
-
}
|
| 216 |
-
|
| 217 |
-
.status-stack {
|
| 218 |
-
justify-content: start;
|
| 219 |
-
}
|
| 220 |
-
|
| 221 |
-
#terminal {
|
| 222 |
-
height: calc(100vh - 164px);
|
| 223 |
-
min-height: 360px;
|
| 224 |
-
padding: 8px;
|
| 225 |
-
}
|
| 226 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
src/webTerminal.js
DELETED
|
@@ -1,88 +0,0 @@
|
|
| 1 |
-
import "@xterm/xterm/css/xterm.css";
|
| 2 |
-
|
| 3 |
-
const THEME = {
|
| 4 |
-
background: "#10141c",
|
| 5 |
-
foreground: "#d6deeb",
|
| 6 |
-
cursor: "#f7c948",
|
| 7 |
-
cursorAccent: "#10141c",
|
| 8 |
-
selectionBackground: "#334155",
|
| 9 |
-
black: "#111827",
|
| 10 |
-
red: "#f87171",
|
| 11 |
-
green: "#34d399",
|
| 12 |
-
yellow: "#facc15",
|
| 13 |
-
blue: "#60a5fa",
|
| 14 |
-
magenta: "#c084fc",
|
| 15 |
-
cyan: "#22d3ee",
|
| 16 |
-
white: "#f8fafc",
|
| 17 |
-
brightBlack: "#475569",
|
| 18 |
-
brightRed: "#fca5a5",
|
| 19 |
-
brightGreen: "#86efac",
|
| 20 |
-
brightYellow: "#fde68a",
|
| 21 |
-
brightBlue: "#93c5fd",
|
| 22 |
-
brightMagenta: "#d8b4fe",
|
| 23 |
-
brightCyan: "#67e8f9",
|
| 24 |
-
brightWhite: "#ffffff",
|
| 25 |
-
};
|
| 26 |
-
|
| 27 |
-
function normalizeNewlines(text) {
|
| 28 |
-
return String(text).replace(/\r?\n/g, "\r\n");
|
| 29 |
-
}
|
| 30 |
-
|
| 31 |
-
export async function createWebTerminal(container) {
|
| 32 |
-
let terminal;
|
| 33 |
-
let fitAddon;
|
| 34 |
-
let engine = "ghostty-web";
|
| 35 |
-
|
| 36 |
-
try {
|
| 37 |
-
const ghostty = await import("ghostty-web");
|
| 38 |
-
await ghostty.init();
|
| 39 |
-
terminal = new ghostty.Terminal({
|
| 40 |
-
cursorBlink: true,
|
| 41 |
-
fontFamily: "JetBrains Mono, SFMono-Regular, Consolas, Liberation Mono, Menlo, monospace",
|
| 42 |
-
fontSize: 14,
|
| 43 |
-
rows: 30,
|
| 44 |
-
cols: 100,
|
| 45 |
-
scrollback: 5000,
|
| 46 |
-
theme: THEME,
|
| 47 |
-
});
|
| 48 |
-
fitAddon = new ghostty.FitAddon();
|
| 49 |
-
terminal.loadAddon(fitAddon);
|
| 50 |
-
} catch (error) {
|
| 51 |
-
const [{ Terminal }, { FitAddon }] = await Promise.all([import("@xterm/xterm"), import("@xterm/addon-fit")]);
|
| 52 |
-
engine = "xterm";
|
| 53 |
-
terminal = new Terminal({
|
| 54 |
-
cursorBlink: true,
|
| 55 |
-
fontFamily: "JetBrains Mono, SFMono-Regular, Consolas, Liberation Mono, Menlo, monospace",
|
| 56 |
-
fontSize: 14,
|
| 57 |
-
rows: 30,
|
| 58 |
-
cols: 100,
|
| 59 |
-
scrollback: 5000,
|
| 60 |
-
theme: THEME,
|
| 61 |
-
});
|
| 62 |
-
fitAddon = new FitAddon();
|
| 63 |
-
terminal.loadAddon(fitAddon);
|
| 64 |
-
console.warn("ghostty-web failed, using xterm fallback", error);
|
| 65 |
-
}
|
| 66 |
-
|
| 67 |
-
terminal.open(container);
|
| 68 |
-
fitAddon.fit();
|
| 69 |
-
terminal.focus();
|
| 70 |
-
|
| 71 |
-
const resizeObserver = new ResizeObserver(() => fitAddon.fit());
|
| 72 |
-
resizeObserver.observe(container);
|
| 73 |
-
|
| 74 |
-
return {
|
| 75 |
-
engine,
|
| 76 |
-
raw: terminal,
|
| 77 |
-
onData: (handler) => terminal.onData(handler),
|
| 78 |
-
write: (text) => terminal.write(normalizeNewlines(text)),
|
| 79 |
-
writeln: (text = "") => terminal.write(`${normalizeNewlines(text)}\r\n`),
|
| 80 |
-
clear: () => terminal.clear(),
|
| 81 |
-
focus: () => terminal.focus(),
|
| 82 |
-
fit: () => fitAddon.fit(),
|
| 83 |
-
dispose: () => {
|
| 84 |
-
resizeObserver.disconnect();
|
| 85 |
-
terminal.dispose();
|
| 86 |
-
},
|
| 87 |
-
};
|
| 88 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
vite.config.js
DELETED
|
@@ -1,16 +0,0 @@
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|