| <!DOCTYPE html>
|
| <html lang="en">
|
| <head>
|
| <meta charset="UTF-8">
|
| <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
| <meta name="color-scheme" content="dark">
|
| <title>Macro VM</title>
|
| <script src="https://cdn.jsdelivr.net/pyodide/v0.26.4/full/pyodide.js"></script>
|
| <style>
|
| :root{--bg:#0f0f10;--surface:#1a1a1c;--surface2:#242426;--border:rgba(255,255,255,.06);--text:#e5e5e7;--muted:#7a7a7e;--dim:#4a4a4e;--blue:#5b7fff;--green:#34c759;--red:#ff453a;--yellow:#f0b232}
|
| *{box-sizing:border-box;margin:0;padding:0}
|
| html,body{height:100%;background:var(--bg);color:var(--text);font-family:-apple-system,system-ui,sans-serif;font-size:14px;-webkit-font-smoothing:antialiased;overflow:hidden}
|
| .mono{font-family:'SF Mono','Menlo','Monaco',ui-monospace,monospace}
|
| .onboard{position:fixed;inset:0;z-index:100;background:var(--bg);display:grid;place-items:center}
|
| .onboard.hidden{display:none}
|
| .card{width:380px;padding:28px;background:var(--surface);border:1px solid var(--border);border-radius:16px}
|
| .card h1{font-size:18px;font-weight:600;margin-bottom:2px}
|
| .card .sub{font-size:12px;color:var(--muted);margin-bottom:20px}
|
| .field{margin-bottom:12px}
|
| .field label{display:block;font-size:11px;color:var(--muted);margin-bottom:4px;font-weight:500}
|
| .field input,.field select{width:100%;padding:8px 10px;background:var(--surface2);border:1px solid var(--border);border-radius:7px;color:var(--text);font-size:13px;outline:none}
|
| .field input:focus,.field select:focus{border-color:var(--blue)}
|
| .field input::placeholder{color:var(--dim)}
|
| .field .hint{font-size:10px;color:var(--dim);margin-top:2px}
|
| .bp{width:100%;padding:9px;border-radius:7px;border:none;background:var(--blue);color:#fff;font-size:13px;font-weight:500;cursor:pointer;margin-top:6px}
|
| .bp:hover{opacity:.9}.bp:disabled{opacity:.4;cursor:default}
|
| .em{color:var(--red);font-size:11px;margin-top:6px;display:none}
|
| .app{display:flex;flex-direction:column;height:100vh}
|
| .topbar{height:42px;display:flex;align-items:center;justify-content:space-between;padding:0 14px;border-bottom:1px solid var(--border);background:rgba(26,26,28,.9);backdrop-filter:blur(20px);flex-shrink:0}
|
| .topbar .b{font-size:13px;font-weight:600}
|
| .topbar .s{display:flex;align-items:center;gap:5px;font-size:10px;color:var(--muted)}
|
| .dot{width:6px;height:6px;border-radius:50%;background:var(--dim)}.dot.on{background:var(--green)}.dot.err{background:var(--red)}.dot.busy{background:var(--yellow)}
|
| .topbar .a{display:flex;gap:5px}
|
| .topbar .p{padding:3px 7px;border-radius:5px;background:var(--surface2);font-size:10px;color:var(--muted);cursor:pointer;border:none}.topbar .p:hover{color:var(--text)}
|
| .chat{flex:1;overflow:auto;padding:14px;display:flex;flex-direction:column;gap:10px}
|
| .msg{max-width:700px;width:100%;margin:0 auto}
|
| .msg .r{font-size:10px;color:var(--dim);margin-bottom:3px;text-transform:uppercase;letter-spacing:.03em}
|
| .msg.user .r{text-align:right}
|
| .msg.user .c{background:var(--blue);color:#fff;padding:9px 13px;border-radius:14px 14px 4px 14px;font-size:13px;line-height:1.5;margin-left:auto;max-width:80%;display:inline-block}
|
| .msg.agent .c{background:var(--surface);padding:9px 13px;border-radius:14px 14px 14px 4px;font-size:13px;line-height:1.5}
|
| .msg.agent .c pre{margin-top:6px;padding:8px;background:rgba(0,0,0,.3);border-radius:7px;font-family:'SF Mono',monospace;font-size:11px;overflow-x:auto;white-space:pre-wrap}
|
| .msg.agent .c code{font-family:'SF Mono',monospace;font-size:11px;background:rgba(0,0,0,.3);padding:1px 4px;border-radius:3px}
|
| .tc{margin-top:6px;padding:7px 9px;background:rgba(91,127,255,.06);border:1px solid rgba(91,127,255,.12);border-radius:7px;font-size:11px}
|
| .tc .th{color:var(--blue);font-weight:500;margin-bottom:3px;display:flex;align-items:center;gap:5px}
|
| .tc .th .sp{width:11px;height:11px;border:2px solid var(--blue);border-top-color:transparent;border-radius:50%;animation:sp .6s linear infinite}
|
| @keyframes sp{to{transform:rotate(360deg)}}
|
| .tc .td{font-family:'SF Mono',monospace;font-size:10px;color:var(--muted);white-space:pre-wrap;max-height:100px;overflow:auto}
|
| .tr{margin-top:5px;padding:7px 9px;background:rgba(52,199,89,.06);border:1px solid rgba(52,199,89,.12);border-radius:7px;font-size:10px;font-family:'SF Mono',monospace;white-space:pre-wrap;max-height:160px;overflow:auto;color:var(--green)}
|
| .tr.er{background:rgba(255,69,58,.06);border-color:rgba(255,69,58,.12);color:var(--red)}
|
| .typing{display:flex;gap:3px;padding:6px 0}
|
| .typing span{width:5px;height:5px;border-radius:50%;background:var(--dim);animation:ty .8s infinite}
|
| .typing span:nth-child(2){animation-delay:.15s}.typing span:nth-child(3){animation-delay:.3s}
|
| @keyframes ty{0%,100%{opacity:.3}50%{opacity:1}}
|
| .wel{text-align:center;padding:50px 20px;color:var(--muted)}
|
| .wel h2{font-size:16px;color:var(--text);margin-bottom:6px;font-weight:500}
|
| .wel p{font-size:12px;line-height:1.5}
|
| .wel .q{display:flex;gap:6px;justify-content:center;margin-top:14px;flex-wrap:wrap}
|
| .wel .qb{padding:7px 12px;border-radius:7px;border:1px solid var(--border);background:var(--surface);color:var(--text);font-size:11px;cursor:pointer}.wel .qb:hover{border-color:var(--blue);color:var(--blue)}
|
| .ib{padding:10px 14px;border-top:1px solid var(--border);display:flex;gap:7px;align-items:flex-end;max-width:728px;width:100%;margin:0 auto}
|
| .ib textarea{flex:1;padding:9px 12px;background:var(--surface);border:1px solid var(--border);border-radius:10px;color:var(--text);font-size:13px;font-family:inherit;outline:none;resize:none;min-height:40px;max-height:140px;line-height:1.4}
|
| .ib textarea:focus{border-color:var(--blue)}.ib textarea::placeholder{color:var(--dim)}
|
| .sb{width:40px;height:40px;border-radius:9px;border:none;background:var(--blue);color:#fff;cursor:pointer;display:grid;place-items:center;flex-shrink:0}.sb:hover{opacity:.85}.sb:disabled{opacity:.3;cursor:default}
|
| .tp{border-top:1px solid var(--border);background:var(--surface);max-height:0;overflow:hidden;transition:max-height .2s}.tp.open{max-height:220px}
|
| .tp .h{padding:5px 14px;display:flex;align-items:center;justify-content:space-between;border-bottom:1px solid var(--border);font-size:10px;color:var(--muted)}
|
| .tp .h button{background:none;border:none;color:var(--muted);cursor:pointer;font-size:10px}.tp .h button:hover{color:var(--text)}
|
| .tp .b{padding:6px 14px;height:180px;overflow:auto;font-family:'SF Mono',monospace;font-size:10px;line-height:1.5;color:var(--muted);white-space:pre-wrap}
|
| .tl{margin-bottom:1px}.tl.ok{color:var(--green)}.tl.err{color:var(--red)}.tl.info{color:var(--blue)}.tl.tool{color:var(--yellow)}
|
| </style>
|
| </head>
|
| <body>
|
| <div class="onboard" id="onboard">
|
| <div class="card">
|
| <h1>Macro VM</h1>
|
| <p class="sub">Sign in with Ollama Cloud.</p>
|
| <div class="field"><label>API Key</label><input id="apiKey" type="password" placeholder="ollama key from ollama.com/settings/keys"><div class="hint">Get yours at ollama.com/settings/keys</div></div>
|
| <div class="field"><label>Model</label><select id="modelPick">
|
| <option value="gpt-oss:120b">GPT-OSS 120B</option>
|
| <option value="gpt-oss:20b">GPT-OSS 20B</option>
|
| <option value="deepseek-v4-flash">DeepSeek V4 Flash</option>
|
| <option value="deepseek-v4-pro">DeepSeek V4 Pro</option>
|
| <option value="gemma4:31b">Gemma 4 31B</option>
|
| <option value="kimi-k3">Kimi K3</option>
|
| <option value="kimi-k2.6">Kimi K2.6</option>
|
| <option value="minimax-m3">MiniMax M3</option>
|
| <option value="mistral-large-3">Mistral Large 3</option>
|
| <option value="nemotron-3-ultra">Nemotron 3 Ultra</option>
|
| <option value="qwen3.5:397b">Qwen 3.5 397B</option>
|
| </select></div>
|
| <button class="bp" id="btnConnect" onclick="connect()">Sign in</button>
|
| <p class="em" id="connectErr"></p>
|
| </div>
|
| </div>
|
|
|
| <div class="app" id="mainApp" style="display:none">
|
| <header class="topbar">
|
| <div class="b">Macro VM</div>
|
| <div class="s"><span class="dot on" id="connDot"></span><span id="modelLabel"></span></div>
|
| <div class="a"><button class="p" onclick="toggleTerm()">Terminal</button><button class="p" onclick="document.getElementById('onboard').classList.remove('hidden')">Sign out</button></div>
|
| </header>
|
| <div class="chat" id="chat">
|
| <div class="wel">
|
| <h2>What can I help with?</h2>
|
| <p>Python, search, and terminal tools run automatically.</p>
|
| <div class="q">
|
| <button class="qb" onclick="quickAsk('Calculate the first 20 Fibonacci numbers')">Python math</button>
|
| <button class="qb" onclick="quickAsk('Search for what is new in WebGPU 2025')">Search web</button>
|
| <button class="qb" onclick="quickAsk('Write and run a Python function to find primes up to 100')">Run code</button>
|
| </div>
|
| </div>
|
| </div>
|
| <div class="ib">
|
| <textarea id="askInput" rows="1" placeholder="Ask anything..." onkeydown="if(event.key==='Enter'&&!event.shiftKey){event.preventDefault();send()}"></textarea>
|
| <button class="sb" id="sendBtn" onclick="send()"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 2L11 13"/><path d="M22 2L15 22L11 13L2 9L22 2Z"/></svg></button>
|
| </div>
|
| <div class="tp" id="termPanel">
|
| <div class="h"><span>Output</span><button onclick="toggleTerm()">Close</button></div>
|
| <div class="b" id="termBody"></div>
|
| </div>
|
| </div>
|
|
|
| <script>
|
| let apiKey='',model='',chatHistory=[],sending=false,pyodide=null,pyReady=false;
|
| const SP=`You are a helpful AI assistant with access to tools. You can run Python code, search the web, and execute terminal commands.
|
|
|
| To use a tool, emit a fenced code block with the tool name as language tag:
|
| \`\`\`python
|
| your code here
|
| \`\`\`
|
| \`\`\`search
|
| search query here
|
| \`\`\`
|
| \`\`\`terminal
|
| command here
|
| \`\`\`
|
|
|
| Rules:
|
| - For Python: write valid Python 3. Print results with print().
|
| - For search: emit ONLY the search query.
|
| - For terminal: emit ONLY the shell command.
|
| - After seeing tool results, continue your answer with the findings.
|
| - Use markdown for formatting.`;
|
|
|
| async function connect(){
|
| const btn=document.getElementById('btnConnect'),err=document.getElementById('connectErr');
|
| btn.disabled=true;btn.textContent='Signing in...';err.style.display='none';
|
| try{
|
| apiKey=document.getElementById('apiKey').value.trim();
|
| model=document.getElementById('modelPick').value;
|
| if(!apiKey){throw new Error('Enter your API key')}
|
| const r=await fetch('https://ollama.com/api/chat',{method:'POST',headers:{'Content-Type':'application/json','Authorization':'Bearer '+apiKey},body:JSON.stringify({model,messages:[{role:'user',content:'hi'}],stream:false,max_tokens:1})});
|
| if(!r.ok){const e=await r.text();throw new Error('Bad key ('+r.status+'): '+e.slice(0,150))}
|
| document.getElementById('onboard').classList.add('hidden');
|
| document.getElementById('mainApp').style.display='';
|
| document.getElementById('modelLabel').textContent=model;
|
| t('Signed in — '+model,'ok');t('Loading Python...','info');initPyodide();
|
| }catch(e){err.textContent=e.message;err.style.display=''}
|
| btn.disabled=false;btn.textContent='Sign in';
|
| }
|
|
|
| async function initPyodide(){try{pyodide=await loadPyodide();pyReady=true;t('Python ready','ok');document.getElementById('connDot').className='dot on'}catch(e){t('Python error: '+e.message,'err')}}
|
| async function runPython(code){if(!pyReady)return'Error: Python not loaded';try{let o='';pyodide.setStdout({batched:x=>{o+=x}});pyodide.setStderr({batched:x=>{o+='ERROR: '+x}});await pyodide.runPythonAsync(code);return o||'(no output)'}catch(e){return'Error: '+e.message}}
|
|
|
| async function execTool(n,input){
|
| t('→ tool: '+n+'('+input.slice(0,80)+')','tool');
|
| if(n==='python')return await runPython(input);
|
| if(n==='search')return await runSearch(input);
|
| if(n==='terminal')return await runTerminal(input);
|
| return'Unknown tool: '+n;
|
| }
|
| async function runSearch(q){
|
| try{const r=await fetch('https://api.duckduckgo.com/?q='+encodeURIComponent(q)+'&format=json&no_html=1');const d=await r.json();let R=[];
|
| if(d.AbstractText)R.push({t:d.Heading,s:d.AbstractText,u:d.AbstractURL});
|
| if(d.RelatedTopics)d.RelatedTopics.slice(0,5).forEach(x=>{if(x.Text)R.push({t:x.Text.slice(0,60),s:x.Text,u:x.FirstURL||''})});
|
| if(!R.length)return'No results for: '+q;return R.map((r,i)=>`${i+1}. ${r.t}\n ${r.s}\n ${r.u}`).join('\n\n')}catch(e){return'Search error: '+e.message}
|
| }
|
| async function runTerminal(cmd){
|
| const c=cmd.trim().toLowerCase();
|
| if(c==='pwd'||c==='cd')return'/workspace';if(c.startsWith('ls'))return'README.md src/ docs/ package.json .git/';
|
| if(c==='whoami')return'macro-vm';if(c==='date')return new Date().toISOString();if(c.startsWith('echo '))return cmd.slice(5);
|
| if(c==='help')return'Available: ls, pwd, cat, echo, whoami, date, help';
|
| return'Command: "'+cmd+'"\n(client-side)';
|
| }
|
|
|
| function extractToolCalls(text){const C=[];const rx=/```(python|search|terminal)\n([\s\S]*?)```/g;let m;while((m=rx.exec(text))!==null)C.push({n:m[1],c:m[2].trim()});return C}
|
|
|
| async function callModel(){
|
| const msgs=[{role:'system',content:SP},...chatHistory];
|
| const r=await fetch('https://ollama.com/api/chat',{method:'POST',headers:{'Content-Type':'application/json','Authorization':'Bearer '+apiKey},body:JSON.stringify({model,messages:msgs,stream:false})});
|
| if(!r.ok){const e=await r.text();throw new Error('Ollama: '+e.slice(0,200))}
|
| const d=await r.json();return d.message?.content||'';
|
| }
|
|
|
| async function send(){
|
| if(sending)return;const inp=document.getElementById('askInput');const txt=inp.value.trim();if(!txt)return;
|
| inp.value='';inp.style.height='auto';sending=true;document.getElementById('sendBtn').disabled=true;
|
| const w=document.querySelector('.wel');if(w)w.remove();
|
| addMsg('user',txt);chatHistory.push({role:'user',content:txt});
|
| const cd=document.getElementById('connDot');
|
| try{for(let i=0;i<8;i++){
|
| cd.className='dot busy';const te=addTyping();let rp;try{rp=await callModel()}catch(e){te.remove();throw e}te.remove();
|
| const tc=extractToolCalls(rp);addMsgWithTools(rp,tc);
|
| if(!tc.length){chatHistory.push({role:'assistant',content:rp});cd.className='dot on';break}
|
| chatHistory.push({role:'assistant',content:rp});let rm='Tool results:\n\n';
|
| for(const t of tc){const res=await execTool(t.n,t.c);t('← '+t.n+': '+res.slice(0,80)+(res.length>80?'...':''),'ok');rm+=`\`\`\`${t.n}\n${res}\n\`\`\`\n\n`;updateLastToolRes(t.n,res)}
|
| chatHistory.push({role:'user',content:rm});
|
| }}catch(e){addMsg('agent','Error: '+e.message);t('Error: '+e.message,'err');cd.className='dot err'}
|
| sending=false;document.getElementById('sendBtn').disabled=false;
|
| }
|
|
|
| function addMsg(role,text){const ch=document.getElementById('chat'),d=document.createElement('div');d.className='msg '+role;d.innerHTML=`<div class="r">${role==='user'?'You':'Macro VM'}</div><div class="c">${rMd(text)}</div>`;ch.appendChild(d);ch.scrollTop=ch.scrollHeight}
|
| function addMsgWithTools(text,tc){const ch=document.getElementById('chat'),d=document.createElement('div');d.className='msg agent';let dt=text.replace(/```(python|search|terminal)\n[\s\S]*?```/g,'').trim();let h=dt?rMd(dt):'';for(const t of tc)h+=`<div class="tc" data-tool="${t.n}"><div class="th"><span class="sp"></span> ${t.n}</div><div class="td">${esc(t.c)}</div></div>`;d.innerHTML=`<div class="r">Macro VM</div><div class="c">${h}</div>`;ch.appendChild(d);ch.scrollTop=ch.scrollHeight}
|
| function updateLastToolRes(n,r){const c=document.querySelectorAll('.tc[data-tool="'+n+'"]');const l=c[c.length-1];if(!l)return;l.querySelector('.th').innerHTML='✓ '+n;const d=document.createElement('div');d.className='tr'+(r.startsWith('Error')?' er':'');d.textContent=r.slice(0,500)+(r.length>500?'\n...':'');l.appendChild(d)}
|
| function rMd(t){let h=t.replace(/```(\w*)\n([\s\S]*?)```/g,'<pre><code>$2</code></pre>');h=h.replace(/`([^`]+)`/g,'<code>$1</code>');h=h.replace(/\n/g,'<br>');return h}
|
| function esc(s){return s.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>')}
|
| function addTyping(){const ch=document.getElementById('chat'),d=document.createElement('div');d.className='msg agent';d.innerHTML='<div class="r">Macro VM</div><div class="typing"><span></span><span></span><span></span></div>';ch.appendChild(d);ch.scrollTop=ch.scrollHeight;return d}
|
| function quickAsk(t){document.getElementById('askInput').value=t;send()}
|
| function t(text,cls){const b=document.getElementById('termBody'),l=document.createElement('div');l.className='tl'+(cls?' '+cls:'');l.textContent=text;b.appendChild(l);b.scrollTop=b.scrollHeight}
|
| function toggleTerm(){document.getElementById('termPanel').classList.toggle('open')}
|
| document.getElementById('askInput')?.addEventListener('input',function(){this.style.height='auto';this.style.height=Math.min(this.scrollHeight,140)+'px'});
|
| </script>
|
| </body>
|
| </html>
|
|
|