sgclaw: stop zhihu publish flow before editor on creator page
This commit is contained in:
@@ -5,16 +5,30 @@ use regex::Regex;
|
|||||||
use serde_json::{json, Value};
|
use serde_json::{json, Value};
|
||||||
use zeroclaw::tools::Tool;
|
use zeroclaw::tools::Tool;
|
||||||
|
|
||||||
|
use crate::compat::runtime::CompatTaskContext;
|
||||||
use crate::compat::openxml_office_tool::OpenXmlOfficeTool;
|
use crate::compat::openxml_office_tool::OpenXmlOfficeTool;
|
||||||
use crate::compat::screen_html_export_tool::ScreenHtmlExportTool;
|
use crate::compat::screen_html_export_tool::ScreenHtmlExportTool;
|
||||||
use crate::pipe::{Action, AgentMessage, BrowserPipeTool, PipeError, Transport};
|
use crate::pipe::{
|
||||||
|
Action,
|
||||||
|
AgentMessage,
|
||||||
|
BrowserPipeTool,
|
||||||
|
ConversationMessage,
|
||||||
|
PipeError,
|
||||||
|
Transport,
|
||||||
|
};
|
||||||
|
|
||||||
const ZHIHU_DOMAIN: &str = "www.zhihu.com";
|
const ZHIHU_DOMAIN: &str = "www.zhihu.com";
|
||||||
|
const ZHIHU_EDITOR_DOMAIN: &str = "zhuanlan.zhihu.com";
|
||||||
const ZHIHU_HOT_URL: &str = "https://www.zhihu.com/hot";
|
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";
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub enum WorkflowRoute {
|
pub enum WorkflowRoute {
|
||||||
ZhihuHotlistExportXlsx,
|
ZhihuHotlistExportXlsx,
|
||||||
ZhihuHotlistScreen,
|
ZhihuHotlistScreen,
|
||||||
|
ZhihuArticleEntry,
|
||||||
|
ZhihuArticleDraft,
|
||||||
|
ZhihuArticlePublish,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
@@ -24,25 +38,47 @@ struct HotlistItem {
|
|||||||
heat: String,
|
heat: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
struct ArticleDraft {
|
||||||
|
title: String,
|
||||||
|
body: String,
|
||||||
|
}
|
||||||
|
|
||||||
pub fn detect_route(
|
pub fn detect_route(
|
||||||
instruction: &str,
|
instruction: &str,
|
||||||
page_url: Option<&str>,
|
page_url: Option<&str>,
|
||||||
page_title: Option<&str>,
|
page_title: Option<&str>,
|
||||||
) -> Option<WorkflowRoute> {
|
) -> Option<WorkflowRoute> {
|
||||||
if !crate::runtime::is_zhihu_hotlist_task(instruction, page_url, page_title) {
|
if crate::runtime::is_zhihu_hotlist_task(instruction, page_url, page_title) {
|
||||||
return None;
|
let normalized = instruction.to_ascii_lowercase();
|
||||||
|
if normalized.contains("dashboard") || instruction.contains("大屏") || instruction.contains("新标签页") {
|
||||||
|
return Some(WorkflowRoute::ZhihuHotlistScreen);
|
||||||
|
}
|
||||||
|
if normalized.contains("excel") || normalized.contains("xlsx") || instruction.contains("导出") {
|
||||||
|
return Some(WorkflowRoute::ZhihuHotlistExportXlsx);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
if task_requests_zhihu_article_entry(instruction, page_url, page_title) {
|
||||||
let normalized = instruction.to_ascii_lowercase();
|
return Some(WorkflowRoute::ZhihuArticleEntry);
|
||||||
if normalized.contains("dashboard") || instruction.contains("大屏") || instruction.contains("新标签页") {
|
|
||||||
return Some(WorkflowRoute::ZhihuHotlistScreen);
|
|
||||||
}
|
}
|
||||||
if normalized.contains("excel") || normalized.contains("xlsx") || instruction.contains("导出") {
|
if crate::runtime::task_requests_zhihu_article_publish(instruction, page_url, page_title) {
|
||||||
return Some(WorkflowRoute::ZhihuHotlistExportXlsx);
|
return Some(WorkflowRoute::ZhihuArticlePublish);
|
||||||
|
}
|
||||||
|
if crate::runtime::is_zhihu_write_task(instruction, page_url, page_title) {
|
||||||
|
return Some(WorkflowRoute::ZhihuArticleDraft);
|
||||||
}
|
}
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn prefers_direct_execution(route: &WorkflowRoute) -> bool {
|
||||||
|
matches!(
|
||||||
|
route,
|
||||||
|
WorkflowRoute::ZhihuArticleEntry |
|
||||||
|
WorkflowRoute::ZhihuArticleDraft |
|
||||||
|
WorkflowRoute::ZhihuArticlePublish
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
pub fn should_fallback_after_summary(summary: &str, route: &WorkflowRoute) -> bool {
|
pub fn should_fallback_after_summary(summary: &str, route: &WorkflowRoute) -> bool {
|
||||||
let normalized = summary.to_ascii_lowercase();
|
let normalized = summary.to_ascii_lowercase();
|
||||||
if normalized.contains(".xlsx") || normalized.contains(".html") {
|
if normalized.contains(".xlsx") || normalized.contains(".html") {
|
||||||
@@ -52,10 +88,19 @@ pub fn should_fallback_after_summary(summary: &str, route: &WorkflowRoute) -> bo
|
|||||||
let looks_like_denial = summary.contains("拒绝") ||
|
let looks_like_denial = summary.contains("拒绝") ||
|
||||||
normalized.contains("denied") ||
|
normalized.contains("denied") ||
|
||||||
normalized.contains("failed") ||
|
normalized.contains("failed") ||
|
||||||
|
normalized.contains("protocol error") ||
|
||||||
|
normalized.contains("maximum tool iterations") ||
|
||||||
summary.contains("失败") ||
|
summary.contains("失败") ||
|
||||||
summary.contains("无法");
|
summary.contains("无法");
|
||||||
|
|
||||||
looks_like_denial || matches!(route, WorkflowRoute::ZhihuHotlistExportXlsx | WorkflowRoute::ZhihuHotlistScreen)
|
looks_like_denial || matches!(
|
||||||
|
route,
|
||||||
|
WorkflowRoute::ZhihuHotlistExportXlsx |
|
||||||
|
WorkflowRoute::ZhihuHotlistScreen |
|
||||||
|
WorkflowRoute::ZhihuArticleEntry |
|
||||||
|
WorkflowRoute::ZhihuArticleDraft |
|
||||||
|
WorkflowRoute::ZhihuArticlePublish
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn execute_route<T: Transport + 'static>(
|
pub fn execute_route<T: Transport + 'static>(
|
||||||
@@ -63,19 +108,33 @@ pub fn execute_route<T: Transport + 'static>(
|
|||||||
browser_tool: &BrowserPipeTool<T>,
|
browser_tool: &BrowserPipeTool<T>,
|
||||||
workspace_root: &Path,
|
workspace_root: &Path,
|
||||||
instruction: &str,
|
instruction: &str,
|
||||||
|
task_context: &CompatTaskContext,
|
||||||
route: WorkflowRoute,
|
route: WorkflowRoute,
|
||||||
) -> Result<String, PipeError> {
|
) -> Result<String, PipeError> {
|
||||||
let top_n = extract_top_n(instruction);
|
|
||||||
let items = collect_hotlist_items(transport, browser_tool, top_n)?;
|
|
||||||
if items.is_empty() {
|
|
||||||
return Err(PipeError::Protocol(
|
|
||||||
"知乎热榜采集失败:未能从页面文本中解析到热榜条目".to_string(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
match route {
|
match route {
|
||||||
WorkflowRoute::ZhihuHotlistExportXlsx => export_xlsx(transport, workspace_root, &items),
|
WorkflowRoute::ZhihuHotlistExportXlsx | WorkflowRoute::ZhihuHotlistScreen => {
|
||||||
WorkflowRoute::ZhihuHotlistScreen => export_screen(transport, workspace_root, &items),
|
let top_n = extract_top_n(instruction);
|
||||||
|
let items = collect_hotlist_items(transport, browser_tool, top_n)?;
|
||||||
|
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),
|
||||||
|
_ => 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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -210,26 +269,153 @@ fn export_screen<T: Transport>(
|
|||||||
Ok(format!("已生成知乎热榜大屏 {output_path}"))
|
Ok(format!("已生成知乎热榜大屏 {output_path}"))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn load_hotlist_extractor_script(top_n: usize) -> Result<String, PipeError> {
|
fn execute_zhihu_article_route<T: Transport + 'static>(
|
||||||
let script_path = Path::new(env!("CARGO_MANIFEST_DIR"))
|
transport: &T,
|
||||||
.parent()
|
browser_tool: &BrowserPipeTool<T>,
|
||||||
.unwrap_or_else(|| Path::new(env!("CARGO_MANIFEST_DIR")))
|
instruction: &str,
|
||||||
.join("skill_lib")
|
task_context: &CompatTaskContext,
|
||||||
.join("skills")
|
publish_mode: bool,
|
||||||
.join("zhihu-hotlist")
|
) -> Result<String, PipeError> {
|
||||||
.join("scripts")
|
let Some(article) = extract_article_draft(instruction, &task_context.messages) else {
|
||||||
.join("extract_hotlist.js");
|
return Ok(
|
||||||
let script = fs::read_to_string(&script_path).map_err(|err| {
|
"这类知乎文章任务需要同时提供标题和正文后我才能继续确定性写作流程。请按“标题:…\\n正文:…”的格式补充内容。"
|
||||||
PipeError::Protocol(format!(
|
.to_string(),
|
||||||
"failed to read zhihu hotlist extractor script {}: {err}",
|
);
|
||||||
script_path.display()
|
};
|
||||||
))
|
|
||||||
|
if publish_mode && !has_explicit_publish_confirmation(instruction) {
|
||||||
|
return Ok(build_publish_confirmation_message(&article));
|
||||||
|
}
|
||||||
|
|
||||||
|
navigate_zhihu_page(transport, browser_tool, ZHIHU_CREATOR_URL)?;
|
||||||
|
transport.send(&AgentMessage::LogEntry {
|
||||||
|
level: "info".to_string(),
|
||||||
|
message: "call zhihu-navigate.open_creator_entry".to_string(),
|
||||||
})?;
|
})?;
|
||||||
Ok(format!(
|
let creator_state = execute_browser_skill_script(
|
||||||
"(function() {{\nconst args = {};\n{}\n}})()",
|
browser_tool,
|
||||||
|
"zhihu-navigate",
|
||||||
|
"open_creator_entry.js",
|
||||||
|
json!({ "desired_target": "article_editor" }),
|
||||||
|
ZHIHU_DOMAIN,
|
||||||
|
)?;
|
||||||
|
if is_login_required_payload(&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(
|
||||||
|
&creator_state,
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
navigate_to_editor_after_creator_entry(transport, browser_tool, &creator_state)?;
|
||||||
|
|
||||||
|
transport.send(&AgentMessage::LogEntry {
|
||||||
|
level: "info".to_string(),
|
||||||
|
message: "call zhihu-write.prepare_article_editor".to_string(),
|
||||||
|
})?;
|
||||||
|
let editor_state = execute_browser_skill_script(
|
||||||
|
browser_tool,
|
||||||
|
"zhihu-write",
|
||||||
|
"prepare_article_editor.js",
|
||||||
|
json!({ "desired_mode": if publish_mode { "publish" } else { "draft" } }),
|
||||||
|
ZHIHU_EDITOR_DOMAIN,
|
||||||
|
)?;
|
||||||
|
if is_login_required_payload(&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)));
|
||||||
|
}
|
||||||
|
|
||||||
|
transport.send(&AgentMessage::LogEntry {
|
||||||
|
level: "info".to_string(),
|
||||||
|
message: "call zhihu-write.fill_article_draft".to_string(),
|
||||||
|
})?;
|
||||||
|
let fill_result = execute_browser_skill_script(
|
||||||
|
browser_tool,
|
||||||
|
"zhihu-write",
|
||||||
|
"fill_article_draft.js",
|
||||||
|
json!({
|
||||||
|
"title": article.title,
|
||||||
|
"body": article.body,
|
||||||
|
"publish_mode": publish_mode.to_string(),
|
||||||
|
}),
|
||||||
|
ZHIHU_EDITOR_DOMAIN,
|
||||||
|
)?;
|
||||||
|
if is_login_required_payload(&fill_result) {
|
||||||
|
return Ok(build_login_block_message(payload_current_url(&fill_result)));
|
||||||
|
}
|
||||||
|
|
||||||
|
match payload_status(&fill_result) {
|
||||||
|
Some("draft_ready") => Ok(format!("已进入知乎文章编辑器并写入草稿《{}》", article.title)),
|
||||||
|
Some("publish_clicked") | Some("publish_submitted") => {
|
||||||
|
Ok(format!("已提交知乎文章发布流程《{}》", article.title))
|
||||||
|
}
|
||||||
|
Some("publish_button_missing") => Err(PipeError::Protocol(
|
||||||
|
"知乎文章流程失败:未找到发布按钮".to_string(),
|
||||||
|
)),
|
||||||
|
Some("editor_not_ready") => Err(PipeError::Protocol(
|
||||||
|
"知乎文章流程失败:编辑器尚未准备就绪".to_string(),
|
||||||
|
)),
|
||||||
|
_ => Err(PipeError::Protocol(format!(
|
||||||
|
"知乎文章流程失败:浏览器脚本返回了未知状态 {fill_result}"
|
||||||
|
))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn execute_zhihu_article_entry_route<T: Transport + 'static>(
|
||||||
|
transport: &T,
|
||||||
|
browser_tool: &BrowserPipeTool<T>,
|
||||||
|
) -> Result<String, PipeError> {
|
||||||
|
navigate_zhihu_page(transport, browser_tool, ZHIHU_CREATOR_URL)?;
|
||||||
|
transport.send(&AgentMessage::LogEntry {
|
||||||
|
level: "info".to_string(),
|
||||||
|
message: "call zhihu-navigate.open_creator_entry".to_string(),
|
||||||
|
})?;
|
||||||
|
let creator_state = execute_browser_skill_script(
|
||||||
|
browser_tool,
|
||||||
|
"zhihu-navigate",
|
||||||
|
"open_creator_entry.js",
|
||||||
|
json!({ "desired_target": "article_editor" }),
|
||||||
|
ZHIHU_DOMAIN,
|
||||||
|
)?;
|
||||||
|
if is_login_required_payload(&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(
|
||||||
|
&creator_state,
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
navigate_to_editor_after_creator_entry(transport, browser_tool, &creator_state)?;
|
||||||
|
|
||||||
|
transport.send(&AgentMessage::LogEntry {
|
||||||
|
level: "info".to_string(),
|
||||||
|
message: "call zhihu-write.prepare_article_editor".to_string(),
|
||||||
|
})?;
|
||||||
|
let editor_state = execute_browser_skill_script(
|
||||||
|
browser_tool,
|
||||||
|
"zhihu-write",
|
||||||
|
"prepare_article_editor.js",
|
||||||
|
json!({ "desired_mode": "draft" }),
|
||||||
|
ZHIHU_EDITOR_DOMAIN,
|
||||||
|
)?;
|
||||||
|
if is_login_required_payload(&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)))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn load_hotlist_extractor_script(top_n: usize) -> Result<String, PipeError> {
|
||||||
|
load_browser_skill_script(
|
||||||
|
"zhihu-hotlist",
|
||||||
|
"extract_hotlist.js",
|
||||||
json!({ "top_n": top_n.to_string() }),
|
json!({ "top_n": top_n.to_string() }),
|
||||||
script
|
)
|
||||||
))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn parse_hotlist_items_payload(payload: &Value) -> Result<Vec<HotlistItem>, PipeError> {
|
fn parse_hotlist_items_payload(payload: &Value) -> Result<Vec<HotlistItem>, PipeError> {
|
||||||
@@ -283,3 +469,262 @@ fn extract_top_n(instruction: &str) -> usize {
|
|||||||
.filter(|value| *value > 0)
|
.filter(|value| *value > 0)
|
||||||
.unwrap_or(10)
|
.unwrap_or(10)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn navigate_zhihu_page<T: Transport + 'static>(
|
||||||
|
transport: &T,
|
||||||
|
browser_tool: &BrowserPipeTool<T>,
|
||||||
|
url: &str,
|
||||||
|
) -> Result<(), PipeError> {
|
||||||
|
transport.send(&AgentMessage::LogEntry {
|
||||||
|
level: "info".to_string(),
|
||||||
|
message: format!("navigate {url}"),
|
||||||
|
})?;
|
||||||
|
let response = browser_tool.invoke(Action::Navigate, json!({ "url": url }), ZHIHU_DOMAIN)?;
|
||||||
|
if response.success {
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err(PipeError::Protocol(format!("navigate failed: {}", response.data)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn execute_browser_skill_script<T: Transport + 'static>(
|
||||||
|
browser_tool: &BrowserPipeTool<T>,
|
||||||
|
skill_name: &str,
|
||||||
|
script_name: &str,
|
||||||
|
args: Value,
|
||||||
|
expected_domain: &str,
|
||||||
|
) -> Result<Value, PipeError> {
|
||||||
|
let wrapped_script = load_browser_skill_script(skill_name, script_name, args)?;
|
||||||
|
let response = browser_tool.invoke(
|
||||||
|
Action::Eval,
|
||||||
|
json!({ "script": wrapped_script }),
|
||||||
|
expected_domain,
|
||||||
|
)?;
|
||||||
|
if !response.success {
|
||||||
|
return Err(PipeError::Protocol(format!(
|
||||||
|
"browser script failed: {}",
|
||||||
|
response.data
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(normalize_payload(response.data.get("text").unwrap_or(&response.data)))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn navigate_to_editor_after_creator_entry<T: Transport + 'static>(
|
||||||
|
transport: &T,
|
||||||
|
browser_tool: &BrowserPipeTool<T>,
|
||||||
|
creator_state: &Value,
|
||||||
|
) -> Result<(), PipeError> {
|
||||||
|
let status = payload_status(creator_state);
|
||||||
|
if status == Some("editor_ready") {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let target_url = payload_next_url(creator_state).unwrap_or(ZHIHU_EDITOR_URL);
|
||||||
|
if status == Some("creator_entry_clicked") || status == Some("creator_entry_found") {
|
||||||
|
transport.send(&AgentMessage::LogEntry {
|
||||||
|
level: "info".to_string(),
|
||||||
|
message: format!("navigate {target_url}"),
|
||||||
|
})?;
|
||||||
|
let response = browser_tool.invoke(
|
||||||
|
Action::Navigate,
|
||||||
|
json!({ "url": target_url }),
|
||||||
|
ZHIHU_EDITOR_DOMAIN,
|
||||||
|
)?;
|
||||||
|
if !response.success {
|
||||||
|
return Err(PipeError::Protocol(format!(
|
||||||
|
"navigate failed: {}",
|
||||||
|
response.data
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn load_browser_skill_script(
|
||||||
|
skill_name: &str,
|
||||||
|
script_name: &str,
|
||||||
|
args: Value,
|
||||||
|
) -> Result<String, PipeError> {
|
||||||
|
let script_path = Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||||
|
.parent()
|
||||||
|
.unwrap_or_else(|| Path::new(env!("CARGO_MANIFEST_DIR")))
|
||||||
|
.join("skill_lib")
|
||||||
|
.join("skills")
|
||||||
|
.join(skill_name)
|
||||||
|
.join("scripts")
|
||||||
|
.join(script_name);
|
||||||
|
let script = fs::read_to_string(&script_path).map_err(|err| {
|
||||||
|
PipeError::Protocol(format!(
|
||||||
|
"failed to read browser script {}: {err}",
|
||||||
|
script_path.display()
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
Ok(format!(
|
||||||
|
"(function() {{\nconst args = {};\n{}\n}})()",
|
||||||
|
args,
|
||||||
|
script
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn normalize_payload(payload: &Value) -> Value {
|
||||||
|
if let Some(text) = payload.as_str() {
|
||||||
|
serde_json::from_str::<Value>(text).unwrap_or_else(|_| Value::String(text.to_string()))
|
||||||
|
} else {
|
||||||
|
payload.clone()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn payload_status(payload: &Value) -> Option<&str> {
|
||||||
|
payload.get("status").and_then(Value::as_str)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn payload_current_url(payload: &Value) -> Option<&str> {
|
||||||
|
payload.get("current_url").and_then(Value::as_str)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn payload_next_url(payload: &Value) -> Option<&str> {
|
||||||
|
payload.get("next_url").and_then(Value::as_str)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_login_required_payload(payload: &Value) -> bool {
|
||||||
|
payload_status(payload) == Some("login_required")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_login_block_message(current_url: Option<&str>) -> String {
|
||||||
|
let suffix = current_url
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
.map(|value| format!(" 当前页面:{value}。"))
|
||||||
|
.unwrap_or_default();
|
||||||
|
format!(
|
||||||
|
"当前知乎浏览器会话未登录,无法进入创作者中心或发布文章。请先登录知乎后再继续。{suffix}"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_editor_unavailable_message(current_url: Option<&str>) -> String {
|
||||||
|
let suffix = current_url
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
.map(|value| format!(" 当前页面:{value}。"))
|
||||||
|
.unwrap_or_default();
|
||||||
|
format!(
|
||||||
|
"已进入知乎创作者流程,但当前未检测到文章编辑器。可能原因是页面仍在加载、当前账号暂未开放写作入口,或知乎页面结构发生变化。请确认当前知乎账号已登录且具备发文权限,然后在页面稳定后重试。{suffix}"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_creator_entry_missing_message(current_url: Option<&str>) -> String {
|
||||||
|
let suffix = current_url
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
.map(|value| format!(" 当前页面:{value}。"))
|
||||||
|
.unwrap_or_default();
|
||||||
|
format!(
|
||||||
|
"已进入知乎创作者中心,但当前未找到“写文章”入口。请确认页面已加载完成,且当前账号具备文章发布入口后再重试。{suffix}"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_publish_confirmation_message(article: &ArticleDraft) -> String {
|
||||||
|
format!(
|
||||||
|
"我已收到这篇知乎文章的内容,但在当前会话里还没有拿到明确发布确认。\n\n标题:{}\n正文:{}\n\n如果你确定现在要发布,请直接回复“确认发布”。在收到明确确认之前,我不会执行任何发布动作。",
|
||||||
|
article.title,
|
||||||
|
article.body
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn has_explicit_publish_confirmation(instruction: &str) -> bool {
|
||||||
|
let trimmed = instruction.trim();
|
||||||
|
trimmed.contains("确认发布") ||
|
||||||
|
trimmed.contains("确认发表") ||
|
||||||
|
trimmed.contains("现在发布") ||
|
||||||
|
trimmed.contains("立即发布") ||
|
||||||
|
trimmed.contains("可以发布")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn task_requests_zhihu_article_entry(
|
||||||
|
instruction: &str,
|
||||||
|
page_url: Option<&str>,
|
||||||
|
page_title: Option<&str>,
|
||||||
|
) -> bool {
|
||||||
|
if !crate::runtime::is_zhihu_write_task(instruction, page_url, page_title) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 has_article_inputs = parse_article_draft(instruction).is_some();
|
||||||
|
|
||||||
|
asks_to_open && mentions_entry && !has_article_inputs
|
||||||
|
}
|
||||||
|
|
||||||
|
fn extract_article_draft(
|
||||||
|
instruction: &str,
|
||||||
|
messages: &[ConversationMessage],
|
||||||
|
) -> Option<ArticleDraft> {
|
||||||
|
parse_article_draft(instruction).or_else(|| {
|
||||||
|
messages
|
||||||
|
.iter()
|
||||||
|
.rev()
|
||||||
|
.filter(|message| message.role == "user")
|
||||||
|
.find_map(|message| parse_article_draft(&message.content))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
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 title = title_re
|
||||||
|
.captures(&normalized)
|
||||||
|
.and_then(|capture| capture.get(1))
|
||||||
|
.map(|value| value.as_str().trim().to_string())
|
||||||
|
.or_else(|| {
|
||||||
|
inline_title_re
|
||||||
|
.captures(&normalized)
|
||||||
|
.and_then(|capture| capture.get(1))
|
||||||
|
.map(|value| value.as_str().trim().to_string())
|
||||||
|
})?;
|
||||||
|
let body = body_re
|
||||||
|
.captures(&normalized)
|
||||||
|
.and_then(|capture| capture.get(1))
|
||||||
|
.map(|value| value.as_str().trim().to_string())
|
||||||
|
.or_else(|| {
|
||||||
|
inline_body_re
|
||||||
|
.captures(&normalized)
|
||||||
|
.and_then(|capture| capture.get(1))
|
||||||
|
.map(|value| value.as_str().trim().trim_end_matches('。').to_string())
|
||||||
|
})?;
|
||||||
|
|
||||||
|
if title.is_empty() || body.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
Some(ArticleDraft { title, body })
|
||||||
|
}
|
||||||
|
|
||||||
|
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('\'')))
|
||||||
|
{
|
||||||
|
&trimmed[1..trimmed.len() - 1]
|
||||||
|
} else {
|
||||||
|
trimmed
|
||||||
|
};
|
||||||
|
unquoted.replace("\\n", "\n")
|
||||||
|
}
|
||||||
|
|||||||
@@ -1919,6 +1919,62 @@ fn browser_attached_excel_request_uses_execution_contract_not_skill_source_stuff
|
|||||||
assert!(!first_request.contains("Preloaded skill context:"));
|
assert!(!first_request.contains("Preloaded skill context:"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn browser_attached_publish_request_injects_confirmation_contract() {
|
||||||
|
let _guard = env_lock().lock().unwrap_or_else(|err| err.into_inner());
|
||||||
|
|
||||||
|
let response = json!({
|
||||||
|
"choices": [{
|
||||||
|
"message": {
|
||||||
|
"content": "请先确认是否发布"
|
||||||
|
}
|
||||||
|
}]
|
||||||
|
});
|
||||||
|
let (base_url, requests, server_handle) = start_fake_deepseek_server(vec![response]);
|
||||||
|
|
||||||
|
let workspace_root = temp_workspace_root();
|
||||||
|
let mut settings = SgClawSettings::from_legacy_deepseek_fields(
|
||||||
|
"deepseek-test-key".to_string(),
|
||||||
|
base_url,
|
||||||
|
"deepseek-chat".to_string(),
|
||||||
|
Some(real_skill_lib_root()),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
settings.runtime_profile = RuntimeProfile::BrowserAttached;
|
||||||
|
|
||||||
|
let transport = Arc::new(MockTransport::new(vec![]));
|
||||||
|
let browser_tool = BrowserPipeTool::new(
|
||||||
|
transport.clone(),
|
||||||
|
zhihu_test_policy(),
|
||||||
|
vec![1, 2, 3, 4, 5, 6, 7, 8],
|
||||||
|
)
|
||||||
|
.with_response_timeout(Duration::from_secs(1));
|
||||||
|
|
||||||
|
execute_task_with_sgclaw_settings(
|
||||||
|
transport.as_ref(),
|
||||||
|
browser_tool,
|
||||||
|
"请直接发表这篇知乎文章,标题是测试标题,正文是第一段内容",
|
||||||
|
&CompatTaskContext {
|
||||||
|
conversation_id: None,
|
||||||
|
messages: vec![],
|
||||||
|
page_url: Some("https://www.zhihu.com/creator".to_string()),
|
||||||
|
page_title: Some("知乎创作中心".to_string()),
|
||||||
|
},
|
||||||
|
&workspace_root,
|
||||||
|
&settings,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
server_handle.join().unwrap();
|
||||||
|
|
||||||
|
let request_bodies = requests.lock().unwrap().clone();
|
||||||
|
let first_request = request_bodies[0].to_string();
|
||||||
|
|
||||||
|
assert!(first_request.contains("Zhihu article publish contract"));
|
||||||
|
assert!(first_request.contains("must not click publish without explicit human confirmation"));
|
||||||
|
assert!(first_request.contains("ask for confirmation concisely"));
|
||||||
|
assert!(first_request.contains("stop after the confirmation request"));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn handle_browser_message_executes_real_zhihu_hotlist_skill_flow() {
|
fn handle_browser_message_executes_real_zhihu_hotlist_skill_flow() {
|
||||||
let _guard = env_lock().lock().unwrap_or_else(|err| err.into_inner());
|
let _guard = env_lock().lock().unwrap_or_else(|err| err.into_inner());
|
||||||
@@ -2491,6 +2547,7 @@ fn browser_submit_path_prefers_zeroclaw_process_message_orchestrator() {
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
let sent = transport.sent_messages();
|
let sent = transport.sent_messages();
|
||||||
|
dbg!(&sent);
|
||||||
|
|
||||||
assert!(sent.iter().any(|message| {
|
assert!(sent.iter().any(|message| {
|
||||||
matches!(
|
matches!(
|
||||||
@@ -2508,6 +2565,592 @@ fn browser_submit_path_prefers_zeroclaw_process_message_orchestrator() {
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn browser_submit_path_prefers_zeroclaw_process_message_orchestrator_for_zhihu_publish() {
|
||||||
|
let _guard = env_lock().lock().unwrap_or_else(|err| err.into_inner());
|
||||||
|
|
||||||
|
let workspace_root = temp_workspace_root();
|
||||||
|
let config_path = write_deepseek_config_with_skills_dir(
|
||||||
|
&workspace_root,
|
||||||
|
"deepseek-test-key",
|
||||||
|
"http://127.0.0.1:9",
|
||||||
|
"deepseek-chat",
|
||||||
|
Some(real_skill_lib_root().to_str().unwrap()),
|
||||||
|
);
|
||||||
|
let runtime_context = AgentRuntimeContext::new(Some(config_path), workspace_root.clone());
|
||||||
|
|
||||||
|
let transport = Arc::new(MockTransport::new(vec![]));
|
||||||
|
let browser_tool = BrowserPipeTool::new(
|
||||||
|
transport.clone(),
|
||||||
|
zhihu_test_policy(),
|
||||||
|
vec![1, 2, 3, 4, 5, 6, 7, 8],
|
||||||
|
)
|
||||||
|
.with_response_timeout(Duration::from_secs(1));
|
||||||
|
|
||||||
|
handle_browser_message_with_context(
|
||||||
|
transport.as_ref(),
|
||||||
|
&browser_tool,
|
||||||
|
&runtime_context,
|
||||||
|
BrowserMessage::SubmitTask {
|
||||||
|
instruction: "请直接发表这篇知乎文章,标题是测试标题,正文是第一段内容".to_string(),
|
||||||
|
conversation_id: String::new(),
|
||||||
|
messages: vec![],
|
||||||
|
page_url: "https://www.zhihu.com/".to_string(),
|
||||||
|
page_title: "知乎".to_string(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let sent = transport.sent_messages();
|
||||||
|
|
||||||
|
assert!(sent.iter().any(|message| {
|
||||||
|
matches!(
|
||||||
|
message,
|
||||||
|
AgentMessage::LogEntry { level, message }
|
||||||
|
if level == "mode" && message == "zeroclaw_process_message_primary"
|
||||||
|
)
|
||||||
|
}));
|
||||||
|
assert!(!sent.iter().any(|message| {
|
||||||
|
matches!(
|
||||||
|
message,
|
||||||
|
AgentMessage::LogEntry { level, message }
|
||||||
|
if level == "mode" && (message == "compat_llm_primary" || message == "compat_skill_runner_primary")
|
||||||
|
)
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn zhihu_publish_task_matches_primary_orchestration_gate() {
|
||||||
|
assert!(sgclaw::compat::orchestration::should_use_primary_orchestration(
|
||||||
|
"请直接发表这篇知乎文章,标题是测试标题,正文是第一段内容",
|
||||||
|
Some("https://www.zhihu.com/"),
|
||||||
|
Some("知乎"),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn zhihu_article_entry_task_matches_primary_orchestration_gate() {
|
||||||
|
assert!(sgclaw::compat::orchestration::should_use_primary_orchestration(
|
||||||
|
"打开知乎发文章页面",
|
||||||
|
Some("https://www.zhihu.com/"),
|
||||||
|
Some("知乎"),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn zhihu_publish_without_article_inputs_returns_missing_fields_prompt() {
|
||||||
|
let _guard = env_lock().lock().unwrap_or_else(|err| err.into_inner());
|
||||||
|
|
||||||
|
let workspace_root = temp_workspace_root();
|
||||||
|
let config_path = write_deepseek_config_with_skills_dir(
|
||||||
|
&workspace_root,
|
||||||
|
"deepseek-test-key",
|
||||||
|
"http://127.0.0.1:9",
|
||||||
|
"deepseek-chat",
|
||||||
|
Some(real_skill_lib_root().to_str().unwrap()),
|
||||||
|
);
|
||||||
|
let runtime_context = AgentRuntimeContext::new(Some(config_path), workspace_root.clone());
|
||||||
|
|
||||||
|
let transport = Arc::new(MockTransport::new(vec![]));
|
||||||
|
let browser_tool = BrowserPipeTool::new(
|
||||||
|
transport.clone(),
|
||||||
|
zhihu_test_policy(),
|
||||||
|
vec![1, 2, 3, 4, 5, 6, 7, 8],
|
||||||
|
)
|
||||||
|
.with_response_timeout(Duration::from_secs(1));
|
||||||
|
|
||||||
|
handle_browser_message_with_context(
|
||||||
|
transport.as_ref(),
|
||||||
|
&browser_tool,
|
||||||
|
&runtime_context,
|
||||||
|
BrowserMessage::SubmitTask {
|
||||||
|
instruction: "请发表知乎文章".to_string(),
|
||||||
|
conversation_id: String::new(),
|
||||||
|
messages: vec![],
|
||||||
|
page_url: "https://www.zhihu.com/".to_string(),
|
||||||
|
page_title: "知乎".to_string(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let sent = transport.sent_messages();
|
||||||
|
|
||||||
|
assert!(sent.iter().any(|message| {
|
||||||
|
matches!(
|
||||||
|
message,
|
||||||
|
AgentMessage::TaskComplete { success, summary }
|
||||||
|
if *success &&
|
||||||
|
summary.contains("标题") &&
|
||||||
|
summary.contains("正文")
|
||||||
|
)
|
||||||
|
}));
|
||||||
|
assert!(!sent.iter().any(|message| {
|
||||||
|
matches!(message, AgentMessage::Command { .. })
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn zhihu_publish_accepts_literal_backslash_n_between_title_and_body() {
|
||||||
|
let _guard = env_lock().lock().unwrap_or_else(|err| err.into_inner());
|
||||||
|
|
||||||
|
let workspace_root = temp_workspace_root();
|
||||||
|
let config_path = write_deepseek_config_with_skills_dir(
|
||||||
|
&workspace_root,
|
||||||
|
"deepseek-test-key",
|
||||||
|
"http://127.0.0.1:9",
|
||||||
|
"deepseek-chat",
|
||||||
|
Some(real_skill_lib_root().to_str().unwrap()),
|
||||||
|
);
|
||||||
|
let runtime_context = AgentRuntimeContext::new(Some(config_path), workspace_root.clone());
|
||||||
|
|
||||||
|
let transport = Arc::new(MockTransport::new(vec![
|
||||||
|
success_browser_response(1, json!({ "navigated": true })),
|
||||||
|
success_browser_response(
|
||||||
|
2,
|
||||||
|
json!({
|
||||||
|
"text": {
|
||||||
|
"status": "creator_entry_clicked",
|
||||||
|
"current_url": "https://www.zhihu.com/creator",
|
||||||
|
"next_url": "https://zhuanlan.zhihu.com/write"
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
success_browser_response(3, json!({ "navigated": true })),
|
||||||
|
success_browser_response(
|
||||||
|
4,
|
||||||
|
json!({
|
||||||
|
"text": {
|
||||||
|
"status": "editor_ready",
|
||||||
|
"current_url": "https://zhuanlan.zhihu.com/write"
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
success_browser_response(
|
||||||
|
5,
|
||||||
|
json!({
|
||||||
|
"text": {
|
||||||
|
"status": "draft_ready",
|
||||||
|
"current_url": "https://zhuanlan.zhihu.com/write",
|
||||||
|
"title": "ai时代,普通人如何自救"
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
]));
|
||||||
|
let browser_tool = BrowserPipeTool::new(
|
||||||
|
transport.clone(),
|
||||||
|
zhihu_test_policy(),
|
||||||
|
vec![1, 2, 3, 4, 5, 6, 7, 8],
|
||||||
|
)
|
||||||
|
.with_response_timeout(Duration::from_secs(1));
|
||||||
|
|
||||||
|
handle_browser_message_with_context(
|
||||||
|
transport.as_ref(),
|
||||||
|
&browser_tool,
|
||||||
|
&runtime_context,
|
||||||
|
BrowserMessage::SubmitTask {
|
||||||
|
instruction: "标题:ai时代,普通人如何自救 \\n正文:第一段内容。 第二段内容。".to_string(),
|
||||||
|
conversation_id: String::new(),
|
||||||
|
messages: vec![],
|
||||||
|
page_url: "https://www.zhihu.com/creator".to_string(),
|
||||||
|
page_title: "知乎创作中心".to_string(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let sent = transport.sent_messages();
|
||||||
|
|
||||||
|
assert!(sent.iter().any(|message| {
|
||||||
|
matches!(
|
||||||
|
message,
|
||||||
|
AgentMessage::TaskComplete { success, summary }
|
||||||
|
if *success && summary == "已进入知乎文章编辑器并写入草稿《ai时代,普通人如何自救》"
|
||||||
|
)
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn zhihu_article_entry_opens_editor_without_generic_selector_probing() {
|
||||||
|
let _guard = env_lock().lock().unwrap_or_else(|err| err.into_inner());
|
||||||
|
|
||||||
|
let workspace_root = temp_workspace_root();
|
||||||
|
let config_path = write_deepseek_config_with_skills_dir(
|
||||||
|
&workspace_root,
|
||||||
|
"deepseek-test-key",
|
||||||
|
"http://127.0.0.1:9",
|
||||||
|
"deepseek-chat",
|
||||||
|
Some(real_skill_lib_root().to_str().unwrap()),
|
||||||
|
);
|
||||||
|
let runtime_context = AgentRuntimeContext::new(Some(config_path), workspace_root.clone());
|
||||||
|
|
||||||
|
let transport = Arc::new(MockTransport::new(vec![
|
||||||
|
success_browser_response(1, json!({ "navigated": true })),
|
||||||
|
success_browser_response(
|
||||||
|
2,
|
||||||
|
json!({
|
||||||
|
"text": {
|
||||||
|
"status": "creator_entry_clicked",
|
||||||
|
"current_url": "https://www.zhihu.com/creator",
|
||||||
|
"next_url": "https://zhuanlan.zhihu.com/write"
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
success_browser_response(3, json!({ "navigated": true })),
|
||||||
|
success_browser_response(
|
||||||
|
4,
|
||||||
|
json!({
|
||||||
|
"text": {
|
||||||
|
"status": "editor_ready",
|
||||||
|
"current_url": "https://zhuanlan.zhihu.com/write"
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
]));
|
||||||
|
let browser_tool = BrowserPipeTool::new(
|
||||||
|
transport.clone(),
|
||||||
|
zhihu_test_policy(),
|
||||||
|
vec![1, 2, 3, 4, 5, 6, 7, 8],
|
||||||
|
)
|
||||||
|
.with_response_timeout(Duration::from_secs(1));
|
||||||
|
|
||||||
|
handle_browser_message_with_context(
|
||||||
|
transport.as_ref(),
|
||||||
|
&browser_tool,
|
||||||
|
&runtime_context,
|
||||||
|
BrowserMessage::SubmitTask {
|
||||||
|
instruction: "打开知乎发文章页面".to_string(),
|
||||||
|
conversation_id: String::new(),
|
||||||
|
messages: vec![],
|
||||||
|
page_url: "https://www.zhihu.com/".to_string(),
|
||||||
|
page_title: "知乎".to_string(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let sent = transport.sent_messages();
|
||||||
|
|
||||||
|
assert!(sent.iter().any(|message| {
|
||||||
|
matches!(
|
||||||
|
message,
|
||||||
|
AgentMessage::TaskComplete { success, summary }
|
||||||
|
if *success && summary.contains("编辑器")
|
||||||
|
)
|
||||||
|
}));
|
||||||
|
assert!(sent.iter().any(|message| {
|
||||||
|
matches!(
|
||||||
|
message,
|
||||||
|
AgentMessage::LogEntry { level, message }
|
||||||
|
if level == "mode" && message == "zeroclaw_process_message_primary"
|
||||||
|
)
|
||||||
|
}));
|
||||||
|
assert!(!sent.iter().any(|message| {
|
||||||
|
matches!(
|
||||||
|
message,
|
||||||
|
AgentMessage::LogEntry { level, message }
|
||||||
|
if level == "info" &&
|
||||||
|
(message.starts_with("getText ") || message.starts_with("click "))
|
||||||
|
)
|
||||||
|
}));
|
||||||
|
assert!(sent.iter().any(|message| {
|
||||||
|
matches!(
|
||||||
|
message,
|
||||||
|
AgentMessage::Command { action, params, .. }
|
||||||
|
if action == &Action::Navigate &&
|
||||||
|
params["url"].as_str() == Some("https://zhuanlan.zhihu.com/write")
|
||||||
|
)
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn zhihu_article_entry_reports_editor_unavailable_without_protocol_error() {
|
||||||
|
let _guard = env_lock().lock().unwrap_or_else(|err| err.into_inner());
|
||||||
|
|
||||||
|
let workspace_root = temp_workspace_root();
|
||||||
|
let config_path = write_deepseek_config_with_skills_dir(
|
||||||
|
&workspace_root,
|
||||||
|
"deepseek-test-key",
|
||||||
|
"http://127.0.0.1:9",
|
||||||
|
"deepseek-chat",
|
||||||
|
Some(real_skill_lib_root().to_str().unwrap()),
|
||||||
|
);
|
||||||
|
let runtime_context = AgentRuntimeContext::new(Some(config_path), workspace_root.clone());
|
||||||
|
|
||||||
|
let transport = Arc::new(MockTransport::new(vec![
|
||||||
|
success_browser_response(1, json!({ "navigated": true })),
|
||||||
|
success_browser_response(
|
||||||
|
2,
|
||||||
|
json!({
|
||||||
|
"text": {
|
||||||
|
"status": "creator_entry_clicked",
|
||||||
|
"current_url": "https://www.zhihu.com/creator",
|
||||||
|
"next_url": "https://zhuanlan.zhihu.com/write"
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
success_browser_response(3, json!({ "navigated": true })),
|
||||||
|
success_browser_response(
|
||||||
|
4,
|
||||||
|
json!({
|
||||||
|
"text": {
|
||||||
|
"status": "editor_unavailable",
|
||||||
|
"current_url": "https://zhuanlan.zhihu.com/write"
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
]));
|
||||||
|
let browser_tool = BrowserPipeTool::new(
|
||||||
|
transport.clone(),
|
||||||
|
zhihu_test_policy(),
|
||||||
|
vec![1, 2, 3, 4, 5, 6, 7, 8],
|
||||||
|
)
|
||||||
|
.with_response_timeout(Duration::from_secs(1));
|
||||||
|
|
||||||
|
handle_browser_message_with_context(
|
||||||
|
transport.as_ref(),
|
||||||
|
&browser_tool,
|
||||||
|
&runtime_context,
|
||||||
|
BrowserMessage::SubmitTask {
|
||||||
|
instruction: "打开知乎发文章页面".to_string(),
|
||||||
|
conversation_id: String::new(),
|
||||||
|
messages: vec![],
|
||||||
|
page_url: "https://www.zhihu.com/".to_string(),
|
||||||
|
page_title: "知乎".to_string(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let sent = transport.sent_messages();
|
||||||
|
|
||||||
|
assert!(sent.iter().any(|message| {
|
||||||
|
matches!(
|
||||||
|
message,
|
||||||
|
AgentMessage::TaskComplete { success, summary }
|
||||||
|
if *success &&
|
||||||
|
summary.contains("未检测到文章编辑器")
|
||||||
|
)
|
||||||
|
}));
|
||||||
|
assert!(sent.iter().any(|message| {
|
||||||
|
matches!(
|
||||||
|
message,
|
||||||
|
AgentMessage::Command { action, params, .. }
|
||||||
|
if action == &Action::Navigate &&
|
||||||
|
params["url"].as_str() == Some("https://zhuanlan.zhihu.com/write")
|
||||||
|
)
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn zhihu_article_entry_stops_when_creator_page_has_no_write_entry() {
|
||||||
|
let _guard = env_lock().lock().unwrap_or_else(|err| err.into_inner());
|
||||||
|
|
||||||
|
let workspace_root = temp_workspace_root();
|
||||||
|
let config_path = write_deepseek_config_with_skills_dir(
|
||||||
|
&workspace_root,
|
||||||
|
"deepseek-test-key",
|
||||||
|
"http://127.0.0.1:9",
|
||||||
|
"deepseek-chat",
|
||||||
|
Some(real_skill_lib_root().to_str().unwrap()),
|
||||||
|
);
|
||||||
|
let runtime_context = AgentRuntimeContext::new(Some(config_path), workspace_root.clone());
|
||||||
|
|
||||||
|
let transport = Arc::new(MockTransport::new(vec![
|
||||||
|
success_browser_response(1, json!({ "navigated": true })),
|
||||||
|
success_browser_response(
|
||||||
|
2,
|
||||||
|
json!({
|
||||||
|
"text": {
|
||||||
|
"status": "creator_home",
|
||||||
|
"current_url": "https://www.zhihu.com/creator",
|
||||||
|
"desired_target": "article_editor"
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
]));
|
||||||
|
let browser_tool = BrowserPipeTool::new(
|
||||||
|
transport.clone(),
|
||||||
|
zhihu_test_policy(),
|
||||||
|
vec![1, 2, 3, 4, 5, 6, 7, 8],
|
||||||
|
)
|
||||||
|
.with_response_timeout(Duration::from_secs(1));
|
||||||
|
|
||||||
|
handle_browser_message_with_context(
|
||||||
|
transport.as_ref(),
|
||||||
|
&browser_tool,
|
||||||
|
&runtime_context,
|
||||||
|
BrowserMessage::SubmitTask {
|
||||||
|
instruction: "打开知乎发文章页面".to_string(),
|
||||||
|
conversation_id: String::new(),
|
||||||
|
messages: vec![],
|
||||||
|
page_url: "https://www.zhihu.com/".to_string(),
|
||||||
|
page_title: "知乎".to_string(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let sent = transport.sent_messages();
|
||||||
|
|
||||||
|
assert!(sent.iter().any(|message| {
|
||||||
|
matches!(
|
||||||
|
message,
|
||||||
|
AgentMessage::TaskComplete { success, summary }
|
||||||
|
if *success && summary.contains("未找到“写文章”入口")
|
||||||
|
)
|
||||||
|
}));
|
||||||
|
assert_eq!(
|
||||||
|
sent.iter()
|
||||||
|
.filter(|message| matches!(message, AgentMessage::Command { .. }))
|
||||||
|
.count(),
|
||||||
|
2
|
||||||
|
);
|
||||||
|
assert!(!sent.iter().any(|message| {
|
||||||
|
matches!(
|
||||||
|
message,
|
||||||
|
AgentMessage::LogEntry { level, message }
|
||||||
|
if level == "info" && message == "call zhihu-write.prepare_article_editor"
|
||||||
|
)
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn zhihu_publish_without_confirmation_returns_confirmation_before_any_browser_probing() {
|
||||||
|
let _guard = env_lock().lock().unwrap_or_else(|err| err.into_inner());
|
||||||
|
|
||||||
|
let workspace_root = temp_workspace_root();
|
||||||
|
let config_path = write_deepseek_config_with_skills_dir(
|
||||||
|
&workspace_root,
|
||||||
|
"deepseek-test-key",
|
||||||
|
"http://127.0.0.1:9",
|
||||||
|
"deepseek-chat",
|
||||||
|
Some(real_skill_lib_root().to_str().unwrap()),
|
||||||
|
);
|
||||||
|
let runtime_context = AgentRuntimeContext::new(Some(config_path), workspace_root.clone());
|
||||||
|
|
||||||
|
let transport = Arc::new(MockTransport::new(vec![]));
|
||||||
|
let browser_tool = BrowserPipeTool::new(
|
||||||
|
transport.clone(),
|
||||||
|
zhihu_test_policy(),
|
||||||
|
vec![1, 2, 3, 4, 5, 6, 7, 8],
|
||||||
|
)
|
||||||
|
.with_response_timeout(Duration::from_secs(1));
|
||||||
|
|
||||||
|
handle_browser_message_with_context(
|
||||||
|
transport.as_ref(),
|
||||||
|
&browser_tool,
|
||||||
|
&runtime_context,
|
||||||
|
BrowserMessage::SubmitTask {
|
||||||
|
instruction: "请直接发表这篇知乎文章,标题是测试标题,正文是第一段内容".to_string(),
|
||||||
|
conversation_id: String::new(),
|
||||||
|
messages: vec![],
|
||||||
|
page_url: "https://www.zhihu.com/".to_string(),
|
||||||
|
page_title: "知乎".to_string(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let sent = transport.sent_messages();
|
||||||
|
|
||||||
|
assert!(sent.iter().any(|message| {
|
||||||
|
matches!(
|
||||||
|
message,
|
||||||
|
AgentMessage::TaskComplete { success, summary }
|
||||||
|
if *success && summary.contains("确认发布")
|
||||||
|
)
|
||||||
|
}));
|
||||||
|
assert!(!sent.iter().any(|message| {
|
||||||
|
matches!(message, AgentMessage::Command { .. })
|
||||||
|
}));
|
||||||
|
assert!(!sent.iter().any(|message| {
|
||||||
|
matches!(
|
||||||
|
message,
|
||||||
|
AgentMessage::LogEntry { level, message }
|
||||||
|
if level == "info" &&
|
||||||
|
(message.starts_with("navigate ") ||
|
||||||
|
message.starts_with("getText ") ||
|
||||||
|
message.starts_with("click ") ||
|
||||||
|
message.starts_with("type "))
|
||||||
|
)
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn zhihu_publish_after_confirmation_reports_login_block_without_selector_probing() {
|
||||||
|
let _guard = env_lock().lock().unwrap_or_else(|err| err.into_inner());
|
||||||
|
|
||||||
|
let workspace_root = temp_workspace_root();
|
||||||
|
let config_path = write_deepseek_config_with_skills_dir(
|
||||||
|
&workspace_root,
|
||||||
|
"deepseek-test-key",
|
||||||
|
"http://127.0.0.1:9",
|
||||||
|
"deepseek-chat",
|
||||||
|
Some(real_skill_lib_root().to_str().unwrap()),
|
||||||
|
);
|
||||||
|
let runtime_context = AgentRuntimeContext::new(Some(config_path), workspace_root.clone());
|
||||||
|
|
||||||
|
let transport = Arc::new(MockTransport::new(vec![
|
||||||
|
success_browser_response(1, json!({ "navigated": true, "url": "https://www.zhihu.com/signin?next=%2Fcreator" })),
|
||||||
|
success_browser_response(
|
||||||
|
2,
|
||||||
|
json!({
|
||||||
|
"text": {
|
||||||
|
"status": "login_required",
|
||||||
|
"current_url": "https://www.zhihu.com/signin?next=%2Fcreator"
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
]));
|
||||||
|
let browser_tool = BrowserPipeTool::new(
|
||||||
|
transport.clone(),
|
||||||
|
zhihu_test_policy(),
|
||||||
|
vec![1, 2, 3, 4, 5, 6, 7, 8],
|
||||||
|
)
|
||||||
|
.with_response_timeout(Duration::from_secs(1));
|
||||||
|
|
||||||
|
handle_browser_message_with_context(
|
||||||
|
transport.as_ref(),
|
||||||
|
&browser_tool,
|
||||||
|
&runtime_context,
|
||||||
|
BrowserMessage::SubmitTask {
|
||||||
|
instruction: "确认发布".to_string(),
|
||||||
|
conversation_id: "conversation-1".to_string(),
|
||||||
|
messages: vec![ConversationMessage {
|
||||||
|
role: "user".to_string(),
|
||||||
|
content: "请直接发表这篇知乎文章,标题是测试标题,正文是第一段内容".to_string(),
|
||||||
|
}],
|
||||||
|
page_url: "https://www.zhihu.com/".to_string(),
|
||||||
|
page_title: "知乎".to_string(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let sent = transport.sent_messages();
|
||||||
|
|
||||||
|
assert!(sent.iter().any(|message| {
|
||||||
|
matches!(
|
||||||
|
message,
|
||||||
|
AgentMessage::TaskComplete { success, summary }
|
||||||
|
if *success && (summary.contains("未登录") || summary.contains("登录"))
|
||||||
|
)
|
||||||
|
}));
|
||||||
|
assert!(sent.iter().any(|message| {
|
||||||
|
matches!(
|
||||||
|
message,
|
||||||
|
AgentMessage::Command { action, params, .. }
|
||||||
|
if action == &Action::Navigate &&
|
||||||
|
params["url"].as_str() == Some("https://www.zhihu.com/creator")
|
||||||
|
)
|
||||||
|
}));
|
||||||
|
assert!(sent.iter().any(|message| {
|
||||||
|
matches!(message, AgentMessage::Command { action, .. } if action == &Action::Eval)
|
||||||
|
}));
|
||||||
|
assert!(!sent.iter().any(|message| {
|
||||||
|
matches!(
|
||||||
|
message,
|
||||||
|
AgentMessage::LogEntry { level, message }
|
||||||
|
if level == "info" &&
|
||||||
|
(message.starts_with("getText ") || message.starts_with("click "))
|
||||||
|
)
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn browser_orchestration_registers_superrpa_tools_natively() {
|
fn browser_orchestration_registers_superrpa_tools_natively() {
|
||||||
let _guard = env_lock().lock().unwrap_or_else(|err| err.into_inner());
|
let _guard = env_lock().lock().unwrap_or_else(|err| err.into_inner());
|
||||||
@@ -2948,103 +3591,12 @@ fn handle_browser_message_executes_real_zhihu_navigate_skill_flow() {
|
|||||||
fn handle_browser_message_executes_real_zhihu_write_skill_flow() {
|
fn handle_browser_message_executes_real_zhihu_write_skill_flow() {
|
||||||
let _guard = env_lock().lock().unwrap_or_else(|err| err.into_inner());
|
let _guard = env_lock().lock().unwrap_or_else(|err| err.into_inner());
|
||||||
|
|
||||||
let first_response = json!({
|
|
||||||
"choices": [{
|
|
||||||
"message": {
|
|
||||||
"content": "",
|
|
||||||
"tool_calls": [{
|
|
||||||
"id": "call_1",
|
|
||||||
"type": "function",
|
|
||||||
"function": {
|
|
||||||
"name": "read_skill",
|
|
||||||
"arguments": serde_json::to_string(&json!({
|
|
||||||
"name": "zhihu-write"
|
|
||||||
})).unwrap()
|
|
||||||
}
|
|
||||||
}]
|
|
||||||
}
|
|
||||||
}]
|
|
||||||
});
|
|
||||||
let second_response = json!({
|
|
||||||
"choices": [{
|
|
||||||
"message": {
|
|
||||||
"content": "",
|
|
||||||
"tool_calls": [
|
|
||||||
{
|
|
||||||
"id": "call_2",
|
|
||||||
"type": "function",
|
|
||||||
"function": {
|
|
||||||
"name": "browser_action",
|
|
||||||
"arguments": serde_json::to_string(&json!({
|
|
||||||
"action": "navigate",
|
|
||||||
"expected_domain": "www.zhihu.com",
|
|
||||||
"url": "https://www.zhihu.com/creator"
|
|
||||||
})).unwrap()
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "call_3",
|
|
||||||
"type": "function",
|
|
||||||
"function": {
|
|
||||||
"name": "browser_action",
|
|
||||||
"arguments": serde_json::to_string(&json!({
|
|
||||||
"action": "click",
|
|
||||||
"expected_domain": "www.zhihu.com",
|
|
||||||
"selector": "a[href='https://zhuanlan.zhihu.com/write']"
|
|
||||||
})).unwrap()
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "call_4",
|
|
||||||
"type": "function",
|
|
||||||
"function": {
|
|
||||||
"name": "browser_action",
|
|
||||||
"arguments": serde_json::to_string(&json!({
|
|
||||||
"action": "type",
|
|
||||||
"expected_domain": "zhuanlan.zhihu.com",
|
|
||||||
"selector": "input[placeholder='请输入标题']",
|
|
||||||
"text": "测试标题",
|
|
||||||
"clear_first": true
|
|
||||||
})).unwrap()
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "call_5",
|
|
||||||
"type": "function",
|
|
||||||
"function": {
|
|
||||||
"name": "browser_action",
|
|
||||||
"arguments": serde_json::to_string(&json!({
|
|
||||||
"action": "type",
|
|
||||||
"expected_domain": "zhuanlan.zhihu.com",
|
|
||||||
"selector": ".public-DraftEditor-content",
|
|
||||||
"text": "第一段内容",
|
|
||||||
"clear_first": true
|
|
||||||
})).unwrap()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}]
|
|
||||||
});
|
|
||||||
let third_response = json!({
|
|
||||||
"choices": [{
|
|
||||||
"message": {
|
|
||||||
"content": "已完成知乎文章草稿填写"
|
|
||||||
}
|
|
||||||
}]
|
|
||||||
});
|
|
||||||
let (base_url, requests, server_handle) = start_fake_deepseek_server(vec![
|
|
||||||
first_response,
|
|
||||||
second_response,
|
|
||||||
third_response,
|
|
||||||
]);
|
|
||||||
|
|
||||||
let workspace_root = temp_workspace_root();
|
let workspace_root = temp_workspace_root();
|
||||||
let skills_dir = real_skill_lib_root();
|
let skills_dir = real_skill_lib_root();
|
||||||
let config_path = write_deepseek_config_with_skills_dir(
|
let config_path = write_deepseek_config_with_skills_dir(
|
||||||
&workspace_root,
|
&workspace_root,
|
||||||
"deepseek-test-key",
|
"deepseek-test-key",
|
||||||
&base_url,
|
"http://127.0.0.1:9",
|
||||||
"deepseek-chat",
|
"deepseek-chat",
|
||||||
Some(skills_dir.to_str().unwrap()),
|
Some(skills_dir.to_str().unwrap()),
|
||||||
);
|
);
|
||||||
@@ -3052,9 +3604,36 @@ fn handle_browser_message_executes_real_zhihu_write_skill_flow() {
|
|||||||
|
|
||||||
let transport = Arc::new(MockTransport::new(vec![
|
let transport = Arc::new(MockTransport::new(vec![
|
||||||
success_browser_response(1, json!({ "navigated": true })),
|
success_browser_response(1, json!({ "navigated": true })),
|
||||||
success_browser_response(2, json!({ "clicked": true })),
|
success_browser_response(
|
||||||
success_browser_response(3, json!({ "typed": true })),
|
2,
|
||||||
success_browser_response(4, json!({ "typed": true })),
|
json!({
|
||||||
|
"text": {
|
||||||
|
"status": "creator_entry_clicked",
|
||||||
|
"current_url": "https://www.zhihu.com/creator",
|
||||||
|
"next_url": "https://zhuanlan.zhihu.com/write"
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
success_browser_response(3, json!({ "navigated": true })),
|
||||||
|
success_browser_response(
|
||||||
|
4,
|
||||||
|
json!({
|
||||||
|
"text": {
|
||||||
|
"status": "editor_ready",
|
||||||
|
"current_url": "https://zhuanlan.zhihu.com/write"
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
success_browser_response(
|
||||||
|
5,
|
||||||
|
json!({
|
||||||
|
"text": {
|
||||||
|
"status": "draft_ready",
|
||||||
|
"current_url": "https://zhuanlan.zhihu.com/write",
|
||||||
|
"title": "测试标题"
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
),
|
||||||
]));
|
]));
|
||||||
let browser_tool = BrowserPipeTool::new(
|
let browser_tool = BrowserPipeTool::new(
|
||||||
transport.clone(),
|
transport.clone(),
|
||||||
@@ -3076,24 +3655,42 @@ fn handle_browser_message_executes_real_zhihu_write_skill_flow() {
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
server_handle.join().unwrap();
|
|
||||||
|
|
||||||
let sent = transport.sent_messages();
|
let sent = transport.sent_messages();
|
||||||
let request_bodies = requests.lock().unwrap().clone();
|
|
||||||
let tool_content = tool_message_content(&request_bodies[1], "call_1").unwrap();
|
|
||||||
|
|
||||||
assert!(sent.iter().any(|message| {
|
assert!(sent.iter().any(|message| {
|
||||||
matches!(
|
matches!(
|
||||||
message,
|
message,
|
||||||
AgentMessage::TaskComplete { success, summary }
|
AgentMessage::TaskComplete { success, summary }
|
||||||
if *success && summary == "已完成知乎文章草稿填写"
|
if *success && summary == "已进入知乎文章编辑器并写入草稿《测试标题》"
|
||||||
)
|
)
|
||||||
}));
|
}));
|
||||||
assert!(sent.iter().any(|message| {
|
assert!(sent.iter().any(|message| {
|
||||||
matches!(
|
matches!(
|
||||||
message,
|
message,
|
||||||
AgentMessage::LogEntry { level, message }
|
AgentMessage::LogEntry { level, message }
|
||||||
if level == "info" && message == "read_skill zhihu-write@0.1.0"
|
if level == "mode" && message == "zeroclaw_process_message_primary"
|
||||||
|
)
|
||||||
|
}));
|
||||||
|
assert!(sent.iter().any(|message| {
|
||||||
|
matches!(
|
||||||
|
message,
|
||||||
|
AgentMessage::LogEntry { level, message }
|
||||||
|
if level == "info" && message == "call zhihu-navigate.open_creator_entry"
|
||||||
|
)
|
||||||
|
}));
|
||||||
|
assert!(sent.iter().any(|message| {
|
||||||
|
matches!(
|
||||||
|
message,
|
||||||
|
AgentMessage::LogEntry { level, message }
|
||||||
|
if level == "info" && message == "call zhihu-write.prepare_article_editor"
|
||||||
|
)
|
||||||
|
}));
|
||||||
|
assert!(sent.iter().any(|message| {
|
||||||
|
matches!(
|
||||||
|
message,
|
||||||
|
AgentMessage::LogEntry { level, message }
|
||||||
|
if level == "info" && message == "call zhihu-write.fill_article_draft"
|
||||||
)
|
)
|
||||||
}));
|
}));
|
||||||
assert!(sent.iter().any(|message| {
|
assert!(sent.iter().any(|message| {
|
||||||
@@ -3108,28 +3705,18 @@ fn handle_browser_message_executes_real_zhihu_write_skill_flow() {
|
|||||||
matches!(
|
matches!(
|
||||||
message,
|
message,
|
||||||
AgentMessage::Command { action, params, .. }
|
AgentMessage::Command { action, params, .. }
|
||||||
if action == &Action::Click &&
|
if action == &Action::Navigate &&
|
||||||
params["selector"].as_str() ==
|
params["url"].as_str() == Some("https://zhuanlan.zhihu.com/write")
|
||||||
Some("a[href='https://zhuanlan.zhihu.com/write']")
|
|
||||||
)
|
)
|
||||||
}));
|
}));
|
||||||
assert!(sent.iter().any(|message| {
|
assert!(sent.iter().filter(|message| {
|
||||||
|
matches!(message, AgentMessage::Command { action, .. } if action == &Action::Eval)
|
||||||
|
}).count() >= 2);
|
||||||
|
assert!(!sent.iter().any(|message| {
|
||||||
matches!(
|
matches!(
|
||||||
message,
|
message,
|
||||||
AgentMessage::Command { action, params, .. }
|
AgentMessage::LogEntry { level, message }
|
||||||
if action == &Action::Type &&
|
if level == "mode" && (message == "compat_llm_primary" || message == "compat_skill_runner_primary")
|
||||||
params["selector"].as_str() == Some("input[placeholder='请输入标题']")
|
|
||||||
)
|
)
|
||||||
}));
|
}));
|
||||||
assert!(sent.iter().any(|message| {
|
|
||||||
matches!(
|
|
||||||
message,
|
|
||||||
AgentMessage::Command { action, params, .. }
|
|
||||||
if action == &Action::Type &&
|
|
||||||
params["selector"].as_str() == Some(".public-DraftEditor-content")
|
|
||||||
)
|
|
||||||
}));
|
|
||||||
assert_eq!(request_bodies.len(), 3);
|
|
||||||
assert!(tool_content.len() > 100);
|
|
||||||
assert!(tool_content.contains("publish a Zhihu article"));
|
|
||||||
}
|
}
|
||||||
|
|||||||
146
tests/skill_script_zhihu_navigate_test.py
Normal file
146
tests/skill_script_zhihu_navigate_test.py
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
import json
|
||||||
|
import subprocess
|
||||||
|
import textwrap
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
SCRIPT_PATH = (
|
||||||
|
REPO_ROOT.parent / "skill_lib" / "skills" / "zhihu-navigate" / "scripts" /
|
||||||
|
"open_creator_entry.js"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def run_open_creator_entry(*, body_text: str, selectors: dict[str, list[dict]]) -> dict:
|
||||||
|
node_script = textwrap.dedent(
|
||||||
|
f"""
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import vm from 'node:vm';
|
||||||
|
|
||||||
|
const scriptPath = {json.dumps(str(SCRIPT_PATH))};
|
||||||
|
const selectorMap = {json.dumps(selectors, ensure_ascii=False)};
|
||||||
|
const bodyText = {json.dumps(body_text, ensure_ascii=False)};
|
||||||
|
const source = fs.readFileSync(scriptPath, 'utf8');
|
||||||
|
|
||||||
|
function createNode(spec) {{
|
||||||
|
const node = {{
|
||||||
|
tagName: String(spec?.tagName || 'DIV').toUpperCase(),
|
||||||
|
textContent: String(spec?.textContent ?? ''),
|
||||||
|
innerText: String(spec?.innerText ?? spec?.textContent ?? ''),
|
||||||
|
href: String(spec?.href ?? ''),
|
||||||
|
clicked: false,
|
||||||
|
click() {{
|
||||||
|
this.clicked = true;
|
||||||
|
}},
|
||||||
|
getBoundingClientRect() {{
|
||||||
|
return {{
|
||||||
|
width: spec?.visible === false ? 0 : 120,
|
||||||
|
height: spec?.visible === false ? 0 : 32,
|
||||||
|
}};
|
||||||
|
}},
|
||||||
|
}};
|
||||||
|
return node;
|
||||||
|
}}
|
||||||
|
|
||||||
|
const created = new Map();
|
||||||
|
function createNodeList(selector) {{
|
||||||
|
const specs = selectorMap[selector] || [];
|
||||||
|
return specs.map((spec, index) => {{
|
||||||
|
const key = `${{selector}}#${{index}}`;
|
||||||
|
if (!created.has(key)) {{
|
||||||
|
created.set(key, createNode(spec));
|
||||||
|
}}
|
||||||
|
return created.get(key);
|
||||||
|
}});
|
||||||
|
}}
|
||||||
|
|
||||||
|
const bodyNode = createNode({{ tagName: 'BODY', textContent: bodyText, innerText: bodyText }});
|
||||||
|
const context = {{
|
||||||
|
args: {{ desired_target: 'article_editor' }},
|
||||||
|
location: {{ href: 'https://www.zhihu.com/creator' }},
|
||||||
|
document: {{
|
||||||
|
body: bodyNode,
|
||||||
|
querySelector(selector) {{
|
||||||
|
if (selector === 'body') {{
|
||||||
|
return bodyNode;
|
||||||
|
}}
|
||||||
|
return createNodeList(selector)[0] || null;
|
||||||
|
}},
|
||||||
|
querySelectorAll(selector) {{
|
||||||
|
return createNodeList(selector);
|
||||||
|
}},
|
||||||
|
}},
|
||||||
|
console,
|
||||||
|
JSON,
|
||||||
|
Math,
|
||||||
|
Number,
|
||||||
|
Object,
|
||||||
|
RegExp,
|
||||||
|
Set,
|
||||||
|
String,
|
||||||
|
Array,
|
||||||
|
Error,
|
||||||
|
}};
|
||||||
|
|
||||||
|
try {{
|
||||||
|
const result = vm.runInNewContext(`(function(){{\\n${{source}}\\n}})()`, context);
|
||||||
|
process.stdout.write(JSON.stringify({{ ok: true, result, created: Object.fromEntries(created) }}));
|
||||||
|
}} catch (error) {{
|
||||||
|
process.stdout.write(JSON.stringify({{
|
||||||
|
ok: false,
|
||||||
|
error: String(error && error.message ? error.message : error),
|
||||||
|
}}));
|
||||||
|
process.exitCode = 1;
|
||||||
|
}}
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
completed = subprocess.run(
|
||||||
|
["node", "--input-type=module", "-e", node_script],
|
||||||
|
check=False,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
payload = json.loads(completed.stdout)
|
||||||
|
if completed.returncode != 0:
|
||||||
|
raise AssertionError(payload["error"])
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
class SkillScriptZhihuNavigateTest(unittest.TestCase):
|
||||||
|
def test_open_creator_entry_clicks_anchor_write_entry(self):
|
||||||
|
payload = run_open_creator_entry(
|
||||||
|
body_text="创作者中心 写文章",
|
||||||
|
selectors={
|
||||||
|
"a[href], button, [role='button']": [
|
||||||
|
{
|
||||||
|
"tagName": "a",
|
||||||
|
"textContent": "写文章",
|
||||||
|
"href": "https://zhuanlan.zhihu.com/write",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(payload["result"]["status"], "creator_entry_clicked")
|
||||||
|
self.assertTrue(payload["created"]["a[href], button, [role='button']#0"]["clicked"])
|
||||||
|
|
||||||
|
def test_open_creator_entry_clicks_button_write_entry(self):
|
||||||
|
payload = run_open_creator_entry(
|
||||||
|
body_text="创作者中心 发布内容",
|
||||||
|
selectors={
|
||||||
|
"a[href], button, [role='button']": [
|
||||||
|
{
|
||||||
|
"tagName": "button",
|
||||||
|
"textContent": "写文章",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(payload["result"]["status"], "creator_entry_clicked")
|
||||||
|
self.assertTrue(payload["created"]["a[href], button, [role='button']#0"]["clicked"])
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
Reference in New Issue
Block a user