feat: add generated scene skill platform hardening

This commit is contained in:
木炎
2026-04-21 23:19:06 +08:00
parent 118fc77935
commit 956f0c2b68
439 changed files with 61974 additions and 3645 deletions

View File

@@ -49,7 +49,9 @@ fn browser_backend_for_submit<T: Transport + 'static>(
));
}
Ok(Arc::new(PipeBrowserBackend::from_inner(browser_tool.clone())))
Ok(Arc::new(PipeBrowserBackend::from_inner(
browser_tool.clone(),
)))
}
fn configured_browser_ws_url(context: &AgentRuntimeContext) -> Option<String> {
@@ -142,7 +144,10 @@ mod tests {
#[test]
fn normalize_optional_submit_field_trims_and_drops_blank_values() {
assert_eq!(normalize_optional_submit_field(" \n\t ".to_string()), None);
assert_eq!(
normalize_optional_submit_field(" \n\t ".to_string()),
None
);
assert_eq!(
normalize_optional_submit_field(" https://example.com/page ".to_string()),
Some("https://example.com/page".to_string())

View File

@@ -6,9 +6,7 @@ use crate::browser::BrowserBackend;
use crate::compat::config_adapter::resolve_skills_dir_from_sgclaw_settings;
use crate::compat::runtime::CompatTaskContext;
use crate::config::SgClawSettings;
use crate::pipe::{
AgentMessage, BrowserPipeTool, ConversationMessage, PipeError, Transport,
};
use crate::pipe::{AgentMessage, BrowserPipeTool, ConversationMessage, PipeError, Transport};
use crate::runtime::RuntimeEngine;
#[derive(Debug, Clone, PartialEq, Eq)]
@@ -144,7 +142,14 @@ fn resolve_submit_instruction(
instruction: String,
page_url: Option<&str>,
page_title: Option<&str>,
) -> Result<(String, Option<crate::compat::deterministic_submit::DeterministicExecutionPlan>), AgentMessage> {
skills_dir: &std::path::Path,
) -> Result<
(
String,
Option<crate::compat::deterministic_submit::DeterministicExecutionPlan>,
),
AgentMessage,
> {
let raw_instruction = instruction;
let trimmed_instruction = raw_instruction.trim().to_string();
if trimmed_instruction.is_empty() {
@@ -154,10 +159,11 @@ fn resolve_submit_instruction(
});
}
match crate::compat::deterministic_submit::decide_deterministic_submit(
match crate::compat::deterministic_submit::decide_deterministic_submit_with_skills_dir(
&raw_instruction,
page_url,
page_title,
skills_dir,
) {
crate::compat::deterministic_submit::DeterministicSubmitDecision::NotDeterministic => {
Ok((trimmed_instruction, None))
@@ -195,14 +201,6 @@ pub fn run_submit_task<T: Transport + 'static>(
page_url,
page_title,
};
let (instruction, deterministic_plan) = match resolve_submit_instruction(
instruction,
task_context.page_url.as_deref(),
task_context.page_title.as_deref(),
) {
Ok(resolved) => resolved,
Err(completion) => return sink.send(&completion),
};
let _ = sink.send(&AgentMessage::LogEntry {
level: "info".to_string(),
message: runtime_version_log_message(),
@@ -221,6 +219,15 @@ pub fn run_submit_task<T: Transport + 'static>(
Ok(Some(settings)) => {
let resolved_skills_dir =
resolve_skills_dir_from_sgclaw_settings(&context.workspace_root, &settings);
let (instruction, deterministic_plan) = match resolve_submit_instruction(
instruction,
task_context.page_url.as_deref(),
task_context.page_title.as_deref(),
&resolved_skills_dir,
) {
Ok(resolved) => resolved,
Err(completion) => return sink.send(&completion),
};
let _ = sink.send(&AgentMessage::LogEntry {
level: "info".to_string(),
message: format!(
@@ -386,14 +393,6 @@ pub fn run_submit_task_with_browser_backend<T: Transport + 'static>(
page_url,
page_title,
};
let (instruction, deterministic_plan) = match resolve_submit_instruction(
instruction,
task_context.page_url.as_deref(),
task_context.page_title.as_deref(),
) {
Ok(resolved) => resolved,
Err(completion) => return sink.send(&completion),
};
let _ = sink.send(&AgentMessage::LogEntry {
level: "info".to_string(),
message: runtime_version_log_message(),
@@ -412,6 +411,15 @@ pub fn run_submit_task_with_browser_backend<T: Transport + 'static>(
Ok(Some(settings)) => {
let resolved_skills_dir =
resolve_skills_dir_from_sgclaw_settings(&context.workspace_root, &settings);
let (instruction, deterministic_plan) = match resolve_submit_instruction(
instruction,
task_context.page_url.as_deref(),
task_context.page_title.as_deref(),
&resolved_skills_dir,
) {
Ok(resolved) => resolved,
Err(completion) => return sink.send(&completion),
};
let _ = sink.send(&AgentMessage::LogEntry {
level: "info".to_string(),
message: format!(

View File

@@ -75,7 +75,10 @@ fn run() -> Result<(), String> {
ServiceMessage::LogEntry { level: _, message } => {
println!("{message}");
}
ServiceMessage::TaskComplete { success: _, summary } => {
ServiceMessage::TaskComplete {
success: _,
summary,
} => {
println!("{summary}");
break;
}

View File

@@ -2,7 +2,8 @@ use std::env;
use std::path::PathBuf;
use sgclaw::generated_scene::analyzer::SceneKind;
use sgclaw::generated_scene::generator::{generate_scene_package, GenerateSceneRequest, SceneInfoJson};
use sgclaw::generated_scene::generator::{generate_scene_package, GenerateSceneRequest};
use sgclaw::generated_scene::ir::{LegacySceneInfoJson, SceneIr};
fn main() {
if let Err(err) = run() {
@@ -13,10 +14,16 @@ fn main() {
fn run() -> Result<(), String> {
let args = parse_args(env::args().skip(1))?;
let scene_info: Option<SceneInfoJson> = args.scene_info_json
let scene_info: Option<LegacySceneInfoJson> = args
.scene_info_json
.map(|json| serde_json::from_str(&json))
.transpose()
.map_err(|e| format!("Invalid scene-info-json: {}", e))?;
let scene_ir: Option<SceneIr> = args
.scene_ir_json
.map(|json| serde_json::from_str(&json))
.transpose()
.map_err(|e| format!("Invalid scene-ir-json: {}", e))?;
let skill_root = generate_scene_package(GenerateSceneRequest {
source_dir: args.source_dir,
scene_id: args.scene_id,
@@ -26,6 +33,7 @@ fn run() -> Result<(), String> {
output_root: args.output_root,
lessons_path: args.lessons_path,
scene_info_json: scene_info,
scene_ir_json: scene_ir,
})
.map_err(|err| err.to_string())?;
@@ -42,6 +50,7 @@ struct CliArgs {
output_root: PathBuf,
lessons_path: Option<PathBuf>,
scene_info_json: Option<String>,
scene_ir_json: Option<String>,
}
fn parse_args(args: impl Iterator<Item = String>) -> Result<CliArgs, String> {
@@ -53,6 +62,7 @@ fn parse_args(args: impl Iterator<Item = String>) -> Result<CliArgs, String> {
let mut output_root = None;
let mut lessons_path = None;
let mut scene_info_json = None;
let mut scene_ir_json = None;
let mut pending_flag: Option<String> = None;
for arg in args {
@@ -71,6 +81,7 @@ fn parse_args(args: impl Iterator<Item = String>) -> Result<CliArgs, String> {
"--output-root" => output_root = Some(PathBuf::from(arg)),
"--lessons" => lessons_path = Some(PathBuf::from(arg)),
"--scene-info-json" => scene_info_json = Some(arg),
"--scene-ir-json" => scene_ir_json = Some(arg),
_ => return Err(format!("unsupported argument {flag}")),
}
continue;
@@ -78,7 +89,7 @@ fn parse_args(args: impl Iterator<Item = String>) -> Result<CliArgs, String> {
match arg.as_str() {
"--source-dir" | "--scene-id" | "--scene-name" | "--scene-kind" | "--target-url"
| "--output-root" | "--lessons" | "--scene-info-json" => {
| "--output-root" | "--lessons" | "--scene-info-json" | "--scene-ir-json" => {
pending_flag = Some(arg);
}
"--help" | "-h" => return Err(usage()),
@@ -99,9 +110,10 @@ fn parse_args(args: impl Iterator<Item = String>) -> Result<CliArgs, String> {
output_root: output_root.ok_or_else(usage)?,
lessons_path,
scene_info_json,
scene_ir_json,
})
}
fn usage() -> String {
"usage: sg_scene_generate --source-dir <scenario-dir> --scene-id <scene-id> --scene-name <display-name> [--scene-kind <report_collection|monitoring>] [--target-url <url>] --output-root <skill-staging-root> [--lessons <lessons-toml>] [--scene-info-json '<json>']".to_string()
"usage: sg_scene_generate --source-dir <scenario-dir> --scene-id <scene-id> --scene-name <display-name> [--scene-kind <report_collection|monitoring>] [--target-url <url>] --output-root <skill-staging-root> [--lessons <lessons-toml>] [--scene-info-json '<json>'] [--scene-ir-json '<json>']".to_string()
}

View File

@@ -1,5 +1,5 @@
use std::sync::Arc;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Arc;
use serde_json::Value;

View File

@@ -18,7 +18,10 @@ const LOCAL_DASHBOARD_SOURCE: &str = "compat.workflow_executor";
const LOCAL_DASHBOARD_KIND_ZHIHU_HOTLIST_SCREEN: &str = "zhihu_hotlist_screen";
pub trait BrowserCallbackHost: Send + Sync {
fn execute(&self, request: BrowserCallbackRequest) -> Result<BrowserCallbackResponse, PipeError>;
fn execute(
&self,
request: BrowserCallbackRequest,
) -> Result<BrowserCallbackResponse, PipeError>;
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
@@ -183,9 +186,13 @@ impl BrowserCallbackBackend {
self.current_target_url
.lock()
.map_err(|_| PipeError::Protocol("callback backend target url lock poisoned".to_string()))?
.map_err(|_| {
PipeError::Protocol("callback backend target url lock poisoned".to_string())
})?
.clone()
.ok_or_else(|| PipeError::Protocol(format!("target_url is required for {}", action.as_str())))
.ok_or_else(|| {
PipeError::Protocol(format!("target_url is required for {}", action.as_str()))
})
}
fn execute_simulated_click(
@@ -194,10 +201,9 @@ impl BrowserCallbackBackend {
expected_domain: &str,
success: &BrowserCallbackSuccess,
) -> Result<BrowserCallbackSuccess, PipeError> {
let probe = success
.data
.get("probe")
.ok_or_else(|| PipeError::Protocol("callback click probe payload missing".to_string()))?;
let probe = success.data.get("probe").ok_or_else(|| {
PipeError::Protocol("callback click probe payload missing".to_string())
})?;
let x = probe
.get("x")
.and_then(Value::as_f64)
@@ -248,10 +254,9 @@ impl BrowserCallbackBackend {
params: &Value,
success: &BrowserCallbackSuccess,
) -> Result<BrowserCallbackSuccess, PipeError> {
let probe = success
.data
.get("probe")
.ok_or_else(|| PipeError::Protocol("callback type probe payload missing".to_string()))?;
let probe = success.data.get("probe").ok_or_else(|| {
PipeError::Protocol("callback type probe payload missing".to_string())
})?;
let x = probe
.get("x")
.and_then(Value::as_f64)
@@ -307,7 +312,8 @@ impl BrowserBackend for BrowserCallbackBackend {
params: Value,
expected_domain: &str,
) -> Result<CommandOutput, PipeError> {
if let Some(local_dashboard) = approved_local_dashboard_request(&action, &params, expected_domain)
if let Some(local_dashboard) =
approved_local_dashboard_request(&action, &params, expected_domain)
{
self.mac_policy
.validate_local_dashboard_presentation(
@@ -335,7 +341,9 @@ impl BrowserBackend for BrowserCallbackBackend {
match reply {
BrowserCallbackResponse::Success(success) => {
let success = match action {
Action::Click => self.execute_simulated_click(seq, expected_domain, &success)?,
Action::Click => {
self.execute_simulated_click(seq, expected_domain, &success)?
}
Action::Type => {
self.execute_simulated_type(seq, expected_domain, &params, &success)?
}
@@ -349,7 +357,9 @@ impl BrowserBackend for BrowserCallbackBackend {
.filter(|value| !value.is_empty())
{
*self.current_target_url.lock().map_err(|_| {
PipeError::Protocol("callback backend target url lock poisoned".to_string())
PipeError::Protocol(
"callback backend target url lock poisoned".to_string(),
)
})? = Some(url.to_string());
}
}
@@ -524,10 +534,7 @@ fn events_endpoint_url(helper_page_url: &str) -> String {
/// Extract the domain from a URL.
/// e.g. "https://www.zhihu.com/hot" → "www.zhihu.com"
fn extract_domain(url: &str) -> Result<String, PipeError> {
let after_scheme = url
.find("://")
.map(|i| &url[i + 3..])
.unwrap_or(url);
let after_scheme = url.find("://").map(|i| &url[i + 3..]).unwrap_or(url);
let domain = after_scheme
.split('/')
.next()
@@ -627,7 +634,10 @@ mod tests {
}
impl BrowserCallbackHost for FakeCallbackHost {
fn execute(&self, request: BrowserCallbackRequest) -> Result<BrowserCallbackResponse, PipeError> {
fn execute(
&self,
request: BrowserCallbackRequest,
) -> Result<BrowserCallbackResponse, PipeError> {
self.requests.lock().unwrap().push(request);
self.replies
.lock()
@@ -674,15 +684,18 @@ mod tests {
assert!(output.success);
let requests = host.requests();
assert_eq!(requests.len(), 2);
assert_eq!(requests[1].command, json!([
"http://127.0.0.1:17888/sgclaw/browser-helper.html",
"sgBroewserSimulateMouse",
320.5,
240.25,
"left",
"",
""
]));
assert_eq!(
requests[1].command,
json!([
"http://127.0.0.1:17888/sgclaw/browser-helper.html",
"sgBroewserSimulateMouse",
320.5,
240.25,
"left",
"",
""
])
);
}
#[test]
@@ -740,21 +753,27 @@ mod tests {
let requests = host.requests();
assert_eq!(requests.len(), 2);
assert_eq!(requests[0].action, "click");
assert_eq!(requests[0].command[1], json!("sgBrowserExcuteJsCodeByDomain"));
assert_eq!(
requests[0].command[1],
json!("sgBrowserExcuteJsCodeByDomain")
);
assert_eq!(requests[0].command[2], json!("zhuanlan.zhihu.com"));
let script = requests[0].command[3].as_str().unwrap();
assert!(script.contains("document.querySelector('button')"));
assert!(script.contains("sgclawOnClick"));
assert_eq!(requests[1].action, "click");
assert_eq!(requests[1].command, json!([
"http://127.0.0.1:17888/sgclaw/browser-helper.html",
"sgBroewserSimulateMouse",
320.5,
240.25,
"left",
"",
""
]));
assert_eq!(
requests[1].command,
json!([
"http://127.0.0.1:17888/sgclaw/browser-helper.html",
"sgBroewserSimulateMouse",
320.5,
240.25,
"left",
"",
""
])
);
}
#[test]
@@ -783,13 +802,16 @@ mod tests {
assert!(output.success);
let requests = host.requests();
assert_eq!(requests.len(), 2);
assert_eq!(requests[1].command, json!([
"http://127.0.0.1:17888/sgclaw/browser-helper.html",
"sgBroewserSimulateKeyborad",
160.0,
90.0,
"正文"
]));
assert_eq!(
requests[1].command,
json!([
"http://127.0.0.1:17888/sgclaw/browser-helper.html",
"sgBroewserSimulateKeyborad",
160.0,
90.0,
"正文"
])
);
}
#[test]
@@ -822,13 +844,16 @@ mod tests {
let script = requests[0].command[3].as_str().unwrap();
assert!(script.contains("return document.body;"));
assert!(!script.contains("selector not found: div[contenteditable='true']"));
assert_eq!(requests[1].command, json!([
"http://127.0.0.1:17888/sgclaw/browser-helper.html",
"sgBroewserSimulateKeyborad",
160.0,
90.0,
"正文"
]));
assert_eq!(
requests[1].command,
json!([
"http://127.0.0.1:17888/sgclaw/browser-helper.html",
"sgBroewserSimulateKeyborad",
160.0,
90.0,
"正文"
])
);
}
#[test]
@@ -859,20 +884,26 @@ mod tests {
let requests = host.requests();
assert_eq!(requests.len(), 2);
assert_eq!(requests[0].action, "type");
assert_eq!(requests[0].command[1], json!("sgBrowserExcuteJsCodeByDomain"));
assert_eq!(
requests[0].command[1],
json!("sgBrowserExcuteJsCodeByDomain")
);
assert_eq!(requests[0].command[2], json!("zhuanlan.zhihu.com"));
let script = requests[0].command[3].as_str().unwrap();
assert!(script.contains("document.querySelector('div[contenteditable=\\'true\\']')"));
assert!(script.contains("sgclawOnType"));
assert!(!script.contains("el.value="));
assert_eq!(requests[1].action, "type");
assert_eq!(requests[1].command, json!([
"http://127.0.0.1:17888/sgclaw/browser-helper.html",
"sgBroewserSimulateKeyborad",
160.0,
90.0,
"正文"
]));
assert_eq!(
requests[1].command,
json!([
"http://127.0.0.1:17888/sgclaw/browser-helper.html",
"sgBroewserSimulateKeyborad",
160.0,
90.0,
"正文"
])
);
}
#[test]
@@ -905,11 +936,14 @@ mod tests {
assert!(output.success);
let requests = host.requests();
assert_eq!(requests.len(), 1);
assert_eq!(requests[0].command, json!([
"http://127.0.0.1:17888/sgclaw/browser-helper.html",
"sgBrowerserOpenPage",
"file:///C:/tmp/zhihu-hotlist-screen.html"
]));
assert_eq!(
requests[0].command,
json!([
"http://127.0.0.1:17888/sgclaw/browser-helper.html",
"sgBrowerserOpenPage",
"file:///C:/tmp/zhihu-hotlist-screen.html"
])
);
}
#[test]
@@ -945,9 +979,15 @@ mod tests {
let raw = "第一行\n第二行\r\n第三行";
let escaped = escape_js_single_quoted(raw);
assert!(!escaped.contains('\n'), "literal newline must be escaped");
assert!(!escaped.contains('\r'), "literal carriage return must be escaped");
assert!(
!escaped.contains('\r'),
"literal carriage return must be escaped"
);
assert!(escaped.contains("\\n"), "should contain escaped newline");
assert!(escaped.contains("\\r"), "should contain escaped carriage return");
assert!(
escaped.contains("\\r"),
"should contain escaped carriage return"
);
assert_eq!(escaped, "第一行\\n第二行\\r\\n第三行");
}

View File

@@ -40,6 +40,12 @@ pub(crate) struct BrowserCallbackHost {
state: Mutex<CallbackHostState>,
}
#[derive(Debug)]
pub(crate) struct CallbackHostStartupError {
pub(crate) source: PipeError,
pub(crate) logs: Vec<String>,
}
#[derive(Debug)]
#[allow(dead_code)]
pub(crate) struct LiveBrowserCallbackHost {
@@ -55,6 +61,8 @@ pub(crate) struct LiveBrowserCallbackHost {
#[derive(Debug, Default)]
struct CallbackHostState {
ready: bool,
helper_loaded: bool,
startup_logs: Vec<String>,
pending_ready_event: Option<CallbackEvent>,
pending_results: VecDeque<CallbackResult>,
pending_commands: VecDeque<CallbackCommand>,
@@ -136,7 +144,7 @@ impl BrowserCallbackHost {
let origin = normalize_loopback_origin(loopback_origin.as_ref());
let browser_ws_url = browser_ws_url.as_ref().to_string();
let helper_url = format!("{origin}{HELPER_PAGE_PATH}");
let helper_page_html = build_helper_page_html(&origin, &helper_url, &browser_ws_url);
let helper_page_html = build_runtime_console_html(&origin, &helper_url, &browser_ws_url);
Self {
helper_url,
@@ -157,6 +165,20 @@ impl BrowserCallbackHost {
self.state.lock().unwrap().ready
}
pub(crate) fn helper_loaded(&self) -> bool {
self.state.lock().unwrap().helper_loaded
}
pub(crate) fn record_startup_log(&self, message: impl Into<String>) {
let message = message.into();
eprintln!("[sgclaw callback-host startup] {message}");
self.state.lock().unwrap().startup_logs.push(message);
}
pub(crate) fn take_startup_logs(&self) -> Vec<String> {
std::mem::take(&mut self.state.lock().unwrap().startup_logs)
}
pub(crate) fn mark_ready(&self, helper_url: Option<String>) {
let mut state = self.state.lock().unwrap();
if state.ready {
@@ -164,6 +186,9 @@ impl BrowserCallbackHost {
}
state.ready = true;
state
.startup_logs
.push("callback-host helper ready callback received".to_string());
state.pending_ready_event = Some(CallbackEvent::Ready { helper_url });
}
@@ -172,7 +197,21 @@ impl BrowserCallbackHost {
}
pub(crate) fn push_result(&self, result: CallbackResult) {
self.state.lock().unwrap().pending_results.push_back(result);
let mut state = self.state.lock().unwrap();
eprintln!(
"[sgclaw callback-host] event received callback={} target_url={:?} action={:?} payload_keys={}",
result.callback,
result.target_url,
result.action,
payload_keys(&result.payload)
);
if result.callback == NAVIGATE_CALLBACK_NAME && !state.helper_loaded {
state.helper_loaded = true;
state
.startup_logs
.push("callback-host helper loaded callback received".to_string());
}
state.pending_results.push_back(result);
}
pub(crate) fn take_result(&self) -> Option<CallbackResult> {
@@ -180,17 +219,36 @@ impl BrowserCallbackHost {
}
pub(crate) fn clear_results(&self) {
self.state.lock().unwrap().pending_results.clear();
let mut state = self.state.lock().unwrap();
let pending_results = state.pending_results.len();
state.pending_results.clear();
eprintln!("[sgclaw callback-host] clear_results pending_results_cleared={pending_results}");
}
pub(crate) fn enqueue_command(&self, command: CallbackCommand) {
self.state.lock().unwrap().pending_commands.push_back(command);
let action = command.action.clone();
let args_len = command.args.len();
let mut state = self.state.lock().unwrap();
state.pending_commands.push_back(command);
eprintln!(
"[sgclaw callback-host] enqueue_command action={action} args_len={args_len} pending_commands={} in_flight={}",
state.pending_commands.len(),
state.in_flight_command.is_some()
);
}
pub(crate) fn current_command_envelope(&self) -> CallbackCommandEnvelope {
let mut state = self.state.lock().unwrap();
if state.in_flight_command.is_none() {
state.in_flight_command = state.pending_commands.pop_front();
if let Some(command) = state.in_flight_command.as_ref() {
eprintln!(
"[sgclaw callback-host] helper picked command action={} args_len={} remaining_pending={}",
command.action,
command.args.len(),
state.pending_commands.len()
);
}
}
CallbackCommandEnvelope {
@@ -200,7 +258,18 @@ impl BrowserCallbackHost {
}
pub(crate) fn acknowledge_in_flight_command(&self) -> Option<CallbackCommand> {
self.state.lock().unwrap().in_flight_command.take()
let mut state = self.state.lock().unwrap();
let command = state.in_flight_command.take();
if let Some(command) = command.as_ref() {
eprintln!(
"[sgclaw callback-host] helper ack command action={} args_len={}",
command.action,
command.args.len()
);
} else {
eprintln!("[sgclaw callback-host] helper ack with no in-flight command");
}
command
}
/// Clear all pending state so the host can be reused for the next task
@@ -210,6 +279,7 @@ impl BrowserCallbackHost {
state.pending_results.clear();
state.pending_commands.clear();
state.in_flight_command = None;
state.startup_logs.clear();
}
}
@@ -220,29 +290,68 @@ impl LiveBrowserCallbackHost {
ready_timeout: Duration,
result_timeout: Duration,
use_hidden_domain: bool,
) -> Result<Self, PipeError> {
let listener = TcpListener::bind("127.0.0.1:0").map_err(|err| {
PipeError::Protocol(format!("failed to bind callback host listener: {err}"))
})?;
listener.set_nonblocking(true).map_err(|err| {
PipeError::Protocol(format!("failed to configure callback host listener: {err}"))
})?;
) -> Result<Self, CallbackHostStartupError> {
let listener =
TcpListener::bind("127.0.0.1:0").map_err(|err| CallbackHostStartupError {
source: PipeError::Protocol(format!(
"failed to bind callback host listener: {err}"
)),
logs: vec![
"callback-host start_with_browser_ws_url begin".to_string(),
format!("callback-host listener bind failed: {err}"),
],
})?;
listener
.set_nonblocking(true)
.map_err(|err| CallbackHostStartupError {
source: PipeError::Protocol(format!(
"failed to configure callback host listener: {err}"
)),
logs: vec![
"callback-host start_with_browser_ws_url begin".to_string(),
format!("callback-host listener configure failed: {err}"),
],
})?;
let origin = format!(
"http://{}",
listener.local_addr().map_err(|err| {
PipeError::Protocol(format!(
"failed to resolve callback host listener address: {err}"
))
})?
listener
.local_addr()
.map_err(|err| CallbackHostStartupError {
source: PipeError::Protocol(format!(
"failed to resolve callback host listener address: {err}"
)),
logs: vec![
"callback-host start_with_browser_ws_url begin".to_string(),
format!("callback-host listener address resolve failed: {err}"),
],
})?
);
let host = Arc::new(BrowserCallbackHost::with_urls(&origin, browser_ws_url));
host.record_startup_log("callback-host start_with_browser_ws_url begin");
host.record_startup_log("callback-host listener ready");
let shutdown = Arc::new(AtomicBool::new(false));
let thread_host = host.clone();
let thread_shutdown = shutdown.clone();
let server_thread = thread::spawn(move || serve_loop(listener, thread_host, thread_shutdown));
let server_thread =
thread::spawn(move || serve_loop(listener, thread_host, thread_shutdown));
bootstrap_helper_page(browser_ws_url, bootstrap_request_url, host.helper_url(), use_hidden_domain)?;
wait_for_helper_ready(host.as_ref(), ready_timeout)?;
bootstrap_helper_page(
host.as_ref(),
browser_ws_url,
bootstrap_request_url,
host.helper_url(),
use_hidden_domain,
)
.map_err(|source| CallbackHostStartupError {
source,
logs: host.take_startup_logs(),
})?;
wait_for_helper_ready(host.as_ref(), ready_timeout).map_err(|source| {
CallbackHostStartupError {
source,
logs: host.take_startup_logs(),
}
})?;
let live_host = Self {
host,
@@ -281,10 +390,18 @@ fn command_is_fire_and_forget(request: &BrowserCallbackRequest) -> bool {
}
impl BrowserCallbackExecutor for LiveBrowserCallbackHost {
fn execute(&self, request: BrowserCallbackRequest) -> Result<BrowserCallbackResponse, PipeError> {
fn execute(
&self,
request: BrowserCallbackRequest,
) -> Result<BrowserCallbackResponse, PipeError> {
let _command_guard = self.command_lock.lock().unwrap();
eprintln!(
"[sgclaw callback-host] execute begin seq={} action={} expected_domain={} request_url={}",
request.seq, request.action, request.expected_domain, request.request_url
);
self.host.clear_results();
self.host.enqueue_command(command_from_request(&request.command)?);
self.host
.enqueue_command(command_from_request(&request.command)?);
// Navigate uses sgBrowerserOpenPage which opens a new tab without a JS
// callback. Simulated mouse/keyboard follow-up commands also do not emit
@@ -299,18 +416,40 @@ impl BrowserCallbackExecutor for LiveBrowserCallbackHost {
};
let started = Instant::now();
let mut saw_unmatched_result = false;
while started.elapsed() < timeout {
if let Some(result) = self.host.take_result() {
if let Some(response) =
normalize_callback_result(&request, result, started.elapsed())
{
let callback = result.callback.clone();
let response = normalize_callback_result(&request, result, started.elapsed());
if let Some(response) = response {
eprintln!(
"[sgclaw callback-host] execute matched callback seq={} action={} callback={} elapsed_ms={}",
request.seq,
request.action,
callback,
started.elapsed().as_millis()
);
return Ok(response);
}
saw_unmatched_result = true;
eprintln!(
"[sgclaw callback-host] execute ignored unmatched callback seq={} action={} callback={} elapsed_ms={}",
request.seq,
request.action,
callback,
started.elapsed().as_millis()
);
}
thread::sleep(COMMAND_POLL_INTERVAL);
}
if is_fire_and_forget {
eprintln!(
"[sgclaw callback-host] execute fire-and-forget complete seq={} action={} elapsed_ms={}",
request.seq,
request.action,
started.elapsed().as_millis()
);
return Ok(BrowserCallbackResponse::Success(BrowserCallbackSuccess {
success: true,
data: json!({ "loaded": true }),
@@ -319,6 +458,13 @@ impl BrowserCallbackExecutor for LiveBrowserCallbackHost {
}));
}
eprintln!(
"[sgclaw callback-host] execute timeout seq={} action={} timeout_ms={} saw_unmatched_result={}",
request.seq,
request.action,
timeout.as_millis(),
saw_unmatched_result
);
Err(PipeError::Timeout)
}
}
@@ -343,11 +489,13 @@ fn normalize_loopback_origin(origin: &str) -> String {
}
fn bootstrap_helper_page(
host: &BrowserCallbackHost,
browser_ws_url: &str,
request_url: &str,
helper_url: &str,
use_hidden_domain: bool,
) -> Result<(), PipeError> {
host.record_startup_log("callback-host bootstrap_helper_page begin");
let (mut websocket, _) = connect(browser_ws_url)
.map_err(|err| PipeError::Protocol(format!("browser websocket connect failed: {err}")))?;
configure_bootstrap_socket(&mut websocket)?;
@@ -368,15 +516,11 @@ fn bootstrap_helper_page(
let close_payload = json!([request_url, close_action, helper_url]).to_string();
let _ = websocket.send(Message::Text(close_payload.into()));
let payload = json!([
request_url,
open_action,
helper_url,
])
.to_string();
let payload = json!([request_url, open_action, helper_url]).to_string();
websocket
.send(Message::Text(payload.into()))
.map_err(|err| PipeError::Protocol(format!("helper bootstrap send failed: {err}")))?;
host.record_startup_log("callback-host bootstrap hidden-open sent");
Ok(())
}
@@ -386,9 +530,11 @@ fn recv_bootstrap_prelude(
loop {
match websocket.read() {
Ok(Message::Text(_)) | Ok(Message::Binary(_)) | Ok(Message::Frame(_)) => return Ok(()),
Ok(Message::Ping(payload)) => websocket
.send(Message::Pong(payload))
.map_err(|err| PipeError::Protocol(format!("browser websocket pong failed: {err}")))?,
Ok(Message::Ping(payload)) => {
websocket.send(Message::Pong(payload)).map_err(|err| {
PipeError::Protocol(format!("browser websocket pong failed: {err}"))
})?
}
Ok(Message::Pong(_)) => {}
Ok(Message::Close(_)) => return Err(PipeError::PipeClosed),
Err(tungstenite::Error::ConnectionClosed) | Err(tungstenite::Error::AlreadyClosed) => {
@@ -424,15 +570,25 @@ fn configure_bootstrap_socket(
}
}
fn wait_for_helper_ready(host: &BrowserCallbackHost, ready_timeout: Duration) -> Result<(), PipeError> {
fn wait_for_helper_ready(
host: &BrowserCallbackHost,
ready_timeout: Duration,
) -> Result<(), PipeError> {
host.record_startup_log("callback-host wait_for_helper_ready begin");
let started = Instant::now();
while started.elapsed() < ready_timeout {
if host.is_ready() {
host.record_startup_log("callback-host wait_for_helper_ready ready");
return Ok(());
}
thread::sleep(HELPER_POLL_INTERVAL);
}
host.record_startup_log(format!(
"callback-host wait_for_helper_ready timeout (helper_loaded={}, ready={})",
host.helper_loaded(),
host.is_ready()
));
Err(PipeError::Timeout)
}
@@ -469,16 +625,18 @@ fn handle_request(stream: &mut TcpStream, host: &BrowserCallbackHost) -> Result<
host.helper_page_html().as_bytes(),
),
("POST", READY_ENDPOINT_PATH) => {
let payload: IncomingReadyEvent = serde_json::from_slice(&request.body).map_err(|err| {
PipeError::Protocol(format!("invalid callback host ready payload: {err}"))
})?;
let payload: IncomingReadyEvent =
serde_json::from_slice(&request.body).map_err(|err| {
PipeError::Protocol(format!("invalid callback host ready payload: {err}"))
})?;
host.mark_ready(payload.helper_url);
write_json_response(stream, &json!({ "ok": true }))
}
("POST", EVENTS_ENDPOINT_PATH) => {
let payload: IncomingCallbackEvent = serde_json::from_slice(&request.body).map_err(|err| {
PipeError::Protocol(format!("invalid callback host event payload: {err}"))
})?;
let payload: IncomingCallbackEvent =
serde_json::from_slice(&request.body).map_err(|err| {
PipeError::Protocol(format!("invalid callback host event payload: {err}"))
})?;
host.push_result(CallbackResult {
callback: payload.callback,
request_url: payload.request_url,
@@ -507,7 +665,9 @@ fn read_http_request(stream: &mut TcpStream) -> Result<HttpRequest, PipeError> {
while headers_end.is_none() {
let mut chunk = [0_u8; 1024];
let bytes = stream.read(&mut chunk).map_err(|err| {
PipeError::Protocol(format!("failed to read callback host request headers: {err}"))
PipeError::Protocol(format!(
"failed to read callback host request headers: {err}"
))
})?;
if bytes == 0 {
return Err(PipeError::PipeClosed);
@@ -590,7 +750,9 @@ fn write_http_response(
.write_all(headers.as_bytes())
.and_then(|_| stream.write_all(body))
.and_then(|_| stream.flush())
.map_err(|err| PipeError::Protocol(format!("failed to write callback host response: {err}")))
.map_err(|err| {
PipeError::Protocol(format!("failed to write callback host response: {err}"))
})
}
fn write_cors_preflight(stream: &mut TcpStream) -> Result<(), PipeError> {
@@ -604,12 +766,27 @@ fn write_cors_preflight(stream: &mut TcpStream) -> Result<(), PipeError> {
stream
.write_all(headers.as_bytes())
.and_then(|_| stream.flush())
.map_err(|err| PipeError::Protocol(format!("failed to write CORS preflight response: {err}")))
.map_err(|err| {
PipeError::Protocol(format!("failed to write CORS preflight response: {err}"))
})
}
fn payload_keys(payload: &Value) -> String {
match payload {
Value::Object(map) => map.keys().cloned().collect::<Vec<_>>().join(","),
Value::Array(values) => format!("array({})", values.len()),
Value::String(_) => "string".to_string(),
Value::Number(_) => "number".to_string(),
Value::Bool(_) => "bool".to_string(),
Value::Null => "null".to_string(),
}
}
fn command_from_request(command: &Value) -> Result<CallbackCommand, PipeError> {
let values = command.as_array().ok_or_else(|| {
PipeError::Protocol(format!("callback host command must be an array, got {command}"))
PipeError::Protocol(format!(
"callback host command must be an array, got {command}"
))
})?;
if values.len() < 2 {
return Err(PipeError::Protocol(format!(
@@ -621,7 +798,9 @@ fn command_from_request(command: &Value) -> Result<CallbackCommand, PipeError> {
.map(str::trim)
.filter(|value| !value.is_empty())
.ok_or_else(|| {
PipeError::Protocol(format!("callback host command action is invalid: {command}"))
PipeError::Protocol(format!(
"callback host command action is invalid: {command}"
))
})?
.to_string();
Ok(CallbackCommand {
@@ -663,7 +842,11 @@ fn normalize_callback_result(
"type" if result.callback == TYPE_PROBE_CALLBACK_NAME => {
let x = result.payload.get("x").and_then(Value::as_f64)?;
let y = result.payload.get("y").and_then(Value::as_f64)?;
let text = result.payload.get("text").and_then(Value::as_str).unwrap_or_default();
let text = result
.payload
.get("text")
.and_then(Value::as_str)
.unwrap_or_default();
Some(BrowserCallbackResponse::Success(BrowserCallbackSuccess {
success: true,
data: json!({
@@ -734,7 +917,10 @@ fn normalize_callback_result(
let probe: Value = serde_json::from_str(&parsed.response_text).ok()?;
let x = probe.get("x").and_then(Value::as_f64)?;
let y = probe.get("y").and_then(Value::as_f64)?;
let text = probe.get("text").and_then(Value::as_str).unwrap_or_default();
let text = probe
.get("text")
.and_then(Value::as_str)
.unwrap_or_default();
Some(BrowserCallbackResponse::Success(BrowserCallbackSuccess {
success: true,
data: json!({
@@ -806,6 +992,182 @@ fn elapsed_timing(elapsed: Duration) -> Timing {
fn build_helper_page_html(loopback_origin: &str, helper_url: &str, browser_ws_url: &str) -> String {
format!(
r#"<!doctype html>
<html>
<head>
<meta charset="utf-8"/>
<title>sgClaw Browser Helper</title>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
</head>
<body>
<script>
const SGCLAW_LOOPBACK_ORIGIN = {loopback_origin:?};
const SGCLAW_HELPER_URL = {helper_url:?};
const SGCLAW_BROWSER_WS_URL = {browser_ws_url:?};
const SGCLAW_READY_ENDPOINT = `${{SGCLAW_LOOPBACK_ORIGIN}}{READY_ENDPOINT_PATH}`;
const SGCLAW_EVENTS_ENDPOINT = `${{SGCLAW_LOOPBACK_ORIGIN}}{EVENTS_ENDPOINT_PATH}`;
const SGCLAW_COMMANDS_ENDPOINT = `${{SGCLAW_LOOPBACK_ORIGIN}}{COMMANDS_ENDPOINT_PATH}`;
const SGCLAW_COMMAND_ACK_ENDPOINT = `${{SGCLAW_LOOPBACK_ORIGIN}}{COMMAND_ACK_ENDPOINT_PATH}`;
async function sgclawPostJson(url, body) {{
await fetch(url, {{
method: 'POST',
headers: {{ 'content-type': 'application/json' }},
body: JSON.stringify(body)
}});
}}
async function sgclawReady() {{
await sgclawPostJson(SGCLAW_READY_ENDPOINT, {{
type: 'ready',
helper_url: window.location.href || SGCLAW_HELPER_URL
}});
}}
async function sgclawEmitCallback(callback, payload, extra) {{
await sgclawPostJson(SGCLAW_EVENTS_ENDPOINT, Object.assign({{
type: 'callback',
callback,
request_url: window.location.href || SGCLAW_HELPER_URL,
payload
}}, extra || {{}}));
}}
function sgclawOnLoaded(targetUrl) {{
return sgclawEmitCallback('sgclawOnLoaded', {{ loaded: true }}, {{ target_url: targetUrl || null }});
}}
function sgclawOnClickProbe(x, y) {{
return sgclawEmitCallback('sgclawOnClickProbe', {{ x: Number(x) || 0, y: Number(y) || 0 }});
}}
function sgclawOnClick() {{
return sgclawEmitCallback('sgclawOnClick', {{ clicked: true }});
}}
function sgclawOnTypeProbe(x, y, text) {{
return sgclawEmitCallback('sgclawOnTypeProbe', {{ x: Number(x) || 0, y: Number(y) || 0, text: text ?? '' }});
}}
function sgclawOnType() {{
return sgclawEmitCallback('sgclawOnType', {{ typed: true }});
}}
function sgclawOnGetText(text, targetUrl) {{
return sgclawEmitCallback('sgclawOnGetText', {{ text: text ?? null }}, {{ target_url: targetUrl || null }});
}}
function sgclawOnEval(value, targetUrl) {{
return sgclawEmitCallback('sgclawOnEval', {{ value: value ?? null }}, {{ target_url: targetUrl || null }});
}}
function callBackJsToCpp(param) {{
const parts = String(param || '').split('@_@');
return sgclawEmitCallback('callBackJsToCpp', {{ raw: String(param || '') }}, {{
target_url: parts[1] || null,
action: parts[3] || null
}});
}}
window.sgclawOnLoaded = sgclawOnLoaded;
window.sgclawOnClickProbe = sgclawOnClickProbe;
window.sgclawOnClick = sgclawOnClick;
window.sgclawOnTypeProbe = sgclawOnTypeProbe;
window.sgclawOnType = sgclawOnType;
window.sgclawOnGetText = sgclawOnGetText;
window.sgclawOnEval = sgclawOnEval;
window.callBackJsToCpp = callBackJsToCpp;
let sgclawSocket = null;
let sgclawReconnectTimer = null;
let sgclawDeferredCommandLogged = false;
function connectSocket() {{
if (sgclawSocket && (sgclawSocket.readyState === WebSocket.OPEN || sgclawSocket.readyState === WebSocket.CONNECTING)) {{
return;
}}
const socket = new WebSocket(SGCLAW_BROWSER_WS_URL);
sgclawSocket = socket;
socket.addEventListener('open', async () => {{
if (sgclawSocket !== socket) {{
return;
}}
if (sgclawReconnectTimer) {{
clearTimeout(sgclawReconnectTimer);
sgclawReconnectTimer = null;
}}
sgclawDeferredCommandLogged = false;
socket.send(JSON.stringify({{ type: 'register', role: 'web' }}));
await sgclawReady();
}});
socket.addEventListener('close', () => {{
if (sgclawSocket !== socket) {{
return;
}}
sgclawSocket = null;
if (!sgclawReconnectTimer) {{
sgclawReconnectTimer = setTimeout(connectSocket, 1000);
}}
}});
socket.addEventListener('message', (event) => {{
if (sgclawSocket !== socket) {{
return;
}}
console.debug('sgclaw helper received browser frame', event.data);
try {{
var data = String(event.data || '');
if (data.indexOf('@_@') !== -1) {{
sgclawEmitCallback('callBackJsToCpp', {{ raw: data }});
}}
}} catch (_e) {{}}
}});
}}
async function sgclawPollCommands() {{
try {{
const response = await fetch(SGCLAW_COMMANDS_ENDPOINT, {{ cache: 'no-store' }});
if (!response.ok) {{
return;
}}
const envelope = await response.json();
const command = envelope && envelope.command;
if (!command || !command.action) {{
sgclawDeferredCommandLogged = false;
return;
}}
if (!sgclawSocket || sgclawSocket.readyState !== WebSocket.OPEN) {{
if (!sgclawDeferredCommandLogged) {{
console.debug('sgclaw helper deferred command until websocket reconnect');
sgclawDeferredCommandLogged = true;
}}
return;
}}
sgclawDeferredCommandLogged = false;
const args = Array.isArray(command.args) ? command.args : [];
sgclawSocket.send(JSON.stringify([window.location.href || SGCLAW_HELPER_URL, command.action, ...args]));
await sgclawPostJson(SGCLAW_COMMAND_ACK_ENDPOINT, {{ type: 'command_ack' }});
}} catch (_error) {{
}}
}}
connectSocket();
setInterval(sgclawPollCommands, 250);
console.debug('sgclaw helper initialized');
</script>
</body>
</html>
"#
)
}
fn build_runtime_console_html(
loopback_origin: &str,
helper_url: &str,
browser_ws_url: &str,
) -> String {
format!(
r#"<!doctype html>
<html><head><meta charset="utf-8"/><title>sgClaw · Runtime Console</title>
<style>
*{{margin:0;padding:0;box-sizing:border-box}}
@@ -1032,7 +1394,8 @@ mod tests {
use std::time::Duration;
use tungstenite::{accept, Message};
fn start_fake_browser_status_server() -> (String, Arc<Mutex<Vec<String>>>, thread::JoinHandle<()>) {
fn start_fake_browser_status_server(
) -> (String, Arc<Mutex<Vec<String>>>, thread::JoinHandle<()>) {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let address = listener.local_addr().unwrap();
let frames = Arc::new(Mutex::new(Vec::new()));
@@ -1073,8 +1436,7 @@ mod tests {
Err(tungstenite::Error::Io(err))
if matches!(
err.kind(),
std::io::ErrorKind::WouldBlock
| std::io::ErrorKind::TimedOut
std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut
) =>
{
break;
@@ -1103,21 +1465,27 @@ mod tests {
Duration::from_millis(50),
false,
);
assert!(result.is_err(), "expected timeout because no real helper page loads");
assert!(
result.is_err(),
"expected timeout because no real helper page loads"
);
drop(result);
handle.join().unwrap();
let sent = frames.lock().unwrap().clone();
assert!(
sent.iter().any(|frame| frame.contains("sgBrowerserOpenPage")),
sent.iter()
.any(|frame| frame.contains("sgBrowerserOpenPage")),
"bootstrap should send sgBrowerserOpenPage to the browser WS; sent frames: {sent:?}"
);
assert!(
sent.iter().any(|frame| frame.contains("/sgclaw/browser-helper.html")),
sent.iter()
.any(|frame| frame.contains("/sgclaw/browser-helper.html")),
"bootstrap should include the helper page URL; sent frames: {sent:?}"
);
assert!(
sent.iter().any(|frame| frame.contains("https://www.zhihu.com")),
sent.iter()
.any(|frame| frame.contains("https://www.zhihu.com")),
"bootstrap requestUrl should be the provided page URL; sent frames: {sent:?}"
);
}
@@ -1133,13 +1501,17 @@ mod tests {
Duration::from_millis(50),
true,
);
assert!(result.is_err(), "expected timeout because no real helper page loads");
assert!(
result.is_err(),
"expected timeout because no real helper page loads"
);
drop(result);
handle.join().unwrap();
let sent = frames.lock().unwrap().clone();
assert!(
sent.iter().any(|frame| frame.contains("sgHideBrowerserOpenPage")),
sent.iter()
.any(|frame| frame.contains("sgHideBrowerserOpenPage")),
"hidden domain bootstrap should send sgHideBrowerserOpenPage; sent frames: {sent:?}"
);
assert!(
@@ -1149,9 +1521,15 @@ mod tests {
"hidden domain bootstrap should NOT send visible sgBrowerserOpenPage; sent frames: {sent:?}"
);
assert!(
sent.iter().any(|frame| frame.contains("/sgclaw/browser-helper.html")),
sent.iter()
.any(|frame| frame.contains("/sgclaw/browser-helper.html")),
"bootstrap should include the helper page URL; sent frames: {sent:?}"
);
assert!(
!sent.iter()
.any(|frame| frame.contains("sgHideBrowserCallAfterLoaded")),
"ws-aligned hidden bootstrap should not send sgHideBrowserCallAfterLoaded; sent frames: {sent:?}"
);
}
#[test]
@@ -1187,7 +1565,10 @@ mod tests {
]),
});
assert!(response.is_ok(), "simulated mouse follow-up should not wait for a callback");
assert!(
response.is_ok(),
"simulated mouse follow-up should not wait for a callback"
);
}
#[test]
@@ -1200,6 +1581,9 @@ mod tests {
);
let html = host.helper_page_html();
assert!(html.contains("<title>sgClaw · Runtime Console</title>"));
assert!(html.contains("Runtime Console"));
assert!(html.contains("Browser Automation Agent"));
assert!(html.contains("ws://127.0.0.1:12345"));
assert!(html.contains(r#"JSON.stringify({ type: 'register', role: 'web' })"#));
assert!(html.contains("sgclawReady"));
@@ -1289,6 +1673,73 @@ mod tests {
assert!(host.take_result().is_none());
}
#[test]
fn callback_host_records_loaded_ready_and_timeout_stage_logs() {
let host = BrowserCallbackHost::new();
host.record_startup_log("callback-host bootstrap hidden-open sent");
host.push_result(CallbackResult {
callback: "sgclawOnLoaded".to_string(),
request_url: host.helper_url().to_string(),
target_url: Some("https://example.com/page".to_string()),
action: Some("navigate".to_string()),
payload: json!({ "loaded": true }),
});
host.mark_ready(Some(host.helper_url().to_string()));
host.record_startup_log(format!(
"callback-host wait_for_helper_ready timeout (helper_loaded={}, ready={})",
host.helper_loaded(),
host.is_ready()
));
assert_eq!(
host.take_startup_logs(),
vec![
"callback-host bootstrap hidden-open sent".to_string(),
"callback-host helper loaded callback received".to_string(),
"callback-host helper ready callback received".to_string(),
"callback-host wait_for_helper_ready timeout (helper_loaded=true, ready=true)"
.to_string(),
]
);
}
#[test]
fn live_callback_host_timeout_exposes_stage_logs() {
let (ws_url, _frames, handle) = start_fake_browser_status_server();
let error = LiveBrowserCallbackHost::start_with_browser_ws_url(
&ws_url,
"https://www.zhihu.com",
Duration::from_millis(100),
Duration::from_millis(50),
true,
)
.expect_err("expected timeout because no real helper page loads");
handle.join().unwrap();
assert!(matches!(error.source, crate::pipe::PipeError::Timeout));
assert!(error
.logs
.iter()
.any(|message| message == "callback-host start_with_browser_ws_url begin"));
assert!(error
.logs
.iter()
.any(|message| message == "callback-host listener ready"));
assert!(error
.logs
.iter()
.any(|message| message == "callback-host bootstrap_helper_page begin"));
assert!(error
.logs
.iter()
.any(|message| message == "callback-host bootstrap hidden-open sent"));
assert!(error.logs.iter().any(|message| {
message == "callback-host wait_for_helper_ready timeout (helper_loaded=false, ready=false)"
}));
}
#[test]
fn callback_host_repeats_inflight_command_until_acknowledged() {
let host = BrowserCallbackHost::new();
@@ -1440,7 +1891,10 @@ mod tests {
let result = make_callback_js_to_cpp_result(raw);
let response = normalize_callback_result(&request, result, Duration::from_millis(10));
assert!(response.is_none(), "mismatched callback name should return None");
assert!(
response.is_none(),
"mismatched callback name should return None"
);
}
#[test]
@@ -1477,7 +1931,10 @@ mod tests {
};
let response = normalize_callback_result(&request, result, Duration::from_millis(10));
assert!(response.is_some(), "Path A eval should accept structured values");
assert!(
response.is_some(),
"Path A eval should accept structured values"
);
match response.unwrap() {
super::super::callback_backend::BrowserCallbackResponse::Success(s) => {
assert_eq!(

View File

@@ -1,8 +1,8 @@
mod backend;
pub mod bridge_backend;
pub mod bridge_contract;
pub mod bridge_transport;
pub mod callback_backend;
mod backend;
pub(crate) mod callback_host;
mod pipe_backend;
pub mod ws_backend;
@@ -12,8 +12,8 @@ pub mod ws_protocol;
pub use backend::BrowserBackend;
pub use bridge_backend::BridgeBrowserBackend;
pub use callback_backend::{
BrowserCallbackBackend, BrowserCallbackError, BrowserCallbackHost,
BrowserCallbackRequest, BrowserCallbackResponse, BrowserCallbackSuccess,
BrowserCallbackBackend, BrowserCallbackError, BrowserCallbackHost, BrowserCallbackRequest,
BrowserCallbackResponse, BrowserCallbackSuccess,
};
pub use pipe_backend::PipeBrowserBackend;
pub use ws_backend::WsBrowserBackend;

View File

@@ -3,7 +3,9 @@ use std::sync::Arc;
use serde_json::Value;
use crate::browser::BrowserBackend;
use crate::pipe::{Action, BrowserPipeTool, CommandOutput, ExecutionSurfaceMetadata, PipeError, Transport};
use crate::pipe::{
Action, BrowserPipeTool, CommandOutput, ExecutionSurfaceMetadata, PipeError, Transport,
};
use crate::security::MacPolicy;
pub struct PipeBrowserBackend<T: Transport> {

View File

@@ -153,6 +153,11 @@ fn is_ignorable_status_prelude(frame: &str) -> bool {
serde_json::from_str::<Value>(trimmed)
.ok()
.and_then(|value| value.get("type").and_then(Value::as_str).map(str::to_string))
.and_then(|value| {
value
.get("type")
.and_then(Value::as_str)
.map(str::to_string)
})
.is_some_and(|kind| kind == "welcome")
}

View File

@@ -92,7 +92,9 @@ pub fn parse_probe_args(args: &[String]) -> Result<ProbeCliConfig, ProbeError> {
return Err(ProbeError::Args("step label must not be empty".to_string()));
}
if payload.is_empty() {
return Err(ProbeError::Args("step payload must not be empty".to_string()));
return Err(ProbeError::Args(
"step payload must not be empty".to_string(),
));
}
steps.push(ProbeStep {
label: label.to_string(),
@@ -278,7 +280,9 @@ fn map_websocket_error(err: tungstenite::Error, operation: &str) -> ProbeError {
match err {
tungstenite::Error::ConnectionClosed
| tungstenite::Error::AlreadyClosed
| tungstenite::Error::Protocol(tungstenite::error::ProtocolError::ResetWithoutClosingHandshake)
| tungstenite::Error::Protocol(
tungstenite::error::ProtocolError::ResetWithoutClosingHandshake,
)
| tungstenite::Error::Protocol(tungstenite::error::ProtocolError::SendAfterClosing) => {
ProbeError::Closed
}

View File

@@ -51,9 +51,9 @@ pub fn encode_v1_action(
pub fn decode_callback_frame(frame: &str) -> Result<DecodedCallback, PipeError> {
let payload: Value = serde_json::from_str(frame)?;
let array = payload.as_array().ok_or_else(|| {
PipeError::Protocol("callback frame must be a JSON array".to_string())
})?;
let array = payload
.as_array()
.ok_or_else(|| PipeError::Protocol("callback frame must be a JSON array".to_string()))?;
if array.len() != 3 {
return Err(PipeError::Protocol(
"callback frame must contain [requesturl, function, payload]".to_string(),
@@ -69,9 +69,9 @@ pub fn decode_callback_frame(frame: &str) -> Result<DecodedCallback, PipeError>
));
}
let param = array[2].as_str().ok_or_else(|| {
PipeError::Protocol("callback payload must be a string".to_string())
})?;
let param = array[2]
.as_str()
.ok_or_else(|| PipeError::Protocol("callback payload must be a string".to_string()))?;
let mut parts = param.splitn(5, CALLBACK_DELIMITER);
let source_url = parts.next().unwrap_or_default();
let target_url = parts.next().unwrap_or_default();

View File

@@ -44,13 +44,12 @@ pub fn open_local_dashboard(
presentation_url: &str,
) -> PostExportOpen {
if !output_path.exists() {
return PostExportOpen::Failed(format!(
"生成的大屏文件不存在:{}",
output_path.display()
));
return PostExportOpen::Failed(format!("生成的大屏文件不存在:{}", output_path.display()));
}
if presentation_url.trim().is_empty() {
return PostExportOpen::Failed("screen_html_export did not return presentation.url".to_string());
return PostExportOpen::Failed(
"screen_html_export did not return presentation.url".to_string(),
);
}
let params = json!({
@@ -180,9 +179,12 @@ mod tests {
let output_path = temp_file_path("zhihu-hotlist.xlsx");
std::fs::write(&output_path, b"xlsx").expect("xlsx fixture should be writable");
let result = open_exported_xlsx_with(&output_path, |_path| Err("launcher failed".to_string()));
let result =
open_exported_xlsx_with(&output_path, |_path| Err("launcher failed".to_string()));
assert!(matches!(result, PostExportOpen::Failed(reason) if reason.contains("launcher failed")));
assert!(
matches!(result, PostExportOpen::Failed(reason) if reason.contains("launcher failed"))
);
}
#[derive(Default)]
@@ -226,8 +228,12 @@ mod tests {
#[test]
fn open_local_dashboard_uses_exact_approved_marker_payload() {
let output_path = temp_file_path("zhihu-hotlist-screen.html");
std::fs::write(&output_path, "<html></html>").expect("dashboard fixture should be writable");
let presentation_url = format!("file:///{}", output_path.display().to_string().replace('\\', "/"));
std::fs::write(&output_path, "<html></html>")
.expect("dashboard fixture should be writable");
let presentation_url = format!(
"file:///{}",
output_path.display().to_string().replace('\\', "/")
);
let backend = FakeBrowserBackend::new(vec![Ok(CommandOutput {
seq: 1,
success: true,
@@ -245,7 +251,10 @@ mod tests {
assert!(matches!(result, PostExportOpen::Opened));
assert_eq!(invocations.len(), 1);
assert_eq!(invocations[0].0, Action::Navigate);
assert_eq!(invocations[0].2, LOCAL_DASHBOARD_EXPECTED_DOMAIN.to_string());
assert_eq!(
invocations[0].2,
LOCAL_DASHBOARD_EXPECTED_DOMAIN.to_string()
);
assert_eq!(invocations[0].1["url"], json!(presentation_url));
assert_eq!(
invocations[0].1["sgclaw_local_dashboard_open"]["source"],

View File

@@ -156,13 +156,22 @@ fn execute_browser_script_impl(
) -> anyhow::Result<ToolResult> {
eprintln!("[execute_browser_script_impl] 开始执行");
eprintln!("[execute_browser_script_impl] tool.name: {}", tool.name);
eprintln!("[execute_browser_script_impl] tool.command: {}", tool.command);
eprintln!("[execute_browser_script_impl] skill_root: {:?}", skill_root);
eprintln!(
"[execute_browser_script_impl] tool.command: {}",
tool.command
);
eprintln!(
"[execute_browser_script_impl] skill_root: {:?}",
skill_root
);
eprintln!("[execute_browser_script_impl] args: {:?}", args);
let script_path = resolve_browser_script_path(skill_root, &tool.command)?;
eprintln!("[execute_browser_script_impl] script_path: {:?}", script_path);
eprintln!(
"[execute_browser_script_impl] script_path: {:?}",
script_path
);
// 检查脚本文件是否存在
if !script_path.exists() {
eprintln!("[execute_browser_script_impl] 脚本文件不存在!");
@@ -173,45 +182,63 @@ fn execute_browser_script_impl(
let mut args = match args {
Value::Object(args) => args,
other => {
eprintln!("[execute_browser_script_impl] args 不是 Object: {:?}", other);
eprintln!(
"[execute_browser_script_impl] args 不是 Object: {:?}",
other
);
return Ok(failed_tool_result(format!(
"expected object arguments, got {other}"
)))
)));
}
};
let raw_expected_domain = match args.remove("expected_domain") {
Some(Value::String(value)) if !value.trim().is_empty() => value,
Some(other) => {
eprintln!("[execute_browser_script_impl] expected_domain 格式错误: {:?}", other);
eprintln!(
"[execute_browser_script_impl] expected_domain 格式错误: {:?}",
other
);
return Ok(failed_tool_result(format!(
"expected_domain must be a non-empty string, got {other}"
)))
)));
}
None => {
eprintln!("[execute_browser_script_impl] 缺少 expected_domain");
return Ok(failed_tool_result(
"missing required field expected_domain".to_string(),
))
));
}
};
eprintln!("[execute_browser_script_impl] raw_expected_domain: {}", raw_expected_domain);
eprintln!(
"[execute_browser_script_impl] raw_expected_domain: {}",
raw_expected_domain
);
let expected_domain = match normalize_domain_like(&raw_expected_domain) {
Some(value) => value,
None => {
eprintln!("[execute_browser_script_impl] expected_domain 解析失败");
return Ok(failed_tool_result(format!(
"expected_domain must resolve to a hostname, got {raw_expected_domain:?}"
)))
)));
}
};
eprintln!("[execute_browser_script_impl] expected_domain: {}", expected_domain);
args.insert("expected_domain".to_string(), Value::String(expected_domain.clone()));
eprintln!(
"[execute_browser_script_impl] expected_domain: {}",
expected_domain
);
args.insert(
"expected_domain".to_string(),
Value::String(expected_domain.clone()),
);
for required_arg in tool.args.keys() {
if !args.contains_key(required_arg) {
eprintln!("[execute_browser_script_impl] 缺少必需参数: {}", required_arg);
eprintln!(
"[execute_browser_script_impl] 缺少必需参数: {}",
required_arg
);
return Ok(failed_tool_result(format!(
"missing required field {required_arg}"
)));
@@ -220,7 +247,10 @@ fn execute_browser_script_impl(
let script_body = match fs::read_to_string(&script_path) {
Ok(value) => {
eprintln!("[execute_browser_script_impl] 脚本读取成功, 长度: {} 字节", value.len());
eprintln!(
"[execute_browser_script_impl] 脚本读取成功, 长度: {} 字节",
value.len()
);
value
}
Err(err) => {
@@ -228,17 +258,27 @@ fn execute_browser_script_impl(
return Ok(failed_tool_result(format!(
"failed to read browser script {}: {err}",
script_path.display()
)))
)));
}
};
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字符: {}",
if wrapped_script.len() > 500 { &wrapped_script[..500] } else { &wrapped_script });
eprintln!(
"[execute_browser_script_impl] 包装后脚本长度: {} 字节",
wrapped_script.len()
);
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 = args.get("target_url")
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));
@@ -252,18 +292,26 @@ fn execute_browser_script_impl(
&expected_domain,
) {
Ok(result) => {
eprintln!("[execute_browser_script_impl] invoke 成功, result.success: {}", result.success);
eprintln!(
"[execute_browser_script_impl] invoke 成功, result.success: {}",
result.success
);
result
}
Err(err) => {
eprintln!("[execute_browser_script_impl] invoke 失败: {}", err);
return Ok(failed_tool_result(err.to_string()))
return Ok(failed_tool_result(err.to_string()));
}
};
if !result.success {
eprintln!("[execute_browser_script_impl] result.success=false, data: {:?}", result.data);
return Ok(failed_tool_result(format_browser_script_error(&result.data)));
eprintln!(
"[execute_browser_script_impl] result.success=false, data: {:?}",
result.data
);
return Ok(failed_tool_result(format_browser_script_error(
&result.data,
)));
}
let payload = result
@@ -271,7 +319,10 @@ fn execute_browser_script_impl(
.get("text")
.cloned()
.unwrap_or_else(|| result.data.clone());
eprintln!("[execute_browser_script_impl] 返回成功, payload 长度: {:?}", payload.to_string().len());
eprintln!(
"[execute_browser_script_impl] 返回成功, payload 长度: {:?}",
payload.to_string().len()
);
Ok(ToolResult {
success: true,
output: stringify_tool_payload(&payload)?,

View File

@@ -118,4 +118,3 @@ fn normalize_configured_skills_dir(configured_dir: &Path) -> PathBuf {
configured_dir.to_path_buf()
}
}

View File

@@ -1,14 +1,14 @@
use std::ffi::OsString;
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};
use crate::scene_contract::PostprocessSection;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DeterministicExecutionPlan {
@@ -22,6 +22,7 @@ pub struct DeterministicExecutionPlan {
pub period_mode_code: String,
pub period_value: String,
pub period_payload: String,
pub postprocess: Option<PostprocessSection>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
@@ -31,72 +32,29 @@ pub enum DeterministicSubmitDecision {
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 skills_dir = resolve_current_skills_dir();
decide_deterministic_submit_with_skills_dir(raw_instruction, page_url, page_title, &skills_dir)
}
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 decide_deterministic_submit_with_skills_dir(
raw_instruction: &str,
page_url: Option<&str>,
page_title: Option<&str>,
skills_dir: &Path,
) -> DeterministicSubmitDecision {
crate::compat::scene_platform::dispatch::plan_deterministic_scene(
raw_instruction,
page_url,
page_title,
skills_dir,
)
.unwrap_or_else(|err| DeterministicSubmitDecision::Prompt {
summary: err.to_string(),
})
}
@@ -115,8 +73,11 @@ pub fn execute_deterministic_submit<T: Transport + 'static>(
args,
)?;
let export_path = try_export_lineloss_xlsx(&output, workspace_root);
Ok(summarize_lineloss_output_with_export(&output, export_path.as_deref()))
crate::compat::report_artifact::interpret_report_output_and_postprocess(
&output,
plan.postprocess.as_ref(),
workspace_root,
)
}
pub fn execute_deterministic_submit_with_browser_backend(
@@ -135,8 +96,59 @@ pub fn execute_deterministic_submit_with_browser_backend(
args,
)?;
let export_path = try_export_lineloss_xlsx(&output, workspace_root);
Ok(summarize_lineloss_output_with_export(&output, export_path.as_deref()))
crate::compat::report_artifact::interpret_report_output_and_postprocess(
&output,
plan.postprocess.as_ref(),
workspace_root,
)
}
fn resolve_current_skills_dir() -> PathBuf {
let current_dir = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
let config_path = resolve_config_path_from_process_args();
let workspace_root = config_path
.as_ref()
.and_then(|path| path.parent().map(Path::to_path_buf))
.unwrap_or_else(|| current_dir.clone());
match crate::config::SgClawSettings::load(config_path.as_deref()) {
Ok(Some(settings)) => {
crate::compat::config_adapter::resolve_skills_dir_from_sgclaw_settings(
&workspace_root,
&settings,
)
}
_ => crate::compat::config_adapter::zeroclaw_default_skills_dir(&workspace_root),
}
}
fn resolve_config_path_from_process_args() -> Option<PathBuf> {
let mut args = std::env::args_os();
let _ = args.next();
while let Some(arg) = args.next() {
if arg == OsString::from("--config-path") {
let value = args.next()?;
return Some(resolve_process_path(PathBuf::from(value)));
}
let arg_string = arg.to_string_lossy();
if let Some(value) = arg_string.strip_prefix("--config-path=") {
return Some(resolve_process_path(PathBuf::from(value)));
}
}
None
}
fn resolve_process_path(path: PathBuf) -> PathBuf {
if path.is_absolute() {
path
} else {
std::env::current_dir()
.unwrap_or_else(|_| PathBuf::from("."))
.join(path)
}
}
fn deterministic_submit_args(plan: &DeterministicExecutionPlan) -> Map<String, Value> {
@@ -153,10 +165,7 @@ fn deterministic_submit_args(plan: &DeterministicExecutionPlan) -> Map<String, V
"org_label".to_string(),
Value::String(plan.org_label.clone()),
);
args.insert(
"org_code".to_string(),
Value::String(plan.org_code.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()),
@@ -175,283 +184,3 @@ fn deterministic_submit_args(plan: &DeterministicExecutionPlan) -> Map<String, V
);
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(),
}
}

View File

@@ -43,7 +43,10 @@ pub fn execute_direct_submit_skill<T: Transport + 'static>(
let period = derive_period(instruction)?;
let mut args = Map::new();
args.insert("expected_domain".to_string(), Value::String(expected_domain));
args.insert(
"expected_domain".to_string(),
Value::String(expected_domain),
);
args.insert("period".to_string(), Value::String(period));
let output = execute_browser_script_skill_raw_output(
@@ -74,7 +77,10 @@ pub fn execute_direct_submit_skill_with_browser_backend(
let period = derive_period(instruction)?;
let mut args = Map::new();
args.insert("expected_domain".to_string(), Value::String(expected_domain));
args.insert(
"expected_domain".to_string(),
Value::String(expected_domain),
);
args.insert("period".to_string(), Value::String(period));
let output = execute_browser_script_skill_raw_output_with_browser_backend(
@@ -95,7 +101,8 @@ pub fn execute_browser_script_skill_raw_output<T: Transport + 'static>(
settings: &SgClawSettings,
args: Map<String, Value>,
) -> Result<String, PipeError> {
let (tool, skill_root) = resolve_browser_script_skill(configured_tool, workspace_root, settings)?;
let (tool, skill_root) =
resolve_browser_script_skill(configured_tool, workspace_root, settings)?;
execute_browser_script_tool_output(browser_tool, configured_tool, &tool, &skill_root, args)
}
@@ -124,7 +131,8 @@ pub(crate) fn resolve_direct_submit_bootstrap_metadata(
workspace_root: &Path,
settings: &SgClawSettings,
) -> Result<Option<DirectSubmitBootstrapMetadata>, PipeError> {
let (tool, skill_root) = resolve_browser_script_skill(configured_tool, workspace_root, settings)?;
let (tool, skill_root) =
resolve_browser_script_skill(configured_tool, workspace_root, settings)?;
let manifest_path = skill_root.join("SKILL.toml");
let Ok(manifest) = fs::read_to_string(&manifest_path) else {
return Ok(None);
@@ -234,110 +242,14 @@ fn execute_browser_script_tool_output_with_backend(
if result.success {
Ok(result.output)
} else {
Err(PipeError::Protocol(
result
.error
.unwrap_or_else(|| "direct submit skill execution failed".to_string()),
))
Err(PipeError::Protocol(result.error.unwrap_or_else(|| {
"direct submit skill execution failed".to_string()
})))
}
}
fn interpret_direct_submit_output(output: &str) -> DirectSubmitOutcome {
let Some(payload) = serde_json::from_str::<Value>(output).ok() else {
return DirectSubmitOutcome {
success: true,
summary: output.to_string(),
};
};
let Some(artifact) = payload.as_object() else {
return DirectSubmitOutcome {
success: true,
summary: output.to_string(),
};
};
if artifact.get("type").and_then(Value::as_str) != Some("report-artifact") {
return DirectSubmitOutcome {
success: true,
summary: output.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("report-artifact");
let period = artifact
.get("period")
.and_then(Value::as_str)
.unwrap_or("");
let detail_rows = count_rows(artifact.get("counts"), artifact.get("rows"), "detail_rows");
let summary_rows = count_summary_rows(artifact.get("counts"), artifact.get("sections"));
let partial_reasons = artifact
.get("partial_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 !period.trim().is_empty() {
parts.push(period.to_string());
}
parts.push(format!("status={status}"));
parts.push(format!("detail_rows={detail_rows}"));
parts.push(format!("summary_rows={summary_rows}"));
if !partial_reasons.is_empty() {
parts.push(format!("partial_reasons={}", partial_reasons.join(",")));
}
DirectSubmitOutcome {
success,
summary: parts.join(" "),
}
}
fn count_rows(counts: Option<&Value>, rows: Option<&Value>, key: &str) -> usize {
counts
.and_then(Value::as_object)
.and_then(|counts| counts.get(key))
.and_then(Value::as_u64)
.map(|count| count as usize)
.or_else(|| rows.and_then(Value::as_array).map(Vec::len))
.unwrap_or(0)
}
fn count_summary_rows(counts: Option<&Value>, sections: Option<&Value>) -> usize {
counts
.and_then(Value::as_object)
.and_then(|counts| counts.get("summary_rows"))
.and_then(Value::as_u64)
.map(|count| count as usize)
.or_else(|| {
sections
.and_then(Value::as_array)
.and_then(|sections| {
sections.iter().find_map(|section| {
section
.as_object()
.and_then(|section| section.get("rows"))
.and_then(Value::as_array)
.map(Vec::len)
})
})
})
.unwrap_or(0)
crate::compat::report_artifact::interpret_report_output(output)
}
#[derive(Debug, Deserialize)]
@@ -439,8 +351,7 @@ fn derive_period(instruction: &str) -> Result<String, PipeError> {
}
Err(PipeError::Protocol(
"direct submit skill requires an explicit YYYY-MM period in the instruction"
.to_string(),
"direct submit skill requires an explicit YYYY-MM period in the instruction".to_string(),
))
}
@@ -456,16 +367,14 @@ fn is_year_month(candidate: &str) -> bool {
#[cfg(test)]
mod tests {
use super::{
count_rows, count_summary_rows, derive_period, interpret_direct_submit_output,
is_year_month, parse_configured_tool_name,
derive_period, interpret_direct_submit_output, is_year_month, parse_configured_tool_name,
};
use serde_json::json;
#[test]
fn parse_configured_tool_name_requires_skill_and_tool() {
assert_eq!(
parse_configured_tool_name("fault-details-report.collect_fault_details")
.unwrap(),
parse_configured_tool_name("fault-details-report.collect_fault_details").unwrap(),
("fault-details-report", "collect_fault_details")
);
assert!(parse_configured_tool_name("fault-details-report").is_err());
@@ -515,14 +424,20 @@ mod tests {
}
#[test]
fn row_count_helpers_fall_back_to_payload_shapes() {
assert_eq!(
count_rows(None, Some(&json!([{ "qxdbh": "QX-1" }, { "qxdbh": "QX-2" }])), "detail_rows"),
2
);
assert_eq!(
count_summary_rows(None, Some(&json!([{ "name": "summary-sheet", "rows": [{ "index": 1 }] }]))),
1
fn interpret_direct_submit_output_falls_back_to_payload_row_shapes() {
let outcome = interpret_direct_submit_output(
&json!({
"type": "report-artifact",
"report_name": "fault-details-report",
"status": "ok",
"rows": [{ "qxdbh": "QX-1" }, { "qxdbh": "QX-2" }],
"sections": [{ "name": "summary-sheet", "rows": [{ "index": 1 }] }]
})
.to_string(),
);
assert!(outcome.success);
assert!(outcome.summary.contains("detail_rows=2"));
assert!(outcome.summary.contains("summary_rows=1"));
}
}

View File

@@ -23,19 +23,12 @@ pub fn export_lineloss_xlsx(request: &LinelossExportRequest) -> anyhow::Result<P
let sheet_xml = build_worksheet_xml(&request.column_defs, &request.rows);
write_xlsx(
&request.output_path,
&request.sheet_name,
&sheet_xml,
)?;
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 {
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)
@@ -60,10 +53,7 @@ fn build_worksheet_xml(
.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();
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)

View File

@@ -10,7 +10,10 @@ pub mod lineloss_xlsx_export;
pub mod memory_adapter;
pub mod openxml_office_tool;
pub mod orchestration;
pub mod report_artifact;
pub mod report_xlsx_export;
pub mod runtime;
pub mod scene_platform;
pub mod screen_html_export_tool;
pub mod tq_lineloss;
pub mod workflow_executor;

View File

@@ -162,7 +162,9 @@ fn failed_tool_result(error: String) -> ToolResult {
fn create_job_root(workspace_root: &Path) -> anyhow::Result<PathBuf> {
let nanos = SystemTime::now().duration_since(UNIX_EPOCH)?.as_nanos();
let path = workspace_root.join(".sgclaw-openxml").join(format!("{nanos}"));
let path = workspace_root
.join(".sgclaw-openxml")
.join(format!("{nanos}"));
fs::create_dir_all(&path)?;
Ok(path)
}
@@ -220,7 +222,10 @@ fn canonicalize_column_name(value: &str) -> Option<&'static str> {
}
fn reorder_row(row: &[Value], column_order: &[usize]) -> Vec<Value> {
column_order.iter().map(|index| row[*index].clone()).collect()
column_order
.iter()
.map(|index| row[*index].clone())
.collect()
}
fn write_payload_json(path: &Path, rows: &[Vec<Value>]) -> anyhow::Result<()> {
@@ -319,14 +324,23 @@ fn run_openxml_cli(request_path: &Path) -> anyhow::Result<Value> {
Ok(serde_json::from_str(&stdout)?)
}
fn render_locally(template_path: &Path, payload_path: &Path, output_path: &Path) -> anyhow::Result<Value> {
fn render_locally(
template_path: &Path,
payload_path: &Path,
output_path: &Path,
) -> anyhow::Result<Value> {
let payload: Value = serde_json::from_slice(&fs::read(payload_path)?)?;
let variables = payload["variables"]
.as_object()
.ok_or_else(|| anyhow::anyhow!("payload.variables must be an object"))?;
let worksheet = render_template_xml(&worksheet_xml_from_xlsx(template_path)?, variables);
write_rendered_xlsx(template_path, output_path, "xl/worksheets/sheet1.xml", &worksheet)?;
write_rendered_xlsx(
template_path,
output_path,
"xl/worksheets/sheet1.xml",
&worksheet,
)?;
Ok(json!({
"data": {

View File

@@ -84,16 +84,18 @@ pub fn execute_task_with_browser_backend(
)
}
(_, Ok(summary)) => Ok(summary),
(Some(route), Err(_)) => crate::compat::workflow_executor::execute_route_with_browser_backend(
transport,
browser_backend,
workspace_root,
&skills_dir,
instruction,
task_context,
route,
settings,
),
(Some(route), Err(_)) => {
crate::compat::workflow_executor::execute_route_with_browser_backend(
transport,
browser_backend,
workspace_root,
&skills_dir,
instruction,
task_context,
route,
settings,
)
}
(None, Err(err)) => Err(err),
}
}

View File

@@ -0,0 +1,343 @@
use std::path::{Path, PathBuf};
use serde_json::{Map, Value};
use crate::compat::artifact_open::{open_exported_xlsx, PostExportOpen};
use crate::compat::direct_skill_runtime::DirectSubmitOutcome;
use crate::pipe::PipeError;
use crate::scene_contract::PostprocessSection;
#[derive(Debug, Clone)]
pub struct ParsedReportArtifact {
pub report_name: String,
pub status: String,
pub columns: Vec<String>,
pub column_defs: Vec<(String, String)>,
pub rows: Vec<Map<String, Value>>,
pub counts: ReportCounts,
pub partial_reasons: Vec<String>,
pub reasons: Vec<String>,
pub period: String,
}
#[derive(Debug, Clone, Copy, Default)]
pub struct ReportCounts {
pub detail_rows: usize,
pub summary_rows: usize,
}
pub fn interpret_report_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);
interpret_report_artifact(artifact).unwrap_or_else(|| DirectSubmitOutcome {
success: true,
summary: output.to_string(),
})
}
pub fn interpret_report_output_and_postprocess(
output: &str,
postprocess: Option<&PostprocessSection>,
workspace_root: &Path,
) -> Result<DirectSubmitOutcome, PipeError> {
let Some(payload) = serde_json::from_str::<Value>(output).ok() else {
return Ok(DirectSubmitOutcome {
success: true,
summary: output.to_string(),
});
};
let artifact = payload
.as_object()
.and_then(|object| object.get("text"))
.unwrap_or(&payload);
let Some(parsed) = parse_report_artifact(artifact) else {
return Ok(DirectSubmitOutcome {
success: true,
summary: output.to_string(),
});
};
report_artifact_outcome_with_postprocess(&parsed, postprocess, workspace_root)
}
pub fn interpret_report_artifact_and_postprocess(
artifact_json: &Value,
postprocess: Option<&PostprocessSection>,
workspace_root: &Path,
) -> Result<DirectSubmitOutcome, PipeError> {
let artifact = artifact_json
.as_object()
.and_then(|object| object.get("text"))
.unwrap_or(artifact_json);
let Some(parsed) = parse_report_artifact(artifact) else {
return Ok(DirectSubmitOutcome {
success: true,
summary: artifact_json.to_string(),
});
};
report_artifact_outcome_with_postprocess(&parsed, postprocess, workspace_root)
}
fn interpret_report_artifact(artifact_json: &Value) -> Option<DirectSubmitOutcome> {
let parsed = parse_report_artifact(artifact_json)?;
Some(report_artifact_outcome(&parsed))
}
fn report_artifact_outcome_with_postprocess(
artifact: &ParsedReportArtifact,
postprocess: Option<&PostprocessSection>,
workspace_root: &Path,
) -> Result<DirectSubmitOutcome, PipeError> {
let mut outcome = report_artifact_outcome(artifact);
if let Some(export_path) =
export_report_artifact_if_requested(artifact, postprocess, workspace_root)
{
outcome
.summary
.push_str(&format!(" export_path={}", export_path.display()));
if matches!(
postprocess.and_then(|section| section.auto_open.as_deref()),
Some("excel")
) {
match open_exported_xlsx(&export_path) {
PostExportOpen::Opened => outcome.summary.push_str(" 已自动打开Excel"),
PostExportOpen::Failed(reason) => outcome
.summary
.push_str(&format!(" 自动打开Excel失败: {reason}")),
}
}
}
Ok(outcome)
}
fn parse_report_artifact(artifact_json: &Value) -> Option<ParsedReportArtifact> {
let artifact = artifact_json.as_object()?;
if artifact.get("type").and_then(Value::as_str) != Some("report-artifact") {
return None;
}
let report_name = artifact
.get("report_name")
.and_then(Value::as_str)
.unwrap_or("report-artifact")
.to_string();
let status = artifact
.get("status")
.and_then(Value::as_str)
.unwrap_or("ok")
.to_string();
let columns: Vec<String> = artifact
.get("columns")
.and_then(Value::as_array)
.map(|columns| {
columns
.iter()
.filter_map(Value::as_str)
.filter(|value| !value.trim().is_empty())
.map(ToString::to_string)
.collect()
})
.unwrap_or_default();
let column_defs = parse_column_defs(artifact.get("column_defs"), &columns);
let rows: Vec<Map<String, Value>> = artifact
.get("rows")
.and_then(Value::as_array)
.map(|rows| rows.iter().filter_map(Value::as_object).cloned().collect())
.unwrap_or_default();
let counts = ReportCounts {
detail_rows: count_detail_rows(artifact.get("counts"), &rows),
summary_rows: count_summary_rows(artifact.get("counts"), artifact.get("sections")),
};
let partial_reasons = string_array_field(artifact, "partial_reasons");
let reasons = string_array_field(artifact, "reasons");
let period = derive_period(artifact);
Some(ParsedReportArtifact {
report_name,
status,
columns,
column_defs,
rows,
counts,
partial_reasons,
reasons,
period,
})
}
fn report_artifact_outcome(artifact: &ParsedReportArtifact) -> DirectSubmitOutcome {
let success = matches!(artifact.status.as_str(), "ok" | "partial" | "empty");
let mut parts = vec![artifact.report_name.clone()];
if !artifact.period.trim().is_empty() {
parts.push(artifact.period.clone());
}
parts.push(format!("status={}", artifact.status));
parts.push(format!("detail_rows={}", artifact.counts.detail_rows));
parts.push(format!("summary_rows={}", artifact.counts.summary_rows));
if !artifact.partial_reasons.is_empty() {
parts.push(format!(
"partial_reasons={}",
artifact.partial_reasons.join(",")
));
}
if !artifact.reasons.is_empty() {
parts.push(format!("reasons={}", artifact.reasons.join(",")));
}
DirectSubmitOutcome {
success,
summary: parts.join(" "),
}
}
fn export_report_artifact_if_requested(
artifact: &ParsedReportArtifact,
postprocess: Option<&PostprocessSection>,
workspace_root: &Path,
) -> Option<PathBuf> {
let postprocess = postprocess?;
if postprocess.exporter != "xlsx_report" {
return None;
}
if !matches!(artifact.status.as_str(), "ok" | "partial") {
return None;
}
if artifact.rows.is_empty() || artifact.column_defs.is_empty() {
return None;
}
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|duration| duration.as_nanos())
.unwrap_or_default();
let output_path = workspace_root.join("out").join(format!(
"{}-{nanos}.xlsx",
sanitize_file_stem(&artifact.report_name)
));
crate::compat::report_xlsx_export::export_report_xlsx(
&artifact.report_name,
&artifact.column_defs,
&artifact.rows,
output_path,
)
.ok()
}
fn parse_column_defs(value: Option<&Value>, columns: &[String]) -> Vec<(String, String)> {
let defs: Vec<(String, String)> = value
.and_then(Value::as_array)
.map(|defs| {
defs.iter()
.filter_map(|def| {
let array = def.as_array()?;
let key = array.first()?.as_str()?.to_string();
let label = array.get(1)?.as_str()?.to_string();
Some((key, label))
})
.collect()
})
.unwrap_or_default();
if defs.is_empty() {
columns
.iter()
.map(|column| (column.clone(), column.clone()))
.collect()
} else {
defs
}
}
fn count_detail_rows(counts: Option<&Value>, rows: &[Map<String, Value>]) -> usize {
counts
.and_then(Value::as_object)
.and_then(|counts| {
counts
.get("detail_rows")
.or_else(|| counts.get("rows"))
.and_then(Value::as_u64)
})
.map(|value| value as usize)
.unwrap_or(rows.len())
}
fn count_summary_rows(counts: Option<&Value>, sections: Option<&Value>) -> usize {
counts
.and_then(Value::as_object)
.and_then(|counts| counts.get("summary_rows"))
.and_then(Value::as_u64)
.map(|value| value as usize)
.or_else(|| {
sections.and_then(Value::as_array).map(|sections| {
sections
.iter()
.filter_map(|section| section.get("rows").and_then(Value::as_array))
.map(Vec::len)
.sum()
})
})
.unwrap_or(0)
}
fn string_array_field(artifact: &Map<String, Value>, field: &str) -> Vec<String> {
artifact
.get(field)
.and_then(Value::as_array)
.map(|values| {
values
.iter()
.filter_map(Value::as_str)
.filter(|value| !value.trim().is_empty())
.map(ToString::to_string)
.collect()
})
.unwrap_or_default()
}
fn derive_period(artifact: &Map<String, Value>) -> String {
if let Some(period) = artifact.get("period") {
if let Some(value) = period.as_str() {
return value.to_string();
}
if let Some(value) = period
.as_object()
.and_then(|period| period.get("value"))
.and_then(Value::as_str)
{
return value.to_string();
}
}
String::new()
}
fn sanitize_file_stem(value: &str) -> String {
let sanitized: String = value
.chars()
.map(|ch| match ch {
'/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' => '-',
_ => ch,
})
.collect();
let trimmed = sanitized.trim();
if trimmed.is_empty() {
"report-artifact".to_string()
} else {
trimmed.to_string()
}
}

View File

@@ -0,0 +1,19 @@
use std::path::PathBuf;
use serde_json::{Map, Value};
pub fn export_report_xlsx(
sheet_name: &str,
column_defs: &[(String, String)],
rows: &[Map<String, Value>],
output_path: PathBuf,
) -> anyhow::Result<PathBuf> {
crate::compat::lineloss_xlsx_export::export_lineloss_xlsx(
&crate::compat::lineloss_xlsx_export::LinelossExportRequest {
sheet_name: sheet_name.to_string(),
column_defs: column_defs.to_vec(),
rows: rows.to_vec(),
output_path,
},
)
}

View File

@@ -16,9 +16,9 @@ use crate::compat::config_adapter::{
build_zeroclaw_config_from_sgclaw_settings, resolve_skills_dir_from_sgclaw_settings,
};
use crate::compat::event_bridge::log_entry_for_turn_event;
use crate::compat::workflow_executor::parse_generated_article_draft;
use crate::compat::openxml_office_tool::OpenXmlOfficeTool;
use crate::compat::screen_html_export_tool::ScreenHtmlExportTool;
use crate::compat::workflow_executor::parse_generated_article_draft;
use crate::config::{DeepSeekSettings, OfficeBackend, SgClawSettings};
use crate::pipe::{BrowserPipeTool, ConversationMessage, PipeError, Transport};
use crate::runtime::RuntimeEngine;
@@ -370,7 +370,11 @@ mod tests {
fn compat_runtime_source_no_longer_references_legacy_planner_preview() {
let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
let source = fs::read_to_string(manifest_dir.join("src/compat/runtime.rs")).unwrap();
let preview_prefix = ["if let Some(preview) = crate::agent::", "planner::build_execution_preview("].concat();
let preview_prefix = [
"if let Some(preview) = crate::agent::",
"planner::build_execution_preview(",
]
.concat();
let plan_level_expr = ["level: ", "\"plan\".to_string(),"].concat();
assert!(!source

View File

@@ -0,0 +1,375 @@
use std::path::Path;
use serde_json::{Map, Value};
use thiserror::Error;
use crate::compat::deterministic_submit::{
DeterministicExecutionPlan, DeterministicSubmitDecision,
};
use crate::compat::scene_platform::registry::{
load_scene_registry, SceneRegistryEntry, SceneRegistryError,
};
use crate::compat::scene_platform::resolvers::{
resolve_required_scene_params, ResolverError, SceneParamResolution,
};
use crate::scene_contract::PostprocessSection;
const DETERMINISTIC_SUFFIX: &str = "\u{3002}\u{3002}\u{3002}";
const DIAGNOSTIC_SCENE_ID: &str = "sweep-030-scene";
#[derive(Debug, Clone)]
pub struct SceneExecutionPlan {
pub scene_id: String,
pub instruction: String,
pub tool_name: String,
pub expected_domain: String,
pub target_url: String,
pub args: Map<String, Value>,
pub success_statuses: Vec<String>,
pub failure_statuses: Vec<String>,
pub postprocess: Option<PostprocessSection>,
}
#[derive(Debug, Error)]
pub enum SceneDispatchError {
#[error(transparent)]
Registry(#[from] SceneRegistryError),
#[error(transparent)]
Resolver(#[from] ResolverError),
}
#[derive(Debug)]
struct CandidateScene<'a> {
entry: &'a SceneRegistryEntry,
score: usize,
resolution: SceneParamResolution,
}
pub fn plan_deterministic_scene(
raw_instruction: &str,
page_url: Option<&str>,
page_title: Option<&str>,
skills_dir: &Path,
) -> Result<DeterministicSubmitDecision, SceneDispatchError> {
let Some(stripped_instruction) = strip_exact_suffix(raw_instruction) else {
return Ok(DeterministicSubmitDecision::NotDeterministic);
};
let instruction = stripped_instruction.trim();
log_deterministic_diag(format!(
"submit suffix=ok skills_dir={} skills_dir_exists={} instruction={} page_url={} page_title={}",
skills_dir.display(),
skills_dir.exists(),
instruction,
page_url.unwrap_or_default(),
page_title.unwrap_or_default()
));
if instruction.is_empty() {
log_deterministic_diag("unsupported: empty deterministic instruction");
return Ok(unsupported_scene_prompt());
}
let registry = load_scene_registry(skills_dir)?;
log_registry_diag(&registry);
if registry.is_empty() {
log_deterministic_diag("unsupported: scene registry is empty");
return Ok(unsupported_scene_prompt());
}
let mut candidates = Vec::new();
for entry in &registry {
if entry.manifest.scene.id == DIAGNOSTIC_SCENE_ID {
log_scene_match_diag(entry, instruction);
}
let Some(score) = score_scene(entry, instruction, page_url, page_title) else {
continue;
};
let resolution = resolve_required_scene_params(
&entry.skill_root,
instruction,
&entry.manifest.scene.id,
&entry.manifest.params,
)?;
candidates.push(CandidateScene {
entry,
score,
resolution,
});
}
if candidates.is_empty() {
log_deterministic_diag(
"unsupported: no scene candidate matched include/suffix/exclude rules",
);
return Ok(unsupported_scene_prompt());
}
candidates.sort_by(|left, right| {
right
.score
.cmp(&left.score)
.then_with(|| {
right
.resolution
.resolved_required_count
.cmp(&left.resolution.resolved_required_count)
})
.then_with(|| {
left.entry
.manifest
.scene
.id
.cmp(&right.entry.manifest.scene.id)
})
});
if candidates.len() > 1 {
let best = &candidates[0];
let next = &candidates[1];
if best.score == next.score
&& best.resolution.resolved_required_count == next.resolution.resolved_required_count
{
return Ok(DeterministicSubmitDecision::Prompt {
summary: format!(
"已命中多个确定性场景({}、{}),请补充更明确关键词或页面上下文。",
best.entry.manifest.scene.id, next.entry.manifest.scene.id
),
});
}
}
let selected = candidates.remove(0);
log_deterministic_diag(format!(
"selected scene={} tool={} score={} resolved_required_count={}",
selected.entry.manifest.scene.id,
selected.entry.manifest.scene.tool,
selected.score,
selected.resolution.resolved_required_count
));
if let Some(summary) = selected.resolution.prompt {
log_deterministic_diag(format!(
"selected scene={} returned resolver prompt",
selected.entry.manifest.scene.id
));
return Ok(DeterministicSubmitDecision::Prompt { summary });
}
Ok(DeterministicSubmitDecision::Execute(to_deterministic_plan(
instruction,
build_scene_execution_plan(selected.entry, instruction, selected.resolution.args),
)))
}
fn build_scene_execution_plan(
entry: &SceneRegistryEntry,
instruction: &str,
mut args: Map<String, Value>,
) -> SceneExecutionPlan {
args.insert(
"expected_domain".to_string(),
Value::String(entry.manifest.bootstrap.expected_domain.clone()),
);
args.insert(
"target_url".to_string(),
Value::String(entry.manifest.bootstrap.target_url.clone()),
);
SceneExecutionPlan {
scene_id: entry.manifest.scene.id.clone(),
instruction: instruction.to_string(),
tool_name: format!(
"{}.{}",
entry.manifest.scene.skill, entry.manifest.scene.tool
),
expected_domain: entry.manifest.bootstrap.expected_domain.clone(),
target_url: entry.manifest.bootstrap.target_url.clone(),
args,
success_statuses: entry.manifest.artifact.success_status.clone(),
failure_statuses: entry.manifest.artifact.failure_status.clone(),
postprocess: entry.manifest.postprocess.clone(),
}
}
fn to_deterministic_plan(
instruction: &str,
scene_plan: SceneExecutionPlan,
) -> DeterministicExecutionPlan {
let org_label = scene_plan
.args
.get("org_label")
.and_then(Value::as_str)
.unwrap_or_default()
.to_string();
let org_code = scene_plan
.args
.get("org_code")
.and_then(Value::as_str)
.unwrap_or_default()
.to_string();
let period_mode = scene_plan
.args
.get("period_mode")
.and_then(Value::as_str)
.unwrap_or_default()
.to_string();
let period_mode_code = scene_plan
.args
.get("period_mode_code")
.and_then(Value::as_str)
.unwrap_or_default()
.to_string();
let period_value = scene_plan
.args
.get("period_value")
.and_then(Value::as_str)
.unwrap_or_default()
.to_string();
let period_payload = scene_plan
.args
.get("period_payload")
.cloned()
.map(|payload| serde_json::to_string(&payload).unwrap_or_else(|_| "{}".to_string()))
.unwrap_or_else(|| "{}".to_string());
DeterministicExecutionPlan {
instruction: instruction.to_string(),
tool_name: scene_plan.tool_name,
expected_domain: scene_plan.expected_domain,
target_url: scene_plan.target_url,
org_label,
org_code,
period_mode,
period_mode_code,
period_value,
period_payload,
postprocess: scene_plan.postprocess,
}
}
fn score_scene(
entry: &SceneRegistryEntry,
instruction: &str,
page_url: Option<&str>,
page_title: Option<&str>,
) -> Option<usize> {
let deterministic = &entry.manifest.deterministic;
if deterministic.suffix != DETERMINISTIC_SUFFIX {
return None;
}
let include_hits = deterministic
.include_keywords
.iter()
.filter(|keyword| !keyword.trim().is_empty() && instruction.contains(keyword.as_str()))
.count();
if include_hits == 0 {
return None;
}
if deterministic
.exclude_keywords
.iter()
.any(|keyword| !keyword.trim().is_empty() && instruction.contains(keyword.as_str()))
{
return None;
}
let mut score = include_hits * 10;
let normalized_url = page_url.unwrap_or_default().to_ascii_lowercase();
if !normalized_url.is_empty() {
if normalized_url.contains(
&entry
.manifest
.bootstrap
.expected_domain
.to_ascii_lowercase(),
) {
score += 100;
} else if normalized_url.contains(&entry.manifest.scene.id.to_ascii_lowercase()) {
score += 40;
}
}
let title = page_title.unwrap_or_default();
if !title.is_empty()
&& entry
.manifest
.bootstrap
.page_title_keywords
.iter()
.any(|keyword| !keyword.trim().is_empty() && title.contains(keyword.as_str()))
{
score += 60;
}
Some(score)
}
fn strip_exact_suffix(raw_instruction: &str) -> Option<&str> {
let without_suffix = raw_instruction.strip_suffix(DETERMINISTIC_SUFFIX)?;
if without_suffix.ends_with('\u{3002}') {
return None;
}
Some(without_suffix)
}
fn log_registry_diag(registry: &[SceneRegistryEntry]) {
let sweep_030 = registry
.iter()
.find(|entry| entry.manifest.scene.id == DIAGNOSTIC_SCENE_ID);
match sweep_030 {
Some(entry) => log_deterministic_diag(format!(
"registry loaded count={} diagnostic_scene={} skill_root={} suffix_ok={} include_keywords={:?}",
registry.len(),
DIAGNOSTIC_SCENE_ID,
entry.skill_root.display(),
entry.manifest.deterministic.suffix == DETERMINISTIC_SUFFIX,
entry.manifest.deterministic.include_keywords
)),
None => log_deterministic_diag(format!(
"registry loaded count={} diagnostic_scene={} registered=false",
registry.len(),
DIAGNOSTIC_SCENE_ID
)),
}
}
fn log_scene_match_diag(entry: &SceneRegistryEntry, instruction: &str) {
let deterministic = &entry.manifest.deterministic;
let include_hits = deterministic
.include_keywords
.iter()
.filter(|keyword| !keyword.trim().is_empty() && instruction.contains(keyword.as_str()))
.cloned()
.collect::<Vec<_>>();
let exclude_hits = deterministic
.exclude_keywords
.iter()
.filter(|keyword| !keyword.trim().is_empty() && instruction.contains(keyword.as_str()))
.cloned()
.collect::<Vec<_>>();
log_deterministic_diag(format!(
"diagnostic_scene={} suffix_ok={} suffix_codepoints={} include_hits={:?} exclude_hits={:?}",
entry.manifest.scene.id,
deterministic.suffix == DETERMINISTIC_SUFFIX,
deterministic
.suffix
.chars()
.map(|ch| format!("U+{:04X}", ch as u32))
.collect::<Vec<_>>()
.join(","),
include_hits,
exclude_hits
));
}
fn log_deterministic_diag(message: impl AsRef<str>) {
eprintln!("[sgclaw deterministic] {}", message.as_ref());
}
fn unsupported_scene_prompt() -> DeterministicSubmitDecision {
DeterministicSubmitDecision::Prompt {
summary: "确定性提交当前只支持已注册的报表采集场景,请补充已支持的业务请求。".to_string(),
}
}

View File

@@ -0,0 +1,3 @@
pub mod dispatch;
pub mod registry;
pub mod resolvers;

View File

@@ -0,0 +1,230 @@
use std::collections::HashMap;
use std::fs;
use std::path::{Path, PathBuf};
use thiserror::Error;
use zeroclaw::skills::{load_skills_from_directory, Skill};
use crate::scene_contract::manifest::{
SceneManifest, SCENE_MANIFEST_FILE_NAME, SUPPORTED_SCENE_CATEGORY_V1, SUPPORTED_SCENE_KIND_V1,
SUPPORTED_SCHEMA_VERSION_V1,
};
#[derive(Debug, Clone)]
pub struct SceneRegistryEntry {
pub manifest: SceneManifest,
pub skill_root: PathBuf,
}
#[derive(Debug, Error)]
pub enum SceneRegistryError {
#[error("failed to read skills directory {path}: {source}")]
ReadSkillsDir {
path: PathBuf,
#[source]
source: std::io::Error,
},
#[error("failed to read scene manifest {path}: {source}")]
ReadManifest {
path: PathBuf,
#[source]
source: std::io::Error,
},
#[error("failed to parse scene manifest {path}: {source}")]
ParseManifest {
path: PathBuf,
#[source]
source: toml::de::Error,
},
#[error(
"scene manifest {path} declares unsupported schema_version {version}; only {supported} is supported in v1"
)]
UnsupportedSchemaVersion {
path: PathBuf,
version: String,
supported: &'static str,
},
#[error("scene manifest {path} declares unsupported kind {kind}; only {supported} is supported in v1")]
UnsupportedSceneKind {
path: PathBuf,
kind: String,
supported: &'static str,
},
#[error(
"scene manifest {path} declares unsupported category {category}; only {supported} is supported in v1"
)]
UnsupportedSceneCategory {
path: PathBuf,
category: String,
supported: &'static str,
},
#[error(
"scene manifest {path} declares skill {manifest_skill}, but containing skill package is {package_skill}"
)]
SkillPackageMismatch {
path: PathBuf,
manifest_skill: String,
package_skill: String,
},
#[error("scene manifest {path} points to missing skill package {skill}")]
MissingSkill { path: PathBuf, skill: String },
#[error("scene manifest {path} points to tool {tool} that is missing from skill {skill}")]
MissingTool {
path: PathBuf,
skill: String,
tool: String,
},
#[error("scene id {scene_id} is declared twice: {first_path} and {second_path}")]
DuplicateSceneId {
scene_id: String,
first_path: PathBuf,
second_path: PathBuf,
},
}
pub fn load_scene_registry(
skills_dir: &Path,
) -> Result<Vec<SceneRegistryEntry>, SceneRegistryError> {
if !skills_dir.exists() {
return Ok(Vec::new());
}
let mut skill_roots = Vec::new();
for entry in fs::read_dir(skills_dir).map_err(|source| SceneRegistryError::ReadSkillsDir {
path: skills_dir.to_path_buf(),
source,
})? {
let entry = entry.map_err(|source| SceneRegistryError::ReadSkillsDir {
path: skills_dir.to_path_buf(),
source,
})?;
let path = entry.path();
if path.is_dir() {
skill_roots.push(path);
}
}
skill_roots.sort();
let skills_by_root = index_skills_by_root(skills_dir);
let mut scene_ids = HashMap::new();
let mut registry = Vec::new();
for skill_root in skill_roots {
let manifest_path = skill_root.join(SCENE_MANIFEST_FILE_NAME);
if !manifest_path.exists() {
continue;
}
let manifest = load_manifest(&manifest_path)?;
validate_manifest(&manifest, &manifest_path, &skill_root, &skills_by_root)?;
if let Some(first_path) = scene_ids.insert(manifest.scene.id.clone(), manifest_path.clone())
{
return Err(SceneRegistryError::DuplicateSceneId {
scene_id: manifest.scene.id.clone(),
first_path,
second_path: manifest_path,
});
}
registry.push(SceneRegistryEntry {
manifest,
skill_root,
});
}
Ok(registry)
}
fn index_skills_by_root(skills_dir: &Path) -> HashMap<PathBuf, Skill> {
load_skills_from_directory(skills_dir, true)
.into_iter()
.filter_map(|skill| {
let skill_root = skill
.location
.as_deref()
.and_then(Path::parent)
.map(Path::to_path_buf)?;
Some((skill_root, skill))
})
.collect()
}
fn load_manifest(path: &Path) -> Result<SceneManifest, SceneRegistryError> {
let content = fs::read_to_string(path).map_err(|source| SceneRegistryError::ReadManifest {
path: path.to_path_buf(),
source,
})?;
toml::from_str(&content).map_err(|source| SceneRegistryError::ParseManifest {
path: path.to_path_buf(),
source,
})
}
fn validate_manifest(
manifest: &SceneManifest,
manifest_path: &Path,
skill_root: &Path,
skills_by_root: &HashMap<PathBuf, Skill>,
) -> Result<(), SceneRegistryError> {
if manifest.manifest.schema_version != SUPPORTED_SCHEMA_VERSION_V1 {
return Err(SceneRegistryError::UnsupportedSchemaVersion {
path: manifest_path.to_path_buf(),
version: manifest.manifest.schema_version.clone(),
supported: SUPPORTED_SCHEMA_VERSION_V1,
});
}
if manifest.scene.kind != SUPPORTED_SCENE_KIND_V1 {
return Err(SceneRegistryError::UnsupportedSceneKind {
path: manifest_path.to_path_buf(),
kind: manifest.scene.kind.clone(),
supported: SUPPORTED_SCENE_KIND_V1,
});
}
if manifest.scene.category != SUPPORTED_SCENE_CATEGORY_V1 {
return Err(SceneRegistryError::UnsupportedSceneCategory {
path: manifest_path.to_path_buf(),
category: manifest.scene.category.clone(),
supported: SUPPORTED_SCENE_CATEGORY_V1,
});
}
let Some(skill) = skills_by_root.get(skill_root) else {
return Err(SceneRegistryError::MissingSkill {
path: manifest_path.to_path_buf(),
skill: manifest.scene.skill.clone(),
});
};
if skill.name != manifest.scene.skill {
return Err(SceneRegistryError::SkillPackageMismatch {
path: manifest_path.to_path_buf(),
manifest_skill: manifest.scene.skill.clone(),
package_skill: skill.name.clone(),
});
}
let Some(tool) = skill
.tools
.iter()
.find(|tool| tool.name == manifest.scene.tool)
else {
return Err(SceneRegistryError::MissingTool {
path: manifest_path.to_path_buf(),
skill: skill.name.clone(),
tool: manifest.scene.tool.clone(),
});
};
if tool.kind != SUPPORTED_SCENE_KIND_V1 {
return Err(SceneRegistryError::UnsupportedSceneKind {
path: manifest_path.to_path_buf(),
kind: tool.kind.clone(),
supported: SUPPORTED_SCENE_KIND_V1,
});
}
Ok(())
}

View File

@@ -0,0 +1,538 @@
use std::fs;
use std::path::{Path, PathBuf};
use chrono::{Datelike, Duration, Local, NaiveDate};
use serde::Deserialize;
use serde_json::{json, Map, Value};
use thiserror::Error;
use toml::Table;
use crate::compat::tq_lineloss::contracts::{
ambiguous_company_prompt, contradictory_period_mode_prompt, missing_company_prompt,
missing_period_mode_prompt, missing_period_prompt, missing_week_year_prompt,
};
use crate::scene_contract::SceneParam;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ResolverKind {
DictionaryEntity,
MonthWeekPeriod,
FixedEnum,
LiteralPassthrough,
}
impl ResolverKind {
fn parse(raw: &str) -> Option<Self> {
match raw.trim() {
"dictionary_entity" => Some(Self::DictionaryEntity),
"month_week_period" => Some(Self::MonthWeekPeriod),
"fixed_enum" => Some(Self::FixedEnum),
"literal_passthrough" => Some(Self::LiteralPassthrough),
_ => None,
}
}
}
pub fn runtime_supported_resolvers() -> &'static [&'static str] {
&[
"dictionary_entity",
"month_week_period",
"fixed_enum",
"literal_passthrough",
]
}
pub fn is_runtime_supported_resolver(resolver: &str) -> bool {
runtime_supported_resolvers().contains(&resolver.trim())
}
#[derive(Debug, Default)]
pub(crate) struct SceneParamResolution {
pub args: Map<String, Value>,
pub resolved_required_count: usize,
pub prompt: Option<String>,
}
#[derive(Debug, Error)]
pub enum ResolverError {
#[error("scene {scene_id} param {param_name} uses unsupported resolver {resolver}")]
UnsupportedResolver {
scene_id: String,
param_name: String,
resolver: String,
},
#[error("scene {scene_id} param {param_name} is missing resolver_config.{key}")]
MissingResolverConfig {
scene_id: String,
param_name: String,
key: &'static str,
},
#[error("failed to read scene dictionary {path}: {source}")]
ReadDictionary {
path: PathBuf,
#[source]
source: std::io::Error,
},
#[error("failed to parse scene dictionary {path}: {source}")]
ParseDictionary {
path: PathBuf,
#[source]
source: serde_json::Error,
},
}
enum ParamResolutionStatus {
Resolved(Map<String, Value>),
Prompt(String),
}
#[derive(Debug, Clone)]
struct PeriodResolution {
mode: &'static str,
mode_code: &'static str,
value: String,
payload: Value,
}
#[derive(Debug, Deserialize)]
struct DictionaryEntry {
label: String,
code: String,
#[serde(default)]
aliases: Vec<String>,
}
pub(crate) fn resolve_required_scene_params(
skill_root: &Path,
instruction: &str,
scene_id: &str,
params: &[SceneParam],
) -> Result<SceneParamResolution, ResolverError> {
let mut resolution = SceneParamResolution::default();
for param in params {
match resolve_param(skill_root, instruction, scene_id, param)? {
ParamResolutionStatus::Resolved(outputs) => {
if param.required {
resolution.resolved_required_count += 1;
}
resolution.args.extend(outputs);
}
ParamResolutionStatus::Prompt(summary) => {
if param.required {
resolution.prompt = Some(summary);
return Ok(resolution);
}
}
}
}
Ok(resolution)
}
fn resolve_param(
skill_root: &Path,
instruction: &str,
scene_id: &str,
param: &SceneParam,
) -> Result<ParamResolutionStatus, ResolverError> {
let Some(kind) = ResolverKind::parse(&param.resolver) else {
return Err(ResolverError::UnsupportedResolver {
scene_id: scene_id.to_string(),
param_name: param.name.clone(),
resolver: param.resolver.clone(),
});
};
match kind {
ResolverKind::DictionaryEntity => {
resolve_dictionary_entity(skill_root, instruction, scene_id, param)
}
ResolverKind::MonthWeekPeriod => resolve_month_week_period(instruction, param),
ResolverKind::FixedEnum => resolve_fixed_enum(scene_id, param),
ResolverKind::LiteralPassthrough => Ok(resolve_literal_passthrough(instruction, param)),
}
}
fn resolve_dictionary_entity(
skill_root: &Path,
instruction: &str,
scene_id: &str,
param: &SceneParam,
) -> Result<ParamResolutionStatus, ResolverError> {
let dictionary_ref = required_config_string(scene_id, param, "dictionary_ref")?;
let output_label_field = required_config_string(scene_id, param, "output_label_field")?;
let output_code_field = required_config_string(scene_id, param, "output_code_field")?;
let dictionary_path = skill_root.join(dictionary_ref);
let content =
fs::read_to_string(&dictionary_path).map_err(|source| ResolverError::ReadDictionary {
path: dictionary_path.clone(),
source,
})?;
let entries: Vec<DictionaryEntry> =
serde_json::from_str(&content).map_err(|source| ResolverError::ParseDictionary {
path: dictionary_path,
source,
})?;
let Some(best_matches) = best_dictionary_matches(instruction, &entries) else {
return Ok(ParamResolutionStatus::Prompt(prompt_missing(param)));
};
if best_matches.len() > 1 {
return Ok(ParamResolutionStatus::Prompt(prompt_ambiguous(param)));
}
let entry = best_matches[0];
let mut outputs = Map::new();
outputs.insert(
output_label_field.to_string(),
Value::String(entry.label.clone()),
);
outputs.insert(
output_code_field.to_string(),
Value::String(entry.code.clone()),
);
Ok(ParamResolutionStatus::Resolved(outputs))
}
fn best_dictionary_matches<'a>(
instruction: &str,
entries: &'a [DictionaryEntry],
) -> Option<Vec<&'a DictionaryEntry>> {
let mut best_alias_len = 0usize;
let mut matches = Vec::new();
for entry in entries {
let entry_best = entry
.aliases
.iter()
.map(|alias| alias.trim())
.filter(|alias| !alias.is_empty() && instruction.contains(alias))
.map(str::len)
.max()
.unwrap_or(0);
if entry_best == 0 {
continue;
}
if entry_best > best_alias_len {
best_alias_len = entry_best;
matches.clear();
matches.push(entry);
continue;
}
if entry_best == best_alias_len {
matches.push(entry);
}
}
if matches.is_empty() {
None
} else {
Some(matches)
}
}
fn resolve_month_week_period(
instruction: &str,
param: &SceneParam,
) -> Result<ParamResolutionStatus, ResolverError> {
let default_strategy = optional_config_string(&param.resolver_config, "default_strategy");
let resolved = match parse_month_week_period(instruction, default_strategy.as_deref()) {
Ok(resolved) => resolved,
Err(summary) => return Ok(ParamResolutionStatus::Prompt(summary)),
};
let mut outputs = Map::new();
outputs.insert(
"period_mode".to_string(),
Value::String(resolved.mode.to_string()),
);
outputs.insert(
"period_mode_code".to_string(),
Value::String(resolved.mode_code.to_string()),
);
outputs.insert(
"period_value".to_string(),
Value::String(resolved.value.clone()),
);
outputs.insert("period_payload".to_string(), resolved.payload);
Ok(ParamResolutionStatus::Resolved(outputs))
}
fn parse_month_week_period(
instruction: &str,
default_strategy: Option<&str>,
) -> Result<PeriodResolution, String> {
let has_month = instruction.contains("月累计");
let has_week = instruction.contains("周累计");
match (has_month, has_week) {
(true, true) => Err(contradictory_period_mode_prompt()),
(false, false) => Err(missing_period_mode_prompt()),
(true, false) => resolve_month_period(instruction, default_strategy),
(false, true) => resolve_week_period(instruction, default_strategy),
}
}
fn resolve_month_period(
instruction: &str,
default_strategy: Option<&str>,
) -> Result<PeriodResolution, String> {
if let Some(value) = extract_year_month_dash(instruction) {
return Ok(PeriodResolution {
mode: "month",
mode_code: "1",
value: value.clone(),
payload: json!({ "fdate": value }),
});
}
if let Some(value) = extract_year_month_cn(instruction) {
return Ok(PeriodResolution {
mode: "month",
mode_code: "1",
value: value.clone(),
payload: json!({ "fdate": value }),
});
}
if default_strategy == Some("lineloss_page_semantics") {
let value = previous_month_value(Local::now().date_naive());
return Ok(PeriodResolution {
mode: "month",
mode_code: "1",
value: value.clone(),
payload: json!({ "fdate": value }),
});
}
Err(missing_period_prompt())
}
fn resolve_week_period(
instruction: &str,
default_strategy: Option<&str>,
) -> Result<PeriodResolution, String> {
if instruction.contains('第') && instruction.contains('周') && !instruction.contains('年') {
return Err(missing_week_year_prompt());
}
if let Some((year, week)) = extract_year_week(instruction) {
let Some(week_start) = week_start_date(year, week) else {
return Err(missing_period_prompt());
};
let week_end = week_start + Duration::days(6);
return Ok(PeriodResolution {
mode: "week",
mode_code: "2",
value: format!("{year}-W{week:02}"),
payload: json!({
"tjzq": "week",
"level": "00",
"weekSfdate": week_start.format("%Y-%m-%d").to_string(),
"weekEfdate": week_end.format("%Y-%m-%d").to_string(),
}),
});
}
if default_strategy == Some("lineloss_page_semantics") {
let today = Local::now().date_naive();
let Some(month_start) = NaiveDate::from_ymd_opt(today.year(), today.month(), 1) else {
return Err(missing_period_prompt());
};
return Ok(PeriodResolution {
mode: "week",
mode_code: "2",
value: format!(
"{}..{}",
month_start.format("%Y-%m-%d"),
today.format("%Y-%m-%d")
),
payload: json!({
"tjzq": "week",
"level": "00",
"weekSfdate": month_start.format("%Y-%m-%d").to_string(),
"weekEfdate": today.format("%Y-%m-%d").to_string(),
}),
});
}
Err(missing_period_prompt())
}
fn previous_month_value(today: NaiveDate) -> String {
let (year, month) = if today.month() == 1 {
(today.year() - 1, 12)
} else {
(today.year(), today.month() - 1)
};
format!("{year}-{month:02}")
}
fn resolve_fixed_enum(
scene_id: &str,
param: &SceneParam,
) -> Result<ParamResolutionStatus, ResolverError> {
let output_field = optional_config_string(&param.resolver_config, "output_field")
.unwrap_or_else(|| param.name.clone());
let value = required_config_string(scene_id, param, "value")?;
let mut outputs = Map::new();
outputs.insert(output_field, Value::String(value.to_string()));
Ok(ParamResolutionStatus::Resolved(outputs))
}
fn resolve_literal_passthrough(instruction: &str, param: &SceneParam) -> ParamResolutionStatus {
let output_field = optional_config_string(&param.resolver_config, "output_field")
.unwrap_or_else(|| param.name.clone());
let value = optional_config_string(&param.resolver_config, "value")
.unwrap_or_else(|| instruction.trim().to_string());
let mut outputs = Map::new();
outputs.insert(output_field, Value::String(value));
ParamResolutionStatus::Resolved(outputs)
}
fn required_config_string<'a>(
scene_id: &str,
param: &'a SceneParam,
key: &'static str,
) -> Result<&'a str, ResolverError> {
param
.resolver_config
.get(key)
.and_then(|value| value.as_str())
.ok_or_else(|| ResolverError::MissingResolverConfig {
scene_id: scene_id.to_string(),
param_name: param.name.clone(),
key,
})
}
fn optional_config_string(config: &Table, key: &str) -> Option<String> {
config
.get(key)
.and_then(|value| value.as_str())
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToString::to_string)
}
fn prompt_missing(param: &SceneParam) -> String {
let prompt = param.prompt_missing.trim();
if prompt.is_empty() {
missing_company_prompt()
} else {
prompt.to_string()
}
}
fn prompt_ambiguous(param: &SceneParam) -> String {
let prompt = param.prompt_ambiguous.trim();
if prompt.is_empty() {
ambiguous_company_prompt()
} else {
prompt.to_string()
}
}
fn extract_year_month_dash(input: &str) -> Option<String> {
let chars: Vec<char> = input.chars().collect();
for window in chars.windows(7) {
let candidate: String = window.iter().collect();
if is_year_month_dash(&candidate) {
return Some(candidate);
}
}
None
}
fn is_year_month_dash(candidate: &str) -> bool {
let bytes = candidate.as_bytes();
bytes.len() == 7
&& bytes[0..4].iter().all(u8::is_ascii_digit)
&& bytes[4] == b'-'
&& bytes[5..7].iter().all(u8::is_ascii_digit)
&& matches!((bytes[5] - b'0') * 10 + (bytes[6] - b'0'), 1..=12)
}
fn extract_year_month_cn(input: &str) -> Option<String> {
let chars: Vec<char> = input.chars().collect();
for index in 0..chars.len() {
if index + 6 >= chars.len() {
break;
}
if !chars[index..index + 4].iter().all(|ch| ch.is_ascii_digit()) {
continue;
}
if chars[index + 4] != '年' {
continue;
}
let mut month_digits = String::new();
let mut cursor = index + 5;
while cursor < chars.len() && chars[cursor].is_ascii_digit() && month_digits.len() < 2 {
month_digits.push(chars[cursor]);
cursor += 1;
}
if month_digits.is_empty() || cursor >= chars.len() || chars[cursor] != '月' {
continue;
}
let month: u32 = month_digits.parse().ok()?;
if !(1..=12).contains(&month) {
continue;
}
let year: String = chars[index..index + 4].iter().collect();
return Some(format!("{year}-{month:02}"));
}
None
}
fn extract_year_week(input: &str) -> Option<(i32, u32)> {
let chars: Vec<char> = input.chars().collect();
for index in 0..chars.len() {
if index + 7 >= chars.len() {
break;
}
if !chars[index..index + 4].iter().all(|ch| ch.is_ascii_digit()) {
continue;
}
if chars[index + 4] != '年' || chars[index + 5] != '第' {
continue;
}
let mut week_digits = String::new();
let mut cursor = index + 6;
while cursor < chars.len() && chars[cursor].is_ascii_digit() && week_digits.len() < 2 {
week_digits.push(chars[cursor]);
cursor += 1;
}
if week_digits.is_empty() || cursor >= chars.len() || chars[cursor] != '周' {
continue;
}
let year: i32 = chars[index..index + 4]
.iter()
.collect::<String>()
.parse()
.ok()?;
let week: u32 = week_digits.parse().ok()?;
if !(1..=53).contains(&week) {
continue;
}
return Some((year, week));
}
None
}
fn week_start_date(year: i32, week: u32) -> Option<NaiveDate> {
let jan4 = NaiveDate::from_ymd_opt(year, 1, 4)?;
let iso_week1_monday = jan4 - Duration::days(jan4.weekday().num_days_from_monday() as i64);
let candidate = iso_week1_monday + Duration::weeks((week - 1) as i64);
let iso = candidate.iso_week();
(iso.year() == year && iso.week() == week).then_some(candidate)
}

View File

@@ -21,8 +21,7 @@ pub struct ResolvedPeriod {
}
pub fn missing_company_prompt() -> String {
"已命中台区线损报表技能,但缺少供电单位,请补充如“兰州公司”或“城关供电分公司”。"
.to_string()
"已命中台区线损报表技能,但缺少供电单位,请补充如“兰州公司”或“城关供电分公司”。".to_string()
}
pub fn ambiguous_company_prompt() -> String {
@@ -30,21 +29,17 @@ pub fn ambiguous_company_prompt() -> String {
}
pub fn missing_period_mode_prompt() -> String {
"已命中台区线损报表技能,但未识别到月/周类型,请补充“月累计”或“周累计”。"
.to_string()
"已命中台区线损报表技能,但未识别到月/周类型,请补充“月累计”或“周累计”。".to_string()
}
pub fn missing_period_prompt() -> String {
"已命中台区线损报表技能但缺少统计周期请补充如“2026-03”或“2026年第12周”。"
.to_string()
"已命中台区线损报表技能但缺少统计周期请补充如“2026-03”或“2026年第12周”。".to_string()
}
pub fn contradictory_period_mode_prompt() -> String {
"已命中台区线损报表技能,但月/周类型存在冲突,请只保留“月累计”或“周累计”之一。"
.to_string()
"已命中台区线损报表技能,但月/周类型存在冲突,请只保留“月累计”或“周累计”之一。".to_string()
}
pub fn missing_week_year_prompt() -> String {
"已命中台区线损报表技能但周累计缺少年份请补充如“2026年第12周”。"
.to_string()
"已命中台区线损报表技能但周累计缺少年份请补充如“2026年第12周”。".to_string()
}

View File

@@ -1,4 +1 @@
pub mod contracts;
pub mod org_resolver;
pub mod org_units;
pub mod period_resolver;

View File

@@ -1,71 +0,0 @@
use super::contracts::{ambiguous_company_prompt, ResolvedOrg};
use super::org_units::{OrgUnit, ORG_UNITS};
fn normalize(value: &str) -> String {
value.chars().filter(|ch| !ch.is_whitespace()).collect()
}
fn candidate_names(unit: &'static OrgUnit) -> impl Iterator<Item = &'static str> {
std::iter::once(unit.label).chain(unit.aliases.iter().copied())
}
fn to_resolved_org(unit: &OrgUnit) -> ResolvedOrg {
ResolvedOrg {
label: unit.label.to_string(),
code: unit.code.to_string(),
}
}
pub fn resolve_org(input: &str) -> Result<ResolvedOrg, String> {
let normalized = normalize(input);
if normalized.is_empty() {
return Err(super::contracts::missing_company_prompt());
}
let exact_matches: Vec<&OrgUnit> = ORG_UNITS
.iter()
.filter(|unit| candidate_names(unit).any(|name| normalize(name) == normalized))
.collect();
if exact_matches.len() == 1 {
return Ok(to_resolved_org(exact_matches[0]));
}
if exact_matches.len() > 1 {
return Err(ambiguous_company_prompt());
}
let fuzzy_matches: Vec<&OrgUnit> = ORG_UNITS
.iter()
.filter(|unit| {
candidate_names(unit).any(|name| {
let normalized_name = normalize(name);
normalized_name.contains(&normalized) || normalized.contains(&normalized_name)
})
})
.collect();
if fuzzy_matches.len() == 1 {
return Ok(to_resolved_org(fuzzy_matches[0]));
}
if fuzzy_matches.len() > 1 {
return Err(ambiguous_company_prompt());
}
Err(super::contracts::missing_company_prompt())
}
pub fn resolve_org_from_instruction(instruction: &str) -> Result<Option<ResolvedOrg>, String> {
let normalized_instruction = normalize(instruction);
let direct_matches: Vec<&OrgUnit> = ORG_UNITS
.iter()
.filter(|unit| {
candidate_names(unit).any(|name| normalized_instruction.contains(&normalize(name)))
})
.collect();
if direct_matches.len() == 1 {
return Ok(Some(to_resolved_org(direct_matches[0])));
}
if direct_matches.len() > 1 {
return Err(ambiguous_company_prompt());
}
Ok(None)
}

View File

@@ -1,602 +0,0 @@
pub(crate) struct OrgUnit {
pub(crate) label: &'static str,
pub(crate) code: &'static str,
pub(crate) aliases: &'static [&'static str],
}
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: "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,244 +0,0 @@
use chrono::{Datelike, Duration, Local, NaiveDate};
use serde_json::json;
use super::contracts::{
contradictory_period_mode_prompt, missing_period_mode_prompt, missing_period_prompt,
missing_week_year_prompt, PeriodMode, ResolvedPeriod,
};
pub fn resolve_period(input: &str) -> Result<ResolvedPeriod, String> {
let has_month = input.contains("月累计");
let has_week = input.contains("周累计");
match (has_month, has_week) {
(true, true) => return Err(contradictory_period_mode_prompt()),
(false, false) => return Err(missing_period_mode_prompt()),
(true, false) => resolve_month_period(input),
(false, true) => resolve_week_period(input),
}
}
fn resolve_month_period(input: &str) -> Result<ResolvedPeriod, String> {
if let Some(value) = extract_year_month_dash(input) {
return Ok(ResolvedPeriod {
mode: PeriodMode::Month,
mode_code: "1".to_string(),
value: value.clone(),
payload: json!({ "fdate": value }),
});
}
if let Some(value) = extract_year_month_cn(input) {
return Ok(ResolvedPeriod {
mode: PeriodMode::Month,
mode_code: "1".to_string(),
value: value.clone(),
payload: json!({ "fdate": value }),
});
}
if contains_explicit_month_period_hint(input) {
return Err(missing_period_prompt());
}
Ok(default_month_period())
}
fn resolve_week_period(input: &str) -> Result<ResolvedPeriod, String> {
if input.contains('第') && input.contains('周') && !input.contains('年') {
return Err(missing_week_year_prompt());
}
if let Some((year, week)) = extract_year_week(input) {
let Some(week_start) = week_start_date(year, week) else {
return Err(missing_period_prompt());
};
let week_end = week_start + Duration::days(6);
return Ok(ResolvedPeriod {
mode: PeriodMode::Week,
mode_code: "2".to_string(),
value: format!("{year}-W{week:02}"),
payload: json!({
"tjzq": "week",
"level": "00",
"weekSfdate": week_start.format("%Y-%m-%d").to_string(),
"weekEfdate": week_end.format("%Y-%m-%d").to_string(),
}),
});
}
if contains_explicit_week_period_hint(input) {
return Err(missing_period_prompt());
}
Ok(default_week_period())
}
fn default_month_period() -> ResolvedPeriod {
let today = Local::now().date_naive();
let (year, month) = if today.month() == 1 {
(today.year() - 1, 12)
} else {
(today.year(), today.month() - 1)
};
let value = format!("{year}-{month:02}");
ResolvedPeriod {
mode: PeriodMode::Month,
mode_code: "1".to_string(),
value: value.clone(),
payload: json!({ "fdate": value }),
}
}
fn default_week_period() -> ResolvedPeriod {
let today = Local::now().date_naive();
let month_start = today.with_day(1).expect("current month should have day 1");
let start = month_start.format("%Y-%m-%d").to_string();
let end = today.format("%Y-%m-%d").to_string();
ResolvedPeriod {
mode: PeriodMode::Week,
mode_code: "2".to_string(),
value: format!("{start}至{end}"),
payload: json!({
"tjzq": "week",
"level": "00",
"weekSfdate": start,
"weekEfdate": end,
}),
}
}
fn contains_explicit_month_period_hint(input: &str) -> bool {
let trimmed = input.replace("月累计", "");
trimmed.contains('年')
|| trimmed.contains('月')
|| trimmed.contains('-')
|| trimmed.chars().any(|ch| ch.is_ascii_digit())
}
fn contains_explicit_week_period_hint(input: &str) -> bool {
let trimmed = input.replace("周累计", "");
trimmed.contains('年')
|| trimmed.contains('第')
|| trimmed.contains('周')
|| trimmed.contains('-')
|| trimmed.chars().any(|ch| ch.is_ascii_digit())
}
fn extract_year_month_dash(input: &str) -> Option<String> {
let chars: Vec<char> = input.chars().collect();
for window in chars.windows(7) {
let candidate: String = window.iter().collect();
if is_year_month_dash(&candidate) {
return Some(candidate);
}
}
None
}
fn is_year_month_dash(candidate: &str) -> bool {
let bytes = candidate.as_bytes();
bytes.len() == 7
&& bytes[0..4].iter().all(u8::is_ascii_digit)
&& bytes[4] == b'-'
&& bytes[5..7].iter().all(u8::is_ascii_digit)
&& matches!((bytes[5] - b'0') * 10 + (bytes[6] - b'0'), 1..=12)
}
fn extract_year_month_cn(input: &str) -> Option<String> {
let chars: Vec<char> = input.chars().collect();
for index in 0..chars.len() {
if index + 6 >= chars.len() {
break;
}
if !chars[index..index + 4].iter().all(|ch| ch.is_ascii_digit()) {
continue;
}
if chars[index + 4] != '年' {
continue;
}
let mut month_digits = String::new();
let mut cursor = index + 5;
while cursor < chars.len() && chars[cursor].is_ascii_digit() && month_digits.len() < 2 {
month_digits.push(chars[cursor]);
cursor += 1;
}
if month_digits.is_empty() || cursor >= chars.len() || chars[cursor] != '月' {
continue;
}
let month: u32 = month_digits.parse().ok()?;
if !(1..=12).contains(&month) {
continue;
}
let year: String = chars[index..index + 4].iter().collect();
return Some(format!("{year}-{month:02}"));
}
None
}
fn extract_year_week(input: &str) -> Option<(i32, u32)> {
let chars: Vec<char> = input.chars().collect();
for index in 0..chars.len() {
if index + 7 >= chars.len() {
break;
}
if !chars[index..index + 4].iter().all(|ch| ch.is_ascii_digit()) {
continue;
}
if chars[index + 4] != '年' || chars[index + 5] != '第' {
continue;
}
let mut week_digits = String::new();
let mut cursor = index + 6;
while cursor < chars.len() && chars[cursor].is_ascii_digit() && week_digits.len() < 2 {
week_digits.push(chars[cursor]);
cursor += 1;
}
if week_digits.is_empty() || cursor >= chars.len() || chars[cursor] != '周' {
continue;
}
let year: i32 = chars[index..index + 4].iter().collect::<String>().parse().ok()?;
let week: u32 = week_digits.parse().ok()?;
if !(1..=53).contains(&week) {
continue;
}
return Some((year, week));
}
None
}
fn week_start_date(year: i32, week: u32) -> Option<NaiveDate> {
let jan4 = NaiveDate::from_ymd_opt(year, 1, 4)?;
let iso_week1_monday = jan4 - Duration::days(jan4.weekday().num_days_from_monday() as i64);
let candidate = iso_week1_monday + Duration::weeks((week - 1) as i64);
let iso = candidate.iso_week();
(iso.year() == year && iso.week() == week).then_some(candidate)
}
#[cfg(test)]
mod tests {
use super::resolve_period;
use crate::compat::tq_lineloss::contracts::PeriodMode;
#[test]
fn resolves_dash_month() {
let resolved = resolve_period("月累计 2026-03").unwrap();
assert_eq!(resolved.mode, PeriodMode::Month);
assert_eq!(resolved.payload["fdate"], "2026-03");
}
#[test]
fn resolves_week_range() {
let resolved = resolve_period("周累计 2026年第12周").unwrap();
assert_eq!(resolved.mode, PeriodMode::Week);
assert_eq!(resolved.payload["weekSfdate"], "2026-03-16");
assert_eq!(resolved.payload["weekEfdate"], "2026-03-22");
}
}

View File

@@ -30,8 +30,7 @@ const EDITOR_READY_POLL_INTERVAL: Duration = Duration::from_millis(500);
// Readiness pattern: requires the "热度" suffix so that sidebar "大家都在搜"
// entries (which show bare "414万" without "热度") do NOT trigger a premature
// readiness signal. The main hotlist always renders "538万热度".
const HOTLIST_TEXT_READY_PATTERN: &str =
r"\d+(?:\.\d+)?\s*(?:万|亿|k|K|m|M)\s*热度";
const HOTLIST_TEXT_READY_PATTERN: &str = r"\d+(?:\.\d+)?\s*(?:万|亿|k|K|m|M)\s*热度";
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum WorkflowRoute {
ZhihuHotlistExportXlsx,
@@ -154,7 +153,9 @@ pub fn execute_route_with_browser_backend(
));
}
match route {
WorkflowRoute::ZhihuHotlistExportXlsx => export_xlsx(transport, workspace_root, &items),
WorkflowRoute::ZhihuHotlistExportXlsx => {
export_xlsx(transport, workspace_root, &items)
}
WorkflowRoute::ZhihuHotlistScreen => {
export_screen(transport, browser_backend.as_ref(), workspace_root, &items)
}
@@ -305,10 +306,12 @@ fn ensure_hotlist_page_ready(
// Log the last failure for diagnostics, then let caller try one final
// extraction as a last resort.
if let Some(msg) = last_error {
transport.send(&AgentMessage::LogEntry {
level: "warn".to_string(),
message: msg,
}).ok();
transport
.send(&AgentMessage::LogEntry {
level: "warn".to_string(),
message: msg,
})
.ok();
}
Ok(None)
}
@@ -422,20 +425,18 @@ fn poll_for_hotlist_readiness(browser_tool: &dyn BrowserBackend) -> Result<bool,
// Tolerate individual GetText failures (e.g. callback timeout) they
// are expected while the page is still loading or the callback delivery
// path is not yet established. Only a PipeClosed error is fatal.
let response = match browser_tool.invoke(
Action::GetText,
json!({ "selector": "body" }),
ZHIHU_DOMAIN,
) {
Ok(resp) => resp,
Err(PipeError::PipeClosed) => return Err(PipeError::PipeClosed),
Err(_) => {
if attempt + 1 < HOTLIST_READY_POLL_ATTEMPTS {
thread::sleep(HOTLIST_READY_POLL_INTERVAL);
let response =
match browser_tool.invoke(Action::GetText, json!({ "selector": "body" }), ZHIHU_DOMAIN)
{
Ok(resp) => resp,
Err(PipeError::PipeClosed) => return Err(PipeError::PipeClosed),
Err(_) => {
if attempt + 1 < HOTLIST_READY_POLL_ATTEMPTS {
thread::sleep(HOTLIST_READY_POLL_INTERVAL);
}
continue;
}
continue;
}
};
};
if response.success {
let payload = response.data.get("text").unwrap_or(&response.data);
if hotlist_text_looks_ready(payload, &ready_pattern) {
@@ -540,12 +541,14 @@ pub fn finalize_screen_export(
PipeError::Protocol("screen_html_export did not return output_path".to_string())
})?;
let presentation_url = payload["presentation"]["url"].as_str().unwrap_or_default();
Ok(match open_local_dashboard(browser_backend, Path::new(output_path), presentation_url) {
PostExportOpen::Opened => format!("已在浏览器中打开知乎热榜大屏 {output_path}"),
PostExportOpen::Failed(reason) => {
format!("已生成知乎热榜大屏 {output_path},但浏览器自动打开失败:{reason}")
}
})
Ok(
match open_local_dashboard(browser_backend, Path::new(output_path), presentation_url) {
PostExportOpen::Opened => format!("已在浏览器中打开知乎热榜大屏 {output_path}"),
PostExportOpen::Failed(reason) => {
format!("已生成知乎热榜大屏 {output_path},但浏览器自动打开失败:{reason}")
}
},
)
}
fn execute_zhihu_article_route(
@@ -558,7 +561,9 @@ fn execute_zhihu_article_route(
publish_authorized: bool,
article_override: Option<ArticleDraft>,
) -> Result<String, PipeError> {
let Some(article) = article_override.or_else(|| extract_article_draft(instruction, &task_context.messages)) else {
let Some(article) =
article_override.or_else(|| extract_article_draft(instruction, &task_context.messages))
else {
return Ok(
"这类知乎文章任务需要同时提供标题和正文后我才能继续确定性写作流程。请按“标题:…\\n正文…”的格式补充内容。"
.to_string(),
@@ -668,7 +673,10 @@ fn execute_generated_zhihu_article_publish_route(
settings: &SgClawSettings,
) -> Result<String, PipeError> {
let Some(topic) = extract_generated_article_topic(instruction) else {
return Ok("请按“在知乎自动发表一篇名称为…”或“在知乎自动发布一篇标题为…”的格式提供文章名称。".to_string());
return Ok(
"请按“在知乎自动发表一篇名称为…”或“在知乎自动发布一篇标题为…”的格式提供文章名称。"
.to_string(),
);
};
let article = crate::compat::runtime::generate_zhihu_article_draft(
@@ -693,7 +701,8 @@ fn execute_generated_zhihu_article_publish_route(
fn extract_generated_article_topic(instruction: &str) -> Option<String> {
let normalized = normalize_article_draft_input(instruction);
let name_re = Regex::new(r"(?:名称|标题)(?:是|为)\s*([^,。\n]+)").expect("valid generated zhihu topic regex");
let name_re = Regex::new(r"(?:名称|标题)(?:是|为)\s*([^,。\n]+)")
.expect("valid generated zhihu topic regex");
name_re
.captures(&normalized)
.and_then(|capture| capture.get(1))
@@ -862,8 +871,7 @@ fn execute_browser_skill_script(
args: Value,
expected_domain: &str,
) -> Result<Value, PipeError> {
let wrapped_script =
load_browser_skill_script(skills_dir, skill_name, script_name, args)?;
let wrapped_script = load_browser_skill_script(skills_dir, skill_name, script_name, args)?;
let response = browser_tool.invoke(
Action::Eval,
json!({ "script": wrapped_script }),
@@ -882,7 +890,8 @@ fn execute_browser_skill_script(
}
fn live_input_probe_script(selector_candidates: &[&str]) -> String {
let selectors_json = serde_json::to_string(selector_candidates).expect("valid selector candidates");
let selectors_json =
serde_json::to_string(selector_candidates).expect("valid selector candidates");
format!(
"var selectors={selectors_json};for(var i=0;i<selectors.length;i++){{var nodes=Array.from(document.querySelectorAll(selectors[i]));for(var j=0;j<nodes.length;j++){{var node=nodes[j];if(!node){{continue;}}var rect=(typeof node.getBoundingClientRect==='function')?node.getBoundingClientRect():null;var visible=!rect||rect.width>0||rect.height>0;if(!visible){{continue;}}return node;}}}}return null;"
)
@@ -1069,7 +1078,8 @@ return JSON.stringify({{status:'ok',chunks:chunks.length}});
)?;
if !response.success {
return Err(PipeError::Protocol(format!(
"publish button click failed: {}", response.data
"publish button click failed: {}",
response.data
)));
}
Ok(normalize_payload(
@@ -1344,7 +1354,8 @@ mod tests {
}
#[test]
fn execute_route_with_browser_backend_navigates_to_editor_when_creator_script_misreports_ready_on_www() {
fn execute_route_with_browser_backend_navigates_to_editor_when_creator_script_misreports_ready_on_www(
) {
let transport = Arc::new(MockWorkflowTransport::new(vec![]));
let backend = Arc::new(FakeBrowserBackend::new(vec![
Ok(CommandOutput {
@@ -1549,115 +1560,118 @@ mod tests {
#[test]
fn execute_route_with_browser_backend_uses_live_input_probes_for_zhihu_fill_when_supported() {
let transport = Arc::new(MockWorkflowTransport::new(vec![]));
let backend = Arc::new(FakeBrowserBackend::new(vec![
Ok(CommandOutput {
seq: 1,
success: true,
data: json!({}),
aom_snapshot: vec![],
timing: Timing {
queue_ms: 1,
exec_ms: 1,
},
}),
Ok(CommandOutput {
seq: 2,
success: true,
data: json!({
"text": {
"status": "creator_entry_clicked",
"current_url": "https://www.zhihu.com/creator",
"next_url": ZHIHU_EDITOR_URL,
}
let backend = Arc::new(
FakeBrowserBackend::new(vec![
Ok(CommandOutput {
seq: 1,
success: true,
data: json!({}),
aom_snapshot: vec![],
timing: Timing {
queue_ms: 1,
exec_ms: 1,
},
}),
aom_snapshot: vec![],
timing: Timing {
queue_ms: 1,
exec_ms: 1,
},
}),
Ok(CommandOutput {
seq: 3,
success: true,
data: json!({}),
aom_snapshot: vec![],
timing: Timing {
queue_ms: 1,
exec_ms: 1,
},
}),
Ok(CommandOutput {
seq: 4,
success: true,
data: json!({
"text": {
"status": "editor_ready",
"current_url": ZHIHU_EDITOR_URL,
}
Ok(CommandOutput {
seq: 2,
success: true,
data: json!({
"text": {
"status": "creator_entry_clicked",
"current_url": "https://www.zhihu.com/creator",
"next_url": ZHIHU_EDITOR_URL,
}
}),
aom_snapshot: vec![],
timing: Timing {
queue_ms: 1,
exec_ms: 1,
},
}),
aom_snapshot: vec![],
timing: Timing {
queue_ms: 1,
exec_ms: 1,
},
}),
Ok(CommandOutput {
seq: 5,
success: true,
data: json!({}),
aom_snapshot: vec![],
timing: Timing {
queue_ms: 1,
exec_ms: 1,
},
}),
Ok(CommandOutput {
seq: 6,
success: true,
data: json!({}),
aom_snapshot: vec![],
timing: Timing {
queue_ms: 1,
exec_ms: 1,
},
}),
Ok(CommandOutput {
seq: 7,
success: true,
data: json!({}),
aom_snapshot: vec![],
timing: Timing {
queue_ms: 1,
exec_ms: 1,
},
}),
Ok(CommandOutput {
seq: 8,
success: true,
data: json!({}),
aom_snapshot: vec![],
timing: Timing {
queue_ms: 1,
exec_ms: 1,
},
}),
Ok(CommandOutput {
seq: 9,
success: true,
data: json!({
"text": {
"status": "draft_ready",
"current_url": ZHIHU_EDITOR_URL,
"title": "测试标题"
}
Ok(CommandOutput {
seq: 3,
success: true,
data: json!({}),
aom_snapshot: vec![],
timing: Timing {
queue_ms: 1,
exec_ms: 1,
},
}),
aom_snapshot: vec![],
timing: Timing {
queue_ms: 1,
exec_ms: 1,
},
}),
]).with_live_input());
Ok(CommandOutput {
seq: 4,
success: true,
data: json!({
"text": {
"status": "editor_ready",
"current_url": ZHIHU_EDITOR_URL,
}
}),
aom_snapshot: vec![],
timing: Timing {
queue_ms: 1,
exec_ms: 1,
},
}),
Ok(CommandOutput {
seq: 5,
success: true,
data: json!({}),
aom_snapshot: vec![],
timing: Timing {
queue_ms: 1,
exec_ms: 1,
},
}),
Ok(CommandOutput {
seq: 6,
success: true,
data: json!({}),
aom_snapshot: vec![],
timing: Timing {
queue_ms: 1,
exec_ms: 1,
},
}),
Ok(CommandOutput {
seq: 7,
success: true,
data: json!({}),
aom_snapshot: vec![],
timing: Timing {
queue_ms: 1,
exec_ms: 1,
},
}),
Ok(CommandOutput {
seq: 8,
success: true,
data: json!({}),
aom_snapshot: vec![],
timing: Timing {
queue_ms: 1,
exec_ms: 1,
},
}),
Ok(CommandOutput {
seq: 9,
success: true,
data: json!({
"text": {
"status": "draft_ready",
"current_url": ZHIHU_EDITOR_URL,
"title": "测试标题"
}
}),
aom_snapshot: vec![],
timing: Timing {
queue_ms: 1,
exec_ms: 1,
},
}),
])
.with_live_input(),
);
let summary = execute_zhihu_article_route(
transport.as_ref(),
@@ -1691,8 +1705,9 @@ mod tests {
.as_str()
.is_some_and(|s| s.contains("第一段内容") && s.contains("ClipboardEvent")));
assert_eq!(invocations[8].0, Action::Eval);
assert_eq!(invocations[8].1["script"], json!(
load_browser_skill_script(
assert_eq!(
invocations[8].1["script"],
json!(load_browser_skill_script(
test_skills_dir(),
"zhihu-write",
"fill_article_draft.js",
@@ -1703,8 +1718,8 @@ mod tests {
"input_mode": "live_input",
})
)
.expect("zhihu write fill script should load")
));
.expect("zhihu write fill script should load"))
);
}
#[test]
@@ -1803,9 +1818,11 @@ mod tests {
let invocations = backend.invocations();
assert_eq!(invocations[7].0, Action::Eval);
assert!(invocations[7].1["script"]
.as_str()
.is_some_and(|s| s.contains("第一段内容\\n第二段内容") && s.contains("ClipboardEvent")));
assert!(
invocations[7].1["script"].as_str().is_some_and(|s| s
.contains("第一段内容\\n第二段内容")
&& s.contains("ClipboardEvent"))
);
}
#[test]
@@ -2027,7 +2044,7 @@ mod tests {
10,
&task_context,
)
.expect("hotlist collection should succeed");
.expect("hotlist collection should succeed");
assert_eq!(items.len(), 2);
let sent = transport.sent_messages();
@@ -2087,7 +2104,7 @@ mod tests {
10,
&task_context,
)
.expect("hotlist collection should succeed after readiness polling");
.expect("hotlist collection should succeed after readiness polling");
assert_eq!(items.len(), 1);
let sent = transport.sent_messages();
@@ -2126,10 +2143,7 @@ mod tests {
success_browser_response(11, json!({ "text": "" })),
success_browser_response(12, json!({ "text": { "rows": [] } })),
success_browser_response(13, json!({ "navigated": true })),
success_browser_response(
14,
json!({ "text": "知乎热榜\n1 问题一 344万热度" }),
),
success_browser_response(14, json!({ "text": "知乎热榜\n1 问题一 344万热度" })),
success_browser_response(
15,
json!({
@@ -2162,7 +2176,7 @@ mod tests {
10,
&task_context,
)
.expect("hotlist collection should succeed after one navigation retry");
.expect("hotlist collection should succeed after one navigation retry");
assert_eq!(items.len(), 1);
let sent = transport.sent_messages();
@@ -2235,7 +2249,7 @@ mod tests {
10,
&task_context,
)
.expect("hotlist collection should succeed via extractor probe");
.expect("hotlist collection should succeed via extractor probe");
assert_eq!(items.len(), 1);
let sent = transport.sent_messages();

View File

@@ -205,7 +205,10 @@ impl SgClawSettings {
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()),
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(),
@@ -523,7 +526,13 @@ fn resolve_configured_skills_dir(raw: Option<String>, config_dir: &Path) -> Opti
raw.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
.map(PathBuf::from)
.map(|path| if path.is_absolute() { path } else { config_dir.join(path) })
.map(|path| {
if path.is_absolute() {
path
} else {
config_dir.join(path)
}
})
}
fn normalize_required_value(field: &'static str, raw: String) -> Result<String, ConfigError> {
@@ -613,7 +622,10 @@ struct SerializableRawSgClawSettings {
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")]
#[serde(
rename = "serviceWsListenAddr",
skip_serializing_if = "Option::is_none"
)]
service_ws_listen_addr: Option<String>,
#[serde(default)]
providers: Vec<SerializableProviderSettings>,
@@ -662,7 +674,11 @@ struct RawSgClawSettings {
office_backend: Option<String>,
#[serde(rename = "browserWsUrl", alias = "browser_ws_url", default)]
browser_ws_url: Option<String>,
#[serde(rename = "serviceWsListenAddr", alias = "service_ws_listen_addr", default)]
#[serde(
rename = "serviceWsListenAddr",
alias = "service_ws_listen_addr",
default
)]
service_ws_listen_addr: Option<String>,
#[serde(default)]
providers: Vec<RawProviderSettings>,

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

625
src/generated_scene/ir.rs Normal file
View File

@@ -0,0 +1,625 @@
use std::collections::HashMap;
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum WorkflowArchetype {
#[serde(rename = "single_request_table")]
SingleRequestTable,
#[serde(rename = "single_request_enrichment")]
SingleRequestEnrichment,
#[serde(rename = "multi_mode_request")]
MultiModeRequest,
#[serde(rename = "paginated_enrichment")]
PaginatedEnrichment,
#[serde(rename = "host_bridge_workflow")]
HostBridgeWorkflow,
#[serde(rename = "multi_endpoint_inventory")]
MultiEndpointInventory,
#[serde(rename = "local_doc_pipeline")]
LocalDocPipeline,
#[serde(rename = "page_state_eval")]
PageStateEval,
}
impl WorkflowArchetype {
pub fn as_str(&self) -> &'static str {
match self {
Self::SingleRequestTable => "single_request_table",
Self::SingleRequestEnrichment => "single_request_enrichment",
Self::MultiModeRequest => "multi_mode_request",
Self::PaginatedEnrichment => "paginated_enrichment",
Self::HostBridgeWorkflow => "host_bridge_workflow",
Self::MultiEndpointInventory => "multi_endpoint_inventory",
Self::LocalDocPipeline => "local_doc_pipeline",
Self::PageStateEval => "page_state_eval",
}
}
pub fn from_str(raw: &str) -> Option<Self> {
match raw.trim() {
"single_request_table" => Some(Self::SingleRequestTable),
"single_request_enrichment" => Some(Self::SingleRequestEnrichment),
"multi_mode_request" => Some(Self::MultiModeRequest),
"paginated_enrichment" => Some(Self::PaginatedEnrichment),
"host_bridge_workflow" => Some(Self::HostBridgeWorkflow),
"multi_endpoint_inventory" => Some(Self::MultiEndpointInventory),
"local_doc_pipeline" => Some(Self::LocalDocPipeline),
"page_state_eval" => Some(Self::PageStateEval),
_ => None,
}
}
}
fn default_scene_kind() -> String {
"report_collection".to_string()
}
fn default_requires_target_page() -> bool {
true
}
fn default_confidence() -> f64 {
0.0
}
fn default_report_artifact_type() -> String {
"report-artifact".to_string()
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct BootstrapIr {
#[serde(rename = "expectedDomain", default)]
pub expected_domain: String,
#[serde(rename = "targetUrl", default)]
pub target_url: String,
#[serde(rename = "appEntryUrl", default)]
pub app_entry_url: String,
#[serde(rename = "moduleRouteUrl", default)]
pub module_route_url: String,
#[serde(rename = "targetUrlKind", default)]
pub target_url_kind: Option<String>,
#[serde(
rename = "requiresTargetPage",
default = "default_requires_target_page"
)]
pub requires_target_page: bool,
#[serde(rename = "pageTitleKeywords", default)]
pub page_title_keywords: Vec<String>,
#[serde(rename = "source", default)]
pub source: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ApiEndpointIr {
pub name: String,
pub url: String,
#[serde(default)]
pub method: String,
#[serde(rename = "contentType", default)]
pub content_type: Option<String>,
#[serde(default)]
pub description: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ParamIr {
pub name: String,
pub resolver: String,
#[serde(default)]
pub required: bool,
#[serde(rename = "promptMissing", default)]
pub prompt_missing: String,
#[serde(rename = "promptAmbiguous", default)]
pub prompt_ambiguous: String,
#[serde(rename = "resolverConfig", default)]
pub resolver_config: Map<String, Value>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ModeConditionIr {
pub field: String,
#[serde(default)]
pub operator: String,
pub value: Value,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct NormalizeRulesIr {
#[serde(rename = "type", default)]
pub rules_type: String,
#[serde(rename = "requiredFields", default)]
pub required_fields: Vec<String>,
#[serde(rename = "filterNull", default)]
pub filter_null: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct RequestFieldMappingIr {
#[serde(rename = "sourceField", default)]
pub source_field: String,
#[serde(rename = "targetField", default)]
pub target_field: String,
#[serde(default)]
pub mode: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ModeIr {
pub name: String,
#[serde(default)]
pub label: Option<String>,
#[serde(default)]
pub condition: Option<ModeConditionIr>,
#[serde(rename = "apiEndpoint", default)]
pub api_endpoint: Option<ApiEndpointIr>,
#[serde(rename = "columnDefs", default)]
pub column_defs: Vec<(String, String)>,
#[serde(rename = "requestTemplate", default)]
pub request_template: Value,
#[serde(rename = "requestFieldMappings", default)]
pub request_field_mappings: Vec<RequestFieldMappingIr>,
#[serde(rename = "normalizeRules", default)]
pub normalize_rules: Option<NormalizeRulesIr>,
#[serde(rename = "responsePath", default)]
pub response_path: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct WorkflowStepIr {
#[serde(rename = "type", default)]
pub step_type: String,
#[serde(default)]
pub entry: Option<String>,
#[serde(default)]
pub source: Option<String>,
#[serde(default)]
pub expr: Option<String>,
#[serde(default)]
pub description: Option<String>,
#[serde(default)]
pub endpoint: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct WorkflowEvidenceIr {
#[serde(rename = "requestEntries", default)]
pub request_entries: Vec<String>,
#[serde(rename = "paginationFields", default)]
pub pagination_fields: Vec<String>,
#[serde(rename = "secondaryRequestEntries", default)]
pub secondary_request_entries: Vec<String>,
#[serde(rename = "postProcessSteps", default)]
pub post_process_steps: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct MainRequestIr {
#[serde(rename = "apiEndpoint", default)]
pub api_endpoint: Option<ApiEndpointIr>,
#[serde(rename = "requestTemplate", default)]
pub request_template: Value,
#[serde(rename = "responsePath", default)]
pub response_path: String,
#[serde(rename = "columnDefs", default)]
pub column_defs: Vec<(String, String)>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct PaginationPlanIr {
#[serde(rename = "pageField", default)]
pub page_field: String,
#[serde(rename = "pageSizeField", default)]
pub page_size_field: Option<String>,
#[serde(rename = "startPage", default)]
pub start_page: Option<u64>,
#[serde(rename = "terminationRule", default)]
pub termination_rule: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct EnrichmentRequestIr {
#[serde(default)]
pub name: String,
#[serde(rename = "apiEndpoint", default)]
pub api_endpoint: Option<ApiEndpointIr>,
#[serde(rename = "paramBindings", default)]
pub param_bindings: Map<String, Value>,
#[serde(rename = "responsePath", default)]
pub response_path: String,
#[serde(rename = "consumedFields", default)]
pub consumed_fields: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ExportPlanIr {
#[serde(rename = "entry", default)]
pub entry: Option<String>,
#[serde(rename = "artifactType", default)]
pub artifact_type: Option<String>,
#[serde(rename = "dependsOnHostBridge", default)]
pub depends_on_host_bridge: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct RuntimeDependencyIr {
#[serde(rename = "url", default)]
pub url: String,
#[serde(rename = "role", default)]
pub role: String,
#[serde(rename = "subordinateToBusinessChain", default)]
pub subordinate_to_business_chain: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct MergeFieldMappingIr {
#[serde(rename = "outputField", default)]
pub output_field: String,
#[serde(rename = "sourceType", default)]
pub source_type: String,
#[serde(rename = "sourceField", default)]
pub source_field: String,
#[serde(rename = "requestName", default)]
pub request_name: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct MergePlanIr {
#[serde(rename = "joinKeys", default)]
pub join_keys: Vec<String>,
#[serde(rename = "fieldMappings", default)]
pub field_mappings: Vec<MergeFieldMappingIr>,
#[serde(rename = "aggregateRules", default)]
pub aggregate_rules: Vec<String>,
#[serde(rename = "outputColumns", default)]
pub output_columns: Vec<(String, String)>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ArtifactContractIr {
#[serde(rename = "type", default = "default_report_artifact_type")]
pub artifact_type: String,
#[serde(rename = "successStatus", default)]
pub success_status: Vec<String>,
#[serde(rename = "failureStatus", default)]
pub failure_status: Vec<String>,
}
impl Default for ArtifactContractIr {
fn default() -> Self {
Self {
artifact_type: default_report_artifact_type(),
success_status: vec!["ok".to_string(), "partial".to_string(), "empty".to_string()],
failure_status: vec!["blocked".to_string(), "error".to_string()],
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ValidationHintsIr {
#[serde(
rename = "requiresTargetPage",
default = "default_requires_target_page"
)]
pub requires_target_page: bool,
#[serde(rename = "runtimeCompatible", default)]
pub runtime_compatible: bool,
#[serde(rename = "manualCompletionRequired", default)]
pub manual_completion_required: bool,
#[serde(rename = "missingPieces", default)]
pub missing_pieces: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct EvidenceIr {
#[serde(default)]
pub kind: String,
#[serde(rename = "evidenceType", default)]
pub evidence_type: String,
#[serde(default)]
pub layer: String,
#[serde(default)]
pub subject: Option<String>,
#[serde(default)]
pub summary: String,
#[serde(default)]
pub source: Option<String>,
#[serde(default = "default_confidence")]
pub confidence: f64,
#[serde(default)]
pub payload: Option<Map<String, Value>>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ReadinessIr {
#[serde(default)]
pub level: String,
#[serde(default = "default_confidence")]
pub confidence: f64,
#[serde(default)]
pub gates: Vec<ReadinessGateIr>,
#[serde(default)]
pub risks: Vec<String>,
#[serde(rename = "missingPieces", default)]
pub missing_pieces: Vec<String>,
#[serde(default)]
pub notes: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ReadinessGateIr {
#[serde(default)]
pub name: String,
#[serde(default)]
pub passed: bool,
#[serde(default)]
pub reason: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct SceneIdCandidateIr {
#[serde(default)]
pub value: String,
#[serde(default)]
pub source: String,
#[serde(default)]
pub valid: bool,
#[serde(default)]
pub reason: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct SceneIdDiagnosticsIr {
#[serde(rename = "candidateSource", default)]
pub candidate_source: String,
#[serde(default)]
pub valid: bool,
#[serde(rename = "invalidReason", default)]
pub invalid_reason: Option<String>,
#[serde(default)]
pub candidates: Vec<SceneIdCandidateIr>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SceneIr {
#[serde(rename = "sceneId")]
pub scene_id: String,
#[serde(rename = "sceneIdDiagnostics", default)]
pub scene_id_diagnostics: SceneIdDiagnosticsIr,
#[serde(rename = "sceneName")]
pub scene_name: String,
#[serde(rename = "sceneKind", default = "default_scene_kind")]
pub scene_kind: String,
#[serde(rename = "workflowArchetype", default)]
pub workflow_archetype: Option<WorkflowArchetype>,
#[serde(default)]
pub bootstrap: BootstrapIr,
#[serde(default)]
pub params: Vec<ParamIr>,
#[serde(default)]
pub modes: Vec<ModeIr>,
#[serde(rename = "defaultMode", default)]
pub default_mode: Option<String>,
#[serde(rename = "modeSwitchField", default)]
pub mode_switch_field: Option<String>,
#[serde(rename = "workflowSteps", default)]
pub workflow_steps: Vec<WorkflowStepIr>,
#[serde(rename = "workflowEvidence", default)]
pub workflow_evidence: WorkflowEvidenceIr,
#[serde(rename = "mainRequest", default)]
pub main_request: Option<MainRequestIr>,
#[serde(rename = "paginationPlan", default)]
pub pagination_plan: Option<PaginationPlanIr>,
#[serde(rename = "enrichmentRequests", default)]
pub enrichment_requests: Vec<EnrichmentRequestIr>,
#[serde(rename = "joinKeys", default)]
pub join_keys: Vec<String>,
#[serde(rename = "mergeOrDedupeRules", default)]
pub merge_or_dedupe_rules: Vec<String>,
#[serde(rename = "exportPlan", default)]
pub export_plan: Option<ExportPlanIr>,
#[serde(rename = "mergePlan", default)]
pub merge_plan: Option<MergePlanIr>,
#[serde(rename = "requestTemplate", default)]
pub request_template: Value,
#[serde(rename = "responsePath", default)]
pub response_path: String,
#[serde(rename = "normalizeRules", default)]
pub normalize_rules: Option<NormalizeRulesIr>,
#[serde(rename = "artifactContract", default)]
pub artifact_contract: ArtifactContractIr,
#[serde(rename = "validationHints", default)]
pub validation_hints: ValidationHintsIr,
#[serde(default)]
pub evidence: Vec<EvidenceIr>,
#[serde(default)]
pub readiness: ReadinessIr,
#[serde(rename = "apiEndpoints", default)]
pub api_endpoints: Vec<ApiEndpointIr>,
#[serde(rename = "runtimeDependencies", default)]
pub runtime_dependencies: Vec<RuntimeDependencyIr>,
#[serde(rename = "staticParams", default)]
pub static_params: HashMap<String, Value>,
#[serde(rename = "columnDefs", default)]
pub column_defs: Vec<(String, String)>,
#[serde(default)]
pub confidence: f64,
#[serde(default)]
pub uncertainties: Vec<String>,
}
impl SceneIr {
pub fn workflow_archetype(&self) -> WorkflowArchetype {
self.workflow_archetype.clone().unwrap_or_else(|| {
if self.main_request.is_some() || !self.enrichment_requests.is_empty() {
WorkflowArchetype::SingleRequestEnrichment
} else if !self.modes.is_empty() {
WorkflowArchetype::MultiModeRequest
} else {
WorkflowArchetype::SingleRequestTable
}
})
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct LegacyModeConditionJson {
pub field: String,
#[serde(default)]
pub operator: String,
pub value: Value,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct LegacyNormalizeRulesJson {
#[serde(rename = "type", default)]
pub rules_type: String,
#[serde(rename = "requiredFields", default)]
pub required_fields: Vec<String>,
#[serde(rename = "filterNull", default)]
pub filter_null: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct LegacyModeConfigJson {
pub name: String,
#[serde(default)]
pub label: Option<String>,
pub condition: LegacyModeConditionJson,
#[serde(rename = "apiEndpoint", default)]
pub api_endpoint: ApiEndpointIr,
#[serde(rename = "columnDefs", default)]
pub column_defs: Vec<(String, String)>,
#[serde(rename = "requestTemplate", default)]
pub request_template: Value,
#[serde(rename = "normalizeRules", default)]
pub normalize_rules: Option<LegacyNormalizeRulesJson>,
#[serde(rename = "responsePath", default)]
pub response_path: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct LegacyBusinessLogicJson {
#[serde(default)]
pub data_fetch: Option<String>,
#[serde(default)]
pub data_transform: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct LegacySceneInfoJson {
#[serde(rename = "sceneId")]
pub scene_id: String,
#[serde(rename = "sceneName")]
pub scene_name: String,
#[serde(rename = "sceneKind", default = "default_scene_kind")]
pub scene_kind: String,
#[serde(rename = "sourceSystem", default)]
pub source_system: Option<String>,
#[serde(rename = "expectedDomain", default)]
pub expected_domain: Option<String>,
#[serde(rename = "targetUrl", default)]
pub target_url: Option<String>,
#[serde(rename = "apiEndpoints", default)]
pub api_endpoints: Vec<ApiEndpointIr>,
#[serde(rename = "staticParams", default)]
pub static_params: HashMap<String, Value>,
#[serde(rename = "columnDefs", default)]
pub column_defs: Vec<(String, String)>,
#[serde(rename = "entryMethod", default)]
pub entry_method: Option<String>,
#[serde(rename = "businessLogic", default)]
pub business_logic: Option<LegacyBusinessLogicJson>,
#[serde(default)]
pub modes: Vec<LegacyModeConfigJson>,
#[serde(rename = "defaultMode", default)]
pub default_mode: Option<String>,
#[serde(rename = "modeSwitchField", default)]
pub mode_switch_field: Option<String>,
}
impl From<LegacySceneInfoJson> for SceneIr {
fn from(value: LegacySceneInfoJson) -> Self {
let workflow_steps = value
.entry_method
.iter()
.map(|entry| WorkflowStepIr {
step_type: "request".to_string(),
entry: Some(entry.clone()),
..WorkflowStepIr::default()
})
.collect::<Vec<_>>();
let modes = value
.modes
.into_iter()
.map(|mode| ModeIr {
name: mode.name,
label: mode.label,
condition: Some(ModeConditionIr {
field: mode.condition.field,
operator: mode.condition.operator,
value: mode.condition.value,
}),
api_endpoint: Some(mode.api_endpoint),
column_defs: mode.column_defs,
request_template: mode.request_template,
request_field_mappings: Vec::new(),
normalize_rules: mode.normalize_rules.map(|rules| NormalizeRulesIr {
rules_type: rules.rules_type,
required_fields: rules.required_fields,
filter_null: rules.filter_null,
}),
response_path: mode.response_path,
})
.collect::<Vec<_>>();
let workflow_archetype = if !modes.is_empty() {
Some(WorkflowArchetype::MultiModeRequest)
} else {
Some(WorkflowArchetype::SingleRequestTable)
};
Self {
scene_id: value.scene_id,
scene_id_diagnostics: SceneIdDiagnosticsIr::default(),
scene_name: value.scene_name,
scene_kind: value.scene_kind,
workflow_archetype,
bootstrap: BootstrapIr {
expected_domain: value.expected_domain.unwrap_or_default(),
target_url: value.target_url.unwrap_or_default(),
app_entry_url: String::new(),
module_route_url: String::new(),
target_url_kind: None,
requires_target_page: true,
page_title_keywords: Vec::new(),
source: value.source_system,
},
params: Vec::new(),
modes,
default_mode: value.default_mode,
mode_switch_field: value.mode_switch_field,
workflow_steps,
workflow_evidence: WorkflowEvidenceIr::default(),
main_request: None,
pagination_plan: None,
enrichment_requests: Vec::new(),
join_keys: Vec::new(),
merge_or_dedupe_rules: Vec::new(),
export_plan: None,
merge_plan: None,
request_template: Value::Null,
response_path: String::new(),
normalize_rules: None,
artifact_contract: ArtifactContractIr::default(),
validation_hints: ValidationHintsIr::default(),
evidence: Vec::new(),
readiness: ReadinessIr::default(),
api_endpoints: value.api_endpoints,
runtime_dependencies: Vec::new(),
static_params: value.static_params,
column_defs: value.column_defs,
confidence: 0.0,
uncertainties: Vec::new(),
}
}
}

View File

@@ -0,0 +1,98 @@
use std::fs;
use std::path::Path;
use serde::Deserialize;
#[derive(Debug, Deserialize)]
pub struct GenerationLessons {
pub routing: RoutingLessons,
pub canonical_params: CanonicalParamLessons,
pub bootstrap: BootstrapLessons,
pub artifact: ArtifactLessons,
pub validation: ValidationLessons,
}
#[derive(Debug, Deserialize)]
pub struct RoutingLessons {
pub require_exact_suffix: bool,
pub unsupported_scene_fail_closed: bool,
pub ambiguity_fail_closed: bool,
}
#[derive(Debug, Deserialize)]
pub struct CanonicalParamLessons {
pub require_dictionary_entity_for_org: bool,
pub require_explicit_period: bool,
pub forbid_hidden_page_defaults: bool,
}
#[derive(Debug, Deserialize)]
pub struct BootstrapLessons {
pub require_expected_domain: bool,
pub require_target_url: bool,
pub prefer_page_context_when_present: bool,
}
#[derive(Debug, Deserialize)]
pub struct ArtifactLessons {
pub require_report_artifact: bool,
pub require_column_defs_for_export: bool,
pub rust_side_xlsx_export_when_postprocess_xlsx: bool,
}
#[derive(Debug, Deserialize)]
pub struct ValidationLessons {
pub require_pipe_and_ws_checks: bool,
pub require_manual_service_console_smoke: bool,
pub require_callback_host_timeout_notes: bool,
}
pub const BUILTIN_REPORT_COLLECTION_LESSONS: &str = "builtin:report_collection_v1";
impl GenerationLessons {
pub fn default_report_collection() -> Self {
Self {
routing: RoutingLessons {
require_exact_suffix: true,
unsupported_scene_fail_closed: true,
ambiguity_fail_closed: true,
},
canonical_params: CanonicalParamLessons {
require_dictionary_entity_for_org: true,
require_explicit_period: true,
forbid_hidden_page_defaults: true,
},
bootstrap: BootstrapLessons {
require_expected_domain: true,
require_target_url: true,
prefer_page_context_when_present: true,
},
artifact: ArtifactLessons {
require_report_artifact: true,
require_column_defs_for_export: true,
rust_side_xlsx_export_when_postprocess_xlsx: true,
},
validation: ValidationLessons {
require_pipe_and_ws_checks: true,
require_manual_service_console_smoke: true,
require_callback_host_timeout_notes: true,
},
}
}
}
pub fn load_generation_lessons(path: impl AsRef<Path>) -> Result<GenerationLessons, String> {
let path = path.as_ref();
let content = fs::read_to_string(path).map_err(|err| {
format!(
"failed to read generation lessons {}: {err}",
path.display()
)
})?;
toml::from_str(&content).map_err(|err| {
format!(
"failed to parse generation lessons {}: {err}",
path.display()
)
})
}

View File

@@ -0,0 +1,4 @@
pub mod analyzer;
pub mod generator;
pub mod ir;
pub mod lessons;

View File

@@ -2,9 +2,11 @@ pub mod agent;
pub mod browser;
pub mod compat;
pub mod config;
pub mod generated_scene;
pub mod llm;
pub mod pipe;
pub mod runtime;
pub mod scene_contract;
pub mod security;
pub mod service;

View File

@@ -79,11 +79,9 @@ impl<T: Transport> BrowserPipeTool<T> {
params: Value,
expected_domain: &str,
) -> Result<CommandOutput, PipeError> {
if let Some((presentation_url, output_path)) = approved_local_dashboard_request(
&action,
&params,
expected_domain,
) {
if let Some((presentation_url, output_path)) =
approved_local_dashboard_request(&action, &params, expected_domain)
{
self.mac_policy
.validate_local_dashboard_presentation(
&action,

View File

@@ -285,8 +285,8 @@ pub fn is_zhihu_hotlist_task(
|| normalized_url.contains("zhihu.com")
|| normalized_title.contains("zhihu")
|| page_title.unwrap_or_default().contains("知乎");
let hotlist_in_instruction = normalized_instruction.contains("hotlist")
|| instruction.contains("热榜");
let hotlist_in_instruction =
normalized_instruction.contains("hotlist") || instruction.contains("热榜");
let hotlist_in_context = normalized_url.contains("/hot")
|| normalized_title.contains("hotlist")
|| page_title.unwrap_or_default().contains("热榜");
@@ -358,7 +358,10 @@ pub fn is_zhihu_write_task(
is_zhihu && is_write
}
fn load_runtime_skills(config: &ZeroClawConfig, skills_dirs: &[PathBuf]) -> Vec<zeroclaw::skills::Skill> {
fn load_runtime_skills(
config: &ZeroClawConfig,
skills_dirs: &[PathBuf],
) -> Vec<zeroclaw::skills::Skill> {
let default_skills_dir = config.workspace_dir.join("skills");
// When using only the default workspace skills directory, use the

View File

@@ -0,0 +1,81 @@
use serde::{Deserialize, Serialize};
use toml::Table;
pub const SCENE_MANIFEST_FILE_NAME: &str = "scene.toml";
pub const SUPPORTED_SCHEMA_VERSION_V1: &str = "1";
pub const SUPPORTED_SCENE_KIND_V1: &str = "browser_script";
pub const SUPPORTED_SCENE_CATEGORY_V1: &str = "report_collection";
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SceneManifest {
pub scene: SceneSection,
pub manifest: ManifestSection,
pub bootstrap: BootstrapSection,
pub deterministic: DeterministicSection,
#[serde(default)]
pub params: Vec<SceneParam>,
pub artifact: ArtifactSection,
#[serde(default)]
pub postprocess: Option<PostprocessSection>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SceneSection {
pub id: String,
pub skill: String,
pub tool: String,
pub kind: String,
pub version: String,
pub category: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ManifestSection {
pub schema_version: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BootstrapSection {
pub expected_domain: String,
pub target_url: String,
#[serde(default)]
pub page_title_keywords: Vec<String>,
pub requires_target_page: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DeterministicSection {
pub suffix: String,
#[serde(default)]
pub include_keywords: Vec<String>,
#[serde(default)]
pub exclude_keywords: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SceneParam {
pub name: String,
pub resolver: String,
pub required: bool,
pub prompt_missing: String,
pub prompt_ambiguous: String,
#[serde(default)]
pub resolver_config: Table,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ArtifactSection {
#[serde(rename = "type")]
pub artifact_type: String,
#[serde(default)]
pub success_status: Vec<String>,
#[serde(default)]
pub failure_status: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PostprocessSection {
pub exporter: String,
#[serde(default)]
pub auto_open: Option<String>,
}

View File

@@ -0,0 +1,7 @@
pub mod manifest;
pub use manifest::{
ArtifactSection, BootstrapSection, DeterministicSection, ManifestSection, PostprocessSection,
SceneManifest, SceneParam, SceneSection, SCENE_MANIFEST_FILE_NAME, SUPPORTED_SCENE_CATEGORY_V1,
SUPPORTED_SCENE_KIND_V1, SUPPORTED_SCHEMA_VERSION_V1,
};

View File

@@ -196,13 +196,10 @@ fn normalize_local_dashboard_path(raw: &str) -> String {
}
fn normalize_local_dashboard_file_url(raw: &str) -> Result<String, SecurityError> {
let path = raw
.trim()
.strip_prefix("file:///")
.ok_or_else(|| {
SecurityError::InvalidLocalDashboard(
"local dashboard presentation_url must be file:///".to_string(),
)
})?;
let path = raw.trim().strip_prefix("file:///").ok_or_else(|| {
SecurityError::InvalidLocalDashboard(
"local dashboard presentation_url must be file:///".to_string(),
)
})?;
Ok(normalize_local_dashboard_path(path))
}

View File

@@ -30,9 +30,9 @@ pub struct ServiceStartupConfig {
pub fn load_startup_config(
runtime_context: &AgentRuntimeContext,
) -> Result<ServiceStartupConfig, PipeError> {
let settings = runtime_context
.load_sgclaw_settings()?
.ok_or_else(|| PipeError::Protocol("missing environment variable: DEEPSEEK_API_KEY".to_string()))?;
let settings = runtime_context.load_sgclaw_settings()?.ok_or_else(|| {
PipeError::Protocol("missing environment variable: DEEPSEEK_API_KEY".to_string())
})?;
Ok(ServiceStartupConfig {
browser_ws_url: Some(
@@ -59,15 +59,17 @@ pub fn run() -> Result<(), PipeError> {
.browser_ws_url
.as_deref()
.unwrap_or(DEFAULT_BROWSER_WS_URL);
let listener = TcpListener::bind(service_ws_listen_addr)
.map_err(|err| PipeError::Protocol(format!("failed to bind service listener {service_ws_listen_addr}: {err}")))?;
let listener = TcpListener::bind(service_ws_listen_addr).map_err(|err| {
PipeError::Protocol(format!(
"failed to bind service listener {service_ws_listen_addr}: {err}"
))
})?;
let mac_policy = load_service_mac_policy()?;
let session = ServiceSession::new();
eprintln!(
"sg_claw ready: service_ws_listen_addr={}, browser_ws_url={}",
service_ws_listen_addr,
browser_ws_url,
service_ws_listen_addr, browser_ws_url,
);
// Cache the browser callback host across client sessions so the helper
@@ -77,8 +79,9 @@ pub fn run() -> Result<(), PipeError> {
loop {
let (stream, _) = listener.accept()?;
let websocket = accept(stream)
.map_err(|err| PipeError::Protocol(format!("service websocket accept failed: {err}")))?;
let websocket = accept(stream).map_err(|err| {
PipeError::Protocol(format!("service websocket accept failed: {err}"))
})?;
let sink = Arc::new(ServiceEventSink::from_websocket(websocket));
match session.try_attach_client() {
Ok(()) => {
@@ -112,7 +115,9 @@ fn load_service_mac_policy() -> Result<MacPolicy, PipeError> {
let path = if candidate.exists() {
candidate
} else {
std::env::current_dir()?.join("resources").join("rules.json")
std::env::current_dir()?
.join("resources")
.join("rules.json")
};
MacPolicy::load_from_path(&path).map_err(PipeError::from)
}

View File

@@ -77,10 +77,7 @@ pub enum ServiceMessage {
TaskComplete { success: bool, summary: String },
Busy { message: String },
Pong,
ConfigUpdated {
success: bool,
message: String,
},
ConfigUpdated { success: bool, message: String },
}
fn normalize_optional_field(value: String) -> Option<String> {

View File

@@ -4,21 +4,17 @@ use std::path::PathBuf;
use std::sync::{Arc, Mutex};
use std::time::Duration;
use reqwest::Url;
#[cfg(test)]
use reqwest::blocking::Client;
#[cfg(test)]
use serde_json::{json, Map};
use reqwest::Url;
#[cfg(test)]
use serde_json::Value;
#[cfg(test)]
use serde_json::{json, Map};
use tungstenite::stream::MaybeTlsStream;
use tungstenite::{connect, Message, WebSocket};
use crate::agent::{
run_submit_task_with_browser_backend, AgentEventSink, AgentRuntimeContext,
};
use crate::browser::callback_host::LiveBrowserCallbackHost;
use crate::browser::ws_backend::WsClient;
use crate::agent::{run_submit_task_with_browser_backend, AgentEventSink, AgentRuntimeContext};
#[cfg(test)]
use crate::browser::bridge_contract::{
BridgeBrowserActionError, BridgeBrowserActionReply, BridgeBrowserActionRequest,
@@ -26,13 +22,15 @@ use crate::browser::bridge_contract::{
};
#[cfg(test)]
use crate::browser::bridge_transport::BridgeActionTransport;
use crate::browser::{BrowserBackend, BrowserCallbackBackend};
use crate::browser::callback_host::LiveBrowserCallbackHost;
use crate::browser::ws_backend::WsClient;
#[cfg(test)]
use crate::browser::BridgeBrowserBackend;
use crate::browser::{BrowserBackend, BrowserCallbackBackend};
use crate::config::SgClawSettings;
use crate::pipe::{AgentMessage, BrowserMessage, PipeError, Transport};
#[cfg(test)]
use crate::pipe::Timing;
use crate::pipe::{AgentMessage, BrowserMessage, PipeError, Transport};
use crate::security::MacPolicy;
use super::{ClientMessage, ServiceMessage};
@@ -128,7 +126,9 @@ impl ServiceEventSink {
let payload = serde_json::to_string(&message)?;
writer
.lock()
.map_err(|_| PipeError::Protocol("service websocket writer lock poisoned".to_string()))?
.map_err(|_| {
PipeError::Protocol("service websocket writer lock poisoned".to_string())
})?
.send(Message::Text(payload.into()))
.map_err(|err| map_service_websocket_error(err, "send"))?;
}
@@ -143,21 +143,20 @@ impl ServiceEventSink {
};
loop {
let mut websocket = writer
.lock()
.map_err(|_| PipeError::Protocol("service websocket writer lock poisoned".to_string()))?;
let mut websocket = writer.lock().map_err(|_| {
PipeError::Protocol("service websocket writer lock poisoned".to_string())
})?;
match websocket.read() {
Ok(Message::Text(text)) => return Ok(Some(serde_json::from_str(&text)?)),
Ok(Message::Close(_)) => return Ok(None),
Ok(Message::Ping(payload)) => {
websocket
.send(Message::Pong(payload))
.map_err(|err| PipeError::Protocol(format!("service websocket pong failed: {err}")))?;
websocket.send(Message::Pong(payload)).map_err(|err| {
PipeError::Protocol(format!("service websocket pong failed: {err}"))
})?;
}
Ok(_) => {}
Err(tungstenite::Error::ConnectionClosed) | Err(tungstenite::Error::AlreadyClosed) => {
return Ok(None)
}
Err(tungstenite::Error::ConnectionClosed)
| Err(tungstenite::Error::AlreadyClosed) => return Ok(None),
Err(err) => return Err(map_service_websocket_error(err, "read")),
}
}
@@ -197,7 +196,9 @@ fn map_service_websocket_error(err: tungstenite::Error, operation: &str) -> Pipe
match err {
tungstenite::Error::ConnectionClosed
| tungstenite::Error::AlreadyClosed
| tungstenite::Error::Protocol(tungstenite::error::ProtocolError::ResetWithoutClosingHandshake)
| tungstenite::Error::Protocol(
tungstenite::error::ProtocolError::ResetWithoutClosingHandshake,
)
| tungstenite::Error::Protocol(tungstenite::error::ProtocolError::SendAfterClosing) => {
PipeError::PipeClosed
}
@@ -233,7 +234,17 @@ fn send_status_changed(sink: &ServiceEventSink, state: &str) -> Result<(), PipeE
})
}
fn update_config_file(config_path: &Path, config: crate::service::protocol::ConfigUpdatePayload) -> Result<(), String> {
fn send_info_log(sink: &ServiceEventSink, message: impl Into<String>) -> Result<(), PipeError> {
sink.send(&AgentMessage::LogEntry {
level: "info".to_string(),
message: message.into(),
})
}
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))
@@ -346,6 +357,7 @@ pub(crate) fn serve_client(
&settings,
);
let bootstrap_url = bootstrap_target.request_url;
send_info_log(sink.as_ref(), "callback-host startup begin")?;
match LiveBrowserCallbackHost::start_with_browser_ws_url(
browser_ws_url,
&bootstrap_url,
@@ -354,14 +366,25 @@ pub(crate) fn serve_client(
true, // use_hidden_domain: hidden domain for invisible helper
) {
Ok(host) => {
send_info_log(sink.as_ref(), "callback-host startup ready")?;
*cached_host = Some(Arc::new(host));
}
Err(err) => {
for message in err.logs {
eprintln!("[sgclaw callback-host startup] {message}");
let _ = send_info_log(sink.as_ref(), message);
}
let source = err.source;
eprintln!("[sgclaw callback-host startup] failed: {source}");
let _ = send_info_log(
sink.as_ref(),
format!("callback-host startup failed: {source}"),
);
session.finish_task();
eprintln!("task execution failed: {err}");
eprintln!("task execution failed: {source}");
sink.send(&AgentMessage::TaskComplete {
success: false,
summary: format!("任务执行失败: {err}"),
summary: format!("任务执行失败: {source}"),
})?;
continue;
}
@@ -470,11 +493,17 @@ pub(crate) fn resolve_submit_bootstrap_target(
};
}
let resolved_skills_dir =
crate::compat::config_adapter::resolve_skills_dir_from_sgclaw_settings(
workspace_root,
settings,
);
if let crate::compat::deterministic_submit::DeterministicSubmitDecision::Execute(plan) =
crate::compat::deterministic_submit::decide_deterministic_submit(
crate::compat::deterministic_submit::decide_deterministic_submit_with_skills_dir(
&request.instruction,
request.page_url.as_deref(),
request.page_title.as_deref(),
&resolved_skills_dir,
)
{
return SubmitBootstrapTarget {
@@ -528,15 +557,15 @@ fn derive_request_url_from_instruction(instruction: &str) -> Option<String> {
return Some("https://www.zhihu.com".to_string());
}
if crate::compat::workflow_executor::detect_route(instruction, None, None)
.is_some_and(|route| {
if crate::compat::workflow_executor::detect_route(instruction, None, None).is_some_and(
|route| {
matches!(
route,
crate::compat::workflow_executor::WorkflowRoute::ZhihuArticleDraft
| crate::compat::workflow_executor::WorkflowRoute::ZhihuArticlePublish
)
})
{
},
) {
return Some("https://zhuanlan.zhihu.com".to_string());
}
@@ -549,8 +578,9 @@ pub(crate) struct ServiceWsClient {
impl ServiceWsClient {
pub(crate) fn connect(browser_ws_url: &str) -> Result<Self, PipeError> {
let (mut websocket, _) = connect(browser_ws_url)
.map_err(|err| PipeError::Protocol(format!("browser websocket connect failed: {err}")))?;
let (mut websocket, _) = connect(browser_ws_url).map_err(|err| {
PipeError::Protocol(format!("browser websocket connect failed: {err}"))
})?;
configure_browser_ws_timeouts(&mut websocket, BROWSER_RESPONSE_TIMEOUT)?;
websocket
.send(Message::Text(
@@ -639,14 +669,21 @@ mod pipe_closed_mapping_tests {
#[test]
fn map_service_websocket_error_treats_connection_aborted_send_as_pipe_closed() {
let err = tungstenite::Error::Io(std::io::Error::from(std::io::ErrorKind::ConnectionAborted));
assert!(matches!(map_service_websocket_error(err, "send"), PipeError::PipeClosed));
let err =
tungstenite::Error::Io(std::io::Error::from(std::io::ErrorKind::ConnectionAborted));
assert!(matches!(
map_service_websocket_error(err, "send"),
PipeError::PipeClosed
));
}
#[test]
fn map_service_websocket_error_treats_send_after_closing_as_pipe_closed() {
let err = tungstenite::Error::Protocol(tungstenite::error::ProtocolError::SendAfterClosing);
assert!(matches!(map_service_websocket_error(err, "send"), PipeError::PipeClosed));
assert!(matches!(
map_service_websocket_error(err, "send"),
PipeError::PipeClosed
));
}
}
@@ -731,7 +768,11 @@ fn bridge_base_url_from_browser_ws_url(browser_ws_url: &str) -> String {
let normalized = trimmed
.strip_prefix("ws://")
.map(|rest| format!("http://{rest}"))
.or_else(|| trimmed.strip_prefix("wss://").map(|rest| format!("https://{rest}")))
.or_else(|| {
trimmed
.strip_prefix("wss://")
.map(|rest| format!("https://{rest}"))
})
.unwrap_or_else(|| trimmed.to_string());
let Ok(parsed) = reqwest::Url::parse(&normalized) else {
@@ -770,8 +811,12 @@ fn normalize_bridge_action_reply(value: Value) -> Result<BridgeBrowserActionRepl
}
match value {
Value::Number(number) if number.as_i64() == Some(0) => Ok(bridge_success_reply(serde_json::json!({}))),
Value::String(text) if text.trim() == "0" => Ok(bridge_success_reply(serde_json::json!({}))),
Value::Number(number) if number.as_i64() == Some(0) => {
Ok(bridge_success_reply(serde_json::json!({})))
}
Value::String(text) if text.trim() == "0" => {
Ok(bridge_success_reply(serde_json::json!({})))
}
Value::Object(object) => normalize_bridge_action_reply_object(object),
other => Err(PipeError::Protocol(format!(
"invalid browser bridge reply: {other}"
@@ -787,7 +832,10 @@ fn normalize_bridge_action_reply_object(
return Ok(if success {
bridge_success_reply(success_data_from_object(&object))
} else {
bridge_error_reply(error_message_from_object(&object), error_details_from_object(&object))
bridge_error_reply(
error_message_from_object(&object),
error_details_from_object(&object),
)
});
}
@@ -835,7 +883,12 @@ fn success_data_from_object(object: &Map<String, Value>) -> Value {
.get("data")
.cloned()
.or_else(|| object.get("result").cloned())
.or_else(|| object.get("text").cloned().map(|text| json!({ "text": text })))
.or_else(|| {
object
.get("text")
.cloned()
.map(|text| json!({ "text": text }))
})
.unwrap_or_else(|| json!({}))
}
@@ -945,6 +998,70 @@ mod tests {
.expect("staged skills dir")
}
fn temp_manifest_scene_skill_root() -> PathBuf {
let root = std::env::temp_dir().join(format!(
"sgclaw-bootstrap-target-scene-root-{}",
Uuid::new_v4()
));
let skill_dir = root.join("manifest-scene-report");
let script_dir = skill_dir.join("scripts");
fs::create_dir_all(&script_dir).expect("create script dir");
fs::write(
skill_dir.join("SKILL.toml"),
r#"
[skill]
name = "manifest-scene-report"
description = "Collect manifest scene report data."
version = "0.1.0"
[[tools]]
name = "collect_manifest_scene"
description = "Collect manifest scene report rows."
kind = "browser_script"
command = "scripts/collect_manifest_scene.js"
"#,
)
.expect("write skill manifest");
fs::write(
skill_dir.join("scene.toml"),
r#"
[scene]
id = "manifest-scene-report"
skill = "manifest-scene-report"
tool = "collect_manifest_scene"
kind = "browser_script"
version = "0.1.0"
category = "report_collection"
[manifest]
schema_version = "1"
[bootstrap]
expected_domain = "manifest.example.test"
target_url = "https://manifest.example.test/report"
page_title_keywords = []
requires_target_page = true
[deterministic]
suffix = "。。。"
include_keywords = ["自定义场景报表"]
exclude_keywords = ["知乎"]
[artifact]
type = "report-artifact"
success_status = ["ok", "partial", "empty"]
failure_status = ["blocked", "error"]
"#,
)
.expect("write scene manifest");
fs::write(
script_dir.join("collect_manifest_scene.js"),
"return { ok: true };\n",
)
.expect("write skill script");
root
}
fn temp_direct_submit_skill_root(bootstrap_url: &str) -> PathBuf {
let root = std::env::temp_dir().join(format!(
"sgclaw-bootstrap-target-skill-root-{}",
@@ -1014,7 +1131,10 @@ expected_domain = "95598.sgcc.com.cn"
let target = resolve_submit_bootstrap_target(&request, Path::new("."), &settings);
assert_eq!(target.request_url, "https://already-open.example.com/page");
assert_eq!(target.expected_domain.as_deref(), Some("already-open.example.com"));
assert_eq!(
target.expected_domain.as_deref(),
Some("already-open.example.com")
);
assert_eq!(target.source, BootstrapTargetSource::PageContext);
}
@@ -1057,6 +1177,27 @@ expected_domain = "95598.sgcc.com.cn"
assert_eq!(target.source, BootstrapTargetSource::DeterministicPlan);
}
#[test]
fn deterministic_bootstrap_target_uses_configured_manifest_scene_target_url() {
let skills_dir = temp_manifest_scene_skill_root();
let request = SubmitTaskRequest {
instruction: "请执行自定义场景报表。。。".to_string(),
..SubmitTaskRequest::default()
};
let settings = service_test_settings(Some(skills_dir.clone()), None);
let target = resolve_submit_bootstrap_target(&request, Path::new("."), &settings);
assert_eq!(target.request_url, "https://manifest.example.test/report");
assert_eq!(
target.expected_domain.as_deref(),
Some("manifest.example.test")
);
assert_eq!(target.source, BootstrapTargetSource::DeterministicPlan);
let _ = fs::remove_dir_all(skills_dir);
}
#[test]
fn skill_metadata_bootstrap_url_is_used_when_no_page_context_or_plan_exists() {
let request = SubmitTaskRequest {
@@ -1109,7 +1250,10 @@ expected_domain = "95598.sgcc.com.cn"
);
let page_target =
resolve_submit_bootstrap_target(&page_request, Path::new("."), &page_settings);
assert_eq!(page_target.request_url, "https://already-open.example.com/page");
assert_eq!(
page_target.request_url,
"https://already-open.example.com/page"
);
assert_eq!(page_target.source, BootstrapTargetSource::PageContext);
let deterministic_request = SubmitTaskRequest {
@@ -1158,6 +1302,53 @@ expected_domain = "95598.sgcc.com.cn"
assert_eq!(fallback_target.source, BootstrapTargetSource::Fallback);
}
#[test]
fn service_sink_emits_callback_host_logs_before_task_complete() {
let sink = ServiceEventSink::default();
send_info_log(&sink, "callback-host startup begin").unwrap();
send_info_log(&sink, "callback-host start_with_browser_ws_url begin").unwrap();
send_info_log(
&sink,
"callback-host wait_for_helper_ready timeout (helper_loaded=false, ready=false)",
)
.unwrap();
send_info_log(&sink, "callback-host startup failed: timeout").unwrap();
sink.send(&AgentMessage::TaskComplete {
success: false,
summary: "任务执行失败: timeout".to_string(),
})
.unwrap();
assert_eq!(
sink.sent_messages(),
vec![
ServiceMessage::LogEntry {
level: "info".to_string(),
message: "callback-host startup begin".to_string(),
},
ServiceMessage::LogEntry {
level: "info".to_string(),
message: "callback-host start_with_browser_ws_url begin".to_string(),
},
ServiceMessage::LogEntry {
level: "info".to_string(),
message:
"callback-host wait_for_helper_ready timeout (helper_loaded=false, ready=false)"
.to_string(),
},
ServiceMessage::LogEntry {
level: "info".to_string(),
message: "callback-host startup failed: timeout".to_string(),
},
ServiceMessage::TaskComplete {
success: false,
summary: "任务执行失败: timeout".to_string(),
},
]
);
}
#[test]
fn bridge_base_url_defaults_local_browser_ws_endpoint_to_http_bridge() {
assert_eq!(
@@ -1208,7 +1399,11 @@ expected_domain = "95598.sgcc.com.cn"
);
let output = backend
.invoke(Action::GetText, json!({ "selector": "body" }), "www.zhihu.com")
.invoke(
Action::GetText,
json!({ "selector": "body" }),
"www.zhihu.com",
)
.expect("bridge transport should normalize success reply");
let request = request_rx.recv_timeout(Duration::from_secs(1)).unwrap();
@@ -1255,7 +1450,11 @@ expected_domain = "95598.sgcc.com.cn"
);
let error = backend
.invoke(Action::GetText, json!({ "selector": "#missing" }), "www.zhihu.com")
.invoke(
Action::GetText,
json!({ "selector": "#missing" }),
"www.zhihu.com",
)
.expect_err("bridge transport should surface semantic bridge failures");
server.join().unwrap();