# Service Console Enhancement Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Add auto-connect on page load and a settings panel to sg_claw_service_console.html, with config save via WebSocket to the sgClaw service. **Architecture:** The HTML page auto-connects on load and provides a settings modal. When user saves, the page sends an `update_config` WebSocket message. The Rust service receives it, merges with existing config, writes to `sgclaw_config.json`, and responds. **Tech Stack:** Rust (serde, tungstenite), vanilla JavaScript/HTML/CSS --- ### Task 1: Add `UpdateConfig` and `ConfigUpdated` protocol types **Files:** - Modify: `src/service/protocol.rs` - [ ] **Step 1: Add `ConfigUpdatePayload` struct and `UpdateConfig` variant to `ClientMessage`** Add this struct above the `ClientMessage` enum, and add the `UpdateConfig` variant to the enum: ```rust use std::path::PathBuf; #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct ConfigUpdatePayload { #[serde(rename = "apiKey", default)] pub api_key: Option, #[serde(rename = "baseUrl", default)] pub base_url: Option, #[serde(default)] pub model: Option, #[serde(rename = "skillsDir", default)] pub skills_dir: Option, #[serde(rename = "directSubmitSkill", default)] pub direct_submit_skill: Option, #[serde(rename = "runtimeProfile", default)] pub runtime_profile: Option, #[serde(rename = "browserBackend", default)] pub browser_backend: Option, } ``` Add `UpdateConfig` variant to `ClientMessage` enum (after `Ping`): ```rust UpdateConfig { config: ConfigUpdatePayload, }, ``` - [ ] **Step 2: Add `ConfigUpdated` variant to `ServiceMessage`** Add after `Pong`: ```rust ConfigUpdated { success: bool, message: String, }, ``` - [ ] **Step 3: Update `into_submit_task_request` to handle `UpdateConfig`** In the match arm, add `ClientMessage::UpdateConfig { .. }` to the list that returns `None`: ```rust ClientMessage::Connect | ClientMessage::Start | ClientMessage::Stop | ClientMessage::Ping | ClientMessage::UpdateConfig { .. } => None, ``` - [ ] **Step 4: Run tests to verify protocol compiles** Run: `cargo test --lib service::protocol` Expected: PASS (no protocol-specific tests yet, but it should compile) ### Task 2: Add `config_path()` getter to `AgentRuntimeContext` **Files:** - Modify: `src/agent/task_runner.rs` - [ ] **Step 1: Add public getter method** In the `impl AgentRuntimeContext` block, add after `load_sgclaw_settings()`: ```rust pub fn config_path(&self) -> Option<&Path> { self.config_path.as_deref() } ``` Add the import at the top of the file if not present: ```rust use std::path::Path; ``` - [ ] **Step 2: Run tests to verify** Run: `cargo test agent::task_runner` Expected: PASS ### Task 3: Add `save_to_path()` method to `SgClawSettings` **Files:** - Modify: `src/config/settings.rs` - [ ] **Step 1: Add Serialize derive to SgClawSettings and related types** The `RawSgClawSettings` struct uses `Deserialize` only. We need to add `Serialize` to `SgClawSettings` for writing. Add `use serde::Serialize;` at the top. Add `Serialize` derive to `SgClawSettings`: ```rust #[derive(Debug, Clone, PartialEq, Eq, Serialize)] pub struct SgClawSettings { ``` But wait - `SgClawSettings` has enum fields (`RuntimeProfile`, `SkillsPromptMode`, `PlannerMode`, `BrowserBackend`, `OfficeBackend`) that don't implement `Serialize`. We need to add Serialize derives to those types too. Instead, the simpler approach is to write a `to_raw()` method that converts `SgClawSettings` to a serializable struct, then serialize that. - [ ] **Step 2: Create serializable raw config struct** Add a new struct at the bottom of the file (before tests if any): ```rust #[derive(Debug, Serialize)] struct SerializableRawSgClawSettings { #[serde(rename = "apiKey")] api_key: String, #[serde(rename = "baseUrl")] base_url: String, model: String, #[serde(rename = "skillsDir", skip_serializing_if = "Option::is_none")] skills_dir: Option, #[serde(rename = "directSubmitSkill", skip_serializing_if = "Option::is_none")] direct_submit_skill: Option, #[serde(rename = "skillsPromptMode", skip_serializing_if = "Option::is_none")] skills_prompt_mode: Option, #[serde(rename = "runtimeProfile", skip_serializing_if = "Option::is_none")] runtime_profile: Option, #[serde(rename = "plannerMode", skip_serializing_if = "Option::is_none")] planner_mode: Option, #[serde(rename = "activeProvider", skip_serializing_if = "Option::is_none")] active_provider: Option, #[serde(rename = "browserBackend", skip_serializing_if = "Option::is_none")] browser_backend: Option, #[serde(rename = "officeBackend", skip_serializing_if = "Option::is_none")] office_backend: Option, #[serde(rename = "browserWsUrl", skip_serializing_if = "Option::is_none")] browser_ws_url: Option, #[serde(rename = "serviceWsListenAddr", skip_serializing_if = "Option::is_none")] service_ws_listen_addr: Option, #[serde(default)] providers: Vec, } #[derive(Debug, Serialize)] struct SerializableProviderSettings { id: String, provider: Option, #[serde(rename = "apiKey")] api_key: String, #[serde(rename = "baseUrl", skip_serializing_if = "Option::is_none")] base_url: Option, model: String, #[serde(rename = "apiPath", skip_serializing_if = "Option::is_none")] api_path: Option, #[serde(rename = "wireApi", skip_serializing_if = "Option::is_none")] wire_api: Option, #[serde(rename = "requiresOpenaiAuth")] requires_openai_auth: bool, } ``` Add `use serde::Serialize;` at the top of the file (combine with existing `use serde::Deserialize;`): ```rust use serde::{Deserialize, Serialize}; ``` - [ ] **Step 3: Add `to_serializable()` method to `SgClawSettings`** In the `impl SgClawSettings` block, add: ```rust fn to_serializable(&self) -> SerializableRawSgClawSettings { let format_enum_value = |s: &str| s.to_string(); SerializableRawSgClawSettings { api_key: self.provider_api_key.clone(), base_url: self.provider_base_url.clone(), model: self.provider_model.clone(), skills_dir: self.skills_dir.as_ref().map(|p| p.to_string_lossy().into_owned()), direct_submit_skill: self.direct_submit_skill.clone(), skills_prompt_mode: Some(format_enum_value(match self.skills_prompt_mode { SkillsPromptMode::Full => "full", SkillsPromptMode::Compact => "compact", })), runtime_profile: Some(format_enum_value(match self.runtime_profile { RuntimeProfile::BrowserAttached => "browser-attached", RuntimeProfile::BrowserHeavy => "browser-heavy", RuntimeProfile::GeneralAssistant => "general-assistant", })), planner_mode: Some(format_enum_value(match self.planner_mode { PlannerMode::ZeroclawPlanFirst => "zeroclaw-plan-first", PlannerMode::LegacyDeterministic => "legacy-deterministic", })), active_provider: Some(self.active_provider.clone()), browser_backend: Some(format_enum_value(match self.browser_backend { BrowserBackend::SuperRpa => "super-rpa", BrowserBackend::AgentBrowser => "agent-browser", BrowserBackend::RustNative => "rust-native", BrowserBackend::ComputerUse => "computer-use", BrowserBackend::Auto => "auto", })), office_backend: Some(format_enum_value(match self.office_backend { OfficeBackend::OpenXml => "openxml", OfficeBackend::Disabled => "disabled", })), browser_ws_url: self.browser_ws_url.clone(), service_ws_listen_addr: self.service_ws_listen_addr.clone(), providers: self .providers .iter() .map(|p| SerializableProviderSettings { id: p.id.clone(), provider: Some(p.provider.clone()), api_key: p.api_key.clone(), base_url: p.base_url.clone(), model: p.model.clone(), api_path: p.api_path.clone(), wire_api: p.wire_api.clone(), requires_openai_auth: p.requires_openai_auth, }) .collect(), } } ``` - [ ] **Step 4: Add `save_to_path()` method** In the same `impl SgClawSettings` block, add: ```rust pub fn save_to_path(&self, path: &Path) -> Result<(), ConfigError> { let serializable = self.to_serializable(); let json = serde_json::to_string_pretty(&serializable) .map_err(|err| ConfigError::ConfigParse(path.to_path_buf(), err.to_string()))?; std::fs::write(path, json) .map_err(|err| ConfigError::ConfigRead(path.to_path_buf(), err.to_string())) } ``` - [ ] **Step 5: Run tests to verify compilation** Run: `cargo test --lib config::settings` Expected: PASS ### Task 4: Handle `UpdateConfig` in the service server **Files:** - Modify: `src/service/server.rs` - Modify: `src/service/mod.rs` (if needed for imports) - [ ] **Step 1: Add `UpdateConfig` match arm in `serve_client`** In the `match message` block in `serve_client`, after the `SubmitTask` arm, add: ```rust ClientMessage::UpdateConfig { config } => { let Some(config_path) = context.config_path() else { sink.send_service_message(ServiceMessage::ConfigUpdated { success: false, message: "未找到配置文件路径。请通过 --config-path 参数启动 sg_claw 后再使用此功能。".to_string(), })?; continue; }; if !config_path.exists() { sink.send_service_message(ServiceMessage::ConfigUpdated { success: false, message: format!("配置文件不存在: {}", config_path.display()), })?; continue; } let result = update_config_file(config_path, config); match result { Ok(()) => { sink.send_service_message(ServiceMessage::ConfigUpdated { success: true, message: "配置已保存。重启 sg_claw 以应用新配置。".to_string(), })?; } Err(err) => { sink.send_service_message(ServiceMessage::ConfigUpdated { success: false, message: format!("保存配置失败: {}", err), })?; } } } ``` - [ ] **Step 2: Add `update_config_file` helper function** Add this function above `serve_client` in `server.rs`: ```rust use crate::config::settings::{ConfigError, SgClawSettings}; use crate::service::protocol::ConfigUpdatePayload; use std::path::Path; fn update_config_file(config_path: &Path, config: ConfigUpdatePayload) -> Result<(), String> { let mut settings = SgClawSettings::load(Some(config_path)) .map_err(|e| e.to_string())? .ok_or_else(|| "无法读取现有配置".to_string())?; if let Some(v) = config.api_key { settings.provider_api_key = v; } if let Some(v) = config.base_url { settings.provider_base_url = v; } if let Some(v) = config.model { settings.provider_model = v; } if let Some(v) = config.skills_dir { settings.skills_dir = Some(PathBuf::from(&v)); } if let Some(v) = config.direct_submit_skill { settings.direct_submit_skill = Some(v); } if let Some(v) = config.runtime_profile { settings.runtime_profile = match v.as_str() { "browser-attached" => crate::config::settings::RuntimeProfile::BrowserAttached, "browser-heavy" => crate::config::settings::RuntimeProfile::BrowserHeavy, "general-assistant" => crate::config::settings::RuntimeProfile::GeneralAssistant, _ => return Err(format!("无效的 runtimeProfile: {}", v)), }; } if let Some(v) = config.browser_backend { settings.browser_backend = match v.as_str() { "super-rpa" => crate::config::settings::BrowserBackend::SuperRpa, "agent-browser" => crate::config::settings::BrowserBackend::AgentBrowser, "rust-native" => crate::config::settings::BrowserBackend::RustNative, "computer-use" => crate::config::settings::BrowserBackend::ComputerUse, "auto" => crate::config::settings::BrowserBackend::Auto, _ => return Err(format!("无效的 browserBackend: {}", v)), }; } settings .save_to_path(config_path) .map_err(|e| format!("写入配置文件失败: {}", e)) } ``` Add the import at the top of server.rs: ```rust use std::path::PathBuf; ``` - [ ] **Step 3: Run tests to verify compilation** Run: `cargo build` Expected: SUCCESS ### Task 5: Add auto-connect and settings UI to the service console HTML **Files:** - Modify: `frontend/service-console/sg_claw_service_console.html` - [ ] **Step 1: Add auto-connect on page load** At the very end of the `` tag: ```javascript // Settings modal state const settingsElements = { modal: document.getElementById("settingsModal"), apiKey: document.getElementById("settingApiKey"), baseUrl: document.getElementById("settingBaseUrl"), model: document.getElementById("settingModel"), skillsDir: document.getElementById("settingSkillsDir"), directSubmitSkill: document.getElementById("settingDirectSubmitSkill"), runtimeProfile: document.getElementById("settingRuntimeProfile"), browserBackend: document.getElementById("settingBrowserBackend"), validation: document.getElementById("settingsValidation"), saveBtn: document.getElementById("settingsSaveBtn"), cancelBtn: document.getElementById("settingsCancelBtn"), }; let settingsOpenBtn = null; // will be set below function openSettingsModal() { // Pre-fill with current values from wsUrl field (for baseUrl hint) settingsElements.apiKey.value = ""; settingsElements.baseUrl.value = ""; settingsElements.model.value = ""; settingsElements.skillsDir.value = ""; settingsElements.directSubmitSkill.value = ""; settingsElements.runtimeProfile.value = "browser-attached"; settingsElements.browserBackend.value = "super-rpa"; settingsElements.validation.textContent = ""; settingsElements.modal.style.display = "flex"; } function closeSettingsModal() { settingsElements.modal.style.display = "none"; } function validateSettings() { const apiKey = settingsElements.apiKey.value.trim(); const baseUrl = settingsElements.baseUrl.value.trim(); const model = settingsElements.model.value.trim(); if (!apiKey) { return "API 密钥不能为空"; } if (!model) { return "模型名称不能为空"; } if (!baseUrl) { return "模型服务地址不能为空"; } try { new URL(baseUrl); } catch { return "模型服务地址格式无效,请输入有效的 URL"; } return ""; } function saveSettings() { const error = validateSettings(); if (error) { settingsElements.validation.textContent = error; return; } if (!socket || socket.readyState !== WebSocket.OPEN) { settingsElements.validation.textContent = "请先连接服务"; return; } settingsElements.validation.textContent = ""; settingsElements.saveBtn.disabled = true; settingsElements.saveBtn.textContent = "保存中..."; const config = { apiKey: settingsElements.apiKey.value.trim(), baseUrl: settingsElements.baseUrl.value.trim(), model: settingsElements.model.value.trim(), }; const skillsDir = settingsElements.skillsDir.value.trim(); if (skillsDir) config.skillsDir = skillsDir; const directSubmitSkill = settingsElements.directSubmitSkill.value.trim(); if (directSubmitSkill) config.directSubmitSkill = directSubmitSkill; config.runtimeProfile = settingsElements.runtimeProfile.value; config.browserBackend = settingsElements.browserBackend.value; socket.send(JSON.stringify({ type: "update_config", config, })); } function handleConfigResponse(message) { settingsElements.saveBtn.disabled = false; settingsElements.saveBtn.textContent = "保存"; if (message.success) { settingsElements.validation.textContent = message.message; settingsElements.validation.style.color = "var(--success)"; // Auto-close after 2 seconds on success setTimeout(closeSettingsModal, 2000); } else { settingsElements.validation.textContent = message.message; settingsElements.validation.style.color = "var(--error)"; } } // Event listeners for settings settingsOpenBtn = document.getElementById("settingsBtn"); settingsOpenBtn.addEventListener("click", openSettingsModal); settingsElements.cancelBtn.addEventListener("click", closeSettingsModal); settingsElements.saveBtn.addEventListener("click", saveSettings); // Close modal on background click settingsElements.modal.addEventListener("click", (e) => { if (e.target === settingsElements.modal) { closeSettingsModal(); } }); ``` - [ ] **Step 6: Handle `config_updated` message in `handleMessage`** In the existing `handleMessage` function, add a new case in the switch statement: ```javascript case "config_updated": handleConfigResponse(message); break; ``` - [ ] **Step 7: Verify the HTML is well-formed** Open the file in a browser and visually check that: - The settings button appears below the connect button - Clicking it opens the modal - The modal closes on Cancel or background click ### Task 6: Add protocol tests for new message types **Files:** - Modify: `tests/service_console_html_test.rs` - Create: `tests/service_protocol_update_config_test.rs` - [ ] **Step 1: Create protocol serialization test** Create `tests/service_protocol_update_config_test.rs`: ```rust use sgclaw::service::protocol::{ClientMessage, ConfigUpdatePayload, ServiceMessage}; #[test] fn update_config_serializes_correctly() { let config = ConfigUpdatePayload { api_key: Some("test-key".to_string()), base_url: Some("https://api.example.com".to_string()), model: Some("test-model".to_string()), skills_dir: Some("/path/to/skills".to_string()), direct_submit_skill: Some("my-skill.my-tool".to_string()), runtime_profile: Some("browser-attached".to_string()), browser_backend: Some("super-rpa".to_string()), }; let msg = ClientMessage::UpdateConfig { config }; let json = serde_json::to_string(&msg).unwrap(); assert!(json.contains("\"type\":\"update_config\"")); assert!(json.contains("\"apiKey\":\"test-key\"")); assert!(json.contains("\"baseUrl\":\"https://api.example.com\"")); assert!(json.contains("\"model\":\"test-model\"")); } #[test] fn update_config_deserializes_correctly() { let json = r#"{ "type": "update_config", "config": { "apiKey": "key123", "baseUrl": "https://api.test.com", "model": "gpt-4" } }"#; let msg: ClientMessage = serde_json::from_str(json).unwrap(); match msg { ClientMessage::UpdateConfig { config } => { assert_eq!(config.api_key, Some("key123".to_string())); assert_eq!(config.base_url, Some("https://api.test.com".to_string())); assert_eq!(config.model, Some("gpt-4".to_string())); assert!(config.skills_dir.is_none()); } _ => panic!("expected UpdateConfig variant"), } } #[test] fn config_updated_serializes_correctly() { let msg = ServiceMessage::ConfigUpdated { success: true, message: "配置已保存".to_string(), }; let json = serde_json::to_string(&msg).unwrap(); assert!(json.contains("\"type\":\"config_updated\"")); assert!(json.contains("\"success\":true")); assert!(json.contains("配置已保存")); } #[test] fn config_updated_deserializes_correctly() { let json = r#"{"type":"config_updated","success":false,"message":"保存失败"}"#; let msg: ServiceMessage = serde_json::from_str(json).unwrap(); match msg { ServiceMessage::ConfigUpdated { success, message } => { assert!(!success); assert_eq!(message, "保存失败"); } _ => panic!("expected ConfigUpdated variant"), } } ``` - [ ] **Step 2: Update service console HTML test** Add to `tests/service_console_html_test.rs`, at the end of the existing test: ```rust // New enhancement assertions assert!(source.contains("DOMContentLoaded")); assert!(source.contains("settingsBtn")); assert!(source.contains("settingsModal")); assert!(source.contains("update_config")); assert!(source.contains("config_updated")); assert!(source.contains("settingApiKey")); assert!(source.contains("settingBaseUrl")); assert!(source.contains("settingModel")); ``` - [ ] **Step 3: Run all new tests** Run: `cargo test --test service_protocol_update_config_test` Run: `cargo test --test service_console_html_test` Expected: All PASS ### Task 7: Full build and test verification - [ ] **Step 1: Run full test suite** Run: `cargo test 2>&1` Expected: All tests pass (except pre-existing `lineloss_period_resolver_prompts_for_missing_period` which was already failing before our changes) - [ ] **Step 2: Build release binary** Run: `cargo build --release 2>&1` Expected: SUCCESS ### Task 8: Manual smoke test instructions After implementation, verify manually: 1. Start sg_claw with config path: `sg_claw.exe --config-path sgclaw_config.json` 2. Open `sg_claw_service_console.html` in browser 3. Verify: Page auto-connects (should show "已连接" within a few seconds) 4. Click "设置" button 5. Fill in API Key, Base URL, Model 6. Click "保存" 7. Verify: Modal shows "配置已保存。重启 sg_claw 以应用新配置。" and auto-closes after 2 seconds 8. Verify: `sgclaw_config.json` file contains the new values 9. Verify: Existing task submission still works (send a test instruction)