From 86918376084f9c32ba0920d3eed9accf880e1b69 Mon Sep 17 00:00:00 2001 From: iven Date: Wed, 22 Apr 2026 03:24:55 +0800 Subject: [PATCH] =?UTF-8?q?fix(runtime,hands):=204=E9=A1=B9=E6=A0=B9?= =?UTF-8?q?=E5=9B=A0=E4=BF=AE=E5=A4=8D=20=E2=80=94=20URL=E7=BC=96=E7=A0=81?= =?UTF-8?q?/Browser=E6=A1=A9/=E5=AE=9A=E6=97=B6=E8=A7=A3=E6=9E=90/LLM?= =?UTF-8?q?=E8=B6=85=E6=97=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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()补全配置 --- crates/zclaw-hands/src/hands/browser.rs | 41 +++++++---- crates/zclaw-hands/src/hands/researcher.rs | 20 ++++-- crates/zclaw-runtime/src/driver/anthropic.rs | 14 +++- crates/zclaw-runtime/src/driver/gemini.rs | 6 +- crates/zclaw-runtime/src/driver/local.rs | 1 - crates/zclaw-runtime/src/driver/openai.rs | 10 ++- crates/zclaw-runtime/src/nl_schedule.rs | 74 +++++++++++++++++++- 7 files changed, 133 insertions(+), 33 deletions(-) diff --git a/crates/zclaw-hands/src/hands/browser.rs b/crates/zclaw-hands/src/hands/browser.rs index 07130d0..00fba04 100644 --- a/crates/zclaw-hands/src/hands/browser.rs +++ b/crates/zclaw-hands/src/hands/browser.rs @@ -246,23 +246,29 @@ impl Hand for BrowserHand { } async fn execute(&self, _context: &HandContext, input: Value) -> Result { - // Parse the action let action: BrowserAction = match serde_json::from_value(input) { Ok(a) => a, 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 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!({ "action": action_type, "status": "delegated_to_frontend", - "message": format!("浏览器操作「{}」已委托给前端执行。请在 HandsPanel 中查看执行结果。", summary), - "details": format!("{} — 需要 WebDriver 会话,由前端 BrowserHandCard 管理。", summary), + "message": format!("浏览器操作「{}」已发送到前端执行。WebDriver 已就绪。", summary), + "details": format!("{} — 由前端 BrowserHandCard 通过 Fantoccini 执行。", summary), }))) } @@ -512,12 +518,16 @@ mod tests { assert!(!sequence.stop_on_error); 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 result = hand.execute(&ctx, action_json).await.expect("execute"); - assert!(result.success); - assert_eq!(result.output["action"], "navigate"); - assert_eq!(result.output["status"], "delegated_to_frontend"); + // 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["status"], "delegated_to_frontend"); + } else { + assert!(result.error.as_deref().unwrap_or("").contains("WebDriver")); + } } #[tokio::test] @@ -533,11 +543,18 @@ mod tests { 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() { let action_json = serde_json::to_value(step).expect("serialize 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 + ); + } } } diff --git a/crates/zclaw-hands/src/hands/researcher.rs b/crates/zclaw-hands/src/hands/researcher.rs index bc7c10b..113dec4 100644 --- a/crates/zclaw-hands/src/hands/researcher.rs +++ b/crates/zclaw-hands/src/hands/researcher.rs @@ -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 { - s.chars() - .map(|c| match c { - 'A'..='Z' | 'a'..='z' | '0'..='9' | '-' | '_' | '.' | '~' => c.to_string(), - _ => format!("%{:02X}", c as u32), + s.bytes() + .map(|b| match b { + b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => { + (b as char).to_string() + } + _ => format!("%{:02X}", b), }) .collect() } @@ -706,9 +708,13 @@ mod tests { #[test] 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("中文搜索"); - assert!(encoded.contains("%")); - // Chinese chars should be percent-encoded + assert_eq!(&encoded[0..9], "%E4%B8%AD"); assert!(!encoded.contains("中文")); } diff --git a/crates/zclaw-runtime/src/driver/anthropic.rs b/crates/zclaw-runtime/src/driver/anthropic.rs index e943d88..8558d8c 100644 --- a/crates/zclaw-runtime/src/driver/anthropic.rs +++ b/crates/zclaw-runtime/src/driver/anthropic.rs @@ -22,7 +22,12 @@ pub struct AnthropicDriver { impl AnthropicDriver { pub fn new(api_key: SecretString) -> 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, 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 { 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, base_url, } diff --git a/crates/zclaw-runtime/src/driver/gemini.rs b/crates/zclaw-runtime/src/driver/gemini.rs index fbe628c..d3820b7 100644 --- a/crates/zclaw-runtime/src/driver/gemini.rs +++ b/crates/zclaw-runtime/src/driver/gemini.rs @@ -30,8 +30,7 @@ impl GeminiDriver { Self { client: Client::builder() .user_agent(crate::USER_AGENT) - .http1_only() - .timeout(std::time::Duration::from_secs(120)) + .timeout(std::time::Duration::from_secs(300)) .connect_timeout(std::time::Duration::from_secs(30)) .build() .unwrap_or_else(|_| Client::new()), @@ -44,8 +43,7 @@ impl GeminiDriver { Self { client: Client::builder() .user_agent(crate::USER_AGENT) - .http1_only() - .timeout(std::time::Duration::from_secs(120)) + .timeout(std::time::Duration::from_secs(300)) .connect_timeout(std::time::Duration::from_secs(30)) .build() .unwrap_or_else(|_| Client::new()), diff --git a/crates/zclaw-runtime/src/driver/local.rs b/crates/zclaw-runtime/src/driver/local.rs index a88a0a3..7b923a7 100644 --- a/crates/zclaw-runtime/src/driver/local.rs +++ b/crates/zclaw-runtime/src/driver/local.rs @@ -29,7 +29,6 @@ impl LocalDriver { Self { client: Client::builder() .user_agent(crate::USER_AGENT) - .http1_only() .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 .build() diff --git a/crates/zclaw-runtime/src/driver/openai.rs b/crates/zclaw-runtime/src/driver/openai.rs index 6dcbe4d..2619ee9 100644 --- a/crates/zclaw-runtime/src/driver/openai.rs +++ b/crates/zclaw-runtime/src/driver/openai.rs @@ -24,9 +24,8 @@ impl OpenAiDriver { Self { client: Client::builder() .user_agent(crate::USER_AGENT) - .http1_only() - .timeout(std::time::Duration::from_secs(120)) // 2 minute timeout - .connect_timeout(std::time::Duration::from_secs(30)) // 30 second connect timeout + .timeout(std::time::Duration::from_secs(300)) + .connect_timeout(std::time::Duration::from_secs(30)) .build() .unwrap_or_else(|_| Client::new()), api_key, @@ -38,9 +37,8 @@ impl OpenAiDriver { Self { client: Client::builder() .user_agent(crate::USER_AGENT) - .http1_only() - .timeout(std::time::Duration::from_secs(120)) // 2 minute timeout - .connect_timeout(std::time::Duration::from_secs(30)) // 30 second connect timeout + .timeout(std::time::Duration::from_secs(300)) + .connect_timeout(std::time::Duration::from_secs(30)) .build() .unwrap_or_else(|_| Client::new()), api_key, diff --git a/crates/zclaw-runtime/src/nl_schedule.rs b/crates/zclaw-runtime/src/nl_schedule.rs index 2241893..303a285 100644 --- a/crates/zclaw-runtime/src/nl_schedule.rs +++ b/crates/zclaw-runtime/src/nl_schedule.rs @@ -113,6 +113,11 @@ static RE_INTERVAL: LazyLock = LazyLock::new(|| { Regex::new(r"每(\d{1,2})(小时|分钟|分|钟|个小时)").expect("static regex pattern is valid") }); +// try_relative_delay — "X秒后", "X分钟后", "X小时后" +static RE_RELATIVE_DELAY: LazyLock = LazyLock::new(|| { + Regex::new(r"(\d{1,3})\s*(秒|秒钟|分钟|分|小时|个?小时)后").expect("static regex pattern is valid") +}); + // try_monthly static RE_MONTHLY: LazyLock = LazyLock::new(|| { 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) { return result; } + if let Some(result) = try_relative_delay(input, &task_description, default_agent_id) { + return result; + } ScheduleParseResult::Unclear } @@ -470,6 +478,34 @@ fn try_one_shot(input: &str, task_desc: &str, agent_id: &AgentId) -> Option Option { + 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 // --------------------------------------------------------------------------- @@ -478,7 +514,7 @@ fn try_one_shot(input: &str, task_desc: &str, agent_id: &AgentId) -> Option 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), + } + } }