mirror of https://github.com/kortix-ai/suna.git
mid
This commit is contained in:
parent
918f82fff9
commit
542c18cd2f
|
@ -185,6 +185,7 @@ export default function ThreadPage({ params }: { params: Promise<ThreadParams> }
|
|||
const [isSidePanelOpen, setIsSidePanelOpen] = useState(false);
|
||||
const [toolCalls, setToolCalls] = useState<ToolCallInput[]>([]);
|
||||
const [currentToolIndex, setCurrentToolIndex] = useState<number>(0);
|
||||
const [autoOpenedPanel, setAutoOpenedPanel] = useState(false);
|
||||
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
const messagesContainerRef = useRef<HTMLDivElement>(null);
|
||||
|
@ -263,6 +264,11 @@ export default function ThreadPage({ params }: { params: Promise<ThreadParams> }
|
|||
return [...prev, message];
|
||||
}
|
||||
});
|
||||
|
||||
// If we received a tool message, refresh the tool panel
|
||||
if (message.type === 'tool') {
|
||||
setAutoOpenedPanel(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleStreamStatusChange = useCallback((hookStatus: string) => {
|
||||
|
@ -274,6 +280,8 @@ export default function ThreadPage({ params }: { params: Promise<ThreadParams> }
|
|||
case 'agent_not_running':
|
||||
setAgentStatus('idle');
|
||||
setAgentRunId(null);
|
||||
// Reset auto-opened state when agent completes to trigger tool detection
|
||||
setAutoOpenedPanel(false);
|
||||
break;
|
||||
case 'connecting':
|
||||
setAgentStatus('connecting');
|
||||
|
@ -539,6 +547,10 @@ export default function ThreadPage({ params }: { params: Promise<ThreadParams> }
|
|||
|
||||
console.log('[PAGE] Refetched messages after stop:', unifiedMessages.length);
|
||||
setMessages(unifiedMessages);
|
||||
|
||||
// Clear auto-opened state to trigger tool detection with fresh messages
|
||||
setAutoOpenedPanel(false);
|
||||
|
||||
scrollToBottom('smooth');
|
||||
}
|
||||
} catch (err) {
|
||||
|
@ -594,6 +606,82 @@ export default function ThreadPage({ params }: { params: Promise<ThreadParams> }
|
|||
setFileViewerOpen(true);
|
||||
}, []);
|
||||
|
||||
// Automatically detect and populate tool calls from messages
|
||||
useEffect(() => {
|
||||
// Skip if we've already auto-opened the panel or if it's already open
|
||||
if (autoOpenedPanel || isSidePanelOpen) return;
|
||||
|
||||
const historicalToolPairs: ToolCallInput[] = [];
|
||||
const assistantMessages = messages.filter(m => m.type === 'assistant' && m.message_id);
|
||||
|
||||
if (assistantMessages.length === 0) return;
|
||||
|
||||
assistantMessages.forEach(assistantMsg => {
|
||||
const resultMessage = messages.find(toolMsg => {
|
||||
if (toolMsg.type !== 'tool' || !toolMsg.metadata || !assistantMsg.message_id) return false;
|
||||
try {
|
||||
const metadata = JSON.parse(toolMsg.metadata);
|
||||
return metadata.assistant_message_id === assistantMsg.message_id;
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
if (resultMessage) {
|
||||
// Determine tool name from assistant message content
|
||||
let toolName = 'unknown';
|
||||
try {
|
||||
// Try to extract tool name from content
|
||||
const xmlMatch = assistantMsg.content.match(/<([a-zA-Z\-_]+)(?:\s+[^>]*)?>|<([a-zA-Z\-_]+)(?:\s+[^>]*)?\/>/);
|
||||
if (xmlMatch) {
|
||||
toolName = xmlMatch[1] || xmlMatch[2] || 'unknown';
|
||||
} else {
|
||||
// Fallback to checking for tool_calls JSON structure
|
||||
const assistantContentParsed = safeJsonParse<{ tool_calls?: { name: string }[] }>(assistantMsg.content, {});
|
||||
if (assistantContentParsed.tool_calls && assistantContentParsed.tool_calls.length > 0) {
|
||||
toolName = assistantContentParsed.tool_calls[0].name || 'unknown';
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
|
||||
let isSuccess = true;
|
||||
try {
|
||||
const toolContent = resultMessage.content?.toLowerCase() || '';
|
||||
isSuccess = !(toolContent.includes('failed') ||
|
||||
toolContent.includes('error') ||
|
||||
toolContent.includes('failure'));
|
||||
} catch {}
|
||||
|
||||
historicalToolPairs.push({
|
||||
assistantCall: {
|
||||
name: toolName,
|
||||
content: assistantMsg.content,
|
||||
timestamp: assistantMsg.created_at
|
||||
},
|
||||
toolResult: {
|
||||
content: resultMessage.content,
|
||||
isSuccess: isSuccess,
|
||||
timestamp: resultMessage.created_at
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
if (historicalToolPairs.length > 0) {
|
||||
setToolCalls(historicalToolPairs);
|
||||
setCurrentToolIndex(historicalToolPairs.length - 1); // Set to the most recent tool call
|
||||
setIsSidePanelOpen(true);
|
||||
setAutoOpenedPanel(true);
|
||||
}
|
||||
}, [messages, autoOpenedPanel, isSidePanelOpen]);
|
||||
|
||||
// Reset auto-opened state when panel is closed
|
||||
useEffect(() => {
|
||||
if (!isSidePanelOpen) {
|
||||
setAutoOpenedPanel(false);
|
||||
}
|
||||
}, [isSidePanelOpen]);
|
||||
|
||||
const handleToolClick = useCallback((clickedAssistantMessageId: string | null, clickedToolName: string) => {
|
||||
if (!clickedAssistantMessageId) {
|
||||
console.warn("Clicked assistant message ID is null. Cannot open side panel.");
|
||||
|
@ -626,12 +714,17 @@ export default function ThreadPage({ params }: { params: Promise<ThreadParams> }
|
|||
// otherwise fallback to a generic name or the one passed from the click.
|
||||
let toolNameForResult = clickedToolName; // Fallback
|
||||
try {
|
||||
const assistantContentParsed = safeJsonParse<{ tool_calls?: { name: string }[] }>(assistantMsg.content, {});
|
||||
// A simple heuristic: if the assistant message content has tool_calls structure
|
||||
if (assistantContentParsed.tool_calls && assistantContentParsed.tool_calls.length > 0) {
|
||||
toolNameForResult = assistantContentParsed.tool_calls[0].name || clickedToolName;
|
||||
// Try to extract tool name from content
|
||||
const xmlMatch = assistantMsg.content.match(/<([a-zA-Z\-_]+)(?:\s+[^>]*)?>|<([a-zA-Z\-_]+)(?:\s+[^>]*)?\/>/);
|
||||
if (xmlMatch) {
|
||||
toolNameForResult = xmlMatch[1] || xmlMatch[2] || clickedToolName;
|
||||
} else {
|
||||
// Fallback to checking for tool_calls JSON structure
|
||||
const assistantContentParsed = safeJsonParse<{ tool_calls?: { name: string }[] }>(assistantMsg.content, {});
|
||||
if (assistantContentParsed.tool_calls && assistantContentParsed.tool_calls.length > 0) {
|
||||
toolNameForResult = assistantContentParsed.tool_calls[0].name || clickedToolName;
|
||||
}
|
||||
}
|
||||
// More advanced: parse the XML in assistant message content to find the tool name associated with this result
|
||||
} catch {}
|
||||
|
||||
let isSuccess = true;
|
||||
|
@ -681,6 +774,7 @@ export default function ThreadPage({ params }: { params: Promise<ThreadParams> }
|
|||
}
|
||||
|
||||
setIsSidePanelOpen(true);
|
||||
setAutoOpenedPanel(true);
|
||||
|
||||
}, [messages]);
|
||||
|
||||
|
@ -689,17 +783,54 @@ export default function ThreadPage({ params }: { params: Promise<ThreadParams> }
|
|||
if (!toolCall) return;
|
||||
console.log("[STREAM] Received tool call:", toolCall.name || toolCall.xml_tag_name);
|
||||
|
||||
// Now with Markdown support, we can enable the side panel for streaming calls
|
||||
// Create a properly formatted tool call input for the streaming tool
|
||||
// that matches the format of historical tool calls
|
||||
const toolName = toolCall.name || toolCall.xml_tag_name || 'Unknown Tool';
|
||||
const toolArguments = toolCall.arguments || '';
|
||||
|
||||
// Format the arguments in a way that matches the expected XML format for each tool
|
||||
// This ensures the specialized tool views render correctly
|
||||
let formattedContent = toolArguments;
|
||||
if (toolName.toLowerCase().includes('command') && !toolArguments.includes('<execute-command>')) {
|
||||
formattedContent = `<execute-command>${toolArguments}</execute-command>`;
|
||||
} else if (toolName.toLowerCase().includes('file') && !toolArguments.includes('<create-file>')) {
|
||||
// For file operations, wrap with appropriate tag if not already wrapped
|
||||
const fileOpTags = ['create-file', 'delete-file', 'full-file-rewrite'];
|
||||
const matchingTag = fileOpTags.find(tag => toolName.toLowerCase().includes(tag));
|
||||
if (matchingTag && !toolArguments.includes(`<${matchingTag}>`)) {
|
||||
formattedContent = `<${matchingTag}>${toolArguments}</${matchingTag}>`;
|
||||
}
|
||||
}
|
||||
|
||||
const newToolCall: ToolCallInput = {
|
||||
assistantCall: {
|
||||
name: toolCall.name || toolCall.xml_tag_name || 'Unknown Tool',
|
||||
content: toolCall.arguments || '',
|
||||
name: toolName,
|
||||
content: formattedContent,
|
||||
timestamp: new Date().toISOString()
|
||||
},
|
||||
// For streaming tool calls, provide empty content that indicates streaming
|
||||
toolResult: {
|
||||
content: "STREAMING",
|
||||
isSuccess: true,
|
||||
timestamp: new Date().toISOString()
|
||||
}
|
||||
// No toolResult available yet for streaming calls
|
||||
};
|
||||
|
||||
setToolCalls([newToolCall]);
|
||||
// Update the tool calls state to reflect the streaming tool
|
||||
setToolCalls(prev => {
|
||||
// If the same tool is already being streamed, update it instead of adding a new one
|
||||
if (prev.length > 0 && prev[0].assistantCall.name === toolName) {
|
||||
return [{
|
||||
...prev[0],
|
||||
assistantCall: {
|
||||
...prev[0].assistantCall,
|
||||
content: formattedContent
|
||||
}
|
||||
}];
|
||||
}
|
||||
return [newToolCall];
|
||||
});
|
||||
|
||||
setCurrentToolIndex(0);
|
||||
setIsSidePanelOpen(true);
|
||||
}, []);
|
||||
|
|
|
@ -149,6 +149,8 @@ interface ToolCallSidePanelProps {
|
|||
currentIndex: number;
|
||||
onNavigate: (newIndex: number) => void;
|
||||
project?: Project;
|
||||
renderAssistantMessage?: (assistantContent?: string, toolContent?: string) => React.ReactNode;
|
||||
renderToolResult?: (toolContent?: string, isSuccess?: boolean) => React.ReactNode;
|
||||
}
|
||||
|
||||
export function ToolCallSidePanel({
|
||||
|
@ -157,7 +159,9 @@ export function ToolCallSidePanel({
|
|||
toolCalls,
|
||||
currentIndex,
|
||||
onNavigate,
|
||||
project
|
||||
project,
|
||||
renderAssistantMessage,
|
||||
renderToolResult
|
||||
}: ToolCallSidePanelProps) {
|
||||
if (!isOpen) return null;
|
||||
|
||||
|
@ -166,6 +170,26 @@ export function ToolCallSidePanel({
|
|||
const currentToolName = currentToolCall?.assistantCall?.name || 'Tool Call';
|
||||
const CurrentToolIcon = getToolIcon(currentToolName === 'Tool Call' ? 'unknown' : currentToolName);
|
||||
|
||||
// Determine if this is a streaming tool call
|
||||
const isStreaming = currentToolCall?.toolResult?.content === "STREAMING";
|
||||
|
||||
// Set up a pulse animation for streaming
|
||||
const [dots, setDots] = React.useState('');
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!isStreaming) return;
|
||||
|
||||
// Create a loading animation with dots
|
||||
const interval = setInterval(() => {
|
||||
setDots(prev => {
|
||||
if (prev === '...') return '';
|
||||
return prev + '.';
|
||||
});
|
||||
}, 500);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [isStreaming]);
|
||||
|
||||
const renderContent = () => {
|
||||
if (!currentToolCall) {
|
||||
return (
|
||||
|
@ -182,7 +206,7 @@ export function ToolCallSidePanel({
|
|||
currentToolCall.toolResult?.content,
|
||||
currentToolCall.assistantCall.timestamp,
|
||||
currentToolCall.toolResult?.timestamp,
|
||||
currentToolCall.toolResult?.isSuccess ?? true,
|
||||
isStreaming ? true : (currentToolCall.toolResult?.isSuccess ?? true),
|
||||
project
|
||||
);
|
||||
};
|
||||
|
@ -190,7 +214,11 @@ export function ToolCallSidePanel({
|
|||
return (
|
||||
<div className="fixed inset-y-0 right-0 w-[90%] sm:w-[450px] md:w-[500px] lg:w-[550px] xl:w-[600px] bg-background border-l flex flex-col z-10">
|
||||
<div className="p-4 flex items-center justify-between">
|
||||
<h3 className="text-sm font-semibold">Suna's Computer</h3>
|
||||
<h3 className="text-sm font-semibold">
|
||||
{isStreaming
|
||||
? `Suna's Computer (Running${dots})`
|
||||
: "Suna's Computer"}
|
||||
</h3>
|
||||
<Button variant="ghost" size="icon" onClick={onClose} className="text-muted-foreground hover:text-foreground">
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
|
@ -204,7 +232,7 @@ export function ToolCallSidePanel({
|
|||
<div className="flex items-center gap-2 min-w-0">
|
||||
<CurrentToolIcon className="h-4 w-4 text-muted-foreground flex-shrink-0" />
|
||||
<span className="text-xs font-medium text-foreground truncate" title={currentToolName}>
|
||||
{currentToolName}
|
||||
{currentToolName} {isStreaming && `(Running${dots})`}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground flex-shrink-0">
|
||||
|
|
|
@ -2,6 +2,8 @@ import React from "react";
|
|||
import { ToolViewProps } from "./types";
|
||||
import { formatTimestamp } from "./utils";
|
||||
import { getToolIcon } from "../utils";
|
||||
import { CircleDashed } from "lucide-react";
|
||||
import { Markdown } from "@/components/home/ui/markdown";
|
||||
|
||||
export function GenericToolView({
|
||||
name,
|
||||
|
@ -12,6 +14,23 @@ export function GenericToolView({
|
|||
toolTimestamp
|
||||
}: ToolViewProps & { name?: string }) {
|
||||
const toolName = name || 'Unknown Tool';
|
||||
const isStreaming = toolContent === "STREAMING";
|
||||
|
||||
// Parse the assistant content to extract tool parameters
|
||||
const parsedContent = React.useMemo(() => {
|
||||
if (!assistantContent) return null;
|
||||
|
||||
// Try to extract content from XML tags
|
||||
const xmlMatch = assistantContent.match(/<([a-zA-Z\-_]+)(?:\s+[^>]*)?>([^<]*)<\/\1>/);
|
||||
if (xmlMatch) {
|
||||
return {
|
||||
tag: xmlMatch[1],
|
||||
content: xmlMatch[2].trim()
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}, [assistantContent]);
|
||||
|
||||
return (
|
||||
<div className="space-y-4 p-4">
|
||||
|
@ -25,39 +44,86 @@ export function GenericToolView({
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{toolContent && (
|
||||
{toolContent && !isStreaming && (
|
||||
<div className={`px-2 py-1 rounded-full text-xs ${
|
||||
isSuccess ? 'bg-green-50 text-green-700' : 'bg-red-50 text-red-700'
|
||||
isSuccess ? 'bg-green-50 text-green-700 dark:bg-green-900 dark:text-green-300'
|
||||
: 'bg-red-50 text-red-700 dark:bg-red-900 dark:text-red-300'
|
||||
}`}>
|
||||
{isSuccess ? 'Success' : 'Failed'}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isStreaming && (
|
||||
<div className="px-2 py-1 rounded-full text-xs bg-blue-50 text-blue-700 dark:bg-blue-900 dark:text-blue-300 flex items-center gap-1">
|
||||
<CircleDashed className="h-3 w-3 animate-spin" />
|
||||
<span>Running</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Assistant Message */}
|
||||
<div className="space-y-1">
|
||||
<div className="flex justify-between items-center">
|
||||
<div className="text-xs font-medium text-muted-foreground">Assistant Message</div>
|
||||
{assistantTimestamp && (
|
||||
<div className="text-xs text-muted-foreground">{formatTimestamp(assistantTimestamp)}</div>
|
||||
)}
|
||||
{/* Tool Parameters */}
|
||||
{parsedContent && (
|
||||
<div className="space-y-1">
|
||||
<div className="flex justify-between items-center">
|
||||
<div className="text-xs font-medium text-muted-foreground">Tool Parameters</div>
|
||||
{assistantTimestamp && (
|
||||
<div className="text-xs text-muted-foreground">{formatTimestamp(assistantTimestamp)}</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="rounded-md border bg-muted/50 p-3">
|
||||
{parsedContent.content.startsWith('{') ? (
|
||||
// If content looks like JSON, render it prettified
|
||||
<pre className="text-xs overflow-auto whitespace-pre-wrap break-words">
|
||||
{JSON.stringify(JSON.parse(parsedContent.content), null, 2)}
|
||||
</pre>
|
||||
) : (
|
||||
// Otherwise render as Markdown
|
||||
<Markdown className="text-xs prose prose-xs dark:prose-invert max-w-none">
|
||||
{parsedContent.content}
|
||||
</Markdown>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-md border bg-muted/50 p-3">
|
||||
<pre className="text-xs overflow-auto whitespace-pre-wrap break-words">{assistantContent}</pre>
|
||||
)}
|
||||
|
||||
{/* Show original assistant content if couldn't parse properly or for debugging */}
|
||||
{assistantContent && !parsedContent && !isStreaming && (
|
||||
<div className="space-y-1">
|
||||
<div className="flex justify-between items-center">
|
||||
<div className="text-xs font-medium text-muted-foreground">Assistant Message</div>
|
||||
{assistantTimestamp && (
|
||||
<div className="text-xs text-muted-foreground">{formatTimestamp(assistantTimestamp)}</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="rounded-md border bg-muted/50 p-3">
|
||||
<pre className="text-xs overflow-auto whitespace-pre-wrap break-words">{assistantContent}</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tool Result */}
|
||||
{toolContent && (
|
||||
<div className="space-y-1">
|
||||
<div className="flex justify-between items-center">
|
||||
<div className="text-xs font-medium text-muted-foreground">Tool Result</div>
|
||||
{toolTimestamp && (
|
||||
<div className="text-xs font-medium text-muted-foreground">
|
||||
{isStreaming ? "Tool Execution" : "Tool Result"}
|
||||
</div>
|
||||
{toolTimestamp && !isStreaming && (
|
||||
<div className="text-xs text-muted-foreground">{formatTimestamp(toolTimestamp)}</div>
|
||||
)}
|
||||
</div>
|
||||
<div className={`rounded-md border p-3 ${isSuccess ? 'bg-muted/50' : 'bg-red-50'}`}>
|
||||
<pre className="text-xs overflow-auto whitespace-pre-wrap break-words">{toolContent}</pre>
|
||||
<div className={`rounded-md border p-3 ${
|
||||
isStreaming ? 'bg-blue-50/30 dark:bg-blue-900/20' :
|
||||
(isSuccess ? 'bg-muted/50' : 'bg-red-50/30 dark:bg-red-900/20')
|
||||
}`}>
|
||||
{isStreaming ? (
|
||||
<div className="flex items-center gap-2 text-xs text-blue-700 dark:text-blue-300">
|
||||
<CircleDashed className="h-3 w-3 animate-spin" />
|
||||
<span>Executing {toolName.toLowerCase()}...</span>
|
||||
</div>
|
||||
) : (
|
||||
<pre className="text-xs overflow-auto whitespace-pre-wrap break-words">{toolContent}</pre>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
|
Loading…
Reference in New Issue