"use client"; import React, { useEffect, useState, useRef } from 'react'; import { useRouter } from 'next/navigation'; import { api } from '@/lib/api-client'; import { Button } from '@/components/ui/Button/Button'; import styles from './ThreadChat.module.css'; import ReactMarkdown from 'react-markdown'; import remarkGfm from 'remark-gfm'; import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter'; import { atomDark } from 'react-syntax-highlighter/dist/esm/styles/prism'; import { ToolRequest, ToolResponse, ToolExecuting, ToolUsageContainer } from './components/ToolRenderers'; interface Message { id: string; role: 'user' | 'assistant' | 'system' | 'tool'; type?: string; content: string | any; timestamp?: string; tool_calls?: any[]; tool_call_id?: string; name?: string; } interface ThreadChatProps { threadId: string; onBack?: () => void; className?: string; showHeader?: boolean; onResponseComplete?: () => void; } export default function ThreadChat({ threadId, onBack, className, showHeader = true, onResponseComplete }: ThreadChatProps) { const router = useRouter(); const [messages, setMessages] = useState([]); const [inputValue, setInputValue] = useState(''); const [isLoading, setIsLoading] = useState(true); const [isSending, setIsSending] = useState(false); const [error, setError] = useState(null); const [currentTool, setCurrentTool] = useState(null); const [isUploadingFiles, setIsUploadingFiles] = useState(false); const [uploadError, setUploadError] = useState(null); const messagesEndRef = useRef(null); const isProcessingRef = useRef(false); const hasFetchedRef = useRef(false); const fileInputRef = useRef(null); // We need to track the current threadId to reset state when it changes const currentThreadIdRef = useRef(threadId); const scrollToBottom = () => { messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); }; const [thread, setThread] = useState(null); // Reset state when threadId changes if (threadId !== currentThreadIdRef.current) { currentThreadIdRef.current = threadId; hasFetchedRef.current = false; setMessages([]); setThread(null); } useEffect(() => { const fetchThreadData = async () => { if (!threadId || hasFetchedRef.current) return; hasFetchedRef.current = true; setIsLoading(true); setError(null); try { // 1. Get thread details first to verify existence and get context console.log('Fetching thread details for:', threadId); const threadDetails = await (api.get as any)(`/threads/${threadId}`); console.log('Thread details received:', JSON.stringify(threadDetails).substring(0, 500)); setThread(threadDetails); // 2. Try state first (often more reliable and contains latest messages) let data = null; console.log('Attempting to fetch thread state...'); try { data = await (api.get as any)(`/threads/${threadId}/state`); console.log('State fetch success, keys:', Object.keys(data || {})); } catch (stateErr: any) { console.warn('Thread state fetch failed, trying history backup...', stateErr); // 3. Fallback to history if state fails try { data = await (api.get as any)(`/threads/${threadId}/history`); console.log('History backup success'); } catch (historyErr: any) { console.error('Both state and history fetch failed:', historyErr); // Check for specific backend configuration errors const isToolError = stateErr?.message?.toLowerCase()?.includes('tool configuration') || stateErr?.message?.toLowerCase()?.includes('tool type') || historyErr?.message?.toLowerCase()?.includes('tool configuration') || historyErr?.message?.toLowerCase()?.includes('tool type'); if (isToolError) { setError("This thread's assistant has an invalid tool configuration. Please edit the assistant in your Project view and click 'Save Changes' to refresh its configuration."); } else { setError("Could not load previous messages. You can still start a new conversation below."); } setMessages([]); setIsLoading(false); return; } } console.log('Thread data received, processing messages...'); let rawMessages: any[] = []; if (Array.isArray(data)) { // 1. History format (List of snapshots) if (data.length > 0) { // Find the first snapshot that has messages in its values or at the root for (const item of data) { const candidate = item.values?.messages || item.messages || (Array.isArray(item.values) ? item.values : null); if (Array.isArray(candidate) && candidate.length > 0) { rawMessages = candidate; break; } } // If still empty and data itself is an array of messages if (rawMessages.length === 0 && data[0].content && (data[0].role || data[0].type)) { rawMessages = data; } } } else if (data && typeof data === 'object') { // 2. State format (Single object) const values = data.values || data.checkpoint?.values || data; console.log('Using values for extraction:', JSON.stringify(values).substring(0, 500)); if (Array.isArray(values)) { rawMessages = values; } else if (values && typeof values === 'object') { // Look for common message array keys const messageKeys = ['messages', 'history', 'chat_history', 'msgs']; for (const key of messageKeys) { if (Array.isArray(values[key]) && values[key].length > 0) { rawMessages = values[key]; console.log(`Found messages in values.${key}`); break; } } // Fallback: check if any key in values is an array (likely messages) if (rawMessages.length === 0) { const arrayKey = Object.keys(values).find(k => Array.isArray(values[k]) && values[k].length > 0); if (arrayKey) { console.log('State fallback: using array key:', arrayKey); rawMessages = values[arrayKey]; } } } } console.log('Raw messages extracted, count:', rawMessages.length); if (Array.isArray(rawMessages)) { setMessages(rawMessages.map((m: any) => { let role = m.role || m.type; if (role === 'human' || role === 'user') role = 'user'; if (role === 'ai' || role === 'assistant') role = 'assistant'; if (role === 'function' || role === 'tool') role = 'tool'; let content = m.content; if (Array.isArray(content)) { content = content .map((block: any) => typeof block === 'string' ? block : (block.text || '')) .join('\n'); } else if (content && typeof content === 'object' && role !== 'tool') { // Only stringify if it's not a tool response, to preserve structure for ToolResponse component content = content.text || JSON.stringify(content); } // If it's a tool response and an object, we keep it as is return { id: m.id || String(Math.random()), role, content, tool_calls: m.tool_calls, tool_call_id: m.tool_call_id, name: m.name } as Message; })); } else { setMessages([]); } } catch (err: any) { console.error('Critical error in fetchThreadData:', err); setError(err.message || 'Failed to initialize conversation'); } finally { setIsLoading(false); } }; fetchThreadData(); // Add debugging info to console console.log('🔧 Tool Debugging Features Available:'); console.log('====================================='); console.log('• Press Ctrl+Shift+D to toggle debug panel'); console.log('• Run getToolDebugInfo() in console for debug summary'); console.log('• Check window._toolDebug for real-time tool stats'); console.log('• Check window._lastChunks for recent stream data'); console.log(''); // Add keyboard shortcut for debug panel (Ctrl+Shift+D) const handleKeyDown = (e: KeyboardEvent) => { if (e.ctrlKey && e.shiftKey && e.key === 'D') { e.preventDefault(); const panel = document.getElementById('tool-debug-panel'); if (panel) { panel.style.display = panel.style.display === 'none' ? 'block' : 'none'; // Log current debug state to console if (typeof window !== 'undefined') { console.log('🔧 Tool Debug State:', (window as any)._toolDebug); console.log('📊 Last 5 Chunks:', (window as any)._lastChunks?.slice(-5)); } } } }; document.addEventListener('keydown', handleKeyDown); return () => { document.removeEventListener('keydown', handleKeyDown); }; }, [threadId]); const copyToClipboard = (text: string) => { navigator.clipboard.writeText(text); // Optional: Add a toast notification here }; useEffect(() => { scrollToBottom(); // Update debug panel if it exists if (typeof window !== 'undefined' && (window as any)._toolDebug) { const debugInfo = (window as any)._toolDebug; const panel = document.getElementById('tool-debug-panel'); if (panel) { const toolCallsEl = document.getElementById('tool-calls-count'); const toolResponsesEl = document.getElementById('tool-responses-count'); const aiMentionsEl = document.getElementById('ai-mentions-count'); const chunkCountEl = document.getElementById('chunk-count'); if (toolCallsEl) toolCallsEl.textContent = debugInfo.toolCallsFound.toString(); if (toolResponsesEl) toolResponsesEl.textContent = debugInfo.toolResponsesFound.toString(); if (aiMentionsEl) aiMentionsEl.textContent = debugInfo.aiMentionsTools.toString(); if (chunkCountEl) chunkCountEl.textContent = ((window as any)._lastChunks?.length || 0).toString(); } } }, [messages]); const handleSendMessage = async (e?: React.FormEvent) => { e?.preventDefault(); if (!inputValue.trim() || isSending) return; const userMessage: Message = { id: Date.now().toString(), role: 'user', content: inputValue.trim() }; setMessages(prev => [...prev, userMessage]); setInputValue(''); setIsSending(true); setError(null); // Stream monitoring variables let chunkCount = 0; let lastChunkTime = Date.now(); let streamStarted = false; try { console.log('Sending message to thread:', threadId, 'Current thread context:', thread); let assistantMessageId = ''; setCurrentTool(null); const mergeMessages = (current: Message[], incoming: any[]): Message[] => { const messageMap = new Map(); current.forEach(m => messageMap.set(m.id, m)); const normalizeMessage = (m: any) => { let role = m.role || m.type || 'assistant'; // Handle server format: type: 'human'/'ai' -> role: 'user'/'assistant' if (role === 'ai' || role === 'assistant') role = 'assistant'; if (role === 'human' || role === 'user') role = 'user'; if (role === 'function' || role === 'tool') role = 'tool'; let content = m.content; if (Array.isArray(content)) { content = content .map((block: any) => typeof block === 'string' ? block : (block.text || '')) .join('\n'); } // Handle tool calls from server format const tool_calls = m.tool_calls || m.additional_kwargs?.tool_calls || []; return { ...m, role, content: content || '', tool_calls, tool_call_id: m.tool_call_id, // Explicitly preserve tool_call_id name: m.name, // Preserve tool name // Use server ID or generate fallback id: m.id || m.message_id || `msg-${Date.now()}-${Math.random()}` }; }; incoming.forEach(incRaw => { const inc = normalizeMessage(incRaw); let id = inc.id || inc.message_id; // Fallback ID for streaming assistant messages without explicit IDs if (!id) { if (inc.role === 'assistant') { if (!assistantMessageId) { assistantMessageId = (Date.now() + 1).toString(); } id = assistantMessageId; } else if (inc.role === 'tool') { id = inc.tool_call_id || `tool-${Date.now()}`; } else { return; } } // DE-DUPLICATION: If this is a user message, check if we already have it with a temp ID if (inc.role === 'user') { const existingTemp = Array.from(messageMap.values()).find(m => m.role === 'user' && m.content === inc.content && /^\d{13,}$/.test(m.id) ); if (existingTemp) { messageMap.delete(existingTemp.id); } } const existing = messageMap.get(id); if (existing) { messageMap.set(id, { ...existing, ...inc, // Preserve existing content if incoming is empty/undefined but we have content content: (inc.content !== undefined && inc.content !== '') ? inc.content : existing.content, // Merge tool calls tool_calls: inc.tool_calls.length > 0 ? inc.tool_calls : existing.tool_calls, role: inc.role // Always trust latest normalized role }); } else if (['user', 'assistant', 'system', 'tool'].includes(inc.role)) { messageMap.set(id, { ...inc, id, content: inc.content || '' }); } }); return Array.from(messageMap.values()); }; // Track sending state with a ref to prevent race conditions if (isProcessingRef.current) return; isProcessingRef.current = true; // 1. Prepare input const messagePayload = { content: userMessage.content, type: 'human', role: 'user' }; const streamInput = [messagePayload]; const stream = (api as any).stream('/runs/stream', { body: { thread_id: threadId, input: streamInput, stream_mode: ['messages', 'events', 'values'] } }); for await (const chunk of stream) { chunkCount++; lastChunkTime = Date.now(); // Debug logging omitted for brevity but logic remains same // Track last chunks for debug if (typeof window !== 'undefined') { (window as any)._lastChunks = (window as any)._lastChunks || []; (window as any)._lastChunks.push(chunk); if ((window as any)._lastChunks.length > 50) (window as any)._lastChunks.shift(); } // Handle error chunks if (chunk && (chunk.status_code >= 400 || chunk.type === 'processing_error')) { const errorMsg = chunk.message || chunk.details || 'Backend processing error'; throw new Error(`Tool execution error: ${errorMsg}`); } // Handle the actual server format if (chunk.event === 'start') { streamStarted = true; continue; } else if (chunk.event === 'metadata') { continue; } else if (chunk.event === 'end') { break; } else if (chunk.event === 'data' && Array.isArray(chunk)) { // Server sends data as arrays of message objects setMessages(prev => mergeMessages(prev, chunk)); continue; } // Legacy handling for other possible formats if (Array.isArray(chunk)) { // Case A: Array of message snapshots setMessages(prev => mergeMessages(prev, chunk)); } else if (chunk.event === 'on_chat_model_stream' || chunk.event === 'messages/partial') { // Case B: Event wrappers (deltas and signals) const delta = chunk.data?.chunk?.content || chunk.data?.content; // Extract tool call chunks from multiple possible locations const toolCallChunks = chunk.data?.chunk?.additional_kwargs?.tool_calls || chunk.data?.chunk?.tool_call_chunks || chunk.data?.chunk?.tool_calls || chunk.data?.tool_calls || chunk.tool_calls || []; if (delta && typeof delta === 'string') { if (!assistantMessageId) { assistantMessageId = (Date.now() + 1).toString(); setMessages(prev => [...prev, { id: assistantMessageId, role: 'assistant', content: delta, tool_calls: [] }]); } else { setMessages(prev => prev.map(msg => msg.id === assistantMessageId ? { ...msg, content: msg.content + delta } : msg )); } } // Handle tool call chunks - accumulate instead of replace if (toolCallChunks.length > 0) { if (!assistantMessageId) { assistantMessageId = (Date.now() + 1).toString(); setMessages(prev => [...prev, { id: assistantMessageId, role: 'assistant', content: '', tool_calls: toolCallChunks }]); } else { setMessages(prev => prev.map(msg => { if (msg.id === assistantMessageId) { // Merge tool calls by ID, accumulating argument chunks const existingCalls = msg.tool_calls || []; const mergedCalls = [...existingCalls]; toolCallChunks.forEach((newCall: any) => { const callId = newCall.id || newCall.index; const existingIdx = mergedCalls.findIndex( (c: any) => (c.id || c.index) === callId ); if (existingIdx >= 0) { // Accumulate arguments for existing tool call const existing = mergedCalls[existingIdx]; const existingArgs = existing.function?.arguments || existing.args || ''; const newArgs = newCall.function?.arguments || newCall.args || ''; mergedCalls[existingIdx] = { ...existing, ...newCall, function: { ...existing.function, ...newCall.function, arguments: existingArgs + newArgs }, args: typeof existingArgs === 'string' && typeof newArgs === 'string' ? existingArgs + newArgs : newCall.args || existing.args }; } else { mergedCalls.push(newCall); } }); return { ...msg, tool_calls: mergedCalls }; } return msg; })); } } } else if (chunk.event === 'on_tool_start') { const toolName = chunk.name || chunk.data?.name || chunk.data?.input?.tool || 'Retrieving data...'; setCurrentTool(toolName); // Also extract tool call info if available const toolInput = chunk.data?.input || chunk.data?.args || chunk.input; if (toolInput && assistantMessageId) { const toolCallFromEvent = { id: chunk.run_id || chunk.data?.run_id || `tool-${Date.now()}`, name: toolName, args: toolInput }; setMessages(prev => prev.map(msg => { if (msg.id === assistantMessageId) { const existingCalls = msg.tool_calls || []; // Only add if not already present if (!existingCalls.some((c: any) => c.name === toolName)) { return { ...msg, tool_calls: [...existingCalls, toolCallFromEvent] }; } } return msg; })); } } else if (chunk.event === 'on_tool_end') { setCurrentTool(null); // Capture tool response if available const toolOutput = chunk.data?.output || chunk.output; const toolRunId = chunk.run_id || chunk.data?.run_id; if (toolOutput && toolRunId) { setMessages(prev => { // Check if we already have this tool response const hasResponse = prev.some(m => m.role === 'tool' && m.tool_call_id === toolRunId); if (hasResponse) return prev; return [...prev, { id: `tool-response-${toolRunId}`, role: 'tool', content: typeof toolOutput === 'string' ? toolOutput : JSON.stringify(toolOutput), tool_call_id: toolRunId }]; }); } } else if (chunk.values?.messages && Array.isArray(chunk.values.messages)) { // Case C: Standard updates (values) setMessages(prev => mergeMessages(prev, chunk.values.messages)); } } } catch (err: any) { console.error('Failed to send message:', err); // Check if this was a premature stream termination const timeSinceStart = Date.now() - lastChunkTime; const isPrematureEnd = streamStarted && chunkCount < 3 && timeSinceStart < 5000; // Provide more specific error messages based on the error type let errorMessage = 'An unexpected error occurred while communicating with the AI.'; if (isPrematureEnd) { errorMessage = 'Tool execution failed: The AI service encountered an error while trying to use tools. This may be due to a server-side configuration issue.'; } else if (err.name === 'AbortError' || err.message?.includes('aborted')) { errorMessage = 'The request was cancelled or timed out.'; } else if (err.message?.includes('fetch')) { errorMessage = 'Network error: Unable to connect to the AI service.'; } else if (err.message?.includes('stream')) { errorMessage = 'Stream error: The AI service encountered an issue during processing.'; } else if (err.message?.includes('tool') || err.message?.includes('Tool')) { errorMessage = 'Tool execution error: There was an issue running the requested tool.'; } else if (err.message) { errorMessage = err.message; } setError(errorMessage); // Clean up: remove empty assistant messages setMessages(prev => prev.filter(msg => (msg.content && msg.content !== '') || (msg.tool_calls && msg.tool_calls.length > 0) || msg.role !== 'assistant' )); } finally { setIsSending(false); setCurrentTool(null); isProcessingRef.current = false; onResponseComplete?.(); } }; const handleFileUpload = async (files: FileList | null) => { if (!files || files.length === 0 || !thread?.assistant_id) return; setIsUploadingFiles(true); setUploadError(null); try { const formData = new FormData(); Array.from(files).forEach(file => { formData.append('files', file); }); const response = await fetch( `${process.env.NEXT_PUBLIC_API_URL || 'https://api.epsimoagents.com'}/assistants/${thread.assistant_id}/files`, { method: 'POST', headers: { 'Authorization': `Bearer ${localStorage.getItem('jwt_token')}` }, body: formData } ); if (!response.ok) { throw new Error(`Upload failed: ${response.status} ${response.statusText}`); } const result = await response.json(); console.log('Files uploaded successfully:', result); // Add a system message to indicate files were uploaded const fileNames = Array.from(files).map(f => f.name).join(', '); const systemMessage: Message = { id: `upload-${Date.now()}`, role: 'system', content: `📎 Uploaded ${files.length} file(s): ${fileNames}. The assistant now has access to this content.` }; setMessages(prev => [...prev, systemMessage]); } catch (err: any) { console.error('File upload error:', err); setUploadError(err.message || 'Failed to upload files'); } finally { setIsUploadingFiles(false); if (fileInputRef.current) { fileInputRef.current.value = ''; } } }; const handleFileButtonClick = () => { if (!thread?.assistant_id) { setUploadError('No assistant associated with this thread'); return; } fileInputRef.current?.click(); }; // Group tool calls with their responses const toolResponseMap = new Map(); const processedMessages: Message[] = []; messages.forEach(msg => { if (msg.role === 'tool' && (msg.tool_call_id || (msg as any).tool_call_id)) { const id = msg.tool_call_id || (msg as any).tool_call_id; toolResponseMap.set(id, msg); } else { processedMessages.push(msg); } }); return (
{showHeader && (
{onBack && ( )}

{thread?.name || 'Untitled Thread'}

)}
{processedMessages.map((message) => { const isUser = message.role === 'user'; const isSystem = message.role === 'system'; if (isSystem) { return (
{String(message.content)}
); } return (
{isUser ? 'U' : 'AI'}
{String(children).replace(/\n$/, '')} ) : ( {children} ); } }} > {typeof message.content === 'string' ? message.content : ''}
{/* Render Tool Calls if present */} {message.tool_calls && message.tool_calls.length > 0 && (
[k, v.content]) )} currentTool={currentTool} />
)}
); })} {/* Loading indicator for messages */} {inputValue === '' && isLoading && messages.length === 0 && (

Loading conversation...

)} {/* Error Banner */} {error && (
⚠️

Something went wrong

{error}

)} {/* Show active tool execution even if no message yet */} {currentTool && messages.length > 0 && !messages[messages.length - 1].tool_calls?.some((tc: any) => (tc.function?.name === currentTool || tc.name === currentTool) ) && (
Using {currentTool}...
)} {/* Thinking/Typing indicator */} {isSending && !currentTool && (
AI
)}
handleFileUpload(e.target.files)} multiple /> setInputValue(e.target.value)} onKeyDown={(e) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); handleSendMessage(); } }} disabled={isSending || isUploadingFiles} />
{uploadError && (
⚠️ {uploadError}
)}
); }