feat: realign zhihu browser callback runtime
Keep Zhihu browser-attached execution on the callback-host path so direct routes, runtime wiring, and service startup stay aligned for the current websocket browser flow. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -36,43 +36,62 @@ fn run() -> Result<(), String> {
|
|||||||
.unwrap_or_else(|_| "ws://127.0.0.1:42321".to_string());
|
.unwrap_or_else(|_| "ws://127.0.0.1:42321".to_string());
|
||||||
let (mut socket, _) = connect(service_url.as_str()).map_err(|err| err.to_string())?;
|
let (mut socket, _) = connect(service_url.as_str()).map_err(|err| err.to_string())?;
|
||||||
|
|
||||||
let mut input = String::new();
|
let stdin = io::stdin();
|
||||||
io::stdin()
|
|
||||||
.lock()
|
|
||||||
.read_line(&mut input)
|
|
||||||
.map_err(|err| err.to_string())?;
|
|
||||||
|
|
||||||
let (request, exit_on_status) = parse_request(&input);
|
|
||||||
|
|
||||||
let payload = serde_json::to_string(&request).map_err(|err| err.to_string())?;
|
|
||||||
socket
|
|
||||||
.send(Message::Text(payload.into()))
|
|
||||||
.map_err(|err| err.to_string())?;
|
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
match socket.read().map_err(|err| err.to_string())? {
|
eprint!("> ");
|
||||||
Message::Text(text) => {
|
let mut input = String::new();
|
||||||
let message: ServiceMessage =
|
let bytes_read = stdin
|
||||||
serde_json::from_str(&text).map_err(|err| err.to_string())?;
|
.lock()
|
||||||
match message {
|
.read_line(&mut input)
|
||||||
ServiceMessage::StatusChanged { state } => {
|
.map_err(|err| err.to_string())?;
|
||||||
println!("status: {state}");
|
if bytes_read == 0 {
|
||||||
if exit_on_status {
|
break; // EOF — graceful exit
|
||||||
return Ok(());
|
}
|
||||||
|
if input.trim().is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let (request, exit_on_status) = parse_request(&input);
|
||||||
|
|
||||||
|
let payload = serde_json::to_string(&request).map_err(|err| err.to_string())?;
|
||||||
|
socket
|
||||||
|
.send(Message::Text(payload.into()))
|
||||||
|
.map_err(|err| err.to_string())?;
|
||||||
|
|
||||||
|
// Inner loop: consume service messages until the task finishes.
|
||||||
|
loop {
|
||||||
|
match socket.read().map_err(|err| err.to_string())? {
|
||||||
|
Message::Text(text) => {
|
||||||
|
let message: ServiceMessage =
|
||||||
|
serde_json::from_str(&text).map_err(|err| err.to_string())?;
|
||||||
|
match message {
|
||||||
|
ServiceMessage::StatusChanged { state } => {
|
||||||
|
println!("status: {state}");
|
||||||
|
if exit_on_status {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ServiceMessage::LogEntry { level: _, message } => {
|
||||||
|
println!("{message}");
|
||||||
|
}
|
||||||
|
ServiceMessage::TaskComplete { success: _, summary } => {
|
||||||
|
println!("{summary}");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
ServiceMessage::Busy { message } => {
|
||||||
|
eprintln!("busy: {message}");
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
ServiceMessage::LogEntry { level: _, message } => {
|
|
||||||
println!("{message}");
|
|
||||||
}
|
|
||||||
ServiceMessage::TaskComplete { success: _, summary } => {
|
|
||||||
println!("{summary}");
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
ServiceMessage::Busy { message } => return Err(message),
|
|
||||||
}
|
}
|
||||||
|
Message::Close(_) => {
|
||||||
|
return Err("service disconnected".to_string());
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
}
|
}
|
||||||
Message::Close(_) => return Err("service disconnected before task completion".to_string()),
|
|
||||||
_ => {}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,6 +17,10 @@ pub trait BrowserBackend: Send + Sync {
|
|||||||
fn supports_eval(&self) -> bool {
|
fn supports_eval(&self) -> bool {
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn supports_live_input(&self) -> bool {
|
||||||
|
false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<T: BrowserBackend + ?Sized> BrowserBackend for Arc<T> {
|
impl<T: BrowserBackend + ?Sized> BrowserBackend for Arc<T> {
|
||||||
@@ -36,4 +40,8 @@ impl<T: BrowserBackend + ?Sized> BrowserBackend for Arc<T> {
|
|||||||
fn supports_eval(&self) -> bool {
|
fn supports_eval(&self) -> bool {
|
||||||
self.as_ref().supports_eval()
|
self.as_ref().supports_eval()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn supports_live_input(&self) -> bool {
|
||||||
|
self.as_ref().supports_live_input()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,8 @@ use crate::browser::backend::BrowserBackend;
|
|||||||
use crate::pipe::{Action, CommandOutput, ExecutionSurfaceMetadata, PipeError, Timing};
|
use crate::pipe::{Action, CommandOutput, ExecutionSurfaceMetadata, PipeError, Timing};
|
||||||
use crate::security::MacPolicy;
|
use crate::security::MacPolicy;
|
||||||
|
|
||||||
const NAVIGATE_CALLBACK_NAME: &str = "sgclawOnLoaded";
|
const CLICK_PROBE_CALLBACK_NAME: &str = "sgclawOnClickProbe";
|
||||||
|
const TYPE_PROBE_CALLBACK_NAME: &str = "sgclawOnTypeProbe";
|
||||||
const GET_TEXT_CALLBACK_NAME: &str = "sgclawOnGetText";
|
const GET_TEXT_CALLBACK_NAME: &str = "sgclawOnGetText";
|
||||||
const EVAL_CALLBACK_NAME: &str = "sgclawOnEval";
|
const EVAL_CALLBACK_NAME: &str = "sgclawOnEval";
|
||||||
const SHOW_AREA: &str = "show";
|
const SHOW_AREA: &str = "show";
|
||||||
@@ -54,6 +55,12 @@ pub struct BrowserCallbackBackend {
|
|||||||
next_seq: AtomicU64,
|
next_seq: AtomicU64,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
enum CallbackInputMode {
|
||||||
|
Click,
|
||||||
|
Type,
|
||||||
|
}
|
||||||
|
|
||||||
impl BrowserCallbackBackend {
|
impl BrowserCallbackBackend {
|
||||||
pub fn new(
|
pub fn new(
|
||||||
host: Arc<dyn BrowserCallbackHost>,
|
host: Arc<dyn BrowserCallbackHost>,
|
||||||
@@ -91,6 +98,8 @@ impl BrowserCallbackBackend {
|
|||||||
target_url,
|
target_url,
|
||||||
]))
|
]))
|
||||||
}
|
}
|
||||||
|
Action::Click => self.build_input_command(action, params, CallbackInputMode::Click),
|
||||||
|
Action::Type => self.build_input_command(action, params, CallbackInputMode::Type),
|
||||||
Action::GetText => {
|
Action::GetText => {
|
||||||
let target_url = self.target_url(action, params)?;
|
let target_url = self.target_url(action, params)?;
|
||||||
let domain = extract_domain(&target_url)?;
|
let domain = extract_domain(&target_url)?;
|
||||||
@@ -129,6 +138,35 @@ impl BrowserCallbackBackend {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn build_input_command(
|
||||||
|
&self,
|
||||||
|
action: &Action,
|
||||||
|
params: &Value,
|
||||||
|
mode: CallbackInputMode,
|
||||||
|
) -> Result<Value, PipeError> {
|
||||||
|
let target_url = self.target_url(action, params)?;
|
||||||
|
let domain = extract_domain(&target_url)?;
|
||||||
|
let selector = optional_string(params, "selector");
|
||||||
|
let probe_script = optional_string(params, "probe_script");
|
||||||
|
let text = matches!(mode, CallbackInputMode::Type)
|
||||||
|
.then(|| required_string(params, "text"))
|
||||||
|
.transpose()?;
|
||||||
|
let js_code = build_input_probe_js(
|
||||||
|
mode,
|
||||||
|
&self.helper_page_url,
|
||||||
|
selector.as_deref(),
|
||||||
|
probe_script.as_deref(),
|
||||||
|
text.as_deref(),
|
||||||
|
)?;
|
||||||
|
Ok(json!([
|
||||||
|
self.helper_page_url,
|
||||||
|
"sgBrowserExcuteJsCodeByDomain",
|
||||||
|
domain,
|
||||||
|
js_code,
|
||||||
|
SHOW_AREA,
|
||||||
|
]))
|
||||||
|
}
|
||||||
|
|
||||||
fn target_url(&self, action: &Action, params: &Value) -> Result<String, PipeError> {
|
fn target_url(&self, action: &Action, params: &Value) -> Result<String, PipeError> {
|
||||||
if let Some(target_url) = params
|
if let Some(target_url) = params
|
||||||
.get("target_url")
|
.get("target_url")
|
||||||
@@ -146,6 +184,117 @@ impl BrowserCallbackBackend {
|
|||||||
.clone()
|
.clone()
|
||||||
.ok_or_else(|| PipeError::Protocol(format!("target_url is required for {}", action.as_str())))
|
.ok_or_else(|| PipeError::Protocol(format!("target_url is required for {}", action.as_str())))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn execute_simulated_click(
|
||||||
|
&self,
|
||||||
|
seq: u64,
|
||||||
|
expected_domain: &str,
|
||||||
|
success: &BrowserCallbackSuccess,
|
||||||
|
) -> Result<BrowserCallbackSuccess, PipeError> {
|
||||||
|
let probe = success
|
||||||
|
.data
|
||||||
|
.get("probe")
|
||||||
|
.ok_or_else(|| PipeError::Protocol("callback click probe payload missing".to_string()))?;
|
||||||
|
let x = probe
|
||||||
|
.get("x")
|
||||||
|
.and_then(Value::as_f64)
|
||||||
|
.ok_or_else(|| PipeError::Protocol("callback click probe missing x".to_string()))?;
|
||||||
|
let y = probe
|
||||||
|
.get("y")
|
||||||
|
.and_then(Value::as_f64)
|
||||||
|
.ok_or_else(|| PipeError::Protocol("callback click probe missing y".to_string()))?;
|
||||||
|
let timing = success.timing.clone();
|
||||||
|
match self.host.execute(BrowserCallbackRequest {
|
||||||
|
seq,
|
||||||
|
request_url: self.helper_page_url.clone(),
|
||||||
|
expected_domain: expected_domain.to_string(),
|
||||||
|
action: Action::Click.as_str().to_string(),
|
||||||
|
command: json!([
|
||||||
|
self.helper_page_url,
|
||||||
|
"sgBroewserSimulateMouse",
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
"left",
|
||||||
|
"",
|
||||||
|
""
|
||||||
|
]),
|
||||||
|
}) {
|
||||||
|
Ok(BrowserCallbackResponse::Error(error)) => Err(PipeError::Protocol(format!(
|
||||||
|
"callback host browser action failed: {} ({})",
|
||||||
|
error.message, error.details
|
||||||
|
))),
|
||||||
|
Ok(BrowserCallbackResponse::Success(_)) | Err(PipeError::Timeout) => {
|
||||||
|
Ok(BrowserCallbackSuccess {
|
||||||
|
success: true,
|
||||||
|
data: json!({
|
||||||
|
"clicked": true,
|
||||||
|
"probe": { "x": x, "y": y },
|
||||||
|
}),
|
||||||
|
aom_snapshot: vec![],
|
||||||
|
timing,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
Err(error) => Err(error),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn execute_simulated_type(
|
||||||
|
&self,
|
||||||
|
seq: u64,
|
||||||
|
expected_domain: &str,
|
||||||
|
params: &Value,
|
||||||
|
success: &BrowserCallbackSuccess,
|
||||||
|
) -> Result<BrowserCallbackSuccess, PipeError> {
|
||||||
|
let probe = success
|
||||||
|
.data
|
||||||
|
.get("probe")
|
||||||
|
.ok_or_else(|| PipeError::Protocol("callback type probe payload missing".to_string()))?;
|
||||||
|
let x = probe
|
||||||
|
.get("x")
|
||||||
|
.and_then(Value::as_f64)
|
||||||
|
.ok_or_else(|| PipeError::Protocol("callback type probe missing x".to_string()))?;
|
||||||
|
let y = probe
|
||||||
|
.get("y")
|
||||||
|
.and_then(Value::as_f64)
|
||||||
|
.ok_or_else(|| PipeError::Protocol("callback type probe missing y".to_string()))?;
|
||||||
|
let text = params
|
||||||
|
.get("text")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
.ok_or_else(|| PipeError::Protocol("text is required".to_string()))?;
|
||||||
|
let timing = success.timing.clone();
|
||||||
|
match self.host.execute(BrowserCallbackRequest {
|
||||||
|
seq,
|
||||||
|
request_url: self.helper_page_url.clone(),
|
||||||
|
expected_domain: expected_domain.to_string(),
|
||||||
|
action: Action::Type.as_str().to_string(),
|
||||||
|
command: json!([
|
||||||
|
self.helper_page_url,
|
||||||
|
"sgBroewserSimulateKeyborad",
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
text
|
||||||
|
]),
|
||||||
|
}) {
|
||||||
|
Ok(BrowserCallbackResponse::Error(error)) => Err(PipeError::Protocol(format!(
|
||||||
|
"callback host browser action failed: {} ({})",
|
||||||
|
error.message, error.details
|
||||||
|
))),
|
||||||
|
Ok(BrowserCallbackResponse::Success(_)) | Err(PipeError::Timeout) => {
|
||||||
|
Ok(BrowserCallbackSuccess {
|
||||||
|
success: true,
|
||||||
|
data: json!({
|
||||||
|
"typed": true,
|
||||||
|
"probe": { "x": x, "y": y, "text": text },
|
||||||
|
}),
|
||||||
|
aom_snapshot: vec![],
|
||||||
|
timing,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
Err(error) => Err(error),
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl BrowserBackend for BrowserCallbackBackend {
|
impl BrowserBackend for BrowserCallbackBackend {
|
||||||
@@ -168,6 +317,13 @@ impl BrowserBackend for BrowserCallbackBackend {
|
|||||||
|
|
||||||
match reply {
|
match reply {
|
||||||
BrowserCallbackResponse::Success(success) => {
|
BrowserCallbackResponse::Success(success) => {
|
||||||
|
let success = match action {
|
||||||
|
Action::Click => self.execute_simulated_click(seq, expected_domain, &success)?,
|
||||||
|
Action::Type => {
|
||||||
|
self.execute_simulated_type(seq, expected_domain, ¶ms, &success)?
|
||||||
|
}
|
||||||
|
_ => success,
|
||||||
|
};
|
||||||
if matches!(action, Action::Navigate) {
|
if matches!(action, Action::Navigate) {
|
||||||
if let Some(url) = params
|
if let Some(url) = params
|
||||||
.get("url")
|
.get("url")
|
||||||
@@ -202,6 +358,11 @@ impl BrowserBackend for BrowserCallbackBackend {
|
|||||||
fn supports_eval(&self) -> bool {
|
fn supports_eval(&self) -> bool {
|
||||||
self.mac_policy.supports_pipe_action(&Action::Eval)
|
self.mac_policy.supports_pipe_action(&Action::Eval)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn supports_live_input(&self) -> bool {
|
||||||
|
self.mac_policy.supports_pipe_action(&Action::Click)
|
||||||
|
&& self.mac_policy.supports_pipe_action(&Action::Type)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn required_string(params: &Value, key: &str) -> Result<String, PipeError> {
|
fn required_string(params: &Value, key: &str) -> Result<String, PipeError> {
|
||||||
@@ -214,6 +375,15 @@ fn required_string(params: &Value, key: &str) -> Result<String, PipeError> {
|
|||||||
.ok_or_else(|| PipeError::Protocol(format!("{key} is required")))
|
.ok_or_else(|| PipeError::Protocol(format!("{key} is required")))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn optional_string(params: &Value, key: &str) -> Option<String> {
|
||||||
|
params
|
||||||
|
.get(key)
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
.map(ToString::to_string)
|
||||||
|
}
|
||||||
|
|
||||||
fn build_get_text_js(source_url: &str, selector: &str) -> String {
|
fn build_get_text_js(source_url: &str, selector: &str) -> String {
|
||||||
let escaped_source_url = escape_js_single_quoted(source_url);
|
let escaped_source_url = escape_js_single_quoted(source_url);
|
||||||
let escaped_selector = escape_js_single_quoted(selector);
|
let escaped_selector = escape_js_single_quoted(selector);
|
||||||
@@ -259,6 +429,62 @@ fn build_eval_js(source_url: &str, script: &str) -> String {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn build_input_probe_js(
|
||||||
|
mode: CallbackInputMode,
|
||||||
|
source_url: &str,
|
||||||
|
selector: Option<&str>,
|
||||||
|
probe_script: Option<&str>,
|
||||||
|
text: Option<&str>,
|
||||||
|
) -> Result<String, PipeError> {
|
||||||
|
let escaped_source_url = escape_js_single_quoted(source_url);
|
||||||
|
let callback = match mode {
|
||||||
|
CallbackInputMode::Click => CLICK_PROBE_CALLBACK_NAME,
|
||||||
|
CallbackInputMode::Type => TYPE_PROBE_CALLBACK_NAME,
|
||||||
|
};
|
||||||
|
let events_url = escape_js_single_quoted(&events_endpoint_url(source_url));
|
||||||
|
let payload_expression = match mode {
|
||||||
|
CallbackInputMode::Click => "JSON.stringify({x:x,y:y})".to_string(),
|
||||||
|
CallbackInputMode::Type => {
|
||||||
|
let escaped_text = escape_js_single_quoted(text.unwrap_or_default());
|
||||||
|
format!("JSON.stringify({{x:x,y:y,text:'{escaped_text}'}})")
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let payload_object = match mode {
|
||||||
|
CallbackInputMode::Click => "{x:x,y:y}".to_string(),
|
||||||
|
CallbackInputMode::Type => {
|
||||||
|
let escaped_text = escape_js_single_quoted(text.unwrap_or_default());
|
||||||
|
format!("{{x:x,y:y,text:'{escaped_text}'}}")
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let element_lookup = if let Some(script) = probe_script {
|
||||||
|
format!("(function(){{{script}}})()")
|
||||||
|
} else if let Some(selector) = selector {
|
||||||
|
let escaped_selector = escape_js_single_quoted(selector);
|
||||||
|
format!("document.querySelector('{escaped_selector}')")
|
||||||
|
} else {
|
||||||
|
return Err(PipeError::Protocol(
|
||||||
|
"selector or probe_script is required".to_string(),
|
||||||
|
));
|
||||||
|
};
|
||||||
|
let missing_hint = selector
|
||||||
|
.map(|value| format!("selector not found: {}", escape_js_single_quoted(value)))
|
||||||
|
.unwrap_or_else(|| "input probe target not found".to_string());
|
||||||
|
|
||||||
|
Ok(format!(
|
||||||
|
"(function(){{try{{\
|
||||||
|
var el={element_lookup};\
|
||||||
|
if(!el){{throw new Error('{missing_hint}');}}\
|
||||||
|
var rect=(typeof el.getBoundingClientRect==='function')?el.getBoundingClientRect():null;\
|
||||||
|
var x=rect?(rect.left+(rect.width/2)):0;\
|
||||||
|
var y=rect?(rect.top+(rect.height/2)):0;\
|
||||||
|
try{{callBackJsToCpp('{escaped_source_url}@_@'+window.location.href+'@_@{callback}@_@sgBrowserExcuteJsCodeByDomain@_@'+String({payload_expression}))}}catch(_){{}}\
|
||||||
|
var j=JSON.stringify({{type:'callback',callback:'{callback}',request_url:'{escaped_source_url}',payload:{payload_object}}});\
|
||||||
|
try{{var r=new XMLHttpRequest();r.open('POST','{events_url}',true);r.setRequestHeader('Content-Type','application/json');r.send(j)}}catch(_){{}}\
|
||||||
|
try{{navigator.sendBeacon('{events_url}',new Blob([j],{{type:'application/json'}}))}}catch(_){{}}\
|
||||||
|
}}catch(e){{}}}})()"
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
/// Derive the callback host events endpoint URL from the helper page URL.
|
/// Derive the callback host events endpoint URL from the helper page URL.
|
||||||
/// e.g. "http://127.0.0.1:62819/sgclaw/browser-helper.html"
|
/// e.g. "http://127.0.0.1:62819/sgclaw/browser-helper.html"
|
||||||
/// → "http://127.0.0.1:62819/sgclaw/callback/events"
|
/// → "http://127.0.0.1:62819/sgclaw/callback/events"
|
||||||
@@ -297,5 +523,330 @@ fn extract_domain(url: &str) -> Result<String, PipeError> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn escape_js_single_quoted(raw: &str) -> String {
|
fn escape_js_single_quoted(raw: &str) -> String {
|
||||||
raw.replace('\\', "\\\\").replace('\'', "\\'")
|
raw.replace('\\', "\\\\")
|
||||||
|
.replace('\'', "\\'")
|
||||||
|
.replace('\n', "\\n")
|
||||||
|
.replace('\r', "\\r")
|
||||||
|
.replace('\0', "\\0")
|
||||||
|
.replace('\u{2028}', "\\u2028")
|
||||||
|
.replace('\u{2029}', "\\u2029")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use std::collections::VecDeque;
|
||||||
|
|
||||||
|
fn test_policy() -> MacPolicy {
|
||||||
|
MacPolicy::from_json_str(
|
||||||
|
r#"{
|
||||||
|
"version": "1.0",
|
||||||
|
"domains": { "allowed": ["www.zhihu.com", "zhuanlan.zhihu.com"] },
|
||||||
|
"pipe_actions": {
|
||||||
|
"allowed": ["click", "type", "navigate", "getText", "eval"],
|
||||||
|
"blocked": []
|
||||||
|
}
|
||||||
|
}"#,
|
||||||
|
)
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
struct FakeCallbackHost {
|
||||||
|
requests: Mutex<Vec<BrowserCallbackRequest>>,
|
||||||
|
replies: Mutex<VecDeque<Result<BrowserCallbackResponse, PipeError>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FakeCallbackHost {
|
||||||
|
fn new(replies: Vec<Result<BrowserCallbackResponse, PipeError>>) -> Self {
|
||||||
|
Self {
|
||||||
|
requests: Mutex::new(Vec::new()),
|
||||||
|
replies: Mutex::new(VecDeque::from(replies)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn requests(&self) -> Vec<BrowserCallbackRequest> {
|
||||||
|
self.requests.lock().unwrap().clone()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl BrowserCallbackHost for FakeCallbackHost {
|
||||||
|
fn execute(&self, request: BrowserCallbackRequest) -> Result<BrowserCallbackResponse, PipeError> {
|
||||||
|
self.requests.lock().unwrap().push(request);
|
||||||
|
self.replies
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.pop_front()
|
||||||
|
.unwrap_or_else(|| Err(PipeError::Timeout))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn success_reply(data: Value) -> Result<BrowserCallbackResponse, PipeError> {
|
||||||
|
Ok(BrowserCallbackResponse::Success(BrowserCallbackSuccess {
|
||||||
|
success: true,
|
||||||
|
data,
|
||||||
|
aom_snapshot: vec![],
|
||||||
|
timing: Timing {
|
||||||
|
queue_ms: 1,
|
||||||
|
exec_ms: 1,
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn callback_backend_click_treats_simulated_mouse_follow_up_as_fire_and_forget() {
|
||||||
|
let host = Arc::new(FakeCallbackHost::new(vec![success_reply(
|
||||||
|
json!({ "probe": { "x": 320.5, "y": 240.25 } }),
|
||||||
|
)]));
|
||||||
|
let backend = BrowserCallbackBackend::new(
|
||||||
|
host.clone(),
|
||||||
|
test_policy(),
|
||||||
|
"http://127.0.0.1:17888/sgclaw/browser-helper.html",
|
||||||
|
);
|
||||||
|
|
||||||
|
let output = backend
|
||||||
|
.invoke(
|
||||||
|
Action::Click,
|
||||||
|
json!({
|
||||||
|
"target_url": "https://zhuanlan.zhihu.com/write",
|
||||||
|
"selector": "button"
|
||||||
|
}),
|
||||||
|
"zhuanlan.zhihu.com",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert!(output.success);
|
||||||
|
let requests = host.requests();
|
||||||
|
assert_eq!(requests.len(), 2);
|
||||||
|
assert_eq!(requests[1].command, json!([
|
||||||
|
"http://127.0.0.1:17888/sgclaw/browser-helper.html",
|
||||||
|
"sgBroewserSimulateMouse",
|
||||||
|
320.5,
|
||||||
|
240.25,
|
||||||
|
"left",
|
||||||
|
"",
|
||||||
|
""
|
||||||
|
]));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn callback_backend_click_survives_simulated_mouse_timeout() {
|
||||||
|
let host = Arc::new(FakeCallbackHost::new(vec![
|
||||||
|
success_reply(json!({ "probe": { "x": 320.5, "y": 240.25 } })),
|
||||||
|
Err(PipeError::Timeout),
|
||||||
|
]));
|
||||||
|
let backend = BrowserCallbackBackend::new(
|
||||||
|
host.clone(),
|
||||||
|
test_policy(),
|
||||||
|
"http://127.0.0.1:17888/sgclaw/browser-helper.html",
|
||||||
|
);
|
||||||
|
|
||||||
|
let output = backend
|
||||||
|
.invoke(
|
||||||
|
Action::Click,
|
||||||
|
json!({
|
||||||
|
"target_url": "https://zhuanlan.zhihu.com/write",
|
||||||
|
"selector": "button"
|
||||||
|
}),
|
||||||
|
"zhuanlan.zhihu.com",
|
||||||
|
)
|
||||||
|
.expect("simulated mouse timeout should be treated as fire-and-forget success");
|
||||||
|
|
||||||
|
assert!(output.success);
|
||||||
|
let requests = host.requests();
|
||||||
|
assert_eq!(requests.len(), 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn callback_backend_click_uses_domain_probe_then_simulated_mouse_input() {
|
||||||
|
let host = Arc::new(FakeCallbackHost::new(vec![
|
||||||
|
success_reply(json!({ "probe": { "x": 320.5, "y": 240.25 } })),
|
||||||
|
success_reply(json!({ "clicked": true })),
|
||||||
|
]));
|
||||||
|
let backend = BrowserCallbackBackend::new(
|
||||||
|
host.clone(),
|
||||||
|
test_policy(),
|
||||||
|
"http://127.0.0.1:17888/sgclaw/browser-helper.html",
|
||||||
|
);
|
||||||
|
|
||||||
|
let output = backend
|
||||||
|
.invoke(
|
||||||
|
Action::Click,
|
||||||
|
json!({
|
||||||
|
"target_url": "https://zhuanlan.zhihu.com/write",
|
||||||
|
"selector": "button"
|
||||||
|
}),
|
||||||
|
"zhuanlan.zhihu.com",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert!(output.success);
|
||||||
|
let requests = host.requests();
|
||||||
|
assert_eq!(requests.len(), 2);
|
||||||
|
assert_eq!(requests[0].action, "click");
|
||||||
|
assert_eq!(requests[0].command[1], json!("sgBrowserExcuteJsCodeByDomain"));
|
||||||
|
assert_eq!(requests[0].command[2], json!("zhuanlan.zhihu.com"));
|
||||||
|
let script = requests[0].command[3].as_str().unwrap();
|
||||||
|
assert!(script.contains("document.querySelector('button')"));
|
||||||
|
assert!(script.contains("sgclawOnClick"));
|
||||||
|
assert_eq!(requests[1].action, "click");
|
||||||
|
assert_eq!(requests[1].command, json!([
|
||||||
|
"http://127.0.0.1:17888/sgclaw/browser-helper.html",
|
||||||
|
"sgBroewserSimulateMouse",
|
||||||
|
320.5,
|
||||||
|
240.25,
|
||||||
|
"left",
|
||||||
|
"",
|
||||||
|
""
|
||||||
|
]));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn callback_backend_type_treats_simulated_keyboard_follow_up_as_fire_and_forget() {
|
||||||
|
let host = Arc::new(FakeCallbackHost::new(vec![success_reply(
|
||||||
|
json!({ "probe": { "x": 160.0, "y": 90.0, "text": "正文" } }),
|
||||||
|
)]));
|
||||||
|
let backend = BrowserCallbackBackend::new(
|
||||||
|
host.clone(),
|
||||||
|
test_policy(),
|
||||||
|
"http://127.0.0.1:17888/sgclaw/browser-helper.html",
|
||||||
|
);
|
||||||
|
|
||||||
|
let output = backend
|
||||||
|
.invoke(
|
||||||
|
Action::Type,
|
||||||
|
json!({
|
||||||
|
"target_url": "https://zhuanlan.zhihu.com/write",
|
||||||
|
"selector": "div[contenteditable='true']",
|
||||||
|
"text": "正文"
|
||||||
|
}),
|
||||||
|
"zhuanlan.zhihu.com",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert!(output.success);
|
||||||
|
let requests = host.requests();
|
||||||
|
assert_eq!(requests.len(), 2);
|
||||||
|
assert_eq!(requests[1].command, json!([
|
||||||
|
"http://127.0.0.1:17888/sgclaw/browser-helper.html",
|
||||||
|
"sgBroewserSimulateKeyborad",
|
||||||
|
160.0,
|
||||||
|
90.0,
|
||||||
|
"正文"
|
||||||
|
]));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn callback_backend_type_uses_custom_probe_script_when_provided() {
|
||||||
|
let host = Arc::new(FakeCallbackHost::new(vec![
|
||||||
|
success_reply(json!({ "probe": { "x": 160.0, "y": 90.0, "text": "正文" } })),
|
||||||
|
success_reply(json!({ "typed": true })),
|
||||||
|
]));
|
||||||
|
let backend = BrowserCallbackBackend::new(
|
||||||
|
host.clone(),
|
||||||
|
test_policy(),
|
||||||
|
"http://127.0.0.1:17888/sgclaw/browser-helper.html",
|
||||||
|
);
|
||||||
|
|
||||||
|
let output = backend
|
||||||
|
.invoke(
|
||||||
|
Action::Type,
|
||||||
|
json!({
|
||||||
|
"target_url": "https://zhuanlan.zhihu.com/write",
|
||||||
|
"probe_script": "return document.body;",
|
||||||
|
"text": "正文"
|
||||||
|
}),
|
||||||
|
"zhuanlan.zhihu.com",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert!(output.success);
|
||||||
|
let requests = host.requests();
|
||||||
|
assert_eq!(requests.len(), 2);
|
||||||
|
let script = requests[0].command[3].as_str().unwrap();
|
||||||
|
assert!(script.contains("return document.body;"));
|
||||||
|
assert!(!script.contains("selector not found: div[contenteditable='true']"));
|
||||||
|
assert_eq!(requests[1].command, json!([
|
||||||
|
"http://127.0.0.1:17888/sgclaw/browser-helper.html",
|
||||||
|
"sgBroewserSimulateKeyborad",
|
||||||
|
160.0,
|
||||||
|
90.0,
|
||||||
|
"正文"
|
||||||
|
]));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn callback_backend_type_uses_domain_probe_then_simulated_keyboard_input() {
|
||||||
|
let host = Arc::new(FakeCallbackHost::new(vec![
|
||||||
|
success_reply(json!({ "probe": { "x": 160.0, "y": 90.0, "text": "正文" } })),
|
||||||
|
success_reply(json!({ "typed": true })),
|
||||||
|
]));
|
||||||
|
let backend = BrowserCallbackBackend::new(
|
||||||
|
host.clone(),
|
||||||
|
test_policy(),
|
||||||
|
"http://127.0.0.1:17888/sgclaw/browser-helper.html",
|
||||||
|
);
|
||||||
|
|
||||||
|
let output = backend
|
||||||
|
.invoke(
|
||||||
|
Action::Type,
|
||||||
|
json!({
|
||||||
|
"target_url": "https://zhuanlan.zhihu.com/write",
|
||||||
|
"selector": "div[contenteditable='true']",
|
||||||
|
"text": "正文"
|
||||||
|
}),
|
||||||
|
"zhuanlan.zhihu.com",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert!(output.success);
|
||||||
|
let requests = host.requests();
|
||||||
|
assert_eq!(requests.len(), 2);
|
||||||
|
assert_eq!(requests[0].action, "type");
|
||||||
|
assert_eq!(requests[0].command[1], json!("sgBrowserExcuteJsCodeByDomain"));
|
||||||
|
assert_eq!(requests[0].command[2], json!("zhuanlan.zhihu.com"));
|
||||||
|
let script = requests[0].command[3].as_str().unwrap();
|
||||||
|
assert!(script.contains("document.querySelector('div[contenteditable=\\'true\\']')"));
|
||||||
|
assert!(script.contains("sgclawOnType"));
|
||||||
|
assert!(!script.contains("el.value="));
|
||||||
|
assert_eq!(requests[1].action, "type");
|
||||||
|
assert_eq!(requests[1].command, json!([
|
||||||
|
"http://127.0.0.1:17888/sgclaw/browser-helper.html",
|
||||||
|
"sgBroewserSimulateKeyborad",
|
||||||
|
160.0,
|
||||||
|
90.0,
|
||||||
|
"正文"
|
||||||
|
]));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn escape_js_single_quoted_escapes_newlines_and_control_chars() {
|
||||||
|
let raw = "第一行\n第二行\r\n第三行";
|
||||||
|
let escaped = escape_js_single_quoted(raw);
|
||||||
|
assert!(!escaped.contains('\n'), "literal newline must be escaped");
|
||||||
|
assert!(!escaped.contains('\r'), "literal carriage return must be escaped");
|
||||||
|
assert!(escaped.contains("\\n"), "should contain escaped newline");
|
||||||
|
assert!(escaped.contains("\\r"), "should contain escaped carriage return");
|
||||||
|
assert_eq!(escaped, "第一行\\n第二行\\r\\n第三行");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn type_probe_script_with_multiline_text_is_valid_js() {
|
||||||
|
let text_with_newlines = "标题\n\n正文第一段\n正文第二段";
|
||||||
|
let js = build_input_probe_js(
|
||||||
|
CallbackInputMode::Type,
|
||||||
|
"http://127.0.0.1:17888/sgclaw/browser-helper.html",
|
||||||
|
Some("div[contenteditable='true']"),
|
||||||
|
None,
|
||||||
|
Some(text_with_newlines),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
// The generated JS must NOT contain literal newlines inside single-quoted strings.
|
||||||
|
// Split on single quotes and check inner segments.
|
||||||
|
assert!(
|
||||||
|
!js.contains("标题\n"),
|
||||||
|
"literal newline must not appear in the JS probe script"
|
||||||
|
);
|
||||||
|
assert!(js.contains("标题\\n"));
|
||||||
|
assert!(js.contains("sgclawOnTypeProbe"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,6 +27,10 @@ const COMMAND_POLL_INTERVAL: Duration = Duration::from_millis(25);
|
|||||||
const HELPER_POLL_INTERVAL: Duration = Duration::from_millis(50);
|
const HELPER_POLL_INTERVAL: Duration = Duration::from_millis(50);
|
||||||
const HELPER_BOOTSTRAP_ACTION: &str = "sgBrowerserOpenPage";
|
const HELPER_BOOTSTRAP_ACTION: &str = "sgBrowerserOpenPage";
|
||||||
const NAVIGATE_CALLBACK_NAME: &str = "sgclawOnLoaded";
|
const NAVIGATE_CALLBACK_NAME: &str = "sgclawOnLoaded";
|
||||||
|
const CLICK_PROBE_CALLBACK_NAME: &str = "sgclawOnClickProbe";
|
||||||
|
const CLICK_CALLBACK_NAME: &str = "sgclawOnClick";
|
||||||
|
const TYPE_PROBE_CALLBACK_NAME: &str = "sgclawOnTypeProbe";
|
||||||
|
const TYPE_CALLBACK_NAME: &str = "sgclawOnType";
|
||||||
const GET_TEXT_CALLBACK_NAME: &str = "sgclawOnGetText";
|
const GET_TEXT_CALLBACK_NAME: &str = "sgclawOnGetText";
|
||||||
const EVAL_CALLBACK_NAME: &str = "sgclawOnEval";
|
const EVAL_CALLBACK_NAME: &str = "sgclawOnEval";
|
||||||
|
|
||||||
@@ -196,6 +200,15 @@ impl BrowserCallbackHost {
|
|||||||
pub(crate) fn acknowledge_in_flight_command(&self) -> Option<CallbackCommand> {
|
pub(crate) fn acknowledge_in_flight_command(&self) -> Option<CallbackCommand> {
|
||||||
self.state.lock().unwrap().in_flight_command.take()
|
self.state.lock().unwrap().in_flight_command.take()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Clear all pending state so the host can be reused for the next task
|
||||||
|
/// without reopening the helper page.
|
||||||
|
pub(crate) fn reset_pending_state(&self) {
|
||||||
|
let mut state = self.state.lock().unwrap();
|
||||||
|
state.pending_results.clear();
|
||||||
|
state.pending_commands.clear();
|
||||||
|
state.in_flight_command = None;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl LiveBrowserCallbackHost {
|
impl LiveBrowserCallbackHost {
|
||||||
@@ -241,6 +254,25 @@ impl LiveBrowserCallbackHost {
|
|||||||
pub(crate) fn helper_url(&self) -> &str {
|
pub(crate) fn helper_url(&self) -> &str {
|
||||||
self.host.helper_url()
|
self.host.helper_url()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) fn reset_pending_state(&self) {
|
||||||
|
self.host.reset_pending_state();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn command_is_fire_and_forget(request: &BrowserCallbackRequest) -> bool {
|
||||||
|
if request.action == "navigate" {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
request
|
||||||
|
.command
|
||||||
|
.as_array()
|
||||||
|
.and_then(|items| items.get(1))
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.is_some_and(|opcode| {
|
||||||
|
opcode == "sgBroewserSimulateMouse" || opcode == "sgBroewserSimulateKeyborad"
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
impl BrowserCallbackExecutor for LiveBrowserCallbackHost {
|
impl BrowserCallbackExecutor for LiveBrowserCallbackHost {
|
||||||
@@ -250,10 +282,11 @@ impl BrowserCallbackExecutor for LiveBrowserCallbackHost {
|
|||||||
self.host.enqueue_command(command_from_request(&request.command)?);
|
self.host.enqueue_command(command_from_request(&request.command)?);
|
||||||
|
|
||||||
// Navigate uses sgBrowerserOpenPage which opens a new tab without a JS
|
// Navigate uses sgBrowerserOpenPage which opens a new tab without a JS
|
||||||
// callback. We only wait long enough for the helper page to pick up the
|
// callback. Simulated mouse/keyboard follow-up commands also do not emit
|
||||||
// command via its 250 ms poll interval and forward it over WebSocket.
|
// a helper-page callback; the caller validates their effect with a later
|
||||||
// The caller (workflow executor) polls for page readiness separately.
|
// eval/get-text step. We only wait long enough for the helper page poller
|
||||||
let is_fire_and_forget = request.action == "navigate";
|
// to ACK and forward those commands.
|
||||||
|
let is_fire_and_forget = command_is_fire_and_forget(&request);
|
||||||
let timeout = if is_fire_and_forget {
|
let timeout = if is_fire_and_forget {
|
||||||
Duration::from_millis(1500)
|
Duration::from_millis(1500)
|
||||||
} else {
|
} else {
|
||||||
@@ -635,6 +668,33 @@ fn normalize_callback_result(
|
|||||||
timing: elapsed_timing(elapsed),
|
timing: elapsed_timing(elapsed),
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
"click" if result.callback == CLICK_PROBE_CALLBACK_NAME => {
|
||||||
|
let x = result.payload.get("x").and_then(Value::as_f64)?;
|
||||||
|
let y = result.payload.get("y").and_then(Value::as_f64)?;
|
||||||
|
Some(BrowserCallbackResponse::Success(BrowserCallbackSuccess {
|
||||||
|
success: true,
|
||||||
|
data: json!({
|
||||||
|
"probe": { "x": x, "y": y },
|
||||||
|
"callback": CLICK_CALLBACK_NAME,
|
||||||
|
}),
|
||||||
|
aom_snapshot: vec![],
|
||||||
|
timing: elapsed_timing(elapsed),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
"type" if result.callback == TYPE_PROBE_CALLBACK_NAME => {
|
||||||
|
let x = result.payload.get("x").and_then(Value::as_f64)?;
|
||||||
|
let y = result.payload.get("y").and_then(Value::as_f64)?;
|
||||||
|
let text = result.payload.get("text").and_then(Value::as_str).unwrap_or_default();
|
||||||
|
Some(BrowserCallbackResponse::Success(BrowserCallbackSuccess {
|
||||||
|
success: true,
|
||||||
|
data: json!({
|
||||||
|
"probe": { "x": x, "y": y, "text": text },
|
||||||
|
"callback": TYPE_CALLBACK_NAME,
|
||||||
|
}),
|
||||||
|
aom_snapshot: vec![],
|
||||||
|
timing: elapsed_timing(elapsed),
|
||||||
|
}))
|
||||||
|
}
|
||||||
// Path A: The browser's native callBackJsToCpp routes the callback to
|
// Path A: The browser's native callBackJsToCpp routes the callback to
|
||||||
// the helper page and calls sgclawOnGetText / sgclawOnEval directly.
|
// the helper page and calls sgclawOnGetText / sgclawOnEval directly.
|
||||||
// The helper page POSTs to the events endpoint with the callback name
|
// The helper page POSTs to the events endpoint with the callback name
|
||||||
@@ -661,7 +721,7 @@ fn normalize_callback_result(
|
|||||||
// callBackJsToCpp function with the @_@ delimited string. The helper
|
// callBackJsToCpp function with the @_@ delimited string. The helper
|
||||||
// page parses it and POSTs to the events endpoint with callback:
|
// page parses it and POSTs to the events endpoint with callback:
|
||||||
// "callBackJsToCpp" and payload: { raw: "..." }.
|
// "callBackJsToCpp" and payload: { raw: "..." }.
|
||||||
"getText" | "eval" if result.callback == "callBackJsToCpp" => {
|
"getText" | "eval" | "click" | "type" if result.callback == "callBackJsToCpp" => {
|
||||||
let raw = result.payload.get("raw").and_then(Value::as_str)?;
|
let raw = result.payload.get("raw").and_then(Value::as_str)?;
|
||||||
let parsed = match parse_callback_js_payload(raw) {
|
let parsed = match parse_callback_js_payload(raw) {
|
||||||
Ok(parsed) => parsed,
|
Ok(parsed) => parsed,
|
||||||
@@ -676,12 +736,46 @@ fn normalize_callback_result(
|
|||||||
if parsed.callback != expected_callback {
|
if parsed.callback != expected_callback {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
Some(BrowserCallbackResponse::Success(BrowserCallbackSuccess {
|
match request.action.as_str() {
|
||||||
success: true,
|
"click" => {
|
||||||
data: json!({ "text": parsed.response_text }),
|
let probe: Value = serde_json::from_str(&parsed.response_text).ok()?;
|
||||||
aom_snapshot: vec![],
|
let x = probe.get("x").and_then(Value::as_f64)?;
|
||||||
timing: elapsed_timing(elapsed),
|
let y = probe.get("y").and_then(Value::as_f64)?;
|
||||||
}))
|
Some(BrowserCallbackResponse::Success(BrowserCallbackSuccess {
|
||||||
|
success: true,
|
||||||
|
data: json!({
|
||||||
|
"probe": { "x": x, "y": y },
|
||||||
|
"callback": CLICK_CALLBACK_NAME,
|
||||||
|
}),
|
||||||
|
aom_snapshot: vec![],
|
||||||
|
timing: elapsed_timing(elapsed),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
"type" => {
|
||||||
|
let probe: Value = serde_json::from_str(&parsed.response_text).ok()?;
|
||||||
|
let x = probe.get("x").and_then(Value::as_f64)?;
|
||||||
|
let y = probe.get("y").and_then(Value::as_f64)?;
|
||||||
|
let text = probe.get("text").and_then(Value::as_str).unwrap_or_default();
|
||||||
|
Some(BrowserCallbackResponse::Success(BrowserCallbackSuccess {
|
||||||
|
success: true,
|
||||||
|
data: json!({
|
||||||
|
"probe": { "x": x, "y": y, "text": text },
|
||||||
|
"callback": TYPE_CALLBACK_NAME,
|
||||||
|
}),
|
||||||
|
aom_snapshot: vec![],
|
||||||
|
timing: elapsed_timing(elapsed),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
// getText / eval — return raw text
|
||||||
|
Some(BrowserCallbackResponse::Success(BrowserCallbackSuccess {
|
||||||
|
success: true,
|
||||||
|
data: json!({ "text": parsed.response_text }),
|
||||||
|
aom_snapshot: vec![],
|
||||||
|
timing: elapsed_timing(elapsed),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
_ => None,
|
_ => None,
|
||||||
}
|
}
|
||||||
@@ -713,6 +807,8 @@ fn parse_callback_js_payload(raw: &str) -> Result<ParsedCallbackJsPayload, Strin
|
|||||||
fn expected_callback_name(action: &str) -> Result<&'static str, PipeError> {
|
fn expected_callback_name(action: &str) -> Result<&'static str, PipeError> {
|
||||||
match action {
|
match action {
|
||||||
"navigate" => Ok(NAVIGATE_CALLBACK_NAME),
|
"navigate" => Ok(NAVIGATE_CALLBACK_NAME),
|
||||||
|
"click" => Ok(CLICK_PROBE_CALLBACK_NAME),
|
||||||
|
"type" => Ok(TYPE_PROBE_CALLBACK_NAME),
|
||||||
"getText" => Ok(GET_TEXT_CALLBACK_NAME),
|
"getText" => Ok(GET_TEXT_CALLBACK_NAME),
|
||||||
"eval" => Ok(EVAL_CALLBACK_NAME),
|
"eval" => Ok(EVAL_CALLBACK_NAME),
|
||||||
other => Err(PipeError::Protocol(format!(
|
other => Err(PipeError::Protocol(format!(
|
||||||
@@ -731,12 +827,28 @@ fn elapsed_timing(elapsed: Duration) -> Timing {
|
|||||||
fn build_helper_page_html(loopback_origin: &str, helper_url: &str, browser_ws_url: &str) -> String {
|
fn build_helper_page_html(loopback_origin: &str, helper_url: &str, browser_ws_url: &str) -> String {
|
||||||
format!(
|
format!(
|
||||||
r#"<!doctype html>
|
r#"<!doctype html>
|
||||||
<html>
|
<html><head><meta charset="utf-8"/><title>sgClaw · Runtime Console</title>
|
||||||
<head>
|
<style>
|
||||||
<meta charset=\"utf-8\" />
|
*{{margin:0;padding:0;box-sizing:border-box}}
|
||||||
<title>sgClaw Browser Helper</title>
|
body{{background:#0b1120;color:#e2e8f0;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',system-ui,sans-serif;height:100vh;overflow:hidden;display:flex;flex-direction:column}}
|
||||||
</head>
|
.hd{{padding:20px 24px;border-bottom:1px solid #1e293b;display:flex;align-items:center;gap:16px}}
|
||||||
|
.logo{{width:36px;height:36px;background:linear-gradient(135deg,#3b82f6,#06b6d4);border-radius:8px;display:flex;align-items:center;justify-content:center;font-weight:700;font-size:16px;color:#fff;flex-shrink:0}}
|
||||||
|
.ti{{flex:1}}.ti h1{{font-size:16px;font-weight:600;color:#f1f5f9}}.ti p{{font-size:12px;color:#64748b;margin-top:2px}}
|
||||||
|
.sb{{display:flex;align-items:center;gap:6px;font-size:12px;color:#94a3b8}}
|
||||||
|
.sd{{width:8px;height:8px;border-radius:50%;background:#475569}}.sd.on{{background:#22c55e;box-shadow:0 0 8px rgba(34,197,94,.5);animation:pulse 2s ease-in-out infinite}}
|
||||||
|
@keyframes pulse{{0%,100%{{opacity:1}}50%{{opacity:.5}}}}
|
||||||
|
.stats{{padding:14px 24px;display:flex;gap:32px;border-bottom:1px solid #1e293b}}.st{{display:flex;flex-direction:column;gap:2px}}.st .l{{font-size:11px;color:#64748b;text-transform:uppercase;letter-spacing:.5px}}.st .v{{font-size:20px;font-weight:600;color:#e2e8f0;font-variant-numeric:tabular-nums}}
|
||||||
|
.tb{{padding:12px 24px;background:#111827;border-bottom:1px solid #1e293b;display:flex;align-items:center;gap:12px}}.sp{{width:16px;height:16px;border:2px solid #1e293b;border-top-color:#3b82f6;border-radius:50%;animation:spin 1s linear infinite}}@keyframes spin{{to{{transform:rotate(360deg)}}}}.tb .tl{{font-size:12px;color:#64748b}}.tb .tt{{font-size:14px;color:#e2e8f0}}
|
||||||
|
.log{{flex:1;padding:16px 24px;overflow-y:auto;font-family:'Cascadia Code','Fira Code',Consolas,monospace;font-size:13px;line-height:1.7}}.le{{color:#94a3b8;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}}.le .t{{color:#475569}}.le .a{{color:#3b82f6}}.le .u{{color:#06b6d4}}.le .ok{{color:#22c55e}}.le .er{{color:#ef4444}}
|
||||||
|
.ft{{padding:12px 24px;border-top:1px solid #1e293b;font-size:11px;color:#475569;display:flex;justify-content:space-between}}
|
||||||
|
::-webkit-scrollbar{{width:6px}}::-webkit-scrollbar-track{{background:transparent}}::-webkit-scrollbar-thumb{{background:#1e293b;border-radius:3px}}
|
||||||
|
</style></head>
|
||||||
<body>
|
<body>
|
||||||
|
<div class="hd"><div class="logo">sg</div><div class="ti"><h1>sgClaw · Runtime Console</h1><p>Browser Automation Agent</p></div><div class="sb"><div class="sd" id="sd"></div><span id="stx">Connecting…</span></div></div>
|
||||||
|
<div class="stats"><div class="st"><div class="l">Commands</div><div class="v" id="nc">0</div></div><div class="st"><div class="l">Callbacks</div><div class="v" id="nb">0</div></div><div class="st"><div class="l">Uptime</div><div class="v" id="ut">0s</div></div></div>
|
||||||
|
<div class="tb" id="tb"><div class="sp"></div><div><div class="tl">Current</div><div class="tt" id="tt">Initializing…</div></div></div>
|
||||||
|
<div class="log" id="lg"></div>
|
||||||
|
<div class="ft"><span>sgClaw v0.1 · Browser Callback Host</span><span id="wi"></span></div>
|
||||||
<script>
|
<script>
|
||||||
const SGCLAW_LOOPBACK_ORIGIN = {loopback_origin:?};
|
const SGCLAW_LOOPBACK_ORIGIN = {loopback_origin:?};
|
||||||
const SGCLAW_HELPER_URL = {helper_url:?};
|
const SGCLAW_HELPER_URL = {helper_url:?};
|
||||||
@@ -746,6 +858,13 @@ const SGCLAW_EVENTS_ENDPOINT = `${{SGCLAW_LOOPBACK_ORIGIN}}{EVENTS_ENDPOINT_PATH
|
|||||||
const SGCLAW_COMMANDS_ENDPOINT = `${{SGCLAW_LOOPBACK_ORIGIN}}{COMMANDS_ENDPOINT_PATH}`;
|
const SGCLAW_COMMANDS_ENDPOINT = `${{SGCLAW_LOOPBACK_ORIGIN}}{COMMANDS_ENDPOINT_PATH}`;
|
||||||
const SGCLAW_COMMAND_ACK_ENDPOINT = `${{SGCLAW_LOOPBACK_ORIGIN}}{COMMAND_ACK_ENDPOINT_PATH}`;
|
const SGCLAW_COMMAND_ACK_ENDPOINT = `${{SGCLAW_LOOPBACK_ORIGIN}}{COMMAND_ACK_ENDPOINT_PATH}`;
|
||||||
|
|
||||||
|
var _nc=0,_nb=0,_t0=Date.now(),_lastCmd=0,_idle=true;
|
||||||
|
function _log(msg){{var d=new Date();var ts=[d.getHours(),d.getMinutes(),d.getSeconds()].map(function(v){{return v<10?'0'+v:v;}}).join(':');var el=document.getElementById('lg');var r=document.createElement('div');r.className='le';r.innerHTML='<span class="t">'+ts+'</span> '+msg;el.appendChild(r);el.scrollTop=el.scrollHeight;if(el.children.length>200)el.removeChild(el.children[0]);}}
|
||||||
|
function _task(t){{document.getElementById('tt').textContent=t;}}
|
||||||
|
function _setIdle(v){{if(_idle===v)return;_idle=v;var sp=document.querySelector('.sp');var tl=document.querySelector('.tb .tl');var tt=document.getElementById('tt');if(v){{sp.style.borderTopColor='#22c55e';sp.style.animation='none';tl.textContent='Status';tt.textContent='Ready \u2014 waiting for commands';tt.style.color='#22c55e';}}else{{sp.style.borderTopColor='';sp.style.animation='';tl.textContent='Current';tt.style.color='';}}}}
|
||||||
|
function _stat(){{document.getElementById('nc').textContent=_nc;document.getElementById('nb').textContent=_nb;var s=Math.floor((Date.now()-_t0)/1000);var m=Math.floor(s/60);s=s%60;document.getElementById('ut').textContent=m>0?m+'m '+s+'s':s+'s';if(!_idle&&_lastCmd>0&&Date.now()-_lastCmd>3000)_setIdle(true);}}
|
||||||
|
setInterval(_stat,1000);
|
||||||
|
|
||||||
async function sgclawPostJson(url, body) {{
|
async function sgclawPostJson(url, body) {{
|
||||||
await fetch(url, {{
|
await fetch(url, {{
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -762,6 +881,7 @@ async function sgclawReady() {{
|
|||||||
}}
|
}}
|
||||||
|
|
||||||
async function sgclawEmitCallback(callback, payload, extra) {{
|
async function sgclawEmitCallback(callback, payload, extra) {{
|
||||||
|
_nb++;_lastCmd=Date.now();_log('<span class="ok">\u2190</span> callback <span class="a">'+callback+'</span>');
|
||||||
await sgclawPostJson(SGCLAW_EVENTS_ENDPOINT, Object.assign({{
|
await sgclawPostJson(SGCLAW_EVENTS_ENDPOINT, Object.assign({{
|
||||||
type: 'callback',
|
type: 'callback',
|
||||||
callback,
|
callback,
|
||||||
@@ -771,14 +891,32 @@ async function sgclawEmitCallback(callback, payload, extra) {{
|
|||||||
}}
|
}}
|
||||||
|
|
||||||
function sgclawOnLoaded(targetUrl) {{
|
function sgclawOnLoaded(targetUrl) {{
|
||||||
|
_task('Page loaded');
|
||||||
return sgclawEmitCallback('sgclawOnLoaded', {{ loaded: true }}, {{ target_url: targetUrl || null }});
|
return sgclawEmitCallback('sgclawOnLoaded', {{ loaded: true }}, {{ target_url: targetUrl || null }});
|
||||||
}}
|
}}
|
||||||
|
|
||||||
|
function sgclawOnClickProbe(x, y) {{
|
||||||
|
return sgclawEmitCallback('sgclawOnClickProbe', {{ x: Number(x) || 0, y: Number(y) || 0 }});
|
||||||
|
}}
|
||||||
|
|
||||||
|
function sgclawOnClick() {{
|
||||||
|
return sgclawEmitCallback('sgclawOnClick', {{ clicked: true }});
|
||||||
|
}}
|
||||||
|
|
||||||
|
function sgclawOnTypeProbe(x, y, text) {{
|
||||||
|
return sgclawEmitCallback('sgclawOnTypeProbe', {{ x: Number(x) || 0, y: Number(y) || 0, text: text ?? '' }});
|
||||||
|
}}
|
||||||
|
|
||||||
|
function sgclawOnType() {{
|
||||||
|
return sgclawEmitCallback('sgclawOnType', {{ typed: true }});
|
||||||
|
}}
|
||||||
|
|
||||||
function sgclawOnGetText(text, targetUrl) {{
|
function sgclawOnGetText(text, targetUrl) {{
|
||||||
return sgclawEmitCallback('sgclawOnGetText', {{ text: text ?? null }}, {{ target_url: targetUrl || null }});
|
return sgclawEmitCallback('sgclawOnGetText', {{ text: text ?? null }}, {{ target_url: targetUrl || null }});
|
||||||
}}
|
}}
|
||||||
|
|
||||||
function sgclawOnEval(value, targetUrl) {{
|
function sgclawOnEval(value, targetUrl) {{
|
||||||
|
_task('Eval complete');
|
||||||
return sgclawEmitCallback('sgclawOnEval', {{ value: value ?? null }}, {{ target_url: targetUrl || null }});
|
return sgclawEmitCallback('sgclawOnEval', {{ value: value ?? null }}, {{ target_url: targetUrl || null }});
|
||||||
}}
|
}}
|
||||||
|
|
||||||
@@ -791,20 +929,38 @@ function callBackJsToCpp(param) {{
|
|||||||
}}
|
}}
|
||||||
|
|
||||||
window.sgclawOnLoaded = sgclawOnLoaded;
|
window.sgclawOnLoaded = sgclawOnLoaded;
|
||||||
|
window.sgclawOnClickProbe = sgclawOnClickProbe;
|
||||||
|
window.sgclawOnClick = sgclawOnClick;
|
||||||
|
window.sgclawOnTypeProbe = sgclawOnTypeProbe;
|
||||||
|
window.sgclawOnType = sgclawOnType;
|
||||||
window.sgclawOnGetText = sgclawOnGetText;
|
window.sgclawOnGetText = sgclawOnGetText;
|
||||||
window.sgclawOnEval = sgclawOnEval;
|
window.sgclawOnEval = sgclawOnEval;
|
||||||
window.callBackJsToCpp = callBackJsToCpp;
|
window.callBackJsToCpp = callBackJsToCpp;
|
||||||
|
|
||||||
|
document.getElementById('wi').textContent = SGCLAW_BROWSER_WS_URL;
|
||||||
|
_log('Connecting to browser WebSocket\u2026');
|
||||||
|
|
||||||
const sgclawSocket = new WebSocket(SGCLAW_BROWSER_WS_URL);
|
const sgclawSocket = new WebSocket(SGCLAW_BROWSER_WS_URL);
|
||||||
sgclawSocket.addEventListener('open', async () => {{
|
sgclawSocket.addEventListener('open', async () => {{
|
||||||
|
document.getElementById('sd').classList.add('on');
|
||||||
|
document.getElementById('stx').textContent = 'Connected';
|
||||||
|
_log('<span class="ok">\u2713</span> WebSocket connected');
|
||||||
|
_task('Connected to browser');
|
||||||
sgclawSocket.send(JSON.stringify({{ type: 'register', role: 'web' }}));
|
sgclawSocket.send(JSON.stringify({{ type: 'register', role: 'web' }}));
|
||||||
await sgclawReady();
|
await sgclawReady();
|
||||||
|
_log('<span class="ok">\u2713</span> Ready signal sent');
|
||||||
|
_task('Ready \u2014 waiting for commands');
|
||||||
|
}});
|
||||||
|
|
||||||
|
sgclawSocket.addEventListener('close', () => {{
|
||||||
|
document.getElementById('sd').classList.remove('on');
|
||||||
|
document.getElementById('stx').textContent = 'Disconnected';
|
||||||
|
_log('<span class="er">\u2717</span> WebSocket disconnected');
|
||||||
|
_task('Disconnected');
|
||||||
}});
|
}});
|
||||||
|
|
||||||
sgclawSocket.addEventListener('message', (event) => {{
|
sgclawSocket.addEventListener('message', (event) => {{
|
||||||
console.debug('sgclaw helper received browser frame', event.data);
|
console.debug('sgclaw helper received browser frame', event.data);
|
||||||
// If the browser routes a callBackJsToCpp result back over WebSocket,
|
|
||||||
// parse it and forward to the callback host events endpoint.
|
|
||||||
try {{
|
try {{
|
||||||
var data = String(event.data || '');
|
var data = String(event.data || '');
|
||||||
if (data.indexOf('@_@') !== -1) {{
|
if (data.indexOf('@_@') !== -1) {{
|
||||||
@@ -824,7 +980,11 @@ async function sgclawPollCommands() {{
|
|||||||
if (!command || !command.action) {{
|
if (!command || !command.action) {{
|
||||||
return;
|
return;
|
||||||
}}
|
}}
|
||||||
|
_nc++;
|
||||||
const args = Array.isArray(command.args) ? command.args : [];
|
const args = Array.isArray(command.args) ? command.args : [];
|
||||||
|
_lastCmd=Date.now();_setIdle(false);
|
||||||
|
_log('<span class="a">\u2192</span> execute <span class="a">'+command.action+'</span>'+(args.length>1?' <span class="u">'+String(args[1]||'').substring(0,50)+'</span>':''));
|
||||||
|
_task('Executing: '+command.action);
|
||||||
if (sgclawSocket.readyState !== WebSocket.OPEN) {{
|
if (sgclawSocket.readyState !== WebSocket.OPEN) {{
|
||||||
return;
|
return;
|
||||||
}}
|
}}
|
||||||
@@ -835,6 +995,7 @@ async function sgclawPollCommands() {{
|
|||||||
}}
|
}}
|
||||||
|
|
||||||
setInterval(sgclawPollCommands, 250);
|
setInterval(sgclawPollCommands, 250);
|
||||||
|
_log('sgClaw Runtime Console initialized');
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
@@ -945,6 +1106,40 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn live_callback_host_treats_simulated_mouse_command_as_fire_and_forget() {
|
||||||
|
use crate::browser::callback_backend::{
|
||||||
|
BrowserCallbackHost as BrowserCallbackExecutor, BrowserCallbackRequest,
|
||||||
|
};
|
||||||
|
use std::sync::atomic::AtomicBool;
|
||||||
|
|
||||||
|
let host = LiveBrowserCallbackHost {
|
||||||
|
host: Arc::new(BrowserCallbackHost::new()),
|
||||||
|
shutdown: Arc::new(AtomicBool::new(false)),
|
||||||
|
server_thread: Mutex::new(None),
|
||||||
|
command_lock: Mutex::new(()),
|
||||||
|
result_timeout: Duration::from_millis(10),
|
||||||
|
};
|
||||||
|
|
||||||
|
let response = host.execute(BrowserCallbackRequest {
|
||||||
|
seq: 1,
|
||||||
|
request_url: "http://127.0.0.1:17888/sgclaw/browser-helper.html".to_string(),
|
||||||
|
expected_domain: "zhuanlan.zhihu.com".to_string(),
|
||||||
|
action: "click".to_string(),
|
||||||
|
command: json!([
|
||||||
|
"http://127.0.0.1:17888/sgclaw/browser-helper.html",
|
||||||
|
"sgBroewserSimulateMouse",
|
||||||
|
320.5,
|
||||||
|
240.25,
|
||||||
|
"left",
|
||||||
|
"",
|
||||||
|
""
|
||||||
|
]),
|
||||||
|
});
|
||||||
|
|
||||||
|
assert!(response.is_ok(), "simulated mouse follow-up should not wait for a callback");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn callback_host_exposes_loopback_helper_url_and_release_helper_html() {
|
fn callback_host_exposes_loopback_helper_url_and_release_helper_html() {
|
||||||
let host = BrowserCallbackHost::new();
|
let host = BrowserCallbackHost::new();
|
||||||
@@ -959,6 +1154,10 @@ mod tests {
|
|||||||
assert!(html.contains(r#"JSON.stringify({ type: 'register', role: 'web' })"#));
|
assert!(html.contains(r#"JSON.stringify({ type: 'register', role: 'web' })"#));
|
||||||
assert!(html.contains("sgclawReady"));
|
assert!(html.contains("sgclawReady"));
|
||||||
assert!(html.contains("sgclawOnLoaded"));
|
assert!(html.contains("sgclawOnLoaded"));
|
||||||
|
assert!(html.contains("sgclawOnClickProbe"));
|
||||||
|
assert!(html.contains("sgclawOnClick"));
|
||||||
|
assert!(html.contains("sgclawOnTypeProbe"));
|
||||||
|
assert!(html.contains("sgclawOnType"));
|
||||||
assert!(html.contains("sgclawOnGetText"));
|
assert!(html.contains("sgclawOnGetText"));
|
||||||
assert!(html.contains("sgclawOnEval"));
|
assert!(html.contains("sgclawOnEval"));
|
||||||
assert!(html.contains("/sgclaw/callback/ready"));
|
assert!(html.contains("/sgclaw/callback/ready"));
|
||||||
@@ -1102,4 +1301,107 @@ mod tests {
|
|||||||
);
|
);
|
||||||
assert!(host.acknowledge_in_flight_command().is_none());
|
assert!(host.acknowledge_in_flight_command().is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Path B callBackJsToCpp normalization tests ────────────────────
|
||||||
|
|
||||||
|
use super::normalize_callback_result;
|
||||||
|
use crate::browser::callback_backend::BrowserCallbackRequest;
|
||||||
|
|
||||||
|
fn make_request(action: &str) -> BrowserCallbackRequest {
|
||||||
|
BrowserCallbackRequest {
|
||||||
|
seq: 1,
|
||||||
|
request_url: "http://127.0.0.1:17888/sgclaw/browser-helper.html".to_string(),
|
||||||
|
expected_domain: "zhuanlan.zhihu.com".to_string(),
|
||||||
|
action: action.to_string(),
|
||||||
|
command: json!([
|
||||||
|
"http://127.0.0.1:17888/sgclaw/browser-helper.html",
|
||||||
|
"sgBrowserExcuteJsCodeByDomain",
|
||||||
|
"zhuanlan.zhihu.com",
|
||||||
|
"(function(){ /* probe */ })()"
|
||||||
|
]),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn make_callback_js_to_cpp_result(raw: &str) -> CallbackResult {
|
||||||
|
CallbackResult {
|
||||||
|
callback: "callBackJsToCpp".to_string(),
|
||||||
|
request_url: "http://127.0.0.1:17888/sgclaw/browser-helper.html".to_string(),
|
||||||
|
target_url: Some("https://zhuanlan.zhihu.com/write".to_string()),
|
||||||
|
action: Some("sgBrowserExcuteJsCodeByDomain".to_string()),
|
||||||
|
payload: json!({ "raw": raw }),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn normalize_callback_result_path_b_click_probe() {
|
||||||
|
let request = make_request("click");
|
||||||
|
let raw = "https://zhuanlan.zhihu.com/write@_@https://zhuanlan.zhihu.com/write@_@sgclawOnClickProbe@_@sgBrowserExcuteJsCodeByDomain@_@{\"x\":320.5,\"y\":240.25}";
|
||||||
|
let result = make_callback_js_to_cpp_result(raw);
|
||||||
|
|
||||||
|
let response = normalize_callback_result(&request, result, Duration::from_millis(10));
|
||||||
|
assert!(response.is_some(), "Path B click should produce a response");
|
||||||
|
match response.unwrap() {
|
||||||
|
super::super::callback_backend::BrowserCallbackResponse::Success(s) => {
|
||||||
|
let probe = s.data.get("probe").expect("should have probe");
|
||||||
|
assert_eq!(probe.get("x").unwrap().as_f64().unwrap(), 320.5);
|
||||||
|
assert_eq!(probe.get("y").unwrap().as_f64().unwrap(), 240.25);
|
||||||
|
assert_eq!(
|
||||||
|
s.data.get("callback").unwrap().as_str().unwrap(),
|
||||||
|
"sgclawOnClick"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
other => panic!("expected Success, got {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn normalize_callback_result_path_b_type_probe() {
|
||||||
|
let request = make_request("type");
|
||||||
|
let raw = "https://zhuanlan.zhihu.com/write@_@https://zhuanlan.zhihu.com/write@_@sgclawOnTypeProbe@_@sgBrowserExcuteJsCodeByDomain@_@{\"x\":100,\"y\":200,\"text\":\"hello\"}";
|
||||||
|
let result = make_callback_js_to_cpp_result(raw);
|
||||||
|
|
||||||
|
let response = normalize_callback_result(&request, result, Duration::from_millis(10));
|
||||||
|
assert!(response.is_some(), "Path B type should produce a response");
|
||||||
|
match response.unwrap() {
|
||||||
|
super::super::callback_backend::BrowserCallbackResponse::Success(s) => {
|
||||||
|
let probe = s.data.get("probe").expect("should have probe");
|
||||||
|
assert_eq!(probe.get("x").unwrap().as_f64().unwrap(), 100.0);
|
||||||
|
assert_eq!(probe.get("y").unwrap().as_f64().unwrap(), 200.0);
|
||||||
|
assert_eq!(probe.get("text").unwrap().as_str().unwrap(), "hello");
|
||||||
|
assert_eq!(
|
||||||
|
s.data.get("callback").unwrap().as_str().unwrap(),
|
||||||
|
"sgclawOnType"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
other => panic!("expected Success, got {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn normalize_callback_result_path_b_click_wrong_callback_returns_none() {
|
||||||
|
let request = make_request("click");
|
||||||
|
// callback name is sgclawOnTypeProbe (wrong for click action)
|
||||||
|
let raw = "https://zhuanlan.zhihu.com/write@_@https://zhuanlan.zhihu.com/write@_@sgclawOnTypeProbe@_@sgBrowserExcuteJsCodeByDomain@_@{\"x\":1,\"y\":2}";
|
||||||
|
let result = make_callback_js_to_cpp_result(raw);
|
||||||
|
|
||||||
|
let response = normalize_callback_result(&request, result, Duration::from_millis(10));
|
||||||
|
assert!(response.is_none(), "mismatched callback name should return None");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn normalize_callback_result_path_b_eval_still_works() {
|
||||||
|
let request = make_request("eval");
|
||||||
|
let raw = "https://zhuanlan.zhihu.com/write@_@https://zhuanlan.zhihu.com/write@_@sgclawOnEval@_@sgBrowserExcuteJsCodeByDomain@_@{\"status\":\"ok\"}";
|
||||||
|
let result = make_callback_js_to_cpp_result(raw);
|
||||||
|
|
||||||
|
let response = normalize_callback_result(&request, result, Duration::from_millis(10));
|
||||||
|
assert!(response.is_some(), "Path B eval should still work");
|
||||||
|
match response.unwrap() {
|
||||||
|
super::super::callback_backend::BrowserCallbackResponse::Success(s) => {
|
||||||
|
let text = s.data.get("text").unwrap().as_str().unwrap();
|
||||||
|
assert_eq!(text, r#"{"status":"ok"}"#);
|
||||||
|
}
|
||||||
|
other => panic!("expected Success, got {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ pub fn execute_task_with_browser_backend(
|
|||||||
instruction,
|
instruction,
|
||||||
task_context,
|
task_context,
|
||||||
route,
|
route,
|
||||||
|
settings,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -75,6 +76,7 @@ pub fn execute_task_with_browser_backend(
|
|||||||
instruction,
|
instruction,
|
||||||
task_context,
|
task_context,
|
||||||
route,
|
route,
|
||||||
|
settings,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
(_, Ok(summary)) => Ok(summary),
|
(_, Ok(summary)) => Ok(summary),
|
||||||
@@ -85,6 +87,7 @@ pub fn execute_task_with_browser_backend(
|
|||||||
instruction,
|
instruction,
|
||||||
task_context,
|
task_context,
|
||||||
route,
|
route,
|
||||||
|
settings,
|
||||||
),
|
),
|
||||||
(None, Err(err)) => Err(err),
|
(None, Err(err)) => Err(err),
|
||||||
}
|
}
|
||||||
@@ -112,6 +115,7 @@ pub fn execute_task_with_sgclaw_settings<T: Transport + 'static>(
|
|||||||
instruction,
|
instruction,
|
||||||
task_context,
|
task_context,
|
||||||
route,
|
route,
|
||||||
|
settings,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -137,6 +141,7 @@ pub fn execute_task_with_sgclaw_settings<T: Transport + 'static>(
|
|||||||
instruction,
|
instruction,
|
||||||
task_context,
|
task_context,
|
||||||
route,
|
route,
|
||||||
|
settings,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
(_, Ok(summary)) => Ok(summary),
|
(_, Ok(summary)) => Ok(summary),
|
||||||
@@ -147,6 +152,7 @@ pub fn execute_task_with_sgclaw_settings<T: Transport + 'static>(
|
|||||||
instruction,
|
instruction,
|
||||||
task_context,
|
task_context,
|
||||||
route,
|
route,
|
||||||
|
settings,
|
||||||
),
|
),
|
||||||
(None, Err(err)) => Err(err),
|
(None, Err(err)) => Err(err),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ use crate::compat::config_adapter::{
|
|||||||
build_zeroclaw_config_from_sgclaw_settings, resolve_skills_dir_from_sgclaw_settings,
|
build_zeroclaw_config_from_sgclaw_settings, resolve_skills_dir_from_sgclaw_settings,
|
||||||
};
|
};
|
||||||
use crate::compat::event_bridge::log_entry_for_turn_event;
|
use crate::compat::event_bridge::log_entry_for_turn_event;
|
||||||
|
use crate::compat::workflow_executor::parse_generated_article_draft;
|
||||||
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::config::{DeepSeekSettings, OfficeBackend, SgClawSettings};
|
use crate::config::{DeepSeekSettings, OfficeBackend, SgClawSettings};
|
||||||
@@ -101,6 +102,43 @@ pub fn execute_task_with_sgclaw_settings<T: Transport + 'static>(
|
|||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) fn generate_zhihu_article_draft(
|
||||||
|
instruction: &str,
|
||||||
|
topic: &str,
|
||||||
|
_task_context: &CompatTaskContext,
|
||||||
|
workspace_root: &Path,
|
||||||
|
settings: &SgClawSettings,
|
||||||
|
) -> Result<crate::compat::workflow_executor::ArticleDraft, PipeError> {
|
||||||
|
let mut generation_settings = settings.clone();
|
||||||
|
generation_settings.runtime_profile = crate::runtime::RuntimeProfile::GeneralAssistant;
|
||||||
|
|
||||||
|
let config = build_zeroclaw_config_from_sgclaw_settings(workspace_root, &generation_settings);
|
||||||
|
let provider = build_provider(&config)?;
|
||||||
|
let runtime = tokio::runtime::Runtime::new()
|
||||||
|
.map_err(|err| PipeError::Protocol(format!("failed to create tokio runtime: {err}")))?;
|
||||||
|
let generation_prompt = format!(
|
||||||
|
"为知乎文章生成可直接发布的草稿。用户原始请求:{instruction}\n\n主题:{topic}\n\n请严格只输出以下格式,不要添加解释、前言、代码块或其他内容:\n标题:<简洁具体的中文标题>\n正文:<适合知乎发布的中文正文,使用自然段>"
|
||||||
|
);
|
||||||
|
|
||||||
|
let generated = runtime.block_on(async move {
|
||||||
|
provider
|
||||||
|
.chat_with_system(
|
||||||
|
Some("You write concise Chinese Zhihu article drafts. Return only the requested title/body format."),
|
||||||
|
&generation_prompt,
|
||||||
|
config.default_model.as_deref().unwrap_or("deepseek-chat"),
|
||||||
|
config.default_temperature,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(map_anyhow_to_pipe_error)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
parse_generated_article_draft(&generated).ok_or_else(|| {
|
||||||
|
PipeError::Protocol(format!(
|
||||||
|
"generated Zhihu article draft did not match 标题/正文 format: {generated}"
|
||||||
|
))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn execute_task_with_provider(
|
pub async fn execute_task_with_provider(
|
||||||
transport: &dyn crate::agent::AgentEventSink,
|
transport: &dyn crate::agent::AgentEventSink,
|
||||||
browser_backend: Arc<dyn BrowserBackend>,
|
browser_backend: Arc<dyn BrowserBackend>,
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -276,13 +276,16 @@ pub fn is_zhihu_hotlist_task(
|
|||||||
|| normalized_url.contains("zhihu.com")
|
|| normalized_url.contains("zhihu.com")
|
||||||
|| normalized_title.contains("zhihu")
|
|| normalized_title.contains("zhihu")
|
||||||
|| page_title.unwrap_or_default().contains("知乎");
|
|| page_title.unwrap_or_default().contains("知乎");
|
||||||
let is_hotlist = normalized_instruction.contains("hotlist")
|
let hotlist_in_instruction = normalized_instruction.contains("hotlist")
|
||||||
|| instruction.contains("热榜")
|
|| instruction.contains("热榜");
|
||||||
|| normalized_url.contains("/hot")
|
let hotlist_in_context = normalized_url.contains("/hot")
|
||||||
|| normalized_title.contains("hotlist")
|
|| normalized_title.contains("hotlist")
|
||||||
|| page_title.unwrap_or_default().contains("热榜");
|
|| page_title.unwrap_or_default().contains("热榜");
|
||||||
|
|
||||||
is_zhihu && is_hotlist
|
// "热榜"/"hotlist" directly in the instruction implies Zhihu (the only
|
||||||
|
// hotlist feature sgClaw supports). Context-only signals (URL/title)
|
||||||
|
// still require the "知乎" qualifier to avoid false positives.
|
||||||
|
(is_zhihu && (hotlist_in_instruction || hotlist_in_context)) || hotlist_in_instruction
|
||||||
}
|
}
|
||||||
|
|
||||||
fn task_needs_office_export(instruction: &str) -> bool {
|
fn task_needs_office_export(instruction: &str) -> bool {
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ use tungstenite::stream::MaybeTlsStream;
|
|||||||
use tungstenite::{connect, Message, WebSocket};
|
use tungstenite::{connect, Message, WebSocket};
|
||||||
|
|
||||||
use crate::agent::{
|
use crate::agent::{
|
||||||
run_submit_task_with_browser_backend, AgentEventSink, AgentRuntimeContext, SubmitTaskRequest,
|
run_submit_task_with_browser_backend, AgentEventSink, AgentRuntimeContext,
|
||||||
};
|
};
|
||||||
use crate::browser::callback_host::LiveBrowserCallbackHost;
|
use crate::browser::callback_host::LiveBrowserCallbackHost;
|
||||||
use crate::browser::ws_backend::WsClient;
|
use crate::browser::ws_backend::WsClient;
|
||||||
@@ -236,6 +236,10 @@ pub fn serve_client(
|
|||||||
browser_ws_url: &str,
|
browser_ws_url: &str,
|
||||||
mac_policy: &MacPolicy,
|
mac_policy: &MacPolicy,
|
||||||
) -> Result<(), PipeError> {
|
) -> Result<(), PipeError> {
|
||||||
|
// Cache the browser callback host across tasks so the helper page tab is
|
||||||
|
// opened only once per client session instead of once per task.
|
||||||
|
let mut cached_host: Option<Arc<LiveBrowserCallbackHost>> = None;
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
let Some(message) = sink.recv_client_message()? else {
|
let Some(message) = sink.recv_client_message()? else {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
@@ -276,16 +280,48 @@ pub fn serve_client(
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
let result = (|| {
|
// Lazily create and cache the browser callback host. On first
|
||||||
let browser_backend = browser_backend_for_submit(browser_ws_url, mac_policy, &request)?;
|
// task it opens the helper page; subsequent tasks reuse it.
|
||||||
run_submit_task_with_browser_backend(
|
if cached_host.is_none() {
|
||||||
&NoopTransport,
|
let bootstrap_url = initial_request_url_for_submit_task(&request);
|
||||||
sink.as_ref(),
|
match LiveBrowserCallbackHost::start_with_browser_ws_url(
|
||||||
browser_backend,
|
browser_ws_url,
|
||||||
context,
|
&bootstrap_url,
|
||||||
request,
|
Duration::from_secs(15),
|
||||||
)
|
BROWSER_RESPONSE_TIMEOUT,
|
||||||
})();
|
) {
|
||||||
|
Ok(host) => {
|
||||||
|
cached_host = Some(Arc::new(host));
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
session.finish_task();
|
||||||
|
eprintln!("task execution failed: {err}");
|
||||||
|
sink.send(&AgentMessage::TaskComplete {
|
||||||
|
success: false,
|
||||||
|
summary: format!("任务执行失败: {err}"),
|
||||||
|
})?;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
cached_host.as_ref().unwrap().reset_pending_state();
|
||||||
|
}
|
||||||
|
|
||||||
|
let host = cached_host.as_ref().unwrap();
|
||||||
|
let browser_backend: Arc<dyn BrowserBackend> =
|
||||||
|
Arc::new(BrowserCallbackBackend::new(
|
||||||
|
host.clone(),
|
||||||
|
mac_policy.clone(),
|
||||||
|
host.helper_url().to_string(),
|
||||||
|
));
|
||||||
|
|
||||||
|
let result = run_submit_task_with_browser_backend(
|
||||||
|
&NoopTransport,
|
||||||
|
sink.as_ref(),
|
||||||
|
browser_backend,
|
||||||
|
context,
|
||||||
|
request,
|
||||||
|
);
|
||||||
session.finish_task();
|
session.finish_task();
|
||||||
match result {
|
match result {
|
||||||
Ok(()) => {}
|
Ok(()) => {}
|
||||||
@@ -304,40 +340,6 @@ pub fn serve_client(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn browser_backend_for_submit(
|
|
||||||
browser_ws_url: &str,
|
|
||||||
mac_policy: &MacPolicy,
|
|
||||||
request: &SubmitTaskRequest,
|
|
||||||
) -> Result<Arc<dyn BrowserBackend>, PipeError> {
|
|
||||||
// Always use BrowserCallbackBackend which opens a real helper page in the
|
|
||||||
// browser via `sgBrowerserOpenPage`. The helper page acts as a genuine
|
|
||||||
// browser tab whose URL the browser WS server can route commands to.
|
|
||||||
//
|
|
||||||
// WsBrowserBackend is NOT suitable here because:
|
|
||||||
// 1. It uses a fabricated source URL (e.g. "https://www.zhihu.com") that
|
|
||||||
// does not correspond to any open tab, so the browser silently drops
|
|
||||||
// the command.
|
|
||||||
// 2. It expects a numeric status frame ("0") from the browser WS, but
|
|
||||||
// the real SuperRPA browser never sends such frames → timeout.
|
|
||||||
//
|
|
||||||
// The bootstrap_request_url MUST be the URL of a page that is already open
|
|
||||||
// in the browser. The browser WS server requires the first element of the
|
|
||||||
// command array (requestUrl) to match an existing tab; otherwise the
|
|
||||||
// sgBrowerserOpenPage command is silently ignored.
|
|
||||||
let bootstrap_request_url = initial_request_url_for_submit_task(request);
|
|
||||||
let callback_host = Arc::new(LiveBrowserCallbackHost::start_with_browser_ws_url(
|
|
||||||
browser_ws_url,
|
|
||||||
&bootstrap_request_url,
|
|
||||||
Duration::from_secs(15),
|
|
||||||
BROWSER_RESPONSE_TIMEOUT,
|
|
||||||
)?);
|
|
||||||
Ok(Arc::new(BrowserCallbackBackend::new(
|
|
||||||
callback_host.clone(),
|
|
||||||
mac_policy.clone(),
|
|
||||||
callback_host.helper_url().to_string(),
|
|
||||||
)))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn initial_request_url_for_submit_task(request: &crate::agent::SubmitTaskRequest) -> String {
|
pub(crate) fn initial_request_url_for_submit_task(request: &crate::agent::SubmitTaskRequest) -> String {
|
||||||
request
|
request
|
||||||
.page_url
|
.page_url
|
||||||
@@ -357,6 +359,7 @@ fn derive_request_url_from_instruction(instruction: &str) -> Option<String> {
|
|||||||
crate::compat::workflow_executor::WorkflowRoute::ZhihuHotlistExportXlsx
|
crate::compat::workflow_executor::WorkflowRoute::ZhihuHotlistExportXlsx
|
||||||
| crate::compat::workflow_executor::WorkflowRoute::ZhihuHotlistScreen
|
| crate::compat::workflow_executor::WorkflowRoute::ZhihuHotlistScreen
|
||||||
| crate::compat::workflow_executor::WorkflowRoute::ZhihuArticleEntry
|
| crate::compat::workflow_executor::WorkflowRoute::ZhihuArticleEntry
|
||||||
|
| crate::compat::workflow_executor::WorkflowRoute::ZhihuArticleAutoPublishGenerated
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
{
|
{
|
||||||
@@ -780,6 +783,19 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn initial_request_url_falls_back_to_zhihu_origin_for_generated_article_publish_routes() {
|
||||||
|
let request = SubmitTaskRequest {
|
||||||
|
instruction: "在知乎自动发表一篇名称为人工智能技能大全".to_string(),
|
||||||
|
..SubmitTaskRequest::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
initial_request_url_for_submit_task(&request),
|
||||||
|
"https://www.zhihu.com"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn bridge_base_url_defaults_local_browser_ws_endpoint_to_http_bridge() {
|
fn bridge_base_url_defaults_local_browser_ws_endpoint_to_http_bridge() {
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
|
|||||||
Reference in New Issue
Block a user