shubeydoo's picture
Initial release
6c30253
Raw
History Blame Contribute Delete
56.2 kB
import {
createIcons,
Camera,
ChevronDown,
Database,
Edit3,
FileScan,
FileText,
Image,
Info,
Menu,
MessageSquarePlus,
Paperclip,
Send,
Settings2,
Scan,
ScanSearch,
Trash2,
Wrench,
X,
Zap,
} from 'lucide';
import DOMPurify from 'dompurify';
import { marked } from 'marked';
import { runtime } from './runtime/runtime.js';
import { MODEL, MODEL_REPO } from './model-config.js';
import { parseGroundingResponse } from './grounding.js';
import { parseDocumentRegions } from './document-parsing.js';
import { imageFilesFromClipboard, imageFilesFromDataTransfer } from './image-input.js';
import { WebcamSession } from './webcam-session.js';
import { executeBuiltin, loadTools, modelToolDefinitions, prepareToolCall, saveTools } from './tools/tool-registry.js';
import { callMcpTool, connectMcpServer, disconnectMcpServer } from './tools/mcp-client.js';
import './styles.css';
const iconSet = { Camera, ChevronDown, Database, Edit3, FileScan, FileText, Image, Info, Menu, MessageSquarePlus, Paperclip, Scan, ScanSearch, Send, Settings2, Trash2, Wrench, X, Zap };
const app = document.querySelector('#app');
let scrollToBottomAfterRender = false;
let conversationScrollAfterRender = null;
let lightboxItems = [];
const SYSTEM_PROMPT_KEY = 'liquid-lfm-system-prompt-v1';
const MCP_SETTINGS_KEY = 'liquid-lfm-mcp-v1';
const TOOL_USE_POLICY = [
'You are an AI assistant with access to a set of tools.',
"Tool use is optional. Only call a tool when the user's request requires information or an action that an available tool can provide. Otherwise, answer directly.",
'If a tool is needed, respond with a tool call using the following format:',
'<|tool_call_start|>[tool_function_call_1, tool_function_call_2, ...]<|tool_call_end|>.',
'Each tool function call should use Python-like syntax, e.g., calculate(expression="2 + 2").',
'When a successful tool result includes source_url, include it as a Markdown link in the final answer.',
'If a tool returns an error, explain the error to the user.',
'Be concise and helpful.',
].join(' ');
const CHARCUTERIE_SYSTEM_PROMPT = `When asked for bounding boxes for objects, return a valid JSON array.
Each array item must be an object with:
- image_id: the 0-based index of the image
- bbox_2d: [xmin, ymin, xmax, ymax] normalized integer coordinates in [0, 1000]
- label: a concise label you choose for the predicted object or region
Return one item per visible matching object or region. Return [] if none are visible.`;
const POINT_GROUNDING_SYSTEM_PROMPT = `When asked for points corresponding to objects or regions, return a valid JSON array.
Each array item must be an object with:
- image_id: the 0-based index of the image
- point_2d: [x, y] normalized integer coordinates in [0, 1000]
- label: a concise label you choose for the predicted object or region
Return one item per visible matching object or region. Return [] if none are visible.`;
const CHARCUTERIE_USER_PROMPT = 'Provide bounding boxes for the grapes on the far side of the table as well as the nearest glass';
const PAD_THAI_USER_PROMPT = 'How do I make this dish?';
const DOCUMENT_PARSING_PROMPT = `If asked to parse a document, parse it into its layout regions using the following format. The pages are provided as images in reading order. For every region, in reading order across all pages, output a header line immediately followed by the region's content:
image_index=<n> <label> [xmin, ymin, xmax, ymax]
<content>
where:
- image_index is the zero-based index of the page image the region appears on (0 for the first image, 1 for the second, and so on)
- <label> is one of these layout labels: text, title, list, table, table_caption, table_footnote, image, image_block, image_caption, image_footnote, chart, equation, formula_number, code, code_caption, algorithm, aside_text, ref_text, phonetic, page_header, page_footer, page_number, page_footnote
- [xmin, ymin, xmax, ymax] are normalized integer coordinates in [0, 1000]
- <content> is the region's content: plain text for text regions, LaTeX for equations, OTSL for tables, and a short description for images and charts
Separate each region block with one blank line. Return only the parsed regions.`;
const SYSTEM_PROMPT_PRESETS = {
boxes: CHARCUTERIE_SYSTEM_PROMPT,
points: POINT_GROUNDING_SYSTEM_PROMPT,
document: DOCUMENT_PARSING_PROMPT,
};
function loadPersistedSystemPrompt() {
try {
return localStorage.getItem(SYSTEM_PROMPT_KEY) || '';
} catch {
return '';
}
}
const initialSystemPrompt = loadPersistedSystemPrompt();
const initialMcpSettings = (() => {
try { return JSON.parse(localStorage.getItem(MCP_SETTINGS_KEY) || '{}'); } catch { return {}; }
})();
const state = {
messages: [],
attachments: [],
promptDraft: '',
loading: false,
generating: false,
settingsOpen: false,
cacheOpen: false,
toolsOpen: false,
systemPromptOpen: false,
systemPrompt: initialSystemPrompt,
exampleSystemPromptActive: false,
lightbox: null,
tools: loadTools(),
mcp: {
url: initialMcpSettings.url || 'https://gitmcp.io/huggingface/transformers.js',
enabled: new Set(Array.isArray(initialMcpSettings.enabled) ? initialMcpSettings.enabled : []),
status: 'disconnected',
error: '',
},
sidebarOpen: false,
webcamOpen: false,
progress: { progress: 0, file: 'Waiting to load' },
cache: null,
adapter: null,
error: '',
generation: { maxNewTokens: 1024, temperature: 0.2, topP: 0.9, topK: 50 },
abortController: null,
};
let exampleConfigSnapshot = null;
function restoreExampleConfig({ clear = true } = {}) {
if (!exampleConfigSnapshot) return;
state.systemPrompt = exampleConfigSnapshot.systemPrompt;
state.tools.forEach(tool => {
if (exampleConfigSnapshot.toolEnabled.has(tool.id)) {
tool.enabled = exampleConfigSnapshot.toolEnabled.get(tool.id);
}
});
state.exampleSystemPromptActive = false;
if (clear) exampleConfigSnapshot = null;
}
function beginExampleConfig() {
if (!exampleConfigSnapshot) {
exampleConfigSnapshot = {
systemPrompt: state.systemPrompt,
toolEnabled: new Map(state.tools.map(tool => [tool.id, tool.enabled])),
};
} else {
restoreExampleConfig({ clear: false });
}
state.exampleSystemPromptActive = true;
}
function persistTools() {
const tools = exampleConfigSnapshot
? state.tools.map(tool => ({
...tool,
enabled: exampleConfigSnapshot.toolEnabled.has(tool.id)
? exampleConfigSnapshot.toolEnabled.get(tool.id)
: tool.enabled,
}))
: state.tools;
saveTools(tools);
}
function icon(name, label = '') {
const lucideName = name.replace(/([a-z0-9])([A-Z])/g, '$1-$2').toLowerCase();
return `<i data-lucide="${lucideName}"${label ? ` aria-label="${label}"` : ''}></i>`;
}
function refreshIcons() {
createIcons({ icons: iconSet, attrs: { 'stroke-width': 1.8 } });
}
function formatBytes(bytes) {
if (!bytes) return '0 MB';
return `${(bytes / 1024 / 1024 / 1024).toFixed(2)} GB`;
}
function captureConversationScroll(conversation) {
if (!conversation) return null;
return { top: conversation.scrollTop };
}
function applyConversationScroll(conversation, snapshot, followBottom) {
if (!conversation) return;
const maximum = Math.max(0, conversation.scrollHeight - conversation.clientHeight);
if (followBottom) conversation.scrollTop = maximum;
else if (snapshot) conversation.scrollTop = snapshot.top;
}
function render() {
const previousConversationScroll = captureConversationScroll(document.querySelector('#conversation'));
const previousToolsScroll = document.querySelector('.tools-drawer')?.scrollTop;
const requestedConversationScroll = conversationScrollAfterRender || previousConversationScroll;
const followConversationBottom = scrollToBottomAfterRender;
conversationScrollAfterRender = null;
scrollToBottomAfterRender = false;
lightboxItems = [];
app.innerHTML = `
<div class="shell">
<aside class="sidebar ${state.sidebarOpen ? 'open' : ''}">
<div class="brand-row">
<a class="brand-link" href="https://huggingface.co/LiquidAI" target="_blank" rel="noreferrer" aria-label="Liquid AI on Hugging Face">
<img src="/liquid-hero-white.png" alt="Liquid" class="brand-logo" />
</a>
<button class="icon-button sidebar-close" data-action="close-sidebar" aria-label="Close menu">${icon('X')}</button>
</div>
${isRuntimeActive() ? `<button class="new-chat" data-action="new-chat">${icon('MessageSquarePlus')}<span>New conversation</span></button>` : '<div class="sidebar-spacer"></div>'}
<div class="side-section model-card">
<a class="model-name" href="https://huggingface.co/${MODEL_REPO}" target="_blank" rel="noreferrer"><span class="model-identity"><span class="model-orb"></span><span>${MODEL.sidebarLabel}</span></span><span aria-hidden="true">↗</span></a>
</div>
<div class="side-section examples-side">
<div class="section-label">TRY AN EXAMPLE</div>
<button class="example-row" data-action="example-charcuterie"><img src="/charcuterie.jpg" alt="Charcuterie table" /><span><small>Grounding</small>${escapeHtml(CHARCUTERIE_USER_PROMPT)}</span></button>
<button class="example-row" data-action="example-document"><img src="/LFM2-VL-tech-report.png" alt="LFM2-VL technical report page" /><span><small>Document parsing</small>Parse the LFM2-VL technical report into layout regions</span></button>
<button class="example-row" data-action="example-pad-thai"><img src="/pad-thai.jpeg" alt="Pad Thai" /><span><small>Tool calling</small>${escapeHtml(PAD_THAI_USER_PROMPT)}</span></button>
</div>
<div class="sidebar-bottom">
<button class="side-link" data-action="cache">${icon('Database')} ${state.cache ? `${formatBytes(state.cache.used)} cached` : 'Browser model cache'}</button>
<a class="side-link" href="https://www.liquid.ai" target="_blank" rel="noreferrer">${icon('Info')} About Liquid AI</a>
</div>
</aside>
<main class="main">
<header class="topbar">
<button class="icon-button menu-button" data-action="open-sidebar" aria-label="Open menu">${icon('Menu')}</button>
<div class="mobile-brand"><img src="/liquid-mark.svg" alt="" /> LFM2.5-VL-3B</div>
<div class="topbar-status"><span class="privacy-dot"></span> Runs locally</div>
<div class="runtime-status"><span class="status-dot ${isRuntimeActive() ? 'online' : ''}"></span>${isRuntimeActive() ? 'WebGPU active' : 'WebGPU'}</div>
</header>
<section class="conversation" id="conversation">
${state.messages.length ? renderMessages() : renderWelcome()}
</section>
${isRuntimeActive() ? `<section class="composer-zone">
${state.error ? `<div class="error-banner"><span>${escapeHtml(state.error)}</span><button data-action="dismiss-error">${icon('X')}</button></div>` : ''}
${state.attachments.length ? `<div class="attachment-strip">${state.attachments.map((item, index) => `
<div class="attachment">${renderEnlargeableImage(item)}<button class="attachment-remove" data-remove="${index}" aria-label="Remove image">${icon('X')}</button></div>
`).join('')}</div>` : ''}
<div class="composer ${state.generating ? 'busy' : ''}">
${renderSystemPromptEditor()}
<textarea id="prompt" rows="1" maxlength="8000" placeholder="Ask about an image, a document, or anything else…" ${state.generating ? 'disabled' : ''}>${escapeHtml(state.promptDraft)}</textarea>
<div class="composer-actions">
<div class="composer-tools">
<label class="icon-button" aria-label="Attach images" title="Attach images">${icon('Paperclip')}<input id="image-input" type="file" accept="image/*" multiple hidden /></label>
<button class="icon-button" data-action="webcam" aria-label="Use webcam" title="Use webcam">${icon('Camera')}</button>
<button class="icon-button ${state.settingsOpen ? 'active' : ''}" data-action="settings" aria-label="Generation settings" title="Generation settings">${icon('Settings2')}</button>
</div>
<span class="composer-hint">Enter to send · Shift + Enter for newline</span>
<button class="send-button ${state.generating ? 'stop-button' : ''}" data-action="${state.generating ? 'stop' : 'send'}" aria-label="${state.generating ? 'Stop generation' : 'Send message'}" title="${state.generating ? 'Stop generation' : 'Send message'}">
${state.generating ? '<span class="stop-symbol" aria-hidden="true"></span>' : icon('Send')}
</button>
</div>
${state.settingsOpen ? renderSettings() : ''}
</div>
</section>` : ''}
</main>
</div>
${state.cacheOpen ? renderCacheManager() : ''}
${state.toolsOpen ? renderToolsDrawer() : ''}
${state.webcamOpen ? renderWebcam() : ''}
${state.lightbox ? renderLightbox() : ''}
${!isRuntimeActive() ? renderModelLoader() : ''}
`;
bindEvents();
refreshIcons();
const toolsDrawer = document.querySelector('.tools-drawer');
if (toolsDrawer && previousToolsScroll !== undefined) toolsDrawer.scrollTop = previousToolsScroll;
const conversation = document.querySelector('#conversation');
const restoreScroll = () => {
if (conversation?.isConnected) applyConversationScroll(conversation, requestedConversationScroll, followConversationBottom);
};
restoreScroll();
requestAnimationFrame(() => {
restoreScroll();
});
const pendingImages = conversation ? [...conversation.querySelectorAll('img')].filter(image => !image.complete) : [];
if (pendingImages.length) {
void Promise.all(pendingImages.map(image => new Promise(resolve => {
if (image.complete) { resolve(); return; }
image.addEventListener('load', resolve, { once: true });
image.addEventListener('error', resolve, { once: true });
}))).then(() => requestAnimationFrame(restoreScroll));
}
}
function preserveConversationScroll() {
const conversation = document.querySelector('#conversation');
if (conversation) conversationScrollAfterRender = captureConversationScroll(conversation);
scrollToBottomAfterRender = false;
}
function renderWelcome() {
return `
<div class="welcome">
<h1><img class="hero-mark" src="/liquid-mark.svg" alt="" /><span>LFM2.5 <em>VL</em> 3B</span></h1>
<p class="hero-copy">A Better <em>and</em> Faster Vision-Language Model for the Edge</p>
<div class="example-grid">
<button class="example-card" data-action="example-charcuterie"><img src="/charcuterie.jpg" alt="Charcuterie table" /><span><small>Grounding</small>${escapeHtml(CHARCUTERIE_USER_PROMPT)}${icon('ScanSearch')}</span></button>
<button class="example-card" data-action="example-document"><img src="/LFM2-VL-tech-report.png" alt="LFM2-VL technical report page" /><span><small>Document parsing</small>Parse the LFM2-VL technical report into layout regions${icon('FileScan')}</span></button>
<button class="example-card" data-action="example-pad-thai"><img src="/pad-thai.jpeg" alt="Pad Thai" /><span><small>Tool calling</small>${escapeHtml(PAD_THAI_USER_PROMPT)}${icon('Wrench')}</span></button>
</div>
</div>`;
}
function renderMessages() {
return `<div class="messages">${state.messages.map((message, index) => message.role === 'tool'
? renderToolResultMessage(message)
: `
<article class="message ${message.role}">
<div class="message-label">${message.role === 'user' ? 'You' : `<img src="/liquid-mark.svg" alt=""/> Liquid`}</div>
<div class="message-content">
${message.images?.length ? `<div class="message-images">${message.images.map(image => renderEnlargeableImage(image)).join('')}</div>` : ''}
<div class="message-text ${message.role === 'assistant' ? 'markdown-body' : 'plain-text'}">${message.text ? message.role === 'assistant' ? renderMarkdown(message.text) : escapeHtml(message.text) : message.streaming && !message.toolCalls?.length ? '<span class="response-spinner" role="status" aria-label="Generating response"></span>' : ''}</div>
${message.groundings?.length ? renderGroundings(message, index) : ''}
${message.documentRegions?.length ? renderDocumentRegions(message, index) : ''}
${message.toolCalls?.length ? `<div class="tool-call-list">${message.toolCalls.map(call => renderToolCall(call)).join('')}</div>` : ''}
${message.role === 'user' && !state.generating ? `<button class="edit-message" data-edit="${index}">${icon('Edit3')} Edit</button>` : ''}
</div>
</article>
`).join('')}</div>`;
}
function renderEnlargeableImage(image, overlays = [], className = '') {
const lightboxIndex = lightboxItems.push({ image, overlays }) - 1;
return `<button class="image-enlarge ${className}" data-lightbox="${lightboxIndex}" aria-label="Enlarge ${escapeHtml(image.name || 'image')}">
<span class="overlay-image"><img src="${image.url}" alt="${escapeHtml(image.name || 'Attached image')}" />${renderOverlayLayer(overlays)}</span>
</button>`;
}
function renderOverlayLayer(overlays) {
if (!overlays.length) return '';
let pointIndex = 0;
return `<span class="grounding-overlay">${overlays.map(item => {
const label = `<span class="grounding-label">${escapeHtml(item.label)}</span>`;
if (item.type === 'box') {
const [xmin, ymin, xmax, ymax] = item.coordinates;
return `<span class="grounding-box" style="left:${xmin / 10}%;top:${ymin / 10}%;width:${(xmax - xmin) / 10}%;height:${(ymax - ymin) / 10}%">${label}</span>`;
}
pointIndex += 1;
const [x, y] = item.coordinates;
return `<span class="grounding-point" style="left:${x / 10}%;top:${y / 10}%"><i></i>${label}<b>${pointIndex}</b></span>`;
}).join('')}</span>`;
}
function renderGroundings(message, messageIndex) {
const grouped = new Map();
for (const item of message.groundings) grouped.set(item.imageId, [...(grouped.get(item.imageId) || []), item]);
const regionCount = message.groundings.length;
return `<details class="grounding-results" data-rendering-message="${messageIndex}" ${message.renderingCollapsed ? '' : 'open'}>
<summary class="grounding-heading"><span>${icon('Scan')} Grounding overlay</span><small>${grouped.size} ${grouped.size === 1 ? 'image' : 'images'} · ${regionCount} ${regionCount === 1 ? 'region' : 'regions'}</small><i aria-hidden="true">›</i></summary>
<div class="grounding-grid">${[...grouped].map(([imageId, overlays]) => {
const image = message.groundingImages?.[imageId];
return image ? `<figure>${renderEnlargeableImage(image, overlays, 'grounding-image')}<figcaption>Image ${imageId + 1} · ${overlays.length} ${overlays.length === 1 ? 'region' : 'regions'}</figcaption></figure>` : '';
}).join('')}</div></details>`;
}
function renderDocumentRegions(message, messageIndex) {
const grouped = new Map();
for (const region of message.documentRegions) grouped.set(region.imageId, [...(grouped.get(region.imageId) || []), region]);
const regionCount = message.documentRegions.length;
return `<details class="grounding-results document-results" data-rendering-message="${messageIndex}" ${message.renderingCollapsed ? '' : 'open'}>
<summary class="grounding-heading"><span>${icon('FileText')} Document layout</span><small>${grouped.size} ${grouped.size === 1 ? 'page' : 'pages'} · ${regionCount} ${regionCount === 1 ? 'region' : 'regions'}</small><i aria-hidden="true">›</i></summary>
<div class="grounding-grid">${[...grouped].map(([imageId, regions]) => {
const image = message.groundingImages?.[imageId];
if (!image) return '';
const numberedRegions = regions.map((region, index) => ({ ...region, label: `${index + 1} · ${region.label}` }));
return `<figure>${renderEnlargeableImage(image, numberedRegions, 'grounding-image')}<figcaption>Page ${imageId + 1} · ${regions.length} ${regions.length === 1 ? 'region' : 'regions'}</figcaption>
<div class="document-region-list">${regions.map((region, index) => `<div><span>${index + 1}</span><strong>${escapeHtml(region.label)}</strong><p>${escapeHtml(region.content)}</p></div>`).join('')}</div>
</figure>`;
}).join('')}</div></details>`;
}
function renderLightbox() {
return `<div class="modal-scrim lightbox-scrim" data-action="close-lightbox"><div class="lightbox" onclick="event.stopPropagation()">
<button class="lightbox-close" data-action="close-lightbox" aria-label="Close enlarged image">${icon('X')}</button>
<span class="overlay-image"><img src="${state.lightbox.image.url}" alt="${escapeHtml(state.lightbox.image.name || 'Enlarged image')}" />${renderOverlayLayer(state.lightbox.overlays)}</span>
</div></div>`;
}
function renderSystemPromptEditor() {
const enabledToolCount = state.tools.filter(tool => tool.enabled).length;
const systemPromptActive = Boolean(state.systemPrompt.trim());
return `<div class="system-prompt-inline ${state.systemPromptOpen ? 'open' : ''}">
<div class="composer-context-row">
<button type="button" class="system-prompt-toggle ${systemPromptActive ? 'enabled' : ''}" data-action="system-prompt" aria-expanded="${state.systemPromptOpen}"><span>System Prompt${systemPromptActive ? ' · Active' : ''}</span><span class="system-prompt-chevron" aria-hidden="true">▾</span></button>
<button type="button" class="tool-calling-toggle ${enabledToolCount ? 'enabled' : ''}" data-action="tools" aria-expanded="${state.toolsOpen}"><span>Tool Calling · ${enabledToolCount ? `${enabledToolCount} on` : 'Off'}</span><span aria-hidden="true">›</span></button>
</div>
${state.systemPromptOpen ? `<div class="system-prompt-form">
<div class="system-prompt-presets"><span>Defaults</span><button type="button" data-system-preset="boxes" class="${state.systemPrompt === SYSTEM_PROMPT_PRESETS.boxes ? 'active' : ''}">Bounding boxes</button><button type="button" data-system-preset="points" class="${state.systemPrompt === SYSTEM_PROMPT_PRESETS.points ? 'active' : ''}">Grounding points</button><button type="button" data-system-preset="document" class="${state.systemPrompt === SYSTEM_PROMPT_PRESETS.document ? 'active' : ''}">Document parsing</button></div>
<textarea id="system-prompt-editor" rows="6" maxlength="12000" placeholder="No custom system prompt. The model will use its native chat template.">${escapeHtml(state.systemPrompt)}</textarea>
<div class="system-prompt-meta"><span>${state.exampleSystemPromptActive ? 'Example preset · applied automatically for this conversation' : 'Applied automatically · saved in this browser · tools are injected separately'}</span><div class="system-prompt-actions"><button type="button" data-action="clear-system-prompt">Clear</button></div></div>
</div>` : ''}
</div>`;
}
function renderToolCall(call) {
return `<div class="tool-call-card ${call.status || 'proposed'}">
<div><span class="tool-state"></span><strong>${escapeHtml(call.name)}</strong><small>${escapeHtml(call.statusLabel || call.status || 'requested')}</small></div>
${call.arguments === null ? '' : `<pre>${escapeHtml(JSON.stringify(call.arguments, null, 2))}</pre>`}
${call.status === 'error' && call.rawOutput ? `<details class="tool-raw-output" open><summary>Raw model output</summary><pre>${escapeHtml(call.rawOutput)}</pre></details>` : ''}
</div>`;
}
function renderToolResultMessage(message) {
return `<article class="message tool"><div class="message-label">Tools</div><div class="message-content"><div class="tool-result-list">
${(message.executions || []).map(execution => `<div class="tool-result-card ${execution.status}">
<div><strong>${escapeHtml(execution.name)}</strong><span>${escapeHtml(execution.status)} · ${Math.round(execution.durationMs)} ms</span></div>
<pre>${escapeHtml(JSON.stringify(execution.status === 'success' ? execution.result : execution.error, null, 2))}</pre>
</div>`).join('')}
</div></div></article>`;
}
function renderSettings() {
return `<div class="settings-popover">
<div class="popover-title">Generation settings</div>
<label><span>Max new tokens <output>${state.generation.maxNewTokens}</output></span><input data-setting="maxNewTokens" type="range" min="128" max="2048" step="128" value="${state.generation.maxNewTokens}" /></label>
<label><span>Temperature <output>${state.generation.temperature.toFixed(1)}</output></span><input data-setting="temperature" type="range" min="0" max="1.5" step="0.1" value="${state.generation.temperature}" /></label>
<label><span>Top P <output>${state.generation.topP.toFixed(2)}</output></span><input data-setting="topP" type="range" min="0.1" max="1" step="0.05" value="${state.generation.topP}" /></label>
<label><span>Top K <output>${state.generation.topK}</output></span><input data-setting="topK" type="range" min="1" max="100" step="1" value="${state.generation.topK}" /></label>
</div>`;
}
function renderCacheManager() {
const cached = state.cache ? formatBytes(state.cache.used) : 'Calculating…';
return `<div class="modal-scrim" data-action="cache"><section class="cache-modal" onclick="event.stopPropagation()">
<div class="drawer-head"><div><div class="section-label">STORAGE</div><h2>Model cache</h2></div><button class="icon-button" data-action="cache" aria-label="Close model cache">${icon('X')}</button></div>
<p>Model files stay in this browser so returning visits do not download them again.</p>
<div class="cache-total"><span>Cached model data</span><strong>${cached}</strong></div>
<button class="secondary-button cache-clear" data-action="clear-cache" ${state.loading ? 'disabled' : ''}>${icon('Trash2')} ${state.loading ? 'Cache in use' : 'Clear model cache'}</button>
</section></div>`;
}
function renderToolsDrawer() {
const builtins = state.tools.filter(tool => tool.source === 'builtin');
const mcpTools = state.tools.filter(tool => tool.source === 'mcp');
return `<div class="drawer-scrim" data-action="tools"><aside class="drawer-panel tools-drawer" onclick="event.stopPropagation()">
<div class="drawer-head"><div><div class="section-label">MODEL-DIRECTED</div><h2>Tool Calling</h2></div><button class="icon-button" data-action="tools">${icon('X')}</button></div>
<p class="drawer-intro">Tools start disabled. Enabled schemas are included in the model prompt. Built-ins and connected MCP tools run automatically.</p>
<div class="tool-section-head"><span>Browser tools</span><small>${builtins.filter(tool => tool.enabled).length}/${builtins.length} enabled</small></div>
<div class="tool-config-list">${builtins.map(renderToolConfig).join('')}</div>
<div class="tool-section-head"><span>MCP server</span><small>${state.mcp.status === 'connected' ? `${mcpTools.length} tools` : state.mcp.status}</small></div>
<form id="mcp-server-form" class="mcp-server-form">
<input id="mcp-server-url" type="url" required spellcheck="false" aria-label="MCP server URL" value="${escapeHtml(state.mcp.url)}" placeholder="https://example.com/mcp" ${state.mcp.status === 'connecting' ? 'disabled' : ''}/>
${state.mcp.status === 'connected'
? '<button type="button" class="secondary-button" data-action="disconnect-mcp">Disconnect</button>'
: `<button type="submit" class="primary-small" ${state.mcp.status === 'connecting' ? 'disabled' : ''}>${state.mcp.status === 'connecting' ? 'Connecting…' : 'Connect'}</button>`}
</form>
${state.mcp.error ? `<div class="inline-form-error mcp-error">${escapeHtml(state.mcp.error)}</div>` : ''}
${mcpTools.length ? `<div class="tool-config-list mcp-tools">${mcpTools.map(renderToolConfig).join('')}</div>` : ''}
<div class="tool-privacy-note"><b>Explicit external access.</b> MCP tool arguments are sent directly from this browser to the connected server. The server must support browser access (CORS). Images remain local unless included in a tool argument.</div>
</aside></div>`;
}
function renderToolConfig(tool) {
const modelDefinition = { name: tool.name, description: tool.description, parameters: tool.parameters };
return `<div class="tool-config-row">
<label class="switch"><input type="checkbox" data-tool-toggle="${escapeHtml(tool.id)}" ${tool.enabled ? 'checked' : ''}/><i></i></label>
<div><strong>${escapeHtml(tool.name)}</strong><p>${escapeHtml(tool.description)}</p><small>${tool.external ? 'External · TheMealDB' : tool.source === 'builtin' ? 'Automatic browser tool' : 'MCP · automatic'}</small>
<details class="tool-definition"><summary>View definition</summary><pre>${escapeHtml(JSON.stringify(modelDefinition, null, 2))}</pre></details>
</div>
</div>`;
}
function renderModelLoader() {
return `<div class="loader-screen">
<div class="loader-glow"></div>
<div class="loader-card">
<div class="loader-brand"><img src="/liquid-hero-white.png" alt="Liquid" /></div>
<div class="loader-orb" aria-hidden="true"></div>
<div class="eyebrow"><span></span> LOCAL VISION-LANGUAGE MODEL</div>
<h1>Intelligence that<br /><em>stays with you.</em></h1>
<p>Download LFM2.5-VL-3B from Hugging Face, then run it entirely in your browser.</p>
${isFirefoxBasedBrowser() ? `<div class="browser-performance-note">${icon('Info')}<span>This model may run more slowly in Firefox-based browsers. For the best WebGPU performance, use the latest Chrome or Edge.</span></div>` : ''}
${state.error ? `<div class="loader-error">${escapeHtml(state.error)}</div>` : ''}
<div class="model-loader">
<button class="load-model-button" type="button" data-action="load" ${state.loading ? 'disabled' : ''}>
${state.loading ? `<span class="spinner"></span><span>Loading model</span><b>${Math.round(state.progress.progress || 0)}%</b>` : `${icon('Zap')} Load model`}
</button>
${state.loading ? `<div class="loader-progress"><span style="width:${state.progress.progress || 2}%"></span></div><div class="loader-progress-file">${escapeHtml(state.progress.file)}</div>${renderEmbeddingPrecision()}` : '<div class="loader-cache-note">Model files are downloaded once and cached by this browser.</div>'}
</div>
</div>
</div>`;
}
function renderEmbeddingPrecision() {
if (runtime.embeddingPrecision === 'fp16') return '<div class="loader-precision-note">FP16 embeddings · shader-f16 available</div>';
if (runtime.embeddingPrecision === 'fp32') return '<div class="loader-precision-note">FP32 embeddings · shader-f16 unavailable</div>';
return '';
}
function renderWebcam() {
return `<div class="modal-scrim"><div class="webcam-modal"><div class="drawer-head"><div><div class="section-label">CAMERA</div><h2>Capture an image</h2></div><button class="icon-button" data-action="close-webcam">${icon('X')}</button></div><div class="video-frame"><video id="webcam-video" autoplay playsinline muted></video><div class="camera-wait">Waiting for camera…</div></div><button class="capture-button" data-action="capture">${icon('Camera')} Capture frame</button></div></div>`;
}
function escapeHtml(value = '') {
return String(value).replace(/[&<>'"]/g, character => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', "'": '&#39;', '"': '&quot;' })[character]);
}
function renderMarkdown(value = '') {
const sanitized = DOMPurify.sanitize(marked.parse(String(value), { gfm: true, breaks: true }), {
FORBID_TAGS: ['img'],
});
const template = document.createElement('template');
template.innerHTML = sanitized;
for (const link of template.content.querySelectorAll('a')) {
link.target = '_blank';
link.rel = 'noopener noreferrer';
}
return template.innerHTML;
}
function isRuntimeActive() {
return runtime.status === 'ready' || runtime.status === 'generating';
}
function bindEvents() {
document.querySelectorAll('[data-action]').forEach(element => element.addEventListener('click', handleAction));
document.querySelectorAll('[data-lightbox]').forEach(element => element.addEventListener('click', () => {
preserveConversationScroll();
state.lightbox = lightboxItems[Number(element.dataset.lightbox)] || null;
render();
}));
document.querySelectorAll('[data-remove]').forEach(element => element.addEventListener('click', () => { state.attachments.splice(Number(element.dataset.remove), 1); render(); }));
document.querySelectorAll('[data-edit]').forEach(element => element.addEventListener('click', () => editMessage(Number(element.dataset.edit))));
document.querySelectorAll('[data-rendering-message]').forEach(element => element.addEventListener('toggle', event => {
const message = state.messages[Number(event.currentTarget.dataset.renderingMessage)];
if (message) message.renderingCollapsed = !event.currentTarget.open;
}));
document.querySelector('#image-input')?.addEventListener('change', event => addFiles(event.target.files));
document.querySelector('#mcp-server-form')?.addEventListener('submit', connectMcp);
document.querySelector('#system-prompt-editor')?.addEventListener('input', updateSystemPrompt);
document.querySelectorAll('[data-system-preset]').forEach(button => button.addEventListener('click', applySystemPromptPreset));
document.querySelector('#mcp-server-url')?.addEventListener('input', event => { state.mcp.url = event.currentTarget.value; });
document.querySelectorAll('[data-tool-toggle]').forEach(input => input.addEventListener('change', toggleTool));
document.querySelectorAll('[data-setting]').forEach(input => input.addEventListener('input', updateSetting));
const prompt = document.querySelector('#prompt');
prompt?.addEventListener('input', event => {
state.promptDraft = event.currentTarget.value;
resizePrompt(event);
});
prompt?.addEventListener('keydown', event => {
if (event.key === 'Enter' && !event.shiftKey && !event.isComposing) {
event.preventDefault();
sendMessage();
}
});
prompt?.addEventListener('paste', handlePromptPaste);
const composer = document.querySelector('.composer');
composer?.addEventListener('dragenter', handleComposerDragOver);
composer?.addEventListener('dragover', handleComposerDragOver);
composer?.addEventListener('dragleave', handleComposerDragLeave);
composer?.addEventListener('drop', handleComposerDrop);
if (state.webcamOpen) startWebcam();
}
async function handleAction(event) {
const action = event.currentTarget.dataset.action;
if (action === 'load') loadModel();
if (action === 'send') sendMessage();
if (action === 'stop') {
state.abortController?.abort();
}
if (action === 'new-chat') {
state.messages = [];
state.attachments = [];
state.promptDraft = '';
restoreExampleConfig();
if (Object.values(SYSTEM_PROMPT_PRESETS).includes(state.systemPrompt)) {
state.systemPrompt = '';
state.exampleSystemPromptActive = false;
localStorage.removeItem(SYSTEM_PROMPT_KEY);
}
runtime.clearConversationCache();
state.sidebarOpen = false;
render();
}
if (action === 'example-charcuterie') applyCharcuterieExample();
if (action === 'example-document') applyDocumentExample();
if (action === 'example-pad-thai') applyPadThaiExample();
if (action === 'settings') { state.settingsOpen = !state.settingsOpen; render(); document.querySelector('#prompt')?.focus(); }
if (action === 'tools') { state.toolsOpen = !state.toolsOpen; state.systemPromptOpen = false; state.settingsOpen = false; render(); }
if (action === 'system-prompt') {
state.systemPromptOpen = !state.systemPromptOpen;
state.toolsOpen = false;
state.settingsOpen = false;
render();
}
if (action === 'clear-system-prompt') {
state.systemPrompt = '';
if (exampleConfigSnapshot) exampleConfigSnapshot.systemPrompt = '';
localStorage.removeItem(SYSTEM_PROMPT_KEY);
state.exampleSystemPromptActive = false;
runtime.clearConversationCache();
render();
}
if (action === 'close-lightbox') { preserveConversationScroll(); state.lightbox = null; render(); }
if (action === 'disconnect-mcp') await disconnectMcp();
if (action === 'cache') { state.cacheOpen = !state.cacheOpen; state.sidebarOpen = false; render(); if (state.cacheOpen) updateCacheInfo(); }
if (action === 'clear-cache' && !state.loading) { await runtime.clearCache(); await updateCacheInfo(); render(); }
if (action === 'open-sidebar') { state.sidebarOpen = true; render(); }
if (action === 'close-sidebar') { state.sidebarOpen = false; render(); }
if (action === 'dismiss-error') { state.error = ''; render(); }
if (action === 'webcam') { state.webcamOpen = true; render(); }
if (action === 'close-webcam') closeWebcam();
if (action === 'capture') captureWebcam();
}
function applyCharcuterieExample() {
beginExampleConfig();
state.messages = [];
state.attachments = [{ name: 'charcuterie.jpg', url: '/charcuterie.jpg', source: 'example' }];
state.promptDraft = CHARCUTERIE_USER_PROMPT;
state.systemPrompt = CHARCUTERIE_SYSTEM_PROMPT;
state.exampleSystemPromptActive = true;
state.sidebarOpen = false;
state.settingsOpen = false;
runtime.clearConversationCache();
render();
const prompt = document.querySelector('#prompt');
if (prompt) {
resizePrompt({ currentTarget: prompt });
prompt.focus();
}
}
function applyDocumentExample() {
beginExampleConfig();
state.messages = [];
state.attachments = [{ name: 'LFM2-VL-tech-report.png', url: '/LFM2-VL-tech-report.png', source: 'example' }];
state.promptDraft = 'Parse this document into its layout regions.';
state.systemPrompt = DOCUMENT_PARSING_PROMPT;
state.exampleSystemPromptActive = true;
state.sidebarOpen = false;
state.settingsOpen = false;
state.systemPromptOpen = false;
runtime.clearConversationCache();
render();
const prompt = document.querySelector('#prompt');
if (prompt) {
resizePrompt({ currentTarget: prompt });
prompt.focus();
}
}
function applyPadThaiExample() {
beginExampleConfig();
state.messages = [];
state.attachments = [{ name: 'pad-thai.jpeg', url: '/pad-thai.jpeg', source: 'example' }];
state.promptDraft = PAD_THAI_USER_PROMPT;
state.systemPrompt = '';
state.exampleSystemPromptActive = true;
state.tools.forEach(tool => { tool.enabled = tool.name === 'search_recipe_by_dish'; });
state.sidebarOpen = false;
state.settingsOpen = false;
state.systemPromptOpen = false;
runtime.clearConversationCache();
render();
const prompt = document.querySelector('#prompt');
if (prompt) {
resizePrompt({ currentTarget: prompt });
prompt.focus();
}
}
function updateSystemPrompt(event) {
state.systemPrompt = event.currentTarget.value;
if (exampleConfigSnapshot) exampleConfigSnapshot.systemPrompt = state.systemPrompt;
if (state.systemPrompt.trim()) localStorage.setItem(SYSTEM_PROMPT_KEY, state.systemPrompt);
else localStorage.removeItem(SYSTEM_PROMPT_KEY);
state.exampleSystemPromptActive = false;
runtime.clearConversationCache();
const toggle = document.querySelector('.system-prompt-toggle');
const active = Boolean(state.systemPrompt.trim());
toggle?.classList.toggle('enabled', active);
const label = toggle?.querySelector('span:first-child');
if (label) label.textContent = `System Prompt${active ? ' · Active' : ''}`;
document.querySelectorAll('[data-system-preset]').forEach(button => {
button.classList.toggle('active', state.systemPrompt === SYSTEM_PROMPT_PRESETS[button.dataset.systemPreset]);
});
}
function applySystemPromptPreset(event) {
const prompt = SYSTEM_PROMPT_PRESETS[event.currentTarget.dataset.systemPreset];
if (!prompt) return;
const nextPrompt = state.systemPrompt === prompt ? '' : prompt;
state.systemPrompt = nextPrompt;
if (exampleConfigSnapshot) exampleConfigSnapshot.systemPrompt = nextPrompt;
state.exampleSystemPromptActive = false;
if (nextPrompt) localStorage.setItem(SYSTEM_PROMPT_KEY, nextPrompt);
else localStorage.removeItem(SYSTEM_PROMPT_KEY);
runtime.clearConversationCache();
render();
document.querySelector('#system-prompt-editor')?.focus();
}
function toggleTool(event) {
const tool = state.tools.find(candidate => candidate.id === event.currentTarget.dataset.toolToggle);
if (!tool) return;
tool.enabled = event.currentTarget.checked;
if (tool.source === 'mcp') {
if (tool.enabled) state.mcp.enabled.add(tool.name);
else state.mcp.enabled.delete(tool.name);
saveMcpSettings();
}
if (exampleConfigSnapshot) exampleConfigSnapshot.toolEnabled.set(tool.id, tool.enabled);
persistTools();
render();
}
async function connectMcp(event) {
event.preventDefault();
state.mcp.status = 'connecting';
state.mcp.error = '';
render();
try {
const discovered = await connectMcpServer(state.mcp.url);
const existing = new Set(state.tools.filter(tool => tool.source !== 'mcp').map(tool => tool.name));
const accepted = discovered.filter(tool => !existing.has(tool.name)).map(tool => ({ ...tool, enabled: state.mcp.enabled.has(tool.name) }));
const skipped = discovered.length - accepted.length;
state.tools = [...state.tools.filter(tool => tool.source !== 'mcp'), ...accepted];
state.mcp.status = 'connected';
state.mcp.error = skipped ? `${skipped} MCP tool${skipped === 1 ? ' was' : 's were'} skipped because its name conflicts with another tool.` : '';
saveMcpSettings();
} catch (error) {
state.tools = state.tools.filter(tool => tool.source !== 'mcp');
state.mcp.status = 'disconnected';
state.mcp.error = `Connection failed: ${error.message}`;
}
render();
}
async function disconnectMcp() {
await disconnectMcpServer();
state.tools = state.tools.filter(tool => tool.source !== 'mcp');
state.mcp.status = 'disconnected';
state.mcp.error = '';
render();
}
function saveMcpSettings() {
localStorage.setItem(MCP_SETTINGS_KEY, JSON.stringify({ url: state.mcp.url, enabled: [...state.mcp.enabled] }));
}
async function loadModel() {
state.loading = true; state.error = ''; render();
try {
await runtime.load();
await updateCacheInfo();
} catch (error) {
state.error = error.message;
} finally {
state.loading = false; render();
}
}
async function sendMessage() {
const prompt = document.querySelector('#prompt');
const text = prompt?.value.trim() || '';
if ((!text && !state.attachments.length) || state.generating) return;
if (runtime.status !== 'ready') {
state.error = 'Load the on-device model before sending a message.'; render(); return;
}
state.systemPromptOpen = false;
const userMessage = { role: 'user', text, images: state.attachments.slice() };
state.messages.push(userMessage);
state.attachments = [];
state.promptDraft = '';
state.generating = true;
state.error = '';
state.abortController = new AbortController();
scrollToBottomAfterRender = true;
render();
try {
let toolRounds = 0;
while (!state.abortController.signal.aborted) {
const assistant = { role: 'assistant', text: '', streaming: true, toolCalls: [] };
state.messages.push(assistant);
scrollToBottomAfterRender = true;
render();
const promptMessages = state.messages.slice(0, -1);
const groundingImages = conversationImages(promptMessages);
// Once the model has converted the image into tool arguments, the result
// round only needs the textual call and result. Re-encoding the same image
// substantially increases WebGPU prefill memory without adding information.
const enabledTools = modelToolDefinitions(state.tools);
const result = await runtime.generate(toModelConversation(promptMessages, state.systemPrompt, toolRounds === 0, enabledTools.length > 0), {
...state.generation,
tools: enabledTools,
signal: state.abortController.signal,
onToken: token => streamAssistantToken(assistant, token),
onToolCallState: phase => streamToolCallState(assistant, phase),
});
assistant.text = result.text || assistant.text;
assistant.streaming = false;
assistant.toolCalls = result.toolCalls.map((call, index) => ({
id: globalThis.crypto?.randomUUID?.() || `${Date.now()}-${index}`,
name: call.name,
arguments: call.arguments,
positional: call.positional,
rawOutput: result.rawOutput,
status: 'proposed',
statusLabel: 'requested',
}));
if (!assistant.toolCalls.length) {
const documentRegions = parseDocumentRegions(assistant.text, groundingImages.length);
if (documentRegions?.length) {
assistant.documentRegions = documentRegions;
assistant.groundingImages = groundingImages;
} else {
const groundings = parseGroundingResponse(assistant.text, groundingImages.length);
if (groundings?.length) {
assistant.groundings = groundings;
assistant.groundingImages = groundingImages;
}
}
if (!assistant.text) assistant.text = result.finishReason === 'stopped' ? 'Generation stopped.' : 'The model returned no displayable text.';
break;
}
if (toolRounds >= 3) {
assistant.toolCalls.forEach(call => { call.status = 'error'; call.statusLabel = 'round limit reached'; });
assistant.text ||= 'Tool execution stopped because the three-round limit was reached.';
state.error = 'Tool execution exceeded the three-round safety limit.';
break;
}
if (assistant.toolCalls.length > 4) {
assistant.toolCalls.forEach(call => { call.status = 'error'; call.statusLabel = 'call limit reached'; });
assistant.text ||= 'Tool execution stopped because more than four calls were requested in one round.';
state.error = 'The model requested more than four tools in one round.';
break;
}
render();
const executions = [];
for (const call of assistant.toolCalls) {
executions.push(await executeToolCall(call, state.abortController.signal));
}
state.messages.push({ role: 'tool', text: '', executions, modelContent: JSON.stringify(executions.map(execution => ({
tool_call_id: execution.id,
name: execution.name,
status: execution.status,
...(execution.status === 'success' ? { result: execution.result } : { error: execution.error }),
}))) });
toolRounds += 1;
scrollToBottomAfterRender = true;
render();
}
} catch (error) {
if (error.name !== 'AbortError') state.error = error.message;
const assistant = state.messages.at(-1)?.role === 'assistant' ? state.messages.at(-1) : null;
const pendingCall = assistant?.toolCalls?.find(call => call.pending);
if (pendingCall) {
pendingCall.status = 'error';
pendingCall.statusLabel = error.name === 'AbortError' ? 'stopped' : 'request failed';
if (error.rawModelOutput) pendingCall.rawOutput = error.rawModelOutput;
}
if (assistant && !assistant.text && !assistant.toolCalls?.length) assistant.text = error.name === 'AbortError' ? 'Generation stopped.' : 'I could not complete that response.';
} finally {
const assistant = [...state.messages].reverse().find(message => message.role === 'assistant');
if (assistant) assistant.streaming = false;
state.generating = false; state.abortController = null;
scrollToBottomAfterRender = true;
render();
}
}
function toModelConversation(messages, systemPrompt = '', includeImages = true, hasTools = false) {
const conversation = messages.map(message => {
if (message.role === 'tool') return { role: 'tool', content: message.modelContent };
const content = includeImages && message.images?.length ? [
...message.images.map(image => ({ type: 'image', value: image.url })),
{ type: 'text', value: message.text },
] : message.text;
if (message.role === 'assistant' && message.toolCalls?.length) {
return {
role: 'assistant',
content,
tool_calls: message.toolCalls.map(call => ({ type: 'function', id: call.id, function: { name: call.name, arguments: call.arguments } })),
};
}
return { role: message.role, content };
});
const internalSystemPrompt = [systemPrompt, hasTools ? TOOL_USE_POLICY : ''].filter(Boolean).join('\n\n');
return internalSystemPrompt ? [{ role: 'system', content: internalSystemPrompt }, ...conversation] : conversation;
}
function conversationImages(messages) {
return messages.flatMap(message => message.images || []);
}
function streamAssistantToken(assistant, token) {
const conversation = document.querySelector('#conversation');
const followOutput = isNearBottom(conversation);
assistant.text += token;
const textNodes = document.querySelectorAll('.message.assistant .message-text');
const textNode = textNodes[textNodes.length - 1];
if (textNode) textNode.innerHTML = renderMarkdown(assistant.text);
if (followOutput) requestAnimationFrame(() => { conversation.scrollTop = conversation.scrollHeight; });
}
function streamToolCallState(assistant, phase) {
const pending = assistant.toolCalls?.find(call => call.pending);
const statusLabel = phase === 'preparing' ? 'preparing request' : 'validating request';
if (pending) pending.statusLabel = statusLabel;
else assistant.toolCalls = [{
id: 'streaming-tool-call',
name: 'Tool call',
arguments: null,
positional: [],
pending: true,
status: 'running',
statusLabel,
}];
render();
}
async function executeToolCall(call, signal) {
const startedAt = performance.now();
let source = 'unregistered';
let outcome;
try {
const prepared = prepareToolCall(call, state.tools);
source = prepared.tool.source;
call.arguments = prepared.args;
call.status = 'running';
call.statusLabel = source === 'mcp' ? 'calling MCP server' : prepared.tool.external ? 'searching TheMealDB' : 'running locally';
render();
outcome = source === 'builtin'
? { status: 'success', result: await executeBuiltin(prepared.tool.name, prepared.args, signal) }
: { status: 'success', result: await callMcpTool(prepared.tool.name, prepared.args, signal) };
if (signal.aborted) throw new DOMException('Generation stopped.', 'AbortError');
} catch (error) {
if (error.name === 'AbortError') throw error;
outcome = { status: 'error', error: { code: 'tool_error', message: error.message } };
}
const durationMs = performance.now() - startedAt;
call.status = outcome.status;
call.statusLabel = outcome.status === 'success' ? `completed in ${Math.round(durationMs)} ms` : outcome.error.message;
const execution = { id: call.id, name: call.name, source, status: outcome.status, durationMs, ...(outcome.status === 'success' ? { result: outcome.result } : { error: outcome.error }) };
render();
return execution;
}
function isNearBottom(element, threshold = 120) {
if (!element) return false;
return element.scrollHeight - element.scrollTop - element.clientHeight <= threshold;
}
function addFiles(fileList) {
const candidates = [...fileList].filter(file => file.type?.startsWith('image/'));
const files = candidates.slice(0, Math.max(0, 6 - state.attachments.length));
state.attachments.push(...files.map((file, index) => ({
name: file.name || `Pasted image ${index + 1}`,
url: URL.createObjectURL(file),
source: 'upload',
})));
if (files.length < candidates.length) state.error = 'You can attach up to six images per message.';
render();
return files.length;
}
function handlePromptPaste(event) {
const files = imageFilesFromClipboard(event.clipboardData);
if (!files.length) return;
event.preventDefault();
const selectionStart = event.currentTarget.selectionStart;
const selectionEnd = event.currentTarget.selectionEnd;
addFiles(files);
requestAnimationFrame(() => {
const prompt = document.querySelector('#prompt');
if (!prompt) return;
prompt.focus();
prompt.setSelectionRange(selectionStart, selectionEnd);
resizePrompt({ currentTarget: prompt });
});
}
function handleComposerDragOver(event) {
if (![...(event.dataTransfer?.types || [])].includes('Files')) return;
event.preventDefault();
event.dataTransfer.dropEffect = 'copy';
event.currentTarget.classList.add('drag-active');
}
function handleComposerDragLeave(event) {
if (event.relatedTarget && event.currentTarget.contains(event.relatedTarget)) return;
event.currentTarget.classList.remove('drag-active');
}
function handleComposerDrop(event) {
event.preventDefault();
event.currentTarget.classList.remove('drag-active');
const files = imageFilesFromDataTransfer(event.dataTransfer);
if (!files.length) return;
addFiles(files);
requestAnimationFrame(() => document.querySelector('#prompt')?.focus());
}
function editMessage(index) {
const message = state.messages[index];
state.messages = state.messages.slice(0, index);
state.attachments = message.images?.slice() || [];
state.promptDraft = message.text;
render();
const prompt = document.querySelector('#prompt');
resizePrompt({ currentTarget: prompt });
prompt.focus();
}
function updateSetting(event) {
const input = event.currentTarget;
const key = input.dataset.setting;
const value = Number(input.value);
state.generation[key] = value;
const output = input.closest('label')?.querySelector('output');
if (output) output.textContent = key === 'temperature' ? value.toFixed(1) : key === 'topP' ? value.toFixed(2) : String(value);
}
function isFirefoxBasedBrowser() {
return /Firefox\//i.test(navigator.userAgent);
}
function resizePrompt(event) {
const textarea = event.currentTarget;
textarea.style.height = 'auto';
textarea.style.height = `${Math.min(textarea.scrollHeight, 180)}px`;
}
const webcamSession = new WebcamSession(constraints => navigator.mediaDevices.getUserMedia(constraints));
async function startWebcam() {
try {
const stream = await webcamSession.open({ video: { facingMode: 'environment', width: { ideal: 1280 } }, audio: false });
if (!stream || !state.webcamOpen) return;
const video = document.querySelector('#webcam-video');
if (video) { video.srcObject = stream; video.addEventListener('loadeddata', () => document.querySelector('.camera-wait')?.remove(), { once: true }); }
} catch (error) {
if (!state.webcamOpen) return;
state.error = `Camera unavailable: ${error.message}`; closeWebcam();
}
}
function captureWebcam() {
const video = document.querySelector('#webcam-video');
if (!video?.videoWidth) return;
const max = 1280;
const scale = Math.min(1, max / video.videoWidth);
const canvas = document.createElement('canvas');
canvas.width = Math.round(video.videoWidth * scale); canvas.height = Math.round(video.videoHeight * scale);
const context = canvas.getContext('2d');
context.translate(canvas.width, 0);
context.scale(-1, 1);
context.drawImage(video, 0, 0, canvas.width, canvas.height);
state.attachments.push({ name: `Webcam ${new Date().toLocaleTimeString()}`, url: canvas.toDataURL('image/jpeg', 0.9), source: 'webcam' });
closeWebcam();
}
function closeWebcam() {
webcamSession.close(); state.webcamOpen = false; render();
}
async function updateCacheInfo() {
state.cache = await runtime.cacheInfo();
render();
}
runtime.addEventListener('progress', event => { state.progress = event.detail; if (state.loading) render(); });
runtime.addEventListener('status', () => render());
document.addEventListener('click', event => {
if (!state.settingsOpen) return;
if (event.target.closest('.settings-popover, [data-action="settings"]')) return;
state.settingsOpen = false;
render();
});
document.addEventListener('keydown', event => {
if (event.key === 'Escape' && state.lightbox) {
preserveConversationScroll();
state.lightbox = null;
render();
}
});
window.addEventListener('beforeunload', () => { void disconnectMcpServer(); });
render();
updateCacheInfo();