Commit ·
6c30253
0
Parent(s):
Initial release
Browse files- .dockerignore +6 -0
- .gitattributes +38 -0
- .gitignore +5 -0
- Dockerfile +17 -0
- README.md +42 -0
- eslint.config.js +25 -0
- index.html +14 -0
- package-lock.json +0 -0
- package.json +32 -0
- public/LFM2-VL-tech-report.png +3 -0
- public/charcuterie.jpg +3 -0
- public/liquid-hero-white.png +0 -0
- public/liquid-mark.svg +1 -0
- public/model-manifest.json +27 -0
- public/pad-thai.jpeg +3 -0
- server/app.js +25 -0
- server/index.js +17 -0
- src/document-parsing.js +62 -0
- src/engines/conversation-preparation.js +28 -0
- src/engines/onnx-transformers-engine.js +346 -0
- src/grounding.js +95 -0
- src/image-input.js +15 -0
- src/main.js +1088 -0
- src/model-config.js +8 -0
- src/model-precision.js +19 -0
- src/runtime/runtime.js +116 -0
- src/styles.css +352 -0
- src/tools/mcp-client.js +85 -0
- src/tools/tool-protocol.js +163 -0
- src/tools/tool-registry.js +258 -0
- src/webcam-session.js +37 -0
- test/conversation-preparation.test.js +54 -0
- test/document-parsing.test.js +63 -0
- test/grounding.test.js +52 -0
- test/image-input.test.js +36 -0
- test/mcp-client.test.js +38 -0
- test/model-precision.test.js +25 -0
- test/tools.test.js +106 -0
- test/webcam-session.test.js +33 -0
- vite.config.js +13 -0
.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
|
public/charcuterie.jpg
ADDED
|
Git LFS Details
|
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
|
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 => ({ '&': '&', '<': '<', '>': '>', "'": ''', '"': '"' })[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 |
+
});
|