wip broken xml parser, parses only 1 tool, doesnt properly catch tag or attributes

This commit is contained in:
marko-kraemer 2025-04-16 22:34:28 +01:00
parent 9fbad13468
commit 0f5fed85aa
1 changed files with 256 additions and 154 deletions

View File

@ -4,7 +4,11 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import Image from 'next/image'; import Image from 'next/image';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { ArrowDown, File, Terminal, ExternalLink, User, CheckCircle, CircleDashed } from 'lucide-react'; import {
ArrowDown, FileText, Terminal, ExternalLink, User, CheckCircle, CircleDashed,
FileEdit, Search, Globe, Code, MessageSquare, Folder, FileX, CloudUpload, Wrench, Cog
} from 'lucide-react';
import type { ElementType } from 'react';
import { addUserMessage, getMessages, startAgent, stopAgent, getAgentStatus, streamAgent, getAgentRuns, getProject, getThread, updateProject } from '@/lib/api'; import { addUserMessage, getMessages, startAgent, stopAgent, getAgentStatus, streamAgent, getAgentRuns, getProject, getThread, updateProject } from '@/lib/api';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { Skeleton } from "@/components/ui/skeleton"; import { Skeleton } from "@/components/ui/skeleton";
@ -50,11 +54,88 @@ function isToolSequence(item: RenderItem): item is ToolSequence {
return (item as ToolSequence).type === 'tool_sequence'; return (item as ToolSequence).type === 'tool_sequence';
} }
// Helper function to get an icon based on tool name
const getToolIcon = (toolName: string): ElementType => {
// Ensure we handle null/undefined toolName gracefully
if (!toolName) return Cog;
// Convert to lowercase for case-insensitive matching
const normalizedName = toolName.toLowerCase();
switch (normalizedName) {
case 'create-file':
case 'str-replace':
case 'write-file':
return FileEdit;
case 'run_terminal_cmd':
case 'run_command':
return Terminal;
case 'web_search':
return Search;
case 'browse_url':
return Globe;
case 'call_api':
return Code;
case 'send_message':
return MessageSquare;
case 'list_dir':
return Folder;
case 'read_file':
return FileText;
case 'delete_file':
return FileX;
case 'deploy':
return CloudUpload;
default:
// Add logging for debugging unhandled tool types
console.log(`[PAGE] Using default icon for unknown tool type: ${toolName}`);
return Cog; // Default icon
}
};
// Helper function to extract a primary parameter from XML/arguments
const extractPrimaryParam = (toolName: string, content: string | undefined): string | null => {
if (!content) return null;
try {
// Simple regex for common parameters - adjust as needed
let match: RegExpMatchArray | null = null;
switch (toolName?.toLowerCase()) {
case 'edit_file':
case 'read_file':
case 'delete_file':
case 'write_file':
match = content.match(/target_file=(?:"|')([^"|']+)(?:"|')/);
// Return just the filename part
return match ? match[1].split('/').pop() || match[1] : null;
case 'run_terminal_cmd':
case 'run_command':
match = content.match(/command=(?:"|')([^"|']+)(?:"|')/);
// Truncate long commands
return match ? (match[1].length > 30 ? match[1].substring(0, 27) + '...' : match[1]) : null;
case 'web_search':
match = content.match(/query=(?:"|')([^"|']+)(?:"|')/);
return match ? (match[1].length > 30 ? match[1].substring(0, 27) + '...' : match[1]) : null;
case 'browse_url':
match = content.match(/url=(?:"|')([^"|']+)(?:"|')/);
return match ? match[1] : null;
// Add more cases as needed for other tools
default:
return null;
}
} catch (e) {
console.warn("Error parsing tool parameters:", e);
return null;
}
};
// Flag to control whether tool result messages are rendered
const SHOULD_RENDER_TOOL_RESULTS = false;
// Function to group consecutive assistant tool call / user tool result pairs // Function to group consecutive assistant tool call / user tool result pairs
function groupMessages(messages: ApiMessage[]): RenderItem[] { function groupMessages(messages: ApiMessage[]): RenderItem[] {
const grouped: RenderItem[] = []; const grouped: RenderItem[] = [];
let i = 0; let i = 0;
const excludedTags = ['ask', 'inform']; // Tags to exclude from grouping
while (i < messages.length) { while (i < messages.length) {
const currentMsg = messages[i]; const currentMsg = messages[i];
@ -68,13 +149,6 @@ function groupMessages(messages: ApiMessage[]): RenderItem[] {
const toolTagMatch = currentMsg.content?.match(/<([a-zA-Z\-_]+)(?:\s+[^>]*)?>/); const toolTagMatch = currentMsg.content?.match(/<([a-zA-Z\-_]+)(?:\s+[^>]*)?>/);
if (toolTagMatch && nextMsg && nextMsg.role === 'user') { if (toolTagMatch && nextMsg && nextMsg.role === 'user') {
const expectedTag = toolTagMatch[1]; const expectedTag = toolTagMatch[1];
// *** Check if the tag is excluded ***
if (excludedTags.includes(expectedTag)) {
// If excluded, treat as a normal message and break potential sequence start
grouped.push(currentMsg);
i++;
continue;
}
// Regex to check for <tool_result><tagname>...</tagname></tool_result> // Regex to check for <tool_result><tagname>...</tagname></tool_result>
// Using 's' flag for dotall to handle multiline content within tags -> Replaced with [\s\S] to avoid ES target issues // Using 's' flag for dotall to handle multiline content within tags -> Replaced with [\s\S] to avoid ES target issues
@ -95,11 +169,6 @@ function groupMessages(messages: ApiMessage[]): RenderItem[] {
const nextToolTagMatch = potentialAssistant.content?.match(/<([a-zA-Z\-_]+)(?:\s+[^>]*)?>/); const nextToolTagMatch = potentialAssistant.content?.match(/<([a-zA-Z\-_]+)(?:\s+[^>]*)?>/);
if (nextToolTagMatch && potentialUser && potentialUser.role === 'user') { if (nextToolTagMatch && potentialUser && potentialUser.role === 'user') {
const nextExpectedTag = nextToolTagMatch[1]; const nextExpectedTag = nextToolTagMatch[1];
// *** Check if the continuation tag is excluded ***
if (excludedTags.includes(nextExpectedTag)) {
// If excluded, break the sequence
break;
}
// Replaced dotall 's' flag with [\s\S] // Replaced dotall 's' flag with [\s\S]
const nextToolResultRegex = new RegExp(`^<tool_result>\\s*<(${nextExpectedTag})(?:\\s+[^>]*)?>[\\s\\S]*?</\\1>\\s*</tool_result>`); const nextToolResultRegex = new RegExp(`^<tool_result>\\s*<(${nextExpectedTag})(?:\\s+[^>]*)?>[\\s\\S]*?</\\1>\\s*</tool_result>`);
@ -1040,28 +1109,18 @@ export default function ThreadPage({ params }: { params: Promise<ThreadParams> }
<div <div
key={`seq-${index}`} key={`seq-${index}`}
ref={index === processedMessages.length - 1 ? latestMessageRef : null} ref={index === processedMessages.length - 1 ? latestMessageRef : null}
className="relative group pl-10" className="relative group pt-4 pb-2 border-t border-gray-100"
> >
{/* Left border for the sequence */} {/* Simplified header with logo and name */}
<div className="absolute left-3 top-0 bottom-0 w-0.5 bg-blue-500/50" aria-hidden="true"></div> <div className="flex items-center mb-2 text-sm gap-2">
<div className="flex-shrink-0 w-5 h-5 rounded-full flex items-center justify-center overflow-hidden">
{/* Kortix Suna Label (Hover) */} <Image src="/kortix-symbol.svg" alt="Suna" width={16} height={16} className="object-contain" />
<span className="absolute left-0 top-1 -translate-x-full bg-blue-100 text-blue-700 text-xs font-semibold px-1.5 py-0.5 rounded z-10 opacity-0 group-hover:opacity-100 transition-opacity duration-200">
Kortix Suna
</span>
{/* Render Avatar & Name ONCE for the sequence */}
<div className="absolute left-0 top-0 -translate-x-1/2 transform -translate-y-0 "> {/* Position avatar centered on the line */}
<div className="flex-shrink-0 w-7 h-7 rounded-full bg-muted flex items-center justify-center overflow-hidden border bg-background">
<Image src="/kortix-symbol.svg" alt="Suna Logo" width={20} height={20} className="object-contain" />
</div> </div>
</div> <span className="text-gray-700 font-medium">Suna</span>
<div className="mb-1 ml-[-2.5rem]"> {/* Adjust margin to align name */}
<span className="text-xs font-semibold">Suna</span>
</div> </div>
{/* Container for the pairs within the sequence */} {/* Container for the pairs within the sequence */}
<div className="space-y-3"> <div className="space-y-4">
{pairs.map((pair, pairIndex) => { {pairs.map((pair, pairIndex) => {
// Parse assistant message content // Parse assistant message content
const assistantContent = pair.assistantCall.content || ''; const assistantContent = pair.assistantCall.content || '';
@ -1072,54 +1131,55 @@ export default function ThreadPage({ params }: { params: Promise<ThreadParams> }
const postContent = xmlMatch ? assistantContent.substring(xmlMatch.index + xmlMatch[0].length).trim() : ''; const postContent = xmlMatch ? assistantContent.substring(xmlMatch.index + xmlMatch[0].length).trim() : '';
const userResultName = pair.userResult.content?.match(/<tool_result>\s*<([a-zA-Z\-_]+)/)?.[1] || 'Result'; const userResultName = pair.userResult.content?.match(/<tool_result>\s*<([a-zA-Z\-_]+)/)?.[1] || 'Result';
// Get icon and parameter for the tag
const IconComponent = getToolIcon(toolName);
const paramDisplay = extractPrimaryParam(toolName, assistantContent);
return ( return (
<div key={`${index}-pair-${pairIndex}`} className="space-y-2"> <div key={`${index}-pair-${pairIndex}`} className="space-y-2">
{/* Assistant Content (No Avatar/Name here) */} {/* Tool execution content */}
<div className="flex flex-col items-start space-y-2 flex-1"> <div className="space-y-1">
{/* Pre-XML Content */} {/* First show any text content before the tool call */}
{preContent && ( {preContent && (
<div className="w-full rounded-lg bg-muted p-3 text-sm"> <p className="text-sm text-gray-800 whitespace-pre-wrap break-words">
<div className="whitespace-pre-wrap break-words"> {preContent}
{preContent} </p>
</div>
</div>
)} )}
{/* Tool Call Button */} {/* Clickable Tool Tag */}
{xmlMatch && ( {xmlMatch && (
<Button <button
variant="outline"
size="sm"
className="h-auto py-1.5 px-3 text-xs w-full sm:w-auto justify-start bg-background hover:bg-muted/50 border-muted-foreground/20 shadow-sm"
onClick={() => handleHistoricalToolClick(pair)} onClick={() => handleHistoricalToolClick(pair)}
className="inline-flex items-center gap-1.5 py-0.5 px-2 text-xs text-gray-600 bg-gray-100 hover:bg-gray-200 rounded-md transition-colors cursor-pointer border border-gray-200"
> >
<Terminal className="h-3 w-3 mr-1.5 flex-shrink-0" /> <IconComponent className="h-3.5 w-3.5 text-gray-500 flex-shrink-0" />
<span className="font-mono truncate mr-2">{toolName}</span> <span className="font-mono text-xs text-gray-700">
<span className="ml-auto text-muted-foreground/70 flex items-center"> {toolName}
View Details <ExternalLink className="h-3 w-3 ml-1" />
</span> </span>
</Button> {paramDisplay && (
<span className="ml-1 text-gray-500 truncate" title={paramDisplay}>
{paramDisplay}
</span>
)}
</button>
)} )}
{/* Post-XML Content (Less Common) */} {/* Post-XML Content (Less Common) */}
{postContent && ( {postContent && (
<div className="w-full rounded-lg bg-muted p-3 text-sm"> <p className="text-sm text-gray-800 whitespace-pre-wrap break-words">
<div className="whitespace-pre-wrap break-words"> {postContent}
{postContent} </p>
</div>
</div>
)} )}
</div> </div>
{/* User Tool Result Part */} {/* Simple tool result indicator */}
<div className="flex justify-start"> {SHOULD_RENDER_TOOL_RESULTS && userResultName && (
<div className="flex items-center gap-2 rounded-md bg-green-100/60 border border-green-200/80 px-2.5 py-1 text-xs font-mono text-green-900 shadow-sm"> <div className="ml-4 flex items-center gap-1.5 text-xs text-gray-500">
<CheckCircle className="h-3.5 w-3.5 flex-shrink-0 text-green-600" /> <CheckCircle className="h-3 w-3 text-green-600" />
<span>{userResultName} Result Received</span> <span className="font-mono">{userResultName} completed</span>
{/* Optional: Add a button to show result details here too? */}
{/* <Button variant="ghost" size="xs" onClick={() => handleHistoricalToolClick(pair)}>(details)</Button> */}
</div> </div>
</div> )}
</div> </div>
); );
})} })}
@ -1137,65 +1197,86 @@ export default function ThreadPage({ params }: { params: Promise<ThreadParams> }
<div <div
key={index} // Use the index from processedMessages key={index} // Use the index from processedMessages
ref={index === processedMessages.length - 1 && message.role !== 'user' ? latestMessageRef : null} // Ref on the regular message div if it's last (and not user) ref={index === processedMessages.length - 1 && message.role !== 'user' ? latestMessageRef : null} // Ref on the regular message div if it's last (and not user)
className={`flex items-start gap-3 ${message.role === 'user' ? 'justify-end pl-12' : 'justify-start'}`} className={`${message.role === 'user' ? 'text-right py-1' : 'py-2'} ${index > 0 ? 'border-t border-gray-100' : ''}`} // Add top border between messages
> >
{/* Avatar (User = Right, Assistant/Tool = Left) */} {/* Avatar (User = Right, Assistant/Tool = Left) */}
{message.role === 'user' ? ( {message.role === 'user' ? (
// User bubble comes first in flex-end // User bubble comes first in flex-end
<> <div className="max-w-[85%] ml-auto text-sm text-gray-800 whitespace-pre-wrap break-words">
<div className="flex-1 space-y-1 flex justify-end"> {message.content}
{/* User message bubble */} </div>
<div className="max-w-[85%] rounded-lg bg-primary text-primary-foreground p-3 text-sm shadow-sm">
<div className="whitespace-pre-wrap break-words">
{message.content}
</div>
</div>
</div>
</>
) : ( ) : (
// Assistant / Tool bubble on the left // Assistant / Tool bubble on the left
<> <div>
{/* Assistant Avatar */} {/* Simplified header with logo and name */}
<div className="flex-shrink-0 w-7 h-7 rounded-full bg-muted flex items-center justify-center overflow-hidden border"> <div className="flex items-center mb-2 text-sm gap-2">
<Image src="/kortix-symbol.svg" alt="Suna Logo" width={20} height={20} className="object-contain" /> <div className="flex-shrink-0 w-5 h-5 rounded-full flex items-center justify-center overflow-hidden">
</div> <Image src="/kortix-symbol.svg" alt="Suna" width={16} height={16} className="object-contain" />
{/* Content Bubble */}
<div className="flex-1 space-y-1">
<span className="text-xs font-semibold">Suna</span>
<div className={`max-w-[85%] rounded-lg p-3 text-sm shadow-sm ${message.role === 'tool' ? 'bg-purple-100/60 border border-purple-200/80' : 'bg-muted'}`}>
<div className="whitespace-pre-wrap break-words">
{/* Use existing logic for structured tool calls/results and normal messages */}
{message.type === 'tool_call' && message.tool_call ? (
// Existing rendering for structured tool_call type
<div className="font-mono text-xs space-y-1.5">
<div className="flex items-center gap-1.5 text-muted-foreground">
<CircleDashed className="h-3.5 w-3.5 animate-spin animation-duration-2000" />
<span>Tool Call: {message.tool_call.function.name}</span>
</div>
<div className="mt-1 p-2 bg-background/50 rounded-md overflow-x-auto border">
{message.tool_call.function.arguments}
</div>
</div>
) : message.role === 'tool' ? (
// Existing rendering for standard 'tool' role messages
<div className="font-mono text-xs space-y-1.5">
<div className="flex items-center gap-1.5 text-purple-800">
<CheckCircle className="h-3.5 w-3.5 text-purple-600" />
<span>Tool Result: {message.name || 'Unknown Tool'}</span>
</div>
<div className="mt-1 p-2 bg-background/50 rounded-md overflow-x-auto border">
{/* Render content safely, handle potential objects */}
{typeof message.content === 'string' ? message.content : JSON.stringify(message.content)}
</div>
</div>
) : (
// Default rendering for plain assistant messages
message.content
)}
</div>
</div> </div>
<span className="text-gray-700 font-medium">Suna</span>
</div> </div>
</>
{/* Message content */}
{message.type === 'tool_call' && message.tool_call ? (
// Clickable Tool Tag (Live)
<div className="space-y-2">
{(() => { // IIFE for scope
const toolName = message.tool_call.function.name;
const IconComponent = getToolIcon(toolName);
const paramDisplay = extractPrimaryParam(toolName, message.tool_call.function.arguments);
return (
<button
className="inline-flex items-center gap-1.5 py-0.5 px-2 text-xs text-gray-600 bg-gray-100 hover:bg-gray-200 rounded-md transition-colors cursor-pointer border border-gray-200"
onClick={() => {
if (message.tool_call) {
setSidePanelContent({
id: message.tool_call.id,
name: message.tool_call.function.name,
arguments: message.tool_call.function.arguments,
index: message.tool_call.index
});
setIsSidePanelOpen(true);
}
}}
>
<IconComponent className="h-3.5 w-3.5 text-gray-500 flex-shrink-0 animate-spin animation-duration-2000" />
<span className="font-mono text-xs text-gray-700">
{toolName}
</span>
{paramDisplay && (
<span className="ml-1 text-gray-500 truncate" title={paramDisplay}>
{paramDisplay}
</span>
)}
</button>
);
})()}
<pre className="text-xs font-mono overflow-x-auto my-1 p-2 bg-gray-50 border border-gray-100 rounded-sm">
{message.tool_call.function.arguments}
</pre>
</div>
) : (message.role === 'tool' && SHOULD_RENDER_TOOL_RESULTS) ? (
// Clean tool result UI
<div className="space-y-2">
<div className="flex items-center justify-between py-1 group">
<div className="flex items-center gap-2">
<CheckCircle className="h-4 w-4 text-gray-400" />
<span className="font-mono text-sm text-gray-700">
{message.name || 'Unknown Tool'}
</span>
</div>
</div>
<pre className="text-xs font-mono overflow-x-auto my-1 p-2 bg-gray-50 border border-gray-100 rounded-sm">
{typeof message.content === 'string' ? message.content : JSON.stringify(message.content, null, 2)}
</pre>
</div>
) : (
// Plain text message
<div className="max-w-[85%] text-sm text-gray-800 whitespace-pre-wrap break-words">
{message.content}
</div>
)}
</div>
)} )}
</div> </div>
); );
@ -1205,58 +1286,79 @@ export default function ThreadPage({ params }: { params: Promise<ThreadParams> }
{streamContent && ( {streamContent && (
<div <div
ref={latestMessageRef} ref={latestMessageRef}
className="flex items-start gap-3 justify-start" // Assistant streaming style className="py-2 border-t border-gray-100" // Assistant streaming style
> >
{/* Assistant Avatar */} {/* Simplified header with logo and name */}
<div className="flex-shrink-0 w-7 h-7 rounded-full bg-muted flex items-center justify-center overflow-hidden border"> <div className="flex items-center mb-2 text-sm gap-2">
<Image src="/kortix-symbol.svg" alt="Suna Logo" width={20} height={20} className="object-contain" /> <div className="flex-shrink-0 w-5 h-5 rounded-full flex items-center justify-center overflow-hidden">
<Image src="/kortix-symbol.svg" alt="Suna" width={16} height={16} className="object-contain" />
</div>
<span className="text-gray-700 font-medium">Suna</span>
</div> </div>
{/* Content Bubble */}
<div className="flex-1 space-y-1"> <div className="space-y-2">
<span className="text-xs font-semibold">Suna</span> {toolCallData ? (
<div className="max-w-[85%] rounded-lg bg-muted p-3 text-sm shadow-sm"> // Clickable Tool Tag (Streaming)
<div className="whitespace-pre-wrap break-words"> <div className="space-y-2">
{toolCallData ? ( {(() => { // IIFE for scope
// Streaming Tool Call const toolName = toolCallData.name;
<div className="font-mono text-xs space-y-1.5"> const IconComponent = getToolIcon(toolName);
<div className="flex items-center gap-1.5 text-muted-foreground"> const paramDisplay = extractPrimaryParam(toolName, toolCallData.arguments);
<CircleDashed className="h-3.5 w-3.5 animate-spin animation-duration-2000" /> return (
<span>Tool Call: {toolCallData.name}</span> <button
</div> className="inline-flex items-center gap-1.5 py-0.5 px-2 text-xs text-gray-600 bg-gray-100 hover:bg-gray-200 rounded-md transition-colors cursor-pointer border border-gray-200"
<div className="mt-1 p-2 bg-background/50 rounded-md overflow-x-auto border"> onClick={() => {
{toolCallData.arguments || ''} if (toolCallData) {
</div> setSidePanelContent(toolCallData);
</div> setIsSidePanelOpen(true);
) : ( }
// Streaming Text Content }}
streamContent >
)} <CircleDashed className="h-3.5 w-3.5 text-gray-500 flex-shrink-0 animate-spin animation-duration-2000" />
{/* Blinking Cursor */} <span className="font-mono text-xs text-gray-700">
{toolName}
</span>
{paramDisplay && (
<span className="ml-1 text-gray-500 truncate" title={paramDisplay}>
{paramDisplay}
</span>
)}
</button>
);
})()}
<pre className="text-xs font-mono overflow-x-auto my-1 p-2 bg-gray-50 border border-gray-100 rounded-sm">
{toolCallData.arguments || ''}
</pre>
</div>
) : (
// Simple text streaming
<div className="text-sm text-gray-800 whitespace-pre-wrap break-words max-w-[85%]">
{streamContent}
{isStreaming && ( {isStreaming && (
<span className="inline-block h-4 w-0.5 bg-foreground/50 ml-0.5 -mb-1 animate-pulse" /> <span className="inline-block h-4 w-0.5 bg-gray-400 ml-0.5 -mb-1 animate-pulse" />
)} )}
</div> </div>
</div> )}
</div> </div>
</div> </div>
)} )}
{/* Loading indicator (three dots) */} {/* Loading indicator (three dots) */}
{agentStatus === 'running' && !streamContent && !toolCallData && ( {agentStatus === 'running' && !streamContent && !toolCallData && (
<div className="flex items-start gap-3 justify-start"> {/* Assistant style */} <div className="py-2 border-t border-gray-100">
<div className="flex-shrink-0 w-7 h-7 rounded-full bg-muted flex items-center justify-center overflow-hidden border"> {/* Simplified header with logo and name */}
<Image src="/kortix-symbol.svg" alt="Suna Logo" width={20} height={20} className="object-contain" /> <div className="flex items-center mb-2 text-sm gap-2">
</div> <div className="flex-shrink-0 w-5 h-5 rounded-full flex items-center justify-center overflow-hidden">
<div className="flex-1 space-y-1"> <Image src="/kortix-symbol.svg" alt="Suna" width={16} height={16} className="object-contain" />
<span className="text-xs font-semibold">Suna</span>
<div className="max-w-[85%] rounded-lg bg-muted px-4 py-3 text-sm shadow-sm">
<div className="flex items-center gap-1.5">
<div className="h-1.5 w-1.5 rounded-full bg-foreground/50 animate-pulse" />
<div className="h-1.5 w-1.5 rounded-full bg-foreground/50 animate-pulse delay-150" />
<div className="h-1.5 w-1.5 rounded-full bg-foreground/50 animate-pulse delay-300" />
</div>
</div> </div>
<span className="text-gray-700 font-medium">Suna</span>
</div>
<div className="flex items-center gap-1.5 py-1">
<div className="h-1.5 w-1.5 rounded-full bg-gray-400/50 animate-pulse" />
<div className="h-1.5 w-1.5 rounded-full bg-gray-400/50 animate-pulse delay-150" />
<div className="h-1.5 w-1.5 rounded-full bg-gray-400/50 animate-pulse delay-300" />
</div> </div>
</div> </div>
)} )}