diff --git a/webui/app.go b/webui/app.go index cd94a05..1f5b0bb 100644 --- a/webui/app.go +++ b/webui/app.go @@ -301,6 +301,128 @@ func (a *App) Chat(pool *state.AgentPool) func(c *fiber.Ctx) error { } } +// ChatAPI provides a JSON-based API for chat functionality +// This is designed to work better with the React UI +func (a *App) ChatAPI(pool *state.AgentPool) func(c *fiber.Ctx) error { + return func(c *fiber.Ctx) error { + // Parse the request body + payload := struct { + Message string `json:"message"` + }{} + + if err := c.BodyParser(&payload); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(map[string]interface{}{ + "error": "Invalid request format", + }) + } + + // Get agent name from URL parameter + agentName := c.Params("name") + + // Validate message + message := strings.TrimSpace(payload.Message) + if message == "" { + return c.Status(fiber.StatusBadRequest).JSON(map[string]interface{}{ + "error": "Message cannot be empty", + }) + } + + // Get the agent from the pool + agent := pool.GetAgent(agentName) + if agent == nil { + return c.Status(fiber.StatusNotFound).JSON(map[string]interface{}{ + "error": "Agent not found", + }) + } + + // Get the SSE manager for this agent + manager := pool.GetManager(agentName) + + // Create a unique message ID + messageID := fmt.Sprintf("%d", time.Now().UnixNano()) + + // Send user message event via SSE + userMessageData, err := json.Marshal(map[string]interface{}{ + "id": messageID + "-user", + "sender": "user", + "content": message, + "timestamp": time.Now().Format(time.RFC3339), + }) + if err != nil { + xlog.Error("Error marshaling user message", "error", err) + } else { + manager.Send( + sse.NewMessage(string(userMessageData)).WithEvent("json_message")) + } + + // Send processing status + statusData, err := json.Marshal(map[string]interface{}{ + "status": "processing", + "timestamp": time.Now().Format(time.RFC3339), + }) + if err != nil { + xlog.Error("Error marshaling status message", "error", err) + } else { + manager.Send( + sse.NewMessage(string(statusData)).WithEvent("status")) + } + + // Process the message asynchronously + go func() { + // Ask the agent for a response + response := agent.Ask(coreTypes.WithText(message)) + + if response.Error != nil { + // Send error message + xlog.Error("Error asking agent", "agent", agentName, "error", response.Error) + errorData, err := json.Marshal(map[string]interface{}{ + "error": response.Error.Error(), + "timestamp": time.Now().Format(time.RFC3339), + }) + if err != nil { + xlog.Error("Error marshaling error message", "error", err) + } else { + manager.Send( + sse.NewMessage(string(errorData)).WithEvent("error")) + } + } else { + // Send agent response + xlog.Info("Response from agent", "agent", agentName, "response", response.Response) + responseData, err := json.Marshal(map[string]interface{}{ + "id": messageID + "-agent", + "sender": "agent", + "content": response.Response, + "timestamp": time.Now().Format(time.RFC3339), + }) + if err != nil { + xlog.Error("Error marshaling agent response", "error", err) + } else { + manager.Send( + sse.NewMessage(string(responseData)).WithEvent("json_message")) + } + } + + // Send completed status + completedData, err := json.Marshal(map[string]interface{}{ + "status": "completed", + "timestamp": time.Now().Format(time.RFC3339), + }) + if err != nil { + xlog.Error("Error marshaling completed status", "error", err) + } else { + manager.Send( + sse.NewMessage(string(completedData)).WithEvent("status")) + } + }() + + // Return immediate success response + return c.Status(fiber.StatusAccepted).JSON(map[string]interface{}{ + "status": "message_received", + "message_id": messageID, + }) + } +} + func (a *App) ExecuteAction(pool *state.AgentPool) func(c *fiber.Ctx) error { return func(c *fiber.Ctx) error { payload := struct { diff --git a/webui/react-ui/src/hooks/useChat.js b/webui/react-ui/src/hooks/useChat.js index 49a8993..3252fe4 100644 --- a/webui/react-ui/src/hooks/useChat.js +++ b/webui/react-ui/src/hooks/useChat.js @@ -1,4 +1,4 @@ -import { useState, useCallback, useEffect } from 'react'; +import { useState, useCallback, useEffect, useRef } from 'react'; import { chatApi } from '../utils/api'; import { useSSE } from './useSSE'; @@ -11,37 +11,103 @@ export function useChat(agentName) { const [messages, setMessages] = useState([]); const [sending, setSending] = useState(false); const [error, setError] = useState(null); + const processedMessageIds = useRef(new Set()); // Use SSE hook to receive real-time messages - const { data: sseData, isConnected } = useSSE(agentName); + const { messages: sseMessages, statusUpdates, errorMessages, isConnected } = useSSE(agentName); - // Process SSE data into messages + // Process SSE messages into chat messages useEffect(() => { - if (sseData && sseData.length > 0) { - // Process the latest SSE data - const latestData = sseData[sseData.length - 1]; - - if (latestData.type === 'message') { + if (!sseMessages || sseMessages.length === 0) return; + + // Process the latest SSE message + const latestMessage = sseMessages[sseMessages.length - 1]; + + // Skip if we've already processed this message + if (processedMessageIds.current.has(latestMessage.id)) { + return; + } + + // Handle JSON messages + if (latestMessage.type === 'json_message') { + try { + // The message should already be a parsed JSON object + const messageData = latestMessage.content; + + // Skip if we've already processed this message ID + if (processedMessageIds.current.has(messageData.id)) { + return; + } + + // Add to processed set to avoid duplicates + processedMessageIds.current.add(messageData.id); + + // Add the message to our state setMessages(prev => [...prev, { - id: Date.now().toString(), - sender: 'agent', - content: latestData.content, - timestamp: new Date().toISOString(), + id: messageData.id, + sender: messageData.sender, + content: messageData.content, + timestamp: messageData.timestamp, }]); + } catch (err) { + console.error('Error processing JSON message:', err); } } - }, [sseData]); + }, [sseMessages]); + + // Process status updates + useEffect(() => { + if (!statusUpdates || statusUpdates.length === 0) return; + + const latestStatus = statusUpdates[statusUpdates.length - 1]; + + // Handle status updates + if (latestStatus.type === 'status') { + try { + // The status should be a parsed JSON object + const statusData = latestStatus.content; + + if (statusData.status === 'processing') { + setSending(true); + } else if (statusData.status === 'completed') { + setSending(false); + } + } catch (err) { + console.error('Error processing status update:', err); + } + } + }, [statusUpdates]); + + // Process error messages + useEffect(() => { + if (!errorMessages || errorMessages.length === 0) return; + + const latestError = errorMessages[errorMessages.length - 1]; + + try { + // The error should be a parsed JSON object + const errorData = latestError.content; + + if (errorData.error) { + setError(errorData.error); + } + } catch (err) { + console.error('Error processing error message:', err); + } + }, [errorMessages]); // Send a message to the agent const sendMessage = useCallback(async (content) => { - if (!agentName || !content) return; + if (!agentName || !content) return false; setSending(true); setError(null); - // Add user message to the list + // Add user message to the local state immediately for better UX + const messageId = `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; + const userMessage = { - id: Date.now().toString(), + id: messageId, sender: 'user', content, timestamp: new Date().toISOString(), @@ -50,21 +116,21 @@ export function useChat(agentName) { setMessages(prev => [...prev, userMessage]); try { + // Use the JSON-based API endpoint await chatApi.sendMessage(agentName, content); - // The agent's response will come through SSE return true; } catch (err) { setError(err.message || 'Failed to send message'); console.error('Error sending message:', err); - return false; - } finally { setSending(false); + return false; } }, [agentName]); // Clear chat history const clearChat = useCallback(() => { setMessages([]); + processedMessageIds.current.clear(); }, []); return { diff --git a/webui/react-ui/src/hooks/useSSE.js b/webui/react-ui/src/hooks/useSSE.js index c24351f..2218c77 100644 --- a/webui/react-ui/src/hooks/useSSE.js +++ b/webui/react-ui/src/hooks/useSSE.js @@ -1,63 +1,130 @@ -import { useState, useEffect } from 'react'; +import { useState, useEffect, useCallback, useRef } from 'react'; import { API_CONFIG } from '../utils/config'; /** - * Helper function to build a full URL - * @param {string} endpoint - API endpoint - * @returns {string} - Full URL - */ -const buildUrl = (endpoint) => { - return `${API_CONFIG.baseUrl}${endpoint.startsWith('/') ? endpoint.substring(1) : endpoint}`; -}; - -/** - * Custom hook for handling Server-Sent Events (SSE) + * Custom hook for Server-Sent Events (SSE) * @param {string} agentName - Name of the agent to connect to - * @returns {Object} - SSE data and connection status + * @returns {Object} - SSE state and messages */ export function useSSE(agentName) { - const [data, setData] = useState([]); + const [messages, setMessages] = useState([]); + const [statusUpdates, setStatusUpdates] = useState([]); + const [errorMessages, setErrorMessages] = useState([]); const [isConnected, setIsConnected] = useState(false); - const [error, setError] = useState(null); + const eventSourceRef = useRef(null); - useEffect(() => { + // Connect to SSE endpoint + const connect = useCallback(() => { if (!agentName) return; - - // Create EventSource for SSE connection - const eventSource = new EventSource(buildUrl(API_CONFIG.endpoints.sse(agentName))); - // Connection opened + // Close existing connection if any + if (eventSourceRef.current) { + eventSourceRef.current.close(); + } + + // Create a new EventSource connection + const sseUrl = `${API_CONFIG.baseUrl}${API_CONFIG.endpoints.sse(agentName)}`; + const eventSource = new EventSource(sseUrl); + eventSourceRef.current = eventSource; + + // Handle connection open eventSource.onopen = () => { + console.log('SSE connection opened'); setIsConnected(true); - setError(null); }; - // Handle incoming messages - eventSource.onmessage = (event) => { - try { - const parsedData = JSON.parse(event.data); - setData((prevData) => [...prevData, parsedData]); - } catch (err) { - console.error('Error parsing SSE data:', err); - } - }; - - // Handle errors - eventSource.onerror = (err) => { + // Handle connection error + eventSource.onerror = (error) => { + console.error('SSE connection error:', error); setIsConnected(false); - setError('SSE connection error'); - console.error('SSE connection error:', err); + + // Try to reconnect after a delay + setTimeout(() => { + if (eventSourceRef.current === eventSource) { + connect(); + } + }, 5000); }; - // Clean up on unmount + // Handle 'json_message' event + eventSource.addEventListener('json_message', (event) => { + try { + const data = JSON.parse(event.data); + const timestamp = data.timestamp || new Date().toISOString(); + + setMessages(prev => [...prev, { + id: `json-message-${Date.now()}`, + type: 'json_message', + content: data, + timestamp, + }]); + } catch (error) { + console.error('Error parsing JSON message:', error); + } + }); + + // Handle 'status' event + eventSource.addEventListener('status', (event) => { + try { + const data = JSON.parse(event.data); + const timestamp = data.timestamp || new Date().toISOString(); + + setStatusUpdates(prev => [...prev, { + id: `json-status-${Date.now()}`, + type: 'status', + content: data, + timestamp, + }]); + } catch (error) { + console.error('Error parsing status message:', error); + } + }); + + // Handle 'error' event + eventSource.addEventListener('error', (event) => { + try { + const data = JSON.parse(event.data); + const timestamp = data.timestamp || new Date().toISOString(); + + setErrorMessages(prev => [...prev, { + id: `error-${Date.now()}`, + type: 'error', + content: data, + timestamp, + }]); + } catch (error) { + console.error('Error parsing error message:', error); + } + }); + return () => { eventSource.close(); - setIsConnected(false); }; }, [agentName]); - // Function to clear the data - const clearData = () => setData([]); + // Connect on mount and when agentName changes + useEffect(() => { + connect(); + + // Cleanup on unmount + return () => { + if (eventSourceRef.current) { + eventSourceRef.current.close(); + eventSourceRef.current = null; + } + }; + }, [connect]); - return { data, isConnected, error, clearData }; + // Reconnect function + const reconnect = useCallback(() => { + connect(); + }, [connect]); + + return { + messages, + statusUpdates, + errorMessages, + isConnected, + reconnect, + }; } diff --git a/webui/react-ui/src/pages/Chat.jsx b/webui/react-ui/src/pages/Chat.jsx index 557a87e..a33e38f 100644 --- a/webui/react-ui/src/pages/Chat.jsx +++ b/webui/react-ui/src/pages/Chat.jsx @@ -41,64 +41,94 @@ function Chat() { } }; + // Handle pressing Enter to send (Shift+Enter for new line) + const handleKeyDown = (e) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + handleSubmit(e); + } + }; + return ( -
No messages yet. Start a conversation with {name}!
-Start a conversation with {name}!