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,5 +1,5 @@
use std::ffi::OsString;
use std::path::PathBuf;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use crate::browser::BrowserBackend;
@@ -64,6 +64,10 @@ impl AgentRuntimeContext {
.map_err(|err| PipeError::Protocol(err.to_string()))
}
pub fn config_path(&self) -> Option<&Path> {
self.config_path.as_deref()
}
fn settings_source_label(&self) -> String {
match &self.config_path {
Some(path) if path.exists() => path.display().to_string(),

View File

@@ -84,6 +84,13 @@ fn run() -> Result<(), String> {
break;
}
ServiceMessage::Pong => {}
ServiceMessage::ConfigUpdated { success, message } => {
if success {
println!("config updated: {message}");
} else {
eprintln!("config update failed: {message}");
}
}
}
}
Message::Close(_) => {

View File

@@ -436,14 +436,16 @@ fn build_eval_js(source_url: &str, script: &str) -> String {
let events_url = escape_js_single_quoted(&events_endpoint_url(source_url));
format!(
"(async function(){{try{{\
var v=await (async function(){{return {script}}})();\
if(v&&typeof v.then==='function'){{v=await v;}}\
"(function(){{try{{\
var v=(function(){{return {script}}})();\
function _s(v){{\
var t=(typeof v==='string')?v:JSON.stringify(v);\
try{{callBackJsToCpp('{escaped_source_url}@_@'+window.location.href+'@_@{callback}@_@sgBrowserExcuteJsCodeByDomain@_@'+(t??''))}}catch(_){{}}\
var j=JSON.stringify({{type:'callback',callback:'{callback}',request_url:'{escaped_source_url}',payload:{{value:(t??'')}}}});\
try{{var r=new XMLHttpRequest();r.open('POST','{events_url}',true);r.setRequestHeader('Content-Type','application/json');r.send(j)}}catch(_){{}}\
try{{navigator.sendBeacon('{events_url}',new Blob([j],{{type:'application/json'}}))}}catch(_){{}}\
}}\
if(v&&typeof v.then==='function'){{v.then(_s).catch(function(){{}});}}else{{_s(v);}}\
}}catch(e){{}}}})()"
)
}

View File

@@ -234,11 +234,15 @@ fn execute_browser_script_impl(
let wrapped_script = wrap_browser_script(&script_body, &Value::Object(args.clone()));
eprintln!("[execute_browser_script_impl] 包装后脚本长度: {} 字节", wrapped_script.len());
eprintln!("[execute_browser_script_impl] 包装后脚本前500字符: {}",
eprintln!("[execute_browser_script_impl] 包装后脚本前500字符: {}",
if wrapped_script.len() > 500 { &wrapped_script[..500] } else { &wrapped_script });
eprintln!("[execute_browser_script_impl] 调用 browser_tool.invoke(Action::Eval)...");
let target_url = format!("http://{}", expected_domain);
let target_url = args.get("target_url")
.and_then(|v| v.as_str())
.map(|s| s.to_string())
.unwrap_or_else(|| format!("http://{}", expected_domain));
eprintln!("[execute_browser_script_impl] target_url: {}", target_url);
let result = match browser_tool.invoke(
Action::Eval,
json!({

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('。') {

View File

@@ -330,8 +330,7 @@ fn derive_expected_domain(task_context: &CompatTaskContext) -> Result<String, Pi
.filter(|value| !value.is_empty())
.ok_or_else(|| {
PipeError::Protocol(
"direct submit skill requires page_url so expected_domain can be derived"
.to_string(),
"当前命令需要浏览器页面上下文才能执行。请在浏览器中打开目标页面后重试,或在指令末尾添加'。。。'使用确定性提交。".to_string(),
)
})?;

View File

@@ -0,0 +1,223 @@
use std::fs;
use std::io::Write;
use std::path::{Path, PathBuf};
use serde_json::{Map, Value};
use zip::write::FileOptions;
use zip::{CompressionMethod, ZipWriter};
pub struct LinelossExportRequest {
pub sheet_name: String,
pub column_defs: Vec<(String, String)>,
pub rows: Vec<Map<String, Value>>,
pub output_path: PathBuf,
}
pub fn export_lineloss_xlsx(request: &LinelossExportRequest) -> anyhow::Result<PathBuf> {
if request.rows.is_empty() {
anyhow::bail!("rows must not be empty");
}
if request.column_defs.is_empty() {
anyhow::bail!("column_defs must not be empty");
}
let sheet_xml = build_worksheet_xml(&request.column_defs, &request.rows);
write_xlsx(
&request.output_path,
&request.sheet_name,
&sheet_xml,
)?;
Ok(request.output_path.clone())
}
fn build_worksheet_xml(
column_defs: &[(String, String)],
rows: &[Map<String, Value>],
) -> String {
let mut xml_rows = Vec::with_capacity(rows.len() + 1);
// Header row (row 1)
let header_cells: Vec<String> = column_defs
.iter()
.enumerate()
.map(|(col_idx, (_key, label))| {
let col_letter = column_letter(col_idx);
format!(
"<c r=\"{col_letter}1\" t=\"inlineStr\"><is><t>{}</t></is></c>",
xml_escape(label)
)
})
.collect();
xml_rows.push(format!("<row r=\"1\">{}</row>", header_cells.join("")));
// Data rows (row 2+)
for (row_idx, row) in rows.iter().enumerate() {
let excel_row = row_idx + 2;
let cells: Vec<String> = column_defs
.iter()
.enumerate()
.map(|(col_idx, (key, _label))| {
let col_letter = column_letter(col_idx);
let value = row
.get(key)
.map(|v| value_to_string(v))
.unwrap_or_default();
format!(
"<c r=\"{col_letter}{excel_row}\" t=\"inlineStr\"><is><t>{}</t></is></c>",
xml_escape(&value)
)
})
.collect();
xml_rows.push(format!("<row r=\"{excel_row}\">{}</row>", cells.join("")));
}
format!(
"<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>\
<worksheet xmlns=\"http://schemas.openxmlformats.org/spreadsheetml/2006/main\">\
<sheetData>{}</sheetData>\
</worksheet>",
xml_rows.join("")
)
}
fn column_letter(index: usize) -> String {
let mut result = String::new();
let mut n = index;
loop {
result.insert(0, (b'A' + (n % 26) as u8) as char);
if n < 26 {
break;
}
n = n / 26 - 1;
}
result
}
fn value_to_string(value: &Value) -> String {
match value {
Value::String(text) => text.clone(),
Value::Number(number) => number.to_string(),
Value::Bool(flag) => flag.to_string(),
Value::Null => String::new(),
other => other.to_string(),
}
}
fn xml_escape(value: &str) -> String {
value
.replace('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
}
fn write_xlsx(output_path: &Path, sheet_name: &str, sheet_xml: &str) -> anyhow::Result<()> {
if let Some(parent) = output_path.parent() {
fs::create_dir_all(parent)?;
}
if output_path.exists() {
fs::remove_file(output_path)?;
}
let file = fs::File::create(output_path)?;
let mut zip = ZipWriter::new(file);
let options = FileOptions::default().compression_method(CompressionMethod::Stored);
zip.start_file("[Content_Types].xml", options)?;
zip.write_all(content_types_xml().as_bytes())?;
zip.start_file("_rels/.rels", options)?;
zip.write_all(root_rels_xml().as_bytes())?;
zip.start_file("docProps/app.xml", options)?;
zip.write_all(app_xml().as_bytes())?;
zip.start_file("docProps/core.xml", options)?;
zip.write_all(core_xml().as_bytes())?;
zip.start_file("xl/workbook.xml", options)?;
zip.write_all(workbook_xml(&xml_escape(sheet_name)).as_bytes())?;
zip.start_file("xl/_rels/workbook.xml.rels", options)?;
zip.write_all(workbook_rels_xml().as_bytes())?;
zip.start_file("xl/worksheets/sheet1.xml", options)?;
zip.write_all(sheet_xml.as_bytes())?;
zip.finish()?;
Ok(())
}
fn content_types_xml() -> &'static str {
r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">
<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>
<Default Extension="xml" ContentType="application/xml"/>
<Override PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/>
<Override PartName="/xl/worksheets/sheet1.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>
<Override PartName="/docProps/core.xml" ContentType="application/vnd.openxmlformats-package.core-properties+xml"/>
<Override PartName="/docProps/app.xml" ContentType="application/vnd.openxmlformats-officedocument.extended-properties+xml"/>
</Types>"#
}
fn root_rels_xml() -> &'static str {
r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="xl/workbook.xml"/>
<Relationship Id="rId2" Type="http://schemas.openxmlformats.org/package/2006/relationships/metadata/core-properties" Target="docProps/core.xml"/>
<Relationship Id="rId3" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/extended-properties" Target="docProps/app.xml"/>
</Relationships>"#
}
fn app_xml() -> &'static str {
r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Properties xmlns="http://schemas.openxmlformats.org/officeDocument/2006/extended-properties"
xmlns:vt="http://schemas.openxmlformats.org/officeDocument/2006/docPropsVTypes">
<Application>sgClaw</Application>
</Properties>"#
}
fn core_xml() -> &'static str {
r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<cp:coreProperties xmlns:cp="http://schemas.openxmlformats.org/package/2006/metadata/core-properties"
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:dcterms="http://purl.org/dc/terms/"
xmlns:dcmitype="http://purl.org/dc/dcmitype/"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
<dc:title>台区线损报表</dc:title>
</cp:coreProperties>"#
}
fn workbook_xml(sheet_name: &str) -> String {
format!(
r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"
xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">
<sheets>
<sheet name="{sheet_name}" sheetId="1" r:id="rId1"/>
</sheets>
</workbook>"#
)
}
fn workbook_rels_xml() -> &'static str {
r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="worksheets/sheet1.xml"/>
</Relationships>"#
}
#[cfg(test)]
mod tests {
use super::column_letter;
#[test]
fn column_letter_maps_indices_correctly() {
assert_eq!(column_letter(0), "A");
assert_eq!(column_letter(1), "B");
assert_eq!(column_letter(6), "G");
assert_eq!(column_letter(25), "Z");
assert_eq!(column_letter(26), "AA");
}
}

View File

@@ -6,6 +6,7 @@ pub mod cron_adapter;
pub mod deterministic_submit;
pub mod direct_skill_runtime;
pub mod event_bridge;
pub mod lineloss_xlsx_export;
pub mod memory_adapter;
pub mod openxml_office_tool;
pub mod orchestration;

View File

@@ -5,29 +5,598 @@ pub(crate) struct OrgUnit {
}
pub(crate) const ORG_UNITS: &[OrgUnit] = &[
// ===== Province-level =====
OrgUnit {
label: "国网甘肃省电力公司",
code: "62101",
aliases: &["国网甘肃省电力公司", "甘肃省电力公司", "甘肃电力公司", "甘肃省公司"],
},
// ===== City-level (lv=2) =====
OrgUnit {
label: "国网兰州供电公司",
code: "62401",
aliases: &["国网兰州供电公司", "兰州供电公司", "兰州公司"],
},
OrgUnit {
label: "国网白银供电公司",
code: "62402",
aliases: &["国网白银供电公司", "白银供电公司", "白银公司"],
},
OrgUnit {
label: "国网天水供电公司",
code: "62403",
aliases: &["国网天水供电公司", "天水供电公司", "天水公司"],
},
OrgUnit {
label: "国网平凉供电公司",
code: "62404",
aliases: &["国网平凉供电公司", "平凉供电公司", "平凉公司"],
},
OrgUnit {
label: "国网金昌供电公司",
code: "62405",
aliases: &["国网金昌供电公司", "金昌供电公司", "金昌公司"],
},
OrgUnit {
label: "国网张掖供电公司",
code: "62406",
aliases: &["国网张掖供电公司", "张掖供电公司", "张掖公司"],
},
OrgUnit {
label: "国网陇南供电公司",
code: "62407",
aliases: &["国网陇南供电公司", "陇南供电公司", "陇南公司"],
},
OrgUnit {
label: "国网定西供电公司",
code: "62408",
aliases: &["国网定西供电公司", "定西供电公司", "定西公司"],
},
OrgUnit {
label: "国网庆阳供电公司",
code: "62409",
aliases: &["国网庆阳供电公司", "庆阳供电公司", "庆阳公司"],
},
OrgUnit {
label: "国网武威供电公司",
code: "62410",
aliases: &["国网武威供电公司", "武威供电公司", "武威公司"],
},
OrgUnit {
label: "国网酒泉供电公司",
code: "62411",
aliases: &["国网酒泉供电公司", "酒泉供电公司", "酒泉公司"],
},
OrgUnit {
label: "国网临夏供电公司",
code: "62412",
aliases: &["国网临夏供电公司", "临夏供电公司", "临夏公司"],
},
OrgUnit {
label: "国网甘南供电公司",
code: "62413",
aliases: &["国网甘南供电公司", "甘南供电公司", "甘南公司"],
},
OrgUnit {
label: "国网嘉峪关供电公司",
code: "62414",
aliases: &["国网嘉峪关供电公司", "嘉峪关供电公司", "嘉峪关公司"],
},
OrgUnit {
label: "国网兰州新区供电公司",
code: "62415",
aliases: &["国网兰州新区供电公司", "兰州新区供电公司", "兰州新区公司"],
},
// ===== 兰州供电公司 children (lv=3) =====
OrgUnit {
label: "城关供电分公司",
code: "6240108",
aliases: &["城关供电分公司", "城关分公司"],
},
OrgUnit {
label: "七里河供电分公司",
code: "6240109",
aliases: &["七里河供电分公司", "七里河分公司"],
},
OrgUnit {
label: "西固供电分公司",
code: "6240107",
aliases: &["西固供电分公司", "西固分公司"],
},
OrgUnit {
label: "安宁供电分公司",
code: "6240111",
aliases: &["安宁供电分公司", "安宁分公司"],
},
OrgUnit {
label: "红古供电分公司",
code: "6240102",
aliases: &["红古供电分公司", "红古分公司"],
},
OrgUnit {
label: "东岗供电分公司",
code: "6240110",
aliases: &["东岗供电分公司", "东岗分公司"],
},
OrgUnit {
label: "国网永登县供电公司",
code: "6240122",
aliases: &["国网永登县供电公司", "永登县供电公司", "永登县公司"],
},
OrgUnit {
label: "国网榆中县供电公司",
code: "6240121",
aliases: &["国网榆中县供电公司", "榆中县供电公司", "榆中县公司"],
},
OrgUnit {
label: "榆中城关供电所",
code: "624012108",
aliases: &["榆中城关供电所"],
label: "国网永靖县供电公司",
code: "6240123",
aliases: &["国网永靖县供电公司", "永靖县供电公司", "永靖县公司"],
},
OrgUnit {
label: "兰州客户服务中心",
code: "6240101",
aliases: &["兰州客户服务中心", "兰州客服中心"],
},
// ===== 白银供电公司 children (lv=3) =====
OrgUnit {
label: "城区供电分公司",
code: "6240201",
aliases: &["城区供电分公司", "城区分公司"],
},
OrgUnit {
label: "国网白银市城区供电分公司",
code: "6240201",
aliases: &["国网白银市城区供电分公司", "白银市城区供电分公司", "白银城区分公司"],
},
OrgUnit {
label: "国网皋兰县供电公司",
code: "6240223",
aliases: &["国网皋兰县供电公司", "皋兰县供电公司", "皋兰县公司"],
},
OrgUnit {
label: "国网靖远县供电公司",
code: "6240221",
aliases: &["国网靖远县供电公司", "靖远县供电公司", "靖远县公司"],
},
OrgUnit {
label: "国网景泰县供电公司",
code: "6240222",
aliases: &["国网景泰县供电公司", "景泰县供电公司", "景泰县公司"],
},
OrgUnit {
label: "国网会宁县供电公司",
code: "6240225",
aliases: &["国网会宁县供电公司", "会宁县供电公司", "会宁县公司"],
},
OrgUnit {
label: "国网白银市平川区供电公司",
code: "6240224",
aliases: &["国网白银市平川区供电公司", "白银市平川区供电公司", "平川区供电公司", "平川区公司"],
},
OrgUnit {
label: "白银客户服务中心",
code: "6240207",
aliases: &["白银客户服务中心", "白银客服中心"],
},
// ===== 天水供电公司 children (lv=3) =====
OrgUnit {
label: "国网天水市秦州区供电公司",
code: "6240323",
aliases: &["国网天水市秦州区供电公司", "天水市秦州区供电公司", "秦州区供电公司", "秦州区公司"],
},
OrgUnit {
label: "秦州区供电公司",
code: "6240323",
aliases: &["秦州区供电公司", "秦州区公司"],
},
OrgUnit {
label: "国网天水市麦积区供电公司",
code: "6240305",
aliases: &["国网天水市麦积区供电公司", "天水市麦积区供电公司", "麦积区供电公司", "麦积区公司"],
},
OrgUnit {
label: "国网麦积区供电公司",
code: "6240305",
aliases: &["国网麦积区供电公司", "麦积区供电公司", "麦积区公司"],
},
OrgUnit {
label: "国网武山县供电公司",
code: "6240321",
aliases: &["国网武山县供电公司", "武山县供电公司", "武山县公司"],
},
OrgUnit {
label: "武山县供电公司",
code: "6240321",
aliases: &["武山县供电公司", "武山县公司"],
},
OrgUnit {
label: "国网甘谷县供电公司",
code: "6240322",
aliases: &["国网甘谷县供电公司", "甘谷县供电公司", "甘谷县公司"],
},
OrgUnit {
label: "甘谷县供电公司",
code: "6240322",
aliases: &["甘谷县供电公司", "甘谷县公司"],
},
OrgUnit {
label: "国网秦安县供电公司",
code: "6240324",
aliases: &["国网秦安县供电公司", "秦安县供电公司", "秦安县公司"],
},
OrgUnit {
label: "清水县供电公司",
code: "6240325",
aliases: &["清水县供电公司", "清水县公司"],
},
OrgUnit {
label: "张家川县供电公司",
code: "6240326",
aliases: &["张家川县供电公司", "张家川县公司"],
},
OrgUnit {
label: "天水客户服务中心",
code: "6240306",
aliases: &["天水客户服务中心", "天水客服中心"],
},
// ===== 平凉供电公司 children (lv=3) =====
OrgUnit {
label: "国网崇信县供电公司",
code: "6240401",
aliases: &["国网崇信县供电公司", "崇信县供电公司", "崇信县公司"],
},
OrgUnit {
label: "国网庄浪县供电公司",
code: "6240402",
aliases: &["国网庄浪县供电公司", "庄浪县供电公司", "庄浪县公司"],
},
OrgUnit {
label: "国网泾川县供电公司",
code: "6240403",
aliases: &["国网泾川县供电公司", "泾川县供电公司", "泾川县公司"],
},
OrgUnit {
label: "国网静宁县供电公司",
code: "6240404",
aliases: &["国网静宁县供电公司", "静宁县供电公司", "静宁县公司"],
},
OrgUnit {
label: "国网崆峒区供电公司",
code: "6240405",
aliases: &["国网崆峒区供电公司", "崆峒区供电公司", "崆峒区公司"],
},
OrgUnit {
label: "国网华亭市公司",
code: "6240407",
aliases: &["国网华亭市公司", "华亭市公司"],
},
OrgUnit {
label: "国网灵台县供电公司",
code: "6240408",
aliases: &["国网灵台县供电公司", "灵台县供电公司", "灵台县公司"],
},
// ===== 金昌供电公司 children (lv=3) =====
OrgUnit {
label: "金川区供电公司",
code: "6240522",
aliases: &["金川区供电公司", "金川区公司"],
},
OrgUnit {
label: "国网永昌县供电公司",
code: "6240523",
aliases: &["国网永昌县供电公司", "永昌县供电公司", "永昌县公司"],
},
OrgUnit {
label: "城区供电服务中心",
code: "6240505",
aliases: &["城区供电服务中心"],
},
OrgUnit {
label: "金昌客户服务中心",
code: "6240507",
aliases: &["金昌客户服务中心", "金昌客服中心"],
},
// ===== 张掖供电公司 children (lv=3) =====
OrgUnit {
label: "国网甘州区供电公司",
code: "6240621",
aliases: &["国网甘州区供电公司", "甘州区供电公司", "甘州区公司"],
},
OrgUnit {
label: "肃南县供电公司",
code: "6240622",
aliases: &["肃南县供电公司", "肃南县公司"],
},
OrgUnit {
label: "国网高台县供电公司",
code: "6240623",
aliases: &["国网高台县供电公司", "高台县供电公司", "高台县公司"],
},
OrgUnit {
label: "国网山丹县供电公司",
code: "6240624",
aliases: &["国网山丹县供电公司", "山丹县供电公司", "山丹县公司"],
},
OrgUnit {
label: "国网民乐县供电公司",
code: "6240625",
aliases: &["国网民乐县供电公司", "民乐县供电公司", "民乐县公司"],
},
OrgUnit {
label: "国网临泽县供电公司",
code: "6240626",
aliases: &["国网临泽县供电公司", "临泽县供电公司", "临泽县公司"],
},
// ===== 陇南供电公司 children (lv=3) =====
OrgUnit {
label: "国网武都区供电公司",
code: "6240701",
aliases: &["国网武都区供电公司", "武都区供电公司", "武都区公司"],
},
OrgUnit {
label: "国网宕昌县供电公司",
code: "6240702",
aliases: &["国网宕昌县供电公司", "宕昌县供电公司", "宕昌县公司"],
},
OrgUnit {
label: "国网文县供电公司",
code: "6240703",
aliases: &["国网文县供电公司", "文县供电公司", "文县公司"],
},
OrgUnit {
label: "国网康县供电公司",
code: "6240704",
aliases: &["国网康县供电公司", "康县供电公司", "康县公司"],
},
OrgUnit {
label: "国网西和县供电公司",
code: "6240705",
aliases: &["国网西和县供电公司", "西和县供电公司", "西和县公司"],
},
OrgUnit {
label: "国网礼县供电公司",
code: "6240706",
aliases: &["国网礼县供电公司", "礼县供电公司", "礼县公司"],
},
OrgUnit {
label: "国网成县供电公司",
code: "6240707",
aliases: &["国网成县供电公司", "成县供电公司", "成县公司"],
},
OrgUnit {
label: "国网徽县供电公司",
code: "6240708",
aliases: &["国网徽县供电公司", "徽县供电公司", "徽县公司"],
},
OrgUnit {
label: "国网两当县供电公司",
code: "6240709",
aliases: &["国网两当县供电公司", "两当县供电公司", "两当县公司"],
},
// ===== 定西供电公司 children (lv=3) =====
OrgUnit {
label: "国网定西市安定区供电公司",
code: "6240801",
aliases: &["国网定西市安定区供电公司", "定西市安定区供电公司", "安定区供电公司", "安定区公司"],
},
OrgUnit {
label: "国网通渭县供电公司",
code: "6240802",
aliases: &["国网通渭县供电公司", "通渭县供电公司", "通渭县公司"],
},
OrgUnit {
label: "国网陇西县供电公司",
code: "6240803",
aliases: &["国网陇西县供电公司", "陇西县供电公司", "陇西县公司"],
},
OrgUnit {
label: "国网渭源县供电公司",
code: "6240804",
aliases: &["国网渭源县供电公司", "渭源县供电公司", "渭源县公司"],
},
OrgUnit {
label: "国网临洮县供电公司",
code: "6240805",
aliases: &["国网临洮县供电公司", "临洮县供电公司", "临洮县公司"],
},
OrgUnit {
label: "国网漳县供电公司",
code: "6240806",
aliases: &["国网漳县供电公司", "漳县供电公司", "漳县公司"],
},
OrgUnit {
label: "国网岷县供电公司",
code: "6240807",
aliases: &["国网岷县供电公司", "岷县供电公司", "岷县公司"],
},
// ===== 庆阳供电公司 children (lv=3) =====
OrgUnit {
label: "西峰区供电公司",
code: "6240901",
aliases: &["西峰区供电公司", "西峰区公司"],
},
OrgUnit {
label: "国网庆城县供电公司",
code: "6240902",
aliases: &["国网庆城县供电公司", "庆城县供电公司", "庆城县公司"],
},
OrgUnit {
label: "国网正宁县供电公司",
code: "6240903",
aliases: &["国网正宁县供电公司", "正宁县供电公司", "正宁县公司"],
},
OrgUnit {
label: "国网镇原县供电公司",
code: "6240904",
aliases: &["国网镇原县供电公司", "镇原县供电公司", "镇原县公司"],
},
OrgUnit {
label: "国网环县供电公司",
code: "6240905",
aliases: &["国网环县供电公司", "环县供电公司", "环县公司"],
},
OrgUnit {
label: "国网华池县供电公司",
code: "6240906",
aliases: &["国网华池县供电公司", "华池县供电公司", "华池县公司"],
},
OrgUnit {
label: "国网合水县供电公司",
code: "6240907",
aliases: &["国网合水县供电公司", "合水县供电公司", "合水县公司"],
},
OrgUnit {
label: "国网宁县供电公司",
code: "6240908",
aliases: &["国网宁县供电公司", "宁县供电公司", "宁县公司"],
},
// ===== 武威供电公司 children (lv=3) =====
OrgUnit {
label: "国网古浪县供电公司",
code: "6241001",
aliases: &["国网古浪县供电公司", "古浪县供电公司", "古浪县公司"],
},
OrgUnit {
label: "国网凉州区供电公司",
code: "6241002",
aliases: &["国网凉州区供电公司", "凉州区供电公司", "凉州区公司"],
},
OrgUnit {
label: "国网民勤县供电公司",
code: "6241003",
aliases: &["国网民勤县供电公司", "民勤县供电公司", "民勤县公司"],
},
OrgUnit {
label: "国网天祝县供电公司",
code: "6241004",
aliases: &["国网天祝县供电公司", "天祝县供电公司", "天祝县公司"],
},
// ===== 酒泉供电公司 children (lv=3) =====
OrgUnit {
label: "国网酒泉市肃州区供电公司",
code: "6241101",
aliases: &["国网酒泉市肃州区供电公司", "酒泉市肃州区供电公司", "肃州区供电公司", "肃州区公司"],
},
OrgUnit {
label: "国网金塔县供电公司",
code: "6241102",
aliases: &["国网金塔县供电公司", "金塔县供电公司", "金塔县公司"],
},
OrgUnit {
label: "国网玉门市供电公司",
code: "6241103",
aliases: &["国网玉门市供电公司", "玉门市供电公司", "玉门市公司"],
},
OrgUnit {
label: "国网瓜州县供电公司",
code: "6241104",
aliases: &["国网瓜州县供电公司", "瓜州县供电公司", "瓜州县公司"],
},
OrgUnit {
label: "国网敦煌市供电公司",
code: "6241105",
aliases: &["国网敦煌市供电公司", "敦煌市供电公司", "敦煌市公司"],
},
OrgUnit {
label: "国网肃北县供电公司",
code: "6241106",
aliases: &["国网肃北县供电公司", "肃北县供电公司", "肃北县公司"],
},
OrgUnit {
label: "国网阿克塞县供电公司",
code: "6241107",
aliases: &["国网阿克塞县供电公司", "阿克塞县供电公司", "阿克塞县公司"],
},
// ===== 临夏供电公司 children (lv=3) =====
OrgUnit {
label: "临夏市城关营业班",
code: "6241201",
aliases: &["临夏市城关营业班"],
},
OrgUnit {
label: "国网临夏县供电公司",
code: "6241202",
aliases: &["国网临夏县供电公司", "临夏县供电公司", "临夏县公司"],
},
OrgUnit {
label: "国网东乡县供电公司",
code: "6241203",
aliases: &["国网东乡县供电公司", "东乡县供电公司", "东乡县公司"],
},
OrgUnit {
label: "国网和政县供电公司",
code: "6241204",
aliases: &["国网和政县供电公司", "和政县供电公司", "和政县公司"],
},
OrgUnit {
label: "国网广河县供电公司",
code: "6241205",
aliases: &["国网广河县供电公司", "广河县供电公司", "广河县公司"],
},
OrgUnit {
label: "国网积石山县供电公司",
code: "6241206",
aliases: &["国网积石山县供电公司", "积石山县供电公司", "积石山县公司"],
},
OrgUnit {
label: "国网康乐县供电公司",
code: "6241207",
aliases: &["国网康乐县供电公司", "康乐县供电公司", "康乐县公司"],
},
// ===== 甘南供电公司 children (lv=3) =====
OrgUnit {
label: "国网合作市供电公司",
code: "6241301",
aliases: &["国网合作市供电公司", "合作市供电公司", "合作市公司"],
},
OrgUnit {
label: "国网夏河县供电公司",
code: "6241302",
aliases: &["国网夏河县供电公司", "夏河县供电公司", "夏河县公司"],
},
OrgUnit {
label: "国网卓尼县供电公司",
code: "6241303",
aliases: &["国网卓尼县供电公司", "卓尼县供电公司", "卓尼县公司"],
},
OrgUnit {
label: "国网临潭县供电公司",
code: "6241304",
aliases: &["国网临潭县供电公司", "临潭县供电公司", "临潭县公司"],
},
OrgUnit {
label: "国网碌曲县供电公司",
code: "6241305",
aliases: &["国网碌曲县供电公司", "碌曲县供电公司", "碌曲县公司"],
},
OrgUnit {
label: "国网玛曲县供电公司",
code: "6241306",
aliases: &["国网玛曲县供电公司", "玛曲县供电公司", "玛曲县公司"],
},
OrgUnit {
label: "国网迭部县供电公司",
code: "6241307",
aliases: &["国网迭部县供电公司", "迭部县供电公司", "迭部县公司"],
},
OrgUnit {
label: "国网舟曲县供电公司",
code: "6241308",
aliases: &["国网舟曲县供电公司", "舟曲县供电公司", "舟曲县公司"],
},
];

View File

@@ -1,5 +1,7 @@
mod settings;
pub use crate::runtime::RuntimeProfile;
pub use settings::{
BrowserBackend, ConfigError, DeepSeekSettings, OfficeBackend, PlannerMode, ProviderSettings,
SgClawSettings, SkillsPromptMode,

View File

@@ -1,6 +1,6 @@
use std::path::{Path, PathBuf};
use serde::Deserialize;
use serde::{Deserialize, Serialize};
use thiserror::Error;
use crate::runtime::RuntimeProfile;
@@ -200,6 +200,65 @@ impl SgClawSettings {
.expect("active_provider should always resolve to a configured provider")
}
fn to_serializable(&self) -> SerializableRawSgClawSettings {
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(match self.skills_prompt_mode {
SkillsPromptMode::Full => "full".to_string(),
SkillsPromptMode::Compact => "compact".to_string(),
}),
runtime_profile: Some(match self.runtime_profile {
RuntimeProfile::BrowserAttached => "browser-attached".to_string(),
RuntimeProfile::BrowserHeavy => "browser-heavy".to_string(),
RuntimeProfile::GeneralAssistant => "general-assistant".to_string(),
}),
planner_mode: Some(match self.planner_mode {
PlannerMode::ZeroclawPlanFirst => "zeroclaw-plan-first".to_string(),
PlannerMode::LegacyDeterministic => "legacy-deterministic".to_string(),
}),
active_provider: Some(self.active_provider.clone()),
browser_backend: Some(match self.browser_backend {
BrowserBackend::SuperRpa => "super-rpa".to_string(),
BrowserBackend::AgentBrowser => "agent-browser".to_string(),
BrowserBackend::RustNative => "rust-native".to_string(),
BrowserBackend::ComputerUse => "computer-use".to_string(),
BrowserBackend::Auto => "auto".to_string(),
}),
office_backend: Some(match self.office_backend {
OfficeBackend::OpenXml => "openxml".to_string(),
OfficeBackend::Disabled => "disabled".to_string(),
}),
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(),
}
}
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()))
}
fn maybe_from_env() -> Result<Option<Self>, ConfigError> {
let api_key = match std::env::var("DEEPSEEK_API_KEY") {
Ok(value) => value,
@@ -529,6 +588,54 @@ fn normalize_enum_token(raw: &str) -> String {
.to_ascii_lowercase()
}
#[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,
}
#[derive(Debug, Deserialize)]
struct RawSgClawSettings {
#[serde(rename = "apiKey", default)]

View File

@@ -14,7 +14,7 @@ use crate::security::MacPolicy;
const DEFAULT_BROWSER_WS_URL: &str = "ws://127.0.0.1:12345";
const DEFAULT_SERVICE_WS_LISTEN_ADDR: &str = "127.0.0.1:42321";
pub use protocol::{ClientMessage, ServiceMessage};
pub use protocol::{ClientMessage, ConfigUpdatePayload, ServiceMessage};
pub use server::{ServiceEventSink, ServiceSession};
pub(crate) mod browser_ws_client {

View File

@@ -3,6 +3,24 @@ use serde::{Deserialize, Serialize};
use crate::agent::SubmitTaskRequest;
use crate::pipe::ConversationMessage;
#[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>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum ClientMessage {
@@ -21,6 +39,9 @@ pub enum ClientMessage {
page_title: String,
},
Ping,
UpdateConfig {
config: ConfigUpdatePayload,
},
}
impl ClientMessage {
@@ -39,7 +60,11 @@ impl ClientMessage {
page_url: normalize_optional_field(page_url),
page_title: normalize_optional_field(page_title),
}),
ClientMessage::Connect | ClientMessage::Start | ClientMessage::Stop | ClientMessage::Ping => None,
ClientMessage::Connect
| ClientMessage::Start
| ClientMessage::Stop
| ClientMessage::Ping
| ClientMessage::UpdateConfig { .. } => None,
}
}
}
@@ -52,6 +77,10 @@ pub enum ServiceMessage {
TaskComplete { success: bool, summary: String },
Busy { message: String },
Pong,
ConfigUpdated {
success: bool,
message: String,
},
}
fn normalize_optional_field(value: String) -> Option<String> {

View File

@@ -1,4 +1,6 @@
use std::net::TcpStream;
use std::path::Path;
use std::path::PathBuf;
use std::sync::{Arc, Mutex};
use std::time::Duration;
@@ -229,6 +231,52 @@ fn send_status_changed(sink: &ServiceEventSink, state: &str) -> Result<(), PipeE
})
}
fn update_config_file(config_path: &Path, config: crate::service::protocol::ConfigUpdatePayload) -> Result<(), String> {
use crate::config::{BrowserBackend, RuntimeProfile, SgClawSettings};
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" => RuntimeProfile::BrowserAttached,
"browser-heavy" => RuntimeProfile::BrowserHeavy,
"general-assistant" => RuntimeProfile::GeneralAssistant,
_ => return Err(format!("无效的 runtimeProfile: {}", v)),
};
}
if let Some(v) = config.browser_backend {
settings.browser_backend = match v.as_str() {
"super-rpa" => BrowserBackend::SuperRpa,
"agent-browser" => BrowserBackend::AgentBrowser,
"rust-native" => BrowserBackend::RustNative,
"computer-use" => BrowserBackend::ComputerUse,
"auto" => BrowserBackend::Auto,
_ => return Err(format!("无效的 browserBackend: {}", v)),
};
}
settings
.save_to_path(config_path)
.map_err(|e| format!("写入配置文件失败: {}", e))
}
pub(crate) fn serve_client(
context: &AgentRuntimeContext,
session: &ServiceSession,
@@ -334,6 +382,39 @@ pub(crate) fn serve_client(
}
}
}
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),
})?;
}
}
}
}
}
}