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

257
src/tools/search-images.js Normal file
View File

@@ -0,0 +1,257 @@
import fs from 'fs';
import path from 'path';
import sharp from 'sharp';
import { defineTool, ToolError } from 'libllmorbiter';
import env from '../config/env.js';
import { prepareVfs, syncVfsToDb } from './vfs.js';
const SERPER_ENDPOINT = 'https://google.serper.dev/images';
const SEARCH_TIMEOUT_MS = 15_000;
const DOWNLOAD_TIMEOUT_MS = 8_000;
const MAX_EDGE = 1000;
const AVIF_QUALITY = 80;
const DEFAULT_COUNT = 4;
const MAX_COUNT = 9;
const IMAGE_FOLDER = 'images';
/**
* search_images — Google image search via Serper.dev, normalised into the VFS.
*
* Results are written to the VFS as AVIF candidates and returned as paths, NOT as inline
* base64 blobs. That is the whole point: the model can hand several candidates to
* `analyze_image`, which grids them into one vision call, and only the chosen one is
* committed to the database. Dumping 9 images into the transcript would be both enormous
* and useless, because the model cannot look at an image without asking the vision model to.
*
* Every downloaded candidate lands in `images/<domain>-<n>.avif` and is persisted to
* `modelvfs`, so a follow-up turn can still reference them.
*/
const USER_AGENT =
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36';
const ACCEPT_HEADER = 'image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8';
/**
* Slugifies a hostname into a safe, readable file-name fragment.
* @param {string} host
* @returns {string}
*/
function slugifyHost(host) {
const cleaned = String(host || '')
.toLowerCase()
.replace(/^www\./, '')
.replace(/[^a-z0-9.]+/g, '-')
.replace(/-+/g, '-')
.replace(/^-|-$/g, '');
return cleaned.slice(0, 40) || 'source';
}
/**
* Queries Serper for image results.
*
* @param {string} query
* @param {string} apiKey
* @param {{ gl?: string, hl?: string, signal?: AbortSignal }} [options]
* @returns {Promise<Array<object>>} raw `images` array from Serper
*/
async function serperImageSearch(query, apiKey, { gl = 'de', hl = 'de', signal } = {}) {
const headers = { 'X-API-KEY': apiKey, 'Content-Type': 'application/json' };
// Cap the whole search at SEARCH_TIMEOUT_MS even if the caller's signal never fires, so a
// hung Serper connection cannot wedge the agent turn indefinitely.
const timeoutSignal = AbortSignal.timeout(SEARCH_TIMEOUT_MS);
const combined = signal ? AbortSignal.any([signal, timeoutSignal]) : timeoutSignal;
let response;
try {
response = await fetch(SERPER_ENDPOINT, {
method: 'POST',
headers,
body: JSON.stringify({ q: query, gl, hl }),
redirect: 'follow',
signal: combined,
});
} catch (err) {
throw new ToolError(`search_images: network failure contacting Serper (${query}): ${err.message}`);
}
if (!response.ok) {
let detail = `${response.status} ${response.statusText}`;
try {
const body = await response.text();
if (body) detail += ` - ${body.slice(0, 300)}`;
} catch { /* body already consumed or unreadable */ }
throw new ToolError(`search_images: Serper API error: ${detail}`);
}
const data = await response.json();
return Array.isArray(data?.images) ? data.images : [];
}
/**
* Downloads one candidate and converts it to a ratio-preserving AVIF buffer.
*
* @param {object} img raw Serper image entry
* @param {{ signal?: AbortSignal }} [options]
* @returns {Promise<Buffer|null>} AVIF bytes, or null if the image was unusable
*/
async function toAvif(img, { signal } = {}) {
const urls = [img?.imageUrl, img?.thumbnailUrl].filter((u) => typeof u === 'string' && u.trim());
if (urls.length === 0) return null;
for (const url of urls) {
if (signal?.aborted) return null;
try {
const downloadSignal = AbortSignal.any(
signal ? [signal, AbortSignal.timeout(DOWNLOAD_TIMEOUT_MS)] : [AbortSignal.timeout(DOWNLOAD_TIMEOUT_MS)]
);
const res = await fetch(url, {
headers: { 'User-Agent': USER_AGENT, Accept: ACCEPT_HEADER },
signal: downloadSignal,
redirect: 'follow',
});
if (!res.ok) continue;
const input = Buffer.from(await res.arrayBuffer());
if (input.length === 0) continue;
return await sharp(input)
.rotate() // honour EXIF orientation before resizing
.resize(MAX_EDGE, MAX_EDGE, { fit: 'inside', withoutEnlargement: true })
.avif({ quality: AVIF_QUALITY })
.toBuffer();
} catch {
// Try the next candidate URL; a dead imageUrl with a live thumbnail is common.
}
}
return null;
}
/**
* Creates the search_images tool bound to a company's virtual filesystem.
* @param {{ googleAuthId: number }} ctx
*/
export function createSearchImagesTool({ googleAuthId }) {
return defineTool({
name: 'search_images',
description:
'Search Google Images and store the results in the virtual filesystem as AVIF candidates. ' +
'Returns VFS paths, not inline images — then pass those paths to analyze_image to actually look at them ' +
'(up to 9 at once, rendered as a labelled grid) and pick the best one before uploading it to an article ' +
'or category. Candidates are cached, so a second search of the same term is cheap.',
strict: false,
parameters: {
type: 'object',
additionalProperties: false,
required: ['query'],
properties: {
query: {
type: 'string',
description: 'Search term, e.g. " Mineralwasser Flasche Glas ".',
},
count: {
type: 'number',
description: `How many candidate images to return. Default ${DEFAULT_COUNT}, max ${MAX_COUNT}.`,
},
gl: {
type: 'string',
description: 'Geolocation country code. Default "de".',
},
hl: {
type: 'string',
description: 'Result language code. Default "de".',
},
},
},
invoke: async ({ query, count = DEFAULT_COUNT, gl = 'de', hl = 'de' } = {}) => {
const term = typeof query === 'string' ? query.trim() : '';
if (!term) {
throw new ToolError('search_images requires a non-empty "query" (e.g. " Mineralwasser Flasche Glas ").');
}
if (!Number.isInteger(count) || count < 1) {
throw new ToolError(`"count" must be a positive integer (received ${count}).`);
}
const wanted = Math.min(count, MAX_COUNT);
const apiKey = env.serperApiKey?.trim();
if (!apiKey) {
throw new ToolError(
'search_images is unavailable: SERPER_API_KEY is not configured on the server. ' +
'Use a direct image URL with javascript_execute instead.'
);
}
const raw = await serperImageSearch(term, apiKey, { gl, hl });
if (raw.length === 0) {
return {
query: term,
total: 0,
returned: 0,
images: [],
note: `No image results for "${term}". Try a broader or more generic term.`,
};
}
const vfsRoot = await prepareVfs({ googleAuthId });
const imagesDir = path.join(vfsRoot, IMAGE_FOLDER);
fs.mkdirSync(imagesDir, { recursive: true });
const results = [];
const failures = [];
// Take a generous slice from Serper: downloads fail often enough (hotlink
// protection, dead URLs) that stopping at `wanted` raw hits under-fills the batch.
for (const img of raw.slice(0, Math.max(wanted * 3, wanted))) {
if (results.length >= wanted) break;
const host = img?.domain || (() => {
try { return img?.imageUrl ? new URL(img.imageUrl).hostname : ''; } catch { return ''; }
})();
const bytes = await toAvif(img);
if (!bytes || bytes.length === 0) {
failures.push({ source: img?.link || img?.imageUrl || '', reason: 'download or conversion failed' });
continue;
}
const name = `${slugifyHost(host)}-${results.length + 1}.avif`;
const relPath = `${IMAGE_FOLDER}/${name}`;
fs.writeFileSync(path.join(imagesDir, name), bytes);
results.push({
path: relPath,
domain: host,
title: typeof img?.title === 'string' ? img.title : '',
sourcePage: typeof img?.link === 'string' ? img.link : '',
bytes: bytes.length,
});
}
if (results.length > 0) {
try {
await syncVfsToDb({ googleAuthId, vfsRoot });
} catch (err) {
// Non-fatal: the files are on disk and will be picked up by the next
// javascript_execute / patch sync. Losing the images because a DB write
// failed would be a worse outcome than a slightly stale store.
console.warn('[search_images] failed to persist candidates:', err.message);
}
}
const result = {
query: term,
total: results.length,
returned: results.length,
images: results,
nextStep: 'Call analyze_image with these paths (grid) to compare them, then upload the winner.',
};
if (failures.length > 0) result.skipped = failures.length;
if (results.length === 0) {
result.note = `Found ${raw.length} result(s) for "${term}" but none could be downloaded. Try different wording.`;
}
return result;
},
});
}