fix(runtime,hands): 4项根因修复 — URL编码/Browser桩/定时解析/LLM超时
Some checks failed
CI / Lint & TypeCheck (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Build Frontend (push) Has been cancelled
CI / Rust Check (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
Some checks failed
CI / Lint & TypeCheck (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Build Frontend (push) Has been cancelled
CI / Rust Check (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
1. researcher.rs: url_encode() chars→bytes,修复中文搜索URL编码 (U+533B→%533B 改为 UTF-8 %E5%8C%BB) 2. browser.rs: WebDriver不可用时返回明确错误而非静默成功, 防止LLM误以为操作已完成 3. nl_schedule.rs: 新增相对延迟解析(秒后/分钟后/小时后), 避免fallback到LLM幻觉cron 4. 4个LLM driver: 移除http1_only()防reqwest解码错误, 超时120s→300s适配工具调用链,Anthropic裸Client::new()补全配置
This commit is contained in:
@@ -246,23 +246,29 @@ impl Hand for BrowserHand {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn execute(&self, _context: &HandContext, input: Value) -> Result<HandResult> {
|
async fn execute(&self, _context: &HandContext, input: Value) -> Result<HandResult> {
|
||||||
// Parse the action
|
|
||||||
let action: BrowserAction = match serde_json::from_value(input) {
|
let action: BrowserAction = match serde_json::from_value(input) {
|
||||||
Ok(a) => a,
|
Ok(a) => a,
|
||||||
Err(e) => return Ok(HandResult::error(format!("Invalid action: {}", e))),
|
Err(e) => return Ok(HandResult::error(format!("Invalid action: {}", e))),
|
||||||
};
|
};
|
||||||
|
|
||||||
// Browser automation executes on the frontend via BrowserHandCard.
|
|
||||||
// Return the parsed action with a clear message so the LLM can inform
|
|
||||||
// the user and the frontend can pick it up via Tauri events.
|
|
||||||
let action_type = action.action_name();
|
let action_type = action.action_name();
|
||||||
let summary = action.summary();
|
let summary = action.summary();
|
||||||
|
|
||||||
|
// Check if WebDriver is available
|
||||||
|
if !self.check_webdriver() {
|
||||||
|
return Ok(HandResult::error(format!(
|
||||||
|
"浏览器操作「{}」无法执行:未检测到 WebDriver (ChromeDriver/GeckoDriver)。请先启动 WebDriver 服务。",
|
||||||
|
summary
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// WebDriver is running — delegate to frontend BrowserHandCard.
|
||||||
|
// The frontend manages the Fantoccini session lifecycle.
|
||||||
Ok(HandResult::success(serde_json::json!({
|
Ok(HandResult::success(serde_json::json!({
|
||||||
"action": action_type,
|
"action": action_type,
|
||||||
"status": "delegated_to_frontend",
|
"status": "delegated_to_frontend",
|
||||||
"message": format!("浏览器操作「{}」已委托给前端执行。请在 HandsPanel 中查看执行结果。", summary),
|
"message": format!("浏览器操作「{}」已发送到前端执行。WebDriver 已就绪。", summary),
|
||||||
"details": format!("{} — 需要 WebDriver 会话,由前端 BrowserHandCard 管理。", summary),
|
"details": format!("{} — 由前端 BrowserHandCard 通过 Fantoccini 执行。", summary),
|
||||||
})))
|
})))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -512,12 +518,16 @@ mod tests {
|
|||||||
assert!(!sequence.stop_on_error);
|
assert!(!sequence.stop_on_error);
|
||||||
assert_eq!(sequence.steps.len(), 1);
|
assert_eq!(sequence.steps.len(), 1);
|
||||||
|
|
||||||
// Execute the navigate step
|
// Execute the navigate step — without WebDriver running, should report error
|
||||||
let action_json = serde_json::to_value(&sequence.steps[0]).expect("serialize step");
|
let action_json = serde_json::to_value(&sequence.steps[0]).expect("serialize step");
|
||||||
let result = hand.execute(&ctx, action_json).await.expect("execute");
|
let result = hand.execute(&ctx, action_json).await.expect("execute");
|
||||||
assert!(result.success);
|
// In test env no WebDriver is running, so we get an error about missing WebDriver
|
||||||
|
if result.success {
|
||||||
assert_eq!(result.output["action"], "navigate");
|
assert_eq!(result.output["action"], "navigate");
|
||||||
assert_eq!(result.output["status"], "delegated_to_frontend");
|
assert_eq!(result.output["status"], "delegated_to_frontend");
|
||||||
|
} else {
|
||||||
|
assert!(result.error.as_deref().unwrap_or("").contains("WebDriver"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
@@ -533,11 +543,18 @@ mod tests {
|
|||||||
|
|
||||||
assert_eq!(sequence.steps.len(), 4);
|
assert_eq!(sequence.steps.len(), 4);
|
||||||
|
|
||||||
// Verify each step can execute
|
// Verify each step can parse and execute (or report missing WebDriver)
|
||||||
for (i, step) in sequence.steps.iter().enumerate() {
|
for (i, step) in sequence.steps.iter().enumerate() {
|
||||||
let action_json = serde_json::to_value(step).expect("serialize step");
|
let action_json = serde_json::to_value(step).expect("serialize step");
|
||||||
let result = hand.execute(&ctx, action_json).await.expect("execute step");
|
let result = hand.execute(&ctx, action_json).await.expect("execute step");
|
||||||
assert!(result.success, "Step {} failed: {:?}", i, result.error);
|
// Without WebDriver, all steps should report the error cleanly
|
||||||
|
if !result.success {
|
||||||
|
assert!(
|
||||||
|
result.error.as_deref().unwrap_or("").contains("WebDriver"),
|
||||||
|
"Step {} unexpected error: {:?}",
|
||||||
|
i, result.error
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -562,12 +562,14 @@ impl Hand for ResearcherHand {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// URL encoding helper (simple implementation)
|
/// URL encoding helper — encodes each UTF-8 byte, not Unicode code points.
|
||||||
fn url_encode(s: &str) -> String {
|
fn url_encode(s: &str) -> String {
|
||||||
s.chars()
|
s.bytes()
|
||||||
.map(|c| match c {
|
.map(|b| match b {
|
||||||
'A'..='Z' | 'a'..='z' | '0'..='9' | '-' | '_' | '.' | '~' => c.to_string(),
|
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
|
||||||
_ => format!("%{:02X}", c as u32),
|
(b as char).to_string()
|
||||||
|
}
|
||||||
|
_ => format!("%{:02X}", b),
|
||||||
})
|
})
|
||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
@@ -706,9 +708,13 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_url_encode_chinese() {
|
fn test_url_encode_chinese() {
|
||||||
|
// "医" = UTF-8 bytes E5 8C BB → must produce %E5%8C%BB, not %533B
|
||||||
|
let encoded = url_encode("医");
|
||||||
|
assert_eq!(encoded, "%E5%8C%BB");
|
||||||
|
|
||||||
|
// Full phrase: "中文" = E4 B8 AD E6 96 87
|
||||||
let encoded = url_encode("中文搜索");
|
let encoded = url_encode("中文搜索");
|
||||||
assert!(encoded.contains("%"));
|
assert_eq!(&encoded[0..9], "%E4%B8%AD");
|
||||||
// Chinese chars should be percent-encoded
|
|
||||||
assert!(!encoded.contains("中文"));
|
assert!(!encoded.contains("中文"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -22,7 +22,12 @@ pub struct AnthropicDriver {
|
|||||||
impl AnthropicDriver {
|
impl AnthropicDriver {
|
||||||
pub fn new(api_key: SecretString) -> Self {
|
pub fn new(api_key: SecretString) -> Self {
|
||||||
Self {
|
Self {
|
||||||
client: Client::new(),
|
client: Client::builder()
|
||||||
|
.user_agent(crate::USER_AGENT)
|
||||||
|
.timeout(std::time::Duration::from_secs(300))
|
||||||
|
.connect_timeout(std::time::Duration::from_secs(30))
|
||||||
|
.build()
|
||||||
|
.unwrap_or_else(|_| Client::new()),
|
||||||
api_key,
|
api_key,
|
||||||
base_url: "https://api.anthropic.com".to_string(),
|
base_url: "https://api.anthropic.com".to_string(),
|
||||||
}
|
}
|
||||||
@@ -30,7 +35,12 @@ impl AnthropicDriver {
|
|||||||
|
|
||||||
pub fn with_base_url(api_key: SecretString, base_url: String) -> Self {
|
pub fn with_base_url(api_key: SecretString, base_url: String) -> Self {
|
||||||
Self {
|
Self {
|
||||||
client: Client::new(),
|
client: Client::builder()
|
||||||
|
.user_agent(crate::USER_AGENT)
|
||||||
|
.timeout(std::time::Duration::from_secs(300))
|
||||||
|
.connect_timeout(std::time::Duration::from_secs(30))
|
||||||
|
.build()
|
||||||
|
.unwrap_or_else(|_| Client::new()),
|
||||||
api_key,
|
api_key,
|
||||||
base_url,
|
base_url,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,8 +30,7 @@ impl GeminiDriver {
|
|||||||
Self {
|
Self {
|
||||||
client: Client::builder()
|
client: Client::builder()
|
||||||
.user_agent(crate::USER_AGENT)
|
.user_agent(crate::USER_AGENT)
|
||||||
.http1_only()
|
.timeout(std::time::Duration::from_secs(300))
|
||||||
.timeout(std::time::Duration::from_secs(120))
|
|
||||||
.connect_timeout(std::time::Duration::from_secs(30))
|
.connect_timeout(std::time::Duration::from_secs(30))
|
||||||
.build()
|
.build()
|
||||||
.unwrap_or_else(|_| Client::new()),
|
.unwrap_or_else(|_| Client::new()),
|
||||||
@@ -44,8 +43,7 @@ impl GeminiDriver {
|
|||||||
Self {
|
Self {
|
||||||
client: Client::builder()
|
client: Client::builder()
|
||||||
.user_agent(crate::USER_AGENT)
|
.user_agent(crate::USER_AGENT)
|
||||||
.http1_only()
|
.timeout(std::time::Duration::from_secs(300))
|
||||||
.timeout(std::time::Duration::from_secs(120))
|
|
||||||
.connect_timeout(std::time::Duration::from_secs(30))
|
.connect_timeout(std::time::Duration::from_secs(30))
|
||||||
.build()
|
.build()
|
||||||
.unwrap_or_else(|_| Client::new()),
|
.unwrap_or_else(|_| Client::new()),
|
||||||
|
|||||||
@@ -29,7 +29,6 @@ impl LocalDriver {
|
|||||||
Self {
|
Self {
|
||||||
client: Client::builder()
|
client: Client::builder()
|
||||||
.user_agent(crate::USER_AGENT)
|
.user_agent(crate::USER_AGENT)
|
||||||
.http1_only()
|
|
||||||
.timeout(std::time::Duration::from_secs(300)) // 5 min -- local inference can be slow
|
.timeout(std::time::Duration::from_secs(300)) // 5 min -- local inference can be slow
|
||||||
.connect_timeout(std::time::Duration::from_secs(10)) // short connect timeout
|
.connect_timeout(std::time::Duration::from_secs(10)) // short connect timeout
|
||||||
.build()
|
.build()
|
||||||
|
|||||||
@@ -24,9 +24,8 @@ impl OpenAiDriver {
|
|||||||
Self {
|
Self {
|
||||||
client: Client::builder()
|
client: Client::builder()
|
||||||
.user_agent(crate::USER_AGENT)
|
.user_agent(crate::USER_AGENT)
|
||||||
.http1_only()
|
.timeout(std::time::Duration::from_secs(300))
|
||||||
.timeout(std::time::Duration::from_secs(120)) // 2 minute timeout
|
.connect_timeout(std::time::Duration::from_secs(30))
|
||||||
.connect_timeout(std::time::Duration::from_secs(30)) // 30 second connect timeout
|
|
||||||
.build()
|
.build()
|
||||||
.unwrap_or_else(|_| Client::new()),
|
.unwrap_or_else(|_| Client::new()),
|
||||||
api_key,
|
api_key,
|
||||||
@@ -38,9 +37,8 @@ impl OpenAiDriver {
|
|||||||
Self {
|
Self {
|
||||||
client: Client::builder()
|
client: Client::builder()
|
||||||
.user_agent(crate::USER_AGENT)
|
.user_agent(crate::USER_AGENT)
|
||||||
.http1_only()
|
.timeout(std::time::Duration::from_secs(300))
|
||||||
.timeout(std::time::Duration::from_secs(120)) // 2 minute timeout
|
.connect_timeout(std::time::Duration::from_secs(30))
|
||||||
.connect_timeout(std::time::Duration::from_secs(30)) // 30 second connect timeout
|
|
||||||
.build()
|
.build()
|
||||||
.unwrap_or_else(|_| Client::new()),
|
.unwrap_or_else(|_| Client::new()),
|
||||||
api_key,
|
api_key,
|
||||||
|
|||||||
@@ -113,6 +113,11 @@ static RE_INTERVAL: LazyLock<Regex> = LazyLock::new(|| {
|
|||||||
Regex::new(r"每(\d{1,2})(小时|分钟|分|钟|个小时)").expect("static regex pattern is valid")
|
Regex::new(r"每(\d{1,2})(小时|分钟|分|钟|个小时)").expect("static regex pattern is valid")
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// try_relative_delay — "X秒后", "X分钟后", "X小时后"
|
||||||
|
static RE_RELATIVE_DELAY: LazyLock<Regex> = LazyLock::new(|| {
|
||||||
|
Regex::new(r"(\d{1,3})\s*(秒|秒钟|分钟|分|小时|个?小时)后").expect("static regex pattern is valid")
|
||||||
|
});
|
||||||
|
|
||||||
// try_monthly
|
// try_monthly
|
||||||
static RE_MONTHLY: LazyLock<Regex> = LazyLock::new(|| {
|
static RE_MONTHLY: LazyLock<Regex> = LazyLock::new(|| {
|
||||||
Regex::new(&format!(
|
Regex::new(&format!(
|
||||||
@@ -222,6 +227,9 @@ pub fn parse_nl_schedule(input: &str, default_agent_id: &AgentId) -> SchedulePar
|
|||||||
if let Some(result) = try_one_shot(input, &task_description, default_agent_id) {
|
if let Some(result) = try_one_shot(input, &task_description, default_agent_id) {
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
if let Some(result) = try_relative_delay(input, &task_description, default_agent_id) {
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
ScheduleParseResult::Unclear
|
ScheduleParseResult::Unclear
|
||||||
}
|
}
|
||||||
@@ -470,6 +478,34 @@ fn try_one_shot(input: &str, task_desc: &str, agent_id: &AgentId) -> Option<Sche
|
|||||||
None
|
None
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Parse relative delay expressions like "10秒后", "5分钟后", "2小时后".
|
||||||
|
/// Converts to ISO-8601 timestamp from now.
|
||||||
|
fn try_relative_delay(input: &str, task_desc: &str, agent_id: &AgentId) -> Option<ScheduleParseResult> {
|
||||||
|
let caps = RE_RELATIVE_DELAY.captures(input)?;
|
||||||
|
let amount: i64 = caps.get(1)?.as_str().parse().ok()?;
|
||||||
|
if amount <= 0 || amount > 999 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let unit = caps.get(2)?.as_str();
|
||||||
|
let (seconds, desc_unit) = match unit {
|
||||||
|
"秒" | "秒钟" => (amount, "秒"),
|
||||||
|
"分钟" | "分" => (amount * 60, "分钟"),
|
||||||
|
"小时" | "个小时" => (amount * 3600, "小时"),
|
||||||
|
_ => return None,
|
||||||
|
};
|
||||||
|
|
||||||
|
let target = chrono::Utc::now() + chrono::Duration::seconds(seconds);
|
||||||
|
|
||||||
|
Some(ScheduleParseResult::Exact(ParsedSchedule {
|
||||||
|
cron_expression: target.to_rfc3339(),
|
||||||
|
natural_description: format!("{}{}后", amount, desc_unit),
|
||||||
|
confidence: 0.92,
|
||||||
|
task_description: task_desc.to_string(),
|
||||||
|
task_target: TaskTarget::Agent(agent_id.to_string()),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Schedule intent detection
|
// Schedule intent detection
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -478,7 +514,7 @@ fn try_one_shot(input: &str, task_desc: &str, agent_id: &AgentId) -> Option<Sche
|
|||||||
const SCHEDULE_INTENT_KEYWORDS: &[&str] = &[
|
const SCHEDULE_INTENT_KEYWORDS: &[&str] = &[
|
||||||
"提醒我", "提醒", "定时", "每天", "每日", "每周", "每月",
|
"提醒我", "提醒", "定时", "每天", "每日", "每周", "每月",
|
||||||
"工作日", "每隔", "每", "定期", "到时候", "准时",
|
"工作日", "每隔", "每", "定期", "到时候", "准时",
|
||||||
"闹钟", "闹铃", "日程", "日历",
|
"闹钟", "闹铃", "日程", "日历", "秒后", "分钟后", "小时后",
|
||||||
];
|
];
|
||||||
|
|
||||||
/// Check if user input contains schedule intent.
|
/// Check if user input contains schedule intent.
|
||||||
@@ -731,4 +767,40 @@ mod tests {
|
|||||||
_ => panic!("Expected Exact, got {:?}", result),
|
_ => panic!("Expected Exact, got {:?}", result),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_relative_delay_seconds() {
|
||||||
|
let result = parse_nl_schedule("30秒后提醒我", &default_agent());
|
||||||
|
match result {
|
||||||
|
ScheduleParseResult::Exact(s) => {
|
||||||
|
assert!(s.natural_description.contains("30秒"));
|
||||||
|
assert!(s.confidence >= 0.9);
|
||||||
|
}
|
||||||
|
_ => panic!("Expected Exact, got {:?}", result),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_relative_delay_minutes() {
|
||||||
|
let result = parse_nl_schedule("5分钟后提醒我喝水", &default_agent());
|
||||||
|
match result {
|
||||||
|
ScheduleParseResult::Exact(s) => {
|
||||||
|
assert!(s.natural_description.contains("5分钟"));
|
||||||
|
// task_description preserves the original text minus schedule keywords
|
||||||
|
assert!(s.task_description.contains("喝水"));
|
||||||
|
}
|
||||||
|
_ => panic!("Expected Exact, got {:?}", result),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_relative_delay_hours() {
|
||||||
|
let result = parse_nl_schedule("2小时后提醒我开会", &default_agent());
|
||||||
|
match result {
|
||||||
|
ScheduleParseResult::Exact(s) => {
|
||||||
|
assert!(s.natural_description.contains("2小时"));
|
||||||
|
}
|
||||||
|
_ => panic!("Expected Exact, got {:?}", result),
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user