FEAT WebUI (#61)

* FEAT webui to run RA.Aid from a browser

* FEAT startin webui from ra-aid cmd

* FEAT updating readme

* FEAT adding ADR for webui

* FEAT marking webui as alpha feature
This commit is contained in:
Jose M Leon 2025-01-29 20:10:10 -05:00 committed by GitHub
parent 1b239f07bf
commit 0e188c961b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 1175 additions and 4 deletions

2
.gitignore vendored
View File

@ -12,3 +12,5 @@ __pycache__/
/.idea
/htmlcov
.envrc
appmap.log

View File

@ -1,4 +1,4 @@
include README.md
include LICENSE
include requirements*.txt
recursive-include agent_langchain *.py
include README.md
include CHANGELOG.md
recursive-include ra_aid/webui/static *

View File

@ -183,6 +183,9 @@ ra-aid -m "Add new feature" --verbose
- `--auto-test`: Automatically run tests after each code change
- `--max-test-cmd-retries`: Maximum number of test command retry attempts (default: 3)
- `--version`: Show program version number and exit
- `--webui`: Launch the web interface (alpha feature)
- `--webui-host`: Host to listen on for web interface (default: 0.0.0.0) (alpha feature)
- `--webui-port`: Port to listen on for web interface (default: 8080) (alpha feature)
### Example Tasks
@ -259,6 +262,38 @@ Make sure to set your TAVILY_API_KEY environment variable to enable this feature
Enable with `--chat` to transform ra-aid into an interactive assistant that guides you through research and implementation tasks. Have a natural conversation about what you want to build, explore options together, and dispatch work - all while maintaining context of your discussion. Perfect for when you want to think through problems collaboratively rather than just executing commands.
### Web Interface
RA.Aid includes a modern web interface that provides:
- Beautiful dark-themed chat interface
- Real-time streaming of command output
- Request history with quick resubmission
- Responsive design that works on all devices
To launch the web interface:
```bash
# Start with default settings (0.0.0.0:8080)
ra-aid --webui
# Specify custom host and port
ra-aid --webui --webui-host 127.0.0.1 --webui-port 3000
```
Command line options for web interface:
- `--webui`: Launch the web interface
- `--webui-host`: Host to listen on (default: 0.0.0.0)
- `--webui-port`: Port to listen on (default: 8080)
After starting the server, open your web browser to the displayed URL (e.g., http://localhost:8080). The interface provides:
- Left sidebar showing request history
- Main chat area with real-time output
- Input box for typing requests
- Automatic reconnection handling
- Error reporting and status messages
All ra-aid commands sent through the web interface automatically use cowboy mode for seamless execution.
### Command Interruption and Feedback
<img src="assets/demo-chat-mode-interrupted-1.gif" alt="Command Interrupt Demo" autoplay loop style="display: block; margin: 0 auto; width: 100%; max-width: 800px;">

20
adr/webui.md Normal file
View File

@ -0,0 +1,20 @@
# A couple thoughts:
1. I like that you're using websockets. I think a bi-directional message passing is what we will ultimately need, and this sets up the groundwork for that.
2. Since rn we're just sending raw ra-aid text output to the front-end, an easy win to make it render nicer would be to put it in a non-wrapping or similar.
3. I like the minimal html/js as an initial setup.
Thoughts for down the line if we merge this (the following would be in future PRs):
1. We'll ultimately want to stream structured objects from the agent
* Rn, the code has a ton of cases like console.print(Panel(panel_content, title="Expert Context", border_style="blue")). We will want to abstract these calls and have something like a send_agent_output function. Depending on config (e.g. CLI use vs web ui use), that would send an object onto the web socket stream, or send output to the console.
2. We need a way to send user inputs back to the agent, e.g. anything currently using stdin (we don't use stdin directly rn, but we do have things that use tty or prompt the user for input which ultimately do use stdin)
3. Tricky run_shell_command is really powerful, and it essentially passes fully interactive TTY through to the user. If we want to keep the same UX, we could do this potentially by using tty.js on the front-end and streaming the tty over websocket. Alternatively, we could simply disable TTY tools like run_shell_command or restrict them to a non-tty mode (e.g. simple command running, stdout/stderr capture only)
4. Ideally we should keep the websocket and any API endpoints friendly to machine endpoints. JSON objects over websocket + any JSON REST endpoints we might need would satisfy this.
5. The UX itself should be carefully considered. IMO one of the big benefits of a web UI is to be able to have the agent doing work which I can direct from a mobile device, so I think an efficient mobile-first UI would be ideal. For this, my initial thought was something like react + shadcn.
6. Initially I had considered https://socket.io/. In this PR, we're just going straight to websockets, which is cool and has one less dependency. Not sure if socket.io would be worth it, but wanted to mention it. The overall architecture wouldn't change.
7. Semi-related: we should have a way to send logs to a directory or to a logging backend.
@leonj1 I think starting with a small PR like this and then incrementally adding to the web UI is a good approach.
@sosacrazy126 would be interested in your thoughts since I know you were working on webui as well, want to respect the work you've done.

View File

@ -37,7 +37,11 @@ dependencies = [
"pathspec>=0.11.0",
"aider-chat>=0.72.1",
"tavily-python>=0.5.0",
"litellm"
"litellm",
"fastapi>=0.104.0",
"uvicorn>=0.24.0",
"websockets>=12.0",
"jinja2>=3.1.2"
]
[project.optional-dependencies]

View File

@ -31,6 +31,12 @@ from ra_aid.tools.memory import _global_memory
logger = get_logger(__name__)
def launch_webui(host: str, port: int):
"""Launch the RA.Aid web interface."""
from ra_aid.webui import run_server
print(f"Starting RA.Aid web interface on http://{host}:{port}")
run_server(host=host, port=port)
def parse_arguments(args=None):
VALID_PROVIDERS = [
"anthropic",
@ -166,6 +172,23 @@ Examples:
default=DEFAULT_MAX_TEST_CMD_RETRIES,
help="Maximum number of retries for the test command (default: 10)",
)
parser.add_argument(
"--webui",
action="store_true",
help="Launch the web interface",
)
parser.add_argument(
"--webui-host",
type=str,
default="0.0.0.0",
help="Host to listen on for web interface (default: 0.0.0.0)",
)
parser.add_argument(
"--webui-port",
type=int,
default=8080,
help="Port to listen on for web interface (default: 8080)",
)
if args is None:
args = sys.argv[1:]
parsed_args = parser.parse_args(args)
@ -246,6 +269,11 @@ def main():
setup_logging(args.verbose)
logger.debug("Starting RA.Aid with arguments: %s", args)
# Launch web interface if requested
if args.webui:
launch_webui(args.webui_host, args.webui_port)
return
try:
# Check dependencies before proceeding
check_dependencies()

5
ra_aid/webui/__init__.py Normal file
View File

@ -0,0 +1,5 @@
"""Web interface module for RA.Aid."""
from .server import run_server
__all__ = ['run_server']

220
ra_aid/webui/server.py Normal file
View File

@ -0,0 +1,220 @@
"""Web interface server implementation for RA.Aid."""
import asyncio
import shutil
import sys
import logging
from pathlib import Path
from typing import List, Dict, Any
import uvicorn
from fastapi import FastAPI, WebSocket, Request, WebSocketDisconnect
from fastapi.staticfiles import StaticFiles
from fastapi.responses import HTMLResponse
from fastapi.middleware.cors import CORSMiddleware
from fastapi.templating import Jinja2Templates
# Configure logging
logging.basicConfig(level=logging.DEBUG) # Set to DEBUG for more info
logger = logging.getLogger(__name__)
# Verify ra-aid is available
if not shutil.which('ra-aid'):
logger.error("ra-aid command not found. Please ensure it's installed and in your PATH")
sys.exit(1)
app = FastAPI()
# Add CORS middleware
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# Get the directory containing static files
STATIC_DIR = Path(__file__).parent / "static"
if not STATIC_DIR.exists():
logger.error(f"Static directory not found at {STATIC_DIR}")
sys.exit(1)
logger.info(f"Using static directory: {STATIC_DIR}")
# Setup templates
templates = Jinja2Templates(directory=str(STATIC_DIR))
class ConnectionManager:
def __init__(self):
self.active_connections: List[WebSocket] = []
async def connect(self, websocket: WebSocket) -> bool:
try:
logger.debug("Accepting WebSocket connection...")
await websocket.accept()
logger.debug("WebSocket connection accepted")
self.active_connections.append(websocket)
return True
except Exception as e:
logger.error(f"Error accepting WebSocket connection: {e}")
return False
def disconnect(self, websocket: WebSocket):
if websocket in self.active_connections:
self.active_connections.remove(websocket)
async def send_message(self, websocket: WebSocket, message: Dict[str, Any]):
try:
await websocket.send_json(message)
except Exception as e:
logger.error(f"Error sending message: {e}")
await self.handle_error(websocket, str(e))
async def handle_error(self, websocket: WebSocket, error_message: str):
try:
await websocket.send_json({
"type": "chunk",
"chunk": {
"tools": {
"messages": [{
"content": f"Error: {error_message}",
"status": "error"
}]
}
}
})
except Exception as e:
logger.error(f"Error sending error message: {e}")
manager = ConnectionManager()
@app.get("/", response_class=HTMLResponse)
async def get_root(request: Request):
"""Serve the index.html file with port parameter."""
return templates.TemplateResponse(
"index.html",
{"request": request, "server_port": request.url.port or 8080}
)
# Mount static files for js and other assets
app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
@app.websocket("/ws")
async def websocket_endpoint(websocket: WebSocket):
client_id = id(websocket)
logger.info(f"New WebSocket connection attempt from client {client_id}")
if not await manager.connect(websocket):
logger.error(f"Failed to accept WebSocket connection for client {client_id}")
return
logger.info(f"WebSocket connection accepted for client {client_id}")
try:
# Send initial connection success message
await manager.send_message(websocket, {
"type": "chunk",
"chunk": {
"agent": {
"messages": [{
"content": "Connected to RA.Aid server",
"status": "info"
}]
}
}
})
while True:
try:
message = await websocket.receive_json()
logger.debug(f"Received message from client {client_id}: {message}")
if message["type"] == "request":
await manager.send_message(websocket, {
"type": "stream_start"
})
try:
# Run ra-aid with the request
cmd = ["ra-aid", "-m", message["content"], "--cowboy-mode"]
logger.info(f"Executing command: {' '.join(cmd)}")
process = await asyncio.create_subprocess_exec(
*cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE
)
logger.info(f"Process started with PID: {process.pid}")
async def read_stream(stream, is_error=False):
while True:
line = await stream.readline()
if not line:
break
try:
decoded_line = line.decode().strip()
if decoded_line:
await manager.send_message(websocket, {
"type": "chunk",
"chunk": {
"tools" if is_error else "agent": {
"messages": [{
"content": decoded_line,
"status": "error" if is_error else "info"
}]
}
}
})
except Exception as e:
logger.error(f"Error processing output: {e}")
# Create tasks for reading stdout and stderr
stdout_task = asyncio.create_task(read_stream(process.stdout))
stderr_task = asyncio.create_task(read_stream(process.stderr, True))
# Wait for both streams to complete
await asyncio.gather(stdout_task, stderr_task)
# Wait for process to complete
return_code = await process.wait()
if return_code != 0:
await manager.handle_error(
websocket,
f"Process exited with code {return_code}"
)
await manager.send_message(websocket, {
"type": "stream_end",
"request": message["content"]
})
except Exception as e:
logger.error(f"Error executing ra-aid: {e}")
await manager.handle_error(websocket, str(e))
except Exception as e:
logger.error(f"Error processing message: {e}")
await manager.handle_error(websocket, str(e))
except WebSocketDisconnect:
logger.info(f"WebSocket client {client_id} disconnected")
except Exception as e:
logger.error(f"WebSocket error for client {client_id}: {e}")
finally:
manager.disconnect(websocket)
logger.info(f"WebSocket connection cleaned up for client {client_id}")
def run_server(host: str = "0.0.0.0", port: int = 8080):
"""Run the FastAPI server."""
logger.info(f"Starting server on {host}:{port}")
uvicorn.run(
app,
host=host,
port=port,
log_level="debug",
ws_max_size=16777216, # 16MB
timeout_keep_alive=0 # Disable keep-alive timeout
)

View File

@ -0,0 +1,90 @@
<!DOCTYPE html>
<html lang="en" class="h-full bg-gray-900">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="server-port" content="{{ server_port }}">
<title>RA.Aid Web Interface</title>
<script src="https://cdn.tailwindcss.com"></script>
<script>
tailwind.config = {
darkMode: 'class',
theme: {
extend: {
colors: {
'dark-primary': '#1a1b26',
'dark-secondary': '#24283b',
'dark-accent': '#7aa2f7',
'dark-text': '#c0caf5'
}
}
}
}
</script>
</head>
<body class="h-full bg-dark-primary text-dark-text">
<div class="flex h-full">
<!-- Sidebar -->
<div class="w-64 bg-dark-secondary border-r border-gray-700 flex flex-col">
<div class="p-4 border-b border-gray-700">
<h2 class="text-xl font-semibold text-dark-accent">History</h2>
</div>
<div id="history-list" class="flex-1 overflow-y-auto p-4 space-y-2"></div>
</div>
<!-- Main Content -->
<div class="flex-1 flex flex-col min-w-0">
<!-- Chat Container -->
<div class="flex-1 overflow-y-auto p-4 space-y-4" id="chat-container">
<div id="chat-messages"></div>
<div id="stream-output" class="hidden font-mono bg-dark-secondary rounded-lg p-4 text-sm"></div>
</div>
<!-- Input Area -->
<div class="border-t border-gray-700 p-4 bg-dark-secondary">
<div class="flex space-x-4">
<textarea
id="user-input"
class="flex-1 bg-dark-primary border border-gray-700 rounded-lg p-3 text-dark-text placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-dark-accent resize-none"
placeholder="Type your request here..."
rows="3"
></textarea>
<button
id="send-button"
class="px-6 py-2 bg-dark-accent text-white rounded-lg hover:bg-opacity-90 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-dark-accent disabled:opacity-50 disabled:cursor-not-allowed h-fit"
>
Send
</button>
</div>
</div>
</div>
</div>
<script>
// Add dynamic styles for messages
const style = document.createElement('style');
style.textContent = `
.message {
@apply mb-4 p-4 rounded-lg max-w-3xl;
}
.user-message {
@apply bg-dark-accent text-white ml-auto;
}
.system-message {
@apply bg-dark-secondary mr-auto;
}
.error-message {
@apply bg-red-900 text-red-100 mr-auto;
}
.history-item {
@apply p-3 rounded-lg hover:bg-dark-primary cursor-pointer transition-colors duration-200 text-sm;
}
#stream-output:not(:empty) {
@apply block;
}
`;
document.head.appendChild(style);
</script>
<script src="/static/script.js"></script>
</body>
</html>

View File

@ -0,0 +1,206 @@
class RAWebUI {
constructor() {
this.messageHistory = [];
this.connectionAttempts = 0;
this.maxReconnectAttempts = 5;
this.setupElements();
this.setupEventListeners();
this.connectWebSocket();
}
setupElements() {
this.userInput = document.getElementById('user-input');
this.sendButton = document.getElementById('send-button');
this.chatMessages = document.getElementById('chat-messages');
this.streamOutput = document.getElementById('stream-output');
this.historyList = document.getElementById('history-list');
// Disable send button initially
this.sendButton.disabled = true;
}
setupEventListeners() {
this.sendButton.addEventListener('click', () => this.sendMessage());
this.userInput.addEventListener('keypress', (e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
this.sendMessage();
}
});
}
async connectWebSocket() {
// Don't try to reconnect if we've exceeded the maximum attempts
if (this.connectionAttempts >= this.maxReconnectAttempts) {
this.appendMessage(
'Maximum reconnection attempts reached. Please refresh the page.',
'error'
);
return;
}
try {
// Get the server port from the meta tag
const serverPort = document.querySelector('meta[name="server-port"]')?.content || '8080';
// Construct WebSocket URL
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsUrl = `${protocol}//${window.location.hostname}:${serverPort}/ws`;
console.log('Attempting to connect to WebSocket URL:', wsUrl);
// Close existing connection if any
if (this.ws) {
this.ws.close();
}
// Create new WebSocket connection
console.log('Creating new WebSocket connection...');
this.ws = new WebSocket(wsUrl);
this.connectionAttempts++;
// Setup WebSocket event handlers
this.ws.onopen = () => {
console.log('WebSocket connection established successfully');
this.connectionAttempts = 0; // Reset counter on successful connection
this.sendButton.disabled = false;
};
this.ws.onclose = (event) => {
console.log('WebSocket closed:', event.code, event.reason);
this.sendButton.disabled = true;
// Only attempt reconnect if not a normal closure and within retry limits
if (event.code !== 1000 && this.connectionAttempts < this.maxReconnectAttempts) {
const delay = Math.min(1000 * Math.pow(2, this.connectionAttempts), 10000);
this.appendMessage(
`Connection lost. Reconnecting in ${delay/1000} seconds...`,
'error'
);
setTimeout(() => this.connectWebSocket(), delay);
}
};
this.ws.onerror = (error) => {
console.error('WebSocket error:', error);
};
this.ws.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
this.handleServerMessage(data);
} catch (error) {
console.error('Error parsing message:', error);
this.appendMessage('Error processing server message', 'error');
}
};
} catch (error) {
console.error('Failed to connect to WebSocket:', error);
this.appendMessage(
`Connection error: ${error.message}. Retrying...`,
'error'
);
// Attempt to reconnect with exponential backoff
const delay = Math.min(1000 * Math.pow(2, this.connectionAttempts), 10000);
setTimeout(() => this.connectWebSocket(), delay);
}
}
handleServerMessage(data) {
if (data.type === 'stream_start') {
this.streamOutput.textContent = '';
this.streamOutput.style.display = 'block';
} else if (data.type === 'stream_end') {
this.streamOutput.style.display = 'none';
this.addToHistory(data.request);
this.sendButton.disabled = false;
} else if (data.type === 'chunk') {
this.handleChunk(data.chunk);
}
}
handleChunk(chunk) {
if (chunk.agent && chunk.agent.messages) {
chunk.agent.messages.forEach(msg => {
if (msg.content) {
if (Array.isArray(msg.content)) {
msg.content.forEach(content => {
if (content.type === 'text' && content.text.trim()) {
this.appendMessage(content.text.trim(), 'system');
}
});
} else if (msg.content.trim()) {
this.appendMessage(msg.content.trim(), 'system');
}
}
});
} else if (chunk.tools && chunk.tools.messages) {
chunk.tools.messages.forEach(msg => {
if (msg.status === 'error' && msg.content) {
this.appendMessage(msg.content.trim(), 'error');
}
});
}
}
appendMessage(content, type) {
const messageDiv = document.createElement('div');
messageDiv.className = `message ${type}-message`;
messageDiv.textContent = content;
this.chatMessages.appendChild(messageDiv);
this.chatMessages.scrollTop = this.chatMessages.scrollHeight;
}
addToHistory(request) {
const historyItem = document.createElement('div');
historyItem.className = 'history-item';
historyItem.textContent = request.slice(0, 50) + (request.length > 50 ? '...' : '');
historyItem.title = request;
historyItem.addEventListener('click', () => {
this.userInput.value = request;
this.userInput.focus();
});
this.historyList.insertBefore(historyItem, this.historyList.firstChild);
this.messageHistory.push(request);
}
sendMessage() {
console.log('Send button clicked');
const message = this.userInput.value.trim();
console.log('Message content:', message);
if (!message) {
console.log('Message is empty, not sending');
return;
}
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
console.error('WebSocket is not connected');
this.appendMessage('Error: Not connected to server. Please wait...', 'error');
return;
}
try {
console.log('Sending message to server');
this.appendMessage(message, 'user');
const payload = { type: 'request', content: message };
console.log('Payload:', payload);
this.ws.send(JSON.stringify(payload));
console.log('Message sent successfully');
this.userInput.value = '';
this.sendButton.disabled = true;
} catch (error) {
console.error('Error sending message:', error);
this.appendMessage(`Error sending message: ${error.message}`, 'error');
this.sendButton.disabled = false;
}
}
}
// Initialize the UI when the page loads
document.addEventListener('DOMContentLoaded', () => {
window.raWebUI = new RAWebUI();
});

