Spaces:
Running
Running
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>LFM2.5 VL 1B Demo</title> | |
| <link rel="preconnect" href="https://fonts.googleapis.com"> | |
| <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> | |
| <link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet"> | |
| <link rel="stylesheet" href="styles.css"> | |
| </head> | |
| <body> | |
| <!-- Loading Screen --> | |
| <div id="loading-screen" class="loading-screen"> | |
| <!-- Animated Background Canvas --> | |
| <canvas id="loading-canvas" class="loading-canvas"></canvas> | |
| <!-- Vignette Overlay --> | |
| <div class="loading-vignette"></div> | |
| <!-- Main Content --> | |
| <div class="loading-content"> | |
| <div class="loading-header" style="display: flex; justify-content: center; align-items: center;"> | |
| <img | |
| src="assets/liquid-ai.svg" | |
| alt="Liquid AI" | |
| class="loading-logo" | |
| style=" | |
| width: min(16vw, 128px); | |
| height: min(16vw, 128px); | |
| max-width: 40vw; | |
| max-height: 40vw; | |
| min-width: 64px; | |
| min-height: 64px; | |
| object-fit: contain; | |
| transition: width 0.2s, height 0.2s; | |
| "> | |
| </div> | |
| <div class="loading-title-section"> | |
| <h1 class="loading-title">LFM2.5-VL-1.6B WebGPU</h1> | |
| <p class="loading-subtitle">Vision-Language Model in Your Browser</p> | |
| </div> | |
| <div class="loading-description"> | |
| <p>This demo showcases in-browser vision-language inference with LFM2.5-VL-1.6B, powered by ONNX Runtime and WebGPU.</p> | |
| <p>Everything runs entirely in your browser with WebGPU acceleration, meaning no data is sent to a server.</p> | |
| </div> | |
| <div class="loading-action-section"> | |
| <button id="loading-explore-btn" class="loading-explore-button"> | |
| <span id="loading-btn-text">Explore</span> | |
| <span id="loading-spinner" class="loading-spinner hidden"></span> | |
| <span id="loading-progress-text" class="loading-progress-text hidden"></span> | |
| </button> | |
| </div> | |
| <div id="loading-error" class="loading-error hidden"> | |
| <p id="loading-error-text"></p> | |
| <button id="loading-retry-btn" class="loading-retry-button">Retry</button> | |
| </div> | |
| </div> | |
| </div> | |
| <div class="app-layout"> | |
| <!-- Top Navigation --> | |
| <div class="top-nav"> | |
| <div class="nav-left"> | |
| <img src="assets/liquid-ai.svg" alt="Liquid AI" class="nav-logo-img"> | |
| <a href="https://www.liquid.ai" target="_blank" rel="noopener noreferrer" class="nav-logo-link">Liquid</a> | |
| </div> | |
| <div class="nav-center"> | |
| <span class="nav-title">LFM2.5-VL-1.6B</span> | |
| <span class="nav-subtitle">Stream from your webcam with real-time captions,powered by your own hardware with WebGPU!</span> | |
| </div> | |
| </div> | |
| <!-- Main Content Area --> | |
| <div class="container"> | |
| <!-- Live Caption Mode --> | |
| <div id="live-caption-mode" class="mode-container active"> | |
| <div class="live-caption-content"> | |
| <div class="live-caption-video-section"> | |
| <div class="live-caption-video-container"> | |
| <video id="live-caption-video" autoplay></video> | |
| <!-- Capture Overlay (on video) --> | |
| <div class="capture-overlay"> | |
| <button id="start-live-caption-btn" class="control-btn primary">Start</button> | |
| <div class="overlay-field"> | |
| <label class="overlay-label">Input:</label> | |
| <select id="live-caption-resolution-select" class="control-select"> | |
| <option value="256">256px</option> | |
| <option value="384" selected>384px</option> | |
| <option value="448">448px</option> | |
| <option value="512">512px</option> | |
| </select> | |
| </div> | |
| <div class="capture-status"> | |
| <span class="status-indicator" id="live-status-indicator"></span> | |
| <span class="status-text" id="live-status-text">Idle</span> | |
| </div> | |
| </div> | |
| </div> | |
| <div class="controls-bar"> | |
| <!-- Status Row --> | |
| <div class="controls-row status-row"> | |
| <span class="model-status" id="model-status"></span> | |
| <!-- Progress Bar (shown during loading) --> | |
| <div class="progress-bar-row" id="loading-progress" style="display: none;"> | |
| <div class="progress-fill" id="progress-fill" style="width: 0%"></div> | |
| </div> | |
| </div> | |
| <!-- Controls Row --> | |
| <div class="controls-row"> | |
| <div class="control-group model-group"> | |
| <label class="control-label">Select quantization:</label> | |
| <select id="model-select" class="control-select model-select"> | |
| <!-- Options populated from config.js --> | |
| </select> | |
| <button class="control-btn" id="reload-model-btn" title="Load Model">Load</button> | |
| </div> | |
| <div class="control-group cache-group"> | |
| <span class="cache-info" id="cache-info">0 MB</span> | |
| <button class="control-btn small" id="clear-cache-btn" disabled title="Clear Cache">Clear</button> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <div class="live-caption-text-section"> | |
| <h3 class="caption-section-title">Captions</h3> | |
| <div class="latest-caption" id="latest-caption">Start capturing to see live captions...</div> | |
| <div class="caption-history" id="caption-history"></div> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <script> | |
| // ============================================ | |
| // LOADING SCREEN | |
| // ============================================ | |
| let loadingScreenVisible = true; | |
| let loadingCanvas = null; | |
| let loadingCtx = null; | |
| let loadingDots = []; | |
| let loadingAnimationId = null; | |
| function initLoadingScreen() { | |
| // Initialize canvas animation | |
| loadingCanvas = document.getElementById('loading-canvas'); | |
| if (!loadingCanvas) return; | |
| loadingCtx = loadingCanvas.getContext('2d'); | |
| setupLoadingCanvas(); | |
| animateLoadingCanvas(); | |
| // Set up event listeners | |
| setupLoadingScreenListeners(); | |
| // Handle window resize | |
| window.addEventListener('resize', setupLoadingCanvas); | |
| } | |
| function setupLoadingCanvas() { | |
| if (!loadingCanvas || !loadingCtx) return; | |
| loadingCanvas.width = window.innerWidth; | |
| loadingCanvas.height = window.innerHeight; | |
| // Create dots | |
| loadingDots = []; | |
| const numDots = Math.floor((loadingCanvas.width * loadingCanvas.height) / 15000); | |
| for (let i = 0; i < numDots; i++) { | |
| loadingDots.push({ | |
| x: Math.random() * loadingCanvas.width, | |
| y: Math.random() * loadingCanvas.height, | |
| radius: Math.random() * 1.5 + 0.5, | |
| speed: Math.random() * 0.5 + 0.1, | |
| opacity: Math.random() * 0.5 + 0.2, | |
| blur: Math.random() > 0.7 ? Math.random() * 2 + 1 : 0 | |
| }); | |
| } | |
| } | |
| function animateLoadingCanvas() { | |
| if (!loadingCtx || !loadingCanvas) return; | |
| loadingCtx.clearRect(0, 0, loadingCanvas.width, loadingCanvas.height); | |
| loadingDots.forEach(dot => { | |
| // Update position | |
| dot.y += dot.speed; | |
| if (dot.y > loadingCanvas.height) { | |
| dot.y = 0 - dot.radius; | |
| dot.x = Math.random() * loadingCanvas.width; | |
| } | |
| // Draw dot | |
| loadingCtx.beginPath(); | |
| loadingCtx.arc(dot.x, dot.y, dot.radius, 0, Math.PI * 2); | |
| loadingCtx.fillStyle = `rgba(255, 255, 255, ${dot.opacity})`; | |
| if (dot.blur > 0) { | |
| loadingCtx.filter = `blur(${dot.blur}px)`; | |
| } | |
| loadingCtx.fill(); | |
| loadingCtx.filter = 'none'; | |
| }); | |
| loadingAnimationId = requestAnimationFrame(animateLoadingCanvas); | |
| } | |
| function setupLoadingScreenListeners() { | |
| // Explore button | |
| const exploreBtn = document.getElementById('loading-explore-btn'); | |
| if (exploreBtn) { | |
| exploreBtn.addEventListener('click', handleLoadingScreenLoad); | |
| } | |
| // Retry button | |
| const retryBtn = document.getElementById('loading-retry-btn'); | |
| if (retryBtn) { | |
| retryBtn.addEventListener('click', handleLoadingScreenLoad); | |
| } | |
| } | |
| async function handleLoadingScreenLoad() { | |
| const exploreBtn = document.getElementById('loading-explore-btn'); | |
| if (!exploreBtn) return; | |
| // Simply hide the loading screen - user can load model manually via the input field | |
| hideLoadingScreen(); | |
| } | |
| function hideLoadingScreen() { | |
| const screen = document.getElementById('loading-screen'); | |
| if (screen) { | |
| screen.classList.add('hidden'); | |
| loadingScreenVisible = false; | |
| // Stop canvas animation | |
| if (loadingAnimationId) { | |
| cancelAnimationFrame(loadingAnimationId); | |
| loadingAnimationId = null; | |
| } | |
| // Clear any URL hash from old bookmarks/links | |
| if (window.location.hash) { | |
| history.replaceState(null, '', window.location.pathname); | |
| } | |
| } | |
| } | |
| function showLoadingScreen() { | |
| const screen = document.getElementById('loading-screen'); | |
| if (screen) { | |
| screen.classList.remove('hidden'); | |
| loadingScreenVisible = true; | |
| // Restart canvas animation if needed | |
| if (!loadingAnimationId && loadingCanvas) { | |
| animateLoadingCanvas(); | |
| } | |
| } | |
| } | |
| function isMobileDevice() { | |
| return /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent) | |
| || (navigator.maxTouchPoints > 0 && window.innerWidth < 1024); | |
| } | |
| function showMobileWarning() { | |
| if (!isMobileDevice()) return; | |
| const warningDiv = document.createElement('div'); | |
| warningDiv.className = 'mobile-warning'; | |
| warningDiv.innerHTML = ` | |
| <div class="mobile-warning-title"> | |
| <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> | |
| <path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"></path> | |
| <line x1="12" y1="9" x2="12" y2="13"></line> | |
| <line x1="12" y1="17" x2="12.01" y2="17"></line> | |
| </svg> | |
| Mobile Device Detected | |
| </div> | |
| <p>This demo requires a desktop browser. Mobile browsers have memory limits smaller than the model size.</p> | |
| <p>Please visit this page on a desktop computer.</p> | |
| `; | |
| const actionSection = document.querySelector('.loading-action-section'); | |
| if (actionSection) { | |
| actionSection.parentNode.insertBefore(warningDiv, actionSection); | |
| } | |
| const btnText = document.getElementById('loading-btn-text'); | |
| if (btnText) { | |
| btnText.textContent = 'Try Anyway'; | |
| } | |
| } | |
| function isSafariDesktop() { | |
| const ua = navigator.userAgent; | |
| // Safari has "Safari" in UA but Chrome/Edge also include it | |
| // True Safari doesn't have "Chrome" or "Chromium" in UA | |
| const isSafari = /Safari/.test(ua) && !/Chrome|Chromium/.test(ua); | |
| // Exclude iOS (iPhone, iPad, iPod) | |
| const isIOS = /iPhone|iPad|iPod/.test(ua); | |
| return isSafari && !isIOS; | |
| } | |
| function showSafariWarning() { | |
| if (!isSafariDesktop()) return; | |
| // Add Safari class to body for CSS targeting | |
| document.body.classList.add('is-safari'); | |
| const warningDiv = document.createElement('div'); | |
| warningDiv.className = 'safari-warning'; | |
| warningDiv.innerHTML = ` | |
| <div class="safari-warning-title"> | |
| <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> | |
| <circle cx="12" cy="12" r="10"></circle> | |
| <line x1="12" y1="8" x2="12" y2="12"></line> | |
| <line x1="12" y1="16" x2="12.01" y2="16"></line> | |
| </svg> | |
| Safari requires WebGPU to be explicitly enabled | |
| </div> | |
| <p>WebGPU must be manually enabled in Safari. Go to <strong>Safari → Settings → Feature Flags </strong> and enable <strong>WebGPU</strong>.</p> | |
| <p><a href="https://webkit.org/blog/14879/webgpu-now-available-for-testing-in-safari-technology-preview/" target="_blank" rel="noopener noreferrer">Learn more about WebGPU in Safari</a></p> | |
| `; | |
| const actionSection = document.querySelector('.loading-action-section'); | |
| if (actionSection) { | |
| actionSection.parentNode.insertBefore(warningDiv, actionSection); | |
| } | |
| } | |
| // Initialize loading screen on page load | |
| if (document.readyState === 'loading') { | |
| document.addEventListener('DOMContentLoaded', () => { | |
| initLoadingScreen(); | |
| showMobileWarning(); | |
| showSafariWarning(); | |
| }); | |
| } else { | |
| initLoadingScreen(); | |
| showMobileWarning(); | |
| showSafariWarning(); | |
| } | |
| // ============================================ | |
| // GENERATION CONFIG | |
| // ============================================ | |
| const generationConfig = { | |
| max_new_tokens: 128 | |
| }; | |
| function getModeConfig() { | |
| return { | |
| max_new_tokens: generationConfig.max_new_tokens | |
| }; | |
| } | |
| // Make getModeConfig available globally for main.js | |
| window.getModeConfig = getModeConfig; | |
| // ============================================ | |
| // LIVE CAPTION MODE | |
| // ============================================ | |
| let liveCaptionStream = null; | |
| let isCapturing = false; | |
| let captureLoopRunning = false; | |
| async function startLiveCaption() { | |
| try { | |
| const video = document.getElementById('live-caption-video'); | |
| const statusIndicator = document.getElementById('live-status-indicator'); | |
| const statusText = document.getElementById('live-status-text'); | |
| const startBtn = document.getElementById('start-live-caption-btn'); | |
| // Get webcam stream | |
| liveCaptionStream = await navigator.mediaDevices.getUserMedia({ | |
| video: { width: 1024, height: 1024 } | |
| }); | |
| video.srcObject = liveCaptionStream; | |
| // Wait for video to actually start playing before capturing | |
| await new Promise((resolve) => { | |
| video.onloadeddata = () => { | |
| video.play(); | |
| resolve(); | |
| }; | |
| // If already loaded, resolve immediately | |
| if (video.readyState >= 2) { | |
| video.play(); | |
| resolve(); | |
| } | |
| }); | |
| // Wait for camera to warm up (avoid black first frame) | |
| await new Promise(resolve => setTimeout(resolve, 500)); | |
| // Update UI | |
| isCapturing = true; | |
| statusIndicator.classList.add('active'); | |
| statusText.textContent = 'Capturing'; | |
| startBtn.textContent = 'Stop'; | |
| startBtn.classList.add('stop'); | |
| // Hide the initial placeholder text | |
| const latestCaption = document.getElementById('latest-caption'); | |
| if (latestCaption) { | |
| latestCaption.style.display = 'none'; | |
| } | |
| // Start capture loop (waits for each generation to complete) | |
| captureLoop(); | |
| } catch (error) { | |
| console.error('Error starting live caption:', error); | |
| alert('Could not access webcam. Please check permissions.'); | |
| } | |
| } | |
| function stopLiveCaption() { | |
| const video = document.getElementById('live-caption-video'); | |
| const statusIndicator = document.getElementById('live-status-indicator'); | |
| const statusText = document.getElementById('live-status-text'); | |
| const startBtn = document.getElementById('start-live-caption-btn'); | |
| // Setting isCapturing to false stops the capture loop | |
| isCapturing = false; | |
| if (liveCaptionStream) { | |
| liveCaptionStream.getTracks().forEach(track => track.stop()); | |
| liveCaptionStream = null; | |
| } | |
| if (video) { | |
| video.srcObject = null; | |
| } | |
| if (statusIndicator) statusIndicator.classList.remove('active'); | |
| if (statusText) statusText.textContent = 'Idle'; | |
| if (startBtn) { | |
| startBtn.textContent = 'Start'; | |
| startBtn.classList.remove('stop'); | |
| } | |
| } | |
| // Expose globally so main.js can stop capture before loading new model | |
| window.stopLiveCaption = stopLiveCaption; | |
| /** | |
| * Async capture loop - waits for each generation to complete before starting next | |
| */ | |
| async function captureLoop() { | |
| if (!isCapturing || captureLoopRunning) return; | |
| captureLoopRunning = true; | |
| while (isCapturing) { | |
| await captureLiveCaptionFrame(); | |
| } | |
| captureLoopRunning = false; | |
| } | |
| async function captureLiveCaptionFrame() { | |
| if (!isCapturing) return; | |
| const video = document.getElementById('live-caption-video'); | |
| const statusText = document.getElementById('live-status-text'); | |
| // Get resolution from dropdown | |
| const resolutionSelect = document.getElementById('live-caption-resolution-select'); | |
| const resolution = parseInt(resolutionSelect?.value || '384', 10); | |
| // Create canvas and capture frame | |
| const canvas = document.createElement('canvas'); | |
| canvas.width = resolution; | |
| canvas.height = resolution; | |
| const ctx = canvas.getContext('2d'); | |
| ctx.drawImage(video, 0, 0, resolution, resolution); | |
| // Use DataURL - browser's native JPEG encoding is faster than JS ImageData handling | |
| const dataURL = canvas.toDataURL('image/jpeg', 0.8); | |
| try { | |
| const config = getModeConfig(); | |
| // Wait for webgpuInit to be available | |
| if (!window.webgpuInit) { | |
| await new Promise((resolve, reject) => { | |
| if (window.webgpuInit) { | |
| resolve(); | |
| } else { | |
| const timeout = setTimeout(() => { | |
| reject(new Error('WebGPU system initialization timeout')); | |
| }, 10000); | |
| window.addEventListener('webgpu-ready', () => { | |
| clearTimeout(timeout); | |
| resolve(); | |
| }, { once: true }); | |
| } | |
| }); | |
| } | |
| if (!window.webgpuInit.isModelLoaded()) { | |
| if (statusText) statusText.textContent = 'Model not loaded'; | |
| return; | |
| } | |
| // Build message | |
| const messages = [{ | |
| role: 'user', | |
| content: [ | |
| { type: 'image', value: dataURL }, | |
| { type: 'text', value: 'Describe what you see in one sentence.' } | |
| ] | |
| }]; | |
| const response = await window.webgpuInit.generate(messages, { | |
| maxNewTokens: config.max_new_tokens | |
| }); | |
| updateLiveCaption(response); | |
| } catch (error) { | |
| console.error('Error generating caption:', error); | |
| if (statusText) statusText.textContent = 'Error'; | |
| } | |
| } | |
| function updateLiveCaption(caption) { | |
| // Skip empty or whitespace-only captions (happens when model is busy) | |
| if (!caption || !caption.trim()) { | |
| return; | |
| } | |
| const captionHistory = document.getElementById('caption-history'); | |
| // Remove 'latest' class from all existing items | |
| const existingItems = captionHistory.querySelectorAll('.caption-history-item'); | |
| existingItems.forEach(item => item.classList.remove('latest')); | |
| // Add to history at the top (most recent first) | |
| const timestamp = new Date().toLocaleTimeString(); | |
| const historyItem = document.createElement('div'); | |
| historyItem.className = 'caption-history-item latest'; | |
| historyItem.innerHTML = ` | |
| <span class="caption-timestamp">${timestamp}</span> | |
| <span class="caption-text">${caption.trim()}</span> | |
| `; | |
| captionHistory.prepend(historyItem); | |
| // Keep only the last 7 items, remove oldest from bottom | |
| while (captionHistory.children.length > 7) { | |
| captionHistory.removeChild(captionHistory.lastChild); | |
| } | |
| // Apply fading effect: newest (first) is fully visible, older ones fade | |
| const items = captionHistory.children; | |
| for (let i = 0; i < items.length; i++) { | |
| // First item is 1.0, then fade to 0.1 for older items | |
| const opacity = Math.max(0.1, 1.0 - (i * 0.2)); | |
| items[i].style.opacity = opacity; | |
| } | |
| } | |
| // Model state storage - don't restore from localStorage since model needs to be reloaded each session | |
| let CURRENT_MODEL = ''; | |
| function formatModelName(modelId) { | |
| // Convert "LFM2.5-VL-1.6B-merge-linear-Q4-FP16" to "LFM2.5-VL-1.6B Q4/FP16" | |
| if (!modelId || modelId === 'Loading...') return modelId; | |
| // Remove "merge-linear-" or similar internal identifiers | |
| let clean = modelId.replace(/-merge-linear/g, '').replace(/-91\d+/g, ''); | |
| // Convert trailing "Q4-FP16" to "Q4/FP16" | |
| clean = clean.replace(/-(Q4|FP16)-(Q4|FP16)$/, ' $1/$2'); | |
| return clean; | |
| } | |
| function updateModelStatus(modelId = null) { | |
| const statusEl = document.getElementById('model-status'); | |
| if (statusEl) { | |
| if (modelId) { | |
| CURRENT_MODEL = modelId; | |
| localStorage.setItem('CURRENT_MODEL', modelId); | |
| statusEl.textContent = formatModelName(modelId); | |
| statusEl.style.color = 'var(--text-primary)'; | |
| } else { | |
| CURRENT_MODEL = ''; | |
| localStorage.removeItem('CURRENT_MODEL'); | |
| statusEl.textContent = 'No model loaded'; | |
| statusEl.style.color = 'var(--text-secondary)'; | |
| } | |
| } | |
| } | |
| async function loadSelectedModel(modelId) { | |
| const modelSelect = document.getElementById('model-select'); | |
| const reloadBtn = document.getElementById('reload-model-btn'); | |
| // Show loading state | |
| if (modelSelect) { | |
| modelSelect.disabled = true; | |
| } | |
| if (reloadBtn) { | |
| reloadBtn.disabled = true; | |
| reloadBtn.style.opacity = '0.6'; | |
| reloadBtn.style.cursor = 'not-allowed'; | |
| } | |
| // Update status to loading | |
| updateModelStatus('Loading...'); | |
| try { | |
| // Wait for webgpuInit to be available | |
| if (!window.webgpuInit) { | |
| await new Promise((resolve, reject) => { | |
| if (window.webgpuInit) { | |
| resolve(); | |
| } else { | |
| const timeout = setTimeout(() => { | |
| reject(new Error('WebGPU system initialization timeout. Please refresh the page.')); | |
| }, 10000); | |
| window.addEventListener('webgpu-ready', () => { | |
| clearTimeout(timeout); | |
| resolve(); | |
| }, { once: true }); | |
| } | |
| }); | |
| } | |
| await window.webgpuInit.handleLoadModel(); | |
| // Store current model (status already updated by handleLoadModel) | |
| CURRENT_MODEL = modelId; | |
| localStorage.setItem('CURRENT_MODEL', CURRENT_MODEL); | |
| // Enable buttons when model loads | |
| if (window.webgpuInit && window.webgpuInit.updateButtonStates) { | |
| window.webgpuInit.updateButtonStates(true); | |
| } | |
| } catch (error) { | |
| updateModelStatus(null); | |
| alert(`Error loading model: ${error.message}`); | |
| console.error('Error loading model:', error); | |
| } finally { | |
| // Restore UI state | |
| if (modelSelect) { | |
| modelSelect.disabled = false; | |
| } | |
| if (reloadBtn) { | |
| reloadBtn.disabled = false; | |
| reloadBtn.style.opacity = '1'; | |
| reloadBtn.style.cursor = 'pointer'; | |
| } | |
| } | |
| } | |
| // Inference via WebGPU | |
| async function generate(messages, options = {}) { | |
| const config = getModeConfig(); | |
| // Wait for webgpuInit to be available | |
| if (!window.webgpuInit) { | |
| // Wait for webgpu-ready event | |
| await new Promise((resolve, reject) => { | |
| if (window.webgpuInit) { | |
| resolve(); | |
| } else { | |
| const timeout = setTimeout(() => { | |
| reject(new Error('WebGPU system initialization timeout. Please refresh the page.')); | |
| }, 10000); | |
| window.addEventListener('webgpu-ready', () => { | |
| clearTimeout(timeout); | |
| resolve(); | |
| }, { once: true }); | |
| } | |
| }); | |
| } | |
| if (!window.webgpuInit.isModelLoaded()) { | |
| throw new Error('Model not loaded. Please load a model first.'); | |
| } | |
| try { | |
| // Generate using WebGPU with streaming support | |
| const response = await window.webgpuInit.generate(messages, { | |
| maxNewTokens: config.max_new_tokens, | |
| onToken: options.onToken || ((token) => { | |
| // Default: do nothing if no callback provided | |
| return false; | |
| }) | |
| }); | |
| return response; | |
| } catch (error) { | |
| console.error('Error calling WebGPU inference:', error); | |
| throw error; | |
| } | |
| } | |
| // Initialize | |
| function init() { | |
| setupModeEventListeners(); | |
| // Clear any URL hash from old bookmarks/links | |
| if (window.location.hash) { | |
| history.replaceState(null, '', window.location.pathname); | |
| } | |
| } | |
| function setupModeEventListeners() { | |
| const reloadModelBtn = document.getElementById('reload-model-btn'); | |
| if (reloadModelBtn) { | |
| reloadModelBtn.addEventListener('click', () => { | |
| const modelSelect = document.getElementById('model-select'); | |
| const selectedModelId = modelSelect?.value; | |
| if (!selectedModelId) { | |
| alert('Please select a model first.'); | |
| return; | |
| } | |
| loadSelectedModel(selectedModelId); | |
| }); | |
| } | |
| // Live Caption controls | |
| const liveCaptionBtn = document.getElementById('start-live-caption-btn'); | |
| if (liveCaptionBtn) { | |
| liveCaptionBtn.addEventListener('click', () => { | |
| if (isCapturing) { | |
| stopLiveCaption(); | |
| } else { | |
| startLiveCaption(); | |
| } | |
| }); | |
| } | |
| // Model selector dropdown | |
| const modelSelect = document.getElementById('model-select'); | |
| if (modelSelect) { | |
| // Restore last selected model if saved | |
| const savedModelId = localStorage.getItem('SELECTED_MODEL_ID'); | |
| if (savedModelId && modelSelect.querySelector(`option[value="${savedModelId}"]`)) { | |
| modelSelect.value = savedModelId; | |
| } | |
| // Initialize model status display | |
| if (CURRENT_MODEL) { | |
| updateModelStatus(CURRENT_MODEL); | |
| } else { | |
| updateModelStatus(null); | |
| } | |
| // Save selection on change | |
| modelSelect.addEventListener('change', (e) => { | |
| localStorage.setItem('SELECTED_MODEL_ID', e.target.value); | |
| }); | |
| } | |
| } | |
| if (document.readyState === 'loading') { | |
| document.addEventListener('DOMContentLoaded', init); | |
| } else { | |
| init(); | |
| } | |
| </script> | |
| <!-- Load main.js module --> | |
| <script type="module" src="./main.js"></script> | |
| </body> | |
| </html> | |