mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-25 14:53:34 +00:00
Adding web UI
This commit is contained in:
Generated
+1
@@ -4217,6 +4217,7 @@ dependencies = [
|
||||
"futures-core",
|
||||
"pin-project-lite",
|
||||
"tokio",
|
||||
"tokio-util",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
+1
-1
@@ -9,7 +9,7 @@ license = "MIT OR Apache-2.0"
|
||||
[dependencies]
|
||||
# Async runtime
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
tokio-stream = "0.1"
|
||||
tokio-stream = { version = "0.1", features = ["sync"] }
|
||||
futures = "0.3"
|
||||
|
||||
# HTTP client
|
||||
|
||||
@@ -86,18 +86,21 @@ pub struct Agent {
|
||||
impl Agent {
|
||||
/// Create a new agent.
|
||||
///
|
||||
/// Optionally accepts a pre-created `ContextManager` for sharing with job tools.
|
||||
/// If not provided, creates a new one.
|
||||
/// Optionally accepts pre-created `ContextManager` and `SessionManager` for sharing
|
||||
/// with external components (job tools, web gateway). Creates new ones if not provided.
|
||||
pub fn new(
|
||||
config: AgentConfig,
|
||||
deps: AgentDeps,
|
||||
channels: ChannelManager,
|
||||
heartbeat_config: Option<HeartbeatConfig>,
|
||||
context_manager: Option<Arc<ContextManager>>,
|
||||
session_manager: Option<Arc<SessionManager>>,
|
||||
) -> Self {
|
||||
let context_manager = context_manager
|
||||
.unwrap_or_else(|| Arc::new(ContextManager::new(config.max_parallel_jobs)));
|
||||
|
||||
let session_manager = session_manager.unwrap_or_else(|| Arc::new(SessionManager::new()));
|
||||
|
||||
let scheduler = Arc::new(Scheduler::new(
|
||||
config.clone(),
|
||||
context_manager.clone(),
|
||||
@@ -114,7 +117,7 @@ impl Agent {
|
||||
context_manager,
|
||||
scheduler,
|
||||
router: Router::new(),
|
||||
session_manager: Arc::new(SessionManager::new()),
|
||||
session_manager,
|
||||
context_monitor: ContextMonitor::new(),
|
||||
heartbeat_config,
|
||||
}
|
||||
|
||||
@@ -33,9 +33,11 @@ mod http;
|
||||
mod manager;
|
||||
mod repl;
|
||||
pub mod wasm;
|
||||
pub mod web;
|
||||
|
||||
pub use channel::{Channel, IncomingMessage, MessageStream, OutgoingResponse, StatusUpdate};
|
||||
pub use cli::{AppEvent, TuiChannel};
|
||||
pub use http::HttpChannel;
|
||||
pub use manager::ChannelManager;
|
||||
pub use repl::ReplChannel;
|
||||
pub use web::GatewayChannel;
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
//! Bearer token authentication middleware for the web gateway.
|
||||
|
||||
use axum::{
|
||||
extract::{Request, State},
|
||||
http::{HeaderMap, StatusCode},
|
||||
middleware::Next,
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
|
||||
/// Shared auth state injected via axum middleware state.
|
||||
#[derive(Clone)]
|
||||
pub struct AuthState {
|
||||
pub token: String,
|
||||
}
|
||||
|
||||
/// Auth middleware that validates bearer token from header or query param.
|
||||
///
|
||||
/// SSE connections can't set headers from `EventSource`, so we also accept
|
||||
/// `?token=xxx` as a query parameter.
|
||||
pub async fn auth_middleware(
|
||||
State(auth): State<AuthState>,
|
||||
headers: HeaderMap,
|
||||
request: Request,
|
||||
next: Next,
|
||||
) -> Response {
|
||||
// Try Authorization header first
|
||||
if let Some(auth_header) = headers.get("authorization") {
|
||||
if let Ok(value) = auth_header.to_str() {
|
||||
if let Some(token) = value.strip_prefix("Bearer ") {
|
||||
if token == auth.token {
|
||||
return next.run(request).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to query parameter (for SSE EventSource)
|
||||
if let Some(query) = request.uri().query() {
|
||||
for pair in query.split('&') {
|
||||
if let Some(token) = pair.strip_prefix("token=") {
|
||||
if token == auth.token {
|
||||
return next.run(request).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
(StatusCode::UNAUTHORIZED, "Invalid or missing auth token").into_response()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_auth_state_clone() {
|
||||
let state = AuthState {
|
||||
token: "test-token".to_string(),
|
||||
};
|
||||
let cloned = state.clone();
|
||||
assert_eq!(cloned.token, "test-token");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,264 @@
|
||||
//! Tracing layer that broadcasts log events to the web gateway via SSE.
|
||||
//!
|
||||
//! ```text
|
||||
//! tracing::info!("...")
|
||||
//! │
|
||||
//! ▼
|
||||
//! WebLogLayer::on_event()
|
||||
//! │
|
||||
//! ▼
|
||||
//! LogBroadcaster::send()
|
||||
//! │
|
||||
//! ├──► broadcast::Sender<LogEntry> (live subscribers)
|
||||
//! └──► ring buffer (recent history for late joiners)
|
||||
//! │
|
||||
//! ▼
|
||||
//! SSE /api/logs/events
|
||||
//! ```
|
||||
|
||||
use std::collections::VecDeque;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use serde::Serialize;
|
||||
use tokio::sync::broadcast;
|
||||
use tracing::field::{Field, Visit};
|
||||
use tracing_subscriber::Layer;
|
||||
|
||||
/// Maximum number of recent log entries kept for late-joining SSE subscribers.
|
||||
const HISTORY_CAP: usize = 500;
|
||||
|
||||
/// A single log entry broadcast to connected clients.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct LogEntry {
|
||||
pub level: String,
|
||||
pub target: String,
|
||||
pub message: String,
|
||||
pub timestamp: String,
|
||||
}
|
||||
|
||||
/// Broadcasts log entries to SSE subscribers.
|
||||
///
|
||||
/// Created early in main.rs (before tracing init), shared with both
|
||||
/// the tracing layer and the gateway's SSE endpoint.
|
||||
///
|
||||
/// Keeps a ring buffer of recent entries so browsers that connect
|
||||
/// after startup still see the boot log.
|
||||
pub struct LogBroadcaster {
|
||||
tx: broadcast::Sender<LogEntry>,
|
||||
recent: Mutex<VecDeque<LogEntry>>,
|
||||
}
|
||||
|
||||
impl LogBroadcaster {
|
||||
pub fn new() -> Self {
|
||||
let (tx, _) = broadcast::channel(512);
|
||||
Self {
|
||||
tx,
|
||||
recent: Mutex::new(VecDeque::with_capacity(HISTORY_CAP)),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn send(&self, entry: LogEntry) {
|
||||
// Stash in ring buffer (for late joiners)
|
||||
if let Ok(mut buf) = self.recent.lock() {
|
||||
if buf.len() >= HISTORY_CAP {
|
||||
buf.pop_front();
|
||||
}
|
||||
buf.push_back(entry.clone());
|
||||
}
|
||||
// Broadcast to live subscribers (ok to drop if nobody listening)
|
||||
let _ = self.tx.send(entry);
|
||||
}
|
||||
|
||||
/// Subscribe to the live event stream.
|
||||
pub fn subscribe(&self) -> broadcast::Receiver<LogEntry> {
|
||||
self.tx.subscribe()
|
||||
}
|
||||
|
||||
/// Snapshot of recent entries for replaying to a new subscriber.
|
||||
pub fn recent_entries(&self) -> Vec<LogEntry> {
|
||||
self.recent
|
||||
.lock()
|
||||
.map(|buf| buf.iter().cloned().collect())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for LogBroadcaster {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
/// Visitor that extracts the `message` field from a tracing event.
|
||||
struct MessageVisitor {
|
||||
message: String,
|
||||
}
|
||||
|
||||
impl MessageVisitor {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
message: String::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Visit for MessageVisitor {
|
||||
fn record_debug(&mut self, field: &Field, value: &dyn std::fmt::Debug) {
|
||||
if field.name() == "message" {
|
||||
self.message = format!("{:?}", value);
|
||||
// Strip surrounding quotes from Debug output
|
||||
if self.message.starts_with('"') && self.message.ends_with('"') {
|
||||
self.message = self.message[1..self.message.len() - 1].to_string();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn record_str(&mut self, field: &Field, value: &str) {
|
||||
if field.name() == "message" {
|
||||
self.message = value.to_string();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Tracing layer that forwards events to a [`LogBroadcaster`].
|
||||
///
|
||||
/// Only forwards DEBUG and above. Attach to the tracing subscriber
|
||||
/// alongside the existing fmt layer.
|
||||
pub struct WebLogLayer {
|
||||
broadcaster: Arc<LogBroadcaster>,
|
||||
}
|
||||
|
||||
impl WebLogLayer {
|
||||
pub fn new(broadcaster: Arc<LogBroadcaster>) -> Self {
|
||||
Self { broadcaster }
|
||||
}
|
||||
}
|
||||
|
||||
impl<S: tracing::Subscriber> Layer<S> for WebLogLayer {
|
||||
fn on_event(
|
||||
&self,
|
||||
event: &tracing::Event<'_>,
|
||||
_ctx: tracing_subscriber::layer::Context<'_, S>,
|
||||
) {
|
||||
let metadata = event.metadata();
|
||||
|
||||
// Only forward DEBUG+
|
||||
if *metadata.level() > tracing::Level::DEBUG {
|
||||
return;
|
||||
}
|
||||
|
||||
let mut visitor = MessageVisitor::new();
|
||||
event.record(&mut visitor);
|
||||
|
||||
let entry = LogEntry {
|
||||
level: metadata.level().to_string().to_uppercase(),
|
||||
target: metadata.target().to_string(),
|
||||
message: visitor.message,
|
||||
timestamp: chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true),
|
||||
};
|
||||
|
||||
self.broadcaster.send(entry);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_log_broadcaster_creation() {
|
||||
let broadcaster = LogBroadcaster::new();
|
||||
// Should not panic with no receivers
|
||||
broadcaster.send(LogEntry {
|
||||
level: "INFO".to_string(),
|
||||
target: "test".to_string(),
|
||||
message: "hello".to_string(),
|
||||
timestamp: "2024-01-01T00:00:00.000Z".to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_log_broadcaster_subscribe() {
|
||||
let broadcaster = LogBroadcaster::new();
|
||||
let mut rx = broadcaster.subscribe();
|
||||
|
||||
broadcaster.send(LogEntry {
|
||||
level: "WARN".to_string(),
|
||||
target: "ironclaw::test".to_string(),
|
||||
message: "test warning".to_string(),
|
||||
timestamp: "2024-01-01T00:00:00.000Z".to_string(),
|
||||
});
|
||||
|
||||
let entry = rx.try_recv().expect("should receive entry");
|
||||
assert_eq!(entry.level, "WARN");
|
||||
assert_eq!(entry.message, "test warning");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_log_entry_serialization() {
|
||||
let entry = LogEntry {
|
||||
level: "ERROR".to_string(),
|
||||
target: "ironclaw::agent".to_string(),
|
||||
message: "something broke".to_string(),
|
||||
timestamp: "2024-01-01T00:00:00.000Z".to_string(),
|
||||
};
|
||||
let json = serde_json::to_string(&entry).expect("should serialize");
|
||||
assert!(json.contains("\"level\":\"ERROR\""));
|
||||
assert!(json.contains("something broke"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_recent_entries_buffer() {
|
||||
let broadcaster = LogBroadcaster::new();
|
||||
|
||||
for i in 0..5 {
|
||||
broadcaster.send(LogEntry {
|
||||
level: "INFO".to_string(),
|
||||
target: "test".to_string(),
|
||||
message: format!("msg {}", i),
|
||||
timestamp: "2024-01-01T00:00:00.000Z".to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
let recent = broadcaster.recent_entries();
|
||||
assert_eq!(recent.len(), 5);
|
||||
assert_eq!(recent[0].message, "msg 0");
|
||||
assert_eq!(recent[4].message, "msg 4");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_recent_entries_cap() {
|
||||
let broadcaster = LogBroadcaster::new();
|
||||
|
||||
// Overflow the buffer
|
||||
for i in 0..(HISTORY_CAP + 50) {
|
||||
broadcaster.send(LogEntry {
|
||||
level: "INFO".to_string(),
|
||||
target: "test".to_string(),
|
||||
message: format!("msg {}", i),
|
||||
timestamp: "2024-01-01T00:00:00.000Z".to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
let recent = broadcaster.recent_entries();
|
||||
assert_eq!(recent.len(), HISTORY_CAP);
|
||||
// Oldest should be msg 50 (first 50 evicted)
|
||||
assert_eq!(recent[0].message, "msg 50");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_recent_entries_available_without_subscribers() {
|
||||
let broadcaster = LogBroadcaster::new();
|
||||
// No subscribe() call, just send
|
||||
broadcaster.send(LogEntry {
|
||||
level: "INFO".to_string(),
|
||||
target: "test".to_string(),
|
||||
message: "before anyone listened".to_string(),
|
||||
timestamp: "2024-01-01T00:00:00.000Z".to_string(),
|
||||
});
|
||||
|
||||
let recent = broadcaster.recent_entries();
|
||||
assert_eq!(recent.len(), 1);
|
||||
assert_eq!(recent[0].message, "before anyone listened");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
//! Web gateway channel for browser-based access to IronClaw.
|
||||
//!
|
||||
//! Provides a single-page web UI with:
|
||||
//! - Chat with the agent (via REST + SSE)
|
||||
//! - Workspace/memory browsing
|
||||
//! - Job management
|
||||
//!
|
||||
//! ```text
|
||||
//! Browser ─── POST /api/chat/send ──► Agent Loop
|
||||
//! ◄── GET /api/chat/events ── SSE stream
|
||||
//! ─── GET /api/memory/* ────► Workspace
|
||||
//! ─── GET /api/jobs/* ──────► ContextManager
|
||||
//! ◄── GET / ───────────────── Static HTML/CSS/JS
|
||||
//! ```
|
||||
|
||||
pub mod auth;
|
||||
pub mod log_layer;
|
||||
pub mod server;
|
||||
pub mod sse;
|
||||
pub mod types;
|
||||
|
||||
use std::net::SocketAddr;
|
||||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use tokio::sync::mpsc;
|
||||
use tokio_stream::wrappers::ReceiverStream;
|
||||
|
||||
use crate::agent::SessionManager;
|
||||
use crate::channels::{Channel, IncomingMessage, MessageStream, OutgoingResponse, StatusUpdate};
|
||||
use crate::config::GatewayConfig;
|
||||
use crate::context::ContextManager;
|
||||
use crate::error::ChannelError;
|
||||
use crate::extensions::ExtensionManager;
|
||||
use crate::tools::ToolRegistry;
|
||||
use crate::workspace::Workspace;
|
||||
|
||||
use self::log_layer::LogBroadcaster;
|
||||
|
||||
use self::server::GatewayState;
|
||||
use self::sse::SseManager;
|
||||
use self::types::SseEvent;
|
||||
|
||||
/// Web gateway channel implementing the Channel trait.
|
||||
pub struct GatewayChannel {
|
||||
config: GatewayConfig,
|
||||
state: Arc<GatewayState>,
|
||||
/// The actual auth token in use (generated or from config).
|
||||
auth_token: String,
|
||||
}
|
||||
|
||||
impl GatewayChannel {
|
||||
/// Create a new gateway channel.
|
||||
///
|
||||
/// If no auth token is configured, generates a random one and prints it.
|
||||
pub fn new(config: GatewayConfig) -> Self {
|
||||
let auth_token = config.auth_token.clone().unwrap_or_else(|| {
|
||||
use rand::Rng;
|
||||
let token: String = rand::thread_rng()
|
||||
.sample_iter(&rand::distributions::Alphanumeric)
|
||||
.take(32)
|
||||
.map(char::from)
|
||||
.collect();
|
||||
token
|
||||
});
|
||||
|
||||
let state = Arc::new(GatewayState {
|
||||
msg_tx: tokio::sync::RwLock::new(None),
|
||||
sse: SseManager::new(),
|
||||
workspace: None,
|
||||
context_manager: None,
|
||||
session_manager: None,
|
||||
log_broadcaster: None,
|
||||
extension_manager: None,
|
||||
tool_registry: None,
|
||||
user_id: config.user_id.clone(),
|
||||
shutdown_tx: tokio::sync::RwLock::new(None),
|
||||
});
|
||||
|
||||
Self {
|
||||
config,
|
||||
state,
|
||||
auth_token,
|
||||
}
|
||||
}
|
||||
|
||||
/// Helper to rebuild state, copying existing fields and applying a mutation.
|
||||
fn rebuild_state(&mut self, mutate: impl FnOnce(&mut GatewayState)) {
|
||||
let mut new_state = GatewayState {
|
||||
msg_tx: tokio::sync::RwLock::new(None),
|
||||
sse: SseManager::new(),
|
||||
workspace: self.state.workspace.clone(),
|
||||
context_manager: self.state.context_manager.clone(),
|
||||
session_manager: self.state.session_manager.clone(),
|
||||
log_broadcaster: self.state.log_broadcaster.clone(),
|
||||
extension_manager: self.state.extension_manager.clone(),
|
||||
tool_registry: self.state.tool_registry.clone(),
|
||||
user_id: self.state.user_id.clone(),
|
||||
shutdown_tx: tokio::sync::RwLock::new(None),
|
||||
};
|
||||
mutate(&mut new_state);
|
||||
self.state = Arc::new(new_state);
|
||||
}
|
||||
|
||||
/// Inject the workspace reference for the memory API.
|
||||
pub fn with_workspace(mut self, workspace: Arc<Workspace>) -> Self {
|
||||
self.rebuild_state(|s| s.workspace = Some(workspace));
|
||||
self
|
||||
}
|
||||
|
||||
/// Inject the context manager for the jobs API.
|
||||
pub fn with_context_manager(mut self, cm: Arc<ContextManager>) -> Self {
|
||||
self.rebuild_state(|s| s.context_manager = Some(cm));
|
||||
self
|
||||
}
|
||||
|
||||
/// Inject the session manager for thread/session info.
|
||||
pub fn with_session_manager(mut self, sm: Arc<SessionManager>) -> Self {
|
||||
self.rebuild_state(|s| s.session_manager = Some(sm));
|
||||
self
|
||||
}
|
||||
|
||||
/// Inject the log broadcaster for the logs SSE endpoint.
|
||||
pub fn with_log_broadcaster(mut self, lb: Arc<LogBroadcaster>) -> Self {
|
||||
self.rebuild_state(|s| s.log_broadcaster = Some(lb));
|
||||
self
|
||||
}
|
||||
|
||||
/// Inject the extension manager for the extensions API.
|
||||
pub fn with_extension_manager(mut self, em: Arc<ExtensionManager>) -> Self {
|
||||
self.rebuild_state(|s| s.extension_manager = Some(em));
|
||||
self
|
||||
}
|
||||
|
||||
/// Inject the tool registry for the extensions API.
|
||||
pub fn with_tool_registry(mut self, tr: Arc<ToolRegistry>) -> Self {
|
||||
self.rebuild_state(|s| s.tool_registry = Some(tr));
|
||||
self
|
||||
}
|
||||
|
||||
/// Get the auth token (for printing to console on startup).
|
||||
pub fn auth_token(&self) -> &str {
|
||||
&self.auth_token
|
||||
}
|
||||
|
||||
/// Get a reference to the shared gateway state (for the agent to push SSE events).
|
||||
pub fn state(&self) -> &Arc<GatewayState> {
|
||||
&self.state
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Channel for GatewayChannel {
|
||||
fn name(&self) -> &str {
|
||||
"gateway"
|
||||
}
|
||||
|
||||
async fn start(&self) -> Result<MessageStream, ChannelError> {
|
||||
let (tx, rx) = mpsc::channel(256);
|
||||
*self.state.msg_tx.write().await = Some(tx);
|
||||
|
||||
let addr: SocketAddr = format!("{}:{}", self.config.host, self.config.port)
|
||||
.parse()
|
||||
.map_err(|e| ChannelError::StartupFailed {
|
||||
name: "gateway".to_string(),
|
||||
reason: format!(
|
||||
"Invalid address '{}:{}': {}",
|
||||
self.config.host, self.config.port, e
|
||||
),
|
||||
})?;
|
||||
|
||||
server::start_server(addr, self.state.clone(), self.auth_token.clone()).await?;
|
||||
|
||||
tracing::info!("Web gateway listening on http://{}", addr);
|
||||
tracing::info!("Auth token: {}", self.auth_token);
|
||||
|
||||
Ok(Box::pin(ReceiverStream::new(rx)))
|
||||
}
|
||||
|
||||
async fn respond(
|
||||
&self,
|
||||
msg: &IncomingMessage,
|
||||
response: OutgoingResponse,
|
||||
) -> Result<(), ChannelError> {
|
||||
let thread_id = msg.thread_id.clone().unwrap_or_default();
|
||||
|
||||
self.state.sse.broadcast(SseEvent::Response {
|
||||
content: response.content,
|
||||
thread_id,
|
||||
});
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn send_status(
|
||||
&self,
|
||||
status: StatusUpdate,
|
||||
_metadata: &serde_json::Value,
|
||||
) -> Result<(), ChannelError> {
|
||||
let event = match status {
|
||||
StatusUpdate::Thinking(msg) => SseEvent::Thinking { message: msg },
|
||||
StatusUpdate::ToolStarted { name } => SseEvent::ToolStarted { name },
|
||||
StatusUpdate::ToolCompleted { name, success } => {
|
||||
SseEvent::ToolCompleted { name, success }
|
||||
}
|
||||
StatusUpdate::StreamChunk(content) => SseEvent::StreamChunk { content },
|
||||
StatusUpdate::Status(msg) => SseEvent::Status { message: msg },
|
||||
};
|
||||
|
||||
self.state.sse.broadcast(event);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn broadcast(
|
||||
&self,
|
||||
_user_id: &str,
|
||||
response: OutgoingResponse,
|
||||
) -> Result<(), ChannelError> {
|
||||
self.state.sse.broadcast(SseEvent::Response {
|
||||
content: response.content,
|
||||
thread_id: String::new(),
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn health_check(&self) -> Result<(), ChannelError> {
|
||||
if self.state.msg_tx.read().await.is_some() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(ChannelError::HealthCheckFailed {
|
||||
name: "gateway".to_string(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async fn shutdown(&self) -> Result<(), ChannelError> {
|
||||
if let Some(tx) = self.state.shutdown_tx.write().await.take() {
|
||||
let _ = tx.send(());
|
||||
}
|
||||
*self.state.msg_tx.write().await = None;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,753 @@
|
||||
//! Axum HTTP server for the web gateway.
|
||||
//!
|
||||
//! Handles all API routes: chat, memory, jobs, health, and static file serving.
|
||||
|
||||
use std::convert::Infallible;
|
||||
use std::net::SocketAddr;
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::{
|
||||
Json, Router,
|
||||
extract::{Path, Query, State},
|
||||
http::{StatusCode, header},
|
||||
middleware,
|
||||
response::{
|
||||
Html, IntoResponse,
|
||||
sse::{Event, KeepAlive, Sse},
|
||||
},
|
||||
routing::{get, post},
|
||||
};
|
||||
use serde::Deserialize;
|
||||
use tokio::sync::{mpsc, oneshot};
|
||||
use tokio_stream::StreamExt;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::agent::SessionManager;
|
||||
use crate::channels::IncomingMessage;
|
||||
use crate::channels::web::auth::{AuthState, auth_middleware};
|
||||
use crate::channels::web::log_layer::LogBroadcaster;
|
||||
use crate::channels::web::sse::SseManager;
|
||||
use crate::channels::web::types::*;
|
||||
use crate::context::ContextManager;
|
||||
use crate::extensions::ExtensionManager;
|
||||
use crate::tools::ToolRegistry;
|
||||
use crate::workspace::Workspace;
|
||||
|
||||
/// Shared state for all gateway handlers.
|
||||
pub struct GatewayState {
|
||||
/// Channel to send messages to the agent loop.
|
||||
pub msg_tx: tokio::sync::RwLock<Option<mpsc::Sender<IncomingMessage>>>,
|
||||
/// SSE broadcast manager.
|
||||
pub sse: SseManager,
|
||||
/// Workspace for memory API.
|
||||
pub workspace: Option<Arc<Workspace>>,
|
||||
/// Context manager for jobs API.
|
||||
pub context_manager: Option<Arc<ContextManager>>,
|
||||
/// Session manager for thread info.
|
||||
pub session_manager: Option<Arc<SessionManager>>,
|
||||
/// Log broadcaster for the logs SSE endpoint.
|
||||
pub log_broadcaster: Option<Arc<LogBroadcaster>>,
|
||||
/// Extension manager for extension management API.
|
||||
pub extension_manager: Option<Arc<ExtensionManager>>,
|
||||
/// Tool registry for listing registered tools.
|
||||
pub tool_registry: Option<Arc<ToolRegistry>>,
|
||||
/// User ID for this gateway.
|
||||
pub user_id: String,
|
||||
/// Shutdown signal sender.
|
||||
pub shutdown_tx: tokio::sync::RwLock<Option<oneshot::Sender<()>>>,
|
||||
}
|
||||
|
||||
/// Start the gateway HTTP server.
|
||||
pub async fn start_server(
|
||||
addr: SocketAddr,
|
||||
state: Arc<GatewayState>,
|
||||
auth_token: String,
|
||||
) -> Result<(), crate::error::ChannelError> {
|
||||
let listener = tokio::net::TcpListener::bind(addr).await.map_err(|e| {
|
||||
crate::error::ChannelError::StartupFailed {
|
||||
name: "gateway".to_string(),
|
||||
reason: format!("Failed to bind to {}: {}", addr, e),
|
||||
}
|
||||
})?;
|
||||
|
||||
// Public routes (no auth)
|
||||
let public = Router::new().route("/api/health", get(health_handler));
|
||||
|
||||
// Protected routes (require auth)
|
||||
let auth_state = AuthState { token: auth_token };
|
||||
let protected = Router::new()
|
||||
// Chat
|
||||
.route("/api/chat/send", post(chat_send_handler))
|
||||
.route("/api/chat/events", get(chat_events_handler))
|
||||
.route("/api/chat/history", get(chat_history_handler))
|
||||
.route("/api/chat/threads", get(chat_threads_handler))
|
||||
.route("/api/chat/thread/new", post(chat_new_thread_handler))
|
||||
// Memory
|
||||
.route("/api/memory/tree", get(memory_tree_handler))
|
||||
.route("/api/memory/list", get(memory_list_handler))
|
||||
.route("/api/memory/read", get(memory_read_handler))
|
||||
.route("/api/memory/write", post(memory_write_handler))
|
||||
.route("/api/memory/search", post(memory_search_handler))
|
||||
// Jobs
|
||||
.route("/api/jobs", get(jobs_list_handler))
|
||||
.route("/api/jobs/summary", get(jobs_summary_handler))
|
||||
.route("/api/jobs/{id}", get(jobs_detail_handler))
|
||||
.route("/api/jobs/{id}/cancel", post(jobs_cancel_handler))
|
||||
// Logs
|
||||
.route("/api/logs/events", get(logs_events_handler))
|
||||
// Extensions
|
||||
.route("/api/extensions", get(extensions_list_handler))
|
||||
.route("/api/extensions/tools", get(extensions_tools_handler))
|
||||
.route("/api/extensions/install", post(extensions_install_handler))
|
||||
.route(
|
||||
"/api/extensions/{name}/activate",
|
||||
post(extensions_activate_handler),
|
||||
)
|
||||
.route(
|
||||
"/api/extensions/{name}/remove",
|
||||
post(extensions_remove_handler),
|
||||
)
|
||||
.route_layer(middleware::from_fn_with_state(auth_state, auth_middleware));
|
||||
|
||||
// Static file routes (no auth, served from embedded strings)
|
||||
let statics = Router::new()
|
||||
.route("/", get(index_handler))
|
||||
.route("/style.css", get(css_handler))
|
||||
.route("/app.js", get(js_handler));
|
||||
|
||||
let app = Router::new()
|
||||
.merge(public)
|
||||
.merge(statics)
|
||||
.merge(protected)
|
||||
.with_state(state.clone());
|
||||
|
||||
let (shutdown_tx, shutdown_rx) = oneshot::channel();
|
||||
*state.shutdown_tx.write().await = Some(shutdown_tx);
|
||||
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = axum::serve(listener, app)
|
||||
.with_graceful_shutdown(async {
|
||||
let _ = shutdown_rx.await;
|
||||
tracing::info!("Web gateway shutting down");
|
||||
})
|
||||
.await
|
||||
{
|
||||
tracing::error!("Web gateway server error: {}", e);
|
||||
}
|
||||
});
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// --- Static file handlers ---
|
||||
|
||||
async fn index_handler() -> Html<&'static str> {
|
||||
Html(include_str!("static/index.html"))
|
||||
}
|
||||
|
||||
async fn css_handler() -> impl IntoResponse {
|
||||
(
|
||||
[(header::CONTENT_TYPE, "text/css")],
|
||||
include_str!("static/style.css"),
|
||||
)
|
||||
}
|
||||
|
||||
async fn js_handler() -> impl IntoResponse {
|
||||
(
|
||||
[(header::CONTENT_TYPE, "application/javascript")],
|
||||
include_str!("static/app.js"),
|
||||
)
|
||||
}
|
||||
|
||||
// --- Health ---
|
||||
|
||||
async fn health_handler() -> Json<HealthResponse> {
|
||||
Json(HealthResponse {
|
||||
status: "healthy",
|
||||
channel: "gateway",
|
||||
})
|
||||
}
|
||||
|
||||
// --- Chat handlers ---
|
||||
|
||||
async fn chat_send_handler(
|
||||
State(state): State<Arc<GatewayState>>,
|
||||
Json(req): Json<SendMessageRequest>,
|
||||
) -> Result<(StatusCode, Json<SendMessageResponse>), (StatusCode, String)> {
|
||||
let mut msg = IncomingMessage::new("gateway", &state.user_id, &req.content);
|
||||
|
||||
if let Some(ref thread_id) = req.thread_id {
|
||||
msg = msg.with_thread(thread_id);
|
||||
}
|
||||
|
||||
let msg_id = msg.id;
|
||||
|
||||
let tx_guard = state.msg_tx.read().await;
|
||||
let tx = tx_guard.as_ref().ok_or((
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
"Channel not started".to_string(),
|
||||
))?;
|
||||
|
||||
tx.send(msg).await.map_err(|_| {
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"Channel closed".to_string(),
|
||||
)
|
||||
})?;
|
||||
|
||||
Ok((
|
||||
StatusCode::ACCEPTED,
|
||||
Json(SendMessageResponse {
|
||||
message_id: msg_id,
|
||||
status: "accepted",
|
||||
}),
|
||||
))
|
||||
}
|
||||
|
||||
async fn chat_events_handler(State(state): State<Arc<GatewayState>>) -> impl IntoResponse {
|
||||
// subscribe() returns Sse<impl Stream + 'static + use<>> so no lifetime issues
|
||||
state.sse.subscribe()
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct HistoryQuery {
|
||||
thread_id: Option<String>,
|
||||
}
|
||||
|
||||
async fn chat_history_handler(
|
||||
State(state): State<Arc<GatewayState>>,
|
||||
Query(query): Query<HistoryQuery>,
|
||||
) -> Result<Json<HistoryResponse>, (StatusCode, String)> {
|
||||
let session_manager = state.session_manager.as_ref().ok_or((
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
"Session manager not available".to_string(),
|
||||
))?;
|
||||
|
||||
let session = session_manager.get_or_create_session(&state.user_id).await;
|
||||
let sess = session.lock().await;
|
||||
|
||||
// Find the thread
|
||||
let thread_id = if let Some(ref tid) = query.thread_id {
|
||||
Uuid::parse_str(tid)
|
||||
.map_err(|_| (StatusCode::BAD_REQUEST, "Invalid thread_id".to_string()))?
|
||||
} else {
|
||||
sess.active_thread
|
||||
.ok_or((StatusCode::NOT_FOUND, "No active thread".to_string()))?
|
||||
};
|
||||
|
||||
let thread = sess
|
||||
.threads
|
||||
.get(&thread_id)
|
||||
.ok_or((StatusCode::NOT_FOUND, "Thread not found".to_string()))?;
|
||||
|
||||
let turns: Vec<TurnInfo> = thread
|
||||
.turns
|
||||
.iter()
|
||||
.map(|t| TurnInfo {
|
||||
turn_number: t.turn_number,
|
||||
user_input: t.user_input.clone(),
|
||||
response: t.response.clone(),
|
||||
state: format!("{:?}", t.state),
|
||||
started_at: t.started_at.to_rfc3339(),
|
||||
completed_at: t.completed_at.map(|dt| dt.to_rfc3339()),
|
||||
tool_calls: t
|
||||
.tool_calls
|
||||
.iter()
|
||||
.map(|tc| ToolCallInfo {
|
||||
name: tc.name.clone(),
|
||||
has_result: tc.result.is_some(),
|
||||
has_error: tc.error.is_some(),
|
||||
})
|
||||
.collect(),
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(Json(HistoryResponse { thread_id, turns }))
|
||||
}
|
||||
|
||||
async fn chat_threads_handler(
|
||||
State(state): State<Arc<GatewayState>>,
|
||||
) -> Result<Json<ThreadListResponse>, (StatusCode, String)> {
|
||||
let session_manager = state.session_manager.as_ref().ok_or((
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
"Session manager not available".to_string(),
|
||||
))?;
|
||||
|
||||
let session = session_manager.get_or_create_session(&state.user_id).await;
|
||||
let sess = session.lock().await;
|
||||
|
||||
let threads: Vec<ThreadInfo> = sess
|
||||
.threads
|
||||
.values()
|
||||
.map(|t| ThreadInfo {
|
||||
id: t.id,
|
||||
state: format!("{:?}", t.state),
|
||||
turn_count: t.turns.len(),
|
||||
created_at: t.created_at.to_rfc3339(),
|
||||
updated_at: t.updated_at.to_rfc3339(),
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(Json(ThreadListResponse {
|
||||
threads,
|
||||
active_thread: sess.active_thread,
|
||||
}))
|
||||
}
|
||||
|
||||
async fn chat_new_thread_handler(
|
||||
State(state): State<Arc<GatewayState>>,
|
||||
) -> Result<Json<ThreadInfo>, (StatusCode, String)> {
|
||||
let session_manager = state.session_manager.as_ref().ok_or((
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
"Session manager not available".to_string(),
|
||||
))?;
|
||||
|
||||
let session = session_manager.get_or_create_session(&state.user_id).await;
|
||||
let mut sess = session.lock().await;
|
||||
let thread = sess.create_thread();
|
||||
|
||||
Ok(Json(ThreadInfo {
|
||||
id: thread.id,
|
||||
state: format!("{:?}", thread.state),
|
||||
turn_count: thread.turns.len(),
|
||||
created_at: thread.created_at.to_rfc3339(),
|
||||
updated_at: thread.updated_at.to_rfc3339(),
|
||||
}))
|
||||
}
|
||||
|
||||
// --- Memory handlers ---
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct TreeQuery {
|
||||
#[allow(dead_code)]
|
||||
depth: Option<usize>,
|
||||
}
|
||||
|
||||
async fn memory_tree_handler(
|
||||
State(state): State<Arc<GatewayState>>,
|
||||
Query(_query): Query<TreeQuery>,
|
||||
) -> Result<Json<MemoryTreeResponse>, (StatusCode, String)> {
|
||||
let workspace = state.workspace.as_ref().ok_or((
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
"Workspace not available".to_string(),
|
||||
))?;
|
||||
|
||||
// Build tree from list_all (flat list of all paths)
|
||||
let all_paths = workspace
|
||||
.list_all()
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
// Collect unique directories and files
|
||||
let mut entries: Vec<TreeEntry> = Vec::new();
|
||||
let mut seen_dirs: std::collections::HashSet<String> = std::collections::HashSet::new();
|
||||
|
||||
for path in &all_paths {
|
||||
// Add parent directories
|
||||
let parts: Vec<&str> = path.split('/').collect();
|
||||
for i in 0..parts.len().saturating_sub(1) {
|
||||
let dir_path = parts[..=i].join("/");
|
||||
if seen_dirs.insert(dir_path.clone()) {
|
||||
entries.push(TreeEntry {
|
||||
path: dir_path,
|
||||
is_dir: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
// Add the file itself
|
||||
entries.push(TreeEntry {
|
||||
path: path.clone(),
|
||||
is_dir: false,
|
||||
});
|
||||
}
|
||||
|
||||
entries.sort_by(|a, b| a.path.cmp(&b.path));
|
||||
|
||||
Ok(Json(MemoryTreeResponse { entries }))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct ListQuery {
|
||||
path: Option<String>,
|
||||
}
|
||||
|
||||
async fn memory_list_handler(
|
||||
State(state): State<Arc<GatewayState>>,
|
||||
Query(query): Query<ListQuery>,
|
||||
) -> Result<Json<MemoryListResponse>, (StatusCode, String)> {
|
||||
let workspace = state.workspace.as_ref().ok_or((
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
"Workspace not available".to_string(),
|
||||
))?;
|
||||
|
||||
let path = query.path.as_deref().unwrap_or("");
|
||||
let entries = workspace
|
||||
.list(path)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
let list_entries: Vec<ListEntry> = entries
|
||||
.iter()
|
||||
.map(|e| ListEntry {
|
||||
name: e.path.rsplit('/').next().unwrap_or(&e.path).to_string(),
|
||||
path: e.path.clone(),
|
||||
is_dir: e.is_directory,
|
||||
updated_at: e.updated_at.map(|dt| dt.to_rfc3339()),
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(Json(MemoryListResponse {
|
||||
path: path.to_string(),
|
||||
entries: list_entries,
|
||||
}))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct ReadQuery {
|
||||
path: String,
|
||||
}
|
||||
|
||||
async fn memory_read_handler(
|
||||
State(state): State<Arc<GatewayState>>,
|
||||
Query(query): Query<ReadQuery>,
|
||||
) -> Result<Json<MemoryReadResponse>, (StatusCode, String)> {
|
||||
let workspace = state.workspace.as_ref().ok_or((
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
"Workspace not available".to_string(),
|
||||
))?;
|
||||
|
||||
let doc = workspace
|
||||
.read(&query.path)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::NOT_FOUND, e.to_string()))?;
|
||||
|
||||
Ok(Json(MemoryReadResponse {
|
||||
path: query.path,
|
||||
content: doc.content,
|
||||
updated_at: Some(doc.updated_at.to_rfc3339()),
|
||||
}))
|
||||
}
|
||||
|
||||
async fn memory_write_handler(
|
||||
State(state): State<Arc<GatewayState>>,
|
||||
Json(req): Json<MemoryWriteRequest>,
|
||||
) -> Result<Json<MemoryWriteResponse>, (StatusCode, String)> {
|
||||
let workspace = state.workspace.as_ref().ok_or((
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
"Workspace not available".to_string(),
|
||||
))?;
|
||||
|
||||
workspace
|
||||
.write(&req.path, &req.content)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
Ok(Json(MemoryWriteResponse {
|
||||
path: req.path,
|
||||
status: "written",
|
||||
}))
|
||||
}
|
||||
|
||||
async fn memory_search_handler(
|
||||
State(state): State<Arc<GatewayState>>,
|
||||
Json(req): Json<MemorySearchRequest>,
|
||||
) -> Result<Json<MemorySearchResponse>, (StatusCode, String)> {
|
||||
let workspace = state.workspace.as_ref().ok_or((
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
"Workspace not available".to_string(),
|
||||
))?;
|
||||
|
||||
let limit = req.limit.unwrap_or(10);
|
||||
let results = workspace
|
||||
.search(&req.query, limit)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
let hits: Vec<SearchHit> = results
|
||||
.iter()
|
||||
.map(|r| SearchHit {
|
||||
path: r.document_id.to_string(),
|
||||
content: r.content.clone(),
|
||||
score: r.score as f64,
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(Json(MemorySearchResponse { results: hits }))
|
||||
}
|
||||
|
||||
// --- Jobs handlers ---
|
||||
|
||||
async fn jobs_list_handler(
|
||||
State(state): State<Arc<GatewayState>>,
|
||||
) -> Result<Json<JobListResponse>, (StatusCode, String)> {
|
||||
let context_manager = state.context_manager.as_ref().ok_or((
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
"Context manager not available".to_string(),
|
||||
))?;
|
||||
|
||||
let job_ids = context_manager.all_jobs_for(&state.user_id).await;
|
||||
let mut jobs = Vec::new();
|
||||
|
||||
for job_id in job_ids {
|
||||
if let Ok(ctx) = context_manager.get_context(job_id).await {
|
||||
jobs.push(JobInfo {
|
||||
id: ctx.job_id,
|
||||
title: ctx.title.clone(),
|
||||
state: ctx.state.to_string(),
|
||||
user_id: ctx.user_id.clone(),
|
||||
created_at: ctx.created_at.to_rfc3339(),
|
||||
started_at: ctx.started_at.map(|dt| dt.to_rfc3339()),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Ok(Json(JobListResponse { jobs }))
|
||||
}
|
||||
|
||||
async fn jobs_summary_handler(
|
||||
State(state): State<Arc<GatewayState>>,
|
||||
) -> Result<Json<JobSummaryResponse>, (StatusCode, String)> {
|
||||
let context_manager = state.context_manager.as_ref().ok_or((
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
"Context manager not available".to_string(),
|
||||
))?;
|
||||
|
||||
let summary = context_manager.summary_for(&state.user_id).await;
|
||||
|
||||
Ok(Json(JobSummaryResponse {
|
||||
total: summary.total,
|
||||
pending: summary.pending,
|
||||
in_progress: summary.in_progress,
|
||||
completed: summary.completed,
|
||||
failed: summary.failed,
|
||||
stuck: summary.stuck,
|
||||
}))
|
||||
}
|
||||
|
||||
async fn jobs_detail_handler(
|
||||
State(state): State<Arc<GatewayState>>,
|
||||
Path(id): Path<String>,
|
||||
) -> Result<Json<JobInfo>, (StatusCode, String)> {
|
||||
let context_manager = state.context_manager.as_ref().ok_or((
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
"Context manager not available".to_string(),
|
||||
))?;
|
||||
|
||||
let job_id = Uuid::parse_str(&id)
|
||||
.map_err(|_| (StatusCode::BAD_REQUEST, "Invalid job ID".to_string()))?;
|
||||
|
||||
let ctx = context_manager
|
||||
.get_context(job_id)
|
||||
.await
|
||||
.map_err(|_| (StatusCode::NOT_FOUND, "Job not found".to_string()))?;
|
||||
|
||||
if ctx.user_id != state.user_id {
|
||||
return Err((StatusCode::NOT_FOUND, "Job not found".to_string()));
|
||||
}
|
||||
|
||||
Ok(Json(JobInfo {
|
||||
id: ctx.job_id,
|
||||
title: ctx.title.clone(),
|
||||
state: ctx.state.to_string(),
|
||||
user_id: ctx.user_id.clone(),
|
||||
created_at: ctx.created_at.to_rfc3339(),
|
||||
started_at: ctx.started_at.map(|dt| dt.to_rfc3339()),
|
||||
}))
|
||||
}
|
||||
|
||||
async fn jobs_cancel_handler(
|
||||
State(state): State<Arc<GatewayState>>,
|
||||
Path(id): Path<String>,
|
||||
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
|
||||
let context_manager = state.context_manager.as_ref().ok_or((
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
"Context manager not available".to_string(),
|
||||
))?;
|
||||
|
||||
let job_id = Uuid::parse_str(&id)
|
||||
.map_err(|_| (StatusCode::BAD_REQUEST, "Invalid job ID".to_string()))?;
|
||||
|
||||
let ctx = context_manager
|
||||
.get_context(job_id)
|
||||
.await
|
||||
.map_err(|_| (StatusCode::NOT_FOUND, "Job not found".to_string()))?;
|
||||
|
||||
if ctx.user_id != state.user_id {
|
||||
return Err((StatusCode::NOT_FOUND, "Job not found".to_string()));
|
||||
}
|
||||
|
||||
context_manager
|
||||
.update_context(job_id, |ctx| {
|
||||
ctx.transition_to(crate::context::JobState::Cancelled, None)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
|
||||
.map_err(|msg| (StatusCode::CONFLICT, msg))?;
|
||||
|
||||
Ok(Json(serde_json::json!({
|
||||
"status": "cancelled",
|
||||
"job_id": job_id,
|
||||
})))
|
||||
}
|
||||
|
||||
// --- Logs handlers ---
|
||||
|
||||
async fn logs_events_handler(
|
||||
State(state): State<Arc<GatewayState>>,
|
||||
) -> Result<
|
||||
Sse<impl futures::Stream<Item = Result<Event, Infallible>> + Send + 'static>,
|
||||
(StatusCode, String),
|
||||
> {
|
||||
let broadcaster = state.log_broadcaster.as_ref().ok_or((
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
"Log broadcaster not available".to_string(),
|
||||
))?;
|
||||
|
||||
// Replay recent history so late-joining browsers see startup logs.
|
||||
// Subscribe BEFORE snapshotting to avoid a gap between history and live.
|
||||
let rx = broadcaster.subscribe();
|
||||
let history = broadcaster.recent_entries();
|
||||
|
||||
let history_stream = futures::stream::iter(history).map(|entry| {
|
||||
let data = serde_json::to_string(&entry).unwrap_or_default();
|
||||
Ok(Event::default().event("log").data(data))
|
||||
});
|
||||
|
||||
let live_stream = tokio_stream::wrappers::BroadcastStream::new(rx)
|
||||
.filter_map(|result| result.ok())
|
||||
.map(|entry| {
|
||||
let data = serde_json::to_string(&entry).unwrap_or_default();
|
||||
Ok(Event::default().event("log").data(data))
|
||||
});
|
||||
|
||||
let stream = history_stream.chain(live_stream);
|
||||
|
||||
Ok(Sse::new(stream).keep_alive(
|
||||
KeepAlive::new()
|
||||
.interval(std::time::Duration::from_secs(30))
|
||||
.text(""),
|
||||
))
|
||||
}
|
||||
|
||||
// --- Extension handlers ---
|
||||
|
||||
async fn extensions_list_handler(
|
||||
State(state): State<Arc<GatewayState>>,
|
||||
) -> Result<Json<ExtensionListResponse>, (StatusCode, String)> {
|
||||
let ext_mgr = state.extension_manager.as_ref().ok_or((
|
||||
StatusCode::NOT_IMPLEMENTED,
|
||||
"Extension manager not available (secrets store required)".to_string(),
|
||||
))?;
|
||||
|
||||
let installed = ext_mgr
|
||||
.list(None)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
let extensions = installed
|
||||
.into_iter()
|
||||
.map(|ext| ExtensionInfo {
|
||||
name: ext.name,
|
||||
kind: ext.kind.to_string(),
|
||||
description: ext.description,
|
||||
authenticated: ext.authenticated,
|
||||
active: ext.active,
|
||||
tools: ext.tools,
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(Json(ExtensionListResponse { extensions }))
|
||||
}
|
||||
|
||||
async fn extensions_tools_handler(
|
||||
State(state): State<Arc<GatewayState>>,
|
||||
) -> Result<Json<ToolListResponse>, (StatusCode, String)> {
|
||||
let registry = state.tool_registry.as_ref().ok_or((
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
"Tool registry not available".to_string(),
|
||||
))?;
|
||||
|
||||
let definitions = registry.tool_definitions().await;
|
||||
let tools = definitions
|
||||
.into_iter()
|
||||
.map(|td| ToolInfo {
|
||||
name: td.name,
|
||||
description: td.description,
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(Json(ToolListResponse { tools }))
|
||||
}
|
||||
|
||||
async fn extensions_install_handler(
|
||||
State(state): State<Arc<GatewayState>>,
|
||||
Json(req): Json<InstallExtensionRequest>,
|
||||
) -> Result<Json<ActionResponse>, (StatusCode, String)> {
|
||||
let ext_mgr = state.extension_manager.as_ref().ok_or((
|
||||
StatusCode::NOT_IMPLEMENTED,
|
||||
"Extension manager not available (secrets store required)".to_string(),
|
||||
))?;
|
||||
|
||||
let kind_hint = req.kind.as_deref().and_then(|k| match k {
|
||||
"mcp_server" => Some(crate::extensions::ExtensionKind::McpServer),
|
||||
"wasm_tool" => Some(crate::extensions::ExtensionKind::WasmTool),
|
||||
"wasm_channel" => Some(crate::extensions::ExtensionKind::WasmChannel),
|
||||
_ => None,
|
||||
});
|
||||
|
||||
match ext_mgr
|
||||
.install(&req.name, req.url.as_deref(), kind_hint)
|
||||
.await
|
||||
{
|
||||
Ok(result) => Ok(Json(ActionResponse {
|
||||
success: true,
|
||||
message: result.message,
|
||||
})),
|
||||
Err(e) => Ok(Json(ActionResponse {
|
||||
success: false,
|
||||
message: e.to_string(),
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
async fn extensions_activate_handler(
|
||||
State(state): State<Arc<GatewayState>>,
|
||||
Path(name): Path<String>,
|
||||
) -> Result<Json<ActionResponse>, (StatusCode, String)> {
|
||||
let ext_mgr = state.extension_manager.as_ref().ok_or((
|
||||
StatusCode::NOT_IMPLEMENTED,
|
||||
"Extension manager not available (secrets store required)".to_string(),
|
||||
))?;
|
||||
|
||||
match ext_mgr.activate(&name).await {
|
||||
Ok(result) => Ok(Json(ActionResponse {
|
||||
success: true,
|
||||
message: result.message,
|
||||
})),
|
||||
Err(e) => Ok(Json(ActionResponse {
|
||||
success: false,
|
||||
message: e.to_string(),
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
async fn extensions_remove_handler(
|
||||
State(state): State<Arc<GatewayState>>,
|
||||
Path(name): Path<String>,
|
||||
) -> Result<Json<ActionResponse>, (StatusCode, String)> {
|
||||
let ext_mgr = state.extension_manager.as_ref().ok_or((
|
||||
StatusCode::NOT_IMPLEMENTED,
|
||||
"Extension manager not available (secrets store required)".to_string(),
|
||||
))?;
|
||||
|
||||
match ext_mgr.remove(&name).await {
|
||||
Ok(message) => Ok(Json(ActionResponse {
|
||||
success: true,
|
||||
message,
|
||||
})),
|
||||
Err(e) => Ok(Json(ActionResponse {
|
||||
success: false,
|
||||
message: e.to_string(),
|
||||
})),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
//! SSE connection manager for broadcasting events to browser tabs.
|
||||
|
||||
use std::convert::Infallible;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::time::Duration;
|
||||
|
||||
use axum::response::sse::{Event, KeepAlive, Sse};
|
||||
use futures::Stream;
|
||||
use tokio::sync::broadcast;
|
||||
use tokio_stream::StreamExt;
|
||||
use tokio_stream::wrappers::BroadcastStream;
|
||||
|
||||
use crate::channels::web::types::SseEvent;
|
||||
|
||||
/// Manages SSE broadcast to all connected browser tabs.
|
||||
pub struct SseManager {
|
||||
tx: broadcast::Sender<SseEvent>,
|
||||
connection_count: Arc<AtomicU64>,
|
||||
}
|
||||
|
||||
impl SseManager {
|
||||
/// Create a new SSE manager.
|
||||
pub fn new() -> Self {
|
||||
// Buffer 256 events; slow clients will miss events (acceptable for SSE with reconnect)
|
||||
let (tx, _) = broadcast::channel(256);
|
||||
Self {
|
||||
tx,
|
||||
connection_count: Arc::new(AtomicU64::new(0)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Broadcast an event to all connected clients.
|
||||
pub fn broadcast(&self, event: SseEvent) {
|
||||
// Ignore send errors (no receivers is fine)
|
||||
let _ = self.tx.send(event);
|
||||
}
|
||||
|
||||
/// Get current number of active connections.
|
||||
pub fn connection_count(&self) -> u64 {
|
||||
self.connection_count.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// Create a new SSE stream for a client connection.
|
||||
pub fn subscribe(
|
||||
&self,
|
||||
) -> Sse<impl Stream<Item = Result<Event, Infallible>> + Send + 'static + use<>> {
|
||||
let counter = Arc::clone(&self.connection_count);
|
||||
counter.fetch_add(1, Ordering::Relaxed);
|
||||
let rx = self.tx.subscribe();
|
||||
|
||||
let stream = BroadcastStream::new(rx)
|
||||
.filter_map(|result| result.ok())
|
||||
.map(|event| {
|
||||
let data = serde_json::to_string(&event).unwrap_or_default();
|
||||
let event_type = match &event {
|
||||
SseEvent::Response { .. } => "response",
|
||||
SseEvent::Thinking { .. } => "thinking",
|
||||
SseEvent::ToolStarted { .. } => "tool_started",
|
||||
SseEvent::ToolCompleted { .. } => "tool_completed",
|
||||
SseEvent::StreamChunk { .. } => "stream_chunk",
|
||||
SseEvent::Status { .. } => "status",
|
||||
SseEvent::ApprovalNeeded { .. } => "approval_needed",
|
||||
SseEvent::Error { .. } => "error",
|
||||
SseEvent::Heartbeat => "heartbeat",
|
||||
};
|
||||
Ok(Event::default().event(event_type).data(data))
|
||||
});
|
||||
|
||||
// Wrap in a stream that decrements on drop
|
||||
let counted_stream = CountedStream {
|
||||
inner: stream,
|
||||
counter,
|
||||
};
|
||||
|
||||
Sse::new(counted_stream)
|
||||
.keep_alive(KeepAlive::new().interval(Duration::from_secs(30)).text(""))
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for SseManager {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
/// Stream wrapper that decrements connection count on drop.
|
||||
///
|
||||
/// When the SSE client disconnects, this stream is dropped
|
||||
/// and the counter is decremented.
|
||||
struct CountedStream<S> {
|
||||
inner: S,
|
||||
counter: Arc<AtomicU64>,
|
||||
}
|
||||
|
||||
impl<S: Stream + Unpin> Stream for CountedStream<S> {
|
||||
type Item = S::Item;
|
||||
|
||||
fn poll_next(
|
||||
mut self: std::pin::Pin<&mut Self>,
|
||||
cx: &mut std::task::Context<'_>,
|
||||
) -> std::task::Poll<Option<Self::Item>> {
|
||||
std::pin::Pin::new(&mut self.inner).poll_next(cx)
|
||||
}
|
||||
}
|
||||
|
||||
impl<S> Drop for CountedStream<S> {
|
||||
fn drop(&mut self) {
|
||||
self.counter.fetch_sub(1, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_sse_manager_creation() {
|
||||
let manager = SseManager::new();
|
||||
assert_eq!(manager.connection_count(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_broadcast_without_receivers() {
|
||||
let manager = SseManager::new();
|
||||
// Should not panic even with no receivers
|
||||
manager.broadcast(SseEvent::Heartbeat);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_broadcast_to_receiver() {
|
||||
let manager = SseManager::new();
|
||||
let mut rx = BroadcastStream::new(manager.tx.subscribe());
|
||||
|
||||
manager.broadcast(SseEvent::Status {
|
||||
message: "test".to_string(),
|
||||
});
|
||||
|
||||
let event = rx.next().await;
|
||||
assert!(event.is_some());
|
||||
let event = event.unwrap().unwrap();
|
||||
match event {
|
||||
SseEvent::Status { message } => assert_eq!(message, "test"),
|
||||
_ => panic!("unexpected event type"),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,707 @@
|
||||
// IronClaw Web Gateway - Client
|
||||
|
||||
let token = '';
|
||||
let eventSource = null;
|
||||
let logEventSource = null;
|
||||
let currentTab = 'chat';
|
||||
|
||||
// --- Auth ---
|
||||
|
||||
function authenticate() {
|
||||
token = document.getElementById('token-input').value.trim();
|
||||
if (!token) {
|
||||
document.getElementById('auth-error').textContent = 'Token required';
|
||||
return;
|
||||
}
|
||||
|
||||
// Test the token against the health-ish endpoint (chat/threads requires auth)
|
||||
apiFetch('/api/chat/threads')
|
||||
.then(() => {
|
||||
document.getElementById('auth-screen').style.display = 'none';
|
||||
document.getElementById('app').style.display = 'flex';
|
||||
connectSSE();
|
||||
connectLogSSE();
|
||||
loadHistory();
|
||||
loadMemoryTree();
|
||||
loadJobs();
|
||||
})
|
||||
.catch(() => {
|
||||
document.getElementById('auth-error').textContent = 'Invalid token';
|
||||
});
|
||||
}
|
||||
|
||||
document.getElementById('token-input').addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter') authenticate();
|
||||
});
|
||||
|
||||
// --- API helper ---
|
||||
|
||||
function apiFetch(path, options) {
|
||||
const opts = options || {};
|
||||
opts.headers = opts.headers || {};
|
||||
opts.headers['Authorization'] = 'Bearer ' + token;
|
||||
if (opts.body && typeof opts.body === 'object') {
|
||||
opts.headers['Content-Type'] = 'application/json';
|
||||
opts.body = JSON.stringify(opts.body);
|
||||
}
|
||||
return fetch(path, opts).then((res) => {
|
||||
if (!res.ok) throw new Error(res.status + ' ' + res.statusText);
|
||||
return res.json();
|
||||
});
|
||||
}
|
||||
|
||||
// --- SSE ---
|
||||
|
||||
function connectSSE() {
|
||||
if (eventSource) eventSource.close();
|
||||
|
||||
eventSource = new EventSource('/api/chat/events?token=' + encodeURIComponent(token));
|
||||
|
||||
eventSource.onopen = () => {
|
||||
document.getElementById('sse-dot').classList.remove('disconnected');
|
||||
document.getElementById('sse-status').textContent = 'Connected';
|
||||
};
|
||||
|
||||
eventSource.onerror = () => {
|
||||
document.getElementById('sse-dot').classList.add('disconnected');
|
||||
document.getElementById('sse-status').textContent = 'Reconnecting...';
|
||||
};
|
||||
|
||||
eventSource.addEventListener('response', (e) => {
|
||||
const data = JSON.parse(e.data);
|
||||
addMessage('assistant', data.content);
|
||||
setStatus('');
|
||||
hideApproval();
|
||||
});
|
||||
|
||||
eventSource.addEventListener('thinking', (e) => {
|
||||
const data = JSON.parse(e.data);
|
||||
setStatus(data.message, true);
|
||||
});
|
||||
|
||||
eventSource.addEventListener('tool_started', (e) => {
|
||||
const data = JSON.parse(e.data);
|
||||
setStatus('Running tool: ' + data.name, true);
|
||||
});
|
||||
|
||||
eventSource.addEventListener('tool_completed', (e) => {
|
||||
const data = JSON.parse(e.data);
|
||||
const icon = data.success ? '\u2713' : '\u2717';
|
||||
setStatus('Tool ' + data.name + ' ' + icon);
|
||||
});
|
||||
|
||||
eventSource.addEventListener('stream_chunk', (e) => {
|
||||
const data = JSON.parse(e.data);
|
||||
appendToLastAssistant(data.content);
|
||||
});
|
||||
|
||||
eventSource.addEventListener('status', (e) => {
|
||||
const data = JSON.parse(e.data);
|
||||
setStatus(data.message);
|
||||
});
|
||||
|
||||
eventSource.addEventListener('approval_needed', (e) => {
|
||||
const data = JSON.parse(e.data);
|
||||
showApproval(data);
|
||||
});
|
||||
|
||||
eventSource.addEventListener('error', (e) => {
|
||||
if (e.data) {
|
||||
const data = JSON.parse(e.data);
|
||||
addMessage('system', 'Error: ' + data.message);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// --- Chat ---
|
||||
|
||||
function sendMessage() {
|
||||
const input = document.getElementById('chat-input');
|
||||
const content = input.value.trim();
|
||||
if (!content) return;
|
||||
|
||||
addMessage('user', content);
|
||||
input.value = '';
|
||||
autoResizeTextarea(input);
|
||||
setStatus('Sending...', true);
|
||||
|
||||
apiFetch('/api/chat/send', {
|
||||
method: 'POST',
|
||||
body: { content },
|
||||
}).catch((err) => {
|
||||
addMessage('system', 'Failed to send: ' + err.message);
|
||||
setStatus('');
|
||||
});
|
||||
}
|
||||
|
||||
function sendApproval(response) {
|
||||
apiFetch('/api/chat/send', {
|
||||
method: 'POST',
|
||||
body: { content: response },
|
||||
}).catch((err) => {
|
||||
addMessage('system', 'Failed to send approval: ' + err.message);
|
||||
});
|
||||
hideApproval();
|
||||
}
|
||||
|
||||
function addMessage(role, content) {
|
||||
const container = document.getElementById('chat-messages');
|
||||
const div = document.createElement('div');
|
||||
div.className = 'message ' + role;
|
||||
div.textContent = content;
|
||||
container.appendChild(div);
|
||||
container.scrollTop = container.scrollHeight;
|
||||
}
|
||||
|
||||
function appendToLastAssistant(chunk) {
|
||||
const container = document.getElementById('chat-messages');
|
||||
const messages = container.querySelectorAll('.message.assistant');
|
||||
if (messages.length > 0) {
|
||||
const last = messages[messages.length - 1];
|
||||
last.textContent += chunk;
|
||||
container.scrollTop = container.scrollHeight;
|
||||
} else {
|
||||
addMessage('assistant', chunk);
|
||||
}
|
||||
}
|
||||
|
||||
function setStatus(text, spinning) {
|
||||
const el = document.getElementById('chat-status');
|
||||
if (!text) {
|
||||
el.innerHTML = '';
|
||||
return;
|
||||
}
|
||||
el.innerHTML = (spinning ? '<div class="spinner"></div>' : '') + escapeHtml(text);
|
||||
}
|
||||
|
||||
function showApproval(data) {
|
||||
const banner = document.getElementById('approval-banner');
|
||||
const info = document.getElementById('approval-info');
|
||||
info.textContent = 'Tool "' + data.tool_name + '" requires approval: ' + data.description;
|
||||
banner.classList.add('visible');
|
||||
}
|
||||
|
||||
function hideApproval() {
|
||||
document.getElementById('approval-banner').classList.remove('visible');
|
||||
}
|
||||
|
||||
function loadHistory() {
|
||||
apiFetch('/api/chat/history').then((data) => {
|
||||
const container = document.getElementById('chat-messages');
|
||||
container.innerHTML = '';
|
||||
for (const turn of data.turns) {
|
||||
addMessage('user', turn.user_input);
|
||||
if (turn.response) {
|
||||
addMessage('assistant', turn.response);
|
||||
}
|
||||
}
|
||||
}).catch(() => {
|
||||
// No history or no active thread, that's fine
|
||||
});
|
||||
}
|
||||
|
||||
// Chat input auto-resize and keyboard handling
|
||||
const chatInput = document.getElementById('chat-input');
|
||||
chatInput.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
sendMessage();
|
||||
}
|
||||
});
|
||||
chatInput.addEventListener('input', () => autoResizeTextarea(chatInput));
|
||||
|
||||
function autoResizeTextarea(el) {
|
||||
el.style.height = 'auto';
|
||||
el.style.height = Math.min(el.scrollHeight, 120) + 'px';
|
||||
}
|
||||
|
||||
// --- Tabs ---
|
||||
|
||||
document.querySelectorAll('.tab-bar button[data-tab]').forEach((btn) => {
|
||||
btn.addEventListener('click', () => {
|
||||
const tab = btn.getAttribute('data-tab');
|
||||
switchTab(tab);
|
||||
});
|
||||
});
|
||||
|
||||
function switchTab(tab) {
|
||||
currentTab = tab;
|
||||
document.querySelectorAll('.tab-bar button[data-tab]').forEach((b) => {
|
||||
b.classList.toggle('active', b.getAttribute('data-tab') === tab);
|
||||
});
|
||||
document.querySelectorAll('.tab-panel').forEach((p) => {
|
||||
p.classList.toggle('active', p.id === 'tab-' + tab);
|
||||
});
|
||||
|
||||
if (tab === 'memory') loadMemoryTree();
|
||||
if (tab === 'jobs') loadJobs();
|
||||
if (tab === 'extensions') loadExtensions();
|
||||
}
|
||||
|
||||
// --- Memory (filesystem tree) ---
|
||||
|
||||
let memorySearchTimeout = null;
|
||||
// Tree state: nested nodes persisted across renders
|
||||
// { name, path, is_dir, children: [] | null, expanded: bool, loaded: bool }
|
||||
let memoryTreeState = null;
|
||||
|
||||
document.getElementById('memory-search').addEventListener('input', (e) => {
|
||||
clearTimeout(memorySearchTimeout);
|
||||
const query = e.target.value.trim();
|
||||
if (!query) {
|
||||
loadMemoryTree();
|
||||
return;
|
||||
}
|
||||
memorySearchTimeout = setTimeout(() => searchMemory(query), 300);
|
||||
});
|
||||
|
||||
function loadMemoryTree() {
|
||||
// Only load top-level on first load (or refresh)
|
||||
apiFetch('/api/memory/list?path=').then((data) => {
|
||||
memoryTreeState = data.entries.map((e) => ({
|
||||
name: e.name,
|
||||
path: e.path,
|
||||
is_dir: e.is_dir,
|
||||
children: e.is_dir ? null : undefined,
|
||||
expanded: false,
|
||||
loaded: false,
|
||||
}));
|
||||
renderTree();
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
function renderTree() {
|
||||
const container = document.getElementById('memory-tree');
|
||||
container.innerHTML = '';
|
||||
if (!memoryTreeState || memoryTreeState.length === 0) {
|
||||
container.innerHTML = '<div class="tree-item" style="color:var(--text-secondary)">No files in workspace</div>';
|
||||
return;
|
||||
}
|
||||
renderNodes(memoryTreeState, container, 0);
|
||||
}
|
||||
|
||||
function renderNodes(nodes, container, depth) {
|
||||
for (const node of nodes) {
|
||||
const row = document.createElement('div');
|
||||
row.className = 'tree-row';
|
||||
row.style.paddingLeft = (depth * 16 + 8) + 'px';
|
||||
|
||||
if (node.is_dir) {
|
||||
const arrow = document.createElement('span');
|
||||
arrow.className = 'expand-arrow' + (node.expanded ? ' expanded' : '');
|
||||
arrow.textContent = '\u25B6';
|
||||
arrow.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
toggleExpand(node);
|
||||
});
|
||||
row.appendChild(arrow);
|
||||
|
||||
const label = document.createElement('span');
|
||||
label.className = 'tree-label dir';
|
||||
label.textContent = node.name;
|
||||
label.addEventListener('click', () => toggleExpand(node));
|
||||
row.appendChild(label);
|
||||
} else {
|
||||
const spacer = document.createElement('span');
|
||||
spacer.className = 'expand-arrow-spacer';
|
||||
row.appendChild(spacer);
|
||||
|
||||
const label = document.createElement('span');
|
||||
label.className = 'tree-label file';
|
||||
label.textContent = node.name;
|
||||
label.addEventListener('click', () => readMemoryFile(node.path));
|
||||
row.appendChild(label);
|
||||
}
|
||||
|
||||
container.appendChild(row);
|
||||
|
||||
if (node.is_dir && node.expanded && node.children) {
|
||||
const childContainer = document.createElement('div');
|
||||
childContainer.className = 'tree-children';
|
||||
renderNodes(node.children, childContainer, depth + 1);
|
||||
container.appendChild(childContainer);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function toggleExpand(node) {
|
||||
if (node.expanded) {
|
||||
node.expanded = false;
|
||||
renderTree();
|
||||
return;
|
||||
}
|
||||
|
||||
if (node.loaded) {
|
||||
node.expanded = true;
|
||||
renderTree();
|
||||
return;
|
||||
}
|
||||
|
||||
// Lazy-load children
|
||||
apiFetch('/api/memory/list?path=' + encodeURIComponent(node.path)).then((data) => {
|
||||
node.children = data.entries.map((e) => ({
|
||||
name: e.name,
|
||||
path: e.path,
|
||||
is_dir: e.is_dir,
|
||||
children: e.is_dir ? null : undefined,
|
||||
expanded: false,
|
||||
loaded: false,
|
||||
}));
|
||||
node.loaded = true;
|
||||
node.expanded = true;
|
||||
renderTree();
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
function readMemoryFile(path) {
|
||||
// Update breadcrumb
|
||||
document.getElementById('memory-breadcrumb').innerHTML = buildBreadcrumb(path);
|
||||
|
||||
apiFetch('/api/memory/read?path=' + encodeURIComponent(path)).then((data) => {
|
||||
document.getElementById('memory-viewer').textContent = data.content;
|
||||
}).catch((err) => {
|
||||
document.getElementById('memory-viewer').innerHTML = '<div class="empty">Error: ' + escapeHtml(err.message) + '</div>';
|
||||
});
|
||||
}
|
||||
|
||||
function buildBreadcrumb(path) {
|
||||
const parts = path.split('/');
|
||||
let html = '<a onclick="loadMemoryTree()">workspace</a>';
|
||||
let current = '';
|
||||
for (const part of parts) {
|
||||
current += (current ? '/' : '') + part;
|
||||
html += ' / <a onclick="readMemoryFile(\'' + escapeHtml(current) + '\')">' + escapeHtml(part) + '</a>';
|
||||
}
|
||||
return html;
|
||||
}
|
||||
|
||||
function searchMemory(query) {
|
||||
apiFetch('/api/memory/search', {
|
||||
method: 'POST',
|
||||
body: { query, limit: 20 },
|
||||
}).then((data) => {
|
||||
const tree = document.getElementById('memory-tree');
|
||||
tree.innerHTML = '';
|
||||
if (data.results.length === 0) {
|
||||
tree.innerHTML = '<div class="tree-item" style="color:var(--text-secondary)">No results</div>';
|
||||
return;
|
||||
}
|
||||
for (const result of data.results) {
|
||||
const item = document.createElement('div');
|
||||
item.className = 'search-result';
|
||||
item.innerHTML = '<div class="path">' + escapeHtml(result.path) + '</div>'
|
||||
+ '<div class="snippet">' + escapeHtml(result.content.substring(0, 120)) + '</div>';
|
||||
item.addEventListener('click', () => readMemoryFile(result.path));
|
||||
tree.appendChild(item);
|
||||
}
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
// --- Logs ---
|
||||
|
||||
const LOG_MAX_ENTRIES = 2000;
|
||||
let logsPaused = false;
|
||||
let logBuffer = []; // buffer while paused
|
||||
|
||||
function connectLogSSE() {
|
||||
if (logEventSource) logEventSource.close();
|
||||
|
||||
logEventSource = new EventSource('/api/logs/events?token=' + encodeURIComponent(token));
|
||||
|
||||
logEventSource.addEventListener('log', (e) => {
|
||||
const entry = JSON.parse(e.data);
|
||||
if (logsPaused) {
|
||||
logBuffer.push(entry);
|
||||
return;
|
||||
}
|
||||
appendLogEntry(entry);
|
||||
});
|
||||
|
||||
logEventSource.onerror = () => {
|
||||
// Silent reconnect
|
||||
};
|
||||
}
|
||||
|
||||
function appendLogEntry(entry) {
|
||||
const output = document.getElementById('logs-output');
|
||||
|
||||
// Level filter
|
||||
const levelFilter = document.getElementById('logs-level-filter').value;
|
||||
const targetFilter = document.getElementById('logs-target-filter').value.trim().toLowerCase();
|
||||
|
||||
const div = document.createElement('div');
|
||||
div.className = 'log-entry level-' + entry.level;
|
||||
div.setAttribute('data-level', entry.level);
|
||||
div.setAttribute('data-target', entry.target);
|
||||
|
||||
const ts = document.createElement('span');
|
||||
ts.className = 'log-ts';
|
||||
ts.textContent = entry.timestamp.substring(11, 23);
|
||||
div.appendChild(ts);
|
||||
|
||||
const lvl = document.createElement('span');
|
||||
lvl.className = 'log-level';
|
||||
lvl.textContent = entry.level.padEnd(5);
|
||||
div.appendChild(lvl);
|
||||
|
||||
const tgt = document.createElement('span');
|
||||
tgt.className = 'log-target';
|
||||
tgt.textContent = entry.target;
|
||||
div.appendChild(tgt);
|
||||
|
||||
const msg = document.createElement('span');
|
||||
msg.className = 'log-msg';
|
||||
msg.textContent = entry.message;
|
||||
div.appendChild(msg);
|
||||
|
||||
// Apply current filters as visibility
|
||||
const matchesLevel = levelFilter === 'all' || entry.level === levelFilter;
|
||||
const matchesTarget = !targetFilter || entry.target.toLowerCase().includes(targetFilter);
|
||||
if (!matchesLevel || !matchesTarget) {
|
||||
div.style.display = 'none';
|
||||
}
|
||||
|
||||
output.appendChild(div);
|
||||
|
||||
// Cap entries
|
||||
while (output.children.length > LOG_MAX_ENTRIES) {
|
||||
output.removeChild(output.firstChild);
|
||||
}
|
||||
|
||||
// Auto-scroll
|
||||
if (document.getElementById('logs-autoscroll').checked) {
|
||||
output.scrollTop = output.scrollHeight;
|
||||
}
|
||||
}
|
||||
|
||||
function toggleLogsPause() {
|
||||
logsPaused = !logsPaused;
|
||||
const btn = document.getElementById('logs-pause-btn');
|
||||
btn.textContent = logsPaused ? 'Resume' : 'Pause';
|
||||
|
||||
if (!logsPaused) {
|
||||
// Flush buffer
|
||||
for (const entry of logBuffer) {
|
||||
appendLogEntry(entry);
|
||||
}
|
||||
logBuffer = [];
|
||||
}
|
||||
}
|
||||
|
||||
function clearLogs() {
|
||||
document.getElementById('logs-output').innerHTML = '';
|
||||
logBuffer = [];
|
||||
}
|
||||
|
||||
// Re-apply filters when level or target changes
|
||||
document.getElementById('logs-level-filter').addEventListener('change', applyLogFilters);
|
||||
document.getElementById('logs-target-filter').addEventListener('input', applyLogFilters);
|
||||
|
||||
function applyLogFilters() {
|
||||
const levelFilter = document.getElementById('logs-level-filter').value;
|
||||
const targetFilter = document.getElementById('logs-target-filter').value.trim().toLowerCase();
|
||||
const entries = document.querySelectorAll('#logs-output .log-entry');
|
||||
for (const el of entries) {
|
||||
const matchesLevel = levelFilter === 'all' || el.getAttribute('data-level') === levelFilter;
|
||||
const matchesTarget = !targetFilter || el.getAttribute('data-target').toLowerCase().includes(targetFilter);
|
||||
el.style.display = (matchesLevel && matchesTarget) ? '' : 'none';
|
||||
}
|
||||
}
|
||||
|
||||
// --- Extensions ---
|
||||
|
||||
function loadExtensions() {
|
||||
const extList = document.getElementById('extensions-list');
|
||||
const toolsTbody = document.getElementById('tools-tbody');
|
||||
const toolsEmpty = document.getElementById('tools-empty');
|
||||
|
||||
// Fetch both in parallel
|
||||
Promise.all([
|
||||
apiFetch('/api/extensions').catch(() => ({ extensions: [] })),
|
||||
apiFetch('/api/extensions/tools').catch(() => ({ tools: [] })),
|
||||
]).then(([extData, toolData]) => {
|
||||
// Render extensions
|
||||
if (extData.extensions.length === 0) {
|
||||
extList.innerHTML = '<div class="empty-state">No extensions installed</div>';
|
||||
} else {
|
||||
extList.innerHTML = '';
|
||||
for (const ext of extData.extensions) {
|
||||
extList.appendChild(renderExtensionCard(ext));
|
||||
}
|
||||
}
|
||||
|
||||
// Render tools
|
||||
if (toolData.tools.length === 0) {
|
||||
toolsTbody.innerHTML = '';
|
||||
toolsEmpty.style.display = 'block';
|
||||
} else {
|
||||
toolsEmpty.style.display = 'none';
|
||||
toolsTbody.innerHTML = toolData.tools.map((t) =>
|
||||
'<tr><td>' + escapeHtml(t.name) + '</td><td>' + escapeHtml(t.description) + '</td></tr>'
|
||||
).join('');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function renderExtensionCard(ext) {
|
||||
const card = document.createElement('div');
|
||||
card.className = 'ext-card';
|
||||
|
||||
const header = document.createElement('div');
|
||||
header.className = 'ext-header';
|
||||
|
||||
const name = document.createElement('span');
|
||||
name.className = 'ext-name';
|
||||
name.textContent = ext.name;
|
||||
header.appendChild(name);
|
||||
|
||||
const kind = document.createElement('span');
|
||||
kind.className = 'ext-kind kind-' + ext.kind;
|
||||
kind.textContent = ext.kind;
|
||||
header.appendChild(kind);
|
||||
|
||||
const authDot = document.createElement('span');
|
||||
authDot.className = 'ext-auth-dot ' + (ext.authenticated ? 'authed' : 'unauthed');
|
||||
authDot.title = ext.authenticated ? 'Authenticated' : 'Not authenticated';
|
||||
header.appendChild(authDot);
|
||||
|
||||
card.appendChild(header);
|
||||
|
||||
if (ext.description) {
|
||||
const desc = document.createElement('div');
|
||||
desc.className = 'ext-desc';
|
||||
desc.textContent = ext.description;
|
||||
card.appendChild(desc);
|
||||
}
|
||||
|
||||
if (ext.tools.length > 0) {
|
||||
const tools = document.createElement('div');
|
||||
tools.className = 'ext-tools';
|
||||
tools.textContent = 'Tools: ' + ext.tools.join(', ');
|
||||
card.appendChild(tools);
|
||||
}
|
||||
|
||||
const actions = document.createElement('div');
|
||||
actions.className = 'ext-actions';
|
||||
|
||||
if (!ext.active) {
|
||||
const activateBtn = document.createElement('button');
|
||||
activateBtn.className = 'btn-ext activate';
|
||||
activateBtn.textContent = 'Activate';
|
||||
activateBtn.addEventListener('click', () => activateExtension(ext.name));
|
||||
actions.appendChild(activateBtn);
|
||||
} else {
|
||||
const activeLabel = document.createElement('span');
|
||||
activeLabel.className = 'ext-active-label';
|
||||
activeLabel.textContent = 'Active';
|
||||
actions.appendChild(activeLabel);
|
||||
}
|
||||
|
||||
const removeBtn = document.createElement('button');
|
||||
removeBtn.className = 'btn-ext remove';
|
||||
removeBtn.textContent = 'Remove';
|
||||
removeBtn.addEventListener('click', () => removeExtension(ext.name));
|
||||
actions.appendChild(removeBtn);
|
||||
|
||||
card.appendChild(actions);
|
||||
return card;
|
||||
}
|
||||
|
||||
function activateExtension(name) {
|
||||
apiFetch('/api/extensions/' + encodeURIComponent(name) + '/activate', { method: 'POST' })
|
||||
.then((res) => {
|
||||
if (!res.success) {
|
||||
addMessage('system', 'Activate failed: ' + res.message);
|
||||
}
|
||||
loadExtensions();
|
||||
})
|
||||
.catch((err) => addMessage('system', 'Activate failed: ' + err.message));
|
||||
}
|
||||
|
||||
function removeExtension(name) {
|
||||
apiFetch('/api/extensions/' + encodeURIComponent(name) + '/remove', { method: 'POST' })
|
||||
.then((res) => {
|
||||
if (!res.success) {
|
||||
addMessage('system', 'Remove failed: ' + res.message);
|
||||
}
|
||||
loadExtensions();
|
||||
})
|
||||
.catch((err) => addMessage('system', 'Remove failed: ' + err.message));
|
||||
}
|
||||
|
||||
// --- Jobs ---
|
||||
|
||||
function loadJobs() {
|
||||
Promise.all([
|
||||
apiFetch('/api/jobs/summary'),
|
||||
apiFetch('/api/jobs'),
|
||||
]).then(([summary, jobList]) => {
|
||||
renderJobsSummary(summary);
|
||||
renderJobsList(jobList.jobs);
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
function renderJobsSummary(s) {
|
||||
document.getElementById('jobs-summary').innerHTML = ''
|
||||
+ summaryCard('Total', s.total, '')
|
||||
+ summaryCard('In Progress', s.in_progress, 'active')
|
||||
+ summaryCard('Completed', s.completed, 'completed')
|
||||
+ summaryCard('Failed', s.failed, 'failed')
|
||||
+ summaryCard('Stuck', s.stuck, 'stuck');
|
||||
}
|
||||
|
||||
function summaryCard(label, count, cls) {
|
||||
return '<div class="summary-card ' + cls + '">'
|
||||
+ '<div class="count">' + count + '</div>'
|
||||
+ '<div class="label">' + label + '</div>'
|
||||
+ '</div>';
|
||||
}
|
||||
|
||||
function renderJobsList(jobs) {
|
||||
const tbody = document.getElementById('jobs-tbody');
|
||||
const empty = document.getElementById('jobs-empty');
|
||||
|
||||
if (jobs.length === 0) {
|
||||
tbody.innerHTML = '';
|
||||
empty.style.display = 'block';
|
||||
return;
|
||||
}
|
||||
|
||||
empty.style.display = 'none';
|
||||
tbody.innerHTML = jobs.map((job) => {
|
||||
const shortId = job.id.substring(0, 8);
|
||||
const stateClass = job.state.replace(' ', '_');
|
||||
const cancelBtn = (job.state === 'pending' || job.state === 'in_progress')
|
||||
? '<button class="btn-cancel" onclick="cancelJob(\'' + job.id + '\')">Cancel</button>'
|
||||
: '';
|
||||
return '<tr>'
|
||||
+ '<td title="' + escapeHtml(job.id) + '">' + shortId + '</td>'
|
||||
+ '<td>' + escapeHtml(job.title) + '</td>'
|
||||
+ '<td><span class="badge ' + stateClass + '">' + escapeHtml(job.state) + '</span></td>'
|
||||
+ '<td>' + formatDate(job.created_at) + '</td>'
|
||||
+ '<td>' + cancelBtn + '</td>'
|
||||
+ '</tr>';
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function cancelJob(jobId) {
|
||||
apiFetch('/api/jobs/' + jobId + '/cancel', { method: 'POST' })
|
||||
.then(() => loadJobs())
|
||||
.catch((err) => {
|
||||
addMessage('system', 'Failed to cancel job: ' + err.message);
|
||||
});
|
||||
}
|
||||
|
||||
// --- Utilities ---
|
||||
|
||||
function escapeHtml(str) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = str;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
function formatDate(isoString) {
|
||||
if (!isoString) return '-';
|
||||
const d = new Date(isoString);
|
||||
return d.toLocaleString();
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>IronClaw</title>
|
||||
<link rel="stylesheet" href="/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<!-- Auth Screen -->
|
||||
<div id="auth-screen">
|
||||
<h1>IronClaw</h1>
|
||||
<div class="auth-form">
|
||||
<input type="password" id="token-input" placeholder="Auth token" autofocus>
|
||||
<button onclick="authenticate()">Connect</button>
|
||||
</div>
|
||||
<div id="auth-error"></div>
|
||||
</div>
|
||||
|
||||
<!-- Main App (hidden until authenticated) -->
|
||||
<div id="app">
|
||||
<!-- Tab Bar -->
|
||||
<div class="tab-bar">
|
||||
<button class="active" data-tab="chat">Chat</button>
|
||||
<button data-tab="memory">Memory</button>
|
||||
<button data-tab="jobs">Jobs</button>
|
||||
<button data-tab="logs">Logs</button>
|
||||
<button data-tab="extensions">Extensions</button>
|
||||
<div class="spacer"></div>
|
||||
<div class="status">
|
||||
<div class="dot" id="sse-dot"></div>
|
||||
<span id="sse-status">Connected</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Chat Tab -->
|
||||
<div class="tab-panel active" id="tab-chat">
|
||||
<div class="chat-container">
|
||||
<div class="chat-messages" id="chat-messages"></div>
|
||||
<div class="chat-status" id="chat-status"></div>
|
||||
<div class="approval-banner" id="approval-banner">
|
||||
<div class="approval-info" id="approval-info"></div>
|
||||
<div class="approval-actions">
|
||||
<button class="approve" onclick="sendApproval('yes')">Approve</button>
|
||||
<button class="always" onclick="sendApproval('always')">Always</button>
|
||||
<button class="deny" onclick="sendApproval('no')">Deny</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="chat-input">
|
||||
<textarea id="chat-input" placeholder="Type a message..." rows="1"></textarea>
|
||||
<button id="send-btn" onclick="sendMessage()">Send</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Memory Tab -->
|
||||
<div class="tab-panel" id="tab-memory">
|
||||
<div class="memory-container">
|
||||
<div class="memory-sidebar">
|
||||
<div class="search-box">
|
||||
<input type="text" id="memory-search" placeholder="Search memory...">
|
||||
</div>
|
||||
<div class="memory-tree" id="memory-tree"></div>
|
||||
</div>
|
||||
<div class="memory-content">
|
||||
<div class="memory-breadcrumb" id="memory-breadcrumb">workspace /</div>
|
||||
<div class="memory-viewer" id="memory-viewer">
|
||||
<div class="empty">Select a file to view its contents</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Jobs Tab -->
|
||||
<div class="tab-panel" id="tab-jobs">
|
||||
<div class="jobs-container">
|
||||
<div class="jobs-summary" id="jobs-summary"></div>
|
||||
<table class="jobs-table" id="jobs-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Title</th>
|
||||
<th>Status</th>
|
||||
<th>Created</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="jobs-tbody"></tbody>
|
||||
</table>
|
||||
<div class="empty-state" id="jobs-empty" style="display:none">No jobs found</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Logs Tab -->
|
||||
<div class="tab-panel" id="tab-logs">
|
||||
<div class="logs-container">
|
||||
<div class="logs-toolbar">
|
||||
<select id="logs-level-filter">
|
||||
<option value="all">All Levels</option>
|
||||
<option value="ERROR">Error</option>
|
||||
<option value="WARN">Warn</option>
|
||||
<option value="INFO">Info</option>
|
||||
<option value="DEBUG">Debug</option>
|
||||
</select>
|
||||
<input type="text" id="logs-target-filter" placeholder="Filter by target...">
|
||||
<label class="logs-checkbox"><input type="checkbox" id="logs-autoscroll" checked> Auto-scroll</label>
|
||||
<button id="logs-pause-btn" onclick="toggleLogsPause()">Pause</button>
|
||||
<button onclick="clearLogs()">Clear</button>
|
||||
</div>
|
||||
<div class="logs-output" id="logs-output"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Extensions Tab -->
|
||||
<div class="tab-panel" id="tab-extensions">
|
||||
<div class="extensions-container">
|
||||
<div class="extensions-section">
|
||||
<h3>Installed Extensions</h3>
|
||||
<div class="extensions-list" id="extensions-list">
|
||||
<div class="empty-state">Loading extensions...</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="extensions-section">
|
||||
<h3>Registered Tools</h3>
|
||||
<table class="tools-table" id="tools-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Description</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="tools-tbody"></tbody>
|
||||
</table>
|
||||
<div class="empty-state" id="tools-empty" style="display:none">No tools registered</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,946 @@
|
||||
/* IronClaw Web Gateway */
|
||||
|
||||
:root {
|
||||
--bg: #0d1117;
|
||||
--bg-secondary: #161b22;
|
||||
--bg-tertiary: #21262d;
|
||||
--border: #30363d;
|
||||
--text: #e6edf3;
|
||||
--text-secondary: #8b949e;
|
||||
--accent: #58a6ff;
|
||||
--accent-hover: #79c0ff;
|
||||
--success: #3fb950;
|
||||
--warning: #d29922;
|
||||
--danger: #f85149;
|
||||
--code-bg: #1b2028;
|
||||
--radius: 6px;
|
||||
--shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Auth Screen */
|
||||
#auth-screen {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100vh;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
#auth-screen h1 {
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
#auth-screen .auth-form {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
#auth-screen input {
|
||||
padding: 8px 12px;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
color: var(--text);
|
||||
font-size: 14px;
|
||||
width: 300px;
|
||||
}
|
||||
|
||||
#auth-screen input:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
#auth-screen button {
|
||||
padding: 8px 16px;
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: var(--radius);
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
#auth-screen button:hover {
|
||||
background: var(--accent-hover);
|
||||
}
|
||||
|
||||
#auth-error {
|
||||
color: var(--danger);
|
||||
font-size: 13px;
|
||||
min-height: 20px;
|
||||
}
|
||||
|
||||
/* Main App */
|
||||
#app {
|
||||
display: none;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
/* Tab Bar */
|
||||
.tab-bar {
|
||||
display: flex;
|
||||
background: var(--bg-secondary);
|
||||
border-bottom: 1px solid var(--border);
|
||||
padding: 0 16px;
|
||||
gap: 0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.tab-bar button {
|
||||
padding: 10px 20px;
|
||||
background: none;
|
||||
border: none;
|
||||
border-bottom: 2px solid transparent;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
transition: color 0.15s, border-color 0.15s;
|
||||
}
|
||||
|
||||
.tab-bar button:hover {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.tab-bar button.active {
|
||||
color: var(--accent);
|
||||
border-bottom-color: var(--accent);
|
||||
}
|
||||
|
||||
.tab-bar .spacer {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.tab-bar .status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.tab-bar .status .dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--success);
|
||||
}
|
||||
|
||||
.tab-bar .status .dot.disconnected {
|
||||
background: var(--danger);
|
||||
}
|
||||
|
||||
/* Tab Panels */
|
||||
.tab-panel {
|
||||
display: none;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.tab-panel.active {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* Chat Tab */
|
||||
.chat-container {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.chat-messages {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.message {
|
||||
max-width: 80%;
|
||||
padding: 10px 14px;
|
||||
border-radius: var(--radius);
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
word-wrap: break-word;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.message.user {
|
||||
align-self: flex-end;
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
border-bottom-right-radius: 2px;
|
||||
}
|
||||
|
||||
.message.assistant {
|
||||
align-self: flex-start;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border);
|
||||
border-bottom-left-radius: 2px;
|
||||
}
|
||||
|
||||
.message.system {
|
||||
align-self: center;
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-secondary);
|
||||
font-size: 12px;
|
||||
padding: 6px 12px;
|
||||
}
|
||||
|
||||
.message code {
|
||||
background: var(--code-bg);
|
||||
padding: 1px 4px;
|
||||
border-radius: 3px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.message pre {
|
||||
background: var(--code-bg);
|
||||
padding: 8px 12px;
|
||||
border-radius: var(--radius);
|
||||
overflow-x: auto;
|
||||
margin: 6px 0;
|
||||
}
|
||||
|
||||
.message pre code {
|
||||
background: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* Status bar */
|
||||
.chat-status {
|
||||
padding: 6px 16px;
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
border-top: 1px solid var(--border);
|
||||
background: var(--bg-secondary);
|
||||
min-height: 28px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.chat-status .spinner {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border: 2px solid var(--border);
|
||||
border-top-color: var(--accent);
|
||||
border-radius: 50%;
|
||||
animation: spin 0.6s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
/* Approval banner */
|
||||
.approval-banner {
|
||||
display: none;
|
||||
padding: 12px 16px;
|
||||
background: var(--bg-tertiary);
|
||||
border-top: 1px solid var(--warning);
|
||||
gap: 8px;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.approval-banner.visible {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.approval-banner .approval-info {
|
||||
font-size: 13px;
|
||||
color: var(--warning);
|
||||
}
|
||||
|
||||
.approval-banner .approval-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.approval-banner button {
|
||||
padding: 6px 14px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.approval-banner button.approve {
|
||||
background: var(--success);
|
||||
border-color: var(--success);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.approval-banner button.always {
|
||||
background: var(--accent);
|
||||
border-color: var(--accent);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.approval-banner button.deny {
|
||||
background: var(--danger);
|
||||
border-color: var(--danger);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* Chat input */
|
||||
.chat-input {
|
||||
display: flex;
|
||||
padding: 12px 16px;
|
||||
gap: 8px;
|
||||
background: var(--bg-secondary);
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.chat-input textarea {
|
||||
flex: 1;
|
||||
padding: 8px 12px;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
color: var(--text);
|
||||
font-size: 14px;
|
||||
font-family: inherit;
|
||||
resize: none;
|
||||
min-height: 40px;
|
||||
max-height: 120px;
|
||||
}
|
||||
|
||||
.chat-input textarea:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.chat-input button {
|
||||
padding: 8px 20px;
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: var(--radius);
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
align-self: flex-end;
|
||||
}
|
||||
|
||||
.chat-input button:hover {
|
||||
background: var(--accent-hover);
|
||||
}
|
||||
|
||||
.chat-input button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Memory Tab */
|
||||
.memory-container {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.memory-sidebar {
|
||||
width: 280px;
|
||||
border-right: 1px solid var(--border);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
.memory-sidebar .search-box {
|
||||
padding: 12px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.memory-sidebar input {
|
||||
width: 100%;
|
||||
padding: 6px 10px;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
color: var(--text);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.memory-sidebar input:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.memory-tree {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
/* Tree view */
|
||||
.tree-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 3px 8px;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
gap: 4px;
|
||||
min-height: 26px;
|
||||
}
|
||||
|
||||
.tree-row:hover {
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.expand-arrow {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
font-size: 8px;
|
||||
color: var(--text-secondary);
|
||||
transition: transform 0.15s ease;
|
||||
flex-shrink: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.expand-arrow.expanded {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
.expand-arrow-spacer {
|
||||
display: inline-block;
|
||||
width: 16px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.tree-label {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.tree-label.dir {
|
||||
color: var(--text);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.tree-label.file {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.tree-label.file:hover {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.tree-children {
|
||||
/* Rendered inline, indentation handled by padding-left on tree-row */
|
||||
}
|
||||
|
||||
/* Legacy tree-item (search results) */
|
||||
.tree-item {
|
||||
padding: 4px 12px 4px 16px;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.tree-item:hover {
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.tree-item.active {
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.tree-item .icon {
|
||||
font-size: 12px;
|
||||
width: 16px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.memory-content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.memory-breadcrumb {
|
||||
padding: 8px 16px;
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
.memory-breadcrumb a {
|
||||
color: var(--accent);
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.memory-breadcrumb a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.memory-viewer {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 16px;
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
white-space: pre-wrap;
|
||||
font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace;
|
||||
}
|
||||
|
||||
.memory-viewer .empty {
|
||||
color: var(--text-secondary);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.search-results {
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.search-result {
|
||||
padding: 8px 12px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.search-result:hover {
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
.search-result .path {
|
||||
font-size: 12px;
|
||||
color: var(--accent);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.search-result .snippet {
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Jobs Tab */
|
||||
.jobs-container {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.jobs-summary {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
|
||||
gap: 12px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.summary-card {
|
||||
padding: 16px;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.summary-card .count {
|
||||
font-size: 28px;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.summary-card .label {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
margin-top: 4px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.summary-card.active .count { color: var(--accent); }
|
||||
.summary-card.completed .count { color: var(--success); }
|
||||
.summary-card.failed .count { color: var(--danger); }
|
||||
.summary-card.stuck .count { color: var(--warning); }
|
||||
|
||||
.jobs-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.jobs-table th,
|
||||
.jobs-table td {
|
||||
padding: 10px 12px;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid var(--border);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.jobs-table th {
|
||||
color: var(--text-secondary);
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.jobs-table tr:hover td {
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
border-radius: 10px;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.badge.pending { background: var(--bg-tertiary); color: var(--text-secondary); }
|
||||
.badge.in_progress { background: rgba(88, 166, 255, 0.15); color: var(--accent); }
|
||||
.badge.completed { background: rgba(63, 185, 80, 0.15); color: var(--success); }
|
||||
.badge.failed { background: rgba(248, 81, 73, 0.15); color: var(--danger); }
|
||||
.badge.stuck { background: rgba(210, 153, 34, 0.15); color: var(--warning); }
|
||||
.badge.cancelled { background: var(--bg-tertiary); color: var(--text-secondary); }
|
||||
|
||||
.btn-cancel {
|
||||
padding: 4px 10px;
|
||||
background: none;
|
||||
border: 1px solid var(--danger);
|
||||
border-radius: var(--radius);
|
||||
color: var(--danger);
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.btn-cancel:hover {
|
||||
background: rgba(248, 81, 73, 0.15);
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 40px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* Logs Tab */
|
||||
.logs-container {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.logs-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 16px;
|
||||
background: var(--bg-secondary);
|
||||
border-bottom: 1px solid var(--border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.logs-toolbar select,
|
||||
.logs-toolbar input {
|
||||
padding: 5px 8px;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
color: var(--text);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.logs-toolbar select {
|
||||
min-width: 100px;
|
||||
}
|
||||
|
||||
.logs-toolbar input {
|
||||
flex: 1;
|
||||
max-width: 240px;
|
||||
}
|
||||
|
||||
.logs-toolbar select:focus,
|
||||
.logs-toolbar input:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.logs-checkbox {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.logs-toolbar button {
|
||||
padding: 5px 12px;
|
||||
background: var(--bg-tertiary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.logs-toolbar button:hover {
|
||||
background: var(--border);
|
||||
}
|
||||
|
||||
.logs-output {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 4px 0;
|
||||
font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
.log-entry {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
padding: 1px 12px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.log-entry:hover {
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
.log-ts {
|
||||
color: var(--text-secondary);
|
||||
flex-shrink: 0;
|
||||
width: 80px;
|
||||
}
|
||||
|
||||
.log-level {
|
||||
flex-shrink: 0;
|
||||
width: 44px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.log-target {
|
||||
color: var(--text-secondary);
|
||||
flex-shrink: 0;
|
||||
max-width: 200px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.log-msg {
|
||||
color: var(--text);
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* Log level coloring */
|
||||
.log-entry.level-ERROR .log-level { color: var(--danger); }
|
||||
.log-entry.level-ERROR .log-msg { color: var(--danger); }
|
||||
.log-entry.level-WARN .log-level { color: var(--warning); }
|
||||
.log-entry.level-WARN .log-msg { color: var(--warning); }
|
||||
.log-entry.level-INFO .log-level { color: var(--text); }
|
||||
.log-entry.level-DEBUG .log-level { color: var(--text-secondary); }
|
||||
.log-entry.level-DEBUG .log-msg { color: var(--text-secondary); }
|
||||
|
||||
/* Extensions Tab */
|
||||
.extensions-container {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.extensions-section {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.extensions-section h3 {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 12px;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.extensions-list {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.ext-card {
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 14px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.ext-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.ext-name {
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.ext-kind {
|
||||
font-size: 10px;
|
||||
padding: 2px 6px;
|
||||
border-radius: 8px;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.3px;
|
||||
}
|
||||
|
||||
.ext-kind.kind-mcp_server {
|
||||
background: rgba(88, 166, 255, 0.15);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.ext-kind.kind-wasm_tool {
|
||||
background: rgba(63, 185, 80, 0.15);
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.ext-kind.kind-wasm_channel {
|
||||
background: rgba(210, 153, 34, 0.15);
|
||||
color: var(--warning);
|
||||
}
|
||||
|
||||
.ext-auth-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.ext-auth-dot.authed {
|
||||
background: var(--success);
|
||||
}
|
||||
|
||||
.ext-auth-dot.unauthed {
|
||||
background: var(--danger);
|
||||
}
|
||||
|
||||
.ext-desc {
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.ext-tools {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.ext-actions {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.ext-active-label {
|
||||
font-size: 12px;
|
||||
color: var(--success);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.btn-ext {
|
||||
padding: 4px 10px;
|
||||
border-radius: var(--radius);
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.btn-ext:hover {
|
||||
background: var(--border);
|
||||
}
|
||||
|
||||
.btn-ext.activate {
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.btn-ext.activate:hover {
|
||||
background: rgba(88, 166, 255, 0.15);
|
||||
}
|
||||
|
||||
.btn-ext.remove {
|
||||
border-color: var(--danger);
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.btn-ext.remove:hover {
|
||||
background: rgba(248, 81, 73, 0.15);
|
||||
}
|
||||
|
||||
.tools-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.tools-table th,
|
||||
.tools-table td {
|
||||
padding: 8px 12px;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid var(--border);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.tools-table th {
|
||||
color: var(--text-secondary);
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.tools-table tr:hover td {
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
//! Request and response DTOs for the web gateway API.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
// --- Chat ---
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct SendMessageRequest {
|
||||
pub content: String,
|
||||
pub thread_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct SendMessageResponse {
|
||||
pub message_id: Uuid,
|
||||
pub status: &'static str,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct ThreadInfo {
|
||||
pub id: Uuid,
|
||||
pub state: String,
|
||||
pub turn_count: usize,
|
||||
pub created_at: String,
|
||||
pub updated_at: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct ThreadListResponse {
|
||||
pub threads: Vec<ThreadInfo>,
|
||||
pub active_thread: Option<Uuid>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct TurnInfo {
|
||||
pub turn_number: usize,
|
||||
pub user_input: String,
|
||||
pub response: Option<String>,
|
||||
pub state: String,
|
||||
pub started_at: String,
|
||||
pub completed_at: Option<String>,
|
||||
pub tool_calls: Vec<ToolCallInfo>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct ToolCallInfo {
|
||||
pub name: String,
|
||||
pub has_result: bool,
|
||||
pub has_error: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct HistoryResponse {
|
||||
pub thread_id: Uuid,
|
||||
pub turns: Vec<TurnInfo>,
|
||||
}
|
||||
|
||||
// --- SSE Event Types ---
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(tag = "type")]
|
||||
pub enum SseEvent {
|
||||
#[serde(rename = "response")]
|
||||
Response { content: String, thread_id: String },
|
||||
#[serde(rename = "thinking")]
|
||||
Thinking { message: String },
|
||||
#[serde(rename = "tool_started")]
|
||||
ToolStarted { name: String },
|
||||
#[serde(rename = "tool_completed")]
|
||||
ToolCompleted { name: String, success: bool },
|
||||
#[serde(rename = "stream_chunk")]
|
||||
StreamChunk { content: String },
|
||||
#[serde(rename = "status")]
|
||||
Status { message: String },
|
||||
#[serde(rename = "approval_needed")]
|
||||
ApprovalNeeded {
|
||||
request_id: String,
|
||||
tool_name: String,
|
||||
description: String,
|
||||
parameters: String,
|
||||
},
|
||||
#[serde(rename = "error")]
|
||||
Error { message: String },
|
||||
#[serde(rename = "heartbeat")]
|
||||
Heartbeat,
|
||||
}
|
||||
|
||||
// --- Memory ---
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct MemoryTreeResponse {
|
||||
pub entries: Vec<TreeEntry>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct TreeEntry {
|
||||
pub path: String,
|
||||
pub is_dir: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct MemoryListResponse {
|
||||
pub path: String,
|
||||
pub entries: Vec<ListEntry>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct ListEntry {
|
||||
pub name: String,
|
||||
pub path: String,
|
||||
pub is_dir: bool,
|
||||
pub updated_at: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct MemoryReadResponse {
|
||||
pub path: String,
|
||||
pub content: String,
|
||||
pub updated_at: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct MemoryWriteRequest {
|
||||
pub path: String,
|
||||
pub content: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct MemoryWriteResponse {
|
||||
pub path: String,
|
||||
pub status: &'static str,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct MemorySearchRequest {
|
||||
pub query: String,
|
||||
pub limit: Option<usize>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct MemorySearchResponse {
|
||||
pub results: Vec<SearchHit>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct SearchHit {
|
||||
pub path: String,
|
||||
pub content: String,
|
||||
pub score: f64,
|
||||
}
|
||||
|
||||
// --- Jobs ---
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct JobInfo {
|
||||
pub id: Uuid,
|
||||
pub title: String,
|
||||
pub state: String,
|
||||
pub user_id: String,
|
||||
pub created_at: String,
|
||||
pub started_at: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct JobListResponse {
|
||||
pub jobs: Vec<JobInfo>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct JobSummaryResponse {
|
||||
pub total: usize,
|
||||
pub pending: usize,
|
||||
pub in_progress: usize,
|
||||
pub completed: usize,
|
||||
pub failed: usize,
|
||||
pub stuck: usize,
|
||||
}
|
||||
|
||||
// --- Extensions ---
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct ExtensionInfo {
|
||||
pub name: String,
|
||||
pub kind: String,
|
||||
pub description: Option<String>,
|
||||
pub authenticated: bool,
|
||||
pub active: bool,
|
||||
pub tools: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct ExtensionListResponse {
|
||||
pub extensions: Vec<ExtensionInfo>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct ToolInfo {
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct ToolListResponse {
|
||||
pub tools: Vec<ToolInfo>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct InstallExtensionRequest {
|
||||
pub name: String,
|
||||
pub url: Option<String>,
|
||||
pub kind: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct ActionResponse {
|
||||
pub success: bool,
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
// --- Health ---
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct HealthResponse {
|
||||
pub status: &'static str,
|
||||
pub channel: &'static str,
|
||||
}
|
||||
@@ -333,6 +333,7 @@ fn default_session_path() -> PathBuf {
|
||||
pub struct ChannelsConfig {
|
||||
pub cli: CliConfig,
|
||||
pub http: Option<HttpConfig>,
|
||||
pub gateway: Option<GatewayConfig>,
|
||||
/// Directory containing WASM channel modules (default: ~/.ironclaw/channels/).
|
||||
pub wasm_channels_dir: std::path::PathBuf,
|
||||
/// Whether WASM channels are enabled.
|
||||
@@ -352,6 +353,16 @@ pub struct HttpConfig {
|
||||
pub user_id: String,
|
||||
}
|
||||
|
||||
/// Web gateway configuration.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct GatewayConfig {
|
||||
pub host: String,
|
||||
pub port: u16,
|
||||
/// Bearer token for authentication. Random hex generated at startup if unset.
|
||||
pub auth_token: Option<String>,
|
||||
pub user_id: String,
|
||||
}
|
||||
|
||||
impl ChannelsConfig {
|
||||
fn from_env() -> Result<Self, ConfigError> {
|
||||
let http = if optional_env("HTTP_PORT")?.is_some() || optional_env("HTTP_HOST")?.is_some() {
|
||||
@@ -372,6 +383,27 @@ impl ChannelsConfig {
|
||||
None
|
||||
};
|
||||
|
||||
let gateway = if optional_env("GATEWAY_ENABLED")?
|
||||
.map(|s| s.to_lowercase() == "true" || s == "1")
|
||||
.unwrap_or(false)
|
||||
{
|
||||
Some(GatewayConfig {
|
||||
host: optional_env("GATEWAY_HOST")?.unwrap_or_else(|| "127.0.0.1".to_string()),
|
||||
port: optional_env("GATEWAY_PORT")?
|
||||
.map(|s| s.parse())
|
||||
.transpose()
|
||||
.map_err(|e| ConfigError::InvalidValue {
|
||||
key: "GATEWAY_PORT".to_string(),
|
||||
message: format!("must be a valid port number: {e}"),
|
||||
})?
|
||||
.unwrap_or(3000),
|
||||
auth_token: optional_env("GATEWAY_AUTH_TOKEN")?,
|
||||
user_id: optional_env("GATEWAY_USER_ID")?.unwrap_or_else(|| "default".to_string()),
|
||||
})
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let cli_enabled = optional_env("CLI_ENABLED")?
|
||||
.map(|s| s.to_lowercase() != "false" && s != "0")
|
||||
.unwrap_or(true);
|
||||
@@ -381,6 +413,7 @@ impl ChannelsConfig {
|
||||
enabled: cli_enabled,
|
||||
},
|
||||
http,
|
||||
gateway,
|
||||
wasm_channels_dir: optional_env("WASM_CHANNELS_DIR")?
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(default_channels_dir),
|
||||
|
||||
+37
-2
@@ -6,13 +6,14 @@ use clap::Parser;
|
||||
use tracing_subscriber::{EnvFilter, layer::SubscriberExt, util::SubscriberInitExt};
|
||||
|
||||
use ironclaw::{
|
||||
agent::{Agent, AgentDeps},
|
||||
agent::{Agent, AgentDeps, SessionManager},
|
||||
channels::{
|
||||
AppEvent, ChannelManager, HttpChannel, ReplChannel, TuiChannel,
|
||||
AppEvent, ChannelManager, GatewayChannel, HttpChannel, ReplChannel, TuiChannel,
|
||||
wasm::{
|
||||
RegisteredEndpoint, SharedWasmChannel, WasmChannelLoader, WasmChannelRouter,
|
||||
WasmChannelRuntime, WasmChannelRuntimeConfig, WasmChannelServer,
|
||||
},
|
||||
web::log_layer::{LogBroadcaster, WebLogLayer},
|
||||
},
|
||||
cli::{
|
||||
Cli, Command, run_mcp_command, run_memory_command, run_status_command, run_tool_command,
|
||||
@@ -193,6 +194,10 @@ async fn main() -> anyhow::Result<()> {
|
||||
let env_filter = EnvFilter::try_from_default_env()
|
||||
.unwrap_or_else(|_| EnvFilter::new("ironclaw=info,tower_http=debug"));
|
||||
|
||||
// Create log broadcaster before tracing init so the WebLogLayer can capture all events.
|
||||
// This gets wired to the gateway's /api/logs/events SSE endpoint later.
|
||||
let log_broadcaster = Arc::new(LogBroadcaster::new());
|
||||
|
||||
// Determine which mode to use: REPL, single message, or TUI
|
||||
let use_repl = cli.repl || cli.message.is_some();
|
||||
|
||||
@@ -202,6 +207,7 @@ async fn main() -> anyhow::Result<()> {
|
||||
tracing_subscriber::registry()
|
||||
.with(env_filter)
|
||||
.with(tracing_subscriber::fmt::layer().with_target(false))
|
||||
.with(WebLogLayer::new(Arc::clone(&log_broadcaster)))
|
||||
.init();
|
||||
|
||||
let repl = if let Some(ref msg) = cli.message {
|
||||
@@ -226,6 +232,7 @@ async fn main() -> anyhow::Result<()> {
|
||||
.with_target(false)
|
||||
.with_level(true),
|
||||
)
|
||||
.with(WebLogLayer::new(Arc::clone(&log_broadcaster)))
|
||||
.init();
|
||||
|
||||
(Some(channel), Some(event_sender), None)
|
||||
@@ -234,6 +241,7 @@ async fn main() -> anyhow::Result<()> {
|
||||
tracing_subscriber::registry()
|
||||
.with(env_filter)
|
||||
.with(tracing_subscriber::fmt::layer().with_target(false))
|
||||
.with(WebLogLayer::new(Arc::clone(&log_broadcaster)))
|
||||
.init();
|
||||
|
||||
(None, None, None)
|
||||
@@ -752,9 +760,35 @@ async fn main() -> anyhow::Result<()> {
|
||||
// Create context manager (shared between job tools and agent)
|
||||
let context_manager = Arc::new(ContextManager::new(config.agent.max_parallel_jobs));
|
||||
|
||||
// Create session manager (shared between agent and web gateway)
|
||||
let session_manager = Arc::new(SessionManager::new());
|
||||
|
||||
// Register job tools
|
||||
tools.register_job_tools(Arc::clone(&context_manager));
|
||||
|
||||
// Add web gateway channel if configured
|
||||
if let Some(ref gw_config) = config.channels.gateway {
|
||||
let mut gw = GatewayChannel::new(gw_config.clone());
|
||||
if let Some(ref ws) = workspace {
|
||||
gw = gw.with_workspace(Arc::clone(ws));
|
||||
}
|
||||
gw = gw.with_context_manager(Arc::clone(&context_manager));
|
||||
gw = gw.with_session_manager(Arc::clone(&session_manager));
|
||||
gw = gw.with_log_broadcaster(Arc::clone(&log_broadcaster));
|
||||
gw = gw.with_tool_registry(Arc::clone(&tools));
|
||||
if let Some(ref ext_mgr) = extension_manager {
|
||||
gw = gw.with_extension_manager(Arc::clone(ext_mgr));
|
||||
}
|
||||
|
||||
tracing::info!(
|
||||
"Web gateway enabled on {}:{}",
|
||||
gw_config.host,
|
||||
gw_config.port
|
||||
);
|
||||
|
||||
channels.add(Box::new(gw));
|
||||
}
|
||||
|
||||
// Create and run the agent
|
||||
let deps = AgentDeps {
|
||||
store,
|
||||
@@ -769,6 +803,7 @@ async fn main() -> anyhow::Result<()> {
|
||||
channels,
|
||||
Some(config.heartbeat.clone()),
|
||||
Some(context_manager),
|
||||
Some(session_manager),
|
||||
);
|
||||
|
||||
tracing::info!("Agent initialized, starting main loop...");
|
||||
|
||||
Reference in New Issue
Block a user