This commit is contained in:
seb
2026-10-05 11:14:31 +02:00
parent 9831bea110
commit 25266cfcec
12 changed files with 1991 additions and 58 deletions

419
src/tools/analyze-image.js Normal file
View File

@@ -0,0 +1,419 @@
import fs from 'fs';
import path from 'path';
import sharp from 'sharp';
import jwt from 'jsonwebtoken';
import { defineTool, ToolError, OpenRouterProvider } from 'libllmorbiter';
import env from '../config/env.js';
import { prepareVfs } from './vfs.js';
import { resolveOpenRouterKey } from './llm-key.js';
/**
* analyze_image — lets a vision model actually LOOK at images held in the VFS.
*
* The agent itself is text-only, so "seeing" an image requires a second, tool-free model
* call with the image attached. This tool is that bridge:
*
* - 1 image → resized to fit 400×400 and sent as a single WebP.
* - 2–9 → composited into a labelled grid (2x1/3x1/2x2/3x2/3x3) with the file name
* printed under each cell, so one cheap call can compare many candidates.
* - Database images are addressed as `image/<fullHash>` and fetched from `/image/:hash`.
*
* The prompt is assembled from the caller's `prompt` plus a layout summary, so the model
* knows which cell is which file.
*/
const GRID_CONFIGS = {
'1x1': { cols: 1, rows: 1, totalWidth: 400, totalHeight: 400 },
'2x1': { cols: 2, rows: 1, totalWidth: 600, totalHeight: 300 },
'3x1': { cols: 3, rows: 1, totalWidth: 800, totalHeight: 300 },
'2x2': { cols: 2, rows: 2, totalWidth: 600, totalHeight: 600 },
'3x2': { cols: 3, rows: 2, totalWidth: 800, totalHeight: 600 },
'3x3': { cols: 3, rows: 3, totalWidth: 800, totalHeight: 800 },
};
const MAX_IMAGES = 9;
const SINGLE_BOX = 400;
const WEBP_QUALITY = 80;
const IMAGE_TIMEOUT_MS = 10_000;
const LABEL_HEIGHT = 26;
const LABEL_PAD = 5;
/**
* Picks a grid shape: the caller's choice when valid, otherwise one that fits the count.
* @param {number} count
* @param {string} [requested]
* @returns {{ cols: number, rows: number, name: string }}
*/
function resolveGrid(count, requested) {
if (requested) {
const match = String(requested).trim().toLowerCase();
if (GRID_CONFIGS[match]) {
const [c, r] = match.split('x').map(Number);
return { cols: c, rows: r, name: match };
}
}
if (count <= 1) return { cols: 1, rows: 1, name: '1x1' };
if (count === 2) return { cols: 2, rows: 1, name: '2x1' };
if (count === 3) return { cols: 3, rows: 1, name: '3x1' };
if (count === 4) return { cols: 2, rows: 2, name: '2x2' };
if (count <= 6) return { cols: 3, rows: 2, name: '3x2' };
return { cols: 3, rows: 3, name: '3x3' };
}
/**
* Escapes text for safe embedding in the label SVG.
* @param {string} unsafe
* @returns {string}
*/
function escapeXml(unsafe) {
return String(unsafe).replace(/[<>&'"]/g, (c) => {
switch (c) {
case '<': return '&lt;';
case '>': return '&gt;';
case '&': return '&amp;';
case "'": return '&apos;';
case '"': return '&quot;';
default: return c;
}
});
}
/**
* Shortens a label for display, keeping the extension visible so `a.avif` and `b.avif`
* never truncate to the same text.
* @param {string} name
* @param {number} maxChars
* @returns {string}
*/
function formatLabelText(name, maxChars = 28) {
if (!name || name.length <= maxChars) return name || '';
const extMatch = name.match(/(\.[a-zA-Z0-9]{3,5})$/);
if (extMatch) {
const ext = extMatch[1];
const avail = maxChars - ext.length - 1;
if (avail > 5) return `${name.slice(0, avail)}…${ext}`;
}
return `${name.slice(0, maxChars - 1)}…`;
}
/**
* Renders the dark caption bar drawn beneath each grid cell.
* @param {string} name
* @param {number} width
* @param {number} height
* @param {number} fontSize
* @returns {Buffer}
*/
function createLabelSvg(name, width, height, fontSize = 11) {
const maxChars = Math.max(8, Math.floor(width / (fontSize * 0.65)));
const text = escapeXml(formatLabelText(name, maxChars));
const textY = Math.round(height / 2 + fontSize / 3 + 1);
return Buffer.from(
`<svg width="${width}" height="${height}" xmlns="http://www.w3.org/2000/svg">` +
`<rect width="100%" height="100%" fill="#1e2025" rx="3" stroke="#333842" stroke-width="1"/>` +
`<text x="${Math.round(width / 2)}" y="${textY}" font-family="system-ui,-apple-system,sans-serif" ` +
`font-size="${fontSize}" font-weight="600" fill="#f1f5f9" text-anchor="middle">${text}</text>` +
`</svg>`
);
}
/**
* Composites the images into a single labelled grid WebP.
* @param {Array<{ name: string, rawBuffer: Buffer }>} items
* @param {{ cols: number, rows: number, totalWidth: number, totalHeight: number }} config
* @returns {Promise<Buffer>}
*/
async function renderGrid(items, config) {
const { cols, totalWidth, totalHeight } = config;
const overlays = [];
for (let i = 0; i < items.length; i += 1) {
const item = items[i];
const col = i % cols;
const row = Math.floor(i / cols);
const cellX = Math.round((col * totalWidth) / cols);
const cellW = Math.round(((col + 1) * totalWidth) / cols) - cellX;
const cellY = Math.round((row * totalHeight) / config.rows);
const cellH = Math.round(((row + 1) * totalHeight) / config.rows) - cellY;
const maxW = cellW - 2 * LABEL_PAD;
const maxH = cellH - LABEL_HEIGHT - 2 * LABEL_PAD;
const resized = await sharp(item.rawBuffer)
.rotate()
.resize(maxW, maxH, { fit: 'inside', withoutEnlargement: true })
.toBuffer({ resolveWithObject: true });
overlays.push({
input: resized.data,
left: cellX + LABEL_PAD + Math.round((maxW - resized.info.width) / 2),
top: cellY + LABEL_PAD + Math.round((maxH - resized.info.height) / 2),
});
const labelW = cellW - 2 * LABEL_PAD;
overlays.push({
input: createLabelSvg(item.name, labelW, LABEL_HEIGHT, cols === 2 ? 12 : 11),
left: cellX + LABEL_PAD,
top: cellY + cellH - LABEL_HEIGHT - LABEL_PAD,
});
}
return sharp({
create: {
width: totalWidth,
height: totalHeight,
channels: 4,
background: { r: 20, g: 21, b: 24, alpha: 1 },
},
})
.composite(overlays)
.webp({ quality: WEBP_QUALITY })
.toBuffer();
}
/**
* Normalises the caller's arguments into a list of `{ path, name }` items.
*
* Deliberately permissive on input shape (`path` / `paths` / `file` / `images`, string or
* array, comma-separated) because models are inconsistent here, while keeping the schema
* itself strict.
*
* @param {object} args
* @returns {Array<{ path: string, name: string }>}
*/
function extractImageInputs(args = {}) {
const items = [];
const add = (entry) => {
if (!entry) return;
if (typeof entry === 'string') {
const trimmed = entry.trim();
if (!trimmed) return;
// Some models send a JSON array as a string.
if (trimmed.startsWith('[') && trimmed.endsWith(']')) {
try {
const parsed = JSON.parse(trimmed);
if (Array.isArray(parsed)) {
parsed.forEach(add);
return;
}
} catch { /* not JSON, treat as a plain path */ }
}
if (trimmed.includes(',')) {
trimmed.split(',').map((s) => s.trim()).filter(Boolean).forEach((p) => {
items.push({ path: p, name: path.posix.basename(p) || p });
});
return;
}
items.push({ path: trimmed, name: path.posix.basename(trimmed) || trimmed });
return;
}
if (typeof entry === 'object') {
const p = String(entry.path || entry.file || entry.filename || '').trim();
if (!p) return;
const name = String(entry.name || entry.title || path.posix.basename(p) || p).trim();
items.push({ path: p, name });
}
};
for (const key of ['paths', 'images', 'path', 'file', 'filename']) {
const value = args[key];
if (Array.isArray(value)) value.forEach(add);
else if (typeof value === 'string') add(value);
}
if (items.length === 1 && typeof args.name === 'string' && args.name.trim()) {
items[0].name = args.name.trim();
}
return items;
}
/**
* Fetches a database image by its content hash, authenticated as the calling user.
* @param {string} hash 64-char SHA-256 hex
* @param {number} googleAuthId
* @returns {Promise<Buffer>}
*/
async function fetchDatabaseImage(hash, googleAuthId) {
const url = `http://127.0.0.1:${env.port}/image/${hash}`;
const token = jwt.sign({ id: googleAuthId }, env.jwtSecret, { expiresIn: '5m' });
const res = await fetch(url, {
headers: { Cookie: `token=${token}` },
signal: AbortSignal.timeout(IMAGE_TIMEOUT_MS),
});
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
return Buffer.from(await res.arrayBuffer());
}
/**
* Creates the analyze_image tool.
* @param {{ googleAuthId: number, model: string }} ctx
* `model` is the vision model id from the conversation record; the same model that is
* driving the conversation is used for inspection so the agent does not silently switch
* vendors mid-task.
*/
export function createAnalyzeImageTool({ googleAuthId, model }) {
return defineTool({
name: 'analyze_image',
description:
'Look at one or up to 9 images with a vision model and get its written judgement back. ' +
'Paths are VFS-relative (e.g. "images/bild.de-1.avif" from search_images) or ' +
'"image/<fullHash>" for a database image. ' +
'Passing several paths renders them as a labelled grid in one call — use that to compare ' +
'search candidates side by side. The returned text is the vision model\'s answer, so judge from it ' +
'rather than claiming to have seen the image yourself.',
strict: false,
parameters: {
type: 'object',
additionalProperties: false,
required: ['paths'],
properties: {
paths: {
type: 'array',
description:
'1–9 image paths. VFS files (e.g. ["images/a-1.avif"]) or database images (e.g. ["image/<fullHash>"]). ' +
'Several paths are rendered as one labelled grid.',
items: { type: 'string' },
minItems: 1,
maxItems: MAX_IMAGES,
},
prompt: {
type: 'string',
description:
'What to inspect or compare, e.g. "Which of these is a clean product shot on white, ' +
'without watermark or text overlay?"',
},
grid: {
type: 'string',
enum: Object.keys(GRID_CONFIGS),
description: 'Force a grid layout. Omit to pick one from the image count.',
},
},
},
invoke: async ({ paths, prompt, grid } = {}) => {
const inputs = extractImageInputs({ paths });
if (inputs.length === 0) {
throw new ToolError(
'analyze_image requires "paths": an array of 1–9 VFS paths (e.g. ["images/a-1.avif"]) ' +
'or database paths (e.g. ["image/<fullHash>"]). Get candidates from search_images first.'
);
}
if (inputs.length > MAX_IMAGES) {
throw new ToolError(`analyze_image takes at most ${MAX_IMAGES} images per call (received ${inputs.length}). Split into batches.`);
}
const vfsRoot = await prepareVfs({ googleAuthId });
// ── Load the bytes ────────────────────────────────────────────
const items = [];
for (const item of inputs) {
// Only a path that actually carries a 64-char content hash is a database image.
// A bare "images/…" prefix is just the VFS folder, so it must NOT be treated as
// a DB lookup or `images/candidates/x.avif` would be misread as a hash path.
const hash = extractFullHash(item.path);
let buf = null;
if (!hash) {
const abs = path.resolve(vfsRoot, item.path.replace(/^([a-zA-Z]:|\/)+/, '').replace(/^\.\/+/, ''));
const rel = path.relative(vfsRoot, abs);
if (rel.startsWith('..') || path.isAbsolute(rel)) {
throw new ToolError(`Access denied: path "${item.path}" escapes virtual filesystem boundary.`);
}
if (fs.existsSync(abs) && fs.statSync(abs).isFile()) {
buf = fs.readFileSync(abs);
}
} else {
const cached = path.join(vfsRoot, 'images', `${hash}.avif`);
if (fs.existsSync(cached)) {
buf = fs.readFileSync(cached);
} else {
buf = await fetchDatabaseImage(hash, googleAuthId);
}
}
if (!buf || buf.length === 0) {
throw new ToolError(
`Image not found or empty: "${item.path}". ` +
(hash
? 'Database images must be addressed as "image/<fullHash>" with a 64-char hash.'
: 'Store it first (search_images writes to "images/…") or check the path with find_files.')
);
}
items.push({ ...item, rawBuffer: buf });
}
// ── Compose ───────────────────────────────────────────────────
const shape = resolveGrid(items.length, grid);
let webp;
if (items.length === 1 && shape.name === '1x1') {
webp = await sharp(items[0].rawBuffer)
.rotate()
.resize(SINGLE_BOX, SINGLE_BOX, { fit: 'inside', withoutEnlargement: true })
.webp({ quality: WEBP_QUALITY })
.toBuffer();
} else {
webp = await renderGrid(items, GRID_CONFIGS[shape.name]);
}
// ── Ask the vision model ──────────────────────────────────────
const question = typeof prompt === 'string' && prompt.trim() ? prompt.trim() : null;
const text = items.length > 1
? `${question || 'Compare these images, judge visual quality, flag watermarks, text overlays or defects, and say which one is best and why.'}\n\n` +
`The grid is ${shape.name}. Each image has its file name printed beneath it:\n` +
items.map((it, idx) => {
const row = Math.floor(idx / GRID_CONFIGS[shape.name].cols) + 1;
const col = (idx % GRID_CONFIGS[shape.name].cols) + 1;
return `- Row ${row}, Col ${col}: "${it.name}"`;
}).join('\n')
: question || 'Describe this image, judge its visual quality, flag any watermark, text overlay or defect, and say whether it is usable as a product or category image.';
const apiKey = await resolveOpenRouterKey(googleAuthId);
const provider = new OpenRouterProvider({ apiKey });
const response = await provider.chatCompletions({
model,
messages: [
{
role: 'user',
content: [
{ type: 'text', text },
{ type: 'image_url', imageUrl: { url: `data:image/webp;base64,${webp.toString('base64')}` } },
],
},
],
signal: AbortSignal.timeout(120_000),
});
const analysis = (response?.content || '').trim();
return {
images: items.map((it) => it.path),
grid: shape.name,
prompt: text,
analysis: analysis || '(The vision model returned no text.)',
usage: response?.usage
? { input: response.usage.input, output: response.usage.output, cost: response.usage.cost }
: undefined,
};
},
});
}
/**
* Extracts a 64-char hex SHA-256 hash from a VFS image path, if present.
* Mirrors the sandbox's `extractFullHash` so `image/<hash>` resolves identically here.
* @param {string} p
* @returns {string|null}
*/
function extractFullHash(p) {
if (typeof p !== 'string') return null;
const parts = p.trim().replace(/^([a-zA-Z]:|\/)+/, '').split('/');
for (const part of parts) {
const seg = part.replace(/\.[a-zA-Z0-9]+$/, '');
if (/^[a-fA-F0-9]{64}$/.test(seg)) return seg.toLowerCase();
}
return null;
}