mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-30 16:19:21 +00:00
* feat(gateway): full settings page polish with all tiers - Backend: add ActiveConfigSnapshot to expose resolved LLM backend, model, and enabled channels via /api/gateway/status - Add missing Agent settings (daily cost cap, actions/hour, local tools) - Add Sandbox, Routines, Safety, Skills, and Search setting groups - Settings import/export (JSON download + file upload) - Active env defaults shown as placeholders in Inference settings - Styled confirmation modals replace window.confirm() for remove actions - Global restart banner persists across settings subtab switches - Client-side validation with min/max constraints on number inputs - Accessibility: aria-label on inputs, role=status on save indicators - Settings search filters rows across current subtab - Smooth CSS transitions for conditional field visibility (showWhen) - Tunnel settings in Channels subtab - Mobile responsive settings layout at 768px breakpoint - i18n keys for toolbar, search, and import/export in en + zh-CN Co-Authored-By: Claude Opus 4.6 <[email protected]> * feat(gateway): polish settings page and remove registered tools debug section Remove the "Registered Tools" table from the extensions tab (debug info not useful to end users), clean up associated CSS/i18n/JS. Additional settings page UI polish: extension card state styling, layout refinements. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * fix(gateway): address PR review feedback [skip-regression-check] - Use refreshCurrentSettingsTab() in SSE event handlers to reduce duplication - Remove unused formatGroupName/formatSettingLabel helpers - Use i18n keys for MCP Configure/Reconfigure buttons - Add data-i18n-placeholder to settings search input - Remove data-i18n from confirm modal button (set dynamically by showConfirmModal) - Fix cargo fmt in main.rs Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * fix(e2e): update tests for unified settings tab layout [skip-regression-check] - Update TABS list: replace extensions/skills with settings - Add settings_subtab/settings_subpanel selectors to helpers - Update test_connection, test_skills, test_extensions, test_wasm_lifecycle to navigate via Settings > subtab instead of top-level tabs - Move MCP card tests to use go_to_mcp() helper (MCP is now a separate subtab) - Remove tools table tests and mock_ext_apis tools= parameter - Fix CSP violation: replace inline onclick on confirm modal cancel button Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * fix(gateway): address second round of PR review feedback [skip-regression-check] - Use I18n.t() for MCP empty state, export/import toasts, confirm modal - Fix CLI channel card using wrong channel key ('repl' -> 'cli') - Fix settings search counting hidden rows as visible - Add aria-label i18n for settings search input - Add common.loadFailed i18n key (en + zh-CN) - Update E2E tests: WASM channel tests use Channels subtab, remove tests use custom confirm modal instead of window.confirm Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * fix(e2e): fix WASM channel card selector and skills remove confirm [skip-regression-check] - WASM channel tests: filter by display name to avoid matching built-in channel cards in the Channels subtab - Skills remove test: click confirm modal button instead of using window.confirm (skill removal now uses custom confirm modal) Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * fix(gateway): address third round of PR review feedback [skip-regression-check] - approval_needed SSE: refresh any active settings subtab, not just Extensions — approvals can surface from Channels/MCP setup flows too - renderCardsSkeleton: remove nested .extensions-list wrapper that caused skeleton cards to render constrained inside grid cells Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * fix(e2e): fix auth_completed reload test race condition [skip-regression-check] Use expect_response to deterministically wait for the /api/extensions reload triggered by handleAuthCompleted → refreshCurrentSettingsTab, instead of a fixed 600ms sleep that was too short under CI load. Also remove stale /api/extensions/tools route handler. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * fix(e2e): debug auth_completed reload test with function counter [skip-regression-check] Inject a counter wrapper around refreshCurrentSettingsTab to verify it's actually called, and wait for the async fetch to complete before asserting the reload count. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * feat(gateway): localize all settings labels, descriptions, and channel cards [skip-regression-check] Move 120+ hardcoded strings in settings definitions (INFERENCE_SETTINGS, AGENT_SETTINGS, NETWORKING_SETTINGS) and channel card labels to i18n keys. Render functions now resolve labels via I18n.t() so the settings page translates when switching locales. Covers: group titles, setting labels/descriptions, built-in channel names/descriptions, and the "No settings found" empty state. Both en.js and zh-CN.js updated with all new cfg.* and channels.* keys. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * fix(gateway): localize remaining hardcoded UI strings [skip-regression-check] - Fix export error toast using wrong i18n key (importFailed → exportFailed) - Replace "Failed to load settings:" with I18n.t('common.loadFailed') - Localize renderBuiltinChannelCard: "Built-in", "Active", "Inactive" - Localize settings placeholders: "env: ", "env default", "use env default" - Localize "✓ Saved" indicator - Add new i18n keys to both en.js and zh-CN.js Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * fix(gateway): confirm modal a11y, Esc/click-outside, search guard [skip-regression-check] - Add role="dialog", aria-modal="true", aria-labelledby to confirm modal - Focus confirm button when modal opens - Close modal on Escape key or overlay click - Skip settings search on non-settings panels (Extensions/MCP/Skills/Channels) Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * fix(gateway): boolean tri-state, search reset on subtab switch, stale model suggestions [skip-regression-check] Address PR review feedback: - Boolean settings now use a tri-state select (env default / On / Off) instead of a checkbox, matching the pattern used by other select settings and allowing users to revert to the env default - Clear search input when switching settings subtabs so stale filters don't carry over to the new panel - Always assign model suggestions (even empty array) so stale IDs from a previous successful /v1/models fetch don't persist when the endpoint later returns empty Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * fix(gateway): auth_completed handler, bedrock_cross_region select, integer-only number inputs [skip-regression-check] Address PR review feedback: - auth_completed SSE listener now delegates to handleAuthCompleted(data) instead of inlining logic with a bare closeConfigureModal() call, so only the matching extension's modal is dismissed - bedrock_cross_region changed from free text to select with the four valid values (us/eu/apac/global), matching backend validation - Number settings now use step=1 and parseInt() instead of parseFloat(), preventing fractional values that the backend (u32/u64) would reject Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> --------- Co-authored-by: Claude Opus 4.6 <[email protected]>
528 lines
18 KiB
Rust
528 lines
18 KiB
Rust
//! WebSocket handler for bidirectional client communication.
|
|
//!
|
|
//! Provides the same event stream as SSE but also accepts incoming messages
|
|
//! (chat, approvals) over a single persistent connection.
|
|
//!
|
|
//! ```text
|
|
//! Client ──── WS frame: {"type":"message","content":"hello"} ──► Agent Loop
|
|
//! ◄─── WS frame: {"type":"event","event_type":"response","data":{...}} ── Broadcast
|
|
//! ──── WS frame: {"type":"ping"} ──────────────────────────────────────►
|
|
//! ◄─── WS frame: {"type":"pong"} ──────────────────────────────────────
|
|
//! ```
|
|
|
|
use std::sync::Arc;
|
|
use std::sync::atomic::{AtomicU64, Ordering};
|
|
|
|
use axum::extract::ws::{Message, WebSocket};
|
|
use futures::{SinkExt, StreamExt};
|
|
use tokio::sync::mpsc;
|
|
use uuid::Uuid;
|
|
|
|
use crate::agent::submission::Submission;
|
|
use crate::channels::IncomingMessage;
|
|
use crate::channels::web::server::GatewayState;
|
|
use crate::channels::web::types::{WsClientMessage, WsServerMessage};
|
|
|
|
/// Tracks active WebSocket connections.
|
|
pub struct WsConnectionTracker {
|
|
count: AtomicU64,
|
|
}
|
|
|
|
impl WsConnectionTracker {
|
|
pub fn new() -> Self {
|
|
Self {
|
|
count: AtomicU64::new(0),
|
|
}
|
|
}
|
|
|
|
pub fn connection_count(&self) -> u64 {
|
|
self.count.load(Ordering::Relaxed)
|
|
}
|
|
|
|
fn increment(&self) {
|
|
self.count.fetch_add(1, Ordering::Relaxed);
|
|
}
|
|
|
|
fn decrement(&self) {
|
|
self.count.fetch_sub(1, Ordering::Relaxed);
|
|
}
|
|
}
|
|
|
|
impl Default for WsConnectionTracker {
|
|
fn default() -> Self {
|
|
Self::new()
|
|
}
|
|
}
|
|
|
|
/// Handle an upgraded WebSocket connection.
|
|
///
|
|
/// Spawns two tasks:
|
|
/// - **sender**: forwards broadcast events to the WebSocket client
|
|
/// - **receiver**: reads client frames and routes them to the agent
|
|
///
|
|
/// When either task ends (client disconnect or broadcast closed), both are
|
|
/// cleaned up.
|
|
pub async fn handle_ws_connection(socket: WebSocket, state: Arc<GatewayState>) {
|
|
let (mut ws_sink, mut ws_stream) = socket.split();
|
|
|
|
// Track connection
|
|
if let Some(ref tracker) = state.ws_tracker {
|
|
tracker.increment();
|
|
}
|
|
let tracker_for_drop = state.ws_tracker.clone();
|
|
|
|
// Subscribe to broadcast events (same source as SSE).
|
|
// Reject if we've hit the connection limit.
|
|
let Some(raw_stream) = state.sse.subscribe_raw() else {
|
|
tracing::warn!("WebSocket rejected: too many connections");
|
|
// Decrement the WS tracker we already incremented above.
|
|
if let Some(ref tracker) = tracker_for_drop {
|
|
tracker.decrement();
|
|
}
|
|
return;
|
|
};
|
|
let mut event_stream = Box::pin(raw_stream);
|
|
|
|
// Channel for the sender task to receive messages from both
|
|
// the broadcast stream and any direct sends (like Pong)
|
|
let (direct_tx, mut direct_rx) = mpsc::channel::<WsServerMessage>(64);
|
|
|
|
// Sender task: forward broadcast events + direct messages to WS client
|
|
let sender_handle = tokio::spawn(async move {
|
|
loop {
|
|
let msg = tokio::select! {
|
|
event = event_stream.next() => {
|
|
match event {
|
|
Some(sse_event) => WsServerMessage::from_sse_event(&sse_event),
|
|
None => break, // Broadcast channel closed
|
|
}
|
|
}
|
|
direct = direct_rx.recv() => {
|
|
match direct {
|
|
Some(msg) => msg,
|
|
None => break, // Direct channel closed
|
|
}
|
|
}
|
|
};
|
|
|
|
let json = match serde_json::to_string(&msg) {
|
|
Ok(j) => j,
|
|
Err(_) => continue,
|
|
};
|
|
|
|
if ws_sink.send(Message::Text(json.into())).await.is_err() {
|
|
break; // Client disconnected
|
|
}
|
|
}
|
|
});
|
|
|
|
// Receiver task: read client frames and route to agent
|
|
let user_id = state.user_id.clone();
|
|
while let Some(Ok(frame)) = ws_stream.next().await {
|
|
match frame {
|
|
Message::Text(text) => {
|
|
let parsed: Result<WsClientMessage, _> = serde_json::from_str(&text);
|
|
match parsed {
|
|
Ok(client_msg) => {
|
|
handle_client_message(client_msg, &state, &user_id, &direct_tx).await;
|
|
}
|
|
Err(e) => {
|
|
let _ = direct_tx
|
|
.send(WsServerMessage::Error {
|
|
message: format!("Invalid message: {}", e),
|
|
})
|
|
.await;
|
|
}
|
|
}
|
|
}
|
|
Message::Close(_) => break,
|
|
// Ignore binary, ping/pong (axum handles protocol-level pings)
|
|
_ => {}
|
|
}
|
|
}
|
|
|
|
// Clean up: abort sender, decrement counter
|
|
sender_handle.abort();
|
|
if let Some(ref tracker) = tracker_for_drop {
|
|
tracker.decrement();
|
|
}
|
|
}
|
|
|
|
/// Route a parsed client message to the appropriate handler.
|
|
async fn handle_client_message(
|
|
msg: WsClientMessage,
|
|
state: &GatewayState,
|
|
user_id: &str,
|
|
direct_tx: &mpsc::Sender<WsServerMessage>,
|
|
) {
|
|
match msg {
|
|
WsClientMessage::Message {
|
|
content,
|
|
thread_id,
|
|
timezone,
|
|
images,
|
|
} => {
|
|
let mut incoming = IncomingMessage::new("gateway", user_id, &content);
|
|
if let Some(ref tz) = timezone {
|
|
incoming = incoming.with_timezone(tz);
|
|
}
|
|
if let Some(ref tid) = thread_id {
|
|
incoming = incoming.with_thread(tid);
|
|
}
|
|
|
|
// Convert uploaded images to IncomingAttachments
|
|
if !images.is_empty() {
|
|
let attachments = crate::channels::web::server::images_to_attachments(&images);
|
|
incoming = incoming.with_attachments(attachments);
|
|
}
|
|
|
|
// Clone sender to avoid holding RwLock read guard across send().await
|
|
let tx = {
|
|
let tx_guard = state.msg_tx.read().await;
|
|
tx_guard.as_ref().cloned()
|
|
};
|
|
if let Some(tx) = tx {
|
|
if tx.send(incoming).await.is_err() {
|
|
let _ = direct_tx
|
|
.send(WsServerMessage::Error {
|
|
message: "Channel closed".to_string(),
|
|
})
|
|
.await;
|
|
}
|
|
} else {
|
|
let _ = direct_tx
|
|
.send(WsServerMessage::Error {
|
|
message: "Channel not started".to_string(),
|
|
})
|
|
.await;
|
|
}
|
|
}
|
|
WsClientMessage::Approval {
|
|
request_id,
|
|
action,
|
|
thread_id,
|
|
} => {
|
|
let (approved, always) = match action.as_str() {
|
|
"approve" => (true, false),
|
|
"always" => (true, true),
|
|
"deny" => (false, false),
|
|
other => {
|
|
let _ = direct_tx
|
|
.send(WsServerMessage::Error {
|
|
message: format!("Unknown approval action: {}", other),
|
|
})
|
|
.await;
|
|
return;
|
|
}
|
|
};
|
|
|
|
let request_uuid = match Uuid::parse_str(&request_id) {
|
|
Ok(id) => id,
|
|
Err(_) => {
|
|
let _ = direct_tx
|
|
.send(WsServerMessage::Error {
|
|
message: "Invalid request_id (expected UUID)".to_string(),
|
|
})
|
|
.await;
|
|
return;
|
|
}
|
|
};
|
|
|
|
let approval = Submission::ExecApproval {
|
|
request_id: request_uuid,
|
|
approved,
|
|
always,
|
|
};
|
|
let content = match serde_json::to_string(&approval) {
|
|
Ok(c) => c,
|
|
Err(e) => {
|
|
let _ = direct_tx
|
|
.send(WsServerMessage::Error {
|
|
message: format!("Failed to serialize approval: {}", e),
|
|
})
|
|
.await;
|
|
return;
|
|
}
|
|
};
|
|
|
|
let mut msg = IncomingMessage::new("gateway", user_id, content);
|
|
if let Some(ref tid) = thread_id {
|
|
msg = msg.with_thread(tid);
|
|
}
|
|
// Clone sender to avoid holding RwLock read guard across send().await
|
|
let tx = {
|
|
let tx_guard = state.msg_tx.read().await;
|
|
tx_guard.as_ref().cloned()
|
|
};
|
|
if let Some(tx) = tx {
|
|
let _ = tx.send(msg).await;
|
|
}
|
|
}
|
|
WsClientMessage::AuthToken {
|
|
extension_name,
|
|
token,
|
|
} => {
|
|
if let Some(ref ext_mgr) = state.extension_manager {
|
|
match ext_mgr.configure_token(&extension_name, &token).await {
|
|
Ok(result) => {
|
|
if result.verification.is_some() {
|
|
state.sse.broadcast(
|
|
crate::channels::web::types::SseEvent::AuthRequired {
|
|
extension_name: extension_name.clone(),
|
|
instructions: Some(result.message),
|
|
auth_url: None,
|
|
setup_url: None,
|
|
},
|
|
);
|
|
} else {
|
|
crate::channels::web::server::clear_auth_mode(state).await;
|
|
state.sse.broadcast(
|
|
crate::channels::web::types::SseEvent::AuthCompleted {
|
|
extension_name,
|
|
success: true,
|
|
message: result.message,
|
|
},
|
|
);
|
|
}
|
|
}
|
|
Err(e) => {
|
|
let msg = format!("Auth failed: {}", e);
|
|
if matches!(e, crate::extensions::ExtensionError::ValidationFailed(_)) {
|
|
state.sse.broadcast(
|
|
crate::channels::web::types::SseEvent::AuthRequired {
|
|
extension_name: extension_name.clone(),
|
|
instructions: Some(msg.clone()),
|
|
auth_url: None,
|
|
setup_url: None,
|
|
},
|
|
);
|
|
}
|
|
let _ = direct_tx
|
|
.send(WsServerMessage::Error { message: msg })
|
|
.await;
|
|
}
|
|
}
|
|
} else {
|
|
let _ = direct_tx
|
|
.send(WsServerMessage::Error {
|
|
message: "Extension manager not available".to_string(),
|
|
})
|
|
.await;
|
|
}
|
|
}
|
|
WsClientMessage::AuthCancel { .. } => {
|
|
crate::channels::web::server::clear_auth_mode(state).await;
|
|
}
|
|
WsClientMessage::Ping => {
|
|
let _ = direct_tx.send(WsServerMessage::Pong).await;
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn test_ws_connection_tracker() {
|
|
let tracker = WsConnectionTracker::new();
|
|
assert_eq!(tracker.connection_count(), 0);
|
|
|
|
tracker.increment();
|
|
assert_eq!(tracker.connection_count(), 1);
|
|
|
|
tracker.increment();
|
|
assert_eq!(tracker.connection_count(), 2);
|
|
|
|
tracker.decrement();
|
|
assert_eq!(tracker.connection_count(), 1);
|
|
|
|
tracker.decrement();
|
|
assert_eq!(tracker.connection_count(), 0);
|
|
}
|
|
|
|
#[test]
|
|
fn test_ws_connection_tracker_default() {
|
|
let tracker = WsConnectionTracker::default();
|
|
assert_eq!(tracker.connection_count(), 0);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_handle_client_message_ping() {
|
|
// Ping should produce a Pong on the direct channel
|
|
let (direct_tx, mut direct_rx) = mpsc::channel(16);
|
|
let state = make_test_state(None).await;
|
|
|
|
handle_client_message(WsClientMessage::Ping, &state, "user1", &direct_tx).await;
|
|
|
|
let response = direct_rx.recv().await.unwrap();
|
|
assert!(matches!(response, WsServerMessage::Pong));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_handle_client_message_sends_to_agent() {
|
|
// A Message should be forwarded to the agent's msg_tx
|
|
let (agent_tx, mut agent_rx) = mpsc::channel(16);
|
|
let state = make_test_state(Some(agent_tx)).await;
|
|
let (direct_tx, _direct_rx) = mpsc::channel(16);
|
|
|
|
handle_client_message(
|
|
WsClientMessage::Message {
|
|
content: "hello agent".to_string(),
|
|
thread_id: Some("t1".to_string()),
|
|
timezone: None,
|
|
images: Vec::new(),
|
|
},
|
|
&state,
|
|
"user1",
|
|
&direct_tx,
|
|
)
|
|
.await;
|
|
|
|
let incoming = agent_rx.recv().await.unwrap();
|
|
assert_eq!(incoming.content, "hello agent");
|
|
assert_eq!(incoming.thread_id.as_deref(), Some("t1"));
|
|
assert_eq!(incoming.channel, "gateway");
|
|
assert_eq!(incoming.user_id, "user1");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_handle_client_message_no_channel() {
|
|
// When msg_tx is None, should send an error back
|
|
let state = make_test_state(None).await;
|
|
let (direct_tx, mut direct_rx) = mpsc::channel(16);
|
|
|
|
handle_client_message(
|
|
WsClientMessage::Message {
|
|
content: "hello".to_string(),
|
|
thread_id: None,
|
|
timezone: None,
|
|
images: Vec::new(),
|
|
},
|
|
&state,
|
|
"user1",
|
|
&direct_tx,
|
|
)
|
|
.await;
|
|
|
|
let response = direct_rx.recv().await.unwrap();
|
|
match response {
|
|
WsServerMessage::Error { message } => {
|
|
assert!(message.contains("not started"));
|
|
}
|
|
_ => panic!("Expected Error variant"),
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_handle_client_approval_approve() {
|
|
let (agent_tx, mut agent_rx) = mpsc::channel(16);
|
|
let state = make_test_state(Some(agent_tx)).await;
|
|
let (direct_tx, _direct_rx) = mpsc::channel(16);
|
|
|
|
let request_id = Uuid::new_v4();
|
|
handle_client_message(
|
|
WsClientMessage::Approval {
|
|
request_id: request_id.to_string(),
|
|
action: "approve".to_string(),
|
|
thread_id: Some("thread-42".to_string()),
|
|
},
|
|
&state,
|
|
"user1",
|
|
&direct_tx,
|
|
)
|
|
.await;
|
|
|
|
let incoming = agent_rx.recv().await.unwrap();
|
|
// The content should be a serialized ExecApproval
|
|
assert!(incoming.content.contains("ExecApproval"));
|
|
// Thread should be forwarded onto the IncomingMessage.
|
|
assert_eq!(incoming.thread_id.as_deref(), Some("thread-42"));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_handle_client_approval_invalid_action() {
|
|
let state = make_test_state(None).await;
|
|
let (direct_tx, mut direct_rx) = mpsc::channel(16);
|
|
|
|
handle_client_message(
|
|
WsClientMessage::Approval {
|
|
request_id: Uuid::new_v4().to_string(),
|
|
action: "maybe".to_string(),
|
|
thread_id: None,
|
|
},
|
|
&state,
|
|
"user1",
|
|
&direct_tx,
|
|
)
|
|
.await;
|
|
|
|
let response = direct_rx.recv().await.unwrap();
|
|
match response {
|
|
WsServerMessage::Error { message } => {
|
|
assert!(message.contains("Unknown approval action"));
|
|
}
|
|
_ => panic!("Expected Error variant"),
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_handle_client_approval_invalid_uuid() {
|
|
let state = make_test_state(None).await;
|
|
let (direct_tx, mut direct_rx) = mpsc::channel(16);
|
|
|
|
handle_client_message(
|
|
WsClientMessage::Approval {
|
|
request_id: "not-a-uuid".to_string(),
|
|
action: "approve".to_string(),
|
|
thread_id: None,
|
|
},
|
|
&state,
|
|
"user1",
|
|
&direct_tx,
|
|
)
|
|
.await;
|
|
|
|
let response = direct_rx.recv().await.unwrap();
|
|
match response {
|
|
WsServerMessage::Error { message } => {
|
|
assert!(message.contains("Invalid request_id"));
|
|
}
|
|
_ => panic!("Expected Error variant"),
|
|
}
|
|
}
|
|
|
|
/// Helper to create a GatewayState for testing.
|
|
async fn make_test_state(msg_tx: Option<mpsc::Sender<IncomingMessage>>) -> GatewayState {
|
|
use crate::channels::web::sse::SseManager;
|
|
|
|
GatewayState {
|
|
msg_tx: tokio::sync::RwLock::new(msg_tx),
|
|
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: "test".to_string(),
|
|
shutdown_tx: tokio::sync::RwLock::new(None),
|
|
ws_tracker: Some(Arc::new(WsConnectionTracker::new())),
|
|
llm_provider: None,
|
|
skill_registry: None,
|
|
skill_catalog: None,
|
|
chat_rate_limiter: crate::channels::web::server::RateLimiter::new(30, 60),
|
|
oauth_rate_limiter: crate::channels::web::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: crate::channels::web::server::ActiveConfigSnapshot::default(),
|
|
}
|
|
}
|
|
}
|