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:
木炎
2026-04-14 14:32:46 +08:00
parent 6aa0c110bd
commit c60cd308ca
31 changed files with 4883 additions and 18 deletions

View File

@@ -1,10 +1,12 @@
use std::path::Path;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use serde_json::{Map, Value};
use crate::browser::BrowserBackend;
use crate::compat::artifact_open::{open_exported_xlsx, PostExportOpen};
use crate::compat::direct_skill_runtime::DirectSubmitOutcome;
use crate::compat::lineloss_xlsx_export::{export_lineloss_xlsx, LinelossExportRequest};
use crate::config::SgClawSettings;
use crate::pipe::{BrowserPipeTool, PipeError, Transport};
@@ -13,6 +15,7 @@ pub struct DeterministicExecutionPlan {
pub instruction: String,
pub tool_name: String,
pub expected_domain: String,
pub target_url: String,
pub org_label: String,
pub org_code: String,
pub period_mode: String,
@@ -30,6 +33,7 @@ pub enum DeterministicSubmitDecision {
const DETERMINISTIC_SUFFIX: &str = "。。。";
const LINELLOSS_HOST: &str = "20.76.57.61";
const LINELLOSS_TARGET_URL: &str = "http://20.76.57.61:18080/gsllys/tqLinelossStatis/tqQualifyRateMonitor";
const LINELLOSS_TOOL: &str = "tq-lineloss-report.collect_lineloss";
pub fn decide_deterministic_submit(
@@ -85,6 +89,7 @@ pub fn decide_deterministic_submit(
instruction: normalized_instruction.to_string(),
tool_name: LINELLOSS_TOOL.to_string(),
expected_domain: LINELLOSS_HOST.to_string(),
target_url: LINELLOSS_TARGET_URL.to_string(),
org_label: resolved_org.label,
org_code: resolved_org.code,
period_mode: period_mode_name(&resolved_period.mode).to_string(),
@@ -110,7 +115,8 @@ pub fn execute_deterministic_submit<T: Transport + 'static>(
args,
)?;
Ok(summarize_lineloss_output(&output))
let export_path = try_export_lineloss_xlsx(&output, workspace_root);
Ok(summarize_lineloss_output_with_export(&output, export_path.as_deref()))
}
pub fn execute_deterministic_submit_with_browser_backend(
@@ -129,7 +135,8 @@ pub fn execute_deterministic_submit_with_browser_backend(
args,
)?;
Ok(summarize_lineloss_output(&output))
let export_path = try_export_lineloss_xlsx(&output, workspace_root);
Ok(summarize_lineloss_output_with_export(&output, export_path.as_deref()))
}
fn deterministic_submit_args(plan: &DeterministicExecutionPlan) -> Map<String, Value> {
@@ -138,6 +145,10 @@ fn deterministic_submit_args(plan: &DeterministicExecutionPlan) -> Map<String, V
"expected_domain".to_string(),
Value::String(plan.expected_domain.clone()),
);
args.insert(
"target_url".to_string(),
Value::String(plan.target_url.clone()),
);
args.insert(
"org_label".to_string(),
Value::String(plan.org_label.clone()),
@@ -256,6 +267,155 @@ fn summarize_lineloss_artifact(artifact: &Value) -> DirectSubmitOutcome {
}
}
fn summarize_lineloss_output_with_export(output: &str, export_path: Option<&Path>) -> DirectSubmitOutcome {
let mut outcome = summarize_lineloss_output(output);
if let Some(path) = export_path {
outcome.summary.push_str(&format!(" export_path={}", path.display()));
match open_exported_xlsx(path) {
PostExportOpen::Opened => {
outcome.summary.push_str(" 已自动打开Excel");
}
PostExportOpen::Failed(reason) => {
outcome.summary.push_str(&format!(" 自动打开Excel失败: {}", reason));
}
}
}
outcome
}
struct LinelossArtifactExportData {
sheet_name: String,
column_defs: Vec<(String, String)>,
rows: Vec<Map<String, Value>>,
}
fn extract_export_data(output: &str) -> Option<LinelossArtifactExportData> {
let payload: Value = serde_json::from_str(output).ok()?;
let artifact = payload
.as_object()
.and_then(|object| object.get("text"))
.unwrap_or(&payload);
let artifact = artifact.as_object()?;
if artifact.get("type").and_then(Value::as_str) != Some("report-artifact") {
return None;
}
let status = artifact.get("status").and_then(Value::as_str).unwrap_or("");
if !matches!(status, "ok" | "partial") {
return None;
}
let rows = artifact
.get("rows")
.and_then(Value::as_array)?;
if rows.is_empty() {
return None;
}
let rows: Vec<Map<String, Value>> = rows
.iter()
.filter_map(|row| row.as_object().cloned())
.collect();
if rows.is_empty() {
return None;
}
let column_defs: Vec<(String, String)> = artifact
.get("column_defs")
.and_then(Value::as_array)
.map(|defs| {
defs.iter()
.filter_map(|def| {
let arr = def.as_array()?;
let key = arr.first()?.as_str()?.to_string();
let label = arr.get(1)?.as_str()?.to_string();
Some((key, label))
})
.collect()
})
.unwrap_or_default();
// Fallback: if column_defs not in artifact, try "columns" array as keys
let column_defs = if column_defs.is_empty() {
let columns = artifact
.get("columns")
.and_then(Value::as_array)?;
columns
.iter()
.filter_map(|col| {
let key = col.as_str()?.to_string();
Some((key.clone(), key))
})
.collect()
} else {
column_defs
};
if column_defs.is_empty() {
return None;
}
let org_label = artifact
.get("org")
.and_then(Value::as_object)
.and_then(|org| org.get("label"))
.and_then(Value::as_str)
.unwrap_or("lineloss");
let period_mode = artifact
.get("period")
.and_then(Value::as_object)
.and_then(|p| p.get("mode"))
.and_then(Value::as_str)
.unwrap_or("month");
let period_value = artifact
.get("period")
.and_then(Value::as_object)
.and_then(|p| p.get("value"))
.and_then(Value::as_str)
.unwrap_or("");
let mode_label = if period_mode == "week" { "周度" } else { "月度" };
let sheet_name = format!("{org_label}{mode_label}线损分析报表({period_value})");
Some(LinelossArtifactExportData {
sheet_name,
column_defs,
rows,
})
}
fn try_export_lineloss_xlsx(
output: &str,
workspace_root: &Path,
) -> Option<PathBuf> {
let data = extract_export_data(output)?;
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or_default();
let out_dir = workspace_root.join("out");
let output_path = out_dir.join(format!("tq-lineloss-{nanos}.xlsx"));
let request = LinelossExportRequest {
sheet_name: data.sheet_name,
column_defs: data.column_defs,
rows: data.rows,
output_path,
};
match export_lineloss_xlsx(&request) {
Ok(path) => {
eprintln!("[deterministic_submit] XLSX exported to: {}", path.display());
Some(path)
}
Err(err) => {
eprintln!("[deterministic_submit] XLSX export failed: {err}");
None
}
}
}
fn strip_exact_deterministic_suffix(raw_instruction: &str) -> Option<&str> {
let without_suffix = raw_instruction.strip_suffix(DETERMINISTIC_SUFFIX)?;
if without_suffix.ends_with('。') {