Spaces:
Running
Running
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>LOCAL MIND β On-Device VLM</title> | |
| <script type="module"> | |
| import * as ort from "https://cdn.jsdelivr.net/npm/onnxruntime-web@1.21.0/dist/ort.webgpu.bundle.min.mjs"; | |
| import { AutoTokenizer } from "https://cdn.jsdelivr.net/npm/@huggingface/transformers@3.5.1/dist/transformers.min.js"; | |
| // βββ CONFIG βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| const MODEL_ID = "LiquidAI/LFM2.5-VL-1.6B-ONNX"; | |
| const MODEL_BASE = `https://huggingface.co/${MODEL_ID}/resolve/main`; | |
| const HIDDEN_SIZE = 1536; | |
| const NUM_KV_HEADS = 12; | |
| const HEAD_DIM = 128; | |
| const MAX_NEW_TOKENS = 512; | |
| // Approximate file sizes in bytes for progress weighting | |
| const FILE_SIZES = { | |
| 'embed_tokens_fp16.onnx_data': 30 * 1024 * 1024, | |
| 'embed_images_fp16.onnx_data': 400 * 1024 * 1024, | |
| 'decoder_q4.onnx_data': 1100 * 1024 * 1024, | |
| }; | |
| const TOTAL_BYTES = Object.values(FILE_SIZES).reduce((a, b) => a + b, 0); | |
| // Per-file downloaded bytes tracker | |
| const downloadedBytes = {}; | |
| let compilingPhase = false; | |
| let tokenizer = null; | |
| let embedTokens = null; | |
| let embedImages = null; | |
| let decoder = null; | |
| let isLoaded = false; | |
| let isGenerating = false; | |
| let chatHistory = []; | |
| const $ = id => document.getElementById(id); | |
| const statusEl = $('status'); | |
| const progressEl = $('progress'); | |
| const progressBar = $('progress-bar'); | |
| const progressText = $('progress-text'); | |
| const progressDetail = $('progress-detail'); | |
| const chatContainer = $('chat-container'); | |
| const inputEl = $('user-input'); | |
| const sendBtn = $('send-btn'); | |
| const loadBtn = $('load-btn'); | |
| const storageInfo = $('storage-info'); | |
| const cacheIndicator = $('cache-indicator'); | |
| const imageBtn = $('image-btn'); | |
| const imageInput = $('image-input'); | |
| const imagePreview = $('image-preview'); | |
| const imagePreviewImg = $('image-preview-img'); | |
| const removeImageBtn = $('remove-image-btn'); | |
| const loadingOverlay = $('loading-overlay'); | |
| const loadingStep = $('loading-step'); | |
| const loadingFile = $('loading-file'); | |
| const loadingBytes = $('loading-bytes'); | |
| const loadingEta = $('loading-eta'); | |
| const loadingBarFill = $('loading-bar-fill'); | |
| const loadingPct = $('loading-pct'); | |
| let currentImageData = null; | |
| // βββ SPEED / ETA TRACKING ββββββββββββββββββββββββββββββββββββββββββββββββ | |
| let downloadStart = null; | |
| let lastSpeedBytes = 0; | |
| let lastSpeedTime = 0; | |
| let speedSamples = []; | |
| function formatBytes(bytes) { | |
| if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} KB`; | |
| return `${(bytes / 1024 / 1024).toFixed(1)} MB`; | |
| } | |
| function formatSpeed(bps) { | |
| if (bps < 1024 * 1024) return `${(bps / 1024).toFixed(0)} KB/s`; | |
| return `${(bps / 1024 / 1024).toFixed(1)} MB/s`; | |
| } | |
| function formatEta(seconds) { | |
| if (!isFinite(seconds) || seconds < 0) return 'β'; | |
| if (seconds < 60) return `~${Math.ceil(seconds)}s`; | |
| return `~${Math.ceil(seconds / 60)}min ${Math.ceil(seconds % 60)}s`; | |
| } | |
| function updateOverallProgress(currentFile) { | |
| const total = Object.values(downloadedBytes).reduce((a, b) => a + b, 0); | |
| const pct = Math.min(99, Math.round((total / TOTAL_BYTES) * 100)); | |
| // Speed calculation (rolling average over last 5 samples) | |
| const now = Date.now(); | |
| if (lastSpeedTime) { | |
| const dt = (now - lastSpeedTime) / 1000; | |
| const db = total - lastSpeedBytes; | |
| if (dt > 0.3) { | |
| const sample = db / dt; | |
| speedSamples.push(sample); | |
| if (speedSamples.length > 8) speedSamples.shift(); | |
| lastSpeedBytes = total; | |
| lastSpeedTime = now; | |
| } | |
| } else { | |
| downloadStart = now; | |
| lastSpeedTime = now; | |
| lastSpeedBytes = 0; | |
| } | |
| const avgSpeed = speedSamples.length | |
| ? speedSamples.reduce((a, b) => a + b, 0) / speedSamples.length | |
| : 0; | |
| const remaining = avgSpeed > 0 ? (TOTAL_BYTES - total) / avgSpeed : Infinity; | |
| // Update big overlay | |
| loadingBarFill.style.width = `${pct}%`; | |
| loadingPct.textContent = `${pct}%`; | |
| loadingBytes.textContent = `${formatBytes(total)} / ${formatBytes(TOTAL_BYTES)}`; | |
| if (avgSpeed > 0) { | |
| loadingEta.textContent = `${formatSpeed(avgSpeed)} Β· ETA ${formatEta(remaining)}`; | |
| } | |
| // Update header mini bar | |
| progressBar.style.width = `${pct}%`; | |
| progressText.textContent = `${pct}%`; | |
| // Current file label | |
| if (currentFile) { | |
| const fileBytes = downloadedBytes[currentFile] || 0; | |
| const fileTotal = FILE_SIZES[currentFile] || 0; | |
| const filePct = fileTotal ? Math.min(100, Math.round(fileBytes / fileTotal * 100)) : 0; | |
| loadingFile.textContent = currentFile; | |
| if (progressDetail) progressDetail.textContent = `${currentFile} β ${filePct}%`; | |
| } | |
| } | |
| // βββ FETCH WITH PROGRESS βββββββββββββββββββββββββββββββββββββββββββββββββ | |
| async function fetchWithProgress(url, label) { | |
| const key = label; | |
| downloadedBytes[key] = 0; | |
| const resp = await fetch(url); | |
| if (!resp.ok) throw new Error(`HTTP ${resp.status} for ${url}`); | |
| const contentLength = parseInt(resp.headers.get('content-length') || '0', 10); | |
| const knownSize = FILE_SIZES[label] || contentLength || 1; | |
| const reader = resp.body.getReader(); | |
| const chunks = []; | |
| let received = 0; | |
| while (true) { | |
| const { done, value } = await reader.read(); | |
| if (done) break; | |
| chunks.push(value); | |
| received += value.length; | |
| downloadedBytes[key] = received; | |
| updateOverallProgress(label); | |
| } | |
| // Merge chunks | |
| const total = chunks.reduce((s, c) => s + c.length, 0); | |
| const merged = new Uint8Array(total); | |
| let offset = 0; | |
| for (const c of chunks) { merged.set(c, offset); offset += c.length; } | |
| downloadedBytes[key] = merged.length; | |
| return merged; | |
| } | |
| // βββ LOAD ORT SESSION WITH PROGRESS ββββββββββββββββββββββββββββββββββββββ | |
| async function loadOrtSessionWithProgress(name, stepLabel, stepNum) { | |
| const onnxUrl = `${MODEL_BASE}/onnx/${name}.onnx`; | |
| const dataLabel = `${name}.onnx_data`; | |
| const dataUrl = `${MODEL_BASE}/onnx/${dataLabel}`; | |
| setStep(stepLabel, 'Fetching model header...', stepNum); | |
| // Fetch the small .onnx file (just the graph, no weights) | |
| const onnxResp = await fetch(onnxUrl); | |
| if (!onnxResp.ok) throw new Error(`Failed to fetch ${name}.onnx`); | |
| const onnxBuffer = await onnxResp.arrayBuffer(); | |
| // Fetch the large external data with progress | |
| setStep(stepLabel, `Downloading ${dataLabel}...`); | |
| const dataBuffer = await fetchWithProgress(dataUrl, dataLabel); | |
| // Compiling phase | |
| setStep(stepLabel, 'Compiling WebGPU shaders...'); | |
| loadingEta.textContent = 'Compiling shaders β this can take 30β60s, please wait...'; | |
| compilingPhase = true; | |
| const session = await ort.InferenceSession.create(onnxBuffer, { | |
| executionProviders: ['webgpu'], | |
| externalData: [{ path: dataLabel, data: dataBuffer.buffer }], | |
| }); | |
| compilingPhase = false; | |
| return session; | |
| } | |
| // Step index mapping | |
| const STEP_MAP = { | |
| 1: 'Step 1 / 4', | |
| 2: 'Step 2 / 4', | |
| 3: 'Step 3 / 4', | |
| 4: 'Step 4 / 4', | |
| }; | |
| function setStep(step, file, stepNum) { | |
| loadingStep.textContent = step; | |
| loadingFile.textContent = file; | |
| statusEl.textContent = `${step} β ${file}`; | |
| // Update step dots | |
| for (let i = 1; i <= 4; i++) { | |
| const dot = document.getElementById(`step-dot-${i}`); | |
| const lbl = document.getElementById(`step-lbl-${i}`); | |
| if (!dot) continue; | |
| if (stepNum && i < stepNum) { | |
| dot.className = 'lo-step-dot done'; | |
| lbl.className = 'lo-step-label done'; | |
| } else if (stepNum && i === stepNum) { | |
| dot.className = 'lo-step-dot active'; | |
| lbl.className = 'lo-step-label active'; | |
| } else { | |
| dot.className = 'lo-step-dot'; | |
| lbl.className = 'lo-step-label'; | |
| } | |
| } | |
| } | |
| // βββ CACHE CHECK βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| async function checkCache() { | |
| try { | |
| if ('storage' in navigator && 'estimate' in navigator.storage) { | |
| const est = await navigator.storage.estimate(); | |
| const usedMB = ((est.usage || 0) / 1024 / 1024).toFixed(0); | |
| const quotaGB = ((est.quota || 0) / 1024 / 1024 / 1024).toFixed(1); | |
| storageInfo.textContent = `${usedMB}MB used / ${quotaGB}GB available`; | |
| } | |
| } catch(e) {} | |
| } | |
| // βββ LOAD MODEL ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| async function loadModel() { | |
| loadBtn.disabled = true; | |
| loadBtn.textContent = 'Loading...'; | |
| progressEl.style.display = 'flex'; | |
| loadingOverlay.style.display = 'flex'; | |
| $('welcome').style.display = 'none'; | |
| downloadStart = Date.now(); | |
| try { | |
| ort.env.wasm.numThreads = 1; | |
| // Step 1: Tokenizer | |
| setStep('Step 1 / 4 β Tokenizer', 'Downloading config files...', 1); | |
| tokenizer = await AutoTokenizer.from_pretrained(MODEL_ID); | |
| setStep('Step 1 / 4 β Tokenizer', 'Done β', 1); | |
| // Step 2: Token embedder (~30MB) | |
| embedTokens = await loadOrtSessionWithProgress( | |
| 'embed_tokens_fp16', | |
| 'Step 2 / 4 β Token Embedder (~30 MB)', | |
| 2 | |
| ); | |
| // Step 3: Vision encoder (~400MB) | |
| embedImages = await loadOrtSessionWithProgress( | |
| 'embed_images_fp16', | |
| 'Step 3 / 4 β Vision Encoder (~400 MB)', | |
| 3 | |
| ); | |
| // Step 4: Decoder (~1.1GB) | |
| decoder = await loadOrtSessionWithProgress( | |
| 'decoder_q4', | |
| 'Step 4 / 4 β Language Decoder (~1.1 GB)', | |
| 4 | |
| ); | |
| // Done! Mark all steps done | |
| for (let i = 1; i <= 4; i++) { | |
| const dot = document.getElementById(`step-dot-${i}`); | |
| const lbl = document.getElementById(`step-lbl-${i}`); | |
| if (dot) { dot.className = 'lo-step-dot done'; lbl.className = 'lo-step-label done'; } | |
| } | |
| loadingBarFill.style.width = '100%'; | |
| loadingPct.textContent = '100%'; | |
| loadingEta.textContent = `Completed in ${((Date.now() - downloadStart) / 1000).toFixed(0)}s`; | |
| await new Promise(r => setTimeout(r, 600)); | |
| loadingOverlay.style.display = 'none'; | |
| progressEl.style.display = 'none'; | |
| isLoaded = true; | |
| inputEl.disabled = false; | |
| sendBtn.disabled = false; | |
| imageBtn.disabled = false; | |
| inputEl.placeholder = 'Ask anything... (optionally attach an image πΌ)'; | |
| loadBtn.style.display = 'none'; | |
| statusEl.textContent = 'Model ready β running fully on your device'; | |
| cacheIndicator.innerHTML = `<span class="dot cached"></span> Model running on-device`; | |
| cacheIndicator.classList.add('has-cache'); | |
| checkCache(); | |
| addSystemMessage('β LFM2.5-VL-1.6B loaded. Runs 100% in-browser via WebGPU. Attach an image or just chat!'); | |
| } catch(err) { | |
| console.error(err); | |
| loadingOverlay.style.display = 'none'; | |
| progressEl.style.display = 'none'; | |
| loadBtn.disabled = false; | |
| loadBtn.textContent = 'Retry Load'; | |
| statusEl.textContent = `Error: ${err.message.slice(0, 80)}`; | |
| if (err.message.includes('WebGPU') || err.message.includes('gpu')) { | |
| addSystemMessage('β οΈ WebGPU not supported. Please use Chrome 113+ or Edge 113+ and check chrome://flags/#enable-unsafe-webgpu'); | |
| } else { | |
| addSystemMessage(`β Error loading model: ${err.message}\n\nTry refreshing and loading again β large file downloads sometimes fail.`); | |
| } | |
| } | |
| } | |
| // βββ IMAGE HANDLING βββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| imageBtn.addEventListener('click', () => imageInput.click()); | |
| imageInput.addEventListener('change', async (e) => { | |
| const file = e.target.files[0]; | |
| if (!file) return; | |
| const reader = new FileReader(); | |
| reader.onload = async (ev) => { | |
| currentImageData = ev.target.result; | |
| imagePreviewImg.src = currentImageData; | |
| imagePreview.style.display = 'flex'; | |
| }; | |
| reader.readAsDataURL(file); | |
| imageInput.value = ''; | |
| }); | |
| removeImageBtn.addEventListener('click', () => { | |
| currentImageData = null; | |
| currentImagePixels = null; | |
| imagePreview.style.display = 'none'; | |
| imagePreviewImg.src = ''; | |
| }); | |
| // ββ IMAGE PROCESSING ββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| // SigLIP2 NaFlex expects patches of 16x16 pixels from tiles of up to 512x512. | |
| // embed_images_fp16 input shape: | |
| // pixel_values: [total_patches, 3, 16, 16] (rank 4 β one entry per patch) | |
| // pixel_attention_mask:[total_patches, seq_per_patch] where seq_per_patch = (512/16)^2 = 1024... | |
| // spatial_shapes: [num_tiles, 2] each row = [nPatchH, nPatchW] for that tile | |
| // We use a single tile resized to β€512x512, snapped to multiples of 16. | |
| const PATCH_SIZE = 16; // pixel patch size | |
| const MAX_TILE = 512; // max tile dimension | |
| async function processImage(dataUrl) { | |
| return new Promise((resolve, reject) => { | |
| const img = new Image(); | |
| img.onload = () => { | |
| // Resize to fit inside MAX_TILE x MAX_TILE preserving aspect ratio | |
| let w = img.width, h = img.height; | |
| if (w > MAX_TILE || h > MAX_TILE) { | |
| if (w >= h) { h = Math.round(h * MAX_TILE / w); w = MAX_TILE; } | |
| else { w = Math.round(w * MAX_TILE / h); h = MAX_TILE; } | |
| } | |
| // Snap to nearest multiple of PATCH_SIZE | |
| w = Math.max(PATCH_SIZE, Math.round(w / PATCH_SIZE) * PATCH_SIZE); | |
| h = Math.max(PATCH_SIZE, Math.round(h / PATCH_SIZE) * PATCH_SIZE); | |
| const canvas = document.createElement('canvas'); | |
| canvas.width = w; canvas.height = h; | |
| const ctx = canvas.getContext('2d'); | |
| ctx.drawImage(img, 0, 0, w, h); | |
| const rgba = ctx.getImageData(0, 0, w, h).data; | |
| // Number of patches in each dimension for this single tile | |
| const nPatchH = h / PATCH_SIZE; // rows of patches | |
| const nPatchW = w / PATCH_SIZE; // cols of patches | |
| const totalPatches = nPatchH * nPatchW; | |
| // Build pixel_values: [totalPatches, 3, PATCH_SIZE, PATCH_SIZE] | |
| // Normalise: (x/255 - 0.5) / 0.5 (SigLIP2 mean=0.5, std=0.5) | |
| const patchElems = 3 * PATCH_SIZE * PATCH_SIZE; | |
| const pvData = new Float32Array(totalPatches * patchElems); | |
| for (let pr = 0; pr < nPatchH; pr++) { | |
| for (let pc = 0; pc < nPatchW; pc++) { | |
| const patchIdx = pr * nPatchW + pc; | |
| for (let py = 0; py < PATCH_SIZE; py++) { | |
| for (let px = 0; px < PATCH_SIZE; px++) { | |
| const imgY = pr * PATCH_SIZE + py; | |
| const imgX = pc * PATCH_SIZE + px; | |
| const pixOff = (imgY * w + imgX) * 4; // RGBA offset in imageData | |
| const base = patchIdx * patchElems; | |
| // channel-first: [3, PATCH_SIZE, PATCH_SIZE] | |
| pvData[base + 0 * PATCH_SIZE * PATCH_SIZE + py * PATCH_SIZE + px] = (rgba[pixOff + 0] / 255 - 0.5) / 0.5; | |
| pvData[base + 1 * PATCH_SIZE * PATCH_SIZE + py * PATCH_SIZE + px] = (rgba[pixOff + 1] / 255 - 0.5) / 0.5; | |
| pvData[base + 2 * PATCH_SIZE * PATCH_SIZE + py * PATCH_SIZE + px] = (rgba[pixOff + 2] / 255 - 0.5) / 0.5; | |
| } | |
| } | |
| } | |
| } | |
| // pixel_attention_mask: [totalPatches, nPatchH * nPatchW] β all ones (all patches valid) | |
| // Each patch attends to all other patches within the same tile | |
| const seqPerPatch = nPatchH * nPatchW; | |
| const pamData = new BigInt64Array(totalPatches * seqPerPatch).fill(1n); | |
| // spatial_shapes: [1, 2] β one tile with shape [nPatchH, nPatchW] | |
| const ssData = new BigInt64Array([BigInt(nPatchH), BigInt(nPatchW)]); | |
| const pixelValues = new ort.Tensor('float32', pvData, [totalPatches, 3, PATCH_SIZE, PATCH_SIZE]); | |
| const pixelAttentionMask = new ort.Tensor('int64', pamData, [totalPatches, seqPerPatch]); | |
| const spatialShapes = new ort.Tensor('int64', ssData, [1, 2]); | |
| resolve({ pixelValues, pixelAttentionMask, spatialShapes, nPatchH, nPatchW, totalPatches }); | |
| }; | |
| img.onerror = reject; | |
| img.src = dataUrl; | |
| }); | |
| } | |
| // Look up the integer token ID for a special token string by scanning the vocab | |
| function findTokenId(tokStr) { | |
| // Transformers.js tokenizer exposes vocab via .vocab or ._tokenizer.model.vocab | |
| try { | |
| const vocab = tokenizer.vocab || tokenizer._tokenizer?.model?.vocab; | |
| if (vocab && vocab[tokStr] !== undefined) return vocab[tokStr]; | |
| } catch(e) {} | |
| // Fallback: encode the bare string and take the first token | |
| try { | |
| const ids = tokenizer.encode(tokStr, { add_special_tokens: false }); | |
| if (ids && ids.length > 0) return ids[0]; | |
| } catch(e) {} | |
| return null; | |
| } | |
| // βββ HELPERS βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| async function getTextEmbeddings(ids) { | |
| const tensor = new ort.Tensor('int64', | |
| new BigInt64Array(ids.map(BigInt)), [1, ids.length]); | |
| const out = await embedTokens.run({ input_ids: tensor }); | |
| return out.inputs_embeds; | |
| } | |
| function initCache() { | |
| const cache = {}; | |
| for (const name of decoder.inputNames) { | |
| if (name.startsWith('past_conv')) { | |
| cache[name] = new ort.Tensor('float32', | |
| new Float32Array(HIDDEN_SIZE * 3), [1, HIDDEN_SIZE, 3]); | |
| } else if (name.startsWith('past_key_values')) { | |
| cache[name] = new ort.Tensor('float32', | |
| new Float32Array(0), [1, NUM_KV_HEADS, 0, HEAD_DIM]); | |
| } | |
| } | |
| return cache; | |
| } | |
| function updateCache(cache, outputs) { | |
| for (const [name, tensor] of Object.entries(outputs)) { | |
| if (name === 'logits') continue; | |
| if (name.startsWith('present_conv')) { | |
| cache[name.replace('present_conv', 'past_conv')] = tensor; | |
| } else if (name.startsWith('present.')) { | |
| cache[name.replace('present.', 'past_key_values.')] = tensor; | |
| } | |
| } | |
| } | |
| // βββ MESSAGES βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| function addSystemMessage(text) { | |
| const div = document.createElement('div'); | |
| div.className = 'msg system-msg'; | |
| div.textContent = text; | |
| chatContainer.appendChild(div); | |
| chatContainer.scrollTop = chatContainer.scrollHeight; | |
| } | |
| function addMessage(role, content, imageDataUrl = null) { | |
| const div = document.createElement('div'); | |
| div.className = `msg ${role}-msg`; | |
| const label = document.createElement('span'); | |
| label.className = 'msg-label'; | |
| label.textContent = role === 'user' ? 'YOU' : 'AI'; | |
| const contentWrapper = document.createElement('div'); | |
| contentWrapper.className = 'msg-content-wrapper'; | |
| // Show attached image thumbnail in user message | |
| if (imageDataUrl && role === 'user') { | |
| const imgEl = document.createElement('img'); | |
| imgEl.src = imageDataUrl; | |
| imgEl.className = 'msg-image'; | |
| contentWrapper.appendChild(imgEl); | |
| } | |
| const thinkingEl = document.createElement('div'); | |
| thinkingEl.className = 'thinking-indicator'; | |
| const thinkingLabel = document.createElement('span'); | |
| thinkingLabel.className = 'thinking-label'; | |
| thinkingLabel.textContent = 'Generating'; | |
| const dotsWrap = document.createElement('div'); | |
| dotsWrap.className = 'thinking-dots'; | |
| for (let i = 0; i < 3; i++) { | |
| const dot = document.createElement('span'); | |
| dot.className = 'thinking-dot'; | |
| dotsWrap.appendChild(dot); | |
| } | |
| thinkingEl.appendChild(thinkingLabel); | |
| thinkingEl.appendChild(dotsWrap); | |
| thinkingEl.style.display = role === 'assistant' ? 'flex' : 'none'; | |
| const text = document.createElement('p'); | |
| text.className = 'msg-text'; | |
| text.textContent = content; | |
| contentWrapper.appendChild(thinkingEl); | |
| contentWrapper.appendChild(text); | |
| div.appendChild(label); | |
| div.appendChild(contentWrapper); | |
| chatContainer.appendChild(div); | |
| chatContainer.scrollTop = chatContainer.scrollHeight; | |
| return { text, thinkingEl }; | |
| } | |
| // βββ SEND βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| async function sendMessage() { | |
| if (!isLoaded || isGenerating) return; | |
| const userText = inputEl.value.trim(); | |
| if (!userText && !currentImageData) return; | |
| const attachedImage = currentImageData; | |
| currentImageData = null; | |
| imagePreview.style.display = 'none'; | |
| imagePreviewImg.src = ''; | |
| inputEl.value = ''; | |
| inputEl.style.height = 'auto'; | |
| sendBtn.disabled = true; | |
| inputEl.disabled = true; | |
| imageBtn.disabled = true; | |
| isGenerating = true; | |
| // Display user message | |
| addMessage('user', userText || '(image attached)', attachedImage); | |
| const aiElements = addMessage('assistant', ''); | |
| const { text, thinkingEl } = aiElements; | |
| try { | |
| // Build prompt using ChatML format used by LFM2.5-VL | |
| const systemPrompt = 'You are a helpful multimodal assistant by Liquid AI.'; | |
| let promptText; | |
| if (attachedImage) { | |
| promptText = `<|startoftext|><|im_start|>system\n${systemPrompt}<|im_end|>\n<|im_start|>user\n<image>${userText || 'Describe this image.'}<|im_end|>\n<|im_start|>assistant\n`; | |
| } else { | |
| // Build multi-turn text conversation | |
| let history = `<|startoftext|><|im_start|>system\n${systemPrompt}<|im_end|>\n`; | |
| for (const turn of chatHistory) { | |
| history += `<|im_start|>${turn.role}\n${turn.content}<|im_end|>\n`; | |
| } | |
| history += `<|im_start|>user\n${userText}<|im_end|>\n<|im_start|>assistant\n`; | |
| promptText = history; | |
| } | |
| // Tokenize | |
| const inputIds = tokenizer.encode(promptText, { add_special_tokens: false }); | |
| let inputsEmbeds = await getTextEmbeddings(inputIds); | |
| // If image attached, process and inject image embeddings | |
| if (attachedImage) { | |
| statusEl.textContent = 'Processing image...'; | |
| const imgData = await processImage(attachedImage); | |
| // Get image embeddings from vision encoder | |
| // Output is image_features: [total_image_tokens, hidden_size] | |
| const imgOut = await embedImages.run({ | |
| pixel_values: imgData.pixelValues, | |
| pixel_attention_mask: imgData.pixelAttentionMask, | |
| spatial_shapes: imgData.spatialShapes, | |
| }); | |
| // The output key may vary β grab the first output tensor | |
| const imageEmbeds = imgOut.image_features | |
| || imgOut.outputs | |
| || imgOut[Object.keys(imgOut)[0]]; | |
| console.log('Image embed output keys:', Object.keys(imgOut)); | |
| console.log('Image embed shape:', imageEmbeds.dims); | |
| // Find <image> token positions in the token sequence. | |
| // In Transformers.js, use findTokenId() helper β no .convert_tokens_to_ids() | |
| const imageTokenId = findTokenId('<image>'); | |
| console.log('Image token ID:', imageTokenId); | |
| const ids = Array.from(inputIds); | |
| // Count how many image positions we have | |
| const imagePositions = ids.reduce((acc, id, i) => { | |
| if (id === imageTokenId) acc.push(i); | |
| return acc; | |
| }, []); | |
| console.log('Image token positions:', imagePositions.length, 'image embeds:', imageEmbeds.dims[0]); | |
| // The vision encoder returns one embedding vector per image token slot. | |
| // We replace each <image> token embedding with the corresponding image embed. | |
| // If there are more image embed vectors than <image> tokens, we expand: | |
| // the single <image> token placeholder is replaced by ALL image embed vectors. | |
| const embedDim = inputsEmbeds.dims[2]; | |
| const numImgVecs = imageEmbeds.dims[0]; // actual number of image feature vectors | |
| if (imagePositions.length === 0) { | |
| // No <image> placeholder in tokenised text β just prepend image embeds | |
| const totalLen = numImgVecs + inputsEmbeds.dims[1]; | |
| const mergedData = new Float32Array(totalLen * embedDim); | |
| mergedData.set(new Float32Array(imageEmbeds.data.buffer, imageEmbeds.data.byteOffset, numImgVecs * embedDim), 0); | |
| mergedData.set(new Float32Array(inputsEmbeds.data.buffer, inputsEmbeds.data.byteOffset, inputsEmbeds.dims[1] * embedDim), numImgVecs * embedDim); | |
| inputsEmbeds = new ort.Tensor('float32', mergedData, [1, totalLen, embedDim]); | |
| } else { | |
| // Replace <image> token(s) with image embed vectors (expanding 1βN if needed) | |
| const numReplace = imagePositions.length; // usually 1 | |
| const expandPer = Math.ceil(numImgVecs / numReplace); | |
| const totalLen = inputsEmbeds.dims[1] - numReplace + numImgVecs; | |
| const mergedData = new Float32Array(totalLen * embedDim); | |
| const imgEmbeds32 = new Float32Array(imageEmbeds.data.buffer ?? imageEmbeds.data, 0, numImgVecs * embedDim); | |
| const txtData32 = new Float32Array(inputsEmbeds.data.buffer ?? inputsEmbeds.data, 0, inputsEmbeds.dims[1] * embedDim); | |
| let dst = 0; | |
| let imgCursor = 0; | |
| const imgPosSet = new Set(imagePositions); | |
| for (let i = 0; i < ids.length; i++) { | |
| if (imgPosSet.has(i)) { | |
| // Insert all remaining image embed vectors at first <image> token, skip rest | |
| if (imgCursor < numImgVecs) { | |
| const toCopy = (i === imagePositions[0]) ? numImgVecs : 0; | |
| mergedData.set(imgEmbeds32.subarray(0, toCopy * embedDim), dst * embedDim); | |
| dst += toCopy; | |
| imgCursor = numImgVecs; | |
| } | |
| } else { | |
| mergedData.set(txtData32.subarray(i * embedDim, (i + 1) * embedDim), dst * embedDim); | |
| dst++; | |
| } | |
| } | |
| inputsEmbeds = new ort.Tensor('float32', mergedData, [1, dst, embedDim]); | |
| } | |
| statusEl.textContent = 'Generating response...'; | |
| } else { | |
| statusEl.textContent = 'Generating response...'; | |
| } | |
| thinkingEl.style.display = 'flex'; | |
| // Generation loop | |
| const cache = initCache(); | |
| const eosId = tokenizer.eos_token_id; | |
| const imEndId = findTokenId('<|im_end|>'); | |
| const generatedTokens = []; | |
| let curLen = inputsEmbeds.dims[1]; | |
| let embeds = inputsEmbeds; | |
| let responseText = ''; | |
| for (let step = 0; step < MAX_NEW_TOKENS; step++) { | |
| const attentionMask = new ort.Tensor('int64', | |
| new BigInt64Array(curLen).fill(1n), [1, curLen]); | |
| const outputs = await decoder.run({ | |
| inputs_embeds: embeds, | |
| attention_mask: attentionMask, | |
| ...cache | |
| }); | |
| const logits = outputs.logits; | |
| const vocabSize = logits.dims[2]; | |
| const lastLogitsData = logits.data.slice((logits.dims[1] - 1) * vocabSize); | |
| let maxVal = -Infinity, nextToken = 0; | |
| for (let i = 0; i < vocabSize; i++) { | |
| if (lastLogitsData[i] > maxVal) { maxVal = lastLogitsData[i]; nextToken = i; } | |
| } | |
| generatedTokens.push(nextToken); | |
| // Decode incrementally | |
| if (step === 0 || step % 3 === 0) { | |
| responseText = tokenizer.decode(generatedTokens, { skip_special_tokens: true }); | |
| text.textContent = responseText || '...'; | |
| chatContainer.scrollTop = chatContainer.scrollHeight; | |
| } | |
| if (nextToken === eosId || nextToken === imEndId) break; | |
| updateCache(cache, outputs); | |
| embeds = await getTextEmbeddings([nextToken]); | |
| curLen++; | |
| } | |
| // Final decode | |
| responseText = tokenizer.decode(generatedTokens, { skip_special_tokens: true }); | |
| text.textContent = responseText; | |
| thinkingEl.style.display = 'none'; | |
| // Add to history (text only) | |
| if (!attachedImage) { | |
| chatHistory.push({ role: 'user', content: userText }); | |
| chatHistory.push({ role: 'assistant', content: responseText }); | |
| // Keep last 10 turns to avoid context overflow | |
| if (chatHistory.length > 20) chatHistory = chatHistory.slice(-20); | |
| } else { | |
| // Reset history after vision query (context changes) | |
| chatHistory = [{ role: 'assistant', content: responseText }]; | |
| } | |
| statusEl.textContent = 'Model ready β running fully on your device'; | |
| } catch(err) { | |
| console.error(err); | |
| thinkingEl.style.display = 'none'; | |
| text.textContent = `Error: ${err.message}`; | |
| statusEl.textContent = 'Error during generation'; | |
| } | |
| isGenerating = false; | |
| sendBtn.disabled = false; | |
| inputEl.disabled = false; | |
| imageBtn.disabled = false; | |
| inputEl.focus(); | |
| } | |
| // βββ EVENTS βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| loadBtn.addEventListener('click', loadModel); | |
| sendBtn.addEventListener('click', sendMessage); | |
| inputEl.addEventListener('keydown', e => { | |
| if (e.key === 'Enter' && !e.shiftKey) { | |
| e.preventDefault(); | |
| sendMessage(); | |
| } | |
| }); | |
| $('clear-btn').addEventListener('click', () => { | |
| chatHistory = []; | |
| chatContainer.innerHTML = ''; | |
| currentImageData = null; | |
| imagePreview.style.display = 'none'; | |
| addSystemMessage('Conversation cleared. Model still loaded.'); | |
| }); | |
| // βββ INIT βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| window.addEventListener('load', () => { | |
| checkCache(); | |
| if (!navigator.gpu) { | |
| statusEl.textContent = 'β οΈ WebGPU required β use Chrome 113+ / Edge 113+ with WebGPU enabled'; | |
| loadBtn.disabled = true; | |
| loadBtn.title = 'WebGPU not available in this browser'; | |
| } | |
| }); | |
| </script> | |
| <style> | |
| @import url('https://fonts.googleapis.com/css2?family=DM+Mono:wght@300;400;500&family=Bebas+Neue&family=DM+Sans:ital,wght@0,300;0,400;0,500;1,300&display=swap'); | |
| :root { | |
| --bg: #080808; | |
| --surface: #0f0f0f; | |
| --surface2: #141414; | |
| --border: #1e1e1e; | |
| --border2: #2a2a2a; | |
| --accent: #c8ff00; | |
| --accent2: #00ffc8; | |
| --text: #ddd; | |
| --muted: #444; | |
| --ai-border: #2a3a0a; | |
| --ai-bg: #0d1205; | |
| } | |
| * { box-sizing: border-box; margin: 0; padding: 0; } | |
| body { | |
| background: var(--bg); | |
| color: var(--text); | |
| font-family: 'DM Sans', sans-serif; | |
| font-weight: 300; | |
| height: 100dvh; | |
| display: flex; | |
| flex-direction: column; | |
| overflow: hidden; | |
| } | |
| /* HEADER */ | |
| header { | |
| padding: 14px 20px; | |
| border-bottom: 1px solid var(--border); | |
| display: flex; | |
| align-items: center; | |
| justify-content: space-between; | |
| background: var(--surface); | |
| flex-shrink: 0; | |
| } | |
| .logo { display: flex; align-items: baseline; gap: 10px; } | |
| .logo-text { | |
| font-family: 'Bebas Neue', sans-serif; | |
| font-size: 26px; | |
| letter-spacing: 4px; | |
| color: var(--accent); | |
| line-height: 1; | |
| } | |
| .logo-badge { | |
| font-family: 'DM Mono', monospace; | |
| font-size: 9px; | |
| color: #000; | |
| background: var(--accent); | |
| padding: 2px 6px; | |
| letter-spacing: 1px; | |
| } | |
| .header-right { display: flex; flex-direction: column; align-items: flex-end; gap: 3px; } | |
| #cache-indicator { | |
| font-family: 'DM Mono', monospace; | |
| font-size: 10px; | |
| color: var(--muted); | |
| display: flex; | |
| align-items: center; | |
| gap: 6px; | |
| transition: color 0.4s; | |
| } | |
| #cache-indicator.has-cache { color: var(--accent); } | |
| .dot { | |
| width: 5px; height: 5px; border-radius: 50%; | |
| background: var(--muted); display: inline-block; | |
| animation: blink 2s infinite; | |
| } | |
| .dot.cached { background: var(--accent); animation: none; } | |
| @keyframes blink { 0%,100%{opacity:1} 50%{opacity:0.2} } | |
| #storage-info { | |
| font-family: 'DM Mono', monospace; | |
| font-size: 9px; | |
| color: #2a2a2a; | |
| } | |
| /* STATUS BAR */ | |
| .status-bar { | |
| padding: 7px 20px; | |
| background: var(--surface2); | |
| border-bottom: 1px solid var(--border); | |
| display: flex; | |
| align-items: center; | |
| gap: 14px; | |
| flex-shrink: 0; | |
| min-height: 40px; | |
| } | |
| #status { | |
| font-family: 'DM Mono', monospace; | |
| font-size: 10px; | |
| color: var(--muted); | |
| flex: 1; | |
| white-space: nowrap; | |
| overflow: hidden; | |
| text-overflow: ellipsis; | |
| } | |
| #progress { display: none; align-items: center; gap: 10px; width: 260px; flex-shrink: 0; } | |
| .progress-track { | |
| flex: 1; height: 2px; | |
| background: var(--border2); | |
| border-radius: 1px; overflow: hidden; | |
| } | |
| #progress-bar { | |
| height: 100%; background: var(--accent); width: 0%; | |
| transition: width 0.4s ease; | |
| box-shadow: 0 0 6px var(--accent); | |
| } | |
| #progress-text { | |
| font-family: 'DM Mono', monospace; | |
| font-size: 9px; | |
| color: var(--accent); | |
| width: 50px; text-align: right; | |
| white-space: nowrap; overflow: hidden; text-overflow: ellipsis; | |
| } | |
| .model-tag { | |
| font-family: 'DM Mono', monospace; | |
| font-size: 9px; | |
| color: #2e2e2e; | |
| border: 1px solid #1a1a1a; | |
| padding: 3px 8px; | |
| flex-shrink: 0; | |
| } | |
| #load-btn { | |
| font-family: 'Bebas Neue', sans-serif; | |
| font-size: 14px; | |
| letter-spacing: 2px; | |
| background: var(--accent); | |
| color: #000; | |
| border: none; | |
| padding: 7px 18px; | |
| cursor: pointer; | |
| transition: all 0.15s; | |
| flex-shrink: 0; | |
| } | |
| #load-btn:hover:not(:disabled) { | |
| background: var(--accent2); | |
| box-shadow: 0 0 18px rgba(200,255,0,0.25); | |
| } | |
| #load-btn:disabled { opacity: 0.3; cursor: not-allowed; } | |
| /* CHAT */ | |
| #chat-container { | |
| flex: 1; | |
| overflow-y: auto; | |
| padding: 20px; | |
| display: flex; | |
| flex-direction: column; | |
| gap: 14px; | |
| position: relative; | |
| } | |
| #chat-container::-webkit-scrollbar { width: 3px; } | |
| #chat-container::-webkit-scrollbar-thumb { background: #1e1e1e; } | |
| /* WELCOME */ | |
| #welcome { | |
| position: absolute; | |
| inset: 0; | |
| display: flex; | |
| flex-direction: column; | |
| align-items: center; | |
| justify-content: center; | |
| gap: 24px; | |
| pointer-events: none; | |
| user-select: none; | |
| } | |
| .welcome-title { | |
| font-family: 'Bebas Neue', sans-serif; | |
| font-size: clamp(48px, 8vw, 80px); | |
| letter-spacing: 8px; | |
| color: #141414; | |
| line-height: 1; | |
| text-align: center; | |
| } | |
| .welcome-features { | |
| display: flex; | |
| gap: 0; | |
| border: 1px solid #141414; | |
| } | |
| .wf { | |
| padding: 8px 16px; | |
| border-right: 1px solid #141414; | |
| text-align: center; | |
| } | |
| .wf:last-child { border-right: none; } | |
| .wf-icon { font-size: 16px; margin-bottom: 4px; opacity: 0.3; } | |
| .wf-text { | |
| font-family: 'DM Mono', monospace; | |
| font-size: 9px; | |
| color: #1e1e1e; | |
| letter-spacing: 1px; | |
| text-transform: uppercase; | |
| line-height: 1.6; | |
| } | |
| /* MESSAGES */ | |
| .msg { max-width: 680px; animation: fadeUp 0.2s ease; } | |
| @keyframes fadeUp { | |
| from { opacity: 0; transform: translateY(6px); } | |
| to { opacity: 1; transform: translateY(0); } | |
| } | |
| .system-msg { | |
| font-family: 'DM Mono', monospace; | |
| font-size: 10px; | |
| color: #2a2a2a; | |
| align-self: center; | |
| text-align: center; | |
| max-width: 100%; | |
| padding: 4px 0; | |
| } | |
| .user-msg { | |
| align-self: flex-end; | |
| background: #131313; | |
| border: 1px solid var(--border2); | |
| border-radius: 1px 1px 0 1px; | |
| padding: 12px 16px; | |
| } | |
| .assistant-msg { | |
| align-self: flex-start; | |
| background: var(--ai-bg); | |
| border: 1px solid var(--ai-border); | |
| border-left: 2px solid var(--accent); | |
| border-radius: 1px 1px 1px 0; | |
| padding: 12px 16px; | |
| } | |
| .msg-label { | |
| font-family: 'DM Mono', monospace; | |
| font-size: 8px; | |
| letter-spacing: 2px; | |
| color: var(--muted); | |
| display: block; | |
| margin-bottom: 6px; | |
| } | |
| .assistant-msg .msg-label { color: var(--accent); opacity: 0.5; } | |
| .msg p { | |
| font-size: 13.5px; | |
| line-height: 1.75; | |
| font-weight: 300; | |
| white-space: pre-wrap; | |
| word-break: break-word; | |
| } | |
| .msg-content-wrapper { | |
| display: flex; | |
| flex-direction: column; | |
| gap: 8px; | |
| } | |
| /* Attached image in user message */ | |
| .msg-image { | |
| max-width: 240px; | |
| max-height: 200px; | |
| object-fit: contain; | |
| border: 1px solid var(--border2); | |
| border-radius: 2px; | |
| display: block; | |
| } | |
| /* THINKING ANIMATION */ | |
| .thinking-indicator { | |
| display: none; | |
| align-items: center; | |
| gap: 8px; | |
| padding: 2px 0 4px; | |
| } | |
| .thinking-label { | |
| font-family: 'DM Mono', monospace; | |
| font-size: 9px; | |
| letter-spacing: 1px; | |
| text-transform: uppercase; | |
| color: var(--accent); | |
| opacity: 0.6; | |
| } | |
| .thinking-dots { display: flex; gap: 4px; align-items: center; } | |
| .thinking-dot { | |
| width: 5px; | |
| height: 5px; | |
| background: var(--accent); | |
| border-radius: 50%; | |
| animation: thinkBounce 1.3s ease-in-out infinite both; | |
| } | |
| .thinking-dot:nth-child(1) { animation-delay: 0s; } | |
| .thinking-dot:nth-child(2) { animation-delay: 0.18s; } | |
| .thinking-dot:nth-child(3) { animation-delay: 0.36s; } | |
| @keyframes thinkBounce { | |
| 0%, 80%, 100% { transform: scale(0.55); opacity: 0.25; } | |
| 40% { transform: scale(1); opacity: 1; } | |
| } | |
| /* INPUT */ | |
| .input-area { | |
| border-top: 1px solid var(--border); | |
| padding: 14px 20px; | |
| background: var(--surface); | |
| display: flex; | |
| flex-direction: column; | |
| gap: 8px; | |
| flex-shrink: 0; | |
| } | |
| .input-row { | |
| display: flex; | |
| gap: 10px; | |
| align-items: flex-end; | |
| } | |
| /* Image preview strip above input */ | |
| #image-preview { | |
| display: none; | |
| align-items: center; | |
| gap: 8px; | |
| padding: 6px 8px; | |
| background: var(--surface2); | |
| border: 1px solid var(--border2); | |
| border-radius: 2px; | |
| width: fit-content; | |
| } | |
| #image-preview-img { | |
| width: 48px; | |
| height: 48px; | |
| object-fit: cover; | |
| border: 1px solid var(--border2); | |
| border-radius: 1px; | |
| } | |
| #remove-image-btn { | |
| background: transparent; | |
| border: none; | |
| color: var(--muted); | |
| font-size: 16px; | |
| cursor: pointer; | |
| line-height: 1; | |
| padding: 2px 4px; | |
| transition: color 0.15s; | |
| } | |
| #remove-image-btn:hover { color: #ff4444; } | |
| /* Image attach button */ | |
| #image-btn { | |
| font-family: 'DM Mono', monospace; | |
| font-size: 16px; | |
| background: transparent; | |
| color: var(--muted); | |
| border: 1px solid var(--border2); | |
| padding: 9px 11px; | |
| cursor: pointer; | |
| height: 42px; | |
| transition: all 0.15s; | |
| flex-shrink: 0; | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| line-height: 1; | |
| } | |
| #image-btn:hover:not(:disabled) { | |
| border-color: var(--accent2); | |
| color: var(--accent2); | |
| } | |
| #image-btn:disabled { opacity: 0.15; cursor: not-allowed; } | |
| #user-input { | |
| flex: 1; | |
| background: var(--surface2); | |
| border: 1px solid var(--border2); | |
| color: var(--text); | |
| font-family: 'DM Sans', sans-serif; | |
| font-size: 13.5px; | |
| font-weight: 300; | |
| padding: 11px 14px; | |
| resize: none; | |
| outline: none; | |
| transition: border-color 0.2s; | |
| min-height: 42px; | |
| max-height: 120px; | |
| border-radius: 0; | |
| line-height: 1.5; | |
| } | |
| #user-input:focus { border-color: var(--accent); } | |
| #user-input:disabled { opacity: 0.25; } | |
| #user-input::placeholder { color: #2a2a2a; } | |
| #send-btn { | |
| font-family: 'Bebas Neue', sans-serif; | |
| font-size: 14px; | |
| letter-spacing: 2px; | |
| background: transparent; | |
| color: var(--accent); | |
| border: 1px solid var(--accent); | |
| padding: 9px 18px; | |
| cursor: pointer; | |
| transition: all 0.15s; | |
| height: 42px; | |
| flex-shrink: 0; | |
| } | |
| #send-btn:hover:not(:disabled) { | |
| background: var(--accent); | |
| color: #000; | |
| box-shadow: 0 0 14px rgba(200,255,0,0.15); | |
| } | |
| #send-btn:disabled { opacity: 0.15; cursor: not-allowed; } | |
| #clear-btn { | |
| font-family: 'DM Mono', monospace; | |
| font-size: 9px; | |
| letter-spacing: 1px; | |
| background: transparent; | |
| color: var(--muted); | |
| border: 1px solid var(--border2); | |
| padding: 9px 12px; | |
| cursor: pointer; | |
| height: 42px; | |
| transition: all 0.15s; | |
| flex-shrink: 0; | |
| text-transform: uppercase; | |
| } | |
| #clear-btn:hover { border-color: #444; color: #888; } | |
| /* SPECS BAR */ | |
| .specs { | |
| display: flex; | |
| border-top: 1px solid var(--border); | |
| flex-shrink: 0; | |
| overflow-x: auto; | |
| } | |
| .spec-item { | |
| flex: 1; | |
| padding: 7px 14px; | |
| border-right: 1px solid var(--border); | |
| min-width: 90px; | |
| } | |
| .spec-item:last-child { border-right: none; } | |
| .spec-label { | |
| font-family: 'DM Mono', monospace; | |
| font-size: 8px; | |
| color: #222; | |
| letter-spacing: 1px; | |
| text-transform: uppercase; | |
| margin-bottom: 2px; | |
| } | |
| .spec-val { | |
| font-family: 'DM Mono', monospace; | |
| font-size: 10px; | |
| color: #333; | |
| } | |
| .spec-val.green { color: #5a8a00; } | |
| /* LOADING OVERLAY */ | |
| #loading-overlay { | |
| display: none; | |
| position: fixed; | |
| inset: 0; | |
| z-index: 100; | |
| background: rgba(8,8,8,0.97); | |
| flex-direction: column; | |
| align-items: center; | |
| justify-content: center; | |
| backdrop-filter: blur(4px); | |
| } | |
| .lo-inner { | |
| width: min(540px, 90vw); | |
| display: flex; | |
| flex-direction: column; | |
| gap: 28px; | |
| } | |
| .lo-title { | |
| font-family: 'Bebas Neue', sans-serif; | |
| font-size: 11px; | |
| letter-spacing: 4px; | |
| color: var(--accent); | |
| opacity: 0.6; | |
| margin-bottom: 4px; | |
| } | |
| #loading-step { | |
| font-family: 'DM Mono', monospace; | |
| font-size: 13px; | |
| color: var(--text); | |
| } | |
| .lo-bar-wrap { | |
| height: 6px; | |
| background: #1a1a1a; | |
| border-radius: 3px; | |
| overflow: visible; | |
| position: relative; | |
| } | |
| #loading-bar-fill { | |
| height: 100%; | |
| background: var(--accent); | |
| border-radius: 3px; | |
| width: 0%; | |
| transition: width 0.5s ease; | |
| box-shadow: 0 0 12px var(--accent); | |
| position: relative; | |
| } | |
| #loading-bar-fill::after { | |
| content: ''; | |
| position: absolute; | |
| right: -1px; top: -3px; | |
| width: 12px; height: 12px; | |
| background: var(--accent); | |
| border-radius: 50%; | |
| box-shadow: 0 0 10px var(--accent), 0 0 20px var(--accent); | |
| } | |
| .lo-stats { | |
| display: flex; | |
| justify-content: space-between; | |
| align-items: baseline; | |
| } | |
| #loading-pct { | |
| font-family: 'Bebas Neue', sans-serif; | |
| font-size: 52px; | |
| letter-spacing: 2px; | |
| color: var(--accent); | |
| line-height: 1; | |
| } | |
| .lo-right { | |
| display: flex; | |
| flex-direction: column; | |
| align-items: flex-end; | |
| gap: 5px; | |
| } | |
| #loading-bytes { | |
| font-family: 'DM Mono', monospace; | |
| font-size: 11px; | |
| color: #555; | |
| } | |
| #loading-eta { | |
| font-family: 'DM Mono', monospace; | |
| font-size: 10px; | |
| color: var(--muted); | |
| text-align: right; | |
| max-width: 300px; | |
| } | |
| .lo-steps { | |
| display: flex; | |
| flex-direction: column; | |
| border: 1px solid var(--border); | |
| } | |
| .lo-step-row { | |
| display: flex; | |
| align-items: center; | |
| gap: 12px; | |
| padding: 9px 14px; | |
| border-bottom: 1px solid var(--border); | |
| } | |
| .lo-step-row:last-child { border-bottom: none; } | |
| .lo-step-dot { | |
| width: 6px; height: 6px; | |
| border-radius: 50%; | |
| background: #1e1e1e; | |
| flex-shrink: 0; | |
| transition: background 0.3s; | |
| } | |
| .lo-step-dot.active { | |
| background: var(--accent); | |
| box-shadow: 0 0 8px var(--accent); | |
| animation: thinkBounce 1s infinite; | |
| } | |
| .lo-step-dot.done { background: #3a6a00; animation: none; } | |
| .lo-step-label { | |
| font-family: 'DM Mono', monospace; | |
| font-size: 10px; | |
| color: #2a2a2a; | |
| transition: color 0.3s; | |
| flex: 1; | |
| } | |
| .lo-step-label.active { color: var(--text); } | |
| .lo-step-label.done { color: #3a6a00; } | |
| .lo-step-size { | |
| font-family: 'DM Mono', monospace; | |
| font-size: 9px; | |
| color: #1e1e1e; | |
| } | |
| #loading-file { | |
| font-family: 'DM Mono', monospace; | |
| font-size: 9px; | |
| color: #2a2a2a; | |
| white-space: nowrap; | |
| overflow: hidden; | |
| text-overflow: ellipsis; | |
| max-width: 100%; | |
| margin-top: 4px; | |
| } | |
| .lo-note { | |
| font-family: 'DM Mono', monospace; | |
| font-size: 9px; | |
| color: #1e1e1e; | |
| line-height: 1.8; | |
| } | |
| #progress-detail { | |
| font-family: 'DM Mono', monospace; | |
| font-size: 9px; | |
| color: #444; | |
| white-space: nowrap; | |
| overflow: hidden; | |
| text-overflow: ellipsis; | |
| max-width: 200px; | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <header> | |
| <div class="logo"> | |
| <span class="logo-text">LOCAL MIND</span> | |
| <span class="logo-badge">OFFLINE Β· VLM</span> | |
| </div> | |
| <div class="header-right"> | |
| <div id="cache-indicator"><span class="dot"></span> Checking cache...</div> | |
| <div id="storage-info"></div> | |
| </div> | |
| </header> | |
| <div class="status-bar"> | |
| <span id="status">Ready β click Load Model to initialize</span> | |
| <div id="progress"> | |
| <div class="progress-track"><div id="progress-bar"></div></div> | |
| <span id="progress-text"></span> | |
| <span id="progress-detail"></span> | |
| </div> | |
| <div class="model-tag">LFM2.5-VL-1.6B Β· ONNX Β· WebGPU</div> | |
| <button id="load-btn">Load Model</button> | |
| </div> | |
| <div id="chat-container"> | |
| <div id="welcome"> | |
| <div class="welcome-title">SEE.<br>THINK.<br>ANSWER.</div> | |
| <div class="welcome-features"> | |
| <div class="wf"> | |
| <div class="wf-icon">ποΈ</div> | |
| <div class="wf-text">Vision<br>Language</div> | |
| </div> | |
| <div class="wf"> | |
| <div class="wf-icon">β‘</div> | |
| <div class="wf-text">WebGPU<br>Accelerated</div> | |
| </div> | |
| <div class="wf"> | |
| <div class="wf-icon">π</div> | |
| <div class="wf-text">Zero Data<br>Leaves Browser</div> | |
| </div> | |
| <div class="wf"> | |
| <div class="wf-icon">πΌοΈ</div> | |
| <div class="wf-text">Attach<br>Images</div> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- Hidden file input --> | |
| <input type="file" id="image-input" accept="image/*" style="display:none"> | |
| <div class="input-area"> | |
| <!-- Image preview strip --> | |
| <div id="image-preview"> | |
| <img id="image-preview-img" src="" alt="attached image"> | |
| <button id="remove-image-btn" title="Remove image">β</button> | |
| </div> | |
| <!-- Text + buttons row --> | |
| <div class="input-row"> | |
| <button id="image-btn" disabled title="Attach image">πΌ</button> | |
| <textarea | |
| id="user-input" | |
| placeholder="Load model first..." | |
| disabled | |
| rows="1" | |
| oninput="this.style.height='auto';this.style.height=Math.min(this.scrollHeight,120)+'px'" | |
| ></textarea> | |
| <button id="clear-btn">Clear</button> | |
| <button id="send-btn" disabled>Send</button> | |
| </div> | |
| </div> | |
| <div class="specs"> | |
| <div class="spec-item"> | |
| <div class="spec-label">Model</div> | |
| <div class="spec-val green">LFM2.5-VL-1.6B</div> | |
| </div> | |
| <div class="spec-item"> | |
| <div class="spec-label">Inference</div> | |
| <div class="spec-val green">WebGPU + ONNX</div> | |
| </div> | |
| <div class="spec-item"> | |
| <div class="spec-label">Vision</div> | |
| <div class="spec-val green">SigLIP2 NaFlex</div> | |
| </div> | |
| <div class="spec-item"> | |
| <div class="spec-label">Privacy</div> | |
| <div class="spec-val green">100% Local</div> | |
| </div> | |
| <div class="spec-item"> | |
| <div class="spec-label">Download</div> | |
| <div class="spec-val">~1.5GB (one-time)</div> | |
| </div> | |
| <div class="spec-item"> | |
| <div class="spec-label">Runtime</div> | |
| <div class="spec-val">Transformers.js + ORT</div> | |
| </div> | |
| </div> | |
| <!-- Loading overlay --> | |
| <div id="loading-overlay"> | |
| <div class="lo-inner"> | |
| <div> | |
| <div class="lo-title">DOWNLOADING MODEL</div> | |
| <div id="loading-step">Initializing...</div> | |
| <div id="loading-file"></div> | |
| </div> | |
| <div> | |
| <div class="lo-stats"> | |
| <div id="loading-pct">0%</div> | |
| <div class="lo-right"> | |
| <div id="loading-bytes">0 MB / ~1.5 GB</div> | |
| <div id="loading-eta">Calculating speed...</div> | |
| </div> | |
| </div> | |
| <div class="lo-bar-wrap" style="margin-top:12px"> | |
| <div id="loading-bar-fill"></div> | |
| </div> | |
| </div> | |
| <div class="lo-steps"> | |
| <div class="lo-step-row" id="step-row-1"> | |
| <div class="lo-step-dot" id="step-dot-1"></div> | |
| <div class="lo-step-label" id="step-lbl-1">Tokenizer</div> | |
| <div class="lo-step-size">~5 MB</div> | |
| </div> | |
| <div class="lo-step-row" id="step-row-2"> | |
| <div class="lo-step-dot" id="step-dot-2"></div> | |
| <div class="lo-step-label" id="step-lbl-2">Token Embedder</div> | |
| <div class="lo-step-size">~30 MB</div> | |
| </div> | |
| <div class="lo-step-row" id="step-row-3"> | |
| <div class="lo-step-dot" id="step-dot-3"></div> | |
| <div class="lo-step-label" id="step-lbl-3">Vision Encoder (SigLIP2)</div> | |
| <div class="lo-step-size">~400 MB</div> | |
| </div> | |
| <div class="lo-step-row" id="step-row-4"> | |
| <div class="lo-step-dot" id="step-dot-4"></div> | |
| <div class="lo-step-label" id="step-lbl-4">Language Decoder (Q4)</div> | |
| <div class="lo-step-size">~1.1 GB</div> | |
| </div> | |
| </div> | |
| <div class="lo-note"> | |
| β‘ First load downloads ~1.5 GB from Hugging Face.<br> | |
| π Everything runs 100% in-browser β zero data leaves your device.<br> | |
| π Keep this tab open. Do not refresh during download. | |
| </div> | |
| </div> | |
| </div> | |
| </body> | |
| </html> |