shubeydoo commited on
Commit
6c30253
·
0 Parent(s):

Initial release

Browse files
.dockerignore ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ .git
2
+ .env
3
+ .env.*
4
+ dist
5
+ node_modules
6
+ npm-debug.log*
.gitattributes ADDED
@@ -0,0 +1,38 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ *.7z filter=lfs diff=lfs merge=lfs -text
2
+ *.arrow filter=lfs diff=lfs merge=lfs -text
3
+ *.bin filter=lfs diff=lfs merge=lfs -text
4
+ *.bz2 filter=lfs diff=lfs merge=lfs -text
5
+ *.ckpt filter=lfs diff=lfs merge=lfs -text
6
+ *.ftz filter=lfs diff=lfs merge=lfs -text
7
+ *.gz filter=lfs diff=lfs merge=lfs -text
8
+ *.h5 filter=lfs diff=lfs merge=lfs -text
9
+ *.joblib filter=lfs diff=lfs merge=lfs -text
10
+ *.lfs.* filter=lfs diff=lfs merge=lfs -text
11
+ *.mlmodel filter=lfs diff=lfs merge=lfs -text
12
+ *.model filter=lfs diff=lfs merge=lfs -text
13
+ *.msgpack filter=lfs diff=lfs merge=lfs -text
14
+ *.npy filter=lfs diff=lfs merge=lfs -text
15
+ *.npz filter=lfs diff=lfs merge=lfs -text
16
+ *.onnx filter=lfs diff=lfs merge=lfs -text
17
+ *.ot filter=lfs diff=lfs merge=lfs -text
18
+ *.parquet filter=lfs diff=lfs merge=lfs -text
19
+ *.pb filter=lfs diff=lfs merge=lfs -text
20
+ *.pickle filter=lfs diff=lfs merge=lfs -text
21
+ *.pkl filter=lfs diff=lfs merge=lfs -text
22
+ *.pt filter=lfs diff=lfs merge=lfs -text
23
+ *.pth filter=lfs diff=lfs merge=lfs -text
24
+ *.rar filter=lfs diff=lfs merge=lfs -text
25
+ *.safetensors filter=lfs diff=lfs merge=lfs -text
26
+ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
27
+ *.tar.* filter=lfs diff=lfs merge=lfs -text
28
+ *.tar filter=lfs diff=lfs merge=lfs -text
29
+ *.tflite filter=lfs diff=lfs merge=lfs -text
30
+ *.tgz filter=lfs diff=lfs merge=lfs -text
31
+ *.wasm filter=lfs diff=lfs merge=lfs -text
32
+ *.xz filter=lfs diff=lfs merge=lfs -text
33
+ *.zip filter=lfs diff=lfs merge=lfs -text
34
+ *.zst filter=lfs diff=lfs merge=lfs -text
35
+ *tfevents* filter=lfs diff=lfs merge=lfs -text
36
+ public/LFM2-VL-tech-report.png filter=lfs diff=lfs merge=lfs -text
37
+ public/charcuterie.jpg filter=lfs diff=lfs merge=lfs -text
38
+ public/pad-thai.jpeg filter=lfs diff=lfs merge=lfs -text
.gitignore ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ node_modules/
2
+ dist/
3
+ .env
4
+ .env.local
5
+ npm-debug.log*
Dockerfile ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM node:22-alpine AS builder
2
+ WORKDIR /app
3
+ COPY package*.json ./
4
+ RUN npm ci
5
+ COPY . .
6
+ RUN npm run build
7
+
8
+ FROM node:22-alpine
9
+ ENV NODE_ENV=production
10
+ WORKDIR /app
11
+ COPY package*.json ./
12
+ RUN npm ci --omit=dev
13
+ COPY --from=builder --chown=node:node /app/dist ./dist
14
+ COPY --chown=node:node server ./server
15
+ USER node
16
+ EXPOSE 7860
17
+ CMD ["node", "server/index.js"]
README.md ADDED
@@ -0,0 +1,42 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: LFM2.5-VL-3B WebGPU
3
+ emoji: 💧
4
+ colorFrom: purple
5
+ colorTo: purple
6
+ sdk: docker
7
+ pinned: false
8
+ models:
9
+ - LiquidAI/LFM2.5-VL-3B
10
+ - LiquidAI/LFM2.5-VL-3B-ONNX
11
+ short_description: Run LFM2.5-VL-3B locally in your browser with WebGPU
12
+ ---
13
+
14
+ # LFM2.5-VL-3B WebGPU
15
+
16
+ Run the Q4 ONNX export of LFM2.5-VL-3B directly in a WebGPU-capable browser. Model files are downloaded from Hugging Face and cached locally; prompts, images, and generated responses remain on the user's device unless an explicitly enabled external tool is called.
17
+
18
+ ## Features
19
+
20
+ - Text and multiple-image conversations
21
+ - Paste, drag-and-drop, file upload, and webcam image input
22
+ - Grounding and document-layout overlays
23
+ - Built-in and MCP tool calling
24
+ - Editable system prompts and generation settings
25
+ - Persistent browser model cache
26
+ - Strict WebGPU execution with visible runtime warnings and errors
27
+
28
+ ## Local development
29
+
30
+ ```bash
31
+ npm install
32
+ npm run dev
33
+ ```
34
+
35
+ WebGPU requires HTTPS or localhost.
36
+
37
+ For a production-style local run:
38
+
39
+ ```bash
40
+ npm run build
41
+ npm start
42
+ ```
eslint.config.js ADDED
@@ -0,0 +1,25 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import globals from 'globals';
2
+
3
+ export default [
4
+ {
5
+ ignores: ['dist/**', 'node_modules/**'],
6
+ },
7
+ {
8
+ files: ['**/*.js'],
9
+ languageOptions: {
10
+ ecmaVersion: 'latest',
11
+ sourceType: 'module',
12
+ globals: globals.browser,
13
+ },
14
+ rules: {
15
+ 'no-unused-vars': ['error', { argsIgnorePattern: '^_' }],
16
+ 'no-undef': 'error',
17
+ },
18
+ },
19
+ {
20
+ files: ['server/**/*.js', 'test/**/*.js'],
21
+ languageOptions: {
22
+ globals: globals.node,
23
+ },
24
+ },
25
+ ];
index.html ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <meta name="theme-color" content="#10100f" />
7
+ <meta name="description" content="On-device vision-language inference with LFM2.5-VL-3B and WebGPU." />
8
+ <title>LFM2.5-VL-3B · WebGPU</title>
9
+ </head>
10
+ <body>
11
+ <div id="app"></div>
12
+ <script type="module" src="/src/main.js"></script>
13
+ </body>
14
+ </html>
package-lock.json ADDED
The diff for this file is too large to render. See raw diff
 
package.json ADDED
@@ -0,0 +1,32 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "lfm2-5-vl-3b-webgpu",
3
+ "private": true,
4
+ "version": "0.1.0",
5
+ "type": "module",
6
+ "scripts": {
7
+ "dev": "vite --host 0.0.0.0",
8
+ "start": "node server/index.js",
9
+ "build": "vite build",
10
+ "preview": "vite preview --host 0.0.0.0 --port 7860",
11
+ "test": "node --test",
12
+ "check": "eslint . && node --test && vite build"
13
+ },
14
+ "dependencies": {
15
+ "@huggingface/transformers": "4.2.0",
16
+ "@modelcontextprotocol/sdk": "1.30.0",
17
+ "dompurify": "^3.4.13",
18
+ "express": "5.2.1",
19
+ "lucide": "0.468.0",
20
+ "marked": "^18.0.9",
21
+ "onnxruntime-web": "1.26.0-dev.20260416-b7804b056c"
22
+ },
23
+ "devDependencies": {
24
+ "eslint": "9.39.5",
25
+ "globals": "16.4.0",
26
+ "vite": "8.2.0"
27
+ },
28
+ "overrides": {
29
+ "adm-zip": "0.6.0",
30
+ "sharp": "0.35.3"
31
+ }
32
+ }
public/LFM2-VL-tech-report.png ADDED

Git LFS Details

  • SHA256: 29ac81f22109c6643b4d378ff27cef49c2bcb76649f0688d18f560f193f17b5b
  • Pointer size: 131 Bytes
  • Size of remote file: 507 kB
public/charcuterie.jpg ADDED

Git LFS Details

  • SHA256: 597703edba9a3871abf424fd68e2c47dd47bc75096f49f719cb63267ade49a0b
  • Pointer size: 132 Bytes
  • Size of remote file: 1.96 MB
public/liquid-hero-white.png ADDED
public/liquid-mark.svg ADDED
public/model-manifest.json ADDED
@@ -0,0 +1,27 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "schemaVersion": 1,
3
+ "model": "LiquidAI/LFM2.5-VL-3B-ONNX",
4
+ "revision": "main",
5
+ "quantization": "q4",
6
+ "engine": {
7
+ "format": "onnx",
8
+ "adapter": "onnx-transformers"
9
+ },
10
+ "runtime": {
11
+ "device": "webgpu",
12
+ "dtype": {
13
+ "decoder_model_merged": "q4",
14
+ "vision_encoder": "q4",
15
+ "embed_tokens": "fp32"
16
+ },
17
+ "externalDataChunks": {
18
+ "decoder_model_merged_q4.onnx": 5,
19
+ "vision_encoder_q4.onnx": 1
20
+ }
21
+ },
22
+ "artifacts": [
23
+ { "component": "decoder", "bytes": 2604444865 },
24
+ { "component": "vision_encoder", "bytes": 269391474 },
25
+ { "component": "token_embeddings", "bytes": 1048576359 }
26
+ ]
27
+ }
public/pad-thai.jpeg ADDED

Git LFS Details

  • SHA256: 60fe4c2f42e64d9760ef29f945605d3d16543287720544d5cc99410022fa7be3
  • Pointer size: 132 Bytes
  • Size of remote file: 1.13 MB
