This commit is contained in:
Zacharias-Brohn
2026-01-14 22:36:22 +01:00
parent d973e9f9c8
commit bcebaed78f
11 changed files with 1249 additions and 14 deletions
+88 -14
View File
@@ -1,9 +1,14 @@
import { NextRequest } from 'next/server'; import { NextRequest } from 'next/server';
import { Message } from 'ollama';
import ollama from '@/lib/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) { export async function POST(request: NextRequest) {
try { try {
const { model, messages } = await request.json(); const { model, messages, enableTools = true } = await request.json();
if (!model || !messages) { if (!model || !messages) {
return new Response(JSON.stringify({ error: 'Model and messages are required' }), { 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({ // Create a readable stream for the response
model,
messages,
stream: true,
});
// Create a readable stream from the Ollama response
const stream = new ReadableStream({ const stream = new ReadableStream({
async start(controller) { async start(controller) {
const encoder = new TextEncoder(); const encoder = new TextEncoder();
try { try {
for await (const chunk of response) { // Working copy of messages for tool call iterations
const text = chunk.message?.content || ''; const workingMessages: Message[] = [...messages];
if (text) { let iterations = 0;
controller.enqueue(encoder.encode(text));
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<string, unknown> }> = [];
// 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<string, unknown>,
}));
}
} }
// 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(); controller.close();
} catch (error) { } catch (error) {
controller.error(error); controller.error(error);
@@ -43,9 +116,10 @@ export async function POST(request: NextRequest) {
'Transfer-Encoding': 'chunked', 'Transfer-Encoding': 'chunked',
}, },
}); });
} catch (error: any) { } catch (error: unknown) {
console.error('Chat stream error:', error); 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, status: 500,
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
}); });
+82
View File
@@ -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<ToolResult> => {
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',
};
}
};
+126
View File
@@ -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<ToolResult> => {
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<never>((_, reject) => {
setTimeout(() => reject(new Error('Execution timed out')), timeoutMs);
});
const executionPromise = new Promise<unknown>((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',
};
}
};
+94
View File
@@ -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<ToolResult> => {
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',
};
}
};
+177
View File
@@ -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<ToolResult> => {
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<ToolResult> => {
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',
};
}
};
+134
View File
@@ -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<ToolResult> => {
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<string, string> = {
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',
};
}
};
+97
View File
@@ -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<string, ToolHandler> = 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<string, unknown>
): Promise<ToolResult> {
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,
};
+13
View File
@@ -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<string, unknown>) => Promise<ToolResult>;
+122
View File
@@ -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(/<script[^>]*>[\s\S]*?<\/script>/gi, '')
.replace(/<style[^>]*>[\s\S]*?<\/style>/gi, '')
// Remove HTML comments
.replace(/<!--[\s\S]*?-->/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(/&nbsp;/g, ' ')
.replace(/&amp;/g, '&')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
// Clean up whitespace
.replace(/[ \t]+/g, ' ')
.replace(/\n\s*\n/g, '\n\n')
.trim()
);
}
export const urlFetchHandler: ToolHandler = async (args): Promise<ToolResult> => {
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',
};
}
};
+189
View File
@@ -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<number, string> = {
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<ToolResult> => {
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}&current=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',
};
}
};
+127
View File
@@ -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<SearchResult[]> {
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 <div class="result"> elements
const resultRegex =
/<a[^>]+class="result__a"[^>]*href="([^"]+)"[^>]*>([^<]+)<\/a>[\s\S]*?<a[^>]+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 =
/<div class="result[^"]*"[\s\S]*?<a[^>]+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<ToolResult> => {
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',
};
}
};