Spaces:
Running
Running
File size: 13,370 Bytes
6c30253 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 | 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); }
|