fix(ui): Re-add Chat

This commit is contained in:
Richard Palethorpe
2025-03-27 21:23:17 +00:00
parent c96c8d8009
commit 45078e1fa7
7 changed files with 393 additions and 104 deletions

View File

@@ -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 {

View File

@@ -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 (!sseMessages || sseMessages.length === 0) return;
if (latestData.type === 'message') {
// 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 {

View File

@@ -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)));
// Close existing connection if any
if (eventSourceRef.current) {
eventSourceRef.current.close();
}
// Connection opened
// 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();
return { data, isConnected, error, clearData };
// Cleanup on unmount
return () => {
if (eventSourceRef.current) {
eventSourceRef.current.close();
eventSourceRef.current = null;
}
};
}, [connect]);
// Reconnect function
const reconnect = useCallback(() => {
connect();
}, [connect]);
return {
messages,
statusUpdates,
errorMessages,
isConnected,
reconnect,
};
}

View File

@@ -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 (
<div className="chat-container">
<header className="chat-header">
<div className="agents-container">
<header className="page-header">
<h1>Chat with {name}</h1>
<div className="connection-status">
<span className={`status-indicator ${isConnected ? 'connected' : 'disconnected'}`}>
<div className="connection-status" style={{ display: 'flex', alignItems: 'center' }}>
<span
className={isConnected ? 'active' : 'inactive'}
style={{
position: 'static',
display: 'inline-block',
padding: '5px 12px',
borderRadius: '20px',
fontSize: '0.8rem',
fontWeight: '500',
textTransform: 'uppercase',
letterSpacing: '1px',
boxShadow: '0 0 10px rgba(0, 0, 0, 0.2)',
marginLeft: '10px'
}}
>
{isConnected ? 'Connected' : 'Disconnected'}
</span>
</div>
<button
className="clear-chat-btn"
className="action-btn delete-btn"
onClick={clearChat}
disabled={messages.length === 0}
>
Clear Chat
<i className="fas fa-trash-alt"></i> Clear Chat
</button>
</header>
<div className="messages-container">
{messages.length === 0 ? (
<div className="empty-chat">
<p>No messages yet. Start a conversation with {name}!</p>
</div>
) : (
messages.map((msg) => (
<div
key={msg.id}
className={`message ${msg.sender === 'user' ? 'user-message' : 'agent-message'}`}
>
<div className="message-content">
{msg.content}
</div>
<div className="message-timestamp">
{new Date(msg.timestamp).toLocaleTimeString()}
</div>
<div className="chat-container">
<div className="chat-messages">
{messages.length === 0 ? (
<div className="no-agents">
<h2>No messages yet</h2>
<p>Start a conversation with {name}!</p>
</div>
))
)}
<div ref={messagesEndRef} />
</div>
) : (
messages.map((msg) => (
<div
key={msg.id}
className={`message ${msg.sender === 'user' ? 'message-user' : 'message-agent'}`}
>
<div className="message-content">
{msg.content}
</div>
<div className="message-timestamp">
{new Date(msg.timestamp).toLocaleTimeString()}
</div>
</div>
))
)}
<div ref={messagesEndRef} />
</div>
<form className="message-form" onSubmit={handleSubmit}>
<input
type="text"
value={message}
onChange={(e) => setMessage(e.target.value)}
placeholder="Type your message..."
disabled={sending || !isConnected}
className="message-input"
/>
<button
type="submit"
disabled={sending || !message.trim() || !isConnected}
className="send-button"
>
{sending ? 'Sending...' : 'Send'}
</button>
</form>
<div className="chat-input">
<form className="message-form" onSubmit={handleSubmit} style={{ display: 'flex', gap: '1rem', width: '100%' }}>
<textarea
value={message}
onChange={(e) => setMessage(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Type your message... (Press Enter to send, Shift+Enter for new line)"
disabled={sending || !isConnected}
className="form-control"
rows={2}
style={{ flex: 1, resize: 'vertical', minHeight: '38px', maxHeight: '150px' }}
/>
<button
type="submit"
disabled={sending || !message.trim() || !isConnected}
className="action-btn chat-btn"
style={{ alignSelf: 'flex-end' }}
>
<i className={`fas ${sending ? 'fa-spinner fa-spin' : 'fa-paper-plane'}`}></i> {sending ? 'Sending...' : 'Send'}
</button>
</form>
</div>
</div>
</div>
);
}

View File

@@ -177,9 +177,9 @@ export const agentApi = {
// Chat-related API calls
export const chatApi = {
// Send a chat message to an agent
// Send a message to an agent using the JSON-based API
sendMessage: async (name, message) => {
const response = await fetch(buildUrl(API_CONFIG.endpoints.chat(name)), {
const response = await fetch(buildUrl(API_CONFIG.endpoints.chatApi(name)), {
method: 'POST',
headers: API_CONFIG.headers,
body: JSON.stringify({ message }),

View File

@@ -34,6 +34,7 @@ export const API_CONFIG = {
// Chat endpoints
chat: (name) => `/chat/${name}`,
chatApi: (name) => `/api/chat/${name}`,
notify: (name) => `/notify/${name}`,
responses: '/v1/responses',

View File

@@ -135,6 +135,9 @@ func (app *App) registerRoutes(pool *state.AgentPool, webapp *fiber.App) {
webapp.Put("/api/agent/:name/pause", app.Pause(pool))
webapp.Put("/api/agent/:name/start", app.Start(pool))
// Add JSON-based chat API endpoint
webapp.Post("/api/chat/:name", app.ChatAPI(pool))
webapp.Post("/v1/responses", app.Responses(pool))
webapp.Get("/talk/:name", func(c *fiber.Ctx) error {