diff --git a/Cargo.lock b/Cargo.lock index 655ae87f..4bc0af99 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4217,6 +4217,7 @@ dependencies = [ "futures-core", "pin-project-lite", "tokio", + "tokio-util", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 45524ee3..e78724d8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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 diff --git a/src/agent/agent_loop.rs b/src/agent/agent_loop.rs index 639e66f9..a56960ba 100644 --- a/src/agent/agent_loop.rs +++ b/src/agent/agent_loop.rs @@ -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, context_manager: Option>, + session_manager: Option>, ) -> 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, } diff --git a/src/channels/mod.rs b/src/channels/mod.rs index 550707d1..18f471fa 100644 --- a/src/channels/mod.rs +++ b/src/channels/mod.rs @@ -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; diff --git a/src/channels/web/auth.rs b/src/channels/web/auth.rs new file mode 100644 index 00000000..219528eb --- /dev/null +++ b/src/channels/web/auth.rs @@ -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, + 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"); + } +} diff --git a/src/channels/web/log_layer.rs b/src/channels/web/log_layer.rs new file mode 100644 index 00000000..f4f6b227 --- /dev/null +++ b/src/channels/web/log_layer.rs @@ -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 (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, + recent: Mutex>, +} + +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 { + self.tx.subscribe() + } + + /// Snapshot of recent entries for replaying to a new subscriber. + pub fn recent_entries(&self) -> Vec { + 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, +} + +impl WebLogLayer { + pub fn new(broadcaster: Arc) -> Self { + Self { broadcaster } + } +} + +impl Layer 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"); + } +} diff --git a/src/channels/web/mod.rs b/src/channels/web/mod.rs new file mode 100644 index 00000000..33cdbb0d --- /dev/null +++ b/src/channels/web/mod.rs @@ -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, + /// 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) -> 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) -> 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) -> 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) -> 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) -> 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) -> 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 { + &self.state + } +} + +#[async_trait] +impl Channel for GatewayChannel { + fn name(&self) -> &str { + "gateway" + } + + async fn start(&self) -> Result { + 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(()) + } +} diff --git a/src/channels/web/server.rs b/src/channels/web/server.rs new file mode 100644 index 00000000..4020b9a4 --- /dev/null +++ b/src/channels/web/server.rs @@ -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>>, + /// SSE broadcast manager. + pub sse: SseManager, + /// Workspace for memory API. + pub workspace: Option>, + /// Context manager for jobs API. + pub context_manager: Option>, + /// Session manager for thread info. + pub session_manager: Option>, + /// Log broadcaster for the logs SSE endpoint. + pub log_broadcaster: Option>, + /// Extension manager for extension management API. + pub extension_manager: Option>, + /// Tool registry for listing registered tools. + pub tool_registry: Option>, + /// User ID for this gateway. + pub user_id: String, + /// Shutdown signal sender. + pub shutdown_tx: tokio::sync::RwLock>>, +} + +/// Start the gateway HTTP server. +pub async fn start_server( + addr: SocketAddr, + state: Arc, + 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 { + Json(HealthResponse { + status: "healthy", + channel: "gateway", + }) +} + +// --- Chat handlers --- + +async fn chat_send_handler( + State(state): State>, + Json(req): Json, +) -> Result<(StatusCode, Json), (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>) -> impl IntoResponse { + // subscribe() returns Sse> so no lifetime issues + state.sse.subscribe() +} + +#[derive(Deserialize)] +struct HistoryQuery { + thread_id: Option, +} + +async fn chat_history_handler( + State(state): State>, + Query(query): Query, +) -> Result, (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 = 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>, +) -> Result, (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 = 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>, +) -> Result, (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, +} + +async fn memory_tree_handler( + State(state): State>, + Query(_query): Query, +) -> Result, (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 = Vec::new(); + let mut seen_dirs: std::collections::HashSet = 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, +} + +async fn memory_list_handler( + State(state): State>, + Query(query): Query, +) -> Result, (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 = 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>, + Query(query): Query, +) -> Result, (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>, + Json(req): Json, +) -> Result, (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>, + Json(req): Json, +) -> Result, (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 = 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>, +) -> Result, (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>, +) -> Result, (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>, + Path(id): Path, +) -> Result, (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>, + Path(id): Path, +) -> Result, (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>, +) -> Result< + Sse> + 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>, +) -> Result, (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>, +) -> Result, (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>, + Json(req): Json, +) -> Result, (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>, + Path(name): Path, +) -> Result, (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>, + Path(name): Path, +) -> Result, (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(), + })), + } +} diff --git a/src/channels/web/sse.rs b/src/channels/web/sse.rs new file mode 100644 index 00000000..e15446e1 --- /dev/null +++ b/src/channels/web/sse.rs @@ -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, + connection_count: Arc, +} + +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> + 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 { + inner: S, + counter: Arc, +} + +impl Stream for CountedStream { + type Item = S::Item; + + fn poll_next( + mut self: std::pin::Pin<&mut Self>, + cx: &mut std::task::Context<'_>, + ) -> std::task::Poll> { + std::pin::Pin::new(&mut self.inner).poll_next(cx) + } +} + +impl Drop for CountedStream { + 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"), + } + } +} diff --git a/src/channels/web/static/app.js b/src/channels/web/static/app.js new file mode 100644 index 00000000..78431c35 --- /dev/null +++ b/src/channels/web/static/app.js @@ -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 ? '
' : '') + 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 = '
No files in workspace
'; + 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 = '
Error: ' + escapeHtml(err.message) + '
'; + }); +} + +function buildBreadcrumb(path) { + const parts = path.split('/'); + let html = 'workspace'; + let current = ''; + for (const part of parts) { + current += (current ? '/' : '') + part; + html += ' / ' + escapeHtml(part) + ''; + } + 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 = '
No results
'; + return; + } + for (const result of data.results) { + const item = document.createElement('div'); + item.className = 'search-result'; + item.innerHTML = '
' + escapeHtml(result.path) + '
' + + '
' + escapeHtml(result.content.substring(0, 120)) + '
'; + 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 = '
No extensions installed
'; + } 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) => + '' + escapeHtml(t.name) + '' + escapeHtml(t.description) + '' + ).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 '
' + + '
' + count + '
' + + '
' + label + '
' + + '
'; +} + +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') + ? '' + : ''; + return '' + + '' + shortId + '' + + '' + escapeHtml(job.title) + '' + + '' + escapeHtml(job.state) + '' + + '' + formatDate(job.created_at) + '' + + '' + cancelBtn + '' + + ''; + }).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(); +} diff --git a/src/channels/web/static/index.html b/src/channels/web/static/index.html new file mode 100644 index 00000000..9d68e2a4 --- /dev/null +++ b/src/channels/web/static/index.html @@ -0,0 +1,142 @@ + + + + + + IronClaw + + + + +
+

