chore: release v0.98.0

This commit is contained in:
coso
2026-03-29 14:15:34 +08:00
parent dfbbb3d204
commit deceacc699
435 changed files with 35673 additions and 8535 deletions
+7
View File
@@ -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,
+3
View File
@@ -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,
+3 -3
View File
@@ -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(),
+46 -4
View File
@@ -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>,
+12 -1
View File
@@ -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(
+86 -3
View File
@@ -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)?;
+1
View File
@@ -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(),
+204 -119
View File
@@ -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());
}
}
+1
View File
@@ -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
+357 -31
View File
@@ -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,