Upload 65 files
Browse files- NOTICE +9 -4
- README.md +114 -76
- assets/index-DnXFGQ8z.js +2 -2
- docs/integration.md +97 -0
- docs/runtime-api.md +345 -0
- index.html +8 -10
- package.json +15 -0
- runtime/agent.js +3 -0
- runtime/minicpm5.js +0 -0
- scripts/build.mjs +24 -0
- scripts/serve.mjs +31 -0
- src/agent.worker.js +28 -0
- src/config.js +14 -0
- src/controller.js +231 -0
- src/conversation.js +56 -0
- src/workspace.js +225 -0
- test/controller.test.js +399 -0
- test/runtime.test.js +276 -0
- test/worker.test.js +92 -0
- test/workspace.test.js +167 -0
- vendor/just-bash.js +0 -0
NOTICE
CHANGED
|
@@ -1,4 +1,9 @@
|
|
| 1 |
-
|
| 2 |
-
|
| 3 |
-
|
| 4 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
Upstream workspace: victor/MiniCPM5-2B-WebGPU-Pi (Apache-2.0),
|
| 2 |
+
commit 8ac95048396e4cb8ae997d6996505f499fa0c67b.
|
| 3 |
+
Pi terminal components: earendil-works/pi 0.85.1. Pi renders the interface;
|
| 4 |
+
the supplied runtime/agent.js replaces its agent core.
|
| 5 |
+
Virtual shell: just-bash 3.4.2, Vercel, and its retained dependencies.
|
| 6 |
+
Model: OpenBMB MiniCPM5-2B GGUF. Weights are downloaded separately.
|
| 7 |
+
Inference and agent: user-supplied Archive.zip, copied byte-for-byte into runtime/.
|
| 8 |
+
Original dependency license texts are retained in licenses/ for attribution.
|
| 9 |
+
The original Transformers.js and ONNX Runtime inference assets are removed.
|
README.md
CHANGED
|
@@ -1,5 +1,5 @@
|
|
| 1 |
---
|
| 2 |
-
title: MiniCPM5
|
| 3 |
emoji: 🥧
|
| 4 |
colorFrom: green
|
| 5 |
colorTo: blue
|
|
@@ -7,86 +7,124 @@ sdk: static
|
|
| 7 |
app_file: index.html
|
| 8 |
pinned: false
|
| 9 |
license: apache-2.0
|
| 10 |
-
short_description: A coding agent
|
| 11 |
-
thumbnail: https://huggingface.co/spaces/townbox/MiniCPM5-2B-WebGPU-Pi/resolve/main/social-thumbnail-v3.png
|
| 12 |
models:
|
| 13 |
-
- openbmb/MiniCPM5-2B
|
| 14 |
-
- Mike0021/MiniCPM5-2B-ONNX
|
| 15 |
tags:
|
| 16 |
-
- transformers.js
|
| 17 |
-
- onnx
|
| 18 |
- webgpu
|
|
|
|
| 19 |
- text-generation
|
| 20 |
- agents
|
| 21 |
-
- pi
|
| 22 |
- just-bash
|
| 23 |
---
|
| 24 |
|
| 25 |
-
#
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
---
|
| 2 |
+
title: MiniCPM5 WebGPU Workspace
|
| 3 |
emoji: 🥧
|
| 4 |
colorFrom: green
|
| 5 |
colorTo: blue
|
|
|
|
| 7 |
app_file: index.html
|
| 8 |
pinned: false
|
| 9 |
license: apache-2.0
|
| 10 |
+
short_description: A local coding agent with a GGUF WebGPU runtime
|
|
|
|
| 11 |
models:
|
| 12 |
+
- openbmb/MiniCPM5-2B-GGUF
|
|
|
|
| 13 |
tags:
|
|
|
|
|
|
|
| 14 |
- webgpu
|
| 15 |
+
- gguf
|
| 16 |
- text-generation
|
| 17 |
- agents
|
|
|
|
| 18 |
- just-bash
|
| 19 |
---
|
| 20 |
|
| 21 |
+
# MiniCPM5 WebGPU workspace
|
| 22 |
+
|
| 23 |
+
A clone of [victor/MiniCPM5-2B-WebGPU-Pi](https://huggingface.co/spaces/victor/MiniCPM5-2B-WebGPU-Pi),
|
| 24 |
+
with the supplied `minicpm5.js` and `agent.js` replacing Transformers.js, ONNX Runtime,
|
| 25 |
+
and Pi's agent core. The original Pi terminal interface, editor, cat mascot, shell,
|
| 26 |
+
and virtual file workspace are preserved.
|
| 27 |
+
|
| 28 |
+
## Deploy to Hugging Face Spaces without a build
|
| 29 |
+
|
| 30 |
+
Create a **Static** Space, extract this ZIP, and upload its contents directly to
|
| 31 |
+
the Space repository root. Keep `README.md`, `index.html`, `assets/`, `src/`,
|
| 32 |
+
`runtime/`, `vendor/`, icons, images, and license files in their existing relative
|
| 33 |
+
locations. This README already declares `sdk: static` and `app_file: index.html`.
|
| 34 |
+
|
| 35 |
+
No build command, npm install, or `dist/` directory is required. The editable
|
| 36 |
+
integration source runs directly as ES modules, and the upstream UI assets are
|
| 37 |
+
already built. `npm run build` is optional: it only copies the runnable files to
|
| 38 |
+
`dist/` for hosts that expect a distribution directory.
|
| 39 |
+
|
| 40 |
+
The ZIP also contains optional local server/build scripts, tests, and API docs.
|
| 41 |
+
Model weights download on first use and are not included.
|
| 42 |
+
|
| 43 |
+
## Run locally
|
| 44 |
+
|
| 45 |
+
Requires Node.js 20+ to serve files. No npm dependencies or installation step.
|
| 46 |
+
|
| 47 |
+
```sh
|
| 48 |
+
npm start
|
| 49 |
+
```
|
| 50 |
+
|
| 51 |
+
Open http://127.0.0.1:5173 in a browser with WebGPU. Alternatively, serve the
|
| 52 |
+
project directory using any static HTTP server. Use HTTPS when hosting remotely.
|
| 53 |
+
`file://` does not work. A recent Chrome or Edge with sufficient GPU memory is
|
| 54 |
+
the runtime's documented target; this integration has not been tested on Safari.
|
| 55 |
+
|
| 56 |
+
Click **Load model**, or send a message and confirm the download. The default is
|
| 57 |
+
`openbmb/MiniCPM5-2B-GGUF/MiniCPM5-2B-Q4_K_M.gguf`, approximately 1.6 GB. The
|
| 58 |
+
runtime caches downloaded ranges in browser storage when available. This is a
|
| 59 |
+
different weight format and cache from the original ONNX demo.
|
| 60 |
+
|
| 61 |
+
The app asks for confirmation before loading, including on later visits. The
|
| 62 |
+
runtime has no public complete-cache/cache-only check, so the app cannot promise
|
| 63 |
+
that a cached reload makes zero weight requests. Available cached ranges are
|
| 64 |
+
still reused. Browser storage availability and eviction are browser-dependent.
|
| 65 |
+
|
| 66 |
+
Once loaded, prompts, model inference, tools, and files stay in the browser.
|
| 67 |
+
Weights are downloaded from Hugging Face; no inference API or API key is used.
|
| 68 |
+
The shell has no network, Python, Node.js, npm, or native binaries.
|
| 69 |
+
|
| 70 |
+
Try: **What's our total sales revenue?** The starter CSV and product prices give
|
| 71 |
+
$144; ask the agent to inspect the files and write a report.
|
| 72 |
+
|
| 73 |
+
## Controls
|
| 74 |
+
|
| 75 |
+
- Enter sends a message; Shift+Enter adds a newline.
|
| 76 |
+
- Stop, Escape, or Ctrl+C cancels generation and signals the active tool.
|
| 77 |
+
- `/load`, `/new`, `/help`, and `!ls` work in the terminal.
|
| 78 |
+
- Read, write, edit, and bash tools operate on a virtual filesystem.
|
| 79 |
+
- Import accepts text files; Export downloads a workspace JSON snapshot.
|
| 80 |
+
- Files and conversations persist in IndexedDB when browser storage is available.
|
| 81 |
+
|
| 82 |
+
The agent uses greedy decoding, automatic thinking, up to 4,096 new tokens per
|
| 83 |
+
reply including reasoning, and at most 12 replies per run. It requests an
|
| 84 |
+
8,192-token context; the runtime may lower that to fit the GPU. The UI shows the
|
| 85 |
+
actual capacity. A full context produces a clear error; use New chat to continue
|
| 86 |
+
with the same files. Histories are not silently truncated.
|
| 87 |
+
|
| 88 |
+
Cancelled partial replies remain visible but are omitted from the canonical
|
| 89 |
+
model history, matching the supplied Agent's behavior. Finished tool calls and
|
| 90 |
+
their results stay in history. Exact decode speed is reported from the runtime's
|
| 91 |
+
completed-turn timings; streaming deltas do not expose token counts.
|
| 92 |
+
|
| 93 |
+
## Project layout
|
| 94 |
+
|
| 95 |
+
| Path | Purpose |
|
| 96 |
+
| --- | --- |
|
| 97 |
+
| `index.html`, `assets/` | Original built UI, with the new worker URL and updated runtime metadata. |
|
| 98 |
+
| `runtime/minicpm5.js` | Supplied GGUF/WebGPU model implementation, unchanged. |
|
| 99 |
+
| `runtime/agent.js` | Supplied model-independent agent loop, unchanged. |
|
| 100 |
+
| `src/agent.worker.js` | Worker entry point and RPC transport. |
|
| 101 |
+
| `src/controller.js` | Loading, events, conversation lifecycle, cancellation, and saves. |
|
| 102 |
+
| `src/conversation.js` | Display-only adapter for Pi terminal messages and progress. |
|
| 103 |
+
| `src/workspace.js` | Shell tools, virtual filesystem, serialization, IndexedDB. |
|
| 104 |
+
| `src/config.js` | Model, context/token limits, and instructions. |
|
| 105 |
+
| `vendor/just-bash.js` | Shell and its dependencies extracted from the upstream worker. |
|
| 106 |
+
| `scripts/` | Dependency-free local server and static build. |
|
| 107 |
+
| `test/` | Node tests using real shell/tools and the supplied Agent with fake model sessions. |
|
| 108 |
+
| `docs/runtime-api.md` | README from the supplied archive. |
|
| 109 |
+
| `docs/integration.md` | Integration notes, provenance, and verification limits. |
|
| 110 |
+
| `licenses/`, `NOTICE`, `LICENSE` | Original notices and retained attribution. |
|
| 111 |
+
|
| 112 |
+
The upstream repository only ships built UI assets; it does not include the
|
| 113 |
+
original UI source or source maps. The preserved UI therefore remains a compiled
|
| 114 |
+
asset. The replacement worker and integration code are editable ES modules.
|
| 115 |
+
|
| 116 |
+
## Validate and build
|
| 117 |
+
|
| 118 |
+
```sh
|
| 119 |
+
npm test
|
| 120 |
+
npm run build
|
| 121 |
+
```
|
| 122 |
+
|
| 123 |
+
`dist/` contains the static app. The project root also works directly in a
|
| 124 |
+
Hugging Face static Space using the metadata above; no server or build service is
|
| 125 |
+
required. Model weights are not included in this project or its ZIP.
|
| 126 |
+
|
| 127 |
+
Tests cover integration behavior without loading model weights. Actual WebGPU
|
| 128 |
+
inference and visual browser checks were unavailable in the implementation
|
| 129 |
+
environment; those remain to be run on a WebGPU device. No performance or model
|
| 130 |
+
quality claim is made from the mocked tests.
|
assets/index-DnXFGQ8z.js
CHANGED
|
@@ -149,7 +149,7 @@ Enter: send · Shift+Enter: newline
|
|
| 149 |
Esc or Ctrl+C: stop · Ctrl+O: expand tools
|
| 150 |
Ctrl+Shift+F: search transcript
|
| 151 |
|
| 152 |
-
Pi’s original terminal UI and
|
| 153 |
Node CLI extensions, npm, Python and native binaries are unavailable.`);else if(n===`/load`)await t.load();else if(n===`/new`||n===`/clear`)await t.newChat(),E.length=0,re();else if(n.startsWith(`/`))throw Error(`Unknown browser command. Type /help for available commands.`);else if(n.startsWith(`!`)){let e=n.slice(1).trim();if(!e)throw Error(`Enter a shell command after !.`);let r=await t.shell(e);te(`$ ${e}\n${r.stdout}${r.stderr}\n[exit ${r.exitCode}]`,r.exitCode!==0)}else await t.prompt(e)}catch(t){t.name!==`AbortError`&&te(t.message,!0),c.getText()||c.setText(e)}finally{v=!1,j()}}}c.onSubmit=e=>{M(e)},c.onChange=()=>{t.onInput?.(c.getText()),o.requestRender()},c.onEscape=()=>{m.busy&&t.stop().catch(e=>te(e.message,!0))},c.onCtrlD=()=>te(`This terminal lives in your browser. Close the tab to leave.`),c.onAction(`app.tools.expand`,()=>{_=!_,re()}),c.setAutocompleteProvider({getSuggestions(e,t,n){let r=e[t].slice(0,n),i=r.match(/\S*$/)?.[0]??``;if(!i)return null;let a=(r.startsWith(`/`)?Vg:h.map(e=>({value:e,label:e,description:`workspace file`}))).filter(e=>e.value.startsWith(i)&&e.value!==i);return a.length?{items:a,prefix:i}:null},applyCompletion(e,t,n,r,i){let a=[...e];return a[t]=e[t].slice(0,n-i.length)+r.value+` `+e[t].slice(n),{lines:a,cursorLine:t,cursorCol:n-i.length+r.value.length+1}}}),n.attachCustomKeyEventHandler(e=>e.type!==`keydown`||e.isComposing?!0:(e.ctrlKey||e.metaKey)&&e.key.toLowerCase()===`v`?!1:e.ctrlKey&&e.shiftKey&&e.code===`KeyF`?(e.preventDefault(),a.inputData(`\x1B[102;6u`),!1):e.key===`Enter`&&e.shiftKey?(e.preventDefault(),a.inputData(`\x1B\r`),!1):(e.ctrlKey||e.metaKey)&&e.key.toLowerCase()===`c`&&(n.hasSelection()||o.hasActiveSelection())?(o.hasActiveSelection()&&(e.preventDefault(),o.copyActiveSelectionToClipboard()),!1):!0);let N;e.addEventListener(`touchstart`,e=>{e.touches.length===1&&(N=e.touches[0].clientY)},{passive:!0}),e.addEventListener(`touchmove`,e=>{if(N===void 0||e.touches.length!==1)return;let t=e.touches[0].clientY,n=Math.trunc((N-t)/18);n&&(o.scrollBy(n),N=t,e.preventDefault())},{passive:!1}),e.addEventListener(`touchend`,()=>{N=void 0},{passive:!0});function P(){x.reset(),e.clientWidth>0&&e.clientHeight>0&&(i.fit(),o.requestRender())}let ie=new ResizeObserver(P);return ie.observe(e),P(),o.start(),re(),j(),document.fonts.ready.then(P),n.textarea.setAttribute(`aria-label`,`Message Pi`),n.textarea.setAttribute(`autocapitalize`,`off`),n.textarea.setAttribute(`spellcheck`,`false`),{setMessages(e,{reset:t=!1}={}){b=!0,g=e,t&&(E.length=0,T.clear()),re()},setFiles(e){h=Object.keys(e).map(e=>e.replace(/^\/workspace\//,``)),C()},setState(e){m={...m,...e},j()},setQueuedPrompt(e){(e||y)&&(y=e?new yg(Bg(e)):void 0,re(),o.scrollToBottom())},submit(){let e=c.getExpandedText();if(!c.disableSubmit&&e.trim())return c.setText(``),M(e)},focus(){n.focus()},newline(){a.inputData(`\x1B\r`),n.focus()},get text(){return c.getExpandedText()},get cols(){return n.cols},get rows(){return n.rows},get viewportTop(){return o.viewportTop},readScreen(){return Array.from({length:n.buffer.active.length},(e,t)=>n.buffer.active.getLine(t)?.translateToString(!0)??``).join(`
|
| 154 |
`)},readDocument(){return u.render(n.cols).map(e=>nd(e).trimEnd())},readCells(){return Array.from({length:n.rows},(e,t)=>Array.from({length:n.cols},(e,r)=>{let i=n.buffer.active.getLine(t)?.getCell(r);return i?{text:i.getChars(),width:i.getWidth(),fg:i.isFgDefault()?pg.foreground:i.getFgColor(),bg:i.isBgDefault()?pg.background:i.getBgColor()}:null}))},dispose(){ie.disconnect(),w.dispose(),S.dispose(),x.dispose(),o.stop({preserveScreen:!0}),n.dispose()}}}function Ug(e,{onClose:t}){let n=t=>e.querySelector(t),r=n(`#model-confirm`),i=n(`#model-cancel`),a=n(`#model-error`),o=n(`.setup-cat img`),s=o.parentElement,c=n(`.setup-cat-tail`),l=matchMedia(`(prefers-reduced-motion: reduce)`),u;function d(){u?.cancel(),u=void 0}s.onpointerdown=e=>e.preventDefault(),s.onmousedown=e=>e.preventDefault(),s.onclick=()=>{if(Fg(),!e.open||document.hidden||l.matches||!s.classList.contains(`is-ready`))return;let t=getComputedStyle(c).transform;d();let n=c.getAnimations().find(e=>e.animationName===`setup-tail-swish`);n&&(n.currentTime=0),u=c.animate([{transform:t},{transform:`rotate(-7deg)`,offset:.3},{transform:`rotate(4deg)`,offset:.6},{transform:`rotate(-1deg)`,offset:.82},{transform:`rotate(0deg)`}],{id:`cat-tail-reaction`,duration:1200,easing:`ease-in-out`}),u.onfinish=()=>{u=void 0}},e.addEventListener(`close`,d),o.decode().then(()=>{o.parentElement.style.setProperty(`--cat-sprite`,`url("${o.currentSrc||o.src}")`),o.parentElement.classList.add(`is-ready`)}).catch(()=>{});let f=()=>{e.style.setProperty(`--cat-play-state`,document.hidden?`paused`:`running`),(document.hidden||l.matches)&&d()};document.addEventListener(`visibilitychange`,f),l.addEventListener(`change`,f),f();let p;function m(n){if(!p)return;d();let r=p;p=void 0,e.close(),t(),r(n)}return i.onclick=()=>m(!1),e.addEventListener(`cancel`,e=>{e.preventDefault(),m(!1)}),r.onclick=()=>m(!0),{request(t,{errorMessage:i}={}){if(p)throw Error(`Finish the current model setup before sending another message.`);r.textContent=i?`Try again`:`Load model & send`,n(`#model-title`).textContent=i?`Load model`:`Download model weights`,n(`#model-preview`).textContent=t,a.textContent=i??``,a.hidden=!i;let o=document.querySelector(`#open-tab`);n(`#model-open-tab`).hidden=!i||o.hidden,n(`#model-open-tab`).href=o.href;let s=new Promise(e=>{p=e});return e.showModal(),n(`.setup-body`).scrollTop=0,r.focus(),s}}}var Wg=c(o(((e,t)=>{var n=function(e){var t=/(?:^|\s)lang(?:uage)?-([\w-]+)(?=\s|$)/i,n=0,r={},i={manual:e.Prism&&e.Prism.manual,disableWorkerMessageHandler:e.Prism&&e.Prism.disableWorkerMessageHandler,util:{encode:function e(t){return t instanceof a?new a(t.type,e(t.content),t.alias):Array.isArray(t)?t.map(e):t.replace(/&/g,`&`).replace(/</g,`<`).replace(/\u00a0/g,` `)},type:function(e){return Object.prototype.toString.call(e).slice(8,-1)},objId:function(e){return e.__id||Object.defineProperty(e,"__id",{value:++n}),e.__id},clone:function e(t,n){n||={};var r,a;switch(i.util.type(t)){case`Object`:if(a=i.util.objId(t),n[a])return n[a];for(var o in r={},n[a]=r,t)t.hasOwnProperty(o)&&(r[o]=e(t[o],n));return r;case`Array`:return a=i.util.objId(t),n[a]?n[a]:(r=[],n[a]=r,t.forEach(function(t,i){r[i]=e(t,n)}),r);default:return t}},getLanguage:function(e){for(;e;){var n=t.exec(e.className);if(n)return n[1].toLowerCase();e=e.parentElement}return`none`},setLanguage:function(e,n){e.className=e.className.replace(RegExp(t,`gi`),``),e.classList.add(`language-`+n)},currentScript:function(){if(typeof document>`u`)return null;if(document.currentScript&&document.currentScript.tagName===`SCRIPT`)return document.currentScript;try{throw Error()}catch(r){var e=(/at [^(\r\n]*\((.*):[^:]+:[^:]+\)$/i.exec(r.stack)||[])[1];if(e){var t=document.getElementsByTagName(`script`);for(var n in t)if(t[n].src==e)return t[n]}return null}},isActive:function(e,t,n){for(var r=`no-`+t;e;){var i=e.classList;if(i.contains(t))return!0;if(i.contains(r))return!1;e=e.parentElement}return!!n}},languages:{plain:r,plaintext:r,text:r,txt:r,extend:function(e,t){var n=i.util.clone(i.languages[e]);for(var r in t)n[r]=t[r];return n},insertBefore:function(e,t,n,r){r||=i.languages;var a=r[e],o={};for(var s in a)if(a.hasOwnProperty(s)){if(s==t)for(var c in n)n.hasOwnProperty(c)&&(o[c]=n[c]);n.hasOwnProperty(s)||(o[s]=a[s])}var l=r[e];return r[e]=o,i.languages.DFS(i.languages,function(t,n){n===l&&t!=e&&(this[t]=o)}),o},DFS:function e(t,n,r,a){a||={};var o=i.util.objId;for(var s in t)if(t.hasOwnProperty(s)){n.call(t,s,t[s],r||s);var c=t[s],l=i.util.type(c);l===`Object`&&!a[o(c)]?(a[o(c)]=!0,e(c,n,null,a)):l===`Array`&&!a[o(c)]&&(a[o(c)]=!0,e(c,n,s,a))}}},plugins:{},highlightAll:function(e,t){i.highlightAllUnder(document,e,t)},highlightAllUnder:function(e,t,n){var r={callback:n,container:e,selector:`code[class*="language-"], [class*="language-"] code, code[class*="lang-"], [class*="lang-"] code`};i.hooks.run(`before-highlightall`,r),r.elements=Array.prototype.slice.apply(r.container.querySelectorAll(r.selector)),i.hooks.run(`before-all-elements-highlight`,r);for(var a=0,o;o=r.elements[a++];)i.highlightElement(o,t===!0,r.callback)},highlightElement:function(t,n,r){var a=i.util.getLanguage(t),o=i.languages[a];i.util.setLanguage(t,a);var s=t.parentElement;s&&s.nodeName.toLowerCase()===`pre`&&i.util.setLanguage(s,a);var c={element:t,language:a,grammar:o,code:t.textContent};function l(e){c.highlightedCode=e,i.hooks.run(`before-insert`,c),c.element.innerHTML=c.highlightedCode,i.hooks.run(`after-highlight`,c),i.hooks.run(`complete`,c),r&&r.call(c.element)}if(i.hooks.run(`before-sanity-check`,c),s=c.element.parentElement,s&&s.nodeName.toLowerCase()===`pre`&&!s.hasAttribute(`tabindex`)&&s.setAttribute(`tabindex`,`0`),!c.code){i.hooks.run(`complete`,c),r&&r.call(c.element);return}if(i.hooks.run(`before-highlight`,c),!c.grammar){l(i.util.encode(c.code));return}if(n&&e.Worker){var u=new Worker(i.filename);u.onmessage=function(e){l(e.data)},u.postMessage(JSON.stringify({language:c.language,code:c.code,immediateClose:!0}))}else l(i.highlight(c.code,c.grammar,c.language))},highlight:function(e,t,n){var r={code:e,grammar:t,language:n};if(i.hooks.run(`before-tokenize`,r),!r.grammar)throw Error(`The language "`+r.language+`" has no grammar.`);return r.tokens=i.tokenize(r.code,r.grammar),i.hooks.run(`after-tokenize`,r),a.stringify(i.util.encode(r.tokens),r.language)},tokenize:function(e,t){var n=t.rest;if(n){for(var r in n)t[r]=n[r];delete t.rest}var i=new c;return l(i,i.head,e),s(e,i,t,i.head,0),d(i)},hooks:{all:{},add:function(e,t){var n=i.hooks.all;n[e]=n[e]||[],n[e].push(t)},run:function(e,t){var n=i.hooks.all[e];if(n&&n.length)for(var r=0,a;a=n[r++];)a(t)}},Token:a};e.Prism=i;function a(e,t,n,r){this.type=e,this.content=t,this.alias=n,this.length=(r||``).length|0}a.stringify=function e(t,n){if(typeof t==`string`)return t;if(Array.isArray(t)){var r=``;return t.forEach(function(t){r+=e(t,n)}),r}var a={type:t.type,content:e(t.content,n),tag:`span`,classes:[`token`,t.type],attributes:{},language:n},o=t.alias;o&&(Array.isArray(o)?Array.prototype.push.apply(a.classes,o):a.classes.push(o)),i.hooks.run(`wrap`,a);var s=``;for(var c in a.attributes)s+=` `+c+`="`+(a.attributes[c]||``).replace(/"/g,`"`)+`"`;return`<`+a.tag+` class="`+a.classes.join(` `)+`"`+s+`>`+a.content+`</`+a.tag+`>`};function o(e,t,n,r){e.lastIndex=t;var i=e.exec(n);if(i&&r&&i[1]){var a=i[1].length;i.index+=a,i[0]=i[0].slice(a)}return i}function s(e,t,n,r,c,d){for(var f in n)if(n.hasOwnProperty(f)&&n[f]){var p=n[f];p=Array.isArray(p)?p:[p];for(var m=0;m<p.length;++m){if(d&&d.cause==f+`,`+m)return;var h=p[m],g=h.inside,_=!!h.lookbehind,v=!!h.greedy,y=h.alias;if(v&&!h.pattern.global){var b=h.pattern.toString().match(/[imsuy]*$/)[0];h.pattern=RegExp(h.pattern.source,b+`g`)}for(var x=h.pattern||h,S=r.next,C=c;S!==t.tail&&!(d&&C>=d.reach);C+=S.value.length,S=S.next){var w=S.value;if(t.length>e.length)return;if(!(w instanceof a)){var T=1,E;if(v){if(E=o(x,C,e,_),!E||E.index>=e.length)break;var D=E.index,O=E.index+E[0].length,k=C;for(k+=S.value.length;D>=k;)S=S.next,k+=S.value.length;if(k-=S.value.length,C=k,S.value instanceof a)continue;for(var ee=S;ee!==t.tail&&(k<O||typeof ee.value==`string`);ee=ee.next)T++,k+=ee.value.length;T--,w=e.slice(C,k),E.index-=C}else if(E=o(x,0,w,_),!E)continue;var D=E.index,te=E[0],ne=w.slice(0,D),A=w.slice(D+te.length),re=C+w.length;d&&re>d.reach&&(d.reach=re);var j=S.prev;ne&&(j=l(t,j,ne),C+=ne.length),u(t,j,T);var M=new a(f,g?i.tokenize(te,g):te,y,te);if(S=l(t,j,M),A&&l(t,S,A),T>1){var N={cause:f+`,`+m,reach:re};s(e,t,n,S.prev,C,N),d&&N.reach>d.reach&&(d.reach=N.reach)}}}}}}function c(){var e={value:null,prev:null,next:null},t={value:null,prev:e,next:null};e.next=t,this.head=e,this.tail=t,this.length=0}function l(e,t,n){var r=t.next,i={value:n,prev:t,next:r};return t.next=i,r.prev=i,e.length++,i}function u(e,t,n){for(var r=t.next,i=0;i<n&&r!==e.tail;i++)r=r.next;t.next=r,r.prev=t,e.length-=i}function d(e){for(var t=[],n=e.head.next;n!==e.tail;)t.push(n.value),n=n.next;return t}if(!e.document)return e.addEventListener&&(i.disableWorkerMessageHandler||e.addEventListener(`message`,function(t){var n=JSON.parse(t.data),r=n.language,a=n.code,o=n.immediateClose;e.postMessage(i.highlight(a,i.languages[r],r)),o&&e.close()},!1)),i;var f=i.util.currentScript();f&&(i.filename=f.src,f.hasAttribute(`data-manual`)&&(i.manual=!0));function p(){i.manual||i.highlightAll()}if(!i.manual){var m=document.readyState;m===`loading`||m===`interactive`&&f&&f.defer?document.addEventListener(`DOMContentLoaded`,p):window.requestAnimationFrame?window.requestAnimationFrame(p):window.setTimeout(p,16)}return i}(typeof window<`u`?window:typeof WorkerGlobalScope<`u`&&self instanceof WorkerGlobalScope?self:{});t!==void 0&&t.exports&&(t.exports=n),typeof global<`u`&&(global.Prism=n)}))(),1);(function(e){var t=`\\b(?:BASH|BASHOPTS|BASH_ALIASES|BASH_ARGC|BASH_ARGV|BASH_CMDS|BASH_COMPLETION_COMPAT_DIR|BASH_LINENO|BASH_REMATCH|BASH_SOURCE|BASH_VERSINFO|BASH_VERSION|COLORTERM|COLUMNS|COMP_WORDBREAKS|DBUS_SESSION_BUS_ADDRESS|DEFAULTS_PATH|DESKTOP_SESSION|DIRSTACK|DISPLAY|EUID|GDMSESSION|GDM_LANG|GNOME_KEYRING_CONTROL|GNOME_KEYRING_PID|GPG_AGENT_INFO|GROUPS|HISTCONTROL|HISTFILE|HISTFILESIZE|HISTSIZE|HOME|HOSTNAME|HOSTTYPE|IFS|INSTANCE|JOB|LANG|LANGUAGE|LC_ADDRESS|LC_ALL|LC_IDENTIFICATION|LC_MEASUREMENT|LC_MONETARY|LC_NAME|LC_NUMERIC|LC_PAPER|LC_TELEPHONE|LC_TIME|LESSCLOSE|LESSOPEN|LINES|LOGNAME|LS_COLORS|MACHTYPE|MAILCHECK|MANDATORY_PATH|NO_AT_BRIDGE|OLDPWD|OPTERR|OPTIND|ORBIT_SOCKETDIR|OSTYPE|PAPERSIZE|PATH|PIPESTATUS|PPID|PS1|PS2|PS3|PS4|PWD|RANDOM|REPLY|SECONDS|SELINUX_INIT|SESSION|SESSIONTYPE|SESSION_MANAGER|SHELL|SHELLOPTS|SHLVL|SSH_AUTH_SOCK|TERM|UID|UPSTART_EVENTS|UPSTART_INSTANCE|UPSTART_JOB|UPSTART_SESSION|USER|WINDOWID|XAUTHORITY|XDG_CONFIG_DIRS|XDG_CURRENT_DESKTOP|XDG_DATA_DIRS|XDG_GREETER_DATA_DIR|XDG_MENU_PREFIX|XDG_RUNTIME_DIR|XDG_SEAT|XDG_SEAT_PATH|XDG_SESSION_DESKTOP|XDG_SESSION_ID|XDG_SESSION_PATH|XDG_SESSION_TYPE|XDG_VTNR|XMODIFIERS)\\b`,n={pattern:/(^(["']?)\w+\2)[ \t]+\S.*/,lookbehind:!0,alias:`punctuation`,inside:null},r={bash:n,environment:{pattern:RegExp(`\\$`+t),alias:`constant`},variable:[{pattern:/\$?\(\([\s\S]+?\)\)/,greedy:!0,inside:{variable:[{pattern:/(^\$\(\([\s\S]+)\)\)/,lookbehind:!0},/^\$\(\(/],number:/\b0x[\dA-Fa-f]+\b|(?:\b\d+(?:\.\d*)?|\B\.\d+)(?:[Ee]-?\d+)?/,operator:/--|\+\+|\*\*=?|<<=?|>>=?|&&|\|\||[=!+\-*/%<>^&|]=?|[?~:]/,punctuation:/\(\(?|\)\)?|,|;/}},{pattern:/\$\((?:\([^)]+\)|[^()])+\)|`[^`]+`/,greedy:!0,inside:{variable:/^\$\(|^`|\)$|`$/}},{pattern:/\$\{[^}]+\}/,greedy:!0,inside:{operator:/:[-=?+]?|[!\/]|##?|%%?|\^\^?|,,?/,punctuation:/[\[\]]/,environment:{pattern:RegExp(`(\\{)`+t),lookbehind:!0,alias:`constant`}}},/\$(?:\w+|[#?*!@$])/],entity:/\\(?:[abceEfnrtv\\"]|O?[0-7]{1,3}|U[0-9a-fA-F]{8}|u[0-9a-fA-F]{4}|x[0-9a-fA-F]{1,2})/};e.languages.bash={shebang:{pattern:/^#!\s*\/.*/,alias:`important`},comment:{pattern:/(^|[^"{\\$])#.*/,lookbehind:!0},"function-name":[{pattern:/(\bfunction\s+)[\w-]+(?=(?:\s*\(?:\s*\))?\s*\{)/,lookbehind:!0,alias:`function`},{pattern:/\b[\w-]+(?=\s*\(\s*\)\s*\{)/,alias:`function`}],"for-or-select":{pattern:/(\b(?:for|select)\s+)\w+(?=\s+in\s)/,alias:`variable`,lookbehind:!0},"assign-left":{pattern:/(^|[\s;|&]|[<>]\()\w+(?:\.\w+)*(?=\+?=)/,inside:{environment:{pattern:RegExp(`(^|[\\s;|&]|[<>]\\()`+t),lookbehind:!0,alias:`constant`}},alias:`variable`,lookbehind:!0},parameter:{pattern:/(^|\s)-{1,2}(?:\w+:[+-]?)?\w+(?:\.\w+)*(?=[=\s]|$)/,alias:`variable`,lookbehind:!0},string:[{pattern:/((?:^|[^<])<<-?\s*)(\w+)\s[\s\S]*?(?:\r?\n|\r)\2/,lookbehind:!0,greedy:!0,inside:r},{pattern:/((?:^|[^<])<<-?\s*)(["'])(\w+)\2\s[\s\S]*?(?:\r?\n|\r)\3/,lookbehind:!0,greedy:!0,inside:{bash:n}},{pattern:/(^|[^\\](?:\\\\)*)"(?:\\[\s\S]|\$\([^)]+\)|\$(?!\()|`[^`]+`|[^"\\`$])*"/,lookbehind:!0,greedy:!0,inside:r},{pattern:/(^|[^$\\])'[^']*'/,lookbehind:!0,greedy:!0},{pattern:/\$'(?:[^'\\]|\\[\s\S])*'/,greedy:!0,inside:{entity:r.entity}}],environment:{pattern:RegExp(`\\$?`+t),alias:`constant`},variable:r.variable,function:{pattern:/(^|[\s;|&]|[<>]\()(?:add|apropos|apt|apt-cache|apt-get|aptitude|aspell|automysqlbackup|awk|basename|bash|bc|bconsole|bg|bzip2|cal|cargo|cat|cfdisk|chgrp|chkconfig|chmod|chown|chroot|cksum|clear|cmp|column|comm|composer|cp|cron|crontab|csplit|curl|cut|date|dc|dd|ddrescue|debootstrap|df|diff|diff3|dig|dir|dircolors|dirname|dirs|dmesg|docker|docker-compose|du|egrep|eject|env|ethtool|expand|expect|expr|fdformat|fdisk|fg|fgrep|file|find|fmt|fold|format|free|fsck|ftp|fuser|gawk|git|gparted|grep|groupadd|groupdel|groupmod|groups|grub-mkconfig|gzip|halt|head|hg|history|host|hostname|htop|iconv|id|ifconfig|ifdown|ifup|import|install|ip|java|jobs|join|kill|killall|less|link|ln|locate|logname|logrotate|look|lpc|lpr|lprint|lprintd|lprintq|lprm|ls|lsof|lynx|make|man|mc|mdadm|mkconfig|mkdir|mke2fs|mkfifo|mkfs|mkisofs|mknod|mkswap|mmv|more|most|mount|mtools|mtr|mutt|mv|nano|nc|netstat|nice|nl|node|nohup|notify-send|npm|nslookup|op|open|parted|passwd|paste|pathchk|ping|pkill|pnpm|podman|podman-compose|popd|pr|printcap|printenv|ps|pushd|pv|quota|quotacheck|quotactl|ram|rar|rcp|reboot|remsync|rename|renice|rev|rm|rmdir|rpm|rsync|scp|screen|sdiff|sed|sendmail|seq|service|sftp|sh|shellcheck|shuf|shutdown|sleep|slocate|sort|split|ssh|stat|strace|su|sudo|sum|suspend|swapon|sync|sysctl|tac|tail|tar|tee|time|timeout|top|touch|tr|traceroute|tsort|tty|umount|uname|unexpand|uniq|units|unrar|unshar|unzip|update-grub|uptime|useradd|userdel|usermod|users|uudecode|uuencode|v|vcpkg|vdir|vi|vim|virsh|vmstat|wait|watch|wc|wget|whereis|which|who|whoami|write|xargs|xdg-open|yarn|yes|zenity|zip|zsh|zypper)(?=$|[)\s;|&])/,lookbehind:!0},keyword:{pattern:/(^|[\s;|&]|[<>]\()(?:case|do|done|elif|else|esac|fi|for|function|if|in|select|then|until|while)(?=$|[)\s;|&])/,lookbehind:!0},builtin:{pattern:/(^|[\s;|&]|[<>]\()(?:\.|:|alias|bind|break|builtin|caller|cd|command|continue|declare|echo|enable|eval|exec|exit|export|getopts|hash|help|let|local|logout|mapfile|printf|pwd|read|readarray|readonly|return|set|shift|shopt|source|test|times|trap|type|typeset|ulimit|umask|unalias|unset)(?=$|[)\s;|&])/,lookbehind:!0,alias:`class-name`},boolean:{pattern:/(^|[\s;|&]|[<>]\()(?:false|true)(?=$|[)\s;|&])/,lookbehind:!0},"file-descriptor":{pattern:/\B&\d\b/,alias:`important`},operator:{pattern:/\d?<>|>\||\+=|=[=~]?|!=?|<<[<-]?|[&\d]?>>|\d[<>]&?|[<>][&=]?|&[>&]?|\|[&|]?/,inside:{"file-descriptor":{pattern:/^\d/,alias:`important`}}},punctuation:/\$?\(\(?|\)\)?|\.\.|[{}[\];\\]/,number:{pattern:/(^|\s)(?:[1-9]\d*|0)(?:[.,]\d+)?\b/,lookbehind:!0}},n.inside=e.languages.bash;for(var i=[`comment`,`function-name`,`for-or-select`,`assign-left`,`parameter`,`string`,`environment`,`function`,`keyword`,`builtin`,`boolean`,`file-descriptor`,`operator`,`punctuation`,`number`],a=r.variable[1].inside,o=0;o<i.length;o++)a[i[o]]=e.languages.bash[i[o]];e.languages.sh=e.languages.bash,e.languages.shell=e.languages.bash})(Prism),Wg.default.manual=!0;var Gg=e=>nd(String(e??``)).replace(/\r\n?/g,`
|
| 155 |
`).replace(/[\x00-\x08\x0b-\x1f\x7f-\x9f]/g,``),Kg=24e3;function qg(e){return e=Gg(e),e.length>Kg?e.slice(0,Kg)+`
|
|
@@ -157,4 +157,4 @@ Node CLI extensions, npm, Python and native binaries are unavailable.`);else if(
|
|
| 157 |
`)),r.style.height=`auto`,r.style.height=Math.min(104,Math.max(32,r.scrollHeight))+`px`,i.scrollTop=r.scrollTop,i.scrollLeft=r.scrollLeft,v()}function b(e){r.value=e,y(),r.setSelectionRange(e.length,e.length)}function x(){m||(d.length=0,a.replaceChildren(l.cloneNode(!0)),v())}function S(e,t,n){if(!t)return;let r=document.createElement(`pre`);r.className=n,r.textContent=qg(t),e.append(r)}o.onsubmit=async e=>{e.preventDefault();let n=r.value;if(m||h||!n.trim())return;m=!0,u.at(-1)!==n&&u.push(n),u.length>50&&u.shift(),f=u.length,p=``,b(``),r.focus(),d.length||a.replaceChildren();let i=document.createElement(`section`);i.className=`shell-entry`;let o=document.createElement(`div`);o.className=`shell-entry-heading`;let s=document.createElement(`span`);s.className=`shell-prompt`,s.textContent=`$`;let c=document.createElement(`pre`);c.className=`shell-code`,c.append(Jg(qg(n)));let l=document.createElement(`span`);l.className=`shell-status`,l.textContent=`Running…`,o.append(s,c,l),i.append(o),a.append(i),d.push(i),_();try{let e=await t(n),r=g();S(i,e.stdout,`shell-stdout`),S(i,e.stderr,`shell-stderr`),l.classList.add(e.exitCode===0?`success`:`error`),l.textContent=`[exit ${e.exitCode}]`,r&&_()}catch(e){let t=g();S(i,e.message,`shell-stderr`),l.classList.add(`error`),l.textContent=`[error]`,r.value||b(n),t&&_()}finally{for(;d.length>1&&(d.length>40||a.textContent.length>48e3);)d.shift().remove();m=!1,v()}},r.addEventListener(`input`,()=>{f=u.length,p=r.value,y()}),r.addEventListener(`scroll`,()=>{i.scrollTop=r.scrollTop,i.scrollLeft=r.scrollLeft}),r.addEventListener(`keydown`,e=>{if(e.isComposing||e.keyCode===229)return;if(e.key===`Enter`&&!e.shiftKey){e.preventDefault(),o.requestSubmit();return}if(e.ctrlKey&&e.key.toLowerCase()===`l`){e.preventDefault(),x();return}if(e.ctrlKey||e.altKey||e.metaKey||e.shiftKey||r.selectionStart!==r.selectionEnd)return;let t=!r.value.slice(0,r.selectionStart).includes(`
|
| 158 |
`),n=!r.value.slice(r.selectionEnd).includes(`
|
| 159 |
`);e.key===`ArrowUp`&&t&&f>0?(e.preventDefault(),f===u.length&&(p=r.value),b(u[--f])):e.key===`ArrowDown`&&n&&f<u.length&&(e.preventDefault(),b(++f===u.length?p:u[f]))}),c.onclick=()=>{x(),r.focus()};let C=0;return new ResizeObserver(([e])=>{e.contentRect.width!==C&&(C=e.contentRect.width,y())}).observe(r.parentElement),y(),{setBusy(e){h=e,v()}}}function Xg(e){let t=e.querySelector(`.runtime-value`),n=e.querySelector(`.runtime-sparkline`),r=e.querySelector(`.runtime-bars`),i=e.querySelector(`[role="status"]`),a=e.querySelector(`.runtime-engine`),o=e.querySelector(`.runtime-content`);new ResizeObserver(([t])=>{e.style.width=`calc(${Math.ceil(t.contentRect.width)}px + 2 * var(--runtime-padding))`}).observe(o);let s=!1,c,l=[],u;function d(o,s,d){clearTimeout(u),o!==c&&(i.textContent=d??`${s} in this browser.`),c=o,a.textContent=c===`tool`?`JavaScript`:`WebGPU`,e.dataset.state=c,t.textContent=s,n.setAttribute(`hidden`,``),e.title=d??`${s} in this browser.`,c!==`generating`&&(l=[],r.replaceChildren())}function f(){d(`idle`,s?`Ready`:`Idle`,s?`Model ready. Inference and tools run in this browser.`:`Model loads when you send a message. Inference and tools run in this browser.`)}function p(e){if(!Number.isFinite(e)||e<0){d(`generating`,`Generating…`);return}d(`generating`,`${e<10?e.toFixed(1):Math.round(e)} tok/s`,`Live token generation speed, including thinking tokens. Prompt-processing time is excluded.`),l.push(e),l.length>12&&l.shift();let t=Math.max(40,...l),i=[...Array(12-l.length).fill(null),...l];r.replaceChildren(...i.map((e,n)=>{if(e===null||e===0){let t=document.createElementNS(`http://www.w3.org/2000/svg`,`circle`);return t.setAttribute(`cx`,String(n*4+1)),t.setAttribute(`cy`,`17`),t.setAttribute(`r`,`1`),t.setAttribute(`opacity`,e===null?`.35`:`.6`),t}let r=document.createElementNS(`http://www.w3.org/2000/svg`,`rect`),i=Math.max(2,Math.min(16,e/t*16));return r.setAttribute(`x`,String(n*4)),r.setAttribute(`y`,(18-i).toFixed(2)),r.setAttribute(`width`,`2`),r.setAttribute(`height`,i.toFixed(2)),r.setAttribute(`rx`,`1`),r})),n.removeAttribute(`hidden`),u=setTimeout(()=>{d(`generating`,`Generating…`),l=[],r.replaceChildren()},2e3)}return f(),{update(e){e.type===`loaded`?(s=!0,f()):e.type===`idle`||e.type===`initialized`?f():e.type===`fatal`?(s=!1,d(`error`,`Unavailable`)):e.type===`busy`?e.action===`load`?d(`loading`,`Loading…`,e.cachedOnly?`Loading local model…`:void 0):e.action===`prompt`?d(`prefill`,`Reading prompt…`):e.action===`shell`?d(`tool`,`Running bash…`):e.action===`read`?d(`tool`,`Reading file…`):e.action===`write`?d(`tool`,`Writing file…`):d(`working`,`Working…`):e.type===`load_progress`?d(`loading`,e.phase===`download`&&e.loaded<e.total?`Downloading ${Math.round(e.loaded/e.total*100)}%`:e.phase===`warmup`?`Warming up…`:`Preparing…`):e.type===`inference_activity`?e.phase===`prefill`?d(`prefill`,`Reading prompt…`):e.phase===`decode`?p(e.rate):(c===`generating`||c===`prefill`)&&d(`working`,`Working…`):e.type===`agent_event`&&e.event.type===`tool_execution_start`&&d(`tool`,{bash:`Running bash…`,read:`Reading file…`,write:`Writing file…`,edit:`Editing file…`}[e.event.toolName]??`Running tool…`)}}}var Zg={default:`#b0b0bd`,markdown:`#519aba`,csv:`#8dc149`,json:`#cbcb41`,javascript:`#cbcb41`,typescript:`#519aba`,react:`#519aba`,html:`#e37933`,css:`#519aba`,python:`#519aba`,shell:`#8dc149`,config:`#b0b0bd`,image:`#a074c4`,svg:`#a074c4`,git:`#e37933`,pdf:`#cc3e44`,zip:`#b0b0bd`,db:`#cbcb41`},Qg={md:`markdown`,markdown:`markdown`,mdx:`markdown`,csv:`csv`,tsv:`csv`,json:`json`,jsonc:`json`,jsonl:`json`,js:`javascript`,mjs:`javascript`,cjs:`javascript`,ts:`typescript`,mts:`typescript`,cts:`typescript`,jsx:`react`,tsx:`react`,html:`html`,htm:`html`,xml:`html`,css:`css`,scss:`css`,sass:`css`,less:`css`,py:`python`,pyi:`python`,sh:`shell`,bash:`shell`,zsh:`shell`,fish:`shell`,yaml:`config`,yml:`config`,toml:`config`,ini:`config`,conf:`config`,env:`config`,png:`image`,jpg:`image`,jpeg:`image`,gif:`image`,webp:`image`,avif:`image`,ico:`image`,svg:`svg`,pdf:`pdf`,zip:`zip`,gz:`zip`,tar:`zip`,sql:`db`,sqlite:`db`,db:`db`};function $g(e){let t=e.split(`/`).at(-1).toLowerCase();return[`.gitignore`,`.gitattributes`,`.gitmodules`].includes(t)?`git`:t===`.env`||t.startsWith(`.env.`)||t===`.editorconfig`?`config`:Qg[t.split(`.`).at(-1)]??`default`}function e_(e){let t=$g(e),n=document.createElement(`span`);return n.className=`file-icon`,n.dataset.kind=t,n.setAttribute(`aria-hidden`,`true`),n.style.setProperty(`--file-color`,Zg[t]),n.style.setProperty(`--file-icon`,`url("${new URL(`file-icons/${t}.svg`,document.baseURI).href}")`),n}var t_={html:`markup`,htm:`markup`,xml:`markup`,svg:`markup`,vue:`markup`,css:`css`,js:`javascript`,mjs:`javascript`,cjs:`javascript`,jsx:`jsx`,ts:`typescript`,mts:`typescript`,cts:`typescript`,tsx:`tsx`,json:`json`,ipynb:`json`,md:`markdown`,markdown:`markdown`,py:`python`,pyw:`python`,sh:`bash`,bash:`bash`,zsh:`bash`,yml:`yaml`,yaml:`yaml`,toml:`toml`,sql:`sql`,csv:`csv`,c:`c`,h:`c`,cpp:`cpp`,cc:`cpp`,cxx:`cpp`,hpp:`cpp`,cs:`csharp`,java:`java`,go:`go`,rs:`rust`,diff:`diff`,patch:`diff`};function n_(e=``){let t=e.split(`/`).at(-1).toLowerCase();return/^dockerfile(?:\.|$)/.test(t)?`docker`:/^\.(?:bashrc|bash_profile|zshrc|profile)$/.test(t)?`bash`:t_[t.split(`.`).at(-1)]??null}function r_(e){if(e.length>2e5)return!1;let t=1;for(let n=0;n<e.length;n++)if(e.charCodeAt(n)===10&&++t>5e3)return!1;return!0}function i_(e){let t=e.querySelector(`#editor`),n=e.querySelector(`#editor-highlight`),r=``,i=0,a,o,s,c=!1,l=!1,u;function d(){n.style.width=t.clientWidth+`px`,n.style.height=t.clientHeight+`px`,n.scrollTop=t.scrollTop,n.scrollLeft=t.scrollLeft}function f(){e.classList.remove(`is-highlighted`)}function p(){clearTimeout(o),s?.terminate(),s=void 0,c=!1}function m(){if(c||!u)return;let t=u;u=void 0;try{s||(s=new Worker(new URL(new URL(`editor-highlight.worker-oPpFVFqT.js`,import.meta.url).href,``+import.meta.url),{type:`module`}),s.onmessage=({data:t})=>{clearTimeout(o),c=!1,t.id===i&&t.html!==null&&(n.innerHTML=t.html+`
|
| 160 |
-
`,d(),e.classList.add(`is-highlighted`)),m()},s.onerror=()=>{p(),u=void 0}),c=!0,s.postMessage(t),o=setTimeout(()=>{p(),m()},1500)}catch{p()}}function h(n=0){i++,clearTimeout(a),u=void 0,f();let o=n_(r),s=t.value;if(e.dataset.language=o??`plain`,l||!s||!o||!r_(s))return;let c=i;a=setTimeout(()=>{u={id:c,text:s,language:o},m()},n)}t.addEventListener(`input`,()=>h(80)),t.addEventListener(`compositionstart`,()=>{l=!0,h()}),t.addEventListener(`compositionend`,()=>{l=!1,h(80)}),t.addEventListener(`scroll`,d,{passive:!0});let g=new ResizeObserver(d);return g.observe(t),{setFile(e,n){if(r===e&&t.value===n)return;let i=r!==e;r=e,t.value!==n&&(t.value=n),i&&(t.scrollTop=0,t.scrollLeft=0),h()},dispose(){clearTimeout(a),p(),g.disconnect()}}}var a_=`pi-column-ratio`,o_=1/1.83;function s_(e,t){let n=matchMedia(`(min-width: 851px)`),r=getComputedStyle(e),i=parseFloat(r.getPropertyValue(`--conversation-min`)),a=parseFloat(r.getPropertyValue(`--workspace-min`)),o=o_,s,c=0;try{let e=Number(localStorage.getItem(a_));Number.isFinite(e)&&e>0&&e<1&&(o=e)}catch{}function l(){if(!n.matches)return;let r=e.getBoundingClientRect().width;if(!r)return;let s=Math.max(i,Math.min(r-a,r*o));e.style.setProperty(`--conversation-width`,`${s}px`);let c=Math.round(s/r*100);t.setAttribute(`aria-valuemin`,String(Math.round(i/r*100))),t.setAttribute(`aria-valuemax`,String(Math.round((r-a)/r*100))),t.setAttribute(`aria-valuenow`,String(c)),t.setAttribute(`aria-valuetext`,`Chat ${c}%, workspace ${100-c}%`)}function u(){try{localStorage.setItem(a_,String(o))}catch{}}function d(t){let n=e.getBoundingClientRect().width;o=Math.max(i,Math.min(n-a,t))/n,l()}function f(){cancelAnimationFrame(c),c=0,s&&d(s.x-e.getBoundingClientRect().left-s.offset)}function p(e){if(!s)return;e?f():(cancelAnimationFrame(c),c=0,o=s.initialRatio);let n=s.pointer;s=void 0,t.hasPointerCapture(n)&&t.releasePointerCapture(n),document.documentElement.classList.remove(`is-resizing-columns`),l(),e&&u()}function m(){p(!1),o=o_,l(),u()}t.addEventListener(`pointerdown`,r=>{if(!n.matches||r.button!==0||!r.isPrimary||s)return;r.preventDefault(),t.focus({preventScroll:!0});let i=e.querySelector(`.conversation`).getBoundingClientRect().right;s={pointer:r.pointerId,initialRatio:o,x:r.clientX,offset:r.clientX-i},t.setPointerCapture(r.pointerId),document.documentElement.classList.add(`is-resizing-columns`)}),t.addEventListener(`pointermove`,e=>{s&&e.pointerId===s.pointer&&(s.x=e.clientX,c||=requestAnimationFrame(f))}),t.addEventListener(`pointerup`,e=>{s&&e.pointerId===s.pointer&&(s.x=e.clientX,p(!0))}),t.addEventListener(`pointercancel`,()=>p(!1)),t.addEventListener(`lostpointercapture`,()=>p(!1)),t.addEventListener(`dblclick`,m),t.addEventListener(`keydown`,t=>{if(!n.matches)return;if(t.key===`Escape`&&s){t.preventDefault(),p(!1);return}if(![`ArrowLeft`,`ArrowRight`,`Home`,`End`,`Enter`].includes(t.key))return;if(t.preventDefault(),t.key===`Enter`){m();return}p(!1);let r=e.querySelector(`.conversation`).getBoundingClientRect().width,i=t.shiftKey?48:16;d(t.key===`Home`?0:t.key===`End`?1/0:r+(t.key===`ArrowLeft`?-i:i)),u()}),window.addEventListener(`blur`,()=>p(!1)),n.addEventListener(`change`,()=>{p(!1),l()}),new ResizeObserver(l).observe(e),l()}var $=e=>document.querySelector(e);s_($(`main`),$(`#column-resizer`));var c_=new Worker(new URL(new URL(`agent.worker-CS8jL-AS.js`,import.meta.url).href,``+import.meta.url),{type:`module`}),l_=new Map,u_=1,d_=!1,f_=!1,p_,m_=!1,h_={},g_=[],__,v_,y_=new Promise(e=>{__=e}),b_={events:[],generations:[],runtimeSamples:[],device:null,contextUsage:Mg([])},x_=Xg($(`#runtime-meter`)),S_=i_($(`.editor-input`)),C_=Hg($(`#pi-terminal`),{onInput:e=>$(`#send`).classList.toggle(`has-input`,!!e.trim()),load:()=>y_.then(I_),prompt:e=>y_.then(()=>L_(e)),newChat:()=>y_.then(R_),stop:()=>E_(`stop`),shell:e=>{if(m_)throw Error(`Save your file edits before running commands.`);return E_(`shell`,{command:e})}}),w_=Ug($(`#model-dialog`),{onClose:()=>C_.focus()}),T_=Yg($(`.workspace > .terminal`),{run:async e=>{if(await y_,m_)throw Error(`Save your file edits before running commands.`);if(d_)throw Error(`Wait for Pi to finish or stop the current task first.`);return E_(`shell`,{command:e})}});function E_(e,t){return new Promise((n,r)=>{let i=u_++;l_.set(i,{resolve:n,reject:r}),c_.postMessage({id:i,action:e,args:t})})}function D_(e){$(`#notice`).textContent=e,$(`#notice`).hidden=!e}function O_(e){return window.self===window.top||!/denied|storage directory access/i.test(e)?e:($(`#open-tab`).href=location.href,$(`#open-tab`).hidden=!1,`Your browser blocked storage in this embedded app. Open the workspace in a new tab to load the model and save files.`)}function k_(){$(`#load`).disabled=d_||f_,$(`#load`).classList.toggle(`is-loaded`,f_),f_?$(`#load`).textContent=`Loaded`:d_||($(`#load`).textContent=`Load model`),$(`#load`).title=$(`#load-status`).textContent+` `+$(`#load-detail`).textContent,$(`#send`).disabled=d_,$(`#newline`).disabled=d_,C_.setState({busy:d_,loaded:f_,activity:$(`#activity`).textContent}),$(`#stop`).hidden=!d_;for(let e of[`#new-chat`,`#add-file`,`#upload`])$(e).disabled=d_;T_.setBusy(d_),$(`#save`).disabled=d_||!p_||!m_,$(`#editor`).disabled=d_||!p_}function A_(){C_.setFiles(h_),$(`#files`).replaceChildren();for(let e of Object.keys(h_).sort()){let t=document.createElement(`button`),n=document.createElement(`span`);n.className=`file-name`,n.textContent=e.replace(/^\/workspace\//,``),t.append(e_(e),n),t.title=e,t.classList.toggle(`selected`,e===p_),t.onclick=()=>j_(e),$(`#files`).append(t)}p_&&!m_&&S_.setFile(p_,h_[p_]??``)}function j_(e){if(v_=void 0,m_){D_(`Save the open file before selecting another file.`);return}p_=e,$(`#filename`).textContent=e,$(`#filetype`).replaceChildren(e_(e)),A_(),k_()}function M_(){if(m_){v_=void 0;return}if(!v_||!Object.hasOwn(h_,v_))return;let e=v_,t=e!==p_;j_(e),t&&($(`#editor`).scrollTop=0,$(`#editor`).scrollLeft=0);let n=[...$(`#files`).children].find(t=>t.title===e);if(n){let e=n.getBoundingClientRect(),t=$(`#files`).getBoundingClientRect();e.top<t.top?$(`#files`).scrollTop+=e.top-t.top:e.bottom>t.bottom&&($(`#files`).scrollTop+=e.bottom-t.bottom)}}function N_(){C_.setMessages(g_)}function P_(e){b_.contextUsage=e,C_.setState({contextUsage:e})}c_.onmessage=({data:e})=>{if(x_.update(e),e.type===`inference_activity`&&(b_.runtimeSamples.push(e),b_.runtimeSamples.length>160&&b_.runtimeSamples.shift()),e.type===`reply`){let t=l_.get(e.id);l_.delete(e.id),e.error?t?.reject(Object.assign(Error(e.error),{name:e.errorName||`Error`,code:e.errorCode})):t?.resolve(e.value)}else if(e.type===`initialized`)h_=e.files,g_=e.messages,A_(),N_(),P_(Mg(g_)),h_[`/workspace/README.md`]&&j_(`/workspace/README.md`),document.activeElement===document.body&&C_.focus(),__();else if(e.type===`busy`)e.action===`load`&&($(`#load`).textContent=`Loading…`),d_=!0,$(`#activity`).textContent=e.action===`prompt`?`Pi is working…`:e.action===`load`?e.cachedOnly?`Loading local model…`:`Loading model…`:`Working…`,k_();else if(e.type===`idle`)d_=!1,$(`#progress`).hidden=!0,$(`#activity`).textContent=f_?`Ready · running on your device`:`Files and chat are saved on this device.`,k_();else if(e.type===`workspace`)h_=e.files,A_(),M_();else if(e.type===`device`)b_.device=e.device;else if(e.type===`load_progress`)$(`#progress`).hidden=!1,e.phase===`download`?($(`#progress`).hidden=!1,$(`#progress`).value=e.loaded/e.total*100,$(`#load-status`).textContent=e.loaded===e.total?`Download verified. Preparing model…`:`Downloading model weights from Hugging Face…`,$(`#load-detail`).textContent=`${(e.loaded/1e9).toFixed(2)} / ${(e.total/1e9).toFixed(2)} GB · ${Math.round(e.loaded/e.total*100)}%${e.cached?` · ${(e.cached/1e9).toFixed(2)} GB cached`:``}`,$(`#load`).textContent=`Loading ${Math.round(e.loaded/e.total*100)}%`):($(`#load-status`).textContent=e.phase===`compile`?`Preparing WebGPU session…`:`Warming up GPU shaders…`,$(`#load-detail`).textContent=``,$(`#progress`).removeAttribute(`value`),$(`#load`).textContent=e.phase===`compile`?`Preparing…`:`Almost ready…`),$(`#load`).title=$(`#load-status`).textContent+` `+$(`#load-detail`).textContent;else if(e.type===`loaded`)f_=!0,b_.device=e.device,$(`#progress`).hidden=!0,$(`#load`).textContent=`Loaded`,$(`#load-status`).textContent=`Ready. Model inference and tools run entirely in this browser.`,$(`#load-detail`).textContent=e.cachedBytes?`Verified model restored from browser storage.`:`SHA-256 verified · model cached for next time.`,k_();else if(e.type===`agent_event`){let t=e.event;if(b_.events.push(t.type),b_.events.length>500&&b_.events.shift(),t.message){let e=g_.findIndex(e=>e.timestamp===t.message.timestamp&&e.role===t.message.role&&e.toolCallId===t.message.toolCallId);e<0?g_.push(t.message):g_[e]=t.message,N_()}t.type===`tool_execution_start`&&($(`#activity`).textContent=`Running ${t.toolName}…`),t.type===`tool_execution_end`&&t.toolName===`read`&&!t.isError&&typeof t.result?.details?.path==`string`&&(v_=t.result.details.path,M_()),t.type===`turn_start`&&($(`#activity`).textContent=`Pi is thinking…`),C_.setState({activity:$(`#activity`).textContent})}else e.type===`context_usage`?P_({inputTokens:e.inputTokens,outputTokens:e.outputTokens}):e.type===`generation`?(b_.generations.push(e),b_.generations.length>100&&b_.generations.shift()):e.type===`context_trim`?D_(`Older turns were left out of the model context. Your full chat remains saved.`):e.type===`notice`?D_(e.text):e.type===`persistence_error`?D_(O_(e.error)):e.type===`fatal`&&(f_=!1,D_(e.error),k_())},c_.onerror=e=>{x_.update({type:`fatal`});let t=Error(e.message||`The worker stopped. Reload to restore your saved workspace.`);D_(t.message);for(let e of l_.values())e.reject(t);l_.clear(),d_=!1,f_=!1,$(`#progress`).hidden=!0,k_()};var F_=e=>async t=>{t?.preventDefault();try{await y_,await e(t)}catch(e){D_(e.message)}};async function I_({cachedOnly:e=!1}={}){if(d_)throw Error(`Wait for the current operation or stop it first.`);D_(``);let t=new URLSearchParams(location.search);d_=!0,$(`#load`).textContent=`Loading…`,$(`#activity`).textContent=e?`Loading local model…`:`Loading model…`,$(`#load-status`).textContent=e?`Loading local model…`:`Checking your browser and saved model…`,$(`#load-detail`).textContent=``,$(`#progress`).hidden=!1,$(`#progress`).removeAttribute(`value`),x_.update({type:`busy`,action:`load`,cachedOnly:e}),k_();try{return await E_(`load`,{runtimeURL:new URL(`./runtime/`,location.href).href,local:t.get(`source`)===`local`,origin:location.origin,limit128:t.has(`limit128`),cachedOnly:e})}catch(e){throw e.message=O_(e.message),$(`#load-status`).textContent=e.message,$(`#progress`).hidden=!0,e}}async function L_(e){if(e.trim()){if(m_)throw Error(`Save your file edits before asking Pi to work on them.`);try{if(!f_){let t;if(await E_(`cache_status`))try{C_.setQueuedPrompt(e),await I_({cachedOnly:!0})}catch(e){if(e.name===`AbortError`)throw e;e.code!==`MODEL_CACHE_MISS`&&(t=e.message)}for(;!f_;){if(C_.setQueuedPrompt(``),!await w_.request(e,{errorMessage:t}))throw new DOMException(`Message kept for later.`,`AbortError`);C_.setQueuedPrompt(e);try{await I_()}catch(e){if(e.name===`AbortError`)throw e;t=e.message}}}C_.setQueuedPrompt(``),D_(``);let t=await E_(`prompt`,{text:e});return g_=t.messages,N_(),t}finally{C_.setQueuedPrompt(``)}}}$(`#load`).onclick=F_(I_),$(`#send`).onclick=()=>{C_.submit(),C_.focus()},$(`#newline`).onclick=()=>C_.newline(),$(`#stop`).onclick=F_(()=>E_(`stop`)),$(`.terminal-panel`).addEventListener(`click`,e=>{!e.defaultPrevented&&e.button===0&&e.target instanceof Element&&(e.target.closest(`#pi-terminal, button, a, input, textarea, select, label, summary, [role="button"], [contenteditable]`)||window.getSelection()?.isCollapsed!==!1&&C_.focus())});async function R_(){v_=void 0,g_=(await E_(`new_chat`)).messages,P_(Mg(g_)),C_.setMessages(g_,{reset:!0}),D_(``),C_.focus()}$(`#new-chat`).onclick=F_(R_),$(`#editor`).oninput=()=>{m_=!0,k_()},$(`#save`).onclick=F_(async()=>{await E_(`write`,{path:p_,content:$(`#editor`).value}),m_=!1,A_(),k_(),D_(``)}),$(`#add-file`).onclick=F_(async()=>{if(m_)throw Error(`Save the open file first.`);let e=window.prompt(`File path, relative to /workspace:`);if(!e?.trim())return;let t=e.startsWith(`/`)?e:`/workspace/`+e;if(Object.hasOwn(h_,t))throw Error(`That file already exists.`);await E_(`write`,{path:t,content:``}),j_(t)}),$(`#upload`).onchange=F_(async()=>{if(m_)throw Error(`Save the open file first.`);for(let e of $(`#upload`).files){if(e.size>1024**2)throw Error(`Import text files of 1 MiB or less.`);let t=await e.text();if(t.includes(`\0`))throw Error(`Import supports text files only.`);let n=`/workspace/`+e.name;if(Object.hasOwn(h_,n))throw Error(e.name+` already exists. Rename it before importing.`);await E_(`write`,{path:n,content:t}),j_(n)}$(`#upload`).value=``}),$(`#export`).onclick=F_(async()=>{let e=await E_(`snapshot`),t=URL.createObjectURL(new Blob([JSON.stringify(e,null,2)],{type:`application/json`})),n=document.createElement(`a`);n.href=t,n.download=`pi-workspace.json`,n.click(),setTimeout(()=>URL.revokeObjectURL(t),1e3)}),window.browserPi={ready:y_,load:I_,prompt:L_,rpc:E_,diagnostics:b_,terminal:C_,runtimeMeter:x_,get loaded(){return f_},get busy(){return d_}},k_();
|
|
|
|
| 149 |
Esc or Ctrl+C: stop · Ctrl+O: expand tools
|
| 150 |
Ctrl+Shift+F: search transcript
|
| 151 |
|
| 152 |
+
Pi’s original terminal UI and the local WebGPU agent run here.
|
| 153 |
Node CLI extensions, npm, Python and native binaries are unavailable.`);else if(n===`/load`)await t.load();else if(n===`/new`||n===`/clear`)await t.newChat(),E.length=0,re();else if(n.startsWith(`/`))throw Error(`Unknown browser command. Type /help for available commands.`);else if(n.startsWith(`!`)){let e=n.slice(1).trim();if(!e)throw Error(`Enter a shell command after !.`);let r=await t.shell(e);te(`$ ${e}\n${r.stdout}${r.stderr}\n[exit ${r.exitCode}]`,r.exitCode!==0)}else await t.prompt(e)}catch(t){t.name!==`AbortError`&&te(t.message,!0),c.getText()||c.setText(e)}finally{v=!1,j()}}}c.onSubmit=e=>{M(e)},c.onChange=()=>{t.onInput?.(c.getText()),o.requestRender()},c.onEscape=()=>{m.busy&&t.stop().catch(e=>te(e.message,!0))},c.onCtrlD=()=>te(`This terminal lives in your browser. Close the tab to leave.`),c.onAction(`app.tools.expand`,()=>{_=!_,re()}),c.setAutocompleteProvider({getSuggestions(e,t,n){let r=e[t].slice(0,n),i=r.match(/\S*$/)?.[0]??``;if(!i)return null;let a=(r.startsWith(`/`)?Vg:h.map(e=>({value:e,label:e,description:`workspace file`}))).filter(e=>e.value.startsWith(i)&&e.value!==i);return a.length?{items:a,prefix:i}:null},applyCompletion(e,t,n,r,i){let a=[...e];return a[t]=e[t].slice(0,n-i.length)+r.value+` `+e[t].slice(n),{lines:a,cursorLine:t,cursorCol:n-i.length+r.value.length+1}}}),n.attachCustomKeyEventHandler(e=>e.type!==`keydown`||e.isComposing?!0:(e.ctrlKey||e.metaKey)&&e.key.toLowerCase()===`v`?!1:e.ctrlKey&&e.shiftKey&&e.code===`KeyF`?(e.preventDefault(),a.inputData(`\x1B[102;6u`),!1):e.key===`Enter`&&e.shiftKey?(e.preventDefault(),a.inputData(`\x1B\r`),!1):(e.ctrlKey||e.metaKey)&&e.key.toLowerCase()===`c`&&(n.hasSelection()||o.hasActiveSelection())?(o.hasActiveSelection()&&(e.preventDefault(),o.copyActiveSelectionToClipboard()),!1):!0);let N;e.addEventListener(`touchstart`,e=>{e.touches.length===1&&(N=e.touches[0].clientY)},{passive:!0}),e.addEventListener(`touchmove`,e=>{if(N===void 0||e.touches.length!==1)return;let t=e.touches[0].clientY,n=Math.trunc((N-t)/18);n&&(o.scrollBy(n),N=t,e.preventDefault())},{passive:!1}),e.addEventListener(`touchend`,()=>{N=void 0},{passive:!0});function P(){x.reset(),e.clientWidth>0&&e.clientHeight>0&&(i.fit(),o.requestRender())}let ie=new ResizeObserver(P);return ie.observe(e),P(),o.start(),re(),j(),document.fonts.ready.then(P),n.textarea.setAttribute(`aria-label`,`Message Pi`),n.textarea.setAttribute(`autocapitalize`,`off`),n.textarea.setAttribute(`spellcheck`,`false`),{setMessages(e,{reset:t=!1}={}){b=!0,g=e,t&&(E.length=0,T.clear()),re()},setFiles(e){h=Object.keys(e).map(e=>e.replace(/^\/workspace\//,``)),C()},setState(e){m={...m,...e},j()},setQueuedPrompt(e){(e||y)&&(y=e?new yg(Bg(e)):void 0,re(),o.scrollToBottom())},submit(){let e=c.getExpandedText();if(!c.disableSubmit&&e.trim())return c.setText(``),M(e)},focus(){n.focus()},newline(){a.inputData(`\x1B\r`),n.focus()},get text(){return c.getExpandedText()},get cols(){return n.cols},get rows(){return n.rows},get viewportTop(){return o.viewportTop},readScreen(){return Array.from({length:n.buffer.active.length},(e,t)=>n.buffer.active.getLine(t)?.translateToString(!0)??``).join(`
|
| 154 |
`)},readDocument(){return u.render(n.cols).map(e=>nd(e).trimEnd())},readCells(){return Array.from({length:n.rows},(e,t)=>Array.from({length:n.cols},(e,r)=>{let i=n.buffer.active.getLine(t)?.getCell(r);return i?{text:i.getChars(),width:i.getWidth(),fg:i.isFgDefault()?pg.foreground:i.getFgColor(),bg:i.isBgDefault()?pg.background:i.getBgColor()}:null}))},dispose(){ie.disconnect(),w.dispose(),S.dispose(),x.dispose(),o.stop({preserveScreen:!0}),n.dispose()}}}function Ug(e,{onClose:t}){let n=t=>e.querySelector(t),r=n(`#model-confirm`),i=n(`#model-cancel`),a=n(`#model-error`),o=n(`.setup-cat img`),s=o.parentElement,c=n(`.setup-cat-tail`),l=matchMedia(`(prefers-reduced-motion: reduce)`),u;function d(){u?.cancel(),u=void 0}s.onpointerdown=e=>e.preventDefault(),s.onmousedown=e=>e.preventDefault(),s.onclick=()=>{if(Fg(),!e.open||document.hidden||l.matches||!s.classList.contains(`is-ready`))return;let t=getComputedStyle(c).transform;d();let n=c.getAnimations().find(e=>e.animationName===`setup-tail-swish`);n&&(n.currentTime=0),u=c.animate([{transform:t},{transform:`rotate(-7deg)`,offset:.3},{transform:`rotate(4deg)`,offset:.6},{transform:`rotate(-1deg)`,offset:.82},{transform:`rotate(0deg)`}],{id:`cat-tail-reaction`,duration:1200,easing:`ease-in-out`}),u.onfinish=()=>{u=void 0}},e.addEventListener(`close`,d),o.decode().then(()=>{o.parentElement.style.setProperty(`--cat-sprite`,`url("${o.currentSrc||o.src}")`),o.parentElement.classList.add(`is-ready`)}).catch(()=>{});let f=()=>{e.style.setProperty(`--cat-play-state`,document.hidden?`paused`:`running`),(document.hidden||l.matches)&&d()};document.addEventListener(`visibilitychange`,f),l.addEventListener(`change`,f),f();let p;function m(n){if(!p)return;d();let r=p;p=void 0,e.close(),t(),r(n)}return i.onclick=()=>m(!1),e.addEventListener(`cancel`,e=>{e.preventDefault(),m(!1)}),r.onclick=()=>m(!0),{request(t,{errorMessage:i}={}){if(p)throw Error(`Finish the current model setup before sending another message.`);r.textContent=i?`Try again`:`Load model & send`,n(`#model-title`).textContent=i?`Load model`:`Download model weights`,n(`#model-preview`).textContent=t,a.textContent=i??``,a.hidden=!i;let o=document.querySelector(`#open-tab`);n(`#model-open-tab`).hidden=!i||o.hidden,n(`#model-open-tab`).href=o.href;let s=new Promise(e=>{p=e});return e.showModal(),n(`.setup-body`).scrollTop=0,r.focus(),s}}}var Wg=c(o(((e,t)=>{var n=function(e){var t=/(?:^|\s)lang(?:uage)?-([\w-]+)(?=\s|$)/i,n=0,r={},i={manual:e.Prism&&e.Prism.manual,disableWorkerMessageHandler:e.Prism&&e.Prism.disableWorkerMessageHandler,util:{encode:function e(t){return t instanceof a?new a(t.type,e(t.content),t.alias):Array.isArray(t)?t.map(e):t.replace(/&/g,`&`).replace(/</g,`<`).replace(/\u00a0/g,` `)},type:function(e){return Object.prototype.toString.call(e).slice(8,-1)},objId:function(e){return e.__id||Object.defineProperty(e,"__id",{value:++n}),e.__id},clone:function e(t,n){n||={};var r,a;switch(i.util.type(t)){case`Object`:if(a=i.util.objId(t),n[a])return n[a];for(var o in r={},n[a]=r,t)t.hasOwnProperty(o)&&(r[o]=e(t[o],n));return r;case`Array`:return a=i.util.objId(t),n[a]?n[a]:(r=[],n[a]=r,t.forEach(function(t,i){r[i]=e(t,n)}),r);default:return t}},getLanguage:function(e){for(;e;){var n=t.exec(e.className);if(n)return n[1].toLowerCase();e=e.parentElement}return`none`},setLanguage:function(e,n){e.className=e.className.replace(RegExp(t,`gi`),``),e.classList.add(`language-`+n)},currentScript:function(){if(typeof document>`u`)return null;if(document.currentScript&&document.currentScript.tagName===`SCRIPT`)return document.currentScript;try{throw Error()}catch(r){var e=(/at [^(\r\n]*\((.*):[^:]+:[^:]+\)$/i.exec(r.stack)||[])[1];if(e){var t=document.getElementsByTagName(`script`);for(var n in t)if(t[n].src==e)return t[n]}return null}},isActive:function(e,t,n){for(var r=`no-`+t;e;){var i=e.classList;if(i.contains(t))return!0;if(i.contains(r))return!1;e=e.parentElement}return!!n}},languages:{plain:r,plaintext:r,text:r,txt:r,extend:function(e,t){var n=i.util.clone(i.languages[e]);for(var r in t)n[r]=t[r];return n},insertBefore:function(e,t,n,r){r||=i.languages;var a=r[e],o={};for(var s in a)if(a.hasOwnProperty(s)){if(s==t)for(var c in n)n.hasOwnProperty(c)&&(o[c]=n[c]);n.hasOwnProperty(s)||(o[s]=a[s])}var l=r[e];return r[e]=o,i.languages.DFS(i.languages,function(t,n){n===l&&t!=e&&(this[t]=o)}),o},DFS:function e(t,n,r,a){a||={};var o=i.util.objId;for(var s in t)if(t.hasOwnProperty(s)){n.call(t,s,t[s],r||s);var c=t[s],l=i.util.type(c);l===`Object`&&!a[o(c)]?(a[o(c)]=!0,e(c,n,null,a)):l===`Array`&&!a[o(c)]&&(a[o(c)]=!0,e(c,n,s,a))}}},plugins:{},highlightAll:function(e,t){i.highlightAllUnder(document,e,t)},highlightAllUnder:function(e,t,n){var r={callback:n,container:e,selector:`code[class*="language-"], [class*="language-"] code, code[class*="lang-"], [class*="lang-"] code`};i.hooks.run(`before-highlightall`,r),r.elements=Array.prototype.slice.apply(r.container.querySelectorAll(r.selector)),i.hooks.run(`before-all-elements-highlight`,r);for(var a=0,o;o=r.elements[a++];)i.highlightElement(o,t===!0,r.callback)},highlightElement:function(t,n,r){var a=i.util.getLanguage(t),o=i.languages[a];i.util.setLanguage(t,a);var s=t.parentElement;s&&s.nodeName.toLowerCase()===`pre`&&i.util.setLanguage(s,a);var c={element:t,language:a,grammar:o,code:t.textContent};function l(e){c.highlightedCode=e,i.hooks.run(`before-insert`,c),c.element.innerHTML=c.highlightedCode,i.hooks.run(`after-highlight`,c),i.hooks.run(`complete`,c),r&&r.call(c.element)}if(i.hooks.run(`before-sanity-check`,c),s=c.element.parentElement,s&&s.nodeName.toLowerCase()===`pre`&&!s.hasAttribute(`tabindex`)&&s.setAttribute(`tabindex`,`0`),!c.code){i.hooks.run(`complete`,c),r&&r.call(c.element);return}if(i.hooks.run(`before-highlight`,c),!c.grammar){l(i.util.encode(c.code));return}if(n&&e.Worker){var u=new Worker(i.filename);u.onmessage=function(e){l(e.data)},u.postMessage(JSON.stringify({language:c.language,code:c.code,immediateClose:!0}))}else l(i.highlight(c.code,c.grammar,c.language))},highlight:function(e,t,n){var r={code:e,grammar:t,language:n};if(i.hooks.run(`before-tokenize`,r),!r.grammar)throw Error(`The language "`+r.language+`" has no grammar.`);return r.tokens=i.tokenize(r.code,r.grammar),i.hooks.run(`after-tokenize`,r),a.stringify(i.util.encode(r.tokens),r.language)},tokenize:function(e,t){var n=t.rest;if(n){for(var r in n)t[r]=n[r];delete t.rest}var i=new c;return l(i,i.head,e),s(e,i,t,i.head,0),d(i)},hooks:{all:{},add:function(e,t){var n=i.hooks.all;n[e]=n[e]||[],n[e].push(t)},run:function(e,t){var n=i.hooks.all[e];if(n&&n.length)for(var r=0,a;a=n[r++];)a(t)}},Token:a};e.Prism=i;function a(e,t,n,r){this.type=e,this.content=t,this.alias=n,this.length=(r||``).length|0}a.stringify=function e(t,n){if(typeof t==`string`)return t;if(Array.isArray(t)){var r=``;return t.forEach(function(t){r+=e(t,n)}),r}var a={type:t.type,content:e(t.content,n),tag:`span`,classes:[`token`,t.type],attributes:{},language:n},o=t.alias;o&&(Array.isArray(o)?Array.prototype.push.apply(a.classes,o):a.classes.push(o)),i.hooks.run(`wrap`,a);var s=``;for(var c in a.attributes)s+=` `+c+`="`+(a.attributes[c]||``).replace(/"/g,`"`)+`"`;return`<`+a.tag+` class="`+a.classes.join(` `)+`"`+s+`>`+a.content+`</`+a.tag+`>`};function o(e,t,n,r){e.lastIndex=t;var i=e.exec(n);if(i&&r&&i[1]){var a=i[1].length;i.index+=a,i[0]=i[0].slice(a)}return i}function s(e,t,n,r,c,d){for(var f in n)if(n.hasOwnProperty(f)&&n[f]){var p=n[f];p=Array.isArray(p)?p:[p];for(var m=0;m<p.length;++m){if(d&&d.cause==f+`,`+m)return;var h=p[m],g=h.inside,_=!!h.lookbehind,v=!!h.greedy,y=h.alias;if(v&&!h.pattern.global){var b=h.pattern.toString().match(/[imsuy]*$/)[0];h.pattern=RegExp(h.pattern.source,b+`g`)}for(var x=h.pattern||h,S=r.next,C=c;S!==t.tail&&!(d&&C>=d.reach);C+=S.value.length,S=S.next){var w=S.value;if(t.length>e.length)return;if(!(w instanceof a)){var T=1,E;if(v){if(E=o(x,C,e,_),!E||E.index>=e.length)break;var D=E.index,O=E.index+E[0].length,k=C;for(k+=S.value.length;D>=k;)S=S.next,k+=S.value.length;if(k-=S.value.length,C=k,S.value instanceof a)continue;for(var ee=S;ee!==t.tail&&(k<O||typeof ee.value==`string`);ee=ee.next)T++,k+=ee.value.length;T--,w=e.slice(C,k),E.index-=C}else if(E=o(x,0,w,_),!E)continue;var D=E.index,te=E[0],ne=w.slice(0,D),A=w.slice(D+te.length),re=C+w.length;d&&re>d.reach&&(d.reach=re);var j=S.prev;ne&&(j=l(t,j,ne),C+=ne.length),u(t,j,T);var M=new a(f,g?i.tokenize(te,g):te,y,te);if(S=l(t,j,M),A&&l(t,S,A),T>1){var N={cause:f+`,`+m,reach:re};s(e,t,n,S.prev,C,N),d&&N.reach>d.reach&&(d.reach=N.reach)}}}}}}function c(){var e={value:null,prev:null,next:null},t={value:null,prev:e,next:null};e.next=t,this.head=e,this.tail=t,this.length=0}function l(e,t,n){var r=t.next,i={value:n,prev:t,next:r};return t.next=i,r.prev=i,e.length++,i}function u(e,t,n){for(var r=t.next,i=0;i<n&&r!==e.tail;i++)r=r.next;t.next=r,r.prev=t,e.length-=i}function d(e){for(var t=[],n=e.head.next;n!==e.tail;)t.push(n.value),n=n.next;return t}if(!e.document)return e.addEventListener&&(i.disableWorkerMessageHandler||e.addEventListener(`message`,function(t){var n=JSON.parse(t.data),r=n.language,a=n.code,o=n.immediateClose;e.postMessage(i.highlight(a,i.languages[r],r)),o&&e.close()},!1)),i;var f=i.util.currentScript();f&&(i.filename=f.src,f.hasAttribute(`data-manual`)&&(i.manual=!0));function p(){i.manual||i.highlightAll()}if(!i.manual){var m=document.readyState;m===`loading`||m===`interactive`&&f&&f.defer?document.addEventListener(`DOMContentLoaded`,p):window.requestAnimationFrame?window.requestAnimationFrame(p):window.setTimeout(p,16)}return i}(typeof window<`u`?window:typeof WorkerGlobalScope<`u`&&self instanceof WorkerGlobalScope?self:{});t!==void 0&&t.exports&&(t.exports=n),typeof global<`u`&&(global.Prism=n)}))(),1);(function(e){var t=`\\b(?:BASH|BASHOPTS|BASH_ALIASES|BASH_ARGC|BASH_ARGV|BASH_CMDS|BASH_COMPLETION_COMPAT_DIR|BASH_LINENO|BASH_REMATCH|BASH_SOURCE|BASH_VERSINFO|BASH_VERSION|COLORTERM|COLUMNS|COMP_WORDBREAKS|DBUS_SESSION_BUS_ADDRESS|DEFAULTS_PATH|DESKTOP_SESSION|DIRSTACK|DISPLAY|EUID|GDMSESSION|GDM_LANG|GNOME_KEYRING_CONTROL|GNOME_KEYRING_PID|GPG_AGENT_INFO|GROUPS|HISTCONTROL|HISTFILE|HISTFILESIZE|HISTSIZE|HOME|HOSTNAME|HOSTTYPE|IFS|INSTANCE|JOB|LANG|LANGUAGE|LC_ADDRESS|LC_ALL|LC_IDENTIFICATION|LC_MEASUREMENT|LC_MONETARY|LC_NAME|LC_NUMERIC|LC_PAPER|LC_TELEPHONE|LC_TIME|LESSCLOSE|LESSOPEN|LINES|LOGNAME|LS_COLORS|MACHTYPE|MAILCHECK|MANDATORY_PATH|NO_AT_BRIDGE|OLDPWD|OPTERR|OPTIND|ORBIT_SOCKETDIR|OSTYPE|PAPERSIZE|PATH|PIPESTATUS|PPID|PS1|PS2|PS3|PS4|PWD|RANDOM|REPLY|SECONDS|SELINUX_INIT|SESSION|SESSIONTYPE|SESSION_MANAGER|SHELL|SHELLOPTS|SHLVL|SSH_AUTH_SOCK|TERM|UID|UPSTART_EVENTS|UPSTART_INSTANCE|UPSTART_JOB|UPSTART_SESSION|USER|WINDOWID|XAUTHORITY|XDG_CONFIG_DIRS|XDG_CURRENT_DESKTOP|XDG_DATA_DIRS|XDG_GREETER_DATA_DIR|XDG_MENU_PREFIX|XDG_RUNTIME_DIR|XDG_SEAT|XDG_SEAT_PATH|XDG_SESSION_DESKTOP|XDG_SESSION_ID|XDG_SESSION_PATH|XDG_SESSION_TYPE|XDG_VTNR|XMODIFIERS)\\b`,n={pattern:/(^(["']?)\w+\2)[ \t]+\S.*/,lookbehind:!0,alias:`punctuation`,inside:null},r={bash:n,environment:{pattern:RegExp(`\\$`+t),alias:`constant`},variable:[{pattern:/\$?\(\([\s\S]+?\)\)/,greedy:!0,inside:{variable:[{pattern:/(^\$\(\([\s\S]+)\)\)/,lookbehind:!0},/^\$\(\(/],number:/\b0x[\dA-Fa-f]+\b|(?:\b\d+(?:\.\d*)?|\B\.\d+)(?:[Ee]-?\d+)?/,operator:/--|\+\+|\*\*=?|<<=?|>>=?|&&|\|\||[=!+\-*/%<>^&|]=?|[?~:]/,punctuation:/\(\(?|\)\)?|,|;/}},{pattern:/\$\((?:\([^)]+\)|[^()])+\)|`[^`]+`/,greedy:!0,inside:{variable:/^\$\(|^`|\)$|`$/}},{pattern:/\$\{[^}]+\}/,greedy:!0,inside:{operator:/:[-=?+]?|[!\/]|##?|%%?|\^\^?|,,?/,punctuation:/[\[\]]/,environment:{pattern:RegExp(`(\\{)`+t),lookbehind:!0,alias:`constant`}}},/\$(?:\w+|[#?*!@$])/],entity:/\\(?:[abceEfnrtv\\"]|O?[0-7]{1,3}|U[0-9a-fA-F]{8}|u[0-9a-fA-F]{4}|x[0-9a-fA-F]{1,2})/};e.languages.bash={shebang:{pattern:/^#!\s*\/.*/,alias:`important`},comment:{pattern:/(^|[^"{\\$])#.*/,lookbehind:!0},"function-name":[{pattern:/(\bfunction\s+)[\w-]+(?=(?:\s*\(?:\s*\))?\s*\{)/,lookbehind:!0,alias:`function`},{pattern:/\b[\w-]+(?=\s*\(\s*\)\s*\{)/,alias:`function`}],"for-or-select":{pattern:/(\b(?:for|select)\s+)\w+(?=\s+in\s)/,alias:`variable`,lookbehind:!0},"assign-left":{pattern:/(^|[\s;|&]|[<>]\()\w+(?:\.\w+)*(?=\+?=)/,inside:{environment:{pattern:RegExp(`(^|[\\s;|&]|[<>]\\()`+t),lookbehind:!0,alias:`constant`}},alias:`variable`,lookbehind:!0},parameter:{pattern:/(^|\s)-{1,2}(?:\w+:[+-]?)?\w+(?:\.\w+)*(?=[=\s]|$)/,alias:`variable`,lookbehind:!0},string:[{pattern:/((?:^|[^<])<<-?\s*)(\w+)\s[\s\S]*?(?:\r?\n|\r)\2/,lookbehind:!0,greedy:!0,inside:r},{pattern:/((?:^|[^<])<<-?\s*)(["'])(\w+)\2\s[\s\S]*?(?:\r?\n|\r)\3/,lookbehind:!0,greedy:!0,inside:{bash:n}},{pattern:/(^|[^\\](?:\\\\)*)"(?:\\[\s\S]|\$\([^)]+\)|\$(?!\()|`[^`]+`|[^"\\`$])*"/,lookbehind:!0,greedy:!0,inside:r},{pattern:/(^|[^$\\])'[^']*'/,lookbehind:!0,greedy:!0},{pattern:/\$'(?:[^'\\]|\\[\s\S])*'/,greedy:!0,inside:{entity:r.entity}}],environment:{pattern:RegExp(`\\$?`+t),alias:`constant`},variable:r.variable,function:{pattern:/(^|[\s;|&]|[<>]\()(?:add|apropos|apt|apt-cache|apt-get|aptitude|aspell|automysqlbackup|awk|basename|bash|bc|bconsole|bg|bzip2|cal|cargo|cat|cfdisk|chgrp|chkconfig|chmod|chown|chroot|cksum|clear|cmp|column|comm|composer|cp|cron|crontab|csplit|curl|cut|date|dc|dd|ddrescue|debootstrap|df|diff|diff3|dig|dir|dircolors|dirname|dirs|dmesg|docker|docker-compose|du|egrep|eject|env|ethtool|expand|expect|expr|fdformat|fdisk|fg|fgrep|file|find|fmt|fold|format|free|fsck|ftp|fuser|gawk|git|gparted|grep|groupadd|groupdel|groupmod|groups|grub-mkconfig|gzip|halt|head|hg|history|host|hostname|htop|iconv|id|ifconfig|ifdown|ifup|import|install|ip|java|jobs|join|kill|killall|less|link|ln|locate|logname|logrotate|look|lpc|lpr|lprint|lprintd|lprintq|lprm|ls|lsof|lynx|make|man|mc|mdadm|mkconfig|mkdir|mke2fs|mkfifo|mkfs|mkisofs|mknod|mkswap|mmv|more|most|mount|mtools|mtr|mutt|mv|nano|nc|netstat|nice|nl|node|nohup|notify-send|npm|nslookup|op|open|parted|passwd|paste|pathchk|ping|pkill|pnpm|podman|podman-compose|popd|pr|printcap|printenv|ps|pushd|pv|quota|quotacheck|quotactl|ram|rar|rcp|reboot|remsync|rename|renice|rev|rm|rmdir|rpm|rsync|scp|screen|sdiff|sed|sendmail|seq|service|sftp|sh|shellcheck|shuf|shutdown|sleep|slocate|sort|split|ssh|stat|strace|su|sudo|sum|suspend|swapon|sync|sysctl|tac|tail|tar|tee|time|timeout|top|touch|tr|traceroute|tsort|tty|umount|uname|unexpand|uniq|units|unrar|unshar|unzip|update-grub|uptime|useradd|userdel|usermod|users|uudecode|uuencode|v|vcpkg|vdir|vi|vim|virsh|vmstat|wait|watch|wc|wget|whereis|which|who|whoami|write|xargs|xdg-open|yarn|yes|zenity|zip|zsh|zypper)(?=$|[)\s;|&])/,lookbehind:!0},keyword:{pattern:/(^|[\s;|&]|[<>]\()(?:case|do|done|elif|else|esac|fi|for|function|if|in|select|then|until|while)(?=$|[)\s;|&])/,lookbehind:!0},builtin:{pattern:/(^|[\s;|&]|[<>]\()(?:\.|:|alias|bind|break|builtin|caller|cd|command|continue|declare|echo|enable|eval|exec|exit|export|getopts|hash|help|let|local|logout|mapfile|printf|pwd|read|readarray|readonly|return|set|shift|shopt|source|test|times|trap|type|typeset|ulimit|umask|unalias|unset)(?=$|[)\s;|&])/,lookbehind:!0,alias:`class-name`},boolean:{pattern:/(^|[\s;|&]|[<>]\()(?:false|true)(?=$|[)\s;|&])/,lookbehind:!0},"file-descriptor":{pattern:/\B&\d\b/,alias:`important`},operator:{pattern:/\d?<>|>\||\+=|=[=~]?|!=?|<<[<-]?|[&\d]?>>|\d[<>]&?|[<>][&=]?|&[>&]?|\|[&|]?/,inside:{"file-descriptor":{pattern:/^\d/,alias:`important`}}},punctuation:/\$?\(\(?|\)\)?|\.\.|[{}[\];\\]/,number:{pattern:/(^|\s)(?:[1-9]\d*|0)(?:[.,]\d+)?\b/,lookbehind:!0}},n.inside=e.languages.bash;for(var i=[`comment`,`function-name`,`for-or-select`,`assign-left`,`parameter`,`string`,`environment`,`function`,`keyword`,`builtin`,`boolean`,`file-descriptor`,`operator`,`punctuation`,`number`],a=r.variable[1].inside,o=0;o<i.length;o++)a[i[o]]=e.languages.bash[i[o]];e.languages.sh=e.languages.bash,e.languages.shell=e.languages.bash})(Prism),Wg.default.manual=!0;var Gg=e=>nd(String(e??``)).replace(/\r\n?/g,`
|
| 155 |
`).replace(/[\x00-\x08\x0b-\x1f\x7f-\x9f]/g,``),Kg=24e3;function qg(e){return e=Gg(e),e.length>Kg?e.slice(0,Kg)+`
|
|
|
|
| 157 |
`)),r.style.height=`auto`,r.style.height=Math.min(104,Math.max(32,r.scrollHeight))+`px`,i.scrollTop=r.scrollTop,i.scrollLeft=r.scrollLeft,v()}function b(e){r.value=e,y(),r.setSelectionRange(e.length,e.length)}function x(){m||(d.length=0,a.replaceChildren(l.cloneNode(!0)),v())}function S(e,t,n){if(!t)return;let r=document.createElement(`pre`);r.className=n,r.textContent=qg(t),e.append(r)}o.onsubmit=async e=>{e.preventDefault();let n=r.value;if(m||h||!n.trim())return;m=!0,u.at(-1)!==n&&u.push(n),u.length>50&&u.shift(),f=u.length,p=``,b(``),r.focus(),d.length||a.replaceChildren();let i=document.createElement(`section`);i.className=`shell-entry`;let o=document.createElement(`div`);o.className=`shell-entry-heading`;let s=document.createElement(`span`);s.className=`shell-prompt`,s.textContent=`$`;let c=document.createElement(`pre`);c.className=`shell-code`,c.append(Jg(qg(n)));let l=document.createElement(`span`);l.className=`shell-status`,l.textContent=`Running…`,o.append(s,c,l),i.append(o),a.append(i),d.push(i),_();try{let e=await t(n),r=g();S(i,e.stdout,`shell-stdout`),S(i,e.stderr,`shell-stderr`),l.classList.add(e.exitCode===0?`success`:`error`),l.textContent=`[exit ${e.exitCode}]`,r&&_()}catch(e){let t=g();S(i,e.message,`shell-stderr`),l.classList.add(`error`),l.textContent=`[error]`,r.value||b(n),t&&_()}finally{for(;d.length>1&&(d.length>40||a.textContent.length>48e3);)d.shift().remove();m=!1,v()}},r.addEventListener(`input`,()=>{f=u.length,p=r.value,y()}),r.addEventListener(`scroll`,()=>{i.scrollTop=r.scrollTop,i.scrollLeft=r.scrollLeft}),r.addEventListener(`keydown`,e=>{if(e.isComposing||e.keyCode===229)return;if(e.key===`Enter`&&!e.shiftKey){e.preventDefault(),o.requestSubmit();return}if(e.ctrlKey&&e.key.toLowerCase()===`l`){e.preventDefault(),x();return}if(e.ctrlKey||e.altKey||e.metaKey||e.shiftKey||r.selectionStart!==r.selectionEnd)return;let t=!r.value.slice(0,r.selectionStart).includes(`
|
| 158 |
`),n=!r.value.slice(r.selectionEnd).includes(`
|
| 159 |
`);e.key===`ArrowUp`&&t&&f>0?(e.preventDefault(),f===u.length&&(p=r.value),b(u[--f])):e.key===`ArrowDown`&&n&&f<u.length&&(e.preventDefault(),b(++f===u.length?p:u[f]))}),c.onclick=()=>{x(),r.focus()};let C=0;return new ResizeObserver(([e])=>{e.contentRect.width!==C&&(C=e.contentRect.width,y())}).observe(r.parentElement),y(),{setBusy(e){h=e,v()}}}function Xg(e){let t=e.querySelector(`.runtime-value`),n=e.querySelector(`.runtime-sparkline`),r=e.querySelector(`.runtime-bars`),i=e.querySelector(`[role="status"]`),a=e.querySelector(`.runtime-engine`),o=e.querySelector(`.runtime-content`);new ResizeObserver(([t])=>{e.style.width=`calc(${Math.ceil(t.contentRect.width)}px + 2 * var(--runtime-padding))`}).observe(o);let s=!1,c,l=[],u;function d(o,s,d){clearTimeout(u),o!==c&&(i.textContent=d??`${s} in this browser.`),c=o,a.textContent=c===`tool`?`JavaScript`:`WebGPU`,e.dataset.state=c,t.textContent=s,n.setAttribute(`hidden`,``),e.title=d??`${s} in this browser.`,c!==`generating`&&(l=[],r.replaceChildren())}function f(){d(`idle`,s?`Ready`:`Idle`,s?`Model ready. Inference and tools run in this browser.`:`Model loads when you send a message. Inference and tools run in this browser.`)}function p(e){if(!Number.isFinite(e)||e<0){d(`generating`,`Generating…`);return}d(`generating`,`${e<10?e.toFixed(1):Math.round(e)} tok/s`,`Live token generation speed, including thinking tokens. Prompt-processing time is excluded.`),l.push(e),l.length>12&&l.shift();let t=Math.max(40,...l),i=[...Array(12-l.length).fill(null),...l];r.replaceChildren(...i.map((e,n)=>{if(e===null||e===0){let t=document.createElementNS(`http://www.w3.org/2000/svg`,`circle`);return t.setAttribute(`cx`,String(n*4+1)),t.setAttribute(`cy`,`17`),t.setAttribute(`r`,`1`),t.setAttribute(`opacity`,e===null?`.35`:`.6`),t}let r=document.createElementNS(`http://www.w3.org/2000/svg`,`rect`),i=Math.max(2,Math.min(16,e/t*16));return r.setAttribute(`x`,String(n*4)),r.setAttribute(`y`,(18-i).toFixed(2)),r.setAttribute(`width`,`2`),r.setAttribute(`height`,i.toFixed(2)),r.setAttribute(`rx`,`1`),r})),n.removeAttribute(`hidden`),u=setTimeout(()=>{d(`generating`,`Generating…`),l=[],r.replaceChildren()},2e3)}return f(),{update(e){e.type===`loaded`?(s=!0,f()):e.type===`idle`||e.type===`initialized`?f():e.type===`fatal`?(s=!1,d(`error`,`Unavailable`)):e.type===`busy`?e.action===`load`?d(`loading`,`Loading…`,e.cachedOnly?`Loading local model…`:void 0):e.action===`prompt`?d(`prefill`,`Reading prompt…`):e.action===`shell`?d(`tool`,`Running bash…`):e.action===`read`?d(`tool`,`Reading file…`):e.action===`write`?d(`tool`,`Writing file…`):d(`working`,`Working…`):e.type===`load_progress`?d(`loading`,e.phase===`download`&&e.loaded<e.total?`Downloading ${Math.round(e.loaded/e.total*100)}%`:e.phase===`warmup`?`Warming up…`:`Preparing…`):e.type===`inference_activity`?e.phase===`prefill`?d(`prefill`,`Reading prompt…`):e.phase===`decode`?p(e.rate):(c===`generating`||c===`prefill`)&&d(`working`,`Working…`):e.type===`agent_event`&&e.event.type===`tool_execution_start`&&d(`tool`,{bash:`Running bash…`,read:`Reading file…`,write:`Writing file…`,edit:`Editing file…`}[e.event.toolName]??`Running tool…`)}}}var Zg={default:`#b0b0bd`,markdown:`#519aba`,csv:`#8dc149`,json:`#cbcb41`,javascript:`#cbcb41`,typescript:`#519aba`,react:`#519aba`,html:`#e37933`,css:`#519aba`,python:`#519aba`,shell:`#8dc149`,config:`#b0b0bd`,image:`#a074c4`,svg:`#a074c4`,git:`#e37933`,pdf:`#cc3e44`,zip:`#b0b0bd`,db:`#cbcb41`},Qg={md:`markdown`,markdown:`markdown`,mdx:`markdown`,csv:`csv`,tsv:`csv`,json:`json`,jsonc:`json`,jsonl:`json`,js:`javascript`,mjs:`javascript`,cjs:`javascript`,ts:`typescript`,mts:`typescript`,cts:`typescript`,jsx:`react`,tsx:`react`,html:`html`,htm:`html`,xml:`html`,css:`css`,scss:`css`,sass:`css`,less:`css`,py:`python`,pyi:`python`,sh:`shell`,bash:`shell`,zsh:`shell`,fish:`shell`,yaml:`config`,yml:`config`,toml:`config`,ini:`config`,conf:`config`,env:`config`,png:`image`,jpg:`image`,jpeg:`image`,gif:`image`,webp:`image`,avif:`image`,ico:`image`,svg:`svg`,pdf:`pdf`,zip:`zip`,gz:`zip`,tar:`zip`,sql:`db`,sqlite:`db`,db:`db`};function $g(e){let t=e.split(`/`).at(-1).toLowerCase();return[`.gitignore`,`.gitattributes`,`.gitmodules`].includes(t)?`git`:t===`.env`||t.startsWith(`.env.`)||t===`.editorconfig`?`config`:Qg[t.split(`.`).at(-1)]??`default`}function e_(e){let t=$g(e),n=document.createElement(`span`);return n.className=`file-icon`,n.dataset.kind=t,n.setAttribute(`aria-hidden`,`true`),n.style.setProperty(`--file-color`,Zg[t]),n.style.setProperty(`--file-icon`,`url("${new URL(`file-icons/${t}.svg`,document.baseURI).href}")`),n}var t_={html:`markup`,htm:`markup`,xml:`markup`,svg:`markup`,vue:`markup`,css:`css`,js:`javascript`,mjs:`javascript`,cjs:`javascript`,jsx:`jsx`,ts:`typescript`,mts:`typescript`,cts:`typescript`,tsx:`tsx`,json:`json`,ipynb:`json`,md:`markdown`,markdown:`markdown`,py:`python`,pyw:`python`,sh:`bash`,bash:`bash`,zsh:`bash`,yml:`yaml`,yaml:`yaml`,toml:`toml`,sql:`sql`,csv:`csv`,c:`c`,h:`c`,cpp:`cpp`,cc:`cpp`,cxx:`cpp`,hpp:`cpp`,cs:`csharp`,java:`java`,go:`go`,rs:`rust`,diff:`diff`,patch:`diff`};function n_(e=``){let t=e.split(`/`).at(-1).toLowerCase();return/^dockerfile(?:\.|$)/.test(t)?`docker`:/^\.(?:bashrc|bash_profile|zshrc|profile)$/.test(t)?`bash`:t_[t.split(`.`).at(-1)]??null}function r_(e){if(e.length>2e5)return!1;let t=1;for(let n=0;n<e.length;n++)if(e.charCodeAt(n)===10&&++t>5e3)return!1;return!0}function i_(e){let t=e.querySelector(`#editor`),n=e.querySelector(`#editor-highlight`),r=``,i=0,a,o,s,c=!1,l=!1,u;function d(){n.style.width=t.clientWidth+`px`,n.style.height=t.clientHeight+`px`,n.scrollTop=t.scrollTop,n.scrollLeft=t.scrollLeft}function f(){e.classList.remove(`is-highlighted`)}function p(){clearTimeout(o),s?.terminate(),s=void 0,c=!1}function m(){if(c||!u)return;let t=u;u=void 0;try{s||(s=new Worker(new URL(new URL(`editor-highlight.worker-oPpFVFqT.js`,import.meta.url).href,``+import.meta.url),{type:`module`}),s.onmessage=({data:t})=>{clearTimeout(o),c=!1,t.id===i&&t.html!==null&&(n.innerHTML=t.html+`
|
| 160 |
+
`,d(),e.classList.add(`is-highlighted`)),m()},s.onerror=()=>{p(),u=void 0}),c=!0,s.postMessage(t),o=setTimeout(()=>{p(),m()},1500)}catch{p()}}function h(n=0){i++,clearTimeout(a),u=void 0,f();let o=n_(r),s=t.value;if(e.dataset.language=o??`plain`,l||!s||!o||!r_(s))return;let c=i;a=setTimeout(()=>{u={id:c,text:s,language:o},m()},n)}t.addEventListener(`input`,()=>h(80)),t.addEventListener(`compositionstart`,()=>{l=!0,h()}),t.addEventListener(`compositionend`,()=>{l=!1,h(80)}),t.addEventListener(`scroll`,d,{passive:!0});let g=new ResizeObserver(d);return g.observe(t),{setFile(e,n){if(r===e&&t.value===n)return;let i=r!==e;r=e,t.value!==n&&(t.value=n),i&&(t.scrollTop=0,t.scrollLeft=0),h()},dispose(){clearTimeout(a),p(),g.disconnect()}}}var a_=`pi-column-ratio`,o_=1/1.83;function s_(e,t){let n=matchMedia(`(min-width: 851px)`),r=getComputedStyle(e),i=parseFloat(r.getPropertyValue(`--conversation-min`)),a=parseFloat(r.getPropertyValue(`--workspace-min`)),o=o_,s,c=0;try{let e=Number(localStorage.getItem(a_));Number.isFinite(e)&&e>0&&e<1&&(o=e)}catch{}function l(){if(!n.matches)return;let r=e.getBoundingClientRect().width;if(!r)return;let s=Math.max(i,Math.min(r-a,r*o));e.style.setProperty(`--conversation-width`,`${s}px`);let c=Math.round(s/r*100);t.setAttribute(`aria-valuemin`,String(Math.round(i/r*100))),t.setAttribute(`aria-valuemax`,String(Math.round((r-a)/r*100))),t.setAttribute(`aria-valuenow`,String(c)),t.setAttribute(`aria-valuetext`,`Chat ${c}%, workspace ${100-c}%`)}function u(){try{localStorage.setItem(a_,String(o))}catch{}}function d(t){let n=e.getBoundingClientRect().width;o=Math.max(i,Math.min(n-a,t))/n,l()}function f(){cancelAnimationFrame(c),c=0,s&&d(s.x-e.getBoundingClientRect().left-s.offset)}function p(e){if(!s)return;e?f():(cancelAnimationFrame(c),c=0,o=s.initialRatio);let n=s.pointer;s=void 0,t.hasPointerCapture(n)&&t.releasePointerCapture(n),document.documentElement.classList.remove(`is-resizing-columns`),l(),e&&u()}function m(){p(!1),o=o_,l(),u()}t.addEventListener(`pointerdown`,r=>{if(!n.matches||r.button!==0||!r.isPrimary||s)return;r.preventDefault(),t.focus({preventScroll:!0});let i=e.querySelector(`.conversation`).getBoundingClientRect().right;s={pointer:r.pointerId,initialRatio:o,x:r.clientX,offset:r.clientX-i},t.setPointerCapture(r.pointerId),document.documentElement.classList.add(`is-resizing-columns`)}),t.addEventListener(`pointermove`,e=>{s&&e.pointerId===s.pointer&&(s.x=e.clientX,c||=requestAnimationFrame(f))}),t.addEventListener(`pointerup`,e=>{s&&e.pointerId===s.pointer&&(s.x=e.clientX,p(!0))}),t.addEventListener(`pointercancel`,()=>p(!1)),t.addEventListener(`lostpointercapture`,()=>p(!1)),t.addEventListener(`dblclick`,m),t.addEventListener(`keydown`,t=>{if(!n.matches)return;if(t.key===`Escape`&&s){t.preventDefault(),p(!1);return}if(![`ArrowLeft`,`ArrowRight`,`Home`,`End`,`Enter`].includes(t.key))return;if(t.preventDefault(),t.key===`Enter`){m();return}p(!1);let r=e.querySelector(`.conversation`).getBoundingClientRect().width,i=t.shiftKey?48:16;d(t.key===`Home`?0:t.key===`End`?1/0:r+(t.key===`ArrowLeft`?-i:i)),u()}),window.addEventListener(`blur`,()=>p(!1)),n.addEventListener(`change`,()=>{p(!1),l()}),new ResizeObserver(l).observe(e),l()}var $=e=>document.querySelector(e);s_($(`main`),$(`#column-resizer`));var c_=new Worker(new URL(new URL(`../src/agent.worker.js`,import.meta.url).href,``+import.meta.url),{type:`module`}),l_=new Map,u_=1,d_=!1,f_=!1,p_,m_=!1,h_={},g_=[],__,rejectReady,v_,y_=new Promise((e,t)=>{__=e,rejectReady=t}),b_={events:[],generations:[],runtimeSamples:[],device:null,contextUsage:Mg([])},x_=Xg($(`#runtime-meter`)),S_=i_($(`.editor-input`)),C_=Hg($(`#pi-terminal`),{onInput:e=>$(`#send`).classList.toggle(`has-input`,!!e.trim()),load:()=>y_.then(I_),prompt:e=>y_.then(()=>L_(e)),newChat:()=>y_.then(R_),stop:()=>E_(`stop`),shell:e=>{if(m_)throw Error(`Save your file edits before running commands.`);return E_(`shell`,{command:e})}}),w_=Ug($(`#model-dialog`),{onClose:()=>C_.focus()}),T_=Yg($(`.workspace > .terminal`),{run:async e=>{if(await y_,m_)throw Error(`Save your file edits before running commands.`);if(d_)throw Error(`Wait for Pi to finish or stop the current task first.`);return E_(`shell`,{command:e})}});y_.catch(()=>{});function E_(e,t){return new Promise((n,r)=>{let i=u_++;l_.set(i,{resolve:n,reject:r}),c_.postMessage({id:i,action:e,args:t})})}function D_(e){$(`#notice`).textContent=e,$(`#notice`).hidden=!e}function O_(e){return window.self===window.top||!/denied|storage directory access/i.test(e)?e:($(`#open-tab`).href=location.href,$(`#open-tab`).hidden=!1,`Your browser blocked storage in this embedded app. Open the workspace in a new tab to load the model and save files.`)}function k_(){$(`#load`).disabled=d_||f_,$(`#load`).classList.toggle(`is-loaded`,f_),f_?$(`#load`).textContent=`Loaded`:d_||($(`#load`).textContent=`Load model`),$(`#load`).title=$(`#load-status`).textContent+` `+$(`#load-detail`).textContent,$(`#send`).disabled=d_,$(`#newline`).disabled=d_,C_.setState({busy:d_,loaded:f_,activity:$(`#activity`).textContent}),$(`#stop`).hidden=!d_;for(let e of[`#new-chat`,`#add-file`,`#upload`])$(e).disabled=d_;T_.setBusy(d_),$(`#save`).disabled=d_||!p_||!m_,$(`#editor`).disabled=d_||!p_}function A_(){C_.setFiles(h_),$(`#files`).replaceChildren();for(let e of Object.keys(h_).sort()){let t=document.createElement(`button`),n=document.createElement(`span`);n.className=`file-name`,n.textContent=e.replace(/^\/workspace\//,``),t.append(e_(e),n),t.title=e,t.classList.toggle(`selected`,e===p_),t.onclick=()=>j_(e),$(`#files`).append(t)}p_&&!m_&&S_.setFile(p_,h_[p_]??``)}function j_(e){if(v_=void 0,m_){D_(`Save the open file before selecting another file.`);return}p_=e,$(`#filename`).textContent=e,$(`#filetype`).replaceChildren(e_(e)),A_(),k_()}function M_(){if(m_){v_=void 0;return}if(!v_||!Object.hasOwn(h_,v_))return;let e=v_,t=e!==p_;j_(e),t&&($(`#editor`).scrollTop=0,$(`#editor`).scrollLeft=0);let n=[...$(`#files`).children].find(t=>t.title===e);if(n){let e=n.getBoundingClientRect(),t=$(`#files`).getBoundingClientRect();e.top<t.top?$(`#files`).scrollTop+=e.top-t.top:e.bottom>t.bottom&&($(`#files`).scrollTop+=e.bottom-t.bottom)}}function N_(){C_.setMessages(g_)}function P_(e){b_.contextUsage=e,C_.setState({contextUsage:e})}c_.onmessage=({data:e})=>{if(x_.update(e),e.type===`inference_activity`&&(b_.runtimeSamples.push(e),b_.runtimeSamples.length>160&&b_.runtimeSamples.shift()),e.type===`reply`){let t=l_.get(e.id);l_.delete(e.id),e.error?t?.reject(Object.assign(Error(e.error),{name:e.errorName||`Error`,code:e.errorCode})):t?.resolve(e.value)}else if(e.type===`initialized`)h_=e.files,g_=e.messages,A_(),N_(),P_(Mg(g_)),h_[`/workspace/README.md`]&&j_(`/workspace/README.md`),document.activeElement===document.body&&C_.focus(),__();else if(e.type===`busy`)e.action===`load`&&($(`#load`).textContent=`Loading…`),d_=!0,$(`#activity`).textContent=e.action===`prompt`?`Pi is working…`:e.action===`load`?e.cachedOnly?`Loading local model…`:`Loading model…`:`Working…`,k_();else if(e.type===`idle`)d_=!1,$(`#progress`).hidden=!0,$(`#activity`).textContent=f_?`Ready · running on your device`:`Files and chat are saved on this device.`,k_();else if(e.type===`workspace`)h_=e.files,A_(),M_();else if(e.type===`device`)b_.device=e.device;else if(e.type===`load_progress`)$(`#progress`).hidden=!1,e.phase===`download`?($(`#progress`).hidden=!1,$(`#progress`).value=e.loaded/e.total*100,$(`#load-status`).textContent=e.loaded===e.total?`Download complete. Preparing model…`:`Downloading model weights from Hugging Face…`,$(`#load-detail`).textContent=`${(e.loaded/1e9).toFixed(2)} / ${(e.total/1e9).toFixed(2)} GB · ${Math.round(e.loaded/e.total*100)}%${e.cached?` · ${(e.cached/1e9).toFixed(2)} GB cached`:``}`,$(`#load`).textContent=`Loading ${Math.round(e.loaded/e.total*100)}%`):($(`#load-status`).textContent=e.phase===`compile`?`Preparing WebGPU session…`:`Warming up GPU shaders…`,$(`#load-detail`).textContent=``,$(`#progress`).removeAttribute(`value`),$(`#load`).textContent=e.phase===`compile`?`Preparing…`:`Almost ready…`),$(`#load`).title=$(`#load-status`).textContent+` `+$(`#load-detail`).textContent;else if(e.type===`loaded`)f_=!0,Number.isInteger(e.contextLength)&&e.contextLength>0&&(jg=e.contextLength),P_(b_.contextUsage),b_.device=e.device,$(`#progress`).hidden=!0,$(`#load`).textContent=`Loaded`,$(`#load-status`).textContent=`Ready. Model inference and tools run entirely in this browser.`,$(`#load-detail`).textContent=e.cache===`browser`?`Browser caching enabled; saved weights may be reused.`:`Browser model caching is unavailable for this session.`,k_();else if(e.type===`agent_event`){let t=e.event;if(b_.events.push(t.type),b_.events.length>500&&b_.events.shift(),t.message){let e=g_.findIndex(e=>e.timestamp===t.message.timestamp&&e.role===t.message.role&&e.toolCallId===t.message.toolCallId);e<0?g_.push(t.message):g_[e]=t.message,N_()}t.type===`tool_execution_start`&&($(`#activity`).textContent=`Running ${t.toolName}…`),t.type===`tool_execution_end`&&t.toolName===`read`&&!t.isError&&typeof t.result?.details?.path==`string`&&(v_=t.result.details.path,M_()),t.type===`turn_start`&&($(`#activity`).textContent=`Pi is thinking…`),C_.setState({activity:$(`#activity`).textContent})}else e.type===`context_usage`?P_({inputTokens:e.inputTokens,outputTokens:e.outputTokens}):e.type===`generation`?(b_.generations.push(e),b_.generations.length>100&&b_.generations.shift()):e.type===`context_trim`?D_(`Older turns were left out of the model context. Your full chat remains saved.`):e.type===`notice`?D_(e.text):e.type===`persistence_error`?D_(O_(e.error)):e.type===`fatal`&&(f_=!1,d_=!1,$(`#progress`).hidden=!0,$(`#load-status`).textContent=e.stage===`initialization`?`${e.error||`Workspace initialization failed.`} Reload the page to retry.`:e.error||`The model runtime stopped. Load the model to continue.`,rejectReady(Error($(`#load-status`).textContent)),$(`#load-detail`).textContent=``,$(`#activity`).textContent=e.stage===`initialization`?`Reload the page to retry.`:`Load the model to continue.`,D_($(`#load-status`).textContent),k_())},c_.onerror=e=>{x_.update({type:`fatal`});let t=Error(e.message||`The worker stopped. Reload to restore your saved workspace.`);rejectReady(t),D_(t.message);for(let e of l_.values())e.reject(t);l_.clear(),d_=!1,f_=!1,$(`#progress`).hidden=!0,k_()};var F_=e=>async t=>{t?.preventDefault();try{await y_,await e(t)}catch(e){D_(e.message)}};async function I_({cachedOnly:e=!1}={}){if(d_)throw Error(`Wait for the current operation or stop it first.`);D_(``);d_=!0,$(`#load`).textContent=`Loading…`,$(`#activity`).textContent=e?`Loading local model…`:`Loading model…`,$(`#load-status`).textContent=e?`Loading local model…`:`Checking your browser and saved model…`,$(`#load-detail`).textContent=``,$(`#progress`).hidden=!1,$(`#progress`).removeAttribute(`value`),x_.update({type:`busy`,action:`load`,cachedOnly:e}),k_();try{return await E_(`load`,{cachedOnly:e})}catch(e){throw e.message=O_(e.message),$(`#load-status`).textContent=e.message,$(`#progress`).hidden=!0,e}}async function L_(e){if(e.trim()){if(m_)throw Error(`Save your file edits before asking Pi to work on them.`);try{if(!f_){let t;if(await E_(`cache_status`))try{C_.setQueuedPrompt(e),await I_({cachedOnly:!0})}catch(e){if(e.name===`AbortError`)throw e;e.code!==`MODEL_CACHE_MISS`&&(t=e.message)}for(;!f_;){if(C_.setQueuedPrompt(``),!await w_.request(e,{errorMessage:t}))throw new DOMException(`Message kept for later.`,`AbortError`);C_.setQueuedPrompt(e);try{await I_()}catch(e){if(e.name===`AbortError`)throw e;t=e.message}}}C_.setQueuedPrompt(``),D_(``);let t=await E_(`prompt`,{text:e});return g_=t.messages,N_(),t}finally{C_.setQueuedPrompt(``)}}}$(`#load`).onclick=F_(I_),$(`#send`).onclick=()=>{C_.submit(),C_.focus()},$(`#newline`).onclick=()=>C_.newline(),$(`#stop`).onclick=F_(()=>E_(`stop`)),$(`.terminal-panel`).addEventListener(`click`,e=>{!e.defaultPrevented&&e.button===0&&e.target instanceof Element&&(e.target.closest(`#pi-terminal, button, a, input, textarea, select, label, summary, [role="button"], [contenteditable]`)||window.getSelection()?.isCollapsed!==!1&&C_.focus())});async function R_(){v_=void 0,g_=(await E_(`new_chat`)).messages,P_(Mg(g_)),C_.setMessages(g_,{reset:!0}),D_(``),C_.focus()}$(`#new-chat`).onclick=F_(R_),$(`#editor`).oninput=()=>{m_=!0,k_()},$(`#save`).onclick=F_(async()=>{await E_(`write`,{path:p_,content:$(`#editor`).value}),m_=!1,A_(),k_(),D_(``)}),$(`#add-file`).onclick=F_(async()=>{if(m_)throw Error(`Save the open file first.`);let e=window.prompt(`File path, relative to /workspace:`);if(!e?.trim())return;let t=e.startsWith(`/`)?e:`/workspace/`+e;if(Object.hasOwn(h_,t))throw Error(`That file already exists.`);await E_(`write`,{path:t,content:``}),j_(t)}),$(`#upload`).onchange=F_(async()=>{if(m_)throw Error(`Save the open file first.`);for(let e of $(`#upload`).files){if(e.size>1024**2)throw Error(`Import text files of 1 MiB or less.`);let t=await e.text();if(t.includes(`\0`))throw Error(`Import supports text files only.`);let n=`/workspace/`+e.name;if(Object.hasOwn(h_,n))throw Error(e.name+` already exists. Rename it before importing.`);await E_(`write`,{path:n,content:t}),j_(n)}$(`#upload`).value=``}),$(`#export`).onclick=F_(async()=>{let e=await E_(`snapshot`),t=URL.createObjectURL(new Blob([JSON.stringify(e,null,2)],{type:`application/json`})),n=document.createElement(`a`);n.href=t,n.download=`pi-workspace.json`,n.click(),setTimeout(()=>URL.revokeObjectURL(t),1e3)}),window.browserPi={ready:y_,load:I_,prompt:L_,rpc:E_,diagnostics:b_,terminal:C_,runtimeMeter:x_,get loaded(){return f_},get busy(){return d_}},k_();
|
docs/integration.md
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Integration notes
|
| 2 |
+
|
| 3 |
+
## Inputs
|
| 4 |
+
|
| 5 |
+
- Upstream: `https://huggingface.co/spaces/victor/MiniCPM5-2B-WebGPU-Pi`.
|
| 6 |
+
- Cloned commit: `8ac95048396e4cb8ae997d6996505f499fa0c67b`.
|
| 7 |
+
- Supplied archive SHA-256: `063e89c96bb7afdeb476fc04f4031a8dfdba0fd4d8e175d61490dc49643d949c`.
|
| 8 |
+
- `runtime/minicpm5.js` SHA-256: `785c0cdba03a74f1b00be99f6fe6abcfedb22292f92bc89bbf78ea546ab5a4f7`.
|
| 9 |
+
- `runtime/agent.js` SHA-256: `1a15c495d2a4f699f2629d023ec17717f115c97c964f0e8c7e67d9d0a583c7b0`.
|
| 10 |
+
|
| 11 |
+
Both supplied modules are copied unchanged. Model weights use the supplied
|
| 12 |
+
runtime's default GGUF repository/file at revision `main`. Its loader validates
|
| 13 |
+
format and resource identity; the app does not claim a pinned weight revision or
|
| 14 |
+
whole-file SHA-256 verification.
|
| 15 |
+
|
| 16 |
+
## Replacement boundary
|
| 17 |
+
|
| 18 |
+
The UI starts `src/agent.worker.js` as a module worker. `WorkspaceController`
|
| 19 |
+
loads `MiniCPM5ForCausalLM` directly and constructs the supplied `Agent`. The
|
| 20 |
+
agent owns one lazily created session and reuses it for follow-ups and new chats.
|
| 21 |
+
Agent and model resources are disposed separately after device loss.
|
| 22 |
+
|
| 23 |
+
Pi's agent core, Transformers.js model/tokenizer loader, ONNX Runtime, custom
|
| 24 |
+
ONNX download/cache layer, XML tool parser, sampling adapter, and WASM binaries
|
| 25 |
+
from the original worker are removed. The runtime handles tokenization, GGUF
|
| 26 |
+
weights, prompt rendering, KV caching, reasoning/text separation, and validated
|
| 27 |
+
tool-call parsing.
|
| 28 |
+
|
| 29 |
+
The frontend still uses Pi's terminal components. `conversation.js` translates
|
| 30 |
+
display events only: it does not implement inference or another agent loop.
|
| 31 |
+
Tool arguments are already parsed objects. Tool definitions use plain JSON
|
| 32 |
+
Schema and execute against the same just-bash workspace as the editor and shell.
|
| 33 |
+
Nonzero bash exits reject so the supplied agent recognizes failures and avoids
|
| 34 |
+
repeating identical failed calls.
|
| 35 |
+
|
| 36 |
+
## History and persistence
|
| 37 |
+
|
| 38 |
+
Canonical messages come directly from the supplied Agent and are preserved
|
| 39 |
+
unchanged. Pi-formatted display messages are stored separately. Saves happen
|
| 40 |
+
after each completed reply and tool result, before the agent continues, and
|
| 41 |
+
again when the run settles. Storage failures leave the in-memory workspace
|
| 42 |
+
usable and report that saving failed.
|
| 43 |
+
|
| 44 |
+
The database is `minicpm5-gguf-workspace`, distinct from the original Pi database
|
| 45 |
+
because the message schemas differ. Existing upstream conversations are not
|
| 46 |
+
automatically migrated. Export important old files from the original app and
|
| 47 |
+
import text files into this version. A clone on a new origin also naturally has
|
| 48 |
+
separate browser storage.
|
| 49 |
+
|
| 50 |
+
The attached Agent intentionally excludes an interrupted partial assistant
|
| 51 |
+
reply from its own history. If no complete assistant reply was produced, its
|
| 52 |
+
user turn is omitted too. The UI retains that interrupted exchange for review,
|
| 53 |
+
marks it stopped, and explains that it is not used as model history. Completed
|
| 54 |
+
tool rounds remain valid and are saved.
|
| 55 |
+
|
| 56 |
+
## Loading and metrics
|
| 57 |
+
|
| 58 |
+
Only byte progress is rendered as transferred bytes. Tensor uploads and GPU
|
| 59 |
+
preparation are indeterminate. The UI becomes ready only after
|
| 60 |
+
`from_pretrained` resolves. The requested context is 8,192 tokens and the UI is
|
| 61 |
+
updated to the runtime's actual device-dependent capacity.
|
| 62 |
+
|
| 63 |
+
Cache probing conservatively returns false because the supplied public API has
|
| 64 |
+
no complete-cache check. This preserves an honest download confirmation while
|
| 65 |
+
allowing the runtime's range cache to work normally. `cachedOnly` requests fail
|
| 66 |
+
with `MODEL_CACHE_MISS`, without starting a network load.
|
| 67 |
+
|
| 68 |
+
The old sampling configuration is removed. The runtime decodes greedily.
|
| 69 |
+
Generation activity is shown while deltas stream. Exact token counts, TTFT,
|
| 70 |
+
cached tokens, and decode speed are taken from completed runtime results rather
|
| 71 |
+
than estimated from characters.
|
| 72 |
+
|
| 73 |
+
## Verification
|
| 74 |
+
|
| 75 |
+
Validation completed: **37 tests passed** with `npm test`, including real
|
| 76 |
+
shell/tool execution, supplied-agent lifecycle checks, controller/event checks
|
| 77 |
+
with fake model sessions, and the actual worker module/RPC transport using the
|
| 78 |
+
real shell. `npm run build` passed. Every shipped JavaScript file passed syntax
|
| 79 |
+
checks, local asset/module references resolved, and both supplied runtime
|
| 80 |
+
modules matched the archive byte-for-byte. These checks do not validate
|
| 81 |
+
numerical inference, shader
|
| 82 |
+
compatibility, actual GGUF download/cache recovery, or visual layout in a browser.
|
| 83 |
+
|
| 84 |
+
No browser-control capability or WebGPU device was available for a browser
|
| 85 |
+
inference check. The upstream layout/assets were retained, but the updated app
|
| 86 |
+
still needs the following checks on the intended GPU:
|
| 87 |
+
|
| 88 |
+
1. Start locally, load the GGUF, and verify download progress and actual context.
|
| 89 |
+
2. Ask for total sales revenue; verify the agent reads both files, computes
|
| 90 |
+
$144, writes a report, and reads it back.
|
| 91 |
+
3. Ask for a script, run it, edit it, and verify the updated output.
|
| 92 |
+
4. Stop during reasoning and during a shell task; send a follow-up.
|
| 93 |
+
5. Reload and verify workspace restoration and cache reuse.
|
| 94 |
+
6. Start a new chat and verify files remain; test Import/Export and narrow layout.
|
| 95 |
+
|
| 96 |
+
The original project's conversion benchmarks and browser validation claims are
|
| 97 |
+
not carried forward: they measured a different model format and runtime.
|
docs/runtime-api.md
ADDED
|
@@ -0,0 +1,345 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# MiniCPM5 on WebGPU
|
| 2 |
+
|
| 3 |
+
Two self-contained ES modules that run [MiniCPM5-2B](https://huggingface.co/openbmb/MiniCPM5-2B-GGUF)
|
| 4 |
+
on the local GPU and let it call your tools:
|
| 5 |
+
|
| 6 |
+
| File | Size | Exports |
|
| 7 |
+
| --- | --- | --- |
|
| 8 |
+
| `minicpm5.js` | 1.7 MB | `MiniCPM5ForCausalLM`, which loads the model and runs conversations. |
|
| 9 |
+
| `agent.js` | 6 KB | `Agent` and `runAgent`, which run the model as an agent: it calls your tools until it can answer. |
|
| 10 |
+
|
| 11 |
+
Neither file has dependencies. Prompts, replies and tool results stay on the device. The only network
|
| 12 |
+
traffic is the model download.
|
| 13 |
+
|
| 14 |
+
**Requirements**
|
| 15 |
+
|
| 16 |
+
- WebGPU: a recent Chrome or Edge, or Node with Dawn's WebGPU bindings installed as `navigator.gpu`.
|
| 17 |
+
- Serve the files over HTTP. ES modules and WebGPU do not run from `file://`.
|
| 18 |
+
- The default Q4_K_M file is about a 1.6 GB download. At the default 8,192-token context the model
|
| 19 |
+
holds about 2 GB of GPU memory.
|
| 20 |
+
|
| 21 |
+
## Quick start
|
| 22 |
+
|
| 23 |
+
```js
|
| 24 |
+
import { MiniCPM5ForCausalLM } from "./minicpm5.js";
|
| 25 |
+
import { Agent } from "./agent.js";
|
| 26 |
+
|
| 27 |
+
const model = await MiniCPM5ForCausalLM.from_pretrained(); // openbmb/MiniCPM5-2B-GGUF, Q4_K_M
|
| 28 |
+
|
| 29 |
+
const agent = new Agent({
|
| 30 |
+
model,
|
| 31 |
+
instructions: "You are a helpful assistant. Use the tools when they help.",
|
| 32 |
+
tools: [{
|
| 33 |
+
name: "get_weather",
|
| 34 |
+
description: "Current weather for a city.",
|
| 35 |
+
parameters: {
|
| 36 |
+
type: "object",
|
| 37 |
+
properties: { city: { type: "string" }, unit: { type: "string", enum: ["celsius", "fahrenheit"] } },
|
| 38 |
+
required: ["city"],
|
| 39 |
+
},
|
| 40 |
+
async execute({ city, unit = "celsius" }) {
|
| 41 |
+
return JSON.stringify(await lookUpWeather(city, unit)); // your code
|
| 42 |
+
},
|
| 43 |
+
}],
|
| 44 |
+
max_new_tokens: 4096,
|
| 45 |
+
});
|
| 46 |
+
|
| 47 |
+
const run = agent.run("What's the weather in Paris and in Tokyo?");
|
| 48 |
+
for await (const event of run) {
|
| 49 |
+
if (event.type === "text") showAnswer(event.text);
|
| 50 |
+
}
|
| 51 |
+
const { content } = await run.result;
|
| 52 |
+
```
|
| 53 |
+
|
| 54 |
+
`agent.js` does not depend on MiniCPM5. It drives any model object with a `create_session()` method,
|
| 55 |
+
and any session whose `generate` works like the one below.
|
| 56 |
+
|
| 57 |
+
## Loading the model
|
| 58 |
+
|
| 59 |
+
```js
|
| 60 |
+
const { ok, reason } = await MiniCPM5ForCausalLM.checkAvailability();
|
| 61 |
+
if (!ok) showError(reason);
|
| 62 |
+
|
| 63 |
+
const model = await MiniCPM5ForCausalLM.from_pretrained("openbmb/MiniCPM5-2B-GGUF", {
|
| 64 |
+
file: "MiniCPM5-2B-Q8_0.gguf",
|
| 65 |
+
maxLength: 16384,
|
| 66 |
+
onProgress: ({ status, fraction }) => showProgress(status, fraction),
|
| 67 |
+
});
|
| 68 |
+
```
|
| 69 |
+
|
| 70 |
+
`checkAvailability(id, options)` reads only the GGUF header and checks it against this device's
|
| 71 |
+
GPU. It never throws: if the check itself fails (for example, the network is offline), it resolves
|
| 72 |
+
`{ ok: true }` so the real load can report the real error.
|
| 73 |
+
|
| 74 |
+
`from_pretrained(id, options)` takes a repo id, or a direct URL to a `.gguf` file. With no id, it loads
|
| 75 |
+
`openbmb/MiniCPM5-2B-GGUF`.
|
| 76 |
+
|
| 77 |
+
| Option | Meaning |
|
| 78 |
+
| --- | --- |
|
| 79 |
+
| `file` | The GGUF file in the repo: `MiniCPM5-2B-Q4_K_M.gguf` (default), `MiniCPM5-2B-Q8_0.gguf` or `MiniCPM5-2B-F16.gguf`. |
|
| 80 |
+
| `maxLength` | Context size in tokens, prompt and reply together. Default 8192, clamped to what the device supports. Each token of context takes 84 KiB of GPU memory, allocated at load. |
|
| 81 |
+
| `onProgress` | Called with `{ status, fraction?, loaded?, total?, message? }`. `status` runs `init`, `config`, `support`, `tokenizer`, `weights`, `ready`. |
|
| 82 |
+
| `signal` | An `AbortSignal` that cancels the load and frees whatever it had allocated. |
|
| 83 |
+
| `revision` | Branch, tag or commit of the repo. Default `main`. |
|
| 84 |
+
| `accessToken` | A Hugging Face token, for a private or gated repo. It is used only in request headers and never stored. |
|
| 85 |
+
| `cache` | `false` turns off the browser cache described below. |
|
| 86 |
+
|
| 87 |
+
In a browser, the weights are cached as they stream, so a reload downloads only what is missing.
|
| 88 |
+
Outside a browser nothing is cached, and the file is read over HTTP on every load.
|
| 89 |
+
|
| 90 |
+
Other repos' copies of MiniCPM5-2B load as well (for example `bartowski/MiniCPM5-2B-GGUF`, Q4_K_M), as
|
| 91 |
+
long as every tensor is F32, F16, Q4_K, Q5_K, Q6_K or Q8_0. A file in any other format is refused
|
| 92 |
+
before its weights download.
|
| 93 |
+
|
| 94 |
+
A loaded model has:
|
| 95 |
+
|
| 96 |
+
- `create_session()`, which starts a conversation.
|
| 97 |
+
- `generation_config`, which is always `{ do_sample: false }` for this model.
|
| 98 |
+
- `contextLength`.
|
| 99 |
+
- `deviceInfo()`.
|
| 100 |
+
- `memoryStats()`, the GPU bytes held now and the peak so far.
|
| 101 |
+
- `lost`, a promise that resolves if the GPU device is lost.
|
| 102 |
+
- `dispose()`, which frees everything.
|
| 103 |
+
|
| 104 |
+
## Chatting without tools
|
| 105 |
+
|
| 106 |
+
```js
|
| 107 |
+
const session = model.create_session();
|
| 108 |
+
const history = [{ role: "user", content: "Explain recursion in two sentences." }];
|
| 109 |
+
|
| 110 |
+
const turn = session.generate(history, { max_new_tokens: 2048 });
|
| 111 |
+
for await (const event of turn) {
|
| 112 |
+
if (event.type === "reasoning") showThinking(event.text);
|
| 113 |
+
else if (event.type === "text") showAnswer(event.text);
|
| 114 |
+
}
|
| 115 |
+
const { finish_reason, message, usage, timings } = await turn.result;
|
| 116 |
+
history.push(message); // push it back unchanged
|
| 117 |
+
```
|
| 118 |
+
|
| 119 |
+
**Send the whole history on every turn.** The session keeps the conversation's keys and values on
|
| 120 |
+
the GPU and computes only the part of the prompt it has not seen. To make that work, push each
|
| 121 |
+
`message` back exactly as it was returned. Edited or restored histories still work: the session
|
| 122 |
+
compares tokens, and recomputes from the first difference.
|
| 123 |
+
|
| 124 |
+
**Events** arrive in this order:
|
| 125 |
+
|
| 126 |
+
1. `prefill`, with `{ done, total }`, while a long prompt is read.
|
| 127 |
+
2. `reasoning` and `text` deltas. Joined, they equal `message.reasoning_content` and `message.content`.
|
| 128 |
+
3. `tool_call`, one per call, after the reply has ended.
|
| 129 |
+
|
| 130 |
+
**`finish_reason`** is one of:
|
| 131 |
+
|
| 132 |
+
- `stop`: the model finished.
|
| 133 |
+
- `length`: the reply ran out of room. `limit` is `max_new_tokens` or `context`.
|
| 134 |
+
- `tool_calls`: the reply asked for tools (see below).
|
| 135 |
+
- `cancelled`: the turn was stopped.
|
| 136 |
+
|
| 137 |
+
**`usage`** has `prompt_tokens`, `cached_tokens` (not recomputed this turn), `completion_tokens` and
|
| 138 |
+
`reasoning_tokens`. **`timings`** has `ttft_ms`, `decode_ms` and `tokens_per_second`.
|
| 139 |
+
|
| 140 |
+
**Turn options:**
|
| 141 |
+
|
| 142 |
+
| Option | Meaning |
|
| 143 |
+
| --- | --- |
|
| 144 |
+
| `max_new_tokens` | Most tokens to generate, reasoning included. Default 1024. |
|
| 145 |
+
| `enable_thinking` | `true` or `false`. Leave it unset to let the model decide (see below). |
|
| 146 |
+
| `tools` | Functions the model may call. |
|
| 147 |
+
| `stop_strings` | End the reply at any of these strings. The match is left out of the reply. |
|
| 148 |
+
| `signal` | An `AbortSignal` that cancels the turn. |
|
| 149 |
+
|
| 150 |
+
**Cancel** with `turn.cancel()`, a `signal`, or by breaking out of the `for await` loop. The result is
|
| 151 |
+
`cancelled`, with the partial reply. The cache stays valid either way. You may leave the partial reply
|
| 152 |
+
out of the history, or push it.
|
| 153 |
+
|
| 154 |
+
**Errors.** A turn that fails rejects its `result`, and iterating it throws the same error. The model
|
| 155 |
+
runs one turn at a time, so a turn started while another is running fails with `BusyError`. A prompt
|
| 156 |
+
that cannot fit fails with `ContextOverflowError` (with `required` and `capacity`) before anything
|
| 157 |
+
runs; trim the history and try again. If the GPU device is lost, the running turn fails with
|
| 158 |
+
`DeviceLostError` and `model.lost` resolves; dispose the model and load it again. All three classes
|
| 159 |
+
are exported by `minicpm5.js`.
|
| 160 |
+
|
| 161 |
+
**Sessions.** Each session is one conversation with its own cache. The first session uses the cache
|
| 162 |
+
the model allocated at load, and each further one allocates another. Use `session.dispose()` to free
|
| 163 |
+
one. Use `session.count_tokens(history, { tools, enable_thinking })` to count a prompt before sending
|
| 164 |
+
it.
|
| 165 |
+
|
| 166 |
+
### Thinking
|
| 167 |
+
|
| 168 |
+
With `enable_thinking` unset, MiniCPM5 decides for itself whether to think first. It usually does, and
|
| 169 |
+
often at length: a coding question can think for more than 700 tokens. Give such turns a
|
| 170 |
+
`max_new_tokens` of a few thousand, or the reply ends on `length` before it answers.
|
| 171 |
+
`enable_thinking: true` always thinks. `false` answers directly, which is faster but can be weaker.
|
| 172 |
+
|
| 173 |
+
The reasoning never appears in `content`.
|
| 174 |
+
|
| 175 |
+
### Decoding
|
| 176 |
+
|
| 177 |
+
MiniCPM5 decodes greedily (always the most likely token), so the same history gives the same reply.
|
| 178 |
+
A turn that asks to sample (`do_sample: true`, `temperature`, `top_p`, ...) is refused with an error.
|
| 179 |
+
|
| 180 |
+
### Tool calls without agent.js
|
| 181 |
+
|
| 182 |
+
If you already have an agent loop, use the session directly. Tools use the format of transformers'
|
| 183 |
+
chat templates:
|
| 184 |
+
|
| 185 |
+
```js
|
| 186 |
+
const tools = [{ type: "function", function: {
|
| 187 |
+
name: "get_weather",
|
| 188 |
+
description: "Current weather for a city.",
|
| 189 |
+
parameters: { type: "object", properties: { city: { type: "string" } }, required: ["city"] },
|
| 190 |
+
} }];
|
| 191 |
+
|
| 192 |
+
const turn = session.generate(history, { tools, max_new_tokens: 4096 });
|
| 193 |
+
for await (const event of turn) { /* ... */ }
|
| 194 |
+
const { finish_reason, message } = await turn.result;
|
| 195 |
+
history.push(message);
|
| 196 |
+
if (finish_reason === "tool_calls") {
|
| 197 |
+
for (const call of message.tool_calls) {
|
| 198 |
+
const content = call.errors ? call.errors.join("; ") : await run(call.function.name, call.function.arguments);
|
| 199 |
+
history.push({ role: "tool", tool_call_id: call.id, name: call.function.name, content });
|
| 200 |
+
}
|
| 201 |
+
// generate again with the same tools
|
| 202 |
+
}
|
| 203 |
+
```
|
| 204 |
+
|
| 205 |
+
- **`call.function.arguments` is an object**, already parsed and converted to the types in the
|
| 206 |
+
schema. (OpenAI's API gives a JSON string instead.)
|
| 207 |
+
- **Never run a call that has `errors`.** The markup did not parse, the tool was not offered, or an
|
| 208 |
+
argument is missing or has the wrong type. Send the errors back as the call's result, and the model
|
| 209 |
+
can correct the call.
|
| 210 |
+
- **Answer every call, in order**, with one `tool` message each. The template pairs results with
|
| 211 |
+
calls by position.
|
| 212 |
+
- **Keep `tools` identical across turns.** The tools are part of the prompt's start, so changing them
|
| 213 |
+
makes the next turn recompute the whole conversation.
|
| 214 |
+
|
| 215 |
+
## The agent (agent.js)
|
| 216 |
+
|
| 217 |
+
`Agent` keeps a conversation and runs the loop for you: it generates a reply, runs each tool the reply
|
| 218 |
+
calls, sends back the results, and repeats until the model answers.
|
| 219 |
+
|
| 220 |
+
```js
|
| 221 |
+
const agent = new Agent({
|
| 222 |
+
model, // a loaded model, or a session you created
|
| 223 |
+
instructions: "You are a helpful assistant.",
|
| 224 |
+
tools: [weather, calculator],
|
| 225 |
+
max_new_tokens: 4096, // and any other turn option: enable_thinking, stop_strings, ...
|
| 226 |
+
max_rounds: 8, // most replies per run (default 8)
|
| 227 |
+
});
|
| 228 |
+
await agent.prime(); // optional: read the instructions and tools now, before the first message
|
| 229 |
+
|
| 230 |
+
const run = agent.run("What is 1234 * 5678 + 91?");
|
| 231 |
+
for await (const event of run) {
|
| 232 |
+
switch (event.type) {
|
| 233 |
+
case "reasoning": showThinking(event.text); break;
|
| 234 |
+
case "text": showAnswer(event.text); break;
|
| 235 |
+
case "tool_start": showCall(event.call.function.name, event.call.function.arguments); break;
|
| 236 |
+
case "tool_end": showResult(event.message.content, event.is_error); break;
|
| 237 |
+
}
|
| 238 |
+
}
|
| 239 |
+
const { finish_reason, content, usage, rounds } = await run.result;
|
| 240 |
+
```
|
| 241 |
+
|
| 242 |
+
### Tools
|
| 243 |
+
|
| 244 |
+
A tool is a plain object:
|
| 245 |
+
|
| 246 |
+
| Field | Meaning |
|
| 247 |
+
| --- | --- |
|
| 248 |
+
| `name` | Unique within the agent. |
|
| 249 |
+
| `description` | What the tool does. The model reads this. |
|
| 250 |
+
| `parameters` | A JSON Schema object for the arguments. |
|
| 251 |
+
| `execute(args, { signal, call })` | Runs the call. `signal` aborts if the run is cancelled. |
|
| 252 |
+
|
| 253 |
+
`execute` may return a string, or `{ content, details }`. The model sees `content`, and `details`
|
| 254 |
+
reaches only your code, on the `tool_end` event. Returning nothing sends an empty result. MiniCPM5
|
| 255 |
+
reads text only, so return text.
|
| 256 |
+
|
| 257 |
+
The agent handles these cases for you:
|
| 258 |
+
|
| 259 |
+
- **Errors go to the model.** If `execute` throws, the error message is the call's result, and the
|
| 260 |
+
model can try something else.
|
| 261 |
+
- **Bad calls never run.** A call that did not parse, names an unknown tool, or has missing or wrong
|
| 262 |
+
arguments is answered with what is wrong.
|
| 263 |
+
- **Repeated failures are refused.** If the model repeats a call that just failed, with the same
|
| 264 |
+
arguments and nothing succeeding in between, it gets "already failed" back, and the call does not
|
| 265 |
+
run again.
|
| 266 |
+
- **Order.** Calls run one at a time, in the order the model wrote them.
|
| 267 |
+
|
| 268 |
+
### Events and results
|
| 269 |
+
|
| 270 |
+
Every event carries `round`, which counts the run's replies from 0. In order:
|
| 271 |
+
|
| 272 |
+
| Event | When | Fields |
|
| 273 |
+
| --- | --- | --- |
|
| 274 |
+
| `turn_start` | A reply begins. | |
|
| 275 |
+
| `prefill`, `reasoning`, `text`, `tool_call` | The model's own events, as from a session. | |
|
| 276 |
+
| `turn_end` | The reply is complete. | `result`: the turn's `finish_reason`, `message`, `usage` and `timings` |
|
| 277 |
+
| `tool_start` | A call is about to run. | `call` |
|
| 278 |
+
| `tool_end` | A call has its answer. | `call`, `message` (the `tool` message), `is_error`, `details` |
|
| 279 |
+
|
| 280 |
+
`run.result` resolves with:
|
| 281 |
+
|
| 282 |
+
| Field | Meaning |
|
| 283 |
+
| --- | --- |
|
| 284 |
+
| `finish_reason` | `stop` (the model answered), `length` (out of room; `limit` says which), `cancelled`, or `max_rounds` (it was still calling tools after `max_rounds` replies) |
|
| 285 |
+
| `content` | The last reply's answer. |
|
| 286 |
+
| `messages` | Everything the run added to the conversation. |
|
| 287 |
+
| `usage` | Token counts summed over every reply. |
|
| 288 |
+
| `rounds` | Number of replies. |
|
| 289 |
+
|
| 290 |
+
A failure, such as `ContextOverflowError` or `DeviceLostError`, rejects `run.result`, and iterating the
|
| 291 |
+
run throws the same error.
|
| 292 |
+
|
| 293 |
+
### Cancelling
|
| 294 |
+
|
| 295 |
+
Call `run.cancel()`, pass `signal` to `agent.run(input, { signal })`, or break out of the loop. A reply
|
| 296 |
+
in progress stops. A running tool sees its `signal` abort and is answered "may have partly completed".
|
| 297 |
+
Calls that had not started are answered "not run". Every call still gets an answer, so the conversation
|
| 298 |
+
remains valid.
|
| 299 |
+
|
| 300 |
+
### The conversation
|
| 301 |
+
|
| 302 |
+
`agent.messages` is the conversation, with the system prompt first. Each reply and each tool result is
|
| 303 |
+
added as soon as it completes, so it is always a valid history, even after a cancel or an error. A run
|
| 304 |
+
that produced no reply leaves it unchanged. You can edit it between runs.
|
| 305 |
+
|
| 306 |
+
The run waits for your loop at the end of each step. It goes on past a `turn_end` or `tool_end` only
|
| 307 |
+
after your loop has handled that event. So you can save the conversation before any tool runs:
|
| 308 |
+
|
| 309 |
+
```js
|
| 310 |
+
for await (const event of run) {
|
| 311 |
+
if (event.type === "turn_end" || event.type === "tool_end") await save(JSON.stringify(agent.messages));
|
| 312 |
+
}
|
| 313 |
+
```
|
| 314 |
+
|
| 315 |
+
To restore a conversation later:
|
| 316 |
+
|
| 317 |
+
```js
|
| 318 |
+
const [system, ...rest] = JSON.parse(await load());
|
| 319 |
+
const agent = new Agent({ model, instructions: system.content, tools, messages: rest });
|
| 320 |
+
```
|
| 321 |
+
|
| 322 |
+
Other methods:
|
| 323 |
+
|
| 324 |
+
- **`run(input, options)`** takes text or a whole message. Its options add to the agent's own.
|
| 325 |
+
- **`running`** says whether a run is in progress. Only one run can go at a time.
|
| 326 |
+
- **`reset()`** clears the conversation but keeps the system prompt.
|
| 327 |
+
- **`dispose()`** cancels any run and frees the session the agent created. A session you passed in
|
| 328 |
+
stays open.
|
| 329 |
+
|
| 330 |
+
### runAgent: the loop with your own history
|
| 331 |
+
|
| 332 |
+
`runAgent(session, history, options)` is the same loop, for code that stores the conversation itself.
|
| 333 |
+
It never changes the array you pass. Append what the run added:
|
| 334 |
+
|
| 335 |
+
```js
|
| 336 |
+
import { runAgent } from "./agent.js";
|
| 337 |
+
|
| 338 |
+
const session = model.create_session();
|
| 339 |
+
const run = runAgent(session, history, { tools, max_new_tokens: 4096 });
|
| 340 |
+
for await (const event of run) render(event);
|
| 341 |
+
history.push(...(await run.result).messages);
|
| 342 |
+
```
|
| 343 |
+
|
| 344 |
+
Push those messages unchanged. The replies are the session's own objects, which it re-renders exactly
|
| 345 |
+
as the model wrote them, so the next run computes only the new part of the prompt.
|
index.html
CHANGED
|
@@ -10,15 +10,14 @@
|
|
| 10 |
<meta property="og:type" content="website">
|
| 11 |
<meta property="og:title" content="Pi, entirely in your browser.">
|
| 12 |
<meta property="og:description" content="MiniCPM5-2B, WebGPU inference, and a local shell. A coding agent running entirely in your browser.">
|
| 13 |
-
<meta property="og:
|
| 14 |
-
<meta property="og:image" content="https://huggingface.co/spaces/townbox/MiniCPM5-2B-WebGPU-Pi/resolve/main/social-thumbnail-v3.png">
|
| 15 |
<meta property="og:image:width" content="3136">
|
| 16 |
<meta property="og:image:height" content="1984">
|
| 17 |
<meta property="og:image:alt" content="A coding agent running entirely in your browser, above the Pi terminal and its pixel cat mascot.">
|
| 18 |
<meta name="twitter:card" content="summary_large_image">
|
| 19 |
<meta name="twitter:title" content="Pi, entirely in your browser.">
|
| 20 |
<meta name="twitter:description" content="MiniCPM5-2B, WebGPU inference, and a local shell. A coding agent running entirely in your browser.">
|
| 21 |
-
<meta name="twitter:image" content="https://huggingface.co/spaces/
|
| 22 |
<meta name="twitter:image:alt" content="A coding agent running entirely in your browser, above the Pi terminal and its pixel cat mascot.">
|
| 23 |
<title>Pi · Coding agent in your browser</title>
|
| 24 |
<script type="module" crossorigin src="./assets/index-DnXFGQ8z.js"></script>
|
|
@@ -37,8 +36,8 @@
|
|
| 37 |
<span class="sr-only" role="status" aria-live="polite"></span>
|
| 38 |
</div>
|
| 39 |
<div class="model-panel">
|
| 40 |
-
<div class="model-heading"><div><a class="header-model-source" href="https://huggingface.co/openbmb/MiniCPM5-2B" target="_blank" rel="noopener noreferrer" aria-label="MiniCPM5-2B by OpenBMB on Hugging Face (opens in a new tab)" title="View MiniCPM5-2B on Hugging Face"><img src="./openbmb-avatar.webp" width="16" height="16" alt=""><strong>MiniCPM5-2B</strong></a><span class="badge">4-bit</span></div><button id="load" class="primary" aria-describedby="load-status load-detail">Load model</button></div>
|
| 41 |
-
<p id="load-status" class="sr-only" role="status">
|
| 42 |
<progress id="progress" hidden max="100" value="0" aria-label="Model download"></progress>
|
| 43 |
<div id="load-detail" class="sr-only"></div>
|
| 44 |
</div>
|
|
@@ -81,18 +80,17 @@
|
|
| 81 |
<div class="setup-body">
|
| 82 |
<p id="model-description">Everything happens in your browser. Your prompts and files stay on this device.</p>
|
| 83 |
<dl class="setup-runtime">
|
| 84 |
-
<div><dt>Model</dt><dd class="setup-model"><a class="model-org" href="https://huggingface.co/openbmb" target="_blank" rel="noopener noreferrer" aria-label="OpenBMB on Hugging Face (opens in a new tab)" title="OpenBMB on Hugging Face"><img src="./openbmb-avatar.webp" width="18" height="18" alt=""></a><a class="model-source" href="https://huggingface.co/openbmb/MiniCPM5-2B" target="_blank" rel="noopener noreferrer" title="View MiniCPM5-2B on Hugging Face">MiniCPM5-2B</a></dd></div>
|
| 85 |
<div><dt>Inference</dt><dd>On your GPU</dd></div>
|
| 86 |
<div><dt>Shell</dt><dd>Bash in the browser</dd></div>
|
| 87 |
</dl>
|
| 88 |
-
<div class="setup-facts"><strong>About 1.
|
| 89 |
<p class="setup-label">Your message</p><div id="model-preview"></div>
|
| 90 |
<p id="model-error" role="alert" hidden></p>
|
| 91 |
<a id="model-open-tab" class="button" target="_blank" rel="noopener noreferrer" hidden>Open workspace in a new tab ↗</a>
|
| 92 |
<p class="setup-credits"><span>Powered by</span>
|
| 93 |
-
<a href="https://huggingface.co/
|
| 94 |
-
<a href="https://
|
| 95 |
-
<a href="https://github.com/earendil-works/pi" target="_blank" rel="noopener noreferrer" title="Pi agent toolkit (opens in a new tab)"><svg class="credit-pi" viewBox="165.29 165.29 469.43 469.43" width="14" height="14" aria-hidden="true" focusable="false"><path fill="currentColor" fill-rule="evenodd" d="M165.29 165.29H517.36V400H400V517.36H282.65V634.72H165.29ZM282.65 282.65V400H400V282.65Z"/><path fill="currentColor" d="M517.36 400H634.72V634.72H517.36Z"/></svg>Pi</a>
|
| 96 |
<a href="https://github.com/vercel-labs/just-bash" target="_blank" rel="noopener noreferrer" title="just-bash browser shell (opens in a new tab)"><svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><rect x="2.5" y="4" width="19" height="16" rx="3"/><path d="m6 9 3 3-3 3m7 0h5"/></svg>just-bash</a>
|
| 97 |
</p>
|
| 98 |
</div>
|
|
|
|
| 10 |
<meta property="og:type" content="website">
|
| 11 |
<meta property="og:title" content="Pi, entirely in your browser.">
|
| 12 |
<meta property="og:description" content="MiniCPM5-2B, WebGPU inference, and a local shell. A coding agent running entirely in your browser.">
|
| 13 |
+
<meta property="og:image" content="https://huggingface.co/spaces/victor/MiniCPM5-2B-WebGPU-Pi/resolve/main/social-thumbnail-v3.png">
|
|
|
|
| 14 |
<meta property="og:image:width" content="3136">
|
| 15 |
<meta property="og:image:height" content="1984">
|
| 16 |
<meta property="og:image:alt" content="A coding agent running entirely in your browser, above the Pi terminal and its pixel cat mascot.">
|
| 17 |
<meta name="twitter:card" content="summary_large_image">
|
| 18 |
<meta name="twitter:title" content="Pi, entirely in your browser.">
|
| 19 |
<meta name="twitter:description" content="MiniCPM5-2B, WebGPU inference, and a local shell. A coding agent running entirely in your browser.">
|
| 20 |
+
<meta name="twitter:image" content="https://huggingface.co/spaces/victor/MiniCPM5-2B-WebGPU-Pi/resolve/main/social-thumbnail-v3.png">
|
| 21 |
<meta name="twitter:image:alt" content="A coding agent running entirely in your browser, above the Pi terminal and its pixel cat mascot.">
|
| 22 |
<title>Pi · Coding agent in your browser</title>
|
| 23 |
<script type="module" crossorigin src="./assets/index-DnXFGQ8z.js"></script>
|
|
|
|
| 36 |
<span class="sr-only" role="status" aria-live="polite"></span>
|
| 37 |
</div>
|
| 38 |
<div class="model-panel">
|
| 39 |
+
<div class="model-heading"><div><a class="header-model-source" href="https://huggingface.co/openbmb/MiniCPM5-2B-GGUF" target="_blank" rel="noopener noreferrer" aria-label="MiniCPM5-2B by OpenBMB on Hugging Face (opens in a new tab)" title="View MiniCPM5-2B GGUF weights on Hugging Face"><img src="./openbmb-avatar.webp" width="16" height="16" alt=""><strong>MiniCPM5-2B</strong></a><span class="badge">4-bit</span></div><button id="load" class="primary" aria-describedby="load-status load-detail">Load model</button></div>
|
| 40 |
+
<p id="load-status" class="sr-only" role="status">First download: about 1.6 GB (Q4_K_M). Browser caching is best effort.</p>
|
| 41 |
<progress id="progress" hidden max="100" value="0" aria-label="Model download"></progress>
|
| 42 |
<div id="load-detail" class="sr-only"></div>
|
| 43 |
</div>
|
|
|
|
| 80 |
<div class="setup-body">
|
| 81 |
<p id="model-description">Everything happens in your browser. Your prompts and files stay on this device.</p>
|
| 82 |
<dl class="setup-runtime">
|
| 83 |
+
<div><dt>Model</dt><dd class="setup-model"><a class="model-org" href="https://huggingface.co/openbmb" target="_blank" rel="noopener noreferrer" aria-label="OpenBMB on Hugging Face (opens in a new tab)" title="OpenBMB on Hugging Face"><img src="./openbmb-avatar.webp" width="18" height="18" alt=""></a><a class="model-source" href="https://huggingface.co/openbmb/MiniCPM5-2B-GGUF" target="_blank" rel="noopener noreferrer" title="View MiniCPM5-2B GGUF weights on Hugging Face">MiniCPM5-2B</a></dd></div>
|
| 84 |
<div><dt>Inference</dt><dd>On your GPU</dd></div>
|
| 85 |
<div><dt>Shell</dt><dd>Bash in the browser</dd></div>
|
| 86 |
</dl>
|
| 87 |
+
<div class="setup-facts"><strong>About 1.6 GB on the first download</strong><span>Your browser may cache weights for faster starts.</span></div>
|
| 88 |
<p class="setup-label">Your message</p><div id="model-preview"></div>
|
| 89 |
<p id="model-error" role="alert" hidden></p>
|
| 90 |
<a id="model-open-tab" class="button" target="_blank" rel="noopener noreferrer" hidden>Open workspace in a new tab ↗</a>
|
| 91 |
<p class="setup-credits"><span>Powered by</span>
|
| 92 |
+
<a href="https://huggingface.co/openbmb/MiniCPM5-2B-GGUF" target="_blank" rel="noopener noreferrer" title="MiniCPM5 GGUF weights for local WebGPU inference (opens in a new tab)"><img src="./library-icons/huggingface.svg" width="14" height="14" alt="">GGUF · WebGPU</a>
|
| 93 |
+
<a href="https://github.com/earendil-works/pi" target="_blank" rel="noopener noreferrer" title="Pi terminal UI (opens in a new tab)"><svg class="credit-pi" viewBox="165.29 165.29 469.43 469.43" width="14" height="14" aria-hidden="true" focusable="false"><path fill="currentColor" fill-rule="evenodd" d="M165.29 165.29H517.36V400H400V517.36H282.65V634.72H165.29ZM282.65 282.65V400H400V282.65Z"/><path fill="currentColor" d="M517.36 400H634.72V634.72H517.36Z"/></svg>Pi</a>
|
|
|
|
| 94 |
<a href="https://github.com/vercel-labs/just-bash" target="_blank" rel="noopener noreferrer" title="just-bash browser shell (opens in a new tab)"><svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><rect x="2.5" y="4" width="19" height="16" rx="3"/><path d="m6 9 3 3-3 3m7 0h5"/></svg>just-bash</a>
|
| 95 |
</p>
|
| 96 |
</div>
|
package.json
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"name": "minicpm5-webgpu-workspace",
|
| 3 |
+
"version": "1.0.0",
|
| 4 |
+
"private": true,
|
| 5 |
+
"type": "module",
|
| 6 |
+
"description": "The MiniCPM5 Pi browser workspace with the supplied GGUF WebGPU runtime and agent.",
|
| 7 |
+
"scripts": {
|
| 8 |
+
"dev": "node scripts/serve.mjs",
|
| 9 |
+
"start": "node scripts/serve.mjs",
|
| 10 |
+
"test": "node --test test/*.test.js",
|
| 11 |
+
"build": "node scripts/build.mjs"
|
| 12 |
+
},
|
| 13 |
+
"engines": { "node": ">=20" },
|
| 14 |
+
"license": "Apache-2.0"
|
| 15 |
+
}
|
runtime/agent.js
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// The agent loop for the WebGPU chat bundles. Import { Agent, runAgent } from this file.
|
| 2 |
+
var I="The run was stopped before this call ran.",D="The run was stopped while this call ran; it may have partly completed.",F=n=>n instanceof Error?n.message:String(n);function N(){return{prompt_tokens:0,cached_tokens:0,image_tokens:0,completion_tokens:0,reasoning_tokens:0}}function v(n){let e=new Set;return n.map(t=>{if(typeof t?.name!="string"||t.name===""||typeof t.execute!="function")throw new TypeError("each tool must be { name, description?, parameters?, execute(args, context) }");if(e.has(t.name))throw new TypeError(`tool "${t.name}" is offered twice`);return e.add(t.name),{type:"function",function:{name:t.name,...t.description!==void 0?{description:t.description}:{},parameters:t.parameters??{type:"object",properties:{}}}}})}var C=class{#t=[];#s=!1;#n=!1;#r=null;#e=null;#o=null;#a;constructor(e){this.#a=e}attach(){if(this.#s)throw new TypeError("an agent run's events can be iterated once");return this.#s=!0,this.#n=!0,this}emit(e){this.#o||(this.#t.push(e),this.#r?.())}handled(){return!this.#n||this.#t.length===0&&this.#r?Promise.resolve():new Promise(e=>{this.#e=e})}end(e){this.#o=e??{},this.#r?.(),this.#e?.()}async next(){for(;;){let e=this.#t.shift();if(e)return{value:e,done:!1};if(this.#o){if("error"in this.#o)throw this.#o.error;return{value:void 0,done:!0}}await new Promise(t=>{this.#r=t,this.#e?.(),this.#e=null}),this.#r=null}}async return(){return this.#n=!1,this.#e?.(),this.#e=null,this.#o||this.#a(),{value:void 0,done:!0}}},p=class{result;#t=new AbortController;#s=new C(()=>this.cancel());constructor(e,t){let l=()=>{};if(t)if(t.aborted)this.#t.abort(t.reason);else{let s=()=>this.#t.abort(t.reason);t.addEventListener("abort",s,{once:!0}),l=()=>t.removeEventListener("abort",s)}let a=this.#s;this.result=e({emit:s=>a.emit(s),handled:()=>a.handled(),signal:this.#t.signal}).then(s=>(a.end(null),s),s=>{throw a.end({error:s}),s}).finally(l),this.result.catch(()=>{})}cancel(){this.#t.abort(new Error("The agent run was cancelled."))}[Symbol.asyncIterator](){return this.#s.attach()}};function $(n,e,t={}){return O(n,e,t,()=>{})}function O(n,e,t,l){let{tools:a=[],signal:s,max_rounds:g=8,...i}=t;if(!Number.isSafeInteger(g)||g<1)throw new RangeError("max_rounds must be a positive integer");let d=v(a),P=new Map(a.map(r=>[r.name,r])),k=[...e];return new p(async r=>{let A=[],w=o=>{A.push(o),l(o)},T=N(),U=new Map,x="",h=(o,m,u)=>({finish_reason:o,...u?{limit:u}:{},messages:A,content:x,usage:T,rounds:m});for(let o=0;o<g;o++){if(r.signal.aborted)return h("cancelled",o);r.emit({type:"turn_start",round:o});let m=n.generate([...k,...A],{...i,...d.length>0?{tools:d}:{},signal:r.signal});for await(let c of m)r.emit({...c,round:o});let u=await m.result;for(let c of Object.keys(T))T[c]+=u.usage[c]??0;if(u.finish_reason==="cancelled")return r.emit({type:"turn_end",round:o,result:u}),h("cancelled",o+1);if(w(u.message),x=u.message.content,r.emit({type:"turn_end",round:o,result:u}),await r.handled(),u.finish_reason!=="tool_calls")return h(u.finish_reason,o+1,u.limit);let b=u.message.tool_calls??[];for(let[c,y]of b.entries()){if(r.signal.aborted){for(let E of b.slice(c)){let S=M(E,I);w(S),r.emit({type:"tool_end",round:o,call:E,message:S,is_error:!0})}return h("cancelled",o+1)}r.emit({type:"tool_start",round:o,call:y});let f=await L(y,P,U,r.signal),R=M(y,f.content);w(R),r.emit({type:"tool_end",round:o,call:y,message:R,is_error:f.is_error,...f.details!==void 0?{details:f.details}:{}}),await r.handled()}}return h(r.signal.aborted?"cancelled":"max_rounds",g)},s)}function M(n,e){return{role:"tool",tool_call_id:n.id,name:n.function.name,content:e}}async function L(n,e,t,l){if(n.errors?.length)return{content:n.errors.join("; "),is_error:!0};let a=e.get(n.function.name);if(!a)return{content:`No tool named "${n.function.name}" was offered.`,is_error:!0};let s=`${n.function.name} ${JSON.stringify(n.function.arguments)}`,g=t.get(s);if(g!==void 0)return{content:`This exact call already failed: ${g}
|
| 3 |
+
Change the call, or tell the user what went wrong.`,is_error:!0};try{let i=await a.execute(n.function.arguments,{signal:l,call:n})??"";if(t.clear(),typeof i=="string"||Array.isArray(i))return{content:i,is_error:!1};let d=i;return{content:d.content,is_error:!1,...d.details!==void 0?{details:d.details}:{}}}catch(i){if(l.aborted)return{content:D,is_error:!0};let d=F(i);return t.set(s,d),{content:d,is_error:!0}}}var _=class{messages;#t;#s;#n=null;#r=!1;#e=null;constructor({model:e,instructions:t,messages:l=[],...a}){this.#t=e,this.#s=a,this.messages=[...t!==void 0?[{role:"system",content:t}]:[],...l]}get#o(){return this.#n?this.#n:("create_session"in this.#t?(this.#n=this.#t.create_session(),this.#r=!0):this.#n=this.#t,this.#n)}get running(){return this.#e!==null}run(e,t={}){if(this.#e)throw new Error("This agent is already running; cancel the run or wait for it.");let l=typeof e=="string"||Array.isArray(e)?{role:"user",content:e}:e,a=!1,s=O(this.#o,[...this.messages,l],{...this.#s,...t},i=>{a=!0,this.messages.push(i)});this.messages.push(l),this.#e=s;let g=()=>{this.#e=null;let i=this.messages.lastIndexOf(l);!a&&i>=0&&this.messages.splice(i,1)};return s.result.then(g,g),s}async prime(e={}){if(this.#e)throw new Error("This agent is already running.");let t=this.messages[0]?.role==="system"?[this.messages[0]]:[],{tools:l=[],max_rounds:a,...s}=this.#s,g=v(l),i=this.#o.generate([...t,{role:"user",content:""}],{...s,...g.length>0?{tools:g}:{},max_new_tokens:1,...e.signal?{signal:e.signal}:{}});for await(let d of i);await i.result}reset(){if(this.#e)throw new Error("This agent is already running.");this.messages.splice(this.messages[0]?.role==="system"?1:0)}async dispose(){let e=this.#e;e&&(e.cancel(),await e.result.catch(()=>{})),this.#r&&await this.#n?.dispose?.(),this.#n=null}};export{_ as Agent,p as AgentRun,v as agentToolSchemas,$ as runAgent};
|
runtime/minicpm5.js
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
scripts/build.mjs
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { cp, mkdir, readFile, rm, stat } from 'node:fs/promises';
|
| 2 |
+
import { fileURLToPath } from 'node:url';
|
| 3 |
+
import path from 'node:path';
|
| 4 |
+
|
| 5 |
+
const root = fileURLToPath(new URL('../', import.meta.url));
|
| 6 |
+
const output = path.join(root, 'dist');
|
| 7 |
+
const directories = ['assets', 'src', 'runtime', 'vendor', 'file-icons', 'library-icons', 'licenses'];
|
| 8 |
+
const files = ['index.html', 'favicon.svg', 'empty-cat-v1.png', 'modal-cat-v1.png',
|
| 9 |
+
'openbmb-avatar.webp', 'cat-meow-v1.mp3', 'social-thumbnail-v3.png', 'LICENSE', 'NOTICE'];
|
| 10 |
+
|
| 11 |
+
// Catch missing LFS downloads before packaging what would look like broken images.
|
| 12 |
+
for (const name of ['empty-cat-v1.png', 'modal-cat-v1.png', 'social-thumbnail-v3.png']) {
|
| 13 |
+
const file = await readFile(path.join(root, name));
|
| 14 |
+
if (file.subarray(0, 42).toString().startsWith('version https://git-lfs.github.com/spec/')) {
|
| 15 |
+
throw new Error(`${name} is a Git LFS pointer. Run git lfs pull before building.`);
|
| 16 |
+
}
|
| 17 |
+
}
|
| 18 |
+
await rm(output, { recursive: true, force: true });
|
| 19 |
+
await mkdir(output);
|
| 20 |
+
for (const item of [...directories, ...files]) {
|
| 21 |
+
await stat(path.join(root, item));
|
| 22 |
+
await cp(path.join(root, item), path.join(output, item), { recursive: true });
|
| 23 |
+
}
|
| 24 |
+
console.log('Built dist/ — static files ready for localhost or a Hugging Face static Space.');
|
scripts/serve.mjs
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { createServer } from 'node:http';
|
| 2 |
+
import { readFile, stat } from 'node:fs/promises';
|
| 3 |
+
import { fileURLToPath } from 'node:url';
|
| 4 |
+
import path from 'node:path';
|
| 5 |
+
|
| 6 |
+
const root = fileURLToPath(new URL('../', import.meta.url));
|
| 7 |
+
const args = process.argv.slice(2);
|
| 8 |
+
function option(name, fallback) {
|
| 9 |
+
const index = args.indexOf(name);
|
| 10 |
+
return index >= 0 ? args[index + 1] : fallback;
|
| 11 |
+
}
|
| 12 |
+
const host = option('--host', '127.0.0.1');
|
| 13 |
+
const port = Number(option('--port', '5173'));
|
| 14 |
+
const types = { '.html': 'text/html; charset=utf-8', '.js': 'text/javascript; charset=utf-8',
|
| 15 |
+
'.mjs': 'text/javascript; charset=utf-8', '.css': 'text/css; charset=utf-8', '.json': 'application/json',
|
| 16 |
+
'.svg': 'image/svg+xml', '.png': 'image/png', '.webp': 'image/webp', '.ico': 'image/x-icon', '.mp3': 'audio/mpeg' };
|
| 17 |
+
|
| 18 |
+
createServer(async (request, response) => {
|
| 19 |
+
try {
|
| 20 |
+
if (!['GET', 'HEAD'].includes(request.method)) { response.writeHead(405).end(); return; }
|
| 21 |
+
const pathname = decodeURIComponent(new URL(request.url, 'http://localhost').pathname);
|
| 22 |
+
if (pathname.split('/').some(part => part.startsWith('.'))) { response.writeHead(403).end(); return; }
|
| 23 |
+
let filename = path.resolve(root, '.' + pathname);
|
| 24 |
+
if (!filename.startsWith(root)) { response.writeHead(403).end(); return; }
|
| 25 |
+
if ((await stat(filename)).isDirectory()) filename = path.join(filename, 'index.html');
|
| 26 |
+
const content = await readFile(filename);
|
| 27 |
+
response.writeHead(200, { 'Content-Type': types[path.extname(filename)] ?? 'application/octet-stream',
|
| 28 |
+
'Cache-Control': 'no-cache', 'X-Content-Type-Options': 'nosniff' });
|
| 29 |
+
response.end(request.method === 'HEAD' ? undefined : content);
|
| 30 |
+
} catch { response.writeHead(404).end('Not found'); }
|
| 31 |
+
}).listen(port, host, () => console.log(`Workspace: http://${host}:${port}`));
|
src/agent.worker.js
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { MiniCPM5ForCausalLM } from '../runtime/minicpm5.js';
|
| 2 |
+
import { WorkspaceController } from './controller.js';
|
| 3 |
+
import { createStore, createWorkspace } from './workspace.js';
|
| 4 |
+
|
| 5 |
+
const emit = message => postMessage(message);
|
| 6 |
+
const initialized = (async () => {
|
| 7 |
+
let store, saved;
|
| 8 |
+
try { store = await createStore(); saved = await store.load(); }
|
| 9 |
+
catch (error) { emit({ type: 'persistence_error', error: `Workspace saving is unavailable: ${error.message}` }); }
|
| 10 |
+
const workspace = createWorkspace(saved?.files, saved?.entries);
|
| 11 |
+
await workspace.ready;
|
| 12 |
+
const controller = new WorkspaceController({ Model: MiniCPM5ForCausalLM, workspace, store, saved, emit });
|
| 13 |
+
emit({ type: 'initialized', ...(await controller.uiSnapshot()) });
|
| 14 |
+
return controller;
|
| 15 |
+
})();
|
| 16 |
+
|
| 17 |
+
// Attach a rejection handler immediately, including failures before the first RPC.
|
| 18 |
+
initialized.catch(error => emit({ type: 'fatal', stage: 'initialization', error: `Workspace initialization failed: ${error.message}. Reload the page to retry.` }));
|
| 19 |
+
|
| 20 |
+
self.onmessage = async ({ data: { id, action, args = {} } }) => {
|
| 21 |
+
try {
|
| 22 |
+
const controller = await initialized;
|
| 23 |
+
const value = await controller.dispatch(action, args);
|
| 24 |
+
emit({ type: 'reply', id, value });
|
| 25 |
+
} catch (error) {
|
| 26 |
+
emit({ type: 'reply', id, error: error.name === 'AbortError' ? 'Stopped.' : String(error.message ?? error), errorName: error.name, errorCode: error.code });
|
| 27 |
+
}
|
| 28 |
+
};
|
src/config.js
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// Keep the tool definitions and instructions stable to reuse the session's KV cache.
|
| 2 |
+
export const MODEL_ID = 'openbmb/MiniCPM5-2B-GGUF';
|
| 3 |
+
export const MODEL_FILE = 'MiniCPM5-2B-Q4_K_M.gguf';
|
| 4 |
+
export const CONTEXT_LENGTH = 8192;
|
| 5 |
+
export const MAX_NEW_TOKENS = 4096;
|
| 6 |
+
export const MAX_ROUNDS = 12;
|
| 7 |
+
|
| 8 |
+
export const INSTRUCTIONS = `You are Pi, a coding assistant powered by MiniCPM5-2B from OpenBMB.
|
| 9 |
+
The model, tools, and workspace run entirely in the user's browser. Workspace files are virtual files stored in this browser, not files on the user's operating system.
|
| 10 |
+
Use tools to inspect and change actual workspace files. Never claim a command ran or a file changed without a successful tool result. When the user explicitly asks for a tool, use it.
|
| 11 |
+
Working directory: /workspace. Tools: read, write, edit, bash. Keep calls and answers concise.
|
| 12 |
+
Bash supports cat, ls, find, grep, sed, awk, jq, sort, wc, printf, pipes, redirects, and shell scripts. Use jq for JSON and awk for CSV and calculations.
|
| 13 |
+
Python, Node.js, npm, native binaries, and network commands are unavailable. You may write HTML/CSS/JavaScript with external URLs, but cannot fetch or test those URLs in the shell.
|
| 14 |
+
Interpret follow-ups using the conversation. Inspect files before editing. Verify changes with a tool, then give a short factual answer.`;
|
src/controller.js
ADDED
|
@@ -0,0 +1,231 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { Agent } from '../runtime/agent.js';
|
| 2 |
+
import { CONTEXT_LENGTH, INSTRUCTIONS, MAX_NEW_TOKENS, MAX_ROUNDS, MODEL_FILE, MODEL_ID } from './config.js';
|
| 3 |
+
import { appendDelta, assistantMessage, loadProgress, timestamp, toolMessage } from './conversation.js';
|
| 4 |
+
|
| 5 |
+
export class WorkspaceController {
|
| 6 |
+
constructor({ Model, workspace, store, saved, emit, AgentClass = Agent }) {
|
| 7 |
+
Object.assign(this, { Model, workspace, store, emit, AgentClass });
|
| 8 |
+
this.history = saved?.messages ?? [];
|
| 9 |
+
this.display = saved?.displayMessages ?? [];
|
| 10 |
+
this.model = null;
|
| 11 |
+
this.agent = null;
|
| 12 |
+
this.busy = false;
|
| 13 |
+
this.operation = null;
|
| 14 |
+
this.run = null;
|
| 15 |
+
this.deviceLoss = null;
|
| 16 |
+
}
|
| 17 |
+
|
| 18 |
+
async snapshot() {
|
| 19 |
+
return {
|
| 20 |
+
version: 2, runtime: 'minicpm5-gguf',
|
| 21 |
+
files: await this.workspace.snapshot(), entries: await this.workspace.serialize(),
|
| 22 |
+
messages: this.agent?.messages.filter(message => message.role !== 'system') ?? this.history,
|
| 23 |
+
displayMessages: this.display,
|
| 24 |
+
};
|
| 25 |
+
}
|
| 26 |
+
|
| 27 |
+
// The frontend receives Pi display messages; persistence keeps canonical runtime messages too.
|
| 28 |
+
async uiSnapshot() {
|
| 29 |
+
return { ...(await this.snapshot()), messages: this.display };
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
async save() {
|
| 33 |
+
const snapshot = await this.snapshot();
|
| 34 |
+
this.history = snapshot.messages;
|
| 35 |
+
try { await this.store?.save(snapshot); }
|
| 36 |
+
catch (error) { this.emit({ type: 'persistence_error', error: `Changes are in memory, but saving failed: ${error.message}` }); }
|
| 37 |
+
this.emit({ type: 'workspace', files: snapshot.files });
|
| 38 |
+
}
|
| 39 |
+
|
| 40 |
+
event(event) { this.emit({ type: 'agent_event', event }); }
|
| 41 |
+
|
| 42 |
+
async load({ cachedOnly = false } = {}, signal) {
|
| 43 |
+
if (this.model) return this.model.deviceInfo();
|
| 44 |
+
// The supplied runtime has no public complete-cache/cache-only API. Never promise
|
| 45 |
+
// a network-free load by inspecting its private cache or by trusting a stale flag.
|
| 46 |
+
if (cachedOnly) {
|
| 47 |
+
const error = new Error('Confirm loading the model; cached ranges will be reused when available.');
|
| 48 |
+
error.code = 'MODEL_CACHE_MISS';
|
| 49 |
+
throw error;
|
| 50 |
+
}
|
| 51 |
+
const support = await this.Model.checkAvailability(MODEL_ID, { file: MODEL_FILE, signal });
|
| 52 |
+
signal.throwIfAborted();
|
| 53 |
+
if (!support.ok) throw new Error(support.reason || 'This GPU cannot run the model.');
|
| 54 |
+
let model;
|
| 55 |
+
try {
|
| 56 |
+
model = await this.Model.from_pretrained(MODEL_ID, {
|
| 57 |
+
file: MODEL_FILE, maxLength: CONTEXT_LENGTH, signal,
|
| 58 |
+
onProgress: progress => this.emit(loadProgress(progress)),
|
| 59 |
+
});
|
| 60 |
+
signal.throwIfAborted();
|
| 61 |
+
this.model = model;
|
| 62 |
+
this.agent = new this.AgentClass({
|
| 63 |
+
model, instructions: INSTRUCTIONS, tools: this.workspace.tools, messages: this.history,
|
| 64 |
+
max_new_tokens: MAX_NEW_TOKENS, max_rounds: MAX_ROUNDS,
|
| 65 |
+
});
|
| 66 |
+
// Reset and reload share one agent/session; Agent owns its session, not the model.
|
| 67 |
+
const lostModel = model;
|
| 68 |
+
model.lost?.then(async info => {
|
| 69 |
+
if (this.model !== lostModel) return;
|
| 70 |
+
this.deviceLoss = new Error(`GPU device lost${info?.message ? ': ' + info.message : '.'} Load the model again to continue.`);
|
| 71 |
+
this.operation?.abort(this.deviceLoss);
|
| 72 |
+
this.run?.cancel();
|
| 73 |
+
this.emit({ type: 'fatal', error: this.deviceLoss.message });
|
| 74 |
+
// Cleanup waits for generation and any tool to settle before releasing buffers.
|
| 75 |
+
const oldAgent = this.agent;
|
| 76 |
+
this.model = null;
|
| 77 |
+
this.agent = null;
|
| 78 |
+
await oldAgent?.dispose();
|
| 79 |
+
await lostModel.dispose();
|
| 80 |
+
}).catch(error => this.emit({ type: 'notice', text: `GPU cleanup: ${error.message}` }));
|
| 81 |
+
this.deviceLoss = null;
|
| 82 |
+
const device = model.deviceInfo();
|
| 83 |
+
this.emit({ type: 'device', device });
|
| 84 |
+
this.emit({ type: 'loaded', device, contextLength: model.contextLength, cache: 'browser' });
|
| 85 |
+
return device;
|
| 86 |
+
} catch (error) {
|
| 87 |
+
if (this.model === model) { this.model = null; this.agent = null; }
|
| 88 |
+
await model?.dispose();
|
| 89 |
+
throw error;
|
| 90 |
+
}
|
| 91 |
+
}
|
| 92 |
+
|
| 93 |
+
async prompt(text, signal) {
|
| 94 |
+
if (!this.agent) throw new Error('Load the model first.');
|
| 95 |
+
const agent = this.agent;
|
| 96 |
+
const user = { role: 'user', content: text, timestamp: timestamp() };
|
| 97 |
+
this.display.push(user);
|
| 98 |
+
this.event({ type: 'agent_start' });
|
| 99 |
+
this.event({ type: 'message_end', message: user });
|
| 100 |
+
let partial;
|
| 101 |
+
let roundStarted;
|
| 102 |
+
let lastPaint = 0;
|
| 103 |
+
let firstDelta;
|
| 104 |
+
let run;
|
| 105 |
+
try {
|
| 106 |
+
run = this.run = agent.run(text, { signal });
|
| 107 |
+
for await (const event of run) {
|
| 108 |
+
if (event.type === 'turn_start') {
|
| 109 |
+
roundStarted = performance.now();
|
| 110 |
+
firstDelta = undefined;
|
| 111 |
+
partial = assistantMessage();
|
| 112 |
+
this.display.push(partial);
|
| 113 |
+
this.event({ type: 'turn_start' });
|
| 114 |
+
this.event({ type: 'message_start', message: partial });
|
| 115 |
+
this.emit({ type: 'inference_activity', phase: 'prefill' });
|
| 116 |
+
} else if (event.type === 'prefill') {
|
| 117 |
+
this.emit({ type: 'inference_activity', phase: 'prefill', done: event.done, total: event.total });
|
| 118 |
+
} else if (event.type === 'text' || event.type === 'reasoning') {
|
| 119 |
+
const key = appendDelta(partial, event.type, event.text);
|
| 120 |
+
firstDelta ??= performance.now() - roundStarted;
|
| 121 |
+
// Delta events carry text, not token counts. Show activity live; exact token
|
| 122 |
+
// speed comes from the runtime's timings when the turn completes.
|
| 123 |
+
const now = performance.now();
|
| 124 |
+
if (now - lastPaint >= 60) {
|
| 125 |
+
this.event({ type: 'message_update', message: partial, assistantMessageEvent: { type: `${key}_delta`, delta: event.text, partial } });
|
| 126 |
+
this.emit({ type: 'inference_activity', phase: 'decode', rate: null });
|
| 127 |
+
lastPaint = now;
|
| 128 |
+
}
|
| 129 |
+
} else if (event.type === 'turn_end') {
|
| 130 |
+
const { result } = event;
|
| 131 |
+
const complete = assistantMessage(result.message, result, partial.timestamp);
|
| 132 |
+
this.display[this.display.indexOf(partial)] = complete;
|
| 133 |
+
partial = complete;
|
| 134 |
+
this.event({ type: 'message_end', message: complete });
|
| 135 |
+
this.event({ type: 'turn_end', message: complete, toolResults: [] });
|
| 136 |
+
this.emit({ type: 'context_usage', inputTokens: result.usage.prompt_tokens, outputTokens: result.usage.completion_tokens });
|
| 137 |
+
this.emit({ type: 'generation', inputTokens: result.usage.prompt_tokens, outputTokens: result.usage.completion_tokens,
|
| 138 |
+
cachedTokens: result.usage.cached_tokens, elapsedMs: performance.now() - roundStarted,
|
| 139 |
+
firstTokenMs: result.timings?.ttft_ms ?? firstDelta, tokensPerSecond: result.timings?.tokens_per_second,
|
| 140 |
+
stopReason: complete.stopReason, sampling: { do_sample: false } });
|
| 141 |
+
if (result.timings?.tokens_per_second > 0) this.emit({ type: 'inference_activity', phase: 'decode', rate: result.timings.tokens_per_second });
|
| 142 |
+
await this.save();
|
| 143 |
+
} else if (event.type === 'tool_start') {
|
| 144 |
+
this.event({ type: 'tool_execution_start', toolCallId: event.call.id,
|
| 145 |
+
toolName: event.call.function.name, args: event.call.function.arguments });
|
| 146 |
+
} else if (event.type === 'tool_end') {
|
| 147 |
+
const message = toolMessage(event);
|
| 148 |
+
this.display.push(message);
|
| 149 |
+
this.event({ type: 'message_end', message });
|
| 150 |
+
this.event({ type: 'tool_execution_end', toolCallId: event.call.id, toolName: event.call.function.name,
|
| 151 |
+
result: { content: message.content, details: event.details }, isError: event.is_error });
|
| 152 |
+
await this.save();
|
| 153 |
+
}
|
| 154 |
+
}
|
| 155 |
+
const result = await run.result;
|
| 156 |
+
if (result.finish_reason === 'max_rounds') this.emit({ type: 'notice', text: `Paused after ${MAX_ROUNDS} agent turns. Send a follow-up to continue.` });
|
| 157 |
+
if (result.finish_reason === 'length') this.emit({ type: 'notice', text: result.limit === 'context'
|
| 158 |
+
? 'The model context is full. Start a new chat or shorten the conversation.'
|
| 159 |
+
: 'The reply reached its token limit. Send a follow-up to continue.' });
|
| 160 |
+
if (result.finish_reason === 'cancelled') this.emit({ type: 'notice', text: 'Stopped. The interrupted reply is shown here; it is omitted from the model history.' });
|
| 161 |
+
} catch (error) {
|
| 162 |
+
// Iteration and run.result reject with the same error. Keep completed history,
|
| 163 |
+
// end the visible partial reply, and surface the failure through the RPC.
|
| 164 |
+
await run?.result.catch(() => {});
|
| 165 |
+
if (partial && !partial.stopReason) {
|
| 166 |
+
partial.stopReason = signal.aborted ? 'aborted' : 'error';
|
| 167 |
+
partial.errorMessage = error.message;
|
| 168 |
+
this.event({ type: 'message_end', message: partial });
|
| 169 |
+
}
|
| 170 |
+
if (error.name === 'ContextOverflowError') {
|
| 171 |
+
this.emit({ type: 'notice', text: `The conversation needs ${error.required} tokens; this GPU has room for ${error.capacity}. Start a new chat or shorten the conversation.` });
|
| 172 |
+
}
|
| 173 |
+
throw error;
|
| 174 |
+
} finally {
|
| 175 |
+
this.run = null;
|
| 176 |
+
// Capture from the local agent even if device-loss cleanup detached it.
|
| 177 |
+
this.history = agent.messages.filter(message => message.role !== 'system');
|
| 178 |
+
await this.save();
|
| 179 |
+
this.emit({ type: 'inference_activity', phase: 'end' });
|
| 180 |
+
this.event({ type: 'agent_end', messages: this.display });
|
| 181 |
+
}
|
| 182 |
+
return this.uiSnapshot();
|
| 183 |
+
}
|
| 184 |
+
|
| 185 |
+
async dispatch(action, args = {}) {
|
| 186 |
+
if (action === 'stop') { this.operation?.abort(new DOMException('Stopped.', 'AbortError')); this.run?.cancel(); return true; }
|
| 187 |
+
if (action === 'snapshot') {
|
| 188 |
+
// Export native messages alongside display messages so exports are restorable.
|
| 189 |
+
if (this.busy) throw new Error('Wait for the current operation or stop it before exporting.');
|
| 190 |
+
return this.snapshot();
|
| 191 |
+
}
|
| 192 |
+
if (action === 'cache_status') return false;
|
| 193 |
+
if (this.busy) throw new Error('Wait for the current operation or stop it first.');
|
| 194 |
+
this.busy = true;
|
| 195 |
+
this.operation = new AbortController();
|
| 196 |
+
this.emit({ type: 'busy', action, cachedOnly: args.cachedOnly === true });
|
| 197 |
+
try {
|
| 198 |
+
const signal = this.operation.signal;
|
| 199 |
+
if (action === 'load') return await this.load(args, signal);
|
| 200 |
+
if (action === 'prompt') return await this.prompt(args.text, signal);
|
| 201 |
+
if (action === 'shell') {
|
| 202 |
+
const result = await this.workspace.bash.exec(args.command, { signal });
|
| 203 |
+
await this.save();
|
| 204 |
+
return result;
|
| 205 |
+
}
|
| 206 |
+
if (action === 'write') { await this.workspace.write(args.path, args.content); await this.save(); return this.uiSnapshot(); }
|
| 207 |
+
if (action === 'read') return await this.workspace.read(args.path);
|
| 208 |
+
if (action === 'new_chat') {
|
| 209 |
+
this.agent?.reset();
|
| 210 |
+
this.history = [];
|
| 211 |
+
this.display = [];
|
| 212 |
+
await this.save();
|
| 213 |
+
return this.uiSnapshot();
|
| 214 |
+
}
|
| 215 |
+
throw new Error(`Unknown action: ${action}`);
|
| 216 |
+
} finally {
|
| 217 |
+
this.busy = false;
|
| 218 |
+
this.operation = null;
|
| 219 |
+
this.emit({ type: 'idle' });
|
| 220 |
+
}
|
| 221 |
+
}
|
| 222 |
+
|
| 223 |
+
async dispose() {
|
| 224 |
+
this.operation?.abort();
|
| 225 |
+
await this.agent?.dispose();
|
| 226 |
+
const model = this.model;
|
| 227 |
+
this.model = null;
|
| 228 |
+
this.agent = null;
|
| 229 |
+
await model?.dispose();
|
| 230 |
+
}
|
| 231 |
+
}
|
src/conversation.js
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// Adapt the supplied Agent's events to the existing Pi terminal's display format.
|
| 2 |
+
// Native messages stay untouched in Agent.messages; these are display objects only.
|
| 3 |
+
let lastTimestamp = 0;
|
| 4 |
+
export function timestamp() {
|
| 5 |
+
lastTimestamp = Math.max(Date.now(), lastTimestamp + 1);
|
| 6 |
+
return lastTimestamp;
|
| 7 |
+
}
|
| 8 |
+
|
| 9 |
+
export function piUsage(usage = {}) {
|
| 10 |
+
const input = usage.prompt_tokens ?? 0;
|
| 11 |
+
const output = usage.completion_tokens ?? 0;
|
| 12 |
+
return {
|
| 13 |
+
input, output, cacheRead: usage.cached_tokens ?? 0, cacheWrite: 0,
|
| 14 |
+
totalTokens: input + output,
|
| 15 |
+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
| 16 |
+
};
|
| 17 |
+
}
|
| 18 |
+
|
| 19 |
+
export function assistantMessage(native = {}, result = {}, at = timestamp()) {
|
| 20 |
+
const content = [];
|
| 21 |
+
if (native.reasoning_content) content.push({ type: 'thinking', thinking: native.reasoning_content });
|
| 22 |
+
if (native.content) content.push({ type: 'text', text: native.content });
|
| 23 |
+
for (const call of native.tool_calls ?? []) {
|
| 24 |
+
content.push({ type: 'toolCall', id: call.id, name: call.function.name, arguments: call.function.arguments });
|
| 25 |
+
}
|
| 26 |
+
const reasons = { stop: 'stop', length: 'length', tool_calls: 'toolUse', cancelled: 'aborted' };
|
| 27 |
+
return {
|
| 28 |
+
role: 'assistant', content, timestamp: at, api: 'webgpu', provider: 'local', model: 'MiniCPM5-2B-GGUF',
|
| 29 |
+
usage: piUsage(result.usage),
|
| 30 |
+
...(result.finish_reason ? { stopReason: reasons[result.finish_reason] ?? 'error' } : {}),
|
| 31 |
+
};
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
export function toolMessage(event) {
|
| 35 |
+
return {
|
| 36 |
+
role: 'toolResult', toolCallId: event.call.id, toolName: event.call.function.name,
|
| 37 |
+
content: [{ type: 'text', text: event.message.content ?? '' }],
|
| 38 |
+
details: event.details, isError: event.is_error, timestamp: timestamp(),
|
| 39 |
+
};
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
export function appendDelta(message, type, text) {
|
| 43 |
+
const key = type === 'reasoning' ? 'thinking' : 'text';
|
| 44 |
+
let block = message.content.find(block => block.type === key);
|
| 45 |
+
if (!block) message.content.push(block = { type: key, [key]: '' });
|
| 46 |
+
block[key] += text;
|
| 47 |
+
return key;
|
| 48 |
+
}
|
| 49 |
+
|
| 50 |
+
export function loadProgress(progress) {
|
| 51 |
+
// Tensor counts are not bytes, even though both have loaded/total fields.
|
| 52 |
+
if (progress.status === 'weights' && progress.kind === 'bytes' && progress.total > 0) {
|
| 53 |
+
return { type: 'load_progress', phase: 'download', loaded: progress.loaded, total: progress.total };
|
| 54 |
+
}
|
| 55 |
+
return { type: 'load_progress', phase: 'compile', status: progress.status, message: progress.message };
|
| 56 |
+
}
|
src/workspace.js
ADDED
|
@@ -0,0 +1,225 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { Bash } from '../vendor/just-bash.js';
|
| 2 |
+
|
| 3 |
+
export const WORKSPACE_DB_NAME = 'minicpm5-gguf-workspace';
|
| 4 |
+
export const WORKSPACE_ROOT = '/workspace';
|
| 5 |
+
export const MAX_EDITOR_BYTES = 1024 * 1024;
|
| 6 |
+
export const EXECUTION_LIMITS = Object.freeze({
|
| 7 |
+
maxExecutionTimeMs: 10_000,
|
| 8 |
+
maxCommandCount: 3_000,
|
| 9 |
+
maxFileSystemBytes: 16 * 1024 * 1024,
|
| 10 |
+
maxOutputSize: 262_144,
|
| 11 |
+
maxSourceBytes: 131_072,
|
| 12 |
+
});
|
| 13 |
+
|
| 14 |
+
export const DEFAULT_FILES = Object.freeze({
|
| 15 |
+
'/workspace/README.md': '# Browser workspace\n\nYour files are saved in this browser.\n',
|
| 16 |
+
'/workspace/sales.csv': 'product,units\nNotebook,12\nPencil,30\nFolder,8\n',
|
| 17 |
+
'/workspace/products.json': JSON.stringify([
|
| 18 |
+
{ product: 'Notebook', price: 5 },
|
| 19 |
+
{ product: 'Pencil', price: 2 },
|
| 20 |
+
{ product: 'Folder', price: 3 },
|
| 21 |
+
{ product: 'Eraser', price: 1.5 },
|
| 22 |
+
{ product: 'Stapler', price: 4.99 },
|
| 23 |
+
{ product: 'Ruler', price: 2.5 },
|
| 24 |
+
], null, 2) + '\n',
|
| 25 |
+
});
|
| 26 |
+
|
| 27 |
+
const encoder = new TextEncoder();
|
| 28 |
+
const generatedRoots = ['/bin', '/usr/bin', '/dev', '/proc'];
|
| 29 |
+
const isGenerated = path => generatedRoots.some(root => path === root || path.startsWith(root + '/'));
|
| 30 |
+
|
| 31 |
+
export function truncateOutput(value, limit = 6_000) {
|
| 32 |
+
return value.length > limit
|
| 33 |
+
? value.slice(0, limit) + '\n[Output truncated at 6,000 characters; use head, grep, or a narrower query.]'
|
| 34 |
+
: value;
|
| 35 |
+
}
|
| 36 |
+
|
| 37 |
+
function result(content, details = {}) {
|
| 38 |
+
return { content: truncateOutput(content), details };
|
| 39 |
+
}
|
| 40 |
+
|
| 41 |
+
function checkAbort(signal) {
|
| 42 |
+
if (signal?.aborted) throw signal.reason ?? new DOMException('The operation was aborted.', 'AbortError');
|
| 43 |
+
}
|
| 44 |
+
|
| 45 |
+
function objectSchema(properties) {
|
| 46 |
+
return { type: 'object', properties, required: Object.keys(properties), additionalProperties: false };
|
| 47 |
+
}
|
| 48 |
+
|
| 49 |
+
function stringSchema(description) {
|
| 50 |
+
return { type: 'string', ...(description ? { description } : {}) };
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
export function bytesToBase64(bytes) {
|
| 54 |
+
let binary = '';
|
| 55 |
+
for (let i = 0; i < bytes.length; i += 32_768) {
|
| 56 |
+
binary += String.fromCharCode(...bytes.subarray(i, i + 32_768));
|
| 57 |
+
}
|
| 58 |
+
return btoa(binary);
|
| 59 |
+
}
|
| 60 |
+
|
| 61 |
+
export function base64ToBytes(base64) {
|
| 62 |
+
return Uint8Array.from(atob(base64), char => char.charCodeAt(0));
|
| 63 |
+
}
|
| 64 |
+
|
| 65 |
+
/** A JavaScript-only shell: no host filesystem, network, Python, or JavaScript command. */
|
| 66 |
+
export function createWorkspace(files = DEFAULT_FILES, entries) {
|
| 67 |
+
const initialFiles = entries
|
| 68 |
+
? Object.fromEntries(entries.filter(([, entry]) => entry.type === 'file').map(([path, entry]) => [
|
| 69 |
+
path, { mode: entry.mode, content: base64ToBytes(entry.base64) },
|
| 70 |
+
]))
|
| 71 |
+
: files;
|
| 72 |
+
const bash = new Bash({
|
| 73 |
+
files: initialFiles,
|
| 74 |
+
cwd: WORKSPACE_ROOT,
|
| 75 |
+
executionLimitProfile: 'hardened',
|
| 76 |
+
executionLimits: { ...EXECUTION_LIMITS },
|
| 77 |
+
});
|
| 78 |
+
const resolve = path => bash.fs.resolvePath(WORKSPACE_ROOT, path);
|
| 79 |
+
const ready = (async () => {
|
| 80 |
+
// File construction creates parents; restore their original modes afterward.
|
| 81 |
+
for (const [path, entry] of entries ?? []) {
|
| 82 |
+
if (entry.type === 'directory') {
|
| 83 |
+
await bash.fs.mkdir(path, { recursive: true });
|
| 84 |
+
await bash.fs.chmod(path, entry.mode);
|
| 85 |
+
} else if (entry.type === 'symlink') {
|
| 86 |
+
const parent = path.slice(0, path.lastIndexOf('/')) || '/';
|
| 87 |
+
await bash.fs.mkdir(parent, { recursive: true });
|
| 88 |
+
await bash.fs.symlink(entry.target, path);
|
| 89 |
+
}
|
| 90 |
+
}
|
| 91 |
+
})();
|
| 92 |
+
|
| 93 |
+
async function read(path, { signal } = {}) {
|
| 94 |
+
await ready;
|
| 95 |
+
checkAbort(signal);
|
| 96 |
+
return bash.fs.readFile(resolve(path));
|
| 97 |
+
}
|
| 98 |
+
|
| 99 |
+
async function write(path, content, { signal } = {}) {
|
| 100 |
+
await ready;
|
| 101 |
+
checkAbort(signal);
|
| 102 |
+
if (typeof content !== 'string') throw new TypeError('File content must be a UTF-8 string.');
|
| 103 |
+
if (encoder.encode(content).length > MAX_EDITOR_BYTES) throw new Error('The editor accepts files up to 1 MiB.');
|
| 104 |
+
const absolutePath = resolve(path);
|
| 105 |
+
await bash.fs.mkdir(absolutePath.slice(0, absolutePath.lastIndexOf('/')) || '/', { recursive: true });
|
| 106 |
+
checkAbort(signal);
|
| 107 |
+
await bash.fs.writeFile(absolutePath, content);
|
| 108 |
+
}
|
| 109 |
+
|
| 110 |
+
const tools = [
|
| 111 |
+
{
|
| 112 |
+
name: 'bash',
|
| 113 |
+
description: 'Run a bash command in /workspace. Supports cat, ls, grep, sed, awk, jq, sort, pipes, redirects, and shell scripts. No network, Node.js, Python, npm, or native binaries. Files persist across commands; cd and variables reset each call.',
|
| 114 |
+
parameters: objectSchema({ command: stringSchema('The shell command to execute.') }),
|
| 115 |
+
async execute({ command }, { signal } = {}) {
|
| 116 |
+
await ready;
|
| 117 |
+
checkAbort(signal);
|
| 118 |
+
const execution = await bash.exec(command, { signal });
|
| 119 |
+
checkAbort(signal);
|
| 120 |
+
const content = `${execution.stdout}${execution.stderr ? '\nSTDERR:\n' + execution.stderr : ''}\nExit code: ${execution.exitCode}`;
|
| 121 |
+
// The agent's repeated-failure guard relies on tools rejecting failed commands.
|
| 122 |
+
if (execution.exitCode !== 0) {
|
| 123 |
+
const error = new Error(truncateOutput(content));
|
| 124 |
+
error.exitCode = execution.exitCode;
|
| 125 |
+
throw error;
|
| 126 |
+
}
|
| 127 |
+
return result(content, { exitCode: execution.exitCode });
|
| 128 |
+
},
|
| 129 |
+
},
|
| 130 |
+
{
|
| 131 |
+
name: 'read',
|
| 132 |
+
description: 'Read a UTF-8 text file. Use bash head/grep for large files.',
|
| 133 |
+
parameters: objectSchema({ path: stringSchema() }),
|
| 134 |
+
async execute({ path }, { signal } = {}) {
|
| 135 |
+
await ready;
|
| 136 |
+
checkAbort(signal);
|
| 137 |
+
const absolutePath = await bash.fs.realpath(resolve(path));
|
| 138 |
+
return result(await read(absolutePath, { signal }), { path: absolutePath });
|
| 139 |
+
},
|
| 140 |
+
},
|
| 141 |
+
{
|
| 142 |
+
name: 'write',
|
| 143 |
+
description: 'Create or replace a UTF-8 text file. Parent directories are created.',
|
| 144 |
+
parameters: objectSchema({ path: stringSchema(), content: stringSchema() }),
|
| 145 |
+
async execute({ path, content }, context = {}) {
|
| 146 |
+
await write(path, content, context);
|
| 147 |
+
return result('Wrote ' + resolve(path));
|
| 148 |
+
},
|
| 149 |
+
},
|
| 150 |
+
{
|
| 151 |
+
name: 'edit',
|
| 152 |
+
description: 'Replace one exact occurrence of oldText with newText in a UTF-8 file. oldText must match exactly once.',
|
| 153 |
+
parameters: objectSchema({ path: stringSchema(), oldText: stringSchema(), newText: stringSchema() }),
|
| 154 |
+
async execute({ path, oldText, newText }, context = {}) {
|
| 155 |
+
const content = await read(path, context);
|
| 156 |
+
if (!oldText || content.split(oldText).length !== 2) {
|
| 157 |
+
throw new Error('oldText must match exactly once. Read the file first.');
|
| 158 |
+
}
|
| 159 |
+
await write(path, content.replace(oldText, () => newText), context);
|
| 160 |
+
return result('Edited ' + resolve(path));
|
| 161 |
+
},
|
| 162 |
+
},
|
| 163 |
+
];
|
| 164 |
+
|
| 165 |
+
async function snapshot() {
|
| 166 |
+
await ready;
|
| 167 |
+
const files = {};
|
| 168 |
+
for (const path of bash.fs.getAllPaths().sort()) {
|
| 169 |
+
if (!isGenerated(path) && (await bash.fs.lstat(path)).isFile) files[path] = await bash.fs.readFile(path);
|
| 170 |
+
}
|
| 171 |
+
return files;
|
| 172 |
+
}
|
| 173 |
+
|
| 174 |
+
async function serialize() {
|
| 175 |
+
await ready;
|
| 176 |
+
const entries = [];
|
| 177 |
+
for (const path of bash.fs.getAllPaths().sort()) {
|
| 178 |
+
if (isGenerated(path)) continue;
|
| 179 |
+
const stat = await bash.fs.lstat(path);
|
| 180 |
+
if (stat.isSymbolicLink) {
|
| 181 |
+
entries.push([path, { type: 'symlink', target: await bash.fs.readlink(path) }]);
|
| 182 |
+
} else if (stat.isDirectory) {
|
| 183 |
+
entries.push([path, { type: 'directory', mode: stat.mode }]);
|
| 184 |
+
} else if (stat.isFile) {
|
| 185 |
+
entries.push([path, { type: 'file', base64: bytesToBase64(await bash.fs.readFileBuffer(path)), mode: stat.mode }]);
|
| 186 |
+
}
|
| 187 |
+
}
|
| 188 |
+
return entries;
|
| 189 |
+
}
|
| 190 |
+
|
| 191 |
+
return { bash, tools, ready, read, write, snapshot, serialize };
|
| 192 |
+
}
|
| 193 |
+
|
| 194 |
+
/** Separate from legacy Pi storage because the new agent uses a different message schema. */
|
| 195 |
+
export async function createStore(indexedDB = globalThis.indexedDB) {
|
| 196 |
+
if (!indexedDB) throw new Error('Browser storage is unavailable.');
|
| 197 |
+
const db = await new Promise((resolve, reject) => {
|
| 198 |
+
const request = indexedDB.open(WORKSPACE_DB_NAME, 1);
|
| 199 |
+
request.onupgradeneeded = () => request.result.createObjectStore('state');
|
| 200 |
+
request.onsuccess = () => resolve(request.result);
|
| 201 |
+
request.onerror = () => reject(request.error);
|
| 202 |
+
request.onblocked = () => reject(new Error('Close other tabs to enable browser storage.'));
|
| 203 |
+
});
|
| 204 |
+
return {
|
| 205 |
+
async load() {
|
| 206 |
+
return new Promise((resolve, reject) => {
|
| 207 |
+
const transaction = db.transaction('state');
|
| 208 |
+
const request = transaction.objectStore('state').get('current');
|
| 209 |
+
request.onsuccess = () => resolve(request.result);
|
| 210 |
+
request.onerror = () => reject(request.error);
|
| 211 |
+
transaction.onabort = () => reject(transaction.error ?? new Error('Loading browser storage was aborted.'));
|
| 212 |
+
});
|
| 213 |
+
},
|
| 214 |
+
async save(state) {
|
| 215 |
+
return new Promise((resolve, reject) => {
|
| 216 |
+
const transaction = db.transaction('state', 'readwrite');
|
| 217 |
+
transaction.objectStore('state').put(state, 'current');
|
| 218 |
+
transaction.oncomplete = () => resolve();
|
| 219 |
+
transaction.onerror = () => reject(transaction.error);
|
| 220 |
+
transaction.onabort = () => reject(transaction.error ?? new Error('Saving browser storage was aborted.'));
|
| 221 |
+
});
|
| 222 |
+
},
|
| 223 |
+
close() { db.close(); },
|
| 224 |
+
};
|
| 225 |
+
}
|
test/controller.test.js
ADDED
|
@@ -0,0 +1,399 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import test from 'node:test';
|
| 2 |
+
import assert from 'node:assert/strict';
|
| 3 |
+
import { WorkspaceController } from '../src/controller.js';
|
| 4 |
+
import { ContextOverflowError, DeviceLostError } from '../runtime/minicpm5.js';
|
| 5 |
+
|
| 6 |
+
const deferred = () => {
|
| 7 |
+
let resolve;
|
| 8 |
+
let reject;
|
| 9 |
+
const promise = new Promise((yes, no) => { resolve = yes; reject = no; });
|
| 10 |
+
return { promise, resolve, reject };
|
| 11 |
+
};
|
| 12 |
+
|
| 13 |
+
function answer(content = 'Done.', { calls, reasoning, finish = calls ? 'tool_calls' : 'stop' } = {}) {
|
| 14 |
+
return {
|
| 15 |
+
finish_reason: finish,
|
| 16 |
+
message: {
|
| 17 |
+
role: 'assistant', content,
|
| 18 |
+
...(reasoning ? { reasoning_content: reasoning } : {}),
|
| 19 |
+
...(calls ? { tool_calls: calls } : {}),
|
| 20 |
+
},
|
| 21 |
+
usage: { prompt_tokens: 80, cached_tokens: 40, completion_tokens: 12, reasoning_tokens: 3 },
|
| 22 |
+
timings: { ttft_ms: 15, decode_ms: 50, tokens_per_second: 220 },
|
| 23 |
+
};
|
| 24 |
+
}
|
| 25 |
+
|
| 26 |
+
function waitForAbort(signal) {
|
| 27 |
+
if (signal.aborted) return Promise.resolve();
|
| 28 |
+
return new Promise(resolve => signal.addEventListener('abort', resolve, { once: true }));
|
| 29 |
+
}
|
| 30 |
+
|
| 31 |
+
function modelWithSteps(steps) {
|
| 32 |
+
const loss = deferred();
|
| 33 |
+
const disposed = deferred();
|
| 34 |
+
const requests = [];
|
| 35 |
+
const model = {
|
| 36 |
+
lost: loss.promise,
|
| 37 |
+
lose: loss.resolve,
|
| 38 |
+
disposed: disposed.promise,
|
| 39 |
+
disposeCount: 0,
|
| 40 |
+
sessionCount: 0,
|
| 41 |
+
sessionDisposeCount: 0,
|
| 42 |
+
contextLength: 4096,
|
| 43 |
+
requests,
|
| 44 |
+
deviceInfo: () => ({ vendor: 'test', architecture: 'mock-gpu' }),
|
| 45 |
+
dispose() { this.disposeCount += 1; disposed.resolve(); },
|
| 46 |
+
create_session() {
|
| 47 |
+
this.sessionCount += 1;
|
| 48 |
+
return {
|
| 49 |
+
dispose() { model.sessionDisposeCount += 1; },
|
| 50 |
+
generate(history, options) {
|
| 51 |
+
const step = steps[requests.length];
|
| 52 |
+
assert.ok(step, 'unexpected extra generation');
|
| 53 |
+
requests.push({ history, options });
|
| 54 |
+
const completion = deferred();
|
| 55 |
+
completion.promise.catch(() => {});
|
| 56 |
+
return {
|
| 57 |
+
result: completion.promise,
|
| 58 |
+
async *[Symbol.asyncIterator]() {
|
| 59 |
+
try {
|
| 60 |
+
for (const event of step.events ?? []) yield event;
|
| 61 |
+
if (step.waitForAbort) await waitForAbort(options.signal);
|
| 62 |
+
if (step.error) throw step.error;
|
| 63 |
+
const result = step.result ?? step;
|
| 64 |
+
completion.resolve(result);
|
| 65 |
+
} catch (error) {
|
| 66 |
+
completion.reject(error);
|
| 67 |
+
throw error;
|
| 68 |
+
}
|
| 69 |
+
},
|
| 70 |
+
};
|
| 71 |
+
},
|
| 72 |
+
};
|
| 73 |
+
},
|
| 74 |
+
};
|
| 75 |
+
return model;
|
| 76 |
+
}
|
| 77 |
+
|
| 78 |
+
function harness({ models = [], saved, checkAvailability, fromPretrained, tools = [], save } = {}) {
|
| 79 |
+
const events = [];
|
| 80 |
+
const snapshots = [];
|
| 81 |
+
const waiters = [];
|
| 82 |
+
const calls = { support: 0, load: 0 };
|
| 83 |
+
const files = {};
|
| 84 |
+
const workspace = {
|
| 85 |
+
tools,
|
| 86 |
+
snapshot: async () => ({ ...files }),
|
| 87 |
+
serialize: async () => Object.entries(files).map(([path, content]) => ({ path, content })),
|
| 88 |
+
write: async (path, content) => { files[path] = content; },
|
| 89 |
+
read: async path => files[path],
|
| 90 |
+
bash: { exec: async command => ({ stdout: command, stderr: '', exitCode: 0 }) },
|
| 91 |
+
};
|
| 92 |
+
const store = {
|
| 93 |
+
async save(snapshot) {
|
| 94 |
+
snapshots.push(structuredClone(snapshot));
|
| 95 |
+
if (save) await save(snapshot);
|
| 96 |
+
},
|
| 97 |
+
};
|
| 98 |
+
const Model = {
|
| 99 |
+
async checkAvailability(...args) {
|
| 100 |
+
calls.support += 1;
|
| 101 |
+
return checkAvailability ? checkAvailability(...args) : { ok: true };
|
| 102 |
+
},
|
| 103 |
+
async from_pretrained(...args) {
|
| 104 |
+
const index = calls.load++;
|
| 105 |
+
if (fromPretrained) return fromPretrained(index, ...args);
|
| 106 |
+
assert.ok(models[index], 'unexpected extra model load');
|
| 107 |
+
return models[index];
|
| 108 |
+
},
|
| 109 |
+
};
|
| 110 |
+
const controller = new WorkspaceController({ Model, workspace, store, saved, emit(event) {
|
| 111 |
+
// Match postMessage/IndexedDB boundaries; source objects continue changing.
|
| 112 |
+
events.push(structuredClone(event));
|
| 113 |
+
for (const waiter of [...waiters]) {
|
| 114 |
+
if (waiter.test(event)) {
|
| 115 |
+
waiters.splice(waiters.indexOf(waiter), 1);
|
| 116 |
+
waiter.resolve(event);
|
| 117 |
+
}
|
| 118 |
+
}
|
| 119 |
+
} });
|
| 120 |
+
const until = predicate => {
|
| 121 |
+
const existing = events.find(predicate);
|
| 122 |
+
if (existing) return Promise.resolve(existing);
|
| 123 |
+
return new Promise(resolve => waiters.push({ test: predicate, resolve }));
|
| 124 |
+
};
|
| 125 |
+
return { controller, events, snapshots, calls, files, until };
|
| 126 |
+
}
|
| 127 |
+
|
| 128 |
+
const agentEvents = events => events.filter(event => event.type === 'agent_event').map(event => event.event);
|
| 129 |
+
|
| 130 |
+
test('native agent replies and tools render in the existing terminal protocol and persist separately', async () => {
|
| 131 |
+
const call = { id: 'write-1', type: 'function', function: {
|
| 132 |
+
name: 'write', arguments: { path: '/workspace/result.txt', content: '42' },
|
| 133 |
+
} };
|
| 134 |
+
const first = answer('Writing the result.', { calls: [call], reasoning: 'I should save it.' });
|
| 135 |
+
const last = answer('Saved result.txt.');
|
| 136 |
+
const model = modelWithSteps([
|
| 137 |
+
{ result: first, events: [
|
| 138 |
+
{ type: 'prefill', done: 32, total: 64 },
|
| 139 |
+
{ type: 'reasoning', text: 'I should save it.' },
|
| 140 |
+
{ type: 'text', text: 'Writing the result.' },
|
| 141 |
+
] },
|
| 142 |
+
{ result: last, events: [{ type: 'text', text: 'Saved result.txt.' }] },
|
| 143 |
+
]);
|
| 144 |
+
const h = harness({ models: [model] });
|
| 145 |
+
h.controller.workspace.tools.push({
|
| 146 |
+
name: 'write', parameters: { type: 'object', properties: {
|
| 147 |
+
path: { type: 'string' }, content: { type: 'string' },
|
| 148 |
+
} },
|
| 149 |
+
async execute({ path, content }) {
|
| 150 |
+
await h.controller.workspace.write(path, content);
|
| 151 |
+
return { content: 'File saved.', details: { path } };
|
| 152 |
+
},
|
| 153 |
+
});
|
| 154 |
+
await h.controller.dispatch('load');
|
| 155 |
+
const ui = await h.controller.dispatch('prompt', { text: 'Save 42.' });
|
| 156 |
+
const native = h.snapshots.at(-1).messages;
|
| 157 |
+
|
| 158 |
+
assert.equal(h.files['/workspace/result.txt'], '42');
|
| 159 |
+
assert.deepEqual(native.map(message => message.role), ['user', 'assistant', 'tool', 'assistant']);
|
| 160 |
+
assert.deepEqual(native[1], first.message, 'canonical reasoning and tool calls must remain restorable');
|
| 161 |
+
assert.deepEqual(native[2], { role: 'tool', tool_call_id: 'write-1', name: 'write', content: 'File saved.' });
|
| 162 |
+
assert.deepEqual(ui.messages.map(message => message.role), ['user', 'assistant', 'toolResult', 'assistant']);
|
| 163 |
+
assert.deepEqual(ui.messages[1].content.map(block => block.type), ['thinking', 'text', 'toolCall']);
|
| 164 |
+
assert.equal(ui.messages[1].stopReason, 'toolUse');
|
| 165 |
+
assert.equal(ui.messages[2].content[0].text, 'File saved.');
|
| 166 |
+
assert.equal(ui.messages[3].content[0].text, 'Saved result.txt.');
|
| 167 |
+
assert.equal(ui.messages[3].usage.cacheRead, 40);
|
| 168 |
+
assert.equal(ui.messages[3].usage.output, 12);
|
| 169 |
+
assert.ok(native.every(message => !Object.hasOwn(message, 'timestamp')));
|
| 170 |
+
assert.ok(!native.some(message => message.role === 'system'), 'instructions are supplied fresh on restore');
|
| 171 |
+
assert.deepEqual(model.requests[1].history.slice(-2).map(message => message.role), ['assistant', 'tool']);
|
| 172 |
+
const start = agentEvents(h.events).find(event => event.type === 'tool_execution_start');
|
| 173 |
+
assert.equal(start.toolName, 'write');
|
| 174 |
+
assert.deepEqual(start.args, call.function.arguments);
|
| 175 |
+
const end = agentEvents(h.events).find(event => event.type === 'tool_execution_end');
|
| 176 |
+
assert.equal(end.result.content[0].text, 'File saved.');
|
| 177 |
+
assert.equal(end.isError, false);
|
| 178 |
+
assert.ok(h.events.some(event => event.type === 'inference_activity' && event.done === 32 && event.total === 64));
|
| 179 |
+
const metrics = h.events.filter(event => event.type === 'generation');
|
| 180 |
+
assert.equal(metrics.at(-1).tokensPerSecond, 220, 'show runtime token timing, not character throughput');
|
| 181 |
+
assert.equal(metrics.at(-1).outputTokens, 12);
|
| 182 |
+
assert.equal(h.events.find(event => event.type === 'loaded').contextLength, 4096);
|
| 183 |
+
assert.equal(agentEvents(h.events).at(-1).type, 'agent_end');
|
| 184 |
+
assert.equal(h.events.at(-1).type, 'idle');
|
| 185 |
+
});
|
| 186 |
+
|
| 187 |
+
test('restoring a saved chat sends native history to the model and display history to the UI', async () => {
|
| 188 |
+
const native = [
|
| 189 |
+
{ role: 'user', content: 'Earlier question' },
|
| 190 |
+
{ role: 'assistant', content: 'Earlier answer', reasoning_content: 'Earlier reasoning' },
|
| 191 |
+
];
|
| 192 |
+
const display = [
|
| 193 |
+
{ role: 'user', content: 'Earlier question', timestamp: 1 },
|
| 194 |
+
{ role: 'assistant', content: [{ type: 'text', text: 'Earlier answer' }], timestamp: 2, stopReason: 'stop' },
|
| 195 |
+
];
|
| 196 |
+
const model = modelWithSteps([answer('Follow-up answer')]);
|
| 197 |
+
const h = harness({ models: [model], saved: { messages: native, displayMessages: display } });
|
| 198 |
+
assert.deepEqual((await h.controller.uiSnapshot()).messages, display);
|
| 199 |
+
await h.controller.dispatch('load');
|
| 200 |
+
await h.controller.dispatch('prompt', { text: 'Follow-up' });
|
| 201 |
+
|
| 202 |
+
assert.deepEqual(model.requests[0].history.slice(1, 3), native);
|
| 203 |
+
assert.equal(model.requests[0].history.at(-1).content, 'Follow-up');
|
| 204 |
+
assert.equal(h.snapshots.at(-1).messages[1].reasoning_content, 'Earlier reasoning');
|
| 205 |
+
const exported = await h.controller.dispatch('snapshot');
|
| 206 |
+
assert.equal(exported.messages[1].content, 'Earlier answer');
|
| 207 |
+
assert.equal(exported.displayMessages[1].content[0].text, 'Earlier answer');
|
| 208 |
+
});
|
| 209 |
+
|
| 210 |
+
test('new chat clears model/display history and reuses the loaded model and session', async () => {
|
| 211 |
+
const model = modelWithSteps([answer('First answer'), answer('Fresh answer')]);
|
| 212 |
+
const h = harness({ models: [model] });
|
| 213 |
+
await h.controller.dispatch('load');
|
| 214 |
+
await h.controller.dispatch('prompt', { text: 'First question' });
|
| 215 |
+
const cleared = await h.controller.dispatch('new_chat');
|
| 216 |
+
assert.deepEqual(cleared.messages, []);
|
| 217 |
+
assert.deepEqual(h.snapshots.at(-1).messages, []);
|
| 218 |
+
await h.controller.dispatch('prompt', { text: 'Fresh question' });
|
| 219 |
+
|
| 220 |
+
assert.equal(h.calls.load, 1);
|
| 221 |
+
assert.equal(model.sessionCount, 1);
|
| 222 |
+
assert.deepEqual(model.requests[1].history.map(message => message.role), ['system', 'user']);
|
| 223 |
+
assert.equal(model.requests[1].history.at(-1).content, 'Fresh question');
|
| 224 |
+
});
|
| 225 |
+
|
| 226 |
+
test('stop settles generation, preserves its visible partial, and excludes it from future model history', async () => {
|
| 227 |
+
const partial = answer('Interrupted text', { finish: 'cancelled' });
|
| 228 |
+
const model = modelWithSteps([
|
| 229 |
+
{ result: partial, events: [{ type: 'text', text: 'Interrupted text' }], waitForAbort: true },
|
| 230 |
+
answer('Next answer'),
|
| 231 |
+
]);
|
| 232 |
+
const h = harness({ models: [model] });
|
| 233 |
+
await h.controller.dispatch('load');
|
| 234 |
+
const pending = h.controller.dispatch('prompt', { text: 'Interrupted question' });
|
| 235 |
+
await h.until(event => event.type === 'agent_event' && event.event.type === 'message_start');
|
| 236 |
+
assert.equal(await h.controller.dispatch('stop'), true);
|
| 237 |
+
const ui = await pending;
|
| 238 |
+
|
| 239 |
+
assert.equal(ui.messages.at(-1).content[0].text, 'Interrupted text');
|
| 240 |
+
assert.equal(ui.messages.at(-1).stopReason, 'aborted');
|
| 241 |
+
assert.deepEqual(h.snapshots.at(-1).messages, []);
|
| 242 |
+
assert.ok(h.events.some(event => event.type === 'notice' && /omitted from the model history/.test(event.text)));
|
| 243 |
+
await h.controller.dispatch('prompt', { text: 'Next question' });
|
| 244 |
+
assert.deepEqual(model.requests[1].history.map(message => message.role), ['system', 'user']);
|
| 245 |
+
assert.equal(model.requests[1].history.at(-1).content, 'Next question');
|
| 246 |
+
assert.equal(h.controller.busy, false);
|
| 247 |
+
});
|
| 248 |
+
|
| 249 |
+
test('busy operations reject extra prompts, edits, resets, and export while stop remains available', async () => {
|
| 250 |
+
const model = modelWithSteps([{ result: answer('', { finish: 'cancelled' }), waitForAbort: true }]);
|
| 251 |
+
const h = harness({ models: [model] });
|
| 252 |
+
await h.controller.dispatch('load');
|
| 253 |
+
const pending = h.controller.dispatch('prompt', { text: 'Wait' });
|
| 254 |
+
await h.until(event => event.type === 'agent_event' && event.event.type === 'message_start');
|
| 255 |
+
for (const action of ['prompt', 'write', 'new_chat', 'snapshot', 'load']) {
|
| 256 |
+
await assert.rejects(h.controller.dispatch(action, { text: 'Other', path: '/x', content: 'bad' }), /current operation/);
|
| 257 |
+
}
|
| 258 |
+
assert.deepEqual(h.files, {});
|
| 259 |
+
await h.controller.dispatch('stop');
|
| 260 |
+
await pending;
|
| 261 |
+
assert.equal(h.controller.busy, false);
|
| 262 |
+
assert.equal(h.controller.operation, null);
|
| 263 |
+
});
|
| 264 |
+
|
| 265 |
+
test('a failed model download can be retried and never leaves the controller busy', async () => {
|
| 266 |
+
const model = modelWithSteps([answer('Ready')]);
|
| 267 |
+
const h = harness({ fromPretrained: async index => {
|
| 268 |
+
if (index === 0) throw new Error('Download interrupted');
|
| 269 |
+
return model;
|
| 270 |
+
} });
|
| 271 |
+
await assert.rejects(h.controller.dispatch('load'), /Download interrupted/);
|
| 272 |
+
assert.equal(h.controller.busy, false);
|
| 273 |
+
assert.equal(h.controller.model, null);
|
| 274 |
+
assert.equal(h.controller.agent, null);
|
| 275 |
+
await h.controller.dispatch('load');
|
| 276 |
+
await h.controller.dispatch('prompt', { text: 'Hello' });
|
| 277 |
+
assert.equal(h.calls.load, 2);
|
| 278 |
+
assert.equal(h.events.filter(event => event.type === 'loaded').length, 1);
|
| 279 |
+
});
|
| 280 |
+
|
| 281 |
+
test('unsupported GPUs and cache-only loading fail before any weight download', async () => {
|
| 282 |
+
const unsupported = harness({ checkAvailability: async () => ({ ok: false, reason: 'Insufficient GPU limits' }) });
|
| 283 |
+
await assert.rejects(unsupported.controller.dispatch('load'), /Insufficient GPU limits/);
|
| 284 |
+
assert.equal(unsupported.calls.load, 0);
|
| 285 |
+
assert.equal(unsupported.controller.busy, false);
|
| 286 |
+
|
| 287 |
+
const cacheOnly = harness();
|
| 288 |
+
await assert.rejects(cacheOnly.controller.dispatch('load', { cachedOnly: true }), error => error.code === 'MODEL_CACHE_MISS');
|
| 289 |
+
assert.equal(cacheOnly.calls.support, 0);
|
| 290 |
+
assert.equal(cacheOnly.calls.load, 0);
|
| 291 |
+
assert.equal(await cacheOnly.controller.dispatch('cache_status'), false);
|
| 292 |
+
});
|
| 293 |
+
|
| 294 |
+
test('GPU loss releases the old session/model and allows a reload with the saved conversation', async () => {
|
| 295 |
+
const oldModel = modelWithSteps([answer('Before loss')]);
|
| 296 |
+
const newModel = modelWithSteps([answer('After reload')]);
|
| 297 |
+
const h = harness({ models: [oldModel, newModel] });
|
| 298 |
+
await h.controller.dispatch('load');
|
| 299 |
+
await h.controller.dispatch('prompt', { text: 'Remember this' });
|
| 300 |
+
oldModel.lose(new DeviceLostError('unknown', 'Adapter removed'));
|
| 301 |
+
await h.until(event => event.type === 'fatal' && /GPU device lost/.test(event.error));
|
| 302 |
+
await oldModel.disposed;
|
| 303 |
+
|
| 304 |
+
assert.equal(oldModel.disposeCount, 1);
|
| 305 |
+
assert.equal(oldModel.sessionDisposeCount, 1);
|
| 306 |
+
assert.equal(h.controller.model, null);
|
| 307 |
+
await h.controller.dispatch('load');
|
| 308 |
+
await h.controller.dispatch('prompt', { text: 'Continue' });
|
| 309 |
+
assert.equal(newModel.requests[0].history[1].content, 'Remember this');
|
| 310 |
+
assert.equal(newModel.requests[0].history[2].content, 'Before loss');
|
| 311 |
+
assert.equal(h.controller.deviceLoss, null);
|
| 312 |
+
assert.equal(h.calls.load, 2);
|
| 313 |
+
});
|
| 314 |
+
|
| 315 |
+
test('GPU loss during a reply ends the operation and retains the last completed conversation', async () => {
|
| 316 |
+
const deviceError = new DeviceLostError('unknown', 'Adapter disconnected');
|
| 317 |
+
const oldModel = modelWithSteps([
|
| 318 |
+
answer('Completed answer'),
|
| 319 |
+
{ error: deviceError, events: [{ type: 'text', text: 'Interrupted' }], waitForAbort: true },
|
| 320 |
+
]);
|
| 321 |
+
const newModel = modelWithSteps([answer('Recovered answer')]);
|
| 322 |
+
const h = harness({ models: [oldModel, newModel] });
|
| 323 |
+
await h.controller.dispatch('load');
|
| 324 |
+
await h.controller.dispatch('prompt', { text: 'Completed question' });
|
| 325 |
+
h.events.length = 0;
|
| 326 |
+
const pending = h.controller.dispatch('prompt', { text: 'Interrupted question' });
|
| 327 |
+
const rejected = assert.rejects(pending, error => error === deviceError);
|
| 328 |
+
await h.until(event => event.type === 'agent_event' && event.event.type === 'message_start');
|
| 329 |
+
oldModel.lose(deviceError);
|
| 330 |
+
await rejected;
|
| 331 |
+
await oldModel.disposed;
|
| 332 |
+
|
| 333 |
+
assert.equal(h.controller.busy, false);
|
| 334 |
+
assert.equal(h.snapshots.at(-1).displayMessages.at(-1).stopReason, 'aborted');
|
| 335 |
+
assert.deepEqual(h.snapshots.at(-1).messages.map(message => message.content), ['Completed question', 'Completed answer']);
|
| 336 |
+
assert.equal(agentEvents(h.events).at(-1).type, 'agent_end');
|
| 337 |
+
await h.controller.dispatch('load');
|
| 338 |
+
await h.controller.dispatch('prompt', { text: 'Continue after loss' });
|
| 339 |
+
assert.deepEqual(newModel.requests[0].history.slice(1).map(message => message.content), [
|
| 340 |
+
'Completed question', 'Completed answer', 'Continue after loss',
|
| 341 |
+
]);
|
| 342 |
+
});
|
| 343 |
+
|
| 344 |
+
test('context overflow and generation failures end the visible reply without corrupting canonical history', async () => {
|
| 345 |
+
const overflow = new ContextOverflowError(9000, 4096);
|
| 346 |
+
const model = modelWithSteps([
|
| 347 |
+
{ error: overflow },
|
| 348 |
+
{ error: new Error('Shader execution failed'), events: [{ type: 'text', text: 'Partial' }] },
|
| 349 |
+
answer('Recovered'),
|
| 350 |
+
]);
|
| 351 |
+
const h = harness({ models: [model] });
|
| 352 |
+
await h.controller.dispatch('load');
|
| 353 |
+
await assert.rejects(h.controller.dispatch('prompt', { text: 'Too long' }), error => error === overflow);
|
| 354 |
+
assert.ok(h.events.some(event => event.type === 'notice' && /9000 tokens.*4096/.test(event.text)));
|
| 355 |
+
assert.equal(h.snapshots.at(-1).displayMessages.at(-1).stopReason, 'error');
|
| 356 |
+
assert.deepEqual(h.snapshots.at(-1).messages, []);
|
| 357 |
+
await assert.rejects(h.controller.dispatch('prompt', { text: 'Fails' }), /Shader execution failed/);
|
| 358 |
+
const visible = h.snapshots.at(-1).displayMessages.at(-1);
|
| 359 |
+
assert.equal(visible.stopReason, 'error');
|
| 360 |
+
assert.equal(visible.content[0].text, 'Partial');
|
| 361 |
+
assert.equal(visible.errorMessage, 'Shader execution failed');
|
| 362 |
+
assert.deepEqual(h.snapshots.at(-1).messages, []);
|
| 363 |
+
await h.controller.dispatch('prompt', { text: 'Try again' });
|
| 364 |
+
assert.deepEqual(model.requests[2].history.map(message => message.role), ['system', 'user']);
|
| 365 |
+
assert.equal(h.snapshots.at(-1).messages.at(-1).content, 'Recovered');
|
| 366 |
+
assert.equal(h.controller.busy, false);
|
| 367 |
+
});
|
| 368 |
+
|
| 369 |
+
test('load progress distinguishes byte download counts from tensors and compilation', async () => {
|
| 370 |
+
const model = modelWithSteps([]);
|
| 371 |
+
const h = harness({ fromPretrained: async (_index, _id, options) => {
|
| 372 |
+
options.onProgress({ status: 'init', message: 'Requesting GPU' });
|
| 373 |
+
options.onProgress({ status: 'weights', kind: 'bytes', loaded: 512, total: 1024, fraction: 0.5 });
|
| 374 |
+
options.onProgress({ status: 'weights', kind: 'tensors', loaded: 10, total: 20, message: 'Uploading' });
|
| 375 |
+
options.onProgress({ status: 'weights', kind: 'tensors', message: 'Compiling kernels' });
|
| 376 |
+
options.onProgress({ status: 'ready', fraction: 1, message: 'Ready' });
|
| 377 |
+
return model;
|
| 378 |
+
} });
|
| 379 |
+
await h.controller.dispatch('load');
|
| 380 |
+
const progress = h.events.filter(event => event.type === 'load_progress');
|
| 381 |
+
assert.deepEqual(progress.filter(event => event.phase === 'download'), [
|
| 382 |
+
{ type: 'load_progress', phase: 'download', loaded: 512, total: 1024 },
|
| 383 |
+
]);
|
| 384 |
+
assert.equal(progress.find(event => event.message === 'Uploading').phase, 'compile');
|
| 385 |
+
assert.equal(progress.find(event => event.message === 'Compiling kernels').phase, 'compile');
|
| 386 |
+
assert.equal(h.events.filter(event => event.type === 'loaded').length, 1);
|
| 387 |
+
assert.ok(h.events.findIndex(event => event.type === 'loaded') > h.events.findIndex(event => event.message === 'Compiling kernels'));
|
| 388 |
+
});
|
| 389 |
+
|
| 390 |
+
test('a persistence failure keeps the answer usable and reports that changes remain in memory', async () => {
|
| 391 |
+
const model = modelWithSteps([answer('Still available')]);
|
| 392 |
+
const h = harness({ models: [model], save: async () => { throw new Error('Storage quota exceeded'); } });
|
| 393 |
+
await h.controller.dispatch('load');
|
| 394 |
+
const ui = await h.controller.dispatch('prompt', { text: 'Hello' });
|
| 395 |
+
assert.equal(ui.messages.at(-1).content[0].text, 'Still available');
|
| 396 |
+
assert.ok(h.events.some(event => event.type === 'persistence_error' && /in memory.*Storage quota/.test(event.error)));
|
| 397 |
+
assert.equal(h.controller.history.at(-1).content, 'Still available');
|
| 398 |
+
assert.equal(h.controller.busy, false);
|
| 399 |
+
});
|
test/runtime.test.js
ADDED
|
@@ -0,0 +1,276 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import test from 'node:test';
|
| 2 |
+
import assert from 'node:assert/strict';
|
| 3 |
+
import { Agent, runAgent } from '../runtime/agent.js';
|
| 4 |
+
import {
|
| 5 |
+
MiniCPM5ForCausalLM,
|
| 6 |
+
BusyError,
|
| 7 |
+
ContextOverflowError,
|
| 8 |
+
DeviceLostError,
|
| 9 |
+
} from '../runtime/minicpm5.js';
|
| 10 |
+
|
| 11 |
+
const toolCall = (name, args = {}, id = name, errors) => ({
|
| 12 |
+
id,
|
| 13 |
+
type: 'function',
|
| 14 |
+
function: { name, arguments: args },
|
| 15 |
+
...(errors ? { errors } : {}),
|
| 16 |
+
});
|
| 17 |
+
|
| 18 |
+
function reply(content = '', calls) {
|
| 19 |
+
return {
|
| 20 |
+
finish_reason: calls ? 'tool_calls' : 'stop',
|
| 21 |
+
message: { role: 'assistant', content, ...(calls ? { tool_calls: calls } : {}) },
|
| 22 |
+
usage: { prompt_tokens: 10, cached_tokens: 2, completion_tokens: 4, reasoning_tokens: 1 },
|
| 23 |
+
timings: { ttft_ms: 1, decode_ms: 3, tokens_per_second: 1000 },
|
| 24 |
+
};
|
| 25 |
+
}
|
| 26 |
+
|
| 27 |
+
function fakeSession(steps) {
|
| 28 |
+
const requests = [];
|
| 29 |
+
let disposed = 0;
|
| 30 |
+
return {
|
| 31 |
+
requests,
|
| 32 |
+
get disposed() { return disposed; },
|
| 33 |
+
dispose() { disposed += 1; },
|
| 34 |
+
generate(history, options) {
|
| 35 |
+
requests.push({ history, options });
|
| 36 |
+
const step = steps[requests.length - 1];
|
| 37 |
+
assert.ok(step, `unexpected generation round ${requests.length}`);
|
| 38 |
+
return {
|
| 39 |
+
result: step.error ? Promise.reject(step.error) : Promise.resolve(step.result ?? step),
|
| 40 |
+
async *[Symbol.asyncIterator]() {
|
| 41 |
+
if (step.error) throw step.error;
|
| 42 |
+
for (const event of step.events ?? []) yield event;
|
| 43 |
+
},
|
| 44 |
+
};
|
| 45 |
+
},
|
| 46 |
+
};
|
| 47 |
+
}
|
| 48 |
+
|
| 49 |
+
async function collect(run, onEvent = () => {}) {
|
| 50 |
+
const events = [];
|
| 51 |
+
for await (const event of run) {
|
| 52 |
+
events.push(event);
|
| 53 |
+
await onEvent(event);
|
| 54 |
+
}
|
| 55 |
+
return { events, result: await run.result };
|
| 56 |
+
}
|
| 57 |
+
|
| 58 |
+
const offeredTool = (name, execute) => ({
|
| 59 |
+
name,
|
| 60 |
+
description: `${name} test tool`,
|
| 61 |
+
parameters: { type: 'object', properties: { value: { type: 'number' } } },
|
| 62 |
+
execute,
|
| 63 |
+
});
|
| 64 |
+
|
| 65 |
+
test('runtime imports in Node without requesting a GPU or downloading weights', () => {
|
| 66 |
+
assert.equal(typeof MiniCPM5ForCausalLM.from_pretrained, 'function');
|
| 67 |
+
assert.equal(typeof MiniCPM5ForCausalLM.checkAvailability, 'function');
|
| 68 |
+
assert.equal(MiniCPM5ForCausalLM.DEFAULT_MODEL_ID, 'openbmb/MiniCPM5-2B-GGUF');
|
| 69 |
+
assert.equal(new BusyError('busy').name, 'BusyError');
|
| 70 |
+
const overflow = new ContextOverflowError(9000, 8192);
|
| 71 |
+
assert.equal(overflow.required, 9000);
|
| 72 |
+
assert.equal(overflow.capacity, 8192);
|
| 73 |
+
assert.equal(new DeviceLostError('unknown', 'test').name, 'DeviceLostError');
|
| 74 |
+
});
|
| 75 |
+
|
| 76 |
+
test('successful tools run sequentially and feed their text back before the final answer', async () => {
|
| 77 |
+
const calls = [toolCall('first', { value: 3 }, 'one'), toolCall('second', { value: 4 }, 'two')];
|
| 78 |
+
const finalReply = reply('The answer is 7.');
|
| 79 |
+
const session = fakeSession([reply('', calls), finalReply]);
|
| 80 |
+
const order = [];
|
| 81 |
+
let active = false;
|
| 82 |
+
const tools = ['first', 'second'].map(name => offeredTool(name, async (args, { signal, call }) => {
|
| 83 |
+
assert.equal(active, false, 'tools must not overlap');
|
| 84 |
+
assert.equal(signal.aborted, false);
|
| 85 |
+
assert.equal(call.function.name, name);
|
| 86 |
+
active = true;
|
| 87 |
+
order.push(`start:${name}`);
|
| 88 |
+
await Promise.resolve();
|
| 89 |
+
order.push(`end:${name}`);
|
| 90 |
+
active = false;
|
| 91 |
+
return { content: String(args.value), details: { source: name } };
|
| 92 |
+
}));
|
| 93 |
+
const agent = new Agent({ model: session, instructions: 'Use the tools.', tools });
|
| 94 |
+
const { events, result } = await collect(agent.run('Add the two values.'));
|
| 95 |
+
|
| 96 |
+
assert.deepEqual(order, ['start:first', 'end:first', 'start:second', 'end:second']);
|
| 97 |
+
assert.equal(result.finish_reason, 'stop');
|
| 98 |
+
assert.equal(result.content, 'The answer is 7.');
|
| 99 |
+
assert.equal(result.rounds, 2);
|
| 100 |
+
assert.equal(result.usage.prompt_tokens, 20);
|
| 101 |
+
assert.equal(result.usage.completion_tokens, 8);
|
| 102 |
+
assert.deepEqual(session.requests[1].history.slice(-2), [
|
| 103 |
+
{ role: 'tool', tool_call_id: 'one', name: 'first', content: '3' },
|
| 104 |
+
{ role: 'tool', tool_call_id: 'two', name: 'second', content: '4' },
|
| 105 |
+
]);
|
| 106 |
+
assert.deepEqual(session.requests[0].options.tools, session.requests[1].options.tools);
|
| 107 |
+
assert.deepEqual(events.filter(event => event.type === 'tool_end').map(event => event.details), [
|
| 108 |
+
{ source: 'first' }, { source: 'second' },
|
| 109 |
+
]);
|
| 110 |
+
assert.ok(events.every(event => Number.isInteger(event.round)));
|
| 111 |
+
assert.strictEqual(agent.messages.at(-1), finalReply.message);
|
| 112 |
+
});
|
| 113 |
+
|
| 114 |
+
test('malformed and unknown calls become tool errors without executing a tool', async () => {
|
| 115 |
+
let executions = 0;
|
| 116 |
+
const calls = [
|
| 117 |
+
toolCall('known', { value: 'wrong' }, 'invalid', ['value must be a number']),
|
| 118 |
+
toolCall('unavailable', {}, 'unknown'),
|
| 119 |
+
];
|
| 120 |
+
const agent = new Agent({
|
| 121 |
+
model: fakeSession([reply('', calls), reply('Please correct the request.')]),
|
| 122 |
+
tools: [offeredTool('known', () => { executions += 1; return 'unexpected'; })],
|
| 123 |
+
});
|
| 124 |
+
const { events } = await collect(agent.run('Try those calls.'));
|
| 125 |
+
const ended = events.filter(event => event.type === 'tool_end');
|
| 126 |
+
|
| 127 |
+
assert.equal(executions, 0);
|
| 128 |
+
assert.equal(ended.length, 2);
|
| 129 |
+
assert.ok(ended.every(event => event.is_error));
|
| 130 |
+
assert.equal(ended[0].message.content, 'value must be a number');
|
| 131 |
+
assert.match(ended[1].message.content, /No tool named "unavailable"/);
|
| 132 |
+
assert.deepEqual(ended.map(event => event.message.tool_call_id), ['invalid', 'unknown']);
|
| 133 |
+
});
|
| 134 |
+
|
| 135 |
+
test('identical failed calls are refused until another tool succeeds', async () => {
|
| 136 |
+
let failures = 0;
|
| 137 |
+
const fail = () => reply('', [toolCall('fails', { value: 1 })]);
|
| 138 |
+
const session = fakeSession([
|
| 139 |
+
fail(), fail(), reply('', [toolCall('succeeds')]), fail(), reply('Unable to finish.'),
|
| 140 |
+
]);
|
| 141 |
+
const agent = new Agent({ model: session, tools: [
|
| 142 |
+
offeredTool('fails', () => { failures += 1; throw new Error('Operation failed'); }),
|
| 143 |
+
offeredTool('succeeds', () => 'Success'),
|
| 144 |
+
] });
|
| 145 |
+
const { events, result } = await collect(agent.run('Attempt the operation.'));
|
| 146 |
+
const ended = events.filter(event => event.type === 'tool_end');
|
| 147 |
+
|
| 148 |
+
assert.equal(failures, 2, 'the duplicate must be skipped, then allowed again after success');
|
| 149 |
+
assert.equal(ended[0].message.content, 'Operation failed');
|
| 150 |
+
assert.match(ended[1].message.content, /already failed/);
|
| 151 |
+
assert.equal(ended[2].is_error, false);
|
| 152 |
+
assert.equal(ended[3].message.content, 'Operation failed');
|
| 153 |
+
assert.equal(result.rounds, 5);
|
| 154 |
+
});
|
| 155 |
+
|
| 156 |
+
test('cancelling a running tool answers that call and every remaining call', async () => {
|
| 157 |
+
let firstStarted = false;
|
| 158 |
+
let secondStarted = false;
|
| 159 |
+
const calls = [toolCall('wait', {}, 'running'), toolCall('later', {}, 'pending')];
|
| 160 |
+
const agent = new Agent({ model: fakeSession([reply('', calls)]), tools: [
|
| 161 |
+
offeredTool('wait', async (_args, { signal }) => {
|
| 162 |
+
firstStarted = true;
|
| 163 |
+
await new Promise((_resolve, reject) => {
|
| 164 |
+
signal.addEventListener('abort', () => reject(signal.reason), { once: true });
|
| 165 |
+
});
|
| 166 |
+
return 'Should not complete';
|
| 167 |
+
}),
|
| 168 |
+
offeredTool('later', () => { secondStarted = true; return 'Should not run'; }),
|
| 169 |
+
] });
|
| 170 |
+
const run = agent.run('Run both tools.');
|
| 171 |
+
const { events, result } = await collect(run, event => {
|
| 172 |
+
if (event.type === 'tool_start') run.cancel();
|
| 173 |
+
});
|
| 174 |
+
const ended = events.filter(event => event.type === 'tool_end');
|
| 175 |
+
|
| 176 |
+
assert.equal(firstStarted, true);
|
| 177 |
+
assert.equal(secondStarted, false);
|
| 178 |
+
assert.equal(result.finish_reason, 'cancelled');
|
| 179 |
+
assert.equal(agent.running, false);
|
| 180 |
+
assert.deepEqual(ended.map(event => event.message.tool_call_id), ['running', 'pending']);
|
| 181 |
+
assert.ok(ended.every(event => event.is_error));
|
| 182 |
+
assert.match(ended[0].message.content, /may have partly completed/);
|
| 183 |
+
assert.match(ended[1].message.content, /before this call ran/);
|
| 184 |
+
assert.deepEqual(agent.messages.map(message => message.role), ['user', 'assistant', 'tool', 'tool']);
|
| 185 |
+
assert.doesNotThrow(() => agent.reset(), 'cancellation must settle before resetting');
|
| 186 |
+
assert.deepEqual(agent.messages, []);
|
| 187 |
+
});
|
| 188 |
+
|
| 189 |
+
test('Agent preserves canonical assistant objects and the full history across runs', async () => {
|
| 190 |
+
const first = reply('First answer.');
|
| 191 |
+
first.message.reasoning_content = 'Private reasoning text.';
|
| 192 |
+
Object.freeze(first.message);
|
| 193 |
+
const second = reply('Second answer.');
|
| 194 |
+
const session = fakeSession([first, second]);
|
| 195 |
+
const agent = new Agent({ model: session, instructions: 'Help.' });
|
| 196 |
+
await collect(agent.run('First question.'));
|
| 197 |
+
await collect(agent.run('Second question.'));
|
| 198 |
+
|
| 199 |
+
assert.strictEqual(session.requests[1].history[2], first.message);
|
| 200 |
+
assert.deepEqual(session.requests[1].history.map(message => message.role), [
|
| 201 |
+
'system', 'user', 'assistant', 'user',
|
| 202 |
+
]);
|
| 203 |
+
assert.strictEqual(agent.messages[2], first.message);
|
| 204 |
+
assert.strictEqual(agent.messages[4], second.message);
|
| 205 |
+
agent.reset();
|
| 206 |
+
assert.deepEqual(agent.messages, [{ role: 'system', content: 'Help.' }]);
|
| 207 |
+
});
|
| 208 |
+
|
| 209 |
+
test('Agent reuses and releases its owned session, while a supplied session remains open', async () => {
|
| 210 |
+
const owned = fakeSession([reply('One'), reply('Two')]);
|
| 211 |
+
let created = 0;
|
| 212 |
+
let modelsDisposed = 0;
|
| 213 |
+
const model = {
|
| 214 |
+
create_session() { created += 1; return owned; },
|
| 215 |
+
dispose() { modelsDisposed += 1; },
|
| 216 |
+
};
|
| 217 |
+
const agent = new Agent({ model });
|
| 218 |
+
assert.equal(created, 0, 'the session is allocated lazily');
|
| 219 |
+
await collect(agent.run('One'));
|
| 220 |
+
await collect(agent.run('Two'));
|
| 221 |
+
assert.equal(created, 1);
|
| 222 |
+
await agent.dispose();
|
| 223 |
+
await agent.dispose();
|
| 224 |
+
assert.equal(owned.disposed, 1);
|
| 225 |
+
assert.equal(modelsDisposed, 0, 'the caller retains ownership of the loaded model');
|
| 226 |
+
|
| 227 |
+
const supplied = fakeSession([reply('Answer')]);
|
| 228 |
+
const externalAgent = new Agent({ model: supplied });
|
| 229 |
+
await collect(externalAgent.run('Question'));
|
| 230 |
+
await externalAgent.dispose();
|
| 231 |
+
assert.equal(supplied.disposed, 0);
|
| 232 |
+
});
|
| 233 |
+
|
| 234 |
+
test('a cancelled partial reply is emitted but is omitted from canonical Agent history', async () => {
|
| 235 |
+
const partial = { ...reply('Partial answer'), finish_reason: 'cancelled' };
|
| 236 |
+
const agent = new Agent({ model: fakeSession([{
|
| 237 |
+
result: partial,
|
| 238 |
+
events: [{ type: 'text', text: 'Partial answer' }],
|
| 239 |
+
}]), instructions: 'Help.' });
|
| 240 |
+
const { events, result } = await collect(agent.run('Question'));
|
| 241 |
+
|
| 242 |
+
assert.equal(events.find(event => event.type === 'text').text, 'Partial answer');
|
| 243 |
+
assert.strictEqual(events.find(event => event.type === 'turn_end').result.message, partial.message);
|
| 244 |
+
assert.equal(result.finish_reason, 'cancelled');
|
| 245 |
+
assert.equal(result.content, '');
|
| 246 |
+
assert.deepEqual(result.messages, []);
|
| 247 |
+
assert.deepEqual(agent.messages, [{ role: 'system', content: 'Help.' }]);
|
| 248 |
+
});
|
| 249 |
+
|
| 250 |
+
test('max_rounds stops an ongoing tool loop after answering the final round of calls', async () => {
|
| 251 |
+
let executions = 0;
|
| 252 |
+
const session = fakeSession([
|
| 253 |
+
reply('', [toolCall('repeat', { value: 1 }, 'first')]),
|
| 254 |
+
reply('', [toolCall('repeat', { value: 2 }, 'second')]),
|
| 255 |
+
]);
|
| 256 |
+
const agent = new Agent({ model: session, max_rounds: 2, tools: [
|
| 257 |
+
offeredTool('repeat', () => { executions += 1; return 'Continue'; }),
|
| 258 |
+
] });
|
| 259 |
+
const { result } = await collect(agent.run('Keep going.'));
|
| 260 |
+
|
| 261 |
+
assert.equal(result.finish_reason, 'max_rounds');
|
| 262 |
+
assert.equal(result.rounds, 2);
|
| 263 |
+
assert.equal(executions, 2);
|
| 264 |
+
assert.deepEqual(result.messages.map(message => message.role), ['assistant', 'tool', 'assistant', 'tool']);
|
| 265 |
+
assert.equal(agent.messages.at(-1).tool_call_id, 'second');
|
| 266 |
+
});
|
| 267 |
+
|
| 268 |
+
test('runAgent leaves caller history unchanged and returns canonical additions', async () => {
|
| 269 |
+
const history = Object.freeze([{ role: 'user', content: 'Hello' }]);
|
| 270 |
+
const finalReply = reply('Hi');
|
| 271 |
+
const { result } = await collect(runAgent(fakeSession([finalReply]), history));
|
| 272 |
+
|
| 273 |
+
assert.equal(history.length, 1);
|
| 274 |
+
assert.equal(result.messages.length, 1);
|
| 275 |
+
assert.strictEqual(result.messages[0], finalReply.message);
|
| 276 |
+
});
|
test/worker.test.js
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import assert from 'node:assert/strict';
|
| 2 |
+
import { once } from 'node:events';
|
| 3 |
+
import { Worker } from 'node:worker_threads';
|
| 4 |
+
import test from 'node:test';
|
| 5 |
+
|
| 6 |
+
test('actual worker module initializes and serves shell/editor/export RPCs without loading a model', { timeout: 10_000 }, async t => {
|
| 7 |
+
// Supply only browser worker messaging globals. The app's real module graph,
|
| 8 |
+
// real shell, actual runtime imports, and RPC entrypoint execute unchanged.
|
| 9 |
+
const url = new URL('../src/agent.worker.js', import.meta.url).href;
|
| 10 |
+
const worker = new Worker(`
|
| 11 |
+
const { parentPort } = require('node:worker_threads');
|
| 12 |
+
globalThis.self = globalThis;
|
| 13 |
+
globalThis.postMessage = message => parentPort.postMessage(message);
|
| 14 |
+
parentPort.on('message', data => globalThis.onmessage({ data }));
|
| 15 |
+
import(${JSON.stringify(url)}).catch(error => { throw error; });
|
| 16 |
+
`, { eval: true });
|
| 17 |
+
t.after(() => worker.terminate());
|
| 18 |
+
const events = [];
|
| 19 |
+
worker.on('message', message => events.push(message));
|
| 20 |
+
let initialized;
|
| 21 |
+
while (!initialized) {
|
| 22 |
+
const [event] = await once(worker, 'message');
|
| 23 |
+
if (event.type === 'fatal') throw new Error(event.error);
|
| 24 |
+
if (event.type === 'initialized') initialized = event;
|
| 25 |
+
}
|
| 26 |
+
assert.match(initialized.files['/workspace/sales.csv'], /Notebook,12/);
|
| 27 |
+
assert.deepEqual(initialized.messages, []);
|
| 28 |
+
assert(events.some(event => event.type === 'persistence_error'));
|
| 29 |
+
let id = 0;
|
| 30 |
+
async function rpc(action, args) {
|
| 31 |
+
const requestId = ++id;
|
| 32 |
+
worker.postMessage({ id: requestId, action, args });
|
| 33 |
+
for (;;) {
|
| 34 |
+
const [event] = await once(worker, 'message');
|
| 35 |
+
if (event.type === 'reply' && event.id === requestId) {
|
| 36 |
+
if (event.error) throw Object.assign(new Error(event.error), { code: event.errorCode });
|
| 37 |
+
return event.value;
|
| 38 |
+
}
|
| 39 |
+
}
|
| 40 |
+
}
|
| 41 |
+
assert.equal(await rpc('cache_status'), false);
|
| 42 |
+
const shell = await rpc('shell', { command: 'printf "verified\\n" > /workspace/worker-check.txt; cat /workspace/worker-check.txt' });
|
| 43 |
+
assert.equal(shell.exitCode, 0);
|
| 44 |
+
assert.equal(shell.stdout, 'verified\n');
|
| 45 |
+
await rpc('write', { path: 'worker-check.txt', content: 'edited' });
|
| 46 |
+
assert.equal(await rpc('read', { path: 'worker-check.txt' }), 'edited');
|
| 47 |
+
const exported = await rpc('snapshot');
|
| 48 |
+
assert.equal(exported.runtime, 'minicpm5-gguf');
|
| 49 |
+
assert.equal(exported.files['/workspace/worker-check.txt'], 'edited');
|
| 50 |
+
assert(exported.entries.some(([path]) => path === '/workspace/worker-check.txt'));
|
| 51 |
+
const fresh = await rpc('new_chat');
|
| 52 |
+
assert.deepEqual(fresh.messages, []);
|
| 53 |
+
assert.equal(fresh.files['/workspace/worker-check.txt'], 'edited');
|
| 54 |
+
await assert.rejects(rpc('prompt', { text: 'hello' }), /Load the model first/);
|
| 55 |
+
await assert.rejects(rpc('load', { cachedOnly: true }), error => error.code === 'MODEL_CACHE_MISS');
|
| 56 |
+
assert.equal(await rpc('stop'), true);
|
| 57 |
+
assert(events.some(event => event.type === 'workspace'));
|
| 58 |
+
assert(events.some(event => event.type === 'idle'));
|
| 59 |
+
});
|
| 60 |
+
|
| 61 |
+
test('a failed worker initialization identifies the failure stage and rejects subsequent RPCs', { timeout: 10_000 }, async t => {
|
| 62 |
+
const url = new URL('../src/agent.worker.js', import.meta.url).href;
|
| 63 |
+
const worker = new Worker(`
|
| 64 |
+
const { parentPort } = require('node:worker_threads');
|
| 65 |
+
globalThis.self = globalThis;
|
| 66 |
+
globalThis.postMessage = message => parentPort.postMessage(message);
|
| 67 |
+
parentPort.on('message', data => globalThis.onmessage({ data }));
|
| 68 |
+
// Simulate an unreadable workspace rather than a model or storage outage.
|
| 69 |
+
// Loading malformed saved entries fails after IndexedDB has opened.
|
| 70 |
+
globalThis.indexedDB = { open() {
|
| 71 |
+
const db = { transaction() { return { objectStore() { return { get() {
|
| 72 |
+
const request = { result: { entries: [['/workspace/file', { type: 'file', base64: 'invalid!' }]] } };
|
| 73 |
+
queueMicrotask(() => request.onsuccess());
|
| 74 |
+
return request;
|
| 75 |
+
} }; } }; } };
|
| 76 |
+
const request = { result: db };
|
| 77 |
+
queueMicrotask(() => request.onsuccess());
|
| 78 |
+
return request;
|
| 79 |
+
} };
|
| 80 |
+
import(${JSON.stringify(url)}).catch(error => { throw error; });
|
| 81 |
+
`, { eval: true });
|
| 82 |
+
t.after(() => worker.terminate());
|
| 83 |
+
const [fatal] = await once(worker, 'message');
|
| 84 |
+
assert.equal(fatal.type, 'fatal');
|
| 85 |
+
assert.equal(fatal.stage, 'initialization');
|
| 86 |
+
assert.match(fatal.error, /Reload the page/);
|
| 87 |
+
worker.postMessage({ id: 1, action: 'read', args: { path: 'README.md' } });
|
| 88 |
+
const [reply] = await once(worker, 'message');
|
| 89 |
+
assert.equal(reply.type, 'reply');
|
| 90 |
+
assert.equal(reply.id, 1);
|
| 91 |
+
assert(reply.error);
|
| 92 |
+
});
|
test/workspace.test.js
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import test from 'node:test';
|
| 2 |
+
import assert from 'node:assert/strict';
|
| 3 |
+
import { mkdtemp, readFile, writeFile, rm } from 'node:fs/promises';
|
| 4 |
+
import { tmpdir } from 'node:os';
|
| 5 |
+
import { join } from 'node:path';
|
| 6 |
+
import {
|
| 7 |
+
createWorkspace, createStore, DEFAULT_FILES, MAX_EDITOR_BYTES, WORKSPACE_DB_NAME,
|
| 8 |
+
} from '../src/workspace.js';
|
| 9 |
+
|
| 10 |
+
const tool = (workspace, name) => workspace.tools.find(tool => tool.name === name);
|
| 11 |
+
|
| 12 |
+
test('real shell combines the demo data with pipes, awk, jq, redirects, and subshells', async () => {
|
| 13 |
+
const workspace = createWorkspace();
|
| 14 |
+
const response = await tool(workspace, 'bash').execute({
|
| 15 |
+
command: `cat sales.csv | tail -n +2 | awk -F, '{total += $2} END {print total}' > units.txt
|
| 16 |
+
printf '%s\\n' "$(jq -r '.[0].product' products.json):$(cat units.txt)"`,
|
| 17 |
+
});
|
| 18 |
+
assert.equal(response.content, 'Notebook:50\n\nExit code: 0');
|
| 19 |
+
assert.deepEqual(response.details, { exitCode: 0 });
|
| 20 |
+
assert.equal(await workspace.read('units.txt'), '50\n');
|
| 21 |
+
});
|
| 22 |
+
|
| 23 |
+
test('each command resets cwd and variables while keeping file changes', async () => {
|
| 24 |
+
const workspace = createWorkspace();
|
| 25 |
+
const bash = tool(workspace, 'bash');
|
| 26 |
+
await bash.execute({ command: 'mkdir nested; cd nested; VALUE=private; echo saved > note.txt' });
|
| 27 |
+
const response = await bash.execute({ command: 'printf "%s:%s:%s" "$PWD" "${VALUE-unset}" "$(cat nested/note.txt)"' });
|
| 28 |
+
assert.equal(response.content, '/workspace:unset:saved\nExit code: 0');
|
| 29 |
+
});
|
| 30 |
+
|
| 31 |
+
test('file tools use plain JSON Schema and text results with exact replacement semantics', async () => {
|
| 32 |
+
const workspace = createWorkspace();
|
| 33 |
+
for (const { parameters } of workspace.tools) {
|
| 34 |
+
assert.equal(parameters.type, 'object');
|
| 35 |
+
assert.equal(parameters.additionalProperties, false);
|
| 36 |
+
assert.doesNotThrow(() => JSON.stringify(parameters));
|
| 37 |
+
}
|
| 38 |
+
await tool(workspace, 'write').execute({ path: 'notes/new.txt', content: 'hello 🌍\nhello again' });
|
| 39 |
+
const response = await tool(workspace, 'read').execute({ path: 'notes/new.txt' });
|
| 40 |
+
assert.equal(response.content, 'hello 🌍\nhello again');
|
| 41 |
+
assert.equal(response.details.path, '/workspace/notes/new.txt');
|
| 42 |
+
await assert.rejects(tool(workspace, 'edit').execute({ path: 'notes/new.txt', oldText: 'hello', newText: 'bad' }), /exactly once/);
|
| 43 |
+
await assert.rejects(tool(workspace, 'edit').execute({ path: 'notes/new.txt', oldText: '', newText: 'bad' }), /exactly once/);
|
| 44 |
+
await tool(workspace, 'edit').execute({ path: 'notes/new.txt', oldText: 'hello 🌍', newText: '$& is literal' });
|
| 45 |
+
assert.equal(await workspace.read('notes/new.txt'), '$& is literal\nhello again');
|
| 46 |
+
});
|
| 47 |
+
|
| 48 |
+
test('a failed command rejects with its exit code and useful output', async () => {
|
| 49 |
+
const workspace = createWorkspace();
|
| 50 |
+
await assert.rejects(tool(workspace, 'bash').execute({ command: 'echo problem >&2; exit 7' }), error => {
|
| 51 |
+
assert.equal(error.exitCode, 7);
|
| 52 |
+
assert.match(error.message, /STDERR:\nproblem/);
|
| 53 |
+
assert.match(error.message, /Exit code: 7/);
|
| 54 |
+
return true;
|
| 55 |
+
});
|
| 56 |
+
assert.equal((await tool(workspace, 'bash').execute({ command: 'echo recovered' })).details.exitCode, 0);
|
| 57 |
+
});
|
| 58 |
+
|
| 59 |
+
test('editor limit counts UTF-8 bytes and tool output is truncated without changing the file', async () => {
|
| 60 |
+
const workspace = createWorkspace();
|
| 61 |
+
await assert.rejects(workspace.write('large.txt', '🌍'.repeat(MAX_EDITOR_BYTES / 4 + 1)), /up to 1 MiB/);
|
| 62 |
+
const original = 'x'.repeat(7_000);
|
| 63 |
+
await workspace.write('large.txt', original);
|
| 64 |
+
const response = await tool(workspace, 'read').execute({ path: 'large.txt' });
|
| 65 |
+
assert.match(response.content, /Output truncated at 6,000 characters/);
|
| 66 |
+
assert.equal(await workspace.read('large.txt'), original);
|
| 67 |
+
});
|
| 68 |
+
|
| 69 |
+
test('snapshot and serialization preserve default files and exclude generated command/device entries', async () => {
|
| 70 |
+
const workspace = createWorkspace();
|
| 71 |
+
assert.deepEqual(await workspace.snapshot(), DEFAULT_FILES);
|
| 72 |
+
const entries = await workspace.serialize();
|
| 73 |
+
assert.ok(entries.some(([path]) => path === '/workspace'));
|
| 74 |
+
assert.ok(!entries.some(([path]) => /^\/(?:bin|usr\/bin|dev|proc)(?:\/|$)/.test(path)));
|
| 75 |
+
const restored = createWorkspace(undefined, entries);
|
| 76 |
+
assert.deepEqual(await restored.snapshot(), DEFAULT_FILES);
|
| 77 |
+
});
|
| 78 |
+
|
| 79 |
+
test('binary contents, executable modes, empty directories, and symbolic links survive serialization', async () => {
|
| 80 |
+
const workspace = createWorkspace({});
|
| 81 |
+
await workspace.ready;
|
| 82 |
+
const binary = new Uint8Array([0, 255, 128, 65, 10, 0, 254]);
|
| 83 |
+
await workspace.bash.fs.writeFile('/workspace/binary.dat', binary);
|
| 84 |
+
await workspace.bash.fs.chmod('/workspace/binary.dat', 0o640);
|
| 85 |
+
await workspace.bash.fs.mkdir('/workspace/empty');
|
| 86 |
+
await workspace.bash.fs.chmod('/workspace/empty', 0o750);
|
| 87 |
+
await workspace.write('hello.sh', '#!/bin/bash\necho hello\n');
|
| 88 |
+
await workspace.bash.fs.chmod('/workspace/hello.sh', 0o755);
|
| 89 |
+
await workspace.bash.fs.symlink('hello.sh', '/workspace/hello-link');
|
| 90 |
+
const restored = createWorkspace(undefined, await workspace.serialize());
|
| 91 |
+
await restored.ready;
|
| 92 |
+
assert.deepEqual(await restored.bash.fs.readFileBuffer('/workspace/binary.dat'), binary);
|
| 93 |
+
assert.equal((await restored.bash.fs.stat('/workspace/binary.dat')).mode & 0o777, 0o640);
|
| 94 |
+
assert.equal((await restored.bash.fs.stat('/workspace/empty')).mode & 0o777, 0o750);
|
| 95 |
+
assert.equal((await restored.bash.fs.stat('/workspace/hello.sh')).mode & 0o777, 0o755);
|
| 96 |
+
assert.equal(await restored.bash.fs.readlink('/workspace/hello-link'), 'hello.sh');
|
| 97 |
+
assert.equal(await restored.read('hello-link'), '#!/bin/bash\necho hello\n');
|
| 98 |
+
assert.equal((await tool(restored, 'bash').execute({ command: 'bash hello-link' })).content, 'hello\n\nExit code: 0');
|
| 99 |
+
});
|
| 100 |
+
|
| 101 |
+
test('aborting a live shell interrupts it and later commands remain usable', async () => {
|
| 102 |
+
const workspace = createWorkspace();
|
| 103 |
+
const controller = new AbortController();
|
| 104 |
+
const running = tool(workspace, 'bash').execute({ command: 'sleep 10; echo should-not-run' }, { signal: controller.signal });
|
| 105 |
+
const timer = setTimeout(() => controller.abort(new DOMException('Stopped by user', 'AbortError')), 20);
|
| 106 |
+
try {
|
| 107 |
+
await assert.rejects(running, /Stopped by user|abort/i);
|
| 108 |
+
} finally {
|
| 109 |
+
clearTimeout(timer);
|
| 110 |
+
}
|
| 111 |
+
assert.equal((await tool(workspace, 'bash').execute({ command: 'echo ready' })).content, 'ready\n\nExit code: 0');
|
| 112 |
+
});
|
| 113 |
+
|
| 114 |
+
test('pre-aborted file operations do not change files', async () => {
|
| 115 |
+
const workspace = createWorkspace();
|
| 116 |
+
const controller = new AbortController();
|
| 117 |
+
controller.abort(new DOMException('Stopped', 'AbortError'));
|
| 118 |
+
for (const [name, args] of [
|
| 119 |
+
['write', { path: 'sales.csv', content: 'lost' }],
|
| 120 |
+
['edit', { path: 'sales.csv', oldText: 'Notebook', newText: 'lost' }],
|
| 121 |
+
['read', { path: 'sales.csv' }],
|
| 122 |
+
['bash', { command: 'echo lost > sales.csv' }],
|
| 123 |
+
]) await assert.rejects(tool(workspace, name).execute(args, { signal: controller.signal }), /Stopped/);
|
| 124 |
+
assert.equal(await workspace.read('sales.csv'), DEFAULT_FILES['/workspace/sales.csv']);
|
| 125 |
+
});
|
| 126 |
+
|
| 127 |
+
test('shell cannot use host binaries or network commands and does not call fetch', async () => {
|
| 128 |
+
const workspace = createWorkspace();
|
| 129 |
+
const originalFetch = globalThis.fetch;
|
| 130 |
+
let calls = 0;
|
| 131 |
+
globalThis.fetch = async () => { calls++; throw new Error('Unexpected network call'); };
|
| 132 |
+
try {
|
| 133 |
+
for (const command of ['curl https://example.com', 'wget https://example.com', 'node -e "process.exit(0)"', 'python3 -c "print(1)"', 'npm --version', 'js "console.log(1)"']) {
|
| 134 |
+
await assert.rejects(tool(workspace, 'bash').execute({ command }), /command not (?:found|available)/);
|
| 135 |
+
}
|
| 136 |
+
assert.equal(calls, 0);
|
| 137 |
+
} finally {
|
| 138 |
+
globalThis.fetch = originalFetch;
|
| 139 |
+
}
|
| 140 |
+
});
|
| 141 |
+
|
| 142 |
+
test('shell absolute paths stay in the virtual filesystem', async () => {
|
| 143 |
+
const temporary = await mkdtemp(join(tmpdir(), 'minicpm-workspace-test-'));
|
| 144 |
+
const hostFile = join(temporary, 'private.txt');
|
| 145 |
+
try {
|
| 146 |
+
await writeFile(hostFile, 'host-only-secret');
|
| 147 |
+
const workspace = createWorkspace();
|
| 148 |
+
await assert.rejects(tool(workspace, 'bash').execute({ command: `cat '${hostFile}'` }), /No such file|ENOENT|not found/);
|
| 149 |
+
await tool(workspace, 'bash').execute({ command: `mkdir -p '${temporary}'; printf virtual > '${hostFile}'` });
|
| 150 |
+
assert.equal(await workspace.read(hostFile), 'virtual');
|
| 151 |
+
assert.equal(await readFile(hostFile, 'utf8'), 'host-only-secret');
|
| 152 |
+
} finally {
|
| 153 |
+
await rm(temporary, { recursive: true, force: true });
|
| 154 |
+
}
|
| 155 |
+
});
|
| 156 |
+
|
| 157 |
+
test('shell rejects oversized source and excessive virtual filesystem allocation', async () => {
|
| 158 |
+
const workspace = createWorkspace({});
|
| 159 |
+
await assert.rejects(tool(workspace, 'bash').execute({ command: '#'.repeat(131_073) }), /script input size limit/);
|
| 160 |
+
await workspace.ready;
|
| 161 |
+
await assert.rejects(workspace.bash.fs.writeFile('/workspace/excessive.bin', new Uint8Array(16 * 1024 * 1024 + 1)), /ENOSPC|byte limit/);
|
| 162 |
+
});
|
| 163 |
+
|
| 164 |
+
test('new storage name avoids legacy Pi message data and missing IndexedDB is explicit', async () => {
|
| 165 |
+
assert.equal(WORKSPACE_DB_NAME, 'minicpm5-gguf-workspace');
|
| 166 |
+
await assert.rejects(createStore(null), /Browser storage is unavailable/);
|
| 167 |
+
});
|
vendor/just-bash.js
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|