109
webui/README.md Normal file
View File

@ -0,0 +1,109 @@
# RA.Aid Web Interface
A modern, dark-themed web interface for RA.Aid that provides:
- Beautiful dark mode chat interface
- Real-time streaming output with syntax highlighting
- Persistent request history with quick resubmission
- Responsive design that works on all devices
## Design
The interface features a modern dark theme using Tailwind CSS with:
- Sleek dark color scheme optimized for readability
- Clean, minimalist design focused on content
- Smooth transitions and hover effects
- Monospace font for code and output display
## Features
- **Left Sidebar**: Shows history of previous requests
- **Main Chat Area**: Displays conversation and streaming output
- **Input Area**: Text input for new requests
- **Real-time Updates**: See RA.Aid output as it happens
- **Error Handling**: Clear display of any errors that occur
## Setup
1. Install the required dependencies:
```bash
pip install -r requirements.txt
```
2. Make sure you have RA.Aid installed in your Python environment
3. Start the web server:
```bash
# Default: Listen on all interfaces (0.0.0.0) port 8080
./server.py
# Specify custom port
./server.py --port 3000
# Specify custom host and port
./server.py --host 127.0.0.1 --port 3000
```
4. Open your web browser and navigate to the server address:
```
# If running locally with default settings:
http://localhost:8080
# If running on a different port:
http://localhost:<port>
# If accessing from another machine:
http://<server-ip>:<port>
```
## Usage
1. Type your request in the input box at the bottom of the screen
2. Press Enter or click the Send button to submit
3. Watch the real-time output in the chat area
4. Previous requests appear in the left sidebar
5. Click any previous request to load it into the input box
## Development
The web interface consists of:
- `index.html`: Main page layout with integrated Tailwind CSS styling
- `script.js`: Client-side functionality and WebSocket handling
- `server.py`: FastAPI WebSocket server with RA.Aid integration
- `requirements.txt`: Python server dependencies
## Technology Stack
- **Frontend**:
- Tailwind CSS for modern, utility-first styling
- Vanilla JavaScript for lightweight client-side operations
- WebSocket for real-time communication
- **Backend**:
- FastAPI for high-performance async server
- WebSocket support for real-time streaming
- Direct integration with ra-aid CLI
- Concurrent stdout/stderr handling
- Error handling and status reporting
## Command Integration
The web interface integrates with ra-aid by:
- Executing `ra-aid -m "<message>" --cowboy-mode` for each request
- Streaming real-time output through WebSocket connection
- Handling both standard output and error streams
- Providing error feedback for failed commands
For example, when you type "What time is it?" in the interface, it executes:
```bash
ra-aid -m "What time is it?" --cowboy-mode
```
The output is streamed in real-time to the web interface, maintaining the same functionality as the CLI but with a modern web interface.
## Architecture
- FastAPI backend with WebSocket support
- Static file serving for web assets
- Real-time bi-directional communication
- Integration with RA.Aid's output streaming
- Browser-based frontend with vanilla JavaScript

