Adding web UI

This commit is contained in:
Illia Polosukhin
2026-02-06 12:11:12 -08:00
parent 8be390afab
commit a351711312
15 changed files with 3572 additions and 6 deletions
Generated
+1
View File
@@ -4217,6 +4217,7 @@ dependencies = [
"futures-core",
"pin-project-lite",
"tokio",
"tokio-util",
]
[[package]]
+1 -1
View File
@@ -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
+6 -3
View File
@@ -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,
}
+2
View File
@@ -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;
+63
View File
@@ -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");
}
}
+264
View File
@@ -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");
}
}
+243
View File
@@ -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(())
}
}
+753
View File
@@ -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(),
})),
}
}
+147
View File
@@ -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"),
}
}
}
+707
View File
@@ -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();
}
+142
View File
@@ -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>
+946
View File
@@ -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);
}
+227
View File
@@ -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,
}
+33
View File
@@ -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
View File
@@ -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...");