This commit is contained in:
Zacharias-Brohn
2026-01-14 22:51:46 +01:00
parent e222977f5c
commit c51b3c3fab
14 changed files with 452 additions and 216 deletions
+19 -14
View File
@@ -46,11 +46,12 @@ const tools: Tool[] = [
// --- Tool Implementations --- // --- Tool Implementations ---
const availableTools: Record<string, Function> = { const availableTools: Record<string, (args: Record<string, unknown>) => string> = {
get_current_time: () => { get_current_time: () => {
return new Date().toLocaleTimeString(); return new Date().toLocaleTimeString();
}, },
calculate: ({ expression }: { expression: string }) => { calculate: (args) => {
const expression = args.expression as string;
try { try {
// Safety: simplistic eval for demo purposes. // Safety: simplistic eval for demo purposes.
// In production, use a math parser library like mathjs. // In production, use a math parser library like mathjs.
@@ -68,17 +69,19 @@ export async function chat(model: string, messages: ChatMessage[]) {
let response; let response;
try { try {
response = await ollama.chat({ response = await ollama.chat({
model: model, model,
messages: messages, messages,
tools: tools, tools,
}); });
} catch (e: any) { } catch (e: unknown) {
// Fallback: If model doesn't support tools, retry without them // Fallback: If model doesn't support tools, retry without them
if (e.message?.includes('does not support tools')) { const errorMessage = e instanceof Error ? e.message : '';
if (errorMessage.includes('does not support tools')) {
// eslint-disable-next-line no-console
console.warn(`Model ${model} does not support tools. Falling back to standard chat.`); console.warn(`Model ${model} does not support tools. Falling back to standard chat.`);
response = await ollama.chat({ response = await ollama.chat({
model: model, model,
messages: messages, messages,
}); });
} else { } else {
throw e; throw e;
@@ -101,6 +104,7 @@ export async function chat(model: string, messages: ChatMessage[]) {
const functionToCall = availableTools[functionName]; const functionToCall = availableTools[functionName];
if (functionToCall) { if (functionToCall) {
// eslint-disable-next-line no-console
console.log(`🤖 Tool Call: ${functionName}`, tool.function.arguments); console.log(`🤖 Tool Call: ${functionName}`, tool.function.arguments);
const functionArgs = tool.function.arguments; const functionArgs = tool.function.arguments;
const functionResponse = functionToCall(functionArgs); const functionResponse = functionToCall(functionArgs);
@@ -115,9 +119,9 @@ export async function chat(model: string, messages: ChatMessage[]) {
// 3. Send the tool results back to the model to get the final answer // 3. Send the tool results back to the model to get the final answer
response = await ollama.chat({ response = await ollama.chat({
model: model, model,
messages: messages, messages,
tools: tools, tools,
}); });
} }
@@ -125,11 +129,12 @@ export async function chat(model: string, messages: ChatMessage[]) {
success: true, success: true,
message: response.message, message: response.message,
}; };
} catch (error: any) { } catch (error: unknown) {
// eslint-disable-next-line no-console
console.error('Chat error:', error); console.error('Chat error:', error);
return { return {
success: false, success: false,
error: error.message || 'Failed to generate response', error: error instanceof Error ? error.message : 'Failed to generate response',
}; };
} }
} }
+9 -10
View File
@@ -144,21 +144,20 @@ export async function POST(request: NextRequest) {
// Execute each tool and collect results // Execute each tool and collect results
for (const toolCall of toolCalls) { for (const toolCall of toolCalls) {
controller.enqueue(encoder.encode(`**Using tool: ${toolCall.name}**\n`)); // Use structured markers for frontend parsing
const toolOutput = await executeTool(toolCall.name, toolCall.arguments);
const toolResult = toolOutput.success
? toolOutput.result || ''
: `Error: ${toolOutput.error}`;
const result = await executeTool(toolCall.name, toolCall.arguments); // Format: <!--TOOL_START:name:args-->result<!--TOOL_END-->
const toolMarker = `<!--TOOL_START:${toolCall.name}:${JSON.stringify(toolCall.arguments)}-->${toolResult}<!--TOOL_END-->\n\n`;
// Send tool result to stream controller.enqueue(encoder.encode(toolMarker));
if (result.success) {
controller.enqueue(encoder.encode(`\`\`\`\n${result.result}\n\`\`\`\n\n`));
} else {
controller.enqueue(encoder.encode(`Error: ${result.error}\n\n`));
}
// Add tool result to messages for next iteration // Add tool result to messages for next iteration
workingMessages.push({ workingMessages.push({
role: 'tool', role: 'tool',
content: result.success ? result.result || '' : `Error: ${result.error}`, content: toolResult,
}); });
} }
+1
View File
@@ -528,6 +528,7 @@ export default function ChatLayout() {
}; };
const handleRemoveChat = async (chatId: string) => { const handleRemoveChat = async (chatId: string) => {
// eslint-disable-next-line no-alert
if (!window.confirm('Are you sure you want to delete this chat?')) { if (!window.confirm('Are you sure you want to delete this chat?')) {
return; return;
} }
+4 -4
View File
@@ -1,5 +1,4 @@
.markdown { .markdown {
word-wrap: break-word;
overflow-wrap: break-word; overflow-wrap: break-word;
} }
@@ -14,19 +13,20 @@
.codeBlock { .codeBlock {
display: block; display: block;
white-space: pre-wrap; white-space: pre-wrap;
word-break: break-word; word-break: break-all;
max-width: 100%; max-width: 100%;
overflow-x: auto; overflow-x: auto;
} }
.fadeIn { .fadeIn {
animation: textFadeIn 0.3s ease-out forwards; animation: text-fade-in 0.3s ease-out forwards;
} }
@keyframes textFadeIn { @keyframes text-fade-in {
from { from {
opacity: 0; opacity: 0;
} }
to { to {
opacity: 1; opacity: 1;
} }
+257 -154
View File
@@ -18,6 +18,7 @@ import {
Text, Text,
Title, Title,
} from '@mantine/core'; } from '@mantine/core';
import { ToolCallDisplay } from './ToolCallDisplay';
import classes from './MarkdownMessage.module.css'; import classes from './MarkdownMessage.module.css';
// Import KaTeX CSS for LaTeX rendering // Import KaTeX CSS for LaTeX rendering
import 'katex/dist/katex.min.css'; import 'katex/dist/katex.min.css';
@@ -27,6 +28,71 @@ interface MarkdownMessageProps {
isStreaming?: boolean; isStreaming?: boolean;
} }
interface ParsedToolCall {
toolName: string;
args: Record<string, unknown>;
result: string;
}
interface ContentSegment {
type: 'text' | 'tool';
content?: string;
toolCall?: ParsedToolCall;
}
/**
* Parse content to extract tool calls and regular text segments
*/
function parseContentWithToolCalls(content: string): ContentSegment[] {
const segments: ContentSegment[] = [];
const toolPattern = /<!--TOOL_START:(\w+):(\{.*?\})-->([\s\S]*?)<!--TOOL_END-->/g;
let lastIndex = 0;
let match;
while ((match = toolPattern.exec(content)) !== null) {
// Add text before this tool call
if (match.index > lastIndex) {
const textBefore = content.slice(lastIndex, match.index).trim();
if (textBefore) {
segments.push({ type: 'text', content: textBefore });
}
}
// Parse the tool call
const toolName = match[1];
let args: Record<string, unknown> = {};
try {
args = JSON.parse(match[2]);
} catch {
// Invalid JSON, use empty args
}
const result = match[3].trim();
segments.push({
type: 'tool',
toolCall: { toolName, args, result },
});
lastIndex = match.index + match[0].length;
}
// Add remaining text after last tool call
if (lastIndex < content.length) {
const remainingText = content.slice(lastIndex).trim();
if (remainingText) {
segments.push({ type: 'text', content: remainingText });
}
}
// If no tool calls found, return the whole content as text
if (segments.length === 0 && content.trim()) {
segments.push({ type: 'text', content });
}
return segments;
}
/** /**
* Hook that tracks content changes and returns content with fade-in markers for new text * Hook that tracks content changes and returns content with fade-in markers for new text
*/ */
@@ -90,165 +156,202 @@ function escapeHtml(text: string): string {
.replace(/'/g, '&#039;'); .replace(/'/g, '&#039;');
} }
/**
* Strip tool call markers from content for streaming display
*/
function stripToolMarkers(content: string): string {
return content.replace(/<!--TOOL_START:\w+:\{.*?\}-->/g, '').replace(/<!--TOOL_END-->/g, '');
}
function MarkdownContent({ content }: { content: string }) {
return (
<ReactMarkdown
remarkPlugins={[remarkGfm, remarkMath]}
rehypePlugins={[rehypeRaw, rehypeKatex]}
components={{
// Headings
h1: ({ children }) => (
<Title order={2} mb="xs">
{children}
</Title>
),
h2: ({ children }) => (
<Title order={3} mb="xs">
{children}
</Title>
),
h3: ({ children }) => (
<Title order={4} mb="xs">
{children}
</Title>
),
h4: ({ children }) => (
<Title order={5} mb="xs">
{children}
</Title>
),
h5: ({ children }) => (
<Title order={6} mb="xs">
{children}
</Title>
),
h6: ({ children }) => (
<Text size="sm" fw={700} mb="xs">
{children}
</Text>
),
// Paragraphs and text
p: ({ children }) => (
<Text size="sm" style={{ lineHeight: 1.6, marginBottom: '0.5em' }}>
{children}
</Text>
),
strong: ({ children }) => (
<Text component="strong" fw={700} inherit>
{children}
</Text>
),
em: ({ children }) => (
<Text component="em" fs="italic" inherit>
{children}
</Text>
),
del: ({ children }) => (
<Text component="del" td="line-through" inherit>
{children}
</Text>
),
// Code
code: ({ className, children }) => {
const isInline = !className;
if (isInline) {
return <Code>{children}</Code>;
}
return (
<Code block className={classes.codeBlock}>
{children}
</Code>
);
},
pre: ({ children }) => <div className={classes.preWrapper}>{children}</div>,
// Lists
ul: ({ children, className }) => {
// Check if this is a task list (GFM)
if (className?.includes('contains-task-list')) {
return <ul className={classes.taskList}>{children}</ul>;
}
return (
<List size="sm" mb="xs">
{children}
</List>
);
},
ol: ({ children }) => (
<List type="ordered" size="sm" mb="xs">
{children}
</List>
),
li: ({ children, className }) => {
// Task list items have a checkbox
if (className?.includes('task-list-item')) {
return <li className={classes.taskListItem}>{children}</li>;
}
return <List.Item>{children}</List.Item>;
},
input: ({ checked, type }) => {
if (type === 'checkbox') {
return <Checkbox checked={checked} readOnly size="xs" mr="xs" />;
}
return null;
},
// Links and media
a: ({ href, children }) => (
<Anchor href={href} target="_blank" rel="noopener noreferrer">
{children}
</Anchor>
),
img: ({ src, alt }) => {
const imgSrc = typeof src === 'string' ? src : '';
return (
<span className={classes.imageWrapper}>
<Image
src={imgSrc}
alt={alt || ''}
width={600}
height={400}
style={{ maxWidth: '100%', height: 'auto' }}
unoptimized // Allow external images
/>
</span>
);
},
// Block elements
blockquote: ({ children }) => <Blockquote mb="xs">{children}</Blockquote>,
hr: () => <Divider my="md" />,
// Tables (GFM)
table: ({ children }) => (
<Table
striped
highlightOnHover
withTableBorder
withColumnBorders
mb="md"
className={classes.table}
>
{children}
</Table>
),
thead: ({ children }) => <Table.Thead>{children}</Table.Thead>,
tbody: ({ children }) => <Table.Tbody>{children}</Table.Tbody>,
tr: ({ children }) => <Table.Tr>{children}</Table.Tr>,
th: ({ children }) => <Table.Th>{children}</Table.Th>,
td: ({ children }) => <Table.Td>{children}</Table.Td>,
// Allow the fade-in spans to pass through
span: ({ className, children }) => <span className={className}>{children}</span>,
}}
>
{content}
</ReactMarkdown>
);
}
export function MarkdownMessage({ content, isStreaming = false }: MarkdownMessageProps) { export function MarkdownMessage({ content, isStreaming = false }: MarkdownMessageProps) {
const processedContent = useStreamingContent(content, isStreaming); const processedContent = useStreamingContent(content, isStreaming);
// During streaming, just show the raw content (with tool markers stripped)
if (isStreaming) {
return (
<div className={classes.markdown}>
<MarkdownContent content={stripToolMarkers(processedContent)} />
</div>
);
}
// When not streaming, parse and render tool calls
const segments = parseContentWithToolCalls(content);
return ( return (
<div className={classes.markdown}> <div className={classes.markdown}>
<ReactMarkdown {segments.map((segment, index) => {
remarkPlugins={[remarkGfm, remarkMath]} if (segment.type === 'tool' && segment.toolCall) {
rehypePlugins={[rehypeRaw, rehypeKatex]} return (
components={{ <ToolCallDisplay
// Headings key={`tool-${index}`}
h1: ({ children }) => ( toolName={segment.toolCall.toolName}
<Title order={2} mb="xs"> args={segment.toolCall.args}
{children} result={segment.toolCall.result}
</Title> />
), );
h2: ({ children }) => ( }
<Title order={3} mb="xs"> return <MarkdownContent key={`text-${index}`} content={segment.content || ''} />;
{children} })}
</Title>
),
h3: ({ children }) => (
<Title order={4} mb="xs">
{children}
</Title>
),
h4: ({ children }) => (
<Title order={5} mb="xs">
{children}
</Title>
),
h5: ({ children }) => (
<Title order={6} mb="xs">
{children}
</Title>
),
h6: ({ children }) => (
<Text size="sm" fw={700} mb="xs">
{children}
</Text>
),
// Paragraphs and text
p: ({ children }) => (
<Text size="sm" style={{ lineHeight: 1.6, marginBottom: '0.5em' }}>
{children}
</Text>
),
strong: ({ children }) => (
<Text component="strong" fw={700} inherit>
{children}
</Text>
),
em: ({ children }) => (
<Text component="em" fs="italic" inherit>
{children}
</Text>
),
del: ({ children }) => (
<Text component="del" td="line-through" inherit>
{children}
</Text>
),
// Code
code: ({ className, children }) => {
const isInline = !className;
if (isInline) {
return <Code>{children}</Code>;
}
return (
<Code block className={classes.codeBlock}>
{children}
</Code>
);
},
pre: ({ children }) => <div className={classes.preWrapper}>{children}</div>,
// Lists
ul: ({ children, className }) => {
// Check if this is a task list (GFM)
if (className?.includes('contains-task-list')) {
return <ul className={classes.taskList}>{children}</ul>;
}
return (
<List size="sm" mb="xs">
{children}
</List>
);
},
ol: ({ children }) => (
<List type="ordered" size="sm" mb="xs">
{children}
</List>
),
li: ({ children, className }) => {
// Task list items have a checkbox
if (className?.includes('task-list-item')) {
return <li className={classes.taskListItem}>{children}</li>;
}
return <List.Item>{children}</List.Item>;
},
input: ({ checked, type }) => {
if (type === 'checkbox') {
return <Checkbox checked={checked} readOnly size="xs" mr="xs" />;
}
return null;
},
// Links and media
a: ({ href, children }) => (
<Anchor href={href} target="_blank" rel="noopener noreferrer">
{children}
</Anchor>
),
img: ({ src, alt }) => {
const imgSrc = typeof src === 'string' ? src : '';
return (
<span className={classes.imageWrapper}>
<Image
src={imgSrc}
alt={alt || ''}
width={600}
height={400}
style={{ maxWidth: '100%', height: 'auto' }}
unoptimized // Allow external images
/>
</span>
);
},
// Block elements
blockquote: ({ children }) => <Blockquote mb="xs">{children}</Blockquote>,
hr: () => <Divider my="md" />,
// Tables (GFM)
table: ({ children }) => (
<Table
striped
highlightOnHover
withTableBorder
withColumnBorders
mb="md"
className={classes.table}
>
{children}
</Table>
),
thead: ({ children }) => <Table.Thead>{children}</Table.Thead>,
tbody: ({ children }) => <Table.Tbody>{children}</Table.Tbody>,
tr: ({ children }) => <Table.Tr>{children}</Table.Tr>,
th: ({ children }) => <Table.Th>{children}</Table.Th>,
td: ({ children }) => <Table.Td>{children}</Table.Td>,
// Allow the fade-in spans to pass through
span: ({ className, children }) => <span className={className}>{children}</span>,
}}
>
{processedContent}
</ReactMarkdown>
</div> </div>
); );
} }
@@ -0,0 +1,29 @@
.container {
background-color: var(--mantine-color-default-hover);
overflow: hidden;
}
.header {
transition: background-color 0.15s ease;
}
.header:hover {
background-color: var(--mantine-color-default-hover);
}
.content {
padding: 0 var(--mantine-spacing-xs) var(--mantine-spacing-xs);
border-top: 1px solid var(--mantine-color-default-border);
}
.section {
margin-top: var(--mantine-spacing-xs);
}
.code {
font-size: var(--mantine-font-size-xs);
white-space: pre-wrap;
word-break: break-all;
max-height: 200px;
overflow-y: auto;
}
+89
View File
@@ -0,0 +1,89 @@
'use client';
import { useState } from 'react';
import { IconChevronDown, IconChevronRight, IconTool } from '@tabler/icons-react';
import { ActionIcon, Code, Collapse, Group, Paper, Text, useMantineTheme } from '@mantine/core';
import { useThemeContext } from '@/components/DynamicThemeProvider';
import classes from './ToolCallDisplay.module.css';
interface ToolCallDisplayProps {
toolName: string;
args: Record<string, unknown>;
result: string;
}
// Friendly tool names
const toolDisplayNames: Record<string, string> = {
calculator: 'Calculator',
get_current_datetime: 'Date/Time',
fetch_url: 'Fetch URL',
web_search: 'Web Search',
execute_code: 'Code Execution',
read_file: 'Read File',
write_file: 'Write File',
get_weather: 'Weather',
generate_image: 'Image Generation',
};
export function ToolCallDisplay({ toolName, args, result }: ToolCallDisplayProps) {
const [opened, setOpened] = useState(false);
const { primaryColor } = useThemeContext();
const theme = useMantineTheme();
const displayName = toolDisplayNames[toolName] || toolName;
const isError = result.startsWith('Error:');
// Format args for display
const argsDisplay = Object.entries(args)
.map(([key, value]) => `${key}: ${JSON.stringify(value)}`)
.join(', ');
return (
<Paper className={classes.container} withBorder radius="sm" p={0} my="xs">
<Group
className={classes.header}
onClick={() => setOpened(!opened)}
gap="xs"
wrap="nowrap"
p="xs"
style={{ cursor: 'pointer' }}
>
<ActionIcon variant="subtle" color={primaryColor} size="xs">
{opened ? <IconChevronDown size={14} /> : <IconChevronRight size={14} />}
</ActionIcon>
<IconTool size={16} color={theme.colors[primaryColor][6]} />
<Text size="sm" fw={500} c={primaryColor}>
{displayName}
</Text>
{!opened && argsDisplay && (
<Text size="xs" c="dimmed" truncate style={{ flex: 1 }}>
{argsDisplay}
</Text>
)}
</Group>
<Collapse in={opened}>
<div className={classes.content}>
{Object.keys(args).length > 0 && (
<div className={classes.section}>
<Text size="xs" c="dimmed" mb={4}>
Arguments:
</Text>
<Code block className={classes.code}>
{JSON.stringify(args, null, 2)}
</Code>
</div>
)}
<div className={classes.section}>
<Text size="xs" c="dimmed" mb={4}>
Result:
</Text>
<Code block className={classes.code} c={isError ? 'red' : undefined}>
{result}
</Code>
</div>
</div>
</Collapse>
</Paper>
);
}
+30 -19
View File
@@ -35,19 +35,22 @@ import {
} from '@mantine/core'; } from '@mantine/core';
import { deleteModel, getInstalledModels, pullModel, type OllamaModel } from '@/app/actions/ollama'; import { deleteModel, getInstalledModels, pullModel, type OllamaModel } from '@/app/actions/ollama';
const POPULAR_MODELS = [ /*
'llama3.2', * Popular models list - reserved for future autocomplete feature
'llama3.1', * const POPULAR_MODELS = [
'mistral', * 'llama3.2',
'gemma2', * 'llama3.1',
'qwen2.5', * 'mistral',
'phi3.5', * 'gemma2',
'neural-chat', * 'qwen2.5',
'starling-lm', * 'phi3.5',
'codellama', * 'neural-chat',
'deepseek-coder', * 'starling-lm',
'llava', * 'codellama',
]; * 'deepseek-coder',
* 'llava',
* ];
*/
interface User { interface User {
id: string; id: string;
@@ -98,8 +101,6 @@ export function SettingsModal({
}, },
}); });
const [value, setValue] = useState<string | null>(null);
// Filter installed models based on search // Filter installed models based on search
const options = models const options = models
.filter((item) => item.name.toLowerCase().includes(search.toLowerCase().trim())) .filter((item) => item.name.toLowerCase().includes(search.toLowerCase().trim()))
@@ -129,6 +130,7 @@ export function SettingsModal({
setUser(null); setUser(null);
} }
} catch (e) { } catch (e) {
// eslint-disable-next-line no-console
console.error(e); console.error(e);
} }
}; };
@@ -139,6 +141,7 @@ export function SettingsModal({
const list = await getInstalledModels(); const list = await getInstalledModels();
setModels(list); setModels(list);
} catch (e) { } catch (e) {
// eslint-disable-next-line no-console
console.error(e); console.error(e);
} finally { } finally {
setLoadingModels(false); setLoadingModels(false);
@@ -146,7 +149,9 @@ export function SettingsModal({
}; };
const handlePullModel = async () => { const handlePullModel = async () => {
if (!newModelName) return; if (!newModelName) {
return;
}
setPullingModel(newModelName); setPullingModel(newModelName);
try { try {
const result = await pullModel(newModelName); const result = await pullModel(newModelName);
@@ -157,6 +162,7 @@ export function SettingsModal({
setError(result.message); setError(result.message);
} }
} catch (e) { } catch (e) {
// eslint-disable-next-line no-console
console.error(e); console.error(e);
} finally { } finally {
setPullingModel(null); setPullingModel(null);
@@ -164,11 +170,15 @@ export function SettingsModal({
}; };
const handleDeleteModel = async (name: string) => { const handleDeleteModel = async (name: string) => {
if (!confirm(`Are you sure you want to delete ${name}?`)) return; // eslint-disable-next-line no-alert
if (!confirm(`Are you sure you want to delete ${name}?`)) {
return;
}
try { try {
await deleteModel(name); await deleteModel(name);
await fetchModels(); await fetchModels();
} catch (e) { } catch (e) {
// eslint-disable-next-line no-console
console.error(e); console.error(e);
} }
}; };
@@ -195,8 +205,8 @@ export function SettingsModal({
await fetchUser(); await fetchUser();
setUsername(''); setUsername('');
setPassword(''); setPassword('');
} catch (err: any) { } catch (err: unknown) {
setError(err.message); setError(err instanceof Error ? err.message : 'An error occurred');
} finally { } finally {
setLoading(false); setLoading(false);
} }
@@ -220,6 +230,7 @@ export function SettingsModal({
body: JSON.stringify({ accentColor: color }), body: JSON.stringify({ accentColor: color }),
}); });
} catch (e) { } catch (e) {
// eslint-disable-next-line no-console
console.error('Failed to save accent color:', e); console.error('Failed to save accent color:', e);
} }
} }
+1 -1
View File
@@ -28,7 +28,7 @@ export const calculatorTool: Tool = {
// Safe math evaluation using Function constructor with limited scope // Safe math evaluation using Function constructor with limited scope
function safeEvaluate(expression: string): number { function safeEvaluate(expression: string): number {
// Replace common math notation // Replace common math notation
let expr = expression const expr = expression
.replace(/\^/g, '**') // Exponents .replace(/\^/g, '**') // Exponents
.replace(/sqrt/g, 'Math.sqrt') .replace(/sqrt/g, 'Math.sqrt')
.replace(/sin/g, 'Math.sin') .replace(/sin/g, 'Math.sin')
+6 -7
View File
@@ -44,9 +44,9 @@ export const codeExecutionHandler: ToolHandler = async (args): Promise<ToolResul
const logs: string[] = []; const logs: string[] = [];
const mockConsole = { const mockConsole = {
log: (...args: unknown[]) => logs.push(args.map(String).join(' ')), log: (...args: unknown[]) => logs.push(args.map(String).join(' ')),
error: (...args: unknown[]) => logs.push('[ERROR] ' + args.map(String).join(' ')), error: (...args: unknown[]) => logs.push(`[ERROR] ${args.map(String).join(' ')}`),
warn: (...args: unknown[]) => logs.push('[WARN] ' + args.map(String).join(' ')), warn: (...args: unknown[]) => logs.push(`[WARN] ${args.map(String).join(' ')}`),
info: (...args: unknown[]) => logs.push('[INFO] ' + args.map(String).join(' ')), info: (...args: unknown[]) => logs.push(`[INFO] ${args.map(String).join(' ')}`),
}; };
// Create a sandboxed context with limited globals // Create a sandboxed context with limited globals
@@ -87,7 +87,6 @@ export const codeExecutionHandler: ToolHandler = async (args): Promise<ToolResul
return fn.call(sandbox); return fn.call(sandbox);
}; };
let result: unknown;
const timeoutPromise = new Promise<never>((_, reject) => { const timeoutPromise = new Promise<never>((_, reject) => {
setTimeout(() => reject(new Error('Execution timed out')), timeoutMs); setTimeout(() => reject(new Error('Execution timed out')), timeoutMs);
}); });
@@ -100,15 +99,15 @@ export const codeExecutionHandler: ToolHandler = async (args): Promise<ToolResul
} }
}); });
result = await Promise.race([executionPromise, timeoutPromise]); const result = await Promise.race([executionPromise, timeoutPromise]);
// Format output // Format output
let output = ''; let output = '';
if (logs.length > 0) { if (logs.length > 0) {
output += 'Console output:\n' + logs.join('\n') + '\n\n'; output += `Console output:\n${logs.join('\n')}\n\n`;
} }
if (result !== undefined) { if (result !== undefined) {
output += 'Result: ' + JSON.stringify(result, null, 2); output += `Result: ${JSON.stringify(result, null, 2)}`;
} else if (logs.length === 0) { } else if (logs.length === 0) {
output = 'Code executed successfully (no output)'; output = 'Code executed successfully (no output)';
} }
+1 -1
View File
@@ -41,7 +41,7 @@ export const dateTimeHandler: ToolHandler = async (args): Promise<ToolResult> =>
switch (format) { switch (format) {
case 'iso': case 'iso':
result = now.toLocaleString('sv-SE', { timeZone: timezone }).replace(' ', 'T') + 'Z'; result = `${now.toLocaleString('sv-SE', { timeZone: timezone }).replace(' ', 'T')}Z`;
break; break;
case 'date_only': case 'date_only':
result = now.toLocaleDateString('en-US', { result = now.toLocaleDateString('en-US', {
+4 -4
View File
@@ -13,11 +13,11 @@ export const imageGenerationTool: Tool = {
type: 'function', type: 'function',
function: { function: {
name: 'generate_image', name: 'generate_image',
description: description: `Generate an image based on a text description. Creates images using AI image generation. ${
'Generate an image based on a text description. Creates images using AI image generation. ' + IMAGE_API_URL
(IMAGE_API_URL
? 'Image generation is available.' ? 'Image generation is available.'
: 'NOTE: Image generation is not configured. Set IMAGE_GENERATION_API_URL environment variable.'), : 'NOTE: Image generation is not configured. Set IMAGE_GENERATION_API_URL environment variable.'
}`,
parameters: { parameters: {
type: 'object', type: 'object',
properties: { properties: {
+1 -1
View File
@@ -18,7 +18,7 @@ import { webSearchHandler, webSearchTool } from './web-search';
export type { ToolHandler, ToolResult } from './types'; export type { ToolHandler, ToolResult } from './types';
// Registry of tool handlers // Registry of tool handlers
const toolHandlers: Map<string, ToolHandler> = new Map(); const toolHandlers = new Map<string, ToolHandler>();
/** /**
* Register a tool handler * Register a tool handler
+1 -1
View File
@@ -106,7 +106,7 @@ export const urlFetchHandler: ToolHandler = async (args): Promise<ToolResult> =>
// Truncate if needed // Truncate if needed
if (text.length > maxLength) { if (text.length > maxLength) {
text = text.substring(0, maxLength) + '\n\n[Content truncated...]'; text = `${text.substring(0, maxLength)}\n\n[Content truncated...]`;
} }
return { return {