From ac8167297cdf63fd2211f27da8b97f24e4e6ffc4 Mon Sep 17 00:00:00 2001 From: pingandai Date: Sat, 7 Mar 2026 09:27:46 +0800 Subject: [PATCH] feat: improve UI and add agent-to-agent chat MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add CSS styles for modern chat interface - Fix sent messages not being displayed locally - Add agent selector to trigger specific agent responses - Broadcast agent responses via WebSocket - Distinguish sent/received/system messages visually 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- backend/main.py | 56 +++++- backend/websocket_manager.py | 9 + frontend/src/App.css | 379 +++++++++++++++++++++++++++++++++++ frontend/src/App.tsx | 285 +++++++++++++++++++++----- 4 files changed, 668 insertions(+), 61 deletions(-) create mode 100644 frontend/src/App.css diff --git a/backend/main.py b/backend/main.py index e6dc79c..ac36d99 100644 --- a/backend/main.py +++ b/backend/main.py @@ -1,4 +1,8 @@ """OpenCLAW Bot Chat Backend""" + +import subprocess +import re + from fastapi import FastAPI, HTTPException from pydantic import BaseModel from openclaw_client import OpenCLAWClient @@ -20,11 +24,43 @@ def root(): return {"message": "OpenCLAW Bot Chat API"} +def get_agents_from_openclaw() -> list[dict]: + """Get agents from OpenCLAW CLI.""" + try: + output = subprocess.check_output( + ["openclaw", "agents", "list"], timeout=10, text=True + ) + agents = [] + for line in output.split("\n"): + # Match "- agentName" or "- agentName (default)" format + match = re.match(r"^- (\w+)", line) + if match: + agent_id = match.group(1) + is_default = "(default)" in line + agents.append( + { + "id": agent_id, + "name": agent_id, + "emoji": "⭐" if is_default else "🤖", + } + ) + return agents + except Exception as e: + print(f"[ERROR] Failed to get agents: {e}") + return [] + + @app.get("/api/agents") async def get_agents(): """Get list of agents from OpenCLAW Gateway.""" try: - agents = await openclaw_client.get_agents() + agents = get_agents_from_openclaw() + if not agents: + # Fallback to default agents + agents = [ + {"id": "main", "name": "Main Agent", "emoji": "🤖"}, + {"id": "default", "name": "Default Agent", "emoji": "🦊"}, + ] return {"agents": agents} except Exception as e: raise HTTPException(status_code=503, detail=str(e)) @@ -32,13 +68,20 @@ async def get_agents(): @app.post("/api/chat") async def chat(request: ChatRequest): - """Send message to an agent.""" + """Send message to an agent and broadcast response via WebSocket.""" if not request.message: raise HTTPException(status_code=400, detail="Message is required") - + agent_id = request.agent_id or "default" try: result = await openclaw_client.send_message(agent_id, request.message) + + # Broadcast agent response to all connected clients via WebSocket + response_content = ( + result.get("response") or result.get("message") or str(result) + ) + await ws_manager.broadcast({"client_id": agent_id, "message": response_content}) + return result except Exception as e: if "not found" in str(e).lower(): @@ -54,9 +97,8 @@ async def websocket_endpoint(websocket: WebSocket, client_id: str): while True: data = await websocket.receive_text() # Broadcast to all clients - await ws_manager.broadcast({ - "client_id": client_id, - "message": data - }, exclude=client_id) + await ws_manager.broadcast( + {"client_id": client_id, "message": data}, exclude=client_id + ) except Exception: ws_manager.disconnect(client_id) diff --git a/backend/websocket_manager.py b/backend/websocket_manager.py index 354171d..3575145 100644 --- a/backend/websocket_manager.py +++ b/backend/websocket_manager.py @@ -1,4 +1,5 @@ """WebSocket Manager for real-time chat.""" + from typing import Dict from fastapi import WebSocket @@ -28,3 +29,11 @@ async def broadcast(self, message: dict, exclude: str | None = None): except Exception: # Handle disconnection gracefully self.disconnect(client_id) + + async def send_to(self, client_id: str, message: dict): + """Send a message to a specific client.""" + if client_id in self.active_connections: + try: + await self.active_connections[client_id].send_json(message) + except Exception: + self.disconnect(client_id) diff --git a/frontend/src/App.css b/frontend/src/App.css new file mode 100644 index 0000000..9243c97 --- /dev/null +++ b/frontend/src/App.css @@ -0,0 +1,379 @@ +/* OpenCLAW Bot Chat Styles */ + +* { + box-sizing: border-box; +} + +body { + margin: 0; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif; + background: #f5f7fa; +} + +.app-container { + display: flex; + height: 100vh; + background: #f5f7fa; +} + +/* Sidebar */ +.sidebar { + width: 280px; + background: linear-gradient(180deg, #1a1a2e 0%, #16213e 100%); + color: white; + display: flex; + flex-direction: column; + box-shadow: 2px 0 10px rgba(0, 0, 0, 0.1); +} + +.sidebar-header { + padding: 20px; + border-bottom: 1px solid rgba(255, 255, 255, 0.1); +} + +.sidebar-header h1 { + margin: 0; + font-size: 18px; + font-weight: 600; + display: flex; + align-items: center; + gap: 8px; +} + +.sidebar-header .subtitle { + font-size: 12px; + color: rgba(255, 255, 255, 0.5); + margin-top: 4px; +} + +.agent-list { + flex: 1; + overflow-y: auto; + padding: 12px; +} + +.agent-list-title { + font-size: 11px; + text-transform: uppercase; + color: rgba(255, 255, 255, 0.4); + padding: 8px 12px; + letter-spacing: 1px; +} + +.agent-item { + display: flex; + align-items: center; + padding: 12px; + margin-bottom: 8px; + border-radius: 12px; + cursor: pointer; + transition: all 0.2s ease; + background: rgba(255, 255, 255, 0.05); +} + +.agent-item:hover { + background: rgba(255, 255, 255, 0.1); + transform: translateX(4px); +} + +.agent-item.active { + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + box-shadow: 0 4px 15px rgba(102, 126, 234, 0.4); +} + +.agent-avatar { + width: 40px; + height: 40px; + border-radius: 50%; + background: rgba(255, 255, 255, 0.1); + display: flex; + align-items: center; + justify-content: center; + font-size: 20px; + margin-right: 12px; +} + +.agent-info { + flex: 1; +} + +.agent-name { + font-weight: 500; + font-size: 14px; +} + +.agent-status { + font-size: 11px; + color: rgba(255, 255, 255, 0.5); + display: flex; + align-items: center; + gap: 4px; +} + +.status-dot { + width: 6px; + height: 6px; + border-radius: 50%; + background: #4ade80; +} + +/* Chat Area */ +.chat-container { + flex: 1; + display: flex; + flex-direction: column; + background: white; +} + +.chat-header { + padding: 16px 24px; + background: white; + border-bottom: 1px solid #e5e7eb; + display: flex; + align-items: center; + justify-content: space-between; +} + +.chat-header-title { + font-size: 16px; + font-weight: 600; + color: #1f2937; +} + +.chat-header-subtitle { + font-size: 12px; + color: #6b7280; +} + +/* Messages */ +.messages-container { + flex: 1; + overflow-y: auto; + padding: 24px; + display: flex; + flex-direction: column; + gap: 16px; + background: #f9fafb; +} + +.message { + display: flex; + flex-direction: column; + max-width: 70%; + animation: fadeIn 0.3s ease; +} + +@keyframes fadeIn { + from { + opacity: 0; + transform: translateY(10px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.message.sent { + align-self: flex-end; +} + +.message.received { + align-self: flex-start; +} + +.message.system { + align-self: center; + max-width: 80%; +} + +.message-bubble { + padding: 12px 16px; + border-radius: 16px; + font-size: 14px; + line-height: 1.5; + word-wrap: break-word; +} + +.message.sent .message-bubble { + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + color: white; + border-bottom-right-radius: 4px; +} + +.message.received .message-bubble { + background: white; + color: #1f2937; + border: 1px solid #e5e7eb; + border-bottom-left-radius: 4px; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05); +} + +.message.system .message-bubble { + background: #fef3c7; + color: #92400e; + border: 1px solid #fcd34d; + font-size: 13px; + text-align: center; +} + +.message-meta { + display: flex; + align-items: center; + gap: 8px; + margin-top: 4px; + padding: 0 4px; +} + +.message.sent .message-meta { + justify-content: flex-end; +} + +.message-sender { + font-size: 12px; + font-weight: 500; + color: #6b7280; +} + +.message-time { + font-size: 11px; + color: #9ca3af; +} + +/* Input Area */ +.input-container { + padding: 16px 24px; + background: white; + border-top: 1px solid #e5e7eb; +} + +.input-wrapper { + display: flex; + align-items: center; + background: #f3f4f6; + border-radius: 12px; + padding: 4px; +} + +.input-wrapper:focus-within { + background: white; + box-shadow: 0 0 0 2px #667eea; +} + +.message-input { + flex: 1; + border: none; + background: transparent; + padding: 12px 16px; + font-size: 14px; + outline: none; +} + +.message-input::placeholder { + color: #9ca3af; +} + +.send-button { + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + color: white; + border: none; + padding: 10px 20px; + border-radius: 8px; + font-size: 14px; + font-weight: 500; + cursor: pointer; + transition: all 0.2s ease; +} + +.send-button:hover { + transform: scale(1.02); + box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4); +} + +.send-button:disabled { + opacity: 0.5; + cursor: not-allowed; + transform: none; +} + +/* Agent Selector in Input */ +.agent-selector { + display: flex; + gap: 8px; + margin-bottom: 12px; + flex-wrap: wrap; +} + +.agent-chip { + display: flex; + align-items: center; + gap: 6px; + padding: 6px 12px; + border-radius: 20px; + font-size: 13px; + cursor: pointer; + transition: all 0.2s ease; + border: 1px solid #e5e7eb; + background: white; +} + +.agent-chip:hover { + border-color: #667eea; +} + +.agent-chip.selected { + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + color: white; + border-color: transparent; +} + +.agent-chip-emoji { + font-size: 16px; +} + +/* Empty State */ +.empty-state { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + color: #9ca3af; + padding: 40px; +} + +.empty-state-icon { + font-size: 48px; + margin-bottom: 16px; + opacity: 0.5; +} + +.empty-state-text { + font-size: 14px; +} + +/* Loading */ +.loading { + display: flex; + align-items: center; + justify-content: center; + padding: 20px; + color: #6b7280; +} + +.loading::after { + content: ''; + width: 16px; + height: 16px; + border: 2px solid #e5e7eb; + border-top-color: #667eea; + border-radius: 50%; + animation: spin 0.8s linear infinite; + margin-left: 8px; +} + +@keyframes spin { + to { + transform: rotate(360deg); + } +} \ No newline at end of file diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 4d91e8c..a6d6e82 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,4 +1,5 @@ -import { useState, useEffect } from 'react' +import { useState, useEffect, useRef } from 'react' +import './App.css' interface Agent { id: string @@ -13,6 +14,7 @@ interface Message { senderName: string content: string timestamp: Date + type: 'sent' | 'received' | 'system' } function App() { @@ -20,6 +22,17 @@ function App() { const [messages, setMessages] = useState([]) const [input, setInput] = useState('') const [ws, setWs] = useState(null) + const [selectedAgent, setSelectedAgent] = useState(null) + const [isLoading, setIsLoading] = useState(false) + const messagesEndRef = useRef(null) + + const scrollToBottom = () => { + messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }) + } + + useEffect(() => { + scrollToBottom() + }, [messages]) useEffect(() => { // Fetch agents on mount @@ -30,76 +43,240 @@ function App() { // Connect WebSocket const socket = new WebSocket(`ws://${window.location.host}/ws/user`) - + + socket.onopen = () => { + console.log('WebSocket connected') + } + socket.onmessage = (event) => { - const data = JSON.parse(event.data) - setMessages(prev => [...prev, { - id: Date.now().toString(), - sender: data.client_id, - senderName: data.client_id, - content: data.message, - timestamp: new Date() - }]) + try { + const data = JSON.parse(event.data) + setMessages(prev => [...prev, { + id: Date.now().toString(), + sender: data.client_id, + senderName: data.client_id, + content: data.message, + timestamp: new Date(), + type: 'received' + }]) + } catch (e) { + // Handle non-JSON messages + setMessages(prev => [...prev, { + id: Date.now().toString(), + sender: 'system', + senderName: 'System', + content: event.data, + timestamp: new Date(), + type: 'system' + }]) + } + } + + socket.onerror = (error) => { + console.error('WebSocket error:', error) } - + setWs(socket) - + return () => socket.close() }, []) - const sendMessage = () => { - if (!input.trim() || !ws) return - - ws.send(input) + const sendMessage = async () => { + if (!input.trim()) return + + const messageText = input.trim() + const currentAgent = selectedAgent + + // Add user's message to the chat immediately + const userMessage: Message = { + id: Date.now().toString(), + sender: 'user', + senderName: 'You', + content: messageText, + timestamp: new Date(), + type: 'sent' + } + setMessages(prev => [...prev, userMessage]) setInput('') + + // Also broadcast via WebSocket for real-time sync + if (ws && ws.readyState === WebSocket.OPEN) { + ws.send(messageText) + } + + // If an agent is selected, send to that agent via API + if (currentAgent) { + setIsLoading(true) + try { + const response = await fetch('/api/chat', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + message: messageText, + agent_id: currentAgent + }) + }) + + if (!response.ok) { + throw new Error(`HTTP ${response.status}`) + } + + const result = await response.json() + + // Add agent's response to the chat + const agentResponse: Message = { + id: (Date.now() + 1).toString(), + sender: currentAgent, + senderName: currentAgent, + content: result.response || result.message || JSON.stringify(result), + timestamp: new Date(), + type: 'received' + } + setMessages(prev => [...prev, agentResponse]) + } catch (error) { + console.error('Failed to send to agent:', error) + const errorMessage: Message = { + id: (Date.now() + 1).toString(), + sender: 'system', + senderName: 'System', + content: `Failed to send message to ${currentAgent}: ${error instanceof Error ? error.message : 'Unknown error'}`, + timestamp: new Date(), + type: 'system' + } + setMessages(prev => [...prev, errorMessage]) + } finally { + setIsLoading(false) + } + } + } + + const formatTime = (date: Date) => { + return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) } return ( -
- {/* Agent List Sidebar */} -
-

