feat: align browser callback runtime and export flows

Consolidate the browser task runtime around the callback path, add safer artifact opening for Zhihu exports, and cover the new service/browser flows with focused tests and supporting docs.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
木炎
2026-04-06 21:44:53 +08:00
parent 0dd655712c
commit bdf8e12246
55 changed files with 14440 additions and 1053 deletions

View File

@@ -9,6 +9,7 @@ use serde_json::{json, Value};
use zeroclaw::tools::Tool;
use crate::browser::{BrowserBackend, PipeBrowserBackend};
use crate::compat::artifact_open::{open_exported_xlsx, open_local_dashboard, PostExportOpen};
use crate::compat::openxml_office_tool::OpenXmlOfficeTool;
use crate::compat::runtime::CompatTaskContext;
use crate::compat::screen_html_export_tool::ScreenHtmlExportTool;
@@ -147,7 +148,9 @@ pub fn execute_route_with_browser_backend(
}
match route {
WorkflowRoute::ZhihuHotlistExportXlsx => export_xlsx(transport, workspace_root, &items),
WorkflowRoute::ZhihuHotlistScreen => export_screen(transport, workspace_root, &items),
WorkflowRoute::ZhihuHotlistScreen => {
export_screen(transport, browser_backend.as_ref(), workspace_root, &items)
}
_ => unreachable!("handled by outer match"),
}
}
@@ -297,21 +300,10 @@ fn probe_hotlist_extractor(
ZHIHU_DOMAIN,
)?;
if !response.success {
eprintln!("probe_hotlist_extractor: eval not successful data={}", response.data);
return Ok(None);
}
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)),
@@ -366,11 +358,6 @@ fn poll_for_hotlist_readiness(browser_tool: &dyn BrowserBackend) -> Result<bool,
};
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);
}
@@ -424,11 +411,17 @@ fn export_xlsx(
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}"))
Ok(match open_exported_xlsx(Path::new(output_path)) {
PostExportOpen::Opened => format!("已导出并打开知乎热榜 Excel {output_path}"),
PostExportOpen::Failed(reason) => {
format!("已导出知乎热榜 Excel {output_path},但自动打开失败:{reason}")
}
})
}
fn export_screen(
transport: &dyn crate::agent::AgentEventSink,
browser_backend: &dyn BrowserBackend,
workspace_root: &Path,
items: &[HotlistItem],
) -> Result<String, PipeError> {
@@ -454,12 +447,25 @@ fn export_screen(
));
}
let payload: Value = serde_json::from_str(&result.output)
finalize_screen_export(browser_backend, &result.output)
}
pub fn finalize_screen_export(
browser_backend: &dyn BrowserBackend,
output: &str,
) -> Result<String, PipeError> {
let payload: Value = serde_json::from_str(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())
})?;
Ok(format!("已生成知乎热榜大屏 {output_path}"))
let presentation_url = payload["presentation"]["url"].as_str().unwrap_or_default();
Ok(match open_local_dashboard(browser_backend, Path::new(output_path), presentation_url) {
PostExportOpen::Opened => format!("已在浏览器中打开知乎热榜大屏 {output_path}"),
PostExportOpen::Failed(reason) => {
format!("已生成知乎热榜大屏 {output_path},但浏览器自动打开失败:{reason}")
}
})
}
fn execute_zhihu_article_route(
@@ -823,7 +829,6 @@ fn execute_zhihu_fill_via_live_input(
]);
// ── Step 1: Click title field ──────────────────────────────
eprintln!("live_input: step 1 — click title field");
browser_tool.invoke(
Action::Click,
json!({
@@ -871,7 +876,6 @@ return JSON.stringify({{status:'ok',chunks:chunks.length}});
title_chunk = title_chunk,
title_delay = title_delay,
);
eprintln!("live_input: step 2 — animated title typing ({title_chars} chars, ~{title_wait}ms)");
browser_tool.invoke(
Action::Eval,
json!({ "script": title_script }),
@@ -880,7 +884,6 @@ return JSON.stringify({{status:'ok',chunks:chunks.length}});
std::thread::sleep(std::time::Duration::from_millis(title_wait));
// ── Step 3: Click body field ────────────────────────────────
eprintln!("live_input: step 3 — click body field");
browser_tool.invoke(
Action::Click,
json!({
@@ -930,7 +933,6 @@ return JSON.stringify({{status:'ok',chunks:chunks.length}});
body_chunk = body_chunk,
body_delay = body_delay,
);
eprintln!("live_input: step 4 — animated body typing ({body_chars} chars, ~{body_wait}ms)");
browser_tool.invoke(
Action::Eval,
json!({ "script": body_script }),
@@ -941,7 +943,6 @@ return JSON.stringify({{status:'ok',chunks:chunks.length}});
// Step 5: Fill content only. The publish-button click is split into a
// separate eval (step 6) because React needs a full render cycle to
// enable the button after the content fill updates the editor state.
eprintln!("live_input: step 5 — eval fill_article_draft.js (fill only, publish_mode=false)");
let fill_result = execute_browser_skill_script(
browser_tool,
"zhihu-write",
@@ -960,7 +961,6 @@ return JSON.stringify({{status:'ok',chunks:chunks.length}});
}
// Step 6: After React has rendered the enabled publish button, click it.
eprintln!("live_input: step 6 — waiting 1.5s for React render, then clicking publish");
std::thread::sleep(std::time::Duration::from_millis(1500));
let publish_script = r#"(function(){