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