Spaces:
Running
Running
| <html lang="en"> | |
| <head> | |
| <meta charset="utf-8" /> | |
| <meta name="viewport" content="width=device-width, initial-scale=1" /> | |
| <title>MiniCPM5-1B WebGPU spike — think-on tool-calling</title> | |
| <style> | |
| :root { color-scheme: dark; } | |
| body { font: 14px/1.5 ui-monospace, SFMono-Regular, Menlo, monospace; background:#0d1017; color:#c9d1d9; margin:0; padding:16px; } | |
| h1 { font-size:16px; margin:0 0 4px; } | |
| .sub { color:#8b949e; margin:0 0 16px; font-size:12px; } | |
| button { font:inherit; background:#238636; color:#fff; border:0; border-radius:6px; padding:8px 16px; cursor:pointer; } | |
| button:disabled { background:#30363d; color:#8b949e; cursor:default; } | |
| #env { margin:8px 0; padding:8px 12px; border-radius:6px; background:#161b22; border:1px solid #30363d; } | |
| .ok { color:#3fb950; } .bad { color:#f85149; } .warn { color:#d29922; } | |
| #metrics { display:grid; grid-template-columns:repeat(auto-fit,minmax(140px,1fr)); gap:8px; margin:12px 0; } | |
| .m { background:#161b22; border:1px solid #30363d; border-radius:6px; padding:8px 12px; } | |
| .m .k { color:#8b949e; font-size:11px; } .m .v { font-size:18px; color:#58a6ff; } | |
| .test { border:1px solid #30363d; border-radius:6px; margin:10px 0; overflow:hidden; } | |
| .test h3 { margin:0; padding:8px 12px; background:#161b22; font-size:13px; display:flex; justify-content:space-between; } | |
| .test pre { margin:0; padding:10px 12px; white-space:pre-wrap; word-break:break-word; font-size:12px; max-height:280px; overflow:auto; } | |
| .badge { border-radius:10px; padding:1px 8px; font-size:11px; } | |
| .badge.pass { background:#1a3d24; color:#3fb950; } .badge.fail { background:#3d1a1a; color:#f85149; } | |
| #log { color:#8b949e; font-size:11px; white-space:pre-wrap; margin-top:12px; max-height:160px; overflow:auto; } | |
| </style> | |
| </head> | |
| <body> | |
| <h1>MiniCPM5-1B WebGPU spike</h1> | |
| <p class="sub" id="sub">Lens C + think-on tool-calling proof · web-llm/WebGPU · @0.3</p> | |
| <div id="env">checking environment…</div> | |
| <button id="run" disabled>Run spike</button> | |
| <div id="metrics"></div> | |
| <div id="tests"></div> | |
| <div id="log"></div> | |
| <script type="module"> | |
| import * as webllm from "https://cdn.jsdelivr.net/npm/@mlc-ai/web-llm@0.2.84/lib/index.js"; | |
| const $ = (id) => document.getElementById(id); | |
| const log = (m) => { $("log").textContent += m + "\n"; $("log").scrollTop = 1e9; }; | |
| // Default = OUR clean conversion (mlc-convert Space, ffi-0.1.10 toolchain). | |
| // ?model=christophdet switches to the community build (known-garbage weights) for A/B. | |
| const VARIANTS = { | |
| tinkersnot: { | |
| repo: "https://huggingface.co/tinkersnot/minicpm5-1b-q4f16-MLC/resolve/main", | |
| modelId: "minicpm5-1b-q4f16-MLC", | |
| modelDir: "", // files at repo root | |
| wasm: "minicpm5-1b-q4f16-MLC-ctx4096-webgpu.wasm", | |
| label: "tinkersnot/minicpm5-1b-q4f16-MLC (own clean conversion)", | |
| }, | |
| r24: { | |
| repo: "https://huggingface.co/tinkersnot/minicpm5-1b-r24-merged-MLC/resolve/main", | |
| modelId: "minicpm5-1b-r24-merged-MLC", | |
| modelDir: "", | |
| wasm: "minicpm5-1b-r24-merged-MLC-ctx4096-webgpu.wasm", | |
| label: "tinkersnot/minicpm5-1b-r24-merged-MLC (R24 turn-pred — No-Think instrument; think-on tests are NOT its exam)", | |
| }, | |
| christophdet: { | |
| repo: "https://huggingface.co/christophdet/MiniCPM5-1B-q4f16_1-MLC/resolve/main", | |
| modelId: "MiniCPM5-1B-q4f16_1-MLC", | |
| modelDir: "MiniCPM5-1B-q4f16_1-MLC", | |
| wasm: "MiniCPM5-1B-q4f16_1-ctx4k-webgpu.wasm", | |
| label: "christophdet/MiniCPM5-1B-q4f16_1-MLC (community, garbage weights)", | |
| }, | |
| }; | |
| const V = VARIANTS[new URLSearchParams(location.search).get("model")] || VARIANTS.tinkersnot; | |
| const REPO = V.repo; | |
| const MODEL_ID = V.modelId; | |
| document.getElementById("sub").textContent = | |
| `Lens C + think-on tool-calling proof · model: ${V.label} · web-llm/WebGPU · @0.3`; | |
| // ── native MiniCPM5 render (the config's conv_template is generic chatml with | |
| // function-calling OFF, so we render the model's OWN tool/think format by hand — | |
| // same shape proved in the training harvest) ────────────────────────────────── | |
| function renderPrompt(system, user, tools, think) { | |
| const toolBlock = tools ? ( | |
| "# Tools\n\nYou are provided with function signatures within <tools></tools> XML tags:\n<tools>\n" + | |
| tools.map((t) => JSON.stringify(t)).join("\n") + | |
| "\n</tools>\n\nWhen calling a function, return an XML object within <function ... </function> using:\n" + | |
| '<function name="function-name"><param name="param-name">param-value</param></function>' | |
| ) : ""; | |
| const sys = [system, toolBlock].filter(Boolean).join("\n\n"); | |
| // literal BOS: web-llm raw completions encode with add_special_tokens=false — | |
| // no post-processor BOS. "<s>" matches as added token id 0; without it the | |
| // model degenerates (echo/repetition — the earlier "garbage weights" signature). | |
| let p = "<s>"; | |
| if (sys) p += `<|im_start|>system\n${sys}<|im_end|>\n`; | |
| p += `<|im_start|>user\n${user}<|im_end|>\n`; | |
| p += `<|im_start|>assistant\n`; | |
| p += think ? "<think>\n" : "<think>\n\n</think>\n\n"; | |
| return p; | |
| } | |
| function parseEmission(text) { | |
| let think = "", rest = text; | |
| if (text.includes("</think>")) { const i = text.indexOf("</think>"); think = text.slice(0, i).replace("<think>", "").trim(); rest = text.slice(i + 8); } | |
| const calls = []; | |
| const re = /<function name="([^"]+)">([\s\S]*?)<\/function>/g; let m; | |
| while ((m = re.exec(rest))) { | |
| const args = {}; const pr = /<param name="([^"]+)">([\s\S]*?)<\/param>/g; let pm; | |
| while ((pm = pr.exec(m[2]))) args[pm[1]] = pm[2].replace(/^\s*<!\[CDATA\[|\]\]>\s*$/g, "").trim(); | |
| calls.push({ name: m[1], args }); | |
| } | |
| return { think, calls, content: rest.replace(re, "").trim() }; | |
| } | |
| const QUICK = new URLSearchParams(location.search).has("quick"); | |
| const MAXTOK = Number(new URLSearchParams(location.search).get("tok")) || (QUICK ? 320 : 1024); | |
| const TESTS_ALL = [ | |
| { id: "tool-invoke", think: true, expect: (r) => r.calls[0]?.name === "execute", | |
| system: "You are the CORTEX agent inside Nexus Terminal. Use the available tools.", | |
| user: "Open an NVDA chart window.", | |
| tools: [{ type:"function", function:{ name:"execute", description:"Open windows via terminal commands", parameters:{ type:"object", properties:{ command:{type:"string"} }, required:["command"] } }}] }, | |
| { id: "consume-ground", think: true, expect: (r) => r.content.includes("8443"), | |
| system: "You are the CORTEX agent. Ground answers in tool results.", | |
| // pre-seed the tool result in the prompt so the model must READ it | |
| user: "What port is the gateway on? I ran the check and got:\n<tool_response>\nlisten_port = 8443\nmode = tls\n</tool_response>\nAnswer from that.", | |
| tools: null }, | |
| { id: "plain-chat", think: true, expect: (r) => r.content.length > 0 && r.calls.length === 0, | |
| system: "You are a helpful assistant.", user: "In one sentence, what is a terminal workspace?", tools: null }, | |
| ]; | |
| const TESTS = QUICK ? TESTS_ALL.slice(0, 1) : TESTS_ALL; | |
| async function main() { | |
| // env probe | |
| const gpu = navigator.gpu; | |
| let adapter = null, info = ""; | |
| if (gpu) { try { | |
| adapter = await Promise.race([ gpu.requestAdapter(), new Promise((r) => setTimeout(() => r("TIMEOUT"), 4000)) ]); | |
| if (adapter === "TIMEOUT") { $("env").innerHTML = '<span class="bad">✗ requestAdapter() timed out</span> — WebGPU blocked by frame permissions. Open this page in a top-level browser tab (not an embedded preview/iframe).'; return; } | |
| const a = (adapter && adapter.info) || {}; info = `${a.vendor||""} ${a.architecture||""}`.trim(); | |
| } catch(e) { info = "probe-err:" + e.message; } } | |
| if (!gpu) { $("env").innerHTML = '<span class="bad">✗ navigator.gpu ABSENT</span> — this browser/context has no WebGPU. Use Chrome 121+/Edge, or Safari 18+ (iOS 18+), over https or localhost.'; return; } | |
| if (!adapter) { | |
| try { adapter = await gpu.requestAdapter({ forceFallbackAdapter: true }); if (adapter) info = "FALLBACK/software"; } catch {} | |
| } | |
| if (!adapter) { $("env").innerHTML = '<span class="bad">✗ WebGPU present but no adapter</span> — GPU blocked (headless/software). Needs real GPU hardware.'; return; } | |
| if (adapter.isFallbackAdapter || info === "FALLBACK/software") info += " [SOFTWARE — correctness only, tok/s NOT representative]"; | |
| $("env").innerHTML = `<span class="ok">✓ WebGPU adapter OK</span> ${info ? "— " + info : ""}`; | |
| $("run").disabled = false; | |
| if (QUICK) setTimeout(() => $("run").click(), 500); | |
| $("run").onclick = async () => { | |
| $("run").disabled = true; $("tests").innerHTML = ""; $("metrics").innerHTML = ""; $("log").textContent = ""; | |
| const t0 = performance.now(); | |
| const appConfig = { model_list: [{ model: REPO + (V.modelDir ? "/" + V.modelDir : ""), model_id: MODEL_ID, model_lib: REPO + "/" + V.wasm }] }; | |
| const engine = new webllm.MLCEngine({ appConfig, initProgressCallback: (p) => { $("env").innerHTML = `<span class="warn">loading… ${(p.progress*100|0)}%</span> ${p.text||""}`; } }); | |
| log("loading model (first run downloads ~700MB to browser cache)…"); | |
| await engine.reload(MODEL_ID); | |
| const loadMs = performance.now() - t0; | |
| $("env").innerHTML = `<span class="ok">✓ model loaded</span> in ${(loadMs/1000).toFixed(1)}s`; | |
| let prefillTps = [], decodeTps = [], passes = 0; | |
| for (const test of TESTS) { | |
| const prompt = renderPrompt(test.system, test.user, test.tools, test.think); | |
| const c0 = performance.now(); | |
| const res = await engine.completions.create({ prompt, max_tokens: MAXTOK, temperature: 0.3, stop: ["<|im_end|>"] }); | |
| const ms = performance.now() - c0; | |
| const text = res.choices?.[0]?.text ?? ""; | |
| const parsed = parseEmission(text); | |
| const ok = test.expect(parsed); | |
| if (ok) passes++; | |
| const u = res.usage || {}; | |
| const extra = res.usage?.extra || {}; | |
| if (extra.prefill_tokens_per_s) prefillTps.push(extra.prefill_tokens_per_s); | |
| if (extra.decode_tokens_per_s) decodeTps.push(extra.decode_tokens_per_s); | |
| const div = document.createElement("div"); div.className = "test"; | |
| div.innerHTML = `<h3><span>${test.id}${test.think?" · think-on":""}</span><span class="badge ${ok?"pass":"fail"}">${ok?"PASS":"FAIL"}</span></h3>` + | |
| `<pre>THINK: ${escapeHtml(parsed.think||"(none)").slice(0,400)}\n\nTOOL CALL: ${parsed.calls.length?escapeHtml(JSON.stringify(parsed.calls[0])):"(none)"}\n\nCONTENT: ${escapeHtml(parsed.content||"(none)").slice(0,400)}\n\n[${(ms/1000).toFixed(1)}s · decode ${(extra.decode_tokens_per_s||0).toFixed(1)} tok/s]</pre>`; | |
| $("tests").appendChild(div); | |
| log(`${test.id}: ${ok?"PASS":"FAIL"} (${(ms/1000).toFixed(1)}s)`); | |
| } | |
| const avg = (a) => a.length ? (a.reduce((s,x)=>s+x,0)/a.length) : 0; | |
| const mem = engine.runtimeStatsText ? await engine.runtimeStatsText().catch(()=>"") : ""; | |
| const cards = { | |
| "load time": (loadMs/1000).toFixed(1)+"s", | |
| "decode tok/s": avg(decodeTps).toFixed(1), | |
| "prefill tok/s": avg(prefillTps).toFixed(0), | |
| "tool tests": `${passes}/${TESTS.length}`, | |
| "quant": "q4f16_1", | |
| }; | |
| $("metrics").innerHTML = Object.entries(cards).map(([k,v]) => `<div class="m"><div class="k">${k}</div><div class="v">${v}</div></div>`).join(""); | |
| log("\nVERDICT: " + (passes===TESTS.length ? "WebGPU serving VIABLE — think-on tool-calling works in-browser" : `${passes}/${TESTS.length} passed — inspect failures`)); | |
| if (mem) log("\n" + mem); | |
| }; | |
| } | |
| function escapeHtml(s){ return String(s).replace(/[&<>]/g,(c)=>({"&":"&","<":"<",">":">"}[c])); } | |
| main().catch((e)=>{ $("env").innerHTML = '<span class="bad">✗ error: '+escapeHtml(e.message)+'</span>'; log(String(e.stack||e)); }); | |
| </script> | |
| </body> | |
| </html> | |