feat: add websocket browser service runtime

Wire the service/browser runtime onto the websocket-driven execution path and add the new browser/service modules needed for the submit flow and runtime integration.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
木炎
2026-04-04 23:42:27 +08:00
parent 2ae71fb1c9
commit 3e18350320
33 changed files with 4993 additions and 327 deletions

View File

@@ -1,5 +1,6 @@
use std::fs;
use std::path::Path;
use std::sync::Arc;
use std::thread;
use std::time::Duration;
@@ -7,6 +8,7 @@ use regex::Regex;
use serde_json::{json, Value};
use zeroclaw::tools::Tool;
use crate::browser::{BrowserBackend, PipeBrowserBackend};
use crate::compat::openxml_office_tool::OpenXmlOfficeTool;
use crate::compat::runtime::CompatTaskContext;
use crate::compat::screen_html_export_tool::ScreenHtmlExportTool;
@@ -21,8 +23,13 @@ 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);
// Simplified readiness pattern: only checks that *some* heat metric exists
// (e.g. "3440万热度", "2.1亿"). The full rank-title-heat structure is validated
// later by the extraction script. Using a simple pattern avoids problems with
// the multi-line innerText format where rank, title, and heat are on separate
// lines (`.` does not cross newlines by default).
const HOTLIST_TEXT_READY_PATTERN: &str =
r"(?:^|\n)\s*1(?:[.、]|\s)+.+\d+(?:\.\d+)?\s*(?:万|亿|k|K|m|M)(?:热度)?";
r"\d+(?:\.\d+)?\s*(?:万|亿|k|K|m|M)\s*(?:热度)?";
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum WorkflowRoute {
ZhihuHotlistExportXlsx,
@@ -113,9 +120,9 @@ pub fn should_fallback_after_summary(summary: &str, route: &WorkflowRoute) -> bo
)
}
pub fn execute_route<T: Transport + 'static>(
transport: &T,
browser_tool: &BrowserPipeTool<T>,
pub fn execute_route_with_browser_backend(
transport: &dyn crate::agent::AgentEventSink,
browser_backend: Arc<dyn BrowserBackend>,
workspace_root: &Path,
instruction: &str,
task_context: &CompatTaskContext,
@@ -124,37 +131,61 @@ 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, task_context)?;
let items = collect_hotlist_items(transport, browser_backend.as_ref(), 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"),
}
}
WorkflowRoute::ZhihuArticleEntry => {
execute_zhihu_article_entry_route(transport, browser_tool)
}
WorkflowRoute::ZhihuArticleDraft => {
execute_zhihu_article_route(transport, browser_tool, instruction, task_context, false)
}
WorkflowRoute::ZhihuArticlePublish => {
execute_zhihu_article_route(transport, browser_tool, instruction, task_context, true)
execute_zhihu_article_entry_route(transport, browser_backend.as_ref())
}
WorkflowRoute::ZhihuArticleDraft => execute_zhihu_article_route(
transport,
browser_backend.as_ref(),
instruction,
task_context,
false,
),
WorkflowRoute::ZhihuArticlePublish => execute_zhihu_article_route(
transport,
browser_backend.as_ref(),
instruction,
task_context,
true,
),
}
}
fn collect_hotlist_items<T: Transport + 'static>(
pub fn execute_route<T: Transport + 'static>(
transport: &T,
browser_tool: &BrowserPipeTool<T>,
workspace_root: &Path,
instruction: &str,
task_context: &CompatTaskContext,
route: WorkflowRoute,
) -> Result<String, PipeError> {
let browser_backend: Arc<dyn BrowserBackend> =
Arc::new(PipeBrowserBackend::from_inner(browser_tool.clone()));
execute_route_with_browser_backend(
transport,
browser_backend,
workspace_root,
instruction,
task_context,
route,
)
}
fn collect_hotlist_items(
transport: &dyn crate::agent::AgentEventSink,
browser_tool: &dyn BrowserBackend,
top_n: usize,
task_context: &CompatTaskContext,
) -> Result<Vec<HotlistItem>, PipeError> {
@@ -185,9 +216,9 @@ fn collect_hotlist_items<T: Transport + 'static>(
parse_hotlist_items_payload(response.data.get("text").unwrap_or(&response.data))
}
fn ensure_hotlist_page_ready<T: Transport + 'static>(
transport: &T,
browser_tool: &BrowserPipeTool<T>,
fn ensure_hotlist_page_ready(
transport: &dyn crate::agent::AgentEventSink,
browser_tool: &dyn BrowserBackend,
top_n: usize,
task_context: &CompatTaskContext,
) -> Result<Option<Vec<HotlistItem>>, PipeError> {
@@ -227,9 +258,9 @@ fn ensure_hotlist_page_ready<T: Transport + 'static>(
Err(last_error.unwrap_or_else(|| PipeError::Protocol("知乎热榜页面未就绪".to_string())))
}
fn probe_hotlist_extractor<T: Transport + 'static>(
transport: &T,
browser_tool: &BrowserPipeTool<T>,
fn probe_hotlist_extractor(
transport: &dyn crate::agent::AgentEventSink,
browser_tool: &dyn BrowserBackend,
top_n: usize,
) -> Result<Option<Vec<HotlistItem>>, PipeError> {
transport.send(&AgentMessage::LogEntry {
@@ -242,19 +273,32 @@ fn probe_hotlist_extractor<T: Transport + 'static>(
ZHIHU_DOMAIN,
)?;
if !response.success {
eprintln!("probe_hotlist_extractor: eval not successful data={}", response.data);
return Ok(None);
}
match parse_hotlist_items_payload(response.data.get("text").unwrap_or(&response.data)) {
let eval_text = response.data.get("text").unwrap_or(&response.data);
let eval_preview: String = eval_text
.as_str()
.unwrap_or_default()
.chars()
.take(300)
.collect();
eprintln!(
"probe_hotlist_extractor: eval_len={} preview={eval_preview:?}",
eval_text.as_str().unwrap_or_default().len()
);
match parse_hotlist_items_payload(eval_text) {
Ok(items) if !items.is_empty() => Ok(Some(items)),
Ok(_) => Ok(None),
Err(_) => Ok(None),
}
}
fn navigate_hotlist_page<T: Transport + 'static>(
transport: &T,
browser_tool: &BrowserPipeTool<T>,
fn navigate_hotlist_page(
transport: &dyn crate::agent::AgentEventSink,
browser_tool: &dyn BrowserBackend,
) -> Result<(), PipeError> {
transport.send(&AgentMessage::LogEntry {
level: "info".to_string(),
@@ -275,16 +319,34 @@ fn navigate_hotlist_page<T: Transport + 'static>(
}
}
fn poll_for_hotlist_readiness<T: Transport + 'static>(
browser_tool: &BrowserPipeTool<T>,
) -> Result<bool, PipeError> {
fn poll_for_hotlist_readiness(browser_tool: &dyn BrowserBackend) -> 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)?;
// Tolerate individual GetText failures (e.g. callback timeout) they
// are expected while the page is still loading or the callback delivery
// path is not yet established. Only a PipeClosed error is fatal.
let response = match browser_tool.invoke(
Action::GetText,
json!({ "selector": "body" }),
ZHIHU_DOMAIN,
) {
Ok(resp) => resp,
Err(PipeError::PipeClosed) => return Err(PipeError::PipeClosed),
Err(_) => {
if attempt + 1 < HOTLIST_READY_POLL_ATTEMPTS {
thread::sleep(HOTLIST_READY_POLL_INTERVAL);
}
continue;
}
};
if response.success {
let payload = response.data.get("text").unwrap_or(&response.data);
let preview: String = payload.as_str().unwrap_or_default().chars().take(200).collect();
eprintln!(
"poll_hotlist_readiness[{attempt}]: text_len={} preview={preview:?}",
payload.as_str().unwrap_or_default().len()
);
if hotlist_text_looks_ready(payload, &ready_pattern) {
return Ok(true);
}
@@ -302,8 +364,8 @@ fn hotlist_text_looks_ready(payload: &Value, ready_pattern: &Regex) -> bool {
text.contains("热榜") && ready_pattern.is_match(text)
}
fn export_xlsx<T: Transport>(
transport: &T,
fn export_xlsx(
transport: &dyn crate::agent::AgentEventSink,
workspace_root: &Path,
items: &[HotlistItem],
) -> Result<String, PipeError> {
@@ -341,8 +403,8 @@ fn export_xlsx<T: Transport>(
Ok(format!("已导出知乎热榜 Excel {output_path}"))
}
fn export_screen<T: Transport>(
transport: &T,
fn export_screen(
transport: &dyn crate::agent::AgentEventSink,
workspace_root: &Path,
items: &[HotlistItem],
) -> Result<String, PipeError> {
@@ -376,9 +438,9 @@ fn export_screen<T: Transport>(
Ok(format!("已生成知乎热榜大屏 {output_path}"))
}
fn execute_zhihu_article_route<T: Transport + 'static>(
transport: &T,
browser_tool: &BrowserPipeTool<T>,
fn execute_zhihu_article_route(
transport: &dyn crate::agent::AgentEventSink,
browser_tool: &dyn BrowserBackend,
instruction: &str,
task_context: &CompatTaskContext,
publish_mode: bool,
@@ -479,9 +541,9 @@ fn execute_zhihu_article_route<T: Transport + 'static>(
}
}
fn execute_zhihu_article_entry_route<T: Transport + 'static>(
transport: &T,
browser_tool: &BrowserPipeTool<T>,
fn execute_zhihu_article_entry_route(
transport: &dyn crate::agent::AgentEventSink,
browser_tool: &dyn BrowserBackend,
) -> Result<String, PipeError> {
navigate_zhihu_page(transport, browser_tool, ZHIHU_CREATOR_URL)?;
transport.send(&AgentMessage::LogEntry {
@@ -596,9 +658,9 @@ fn extract_top_n(instruction: &str) -> usize {
.unwrap_or(10)
}
fn navigate_zhihu_page<T: Transport + 'static>(
transport: &T,
browser_tool: &BrowserPipeTool<T>,
fn navigate_zhihu_page(
transport: &dyn crate::agent::AgentEventSink,
browser_tool: &dyn BrowserBackend,
url: &str,
) -> Result<(), PipeError> {
transport.send(&AgentMessage::LogEntry {
@@ -616,8 +678,8 @@ fn navigate_zhihu_page<T: Transport + 'static>(
}
}
fn execute_browser_skill_script<T: Transport + 'static>(
browser_tool: &BrowserPipeTool<T>,
fn execute_browser_skill_script(
browser_tool: &dyn BrowserBackend,
skill_name: &str,
script_name: &str,
args: Value,
@@ -641,9 +703,9 @@ fn execute_browser_skill_script<T: Transport + 'static>(
))
}
fn navigate_to_editor_after_creator_entry<T: Transport + 'static>(
transport: &T,
browser_tool: &BrowserPipeTool<T>,
fn navigate_to_editor_after_creator_entry(
transport: &dyn crate::agent::AgentEventSink,
browser_tool: &dyn BrowserBackend,
creator_state: &Value,
) -> Result<(), PipeError> {
let status = payload_status(creator_state);
@@ -679,7 +741,7 @@ mod tests {
use std::collections::VecDeque;
use std::sync::{Arc, Mutex};
use crate::pipe::{BrowserMessage, Timing};
use crate::pipe::{BrowserMessage, CommandOutput, ExecutionSurfaceMetadata, Timing};
use crate::security::MacPolicy;
struct MockWorkflowTransport {
@@ -743,6 +805,245 @@ mod tests {
}
}
#[derive(Default)]
struct FakeBrowserBackend {
responses: Mutex<VecDeque<Result<CommandOutput, PipeError>>>,
invocations: Mutex<Vec<(Action, Value, String)>>,
}
impl FakeBrowserBackend {
fn new(responses: Vec<Result<CommandOutput, PipeError>>) -> Self {
Self {
responses: Mutex::new(VecDeque::from(responses)),
invocations: Mutex::new(Vec::new()),
}
}
fn invocations(&self) -> Vec<(Action, Value, String)> {
self.invocations.lock().unwrap().clone()
}
}
impl BrowserBackend for FakeBrowserBackend {
fn invoke(
&self,
action: Action,
params: Value,
expected_domain: &str,
) -> Result<CommandOutput, PipeError> {
self.invocations
.lock()
.unwrap()
.push((action, params, expected_domain.to_string()));
self.responses
.lock()
.unwrap()
.pop_front()
.unwrap_or_else(|| Err(PipeError::Timeout))
}
fn surface_metadata(&self) -> ExecutionSurfaceMetadata {
ExecutionSurfaceMetadata::privileged_browser_pipe("fake_backend")
}
}
#[test]
fn execute_route_with_browser_backend_runs_direct_route_with_ws_style_backend() {
let transport = Arc::new(MockWorkflowTransport::new(vec![]));
let backend = Arc::new(FakeBrowserBackend::new(vec![
Ok(CommandOutput {
seq: 1,
success: true,
data: json!({}),
aom_snapshot: vec![],
timing: Timing {
queue_ms: 1,
exec_ms: 1,
},
}),
Ok(CommandOutput {
seq: 2,
success: true,
data: json!({"text": "已进入编辑器"}),
aom_snapshot: vec![],
timing: Timing {
queue_ms: 1,
exec_ms: 1,
},
}),
Ok(CommandOutput {
seq: 3,
success: true,
data: json!({
"text": {
"status": "editor_ready",
"current_url": "https://zhuanlan.zhihu.com/write"
}
}),
aom_snapshot: vec![],
timing: Timing {
queue_ms: 1,
exec_ms: 1,
},
}),
]));
let summary = execute_route_with_browser_backend(
transport.as_ref(),
backend.clone(),
Path::new("."),
"打开知乎写文章页面",
&CompatTaskContext::default(),
WorkflowRoute::ZhihuArticleEntry,
)
.expect("ws-style backend should satisfy direct route execution");
assert_eq!(summary, "已进入知乎文章编辑器。");
assert_eq!(
backend.invocations(),
vec![
(
Action::Navigate,
json!({ "url": ZHIHU_CREATOR_URL }),
ZHIHU_DOMAIN.to_string(),
),
(
Action::Eval,
json!({
"script": load_browser_skill_script(
"zhihu-navigate",
"open_creator_entry.js",
json!({ "desired_target": "article_editor" })
)
.expect("zhihu navigate script should load")
}),
ZHIHU_DOMAIN.to_string(),
),
(
Action::Eval,
json!({
"script": load_browser_skill_script(
"zhihu-write",
"prepare_article_editor.js",
json!({ "desired_mode": "draft" })
)
.expect("zhihu write script should load")
}),
ZHIHU_EDITOR_DOMAIN.to_string(),
),
]
);
}
#[test]
fn execute_route_with_browser_backend_keeps_bridge_style_article_entry_direct_route() {
let transport = Arc::new(MockWorkflowTransport::new(vec![]));
let backend = Arc::new(FakeBrowserBackend::new(vec![
Ok(CommandOutput {
seq: 1,
success: true,
data: json!({}),
aom_snapshot: vec![],
timing: Timing {
queue_ms: 1,
exec_ms: 1,
},
}),
Ok(CommandOutput {
seq: 2,
success: true,
data: json!({
"text": {
"status": "creator_entry_clicked",
"current_url": "https://www.zhihu.com/creator",
"next_url": ZHIHU_EDITOR_URL,
}
}),
aom_snapshot: vec![],
timing: Timing {
queue_ms: 1,
exec_ms: 1,
},
}),
Ok(CommandOutput {
seq: 3,
success: true,
data: json!({}),
aom_snapshot: vec![],
timing: Timing {
queue_ms: 1,
exec_ms: 1,
},
}),
Ok(CommandOutput {
seq: 4,
success: true,
data: json!({
"text": {
"status": "editor_ready",
"current_url": ZHIHU_EDITOR_URL,
}
}),
aom_snapshot: vec![],
timing: Timing {
queue_ms: 1,
exec_ms: 1,
},
}),
]));
let summary = execute_route_with_browser_backend(
transport.as_ref(),
backend.clone(),
Path::new("."),
"打开知乎写文章页面",
&CompatTaskContext::default(),
WorkflowRoute::ZhihuArticleEntry,
)
.expect("bridge-style backend should satisfy direct route execution");
assert_eq!(summary, "已进入知乎文章编辑器。");
assert_eq!(
backend.invocations(),
vec![
(
Action::Navigate,
json!({ "url": ZHIHU_CREATOR_URL }),
ZHIHU_DOMAIN.to_string(),
),
(
Action::Eval,
json!({
"script": load_browser_skill_script(
"zhihu-navigate",
"open_creator_entry.js",
json!({ "desired_target": "article_editor" })
)
.expect("zhihu navigate script should load")
}),
ZHIHU_DOMAIN.to_string(),
),
(
Action::Navigate,
json!({ "url": ZHIHU_EDITOR_URL }),
ZHIHU_EDITOR_DOMAIN.to_string(),
),
(
Action::Eval,
json!({
"script": load_browser_skill_script(
"zhihu-write",
"prepare_article_editor.js",
json!({ "desired_mode": "draft" })
)
.expect("zhihu write script should load")
}),
ZHIHU_EDITOR_DOMAIN.to_string(),
),
]
);
}
#[test]
fn collect_hotlist_items_skips_navigation_when_hot_page_is_already_readable() {
let transport = Arc::new(MockWorkflowTransport::new(vec![
@@ -771,7 +1072,8 @@ mod tests {
..CompatTaskContext::default()
};
let items = collect_hotlist_items(transport.as_ref(), &browser_tool, 10, &task_context)
let browser_backend = PipeBrowserBackend::from_inner(browser_tool);
let items = collect_hotlist_items(transport.as_ref(), &browser_backend, 10, &task_context)
.expect("hotlist collection should succeed");
assert_eq!(items.len(), 2);
@@ -824,7 +1126,8 @@ mod tests {
..CompatTaskContext::default()
};
let items = collect_hotlist_items(transport.as_ref(), &browser_tool, 10, &task_context)
let browser_backend = PipeBrowserBackend::from_inner(browser_tool);
let items = collect_hotlist_items(transport.as_ref(), &browser_backend, 10, &task_context)
.expect("hotlist collection should succeed after readiness polling");
assert_eq!(items.len(), 1);
@@ -892,7 +1195,8 @@ mod tests {
..CompatTaskContext::default()
};
let items = collect_hotlist_items(transport.as_ref(), &browser_tool, 10, &task_context)
let browser_backend = PipeBrowserBackend::from_inner(browser_tool);
let items = collect_hotlist_items(transport.as_ref(), &browser_backend, 10, &task_context)
.expect("hotlist collection should succeed after one navigation retry");
assert_eq!(items.len(), 1);
@@ -958,7 +1262,8 @@ mod tests {
..CompatTaskContext::default()
};
let items = collect_hotlist_items(transport.as_ref(), &browser_tool, 10, &task_context)
let browser_backend = PipeBrowserBackend::from_inner(browser_tool);
let items = collect_hotlist_items(transport.as_ref(), &browser_backend, 10, &task_context)
.expect("hotlist collection should succeed via extractor probe");
assert_eq!(items.len(), 1);