|
|
import React, { useRef, useState, useCallback } from 'react'; |
|
|
import { ArrowDown, CircleDashed, CheckCircle, AlertTriangle } from 'lucide-react'; |
|
|
import { Button } from '@/components/ui/button'; |
|
|
import { Markdown } from '@/components/ui/markdown'; |
|
|
import { UnifiedMessage, ParsedContent, ParsedMetadata } from '@/components/thread/types'; |
|
|
import { FileAttachmentGrid } from '@/components/thread/file-attachment'; |
|
|
import { useFilePreloader, FileCache } from '@/hooks/react-query/files'; |
|
|
import { useAuth } from '@/components/AuthProvider'; |
|
|
import { Project } from '@/lib/api'; |
|
|
import { |
|
|
extractPrimaryParam, |
|
|
getToolIcon, |
|
|
getUserFriendlyToolName, |
|
|
safeJsonParse, |
|
|
} from '@/components/thread/utils'; |
|
|
import { formatMCPToolDisplayName } from '@/components/thread/tool-views/mcp-tool/_utils'; |
|
|
import { KortixLogo } from '@/components/sidebar/kortix-logo'; |
|
|
import { AgentLoader } from './loader'; |
|
|
import { parseXmlToolCalls, isNewXmlFormat, extractToolNameFromStream } from '@/components/thread/tool-views/xml-parser'; |
|
|
import { parseToolResult } from '@/components/thread/tool-views/tool-result-parser'; |
|
|
|
|
|
|
|
|
const HIDE_STREAMING_XML_TAGS = new Set([ |
|
|
'execute-command', |
|
|
'create-file', |
|
|
'delete-file', |
|
|
'full-file-rewrite', |
|
|
'str-replace', |
|
|
'browser-click-element', |
|
|
'browser-close-tab', |
|
|
'browser-drag-drop', |
|
|
'browser-get-dropdown-options', |
|
|
'browser-go-back', |
|
|
'browser-input-text', |
|
|
'browser-navigate-to', |
|
|
'browser-scroll-down', |
|
|
'browser-scroll-to-text', |
|
|
'browser-scroll-up', |
|
|
'browser-select-dropdown-option', |
|
|
'browser-send-keys', |
|
|
'browser-switch-tab', |
|
|
'browser-wait', |
|
|
'deploy', |
|
|
'ask', |
|
|
'complete', |
|
|
'crawl-webpage', |
|
|
'web-search', |
|
|
'see-image', |
|
|
'call-mcp-tool', |
|
|
|
|
|
'execute_data_provider_call', |
|
|
'execute_data_provider_endpoint', |
|
|
|
|
|
'execute-data-provider-call', |
|
|
'execute-data-provider-endpoint', |
|
|
]); |
|
|
|
|
|
function getEnhancedToolDisplayName(toolName: string, rawXml?: string): string { |
|
|
if (toolName === 'call-mcp-tool' && rawXml) { |
|
|
const toolNameMatch = rawXml.match(/tool_name="([^"]+)"/); |
|
|
if (toolNameMatch) { |
|
|
const fullToolName = toolNameMatch[1]; |
|
|
const parts = fullToolName.split('_'); |
|
|
if (parts.length >= 3 && fullToolName.startsWith('mcp_')) { |
|
|
const serverName = parts[1]; |
|
|
const toolNamePart = parts.slice(2).join('_'); |
|
|
return formatMCPToolDisplayName(serverName, toolNamePart); |
|
|
} |
|
|
} |
|
|
} |
|
|
return getUserFriendlyToolName(toolName); |
|
|
} |
|
|
|
|
|
|
|
|
export function renderAttachments(attachments: string[], fileViewerHandler?: (filePath?: string, filePathList?: string[]) => void, sandboxId?: string, project?: Project) { |
|
|
if (!attachments || attachments.length === 0) return null; |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
return <FileAttachmentGrid |
|
|
attachments={attachments} |
|
|
onFileClick={fileViewerHandler} |
|
|
showPreviews={true} |
|
|
sandboxId={sandboxId} |
|
|
project={project} |
|
|
/>; |
|
|
} |
|
|
|
|
|
|
|
|
export function renderMarkdownContent( |
|
|
content: string, |
|
|
handleToolClick: (assistantMessageId: string | null, toolName: string) => void, |
|
|
messageId: string | null, |
|
|
fileViewerHandler?: (filePath?: string, filePathList?: string[]) => void, |
|
|
sandboxId?: string, |
|
|
project?: Project, |
|
|
debugMode?: boolean |
|
|
) { |
|
|
|
|
|
if (debugMode) { |
|
|
return ( |
|
|
<pre className="text-xs font-mono whitespace-pre-wrap overflow-x-auto p-2 border border-border rounded-md bg-muted/30 text-foreground"> |
|
|
{content} |
|
|
</pre> |
|
|
); |
|
|
} |
|
|
|
|
|
|
|
|
if (isNewXmlFormat(content)) { |
|
|
const contentParts: React.ReactNode[] = []; |
|
|
let lastIndex = 0; |
|
|
|
|
|
|
|
|
const functionCallsRegex = /<function_calls>([\s\S]*?)<\/function_calls>/gi; |
|
|
let match: RegExpExecArray | null = null; |
|
|
|
|
|
while ((match = functionCallsRegex.exec(content)) !== null) { |
|
|
|
|
|
if (match.index > lastIndex) { |
|
|
const textBeforeBlock = content.substring(lastIndex, match.index); |
|
|
if (textBeforeBlock.trim()) { |
|
|
contentParts.push( |
|
|
<Markdown key={`md-${lastIndex}`} className="text-sm prose prose-sm dark:prose-invert chat-markdown max-w-none break-words"> |
|
|
{textBeforeBlock} |
|
|
</Markdown> |
|
|
); |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
const toolCalls = parseXmlToolCalls(match[0]); |
|
|
|
|
|
toolCalls.forEach((toolCall, index) => { |
|
|
const toolName = toolCall.functionName.replace(/_/g, '-'); |
|
|
|
|
|
if (toolName === 'ask') { |
|
|
|
|
|
const askText = toolCall.parameters.text || ''; |
|
|
const attachments = toolCall.parameters.attachments || []; |
|
|
|
|
|
|
|
|
const attachmentArray = Array.isArray(attachments) ? attachments : |
|
|
(typeof attachments === 'string' ? attachments.split(',').map(a => a.trim()) : []); |
|
|
|
|
|
|
|
|
contentParts.push( |
|
|
<div key={`ask-${match.index}-${index}`} className="space-y-3"> |
|
|
<Markdown className="text-sm prose prose-sm dark:prose-invert chat-markdown max-w-none break-words [&>:first-child]:mt-0 prose-headings:mt-3">{askText}</Markdown> |
|
|
{renderAttachments(attachmentArray, fileViewerHandler, sandboxId, project)} |
|
|
</div> |
|
|
); |
|
|
} else { |
|
|
const IconComponent = getToolIcon(toolName); |
|
|
|
|
|
|
|
|
let paramDisplay = ''; |
|
|
if (toolCall.parameters.file_path) { |
|
|
paramDisplay = toolCall.parameters.file_path; |
|
|
} else if (toolCall.parameters.command) { |
|
|
paramDisplay = toolCall.parameters.command; |
|
|
} else if (toolCall.parameters.query) { |
|
|
paramDisplay = toolCall.parameters.query; |
|
|
} else if (toolCall.parameters.url) { |
|
|
paramDisplay = toolCall.parameters.url; |
|
|
} |
|
|
|
|
|
contentParts.push( |
|
|
<div key={`tool-${match.index}-${index}`} className="my-1"> |
|
|
<button |
|
|
onClick={() => handleToolClick(messageId, toolName)} |
|
|
className="inline-flex items-center gap-1.5 py-1 px-1 pr-1.5 text-xs text-muted-foreground bg-muted hover:bg-muted/80 rounded-lg transition-colors cursor-pointer border border-neutral-200 dark:border-neutral-700/50" |
|
|
> |
|
|
<div className='border-2 bg-gradient-to-br from-neutral-200 to-neutral-300 dark:from-neutral-700 dark:to-neutral-800 flex items-center justify-center p-0.5 rounded-sm border-neutral-400/20 dark:border-neutral-600'> |
|
|
<IconComponent className="h-3.5 w-3.5 text-muted-foreground flex-shrink-0" /> |
|
|
</div> |
|
|
<span className="font-mono text-xs text-foreground">{getUserFriendlyToolName(toolName)}</span> |
|
|
{paramDisplay && <span className="ml-1 text-muted-foreground truncate max-w-[200px]" title={paramDisplay}>{paramDisplay}</span>} |
|
|
</button> |
|
|
</div> |
|
|
); |
|
|
} |
|
|
}); |
|
|
|
|
|
lastIndex = match.index + match[0].length; |
|
|
} |
|
|
|
|
|
|
|
|
if (lastIndex < content.length) { |
|
|
const remainingText = content.substring(lastIndex); |
|
|
if (remainingText.trim()) { |
|
|
contentParts.push( |
|
|
<Markdown key={`md-${lastIndex}`} className="text-sm prose prose-sm dark:prose-invert chat-markdown max-w-none break-words"> |
|
|
{remainingText} |
|
|
</Markdown> |
|
|
); |
|
|
} |
|
|
} |
|
|
|
|
|
return contentParts.length > 0 ? contentParts : <Markdown className="text-sm prose prose-sm dark:prose-invert chat-markdown max-w-none break-words">{content}</Markdown>; |
|
|
} |
|
|
|
|
|
|
|
|
const xmlRegex = /<(?!inform\b)([a-zA-Z\-_]+)(?:\s+[^>]*)?>(?:[\s\S]*?)<\/\1>|<(?!inform\b)([a-zA-Z\-_]+)(?:\s+[^>]*)?\/>/g; |
|
|
let lastIndex = 0; |
|
|
const contentParts: React.ReactNode[] = []; |
|
|
let match: RegExpExecArray | null = null; |
|
|
|
|
|
|
|
|
if (!content.match(xmlRegex)) { |
|
|
return <Markdown className="text-sm prose prose-sm dark:prose-invert chat-markdown max-w-none break-words">{content}</Markdown>; |
|
|
} |
|
|
|
|
|
while ((match = xmlRegex.exec(content)) !== null) { |
|
|
|
|
|
if (match.index > lastIndex) { |
|
|
const textBeforeTag = content.substring(lastIndex, match.index); |
|
|
contentParts.push( |
|
|
<Markdown key={`md-${lastIndex}`} className="text-sm prose prose-sm dark:prose-invert chat-markdown max-w-none inline-block mr-1 break-words">{textBeforeTag}</Markdown> |
|
|
); |
|
|
} |
|
|
|
|
|
const rawXml = match[0]; |
|
|
const toolName = match[1] || match[2]; |
|
|
const toolCallKey = `tool-${match.index}`; |
|
|
|
|
|
if (toolName === 'ask') { |
|
|
|
|
|
const attachmentsMatch = rawXml.match(/attachments=["']([^"']*)["']/i); |
|
|
const attachments = attachmentsMatch |
|
|
? attachmentsMatch[1].split(',').map(a => a.trim()) |
|
|
: []; |
|
|
|
|
|
|
|
|
const contentMatch = rawXml.match(/<ask[^>]*>([\s\S]*?)<\/ask>/i); |
|
|
const askContent = contentMatch ? contentMatch[1] : ''; |
|
|
|
|
|
|
|
|
contentParts.push( |
|
|
<div key={`ask-${match.index}`} className="space-y-3"> |
|
|
<Markdown className="text-sm prose prose-sm dark:prose-invert chat-markdown max-w-none break-words [&>:first-child]:mt-0 prose-headings:mt-3">{askContent}</Markdown> |
|
|
{renderAttachments(attachments, fileViewerHandler, sandboxId, project)} |
|
|
</div> |
|
|
); |
|
|
} else { |
|
|
const IconComponent = getToolIcon(toolName); |
|
|
const paramDisplay = extractPrimaryParam(toolName, rawXml); |
|
|
|
|
|
|
|
|
contentParts.push( |
|
|
<div key={toolCallKey} className="my-1"> |
|
|
<button |
|
|
onClick={() => handleToolClick(messageId, toolName)} |
|
|
className="inline-flex items-center gap-1.5 py-1 px-1 pr-1.5 text-xs text-muted-foreground bg-muted hover:bg-muted/80 rounded-lg transition-colors cursor-pointer border border-neutral-200 dark:border-neutral-700/50" |
|
|
> |
|
|
<div className='border-2 bg-gradient-to-br from-neutral-200 to-neutral-300 dark:from-neutral-700 dark:to-neutral-800 flex items-center justify-center p-0.5 rounded-sm border-neutral-400/20 dark:border-neutral-600'> |
|
|
<IconComponent className="h-3.5 w-3.5 text-muted-foreground flex-shrink-0" /> |
|
|
</div> |
|
|
<span className="font-mono text-xs text-foreground">{getUserFriendlyToolName(toolName)}</span> |
|
|
{paramDisplay && <span className="ml-1 text-muted-foreground truncate max-w-[200px]" title={paramDisplay}>{paramDisplay}</span>} |
|
|
</button> |
|
|
</div> |
|
|
); |
|
|
} |
|
|
lastIndex = xmlRegex.lastIndex; |
|
|
} |
|
|
|
|
|
|
|
|
if (lastIndex < content.length) { |
|
|
contentParts.push( |
|
|
<Markdown key={`md-${lastIndex}`} className="text-sm prose prose-sm dark:prose-invert chat-markdown max-w-none break-words">{content.substring(lastIndex)}</Markdown> |
|
|
); |
|
|
} |
|
|
|
|
|
return contentParts; |
|
|
} |
|
|
|
|
|
export interface ThreadContentProps { |
|
|
messages: UnifiedMessage[]; |
|
|
streamingTextContent?: string; |
|
|
streamingToolCall?: any; |
|
|
agentStatus: 'idle' | 'running' | 'connecting' | 'error'; |
|
|
handleToolClick: (assistantMessageId: string | null, toolName: string) => void; |
|
|
handleOpenFileViewer: (filePath?: string, filePathList?: string[]) => void; |
|
|
readOnly?: boolean; |
|
|
visibleMessages?: UnifiedMessage[]; |
|
|
streamingText?: string; |
|
|
isStreamingText?: boolean; |
|
|
currentToolCall?: any; |
|
|
streamHookStatus?: string; |
|
|
sandboxId?: string; |
|
|
project?: Project; |
|
|
debugMode?: boolean; |
|
|
isPreviewMode?: boolean; |
|
|
agentName?: string; |
|
|
agentAvatar?: React.ReactNode; |
|
|
emptyStateComponent?: React.ReactNode; |
|
|
} |
|
|
|
|
|
export const ThreadContent: React.FC<ThreadContentProps> = ({ |
|
|
messages, |
|
|
streamingTextContent = "", |
|
|
streamingToolCall, |
|
|
agentStatus, |
|
|
handleToolClick, |
|
|
handleOpenFileViewer, |
|
|
readOnly = false, |
|
|
visibleMessages, |
|
|
streamingText = "", |
|
|
isStreamingText = false, |
|
|
currentToolCall, |
|
|
streamHookStatus = "idle", |
|
|
sandboxId, |
|
|
project, |
|
|
debugMode = false, |
|
|
isPreviewMode = false, |
|
|
agentName = 'Suna', |
|
|
agentAvatar = <KortixLogo size={16} />, |
|
|
emptyStateComponent, |
|
|
}) => { |
|
|
const messagesEndRef = useRef<HTMLDivElement>(null); |
|
|
const messagesContainerRef = useRef<HTMLDivElement>(null); |
|
|
const latestMessageRef = useRef<HTMLDivElement>(null); |
|
|
const [showScrollButton, setShowScrollButton] = useState(false); |
|
|
const [, setUserHasScrolled] = useState(false); |
|
|
const { session } = useAuth(); |
|
|
|
|
|
|
|
|
const { preloadFiles } = useFilePreloader(); |
|
|
|
|
|
const containerClassName = isPreviewMode |
|
|
? "flex-1 overflow-y-auto scrollbar-thin scrollbar-track-secondary/0 scrollbar-thumb-primary/10 scrollbar-thumb-rounded-full hover:scrollbar-thumb-primary/10 px-6 py-4 pb-72" |
|
|
: "flex-1 overflow-y-auto scrollbar-thin scrollbar-track-secondary/0 scrollbar-thumb-primary/10 scrollbar-thumb-rounded-full hover:scrollbar-thumb-primary/10 px-6 py-4 pb-72 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60"; |
|
|
|
|
|
|
|
|
const displayMessages = readOnly && visibleMessages ? visibleMessages : messages; |
|
|
|
|
|
const handleScroll = () => { |
|
|
if (!messagesContainerRef.current) return; |
|
|
const { scrollTop, scrollHeight, clientHeight } = messagesContainerRef.current; |
|
|
const isScrolledUp = scrollHeight - scrollTop - clientHeight > 100; |
|
|
setShowScrollButton(isScrolledUp); |
|
|
setUserHasScrolled(isScrolledUp); |
|
|
}; |
|
|
|
|
|
const scrollToBottom = useCallback((behavior: ScrollBehavior = 'smooth') => { |
|
|
messagesEndRef.current?.scrollIntoView({ behavior }); |
|
|
}, []); |
|
|
|
|
|
|
|
|
React.useEffect(() => { |
|
|
if (!sandboxId) return; |
|
|
|
|
|
|
|
|
const allAttachments: string[] = []; |
|
|
|
|
|
displayMessages.forEach(message => { |
|
|
if (message.type === 'user') { |
|
|
try { |
|
|
const content = typeof message.content === 'string' ? message.content : ''; |
|
|
const attachmentsMatch = content.match(/\[Uploaded File: (.*?)\]/g); |
|
|
if (attachmentsMatch) { |
|
|
attachmentsMatch.forEach(match => { |
|
|
const pathMatch = match.match(/\[Uploaded File: (.*?)\]/); |
|
|
if (pathMatch && pathMatch[1]) { |
|
|
allAttachments.push(pathMatch[1]); |
|
|
} |
|
|
}); |
|
|
} |
|
|
} catch (e) { |
|
|
console.error('Error parsing message attachments:', e); |
|
|
} |
|
|
} |
|
|
}); |
|
|
|
|
|
|
|
|
if (allAttachments.length > 0 && session?.access_token) { |
|
|
|
|
|
preloadFiles(sandboxId, allAttachments).catch(err => { |
|
|
console.error('React Query preload failed:', err); |
|
|
}); |
|
|
} |
|
|
}, [displayMessages, sandboxId, session?.access_token, preloadFiles]); |
|
|
|
|
|
return ( |
|
|
<> |
|
|
{displayMessages.length === 0 && !streamingTextContent && !streamingToolCall && |
|
|
!streamingText && !currentToolCall && agentStatus === 'idle' ? ( |
|
|
// Render empty state outside scrollable container |
|
|
<div className="flex-1 min-h-[60vh] flex items-center justify-center"> |
|
|
{emptyStateComponent || ( |
|
|
<div className="text-center text-muted-foreground"> |
|
|
{readOnly ? "No messages to display." : "Send a message to start."} |
|
|
</div> |
|
|
)} |
|
|
</div> |
|
|
) : ( |
|
|
// Render scrollable content container |
|
|
<div |
|
|
ref={messagesContainerRef} |
|
|
className={containerClassName} |
|
|
onScroll={handleScroll} |
|
|
> |
|
|
<div className="mx-auto max-w-3xl md:px-8 min-w-0"> |
|
|
<div className="space-y-8 min-w-0"> |
|
|
{(() => { |
|
|
|
|
|
type MessageGroup = { |
|
|
type: 'user' | 'assistant_group'; |
|
|
messages: UnifiedMessage[]; |
|
|
key: string; |
|
|
}; |
|
|
const groupedMessages: MessageGroup[] = []; |
|
|
let currentGroup: MessageGroup | null = null; |
|
|
let assistantGroupCounter = 0; // Counter for assistant groups |
|
|
|
|
|
displayMessages.forEach((message, index) => { |
|
|
const messageType = message.type; |
|
|
const key = message.message_id || `msg-${index}`; |
|
|
|
|
|
if (messageType === 'user') { |
|
|
// Finalize any existing assistant group |
|
|
if (currentGroup) { |
|
|
groupedMessages.push(currentGroup); |
|
|
currentGroup = null; |
|
|
} |
|
|
// Create a new user message group |
|
|
groupedMessages.push({ type: 'user', messages: [message], key }); |
|
|
} else if (messageType === 'assistant' || messageType === 'tool' || messageType === 'browser_state') { |
|
|
// Check if we can add to existing assistant group (same agent) |
|
|
const canAddToExistingGroup = currentGroup && |
|
|
currentGroup.type === 'assistant_group' && |
|
|
(() => { |
|
|
// For assistant messages, check if agent matches |
|
|
if (messageType === 'assistant') { |
|
|
const lastAssistantMsg = currentGroup.messages.findLast(m => m.type === 'assistant'); |
|
|
if (!lastAssistantMsg) return true; // No assistant message yet, can add |
|
|
|
|
|
// Compare agent info - both null/undefined should be treated as same (default agent) |
|
|
const currentAgentId = message.agent_id; |
|
|
const lastAgentId = lastAssistantMsg.agent_id; |
|
|
return currentAgentId === lastAgentId; |
|
|
} |
|
|
// For tool/browser_state messages, always add to current group |
|
|
return true; |
|
|
})(); |
|
|
|
|
|
if (canAddToExistingGroup) { |
|
|
// Add to existing assistant group |
|
|
currentGroup?.messages.push(message); |
|
|
} else { |
|
|
// Finalize any existing group |
|
|
if (currentGroup) { |
|
|
groupedMessages.push(currentGroup); |
|
|
} |
|
|
// Create a new assistant group with a group-level key |
|
|
assistantGroupCounter++; |
|
|
currentGroup = { |
|
|
type: 'assistant_group', |
|
|
messages: [message], |
|
|
key: `assistant-group-${assistantGroupCounter}` |
|
|
}; |
|
|
} |
|
|
} else if (messageType !== 'status') { |
|
|
// For any other message types, finalize current group |
|
|
if (currentGroup) { |
|
|
groupedMessages.push(currentGroup); |
|
|
currentGroup = null; |
|
|
} |
|
|
} |
|
|
}); |
|
|
|
|
|
// Finalize any remaining group |
|
|
if (currentGroup) { |
|
|
groupedMessages.push(currentGroup); |
|
|
} |
|
|
|
|
|
// Merge consecutive assistant groups |
|
|
const mergedGroups: MessageGroup[] = []; |
|
|
let currentMergedGroup: MessageGroup | null = null; |
|
|
|
|
|
groupedMessages.forEach((group) => { |
|
|
if (group.type === 'assistant_group') { |
|
|
if (currentMergedGroup && currentMergedGroup.type === 'assistant_group') { |
|
|
// Merge with the current group |
|
|
currentMergedGroup.messages.push(...group.messages); |
|
|
} else { |
|
|
// Finalize previous group if it exists |
|
|
if (currentMergedGroup) { |
|
|
mergedGroups.push(currentMergedGroup); |
|
|
} |
|
|
// Start new merged group |
|
|
currentMergedGroup = { ...group }; |
|
|
} |
|
|
} else { |
|
|
// Finalize current merged group if it exists |
|
|
if (currentMergedGroup) { |
|
|
mergedGroups.push(currentMergedGroup); |
|
|
currentMergedGroup = null; |
|
|
} |
|
|
// Add non-assistant group as-is |
|
|
mergedGroups.push(group); |
|
|
} |
|
|
}); |
|
|
|
|
|
// Finalize any remaining merged group |
|
|
if (currentMergedGroup) { |
|
|
mergedGroups.push(currentMergedGroup); |
|
|
} |
|
|
|
|
|
// Use merged groups instead of original grouped messages |
|
|
const finalGroupedMessages = mergedGroups; |
|
|
|
|
|
// Handle streaming content - only add to existing group or create new one if needed |
|
|
if (streamingTextContent) { |
|
|
const lastGroup = finalGroupedMessages.at(-1); |
|
|
if (!lastGroup || lastGroup.type === 'user') { |
|
|
// Create new assistant group for streaming content |
|
|
assistantGroupCounter++; |
|
|
finalGroupedMessages.push({ |
|
|
type: 'assistant_group', |
|
|
messages: [{ |
|
|
content: streamingTextContent, |
|
|
type: 'assistant', |
|
|
message_id: 'streamingTextContent', |
|
|
metadata: 'streamingTextContent', |
|
|
created_at: new Date().toISOString(), |
|
|
updated_at: new Date().toISOString(), |
|
|
is_llm_message: true, |
|
|
thread_id: 'streamingTextContent', |
|
|
sequence: Infinity, |
|
|
}], |
|
|
key: `assistant-group-${assistantGroupCounter}-streaming` |
|
|
}); |
|
|
} else if (lastGroup.type === 'assistant_group') { |
|
|
// Only add streaming content if it's not already represented in the last message |
|
|
const lastMessage = lastGroup.messages[lastGroup.messages.length - 1]; |
|
|
if (lastMessage.message_id !== 'streamingTextContent') { |
|
|
lastGroup.messages.push({ |
|
|
content: streamingTextContent, |
|
|
type: 'assistant', |
|
|
message_id: 'streamingTextContent', |
|
|
metadata: 'streamingTextContent', |
|
|
created_at: new Date().toISOString(), |
|
|
updated_at: new Date().toISOString(), |
|
|
is_llm_message: true, |
|
|
thread_id: 'streamingTextContent', |
|
|
sequence: Infinity, |
|
|
}); |
|
|
} |
|
|
} |
|
|
} |
|
|
|
|
|
return finalGroupedMessages.map((group, groupIndex) => { |
|
|
if (group.type === 'user') { |
|
|
const message = group.messages[0]; |
|
|
const messageContent = (() => { |
|
|
try { |
|
|
const parsed = safeJsonParse<ParsedContent>(message.content, { content: message.content }); |
|
|
return parsed.content || message.content; |
|
|
} catch { |
|
|
return message.content; |
|
|
} |
|
|
})(); |
|
|
|
|
|
// In debug mode, display raw message content |
|
|
if (debugMode) { |
|
|
return ( |
|
|
<div key={group.key} className="flex justify-end"> |
|
|
<div className="flex max-w-[85%] rounded-2xl bg-card px-4 py-3 break-words overflow-hidden"> |
|
|
<pre className="text-xs font-mono whitespace-pre-wrap overflow-x-auto min-w-0 flex-1"> |
|
|
{message.content} |
|
|
</pre> |
|
|
</div> |
|
|
</div> |
|
|
); |
|
|
} |
|
|
|
|
|
// Extract attachments from the message content |
|
|
const attachmentsMatch = messageContent.match(/\[Uploaded File: (.*?)\]/g); |
|
|
const attachments = attachmentsMatch |
|
|
? attachmentsMatch.map((match: string) => { |
|
|
const pathMatch = match.match(/\[Uploaded File: (.*?)\]/); |
|
|
return pathMatch ? pathMatch[1] : null; |
|
|
}).filter(Boolean) |
|
|
: []; |
|
|
|
|
|
// Remove attachment info from the message content |
|
|
const cleanContent = messageContent.replace(/\[Uploaded File: .*?\]/g, '').trim(); |
|
|
|
|
|
return ( |
|
|
<div key={group.key} className="flex justify-end"> |
|
|
<div className="flex max-w-[85%] rounded-3xl rounded-br-lg bg-card border px-4 py-3 break-words overflow-hidden"> |
|
|
<div className="space-y-3 min-w-0 flex-1"> |
|
|
{cleanContent && ( |
|
|
<Markdown className="text-sm prose prose-sm dark:prose-invert chat-markdown max-w-none [&>:first-child]:mt-0 prose-headings:mt-3 break-words overflow-wrap-anywhere">{cleanContent}</Markdown> |
|
|
)} |
|
|
|
|
|
{/* Use the helper function to render user attachments */} |
|
|
{renderAttachments(attachments as string[], handleOpenFileViewer, sandboxId, project)} |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
); |
|
|
} else if (group.type === 'assistant_group') { |
|
|
return ( |
|
|
<div key={group.key} ref={groupIndex === groupedMessages.length - 1 ? latestMessageRef : null}> |
|
|
<div className="flex flex-col gap-2"> |
|
|
<div className="flex items-center"> |
|
|
<div className="rounded-md flex items-center justify-center"> |
|
|
{(() => { |
|
|
const firstAssistantWithAgent = group.messages.find(msg => |
|
|
msg.type === 'assistant' && (msg.agents?.avatar || msg.agents?.avatar_color) |
|
|
); |
|
|
if (firstAssistantWithAgent?.agents?.avatar) { |
|
|
const avatar = firstAssistantWithAgent.agents.avatar; |
|
|
return ( |
|
|
<div |
|
|
className="h-4 w-5 flex items-center justify-center rounded text-xs" |
|
|
> |
|
|
<span className="text-lg">{avatar}</span> |
|
|
</div> |
|
|
); |
|
|
} |
|
|
return <KortixLogo size={16} />; |
|
|
})()} |
|
|
</div> |
|
|
<p className='ml-2 text-sm text-muted-foreground'> |
|
|
{(() => { |
|
|
const firstAssistantWithAgent = group.messages.find(msg => |
|
|
msg.type === 'assistant' && msg.agents?.name |
|
|
); |
|
|
if (firstAssistantWithAgent?.agents?.name) { |
|
|
return firstAssistantWithAgent.agents.name; |
|
|
} |
|
|
return 'Suna'; |
|
|
})()} |
|
|
</p> |
|
|
</div> |
|
|
|
|
|
{/* Message content - ALL messages in the group */} |
|
|
<div className="flex max-w-[90%] text-sm break-words overflow-hidden"> |
|
|
<div className="space-y-2 min-w-0 flex-1"> |
|
|
{(() => { |
|
|
// In debug mode, just show raw messages content |
|
|
if (debugMode) { |
|
|
return group.messages.map((message, msgIndex) => { |
|
|
const msgKey = message.message_id || `raw-msg-${msgIndex}`; |
|
|
return ( |
|
|
<div key={msgKey} className="mb-4"> |
|
|
<div className="text-xs font-medium text-muted-foreground mb-1"> |
|
|
Type: {message.type} | ID: {message.message_id || 'no-id'} |
|
|
</div> |
|
|
<pre className="text-xs font-mono whitespace-pre-wrap overflow-x-auto p-2 border border-border rounded-md bg-muted/30"> |
|
|
{message.content} |
|
|
</pre> |
|
|
{message.metadata && message.metadata !== '{}' && ( |
|
|
<div className="mt-2"> |
|
|
<div className="text-xs font-medium text-muted-foreground mb-1"> |
|
|
Metadata: |
|
|
</div> |
|
|
<pre className="text-xs font-mono whitespace-pre-wrap overflow-x-auto p-2 border border-border rounded-md bg-muted/30"> |
|
|
{message.metadata} |
|
|
</pre> |
|
|
</div> |
|
|
)} |
|
|
</div> |
|
|
); |
|
|
}); |
|
|
} |
|
|
|
|
|
const toolResultsMap = new Map<string | null, UnifiedMessage[]>(); |
|
|
group.messages.forEach(msg => { |
|
|
if (msg.type === 'tool') { |
|
|
const meta = safeJsonParse<ParsedMetadata>(msg.metadata, {}); |
|
|
const assistantId = meta.assistant_message_id || null; |
|
|
if (!toolResultsMap.has(assistantId)) { |
|
|
toolResultsMap.set(assistantId, []); |
|
|
} |
|
|
toolResultsMap.get(assistantId)?.push(msg); |
|
|
} |
|
|
}); |
|
|
|
|
|
const elements: React.ReactNode[] = []; |
|
|
let assistantMessageCount = 0; // Move this outside the loop |
|
|
|
|
|
group.messages.forEach((message, msgIndex) => { |
|
|
if (message.type === 'assistant') { |
|
|
const parsedContent = safeJsonParse<ParsedContent>(message.content, {}); |
|
|
const msgKey = message.message_id || `submsg-assistant-${msgIndex}`; |
|
|
|
|
|
if (!parsedContent.content) return; |
|
|
|
|
|
const renderedContent = renderMarkdownContent( |
|
|
parsedContent.content, |
|
|
handleToolClick, |
|
|
message.message_id, |
|
|
handleOpenFileViewer, |
|
|
sandboxId, |
|
|
project, |
|
|
debugMode |
|
|
); |
|
|
|
|
|
elements.push( |
|
|
<div key={msgKey} className={assistantMessageCount > 0 ? "mt-4" : ""}> |
|
|
<div className="prose prose-sm dark:prose-invert chat-markdown max-w-none [&>:first-child]:mt-0 prose-headings:mt-3 break-words overflow-hidden"> |
|
|
{renderedContent} |
|
|
</div> |
|
|
</div> |
|
|
); |
|
|
|
|
|
assistantMessageCount++; // Increment after adding the element |
|
|
} |
|
|
}); |
|
|
|
|
|
return elements; |
|
|
})()} |
|
|
|
|
|
{groupIndex === finalGroupedMessages.length - 1 && !readOnly && (streamHookStatus === 'streaming' || streamHookStatus === 'connecting') && ( |
|
|
<div className="mt-2"> |
|
|
{(() => { |
|
|
// In debug mode, show raw streaming content |
|
|
if (debugMode && streamingTextContent) { |
|
|
return ( |
|
|
<pre className="text-xs font-mono whitespace-pre-wrap overflow-x-auto p-2 border border-border rounded-md bg-muted/30"> |
|
|
{streamingTextContent} |
|
|
</pre> |
|
|
); |
|
|
} |
|
|
|
|
|
let detectedTag: string | null = null; |
|
|
let tagStartIndex = -1; |
|
|
if (streamingTextContent) { |
|
|
// First check for new format |
|
|
const functionCallsIndex = streamingTextContent.indexOf('<function_calls>'); |
|
|
if (functionCallsIndex !== -1) { |
|
|
detectedTag = 'function_calls'; |
|
|
tagStartIndex = functionCallsIndex; |
|
|
} else { |
|
|
// Fall back to old format detection |
|
|
for (const tag of HIDE_STREAMING_XML_TAGS) { |
|
|
const openingTagPattern = `<${tag}`; |
|
|
const index = streamingTextContent.indexOf(openingTagPattern); |
|
|
if (index !== -1) { |
|
|
detectedTag = tag; |
|
|
tagStartIndex = index; |
|
|
break; |
|
|
} |
|
|
} |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
const textToRender = streamingTextContent || ''; |
|
|
const textBeforeTag = detectedTag ? textToRender.substring(0, tagStartIndex) : textToRender; |
|
|
const showCursor = (streamHookStatus === 'streaming' || streamHookStatus === 'connecting') && !detectedTag; |
|
|
|
|
|
return ( |
|
|
<> |
|
|
{textBeforeTag && ( |
|
|
<Markdown className="text-sm prose prose-sm dark:prose-invert chat-markdown max-w-none [&>:first-child]:mt-0 prose-headings:mt-3 break-words overflow-wrap-anywhere">{textBeforeTag}</Markdown> |
|
|
)} |
|
|
{showCursor && ( |
|
|
<span className="inline-block h-4 w-0.5 bg-primary ml-0.5 -mb-1 animate-pulse" /> |
|
|
)} |
|
|
|
|
|
{detectedTag && detectedTag !== 'function_calls' && ( |
|
|
<div className="mt-2 mb-1"> |
|
|
<button |
|
|
className="animate-shimmer inline-flex items-center gap-1.5 py-1 px-1 pr-1.5 text-xs font-medium text-primary bg-muted hover:bg-muted/80 rounded-md transition-colors cursor-pointer border border-primary/20" |
|
|
> |
|
|
<div className='border-2 bg-gradient-to-br from-neutral-200 to-neutral-300 dark:from-neutral-700 dark:to-neutral-800 flex items-center justify-center p-0.5 rounded-sm border-neutral-400/20 dark:border-neutral-600'> |
|
|
<CircleDashed className="h-3.5 w-3.5 text-primary flex-shrink-0 animate-spin animation-duration-2000" /> |
|
|
</div> |
|
|
<span className="font-mono text-xs text-primary">{getUserFriendlyToolName(detectedTag)}</span> |
|
|
</button> |
|
|
</div> |
|
|
)} |
|
|
|
|
|
{detectedTag === 'function_calls' && ( |
|
|
<div className="mt-2 mb-1"> |
|
|
<button |
|
|
className="animate-shimmer inline-flex items-center gap-1.5 py-1 px-1 pr-1.5 text-xs font-medium text-primary bg-muted hover:bg-muted/80 rounded-md transition-colors cursor-pointer border border-primary/20" |
|
|
> |
|
|
<div className='border-2 bg-gradient-to-br from-neutral-200 to-neutral-300 dark:from-neutral-700 dark:to-neutral-800 flex items-center justify-center p-0.5 rounded-sm border-neutral-400/20 dark:border-neutral-600'> |
|
|
<CircleDashed className="h-3.5 w-3.5 text-primary flex-shrink-0 animate-spin animation-duration-2000" /> |
|
|
</div> |
|
|
<span className="font-mono text-xs text-primary"> |
|
|
{(() => { |
|
|
const extractedToolName = extractToolNameFromStream(streamingTextContent); |
|
|
return extractedToolName ? getUserFriendlyToolName(extractedToolName) : 'Using Tool...'; |
|
|
})()} |
|
|
</span> |
|
|
</button> |
|
|
</div> |
|
|
)} |
|
|
|
|
|
{streamingToolCall && !detectedTag && ( |
|
|
<div className="mt-2 mb-1"> |
|
|
{(() => { |
|
|
const toolName = streamingToolCall.name || streamingToolCall.xml_tag_name || 'Tool'; |
|
|
const paramDisplay = extractPrimaryParam(toolName, streamingToolCall.arguments || ''); |
|
|
return ( |
|
|
<button |
|
|
className="animate-shimmer inline-flex items-center gap-1.5 py-1 px-1 pr-1.5 text-xs font-medium text-primary bg-muted hover:bg-muted/80 rounded-md transition-colors cursor-pointer border border-primary/20" |
|
|
> |
|
|
<div className='border-2 bg-gradient-to-br from-neutral-200 to-neutral-300 dark:from-neutral-700 dark:to-neutral-800 flex items-center justify-center p-0.5 rounded-sm border-neutral-400/20 dark:border-neutral-600'> |
|
|
<CircleDashed className="h-3.5 w-3.5 text-primary flex-shrink-0 animate-spin animation-duration-2000" /> |
|
|
</div> |
|
|
<span className="font-mono text-xs text-primary">{toolName}</span> |
|
|
{paramDisplay && <span className="ml-1 text-primary/70 truncate max-w-[200px]" title={paramDisplay}>{paramDisplay}</span>} |
|
|
</button> |
|
|
); |
|
|
})()} |
|
|
</div> |
|
|
)} |
|
|
</> |
|
|
); |
|
|
})()} |
|
|
</div> |
|
|
)} |
|
|
|
|
|
{/* For playback mode, show streaming text and tool calls */} |
|
|
{readOnly && groupIndex === finalGroupedMessages.length - 1 && isStreamingText && ( |
|
|
<div className="mt-2"> |
|
|
{(() => { |
|
|
let detectedTag: string | null = null; |
|
|
let tagStartIndex = -1; |
|
|
if (streamingText) { |
|
|
// First check for new format |
|
|
const functionCallsIndex = streamingText.indexOf('<function_calls>'); |
|
|
if (functionCallsIndex !== -1) { |
|
|
detectedTag = 'function_calls'; |
|
|
tagStartIndex = functionCallsIndex; |
|
|
} else { |
|
|
// Fall back to old format detection |
|
|
for (const tag of HIDE_STREAMING_XML_TAGS) { |
|
|
const openingTagPattern = `<${tag}`; |
|
|
const index = streamingText.indexOf(openingTagPattern); |
|
|
if (index !== -1) { |
|
|
detectedTag = tag; |
|
|
tagStartIndex = index; |
|
|
break; |
|
|
} |
|
|
} |
|
|
} |
|
|
} |
|
|
|
|
|
const textToRender = streamingText || ''; |
|
|
const textBeforeTag = detectedTag ? textToRender.substring(0, tagStartIndex) : textToRender; |
|
|
const showCursor = isStreamingText && !detectedTag; |
|
|
|
|
|
return ( |
|
|
<> |
|
|
{/* In debug mode, show raw streaming content */} |
|
|
{debugMode && streamingText ? ( |
|
|
<pre className="text-xs font-mono whitespace-pre-wrap overflow-x-auto p-2 border border-border rounded-md bg-muted/30"> |
|
|
{streamingText} |
|
|
</pre> |
|
|
) : ( |
|
|
<> |
|
|
{textBeforeTag && ( |
|
|
<Markdown className="text-sm prose prose-sm dark:prose-invert chat-markdown max-w-none [&>:first-child]:mt-0 prose-headings:mt-3 break-words overflow-wrap-anywhere">{textBeforeTag}</Markdown> |
|
|
)} |
|
|
{showCursor && ( |
|
|
<span className="inline-block h-4 w-0.5 bg-primary ml-0.5 -mb-1 animate-pulse" /> |
|
|
)} |
|
|
|
|
|
{detectedTag && ( |
|
|
<div className="mt-2 mb-1"> |
|
|
<button |
|
|
className="animate-shimmer inline-flex items-center gap-1.5 py-1 px-2.5 text-xs font-medium text-primary bg-primary/10 hover:bg-primary/20 rounded-md transition-colors cursor-pointer border border-primary/20" |
|
|
> |
|
|
<CircleDashed className="h-3.5 w-3.5 text-primary flex-shrink-0 animate-spin animation-duration-2000" /> |
|
|
<span className="font-mono text-xs text-primary"> |
|
|
{detectedTag === 'function_calls' ? |
|
|
(() => { |
|
|
const extractedToolName = extractToolNameFromStream(streamingText); |
|
|
return extractedToolName ? getUserFriendlyToolName(extractedToolName) : 'Using Tool...'; |
|
|
})() : |
|
|
getUserFriendlyToolName(detectedTag) |
|
|
} |
|
|
</span> |
|
|
</button> |
|
|
</div> |
|
|
)} |
|
|
</> |
|
|
)} |
|
|
</> |
|
|
); |
|
|
})()} |
|
|
</div> |
|
|
)} |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
); |
|
|
} |
|
|
return null; |
|
|
}); |
|
|
})()} |
|
|
{((agentStatus === 'running' || agentStatus === 'connecting') && !streamingTextContent && |
|
|
!readOnly && |
|
|
(messages.length === 0 || messages[messages.length - 1].type === 'user')) && ( |
|
|
<div ref={latestMessageRef} className='w-full h-22 rounded'> |
|
|
<div className="flex flex-col gap-2"> |
|
|
{/* Logo positioned above the loader */} |
|
|
<div className="flex items-center"> |
|
|
<div className="rounded-md flex items-center justify-center"> |
|
|
{agentAvatar} |
|
|
</div> |
|
|
<p className='ml-2 text-sm text-muted-foreground'>{agentName || 'Suna'}</p> |
|
|
</div> |
|
|
|
|
|
{/* Loader content */} |
|
|
<div className="space-y-2 w-full h-12"> |
|
|
<AgentLoader /> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
)} |
|
|
|
|
|
{/* For playback mode - Show tool call animation if active */} |
|
|
{readOnly && currentToolCall && ( |
|
|
<div ref={latestMessageRef}> |
|
|
<div className="flex flex-col gap-2"> |
|
|
{/* Logo positioned above the tool call */} |
|
|
<div className="flex justify-start"> |
|
|
<div className="rounded-md flex items-center justify-center"> |
|
|
{agentAvatar} |
|
|
</div> |
|
|
<p className='ml-2 text-sm text-muted-foreground'>{agentName || 'Suna'}</p> |
|
|
</div> |
|
|
|
|
|
{/* Tool call content */} |
|
|
<div className="space-y-2"> |
|
|
<div className="animate-shimmer inline-flex items-center gap-1.5 py-1.5 px-3 text-xs font-medium text-primary bg-primary/10 rounded-md border border-primary/20"> |
|
|
<CircleDashed className="h-3.5 w-3.5 text-primary flex-shrink-0 animate-spin animation-duration-2000" /> |
|
|
<span className="font-mono text-xs text-primary"> |
|
|
{currentToolCall.name || 'Using Tool'} |
|
|
</span> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
)} |
|
|
|
|
|
{/* For playback mode - Show streaming indicator if no messages yet */} |
|
|
{readOnly && visibleMessages && visibleMessages.length === 0 && isStreamingText && ( |
|
|
<div ref={latestMessageRef}> |
|
|
<div className="flex flex-col gap-2"> |
|
|
{/* Logo positioned above the streaming indicator */} |
|
|
<div className="flex justify-start"> |
|
|
<div className="rounded-md flex items-center justify-center"> |
|
|
{agentAvatar} |
|
|
</div> |
|
|
<p className='ml-2 text-sm text-muted-foreground'>{agentName || 'Suna'}</p> |
|
|
</div> |
|
|
|
|
|
{/* Streaming indicator content */} |
|
|
<div className="max-w-[90%] px-4 py-3 text-sm"> |
|
|
<div className="flex items-center gap-1.5 py-1"> |
|
|
<div className="h-1.5 w-1.5 rounded-full bg-primary/50 animate-pulse" /> |
|
|
<div className="h-1.5 w-1.5 rounded-full bg-primary/50 animate-pulse delay-150" /> |
|
|
<div className="h-1.5 w-1.5 rounded-full bg-primary/50 animate-pulse delay-300" /> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
)} |
|
|
</div> |
|
|
</div> |
|
|
<div ref={messagesEndRef} className="h-1" /> |
|
|
</div> |
|
|
)} |
|
|
|
|
|
{/* Scroll to bottom button */} |
|
|
{showScrollButton && ( |
|
|
<Button |
|
|
variant="outline" |
|
|
size="icon" |
|
|
className="fixed bottom-20 right-6 z-10 h-8 w-8 rounded-full shadow-md" |
|
|
onClick={() => scrollToBottom('smooth')} |
|
|
> |
|
|
<ArrowDown className="h-4 w-4" /> |
|
|
</Button> |
|
|
)} |
|
|
</> |
|
|
); |
|
|
}; |
|
|
|
|
|
export default ThreadContent; |
|
|
|