From c51b3c3fab8db72fe3fd329bd6c15f0b44a484eb Mon Sep 17 00:00:00 2001 From: Zacharias-Brohn Date: Wed, 14 Jan 2026 22:51:46 +0100 Subject: [PATCH] changes --- app/actions/chat.ts | 33 +- app/api/chat/route.ts | 19 +- components/Chat/ChatLayout.tsx | 1 + components/Chat/MarkdownMessage.module.css | 8 +- components/Chat/MarkdownMessage.tsx | 411 +++++++++++++-------- components/Chat/ToolCallDisplay.module.css | 29 ++ components/Chat/ToolCallDisplay.tsx | 89 +++++ components/Settings/SettingsModal.tsx | 49 ++- lib/tools/calculator.ts | 2 +- lib/tools/code-execution.ts | 13 +- lib/tools/datetime.ts | 2 +- lib/tools/image-generation.ts | 8 +- lib/tools/index.ts | 2 +- lib/tools/url-fetch.ts | 2 +- 14 files changed, 452 insertions(+), 216 deletions(-) create mode 100644 components/Chat/ToolCallDisplay.module.css create mode 100644 components/Chat/ToolCallDisplay.tsx diff --git a/app/actions/chat.ts b/app/actions/chat.ts index f95cb5b..7743279 100644 --- a/app/actions/chat.ts +++ b/app/actions/chat.ts @@ -46,11 +46,12 @@ const tools: Tool[] = [ // --- Tool Implementations --- -const availableTools: Record = { +const availableTools: Record) => string> = { get_current_time: () => { return new Date().toLocaleTimeString(); }, - calculate: ({ expression }: { expression: string }) => { + calculate: (args) => { + const expression = args.expression as string; try { // Safety: simplistic eval for demo purposes. // In production, use a math parser library like mathjs. @@ -68,17 +69,19 @@ export async function chat(model: string, messages: ChatMessage[]) { let response; try { response = await ollama.chat({ - model: model, - messages: messages, - tools: tools, + model, + messages, + tools, }); - } catch (e: any) { + } catch (e: unknown) { // 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.`); response = await ollama.chat({ - model: model, - messages: messages, + model, + messages, }); } else { throw e; @@ -101,6 +104,7 @@ export async function chat(model: string, messages: ChatMessage[]) { const functionToCall = availableTools[functionName]; if (functionToCall) { + // eslint-disable-next-line no-console console.log(`🤖 Tool Call: ${functionName}`, tool.function.arguments); const functionArgs = tool.function.arguments; 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 response = await ollama.chat({ - model: model, - messages: messages, - tools: tools, + model, + messages, + tools, }); } @@ -125,11 +129,12 @@ export async function chat(model: string, messages: ChatMessage[]) { success: true, message: response.message, }; - } catch (error: any) { + } catch (error: unknown) { + // eslint-disable-next-line no-console console.error('Chat error:', error); return { success: false, - error: error.message || 'Failed to generate response', + error: error instanceof Error ? error.message : 'Failed to generate response', }; } } diff --git a/app/api/chat/route.ts b/app/api/chat/route.ts index 078cdd3..3b8ba7c 100644 --- a/app/api/chat/route.ts +++ b/app/api/chat/route.ts @@ -144,21 +144,20 @@ export async function POST(request: NextRequest) { // Execute each tool and collect results 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); - - // Send tool result to stream - if (result.success) { - controller.enqueue(encoder.encode(`\`\`\`\n${result.result}\n\`\`\`\n\n`)); - } else { - controller.enqueue(encoder.encode(`Error: ${result.error}\n\n`)); - } + // Format: result + const toolMarker = `${toolResult}\n\n`; + controller.enqueue(encoder.encode(toolMarker)); // Add tool result to messages for next iteration workingMessages.push({ role: 'tool', - content: result.success ? result.result || '' : `Error: ${result.error}`, + content: toolResult, }); } diff --git a/components/Chat/ChatLayout.tsx b/components/Chat/ChatLayout.tsx index 0ad12be..e8eafa1 100644 --- a/components/Chat/ChatLayout.tsx +++ b/components/Chat/ChatLayout.tsx @@ -528,6 +528,7 @@ export default function ChatLayout() { }; const handleRemoveChat = async (chatId: string) => { + // eslint-disable-next-line no-alert if (!window.confirm('Are you sure you want to delete this chat?')) { return; } diff --git a/components/Chat/MarkdownMessage.module.css b/components/Chat/MarkdownMessage.module.css index 835aa7c..1c01cf7 100644 --- a/components/Chat/MarkdownMessage.module.css +++ b/components/Chat/MarkdownMessage.module.css @@ -1,5 +1,4 @@ .markdown { - word-wrap: break-word; overflow-wrap: break-word; } @@ -14,19 +13,20 @@ .codeBlock { display: block; white-space: pre-wrap; - word-break: break-word; + word-break: break-all; max-width: 100%; overflow-x: auto; } .fadeIn { - animation: textFadeIn 0.3s ease-out forwards; + animation: text-fade-in 0.3s ease-out forwards; } -@keyframes textFadeIn { +@keyframes text-fade-in { from { opacity: 0; } + to { opacity: 1; } diff --git a/components/Chat/MarkdownMessage.tsx b/components/Chat/MarkdownMessage.tsx index 514ae8f..9a86546 100644 --- a/components/Chat/MarkdownMessage.tsx +++ b/components/Chat/MarkdownMessage.tsx @@ -18,6 +18,7 @@ import { Text, Title, } from '@mantine/core'; +import { ToolCallDisplay } from './ToolCallDisplay'; import classes from './MarkdownMessage.module.css'; // Import KaTeX CSS for LaTeX rendering import 'katex/dist/katex.min.css'; @@ -27,6 +28,71 @@ interface MarkdownMessageProps { isStreaming?: boolean; } +interface ParsedToolCall { + toolName: string; + args: Record; + 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 = /([\s\S]*?)/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 = {}; + 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 */ @@ -90,165 +156,202 @@ function escapeHtml(text: string): string { .replace(/'/g, '''); } +/** + * Strip tool call markers from content for streaming display + */ +function stripToolMarkers(content: string): string { + return content.replace(//g, '').replace(//g, ''); +} + +function MarkdownContent({ content }: { content: string }) { + return ( + ( + + {children} + + ), + h2: ({ children }) => ( + + {children} + + ), + h3: ({ children }) => ( + + {children} + + ), + h4: ({ children }) => ( + + {children} + + ), + h5: ({ children }) => ( + + {children} + + ), + h6: ({ children }) => ( + + {children} + + ), + + // Paragraphs and text + p: ({ children }) => ( + + {children} + + ), + strong: ({ children }) => ( + + {children} + + ), + em: ({ children }) => ( + + {children} + + ), + del: ({ children }) => ( + + {children} + + ), + + // Code + code: ({ className, children }) => { + const isInline = !className; + if (isInline) { + return {children}; + } + return ( + + {children} + + ); + }, + pre: ({ children }) =>
{children}
, + + // Lists + ul: ({ children, className }) => { + // Check if this is a task list (GFM) + if (className?.includes('contains-task-list')) { + return
    {children}
; + } + return ( + + {children} + + ); + }, + ol: ({ children }) => ( + + {children} + + ), + li: ({ children, className }) => { + // Task list items have a checkbox + if (className?.includes('task-list-item')) { + return
  • {children}
  • ; + } + return {children}; + }, + input: ({ checked, type }) => { + if (type === 'checkbox') { + return ; + } + return null; + }, + + // Links and media + a: ({ href, children }) => ( + + {children} + + ), + img: ({ src, alt }) => { + const imgSrc = typeof src === 'string' ? src : ''; + return ( + + {alt + + ); + }, + + // Block elements + blockquote: ({ children }) =>
    {children}
    , + hr: () => , + + // Tables (GFM) + table: ({ children }) => ( + + {children} +
    + ), + thead: ({ children }) => {children}, + tbody: ({ children }) => {children}, + tr: ({ children }) => {children}, + th: ({ children }) => {children}, + td: ({ children }) => {children}, + + // Allow the fade-in spans to pass through + span: ({ className, children }) => {children}, + }} + > + {content} +
    + ); +} + export function MarkdownMessage({ content, isStreaming = false }: MarkdownMessageProps) { const processedContent = useStreamingContent(content, isStreaming); + // During streaming, just show the raw content (with tool markers stripped) + if (isStreaming) { + return ( +
    + +
    + ); + } + + // When not streaming, parse and render tool calls + const segments = parseContentWithToolCalls(content); + return (
    - ( - - {children} - - ), - h2: ({ children }) => ( - - {children} - - ), - h3: ({ children }) => ( - - {children} - - ), - h4: ({ children }) => ( - - {children} - - ), - h5: ({ children }) => ( - - {children} - - ), - h6: ({ children }) => ( - - {children} - - ), - - // Paragraphs and text - p: ({ children }) => ( - - {children} - - ), - strong: ({ children }) => ( - - {children} - - ), - em: ({ children }) => ( - - {children} - - ), - del: ({ children }) => ( - - {children} - - ), - - // Code - code: ({ className, children }) => { - const isInline = !className; - if (isInline) { - return {children}; - } - return ( - - {children} - - ); - }, - pre: ({ children }) =>
    {children}
    , - - // Lists - ul: ({ children, className }) => { - // Check if this is a task list (GFM) - if (className?.includes('contains-task-list')) { - return
      {children}
    ; - } - return ( - - {children} - - ); - }, - ol: ({ children }) => ( - - {children} - - ), - li: ({ children, className }) => { - // Task list items have a checkbox - if (className?.includes('task-list-item')) { - return
  • {children}
  • ; - } - return {children}; - }, - input: ({ checked, type }) => { - if (type === 'checkbox') { - return ; - } - return null; - }, - - // Links and media - a: ({ href, children }) => ( - - {children} - - ), - img: ({ src, alt }) => { - const imgSrc = typeof src === 'string' ? src : ''; - return ( - - {alt - - ); - }, - - // Block elements - blockquote: ({ children }) =>
    {children}
    , - hr: () => , - - // Tables (GFM) - table: ({ children }) => ( - - {children} -
    - ), - thead: ({ children }) => {children}, - tbody: ({ children }) => {children}, - tr: ({ children }) => {children}, - th: ({ children }) => {children}, - td: ({ children }) => {children}, - - // Allow the fade-in spans to pass through - span: ({ className, children }) => {children}, - }} - > - {processedContent} -
    + {segments.map((segment, index) => { + if (segment.type === 'tool' && segment.toolCall) { + return ( + + ); + } + return ; + })}
    ); } diff --git a/components/Chat/ToolCallDisplay.module.css b/components/Chat/ToolCallDisplay.module.css new file mode 100644 index 0000000..2100a25 --- /dev/null +++ b/components/Chat/ToolCallDisplay.module.css @@ -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; +} diff --git a/components/Chat/ToolCallDisplay.tsx b/components/Chat/ToolCallDisplay.tsx new file mode 100644 index 0000000..43eede2 --- /dev/null +++ b/components/Chat/ToolCallDisplay.tsx @@ -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; + result: string; +} + +// Friendly tool names +const toolDisplayNames: Record = { + 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 ( + + setOpened(!opened)} + gap="xs" + wrap="nowrap" + p="xs" + style={{ cursor: 'pointer' }} + > + + {opened ? : } + + + + {displayName} + + {!opened && argsDisplay && ( + + {argsDisplay} + + )} + + + +
    + {Object.keys(args).length > 0 && ( +
    + + Arguments: + + + {JSON.stringify(args, null, 2)} + +
    + )} +
    + + Result: + + + {result} + +
    +
    +
    +
    + ); +} diff --git a/components/Settings/SettingsModal.tsx b/components/Settings/SettingsModal.tsx index b25954e..384bcd9 100644 --- a/components/Settings/SettingsModal.tsx +++ b/components/Settings/SettingsModal.tsx @@ -35,19 +35,22 @@ import { } from '@mantine/core'; import { deleteModel, getInstalledModels, pullModel, type OllamaModel } from '@/app/actions/ollama'; -const POPULAR_MODELS = [ - 'llama3.2', - 'llama3.1', - 'mistral', - 'gemma2', - 'qwen2.5', - 'phi3.5', - 'neural-chat', - 'starling-lm', - 'codellama', - 'deepseek-coder', - 'llava', -]; +/* + * Popular models list - reserved for future autocomplete feature + * const POPULAR_MODELS = [ + * 'llama3.2', + * 'llama3.1', + * 'mistral', + * 'gemma2', + * 'qwen2.5', + * 'phi3.5', + * 'neural-chat', + * 'starling-lm', + * 'codellama', + * 'deepseek-coder', + * 'llava', + * ]; + */ interface User { id: string; @@ -98,8 +101,6 @@ export function SettingsModal({ }, }); - const [value, setValue] = useState(null); - // Filter installed models based on search const options = models .filter((item) => item.name.toLowerCase().includes(search.toLowerCase().trim())) @@ -129,6 +130,7 @@ export function SettingsModal({ setUser(null); } } catch (e) { + // eslint-disable-next-line no-console console.error(e); } }; @@ -139,6 +141,7 @@ export function SettingsModal({ const list = await getInstalledModels(); setModels(list); } catch (e) { + // eslint-disable-next-line no-console console.error(e); } finally { setLoadingModels(false); @@ -146,7 +149,9 @@ export function SettingsModal({ }; const handlePullModel = async () => { - if (!newModelName) return; + if (!newModelName) { + return; + } setPullingModel(newModelName); try { const result = await pullModel(newModelName); @@ -157,6 +162,7 @@ export function SettingsModal({ setError(result.message); } } catch (e) { + // eslint-disable-next-line no-console console.error(e); } finally { setPullingModel(null); @@ -164,11 +170,15 @@ export function SettingsModal({ }; 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 { await deleteModel(name); await fetchModels(); } catch (e) { + // eslint-disable-next-line no-console console.error(e); } }; @@ -195,8 +205,8 @@ export function SettingsModal({ await fetchUser(); setUsername(''); setPassword(''); - } catch (err: any) { - setError(err.message); + } catch (err: unknown) { + setError(err instanceof Error ? err.message : 'An error occurred'); } finally { setLoading(false); } @@ -220,6 +230,7 @@ export function SettingsModal({ body: JSON.stringify({ accentColor: color }), }); } catch (e) { + // eslint-disable-next-line no-console console.error('Failed to save accent color:', e); } } diff --git a/lib/tools/calculator.ts b/lib/tools/calculator.ts index 1f799bf..d924f1f 100644 --- a/lib/tools/calculator.ts +++ b/lib/tools/calculator.ts @@ -28,7 +28,7 @@ export const calculatorTool: Tool = { // Safe math evaluation using Function constructor with limited scope function safeEvaluate(expression: string): number { // Replace common math notation - let expr = expression + const expr = expression .replace(/\^/g, '**') // Exponents .replace(/sqrt/g, 'Math.sqrt') .replace(/sin/g, 'Math.sin') diff --git a/lib/tools/code-execution.ts b/lib/tools/code-execution.ts index ca2cb07..742db12 100644 --- a/lib/tools/code-execution.ts +++ b/lib/tools/code-execution.ts @@ -44,9 +44,9 @@ export const codeExecutionHandler: ToolHandler = async (args): Promise 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(' ')), + 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 @@ -87,7 +87,6 @@ export const codeExecutionHandler: ToolHandler = async (args): Promise((_, reject) => { setTimeout(() => reject(new Error('Execution timed out')), timeoutMs); }); @@ -100,15 +99,15 @@ export const codeExecutionHandler: ToolHandler = async (args): Promise 0) { - output += 'Console output:\n' + logs.join('\n') + '\n\n'; + output += `Console output:\n${logs.join('\n')}\n\n`; } if (result !== undefined) { - output += 'Result: ' + JSON.stringify(result, null, 2); + output += `Result: ${JSON.stringify(result, null, 2)}`; } else if (logs.length === 0) { output = 'Code executed successfully (no output)'; } diff --git a/lib/tools/datetime.ts b/lib/tools/datetime.ts index 78d5d57..edf2f09 100644 --- a/lib/tools/datetime.ts +++ b/lib/tools/datetime.ts @@ -41,7 +41,7 @@ export const dateTimeHandler: ToolHandler = async (args): Promise => switch (format) { case 'iso': - result = now.toLocaleString('sv-SE', { timeZone: timezone }).replace(' ', 'T') + 'Z'; + result = `${now.toLocaleString('sv-SE', { timeZone: timezone }).replace(' ', 'T')}Z`; break; case 'date_only': result = now.toLocaleDateString('en-US', { diff --git a/lib/tools/image-generation.ts b/lib/tools/image-generation.ts index 1275f5e..5dc7a00 100644 --- a/lib/tools/image-generation.ts +++ b/lib/tools/image-generation.ts @@ -13,11 +13,11 @@ 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 + 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.'), + : 'NOTE: Image generation is not configured. Set IMAGE_GENERATION_API_URL environment variable.' + }`, parameters: { type: 'object', properties: { diff --git a/lib/tools/index.ts b/lib/tools/index.ts index 71d0e0d..c3a31b7 100644 --- a/lib/tools/index.ts +++ b/lib/tools/index.ts @@ -18,7 +18,7 @@ import { webSearchHandler, webSearchTool } from './web-search'; export type { ToolHandler, ToolResult } from './types'; // Registry of tool handlers -const toolHandlers: Map = new Map(); +const toolHandlers = new Map(); /** * Register a tool handler diff --git a/lib/tools/url-fetch.ts b/lib/tools/url-fetch.ts index 3f87452..77d48c5 100644 --- a/lib/tools/url-fetch.ts +++ b/lib/tools/url-fetch.ts @@ -106,7 +106,7 @@ export const urlFetchHandler: ToolHandler = async (args): Promise => // Truncate if needed if (text.length > maxLength) { - text = text.substring(0, maxLength) + '\n\n[Content truncated...]'; + text = `${text.substring(0, maxLength)}\n\n[Content truncated...]`; } return {