- Auto-connect WebSocket on page load in service console - Settings modal for editing sgclaw_config.json (API key, base URL, model, skills dir, etc.) - UpdateConfig/ConfigUpdated protocol messages for remote config save - save_to_path() for SgClawSettings serialization - ConfigUpdated handler in sg_claw_client binary - Protocol serialization tests for new message types - HTML test assertions for auto-connect and settings UI - Additional pending changes: deterministic submit, org units, lineloss xlsx export, browser script tool, and docs 🤖 Generated with [Qoder][https://qoder.com]
119 lines
4.0 KiB
Rust
119 lines
4.0 KiB
Rust
mod protocol;
|
|
pub(crate) mod server;
|
|
|
|
use std::net::TcpListener;
|
|
use std::sync::Arc;
|
|
|
|
use tungstenite::accept;
|
|
|
|
use crate::agent::AgentRuntimeContext;
|
|
use crate::browser::callback_host::LiveBrowserCallbackHost;
|
|
use crate::pipe::PipeError;
|
|
use crate::security::MacPolicy;
|
|
|
|
const DEFAULT_BROWSER_WS_URL: &str = "ws://127.0.0.1:12345";
|
|
const DEFAULT_SERVICE_WS_LISTEN_ADDR: &str = "127.0.0.1:42321";
|
|
|
|
pub use protocol::{ClientMessage, ConfigUpdatePayload, ServiceMessage};
|
|
pub use server::{ServiceEventSink, ServiceSession};
|
|
|
|
pub(crate) mod browser_ws_client {
|
|
pub(crate) use super::server::{initial_request_url_for_submit_task, ServiceWsClient};
|
|
}
|
|
|
|
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
|
pub struct ServiceStartupConfig {
|
|
pub browser_ws_url: Option<String>,
|
|
pub service_ws_listen_addr: Option<String>,
|
|
}
|
|
|
|
pub fn load_startup_config(
|
|
runtime_context: &AgentRuntimeContext,
|
|
) -> Result<ServiceStartupConfig, PipeError> {
|
|
let settings = runtime_context
|
|
.load_sgclaw_settings()?
|
|
.ok_or_else(|| PipeError::Protocol("missing environment variable: DEEPSEEK_API_KEY".to_string()))?;
|
|
|
|
Ok(ServiceStartupConfig {
|
|
browser_ws_url: Some(
|
|
settings
|
|
.browser_ws_url
|
|
.unwrap_or_else(|| DEFAULT_BROWSER_WS_URL.to_string()),
|
|
),
|
|
service_ws_listen_addr: Some(
|
|
settings
|
|
.service_ws_listen_addr
|
|
.unwrap_or_else(|| DEFAULT_SERVICE_WS_LISTEN_ADDR.to_string()),
|
|
),
|
|
})
|
|
}
|
|
|
|
pub fn run() -> Result<(), PipeError> {
|
|
let runtime_context = AgentRuntimeContext::from_process_args(std::env::args_os())?;
|
|
let startup = load_startup_config(&runtime_context)?;
|
|
let service_ws_listen_addr = startup
|
|
.service_ws_listen_addr
|
|
.as_deref()
|
|
.unwrap_or(DEFAULT_SERVICE_WS_LISTEN_ADDR);
|
|
let browser_ws_url = startup
|
|
.browser_ws_url
|
|
.as_deref()
|
|
.unwrap_or(DEFAULT_BROWSER_WS_URL);
|
|
let listener = TcpListener::bind(service_ws_listen_addr)
|
|
.map_err(|err| PipeError::Protocol(format!("failed to bind service listener {service_ws_listen_addr}: {err}")))?;
|
|
let mac_policy = load_service_mac_policy()?;
|
|
let session = ServiceSession::new();
|
|
|
|
eprintln!(
|
|
"sg_claw ready: service_ws_listen_addr={}, browser_ws_url={}",
|
|
service_ws_listen_addr,
|
|
browser_ws_url,
|
|
);
|
|
|
|
// Cache the browser callback host across client sessions so the helper
|
|
// page tab is opened only once per process lifetime instead of once per
|
|
// WebSocket reconnection.
|
|
let mut cached_host: Option<Arc<LiveBrowserCallbackHost>> = None;
|
|
|
|
loop {
|
|
let (stream, _) = listener.accept()?;
|
|
let websocket = accept(stream)
|
|
.map_err(|err| PipeError::Protocol(format!("service websocket accept failed: {err}")))?;
|
|
let sink = Arc::new(ServiceEventSink::from_websocket(websocket));
|
|
match session.try_attach_client() {
|
|
Ok(()) => {
|
|
let result = server::serve_client(
|
|
&runtime_context,
|
|
&session,
|
|
sink.clone(),
|
|
browser_ws_url,
|
|
&mac_policy,
|
|
&mut cached_host,
|
|
);
|
|
session.detach_client();
|
|
match result {
|
|
Ok(()) | Err(PipeError::PipeClosed) => {}
|
|
Err(err) => return Err(err),
|
|
}
|
|
}
|
|
Err(message) => {
|
|
sink.send_service_message(message)?;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
fn load_service_mac_policy() -> Result<MacPolicy, PipeError> {
|
|
let current_exe = std::env::current_exe()?;
|
|
let candidate = current_exe
|
|
.parent()
|
|
.map(|dir| dir.join("resources").join("rules.json"))
|
|
.unwrap_or_else(|| std::path::PathBuf::from("resources").join("rules.json"));
|
|
let path = if candidate.exists() {
|
|
candidate
|
|
} else {
|
|
std::env::current_dir()?.join("resources").join("rules.json")
|
|
};
|
|
MacPolicy::load_from_path(&path).map_err(PipeError::from)
|
|
}
|