mirror of
https://github.com/aiclientproxy/proxycast.git
synced 2026-09-24 23:10:56 +08:00
chore: release v0.99.0
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
use crate::agent_tools::catalog::{
|
||||
tool_catalog_entries_for_surface, tool_catalog_entry, workspace_default_allowed_tool_names,
|
||||
ToolCapability, ToolLifecycle, ToolPermissionPlane, ToolSourceKind, ToolSurfaceProfile,
|
||||
WorkspaceToolSurface,
|
||||
WorkspaceToolSurface, BROWSER_RUNTIME_TOOL_PREFIX,
|
||||
};
|
||||
use crate::agent_tools::execution::{
|
||||
resolve_tool_execution_policy_resolution, ToolExecutionPolicySource,
|
||||
@@ -18,6 +18,55 @@ use lime_core::tool_calling::{
|
||||
use serde::Serialize;
|
||||
use std::collections::{BTreeSet, HashSet};
|
||||
|
||||
fn extract_harness_object(
|
||||
request_metadata: Option<&serde_json::Value>,
|
||||
) -> Option<&serde_json::Map<String, serde_json::Value>> {
|
||||
let metadata = request_metadata?;
|
||||
let object = metadata.as_object()?;
|
||||
if let Some(harness) = object.get("harness").and_then(serde_json::Value::as_object) {
|
||||
return Some(harness);
|
||||
}
|
||||
Some(object)
|
||||
}
|
||||
|
||||
fn extract_harness_nested_object<'a>(
|
||||
request_metadata: Option<&'a serde_json::Value>,
|
||||
keys: &[&str],
|
||||
) -> Option<&'a serde_json::Map<String, serde_json::Value>> {
|
||||
let harness = extract_harness_object(request_metadata)?;
|
||||
keys.iter()
|
||||
.filter_map(|key| harness.get(*key))
|
||||
.find_map(serde_json::Value::as_object)
|
||||
}
|
||||
|
||||
fn extract_object_string(
|
||||
object: &serde_json::Map<String, serde_json::Value>,
|
||||
keys: &[&str],
|
||||
) -> Option<String> {
|
||||
keys.iter()
|
||||
.filter_map(|key| object.get(*key))
|
||||
.find_map(serde_json::Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(str::to_string)
|
||||
}
|
||||
|
||||
fn should_lock_service_skill_launch_to_site_tools(
|
||||
request_metadata: Option<&serde_json::Value>,
|
||||
) -> bool {
|
||||
let Some(launch) = extract_harness_nested_object(
|
||||
request_metadata,
|
||||
&["service_skill_launch", "serviceSkillLaunch"],
|
||||
) else {
|
||||
return false;
|
||||
};
|
||||
|
||||
let kind =
|
||||
extract_object_string(launch, &["kind"]).unwrap_or_else(|| "site_adapter".to_string());
|
||||
kind == "site_adapter"
|
||||
&& extract_object_string(launch, &["adapter_name", "adapterName"]).is_some()
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum RuntimeExtensionSourceKind {
|
||||
@@ -212,6 +261,8 @@ pub fn build_tool_inventory(input: AgentToolInventoryBuildInput) -> AgentToolInv
|
||||
persisted_policy: persisted_execution_policy.as_ref(),
|
||||
request_metadata: request_metadata.as_ref(),
|
||||
};
|
||||
let lock_service_skill_launch_to_site_tools =
|
||||
should_lock_service_skill_launch_to_site_tools(request_metadata.as_ref());
|
||||
|
||||
let mut mcp_servers = mcp_server_names
|
||||
.into_iter()
|
||||
@@ -229,6 +280,9 @@ pub fn build_tool_inventory(input: AgentToolInventoryBuildInput) -> AgentToolInv
|
||||
|
||||
let catalog_tools = tool_catalog_entries_for_surface(surface)
|
||||
.into_iter()
|
||||
.filter(|entry| {
|
||||
!lock_service_skill_launch_to_site_tools || entry.name != BROWSER_RUNTIME_TOOL_PREFIX
|
||||
})
|
||||
.map(|entry| {
|
||||
let resolution =
|
||||
resolve_tool_execution_policy_resolution(entry.name, execution_policy_input);
|
||||
@@ -251,8 +305,12 @@ pub fn build_tool_inventory(input: AgentToolInventoryBuildInput) -> AgentToolInv
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let registry_tools =
|
||||
build_registry_inventory(®istry_definitions, &caller, execution_policy_input);
|
||||
let registry_tools = build_registry_inventory(
|
||||
®istry_definitions,
|
||||
&caller,
|
||||
execution_policy_input,
|
||||
lock_service_skill_launch_to_site_tools,
|
||||
);
|
||||
let extension_surfaces = build_extension_surface_inventory(
|
||||
&extension_configs,
|
||||
&visible_extension_tools,
|
||||
@@ -336,9 +394,14 @@ fn build_registry_inventory(
|
||||
definitions: &[ToolDefinition],
|
||||
caller: &str,
|
||||
execution_policy_input: ToolExecutionResolverInput<'_>,
|
||||
lock_service_skill_launch_to_site_tools: bool,
|
||||
) -> Vec<RuntimeRegistryToolInventoryEntry> {
|
||||
let mut result = definitions
|
||||
.iter()
|
||||
.filter(|definition| {
|
||||
!lock_service_skill_launch_to_site_tools
|
||||
|| !definition.name.starts_with(BROWSER_RUNTIME_TOOL_PREFIX)
|
||||
})
|
||||
.map(|definition| {
|
||||
let metadata =
|
||||
extract_tool_surface_metadata(&definition.name, &definition.input_schema);
|
||||
@@ -930,6 +993,66 @@ mod tests {
|
||||
.any(|name| name.starts_with("mcp__lime-browser__")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_tool_inventory_hides_browser_compat_tools_for_service_skill_launch() {
|
||||
let inventory = build_tool_inventory(AgentToolInventoryBuildInput {
|
||||
surface: WorkspaceToolSurface::browser_assist(),
|
||||
caller: "assistant".to_string(),
|
||||
agent_initialized: true,
|
||||
warnings: Vec::new(),
|
||||
persisted_execution_policy: None,
|
||||
request_metadata: Some(json!({
|
||||
"harness": {
|
||||
"browser_assist": {
|
||||
"enabled": true,
|
||||
"profile_key": "attached-github"
|
||||
},
|
||||
"service_skill_launch": {
|
||||
"kind": "site_adapter",
|
||||
"adapter_name": "github/search",
|
||||
"args": {
|
||||
"query": "AI Agent"
|
||||
}
|
||||
}
|
||||
}
|
||||
})),
|
||||
mcp_server_names: Vec::new(),
|
||||
mcp_tools: Vec::new(),
|
||||
registry_definitions: vec![
|
||||
definition(
|
||||
"mcp__lime-browser__browser_navigate",
|
||||
"browser navigate",
|
||||
json!({ "type": "object" }),
|
||||
),
|
||||
definition(
|
||||
"lime_site_run",
|
||||
"run site adapter",
|
||||
json!({ "type": "object" }),
|
||||
),
|
||||
],
|
||||
extension_configs: Vec::new(),
|
||||
visible_extension_tools: Vec::new(),
|
||||
searchable_extension_tools: Vec::new(),
|
||||
});
|
||||
|
||||
assert!(!inventory
|
||||
.catalog_tools
|
||||
.iter()
|
||||
.any(|entry| entry.name == BROWSER_RUNTIME_TOOL_PREFIX));
|
||||
assert!(!inventory
|
||||
.registry_tools
|
||||
.iter()
|
||||
.any(|entry| entry.name.starts_with(BROWSER_RUNTIME_TOOL_PREFIX)));
|
||||
assert!(inventory
|
||||
.catalog_tools
|
||||
.iter()
|
||||
.any(|entry| entry.name == "lime_site_run"));
|
||||
assert!(inventory
|
||||
.registry_tools
|
||||
.iter()
|
||||
.any(|entry| entry.name == "lime_site_run"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_tool_inventory_uses_effective_execution_policy_provenance() {
|
||||
let inventory = build_tool_inventory(AgentToolInventoryBuildInput {
|
||||
|
||||
@@ -1,20 +1,10 @@
|
||||
//! API 测试和兼容性检查命令
|
||||
//! API 兼容性与模型查询命令
|
||||
//!
|
||||
//! 包含 API 测试、模型列表和兼容性检查命令。
|
||||
//! 保留面向开发者与内部工具的只读能力。
|
||||
|
||||
use crate::app::types::{AppState, LogState, ProviderType};
|
||||
use crate::commands::model_registry_cmd::ModelRegistryState;
|
||||
|
||||
/// 测试结果
|
||||
#[derive(serde::Serialize)]
|
||||
pub struct TestResult {
|
||||
pub success: bool,
|
||||
pub status: u16,
|
||||
pub body: String,
|
||||
pub time_ms: u64,
|
||||
pub response_headers: std::collections::HashMap<String, String>,
|
||||
}
|
||||
|
||||
/// 模型信息
|
||||
#[derive(serde::Serialize)]
|
||||
pub struct ModelInfo {
|
||||
@@ -298,85 +288,3 @@ pub async fn get_available_models(
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// 测试 API
|
||||
#[tauri::command]
|
||||
pub async fn test_api(
|
||||
state: tauri::State<'_, AppState>,
|
||||
method: String,
|
||||
path: String,
|
||||
body: Option<String>,
|
||||
auth: bool,
|
||||
) -> Result<TestResult, String> {
|
||||
let s = state.read().await;
|
||||
// 使用 status() 获取实际监听的地址(可能与配置不同)
|
||||
let status = s.status();
|
||||
let base_url = format!("http://{}:{}", status.host, status.port);
|
||||
let api_key = s
|
||||
.running_api_key
|
||||
.as_ref()
|
||||
.unwrap_or(&s.config.server.api_key);
|
||||
|
||||
let client = reqwest::Client::builder()
|
||||
.no_proxy()
|
||||
.build()
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let url = format!("{base_url}{path}");
|
||||
|
||||
tracing::info!("Testing API: {} {}", method, url);
|
||||
|
||||
let start = std::time::Instant::now();
|
||||
|
||||
let mut req = match method.as_str() {
|
||||
"GET" => client.get(&url),
|
||||
"POST" => client.post(&url),
|
||||
_ => return Err("Unsupported method".to_string()),
|
||||
};
|
||||
|
||||
req = req.header("Content-Type", "application/json");
|
||||
|
||||
if auth {
|
||||
req = req.header("Authorization", format!("Bearer {api_key}"));
|
||||
}
|
||||
|
||||
if let Some(b) = body {
|
||||
req = req.body(b);
|
||||
}
|
||||
|
||||
match req.send().await {
|
||||
Ok(resp) => {
|
||||
let status = resp.status().as_u16();
|
||||
let response_headers = resp
|
||||
.headers()
|
||||
.iter()
|
||||
.filter_map(|(name, value)| {
|
||||
value
|
||||
.to_str()
|
||||
.ok()
|
||||
.map(|text| (name.as_str().to_string(), text.to_string()))
|
||||
})
|
||||
.collect::<std::collections::HashMap<_, _>>();
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
let time_ms = start.elapsed().as_millis() as u64;
|
||||
|
||||
tracing::info!(
|
||||
"API test result: status={}, body_len={}",
|
||||
status,
|
||||
body.len()
|
||||
);
|
||||
|
||||
Ok(TestResult {
|
||||
success: (200..300).contains(&status),
|
||||
status,
|
||||
body,
|
||||
time_ms,
|
||||
response_headers,
|
||||
})
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("API test error: {}", e);
|
||||
Err(e.to_string())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,6 +27,9 @@ pub async fn save_config(
|
||||
config_manager: tauri::State<'_, GlobalConfigManagerState>,
|
||||
config: config::Config,
|
||||
) -> Result<(), String> {
|
||||
let mut config = config;
|
||||
config.normalize_local_server_surface();
|
||||
config.normalize_workspace_preferences();
|
||||
let host = config.server.host.to_lowercase();
|
||||
|
||||
tracing::info!(
|
||||
@@ -44,12 +47,6 @@ pub async fn save_config(
|
||||
);
|
||||
}
|
||||
|
||||
// 禁止开启远程管理
|
||||
if config.remote_management.allow_remote {
|
||||
tracing::warn!("[CONFIG] 安全限制:不允许开启远程管理功能");
|
||||
return Err("安全限制:不允许开启远程管理功能".to_string());
|
||||
}
|
||||
|
||||
{
|
||||
let mut s = state.write().await;
|
||||
s.config = config.clone();
|
||||
@@ -214,7 +211,7 @@ pub async fn set_endpoint_provider(
|
||||
|
||||
/// 根据 API Key Provider 更新环境变量
|
||||
///
|
||||
/// 当用户在 API Server 页面选择一个 API Key Provider 时调用
|
||||
/// 当用户切换本机默认 Provider 的凭证来源时调用
|
||||
/// 会更新 ~/.claude/settings.json 和 shell 配置文件中的环境变量
|
||||
#[tauri::command]
|
||||
pub async fn update_provider_env_vars(
|
||||
|
||||
@@ -1,131 +1,10 @@
|
||||
//! 服务器控制命令
|
||||
//! 服务器诊断命令
|
||||
//!
|
||||
//! 包含服务器启动、停止、状态查询等命令。
|
||||
//! 保留仅供开发者与运行时诊断使用的只读查询。
|
||||
|
||||
use crate::app::types::{AppState, LogState};
|
||||
use crate::app::TokenCacheServiceState;
|
||||
use crate::commands::provider_pool_cmd::ProviderPoolServiceState;
|
||||
use crate::app::types::AppState;
|
||||
use crate::commands::telemetry_cmd::TelemetryState;
|
||||
use crate::database;
|
||||
use chrono::{Duration, Utc};
|
||||
use lime_infra::telemetry::RequestStatus;
|
||||
use lime_server as server;
|
||||
use std::collections::HashSet;
|
||||
|
||||
/// 启动服务器
|
||||
#[tauri::command]
|
||||
pub async fn start_server(
|
||||
state: tauri::State<'_, AppState>,
|
||||
logs: tauri::State<'_, LogState>,
|
||||
db: tauri::State<'_, database::DbConnection>,
|
||||
pool_service: tauri::State<'_, ProviderPoolServiceState>,
|
||||
token_cache: tauri::State<'_, TokenCacheServiceState>,
|
||||
) -> Result<String, String> {
|
||||
let mut s = state.write().await;
|
||||
logs.write().await.add("info", "Starting server...");
|
||||
s.start(
|
||||
logs.inner().clone(),
|
||||
pool_service.0.clone(),
|
||||
token_cache.0.clone(),
|
||||
Some(db.inner().clone()),
|
||||
)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
// 使用 status() 获取实际使用的地址(可能已经自动切换到有效的 IP)
|
||||
let status = s.status();
|
||||
logs.write().await.add(
|
||||
"info",
|
||||
&format!("Server started on {}:{}", status.host, status.port),
|
||||
);
|
||||
Ok("Server started".to_string())
|
||||
}
|
||||
|
||||
/// 停止服务器
|
||||
#[tauri::command]
|
||||
pub async fn stop_server(
|
||||
state: tauri::State<'_, AppState>,
|
||||
logs: tauri::State<'_, LogState>,
|
||||
) -> Result<String, String> {
|
||||
let mut s = state.write().await;
|
||||
s.stop().await;
|
||||
logs.write().await.add("info", "Server stopped");
|
||||
Ok("Server stopped".to_string())
|
||||
}
|
||||
|
||||
/// 获取服务器状态
|
||||
#[tauri::command]
|
||||
pub async fn get_server_status(
|
||||
state: tauri::State<'_, AppState>,
|
||||
db: tauri::State<'_, database::DbConnection>,
|
||||
pool_service: tauri::State<'_, ProviderPoolServiceState>,
|
||||
telemetry_state: tauri::State<'_, TelemetryState>,
|
||||
) -> Result<server::ServerStatus, String> {
|
||||
let s = state.read().await;
|
||||
let mut status = s.status();
|
||||
|
||||
// 从遥测系统获取真实请求计数
|
||||
let stats = telemetry_state.stats.read();
|
||||
let summary = stats.summary(None);
|
||||
status.requests = summary.total_requests;
|
||||
|
||||
// 最近 1 分钟统计
|
||||
let one_minute_ago = Utc::now() - Duration::minutes(1);
|
||||
let recent_logs: Vec<_> = stats
|
||||
.get_all()
|
||||
.into_iter()
|
||||
.filter(|log| log.timestamp >= one_minute_ago)
|
||||
.collect();
|
||||
|
||||
let total_1m = recent_logs.len() as u64;
|
||||
let error_count_1m = recent_logs
|
||||
.iter()
|
||||
.filter(|log| matches!(log.status, RequestStatus::Failed | RequestStatus::Timeout))
|
||||
.count() as u64;
|
||||
status.error_rate_1m = if total_1m == 0 {
|
||||
0.0
|
||||
} else {
|
||||
error_count_1m as f64 / total_1m as f64
|
||||
};
|
||||
|
||||
let mut latencies: Vec<u64> = recent_logs.iter().map(|log| log.duration_ms).collect();
|
||||
latencies.sort_unstable();
|
||||
status.p95_latency_ms_1m = if latencies.is_empty() {
|
||||
None
|
||||
} else {
|
||||
let last_index = latencies.len().saturating_sub(1);
|
||||
let p95_index = (last_index * 95) / 100;
|
||||
latencies.get(p95_index).copied()
|
||||
};
|
||||
|
||||
// 使用凭证健康状态近似熔断状态:统计当前不健康的上游类型数量
|
||||
status.open_circuit_count = match pool_service.0.get_all_credential_health(db.inner()) {
|
||||
Ok(health_list) => {
|
||||
let unhealthy_provider_count = health_list
|
||||
.into_iter()
|
||||
.filter(|item| !item.is_healthy)
|
||||
.map(|item| item.provider_type)
|
||||
.collect::<HashSet<_>>()
|
||||
.len();
|
||||
u32::try_from(unhealthy_provider_count).unwrap_or(u32::MAX)
|
||||
}
|
||||
Err(err) => {
|
||||
tracing::warn!("[SERVER_STATUS] 获取凭证健康状态失败: {}", err);
|
||||
0
|
||||
}
|
||||
};
|
||||
// 使用 Retry 状态日志作为活跃请求近似值
|
||||
status.active_requests = recent_logs
|
||||
.iter()
|
||||
.filter(|log| matches!(log.status, RequestStatus::Retrying))
|
||||
.count() as u64;
|
||||
status.capability_routing = s.capability_routing_metrics_store.snapshot();
|
||||
status.response_cache = s.response_cache_store.stats();
|
||||
status.request_dedup = s.request_dedup_store.stats();
|
||||
status.idempotency = s.idempotency_store.stats();
|
||||
|
||||
Ok(status)
|
||||
}
|
||||
|
||||
/// 获取服务器诊断信息(对标 /stats 与 /cache 端点)
|
||||
#[tauri::command]
|
||||
|
||||
+32
-31
@@ -16,6 +16,23 @@ use super::commands as app_commands;
|
||||
use super::types::{AppState, TrayManagerState};
|
||||
|
||||
const MAIN_WINDOW_LABEL: &str = "main";
|
||||
const SKIP_STARTUP_WINDOW_REVEAL_ENV: &str = "LIME_SKIP_STARTUP_WINDOW_REVEAL";
|
||||
|
||||
fn env_flag_enabled(key: &str) -> bool {
|
||||
matches!(
|
||||
std::env::var(key)
|
||||
.ok()
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.map(str::to_ascii_lowercase)
|
||||
.as_deref(),
|
||||
Some("1") | Some("true") | Some("yes") | Some("on")
|
||||
)
|
||||
}
|
||||
|
||||
fn should_reveal_main_window_on_startup() -> bool {
|
||||
!env_flag_enabled(SKIP_STARTUP_WINDOW_REVEAL_ENV)
|
||||
}
|
||||
|
||||
fn compiled_updater_public_key() -> Option<&'static str> {
|
||||
option_env!("LIME_UPDATER_PUBLIC_KEY")
|
||||
@@ -161,8 +178,12 @@ pub fn run() {
|
||||
tracing::info!("[单实例] 收到来自新实例的参数: {:?}", args);
|
||||
|
||||
// 将窗口带到前台
|
||||
if let Some(window) = app.get_webview_window("main") {
|
||||
reveal_main_window(&window);
|
||||
if should_reveal_main_window_on_startup() {
|
||||
if let Some(window) = app.get_webview_window("main") {
|
||||
reveal_main_window(&window);
|
||||
}
|
||||
} else {
|
||||
tracing::info!("[启动] 已跳过主窗口展示流程(headless smoke 模式)");
|
||||
}
|
||||
|
||||
let deep_link_urls: Vec<String> = args
|
||||
@@ -266,10 +287,14 @@ pub fn run() {
|
||||
|
||||
// 启动时先最大化再显示,避免用户看到“先小窗后展开”的过程。
|
||||
if let Some(main_window) = app.get_webview_window("main") {
|
||||
reveal_main_window(&main_window);
|
||||
if should_reveal_main_window_on_startup() {
|
||||
reveal_main_window(&main_window);
|
||||
} else {
|
||||
tracing::info!("[启动] 已跳过主窗口展示流程(headless smoke 模式)");
|
||||
}
|
||||
|
||||
#[cfg(debug_assertions)]
|
||||
if crate::profiling::should_open_webview_devtools() {
|
||||
if env_flag_enabled("LIME_OPEN_WEBVIEW_DEVTOOLS") {
|
||||
main_window.open_devtools();
|
||||
tracing::info!("[Profiling] 已自动打开主窗口 WebView DevTools");
|
||||
}
|
||||
@@ -756,8 +781,6 @@ pub fn run() {
|
||||
}
|
||||
}
|
||||
// 启动服务器(使用共享的遥测实例)
|
||||
let server_started;
|
||||
let server_address;
|
||||
{
|
||||
let mut s = state.write().await;
|
||||
logs.write()
|
||||
@@ -783,28 +806,21 @@ pub fn run() {
|
||||
logs.write()
|
||||
.await
|
||||
.add("info", &format!("[启动] 服务器已启动: {host}:{port}"));
|
||||
server_started = true;
|
||||
server_address = format!("{host}:{port}");
|
||||
}
|
||||
Err(e) => {
|
||||
logs.write()
|
||||
.await
|
||||
.add("error", &format!("[启动] 服务器启动失败: {e}"));
|
||||
server_started = false;
|
||||
server_address = String::new();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 更新托盘状态
|
||||
// Requirements 7.1: API 服务器状态变化时更新托盘图标
|
||||
if let Some(tray_state) = app_handle.try_state::<TrayManagerState<tauri::Wry>>() {
|
||||
let tray_guard = tray_state.0.read().await;
|
||||
if let Some(tray_manager) = tray_guard.as_ref() {
|
||||
let current_state = tray_manager.get_state().await;
|
||||
let icon_status = if !server_started {
|
||||
TrayIconStatus::Stopped
|
||||
} else if total_credentials > 0 && available_credentials == 0 {
|
||||
let icon_status = if total_credentials == 0 || available_credentials == 0 {
|
||||
TrayIconStatus::Error
|
||||
} else if available_credentials < total_credentials {
|
||||
TrayIconStatus::Warning
|
||||
@@ -814,8 +830,6 @@ pub fn run() {
|
||||
|
||||
let snapshot = TrayStateSnapshot {
|
||||
icon_status,
|
||||
server_running: server_started,
|
||||
server_address,
|
||||
available_credentials,
|
||||
total_credentials,
|
||||
today_requests: current_state.today_requests,
|
||||
@@ -1038,9 +1052,6 @@ pub fn run() {
|
||||
})
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
// Server commands (from app::commands)
|
||||
app_commands::start_server,
|
||||
app_commands::stop_server,
|
||||
app_commands::get_server_status,
|
||||
app_commands::get_server_diagnostics,
|
||||
// Config commands (from app::commands)
|
||||
app_commands::get_config,
|
||||
@@ -1089,7 +1100,6 @@ pub fn run() {
|
||||
app_commands::report_frontend_crash,
|
||||
app_commands::report_frontend_debug_log,
|
||||
// API test commands (from app::commands)
|
||||
app_commands::test_api,
|
||||
app_commands::get_available_models,
|
||||
app_commands::check_api_compatibility,
|
||||
// Switch commands
|
||||
@@ -1320,9 +1330,6 @@ pub fn run() {
|
||||
// API Key Provider connection test command
|
||||
commands::api_key_provider_cmd::test_api_key_provider_connection,
|
||||
commands::api_key_provider_cmd::test_api_key_provider_chat,
|
||||
// Route commands
|
||||
commands::route_cmd::get_available_routes,
|
||||
commands::route_cmd::get_route_curl_examples,
|
||||
// Resilience config commands
|
||||
commands::resilience_cmd::get_retry_config,
|
||||
commands::resilience_cmd::update_retry_config,
|
||||
@@ -1360,12 +1367,6 @@ pub fn run() {
|
||||
// Usage commands
|
||||
commands::usage_cmd::get_kiro_usage,
|
||||
// Tray commands
|
||||
commands::tray_cmd::sync_tray_state,
|
||||
commands::tray_cmd::update_tray_server_status,
|
||||
commands::tray_cmd::update_tray_credential_status,
|
||||
commands::tray_cmd::get_tray_state,
|
||||
commands::tray_cmd::refresh_tray_menu,
|
||||
commands::tray_cmd::refresh_tray_with_stats,
|
||||
commands::tray_cmd::sync_tray_model_shortcuts,
|
||||
// Plugin commands
|
||||
commands::plugin_cmd::get_plugin_status,
|
||||
@@ -1479,8 +1480,6 @@ pub fn run() {
|
||||
commands::models_cmd::toggle_model_enabled,
|
||||
commands::models_cmd::add_provider,
|
||||
commands::models_cmd::remove_provider,
|
||||
// Network commands
|
||||
commands::network_cmd::get_network_info,
|
||||
// Orchestrator commands
|
||||
commands::orchestrator_cmd::init_orchestrator,
|
||||
commands::orchestrator_cmd::get_orchestrator_config,
|
||||
@@ -1569,9 +1568,11 @@ pub fn run() {
|
||||
commands::browser_connector_cmd::set_browser_connector_install_root_cmd,
|
||||
commands::browser_connector_cmd::set_browser_connector_enabled_cmd,
|
||||
commands::browser_connector_cmd::set_system_connector_enabled_cmd,
|
||||
commands::browser_connector_cmd::set_browser_action_capability_enabled_cmd,
|
||||
commands::browser_connector_cmd::get_browser_connector_install_status_cmd,
|
||||
commands::browser_connector_cmd::install_browser_connector_extension_cmd,
|
||||
commands::browser_connector_cmd::open_browser_extensions_page_cmd,
|
||||
commands::browser_connector_cmd::open_browser_remote_debugging_page_cmd,
|
||||
// Browser profile commands
|
||||
commands::browser_profile_cmd::list_browser_profiles_cmd,
|
||||
commands::browser_profile_cmd::save_browser_profile_cmd,
|
||||
|
||||
@@ -55,7 +55,7 @@ pub async fn agent_start_process(
|
||||
};
|
||||
|
||||
agent_state.init_agent_with_db(&db).await?;
|
||||
ensure_browser_mcp_tools_registered(agent_state.inner()).await?;
|
||||
ensure_browser_mcp_tools_registered(agent_state.inner(), &db).await?;
|
||||
let base_url = if gateway_running {
|
||||
Some(format!("http://{host}:{port}"))
|
||||
} else {
|
||||
|
||||
@@ -149,6 +149,8 @@ const SOCIAL_IMAGE_DEFAULT_SIZE: &str = "1024x1024";
|
||||
const SOCIAL_IMAGE_DEFAULT_RESPONSE_FORMAT: &str = "url";
|
||||
const AUTO_CONTINUE_PROMPT_MARKER: &str = "【自动续写策略】";
|
||||
const ELICITATION_CONTEXT_PROMPT_MARKER: &str = "【已收集的补充信息】";
|
||||
const SERVICE_SKILL_LAUNCH_PROMPT_MARKER: &str = "【站点技能启动】";
|
||||
const SERVICE_SKILL_LAUNCH_PRELOAD_PROMPT_MARKER: &str = "【站点技能预执行结果】";
|
||||
const TEAM_PREFERENCE_PROMPT_MARKER: &str = "【Team 协作偏好】";
|
||||
const LIME_TOOL_METADATA_BEGIN: &str = "[Lime 工具元数据开始]";
|
||||
const LIME_TOOL_METADATA_END: &str = "[Lime 工具元数据结束]";
|
||||
@@ -264,6 +266,7 @@ mod reply_runtime;
|
||||
mod request_model_resolution;
|
||||
mod run_metadata;
|
||||
mod runtime_turn;
|
||||
mod service_skill_launch;
|
||||
mod session_runtime;
|
||||
mod subagent_runtime;
|
||||
pub(crate) mod tool_runtime;
|
||||
@@ -280,7 +283,6 @@ use self::tool_runtime::{
|
||||
#[cfg(test)]
|
||||
include!("tests.rs");
|
||||
|
||||
pub(crate) use action_runtime::{agent_runtime_delete_session, agent_runtime_respond_action};
|
||||
#[cfg(test)]
|
||||
pub(crate) use action_runtime::{
|
||||
build_action_resume_runtime_status, build_runtime_action_user_data,
|
||||
@@ -335,6 +337,8 @@ pub(crate) use mcp_bridge::{ensure_lime_mcp_servers_running, inject_mcp_extensio
|
||||
pub(crate) use prompt_context::build_team_preference_system_prompt;
|
||||
pub(crate) use prompt_context::{
|
||||
merge_system_prompt_with_auto_continue, merge_system_prompt_with_elicitation_context,
|
||||
merge_system_prompt_with_service_skill_launch,
|
||||
merge_system_prompt_with_service_skill_launch_preload,
|
||||
merge_system_prompt_with_team_preference,
|
||||
};
|
||||
#[cfg(test)]
|
||||
@@ -360,11 +364,18 @@ pub(crate) use runtime_turn::{build_queued_turn_task, build_runtime_queue_execut
|
||||
pub(crate) use runtime_turn::{
|
||||
resolve_request_web_search_preference_from_sources, resolve_workspace_id_from_sources,
|
||||
};
|
||||
#[cfg(test)]
|
||||
pub(crate) use service_skill_launch::build_service_skill_launch_run_request;
|
||||
pub(crate) use service_skill_launch::{
|
||||
append_service_skill_launch_session_permissions, preload_service_skill_launch_execution,
|
||||
should_lock_service_skill_launch_to_site_tools, ServiceSkillLaunchPreloadExecution,
|
||||
};
|
||||
pub(crate) use session_runtime::{
|
||||
delete_runtime_session_internal, persist_session_provider_routing,
|
||||
resolve_recent_preference_from_sources, resolve_session_provider_selector,
|
||||
resolve_session_recent_harness_context, resolve_session_recent_preferences,
|
||||
resolve_session_recent_team_selection, SessionRecentHarnessContext,
|
||||
resolve_session_recent_runtime_context, SessionRecentHarnessContext,
|
||||
SessionRecentRuntimeContext,
|
||||
};
|
||||
pub(crate) use subagent_runtime::{
|
||||
agent_runtime_close_subagent_internal, agent_runtime_resume_subagent_internal,
|
||||
|
||||
@@ -173,6 +173,328 @@ pub(crate) fn merge_system_prompt_with_elicitation_context(
|
||||
}
|
||||
}
|
||||
|
||||
fn extract_object_string(
|
||||
object: &serde_json::Map<String, serde_json::Value>,
|
||||
keys: &[&str],
|
||||
) -> Option<String> {
|
||||
keys.iter()
|
||||
.filter_map(|key| object.get(*key))
|
||||
.find_map(serde_json::Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(str::to_string)
|
||||
}
|
||||
|
||||
fn render_json_inline(value: Option<&serde_json::Value>) -> Option<String> {
|
||||
let rendered = serde_json::to_string(value?).ok()?;
|
||||
let normalized = rendered.trim();
|
||||
if normalized.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(normalized.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
fn build_service_skill_launch_run_example(
|
||||
adapter_name: &str,
|
||||
args: Option<&serde_json::Value>,
|
||||
profile_key: Option<&str>,
|
||||
target_id: Option<&str>,
|
||||
content_id: Option<&str>,
|
||||
project_id: Option<&str>,
|
||||
save_title: Option<&str>,
|
||||
) -> String {
|
||||
let mut payload = serde_json::Map::new();
|
||||
payload.insert(
|
||||
"adapter_name".to_string(),
|
||||
serde_json::Value::String(adapter_name.to_string()),
|
||||
);
|
||||
payload.insert(
|
||||
"args".to_string(),
|
||||
args.cloned()
|
||||
.filter(|value| value.is_object())
|
||||
.unwrap_or_else(|| serde_json::json!({})),
|
||||
);
|
||||
|
||||
if let Some(value) = profile_key.filter(|value| !value.trim().is_empty()) {
|
||||
payload.insert(
|
||||
"profile_key".to_string(),
|
||||
serde_json::Value::String(value.to_string()),
|
||||
);
|
||||
}
|
||||
if let Some(value) = target_id.filter(|value| !value.trim().is_empty()) {
|
||||
payload.insert(
|
||||
"target_id".to_string(),
|
||||
serde_json::Value::String(value.to_string()),
|
||||
);
|
||||
}
|
||||
if let Some(value) = content_id.filter(|value| !value.trim().is_empty()) {
|
||||
payload.insert(
|
||||
"content_id".to_string(),
|
||||
serde_json::Value::String(value.to_string()),
|
||||
);
|
||||
}
|
||||
if let Some(value) = project_id.filter(|value| !value.trim().is_empty()) {
|
||||
payload.insert(
|
||||
"project_id".to_string(),
|
||||
serde_json::Value::String(value.to_string()),
|
||||
);
|
||||
}
|
||||
if let Some(value) = save_title.filter(|value| !value.trim().is_empty()) {
|
||||
payload.insert(
|
||||
"save_title".to_string(),
|
||||
serde_json::Value::String(value.to_string()),
|
||||
);
|
||||
}
|
||||
|
||||
serde_json::Value::Object(payload).to_string()
|
||||
}
|
||||
|
||||
fn build_service_skill_launch_system_prompt(
|
||||
request_metadata: Option<&serde_json::Value>,
|
||||
) -> Option<String> {
|
||||
let launch = extract_harness_nested_object(
|
||||
request_metadata,
|
||||
&["service_skill_launch", "serviceSkillLaunch"],
|
||||
)?;
|
||||
let kind =
|
||||
extract_object_string(launch, &["kind"]).unwrap_or_else(|| "site_adapter".to_string());
|
||||
if kind != "site_adapter" {
|
||||
return None;
|
||||
}
|
||||
|
||||
let adapter_name = extract_object_string(launch, &["adapter_name", "adapterName"])?;
|
||||
let skill_title = extract_object_string(launch, &["skill_title", "skillTitle"]);
|
||||
let save_mode = extract_object_string(launch, &["save_mode", "saveMode"])
|
||||
.unwrap_or_else(|| "project_resource".to_string());
|
||||
let save_title = extract_object_string(launch, &["save_title", "saveTitle"]);
|
||||
let content_id = extract_object_string(launch, &["content_id", "contentId"]);
|
||||
let project_id = extract_object_string(launch, &["project_id", "projectId"]);
|
||||
let args_json = render_json_inline(launch.get("args")).unwrap_or_else(|| "{}".to_string());
|
||||
let launch_readiness = launch
|
||||
.get("launch_readiness")
|
||||
.and_then(serde_json::Value::as_object);
|
||||
let readiness_status =
|
||||
launch_readiness.and_then(|value| extract_object_string(value, &["status"]));
|
||||
let readiness_message =
|
||||
launch_readiness.and_then(|value| extract_object_string(value, &["message"]));
|
||||
let readiness_hint = launch_readiness
|
||||
.and_then(|value| extract_object_string(value, &["report_hint", "reportHint"]));
|
||||
let launch_profile_key = launch_readiness
|
||||
.and_then(|value| extract_object_string(value, &["profile_key", "profileKey"]));
|
||||
let launch_target_id =
|
||||
launch_readiness.and_then(|value| extract_object_string(value, &["target_id", "targetId"]));
|
||||
let browser_assist =
|
||||
extract_harness_nested_object(request_metadata, &["browser_assist", "browserAssist"]);
|
||||
let browser_profile_key = browser_assist
|
||||
.and_then(|value| extract_object_string(value, &["profile_key", "profileKey"]));
|
||||
let resolved_profile_key = launch_profile_key.or(browser_profile_key);
|
||||
let run_example = build_service_skill_launch_run_example(
|
||||
&adapter_name,
|
||||
launch.get("args"),
|
||||
resolved_profile_key.as_deref(),
|
||||
launch_target_id.as_deref(),
|
||||
content_id.as_deref(),
|
||||
project_id.as_deref(),
|
||||
save_title.as_deref(),
|
||||
);
|
||||
|
||||
let mut lines = vec![
|
||||
SERVICE_SKILL_LAUNCH_PROMPT_MARKER.to_string(),
|
||||
"- 当前回合来自站点技能启动,不要把它当成普通聊天或纯文本分析。".to_string(),
|
||||
"- 第一步优先调用 lime_site_run,不要先停留在解释、总结或泛化检索。".to_string(),
|
||||
"- 在 lime_site_run 完成前,不要先用 WebSearch、research、webReader 一类通用检索/阅读工具替代执行。".to_string(),
|
||||
"- 在 lime_site_run 完成前,不要直接调用 mcp__lime-browser__browser_navigate、mcp__lime-browser__read_page、browser_navigate、browser_run_code、Playwright code 或其他 mcp__lime-browser__* / browser_* 底层浏览器工具。".to_string(),
|
||||
"- lime_site_run 的参数必须是一个严格 JSON 对象;不要写注释、不要漏引号、不要输出半截对象,也不要把整个 JSON 再包成字符串。".to_string(),
|
||||
format!("- 当前站点适配器:{adapter_name}。"),
|
||||
format!("- 当前执行参数(JSON):{args_json}。"),
|
||||
format!("- 第一工具调用示例(lime_site_run 参数 JSON):{run_example}。"),
|
||||
format!("- 当前保存模式:{save_mode}。"),
|
||||
format!(
|
||||
"- 当前技能标题:{}。",
|
||||
skill_title.unwrap_or_else(|| "未提供".to_string())
|
||||
),
|
||||
format!(
|
||||
"- 当前 project_id:{}。",
|
||||
project_id.unwrap_or_else(|| "未提供".to_string())
|
||||
),
|
||||
format!(
|
||||
"- 当前 content_id:{}。",
|
||||
content_id.unwrap_or_else(|| "未提供".to_string())
|
||||
),
|
||||
format!(
|
||||
"- 当前 save_title:{}。",
|
||||
save_title.unwrap_or_else(|| "未提供".to_string())
|
||||
),
|
||||
];
|
||||
|
||||
if let Some(status) = readiness_status.as_deref() {
|
||||
lines.push(format!("- 浏览器会话检测状态:{status}。"));
|
||||
}
|
||||
if let Some(message) = readiness_message.as_deref() {
|
||||
lines.push(format!("- 浏览器会话检测说明:{message}"));
|
||||
}
|
||||
if let Some(hint) = readiness_hint.as_deref() {
|
||||
lines.push(format!("- 浏览器恢复提示:{hint}"));
|
||||
}
|
||||
if let Some(profile_key) = resolved_profile_key.as_deref() {
|
||||
lines.push(format!(
|
||||
"- 调用 lime_site_run 时必须显式透传 profile_key={profile_key}。"
|
||||
));
|
||||
}
|
||||
if let Some(target_id) = launch_target_id.as_deref() {
|
||||
lines.push(format!(
|
||||
"- 调用 lime_site_run 时必须显式透传 target_id={target_id}。"
|
||||
));
|
||||
}
|
||||
|
||||
lines.push(
|
||||
"- 如果工具返回 attached_session_required、no_matching_context、登录受限或权限受限,不要伪造成功结果;直接说明当前缺少可执行的浏览器上下文,并要求用户先完成连接、登录或授权。".to_string(),
|
||||
);
|
||||
lines.push(
|
||||
"- 当前任务已经明确来自技能启动,不要再让用户额外确认“是否继续执行站点技能”。".to_string(),
|
||||
);
|
||||
lines.push(
|
||||
"- 只有在 lime_site_run 完成后,为了补充背景资料或交叉验证,才允许再决定是否追加 WebSearch 或其他通用检索工具。".to_string(),
|
||||
);
|
||||
|
||||
Some(lines.join("\n"))
|
||||
}
|
||||
|
||||
pub(crate) fn merge_system_prompt_with_service_skill_launch(
|
||||
base_prompt: Option<String>,
|
||||
request_metadata: Option<&serde_json::Value>,
|
||||
) -> Option<String> {
|
||||
let Some(service_skill_prompt) = build_service_skill_launch_system_prompt(request_metadata)
|
||||
else {
|
||||
return base_prompt;
|
||||
};
|
||||
|
||||
match base_prompt {
|
||||
Some(base) => {
|
||||
if base.contains(SERVICE_SKILL_LAUNCH_PROMPT_MARKER) {
|
||||
Some(base)
|
||||
} else if base.trim().is_empty() {
|
||||
Some(service_skill_prompt)
|
||||
} else {
|
||||
Some(format!("{base}\n\n{service_skill_prompt}"))
|
||||
}
|
||||
}
|
||||
None => Some(service_skill_prompt),
|
||||
}
|
||||
}
|
||||
|
||||
fn truncate_prompt_text(value: String, max_chars: usize) -> String {
|
||||
let total_chars = value.chars().count();
|
||||
if total_chars <= max_chars {
|
||||
return value;
|
||||
}
|
||||
|
||||
let truncated = value.chars().take(max_chars).collect::<String>();
|
||||
format!("{truncated}...(已截断,原始长度 {total_chars} 字)")
|
||||
}
|
||||
|
||||
fn build_service_skill_launch_preload_prompt(
|
||||
execution: &ServiceSkillLaunchPreloadExecution,
|
||||
) -> String {
|
||||
let request_summary = serde_json::json!({
|
||||
"adapter_name": execution.request.adapter_name.clone(),
|
||||
"args": execution.request.args.clone(),
|
||||
"profile_key": execution.request.profile_key.clone(),
|
||||
"target_id": execution.request.target_id.clone(),
|
||||
"timeout_ms": execution.request.timeout_ms,
|
||||
"content_id": execution.request.content_id.clone(),
|
||||
"project_id": execution.request.project_id.clone(),
|
||||
"save_title": execution.request.save_title.clone(),
|
||||
"require_attached_session": execution.request.require_attached_session,
|
||||
"skill_title": execution.request.skill_title.clone(),
|
||||
});
|
||||
let request_json = truncate_prompt_text(
|
||||
serde_json::to_string(&request_summary).unwrap_or_else(|_| "{}".to_string()),
|
||||
4_000,
|
||||
);
|
||||
let result_summary = serde_json::json!({
|
||||
"ok": execution.result.ok,
|
||||
"adapter": execution.result.adapter.clone(),
|
||||
"profile_key": execution.result.profile_key.clone(),
|
||||
"session_id": execution.result.session_id.clone(),
|
||||
"target_id": execution.result.target_id.clone(),
|
||||
"entry_url": execution.result.entry_url.clone(),
|
||||
"source_url": execution.result.source_url.clone(),
|
||||
"data": execution.result.data.clone(),
|
||||
"error_code": execution.result.error_code.clone(),
|
||||
"error_message": execution.result.error_message.clone(),
|
||||
"auth_hint": execution.result.auth_hint.clone(),
|
||||
"report_hint": execution.result.report_hint.clone(),
|
||||
"saved_content": execution.result.saved_content.clone(),
|
||||
"saved_project_id": execution.result.saved_project_id.clone(),
|
||||
});
|
||||
let result_json = truncate_prompt_text(
|
||||
serde_json::to_string(&result_summary).unwrap_or_else(|_| "{}".to_string()),
|
||||
12_000,
|
||||
);
|
||||
let adapter_name = execution
|
||||
.adapter
|
||||
.as_ref()
|
||||
.map(|adapter| adapter.name.clone())
|
||||
.unwrap_or_else(|| execution.request.adapter_name.clone());
|
||||
let adapter_description = execution
|
||||
.adapter
|
||||
.as_ref()
|
||||
.map(|adapter| adapter.description.clone())
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
.unwrap_or_else(|| "未提供".to_string());
|
||||
let execution_requirement = if execution.result.ok {
|
||||
"- 站点技能已经在系统侧预执行成功。请直接基于下面的结构化结果完成答复,不要再次调用 lime_site_run,也不要回退到底层浏览器兼容工具。".to_string()
|
||||
} else {
|
||||
"- 站点技能已经在系统侧预执行,但执行失败。请直接根据失败结果向用户说明缺少的浏览器上下文、登录态或权限,不要伪造采集成功,也不要再次尝试调用 lime_site_run / mcp__lime-browser__* / browser_*。".to_string()
|
||||
};
|
||||
let failure_contract = match execution.result.error_code.as_deref() {
|
||||
Some("attached_session_required") => {
|
||||
"- 当前失败类型为 attached_session_required:必须明确告诉用户先连接并附着到目标站点页面,再重试当前技能。".to_string()
|
||||
}
|
||||
Some("no_matching_context") => {
|
||||
"- 当前失败类型为 no_matching_context:必须告诉用户先把浏览器切到目标站点或正确页面,再重试当前技能。".to_string()
|
||||
}
|
||||
_ => "- 如果用户追问失败原因,优先引用 error_code / error_message / report_hint / auth_hint,而不是自行编造执行细节。".to_string(),
|
||||
};
|
||||
|
||||
[
|
||||
SERVICE_SKILL_LAUNCH_PRELOAD_PROMPT_MARKER.to_string(),
|
||||
execution_requirement,
|
||||
failure_contract,
|
||||
format!("- 当前适配器:{adapter_name}。"),
|
||||
format!("- 当前适配器说明:{adapter_description}"),
|
||||
format!("- 已预执行请求(JSON):{request_json}。"),
|
||||
format!("- 已预执行结果(JSON):{result_json}。"),
|
||||
"- 除非用户明确要求“重跑一次 / 换关键词 / 换筛选条件 / 重新抓取”,否则本回合不要再次调用任何站点执行工具。".to_string(),
|
||||
]
|
||||
.join("\n")
|
||||
}
|
||||
|
||||
pub(crate) fn merge_system_prompt_with_service_skill_launch_preload(
|
||||
base_prompt: Option<String>,
|
||||
execution: Option<&ServiceSkillLaunchPreloadExecution>,
|
||||
) -> Option<String> {
|
||||
let Some(execution) = execution else {
|
||||
return base_prompt;
|
||||
};
|
||||
let preload_prompt = build_service_skill_launch_preload_prompt(execution);
|
||||
|
||||
match base_prompt {
|
||||
Some(base) => {
|
||||
if base.contains(SERVICE_SKILL_LAUNCH_PRELOAD_PROMPT_MARKER) {
|
||||
Some(base)
|
||||
} else if base.trim().is_empty() {
|
||||
Some(preload_prompt)
|
||||
} else {
|
||||
Some(format!("{base}\n\n{preload_prompt}"))
|
||||
}
|
||||
}
|
||||
None => Some(preload_prompt),
|
||||
}
|
||||
}
|
||||
|
||||
fn render_team_roles(role_items: &[serde_json::Value]) -> Vec<String> {
|
||||
role_items
|
||||
.iter()
|
||||
|
||||
@@ -327,12 +327,15 @@ async fn execute_aster_chat_request(
|
||||
}
|
||||
}
|
||||
ensure_tool_search_tool_registered(state).await?;
|
||||
let request_session_id = request.session_id.clone();
|
||||
let mcp_runtime_prepare_future = async {
|
||||
let (_start_ok, start_fail) = ensure_lime_mcp_servers_running(db, mcp_manager).await;
|
||||
let (_mcp_ok, mcp_fail) = inject_mcp_extensions(state, mcp_manager).await;
|
||||
(start_fail, mcp_fail)
|
||||
};
|
||||
let session_recent_runtime_context_future =
|
||||
resolve_session_recent_runtime_context(&request_session_id);
|
||||
|
||||
if let Some(resolved_provider_config) =
|
||||
resolve_runtime_request_provider_config(app, db, api_key_provider_service, &request).await?
|
||||
{
|
||||
request.provider_config = Some(resolved_provider_config);
|
||||
}
|
||||
let should_resolve_session_recent_harness_context = extract_harness_string(
|
||||
request.metadata.as_ref(),
|
||||
&["theme", "harness_theme", "harnessTheme"],
|
||||
@@ -348,11 +351,22 @@ async fn execute_aster_chat_request(
|
||||
.is_none()
|
||||
|| extract_harness_string(request.metadata.as_ref(), &["content_id", "contentId"])
|
||||
.is_none();
|
||||
let session_recent_harness_context = if should_resolve_session_recent_harness_context {
|
||||
resolve_session_recent_harness_context(&request.session_id).await?
|
||||
} else {
|
||||
SessionRecentHarnessContext::default()
|
||||
let provider_config_future =
|
||||
resolve_runtime_request_provider_config(app, db, api_key_provider_service, &request);
|
||||
let session_recent_harness_context_future = async {
|
||||
if should_resolve_session_recent_harness_context {
|
||||
resolve_session_recent_harness_context(&request.session_id).await
|
||||
} else {
|
||||
Ok(SessionRecentHarnessContext::default())
|
||||
}
|
||||
};
|
||||
let (resolved_provider_config, session_recent_harness_context) = tokio::try_join!(
|
||||
provider_config_future,
|
||||
session_recent_harness_context_future
|
||||
)?;
|
||||
if let Some(resolved_provider_config) = resolved_provider_config {
|
||||
request.provider_config = Some(resolved_provider_config);
|
||||
}
|
||||
normalize_runtime_turn_request_metadata(
|
||||
&mut request,
|
||||
session_recent_harness_context.theme.as_deref(),
|
||||
@@ -464,16 +478,17 @@ async fn execute_aster_chat_request(
|
||||
session_state_snapshot.with_working_dir(Some(workspace_root.clone()));
|
||||
}
|
||||
|
||||
// 启动并注入 MCP extensions 到 Aster Agent
|
||||
let (_start_ok, start_fail) = ensure_lime_mcp_servers_running(db, mcp_manager).await;
|
||||
let ((start_fail, mcp_fail), session_recent_runtime_context) = tokio::join!(
|
||||
mcp_runtime_prepare_future,
|
||||
session_recent_runtime_context_future
|
||||
);
|
||||
|
||||
if start_fail > 0 {
|
||||
tracing::warn!(
|
||||
"[AsterAgent] 部分 MCP server 自动启动失败 ({} 失败),后续可用工具可能不完整",
|
||||
start_fail
|
||||
);
|
||||
}
|
||||
|
||||
let (_mcp_ok, mcp_fail) = inject_mcp_extensions(state, mcp_manager).await;
|
||||
if mcp_fail > 0 {
|
||||
tracing::warn!(
|
||||
"[AsterAgent] 部分 MCP extension 注入失败 ({} 失败),Agent 可能无法使用某些 MCP 工具",
|
||||
@@ -481,8 +496,10 @@ async fn execute_aster_chat_request(
|
||||
);
|
||||
}
|
||||
|
||||
let session_recent_preferences = resolve_session_recent_preferences(session_id).await?;
|
||||
let session_recent_team_selection = resolve_session_recent_team_selection(session_id).await?;
|
||||
let SessionRecentRuntimeContext {
|
||||
preferences: session_recent_preferences,
|
||||
team_selection: session_recent_team_selection,
|
||||
} = session_recent_runtime_context?;
|
||||
let runtime_chat_mode = resolve_runtime_chat_mode(request.metadata.as_ref());
|
||||
let mode_default_web_search = default_web_search_enabled_for_chat_mode(runtime_chat_mode);
|
||||
let resolved_request_web_search = resolve_request_web_search_preference_from_sources(
|
||||
@@ -639,10 +656,19 @@ async fn execute_aster_chat_request(
|
||||
prompt_with_artifact.clone(),
|
||||
);
|
||||
|
||||
let prompt_with_elicitation = merge_system_prompt_with_elicitation_context(
|
||||
let prompt_with_service_skill_launch = merge_system_prompt_with_service_skill_launch(
|
||||
prompt_with_artifact,
|
||||
request.metadata.as_ref(),
|
||||
);
|
||||
turn_input_builder.apply_prompt_stage(
|
||||
TurnPromptAugmentationStageKind::ServiceSkillLaunch,
|
||||
prompt_with_service_skill_launch.clone(),
|
||||
);
|
||||
|
||||
let prompt_with_elicitation = merge_system_prompt_with_elicitation_context(
|
||||
prompt_with_service_skill_launch,
|
||||
request.metadata.as_ref(),
|
||||
);
|
||||
turn_input_builder.apply_prompt_stage(
|
||||
TurnPromptAugmentationStageKind::Elicitation,
|
||||
prompt_with_elicitation.clone(),
|
||||
@@ -863,6 +889,18 @@ async fn execute_aster_chat_request(
|
||||
let auto_continue_metadata = auto_continue_config.clone();
|
||||
let request_metadata = request.metadata.clone();
|
||||
sync_browser_assist_runtime_hint(session_id, request_metadata.as_ref()).await;
|
||||
let service_skill_preload =
|
||||
preload_service_skill_launch_execution(db, request_metadata.as_ref())
|
||||
.await
|
||||
.map_err(|error| format!("站点技能预执行失败: {error}"))?;
|
||||
let system_prompt = merge_system_prompt_with_service_skill_launch_preload(
|
||||
system_prompt,
|
||||
service_skill_preload.as_ref(),
|
||||
);
|
||||
turn_input_builder.apply_prompt_stage(
|
||||
TurnPromptAugmentationStageKind::ServiceSkillLaunchPreload,
|
||||
system_prompt.clone(),
|
||||
);
|
||||
let model_skill_tool_enabled = should_enable_model_skill_tool(request_metadata.as_ref());
|
||||
let run_observation = Arc::new(Mutex::new(ChatRunObservation::default()));
|
||||
let run_observation_for_finalize = run_observation.clone();
|
||||
@@ -943,6 +981,22 @@ async fn execute_aster_chat_request(
|
||||
if let Ok(turn_input_value) = serde_json::to_value(&turn_input_diagnostics) {
|
||||
run_start_metadata.insert("turn_input".to_string(), turn_input_value);
|
||||
}
|
||||
if let Some(preload) = service_skill_preload.as_ref() {
|
||||
run_start_metadata.insert(
|
||||
"service_skill_launch_preload".to_string(),
|
||||
serde_json::json!({
|
||||
"executed": true,
|
||||
"adapter_name": preload.request.adapter_name,
|
||||
"ok": preload.result.ok,
|
||||
"error_code": preload.result.error_code,
|
||||
"saved_content_id": preload
|
||||
.result
|
||||
.saved_content
|
||||
.as_ref()
|
||||
.map(|content| content.content_id.clone()),
|
||||
}),
|
||||
);
|
||||
}
|
||||
let run_start_metadata_for_finalize = run_start_metadata.clone();
|
||||
let timeline_recorder = Arc::new(Mutex::new(AgentTimelineRecorder::create(
|
||||
db.clone(),
|
||||
@@ -2093,6 +2147,90 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn service_skill_launch_stage_should_preserve_simple_user_message_and_force_site_run_first() {
|
||||
let user_message = "请帮我使用 GitHub 查一下 AI Agent 项目";
|
||||
let metadata = json!({
|
||||
"harness": {
|
||||
"browser_assist": {
|
||||
"enabled": true,
|
||||
"profile_key": "attached-github",
|
||||
},
|
||||
"service_skill_launch": {
|
||||
"kind": "site_adapter",
|
||||
"skill_title": "GitHub 仓库线索检索",
|
||||
"adapter_name": "github/search",
|
||||
"args": {
|
||||
"query": "AI Agent",
|
||||
"limit": 10
|
||||
},
|
||||
"save_mode": "current_content",
|
||||
"content_id": "content-1",
|
||||
"project_id": "project-1",
|
||||
"launch_readiness": {
|
||||
"status": "ready",
|
||||
"message": "已检测到 github.com 的真实浏览器页面。",
|
||||
"target_id": "tab-github"
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let prompt_with_web_search =
|
||||
Some("基础系统提示\n- 如果需要可使用 WebSearch 补充信息。".to_string());
|
||||
let prompt_with_service_skill_launch = merge_system_prompt_with_service_skill_launch(
|
||||
prompt_with_web_search.clone(),
|
||||
Some(&metadata),
|
||||
)
|
||||
.expect("service skill prompt");
|
||||
|
||||
let mut turn_input_builder =
|
||||
TurnInputEnvelopeBuilder::new("session-service-skill", "workspace-service-skill");
|
||||
turn_input_builder
|
||||
.set_base_system_prompt(
|
||||
TurnSystemPromptSource::Frontend,
|
||||
Some("基础系统提示".to_string()),
|
||||
)
|
||||
.set_turn_context_metadata_from_value(Some(&metadata))
|
||||
.set_effective_user_message(user_message)
|
||||
.apply_prompt_stage(
|
||||
TurnPromptAugmentationStageKind::WebSearch,
|
||||
prompt_with_web_search,
|
||||
)
|
||||
.apply_prompt_stage(
|
||||
TurnPromptAugmentationStageKind::ServiceSkillLaunch,
|
||||
Some(prompt_with_service_skill_launch.clone()),
|
||||
);
|
||||
|
||||
let envelope = turn_input_builder.build();
|
||||
let diagnostics = envelope.diagnostics_snapshot();
|
||||
let final_prompt = envelope.system_prompt().expect("final prompt");
|
||||
let service_skill_stage = diagnostics
|
||||
.prompt_augmentation_stages
|
||||
.iter()
|
||||
.find(|stage| stage.stage == TurnPromptAugmentationStageKind::ServiceSkillLaunch)
|
||||
.expect("service skill stage");
|
||||
|
||||
assert_eq!(
|
||||
diagnostics.effective_user_message_len,
|
||||
user_message.chars().count()
|
||||
);
|
||||
assert!(diagnostics.has_turn_context_metadata);
|
||||
assert!(diagnostics
|
||||
.turn_context_metadata_keys
|
||||
.contains(&"harness".to_string()));
|
||||
assert!(service_skill_stage.changed);
|
||||
assert!(final_prompt.contains(SERVICE_SKILL_LAUNCH_PROMPT_MARKER));
|
||||
assert!(final_prompt.contains("第一步优先调用 lime_site_run"));
|
||||
assert!(final_prompt.contains("不要先用 WebSearch、research、webReader"));
|
||||
assert!(final_prompt.contains("不要直接调用 mcp__lime-browser__browser_navigate"));
|
||||
assert!(final_prompt.contains("第一工具调用示例(lime_site_run 参数 JSON)"));
|
||||
assert!(final_prompt.contains("profile_key=attached-github"));
|
||||
assert!(final_prompt.contains("target_id=tab-github"));
|
||||
assert!(final_prompt.contains("\"adapter_name\":\"github/search\""));
|
||||
assert!(final_prompt.contains("attached_session_required、no_matching_context"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_runtime_turn_request_metadata_should_backfill_content_id_from_session_runtime() {
|
||||
let mut request = AsterChatRequest {
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
use super::*;
|
||||
use crate::services::site_capability_service::{
|
||||
get_site_adapter, run_site_adapter_with_optional_save, RunSiteAdapterRequest,
|
||||
SiteAdapterDefinition, SiteAdapterRunResult,
|
||||
};
|
||||
|
||||
const SERVICE_SKILL_LAUNCH_BROWSER_DENY_PATTERNS: &[&str] = &[
|
||||
"mcp__lime-browser__*",
|
||||
"browser_*",
|
||||
"mcp__playwright__*",
|
||||
"playwright*",
|
||||
];
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub(crate) struct ServiceSkillLaunchSiteAdapterContext {
|
||||
pub(crate) adapter_name: String,
|
||||
pub(crate) args: serde_json::Value,
|
||||
pub(crate) profile_key: Option<String>,
|
||||
pub(crate) target_id: Option<String>,
|
||||
pub(crate) content_id: Option<String>,
|
||||
pub(crate) project_id: Option<String>,
|
||||
pub(crate) save_title: Option<String>,
|
||||
pub(crate) skill_title: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) struct ServiceSkillLaunchPreloadExecution {
|
||||
pub(crate) request: RunSiteAdapterRequest,
|
||||
pub(crate) adapter: Option<SiteAdapterDefinition>,
|
||||
pub(crate) result: SiteAdapterRunResult,
|
||||
}
|
||||
|
||||
fn extract_object_string(
|
||||
object: &serde_json::Map<String, serde_json::Value>,
|
||||
keys: &[&str],
|
||||
) -> Option<String> {
|
||||
keys.iter()
|
||||
.filter_map(|key| object.get(*key))
|
||||
.find_map(serde_json::Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(str::to_string)
|
||||
}
|
||||
|
||||
fn normalized_optional_object(
|
||||
value: Option<&serde_json::Value>,
|
||||
) -> Option<&serde_json::Map<String, serde_json::Value>> {
|
||||
value.and_then(serde_json::Value::as_object)
|
||||
}
|
||||
|
||||
pub(crate) fn extract_service_skill_launch_site_adapter_context(
|
||||
request_metadata: Option<&serde_json::Value>,
|
||||
) -> Option<ServiceSkillLaunchSiteAdapterContext> {
|
||||
let launch = extract_harness_nested_object(
|
||||
request_metadata,
|
||||
&["service_skill_launch", "serviceSkillLaunch"],
|
||||
)?;
|
||||
let kind =
|
||||
extract_object_string(launch, &["kind"]).unwrap_or_else(|| "site_adapter".to_string());
|
||||
if kind != "site_adapter" {
|
||||
return None;
|
||||
}
|
||||
|
||||
let adapter_name = extract_object_string(launch, &["adapter_name", "adapterName"])?;
|
||||
let launch_readiness = normalized_optional_object(launch.get("launch_readiness"));
|
||||
let browser_assist =
|
||||
extract_harness_nested_object(request_metadata, &["browser_assist", "browserAssist"]);
|
||||
let profile_key = launch_readiness
|
||||
.and_then(|value| extract_object_string(value, &["profile_key", "profileKey"]))
|
||||
.or_else(|| {
|
||||
browser_assist
|
||||
.and_then(|value| extract_object_string(value, &["profile_key", "profileKey"]))
|
||||
});
|
||||
let target_id =
|
||||
launch_readiness.and_then(|value| extract_object_string(value, &["target_id", "targetId"]));
|
||||
|
||||
Some(ServiceSkillLaunchSiteAdapterContext {
|
||||
adapter_name,
|
||||
args: launch
|
||||
.get("args")
|
||||
.cloned()
|
||||
.filter(|value| value.is_object())
|
||||
.unwrap_or_else(|| serde_json::json!({})),
|
||||
profile_key,
|
||||
target_id,
|
||||
content_id: extract_object_string(launch, &["content_id", "contentId"]),
|
||||
project_id: extract_object_string(launch, &["project_id", "projectId"]),
|
||||
save_title: extract_object_string(launch, &["save_title", "saveTitle"]),
|
||||
skill_title: extract_object_string(launch, &["skill_title", "skillTitle"]),
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn should_lock_service_skill_launch_to_site_tools(
|
||||
request_metadata: Option<&serde_json::Value>,
|
||||
) -> bool {
|
||||
extract_service_skill_launch_site_adapter_context(request_metadata).is_some()
|
||||
}
|
||||
|
||||
pub(crate) fn service_skill_launch_browser_deny_patterns() -> &'static [&'static str] {
|
||||
SERVICE_SKILL_LAUNCH_BROWSER_DENY_PATTERNS
|
||||
}
|
||||
|
||||
pub(crate) fn build_service_skill_launch_run_request(
|
||||
request_metadata: Option<&serde_json::Value>,
|
||||
) -> Option<RunSiteAdapterRequest> {
|
||||
let context = extract_service_skill_launch_site_adapter_context(request_metadata)?;
|
||||
Some(RunSiteAdapterRequest {
|
||||
adapter_name: context.adapter_name,
|
||||
args: context.args,
|
||||
profile_key: context.profile_key,
|
||||
target_id: context.target_id,
|
||||
timeout_ms: None,
|
||||
content_id: context.content_id,
|
||||
project_id: context.project_id,
|
||||
save_title: context.save_title,
|
||||
require_attached_session: Some(true),
|
||||
skill_title: context.skill_title,
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) async fn preload_service_skill_launch_execution(
|
||||
db: &DbConnection,
|
||||
request_metadata: Option<&serde_json::Value>,
|
||||
) -> Result<Option<ServiceSkillLaunchPreloadExecution>, String> {
|
||||
let Some(request) = build_service_skill_launch_run_request(request_metadata) else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
let adapter = get_site_adapter(&request.adapter_name);
|
||||
let result = run_site_adapter_with_optional_save(db, request.clone()).await;
|
||||
|
||||
Ok(Some(ServiceSkillLaunchPreloadExecution {
|
||||
request,
|
||||
adapter,
|
||||
result,
|
||||
}))
|
||||
}
|
||||
|
||||
pub(crate) fn append_service_skill_launch_session_permissions(
|
||||
permissions: &mut Vec<ToolPermission>,
|
||||
session_id: &str,
|
||||
request_metadata: Option<&serde_json::Value>,
|
||||
) {
|
||||
if !should_lock_service_skill_launch_to_site_tools(request_metadata) {
|
||||
return;
|
||||
}
|
||||
|
||||
let session_id = session_id.trim();
|
||||
let conditions = if session_id.is_empty() {
|
||||
Vec::new()
|
||||
} else {
|
||||
vec![PermissionCondition {
|
||||
condition_type: ConditionType::Session,
|
||||
field: Some("session_id".to_string()),
|
||||
operator: ConditionOperator::Equals,
|
||||
value: serde_json::json!(session_id),
|
||||
validator: None,
|
||||
description: Some("仅对当前站点技能启动回合生效".to_string()),
|
||||
}]
|
||||
};
|
||||
|
||||
for pattern in service_skill_launch_browser_deny_patterns() {
|
||||
permissions.push(ToolPermission {
|
||||
tool: (*pattern).to_string(),
|
||||
allowed: false,
|
||||
priority: 1250,
|
||||
conditions: conditions.clone(),
|
||||
parameter_restrictions: Vec::new(),
|
||||
scope: PermissionScope::Session,
|
||||
reason: Some(
|
||||
"站点技能启动回合已锁定为 site adapter 执行,禁止直接回退到底层浏览器兼容工具"
|
||||
.to_string(),
|
||||
),
|
||||
expires_at: None,
|
||||
metadata: HashMap::new(),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -49,6 +49,12 @@ pub(crate) struct SessionRecentHarnessContext {
|
||||
pub(crate) content_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub(crate) struct SessionRecentRuntimeContext {
|
||||
pub(crate) preferences: Option<lime_agent::SessionExecutionRuntimePreferences>,
|
||||
pub(crate) team_selection: Option<lime_agent::SessionExecutionRuntimeRecentTeamSelection>,
|
||||
}
|
||||
|
||||
pub(crate) async fn persist_session_provider_routing(
|
||||
session_id: &str,
|
||||
provider_selector: &str,
|
||||
@@ -69,32 +75,39 @@ pub(crate) fn resolve_session_provider_selector(
|
||||
SessionProviderRoutingState::from_session(session).map(|state| state.provider_selector)
|
||||
}
|
||||
|
||||
fn build_session_recent_runtime_context(
|
||||
session_id: &str,
|
||||
session: &aster::session::Session,
|
||||
) -> SessionRecentRuntimeContext {
|
||||
let runtime = lime_agent::build_session_execution_runtime(
|
||||
session_id,
|
||||
Some(session),
|
||||
None,
|
||||
None,
|
||||
resolve_session_provider_selector(session),
|
||||
);
|
||||
|
||||
SessionRecentRuntimeContext {
|
||||
preferences: runtime
|
||||
.as_ref()
|
||||
.and_then(|value| value.recent_preferences.clone()),
|
||||
team_selection: runtime.and_then(|value| value.recent_team_selection),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn resolve_session_recent_runtime_context(
|
||||
session_id: &str,
|
||||
) -> Result<SessionRecentRuntimeContext, String> {
|
||||
let session = read_session(session_id, false, "读取会话 recent runtime 上下文失败").await?;
|
||||
Ok(build_session_recent_runtime_context(session_id, &session))
|
||||
}
|
||||
|
||||
pub(crate) async fn resolve_session_recent_preferences(
|
||||
session_id: &str,
|
||||
) -> Result<Option<lime_agent::SessionExecutionRuntimePreferences>, String> {
|
||||
let session = read_session(session_id, false, "读取会话 recent_preferences 失败").await?;
|
||||
Ok(lime_agent::build_session_execution_runtime(
|
||||
session_id,
|
||||
Some(&session),
|
||||
None,
|
||||
None,
|
||||
resolve_session_provider_selector(&session),
|
||||
)
|
||||
.and_then(|runtime| runtime.recent_preferences))
|
||||
}
|
||||
|
||||
pub(crate) async fn resolve_session_recent_team_selection(
|
||||
session_id: &str,
|
||||
) -> Result<Option<lime_agent::SessionExecutionRuntimeRecentTeamSelection>, String> {
|
||||
let session = read_session(session_id, false, "读取会话 recent_team_selection 失败").await?;
|
||||
Ok(lime_agent::build_session_execution_runtime(
|
||||
session_id,
|
||||
Some(&session),
|
||||
None,
|
||||
None,
|
||||
resolve_session_provider_selector(&session),
|
||||
)
|
||||
.and_then(|runtime| runtime.recent_team_selection))
|
||||
Ok(resolve_session_recent_runtime_context(session_id)
|
||||
.await?
|
||||
.preferences)
|
||||
}
|
||||
|
||||
pub(crate) async fn resolve_session_recent_harness_context(
|
||||
|
||||
@@ -3,6 +3,9 @@ mod tests {
|
||||
use super::*;
|
||||
use crate::commands::aster_agent_cmd::action_runtime::build_runtime_action_scope;
|
||||
use crate::commands::aster_agent_cmd::dto::AgentRuntimeActionScope;
|
||||
use crate::services::site_capability_service::{
|
||||
RunSiteAdapterRequest, SiteAdapterDefinition, SiteAdapterRunResult,
|
||||
};
|
||||
use async_trait::async_trait;
|
||||
use lime_agent::request_tool_policy::resolve_request_tool_policy;
|
||||
use lime_agent::AgentEvent as RuntimeAgentEvent;
|
||||
@@ -448,11 +451,7 @@ mod tests {
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
LimeBrowserMcpTool::resolve_backend("find", ¶ms, Some(&session_hint)),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
LimeBrowserMcpTool::resolve_backend("javascript_tool", ¶ms, Some(&session_hint)),
|
||||
LimeBrowserMcpTool::resolve_backend("drag", ¶ms, Some(&session_hint)),
|
||||
None
|
||||
);
|
||||
}
|
||||
@@ -471,10 +470,56 @@ mod tests {
|
||||
LimeBrowserMcpTool::resolve_backend("navigate", ¶ms, Some(&session_hint)),
|
||||
Some(BrowserBackendType::CdpDirect)
|
||||
);
|
||||
assert_eq!(
|
||||
LimeBrowserMcpTool::resolve_backend("find", ¶ms, Some(&session_hint)),
|
||||
Some(BrowserBackendType::CdpDirect)
|
||||
);
|
||||
assert_eq!(
|
||||
LimeBrowserMcpTool::resolve_backend("read_page", ¶ms, Some(&session_hint)),
|
||||
Some(BrowserBackendType::CdpDirect)
|
||||
);
|
||||
assert_eq!(
|
||||
LimeBrowserMcpTool::resolve_backend("javascript_tool", ¶ms, Some(&session_hint)),
|
||||
Some(BrowserBackendType::CdpDirect)
|
||||
);
|
||||
assert_eq!(
|
||||
LimeBrowserMcpTool::resolve_backend("computer", ¶ms, Some(&session_hint)),
|
||||
Some(BrowserBackendType::CdpDirect)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_should_not_auto_launch_managed_browser_for_existing_session_profile() {
|
||||
let session_hint = BrowserAssistRuntimeHint {
|
||||
profile_key: "attached-xhs".to_string(),
|
||||
preferred_backend: Some(BrowserBackendType::LimeExtensionBridge),
|
||||
auto_launch: true,
|
||||
launch_url: None,
|
||||
};
|
||||
|
||||
assert!(!LimeBrowserMcpTool::should_auto_launch_managed_browser(
|
||||
Some(BrowserBackendType::LimeExtensionBridge),
|
||||
Some(&session_hint),
|
||||
Some(lime_core::database::dao::browser_profile::BrowserProfileTransportKind::ExistingSession),
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_should_keep_managed_auto_launch_for_managed_cdp_profile() {
|
||||
let session_hint = BrowserAssistRuntimeHint {
|
||||
profile_key: "general_browser_assist".to_string(),
|
||||
preferred_backend: Some(BrowserBackendType::CdpDirect),
|
||||
auto_launch: true,
|
||||
launch_url: Some("https://www.google.com".to_string()),
|
||||
};
|
||||
|
||||
assert!(LimeBrowserMcpTool::should_auto_launch_managed_browser(
|
||||
Some(BrowserBackendType::CdpDirect),
|
||||
Some(&session_hint),
|
||||
Some(
|
||||
lime_core::database::dao::browser_profile::BrowserProfileTransportKind::ManagedCdp
|
||||
),
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -543,6 +588,81 @@ mod tests {
|
||||
assert_eq!(deny_rule.conditions, allow_rule.conditions);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_service_skill_launch_run_request_requires_attached_session() {
|
||||
let metadata = serde_json::json!({
|
||||
"harness": {
|
||||
"browser_assist": {
|
||||
"enabled": true,
|
||||
"profile_key": "attached-github"
|
||||
},
|
||||
"service_skill_launch": {
|
||||
"kind": "site_adapter",
|
||||
"skill_title": "GitHub 仓库线索检索",
|
||||
"adapter_name": "github/search",
|
||||
"args": {
|
||||
"query": "AI Agent",
|
||||
"limit": 10
|
||||
},
|
||||
"content_id": "content-1",
|
||||
"project_id": "project-1",
|
||||
"save_title": "AI Agent GitHub 结果",
|
||||
"launch_readiness": {
|
||||
"status": "ready",
|
||||
"target_id": "tab-github"
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let request = build_service_skill_launch_run_request(Some(&metadata))
|
||||
.expect("should build run request");
|
||||
|
||||
assert_eq!(request.adapter_name, "github/search");
|
||||
assert_eq!(request.profile_key.as_deref(), Some("attached-github"));
|
||||
assert_eq!(request.target_id.as_deref(), Some("tab-github"));
|
||||
assert_eq!(request.require_attached_session, Some(true));
|
||||
assert_eq!(request.skill_title.as_deref(), Some("GitHub 仓库线索检索"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_append_service_skill_launch_session_permissions_blocks_browser_compat_tools() {
|
||||
let metadata = serde_json::json!({
|
||||
"harness": {
|
||||
"browser_assist": {
|
||||
"enabled": true,
|
||||
"profile_key": "attached-github"
|
||||
},
|
||||
"service_skill_launch": {
|
||||
"kind": "site_adapter",
|
||||
"adapter_name": "github/search",
|
||||
"args": {
|
||||
"query": "AI Agent"
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
let mut permissions = Vec::new();
|
||||
|
||||
append_service_skill_launch_session_permissions(
|
||||
&mut permissions,
|
||||
"session-service-skill-1",
|
||||
Some(&metadata),
|
||||
);
|
||||
|
||||
let deny_rule = permissions
|
||||
.iter()
|
||||
.find(|permission| permission.tool == "mcp__lime-browser__*")
|
||||
.expect("should add browser compat deny rule");
|
||||
assert!(!deny_rule.allowed);
|
||||
assert_eq!(deny_rule.priority, 1250);
|
||||
assert_eq!(deny_rule.conditions.len(), 1);
|
||||
assert_eq!(
|
||||
deny_rule.conditions[0].value,
|
||||
serde_json::json!("session-service-skill-1")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_agent_runtime_submit_turn_request_maps_to_aster_chat_request() {
|
||||
let json = r#"{
|
||||
@@ -1708,6 +1828,235 @@ mod tests {
|
||||
assert!(merged.contains("3"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_merge_system_prompt_with_service_skill_launch_appends_prompt() {
|
||||
let metadata = serde_json::json!({
|
||||
"harness": {
|
||||
"browser_assist": {
|
||||
"enabled": true,
|
||||
"profile_key": "attached-github",
|
||||
},
|
||||
"service_skill_launch": {
|
||||
"kind": "site_adapter",
|
||||
"skill_title": "GitHub 仓库线索检索",
|
||||
"adapter_name": "github/search",
|
||||
"args": {
|
||||
"query": "AI Agent",
|
||||
"limit": 10
|
||||
},
|
||||
"save_mode": "current_content",
|
||||
"content_id": "content-1",
|
||||
"project_id": "project-1",
|
||||
"launch_readiness": {
|
||||
"status": "ready",
|
||||
"message": "已检测到 github.com 的真实浏览器页面。",
|
||||
"target_id": "tab-github"
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let merged = merge_system_prompt_with_service_skill_launch(
|
||||
Some("你是助手".to_string()),
|
||||
Some(&metadata),
|
||||
)
|
||||
.expect("should contain merged prompt");
|
||||
|
||||
assert!(merged.contains(SERVICE_SKILL_LAUNCH_PROMPT_MARKER));
|
||||
assert!(merged.contains("github/search"));
|
||||
assert!(merged.contains("\"query\":\"AI Agent\""));
|
||||
assert!(merged.contains("profile_key=attached-github"));
|
||||
assert!(merged.contains("target_id=tab-github"));
|
||||
assert!(merged.contains("mcp__lime-browser__browser_navigate"));
|
||||
assert!(merged.contains("严格 JSON 对象"));
|
||||
assert!(merged.contains("\"adapter_name\":\"github/search\""));
|
||||
assert!(merged.contains("不要再让用户额外确认"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_merge_system_prompt_with_service_skill_launch_skips_duplicate_marker() {
|
||||
let metadata = serde_json::json!({
|
||||
"harness": {
|
||||
"service_skill_launch": {
|
||||
"kind": "site_adapter",
|
||||
"adapter_name": "github/search",
|
||||
"args": {
|
||||
"query": "AI Agent"
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
let base = Some(format!(
|
||||
"{SERVICE_SKILL_LAUNCH_PROMPT_MARKER}\n已有站点技能上下文"
|
||||
));
|
||||
|
||||
let merged = merge_system_prompt_with_service_skill_launch(base.clone(), Some(&metadata));
|
||||
|
||||
assert_eq!(merged, base);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_merge_system_prompt_with_service_skill_launch_includes_missing_session_failure_contract(
|
||||
) {
|
||||
let metadata = serde_json::json!({
|
||||
"harness": {
|
||||
"service_skill_launch": {
|
||||
"kind": "site_adapter",
|
||||
"skill_title": "GitHub 仓库线索检索",
|
||||
"adapter_name": "github/search",
|
||||
"args": {
|
||||
"query": "AI Agent"
|
||||
},
|
||||
"launch_readiness": {
|
||||
"status": "attached_session_required",
|
||||
"message": "当前缺少已附着的 GitHub 浏览器上下文。",
|
||||
"report_hint": "请先连接并停留在 github.com。"
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let merged = merge_system_prompt_with_service_skill_launch(
|
||||
Some("你是助手".to_string()),
|
||||
Some(&metadata),
|
||||
)
|
||||
.expect("should contain merged prompt");
|
||||
|
||||
assert!(
|
||||
merged.contains("attached_session_required、no_matching_context、登录受限或权限受限")
|
||||
);
|
||||
assert!(merged.contains("当前缺少可执行的浏览器上下文"));
|
||||
assert!(merged.contains("不要再让用户额外确认"));
|
||||
assert!(merged.contains("请先连接并停留在 github.com。"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_merge_system_prompt_with_service_skill_launch_preload_appends_result_context() {
|
||||
let execution = ServiceSkillLaunchPreloadExecution {
|
||||
request: RunSiteAdapterRequest {
|
||||
adapter_name: "github/search".to_string(),
|
||||
args: serde_json::json!({
|
||||
"query": "AI Agent",
|
||||
"limit": 10
|
||||
}),
|
||||
profile_key: Some("attached-github".to_string()),
|
||||
target_id: Some("tab-github".to_string()),
|
||||
timeout_ms: None,
|
||||
content_id: Some("content-1".to_string()),
|
||||
project_id: Some("project-1".to_string()),
|
||||
save_title: Some("AI Agent GitHub 结果".to_string()),
|
||||
require_attached_session: Some(true),
|
||||
skill_title: Some("GitHub 仓库线索检索".to_string()),
|
||||
},
|
||||
adapter: Some(SiteAdapterDefinition {
|
||||
name: "github/search".to_string(),
|
||||
domain: "github.com".to_string(),
|
||||
description: "按关键词采集 GitHub 仓库搜索结果。".to_string(),
|
||||
read_only: true,
|
||||
capabilities: vec!["search".to_string()],
|
||||
input_schema: serde_json::json!({}),
|
||||
example_args: serde_json::json!({"query":"mcp","limit":5}),
|
||||
example: "github/search {\"query\":\"mcp\"}".to_string(),
|
||||
auth_hint: Some("请先登录 GitHub。".to_string()),
|
||||
source_kind: Some("bundled".to_string()),
|
||||
source_version: Some("2026-03-25".to_string()),
|
||||
}),
|
||||
result: SiteAdapterRunResult {
|
||||
ok: true,
|
||||
adapter: "github/search".to_string(),
|
||||
domain: "github.com".to_string(),
|
||||
profile_key: "attached-github".to_string(),
|
||||
session_id: Some("session-1".to_string()),
|
||||
target_id: Some("tab-github".to_string()),
|
||||
entry_url: "https://github.com/search?q=AI%20Agent&type=repositories".to_string(),
|
||||
source_url: Some(
|
||||
"https://github.com/search?q=AI%20Agent&type=repositories".to_string(),
|
||||
),
|
||||
data: Some(serde_json::json!({
|
||||
"items": [
|
||||
{"title": "microsoft/autogen"}
|
||||
]
|
||||
})),
|
||||
error_code: None,
|
||||
error_message: None,
|
||||
auth_hint: None,
|
||||
report_hint: None,
|
||||
saved_content: None,
|
||||
saved_project_id: None,
|
||||
saved_by: None,
|
||||
save_skipped_project_id: None,
|
||||
save_skipped_by: None,
|
||||
save_error_message: None,
|
||||
},
|
||||
};
|
||||
|
||||
let merged = merge_system_prompt_with_service_skill_launch_preload(
|
||||
Some("你是助手".to_string()),
|
||||
Some(&execution),
|
||||
)
|
||||
.expect("should contain preload prompt");
|
||||
|
||||
assert!(merged.contains(SERVICE_SKILL_LAUNCH_PRELOAD_PROMPT_MARKER));
|
||||
assert!(merged.contains("系统侧预执行成功"));
|
||||
assert!(merged.contains("不要再次调用 lime_site_run"));
|
||||
assert!(merged.contains("microsoft/autogen"));
|
||||
assert!(merged.contains("\"require_attached_session\":true"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_merge_system_prompt_with_service_skill_launch_preload_handles_missing_context_failure()
|
||||
{
|
||||
let execution = ServiceSkillLaunchPreloadExecution {
|
||||
request: RunSiteAdapterRequest {
|
||||
adapter_name: "github/search".to_string(),
|
||||
args: serde_json::json!({
|
||||
"query": "AI Agent"
|
||||
}),
|
||||
profile_key: Some("attached-github".to_string()),
|
||||
target_id: None,
|
||||
timeout_ms: None,
|
||||
content_id: None,
|
||||
project_id: None,
|
||||
save_title: None,
|
||||
require_attached_session: Some(true),
|
||||
skill_title: Some("GitHub 仓库线索检索".to_string()),
|
||||
},
|
||||
adapter: None,
|
||||
result: SiteAdapterRunResult {
|
||||
ok: false,
|
||||
adapter: "github/search".to_string(),
|
||||
domain: "github.com".to_string(),
|
||||
profile_key: "attached-github".to_string(),
|
||||
session_id: None,
|
||||
target_id: None,
|
||||
entry_url: "https://github.com/search?q=AI%20Agent&type=repositories".to_string(),
|
||||
source_url: None,
|
||||
data: None,
|
||||
error_code: Some("attached_session_required".to_string()),
|
||||
error_message: Some("当前缺少已附着的 GitHub 浏览器上下文。".to_string()),
|
||||
auth_hint: None,
|
||||
report_hint: Some("请先连接并停留在 github.com。".to_string()),
|
||||
saved_content: None,
|
||||
saved_project_id: None,
|
||||
saved_by: None,
|
||||
save_skipped_project_id: None,
|
||||
save_skipped_by: None,
|
||||
save_error_message: None,
|
||||
},
|
||||
};
|
||||
|
||||
let merged = merge_system_prompt_with_service_skill_launch_preload(
|
||||
Some("你是助手".to_string()),
|
||||
Some(&execution),
|
||||
)
|
||||
.expect("should contain preload prompt");
|
||||
|
||||
assert!(merged.contains("attached_session_required"));
|
||||
assert!(merged.contains("先连接并附着到目标站点页面"));
|
||||
assert!(merged.contains("不要再次尝试调用 lime_site_run"));
|
||||
assert!(merged.contains("请先连接并停留在 github.com。"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_should_fallback_to_react_from_code_orchestrated_when_no_event_emitted() {
|
||||
let error = ReplyAttemptError {
|
||||
@@ -1826,50 +2175,62 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_build_team_preference_system_prompt_requires_subagent_mode() {
|
||||
let prompt = build_team_preference_system_prompt(Some(&serde_json::json!({
|
||||
"harness": {
|
||||
"subagent_mode_enabled": true,
|
||||
"preferred_team_preset_id": "code-triage-team",
|
||||
}
|
||||
})), None, true)
|
||||
let prompt = build_team_preference_system_prompt(
|
||||
Some(&serde_json::json!({
|
||||
"harness": {
|
||||
"subagent_mode_enabled": true,
|
||||
"preferred_team_preset_id": "code-triage-team",
|
||||
}
|
||||
})),
|
||||
None,
|
||||
true,
|
||||
)
|
||||
.expect("team prompt should exist");
|
||||
|
||||
assert!(prompt.contains(TEAM_PREFERENCE_PROMPT_MARKER));
|
||||
assert!(prompt.contains("代码排障团队"));
|
||||
assert!(prompt.contains("spawn_agent"));
|
||||
|
||||
let disabled = build_team_preference_system_prompt(Some(&serde_json::json!({
|
||||
"harness": {
|
||||
"subagent_mode_enabled": false,
|
||||
"preferred_team_preset_id": "code-triage-team",
|
||||
}
|
||||
})), None, false);
|
||||
let disabled = build_team_preference_system_prompt(
|
||||
Some(&serde_json::json!({
|
||||
"harness": {
|
||||
"subagent_mode_enabled": false,
|
||||
"preferred_team_preset_id": "code-triage-team",
|
||||
}
|
||||
})),
|
||||
None,
|
||||
false,
|
||||
);
|
||||
assert!(disabled.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_team_preference_system_prompt_renders_selected_team_details() {
|
||||
let prompt = build_team_preference_system_prompt(Some(&serde_json::json!({
|
||||
"harness": {
|
||||
"subagent_mode_enabled": true,
|
||||
"selected_team_source": "custom",
|
||||
"selected_team_label": "前端联调团队",
|
||||
"selected_team_summary": "分析、实现、验证三段式推进。",
|
||||
"selected_team_roles": [
|
||||
{
|
||||
"label": "分析",
|
||||
"summary": "负责定位问题与影响范围。",
|
||||
"profile_id": "code-explorer",
|
||||
"role_key": "explorer",
|
||||
"skill_ids": ["repo-exploration"]
|
||||
},
|
||||
{
|
||||
"label": "执行",
|
||||
"summary": "负责提交实现与说明改动点。"
|
||||
}
|
||||
]
|
||||
}
|
||||
})), None, true)
|
||||
let prompt = build_team_preference_system_prompt(
|
||||
Some(&serde_json::json!({
|
||||
"harness": {
|
||||
"subagent_mode_enabled": true,
|
||||
"selected_team_source": "custom",
|
||||
"selected_team_label": "前端联调团队",
|
||||
"selected_team_summary": "分析、实现、验证三段式推进。",
|
||||
"selected_team_roles": [
|
||||
{
|
||||
"label": "分析",
|
||||
"summary": "负责定位问题与影响范围。",
|
||||
"profile_id": "code-explorer",
|
||||
"role_key": "explorer",
|
||||
"skill_ids": ["repo-exploration"]
|
||||
},
|
||||
{
|
||||
"label": "执行",
|
||||
"summary": "负责提交实现与说明改动点。"
|
||||
}
|
||||
]
|
||||
}
|
||||
})),
|
||||
None,
|
||||
true,
|
||||
)
|
||||
.expect("team prompt should exist");
|
||||
|
||||
assert!(prompt.contains("前端联调团队"));
|
||||
@@ -1884,22 +2245,26 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_build_team_preference_system_prompt_emphasizes_parent_coordination() {
|
||||
let prompt = build_team_preference_system_prompt(Some(&serde_json::json!({
|
||||
"harness": {
|
||||
"subagent_mode_enabled": true,
|
||||
"selected_team_label": "当前调试 Team",
|
||||
"selected_team_roles": [
|
||||
{
|
||||
"id": "runtime-explorer",
|
||||
"label": "分析",
|
||||
"summary": "负责定位问题。",
|
||||
"profile_id": "code-explorer",
|
||||
"role_key": "explorer",
|
||||
"skill_ids": ["repo-exploration"]
|
||||
}
|
||||
]
|
||||
}
|
||||
})), None, true)
|
||||
let prompt = build_team_preference_system_prompt(
|
||||
Some(&serde_json::json!({
|
||||
"harness": {
|
||||
"subagent_mode_enabled": true,
|
||||
"selected_team_label": "当前调试 Team",
|
||||
"selected_team_roles": [
|
||||
{
|
||||
"id": "runtime-explorer",
|
||||
"label": "分析",
|
||||
"summary": "负责定位问题。",
|
||||
"profile_id": "code-explorer",
|
||||
"role_key": "explorer",
|
||||
"skill_ids": ["repo-exploration"]
|
||||
}
|
||||
]
|
||||
}
|
||||
})),
|
||||
None,
|
||||
true,
|
||||
)
|
||||
.expect("team prompt should exist");
|
||||
|
||||
assert!(prompt.contains("当前调试 Team"));
|
||||
@@ -1965,8 +2330,8 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_team_preference_system_prompt_prefers_request_metadata_over_session_recent_team_selection()
|
||||
{
|
||||
fn test_build_team_preference_system_prompt_prefers_request_metadata_over_session_recent_team_selection(
|
||||
) {
|
||||
let prompt = build_team_preference_system_prompt(
|
||||
Some(&serde_json::json!({
|
||||
"harness": {
|
||||
|
||||
@@ -74,7 +74,7 @@ fn sync_workspace_mode_native_tool_surface(
|
||||
config_manager: Arc<GlobalConfigManager>,
|
||||
) {
|
||||
if surface.browser_assist {
|
||||
browser_tools::register_browser_mcp_tools_to_registry(registry);
|
||||
browser_tools::register_browser_mcp_tools_to_registry(registry, db.clone());
|
||||
site_tools::register_site_tools_to_registry(registry, db.clone());
|
||||
} else {
|
||||
browser_tools::unregister_browser_mcp_tools_from_registry(registry);
|
||||
@@ -123,6 +123,8 @@ pub(crate) async fn apply_workspace_sandbox_permissions(
|
||||
persisted_policy: Some(¤t_config.agent.tool_execution),
|
||||
request_metadata,
|
||||
};
|
||||
let lock_service_skill_launch_to_site_tools =
|
||||
should_lock_service_skill_launch_to_site_tools(request_metadata);
|
||||
let tool_surface = WorkspaceToolSurface {
|
||||
creator: runtime_chat_mode == RuntimeChatMode::Creator,
|
||||
browser_assist: is_browser_assist_enabled(request_metadata),
|
||||
@@ -162,7 +164,7 @@ pub(crate) async fn apply_workspace_sandbox_permissions(
|
||||
execution_policy_input,
|
||||
});
|
||||
|
||||
if tool_surface.browser_assist {
|
||||
if tool_surface.browser_assist && !lock_service_skill_launch_to_site_tools {
|
||||
for tool_name in browser_tools::browser_mcp_tool_names() {
|
||||
permissions.push(ToolPermission {
|
||||
tool: tool_name,
|
||||
@@ -179,6 +181,7 @@ pub(crate) async fn apply_workspace_sandbox_permissions(
|
||||
}
|
||||
|
||||
append_browser_assist_session_permissions(&mut permissions, session_id, request_metadata);
|
||||
append_service_skill_launch_session_permissions(&mut permissions, session_id, request_metadata);
|
||||
|
||||
let (registry_arc, _) = resolve_agent_registry(state).await?;
|
||||
let mut registry = registry_arc.write().await;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
use super::*;
|
||||
use lime_core::database::dao::browser_profile::BrowserProfileTransportKind;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) struct LimeBrowserMcpTool {
|
||||
@@ -6,6 +7,7 @@ pub(crate) struct LimeBrowserMcpTool {
|
||||
action_name: String,
|
||||
description: String,
|
||||
input_schema: serde_json::Value,
|
||||
db: DbConnection,
|
||||
}
|
||||
|
||||
impl LimeBrowserMcpTool {
|
||||
@@ -14,12 +16,14 @@ impl LimeBrowserMcpTool {
|
||||
action_name: String,
|
||||
description: String,
|
||||
input_schema: serde_json::Value,
|
||||
db: DbConnection,
|
||||
) -> Self {
|
||||
Self {
|
||||
tool_name,
|
||||
action_name,
|
||||
description,
|
||||
input_schema,
|
||||
db,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,6 +38,10 @@ impl LimeBrowserMcpTool {
|
||||
"tabs_context_mcp"
|
||||
| "tabs_create_mcp"
|
||||
| "navigate"
|
||||
| "find"
|
||||
| "computer"
|
||||
| "javascript"
|
||||
| "javascript_tool"
|
||||
| "click"
|
||||
| "type"
|
||||
| "form_input"
|
||||
@@ -106,6 +114,50 @@ impl LimeBrowserMcpTool {
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn load_profile_transport_kind(
|
||||
db: &DbConnection,
|
||||
profile_key: &str,
|
||||
) -> Option<BrowserProfileTransportKind> {
|
||||
let conn = db.lock().ok()?;
|
||||
crate::services::browser_profile_service::get_browser_profile_by_key(&conn, profile_key)
|
||||
.ok()
|
||||
.flatten()
|
||||
.map(|profile| profile.transport_kind)
|
||||
}
|
||||
|
||||
pub(crate) fn should_auto_launch_managed_browser(
|
||||
resolved_backend: Option<BrowserBackendType>,
|
||||
session_hint: Option<&BrowserAssistRuntimeHint>,
|
||||
profile_transport: Option<BrowserProfileTransportKind>,
|
||||
) -> bool {
|
||||
if !session_hint.is_some_and(|hint| hint.auto_launch) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if matches!(
|
||||
profile_transport,
|
||||
Some(BrowserProfileTransportKind::ExistingSession)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if matches!(
|
||||
resolved_backend,
|
||||
Some(BrowserBackendType::LimeExtensionBridge)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if matches!(
|
||||
session_hint.and_then(|hint| hint.preferred_backend.clone()),
|
||||
Some(BrowserBackendType::LimeExtensionBridge)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
@@ -139,7 +191,13 @@ impl Tool for LimeBrowserMcpTool {
|
||||
let profile_key = Self::extract_profile_key(¶ms, _context)
|
||||
.or_else(|| session_hint.as_ref().map(|hint| hint.profile_key.clone()));
|
||||
if let (Some(hint), Some(profile_key)) = (session_hint.as_ref(), profile_key.as_ref()) {
|
||||
if hint.auto_launch {
|
||||
let profile_transport =
|
||||
Self::load_profile_transport_kind(&self.db, profile_key.as_str());
|
||||
if Self::should_auto_launch_managed_browser(
|
||||
backend.clone(),
|
||||
Some(hint),
|
||||
profile_transport,
|
||||
) {
|
||||
let launch_url = Self::extract_launch_url(&self.action_name, ¶ms)
|
||||
.or_else(|| hint.launch_url.clone());
|
||||
ensure_managed_chrome_profile_global(profile_key.clone(), launch_url)
|
||||
@@ -158,7 +216,7 @@ impl Tool for LimeBrowserMcpTool {
|
||||
timeout_ms,
|
||||
};
|
||||
|
||||
let result = browser_execute_action_global(request)
|
||||
let result = browser_execute_action_global(self.db.clone(), request)
|
||||
.await
|
||||
.map_err(|e| ToolError::execution_failed(format!("浏览器动作执行失败: {e}")))?;
|
||||
|
||||
@@ -226,7 +284,10 @@ pub(super) fn browser_mcp_tool_names() -> Vec<String> {
|
||||
names
|
||||
}
|
||||
|
||||
pub(super) fn register_browser_mcp_tools_to_registry(registry: &mut aster::tools::ToolRegistry) {
|
||||
pub(super) fn register_browser_mcp_tools_to_registry(
|
||||
registry: &mut aster::tools::ToolRegistry,
|
||||
db: DbConnection,
|
||||
) {
|
||||
let tool_defs = get_chrome_mcp_tools();
|
||||
for tool_def in tool_defs {
|
||||
for prefix in ["mcp__lime-browser__"] {
|
||||
@@ -239,6 +300,7 @@ pub(super) fn register_browser_mcp_tools_to_registry(registry: &mut aster::tools
|
||||
tool_def.name.clone(),
|
||||
tool_def.description.clone(),
|
||||
tool_def.input_schema.clone(),
|
||||
db.clone(),
|
||||
);
|
||||
registry.register(Box::new(tool));
|
||||
}
|
||||
@@ -255,10 +317,11 @@ pub(super) fn unregister_browser_mcp_tools_from_registry(
|
||||
|
||||
pub(crate) async fn ensure_browser_mcp_tools_registered(
|
||||
state: &AsterAgentState,
|
||||
db: &DbConnection,
|
||||
) -> Result<(), String> {
|
||||
let (registry_arc, extension_manager) = resolve_agent_registry(state).await?;
|
||||
let mut registry = registry_arc.write().await;
|
||||
register_browser_mcp_tools_to_registry(&mut registry);
|
||||
register_browser_mcp_tools_to_registry(&mut registry, db.clone());
|
||||
search_bridge::register_tool_search_tool_to_registry(
|
||||
&mut registry,
|
||||
registry_arc.clone(),
|
||||
|
||||
@@ -4,9 +4,10 @@ use crate::app::AppState;
|
||||
use crate::services::browser_connector_service::{
|
||||
get_browser_connector_install_status, get_browser_connector_settings,
|
||||
install_browser_connector_extension, sync_browser_connector_auto_config_if_installed,
|
||||
update_browser_connector_enabled, update_browser_connector_install_root,
|
||||
update_system_connector_enabled, BrowserConnectorAutoConfig, BrowserConnectorInstallResult,
|
||||
BrowserConnectorInstallStatus, BrowserConnectorSettingsSnapshot,
|
||||
update_browser_action_capability_enabled, update_browser_connector_enabled,
|
||||
update_browser_connector_install_root, update_system_connector_enabled,
|
||||
BrowserConnectorAutoConfig, BrowserConnectorInstallResult, BrowserConnectorInstallStatus,
|
||||
BrowserConnectorSettingsSnapshot,
|
||||
};
|
||||
use serde::Deserialize;
|
||||
use tauri::{AppHandle, State};
|
||||
@@ -30,6 +31,12 @@ pub struct SetSystemConnectorEnabledRequest {
|
||||
pub enabled: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct SetBrowserActionCapabilityEnabledRequest {
|
||||
pub key: String,
|
||||
pub enabled: bool,
|
||||
}
|
||||
|
||||
fn normalize_bridge_host(host: &str) -> String {
|
||||
match host.trim() {
|
||||
"" | "0.0.0.0" | "::" | "[::]" => "127.0.0.1".to_string(),
|
||||
@@ -60,6 +67,62 @@ async fn build_auto_config(
|
||||
})
|
||||
}
|
||||
|
||||
fn open_chrome_url(url: &str) -> Result<bool, String> {
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
let mut command = std::process::Command::new("open");
|
||||
command.args(["-a", "Google Chrome", url]);
|
||||
if let Err(primary_error) = command.spawn() {
|
||||
std::process::Command::new("open")
|
||||
.arg(url)
|
||||
.spawn()
|
||||
.map_err(|fallback_error| {
|
||||
format!("打开 Chrome 页面失败: {primary_error}; fallback: {fallback_error}")
|
||||
})?;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
std::process::Command::new("cmd")
|
||||
.args(["/C", "start", "", url])
|
||||
.spawn()
|
||||
.map_err(|error| format!("打开 Chrome 页面失败: {error}"))?;
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
let attempts = [
|
||||
("google-chrome", vec![url]),
|
||||
("chromium", vec![url]),
|
||||
("xdg-open", vec![url]),
|
||||
];
|
||||
|
||||
let mut last_error = None;
|
||||
let mut opened = false;
|
||||
for (binary, args) in attempts {
|
||||
match std::process::Command::new(binary).args(args).spawn() {
|
||||
Ok(_) => {
|
||||
opened = true;
|
||||
break;
|
||||
}
|
||||
Err(error) => {
|
||||
last_error = Some(format!("{binary}: {error}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !opened {
|
||||
return Err(format!(
|
||||
"打开 Chrome 页面失败: {}",
|
||||
last_error.unwrap_or_else(|| "没有可用的浏览器命令".to_string())
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_browser_connector_settings_cmd() -> Result<BrowserConnectorSettingsSnapshot, String> {
|
||||
get_browser_connector_settings()
|
||||
@@ -90,6 +153,13 @@ pub fn set_system_connector_enabled_cmd(
|
||||
update_system_connector_enabled(&request.id, request.enabled)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn set_browser_action_capability_enabled_cmd(
|
||||
request: SetBrowserActionCapabilityEnabledRequest,
|
||||
) -> Result<BrowserConnectorSettingsSnapshot, String> {
|
||||
update_browser_action_capability_enabled(&request.key, request.enabled)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_browser_connector_install_status_cmd(
|
||||
app: AppHandle,
|
||||
@@ -124,57 +194,10 @@ pub async fn install_browser_connector_extension_cmd(
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn open_browser_extensions_page_cmd() -> Result<bool, String> {
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
let mut command = std::process::Command::new("open");
|
||||
command.args(["-a", "Google Chrome", "chrome://extensions"]);
|
||||
if let Err(primary_error) = command.spawn() {
|
||||
std::process::Command::new("open")
|
||||
.arg("chrome://extensions")
|
||||
.spawn()
|
||||
.map_err(|fallback_error| {
|
||||
format!("打开 Chrome 扩展页面失败: {primary_error}; fallback: {fallback_error}")
|
||||
})?;
|
||||
}
|
||||
}
|
||||
open_chrome_url("chrome://extensions")
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
std::process::Command::new("cmd")
|
||||
.args(["/C", "start", "", "chrome://extensions"])
|
||||
.spawn()
|
||||
.map_err(|error| format!("打开 Chrome 扩展页面失败: {error}"))?;
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
let attempts = [
|
||||
("google-chrome", vec!["chrome://extensions"]),
|
||||
("chromium", vec!["chrome://extensions"]),
|
||||
("xdg-open", vec!["chrome://extensions"]),
|
||||
];
|
||||
|
||||
let mut last_error = None;
|
||||
let mut opened = false;
|
||||
for (binary, args) in attempts {
|
||||
match std::process::Command::new(binary).args(args).spawn() {
|
||||
Ok(_) => {
|
||||
opened = true;
|
||||
break;
|
||||
}
|
||||
Err(error) => {
|
||||
last_error = Some(format!("{binary}: {error}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !opened {
|
||||
return Err(format!(
|
||||
"打开 Chrome 扩展页面失败: {}",
|
||||
last_error.unwrap_or_else(|| "没有可用的浏览器命令".to_string())
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(true)
|
||||
#[tauri::command]
|
||||
pub async fn open_browser_remote_debugging_page_cmd() -> Result<bool, String> {
|
||||
open_chrome_url("chrome://inspect/#remote-debugging")
|
||||
}
|
||||
|
||||
@@ -53,6 +53,8 @@ pub struct LaunchBrowserProfileRuntimeAssistRequest {
|
||||
#[serde(default)]
|
||||
pub open_window: Option<bool>,
|
||||
#[serde(default)]
|
||||
pub headless: Option<bool>,
|
||||
#[serde(default)]
|
||||
pub stream_mode: Option<BrowserStreamMode>,
|
||||
}
|
||||
|
||||
@@ -125,6 +127,7 @@ pub async fn launch_browser_profile_runtime_assist_cmd(
|
||||
environment: None,
|
||||
target_id: request.target_id,
|
||||
open_window: request.open_window.unwrap_or(false),
|
||||
headless: request.headless.unwrap_or(false),
|
||||
stream_mode: request.stream_mode.unwrap_or(BrowserStreamMode::Both),
|
||||
},
|
||||
)
|
||||
|
||||
@@ -3,9 +3,10 @@
|
||||
use crate::app::AppState;
|
||||
use crate::commands::webview_cmd::{
|
||||
append_browser_runtime_launch_audit, open_cdp_session_global,
|
||||
open_chrome_profile_window_global, shared_browser_runtime, start_browser_stream_global,
|
||||
BrowserRuntimeLaunchAuditInput, ChromeProfileLaunchOptions, OpenCdpSessionRequest,
|
||||
OpenChromeProfileRequest, OpenChromeProfileResponse, StartBrowserStreamRequest,
|
||||
open_chrome_profile_window_global, resolve_profile_session_global, shared_browser_runtime,
|
||||
start_browser_stream_global, BrowserRuntimeLaunchAuditInput, ChromeProfileLaunchOptions,
|
||||
ChromeProfileSessionInfo, OpenCdpSessionRequest, OpenChromeProfileRequest,
|
||||
OpenChromeProfileResponse, StartBrowserStreamRequest,
|
||||
};
|
||||
use crate::database::{lock_db, DbConnection};
|
||||
use crate::services::browser_environment_service::{
|
||||
@@ -14,7 +15,7 @@ use crate::services::browser_environment_service::{
|
||||
BrowserEnvironmentLaunchConfig,
|
||||
};
|
||||
use crate::services::browser_profile_service::{
|
||||
get_browser_profile, touch_browser_profile_last_used,
|
||||
get_browser_profile, get_browser_profile_by_key, touch_browser_profile_last_used,
|
||||
};
|
||||
use crate::services::browser_runtime_window;
|
||||
use lime_browser_runtime::BrowserStreamMode;
|
||||
@@ -54,6 +55,8 @@ pub struct LaunchBrowserSessionRequest {
|
||||
pub target_id: Option<String>,
|
||||
#[serde(default = "default_open_window")]
|
||||
pub open_window: bool,
|
||||
#[serde(default)]
|
||||
pub headless: bool,
|
||||
#[serde(default = "default_stream_mode")]
|
||||
pub stream_mode: BrowserStreamMode,
|
||||
}
|
||||
@@ -68,6 +71,8 @@ pub struct LaunchBrowserRuntimeAssistRequest {
|
||||
pub target_id: Option<String>,
|
||||
#[serde(default = "default_open_window")]
|
||||
pub open_window: bool,
|
||||
#[serde(default)]
|
||||
pub headless: bool,
|
||||
#[serde(default = "default_stream_mode")]
|
||||
pub stream_mode: BrowserStreamMode,
|
||||
#[serde(default)]
|
||||
@@ -86,11 +91,13 @@ pub type BrowserRuntimeAssistLaunchResponse = BrowserSessionLaunchResponse;
|
||||
pub struct ResolvedLaunchBrowserSessionRequest {
|
||||
pub profile_id: Option<String>,
|
||||
pub profile_key: String,
|
||||
pub transport_kind: Option<BrowserProfileTransportKind>,
|
||||
pub url: String,
|
||||
pub environment_preset_id: Option<String>,
|
||||
pub environment: Option<BrowserEnvironmentLaunchConfig>,
|
||||
pub target_id: Option<String>,
|
||||
pub open_window: bool,
|
||||
pub headless: bool,
|
||||
pub stream_mode: BrowserStreamMode,
|
||||
}
|
||||
|
||||
@@ -126,6 +133,7 @@ fn resolve_launch_browser_session_request(
|
||||
}
|
||||
|
||||
let mut resolved_profile_id = None;
|
||||
let mut resolved_transport_kind = None;
|
||||
let mut resolved_profile_key = request
|
||||
.profile_key
|
||||
.as_deref()
|
||||
@@ -153,14 +161,8 @@ fn resolve_launch_browser_session_request(
|
||||
));
|
||||
}
|
||||
}
|
||||
if profile.transport_kind == BrowserProfileTransportKind::ExistingSession {
|
||||
return Err(
|
||||
"当前资料使用“附着当前 Chrome”模式,运行时附着链路尚未接入;请先改用“托管浏览器”模式启动"
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
|
||||
resolved_profile_id = Some(profile.id.clone());
|
||||
resolved_transport_kind = Some(profile.transport_kind.clone());
|
||||
resolved_profile_key = Some(profile.profile_key.clone());
|
||||
if resolved_url.is_none() {
|
||||
resolved_url = profile.launch_url.clone();
|
||||
@@ -171,6 +173,16 @@ fn resolve_launch_browser_session_request(
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
.ok_or_else(|| "启动浏览器会话时必须提供 profile_id 或 profile_key".to_string())?;
|
||||
|
||||
if resolved_profile_id.is_none() {
|
||||
let conn = lock_db(db)?;
|
||||
if let Some(profile) = get_browser_profile_by_key(&conn, &profile_key)?
|
||||
.filter(|profile| profile.archived_at.is_none())
|
||||
{
|
||||
resolved_profile_id = Some(profile.id.clone());
|
||||
resolved_transport_kind = Some(profile.transport_kind.clone());
|
||||
}
|
||||
}
|
||||
|
||||
let environment = if let Some(preset_id) = request.environment_preset_id.as_deref() {
|
||||
let conn = lock_db(db)?;
|
||||
let preset = get_browser_environment_preset(&conn, preset_id)?
|
||||
@@ -190,11 +202,13 @@ fn resolve_launch_browser_session_request(
|
||||
Ok(ResolvedLaunchBrowserSessionRequest {
|
||||
profile_id: resolved_profile_id,
|
||||
profile_key,
|
||||
transport_kind: resolved_transport_kind,
|
||||
url: resolved_url.unwrap_or_else(default_launch_url),
|
||||
environment_preset_id,
|
||||
environment,
|
||||
target_id: request.target_id,
|
||||
open_window: request.open_window,
|
||||
headless: request.headless,
|
||||
stream_mode: request.stream_mode,
|
||||
})
|
||||
}
|
||||
@@ -326,6 +340,7 @@ pub async fn launch_browser_runtime_assist(
|
||||
profile_id = ?request.profile_id,
|
||||
target_id = ?request.target_id,
|
||||
open_window = request.open_window,
|
||||
headless = request.headless,
|
||||
stream_mode = ?request.stream_mode
|
||||
)
|
||||
)]
|
||||
@@ -340,6 +355,7 @@ pub async fn launch_browser_runtime_assist_global(
|
||||
ResolvedLaunchBrowserSessionRequest {
|
||||
profile_id: request.profile_id,
|
||||
profile_key: request.profile_key,
|
||||
transport_kind: None,
|
||||
url: request.url,
|
||||
environment_preset_id: request
|
||||
.environment
|
||||
@@ -348,6 +364,7 @@ pub async fn launch_browser_runtime_assist_global(
|
||||
environment: request.environment,
|
||||
target_id: request.target_id,
|
||||
open_window: request.open_window,
|
||||
headless: request.headless,
|
||||
stream_mode: request.stream_mode,
|
||||
},
|
||||
)
|
||||
@@ -363,6 +380,7 @@ pub async fn launch_browser_runtime_assist_global(
|
||||
environment_preset_id = ?request.environment_preset_id,
|
||||
target_id = ?request.target_id,
|
||||
open_window = request.open_window,
|
||||
headless = request.headless,
|
||||
stream_mode = ?request.stream_mode
|
||||
)
|
||||
)]
|
||||
@@ -395,6 +413,10 @@ pub async fn launch_browser_session_global(
|
||||
.as_ref()
|
||||
.map(build_chrome_launch_options)
|
||||
.unwrap_or_default();
|
||||
let chrome_launch_options = ChromeProfileLaunchOptions {
|
||||
headless: request.headless,
|
||||
..chrome_launch_options
|
||||
};
|
||||
let launch_url = request.url.clone();
|
||||
let bootstrap_url = if request.environment.is_some() {
|
||||
"about:blank".to_string()
|
||||
@@ -403,25 +425,48 @@ pub async fn launch_browser_session_global(
|
||||
};
|
||||
let launch_started_at = Instant::now();
|
||||
let profile_started_at = Instant::now();
|
||||
let profile = match open_chrome_profile_window_global(
|
||||
app_handle.clone(),
|
||||
app_state,
|
||||
OpenChromeProfileRequest {
|
||||
profile_key: request.profile_key.clone(),
|
||||
url: bootstrap_url.clone(),
|
||||
launch_options: Some(chrome_launch_options),
|
||||
},
|
||||
)
|
||||
.instrument(tracing::info_span!(
|
||||
"launch_browser_session_global.open_profile"
|
||||
))
|
||||
.await
|
||||
{
|
||||
Ok(profile) => profile,
|
||||
Err(error) => {
|
||||
finalize_browser_runtime_launch_audit(launch_audit, Some(error.clone())).await;
|
||||
return Err(error);
|
||||
let profile = match request.transport_kind.clone() {
|
||||
Some(BrowserProfileTransportKind::ExistingSession) => {
|
||||
let profile_session = match resolve_profile_session_global(Some(
|
||||
request.profile_key.clone(),
|
||||
))
|
||||
.instrument(tracing::info_span!(
|
||||
"launch_browser_session_global.resolve_existing_session"
|
||||
))
|
||||
.await
|
||||
{
|
||||
Ok(session) => session,
|
||||
Err(error) => {
|
||||
let wrapped = format!(
|
||||
"当前资料使用“附着当前 Chrome”模式,但没有检测到可复用的浏览器会话:{error}。请确认 Lime Chrome 插件已连接,并且当前 Chrome 已开启远程调试。"
|
||||
);
|
||||
finalize_browser_runtime_launch_audit(launch_audit, Some(wrapped.clone()))
|
||||
.await;
|
||||
return Err(wrapped);
|
||||
}
|
||||
};
|
||||
build_existing_session_profile_response(&profile_session)
|
||||
}
|
||||
_ => match open_chrome_profile_window_global(
|
||||
app_handle.clone(),
|
||||
app_state,
|
||||
OpenChromeProfileRequest {
|
||||
profile_key: request.profile_key.clone(),
|
||||
url: bootstrap_url.clone(),
|
||||
launch_options: Some(chrome_launch_options),
|
||||
},
|
||||
)
|
||||
.instrument(tracing::info_span!(
|
||||
"launch_browser_session_global.open_profile"
|
||||
))
|
||||
.await
|
||||
{
|
||||
Ok(profile) => profile,
|
||||
Err(error) => {
|
||||
finalize_browser_runtime_launch_audit(launch_audit, Some(error.clone())).await;
|
||||
return Err(error);
|
||||
}
|
||||
},
|
||||
};
|
||||
let profile_elapsed_ms = profile_started_at.elapsed().as_millis();
|
||||
launch_audit.reused = Some(profile.reused);
|
||||
@@ -631,6 +676,7 @@ pub async fn launch_browser_session_global(
|
||||
open_session_elapsed_ms,
|
||||
stream_elapsed_ms,
|
||||
open_window = request.open_window,
|
||||
headless = request.headless,
|
||||
"browser session launch: launch completed"
|
||||
);
|
||||
|
||||
@@ -638,12 +684,37 @@ pub async fn launch_browser_session_global(
|
||||
Ok(BrowserSessionLaunchResponse { profile, session })
|
||||
}
|
||||
|
||||
fn build_existing_session_profile_response(
|
||||
profile_session: &ChromeProfileSessionInfo,
|
||||
) -> OpenChromeProfileResponse {
|
||||
let browser_path = (!profile_session.browser_path.trim().is_empty())
|
||||
.then(|| profile_session.browser_path.clone());
|
||||
let profile_dir = (!profile_session.profile_dir.trim().is_empty())
|
||||
.then(|| profile_session.profile_dir.clone());
|
||||
let devtools_http_url = format!(
|
||||
"http://127.0.0.1:{}/json/version",
|
||||
profile_session.remote_debugging_port
|
||||
);
|
||||
OpenChromeProfileResponse {
|
||||
success: true,
|
||||
reused: true,
|
||||
browser_source: Some(profile_session.browser_source.clone()),
|
||||
browser_path,
|
||||
profile_dir,
|
||||
remote_debugging_port: Some(profile_session.remote_debugging_port),
|
||||
pid: Some(profile_session.pid),
|
||||
devtools_http_url: Some(devtools_http_url),
|
||||
error: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn build_chrome_launch_options(
|
||||
environment: &BrowserEnvironmentLaunchConfig,
|
||||
) -> ChromeProfileLaunchOptions {
|
||||
ChromeProfileLaunchOptions {
|
||||
proxy_server: environment.proxy_server.clone(),
|
||||
language: environment.browser_launch_language(),
|
||||
headless: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -750,6 +821,7 @@ mod tests {
|
||||
environment: None,
|
||||
target_id: Some("target-1".to_string()),
|
||||
open_window: false,
|
||||
headless: true,
|
||||
stream_mode: BrowserStreamMode::Both,
|
||||
},
|
||||
)
|
||||
@@ -773,6 +845,7 @@ mod tests {
|
||||
.and_then(|value| value.proxy_server.as_deref()),
|
||||
Some("http://127.0.0.1:7890")
|
||||
);
|
||||
assert!(resolved.headless);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -788,6 +861,7 @@ mod tests {
|
||||
environment: Some(BrowserEnvironmentLaunchConfig::default()),
|
||||
target_id: None,
|
||||
open_window: false,
|
||||
headless: false,
|
||||
stream_mode: BrowserStreamMode::Both,
|
||||
},
|
||||
)
|
||||
@@ -797,7 +871,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_launch_browser_session_request_should_reject_existing_session_profile() {
|
||||
fn resolve_launch_browser_session_request_should_allow_existing_session_profile() {
|
||||
let db = setup_db();
|
||||
{
|
||||
let conn = lock_db(&db).unwrap();
|
||||
@@ -817,7 +891,7 @@ mod tests {
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
let error = resolve_launch_browser_session_request(
|
||||
let resolved = resolve_launch_browser_session_request(
|
||||
&db,
|
||||
LaunchBrowserSessionRequest {
|
||||
profile_id: Some("profile-attach".to_string()),
|
||||
@@ -827,11 +901,62 @@ mod tests {
|
||||
environment: None,
|
||||
target_id: None,
|
||||
open_window: false,
|
||||
headless: false,
|
||||
stream_mode: BrowserStreamMode::Both,
|
||||
},
|
||||
)
|
||||
.unwrap_err();
|
||||
.expect("existing_session profile should resolve");
|
||||
|
||||
assert!(error.contains("附着当前 Chrome"));
|
||||
assert_eq!(resolved.profile_id.as_deref(), Some("profile-attach"));
|
||||
assert_eq!(resolved.profile_key, "weibo_attach");
|
||||
assert_eq!(
|
||||
resolved.transport_kind,
|
||||
Some(BrowserProfileTransportKind::ExistingSession)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_launch_browser_session_request_should_allow_existing_session_profile_key() {
|
||||
let db = setup_db();
|
||||
{
|
||||
let conn = lock_db(&db).unwrap();
|
||||
conn.execute(
|
||||
"INSERT INTO browser_profiles (
|
||||
id, profile_key, name, description, site_scope, launch_url, transport_kind,
|
||||
profile_dir, managed_profile_dir, created_at, updated_at, last_used_at, archived_at
|
||||
) VALUES (?1, ?2, ?3, NULL, NULL, ?4, 'existing_session', '', NULL, ?5, ?5, NULL, NULL)",
|
||||
(
|
||||
"profile-attach",
|
||||
"weibo_attach",
|
||||
"微博附着",
|
||||
"https://weibo.com/",
|
||||
"2026-03-15T00:00:00Z",
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
let resolved = resolve_launch_browser_session_request(
|
||||
&db,
|
||||
LaunchBrowserSessionRequest {
|
||||
profile_id: None,
|
||||
profile_key: Some("weibo_attach".to_string()),
|
||||
url: Some("https://weibo.com/".to_string()),
|
||||
environment_preset_id: None,
|
||||
environment: None,
|
||||
target_id: None,
|
||||
open_window: false,
|
||||
headless: false,
|
||||
stream_mode: BrowserStreamMode::Both,
|
||||
},
|
||||
)
|
||||
.expect("existing_session profile_key should resolve");
|
||||
|
||||
assert_eq!(resolved.profile_id.as_deref(), Some("profile-attach"));
|
||||
assert_eq!(resolved.profile_key, "weibo_attach");
|
||||
assert_eq!(
|
||||
resolved.transport_kind,
|
||||
Some(BrowserProfileTransportKind::ExistingSession)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,7 +39,6 @@ pub mod model_cmd;
|
||||
pub mod model_registry_cmd;
|
||||
pub mod models_cmd;
|
||||
pub mod music_cmd;
|
||||
pub mod network_cmd;
|
||||
pub mod novel_cmd;
|
||||
pub mod oauth_cmd;
|
||||
pub mod openclaw_cmd;
|
||||
@@ -52,7 +51,6 @@ pub mod poster_material_cmd;
|
||||
pub mod prompt_cmd;
|
||||
pub mod provider_pool_cmd;
|
||||
pub mod resilience_cmd;
|
||||
pub mod route_cmd;
|
||||
pub mod screenshot_cmd;
|
||||
pub mod security_perf_cmd;
|
||||
pub mod session_files_cmd;
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
//! 网络相关命令
|
||||
//!
|
||||
//! 核心逻辑已迁移到 lime-core::network,本文件保留 Tauri 命令包装。
|
||||
|
||||
// 重新导出核心类型
|
||||
pub use lime_core::network::{get_accessible_url, NetworkInfo};
|
||||
|
||||
/// 获取本地网络信息(Tauri 命令包装)
|
||||
#[tauri::command]
|
||||
pub fn get_network_info() -> Result<NetworkInfo, String> {
|
||||
lime_core::network::get_network_info()
|
||||
}
|
||||
@@ -295,7 +295,7 @@ pub async fn generate_persona(
|
||||
if !agent_state.is_initialized().await {
|
||||
agent_state.init_agent_with_db(&db).await?;
|
||||
}
|
||||
ensure_browser_mcp_tools_registered(agent_state.inner()).await?;
|
||||
ensure_browser_mcp_tools_registered(agent_state.inner(), &db).await?;
|
||||
|
||||
// 创建临时会话 ID
|
||||
let session_id = format!("persona-gen-{}", uuid::Uuid::new_v4());
|
||||
|
||||
@@ -1,94 +0,0 @@
|
||||
//! 路由相关 Tauri 命令
|
||||
|
||||
use crate::commands::network_cmd::get_accessible_url;
|
||||
use crate::commands::provider_pool_cmd::ProviderPoolServiceState;
|
||||
use crate::config;
|
||||
use crate::database::DbConnection;
|
||||
use crate::models::route_model::{RouteInfo, RouteListResponse};
|
||||
|
||||
/// 获取可访问的服务器地址
|
||||
///
|
||||
/// 使用 `get_accessible_url` 函数生成可访问的 URL。
|
||||
/// 对于 `0.0.0.0`,会转换为局域网 IP 或 `127.0.0.1`。
|
||||
fn get_valid_base_url(config: &config::Config) -> String {
|
||||
get_accessible_url(&config.server.host, config.server.port)
|
||||
}
|
||||
|
||||
/// 获取所有可用的路由端点
|
||||
#[tauri::command]
|
||||
pub async fn get_available_routes(
|
||||
db: tauri::State<'_, DbConnection>,
|
||||
pool_service: tauri::State<'_, ProviderPoolServiceState>,
|
||||
) -> Result<RouteListResponse, String> {
|
||||
// 获取配置中的服务器地址和默认 Provider
|
||||
let config = config::load_config().unwrap_or_default();
|
||||
let base_url = get_valid_base_url(&config);
|
||||
let default_provider = config.default_provider.clone();
|
||||
|
||||
let routes = pool_service
|
||||
.0
|
||||
.get_available_routes(db.inner(), &base_url)
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
// 添加默认路由,使用配置中的默认 Provider
|
||||
let mut all_routes = vec![RouteInfo {
|
||||
selector: "default".to_string(),
|
||||
provider_type: default_provider.clone(),
|
||||
credential_count: 1,
|
||||
endpoints: vec![
|
||||
crate::models::route_model::RouteEndpoint {
|
||||
path: "/v1/messages".to_string(),
|
||||
protocol: "claude".to_string(),
|
||||
url: format!("{base_url}/v1/messages"),
|
||||
},
|
||||
crate::models::route_model::RouteEndpoint {
|
||||
path: "/v1/chat/completions".to_string(),
|
||||
protocol: "openai".to_string(),
|
||||
url: format!("{base_url}/v1/chat/completions"),
|
||||
},
|
||||
],
|
||||
tags: vec!["默认".to_string()],
|
||||
enabled: true,
|
||||
}];
|
||||
all_routes.extend(routes);
|
||||
|
||||
Ok(RouteListResponse {
|
||||
base_url,
|
||||
default_provider,
|
||||
routes: all_routes,
|
||||
})
|
||||
}
|
||||
|
||||
/// 获取指定路由的 curl 示例
|
||||
#[tauri::command]
|
||||
pub async fn get_route_curl_examples(
|
||||
selector: String,
|
||||
db: tauri::State<'_, DbConnection>,
|
||||
pool_service: tauri::State<'_, ProviderPoolServiceState>,
|
||||
) -> Result<Vec<crate::models::route_model::CurlExample>, String> {
|
||||
let config = config::load_config().unwrap_or_default();
|
||||
let base_url = get_valid_base_url(&config);
|
||||
let default_provider = config.default_provider.clone();
|
||||
|
||||
let routes = pool_service
|
||||
.0
|
||||
.get_available_routes(db.inner(), &base_url)
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
// 查找匹配的路由
|
||||
let route = routes.iter().find(|r| r.selector == selector);
|
||||
|
||||
// P0 安全修复:curl 示例使用占位符,不暴露真实 API Key
|
||||
let api_key = "${LIME_API_KEY}";
|
||||
|
||||
match route {
|
||||
Some(r) => Ok(r.generate_curl_examples(api_key)),
|
||||
None => {
|
||||
// 生成默认路由的示例,使用配置中的默认 Provider
|
||||
let mut default_route = RouteInfo::new("default".to_string(), default_provider);
|
||||
default_route.add_endpoint(&base_url, "claude");
|
||||
default_route.add_endpoint(&base_url, "openai");
|
||||
Ok(default_route.generate_curl_examples(api_key))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,278 +1,11 @@
|
||||
//! 托盘相关命令
|
||||
//!
|
||||
//! 提供托盘状态同步和更新的 Tauri 命令
|
||||
//!
|
||||
//! # Requirements
|
||||
//! - 7.1: API 服务器状态变化时在 1 秒内更新托盘图标
|
||||
//! - 7.2: 凭证健康状态变化时在 1 秒内更新托盘图标
|
||||
//! - 7.3: 托盘菜单打开时获取并显示最新信息
|
||||
//! 仅保留托盘当前主链所需的 Tauri 命令。
|
||||
|
||||
use crate::tray::{TrayIconStatus, TrayQuickModelGroup, TrayStateSnapshot};
|
||||
use crate::tray::TrayQuickModelGroup;
|
||||
use crate::TrayManagerState;
|
||||
use tauri::State;
|
||||
use tracing::{debug, info};
|
||||
|
||||
/// 同步托盘状态
|
||||
///
|
||||
/// 从前端或其他模块调用,更新托盘的完整状态
|
||||
///
|
||||
/// # Requirements
|
||||
/// - 7.1: API 服务器状态变化时更新托盘图标
|
||||
/// - 7.2: 凭证健康状态变化时更新托盘图标
|
||||
#[tauri::command]
|
||||
pub async fn sync_tray_state(
|
||||
tray_state: State<'_, TrayManagerState<tauri::Wry>>,
|
||||
server_running: bool,
|
||||
server_address: String,
|
||||
available_credentials: usize,
|
||||
total_credentials: usize,
|
||||
today_requests: u64,
|
||||
auto_start_enabled: bool,
|
||||
) -> Result<(), String> {
|
||||
let tray_guard = tray_state.0.read().await;
|
||||
let tray_manager = tray_guard
|
||||
.as_ref()
|
||||
.ok_or_else(|| "托盘管理器未初始化".to_string())?;
|
||||
let current_state = tray_manager.get_state().await;
|
||||
|
||||
// 计算图标状态
|
||||
let icon_status = if !server_running {
|
||||
TrayIconStatus::Stopped
|
||||
} else if available_credentials == 0 && total_credentials > 0 {
|
||||
TrayIconStatus::Error
|
||||
} else if available_credentials < total_credentials {
|
||||
TrayIconStatus::Warning
|
||||
} else {
|
||||
TrayIconStatus::Running
|
||||
};
|
||||
|
||||
let snapshot = TrayStateSnapshot {
|
||||
icon_status,
|
||||
server_running,
|
||||
server_address,
|
||||
available_credentials,
|
||||
total_credentials,
|
||||
today_requests,
|
||||
auto_start_enabled,
|
||||
current_model_provider_type: current_state.current_model_provider_type,
|
||||
current_model_provider_label: current_state.current_model_provider_label,
|
||||
current_model: current_state.current_model,
|
||||
current_theme_label: current_state.current_theme_label,
|
||||
quick_model_groups: current_state.quick_model_groups,
|
||||
};
|
||||
|
||||
tray_manager
|
||||
.update_state(snapshot)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
debug!(
|
||||
"托盘状态已同步: server_running={}, icon_status={:?}",
|
||||
server_running, icon_status
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 更新托盘服务器状态
|
||||
///
|
||||
/// 仅更新服务器运行状态
|
||||
///
|
||||
/// # Requirements
|
||||
/// - 7.1: API 服务器状态变化时在 1 秒内更新托盘图标
|
||||
#[tauri::command]
|
||||
pub async fn update_tray_server_status(
|
||||
tray_state: State<'_, TrayManagerState<tauri::Wry>>,
|
||||
server_running: bool,
|
||||
server_host: String,
|
||||
server_port: u16,
|
||||
) -> Result<(), String> {
|
||||
let tray_guard = tray_state.0.read().await;
|
||||
let tray_manager = tray_guard
|
||||
.as_ref()
|
||||
.ok_or_else(|| "托盘管理器未初始化".to_string())?;
|
||||
|
||||
// 获取当前状态
|
||||
let mut current_state = tray_manager.get_state().await;
|
||||
|
||||
// 更新服务器相关字段
|
||||
current_state.server_running = server_running;
|
||||
current_state.server_address = if server_running {
|
||||
format!("{server_host}:{server_port}")
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
|
||||
// 重新计算图标状态
|
||||
if !server_running {
|
||||
current_state.icon_status = TrayIconStatus::Stopped;
|
||||
} else if current_state.icon_status == TrayIconStatus::Stopped {
|
||||
current_state.icon_status = TrayIconStatus::Running;
|
||||
}
|
||||
|
||||
tray_manager
|
||||
.update_state(current_state)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
info!(
|
||||
"托盘服务器状态已更新: running={}, address={}:{}",
|
||||
server_running, server_host, server_port
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 更新托盘凭证状态
|
||||
///
|
||||
/// 仅更新凭证健康状态
|
||||
///
|
||||
/// # Requirements
|
||||
/// - 7.2: 凭证健康状态变化时在 1 秒内更新托盘图标
|
||||
#[tauri::command]
|
||||
pub async fn update_tray_credential_status(
|
||||
tray_state: State<'_, TrayManagerState<tauri::Wry>>,
|
||||
available_credentials: usize,
|
||||
total_credentials: usize,
|
||||
has_warning: bool,
|
||||
) -> Result<(), String> {
|
||||
let tray_guard = tray_state.0.read().await;
|
||||
let tray_manager = tray_guard
|
||||
.as_ref()
|
||||
.ok_or_else(|| "托盘管理器未初始化".to_string())?;
|
||||
|
||||
// 获取当前状态
|
||||
let mut current_state = tray_manager.get_state().await;
|
||||
|
||||
// 更新凭证相关字段
|
||||
current_state.available_credentials = available_credentials;
|
||||
current_state.total_credentials = total_credentials;
|
||||
|
||||
// 重新计算图标状态
|
||||
if current_state.server_running {
|
||||
if available_credentials == 0 && total_credentials > 0 {
|
||||
current_state.icon_status = TrayIconStatus::Error;
|
||||
} else if has_warning || available_credentials < total_credentials {
|
||||
current_state.icon_status = TrayIconStatus::Warning;
|
||||
} else {
|
||||
current_state.icon_status = TrayIconStatus::Running;
|
||||
}
|
||||
}
|
||||
|
||||
tray_manager
|
||||
.update_state(current_state)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
info!(
|
||||
"托盘凭证状态已更新: available={}/{}, has_warning={}",
|
||||
available_credentials, total_credentials, has_warning
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 获取托盘当前状态
|
||||
///
|
||||
/// 返回托盘的当前状态快照
|
||||
#[tauri::command]
|
||||
pub async fn get_tray_state(
|
||||
tray_state: State<'_, TrayManagerState<tauri::Wry>>,
|
||||
) -> Result<TrayStateSnapshot, String> {
|
||||
let tray_guard = tray_state.0.read().await;
|
||||
let tray_manager = tray_guard
|
||||
.as_ref()
|
||||
.ok_or_else(|| "托盘管理器未初始化".to_string())?;
|
||||
|
||||
Ok(tray_manager.get_state().await)
|
||||
}
|
||||
|
||||
/// 刷新托盘菜单
|
||||
///
|
||||
/// 强制刷新托盘菜单内容
|
||||
///
|
||||
/// # Requirements
|
||||
/// - 7.3: 托盘菜单打开时获取并显示最新信息
|
||||
#[tauri::command]
|
||||
pub async fn refresh_tray_menu(
|
||||
tray_state: State<'_, TrayManagerState<tauri::Wry>>,
|
||||
) -> Result<(), String> {
|
||||
let tray_guard = tray_state.0.read().await;
|
||||
let tray_manager = tray_guard
|
||||
.as_ref()
|
||||
.ok_or_else(|| "托盘管理器未初始化".to_string())?;
|
||||
|
||||
tray_manager
|
||||
.refresh_menu()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
debug!("托盘菜单已刷新");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 刷新托盘菜单并更新统计数据
|
||||
///
|
||||
/// 在菜单打开时调用,获取最新的统计数据并刷新菜单
|
||||
///
|
||||
/// # Requirements
|
||||
/// - 7.3: 托盘菜单打开时获取并显示最新信息
|
||||
#[tauri::command]
|
||||
pub async fn refresh_tray_with_stats(
|
||||
tray_state: State<'_, TrayManagerState<tauri::Wry>>,
|
||||
server_running: bool,
|
||||
server_address: String,
|
||||
available_credentials: usize,
|
||||
total_credentials: usize,
|
||||
today_requests: u64,
|
||||
auto_start_enabled: bool,
|
||||
) -> Result<(), String> {
|
||||
let tray_guard = tray_state.0.read().await;
|
||||
let tray_manager = tray_guard
|
||||
.as_ref()
|
||||
.ok_or_else(|| "托盘管理器未初始化".to_string())?;
|
||||
let current_state = tray_manager.get_state().await;
|
||||
|
||||
// 计算图标状态
|
||||
let icon_status = if !server_running {
|
||||
TrayIconStatus::Stopped
|
||||
} else if available_credentials == 0 && total_credentials > 0 {
|
||||
TrayIconStatus::Error
|
||||
} else if available_credentials < total_credentials {
|
||||
TrayIconStatus::Warning
|
||||
} else {
|
||||
TrayIconStatus::Running
|
||||
};
|
||||
|
||||
let snapshot = TrayStateSnapshot {
|
||||
icon_status,
|
||||
server_running,
|
||||
server_address,
|
||||
available_credentials,
|
||||
total_credentials,
|
||||
today_requests,
|
||||
auto_start_enabled,
|
||||
current_model_provider_type: current_state.current_model_provider_type,
|
||||
current_model_provider_label: current_state.current_model_provider_label,
|
||||
current_model: current_state.current_model,
|
||||
current_theme_label: current_state.current_theme_label,
|
||||
quick_model_groups: current_state.quick_model_groups,
|
||||
};
|
||||
|
||||
// 更新状态并刷新菜单
|
||||
tray_manager
|
||||
.update_state(snapshot)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
debug!(
|
||||
"托盘菜单已刷新: server_running={}, requests={}, credentials={}/{}",
|
||||
server_running, today_requests, available_credentials, total_credentials
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
use tracing::debug;
|
||||
|
||||
/// 同步托盘中的快速模型切换菜单
|
||||
///
|
||||
|
||||
@@ -9,12 +9,15 @@
|
||||
//! - 控制窗口位置和大小
|
||||
|
||||
use crate::app::AppState;
|
||||
use crate::database::DbConnection;
|
||||
use crate::database::{lock_db, DbConnection};
|
||||
use crate::services::automation_service::browser_runtime_sync::{
|
||||
complete_browser_session_after_resume, sync_browser_session_runtime_state,
|
||||
};
|
||||
use crate::services::browser_connector_service::{
|
||||
ensure_browser_action_capability_enabled, filter_enabled_browser_action_capabilities,
|
||||
};
|
||||
use crate::services::browser_profile_service::{
|
||||
normalize_browser_profile_key,
|
||||
get_browser_profile_by_key, normalize_browser_profile_key,
|
||||
resolve_chrome_profile_data_dir as resolve_managed_chrome_profile_data_dir,
|
||||
resolve_chrome_profile_data_dir_from_base as resolve_managed_chrome_profile_data_dir_from_base,
|
||||
};
|
||||
@@ -25,11 +28,13 @@ use lime_browser_runtime::{
|
||||
BrowserEvent, BrowserRuntimeManager, BrowserStreamMode, CdpSessionState, CdpTargetInfo,
|
||||
EventBufferSnapshot, OpenSessionRequest,
|
||||
};
|
||||
use lime_core::database::dao::browser_profile::BrowserProfileTransportKind;
|
||||
use lime_server::chrome_bridge::{
|
||||
self, ChromeBridgeCommandRequest, ChromeBridgeCommandResult, ChromeBridgeDisconnectResult,
|
||||
ChromeBridgeStatusSnapshot,
|
||||
};
|
||||
use once_cell::sync::Lazy;
|
||||
use regex::Regex;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{json, Value};
|
||||
use std::collections::{HashMap, HashSet, VecDeque};
|
||||
@@ -141,6 +146,14 @@ static BROWSER_STREAM_RELAY_TASKS: Lazy<Mutex<HashMap<String, JoinHandle<()>>>>
|
||||
Lazy::new(|| Mutex::new(HashMap::new()));
|
||||
|
||||
const MANAGED_CHROME_RECOVERY_WAIT_MS: u64 = 800;
|
||||
static WINDOW_SCROLL_SCRIPT_TOP_REGEX: Lazy<Regex> = Lazy::new(|| {
|
||||
Regex::new(r#"(?is)window\.scroll(?:By|To)\s*\(\s*\{[^}]*top\s*:\s*(?P<amount>-?\d+)"#)
|
||||
.expect("window scroll top regex should be valid")
|
||||
});
|
||||
static WINDOW_SCROLL_SCRIPT_COORD_REGEX: Lazy<Regex> = Lazy::new(|| {
|
||||
Regex::new(r#"(?is)window\.scroll(?:By|To)\s*\(\s*-?\d+\s*,\s*(?P<amount>-?\d+)"#)
|
||||
.expect("window scroll coord regex should be valid")
|
||||
});
|
||||
|
||||
pub fn shared_chrome_profile_manager() -> Arc<Mutex<ChromeProfileManagerState>> {
|
||||
SHARED_CHROME_PROFILE_MANAGER.clone()
|
||||
@@ -195,6 +208,8 @@ pub struct ChromeProfileLaunchOptions {
|
||||
pub proxy_server: Option<String>,
|
||||
#[serde(default)]
|
||||
pub language: Option<String>,
|
||||
#[serde(default)]
|
||||
pub headless: bool,
|
||||
}
|
||||
|
||||
/// 启动外部 Chrome Profile 的请求参数
|
||||
@@ -774,8 +789,9 @@ async fn open_chrome_profile_window_with_manager(
|
||||
|
||||
let _ = cleanup_orphan_chrome_profile_processes(&profile_dir).await?;
|
||||
|
||||
// 准备 Chrome 扩展(获取 server 配置并生成 auto_config.json)
|
||||
let extension_dir = {
|
||||
let extension_dir = if launch_options.headless {
|
||||
None
|
||||
} else {
|
||||
let state_guard = app_state.read().await;
|
||||
let status = state_guard.status();
|
||||
let host = normalize_bridge_host(&status.host);
|
||||
@@ -783,7 +799,13 @@ async fn open_chrome_profile_window_with_manager(
|
||||
let bridge_key = state_guard.config.server.api_key.clone();
|
||||
let server_url = format!("ws://{host}:{port}");
|
||||
|
||||
prepare_chrome_extension(&app, &profile_dir, &server_url, &bridge_key, &profile_key)?
|
||||
Some(prepare_chrome_extension(
|
||||
&app,
|
||||
&profile_dir,
|
||||
&server_url,
|
||||
&bridge_key,
|
||||
&profile_key,
|
||||
)?)
|
||||
};
|
||||
|
||||
{
|
||||
@@ -836,7 +858,7 @@ async fn open_chrome_profile_window_with_manager(
|
||||
remote_port,
|
||||
&url_text,
|
||||
true,
|
||||
Some(&extension_dir),
|
||||
extension_dir.as_deref(),
|
||||
&launch_options,
|
||||
)?;
|
||||
let pid = child.id();
|
||||
@@ -1269,7 +1291,7 @@ pub async fn get_browser_backends_status(
|
||||
} else {
|
||||
Some("aster 兼容层当前无可用下游连接(扩展/CDP/native-host)".to_string())
|
||||
},
|
||||
capabilities: aster_backend_capabilities(),
|
||||
capabilities: filter_backend_capabilities(aster_backend_capabilities()),
|
||||
},
|
||||
BrowserBackendStatusItem {
|
||||
backend: BrowserBackendType::LimeExtensionBridge,
|
||||
@@ -1279,17 +1301,22 @@ pub async fn get_browser_backends_status(
|
||||
} else {
|
||||
Some("未检测到扩展 observer 连接".to_string())
|
||||
},
|
||||
capabilities: extension_backend_capabilities(),
|
||||
capabilities: filter_backend_capabilities(extension_backend_capabilities()),
|
||||
},
|
||||
BrowserBackendStatusItem {
|
||||
backend: BrowserBackendType::CdpDirect,
|
||||
available: cdp_available,
|
||||
reason: if cdp_available {
|
||||
None
|
||||
} else if extension_available {
|
||||
Some(
|
||||
"已检测到扩展 observer,但未命中可连接的 CDP 调试端口;请确认当前 Chrome 已开启远程调试。"
|
||||
.to_string(),
|
||||
)
|
||||
} else {
|
||||
Some("未检测到可连接的 CDP 调试端口".to_string())
|
||||
},
|
||||
capabilities: cdp_backend_capabilities(),
|
||||
capabilities: filter_backend_capabilities(cdp_backend_capabilities()),
|
||||
},
|
||||
],
|
||||
})
|
||||
@@ -1336,7 +1363,7 @@ pub async fn get_browser_backends_status_global() -> Result<BrowserBackendsStatu
|
||||
} else {
|
||||
Some("aster 兼容层当前无可用下游连接(扩展/CDP/native-host)".to_string())
|
||||
},
|
||||
capabilities: aster_backend_capabilities(),
|
||||
capabilities: filter_backend_capabilities(aster_backend_capabilities()),
|
||||
},
|
||||
BrowserBackendStatusItem {
|
||||
backend: BrowserBackendType::LimeExtensionBridge,
|
||||
@@ -1346,17 +1373,22 @@ pub async fn get_browser_backends_status_global() -> Result<BrowserBackendsStatu
|
||||
} else {
|
||||
Some("未检测到扩展 observer 连接".to_string())
|
||||
},
|
||||
capabilities: extension_backend_capabilities(),
|
||||
capabilities: filter_backend_capabilities(extension_backend_capabilities()),
|
||||
},
|
||||
BrowserBackendStatusItem {
|
||||
backend: BrowserBackendType::CdpDirect,
|
||||
available: cdp_available,
|
||||
reason: if cdp_available {
|
||||
None
|
||||
} else if extension_available {
|
||||
Some(
|
||||
"已检测到扩展 observer,但未命中可连接的 CDP 调试端口;请确认当前 Chrome 已开启远程调试。"
|
||||
.to_string(),
|
||||
)
|
||||
} else {
|
||||
Some("未检测到可连接的 CDP 调试端口".to_string())
|
||||
},
|
||||
capabilities: cdp_backend_capabilities(),
|
||||
capabilities: filter_backend_capabilities(cdp_backend_capabilities()),
|
||||
},
|
||||
],
|
||||
})
|
||||
@@ -1609,9 +1641,10 @@ fn sync_automation_browser_state(db: &DbConnection, state: &CdpSessionState, fin
|
||||
pub async fn browser_execute_action(
|
||||
_app: AppHandle,
|
||||
state: tauri::State<'_, ChromeProfileManagerWrapper>,
|
||||
db: tauri::State<'_, DbConnection>,
|
||||
request: BrowserActionRequest,
|
||||
) -> Result<BrowserActionResult, String> {
|
||||
browser_execute_action_with_manager(state.0.clone(), request).await
|
||||
browser_execute_action_with_manager(state.0.clone(), db.inner().clone(), request).await
|
||||
}
|
||||
|
||||
/// 获取浏览器动作审计日志
|
||||
@@ -1639,27 +1672,61 @@ pub async fn get_browser_action_audit_logs_global(
|
||||
/// 使用指定 profile manager 执行动作(供非 Tauri 命令入口复用)
|
||||
pub async fn browser_execute_action_with_manager(
|
||||
manager: Arc<Mutex<ChromeProfileManagerState>>,
|
||||
db: DbConnection,
|
||||
request: BrowserActionRequest,
|
||||
) -> Result<BrowserActionResult, String> {
|
||||
let action = normalize_action_name(&request.action)?;
|
||||
let requested_backend = request.backend.clone();
|
||||
let timeout_ms = request.timeout_ms;
|
||||
let (action, normalized_args) =
|
||||
normalize_browser_action_request(&request.action, request.args)?;
|
||||
let request_id = format!("browser-{}", uuid::Uuid::new_v4());
|
||||
let policy = BROWSER_BACKEND_POLICY.read().await.clone();
|
||||
let candidates = build_backend_candidates(request.backend.clone(), &policy);
|
||||
let allow_fallback = request.backend.is_none() && policy.auto_fallback;
|
||||
let allow_fallback = requested_backend.is_none() && policy.auto_fallback;
|
||||
let profile_key = request
|
||||
.profile_key
|
||||
.as_deref()
|
||||
.map(normalize_profile_key)
|
||||
.or_else(|| Some("default".to_string()));
|
||||
if let Err(error) = ensure_browser_action_capability_enabled(&action) {
|
||||
let result = BrowserActionResult {
|
||||
success: false,
|
||||
backend: None,
|
||||
session_id: None,
|
||||
target_id: None,
|
||||
action: action.clone(),
|
||||
request_id: request_id.clone(),
|
||||
data: None,
|
||||
error: Some(error.clone()),
|
||||
attempts: Vec::new(),
|
||||
};
|
||||
append_browser_runtime_audit(BrowserRuntimeAuditRecord::action(
|
||||
request_id,
|
||||
action,
|
||||
profile_key.clone(),
|
||||
requested_backend,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
false,
|
||||
Some(error),
|
||||
Vec::new(),
|
||||
))
|
||||
.await;
|
||||
return Ok(result);
|
||||
}
|
||||
let profile_transport =
|
||||
load_browser_profile_transport_kind(&db, profile_key.as_deref(), &requested_backend);
|
||||
let candidates =
|
||||
build_backend_candidates(requested_backend.clone(), &policy, profile_transport);
|
||||
|
||||
let mut attempts = Vec::new();
|
||||
for (idx, backend) in candidates.iter().enumerate() {
|
||||
match execute_browser_action_with_backend(
|
||||
backend.clone(),
|
||||
&action,
|
||||
request.args.clone(),
|
||||
normalized_args.clone(),
|
||||
profile_key.clone(),
|
||||
request.timeout_ms,
|
||||
timeout_ms,
|
||||
manager.clone(),
|
||||
)
|
||||
.await
|
||||
@@ -1689,7 +1756,7 @@ pub async fn browser_execute_action_with_manager(
|
||||
request_id,
|
||||
result.action.clone(),
|
||||
profile_key.clone(),
|
||||
request.backend.clone(),
|
||||
requested_backend.clone(),
|
||||
result.backend.clone(),
|
||||
result.session_id.clone(),
|
||||
result.target_id.clone(),
|
||||
@@ -1722,7 +1789,7 @@ pub async fn browser_execute_action_with_manager(
|
||||
request_id,
|
||||
result.action.clone(),
|
||||
profile_key.clone(),
|
||||
request.backend.clone(),
|
||||
requested_backend.clone(),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
@@ -1752,7 +1819,7 @@ pub async fn browser_execute_action_with_manager(
|
||||
request_id,
|
||||
action,
|
||||
profile_key,
|
||||
request.backend,
|
||||
requested_backend,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
@@ -1767,9 +1834,10 @@ pub async fn browser_execute_action_with_manager(
|
||||
/// 使用全局 profile manager 执行动作(供 Agent 工具复用)
|
||||
#[cfg_attr(any(test, not(debug_assertions)), allow(dead_code))]
|
||||
pub async fn browser_execute_action_global(
|
||||
db: DbConnection,
|
||||
request: BrowserActionRequest,
|
||||
) -> Result<BrowserActionResult, String> {
|
||||
browser_execute_action_with_manager(shared_chrome_profile_manager(), request).await
|
||||
browser_execute_action_with_manager(shared_chrome_profile_manager(), db, request).await
|
||||
}
|
||||
|
||||
pub async fn append_browser_runtime_launch_audit(input: BrowserRuntimeLaunchAuditInput) {
|
||||
@@ -1812,7 +1880,23 @@ fn normalize_backend_policy(policy: BrowserBackendPolicy) -> Result<BrowserBacke
|
||||
fn build_backend_candidates(
|
||||
forced_backend: Option<BrowserBackendType>,
|
||||
policy: &BrowserBackendPolicy,
|
||||
profile_transport: Option<BrowserProfileTransportKind>,
|
||||
) -> Vec<BrowserBackendType> {
|
||||
if matches!(
|
||||
profile_transport,
|
||||
Some(BrowserProfileTransportKind::ExistingSession)
|
||||
) {
|
||||
if forced_backend
|
||||
.as_ref()
|
||||
.is_some_and(|backend| *backend != BrowserBackendType::LimeExtensionBridge)
|
||||
{
|
||||
tracing::warn!(
|
||||
"[BrowserRuntime] existing_session 资料不支持 {:?},已强制切换为 lime_extension_bridge",
|
||||
forced_backend
|
||||
);
|
||||
}
|
||||
return vec![BrowserBackendType::LimeExtensionBridge];
|
||||
}
|
||||
if let Some(backend) = forced_backend {
|
||||
return vec![backend];
|
||||
}
|
||||
@@ -1822,6 +1906,39 @@ fn build_backend_candidates(
|
||||
policy.priority.clone()
|
||||
}
|
||||
|
||||
fn load_browser_profile_transport_kind(
|
||||
db: &DbConnection,
|
||||
profile_key: Option<&str>,
|
||||
requested_backend: &Option<BrowserBackendType>,
|
||||
) -> Option<BrowserProfileTransportKind> {
|
||||
let normalized_profile_key = profile_key.map(normalize_profile_key)?;
|
||||
let conn = match lock_db(db) {
|
||||
Ok(conn) => conn,
|
||||
Err(error) => {
|
||||
tracing::warn!(
|
||||
"[BrowserRuntime] 读取浏览器资料 transport_kind 失败: profile_key={}, requested_backend={:?}, error={}",
|
||||
normalized_profile_key,
|
||||
requested_backend,
|
||||
error
|
||||
);
|
||||
return None;
|
||||
}
|
||||
};
|
||||
match get_browser_profile_by_key(&conn, &normalized_profile_key) {
|
||||
Ok(Some(profile)) if profile.archived_at.is_none() => Some(profile.transport_kind),
|
||||
Ok(_) => None,
|
||||
Err(error) => {
|
||||
tracing::warn!(
|
||||
"[BrowserRuntime] 查询浏览器资料 transport_kind 失败: profile_key={}, requested_backend={:?}, error={}",
|
||||
normalized_profile_key,
|
||||
requested_backend,
|
||||
error
|
||||
);
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_action_name(action: &str) -> Result<String, String> {
|
||||
let raw = action.trim();
|
||||
if raw.is_empty() {
|
||||
@@ -1837,6 +1954,124 @@ fn normalize_action_name(action: &str) -> Result<String, String> {
|
||||
Ok(stripped.to_ascii_lowercase())
|
||||
}
|
||||
|
||||
fn normalize_browser_action_request(action: &str, args: Value) -> Result<(String, Value), String> {
|
||||
let normalized_action = normalize_action_name(action)?;
|
||||
match normalized_action.as_str() {
|
||||
"computer" => normalize_legacy_computer_action(args),
|
||||
"javascript_tool" => normalize_legacy_javascript_tool_action(args),
|
||||
_ => Ok((normalized_action, args)),
|
||||
}
|
||||
}
|
||||
|
||||
fn value_into_object(args: Value) -> serde_json::Map<String, Value> {
|
||||
match args {
|
||||
Value::Object(map) => map,
|
||||
_ => serde_json::Map::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn extract_scroll_amount_from_script(script: &str) -> Option<i64> {
|
||||
WINDOW_SCROLL_SCRIPT_TOP_REGEX
|
||||
.captures(script)
|
||||
.and_then(|captures| captures.name("amount"))
|
||||
.or_else(|| {
|
||||
WINDOW_SCROLL_SCRIPT_COORD_REGEX
|
||||
.captures(script)
|
||||
.and_then(|captures| captures.name("amount"))
|
||||
})
|
||||
.and_then(|amount| amount.as_str().trim().parse::<i64>().ok())
|
||||
}
|
||||
|
||||
fn normalize_legacy_computer_action(args: Value) -> Result<(String, Value), String> {
|
||||
let mut next_args = value_into_object(args);
|
||||
let computer_action = next_args
|
||||
.get("action")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or("click")
|
||||
.to_ascii_lowercase();
|
||||
|
||||
match computer_action.as_str() {
|
||||
"click" => Ok(("click".to_string(), Value::Object(next_args))),
|
||||
"type" | "input" => Ok(("type".to_string(), Value::Object(next_args))),
|
||||
"scroll" => {
|
||||
let raw_amount = next_args
|
||||
.get("amount")
|
||||
.and_then(Value::as_i64)
|
||||
.or_else(|| next_args.get("y").and_then(Value::as_i64))
|
||||
.unwrap_or(500);
|
||||
let direction = next_args
|
||||
.get("direction")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToString::to_string)
|
||||
.unwrap_or_else(|| {
|
||||
if raw_amount < 0 {
|
||||
"up".to_string()
|
||||
} else {
|
||||
"down".to_string()
|
||||
}
|
||||
});
|
||||
let amount = raw_amount.unsigned_abs().max(1);
|
||||
next_args.insert(
|
||||
"direction".to_string(),
|
||||
Value::String(direction.clone()),
|
||||
);
|
||||
next_args.insert("amount".to_string(), Value::from(amount));
|
||||
next_args.insert(
|
||||
"text".to_string(),
|
||||
Value::String(format!("{direction}:{amount}")),
|
||||
);
|
||||
Ok(("scroll_page".to_string(), Value::Object(next_args)))
|
||||
}
|
||||
_ => Err(format!(
|
||||
"当前浏览器兼容层暂不支持 computer.action={computer_action},请改用 click/type/scroll 一类动作"
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_legacy_javascript_tool_action(args: Value) -> Result<(String, Value), String> {
|
||||
let mut next_args = value_into_object(args);
|
||||
let script = [
|
||||
"script",
|
||||
"code",
|
||||
"javascript",
|
||||
"expression",
|
||||
"text",
|
||||
"value",
|
||||
]
|
||||
.iter()
|
||||
.find_map(|key| {
|
||||
next_args
|
||||
.get(*key)
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToString::to_string)
|
||||
})
|
||||
.ok_or_else(|| "javascript_tool 需要提供 script/code/expression 参数".to_string())?;
|
||||
|
||||
if let Some(amount) = extract_scroll_amount_from_script(&script) {
|
||||
let direction = if amount < 0 { "up" } else { "down" };
|
||||
let normalized_amount = amount.unsigned_abs().max(1);
|
||||
next_args.insert(
|
||||
"direction".to_string(),
|
||||
Value::String(direction.to_string()),
|
||||
);
|
||||
next_args.insert("amount".to_string(), Value::from(normalized_amount));
|
||||
next_args.insert(
|
||||
"text".to_string(),
|
||||
Value::String(format!("{direction}:{normalized_amount}")),
|
||||
);
|
||||
return Ok(("scroll_page".to_string(), Value::Object(next_args)));
|
||||
}
|
||||
|
||||
next_args.insert("expression".to_string(), Value::String(script));
|
||||
Ok(("javascript".to_string(), Value::Object(next_args)))
|
||||
}
|
||||
|
||||
fn normalize_action_timeout(timeout_ms: Option<u64>) -> u64 {
|
||||
timeout_ms
|
||||
.unwrap_or(DEFAULT_BROWSER_ACTION_TIMEOUT_MS)
|
||||
@@ -2046,6 +2281,9 @@ fn cdp_backend_capabilities() -> Vec<String> {
|
||||
"navigate".to_string(),
|
||||
"read_page".to_string(),
|
||||
"get_page_text".to_string(),
|
||||
"find".to_string(),
|
||||
"computer".to_string(),
|
||||
"javascript_tool".to_string(),
|
||||
"click".to_string(),
|
||||
"type".to_string(),
|
||||
"scroll".to_string(),
|
||||
@@ -2059,6 +2297,10 @@ fn cdp_backend_capabilities() -> Vec<String> {
|
||||
]
|
||||
}
|
||||
|
||||
fn filter_backend_capabilities(capabilities: Vec<String>) -> Vec<String> {
|
||||
filter_enabled_browser_action_capabilities(&capabilities).unwrap_or(capabilities)
|
||||
}
|
||||
|
||||
fn action_arg_string(args: &Value, keys: &[&str]) -> Option<String> {
|
||||
keys.iter().find_map(|key| {
|
||||
args.get(*key)
|
||||
@@ -2347,7 +2589,7 @@ async fn execute_extension_backend_action(
|
||||
target: action_arg_string(&args, &["target", "ref_id"]),
|
||||
text: action_arg_string(&args, &["text", "value"]),
|
||||
url: action_arg_string(&args, &["url"]),
|
||||
payload: None,
|
||||
payload: Some(args.clone()),
|
||||
wait_for_page_info: action_arg_bool(
|
||||
&args,
|
||||
"wait_for_page_info",
|
||||
@@ -2400,6 +2642,8 @@ async fn execute_cdp_backend_action(
|
||||
| "click"
|
||||
| "type"
|
||||
| "form_input"
|
||||
| "javascript"
|
||||
| "find"
|
||||
| "scroll"
|
||||
| "scroll_page"
|
||||
| "refresh_page"
|
||||
@@ -2556,6 +2800,52 @@ async fn discover_unmanaged_profile_session(
|
||||
}))
|
||||
}
|
||||
|
||||
async fn discover_bridge_backed_profile_session(
|
||||
profile_key: &str,
|
||||
) -> Result<Option<ChromeProfileSessionInfo>, String> {
|
||||
let normalized_profile_key = normalize_profile_key(profile_key);
|
||||
let observer = chrome_bridge::chrome_bridge_hub()
|
||||
.get_status_snapshot()
|
||||
.await
|
||||
.observers
|
||||
.into_iter()
|
||||
.find(|item| item.profile_key == normalized_profile_key);
|
||||
let Some(observer) = observer else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
let remote_debugging_port = profile_remote_debugging_port(&normalized_profile_key);
|
||||
let runtime = shared_browser_runtime();
|
||||
let targets = match runtime.list_targets(remote_debugging_port).await {
|
||||
Ok(value) => value,
|
||||
Err(_) => return Ok(None),
|
||||
};
|
||||
|
||||
let last_url = targets
|
||||
.iter()
|
||||
.find(|target| target.target_type == "page" && !target.url.trim().is_empty())
|
||||
.map(|target| target.url.trim().to_string())
|
||||
.or_else(|| {
|
||||
observer
|
||||
.last_page_info
|
||||
.as_ref()
|
||||
.and_then(|page| page.url.as_ref().map(|value| value.trim().to_string()))
|
||||
.filter(|value| !value.is_empty())
|
||||
})
|
||||
.unwrap_or_else(|| "about:blank".to_string());
|
||||
|
||||
Ok(Some(ChromeProfileSessionInfo {
|
||||
profile_key: normalized_profile_key,
|
||||
browser_source: "system".to_string(),
|
||||
browser_path: String::new(),
|
||||
profile_dir: String::new(),
|
||||
remote_debugging_port,
|
||||
pid: 0,
|
||||
started_at: observer.connected_at,
|
||||
last_url,
|
||||
}))
|
||||
}
|
||||
|
||||
async fn discover_unmanaged_profile_sessions() -> Result<Vec<ChromeProfileSessionInfo>, String> {
|
||||
let base_dir = lime_core::app_paths::preferred_data_dir()
|
||||
.map_err(|error| format!("获取应用数据目录失败: {error}"))?;
|
||||
@@ -2586,6 +2876,29 @@ async fn discover_unmanaged_profile_sessions() -> Result<Vec<ChromeProfileSessio
|
||||
Ok(discovered)
|
||||
}
|
||||
|
||||
async fn discover_bridge_backed_profile_sessions() -> Result<Vec<ChromeProfileSessionInfo>, String>
|
||||
{
|
||||
let bridge_status = chrome_bridge::chrome_bridge_hub()
|
||||
.get_status_snapshot()
|
||||
.await;
|
||||
let mut discovered = Vec::new();
|
||||
let mut seen_profile_keys = HashSet::new();
|
||||
|
||||
for observer in bridge_status.observers {
|
||||
if !seen_profile_keys.insert(observer.profile_key.clone()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some(session) = discover_bridge_backed_profile_session(&observer.profile_key).await?
|
||||
{
|
||||
discovered.push(session);
|
||||
}
|
||||
}
|
||||
|
||||
discovered.sort_by(|left, right| left.profile_key.cmp(&right.profile_key));
|
||||
Ok(discovered)
|
||||
}
|
||||
|
||||
async fn list_alive_profile_sessions(
|
||||
manager: Arc<Mutex<ChromeProfileManagerState>>,
|
||||
) -> Vec<ChromeProfileSessionInfo> {
|
||||
@@ -2605,7 +2918,7 @@ async fn list_alive_profile_sessions(
|
||||
guard.sessions.remove(&key);
|
||||
}
|
||||
|
||||
let managed_profile_keys = sessions
|
||||
let mut known_profile_keys = sessions
|
||||
.iter()
|
||||
.map(|session| session.profile_key.clone())
|
||||
.collect::<HashSet<_>>();
|
||||
@@ -2619,11 +2932,25 @@ async fn list_alive_profile_sessions(
|
||||
}
|
||||
};
|
||||
|
||||
sessions.extend(
|
||||
unmanaged_sessions
|
||||
.into_iter()
|
||||
.filter(|session| !managed_profile_keys.contains(&session.profile_key)),
|
||||
);
|
||||
for session in unmanaged_sessions {
|
||||
if known_profile_keys.insert(session.profile_key.clone()) {
|
||||
sessions.push(session);
|
||||
}
|
||||
}
|
||||
|
||||
let bridge_backed_sessions = match discover_bridge_backed_profile_sessions().await {
|
||||
Ok(items) => items,
|
||||
Err(error) => {
|
||||
tracing::warn!("[ChromeProfile] 发现扩展附着会话失败: {}", error);
|
||||
Vec::new()
|
||||
}
|
||||
};
|
||||
|
||||
for session in bridge_backed_sessions {
|
||||
if known_profile_keys.insert(session.profile_key.clone()) {
|
||||
sessions.push(session);
|
||||
}
|
||||
}
|
||||
sessions
|
||||
}
|
||||
|
||||
@@ -2633,7 +2960,10 @@ async fn select_profile_session(
|
||||
) -> Result<ChromeProfileSessionInfo, String> {
|
||||
let sessions = list_alive_profile_sessions(manager).await;
|
||||
if sessions.is_empty() {
|
||||
return Err("没有可用的 Chrome profile 会话,请先打开独立浏览器窗口".to_string());
|
||||
return Err(
|
||||
"没有可用的 Chrome 会话,请先连接当前 Chrome 扩展并开启远程调试,或启动托管浏览器。"
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
|
||||
if let Some(key) = profile_key {
|
||||
@@ -2647,7 +2977,13 @@ async fn select_profile_session(
|
||||
sessions
|
||||
.into_iter()
|
||||
.next()
|
||||
.ok_or_else(|| "没有可用的 Chrome profile 会话".to_string())
|
||||
.ok_or_else(|| "没有可用的 Chrome 会话".to_string())
|
||||
}
|
||||
|
||||
pub async fn resolve_profile_session_global(
|
||||
profile_key: Option<String>,
|
||||
) -> Result<ChromeProfileSessionInfo, String> {
|
||||
select_profile_session(shared_chrome_profile_manager(), profile_key).await
|
||||
}
|
||||
|
||||
async fn ensure_cdp_runtime_session(
|
||||
@@ -2816,12 +3152,17 @@ fn spawn_chrome_with_profile(
|
||||
cmd.arg(format!("--lang={language}"));
|
||||
}
|
||||
|
||||
// 如果提供了扩展目录,添加 --load-extension 参数
|
||||
if let Some(ext_dir) = extension_dir {
|
||||
cmd.arg(format!("--load-extension={}", ext_dir.to_string_lossy()));
|
||||
if launch_options.headless {
|
||||
cmd.arg("--headless=new").arg("--disable-gpu");
|
||||
}
|
||||
|
||||
if new_window {
|
||||
if !launch_options.headless {
|
||||
if let Some(ext_dir) = extension_dir {
|
||||
cmd.arg(format!("--load-extension={}", ext_dir.to_string_lossy()));
|
||||
}
|
||||
}
|
||||
|
||||
if new_window && !launch_options.headless {
|
||||
cmd.arg("--new-window");
|
||||
}
|
||||
cmd.arg(url);
|
||||
@@ -3269,6 +3610,31 @@ pub async fn focus_webview_panel(app: AppHandle, panel_id: String) -> Result<boo
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::services::browser_profile_service::sanitize_browser_profile_key;
|
||||
use rusqlite::Connection;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
fn setup_db() -> DbConnection {
|
||||
let conn = Connection::open_in_memory().unwrap();
|
||||
conn.execute_batch(
|
||||
"CREATE TABLE browser_profiles (
|
||||
id TEXT PRIMARY KEY,
|
||||
profile_key TEXT NOT NULL UNIQUE,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT,
|
||||
site_scope TEXT,
|
||||
launch_url TEXT,
|
||||
transport_kind TEXT NOT NULL DEFAULT 'managed_cdp',
|
||||
profile_dir TEXT NOT NULL,
|
||||
managed_profile_dir TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
last_used_at TEXT,
|
||||
archived_at TEXT
|
||||
);",
|
||||
)
|
||||
.unwrap();
|
||||
Arc::new(Mutex::new(conn))
|
||||
}
|
||||
|
||||
#[cfg(any(target_os = "macos", target_os = "ios"))]
|
||||
#[test]
|
||||
@@ -3341,6 +3707,37 @@ mod tests {
|
||||
assert!(!normalized.auto_fallback);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_browser_profile_transport_kind_should_resolve_existing_session_profile() {
|
||||
let db = setup_db();
|
||||
{
|
||||
let conn = lock_db(&db).unwrap();
|
||||
conn.execute(
|
||||
"INSERT INTO browser_profiles (
|
||||
id, profile_key, name, description, site_scope, launch_url, transport_kind,
|
||||
profile_dir, managed_profile_dir, created_at, updated_at, last_used_at, archived_at
|
||||
) VALUES (?1, ?2, ?3, NULL, NULL, ?4, 'existing_session', '', NULL, ?5, ?5, NULL, NULL)",
|
||||
(
|
||||
"profile-attach",
|
||||
"xhs_attach",
|
||||
"小红书附着",
|
||||
"https://www.xiaohongshu.com/",
|
||||
"2026-03-30T00:00:00Z",
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
let requested_backend: Option<BrowserBackendType> = None;
|
||||
let transport =
|
||||
load_browser_profile_transport_kind(&db, Some("xhs_attach"), &requested_backend);
|
||||
|
||||
assert_eq!(
|
||||
transport,
|
||||
Some(BrowserProfileTransportKind::ExistingSession)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_action_name_should_strip_aster_prefix() {
|
||||
let action =
|
||||
@@ -3348,11 +3745,63 @@ mod tests {
|
||||
assert_eq!(action, "read_page");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_browser_action_request_should_map_computer_scroll() {
|
||||
let (action, args) = normalize_browser_action_request(
|
||||
"computer",
|
||||
json!({
|
||||
"action": "scroll",
|
||||
"amount": 2000,
|
||||
}),
|
||||
)
|
||||
.expect("computer scroll should normalize");
|
||||
|
||||
assert_eq!(action, "scroll_page");
|
||||
assert_eq!(args.get("direction"), Some(&json!("down")));
|
||||
assert_eq!(args.get("amount"), Some(&json!(2000)));
|
||||
assert_eq!(args.get("text"), Some(&json!("down:2000")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_browser_action_request_should_convert_scroll_script() {
|
||||
let (action, args) = normalize_browser_action_request(
|
||||
"javascript_tool",
|
||||
json!({
|
||||
"code": "// 滚动页面以加载更多内容\nwindow.scrollBy(0, 2000);\n// 等待一会儿",
|
||||
}),
|
||||
)
|
||||
.expect("scroll script should normalize");
|
||||
|
||||
assert_eq!(action, "scroll_page");
|
||||
assert_eq!(args.get("direction"), Some(&json!("down")));
|
||||
assert_eq!(args.get("amount"), Some(&json!(2000)));
|
||||
assert_eq!(args.get("text"), Some(&json!("down:2000")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_browser_action_request_should_keep_generic_javascript() {
|
||||
let (action, args) = normalize_browser_action_request(
|
||||
"javascript_tool",
|
||||
json!({
|
||||
"script": "document.title",
|
||||
}),
|
||||
)
|
||||
.expect("generic javascript should normalize");
|
||||
|
||||
assert_eq!(action, "javascript");
|
||||
assert_eq!(args.get("expression"), Some(&json!("document.title")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extension_backend_capabilities_should_include_list_tabs() {
|
||||
assert!(extension_backend_capabilities().contains(&"list_tabs".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cdp_backend_capabilities_should_include_find() {
|
||||
assert!(cdp_backend_capabilities().contains(&"find".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bridge_result_to_value_should_include_data_payload() {
|
||||
let value = bridge_result_to_value(ChromeBridgeCommandResult {
|
||||
@@ -3390,10 +3839,25 @@ mod tests {
|
||||
#[test]
|
||||
fn build_backend_candidates_should_prefer_forced_backend() {
|
||||
let policy = BrowserBackendPolicy::default();
|
||||
let candidates = build_backend_candidates(Some(BrowserBackendType::CdpDirect), &policy);
|
||||
let candidates = build_backend_candidates(
|
||||
Some(BrowserBackendType::CdpDirect),
|
||||
&policy,
|
||||
Some(BrowserProfileTransportKind::ManagedCdp),
|
||||
);
|
||||
assert_eq!(candidates, vec![BrowserBackendType::CdpDirect]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_backend_candidates_should_pin_existing_session_to_extension_bridge() {
|
||||
let policy = BrowserBackendPolicy::default();
|
||||
let candidates = build_backend_candidates(
|
||||
Some(BrowserBackendType::CdpDirect),
|
||||
&policy,
|
||||
Some(BrowserProfileTransportKind::ExistingSession),
|
||||
);
|
||||
assert_eq!(candidates, vec![BrowserBackendType::LimeExtensionBridge]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn browser_runtime_audit_should_store_launch_metadata() {
|
||||
BROWSER_RUNTIME_AUDIT_LOGS.lock().await.clear();
|
||||
|
||||
@@ -246,7 +246,7 @@ pub(super) async fn try_handle(
|
||||
let aster_state = app_handle.state::<crate::agent::AsterAgentState>();
|
||||
let db = app_handle.state::<crate::database::DbConnection>();
|
||||
|
||||
crate::commands::aster_agent_cmd::agent_runtime_delete_session(
|
||||
crate::commands::aster_agent_cmd::action_runtime::agent_runtime_delete_session(
|
||||
app_handle.clone(),
|
||||
aster_state,
|
||||
db,
|
||||
@@ -302,7 +302,7 @@ pub(super) async fn try_handle(
|
||||
>(args)?;
|
||||
let aster_state = app_handle.state::<crate::agent::AsterAgentState>();
|
||||
|
||||
crate::commands::aster_agent_cmd::agent_runtime_respond_action(
|
||||
crate::commands::aster_agent_cmd::action_runtime::agent_runtime_respond_action(
|
||||
app_handle.clone(),
|
||||
aster_state,
|
||||
request,
|
||||
|
||||
@@ -15,8 +15,10 @@ pub(super) async fn try_handle(
|
||||
serde_json::to_value(manager.config())?
|
||||
}
|
||||
"save_config" => {
|
||||
let config: lime_core::config::Config =
|
||||
let mut config: lime_core::config::Config =
|
||||
serde_json::from_value(args.cloned().unwrap_or_default())?;
|
||||
config.normalize_local_server_surface();
|
||||
config.normalize_workspace_preferences();
|
||||
lime_core::config::save_config(&config)?;
|
||||
crate::services::environment_service::apply_configured_environment(&config).await;
|
||||
serde_json::json!({ "success": true })
|
||||
@@ -38,10 +40,6 @@ pub(super) async fn try_handle(
|
||||
let providers = { state.server.read().await.config.endpoint_providers.clone() };
|
||||
serde_json::to_value(providers)?
|
||||
}
|
||||
"get_server_status" => {
|
||||
let status = { state.server.read().await.status() };
|
||||
serde_json::to_value(status)?
|
||||
}
|
||||
"get_server_diagnostics" => {
|
||||
let (status, capability_routing, response_cache, request_dedup, idempotency) = {
|
||||
let server = state.server.read().await;
|
||||
|
||||
@@ -50,8 +50,9 @@ pub(super) async fn try_handle(
|
||||
}
|
||||
"browser_execute_action" => {
|
||||
let request: crate::commands::webview_cmd::BrowserActionRequest = parse_request(args)?;
|
||||
let db = get_db(state)?.clone();
|
||||
serde_json::to_value(
|
||||
crate::commands::webview_cmd::browser_execute_action_global(request).await?,
|
||||
crate::commands::webview_cmd::browser_execute_action_global(db, request).await?,
|
||||
)?
|
||||
}
|
||||
"get_browser_action_audit_logs" => {
|
||||
|
||||
@@ -11,45 +11,6 @@ pub(super) async fn try_handle(
|
||||
args: Option<&JsonValue>,
|
||||
) -> Result<Option<JsonValue>, DynError> {
|
||||
let result = match cmd {
|
||||
"get_network_info" => serde_json::to_value(
|
||||
crate::commands::network_cmd::get_network_info()
|
||||
.map_err(|e| format!("获取网络信息失败: {e}"))?,
|
||||
)?,
|
||||
"test_api" => {
|
||||
let args = args.cloned().ok_or("缺少参数")?;
|
||||
let provider = args
|
||||
.get("provider")
|
||||
.and_then(|value| value.as_str())
|
||||
.ok_or("缺少 provider 参数")?;
|
||||
|
||||
let credential = if let Some(db) = &state.db {
|
||||
state
|
||||
.pool_service
|
||||
.select_credential(db, provider, None)
|
||||
.ok()
|
||||
.flatten()
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
match credential {
|
||||
Some(cred) => {
|
||||
state.logs.write().await.add(
|
||||
"info",
|
||||
&format!("[DevBridge] 测试 API 使用凭证: {:?}", cred.name),
|
||||
);
|
||||
serde_json::json!({
|
||||
"success": true,
|
||||
"credential_name": cred.name,
|
||||
"provider_type": cred.provider_type,
|
||||
})
|
||||
}
|
||||
None => serde_json::json!({
|
||||
"success": false,
|
||||
"error": "未找到可用凭证"
|
||||
}),
|
||||
}
|
||||
}
|
||||
"execution_run_list" => {
|
||||
let args = args_or_default(args);
|
||||
let limit = args
|
||||
|
||||
@@ -185,18 +185,6 @@ pub fn init() -> ProfilingGuard {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn should_open_webview_devtools() -> bool {
|
||||
matches!(
|
||||
env::var("LIME_OPEN_WEBVIEW_DEVTOOLS")
|
||||
.ok()
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.map(str::to_ascii_lowercase)
|
||||
.as_deref(),
|
||||
Some("1") | Some("true") | Some("yes") | Some("on")
|
||||
)
|
||||
}
|
||||
|
||||
fn try_init(config: &ProfilingConfig) -> Result<ProfilingGuard, String> {
|
||||
let filter_layer = EnvFilter::try_from_default_env()
|
||||
.or_else(|_| EnvFilter::try_new("info"))
|
||||
|
||||
@@ -1089,8 +1089,17 @@ fn truncate_text(value: &str, max_chars: usize) -> String {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::database::schema::create_tables;
|
||||
use rusqlite::Connection;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use tempfile::tempdir;
|
||||
|
||||
fn setup_test_db() -> DbConnection {
|
||||
let conn = Connection::open_in_memory().expect("创建内存数据库失败");
|
||||
create_tables(&conn).expect("初始化表结构失败");
|
||||
Arc::new(Mutex::new(conn))
|
||||
}
|
||||
|
||||
fn build_params() -> ArtifactDocumentPersistParams {
|
||||
let workspace_root = tempdir().expect("tempdir").keep();
|
||||
ArtifactDocumentPersistParams {
|
||||
@@ -1290,10 +1299,9 @@ mod tests {
|
||||
#[test]
|
||||
fn sync_persisted_artifact_document_to_content_should_update_body_and_metadata() {
|
||||
use crate::content::{ContentCreateRequest, ContentManager};
|
||||
use crate::database::init_database;
|
||||
use crate::workspace::{WorkspaceManager, WorkspaceType};
|
||||
|
||||
let db = init_database().expect("db should init");
|
||||
let db = setup_test_db();
|
||||
let workspace_root = tempdir().expect("tempdir").keep();
|
||||
let workspace = WorkspaceManager::new(db.clone())
|
||||
.create_with_type(
|
||||
|
||||
@@ -8,6 +8,7 @@ use serde_json::Value;
|
||||
use std::collections::HashMap;
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
#[cfg(target_os = "macos")]
|
||||
use std::process::Command;
|
||||
use tauri::{AppHandle, Manager};
|
||||
|
||||
@@ -27,8 +28,63 @@ const SYSTEM_CONNECTOR_DEFINITIONS: [(&str, &str, &str); 5] = [
|
||||
("contacts", "通讯录", "搜索、读取和创建联系人。"),
|
||||
];
|
||||
|
||||
const BROWSER_ACTION_CAPABILITY_DEFINITIONS: [(&str, &str, &str, &str); 20] = [
|
||||
(
|
||||
"tabs_context_mcp",
|
||||
"标签页概览",
|
||||
"读取当前已附着标签页的上下文摘要。",
|
||||
"read",
|
||||
),
|
||||
("list_tabs", "列出标签页", "列出当前浏览器标签页。", "read"),
|
||||
(
|
||||
"tabs_create_mcp",
|
||||
"新建标签页",
|
||||
"创建新的浏览器标签页。",
|
||||
"write",
|
||||
),
|
||||
("read_page", "页面快照", "抓取当前页面快照。", "read"),
|
||||
(
|
||||
"get_page_text",
|
||||
"页面文本",
|
||||
"读取当前页面文本内容。",
|
||||
"read",
|
||||
),
|
||||
(
|
||||
"get_page_info",
|
||||
"页面信息",
|
||||
"读取页面标题、URL 与快照信息。",
|
||||
"read",
|
||||
),
|
||||
("find", "页面内查找", "在当前页面中查找文本。", "read"),
|
||||
(
|
||||
"read_console_messages",
|
||||
"控制台消息",
|
||||
"读取浏览器控制台消息。",
|
||||
"read",
|
||||
),
|
||||
(
|
||||
"read_network_requests",
|
||||
"网络请求",
|
||||
"读取页面网络请求记录。",
|
||||
"read",
|
||||
),
|
||||
("navigate", "导航", "导航到目标地址。", "write"),
|
||||
("open_url", "打开链接", "直接打开目标链接。", "write"),
|
||||
("click", "点击元素", "点击页面元素。", "write"),
|
||||
("type", "输入文本", "向当前页面输入文本。", "write"),
|
||||
("form_input", "表单输入", "按字段填写页面表单。", "write"),
|
||||
("switch_tab", "切换标签页", "切换当前操作标签页。", "write"),
|
||||
("scroll_page", "滚动页面", "滚动当前页面或容器。", "write"),
|
||||
("refresh_page", "刷新页面", "刷新当前页面。", "write"),
|
||||
("go_back", "返回上一页", "返回上一页。", "write"),
|
||||
("go_forward", "前进到下一页", "前进到下一页。", "write"),
|
||||
("javascript", "执行脚本", "在当前页面执行脚本。", "write"),
|
||||
];
|
||||
|
||||
const AUTH_STATUS_NOT_DETERMINED: &str = "not_determined";
|
||||
#[cfg(target_os = "macos")]
|
||||
const AUTH_STATUS_AUTHORIZED: &str = "authorized";
|
||||
#[cfg(target_os = "macos")]
|
||||
const AUTH_STATUS_DENIED: &str = "denied";
|
||||
const AUTH_STATUS_ERROR: &str = "error";
|
||||
const AUTH_STATUS_UNSUPPORTED: &str = "unsupported";
|
||||
@@ -80,12 +136,22 @@ pub struct SystemConnectorSnapshot {
|
||||
pub capabilities: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct BrowserActionCapabilitySnapshot {
|
||||
pub key: String,
|
||||
pub label: String,
|
||||
pub description: String,
|
||||
pub group: String,
|
||||
pub enabled: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct BrowserConnectorSettingsSnapshot {
|
||||
pub enabled: bool,
|
||||
pub install_root_dir: Option<String>,
|
||||
pub install_dir: Option<String>,
|
||||
pub system_connectors: Vec<SystemConnectorSnapshot>,
|
||||
pub browser_action_capabilities: Vec<BrowserActionCapabilitySnapshot>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
@@ -93,6 +159,8 @@ struct BrowserConnectorSettingsRecord {
|
||||
enabled: bool,
|
||||
install_root_dir: Option<String>,
|
||||
system_connectors: HashMap<String, StoredSystemConnectorState>,
|
||||
#[serde(default = "default_browser_action_capability_states")]
|
||||
browser_action_capabilities: HashMap<String, bool>,
|
||||
updated_at: String,
|
||||
}
|
||||
|
||||
@@ -122,6 +190,7 @@ impl Default for BrowserConnectorSettingsRecord {
|
||||
enabled: true,
|
||||
install_root_dir: None,
|
||||
system_connectors: default_system_connector_state_records(),
|
||||
browser_action_capabilities: default_browser_action_capability_states(),
|
||||
updated_at: Utc::now().to_rfc3339(),
|
||||
}
|
||||
}
|
||||
@@ -141,6 +210,13 @@ fn default_system_connector_state_records() -> HashMap<String, StoredSystemConne
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn default_browser_action_capability_states() -> HashMap<String, bool> {
|
||||
BROWSER_ACTION_CAPABILITY_DEFINITIONS
|
||||
.iter()
|
||||
.map(|(key, _, _, _)| ((*key).to_string(), true))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn default_connector_record(enabled: bool) -> SystemConnectorStateRecord {
|
||||
SystemConnectorStateRecord {
|
||||
enabled,
|
||||
@@ -163,6 +239,59 @@ fn normalize_connector_record(
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_browser_action_capability_key(raw: &str) -> String {
|
||||
match raw.trim().to_ascii_lowercase().as_str() {
|
||||
"scroll" => "scroll_page".to_string(),
|
||||
"javascript_tool" => "javascript".to_string(),
|
||||
other => other.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn browser_action_capability_definition(
|
||||
key: &str,
|
||||
) -> Option<(&'static str, &'static str, &'static str, &'static str)> {
|
||||
BROWSER_ACTION_CAPABILITY_DEFINITIONS
|
||||
.iter()
|
||||
.copied()
|
||||
.find(|(candidate, _, _, _)| *candidate == key)
|
||||
}
|
||||
|
||||
fn browser_action_capability_enabled_in_record(
|
||||
record: &BrowserConnectorSettingsRecord,
|
||||
action: &str,
|
||||
) -> bool {
|
||||
let normalized = normalize_browser_action_capability_key(action);
|
||||
match browser_action_capability_definition(&normalized) {
|
||||
Some((key, _, _, _)) => record
|
||||
.browser_action_capabilities
|
||||
.get(key)
|
||||
.copied()
|
||||
.unwrap_or(true),
|
||||
None => true,
|
||||
}
|
||||
}
|
||||
|
||||
fn browser_action_capability_snapshots(
|
||||
record: &BrowserConnectorSettingsRecord,
|
||||
) -> Vec<BrowserActionCapabilitySnapshot> {
|
||||
BROWSER_ACTION_CAPABILITY_DEFINITIONS
|
||||
.iter()
|
||||
.map(
|
||||
|(key, label, description, group)| BrowserActionCapabilitySnapshot {
|
||||
key: (*key).to_string(),
|
||||
label: (*label).to_string(),
|
||||
description: (*description).to_string(),
|
||||
group: (*group).to_string(),
|
||||
enabled: record
|
||||
.browser_action_capabilities
|
||||
.get(*key)
|
||||
.copied()
|
||||
.unwrap_or(true),
|
||||
},
|
||||
)
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn connector_capabilities(id: &str) -> Vec<String> {
|
||||
match id {
|
||||
"reminders" => vec![
|
||||
@@ -206,6 +335,7 @@ fn connector_probe_script(id: &str) -> Option<&'static str> {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn truncate_connector_error(input: &str) -> String {
|
||||
input
|
||||
.trim()
|
||||
@@ -305,6 +435,12 @@ fn load_settings_record() -> Result<BrowserConnectorSettingsRecord, String> {
|
||||
.entry(id)
|
||||
.or_insert(StoredSystemConnectorState::LegacyBool(enabled));
|
||||
}
|
||||
for (key, enabled) in default_browser_action_capability_states() {
|
||||
record
|
||||
.browser_action_capabilities
|
||||
.entry(key)
|
||||
.or_insert(enabled);
|
||||
}
|
||||
|
||||
Ok(record)
|
||||
}
|
||||
@@ -574,6 +710,7 @@ fn build_settings_snapshot(
|
||||
enabled: record.enabled,
|
||||
install_root_dir,
|
||||
install_dir,
|
||||
browser_action_capabilities: browser_action_capability_snapshots(record),
|
||||
system_connectors: if cfg!(target_os = "macos") {
|
||||
SYSTEM_CONNECTOR_DEFINITIONS
|
||||
.iter()
|
||||
@@ -599,6 +736,29 @@ fn build_settings_snapshot(
|
||||
}
|
||||
}
|
||||
|
||||
pub fn ensure_browser_action_capability_enabled(action: &str) -> Result<(), String> {
|
||||
let record = load_settings_record()?;
|
||||
let normalized = normalize_browser_action_capability_key(action);
|
||||
let Some((key, label, _, _)) = browser_action_capability_definition(&normalized) else {
|
||||
return Ok(());
|
||||
};
|
||||
if browser_action_capability_enabled_in_record(&record, key) {
|
||||
return Ok(());
|
||||
}
|
||||
Err(format!("浏览器动作已被禁用: {label}"))
|
||||
}
|
||||
|
||||
pub fn filter_enabled_browser_action_capabilities(
|
||||
capabilities: &[String],
|
||||
) -> Result<Vec<String>, String> {
|
||||
let record = load_settings_record()?;
|
||||
Ok(capabilities
|
||||
.iter()
|
||||
.filter(|capability| browser_action_capability_enabled_in_record(&record, capability))
|
||||
.cloned()
|
||||
.collect())
|
||||
}
|
||||
|
||||
pub fn update_browser_connector_install_root(
|
||||
install_root_dir: &str,
|
||||
) -> Result<BrowserConnectorSettingsSnapshot, String> {
|
||||
@@ -656,6 +816,24 @@ pub fn update_system_connector_enabled(
|
||||
Ok(build_settings_snapshot(&record))
|
||||
}
|
||||
|
||||
pub fn update_browser_action_capability_enabled(
|
||||
key: &str,
|
||||
enabled: bool,
|
||||
) -> Result<BrowserConnectorSettingsSnapshot, String> {
|
||||
let normalized = normalize_browser_action_capability_key(key);
|
||||
let Some((definition_key, _, _, _)) = browser_action_capability_definition(&normalized) else {
|
||||
return Err(format!("未知的浏览器动作能力: {key}"));
|
||||
};
|
||||
|
||||
let mut record = load_settings_record()?;
|
||||
record
|
||||
.browser_action_capabilities
|
||||
.insert(definition_key.to_string(), enabled);
|
||||
record.updated_at = Utc::now().to_rfc3339();
|
||||
save_settings_record(&record)?;
|
||||
Ok(build_settings_snapshot(&record))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -749,4 +927,35 @@ mod tests {
|
||||
|
||||
assert!(snapshot.system_connectors.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn settings_snapshot_should_include_browser_action_capabilities() {
|
||||
let record = BrowserConnectorSettingsRecord::default();
|
||||
let snapshot = build_settings_snapshot(&record);
|
||||
|
||||
assert!(!snapshot.browser_action_capabilities.is_empty());
|
||||
assert!(snapshot
|
||||
.browser_action_capabilities
|
||||
.iter()
|
||||
.any(|capability| capability.key == "find" && capability.enabled));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filter_enabled_browser_action_capabilities_should_hide_disabled_actions() {
|
||||
let mut record = BrowserConnectorSettingsRecord::default();
|
||||
record
|
||||
.browser_action_capabilities
|
||||
.insert("find".to_string(), false);
|
||||
|
||||
let filtered = vec![
|
||||
"read_page".to_string(),
|
||||
"find".to_string(),
|
||||
"click".to_string(),
|
||||
]
|
||||
.into_iter()
|
||||
.filter(|capability| browser_action_capability_enabled_in_record(&record, capability))
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
assert_eq!(filtered, vec!["read_page".to_string(), "click".to_string()]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -99,6 +99,14 @@ pub fn get_browser_profile(
|
||||
BrowserProfileDao::get_by_id(conn, id).map_err(|error| format!("读取浏览器资料失败: {error}"))
|
||||
}
|
||||
|
||||
pub fn get_browser_profile_by_key(
|
||||
conn: &Connection,
|
||||
profile_key: &str,
|
||||
) -> Result<Option<BrowserProfileRecord>, String> {
|
||||
BrowserProfileDao::get_by_profile_key(conn, profile_key)
|
||||
.map_err(|error| format!("读取浏览器资料失败: {error}"))
|
||||
}
|
||||
|
||||
pub fn save_browser_profile(
|
||||
conn: &Connection,
|
||||
input: SaveBrowserProfileInput,
|
||||
|
||||
@@ -110,8 +110,10 @@ struct SiteAdapterManifestEntry {
|
||||
#[serde(default, alias = "authHint")]
|
||||
auth_hint: Option<String>,
|
||||
entry: SiteAdapterEntryManifest,
|
||||
#[serde(alias = "scriptFile")]
|
||||
#[serde(default, alias = "scriptFile")]
|
||||
script_file: String,
|
||||
#[serde(default)]
|
||||
script: Option<String>,
|
||||
#[serde(default, alias = "sourceVersion")]
|
||||
source_version: Option<String>,
|
||||
}
|
||||
@@ -435,13 +437,7 @@ fn manifest_entry_to_spec(
|
||||
dir: Option<&Path>,
|
||||
source_kind: SiteAdapterSourceKind,
|
||||
) -> Result<SiteAdapterSpec, String> {
|
||||
let script = if let Some(base_dir) = dir {
|
||||
let script_path = base_dir.join(&entry.script_file);
|
||||
fs::read_to_string(&script_path)
|
||||
.map_err(|error| format!("读取站点适配器脚本失败 {}: {error}", script_path.display()))?
|
||||
} else {
|
||||
load_embedded_bundled_script(&entry.script_file)?.to_string()
|
||||
};
|
||||
let script = resolve_manifest_entry_script(&entry, dir, source_kind)?;
|
||||
|
||||
Ok(SiteAdapterSpec {
|
||||
name: entry.name,
|
||||
@@ -469,6 +465,83 @@ fn manifest_entry_to_spec(
|
||||
})
|
||||
}
|
||||
|
||||
fn resolve_manifest_entry_script(
|
||||
entry: &SiteAdapterManifestEntry,
|
||||
dir: Option<&Path>,
|
||||
source_kind: SiteAdapterSourceKind,
|
||||
) -> Result<String, String> {
|
||||
if let Some(base_dir) = dir {
|
||||
if let Some(script_file) = normalize_optional_text(Some(entry.script_file.clone())) {
|
||||
let script_path = base_dir.join(&script_file);
|
||||
match fs::read_to_string(&script_path) {
|
||||
Ok(content) => return Ok(content),
|
||||
Err(error) if should_allow_bundled_script_fallback(source_kind) => {
|
||||
if let Some(script) = try_load_bundled_fallback_script(&entry.name)? {
|
||||
return Ok(script);
|
||||
}
|
||||
return Err(format!(
|
||||
"读取站点适配器脚本失败 {}: {error}",
|
||||
script_path.display()
|
||||
));
|
||||
}
|
||||
Err(error) => {
|
||||
return Err(format!(
|
||||
"读取站点适配器脚本失败 {}: {error}",
|
||||
script_path.display()
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(script) = entry
|
||||
.script
|
||||
.as_ref()
|
||||
.and_then(|value| normalize_optional_text(Some(value.clone())))
|
||||
{
|
||||
return Ok(script);
|
||||
}
|
||||
|
||||
if should_allow_bundled_script_fallback(source_kind) {
|
||||
if let Some(script) = try_load_bundled_fallback_script(&entry.name)? {
|
||||
return Ok(script);
|
||||
}
|
||||
}
|
||||
|
||||
return Err(format!("站点适配器 {} 缺少 script", entry.name));
|
||||
}
|
||||
|
||||
if let Some(script) = entry
|
||||
.script
|
||||
.as_ref()
|
||||
.and_then(|value| normalize_optional_text(Some(value.clone())))
|
||||
{
|
||||
return Ok(script);
|
||||
}
|
||||
|
||||
if let Some(script_file) = normalize_optional_text(Some(entry.script_file.clone())) {
|
||||
return load_embedded_bundled_script(&script_file).map(|value| value.to_string());
|
||||
}
|
||||
|
||||
if let Some(script) = try_load_bundled_fallback_script(&entry.name)? {
|
||||
return Ok(script);
|
||||
}
|
||||
|
||||
Err(format!("站点适配器 {} 缺少 script", entry.name))
|
||||
}
|
||||
|
||||
fn should_allow_bundled_script_fallback(source_kind: SiteAdapterSourceKind) -> bool {
|
||||
!matches!(source_kind, SiteAdapterSourceKind::Bundled)
|
||||
}
|
||||
|
||||
fn try_load_bundled_fallback_script(adapter_name: &str) -> Result<Option<String>, String> {
|
||||
let Some(bundled_manifest) = find_embedded_bundled_manifest_entry(adapter_name)? else {
|
||||
return Ok(None);
|
||||
};
|
||||
Ok(Some(
|
||||
load_embedded_bundled_script(&bundled_manifest.script_file)?.to_string(),
|
||||
))
|
||||
}
|
||||
|
||||
fn normalize_manifest_entry(mut entry: SiteAdapterManifestEntry) -> SiteAdapterManifestEntry {
|
||||
if should_upgrade_legacy_github_search_entry(&entry) {
|
||||
entry.entry = SiteAdapterEntryManifest::UrlTemplate {
|
||||
@@ -587,6 +660,7 @@ fn write_server_synced_catalog_to_dir(
|
||||
auth_hint: normalize_optional_text(entry.auth_hint),
|
||||
entry: entry.entry,
|
||||
script_file,
|
||||
script: None,
|
||||
source_version: normalize_optional_text(entry.source_version),
|
||||
}));
|
||||
}
|
||||
@@ -1344,6 +1418,119 @@ mod tests {
|
||||
assert_eq!(adapters[0].source_kind, SiteAdapterSourceKind::ServerSynced);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_load_legacy_server_synced_catalog_without_script_file_by_falling_back_to_bundled() {
|
||||
let temp_dir = tempdir().expect("temp dir should exist");
|
||||
fs::write(
|
||||
temp_dir.path().join("index.json"),
|
||||
serde_json::json!({
|
||||
"registry_version": 1,
|
||||
"catalog_version": "tenant-sync-legacy",
|
||||
"adapters": [
|
||||
{
|
||||
"name": "github/search",
|
||||
"domain": "github.com",
|
||||
"description": "legacy server synced github search",
|
||||
"read_only": true,
|
||||
"capabilities": ["search"],
|
||||
"args": [],
|
||||
"example": "github/search {\"query\":\"lime\"}",
|
||||
"entry": {
|
||||
"kind": "fixed_url",
|
||||
"url": "https://github.com/search"
|
||||
}
|
||||
}
|
||||
]
|
||||
})
|
||||
.to_string(),
|
||||
)
|
||||
.expect("index.json should write");
|
||||
|
||||
let adapters =
|
||||
load_site_adapters_from_dir(temp_dir.path(), SiteAdapterSourceKind::ServerSynced)
|
||||
.expect("legacy adapters should load with bundled fallback");
|
||||
assert_eq!(adapters.len(), 1);
|
||||
assert_eq!(adapters[0].name, "github/search");
|
||||
assert_eq!(adapters[0].source_kind, SiteAdapterSourceKind::ServerSynced);
|
||||
assert!(adapters[0].script.contains("a.v-align-middle"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_load_legacy_server_synced_catalog_inline_script_without_script_file() {
|
||||
let temp_dir = tempdir().expect("temp dir should exist");
|
||||
fs::write(
|
||||
temp_dir.path().join("index.json"),
|
||||
serde_json::json!({
|
||||
"registry_version": 1,
|
||||
"catalog_version": "tenant-sync-inline",
|
||||
"adapters": [
|
||||
{
|
||||
"name": "github/search",
|
||||
"domain": "github.com",
|
||||
"description": "legacy inline github search",
|
||||
"read_only": true,
|
||||
"capabilities": ["search"],
|
||||
"args": [],
|
||||
"example": "github/search {\"query\":\"lime\"}",
|
||||
"entry": {
|
||||
"kind": "fixed_url",
|
||||
"url": "https://github.com/search"
|
||||
},
|
||||
"script": "async () => ({ items: [{ title: \"inline\" }] })"
|
||||
}
|
||||
]
|
||||
})
|
||||
.to_string(),
|
||||
)
|
||||
.expect("index.json should write");
|
||||
|
||||
let adapters =
|
||||
load_site_adapters_from_dir(temp_dir.path(), SiteAdapterSourceKind::ServerSynced)
|
||||
.expect("legacy adapters should load with inline script");
|
||||
assert_eq!(adapters.len(), 1);
|
||||
assert_eq!(
|
||||
adapters[0].script,
|
||||
"async () => ({ items: [{ title: \"inline\" }] })"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_fallback_to_bundled_when_server_synced_script_file_is_missing() {
|
||||
let temp_dir = tempdir().expect("temp dir should exist");
|
||||
fs::create_dir_all(temp_dir.path().join("scripts")).expect("scripts dir should exist");
|
||||
fs::write(
|
||||
temp_dir.path().join("index.json"),
|
||||
serde_json::json!({
|
||||
"registry_version": 1,
|
||||
"catalog_version": "tenant-sync-missing-file",
|
||||
"adapters": [
|
||||
{
|
||||
"name": "github/search",
|
||||
"domain": "github.com",
|
||||
"description": "missing file github search",
|
||||
"read_only": true,
|
||||
"capabilities": ["search"],
|
||||
"args": [],
|
||||
"example": "github/search {\"query\":\"lime\"}",
|
||||
"entry": {
|
||||
"kind": "fixed_url",
|
||||
"url": "https://github.com/search"
|
||||
},
|
||||
"script_file": "scripts/missing.js"
|
||||
}
|
||||
]
|
||||
})
|
||||
.to_string(),
|
||||
)
|
||||
.expect("index.json should write");
|
||||
|
||||
let adapters =
|
||||
load_site_adapters_from_dir(temp_dir.path(), SiteAdapterSourceKind::ServerSynced)
|
||||
.expect("legacy adapters should load with bundled fallback");
|
||||
assert_eq!(adapters.len(), 1);
|
||||
assert!(adapters[0].script.contains("a.v-align-middle"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_clear_server_synced_catalog_cache() {
|
||||
let temp_dir = tempdir().expect("temp dir should exist");
|
||||
|
||||
@@ -737,20 +737,6 @@ pub async fn run_site_adapter(
|
||||
);
|
||||
}
|
||||
|
||||
let wrapped_script = match build_wrapped_adapter_script(&spec.script, &args) {
|
||||
Ok(value) => value,
|
||||
Err(error) => {
|
||||
return build_error_result(
|
||||
&spec,
|
||||
profile_key,
|
||||
None,
|
||||
None,
|
||||
entry_url,
|
||||
"internal_error",
|
||||
&format!("构造适配器脚本失败: {error}"),
|
||||
);
|
||||
}
|
||||
};
|
||||
let timeout_ms = normalize_timeout_ms(request.timeout_ms);
|
||||
let resolved_target_id = attached_session_readiness
|
||||
.and_then(|result| result.target_id)
|
||||
@@ -764,11 +750,25 @@ pub async fn run_site_adapter(
|
||||
resolved_target_id,
|
||||
entry_url,
|
||||
timeout_ms,
|
||||
wrapped_script,
|
||||
args,
|
||||
)
|
||||
.await
|
||||
}
|
||||
SiteAdapterTransportRoute::ManagedCdp => {
|
||||
let wrapped_script = match build_wrapped_adapter_script(&spec.script, &args) {
|
||||
Ok(value) => value,
|
||||
Err(error) => {
|
||||
return build_error_result(
|
||||
&spec,
|
||||
profile_key,
|
||||
None,
|
||||
None,
|
||||
entry_url,
|
||||
"internal_error",
|
||||
&format!("构造适配器脚本失败: {error}"),
|
||||
);
|
||||
}
|
||||
};
|
||||
run_managed_cdp_adapter(
|
||||
db,
|
||||
&spec,
|
||||
@@ -1688,7 +1688,7 @@ async fn run_existing_session_adapter(
|
||||
target_id: Option<String>,
|
||||
entry_url: String,
|
||||
timeout_ms: u64,
|
||||
wrapped_script: String,
|
||||
args: Map<String, Value>,
|
||||
) -> SiteAdapterRunResult {
|
||||
let selected_target = if let Some(explicit_target_id) = target_id
|
||||
.clone()
|
||||
@@ -1785,7 +1785,8 @@ async fn run_existing_session_adapter(
|
||||
text: None,
|
||||
url: None,
|
||||
payload: Some(json!({
|
||||
"script": wrapped_script,
|
||||
"adapter_name": spec.name,
|
||||
"args": Value::Object(args),
|
||||
})),
|
||||
wait_for_page_info: false,
|
||||
timeout_ms: Some(normalize_adapter_evaluate_timeout_ms(timeout_ms)),
|
||||
|
||||
@@ -9,8 +9,8 @@ use lime_core::models::parse_skill_manifest_from_content;
|
||||
use lime_core::models::{
|
||||
BROADCAST_GENERATE_SKILL_DIRECTORY, COVER_GENERATE_SKILL_DIRECTORY,
|
||||
IMAGE_GENERATE_SKILL_DIRECTORY, LIBRARY_SKILL_DIRECTORY, MODAL_RESOURCE_SEARCH_SKILL_DIRECTORY,
|
||||
RESEARCH_SKILL_DIRECTORY, SOCIAL_POST_WITH_COVER_SKILL_DIRECTORY, TYPESETTING_SKILL_DIRECTORY,
|
||||
URL_PARSE_SKILL_DIRECTORY, VIDEO_GENERATE_SKILL_DIRECTORY,
|
||||
RESEARCH_SKILL_DIRECTORY, SITE_SEARCH_SKILL_DIRECTORY, SOCIAL_POST_WITH_COVER_SKILL_DIRECTORY,
|
||||
TYPESETTING_SKILL_DIRECTORY, URL_PARSE_SKILL_DIRECTORY, VIDEO_GENERATE_SKILL_DIRECTORY,
|
||||
};
|
||||
|
||||
const VIDEO_GENERATE_SKILL_CONTENT: &str =
|
||||
@@ -36,6 +36,15 @@ const URL_PARSE_SKILL_CONTENT: &str =
|
||||
const RESEARCH_SKILL_CONTENT: &str =
|
||||
include_str!("../../resources/default-skills/research/SKILL.md");
|
||||
|
||||
const SITE_SEARCH_SKILL_CONTENT: &str =
|
||||
include_str!("../../resources/default-skills/site_search/SKILL.md");
|
||||
|
||||
const SITE_SEARCH_ADAPTER_CATALOG_CONTENT: &str =
|
||||
include_str!("../../resources/default-skills/site_search/references/adapter-catalog.md");
|
||||
|
||||
const BUNDLED_SITE_ADAPTER_INDEX_CONTENT: &str =
|
||||
include_str!("../../resources/site-adapters/bundled/index.json");
|
||||
|
||||
const TYPESETTING_SKILL_CONTENT: &str =
|
||||
include_str!("../../resources/default-skills/typesetting/SKILL.md");
|
||||
|
||||
@@ -63,7 +72,12 @@ const SOCIAL_POST_WITH_COVER_EXTRA_FILES: &[BundledSkillFile] = &[BundledSkillFi
|
||||
content: SOCIAL_POST_WITH_COVER_WORKFLOW_CONTENT,
|
||||
}];
|
||||
|
||||
fn default_skills() -> [BundledSkillDefinition; 10] {
|
||||
const SITE_SEARCH_EXTRA_FILES: &[BundledSkillFile] = &[BundledSkillFile {
|
||||
relative_path: "references/adapter-catalog.md",
|
||||
content: SITE_SEARCH_ADAPTER_CATALOG_CONTENT,
|
||||
}];
|
||||
|
||||
fn default_skills() -> [BundledSkillDefinition; 11] {
|
||||
[
|
||||
BundledSkillDefinition {
|
||||
directory: VIDEO_GENERATE_SKILL_DIRECTORY,
|
||||
@@ -105,6 +119,11 @@ fn default_skills() -> [BundledSkillDefinition; 10] {
|
||||
skill_content: RESEARCH_SKILL_CONTENT,
|
||||
extra_files: &[],
|
||||
},
|
||||
BundledSkillDefinition {
|
||||
directory: SITE_SEARCH_SKILL_DIRECTORY,
|
||||
skill_content: SITE_SEARCH_SKILL_CONTENT,
|
||||
extra_files: SITE_SEARCH_EXTRA_FILES,
|
||||
},
|
||||
BundledSkillDefinition {
|
||||
directory: TYPESETTING_SKILL_DIRECTORY,
|
||||
skill_content: TYPESETTING_SKILL_CONTENT,
|
||||
@@ -301,6 +320,9 @@ mod tests {
|
||||
assert!(LIBRARY_SKILL_CONTENT.contains("name: library"));
|
||||
assert!(URL_PARSE_SKILL_CONTENT.contains("name: url_parse"));
|
||||
assert!(RESEARCH_SKILL_CONTENT.contains("name: research"));
|
||||
assert!(SITE_SEARCH_SKILL_CONTENT.contains("name: site_search"));
|
||||
assert!(SITE_SEARCH_ADAPTER_CATALOG_CONTENT.contains("`github/search`"));
|
||||
assert!(SITE_SEARCH_ADAPTER_CATALOG_CONTENT.contains("`zhihu/hot`"));
|
||||
assert!(TYPESETTING_SKILL_CONTENT.contains("name: typesetting"));
|
||||
}
|
||||
|
||||
@@ -318,4 +340,40 @@ mod tests {
|
||||
let workflow_content = fs::read_to_string(workflow_path).expect("read workflow");
|
||||
assert!(workflow_content.contains("\"cover\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_sync_extra_files_for_site_search_skill() {
|
||||
let temp = tempfile::tempdir().expect("create temp dir");
|
||||
let skills_root = skills_root_from_base(temp.path());
|
||||
ensure_default_local_skills_in_dir(&skills_root).expect("install");
|
||||
|
||||
let catalog_path = skills_root
|
||||
.join(SITE_SEARCH_SKILL_DIRECTORY)
|
||||
.join("references")
|
||||
.join("adapter-catalog.md");
|
||||
assert!(catalog_path.exists());
|
||||
let catalog_content = fs::read_to_string(catalog_path).expect("read catalog");
|
||||
assert!(catalog_content.contains("`github/search`"));
|
||||
assert!(catalog_content.contains("`yahoo-finance/quote`"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_cover_all_bundled_site_adapters_in_site_search_catalog() {
|
||||
let bundled_index =
|
||||
serde_json::from_str::<serde_json::Value>(BUNDLED_SITE_ADAPTER_INDEX_CONTENT)
|
||||
.expect("parse bundled site adapter index");
|
||||
let adapters = bundled_index["adapters"]
|
||||
.as_array()
|
||||
.expect("bundled site adapter index should contain adapters");
|
||||
|
||||
for adapter in adapters {
|
||||
let adapter_name = adapter["name"]
|
||||
.as_str()
|
||||
.expect("bundled site adapter should contain name");
|
||||
assert!(
|
||||
SITE_SEARCH_ADAPTER_CATALOG_CONTENT.contains(&format!("`{adapter_name}`")),
|
||||
"site_search adapter 目录缺少 bundled adapter: {adapter_name}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -94,7 +94,7 @@ async fn ensure_skill_agent_ready(
|
||||
tracing::info!("[execute_skill] Agent 初始化完成");
|
||||
}
|
||||
|
||||
ensure_browser_mcp_tools_registered(aster_state)
|
||||
ensure_browser_mcp_tools_registered(aster_state, db)
|
||||
.await
|
||||
.map_err(|error| {
|
||||
format_skill_error(
|
||||
|
||||
@@ -4,6 +4,5 @@
|
||||
//! 本模块保留兼容导出。
|
||||
|
||||
pub use lime_core::tray_format::{
|
||||
format_api_address, format_credential_status, format_current_model_status,
|
||||
format_request_count, format_server_status,
|
||||
format_credential_status, format_current_model_status, format_request_count,
|
||||
};
|
||||
|
||||
@@ -3,9 +3,9 @@
|
||||
//! 提供托盘图标和菜单的管理功能
|
||||
//!
|
||||
//! # Requirements
|
||||
//! - 1.1: 服务器运行且凭证健康时显示正常状态图标
|
||||
//! - 1.4: 应用启动时显示停止状态图标
|
||||
//! - 7.1, 7.2, 7.3: 状态变化时更新托盘
|
||||
//! - 1.1: 账号可用时显示正常状态图标
|
||||
//! - 1.4: 应用启动时显示初始状态图标
|
||||
//! - 7.1, 7.2: 状态变化时更新托盘
|
||||
|
||||
use super::events::handle_tray_icon_event;
|
||||
use super::menu::build_tray_menu;
|
||||
@@ -185,7 +185,7 @@ impl TrayIcons {
|
||||
///
|
||||
/// # Requirements
|
||||
/// - 1.1, 1.4: 托盘图标状态管理
|
||||
/// - 7.1, 7.2, 7.3: 状态同步和更新
|
||||
/// - 7.1, 7.2: 状态同步和更新
|
||||
pub struct TrayManager<R: Runtime> {
|
||||
/// Tauri 托盘图标句柄
|
||||
tray: TrayIcon<R>,
|
||||
@@ -203,7 +203,7 @@ impl<R: Runtime> TrayManager<R> {
|
||||
/// 初始化托盘图标,设置初始状态为 Stopped
|
||||
///
|
||||
/// # Requirements
|
||||
/// - 1.4: 应用启动时显示停止状态图标
|
||||
/// - 1.4: 应用启动时显示初始状态图标
|
||||
pub fn new(app: &AppHandle<R>) -> Result<Self, TrayError> {
|
||||
info!("初始化托盘管理器...");
|
||||
|
||||
@@ -263,8 +263,8 @@ impl<R: Runtime> TrayManager<R> {
|
||||
/// 更新内部状态并刷新图标和菜单
|
||||
///
|
||||
/// # Requirements
|
||||
/// - 7.1: API 服务器状态变化时更新托盘图标
|
||||
/// - 7.2: 凭证健康状态变化时更新托盘图标
|
||||
/// - 7.1: 账号状态变化时更新托盘图标
|
||||
/// - 7.2: 托盘菜单信息变化时更新菜单
|
||||
pub async fn update_state(&self, snapshot: TrayStateSnapshot) -> Result<(), TrayError> {
|
||||
let old_status = {
|
||||
let state = self.state.read().await;
|
||||
@@ -395,7 +395,7 @@ mod tests {
|
||||
let manager = SimpleTrayManager::new();
|
||||
let state = manager.get_state().await;
|
||||
assert_eq!(state.icon_status, TrayIconStatus::Stopped);
|
||||
assert!(!state.server_running);
|
||||
assert_eq!(state.available_credentials, 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -404,8 +404,6 @@ mod tests {
|
||||
|
||||
let new_state = TrayStateSnapshot {
|
||||
icon_status: TrayIconStatus::Running,
|
||||
server_running: true,
|
||||
server_address: "127.0.0.1:8080".to_string(),
|
||||
available_credentials: 3,
|
||||
total_credentials: 5,
|
||||
today_requests: 100,
|
||||
@@ -417,8 +415,6 @@ mod tests {
|
||||
|
||||
let state = manager.get_state().await;
|
||||
assert_eq!(state.icon_status, TrayIconStatus::Running);
|
||||
assert!(state.server_running);
|
||||
assert_eq!(state.server_address, "127.0.0.1:8080");
|
||||
assert_eq!(state.available_credentials, 3);
|
||||
assert_eq!(state.total_credentials, 5);
|
||||
assert_eq!(state.today_requests, 100);
|
||||
|
||||
+11
-105
@@ -2,10 +2,7 @@
|
||||
//!
|
||||
//! 定义菜单项 ID 和菜单构建函数
|
||||
|
||||
use super::format::{
|
||||
format_credential_status, format_current_model_status, format_request_count,
|
||||
format_server_status,
|
||||
};
|
||||
use super::format::{format_credential_status, format_current_model_status, format_request_count};
|
||||
use super::state::TrayStateSnapshot;
|
||||
use tauri::{
|
||||
menu::{CheckMenuItem, IsMenuItem, Menu, MenuItem, PredefinedMenuItem, Submenu},
|
||||
@@ -13,9 +10,7 @@ use tauri::{
|
||||
};
|
||||
|
||||
pub use lime_core::tray_menu_meta::menu_ids;
|
||||
pub use lime_core::tray_menu_meta::{
|
||||
build_quick_model_item_id, get_menu_item_ids, parse_server_address,
|
||||
};
|
||||
pub use lime_core::tray_menu_meta::{build_quick_model_item_id, get_menu_item_ids};
|
||||
|
||||
/// 托盘菜单构建错误
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
@@ -91,27 +86,23 @@ fn build_quick_model_submenu<R: Runtime>(
|
||||
/// 构建托盘菜单
|
||||
///
|
||||
/// 根据当前状态快照构建完整的托盘菜单,包含:
|
||||
/// - 状态信息(服务器状态、凭证状态、请求统计)
|
||||
/// - 服务器控制(启动/停止、刷新 Token、健康检查)
|
||||
/// - 快捷工具(打开主窗口、复制 API 地址、打开日志目录)
|
||||
/// - 状态信息(凭证状态、请求统计)
|
||||
/// - 运维动作(刷新 Token、健康检查)
|
||||
/// - 快捷工具(打开主窗口、打开日志目录)
|
||||
/// - 设置(开机自启)
|
||||
/// - 退出
|
||||
///
|
||||
/// # Requirements
|
||||
/// - 2.1: 右键点击托盘图标显示包含所有可用操作的托盘菜单
|
||||
/// - 2.2: 显示当前服务器状态,包括运行状态和端口号
|
||||
/// - 2.3: 显示凭证池状态,包括可用凭证数和总凭证数
|
||||
/// - 2.4: 显示今日请求次数
|
||||
/// - 3.1, 3.2, 3.3, 3.4: 服务器控制菜单项
|
||||
/// - 4.1, 4.2, 4.3, 4.4: 快捷工具菜单项
|
||||
/// - 2.2: 显示凭证池状态,包括可用凭证数和总凭证数
|
||||
/// - 2.3: 显示今日请求次数
|
||||
/// - 3.3, 3.4: 运行时维护菜单项
|
||||
/// - 4.1, 4.3, 4.4: 快捷工具菜单项
|
||||
/// - 5.1, 5.2: 开机自启设置
|
||||
pub fn build_tray_menu<R: Runtime>(
|
||||
app: &AppHandle<R>,
|
||||
state: &TrayStateSnapshot,
|
||||
) -> Result<Menu<R>, MenuBuildError> {
|
||||
// 解析服务器地址
|
||||
let (host, port) = parse_server_address(&state.server_address);
|
||||
|
||||
// === 当前模型信息 ===
|
||||
let current_model_text = format_current_model_status(
|
||||
&state.current_model_provider_label,
|
||||
@@ -134,17 +125,6 @@ pub fn build_tray_menu<R: Runtime>(
|
||||
let separator_0 = PredefinedMenuItem::separator(app)
|
||||
.map_err(|e| MenuBuildError::MenuItemError(e.to_string()))?;
|
||||
|
||||
// === 状态信息区域 ===
|
||||
let status_text = format_server_status(state.server_running, &host, port);
|
||||
let status_info = MenuItem::with_id(
|
||||
app,
|
||||
menu_ids::STATUS_INFO,
|
||||
&status_text,
|
||||
false,
|
||||
None::<&str>,
|
||||
)
|
||||
.map_err(|e| MenuBuildError::MenuItemError(e.to_string()))?;
|
||||
|
||||
let credential_text =
|
||||
format_credential_status(state.available_credentials, state.total_credentials);
|
||||
let credential_info = MenuItem::with_id(
|
||||
@@ -170,28 +150,7 @@ pub fn build_tray_menu<R: Runtime>(
|
||||
let separator_1 = PredefinedMenuItem::separator(app)
|
||||
.map_err(|e| MenuBuildError::MenuItemError(e.to_string()))?;
|
||||
|
||||
// === 服务器控制区域 ===
|
||||
// 启动服务器(服务器未运行时可用)
|
||||
let start_server = MenuItem::with_id(
|
||||
app,
|
||||
menu_ids::START_SERVER,
|
||||
"启动 Lime 网关",
|
||||
!state.server_running,
|
||||
None::<&str>,
|
||||
)
|
||||
.map_err(|e| MenuBuildError::MenuItemError(e.to_string()))?;
|
||||
|
||||
// 停止服务器(服务器运行时可用)
|
||||
let stop_server = MenuItem::with_id(
|
||||
app,
|
||||
menu_ids::STOP_SERVER,
|
||||
"停止 Lime 网关",
|
||||
state.server_running,
|
||||
None::<&str>,
|
||||
)
|
||||
.map_err(|e| MenuBuildError::MenuItemError(e.to_string()))?;
|
||||
|
||||
// 刷新所有 Token
|
||||
// === 运行时维护区域 ===
|
||||
let refresh_tokens = MenuItem::with_id(
|
||||
app,
|
||||
menu_ids::REFRESH_TOKENS,
|
||||
@@ -220,15 +179,6 @@ pub fn build_tray_menu<R: Runtime>(
|
||||
MenuItem::with_id(app, menu_ids::OPEN_WINDOW, "打开 Lime", true, None::<&str>)
|
||||
.map_err(|e| MenuBuildError::MenuItemError(e.to_string()))?;
|
||||
|
||||
let copy_api_address = MenuItem::with_id(
|
||||
app,
|
||||
menu_ids::COPY_API_ADDRESS,
|
||||
"复制网关地址",
|
||||
state.server_running,
|
||||
None::<&str>,
|
||||
)
|
||||
.map_err(|e| MenuBuildError::MenuItemError(e.to_string()))?;
|
||||
|
||||
let open_log_dir = MenuItem::with_id(
|
||||
app,
|
||||
menu_ids::OPEN_LOG_DIR,
|
||||
@@ -268,17 +218,13 @@ pub fn build_tray_menu<R: Runtime>(
|
||||
}
|
||||
items.extend([
|
||||
&separator_0 as &dyn IsMenuItem<R>,
|
||||
&status_info,
|
||||
&credential_info,
|
||||
&request_info,
|
||||
&separator_1,
|
||||
&start_server,
|
||||
&stop_server,
|
||||
&refresh_tokens,
|
||||
&health_check,
|
||||
&separator_2,
|
||||
&open_window,
|
||||
©_api_address,
|
||||
&open_log_dir,
|
||||
&separator_3,
|
||||
&auto_start,
|
||||
@@ -316,61 +262,26 @@ mod tests {
|
||||
let ids = menu_ids::all_required_ids();
|
||||
|
||||
// 验证所有预定义的菜单项 ID 都在列表中
|
||||
assert!(ids.contains(&menu_ids::STATUS_INFO), "应包含 STATUS_INFO");
|
||||
assert!(
|
||||
ids.contains(&menu_ids::CREDENTIAL_INFO),
|
||||
"应包含 CREDENTIAL_INFO"
|
||||
);
|
||||
assert!(ids.contains(&menu_ids::REQUEST_INFO), "应包含 REQUEST_INFO");
|
||||
assert!(ids.contains(&menu_ids::START_SERVER), "应包含 START_SERVER");
|
||||
assert!(ids.contains(&menu_ids::STOP_SERVER), "应包含 STOP_SERVER");
|
||||
assert!(
|
||||
ids.contains(&menu_ids::REFRESH_TOKENS),
|
||||
"应包含 REFRESH_TOKENS"
|
||||
);
|
||||
assert!(ids.contains(&menu_ids::HEALTH_CHECK), "应包含 HEALTH_CHECK");
|
||||
assert!(ids.contains(&menu_ids::OPEN_WINDOW), "应包含 OPEN_WINDOW");
|
||||
assert!(
|
||||
ids.contains(&menu_ids::COPY_API_ADDRESS),
|
||||
"应包含 COPY_API_ADDRESS"
|
||||
);
|
||||
assert!(ids.contains(&menu_ids::OPEN_LOG_DIR), "应包含 OPEN_LOG_DIR");
|
||||
assert!(ids.contains(&menu_ids::AUTO_START), "应包含 AUTO_START");
|
||||
assert!(ids.contains(&menu_ids::QUIT), "应包含 QUIT");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_server_address_with_port() {
|
||||
let (host, port) = parse_server_address("127.0.0.1:8080");
|
||||
assert_eq!(host, "127.0.0.1");
|
||||
assert_eq!(port, 8080);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_server_address_without_port() {
|
||||
let (host, port) = parse_server_address("localhost");
|
||||
assert_eq!(host, "localhost");
|
||||
assert_eq!(port, 8080);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_server_address_empty() {
|
||||
let (host, port) = parse_server_address("");
|
||||
assert_eq!(host, "127.0.0.1");
|
||||
assert_eq!(port, 8080);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_server_address_ipv6() {
|
||||
let (host, port) = parse_server_address("[::1]:9000");
|
||||
assert_eq!(host, "[::1]");
|
||||
assert_eq!(port, 9000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_menu_item_ids() {
|
||||
let ids = get_menu_item_ids();
|
||||
assert_eq!(ids.len(), 12, "应有 12 个必需的菜单项");
|
||||
assert_eq!(ids.len(), 8, "应有 8 个必需的菜单项");
|
||||
}
|
||||
|
||||
proptest! {
|
||||
@@ -380,7 +291,6 @@ mod tests {
|
||||
/// 验证对于任意托盘菜单构建,生成的菜单 SHALL 包含所有预定义的菜单项 ID
|
||||
#[test]
|
||||
fn prop_menu_ids_completeness(
|
||||
_server_running in any::<bool>(),
|
||||
_available in 0usize..100,
|
||||
_total in 0usize..100,
|
||||
_requests in 0u64..1000000,
|
||||
@@ -391,15 +301,11 @@ mod tests {
|
||||
|
||||
// 必须包含所有预定义的 ID
|
||||
let required = vec![
|
||||
menu_ids::STATUS_INFO,
|
||||
menu_ids::CREDENTIAL_INFO,
|
||||
menu_ids::REQUEST_INFO,
|
||||
menu_ids::START_SERVER,
|
||||
menu_ids::STOP_SERVER,
|
||||
menu_ids::REFRESH_TOKENS,
|
||||
menu_ids::HEALTH_CHECK,
|
||||
menu_ids::OPEN_WINDOW,
|
||||
menu_ids::COPY_API_ADDRESS,
|
||||
menu_ids::OPEN_LOG_DIR,
|
||||
menu_ids::AUTO_START,
|
||||
menu_ids::QUIT,
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
//! 处理托盘菜单项的点击事件
|
||||
//!
|
||||
//! # Requirements
|
||||
//! - 3.1, 3.2, 3.3, 3.4: 服务器控制事件处理
|
||||
//! - 4.1, 4.2, 4.3, 4.4: 快捷工具事件处理
|
||||
//! - 3.3, 3.4: 运行时维护事件处理
|
||||
//! - 4.1, 4.3, 4.4: 快捷工具事件处理
|
||||
//! - 5.1, 5.2: 设置切换事件处理
|
||||
|
||||
use super::menu::menu_ids;
|
||||
@@ -14,17 +14,10 @@ use tauri::{AppHandle, Emitter, Manager, Runtime};
|
||||
use tauri_plugin_autostart::ManagerExt;
|
||||
use tracing::{debug, error, info, warn};
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
use std::os::windows::process::CommandExt;
|
||||
|
||||
/// 菜单事件类型
|
||||
///
|
||||
/// 用于前端监听的事件名称
|
||||
pub mod menu_events {
|
||||
/// 启动服务器事件
|
||||
pub const START_SERVER: &str = "tray-start-server";
|
||||
/// 停止服务器事件
|
||||
pub const STOP_SERVER: &str = "tray-stop-server";
|
||||
/// 刷新所有 Token 事件
|
||||
pub const REFRESH_TOKENS: &str = "tray-refresh-tokens";
|
||||
/// 健康检查事件
|
||||
@@ -47,8 +40,8 @@ struct TrayModelSelectedPayload {
|
||||
/// 根据菜单项 ID 执行相应的操作
|
||||
///
|
||||
/// # Requirements
|
||||
/// - 3.1, 3.2, 3.3, 3.4: 服务器控制
|
||||
/// - 4.1, 4.2, 4.3, 4.4: 快捷工具
|
||||
/// - 3.3, 3.4: 运行时维护
|
||||
/// - 4.1, 4.3, 4.4: 快捷工具
|
||||
/// - 5.1, 5.2: 设置切换
|
||||
pub fn handle_menu_event<R: Runtime>(app: &AppHandle<R>, menu_id: &str) {
|
||||
debug!("处理托盘菜单事件: {}", menu_id);
|
||||
@@ -59,15 +52,12 @@ pub fn handle_menu_event<R: Runtime>(app: &AppHandle<R>, menu_id: &str) {
|
||||
}
|
||||
|
||||
match menu_id {
|
||||
// === 服务器控制 ===
|
||||
menu_ids::START_SERVER => handle_start_server(app),
|
||||
menu_ids::STOP_SERVER => handle_stop_server(app),
|
||||
// === 运行时维护 ===
|
||||
menu_ids::REFRESH_TOKENS => handle_refresh_tokens(app),
|
||||
menu_ids::HEALTH_CHECK => handle_health_check(app),
|
||||
|
||||
// === 快捷工具 ===
|
||||
menu_ids::OPEN_WINDOW => handle_open_window(app),
|
||||
menu_ids::COPY_API_ADDRESS => handle_copy_api_address(app),
|
||||
menu_ids::OPEN_LOG_DIR => handle_open_log_dir(app),
|
||||
menu_ids::QUIT => handle_quit(app),
|
||||
|
||||
@@ -75,10 +65,7 @@ pub fn handle_menu_event<R: Runtime>(app: &AppHandle<R>, menu_id: &str) {
|
||||
menu_ids::AUTO_START => handle_auto_start_toggle(app),
|
||||
|
||||
// 忽略信息类菜单项和分隔符
|
||||
menu_ids::CURRENT_MODEL_INFO
|
||||
| menu_ids::STATUS_INFO
|
||||
| menu_ids::CREDENTIAL_INFO
|
||||
| menu_ids::REQUEST_INFO => {
|
||||
menu_ids::CURRENT_MODEL_INFO | menu_ids::CREDENTIAL_INFO | menu_ids::REQUEST_INFO => {
|
||||
debug!("忽略信息类菜单项: {}", menu_id);
|
||||
}
|
||||
|
||||
@@ -104,34 +91,6 @@ fn handle_model_selected<R: Runtime>(app: &AppHandle<R>, provider_type: String,
|
||||
}
|
||||
}
|
||||
|
||||
/// 处理启动服务器事件
|
||||
///
|
||||
/// # Requirements
|
||||
/// - 3.1: WHEN API 服务器已停止且用户点击托盘菜单中的"启动服务器"
|
||||
/// THEN 系统托盘 SHALL 启动 API 服务器并更新托盘图标以反映运行状态
|
||||
fn handle_start_server<R: Runtime>(app: &AppHandle<R>) {
|
||||
info!("[托盘] 用户请求启动服务器");
|
||||
|
||||
// 发送事件到前端,由前端调用 start_server 命令
|
||||
if let Err(e) = app.emit(menu_events::START_SERVER, ()) {
|
||||
error!("[托盘] 发送启动服务器事件失败: {}", e);
|
||||
}
|
||||
}
|
||||
|
||||
/// 处理停止服务器事件
|
||||
///
|
||||
/// # Requirements
|
||||
/// - 3.2: WHEN API 服务器正在运行且用户点击托盘菜单中的"停止服务器"
|
||||
/// THEN 系统托盘 SHALL 停止 API 服务器并更新托盘图标以反映停止状态
|
||||
fn handle_stop_server<R: Runtime>(app: &AppHandle<R>) {
|
||||
info!("[托盘] 用户请求停止服务器");
|
||||
|
||||
// 发送事件到前端,由前端调用 stop_server 命令
|
||||
if let Err(e) = app.emit(menu_events::STOP_SERVER, ()) {
|
||||
error!("[托盘] 发送停止服务器事件失败: {}", e);
|
||||
}
|
||||
}
|
||||
|
||||
/// 处理刷新所有 Token 事件
|
||||
///
|
||||
/// # Requirements
|
||||
@@ -195,73 +154,6 @@ fn handle_open_window<R: Runtime>(app: &AppHandle<R>) {
|
||||
}
|
||||
}
|
||||
|
||||
/// 处理复制 API 地址事件
|
||||
///
|
||||
/// # Requirements
|
||||
/// - 4.2: WHEN 用户点击托盘菜单中的"复制 API 地址"
|
||||
/// THEN 系统托盘 SHALL 将当前 API 服务器地址复制到系统剪贴板
|
||||
fn handle_copy_api_address<R: Runtime>(app: &AppHandle<R>) {
|
||||
info!("[托盘] 用户请求复制 API 地址");
|
||||
|
||||
// 获取托盘状态以获取服务器地址
|
||||
let app_clone = app.clone();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
if let Some(tray_state) = app_clone.try_state::<crate::TrayManagerState<R>>() {
|
||||
let tray_guard = tray_state.0.read().await;
|
||||
if let Some(tray_manager) = tray_guard.as_ref() {
|
||||
let state = tray_manager.get_state().await;
|
||||
|
||||
if state.server_running && !state.server_address.is_empty() {
|
||||
let api_address = format!("http://{}", state.server_address);
|
||||
|
||||
// 使用剪贴板 API 复制地址
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
let _ = std::process::Command::new("pbcopy")
|
||||
.stdin(std::process::Stdio::piped())
|
||||
.spawn()
|
||||
.and_then(|mut child| {
|
||||
use std::io::Write;
|
||||
if let Some(stdin) = child.stdin.as_mut() {
|
||||
stdin.write_all(api_address.as_bytes())?;
|
||||
}
|
||||
child.wait()
|
||||
});
|
||||
info!("[托盘] API 地址已复制到剪贴板: {}", api_address);
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
let _ = std::process::Command::new("cmd")
|
||||
.args(["/C", &format!("echo {} | clip", api_address)])
|
||||
.creation_flags(0x08000000) // CREATE_NO_WINDOW
|
||||
.spawn();
|
||||
info!("[托盘] API 地址已复制到剪贴板: {}", api_address);
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
let _ = std::process::Command::new("xclip")
|
||||
.args(["-selection", "clipboard"])
|
||||
.stdin(std::process::Stdio::piped())
|
||||
.spawn()
|
||||
.and_then(|mut child| {
|
||||
use std::io::Write;
|
||||
if let Some(stdin) = child.stdin.as_mut() {
|
||||
stdin.write_all(api_address.as_bytes())?;
|
||||
}
|
||||
child.wait()
|
||||
});
|
||||
info!("[托盘] API 地址已复制到剪贴板: {}", api_address);
|
||||
}
|
||||
} else {
|
||||
warn!("[托盘] 服务器未运行,无法复制 API 地址");
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// 处理打开日志目录事件
|
||||
///
|
||||
/// # Requirements
|
||||
@@ -300,15 +192,10 @@ fn handle_open_log_dir<R: Runtime>(app: &AppHandle<R>) {
|
||||
///
|
||||
/// # Requirements
|
||||
/// - 4.4: WHEN 用户点击托盘菜单中的"退出"
|
||||
/// THEN 系统托盘 SHALL 优雅地停止 API 服务器并终止应用程序
|
||||
/// THEN 系统托盘 SHALL 终止应用程序
|
||||
fn handle_quit<R: Runtime>(app: &AppHandle<R>) {
|
||||
info!("[托盘] 用户请求退出应用");
|
||||
|
||||
// 先发送停止服务器事件
|
||||
if let Err(e) = app.emit(menu_events::STOP_SERVER, ()) {
|
||||
warn!("[托盘] 发送停止服务器事件失败: {}", e);
|
||||
}
|
||||
|
||||
// 退出应用
|
||||
app.exit(0);
|
||||
}
|
||||
@@ -365,8 +252,6 @@ mod tests {
|
||||
#[test]
|
||||
fn test_menu_events_constants() {
|
||||
// 验证事件常量不为空
|
||||
assert!(!menu_events::START_SERVER.is_empty());
|
||||
assert!(!menu_events::STOP_SERVER.is_empty());
|
||||
assert!(!menu_events::REFRESH_TOKENS.is_empty());
|
||||
assert!(!menu_events::HEALTH_CHECK.is_empty());
|
||||
assert!(!menu_events::AUTO_START_CHANGED.is_empty());
|
||||
@@ -376,8 +261,6 @@ mod tests {
|
||||
fn test_menu_events_unique() {
|
||||
// 验证事件常量唯一
|
||||
let events = vec![
|
||||
menu_events::START_SERVER,
|
||||
menu_events::STOP_SERVER,
|
||||
menu_events::REFRESH_TOKENS,
|
||||
menu_events::HEALTH_CHECK,
|
||||
menu_events::AUTO_START_CHANGED,
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
//! - 托盘菜单构建
|
||||
//! - 菜单事件处理
|
||||
//! - 托盘图标点击事件处理
|
||||
//! - 状态同步
|
||||
|
||||
mod events;
|
||||
mod format;
|
||||
@@ -13,7 +12,6 @@ mod manager;
|
||||
mod menu;
|
||||
mod menu_handler;
|
||||
mod state;
|
||||
mod sync;
|
||||
|
||||
pub use events::*;
|
||||
pub use format::*;
|
||||
@@ -21,4 +19,3 @@ pub use manager::*;
|
||||
pub use menu::*;
|
||||
pub use menu_handler::*;
|
||||
pub use state::*;
|
||||
pub use sync::*;
|
||||
|
||||
@@ -1,237 +0,0 @@
|
||||
//! 托盘状态同步模块
|
||||
//!
|
||||
//! 提供托盘状态与应用状态的同步功能
|
||||
//!
|
||||
//! # Requirements
|
||||
|
||||
#![allow(dead_code)]
|
||||
//! - 7.1: API 服务器状态变化时在 1 秒内更新托盘图标
|
||||
//! - 7.2: 凭证健康状态变化时在 1 秒内更新托盘图标
|
||||
|
||||
use super::state::{calculate_icon_status, CredentialHealth, TrayIconStatus, TrayStateSnapshot};
|
||||
use super::TrayManager;
|
||||
use std::sync::Arc;
|
||||
use tauri::{AppHandle, Runtime};
|
||||
use tokio::sync::RwLock;
|
||||
use tracing::{debug, info};
|
||||
|
||||
/// 托盘状态同步器
|
||||
///
|
||||
/// 负责监听应用状态变化并更新托盘
|
||||
pub struct TraySynchronizer<R: Runtime> {
|
||||
/// AppHandle 引用
|
||||
app: AppHandle<R>,
|
||||
/// 托盘管理器引用
|
||||
tray_manager: Arc<RwLock<Option<TrayManager<R>>>>,
|
||||
}
|
||||
|
||||
impl<R: Runtime> TraySynchronizer<R> {
|
||||
/// 创建托盘状态同步器
|
||||
pub fn new(app: AppHandle<R>, tray_manager: Arc<RwLock<Option<TrayManager<R>>>>) -> Self {
|
||||
Self { app, tray_manager }
|
||||
}
|
||||
|
||||
/// 同步托盘状态
|
||||
///
|
||||
/// 从应用状态获取最新数据并更新托盘
|
||||
///
|
||||
/// # Requirements
|
||||
/// - 7.1: API 服务器状态变化时更新托盘图标
|
||||
/// - 7.2: 凭证健康状态变化时更新托盘图标
|
||||
pub async fn sync_state(
|
||||
&self,
|
||||
server_running: bool,
|
||||
server_host: &str,
|
||||
server_port: u16,
|
||||
credentials: &[CredentialHealth],
|
||||
today_requests: u64,
|
||||
auto_start_enabled: bool,
|
||||
) -> Result<(), String> {
|
||||
let tray_guard = self.tray_manager.read().await;
|
||||
let tray_manager = tray_guard
|
||||
.as_ref()
|
||||
.ok_or_else(|| "托盘管理器未初始化".to_string())?;
|
||||
let current_state = tray_manager.get_state().await;
|
||||
|
||||
// 计算图标状态
|
||||
let icon_status = calculate_icon_status(server_running, credentials);
|
||||
|
||||
// 计算可用凭证数
|
||||
let available_credentials = credentials.iter().filter(|c| c.is_valid).count();
|
||||
let total_credentials = credentials.len();
|
||||
|
||||
// 构建状态快照
|
||||
let snapshot = TrayStateSnapshot {
|
||||
icon_status,
|
||||
server_running,
|
||||
server_address: if server_running {
|
||||
format!("{server_host}:{server_port}")
|
||||
} else {
|
||||
String::new()
|
||||
},
|
||||
available_credentials,
|
||||
total_credentials,
|
||||
today_requests,
|
||||
auto_start_enabled,
|
||||
current_model_provider_type: current_state.current_model_provider_type,
|
||||
current_model_provider_label: current_state.current_model_provider_label,
|
||||
current_model: current_state.current_model,
|
||||
current_theme_label: current_state.current_theme_label,
|
||||
quick_model_groups: current_state.quick_model_groups,
|
||||
};
|
||||
|
||||
// 更新托盘状态
|
||||
tray_manager
|
||||
.update_state(snapshot)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
debug!(
|
||||
"托盘状态已同步: server_running={}, icon_status={:?}, credentials={}/{}",
|
||||
server_running, icon_status, available_credentials, total_credentials
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 仅更新服务器状态
|
||||
///
|
||||
/// # Requirements
|
||||
/// - 7.1: API 服务器状态变化时在 1 秒内更新托盘图标
|
||||
pub async fn update_server_status(
|
||||
&self,
|
||||
server_running: bool,
|
||||
server_host: &str,
|
||||
server_port: u16,
|
||||
) -> Result<(), String> {
|
||||
let tray_guard = self.tray_manager.read().await;
|
||||
let tray_manager = tray_guard
|
||||
.as_ref()
|
||||
.ok_or_else(|| "托盘管理器未初始化".to_string())?;
|
||||
|
||||
// 获取当前状态
|
||||
let mut current_state = tray_manager.get_state().await;
|
||||
|
||||
// 更新服务器相关字段
|
||||
current_state.server_running = server_running;
|
||||
current_state.server_address = if server_running {
|
||||
format!("{server_host}:{server_port}")
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
|
||||
// 重新计算图标状态
|
||||
// 如果服务器停止,图标状态为 Stopped
|
||||
// 否则保持当前状态(凭证状态未变)
|
||||
if !server_running {
|
||||
current_state.icon_status = TrayIconStatus::Stopped;
|
||||
} else if current_state.icon_status == TrayIconStatus::Stopped {
|
||||
// 服务器启动,但之前是停止状态,设为 Running
|
||||
current_state.icon_status = TrayIconStatus::Running;
|
||||
}
|
||||
|
||||
// 更新托盘状态
|
||||
tray_manager
|
||||
.update_state(current_state)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
info!(
|
||||
"托盘服务器状态已更新: running={}, address={}:{}",
|
||||
server_running, server_host, server_port
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 仅更新凭证健康状态
|
||||
///
|
||||
/// # Requirements
|
||||
/// - 7.2: 凭证健康状态变化时在 1 秒内更新托盘图标
|
||||
pub async fn update_credential_health(
|
||||
&self,
|
||||
credentials: &[CredentialHealth],
|
||||
) -> Result<(), String> {
|
||||
let tray_guard = self.tray_manager.read().await;
|
||||
let tray_manager = tray_guard
|
||||
.as_ref()
|
||||
.ok_or_else(|| "托盘管理器未初始化".to_string())?;
|
||||
|
||||
// 获取当前状态
|
||||
let mut current_state = tray_manager.get_state().await;
|
||||
|
||||
// 更新凭证相关字段
|
||||
current_state.available_credentials = credentials.iter().filter(|c| c.is_valid).count();
|
||||
current_state.total_credentials = credentials.len();
|
||||
|
||||
// 重新计算图标状态
|
||||
current_state.icon_status =
|
||||
calculate_icon_status(current_state.server_running, credentials);
|
||||
|
||||
// 保存日志所需的值
|
||||
let available = current_state.available_credentials;
|
||||
let total = current_state.total_credentials;
|
||||
let icon_status = current_state.icon_status;
|
||||
|
||||
// 更新托盘状态
|
||||
tray_manager
|
||||
.update_state(current_state)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
info!(
|
||||
"托盘凭证状态已更新: available={}/{}, icon_status={:?}",
|
||||
available, total, icon_status
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 更新今日请求数
|
||||
pub async fn update_request_count(&self, today_requests: u64) -> Result<(), String> {
|
||||
let tray_guard = self.tray_manager.read().await;
|
||||
let tray_manager = tray_guard
|
||||
.as_ref()
|
||||
.ok_or_else(|| "托盘管理器未初始化".to_string())?;
|
||||
|
||||
// 获取当前状态
|
||||
let mut current_state = tray_manager.get_state().await;
|
||||
|
||||
// 更新请求数
|
||||
current_state.today_requests = today_requests;
|
||||
|
||||
// 更新托盘状态(不改变图标)
|
||||
tray_manager
|
||||
.update_state(current_state)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
debug!("托盘请求数已更新: {}", today_requests);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 更新自启动状态
|
||||
pub async fn update_auto_start(&self, enabled: bool) -> Result<(), String> {
|
||||
let tray_guard = self.tray_manager.read().await;
|
||||
let tray_manager = tray_guard
|
||||
.as_ref()
|
||||
.ok_or_else(|| "托盘管理器未初始化".to_string())?;
|
||||
|
||||
// 获取当前状态
|
||||
let mut current_state = tray_manager.get_state().await;
|
||||
|
||||
// 更新自启动状态
|
||||
current_state.auto_start_enabled = enabled;
|
||||
|
||||
// 更新托盘状态(不改变图标)
|
||||
tray_manager
|
||||
.update_state(current_state)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
debug!("托盘自启动状态已更新: {}", enabled);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user