LFM2.5-VL-3B-WebGPU / src /tools /tool-registry.js
shubeydoo's picture
Initial release
6c30253
Raw
History Blame Contribute Delete
13.4 kB
const STORAGE_KEY = 'liquid-webgpu-tools-v1';
const STORAGE_VERSION = 2;
export const BUILTIN_TOOLS = [
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.', {
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.' },
}, ['expression']),
definition('current_datetime', 'Get the current date and time, optionally in an IANA time zone.', {
time_zone: { type: 'string', description: 'Optional IANA time zone, such as America/New_York.' },
}),
definition('random_integer', 'Generate a cryptographically random integer in an inclusive range.', {
min: { type: 'integer', description: 'Inclusive lower bound.' },
max: { type: 'integer', description: 'Inclusive upper bound.' },
}, ['min', 'max']),
definition('get_geolocation', 'Request the device location using the browser permission prompt.', {
high_accuracy: { type: 'boolean', description: 'Whether to request high-accuracy location.' },
}),
definition('search_recipe_by_dish', 'Search for one recipe matching the name of a completed dish.', {
dish_name: { type: 'string', description: 'The conventional name of the dish.' },
}, ['dish_name'], { external: true }),
];
function definition(name, description, properties, required = [], metadata = {}) {
return { id: `builtin:${name}`, name, description, source: 'builtin', enabled: false, ...metadata, parameters: { type: 'object', properties, required, additionalProperties: false } };
}
export function loadTools() {
let saved = {};
try { saved = JSON.parse(localStorage.getItem(STORAGE_KEY) || '{}'); } catch { saved = {}; }
const enabled = saved.enabled || {};
const resetEnabledState = saved.version !== STORAGE_VERSION;
const builtins = BUILTIN_TOOLS.map(tool => ({ ...tool, enabled: resetEnabledState ? false : enabled[tool.id] ?? false }));
if (resetEnabledState || saved.custom) saveTools(builtins);
return builtins;
}
export function saveTools(tools) {
const enabled = Object.fromEntries(tools.filter(tool => tool.source === 'builtin').map(tool => [tool.id, Boolean(tool.enabled)]));
localStorage.setItem(STORAGE_KEY, JSON.stringify({ version: STORAGE_VERSION, enabled }));
}
export function modelToolDefinitions(tools) {
return tools.filter(tool => tool.enabled).map(({ name, description, parameters }) => ({ name, description, parameters }));
}
export function prepareToolCall(call, tools) {
const tool = tools.find(candidate => candidate.enabled && candidate.name === call.name);
if (!tool) throw new Error(`Unknown or disabled tool: ${call.name}`);
const propertyNames = Object.keys(tool.parameters.properties || {});
if (call.positional.length > propertyNames.length) throw new Error(`${tool.name} received too many positional arguments.`);
const args = { ...call.arguments };
call.positional.forEach((value, index) => {
const key = propertyNames[index];
if (Object.hasOwn(args, key)) throw new Error(`${tool.name} received ${key} twice.`);
args[key] = value;
});
validateArguments(tool.parameters, args);
return { tool, args };
}
export async function executeBuiltin(name, args, signal) {
if (signal?.aborted) throw new DOMException('Generation stopped.', 'AbortError');
if (name === 'calculate') return { value: calculate(args.expression) };
if (name === 'current_datetime') return currentDatetime(args.time_zone);
if (name === 'random_integer') return { value: randomInteger(args.min, args.max), min: args.min, max: args.max };
if (name === 'get_geolocation') return geolocate(args.high_accuracy, signal);
if (name === 'search_recipe_by_dish') return searchRecipeByDish(args.dish_name, signal);
throw new Error(`No built-in executor exists for ${name}.`);
}
export async function searchRecipeByDish(rawDishName, signal, fetcher = globalThis.fetch) {
const dishName = typeof rawDishName === 'string' ? rawDishName.trim() : '';
if (!dishName || dishName.length > 100) throw new Error('dish_name must contain between 1 and 100 characters.');
if (typeof fetcher !== 'function') throw new Error('Recipe search is unavailable in this browser.');
const response = await fetcher(`https://www.themealdb.com/api/json/v1/1/search.php?s=${encodeURIComponent(dishName)}`, { signal });
if (!response.ok) throw new Error(`TheMealDB search failed (${response.status}).`);
const queryWords = normalizedWords(dishName);
const candidates = (await response.json()).meals || [];
const relevant = candidates.filter(meal => {
const titleWords = normalizedWords(meal.strMeal || '');
return queryWords.every(word => titleWords.includes(word));
});
const exact = relevant.find(meal => normalizedWords(meal.strMeal || '').join(' ') === queryWords.join(' '));
const meal = exact || relevant[0] || null;
const recipes = meal ? [{
id: meal.idMeal,
name: meal.strMeal,
category: meal.strCategory || null,
cuisine: meal.strArea || null,
ingredients: extractRecipeIngredients(meal).map(item => [item.measure, item.name].filter(Boolean).join(' ')),
instructions: String(meal.strInstructions || '').trim().slice(0, 800),
source_url: meal.strSource || `https://www.themealdb.com/meal/${meal.idMeal}`,
}] : [];
return {
provider: 'TheMealDB',
recipes,
};
}
function extractRecipeIngredients(meal) {
const ingredients = [];
for (let index = 1; index <= 20; index += 1) {
const name = String(meal[`strIngredient${index}`] || '').trim();
if (name) ingredients.push({ name, measure: String(meal[`strMeasure${index}`] || '').trim() });
}
return ingredients;
}
function normalizedWords(value) {
return String(value).toLowerCase().normalize('NFKD').replace(/[\u0300-\u036f]/g, '').match(/[a-z0-9]+/g)?.map(word => {
if (word.endsWith('oes') && word.length > 4) return word.slice(0, -2);
if (word.endsWith('ies') && word.length > 4) return `${word.slice(0, -3)}y`;
if (word.endsWith('s') && !word.endsWith('ss') && word.length > 3) return word.slice(0, -1);
return word;
}) || [];
}
export function calculate(expression) {
if (typeof expression !== 'string' || !expression.trim() || expression.length > 256) throw new Error('Expression must contain 1–256 characters.');
const tokens = tokenize(expression);
let position = 0;
const peek = value => tokens[position]?.value === value;
const consume = value => {
if (!peek(value)) throw new Error(`Expected ${value || 'a number'}.`);
return tokens[position++];
};
function primary() {
if (peek('+') || peek('-')) {
const operator = tokens[position++].value;
const operand = primary();
return operator === '-' ? -operand : operand;
}
if (peek('(')) { consume('('); const value = additive(); consume(')'); return value; }
const token = tokens[position++];
if (token?.type === 'identifier') {
if (Object.hasOwn(CALCULATOR_CONSTANTS, token.value)) return CALCULATOR_CONSTANTS[token.value];
const operation = CALCULATOR_FUNCTIONS[token.value];
if (!operation) throw new Error(`Unsupported function or constant: ${token.value}`);
consume('(');
const argument = additive();
consume(')');
return operation(argument);
}
if (!token || token.type !== 'number') throw new Error('Expected a number.');
return token.number;
}
function power() { let left = primary(); if (peek('^')) { consume('^'); left **= power(); } return left; }
function multiplicative() {
let left = power();
while (peek('*') || peek('/') || peek('%')) {
const operator = tokens[position++].value; const right = power();
if ((operator === '/' || operator === '%') && right === 0) throw new Error('Division by zero is undefined.');
left = operator === '*' ? left * right : operator === '/' ? left / right : left % right;
}
return left;
}
function additive() {
let left = multiplicative();
while (peek('+') || peek('-')) { const operator = tokens[position++].value; const right = multiplicative(); left = operator === '+' ? left + right : left - right; }
return left;
}
const result = additive();
if (position !== tokens.length) throw new Error(`Unexpected token: ${tokens[position].value}`);
if (!Number.isFinite(result)) throw new Error('The expression did not produce a finite number.');
return result;
}
const toDegrees = value => value * 180 / Math.PI;
const toRadians = value => value * Math.PI / 180;
const CALCULATOR_CONSTANTS = Object.freeze({ pi: Math.PI, e: Math.E });
const CALCULATOR_FUNCTIONS = Object.freeze({
sin: Math.sin,
cos: Math.cos,
tan: Math.tan,
asin: Math.asin,
acos: Math.acos,
atan: Math.atan,
sin_deg: value => Math.sin(toRadians(value)),
cos_deg: value => Math.cos(toRadians(value)),
tan_deg: value => Math.tan(toRadians(value)),
asin_deg: value => toDegrees(Math.asin(value)),
acos_deg: value => toDegrees(Math.acos(value)),
atan_deg: value => toDegrees(Math.atan(value)),
sqrt: Math.sqrt,
abs: Math.abs,
});
function tokenize(expression) {
const tokens = [];
let cursor = 0;
while (cursor < expression.length) {
const rest = expression.slice(cursor);
const whitespace = /^\s+/.exec(rest);
if (whitespace) { cursor += whitespace[0].length; continue; }
const number = /^(?:\d+\.?\d*|\.\d+)(?:e[+-]?\d+)?/i.exec(rest);
if (number) { tokens.push({ type: 'number', value: number[0], number: Number(number[0]) }); cursor += number[0].length; continue; }
const identifier = /^[A-Za-z_][A-Za-z0-9_]*/.exec(rest);
if (identifier) { tokens.push({ type: 'identifier', value: identifier[0].toLowerCase() }); cursor += identifier[0].length; continue; }
if ('+-*/%^()'.includes(rest[0])) { tokens.push({ type: 'operator', value: rest[0] }); cursor += 1; continue; }
throw new Error(`Unsupported character: ${rest[0]}`);
}
return tokens;
}
function currentDatetime(timeZone) {
const options = { dateStyle: 'full', timeStyle: 'long' };
if (timeZone) options.timeZone = timeZone;
let formatted;
try { formatted = new Intl.DateTimeFormat(undefined, options).format(new Date()); }
catch { throw new Error(`Invalid IANA time zone: ${timeZone}`); }
return { iso_utc: new Date().toISOString(), time_zone: timeZone || Intl.DateTimeFormat().resolvedOptions().timeZone, formatted };
}
function randomInteger(min, max) {
const range = max - min + 1;
if (!Number.isSafeInteger(min) || !Number.isSafeInteger(max) || min > max) throw new Error('min and max must be safe integers with min ≤ max.');
if (range > 0x100000000) throw new Error('The requested random range must contain at most 2^32 integers.');
if (range === 0x100000000) return min + crypto.getRandomValues(new Uint32Array(1))[0];
const limit = Math.floor(0x100000000 / range) * range;
let value;
do { value = crypto.getRandomValues(new Uint32Array(1))[0]; } while (value >= limit);
return min + (value % range);
}
function geolocate(highAccuracy = false, signal) {
if (!navigator.geolocation) throw new Error('Geolocation is unavailable in this browser.');
return new Promise((resolve, reject) => {
let watchId;
const cleanup = () => { if (watchId !== undefined) navigator.geolocation.clearWatch(watchId); signal?.removeEventListener('abort', abort); };
const abort = () => { cleanup(); reject(new DOMException('Generation stopped.', 'AbortError')); };
signal?.addEventListener('abort', abort, { once: true });
watchId = navigator.geolocation.watchPosition(position => {
cleanup();
resolve({ latitude: position.coords.latitude, longitude: position.coords.longitude, accuracy_meters: position.coords.accuracy, captured_at: new Date(position.timestamp).toISOString() });
}, error => { cleanup(); reject(new Error(`Geolocation failed: ${error.message}`)); }, { enableHighAccuracy: Boolean(highAccuracy), timeout: 15000, maximumAge: 0 });
});
}
function validateArguments(schema, args) {
if (!isPlainObject(args)) throw new Error('Tool arguments must be an object.');
for (const required of schema.required || []) if (!Object.hasOwn(args, required)) throw new Error(`Missing required argument: ${required}`);
if (schema.additionalProperties === false) for (const name of Object.keys(args)) if (!Object.hasOwn(schema.properties, name)) throw new Error(`Unknown argument: ${name}`);
for (const [name, value] of Object.entries(args)) {
const property = schema.properties[name];
if (!property) continue;
const valid = property.type === 'integer' ? Number.isInteger(value)
: property.type === 'number' ? typeof value === 'number' && Number.isFinite(value)
: property.type === 'array' ? Array.isArray(value)
: property.type === 'object' ? isPlainObject(value)
: typeof value === property.type;
if (!valid) throw new Error(`${name} must be ${property.type}.`);
if (property.enum && !property.enum.includes(value)) throw new Error(`${name} must be one of the allowed values.`);
}
}
function isPlainObject(value) { return Boolean(value) && typeof value === 'object' && !Array.isArray(value); }