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,6 +1,7 @@
use std::collections::HashMap;
use std::fs;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use async_trait::async_trait;
use reqwest::Url;
@@ -8,22 +9,23 @@ use serde_json::{json, Value};
use zeroclaw::skills::{Skill, SkillTool};
use zeroclaw::tools::{Tool, ToolResult};
use crate::pipe::{Action, BrowserPipeTool, Transport};
use crate::browser::BrowserBackend;
use crate::pipe::Action;
pub struct BrowserScriptSkillTool<T: Transport> {
pub struct BrowserScriptSkillTool {
tool_name: String,
tool_description: String,
script_path: PathBuf,
args: HashMap<String, String>,
browser_tool: BrowserPipeTool<T>,
browser_tool: Arc<dyn BrowserBackend>,
}
impl<T: Transport> BrowserScriptSkillTool<T> {
impl BrowserScriptSkillTool {
pub fn new(
skill_name: &str,
tool: &SkillTool,
skill_root: &Path,
browser_tool: BrowserPipeTool<T>,
browser_tool: Arc<dyn BrowserBackend>,
) -> anyhow::Result<Self> {
let script_path = skill_root.join(&tool.command);
let canonical_skill_root = skill_root
@@ -83,7 +85,7 @@ impl<T: Transport> BrowserScriptSkillTool<T> {
}
#[async_trait]
impl<T: Transport + 'static> Tool for BrowserScriptSkillTool<T> {
impl Tool for BrowserScriptSkillTool {
fn name(&self) -> &str {
&self.tool_name
}
@@ -175,12 +177,16 @@ impl<T: Transport + 'static> Tool for BrowserScriptSkillTool<T> {
}
}
pub fn build_browser_script_skill_tools<T: Transport + 'static>(
pub fn build_browser_script_skill_tools(
skills: &[Skill],
browser_tool: BrowserPipeTool<T>,
browser_tool: Arc<dyn BrowserBackend>,
) -> Result<Vec<Box<dyn Tool>>, anyhow::Error> {
let mut tools: Vec<Box<dyn Tool>> = Vec::new();
if !browser_tool.supports_eval() {
return Ok(tools);
}
for skill in skills {
let Some(location) = skill.location.as_ref() else {
continue;

View File

@@ -1,9 +1,12 @@
use std::sync::Arc;
use async_trait::async_trait;
use reqwest::Url;
use serde_json::{json, Map, Value};
use zeroclaw::tools::{Tool, ToolResult};
use crate::pipe::{Action, BrowserPipeTool, ExecutionSurfaceMetadata, Transport};
use crate::browser::BrowserBackend;
use crate::pipe::{Action, ExecutionSurfaceMetadata};
pub const BROWSER_ACTION_TOOL_NAME: &str = "browser_action";
pub const SUPERRPA_BROWSER_TOOL_NAME: &str = "superrpa_browser";
@@ -17,14 +20,14 @@ const MAX_DATA_ARRAY_ITEMS: usize = 12;
const MAX_DATA_OBJECT_FIELDS: usize = 24;
const MAX_DATA_RECURSION_DEPTH: usize = 4;
pub struct ZeroClawBrowserTool<T: Transport> {
browser_tool: BrowserPipeTool<T>,
pub struct ZeroClawBrowserTool {
browser_tool: Arc<dyn BrowserBackend>,
tool_name: &'static str,
description: &'static str,
}
impl<T: Transport> ZeroClawBrowserTool<T> {
pub fn new(browser_tool: BrowserPipeTool<T>) -> Self {
impl ZeroClawBrowserTool {
pub fn new(browser_tool: Arc<dyn BrowserBackend>) -> Self {
Self::named(
browser_tool,
BROWSER_ACTION_TOOL_NAME,
@@ -32,7 +35,7 @@ impl<T: Transport> ZeroClawBrowserTool<T> {
)
}
pub fn new_superrpa(browser_tool: BrowserPipeTool<T>) -> Self {
pub fn new_superrpa(browser_tool: Arc<dyn BrowserBackend>) -> Self {
Self::named(
browser_tool,
SUPERRPA_BROWSER_TOOL_NAME,
@@ -41,7 +44,7 @@ impl<T: Transport> ZeroClawBrowserTool<T> {
}
fn named(
browser_tool: BrowserPipeTool<T>,
browser_tool: Arc<dyn BrowserBackend>,
tool_name: &'static str,
description: &'static str,
) -> Self {
@@ -58,7 +61,7 @@ impl<T: Transport> ZeroClawBrowserTool<T> {
}
#[async_trait]
impl<T: Transport + 'static> Tool for ZeroClawBrowserTool<T> {
impl Tool for ZeroClawBrowserTool {
fn name(&self) -> &str {
self.tool_name
}

View File

@@ -4,10 +4,13 @@ use serde_json::{json, Value};
use std::collections::BTreeMap;
use std::collections::BTreeSet;
use std::fs;
use std::io::Write;
use std::path::{Path, PathBuf};
use std::process::Command;
use std::time::{SystemTime, UNIX_EPOCH};
use zeroclaw::tools::{Tool, ToolResult};
use zip::write::SimpleFileOptions;
use zip::{CompressionMethod, ZipWriter};
const OPENXML_OFFICE_TOOL_NAME: &str = "openxml_office";
const DEFAULT_SHEET_NAME: &str = "知乎热榜";
@@ -128,7 +131,9 @@ impl Tool for OpenXmlOfficeTool {
write_payload_json(&payload_path, &normalized_rows)?;
write_request_json(&request_path, &template_path, &payload_path, &output_path)?;
let rendered = run_openxml_cli(&request_path)?;
let rendered = run_openxml_cli(&request_path).or_else(|_| {
render_locally(&template_path, &payload_path, &output_path)
})?;
let artifact_path = rendered["data"]["artifact"]["path"]
.as_str()
.map(str::to_string)
@@ -280,9 +285,14 @@ fn run_openxml_cli(request_path: &Path) -> anyhow::Result<Value> {
.parent()
.map(|path| path.join("openxml_cli").join("Cargo.toml"))
.ok_or_else(|| anyhow::anyhow!("failed to resolve openxml_cli manifest path"))?;
let binary_name = if cfg!(windows) {
"openxml-cli.exe"
} else {
"openxml-cli"
};
let binary_path = manifest_path
.parent()
.map(|path| path.join("target").join("debug").join("openxml-cli"))
.map(|path| path.join("target").join("debug").join(binary_name))
.ok_or_else(|| anyhow::anyhow!("failed to resolve openxml_cli binary path"))?;
let output = if binary_path.exists() {
@@ -325,6 +335,87 @@ fn run_openxml_cli(request_path: &Path) -> anyhow::Result<Value> {
Ok(serde_json::from_str(&stdout)?)
}
fn render_locally(template_path: &Path, payload_path: &Path, output_path: &Path) -> anyhow::Result<Value> {
let payload: Value = serde_json::from_slice(&fs::read(payload_path)?)?;
let variables = payload["variables"]
.as_object()
.ok_or_else(|| anyhow::anyhow!("payload.variables must be an object"))?;
let worksheet = render_template_xml(&worksheet_xml_from_xlsx(template_path)?, variables);
write_rendered_xlsx(template_path, output_path, "xl/worksheets/sheet1.xml", &worksheet)?;
Ok(json!({
"data": {
"artifact": {
"path": output_path.to_string_lossy().to_string(),
}
}
}))
}
fn worksheet_xml_from_xlsx(path: &Path) -> anyhow::Result<String> {
let file = fs::File::open(path)?;
let mut archive = zip::ZipArchive::new(file)?;
let mut sheet = archive.by_name("xl/worksheets/sheet1.xml")?;
let mut xml = String::new();
std::io::Read::read_to_string(&mut sheet, &mut xml)?;
Ok(xml)
}
fn render_template_xml(
template: &str,
variables: &serde_json::Map<String, Value>,
) -> String {
let mut rendered = template.to_string();
for (key, value) in variables {
let placeholder = format!("{{{{{key}}}}}");
let replacement = value.as_str().unwrap_or_default();
rendered = rendered.replace(&placeholder, &xml_escape(replacement));
}
rendered
}
fn write_rendered_xlsx(
template_path: &Path,
output_path: &Path,
replaced_entry: &str,
replaced_body: &str,
) -> anyhow::Result<()> {
if let Some(parent) = output_path.parent() {
fs::create_dir_all(parent)?;
}
if output_path.exists() {
fs::remove_file(output_path)?;
}
let input = fs::File::open(template_path)?;
let mut archive = zip::ZipArchive::new(input)?;
let output = fs::File::create(output_path)?;
let mut writer = ZipWriter::new(output);
let options = SimpleFileOptions::default().compression_method(CompressionMethod::Stored);
for index in 0..archive.len() {
let mut entry = archive.by_index(index)?;
let name = entry.name().to_string();
writer.start_file(name.as_str(), options)?;
if name == replaced_entry {
writer.write_all(replaced_body.as_bytes())?;
} else {
std::io::copy(&mut entry, &mut writer)?;
}
}
writer.finish()?;
Ok(())
}
fn xml_escape(value: &str) -> String {
value
.replace('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
}
fn value_to_string(value: &Value) -> String {
match value {
Value::String(text) => text.clone(),
@@ -336,46 +427,58 @@ fn value_to_string(value: &Value) -> String {
}
fn write_hotlist_template(path: &Path, row_count: usize) -> anyhow::Result<()> {
let build_root = path
.parent()
.ok_or_else(|| anyhow::anyhow!("template path has no parent"))?
.join("template-build");
fs::create_dir_all(build_root.join("_rels"))?;
fs::create_dir_all(build_root.join("docProps"))?;
fs::create_dir_all(build_root.join("xl/_rels"))?;
fs::create_dir_all(build_root.join("xl/worksheets"))?;
write_zip_file(&path, &[Content {
path: "[Content_Types].xml",
body: content_types_xml().to_string(),
},
Content {
path: "_rels/.rels",
body: root_rels_xml().to_string(),
},
Content {
path: "docProps/app.xml",
body: app_xml().to_string(),
},
Content {
path: "docProps/core.xml",
body: core_xml().to_string(),
},
Content {
path: "xl/workbook.xml",
body: workbook_xml().to_string(),
},
Content {
path: "xl/_rels/workbook.xml.rels",
body: workbook_rels_xml().to_string(),
},
Content {
path: "xl/worksheets/sheet1.xml",
body: worksheet_xml(row_count),
}])?;
Ok(())
}
fs::write(build_root.join("[Content_Types].xml"), content_types_xml())?;
fs::write(build_root.join("_rels/.rels"), root_rels_xml())?;
fs::write(build_root.join("docProps/app.xml"), app_xml())?;
fs::write(build_root.join("docProps/core.xml"), core_xml())?;
fs::write(build_root.join("xl/workbook.xml"), workbook_xml())?;
fs::write(
build_root.join("xl/_rels/workbook.xml.rels"),
workbook_rels_xml(),
)?;
fs::write(
build_root.join("xl/worksheets/sheet1.xml"),
worksheet_xml(row_count),
)?;
struct Content<'a> {
path: &'a str,
body: String,
}
fn write_zip_file(path: &Path, entries: &[Content<'_>]) -> anyhow::Result<()> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
if path.exists() {
fs::remove_file(path)?;
}
let zip = Command::new("zip")
.current_dir(&build_root)
.args(["-q", "-r", path.to_string_lossy().as_ref(), "."])
.output()?;
if !zip.status.success() {
let stderr = String::from_utf8_lossy(&zip.stderr);
return Err(anyhow::anyhow!(format!(
"failed to create xlsx template: {}",
stderr.trim()
)));
let file = fs::File::create(path)?;
let mut zip = ZipWriter::new(file);
let options = SimpleFileOptions::default().compression_method(CompressionMethod::Stored);
for entry in entries {
zip.start_file(entry.path, options)?;
zip.write_all(entry.body.as_bytes())?;
}
let _ = fs::remove_dir_all(&build_root);
zip.finish()?;
Ok(())
}

View File

@@ -1,5 +1,7 @@
use std::path::Path;
use std::sync::Arc;
use crate::browser::BrowserBackend;
use crate::compat::runtime::CompatTaskContext;
use crate::config::SgClawSettings;
use crate::pipe::{BrowserPipeTool, PipeError, Transport};
@@ -26,6 +28,68 @@ pub fn should_use_primary_orchestration(
crate::runtime::is_zhihu_hotlist_task(instruction, page_url, page_title) && needs_export
}
pub fn execute_task_with_browser_backend(
transport: &dyn crate::agent::AgentEventSink,
browser_backend: Arc<dyn BrowserBackend>,
instruction: &str,
task_context: &CompatTaskContext,
workspace_root: &Path,
settings: &SgClawSettings,
) -> Result<String, PipeError> {
let route = crate::compat::workflow_executor::detect_route(
instruction,
task_context.page_url.as_deref(),
task_context.page_title.as_deref(),
);
if let Some(route) = route.clone() {
if crate::compat::workflow_executor::prefers_direct_execution(&route) {
return crate::compat::workflow_executor::execute_route_with_browser_backend(
transport,
browser_backend.clone(),
workspace_root,
instruction,
task_context,
route,
);
}
}
let primary_result = crate::compat::runtime::execute_task_with_browser_backend(
transport,
browser_backend.clone(),
instruction,
task_context,
workspace_root,
settings,
);
match (route, primary_result) {
(Some(route), Ok(summary))
if crate::compat::workflow_executor::should_fallback_after_summary(
&summary, &route,
) =>
{
crate::compat::workflow_executor::execute_route_with_browser_backend(
transport,
browser_backend,
workspace_root,
instruction,
task_context,
route,
)
}
(_, Ok(summary)) => Ok(summary),
(Some(route), Err(_)) => crate::compat::workflow_executor::execute_route_with_browser_backend(
transport,
browser_backend,
workspace_root,
instruction,
task_context,
route,
),
(None, Err(err)) => Err(err),
}
}
pub fn execute_task_with_sgclaw_settings<T: Transport + 'static>(
transport: &T,
browser_tool: BrowserPipeTool<T>,

View File

@@ -1,5 +1,6 @@
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use async_trait::async_trait;
use futures_util::{stream, StreamExt};
@@ -8,6 +9,7 @@ use zeroclaw::config::Config as ZeroClawConfig;
use zeroclaw::providers::traits::{ProviderCapabilities, StreamEvent, StreamOptions, StreamResult};
use zeroclaw::providers::{self, ChatMessage, ChatRequest, ChatResponse, Provider};
use crate::browser::{BrowserBackend, PipeBrowserBackend};
use crate::compat::browser_script_skill_tool::build_browser_script_skill_tools;
use crate::compat::browser_tool_adapter::ZeroClawBrowserTool;
use crate::compat::config_adapter::{
@@ -47,6 +49,32 @@ pub fn execute_task<T: Transport + 'static>(
)
}
pub fn execute_task_with_browser_backend(
transport: &dyn crate::agent::AgentEventSink,
browser_backend: Arc<dyn BrowserBackend>,
instruction: &str,
task_context: &CompatTaskContext,
workspace_root: &Path,
settings: &SgClawSettings,
) -> Result<String, PipeError> {
let config = build_zeroclaw_config_from_sgclaw_settings(workspace_root, settings);
let skills_dir = resolve_skills_dir_from_sgclaw_settings(workspace_root, settings);
let provider = build_provider(&config)?;
let runtime = tokio::runtime::Runtime::new()
.map_err(|err| PipeError::Protocol(format!("failed to create tokio runtime: {err}")))?;
runtime.block_on(execute_task_with_provider(
transport,
browser_backend,
provider,
instruction,
task_context,
config,
skills_dir,
settings.clone(),
))
}
pub fn execute_task_with_sgclaw_settings<T: Transport + 'static>(
transport: &T,
browser_tool: BrowserPipeTool<T>,
@@ -63,7 +91,7 @@ pub fn execute_task_with_sgclaw_settings<T: Transport + 'static>(
runtime.block_on(execute_task_with_provider(
transport,
browser_tool,
Arc::new(PipeBrowserBackend::from_inner(browser_tool)),
provider,
instruction,
task_context,
@@ -73,9 +101,9 @@ pub fn execute_task_with_sgclaw_settings<T: Transport + 'static>(
))
}
pub async fn execute_task_with_provider<T: Transport + 'static>(
transport: &T,
browser_tool: BrowserPipeTool<T>,
pub async fn execute_task_with_provider(
transport: &dyn crate::agent::AgentEventSink,
browser_backend: Arc<dyn BrowserBackend>,
provider: Box<dyn Provider>,
instruction: &str,
task_context: &CompatTaskContext,
@@ -116,11 +144,13 @@ pub async fn execute_task_with_provider<T: Transport + 'static>(
message: format!("loaded skills: {}", loaded_skill_labels.join(", ")),
})?;
}
let browser_tool_for_scripts = browser_tool.clone();
let browser_tool_for_scripts = browser_backend.clone();
let browser_tool_for_superrpa = browser_backend.clone();
let browser_tool_for_browser_action = browser_backend;
let mut tools: Vec<Box<dyn zeroclaw::tools::Tool>> = if browser_surface_present {
vec![
Box::new(ZeroClawBrowserTool::new_superrpa(browser_tool.clone())),
Box::new(ZeroClawBrowserTool::new(browser_tool)),
Box::new(ZeroClawBrowserTool::new_superrpa(browser_tool_for_superrpa)),
Box::new(ZeroClawBrowserTool::new(browser_tool_for_browser_action)),
]
} else {
Vec::new()

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);