Agents

- {agents.length === 0 ? ( -

No agents available

- ) : ( -
    - {agents.map(agent => ( -
  • - {agent.emoji || '🤖'} - {agent.name} -
  • - ))} -
- )} +
+ {/* Sidebar */} +
+
+

🤖 OpenCLAW

+
Multi-Agent Chat
+
+ +
+
Available Agents
+ {agents.length === 0 ? ( +
+
😔
+
No agents available
+
+ ) : ( + agents.map(agent => ( +
setSelectedAgent(selectedAgent === agent.id ? null : agent.id)} + > +
+ {agent.emoji || '🤖'} +
+
+
{agent.name}
+
+ + Online +
+
+
+ )) + )} +
- + {/* Chat Area */} -
-
- {messages.map(msg => ( -
- {msg.senderName}: {msg.content} +
+
+
+ {selectedAgent ? `Chat with ${selectedAgent}` : 'General Chat'} +
+
+ {selectedAgent + ? `Sending messages directly to ${selectedAgent}` + : 'Broadcasting to all connected clients'} +
+
+ +
+ {messages.length === 0 ? ( +
+
💬
+
+ No messages yet. Select an agent and start chatting! +
- ))} + ) : ( + messages.map(msg => ( +
+ {msg.type !== 'sent' && ( +
+ {msg.senderName} + {formatTime(msg.timestamp)} +
+ )} +
{msg.content}
+ {msg.type === 'sent' && ( +
+ {formatTime(msg.timestamp)} +
+ )} +
+ )) + )} +
- - {/* Input Area */} -
- setInput(e.target.value)} - onKeyDown={(e) => e.key === 'Enter' && sendMessage()} - placeholder="Type a message... (use @agent to mention)" - style={{ flex: 1, padding: '8px', fontSize: '16px' }} - /> - + +
+ {agents.length > 0 && ( +
+ {agents.slice(0, 5).map(agent => ( +
setSelectedAgent(selectedAgent === agent.id ? null : agent.id)} + > + {agent.emoji || '🤖'} + {agent.name} +
+ ))} + {agents.length > 5 && ( +
+{agents.length - 5} more
+ )} +
+ )} +
+ setInput(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && !isLoading && sendMessage()} + placeholder={selectedAgent + ? `Message ${selectedAgent}...` + : 'Type a message... (select agent to trigger bot)'} + disabled={isLoading} + /> + +
) } -export default App +export default App \ No newline at end of file