90
webui/index.html Normal file
View File

@ -0,0 +1,90 @@
<!DOCTYPE html>
<html lang="en" class="h-full bg-gray-900">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="server-port" content="{{ server_port }}">
<title>RA.Aid Web Interface</title>
<script src="https://cdn.tailwindcss.com"></script>
<script>
tailwind.config = {
darkMode: 'class',
theme: {
extend: {
colors: {
'dark-primary': '#1a1b26',
'dark-secondary': '#24283b',
'dark-accent': '#7aa2f7',
'dark-text': '#c0caf5'
}
}
}
}
</script>
</head>
<body class="h-full bg-dark-primary text-dark-text">
<div class="flex h-full">
<!-- Sidebar -->
<div class="w-64 bg-dark-secondary border-r border-gray-700 flex flex-col">
<div class="p-4 border-b border-gray-700">
<h2 class="text-xl font-semibold text-dark-accent">History</h2>
</div>
<div id="history-list" class="flex-1 overflow-y-auto p-4 space-y-2"></div>
</div>
<!-- Main Content -->
<div class="flex-1 flex flex-col min-w-0">
<!-- Chat Container -->
<div class="flex-1 overflow-y-auto p-4 space-y-4" id="chat-container">
<div id="chat-messages"></div>
<div id="stream-output" class="hidden font-mono bg-dark-secondary rounded-lg p-4 text-sm"></div>
</div>
<!-- Input Area -->
<div class="border-t border-gray-700 p-4 bg-dark-secondary">
<div class="flex space-x-4">
<textarea
id="user-input"
class="flex-1 bg-dark-primary border border-gray-700 rounded-lg p-3 text-dark-text placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-dark-accent resize-none"
placeholder="Type your request here..."
rows="3"
></textarea>
<button
id="send-button"
class="px-6 py-2 bg-dark-accent text-white rounded-lg hover:bg-opacity-90 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-dark-accent disabled:opacity-50 disabled:cursor-not-allowed h-fit"
>
Send
</button>
</div>
</div>
</div>
</div>
<script>
// Add dynamic styles for messages
const style = document.createElement('style');
style.textContent = `
.message {
@apply mb-4 p-4 rounded-lg max-w-3xl;
}
.user-message {
@apply bg-dark-accent text-white ml-auto;
}
.system-message {
@apply bg-dark-secondary mr-auto;
}
.error-message {
@apply bg-red-900 text-red-100 mr-auto;
}
.history-item {
@apply p-3 rounded-lg hover:bg-dark-primary cursor-pointer transition-colors duration-200 text-sm;
}
#stream-output:not(:empty) {
@apply block;
}
`;
document.head.appendChild(style);
</script>
<script src="/static/script.js"></script>
</body>
</html>

