chore: release v0.99.0

This commit is contained in:
coso
2026-03-30 14:31:05 +08:00
parent 149e63c2f7
commit ae7b3163a9
246 changed files with 26175 additions and 13109 deletions
+126 -3
View File
@@ -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(&registry_definitions, &caller, execution_policy_input);
let registry_tools = build_registry_inventory(
&registry_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 {
+2 -94
View File
@@ -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())
}
}
}
+4 -7
View File
@@ -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(
+3 -124
View File
@@ -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
View File
@@ -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,
+1 -1
View File
@@ -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 {
+13 -2
View File
@@ -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(
+421 -56
View File
@@ -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", &params, Some(&session_hint)),
None
);
assert_eq!(
LimeBrowserMcpTool::resolve_backend("javascript_tool", &params, Some(&session_hint)),
LimeBrowserMcpTool::resolve_backend("drag", &params, Some(&session_hint)),
None
);
}
@@ -471,10 +470,56 @@ mod tests {
LimeBrowserMcpTool::resolve_backend("navigate", &params, Some(&session_hint)),
Some(BrowserBackendType::CdpDirect)
);
assert_eq!(
LimeBrowserMcpTool::resolve_backend("find", &params, Some(&session_hint)),
Some(BrowserBackendType::CdpDirect)
);
assert_eq!(
LimeBrowserMcpTool::resolve_backend("read_page", &params, Some(&session_hint)),
Some(BrowserBackendType::CdpDirect)
);
assert_eq!(
LimeBrowserMcpTool::resolve_backend("javascript_tool", &params, Some(&session_hint)),
Some(BrowserBackendType::CdpDirect)
);
assert_eq!(
LimeBrowserMcpTool::resolve_backend("computer", &params, 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(&current_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(&params, _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, &params)
.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(),
+78 -55
View File
@@ -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),
},
)
+158 -33
View File
@@ -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)
);
}
}
-2
View File
@@ -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;
-12
View File
@@ -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()
}
+1 -1
View File
@@ -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());
-94
View File
@@ -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))
}
}
}
+3 -270
View File
@@ -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;
/// 同步托盘中的快速模型切换菜单
///
+500 -36
View File
@@ -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
-12
View File
@@ -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,
+195 -8
View File
@@ -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)),
+61 -3
View File
@@ -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}"
);
}
}
}
+1 -1
View File
@@ -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(
+1 -2
View File
@@ -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,
};
+8 -12
View File
@@ -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
View File
@@ -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,
&copy_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,
+7 -124
View File
@@ -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,
-3
View File
@@ -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::*;
-237
View File
@@ -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(())
}
}