server/app.js ADDED
@@ -0,0 +1,25 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import express from 'express';
2
+ import { resolve } from 'node:path';
3
+
4
+ export function createApp({ distDir = resolve(process.cwd(), 'dist') } = {}) {
5
+ const app = express();
6
+ app.disable('x-powered-by');
7
+ app.use((_request, response, next) => {
8
+ response.set({
9
+ 'Cross-Origin-Opener-Policy': 'same-origin',
10
+ 'Cross-Origin-Embedder-Policy': 'require-corp',
11
+ 'Cross-Origin-Resource-Policy': 'same-origin',
12
+ });
13
+ next();
14
+ });
15
+
16
+ app.use('/assets', express.static(resolve(distDir, 'assets'), {
17
+ immutable: true,
18
+ maxAge: '1y',
19
+ setHeaders: response => response.setHeader('Cache-Control', 'public, max-age=31536000, immutable'),
20
+ }));
21
+ app.use(express.static(distDir, { index: false }));
22
+ app.use((_request, response) => response.sendFile(resolve(distDir, 'index.html')));
23
+
24
+ return { app };
25
+ }
server/index.js ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { createApp } from './app.js';
2
+
3
+ const port = Number(process.env.PORT || 7860);
4
+ const { app } = createApp();
5
+ const server = app.listen(port, '0.0.0.0', () => {
6
+ console.log(`[LFM WebGPU] listening on ${port}`);
7
+ });
8
+
9
+ function shutdown() {
10
+ server.close(() => {
11
+ process.exitCode = 0;
12
+ });
13
+ setTimeout(() => server.closeAllConnections?.(), 5_000).unref?.();
14
+ }
15
+
16
+ process.once('SIGINT', shutdown);
17
+ process.once('SIGTERM', shutdown);
src/document-parsing.js ADDED
@@ -0,0 +1,62 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const COORDINATE = '(\\d+(?:\\.\\d+)?)';
2
+ const HEADER_PATTERN = new RegExp(`^image_index\\s*=\\s*(\\d+)\\s+([^\\[\\]\\n]+?)\\s+\\[\\s*${COORDINATE}\\s*,\\s*${COORDINATE}\\s*,\\s*${COORDINATE}\\s*,\\s*${COORDINATE}\\s*\\]\\s*$`);
3
+
4
+ function normalizeRegion(match, content, imageCount) {
5
+ if (!content) return null;
6
+ const [, rawImageIndex, rawLabel, ...rawCoordinates] = match;
7
+ const imageId = Number(rawImageIndex);
8
+ const label = rawLabel.trim();
9
+ let coordinates = rawCoordinates.map(Number);
10
+ if (imageId < 0 || imageId >= imageCount || !label) return null;
11
+ if (coordinates.every(value => value >= 0 && value <= 1)) {
12
+ coordinates = coordinates.map(value => Math.round(value * 1000));
13
+ } else if (coordinates.some(value => !Number.isInteger(value) || value < 0 || value > 1000)) {
14
+ return null;
15
+ }
16
+ const [xmin, ymin, xmax, ymax] = coordinates;
17
+ if (xmax <= xmin || ymax <= ymin) return null;
18
+ return { imageId, label, type: 'box', coordinates, content };
19
+ }
20
+
21
+ export function parseDocumentRegions(text, imageCount) {
22
+ if (!Number.isInteger(imageCount) || imageCount < 1 || typeof text !== 'string' || !text.trim()) return null;
23
+ const lines = text.trim().replace(/\r\n?/g, '\n').split('\n');
24
+ const regions = [];
25
+ let currentMatch = null;
26
+ let contentLines = [];
27
+ let sawHeader = false;
28
+
29
+ const finishCurrentRegion = () => {
30
+ if (!currentMatch) return;
31
+ const region = normalizeRegion(currentMatch, contentLines.join('\n').trim(), imageCount);
32
+ if (region) regions.push(region);
33
+ };
34
+
35
+ for (const line of lines) {
36
+ const match = HEADER_PATTERN.exec(line);
37
+ if (match) {
38
+ if (currentMatch) {
39
+ finishCurrentRegion();
40
+ } else if (!sawHeader && contentLines.some(value => value.trim())) {
41
+ return null;
42
+ }
43
+ sawHeader = true;
44
+ currentMatch = match;
45
+ contentLines = [];
46
+ } else if (/^\s*image_index\b/.test(line)) {
47
+ finishCurrentRegion();
48
+ sawHeader = true;
49
+ currentMatch = null;
50
+ contentLines = [];
51
+ } else if (currentMatch || !sawHeader) {
52
+ contentLines.push(line);
53
+ } else {
54
+ // Ignore content belonging to an incomplete or malformed region while
55
+ // retaining complete regions that were already parsed.
56
+ }
57
+ }
58
+
59
+ finishCurrentRegion();
60
+
61
+ return regions.length ? regions : null;
62
+ }
src/engines/conversation-preparation.js ADDED
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ export function prepareConversation(messages) {
2
+ const imageUrls = [];
3
+ const preparedMessages = messages.map(message => {
4
+ if (!Array.isArray(message.content)) {
5
+ const prepared = { role: message.role, content: message.content };
6
+ if (message.tool_calls) prepared.tool_calls = message.tool_calls;
7
+ return prepared;
8
+ }
9
+
10
+ const imageCount = message.content.filter(item => item.type === 'image').length;
11
+ let imageIndex = 0;
12
+ const content = message.content.flatMap(item => {
13
+ if (item.type !== 'image') return [{ type: 'text', text: item.value || '' }];
14
+ imageUrls.push(item.value);
15
+ imageIndex += 1;
16
+ if (imageCount < 2) return [{ type: 'image' }];
17
+ return [
18
+ { type: 'text', text: `Media-${imageIndex}` },
19
+ { type: 'image' },
20
+ { type: 'text', text: '\n' },
21
+ ];
22
+ });
23
+ const prepared = { role: message.role, content };
24
+ if (message.tool_calls) prepared.tool_calls = message.tool_calls;
25
+ return prepared;
26
+ });
27
+ return { messages: preparedMessages, imageUrls };
28
+ }
src/engines/onnx-transformers-engine.js ADDED
@@ -0,0 +1,346 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import {
2
+ AutoConfig,
3
+ AutoModelForImageTextToText,
4
+ AutoProcessor,
5
+ env,
6
+ InterruptableStoppingCriteria,
7
+ RawImage,
8
+ TextStreamer,
9
+ } from '@huggingface/transformers';
10
+ import { createToolStreamFilter, displayTextFromRaw, parseToolCalls } from '../tools/tool-protocol.js';
11
+ import { prepareConversation } from './conversation-preparation.js';
12
+
13
+ const MODEL_CACHE_KEY = 'liquid-lfm-models-v4';
14
+
15
+ export async function createEngine({ manifest, telemetry }) {
16
+ let model = null;
17
+ let processor = null;
18
+ let gpuDevice = null;
19
+ const gpuHealth = {
20
+ lost: false,
21
+ lossReason: null,
22
+ lossMessage: null,
23
+ lastUncapturedError: null,
24
+ };
25
+ const repo = manifest.model;
26
+ let revision = manifest.revision || 'main';
27
+
28
+ function emit(level, message, detail = '') {
29
+ telemetry({ level, message, detail });
30
+ }
31
+
32
+ function configureRuntime() {
33
+ env.allowLocalModels = false;
34
+ env.allowRemoteModels = true;
35
+ env.useBrowserCache = true;
36
+ env.cacheKey = MODEL_CACHE_KEY;
37
+ env.backends.onnx.logLevel = 'warning';
38
+ env.backends.onnx.webgpu.powerPreference = 'high-performance';
39
+ }
40
+
41
+ async function attachGpuErrorHandlers() {
42
+ gpuDevice = await env.backends.onnx.webgpu.device;
43
+ if (!gpuDevice) return;
44
+
45
+ gpuDevice.addEventListener?.('uncapturederror', event => {
46
+ const error = event.error;
47
+ const detail = {
48
+ type: error?.constructor?.name || error?.name || 'GPUError',
49
+ message: error?.message || String(error || 'Unknown WebGPU error'),
50
+ };
51
+ gpuHealth.lastUncapturedError = { ...detail, time: new Date().toISOString() };
52
+ emit('error', 'WebGPU reported an uncaptured error', detail);
53
+ });
54
+
55
+ void gpuDevice.lost.then(info => {
56
+ gpuHealth.lost = true;
57
+ gpuHealth.lossReason = info?.reason || 'unknown';
58
+ gpuHealth.lossMessage = info?.message || '';
59
+ emit('error', 'WebGPU device lost', {
60
+ reason: gpuHealth.lossReason,
61
+ message: gpuHealth.lossMessage,
62
+ recovery: 'Reload the page to create a fresh GPU device. Cached model files will be reused.',
63
+ });
64
+ });
65
+ }
66
+
67
+ function progressHandler(onProgress) {
68
+ let lastPercent = -1;
69
+ let lastLoaded = 0;
70
+ let lastSampleAt = performance.now();
71
+ let lastObservedLoaded = 0;
72
+ let smoothedBytesPerSecond = 0;
73
+ const handler = progress => {
74
+ if (progress.status !== 'progress_total') return;
75
+ const percent = Math.max(0, Math.min(100, progress.progress || 0));
76
+ if (progress.loaded > lastObservedLoaded) {
77
+ lastObservedLoaded = progress.loaded;
78
+ handler.lastUpdateAt = performance.now();
79
+ }
80
+ if (percent - lastPercent < 0.25 && percent < 100) return;
81
+ lastPercent = percent;
82
+ const now = performance.now();
83
+ const elapsedSeconds = (now - lastSampleAt) / 1000;
84
+ if (elapsedSeconds >= 0.5 && progress.loaded >= lastLoaded) {
85
+ const currentRate = (progress.loaded - lastLoaded) / elapsedSeconds;
86
+ smoothedBytesPerSecond = smoothedBytesPerSecond
87
+ ? (smoothedBytesPerSecond * 0.7) + (currentRate * 0.3)
88
+ : currentRate;
89
+ lastLoaded = progress.loaded;
90
+ lastSampleAt = now;
91
+ }
92
+ const activeFiles = Object.entries(progress.files || {}).filter(([, value]) => value.loaded < value.total);
93
+ const activeName = [...activeFiles].sort(([, left], [, right]) => (right.total - right.loaded) - (left.total - left.loaded))[0]?.[0];
94
+ const activeLabel = activeName?.split('/').at(-1);
95
+ const rate = smoothedBytesPerSecond > 0 ? formatRate(smoothedBytesPerSecond) : '';
96
+ const remainingSeconds = smoothedBytesPerSecond > 0
97
+ ? Math.max(0, progress.total - progress.loaded) / smoothedBytesPerSecond
98
+ : 0;
99
+ const eta = remainingSeconds > 1 ? formatDuration(remainingSeconds) : '';
100
+ const transferSummary = [rate, eta ? `${eta} left` : ''].filter(Boolean).join(' · ');
101
+ const file = activeFiles.length > 1
102
+ ? `Downloading ${activeLabel} +${activeFiles.length - 1}${transferSummary ? ` · ${transferSummary}` : ''}`
103
+ : activeLabel || `Preparing ${repo}`;
104
+ const snapshot = {
105
+ status: 'loading',
106
+ progress: percent,
107
+ file,
108
+ loaded: progress.loaded,
109
+ total: progress.total,
110
+ activeDownloads: activeFiles.length,
111
+ activeFile: activeName || null,
112
+ bytesPerSecond: Math.round(smoothedBytesPerSecond),
113
+ };
114
+ handler.latest = snapshot;
115
+ handler.lastUpdateAt = performance.now();
116
+ onProgress(snapshot);
117
+ };
118
+ handler.latest = null;
119
+ handler.lastUpdateAt = performance.now();
120
+ return handler;
121
+ }
122
+
123
+ return {
124
+ backend: 'ONNX · Transformers.js · strict WebGPU',
125
+
126
+ async load(onProgress) {
127
+ configureRuntime();
128
+ await navigator.storage?.persist?.().catch(() => false);
129
+ if (revision === 'main') {
130
+ revision = await resolveMainRevision(repo);
131
+ }
132
+
133
+ const options = {
134
+ revision,
135
+ device: 'webgpu',
136
+ dtype: manifest.runtime.dtype,
137
+ use_external_data_format: manifest.runtime.externalDataChunks,
138
+ session_options: {
139
+ executionProviders: ['webgpu'],
140
+ logSeverityLevel: 2,
141
+ },
142
+ };
143
+
144
+ const trackedProgress = progressHandler(onProgress);
145
+ options.progress_callback = trackedProgress;
146
+ const modelConfig = await AutoConfig.from_pretrained(repo, { ...options, progress_callback: null });
147
+ modelConfig['transformers.js_config'] = {
148
+ ...(modelConfig['transformers.js_config'] || {}),
149
+ dtype: manifest.runtime.dtype,
150
+ device: manifest.runtime.device,
151
+ use_external_data_format: manifest.runtime.externalDataChunks,
152
+ };
153
+ options.config = modelConfig;
154
+ let stallReported = false;
155
+ const stallWatchdog = setInterval(() => {
156
+ const idleSeconds = Math.floor((performance.now() - trackedProgress.lastUpdateAt) / 1000);
157
+ if (!trackedProgress.latest || trackedProgress.latest.progress >= 100 || idleSeconds < 30) return;
158
+ onProgress({
159
+ ...trackedProgress.latest,
160
+ bytesPerSecond: 0,
161
+ file: `No transfer progress for ${idleSeconds}s · waiting on a large model shard`,
162
+ });
163
+ if (!stallReported && idleSeconds >= 90) {
164
+ stallReported = true;
165
+ emit('warn', 'Model download has not advanced for 90 seconds', {
166
+ progress: Math.round(trackedProgress.latest.progress),
167
+ activeDownloads: trackedProgress.latest.activeDownloads,
168
+ activeFile: trackedProgress.latest.activeFile,
169
+ likelyCause: 'Large browser shard, memory pressure, cache serialization, or interrupted CDN stream',
170
+ });
171
+ }
172
+ }, 5000);
173
+ try {
174
+ [processor, model] = await Promise.all([
175
+ AutoProcessor.from_pretrained(repo, options),
176
+ AutoModelForImageTextToText.from_pretrained(repo, options),
177
+ ]);
178
+ } finally {
179
+ clearInterval(stallWatchdog);
180
+ }
181
+
182
+ await attachGpuErrorHandlers();
183
+ onProgress({ status: 'done', progress: 100, file: 'Model ready' });
184
+ },
185
+
186
+ async generate(messages, options = {}) {
187
+ if (!model || !processor) throw new Error('The model is not loaded.');
188
+ const prepared = prepareConversation(messages);
189
+ const prompt = processor.apply_chat_template(prepared.messages, {
190
+ add_generation_prompt: true,
191
+ tokenize: false,
192
+ tools: options.tools || [],
193
+ });
194
+ const images = await Promise.all(prepared.imageUrls.map(url => RawImage.read(url)));
195
+ const inputs = images.length
196
+ ? await processor(images, prompt)
197
+ : processor.tokenizer(prompt, { add_special_tokens: false });
198
+ const promptTokens = inputs.input_ids?.dims?.at(-1)
199
+ ?? inputs.inputs_embeds?.dims?.at(-2)
200
+ ?? inputs.attention_mask?.dims?.at(-1)
201
+ ?? null;
202
+ const stopping = new InterruptableStoppingCriteria();
203
+ const abort = () => stopping.interrupt();
204
+ options.signal?.addEventListener('abort', abort, { once: true });
205
+ let streamedText = '';
206
+ const streamFilter = createToolStreamFilter(chunk => {
207
+ streamedText += chunk;
208
+ options.onToken?.(chunk, null);
209
+ }, {
210
+ onToolCallStart: () => options.onToolCallState?.('preparing'),
211
+ onToolCallEnd: () => options.onToolCallState?.('parsing'),
212
+ });
213
+ const streamer = new TextStreamer(processor.tokenizer, {
214
+ skip_prompt: true,
215
+ skip_special_tokens: false,
216
+ callback_function: chunk => streamFilter.push(chunk),
217
+ });
218
+
219
+ let generated;
220
+ try {
221
+ try {
222
+ generated = await model.generate({
223
+ ...inputs,
224
+ max_new_tokens: options.maxNewTokens || 384,
225
+ do_sample: (options.temperature || 0) > 0,
226
+ temperature: Math.max(options.temperature || 0, 0.01),
227
+ top_p: options.topP || 0.9,
228
+ top_k: Number.isInteger(options.topK) ? options.topK : 50,
229
+ streamer,
230
+ stopping_criteria: [stopping],
231
+ });
232
+ } catch (error) {
233
+ const isInvalidBuffer = /Mapping WebGPU buffer failed: Invalid buffer/i.test(error.message);
234
+ const gpuError = gpuHealth.lastUncapturedError;
235
+ const isOutOfMemory = gpuError?.type === 'GPUOutOfMemoryError'
236
+ || /out of memory/i.test(gpuError?.message || '')
237
+ || /out of memory/i.test(error.message);
238
+ emit('error', isInvalidBuffer ? 'WebGPU buffer readback failed' : 'WebGPU generation failed', {
239
+ error: error.message,
240
+ promptTokens,
241
+ requestedMaxNewTokens: options.maxNewTokens || 384,
242
+ deviceLost: gpuHealth.lost,
243
+ deviceLossReason: gpuHealth.lossReason,
244
+ deviceLossMessage: gpuHealth.lossMessage,
245
+ lastUncapturedGpuError: gpuHealth.lastUncapturedError,
246
+ interpretation: isInvalidBuffer
247
+ ? 'ORT could not map a GPU result staging buffer. Inspect the preceding GPU error/device-loss event; this is not a context-length error by itself.'
248
+ : null,
249
+ });
250
+ if (isOutOfMemory) {
251
+ throw new Error("This browser's WebGPU session ran out of available GPU memory. This is a browser/WebGPU memory limit, not the model's context-length limit. Try a shorter conversation, fewer or smaller images, or a lower max-new-tokens setting, then reload the page.", { cause: error });
252
+ }
253
+ if (isInvalidBuffer) {
254
+ throw new Error('The browser could not read a WebGPU model buffer. This often follows browser GPU-memory or resource exhaustion. Reload the page to reset the GPU session; cached model files will be reused.', { cause: error });
255
+ }
256
+ throw error;
257
+ }
258
+ } finally {
259
+ streamFilter.finish();
260
+ options.signal?.removeEventListener('abort', abort);
261
+ }
262
+
263
+ const sequences = generated?.sequences ?? generated;
264
+ const promptLength = inputs.input_ids?.dims?.at(-1) || 0;
265
+ const generatedIds = sequences?.tolist?.()?.[0]?.slice(promptLength) || [];
266
+ const decodedText = generatedIds.length
267
+ ? processor.tokenizer.decode(generatedIds, { skip_special_tokens: false }).trim()
268
+ : '';
269
+ const rawText = decodedText || streamedText.trim();
270
+ let toolCalls;
271
+ try {
272
+ toolCalls = parseToolCalls(rawText);
273
+ } catch (error) {
274
+ error.rawModelOutput = rawText;
275
+ emit('warn', 'Tool-call parsing failed', {
276
+ error: error.message,
277
+ rawOutputCharacters: rawText.length,
278
+ });
279
+ throw error;
280
+ }
281
+ const finalText = displayTextFromRaw(rawText) || streamedText.trim();
282
+ if (!finalText) {
283
+ emit('warn', 'Generation returned no displayable text', {
284
+ sequenceTokens: generatedIds.length,
285
+ });
286
+ }
287
+ return {
288
+ text: finalText,
289
+ toolCalls,
290
+ rawOutput: rawText,
291
+ finishReason: toolCalls.length ? 'tool_calls' : options.signal?.aborted ? 'stopped' : 'stop',
292
+ };
293
+ },
294
+
295
+ async clearCache() {
296
+ if (!globalThis.caches) return { cleared: false, entriesDeleted: 0 };
297
+ const cacheName = env.cacheKey || MODEL_CACHE_KEY;
298
+ const cache = await caches.open(cacheName);
299
+ const entriesDeleted = (await cache.keys()).length;
300
+ const cleared = await caches.delete(cacheName);
301
+ if (!cleared) emit('warn', 'Browser model cache was already empty', { cacheName, entriesDeleted });
302
+ return { cleared, entriesDeleted };
303
+ },
304
+
305
+ async cacheInfo() {
306
+ if (!globalThis.caches) return { used: 0, available: 0 };
307
+ const cache = await caches.open(env.cacheKey || MODEL_CACHE_KEY);
308
+ const keys = await cache.keys();
309
+ let used = 0;
310
+ for (const key of keys) {
311
+ const response = await cache.match(key);
312
+ used += Number(response?.headers.get('content-length') || 0);
313
+ }
314
+ const estimate = await navigator.storage?.estimate?.();
315
+ return { used, available: estimate?.quota || 0 };
316
+ },
317
+
318
+ clearConversationCache() {},
319
+
320
+ async dispose() {
321
+ await model?.dispose?.();
322
+ model = null;
323
+ processor = null;
324
+ },
325
+ };
326
+ }
327
+
328
+ async function resolveMainRevision(repo) {
329
+ const response = await env.fetch(`https://huggingface.co/api/models/${repo}`);
330
+ if (!response.ok) throw new Error(`Could not resolve the model main branch (${response.status}).`);
331
+ const info = await response.json();
332
+ if (!/^[0-9a-f]{40}$/i.test(info.sha || '')) throw new Error('Hugging Face returned an invalid model revision.');
333
+ return info.sha;
334
+ }
335
+
336
+ function formatRate(bytesPerSecond) {
337
+ return `${(bytesPerSecond / 1024 / 1024).toFixed(1)} MB/s`;
338
+ }
339
+
340
+ function formatDuration(seconds) {
341
+ if (seconds < 60) return `${Math.ceil(seconds)}s`;
342
+ const minutes = Math.ceil(seconds / 60);
343
+ if (minutes < 60) return `${minutes}m`;
344
+ const hours = Math.floor(minutes / 60);
345
+ return `${hours}h ${minutes % 60}m`;
346
+ }
src/grounding.js ADDED
@@ -0,0 +1,95 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const ALLOWED_KEYS = new Set(['image_id', 'label', 'bbox_2d', 'point_2d']);
2
+
3
+ function normalizeCoordinateArray(value, length) {
4
+ if (!Array.isArray(value) || value.length !== length ||
5
+ !value.every(coordinate => typeof coordinate === 'number' && Number.isFinite(coordinate))) return null;
6
+ if (value.every(coordinate => coordinate >= 0 && coordinate <= 1)) {
7
+ return value.map(coordinate => Math.round(coordinate * 1000));
8
+ }
9
+ if (value.every(coordinate => Number.isInteger(coordinate) && coordinate >= 0 && coordinate <= 1000)) {
10
+ return value.slice();
11
+ }
12
+ return null;
13
+ }
14
+
15
+ function parseBareBoxes(text) {
16
+ const number = '(-?(?:\\d+(?:\\.\\d*)?|\\.\\d+)(?:[eE][+-]?\\d+)?)';
17
+ const pattern = new RegExp(`\\[\\s*${number}\\s*,\\s*${number}\\s*,\\s*${number}\\s*,\\s*${number}\\s*\\]`, 'g');
18
+ const boxes = [];
19
+ for (const match of text.matchAll(pattern)) {
20
+ const coordinates = normalizeCoordinateArray(match.slice(1, 5).map(Number), 4);
21
+ if (!coordinates) continue;
22
+ const [xmin, ymin, xmax, ymax] = coordinates;
23
+ if (xmax <= xmin || ymax <= ymin) continue;
24
+ boxes.push({
25
+ imageId: 0,
26
+ label: boxes.length ? `Bounding box ${boxes.length + 1}` : 'Bounding box',
27
+ type: 'box',
28
+ coordinates,
29
+ });
30
+ }
31
+ return boxes;
32
+ }
33
+
34
+ export function parseGroundingResponse(text, imageCount) {
35
+ if (!Number.isInteger(imageCount) || imageCount < 1 || typeof text !== 'string') return null;
36
+ let parsed;
37
+ try {
38
+ parsed = JSON.parse(text.trim());
39
+ } catch {
40
+ const boxes = parseBareBoxes(text);
41
+ return boxes.length ? boxes : null;
42
+ }
43
+ if (!Array.isArray(parsed)) {
44
+ const boxes = parseBareBoxes(text);
45
+ return boxes.length ? boxes : null;
46
+ }
47
+
48
+ const structuredCandidate = parsed.length === 0 || parsed.every(item => item && typeof item === 'object' && !Array.isArray(item));
49
+ if (!structuredCandidate) {
50
+ const boxes = parseBareBoxes(text);
51
+ return boxes.length ? boxes : null;
52
+ }
53
+
54
+ const normalized = [];
55
+ let structured = true;
56
+ for (const item of parsed) {
57
+ if (!item || typeof item !== 'object' || Array.isArray(item) ||
58
+ Object.keys(item).some(key => !ALLOWED_KEYS.has(key)) ||
59
+ !Number.isInteger(item.image_id) || item.image_id < 0 || item.image_id >= imageCount ||
60
+ typeof item.label !== 'string' || !item.label.trim()) {
61
+ structured = false;
62
+ break;
63
+ }
64
+
65
+ const hasBox = Object.hasOwn(item, 'bbox_2d');
66
+ const hasPoint = Object.hasOwn(item, 'point_2d');
67
+ if (hasBox === hasPoint) {
68
+ structured = false;
69
+ break;
70
+ }
71
+
72
+ if (hasBox) {
73
+ const coordinates = normalizeCoordinateArray(item.bbox_2d, 4);
74
+ if (!coordinates) {
75
+ structured = false;
76
+ break;
77
+ }
78
+ const [xmin, ymin, xmax, ymax] = coordinates;
79
+ if (xmax <= xmin || ymax <= ymin) {
80
+ structured = false;
81
+ break;
82
+ }
83
+ normalized.push({ imageId: item.image_id, label: item.label.trim(), type: 'box', coordinates });
84
+ } else {
85
+ const coordinates = normalizeCoordinateArray(item.point_2d, 2);
86
+ if (!coordinates) {
87
+ structured = false;
88
+ break;
89
+ }
90
+ normalized.push({ imageId: item.image_id, label: item.label.trim(), type: 'point', coordinates });
91
+ }
92
+ }
93
+ if (structured) return normalized;
94
+ return null;
95
+ }
src/image-input.js ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ export function imageFilesFromClipboard(clipboardData) {
2
+ const items = [...(clipboardData?.items || [])];
3
+ if (items.length) {
4
+ return items.flatMap(item => {
5
+ if (item.kind !== 'file' || !item.type?.startsWith('image/')) return [];
6
+ const file = item.getAsFile?.();
7
+ return file ? [file] : [];
8
+ });
9
+ }
10
+ return [...(clipboardData?.files || [])].filter(file => file.type?.startsWith('image/'));
11
+ }
12
+
13
+ export function imageFilesFromDataTransfer(dataTransfer) {
14
+ return [...(dataTransfer?.files || [])].filter(file => file.type?.startsWith('image/'));
15
+ }
src/main.js ADDED
@@ -0,0 +1,1088 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import {
2
+ createIcons,
3
+ Camera,
4
+ ChevronDown,
5
+ Database,
6
+ Edit3,
7
+ FileScan,
8
+ FileText,
9
+ Image,
10
+ Info,
11
+ Menu,
12
+ MessageSquarePlus,
13
+ Paperclip,
14
+ Send,
15
+ Settings2,
16
+ Scan,
17
+ ScanSearch,
18
+ Trash2,
19
+ Wrench,
20
+ X,
21
+ Zap,
22
+ } from 'lucide';
23
+ import DOMPurify from 'dompurify';
24
+ import { marked } from 'marked';
25
+ import { runtime } from './runtime/runtime.js';
26
+ import { MODEL, MODEL_REPO } from './model-config.js';
27
+ import { parseGroundingResponse } from './grounding.js';
28
+ import { parseDocumentRegions } from './document-parsing.js';
29
+ import { imageFilesFromClipboard, imageFilesFromDataTransfer } from './image-input.js';
30
+ import { WebcamSession } from './webcam-session.js';
31
+ import { executeBuiltin, loadTools, modelToolDefinitions, prepareToolCall, saveTools } from './tools/tool-registry.js';
32
+ import { callMcpTool, connectMcpServer, disconnectMcpServer } from './tools/mcp-client.js';
33
+ import './styles.css';
34
+
35
+ const iconSet = { Camera, ChevronDown, Database, Edit3, FileScan, FileText, Image, Info, Menu, MessageSquarePlus, Paperclip, Scan, ScanSearch, Send, Settings2, Trash2, Wrench, X, Zap };
36
+ const app = document.querySelector('#app');
37
+ let scrollToBottomAfterRender = false;
38
+ let conversationScrollAfterRender = null;
39
+ let lightboxItems = [];
40
+ const SYSTEM_PROMPT_KEY = 'liquid-lfm-system-prompt-v1';
41
+ const MCP_SETTINGS_KEY = 'liquid-lfm-mcp-v1';
42
+ const TOOL_USE_POLICY = [
43
+ 'You are an AI assistant with access to a set of tools.',
44
+ "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.",
45
+ 'If a tool is needed, respond with a tool call using the following format:',
46
+ '<|tool_call_start|>[tool_function_call_1, tool_function_call_2, ...]<|tool_call_end|>.',
47
+ 'Each tool function call should use Python-like syntax, e.g., calculate(expression="2 + 2").',
48
+ 'When a successful tool result includes source_url, include it as a Markdown link in the final answer.',
49
+ 'If a tool returns an error, explain the error to the user.',
50
+ 'Be concise and helpful.',
51
+ ].join(' ');
52
+ const CHARCUTERIE_SYSTEM_PROMPT = `When asked for bounding boxes for objects, return a valid JSON array.
53
+ Each array item must be an object with:
54
+ - image_id: the 0-based index of the image
55
+ - bbox_2d: [xmin, ymin, xmax, ymax] normalized integer coordinates in [0, 1000]
56
+ - label: a concise label you choose for the predicted object or region
57
+
58
+ Return one item per visible matching object or region. Return [] if none are visible.`;
59
+ const POINT_GROUNDING_SYSTEM_PROMPT = `When asked for points corresponding to objects or regions, return a valid JSON array.
60
+ Each array item must be an object with:
61
+ - image_id: the 0-based index of the image
62
+ - point_2d: [x, y] normalized integer coordinates in [0, 1000]
63
+ - label: a concise label you choose for the predicted object or region
64
+
65
+ Return one item per visible matching object or region. Return [] if none are visible.`;
66
+ const CHARCUTERIE_USER_PROMPT = 'Provide bounding boxes for the grapes on the far side of the table as well as the nearest glass';
67
+ const PAD_THAI_USER_PROMPT = 'How do I make this dish?';
68
+ 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:
69
+
70
+ image_index=<n> <label> [xmin, ymin, xmax, ymax]
71
+ <content>
72
+
73
+ where:
74
+ - 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)
75
+ - <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
76
+ - [xmin, ymin, xmax, ymax] are normalized integer coordinates in [0, 1000]
77
+ - <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
78
+
79
+ Separate each region block with one blank line. Return only the parsed regions.`;
80
+ const SYSTEM_PROMPT_PRESETS = {
81
+ boxes: CHARCUTERIE_SYSTEM_PROMPT,
82
+ points: POINT_GROUNDING_SYSTEM_PROMPT,
83
+ document: DOCUMENT_PARSING_PROMPT,
84
+ };
85
+
86
+ function loadPersistedSystemPrompt() {
87
+ try {
88
+ return localStorage.getItem(SYSTEM_PROMPT_KEY) || '';
89
+ } catch {
90
+ return '';
91
+ }
92
+ }
93
+ const initialSystemPrompt = loadPersistedSystemPrompt();
94
+ const initialMcpSettings = (() => {
95
+ try { return JSON.parse(localStorage.getItem(MCP_SETTINGS_KEY) || '{}'); } catch { return {}; }
96
+ })();
97
+
98
+ const state = {
99
+ messages: [],
100
+ attachments: [],
101
+ promptDraft: '',
102
+ loading: false,
103
+ generating: false,
104
+ settingsOpen: false,
105
+ cacheOpen: false,
106
+ toolsOpen: false,
107
+ systemPromptOpen: false,
108
+ systemPrompt: initialSystemPrompt,
109
+ exampleSystemPromptActive: false,
110
+ lightbox: null,
111
+ tools: loadTools(),
112
+ mcp: {
113
+ url: initialMcpSettings.url || 'https://gitmcp.io/huggingface/transformers.js',
114
+ enabled: new Set(Array.isArray(initialMcpSettings.enabled) ? initialMcpSettings.enabled : []),
115
+ status: 'disconnected',
116
+ error: '',
117
+ },
118
+ sidebarOpen: false,
119
+ webcamOpen: false,
120
+ progress: { progress: 0, file: 'Waiting to load' },
121
+ cache: null,
122
+ adapter: null,
123
+ error: '',
124
+ generation: { maxNewTokens: 1024, temperature: 0.2, topP: 0.9, topK: 50 },
125
+ abortController: null,
126
+ };
127
+ let exampleConfigSnapshot = null;
128
+
129
+ function restoreExampleConfig({ clear = true } = {}) {
130
+ if (!exampleConfigSnapshot) return;
131
+ state.systemPrompt = exampleConfigSnapshot.systemPrompt;
132
+ state.tools.forEach(tool => {
133
+ if (exampleConfigSnapshot.toolEnabled.has(tool.id)) {
134
+ tool.enabled = exampleConfigSnapshot.toolEnabled.get(tool.id);
135
+ }
136
+ });
137
+ state.exampleSystemPromptActive = false;
138
+ if (clear) exampleConfigSnapshot = null;
139
+ }
140
+
141
+ function beginExampleConfig() {
142
+ if (!exampleConfigSnapshot) {
143
+ exampleConfigSnapshot = {
144
+ systemPrompt: state.systemPrompt,
145
+ toolEnabled: new Map(state.tools.map(tool => [tool.id, tool.enabled])),
146
+ };
147
+ } else {
148
+ restoreExampleConfig({ clear: false });
149
+ }
150
+ state.exampleSystemPromptActive = true;
151
+ }
152
+
153
+ function persistTools() {
154
+ const tools = exampleConfigSnapshot
155
+ ? state.tools.map(tool => ({
156
+ ...tool,
157
+ enabled: exampleConfigSnapshot.toolEnabled.has(tool.id)
158
+ ? exampleConfigSnapshot.toolEnabled.get(tool.id)
159
+ : tool.enabled,
160
+ }))
161
+ : state.tools;
162
+ saveTools(tools);
163
+ }
164
+
165
+ function icon(name, label = '') {
166
+ const lucideName = name.replace(/([a-z0-9])([A-Z])/g, '$1-$2').toLowerCase();
167
+ return `<i data-lucide="${lucideName}"${label ? ` aria-label="${label}"` : ''}></i>`;
168
+ }
169
+
170
+ function refreshIcons() {
171
+ createIcons({ icons: iconSet, attrs: { 'stroke-width': 1.8 } });
172
+ }
173
+
174
+ function formatBytes(bytes) {
175
+ if (!bytes) return '0 MB';
176
+ return `${(bytes / 1024 / 1024 / 1024).toFixed(2)} GB`;
177
+ }
178
+
179
+ function captureConversationScroll(conversation) {
180
+ if (!conversation) return null;
181
+ return { top: conversation.scrollTop };
182
+ }
183
+
184
+ function applyConversationScroll(conversation, snapshot, followBottom) {
185
+ if (!conversation) return;
186
+ const maximum = Math.max(0, conversation.scrollHeight - conversation.clientHeight);
187
+ if (followBottom) conversation.scrollTop = maximum;
188
+ else if (snapshot) conversation.scrollTop = snapshot.top;
189
+ }
190
+
191
+ function render() {
192
+ const previousConversationScroll = captureConversationScroll(document.querySelector('#conversation'));
193
+ const previousToolsScroll = document.querySelector('.tools-drawer')?.scrollTop;
194
+ const requestedConversationScroll = conversationScrollAfterRender || previousConversationScroll;
195
+ const followConversationBottom = scrollToBottomAfterRender;
196
+ conversationScrollAfterRender = null;
197
+ scrollToBottomAfterRender = false;
198
+ lightboxItems = [];
199
+ app.innerHTML = `
200
+ <div class="shell">
201
+ <aside class="sidebar ${state.sidebarOpen ? 'open' : ''}">
202
+ <div class="brand-row">
203
+ <a class="brand-link" href="https://huggingface.co/LiquidAI" target="_blank" rel="noreferrer" aria-label="Liquid AI on Hugging Face">
204
+ <img src="/liquid-hero-white.png" alt="Liquid" class="brand-logo" />
205
+ </a>
206
+ <button class="icon-button sidebar-close" data-action="close-sidebar" aria-label="Close menu">${icon('X')}</button>
207
+ </div>
208
+
209
+ ${isRuntimeActive() ? `<button class="new-chat" data-action="new-chat">${icon('MessageSquarePlus')}<span>New conversation</span></button>` : '<div class="sidebar-spacer"></div>'}
210
+
211
+ <div class="side-section model-card">
212
+ <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>
213
+ </div>
214
+
215
+ <div class="side-section examples-side">
216
+ <div class="section-label">TRY AN EXAMPLE</div>
217
+ <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>
218
+ <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>
219
+ <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>
220
+ </div>
221
+
222
+ <div class="sidebar-bottom">
223
+ <button class="side-link" data-action="cache">${icon('Database')} ${state.cache ? `${formatBytes(state.cache.used)} cached` : 'Browser model cache'}</button>
224
+ <a class="side-link" href="https://www.liquid.ai" target="_blank" rel="noreferrer">${icon('Info')} About Liquid AI</a>
225
+ </div>
226
+ </aside>
227
+
228
+ <main class="main">
229
+ <header class="topbar">
230
+ <button class="icon-button menu-button" data-action="open-sidebar" aria-label="Open menu">${icon('Menu')}</button>
231
+ <div class="mobile-brand"><img src="/liquid-mark.svg" alt="" /> LFM2.5-VL-3B</div>
232
+ <div class="topbar-status"><span class="privacy-dot"></span> Runs locally</div>
233
+ <div class="runtime-status"><span class="status-dot ${isRuntimeActive() ? 'online' : ''}"></span>${isRuntimeActive() ? 'WebGPU active' : 'WebGPU'}</div>
234
+ </header>
235
+
236
+ <section class="conversation" id="conversation">
237
+ ${state.messages.length ? renderMessages() : renderWelcome()}
238
+ </section>
239
+
240
+ ${isRuntimeActive() ? `<section class="composer-zone">
241
+ ${state.error ? `<div class="error-banner"><span>${escapeHtml(state.error)}</span><button data-action="dismiss-error">${icon('X')}</button></div>` : ''}
242
+ ${state.attachments.length ? `<div class="attachment-strip">${state.attachments.map((item, index) => `
243
+ <div class="attachment">${renderEnlargeableImage(item)}<button class="attachment-remove" data-remove="${index}" aria-label="Remove image">${icon('X')}</button></div>
244
+ `).join('')}</div>` : ''}
245
+ <div class="composer ${state.generating ? 'busy' : ''}">
246
+ ${renderSystemPromptEditor()}
247
+ <textarea id="prompt" rows="1" maxlength="8000" placeholder="Ask about an image, a document, or anything else…" ${state.generating ? 'disabled' : ''}>${escapeHtml(state.promptDraft)}</textarea>
248
+ <div class="composer-actions">
249
+ <div class="composer-tools">
250
+ <label class="icon-button" aria-label="Attach images" title="Attach images">${icon('Paperclip')}<input id="image-input" type="file" accept="image/*" multiple hidden /></label>
251
+ <button class="icon-button" data-action="webcam" aria-label="Use webcam" title="Use webcam">${icon('Camera')}</button>
252
+ <button class="icon-button ${state.settingsOpen ? 'active' : ''}" data-action="settings" aria-label="Generation settings" title="Generation settings">${icon('Settings2')}</button>
253
+ </div>
254
+ <span class="composer-hint">Enter to send · Shift + Enter for newline</span>
255
+ <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'}">
256
+ ${state.generating ? '<span class="stop-symbol" aria-hidden="true"></span>' : icon('Send')}
257
+ </button>
258
+ </div>
259
+ ${state.settingsOpen ? renderSettings() : ''}
260
+ </div>
261
+ </section>` : ''}
262
+ </main>
263
+ </div>
264
+ ${state.cacheOpen ? renderCacheManager() : ''}
265
+ ${state.toolsOpen ? renderToolsDrawer() : ''}
266
+ ${state.webcamOpen ? renderWebcam() : ''}
267
+ ${state.lightbox ? renderLightbox() : ''}
268
+ ${!isRuntimeActive() ? renderModelLoader() : ''}
269
+ `;
270
+ bindEvents();
271
+ refreshIcons();
272
+ const toolsDrawer = document.querySelector('.tools-drawer');
273
+ if (toolsDrawer && previousToolsScroll !== undefined) toolsDrawer.scrollTop = previousToolsScroll;
274
+ const conversation = document.querySelector('#conversation');
275
+ const restoreScroll = () => {
276
+ if (conversation?.isConnected) applyConversationScroll(conversation, requestedConversationScroll, followConversationBottom);
277
+ };
278
+ restoreScroll();
279
+ requestAnimationFrame(() => {
280
+ restoreScroll();
281
+ });
282
+ const pendingImages = conversation ? [...conversation.querySelectorAll('img')].filter(image => !image.complete) : [];
283
+ if (pendingImages.length) {
284
+ void Promise.all(pendingImages.map(image => new Promise(resolve => {
285
+ if (image.complete) { resolve(); return; }
286
+ image.addEventListener('load', resolve, { once: true });
287
+ image.addEventListener('error', resolve, { once: true });
288
+ }))).then(() => requestAnimationFrame(restoreScroll));
289
+ }
290
+ }
291
+
292
+ function preserveConversationScroll() {
293
+ const conversation = document.querySelector('#conversation');
294
+ if (conversation) conversationScrollAfterRender = captureConversationScroll(conversation);
295
+ scrollToBottomAfterRender = false;
296
+ }
297
+
298
+ function renderWelcome() {
299
+ return `
300
+ <div class="welcome">
301
+ <h1><img class="hero-mark" src="/liquid-mark.svg" alt="" /><span>LFM2.5 <em>VL</em> 3B</span></h1>
302
+ <p class="hero-copy">A Better <em>and</em> Faster Vision-Language Model for the Edge</p>
303
+ <div class="example-grid">
304
+ <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>
305
+ <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>
306
+ <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>
307
+ </div>
308
+ </div>`;
309
+ }
310
+
311
+ function renderMessages() {
312
+ return `<div class="messages">${state.messages.map((message, index) => message.role === 'tool'
313
+ ? renderToolResultMessage(message)
314
+ : `
315
+ <article class="message ${message.role}">
316
+ <div class="message-label">${message.role === 'user' ? 'You' : `<img src="/liquid-mark.svg" alt=""/> Liquid`}</div>
317
+ <div class="message-content">
318
+ ${message.images?.length ? `<div class="message-images">${message.images.map(image => renderEnlargeableImage(image)).join('')}</div>` : ''}
319
+ <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>
320
+ ${message.groundings?.length ? renderGroundings(message, index) : ''}
321
+ ${message.documentRegions?.length ? renderDocumentRegions(message, index) : ''}
322
+ ${message.toolCalls?.length ? `<div class="tool-call-list">${message.toolCalls.map(call => renderToolCall(call)).join('')}</div>` : ''}
323
+ ${message.role === 'user' && !state.generating ? `<button class="edit-message" data-edit="${index}">${icon('Edit3')} Edit</button>` : ''}
324
+ </div>
325
+ </article>
326
+ `).join('')}</div>`;
327
+ }
328
+
329
+ function renderEnlargeableImage(image, overlays = [], className = '') {
330
+ const lightboxIndex = lightboxItems.push({ image, overlays }) - 1;
331
+ return `<button class="image-enlarge ${className}" data-lightbox="${lightboxIndex}" aria-label="Enlarge ${escapeHtml(image.name || 'image')}">
332
+ <span class="overlay-image"><img src="${image.url}" alt="${escapeHtml(image.name || 'Attached image')}" />${renderOverlayLayer(overlays)}</span>
333
+ </button>`;
334
+ }
335
+
336
+ function renderOverlayLayer(overlays) {
337
+ if (!overlays.length) return '';
338
+ let pointIndex = 0;
339
+ return `<span class="grounding-overlay">${overlays.map(item => {
340
+ const label = `<span class="grounding-label">${escapeHtml(item.label)}</span>`;
341
+ if (item.type === 'box') {
342
+ const [xmin, ymin, xmax, ymax] = item.coordinates;
343
+ return `<span class="grounding-box" style="left:${xmin / 10}%;top:${ymin / 10}%;width:${(xmax - xmin) / 10}%;height:${(ymax - ymin) / 10}%">${label}</span>`;
344
+ }
345
+ pointIndex += 1;
346
+ const [x, y] = item.coordinates;
347
+ return `<span class="grounding-point" style="left:${x / 10}%;top:${y / 10}%"><i></i>${label}<b>${pointIndex}</b></span>`;
348
+ }).join('')}</span>`;
349
+ }
350
+
351
+ function renderGroundings(message, messageIndex) {
352
+ const grouped = new Map();
353
+ for (const item of message.groundings) grouped.set(item.imageId, [...(grouped.get(item.imageId) || []), item]);
354
+ const regionCount = message.groundings.length;
355
+ return `<details class="grounding-results" data-rendering-message="${messageIndex}" ${message.renderingCollapsed ? '' : 'open'}>
356
+ <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>
357
+ <div class="grounding-grid">${[...grouped].map(([imageId, overlays]) => {
358
+ const image = message.groundingImages?.[imageId];
359
+ return image ? `<figure>${renderEnlargeableImage(image, overlays, 'grounding-image')}<figcaption>Image ${imageId + 1} · ${overlays.length} ${overlays.length === 1 ? 'region' : 'regions'}</figcaption></figure>` : '';
360
+ }).join('')}</div></details>`;
361
+ }
362
+
363
+ function renderDocumentRegions(message, messageIndex) {
364
+ const grouped = new Map();
365
+ for (const region of message.documentRegions) grouped.set(region.imageId, [...(grouped.get(region.imageId) || []), region]);
366
+ const regionCount = message.documentRegions.length;
367
+ return `<details class="grounding-results document-results" data-rendering-message="${messageIndex}" ${message.renderingCollapsed ? '' : 'open'}>
368
+ <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>
369
+ <div class="grounding-grid">${[...grouped].map(([imageId, regions]) => {
370
+ const image = message.groundingImages?.[imageId];
371
+ if (!image) return '';
372
+ const numberedRegions = regions.map((region, index) => ({ ...region, label: `${index + 1} · ${region.label}` }));
373
+ return `<figure>${renderEnlargeableImage(image, numberedRegions, 'grounding-image')}<figcaption>Page ${imageId + 1} · ${regions.length} ${regions.length === 1 ? 'region' : 'regions'}</figcaption>
374
+ <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>
375
+ </figure>`;
376
+ }).join('')}</div></details>`;
377
+ }
378
+
379
+ function renderLightbox() {
380
+ return `<div class="modal-scrim lightbox-scrim" data-action="close-lightbox"><div class="lightbox" onclick="event.stopPropagation()">
381
+ <button class="lightbox-close" data-action="close-lightbox" aria-label="Close enlarged image">${icon('X')}</button>
382
+ <span class="overlay-image"><img src="${state.lightbox.image.url}" alt="${escapeHtml(state.lightbox.image.name || 'Enlarged image')}" />${renderOverlayLayer(state.lightbox.overlays)}</span>
383
+ </div></div>`;
384
+ }
385
+
386
+ function renderSystemPromptEditor() {
387
+ const enabledToolCount = state.tools.filter(tool => tool.enabled).length;
388
+ const systemPromptActive = Boolean(state.systemPrompt.trim());
389
+ return `<div class="system-prompt-inline ${state.systemPromptOpen ? 'open' : ''}">
390
+ <div class="composer-context-row">
391
+ <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>
392
+ <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>
393
+ </div>
394
+ ${state.systemPromptOpen ? `<div class="system-prompt-form">
395
+ <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>
396
+ <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>
397
+ <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>
398
+ </div>` : ''}
399
+ </div>`;
400
+ }
401
+
402
+ function renderToolCall(call) {
403
+ return `<div class="tool-call-card ${call.status || 'proposed'}">
404
+ <div><span class="tool-state"></span><strong>${escapeHtml(call.name)}</strong><small>${escapeHtml(call.statusLabel || call.status || 'requested')}</small></div>
405
+ ${call.arguments === null ? '' : `<pre>${escapeHtml(JSON.stringify(call.arguments, null, 2))}</pre>`}
406
+ ${call.status === 'error' && call.rawOutput ? `<details class="tool-raw-output" open><summary>Raw model output</summary><pre>${escapeHtml(call.rawOutput)}</pre></details>` : ''}
407
+ </div>`;
408
+ }
409
+
410
+ function renderToolResultMessage(message) {
411
+ return `<article class="message tool"><div class="message-label">Tools</div><div class="message-content"><div class="tool-result-list">
412
+ ${(message.executions || []).map(execution => `<div class="tool-result-card ${execution.status}">
413
+ <div><strong>${escapeHtml(execution.name)}</strong><span>${escapeHtml(execution.status)} · ${Math.round(execution.durationMs)} ms</span></div>
414
+ <pre>${escapeHtml(JSON.stringify(execution.status === 'success' ? execution.result : execution.error, null, 2))}</pre>
415
+ </div>`).join('')}
416
+ </div></div></article>`;
417
+ }
418
+
419
+ function renderSettings() {
420
+ return `<div class="settings-popover">
421
+ <div class="popover-title">Generation settings</div>
422
+ <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>
423
+ <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>
424
+ <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>
425
+ <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>
426
+ </div>`;
427
+ }
428
+
429
+ function renderCacheManager() {
430
+ const cached = state.cache ? formatBytes(state.cache.used) : 'Calculating…';
431
+ return `<div class="modal-scrim" data-action="cache"><section class="cache-modal" onclick="event.stopPropagation()">
432
+ <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>
433
+ <p>Model files stay in this browser so returning visits do not download them again.</p>
434
+ <div class="cache-total"><span>Cached model data</span><strong>${cached}</strong></div>
435
+ <button class="secondary-button cache-clear" data-action="clear-cache" ${state.loading ? 'disabled' : ''}>${icon('Trash2')} ${state.loading ? 'Cache in use' : 'Clear model cache'}</button>
436
+ </section></div>`;
437
+ }
438
+
439
+ function renderToolsDrawer() {
440
+ const builtins = state.tools.filter(tool => tool.source === 'builtin');
441
+ const mcpTools = state.tools.filter(tool => tool.source === 'mcp');
442
+ return `<div class="drawer-scrim" data-action="tools"><aside class="drawer-panel tools-drawer" onclick="event.stopPropagation()">
443
+ <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>
444
+ <p class="drawer-intro">Tools start disabled. Enabled schemas are included in the model prompt. Built-ins and connected MCP tools run automatically.</p>
445
+ <div class="tool-section-head"><span>Browser tools</span><small>${builtins.filter(tool => tool.enabled).length}/${builtins.length} enabled</small></div>
446
+ <div class="tool-config-list">${builtins.map(renderToolConfig).join('')}</div>
447
+ <div class="tool-section-head"><span>MCP server</span><small>${state.mcp.status === 'connected' ? `${mcpTools.length} tools` : state.mcp.status}</small></div>
448
+ <form id="mcp-server-form" class="mcp-server-form">
449
+ <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' : ''}/>
450
+ ${state.mcp.status === 'connected'
451
+ ? '<button type="button" class="secondary-button" data-action="disconnect-mcp">Disconnect</button>'
452
+ : `<button type="submit" class="primary-small" ${state.mcp.status === 'connecting' ? 'disabled' : ''}>${state.mcp.status === 'connecting' ? 'Connecting…' : 'Connect'}</button>`}
453
+ </form>
454
+ ${state.mcp.error ? `<div class="inline-form-error mcp-error">${escapeHtml(state.mcp.error)}</div>` : ''}
455
+ ${mcpTools.length ? `<div class="tool-config-list mcp-tools">${mcpTools.map(renderToolConfig).join('')}</div>` : ''}
456
+ <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>
457
+ </aside></div>`;
458
+ }
459
+
460
+ function renderToolConfig(tool) {
461
+ const modelDefinition = { name: tool.name, description: tool.description, parameters: tool.parameters };
462
+ return `<div class="tool-config-row">
463
+ <label class="switch"><input type="checkbox" data-tool-toggle="${escapeHtml(tool.id)}" ${tool.enabled ? 'checked' : ''}/><i></i></label>
464
+ <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>
465
+ <details class="tool-definition"><summary>View definition</summary><pre>${escapeHtml(JSON.stringify(modelDefinition, null, 2))}</pre></details>
466
+ </div>
467
+ </div>`;
468
+ }
469
+
470
+ function renderModelLoader() {
471
+ return `<div class="loader-screen">
472
+ <div class="loader-glow"></div>
473
+ <div class="loader-card">
474
+ <div class="loader-brand"><img src="/liquid-hero-white.png" alt="Liquid" /></div>
475
+ <div class="loader-orb" aria-hidden="true"></div>
476
+ <div class="eyebrow"><span></span> LOCAL VISION-LANGUAGE MODEL</div>
477
+ <h1>Intelligence that<br /><em>stays with you.</em></h1>
478
+ <p>Download LFM2.5-VL-3B from Hugging Face, then run it entirely in your browser.</p>
479
+ ${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>` : ''}
480
+ ${state.error ? `<div class="loader-error">${escapeHtml(state.error)}</div>` : ''}
481
+ <div class="model-loader">
482
+ <button class="load-model-button" type="button" data-action="load" ${state.loading ? 'disabled' : ''}>
483
+ ${state.loading ? `<span class="spinner"></span><span>Loading model</span><b>${Math.round(state.progress.progress || 0)}%</b>` : `${icon('Zap')} Load model`}
484
+ </button>
485
+ ${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>'}
486
+ </div>
487
+ </div>
488
+ </div>`;
489
+ }
490
+
491
+ function renderEmbeddingPrecision() {
492
+ if (runtime.embeddingPrecision === 'fp16') return '<div class="loader-precision-note">FP16 embeddings · shader-f16 available</div>';
493
+ if (runtime.embeddingPrecision === 'fp32') return '<div class="loader-precision-note">FP32 embeddings · shader-f16 unavailable</div>';
494
+ return '';
495
+ }
496
+
497
+ function renderWebcam() {
498
+ 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>`;
499
+ }
500
+
501
+ function escapeHtml(value = '') {
502
+ return String(value).replace(/[&<>'"]/g, character => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', "'": '&#39;', '"': '&quot;' })[character]);
503
+ }
504
+
505
+ function renderMarkdown(value = '') {
506
+ const sanitized = DOMPurify.sanitize(marked.parse(String(value), { gfm: true, breaks: true }), {
507
+ FORBID_TAGS: ['img'],
508
+ });
509
+ const template = document.createElement('template');
510
+ template.innerHTML = sanitized;
511
+ for (const link of template.content.querySelectorAll('a')) {
512
+ link.target = '_blank';
513
+ link.rel = 'noopener noreferrer';
514
+ }
515
+ return template.innerHTML;
516
+ }
517
+
518
+ function isRuntimeActive() {
519
+ return runtime.status === 'ready' || runtime.status === 'generating';
520
+ }
521
+
522
+ function bindEvents() {
523
+ document.querySelectorAll('[data-action]').forEach(element => element.addEventListener('click', handleAction));
524
+ document.querySelectorAll('[data-lightbox]').forEach(element => element.addEventListener('click', () => {
525
+ preserveConversationScroll();
526
+ state.lightbox = lightboxItems[Number(element.dataset.lightbox)] || null;
527
+ render();
528
+ }));
529
+ document.querySelectorAll('[data-remove]').forEach(element => element.addEventListener('click', () => { state.attachments.splice(Number(element.dataset.remove), 1); render(); }));
530
+ document.querySelectorAll('[data-edit]').forEach(element => element.addEventListener('click', () => editMessage(Number(element.dataset.edit))));
531
+ document.querySelectorAll('[data-rendering-message]').forEach(element => element.addEventListener('toggle', event => {
532
+ const message = state.messages[Number(event.currentTarget.dataset.renderingMessage)];
533
+ if (message) message.renderingCollapsed = !event.currentTarget.open;
534
+ }));
535
+ document.querySelector('#image-input')?.addEventListener('change', event => addFiles(event.target.files));
536
+ document.querySelector('#mcp-server-form')?.addEventListener('submit', connectMcp);
537
+ document.querySelector('#system-prompt-editor')?.addEventListener('input', updateSystemPrompt);
538
+ document.querySelectorAll('[data-system-preset]').forEach(button => button.addEventListener('click', applySystemPromptPreset));
539
+ document.querySelector('#mcp-server-url')?.addEventListener('input', event => { state.mcp.url = event.currentTarget.value; });
540
+ document.querySelectorAll('[data-tool-toggle]').forEach(input => input.addEventListener('change', toggleTool));
541
+ document.querySelectorAll('[data-setting]').forEach(input => input.addEventListener('input', updateSetting));
542
+ const prompt = document.querySelector('#prompt');
543
+ prompt?.addEventListener('input', event => {
544
+ state.promptDraft = event.currentTarget.value;
545
+ resizePrompt(event);
546
+ });
547
+ prompt?.addEventListener('keydown', event => {
548
+ if (event.key === 'Enter' && !event.shiftKey && !event.isComposing) {
549
+ event.preventDefault();
550
+ sendMessage();
551
+ }
552
+ });
553
+ prompt?.addEventListener('paste', handlePromptPaste);
554
+ const composer = document.querySelector('.composer');
555
+ composer?.addEventListener('dragenter', handleComposerDragOver);
556
+ composer?.addEventListener('dragover', handleComposerDragOver);
557
+ composer?.addEventListener('dragleave', handleComposerDragLeave);
558
+ composer?.addEventListener('drop', handleComposerDrop);
559
+ if (state.webcamOpen) startWebcam();
560
+ }
561
+
562
+ async function handleAction(event) {
563
+ const action = event.currentTarget.dataset.action;
564
+ if (action === 'load') loadModel();
565
+ if (action === 'send') sendMessage();
566
+ if (action === 'stop') {
567
+ state.abortController?.abort();
568
+ }
569
+ if (action === 'new-chat') {
570
+ state.messages = [];
571
+ state.attachments = [];
572
+ state.promptDraft = '';
573
+ restoreExampleConfig();
574
+ if (Object.values(SYSTEM_PROMPT_PRESETS).includes(state.systemPrompt)) {
575
+ state.systemPrompt = '';
576
+ state.exampleSystemPromptActive = false;
577
+ localStorage.removeItem(SYSTEM_PROMPT_KEY);
578
+ }
579
+ runtime.clearConversationCache();
580
+ state.sidebarOpen = false;
581
+ render();
582
+ }
583
+ if (action === 'example-charcuterie') applyCharcuterieExample();
584
+ if (action === 'example-document') applyDocumentExample();
585
+ if (action === 'example-pad-thai') applyPadThaiExample();
586
+ if (action === 'settings') { state.settingsOpen = !state.settingsOpen; render(); document.querySelector('#prompt')?.focus(); }
587
+ if (action === 'tools') { state.toolsOpen = !state.toolsOpen; state.systemPromptOpen = false; state.settingsOpen = false; render(); }
588
+ if (action === 'system-prompt') {
589
+ state.systemPromptOpen = !state.systemPromptOpen;
590
+ state.toolsOpen = false;
591
+ state.settingsOpen = false;
592
+ render();
593
+ }
594
+ if (action === 'clear-system-prompt') {
595
+ state.systemPrompt = '';
596
+ if (exampleConfigSnapshot) exampleConfigSnapshot.systemPrompt = '';
597
+ localStorage.removeItem(SYSTEM_PROMPT_KEY);
598
+ state.exampleSystemPromptActive = false;
599
+ runtime.clearConversationCache();
600
+ render();
601
+ }
602
+ if (action === 'close-lightbox') { preserveConversationScroll(); state.lightbox = null; render(); }
603
+ if (action === 'disconnect-mcp') await disconnectMcp();
604
+ if (action === 'cache') { state.cacheOpen = !state.cacheOpen; state.sidebarOpen = false; render(); if (state.cacheOpen) updateCacheInfo(); }
605
+ if (action === 'clear-cache' && !state.loading) { await runtime.clearCache(); await updateCacheInfo(); render(); }
606
+ if (action === 'open-sidebar') { state.sidebarOpen = true; render(); }
607
+ if (action === 'close-sidebar') { state.sidebarOpen = false; render(); }
608
+ if (action === 'dismiss-error') { state.error = ''; render(); }
609
+ if (action === 'webcam') { state.webcamOpen = true; render(); }
610
+ if (action === 'close-webcam') closeWebcam();
611
+ if (action === 'capture') captureWebcam();
612
+ }
613
+
614
+ function applyCharcuterieExample() {
615
+ beginExampleConfig();
616
+ state.messages = [];
617
+ state.attachments = [{ name: 'charcuterie.jpg', url: '/charcuterie.jpg', source: 'example' }];
618
+ state.promptDraft = CHARCUTERIE_USER_PROMPT;
619
+ state.systemPrompt = CHARCUTERIE_SYSTEM_PROMPT;
620
+ state.exampleSystemPromptActive = true;
621
+ state.sidebarOpen = false;
622
+ state.settingsOpen = false;
623
+ runtime.clearConversationCache();
624
+ render();
625
+ const prompt = document.querySelector('#prompt');
626
+ if (prompt) {
627
+ resizePrompt({ currentTarget: prompt });
628
+ prompt.focus();
629
+ }
630
+ }
631
+
632
+ function applyDocumentExample() {
633
+ beginExampleConfig();
634
+ state.messages = [];
635
+ state.attachments = [{ name: 'LFM2-VL-tech-report.png', url: '/LFM2-VL-tech-report.png', source: 'example' }];
636
+ state.promptDraft = 'Parse this document into its layout regions.';
637
+ state.systemPrompt = DOCUMENT_PARSING_PROMPT;
638
+ state.exampleSystemPromptActive = true;
639
+ state.sidebarOpen = false;
640
+ state.settingsOpen = false;
641
+ state.systemPromptOpen = false;
642
+ runtime.clearConversationCache();
643
+ render();
644
+ const prompt = document.querySelector('#prompt');
645
+ if (prompt) {
646
+ resizePrompt({ currentTarget: prompt });
647
+ prompt.focus();
648
+ }
649
+ }
650
+
651
+ function applyPadThaiExample() {
652
+ beginExampleConfig();
653
+ state.messages = [];
654
+ state.attachments = [{ name: 'pad-thai.jpeg', url: '/pad-thai.jpeg', source: 'example' }];
655
+ state.promptDraft = PAD_THAI_USER_PROMPT;
656
+ state.systemPrompt = '';
657
+ state.exampleSystemPromptActive = true;
658
+ state.tools.forEach(tool => { tool.enabled = tool.name === 'search_recipe_by_dish'; });
659
+ state.sidebarOpen = false;
660
+ state.settingsOpen = false;
661
+ state.systemPromptOpen = false;
662
+ runtime.clearConversationCache();
663
+ render();
664
+ const prompt = document.querySelector('#prompt');
665
+ if (prompt) {
666
+ resizePrompt({ currentTarget: prompt });
667
+ prompt.focus();
668
+ }
669
+ }
670
+
671
+ function updateSystemPrompt(event) {
672
+ state.systemPrompt = event.currentTarget.value;
673
+ if (exampleConfigSnapshot) exampleConfigSnapshot.systemPrompt = state.systemPrompt;
674
+ if (state.systemPrompt.trim()) localStorage.setItem(SYSTEM_PROMPT_KEY, state.systemPrompt);
675
+ else localStorage.removeItem(SYSTEM_PROMPT_KEY);
676
+ state.exampleSystemPromptActive = false;
677
+ runtime.clearConversationCache();
678
+ const toggle = document.querySelector('.system-prompt-toggle');
679
+ const active = Boolean(state.systemPrompt.trim());
680
+ toggle?.classList.toggle('enabled', active);
681
+ const label = toggle?.querySelector('span:first-child');
682
+ if (label) label.textContent = `System Prompt${active ? ' · Active' : ''}`;
683
+ document.querySelectorAll('[data-system-preset]').forEach(button => {
684
+ button.classList.toggle('active', state.systemPrompt === SYSTEM_PROMPT_PRESETS[button.dataset.systemPreset]);
685
+ });
686
+ }
687
+
688
+ function applySystemPromptPreset(event) {
689
+ const prompt = SYSTEM_PROMPT_PRESETS[event.currentTarget.dataset.systemPreset];
690
+ if (!prompt) return;
691
+ const nextPrompt = state.systemPrompt === prompt ? '' : prompt;
692
+ state.systemPrompt = nextPrompt;
693
+ if (exampleConfigSnapshot) exampleConfigSnapshot.systemPrompt = nextPrompt;
694
+ state.exampleSystemPromptActive = false;
695
+ if (nextPrompt) localStorage.setItem(SYSTEM_PROMPT_KEY, nextPrompt);
696
+ else localStorage.removeItem(SYSTEM_PROMPT_KEY);
697
+ runtime.clearConversationCache();
698
+ render();
699
+ document.querySelector('#system-prompt-editor')?.focus();
700
+ }
701
+
702
+ function toggleTool(event) {
703
+ const tool = state.tools.find(candidate => candidate.id === event.currentTarget.dataset.toolToggle);
704
+ if (!tool) return;
705
+ tool.enabled = event.currentTarget.checked;
706
+ if (tool.source === 'mcp') {
707
+ if (tool.enabled) state.mcp.enabled.add(tool.name);
708
+ else state.mcp.enabled.delete(tool.name);
709
+ saveMcpSettings();
710
+ }
711
+ if (exampleConfigSnapshot) exampleConfigSnapshot.toolEnabled.set(tool.id, tool.enabled);
712
+ persistTools();
713
+ render();
714
+ }
715
+
716
+ async function connectMcp(event) {
717
+ event.preventDefault();
718
+ state.mcp.status = 'connecting';
719
+ state.mcp.error = '';
720
+ render();
721
+ try {
722
+ const discovered = await connectMcpServer(state.mcp.url);
723
+ const existing = new Set(state.tools.filter(tool => tool.source !== 'mcp').map(tool => tool.name));
724
+ const accepted = discovered.filter(tool => !existing.has(tool.name)).map(tool => ({ ...tool, enabled: state.mcp.enabled.has(tool.name) }));
725
+ const skipped = discovered.length - accepted.length;
726
+ state.tools = [...state.tools.filter(tool => tool.source !== 'mcp'), ...accepted];
727
+ state.mcp.status = 'connected';
728
+ state.mcp.error = skipped ? `${skipped} MCP tool${skipped === 1 ? ' was' : 's were'} skipped because its name conflicts with another tool.` : '';
729
+ saveMcpSettings();
730
+ } catch (error) {
731
+ state.tools = state.tools.filter(tool => tool.source !== 'mcp');
732
+ state.mcp.status = 'disconnected';
733
+ state.mcp.error = `Connection failed: ${error.message}`;
734
+ }
735
+ render();
736
+ }
737
+
738
+ async function disconnectMcp() {
739
+ await disconnectMcpServer();
740
+ state.tools = state.tools.filter(tool => tool.source !== 'mcp');
741
+ state.mcp.status = 'disconnected';
742
+ state.mcp.error = '';
743
+ render();
744
+ }
745
+
746
+ function saveMcpSettings() {
747
+ localStorage.setItem(MCP_SETTINGS_KEY, JSON.stringify({ url: state.mcp.url, enabled: [...state.mcp.enabled] }));
748
+ }
749
+
750
+ async function loadModel() {
751
+ state.loading = true; state.error = ''; render();
752
+ try {
753
+ await runtime.load();
754
+ await updateCacheInfo();
755
+ } catch (error) {
756
+ state.error = error.message;
757
+ } finally {
758
+ state.loading = false; render();
759
+ }
760
+ }
761
+
762
+ async function sendMessage() {
763
+ const prompt = document.querySelector('#prompt');
764
+ const text = prompt?.value.trim() || '';
765
+ if ((!text && !state.attachments.length) || state.generating) return;
766
+ if (runtime.status !== 'ready') {
767
+ state.error = 'Load the on-device model before sending a message.'; render(); return;
768
+ }
769
+ state.systemPromptOpen = false;
770
+ const userMessage = { role: 'user', text, images: state.attachments.slice() };
771
+ state.messages.push(userMessage);
772
+ state.attachments = [];
773
+ state.promptDraft = '';
774
+ state.generating = true;
775
+ state.error = '';
776
+ state.abortController = new AbortController();
777
+ scrollToBottomAfterRender = true;
778
+ render();
779
+ try {
780
+ let toolRounds = 0;
781
+ while (!state.abortController.signal.aborted) {
782
+ const assistant = { role: 'assistant', text: '', streaming: true, toolCalls: [] };
783
+ state.messages.push(assistant);
784
+ scrollToBottomAfterRender = true;
785
+ render();
786
+ const promptMessages = state.messages.slice(0, -1);
787
+ const groundingImages = conversationImages(promptMessages);
788
+ // Once the model has converted the image into tool arguments, the result
789
+ // round only needs the textual call and result. Re-encoding the same image
790
+ // substantially increases WebGPU prefill memory without adding information.
791
+ const enabledTools = modelToolDefinitions(state.tools);
792
+ const result = await runtime.generate(toModelConversation(promptMessages, state.systemPrompt, toolRounds === 0, enabledTools.length > 0), {
793
+ ...state.generation,
794
+ tools: enabledTools,
795
+ signal: state.abortController.signal,
796
+ onToken: token => streamAssistantToken(assistant, token),
797
+ onToolCallState: phase => streamToolCallState(assistant, phase),
798
+ });
799
+ assistant.text = result.text || assistant.text;
800
+ assistant.streaming = false;
801
+ assistant.toolCalls = result.toolCalls.map((call, index) => ({
802
+ id: globalThis.crypto?.randomUUID?.() || `${Date.now()}-${index}`,
803
+ name: call.name,
804
+ arguments: call.arguments,
805
+ positional: call.positional,
806
+ rawOutput: result.rawOutput,
807
+ status: 'proposed',
808
+ statusLabel: 'requested',
809
+ }));
810
+ if (!assistant.toolCalls.length) {
811
+ const documentRegions = parseDocumentRegions(assistant.text, groundingImages.length);
812
+ if (documentRegions?.length) {
813
+ assistant.documentRegions = documentRegions;
814
+ assistant.groundingImages = groundingImages;
815
+ } else {
816
+ const groundings = parseGroundingResponse(assistant.text, groundingImages.length);
817
+ if (groundings?.length) {
818
+ assistant.groundings = groundings;
819
+ assistant.groundingImages = groundingImages;
820
+ }
821
+ }
822
+ if (!assistant.text) assistant.text = result.finishReason === 'stopped' ? 'Generation stopped.' : 'The model returned no displayable text.';
823
+ break;
824
+ }
825
+ if (toolRounds >= 3) {
826
+ assistant.toolCalls.forEach(call => { call.status = 'error'; call.statusLabel = 'round limit reached'; });
827
+ assistant.text ||= 'Tool execution stopped because the three-round limit was reached.';
828
+ state.error = 'Tool execution exceeded the three-round safety limit.';
829
+ break;
830
+ }
831
+ if (assistant.toolCalls.length > 4) {
832
+ assistant.toolCalls.forEach(call => { call.status = 'error'; call.statusLabel = 'call limit reached'; });
833
+ assistant.text ||= 'Tool execution stopped because more than four calls were requested in one round.';
834
+ state.error = 'The model requested more than four tools in one round.';
835
+ break;
836
+ }
837
+ render();
838
+ const executions = [];
839
+ for (const call of assistant.toolCalls) {
840
+ executions.push(await executeToolCall(call, state.abortController.signal));
841
+ }
842
+ state.messages.push({ role: 'tool', text: '', executions, modelContent: JSON.stringify(executions.map(execution => ({
843
+ tool_call_id: execution.id,
844
+ name: execution.name,
845
+ status: execution.status,
846
+ ...(execution.status === 'success' ? { result: execution.result } : { error: execution.error }),
847
+ }))) });
848
+ toolRounds += 1;
849
+ scrollToBottomAfterRender = true;
850
+ render();
851
+ }
852
+ } catch (error) {
853
+ if (error.name !== 'AbortError') state.error = error.message;
854
+ const assistant = state.messages.at(-1)?.role === 'assistant' ? state.messages.at(-1) : null;
855
+ const pendingCall = assistant?.toolCalls?.find(call => call.pending);
856
+ if (pendingCall) {
857
+ pendingCall.status = 'error';
858
+ pendingCall.statusLabel = error.name === 'AbortError' ? 'stopped' : 'request failed';
859
+ if (error.rawModelOutput) pendingCall.rawOutput = error.rawModelOutput;
860
+ }
861
+ if (assistant && !assistant.text && !assistant.toolCalls?.length) assistant.text = error.name === 'AbortError' ? 'Generation stopped.' : 'I could not complete that response.';
862
+ } finally {
863
+ const assistant = [...state.messages].reverse().find(message => message.role === 'assistant');
864
+ if (assistant) assistant.streaming = false;
865
+ state.generating = false; state.abortController = null;
866
+ scrollToBottomAfterRender = true;
867
+ render();
868
+ }
869
+ }
870
+
871
+ function toModelConversation(messages, systemPrompt = '', includeImages = true, hasTools = false) {
872
+ const conversation = messages.map(message => {
873
+ if (message.role === 'tool') return { role: 'tool', content: message.modelContent };
874
+ const content = includeImages && message.images?.length ? [
875
+ ...message.images.map(image => ({ type: 'image', value: image.url })),
876
+ { type: 'text', value: message.text },
877
+ ] : message.text;
878
+ if (message.role === 'assistant' && message.toolCalls?.length) {
879
+ return {
880
+ role: 'assistant',
881
+ content,
882
+ tool_calls: message.toolCalls.map(call => ({ type: 'function', id: call.id, function: { name: call.name, arguments: call.arguments } })),
883
+ };
884
+ }
885
+ return { role: message.role, content };
886
+ });
887
+ const internalSystemPrompt = [systemPrompt, hasTools ? TOOL_USE_POLICY : ''].filter(Boolean).join('\n\n');
888
+ return internalSystemPrompt ? [{ role: 'system', content: internalSystemPrompt }, ...conversation] : conversation;
889
+ }
890
+
891
+ function conversationImages(messages) {
892
+ return messages.flatMap(message => message.images || []);
893
+ }
894
+
895
+ function streamAssistantToken(assistant, token) {
896
+ const conversation = document.querySelector('#conversation');
897
+ const followOutput = isNearBottom(conversation);
898
+ assistant.text += token;
899
+ const textNodes = document.querySelectorAll('.message.assistant .message-text');
900
+ const textNode = textNodes[textNodes.length - 1];
901
+ if (textNode) textNode.innerHTML = renderMarkdown(assistant.text);
902
+ if (followOutput) requestAnimationFrame(() => { conversation.scrollTop = conversation.scrollHeight; });
903
+ }
904
+
905
+ function streamToolCallState(assistant, phase) {
906
+ const pending = assistant.toolCalls?.find(call => call.pending);
907
+ const statusLabel = phase === 'preparing' ? 'preparing request' : 'validating request';
908
+ if (pending) pending.statusLabel = statusLabel;
909
+ else assistant.toolCalls = [{
910
+ id: 'streaming-tool-call',
911
+ name: 'Tool call',
912
+ arguments: null,
913
+ positional: [],
914
+ pending: true,
915
+ status: 'running',
916
+ statusLabel,
917
+ }];
918
+ render();
919
+ }
920
+
921
+ async function executeToolCall(call, signal) {
922
+ const startedAt = performance.now();
923
+ let source = 'unregistered';
924
+ let outcome;
925
+ try {
926
+ const prepared = prepareToolCall(call, state.tools);
927
+ source = prepared.tool.source;
928
+ call.arguments = prepared.args;
929
+ call.status = 'running';
930
+ call.statusLabel = source === 'mcp' ? 'calling MCP server' : prepared.tool.external ? 'searching TheMealDB' : 'running locally';
931
+ render();
932
+ outcome = source === 'builtin'
933
+ ? { status: 'success', result: await executeBuiltin(prepared.tool.name, prepared.args, signal) }
934
+ : { status: 'success', result: await callMcpTool(prepared.tool.name, prepared.args, signal) };
935
+ if (signal.aborted) throw new DOMException('Generation stopped.', 'AbortError');
936
+ } catch (error) {
937
+ if (error.name === 'AbortError') throw error;
938
+ outcome = { status: 'error', error: { code: 'tool_error', message: error.message } };
939
+ }
940
+ const durationMs = performance.now() - startedAt;
941
+ call.status = outcome.status;
942
+ call.statusLabel = outcome.status === 'success' ? `completed in ${Math.round(durationMs)} ms` : outcome.error.message;
943
+ const execution = { id: call.id, name: call.name, source, status: outcome.status, durationMs, ...(outcome.status === 'success' ? { result: outcome.result } : { error: outcome.error }) };
944
+ render();
945
+ return execution;
946
+ }
947
+
948
+ function isNearBottom(element, threshold = 120) {
949
+ if (!element) return false;
950
+ return element.scrollHeight - element.scrollTop - element.clientHeight <= threshold;
951
+ }
952
+
953
+ function addFiles(fileList) {
954
+ const candidates = [...fileList].filter(file => file.type?.startsWith('image/'));
955
+ const files = candidates.slice(0, Math.max(0, 6 - state.attachments.length));
956
+ state.attachments.push(...files.map((file, index) => ({
957
+ name: file.name || `Pasted image ${index + 1}`,
958
+ url: URL.createObjectURL(file),
959
+ source: 'upload',
960
+ })));
961
+ if (files.length < candidates.length) state.error = 'You can attach up to six images per message.';
962
+ render();
963
+ return files.length;
964
+ }
965
+
966
+ function handlePromptPaste(event) {
967
+ const files = imageFilesFromClipboard(event.clipboardData);
968
+ if (!files.length) return;
969
+ event.preventDefault();
970
+ const selectionStart = event.currentTarget.selectionStart;
971
+ const selectionEnd = event.currentTarget.selectionEnd;
972
+ addFiles(files);
973
+ requestAnimationFrame(() => {
974
+ const prompt = document.querySelector('#prompt');
975
+ if (!prompt) return;
976
+ prompt.focus();
977
+ prompt.setSelectionRange(selectionStart, selectionEnd);
978
+ resizePrompt({ currentTarget: prompt });
979
+ });
980
+ }
981
+
982
+ function handleComposerDragOver(event) {
983
+ if (![...(event.dataTransfer?.types || [])].includes('Files')) return;
984
+ event.preventDefault();
985
+ event.dataTransfer.dropEffect = 'copy';
986
+ event.currentTarget.classList.add('drag-active');
987
+ }
988
+
989
+ function handleComposerDragLeave(event) {
990
+ if (event.relatedTarget && event.currentTarget.contains(event.relatedTarget)) return;
991
+ event.currentTarget.classList.remove('drag-active');
992
+ }
993
+
994
+ function handleComposerDrop(event) {
995
+ event.preventDefault();
996
+ event.currentTarget.classList.remove('drag-active');
997
+ const files = imageFilesFromDataTransfer(event.dataTransfer);
998
+ if (!files.length) return;
999
+ addFiles(files);
1000
+ requestAnimationFrame(() => document.querySelector('#prompt')?.focus());
1001
+ }
1002
+
1003
+ function editMessage(index) {
1004
+ const message = state.messages[index];
1005
+ state.messages = state.messages.slice(0, index);
1006
+ state.attachments = message.images?.slice() || [];
1007
+ state.promptDraft = message.text;
1008
+ render();
1009
+ const prompt = document.querySelector('#prompt');
1010
+ resizePrompt({ currentTarget: prompt });
1011
+ prompt.focus();
1012
+ }
1013
+
1014
+ function updateSetting(event) {
1015
+ const input = event.currentTarget;
1016
+ const key = input.dataset.setting;
1017
+ const value = Number(input.value);
1018
+ state.generation[key] = value;
1019
+ const output = input.closest('label')?.querySelector('output');
1020
+ if (output) output.textContent = key === 'temperature' ? value.toFixed(1) : key === 'topP' ? value.toFixed(2) : String(value);
1021
+ }
1022
+
1023
+ function isFirefoxBasedBrowser() {
1024
+ return /Firefox\//i.test(navigator.userAgent);
1025
+ }
1026
+
1027
+ function resizePrompt(event) {
1028
+ const textarea = event.currentTarget;
1029
+ textarea.style.height = 'auto';
1030
+ textarea.style.height = `${Math.min(textarea.scrollHeight, 180)}px`;
1031
+ }
1032
+
1033
+ const webcamSession = new WebcamSession(constraints => navigator.mediaDevices.getUserMedia(constraints));
1034
+ async function startWebcam() {
1035
+ try {
1036
+ const stream = await webcamSession.open({ video: { facingMode: 'environment', width: { ideal: 1280 } }, audio: false });
1037
+ if (!stream || !state.webcamOpen) return;
1038
+ const video = document.querySelector('#webcam-video');
1039
+ if (video) { video.srcObject = stream; video.addEventListener('loadeddata', () => document.querySelector('.camera-wait')?.remove(), { once: true }); }
1040
+ } catch (error) {
1041
+ if (!state.webcamOpen) return;
1042
+ state.error = `Camera unavailable: ${error.message}`; closeWebcam();
1043
+ }
1044
+ }
1045
+
1046
+ function captureWebcam() {
1047
+ const video = document.querySelector('#webcam-video');
1048
+ if (!video?.videoWidth) return;
1049
+ const max = 1280;
1050
+ const scale = Math.min(1, max / video.videoWidth);
1051
+ const canvas = document.createElement('canvas');
1052
+ canvas.width = Math.round(video.videoWidth * scale); canvas.height = Math.round(video.videoHeight * scale);
1053
+ const context = canvas.getContext('2d');
1054
+ context.translate(canvas.width, 0);
1055
+ context.scale(-1, 1);
1056
+ context.drawImage(video, 0, 0, canvas.width, canvas.height);
1057
+ state.attachments.push({ name: `Webcam ${new Date().toLocaleTimeString()}`, url: canvas.toDataURL('image/jpeg', 0.9), source: 'webcam' });
1058
+ closeWebcam();
1059
+ }
1060
+
1061
+ function closeWebcam() {
1062
+ webcamSession.close(); state.webcamOpen = false; render();
1063
+ }
1064
+
1065
+ async function updateCacheInfo() {
1066
+ state.cache = await runtime.cacheInfo();
1067
+ render();
1068
+ }
1069
+
1070
+ runtime.addEventListener('progress', event => { state.progress = event.detail; if (state.loading) render(); });
1071
+ runtime.addEventListener('status', () => render());
1072
+ document.addEventListener('click', event => {
1073
+ if (!state.settingsOpen) return;
1074
+ if (event.target.closest('.settings-popover, [data-action="settings"]')) return;
1075
+ state.settingsOpen = false;
1076
+ render();
1077
+ });
1078
+ document.addEventListener('keydown', event => {
1079
+ if (event.key === 'Escape' && state.lightbox) {
1080
+ preserveConversationScroll();
1081
+ state.lightbox = null;
1082
+ render();
1083
+ }
1084
+ });
1085
+ window.addEventListener('beforeunload', () => { void disconnectMcpServer(); });
1086
+
1087
+ render();
1088
+ updateCacheInfo();
src/model-config.js ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ export const MODEL = {
2
+ id: 'lfm2-5-vl-3b',
3
+ label: 'LFM2.5-VL-3B · Q4',
4
+ sidebarLabel: 'LFM2.5-VL-3B · ONNX Q4',
5
+ };
6
+
7
+ export const MODEL_REPO = import.meta.env.VITE_MODEL_REPO ||
8
+ 'LiquidAI/LFM2.5-VL-3B-ONNX';
src/model-precision.js ADDED
@@ -0,0 +1,19 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ export function selectEmbeddingPrecision(manifest, adapterFeatures = []) {
2
+ const features = adapterFeatures instanceof Set ? adapterFeatures : new Set(adapterFeatures);
3
+ const precision = features.has('shader-f16') ? 'fp16' : 'fp32';
4
+ const externalDataChunks = { ...manifest.runtime.externalDataChunks };
5
+ delete externalDataChunks['embed_tokens_fp16.onnx'];
6
+ if (precision === 'fp16') externalDataChunks['embed_tokens_fp16.onnx'] = 1;
7
+
8
+ return {
9
+ precision,
10
+ manifest: {
11
+ ...manifest,
12
+ runtime: {
13
+ ...manifest.runtime,
14
+ dtype: { ...manifest.runtime.dtype, embed_tokens: precision },
15
+ externalDataChunks,
16
+ },
17
+ },
18
+ };
19
+ }
src/runtime/runtime.js ADDED
@@ -0,0 +1,116 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { selectEmbeddingPrecision } from '../model-precision.js';
2
+
3
+ /**
4
+ * Format-neutral runtime boundary.
5
+ *
6
+ * An ONNX or GGUF implementation must satisfy the same adapter contract. The
7
+ * product UI intentionally has no dependency on either runtime.
8
+ */
9
+ class Runtime extends EventTarget {
10
+ adapter = null;
11
+ status = 'idle';
12
+ backend = 'Engine not selected';
13
+ webgpu = null;
14
+ embeddingPrecision = null;
15
+
16
+ emit(type, detail) {
17
+ this.dispatchEvent(new CustomEvent(type, { detail }));
18
+ }
19
+
20
+ log(level, message, detail = '') {
21
+ if (level !== 'error' && level !== 'warn') return;
22
+ console[level](`[LFM edge runtime] ${message}`, detail || '');
23
+ }
24
+
25
+ async probe() {
26
+ if (!globalThis.isSecureContext) throw new Error('WebGPU requires HTTPS or localhost.');
27
+ if (!navigator.gpu) throw new Error('WebGPU is unavailable in this browser.');
28
+ const adapter = await navigator.gpu.requestAdapter({ powerPreference: 'high-performance' });
29
+ if (!adapter) throw new Error('The browser did not return a WebGPU adapter.');
30
+ const info = adapter.info || {};
31
+ this.webgpu = {
32
+ adapter,
33
+ name: info.description || info.device || info.architecture || 'WebGPU adapter',
34
+ vendor: info.vendor || 'Not exposed',
35
+ architecture: info.architecture || 'Not exposed',
36
+ features: [...adapter.features].sort(),
37
+ limits: adapter.limits,
38
+ };
39
+ return this.webgpu;
40
+ }
41
+
42
+ async load() {
43
+ if (this.status === 'loading') return;
44
+ this.status = 'loading';
45
+ this.emit('status', { status: this.status, backend: this.backend });
46
+ try {
47
+ await this.probe();
48
+ const baseManifest = await fetch('/model-manifest.json', { cache: 'no-store' }).then(response => response.json());
49
+ const selection = selectEmbeddingPrecision(baseManifest, this.webgpu.features);
50
+ const manifest = selection.manifest;
51
+ this.embeddingPrecision = selection.precision;
52
+ this.emit('status', { status: this.status, backend: this.backend });
53
+ const loaders = {
54
+ 'onnx-transformers': () => import('../engines/onnx-transformers-engine.js'),
55
+ };
56
+ const loadEngine = loaders[manifest.engine?.adapter];
57
+ if (!loadEngine) throw new Error(`Unknown or unselected inference adapter: ${manifest.engine?.adapter || 'none'}.`);
58
+ const module = await loadEngine();
59
+ this.adapter = await module.createEngine({ manifest, telemetry: event => this.log(event.level, event.message, event.detail) });
60
+ await this.adapter.load(progress => this.emit('progress', progress));
61
+ this.backend = this.adapter.backend;
62
+ this.status = 'ready';
63
+ } catch (error) {
64
+ this.status = 'error';
65
+ this.log('error', 'Engine load stopped', error.message);
66
+ throw error;
67
+ } finally {
68
+ this.emit('status', { status: this.status, backend: this.backend });
69
+ }
70
+ }
71
+
72
+ /** Returns { text, toolCalls, finishReason } for every inference adapter. */
73
+ async generate(messages, options) {
74
+ if (!this.adapter || this.status !== 'ready') throw new Error('No inference engine is configured.');
75
+ this.status = 'generating';
76
+ this.emit('status', { status: this.status, backend: this.backend });
77
+ try {
78
+ return await this.adapter.generate(messages, options);
79
+ } finally {
80
+ this.status = 'ready';
81
+ this.emit('status', { status: this.status, backend: this.backend });
82
+ }
83
+ }
84
+
85
+ async clearCache() {
86
+ const result = this.adapter?.clearCache ? await this.adapter.clearCache() : { cleared: false, entriesDeleted: 0 };
87
+ const cleared = typeof result === 'object' ? result.cleared : Boolean(result);
88
+ return cleared;
89
+ }
90
+
91
+ async cacheInfo() {
92
+ if (this.adapter?.cacheInfo) return this.adapter.cacheInfo();
93
+ if (globalThis.caches) {
94
+ const cacheNames = await caches.keys();
95
+ const modelCacheNames = cacheNames.filter(name => name === 'liquid-lfm-models-v4');
96
+ let used = 0;
97
+ for (const cacheName of modelCacheNames) {
98
+ const cache = await caches.open(cacheName);
99
+ for (const request of await cache.keys()) {
100
+ const response = await cache.match(request);
101
+ used += Number(response?.headers.get('content-length') || 0);
102
+ }
103
+ }
104
+ const estimate = await navigator.storage?.estimate?.();
105
+ return { used, available: estimate?.quota || 0 };
106
+ }
107
+ const estimate = await navigator.storage?.estimate?.();
108
+ return { used: 0, available: estimate?.quota || 0 };
109
+ }
110
+
111
+ clearConversationCache() {
112
+ this.adapter?.clearConversationCache?.();
113
+ }
114
+ }
115
+
116
+ export const runtime = new Runtime();
src/styles.css ADDED
@@ -0,0 +1,352 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ @import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600&display=swap');
2
+
3
+ :root {
4
+ --ink: #000;
5
+ --muted: rgb(0 0 0 / 58%);
6
+ --line: rgb(0 0 0 / 16%);
7
+ --soft-line: rgb(0 0 0 / 9%);
8
+ --paper: #fff;
9
+ --white: #fff;
10
+ --light-purple: #cd82f0;
11
+ --purple: #5505af;
12
+ --orange: #ff5f1e;
13
+ --sidebar: 294px;
14
+ color: var(--ink);
15
+ font-family: Söhne, 'Helvetica Neue', Arial, sans-serif;
16
+ font-synthesis: none;
17
+ }
18
+
19
+ * { box-sizing: border-box; }
20
+ html, body, #app { height: 100%; min-height: 0; margin: 0; }
21
+ body { background: var(--paper); overflow: hidden; }
22
+ button, input, textarea { font: inherit; }
23
+ button, label { -webkit-tap-highlight-color: transparent; }
24
+ button { color: inherit; }
25
+ svg { width: 18px; height: 18px; }
26
+
27
+ .shell { height: 100%; height: 100dvh; min-height: 0; overflow: hidden; display: grid; grid-template-columns: var(--sidebar) minmax(0, 1fr); }
28
+ .sidebar { height: 100%; background: #121310; color: #f2f3ec; padding: 24px 18px 17px; display: flex; flex-direction: column; min-height: 0; overflow-y: auto; overscroll-behavior: contain; z-index: 20; }
29
+ .brand-row { height: 34px; display: flex; align-items: center; padding: 0 4px; }
30
+ .brand-link { display: inline-flex; align-items: center; color: inherit; }
31
+ .brand-logo { display: block; width: 86px; height: auto; }
32
+ .sidebar-close { display: none !important; margin-left: auto; }
33
+ .new-chat { margin: 27px 0 24px; height: 44px; border: 1px solid #3b3d36; color: white; background: transparent; border-radius: 11px; display: flex; align-items: center; gap: 10px; padding: 0 14px; cursor: pointer; transition: .2s; }
34
+ .new-chat:hover { background: #23251f; border-color: #62655a; }
35
+ .sidebar-spacer { height: 51px; }
36
+ .side-section { border-top: 1px solid #2e302a; padding: 22px 5px 18px; }
37
+ .section-label { font: 500 10px/1.2 'JetBrains Mono', monospace; letter-spacing: .12em; color: #8d9085; }
38
+ .model-name { display: flex; align-items: center; justify-content: space-between; gap: 9px; color: #f2f3ec; text-decoration: none; font-size: 14px; font-weight: 600; }
39
+ .model-identity { display: flex; align-items: center; gap: 9px; min-width: 0; }
40
+ .model-orb { width: 20px; height: 20px; flex: 0 0 auto; border-radius: 50%; background: radial-gradient(circle at 70% 30%, #fff 0 7%, var(--light-purple) 10% 34%, var(--purple) 66%, #000 72%); }
41
+ .model-name > span:last-child { color: #74776d; font: 13px/1 'JetBrains Mono', monospace; transition: color .15s, transform .15s; }
42
+ .model-name:hover > span:last-child { color: var(--light-purple); transform: translate(1px, -1px); }
43
+ .load-button { width: 100%; border: 0; border-radius: 8px; background: var(--light-purple); color: #000; height: 38px; display: flex; justify-content: center; align-items: center; gap: 8px; font-size: 12px; font-weight: 700; cursor: pointer; }
44
+ .load-button:disabled { cursor: wait; opacity: .85; }
45
+ .load-button svg { width: 15px; }
46
+ .progress-track { height: 3px; background: #32352c; border-radius: 4px; margin-top: 11px; overflow: hidden; }
47
+ .progress-track span { height: 100%; display: block; background: var(--light-purple); transition: width .25s; }
48
+ .progress-file { font: 9px/1.4 'JetBrains Mono', monospace; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; color: #8e9186; margin-top: 6px; }
49
+ .runtime-pill { display: inline-flex; align-items: center; gap: 7px; color: #afb2a7; font-size: 11px; background: #20221d; border: 1px solid #32352d; border-radius: 99px; padding: 7px 9px; }
50
+ .runtime-pill span { width: 6px; height: 6px; border-radius: 50%; background: #777; }
51
+ .runtime-pill.good span { background: var(--light-purple); box-shadow: 0 0 7px var(--light-purple); }
52
+ .runtime-pill.loading span { background: var(--light-purple); animation: pulse 1.2s ease-in-out infinite; }
53
+ .examples-side { flex: 1 1 auto; min-height: 100px; overflow-y: auto; }
54
+ .example-row { display: grid; grid-template-columns: 52px 1fr; gap: 10px; width: 100%; border: 0; background: transparent; color: #d2d4cb; text-align: left; padding: 10px 0; cursor: pointer; }
55
+ .example-row img { width: 52px; height: 44px; object-fit: cover; border-radius: 7px; border: 1px solid #3b3d36; }
56
+ .example-row span { font-size: 10px; line-height: 1.35; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
57
+ .example-row small { display: block; color: #7f8278; font: 8px 'JetBrains Mono', monospace; text-transform: uppercase; margin-bottom: 4px; }
58
+ .example-row:disabled, .example-card:disabled { cursor: not-allowed; opacity: .48; transform: none; box-shadow: none; }
59
+ .sidebar-bottom { flex: 0 0 auto; margin-top: auto; border-top: 1px solid #2e302a; padding-top: 10px; }
60
+ .side-link { width: 100%; min-height: 36px; padding: 0 6px; display: flex; align-items: center; gap: 10px; border: 0; background: none; text-decoration: none; color: #9da095; font-size: 11px; cursor: pointer; }
61
+ .side-link:hover { color: white; }
62
+ .side-link svg { width: 15px; }
63
+ .status-dot { width: 7px; height: 7px; border-radius: 50%; background: #777a71; margin-left: auto; }
64
+ .status-dot.online { background: var(--light-purple); box-shadow: 0 0 7px var(--light-purple); }
65
+
66
+ .main { height: 100%; min-width: 0; min-height: 0; overflow: hidden; display: grid; grid-template-rows: 58px minmax(0, 1fr) auto; background: #fff; position: relative; }
67
+ .topbar { border-bottom: 1px solid var(--soft-line); display: flex; align-items: center; justify-content: flex-end; padding: 0 28px; }
68
+ .topbar-status, .runtime-status { font: 10px 'JetBrains Mono', monospace; color: #777970; letter-spacing: .02em; }
69
+ .topbar-status { display: flex; align-items: center; gap: 7px; margin-right: 22px; }
70
+ .privacy-dot { width: 6px; height: 6px; background: var(--purple); border-radius: 50%; }
71
+ .runtime-status { height: 30px; display: flex; align-items: center; gap: 8px; padding: 0 10px; }
72
+ .runtime-status .status-dot { margin: 0; }
73
+ .menu-button, .mobile-brand { display: none !important; }
74
+ .icon-button { width: 34px; height: 34px; border: 0; border-radius: 8px; background: transparent; display: inline-flex; align-items: center; justify-content: center; cursor: pointer; }
75
+ .icon-button:hover, .icon-button.active { background: #eff0e9; }
76
+
77
+ .conversation { overflow-y: auto; min-height: 0; overscroll-behavior: contain; scrollbar-width: thin; scrollbar-color: #d9dad3 transparent; }
78
+ .welcome { width: min(790px, calc(100% - 48px)); min-height: 100%; margin: 0 auto; display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 54px 0 30px; }
79
+ .welcome h1, .loader-card h1 { font: 500 clamp(42px, 5.1vw, 67px)/.98 Söhne, 'Helvetica Neue', Arial, sans-serif; letter-spacing: -.03em; text-align: center; margin: 0 0 18px; }
80
+ .welcome h1 { display: flex; align-items: center; justify-content: center; gap: clamp(15px, 1.5vw, 23px); }
81
+ .hero-mark { width: clamp(42px, 4.6vw, 62px); height: auto; flex: 0 0 auto; }
82
+ .welcome h1 em, .loader-card h1 em { font-style: normal; color: var(--purple); }
83
+ .hero-copy { max-width: 610px; text-align: center; color: #686b61; font-size: 14px; line-height: 1.6; margin: 0 0 36px; }
84
+ .example-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 13px; width: 100%; }
85
+ .example-card { border: 1px solid var(--line); border-radius: 14px; background: white; overflow: hidden; text-align: left; padding: 0; cursor: pointer; transition: transform .2s, box-shadow .2s; }
86
+ .example-card:last-child:nth-child(odd) { grid-column: 1 / -1; width: calc((100% - 13px) / 2); justify-self: center; }
87
+ .example-card:hover { transform: translateY(-2px); box-shadow: 0 10px 28px #28291d12; }
88
+ .example-card img { width: 100%; height: 112px; object-fit: cover; display: block; }
89
+ .example-card > span { display: grid; grid-template-columns: 1fr 20px; padding: 13px 15px 15px; font-size: 11px; line-height: 1.45; }
90
+ .example-card small { grid-column: 1 / -1; font: 8px 'JetBrains Mono', monospace; letter-spacing: .08em; color: #8c8f84; text-transform: uppercase; margin-bottom: 5px; }
91
+ .example-card svg { grid-column: 2; grid-row: 2; color: var(--purple); width: 15px; }
92
+ .examples-coming-soon { width: 100%; min-height: 118px; border: 1px dashed rgb(0 0 0 / 28%); border-radius: 14px; display: grid; place-items: center; color: #8a8d83; font: 10px 'JetBrains Mono', monospace; letter-spacing: .02em; }
93
+ .examples-coming-soon.compact { min-height: 72px; margin-top: 13px; border-color: rgb(255 255 255 / 24%); color: #85887d; border-radius: 9px; }
94
+
95
+ .messages { width: min(820px, calc(100% - 48px)); margin: 0 auto; padding: 46px 0 112px; }
96
+ .message { display: grid; grid-template-columns: 88px 1fr; gap: 24px; border-bottom: 1px solid var(--soft-line); padding: 29px 0; }
97
+ .message:first-child { padding-top: 0; }
98
+ .message-label { font: 500 10px 'JetBrains Mono', monospace; color: #74776d; display: flex; gap: 7px; align-items: flex-start; padding-top: 3px; }
99
+ .message-label img { width: 14px; height: 14px; }
100
+ .message.assistant .message-label { color: var(--purple); }
101
+ .message-content { min-width: 0; position: relative; }
102
+ .message-text { font-size: 14px; line-height: 1.75; color: #30322c; overflow-wrap: anywhere; }
103
+ .message-text.plain-text { white-space: pre-wrap; }
104
+ .markdown-body > :first-child { margin-top: 0; }
105
+ .markdown-body > :last-child { margin-bottom: 0; }
106
+ .markdown-body p, .markdown-body ul, .markdown-body ol, .markdown-body blockquote, .markdown-body pre, .markdown-body table { margin: 0 0 12px; }
107
+ .markdown-body ul, .markdown-body ol { padding-left: 22px; }
108
+ .markdown-body li + li { margin-top: 3px; }
109
+ .markdown-body h1, .markdown-body h2, .markdown-body h3, .markdown-body h4 { margin: 18px 0 8px; color: #22241f; line-height: 1.3; }
110
+ .markdown-body h1 { font-size: 20px; }
111
+ .markdown-body h2 { font-size: 17px; }
112
+ .markdown-body h3, .markdown-body h4 { font-size: 15px; }
113
+ .markdown-body a { color: var(--purple); text-decoration: underline; text-underline-offset: 2px; }
114
+ .markdown-body blockquote { padding-left: 12px; border-left: 3px solid rgb(85 5 175 / 28%); color: #66695f; }
115
+ .markdown-body code { padding: 2px 4px; border-radius: 4px; background: #f1f0ec; font: 12px/1.5 'JetBrains Mono', monospace; }
116
+ .markdown-body pre { padding: 12px; border: 1px solid var(--line); border-radius: 8px; background: #f7f7f3; overflow: auto; }
117
+ .markdown-body pre code { padding: 0; background: transparent; }
118
+ .markdown-body table { width: 100%; border-collapse: collapse; display: block; overflow-x: auto; }
119
+ .markdown-body th, .markdown-body td { padding: 7px 9px; border: 1px solid var(--line); text-align: left; }
120
+ .response-spinner { display: inline-block; width: 16px; height: 16px; border: 2px solid rgb(85 5 175 / 18%); border-top-color: var(--purple); border-radius: 50%; animation: spin .75s linear infinite; vertical-align: middle; }
121
+ .message-images { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 14px; }
122
+ .image-enlarge { display: block; border: 0; padding: 0; background: transparent; cursor: zoom-in; text-align: left; }
123
+ .overlay-image { display: block; position: relative; width: fit-content; max-width: 100%; }
124
+ .overlay-image > img { display: block; max-width: 100%; }
125
+ .message-images .image-enlarge { max-width: min(230px, 100%); }
126
+ .message-images img { width: 100%; height: auto; max-height: 220px; object-fit: contain; border-radius: 10px; border: 1px solid var(--line); background: #f6f6f2; }
127
+ .edit-message { border: 0; background: transparent; color: #8a8d83; padding: 8px 0 0; display: flex; gap: 5px; align-items: center; font-size: 10px; cursor: pointer; }
128
+ .edit-message svg { width: 12px; }
129
+
130
+ .composer-zone { width: min(820px, calc(100% - 48px)); justify-self: center; padding: 7px 0 14px; position: relative; }
131
+ .composer-zone::before { content: ''; position: absolute; left: -30px; right: -30px; bottom: 0; top: -45px; pointer-events: none; background: linear-gradient(transparent, #fff 42%); z-index: 0; }
132
+ .composer { min-height: 106px; border: 1px solid #d4d5ce; background: white; border-radius: 16px; box-shadow: 0 8px 28px #28291d12; padding: 15px 16px 11px; position: relative; z-index: 2; }
133
+ .composer.drag-active { border-color: var(--purple); background: rgb(205 130 240 / 7%); box-shadow: 0 0 0 4px rgb(85 5 175 / 10%), 0 8px 28px #28291d12; }
134
+ .composer textarea { display: block; width: 100%; min-height: 42px; max-height: 180px; resize: none; border: 0; outline: 0; color: var(--ink); background: transparent; line-height: 1.5; font-size: 13px; }
135
+ .composer textarea::placeholder { color: #9a9c94; }
136
+ .composer-actions { display: flex; align-items: center; min-height: 33px; }
137
+ .composer-tools { display: flex; gap: 2px; }
138
+ .composer-tools .icon-button { width: 30px; height: 30px; color: #777a70; }
139
+ .composer-tools svg { width: 16px; }
140
+ .composer-hint { margin-left: auto; margin-right: 11px; color: #a0a299; font: 9px 'JetBrains Mono', monospace; }
141
+ .send-button { border: 0; width: 34px; height: 34px; border-radius: 9px; display: grid; place-items: center; background: #181916; color: white; cursor: pointer; }
142
+ .send-button:hover { background: var(--purple); }
143
+ .send-button svg { width: 15px; }
144
+ .stop-button { background: #181916; }
145
+ .stop-symbol { width: 11px; height: 11px; border-radius: 1px; background: white; }
146
+ .local-note { position: relative; z-index: 1; text-align: center; color: #9a9d93; font: 9px 'JetBrains Mono', monospace; margin: 9px 0 0; }
147
+ .attachment-strip { position: relative; z-index: 1; display: flex; gap: 8px; padding: 9px 12px 10px; overflow-x: auto; }
148
+ .attachment { width: 65px; height: 55px; flex: 0 0 auto; border: 2px solid white; outline: 1px solid #d3d4cc; border-radius: 9px; position: relative; }
149
+ .attachment .image-enlarge, .attachment .overlay-image { width: 100%; height: 100%; }
150
+ .attachment img { width: 100%; height: 100%; object-fit: cover; border-radius: 6px; display: block; }
151
+ .attachment .attachment-remove { position: absolute; z-index: 2; top: -7px; right: -7px; width: 18px; height: 18px; display: grid; place-items: center; border: 0; border-radius: 50%; background: #171815; color: white; cursor: pointer; padding: 0; }
152
+ .attachment .attachment-remove svg { width: 10px; }
153
+ .error-banner { z-index: 3; position: relative; display: flex; justify-content: space-between; align-items: center; background: #fff0ec; border: 1px solid #efb7aa; color: #8d3422; border-radius: 9px; padding: 9px 12px; font-size: 11px; margin-bottom: 8px; }
154
+ .error-banner button { border: 0; background: transparent; color: inherit; padding: 0; cursor: pointer; }
155
+ .error-banner svg { width: 14px; }
156
+ .settings-popover { position: absolute; z-index: 3; left: 10px; bottom: 54px; width: 310px; background: white; border: 1px solid var(--line); border-radius: 13px; box-shadow: 0 18px 50px #22231825; padding: 16px; }
157
+ .popover-title { font-size: 12px; font-weight: 600; margin-bottom: 15px; }
158
+ .settings-popover > label { display: block; margin-top: 13px; }
159
+ .settings-popover label > span { display: flex; justify-content: space-between; font-size: 10px; color: #62655b; }
160
+ .settings-popover output { font-family: 'JetBrains Mono', monospace; }
161
+ .settings-popover input[type=range] { width: 100%; accent-color: #7569ed; }
162
+ .toggle-row { display: flex !important; justify-content: space-between; align-items: center; border-top: 1px solid var(--soft-line); padding-top: 13px; }
163
+ .toggle-row > span { display: block !important; }
164
+ .toggle-row b { display: block; font-size: 10px; color: #34362f; }
165
+ .toggle-row small { display: block; margin-top: 3px; font-size: 8px; color: #8c8e86; max-width: 220px; }
166
+ .toggle-row input { accent-color: #7569ed; }
167
+
168
+ .drawer-scrim, .modal-scrim { position: fixed; inset: 0; background: #11120f66; backdrop-filter: blur(3px); z-index: 110; }
169
+ .drawer-panel { position: absolute; right: 0; top: 0; bottom: 0; width: min(440px, 100%); background: #fbfbf8; padding: 27px; box-shadow: -20px 0 60px #11120f25; overflow-y: auto; }
170
+ .drawer-head { display: flex; align-items: flex-start; justify-content: space-between; }
171
+ .drawer-head h2 { font: 600 25px Söhne, 'Helvetica Neue', Arial, sans-serif; margin: 5px 0 0; letter-spacing: -.03em; }
172
+ .secondary-button { height: 38px; border: 1px solid var(--line); background: white; border-radius: 8px; padding: 0 13px; font-size: 10px; display: flex; gap: 7px; align-items: center; cursor: pointer; }
173
+ .secondary-button svg { width: 14px; }
174
+ .secondary-button:disabled { cursor: not-allowed; opacity: .5; }
175
+
176
+ .drawer-intro { color: #6f7268; font-size: 11px; line-height: 1.55; margin: 18px 0 24px; }
177
+ .tool-section-head { display: flex; align-items: center; justify-content: space-between; margin: 22px 0 9px; font-size: 11px; font-weight: 600; }
178
+ .tool-section-head small { color: #898c82; font: 8px 'JetBrains Mono', monospace; }
179
+ .tool-section-head button { border: 0; background: none; color: var(--purple); font-size: 10px; cursor: pointer; }
180
+ .tool-config-list { border: 1px solid var(--line); border-radius: 11px; overflow: hidden; background: white; }
181
+ .tool-config-list.custom { margin-top: 10px; }
182
+ .tool-config-row { display: grid; grid-template-columns: 32px 1fr auto; gap: 9px; align-items: start; padding: 13px; border-bottom: 1px solid var(--soft-line); }
183
+ .tool-config-row > div:nth-child(2) { min-width: 0; }
184
+ .tool-config-row:last-child { border-bottom: 0; }
185
+ .tool-config-row strong { display: block; font: 600 11px 'JetBrains Mono', monospace; }
186
+ .tool-config-row p { color: #6f7268; font-size: 9px; line-height: 1.45; margin: 4px 0; }
187
+ .tool-config-row small { color: #979a90; font-size: 8px; }
188
+ .tool-definition { margin-top: 8px; }
189
+ .tool-definition summary { width: fit-content; color: var(--purple); cursor: pointer; list-style-position: inside; font: 8px 'JetBrains Mono', monospace; }
190
+ .tool-definition pre { max-width: 100%; max-height: 260px; margin: 8px 0 0; padding: 9px; overflow: auto; border: 1px solid var(--soft-line); border-radius: 7px; background: #f6f6f2; color: #4f5249; white-space: pre-wrap; overflow-wrap: anywhere; font: 8px/1.55 'JetBrains Mono', monospace; }
191
+ .switch input { position: absolute; opacity: 0; pointer-events: none; }
192
+ .switch i { display: block; width: 30px; height: 17px; border-radius: 9px; background: #d5d6cf; position: relative; cursor: pointer; transition: .15s; }
193
+ .switch i::after { content: ''; position: absolute; width: 13px; height: 13px; left: 2px; top: 2px; border-radius: 50%; background: white; transition: .15s; box-shadow: 0 1px 3px #0003; }
194
+ .switch input:checked + i { background: var(--purple); }
195
+ .switch input:checked + i::after { transform: translateX(13px); }
196
+ .inline-form-error { color: #8d3422; background: #fff0ec; border: 1px solid #efb7aa; border-radius: 7px; padding: 8px 9px; margin-bottom: 11px; font-size: 9px; line-height: 1.45; }
197
+ .primary-small { min-height: 36px; border: 0; border-radius: 8px; background: #171815; color: white; padding: 0 14px; font-size: 10px; cursor: pointer; }
198
+ .mcp-server-form { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 8px; }
199
+ .mcp-server-form input { min-width: 0; border: 1px solid var(--line); border-radius: 8px; padding: 9px 10px; outline: none; color: #252720; background: white; font: 9px/1.4 'JetBrains Mono', monospace; }
200
+ .mcp-server-form input:focus { border-color: var(--purple); box-shadow: 0 0 0 3px rgb(85 5 175 / 10%); }
201
+ .mcp-server-form .secondary-button { min-height: 36px; }
202
+ .mcp-error { margin: 8px 0 0; }
203
+ .mcp-tools { margin-top: 9px; }
204
+ .tool-privacy-note { margin-top: 19px; border-radius: 9px; background: rgb(205 130 240 / 16%); color: #4c1a83; padding: 12px; font-size: 9px; line-height: 1.55; }
205
+
206
+ .system-prompt-inline { margin: -3px 0 8px; padding-bottom: 7px; border-bottom: 1px solid transparent; }
207
+ .system-prompt-inline.open { border-bottom-color: var(--soft-line); }
208
+ .composer-context-row { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
209
+ .system-prompt-toggle { min-height: 29px; margin: 0; padding: 0 9px; display: flex; align-items: center; gap: 7px; border: 1px solid var(--line); border-radius: 7px; background: #fafaf7; color: #686b61; cursor: pointer; font: 8px 'JetBrains Mono', monospace; }
210
+ .system-prompt-toggle:hover, .system-prompt-toggle.enabled, .system-prompt-inline.open .system-prompt-toggle { color: var(--purple); border-color: rgb(85 5 175 / 38%); background: rgb(205 130 240 / 9%); }
211
+ .tool-calling-toggle { min-height: 29px; margin: 0 0 0 auto; padding: 0 9px; display: flex; align-items: center; gap: 7px; border: 1px solid var(--line); border-radius: 7px; background: #fafaf7; color: #686b61; cursor: pointer; font: 8px 'JetBrains Mono', monospace; }
212
+ .tool-calling-toggle:hover, .tool-calling-toggle.enabled { color: var(--purple); border-color: rgb(85 5 175 / 38%); background: rgb(205 130 240 / 9%); }
213
+ .tool-calling-toggle > span:last-child { font: 14px/1 Arial, sans-serif; }
214
+ .system-prompt-chevron { display: inline-block; color: currentColor; font: 12px/1 Arial, sans-serif; transition: transform .16s; }
215
+ .system-prompt-inline.open .system-prompt-chevron { transform: rotate(180deg); }
216
+ .system-prompt-form { margin-top: 5px; }
217
+ .system-prompt-presets { display: flex; align-items: center; flex-wrap: wrap; gap: 5px; margin-bottom: 6px; }
218
+ .system-prompt-presets > span { margin-right: 2px; color: #999c92; font: 7px 'JetBrains Mono', monospace; text-transform: uppercase; letter-spacing: .05em; }
219
+ .system-prompt-presets button { min-height: 27px; padding: 0 9px; border: 1px solid var(--line); border-radius: 99px; background: white; color: #686b61; cursor: pointer; font-size: 8px; }
220
+ .system-prompt-presets button:hover, .system-prompt-presets button.active { border-color: rgb(85 5 175 / 38%); background: rgb(205 130 240 / 9%); color: var(--purple); }
221
+ .system-prompt-form textarea { width: 100%; max-height: 190px; padding: 10px; resize: vertical; border: 1px solid var(--line); border-radius: 9px; outline: 0; color: #252720; background: #fafaf7; font: 10px/1.55 'JetBrains Mono', monospace; }
222
+ .system-prompt-form textarea:focus { border-color: var(--purple); box-shadow: 0 0 0 3px rgb(85 5 175 / 10%); }
223
+ .system-prompt-meta { display: flex; align-items: center; gap: 10px; margin-top: 6px; color: #999c92; font: 7px 'JetBrains Mono', monospace; }
224
+ .system-prompt-actions { display: flex; gap: 5px; margin-left: auto; }
225
+ .system-prompt-actions button { min-height: 27px; padding: 0 9px; border: 1px solid var(--line); border-radius: 6px; background: white; color: #686b61; cursor: pointer; font-size: 8px; }
226
+ .system-prompt-actions button[type=submit] { border-color: #181916; background: #181916; color: white; }
227
+
228
+ .grounding-results { margin-top: 18px; padding-top: 15px; border-top: 1px solid var(--soft-line); }
229
+ .grounding-heading { display: grid; grid-template-columns: minmax(0, 1fr) auto 16px; align-items: center; gap: 9px; color: var(--purple); cursor: pointer; list-style: none; font: 600 9px 'JetBrains Mono', monospace; text-transform: uppercase; letter-spacing: .06em; }
230
+ .grounding-heading::-webkit-details-marker { display: none; }
231
+ .grounding-heading > span { display: flex; align-items: center; gap: 7px; min-width: 0; }
232
+ .grounding-heading svg { width: 14px; }
233
+ .grounding-heading small { color: #92958b; font: 8px 'JetBrains Mono', monospace; letter-spacing: 0; text-transform: none; }
234
+ .grounding-heading > i { font: 17px/1 Arial, sans-serif; transition: transform .16s; }
235
+ .grounding-results[open] .grounding-heading > i { transform: rotate(90deg); }
236
+ .grounding-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(min(230px, 100%), 280px)); align-items: start; gap: 13px; margin-top: 11px; }
237
+ .grounding-results figure { width: 100%; margin: 0; }
238
+ .grounding-image { width: 100%; }
239
+ .grounding-image .overlay-image { width: fit-content; max-width: 100%; }
240
+ .grounding-image img { width: auto; height: auto; max-width: 100%; max-height: 260px; border: 1px solid var(--line); border-radius: 10px; background: #f6f6f2; }
241
+ .grounding-results figcaption { margin-top: 6px; color: #85887d; font: 8px 'JetBrains Mono', monospace; }
242
+ .document-region-list { display: grid; gap: 5px; max-height: 280px; margin-top: 9px; overflow: auto; }
243
+ .document-region-list > div { display: grid; grid-template-columns: 18px minmax(62px, 82px) minmax(0, 1fr); gap: 7px; align-items: start; padding: 7px 8px; border: 1px solid var(--soft-line); border-radius: 7px; background: #fafaf7; }
244
+ .document-region-list span { width: 17px; height: 17px; display: grid; place-items: center; border-radius: 50%; background: var(--purple); color: white; font: 7px 'JetBrains Mono', monospace; }
245
+ .document-region-list strong { color: var(--purple); font: 8px/1.6 'JetBrains Mono', monospace; }
246
+ .document-region-list p { min-width: 0; margin: 0; color: #5d6056; white-space: pre-wrap; overflow-wrap: anywhere; font-size: 9px; line-height: 1.45; }
247
+ .grounding-overlay { position: absolute; inset: 0; pointer-events: none; }
248
+ .grounding-box { position: absolute; border: 1.5px solid var(--light-purple); box-shadow: 0 0 0 1px rgb(0 0 0 / 35%), inset 0 0 0 1px rgb(0 0 0 / 20%); }
249
+ .grounding-label { position: absolute; left: -1px; top: -1px; max-width: 180px; padding: 3px 5px; background: var(--purple); color: white; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font: 500 9px/1.2 'JetBrains Mono', monospace; transform: translateY(-100%); }
250
+ .grounding-point { position: absolute; width: 0; height: 0; }
251
+ .grounding-point > i { position: absolute; left: -6px; top: -6px; width: 12px; height: 12px; border: 2px solid white; border-radius: 50%; background: var(--purple); box-shadow: 0 0 0 1px rgb(0 0 0 / 55%); }
252
+ .grounding-point > b { position: absolute; left: -4px; top: -4px; width: 8px; color: white; text-align: center; font: 600 6px/8px 'JetBrains Mono', monospace; }
253
+ .grounding-point .grounding-label { left: 9px; top: 0; transform: translateY(-50%); }
254
+
255
+
256
+ .lightbox-scrim { z-index: 140; background: rgb(10 10 9 / 88%); }
257
+ .lightbox { position: relative; display: grid; place-items: center; width: min(94vw, 1400px); height: min(92dvh, 1000px); }
258
+ .lightbox > .overlay-image { width: auto; max-width: 100%; max-height: 100%; }
259
+ .lightbox > .overlay-image > img { width: auto; max-width: 100%; max-height: 92dvh; object-fit: contain; }
260
+ .lightbox-close { position: fixed; z-index: 2; right: 22px; top: 20px; width: 38px; height: 38px; display: grid; place-items: center; border: 1px solid rgb(255 255 255 / 25%); border-radius: 50%; background: rgb(0 0 0 / 42%); color: white; cursor: pointer; }
261
+ .lightbox-close svg { width: 18px; }
262
+
263
+ .tool-call-list, .tool-result-list { display: grid; gap: 8px; margin-top: 12px; }
264
+ .tool-call-card, .tool-result-card { border: 1px solid var(--line); border-radius: 9px; background: #fafaf7; overflow: hidden; }
265
+ .tool-call-card > div, .tool-result-card > div { display: flex; align-items: center; gap: 7px; padding: 9px 11px; }
266
+ .tool-call-card strong, .tool-result-card strong { font: 600 10px 'JetBrains Mono', monospace; }
267
+ .tool-call-card small, .tool-result-card span { margin-left: auto; color: #85887d; font: 8px 'JetBrains Mono', monospace; }
268
+ .tool-state { width: 7px; height: 7px; border-radius: 50%; background: #9a9c94; }
269
+ .tool-call-card.running .tool-state { background: var(--purple); box-shadow: 0 0 0 3px rgb(205 130 240 / 25%); }
270
+ .tool-call-card.success .tool-state { background: var(--purple); }
271
+ .tool-call-card.error .tool-state { background: var(--orange); }
272
+ .tool-call-card pre, .tool-result-card pre { margin: 0; padding: 10px 11px; border-top: 1px solid var(--soft-line); background: white; color: #4f5249; overflow: auto; white-space: pre-wrap; font: 9px/1.5 'JetBrains Mono', monospace; }
273
+ .tool-raw-output { min-width: 0; max-width: 100%; border-top: 1px solid var(--soft-line); background: #fffaf4; overflow: hidden; }
274
+ .tool-raw-output summary { padding: 8px 11px; cursor: pointer; color: #7b5a36; font: 8px 'JetBrains Mono', monospace; }
275
+ .tool-raw-output pre { box-sizing: border-box; width: 100%; max-width: 100%; max-height: 240px; margin: 0; padding: 10px 11px; overflow: auto; border: 0; border-top: 1px solid #eee2d2; background: #fffdf9; color: #4f5249; white-space: pre; overflow-wrap: normal; font: 9px/1.5 'JetBrains Mono', monospace; }
276
+ .tool-result-card.success { border-left: 3px solid var(--purple); }
277
+ .tool-result-card.error { border-left: 3px solid var(--orange); }
278
+ .message.tool .message-label { color: #777970; }
279
+
280
+ .loader-screen { position: fixed; inset: 0; z-index: 100; background: #11120f; display: grid; place-items: center; overflow: auto; padding: 30px; color: white; }
281
+ .loader-screen::before { content: ''; position: absolute; inset: 0; opacity: .18; background-image: linear-gradient(#ffffff09 1px, transparent 1px), linear-gradient(90deg, #ffffff09 1px, transparent 1px); background-size: 42px 42px; mask-image: radial-gradient(circle, black, transparent 72%); }
282
+ .loader-glow { position: absolute; width: 560px; height: 560px; border-radius: 50%; background: radial-gradient(circle, rgb(205 130 240 / 24%), rgb(85 5 175 / 12%) 35%, transparent 68%); }
283
+ .loader-card { width: min(450px, 100%); position: relative; text-align: center; }
284
+ .loader-brand { display: flex; align-items: center; justify-content: center; }
285
+ .loader-brand img { width: 96px; height: auto; }
286
+ .loader-orb { width: 51px; height: 51px; margin: 42px auto 20px; border-radius: 50%; background: radial-gradient(circle at 65% 25%, #fff, var(--light-purple) 28%, var(--purple) 64%, #000); box-shadow: 0 0 38px rgb(205 130 240 / 42%); }
287
+ .loader-card .eyebrow { justify-content: center; color: #a5a89c; }
288
+ .loader-card h1 { font-size: clamp(41px, 6vw, 58px); margin: 15px 0; }
289
+ .loader-card > p { color: #aaada1; font-size: 12px; line-height: 1.65; max-width: 390px; margin: 0 auto 27px; }
290
+ .browser-performance-note { max-width: 390px; margin: -12px auto 20px; padding: 10px 11px; display: flex; align-items: flex-start; gap: 8px; border: 1px solid #393b34; border-radius: 8px; color: #aaada1; text-align: left; font-size: 9px; line-height: 1.5; }
291
+ .browser-performance-note svg { width: 14px; flex: 0 0 14px; color: var(--light-purple); }
292
+ .model-loader { text-align: left; }
293
+ .load-model-button { width: 100%; height: 47px; margin-top: 4px; border-radius: 9px; border: 0; background: var(--light-purple); color: #000; font-weight: 700; font-size: 12px; display: flex; justify-content: center; gap: 10px; align-items: center; cursor: pointer; }
294
+ .load-model-button:disabled { cursor: wait; opacity: .9; }
295
+ .load-model-button svg { width: 15px; }
296
+ .load-model-button b { margin-left: auto; margin-right: 14px; font: 600 10px 'JetBrains Mono', monospace; }
297
+ .model-loader .spinner { border-color: rgb(0 0 0 / 20%); border-top-color: #000; }
298
+ .loader-progress { height: 4px; border-radius: 3px; background: #30322b; overflow: hidden; margin-top: 12px; }
299
+ .loader-progress span { display: block; height: 100%; background: var(--light-purple); transition: width .25s; }
300
+ .loader-progress-file { color: #7f8277; font: 9px/1.4 'JetBrains Mono', monospace; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; margin-top: 8px; }
301
+ .loader-cache-note { color: #777a70; text-align: center; font: 8px/1.5 'JetBrains Mono', monospace; margin-top: 10px; }
302
+ .loader-precision-note { color: var(--light-purple); text-align: center; font: 8px/1.5 'JetBrains Mono', monospace; margin-top: 7px; }
303
+ .loader-error { color: #ffb59a; background: rgb(255 95 30 / 10%); border: 1px solid rgb(255 95 30 / 35%); border-radius: 8px; padding: 10px 12px; margin: -10px 0 14px; font-size: 10px; line-height: 1.5; text-align: left; }
304
+
305
+ .modal-scrim { display: grid; place-items: center; padding: 24px; }
306
+ .cache-modal { width: min(420px, 100%); padding: 22px; border-radius: 16px; background: #fbfbf8; box-shadow: 0 24px 80px rgb(0 0 0 / 18%); }
307
+ .cache-modal > p { margin: 18px 0; color: #6f7268; font-size: 11px; line-height: 1.55; }
308
+ .cache-total { display: flex; align-items: center; justify-content: space-between; gap: 20px; padding: 14px 0; border-top: 1px solid var(--line); border-bottom: 1px solid var(--line); font-size: 11px; }
309
+ .cache-total strong { font: 600 11px 'JetBrains Mono', monospace; }
310
+ .cache-clear { margin-top: 18px; }
311
+ .webcam-modal { width: min(660px, 100%); background: #fbfbf8; border-radius: 18px; padding: 22px; }
312
+ .video-frame { aspect-ratio: 16/10; margin-top: 19px; border-radius: 12px; overflow: hidden; background: #151612; position: relative; display: grid; place-items: center; }
313
+ .video-frame video { width: 100%; height: 100%; object-fit: cover; position: absolute; transform: scaleX(-1); }
314
+ .camera-wait { color: #8d9085; font: 10px 'JetBrains Mono', monospace; }
315
+ .capture-button { width: 100%; height: 43px; border: 0; border-radius: 9px; background: #191a17; color: white; margin-top: 14px; display: flex; justify-content: center; align-items: center; gap: 9px; cursor: pointer; }
316
+ .spinner { width: 13px; height: 13px; border: 2px solid #1b1c1740; border-top-color: #1b1c17; border-radius: 50%; animation: spin .8s linear infinite; }
317
+ @keyframes spin { to { transform: rotate(360deg); } }
318
+ @keyframes pulse { 50% { opacity: .35; } }
319
+
320
+ @media (max-width: 800px) {
321
+ :root { --sidebar: min(310px, 88vw); }
322
+ .shell { display: block; }
323
+ .sidebar { position: fixed; inset: 0 auto 0 0; width: var(--sidebar); transform: translateX(-102%); transition: transform .25s; box-shadow: 20px 0 55px #11130f45; }
324
+ .sidebar.open { transform: translateX(0); }
325
+ .sidebar-close { display: inline-flex !important; color: white; }
326
+ .main { height: 100%; }
327
+ .topbar { justify-content: space-between; padding: 0 14px; }
328
+ .menu-button { display: inline-flex !important; }
329
+ .mobile-brand { display: flex !important; align-items: center; gap: 8px; font: 600 12px Söhne, 'Helvetica Neue', Arial, sans-serif; }
330
+ .mobile-brand img { width: 17px; }
331
+ .topbar-status { display: none; }
332
+ .welcome { width: calc(100% - 28px); padding-top: 35px; justify-content: flex-start; }
333
+ .welcome h1 { font-size: 40px; }
334
+ .hero-copy { font-size: 12px; margin-bottom: 24px; }
335
+ .example-grid { grid-template-columns: 1fr; }
336
+ .example-card:last-child:nth-child(odd) { width: 100%; }
337
+ .example-card img { height: 92px; }
338
+ .messages { width: calc(100% - 28px); padding-top: 25px; }
339
+ .message { display: block; padding: 21px 0; }
340
+ .message-label { margin-bottom: 12px; }
341
+ .composer-zone { width: calc(100% - 20px); padding-bottom: 8px; }
342
+ .composer-hint { display: none; }
343
+ .system-prompt-meta { align-items: flex-start; flex-wrap: wrap; }
344
+ .settings-popover { left: 4px; width: calc(100% - 8px); }
345
+ .local-note { font-size: 8px; }
346
+ .loader-screen { padding: 24px 18px; }
347
+ .loader-card h1 { font-size: 40px; }
348
+ }
349
+
350
+ @media (prefers-reduced-motion: reduce) {
351
+ *, *::before, *::after { scroll-behavior: auto !important; animation-duration: .01ms !important; transition-duration: .01ms !important; }
352
+ }
src/tools/mcp-client.js ADDED
@@ -0,0 +1,85 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const MAX_RESULT_CHARACTERS = 4000;
2
+ const TOOL_NAME = /^[A-Za-z_][A-Za-z0-9_]{0,63}$/;
3
+
4
+ let client = null;
5
+
6
+ export async function connectMcpServer(rawUrl) {
7
+ const url = validateServerUrl(rawUrl);
8
+ await disconnectMcpServer();
9
+ const [{ Client }, { StreamableHTTPClientTransport }] = await Promise.all([
10
+ import('@modelcontextprotocol/sdk/client/index.js'),
11
+ import('@modelcontextprotocol/sdk/client/streamableHttp.js'),
12
+ ]);
13
+ const nextClient = new Client({ name: 'lfm-webgpu', version: '1.0.0' }, { capabilities: {} });
14
+ try {
15
+ await nextClient.connect(new StreamableHTTPClientTransport(url, { fetch: postOnlyFetch }));
16
+ const response = await nextClient.listTools();
17
+ client = nextClient;
18
+ return normalizeMcpTools(response.tools);
19
+ } catch (error) {
20
+ await nextClient.close().catch(() => {});
21
+ throw error;
22
+ }
23
+ }
24
+
25
+ export async function disconnectMcpServer() {
26
+ const active = client;
27
+ client = null;
28
+ if (active) await active.close().catch(() => {});
29
+ }
30
+
31
+ export async function callMcpTool(name, args, signal) {
32
+ if (!client) throw new Error('The MCP server is not connected.');
33
+ if (signal?.aborted) throw new DOMException('Generation stopped.', 'AbortError');
34
+ const response = await client.callTool({ name, arguments: args });
35
+ if (signal?.aborted) throw new DOMException('Generation stopped.', 'AbortError');
36
+ if (response.isError) throw new Error(errorMessage(response.content));
37
+ return compactMcpResult(response);
38
+ }
39
+
40
+ export function compactMcpResult(response) {
41
+ const result = response.structuredContent ?? { content: response.content || [] };
42
+ const serialized = JSON.stringify(result);
43
+ if (serialized.length <= MAX_RESULT_CHARACTERS) return result;
44
+ const text = (response.content || []).filter(item => item.type === 'text').map(item => item.text).join('\n');
45
+ return {
46
+ truncated: true,
47
+ content: [{ type: 'text', text: (text || serialized).slice(0, MAX_RESULT_CHARACTERS) }],
48
+ };
49
+ }
50
+
51
+ export function normalizeMcpTools(tools = []) {
52
+ return (Array.isArray(tools) ? tools : []).flatMap(normalizeDiscoveredTool);
53
+ }
54
+
55
+ function normalizeDiscoveredTool(tool) {
56
+ const name = String(tool?.name || '');
57
+ const parameters = tool?.inputSchema;
58
+ if (!TOOL_NAME.test(name) || !parameters || parameters.type !== 'object' || !parameters.properties || Array.isArray(parameters.properties)) return [];
59
+ return [{
60
+ id: `mcp:${name}`,
61
+ name,
62
+ description: String(tool.description || `MCP tool: ${name}`),
63
+ parameters: structuredClone(parameters),
64
+ source: 'mcp',
65
+ enabled: false,
66
+ }];
67
+ }
68
+
69
+ function validateServerUrl(rawUrl) {
70
+ let url;
71
+ try { url = new URL(String(rawUrl || '').trim()); } catch { throw new Error('Enter a valid MCP server URL.'); }
72
+ const localHttp = url.protocol === 'http:' && ['localhost', '127.0.0.1', '[::1]'].includes(url.hostname);
73
+ if (url.protocol !== 'https:' && !localHttp) throw new Error('MCP servers must use HTTPS, except on localhost.');
74
+ return url;
75
+ }
76
+
77
+ function errorMessage(content) {
78
+ const text = (content || []).filter(item => item.type === 'text').map(item => item.text).join('\n').trim();
79
+ return text || 'The MCP tool returned an error.';
80
+ }
81
+
82
+ function postOnlyFetch(input, init = {}) {
83
+ if (String(init.method || 'GET').toUpperCase() === 'GET') return Promise.resolve(new Response(null, { status: 405 }));
84
+ return fetch(input, init);
85
+ }
src/tools/tool-protocol.js ADDED
@@ -0,0 +1,163 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ export const TOOL_CALL_START = '<|tool_call_start|>';
2
+ export const TOOL_CALL_END = '<|tool_call_end|>';
3
+
4
+ const CONTROL_TOKENS = /<\|(?:im_start|im_end|tool_call_start|tool_call_end)\|>/g;
5
+
6
+ export function parseToolCalls(rawText) {
7
+ const calls = [];
8
+ let cursor = 0;
9
+ while (cursor < rawText.length) {
10
+ const start = rawText.indexOf(TOOL_CALL_START, cursor);
11
+ if (start < 0) break;
12
+ const contentStart = start + TOOL_CALL_START.length;
13
+ const end = rawText.indexOf(TOOL_CALL_END, contentStart);
14
+ if (end < 0) throw new Error('The model emitted an incomplete tool call.');
15
+ calls.push(...parseCallList(rawText.slice(contentStart, end)));
16
+ cursor = end + TOOL_CALL_END.length;
17
+ }
18
+ return calls;
19
+ }
20
+
21
+ export function displayTextFromRaw(rawText) {
22
+ let output = '';
23
+ let cursor = 0;
24
+ while (cursor < rawText.length) {
25
+ const start = rawText.indexOf(TOOL_CALL_START, cursor);
26
+ if (start < 0) {
27
+ output += rawText.slice(cursor);
28
+ break;
29
+ }
30
+ output += rawText.slice(cursor, start);
31
+ const end = rawText.indexOf(TOOL_CALL_END, start + TOOL_CALL_START.length);
32
+ if (end < 0) break;
33
+ cursor = end + TOOL_CALL_END.length;
34
+ }
35
+ return output.replace(CONTROL_TOKENS, '').replace(/^assistant\s*\n/i, '').trim();
36
+ }
37
+
38
+ export function createToolStreamFilter(onText, { onToolCallStart, onToolCallEnd } = {}) {
39
+ let buffer = '';
40
+ let insideCall = false;
41
+ const reserve = Math.max(TOOL_CALL_START.length, TOOL_CALL_END.length) - 1;
42
+
43
+ function flush(force = false) {
44
+ while (buffer) {
45
+ const marker = insideCall ? TOOL_CALL_END : TOOL_CALL_START;
46
+ const markerIndex = buffer.indexOf(marker);
47
+ if (markerIndex >= 0) {
48
+ if (!insideCall && markerIndex > 0) emit(buffer.slice(0, markerIndex));
49
+ buffer = buffer.slice(markerIndex + marker.length);
50
+ insideCall = !insideCall;
51
+ if (insideCall) onToolCallStart?.();
52
+ else onToolCallEnd?.();
53
+ continue;
54
+ }
55
+ const length = force ? buffer.length : Math.max(0, buffer.length - reserve);
56
+ if (!length) break;
57
+ if (!insideCall) emit(buffer.slice(0, length));
58
+ buffer = buffer.slice(length);
59
+ }
60
+ }
61
+
62
+ function emit(value) {
63
+ const clean = value.replace(CONTROL_TOKENS, '');
64
+ if (clean) onText(clean);
65
+ }
66
+
67
+ return {
68
+ push(chunk) { buffer += chunk; flush(false); },
69
+ finish() { flush(true); },
70
+ };
71
+ }
72
+
73
+ function parseCallList(source) {
74
+ const trimmed = source.trim();
75
+ const body = trimmed.startsWith('[') && trimmed.endsWith(']')
76
+ ? trimmed.slice(1, -1)
77
+ : trimmed;
78
+ if (!body.trim()) return [];
79
+ return splitTopLevel(body).map(parseCall);
80
+ }
81
+
82
+ function parseCall(source) {
83
+ const match = /^([A-Za-z_][A-Za-z0-9_]*)\s*\(([\s\S]*)\)$/.exec(source.trim());
84
+ if (!match) throw new Error(`Malformed tool call: ${source.trim().slice(0, 80)}`);
85
+ const args = {};
86
+ const positional = [];
87
+ for (const part of splitTopLevel(match[2])) {
88
+ const assignment = findTopLevelAssignment(part);
89
+ if (assignment < 0) positional.push(parsePythonValue(part));
90
+ else {
91
+ const key = part.slice(0, assignment).trim();
92
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) throw new Error(`Invalid argument name: ${key}`);
93
+ if (Object.hasOwn(args, key)) throw new Error(`Duplicate argument: ${key}`);
94
+ args[key] = parsePythonValue(part.slice(assignment + 1));
95
+ }
96
+ }
97
+ return { name: match[1], arguments: args, positional };
98
+ }
99
+
100
+ function splitTopLevel(source) {
101
+ if (!source.trim()) return [];
102
+ const parts = [];
103
+ let start = 0;
104
+ let quote = '';
105
+ let escaped = false;
106
+ const stack = [];
107
+ for (let index = 0; index < source.length; index += 1) {
108
+ const character = source[index];
109
+ if (quote) {
110
+ if (escaped) escaped = false;
111
+ else if (character === '\\') escaped = true;
112
+ else if (character === quote) quote = '';
113
+ continue;
114
+ }
115
+ if (character === '"' || character === "'") { quote = character; continue; }
116
+ if ('([{'.includes(character)) stack.push(character);
117
+ else if (')]}'.includes(character)) stack.pop();
118
+ else if (character === ',' && stack.length === 0) {
119
+ parts.push(source.slice(start, index).trim());
120
+ start = index + 1;
121
+ }
122
+ }
123
+ if (quote || stack.length) throw new Error('Unbalanced tool-call arguments.');
124
+ const finalPart = source.slice(start).trim();
125
+ if (finalPart) parts.push(finalPart);
126
+ return parts;
127
+ }
128
+
129
+ function findTopLevelAssignment(source) {
130
+ let quote = '';
131
+ let escaped = false;
132
+ let depth = 0;
133
+ for (let index = 0; index < source.length; index += 1) {
134
+ const character = source[index];
135
+ if (quote) {
136
+ if (escaped) escaped = false;
137
+ else if (character === '\\') escaped = true;
138
+ else if (character === quote) quote = '';
139
+ } else if (character === '"' || character === "'") quote = character;
140
+ else if ('([{'.includes(character)) depth += 1;
141
+ else if (')]}'.includes(character)) depth -= 1;
142
+ else if (character === '=' && depth === 0) return index;
143
+ }
144
+ return -1;
145
+ }
146
+
147
+ function parsePythonValue(source) {
148
+ const value = source.trim();
149
+ if (!value) throw new Error('Missing tool argument value.');
150
+ if (value === 'True' || value === 'true') return true;
151
+ if (value === 'False' || value === 'false') return false;
152
+ if (value === 'None' || value === 'null') return null;
153
+ if (/^-?(?:\d+\.?\d*|\.\d+)(?:e[+-]?\d+)?$/i.test(value)) return Number(value);
154
+ if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
155
+ if (value[0] === '"') return JSON.parse(value);
156
+ return value.slice(1, -1).replace(/\\(['\\nrt])/g, (_, escaped) => ({ "'": "'", '\\': '\\', n: '\n', r: '\r', t: '\t' })[escaped]);
157
+ }
158
+ if (value.startsWith('[') && value.endsWith(']')) return splitTopLevel(value.slice(1, -1)).map(parsePythonValue);
159
+ if (value.startsWith('{') && value.endsWith('}')) {
160
+ try { return JSON.parse(value); } catch { throw new Error('Object arguments must use valid JSON.'); }
161
+ }
162
+ throw new Error(`Unsupported tool argument value: ${value.slice(0, 80)}`);
163
+ }
src/tools/tool-registry.js ADDED
@@ -0,0 +1,258 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const STORAGE_KEY = 'liquid-webgpu-tools-v1';
2
+ const STORAGE_VERSION = 2;
3
+
4
+ export const BUILTIN_TOOLS = [
5
+ definition('calculate', 'Evaluate arithmetic and trigonometric expressions. sin/cos/tan and asin/acos/atan use radians; append _deg for degree input/output (for example, sin_deg(30)). Also supports sqrt, abs, pi, and e.', {
6
+ expression: { type: 'string', description: 'Expression using numbers, +, -, *, /, %, ^, parentheses, pi/e, sqrt/abs, or sin/cos/tan/asin/acos/atan. Trig uses radians unless the function name ends in _deg.' },
7
+ }, ['expression']),
8
+ definition('current_datetime', 'Get the current date and time, optionally in an IANA time zone.', {
9
+ time_zone: { type: 'string', description: 'Optional IANA time zone, such as America/New_York.' },
10
+ }),
11
+ definition('random_integer', 'Generate a cryptographically random integer in an inclusive range.', {
12
+ min: { type: 'integer', description: 'Inclusive lower bound.' },
13
+ max: { type: 'integer', description: 'Inclusive upper bound.' },
14
+ }, ['min', 'max']),
15
+ definition('get_geolocation', 'Request the device location using the browser permission prompt.', {
16
+ high_accuracy: { type: 'boolean', description: 'Whether to request high-accuracy location.' },
17
+ }),
18
+ definition('search_recipe_by_dish', 'Search for one recipe matching the name of a completed dish.', {
19
+ dish_name: { type: 'string', description: 'The conventional name of the dish.' },
20
+ }, ['dish_name'], { external: true }),
21
+ ];
22
+
23
+ function definition(name, description, properties, required = [], metadata = {}) {
24
+ return { id: `builtin:${name}`, name, description, source: 'builtin', enabled: false, ...metadata, parameters: { type: 'object', properties, required, additionalProperties: false } };
25
+ }
26
+
27
+ export function loadTools() {
28
+ let saved = {};
29
+ try { saved = JSON.parse(localStorage.getItem(STORAGE_KEY) || '{}'); } catch { saved = {}; }
30
+ const enabled = saved.enabled || {};
31
+ const resetEnabledState = saved.version !== STORAGE_VERSION;
32
+ const builtins = BUILTIN_TOOLS.map(tool => ({ ...tool, enabled: resetEnabledState ? false : enabled[tool.id] ?? false }));
33
+ if (resetEnabledState || saved.custom) saveTools(builtins);
34
+ return builtins;
35
+ }
36
+
37
+ export function saveTools(tools) {
38
+ const enabled = Object.fromEntries(tools.filter(tool => tool.source === 'builtin').map(tool => [tool.id, Boolean(tool.enabled)]));
39
+ localStorage.setItem(STORAGE_KEY, JSON.stringify({ version: STORAGE_VERSION, enabled }));
40
+ }
41
+
42
+ export function modelToolDefinitions(tools) {
43
+ return tools.filter(tool => tool.enabled).map(({ name, description, parameters }) => ({ name, description, parameters }));
44
+ }
45
+
46
+ export function prepareToolCall(call, tools) {
47
+ const tool = tools.find(candidate => candidate.enabled && candidate.name === call.name);
48
+ if (!tool) throw new Error(`Unknown or disabled tool: ${call.name}`);
49
+ const propertyNames = Object.keys(tool.parameters.properties || {});
50
+ if (call.positional.length > propertyNames.length) throw new Error(`${tool.name} received too many positional arguments.`);
51
+ const args = { ...call.arguments };
52
+ call.positional.forEach((value, index) => {
53
+ const key = propertyNames[index];
54
+ if (Object.hasOwn(args, key)) throw new Error(`${tool.name} received ${key} twice.`);
55
+ args[key] = value;
56
+ });
57
+ validateArguments(tool.parameters, args);
58
+ return { tool, args };
59
+ }
60
+
61
+ export async function executeBuiltin(name, args, signal) {
62
+ if (signal?.aborted) throw new DOMException('Generation stopped.', 'AbortError');
63
+ if (name === 'calculate') return { value: calculate(args.expression) };
64
+ if (name === 'current_datetime') return currentDatetime(args.time_zone);
65
+ if (name === 'random_integer') return { value: randomInteger(args.min, args.max), min: args.min, max: args.max };
66
+ if (name === 'get_geolocation') return geolocate(args.high_accuracy, signal);
67
+ if (name === 'search_recipe_by_dish') return searchRecipeByDish(args.dish_name, signal);
68
+ throw new Error(`No built-in executor exists for ${name}.`);
69
+ }
70
+
71
+ export async function searchRecipeByDish(rawDishName, signal, fetcher = globalThis.fetch) {
72
+ const dishName = typeof rawDishName === 'string' ? rawDishName.trim() : '';
73
+ if (!dishName || dishName.length > 100) throw new Error('dish_name must contain between 1 and 100 characters.');
74
+ if (typeof fetcher !== 'function') throw new Error('Recipe search is unavailable in this browser.');
75
+
76
+ const response = await fetcher(`https://www.themealdb.com/api/json/v1/1/search.php?s=${encodeURIComponent(dishName)}`, { signal });
77
+ if (!response.ok) throw new Error(`TheMealDB search failed (${response.status}).`);
78
+ const queryWords = normalizedWords(dishName);
79
+ const candidates = (await response.json()).meals || [];
80
+ const relevant = candidates.filter(meal => {
81
+ const titleWords = normalizedWords(meal.strMeal || '');
82
+ return queryWords.every(word => titleWords.includes(word));
83
+ });
84
+ const exact = relevant.find(meal => normalizedWords(meal.strMeal || '').join(' ') === queryWords.join(' '));
85
+ const meal = exact || relevant[0] || null;
86
+ const recipes = meal ? [{
87
+ id: meal.idMeal,
88
+ name: meal.strMeal,
89
+ category: meal.strCategory || null,
90
+ cuisine: meal.strArea || null,
91
+ ingredients: extractRecipeIngredients(meal).map(item => [item.measure, item.name].filter(Boolean).join(' ')),
92
+ instructions: String(meal.strInstructions || '').trim().slice(0, 800),
93
+ source_url: meal.strSource || `https://www.themealdb.com/meal/${meal.idMeal}`,
94
+ }] : [];
95
+
96
+ return {
97
+ provider: 'TheMealDB',
98
+ recipes,
99
+ };
100
+ }
101
+
102
+ function extractRecipeIngredients(meal) {
103
+ const ingredients = [];
104
+ for (let index = 1; index <= 20; index += 1) {
105
+ const name = String(meal[`strIngredient${index}`] || '').trim();
106
+ if (name) ingredients.push({ name, measure: String(meal[`strMeasure${index}`] || '').trim() });
107
+ }
108
+ return ingredients;
109
+ }
110
+
111
+ function normalizedWords(value) {
112
+ return String(value).toLowerCase().normalize('NFKD').replace(/[\u0300-\u036f]/g, '').match(/[a-z0-9]+/g)?.map(word => {
113
+ if (word.endsWith('oes') && word.length > 4) return word.slice(0, -2);
114
+ if (word.endsWith('ies') && word.length > 4) return `${word.slice(0, -3)}y`;
115
+ if (word.endsWith('s') && !word.endsWith('ss') && word.length > 3) return word.slice(0, -1);
116
+ return word;
117
+ }) || [];
118
+ }
119
+
120
+ export function calculate(expression) {
121
+ if (typeof expression !== 'string' || !expression.trim() || expression.length > 256) throw new Error('Expression must contain 1–256 characters.');
122
+ const tokens = tokenize(expression);
123
+ let position = 0;
124
+ const peek = value => tokens[position]?.value === value;
125
+ const consume = value => {
126
+ if (!peek(value)) throw new Error(`Expected ${value || 'a number'}.`);
127
+ return tokens[position++];
128
+ };
129
+ function primary() {
130
+ if (peek('+') || peek('-')) {
131
+ const operator = tokens[position++].value;
132
+ const operand = primary();
133
+ return operator === '-' ? -operand : operand;
134
+ }
135
+ if (peek('(')) { consume('('); const value = additive(); consume(')'); return value; }
136
+ const token = tokens[position++];
137
+ if (token?.type === 'identifier') {
138
+ if (Object.hasOwn(CALCULATOR_CONSTANTS, token.value)) return CALCULATOR_CONSTANTS[token.value];
139
+ const operation = CALCULATOR_FUNCTIONS[token.value];
140
+ if (!operation) throw new Error(`Unsupported function or constant: ${token.value}`);
141
+ consume('(');
142
+ const argument = additive();
143
+ consume(')');
144
+ return operation(argument);
145
+ }
146
+ if (!token || token.type !== 'number') throw new Error('Expected a number.');
147
+ return token.number;
148
+ }
149
+ function power() { let left = primary(); if (peek('^')) { consume('^'); left **= power(); } return left; }
150
+ function multiplicative() {
151
+ let left = power();
152
+ while (peek('*') || peek('/') || peek('%')) {
153
+ const operator = tokens[position++].value; const right = power();
154
+ if ((operator === '/' || operator === '%') && right === 0) throw new Error('Division by zero is undefined.');
155
+ left = operator === '*' ? left * right : operator === '/' ? left / right : left % right;
156
+ }
157
+ return left;
158
+ }
159
+ function additive() {
160
+ let left = multiplicative();
161
+ while (peek('+') || peek('-')) { const operator = tokens[position++].value; const right = multiplicative(); left = operator === '+' ? left + right : left - right; }
162
+ return left;
163
+ }
164
+ const result = additive();
165
+ if (position !== tokens.length) throw new Error(`Unexpected token: ${tokens[position].value}`);
166
+ if (!Number.isFinite(result)) throw new Error('The expression did not produce a finite number.');
167
+ return result;
168
+ }
169
+
170
+ const toDegrees = value => value * 180 / Math.PI;
171
+ const toRadians = value => value * Math.PI / 180;
172
+ const CALCULATOR_CONSTANTS = Object.freeze({ pi: Math.PI, e: Math.E });
173
+ const CALCULATOR_FUNCTIONS = Object.freeze({
174
+ sin: Math.sin,
175
+ cos: Math.cos,
176
+ tan: Math.tan,
177
+ asin: Math.asin,
178
+ acos: Math.acos,
179
+ atan: Math.atan,
180
+ sin_deg: value => Math.sin(toRadians(value)),
181
+ cos_deg: value => Math.cos(toRadians(value)),
182
+ tan_deg: value => Math.tan(toRadians(value)),
183
+ asin_deg: value => toDegrees(Math.asin(value)),
184
+ acos_deg: value => toDegrees(Math.acos(value)),
185
+ atan_deg: value => toDegrees(Math.atan(value)),
186
+ sqrt: Math.sqrt,
187
+ abs: Math.abs,
188
+ });
189
+
190
+ function tokenize(expression) {
191
+ const tokens = [];
192
+ let cursor = 0;
193
+ while (cursor < expression.length) {
194
+ const rest = expression.slice(cursor);
195
+ const whitespace = /^\s+/.exec(rest);
196
+ if (whitespace) { cursor += whitespace[0].length; continue; }
197
+ const number = /^(?:\d+\.?\d*|\.\d+)(?:e[+-]?\d+)?/i.exec(rest);
198
+ if (number) { tokens.push({ type: 'number', value: number[0], number: Number(number[0]) }); cursor += number[0].length; continue; }
199
+ const identifier = /^[A-Za-z_][A-Za-z0-9_]*/.exec(rest);
200
+ if (identifier) { tokens.push({ type: 'identifier', value: identifier[0].toLowerCase() }); cursor += identifier[0].length; continue; }
201
+ if ('+-*/%^()'.includes(rest[0])) { tokens.push({ type: 'operator', value: rest[0] }); cursor += 1; continue; }
202
+ throw new Error(`Unsupported character: ${rest[0]}`);
203
+ }
204
+ return tokens;
205
+ }
206
+
207
+ function currentDatetime(timeZone) {
208
+ const options = { dateStyle: 'full', timeStyle: 'long' };
209
+ if (timeZone) options.timeZone = timeZone;
210
+ let formatted;
211
+ try { formatted = new Intl.DateTimeFormat(undefined, options).format(new Date()); }
212
+ catch { throw new Error(`Invalid IANA time zone: ${timeZone}`); }
213
+ return { iso_utc: new Date().toISOString(), time_zone: timeZone || Intl.DateTimeFormat().resolvedOptions().timeZone, formatted };
214
+ }
215
+
216
+ function randomInteger(min, max) {
217
+ const range = max - min + 1;
218
+ if (!Number.isSafeInteger(min) || !Number.isSafeInteger(max) || min > max) throw new Error('min and max must be safe integers with min ≤ max.');
219
+ if (range > 0x100000000) throw new Error('The requested random range must contain at most 2^32 integers.');
220
+ if (range === 0x100000000) return min + crypto.getRandomValues(new Uint32Array(1))[0];
221
+ const limit = Math.floor(0x100000000 / range) * range;
222
+ let value;
223
+ do { value = crypto.getRandomValues(new Uint32Array(1))[0]; } while (value >= limit);
224
+ return min + (value % range);
225
+ }
226
+
227
+ function geolocate(highAccuracy = false, signal) {
228
+ if (!navigator.geolocation) throw new Error('Geolocation is unavailable in this browser.');
229
+ return new Promise((resolve, reject) => {
230
+ let watchId;
231
+ const cleanup = () => { if (watchId !== undefined) navigator.geolocation.clearWatch(watchId); signal?.removeEventListener('abort', abort); };
232
+ const abort = () => { cleanup(); reject(new DOMException('Generation stopped.', 'AbortError')); };
233
+ signal?.addEventListener('abort', abort, { once: true });
234
+ watchId = navigator.geolocation.watchPosition(position => {
235
+ cleanup();
236
+ resolve({ latitude: position.coords.latitude, longitude: position.coords.longitude, accuracy_meters: position.coords.accuracy, captured_at: new Date(position.timestamp).toISOString() });
237
+ }, error => { cleanup(); reject(new Error(`Geolocation failed: ${error.message}`)); }, { enableHighAccuracy: Boolean(highAccuracy), timeout: 15000, maximumAge: 0 });
238
+ });
239
+ }
240
+
241
+ function validateArguments(schema, args) {
242
+ if (!isPlainObject(args)) throw new Error('Tool arguments must be an object.');
243
+ for (const required of schema.required || []) if (!Object.hasOwn(args, required)) throw new Error(`Missing required argument: ${required}`);
244
+ if (schema.additionalProperties === false) for (const name of Object.keys(args)) if (!Object.hasOwn(schema.properties, name)) throw new Error(`Unknown argument: ${name}`);
245
+ for (const [name, value] of Object.entries(args)) {
246
+ const property = schema.properties[name];
247
+ if (!property) continue;
248
+ const valid = property.type === 'integer' ? Number.isInteger(value)
249
+ : property.type === 'number' ? typeof value === 'number' && Number.isFinite(value)
250
+ : property.type === 'array' ? Array.isArray(value)
251
+ : property.type === 'object' ? isPlainObject(value)
252
+ : typeof value === property.type;
253
+ if (!valid) throw new Error(`${name} must be ${property.type}.`);
254
+ if (property.enum && !property.enum.includes(value)) throw new Error(`${name} must be one of the allowed values.`);
255
+ }
256
+ }
257
+
258
+ function isPlainObject(value) { return Boolean(value) && typeof value === 'object' && !Array.isArray(value); }
src/webcam-session.js ADDED
@@ -0,0 +1,37 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ function stopStream(stream) {
2
+ stream?.getTracks?.().forEach(track => track.stop());
3
+ }
4
+
5
+ export class WebcamSession {
6
+ constructor(acquire) {
7
+ this.acquire = acquire;
8
+ this.stream = null;
9
+ this.pending = null;
10
+ this.version = 0;
11
+ }
12
+
13
+ async open(constraints) {
14
+ if (this.stream) return this.stream;
15
+ if (this.pending) return this.pending;
16
+ const version = this.version;
17
+ const pending = Promise.resolve(this.acquire(constraints)).then(stream => {
18
+ if (version !== this.version) {
19
+ stopStream(stream);
20
+ return null;
21
+ }
22
+ this.stream = stream;
23
+ return stream;
24
+ }).finally(() => {
25
+ if (this.pending === pending) this.pending = null;
26
+ });
27
+ this.pending = pending;
28
+ return pending;
29
+ }
30
+
31
+ close() {
32
+ this.version += 1;
33
+ this.pending = null;
34
+ stopStream(this.stream);
35
+ this.stream = null;
36
+ }
37
+ }
test/conversation-preparation.test.js ADDED
@@ -0,0 +1,54 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { prepareConversation } from '../src/engines/conversation-preparation.js';
4
+
5
+ test('single-image messages retain the native image then text structure', () => {
6
+ const prepared = prepareConversation([{
7
+ role: 'user',
8
+ content: [
9
+ { type: 'image', value: 'blob:one' },
10
+ { type: 'text', value: 'Describe this image.' },
11
+ ],
12
+ }]);
13
+ assert.deepEqual(prepared.imageUrls, ['blob:one']);
14
+ assert.deepEqual(prepared.messages[0].content, [
15
+ { type: 'image' },
16
+ { type: 'text', text: 'Describe this image.' },
17
+ ]);
18
+ });
19
+
20
+ test('multi-image messages add ordered textual boundaries around every image', () => {
21
+ const prepared = prepareConversation([{
22
+ role: 'user',
23
+ content: [
24
+ { type: 'image', value: 'blob:one' },
25
+ { type: 'image', value: 'blob:two' },
26
+ { type: 'image', value: 'blob:three' },
27
+ { type: 'text', value: 'What is in each image?' },
28
+ ],
29
+ }]);
30
+ assert.deepEqual(prepared.imageUrls, ['blob:one', 'blob:two', 'blob:three']);
31
+ assert.deepEqual(prepared.messages[0].content, [
32
+ { type: 'text', text: 'Media-1' },
33
+ { type: 'image' },
34
+ { type: 'text', text: '\n' },
35
+ { type: 'text', text: 'Media-2' },
36
+ { type: 'image' },
37
+ { type: 'text', text: '\n' },
38
+ { type: 'text', text: 'Media-3' },
39
+ { type: 'image' },
40
+ { type: 'text', text: '\n' },
41
+ { type: 'text', text: 'What is in each image?' },
42
+ ]);
43
+ });
44
+
45
+ test('media numbering restarts for each user message while URL order stays global', () => {
46
+ const prepared = prepareConversation([
47
+ { role: 'user', content: [{ type: 'image', value: 'blob:a' }, { type: 'image', value: 'blob:b' }, { type: 'text', value: 'Compare.' }] },
48
+ { role: 'assistant', content: 'Done.' },
49
+ { role: 'user', content: [{ type: 'image', value: 'blob:c' }, { type: 'image', value: 'blob:d' }, { type: 'text', value: 'Compare these.' }] },
50
+ ]);
51
+ assert.deepEqual(prepared.imageUrls, ['blob:a', 'blob:b', 'blob:c', 'blob:d']);
52
+ assert.equal(prepared.messages[0].content[0].text, 'Media-1');
53
+ assert.equal(prepared.messages[2].content[0].text, 'Media-1');
54
+ });
test/document-parsing.test.js ADDED
@@ -0,0 +1,63 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { parseDocumentRegions } from '../src/document-parsing.js';
4
+
5
+ test('parses document regions across multiple page images', () => {
6
+ const text = `image_index=0 title [58, 78, 677, 100]
7
+ Stellar engines and Dyson bubbles can be stable
8
+
9
+ image_index=0 text [58, 116, 235, 131]
10
+ Colin R. McInnes*
11
+
12
+ image_index=1 equation [100, 200, 800, 300]
13
+ E = mc^2`;
14
+ assert.deepEqual(parseDocumentRegions(text, 2), [
15
+ { imageId: 0, label: 'title', type: 'box', coordinates: [58, 78, 677, 100], content: 'Stellar engines and Dyson bubbles can be stable' },
16
+ { imageId: 0, label: 'text', type: 'box', coordinates: [58, 116, 235, 131], content: 'Colin R. McInnes*' },
17
+ { imageId: 1, label: 'equation', type: 'box', coordinates: [100, 200, 800, 300], content: 'E = mc^2' },
18
+ ]);
19
+ });
20
+
21
+ test('preserves multiline region content', () => {
22
+ const [region] = parseDocumentRegions('image_index=0 code [0, 0, 500, 500]\nconst x = 1;\nreturn x;', 1);
23
+ assert.equal(region.content, 'const x = 1;\nreturn x;');
24
+ });
25
+
26
+ test('accepts harmless header whitespace', () => {
27
+ const parsed = parseDocumentRegions(`image_index = 0 title [ 58,78, 677 , 100 ]
28
+ Liquid Foundation Model`, 1);
29
+ assert.equal(parsed.length, 1);
30
+ assert.deepEqual(parsed[0].coordinates, [58, 78, 677, 100]);
31
+ });
32
+
33
+ test('accepts model-chosen labels and zero-to-one coordinates', () => {
34
+ const parsed = parseDocumentRegions(`image_index=0 Quoted label / callout [0.318, 0.045, 0.707, 0.067]
35
+ The second image shows the cows in their natural habitat.
36
+
37
+ image_index=0 image [202, 78, 797, 486]
38
+ Diagram of Liquid Foundation Model architecture.`, 1);
39
+ assert.deepEqual(parsed, [
40
+ { imageId: 0, label: 'Quoted label / callout', type: 'box', coordinates: [318, 45, 707, 67], content: 'The second image shows the cows in their natural habitat.' },
41
+ { imageId: 0, label: 'image', type: 'box', coordinates: [202, 78, 797, 486], content: 'Diagram of Liquid Foundation Model architecture.' },
42
+ ]);
43
+ });
44
+
45
+ test('keeps complete regions when the final generated region is truncated', () => {
46
+ const parsed = parseDocumentRegions(`image_index=0 title [58, 78, 677, 100]
47
+ Liquid Foundation Model
48
+
49
+ image_index=0 text [58, 116, 530, 155]
50
+ Model architecture description
51
+
52
+ image_index=0 table [`, 1);
53
+ assert.equal(parsed.length, 2);
54
+ assert.equal(parsed[1].content, 'Model architecture description');
55
+ });
56
+
57
+ test('rejects malformed document layouts', () => {
58
+ assert.equal(parseDocumentRegions('ordinary answer', 1), null);
59
+ assert.equal(parseDocumentRegions('image_index=1 text [0, 0, 1, 1]\ncontent', 1), null);
60
+ assert.equal(parseDocumentRegions('image_index=0 text [10, 10, 5, 20]\ncontent', 1), null);
61
+ assert.equal(parseDocumentRegions('image_index=0 text [0, nope, 1, 1]\ncontent', 1), null);
62
+ assert.equal(parseDocumentRegions('image_index=0 text [0, 0, 1, 1]', 1), null);
63
+ });
test/grounding.test.js ADDED
@@ -0,0 +1,52 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { parseGroundingResponse } from '../src/grounding.js';
4
+
5
+ test('parses normalized boxes and points', () => {
6
+ assert.deepEqual(parseGroundingResponse(JSON.stringify([
7
+ { image_id: 0, bbox_2d: [10, 20, 500, 600], label: 'cat' },
8
+ { image_id: 1, point_2d: [750, 125], label: 'nose' },
9
+ ]), 2), [
10
+ { imageId: 0, label: 'cat', type: 'box', coordinates: [10, 20, 500, 600] },
11
+ { imageId: 1, label: 'nose', type: 'point', coordinates: [750, 125] },
12
+ ]);
13
+ });
14
+
15
+ test('accepts a valid empty grounding response', () => {
16
+ assert.deepEqual(parseGroundingResponse('[]', 1), []);
17
+ });
18
+
19
+ test('renders bare four-number lists as boxes on the first image', () => {
20
+ assert.deepEqual(parseGroundingResponse('Detected regions: [10, 20, 500, 600] and [600, 100, 900, 800].', 2), [
21
+ { imageId: 0, label: 'Bounding box', type: 'box', coordinates: [10, 20, 500, 600] },
22
+ { imageId: 0, label: 'Bounding box 2', type: 'box', coordinates: [600, 100, 900, 800] },
23
+ ]);
24
+ });
25
+
26
+ test('normalizes zero-to-one boxes in structured and bare output', () => {
27
+ assert.deepEqual(parseGroundingResponse('[{"image_id":0,"label":"cat","bbox_2d":[0.1,0.2,0.8,0.9]}]', 1), [
28
+ { imageId: 0, label: 'cat', type: 'box', coordinates: [100, 200, 800, 900] },
29
+ ]);
30
+ assert.deepEqual(parseGroundingResponse('box: [0.125, 0.25, 0.75, 1]', 1), [
31
+ { imageId: 0, label: 'Bounding box', type: 'box', coordinates: [125, 250, 750, 1000] },
32
+ ]);
33
+ });
34
+
35
+ test('does not treat fenced or unrelated JSON as grounding', () => {
36
+ assert.equal(parseGroundingResponse('```json\n[]\n```', 1), null);
37
+ assert.equal(parseGroundingResponse('{"answer": 4}', 1), null);
38
+ assert.equal(parseGroundingResponse('[{"image_id":0,"label":"cat","bbox_2d":[0,0,1,1],"score":1}]', 1), null);
39
+ });
40
+
41
+ test('rejects invalid image references and coordinates', () => {
42
+ assert.equal(parseGroundingResponse('[{"image_id":1,"label":"cat","bbox_2d":[0,0,10,10]}]', 1), null);
43
+ assert.equal(parseGroundingResponse('[{"image_id":0,"label":"cat","point_2d":[1.5,2]}]', 1), null);
44
+ assert.equal(parseGroundingResponse('[{"image_id":0,"label":"cat","point_2d":[1001,2]}]', 1), null);
45
+ assert.equal(parseGroundingResponse('[{"image_id":0,"label":"cat","bbox_2d":[20,20,10,30]}]', 1), null);
46
+ });
47
+
48
+ test('requires one and only one supported geometry', () => {
49
+ assert.equal(parseGroundingResponse('[{"image_id":0,"label":"cat"}]', 1), null);
50
+ assert.equal(parseGroundingResponse('[{"image_id":0,"label":"cat","point_2d":[1,2],"bbox_2d":[0,0,2,3]}]', 1), null);
51
+ assert.equal(parseGroundingResponse('[{"image_id":0,"label":"","point_2d":[1,2]}]', 1), null);
52
+ });
test/image-input.test.js ADDED
@@ -0,0 +1,36 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { imageFilesFromClipboard, imageFilesFromDataTransfer } from '../src/image-input.js';
4
+
5
+ test('extracts one or more image files from clipboard items', () => {
6
+ const first = { name: 'first.png', type: 'image/png' };
7
+ const second = { name: 'second.jpg', type: 'image/jpeg' };
8
+ const files = imageFilesFromClipboard({
9
+ items: [
10
+ { kind: 'string', type: 'text/plain', getAsFile: () => null },
11
+ { kind: 'file', type: 'image/png', getAsFile: () => first },
12
+ { kind: 'file', type: 'application/pdf', getAsFile: () => ({ type: 'application/pdf' }) },
13
+ { kind: 'file', type: 'image/jpeg', getAsFile: () => second },
14
+ ],
15
+ });
16
+ assert.deepEqual(files, [first, second]);
17
+ });
18
+
19
+ test('falls back to clipboard files and ignores non-images', () => {
20
+ const image = { name: 'screenshot.webp', type: 'image/webp' };
21
+ const files = imageFilesFromClipboard({
22
+ files: [image, { name: 'notes.txt', type: 'text/plain' }],
23
+ });
24
+ assert.deepEqual(files, [image]);
25
+ assert.deepEqual(imageFilesFromClipboard(null), []);
26
+ });
27
+
28
+ test('extracts only image files from a drag data transfer', () => {
29
+ const image = { name: 'photo.png', type: 'image/png' };
30
+ const files = imageFilesFromDataTransfer({ files: [
31
+ { name: 'notes.txt', type: 'text/plain' },
32
+ image,
33
+ ] });
34
+ assert.deepEqual(files, [image]);
35
+ assert.deepEqual(imageFilesFromDataTransfer(null), []);
36
+ });
test/mcp-client.test.js ADDED
@@ -0,0 +1,38 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { compactMcpResult, normalizeMcpTools } from '../src/tools/mcp-client.js';
4
+ import { prepareToolCall } from '../src/tools/tool-registry.js';
5
+
6
+ test('normalizes compatible MCP tools for the existing tool loop', () => {
7
+ const [tool] = normalizeMcpTools([{
8
+ name: 'search_docs',
9
+ description: 'Search documentation.',
10
+ inputSchema: {
11
+ type: 'object',
12
+ properties: { query: { type: 'string' } },
13
+ required: ['query'],
14
+ additionalProperties: false,
15
+ },
16
+ }]);
17
+ assert.equal(tool.source, 'mcp');
18
+ assert.equal(tool.enabled, false);
19
+ tool.enabled = true;
20
+ const prepared = prepareToolCall({ name: 'search_docs', arguments: { query: 'WebGPU' }, positional: [] }, [tool]);
21
+ assert.deepEqual(prepared.args, { query: 'WebGPU' });
22
+ });
23
+
24
+ test('skips MCP tools with unsupported names or non-object schemas', () => {
25
+ const tools = normalizeMcpTools([
26
+ { name: 'bad-name', inputSchema: { type: 'object', properties: {} } },
27
+ { name: 'primitive', inputSchema: { type: 'string' } },
28
+ { name: 'valid_tool', inputSchema: { type: 'object', properties: {} } },
29
+ ]);
30
+ assert.deepEqual(tools.map(tool => tool.name), ['valid_tool']);
31
+ });
32
+
33
+ test('compacts large MCP text results before returning them to the model', () => {
34
+ const result = compactMcpResult({ content: [{ type: 'text', text: 'x'.repeat(18000) }] });
35
+ assert.equal(result.truncated, true);
36
+ assert.equal(result.content[0].text.length, 4000);
37
+ assert.ok(JSON.stringify(result).length < 4200);
38
+ });
test/model-precision.test.js ADDED
@@ -0,0 +1,25 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import assert from 'node:assert/strict';
2
+ import test from 'node:test';
3
+ import { selectEmbeddingPrecision } from '../src/model-precision.js';
4
+
5
+ const manifest = {
6
+ runtime: {
7
+ dtype: { decoder_model_merged: 'q4', vision_encoder: 'q4', embed_tokens: 'fp32' },
8
+ externalDataChunks: { 'decoder_model_merged_q4.onnx': 5, 'vision_encoder_q4.onnx': 1 },
9
+ },
10
+ };
11
+
12
+ test('selects FP16 embeddings and their external shard when shader-f16 is available', () => {
13
+ const selected = selectEmbeddingPrecision(manifest, ['shader-f16']);
14
+ assert.equal(selected.precision, 'fp16');
15
+ assert.equal(selected.manifest.runtime.dtype.embed_tokens, 'fp16');
16
+ assert.equal(selected.manifest.runtime.externalDataChunks['embed_tokens_fp16.onnx'], 1);
17
+ assert.equal(manifest.runtime.dtype.embed_tokens, 'fp32');
18
+ });
19
+
20
+ test('keeps FP32 embeddings when shader-f16 is unavailable', () => {
21
+ const selected = selectEmbeddingPrecision(manifest, new Set(['timestamp-query']));
22
+ assert.equal(selected.precision, 'fp32');
23
+ assert.equal(selected.manifest.runtime.dtype.embed_tokens, 'fp32');
24
+ assert.equal(selected.manifest.runtime.externalDataChunks['embed_tokens_fp16.onnx'], undefined);
25
+ });
test/tools.test.js ADDED
@@ -0,0 +1,106 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { calculate, executeBuiltin, prepareToolCall, searchRecipeByDish } from '../src/tools/tool-registry.js';
4
+ import { createToolStreamFilter, displayTextFromRaw, parseToolCalls } from '../src/tools/tool-protocol.js';
5
+
6
+ test('calculator evaluates arithmetic without JavaScript evaluation', () => {
7
+ assert.equal(calculate('2 + 3 * (4 - 1)^2'), 29);
8
+ assert.equal(calculate('1.5e2 / 3'), 50);
9
+ assert.throws(() => calculate('globalThis.alert(1)'), /Unsupported character/);
10
+ assert.throws(() => calculate('2 / 0'), /Division by zero/);
11
+ });
12
+
13
+ test('calculator supports safe trigonometry for geometry problems', () => {
14
+ assert.ok(Math.abs(calculate('sin(pi / 2)') - 1) < 1e-12);
15
+ assert.ok(Math.abs(calculate('10 * tan_deg(30)') - 5.773502691896257) < 1e-12);
16
+ assert.ok(Math.abs(calculate('sqrt(3^2 + 4^2)') - 5) < 1e-12);
17
+ assert.ok(Math.abs(calculate('asin_deg(0.5)') - 30) < 1e-12);
18
+ assert.throws(() => calculate('fetch(1)'), /Unsupported function/);
19
+ assert.throws(() => calculate('sqrt(-1)'), /finite number/);
20
+ });
21
+
22
+ test('native Python-style protocol parses single and multiple calls', () => {
23
+ const calls = parseToolCalls("before<|tool_call_start|>[calculate(expression='2 + 2'), current_datetime(time_zone='UTC')]<|tool_call_end|>");
24
+ assert.deepEqual(calls.map(call => call.name), ['calculate', 'current_datetime']);
25
+ assert.deepEqual(calls[0].arguments, { expression: '2 + 2' });
26
+ assert.deepEqual(calls[1].arguments, { time_zone: 'UTC' });
27
+ assert.equal(displayTextFromRaw("Answer<|tool_call_start|>[calculate(expression='1')]<|tool_call_end|><|im_end|>"), 'Answer');
28
+ });
29
+
30
+ test('protocol supports positional, primitive, and nested JSON values', () => {
31
+ const [call] = parseToolCalls('<|tool_call_start|>random_integer(1, max=4)<|tool_call_end|>');
32
+ assert.deepEqual(call.positional, [1]);
33
+ assert.deepEqual(call.arguments, { max: 4 });
34
+ const [nested] = parseToolCalls('<|tool_call_start|>custom(payload={"ok":true,"items":[1,2]}, optional=None)<|tool_call_end|>');
35
+ assert.deepEqual(nested.arguments, { payload: { ok: true, items: [1, 2] }, optional: null });
36
+ assert.throws(() => parseToolCalls('<|tool_call_start|>broken(a=1'), /incomplete/);
37
+ });
38
+
39
+ test('stream filter suppresses tool syntax even across chunks', () => {
40
+ let visible = '';
41
+ const states = [];
42
+ const filter = createToolStreamFilter(chunk => { visible += chunk; }, {
43
+ onToolCallStart: () => states.push('start'),
44
+ onToolCallEnd: () => states.push('end'),
45
+ });
46
+ for (const chunk of ['Hello ', '<|tool_', "call_start|>[calculate(expression='2')]<|tool_call_", 'end|>', ' world']) filter.push(chunk);
47
+ filter.finish();
48
+ assert.equal(visible, 'Hello world');
49
+ assert.deepEqual(states, ['start', 'end']);
50
+ });
51
+
52
+ test('tool schemas and arguments are validated', () => {
53
+ const tool = { id: 'test:lookup_order', source: 'test', enabled: true, name: 'lookup_order', description: 'Look up an order.', parameters: { type: 'object', properties: { order_id: { type: 'string' } }, required: ['order_id'], additionalProperties: false } };
54
+ const prepared = prepareToolCall({ name: 'lookup_order', arguments: { order_id: 'A-1' }, positional: [] }, [tool]);
55
+ assert.deepEqual(prepared.args, { order_id: 'A-1' });
56
+ assert.throws(() => prepareToolCall({ name: 'lookup_order', arguments: {}, positional: [] }, [tool]), /Missing required/);
57
+ tool.enabled = false;
58
+ assert.throws(() => prepareToolCall({ name: 'lookup_order', arguments: { order_id: 'A-1' }, positional: [] }, [tool]), /Unknown or disabled/);
59
+ });
60
+
61
+ test('datetime and random built-ins reject invalid inputs', async () => {
62
+ await assert.rejects(executeBuiltin('current_datetime', { time_zone: 'Definitely/Invalid' }), /Invalid IANA time zone/);
63
+ await assert.rejects(executeBuiltin('random_integer', { min: 5, max: 4 }), /min ≤ max/);
64
+ for (let index = 0; index < 20; index += 1) {
65
+ const result = await executeBuiltin('random_integer', { min: -2, max: 2 });
66
+ assert.ok(result.value >= -2 && result.value <= 2);
67
+ }
68
+ });
69
+
70
+ test('dish recipe search prefers an exact completed-dish name', async () => {
71
+ const fetcher = async url => {
72
+ assert.equal(new URL(url).searchParams.get('s'), 'Pad Thai');
73
+ return {
74
+ ok: true,
75
+ status: 200,
76
+ json: async () => ({ meals: [
77
+ { idMeal: '2', strMeal: 'Quick Pad Thai', strIngredient1: 'Noodles' },
78
+ { idMeal: '1', strMeal: 'Pad Thai', strCategory: 'Main', strArea: 'Thai', strIngredient1: 'Rice Noodles', strMeasure1: '200g', strInstructions: 'Cook it.', strSource: 'https://example.com/pad-thai' },
79
+ ] }),
80
+ };
81
+ };
82
+ const result = await searchRecipeByDish('Pad Thai', undefined, fetcher);
83
+ assert.equal(result.provider, 'TheMealDB');
84
+ assert.equal(result.recipes[0].name, 'Pad Thai');
85
+ assert.deepEqual(result.recipes[0].ingredients, ['200g Rice Noodles']);
86
+ assert.equal(result.recipes[0].source_url, 'https://example.com/pad-thai');
87
+ });
88
+
89
+ test('dish recipe search accepts whole-word title matches and rejects partial false matches', async () => {
90
+ const response = meals => ({ ok: true, status: 200, json: async () => ({ meals }) });
91
+ const friedRice = await searchRecipeByDish('fried rice', undefined, async () => response([
92
+ { idMeal: '1', strMeal: 'Chicken Fried Rice' },
93
+ ]));
94
+ assert.equal(friedRice.recipes[0].name, 'Chicken Fried Rice');
95
+ const falsePartial = await searchRecipeByDish('chicken parm', undefined, async () => response([
96
+ { idMeal: '2', strMeal: 'Chicken Parmentier' },
97
+ ]));
98
+ assert.deepEqual(falsePartial.recipes, []);
99
+ });
100
+
101
+ test('dish recipe search validates names and handles no result', async () => {
102
+ await assert.rejects(searchRecipeByDish('', undefined, async () => {}), /between 1 and 100/);
103
+ await assert.rejects(searchRecipeByDish('x'.repeat(101), undefined, async () => {}), /between 1 and 100/);
104
+ const result = await searchRecipeByDish('Caesar Salad', undefined, async () => ({ ok: true, status: 200, json: async () => ({ meals: null }) }));
105
+ assert.deepEqual(result.recipes, []);
106
+ });
test/webcam-session.test.js ADDED
@@ -0,0 +1,33 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import assert from 'node:assert/strict';
2
+ import test from 'node:test';
3
+ import { WebcamSession } from '../src/webcam-session.js';
4
+
5
+ function fakeStream() {
6
+ const track = { stopped: false, stop() { this.stopped = true; } };
7
+ return { track, getTracks: () => [track] };
8
+ }
9
+
10
+ test('reuses one webcam stream across repeated renders and closes every track', async () => {
11
+ let acquisitions = 0;
12
+ const stream = fakeStream();
13
+ const session = new WebcamSession(async () => { acquisitions += 1; return stream; });
14
+ const [first, second] = await Promise.all([session.open({ video: true }), session.open({ video: true })]);
15
+ assert.equal(first, stream);
16
+ assert.equal(second, stream);
17
+ assert.equal(acquisitions, 1);
18
+ session.close();
19
+ assert.equal(stream.track.stopped, true);
20
+ assert.equal(session.stream, null);
21
+ });
22
+
23
+ test('stops a stream that resolves after the webcam was closed', async () => {
24
+ const stream = fakeStream();
25
+ let resolveAcquire;
26
+ const session = new WebcamSession(() => new Promise(resolve => { resolveAcquire = resolve; }));
27
+ const opening = session.open({ video: true });
28
+ session.close();
29
+ resolveAcquire(stream);
30
+ assert.equal(await opening, null);
31
+ assert.equal(stream.track.stopped, true);
32
+ assert.equal(session.stream, null);
33
+ });
vite.config.js ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { defineConfig } from 'vite';
2
+
3
+ export default defineConfig({
4
+ server: {
5
+ headers: {
6
+ 'Cross-Origin-Opener-Policy': 'same-origin',
7
+ 'Cross-Origin-Embedder-Policy': 'require-corp',
8
+ },
9
+ },
10
+ build: {
11
+ target: 'es2022',
12
+ },
13
+ });