mirror of
https://github.com/aiclientproxy/proxycast.git
synced 2026-09-24 23:10:56 +08:00
chore: release v0.98.0
This commit is contained in:
@@ -11,6 +11,7 @@
|
||||
|
||||
- `mod.rs` - 模块入口
|
||||
- `novel_service.rs` - 小说编排服务(项目/设定/章节生成/一致性检查)
|
||||
- `site_adapter_import_service.rs` - 外部适配器来源导入与 Lime 标准编译层
|
||||
- `provider_pool_service.rs` - Provider 凭证池服务(多凭证轮询)
|
||||
- `token_cache_service.rs` - Token 缓存服务
|
||||
- `mcp_service.rs` - MCP 服务器管理
|
||||
|
||||
@@ -2,16 +2,12 @@
|
||||
//!
|
||||
//! 负责把结构化自动化任务映射到 Aster 执行链路。
|
||||
|
||||
use super::{AutomationJobRecord, AutomationPayload};
|
||||
use super::{AutomationJobRecord, AutomationPayload, BROWSER_AUTOMATION_RETIRED_MESSAGE};
|
||||
use crate::agent::AsterAgentWrapper;
|
||||
use crate::app::AppState;
|
||||
use crate::commands::api_key_provider_cmd::ApiKeyProviderServiceState;
|
||||
use crate::commands::aster_agent_cmd::{
|
||||
build_queued_turn_task, build_runtime_queue_executor, AsterChatRequest,
|
||||
};
|
||||
use crate::commands::browser_runtime_cmd::{
|
||||
launch_browser_session_with_db, LaunchBrowserSessionRequest,
|
||||
};
|
||||
use crate::config::GlobalConfigManagerState;
|
||||
use crate::database::DbConnection;
|
||||
use crate::mcp::McpManagerState;
|
||||
@@ -73,31 +69,8 @@ pub async fn execute_job(
|
||||
)
|
||||
.await
|
||||
}
|
||||
AutomationPayload::BrowserSession {
|
||||
profile_id,
|
||||
profile_key,
|
||||
url,
|
||||
environment_preset_id,
|
||||
target_id,
|
||||
open_window,
|
||||
stream_mode,
|
||||
} => {
|
||||
execute_browser_session(
|
||||
job,
|
||||
db,
|
||||
app_handle,
|
||||
LaunchBrowserSessionRequest {
|
||||
profile_id: Some(profile_id),
|
||||
profile_key,
|
||||
url,
|
||||
environment_preset_id,
|
||||
environment: None,
|
||||
target_id,
|
||||
open_window,
|
||||
stream_mode,
|
||||
},
|
||||
)
|
||||
.await
|
||||
AutomationPayload::BrowserSession { .. } => {
|
||||
Err(BROWSER_AUTOMATION_RETIRED_MESSAGE.to_string())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -164,6 +137,8 @@ async fn execute_agent_turn(
|
||||
provider_preference: None,
|
||||
model_preference: None,
|
||||
thinking_enabled: None,
|
||||
approval_policy: None,
|
||||
sandbox_policy: None,
|
||||
project_id: None,
|
||||
workspace_id: job.workspace_id.clone(),
|
||||
web_search: Some(web_search),
|
||||
@@ -208,47 +183,6 @@ async fn execute_agent_turn(
|
||||
})
|
||||
}
|
||||
|
||||
async fn execute_browser_session(
|
||||
job: &AutomationJobRecord,
|
||||
db: &DbConnection,
|
||||
app_handle: &Option<AppHandle>,
|
||||
request: LaunchBrowserSessionRequest,
|
||||
) -> Result<JobExecutionResult, String> {
|
||||
let app = app_handle
|
||||
.as_ref()
|
||||
.ok_or_else(|| "应用句柄不可用,无法执行浏览器自动化任务".to_string())?;
|
||||
let app_state = app
|
||||
.try_state::<AppState>()
|
||||
.ok_or_else(|| "AppState 未初始化,无法执行浏览器自动化任务".to_string())?;
|
||||
let app_state = app_state.inner().clone();
|
||||
|
||||
let response =
|
||||
launch_browser_session_with_db(app.clone(), app_state, db.clone(), request).await?;
|
||||
let session_id = response.session.session_id.clone();
|
||||
Ok(JobExecutionResult {
|
||||
output: format!("浏览器任务已启动: {} -> {}", job.name, session_id),
|
||||
output_data: Some(json!({
|
||||
"kind": "browser_session",
|
||||
"job_id": job.id.clone(),
|
||||
"job_name": job.name.clone(),
|
||||
"workspace_id": job.workspace_id.clone(),
|
||||
"session_id": response.session.session_id.clone(),
|
||||
"profile_key": response.session.profile_key.clone(),
|
||||
"environment_preset_id": response.session.environment_preset_id.clone(),
|
||||
"environment_preset_name": response.session.environment_preset_name.clone(),
|
||||
"target_id": response.session.target_id.clone(),
|
||||
"target_title": response.session.target_title.clone(),
|
||||
"target_url": response.session.target_url.clone(),
|
||||
"lifecycle_state": response.session.lifecycle_state,
|
||||
"control_mode": response.session.control_mode,
|
||||
"remote_debugging_port": response.session.remote_debugging_port,
|
||||
"ws_debugger_url": response.session.ws_debugger_url.clone(),
|
||||
})),
|
||||
session_id: Some(session_id),
|
||||
browser_session: Some(response.session),
|
||||
})
|
||||
}
|
||||
|
||||
fn build_prompt(job: &AutomationJobRecord, prompt: &str, web_search: bool) -> String {
|
||||
let mut sections = vec![
|
||||
"你是一个自动化任务执行助手。".to_string(),
|
||||
|
||||
@@ -17,8 +17,6 @@ use self::schedule::{
|
||||
describe_schedule, next_run_for_schedule, preview_next_run, validate_schedule,
|
||||
};
|
||||
use crate::database::dao::agent_run::AgentRunStatus;
|
||||
use crate::services::browser_environment_service::get_browser_environment_preset;
|
||||
use crate::services::browser_profile_service::get_browser_profile;
|
||||
use crate::services::execution_tracker_service::{ExecutionTracker, RunHandle, RunSource};
|
||||
use chrono::Utc;
|
||||
use lime_browser_runtime::{BrowserStreamMode, CdpSessionState};
|
||||
@@ -38,7 +36,6 @@ use std::time::Duration;
|
||||
use tauri::Emitter;
|
||||
use tokio::sync::RwLock;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
use url::Url;
|
||||
use uuid::Uuid;
|
||||
|
||||
pub type AutomationJobRecord = AutomationJob;
|
||||
@@ -149,6 +146,11 @@ pub struct AutomationService {
|
||||
app_handle: Option<tauri::AppHandle>,
|
||||
}
|
||||
|
||||
pub(super) const BROWSER_AUTOMATION_RETIRED_MESSAGE: &str =
|
||||
"浏览器自动化任务已下线,不再允许创建或执行";
|
||||
pub(super) const BROWSER_AUTOMATION_RETIRED_LAST_ERROR: &str =
|
||||
"浏览器自动化任务已下线,请删除该任务";
|
||||
|
||||
impl AutomationService {
|
||||
pub fn new(config: AutomationSettings) -> Self {
|
||||
Self {
|
||||
@@ -202,6 +204,17 @@ impl AutomationService {
|
||||
self.status.running = true;
|
||||
self.update_next_poll();
|
||||
|
||||
let retired_job_count = {
|
||||
let conn = db.lock().map_err(|e| format!("数据库锁定失败: {e}"))?;
|
||||
retire_legacy_browser_automation_jobs(&conn)?
|
||||
};
|
||||
if retired_job_count > 0 {
|
||||
tracing::info!(
|
||||
"[Automation] 已停用 {} 条遗留浏览器自动化任务,后续不会再后台启动 Chrome",
|
||||
retired_job_count
|
||||
);
|
||||
}
|
||||
|
||||
let interval_secs = self.config.poll_interval_secs.max(5);
|
||||
let app_handle = self.app_handle.clone();
|
||||
tokio::spawn(async move {
|
||||
@@ -377,6 +390,17 @@ impl AutomationService {
|
||||
.ok_or_else(|| format!("自动化任务不存在: {id}"))?
|
||||
};
|
||||
|
||||
if is_browser_session_payload(&job.payload) {
|
||||
let mut legacy_job = job.clone();
|
||||
let conn = db.lock().map_err(|e| format!("数据库锁定失败: {e}"))?;
|
||||
retire_browser_automation_job(
|
||||
&conn,
|
||||
&mut legacy_job,
|
||||
BROWSER_AUTOMATION_RETIRED_LAST_ERROR,
|
||||
)?;
|
||||
return Err(BROWSER_AUTOMATION_RETIRED_MESSAGE.to_string());
|
||||
}
|
||||
|
||||
let result = Self::execute_job_once(&job, db, &self.app_handle, &self.config).await?;
|
||||
Ok(AutomationCycleResult {
|
||||
job_count: 1,
|
||||
@@ -467,6 +491,16 @@ impl AutomationService {
|
||||
let started_at_str = started_at.to_rfc3339();
|
||||
let is_browser_session = is_browser_session_payload(&working_job.payload);
|
||||
|
||||
if is_browser_session {
|
||||
let conn = db.lock().map_err(|e| format!("数据库锁定失败: {e}"))?;
|
||||
retire_browser_automation_job(
|
||||
&conn,
|
||||
&mut working_job,
|
||||
BROWSER_AUTOMATION_RETIRED_LAST_ERROR,
|
||||
)?;
|
||||
return Ok("error".to_string());
|
||||
}
|
||||
|
||||
set_active_job_state(
|
||||
&mut working_job,
|
||||
"running",
|
||||
@@ -713,17 +747,6 @@ fn normalize_optional_string(value: Option<String>) -> Option<String> {
|
||||
.filter(|item| !item.is_empty())
|
||||
}
|
||||
|
||||
fn validate_optional_http_url(value: Option<&str>, field_name: &str) -> Result<(), String> {
|
||||
let Some(raw) = value.map(str::trim).filter(|item| !item.is_empty()) else {
|
||||
return Ok(());
|
||||
};
|
||||
let parsed = Url::parse(raw).map_err(|error| format!("{field_name}无效: {error}"))?;
|
||||
match parsed.scheme() {
|
||||
"http" | "https" => Ok(()),
|
||||
_ => Err(format!("{field_name}仅支持 http/https")),
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_draft(draft: &AutomationJobDraft) -> Result<(), String> {
|
||||
validate_schedule(&draft.schedule, Utc::now())?;
|
||||
validate_payload(&draft.payload)?;
|
||||
@@ -773,63 +796,65 @@ fn validate_payload(payload: &AutomationPayload) -> Result<(), String> {
|
||||
}
|
||||
}
|
||||
}
|
||||
AutomationPayload::BrowserSession { profile_id, .. } => {
|
||||
if profile_id.trim().is_empty() {
|
||||
return Err("浏览器任务必须绑定浏览器资料".to_string());
|
||||
}
|
||||
AutomationPayload::BrowserSession { .. } => {
|
||||
return Err(BROWSER_AUTOMATION_RETIRED_MESSAGE.to_string());
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_payload_with_conn(
|
||||
conn: &Connection,
|
||||
_conn: &Connection,
|
||||
payload: &AutomationPayload,
|
||||
) -> Result<(), String> {
|
||||
match payload {
|
||||
AutomationPayload::AgentTurn { .. } => Ok(()),
|
||||
AutomationPayload::BrowserSession {
|
||||
profile_id,
|
||||
profile_key,
|
||||
url,
|
||||
environment_preset_id,
|
||||
..
|
||||
} => {
|
||||
let profile_id = profile_id.trim();
|
||||
let profile = get_browser_profile(conn, profile_id)?
|
||||
.filter(|record| record.archived_at.is_none())
|
||||
.ok_or_else(|| format!("未找到可用的浏览器资料: {profile_id}"))?;
|
||||
|
||||
if let Some(expected_profile_key) = profile_key
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
{
|
||||
if profile.profile_key != expected_profile_key {
|
||||
return Err(format!(
|
||||
"浏览器资料 {profile_id} 的 profile_key 与任务配置不一致: {} != {expected_profile_key}",
|
||||
profile.profile_key
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(environment_preset_id) = environment_preset_id
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
{
|
||||
get_browser_environment_preset(conn, environment_preset_id)?
|
||||
.filter(|record| record.archived_at.is_none())
|
||||
.ok_or_else(|| {
|
||||
format!("未找到可用的浏览器环境预设: {environment_preset_id}")
|
||||
})?;
|
||||
}
|
||||
|
||||
validate_optional_http_url(url.as_deref(), "浏览器启动地址")
|
||||
AutomationPayload::BrowserSession { .. } => {
|
||||
Err(BROWSER_AUTOMATION_RETIRED_MESSAGE.to_string())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn retire_legacy_browser_automation_jobs(conn: &Connection) -> Result<usize, String> {
|
||||
let jobs = AutomationJobDao::list(conn).map_err(|e| format!("查询自动化任务失败: {e}"))?;
|
||||
let mut retired_job_count = 0usize;
|
||||
|
||||
for mut job in jobs {
|
||||
if !is_browser_session_payload(&job.payload) {
|
||||
continue;
|
||||
}
|
||||
retire_browser_automation_job(conn, &mut job, BROWSER_AUTOMATION_RETIRED_LAST_ERROR)?;
|
||||
retired_job_count = retired_job_count.saturating_add(1);
|
||||
}
|
||||
|
||||
Ok(retired_job_count)
|
||||
}
|
||||
|
||||
fn retire_browser_automation_job(
|
||||
conn: &Connection,
|
||||
job: &mut AutomationJobRecord,
|
||||
reason: &str,
|
||||
) -> Result<(), String> {
|
||||
let retired_at = Utc::now().to_rfc3339();
|
||||
let previous_running_started_at = job.running_started_at.clone();
|
||||
|
||||
job.enabled = false;
|
||||
job.next_run_at = None;
|
||||
job.last_status = Some("error".to_string());
|
||||
job.last_error = Some(reason.to_string());
|
||||
if job.last_run_at.is_none() {
|
||||
job.last_run_at = previous_running_started_at.clone();
|
||||
}
|
||||
if previous_running_started_at.is_some() {
|
||||
job.last_finished_at = Some(retired_at.clone());
|
||||
}
|
||||
job.running_started_at = None;
|
||||
job.auto_disabled_until = None;
|
||||
job.updated_at = retired_at;
|
||||
|
||||
AutomationJobDao::update(conn, job).map_err(|e| format!("更新自动化任务失败: {e}"))
|
||||
}
|
||||
|
||||
fn build_tracker_start_metadata(job: &AutomationJobRecord) -> Value {
|
||||
let mut metadata = Map::from_iter([
|
||||
("job_id".to_string(), Value::String(job.id.clone())),
|
||||
@@ -1165,11 +1190,6 @@ pub(super) fn append_payload_tracking_metadata(metadata: &mut Map<String, Value>
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::database::schema::create_tables;
|
||||
use crate::services::browser_environment_service::{
|
||||
save_browser_environment_preset, SaveBrowserEnvironmentPresetInput,
|
||||
};
|
||||
use crate::services::browser_profile_service::{save_browser_profile, SaveBrowserProfileInput};
|
||||
use lime_core::database::dao::browser_profile::BrowserProfileTransportKind;
|
||||
use rusqlite::Connection;
|
||||
|
||||
fn setup_db() -> Connection {
|
||||
@@ -1179,72 +1199,22 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_payload_with_conn_should_accept_browser_session_payload() {
|
||||
fn validate_payload_with_conn_should_reject_browser_session_payload() {
|
||||
let conn = setup_db();
|
||||
let profile = save_browser_profile(
|
||||
&conn,
|
||||
SaveBrowserProfileInput {
|
||||
id: None,
|
||||
profile_key: "shop_us".to_string(),
|
||||
name: "美区店铺".to_string(),
|
||||
description: None,
|
||||
site_scope: None,
|
||||
launch_url: Some("https://seller.example.com".to_string()),
|
||||
transport_kind: BrowserProfileTransportKind::ManagedCdp,
|
||||
},
|
||||
)
|
||||
.expect("保存浏览器资料失败");
|
||||
let preset = save_browser_environment_preset(
|
||||
&conn,
|
||||
SaveBrowserEnvironmentPresetInput {
|
||||
id: None,
|
||||
name: "美区桌面".to_string(),
|
||||
description: None,
|
||||
proxy_server: None,
|
||||
timezone_id: Some("America/Los_Angeles".to_string()),
|
||||
locale: Some("en-US".to_string()),
|
||||
accept_language: Some("en-US,en;q=0.9".to_string()),
|
||||
geolocation_lat: None,
|
||||
geolocation_lng: None,
|
||||
geolocation_accuracy_m: None,
|
||||
user_agent: None,
|
||||
platform: None,
|
||||
viewport_width: Some(1440),
|
||||
viewport_height: Some(900),
|
||||
device_scale_factor: Some(2.0),
|
||||
},
|
||||
)
|
||||
.expect("保存浏览器环境预设失败");
|
||||
|
||||
let payload = AutomationPayload::BrowserSession {
|
||||
profile_id: profile.id,
|
||||
profile_id: "profile-1".to_string(),
|
||||
profile_key: Some("shop_us".to_string()),
|
||||
url: Some("https://seller.example.com/dashboard".to_string()),
|
||||
environment_preset_id: Some(preset.id),
|
||||
environment_preset_id: Some("preset-1".to_string()),
|
||||
target_id: None,
|
||||
open_window: false,
|
||||
stream_mode: BrowserStreamMode::Events,
|
||||
};
|
||||
|
||||
validate_payload_with_conn(&conn, &payload).expect("浏览器任务负载校验失败");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_payload_with_conn_should_reject_missing_browser_profile() {
|
||||
let conn = setup_db();
|
||||
let payload = AutomationPayload::BrowserSession {
|
||||
profile_id: "missing-profile".to_string(),
|
||||
profile_key: Some("shop_us".to_string()),
|
||||
url: Some("https://seller.example.com/dashboard".to_string()),
|
||||
environment_preset_id: None,
|
||||
target_id: None,
|
||||
open_window: false,
|
||||
stream_mode: BrowserStreamMode::Events,
|
||||
};
|
||||
|
||||
let error =
|
||||
validate_payload_with_conn(&conn, &payload).expect_err("缺失浏览器资料时应返回错误");
|
||||
assert!(error.contains("未找到可用的浏览器资料"));
|
||||
assert_eq!(
|
||||
validate_payload_with_conn(&conn, &payload),
|
||||
Err(BROWSER_AUTOMATION_RETIRED_MESSAGE.to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1306,6 +1276,103 @@ mod tests {
|
||||
assert_eq!(metadata.get("session_id"), Some(&json!("session-1")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn retire_legacy_browser_automation_jobs_should_disable_browser_session_jobs() {
|
||||
let conn = setup_db();
|
||||
let browser_job = AutomationJob {
|
||||
id: "job-browser-1".to_string(),
|
||||
name: "浏览器巡检".to_string(),
|
||||
description: Some("旧浏览器自动化".to_string()),
|
||||
enabled: true,
|
||||
workspace_id: "workspace-1".to_string(),
|
||||
execution_mode: AutomationExecutionMode::Intelligent,
|
||||
schedule: TaskSchedule::Every { every_secs: 300 },
|
||||
payload: json!({
|
||||
"kind": "browser_session",
|
||||
"profile_id": "profile-1",
|
||||
"profile_key": "shop_us",
|
||||
"url": "https://seller.example.com/dashboard",
|
||||
"open_window": false,
|
||||
"stream_mode": "events"
|
||||
}),
|
||||
delivery: DeliveryConfig::default(),
|
||||
timeout_secs: None,
|
||||
max_retries: 3,
|
||||
next_run_at: Some("2026-03-16T00:05:00Z".to_string()),
|
||||
last_status: Some("running".to_string()),
|
||||
last_error: None,
|
||||
last_run_at: Some("2026-03-16T00:00:00Z".to_string()),
|
||||
last_finished_at: None,
|
||||
running_started_at: Some("2026-03-16T00:00:00Z".to_string()),
|
||||
consecutive_failures: 0,
|
||||
last_retry_count: 0,
|
||||
auto_disabled_until: None,
|
||||
last_delivery: None,
|
||||
created_at: "2026-03-16T00:00:00Z".to_string(),
|
||||
updated_at: "2026-03-16T00:00:00Z".to_string(),
|
||||
};
|
||||
let agent_job = AutomationJob {
|
||||
id: "job-agent-1".to_string(),
|
||||
name: "日报摘要".to_string(),
|
||||
description: None,
|
||||
enabled: true,
|
||||
workspace_id: "workspace-1".to_string(),
|
||||
execution_mode: AutomationExecutionMode::Skill,
|
||||
schedule: TaskSchedule::Cron {
|
||||
expr: "0 9 * * *".to_string(),
|
||||
tz: Some("Asia/Shanghai".to_string()),
|
||||
},
|
||||
payload: json!({
|
||||
"kind": "agent_turn",
|
||||
"prompt": "请输出日报摘要",
|
||||
"web_search": false
|
||||
}),
|
||||
delivery: DeliveryConfig::default(),
|
||||
timeout_secs: None,
|
||||
max_retries: 3,
|
||||
next_run_at: Some("2026-03-16T09:00:00Z".to_string()),
|
||||
last_status: Some("success".to_string()),
|
||||
last_error: None,
|
||||
last_run_at: Some("2026-03-16T08:59:00Z".to_string()),
|
||||
last_finished_at: Some("2026-03-16T09:00:05Z".to_string()),
|
||||
running_started_at: None,
|
||||
consecutive_failures: 0,
|
||||
last_retry_count: 0,
|
||||
auto_disabled_until: None,
|
||||
last_delivery: None,
|
||||
created_at: "2026-03-16T00:00:00Z".to_string(),
|
||||
updated_at: "2026-03-16T00:00:00Z".to_string(),
|
||||
};
|
||||
|
||||
AutomationJobDao::create(&conn, &browser_job).expect("创建浏览器任务失败");
|
||||
AutomationJobDao::create(&conn, &agent_job).expect("创建 agent 任务失败");
|
||||
|
||||
let retired_job_count =
|
||||
retire_legacy_browser_automation_jobs(&conn).expect("停用遗留浏览器任务失败");
|
||||
assert_eq!(retired_job_count, 1);
|
||||
|
||||
let updated_browser = AutomationJobDao::get(&conn, "job-browser-1")
|
||||
.expect("读取浏览器任务失败")
|
||||
.expect("浏览器任务不存在");
|
||||
assert!(!updated_browser.enabled);
|
||||
assert_eq!(updated_browser.next_run_at, None);
|
||||
assert_eq!(updated_browser.running_started_at, None);
|
||||
assert_eq!(updated_browser.last_status.as_deref(), Some("error"));
|
||||
assert_eq!(
|
||||
updated_browser.last_error.as_deref(),
|
||||
Some(BROWSER_AUTOMATION_RETIRED_LAST_ERROR)
|
||||
);
|
||||
|
||||
let updated_agent = AutomationJobDao::get(&conn, "job-agent-1")
|
||||
.expect("读取 agent 任务失败")
|
||||
.expect("agent 任务不存在");
|
||||
assert!(updated_agent.enabled);
|
||||
assert_eq!(
|
||||
updated_agent.next_run_at.as_deref(),
|
||||
Some("2026-03-16T09:00:00Z")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_tracker_finish_metadata_should_include_delivery_summary() {
|
||||
let job = AutomationJob {
|
||||
@@ -1544,4 +1611,22 @@ mod tests {
|
||||
Err("自动化任务 request_metadata 必须为对象".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_payload_should_reject_browser_session_payload() {
|
||||
let payload = AutomationPayload::BrowserSession {
|
||||
profile_id: "profile-1".to_string(),
|
||||
profile_key: Some("shop_us".to_string()),
|
||||
url: Some("https://seller.example.com/dashboard".to_string()),
|
||||
environment_preset_id: None,
|
||||
target_id: None,
|
||||
open_window: false,
|
||||
stream_mode: BrowserStreamMode::Events,
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
validate_payload(&payload),
|
||||
Err(BROWSER_AUTOMATION_RETIRED_MESSAGE.to_string())
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ use serde_json::Value;
|
||||
use std::collections::HashMap;
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
use tauri::{AppHandle, Manager};
|
||||
|
||||
const SETTINGS_SUBDIR: &str = "connectors";
|
||||
@@ -26,6 +27,12 @@ const SYSTEM_CONNECTOR_DEFINITIONS: [(&str, &str, &str); 5] = [
|
||||
("contacts", "通讯录", "搜索、读取和创建联系人。"),
|
||||
];
|
||||
|
||||
const AUTH_STATUS_NOT_DETERMINED: &str = "not_determined";
|
||||
const AUTH_STATUS_AUTHORIZED: &str = "authorized";
|
||||
const AUTH_STATUS_DENIED: &str = "denied";
|
||||
const AUTH_STATUS_ERROR: &str = "error";
|
||||
const AUTH_STATUS_UNSUPPORTED: &str = "unsupported";
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct BrowserConnectorAutoConfig {
|
||||
#[serde(rename = "serverUrl")]
|
||||
@@ -67,6 +74,10 @@ pub struct SystemConnectorSnapshot {
|
||||
pub description: String,
|
||||
pub enabled: bool,
|
||||
pub available: bool,
|
||||
pub visible: bool,
|
||||
pub authorization_status: String,
|
||||
pub last_error: Option<String>,
|
||||
pub capabilities: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
@@ -81,10 +92,24 @@ pub struct BrowserConnectorSettingsSnapshot {
|
||||
struct BrowserConnectorSettingsRecord {
|
||||
enabled: bool,
|
||||
install_root_dir: Option<String>,
|
||||
system_connectors: HashMap<String, bool>,
|
||||
system_connectors: HashMap<String, StoredSystemConnectorState>,
|
||||
updated_at: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(untagged)]
|
||||
enum StoredSystemConnectorState {
|
||||
LegacyBool(bool),
|
||||
Detailed(SystemConnectorStateRecord),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
struct SystemConnectorStateRecord {
|
||||
enabled: bool,
|
||||
authorization_status: String,
|
||||
last_error: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct ManifestInfo {
|
||||
name: String,
|
||||
@@ -96,7 +121,7 @@ impl Default for BrowserConnectorSettingsRecord {
|
||||
Self {
|
||||
enabled: true,
|
||||
install_root_dir: None,
|
||||
system_connectors: default_system_connector_states(),
|
||||
system_connectors: default_system_connector_state_records(),
|
||||
updated_at: Utc::now().to_rfc3339(),
|
||||
}
|
||||
}
|
||||
@@ -109,6 +134,146 @@ fn default_system_connector_states() -> HashMap<String, bool> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn default_system_connector_state_records() -> HashMap<String, StoredSystemConnectorState> {
|
||||
default_system_connector_states()
|
||||
.into_iter()
|
||||
.map(|(id, enabled)| (id, StoredSystemConnectorState::LegacyBool(enabled)))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn default_connector_record(enabled: bool) -> SystemConnectorStateRecord {
|
||||
SystemConnectorStateRecord {
|
||||
enabled,
|
||||
authorization_status: if cfg!(target_os = "macos") {
|
||||
AUTH_STATUS_NOT_DETERMINED.to_string()
|
||||
} else {
|
||||
AUTH_STATUS_UNSUPPORTED.to_string()
|
||||
},
|
||||
last_error: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_connector_record(
|
||||
state: Option<&StoredSystemConnectorState>,
|
||||
) -> SystemConnectorStateRecord {
|
||||
match state {
|
||||
Some(StoredSystemConnectorState::LegacyBool(enabled)) => default_connector_record(*enabled),
|
||||
Some(StoredSystemConnectorState::Detailed(record)) => record.clone(),
|
||||
None => default_connector_record(false),
|
||||
}
|
||||
}
|
||||
|
||||
fn connector_capabilities(id: &str) -> Vec<String> {
|
||||
match id {
|
||||
"reminders" => vec![
|
||||
"list_reminders".to_string(),
|
||||
"create_reminder".to_string(),
|
||||
"update_reminder".to_string(),
|
||||
],
|
||||
"calendar" => vec![
|
||||
"list_events".to_string(),
|
||||
"create_event".to_string(),
|
||||
"update_event".to_string(),
|
||||
],
|
||||
"notes" => vec![
|
||||
"list_notes".to_string(),
|
||||
"read_note".to_string(),
|
||||
"create_note".to_string(),
|
||||
],
|
||||
"mail" => vec![
|
||||
"list_mailboxes".to_string(),
|
||||
"read_messages".to_string(),
|
||||
"create_draft".to_string(),
|
||||
],
|
||||
"contacts" => vec![
|
||||
"search_contacts".to_string(),
|
||||
"read_contact".to_string(),
|
||||
"create_contact".to_string(),
|
||||
],
|
||||
_ => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn connector_probe_script(id: &str) -> Option<&'static str> {
|
||||
match id {
|
||||
"reminders" => Some(r#"tell application id "com.apple.reminders" to count of lists"#),
|
||||
"calendar" => Some(r#"tell application id "com.apple.iCal" to count of calendars"#),
|
||||
"notes" => Some(r#"tell application id "com.apple.Notes" to count of folders"#),
|
||||
"mail" => Some(r#"tell application id "com.apple.mail" to count of mailboxes"#),
|
||||
"contacts" => Some(r#"tell application id "com.apple.AddressBook" to count of people"#),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn truncate_connector_error(input: &str) -> String {
|
||||
input
|
||||
.trim()
|
||||
.split('\n')
|
||||
.find(|line| !line.trim().is_empty())
|
||||
.unwrap_or(input)
|
||||
.trim()
|
||||
.to_string()
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn request_connector_authorization(id: &str) -> Result<SystemConnectorStateRecord, String> {
|
||||
let script = connector_probe_script(id).ok_or_else(|| format!("未知的系统连接器: {id}"))?;
|
||||
let output = Command::new("osascript")
|
||||
.args(["-e", script])
|
||||
.output()
|
||||
.map_err(|error| format!("调用 osascript 失败: {error}"))?;
|
||||
|
||||
if output.status.success() {
|
||||
return Ok(SystemConnectorStateRecord {
|
||||
enabled: true,
|
||||
authorization_status: AUTH_STATUS_AUTHORIZED.to_string(),
|
||||
last_error: None,
|
||||
});
|
||||
}
|
||||
|
||||
let stderr = String::from_utf8_lossy(&output.stderr).to_string();
|
||||
let stdout = String::from_utf8_lossy(&output.stdout).to_string();
|
||||
let combined = format!("{stderr}\n{stdout}");
|
||||
let normalized = combined.to_ascii_lowercase();
|
||||
let last_error = truncate_connector_error(&combined);
|
||||
|
||||
if normalized.contains("not authorized")
|
||||
|| normalized.contains("not permitted")
|
||||
|| normalized.contains("(-1743)")
|
||||
|| normalized.contains("1743")
|
||||
{
|
||||
return Ok(SystemConnectorStateRecord {
|
||||
enabled: false,
|
||||
authorization_status: AUTH_STATUS_DENIED.to_string(),
|
||||
last_error: Some(if last_error.is_empty() {
|
||||
"系统已拒绝该连接器的自动化权限。".to_string()
|
||||
} else {
|
||||
last_error
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
Ok(SystemConnectorStateRecord {
|
||||
enabled: false,
|
||||
authorization_status: AUTH_STATUS_ERROR.to_string(),
|
||||
last_error: Some(if last_error.is_empty() {
|
||||
"系统连接器授权失败。".to_string()
|
||||
} else {
|
||||
last_error
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
fn request_connector_authorization(_id: &str) -> Result<SystemConnectorStateRecord, String> {
|
||||
Ok(SystemConnectorStateRecord {
|
||||
enabled: false,
|
||||
authorization_status: AUTH_STATUS_UNSUPPORTED.to_string(),
|
||||
last_error: Some("当前平台暂不支持系统连接器。".to_string()),
|
||||
})
|
||||
}
|
||||
|
||||
fn browser_connector_settings_path() -> Result<PathBuf, String> {
|
||||
Ok(lime_core::app_paths::preferred_data_dir()
|
||||
.map_err(|error| format!("获取应用数据目录失败: {error}"))?
|
||||
@@ -135,7 +300,10 @@ fn load_settings_record() -> Result<BrowserConnectorSettingsRecord, String> {
|
||||
serde_json::from_str(&content).map_err(|error| format!("解析连接器设置失败: {error}"))?;
|
||||
|
||||
for (id, enabled) in default_system_connector_states() {
|
||||
record.system_connectors.entry(id).or_insert(enabled);
|
||||
record
|
||||
.system_connectors
|
||||
.entry(id)
|
||||
.or_insert(StoredSystemConnectorState::LegacyBool(enabled));
|
||||
}
|
||||
|
||||
Ok(record)
|
||||
@@ -406,16 +574,28 @@ fn build_settings_snapshot(
|
||||
enabled: record.enabled,
|
||||
install_root_dir,
|
||||
install_dir,
|
||||
system_connectors: SYSTEM_CONNECTOR_DEFINITIONS
|
||||
.iter()
|
||||
.map(|(id, label, description)| SystemConnectorSnapshot {
|
||||
id: (*id).to_string(),
|
||||
label: (*label).to_string(),
|
||||
description: (*description).to_string(),
|
||||
enabled: record.system_connectors.get(*id).copied().unwrap_or(false),
|
||||
available: cfg!(target_os = "macos"),
|
||||
})
|
||||
.collect(),
|
||||
system_connectors: if cfg!(target_os = "macos") {
|
||||
SYSTEM_CONNECTOR_DEFINITIONS
|
||||
.iter()
|
||||
.map(|(id, label, description)| {
|
||||
let connector_record =
|
||||
normalize_connector_record(record.system_connectors.get(*id));
|
||||
SystemConnectorSnapshot {
|
||||
id: (*id).to_string(),
|
||||
label: (*label).to_string(),
|
||||
description: (*description).to_string(),
|
||||
enabled: connector_record.enabled,
|
||||
available: true,
|
||||
visible: true,
|
||||
authorization_status: connector_record.authorization_status,
|
||||
last_error: connector_record.last_error,
|
||||
capabilities: connector_capabilities(id),
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
} else {
|
||||
Vec::new()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -454,7 +634,23 @@ pub fn update_system_connector_enabled(
|
||||
}
|
||||
|
||||
let mut record = load_settings_record()?;
|
||||
record.system_connectors.insert(id.to_string(), enabled);
|
||||
let next_state = if enabled {
|
||||
request_connector_authorization(id)?
|
||||
} else {
|
||||
let mut current = normalize_connector_record(record.system_connectors.get(id));
|
||||
current.enabled = false;
|
||||
current.last_error = None;
|
||||
if !cfg!(target_os = "macos") {
|
||||
current.authorization_status = AUTH_STATUS_UNSUPPORTED.to_string();
|
||||
} else if current.authorization_status == AUTH_STATUS_ERROR {
|
||||
current.authorization_status = AUTH_STATUS_NOT_DETERMINED.to_string();
|
||||
}
|
||||
current
|
||||
};
|
||||
record.system_connectors.insert(
|
||||
id.to_string(),
|
||||
StoredSystemConnectorState::Detailed(next_state),
|
||||
);
|
||||
record.updated_at = Utc::now().to_rfc3339();
|
||||
save_settings_record(&record)?;
|
||||
Ok(build_settings_snapshot(&record))
|
||||
@@ -520,4 +716,37 @@ mod tests {
|
||||
assert_eq!(status.installed_version.as_deref(), Some("1.0.0"));
|
||||
assert_eq!(status.bundled_version, "1.1.0");
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
#[test]
|
||||
fn settings_snapshot_should_expose_visible_system_connectors_on_macos() {
|
||||
let record = BrowserConnectorSettingsRecord::default();
|
||||
let snapshot = build_settings_snapshot(&record);
|
||||
|
||||
assert_eq!(
|
||||
snapshot.system_connectors.len(),
|
||||
SYSTEM_CONNECTOR_DEFINITIONS.len()
|
||||
);
|
||||
assert!(snapshot
|
||||
.system_connectors
|
||||
.iter()
|
||||
.all(|connector| connector.visible));
|
||||
assert!(snapshot
|
||||
.system_connectors
|
||||
.iter()
|
||||
.all(|connector| connector.available));
|
||||
assert!(snapshot
|
||||
.system_connectors
|
||||
.iter()
|
||||
.all(|connector| !connector.capabilities.is_empty()));
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
#[test]
|
||||
fn settings_snapshot_should_hide_system_connectors_on_non_macos() {
|
||||
let record = BrowserConnectorSettingsRecord::default();
|
||||
let snapshot = build_settings_snapshot(&record);
|
||||
|
||||
assert!(snapshot.system_connectors.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,6 +36,7 @@ pub mod runtime_evidence_pack_service;
|
||||
pub mod runtime_handoff_artifact_service;
|
||||
pub mod runtime_replay_case_service;
|
||||
pub mod runtime_review_decision_service;
|
||||
pub mod site_adapter_import_service;
|
||||
pub mod site_adapter_registry;
|
||||
pub mod site_capability_service;
|
||||
pub mod sysinfo_service;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -7,6 +7,7 @@ use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
const BUNDLED_ADAPTER_RELATIVE_DIR: &str = "resources/site-adapters/bundled";
|
||||
const IMPORTED_ADAPTER_RELATIVE_DIR: &str = "site-adapters/imported";
|
||||
const SERVER_SYNCED_ADAPTER_RELATIVE_DIR: &str = "site-adapters/server-synced";
|
||||
const BUNDLED_INDEX_FALLBACK: &str =
|
||||
include_str!("../../resources/site-adapters/bundled/index.json");
|
||||
@@ -35,6 +36,7 @@ impl SiteAdapterArgType {
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum SiteAdapterSourceKind {
|
||||
Bundled,
|
||||
Imported,
|
||||
ServerSynced,
|
||||
}
|
||||
|
||||
@@ -42,6 +44,7 @@ impl SiteAdapterSourceKind {
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Bundled => "bundled",
|
||||
Self::Imported => "imported",
|
||||
Self::ServerSynced => "server_synced",
|
||||
}
|
||||
}
|
||||
@@ -81,7 +84,7 @@ pub struct SiteAdapterSpec {
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
struct SiteAdapterRegistryDocument {
|
||||
#[serde(default = "default_registry_version")]
|
||||
#[serde(default = "default_registry_version", alias = "registryVersion")]
|
||||
registry_version: u32,
|
||||
#[serde(default, alias = "catalogVersion", alias = "version")]
|
||||
catalog_version: Option<String>,
|
||||
@@ -118,6 +121,7 @@ struct SiteAdapterArgManifest {
|
||||
name: String,
|
||||
description: String,
|
||||
required: bool,
|
||||
#[serde(alias = "argType")]
|
||||
arg_type: SiteAdapterArgTypeManifest,
|
||||
#[serde(default)]
|
||||
example: Option<Value>,
|
||||
@@ -175,7 +179,7 @@ pub struct SiteAdapterCatalogStatus {
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct SiteAdapterCatalogBootstrapDocument {
|
||||
#[serde(default = "default_registry_version")]
|
||||
#[serde(default = "default_registry_version", alias = "registryVersion")]
|
||||
registry_version: u32,
|
||||
#[serde(default, alias = "catalogVersion", alias = "version")]
|
||||
catalog_version: Option<String>,
|
||||
@@ -203,7 +207,8 @@ struct SiteAdapterCatalogBootstrapEntry {
|
||||
entry: SiteAdapterEntryManifest,
|
||||
#[serde(default, alias = "sourceVersion")]
|
||||
source_version: Option<String>,
|
||||
script: String,
|
||||
#[serde(default)]
|
||||
script: Option<String>,
|
||||
}
|
||||
|
||||
pub fn normalize_site_adapter_name(value: &str) -> String {
|
||||
@@ -212,6 +217,9 @@ pub fn normalize_site_adapter_name(value: &str) -> String {
|
||||
|
||||
pub fn load_site_adapter_specs() -> Result<Vec<SiteAdapterSpec>, String> {
|
||||
let mut merged = BTreeMap::new();
|
||||
for spec in load_imported_site_adapters()? {
|
||||
merged.insert(normalize_site_adapter_name(&spec.name), spec);
|
||||
}
|
||||
for spec in load_bundled_site_adapters()? {
|
||||
merged.insert(normalize_site_adapter_name(&spec.name), spec);
|
||||
}
|
||||
@@ -230,7 +238,26 @@ pub fn find_site_adapter_spec(name: &str) -> Result<Option<SiteAdapterSpec>, Str
|
||||
}
|
||||
|
||||
pub fn get_site_adapter_catalog_status() -> Result<SiteAdapterCatalogStatus, String> {
|
||||
get_site_adapter_catalog_status_from_dir(resolve_server_synced_adapter_dir())
|
||||
let server_synced = get_site_adapter_catalog_status_from_dir(
|
||||
resolve_server_synced_adapter_dir(),
|
||||
SiteAdapterSourceKind::ServerSynced,
|
||||
)?;
|
||||
if server_synced.exists {
|
||||
return Ok(server_synced);
|
||||
}
|
||||
|
||||
let imported = get_site_adapter_catalog_status_from_dir(
|
||||
resolve_imported_adapter_dir(),
|
||||
SiteAdapterSourceKind::Imported,
|
||||
)?;
|
||||
if imported.exists {
|
||||
return Ok(imported);
|
||||
}
|
||||
|
||||
Ok(empty_site_adapter_catalog_status(
|
||||
SiteAdapterSourceKind::Bundled,
|
||||
None,
|
||||
))
|
||||
}
|
||||
|
||||
pub fn apply_site_adapter_catalog_bootstrap(
|
||||
@@ -243,7 +270,9 @@ pub fn apply_site_adapter_catalog_bootstrap(
|
||||
}
|
||||
|
||||
pub fn clear_site_adapter_catalog_cache() -> Result<SiteAdapterCatalogStatus, String> {
|
||||
clear_site_adapter_catalog_cache_at_dir(resolve_server_synced_adapter_dir())
|
||||
clear_site_adapter_catalog_cache_at_dir(resolve_server_synced_adapter_dir())?;
|
||||
clear_site_adapter_catalog_cache_at_dir(resolve_imported_adapter_dir())?;
|
||||
get_site_adapter_catalog_status()
|
||||
}
|
||||
|
||||
pub fn build_entry_url(
|
||||
@@ -275,35 +304,27 @@ fn load_server_synced_site_adapters() -> Result<Vec<SiteAdapterSpec>, String> {
|
||||
load_site_adapters_from_dir(&dir, SiteAdapterSourceKind::ServerSynced)
|
||||
}
|
||||
|
||||
fn load_imported_site_adapters() -> Result<Vec<SiteAdapterSpec>, String> {
|
||||
let Some(dir) = resolve_imported_adapter_dir() else {
|
||||
return Ok(Vec::new());
|
||||
};
|
||||
if !dir.exists() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
load_site_adapters_from_dir(&dir, SiteAdapterSourceKind::Imported)
|
||||
}
|
||||
|
||||
fn get_site_adapter_catalog_status_from_dir(
|
||||
dir: Option<PathBuf>,
|
||||
source_kind: SiteAdapterSourceKind,
|
||||
) -> Result<SiteAdapterCatalogStatus, String> {
|
||||
let directory = dir.as_ref().map(|value| value.display().to_string());
|
||||
let Some(dir) = dir else {
|
||||
return Ok(SiteAdapterCatalogStatus {
|
||||
exists: false,
|
||||
source_kind: SiteAdapterSourceKind::ServerSynced.as_str().to_string(),
|
||||
registry_version: default_registry_version(),
|
||||
directory,
|
||||
catalog_version: None,
|
||||
tenant_id: None,
|
||||
synced_at: None,
|
||||
adapter_count: 0,
|
||||
});
|
||||
return Ok(empty_site_adapter_catalog_status(source_kind, None));
|
||||
};
|
||||
|
||||
let index_path = dir.join("index.json");
|
||||
if !index_path.exists() {
|
||||
return Ok(SiteAdapterCatalogStatus {
|
||||
exists: false,
|
||||
source_kind: SiteAdapterSourceKind::ServerSynced.as_str().to_string(),
|
||||
registry_version: default_registry_version(),
|
||||
directory,
|
||||
catalog_version: None,
|
||||
tenant_id: None,
|
||||
synced_at: None,
|
||||
adapter_count: 0,
|
||||
});
|
||||
return Ok(empty_site_adapter_catalog_status(source_kind, Some(dir)));
|
||||
}
|
||||
|
||||
let content = fs::read_to_string(&index_path)
|
||||
@@ -313,9 +334,9 @@ fn get_site_adapter_catalog_status_from_dir(
|
||||
|
||||
Ok(SiteAdapterCatalogStatus {
|
||||
exists: true,
|
||||
source_kind: SiteAdapterSourceKind::ServerSynced.as_str().to_string(),
|
||||
source_kind: source_kind.as_str().to_string(),
|
||||
registry_version: document.registry_version,
|
||||
directory,
|
||||
directory: Some(dir.display().to_string()),
|
||||
catalog_version: document.catalog_version,
|
||||
tenant_id: document.tenant_id,
|
||||
synced_at: document.synced_at,
|
||||
@@ -331,7 +352,10 @@ fn apply_site_adapter_catalog_bootstrap_to_dir(
|
||||
.ok_or_else(|| "payload 中未找到 siteAdapterCatalog".to_string())?;
|
||||
let document = parse_site_adapter_catalog_bootstrap_document(catalog_value)?;
|
||||
write_server_synced_catalog_to_dir(dir, document)?;
|
||||
get_site_adapter_catalog_status_from_dir(Some(dir.to_path_buf()))
|
||||
get_site_adapter_catalog_status_from_dir(
|
||||
Some(dir.to_path_buf()),
|
||||
SiteAdapterSourceKind::ServerSynced,
|
||||
)
|
||||
}
|
||||
|
||||
fn clear_site_adapter_catalog_cache_at_dir(
|
||||
@@ -356,7 +380,23 @@ fn clear_site_adapter_catalog_cache_at_dir(
|
||||
.map_err(|error| format!("清理站点适配器缓存失败 {}: {error}", dir.display()))?;
|
||||
}
|
||||
|
||||
get_site_adapter_catalog_status_from_dir(Some(dir))
|
||||
get_site_adapter_catalog_status_from_dir(Some(dir), SiteAdapterSourceKind::ServerSynced)
|
||||
}
|
||||
|
||||
fn empty_site_adapter_catalog_status(
|
||||
source_kind: SiteAdapterSourceKind,
|
||||
dir: Option<PathBuf>,
|
||||
) -> SiteAdapterCatalogStatus {
|
||||
SiteAdapterCatalogStatus {
|
||||
exists: false,
|
||||
source_kind: source_kind.as_str().to_string(),
|
||||
registry_version: default_registry_version(),
|
||||
directory: dir.map(|value| value.display().to_string()),
|
||||
catalog_version: None,
|
||||
tenant_id: None,
|
||||
synced_at: None,
|
||||
adapter_count: 0,
|
||||
}
|
||||
}
|
||||
|
||||
fn load_site_adapters_from_dir(
|
||||
@@ -524,7 +564,7 @@ fn write_server_synced_catalog_to_dir(
|
||||
return Err(format!("站点适配器重复: {}", entry.name));
|
||||
}
|
||||
|
||||
let script = normalize_required_text(&entry.script, "script")?;
|
||||
let script = resolve_server_synced_entry_script(&entry)?;
|
||||
let script_file = build_server_synced_script_file(&entry.name);
|
||||
let script_path = dir.join(&script_file);
|
||||
if let Some(parent) = script_path.parent() {
|
||||
@@ -569,6 +609,39 @@ fn write_server_synced_catalog_to_dir(
|
||||
.map_err(|error| format!("写入站点适配器索引失败 {}: {error}", index_path.display()))
|
||||
}
|
||||
|
||||
fn resolve_server_synced_entry_script(
|
||||
entry: &SiteAdapterCatalogBootstrapEntry,
|
||||
) -> Result<String, String> {
|
||||
if let Some(script) = entry
|
||||
.script
|
||||
.as_ref()
|
||||
.and_then(|value| normalize_optional_text(Some(value.clone())))
|
||||
{
|
||||
return Ok(script);
|
||||
}
|
||||
|
||||
let bundled_manifest = find_embedded_bundled_manifest_entry(&entry.name)?.ok_or_else(|| {
|
||||
format!(
|
||||
"站点适配器 {} 缺少 script,且未命中 bundled 回退",
|
||||
entry.name
|
||||
)
|
||||
})?;
|
||||
load_embedded_bundled_script(&bundled_manifest.script_file).map(|value| value.to_string())
|
||||
}
|
||||
|
||||
fn find_embedded_bundled_manifest_entry(
|
||||
adapter_name: &str,
|
||||
) -> Result<Option<SiteAdapterManifestEntry>, String> {
|
||||
let document: SiteAdapterRegistryDocument = serde_json::from_str(BUNDLED_INDEX_FALLBACK)
|
||||
.map_err(|error| format!("解析内置站点适配器索引失败: {error}"))?;
|
||||
let normalized_name = normalize_site_adapter_name(adapter_name);
|
||||
|
||||
Ok(document
|
||||
.adapters
|
||||
.into_iter()
|
||||
.find(|entry| normalize_site_adapter_name(&entry.name) == normalized_name))
|
||||
}
|
||||
|
||||
fn load_embedded_bundled_script(script_file: &str) -> Result<&'static str, String> {
|
||||
match script_file {
|
||||
"scripts/36kr-newsflash.js" => Ok(include_str!(
|
||||
@@ -583,6 +656,18 @@ fn load_embedded_bundled_script(script_file: &str) -> Result<&'static str, Strin
|
||||
"scripts/github-search.js" => Ok(include_str!(
|
||||
"../../resources/site-adapters/bundled/scripts/github-search.js"
|
||||
)),
|
||||
"scripts/linux-do-categories.js" => Ok(include_str!(
|
||||
"../../resources/site-adapters/bundled/scripts/linux-do-categories.js"
|
||||
)),
|
||||
"scripts/linux-do-hot.js" => Ok(include_str!(
|
||||
"../../resources/site-adapters/bundled/scripts/linux-do-hot.js"
|
||||
)),
|
||||
"scripts/smzdm-search.js" => Ok(include_str!(
|
||||
"../../resources/site-adapters/bundled/scripts/smzdm-search.js"
|
||||
)),
|
||||
"scripts/yahoo-finance-quote.js" => Ok(include_str!(
|
||||
"../../resources/site-adapters/bundled/scripts/yahoo-finance-quote.js"
|
||||
)),
|
||||
"scripts/zhihu-hot.js" => Ok(include_str!(
|
||||
"../../resources/site-adapters/bundled/scripts/zhihu-hot.js"
|
||||
)),
|
||||
@@ -610,6 +695,12 @@ fn resolve_server_synced_adapter_dir() -> Option<PathBuf> {
|
||||
.map(|root| root.join(SERVER_SYNCED_ADAPTER_RELATIVE_DIR))
|
||||
}
|
||||
|
||||
pub(crate) fn resolve_imported_adapter_dir() -> Option<PathBuf> {
|
||||
lime_core::app_paths::preferred_data_dir()
|
||||
.ok()
|
||||
.map(|root| root.join(IMPORTED_ADAPTER_RELATIVE_DIR))
|
||||
}
|
||||
|
||||
fn resolve_packaged_resource_root() -> Option<PathBuf> {
|
||||
let mut path = std::env::current_exe().ok()?;
|
||||
path.pop();
|
||||
@@ -784,8 +875,15 @@ fn default_registry_version() -> u32 {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::services::site_adapter_import_service::{
|
||||
compile_imported_yaml_adapter_bundle, persist_compiled_imported_adapters,
|
||||
ImportedYamlCompileOptions,
|
||||
};
|
||||
use tempfile::tempdir;
|
||||
|
||||
const REAL_WORLD_IMPORTED_ADAPTER_BUNDLE_FIXTURE: &str =
|
||||
include_str!("../../tests/fixtures/site-adapters/imported-real-world-bundle.yaml");
|
||||
|
||||
#[test]
|
||||
fn should_load_bundled_registry_from_resources() {
|
||||
let adapters = load_bundled_site_adapters().expect("bundled adapters should load");
|
||||
@@ -801,6 +899,72 @@ mod tests {
|
||||
assert!(github.script.contains("a.v-align-middle"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_load_selected_bundled_market_finance_and_community_adapters_from_embedded_index() {
|
||||
let adapters = load_site_adapters_from_embedded_index(SiteAdapterSourceKind::Bundled)
|
||||
.expect("embedded bundled adapters should load");
|
||||
|
||||
let linux_do_hot = adapters
|
||||
.iter()
|
||||
.find(|adapter| adapter.name == "linux-do/hot")
|
||||
.expect("linux-do/hot should exist");
|
||||
assert_eq!(linux_do_hot.source_kind, SiteAdapterSourceKind::Bundled);
|
||||
assert_eq!(linux_do_hot.source_version.as_deref(), Some("2026-03-28"));
|
||||
assert!(matches!(
|
||||
linux_do_hot.entry,
|
||||
SiteAdapterEntrySpec::FixedUrl { ref url } if url == "https://linux.do"
|
||||
));
|
||||
assert_eq!(
|
||||
linux_do_hot.auth_hint.as_deref(),
|
||||
Some("请先在浏览器中登录 linux.do,再重试该命令。")
|
||||
);
|
||||
assert!(linux_do_hot.script.contains("/top.json?period="));
|
||||
|
||||
let linux_do_categories = adapters
|
||||
.iter()
|
||||
.find(|adapter| adapter.name == "linux-do/categories")
|
||||
.expect("linux-do/categories should exist");
|
||||
assert_eq!(
|
||||
linux_do_categories.source_kind,
|
||||
SiteAdapterSourceKind::Bundled
|
||||
);
|
||||
assert_eq!(
|
||||
linux_do_categories.source_version.as_deref(),
|
||||
Some("2026-03-28")
|
||||
);
|
||||
assert!(matches!(
|
||||
linux_do_categories.entry,
|
||||
SiteAdapterEntrySpec::FixedUrl { ref url } if url == "https://linux.do"
|
||||
));
|
||||
assert!(linux_do_categories.script.contains("/categories.json"));
|
||||
|
||||
let yahoo = adapters
|
||||
.iter()
|
||||
.find(|adapter| adapter.name == "yahoo-finance/quote")
|
||||
.expect("yahoo-finance/quote should exist");
|
||||
assert_eq!(yahoo.source_kind, SiteAdapterSourceKind::Bundled);
|
||||
assert_eq!(yahoo.source_version.as_deref(), Some("2026-03-28"));
|
||||
assert!(matches!(
|
||||
yahoo.entry,
|
||||
SiteAdapterEntrySpec::UrlTemplate { ref template }
|
||||
if template == "https://finance.yahoo.com/quote/{{symbol|urlencode}}/"
|
||||
));
|
||||
assert!(yahoo.script.contains("query1.finance.yahoo.com"));
|
||||
|
||||
let smzdm = adapters
|
||||
.iter()
|
||||
.find(|adapter| adapter.name == "smzdm/search")
|
||||
.expect("smzdm/search should exist");
|
||||
assert_eq!(smzdm.source_kind, SiteAdapterSourceKind::Bundled);
|
||||
assert_eq!(smzdm.source_version.as_deref(), Some("2026-03-28"));
|
||||
assert!(matches!(
|
||||
smzdm.entry,
|
||||
SiteAdapterEntrySpec::UrlTemplate { ref template }
|
||||
if template == "https://search.smzdm.com/?c=home&s={{query|urlencode}}&v=b"
|
||||
));
|
||||
assert!(smzdm.script.contains("li.feed-row-wide"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_render_url_template_with_urlencode() {
|
||||
let spec = SiteAdapterSpec {
|
||||
@@ -876,6 +1040,114 @@ mod tests {
|
||||
assert_eq!(adapters[0].source_version.as_deref(), Some("sync-1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_load_imported_adapters_from_imported_catalog() {
|
||||
let temp_dir = tempdir().expect("temp dir should exist");
|
||||
let dir = temp_dir.path();
|
||||
fs::create_dir_all(dir.join("scripts")).expect("scripts dir should exist");
|
||||
fs::write(
|
||||
dir.join("index.json"),
|
||||
r#"
|
||||
{
|
||||
"catalog_version": "imported-catalog-1",
|
||||
"adapters": [
|
||||
{
|
||||
"name": "reddit/hot",
|
||||
"domain": "www.reddit.com",
|
||||
"description": "imported reddit hot",
|
||||
"read_only": true,
|
||||
"capabilities": ["research", "hot"],
|
||||
"args": [],
|
||||
"example": "reddit/hot {}",
|
||||
"entry": {
|
||||
"kind": "fixed_url",
|
||||
"url": "https://www.reddit.com"
|
||||
},
|
||||
"script_file": "scripts/reddit-hot.js",
|
||||
"source_version": "imported-1"
|
||||
}
|
||||
]
|
||||
}
|
||||
"#,
|
||||
)
|
||||
.expect("index should write");
|
||||
fs::write(
|
||||
dir.join("scripts/reddit-hot.js"),
|
||||
"async () => ({ ok: true, data: { items: [] } })",
|
||||
)
|
||||
.expect("script should write");
|
||||
|
||||
let adapters = load_site_adapters_from_dir(dir, SiteAdapterSourceKind::Imported)
|
||||
.expect("imported adapters should load");
|
||||
assert_eq!(adapters.len(), 1);
|
||||
assert_eq!(adapters[0].name, "reddit/hot");
|
||||
assert_eq!(adapters[0].source_kind, SiteAdapterSourceKind::Imported);
|
||||
assert_eq!(adapters[0].source_version.as_deref(), Some("imported-1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_load_real_world_imported_bundle_persisted_by_import_service() {
|
||||
let temp_dir = tempdir().expect("temp dir should exist");
|
||||
let adapters = compile_imported_yaml_adapter_bundle(
|
||||
REAL_WORLD_IMPORTED_ADAPTER_BUNDLE_FIXTURE,
|
||||
&ImportedYamlCompileOptions {
|
||||
read_only: true,
|
||||
source_version: Some("fixture-real-world".to_string()),
|
||||
},
|
||||
)
|
||||
.expect("real world bundle should compile");
|
||||
|
||||
let persist_result = persist_compiled_imported_adapters(
|
||||
temp_dir.path(),
|
||||
&adapters,
|
||||
Some("fixture-imported-catalog".to_string()),
|
||||
)
|
||||
.expect("real world bundle should persist");
|
||||
assert_eq!(persist_result.adapter_count, 5);
|
||||
|
||||
let status = get_site_adapter_catalog_status_from_dir(
|
||||
Some(temp_dir.path().to_path_buf()),
|
||||
SiteAdapterSourceKind::Imported,
|
||||
)
|
||||
.expect("imported catalog status should load");
|
||||
assert!(status.exists);
|
||||
assert_eq!(status.source_kind, "imported");
|
||||
assert_eq!(
|
||||
status.catalog_version.as_deref(),
|
||||
Some("fixture-imported-catalog")
|
||||
);
|
||||
assert_eq!(status.adapter_count, 5);
|
||||
|
||||
let loaded = load_site_adapters_from_dir(temp_dir.path(), SiteAdapterSourceKind::Imported)
|
||||
.expect("imported adapters should load");
|
||||
assert_eq!(loaded.len(), 5);
|
||||
|
||||
let yahoo_quote = loaded
|
||||
.iter()
|
||||
.find(|adapter| adapter.name == "yahoo-finance/quote")
|
||||
.expect("yahoo-finance/quote should exist");
|
||||
assert_eq!(yahoo_quote.source_kind, SiteAdapterSourceKind::Imported);
|
||||
assert_eq!(
|
||||
yahoo_quote.source_version.as_deref(),
|
||||
Some("fixture-real-world")
|
||||
);
|
||||
assert!(matches!(
|
||||
yahoo_quote.entry,
|
||||
SiteAdapterEntrySpec::UrlTemplate { ref template }
|
||||
if template == "https://finance.yahoo.com/quote/{{symbol|urlencode}}/"
|
||||
));
|
||||
assert_eq!(
|
||||
yahoo_quote.auth_hint.as_deref(),
|
||||
Some("该适配器依赖已有浏览器上下文,必要时请先在目标站点完成登录。")
|
||||
);
|
||||
|
||||
let smzdm_search = loaded
|
||||
.iter()
|
||||
.find(|adapter| adapter.name == "smzdm/search")
|
||||
.expect("smzdm/search should exist");
|
||||
assert!(smzdm_search.capabilities.contains(&"search".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_upgrade_legacy_server_synced_github_search_fixed_url_to_template() {
|
||||
let temp_dir = tempdir().expect("temp dir should exist");
|
||||
@@ -1018,6 +1290,60 @@ mod tests {
|
||||
assert_eq!(adapters[0].source_version.as_deref(), Some("tenant-sync-1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_persist_server_synced_bootstrap_catalog_with_bundled_script_fallback() {
|
||||
let temp_dir = tempdir().expect("temp dir should exist");
|
||||
let payload = serde_json::json!({
|
||||
"siteAdapterCatalog": {
|
||||
"registryVersion": 1,
|
||||
"catalogVersion": "tenant-sync-2",
|
||||
"tenantId": "tenant-demo",
|
||||
"syncedAt": "2026-03-28T10:00:00.000Z",
|
||||
"adapters": [
|
||||
{
|
||||
"name": "github/search",
|
||||
"domain": "github.com",
|
||||
"description": "server synced github search",
|
||||
"readOnly": true,
|
||||
"capabilities": ["search"],
|
||||
"args": [
|
||||
{
|
||||
"name": "query",
|
||||
"description": "搜索关键词",
|
||||
"required": true,
|
||||
"argType": "string",
|
||||
"example": "lime"
|
||||
}
|
||||
],
|
||||
"example": "github/search {\"query\":\"lime\"}",
|
||||
"entry": {
|
||||
"kind": "url_template",
|
||||
"template": "https://github.com/search?q={{query|urlencode}}&type=repositories"
|
||||
},
|
||||
"sourceVersion": "tenant-sync-2"
|
||||
}
|
||||
]
|
||||
}
|
||||
});
|
||||
|
||||
let status = apply_site_adapter_catalog_bootstrap_to_dir(temp_dir.path(), &payload)
|
||||
.expect("bootstrap catalog should persist with bundled fallback");
|
||||
assert!(status.exists);
|
||||
assert_eq!(status.catalog_version.as_deref(), Some("tenant-sync-2"));
|
||||
|
||||
let script_content = fs::read_to_string(temp_dir.path().join("scripts/github-search.js"))
|
||||
.expect("fallback bundled script should be written");
|
||||
assert!(script_content.contains("helpers.uniqueBy"));
|
||||
|
||||
let adapters =
|
||||
load_site_adapters_from_dir(temp_dir.path(), SiteAdapterSourceKind::ServerSynced)
|
||||
.expect("persisted adapters should load");
|
||||
assert_eq!(adapters.len(), 1);
|
||||
assert_eq!(adapters[0].name, "github/search");
|
||||
assert_eq!(adapters[0].source_version.as_deref(), Some("tenant-sync-2"));
|
||||
assert_eq!(adapters[0].source_kind, SiteAdapterSourceKind::ServerSynced);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_clear_server_synced_catalog_cache() {
|
||||
let temp_dir = tempdir().expect("temp dir should exist");
|
||||
|
||||
@@ -69,6 +69,36 @@ pub struct SiteAdapterRecommendation {
|
||||
pub score: u32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct SiteAdapterLaunchReadinessRequest {
|
||||
pub adapter_name: String,
|
||||
#[serde(default)]
|
||||
pub profile_key: Option<String>,
|
||||
#[serde(default)]
|
||||
pub target_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum SiteAdapterLaunchReadinessStatus {
|
||||
Ready,
|
||||
RequiresBrowserRuntime,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SiteAdapterLaunchReadinessResult {
|
||||
pub status: SiteAdapterLaunchReadinessStatus,
|
||||
pub adapter: String,
|
||||
pub domain: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub profile_key: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub target_id: Option<String>,
|
||||
pub message: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub report_hint: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct RunSiteAdapterRequest {
|
||||
pub adapter_name: String,
|
||||
@@ -86,6 +116,10 @@ pub struct RunSiteAdapterRequest {
|
||||
pub project_id: Option<String>,
|
||||
#[serde(default)]
|
||||
pub save_title: Option<String>,
|
||||
#[serde(default)]
|
||||
pub require_attached_session: Option<bool>,
|
||||
#[serde(default)]
|
||||
pub skill_title: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
@@ -174,6 +208,14 @@ struct SiteAdapterRecommendationCandidate {
|
||||
score: u32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct SiteAdapterAttachedLaunchCandidate {
|
||||
profile_key: String,
|
||||
target_id: String,
|
||||
current_url_matches: bool,
|
||||
saved_existing_session: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum SiteAdapterTransportRoute {
|
||||
ManagedCdp,
|
||||
@@ -284,6 +326,23 @@ pub async fn recommend_site_adapters(
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn get_site_adapter_launch_readiness(
|
||||
db: &DbConnection,
|
||||
request: SiteAdapterLaunchReadinessRequest,
|
||||
) -> Result<SiteAdapterLaunchReadinessResult, String> {
|
||||
let normalized_name = normalize_site_adapter_name(&request.adapter_name);
|
||||
let spec = find_site_adapter_spec(&normalized_name)?
|
||||
.ok_or_else(|| "未找到对应的站点适配器".to_string())?;
|
||||
|
||||
resolve_site_adapter_launch_readiness_for_spec(
|
||||
db,
|
||||
&spec,
|
||||
request.profile_key.as_deref(),
|
||||
request.target_id.as_deref(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub fn build_site_result_document_title(adapter_name: &str, custom_title: Option<&str>) -> String {
|
||||
let normalized_custom_title = custom_title
|
||||
.map(str::trim)
|
||||
@@ -586,9 +645,54 @@ pub async fn run_site_adapter(
|
||||
}
|
||||
};
|
||||
|
||||
let profile_key =
|
||||
match resolve_effective_profile_key(db, request.profile_key.as_deref(), &spec.domain).await
|
||||
let attached_session_readiness = if request.require_attached_session.unwrap_or(false) {
|
||||
match resolve_site_adapter_launch_readiness_for_spec(
|
||||
db,
|
||||
&spec,
|
||||
request.profile_key.as_deref(),
|
||||
request.target_id.as_deref(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(result) => {
|
||||
if result.status != SiteAdapterLaunchReadinessStatus::Ready {
|
||||
return build_error_result(
|
||||
&spec,
|
||||
result
|
||||
.profile_key
|
||||
.clone()
|
||||
.unwrap_or_else(|| requested_profile_key.clone()),
|
||||
None,
|
||||
result.target_id.clone(),
|
||||
entry_url,
|
||||
"attached_session_required",
|
||||
&result.message,
|
||||
);
|
||||
}
|
||||
Some(result)
|
||||
}
|
||||
Err(error) => {
|
||||
return build_error_result(
|
||||
&spec,
|
||||
requested_profile_key.clone(),
|
||||
None,
|
||||
None,
|
||||
entry_url,
|
||||
"internal_error",
|
||||
&error,
|
||||
);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let resolved_request_profile_key = attached_session_readiness
|
||||
.as_ref()
|
||||
.and_then(|result| result.profile_key.as_deref())
|
||||
.or(request.profile_key.as_deref());
|
||||
let profile_key =
|
||||
match resolve_effective_profile_key(db, resolved_request_profile_key, &spec.domain).await {
|
||||
Ok(value) => value,
|
||||
Err(error) => {
|
||||
return build_error_result(
|
||||
@@ -617,6 +721,21 @@ pub async fn run_site_adapter(
|
||||
);
|
||||
}
|
||||
};
|
||||
if request.require_attached_session.unwrap_or(false)
|
||||
&& transport_route != SiteAdapterTransportRoute::ExistingSession
|
||||
{
|
||||
return build_error_result(
|
||||
&spec,
|
||||
profile_key,
|
||||
None,
|
||||
attached_session_readiness
|
||||
.as_ref()
|
||||
.and_then(|result| result.target_id.clone()),
|
||||
entry_url,
|
||||
"attached_session_required",
|
||||
"当前执行链路没有附着到真实浏览器会话,请先去浏览器工作台连接目标站点后重试。",
|
||||
);
|
||||
}
|
||||
|
||||
let wrapped_script = match build_wrapped_adapter_script(&spec.script, &args) {
|
||||
Ok(value) => value,
|
||||
@@ -633,13 +752,16 @@ pub async fn run_site_adapter(
|
||||
}
|
||||
};
|
||||
let timeout_ms = normalize_timeout_ms(request.timeout_ms);
|
||||
let resolved_target_id = attached_session_readiness
|
||||
.and_then(|result| result.target_id)
|
||||
.or_else(|| normalize_requested_target_id(request.target_id.as_deref()));
|
||||
|
||||
match transport_route {
|
||||
SiteAdapterTransportRoute::ExistingSession => {
|
||||
run_existing_session_adapter(
|
||||
&spec,
|
||||
profile_key,
|
||||
request.target_id,
|
||||
resolved_target_id,
|
||||
entry_url,
|
||||
timeout_ms,
|
||||
wrapped_script,
|
||||
@@ -651,7 +773,7 @@ pub async fn run_site_adapter(
|
||||
db,
|
||||
&spec,
|
||||
profile_key,
|
||||
request.target_id,
|
||||
resolved_target_id,
|
||||
entry_url,
|
||||
timeout_ms,
|
||||
wrapped_script,
|
||||
@@ -759,6 +881,13 @@ fn normalize_requested_profile_key(profile_key: Option<&str>) -> Option<String>
|
||||
.map(ToString::to_string)
|
||||
}
|
||||
|
||||
fn normalize_requested_target_id(target_id: Option<&str>) -> Option<String> {
|
||||
target_id
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToString::to_string)
|
||||
}
|
||||
|
||||
fn resolve_requested_profile_key(profile_key: Option<&str>) -> String {
|
||||
normalize_requested_profile_key(profile_key).unwrap_or_else(|| DEFAULT_PROFILE_KEY.to_string())
|
||||
}
|
||||
@@ -951,6 +1080,199 @@ async fn resolve_effective_profile_key(
|
||||
)
|
||||
}
|
||||
|
||||
fn build_site_adapter_launch_readiness_result(
|
||||
status: SiteAdapterLaunchReadinessStatus,
|
||||
spec: &SiteAdapterSpec,
|
||||
profile_key: Option<String>,
|
||||
target_id: Option<String>,
|
||||
message: impl Into<String>,
|
||||
) -> SiteAdapterLaunchReadinessResult {
|
||||
let report_hint = match status {
|
||||
SiteAdapterLaunchReadinessStatus::Ready => None,
|
||||
SiteAdapterLaunchReadinessStatus::RequiresBrowserRuntime => {
|
||||
build_site_adapter_report_hint("attached_session_required")
|
||||
}
|
||||
};
|
||||
|
||||
SiteAdapterLaunchReadinessResult {
|
||||
status,
|
||||
adapter: spec.name.clone(),
|
||||
domain: spec.domain.clone(),
|
||||
profile_key,
|
||||
target_id,
|
||||
message: message.into(),
|
||||
report_hint,
|
||||
}
|
||||
}
|
||||
|
||||
fn build_site_adapter_attached_session_required_message(spec: &SiteAdapterSpec) -> String {
|
||||
format!(
|
||||
"当前没有检测到已附着到真实浏览器的 {} 页面,请先去浏览器工作台连接浏览器并打开目标页面。",
|
||||
spec.domain
|
||||
)
|
||||
}
|
||||
|
||||
fn build_site_adapter_attached_session_missing_target_message(spec: &SiteAdapterSpec) -> String {
|
||||
format!(
|
||||
"已检测到真实浏览器会话,但当前没有命中 {} 的目标标签页;请先打开目标页面后再回到 Claw 执行。",
|
||||
spec.domain
|
||||
)
|
||||
}
|
||||
|
||||
fn build_site_adapter_attached_session_ready_message(spec: &SiteAdapterSpec) -> String {
|
||||
format!(
|
||||
"已检测到 {} 的真实浏览器页面,Claw 可以直接复用当前会话执行。",
|
||||
spec.domain
|
||||
)
|
||||
}
|
||||
|
||||
async fn resolve_site_adapter_launch_readiness_for_spec(
|
||||
db: &DbConnection,
|
||||
spec: &SiteAdapterSpec,
|
||||
profile_key: Option<&str>,
|
||||
target_id: Option<&str>,
|
||||
) -> Result<SiteAdapterLaunchReadinessResult, String> {
|
||||
let normalized_profile_key = normalize_requested_profile_key(profile_key);
|
||||
let normalized_target_id = normalize_requested_target_id(target_id);
|
||||
let profiles = load_active_browser_profiles(db)?;
|
||||
let status_snapshot = chrome_bridge::chrome_bridge_hub()
|
||||
.get_status_snapshot()
|
||||
.await;
|
||||
|
||||
if let Some(requested_profile_key) = normalized_profile_key.clone() {
|
||||
let transport = load_profile_transport(db, &requested_profile_key)?;
|
||||
if transport == Some(BrowserProfileTransportKind::ManagedCdp) {
|
||||
return Ok(build_site_adapter_launch_readiness_result(
|
||||
SiteAdapterLaunchReadinessStatus::RequiresBrowserRuntime,
|
||||
spec,
|
||||
Some(requested_profile_key),
|
||||
normalized_target_id,
|
||||
"当前资料属于 Lime 托管浏览器,不允许在 Claw 内静默接管执行;请改走浏览器工作台。",
|
||||
));
|
||||
}
|
||||
|
||||
let observer = status_snapshot
|
||||
.observers
|
||||
.iter()
|
||||
.find(|item| item.profile_key == requested_profile_key);
|
||||
if observer.is_none() {
|
||||
return Ok(build_site_adapter_launch_readiness_result(
|
||||
SiteAdapterLaunchReadinessStatus::RequiresBrowserRuntime,
|
||||
spec,
|
||||
Some(requested_profile_key),
|
||||
normalized_target_id,
|
||||
build_site_adapter_attached_session_required_message(spec),
|
||||
));
|
||||
}
|
||||
|
||||
if let Some(explicit_target_id) = normalized_target_id.clone() {
|
||||
return Ok(build_site_adapter_launch_readiness_result(
|
||||
SiteAdapterLaunchReadinessStatus::Ready,
|
||||
spec,
|
||||
Some(requested_profile_key),
|
||||
Some(explicit_target_id),
|
||||
build_site_adapter_attached_session_ready_message(spec),
|
||||
));
|
||||
}
|
||||
|
||||
let tabs = match load_existing_session_tabs(&requested_profile_key).await {
|
||||
Ok(result) => result,
|
||||
Err(error) => {
|
||||
tracing::debug!(
|
||||
"[site_capability] readiness 读取 existing_session 标签页失败: profile_key={}, error={}",
|
||||
requested_profile_key,
|
||||
error
|
||||
);
|
||||
Vec::new()
|
||||
}
|
||||
};
|
||||
let selected_target = select_existing_session_target(&tabs, &spec.domain).map(|tab| tab.id);
|
||||
let is_ready = selected_target.is_some();
|
||||
|
||||
return Ok(build_site_adapter_launch_readiness_result(
|
||||
if is_ready {
|
||||
SiteAdapterLaunchReadinessStatus::Ready
|
||||
} else {
|
||||
SiteAdapterLaunchReadinessStatus::RequiresBrowserRuntime
|
||||
},
|
||||
spec,
|
||||
Some(requested_profile_key),
|
||||
selected_target,
|
||||
if is_ready {
|
||||
build_site_adapter_attached_session_ready_message(spec)
|
||||
} else {
|
||||
build_site_adapter_attached_session_missing_target_message(spec)
|
||||
},
|
||||
));
|
||||
}
|
||||
|
||||
let mut attached_candidates = Vec::new();
|
||||
for observer in &status_snapshot.observers {
|
||||
let transport = profiles
|
||||
.iter()
|
||||
.find(|profile| profile.profile_key == observer.profile_key)
|
||||
.map(|profile| profile.transport_kind);
|
||||
if transport == Some(BrowserProfileTransportKind::ManagedCdp) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let tabs = match load_existing_session_tabs(&observer.profile_key).await {
|
||||
Ok(result) => result,
|
||||
Err(error) => {
|
||||
tracing::debug!(
|
||||
"[site_capability] readiness 读取自动附着标签页失败: profile_key={}, error={}",
|
||||
observer.profile_key,
|
||||
error
|
||||
);
|
||||
Vec::new()
|
||||
}
|
||||
};
|
||||
let Some(selected_target) = select_existing_session_target(&tabs, &spec.domain) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
attached_candidates.push(SiteAdapterAttachedLaunchCandidate {
|
||||
profile_key: observer.profile_key.clone(),
|
||||
target_id: selected_target.id,
|
||||
current_url_matches: observer_matches_site_domain(observer, &spec.domain),
|
||||
saved_existing_session: transport == Some(BrowserProfileTransportKind::ExistingSession),
|
||||
});
|
||||
}
|
||||
|
||||
if let Some(candidate) = attached_candidates
|
||||
.into_iter()
|
||||
.max_by_key(|item| (item.current_url_matches, item.saved_existing_session))
|
||||
{
|
||||
return Ok(build_site_adapter_launch_readiness_result(
|
||||
SiteAdapterLaunchReadinessStatus::Ready,
|
||||
spec,
|
||||
Some(candidate.profile_key),
|
||||
Some(candidate.target_id),
|
||||
build_site_adapter_attached_session_ready_message(spec),
|
||||
));
|
||||
}
|
||||
|
||||
let has_attached_observer = status_snapshot.observers.iter().any(|observer| {
|
||||
profiles
|
||||
.iter()
|
||||
.find(|profile| profile.profile_key == observer.profile_key)
|
||||
.map(|profile| profile.transport_kind != BrowserProfileTransportKind::ManagedCdp)
|
||||
.unwrap_or(true)
|
||||
});
|
||||
|
||||
Ok(build_site_adapter_launch_readiness_result(
|
||||
SiteAdapterLaunchReadinessStatus::RequiresBrowserRuntime,
|
||||
spec,
|
||||
None,
|
||||
None,
|
||||
if has_attached_observer {
|
||||
build_site_adapter_attached_session_missing_target_message(spec)
|
||||
} else {
|
||||
build_site_adapter_attached_session_required_message(spec)
|
||||
},
|
||||
))
|
||||
}
|
||||
|
||||
fn resolve_transport_route_from_state(
|
||||
profile_transport: Option<BrowserProfileTransportKind>,
|
||||
has_attached_observer: bool,
|
||||
@@ -2174,6 +2496,14 @@ fn looks_like_no_matching_context_message(message: &str) -> bool {
|
||||
|| normalized.contains("上下文")
|
||||
}
|
||||
|
||||
fn looks_like_attached_session_required_message(message: &str) -> bool {
|
||||
let normalized = message.to_ascii_lowercase();
|
||||
normalized.contains("附着")
|
||||
|| normalized.contains("真实浏览器")
|
||||
|| normalized.contains("浏览器工作台")
|
||||
|| normalized.contains("attached session")
|
||||
}
|
||||
|
||||
fn normalize_site_adapter_error_code(
|
||||
error_code: Option<&str>,
|
||||
error_message: Option<&str>,
|
||||
@@ -2193,6 +2523,14 @@ fn normalize_site_adapter_error_code(
|
||||
return Some("auth_required".to_string());
|
||||
}
|
||||
|
||||
if matches!(
|
||||
normalized_code.as_deref(),
|
||||
Some("attached_session_required")
|
||||
) || looks_like_attached_session_required_message(normalized_message)
|
||||
{
|
||||
return Some("attached_session_required".to_string());
|
||||
}
|
||||
|
||||
if matches!(
|
||||
normalized_code.as_deref(),
|
||||
Some("target_not_found") | Some("no_matching_context")
|
||||
@@ -2225,6 +2563,10 @@ fn looks_like_navigation_timeout_error(error: &str) -> bool {
|
||||
|
||||
fn build_site_adapter_report_hint(error_code: &str) -> Option<String> {
|
||||
match error_code {
|
||||
"attached_session_required" => Some(
|
||||
"Claw 不会在后台偷偷启动浏览器;请先进入浏览器工作台连接真实浏览器并打开目标站点页面,再返回 Claw 重试。"
|
||||
.to_string(),
|
||||
),
|
||||
"auth_required" => Some(
|
||||
"请先确认当前浏览器资料已经登录目标站点,再重试;如果仍失败,请附上当前页面 URL 和登录状态。"
|
||||
.to_string(),
|
||||
@@ -2336,6 +2678,63 @@ mod tests {
|
||||
.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_expose_selected_bundled_market_finance_and_community_adapters() {
|
||||
let linux_do_hot = get_site_adapter("linux-do/hot").expect("linux-do/hot should resolve");
|
||||
assert_eq!(linux_do_hot.source_kind.as_deref(), Some("bundled"));
|
||||
assert_eq!(linux_do_hot.source_version.as_deref(), Some("2026-03-28"));
|
||||
assert_eq!(
|
||||
linux_do_hot
|
||||
.example_args
|
||||
.get("period")
|
||||
.and_then(Value::as_str),
|
||||
Some("weekly")
|
||||
);
|
||||
assert_eq!(
|
||||
linux_do_hot
|
||||
.example_args
|
||||
.get("limit")
|
||||
.and_then(Value::as_i64),
|
||||
Some(10)
|
||||
);
|
||||
|
||||
let linux_do_categories =
|
||||
get_site_adapter("linux-do/categories").expect("linux-do/categories should resolve");
|
||||
assert_eq!(linux_do_categories.source_kind.as_deref(), Some("bundled"));
|
||||
assert_eq!(
|
||||
linux_do_categories.source_version.as_deref(),
|
||||
Some("2026-03-28")
|
||||
);
|
||||
assert_eq!(
|
||||
linux_do_categories
|
||||
.example_args
|
||||
.get("limit")
|
||||
.and_then(Value::as_i64),
|
||||
Some(10)
|
||||
);
|
||||
|
||||
let yahoo =
|
||||
get_site_adapter("yahoo-finance/quote").expect("yahoo-finance/quote should resolve");
|
||||
assert_eq!(yahoo.source_kind.as_deref(), Some("bundled"));
|
||||
assert_eq!(yahoo.source_version.as_deref(), Some("2026-03-28"));
|
||||
assert_eq!(
|
||||
yahoo.example_args.get("symbol").and_then(Value::as_str),
|
||||
Some("AAPL")
|
||||
);
|
||||
|
||||
let smzdm = get_site_adapter("smzdm/search").expect("smzdm/search should resolve");
|
||||
assert_eq!(smzdm.source_kind.as_deref(), Some("bundled"));
|
||||
assert_eq!(smzdm.source_version.as_deref(), Some("2026-03-28"));
|
||||
assert_eq!(
|
||||
smzdm.example_args.get("query").and_then(Value::as_str),
|
||||
Some("Mac mini")
|
||||
);
|
||||
assert_eq!(
|
||||
smzdm.example_args.get("limit").and_then(Value::as_i64),
|
||||
Some(5)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_search_site_adapters_by_keyword() {
|
||||
let adapters = search_site_adapters("issue");
|
||||
@@ -2343,6 +2742,25 @@ mod tests {
|
||||
assert_eq!(adapters[0].name, "github/issues");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_search_selected_bundled_adapters_by_domain_and_capability() {
|
||||
let community = search_site_adapters("linux.do");
|
||||
assert!(community
|
||||
.iter()
|
||||
.any(|adapter| adapter.name == "linux-do/hot"));
|
||||
assert!(community
|
||||
.iter()
|
||||
.any(|adapter| adapter.name == "linux-do/categories"));
|
||||
|
||||
let finance = search_site_adapters("finance");
|
||||
assert!(finance
|
||||
.iter()
|
||||
.any(|adapter| adapter.name == "yahoo-finance/quote"));
|
||||
|
||||
let deals = search_site_adapters("deals");
|
||||
assert!(deals.iter().any(|adapter| adapter.name == "smzdm/search"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_prefer_attached_existing_session_profile_for_matching_site() {
|
||||
let attached_profile_keys = HashSet::from(["research_attach".to_string()]);
|
||||
@@ -2716,6 +3134,81 @@ mod tests {
|
||||
assert!(hint.contains("timeout_ms"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_build_attached_session_required_report_hint() {
|
||||
let hint = build_site_adapter_report_hint("attached_session_required")
|
||||
.expect("attached_session_required 应返回提示");
|
||||
assert!(hint.contains("不会在后台偷偷启动浏览器"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn should_report_requires_browser_runtime_when_no_attached_session_exists() {
|
||||
let db = setup_test_db();
|
||||
|
||||
let readiness = get_site_adapter_launch_readiness(
|
||||
&db,
|
||||
SiteAdapterLaunchReadinessRequest {
|
||||
adapter_name: "github/search".to_string(),
|
||||
profile_key: None,
|
||||
target_id: None,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("readiness should resolve");
|
||||
|
||||
assert_eq!(
|
||||
readiness.status,
|
||||
SiteAdapterLaunchReadinessStatus::RequiresBrowserRuntime
|
||||
);
|
||||
assert!(readiness.message.contains("浏览器工作台"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn should_block_managed_profile_when_attached_session_is_required() {
|
||||
let db = setup_test_db();
|
||||
{
|
||||
let conn = lock_db(&db).expect("lock db should succeed");
|
||||
BrowserProfileDao::upsert(
|
||||
&conn,
|
||||
&UpsertBrowserProfileInput {
|
||||
id: None,
|
||||
profile_key: "managed-github".to_string(),
|
||||
name: "托管 GitHub".to_string(),
|
||||
description: Some("托管浏览器".to_string()),
|
||||
site_scope: Some("github.com".to_string()),
|
||||
launch_url: Some("https://github.com".to_string()),
|
||||
transport_kind: BrowserProfileTransportKind::ManagedCdp,
|
||||
profile_dir: "/tmp/managed-github".to_string(),
|
||||
managed_profile_dir: Some("/tmp/managed-github".to_string()),
|
||||
},
|
||||
)
|
||||
.expect("managed profile should save");
|
||||
}
|
||||
|
||||
let result = run_site_adapter(
|
||||
&db,
|
||||
RunSiteAdapterRequest {
|
||||
adapter_name: "github/search".to_string(),
|
||||
args: serde_json::json!({"query":"mcp"}),
|
||||
profile_key: Some("managed-github".to_string()),
|
||||
target_id: None,
|
||||
timeout_ms: Some(5_000),
|
||||
content_id: None,
|
||||
project_id: None,
|
||||
save_title: None,
|
||||
require_attached_session: Some(true),
|
||||
skill_title: Some("GitHub 仓库线索检索".to_string()),
|
||||
},
|
||||
)
|
||||
.await;
|
||||
|
||||
assert!(!result.ok);
|
||||
assert_eq!(
|
||||
result.error_code.as_deref(),
|
||||
Some("attached_session_required")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_save_existing_site_result_to_project_as_document() {
|
||||
let db = setup_test_db();
|
||||
@@ -2741,6 +3234,8 @@ mod tests {
|
||||
content_id: None,
|
||||
project_id: None,
|
||||
save_title: None,
|
||||
require_attached_session: None,
|
||||
skill_title: None,
|
||||
},
|
||||
result: SiteAdapterRunResult {
|
||||
ok: true,
|
||||
@@ -2815,6 +3310,8 @@ mod tests {
|
||||
content_id: None,
|
||||
project_id: None,
|
||||
save_title: None,
|
||||
require_attached_session: None,
|
||||
skill_title: None,
|
||||
},
|
||||
result: SiteAdapterRunResult {
|
||||
ok: false,
|
||||
@@ -2866,6 +3363,8 @@ mod tests {
|
||||
content_id: None,
|
||||
project_id: Some(workspace.id.clone()),
|
||||
save_title: Some("自动保存的 GitHub MCP 搜索结果".to_string()),
|
||||
require_attached_session: None,
|
||||
skill_title: None,
|
||||
};
|
||||
let result = SiteAdapterRunResult {
|
||||
ok: true,
|
||||
@@ -2926,6 +3425,8 @@ mod tests {
|
||||
content_id: None,
|
||||
project_id: Some("project-1".to_string()),
|
||||
save_title: None,
|
||||
require_attached_session: None,
|
||||
skill_title: None,
|
||||
};
|
||||
let result = SiteAdapterRunResult {
|
||||
ok: false,
|
||||
@@ -2995,6 +3496,8 @@ mod tests {
|
||||
content_id: None,
|
||||
project_id: None,
|
||||
save_title: None,
|
||||
require_attached_session: None,
|
||||
skill_title: None,
|
||||
},
|
||||
)
|
||||
.await;
|
||||
@@ -3055,6 +3558,8 @@ mod tests {
|
||||
content_id: Some(existing.id.clone()),
|
||||
project_id: None,
|
||||
save_title: Some("不会用于当前主稿".to_string()),
|
||||
require_attached_session: None,
|
||||
skill_title: None,
|
||||
},
|
||||
result: SiteAdapterRunResult {
|
||||
ok: true,
|
||||
@@ -3154,6 +3659,8 @@ mod tests {
|
||||
content_id: Some(existing.id.clone()),
|
||||
project_id: None,
|
||||
save_title: Some("不应覆盖当前主稿标题".to_string()),
|
||||
require_attached_session: None,
|
||||
skill_title: None,
|
||||
};
|
||||
let result = SiteAdapterRunResult {
|
||||
ok: true,
|
||||
|
||||
Reference in New Issue
Block a user