4
webui/requirements.txt Normal file
View File

@ -0,0 +1,4 @@
fastapi>=0.104.0
uvicorn>=0.24.0
websockets>=12.0
jinja2>=3.1.2

163
webui/script.js Normal file
View File

@ -0,0 +1,163 @@
class RAWebUI {
constructor() {
this.messageHistory = [];
this.setupElements();
this.setupEventListeners();
this.connectWebSocket();
}
setupElements() {
this.userInput = document.getElementById('user-input');
this.sendButton = document.getElementById('send-button');
this.chatMessages = document.getElementById('chat-messages');
this.streamOutput = document.getElementById('stream-output');
this.historyList = document.getElementById('history-list');
}
setupEventListeners() {
this.sendButton.addEventListener('click', () => this.sendMessage());
this.userInput.addEventListener('keypress', (e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
this.sendMessage();
}
});
}
async connectWebSocket() {
try {
// Get the server port from the response header or default to 8080
const serverPort = document.querySelector('meta[name="server-port"]')?.content || '8080';
// Construct WebSocket URL using the server port
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsUrl = `${protocol}//${window.location.hostname}:${serverPort}/ws`;
console.log('Attempting to connect to WebSocket URL:', wsUrl);
console.log('Creating new WebSocket connection...');
this.ws = new WebSocket(wsUrl);
this.ws.onopen = () => {
console.log('WebSocket connection established successfully');
console.log('Connected to WebSocket server');
this.sendButton.disabled = false;
};
this.ws.onclose = () => {
console.log('Disconnected from WebSocket server');
this.sendButton.disabled = true;
setTimeout(() => this.connectWebSocket(), 5000);
};
this.ws.onerror = (error) => {
console.error('WebSocket error:', error);
};
this.ws.onmessage = (event) => {
const data = JSON.parse(event.data);
this.handleServerMessage(data);
};
} catch (error) {
console.error('Failed to connect to WebSocket:', error);
setTimeout(() => this.connectWebSocket(), 5000);
}
}
handleServerMessage(data) {
if (data.type === 'stream_start') {
this.streamOutput.textContent = '';
this.streamOutput.style.display = 'block';
} else if (data.type === 'stream_end') {
this.streamOutput.style.display = 'none';
this.addToHistory(data.request);
} else if (data.type === 'chunk') {
this.handleChunk(data.chunk);
}
}
handleChunk(chunk) {
if (chunk.agent && chunk.agent.messages) {
chunk.agent.messages.forEach(msg => {
if (msg.content) {
if (Array.isArray(msg.content)) {
msg.content.forEach(content => {
if (content.type === 'text' && content.text.trim()) {
this.appendMessage(content.text.trim(), 'system');
}
});
} else if (msg.content.trim()) {
this.appendMessage(msg.content.trim(), 'system');
}
}
});
} else if (chunk.tools && chunk.tools.messages) {
chunk.tools.messages.forEach(msg => {
if (msg.status === 'error' && msg.content) {
this.appendMessage(msg.content.trim(), 'error');
}
});
}
}
appendMessage(content, type) {
const messageDiv = document.createElement('div');
messageDiv.className = `message ${type}-message`;
messageDiv.textContent = content;
this.chatMessages.appendChild(messageDiv);
this.chatMessages.scrollTop = this.chatMessages.scrollHeight;
}
addToHistory(request) {
const historyItem = document.createElement('div');
historyItem.className = 'history-item';
historyItem.textContent = request.slice(0, 50) + (request.length > 50 ? '...' : '');
historyItem.title = request;
historyItem.addEventListener('click', () => {
this.userInput.value = request;
this.userInput.focus();
});
this.historyList.insertBefore(historyItem, this.historyList.firstChild);
this.messageHistory.push(request);
}
sendMessage() {
console.log('Send button clicked');
const message = this.userInput.value.trim();
console.log('Message content:', message);
if (!message) {
console.log('Message is empty, not sending');
return;
}
console.log('WebSocket state:', this.ws.readyState);
if (this.ws.readyState !== WebSocket.OPEN) {
console.error('WebSocket is not connected');
this.appendMessage('Error: WebSocket is not connected. Trying to reconnect...', 'error');
this.connectWebSocket();
return;
}
try {
console.log('Sending message to server');
this.appendMessage(message, 'user');
const payload = { type: 'request', content: message };
console.log('Payload:', payload);
this.ws.send(JSON.stringify(payload));
console.log('Message sent successfully');
this.userInput.value = '';
this.sendButton.disabled = true;
} catch (error) {
console.error('Error sending message:', error);
this.appendMessage(`Error sending message: ${error.message}`, 'error');
this.sendButton.disabled = false;
}
}
}
// Initialize the UI when the page loads
document.addEventListener('DOMContentLoaded', () => {
window.raWebUI = new RAWebUI();
});

