- 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]
458 lines
14 KiB
Rust
458 lines
14 KiB
Rust
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};
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
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,
|
|
pub period_mode_code: String,
|
|
pub period_value: String,
|
|
pub period_payload: String,
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub enum DeterministicSubmitDecision {
|
|
NotDeterministic,
|
|
Prompt { summary: String },
|
|
Execute(DeterministicExecutionPlan),
|
|
}
|
|
|
|
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(
|
|
raw_instruction: &str,
|
|
page_url: Option<&str>,
|
|
page_title: Option<&str>,
|
|
) -> DeterministicSubmitDecision {
|
|
let Some(instruction) = strip_exact_deterministic_suffix(raw_instruction) else {
|
|
return DeterministicSubmitDecision::NotDeterministic;
|
|
};
|
|
|
|
let normalized_instruction = instruction.trim();
|
|
if normalized_instruction.is_empty() {
|
|
return unsupported_scene_prompt();
|
|
}
|
|
|
|
if !matches_lineloss_scene(normalized_instruction) {
|
|
return unsupported_scene_prompt();
|
|
}
|
|
|
|
let resolved_org = match crate::compat::tq_lineloss::org_resolver::resolve_org_from_instruction(
|
|
normalized_instruction,
|
|
) {
|
|
Ok(Some(resolved_org)) => resolved_org,
|
|
Ok(None) => {
|
|
return DeterministicSubmitDecision::Prompt {
|
|
summary: crate::compat::tq_lineloss::contracts::missing_company_prompt(),
|
|
};
|
|
}
|
|
Err(summary) => {
|
|
return DeterministicSubmitDecision::Prompt { summary };
|
|
}
|
|
};
|
|
|
|
let resolved_period = match crate::compat::tq_lineloss::period_resolver::resolve_period(
|
|
normalized_instruction,
|
|
) {
|
|
Ok(resolved_period) => resolved_period,
|
|
Err(summary) => {
|
|
return DeterministicSubmitDecision::Prompt { summary };
|
|
}
|
|
};
|
|
|
|
if page_context_conflicts_with_lineloss(page_url, page_title) {
|
|
return DeterministicSubmitDecision::Prompt {
|
|
summary:
|
|
"已命中台区线损报表技能,但当前页面与台区线损场景不匹配,请切换到线损页面后重试。"
|
|
.to_string(),
|
|
};
|
|
}
|
|
|
|
DeterministicSubmitDecision::Execute(DeterministicExecutionPlan {
|
|
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(),
|
|
period_mode_code: resolved_period.mode_code,
|
|
period_value: resolved_period.value,
|
|
period_payload: serde_json::to_string(&resolved_period.payload)
|
|
.unwrap_or_else(|_| "{}".to_string()),
|
|
})
|
|
}
|
|
|
|
pub fn execute_deterministic_submit<T: Transport + 'static>(
|
|
browser_tool: BrowserPipeTool<T>,
|
|
plan: &DeterministicExecutionPlan,
|
|
workspace_root: &Path,
|
|
settings: &SgClawSettings,
|
|
) -> Result<DirectSubmitOutcome, PipeError> {
|
|
let args = deterministic_submit_args(plan);
|
|
let output = crate::compat::direct_skill_runtime::execute_browser_script_skill_raw_output(
|
|
browser_tool,
|
|
&plan.tool_name,
|
|
workspace_root,
|
|
settings,
|
|
args,
|
|
)?;
|
|
|
|
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(
|
|
browser_backend: Arc<dyn BrowserBackend>,
|
|
plan: &DeterministicExecutionPlan,
|
|
workspace_root: &Path,
|
|
settings: &SgClawSettings,
|
|
) -> Result<DirectSubmitOutcome, PipeError> {
|
|
let args = deterministic_submit_args(plan);
|
|
let output =
|
|
crate::compat::direct_skill_runtime::execute_browser_script_skill_raw_output_with_browser_backend(
|
|
browser_backend,
|
|
&plan.tool_name,
|
|
workspace_root,
|
|
settings,
|
|
args,
|
|
)?;
|
|
|
|
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> {
|
|
let mut args = Map::new();
|
|
args.insert(
|
|
"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()),
|
|
);
|
|
args.insert(
|
|
"org_code".to_string(),
|
|
Value::String(plan.org_code.clone()),
|
|
);
|
|
args.insert(
|
|
"period_mode".to_string(),
|
|
Value::String(plan.period_mode.clone()),
|
|
);
|
|
args.insert(
|
|
"period_mode_code".to_string(),
|
|
Value::String(plan.period_mode_code.clone()),
|
|
);
|
|
args.insert(
|
|
"period_value".to_string(),
|
|
Value::String(plan.period_value.clone()),
|
|
);
|
|
args.insert(
|
|
"period_payload".to_string(),
|
|
Value::String(plan.period_payload.clone()),
|
|
);
|
|
args
|
|
}
|
|
|
|
fn summarize_lineloss_output(output: &str) -> DirectSubmitOutcome {
|
|
let Some(payload) = serde_json::from_str::<Value>(output).ok() else {
|
|
return DirectSubmitOutcome {
|
|
success: true,
|
|
summary: output.to_string(),
|
|
};
|
|
};
|
|
|
|
let artifact = payload
|
|
.as_object()
|
|
.and_then(|object| object.get("text"))
|
|
.unwrap_or(&payload);
|
|
|
|
summarize_lineloss_artifact(artifact)
|
|
}
|
|
|
|
fn summarize_lineloss_artifact(artifact: &Value) -> DirectSubmitOutcome {
|
|
let Some(artifact) = artifact.as_object() else {
|
|
return DirectSubmitOutcome {
|
|
success: true,
|
|
summary: artifact.to_string(),
|
|
};
|
|
};
|
|
|
|
if artifact.get("type").and_then(Value::as_str) != Some("report-artifact") {
|
|
return DirectSubmitOutcome {
|
|
success: true,
|
|
summary: Value::Object(artifact.clone()).to_string(),
|
|
};
|
|
}
|
|
|
|
let status = artifact
|
|
.get("status")
|
|
.and_then(Value::as_str)
|
|
.unwrap_or("ok");
|
|
let success = matches!(status, "ok" | "partial" | "empty");
|
|
let report_name = artifact
|
|
.get("report_name")
|
|
.and_then(Value::as_str)
|
|
.unwrap_or("tq-lineloss-report");
|
|
let org_label = artifact
|
|
.get("org")
|
|
.and_then(Value::as_object)
|
|
.and_then(|org| org.get("label"))
|
|
.and_then(Value::as_str)
|
|
.unwrap_or("");
|
|
let period_value = artifact
|
|
.get("period")
|
|
.and_then(Value::as_object)
|
|
.and_then(|period| period.get("value"))
|
|
.and_then(Value::as_str)
|
|
.unwrap_or("");
|
|
let rows = artifact
|
|
.get("counts")
|
|
.and_then(Value::as_object)
|
|
.and_then(|counts| counts.get("rows"))
|
|
.and_then(Value::as_u64)
|
|
.map(|value| value as usize)
|
|
.or_else(|| artifact.get("rows").and_then(Value::as_array).map(Vec::len))
|
|
.unwrap_or(0);
|
|
let reasons = artifact
|
|
.get("reasons")
|
|
.and_then(Value::as_array)
|
|
.map(|reasons| {
|
|
reasons
|
|
.iter()
|
|
.filter_map(Value::as_str)
|
|
.filter(|value| !value.trim().is_empty())
|
|
.collect::<Vec<_>>()
|
|
})
|
|
.unwrap_or_default();
|
|
|
|
let mut parts = vec![report_name.to_string()];
|
|
if !org_label.is_empty() {
|
|
parts.push(org_label.to_string());
|
|
}
|
|
if !period_value.is_empty() {
|
|
parts.push(period_value.to_string());
|
|
}
|
|
parts.push(format!("status={status}"));
|
|
parts.push(format!("rows={rows}"));
|
|
if !reasons.is_empty() {
|
|
parts.push(format!("reasons={}", reasons.join(",")));
|
|
}
|
|
|
|
DirectSubmitOutcome {
|
|
success,
|
|
summary: parts.join(" "),
|
|
}
|
|
}
|
|
|
|
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('。') {
|
|
return None;
|
|
}
|
|
Some(without_suffix)
|
|
}
|
|
|
|
fn matches_lineloss_scene(instruction: &str) -> bool {
|
|
instruction.contains("线损") || instruction.contains("月累计") || instruction.contains("周累计")
|
|
}
|
|
|
|
fn page_context_conflicts_with_lineloss(page_url: Option<&str>, page_title: Option<&str>) -> bool {
|
|
let url = page_url.unwrap_or_default().to_ascii_lowercase();
|
|
let title = page_title.unwrap_or_default();
|
|
let has_context = !url.is_empty() || !title.is_empty();
|
|
if !has_context {
|
|
return false;
|
|
}
|
|
|
|
let url_matches = url.contains(LINELLOSS_HOST) || url.contains("lineloss");
|
|
let title_matches = title.contains("线损");
|
|
|
|
!(url_matches || title_matches)
|
|
}
|
|
|
|
fn period_mode_name(mode: &crate::compat::tq_lineloss::contracts::PeriodMode) -> &'static str {
|
|
match mode {
|
|
crate::compat::tq_lineloss::contracts::PeriodMode::Month => "month",
|
|
crate::compat::tq_lineloss::contracts::PeriodMode::Week => "week",
|
|
}
|
|
}
|
|
|
|
fn unsupported_scene_prompt() -> DeterministicSubmitDecision {
|
|
DeterministicSubmitDecision::Prompt {
|
|
summary: "确定性提交当前只支持台区线损月/周累计线损率报表场景,请补充台区线损请求。"
|
|
.to_string(),
|
|
}
|
|
}
|