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]
This commit is contained in:
762
docs/superpowers/plans/2026-04-14-service-console-enhancement.md
Normal file
762
docs/superpowers/plans/2026-04-14-service-console-enhancement.md
Normal file
@@ -0,0 +1,762 @@
|
||||
# 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)
|
||||
Reference in New Issue
Block a user