mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-09-01 09:09:19 +00:00
* feat(ux): complete UX overhaul — design system, boot screen, onboarding, web polish Shared design system: CSS custom properties for spacing, typography, transitions, and color tokens used across web UI and boot screen. Boot screen: compact feature-tags line showing enabled subsystems (db, tools, routines, heartbeat, skills, sandbox, embeddings) at a glance. Downgrade startup info logs (libSQL, webhook, workspace seed) to debug level since the boot screen now covers this. Onboarding wizard: model picker with live API fetch, provider-aware auth flow, improved error recovery and progress display. Web UI: ARIA attributes, welcome card, streaming debounce, connection status banner, skeleton loaders, send cooldown. CLI: doctor command enhancements, status command cleanup, REPL banner consolidation, shared fmt module. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * feat(ux): Apple-level design refinements — spring physics, glass morphism, chat polish Merge staging theme support (dark/light/system toggle) and layer UX polish on top: spring-physics motion, glass morphism depth, chat experience improvements, and responsive mobile refinements. Design system: - Restore and extend design token system (spacing, typography, timing, easing) with legacy aliases for theme compatibility - Add shadow tiers, accent glow, glass morphism, spring easing tokens - Tokens defined in both dark (:root) and light ([data-theme="light"]) Micro-interactions (Phase 2): - Spring-overshoot message entry animation (slideUp) - Spring-scale button press on all interactive buttons - Tab crossfade animation, tool card smooth accordion (max-height) - Modal scale(0.95) + blur(8px) entry, toast spring slide - Sidebar width crossfade, card hover lift Visual depth (Phase 3): - Tab bar glass morphism + surface highlight + sliding indicator - Active tab accent background pill - Assistant message accent left border, user message bubble tail - Floating input area (rounded + shadow + margin) Chat polish (Phase 4): - Smooth streaming cursor (cursorPulse), message hover timestamps - Time separators (Today/Yesterday/date) - Textarea smooth auto-expand, send button glow Settings & forms (Phase 5): - iOS-style toggle switches for boolean settings - Input focus glow, save feedback spring animation - Welcome card with gradient background + proper spacing - Sticky settings group headers with glass backdrop Accessibility & mobile (Phase 6): - Animated focus ring, prefers-reduced-motion global kill-switch - Touch target audit (44px min), mobile bottom-sheet modals - Mobile bottom tab bar, toast redesign (icon + border + countdown) - Thread hover translateX, badge in_progress pulse Bug fixes: - Gateway/TEE popover z-index (tab-bar z-index: 200, popovers 500) - Connection lost banner as fixed top bar instead of flex child - Sidebar collapse keeps toggle + new thread buttons visible - Downgrade noisy startup logs (db, webhook, vector) to debug - Remove green dot pulse animation on connected status - Deduplicate confirm-modal in HTML, add tab-indicator div Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * feat(web): mobile layout improvements — sidebar toggle, settings drill-down, tab bar polish - Fix mobile sidebar toggle: use expanded-mobile class instead of collapsed, add backdrop overlay, auto-close on thread select, outside-click dismiss - Settings: replace cramped horizontal tabs with drill-down navigation (category list → detail view → back button) - Bottom tab bar: add glass morphism, hide theme toggle, flip tab indicator to top edge - Keep thread toggle button visible in collapsed 36px sidebar strip Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * feat(repl): interactive approval selector and transient status lines - Replace ASCII-art approval box with clean horizontal rule card - Add inquire-based interactive selector for tool approvals (↑↓ + Enter) - Selector runs directly from send_status via spawn_blocking, with stdin_locked flag to prevent readline from competing for stdin - Transient thinking/tool-started lines: each replaces the previous, all erased before final output (no clutter left in scrollback) - Esc in selector sends denial so agent never gets stuck Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * fix: widen TurnCost token fields to u64 and remove unused variable - Change input_tokens/output_tokens from u32 to u64 in StatusUpdate::TurnCost, SseEvent::TurnCost, and the thread_ops emit site to avoid truncation on large conversations - Remove unused _routine_engine_for_loop binding in agent_loop.rs Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * chore: reduce startup log noise — demote info to debug Demote routine startup messages (builder, WASM tools, tunnel, WASM channels) from info to debug so the default log output stays clean. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * fix(web): allow CDN scripts in CSP connect-src directive Add cdn.jsdelivr.net and cdnjs.cloudflare.com to connect-src so the browser can fetch marked.js and DOMPurify without CSP violations. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * style: fix cargo fmt in repl.rs Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * fix(web): gate turn_cost SSE handler on current thread Prevents cost badge from attaching to the wrong message when switching threads or receiving events from background threads. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * ci: retrigger CI * fix: add missing extension_manager to webhook EngineContext The webhook trigger path added in #736 was missing the extension_manager field introduced by #1453. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * chore: ignore RUSTSEC-2026-0049 rustls-webpki CRL advisory Low impact — requires compromised CA to exploit. Tracked for upstream rustls-webpki upgrade. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * fix(routines): use fields.join for cron normalization Use split_whitespace fields instead of re-trimming the original string to avoid preserving extra internal whitespace in cron expressions. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * feat(repl): Apple-style approval card — clean vertical flow - Drop verbose tool description (the command IS the decision surface) - Unified vertical pipe layout: ◆ header → │ params → │ selector - Selector options show keyboard shortcuts inline: Approve (y) - Compact help message, answered state uses └ to close the flow - No horizontal rules, no blank-line padding — just breathing room Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * refactor(repl): replace inquire with crossterm for approval selector Drop the inquire dependency (which pulled in crossterm 0.25, duplicating the existing 0.28). The 3-option approval selector is now built directly with crossterm raw mode — same UX, zero new dependencies. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * chore(deps): upgrade crossterm 0.28 → 0.29, eliminate duplication termimad (via crokey) uses crossterm 0.29. Upgrading our direct dependency from 0.28 to 0.29 collapses to a single crossterm version in the dependency tree. Also migrated termimad::crossterm:: references to the direct crossterm import. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * fix: address review comments — box_top off-by-one, smart_truncate overflow, mobile theme toggle - Fix box_top() fill calculation: was off-by-one, producing boxes 1 char too wide (fmt.rs) - Fix smart_truncate(): account for "..." in the budget so output never exceeds max_chars (repl.rs) - Move theme toggle to settings sidebar on mobile instead of display:none, so mobile users can still switch themes (style.css, index.html, app.js) Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * style: cargo fmt repl.rs Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * fix: address review — retry duplication, CSP connect-src, deny color - Remove failed message before retry to prevent duplicate user messages - Revert connect-src to 'self' — CDN hosts only need script-src - Use red for Deny confirmation in REPL approval selector Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> --------- Co-authored-by: Claude Opus 4.6 (1M context) <[email protected]>
473 lines
16 KiB
Rust
473 lines
16 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),
|
|
webhook_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(),
|
|
active_config: server::ActiveConfigSnapshot::default(),
|
|
});
|
|
|
|
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),
|
|
webhook_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,
|
|
active_config: self.state.active_config.clone(),
|
|
};
|
|
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
|
|
}
|
|
|
|
/// Inject the active (resolved) configuration snapshot for the status endpoint.
|
|
pub fn with_active_config(mut self, config: server::ActiveConfigSnapshot) -> Self {
|
|
self.rebuild_state(|s| s.active_config = config);
|
|
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,
|
|
allow_always,
|
|
} => SseEvent::ApprovalNeeded {
|
|
request_id,
|
|
tool_name,
|
|
description,
|
|
parameters: serde_json::to_string_pretty(¶meters)
|
|
.unwrap_or_else(|_| parameters.to_string()),
|
|
thread_id,
|
|
allow_always,
|
|
},
|
|
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,
|
|
},
|
|
StatusUpdate::TurnCost {
|
|
input_tokens,
|
|
output_tokens,
|
|
cost_usd,
|
|
} => SseEvent::TurnCost {
|
|
input_tokens,
|
|
output_tokens,
|
|
cost_usd,
|
|
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(())
|
|
}
|
|
}
|