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); }