sgclaw: snapshot today's runtime and skill updates
This commit is contained in:
@@ -1,20 +1,17 @@
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
use std::thread;
|
||||
use std::time::Duration;
|
||||
|
||||
use regex::Regex;
|
||||
use serde_json::{json, Value};
|
||||
use zeroclaw::tools::Tool;
|
||||
|
||||
use crate::compat::runtime::CompatTaskContext;
|
||||
use crate::compat::openxml_office_tool::OpenXmlOfficeTool;
|
||||
use crate::compat::runtime::CompatTaskContext;
|
||||
use crate::compat::screen_html_export_tool::ScreenHtmlExportTool;
|
||||
use crate::pipe::{
|
||||
Action,
|
||||
AgentMessage,
|
||||
BrowserPipeTool,
|
||||
ConversationMessage,
|
||||
PipeError,
|
||||
Transport,
|
||||
Action, AgentMessage, BrowserPipeTool, ConversationMessage, PipeError, Transport,
|
||||
};
|
||||
|
||||
const ZHIHU_DOMAIN: &str = "www.zhihu.com";
|
||||
@@ -22,6 +19,10 @@ const ZHIHU_EDITOR_DOMAIN: &str = "zhuanlan.zhihu.com";
|
||||
const ZHIHU_HOT_URL: &str = "https://www.zhihu.com/hot";
|
||||
const ZHIHU_CREATOR_URL: &str = "https://www.zhihu.com/creator";
|
||||
const ZHIHU_EDITOR_URL: &str = "https://zhuanlan.zhihu.com/write";
|
||||
const HOTLIST_READY_POLL_ATTEMPTS: usize = 10;
|
||||
const HOTLIST_READY_POLL_INTERVAL: Duration = Duration::from_millis(500);
|
||||
const HOTLIST_TEXT_READY_PATTERN: &str =
|
||||
r"(?:^|\n)\s*1(?:[.、]|\s)+.+\d+(?:\.\d+)?\s*(?:万|亿|k|K|m|M)(?:热度)?";
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum WorkflowRoute {
|
||||
ZhihuHotlistExportXlsx,
|
||||
@@ -51,10 +52,16 @@ pub fn detect_route(
|
||||
) -> Option<WorkflowRoute> {
|
||||
if crate::runtime::is_zhihu_hotlist_task(instruction, page_url, page_title) {
|
||||
let normalized = instruction.to_ascii_lowercase();
|
||||
if normalized.contains("dashboard") || instruction.contains("大屏") || instruction.contains("新标签页") {
|
||||
if normalized.contains("dashboard")
|
||||
|| instruction.contains("大屏")
|
||||
|| instruction.contains("新标签页")
|
||||
{
|
||||
return Some(WorkflowRoute::ZhihuHotlistScreen);
|
||||
}
|
||||
if normalized.contains("excel") || normalized.contains("xlsx") || instruction.contains("导出") {
|
||||
if normalized.contains("excel")
|
||||
|| normalized.contains("xlsx")
|
||||
|| instruction.contains("导出")
|
||||
{
|
||||
return Some(WorkflowRoute::ZhihuHotlistExportXlsx);
|
||||
}
|
||||
}
|
||||
@@ -73,9 +80,11 @@ pub fn detect_route(
|
||||
pub fn prefers_direct_execution(route: &WorkflowRoute) -> bool {
|
||||
matches!(
|
||||
route,
|
||||
WorkflowRoute::ZhihuArticleEntry |
|
||||
WorkflowRoute::ZhihuArticleDraft |
|
||||
WorkflowRoute::ZhihuArticlePublish
|
||||
WorkflowRoute::ZhihuHotlistExportXlsx
|
||||
| WorkflowRoute::ZhihuHotlistScreen
|
||||
| WorkflowRoute::ZhihuArticleEntry
|
||||
| WorkflowRoute::ZhihuArticleDraft
|
||||
| WorkflowRoute::ZhihuArticlePublish
|
||||
)
|
||||
}
|
||||
|
||||
@@ -85,22 +94,23 @@ pub fn should_fallback_after_summary(summary: &str, route: &WorkflowRoute) -> bo
|
||||
return false;
|
||||
}
|
||||
|
||||
let looks_like_denial = summary.contains("拒绝") ||
|
||||
normalized.contains("denied") ||
|
||||
normalized.contains("failed") ||
|
||||
normalized.contains("protocol error") ||
|
||||
normalized.contains("maximum tool iterations") ||
|
||||
summary.contains("失败") ||
|
||||
summary.contains("无法");
|
||||
let looks_like_denial = summary.contains("拒绝")
|
||||
|| normalized.contains("denied")
|
||||
|| normalized.contains("failed")
|
||||
|| normalized.contains("protocol error")
|
||||
|| normalized.contains("maximum tool iterations")
|
||||
|| summary.contains("失败")
|
||||
|| summary.contains("无法");
|
||||
|
||||
looks_like_denial || matches!(
|
||||
route,
|
||||
WorkflowRoute::ZhihuHotlistExportXlsx |
|
||||
WorkflowRoute::ZhihuHotlistScreen |
|
||||
WorkflowRoute::ZhihuArticleEntry |
|
||||
WorkflowRoute::ZhihuArticleDraft |
|
||||
WorkflowRoute::ZhihuArticlePublish
|
||||
)
|
||||
looks_like_denial
|
||||
|| matches!(
|
||||
route,
|
||||
WorkflowRoute::ZhihuHotlistExportXlsx
|
||||
| WorkflowRoute::ZhihuHotlistScreen
|
||||
| WorkflowRoute::ZhihuArticleEntry
|
||||
| WorkflowRoute::ZhihuArticleDraft
|
||||
| WorkflowRoute::ZhihuArticlePublish
|
||||
)
|
||||
}
|
||||
|
||||
pub fn execute_route<T: Transport + 'static>(
|
||||
@@ -114,15 +124,19 @@ pub fn execute_route<T: Transport + 'static>(
|
||||
match route {
|
||||
WorkflowRoute::ZhihuHotlistExportXlsx | WorkflowRoute::ZhihuHotlistScreen => {
|
||||
let top_n = extract_top_n(instruction);
|
||||
let items = collect_hotlist_items(transport, browser_tool, top_n)?;
|
||||
let items = collect_hotlist_items(transport, browser_tool, top_n, task_context)?;
|
||||
if items.is_empty() {
|
||||
return Err(PipeError::Protocol(
|
||||
"知乎热榜采集失败:未能从页面文本中解析到热榜条目".to_string(),
|
||||
));
|
||||
}
|
||||
match route {
|
||||
WorkflowRoute::ZhihuHotlistExportXlsx => export_xlsx(transport, workspace_root, &items),
|
||||
WorkflowRoute::ZhihuHotlistScreen => export_screen(transport, workspace_root, &items),
|
||||
WorkflowRoute::ZhihuHotlistExportXlsx => {
|
||||
export_xlsx(transport, workspace_root, &items)
|
||||
}
|
||||
WorkflowRoute::ZhihuHotlistScreen => {
|
||||
export_screen(transport, workspace_root, &items)
|
||||
}
|
||||
_ => unreachable!("handled by outer match"),
|
||||
}
|
||||
}
|
||||
@@ -142,8 +156,9 @@ fn collect_hotlist_items<T: Transport + 'static>(
|
||||
transport: &T,
|
||||
browser_tool: &BrowserPipeTool<T>,
|
||||
top_n: usize,
|
||||
task_context: &CompatTaskContext,
|
||||
) -> Result<Vec<HotlistItem>, PipeError> {
|
||||
navigate_hotlist_with_retry(transport, browser_tool)?;
|
||||
ensure_hotlist_page_ready(transport, browser_tool, task_context)?;
|
||||
transport.send(&AgentMessage::LogEntry {
|
||||
level: "info".to_string(),
|
||||
message: "call zhihu-hotlist.extract_hotlist".to_string(),
|
||||
@@ -168,35 +183,87 @@ fn collect_hotlist_items<T: Transport + 'static>(
|
||||
parse_hotlist_items_payload(response.data.get("text").unwrap_or(&response.data))
|
||||
}
|
||||
|
||||
fn navigate_hotlist_with_retry<T: Transport + 'static>(
|
||||
fn ensure_hotlist_page_ready<T: Transport + 'static>(
|
||||
transport: &T,
|
||||
browser_tool: &BrowserPipeTool<T>,
|
||||
task_context: &CompatTaskContext,
|
||||
) -> Result<(), PipeError> {
|
||||
let starts_on_hotlist = task_context
|
||||
.page_url
|
||||
.as_deref()
|
||||
.is_some_and(|url| url.starts_with(ZHIHU_HOT_URL))
|
||||
|| task_context
|
||||
.page_title
|
||||
.as_deref()
|
||||
.is_some_and(|title| title.contains("热榜"));
|
||||
|
||||
if starts_on_hotlist && poll_for_hotlist_readiness(browser_tool)? {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let mut last_error = None;
|
||||
for attempt in 0..2 {
|
||||
navigate_hotlist_page(transport, browser_tool)?;
|
||||
if poll_for_hotlist_readiness(browser_tool)? {
|
||||
return Ok(());
|
||||
}
|
||||
last_error = Some(PipeError::Protocol(format!(
|
||||
"知乎热榜页面已打开,但在短轮询窗口内仍未出现可读热榜内容(attempt={})",
|
||||
attempt + 1
|
||||
)));
|
||||
}
|
||||
|
||||
Err(last_error.unwrap_or_else(|| PipeError::Protocol("知乎热榜页面未就绪".to_string())))
|
||||
}
|
||||
|
||||
fn navigate_hotlist_page<T: Transport + 'static>(
|
||||
transport: &T,
|
||||
browser_tool: &BrowserPipeTool<T>,
|
||||
) -> Result<(), PipeError> {
|
||||
let mut last_error = None;
|
||||
for _ in 0..2 {
|
||||
transport.send(&AgentMessage::LogEntry {
|
||||
level: "info".to_string(),
|
||||
message: format!("navigate {ZHIHU_HOT_URL}"),
|
||||
})?;
|
||||
match browser_tool.invoke(
|
||||
Action::Navigate,
|
||||
json!({ "url": ZHIHU_HOT_URL }),
|
||||
ZHIHU_DOMAIN,
|
||||
) {
|
||||
Ok(response) if response.success => return Ok(()),
|
||||
Ok(response) => {
|
||||
last_error = Some(PipeError::Protocol(format!(
|
||||
"navigate failed: {}",
|
||||
response.data
|
||||
)));
|
||||
transport.send(&AgentMessage::LogEntry {
|
||||
level: "info".to_string(),
|
||||
message: format!("navigate {ZHIHU_HOT_URL}"),
|
||||
})?;
|
||||
let response = browser_tool.invoke(
|
||||
Action::Navigate,
|
||||
json!({ "url": ZHIHU_HOT_URL }),
|
||||
ZHIHU_DOMAIN,
|
||||
)?;
|
||||
if response.success {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(PipeError::Protocol(format!(
|
||||
"navigate failed: {}",
|
||||
response.data
|
||||
)))
|
||||
}
|
||||
}
|
||||
|
||||
fn poll_for_hotlist_readiness<T: Transport + 'static>(
|
||||
browser_tool: &BrowserPipeTool<T>,
|
||||
) -> Result<bool, PipeError> {
|
||||
let ready_pattern =
|
||||
Regex::new(HOTLIST_TEXT_READY_PATTERN).expect("hotlist readiness regex must compile");
|
||||
for attempt in 0..HOTLIST_READY_POLL_ATTEMPTS {
|
||||
let response =
|
||||
browser_tool.invoke(Action::GetText, json!({ "selector": "body" }), ZHIHU_DOMAIN)?;
|
||||
if response.success {
|
||||
let payload = response.data.get("text").unwrap_or(&response.data);
|
||||
if hotlist_text_looks_ready(payload, &ready_pattern) {
|
||||
return Ok(true);
|
||||
}
|
||||
Err(err) => last_error = Some(err),
|
||||
}
|
||||
|
||||
if attempt + 1 < HOTLIST_READY_POLL_ATTEMPTS {
|
||||
thread::sleep(HOTLIST_READY_POLL_INTERVAL);
|
||||
}
|
||||
}
|
||||
Ok(false)
|
||||
}
|
||||
|
||||
Err(last_error.unwrap_or_else(|| {
|
||||
PipeError::Protocol("navigate failed without detailed error".to_string())
|
||||
}))
|
||||
fn hotlist_text_looks_ready(payload: &Value, ready_pattern: &Regex) -> bool {
|
||||
let text = payload.as_str().unwrap_or_default();
|
||||
text.contains("热榜") && ready_pattern.is_match(text)
|
||||
}
|
||||
|
||||
fn export_xlsx<T: Transport>(
|
||||
@@ -224,15 +291,17 @@ fn export_xlsx<T: Transport>(
|
||||
.map_err(|err| PipeError::Protocol(err.to_string()))?;
|
||||
if !result.success {
|
||||
return Err(PipeError::Protocol(
|
||||
result.error.unwrap_or_else(|| "openxml_office failed".to_string()),
|
||||
result
|
||||
.error
|
||||
.unwrap_or_else(|| "openxml_office failed".to_string()),
|
||||
));
|
||||
}
|
||||
|
||||
let payload: Value = serde_json::from_str(&result.output)
|
||||
.map_err(|err| PipeError::Protocol(format!("invalid openxml_office output: {err}")))?;
|
||||
let output_path = payload["output_path"]
|
||||
.as_str()
|
||||
.ok_or_else(|| PipeError::Protocol("openxml_office did not return output_path".to_string()))?;
|
||||
let output_path = payload["output_path"].as_str().ok_or_else(|| {
|
||||
PipeError::Protocol("openxml_office did not return output_path".to_string())
|
||||
})?;
|
||||
Ok(format!("已导出知乎热榜 Excel {output_path}"))
|
||||
}
|
||||
|
||||
@@ -257,15 +326,17 @@ fn export_screen<T: Transport>(
|
||||
.map_err(|err| PipeError::Protocol(err.to_string()))?;
|
||||
if !result.success {
|
||||
return Err(PipeError::Protocol(
|
||||
result.error.unwrap_or_else(|| "screen_html_export failed".to_string()),
|
||||
result
|
||||
.error
|
||||
.unwrap_or_else(|| "screen_html_export failed".to_string()),
|
||||
));
|
||||
}
|
||||
|
||||
let payload: Value = serde_json::from_str(&result.output)
|
||||
.map_err(|err| PipeError::Protocol(format!("invalid screen_html_export output: {err}")))?;
|
||||
let output_path = payload["output_path"]
|
||||
.as_str()
|
||||
.ok_or_else(|| PipeError::Protocol("screen_html_export did not return output_path".to_string()))?;
|
||||
let output_path = payload["output_path"].as_str().ok_or_else(|| {
|
||||
PipeError::Protocol("screen_html_export did not return output_path".to_string())
|
||||
})?;
|
||||
Ok(format!("已生成知乎热榜大屏 {output_path}"))
|
||||
}
|
||||
|
||||
@@ -300,7 +371,9 @@ fn execute_zhihu_article_route<T: Transport + 'static>(
|
||||
ZHIHU_DOMAIN,
|
||||
)?;
|
||||
if is_login_required_payload(&creator_state) {
|
||||
return Ok(build_login_block_message(payload_current_url(&creator_state)));
|
||||
return Ok(build_login_block_message(payload_current_url(
|
||||
&creator_state,
|
||||
)));
|
||||
}
|
||||
if payload_status(&creator_state) == Some("creator_home") {
|
||||
return Ok(build_creator_entry_missing_message(payload_current_url(
|
||||
@@ -321,10 +394,14 @@ fn execute_zhihu_article_route<T: Transport + 'static>(
|
||||
ZHIHU_EDITOR_DOMAIN,
|
||||
)?;
|
||||
if is_login_required_payload(&editor_state) {
|
||||
return Ok(build_login_block_message(payload_current_url(&editor_state)));
|
||||
return Ok(build_login_block_message(payload_current_url(
|
||||
&editor_state,
|
||||
)));
|
||||
}
|
||||
if payload_status(&editor_state) != Some("editor_ready") {
|
||||
return Ok(build_editor_unavailable_message(payload_current_url(&editor_state)));
|
||||
return Ok(build_editor_unavailable_message(payload_current_url(
|
||||
&editor_state,
|
||||
)));
|
||||
}
|
||||
|
||||
transport.send(&AgentMessage::LogEntry {
|
||||
@@ -347,7 +424,10 @@ fn execute_zhihu_article_route<T: Transport + 'static>(
|
||||
}
|
||||
|
||||
match payload_status(&fill_result) {
|
||||
Some("draft_ready") => Ok(format!("已进入知乎文章编辑器并写入草稿《{}》", article.title)),
|
||||
Some("draft_ready") => Ok(format!(
|
||||
"已进入知乎文章编辑器并写入草稿《{}》",
|
||||
article.title
|
||||
)),
|
||||
Some("publish_clicked") | Some("publish_submitted") => {
|
||||
Ok(format!("已提交知乎文章发布流程《{}》", article.title))
|
||||
}
|
||||
@@ -380,7 +460,9 @@ fn execute_zhihu_article_entry_route<T: Transport + 'static>(
|
||||
ZHIHU_DOMAIN,
|
||||
)?;
|
||||
if is_login_required_payload(&creator_state) {
|
||||
return Ok(build_login_block_message(payload_current_url(&creator_state)));
|
||||
return Ok(build_login_block_message(payload_current_url(
|
||||
&creator_state,
|
||||
)));
|
||||
}
|
||||
if payload_status(&creator_state) == Some("creator_home") {
|
||||
return Ok(build_creator_entry_missing_message(payload_current_url(
|
||||
@@ -401,13 +483,17 @@ fn execute_zhihu_article_entry_route<T: Transport + 'static>(
|
||||
ZHIHU_EDITOR_DOMAIN,
|
||||
)?;
|
||||
if is_login_required_payload(&editor_state) {
|
||||
return Ok(build_login_block_message(payload_current_url(&editor_state)));
|
||||
return Ok(build_login_block_message(payload_current_url(
|
||||
&editor_state,
|
||||
)));
|
||||
}
|
||||
if payload_status(&editor_state) == Some("editor_ready") {
|
||||
return Ok("已进入知乎文章编辑器。".to_string());
|
||||
}
|
||||
|
||||
Ok(build_editor_unavailable_message(payload_current_url(&editor_state)))
|
||||
Ok(build_editor_unavailable_message(payload_current_url(
|
||||
&editor_state,
|
||||
)))
|
||||
}
|
||||
|
||||
fn load_hotlist_extractor_script(top_n: usize) -> Result<String, PipeError> {
|
||||
@@ -443,7 +529,11 @@ fn parse_hotlist_items_payload(payload: &Value) -> Result<Vec<HotlistItem>, Pipe
|
||||
|
||||
let rank = cells[0]
|
||||
.as_u64()
|
||||
.or_else(|| cells[0].as_str().and_then(|value| value.parse::<u64>().ok()))
|
||||
.or_else(|| {
|
||||
cells[0]
|
||||
.as_str()
|
||||
.and_then(|value| value.parse::<u64>().ok())
|
||||
})
|
||||
.unwrap_or((items.len() + 1) as u64);
|
||||
let title = cells[1].as_str().unwrap_or_default().trim().to_string();
|
||||
let heat = cells[2].as_str().unwrap_or_default().trim().to_string();
|
||||
@@ -483,7 +573,10 @@ fn navigate_zhihu_page<T: Transport + 'static>(
|
||||
if response.success {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(PipeError::Protocol(format!("navigate failed: {}", response.data)))
|
||||
Err(PipeError::Protocol(format!(
|
||||
"navigate failed: {}",
|
||||
response.data
|
||||
)))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -507,7 +600,9 @@ fn execute_browser_skill_script<T: Transport + 'static>(
|
||||
)));
|
||||
}
|
||||
|
||||
Ok(normalize_payload(response.data.get("text").unwrap_or(&response.data)))
|
||||
Ok(normalize_payload(
|
||||
response.data.get("text").unwrap_or(&response.data),
|
||||
))
|
||||
}
|
||||
|
||||
fn navigate_to_editor_after_creator_entry<T: Transport + 'static>(
|
||||
@@ -542,6 +637,239 @@ fn navigate_to_editor_after_creator_entry<T: Transport + 'static>(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::collections::VecDeque;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use crate::pipe::{BrowserMessage, Timing};
|
||||
use crate::security::MacPolicy;
|
||||
|
||||
struct MockWorkflowTransport {
|
||||
sent: Mutex<Vec<AgentMessage>>,
|
||||
responses: Mutex<VecDeque<BrowserMessage>>,
|
||||
}
|
||||
|
||||
impl MockWorkflowTransport {
|
||||
fn new(responses: Vec<BrowserMessage>) -> Self {
|
||||
Self {
|
||||
sent: Mutex::new(Vec::new()),
|
||||
responses: Mutex::new(VecDeque::from(responses)),
|
||||
}
|
||||
}
|
||||
|
||||
fn sent_messages(&self) -> Vec<AgentMessage> {
|
||||
self.sent.lock().unwrap().clone()
|
||||
}
|
||||
}
|
||||
|
||||
impl Transport for MockWorkflowTransport {
|
||||
fn send(&self, message: &AgentMessage) -> Result<(), PipeError> {
|
||||
self.sent.lock().unwrap().push(message.clone());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn recv_timeout(&self, _timeout: Duration) -> Result<BrowserMessage, PipeError> {
|
||||
self.responses
|
||||
.lock()
|
||||
.unwrap()
|
||||
.pop_front()
|
||||
.ok_or(PipeError::Timeout)
|
||||
}
|
||||
}
|
||||
|
||||
fn zhihu_test_policy() -> MacPolicy {
|
||||
MacPolicy::from_json_str(
|
||||
&json!({
|
||||
"version": "1.0",
|
||||
"domains": { "allowed": ["www.zhihu.com"] },
|
||||
"pipe_actions": {
|
||||
"allowed": ["navigate", "getText", "eval"],
|
||||
"blocked": []
|
||||
}
|
||||
})
|
||||
.to_string(),
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn success_browser_response(seq: u64, data: Value) -> BrowserMessage {
|
||||
BrowserMessage::Response {
|
||||
seq,
|
||||
success: true,
|
||||
data,
|
||||
aom_snapshot: vec![],
|
||||
timing: Timing {
|
||||
queue_ms: 1,
|
||||
exec_ms: 10,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn collect_hotlist_items_skips_navigation_when_hot_page_is_already_readable() {
|
||||
let transport = Arc::new(MockWorkflowTransport::new(vec![
|
||||
success_browser_response(
|
||||
1,
|
||||
json!({ "text": "知乎热榜\n1 问题一 344万热度\n2 问题二 266万热度" }),
|
||||
),
|
||||
success_browser_response(
|
||||
2,
|
||||
json!({
|
||||
"text": {
|
||||
"source": "https://www.zhihu.com/hot",
|
||||
"sheet_name": "知乎热榜",
|
||||
"columns": ["rank", "title", "heat"],
|
||||
"rows": [[1, "问题一", "344万"], [2, "问题二", "266万"]]
|
||||
}
|
||||
}),
|
||||
),
|
||||
]));
|
||||
let browser_tool =
|
||||
BrowserPipeTool::new(transport.clone(), zhihu_test_policy(), vec![1, 2, 3, 4])
|
||||
.with_response_timeout(Duration::from_secs(1));
|
||||
let task_context = CompatTaskContext {
|
||||
page_url: Some("https://www.zhihu.com/hot".to_string()),
|
||||
page_title: Some("知乎热榜".to_string()),
|
||||
..CompatTaskContext::default()
|
||||
};
|
||||
|
||||
let items = collect_hotlist_items(transport.as_ref(), &browser_tool, 10, &task_context)
|
||||
.expect("hotlist collection should succeed");
|
||||
|
||||
assert_eq!(items.len(), 2);
|
||||
let sent = transport.sent_messages();
|
||||
assert!(sent.iter().any(|message| {
|
||||
matches!(
|
||||
message,
|
||||
AgentMessage::Command { action, .. } if action == &Action::GetText
|
||||
)
|
||||
}));
|
||||
assert!(sent.iter().any(|message| {
|
||||
matches!(
|
||||
message,
|
||||
AgentMessage::Command { action, .. } if action == &Action::Eval
|
||||
)
|
||||
}));
|
||||
assert!(!sent.iter().any(|message| {
|
||||
matches!(
|
||||
message,
|
||||
AgentMessage::Command { action, .. } if action == &Action::Navigate
|
||||
)
|
||||
}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn collect_hotlist_items_polls_after_navigation_before_retrying_navigation() {
|
||||
let transport = Arc::new(MockWorkflowTransport::new(vec![
|
||||
success_browser_response(1, json!({ "navigated": true })),
|
||||
success_browser_response(2, json!({ "text": "" })),
|
||||
success_browser_response(3, json!({ "text": "" })),
|
||||
success_browser_response(4, json!({ "text": "知乎热榜\n1 问题一 344万热度" })),
|
||||
success_browser_response(
|
||||
5,
|
||||
json!({
|
||||
"text": {
|
||||
"source": "https://www.zhihu.com/hot",
|
||||
"sheet_name": "知乎热榜",
|
||||
"columns": ["rank", "title", "heat"],
|
||||
"rows": [[1, "问题一", "344万"]]
|
||||
}
|
||||
}),
|
||||
),
|
||||
]));
|
||||
let browser_tool =
|
||||
BrowserPipeTool::new(transport.clone(), zhihu_test_policy(), vec![1, 2, 3, 4, 5])
|
||||
.with_response_timeout(Duration::from_secs(1));
|
||||
let task_context = CompatTaskContext {
|
||||
page_url: Some("https://www.zhihu.com/".to_string()),
|
||||
page_title: Some("知乎".to_string()),
|
||||
..CompatTaskContext::default()
|
||||
};
|
||||
|
||||
let items = collect_hotlist_items(transport.as_ref(), &browser_tool, 10, &task_context)
|
||||
.expect("hotlist collection should succeed after readiness polling");
|
||||
|
||||
assert_eq!(items.len(), 1);
|
||||
let sent = transport.sent_messages();
|
||||
let actions = sent
|
||||
.iter()
|
||||
.filter_map(|message| match message {
|
||||
AgentMessage::Command { action, .. } => Some(action.clone()),
|
||||
_ => None,
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(
|
||||
actions,
|
||||
vec![
|
||||
Action::Navigate,
|
||||
Action::GetText,
|
||||
Action::GetText,
|
||||
Action::GetText,
|
||||
Action::Eval
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn collect_hotlist_items_retries_navigation_after_short_readiness_budget_expires() {
|
||||
let transport = Arc::new(MockWorkflowTransport::new(vec![
|
||||
success_browser_response(1, json!({ "navigated": true })),
|
||||
success_browser_response(2, json!({ "text": "" })),
|
||||
success_browser_response(3, json!({ "text": "" })),
|
||||
success_browser_response(4, json!({ "text": "" })),
|
||||
success_browser_response(5, json!({ "text": "" })),
|
||||
success_browser_response(6, json!({ "text": "" })),
|
||||
success_browser_response(7, json!({ "text": "" })),
|
||||
success_browser_response(8, json!({ "text": "" })),
|
||||
success_browser_response(9, json!({ "text": "" })),
|
||||
success_browser_response(10, json!({ "text": "" })),
|
||||
success_browser_response(11, json!({ "text": "" })),
|
||||
success_browser_response(12, json!({ "navigated": true })),
|
||||
success_browser_response(13, json!({ "text": "知乎热榜\n1 问题一 344万热度" })),
|
||||
success_browser_response(
|
||||
14,
|
||||
json!({
|
||||
"text": {
|
||||
"source": "https://www.zhihu.com/hot",
|
||||
"sheet_name": "知乎热榜",
|
||||
"columns": ["rank", "title", "heat"],
|
||||
"rows": [[1, "问题一", "344万"]]
|
||||
}
|
||||
}),
|
||||
),
|
||||
]));
|
||||
let browser_tool = BrowserPipeTool::new(
|
||||
transport.clone(),
|
||||
zhihu_test_policy(),
|
||||
vec![1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14],
|
||||
)
|
||||
.with_response_timeout(Duration::from_secs(1));
|
||||
let task_context = CompatTaskContext {
|
||||
page_url: Some("https://www.zhihu.com/".to_string()),
|
||||
page_title: Some("知乎".to_string()),
|
||||
..CompatTaskContext::default()
|
||||
};
|
||||
|
||||
let items = collect_hotlist_items(transport.as_ref(), &browser_tool, 10, &task_context)
|
||||
.expect("hotlist collection should succeed after one navigation retry");
|
||||
|
||||
assert_eq!(items.len(), 1);
|
||||
let sent = transport.sent_messages();
|
||||
let navigate_count = sent
|
||||
.iter()
|
||||
.filter(|message| {
|
||||
matches!(
|
||||
message,
|
||||
AgentMessage::Command { action, .. } if action == &Action::Navigate
|
||||
)
|
||||
})
|
||||
.count();
|
||||
assert_eq!(navigate_count, 2);
|
||||
}
|
||||
}
|
||||
|
||||
fn load_browser_skill_script(
|
||||
skill_name: &str,
|
||||
script_name: &str,
|
||||
@@ -563,8 +891,7 @@ fn load_browser_skill_script(
|
||||
})?;
|
||||
Ok(format!(
|
||||
"(function() {{\nconst args = {};\n{}\n}})()",
|
||||
args,
|
||||
script
|
||||
args, script
|
||||
))
|
||||
}
|
||||
|
||||
@@ -632,11 +959,11 @@ fn build_publish_confirmation_message(article: &ArticleDraft) -> String {
|
||||
|
||||
fn has_explicit_publish_confirmation(instruction: &str) -> bool {
|
||||
let trimmed = instruction.trim();
|
||||
trimmed.contains("确认发布") ||
|
||||
trimmed.contains("确认发表") ||
|
||||
trimmed.contains("现在发布") ||
|
||||
trimmed.contains("立即发布") ||
|
||||
trimmed.contains("可以发布")
|
||||
trimmed.contains("确认发布")
|
||||
|| trimmed.contains("确认发表")
|
||||
|| trimmed.contains("现在发布")
|
||||
|| trimmed.contains("立即发布")
|
||||
|| trimmed.contains("可以发布")
|
||||
}
|
||||
|
||||
fn task_requests_zhihu_article_entry(
|
||||
@@ -649,17 +976,17 @@ fn task_requests_zhihu_article_entry(
|
||||
}
|
||||
|
||||
let normalized = instruction.to_ascii_lowercase();
|
||||
let asks_to_open = normalized.contains("open") ||
|
||||
normalized.contains("goto") ||
|
||||
normalized.contains("go to") ||
|
||||
instruction.contains("打开") ||
|
||||
instruction.contains("进入") ||
|
||||
instruction.contains("去");
|
||||
let mentions_entry = instruction.contains("页面") ||
|
||||
instruction.contains("入口") ||
|
||||
instruction.contains("创作中心") ||
|
||||
instruction.contains("写文章") ||
|
||||
instruction.contains("发文章");
|
||||
let asks_to_open = normalized.contains("open")
|
||||
|| normalized.contains("goto")
|
||||
|| normalized.contains("go to")
|
||||
|| instruction.contains("打开")
|
||||
|| instruction.contains("进入")
|
||||
|| instruction.contains("去");
|
||||
let mentions_entry = instruction.contains("页面")
|
||||
|| instruction.contains("入口")
|
||||
|| instruction.contains("创作中心")
|
||||
|| instruction.contains("写文章")
|
||||
|| instruction.contains("发文章");
|
||||
let has_article_inputs = parse_article_draft(instruction).is_some();
|
||||
|
||||
asks_to_open && mentions_entry && !has_article_inputs
|
||||
@@ -681,12 +1008,11 @@ fn extract_article_draft(
|
||||
fn parse_article_draft(text: &str) -> Option<ArticleDraft> {
|
||||
let normalized = normalize_article_draft_input(text);
|
||||
let title_re = Regex::new(r"(?m)^标题[::]\s*(.+?)\s*$").expect("valid zhihu title regex");
|
||||
let body_re =
|
||||
Regex::new(r"(?s)正文[::]\s*(.+)$").expect("valid zhihu body regex");
|
||||
let inline_title_re = Regex::new(r"标题(?:是|为)\s*([^,,\n]+)")
|
||||
.expect("valid inline zhihu title regex");
|
||||
let inline_body_re = Regex::new(r"(?s)正文(?:是|为)\s*(.+)$")
|
||||
.expect("valid inline zhihu body regex");
|
||||
let body_re = Regex::new(r"(?s)正文[::]\s*(.+)$").expect("valid zhihu body regex");
|
||||
let inline_title_re =
|
||||
Regex::new(r"标题(?:是|为)\s*([^,,\n]+)").expect("valid inline zhihu title regex");
|
||||
let inline_body_re =
|
||||
Regex::new(r"(?s)正文(?:是|为)\s*(.+)$").expect("valid inline zhihu body regex");
|
||||
|
||||
let title = title_re
|
||||
.captures(&normalized)
|
||||
@@ -718,9 +1044,9 @@ fn parse_article_draft(text: &str) -> Option<ArticleDraft> {
|
||||
|
||||
fn normalize_article_draft_input(text: &str) -> String {
|
||||
let trimmed = text.trim();
|
||||
let unquoted = if trimmed.len() >= 2 &&
|
||||
((trimmed.starts_with('"') && trimmed.ends_with('"')) ||
|
||||
(trimmed.starts_with('\'') && trimmed.ends_with('\'')))
|
||||
let unquoted = if trimmed.len() >= 2
|
||||
&& ((trimmed.starts_with('"') && trimmed.ends_with('"'))
|
||||
|| (trimmed.starts_with('\'') && trimmed.ends_with('\'')))
|
||||
{
|
||||
&trimmed[1..trimmed.len() - 1]
|
||||
} else {
|
||||
|
||||
Reference in New Issue
Block a user