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

27 KiB
Raw Blame History

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:

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):

    UpdateConfig {
        config: ConfigUpdatePayload,
    },
  • Step 2: Add ConfigUpdated variant to ServiceMessage

Add after Pong:

    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:

            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():

    pub fn config_path(&self) -> Option<&Path> {
        self.config_path.as_deref()
    }

Add the import at the top of the file if not present:

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:

#[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):

#[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;):

use serde::{Deserialize, Serialize};
  • Step 3: Add to_serializable() method to SgClawSettings

In the impl SgClawSettings block, add:

    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:

    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:

            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:

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:

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:

    // 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:

        <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:

  <!-- 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:

    /* 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:

    // 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:

        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:

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:

    // 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)