Download app/src/main.mjs from Mike0021/MiniCPM5-2B-WebGPU-Pi-HTTP: direct link, hf CLI and curl.
- Browser
- Download file 18 kB
-
https://huggingface.co/spaces/Mike0021/MiniCPM5-2B-WebGPU-Pi-HTTP/resolve/main/app/src/main.mjs
- Command line
-
hf download hf://spaces/Mike0021/MiniCPM5-2B-WebGPU-Pi-HTTP/app/src/main.mjs
-
curl -L -o main.mjs https://huggingface.co/spaces/Mike0021/MiniCPM5-2B-WebGPU-Pi-HTTP/resolve/main/app/src/main.mjs
18 kB
| import './style.css'; | |
| import { createTerminalUI } from './pi/terminal-ui.mjs'; | |
| import { createLoadDialog } from './load-dialog.mjs'; | |
| import { createShellUI } from './shell-ui.mjs'; | |
| import { restoredContextUsage } from './context-usage.mjs'; | |
| import { createRuntimeMeter } from './runtime-meter.mjs'; | |
| import { createFileIcon } from './file-icons.mjs'; | |
| import { createEditorUI } from './editor-ui.mjs'; | |
| import { createColumnResizer } from './column-resizer.mjs'; | |
| import { createLocalFolderUI } from './local-folder-ui.mjs'; | |
| const $ = selector => document.querySelector(selector); | |
| createColumnResizer($('main'), $('#column-resizer')); | |
| const worker = new Worker(new URL('./agent.worker.mjs', import.meta.url), { type: 'module' }); | |
| const pending = new Map(); | |
| let nextId = 1, busy = false, loaded = false, selected, dirty = false, files = {}, messages = [], readyResolve; | |
| let pendingReadPath; | |
| let previewVersion = 0, previewReady = true; | |
| const ready = new Promise(resolve => { readyResolve = resolve; }); | |
| const diagnostics = { events: [], generations: [], runtimeSamples: [], device: null, contextUsage: restoredContextUsage([]) }; | |
| const runtimeMeter = createRuntimeMeter($('#runtime-meter')); | |
| const editorUI = createEditorUI($('.editor-input')); | |
| const terminalUI = createTerminalUI($('#pi-terminal'), { | |
| onInput: text => $('#send').classList.toggle('has-input', Boolean(text.trim())), | |
| load: () => ready.then(load), prompt: text => ready.then(() => prompt(text)), | |
| newChat: () => ready.then(newChat), stop: () => rpc('stop'), | |
| shell: command => { if (dirty) throw Error('Save your file edits before running commands.'); return rpc('shell', { command }); }, | |
| }); | |
| const loadDialog = createLoadDialog($('#model-dialog'), { onClose: () => terminalUI.focus() }); | |
| const shellUI = createShellUI($('.workspace > .terminal'), { run: async command => { | |
| await ready; | |
| if (dirty) throw Error('Save your file edits before running commands.'); | |
| if (busy) throw Error('Wait for Pi to finish or stop the current task first.'); | |
| return rpc('shell', { command }); | |
| } }); | |
| const localUI = createLocalFolderUI({ rpc, notice, | |
| canSwitch() { | |
| if (busy) throw Error('Wait for Pi to finish or stop the current task first.'); | |
| if (dirty) throw Error('Save or stage the open file first.'); | |
| }, | |
| onSwitch(state) { | |
| pendingReadPath = undefined; selected = undefined; dirty = false; previewVersion++; previewReady = false; | |
| files = state.files; messages = state.messages; localUI.setState(state.localFolder); | |
| $('#filename').textContent = 'Select a file'; $('#filetype').replaceChildren(); editorUI.setFile('', ''); | |
| terminalUI.setMessages(messages, { reset: true }); updateContextUsage(restoredContextUsage(messages)); | |
| $('#shell-clear').click(); renderFiles(); | |
| const file = Object.hasOwn(files, '/workspace/README.md') ? '/workspace/README.md' : Object.keys(files).sort()[0]; | |
| if (file) selectFile(file); | |
| controls(); | |
| }, | |
| }); | |
| function rpc(action, args) { | |
| return new Promise((resolve, reject) => { | |
| const id = nextId++; pending.set(id, { resolve, reject }); worker.postMessage({ id, action, args }); | |
| }); | |
| } | |
| function notice(text) { $('#notice').textContent = text; $('#notice').hidden = !text; } | |
| function storageHelp(error) { | |
| if (window.self === window.top || !/denied|storage directory access/i.test(error)) return error; | |
| $('#open-tab').href = location.href; | |
| $('#open-tab').hidden = false; | |
| return 'Your browser blocked storage in this embedded app. Open the workspace in a new tab to load the model and save files.'; | |
| } | |
| function controls() { | |
| $('#load').disabled = busy || loaded; | |
| $('#load').classList.toggle('is-loaded', loaded); | |
| if (loaded) $('#load').textContent = 'Loaded'; | |
| else if (!busy) $('#load').textContent = 'Load model'; | |
| $('#load').title = $('#load-status').textContent + ' ' + $('#load-detail').textContent; | |
| $('#send').disabled = busy; | |
| $('#newline').disabled = busy; | |
| terminalUI.setState({ busy, loaded, activity: $('#activity').textContent }); | |
| $('#stop').hidden = !busy; | |
| for (const id of ['#new-chat', '#add-file', '#upload']) $(id).disabled = busy; | |
| shellUI.setBusy(busy); | |
| localUI.setBusy(busy); | |
| $('#save').disabled = busy || !selected || !dirty; | |
| $('#editor').disabled = busy || !selected || !previewReady; | |
| } | |
| function renderFiles() { | |
| terminalUI.setFiles(files); | |
| $('#files').replaceChildren(); | |
| for (const file of Object.keys(files).sort()) { | |
| const button = document.createElement('button'), label = document.createElement('span'); | |
| label.className = 'file-name'; label.textContent = file.replace(/^\/workspace\//, ''); | |
| button.append(createFileIcon(file), label); | |
| button.title = file; button.classList.toggle('selected', file === selected); | |
| button.onclick = () => selectFile(file); $('#files').append(button); | |
| } | |
| if (selected && !Object.hasOwn(files, selected) && !dirty) { | |
| selected = undefined; previewVersion++; previewReady = false; | |
| $('#filename').textContent = 'Select a file'; $('#filetype').replaceChildren(); editorUI.setFile('', ''); controls(); | |
| } | |
| if (selected && !dirty) { | |
| if (files[selected] === null) loadPreview(selected); | |
| else { previewReady = true; editorUI.setFile(selected, files[selected] ?? ''); } | |
| } | |
| } | |
| async function loadPreview(file) { | |
| const version = ++previewVersion; | |
| previewReady = false; editorUI.setFile(file, '', 'Loading file…'); controls(); | |
| try { | |
| const content = await rpc('preview', { path: file }); | |
| if (version !== previewVersion || file !== selected || dirty) return; | |
| editorUI.setFile(file, content); previewReady = true; | |
| } catch (error) { | |
| if (version !== previewVersion || file !== selected) return; | |
| const message = error.code === 'BINARY_FILE' ? 'No text preview available.\nThis file is binary or isn’t UTF-8 text.' | |
| : error.code === 'FILE_TOO_LARGE' ? 'File too large to preview (1 MiB limit).' | |
| : 'Unable to preview this file.\n' + error.message; | |
| editorUI.setFile(file, '', message); | |
| } | |
| finally { if (version === previewVersion) controls(); } | |
| } | |
| function selectFile(file) { | |
| pendingReadPath = undefined; | |
| if (dirty) { notice('Save the open file before selecting another file.'); return; } | |
| selected = file; | |
| $('#filename').textContent = localUI.folderName | |
| ? file.replace(/^\/workspace(?=\/|$)/, () => '/' + localUI.folderName) | |
| : file; | |
| $('#filetype').replaceChildren(createFileIcon(file)); | |
| renderFiles(); controls(); | |
| } | |
| function followReadFile() { | |
| if (dirty) { pendingReadPath = undefined; return; } | |
| if (!pendingReadPath || !Object.hasOwn(files, pendingReadPath)) return; | |
| const file = pendingReadPath, changed = file !== selected; | |
| selectFile(file); | |
| if (changed) { $('#editor').scrollTop = 0; $('#editor').scrollLeft = 0; } | |
| // Reveal the highlighted row inside the file list, without scrolling the | |
| // page away from the conversation on narrow screens or moving focus. | |
| const row = [...$('#files').children].find(button => button.title === file); | |
| if (row) { | |
| const bounds = row.getBoundingClientRect(), list = $('#files').getBoundingClientRect(); | |
| if (bounds.top < list.top) $('#files').scrollTop += bounds.top - list.top; | |
| else if (bounds.bottom > list.bottom) $('#files').scrollTop += bounds.bottom - list.bottom; | |
| } | |
| } | |
| function renderMessages() { terminalUI.setMessages(messages); } | |
| function updateContextUsage(usage) { | |
| diagnostics.contextUsage = usage; | |
| terminalUI.setState({ contextUsage: usage }); | |
| } | |
| worker.onmessage = ({ data }) => { | |
| runtimeMeter.update(data); | |
| if (data.type === 'inference_activity') { | |
| diagnostics.runtimeSamples.push(data); | |
| if (diagnostics.runtimeSamples.length > 160) diagnostics.runtimeSamples.shift(); | |
| } | |
| if (data.type === 'reply') { | |
| const request = pending.get(data.id); pending.delete(data.id); | |
| if (data.error) request?.reject(Object.assign(Error(data.error), { name: data.errorName || 'Error', code: data.errorCode })); else request?.resolve(data.value); | |
| } else if (data.type === 'initialized') { | |
| localUI.restore(data.savedLocal); localUI.setState(data.localFolder); | |
| files = data.files; messages = data.messages; renderFiles(); renderMessages(); | |
| updateContextUsage(restoredContextUsage(messages)); | |
| if (files['/workspace/README.md']) selectFile('/workspace/README.md'); | |
| // Do not steal focus if the user chose another control during startup. | |
| if (document.activeElement === document.body) terminalUI.focus(); | |
| readyResolve(); | |
| } else if (data.type === 'busy') { | |
| if (data.action === 'load') $('#load').textContent = 'Loading…'; | |
| busy = true; $('#activity').textContent = data.action === 'prompt' ? 'Pi is working…' : data.action === 'load' ? (data.cachedOnly ? 'Loading local model…' : 'Loading model…') : 'Working…'; controls(); | |
| } else if (data.type === 'idle') { | |
| busy = false; $('#progress').hidden = true; $('#activity').textContent = localUI.active ? 'Local folder · review & apply to save changes to disk' : loaded ? 'Ready · running on your device' : 'Files and chat are saved on this device.'; controls(); | |
| } else if (data.type === 'workspace') { files = data.files; localUI.setState(data.localFolder); renderFiles(); followReadFile(); } | |
| else if (data.type === 'device') { diagnostics.device = data.device; } | |
| else if (data.type === 'load_progress') { | |
| $('#progress').hidden = false; | |
| if (data.phase === 'download') { | |
| $('#progress').hidden = false; $('#progress').value = data.loaded / data.total * 100; | |
| $('#load-status').textContent = data.loaded === data.total ? 'Download verified. Preparing model…' : 'Downloading model weights from Hugging Face…'; | |
| $('#load-detail').textContent = `${(data.loaded / 1e9).toFixed(2)} / ${(data.total / 1e9).toFixed(2)} GB · ${Math.round(data.loaded / data.total * 100)}%${data.cached ? ` · ${(data.cached / 1e9).toFixed(2)} GB cached` : ''}`; | |
| $('#load').textContent = `Loading ${Math.round(data.loaded / data.total * 100)}%`; | |
| } else { $('#load-status').textContent = data.phase === 'compile' ? 'Preparing WebGPU session…' : 'Warming up GPU shaders…'; $('#load-detail').textContent = ''; $('#progress').removeAttribute('value'); $('#load').textContent = data.phase === 'compile' ? 'Preparing…' : 'Almost ready…'; } | |
| $('#load').title = $('#load-status').textContent + ' ' + $('#load-detail').textContent; | |
| } else if (data.type === 'loaded') { | |
| loaded = true; diagnostics.device = data.device; $('#progress').hidden = true; $('#load').textContent = 'Loaded'; | |
| $('#load-status').textContent = 'Ready. Model inference and tools run entirely in this browser.'; | |
| $('#load-detail').textContent = data.cachedBytes ? 'Verified model restored from browser storage.' : 'SHA-256 verified · model cached for next time.'; controls(); | |
| } else if (data.type === 'agent_event') { | |
| const event = data.event; | |
| diagnostics.events.push(event.type); if (diagnostics.events.length > 500) diagnostics.events.shift(); | |
| if (event.message) { | |
| const index = messages.findIndex(m => m.timestamp === event.message.timestamp && m.role === event.message.role && m.toolCallId === event.message.toolCallId); | |
| if (index < 0) messages.push(event.message); else messages[index] = event.message; | |
| renderMessages(); | |
| } | |
| if (event.type === 'tool_execution_start') $('#activity').textContent = `Running ${event.toolName}…`; | |
| if (event.type === 'tool_execution_end' && event.toolName === 'read' && !event.isError && typeof event.result?.details?.path === 'string') { | |
| pendingReadPath = event.result.details.path; | |
| followReadFile(); | |
| } | |
| if (event.type === 'turn_start') $('#activity').textContent = 'Pi is thinking…'; | |
| terminalUI.setState({ activity: $('#activity').textContent }); | |
| } else if (data.type === 'context_usage') { updateContextUsage({ inputTokens: data.inputTokens, outputTokens: data.outputTokens }); } | |
| else if (data.type === 'generation') { diagnostics.generations.push(data); if (diagnostics.generations.length > 100) diagnostics.generations.shift(); } | |
| else if (data.type === 'context_trim') notice('Older turns were left out of the model context. Your full chat remains saved.'); | |
| else if (data.type === 'notice') notice(data.text); | |
| else if (data.type === 'persistence_error') notice(storageHelp(data.error)); | |
| else if (data.type === 'fatal') { loaded = false; notice(data.error); controls(); } | |
| }; | |
| worker.onerror = event => { | |
| runtimeMeter.update({ type: 'fatal' }); | |
| const error = Error(event.message || 'The worker stopped. Reload to restore your saved workspace.'); | |
| notice(error.message); for (const request of pending.values()) request.reject(error); pending.clear(); busy = false; loaded = false; $('#progress').hidden = true; controls(); | |
| }; | |
| const guarded = fn => async event => { event?.preventDefault(); try { await ready; await fn(event); } catch (e) { notice(e.message); } }; | |
| async function load({ cachedOnly = false } = {}) { | |
| if (busy) throw Error('Wait for the current operation or stop it first.'); | |
| notice(''); const query = new URLSearchParams(location.search); | |
| busy = true; | |
| $('#load').textContent = 'Loading…'; | |
| $('#activity').textContent = cachedOnly ? 'Loading local model…' : 'Loading model…'; | |
| $('#load-status').textContent = cachedOnly ? 'Loading local model…' : 'Checking your browser and saved model…'; | |
| $('#load-detail').textContent = ''; | |
| $('#progress').hidden = false; $('#progress').removeAttribute('value'); | |
| runtimeMeter.update({ type: 'busy', action: 'load', cachedOnly }); controls(); | |
| try { | |
| return await rpc('load', { runtimeURL: new URL('./runtime/', location.href).href, | |
| local: query.get('source') === 'local', origin: location.origin, limit128: query.has('limit128'), cachedOnly }); | |
| } catch (error) { error.message = storageHelp(error.message); $('#load-status').textContent = error.message; $('#progress').hidden = true; throw error; } | |
| } | |
| async function prompt(text) { | |
| if (!text.trim()) return; | |
| if (dirty) throw Error('Save your file edits before asking Pi to work on them.'); | |
| try { | |
| if (!loaded) { | |
| let loadError; | |
| if (await rpc('cache_status')) { | |
| try { terminalUI.setQueuedPrompt(text); await load({ cachedOnly: true }); } | |
| catch (error) { | |
| if (error.name === 'AbortError') throw error; | |
| if (error.code !== 'MODEL_CACHE_MISS') loadError = error.message; | |
| } | |
| } | |
| while (!loaded) { | |
| terminalUI.setQueuedPrompt(''); | |
| if (!await loadDialog.request(text, { errorMessage: loadError })) throw new DOMException('Message kept for later.', 'AbortError'); | |
| terminalUI.setQueuedPrompt(text); | |
| try { await load(); } | |
| catch (error) { | |
| if (error.name === 'AbortError') throw error; | |
| loadError = error.message; | |
| } | |
| } | |
| } | |
| terminalUI.setQueuedPrompt(''); | |
| notice(''); const result = await rpc('prompt', { text }); messages = result.messages; renderMessages(); return result; | |
| } finally { terminalUI.setQueuedPrompt(''); } | |
| } | |
| $('#load').onclick = guarded(load); | |
| $('#send').onclick = () => { terminalUI.submit(); terminalUI.focus(); }; | |
| $('#newline').onclick = () => terminalUI.newline(); | |
| $('#stop').onclick = guarded(() => rpc('stop')); | |
| $('.terminal-panel').addEventListener('click', event => { | |
| // Let Pi handle its own mouse targets, and keep controls and text selection | |
| // independent of the surrounding space that returns focus to the editor. | |
| if (event.defaultPrevented || event.button !== 0 || !(event.target instanceof Element)) return; | |
| if (event.target.closest('#pi-terminal, button, a, input, textarea, select, label, summary, [role="button"], [contenteditable]')) return; | |
| if (window.getSelection()?.isCollapsed === false) return; | |
| terminalUI.focus(); | |
| }); | |
| async function newChat() { | |
| pendingReadPath = undefined; | |
| const state = await rpc('new_chat'); messages = state.messages; | |
| updateContextUsage(restoredContextUsage(messages)); | |
| terminalUI.setMessages(messages, { reset: true }); notice(''); | |
| terminalUI.focus(); | |
| } | |
| $('#new-chat').onclick = guarded(newChat); | |
| $('#editor').oninput = () => { dirty = true; controls(); }; | |
| $('#save').onclick = guarded(async () => { await rpc('write', { path: selected, content: $('#editor').value }); dirty = false; renderFiles(); controls(); notice(''); }); | |
| $('#add-file').onclick = guarded(async () => { | |
| if (dirty) throw Error('Save the open file first.'); | |
| const name = window.prompt('File path, relative to /workspace:'); if (!name?.trim()) return; | |
| const path = name.startsWith('/') ? name : '/workspace/' + name; | |
| if (Object.hasOwn(files, path)) throw Error('That file already exists.'); | |
| await rpc('write', { path, content: '' }); selectFile(path); | |
| }); | |
| $('#upload').onchange = guarded(async () => { | |
| if (dirty) throw Error('Save the open file first.'); | |
| for (const file of $('#upload').files) { | |
| if (file.size > 1024 ** 2) throw Error('Import text files of 1 MiB or less.'); | |
| const content = await file.text(); | |
| if (content.includes('\0')) throw Error('Import supports text files only.'); | |
| const path = '/workspace/' + file.name; | |
| if (Object.hasOwn(files, path)) throw Error(file.name + ' already exists. Rename it before importing.'); | |
| await rpc('write', { path, content }); selectFile(path); | |
| } | |
| $('#upload').value = ''; | |
| }); | |
| $('#export').onclick = guarded(async () => { | |
| const state = localUI.active ? { version: 1, patches: await rpc('local_changes') } : await rpc('snapshot'); | |
| const url = URL.createObjectURL(new Blob([JSON.stringify(state, null, 2)], { type: 'application/json' })); | |
| const a = document.createElement('a'); a.href = url; a.download = localUI.active ? 'pi-staged-changes.json' : 'pi-workspace.json'; a.click(); setTimeout(() => URL.revokeObjectURL(url), 1000); | |
| }); | |
| // Browser verification exercises the same worker and real Pi / WebGPU paths as the UI. | |
| window.browserPi = { ready, load, prompt, rpc, diagnostics, terminal: terminalUI, runtimeMeter, get loaded() { return loaded; }, get busy() { return busy; } }; | |
| controls(); | |