changes
This commit is contained in:
@@ -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',
|
||||
};
|
||||
}
|
||||
};
|
||||
@@ -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',
|
||||
};
|
||||
}
|
||||
};
|
||||
@@ -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',
|
||||
};
|
||||
}
|
||||
};
|
||||
@@ -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',
|
||||
};
|
||||
}
|
||||
};
|
||||
@@ -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',
|
||||
};
|
||||
}
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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>;
|
||||
@@ -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(/ /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<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',
|
||||
};
|
||||
}
|
||||
};
|
||||
@@ -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}¤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',
|
||||
};
|
||||
}
|
||||
};
|
||||
@@ -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',
|
||||
};
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user