mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-25 14:53:34 +00:00
* feat(web): add follow-up suggestion chips and ghost text to chat UI The LLM now always generates 1-3 follow-up command suggestions via <suggestions> tags in its response. These are extracted server-side, broadcast as SSE events, and rendered as clickable chips above the chat input. The first suggestion also appears as ghost text in the input field (Tab to accept). Includes debug logging for LLM responses in the agentic loop and removes noisy NEAR AI status logging. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * fix: resolve deferred review items from PR #1156 [skip-regression-check] - Remove literal backslashes from raw string prompt (reasoning.rs) - Make WASM channels skip Suggestions status (no-op instead of empty callback) - Add !e.shiftKey guard to Tab-to-accept ghost text handler - Cap extracted suggestions at 3 and trim whitespace-only entries - Extract suggestions in approval-resume path (prevents tag leaking) - Remove stale .has-ghost class during showSuggestionChips reset Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> --------- Co-authored-by: Claude Opus 4.6 (1M context) <[email protected]>
451 lines
15 KiB
Rust
451 lines
15 KiB
Rust
//! 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/chat/ws ─────► WebSocket (bidirectional)
|
|
//! ─── GET /api/memory/* ────► Workspace
|
|
//! ─── GET /api/jobs/* ──────► Database
|
|
//! ◄── GET / ───────────────── Static HTML/CSS/JS
|
|
//! ```
|
|
|
|
pub mod auth;
|
|
pub(crate) mod handlers;
|
|
pub mod log_layer;
|
|
pub mod openai_compat;
|
|
pub mod server;
|
|
pub mod sse;
|
|
pub mod types;
|
|
pub(crate) mod util;
|
|
pub mod ws;
|
|
|
|
/// Test helpers for gateway integration tests.
|
|
///
|
|
/// Always compiled (not behind `#[cfg(test)]`) so that integration tests in
|
|
/// `tests/` -- which import this crate as a regular dependency -- can use
|
|
/// [`TestGatewayBuilder`](test_helpers::TestGatewayBuilder).
|
|
pub mod test_helpers;
|
|
|
|
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::db::Database;
|
|
use crate::error::ChannelError;
|
|
use crate::extensions::ExtensionManager;
|
|
use crate::orchestrator::job_manager::ContainerJobManager;
|
|
use crate::skills::catalog::SkillCatalog;
|
|
use crate::skills::registry::SkillRegistry;
|
|
use crate::tools::ToolRegistry;
|
|
use crate::workspace::Workspace;
|
|
|
|
use self::log_layer::{LogBroadcaster, LogLevelHandle};
|
|
|
|
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::RngCore;
|
|
use rand::rngs::OsRng;
|
|
let mut bytes = [0u8; 32];
|
|
OsRng.fill_bytes(&mut bytes);
|
|
bytes.iter().map(|b| format!("{b:02x}")).collect()
|
|
});
|
|
|
|
let state = Arc::new(GatewayState {
|
|
msg_tx: tokio::sync::RwLock::new(None),
|
|
sse: SseManager::new(),
|
|
workspace: None,
|
|
session_manager: None,
|
|
log_broadcaster: None,
|
|
log_level_handle: None,
|
|
extension_manager: None,
|
|
tool_registry: None,
|
|
store: None,
|
|
job_manager: None,
|
|
prompt_queue: None,
|
|
scheduler: None,
|
|
user_id: config.user_id.clone(),
|
|
shutdown_tx: tokio::sync::RwLock::new(None),
|
|
ws_tracker: Some(Arc::new(ws::WsConnectionTracker::new())),
|
|
llm_provider: None,
|
|
skill_registry: None,
|
|
skill_catalog: None,
|
|
chat_rate_limiter: server::RateLimiter::new(30, 60),
|
|
oauth_rate_limiter: server::RateLimiter::new(10, 60),
|
|
registry_entries: Vec::new(),
|
|
cost_guard: None,
|
|
routine_engine: Arc::new(tokio::sync::RwLock::new(None)),
|
|
startup_time: std::time::Instant::now(),
|
|
});
|
|
|
|
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),
|
|
// Preserve the existing broadcast channel so sender handles remain valid.
|
|
sse: SseManager::from_sender(self.state.sse.sender()),
|
|
workspace: self.state.workspace.clone(),
|
|
session_manager: self.state.session_manager.clone(),
|
|
log_broadcaster: self.state.log_broadcaster.clone(),
|
|
log_level_handle: self.state.log_level_handle.clone(),
|
|
extension_manager: self.state.extension_manager.clone(),
|
|
tool_registry: self.state.tool_registry.clone(),
|
|
store: self.state.store.clone(),
|
|
job_manager: self.state.job_manager.clone(),
|
|
prompt_queue: self.state.prompt_queue.clone(),
|
|
scheduler: self.state.scheduler.clone(),
|
|
user_id: self.state.user_id.clone(),
|
|
shutdown_tx: tokio::sync::RwLock::new(None),
|
|
ws_tracker: self.state.ws_tracker.clone(),
|
|
llm_provider: self.state.llm_provider.clone(),
|
|
skill_registry: self.state.skill_registry.clone(),
|
|
skill_catalog: self.state.skill_catalog.clone(),
|
|
chat_rate_limiter: server::RateLimiter::new(30, 60),
|
|
oauth_rate_limiter: server::RateLimiter::new(10, 60),
|
|
registry_entries: self.state.registry_entries.clone(),
|
|
cost_guard: self.state.cost_guard.clone(),
|
|
routine_engine: Arc::clone(&self.state.routine_engine),
|
|
startup_time: self.state.startup_time,
|
|
};
|
|
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 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 log level handle for runtime log level control.
|
|
pub fn with_log_level_handle(mut self, h: Arc<LogLevelHandle>) -> Self {
|
|
self.rebuild_state(|s| s.log_level_handle = Some(h));
|
|
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
|
|
}
|
|
|
|
/// Inject the database store for sandbox job persistence.
|
|
pub fn with_store(mut self, store: Arc<dyn Database>) -> Self {
|
|
self.rebuild_state(|s| s.store = Some(store));
|
|
self
|
|
}
|
|
|
|
/// Inject the container job manager for sandbox operations.
|
|
pub fn with_job_manager(mut self, jm: Arc<ContainerJobManager>) -> Self {
|
|
self.rebuild_state(|s| s.job_manager = Some(jm));
|
|
self
|
|
}
|
|
|
|
/// Inject the prompt queue for Claude Code follow-up prompts.
|
|
pub fn with_prompt_queue(
|
|
mut self,
|
|
pq: Arc<
|
|
tokio::sync::Mutex<
|
|
std::collections::HashMap<
|
|
uuid::Uuid,
|
|
std::collections::VecDeque<crate::orchestrator::api::PendingPrompt>,
|
|
>,
|
|
>,
|
|
>,
|
|
) -> Self {
|
|
self.rebuild_state(|s| s.prompt_queue = Some(pq));
|
|
self
|
|
}
|
|
|
|
/// Inject the scheduler for sending follow-up messages to agent jobs.
|
|
pub fn with_scheduler(mut self, slot: crate::tools::builtin::SchedulerSlot) -> Self {
|
|
self.rebuild_state(|s| s.scheduler = Some(slot));
|
|
self
|
|
}
|
|
|
|
/// Inject the skill registry for skill management API.
|
|
pub fn with_skill_registry(mut self, sr: Arc<std::sync::RwLock<SkillRegistry>>) -> Self {
|
|
self.rebuild_state(|s| s.skill_registry = Some(sr));
|
|
self
|
|
}
|
|
|
|
/// Inject the skill catalog for skill search API.
|
|
pub fn with_skill_catalog(mut self, sc: Arc<SkillCatalog>) -> Self {
|
|
self.rebuild_state(|s| s.skill_catalog = Some(sc));
|
|
self
|
|
}
|
|
|
|
/// Inject the LLM provider for OpenAI-compatible API proxy.
|
|
pub fn with_llm_provider(mut self, llm: Arc<dyn crate::llm::LlmProvider>) -> Self {
|
|
self.rebuild_state(|s| s.llm_provider = Some(llm));
|
|
self
|
|
}
|
|
|
|
/// Inject registry catalog entries for the available extensions API.
|
|
pub fn with_registry_entries(mut self, entries: Vec<crate::extensions::RegistryEntry>) -> Self {
|
|
self.rebuild_state(|s| s.registry_entries = entries);
|
|
self
|
|
}
|
|
|
|
/// Inject the cost guard for token/cost tracking in the status popover.
|
|
pub fn with_cost_guard(mut self, cg: Arc<crate::agent::cost_guard::CostGuard>) -> Self {
|
|
self.rebuild_state(|s| s.cost_guard = Some(cg));
|
|
self
|
|
}
|
|
|
|
/// Inject a shared routine engine slot used by other HTTP ingress paths.
|
|
pub fn with_routine_engine_slot(mut self, slot: server::RoutineEngineSlot) -> Self {
|
|
self.rebuild_state(|s| s.routine_engine = slot);
|
|
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?;
|
|
|
|
Ok(Box::pin(ReceiverStream::new(rx)))
|
|
}
|
|
|
|
async fn respond(
|
|
&self,
|
|
msg: &IncomingMessage,
|
|
response: OutgoingResponse,
|
|
) -> Result<(), ChannelError> {
|
|
let thread_id = match &msg.thread_id {
|
|
Some(tid) => tid.clone(),
|
|
None => {
|
|
tracing::warn!(
|
|
"Gateway respond with no thread_id — skipping (clients would drop it)"
|
|
);
|
|
return Ok(());
|
|
}
|
|
};
|
|
|
|
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 thread_id = metadata
|
|
.get("thread_id")
|
|
.and_then(|v| v.as_str())
|
|
.map(String::from);
|
|
let event = match status {
|
|
StatusUpdate::Thinking(msg) => SseEvent::Thinking {
|
|
message: msg,
|
|
thread_id: thread_id.clone(),
|
|
},
|
|
StatusUpdate::ToolStarted { name } => SseEvent::ToolStarted {
|
|
name,
|
|
thread_id: thread_id.clone(),
|
|
},
|
|
StatusUpdate::ToolCompleted {
|
|
name,
|
|
success,
|
|
error,
|
|
parameters,
|
|
} => SseEvent::ToolCompleted {
|
|
name,
|
|
success,
|
|
error,
|
|
parameters,
|
|
thread_id: thread_id.clone(),
|
|
},
|
|
StatusUpdate::ToolResult { name, preview } => SseEvent::ToolResult {
|
|
name,
|
|
preview,
|
|
thread_id: thread_id.clone(),
|
|
},
|
|
StatusUpdate::StreamChunk(content) => SseEvent::StreamChunk {
|
|
content,
|
|
thread_id: thread_id.clone(),
|
|
},
|
|
StatusUpdate::Status(msg) => SseEvent::Status {
|
|
message: msg,
|
|
thread_id: thread_id.clone(),
|
|
},
|
|
StatusUpdate::JobStarted {
|
|
job_id,
|
|
title,
|
|
browse_url,
|
|
} => SseEvent::JobStarted {
|
|
job_id,
|
|
title,
|
|
browse_url,
|
|
},
|
|
StatusUpdate::ApprovalNeeded {
|
|
request_id,
|
|
tool_name,
|
|
description,
|
|
parameters,
|
|
} => SseEvent::ApprovalNeeded {
|
|
request_id,
|
|
tool_name,
|
|
description,
|
|
parameters: serde_json::to_string_pretty(¶meters)
|
|
.unwrap_or_else(|_| parameters.to_string()),
|
|
thread_id,
|
|
},
|
|
StatusUpdate::AuthRequired {
|
|
extension_name,
|
|
instructions,
|
|
auth_url,
|
|
setup_url,
|
|
} => SseEvent::AuthRequired {
|
|
extension_name,
|
|
instructions,
|
|
auth_url,
|
|
setup_url,
|
|
},
|
|
StatusUpdate::AuthCompleted {
|
|
extension_name,
|
|
success,
|
|
message,
|
|
} => SseEvent::AuthCompleted {
|
|
extension_name,
|
|
success,
|
|
message,
|
|
},
|
|
StatusUpdate::ImageGenerated { data_url, path } => SseEvent::ImageGenerated {
|
|
data_url,
|
|
path,
|
|
thread_id: thread_id.clone(),
|
|
},
|
|
StatusUpdate::Suggestions { suggestions } => SseEvent::Suggestions {
|
|
suggestions,
|
|
thread_id,
|
|
},
|
|
};
|
|
|
|
self.state.sse.broadcast(event);
|
|
Ok(())
|
|
}
|
|
|
|
async fn broadcast(
|
|
&self,
|
|
_user_id: &str,
|
|
response: OutgoingResponse,
|
|
) -> Result<(), ChannelError> {
|
|
let thread_id = match response.thread_id {
|
|
Some(tid) => tid,
|
|
None => {
|
|
tracing::warn!(
|
|
"Gateway broadcast with no thread_id — skipping (clients would drop it)"
|
|
);
|
|
return Ok(());
|
|
}
|
|
};
|
|
self.state.sse.broadcast(SseEvent::Response {
|
|
content: response.content,
|
|
thread_id,
|
|
});
|
|
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(())
|
|
}
|
|
}
|