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:
@@ -235,6 +235,13 @@ impl AsterAgentWrapper {
|
||||
lime_agent::persist_session_recent_preferences(session_id, preferences).await
|
||||
}
|
||||
|
||||
pub async fn persist_session_recent_access_mode(
|
||||
session_id: &str,
|
||||
recent_access_mode: lime_agent::SessionExecutionRuntimeAccessMode,
|
||||
) -> Result<(), String> {
|
||||
lime_agent::persist_session_recent_access_mode(session_id, recent_access_mode).await
|
||||
}
|
||||
|
||||
pub async fn persist_session_recent_team_selection(
|
||||
session_id: &str,
|
||||
recent_team_selection: lime_agent::SessionExecutionRuntimeRecentTeamSelection,
|
||||
|
||||
@@ -1290,9 +1290,11 @@ pub fn run() {
|
||||
commands::site_capability_cmd::site_recommend_adapters,
|
||||
commands::site_capability_cmd::site_search_adapters,
|
||||
commands::site_capability_cmd::site_get_adapter_info,
|
||||
commands::site_capability_cmd::site_get_adapter_launch_readiness,
|
||||
commands::site_capability_cmd::site_get_adapter_catalog_status,
|
||||
commands::site_capability_cmd::site_apply_adapter_catalog_bootstrap,
|
||||
commands::site_capability_cmd::site_clear_adapter_catalog_cache,
|
||||
commands::site_capability_cmd::site_import_adapter_yaml_bundle,
|
||||
commands::site_capability_cmd::site_run_adapter,
|
||||
commands::site_capability_cmd::site_debug_run_adapter,
|
||||
commands::site_capability_cmd::site_save_adapter_result,
|
||||
@@ -1603,6 +1605,7 @@ pub fn run() {
|
||||
commands::webview_cmd::close_chrome_profile_session,
|
||||
commands::webview_cmd::get_chrome_bridge_endpoint_info,
|
||||
commands::webview_cmd::get_chrome_bridge_status,
|
||||
commands::webview_cmd::disconnect_browser_connector_session,
|
||||
commands::webview_cmd::chrome_bridge_execute_command,
|
||||
commands::webview_cmd::get_browser_backends_status,
|
||||
commands::webview_cmd::get_browser_backend_policy,
|
||||
|
||||
@@ -99,6 +99,14 @@ pub async fn agent_runtime_update_session(
|
||||
.await?;
|
||||
}
|
||||
|
||||
if let Some(recent_access_mode) = request.recent_access_mode {
|
||||
AsterAgentWrapper::persist_session_recent_access_mode(
|
||||
&trimmed_session_id,
|
||||
recent_access_mode,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
|
||||
if let Some(recent_team_selection) = request.recent_team_selection {
|
||||
AsterAgentWrapper::persist_session_recent_team_selection(
|
||||
&trimmed_session_id,
|
||||
|
||||
@@ -71,6 +71,12 @@ pub struct AsterChatRequest {
|
||||
/// 是否偏好 reasoning 变体
|
||||
#[serde(default, alias = "thinkingEnabled")]
|
||||
pub thinking_enabled: Option<bool>,
|
||||
/// 执行权限审批策略
|
||||
#[serde(default, alias = "approvalPolicy")]
|
||||
pub approval_policy: Option<String>,
|
||||
/// 执行沙箱策略
|
||||
#[serde(default, alias = "sandboxPolicy")]
|
||||
pub sandbox_policy: Option<String>,
|
||||
/// 项目 ID(可选,用于注入项目上下文到 System Prompt)
|
||||
#[serde(default, alias = "projectId")]
|
||||
pub project_id: Option<String>,
|
||||
@@ -116,6 +122,10 @@ pub struct AgentTurnConfigSnapshot {
|
||||
pub model_preference: Option<String>,
|
||||
#[serde(default, alias = "thinkingEnabled")]
|
||||
pub thinking_enabled: Option<bool>,
|
||||
#[serde(default, alias = "approvalPolicy")]
|
||||
pub approval_policy: Option<String>,
|
||||
#[serde(default, alias = "sandboxPolicy")]
|
||||
pub sandbox_policy: Option<String>,
|
||||
#[serde(default, alias = "executionStrategy")]
|
||||
pub execution_strategy: Option<AsterExecutionStrategy>,
|
||||
#[serde(default, alias = "webSearch")]
|
||||
@@ -172,6 +182,12 @@ impl From<AgentRuntimeSubmitTurnRequest> for AsterChatRequest {
|
||||
thinking_enabled: turn_config
|
||||
.as_ref()
|
||||
.and_then(|config| config.thinking_enabled),
|
||||
approval_policy: turn_config
|
||||
.as_ref()
|
||||
.and_then(|config| config.approval_policy.clone()),
|
||||
sandbox_policy: turn_config
|
||||
.as_ref()
|
||||
.and_then(|config| config.sandbox_policy.clone()),
|
||||
project_id: None,
|
||||
workspace_id: request.workspace_id.unwrap_or_default(),
|
||||
web_search: turn_config.as_ref().and_then(|config| config.web_search),
|
||||
@@ -1595,6 +1611,8 @@ pub struct AgentRuntimeUpdateSessionRequest {
|
||||
pub model_name: Option<String>,
|
||||
#[serde(default, alias = "executionStrategy")]
|
||||
pub execution_strategy: Option<AsterExecutionStrategy>,
|
||||
#[serde(default, alias = "recentAccessMode")]
|
||||
pub recent_access_mode: Option<lime_agent::SessionExecutionRuntimeAccessMode>,
|
||||
#[serde(default, alias = "recentPreferences")]
|
||||
pub recent_preferences: Option<lime_agent::SessionExecutionRuntimePreferences>,
|
||||
#[serde(default, alias = "recentTeamSelection")]
|
||||
|
||||
@@ -242,7 +242,7 @@ pub(super) async fn build_turn_runtime_statuses(
|
||||
|
||||
let decided = if request_tool_policy.requires_web_search() {
|
||||
(
|
||||
"已决定:先联网检索".to_string(),
|
||||
"先联网检索".to_string(),
|
||||
"当前任务已被明确指定为先搜索后答复,会先完成联网核实再继续生成。".to_string(),
|
||||
vec![
|
||||
"用户明确要求联网搜索".to_string(),
|
||||
@@ -251,7 +251,7 @@ pub(super) async fn build_turn_runtime_statuses(
|
||||
)
|
||||
} else if news_expansion_needed {
|
||||
(
|
||||
"已决定:先联网扩搜".to_string(),
|
||||
"先联网扩搜".to_string(),
|
||||
"当前输入属于新闻或最新动态综述类请求,会先分批执行多组联网搜索,再基于结果做主题归纳与交叉核实。"
|
||||
.to_string(),
|
||||
vec![
|
||||
@@ -261,7 +261,7 @@ pub(super) async fn build_turn_runtime_statuses(
|
||||
)
|
||||
} else if subagent_enabled && message_suggests_subagent(&request.message) {
|
||||
(
|
||||
"已决定:优先拆分为多代理".to_string(),
|
||||
"优先拆分为多代理".to_string(),
|
||||
"用户输入更适合并行分工处理,先按多代理路径组织执行。".to_string(),
|
||||
vec![
|
||||
"检测到并行/多角度需求".to_string(),
|
||||
@@ -270,7 +270,7 @@ pub(super) async fn build_turn_runtime_statuses(
|
||||
)
|
||||
} else if task_enabled && message_suggests_task(&request.message) {
|
||||
(
|
||||
"已决定:升级为后台任务".to_string(),
|
||||
"升级为后台任务".to_string(),
|
||||
"用户输入更接近耗时或异步推进场景,优先走后台任务链路。".to_string(),
|
||||
vec![
|
||||
"检测到排队/持续执行诉求".to_string(),
|
||||
@@ -279,7 +279,7 @@ pub(super) async fn build_turn_runtime_statuses(
|
||||
)
|
||||
} else if thinking_enabled && reasoning_supported {
|
||||
(
|
||||
"已决定:先深度思考".to_string(),
|
||||
"先深度思考".to_string(),
|
||||
"当前模型支持 reasoning,先做更充分的意图理解与方案判断,再决定是否调用搜索或工具。"
|
||||
.to_string(),
|
||||
vec![
|
||||
@@ -289,7 +289,7 @@ pub(super) async fn build_turn_runtime_statuses(
|
||||
)
|
||||
} else if thinking_enabled {
|
||||
(
|
||||
"已决定:轻量理解后回答".to_string(),
|
||||
"轻量理解后回答".to_string(),
|
||||
"当前模型不支持显式 reasoning,先做轻量意图理解,再决定是否需要搜索或其他能力。"
|
||||
.to_string(),
|
||||
vec![
|
||||
@@ -301,7 +301,7 @@ pub(super) async fn build_turn_runtime_statuses(
|
||||
&& message_suggests_live_search(&request.message)
|
||||
{
|
||||
(
|
||||
"已决定:先联网核实".to_string(),
|
||||
"先联网核实".to_string(),
|
||||
"问题包含明显时效性或实时性特征,先搜索核实再回答更稳妥。".to_string(),
|
||||
vec![
|
||||
"已检测到最新/实时信息需求".to_string(),
|
||||
@@ -310,7 +310,7 @@ pub(super) async fn build_turn_runtime_statuses(
|
||||
)
|
||||
} else if message_suggests_planning(&request.message) {
|
||||
(
|
||||
"已决定:先规划再输出".to_string(),
|
||||
"先规划再输出".to_string(),
|
||||
"当前请求更像计划或方案拆解,会先整理执行路径和关键步骤。".to_string(),
|
||||
vec![
|
||||
"检测到计划/拆解需求".to_string(),
|
||||
@@ -319,7 +319,7 @@ pub(super) async fn build_turn_runtime_statuses(
|
||||
)
|
||||
} else if message_suggests_content_generation(&request.message) {
|
||||
(
|
||||
"已决定:先生成草稿".to_string(),
|
||||
"先生成草稿".to_string(),
|
||||
"当前请求属于内容生成类,优先基于已有上下文生成一版草稿,信息不足时附带假设说明,而非反复追问。".to_string(),
|
||||
vec![
|
||||
"检测到内容生成需求".to_string(),
|
||||
@@ -328,7 +328,7 @@ pub(super) async fn build_turn_runtime_statuses(
|
||||
)
|
||||
} else {
|
||||
(
|
||||
"已决定:直接回答优先".to_string(),
|
||||
"直接回答优先".to_string(),
|
||||
"当前请求无需默认升级为搜索或任务,先直接给出结果,必要时再调用工具。".to_string(),
|
||||
vec![
|
||||
"默认保持直接回答".to_string(),
|
||||
|
||||
@@ -115,6 +115,8 @@ pub(in crate::commands::aster_agent_cmd) fn extend_map_with_harness_fields(
|
||||
("gateKey", "gate_key"),
|
||||
("run_title", "run_title"),
|
||||
("runTitle", "run_title"),
|
||||
("access_mode", "access_mode"),
|
||||
("accessMode", "access_mode"),
|
||||
("content_id", "content_id"),
|
||||
("contentId", "content_id"),
|
||||
("preferred_team_preset_id", "preferred_team_preset_id"),
|
||||
@@ -173,6 +175,15 @@ pub(in crate::commands::aster_agent_cmd) fn extend_map_with_harness_fields(
|
||||
}
|
||||
}
|
||||
|
||||
fn derive_request_access_mode(
|
||||
request: &AsterChatRequest,
|
||||
) -> Option<lime_agent::SessionExecutionRuntimeAccessMode> {
|
||||
lime_agent::SessionExecutionRuntimeAccessMode::from_runtime_policies(
|
||||
request.approval_policy.as_deref(),
|
||||
request.sandbox_policy.as_deref(),
|
||||
)
|
||||
}
|
||||
|
||||
pub(in crate::commands::aster_agent_cmd) fn build_chat_run_metadata_base(
|
||||
request: &AsterChatRequest,
|
||||
workspace_id: &str,
|
||||
@@ -200,6 +211,14 @@ pub(in crate::commands::aster_agent_cmd) fn build_chat_run_metadata_base(
|
||||
"message_length".to_string(),
|
||||
serde_json::json!(request.message.chars().count()),
|
||||
);
|
||||
metadata.insert(
|
||||
"approval_policy".to_string(),
|
||||
serde_json::json!(request.approval_policy.clone()),
|
||||
);
|
||||
metadata.insert(
|
||||
"sandbox_policy".to_string(),
|
||||
serde_json::json!(request.sandbox_policy.clone()),
|
||||
);
|
||||
metadata.insert(
|
||||
"web_search_enabled".to_string(),
|
||||
serde_json::json!(request_tool_policy.effective_web_search),
|
||||
@@ -217,6 +236,14 @@ pub(in crate::commands::aster_agent_cmd) fn build_chat_run_metadata_base(
|
||||
serde_json::json!(auto_continue_metadata),
|
||||
);
|
||||
extend_map_with_harness_fields(&mut metadata, request.metadata.as_ref());
|
||||
if !metadata.contains_key("access_mode") {
|
||||
if let Some(access_mode) = derive_request_access_mode(request) {
|
||||
metadata.insert(
|
||||
"access_mode".to_string(),
|
||||
serde_json::json!(access_mode.as_str()),
|
||||
);
|
||||
}
|
||||
}
|
||||
for (target_key, preference_keys, session_value) in [
|
||||
(
|
||||
"thinking_enabled",
|
||||
|
||||
@@ -126,6 +126,33 @@ pub(crate) fn resolve_request_web_search_preference_from_sources(
|
||||
})
|
||||
}
|
||||
|
||||
fn resolve_runtime_access_mode_from_request(
|
||||
request: &AsterChatRequest,
|
||||
) -> Option<lime_agent::SessionExecutionRuntimeAccessMode> {
|
||||
lime_agent::SessionExecutionRuntimeAccessMode::from_runtime_policies(
|
||||
request.approval_policy.as_deref(),
|
||||
request.sandbox_policy.as_deref(),
|
||||
)
|
||||
.or_else(|| {
|
||||
let access_mode =
|
||||
extract_harness_string(request.metadata.as_ref(), &["access_mode", "accessMode"]);
|
||||
lime_agent::SessionExecutionRuntimeAccessMode::from_access_mode_text(access_mode.as_deref())
|
||||
})
|
||||
}
|
||||
|
||||
fn backfill_runtime_access_policies(request: &mut AsterChatRequest) {
|
||||
let Some(access_mode) = resolve_runtime_access_mode_from_request(request) else {
|
||||
return;
|
||||
};
|
||||
|
||||
if request.approval_policy.is_none() {
|
||||
request.approval_policy = Some(access_mode.approval_policy().to_string());
|
||||
}
|
||||
if request.sandbox_policy.is_none() {
|
||||
request.sandbox_policy = Some(access_mode.sandbox_policy().to_string());
|
||||
}
|
||||
}
|
||||
|
||||
fn should_skip_artifact_document_autopersist(
|
||||
run_observation: &Arc<Mutex<ChatRunObservation>>,
|
||||
final_text_output: &str,
|
||||
@@ -334,6 +361,7 @@ async fn execute_aster_chat_request(
|
||||
session_recent_harness_context.run_title.as_deref(),
|
||||
session_recent_harness_context.content_id.as_deref(),
|
||||
);
|
||||
backfill_runtime_access_policies(&mut request);
|
||||
|
||||
// 直接使用前端传递的 session_id
|
||||
// LimeSessionStore 会在 add_message 时自动创建不存在的 session
|
||||
@@ -506,6 +534,8 @@ async fn execute_aster_chat_request(
|
||||
.set_working_dir(Some(workspace_root.clone()))
|
||||
.set_effective_user_message(request.message.clone())
|
||||
.set_include_context_trace(include_context_trace)
|
||||
.set_approval_policy(request.approval_policy.clone())
|
||||
.set_sandbox_policy(request.sandbox_policy.clone())
|
||||
.set_turn_context_metadata_from_value(request.metadata.as_ref());
|
||||
|
||||
// 构建 system_prompt:优先使用项目上下文,其次使用 session 的 system_prompt
|
||||
@@ -1987,6 +2017,8 @@ mod tests {
|
||||
provider_preference: None,
|
||||
model_preference: None,
|
||||
thinking_enabled: None,
|
||||
approval_policy: None,
|
||||
sandbox_policy: None,
|
||||
project_id: None,
|
||||
workspace_id: "workspace-artifact".to_string(),
|
||||
web_search: None,
|
||||
@@ -2072,6 +2104,8 @@ mod tests {
|
||||
provider_preference: None,
|
||||
model_preference: None,
|
||||
thinking_enabled: None,
|
||||
approval_policy: None,
|
||||
sandbox_policy: None,
|
||||
project_id: None,
|
||||
workspace_id: "workspace-artifact".to_string(),
|
||||
web_search: None,
|
||||
@@ -2138,6 +2172,8 @@ mod tests {
|
||||
provider_preference: None,
|
||||
model_preference: None,
|
||||
thinking_enabled: None,
|
||||
approval_policy: None,
|
||||
sandbox_policy: None,
|
||||
project_id: None,
|
||||
workspace_id: "workspace-artifact".to_string(),
|
||||
web_search: None,
|
||||
@@ -2197,6 +2233,8 @@ mod tests {
|
||||
provider_preference: None,
|
||||
model_preference: None,
|
||||
thinking_enabled: None,
|
||||
approval_policy: None,
|
||||
sandbox_policy: None,
|
||||
project_id: None,
|
||||
workspace_id: "workspace-social".to_string(),
|
||||
web_search: None,
|
||||
@@ -2240,6 +2278,45 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn backfill_runtime_access_policies_should_derive_from_legacy_harness_access_mode() {
|
||||
let mut request = AsterChatRequest {
|
||||
message: "继续执行".to_string(),
|
||||
session_id: "session-access-legacy".to_string(),
|
||||
event_name: "agent_stream".to_string(),
|
||||
images: None,
|
||||
provider_config: None,
|
||||
provider_preference: None,
|
||||
model_preference: None,
|
||||
thinking_enabled: None,
|
||||
approval_policy: None,
|
||||
sandbox_policy: None,
|
||||
project_id: None,
|
||||
workspace_id: "workspace-access".to_string(),
|
||||
web_search: None,
|
||||
search_mode: None,
|
||||
execution_strategy: None,
|
||||
auto_continue: None,
|
||||
system_prompt: None,
|
||||
metadata: Some(json!({
|
||||
"harness": {
|
||||
"access_mode": "full-access"
|
||||
}
|
||||
})),
|
||||
turn_id: None,
|
||||
queue_if_busy: None,
|
||||
queued_turn_id: None,
|
||||
};
|
||||
|
||||
backfill_runtime_access_policies(&mut request);
|
||||
|
||||
assert_eq!(request.approval_policy.as_deref(), Some("never"));
|
||||
assert_eq!(
|
||||
request.sandbox_policy.as_deref(),
|
||||
Some("danger-full-access")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn update_compaction_session_metrics_should_move_summary_tokens_to_current_window() {
|
||||
ensure_runtime_turn_test_session_manager().await;
|
||||
|
||||
@@ -682,6 +682,8 @@ pub(crate) async fn agent_runtime_spawn_subagent_internal(
|
||||
provider_preference: None,
|
||||
model_preference: None,
|
||||
thinking_enabled: None,
|
||||
approval_policy: None,
|
||||
sandbox_policy: None,
|
||||
project_id: None,
|
||||
workspace_id,
|
||||
web_search: None,
|
||||
@@ -758,6 +760,8 @@ pub(crate) async fn agent_runtime_send_subagent_input_internal(
|
||||
provider_preference: None,
|
||||
model_preference: None,
|
||||
thinking_enabled: None,
|
||||
approval_policy: None,
|
||||
sandbox_policy: None,
|
||||
project_id: None,
|
||||
workspace_id,
|
||||
web_search: None,
|
||||
|
||||
@@ -928,6 +928,8 @@ mod tests {
|
||||
provider_preference: None,
|
||||
model_preference: None,
|
||||
thinking_enabled: None,
|
||||
approval_policy: None,
|
||||
sandbox_policy: None,
|
||||
project_id: Some("project-1".to_string()),
|
||||
workspace_id: "workspace-1".to_string(),
|
||||
web_search: Some(false),
|
||||
@@ -1112,6 +1114,8 @@ mod tests {
|
||||
provider_preference: None,
|
||||
model_preference: None,
|
||||
thinking_enabled: None,
|
||||
approval_policy: None,
|
||||
sandbox_policy: None,
|
||||
project_id: Some("project-1".to_string()),
|
||||
workspace_id: "workspace-1".to_string(),
|
||||
web_search: Some(false),
|
||||
@@ -1185,6 +1189,8 @@ mod tests {
|
||||
provider_preference: None,
|
||||
model_preference: None,
|
||||
thinking_enabled: None,
|
||||
approval_policy: None,
|
||||
sandbox_policy: None,
|
||||
project_id: Some("project-1".to_string()),
|
||||
workspace_id: "workspace-1".to_string(),
|
||||
web_search: None,
|
||||
@@ -1235,6 +1241,66 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_chat_run_metadata_base_derives_access_mode_from_formal_turn_context() {
|
||||
let metadata = build_chat_run_metadata_base(
|
||||
&AsterChatRequest {
|
||||
message: "hello".to_string(),
|
||||
session_id: "session-access".to_string(),
|
||||
event_name: "event-access".to_string(),
|
||||
images: None,
|
||||
provider_config: None,
|
||||
provider_preference: None,
|
||||
model_preference: None,
|
||||
thinking_enabled: None,
|
||||
approval_policy: Some("never".to_string()),
|
||||
sandbox_policy: Some("danger-full-access".to_string()),
|
||||
project_id: None,
|
||||
workspace_id: "workspace-1".to_string(),
|
||||
web_search: Some(false),
|
||||
search_mode: None,
|
||||
execution_strategy: Some(AsterExecutionStrategy::React),
|
||||
auto_continue: None,
|
||||
system_prompt: None,
|
||||
metadata: None,
|
||||
turn_id: None,
|
||||
queue_if_busy: None,
|
||||
queued_turn_id: None,
|
||||
},
|
||||
"workspace-1",
|
||||
AsterExecutionStrategy::React,
|
||||
&RequestToolPolicy {
|
||||
search_mode: RequestToolPolicyMode::Disabled,
|
||||
effective_web_search: false,
|
||||
required_tools: vec![],
|
||||
allowed_tools: vec![],
|
||||
disallowed_tools: vec![],
|
||||
},
|
||||
false,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
metadata
|
||||
.get("approval_policy")
|
||||
.and_then(serde_json::Value::as_str),
|
||||
Some("never")
|
||||
);
|
||||
assert_eq!(
|
||||
metadata
|
||||
.get("sandbox_policy")
|
||||
.and_then(serde_json::Value::as_str),
|
||||
Some("danger-full-access")
|
||||
);
|
||||
assert_eq!(
|
||||
metadata
|
||||
.get("access_mode")
|
||||
.and_then(serde_json::Value::as_str),
|
||||
Some("full-access")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_chat_run_observation_records_nested_artifact_protocol_paths_from_tool_result() {
|
||||
let mut observation = ChatRunObservation::default();
|
||||
|
||||
@@ -602,6 +602,8 @@ impl Tool for LimeSiteTool {
|
||||
.as_ref()
|
||||
.and_then(|target| target.project_id.clone()),
|
||||
save_title,
|
||||
require_attached_session: None,
|
||||
skill_title: None,
|
||||
};
|
||||
|
||||
let result = Self::apply_save_target_to_run_result(
|
||||
@@ -715,6 +717,8 @@ mod tests {
|
||||
content_id: None,
|
||||
project_id: None,
|
||||
save_title: None,
|
||||
require_attached_session: None,
|
||||
skill_title: None,
|
||||
};
|
||||
let result = SiteAdapterRunResult {
|
||||
ok: true,
|
||||
@@ -785,6 +789,8 @@ mod tests {
|
||||
content_id: None,
|
||||
project_id: None,
|
||||
save_title: None,
|
||||
require_attached_session: None,
|
||||
skill_title: None,
|
||||
};
|
||||
let result = SiteAdapterRunResult {
|
||||
ok: true,
|
||||
|
||||
@@ -24,12 +24,12 @@ pub struct CodexCliStatus {
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
/// 检查 Codex CLI 状态
|
||||
/// 检查 Lime CLI 状态
|
||||
#[tauri::command]
|
||||
pub async fn check_codex_cli_status() -> Result<CodexCliStatus, String> {
|
||||
let mut status = CodexCliStatus::default();
|
||||
|
||||
// 1. 检查 codex 命令是否存在
|
||||
// 1. 检查 Lime 命令是否存在
|
||||
let version_result = Command::new("codex")
|
||||
.arg("--version")
|
||||
.stdout(Stdio::piped())
|
||||
@@ -150,7 +150,7 @@ pub async fn get_external_tools() -> Result<Vec<ExternalTool>, String> {
|
||||
tools.push(ExternalTool {
|
||||
id: "codex-cli".to_string(),
|
||||
name: "Codex CLI".to_string(),
|
||||
description: "OpenAI Codex 命令行工具,支持 Agent 模式和工具调用".to_string(),
|
||||
description: "Lime 命令行工具,支持 Agent 模式和工具调用".to_string(),
|
||||
installed: codex_status.installed,
|
||||
configured: codex_status.logged_in,
|
||||
install_command: "npm i -g @openai/codex".to_string(),
|
||||
|
||||
@@ -1,13 +1,18 @@
|
||||
use crate::database::DbConnection;
|
||||
use crate::services::site_adapter_import_service::{
|
||||
import_imported_yaml_adapter_bundle_to_default_dir, ImportedYamlCompileOptions,
|
||||
PersistImportedCatalogResult,
|
||||
};
|
||||
use crate::services::site_adapter_registry::{
|
||||
apply_site_adapter_catalog_bootstrap, clear_site_adapter_catalog_cache,
|
||||
get_site_adapter_catalog_status, SiteAdapterCatalogStatus,
|
||||
};
|
||||
use crate::services::site_capability_service::{
|
||||
get_site_adapter, list_site_adapters, recommend_site_adapters, run_site_adapter,
|
||||
run_site_adapter_with_optional_save, save_existing_site_result_to_project,
|
||||
search_site_adapters, RunSiteAdapterRequest, SaveSiteAdapterResultRequest,
|
||||
SavedSiteAdapterContent, SiteAdapterDefinition, SiteAdapterRecommendation,
|
||||
get_site_adapter, get_site_adapter_launch_readiness, list_site_adapters,
|
||||
recommend_site_adapters, run_site_adapter, run_site_adapter_with_optional_save,
|
||||
save_existing_site_result_to_project, search_site_adapters, RunSiteAdapterRequest,
|
||||
SaveSiteAdapterResultRequest, SavedSiteAdapterContent, SiteAdapterDefinition,
|
||||
SiteAdapterLaunchReadinessRequest, SiteAdapterLaunchReadinessResult, SiteAdapterRecommendation,
|
||||
SiteAdapterRunResult,
|
||||
};
|
||||
use serde::Deserialize;
|
||||
@@ -35,6 +40,21 @@ pub struct SiteAdapterRecommendRequest {
|
||||
pub limit: Option<usize>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct SiteAdapterImportYamlBundleRequest {
|
||||
pub yaml_bundle: String,
|
||||
#[serde(default)]
|
||||
pub catalog_version: Option<String>,
|
||||
#[serde(default)]
|
||||
pub source_version: Option<String>,
|
||||
#[serde(default = "default_site_adapter_import_read_only")]
|
||||
pub read_only: bool,
|
||||
}
|
||||
|
||||
fn default_site_adapter_import_read_only() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn site_list_adapters() -> Result<Vec<SiteAdapterDefinition>, String> {
|
||||
Ok(list_site_adapters())
|
||||
@@ -62,6 +82,14 @@ pub fn site_get_adapter_info(
|
||||
get_site_adapter(&request.name).ok_or_else(|| "未找到对应的站点适配器".to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn site_get_adapter_launch_readiness(
|
||||
db: State<'_, DbConnection>,
|
||||
request: SiteAdapterLaunchReadinessRequest,
|
||||
) -> Result<SiteAdapterLaunchReadinessResult, String> {
|
||||
get_site_adapter_launch_readiness(db.inner(), request).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn site_get_adapter_catalog_status() -> Result<SiteAdapterCatalogStatus, String> {
|
||||
get_site_adapter_catalog_status()
|
||||
@@ -79,6 +107,20 @@ pub fn site_clear_adapter_catalog_cache() -> Result<SiteAdapterCatalogStatus, St
|
||||
clear_site_adapter_catalog_cache()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn site_import_adapter_yaml_bundle(
|
||||
request: SiteAdapterImportYamlBundleRequest,
|
||||
) -> Result<PersistImportedCatalogResult, String> {
|
||||
import_imported_yaml_adapter_bundle_to_default_dir(
|
||||
&request.yaml_bundle,
|
||||
&ImportedYamlCompileOptions {
|
||||
read_only: request.read_only,
|
||||
source_version: request.source_version,
|
||||
},
|
||||
request.catalog_version,
|
||||
)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn site_run_adapter(
|
||||
db: State<'_, DbConnection>,
|
||||
|
||||
@@ -26,7 +26,8 @@ use lime_browser_runtime::{
|
||||
EventBufferSnapshot, OpenSessionRequest,
|
||||
};
|
||||
use lime_server::chrome_bridge::{
|
||||
self, ChromeBridgeCommandRequest, ChromeBridgeCommandResult, ChromeBridgeStatusSnapshot,
|
||||
self, ChromeBridgeCommandRequest, ChromeBridgeCommandResult, ChromeBridgeDisconnectResult,
|
||||
ChromeBridgeStatusSnapshot,
|
||||
};
|
||||
use once_cell::sync::Lazy;
|
||||
use serde::{Deserialize, Serialize};
|
||||
@@ -1173,6 +1174,16 @@ pub async fn get_chrome_bridge_status_global() -> Result<ChromeBridgeStatusSnaps
|
||||
get_chrome_bridge_status().await
|
||||
}
|
||||
|
||||
/// 主动断开 ChromeBridge 当前连接(用于连接器设置页)
|
||||
#[tauri::command]
|
||||
pub async fn disconnect_browser_connector_session(
|
||||
profile_key: Option<String>,
|
||||
) -> Result<ChromeBridgeDisconnectResult, String> {
|
||||
Ok(chrome_bridge::chrome_bridge_hub()
|
||||
.disconnect_connections(profile_key.as_deref())
|
||||
.await)
|
||||
}
|
||||
|
||||
/// 通过 ChromeBridge 执行命令(用于设置页测试)
|
||||
#[tauri::command]
|
||||
pub async fn chrome_bridge_execute_command(
|
||||
|
||||
@@ -9,9 +9,12 @@ pub mod dispatcher;
|
||||
|
||||
#[cfg(debug_assertions)]
|
||||
use axum::{
|
||||
extract::State,
|
||||
extract::{Query, State},
|
||||
http::{request::Parts as RequestParts, HeaderValue, Method},
|
||||
response::{IntoResponse, Response},
|
||||
response::{
|
||||
sse::{Event as SseEvent, KeepAlive, Sse},
|
||||
IntoResponse, Response,
|
||||
},
|
||||
routing::{get, post},
|
||||
Json, Router,
|
||||
};
|
||||
@@ -20,6 +23,8 @@ use serde::{Deserialize, Serialize};
|
||||
#[cfg(debug_assertions)]
|
||||
use std::sync::Arc;
|
||||
#[cfg(debug_assertions)]
|
||||
use std::{convert::Infallible, time::Duration};
|
||||
#[cfg(debug_assertions)]
|
||||
use tokio::sync::RwLock;
|
||||
#[cfg(debug_assertions)]
|
||||
use tower_http::cors::{AllowOrigin, CorsLayer};
|
||||
@@ -34,7 +39,7 @@ use lime_services::{
|
||||
provider_pool_service::ProviderPoolService, skill_service::SkillService,
|
||||
};
|
||||
#[cfg(debug_assertions)]
|
||||
use tauri::AppHandle;
|
||||
use tauri::{AppHandle, EventId, Listener};
|
||||
|
||||
#[cfg(debug_assertions)]
|
||||
#[derive(Debug, Deserialize)]
|
||||
@@ -51,6 +56,12 @@ pub struct InvokeResponse {
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
#[cfg(debug_assertions)]
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct EventStreamRequest {
|
||||
pub event: String,
|
||||
}
|
||||
|
||||
#[cfg(debug_assertions)]
|
||||
#[derive(Clone)]
|
||||
pub struct DevBridgeState {
|
||||
@@ -143,6 +154,7 @@ impl DevBridgeServer {
|
||||
|
||||
let app = Router::new()
|
||||
.route("/invoke", post(invoke_command))
|
||||
.route("/events", get(stream_events))
|
||||
.route("/health", get(health_check).post(health_check))
|
||||
.layer(
|
||||
// CORS 配置 - 允许本地开发前端访问
|
||||
@@ -176,6 +188,20 @@ impl DevBridgeServer {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(debug_assertions)]
|
||||
#[derive(Clone)]
|
||||
struct DevBridgeEventListenerGuard {
|
||||
app_handle: AppHandle,
|
||||
listener_id: EventId,
|
||||
}
|
||||
|
||||
#[cfg(debug_assertions)]
|
||||
impl Drop for DevBridgeEventListenerGuard {
|
||||
fn drop(&mut self) {
|
||||
self.app_handle.unlisten(self.listener_id);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(debug_assertions)]
|
||||
fn invoke_command(
|
||||
State(state): State<DevBridgeState>,
|
||||
@@ -198,6 +224,63 @@ fn invoke_command(
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(debug_assertions)]
|
||||
async fn stream_events(
|
||||
State(state): State<DevBridgeState>,
|
||||
Query(req): Query<EventStreamRequest>,
|
||||
) -> Response {
|
||||
let event_name = req.event.trim().to_string();
|
||||
if event_name.is_empty() {
|
||||
return (
|
||||
axum::http::StatusCode::BAD_REQUEST,
|
||||
"missing event query parameter",
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
|
||||
let Some(app_handle) = state.app_handle.clone() else {
|
||||
return (
|
||||
axum::http::StatusCode::SERVICE_UNAVAILABLE,
|
||||
"dev bridge app handle unavailable",
|
||||
)
|
||||
.into_response();
|
||||
};
|
||||
|
||||
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::<String>();
|
||||
let listener_event_name = event_name.clone();
|
||||
let listener_id = app_handle.listen_any(listener_event_name.clone(), move |event| {
|
||||
let payload = event.payload();
|
||||
let payload_value = serde_json::from_str::<serde_json::Value>(payload)
|
||||
.unwrap_or_else(|_| serde_json::Value::String(payload.to_string()));
|
||||
let serialized = serde_json::json!({
|
||||
"event": listener_event_name.clone(),
|
||||
"payload": payload_value,
|
||||
})
|
||||
.to_string();
|
||||
let _ = tx.send(serialized);
|
||||
});
|
||||
|
||||
let cleanup_handle = app_handle.clone();
|
||||
let stream = async_stream::stream! {
|
||||
let _listener_guard = DevBridgeEventListenerGuard {
|
||||
app_handle: cleanup_handle,
|
||||
listener_id,
|
||||
};
|
||||
|
||||
while let Some(payload) = rx.recv().await {
|
||||
yield Ok::<SseEvent, Infallible>(SseEvent::default().data(payload));
|
||||
}
|
||||
};
|
||||
|
||||
Sse::new(stream)
|
||||
.keep_alive(
|
||||
KeepAlive::new()
|
||||
.interval(Duration::from_secs(15))
|
||||
.text("keepalive"),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
|
||||
#[cfg(debug_assertions)]
|
||||
async fn health_check() -> impl IntoResponse {
|
||||
Json(serde_json::json!({
|
||||
|
||||
@@ -29,6 +29,18 @@ pub(super) async fn try_handle(
|
||||
"get_chrome_bridge_status" => serde_json::to_value(
|
||||
crate::commands::webview_cmd::get_chrome_bridge_status_global().await?,
|
||||
)?,
|
||||
"disconnect_browser_connector_session" => {
|
||||
let args = args_or_default(args);
|
||||
let profile_key = args
|
||||
.get("profileKey")
|
||||
.or_else(|| args.get("profile_key"))
|
||||
.and_then(|value| value.as_str())
|
||||
.map(|value| value.to_string());
|
||||
serde_json::to_value(
|
||||
crate::commands::webview_cmd::disconnect_browser_connector_session(profile_key)
|
||||
.await?,
|
||||
)?
|
||||
}
|
||||
"get_browser_backend_policy" => serde_json::to_value(
|
||||
crate::commands::webview_cmd::get_browser_backend_policy_global().await?,
|
||||
)?,
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
use super::super::get_db;
|
||||
use super::{parse_request, DynError};
|
||||
use crate::dev_bridge::DevBridgeState;
|
||||
use crate::services::site_adapter_import_service::{
|
||||
import_imported_yaml_adapter_bundle_to_default_dir, ImportedYamlCompileOptions,
|
||||
};
|
||||
use crate::services::site_adapter_registry::{
|
||||
apply_site_adapter_catalog_bootstrap, clear_site_adapter_catalog_cache,
|
||||
get_site_adapter_catalog_status,
|
||||
@@ -48,6 +51,18 @@ pub(super) async fn try_handle(
|
||||
"site_clear_adapter_catalog_cache" => {
|
||||
serde_json::to_value(clear_site_adapter_catalog_cache()?)?
|
||||
}
|
||||
"site_import_adapter_yaml_bundle" => {
|
||||
let request: crate::commands::site_capability_cmd::SiteAdapterImportYamlBundleRequest =
|
||||
parse_request(args)?;
|
||||
serde_json::to_value(import_imported_yaml_adapter_bundle_to_default_dir(
|
||||
&request.yaml_bundle,
|
||||
&ImportedYamlCompileOptions {
|
||||
read_only: request.read_only,
|
||||
source_version: request.source_version,
|
||||
},
|
||||
request.catalog_version,
|
||||
)?)?
|
||||
}
|
||||
"site_run_adapter" | "site_debug_run_adapter" => {
|
||||
let request: crate::services::site_capability_service::RunSiteAdapterRequest =
|
||||
parse_request(args)?;
|
||||
|
||||
@@ -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