vlm / index.html
SiddhJagani's picture
Update index.html
8158e83 verified
Raw
History Blame Contribute Delete
50.3 kB
<!DOCTYPE html>
<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>