IronClaw

+
+ + +
+
+
+ + +
+ +
+ + + + + +
+
+
+ Connected +
+
+ + +
+
+
+
+
+
+
+ + + +
+
+
+ + +
+
+
+ + +
+
+
+ +
+
+
+
workspace /
+
+
Select a file to view its contents
+
+
+
+
+ + +
+
+
+ + + + + + + + + + + +
IDTitleStatusCreatedActions
+ +
+
+ + +
+
+
+ + + + + +
+
+
+
+ + +
+
+
+

Installed Extensions

+
+
Loading extensions...
+
+
+
+

Registered Tools

+ + + + + + + + +
NameDescription
+ +
+
+
+
+ + + + diff --git a/src/channels/web/static/style.css b/src/channels/web/static/style.css new file mode 100644 index 00000000..3d276707 --- /dev/null +++ b/src/channels/web/static/style.css @@ -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); +} diff --git a/src/channels/web/types.rs b/src/channels/web/types.rs new file mode 100644 index 00000000..e86d4d9c --- /dev/null +++ b/src/channels/web/types.rs @@ -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, +} + +#[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, + pub active_thread: Option, +} + +#[derive(Debug, Serialize)] +pub struct TurnInfo { + pub turn_number: usize, + pub user_input: String, + pub response: Option, + pub state: String, + pub started_at: String, + pub completed_at: Option, + pub tool_calls: Vec, +} + +#[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, +} + +// --- 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, +} + +#[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, +} + +#[derive(Debug, Serialize)] +pub struct ListEntry { + pub name: String, + pub path: String, + pub is_dir: bool, + pub updated_at: Option, +} + +#[derive(Debug, Serialize)] +pub struct MemoryReadResponse { + pub path: String, + pub content: String, + pub updated_at: Option, +} + +#[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, +} + +#[derive(Debug, Serialize)] +pub struct MemorySearchResponse { + pub results: Vec, +} + +#[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, +} + +#[derive(Debug, Serialize)] +pub struct JobListResponse { + pub jobs: Vec, +} + +#[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, + pub authenticated: bool, + pub active: bool, + pub tools: Vec, +} + +#[derive(Debug, Serialize)] +pub struct ExtensionListResponse { + pub extensions: Vec, +} + +#[derive(Debug, Serialize)] +pub struct ToolInfo { + pub name: String, + pub description: String, +} + +#[derive(Debug, Serialize)] +pub struct ToolListResponse { + pub tools: Vec, +} + +#[derive(Debug, Deserialize)] +pub struct InstallExtensionRequest { + pub name: String, + pub url: Option, + pub kind: Option, +} + +#[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, +} diff --git a/src/config.rs b/src/config.rs index a317d949..caa1d386 100644 --- a/src/config.rs +++ b/src/config.rs @@ -333,6 +333,7 @@ fn default_session_path() -> PathBuf { pub struct ChannelsConfig { pub cli: CliConfig, pub http: Option, + pub gateway: Option, /// 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, + pub user_id: String, +} + impl ChannelsConfig { fn from_env() -> Result { 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), diff --git a/src/main.rs b/src/main.rs index 569c06e2..fec51b31 100644 --- a/src/main.rs +++ b/src/main.rs @@ -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...");