- Add web_search built-in tool that searches via DuckDuckGo - Add get_location tool to get user's geographic location - Create backend search proxy endpoint (/api/v1/proxy/search) - DuckDuckGo HTML scraping with title, URL, and snippet extraction - Geolocation with OpenStreetMap reverse geocoding for city/country - Fix StreamingIndicator visibility in dark mode - Improve tool descriptions to encourage proper tool usage - Better error messages with suggestions when location fails 🤖 Generated with [Claude Code](https://claude.ai/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
680 lines
19 KiB
TypeScript
680 lines
19 KiB
TypeScript
/**
|
|
* Built-in tools that come with the application
|
|
*/
|
|
|
|
import type { ToolDefinition, BuiltinToolHandler, ToolRegistryEntry } from './types.js';
|
|
|
|
// ============================================================================
|
|
// Get Current Time Tool
|
|
// ============================================================================
|
|
|
|
interface GetTimeArgs {
|
|
timezone?: string;
|
|
format?: 'iso' | 'locale' | 'unix';
|
|
}
|
|
|
|
const getTimeDefinition: ToolDefinition = {
|
|
type: 'function',
|
|
function: {
|
|
name: 'get_current_time',
|
|
description: 'Get the current date and time. Can optionally specify timezone and format.',
|
|
parameters: {
|
|
type: 'object',
|
|
properties: {
|
|
timezone: {
|
|
type: 'string',
|
|
description: 'IANA timezone name (e.g., "America/New_York", "Europe/London"). Defaults to local timezone.'
|
|
},
|
|
format: {
|
|
type: 'string',
|
|
description: 'Output format: "iso" for ISO 8601, "locale" for localized string, "unix" for Unix timestamp.',
|
|
enum: ['iso', 'locale', 'unix']
|
|
}
|
|
}
|
|
}
|
|
}
|
|
};
|
|
|
|
const getTimeHandler: BuiltinToolHandler<GetTimeArgs> = (args) => {
|
|
const now = new Date();
|
|
const format = args.format ?? 'iso';
|
|
|
|
try {
|
|
if (args.timezone) {
|
|
const options: Intl.DateTimeFormatOptions = {
|
|
timeZone: args.timezone,
|
|
year: 'numeric',
|
|
month: '2-digit',
|
|
day: '2-digit',
|
|
hour: '2-digit',
|
|
minute: '2-digit',
|
|
second: '2-digit',
|
|
hour12: false
|
|
};
|
|
|
|
if (format === 'locale') {
|
|
return new Intl.DateTimeFormat('en-US', {
|
|
...options,
|
|
dateStyle: 'full',
|
|
timeStyle: 'long'
|
|
}).format(now);
|
|
}
|
|
|
|
const formatter = new Intl.DateTimeFormat('en-CA', options);
|
|
const parts = formatter.formatToParts(now);
|
|
const get = (type: string) => parts.find(p => p.type === type)?.value ?? '';
|
|
return `${get('year')}-${get('month')}-${get('day')}T${get('hour')}:${get('minute')}:${get('second')}`;
|
|
}
|
|
|
|
switch (format) {
|
|
case 'unix':
|
|
return Math.floor(now.getTime() / 1000);
|
|
case 'locale':
|
|
return now.toLocaleString();
|
|
case 'iso':
|
|
default:
|
|
return now.toISOString();
|
|
}
|
|
} catch {
|
|
return { error: `Invalid timezone: ${args.timezone}` };
|
|
}
|
|
};
|
|
|
|
// ============================================================================
|
|
// Calculate Tool (Safe Math Expression Parser)
|
|
// ============================================================================
|
|
|
|
interface CalculateArgs {
|
|
expression: string;
|
|
precision?: number;
|
|
}
|
|
|
|
const calculateDefinition: ToolDefinition = {
|
|
type: 'function',
|
|
function: {
|
|
name: 'calculate',
|
|
description: 'Compute a mathematical expression. Supports basic arithmetic (+, -, *, /, ^), parentheses, and common functions (sqrt, sin, cos, tan, log, exp, abs, round, floor, ceil).',
|
|
parameters: {
|
|
type: 'object',
|
|
properties: {
|
|
expression: {
|
|
type: 'string',
|
|
description: 'The mathematical expression to compute (e.g., "2 + 2", "sqrt(16)", "sin(3.14159/2)")'
|
|
},
|
|
precision: {
|
|
type: 'number',
|
|
description: 'Number of decimal places for the result (default: 10)'
|
|
}
|
|
},
|
|
required: ['expression']
|
|
}
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Safe math expression parser using recursive descent
|
|
* Parses and computes expressions without using dynamic code execution
|
|
*/
|
|
class MathParser {
|
|
private pos = 0;
|
|
private expr = '';
|
|
|
|
private readonly functions: Record<string, (x: number) => number> = {
|
|
sqrt: Math.sqrt,
|
|
sin: Math.sin,
|
|
cos: Math.cos,
|
|
tan: Math.tan,
|
|
log: Math.log,
|
|
log10: Math.log10,
|
|
exp: Math.exp,
|
|
abs: Math.abs,
|
|
round: Math.round,
|
|
floor: Math.floor,
|
|
ceil: Math.ceil
|
|
};
|
|
|
|
private readonly constants: Record<string, number> = {
|
|
PI: Math.PI,
|
|
pi: Math.PI,
|
|
E: Math.E,
|
|
e: Math.E
|
|
};
|
|
|
|
parse(expression: string): number {
|
|
this.expr = expression.replace(/\s+/g, '');
|
|
this.pos = 0;
|
|
const result = this.parseExpression();
|
|
if (this.pos < this.expr.length) {
|
|
throw new Error(`Unexpected character at position ${this.pos}: ${this.expr[this.pos]}`);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
private parseExpression(): number {
|
|
return this.parseAddSub();
|
|
}
|
|
|
|
private parseAddSub(): number {
|
|
let left = this.parseMulDiv();
|
|
while (this.pos < this.expr.length) {
|
|
const op = this.expr[this.pos];
|
|
if (op === '+') {
|
|
this.pos++;
|
|
left = left + this.parseMulDiv();
|
|
} else if (op === '-') {
|
|
this.pos++;
|
|
left = left - this.parseMulDiv();
|
|
} else {
|
|
break;
|
|
}
|
|
}
|
|
return left;
|
|
}
|
|
|
|
private parseMulDiv(): number {
|
|
let left = this.parsePower();
|
|
while (this.pos < this.expr.length) {
|
|
const op = this.expr[this.pos];
|
|
if (op === '*') {
|
|
this.pos++;
|
|
left = left * this.parsePower();
|
|
} else if (op === '/') {
|
|
this.pos++;
|
|
const right = this.parsePower();
|
|
if (right === 0) throw new Error('Division by zero');
|
|
left = left / right;
|
|
} else if (op === '%') {
|
|
this.pos++;
|
|
left = left % this.parsePower();
|
|
} else {
|
|
break;
|
|
}
|
|
}
|
|
return left;
|
|
}
|
|
|
|
private parsePower(): number {
|
|
const left = this.parseUnary();
|
|
if (this.pos < this.expr.length && (this.expr[this.pos] === '^' || this.expr.slice(this.pos, this.pos + 2) === '**')) {
|
|
if (this.expr[this.pos] === '^') {
|
|
this.pos++;
|
|
} else {
|
|
this.pos += 2;
|
|
}
|
|
return Math.pow(left, this.parsePower());
|
|
}
|
|
return left;
|
|
}
|
|
|
|
private parseUnary(): number {
|
|
if (this.expr[this.pos] === '-') {
|
|
this.pos++;
|
|
return -this.parseUnary();
|
|
}
|
|
if (this.expr[this.pos] === '+') {
|
|
this.pos++;
|
|
return this.parseUnary();
|
|
}
|
|
return this.parsePrimary();
|
|
}
|
|
|
|
private parsePrimary(): number {
|
|
if (this.expr[this.pos] === '(') {
|
|
this.pos++;
|
|
const result = this.parseExpression();
|
|
if (this.expr[this.pos] !== ')') {
|
|
throw new Error('Missing closing parenthesis');
|
|
}
|
|
this.pos++;
|
|
return result;
|
|
}
|
|
|
|
const funcMatch = this.expr.slice(this.pos).match(/^([a-zA-Z_][a-zA-Z0-9_]*)/);
|
|
if (funcMatch) {
|
|
const name = funcMatch[1];
|
|
this.pos += name.length;
|
|
|
|
if (this.constants[name] !== undefined) {
|
|
return this.constants[name];
|
|
}
|
|
|
|
const fn = this.functions[name.toLowerCase()];
|
|
if (!fn) {
|
|
throw new Error(`Unknown function or constant: ${name}`);
|
|
}
|
|
|
|
if (this.expr[this.pos] !== '(') {
|
|
throw new Error(`Expected '(' after function ${name}`);
|
|
}
|
|
this.pos++;
|
|
const arg = this.parseExpression();
|
|
if (this.expr[this.pos] !== ')') {
|
|
throw new Error('Missing closing parenthesis for function');
|
|
}
|
|
this.pos++;
|
|
return fn(arg);
|
|
}
|
|
|
|
const numMatch = this.expr.slice(this.pos).match(/^(\d+\.?\d*|\.\d+)/);
|
|
if (numMatch) {
|
|
this.pos += numMatch[1].length;
|
|
return parseFloat(numMatch[1]);
|
|
}
|
|
|
|
throw new Error(`Unexpected character at position ${this.pos}: ${this.expr[this.pos] || 'end of expression'}`);
|
|
}
|
|
}
|
|
|
|
const mathParser = new MathParser();
|
|
|
|
const calculateHandler: BuiltinToolHandler<CalculateArgs> = (args) => {
|
|
const { expression, precision = 10 } = args;
|
|
|
|
try {
|
|
const result = mathParser.parse(expression);
|
|
|
|
if (typeof result !== 'number' || !isFinite(result)) {
|
|
return { error: 'Expression resulted in invalid number (infinity or NaN)' };
|
|
}
|
|
|
|
return Number(result.toFixed(precision));
|
|
} catch (error) {
|
|
return { error: `Failed to compute expression: ${error instanceof Error ? error.message : 'Unknown error'}` };
|
|
}
|
|
};
|
|
|
|
// ============================================================================
|
|
// Fetch URL Tool (Web Content Retrieval)
|
|
// ============================================================================
|
|
|
|
interface FetchUrlArgs {
|
|
url: string;
|
|
extract?: 'text' | 'title' | 'links' | 'all';
|
|
maxLength?: number;
|
|
}
|
|
|
|
const fetchUrlDefinition: ToolDefinition = {
|
|
type: 'function',
|
|
function: {
|
|
name: 'fetch_url',
|
|
description: 'Fetch content from a URL and extract text, title, or links. Useful for retrieving web page content.',
|
|
parameters: {
|
|
type: 'object',
|
|
properties: {
|
|
url: {
|
|
type: 'string',
|
|
description: 'The URL to fetch (must be a valid HTTP/HTTPS URL)'
|
|
},
|
|
extract: {
|
|
type: 'string',
|
|
description: 'What to extract: "text" for main content, "title" for page title, "links" for all links, "all" for everything',
|
|
enum: ['text', 'title', 'links', 'all']
|
|
},
|
|
maxLength: {
|
|
type: 'number',
|
|
description: 'Maximum length of extracted text (default: 5000 characters)'
|
|
}
|
|
},
|
|
required: ['url']
|
|
}
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Try to fetch URL via backend proxy first (bypasses CORS), fall back to direct fetch
|
|
*/
|
|
async function fetchViaProxy(url: string, maxLength: number): Promise<{ html: string; finalUrl: string } | { error: string }> {
|
|
// Try backend proxy first
|
|
try {
|
|
const proxyResponse = await fetch('/api/v1/proxy/fetch', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ url, maxLength })
|
|
});
|
|
|
|
if (proxyResponse.ok) {
|
|
const data = await proxyResponse.json();
|
|
return { html: data.content, finalUrl: data.url };
|
|
}
|
|
|
|
// If proxy returns an error, extract it
|
|
const errorData = await proxyResponse.json().catch(() => null);
|
|
if (errorData?.error) {
|
|
return { error: errorData.error };
|
|
}
|
|
} catch {
|
|
// Proxy not available, try direct fetch
|
|
}
|
|
|
|
// Fall back to direct fetch (may fail due to CORS)
|
|
try {
|
|
const controller = new AbortController();
|
|
const timeout = setTimeout(() => controller.abort(), 10000);
|
|
|
|
const response = await fetch(url, {
|
|
signal: controller.signal,
|
|
headers: {
|
|
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8'
|
|
}
|
|
});
|
|
|
|
clearTimeout(timeout);
|
|
|
|
if (!response.ok) {
|
|
return { error: `HTTP ${response.status}: ${response.statusText}` };
|
|
}
|
|
|
|
const html = await response.text();
|
|
return { html, finalUrl: response.url };
|
|
} catch (error) {
|
|
if (error instanceof Error && error.name === 'AbortError') {
|
|
return { error: 'Request timed out after 10 seconds' };
|
|
}
|
|
// Provide helpful error message for CORS issues
|
|
const message = error instanceof Error ? error.message : 'Unknown error';
|
|
if (message.includes('NetworkError') || message.includes('CORS')) {
|
|
return { error: `Cannot fetch external URL due to browser security restrictions. The backend proxy is not available. Start the backend server to enable URL fetching.` };
|
|
}
|
|
return { error: `Failed to fetch URL: ${message}` };
|
|
}
|
|
}
|
|
|
|
const fetchUrlHandler: BuiltinToolHandler<FetchUrlArgs> = async (args) => {
|
|
const { url, extract = 'text', maxLength = 5000 } = args;
|
|
|
|
try {
|
|
const parsedUrl = new URL(url);
|
|
if (!['http:', 'https:'].includes(parsedUrl.protocol)) {
|
|
return { error: 'Only HTTP and HTTPS URLs are supported' };
|
|
}
|
|
|
|
// Fetch via proxy or direct
|
|
const result = await fetchViaProxy(url, maxLength);
|
|
if ('error' in result) {
|
|
return result;
|
|
}
|
|
|
|
const { html, finalUrl } = result;
|
|
|
|
const titleMatch = html.match(/<title[^>]*>([^<]+)<\/title>/i);
|
|
const title = titleMatch ? titleMatch[1].trim() : null;
|
|
|
|
if (extract === 'title') {
|
|
return title ?? 'No title found';
|
|
}
|
|
|
|
const stripHtml = (str: string) => {
|
|
return str
|
|
.replace(/<script[^>]*>[\s\S]*?<\/script>/gi, '')
|
|
.replace(/<style[^>]*>[\s\S]*?<\/style>/gi, '')
|
|
.replace(/<[^>]+>/g, ' ')
|
|
.replace(/\s+/g, ' ')
|
|
.trim();
|
|
};
|
|
|
|
const linkMatches = [...html.matchAll(/<a[^>]+href=["']([^"']+)["'][^>]*>([^<]*)<\/a>/gi)];
|
|
const links = linkMatches.slice(0, 50).map(match => ({
|
|
url: match[1],
|
|
text: match[2].trim()
|
|
})).filter(link => link.url && !link.url.startsWith('#'));
|
|
|
|
if (extract === 'links') {
|
|
return links;
|
|
}
|
|
|
|
const text = stripHtml(html).substring(0, maxLength);
|
|
|
|
if (extract === 'text') {
|
|
return text;
|
|
}
|
|
|
|
return {
|
|
title,
|
|
text,
|
|
links: links.slice(0, 20),
|
|
url: finalUrl
|
|
};
|
|
} catch (error) {
|
|
return { error: `Failed to fetch URL: ${error instanceof Error ? error.message : 'Unknown error'}` };
|
|
}
|
|
};
|
|
|
|
// ============================================================================
|
|
// Get Location Tool
|
|
// ============================================================================
|
|
|
|
interface GetLocationArgs {
|
|
highAccuracy?: boolean;
|
|
}
|
|
|
|
interface LocationResult {
|
|
latitude: number;
|
|
longitude: number;
|
|
accuracy: number;
|
|
city?: string;
|
|
country?: string;
|
|
}
|
|
|
|
const getLocationDefinition: ToolDefinition = {
|
|
type: 'function',
|
|
function: {
|
|
name: 'get_location',
|
|
description: 'Get the user\'s current location (city, country, coordinates). Call this IMMEDIATELY when you need location for weather, local info, or nearby places. Do NOT ask the user where they are - use this tool instead.',
|
|
parameters: {
|
|
type: 'object',
|
|
properties: {
|
|
highAccuracy: {
|
|
type: 'boolean',
|
|
description: 'Whether to request high accuracy GPS location (may take longer and use more battery). Default is false.'
|
|
}
|
|
}
|
|
}
|
|
}
|
|
};
|
|
|
|
const getLocationHandler: BuiltinToolHandler<GetLocationArgs> = async (args) => {
|
|
const { highAccuracy = false } = args;
|
|
|
|
// Check if geolocation is available
|
|
if (!navigator.geolocation) {
|
|
return { error: 'Geolocation is not supported by this browser' };
|
|
}
|
|
|
|
try {
|
|
const position = await new Promise<GeolocationPosition>((resolve, reject) => {
|
|
navigator.geolocation.getCurrentPosition(resolve, reject, {
|
|
enableHighAccuracy: highAccuracy,
|
|
timeout: 30000, // 30 seconds - user needs time to accept permission prompt
|
|
maximumAge: 300000 // Cache for 5 minutes
|
|
});
|
|
});
|
|
|
|
const result: LocationResult = {
|
|
latitude: position.coords.latitude,
|
|
longitude: position.coords.longitude,
|
|
accuracy: Math.round(position.coords.accuracy)
|
|
};
|
|
|
|
// Try to get city/country via reverse geocoding (using a free service)
|
|
try {
|
|
const geoResponse = await fetch(
|
|
`https://nominatim.openstreetmap.org/reverse?lat=${result.latitude}&lon=${result.longitude}&format=json`,
|
|
{
|
|
headers: {
|
|
'User-Agent': 'OllamaWebUI/1.0'
|
|
}
|
|
}
|
|
);
|
|
|
|
if (geoResponse.ok) {
|
|
const geoData = await geoResponse.json();
|
|
if (geoData.address) {
|
|
result.city = geoData.address.city || geoData.address.town || geoData.address.village || geoData.address.municipality;
|
|
result.country = geoData.address.country;
|
|
}
|
|
}
|
|
} catch {
|
|
// Reverse geocoding failed, but we still have coordinates
|
|
}
|
|
|
|
return {
|
|
location: result,
|
|
message: result.city
|
|
? `User is located in ${result.city}${result.country ? ', ' + result.country : ''}`
|
|
: `User is at coordinates ${result.latitude.toFixed(4)}, ${result.longitude.toFixed(4)}`
|
|
};
|
|
} catch (error) {
|
|
if (error instanceof GeolocationPositionError) {
|
|
switch (error.code) {
|
|
case error.PERMISSION_DENIED:
|
|
return {
|
|
error: 'Location permission denied',
|
|
suggestion: 'Ask the user for their city/location directly, then use web_search with that location.'
|
|
};
|
|
case error.POSITION_UNAVAILABLE:
|
|
return {
|
|
error: 'Location services unavailable on this device',
|
|
suggestion: 'Ask the user for their city/location directly, then use web_search with that location.'
|
|
};
|
|
case error.TIMEOUT:
|
|
return {
|
|
error: 'Location request timed out',
|
|
suggestion: 'Ask the user for their city/location directly, then use web_search with that location.'
|
|
};
|
|
}
|
|
}
|
|
return {
|
|
error: `Failed to get location: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
|
suggestion: 'Ask the user for their city/location directly, then use web_search with that location.'
|
|
};
|
|
}
|
|
};
|
|
|
|
// ============================================================================
|
|
// Web Search Tool
|
|
// ============================================================================
|
|
|
|
interface WebSearchArgs {
|
|
query: string;
|
|
maxResults?: number;
|
|
}
|
|
|
|
interface WebSearchResult {
|
|
title: string;
|
|
url: string;
|
|
snippet: string;
|
|
}
|
|
|
|
const webSearchDefinition: ToolDefinition = {
|
|
type: 'function',
|
|
function: {
|
|
name: 'web_search',
|
|
description: 'Search the web for current information. You MUST call this tool immediately when the user asks about weather, news, current events, sports, stocks, prices, or any real-time information. Do NOT ask the user for clarification - just search. If no location is specified for weather, call get_location first.',
|
|
parameters: {
|
|
type: 'object',
|
|
properties: {
|
|
query: {
|
|
type: 'string',
|
|
description: 'The search query (e.g., "weather Berlin tomorrow", "latest news", "Bitcoin price")'
|
|
},
|
|
maxResults: {
|
|
type: 'number',
|
|
description: 'Maximum number of results to return (1-10, default 5)'
|
|
}
|
|
},
|
|
required: ['query']
|
|
}
|
|
}
|
|
};
|
|
|
|
const webSearchHandler: BuiltinToolHandler<WebSearchArgs> = async (args) => {
|
|
const { query, maxResults = 5 } = args;
|
|
|
|
if (!query || query.trim() === '') {
|
|
return { error: 'Search query is required' };
|
|
}
|
|
|
|
// Try backend proxy first
|
|
try {
|
|
const proxyResponse = await fetch('/api/v1/proxy/search', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ query, maxResults: Math.min(Math.max(1, maxResults), 10) })
|
|
});
|
|
|
|
if (proxyResponse.ok) {
|
|
const data = await proxyResponse.json();
|
|
const results = data.results as WebSearchResult[];
|
|
|
|
if (results.length === 0) {
|
|
return { message: 'No search results found for the query.', query };
|
|
}
|
|
|
|
// Format results for the AI
|
|
return {
|
|
query,
|
|
resultCount: results.length,
|
|
results: results.map((r, i) => ({
|
|
rank: i + 1,
|
|
title: r.title,
|
|
url: r.url,
|
|
snippet: r.snippet || '(no snippet available)'
|
|
}))
|
|
};
|
|
}
|
|
|
|
// If proxy returns an error, extract it
|
|
const errorData = await proxyResponse.json().catch(() => null);
|
|
if (errorData?.error) {
|
|
return { error: errorData.error };
|
|
}
|
|
} catch {
|
|
// Proxy not available
|
|
}
|
|
|
|
return {
|
|
error: 'Web search is not available. Please start the backend server to enable web search functionality.',
|
|
hint: 'Run the backend server with: cd backend && go run cmd/server/main.go'
|
|
};
|
|
};
|
|
|
|
// ============================================================================
|
|
// Registry of Built-in Tools
|
|
// ============================================================================
|
|
|
|
export const builtinTools: Map<string, ToolRegistryEntry> = new Map([
|
|
['get_current_time', {
|
|
definition: getTimeDefinition,
|
|
handler: getTimeHandler as unknown as BuiltinToolHandler,
|
|
isBuiltin: true
|
|
}],
|
|
['calculate', {
|
|
definition: calculateDefinition,
|
|
handler: calculateHandler as unknown as BuiltinToolHandler,
|
|
isBuiltin: true
|
|
}],
|
|
['fetch_url', {
|
|
definition: fetchUrlDefinition,
|
|
handler: fetchUrlHandler as unknown as BuiltinToolHandler,
|
|
isBuiltin: true
|
|
}],
|
|
['get_location', {
|
|
definition: getLocationDefinition,
|
|
handler: getLocationHandler as unknown as BuiltinToolHandler,
|
|
isBuiltin: true
|
|
}],
|
|
['web_search', {
|
|
definition: webSearchDefinition,
|
|
handler: webSearchHandler as unknown as BuiltinToolHandler,
|
|
isBuiltin: true
|
|
}]
|
|
]);
|
|
|
|
/** Get all built-in tool definitions for Ollama API */
|
|
export function getBuiltinToolDefinitions(): ToolDefinition[] {
|
|
return Array.from(builtinTools.values()).map(entry => entry.definition);
|
|
}
|
|
|
|
// Log available builtin tools at startup
|
|
console.log('[Builtin Tools] Available:', Array.from(builtinTools.keys()));
|