'use client'; import React, { createContext, useContext, useState, useCallback, useRef, ReactNode, } from 'react'; import { ControlCenterConversation, ControlCenterMessage, StreamEvent, ToolCall, ToolResult, } from '@/types/control-center'; // ============================================================================= // Types // ============================================================================= /** * Summary of a conversation for the list view */ export interface ConversationSummary { id: string; title: string; createdAt: string; updatedAt: string; messageCount: number; } /** * Context state interface */ interface ChatContextState { /** List of conversation summaries */ conversations: ConversationSummary[]; /** Currently loaded conversation with full messages */ currentConversation: ControlCenterConversation | null; /** Loading state for API calls */ isLoading: boolean; /** Whether a response is currently streaming */ isStreaming: boolean; /** Current streaming text content */ streamingContent: string; /** Error message if any */ error: string | null; } /** * Context actions interface */ interface ChatContextActions { /** Load all conversations from the API */ loadConversations: () => Promise; /** Select and load a conversation by ID */ selectConversation: (id: string) => Promise; /** Send a message to the current conversation */ sendMessage: (message: string, provider?: string) => Promise; /** Start a new conversation */ newConversation: () => void; /** Clear the current error */ clearError: () => void; } type ChatContextValue = ChatContextState & ChatContextActions; // ============================================================================= // Context // ============================================================================= const ChatContext = createContext(null); // ============================================================================= // Provider Component // ============================================================================= interface ChatProviderProps { children: ReactNode; } export function ChatProvider({ children }: ChatProviderProps) { // State const [conversations, setConversations] = useState([]); const [currentConversation, setCurrentConversation] = useState(null); const [isLoading, setIsLoading] = useState(false); const [isStreaming, setIsStreaming] = useState(false); const [streamingContent, setStreamingContent] = useState(''); const [error, setError] = useState(null); // Abort controller for cancelling streams const abortControllerRef = useRef(null); /** * Load all conversations from the API */ const loadConversations = useCallback(async () => { setIsLoading(true); setError(null); try { const response = await fetch('/api/v1/control-center/conversations'); if (!response.ok) { const errorData = await response.json().catch(() => ({})); throw new Error(errorData.error || `Failed to load conversations: ${response.status}`); } const data = await response.json(); setConversations(data.conversations || []); } catch (err) { const message = err instanceof Error ? err.message : 'Failed to load conversations'; setError(message); console.error('Error loading conversations:', err); } finally { setIsLoading(false); } }, []); /** * Select and load a conversation by ID */ const selectConversation = useCallback(async (id: string) => { setIsLoading(true); setError(null); try { const response = await fetch(`/api/v1/control-center/history/${id}`); if (!response.ok) { const errorData = await response.json().catch(() => ({})); throw new Error(errorData.error || `Failed to load conversation: ${response.status}`); } const data = await response.json(); setCurrentConversation(data.conversation); } catch (err) { const message = err instanceof Error ? err.message : 'Failed to load conversation'; setError(message); console.error('Error loading conversation:', err); } finally { setIsLoading(false); } }, []); /** * Parse SSE data from a chunk */ const parseSSEEvents = (chunk: string): StreamEvent[] => { const events: StreamEvent[] = []; const lines = chunk.split('\n'); for (const line of lines) { const trimmedLine = line.trim(); if (trimmedLine.startsWith('data:')) { const jsonStr = trimmedLine.slice(5).trim(); if (jsonStr && jsonStr !== '[DONE]') { try { const parsed = JSON.parse(jsonStr); events.push(parsed); } catch (e) { console.warn('Failed to parse SSE event:', jsonStr); } } } } return events; }; /** * Send a message to the current conversation with SSE streaming */ const sendMessage = useCallback(async (message: string, provider?: string) => { // Cancel any existing stream if (abortControllerRef.current) { abortControllerRef.current.abort(); } setIsLoading(true); setIsStreaming(true); setStreamingContent(''); setError(null); // Create abort controller for this request abortControllerRef.current = new AbortController(); // Add user message to conversation optimistically const userMessage: ControlCenterMessage = { id: `temp-${Date.now()}`, role: 'user', content: message, createdAt: new Date().toISOString(), }; setCurrentConversation((prev) => { if (prev) { return { ...prev, messages: [...prev.messages, userMessage], updatedAt: new Date().toISOString(), }; } // Create new conversation if none exists return { id: '', title: message.slice(0, 50) + (message.length > 50 ? '...' : ''), messages: [userMessage], createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }; }); try { const response = await fetch('/api/v1/control-center/chat', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Accept': 'text/event-stream', }, body: JSON.stringify({ conversationId: currentConversation?.id || undefined, message, provider: provider || 'openai', }), signal: abortControllerRef.current.signal, }); if (!response.ok) { const errorData = await response.json().catch(() => ({})); throw new Error(errorData.error || `Chat request failed: ${response.status}`); } if (!response.body) { throw new Error('No response body received'); } // Process the stream const reader = response.body.getReader(); const decoder = new TextDecoder(); let buffer = ''; let accumulatedContent = ''; let conversationId = currentConversation?.id || ''; let currentMessageId = ''; const toolCalls: ToolCall[] = []; const toolResults: ToolResult[] = []; setIsLoading(false); while (true) { const { done, value } = await reader.read(); if (done) { break; } // Decode the chunk and add to buffer buffer += decoder.decode(value, { stream: true }); // Split by double newline to get complete events const eventChunks = buffer.split('\n\n'); buffer = eventChunks.pop() || ''; // Keep incomplete chunk in buffer for (const chunk of eventChunks) { const events = parseSSEEvents(chunk); for (const event of events) { switch (event.type) { case 'message_start': conversationId = event.conversationId; currentMessageId = event.messageId; // Update conversation ID if it was empty if (!currentConversation?.id) { setCurrentConversation((prev) => prev ? { ...prev, id: conversationId } : prev); } break; case 'content_delta': accumulatedContent += event.delta; setStreamingContent(accumulatedContent); break; case 'tool_call_start': toolCalls.push(event.toolCall); break; case 'tool_result': toolResults.push(event.toolResult); break; case 'message_complete': // Replace streaming content with final message setCurrentConversation((prev) => { if (!prev) return prev; // Update the user message ID if it was temporary const updatedMessages = prev.messages.map((msg) => { if (msg.id.startsWith('temp-')) { return { ...msg, id: `user-${Date.now()}` }; } return msg; }); // Add the assistant message return { ...prev, id: conversationId || prev.id, messages: [...updatedMessages, event.message], updatedAt: new Date().toISOString(), }; }); setStreamingContent(''); setIsStreaming(false); break; case 'error': throw new Error(event.error); } } } } // Handle case where stream ends without message_complete if (isStreaming && accumulatedContent) { const assistantMessage: ControlCenterMessage = { id: currentMessageId || `assistant-${Date.now()}`, role: 'assistant', content: accumulatedContent, toolCalls: toolCalls.length > 0 ? toolCalls : undefined, toolResults: toolResults.length > 0 ? toolResults : undefined, createdAt: new Date().toISOString(), }; setCurrentConversation((prev) => { if (!prev) return prev; return { ...prev, id: conversationId || prev.id, messages: [...prev.messages, assistantMessage], updatedAt: new Date().toISOString(), }; }); } } catch (err) { if (err instanceof Error && err.name === 'AbortError') { // Request was aborted, don't treat as error return; } const message = err instanceof Error ? err.message : 'Failed to send message'; setError(message); console.error('Error sending message:', err); // Remove the optimistically added user message on error setCurrentConversation((prev) => { if (!prev) return prev; return { ...prev, messages: prev.messages.filter((msg) => !msg.id.startsWith('temp-')), }; }); } finally { setIsLoading(false); setIsStreaming(false); setStreamingContent(''); abortControllerRef.current = null; } }, [currentConversation?.id]); /** * Start a new conversation */ const newConversation = useCallback(() => { // Cancel any existing stream if (abortControllerRef.current) { abortControllerRef.current.abort(); } setCurrentConversation(null); setStreamingContent(''); setIsStreaming(false); setError(null); }, []); /** * Clear the current error */ const clearError = useCallback(() => { setError(null); }, []); // Context value const value: ChatContextValue = { // State conversations, currentConversation, isLoading, isStreaming, streamingContent, error, // Actions loadConversations, selectConversation, sendMessage, newConversation, clearError, }; return ( {children} ); } // ============================================================================= // Hook // ============================================================================= /** * Hook to access the chat context * Must be used within a ChatProvider */ export function useChatContext(): ChatContextValue { const context = useContext(ChatContext); if (!context) { throw new Error('useChatContext must be used within a ChatProvider'); } return context; } export default ChatProvider;