Spaces:
Running
Running
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>dispatchAI — Mobile AI in Your Browser</title> | |
| <style> | |
| :root { | |
| --ink: #0A0F1A; | |
| --off-white: #F5F7FA; | |
| --electric-blue: #2E6BFF; | |
| --cyan: #1FE0E6; | |
| } | |
| * { margin: 0; padding: 0; box-sizing: border-box; } | |
| body { | |
| font-family: 'SF Mono', 'Fira Code', 'JetBrains Mono', monospace; | |
| background: var(--ink); | |
| color: var(--off-white); | |
| min-height: 100vh; | |
| display: flex; | |
| flex-direction: column; | |
| align-items: center; | |
| padding: 20px; | |
| } | |
| .header { | |
| text-align: center; | |
| margin-bottom: 30px; | |
| } | |
| .logo { | |
| font-size: 2.5em; | |
| font-weight: 800; | |
| background: linear-gradient(135deg, var(--electric-blue), var(--cyan)); | |
| -webkit-background-clip: text; | |
| -webkit-text-fill-color: transparent; | |
| letter-spacing: -1px; | |
| } | |
| .tagline { | |
| color: #8892a6; | |
| font-size: 0.9em; | |
| margin-top: 5px; | |
| } | |
| .badge { | |
| display: inline-block; | |
| background: rgba(46, 107, 255, 0.15); | |
| border: 1px solid var(--electric-blue); | |
| color: var(--electric-blue); | |
| padding: 3px 10px; | |
| border-radius: 12px; | |
| font-size: 0.75em; | |
| margin-top: 8px; | |
| } | |
| .chat-container { | |
| width: 100%; | |
| max-width: 700px; | |
| background: #111827; | |
| border: 1px solid #1a2332; | |
| border-radius: 16px; | |
| overflow: hidden; | |
| } | |
| .chat-header { | |
| background: linear-gradient(135deg, rgba(46, 107, 255, 0.1), rgba(31, 224, 230, 0.1)); | |
| padding: 15px 20px; | |
| border-bottom: 1px solid #1a2332; | |
| display: flex; | |
| justify-content: space-between; | |
| align-items: center; | |
| } | |
| .model-name { | |
| font-size: 0.85em; | |
| color: var(--cyan); | |
| } | |
| .status { | |
| font-size: 0.75em; | |
| padding: 4px 10px; | |
| border-radius: 8px; | |
| background: #1a2332; | |
| color: #8892a6; | |
| } | |
| .status.loaded { background: rgba(31, 224, 230, 0.15); color: var(--cyan); } | |
| .status.loading { background: rgba(46, 107, 255, 0.15); color: var(--electric-blue); } | |
| .status.error { background: rgba(255, 80, 80, 0.15); color: #ff5050; } | |
| .messages { | |
| height: 400px; | |
| overflow-y: auto; | |
| padding: 20px; | |
| display: flex; | |
| flex-direction: column; | |
| gap: 12px; | |
| } | |
| .messages::-webkit-scrollbar { width: 6px; } | |
| .messages::-webkit-scrollbar-thumb { background: #1a2332; border-radius: 3px; } | |
| .msg { | |
| max-width: 85%; | |
| padding: 10px 14px; | |
| border-radius: 12px; | |
| font-size: 0.9em; | |
| line-height: 1.5; | |
| white-space: pre-wrap; | |
| word-wrap: break-word; | |
| } | |
| .msg.user { | |
| background: var(--electric-blue); | |
| color: white; | |
| align-self: flex-end; | |
| } | |
| .msg.ai { | |
| background: #1a2332; | |
| color: var(--off-white); | |
| align-self: flex-start; | |
| border: 1px solid #233; | |
| } | |
| .msg.system { | |
| background: transparent; | |
| color: #555; | |
| font-size: 0.8em; | |
| align-self: center; | |
| font-style: italic; | |
| } | |
| .input-area { | |
| padding: 15px 20px; | |
| border-top: 1px solid #1a2332; | |
| display: flex; | |
| gap: 10px; | |
| } | |
| .input-area input { | |
| flex: 1; | |
| background: #0d1421; | |
| border: 1px solid #1a2332; | |
| color: var(--off-white); | |
| padding: 12px 16px; | |
| border-radius: 10px; | |
| font-family: inherit; | |
| font-size: 0.9em; | |
| outline: none; | |
| transition: border-color 0.2s; | |
| } | |
| .input-area input:focus { border-color: var(--electric-blue); } | |
| .input-area input:disabled { opacity: 0.5; } | |
| .input-area button { | |
| background: linear-gradient(135deg, var(--electric-blue), var(--cyan)); | |
| color: var(--ink); | |
| border: none; | |
| padding: 12px 24px; | |
| border-radius: 10px; | |
| font-family: inherit; | |
| font-weight: 700; | |
| font-size: 0.9em; | |
| cursor: pointer; | |
| transition: opacity 0.2s; | |
| } | |
| .input-area button:disabled { opacity: 0.5; cursor: not-allowed; } | |
| .stats { | |
| width: 100%; | |
| max-width: 700px; | |
| margin-top: 15px; | |
| display: flex; | |
| gap: 10px; | |
| flex-wrap: wrap; | |
| } | |
| .stat { | |
| background: #111827; | |
| border: 1px solid #1a2332; | |
| border-radius: 8px; | |
| padding: 8px 14px; | |
| font-size: 0.75em; | |
| color: #8892a6; | |
| } | |
| .stat span { color: var(--cyan); font-weight: 700; } | |
| .footer { | |
| margin-top: 30px; | |
| text-align: center; | |
| color: #555; | |
| font-size: 0.75em; | |
| } | |
| .footer a { color: var(--electric-blue); text-decoration: none; } | |
| </style> | |
| </head> | |
| <body> | |
| <div class="header"> | |
| <div class="logo">dispatchAI</div> | |
| <div class="tagline">Mobile AI running entirely in your browser. No server. No cloud. Zero cost.</div> | |
| <div class="badge">100% on-device | WebGPU powered</div> | |
| </div> | |
| <div class="chat-container"> | |
| <div class="chat-header"> | |
| <span class="model-name" id="modelName">SmolLM2-360M-Instruct</span> | |
| <span class="status" id="status">Click Initialize</span> | |
| </div> | |
| <div class="messages" id="messages"> | |
| <div class="msg system">This model runs entirely in your browser using WebGPU. No data leaves your device.</div> | |
| </div> | |
| <div class="input-area"> | |
| <input type="text" id="input" placeholder="Ask anything..." disabled autocomplete="off"> | |
| <button id="sendBtn" disabled>Send</button> | |
| </div> | |
| </div> | |
| <div class="stats"> | |
| <div class="stat">Model: <span id="statModel">SmolLM2-360M</span></div> | |
| <div class="stat">Size: <span>~200MB</span></div> | |
| <div class="stat">Backend: <span id="statBackend">WebGPU</span></div> | |
| <div class="stat">Tokens/s: <span id="statTps">—</span></div> | |
| <div class="stat">Load time: <span id="statLoad">—</span></div> | |
| </div> | |
| <div class="footer"> | |
| Powered by <a href="https://huggingface.co/dispatchAI" target="_blank">dispatchAI</a> | | |
| Model: <a href="https://huggingface.co/dispatchAI/SmolLM2-360M-Instruct-mobile" target="_blank">SmolLM2-360M-Instruct-mobile</a> | | |
| Built with <a href="https://github.com/xenova/transformers.js" target="_blank">transformers.js</a> | |
| </div> | |
| <script type="module"> | |
| import { pipeline, env } from 'https://cdn.jsdelivr.net/npm/@huggingface/transformers@3.7.0'; | |
| // Configure for browser inference | |
| env.allowLocalModels = false; | |
| env.useBrowserCache = true; | |
| let generator = null; | |
| let isGenerating = false; | |
| const statusEl = document.getElementById('status'); | |
| const modelNameEl = document.getElementById('modelName'); | |
| const messagesEl = document.getElementById('messages'); | |
| const inputEl = document.getElementById('input'); | |
| const sendBtn = document.getElementById('sendBtn'); | |
| const statTps = document.getElementById('statTps'); | |
| const statLoad = document.getElementById('statLoad'); | |
| const statBackend = document.getElementById('statBackend'); | |
| const MODEL_ID = 'onnx-community/SmolLM2-360M-Instruct-ONNX'; | |
| // Check WebGPU support | |
| async function checkWebGPU() { | |
| if (!navigator.gpu) { | |
| statBackend.textContent = 'WASM (no WebGPU)'; | |
| return 'wasm'; | |
| } | |
| try { | |
| const adapter = await navigator.gpu.requestAdapter(); | |
| if (adapter) { | |
| statBackend.textContent = 'WebGPU'; | |
| return 'webgpu'; | |
| } | |
| } catch (e) {} | |
| statBackend.textContent = 'WASM fallback'; | |
| return 'wasm'; | |
| } | |
| function addMessage(text, role) { | |
| const div = document.createElement('div'); | |
| div.className = `msg ${role}`; | |
| div.textContent = text; | |
| messagesEl.appendChild(div); | |
| messagesEl.scrollTop = messagesEl.scrollHeight; | |
| return div; | |
| } | |
| async function initModel() { | |
| statusEl.textContent = 'Initializing...'; | |
| statusEl.className = 'status loading'; | |
| const backend = await checkWebGPU(); | |
| const device = backend === 'webgpu' ? 'webgpu' : 'wasm'; | |
| try { | |
| const t0 = performance.now(); | |
| statusEl.textContent = 'Downloading model...'; | |
| generator = await pipeline('text-generation', MODEL_ID, { | |
| device: device, | |
| dtype: backend === 'webgpu' ? 'q4f16' : 'q8', | |
| }); | |
| const loadTime = ((performance.now() - t0) / 1000).toFixed(1); | |
| statLoad.textContent = `${loadTime}s`; | |
| statusEl.textContent = 'Ready'; | |
| statusEl.className = 'status loaded'; | |
| inputEl.disabled = false; | |
| sendBtn.disabled = false; | |
| inputEl.focus(); | |
| addMessage(`Model loaded in ${loadTime}s on ${backend.toUpperCase()}. Ask me anything!`, 'system'); | |
| } catch (e) { | |
| statusEl.textContent = 'Failed to load'; | |
| statusEl.className = 'status error'; | |
| addMessage(`Error: ${e.message}. Try refreshing the page.`, 'system'); | |
| console.error(e); | |
| } | |
| } | |
| async function generate() { | |
| if (isGenerating || !generator) return; | |
| const text = inputEl.value.trim(); | |
| if (!text) return; | |
| inputEl.value = ''; | |
| inputEl.disabled = true; | |
| sendBtn.disabled = true; | |
| sendBtn.textContent = '...'; | |
| isGenerating = true; | |
| addMessage(text, 'user'); | |
| const aiMsg = addMessage('', 'ai'); | |
| const messages = [ | |
| { role: 'system', content: 'You are a helpful assistant. Keep responses concise.' }, | |
| { role: 'user', content: text }, | |
| ]; | |
| try { | |
| const t0 = performance.now(); | |
| let tokenCount = 0; | |
| // Streaming generation | |
| const stream = await generator(messages, { | |
| max_new_tokens: 128, | |
| do_sample: true, | |
| temperature: 0.7, | |
| streaming: true, | |
| callback_function: (data) => { | |
| tokenCount++; | |
| }, | |
| }); | |
| const elapsed = (performance.now() - t0) / 1000; | |
| const output = stream[0].generated_text[2].content; | |
| // Type out the response | |
| let i = 0; | |
| const typeInterval = setInterval(() => { | |
| if (i < output.length) { | |
| aiMsg.textContent = output.slice(0, i + 1); | |
| messagesEl.scrollTop = messagesEl.scrollHeight; | |
| i++; | |
| } else { | |
| clearInterval(typeInterval); | |
| const tps = (tokenCount / elapsed).toFixed(1); | |
| statTps.textContent = `${tps}`; | |
| } | |
| }, 10); | |
| } catch (e) { | |
| aiMsg.textContent = `Error: ${e.message}`; | |
| console.error(e); | |
| } finally { | |
| inputEl.disabled = false; | |
| sendBtn.disabled = false; | |
| sendBtn.textContent = 'Send'; | |
| isGenerating = false; | |
| inputEl.focus(); | |
| } | |
| } | |
| sendBtn.addEventListener('click', generate); | |
| inputEl.addEventListener('keydown', (e) => { | |
| if (e.key === 'Enter') generate(); | |
| }); | |
| // Auto-init on page load | |
| initModel(); | |
| </script> | |
| </body> | |
| </html> | |