changes
This commit is contained in:
+88
-14
@@ -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' },
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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