217 lines
6.6 KiB
Rust
217 lines
6.6 KiB
Rust
use reqwest::Url;
|
|
use serde_json::{json, Value};
|
|
use thiserror::Error;
|
|
|
|
use crate::config::PlannerMode;
|
|
use crate::pipe::Action;
|
|
|
|
/// Legacy deterministic planner kept for dev-only verification and fixture coverage.
|
|
/// Production browser submit flow no longer routes into this planner.
|
|
pub const LEGACY_DEV_ONLY: bool = true;
|
|
|
|
const BAIDU_URL: &str = "https://www.baidu.com";
|
|
const BAIDU_DOMAIN: &str = "www.baidu.com";
|
|
const BAIDU_INPUT_SELECTOR: &str = "#kw";
|
|
const BAIDU_SEARCH_BUTTON_SELECTOR: &str = "#su";
|
|
const ZHIHU_HOME_URL: &str = "https://www.zhihu.com";
|
|
const ZHIHU_SEARCH_URL: &str = "https://www.zhihu.com/search";
|
|
const ZHIHU_DOMAIN: &str = "www.zhihu.com";
|
|
|
|
#[derive(Debug, Clone, PartialEq)]
|
|
pub struct PlannedStep {
|
|
pub action: Action,
|
|
pub params: Value,
|
|
pub expected_domain: String,
|
|
pub log_message: String,
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq)]
|
|
pub struct TaskPlan {
|
|
pub summary: String,
|
|
pub steps: Vec<PlannedStep>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct ExecutionPreview {
|
|
pub summary: String,
|
|
pub steps: Vec<String>,
|
|
}
|
|
|
|
#[derive(Debug, Error, Clone, PartialEq, Eq)]
|
|
pub enum PlannerError {
|
|
#[error("unsupported instruction: {0}")]
|
|
UnsupportedInstruction(String),
|
|
#[error("missing search query in instruction")]
|
|
MissingQuery,
|
|
}
|
|
|
|
pub fn plan_instruction(instruction: &str) -> Result<TaskPlan, PlannerError> {
|
|
let trimmed = instruction.trim();
|
|
if matches_exact(trimmed, &["打开百度"]) {
|
|
return Ok(plan_homepage("已打开百度首页", BAIDU_URL, BAIDU_DOMAIN));
|
|
}
|
|
|
|
if let Some(query) = extract_query(trimmed, &["打开百度搜索", "打开百度并搜索"])? {
|
|
return Ok(plan_baidu_search(query));
|
|
}
|
|
|
|
if matches_exact(trimmed, &["打开知乎"]) {
|
|
return Ok(plan_homepage(
|
|
"已打开知乎首页",
|
|
ZHIHU_HOME_URL,
|
|
ZHIHU_DOMAIN,
|
|
));
|
|
}
|
|
|
|
if let Some(query) = extract_query(trimmed, &["打开知乎搜索", "打开知乎并搜索"])? {
|
|
return Ok(plan_zhihu_search(query));
|
|
}
|
|
|
|
Err(PlannerError::UnsupportedInstruction(trimmed.to_string()))
|
|
}
|
|
|
|
pub fn build_execution_preview(
|
|
mode: PlannerMode,
|
|
instruction: &str,
|
|
page_url: Option<&str>,
|
|
page_title: Option<&str>,
|
|
) -> Option<ExecutionPreview> {
|
|
if matches!(mode, PlannerMode::LegacyDeterministic) {
|
|
return None;
|
|
}
|
|
|
|
let trimmed = instruction.trim();
|
|
if crate::runtime::is_zhihu_hotlist_task(trimmed, page_url, page_title) {
|
|
return Some(build_zhihu_hotlist_preview(trimmed));
|
|
}
|
|
|
|
if let Ok(plan) = plan_instruction(trimmed) {
|
|
return Some(ExecutionPreview {
|
|
summary: format!("先规划再执行:{}", plan.summary),
|
|
steps: plan
|
|
.steps
|
|
.into_iter()
|
|
.map(|step| step.log_message)
|
|
.collect(),
|
|
});
|
|
}
|
|
|
|
Some(ExecutionPreview {
|
|
summary: "先规划再执行当前任务".to_string(),
|
|
steps: vec![
|
|
"inspect current browser context".to_string(),
|
|
"choose the required sgclaw runtime tools".to_string(),
|
|
"execute and return the concrete result".to_string(),
|
|
],
|
|
})
|
|
}
|
|
|
|
fn extract_query<'a>(
|
|
instruction: &'a str,
|
|
prefixes: &[&str],
|
|
) -> Result<Option<&'a str>, PlannerError> {
|
|
let Some(query) = prefixes
|
|
.iter()
|
|
.find_map(|prefix| instruction.strip_prefix(prefix))
|
|
else {
|
|
return Ok(None);
|
|
};
|
|
|
|
let query = query.trim();
|
|
if query.is_empty() {
|
|
return Err(PlannerError::MissingQuery);
|
|
}
|
|
|
|
Ok(Some(query))
|
|
}
|
|
|
|
fn matches_exact(instruction: &str, candidates: &[&str]) -> bool {
|
|
candidates.iter().any(|candidate| instruction == *candidate)
|
|
}
|
|
|
|
fn plan_homepage(summary: &str, url: &str, domain: &str) -> TaskPlan {
|
|
TaskPlan {
|
|
summary: summary.to_string(),
|
|
steps: vec![PlannedStep {
|
|
action: Action::Navigate,
|
|
params: json!({ "url": url }),
|
|
expected_domain: domain.to_string(),
|
|
log_message: format!("navigate {url}"),
|
|
}],
|
|
}
|
|
}
|
|
|
|
fn plan_baidu_search(query: &str) -> TaskPlan {
|
|
TaskPlan {
|
|
summary: format!("已在百度搜索{query}"),
|
|
steps: vec![
|
|
PlannedStep {
|
|
action: Action::Navigate,
|
|
params: json!({ "url": BAIDU_URL }),
|
|
expected_domain: BAIDU_DOMAIN.to_string(),
|
|
log_message: "navigate https://www.baidu.com".to_string(),
|
|
},
|
|
PlannedStep {
|
|
action: Action::Type,
|
|
params: json!({
|
|
"selector": BAIDU_INPUT_SELECTOR,
|
|
"text": query,
|
|
"clear_first": true
|
|
}),
|
|
expected_domain: BAIDU_DOMAIN.to_string(),
|
|
log_message: format!("type {query} into {BAIDU_INPUT_SELECTOR}"),
|
|
},
|
|
PlannedStep {
|
|
action: Action::Click,
|
|
params: json!({ "selector": BAIDU_SEARCH_BUTTON_SELECTOR }),
|
|
expected_domain: BAIDU_DOMAIN.to_string(),
|
|
log_message: format!("click {BAIDU_SEARCH_BUTTON_SELECTOR}"),
|
|
},
|
|
],
|
|
}
|
|
}
|
|
|
|
fn plan_zhihu_search(query: &str) -> TaskPlan {
|
|
let url = Url::parse_with_params(ZHIHU_SEARCH_URL, &[("type", "content"), ("q", query)])
|
|
.expect("valid Zhihu search URL");
|
|
let url: String = url.into();
|
|
|
|
TaskPlan {
|
|
summary: format!("已在知乎搜索{query}"),
|
|
steps: vec![PlannedStep {
|
|
action: Action::Navigate,
|
|
params: json!({ "url": url }),
|
|
expected_domain: ZHIHU_DOMAIN.to_string(),
|
|
log_message: format!("navigate {url}"),
|
|
}],
|
|
}
|
|
}
|
|
|
|
fn build_zhihu_hotlist_preview(instruction: &str) -> ExecutionPreview {
|
|
let normalized = instruction.to_ascii_lowercase();
|
|
if normalized.contains("dashboard")
|
|
|| instruction.contains("大屏")
|
|
|| instruction.contains("新标签页")
|
|
{
|
|
return ExecutionPreview {
|
|
summary: "先规划再执行知乎热榜大屏生成".to_string(),
|
|
steps: vec![
|
|
"navigate https://www.zhihu.com/hot".to_string(),
|
|
"getText main".to_string(),
|
|
"call screen_html_export".to_string(),
|
|
"return generated local .html path".to_string(),
|
|
],
|
|
};
|
|
}
|
|
|
|
ExecutionPreview {
|
|
summary: "先规划再执行知乎热榜 Excel 导出".to_string(),
|
|
steps: vec![
|
|
"navigate https://www.zhihu.com/hot".to_string(),
|
|
"getText main".to_string(),
|
|
"call openxml_office".to_string(),
|
|
"return generated local .xlsx path".to_string(),
|
|
],
|
|
}
|
|
}
|