diff --git a/app/api/chat/route.ts b/app/api/chat/route.ts index 58539be..6a68603 100644 --- a/app/api/chat/route.ts +++ b/app/api/chat/route.ts @@ -1,9 +1,14 @@ import { NextRequest } from 'next/server'; +import { Message } from 'ollama'; import ollama from '@/lib/ollama'; +import { allTools, executeTool } from '@/lib/tools'; + +// Maximum number of tool call iterations to prevent infinite loops +const MAX_TOOL_ITERATIONS = 10; export async function POST(request: NextRequest) { try { - const { model, messages } = await request.json(); + const { model, messages, enableTools = true } = await request.json(); if (!model || !messages) { return new Response(JSON.stringify({ error: 'Model and messages are required' }), { @@ -12,24 +17,92 @@ export async function POST(request: NextRequest) { }); } - const response = await ollama.chat({ - model, - messages, - stream: true, - }); - - // Create a readable stream from the Ollama response + // Create a readable stream for the response const stream = new ReadableStream({ async start(controller) { const encoder = new TextEncoder(); try { - for await (const chunk of response) { - const text = chunk.message?.content || ''; - if (text) { - controller.enqueue(encoder.encode(text)); + // Working copy of messages for tool call iterations + const workingMessages: Message[] = [...messages]; + let iterations = 0; + + while (iterations < MAX_TOOL_ITERATIONS) { + iterations++; + + // Call Ollama with tools if enabled + const response = await ollama.chat({ + model, + messages: workingMessages, + tools: enableTools ? allTools : undefined, + stream: true, + }); + + let fullContent = ''; + let toolCalls: Array<{ name: string; arguments: Record }> = []; + + // Process the streaming response + for await (const chunk of response) { + // Collect content + const text = chunk.message?.content || ''; + if (text) { + fullContent += text; + controller.enqueue(encoder.encode(text)); + } + + // Check for tool calls in the final chunk + if (chunk.message?.tool_calls) { + toolCalls = chunk.message.tool_calls.map((tc) => ({ + name: tc.function.name, + arguments: tc.function.arguments as Record, + })); + } } + + // If no tool calls, we're done + if (toolCalls.length === 0) { + break; + } + + // Process tool calls + // Send a marker so frontend knows tool calls are happening + controller.enqueue(encoder.encode('\n\n---TOOL_CALLS---\n')); + + // Add the assistant's response with tool calls to messages + workingMessages.push({ + role: 'assistant', + content: fullContent, + tool_calls: toolCalls.map((tc) => ({ + function: { + name: tc.name, + arguments: tc.arguments, + }, + })), + }); + + // Execute each tool and collect results + for (const toolCall of toolCalls) { + controller.enqueue(encoder.encode(`\n**Using tool: ${toolCall.name}**\n`)); + + const result = await executeTool(toolCall.name, toolCall.arguments); + + // Send tool result to stream + if (result.success) { + controller.enqueue(encoder.encode(`\`\`\`\n${result.result}\n\`\`\`\n`)); + } else { + controller.enqueue(encoder.encode(`Error: ${result.error}\n`)); + } + + // Add tool result to messages for next iteration + workingMessages.push({ + role: 'tool', + content: result.success ? result.result || '' : `Error: ${result.error}`, + }); + } + + controller.enqueue(encoder.encode('\n---END_TOOL_CALLS---\n\n')); } + controller.close(); } catch (error) { controller.error(error); @@ -43,9 +116,10 @@ export async function POST(request: NextRequest) { 'Transfer-Encoding': 'chunked', }, }); - } catch (error: any) { + } catch (error: unknown) { console.error('Chat stream error:', error); - return new Response(JSON.stringify({ error: error.message || 'Failed to stream response' }), { + const message = error instanceof Error ? error.message : 'Failed to stream response'; + return new Response(JSON.stringify({ error: message }), { status: 500, headers: { 'Content-Type': 'application/json' }, }); diff --git a/lib/tools/calculator.ts b/lib/tools/calculator.ts new file mode 100644 index 0000000..1f799bf --- /dev/null +++ b/lib/tools/calculator.ts @@ -0,0 +1,82 @@ +/** + * Calculator tool - evaluates mathematical expressions + */ + +import { Tool } from 'ollama'; +import { ToolHandler, ToolResult } from './types'; + +export const calculatorTool: Tool = { + type: 'function', + function: { + name: 'calculator', + description: + 'Evaluate a mathematical expression. Supports basic arithmetic (+, -, *, /), exponents (^), parentheses, and common math functions (sqrt, sin, cos, tan, log, abs, round, floor, ceil).', + parameters: { + type: 'object', + properties: { + expression: { + type: 'string', + description: + 'The mathematical expression to evaluate, e.g., "2 + 2", "sqrt(16)", "sin(3.14159/2)"', + }, + }, + required: ['expression'], + }, + }, +}; + +// Safe math evaluation using Function constructor with limited scope +function safeEvaluate(expression: string): number { + // Replace common math notation + let expr = expression + .replace(/\^/g, '**') // Exponents + .replace(/sqrt/g, 'Math.sqrt') + .replace(/sin/g, 'Math.sin') + .replace(/cos/g, 'Math.cos') + .replace(/tan/g, 'Math.tan') + .replace(/log/g, 'Math.log') + .replace(/abs/g, 'Math.abs') + .replace(/round/g, 'Math.round') + .replace(/floor/g, 'Math.floor') + .replace(/ceil/g, 'Math.ceil') + .replace(/pi/gi, 'Math.PI') + .replace(/e(?![a-z])/gi, 'Math.E'); + + // Validate: only allow safe characters + if (!/^[0-9+\-*/().%\s,Math.sqrtincoablgEPI]+$/.test(expr)) { + throw new Error('Invalid characters in expression'); + } + + // Evaluate using Function constructor (safer than eval) + const result = new Function(`"use strict"; return (${expr})`)(); + + if (typeof result !== 'number' || !isFinite(result)) { + throw new Error('Expression did not evaluate to a valid number'); + } + + return result; +} + +export const calculatorHandler: ToolHandler = async (args): Promise => { + const expression = args.expression as string; + + if (!expression) { + return { + success: false, + error: 'No expression provided', + }; + } + + try { + const result = safeEvaluate(expression); + return { + success: true, + result: `${expression} = ${result}`, + }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to evaluate expression', + }; + } +}; diff --git a/lib/tools/code-execution.ts b/lib/tools/code-execution.ts new file mode 100644 index 0000000..ca2cb07 --- /dev/null +++ b/lib/tools/code-execution.ts @@ -0,0 +1,126 @@ +/** + * Code Execution tool - execute JavaScript code in a sandboxed environment + */ + +import { Tool } from 'ollama'; +import { ToolHandler, ToolResult } from './types'; + +export const codeExecutionTool: Tool = { + type: 'function', + function: { + name: 'execute_code', + description: + 'Execute JavaScript code and return the result. The code runs in a sandboxed environment with limited capabilities. Console.log output is captured. The last expression value is returned.', + parameters: { + type: 'object', + properties: { + code: { + type: 'string', + description: 'JavaScript code to execute', + }, + timeout_ms: { + type: 'number', + description: 'Maximum execution time in milliseconds (default: 5000, max: 30000)', + }, + }, + required: ['code'], + }, + }, +}; + +export const codeExecutionHandler: ToolHandler = async (args): Promise => { + const code = args.code as string; + const timeoutMs = Math.min((args.timeout_ms as number) || 5000, 30000); + + if (!code) { + return { + success: false, + error: 'No code provided', + }; + } + + try { + // Capture console output + const logs: string[] = []; + const mockConsole = { + log: (...args: unknown[]) => logs.push(args.map(String).join(' ')), + error: (...args: unknown[]) => logs.push('[ERROR] ' + args.map(String).join(' ')), + warn: (...args: unknown[]) => logs.push('[WARN] ' + args.map(String).join(' ')), + info: (...args: unknown[]) => logs.push('[INFO] ' + args.map(String).join(' ')), + }; + + // Create a sandboxed context with limited globals + const sandbox = { + console: mockConsole, + Math, + Date, + JSON, + Array, + Object, + String, + Number, + Boolean, + RegExp, + Map, + Set, + Promise, + parseInt, + parseFloat, + isNaN, + isFinite, + encodeURIComponent, + decodeURIComponent, + encodeURI, + decodeURI, + }; + + // Wrap code to capture the last expression value + const wrappedCode = ` + "use strict"; + const { ${Object.keys(sandbox).join(', ')} } = this; + ${code} + `; + + // Execute with timeout + const executeWithTimeout = (): unknown => { + const fn = new Function(wrappedCode); + return fn.call(sandbox); + }; + + let result: unknown; + const timeoutPromise = new Promise((_, reject) => { + setTimeout(() => reject(new Error('Execution timed out')), timeoutMs); + }); + + const executionPromise = new Promise((resolve, reject) => { + try { + resolve(executeWithTimeout()); + } catch (e) { + reject(e); + } + }); + + result = await Promise.race([executionPromise, timeoutPromise]); + + // Format output + let output = ''; + if (logs.length > 0) { + output += 'Console output:\n' + logs.join('\n') + '\n\n'; + } + if (result !== undefined) { + output += 'Result: ' + JSON.stringify(result, null, 2); + } else if (logs.length === 0) { + output = 'Code executed successfully (no output)'; + } + + return { + success: true, + result: output.trim(), + }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Code execution failed', + }; + } +}; diff --git a/lib/tools/datetime.ts b/lib/tools/datetime.ts new file mode 100644 index 0000000..78d5d57 --- /dev/null +++ b/lib/tools/datetime.ts @@ -0,0 +1,94 @@ +/** + * Date/Time tool - get current date, time, and timezone information + */ + +import { Tool } from 'ollama'; +import { ToolHandler, ToolResult } from './types'; + +export const dateTimeTool: Tool = { + type: 'function', + function: { + name: 'get_current_datetime', + description: 'Get the current date and time. Can return in different formats and timezones.', + parameters: { + type: 'object', + properties: { + timezone: { + type: 'string', + description: + 'IANA timezone name (e.g., "America/New_York", "Europe/London", "Asia/Tokyo"). Defaults to server timezone if not specified.', + }, + format: { + type: 'string', + enum: ['iso', 'readable', 'date_only', 'time_only', 'unix'], + description: + 'Output format: "iso" (ISO 8601), "readable" (human readable), "date_only", "time_only", or "unix" (Unix timestamp). Defaults to "readable".', + }, + }, + required: [], + }, + }, +}; + +export const dateTimeHandler: ToolHandler = async (args): Promise => { + const timezone = (args.timezone as string) || Intl.DateTimeFormat().resolvedOptions().timeZone; + const format = (args.format as string) || 'readable'; + + try { + const now = new Date(); + + let result: string; + + switch (format) { + case 'iso': + result = now.toLocaleString('sv-SE', { timeZone: timezone }).replace(' ', 'T') + 'Z'; + break; + case 'date_only': + result = now.toLocaleDateString('en-US', { + timeZone: timezone, + weekday: 'long', + year: 'numeric', + month: 'long', + day: 'numeric', + }); + break; + case 'time_only': + result = now.toLocaleTimeString('en-US', { + timeZone: timezone, + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + hour12: true, + }); + break; + case 'unix': + result = Math.floor(now.getTime() / 1000).toString(); + break; + case 'readable': + default: + result = now.toLocaleString('en-US', { + timeZone: timezone, + weekday: 'long', + year: 'numeric', + month: 'long', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + hour12: true, + timeZoneName: 'short', + }); + break; + } + + return { + success: true, + result: `Current date/time (${timezone}): ${result}`, + }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to get date/time', + }; + } +}; diff --git a/lib/tools/file-operations.ts b/lib/tools/file-operations.ts new file mode 100644 index 0000000..dd44613 --- /dev/null +++ b/lib/tools/file-operations.ts @@ -0,0 +1,177 @@ +/** + * File Operations tools - read and write files with path restrictions + */ + +import { promises as fs } from 'fs'; +import path from 'path'; +import { Tool } from 'ollama'; +import { ToolHandler, ToolResult } from './types'; + +// Configurable allowed directories (can be set via environment variable) +const ALLOWED_DIRECTORIES = (process.env.TOOL_FILE_ALLOWED_PATHS || '/tmp,./workspace') + .split(',') + .map((p) => path.resolve(p.trim())); + +// Maximum file size for reading (5MB) +const MAX_READ_SIZE = 5 * 1024 * 1024; + +// Maximum file size for writing (1MB) +const MAX_WRITE_SIZE = 1 * 1024 * 1024; + +/** + * Check if a path is within allowed directories + */ +function isPathAllowed(filePath: string): boolean { + const resolved = path.resolve(filePath); + return ALLOWED_DIRECTORIES.some((allowed) => resolved.startsWith(allowed)); +} + +export const fileReadTool: Tool = { + type: 'function', + function: { + name: 'read_file', + description: `Read the contents of a file. For security, only files in these directories can be accessed: ${ALLOWED_DIRECTORIES.join(', ')}`, + parameters: { + type: 'object', + properties: { + path: { + type: 'string', + description: 'Path to the file to read', + }, + encoding: { + type: 'string', + enum: ['utf-8', 'base64'], + description: 'File encoding (default: utf-8). Use base64 for binary files.', + }, + }, + required: ['path'], + }, + }, +}; + +export const fileWriteTool: Tool = { + type: 'function', + function: { + name: 'write_file', + description: `Write content to a file. For security, only files in these directories can be written: ${ALLOWED_DIRECTORIES.join(', ')}`, + parameters: { + type: 'object', + properties: { + path: { + type: 'string', + description: 'Path to the file to write', + }, + content: { + type: 'string', + description: 'Content to write to the file', + }, + append: { + type: 'boolean', + description: 'If true, append to file instead of overwriting (default: false)', + }, + }, + required: ['path', 'content'], + }, + }, +}; + +export const fileReadHandler: ToolHandler = async (args): Promise => { + const filePath = args.path as string; + const encoding = (args.encoding as 'utf-8' | 'base64') || 'utf-8'; + + if (!filePath) { + return { + success: false, + error: 'No file path provided', + }; + } + + if (!isPathAllowed(filePath)) { + return { + success: false, + error: `Access denied. File must be in one of: ${ALLOWED_DIRECTORIES.join(', ')}`, + }; + } + + try { + const resolved = path.resolve(filePath); + + // Check file size + const stats = await fs.stat(resolved); + if (stats.size > MAX_READ_SIZE) { + return { + success: false, + error: `File too large (${stats.size} bytes). Maximum: ${MAX_READ_SIZE} bytes`, + }; + } + + const content = await fs.readFile(resolved, encoding === 'base64' ? 'base64' : 'utf-8'); + + return { + success: true, + result: `Contents of ${filePath}:\n\n${content}`, + }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to read file', + }; + } +}; + +export const fileWriteHandler: ToolHandler = async (args): Promise => { + const filePath = args.path as string; + const content = args.content as string; + const append = (args.append as boolean) || false; + + if (!filePath) { + return { + success: false, + error: 'No file path provided', + }; + } + + if (content === undefined || content === null) { + return { + success: false, + error: 'No content provided', + }; + } + + if (!isPathAllowed(filePath)) { + return { + success: false, + error: `Access denied. File must be in one of: ${ALLOWED_DIRECTORIES.join(', ')}`, + }; + } + + if (content.length > MAX_WRITE_SIZE) { + return { + success: false, + error: `Content too large (${content.length} bytes). Maximum: ${MAX_WRITE_SIZE} bytes`, + }; + } + + try { + const resolved = path.resolve(filePath); + + // Ensure directory exists + await fs.mkdir(path.dirname(resolved), { recursive: true }); + + if (append) { + await fs.appendFile(resolved, content, 'utf-8'); + } else { + await fs.writeFile(resolved, content, 'utf-8'); + } + + return { + success: true, + result: `Successfully ${append ? 'appended to' : 'wrote'} file: ${filePath}`, + }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to write file', + }; + } +}; diff --git a/lib/tools/image-generation.ts b/lib/tools/image-generation.ts new file mode 100644 index 0000000..1275f5e --- /dev/null +++ b/lib/tools/image-generation.ts @@ -0,0 +1,134 @@ +/** + * Image Generation tool - generate images using Stable Diffusion or similar + * Requires a compatible image generation API (e.g., Automatic1111, ComfyUI, or cloud service) + */ + +import { Tool } from 'ollama'; +import { ToolHandler, ToolResult } from './types'; + +// Image generation API endpoint (configurable via environment) +const IMAGE_API_URL = process.env.IMAGE_GENERATION_API_URL || ''; + +export const imageGenerationTool: Tool = { + type: 'function', + function: { + name: 'generate_image', + description: + 'Generate an image based on a text description. Creates images using AI image generation. ' + + (IMAGE_API_URL + ? 'Image generation is available.' + : 'NOTE: Image generation is not configured. Set IMAGE_GENERATION_API_URL environment variable.'), + parameters: { + type: 'object', + properties: { + prompt: { + type: 'string', + description: + 'Detailed description of the image to generate. Be specific about style, colors, composition, etc.', + }, + negative_prompt: { + type: 'string', + description: 'Things to avoid in the image (e.g., "blurry, low quality, distorted")', + }, + width: { + type: 'number', + description: 'Image width in pixels (default: 512, max: 1024)', + }, + height: { + type: 'number', + description: 'Image height in pixels (default: 512, max: 1024)', + }, + style: { + type: 'string', + enum: ['realistic', 'artistic', 'anime', 'digital-art', 'photographic'], + description: 'Art style for the image (default: artistic)', + }, + }, + required: ['prompt'], + }, + }, +}; + +export const imageGenerationHandler: ToolHandler = async (args): Promise => { + const prompt = args.prompt as string; + const negativePrompt = (args.negative_prompt as string) || 'blurry, low quality, distorted'; + const width = Math.min((args.width as number) || 512, 1024); + const height = Math.min((args.height as number) || 512, 1024); + const style = (args.style as string) || 'artistic'; + + if (!prompt) { + return { + success: false, + error: 'No prompt provided', + }; + } + + if (!IMAGE_API_URL) { + return { + success: false, + error: + 'Image generation is not configured. Please set the IMAGE_GENERATION_API_URL environment variable to point to a Stable Diffusion API (e.g., Automatic1111 or ComfyUI).', + }; + } + + try { + // Style-based prompt enhancement + const stylePrompts: Record = { + realistic: 'photorealistic, highly detailed, 8k, professional photography', + artistic: 'artistic, beautiful composition, masterpiece, best quality', + anime: 'anime style, vibrant colors, clean lines, studio quality', + 'digital-art': 'digital art, concept art, detailed illustration', + photographic: 'DSLR photo, natural lighting, sharp focus, high resolution', + }; + + const enhancedPrompt = `${prompt}, ${stylePrompts[style] || stylePrompts.artistic}`; + + // Make request to image generation API + // This is configured for Automatic1111's API format + const response = await fetch(`${IMAGE_API_URL}/sdapi/v1/txt2img`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + prompt: enhancedPrompt, + negative_prompt: negativePrompt, + width, + height, + steps: 20, + cfg_scale: 7, + sampler_name: 'Euler a', + }), + signal: AbortSignal.timeout(120000), // 2 minute timeout for generation + }); + + if (!response.ok) { + throw new Error(`Image API returned ${response.status}: ${response.statusText}`); + } + + const data = await response.json(); + + if (!data.images || data.images.length === 0) { + throw new Error('No image was generated'); + } + + // Return base64 image data + // The frontend will need to handle displaying this + return { + success: true, + result: JSON.stringify({ + type: 'image', + format: 'base64', + data: data.images[0], + prompt: enhancedPrompt, + width, + height, + }), + }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to generate image', + }; + } +}; diff --git a/lib/tools/index.ts b/lib/tools/index.ts new file mode 100644 index 0000000..71d0e0d --- /dev/null +++ b/lib/tools/index.ts @@ -0,0 +1,97 @@ +/** + * Tool definitions and registry for Ollama function calling + */ + +import { Tool } from 'ollama'; +// Import and register all tools +import { calculatorHandler, calculatorTool } from './calculator'; +import { codeExecutionHandler, codeExecutionTool } from './code-execution'; +import { dateTimeHandler, dateTimeTool } from './datetime'; +import { fileReadHandler, fileReadTool, fileWriteHandler, fileWriteTool } from './file-operations'; +import { imageGenerationHandler, imageGenerationTool } from './image-generation'; +import { ToolHandler, ToolResult } from './types'; +import { urlFetchHandler, urlFetchTool } from './url-fetch'; +import { weatherHandler, weatherTool } from './weather'; +import { webSearchHandler, webSearchTool } from './web-search'; + +// Re-export types +export type { ToolHandler, ToolResult } from './types'; + +// Registry of tool handlers +const toolHandlers: Map = new Map(); + +/** + * Register a tool handler + */ +export function registerTool(name: string, handler: ToolHandler): void { + toolHandlers.set(name, handler); +} + +/** + * Execute a tool by name with given arguments + */ +export async function executeTool( + name: string, + args: Record +): Promise { + const handler = toolHandlers.get(name); + if (!handler) { + return { + success: false, + error: `Unknown tool: ${name}`, + }; + } + + try { + return await handler(args); + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Unknown error executing tool', + }; + } +} + +/** + * Get all registered tool names + */ +export function getRegisteredTools(): string[] { + return Array.from(toolHandlers.keys()); +} + +// Register all tool handlers +registerTool('calculator', calculatorHandler); +registerTool('get_current_datetime', dateTimeHandler); +registerTool('fetch_url', urlFetchHandler); +registerTool('web_search', webSearchHandler); +registerTool('execute_code', codeExecutionHandler); +registerTool('read_file', fileReadHandler); +registerTool('write_file', fileWriteHandler); +registerTool('get_weather', weatherHandler); +registerTool('generate_image', imageGenerationHandler); + +// Export all tool definitions for Ollama +export const allTools: Tool[] = [ + calculatorTool, + dateTimeTool, + urlFetchTool, + webSearchTool, + codeExecutionTool, + fileReadTool, + fileWriteTool, + weatherTool, + imageGenerationTool, +]; + +// Export individual tools for selective use +export { + calculatorTool, + dateTimeTool, + urlFetchTool, + webSearchTool, + codeExecutionTool, + fileReadTool, + fileWriteTool, + weatherTool, + imageGenerationTool, +}; diff --git a/lib/tools/types.ts b/lib/tools/types.ts new file mode 100644 index 0000000..2764379 --- /dev/null +++ b/lib/tools/types.ts @@ -0,0 +1,13 @@ +/** + * Tool types and interfaces + */ + +// Tool execution result +export interface ToolResult { + success: boolean; + result?: string; + error?: string; +} + +// Tool handler function type +export type ToolHandler = (args: Record) => Promise; diff --git a/lib/tools/url-fetch.ts b/lib/tools/url-fetch.ts new file mode 100644 index 0000000..3f87452 --- /dev/null +++ b/lib/tools/url-fetch.ts @@ -0,0 +1,122 @@ +/** + * URL Fetch tool - fetch and extract content from web pages + */ + +import { Tool } from 'ollama'; +import { ToolHandler, ToolResult } from './types'; + +export const urlFetchTool: Tool = { + type: 'function', + function: { + name: 'fetch_url', + description: + 'Fetch content from a URL and extract the main text. Useful for reading articles, documentation, or any web page content.', + parameters: { + type: 'object', + properties: { + url: { + type: 'string', + description: 'The URL to fetch content from', + }, + max_length: { + type: 'number', + description: + 'Maximum number of characters to return (default: 5000). Longer content will be truncated.', + }, + }, + required: ['url'], + }, + }, +}; + +// Simple HTML to text conversion +function htmlToText(html: string): string { + return ( + html + // Remove scripts and styles + .replace(/]*>[\s\S]*?<\/script>/gi, '') + .replace(/]*>[\s\S]*?<\/style>/gi, '') + // Remove HTML comments + .replace(//g, '') + // Replace common block elements with newlines + .replace(/<\/(p|div|h[1-6]|li|tr|br)[^>]*>/gi, '\n') + .replace(/<(br|hr)[^>]*\/?>/gi, '\n') + // Remove remaining HTML tags + .replace(/<[^>]+>/g, ' ') + // Decode common HTML entities + .replace(/ /g, ' ') + .replace(/&/g, '&') + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/"/g, '"') + .replace(/'/g, "'") + // Clean up whitespace + .replace(/[ \t]+/g, ' ') + .replace(/\n\s*\n/g, '\n\n') + .trim() + ); +} + +export const urlFetchHandler: ToolHandler = async (args): Promise => { + const url = args.url as string; + const maxLength = (args.max_length as number) || 5000; + + if (!url) { + return { + success: false, + error: 'No URL provided', + }; + } + + // Validate URL + try { + new URL(url); + } catch { + return { + success: false, + error: 'Invalid URL format', + }; + } + + try { + const response = await fetch(url, { + headers: { + 'User-Agent': 'Mozilla/5.0 (compatible; ChatGPZ/1.0; +https://github.com/your-repo)', + Accept: 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8', + }, + signal: AbortSignal.timeout(10000), // 10 second timeout + }); + + if (!response.ok) { + return { + success: false, + error: `HTTP ${response.status}: ${response.statusText}`, + }; + } + + const contentType = response.headers.get('content-type') || ''; + const html = await response.text(); + + let text: string; + if (contentType.includes('text/plain')) { + text = html; + } else { + text = htmlToText(html); + } + + // Truncate if needed + if (text.length > maxLength) { + text = text.substring(0, maxLength) + '\n\n[Content truncated...]'; + } + + return { + success: true, + result: `Content from ${url}:\n\n${text}`, + }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to fetch URL', + }; + } +}; diff --git a/lib/tools/weather.ts b/lib/tools/weather.ts new file mode 100644 index 0000000..ade8e81 --- /dev/null +++ b/lib/tools/weather.ts @@ -0,0 +1,189 @@ +/** + * Weather tool - get current weather information + * Uses Open-Meteo API (free, no API key required) + */ + +import { Tool } from 'ollama'; +import { ToolHandler, ToolResult } from './types'; + +export const weatherTool: Tool = { + type: 'function', + function: { + name: 'get_weather', + description: + 'Get current weather information for a location. Provides temperature, conditions, humidity, wind speed, and more.', + parameters: { + type: 'object', + properties: { + location: { + type: 'string', + description: 'City name or location (e.g., "New York", "London, UK", "Tokyo, Japan")', + }, + units: { + type: 'string', + enum: ['metric', 'imperial'], + description: + 'Unit system: "metric" (Celsius, km/h) or "imperial" (Fahrenheit, mph). Default: metric', + }, + }, + required: ['location'], + }, + }, +}; + +interface GeocodingResult { + name: string; + latitude: number; + longitude: number; + country: string; + admin1?: string; +} + +interface WeatherData { + temperature: number; + apparent_temperature: number; + humidity: number; + wind_speed: number; + wind_direction: number; + weather_code: number; + is_day: boolean; +} + +// Weather code to description mapping +const weatherCodes: Record = { + 0: 'Clear sky', + 1: 'Mainly clear', + 2: 'Partly cloudy', + 3: 'Overcast', + 45: 'Foggy', + 48: 'Depositing rime fog', + 51: 'Light drizzle', + 53: 'Moderate drizzle', + 55: 'Dense drizzle', + 56: 'Light freezing drizzle', + 57: 'Dense freezing drizzle', + 61: 'Slight rain', + 63: 'Moderate rain', + 65: 'Heavy rain', + 66: 'Light freezing rain', + 67: 'Heavy freezing rain', + 71: 'Slight snow', + 73: 'Moderate snow', + 75: 'Heavy snow', + 77: 'Snow grains', + 80: 'Slight rain showers', + 81: 'Moderate rain showers', + 82: 'Violent rain showers', + 85: 'Slight snow showers', + 86: 'Heavy snow showers', + 95: 'Thunderstorm', + 96: 'Thunderstorm with slight hail', + 99: 'Thunderstorm with heavy hail', +}; + +function getWindDirection(degrees: number): string { + const directions = [ + 'N', + 'NNE', + 'NE', + 'ENE', + 'E', + 'ESE', + 'SE', + 'SSE', + 'S', + 'SSW', + 'SW', + 'WSW', + 'W', + 'WNW', + 'NW', + 'NNW', + ]; + const index = Math.round(degrees / 22.5) % 16; + return directions[index]; +} + +export const weatherHandler: ToolHandler = async (args): Promise => { + const location = args.location as string; + const units = (args.units as 'metric' | 'imperial') || 'metric'; + + if (!location) { + return { + success: false, + error: 'No location provided', + }; + } + + try { + // First, geocode the location + const geoUrl = `https://geocoding-api.open-meteo.com/v1/search?name=${encodeURIComponent(location)}&count=1&language=en&format=json`; + const geoResponse = await fetch(geoUrl, { signal: AbortSignal.timeout(10000) }); + + if (!geoResponse.ok) { + throw new Error('Failed to geocode location'); + } + + const geoData = await geoResponse.json(); + if (!geoData.results || geoData.results.length === 0) { + return { + success: false, + error: `Location not found: "${location}"`, + }; + } + + const geo: GeocodingResult = geoData.results[0]; + + // Fetch weather data + const tempUnit = units === 'imperial' ? 'fahrenheit' : 'celsius'; + const windUnit = units === 'imperial' ? 'mph' : 'kmh'; + + const weatherUrl = `https://api.open-meteo.com/v1/forecast?latitude=${geo.latitude}&longitude=${geo.longitude}¤t=temperature_2m,relative_humidity_2m,apparent_temperature,is_day,weather_code,wind_speed_10m,wind_direction_10m&temperature_unit=${tempUnit}&wind_speed_unit=${windUnit}`; + + const weatherResponse = await fetch(weatherUrl, { signal: AbortSignal.timeout(10000) }); + + if (!weatherResponse.ok) { + throw new Error('Failed to fetch weather data'); + } + + const weatherData = await weatherResponse.json(); + const current = weatherData.current; + + const weather: WeatherData = { + temperature: current.temperature_2m, + apparent_temperature: current.apparent_temperature, + humidity: current.relative_humidity_2m, + wind_speed: current.wind_speed_10m, + wind_direction: current.wind_direction_10m, + weather_code: current.weather_code, + is_day: current.is_day === 1, + }; + + const tempSymbol = units === 'imperial' ? '°F' : '°C'; + const speedSymbol = units === 'imperial' ? 'mph' : 'km/h'; + const condition = weatherCodes[weather.weather_code] || 'Unknown'; + const windDir = getWindDirection(weather.wind_direction); + + const locationName = geo.admin1 + ? `${geo.name}, ${geo.admin1}, ${geo.country}` + : `${geo.name}, ${geo.country}`; + + const result = `Weather for ${locationName}: + +Conditions: ${condition} (${weather.is_day ? 'Day' : 'Night'}) +Temperature: ${weather.temperature}${tempSymbol} +Feels like: ${weather.apparent_temperature}${tempSymbol} +Humidity: ${weather.humidity}% +Wind: ${weather.wind_speed} ${speedSymbol} from ${windDir}`; + + return { + success: true, + result, + }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to get weather', + }; + } +}; diff --git a/lib/tools/web-search.ts b/lib/tools/web-search.ts new file mode 100644 index 0000000..a7c6b96 --- /dev/null +++ b/lib/tools/web-search.ts @@ -0,0 +1,127 @@ +/** + * Web Search tool - search the internet using DuckDuckGo or SearXNG + */ + +import { Tool } from 'ollama'; +import { ToolHandler, ToolResult } from './types'; + +export const webSearchTool: Tool = { + type: 'function', + function: { + name: 'web_search', + description: + 'Search the internet for current information. Returns a list of relevant search results with titles, URLs, and snippets.', + parameters: { + type: 'object', + properties: { + query: { + type: 'string', + description: 'The search query', + }, + num_results: { + type: 'number', + description: 'Number of results to return (default: 5, max: 10)', + }, + }, + required: ['query'], + }, + }, +}; + +interface SearchResult { + title: string; + url: string; + snippet: string; +} + +// DuckDuckGo HTML search (no API key needed) +async function searchDuckDuckGo(query: string, numResults: number): Promise { + const url = `https://html.duckduckgo.com/html/?q=${encodeURIComponent(query)}`; + + const response = await fetch(url, { + headers: { + 'User-Agent': + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36', + }, + signal: AbortSignal.timeout(10000), + }); + + if (!response.ok) { + throw new Error(`Search failed: HTTP ${response.status}`); + } + + const html = await response.text(); + const results: SearchResult[] = []; + + // Parse results from DDG HTML + // Results are in
elements + const resultRegex = + /]+class="result__a"[^>]*href="([^"]+)"[^>]*>([^<]+)<\/a>[\s\S]*?]+class="result__snippet"[^>]*>([^<]*(?:<[^>]+>[^<]*)*)<\/a>/gi; + + let match; + while ((match = resultRegex.exec(html)) !== null && results.length < numResults) { + const url = match[1]; + const title = match[2].trim(); + const snippet = match[3].replace(/<[^>]+>/g, '').trim(); + + if (url && title && !url.startsWith('/')) { + results.push({ title, url, snippet }); + } + } + + // Fallback: try alternative parsing if no results + if (results.length === 0) { + const altRegex = + /
]+href="([^"]+)"[^>]*>[\s\S]*?<\/a>[\s\S]*?class="result__title"[^>]*>([^<]+)/gi; + + while ((match = altRegex.exec(html)) !== null && results.length < numResults) { + const url = match[1]; + const title = match[2].trim(); + + if (url && title && !url.startsWith('/')) { + results.push({ title, url, snippet: '' }); + } + } + } + + return results; +} + +export const webSearchHandler: ToolHandler = async (args): Promise => { + const query = args.query as string; + const numResults = Math.min((args.num_results as number) || 5, 10); + + if (!query) { + return { + success: false, + error: 'No search query provided', + }; + } + + try { + const results = await searchDuckDuckGo(query, numResults); + + if (results.length === 0) { + return { + success: true, + result: `No search results found for: "${query}"`, + }; + } + + const formatted = results + .map( + (r, i) => `${i + 1}. ${r.title}\n URL: ${r.url}${r.snippet ? `\n ${r.snippet}` : ''}` + ) + .join('\n\n'); + + return { + success: true, + result: `Search results for "${query}":\n\n${formatted}`, + }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Search failed', + }; + } +};