Files
claw/src/agent/planner.rs

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(),
],
}
}