Spaces:
Running
Running
| 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 => ({ '&': '&', '<': '<', '>': '>', "'": ''', '"': '"' })[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(); | |