195
webui/server.py Executable file
View File

@ -0,0 +1,195 @@
#!/usr/bin/env python3
import asyncio
import argparse
import json
import sys
import shutil
from pathlib import Path
from typing import List, Dict, Any
import uvicorn
from fastapi import FastAPI, WebSocket, Request, WebSocketDisconnect
from fastapi.staticfiles import StaticFiles
from fastapi.responses import HTMLResponse
from fastapi.middleware.cors import CORSMiddleware
from fastapi.templating import Jinja2Templates
# Verify ra-aid is available
if not shutil.which('ra-aid'):
print("Error: ra-aid command not found. Please ensure it's installed and in your PATH")
sys.exit(1)
app = FastAPI()
# Add CORS middleware
app.add_middleware(
CORSMiddleware,
allow_origins=["*"], # In production, replace with specific origins
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# Setup templates
templates = Jinja2Templates(directory=Path(__file__).parent)
# Create a route for the root to serve index.html with port parameter
@app.get("/", response_class=HTMLResponse)
async def get_root(request: Request):
"""Serve the index.html file with port parameter."""
return templates.TemplateResponse(
"index.html",
{"request": request, "server_port": request.url.port or 8080}
)
# Mount static files for js and other assets
app.mount("/static", StaticFiles(directory=Path(__file__).parent), name="static")
# Store WebSocket connections
# Store active WebSocket connections
active_connections: List[WebSocket] = []
@app.websocket("/ws")
async def websocket_endpoint(websocket: WebSocket):
print(f"New WebSocket connection from {websocket.client}")
await websocket.accept()
print("WebSocket connection accepted")
active_connections.append(websocket)
try:
while True:
print("Waiting for message...")
message = await websocket.receive_json()
print(f"Received message: {message}")
if message["type"] == "request":
print(f"Processing request: {message['content']}")
# Notify client that streaming is starting
await websocket.send_json({
"type": "stream_start"
})
try:
# Run ra-aid with the request using -m flag and cowboy mode
cmd = ["ra-aid", "-m", message["content"], "--cowboy-mode"]
print(f"Executing command: {' '.join(cmd)}")
# Create subprocess
process = await asyncio.create_subprocess_exec(
*cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE
)
print(f"Process started with PID: {process.pid}")
# Read output and errors concurrently
async def read_stream(stream, is_error=False):
stream_type = "stderr" if is_error else "stdout"
print(f"Starting to read from {stream_type}")
while True:
line = await stream.readline()
if not line:
print(f"End of {stream_type} stream")
break
try:
decoded_line = line.decode().strip()
print(f"{stream_type} line: {decoded_line}")
if decoded_line:
await websocket.send_json({
"type": "chunk",
"chunk": {
"tools" if is_error else "agent": {
"messages": [{
"content": decoded_line,
"status": "error" if is_error else "info"
}]
}
}
})
except Exception as e:
print(f"Error sending output: {e}")
# Create tasks for reading stdout and stderr
stdout_task = asyncio.create_task(read_stream(process.stdout))
stderr_task = asyncio.create_task(read_stream(process.stderr, True))
# Wait for both streams to complete
await asyncio.gather(stdout_task, stderr_task)
# Wait for process to complete
return_code = await process.wait()
if return_code != 0:
await websocket.send_json({
"type": "chunk",
"chunk": {
"tools": {
"messages": [{
"content": f"Process exited with code {return_code}",
"status": "error"
}]
}
}
})
# Notify client that streaming is complete
await websocket.send_json({
"type": "stream_end",
"request": message["content"]
})
except Exception as e:
error_msg = f"Error executing ra-aid: {str(e)}"
print(error_msg)
await websocket.send_json({
"type": "chunk",
"chunk": {
"tools": {
"messages": [{
"content": error_msg,
"status": "error"
}]
}
}
})
except WebSocketDisconnect:
print(f"WebSocket client disconnected")
active_connections.remove(websocket)
except Exception as e:
print(f"WebSocket error: {e}")
try:
await websocket.send_json({
"type": "error",
"error": str(e)
})
except:
pass
finally:
if websocket in active_connections:
active_connections.remove(websocket)
print("WebSocket connection cleaned up")
@app.get("/config")
async def get_config(request: Request):
"""Return server configuration including host and port."""
return {
"host": request.client.host,
"port": request.scope.get("server")[1]
}
def run_server(host: str = "0.0.0.0", port: int = 8080):
"""Run the FastAPI server."""
uvicorn.run(app, host=host, port=port)
if __name__ == "__main__":
parser = argparse.ArgumentParser(description="RA.Aid Web Interface Server")
parser.add_argument("--port", type=int, default=8080,
help="Port to listen on (default: 8080)")
parser.add_argument("--host", type=str, default="0.0.0.0",
help="Host to listen on (default: 0.0.0.0)")
args = parser.parse_args()
run_server(host=args.host, port=args.port)