Files
claw/docs/superpowers/plans/2026-04-14-service-console-enhancement.md
木炎 c60cd308ca feat: service console auto-connect, settings panel, and batch of enhancements
- 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]
2026-04-14 14:32:46 +08:00

763 lines
27 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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<String>,
#[serde(rename = "baseUrl", default)]
pub base_url: Option<String>,
#[serde(default)]
pub model: Option<String>,
#[serde(rename = "skillsDir", default)]
pub skills_dir: Option<String>,
#[serde(rename = "directSubmitSkill", default)]
pub direct_submit_skill: Option<String>,
#[serde(rename = "runtimeProfile", default)]
pub runtime_profile: Option<String>,
#[serde(rename = "browserBackend", default)]
pub browser_backend: Option<String>,
}
```
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<String>,
#[serde(rename = "directSubmitSkill", skip_serializing_if = "Option::is_none")]
direct_submit_skill: Option<String>,
#[serde(rename = "skillsPromptMode", skip_serializing_if = "Option::is_none")]
skills_prompt_mode: Option<String>,
#[serde(rename = "runtimeProfile", skip_serializing_if = "Option::is_none")]
runtime_profile: Option<String>,
#[serde(rename = "plannerMode", skip_serializing_if = "Option::is_none")]
planner_mode: Option<String>,
#[serde(rename = "activeProvider", skip_serializing_if = "Option::is_none")]
active_provider: Option<String>,
#[serde(rename = "browserBackend", skip_serializing_if = "Option::is_none")]
browser_backend: Option<String>,
#[serde(rename = "officeBackend", skip_serializing_if = "Option::is_none")]
office_backend: Option<String>,
#[serde(rename = "browserWsUrl", skip_serializing_if = "Option::is_none")]
browser_ws_url: Option<String>,
#[serde(rename = "serviceWsListenAddr", skip_serializing_if = "Option::is_none")]
service_ws_listen_addr: Option<String>,
#[serde(default)]
providers: Vec<SerializableProviderSettings>,
}
#[derive(Debug, Serialize)]
struct SerializableProviderSettings {
id: String,
provider: Option<String>,
#[serde(rename = "apiKey")]
api_key: String,
#[serde(rename = "baseUrl", skip_serializing_if = "Option::is_none")]
base_url: Option<String>,
model: String,
#[serde(rename = "apiPath", skip_serializing_if = "Option::is_none")]
api_path: Option<String>,
#[serde(rename = "wireApi", skip_serializing_if = "Option::is_none")]
wire_api: Option<String>,
#[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 `<script>` section, after the existing event listeners and `updateUiState()`, add:
```javascript
// Auto-connect on page load
window.addEventListener("DOMContentLoaded", () => {
connectOrDisconnectService(true);
});
```
- [ ] **Step 2: Add Settings button HTML**
In the sidebar section of the HTML, after the connect button and before the "Composer" section label, add:
```html
<button id="settingsBtn" class="ghost-btn" style="margin-top: 8px;">⚙ 设置</button>
```
- [ ] **Step 3: Add Settings modal HTML**
Before the closing `</body>` tag, add the modal HTML:
```html
<!-- Settings Modal -->
<div id="settingsModal" style="display: none; position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.5); z-index: 1000; align-items: center; justify-content: center;">
<div style="background: var(--panel); border-radius: 20px; padding: 28px; width: min(520px, 90%); max-height: 85vh; overflow-y: auto; box-shadow: var(--shadow);">
<h3 style="margin: 0 0 20px; font-size: 1.2rem;">sgClaw 配置</h3>
<div class="field">
<label for="settingApiKey">API 密钥 *</label>
<input id="settingApiKey" type="password" placeholder="输入模型 API 密钥" />
</div>
<div class="field">
<label for="settingBaseUrl">模型服务地址 *</label>
<input id="settingBaseUrl" type="url" placeholder="例如https://api.deepseek.com" />
</div>
<div class="field">
<label for="settingModel">模型名称 *</label>
<input id="settingModel" type="text" placeholder="例如deepseek-chat" />
</div>
<div class="field">
<label for="settingSkillsDir">Skills 目录路径</label>
<input id="settingSkillsDir" type="text" placeholder="例如D:/data/ideaSpace/rust/sgClaw/claw/claw/skills/skill_staging/skills" />
</div>
<div class="field">
<label for="settingDirectSubmitSkill">直接提交技能</label>
<input id="settingDirectSubmitSkill" type="text" placeholder="例如tq-lineloss-report.collect_lineloss" />
</div>
<div class="field">
<label for="settingRuntimeProfile">运行模式</label>
<select id="settingRuntimeProfile" style="width: 100%; border: 1px solid var(--line); border-radius: 16px; padding: 14px 16px; background: rgba(255, 255, 255, 0.92); color: var(--text); font: inherit;">
<option value="browser-attached">browser-attached</option>
<option value="browser-heavy">browser-heavy</option>
<option value="general-assistant">general-assistant</option>
</select>
</div>
<div class="field">
<label for="settingBrowserBackend">浏览器后端</label>
<select id="settingBrowserBackend" style="width: 100%; border: 1px solid var(--line); border-radius: 16px; padding: 14px 16px; background: rgba(255, 255, 255, 0.92); color: var(--text); font: inherit;">
<option value="super-rpa">super-rpa</option>
<option value="agent-browser">agent-browser</option>
<option value="rust-native">rust-native</option>
<option value="computer-use">computer-use</option>
<option value="auto">auto</option>
</select>
</div>
<div id="settingsValidation" style="color: var(--error); font-size: 0.92rem; min-height: 1.4em; margin: 10px 0;"></div>
<div style="display: flex; gap: 12px; margin-top: 16px;">
<button id="settingsSaveBtn" class="primary-btn" style="flex: 1;">保存</button>
<button id="settingsCancelBtn" class="ghost-btn" style="flex: 1;">取消</button>
</div>
</div>
</div>
```
- [ ] **Step 4: Add settings modal CSS**
Add these CSS rules inside the `<style>` block, before the `@media` query:
```css
/* Settings modal elements */
select {
width: 100%;
border: 1px solid var(--line);
border-radius: 16px;
padding: 14px 16px;
background: rgba(255, 255, 255, 0.92);
color: var(--text);
font: inherit;
outline: none;
cursor: pointer;
}
select:focus {
border-color: rgba(15, 118, 110, 0.5);
box-shadow: 0 0 0 4px rgba(15, 118, 110, 0.12);
}
```
- [ ] **Step 5: Add settings modal JavaScript logic**
Add this JavaScript at the end of the `<script>` section, before the closing `</script>` 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)