meditor-webgpu / index.html
Yo-Purple's picture
Optimized WebGPU inference engine without service worker dependency
28ef500 verified
Raw History Blame Contribute Delete
38.9 kB
<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>Meditor AI — In-Browser WebGPU Chat</title> <script src="./marked.min.js"></script> <style> :root { --bg: #090b11; --sidebar-bg: #10141f; --card-bg: #161b2a; --card-elevated: #1d2438; --border: #222b40; --border-focus: #10b981; --text: #f3f4f6; --text-muted: #94a3b8; --accent: #10b981; --accent-glow: rgba(16, 185, 129, 0.25); --accent-purple: #8b5cf6; --user-bubble: #1f293d; --bot-bubble: #141926; --danger: #ef4444; --warning: #f59e0b; } * { box-sizing: border-box; margin: 0; padding: 0; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; } body { background: var(--bg); color: var(--text); display: flex; height: 100vh; overflow: hidden; } /* SIDEBAR */ aside { width: 340px; background: var(--sidebar-bg); border-right: 1px solid var(--border); display: flex; flex-direction: column; padding: 18px; gap: 16px; overflow-y: auto; flex-shrink: 0; z-index: 10; } .brand { display: flex; align-items: center; gap: 12px; padding-bottom: 12px; border-bottom: 1px solid var(--border); } .brand-logo { width: 42px; height: 42px; border-radius: 10px; background: linear-gradient(135deg, #10b981, #6366f1); display: flex; align-items: center; justify-content: center; font-size: 20px; font-weight: 800; color: #fff; box-shadow: 0 0 16px var(--accent-glow); } .brand-info h1 { font-size: 17px; font-weight: 700; color: #fff; letter-spacing: -0.3px; } .brand-info p { font-size: 11px; color: var(--text-muted); } /* HARDWARE STATUS CARD */ .hardware-card { background: var(--card-bg); border: 1px solid var(--border); border-radius: 10px; padding: 12px; display: flex; flex-direction: column; gap: 8px; font-size: 12px; } .hardware-header { display: flex; align-items: center; justify-content: space-between; font-weight: 600; } .status-badge { display: inline-flex; align-items: center; gap: 6px; padding: 3px 8px; border-radius: 12px; font-size: 11px; font-weight: 600; } .status-badge.webgpu-on { background: rgba(16, 185, 129, 0.15); border: 1px solid #10b981; color: #34d399; } .status-badge.webgpu-off { background: rgba(245, 158, 11, 0.15); border: 1px solid #f59e0b; color: #fbbf24; } .dot { width: 7px; height: 7px; border-radius: 50%; background: currentColor; } /* CONTROLS & SELECTION */ .section-title { font-size: 11px; text-transform: uppercase; font-weight: 700; color: var(--text-muted); letter-spacing: 0.7px; margin-top: 4px; } .form-group { display: flex; flex-direction: column; gap: 6px; } .form-group label { font-size: 12px; font-weight: 600; color: #d1d5db; display: flex; justify-content: space-between; } .form-group label span { color: var(--text-muted); font-weight: normal; } select, input[type="text"], input[type="number"], textarea { background: var(--card-bg); border: 1px solid var(--border); color: var(--text); padding: 9px 12px; border-radius: 8px; font-size: 13px; outline: none; transition: border-color 0.2s; width: 100%; } select:focus, input:focus, textarea:focus { border-color: var(--border-focus); } textarea { resize: vertical; min-height: 56px; font-size: 12px; } .model-info-box { background: rgba(26, 32, 53, 0.6); border: 1px solid var(--border); border-radius: 8px; padding: 10px; font-size: 11.5px; line-height: 1.45; color: #cbd5e1; } .model-info-box b { color: #fff; } .slider-row { display: flex; flex-direction: column; gap: 4px; } .slider-header { display: flex; justify-content: space-between; font-size: 12px; color: var(--text-muted); } input[type="range"] { accent-color: var(--accent); cursor: pointer; width: 100%; } /* BUTTONS */ .btn { padding: 10px 14px; border-radius: 8px; font-size: 13px; font-weight: 600; cursor: pointer; display: inline-flex; align-items: center; justify-content: center; gap: 8px; transition: all 0.2s; border: none; } .btn-primary { background: linear-gradient(135deg, #10b981, #059669); color: #fff; box-shadow: 0 2px 10px var(--accent-glow); } .btn-primary:hover:not(:disabled) { filter: brightness(1.1); transform: translateY(-1px); } .btn-primary:disabled { opacity: 0.5; cursor: not-allowed; } .btn-secondary { background: var(--card-bg); border: 1px solid var(--border); color: var(--text); } .btn-secondary:hover { background: var(--card-elevated); border-color: #3b4563; } .btn-danger { background: rgba(239, 68, 68, 0.15); border: 1px solid rgba(239, 68, 68, 0.3); color: #fca5a5; } .btn-danger:hover { background: rgba(239, 68, 68, 0.25); } /* PROGRESS CONTAINER */ #progress-container { background: var(--card-bg); border: 1px solid var(--border); border-radius: 10px; padding: 12px; display: none; flex-direction: column; gap: 8px; font-size: 12px; } .progress-bar-bg { height: 8px; background: #0f131d; border-radius: 4px; overflow: hidden; position: relative; } .progress-bar-fill { height: 100%; background: linear-gradient(90deg, #10b981, #6366f1); width: 0%; transition: width 0.15s ease; } .progress-text { display: flex; justify-content: space-between; color: var(--text-muted); font-size: 11px; } /* MAIN CHAT AREA */ main { flex: 1; display: flex; flex-direction: column; background: var(--bg); position: relative; overflow: hidden; } header { background: var(--sidebar-bg); border-bottom: 1px solid var(--border); padding: 12px 24px; display: flex; justify-content: space-between; align-items: center; flex-shrink: 0; } .header-title { display: flex; align-items: center; gap: 10px; } .active-model-pill { background: var(--card-bg); border: 1px solid var(--border); padding: 4px 10px; border-radius: 14px; font-size: 12px; font-weight: 600; color: #34d399; display: flex; align-items: center; gap: 6px; } .header-telemetry { display: flex; gap: 12px; font-size: 12px; color: var(--text-muted); } .telemetry-tag { background: var(--card-bg); border: 1px solid var(--border); padding: 4px 10px; border-radius: 6px; font-weight: 600; } .telemetry-speed { color: #38bdf8; } /* CHAT STREAM */ #chat-scroll { flex: 1; overflow-y: auto; padding: 24px; display: flex; flex-direction: column; gap: 18px; max-width: 900px; width: 100%; margin: 0 auto; } .welcome-banner { background: linear-gradient(180deg, rgba(16, 185, 129, 0.08) 0%, rgba(99, 102, 241, 0.04) 100%); border: 1px solid rgba(16, 185, 129, 0.2); border-radius: 14px; padding: 24px; text-align: center; margin: auto 0; display: flex; flex-direction: column; align-items: center; gap: 12px; } .welcome-banner h2 { font-size: 20px; font-weight: 700; color: #fff; } .welcome-banner p { font-size: 13.5px; color: var(--text-muted); max-width: 600px; line-height: 1.5; } .preset-chips { display: flex; flex-wrap: wrap; gap: 8px; justify-content: center; margin-top: 8px; } .chip { background: var(--card-bg); border: 1px solid var(--border); padding: 6px 12px; border-radius: 20px; font-size: 12px; cursor: pointer; color: #e2e8f0; transition: all 0.2s; } .chip:hover { background: var(--card-elevated); border-color: var(--accent); color: #fff; transform: translateY(-1px); } /* MESSAGES */ .message { display: flex; flex-direction: column; max-width: 85%; animation: fadeIn 0.2s ease; } @keyframes fadeIn { from { opacity: 0; transform: translateY(4px); } to { opacity: 1; transform: translateY(0); } } .message.user { align-self: flex-end; } .message.user .bubble { background: var(--user-bubble); border: 1px solid var(--border); border-radius: 14px 14px 2px 14px; padding: 12px 16px; color: #fff; font-size: 14px; line-height: 1.5; } .message.bot { align-self: flex-start; max-width: 90%; } .message.bot .bubble { background: var(--bot-bubble); border: 1px solid var(--border); border-left: 3px solid var(--accent); border-radius: 14px 14px 14px 2px; padding: 14px 18px; color: var(--text); font-size: 14px; line-height: 1.6; } .bubble p { margin-bottom: 8px; } .bubble p:last-child { margin-bottom: 0; } .bubble pre { background: #0b0d14; border: 1px solid #1f2738; border-radius: 8px; padding: 12px; overflow-x: auto; margin: 8px 0; position: relative; } .bubble code { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; font-size: 12.5px; color: #38bdf8; } .msg-meta { font-size: 11px; color: var(--text-muted); margin-top: 4px; display: flex; align-items: center; gap: 8px; } /* INPUT SECTION */ .input-section { padding: 14px 24px; background: var(--sidebar-bg); border-top: 1px solid var(--border); display: flex; flex-direction: column; gap: 8px; flex-shrink: 0; } .input-container { max-width: 900px; width: 100%; margin: 0 auto; display: flex; gap: 10px; align-items: flex-end; } #prompt-input { flex: 1; min-height: 48px; max-height: 140px; background: var(--card-bg); border: 1px solid var(--border); border-radius: 10px; padding: 12px 14px; color: #fff; font-size: 14px; line-height: 1.4; resize: none; outline: none; } #prompt-input:focus { border-color: var(--accent); } .send-btn { width: 48px; height: 48px; border-radius: 10px; background: var(--accent); color: #000; display: flex; align-items: center; justify-content: center; cursor: pointer; border: none; font-size: 18px; transition: all 0.2s; flex-shrink: 0; } .send-btn:hover:not(:disabled) { filter: brightness(1.15); transform: scale(1.02); } .send-btn:disabled { opacity: 0.4; cursor: not-allowed; } .stop-btn { background: var(--danger); color: #fff; } /* CACHE FOOTER */ .cache-info-row { display: flex; justify-content: space-between; align-items: center; font-size: 11px; color: var(--text-muted); padding-top: 8px; border-top: 1px solid var(--border); } /* MOBILE TOGGLE */ .mobile-menu-btn { display: none; background: none; border: none; color: #fff; font-size: 20px; cursor: pointer; } @media (max-width: 768px) { aside { position: absolute; top: 0; bottom: 0; left: 0; transform: translateX(-100%); transition: transform 0.3s ease; box-shadow: 4px 0 20px rgba(0,0,0,0.5); } aside.open { transform: translateX(0); } .mobile-menu-btn { display: block; } } </style> </head> <body> <!-- SIDEBAR CONTROLS --> <aside id="sidebar"> <div class="brand"> <div class="brand-logo">M</div> <div class="brand-info"> <h1>Meditor WebGPU</h1> <p>100% Client-Side In-Browser AI</p> </div> </div> <!-- HARDWARE ACCELERATION CARD --> <div class="hardware-card"> <div class="hardware-header"> <span>Hardware Engine</span> <div id="webgpu-status-pill" class="status-badge webgpu-off"> <span class="dot"></span> <span id="webgpu-status-text">Detecting...</span> </div> </div> <div id="hardware-desc" style="color: var(--text-muted); font-size: 11px;"> Checking WebGPU adapter... </div> </div> <!-- MODEL SELECTION --> <div class="section-title">Model Architecture</div> <div class="form-group"> <label for="model-select">Pick a Meditor Model:</label> <select id="model-select"> <!-- populated via js --> </select> </div> <div id="model-info-box" class="model-info-box"> <!-- model details --> </div> <!-- CUSTOM MODEL INPUTS (Hidden unless 'Custom' is chosen) --> <div id="custom-model-fields" style="display: none; flex-direction: column; gap: 8px;"> <div class="form-group"> <label>Hugging Face Repo ID:</label> <input type="text" id="custom-repo" placeholder="e.g. Yo-Purple/Meditor-1-500M-Universal"> </div> <div class="form-group"> <label>GGUF File Name / Path:</label> <input type="text" id="custom-file" placeholder="e.g. meditor-1-universal-q4_k_m.gguf"> </div> <div class="form-group"> <label>HF Token (optional for private models):</label> <input type="password" id="custom-token" placeholder="hf_..."> </div> </div> <!-- LOAD MODEL BUTTON --> <button id="load-model-btn" class="btn btn-primary" style="width: 100%;"> ⚡ Load Model into WebGPU </button> <!-- DOWNLOAD / LOADING PROGRESS --> <div id="progress-container"> <div class="progress-text"> <span id="progress-status-label">Downloading weights...</span> <span id="progress-percent-label">0%</span> </div> <div class="progress-bar-bg"> <div id="progress-bar-fill" class="progress-bar-fill"></div> </div> <div class="progress-text"> <span id="progress-bytes-label">0 / 0 MB</span> <span id="progress-speed-label">-- MB/s</span> </div> </div> <!-- INFERENCE PARAMETERS --> <div class="section-title">Inference Hyperparameters</div> <div class="slider-row"> <div class="slider-header"> <span>Temperature:</span> <span id="temp-val">0.7</span> </div> <input type="range" id="temp-slider" min="0" max="1.5" step="0.05" value="0.7"> </div> <div class="slider-row"> <div class="slider-header"> <span>Context Window (Tokens):</span> <span id="ctx-val">2048</span> </div> <input type="range" id="ctx-slider" min="512" max="8192" step="512" value="2048"> </div> <div class="slider-row"> <div class="slider-header"> <span>Max Output Tokens:</span> <span id="tokens-val">512</span> </div> <input type="range" id="tokens-slider" min="64" max="2048" step="64" value="512"> </div> <div class="slider-row"> <div class="slider-header"> <span>GPU Offload Layers:</span> <span id="gpu-layers-val">All</span> </div> <input type="range" id="gpu-layers-slider" min="0" max="40" step="1" value="40"> </div> <div class="form-group"> <label>System Prompt:</label> <textarea id="system-prompt">You are Meditor, an advanced reasoning and conversational AI assistant. Answer accurately, thoughtfully, and helpfully.</textarea> </div> <!-- CACHE & ACTIONS --> <div style="margin-top: auto; display: flex; flex-direction: column; gap: 8px;"> <div class="cache-info-row"> <span id="cache-status-text">OPFS Cache: Checking...</span> <button id="clear-cache-btn" class="btn btn-danger" style="padding: 4px 8px; font-size: 11px;"> Clear Cache </button> </div> <button id="clear-chat-btn" class="btn btn-secondary" style="width: 100%;"> 🗑️ Clear Conversation </button> </div> </aside> <!-- MAIN CHAT VIEWPORT --> <main> <header> <div class="header-title"> <button class="mobile-menu-btn" id="toggle-sidebar-btn">☰</button> <div id="active-model-display" class="active-model-pill"> <span class="dot"></span> <span id="active-model-name">No Model Loaded</span> </div> </div> <div class="header-telemetry"> <div class="telemetry-tag telemetry-speed" id="speed-meter">0.0 tok/s</div> <div class="telemetry-tag" id="token-count-meter">0 tokens</div> </div> </header> <!-- SCROLLABLE CHAT CONTAINER --> <div id="chat-scroll"> <div id="welcome-box" class="welcome-banner"> <h2>⚡ Meditor WebGPU Engine</h2> <p> Experience true zero-latency client-side AI. The neural network runs entirely inside your browser using hardware-accelerated <b>WebGPU</b> compute shaders. Zero server cost, zero data leaves your machine. </p> <p style="font-size: 12px; color: #34d399;"> Select a Meditor model on the left sidebar and click <b>Load Model into WebGPU</b> to start! </p> <div class="preset-chips"> <div class="chip" data-prompt="What is 14 * 25 - 35?">🧮 14 * 25 - 35</div> <div class="chip" data-prompt="Explain the difference between Mixture-of-Experts (MoE) and Dense LLMs.">🧠 MoE vs Dense</div> <div class="chip" data-prompt="Write a Python function to check if a word is an anagram of another.">🐍 Python Anagrams</div> <div class="chip" data-prompt="Explain quantum entanglement simply in 3 short bullet points.">🌌 Quantum Entanglement</div> </div> </div> </div> <!-- INPUT BAR --> <div class="input-section"> <div class="input-container"> <textarea id="prompt-input" placeholder="Type your message to Meditor... (Press Enter to send, Shift+Enter for newline)" rows="1" disabled></textarea> <button id="send-btn" class="send-btn" disabled title="Send Message"> ➤ </button> </div> </div> </main> <!-- APP SCRIPT --> <script type="module"> import { Wllama } from './wllama.js'; // Meditor Catalog (Published on Hugging Face Hub by @Yo-Purple) const MEDITOR_MODELS = [ { id: "meditor-1-500m-q4", name: "Meditor-1 (500M Universal) — Q4_K_M", tag: "Recommended ⚡", description: "Ultra-fast ~468 MB. Instant load, 60-150 tok/s on WebGPU. Ideal for daily chat and mental math.", repo: "Yo-Purple/Meditor-1-500M-Universal", file: "meditor-1-universal-q4_k_m.gguf", size: "468 MB", params: "500M Dense", context: 2048, url: "https://huggingface.co/Yo-Purple/Meditor-1-500M-Universal/resolve/main/meditor-1-universal-q4_k_m.gguf" }, { id: "meditor-1-500m-q8", name: "Meditor-1 (500M Universal) — Q8_0", tag: "High Precision", description: "644 MB. 8-bit quantization with near-zero quantization loss.", repo: "Yo-Purple/Meditor-1-500M-Universal", file: "meditor-1-universal-q8_0.gguf", size: "644 MB", params: "500M Dense", context: 2048, url: "https://huggingface.co/Yo-Purple/Meditor-1-500M-Universal/resolve/main/meditor-1-universal-q8_0.gguf" }, { id: "meditor-1-500m-bf16", name: "Meditor-1 (500M Universal) — BF16", tag: "Uncompressed", description: "1.18 GB. Uncompressed 16-bit reference weights for studio-grade accuracy.", repo: "Yo-Purple/Meditor-1-500M-Universal", file: "meditor-1-universal-bf16.gguf", size: "1.18 GB", params: "500M Dense", context: 2048, url: "https://huggingface.co/Yo-Purple/Meditor-1-500M-Universal/resolve/main/meditor-1-universal-bf16.gguf" }, { id: "meditor-2-3.4b-q3", name: "Meditor-2 (3.4B MoE Universal) — Q3_K_M", tag: "MoE Fast", description: "1.60 GB. 22-expert MoE architecture, 3-bit compact quantization for fast multi-expert reasoning.", repo: "Yo-Purple/Meditor-2-3.4B-Universal", file: "meditor-2-3.4b-universal-q3_k_m.gguf", size: "1.60 GB", params: "3.4B MoE (1.8B Active)", context: 2048, url: "https://huggingface.co/Yo-Purple/Meditor-2-3.4B-Universal/resolve/main/meditor-2-3.4b-universal-q3_k_m.gguf" }, { id: "meditor-2-3.4b-q4", name: "Meditor-2 (3.4B MoE Universal) — Q4_K_M", tag: "MoE Standard", description: "2.00 GB. 4-bit balanced quantization with full multi-turn dialogue & code generation.", repo: "Yo-Purple/Meditor-2-3.4B-Universal", file: "meditor-2-3.4b-universal-q4_k_m.gguf", size: "2.00 GB", params: "3.4B MoE (1.8B Active)", context: 2048, url: "https://huggingface.co/Yo-Purple/Meditor-2-3.4B-Universal/resolve/main/meditor-2-3.4b-universal-q4_k_m.gguf" }, { id: "meditor-2-3.4b-q5", name: "Meditor-2 (3.4B MoE Universal) — Q5_K_M", tag: "MoE High Fidelity", description: "2.33 GB. 5-bit high fidelity quantization.", repo: "Yo-Purple/Meditor-2-3.4B-Universal", file: "meditor-2-3.4b-universal-q5_k_m.gguf", size: "2.33 GB", params: "3.4B MoE (1.8B Active)", context: 2048, url: "https://huggingface.co/Yo-Purple/Meditor-2-3.4B-Universal/resolve/main/meditor-2-3.4b-universal-q5_k_m.gguf" }, { id: "custom", name: "⚙️ Custom Hugging Face Model / GGUF URL", tag: "Custom", description: "Enter your own Hugging Face model repository or direct GGUF file link.", repo: "", file: "", size: "Custom", params: "Custom", context: 2048, url: "" } ]; // State Variables let wllamaInstance = null; let isGenerating = false; let shouldAbort = false; let chatHistory = []; let hasWebGPU = false; let adapterInfo = null; let downloadStartTime = 0; let lastLoadedBytes = 0; // Elements const modelSelect = document.getElementById('model-select'); const modelInfoBox = document.getElementById('model-info-box'); const customFields = document.getElementById('custom-model-fields'); const loadBtn = document.getElementById('load-model-btn'); const progressContainer = document.getElementById('progress-container'); const progressBarFill = document.getElementById('progress-bar-fill'); const progressPercentLabel = document.getElementById('progress-percent-label'); const progressBytesLabel = document.getElementById('progress-bytes-label'); const progressSpeedLabel = document.getElementById('progress-speed-label'); const progressStatusLabel = document.getElementById('progress-status-label'); const webgpuStatusPill = document.getElementById('webgpu-status-pill'); const webgpuStatusText = document.getElementById('webgpu-status-text'); const hardwareDesc = document.getElementById('hardware-desc'); const activeModelName = document.getElementById('active-model-name'); const chatScroll = document.getElementById('chat-scroll'); const welcomeBox = document.getElementById('welcome-box'); const promptInput = document.getElementById('prompt-input'); const sendBtn = document.getElementById('send-btn'); const speedMeter = document.getElementById('speed-meter'); const tokenCountMeter = document.getElementById('token-count-meter'); const clearChatBtn = document.getElementById('clear-chat-btn'); const clearCacheBtn = document.getElementById('clear-cache-btn'); const cacheStatusText = document.getElementById('cache-status-text'); // Sliders const tempSlider = document.getElementById('temp-slider'); const tempVal = document.getElementById('temp-val'); tempSlider.oninput = () => tempVal.textContent = tempSlider.value; const ctxSlider = document.getElementById('ctx-slider'); const ctxVal = document.getElementById('ctx-val'); ctxSlider.oninput = () => ctxVal.textContent = ctxSlider.value; const tokensSlider = document.getElementById('tokens-slider'); const tokensVal = document.getElementById('tokens-val'); tokensSlider.oninput = () => tokensVal.textContent = tokensSlider.value; const gpuLayersSlider = document.getElementById('gpu-layers-slider'); const gpuLayersVal = document.getElementById('gpu-layers-val'); gpuLayersSlider.oninput = () => { gpuLayersVal.textContent = gpuLayersSlider.value === "40" ? "All" : gpuLayersSlider.value; }; // Populate Models MEDITOR_MODELS.forEach((m) => { const opt = document.createElement('option'); opt.value = m.id; opt.textContent = m.name; modelSelect.appendChild(opt); }); function updateModelInfo() { const selectedId = modelSelect.value; const model = MEDITOR_MODELS.find(m => m.id === selectedId); if (!model) return; if (selectedId === 'custom') { customFields.style.display = 'flex'; modelInfoBox.innerHTML = `<b>Custom Model:</b> Provide the Hugging Face repo & GGUF filename.`; } else { customFields.style.display = 'none'; modelInfoBox.innerHTML = ` <div><b>Parameters:</b> ${model.params}</div> <div><b>File Size:</b> ${model.size} | <b>Quant:</b> ${model.tag}</div> <div style="margin-top:4px; color:#94a3b8;">${model.description}</div> <div style="margin-top:6px; font-size:10.5px; color:#38bdf8;">HF: ${model.repo}/${model.file}</div> `; } } modelSelect.onchange = updateModelInfo; updateModelInfo(); // Hardware Detection async function initHardwareDetection() { try { hasWebGPU = !!navigator.gpu; if (hasWebGPU) { const adapter = await navigator.gpu.requestAdapter(); if (adapter) { adapterInfo = (await adapter.requestAdapterInfo?.()) || adapter.info || {}; webgpuStatusPill.className = "status-badge webgpu-on"; webgpuStatusText.textContent = "WebGPU Ready ⚡"; const vendor = adapterInfo.vendor || "Hardware GPU"; const arch = adapterInfo.architecture || adapterInfo.device || "Dedicated"; hardwareDesc.textContent = `Active Device: ${vendor} (${arch})`; } else { webgpuStatusPill.className = "status-badge webgpu-off"; webgpuStatusText.textContent = "WASM Fallback"; hardwareDesc.textContent = "No suitable WebGPU adapter found. Using WebAssembly CPU."; } } else { webgpuStatusPill.className = "status-badge webgpu-off"; webgpuStatusText.textContent = "WASM CPU"; hardwareDesc.textContent = "Browser WebGPU disabled or unavailable. Falling back to WASM."; } } catch (err) { console.warn("Hardware check:", err); webgpuStatusPill.className = "status-badge webgpu-off"; webgpuStatusText.textContent = "WASM CPU"; hardwareDesc.textContent = "WebGPU check failed; running CPU fallback."; } } initHardwareDetection(); // Cache status inspection async function updateCacheStatus() { try { if ('storage' in navigator && 'estimate' in navigator.storage) { const { usage, quota } = await navigator.storage.estimate(); const usedMB = (usage / (1024 * 1024)).toFixed(1); cacheStatusText.textContent = `Browser Storage: ${usedMB} MB cached`; } else { cacheStatusText.textContent = "Browser Storage: Active"; } } catch (e) { cacheStatusText.textContent = "Storage Active"; } } updateCacheStatus(); clearCacheBtn.onclick = async () => { if (!confirm("Clear all locally cached GGUF models from browser storage?")) return; try { if (wllamaInstance?.cacheManager) { await wllamaInstance.cacheManager.clear(); } if (window.caches) { const keys = await caches.keys(); for (const k of keys) await caches.delete(k); } alert("Browser model cache cleared successfully!"); updateCacheStatus(); } catch (err) { alert("Error clearing cache: " + err.message); } }; // Load Model Handler loadBtn.onclick = async () => { const selectedId = modelSelect.value; const model = MEDITOR_MODELS.find(m => m.id === selectedId); let modelUrl = ""; let displayName = ""; let hfToken = ""; if (selectedId === 'custom') { const repo = document.getElementById('custom-repo').value.trim(); const file = document.getElementById('custom-file').value.trim(); hfToken = document.getElementById('custom-token').value.trim(); if (!repo || !file) { alert("Please provide both Hugging Face Repo ID and GGUF file name."); return; } modelUrl = `https://huggingface.co/${repo}/resolve/main/${file}`; displayName = file; } else { modelUrl = model.url; displayName = model.name; } loadBtn.disabled = true; progressContainer.style.display = 'flex'; progressBarFill.style.width = '0%'; progressPercentLabel.textContent = '0%'; progressStatusLabel.textContent = 'Preparing WebGPU runtime...'; downloadStartTime = Date.now(); lastLoadedBytes = 0; try { // Unload existing model if present if (wllamaInstance) { try { await wllamaInstance.exit(); } catch (e) { console.warn("Clean exit error:", e); } wllamaInstance = null; } // Initialize Wllama instance with local WASM const wasmUrl = new URL('./wllama.wasm', import.meta.url).href; wllamaInstance = new Wllama({ default: wasmUrl, }); const nCtx = parseInt(ctxSlider.value, 10); const gpuLayersInput = gpuLayersSlider.value; const nGpuLayers = gpuLayersInput === "40" ? (hasWebGPU ? 99 : 0) : parseInt(gpuLayersInput, 10); progressStatusLabel.textContent = 'Downloading model weights...'; const downloadOptions = { n_ctx: nCtx, n_gpu_layers: nGpuLayers, n_threads: Math.min(4, Math.max(1, (navigator.hardwareConcurrency || 4) - 1)), progressCallback: ({ loaded, total }) => { if (total > 0) { const pct = Math.min(100, Math.round((loaded / total) * 100)); progressBarFill.style.width = `${pct}%`; progressPercentLabel.textContent = `${pct}%`; const loadedMB = (loaded / (1024 * 1024)).toFixed(1); const totalMB = (total / (1024 * 1024)).toFixed(1); progressBytesLabel.textContent = `${loadedMB} / ${totalMB} MB`; const elapsedSec = (Date.now() - downloadStartTime) / 1000; if (elapsedSec > 0.5) { const speedMBps = ((loaded / (1024 * 1024)) / elapsedSec).toFixed(1); progressSpeedLabel.textContent = `${speedMBps} MB/s`; } } else { progressBytesLabel.textContent = `${(loaded / (1024 * 1024)).toFixed(1)} MB`; } } }; if (hfToken) { modelUrl += `?token=${encodeURIComponent(hfToken)}`; } // Load the model await wllamaInstance.loadModelFromUrl(modelUrl, downloadOptions); progressStatusLabel.textContent = 'Compiling WebGPU shaders...'; progressBarFill.style.width = '100%'; progressPercentLabel.textContent = '100%'; // Set Active Model Pill activeModelName.textContent = displayName; activeModelName.parentElement.classList.add('badge-accent'); promptInput.disabled = false; sendBtn.disabled = false; loadBtn.textContent = '✓ Model Loaded (Ready)'; loadBtn.disabled = false; updateCacheStatus(); if (welcomeBox) welcomeBox.style.display = 'none'; appendMessage('bot', `**${displayName}** has been loaded successfully with **WebGPU** acceleration enabled.\n\nContext window: \`${nCtx} tokens\` | Offload: \`${nGpuLayers > 0 ? 'WebGPU' : 'CPU WASM'}\`.\n\nWhat would you like to explore?`); promptInput.focus(); } catch (err) { console.error("Load model error:", err); progressStatusLabel.textContent = 'Failed: ' + err.message; alert("Failed to load model: " + err.message); loadBtn.disabled = false; loadBtn.textContent = '⚡ Retry Loading Model'; } }; // Chat Interface Helpers function appendMessage(role, text) { if (welcomeBox) welcomeBox.style.display = 'none'; const msgDiv = document.createElement('div'); msgDiv.className = `message ${role}`; const bubble = document.createElement('div'); bubble.className = 'bubble'; if (role === 'bot') { bubble.innerHTML = marked.parse(text); } else { bubble.textContent = text; } msgDiv.appendChild(bubble); chatScroll.appendChild(msgDiv); chatScroll.scrollTop = chatScroll.scrollHeight; return bubble; } // Streaming Chat Completion async function sendMessage() { const text = promptInput.value.trim(); if (!text || isGenerating || !wllamaInstance) return; appendMessage('user', text); promptInput.value = ''; promptInput.style.height = '48px'; chatHistory.push({ role: 'user', content: text }); // Build payload const systemPrompt = document.getElementById('system-prompt').value.trim(); const messages = []; if (systemPrompt) { messages.push({ role: 'system', content: systemPrompt }); } messages.push(...chatHistory); isGenerating = true; shouldAbort = false; sendBtn.innerHTML = '■'; sendBtn.classList.add('stop-btn'); sendBtn.title = "Stop Generation"; const botBubble = appendMessage('bot', ''); let accumulated = ''; let tokenCount = 0; let startTime = Date.now(); let firstTokenTime = 0; const temperature = parseFloat(tempSlider.value); const maxTokens = parseInt(tokensSlider.value, 10); try { const stream = await wllamaInstance.createChatCompletion({ messages: messages, temperature: temperature, max_tokens: maxTokens, stream: true, }); for await (const chunk of stream) { if (shouldAbort) { break; } const delta = chunk.choices[0]?.delta?.content ?? ''; if (delta) { if (tokenCount === 0) { firstTokenTime = Date.now() - startTime; } accumulated += delta; tokenCount++; botBubble.innerHTML = marked.parse(accumulated); chatScroll.scrollTop = chatScroll.scrollHeight; // Live telemetry const elapsed = (Date.now() - startTime) / 1000; if (elapsed > 0.2) { const tokSec = (tokenCount / elapsed).toFixed(1); speedMeter.textContent = `${tokSec} tok/s`; } tokenCountMeter.textContent = `${tokenCount} tokens`; } } chatHistory.push({ role: 'assistant', content: accumulated }); // Add telemetry meta beneath bot response const totalElapsed = (Date.now() - startTime) / 1000; const finalSpeed = (tokenCount / (totalElapsed || 0.001)).toFixed(1); const metaDiv = document.createElement('div'); metaDiv.className = 'msg-meta'; metaDiv.innerHTML = `⚡ <span>${finalSpeed} tok/s</span> · <span>${tokenCount} tokens</span> · <span>TTFT: ${firstTokenTime}ms</span>`; botBubble.parentElement.appendChild(metaDiv); } catch (err) { console.error("Inference error:", err); botBubble.innerHTML += `<br><span style="color:var(--danger)">Error during generation: ${err.message}</span>`; } finally { isGenerating = false; sendBtn.innerHTML = '➤'; sendBtn.classList.remove('stop-btn'); sendBtn.title = "Send Message"; promptInput.focus(); } } sendBtn.onclick = () => { if (isGenerating) { shouldAbort = true; } else { sendMessage(); } }; promptInput.onkeydown = (e) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendMessage(); } }; // Auto-expand textarea promptInput.oninput = () => { promptInput.style.height = 'auto'; promptInput.style.height = Math.min(140, Math.max(48, promptInput.scrollHeight)) + 'px'; }; // Chip prompt triggers document.querySelectorAll('.chip').forEach(chip => { chip.onclick = () => { promptInput.value = chip.getAttribute('data-prompt'); promptInput.focus(); promptInput.dispatchEvent(new Event('input')); }; }); // Clear Chat clearChatBtn.onclick = () => { chatHistory = []; chatScroll.innerHTML = ''; if (welcomeBox) { chatScroll.appendChild(welcomeBox); welcomeBox.style.display = 'flex'; } speedMeter.textContent = '0.0 tok/s'; tokenCountMeter.textContent = '0 tokens'; }; // Sidebar Mobile Toggle const sidebar = document.getElementById('sidebar'); const toggleSidebarBtn = document.getElementById('toggle-sidebar-btn'); if (toggleSidebarBtn) { toggleSidebarBtn.onclick = () => sidebar.classList.toggle('open'); } </script> </body> </html>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Meditor AI — In-Browser WebGPU Chat</title>
<script src="./marked.min.js"></script>
<style>
:root {
--bg: #090b11;
--sidebar-bg: #10141f;
--card-bg: #161b2a;
--card-elevated: #1d2438;
--border: #222b40;
--border-focus: #10b981;
--text: #f3f4f6;
--text-muted: #94a3b8;
--accent: #10b981;
--accent-glow: rgba(16, 185, 129, 0.25);
--accent-purple: #8b5cf6;
--user-bubble: #1f293d;
--bot-bubble: #141926;
--danger: #ef4444;
--warning: #f59e0b;
}
* { box-sizing: border-box; margin: 0; padding: 0; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; }
body { background: var(--bg); color: var(--text); display: flex; height: 100vh; overflow: hidden; }
/* SIDEBAR */
aside {
width: 340px;
background: var(--sidebar-bg);
border-right: 1px solid var(--border);
display: flex;
flex-direction: column;
padding: 18px;
gap: 16px;
overflow-y: auto;
flex-shrink: 0;
z-index: 10;
}
.brand {
display: flex;
align-items: center;
gap: 12px;
padding-bottom: 12px;
border-bottom: 1px solid var(--border);
}
.brand-logo {
width: 42px;
height: 42px;
border-radius: 10px;
background: linear-gradient(135deg, #10b981, #6366f1);
display: flex;
align-items: center;
justify-content: center;
font-size: 20px;
font-weight: 800;
color: #fff;
box-shadow: 0 0 16px var(--accent-glow);
}
.brand-info h1 { font-size: 17px; font-weight: 700; color: #fff; letter-spacing: -0.3px; }
.brand-info p { font-size: 11px; color: var(--text-muted); }
/* HARDWARE STATUS CARD */
.hardware-card {
background: var(--card-bg);
border: 1px solid var(--border);
border-radius: 10px;
padding: 12px;
display: flex;
flex-direction: column;
gap: 8px;
font-size: 12px;
}
.hardware-header {
display: flex;
align-items: center;
justify-content: space-between;
font-weight: 600;
}
.status-badge {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 3px 8px;
border-radius: 12px;
font-size: 11px;
font-weight: 600;
}
.status-badge.webgpu-on {
background: rgba(16, 185, 129, 0.15);
border: 1px solid #10b981;
color: #34d399;
}
.status-badge.webgpu-off {
background: rgba(245, 158, 11, 0.15);
border: 1px solid #f59e0b;