mirror of
https://github.com/aiclientproxy/proxycast.git
synced 2026-09-24 23:10:56 +08:00
Release v0.90.0
This commit is contained in:
@@ -98,9 +98,10 @@ let stream = agent.reply(user_message, session_config, Some(cancel_token)).await
|
||||
| `aster_agent_init` | 初始化 Agent |
|
||||
| `aster_agent_configure_provider` | 手动配置 Provider |
|
||||
| `aster_agent_configure_from_pool` | 从凭证池配置 Provider(推荐) |
|
||||
| `aster_agent_chat_stream` | 流式对话 |
|
||||
| `aster_agent_stop` | 停止会话 |
|
||||
| `aster_session_create/list/get` | 会话管理 |
|
||||
| `agent_runtime_submit_turn` | 统一提交 turn |
|
||||
| `agent_runtime_interrupt_turn` | 统一中断 turn |
|
||||
| `agent_runtime_create/list/get/update/delete_session` | 统一会话管理 |
|
||||
| `agent_runtime_respond_action` | 统一响应工具确认 / ask / elicitation |
|
||||
|
||||
## 凭证池桥接
|
||||
|
||||
|
||||
@@ -10,7 +10,9 @@ use futures::StreamExt;
|
||||
use lime_agent::{convert_agent_event, TauriAgentEvent, WriteArtifactEventEmitter};
|
||||
use tauri::{AppHandle, Emitter};
|
||||
|
||||
pub use lime_agent::session_store::{SessionDetail, SessionInfo};
|
||||
pub use lime_agent::session_store::{
|
||||
PersistedSessionMetadata, SessionDetail, SessionInfo, SessionTitlePreviewMessage,
|
||||
};
|
||||
|
||||
/// Aster Agent 包装器
|
||||
///
|
||||
@@ -138,6 +140,21 @@ impl AsterAgentWrapper {
|
||||
lime_agent::session_store::get_session_sync(db, session_id)
|
||||
}
|
||||
|
||||
pub fn get_persisted_session_metadata_sync(
|
||||
db: &DbConnection,
|
||||
session_id: &str,
|
||||
) -> Result<Option<PersistedSessionMetadata>, String> {
|
||||
lime_agent::session_store::get_persisted_session_metadata_sync(db, session_id)
|
||||
}
|
||||
|
||||
pub fn list_title_preview_messages_sync(
|
||||
db: &DbConnection,
|
||||
session_id: &str,
|
||||
limit: usize,
|
||||
) -> Result<Vec<SessionTitlePreviewMessage>, String> {
|
||||
lime_agent::session_store::list_title_preview_messages_sync(db, session_id, limit)
|
||||
}
|
||||
|
||||
/// 重命名会话
|
||||
pub fn rename_session_sync(
|
||||
db: &DbConnection,
|
||||
@@ -147,9 +164,29 @@ impl AsterAgentWrapper {
|
||||
lime_agent::session_store::rename_session_sync(db, session_id, name)
|
||||
}
|
||||
|
||||
pub fn update_session_working_dir_sync(
|
||||
db: &DbConnection,
|
||||
session_id: &str,
|
||||
working_dir: &str,
|
||||
) -> Result<(), String> {
|
||||
lime_agent::session_store::update_session_working_dir_sync(db, session_id, working_dir)
|
||||
}
|
||||
|
||||
pub fn update_session_execution_strategy_sync(
|
||||
db: &DbConnection,
|
||||
session_id: &str,
|
||||
execution_strategy: &str,
|
||||
) -> Result<(), String> {
|
||||
lime_agent::session_store::update_session_execution_strategy_sync(
|
||||
db,
|
||||
session_id,
|
||||
execution_strategy,
|
||||
)
|
||||
}
|
||||
|
||||
/// 删除会话
|
||||
pub fn delete_session_sync(db: &DbConnection, session_id: &str) -> Result<(), String> {
|
||||
lime_agent::session_store::delete_session_sync(db, session_id)
|
||||
pub async fn delete_session(db: &DbConnection, session_id: &str) -> Result<(), String> {
|
||||
lime_agent::session_store::delete_session(db, session_id).await
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -34,6 +34,18 @@ pub struct FrontendCrashReport {
|
||||
pub context: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
/// 前端调试日志上报参数
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct FrontendDebugLogReport {
|
||||
pub message: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub level: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub category: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub context: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct LogArtifactEntry {
|
||||
pub file_name: String,
|
||||
@@ -438,6 +450,58 @@ pub async fn report_frontend_crash(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn summarize_frontend_debug_context(context: Option<&Value>) -> String {
|
||||
let Some(context) = context else {
|
||||
return String::new();
|
||||
};
|
||||
|
||||
let serialized =
|
||||
serde_json::to_string(context).unwrap_or_else(|_| "\"<invalid-context>\"".into());
|
||||
let sanitized = logger::sanitize_log_message(&serialized);
|
||||
const MAX_LEN: usize = 1200;
|
||||
|
||||
if sanitized.len() > MAX_LEN {
|
||||
format!("{}...", &sanitized[..MAX_LEN])
|
||||
} else {
|
||||
sanitized
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_frontend_debug_level(level: Option<&str>) -> &'static str {
|
||||
match level.unwrap_or("info").trim().to_ascii_lowercase().as_str() {
|
||||
"debug" => "debug",
|
||||
"warn" | "warning" => "warn",
|
||||
"error" => "error",
|
||||
_ => "info",
|
||||
}
|
||||
}
|
||||
|
||||
/// 写入前端调试日志到本地日志,便于排查卡顿或未崩溃问题
|
||||
#[tauri::command]
|
||||
pub async fn report_frontend_debug_log(
|
||||
logs: tauri::State<'_, LogState>,
|
||||
report: FrontendDebugLogReport,
|
||||
) -> Result<(), String> {
|
||||
let sanitized_message = logger::sanitize_log_message(&report.message);
|
||||
let sanitized_category = report
|
||||
.category
|
||||
.as_deref()
|
||||
.map(logger::sanitize_log_message)
|
||||
.unwrap_or_else(|| "general".to_string());
|
||||
let context_preview = summarize_frontend_debug_context(report.context.as_ref());
|
||||
let level = normalize_frontend_debug_level(report.level.as_deref());
|
||||
let message = if context_preview.is_empty() {
|
||||
format!("[FrontendDebug] category={sanitized_category} message={sanitized_message}")
|
||||
} else {
|
||||
format!(
|
||||
"[FrontendDebug] category={sanitized_category} message={sanitized_message} context={context_preview}"
|
||||
)
|
||||
};
|
||||
|
||||
logs.write().await.add(level, &message);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SupportBundleExportResult {
|
||||
pub bundle_path: String,
|
||||
|
||||
+41
-29
@@ -31,6 +31,8 @@ fn should_minimize_to_tray(window_label: &str, minimize_to_tray: bool) -> bool {
|
||||
/// 5. 启动应用
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
let _profiling_guard = crate::profiling::init();
|
||||
|
||||
// 加载并验证配置
|
||||
let config = match bootstrap::load_and_validate_config() {
|
||||
Ok(cfg) => cfg,
|
||||
@@ -222,6 +224,12 @@ pub fn run() {
|
||||
if let Err(e) = main_window.show() {
|
||||
tracing::warn!("[启动] 主窗口显示失败: {}", e);
|
||||
}
|
||||
|
||||
#[cfg(debug_assertions)]
|
||||
if crate::profiling::should_open_webview_devtools() {
|
||||
main_window.open_devtools();
|
||||
tracing::info!("[Profiling] 已自动打开主窗口 WebView DevTools");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
@@ -641,6 +649,9 @@ pub fn run() {
|
||||
let shared_logger = shared_logger_clone.clone();
|
||||
let app_handle = app.handle().clone();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
let mut available_credentials = 0usize;
|
||||
let mut total_credentials = 0usize;
|
||||
|
||||
// 先加载凭证池中的凭证
|
||||
{
|
||||
logs.write().await.add("info", "[启动] 正在加载凭证池...");
|
||||
@@ -649,12 +660,19 @@ pub fn run() {
|
||||
match pool_service.get_overview(&db) {
|
||||
Ok(overview) => {
|
||||
let mut loaded_types = Vec::new();
|
||||
let mut total_credentials = 0;
|
||||
|
||||
for provider_overview in overview {
|
||||
let count = provider_overview.stats.total_count;
|
||||
let enabled_credentials: Vec<_> = provider_overview
|
||||
.credentials
|
||||
.iter()
|
||||
.filter(|credential| !credential.is_disabled)
|
||||
.collect();
|
||||
let count = enabled_credentials.len();
|
||||
if count > 0 {
|
||||
total_credentials += count;
|
||||
available_credentials += enabled_credentials
|
||||
.iter()
|
||||
.filter(|credential| credential.is_healthy)
|
||||
.count();
|
||||
let provider_name =
|
||||
match provider_overview.provider_type.as_str() {
|
||||
"kiro" => "Kiro",
|
||||
@@ -742,22 +760,31 @@ pub fn run() {
|
||||
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 icon_status = if server_started {
|
||||
TrayIconStatus::Running
|
||||
} else {
|
||||
let current_state = tray_manager.get_state().await;
|
||||
let icon_status = if !server_started {
|
||||
TrayIconStatus::Stopped
|
||||
} else if total_credentials > 0 && available_credentials == 0 {
|
||||
TrayIconStatus::Error
|
||||
} else if available_credentials < total_credentials {
|
||||
TrayIconStatus::Warning
|
||||
} else {
|
||||
TrayIconStatus::Running
|
||||
};
|
||||
|
||||
let snapshot = TrayStateSnapshot {
|
||||
icon_status,
|
||||
server_running: server_started,
|
||||
server_address,
|
||||
available_credentials: 0, // 初始值,后续通过状态同步更新
|
||||
total_credentials: 0,
|
||||
today_requests: 0,
|
||||
auto_start_enabled: false, // 后续通过状态同步更新
|
||||
available_credentials,
|
||||
total_credentials,
|
||||
today_requests: current_state.today_requests,
|
||||
auto_start_enabled: current_state.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,
|
||||
};
|
||||
|
||||
if let Err(e) = tray_manager.update_state(snapshot).await {
|
||||
@@ -1019,6 +1046,7 @@ pub fn run() {
|
||||
app_commands::clear_logs,
|
||||
app_commands::clear_diagnostic_log_history,
|
||||
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,
|
||||
@@ -1283,6 +1311,7 @@ pub fn run() {
|
||||
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,
|
||||
commands::plugin_cmd::get_plugins,
|
||||
@@ -1348,13 +1377,6 @@ pub fn run() {
|
||||
commands::agent_cmd::agent_start_process,
|
||||
commands::agent_cmd::agent_stop_process,
|
||||
commands::agent_cmd::agent_get_process_status,
|
||||
commands::agent_cmd::agent_create_session,
|
||||
commands::agent_cmd::agent_send_message,
|
||||
commands::agent_cmd::agent_list_sessions,
|
||||
commands::agent_cmd::agent_get_session,
|
||||
commands::agent_cmd::agent_delete_session,
|
||||
commands::agent_cmd::agent_get_session_messages,
|
||||
commands::agent_cmd::agent_rename_session,
|
||||
commands::agent_cmd::agent_generate_title,
|
||||
// TODO: 重新启用这些命令,适配 aster-rust 工具系统
|
||||
// commands::agent_cmd::agent_terminal_command_response,
|
||||
@@ -1365,24 +1387,14 @@ pub fn run() {
|
||||
commands::aster_agent_cmd::aster_agent_reset,
|
||||
commands::aster_agent_cmd::aster_agent_configure_provider,
|
||||
commands::aster_agent_cmd::aster_agent_configure_from_pool,
|
||||
commands::aster_agent_cmd::aster_agent_chat_stream,
|
||||
commands::aster_agent_cmd::aster_agent_stop,
|
||||
commands::aster_agent_cmd::agent_runtime_submit_turn,
|
||||
commands::aster_agent_cmd::agent_runtime_interrupt_turn,
|
||||
commands::aster_agent_cmd::agent_runtime_remove_queued_turn,
|
||||
commands::aster_agent_cmd::aster_session_create,
|
||||
commands::aster_agent_cmd::aster_session_set_execution_strategy,
|
||||
commands::aster_agent_cmd::aster_session_list,
|
||||
commands::aster_agent_cmd::aster_session_get,
|
||||
commands::aster_agent_cmd::aster_session_rename,
|
||||
commands::aster_agent_cmd::aster_session_delete,
|
||||
commands::aster_agent_cmd::agent_runtime_create_session,
|
||||
commands::aster_agent_cmd::agent_runtime_list_sessions,
|
||||
commands::aster_agent_cmd::agent_runtime_get_session,
|
||||
commands::aster_agent_cmd::agent_runtime_update_session,
|
||||
commands::aster_agent_cmd::agent_runtime_delete_session,
|
||||
commands::aster_agent_cmd::aster_agent_confirm,
|
||||
commands::aster_agent_cmd::aster_agent_submit_elicitation_response,
|
||||
commands::aster_agent_cmd::agent_runtime_respond_action,
|
||||
commands::aster_agent_cmd::social_generate_cover_image_cmd,
|
||||
commands::theme_context_cmd::aster_agent_theme_context_search,
|
||||
|
||||
@@ -141,6 +141,9 @@ async fn start_server_async(
|
||||
shared_logger: Arc<telemetry::RequestLogger>,
|
||||
app_handle: tauri::AppHandle,
|
||||
) {
|
||||
let mut available_credentials = 0usize;
|
||||
let mut total_credentials = 0usize;
|
||||
|
||||
// 先加载凭证池中的凭证
|
||||
{
|
||||
logs.write().await.add("info", "[启动] 正在加载凭证池...");
|
||||
@@ -148,12 +151,20 @@ async fn start_server_async(
|
||||
match pool_service.get_overview(&db) {
|
||||
Ok(overview) => {
|
||||
let mut loaded_types = Vec::new();
|
||||
let mut total_credentials = 0;
|
||||
|
||||
for provider_overview in overview {
|
||||
let count = provider_overview.stats.total_count;
|
||||
let enabled_credentials: Vec<_> = provider_overview
|
||||
.credentials
|
||||
.iter()
|
||||
.filter(|credential| !credential.is_disabled)
|
||||
.collect();
|
||||
let count = enabled_credentials.len();
|
||||
if count > 0 {
|
||||
total_credentials += count;
|
||||
available_credentials += enabled_credentials
|
||||
.iter()
|
||||
.filter(|credential| credential.is_healthy)
|
||||
.count();
|
||||
let provider_name = match provider_overview.provider_type.as_str() {
|
||||
"kiro" => "Kiro",
|
||||
"gemini" => "Gemini",
|
||||
@@ -242,20 +253,30 @@ async fn start_server_async(
|
||||
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 icon_status = if server_started {
|
||||
TrayIconStatus::Running
|
||||
} else {
|
||||
let current_state = tray_manager.get_state().await;
|
||||
let icon_status = if !server_started {
|
||||
TrayIconStatus::Stopped
|
||||
} else if total_credentials > 0 && available_credentials == 0 {
|
||||
TrayIconStatus::Error
|
||||
} else if available_credentials < total_credentials {
|
||||
TrayIconStatus::Warning
|
||||
} else {
|
||||
TrayIconStatus::Running
|
||||
};
|
||||
|
||||
let snapshot = TrayStateSnapshot {
|
||||
icon_status,
|
||||
server_running: server_started,
|
||||
server_address,
|
||||
available_credentials: 0,
|
||||
total_credentials: 0,
|
||||
today_requests: 0,
|
||||
auto_start_enabled: false,
|
||||
available_credentials,
|
||||
total_credentials,
|
||||
today_requests: current_state.today_requests,
|
||||
auto_start_enabled: current_state.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,
|
||||
};
|
||||
|
||||
if let Err(e) = tray_manager.update_state(snapshot).await {
|
||||
|
||||
@@ -1,22 +1,12 @@
|
||||
//! Agent 命令模块
|
||||
//!
|
||||
//! 提供 Agent 的 Tauri 命令(兼容旧 API)
|
||||
//! 内部使用 Aster Agent 实现
|
||||
//! 提供 Agent 的进程与标题相关 Tauri 命令
|
||||
|
||||
use crate::agent::{AgentMessage, AgentSession, AsterAgentState};
|
||||
use crate::agent::{AsterAgentState, AsterAgentWrapper};
|
||||
use crate::commands::aster_agent_cmd::ensure_browser_mcp_tools_registered;
|
||||
use crate::config::GlobalConfigManagerState;
|
||||
use crate::database::dao::agent::AgentDao;
|
||||
use crate::database::DbConnection;
|
||||
use crate::services::memory_profile_prompt_service::{
|
||||
merge_system_prompt_with_memory_profile, merge_system_prompt_with_memory_sources,
|
||||
};
|
||||
use crate::services::web_search_prompt_service::merge_system_prompt_with_web_search;
|
||||
use crate::services::web_search_runtime_service::apply_web_search_runtime_env;
|
||||
use crate::services::workspace_health_service::ensure_workspace_ready_with_auto_relocate;
|
||||
use crate::workspace::WorkspaceManager;
|
||||
use crate::AppState;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde::Serialize;
|
||||
use tauri::State;
|
||||
|
||||
/// 安全截断字符串,确保不会在多字节字符中间切割
|
||||
@@ -37,31 +27,6 @@ fn truncate_string(s: &str, max_chars: usize) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_workspace_id_by_working_dir(
|
||||
conn: &rusqlite::Connection,
|
||||
working_dir: Option<&str>,
|
||||
) -> Option<String> {
|
||||
let resolved_working_dir = working_dir?.trim();
|
||||
if resolved_working_dir.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
conn.query_row(
|
||||
"SELECT id FROM workspaces WHERE root_path = ? LIMIT 1",
|
||||
rusqlite::params![resolved_working_dir],
|
||||
|row| row.get::<_, String>(0),
|
||||
)
|
||||
.ok()
|
||||
}
|
||||
|
||||
fn normalize_execution_strategy(value: Option<String>) -> String {
|
||||
match value.as_deref() {
|
||||
Some("code_orchestrated") => "code_orchestrated".to_string(),
|
||||
Some("auto") => "auto".to_string(),
|
||||
_ => "react".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Agent 进程状态响应
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct AgentProcessStatus {
|
||||
@@ -70,17 +35,6 @@ pub struct AgentProcessStatus {
|
||||
pub port: Option<u16>,
|
||||
}
|
||||
|
||||
/// 创建会话响应
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct CreateSessionResponse {
|
||||
pub session_id: String,
|
||||
pub credential_name: String,
|
||||
pub credential_uuid: String,
|
||||
pub provider_type: String,
|
||||
pub model: Option<String>,
|
||||
pub execution_strategy: String,
|
||||
}
|
||||
|
||||
/// 启动 Agent(使用 Aster 实现)
|
||||
#[tauri::command]
|
||||
pub async fn agent_start_process(
|
||||
@@ -160,290 +114,6 @@ pub async fn agent_get_process_status(
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Skill 信息
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct SkillInfo {
|
||||
pub name: String,
|
||||
pub description: Option<String>,
|
||||
pub path: Option<String>,
|
||||
}
|
||||
|
||||
/// 创建 Agent 会话
|
||||
#[tauri::command]
|
||||
pub async fn agent_create_session(
|
||||
agent_state: State<'_, AsterAgentState>,
|
||||
db: State<'_, DbConnection>,
|
||||
config_manager: State<'_, GlobalConfigManagerState>,
|
||||
provider_type: String,
|
||||
model: Option<String>,
|
||||
system_prompt: Option<String>,
|
||||
skills: Option<Vec<SkillInfo>>,
|
||||
workspace_id: String,
|
||||
execution_strategy: Option<String>,
|
||||
) -> Result<CreateSessionResponse, String> {
|
||||
tracing::info!(
|
||||
"[Agent] 创建会话: provider_type={}, model={:?}, skills_count={:?}",
|
||||
provider_type,
|
||||
model,
|
||||
skills.as_ref().map(|s| s.len())
|
||||
);
|
||||
|
||||
let workspace_id = workspace_id.trim().to_string();
|
||||
if workspace_id.is_empty() {
|
||||
return Err("workspace_id 必填,请先选择项目工作区".to_string());
|
||||
}
|
||||
|
||||
let workspace_manager = WorkspaceManager::new(db.inner().clone());
|
||||
let workspace = workspace_manager
|
||||
.get(&workspace_id)
|
||||
.map_err(|e| format!("读取 workspace 失败: {e}"))?
|
||||
.ok_or_else(|| format!("Workspace 不存在: {workspace_id}"))?;
|
||||
let ensured = ensure_workspace_ready_with_auto_relocate(&workspace_manager, &workspace)?;
|
||||
if ensured.repaired {
|
||||
tracing::warn!(
|
||||
"[Agent] Workspace 路径异常已自动修复: {}{}",
|
||||
ensured.root_path.to_string_lossy(),
|
||||
if ensured.relocated {
|
||||
"(已迁移)"
|
||||
} else {
|
||||
""
|
||||
}
|
||||
);
|
||||
}
|
||||
let workspace_root = ensured.root_path.to_string_lossy().to_string();
|
||||
|
||||
// 初始化 Agent(使用带数据库的版本)
|
||||
agent_state.init_agent_with_db(&db).await?;
|
||||
ensure_browser_mcp_tools_registered(agent_state.inner()).await?;
|
||||
|
||||
// 生成会话 ID
|
||||
let session_id = uuid::Uuid::new_v4().to_string();
|
||||
let normalized_execution_strategy = normalize_execution_strategy(execution_strategy);
|
||||
|
||||
// 从凭证池配置 Provider
|
||||
let model_name = model
|
||||
.clone()
|
||||
.unwrap_or_else(|| "claude-sonnet-4-20250514".to_string());
|
||||
let aster_config = agent_state
|
||||
.configure_provider_from_pool(&db, &provider_type, &model_name, &session_id)
|
||||
.await?;
|
||||
|
||||
// 构建包含 Skills 的 System Prompt,并附加记忆画像偏好
|
||||
let base_system_prompt = build_system_prompt_with_skills(system_prompt, skills.as_ref());
|
||||
let config = config_manager.config();
|
||||
apply_web_search_runtime_env(&config);
|
||||
let prompt_with_memory = merge_system_prompt_with_memory_sources(
|
||||
merge_system_prompt_with_memory_profile(base_system_prompt, &config),
|
||||
&config,
|
||||
std::path::Path::new(&workspace_root),
|
||||
None,
|
||||
);
|
||||
let final_system_prompt = merge_system_prompt_with_web_search(prompt_with_memory, &config);
|
||||
|
||||
// 保存会话到数据库
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
let session = AgentSession {
|
||||
id: session_id.clone(),
|
||||
model: model_name.clone(),
|
||||
messages: Vec::new(),
|
||||
system_prompt: final_system_prompt,
|
||||
title: None, // 初始会话没有标题,后续会自动生成
|
||||
working_dir: Some(workspace_root),
|
||||
execution_strategy: Some(normalized_execution_strategy.clone()),
|
||||
created_at: now.clone(),
|
||||
updated_at: now,
|
||||
};
|
||||
|
||||
{
|
||||
let conn = db.lock().map_err(|e| format!("数据库锁定失败: {e}"))?;
|
||||
if let Err(e) = AgentDao::create_session(&conn, &session) {
|
||||
tracing::warn!("[Agent] 保存会话到数据库失败: {}", e);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(CreateSessionResponse {
|
||||
session_id,
|
||||
credential_name: "Lime".to_string(),
|
||||
credential_uuid: aster_config.credential_uuid,
|
||||
provider_type,
|
||||
model: Some(model_name),
|
||||
execution_strategy: normalized_execution_strategy,
|
||||
})
|
||||
}
|
||||
|
||||
/// 构建包含 Skills 的 System Prompt
|
||||
fn build_system_prompt_with_skills(
|
||||
base_prompt: Option<String>,
|
||||
skills: Option<&Vec<SkillInfo>>,
|
||||
) -> Option<String> {
|
||||
let skills_xml = match skills {
|
||||
Some(skills) if !skills.is_empty() => {
|
||||
let mut xml = String::from("<available_skills>\n");
|
||||
for skill in skills {
|
||||
xml.push_str(" <skill>\n");
|
||||
xml.push_str(&format!(" <name>{}</name>\n", skill.name));
|
||||
if let Some(desc) = &skill.description {
|
||||
xml.push_str(&format!(" <description>{desc}</description>\n"));
|
||||
}
|
||||
if let Some(path) = &skill.path {
|
||||
xml.push_str(&format!(" <location>{path}</location>\n"));
|
||||
}
|
||||
xml.push_str(" </skill>\n");
|
||||
}
|
||||
xml.push_str("</available_skills>\n\n");
|
||||
xml.push_str("当用户的请求匹配某个 Skill 的描述时,请使用该 Skill 来完成任务。\n");
|
||||
xml.push_str("如果需要使用 Skill,请先读取对应的 SKILL.md 文件获取详细指令。\n");
|
||||
Some(xml)
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
|
||||
match (base_prompt, skills_xml) {
|
||||
(Some(base), Some(skills)) => Some(format!("{base}\n\n{skills}")),
|
||||
(Some(base), None) => Some(base),
|
||||
(None, Some(skills)) => Some(skills),
|
||||
(None, None) => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// 图片输入参数
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ImageInputParam {
|
||||
pub data: String,
|
||||
pub media_type: String,
|
||||
}
|
||||
|
||||
/// 发送消息到 Agent
|
||||
///
|
||||
/// 注意:此命令已废弃,请使用 aster_agent_chat_stream
|
||||
#[tauri::command]
|
||||
pub async fn agent_send_message(
|
||||
_agent_state: State<'_, AsterAgentState>,
|
||||
_session_id: Option<String>,
|
||||
_message: String,
|
||||
_images: Option<Vec<ImageInputParam>>,
|
||||
_model: Option<String>,
|
||||
_web_search: Option<bool>,
|
||||
_thinking: Option<bool>,
|
||||
) -> Result<String, String> {
|
||||
Err("此命令已废弃,请使用 aster_agent_chat_stream 进行流式对话".to_string())
|
||||
}
|
||||
|
||||
/// 会话信息
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct SessionInfo {
|
||||
pub session_id: String,
|
||||
pub provider_type: String,
|
||||
pub model: Option<String>,
|
||||
pub title: Option<String>,
|
||||
pub created_at: String,
|
||||
pub last_activity: String,
|
||||
pub messages_count: usize,
|
||||
pub workspace_id: Option<String>,
|
||||
pub working_dir: Option<String>,
|
||||
pub execution_strategy: Option<String>,
|
||||
}
|
||||
|
||||
/// 获取会话列表
|
||||
#[tauri::command]
|
||||
pub async fn agent_list_sessions(db: State<'_, DbConnection>) -> Result<Vec<SessionInfo>, String> {
|
||||
let conn = db.lock().map_err(|e| format!("数据库锁定失败: {e}"))?;
|
||||
|
||||
let sessions = AgentDao::list_sessions(&conn).map_err(|e| format!("获取会话列表失败: {e}"))?;
|
||||
|
||||
let result: Vec<SessionInfo> = sessions
|
||||
.into_iter()
|
||||
.map(|s| {
|
||||
let messages_count = AgentDao::get_message_count(&conn, &s.id).unwrap_or(0);
|
||||
let working_dir = s.working_dir.clone();
|
||||
let workspace_id = resolve_workspace_id_by_working_dir(&conn, working_dir.as_deref());
|
||||
|
||||
SessionInfo {
|
||||
session_id: s.id,
|
||||
provider_type: "aster".to_string(),
|
||||
model: Some(s.model),
|
||||
title: s.title,
|
||||
created_at: s.created_at.clone(),
|
||||
last_activity: s.updated_at,
|
||||
messages_count,
|
||||
workspace_id,
|
||||
working_dir,
|
||||
execution_strategy: s.execution_strategy,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// 获取会话详情
|
||||
#[tauri::command]
|
||||
pub async fn agent_get_session(
|
||||
db: State<'_, DbConnection>,
|
||||
session_id: String,
|
||||
) -> Result<SessionInfo, String> {
|
||||
let conn = db.lock().map_err(|e| format!("数据库锁定失败: {e}"))?;
|
||||
|
||||
let session = AgentDao::get_session(&conn, &session_id)
|
||||
.map_err(|e| format!("获取会话失败: {e}"))?
|
||||
.ok_or_else(|| "会话不存在".to_string())?;
|
||||
|
||||
let messages_count = AgentDao::get_message_count(&conn, &session_id).unwrap_or(0);
|
||||
let working_dir = session.working_dir.clone();
|
||||
let workspace_id = resolve_workspace_id_by_working_dir(&conn, working_dir.as_deref());
|
||||
|
||||
Ok(SessionInfo {
|
||||
session_id: session.id,
|
||||
provider_type: "aster".to_string(),
|
||||
model: Some(session.model),
|
||||
title: session.title,
|
||||
created_at: session.created_at.clone(),
|
||||
last_activity: session.updated_at,
|
||||
messages_count,
|
||||
workspace_id,
|
||||
working_dir,
|
||||
execution_strategy: session.execution_strategy,
|
||||
})
|
||||
}
|
||||
|
||||
/// 删除会话
|
||||
#[tauri::command]
|
||||
pub async fn agent_delete_session(
|
||||
db: State<'_, DbConnection>,
|
||||
session_id: String,
|
||||
) -> Result<(), String> {
|
||||
let conn = db.lock().map_err(|e| format!("数据库锁定失败: {e}"))?;
|
||||
AgentDao::delete_session(&conn, &session_id).map_err(|e| format!("删除会话失败: {e}"))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 获取会话消息列表
|
||||
#[tauri::command]
|
||||
pub async fn agent_get_session_messages(
|
||||
db: State<'_, DbConnection>,
|
||||
session_id: String,
|
||||
) -> Result<Vec<AgentMessage>, String> {
|
||||
let conn = db.lock().map_err(|e| format!("数据库锁定失败: {e}"))?;
|
||||
let messages =
|
||||
AgentDao::get_messages(&conn, &session_id).map_err(|e| format!("获取消息失败: {e}"))?;
|
||||
Ok(messages)
|
||||
}
|
||||
|
||||
/// 重命名会话(更新标题)
|
||||
#[tauri::command]
|
||||
pub async fn agent_rename_session(
|
||||
db: State<'_, DbConnection>,
|
||||
session_id: String,
|
||||
title: String,
|
||||
) -> Result<(), String> {
|
||||
let conn = db.lock().map_err(|e| format!("数据库锁定失败: {e}"))?;
|
||||
AgentDao::update_title(&conn, &session_id, &title)
|
||||
.map_err(|e| format!("更新会话标题失败: {e}"))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 生成智能标题
|
||||
///
|
||||
/// 根据对话内容生成一个简洁的标题
|
||||
@@ -452,43 +122,21 @@ pub async fn agent_generate_title(
|
||||
db: State<'_, DbConnection>,
|
||||
session_id: String,
|
||||
) -> Result<String, String> {
|
||||
let conn = db.lock().map_err(|e| format!("数据库锁定失败: {e}"))?;
|
||||
|
||||
// 获取会话的前几条消息(用于生成标题)
|
||||
let messages =
|
||||
AgentDao::get_messages(&conn, &session_id).map_err(|e| format!("获取消息失败: {e}"))?;
|
||||
let messages = AsterAgentWrapper::list_title_preview_messages_sync(&db, &session_id, 4)?;
|
||||
|
||||
// 过滤出 user 和 assistant 消息
|
||||
let chat_messages: Vec<_> = messages
|
||||
.iter()
|
||||
.filter(|msg| msg.role == "user" || msg.role == "assistant")
|
||||
.take(4) // 取前 2 轮对话
|
||||
.collect();
|
||||
let chat_messages: Vec<_> = messages.iter().collect();
|
||||
|
||||
if chat_messages.len() < 2 {
|
||||
return Ok("新话题".to_string());
|
||||
}
|
||||
|
||||
// 构建对话内容用于 AI 生成标题
|
||||
let mut conversation = String::new();
|
||||
for msg in &chat_messages {
|
||||
let role = if msg.role == "user" {
|
||||
"用户"
|
||||
} else {
|
||||
"助手"
|
||||
};
|
||||
let content = msg.content.as_text();
|
||||
// 使用字符边界安全截断,避免在多字节字符中间切割
|
||||
let truncated_content = truncate_string(&content, 100);
|
||||
conversation.push_str(&format!("{role}:{truncated_content}\n"));
|
||||
}
|
||||
|
||||
// 使用 AI 生成标题(通过 aster_agent_chat_stream 生成)
|
||||
// 这里简化处理:使用第一条用户消息的前 15 个字作为默认标题
|
||||
if let Some(first_user_msg) = chat_messages.iter().find(|msg| msg.role == "user") {
|
||||
let content = first_user_msg.content.as_text();
|
||||
let content = &first_user_msg.content;
|
||||
// 使用字符边界安全截断
|
||||
let title = truncate_string(&content, 15);
|
||||
let title = truncate_string(content, 15);
|
||||
Ok(title)
|
||||
} else {
|
||||
Ok("新话题".to_string())
|
||||
|
||||
@@ -15,7 +15,6 @@ use crate::commands::webview_cmd::{
|
||||
BrowserBackendType,
|
||||
};
|
||||
use crate::config::{GlobalConfigManager, GlobalConfigManagerState};
|
||||
use crate::database::dao::agent::AgentDao;
|
||||
use crate::database::dao::agent_runtime_queue::{
|
||||
AgentRuntimeQueuedTurnDao, NewAgentRuntimeQueuedTurnRecord,
|
||||
};
|
||||
@@ -47,6 +46,7 @@ use aster::permission::{Permission, PermissionConfirmation, PrincipalType};
|
||||
use aster::sandbox::{
|
||||
detect_best_sandbox, execute_in_sandbox, ResourceLimits, SandboxConfig as ProcessSandboxConfig,
|
||||
};
|
||||
use aster::session::{SessionRuntimeSnapshot, TurnContextOverride};
|
||||
use aster::tools::task_output_tool::TaskOutputInput;
|
||||
use aster::tools::{
|
||||
BashTool, KillShellTool, PermissionBehavior, PermissionCheckResult, TaskManager,
|
||||
@@ -62,10 +62,11 @@ use lime_agent::request_tool_policy::{
|
||||
stream_reply_with_policy, ReplyAttemptError, RequestToolPolicy, RequestToolPolicyMode,
|
||||
};
|
||||
use lime_agent::{
|
||||
durable_memory_permission_pattern, is_virtual_memory_path, message_suggests_news_expansion,
|
||||
resolve_virtual_memory_path, virtual_memory_relative_path, TauriRuntimeStatus,
|
||||
DURABLE_MEMORY_VIRTUAL_ROOT,
|
||||
convert_item_runtime, convert_turn_runtime, durable_memory_permission_pattern,
|
||||
is_virtual_memory_path, message_suggests_news_expansion, resolve_virtual_memory_path,
|
||||
virtual_memory_relative_path, TauriRuntimeStatus, DURABLE_MEMORY_VIRTUAL_ROOT,
|
||||
};
|
||||
use lime_core::database::dao::agent_timeline::{AgentThreadItem, AgentThreadTurn};
|
||||
use lime_services::api_key_provider_service::ApiKeyProviderService;
|
||||
use lime_services::mcp_service::McpService;
|
||||
use lime_services::video_generation_service::{
|
||||
@@ -75,7 +76,7 @@ use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{Arc, Mutex, OnceLock};
|
||||
use std::time::Duration;
|
||||
use std::time::{Duration, Instant};
|
||||
use tauri::{AppHandle, Emitter, State};
|
||||
use tokio_util::sync::CancellationToken;
|
||||
use uuid::Uuid;
|
||||
@@ -216,6 +217,19 @@ fn build_workspace_sandbox_warning_message(reason: &str) -> String {
|
||||
format!("已启用 workspace 本地 sandbox,但当前环境不可用,已自动降级为普通执行。原因: {reason}")
|
||||
}
|
||||
|
||||
fn build_turn_context_override(
|
||||
metadata: Option<&serde_json::Value>,
|
||||
) -> Option<TurnContextOverride> {
|
||||
let serde_json::Value::Object(map) = metadata?.clone() else {
|
||||
return None;
|
||||
};
|
||||
|
||||
Some(TurnContextOverride {
|
||||
metadata: map.into_iter().collect(),
|
||||
..TurnContextOverride::default()
|
||||
})
|
||||
}
|
||||
|
||||
/// Aster Agent 状态信息
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct AsterAgentStatus {
|
||||
@@ -419,6 +433,9 @@ pub struct AsterChatRequest {
|
||||
/// 请求级元数据(可选,用于 harness / 主题工作台状态对齐)
|
||||
#[serde(default)]
|
||||
pub metadata: Option<serde_json::Value>,
|
||||
/// 回合 ID(可选,由前端提供时透传到 Aster runtime)
|
||||
#[serde(default, alias = "turnId")]
|
||||
pub turn_id: Option<String>,
|
||||
/// 会话忙时是否进入后端队列
|
||||
#[serde(default, alias = "queueIfBusy")]
|
||||
pub queue_if_busy: Option<bool>,
|
||||
@@ -492,6 +509,7 @@ impl From<AgentRuntimeSubmitTurnRequest> for AsterChatRequest {
|
||||
.as_ref()
|
||||
.and_then(|config| config.system_prompt.clone()),
|
||||
metadata: turn_config.and_then(|config| config.metadata),
|
||||
turn_id: request.turn_id,
|
||||
queue_if_busy: request.queue_if_busy,
|
||||
queued_turn_id: request.queued_turn_id,
|
||||
}
|
||||
@@ -547,6 +565,77 @@ impl AgentRuntimeSessionDetail {
|
||||
}
|
||||
}
|
||||
|
||||
fn sort_runtime_turns(turns: &mut [AgentThreadTurn]) {
|
||||
turns.sort_by(|left, right| {
|
||||
left.started_at
|
||||
.cmp(&right.started_at)
|
||||
.then(left.created_at.cmp(&right.created_at))
|
||||
.then(left.id.cmp(&right.id))
|
||||
});
|
||||
}
|
||||
|
||||
fn sort_runtime_items(items: &mut [AgentThreadItem], turn_started_at: &HashMap<String, String>) {
|
||||
items.sort_by(|left, right| {
|
||||
let left_turn_started = turn_started_at
|
||||
.get(&left.turn_id)
|
||||
.map(String::as_str)
|
||||
.unwrap_or(left.started_at.as_str());
|
||||
let right_turn_started = turn_started_at
|
||||
.get(&right.turn_id)
|
||||
.map(String::as_str)
|
||||
.unwrap_or(right.started_at.as_str());
|
||||
|
||||
left_turn_started
|
||||
.cmp(right_turn_started)
|
||||
.then(left.sequence.cmp(&right.sequence))
|
||||
.then(left.turn_id.cmp(&right.turn_id))
|
||||
.then(left.started_at.cmp(&right.started_at))
|
||||
.then(left.id.cmp(&right.id))
|
||||
});
|
||||
}
|
||||
|
||||
fn apply_aster_runtime_snapshot(detail: &mut SessionDetail, snapshot: &SessionRuntimeSnapshot) {
|
||||
if let Some(thread) = snapshot.threads.first() {
|
||||
detail.thread_id = thread.thread.id.clone();
|
||||
}
|
||||
|
||||
if snapshot.threads.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
let mut turns_by_id = detail
|
||||
.turns
|
||||
.drain(..)
|
||||
.map(|turn| (turn.id.clone(), turn))
|
||||
.collect::<HashMap<_, _>>();
|
||||
for thread in &snapshot.threads {
|
||||
for turn in &thread.turns {
|
||||
turns_by_id.insert(turn.id.clone(), convert_turn_runtime(turn.clone()));
|
||||
}
|
||||
}
|
||||
detail.turns = turns_by_id.into_values().collect();
|
||||
sort_runtime_turns(&mut detail.turns);
|
||||
|
||||
let turn_started_at = detail
|
||||
.turns
|
||||
.iter()
|
||||
.map(|turn| (turn.id.clone(), turn.started_at.clone()))
|
||||
.collect::<HashMap<_, _>>();
|
||||
|
||||
let mut items_by_id = detail
|
||||
.items
|
||||
.drain(..)
|
||||
.map(|item| (item.id.clone(), item))
|
||||
.collect::<HashMap<_, _>>();
|
||||
for thread in &snapshot.threads {
|
||||
for item in &thread.items {
|
||||
items_by_id.insert(item.id.clone(), convert_item_runtime(item.clone()));
|
||||
}
|
||||
}
|
||||
detail.items = items_by_id.into_values().collect();
|
||||
sort_runtime_items(&mut detail.items, &turn_started_at);
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum AgentRuntimeActionType {
|
||||
@@ -5382,6 +5471,8 @@ async fn execute_aster_chat_request(
|
||||
tracing::warn!("[AsterAgent] session_store 存在: {}", has_store);
|
||||
}
|
||||
}
|
||||
ensure_browser_mcp_tools_registered(state).await?;
|
||||
ensure_tool_search_tool_registered(state).await?;
|
||||
ensure_social_image_tool_registered(state, config_manager).await?;
|
||||
|
||||
// 直接使用前端传递的 session_id
|
||||
@@ -5468,25 +5559,17 @@ async fn execute_aster_chat_request(
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
let db_conn = db.lock().map_err(|e| format!("获取数据库连接失败: {e}"))?;
|
||||
if let Some(session) = AgentDao::get_session(&db_conn, session_id)
|
||||
.map_err(|e| format!("读取 session 失败: {e}"))?
|
||||
{
|
||||
let session_dir = session.working_dir.unwrap_or_default();
|
||||
if !session_dir.is_empty() && session_dir != workspace_root {
|
||||
tracing::info!(
|
||||
"[AsterAgent] workspace 变更,自动更新 session working_dir: {} -> {}",
|
||||
session_dir,
|
||||
workspace_root
|
||||
);
|
||||
db_conn
|
||||
.execute(
|
||||
"UPDATE agent_sessions SET working_dir = ?1 WHERE id = ?2",
|
||||
rusqlite::params![&workspace_root, session_id],
|
||||
)
|
||||
.map_err(|e| format!("更新 session working_dir 失败: {e}"))?;
|
||||
}
|
||||
let persisted_session = AsterAgentWrapper::get_persisted_session_metadata_sync(db, session_id)?;
|
||||
|
||||
if let Some(session) = persisted_session.as_ref() {
|
||||
let session_dir = session.working_dir.as_deref().unwrap_or_default();
|
||||
if !session_dir.is_empty() && session_dir != workspace_root {
|
||||
tracing::info!(
|
||||
"[AsterAgent] workspace 变更,自动更新 session working_dir: {} -> {}",
|
||||
session_dir,
|
||||
workspace_root
|
||||
);
|
||||
AsterAgentWrapper::update_session_working_dir_sync(db, session_id, &workspace_root)?;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5539,14 +5622,24 @@ async fn execute_aster_chat_request(
|
||||
|
||||
// 构建 system_prompt:优先使用项目上下文,其次使用 session 的 system_prompt
|
||||
// 同时读取会话已持久化的 execution_strategy
|
||||
let (system_prompt, persisted_strategy) = {
|
||||
let db_conn = db.lock().map_err(|e| format!("获取数据库连接失败: {e}"))?;
|
||||
let session = AgentDao::get_session(&db_conn, session_id)
|
||||
.map_err(|e| format!("读取 session 失败: {e}"))?;
|
||||
let persisted = session
|
||||
let (system_prompt, persisted_strategy, has_persisted_session) = {
|
||||
let persisted = persisted_session
|
||||
.as_ref()
|
||||
.map(|s| AsterExecutionStrategy::from_db_value(s.execution_strategy.as_deref()))
|
||||
.unwrap_or_default();
|
||||
let session_prompt = match persisted_session.as_ref() {
|
||||
Some(session) => {
|
||||
tracing::debug!(
|
||||
"[AsterAgent] 找到 session,system_prompt: {:?}",
|
||||
session.system_prompt.as_ref().map(|s| s.len())
|
||||
);
|
||||
session.system_prompt.clone()
|
||||
}
|
||||
None => {
|
||||
tracing::debug!("[AsterAgent] Lime 数据库中未找到 session: {}", session_id);
|
||||
None
|
||||
}
|
||||
};
|
||||
|
||||
// 1. 如果提供了 project_id,构建项目上下文
|
||||
let project_prompt = if let Some(ref project_id) = request.project_id {
|
||||
@@ -5576,19 +5669,6 @@ async fn execute_aster_chat_request(
|
||||
let resolved_prompt = if project_prompt.is_some() {
|
||||
project_prompt
|
||||
} else {
|
||||
let session_prompt = match session {
|
||||
Some(session) => {
|
||||
tracing::debug!(
|
||||
"[AsterAgent] 找到 session,system_prompt: {:?}",
|
||||
session.system_prompt.as_ref().map(|s| s.len())
|
||||
);
|
||||
session.system_prompt
|
||||
}
|
||||
None => {
|
||||
tracing::debug!("[AsterAgent] Lime 数据库中未找到 session: {}", session_id);
|
||||
None
|
||||
}
|
||||
};
|
||||
// fallback 到前端传入的 system_prompt
|
||||
if session_prompt.is_some() {
|
||||
session_prompt
|
||||
@@ -5624,17 +5704,16 @@ async fn execute_aster_chat_request(
|
||||
auto_continue_config.as_ref(),
|
||||
);
|
||||
|
||||
(merged_prompt, persisted)
|
||||
(merged_prompt, persisted, persisted_session.is_some())
|
||||
};
|
||||
|
||||
let requested_strategy = request.execution_strategy.unwrap_or(persisted_strategy);
|
||||
let effective_strategy = requested_strategy.effective_for_message(&request.message);
|
||||
|
||||
if let Some(explicit_strategy) = request.execution_strategy {
|
||||
let db_conn = db.lock().map_err(|e| format!("获取数据库连接失败: {e}"))?;
|
||||
if AgentDao::session_exists(&db_conn, session_id).unwrap_or(false) {
|
||||
if let Err(e) = AgentDao::update_execution_strategy(
|
||||
&db_conn,
|
||||
if has_persisted_session {
|
||||
if let Err(error) = AsterAgentWrapper::update_session_execution_strategy_sync(
|
||||
db,
|
||||
session_id,
|
||||
explicit_strategy.as_db_value(),
|
||||
) {
|
||||
@@ -5642,7 +5721,7 @@ async fn execute_aster_chat_request(
|
||||
"[AsterAgent] 更新会话执行策略失败: session={}, strategy={}, error={}",
|
||||
session_id,
|
||||
explicit_strategy.as_db_value(),
|
||||
e
|
||||
error
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -5763,20 +5842,39 @@ async fn execute_aster_chat_request(
|
||||
let run_observation = Arc::new(Mutex::new(ChatRunObservation::default()));
|
||||
let run_observation_for_finalize = run_observation.clone();
|
||||
let run_start_metadata_for_finalize = run_start_metadata.clone();
|
||||
|
||||
let agent_arc = state.get_agent_arc();
|
||||
let runtime_snapshot = {
|
||||
let guard = agent_arc.read().await;
|
||||
let agent = guard.as_ref().ok_or("Agent not initialized")?;
|
||||
match agent.runtime_snapshot(session_id).await {
|
||||
Ok(snapshot) => Some(snapshot),
|
||||
Err(error) => {
|
||||
tracing::warn!(
|
||||
"[AsterAgent] 提交 turn 前读取 runtime snapshot 失败: session_id={}, error={}",
|
||||
session_id,
|
||||
error
|
||||
);
|
||||
None
|
||||
}
|
||||
}
|
||||
};
|
||||
let resolved_thread_id = runtime_snapshot
|
||||
.as_ref()
|
||||
.and_then(|snapshot| snapshot.threads.first())
|
||||
.map(|thread| thread.thread.id.clone())
|
||||
.unwrap_or_else(|| session_id.to_string());
|
||||
let resolved_turn_id = request
|
||||
.turn_id
|
||||
.clone()
|
||||
.unwrap_or_else(|| Uuid::new_v4().to_string());
|
||||
let timeline_recorder = Arc::new(Mutex::new(AgentTimelineRecorder::create(
|
||||
db.clone(),
|
||||
session_id.to_string(),
|
||||
resolved_thread_id.clone(),
|
||||
resolved_turn_id.clone(),
|
||||
request.message.clone(),
|
||||
)?));
|
||||
|
||||
{
|
||||
let mut recorder = match timeline_recorder.lock() {
|
||||
Ok(guard) => guard,
|
||||
Err(error) => error.into_inner(),
|
||||
};
|
||||
recorder.emit_start(app, &request.event_name)?;
|
||||
}
|
||||
|
||||
let (initial_runtime_status, decided_runtime_status) = build_turn_runtime_statuses(
|
||||
&request,
|
||||
effective_strategy,
|
||||
@@ -5796,24 +5894,31 @@ async fn execute_aster_chat_request(
|
||||
Err(error) => error.into_inner(),
|
||||
};
|
||||
if let Err(error) =
|
||||
recorder.record_legacy_event(app, &request.event_name, &event, workspace_root.as_str())
|
||||
recorder.record_runtime_event(app, &request.event_name, &event, workspace_root.as_str())
|
||||
{
|
||||
tracing::warn!("[AsterAgent] 记录 runtime_status 失败: {}", error);
|
||||
}
|
||||
}
|
||||
|
||||
// 获取 Agent Arc 并保持 guard 在整个流处理期间存活
|
||||
let agent_arc = state.get_agent_arc();
|
||||
let guard = agent_arc.read().await;
|
||||
let agent = guard.as_ref().ok_or("Agent not initialized")?;
|
||||
|
||||
let include_context_trace = runtime_config.memory.enabled;
|
||||
let turn_context = build_turn_context_override(request.metadata.as_ref());
|
||||
let resolved_thread_id_for_session = resolved_thread_id.clone();
|
||||
let resolved_turn_id_for_session = resolved_turn_id.clone();
|
||||
|
||||
let build_session_config = || {
|
||||
let mut session_config_builder = SessionConfigBuilder::new(session_id);
|
||||
let mut session_config_builder = SessionConfigBuilder::new(session_id)
|
||||
.thread_id(resolved_thread_id_for_session.clone())
|
||||
.turn_id(resolved_turn_id_for_session.clone());
|
||||
if let Some(prompt) = system_prompt.clone() {
|
||||
session_config_builder = session_config_builder.system_prompt(prompt);
|
||||
}
|
||||
if let Some(turn_context) = turn_context.clone() {
|
||||
session_config_builder = session_config_builder.turn_context(turn_context);
|
||||
}
|
||||
session_config_builder =
|
||||
session_config_builder.include_context_trace(include_context_trace);
|
||||
session_config_builder.build()
|
||||
@@ -5823,7 +5928,7 @@ async fn execute_aster_chat_request(
|
||||
let final_result = tracker
|
||||
.with_run_custom(
|
||||
RunSource::Chat,
|
||||
Some("aster_agent_chat_stream".to_string()),
|
||||
Some("agent_runtime_submit_turn".to_string()),
|
||||
Some(session_id.to_string()),
|
||||
Some(serde_json::Value::Object(run_start_metadata.clone())),
|
||||
async {
|
||||
@@ -5867,7 +5972,7 @@ async fn execute_aster_chat_request(
|
||||
Ok(guard) => guard,
|
||||
Err(error) => error.into_inner(),
|
||||
};
|
||||
if let Err(error) = recorder.record_legacy_event(
|
||||
if let Err(error) = recorder.record_runtime_event(
|
||||
&app,
|
||||
&event_name,
|
||||
event,
|
||||
@@ -5939,7 +6044,7 @@ async fn execute_aster_chat_request(
|
||||
Ok(guard) => guard,
|
||||
Err(error) => error.into_inner(),
|
||||
};
|
||||
if let Err(error) = recorder.record_legacy_event(
|
||||
if let Err(error) = recorder.record_runtime_event(
|
||||
&app,
|
||||
&event_name,
|
||||
event,
|
||||
@@ -6046,33 +6151,6 @@ async fn execute_aster_chat_request(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 发送消息并获取流式响应
|
||||
#[tauri::command]
|
||||
pub async fn aster_agent_chat_stream(
|
||||
app: AppHandle,
|
||||
state: State<'_, AsterAgentState>,
|
||||
db: State<'_, DbConnection>,
|
||||
api_key_provider_service: State<'_, ApiKeyProviderServiceState>,
|
||||
logs: State<'_, LogState>,
|
||||
config_manager: State<'_, GlobalConfigManagerState>,
|
||||
mcp_manager: State<'_, McpManagerState>,
|
||||
automation_state: State<'_, AutomationServiceState>,
|
||||
request: AsterChatRequest,
|
||||
) -> Result<(), String> {
|
||||
execute_aster_chat_request(
|
||||
&app,
|
||||
state.inner(),
|
||||
db.inner(),
|
||||
api_key_provider_service.inner(),
|
||||
logs.inner(),
|
||||
config_manager.inner(),
|
||||
mcp_manager.inner(),
|
||||
automation_state.inner(),
|
||||
request,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
struct AgentRuntimeExecutionContext {
|
||||
app: AppHandle,
|
||||
state: AsterAgentState,
|
||||
@@ -6460,16 +6538,6 @@ pub fn resume_persisted_runtime_queues_on_startup(
|
||||
Ok(resumed)
|
||||
}
|
||||
|
||||
/// 停止当前会话
|
||||
#[tauri::command]
|
||||
pub async fn aster_agent_stop(
|
||||
state: State<'_, AsterAgentState>,
|
||||
session_id: String,
|
||||
) -> Result<bool, String> {
|
||||
tracing::info!("[AsterAgent] 停止会话: {}", session_id);
|
||||
Ok(state.cancel_session(&session_id).await)
|
||||
}
|
||||
|
||||
/// 统一运行时:提交一个 turn。
|
||||
#[tauri::command]
|
||||
pub async fn agent_runtime_submit_turn(
|
||||
@@ -6555,13 +6623,11 @@ pub async fn agent_runtime_create_session(
|
||||
name: Option<String>,
|
||||
execution_strategy: Option<AsterExecutionStrategy>,
|
||||
) -> Result<String, String> {
|
||||
aster_session_create(db, None, workspace_id, name, execution_strategy).await
|
||||
create_runtime_session_internal(db.inner(), None, workspace_id, name, execution_strategy).await
|
||||
}
|
||||
|
||||
/// 创建新会话
|
||||
#[tauri::command]
|
||||
pub async fn aster_session_create(
|
||||
db: State<'_, DbConnection>,
|
||||
async fn create_runtime_session_internal(
|
||||
db: &DbConnection,
|
||||
working_dir: Option<String>,
|
||||
workspace_id: String,
|
||||
name: Option<String>,
|
||||
@@ -6574,7 +6640,7 @@ pub async fn aster_session_create(
|
||||
return Err("workspace_id 必填,请先选择项目工作区".to_string());
|
||||
}
|
||||
|
||||
let manager = WorkspaceManager::new(db.inner().clone());
|
||||
let manager = WorkspaceManager::new(db.clone());
|
||||
let workspace = manager
|
||||
.get(&workspace_id)
|
||||
.map_err(|e| format!("读取 workspace 失败: {e}"))?
|
||||
@@ -6602,7 +6668,7 @@ pub async fn aster_session_create(
|
||||
.or_else(|| Some(workspace_root.clone()));
|
||||
|
||||
AsterAgentWrapper::create_session_sync(
|
||||
&db,
|
||||
db,
|
||||
name,
|
||||
resolved_working_dir,
|
||||
workspace_id,
|
||||
@@ -6615,42 +6681,66 @@ pub async fn aster_session_create(
|
||||
)
|
||||
}
|
||||
|
||||
/// 设置会话执行策略
|
||||
#[tauri::command]
|
||||
pub async fn aster_session_set_execution_strategy(
|
||||
db: State<'_, DbConnection>,
|
||||
session_id: String,
|
||||
fn update_runtime_session_execution_strategy_internal(
|
||||
db: &DbConnection,
|
||||
session_id: &str,
|
||||
execution_strategy: AsterExecutionStrategy,
|
||||
) -> Result<(), String> {
|
||||
let conn = db.lock().map_err(|e| format!("数据库锁定失败: {e}"))?;
|
||||
AgentDao::update_execution_strategy(&conn, &session_id, execution_strategy.as_db_value())
|
||||
.map_err(|e| format!("更新会话执行策略失败: {e}"))?;
|
||||
Ok(())
|
||||
AsterAgentWrapper::update_session_execution_strategy_sync(
|
||||
db,
|
||||
session_id,
|
||||
execution_strategy.as_db_value(),
|
||||
)
|
||||
}
|
||||
|
||||
/// 统一运行时:列出会话。
|
||||
#[tauri::command]
|
||||
pub async fn agent_runtime_list_sessions(
|
||||
db: State<'_, DbConnection>,
|
||||
logs: State<'_, LogState>,
|
||||
) -> Result<Vec<SessionInfo>, String> {
|
||||
aster_session_list(db).await
|
||||
let started_at = Instant::now();
|
||||
logs.write()
|
||||
.await
|
||||
.add("info", "[AgentDiag] agent_runtime_list_sessions.start");
|
||||
|
||||
match list_runtime_sessions_internal(db.inner()) {
|
||||
Ok(sessions) => {
|
||||
logs.write().await.add(
|
||||
"info",
|
||||
&format!(
|
||||
"[AgentDiag] agent_runtime_list_sessions.success duration_ms={} sessions={}",
|
||||
started_at.elapsed().as_millis(),
|
||||
sessions.len()
|
||||
),
|
||||
);
|
||||
Ok(sessions)
|
||||
}
|
||||
Err(error) => {
|
||||
logs.write().await.add(
|
||||
"error",
|
||||
&format!(
|
||||
"[AgentDiag] agent_runtime_list_sessions.error duration_ms={} error={}",
|
||||
started_at.elapsed().as_millis(),
|
||||
crate::logger::sanitize_log_message(&error)
|
||||
),
|
||||
);
|
||||
Err(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 列出所有会话
|
||||
#[tauri::command]
|
||||
pub async fn aster_session_list(db: State<'_, DbConnection>) -> Result<Vec<SessionInfo>, String> {
|
||||
fn list_runtime_sessions_internal(db: &DbConnection) -> Result<Vec<SessionInfo>, String> {
|
||||
tracing::info!("[AsterAgent] 列出会话");
|
||||
AsterAgentWrapper::list_sessions_sync(&db)
|
||||
AsterAgentWrapper::list_sessions_sync(db)
|
||||
}
|
||||
|
||||
/// 获取会话详情
|
||||
#[tauri::command]
|
||||
pub async fn aster_session_get(
|
||||
db: State<'_, DbConnection>,
|
||||
session_id: String,
|
||||
fn get_runtime_session_detail_internal(
|
||||
db: &DbConnection,
|
||||
session_id: &str,
|
||||
) -> Result<SessionDetail, String> {
|
||||
tracing::info!("[AsterAgent] 获取会话: {}", session_id);
|
||||
AsterAgentWrapper::get_session_sync(&db, &session_id)
|
||||
AsterAgentWrapper::get_session_sync(db, session_id)
|
||||
}
|
||||
|
||||
/// 统一运行时:获取会话详情。
|
||||
@@ -6667,7 +6757,23 @@ pub async fn agent_runtime_get_session(
|
||||
session_id: String,
|
||||
) -> Result<AgentRuntimeSessionDetail, String> {
|
||||
ensure_runtime_queue_loaded(state.inner(), db.inner(), &session_id)?;
|
||||
let detail = AsterAgentWrapper::get_session_sync(db.inner(), &session_id)?;
|
||||
let mut detail = get_runtime_session_detail_internal(db.inner(), &session_id)?;
|
||||
|
||||
let agent_arc = state.get_agent_arc();
|
||||
let guard = agent_arc.read().await;
|
||||
if let Some(agent) = guard.as_ref() {
|
||||
match agent.runtime_snapshot(&session_id).await {
|
||||
Ok(snapshot) => apply_aster_runtime_snapshot(&mut detail, &snapshot),
|
||||
Err(error) => {
|
||||
tracing::warn!(
|
||||
"[AsterAgent] 读取 Aster runtime snapshot 失败: session_id={}, error={}",
|
||||
session_id,
|
||||
error
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let queued_turns = state.inner().turn_queue().snapshot(&session_id);
|
||||
if !queued_turns.is_empty() && !state.inner().turn_queue().has_active(&session_id) {
|
||||
let context = AgentRuntimeExecutionContext::from_states(
|
||||
@@ -6729,15 +6835,13 @@ pub async fn agent_runtime_remove_queued_turn(
|
||||
Ok(false)
|
||||
}
|
||||
|
||||
/// 重命名会话
|
||||
#[tauri::command]
|
||||
pub async fn aster_session_rename(
|
||||
db: State<'_, DbConnection>,
|
||||
session_id: String,
|
||||
name: String,
|
||||
fn rename_runtime_session_internal(
|
||||
db: &DbConnection,
|
||||
session_id: &str,
|
||||
name: &str,
|
||||
) -> Result<(), String> {
|
||||
tracing::info!("[AsterAgent] 重命名会话: {}", session_id);
|
||||
AsterAgentWrapper::rename_session_sync(&db, &session_id, &name)
|
||||
AsterAgentWrapper::rename_session_sync(db, session_id, name)
|
||||
}
|
||||
|
||||
/// 统一运行时:更新会话元数据。
|
||||
@@ -6754,30 +6858,28 @@ pub async fn agent_runtime_update_session(
|
||||
if let Some(name) = request.name.as_ref() {
|
||||
let normalized_name = name.trim();
|
||||
if !normalized_name.is_empty() {
|
||||
aster_session_rename(
|
||||
db.clone(),
|
||||
trimmed_session_id.clone(),
|
||||
normalized_name.to_string(),
|
||||
)
|
||||
.await?;
|
||||
rename_runtime_session_internal(db.inner(), &trimmed_session_id, normalized_name)?;
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(execution_strategy) = request.execution_strategy {
|
||||
aster_session_set_execution_strategy(db, trimmed_session_id, execution_strategy).await?;
|
||||
update_runtime_session_execution_strategy_internal(
|
||||
db.inner(),
|
||||
&trimmed_session_id,
|
||||
execution_strategy,
|
||||
)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 删除会话
|
||||
#[tauri::command]
|
||||
pub async fn aster_session_delete(
|
||||
db: State<'_, DbConnection>,
|
||||
session_id: String,
|
||||
async fn delete_runtime_session_internal(
|
||||
db: &DbConnection,
|
||||
session_id: &str,
|
||||
) -> Result<(), String> {
|
||||
tracing::info!("[AsterAgent] 删除会话: {}", session_id);
|
||||
AsterAgentWrapper::delete_session_sync(&db, &session_id)
|
||||
AsterAgentWrapper::delete_session(db, session_id).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 统一运行时:删除会话。
|
||||
@@ -6791,7 +6893,7 @@ pub async fn agent_runtime_delete_session(
|
||||
let trimmed_session_id = session_id.trim().to_string();
|
||||
let _ = state.cancel_session(&trimmed_session_id).await;
|
||||
let _ = clear_pending_runtime_queue(&app, state.inner(), db.inner(), &trimmed_session_id);
|
||||
aster_session_delete(db, trimmed_session_id).await
|
||||
delete_runtime_session_internal(db.inner(), &trimmed_session_id).await
|
||||
}
|
||||
|
||||
/// 确认权限请求
|
||||
@@ -6803,10 +6905,8 @@ pub struct ConfirmRequest {
|
||||
pub response: Option<String>,
|
||||
}
|
||||
|
||||
/// 确认权限请求(用于工具调用确认等)
|
||||
#[tauri::command]
|
||||
pub async fn aster_agent_confirm(
|
||||
state: State<'_, AsterAgentState>,
|
||||
async fn confirm_runtime_action_internal(
|
||||
state: &AsterAgentState,
|
||||
request: ConfirmRequest,
|
||||
) -> Result<(), String> {
|
||||
tracing::info!(
|
||||
@@ -6891,8 +6991,8 @@ pub async fn agent_runtime_respond_action(
|
||||
|
||||
let result = match request.action_type {
|
||||
AgentRuntimeActionType::ToolConfirmation => {
|
||||
aster_agent_confirm(
|
||||
state,
|
||||
confirm_runtime_action_internal(
|
||||
state.inner(),
|
||||
ConfirmRequest {
|
||||
request_id: request.request_id.clone(),
|
||||
confirmed: request.confirmed,
|
||||
@@ -6903,8 +7003,8 @@ pub async fn agent_runtime_respond_action(
|
||||
}
|
||||
AgentRuntimeActionType::AskUser | AgentRuntimeActionType::Elicitation => {
|
||||
let user_data = build_runtime_action_user_data(&request);
|
||||
aster_agent_submit_elicitation_response(
|
||||
state,
|
||||
submit_runtime_elicitation_response_internal(
|
||||
state.inner(),
|
||||
request.session_id.clone(),
|
||||
SubmitElicitationResponseRequest {
|
||||
request_id: request.request_id.clone(),
|
||||
@@ -6923,10 +7023,8 @@ pub async fn agent_runtime_respond_action(
|
||||
result
|
||||
}
|
||||
|
||||
/// 提交 elicitation 回答(用于 ask/lsp 等需要用户输入的流程)
|
||||
#[tauri::command]
|
||||
pub async fn aster_agent_submit_elicitation_response(
|
||||
state: State<'_, AsterAgentState>,
|
||||
async fn submit_runtime_elicitation_response_internal(
|
||||
state: &AsterAgentState,
|
||||
session_id: String,
|
||||
request: SubmitElicitationResponseRequest,
|
||||
) -> Result<(), String> {
|
||||
@@ -7611,6 +7709,7 @@ mod tests {
|
||||
"gate_key": "write_mode"
|
||||
}
|
||||
})),
|
||||
turn_id: None,
|
||||
queue_if_busy: None,
|
||||
queued_turn_id: None,
|
||||
},
|
||||
|
||||
@@ -25,7 +25,7 @@ use serde_json::json;
|
||||
use std::time::Instant;
|
||||
use tauri::AppHandle;
|
||||
use tokio::time::{sleep, Duration};
|
||||
use tracing::info;
|
||||
use tracing::{info, Instrument};
|
||||
|
||||
const CDP_READY_MAX_ATTEMPTS: usize = 60;
|
||||
const CDP_READY_RETRY_INTERVAL_MS: u64 = 250;
|
||||
@@ -318,6 +318,17 @@ pub async fn launch_browser_runtime_assist(
|
||||
launch_browser_runtime_assist_global(app_handle, app_state.inner().clone(), request).await
|
||||
}
|
||||
|
||||
#[tracing::instrument(
|
||||
name = "launch_browser_runtime_assist_global",
|
||||
skip(app_handle, app_state, request),
|
||||
fields(
|
||||
profile_key = %request.profile_key,
|
||||
profile_id = ?request.profile_id,
|
||||
target_id = ?request.target_id,
|
||||
open_window = request.open_window,
|
||||
stream_mode = ?request.stream_mode
|
||||
)
|
||||
)]
|
||||
pub async fn launch_browser_runtime_assist_global(
|
||||
app_handle: AppHandle,
|
||||
app_state: AppState,
|
||||
@@ -343,6 +354,18 @@ pub async fn launch_browser_runtime_assist_global(
|
||||
.await
|
||||
}
|
||||
|
||||
#[tracing::instrument(
|
||||
name = "launch_browser_session_global",
|
||||
skip(app_handle, app_state, request),
|
||||
fields(
|
||||
profile_key = %request.profile_key,
|
||||
profile_id = ?request.profile_id,
|
||||
environment_preset_id = ?request.environment_preset_id,
|
||||
target_id = ?request.target_id,
|
||||
open_window = request.open_window,
|
||||
stream_mode = ?request.stream_mode
|
||||
)
|
||||
)]
|
||||
pub async fn launch_browser_session_global(
|
||||
app_handle: AppHandle,
|
||||
app_state: AppState,
|
||||
@@ -389,6 +412,9 @@ pub async fn launch_browser_session_global(
|
||||
launch_options: Some(chrome_launch_options),
|
||||
},
|
||||
)
|
||||
.instrument(tracing::info_span!(
|
||||
"launch_browser_session_global.open_profile"
|
||||
))
|
||||
.await
|
||||
{
|
||||
Ok(profile) => profile,
|
||||
@@ -431,8 +457,12 @@ pub async fn launch_browser_session_global(
|
||||
launch_audit.remote_debugging_port = Some(remote_debugging_port);
|
||||
|
||||
let cdp_ready_started_at = Instant::now();
|
||||
if let Err(error) =
|
||||
wait_for_cdp_ready(remote_debugging_port, request.target_id.as_deref()).await
|
||||
if let Err(error) = wait_for_cdp_ready(remote_debugging_port, request.target_id.as_deref())
|
||||
.instrument(tracing::info_span!(
|
||||
"launch_browser_session_global.wait_for_cdp_ready",
|
||||
remote_debugging_port
|
||||
))
|
||||
.await
|
||||
{
|
||||
finalize_browser_runtime_launch_audit(launch_audit, Some(error.clone())).await;
|
||||
return Err(error);
|
||||
@@ -458,6 +488,9 @@ pub async fn launch_browser_session_global(
|
||||
.as_ref()
|
||||
.and_then(|environment| environment.preset_name.clone()),
|
||||
})
|
||||
.instrument(tracing::info_span!(
|
||||
"launch_browser_session_global.open_cdp_session"
|
||||
))
|
||||
.await
|
||||
{
|
||||
Ok(session) => session,
|
||||
@@ -481,6 +514,10 @@ pub async fn launch_browser_session_global(
|
||||
let runtime = shared_browser_runtime();
|
||||
if let Err(error) =
|
||||
apply_browser_environment_to_session(runtime.as_ref(), &session.session_id, environment)
|
||||
.instrument(tracing::info_span!(
|
||||
"launch_browser_session_global.apply_environment",
|
||||
session_id = %session.session_id
|
||||
))
|
||||
.await
|
||||
{
|
||||
finalize_browser_runtime_launch_audit(launch_audit, Some(error.clone())).await;
|
||||
@@ -497,6 +534,10 @@ pub async fn launch_browser_session_global(
|
||||
mode: stream_mode,
|
||||
},
|
||||
)
|
||||
.instrument(tracing::info_span!(
|
||||
"launch_browser_session_global.start_stream",
|
||||
session_id = %session.session_id
|
||||
))
|
||||
.await
|
||||
{
|
||||
Ok(session) => session,
|
||||
@@ -516,13 +557,18 @@ pub async fn launch_browser_session_global(
|
||||
|
||||
if request.open_window {
|
||||
let window_started_at = Instant::now();
|
||||
if let Err(error) = browser_runtime_window::open_browser_runtime_window(
|
||||
&app_handle,
|
||||
Some(&session.session_id),
|
||||
Some(&request.profile_key),
|
||||
if let Err(error) = tracing::info_span!(
|
||||
"launch_browser_session_global.open_debugger_window",
|
||||
session_id = %session.session_id
|
||||
)
|
||||
.map_err(|e| format!("打开浏览器运行时调试窗口失败: {e}"))
|
||||
{
|
||||
.in_scope(|| {
|
||||
browser_runtime_window::open_browser_runtime_window(
|
||||
&app_handle,
|
||||
Some(&session.session_id),
|
||||
Some(&request.profile_key),
|
||||
)
|
||||
.map_err(|e| format!("打开浏览器运行时调试窗口失败: {e}"))
|
||||
}) {
|
||||
finalize_browser_runtime_launch_audit(launch_audit, Some(error.clone())).await;
|
||||
return Err(error);
|
||||
}
|
||||
@@ -545,12 +591,23 @@ pub async fn launch_browser_session_global(
|
||||
"url": launch_url,
|
||||
}),
|
||||
)
|
||||
.instrument(tracing::info_span!(
|
||||
"launch_browser_session_global.navigate",
|
||||
session_id = %session.session_id
|
||||
))
|
||||
.await
|
||||
{
|
||||
finalize_browser_runtime_launch_audit(launch_audit, Some(error.clone())).await;
|
||||
return Err(error);
|
||||
}
|
||||
session = match runtime.refresh_page_info(&session.session_id).await {
|
||||
session = match runtime
|
||||
.refresh_page_info(&session.session_id)
|
||||
.instrument(tracing::debug_span!(
|
||||
"launch_browser_session_global.refresh_page_info",
|
||||
session_id = %session.session_id
|
||||
))
|
||||
.await
|
||||
{
|
||||
Ok(session) => session,
|
||||
Err(error) => {
|
||||
finalize_browser_runtime_launch_audit(launch_audit, Some(error.clone())).await;
|
||||
|
||||
@@ -705,7 +705,7 @@ mod tests {
|
||||
let stale_run = AgentRun {
|
||||
id: "run-stale".to_string(),
|
||||
source: "chat".to_string(),
|
||||
source_ref: Some("aster_agent_chat_stream".to_string()),
|
||||
source_ref: Some("agent_runtime_submit_turn".to_string()),
|
||||
session_id: Some("session-1".to_string()),
|
||||
status: AgentRunStatus::Running,
|
||||
started_at: stale_started_at.clone(),
|
||||
@@ -720,7 +720,7 @@ mod tests {
|
||||
let fresh_run = AgentRun {
|
||||
id: "run-fresh".to_string(),
|
||||
source: "chat".to_string(),
|
||||
source_ref: Some("aster_agent_chat_stream".to_string()),
|
||||
source_ref: Some("agent_runtime_submit_turn".to_string()),
|
||||
session_id: Some("session-1".to_string()),
|
||||
status: AgentRunStatus::Queued,
|
||||
started_at: fresh_started_at.clone(),
|
||||
@@ -735,7 +735,7 @@ mod tests {
|
||||
let terminal_run = AgentRun {
|
||||
id: "run-terminal".to_string(),
|
||||
source: "chat".to_string(),
|
||||
source_ref: Some("aster_agent_chat_stream".to_string()),
|
||||
source_ref: Some("agent_runtime_submit_turn".to_string()),
|
||||
session_id: Some("session-1".to_string()),
|
||||
status: AgentRunStatus::Success,
|
||||
started_at: now.to_rfc3339(),
|
||||
|
||||
@@ -45,7 +45,7 @@ use crate::mcp::{
|
||||
use crate::models::mcp_model::McpServer;
|
||||
use lime_services::mcp_service::McpService;
|
||||
use tauri::State;
|
||||
use tracing::{debug, error, info};
|
||||
use tracing::{debug, error, info, Instrument};
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_mcp_servers(db: State<'_, DbConnection>) -> Result<Vec<McpServer>, String> {
|
||||
@@ -172,6 +172,11 @@ pub async fn mcp_list_servers_with_status(
|
||||
/// # Requirements
|
||||
///
|
||||
/// - **9.2**: THE mcp_start_server command SHALL start a specified MCP server
|
||||
#[tracing::instrument(
|
||||
name = "mcp_start_server",
|
||||
skip(db, mcp_manager),
|
||||
fields(server_name = %name)
|
||||
)]
|
||||
#[tauri::command]
|
||||
pub async fn mcp_start_server(
|
||||
db: State<'_, DbConnection>,
|
||||
@@ -191,7 +196,9 @@ pub async fn mcp_start_server(
|
||||
let config = parse_server_config(&server.server_config);
|
||||
|
||||
// 3. 获取管理器锁并启动服务器
|
||||
let manager = mcp_manager.lock().await;
|
||||
let manager = async { mcp_manager.lock().await }
|
||||
.instrument(tracing::debug_span!("mcp_start_server.acquire_manager"))
|
||||
.await;
|
||||
manager.start_server(&name, &config).await.map_err(|e| {
|
||||
error!(server_name = %name, error = %e, "启动 MCP 服务器失败");
|
||||
e.to_string()
|
||||
@@ -311,17 +318,24 @@ fn parse_server_config(config_value: &serde_json::Value) -> McpServerConfig {
|
||||
/// # Requirements
|
||||
///
|
||||
/// - **9.4**: THE mcp_list_tools command SHALL return all available tools from running servers
|
||||
#[tracing::instrument(name = "mcp_list_tools", skip(mcp_manager))]
|
||||
#[tauri::command]
|
||||
pub async fn mcp_list_tools(
|
||||
mcp_manager: State<'_, McpManagerState>,
|
||||
) -> Result<Vec<McpToolDefinition>, String> {
|
||||
info!("获取所有 MCP 工具列表");
|
||||
|
||||
let manager = mcp_manager.lock().await;
|
||||
let tools = manager.list_tools().await.map_err(|e| {
|
||||
error!(error = %e, "获取工具列表失败");
|
||||
e.to_string()
|
||||
})?;
|
||||
let manager = async { mcp_manager.lock().await }
|
||||
.instrument(tracing::debug_span!("mcp_list_tools.acquire_manager"))
|
||||
.await;
|
||||
let tools = manager
|
||||
.list_tools()
|
||||
.instrument(tracing::info_span!("mcp_list_tools.list_tools"))
|
||||
.await
|
||||
.map_err(|e| {
|
||||
error!(error = %e, "获取工具列表失败");
|
||||
e.to_string()
|
||||
})?;
|
||||
|
||||
debug!(tool_count = tools.len(), "返回工具列表");
|
||||
Ok(tools)
|
||||
@@ -382,6 +396,11 @@ pub async fn mcp_search_tools(
|
||||
/// # Requirements
|
||||
///
|
||||
/// - **9.5**: THE mcp_call_tool command SHALL call a tool and return the result
|
||||
#[tracing::instrument(
|
||||
name = "mcp_call_tool",
|
||||
skip(mcp_manager, arguments),
|
||||
fields(tool_name = %tool_name)
|
||||
)]
|
||||
#[tauri::command]
|
||||
pub async fn mcp_call_tool(
|
||||
mcp_manager: State<'_, McpManagerState>,
|
||||
@@ -390,9 +409,12 @@ pub async fn mcp_call_tool(
|
||||
) -> Result<McpToolResult, String> {
|
||||
info!(tool_name = %tool_name, "调用 MCP 工具命令");
|
||||
|
||||
let manager = mcp_manager.lock().await;
|
||||
let manager = async { mcp_manager.lock().await }
|
||||
.instrument(tracing::debug_span!("mcp_call_tool.acquire_manager"))
|
||||
.await;
|
||||
let result = manager
|
||||
.call_tool(&tool_name, arguments)
|
||||
.instrument(tracing::info_span!("mcp_call_tool.call_tool"))
|
||||
.await
|
||||
.map_err(|e| {
|
||||
error!(tool_name = %tool_name, error = %e, "调用工具失败");
|
||||
|
||||
@@ -3,12 +3,15 @@
|
||||
//! 提供项目记忆系统(角色、世界观、风格指南、大纲)的前端 API。
|
||||
|
||||
use crate::database::DbConnection;
|
||||
use crate::logger;
|
||||
use crate::memory::{
|
||||
Character, CharacterCreateRequest, CharacterUpdateRequest, MemoryManager, OutlineNode,
|
||||
OutlineNodeCreateRequest, OutlineNodeUpdateRequest, ProjectMemory, StyleGuide,
|
||||
StyleGuideUpdateRequest, WorldBuilding, WorldBuildingUpdateRequest,
|
||||
};
|
||||
use crate::LogState;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::time::Instant;
|
||||
use tauri::State;
|
||||
|
||||
// ==================== 角色相关命令 ====================
|
||||
@@ -219,8 +222,41 @@ pub async fn outline_node_delete(db: State<'_, DbConnection>, id: String) -> Res
|
||||
#[tauri::command]
|
||||
pub async fn project_memory_get(
|
||||
db: State<'_, DbConnection>,
|
||||
logs: State<'_, LogState>,
|
||||
project_id: String,
|
||||
) -> Result<ProjectMemory, String> {
|
||||
let started_at = Instant::now();
|
||||
let sanitized_project_id = logger::sanitize_log_message(&project_id);
|
||||
logs.write().await.add(
|
||||
"info",
|
||||
&format!("[AgentDiag] project_memory_get.start project_id={sanitized_project_id}"),
|
||||
);
|
||||
let manager = MemoryManager::new(db.inner().clone());
|
||||
manager.get_project_memory(&project_id)
|
||||
match manager.get_project_memory(&project_id) {
|
||||
Ok(memory) => {
|
||||
logs.write().await.add(
|
||||
"info",
|
||||
&format!(
|
||||
"[AgentDiag] project_memory_get.success project_id={sanitized_project_id} duration_ms={} characters={} outline={} has_world_building={} has_style_guide={}",
|
||||
started_at.elapsed().as_millis(),
|
||||
memory.characters.len(),
|
||||
memory.outline.len(),
|
||||
memory.world_building.is_some(),
|
||||
memory.style_guide.is_some(),
|
||||
),
|
||||
);
|
||||
Ok(memory)
|
||||
}
|
||||
Err(error) => {
|
||||
logs.write().await.add(
|
||||
"error",
|
||||
&format!(
|
||||
"[AgentDiag] project_memory_get.error project_id={sanitized_project_id} duration_ms={} error={}",
|
||||
started_at.elapsed().as_millis(),
|
||||
logger::sanitize_log_message(&error),
|
||||
),
|
||||
);
|
||||
Err(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -432,7 +432,7 @@ pub async fn aster_agent_theme_context_search(
|
||||
};
|
||||
|
||||
state.remove_cancel_token(&session_id).await;
|
||||
if let Err(error) = AsterAgentWrapper::delete_session_sync(&db, &session_id) {
|
||||
if let Err(error) = AsterAgentWrapper::delete_session(&db, &session_id).await {
|
||||
tracing::warn!(
|
||||
"[ThemeContextSearch] 删除临时会话失败: session={}, error={}",
|
||||
session_id,
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
//! - 7.2: 凭证健康状态变化时在 1 秒内更新托盘图标
|
||||
//! - 7.3: 托盘菜单打开时获取并显示最新信息
|
||||
|
||||
use crate::tray::{TrayIconStatus, TrayStateSnapshot};
|
||||
use crate::tray::{TrayIconStatus, TrayQuickModelGroup, TrayStateSnapshot};
|
||||
use crate::TrayManagerState;
|
||||
use tauri::State;
|
||||
use tracing::{debug, info};
|
||||
@@ -33,6 +33,7 @@ pub async fn sync_tray_state(
|
||||
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 {
|
||||
@@ -53,6 +54,11 @@ pub async fn sync_tray_state(
|
||||
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
|
||||
@@ -226,6 +232,7 @@ pub async fn refresh_tray_with_stats(
|
||||
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 {
|
||||
@@ -246,6 +253,11 @@ pub async fn refresh_tray_with_stats(
|
||||
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,
|
||||
};
|
||||
|
||||
// 更新状态并刷新菜单
|
||||
@@ -261,3 +273,37 @@ pub async fn refresh_tray_with_stats(
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 同步托盘中的快速模型切换菜单
|
||||
///
|
||||
/// 由前端在模型或 Provider 变化时调用,用于更新系统托盘中的当前模型信息与快捷切换列表。
|
||||
#[tauri::command]
|
||||
pub async fn sync_tray_model_shortcuts(
|
||||
tray_state: State<'_, TrayManagerState<tauri::Wry>>,
|
||||
current_model_provider_type: String,
|
||||
current_model_provider_label: String,
|
||||
current_model: String,
|
||||
current_theme_label: String,
|
||||
quick_model_groups: Vec<TrayQuickModelGroup>,
|
||||
) -> 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.current_model_provider_type = current_model_provider_type;
|
||||
current_state.current_model_provider_label = current_model_provider_label;
|
||||
current_state.current_model = current_model;
|
||||
current_state.current_theme_label = current_theme_label;
|
||||
current_state.quick_model_groups = quick_model_groups;
|
||||
|
||||
tray_manager
|
||||
.update_state(current_state)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
debug!("托盘模型快捷菜单已同步");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -35,6 +35,7 @@ use lime_agent::{
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tauri::{AppHandle, Emitter, State};
|
||||
use tracing::Instrument;
|
||||
|
||||
const CODE_EXECUTION_EXTENSION_NAME: &str = "code_execution";
|
||||
|
||||
@@ -321,6 +322,15 @@ pub async fn chat_get_messages(
|
||||
/// 发送消息并获取流式响应
|
||||
///
|
||||
/// 统一的消息发送入口,根据会话模式选择处理方式
|
||||
#[tracing::instrument(
|
||||
name = "chat_send_message",
|
||||
skip(app, db, agent_state, config_manager, request),
|
||||
fields(
|
||||
session_id = %request.session_id,
|
||||
event_name = %request.event_name,
|
||||
image_count = request.images.as_ref().map(|items| items.len()).unwrap_or(0)
|
||||
)
|
||||
)]
|
||||
#[tauri::command]
|
||||
pub async fn chat_send_message(
|
||||
app: AppHandle,
|
||||
@@ -368,6 +378,7 @@ pub async fn chat_send_message(
|
||||
.map_err(|e| format!("获取会话失败: {e}"))?
|
||||
.ok_or_else(|| "会话不存在".to_string())
|
||||
})
|
||||
.instrument(tracing::info_span!("chat_send_message.load_session"))
|
||||
.await
|
||||
.map_err(|e| format!("任务执行失败: {e}"))??
|
||||
};
|
||||
@@ -378,15 +389,18 @@ pub async fn chat_send_message(
|
||||
let config = config_manager.config();
|
||||
apply_web_search_runtime_env(&config);
|
||||
let working_dir = std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from("."));
|
||||
let merged_system_prompt = merge_system_prompt_with_web_search(
|
||||
merge_system_prompt_with_memory_sources(
|
||||
merge_system_prompt_with_memory_profile(session.system_prompt.clone(), &config),
|
||||
&config,
|
||||
&working_dir,
|
||||
None,
|
||||
),
|
||||
&config,
|
||||
);
|
||||
let merged_system_prompt = tracing::debug_span!("chat_send_message.prepare_system_prompt")
|
||||
.in_scope(|| {
|
||||
merge_system_prompt_with_web_search(
|
||||
merge_system_prompt_with_memory_sources(
|
||||
merge_system_prompt_with_memory_profile(session.system_prompt.clone(), &config),
|
||||
&config,
|
||||
&working_dir,
|
||||
None,
|
||||
),
|
||||
&config,
|
||||
)
|
||||
});
|
||||
|
||||
let mode_default_web_search = false;
|
||||
let request_tool_policy = resolve_request_tool_policy_with_mode(
|
||||
@@ -416,6 +430,11 @@ pub async fn chat_send_message(
|
||||
config.memory.enabled,
|
||||
&request_tool_policy,
|
||||
)
|
||||
.instrument(tracing::info_span!(
|
||||
"chat_send_message.dispatch_agent",
|
||||
effective_web_search = request_tool_policy.effective_web_search,
|
||||
search_mode = %request_tool_policy.search_mode.as_str()
|
||||
))
|
||||
.await;
|
||||
|
||||
let total_elapsed = start_time.elapsed();
|
||||
@@ -429,6 +448,17 @@ pub async fn chat_send_message(
|
||||
}
|
||||
|
||||
/// 使用 Aster Agent 发送消息
|
||||
#[tracing::instrument(
|
||||
name = "send_message_with_aster",
|
||||
skip(app, db, agent_state, message, system_prompt, request_tool_policy),
|
||||
fields(
|
||||
session_id = %session_id,
|
||||
event_name = %event_name,
|
||||
message_len = message.len(),
|
||||
include_context_trace = include_context_trace,
|
||||
effective_web_search = request_tool_policy.effective_web_search
|
||||
)
|
||||
)]
|
||||
async fn send_message_with_aster(
|
||||
app: &AppHandle,
|
||||
db: &DbConnection,
|
||||
@@ -449,16 +479,29 @@ async fn send_message_with_aster(
|
||||
|
||||
// 确保 Agent 已初始化
|
||||
let init_start = std::time::Instant::now();
|
||||
if !agent_state.is_initialized().await {
|
||||
agent_state.init_agent_with_db(db).await?;
|
||||
async {
|
||||
if !agent_state.is_initialized().await {
|
||||
agent_state.init_agent_with_db(db).await?;
|
||||
}
|
||||
ensure_browser_mcp_tools_registered(agent_state).await?;
|
||||
Ok::<(), String>(())
|
||||
}
|
||||
ensure_browser_mcp_tools_registered(agent_state).await?;
|
||||
.instrument(tracing::info_span!(
|
||||
"send_message_with_aster.ensure_agent_ready"
|
||||
))
|
||||
.await?;
|
||||
let init_elapsed = init_start.elapsed();
|
||||
tracing::debug!("[UnifiedChat] Agent 初始化检查耗时: {:?}", init_elapsed);
|
||||
|
||||
// 检查 Provider 是否已配置
|
||||
let provider_check_start = std::time::Instant::now();
|
||||
if !agent_state.is_provider_configured().await {
|
||||
let is_provider_configured =
|
||||
async { Ok::<bool, String>(agent_state.is_provider_configured().await) }
|
||||
.instrument(tracing::debug_span!(
|
||||
"send_message_with_aster.check_provider_config"
|
||||
))
|
||||
.await?;
|
||||
if !is_provider_configured {
|
||||
return Err("Provider 未配置,请先配置凭证".to_string());
|
||||
}
|
||||
let provider_check_elapsed = provider_check_start.elapsed();
|
||||
@@ -533,6 +576,9 @@ async fn send_message_with_aster(
|
||||
request_tool_policy,
|
||||
&mut web_search_tracker,
|
||||
)
|
||||
.instrument(tracing::info_span!(
|
||||
"send_message_with_aster.web_search_preflight"
|
||||
))
|
||||
.await;
|
||||
match preflight {
|
||||
Ok(preflight_execution) => {
|
||||
@@ -578,6 +624,7 @@ async fn send_message_with_aster(
|
||||
|
||||
let stream_result = agent
|
||||
.reply(user_message, session_config, Some(cancel_token.clone()))
|
||||
.instrument(tracing::info_span!("send_message_with_aster.reply"))
|
||||
.await;
|
||||
|
||||
let mut first_chunk_time: Option<std::time::Instant> = None;
|
||||
@@ -588,7 +635,13 @@ async fn send_message_with_aster(
|
||||
|
||||
match stream_result {
|
||||
Ok(mut stream) => {
|
||||
while let Some(event_result) = stream.next().await {
|
||||
while let Some(event_result) = stream
|
||||
.next()
|
||||
.instrument(tracing::trace_span!(
|
||||
"send_message_with_aster.next_stream_event"
|
||||
))
|
||||
.await
|
||||
{
|
||||
match event_result {
|
||||
Ok(agent_event) => {
|
||||
// 记录首个 chunk 时间(TTFB)
|
||||
|
||||
@@ -20,6 +20,7 @@ use crate::workspace::{
|
||||
Workspace, WorkspaceManager, WorkspaceSettings, WorkspaceType, WorkspaceUpdate,
|
||||
};
|
||||
use lime_core::app_paths;
|
||||
use lime_core::database::lock_db;
|
||||
use lime_services::project_context_builder::ProjectContextBuilder;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::PathBuf;
|
||||
@@ -427,7 +428,7 @@ pub async fn get_project_context(
|
||||
db: State<'_, DbConnection>,
|
||||
project_id: String,
|
||||
) -> Result<ProjectContext, String> {
|
||||
let conn = db.lock().map_err(|e| format!("数据库锁定失败: {e}"))?;
|
||||
let conn = lock_db(&db).map_err(|e| format!("数据库锁定失败: {e}"))?;
|
||||
ProjectContextBuilder::build_context(&conn, &project_id).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
@@ -447,8 +448,7 @@ pub async fn build_project_system_prompt(
|
||||
db: State<'_, DbConnection>,
|
||||
project_id: String,
|
||||
) -> Result<String, String> {
|
||||
let conn = db.lock().map_err(|e| format!("数据库锁定失败: {e}"))?;
|
||||
let context =
|
||||
ProjectContextBuilder::build_context(&conn, &project_id).map_err(|e| e.to_string())?;
|
||||
Ok(ProjectContextBuilder::build_system_prompt(&context))
|
||||
let conn = lock_db(&db).map_err(|e| format!("数据库锁定失败: {e}"))?;
|
||||
ProjectContextBuilder::build_system_prompt_for_project(&conn, &project_id)
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
@@ -115,7 +115,7 @@ pub async fn handle_command(
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
if let Some(result) = agent_sessions::try_handle(state, cmd, args.as_ref())? {
|
||||
if let Some(result) = agent_sessions::try_handle(state, cmd, args.as_ref()).await? {
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,136 +1,12 @@
|
||||
use super::{args_or_default, get_db};
|
||||
use crate::database::dao::agent::AgentDao;
|
||||
use crate::dev_bridge::DevBridgeState;
|
||||
use lime_core::agent::types::AgentSession;
|
||||
use serde_json::Value as JsonValue;
|
||||
|
||||
type DynError = Box<dyn std::error::Error>;
|
||||
|
||||
fn get_session_id(args: &JsonValue) -> String {
|
||||
args["session_id"]
|
||||
.as_str()
|
||||
.or_else(|| args["sessionId"].as_str())
|
||||
.unwrap_or("")
|
||||
.to_string()
|
||||
}
|
||||
|
||||
pub(super) fn try_handle(
|
||||
state: &DevBridgeState,
|
||||
cmd: &str,
|
||||
args: Option<&JsonValue>,
|
||||
pub(super) async fn try_handle(
|
||||
_state: &DevBridgeState,
|
||||
_cmd: &str,
|
||||
_args: Option<&JsonValue>,
|
||||
) -> Result<Option<JsonValue>, DynError> {
|
||||
let result = match cmd {
|
||||
"agent_create_session" => {
|
||||
let args = args_or_default(args);
|
||||
let provider_type = args["provider_type"].as_str().unwrap_or("").to_string();
|
||||
let model = args["model"].as_str().map(|value| value.to_string());
|
||||
let system_prompt = args["system_prompt"]
|
||||
.as_str()
|
||||
.map(|value| value.to_string());
|
||||
let execution_strategy = args["execution_strategy"]
|
||||
.as_str()
|
||||
.map(|value| value.to_string())
|
||||
.or_else(|| {
|
||||
args["executionStrategy"]
|
||||
.as_str()
|
||||
.map(|value| value.to_string())
|
||||
})
|
||||
.unwrap_or_else(|| "react".to_string());
|
||||
|
||||
let db = get_db(state)?;
|
||||
let session_id = uuid::Uuid::new_v4().to_string();
|
||||
let model_name = model
|
||||
.clone()
|
||||
.unwrap_or_else(|| "claude-sonnet-4-20250514".to_string());
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
let session = AgentSession {
|
||||
id: session_id.clone(),
|
||||
model: model_name.clone(),
|
||||
messages: Vec::new(),
|
||||
system_prompt,
|
||||
title: None,
|
||||
working_dir: None,
|
||||
execution_strategy: Some(execution_strategy.clone()),
|
||||
created_at: now.clone(),
|
||||
updated_at: now,
|
||||
};
|
||||
|
||||
let conn = db.lock().map_err(|e| format!("数据库锁定失败: {e}"))?;
|
||||
AgentDao::create_session(&conn, &session).map_err(|e| format!("创建会话失败: {e}"))?;
|
||||
|
||||
serde_json::json!({
|
||||
"session_id": session_id,
|
||||
"credential_name": "Lime",
|
||||
"credential_uuid": null,
|
||||
"provider_type": provider_type,
|
||||
"model": model_name,
|
||||
"execution_strategy": execution_strategy
|
||||
})
|
||||
}
|
||||
"agent_list_sessions" => {
|
||||
let db = get_db(state)?;
|
||||
let conn = db.lock().map_err(|e| format!("数据库锁定失败: {e}"))?;
|
||||
let sessions =
|
||||
AgentDao::list_sessions(&conn).map_err(|e| format!("获取会话列表失败: {e}"))?;
|
||||
|
||||
let result: Vec<JsonValue> = sessions
|
||||
.into_iter()
|
||||
.map(|session| {
|
||||
let messages_count =
|
||||
AgentDao::get_message_count(&conn, &session.id).unwrap_or(0);
|
||||
serde_json::json!({
|
||||
"session_id": session.id,
|
||||
"provider_type": "aster",
|
||||
"model": session.model,
|
||||
"created_at": session.created_at,
|
||||
"last_activity": session.updated_at,
|
||||
"messages_count": messages_count
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
serde_json::json!(result)
|
||||
}
|
||||
"agent_get_session" => {
|
||||
let args = args_or_default(args);
|
||||
let session_id = get_session_id(&args);
|
||||
let db = get_db(state)?;
|
||||
let conn = db.lock().map_err(|e| format!("数据库锁定失败: {e}"))?;
|
||||
let session = AgentDao::get_session(&conn, &session_id)
|
||||
.map_err(|e| format!("获取会话失败: {e}"))?
|
||||
.ok_or("会话不存在")?;
|
||||
let messages_count = AgentDao::get_message_count(&conn, &session_id).unwrap_or(0);
|
||||
|
||||
serde_json::json!({
|
||||
"session_id": session.id,
|
||||
"provider_type": "aster",
|
||||
"model": session.model,
|
||||
"created_at": session.created_at,
|
||||
"last_activity": session.updated_at,
|
||||
"messages_count": messages_count
|
||||
})
|
||||
}
|
||||
"agent_delete_session" => {
|
||||
let args = args_or_default(args);
|
||||
let session_id = get_session_id(&args);
|
||||
let db = get_db(state)?;
|
||||
let conn = db.lock().map_err(|e| format!("数据库锁定失败: {e}"))?;
|
||||
AgentDao::delete_session(&conn, &session_id)
|
||||
.map_err(|e| format!("删除会话失败: {e}"))?;
|
||||
serde_json::json!({ "success": true })
|
||||
}
|
||||
"agent_get_session_messages" => {
|
||||
let args = args_or_default(args);
|
||||
let session_id = get_session_id(&args);
|
||||
let db = get_db(state)?;
|
||||
let conn = db.lock().map_err(|e| format!("数据库锁定失败: {e}"))?;
|
||||
serde_json::to_value(
|
||||
AgentDao::get_messages(&conn, &session_id)
|
||||
.map_err(|e| format!("获取消息失败: {e}"))?,
|
||||
)?
|
||||
}
|
||||
_ => return Ok(None),
|
||||
};
|
||||
|
||||
Ok(Some(result))
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
use super::{args_or_default, get_string_arg, parse_optional_nested_arg};
|
||||
use crate::dev_bridge::DevBridgeState;
|
||||
use serde_json::Value as JsonValue;
|
||||
use std::time::Instant;
|
||||
|
||||
type DynError = Box<dyn std::error::Error>;
|
||||
|
||||
@@ -56,13 +57,46 @@ pub(super) fn try_handle(
|
||||
"project_memory_get" => {
|
||||
let args = args_or_default(args);
|
||||
let project_id = get_string_arg(&args, "project_id", "projectId")?;
|
||||
let started_at = Instant::now();
|
||||
if let Ok(mut logs) = state.logs.try_write() {
|
||||
logs.add(
|
||||
"info",
|
||||
&format!(
|
||||
"[AgentDiag] project_memory_get.devbridge.start project_id={}",
|
||||
crate::logger::sanitize_log_message(&project_id)
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
match &state.db {
|
||||
Some(db) => {
|
||||
let manager = crate::memory::MemoryManager::new(db.clone());
|
||||
let memory = manager
|
||||
.get_project_memory(&project_id)
|
||||
.map_err(|e| format!("获取项目记忆失败: {e}"))?;
|
||||
let memory = manager.get_project_memory(&project_id).map_err(|e| {
|
||||
if let Ok(mut logs) = state.logs.try_write() {
|
||||
logs.add(
|
||||
"error",
|
||||
&format!(
|
||||
"[AgentDiag] project_memory_get.devbridge.error project_id={} duration_ms={} error={}",
|
||||
crate::logger::sanitize_log_message(&project_id),
|
||||
started_at.elapsed().as_millis(),
|
||||
crate::logger::sanitize_log_message(&e)
|
||||
),
|
||||
);
|
||||
}
|
||||
format!("获取项目记忆失败: {e}")
|
||||
})?;
|
||||
if let Ok(mut logs) = state.logs.try_write() {
|
||||
logs.add(
|
||||
"info",
|
||||
&format!(
|
||||
"[AgentDiag] project_memory_get.devbridge.success project_id={} duration_ms={} characters={} outline={}",
|
||||
crate::logger::sanitize_log_message(&project_id),
|
||||
started_at.elapsed().as_millis(),
|
||||
memory.characters.len(),
|
||||
memory.outline.len()
|
||||
),
|
||||
);
|
||||
}
|
||||
serde_json::to_value(memory)?
|
||||
}
|
||||
None => return Err("Database not initialized".into()),
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use super::{args_or_default, get_string_arg, parse_nested_arg};
|
||||
use super::{args_or_default, parse_nested_arg};
|
||||
use crate::dev_bridge::DevBridgeState;
|
||||
use serde_json::Value as JsonValue;
|
||||
|
||||
@@ -70,29 +70,6 @@ pub(super) async fn try_handle(
|
||||
serde_json::json!([])
|
||||
}
|
||||
}
|
||||
"aster_session_get" => {
|
||||
let args = args_or_default(args);
|
||||
let session_id = get_string_arg(&args, "session_id", "sessionId")?;
|
||||
|
||||
if let Some(db) = &state.db {
|
||||
serde_json::to_value(
|
||||
crate::agent::AsterAgentWrapper::get_session_sync(db, &session_id)
|
||||
.map_err(|e| format!("获取 Aster 会话失败: {e}"))?,
|
||||
)?
|
||||
} else {
|
||||
return Err("Database not initialized".into());
|
||||
}
|
||||
}
|
||||
"aster_session_list" => {
|
||||
if let Some(db) = &state.db {
|
||||
serde_json::to_value(
|
||||
crate::agent::AsterAgentWrapper::list_sessions_sync(db)
|
||||
.map_err(|e| format!("获取 Aster 会话列表失败: {e}"))?,
|
||||
)?
|
||||
} else {
|
||||
serde_json::json!([])
|
||||
}
|
||||
}
|
||||
"report_frontend_crash" => {
|
||||
let args = args_or_default(args);
|
||||
let report: crate::app::commands::FrontendCrashReport =
|
||||
@@ -130,6 +107,56 @@ pub(super) async fn try_handle(
|
||||
|
||||
serde_json::json!({ "success": true })
|
||||
}
|
||||
"report_frontend_debug_log" => {
|
||||
let args = args_or_default(args);
|
||||
let report: crate::app::commands::FrontendDebugLogReport =
|
||||
parse_nested_arg(&args, "report")?;
|
||||
|
||||
let sanitized_message = crate::logger::sanitize_log_message(&report.message);
|
||||
let sanitized_category = report
|
||||
.category
|
||||
.as_deref()
|
||||
.map(crate::logger::sanitize_log_message)
|
||||
.unwrap_or_else(|| "general".to_string());
|
||||
let level = match report
|
||||
.level
|
||||
.as_deref()
|
||||
.unwrap_or("info")
|
||||
.trim()
|
||||
.to_ascii_lowercase()
|
||||
.as_str()
|
||||
{
|
||||
"debug" => "debug",
|
||||
"warn" | "warning" => "warn",
|
||||
"error" => "error",
|
||||
_ => "info",
|
||||
};
|
||||
let context_preview = report
|
||||
.context
|
||||
.as_ref()
|
||||
.and_then(|context| serde_json::to_string(context).ok())
|
||||
.map(|value| crate::logger::sanitize_log_message(&value))
|
||||
.map(|value| {
|
||||
const MAX_LEN: usize = 1200;
|
||||
if value.len() > MAX_LEN {
|
||||
format!("{}...", &value[..MAX_LEN])
|
||||
} else {
|
||||
value
|
||||
}
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
let message = if context_preview.is_empty() {
|
||||
format!("[FrontendDebug] category={sanitized_category} message={sanitized_message}")
|
||||
} else {
|
||||
format!(
|
||||
"[FrontendDebug] category={sanitized_category} message={sanitized_message} context={context_preview}"
|
||||
)
|
||||
};
|
||||
|
||||
state.logs.write().await.add(level, &message);
|
||||
serde_json::json!({ "success": true })
|
||||
}
|
||||
_ => return Ok(None),
|
||||
};
|
||||
|
||||
|
||||
@@ -54,6 +54,7 @@ mod data;
|
||||
#[allow(dead_code)]
|
||||
mod dev_bridge;
|
||||
mod logger;
|
||||
mod profiling;
|
||||
mod theme;
|
||||
use lime_core::models;
|
||||
|
||||
|
||||
@@ -0,0 +1,348 @@
|
||||
use chrono::Local;
|
||||
use lime_core::app_paths;
|
||||
use std::env;
|
||||
use std::path::PathBuf;
|
||||
use tracing::Subscriber;
|
||||
use tracing_subscriber::layer::Layer;
|
||||
use tracing_subscriber::prelude::*;
|
||||
use tracing_subscriber::registry::LookupSpan;
|
||||
use tracing_subscriber::{fmt, util::SubscriberInitExt, EnvFilter};
|
||||
|
||||
#[cfg(feature = "dev-profiling")]
|
||||
use std::fs::{self, File};
|
||||
|
||||
#[cfg(feature = "dev-profiling")]
|
||||
type TraceFlushGuard = tracing_chrome::FlushGuard;
|
||||
#[cfg(not(feature = "dev-profiling"))]
|
||||
type TraceFlushGuard = ();
|
||||
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
|
||||
struct ProfileModes {
|
||||
trace: bool,
|
||||
tokio_console: bool,
|
||||
}
|
||||
|
||||
impl ProfileModes {
|
||||
fn from_env() -> Self {
|
||||
let mut modes = Self::default();
|
||||
let raw = env::var("LIME_PROFILE").unwrap_or_default();
|
||||
|
||||
for token in raw
|
||||
.split(|ch: char| matches!(ch, ',' | '+' | '|' | ';' | ' '))
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
{
|
||||
match token.to_ascii_lowercase().as_str() {
|
||||
"trace" | "chrome" | "perfetto" => modes.trace = true,
|
||||
"console" | "tokio-console" | "tokio_console" => modes.tokio_console = true,
|
||||
"all" => {
|
||||
modes.trace = true;
|
||||
modes.tokio_console = true;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
modes
|
||||
}
|
||||
|
||||
fn as_str(self) -> &'static str {
|
||||
match (self.trace, self.tokio_console) {
|
||||
(false, false) => "disabled",
|
||||
(true, false) => "trace",
|
||||
(false, true) => "console",
|
||||
(true, true) => "trace+console",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct ProfilingConfig {
|
||||
requested_modes: ProfileModes,
|
||||
enabled_modes: ProfileModes,
|
||||
profiling_supported_build: bool,
|
||||
trace_path: Option<PathBuf>,
|
||||
tokio_console_bind: Option<String>,
|
||||
}
|
||||
|
||||
impl ProfilingConfig {
|
||||
fn from_env() -> Self {
|
||||
let requested_modes = ProfileModes::from_env();
|
||||
let profiling_supported_build = is_profiling_supported_build();
|
||||
let enabled_modes = if profiling_supported_build {
|
||||
ProfileModes {
|
||||
trace: requested_modes.trace && is_trace_feature_enabled(),
|
||||
tokio_console: requested_modes.tokio_console && is_tokio_console_feature_enabled(),
|
||||
}
|
||||
} else {
|
||||
ProfileModes::default()
|
||||
};
|
||||
let trace_path = enabled_modes.trace.then(resolve_trace_path);
|
||||
let tokio_console_bind = enabled_modes.tokio_console.then(resolve_tokio_console_bind);
|
||||
|
||||
Self {
|
||||
requested_modes,
|
||||
enabled_modes,
|
||||
profiling_supported_build,
|
||||
trace_path,
|
||||
tokio_console_bind,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct ProfilingGuard {
|
||||
#[cfg_attr(not(feature = "dev-profiling"), allow(dead_code))]
|
||||
chrome_guard: Option<TraceFlushGuard>,
|
||||
trace_path: Option<PathBuf>,
|
||||
}
|
||||
|
||||
impl ProfilingGuard {
|
||||
fn trace_path(&self) -> Option<&PathBuf> {
|
||||
self.trace_path.as_ref()
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for ProfilingGuard {
|
||||
fn drop(&mut self) {
|
||||
#[cfg(feature = "dev-profiling")]
|
||||
if let Some(guard) = &self.chrome_guard {
|
||||
guard.flush();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn init() -> ProfilingGuard {
|
||||
let config = ProfilingConfig::from_env();
|
||||
|
||||
match try_init(&config) {
|
||||
Ok(guard) => {
|
||||
match (guard.trace_path(), config.tokio_console_bind.as_deref()) {
|
||||
(Some(path), Some(bind)) => {
|
||||
tracing::info!(
|
||||
trace_path = %path.display(),
|
||||
tokio_console_bind = %bind,
|
||||
profile_mode = %config.enabled_modes.as_str(),
|
||||
"[Profiling] 已启用 trace 导出与 Tokio Console"
|
||||
);
|
||||
}
|
||||
(Some(path), None) => {
|
||||
tracing::info!(
|
||||
trace_path = %path.display(),
|
||||
profile_mode = %config.enabled_modes.as_str(),
|
||||
"[Profiling] 已启用 trace 导出,可用 Perfetto 打开"
|
||||
);
|
||||
}
|
||||
(None, Some(bind)) => {
|
||||
tracing::info!(
|
||||
tokio_console_bind = %bind,
|
||||
profile_mode = %config.enabled_modes.as_str(),
|
||||
"[Profiling] 已启用 Tokio Console 遥测"
|
||||
);
|
||||
}
|
||||
(None, None) => {
|
||||
tracing::debug!(
|
||||
profile_mode = %config.enabled_modes.as_str(),
|
||||
"[Profiling] tracing subscriber 已初始化"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if !config.profiling_supported_build
|
||||
&& config.requested_modes != ProfileModes::default()
|
||||
{
|
||||
tracing::warn!(
|
||||
requested_profile_mode = %config.requested_modes.as_str(),
|
||||
"[Profiling] 当前是 release/生产构建,已忽略开发环境 profiling 配置"
|
||||
);
|
||||
}
|
||||
|
||||
if config.profiling_supported_build
|
||||
&& config.requested_modes.trace
|
||||
&& !config.enabled_modes.trace
|
||||
{
|
||||
tracing::warn!(
|
||||
requested_profile_mode = %config.requested_modes.as_str(),
|
||||
"[Profiling] 已请求 trace 导出,但当前构建未启用 dev-profiling feature;请使用性能启动脚本"
|
||||
);
|
||||
}
|
||||
|
||||
if config.profiling_supported_build
|
||||
&& config.requested_modes.tokio_console
|
||||
&& !config.enabled_modes.tokio_console
|
||||
{
|
||||
tracing::warn!(
|
||||
requested_profile_mode = %config.requested_modes.as_str(),
|
||||
"[Profiling] 已请求 Tokio Console,但当前构建未启用 tokio-console feature;请使用性能启动脚本"
|
||||
);
|
||||
}
|
||||
guard
|
||||
}
|
||||
Err(error) => {
|
||||
eprintln!("[Profiling] 初始化失败: {error}");
|
||||
ProfilingGuard::default()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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"))
|
||||
.map_err(|error| format!("创建 EnvFilter 失败: {error}"))?;
|
||||
|
||||
let fmt_layer = fmt::layer()
|
||||
.compact()
|
||||
.with_target(true)
|
||||
.with_thread_ids(true)
|
||||
.with_thread_names(true)
|
||||
.with_file(true)
|
||||
.with_line_number(true);
|
||||
|
||||
let subscriber = tracing_subscriber::registry()
|
||||
.with(filter_layer)
|
||||
.with(fmt_layer);
|
||||
let chrome_guard = if let Some(tokio_console_layer) = build_tokio_console_layer::<_>(config) {
|
||||
init_subscriber_with_optional_chrome(subscriber.with(tokio_console_layer), config)?
|
||||
} else {
|
||||
init_subscriber_with_optional_chrome(subscriber, config)?
|
||||
};
|
||||
|
||||
Ok(ProfilingGuard {
|
||||
chrome_guard,
|
||||
trace_path: config.trace_path.clone(),
|
||||
})
|
||||
}
|
||||
|
||||
fn init_subscriber_with_optional_chrome<S>(
|
||||
subscriber: S,
|
||||
config: &ProfilingConfig,
|
||||
) -> Result<Option<TraceFlushGuard>, String>
|
||||
where
|
||||
S: Subscriber + for<'span> LookupSpan<'span> + Send + Sync + 'static,
|
||||
{
|
||||
#[cfg(feature = "dev-profiling")]
|
||||
if let Some((chrome_layer, chrome_guard)) = build_chrome_layer(&subscriber, config)? {
|
||||
subscriber
|
||||
.with(chrome_layer)
|
||||
.try_init()
|
||||
.map_err(|error| format!("注册 tracing subscriber 失败: {error}"))?;
|
||||
return Ok(Some(chrome_guard));
|
||||
}
|
||||
|
||||
#[cfg(feature = "dev-profiling")]
|
||||
{
|
||||
subscriber
|
||||
.try_init()
|
||||
.map_err(|error| format!("注册 tracing subscriber 失败: {error}"))?;
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "dev-profiling"))]
|
||||
{
|
||||
let _ = config;
|
||||
subscriber
|
||||
.try_init()
|
||||
.map_err(|error| format!("注册 tracing subscriber 失败: {error}"))?;
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "dev-profiling")]
|
||||
fn build_chrome_layer<S>(
|
||||
_subscriber: &S,
|
||||
config: &ProfilingConfig,
|
||||
) -> Result<Option<(tracing_chrome::ChromeLayer<S>, TraceFlushGuard)>, String>
|
||||
where
|
||||
S: Subscriber + for<'span> LookupSpan<'span> + Send + Sync,
|
||||
{
|
||||
let Some(trace_path) = config.trace_path.as_ref() else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
if let Some(parent) = trace_path.parent() {
|
||||
fs::create_dir_all(parent)
|
||||
.map_err(|error| format!("创建 profiling 目录失败({}): {error}", parent.display()))?;
|
||||
}
|
||||
|
||||
let file = File::create(trace_path)
|
||||
.map_err(|error| format!("创建 trace 文件失败({}): {error}", trace_path.display()))?;
|
||||
|
||||
let (layer, guard) = tracing_chrome::ChromeLayerBuilder::new()
|
||||
.writer(file)
|
||||
.include_args(false)
|
||||
.include_locations(true)
|
||||
.build();
|
||||
|
||||
Ok(Some((layer, guard)))
|
||||
}
|
||||
|
||||
fn build_tokio_console_layer<S>(
|
||||
config: &ProfilingConfig,
|
||||
) -> Option<Box<dyn Layer<S> + Send + Sync + 'static>>
|
||||
where
|
||||
S: Subscriber + for<'span> LookupSpan<'span> + Send + Sync + 'static,
|
||||
{
|
||||
if !config.enabled_modes.tokio_console {
|
||||
return None;
|
||||
}
|
||||
|
||||
#[cfg(feature = "tokio-console")]
|
||||
{
|
||||
return Some(console_subscriber::ConsoleLayer::builder().spawn().boxed());
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "tokio-console"))]
|
||||
{
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn is_tokio_console_feature_enabled() -> bool {
|
||||
cfg!(feature = "tokio-console")
|
||||
}
|
||||
|
||||
fn is_trace_feature_enabled() -> bool {
|
||||
cfg!(feature = "dev-profiling")
|
||||
}
|
||||
|
||||
fn is_profiling_supported_build() -> bool {
|
||||
cfg!(debug_assertions)
|
||||
}
|
||||
|
||||
fn resolve_trace_path() -> PathBuf {
|
||||
if let Some(custom_path) = env::var("LIME_PROFILE_TRACE_PATH")
|
||||
.ok()
|
||||
.map(|value| value.trim().to_string())
|
||||
.filter(|value| !value.is_empty())
|
||||
{
|
||||
return PathBuf::from(custom_path);
|
||||
}
|
||||
|
||||
let directory = app_paths::best_effort_runtime_subdir("profiles");
|
||||
let file_name = format!(
|
||||
"lime-trace-{}-pid{}.json",
|
||||
Local::now().format("%Y%m%d-%H%M%S"),
|
||||
std::process::id()
|
||||
);
|
||||
directory.join(file_name)
|
||||
}
|
||||
|
||||
fn resolve_tokio_console_bind() -> String {
|
||||
env::var("TOKIO_CONSOLE_BIND")
|
||||
.ok()
|
||||
.map(|value| value.trim().to_string())
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or_else(|| "127.0.0.1:6669".to_string())
|
||||
}
|
||||
@@ -1,14 +1,13 @@
|
||||
use chrono::Utc;
|
||||
use lime_agent::TauriAgentEvent;
|
||||
use lime_core::database::dao::agent_timeline::{
|
||||
AgentRequestOption, AgentRequestQuestion, AgentThreadItem, AgentThreadItemPayload,
|
||||
AgentThreadItemStatus, AgentThreadTurn, AgentThreadTurnStatus, AgentTimelineDao,
|
||||
AgentThreadItem, AgentThreadItemPayload, AgentThreadItemStatus, AgentThreadTurn,
|
||||
AgentThreadTurnStatus, AgentTimelineDao,
|
||||
};
|
||||
use lime_core::database::{lock_db, DbConnection};
|
||||
use serde_json::{json, Value};
|
||||
use std::collections::HashMap;
|
||||
use tauri::{AppHandle, Emitter};
|
||||
use uuid::Uuid;
|
||||
|
||||
const PROPOSED_PLAN_OPEN: &str = "<proposed_plan>";
|
||||
const PROPOSED_PLAN_CLOSE: &str = "</proposed_plan>";
|
||||
@@ -38,52 +37,10 @@ fn emit_event(app: &AppHandle, event_name: &str, event: &TauriAgentEvent) {
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_tool_name(name: &str) -> String {
|
||||
name.replace([' ', '-', '_'], "").to_lowercase()
|
||||
}
|
||||
|
||||
fn parse_json_str(raw: Option<&str>) -> Option<Value> {
|
||||
let value = raw?.trim();
|
||||
if value.is_empty() {
|
||||
return None;
|
||||
}
|
||||
serde_json::from_str::<Value>(value).ok()
|
||||
}
|
||||
|
||||
fn as_object(value: &Value) -> Option<&serde_json::Map<String, Value>> {
|
||||
value.as_object()
|
||||
}
|
||||
|
||||
fn pick_string_from_object(
|
||||
object: Option<&serde_json::Map<String, Value>>,
|
||||
keys: &[&str],
|
||||
) -> Option<String> {
|
||||
let object = object?;
|
||||
for key in keys {
|
||||
if let Some(value) = object.get(*key).and_then(Value::as_str) {
|
||||
let trimmed = value.trim();
|
||||
if !trimmed.is_empty() {
|
||||
return Some(trimmed.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn extract_tool_query(arguments: Option<&Value>) -> Option<String> {
|
||||
pick_string_from_object(
|
||||
arguments.and_then(as_object),
|
||||
&["q", "query", "question", "search", "search_query", "url"],
|
||||
)
|
||||
}
|
||||
|
||||
fn extract_command_text(arguments: Option<&Value>) -> Option<String> {
|
||||
pick_string_from_object(
|
||||
arguments.and_then(as_object),
|
||||
&["cmd", "command", "script", "text"],
|
||||
)
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct ExtractedFileArtifact {
|
||||
path: String,
|
||||
@@ -220,84 +177,6 @@ fn extract_proposed_plan_block(text: &str) -> Option<String> {
|
||||
}
|
||||
}
|
||||
|
||||
fn is_command_tool(name: &str) -> bool {
|
||||
matches!(
|
||||
normalize_tool_name(name).as_str(),
|
||||
"bash" | "execcommand" | "terminal" | "shell" | "runcommand"
|
||||
)
|
||||
}
|
||||
|
||||
fn is_web_tool(name: &str) -> bool {
|
||||
let normalized = normalize_tool_name(name);
|
||||
normalized.contains("websearch")
|
||||
|| normalized.contains("searchquery")
|
||||
|| normalized.contains("webfetch")
|
||||
|| normalized.contains("browser")
|
||||
|| normalized.contains("playwright")
|
||||
|| normalized == "search"
|
||||
}
|
||||
|
||||
fn is_user_input_action(action_type: &str) -> bool {
|
||||
matches!(action_type, "ask_user" | "elicitation")
|
||||
}
|
||||
|
||||
fn map_questions(raw: Option<&Value>) -> Option<Vec<AgentRequestQuestion>> {
|
||||
let items = raw?.as_array()?;
|
||||
let mut questions = Vec::new();
|
||||
|
||||
for item in items {
|
||||
let Some(object) = item.as_object() else {
|
||||
continue;
|
||||
};
|
||||
let Some(question) = object.get("question").and_then(Value::as_str) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
let options = object
|
||||
.get("options")
|
||||
.and_then(Value::as_array)
|
||||
.map(|values| {
|
||||
values
|
||||
.iter()
|
||||
.filter_map(|value| {
|
||||
let object = value.as_object()?;
|
||||
let label = object.get("label")?.as_str()?.trim().to_string();
|
||||
if label.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some(AgentRequestOption {
|
||||
label,
|
||||
description: object
|
||||
.get("description")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(str::to_string),
|
||||
})
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
});
|
||||
|
||||
questions.push(AgentRequestQuestion {
|
||||
question: question.trim().to_string(),
|
||||
header: object
|
||||
.get("header")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(str::to_string),
|
||||
options: options.filter(|values| !values.is_empty()),
|
||||
multi_select: object.get("multi_select").and_then(Value::as_bool),
|
||||
});
|
||||
}
|
||||
|
||||
if questions.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(questions)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct AgentTimelineRecorder {
|
||||
db: DbConnection,
|
||||
@@ -307,8 +186,6 @@ pub struct AgentTimelineRecorder {
|
||||
sequence_counter: i64,
|
||||
item_sequences: HashMap<String, i64>,
|
||||
item_statuses: HashMap<String, AgentThreadItemStatus>,
|
||||
assistant_text: String,
|
||||
reasoning_text: String,
|
||||
plan_text: Option<String>,
|
||||
turn_summary_text: Option<String>,
|
||||
}
|
||||
@@ -317,13 +194,15 @@ impl AgentTimelineRecorder {
|
||||
pub fn create(
|
||||
db: DbConnection,
|
||||
thread_id: impl Into<String>,
|
||||
turn_id: impl Into<String>,
|
||||
prompt_text: impl Into<String>,
|
||||
) -> Result<Self, String> {
|
||||
let thread_id = thread_id.into();
|
||||
let turn_id = turn_id.into();
|
||||
let prompt_text = prompt_text.into();
|
||||
let now = Utc::now().to_rfc3339();
|
||||
let turn = AgentThreadTurn {
|
||||
id: Uuid::new_v4().to_string(),
|
||||
id: turn_id.clone(),
|
||||
thread_id: thread_id.clone(),
|
||||
prompt_text,
|
||||
status: AgentThreadTurnStatus::Running,
|
||||
@@ -343,13 +222,11 @@ impl AgentTimelineRecorder {
|
||||
Ok(Self {
|
||||
db,
|
||||
thread_id,
|
||||
turn_id: turn.id.clone(),
|
||||
turn_id,
|
||||
turn,
|
||||
sequence_counter: 0,
|
||||
item_sequences: HashMap::new(),
|
||||
item_statuses: HashMap::new(),
|
||||
assistant_text: String::new(),
|
||||
reasoning_text: String::new(),
|
||||
plan_text: None,
|
||||
turn_summary_text: None,
|
||||
})
|
||||
@@ -363,80 +240,50 @@ impl AgentTimelineRecorder {
|
||||
&self.turn_id
|
||||
}
|
||||
|
||||
pub fn emit_start(&mut self, app: &AppHandle, event_name: &str) -> Result<(), String> {
|
||||
emit_event(
|
||||
app,
|
||||
event_name,
|
||||
&TauriAgentEvent::ThreadStarted {
|
||||
thread_id: self.thread_id.clone(),
|
||||
},
|
||||
);
|
||||
emit_event(
|
||||
app,
|
||||
event_name,
|
||||
&TauriAgentEvent::TurnStarted {
|
||||
turn: self.turn.clone(),
|
||||
},
|
||||
);
|
||||
|
||||
let user_item = self.build_item(
|
||||
format!("user:{}", self.turn_id),
|
||||
AgentThreadItemStatus::Completed,
|
||||
Some(self.turn.started_at.clone()),
|
||||
AgentThreadItemPayload::UserMessage {
|
||||
content: self.turn.prompt_text.clone(),
|
||||
},
|
||||
);
|
||||
self.persist_and_emit_item(app, event_name, user_item)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn record_legacy_event(
|
||||
pub fn record_runtime_event(
|
||||
&mut self,
|
||||
app: &AppHandle,
|
||||
event_name: &str,
|
||||
event: &TauriAgentEvent,
|
||||
workspace_root: &str,
|
||||
_workspace_root: &str,
|
||||
) -> Result<(), String> {
|
||||
match event {
|
||||
TauriAgentEvent::TextDelta { text } => {
|
||||
self.assistant_text.push_str(text);
|
||||
let item = self.build_item(
|
||||
format!("assistant:{}", self.turn_id),
|
||||
AgentThreadItemStatus::InProgress,
|
||||
None,
|
||||
AgentThreadItemPayload::AgentMessage {
|
||||
text: self.assistant_text.clone(),
|
||||
phase: None,
|
||||
},
|
||||
);
|
||||
self.persist_and_emit_item(app, event_name, item)?;
|
||||
TauriAgentEvent::ThreadStarted { .. } => {}
|
||||
TauriAgentEvent::TurnStarted { turn } => {
|
||||
self.thread_id = turn.thread_id.clone();
|
||||
self.turn_id = turn.id.clone();
|
||||
self.turn = turn.clone();
|
||||
|
||||
if let Some(plan_text) = extract_proposed_plan_block(&self.assistant_text) {
|
||||
if self.plan_text.as_deref() != Some(plan_text.as_str()) {
|
||||
self.plan_text = Some(plan_text.clone());
|
||||
}
|
||||
let plan_item = self.build_item(
|
||||
format!("plan:{}", self.turn_id),
|
||||
AgentThreadItemStatus::InProgress,
|
||||
None,
|
||||
AgentThreadItemPayload::Plan { text: plan_text },
|
||||
);
|
||||
self.persist_and_emit_item(app, event_name, plan_item)?;
|
||||
}
|
||||
let conn = lock_db(&self.db)?;
|
||||
AgentTimelineDao::upsert_turn(&conn, &self.turn)
|
||||
.map_err(|e| format!("同步 turn 启动态失败: {e}"))?;
|
||||
}
|
||||
TauriAgentEvent::ThinkingDelta { text } => {
|
||||
self.reasoning_text.push_str(text);
|
||||
let item = self.build_item(
|
||||
format!("reasoning:{}", self.turn_id),
|
||||
AgentThreadItemStatus::InProgress,
|
||||
None,
|
||||
AgentThreadItemPayload::Reasoning {
|
||||
text: self.reasoning_text.clone(),
|
||||
summary: None,
|
||||
},
|
||||
);
|
||||
self.persist_and_emit_item(app, event_name, item)?;
|
||||
TauriAgentEvent::ItemStarted { item } => {
|
||||
self.persist_runtime_item(
|
||||
app,
|
||||
event_name,
|
||||
item.clone(),
|
||||
TauriAgentEvent::ItemStarted { item: item.clone() },
|
||||
)?;
|
||||
self.maybe_project_plan_item(app, event_name, item)?;
|
||||
}
|
||||
TauriAgentEvent::ItemUpdated { item } => {
|
||||
self.persist_runtime_item(
|
||||
app,
|
||||
event_name,
|
||||
item.clone(),
|
||||
TauriAgentEvent::ItemUpdated { item: item.clone() },
|
||||
)?;
|
||||
self.maybe_project_plan_item(app, event_name, item)?;
|
||||
}
|
||||
TauriAgentEvent::ItemCompleted { item } => {
|
||||
self.persist_runtime_item(
|
||||
app,
|
||||
event_name,
|
||||
item.clone(),
|
||||
TauriAgentEvent::ItemCompleted { item: item.clone() },
|
||||
)?;
|
||||
self.maybe_project_plan_item(app, event_name, item)?;
|
||||
}
|
||||
TauriAgentEvent::RuntimeStatus { status } => {
|
||||
let text =
|
||||
@@ -452,115 +299,15 @@ impl AgentTimelineRecorder {
|
||||
self.persist_and_emit_item(app, event_name, item)?;
|
||||
}
|
||||
}
|
||||
TauriAgentEvent::ToolStart {
|
||||
tool_name,
|
||||
tool_id,
|
||||
arguments,
|
||||
} => {
|
||||
let arguments_value = parse_json_str(arguments.as_deref());
|
||||
let payload = if is_command_tool(tool_name) {
|
||||
AgentThreadItemPayload::CommandExecution {
|
||||
command: extract_command_text(arguments_value.as_ref())
|
||||
.unwrap_or_else(|| tool_name.clone()),
|
||||
cwd: workspace_root.to_string(),
|
||||
aggregated_output: None,
|
||||
exit_code: None,
|
||||
error: None,
|
||||
}
|
||||
} else if is_web_tool(tool_name) {
|
||||
AgentThreadItemPayload::WebSearch {
|
||||
query: extract_tool_query(arguments_value.as_ref()),
|
||||
action: Some(tool_name.clone()),
|
||||
output: None,
|
||||
}
|
||||
} else {
|
||||
AgentThreadItemPayload::ToolCall {
|
||||
tool_name: tool_name.clone(),
|
||||
arguments: arguments_value,
|
||||
output: None,
|
||||
success: None,
|
||||
error: None,
|
||||
metadata: None,
|
||||
}
|
||||
};
|
||||
|
||||
let item = self.build_item(
|
||||
tool_id.clone(),
|
||||
AgentThreadItemStatus::InProgress,
|
||||
None,
|
||||
payload,
|
||||
);
|
||||
self.persist_and_emit_item(app, event_name, item)?;
|
||||
}
|
||||
TauriAgentEvent::ToolEnd { tool_id, result } => {
|
||||
let existing = {
|
||||
let conn = lock_db(&self.db)?;
|
||||
AgentTimelineDao::get_item(&conn, tool_id)
|
||||
.map_err(|e| format!("读取工具 item 失败: {e}"))?
|
||||
};
|
||||
|
||||
let metadata_value = result
|
||||
.metadata
|
||||
.as_ref()
|
||||
.and_then(|metadata| serde_json::to_value(metadata).ok());
|
||||
let status = if result.success {
|
||||
AgentThreadItemStatus::Completed
|
||||
} else {
|
||||
AgentThreadItemStatus::Failed
|
||||
};
|
||||
|
||||
let payload = match existing.map(|item| item.payload) {
|
||||
Some(AgentThreadItemPayload::CommandExecution { command, cwd, .. }) => {
|
||||
AgentThreadItemPayload::CommandExecution {
|
||||
command,
|
||||
cwd,
|
||||
aggregated_output: Some(result.output.clone()),
|
||||
exit_code: metadata_value
|
||||
.as_ref()
|
||||
.and_then(|value| value.get("exit_code"))
|
||||
.and_then(Value::as_i64),
|
||||
error: result.error.clone(),
|
||||
}
|
||||
}
|
||||
Some(AgentThreadItemPayload::WebSearch { query, action, .. }) => {
|
||||
AgentThreadItemPayload::WebSearch {
|
||||
query,
|
||||
action,
|
||||
output: Some(result.output.clone()),
|
||||
}
|
||||
}
|
||||
Some(AgentThreadItemPayload::ToolCall {
|
||||
tool_name,
|
||||
arguments,
|
||||
..
|
||||
}) => AgentThreadItemPayload::ToolCall {
|
||||
tool_name,
|
||||
arguments,
|
||||
output: Some(result.output.clone()),
|
||||
success: Some(result.success),
|
||||
error: result.error.clone(),
|
||||
metadata: metadata_value.clone(),
|
||||
},
|
||||
_ => AgentThreadItemPayload::ToolCall {
|
||||
tool_name: tool_id.clone(),
|
||||
arguments: None,
|
||||
output: Some(result.output.clone()),
|
||||
success: Some(result.success),
|
||||
error: result.error.clone(),
|
||||
metadata: metadata_value.clone(),
|
||||
},
|
||||
};
|
||||
|
||||
let item = self.build_item(
|
||||
tool_id.clone(),
|
||||
status.clone(),
|
||||
Some(Utc::now().to_rfc3339()),
|
||||
payload,
|
||||
);
|
||||
self.persist_and_emit_item(app, event_name, item)?;
|
||||
|
||||
for artifact in extract_file_artifacts(None, metadata_value.as_ref()) {
|
||||
let artifact_path = artifact.path.clone();
|
||||
let status = resolve_artifact_item_status(metadata_value.as_ref());
|
||||
let file_item = self.build_item(
|
||||
artifact
|
||||
.artifact_id
|
||||
@@ -606,53 +353,10 @@ impl AgentTimelineRecorder {
|
||||
self.persist_and_emit_item(app, event_name, item)?;
|
||||
}
|
||||
TauriAgentEvent::ActionRequired {
|
||||
request_id,
|
||||
action_type,
|
||||
data,
|
||||
} => {
|
||||
let payload = if is_user_input_action(action_type) {
|
||||
AgentThreadItemPayload::RequestUserInput {
|
||||
request_id: request_id.clone(),
|
||||
action_type: action_type.clone(),
|
||||
prompt: data
|
||||
.get("prompt")
|
||||
.or_else(|| data.get("message"))
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(str::to_string),
|
||||
questions: map_questions(data.get("questions")),
|
||||
response: None,
|
||||
}
|
||||
} else {
|
||||
AgentThreadItemPayload::ApprovalRequest {
|
||||
request_id: request_id.clone(),
|
||||
action_type: action_type.clone(),
|
||||
prompt: data
|
||||
.get("prompt")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(str::to_string),
|
||||
tool_name: data
|
||||
.get("tool_name")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(str::to_string),
|
||||
arguments: data.get("arguments").cloned(),
|
||||
response: None,
|
||||
}
|
||||
};
|
||||
|
||||
let item = self.build_item(
|
||||
request_id.clone(),
|
||||
AgentThreadItemStatus::InProgress,
|
||||
None,
|
||||
payload,
|
||||
);
|
||||
self.persist_and_emit_item(app, event_name, item)?;
|
||||
}
|
||||
request_id: _,
|
||||
action_type: _,
|
||||
data: _,
|
||||
} => {}
|
||||
TauriAgentEvent::Warning { code, message } => {
|
||||
let item = self.build_item(
|
||||
format!("warning:{}:{}", self.turn_id, self.sequence_counter + 1),
|
||||
@@ -687,7 +391,7 @@ impl AgentTimelineRecorder {
|
||||
app: &AppHandle,
|
||||
event_name: &str,
|
||||
) -> Result<(), String> {
|
||||
self.complete_open_content_items(app, event_name, AgentThreadItemStatus::Completed)?;
|
||||
self.complete_projection_items(app, event_name, AgentThreadItemStatus::Completed)?;
|
||||
let now = Utc::now().to_rfc3339();
|
||||
self.turn.status = AgentThreadTurnStatus::Completed;
|
||||
self.turn.completed_at = Some(now.clone());
|
||||
@@ -721,7 +425,7 @@ impl AgentTimelineRecorder {
|
||||
event_name: &str,
|
||||
message: &str,
|
||||
) -> Result<(), String> {
|
||||
self.complete_open_content_items(app, event_name, AgentThreadItemStatus::Completed)?;
|
||||
self.complete_projection_items(app, event_name, AgentThreadItemStatus::Completed)?;
|
||||
let error_item = self.build_item(
|
||||
format!("error:{}", self.turn_id),
|
||||
AgentThreadItemStatus::Failed,
|
||||
@@ -760,38 +464,12 @@ impl AgentTimelineRecorder {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn complete_open_content_items(
|
||||
fn complete_projection_items(
|
||||
&mut self,
|
||||
app: &AppHandle,
|
||||
event_name: &str,
|
||||
status: AgentThreadItemStatus,
|
||||
) -> Result<(), String> {
|
||||
if !self.assistant_text.is_empty() {
|
||||
let item = self.build_item(
|
||||
format!("assistant:{}", self.turn_id),
|
||||
status.clone(),
|
||||
Some(Utc::now().to_rfc3339()),
|
||||
AgentThreadItemPayload::AgentMessage {
|
||||
text: self.assistant_text.clone(),
|
||||
phase: None,
|
||||
},
|
||||
);
|
||||
self.persist_and_emit_item(app, event_name, item)?;
|
||||
}
|
||||
|
||||
if !self.reasoning_text.is_empty() {
|
||||
let item = self.build_item(
|
||||
format!("reasoning:{}", self.turn_id),
|
||||
status.clone(),
|
||||
Some(Utc::now().to_rfc3339()),
|
||||
AgentThreadItemPayload::Reasoning {
|
||||
text: self.reasoning_text.clone(),
|
||||
summary: None,
|
||||
},
|
||||
);
|
||||
self.persist_and_emit_item(app, event_name, item)?;
|
||||
}
|
||||
|
||||
if let Some(plan_text) = self.plan_text.clone() {
|
||||
let item = self.build_item(
|
||||
format!("plan:{}", self.turn_id),
|
||||
@@ -888,6 +566,59 @@ impl AgentTimelineRecorder {
|
||||
emit_event(app, event_name, &event);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn persist_runtime_item(
|
||||
&mut self,
|
||||
app: &AppHandle,
|
||||
event_name: &str,
|
||||
item: AgentThreadItem,
|
||||
event: TauriAgentEvent,
|
||||
) -> Result<(), String> {
|
||||
self.sync_runtime_item_state(&item);
|
||||
{
|
||||
let conn = lock_db(&self.db)?;
|
||||
AgentTimelineDao::upsert_item(&conn, &item)
|
||||
.map_err(|e| format!("保存 runtime item 失败: {e}"))?;
|
||||
}
|
||||
emit_event(app, event_name, &event);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn sync_runtime_item_state(&mut self, item: &AgentThreadItem) {
|
||||
self.thread_id = item.thread_id.clone();
|
||||
self.turn_id = item.turn_id.clone();
|
||||
self.sequence_counter = self.sequence_counter.max(item.sequence);
|
||||
self.item_sequences.insert(item.id.clone(), item.sequence);
|
||||
self.item_statuses
|
||||
.insert(item.id.clone(), item.status.clone());
|
||||
|
||||
if let AgentThreadItemPayload::AgentMessage { text, .. } = &item.payload {
|
||||
self.plan_text = extract_proposed_plan_block(text);
|
||||
}
|
||||
}
|
||||
|
||||
fn maybe_project_plan_item(
|
||||
&mut self,
|
||||
app: &AppHandle,
|
||||
event_name: &str,
|
||||
item: &AgentThreadItem,
|
||||
) -> Result<(), String> {
|
||||
let AgentThreadItemPayload::AgentMessage { text, .. } = &item.payload else {
|
||||
return Ok(());
|
||||
};
|
||||
let Some(plan_text) = extract_proposed_plan_block(text) else {
|
||||
return Ok(());
|
||||
};
|
||||
self.plan_text = Some(plan_text.clone());
|
||||
let plan_item = self.build_item(
|
||||
format!("plan:{}", self.turn_id),
|
||||
item.status.clone(),
|
||||
item.completed_at.clone(),
|
||||
AgentThreadItemPayload::Plan { text: plan_text },
|
||||
);
|
||||
self.persist_and_emit_item(app, event_name, plan_item)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
pub fn complete_action_item(
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
use crate::database::dao::agent::{AgentDao, AgentModelPatternMatch};
|
||||
use crate::database::load_pending_general_messages;
|
||||
use chrono::{Local, TimeZone};
|
||||
use rusqlite::{params, Connection};
|
||||
use rusqlite::Connection;
|
||||
use std::collections::HashSet;
|
||||
|
||||
const GENERAL_MODE_PATTERN: &str = "general:%";
|
||||
@@ -96,49 +97,26 @@ fn load_unified_general_candidates(
|
||||
let from_datetime = from_timestamp.map(format_sqlite_datetime);
|
||||
let to_datetime = to_timestamp.map(format_sqlite_datetime);
|
||||
|
||||
let mut stmt = conn
|
||||
.prepare(
|
||||
"SELECT m.session_id, m.role, m.content_json, m.timestamp
|
||||
FROM agent_messages m
|
||||
JOIN agent_sessions s ON s.id = m.session_id
|
||||
WHERE s.model LIKE ?1
|
||||
AND (?2 IS NULL OR datetime(m.timestamp) >= datetime(?2))
|
||||
AND (?3 IS NULL OR datetime(m.timestamp) <= datetime(?3))
|
||||
ORDER BY datetime(m.timestamp) DESC
|
||||
LIMIT ?4",
|
||||
)
|
||||
.map_err(|e| format!("查询 unified general agent_messages 失败: {e}"))?;
|
||||
let rows = AgentDao::list_message_text_rows_by_model_pattern(
|
||||
conn,
|
||||
GENERAL_MODE_PATTERN,
|
||||
AgentModelPatternMatch::Like,
|
||||
from_datetime.as_deref(),
|
||||
to_datetime.as_deref(),
|
||||
limit,
|
||||
)
|
||||
.map_err(|e| format!("读取 unified general agent_messages 失败: {e}"))?;
|
||||
|
||||
let rows = stmt
|
||||
.query_map(
|
||||
params![
|
||||
GENERAL_MODE_PATTERN,
|
||||
from_datetime,
|
||||
to_datetime,
|
||||
limit as i64
|
||||
],
|
||||
|row| {
|
||||
let session_id: String = row.get(0)?;
|
||||
let role: String = row.get(1)?;
|
||||
let content_json: String = row.get(2)?;
|
||||
let timestamp: String = row.get(3)?;
|
||||
Ok((session_id, role, content_json, timestamp))
|
||||
},
|
||||
)
|
||||
.map_err(|e| format!("读取 unified general agent_messages 失败: {e}"))?;
|
||||
|
||||
for row in rows.flatten() {
|
||||
if let Some(timestamp_ms) = parse_rfc3339_to_timestamp(&row.3) {
|
||||
push_candidate(
|
||||
candidates,
|
||||
seen,
|
||||
row.0,
|
||||
row.1,
|
||||
extract_text_from_content_json(&row.2),
|
||||
timestamp_ms,
|
||||
min_message_length,
|
||||
);
|
||||
}
|
||||
for row in rows {
|
||||
push_candidate(
|
||||
candidates,
|
||||
seen,
|
||||
row.session_id,
|
||||
row.role,
|
||||
row.content,
|
||||
row.timestamp_ms,
|
||||
min_message_length,
|
||||
);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
@@ -156,49 +134,26 @@ fn load_non_general_agent_candidates(
|
||||
let from_datetime = from_timestamp.map(format_sqlite_datetime);
|
||||
let to_datetime = to_timestamp.map(format_sqlite_datetime);
|
||||
|
||||
let mut stmt = conn
|
||||
.prepare(
|
||||
"SELECT m.session_id, m.role, m.content_json, m.timestamp
|
||||
FROM agent_messages m
|
||||
JOIN agent_sessions s ON s.id = m.session_id
|
||||
WHERE s.model NOT LIKE ?1
|
||||
AND (?2 IS NULL OR datetime(m.timestamp) >= datetime(?2))
|
||||
AND (?3 IS NULL OR datetime(m.timestamp) <= datetime(?3))
|
||||
ORDER BY datetime(m.timestamp) DESC
|
||||
LIMIT ?4",
|
||||
)
|
||||
.map_err(|e| format!("查询非通用 agent_messages 失败: {e}"))?;
|
||||
let rows = AgentDao::list_message_text_rows_by_model_pattern(
|
||||
conn,
|
||||
GENERAL_MODE_PATTERN,
|
||||
AgentModelPatternMatch::NotLike,
|
||||
from_datetime.as_deref(),
|
||||
to_datetime.as_deref(),
|
||||
limit,
|
||||
)
|
||||
.map_err(|e| format!("读取非通用 agent_messages 失败: {e}"))?;
|
||||
|
||||
let rows = stmt
|
||||
.query_map(
|
||||
params![
|
||||
GENERAL_MODE_PATTERN,
|
||||
from_datetime,
|
||||
to_datetime,
|
||||
limit as i64
|
||||
],
|
||||
|row| {
|
||||
let session_id: String = row.get(0)?;
|
||||
let role: String = row.get(1)?;
|
||||
let content_json: String = row.get(2)?;
|
||||
let timestamp: String = row.get(3)?;
|
||||
Ok((session_id, role, content_json, timestamp))
|
||||
},
|
||||
)
|
||||
.map_err(|e| format!("读取非通用 agent_messages 失败: {e}"))?;
|
||||
|
||||
for row in rows.flatten() {
|
||||
if let Some(timestamp_ms) = parse_rfc3339_to_timestamp(&row.3) {
|
||||
push_candidate(
|
||||
candidates,
|
||||
seen,
|
||||
row.0,
|
||||
row.1,
|
||||
extract_text_from_content_json(&row.2),
|
||||
timestamp_ms,
|
||||
min_message_length,
|
||||
);
|
||||
}
|
||||
for row in rows {
|
||||
push_candidate(
|
||||
candidates,
|
||||
seen,
|
||||
row.session_id,
|
||||
row.role,
|
||||
row.content,
|
||||
row.timestamp_ms,
|
||||
min_message_length,
|
||||
);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
@@ -269,76 +224,6 @@ fn format_sqlite_datetime(timestamp_ms: i64) -> String {
|
||||
.unwrap_or_else(|| Local::now().format("%Y-%m-%d %H:%M:%S").to_string())
|
||||
}
|
||||
|
||||
fn parse_rfc3339_to_timestamp(value: &str) -> Option<i64> {
|
||||
chrono::DateTime::parse_from_rfc3339(value)
|
||||
.ok()
|
||||
.map(|dt| dt.timestamp_millis())
|
||||
.or_else(|| parse_datetime_or_timestamp_to_millis(value))
|
||||
}
|
||||
|
||||
fn parse_datetime_or_timestamp_to_millis(value: &str) -> Option<i64> {
|
||||
if let Ok(v) = value.parse::<i64>() {
|
||||
if v > 1_000_000_000_000 {
|
||||
return Some(v);
|
||||
}
|
||||
return Some(v * 1000);
|
||||
}
|
||||
|
||||
chrono::NaiveDateTime::parse_from_str(value, "%Y-%m-%d %H:%M:%S")
|
||||
.ok()
|
||||
.and_then(|naive| {
|
||||
Local
|
||||
.from_local_datetime(&naive)
|
||||
.single()
|
||||
.map(|dt| dt.timestamp_millis())
|
||||
})
|
||||
}
|
||||
|
||||
fn extract_text_from_content_json(content_json: &str) -> String {
|
||||
if let Ok(text) = serde_json::from_str::<String>(content_json) {
|
||||
return text;
|
||||
}
|
||||
|
||||
if let Ok(value) = serde_json::from_str::<serde_json::Value>(content_json) {
|
||||
match value {
|
||||
serde_json::Value::Array(items) => {
|
||||
let texts = items
|
||||
.iter()
|
||||
.filter_map(extract_text_from_json_item)
|
||||
.collect::<Vec<_>>();
|
||||
if !texts.is_empty() {
|
||||
return texts.join(" ");
|
||||
}
|
||||
}
|
||||
serde_json::Value::Object(_) => {
|
||||
if let Some(text) = extract_text_from_json_item(&value) {
|
||||
return text;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
content_json.to_string()
|
||||
}
|
||||
|
||||
fn extract_text_from_json_item(value: &serde_json::Value) -> Option<String> {
|
||||
if let Some(text) = value.get("Text").and_then(|v| v.as_str()) {
|
||||
return Some(text.to_string());
|
||||
}
|
||||
|
||||
if value.get("type").and_then(|v| v.as_str()) == Some("text") {
|
||||
if let Some(text) = value.get("text").and_then(|v| v.as_str()) {
|
||||
return Some(text.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
value
|
||||
.get("text")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|v| v.to_string())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::load_memory_source_candidates;
|
||||
|
||||
@@ -2,12 +2,14 @@
|
||||
//!
|
||||
//! 从数据库查询真实的对话和使用统计数据
|
||||
|
||||
use crate::database::dao::agent::{AgentDao, AgentModelPatternMatch};
|
||||
use crate::database::dao::orchestrator::OrchestratorDao;
|
||||
use crate::database::{
|
||||
count_pending_general_messages, count_pending_general_sessions,
|
||||
sum_pending_general_message_chars,
|
||||
};
|
||||
use chrono::{DateTime, Datelike, Duration, Local, TimeZone, Timelike};
|
||||
use rusqlite::{params, Connection};
|
||||
use rusqlite::Connection;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
const GENERAL_MODE_PATTERN: &str = "general:%";
|
||||
@@ -210,17 +212,14 @@ fn query_general_session_count(
|
||||
let from_text = from_timestamp_ms.map(format_sqlite_datetime);
|
||||
let to_text = to_timestamp_ms.map(format_sqlite_datetime);
|
||||
|
||||
let unified_count: i64 = conn
|
||||
.query_row(
|
||||
"SELECT COUNT(*)
|
||||
FROM agent_sessions s
|
||||
WHERE s.model LIKE ?1
|
||||
AND (?2 IS NULL OR datetime(s.created_at) >= datetime(?2))
|
||||
AND (?3 IS NULL OR datetime(s.created_at) < datetime(?3))",
|
||||
params![GENERAL_MODE_PATTERN, from_text, to_text],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.map_err(|e| format!("查询 unified general 会话数失败: {e}"))?;
|
||||
let unified_count = AgentDao::count_sessions_by_model_pattern(
|
||||
conn,
|
||||
GENERAL_MODE_PATTERN,
|
||||
AgentModelPatternMatch::Like,
|
||||
from_text.as_deref(),
|
||||
to_text.as_deref(),
|
||||
)
|
||||
.map_err(|e| format!("查询 unified general 会话数失败: {e}"))?;
|
||||
|
||||
let pending_count = count_pending_general_sessions(conn, from_timestamp_ms, to_timestamp_ms)
|
||||
.map_err(|e| format!("查询待迁移 general 会话数失败: {e}"))?;
|
||||
@@ -236,18 +235,14 @@ fn query_general_message_count(
|
||||
let from_text = from_timestamp_ms.map(format_sqlite_datetime);
|
||||
let to_text = to_timestamp_ms.map(format_sqlite_datetime);
|
||||
|
||||
let unified_count: i64 = conn
|
||||
.query_row(
|
||||
"SELECT COUNT(*)
|
||||
FROM agent_messages m
|
||||
JOIN agent_sessions s ON s.id = m.session_id
|
||||
WHERE s.model LIKE ?1
|
||||
AND (?2 IS NULL OR datetime(m.timestamp) >= datetime(?2))
|
||||
AND (?3 IS NULL OR datetime(m.timestamp) < datetime(?3))",
|
||||
params![GENERAL_MODE_PATTERN, from_text, to_text],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.map_err(|e| format!("查询 unified general 消息数失败: {e}"))?;
|
||||
let unified_count = AgentDao::count_messages_by_model_pattern(
|
||||
conn,
|
||||
GENERAL_MODE_PATTERN,
|
||||
AgentModelPatternMatch::Like,
|
||||
from_text.as_deref(),
|
||||
to_text.as_deref(),
|
||||
)
|
||||
.map_err(|e| format!("查询 unified general 消息数失败: {e}"))?;
|
||||
|
||||
let pending_count = count_pending_general_messages(conn, from_timestamp_ms, to_timestamp_ms)
|
||||
.map_err(|e| format!("查询待迁移 general 消息数失败: {e}"))?;
|
||||
@@ -263,18 +258,14 @@ fn sum_general_message_chars(
|
||||
let from_text = from_timestamp_ms.map(format_sqlite_datetime);
|
||||
let to_text = to_timestamp_ms.map(format_sqlite_datetime);
|
||||
|
||||
let unified_chars: i64 = conn
|
||||
.query_row(
|
||||
"SELECT COALESCE(SUM(LENGTH(m.content_json)), 0)
|
||||
FROM agent_messages m
|
||||
JOIN agent_sessions s ON s.id = m.session_id
|
||||
WHERE s.model LIKE ?1
|
||||
AND (?2 IS NULL OR datetime(m.timestamp) >= datetime(?2))
|
||||
AND (?3 IS NULL OR datetime(m.timestamp) < datetime(?3))",
|
||||
params![GENERAL_MODE_PATTERN, from_text, to_text],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.map_err(|e| format!("估算 unified general Token 失败: {e}"))?;
|
||||
let unified_chars = AgentDao::sum_message_chars_by_model_pattern(
|
||||
conn,
|
||||
GENERAL_MODE_PATTERN,
|
||||
AgentModelPatternMatch::Like,
|
||||
from_text.as_deref(),
|
||||
to_text.as_deref(),
|
||||
)
|
||||
.map_err(|e| format!("估算 unified general Token 失败: {e}"))?;
|
||||
|
||||
let pending_chars = sum_pending_general_message_chars(conn, from_timestamp_ms, to_timestamp_ms)
|
||||
.map_err(|e| format!("估算待迁移 general Token 失败: {e}"))?;
|
||||
@@ -290,14 +281,12 @@ fn query_non_general_session_count(
|
||||
let from_text = from_timestamp_ms.map(format_sqlite_datetime);
|
||||
let to_text = to_timestamp_ms.map(format_sqlite_datetime);
|
||||
|
||||
conn.query_row(
|
||||
"SELECT COUNT(*)
|
||||
FROM agent_sessions s
|
||||
WHERE s.model NOT LIKE ?1
|
||||
AND (?2 IS NULL OR datetime(s.created_at) >= datetime(?2))
|
||||
AND (?3 IS NULL OR datetime(s.created_at) < datetime(?3))",
|
||||
params![GENERAL_MODE_PATTERN, from_text, to_text],
|
||||
|row| row.get(0),
|
||||
AgentDao::count_sessions_by_model_pattern(
|
||||
conn,
|
||||
GENERAL_MODE_PATTERN,
|
||||
AgentModelPatternMatch::NotLike,
|
||||
from_text.as_deref(),
|
||||
to_text.as_deref(),
|
||||
)
|
||||
.map_err(|e| format!("查询非通用 unified 会话数失败: {e}"))
|
||||
}
|
||||
@@ -310,15 +299,12 @@ fn query_non_general_message_count(
|
||||
let from_text = from_timestamp_ms.map(format_sqlite_datetime);
|
||||
let to_text = to_timestamp_ms.map(format_sqlite_datetime);
|
||||
|
||||
conn.query_row(
|
||||
"SELECT COUNT(*)
|
||||
FROM agent_messages m
|
||||
JOIN agent_sessions s ON s.id = m.session_id
|
||||
WHERE s.model NOT LIKE ?1
|
||||
AND (?2 IS NULL OR datetime(m.timestamp) >= datetime(?2))
|
||||
AND (?3 IS NULL OR datetime(m.timestamp) < datetime(?3))",
|
||||
params![GENERAL_MODE_PATTERN, from_text, to_text],
|
||||
|row| row.get(0),
|
||||
AgentDao::count_messages_by_model_pattern(
|
||||
conn,
|
||||
GENERAL_MODE_PATTERN,
|
||||
AgentModelPatternMatch::NotLike,
|
||||
from_text.as_deref(),
|
||||
to_text.as_deref(),
|
||||
)
|
||||
.map_err(|e| format!("查询非通用 unified 消息数失败: {e}"))
|
||||
}
|
||||
@@ -331,15 +317,12 @@ fn sum_non_general_message_chars(
|
||||
let from_text = from_timestamp_ms.map(format_sqlite_datetime);
|
||||
let to_text = to_timestamp_ms.map(format_sqlite_datetime);
|
||||
|
||||
conn.query_row(
|
||||
"SELECT COALESCE(SUM(LENGTH(m.content_json)), 0)
|
||||
FROM agent_messages m
|
||||
JOIN agent_sessions s ON s.id = m.session_id
|
||||
WHERE s.model NOT LIKE ?1
|
||||
AND (?2 IS NULL OR datetime(m.timestamp) >= datetime(?2))
|
||||
AND (?3 IS NULL OR datetime(m.timestamp) < datetime(?3))",
|
||||
params![GENERAL_MODE_PATTERN, from_text, to_text],
|
||||
|row| row.get(0),
|
||||
AgentDao::sum_message_chars_by_model_pattern(
|
||||
conn,
|
||||
GENERAL_MODE_PATTERN,
|
||||
AgentModelPatternMatch::NotLike,
|
||||
from_text.as_deref(),
|
||||
to_text.as_deref(),
|
||||
)
|
||||
.map_err(|e| format!("估算非通用 unified Token 失败: {e}"))
|
||||
}
|
||||
@@ -413,41 +396,22 @@ fn query_model_usage_table_tokens(
|
||||
today_start: &DateTime<Local>,
|
||||
month_start: &DateTime<Local>,
|
||||
) -> Result<Option<TokenStats>, String> {
|
||||
let row_count: i64 = conn
|
||||
.query_row("SELECT COUNT(*) FROM model_usage_stats", [], |row| {
|
||||
row.get(0)
|
||||
})
|
||||
.map_err(|e| format!("查询 model_usage_stats 行数失败: {e}"))?;
|
||||
|
||||
if row_count <= 0 {
|
||||
if !OrchestratorDao::has_model_usage_stats(conn)
|
||||
.map_err(|e| format!("查询 model_usage_stats 行数失败: {e}"))?
|
||||
{
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let today_key = today_start.format("%Y-%m-%d").to_string();
|
||||
let month_key = month_start.format("%Y-%m-%d").to_string();
|
||||
|
||||
let total_tokens: i64 = conn
|
||||
.query_row(
|
||||
"SELECT COALESCE(SUM(total_tokens), 0) FROM model_usage_stats",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)
|
||||
let total_tokens = OrchestratorDao::get_total_model_usage_tokens(conn)
|
||||
.map_err(|e| format!("查询总 Token 失败: {e}"))?;
|
||||
|
||||
let monthly_tokens: i64 = conn
|
||||
.query_row(
|
||||
"SELECT COALESCE(SUM(total_tokens), 0) FROM model_usage_stats WHERE date >= ?",
|
||||
[month_key],
|
||||
|row| row.get(0),
|
||||
)
|
||||
let monthly_tokens = OrchestratorDao::get_model_usage_tokens_since(conn, &month_key)
|
||||
.map_err(|e| format!("查询本月 Token 失败: {e}"))?;
|
||||
|
||||
let today_tokens: i64 = conn
|
||||
.query_row(
|
||||
"SELECT COALESCE(SUM(total_tokens), 0) FROM model_usage_stats WHERE date = ?",
|
||||
[today_key],
|
||||
|row| row.get(0),
|
||||
)
|
||||
let today_tokens = OrchestratorDao::get_model_usage_tokens_on(conn, &today_key)
|
||||
.map_err(|e| format!("查询今日 Token 失败: {e}"))?;
|
||||
|
||||
Ok(Some(TokenStats {
|
||||
@@ -499,150 +463,42 @@ fn query_model_usage_from_stats_table(
|
||||
conn: &Connection,
|
||||
range_start: Option<DateTime<Local>>,
|
||||
) -> Result<Vec<RawModelUsage>, String> {
|
||||
let mut result = Vec::new();
|
||||
|
||||
if let Some(start) = range_start {
|
||||
let start_key = start.format("%Y-%m-%d").to_string();
|
||||
let mut stmt = conn
|
||||
.prepare(
|
||||
"SELECT model_id,
|
||||
COALESCE(SUM(request_count), 0) AS conversations,
|
||||
COALESCE(SUM(total_tokens), 0) AS tokens
|
||||
FROM model_usage_stats
|
||||
WHERE date >= ?
|
||||
GROUP BY model_id
|
||||
ORDER BY tokens DESC, conversations DESC
|
||||
LIMIT 20",
|
||||
)
|
||||
.map_err(|e| format!("准备模型统计查询失败: {e}"))?;
|
||||
|
||||
let rows = stmt
|
||||
.query_map([start_key], |row| {
|
||||
let model: String = row.get(0)?;
|
||||
let conversations: i64 = row.get(1)?;
|
||||
let tokens: i64 = row.get(2)?;
|
||||
Ok(RawModelUsage {
|
||||
model,
|
||||
conversations: clamp_i64_to_u64(conversations),
|
||||
tokens: clamp_i64_to_u64(tokens),
|
||||
})
|
||||
})
|
||||
.map_err(|e| format!("执行模型统计查询失败: {e}"))?;
|
||||
|
||||
for row in rows {
|
||||
result.push(row.map_err(|e| format!("读取模型统计行失败: {e}"))?);
|
||||
}
|
||||
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
let mut stmt = conn
|
||||
.prepare(
|
||||
"SELECT model_id,
|
||||
COALESCE(SUM(request_count), 0) AS conversations,
|
||||
COALESCE(SUM(total_tokens), 0) AS tokens
|
||||
FROM model_usage_stats
|
||||
GROUP BY model_id
|
||||
ORDER BY tokens DESC, conversations DESC
|
||||
LIMIT 20",
|
||||
)
|
||||
.map_err(|e| format!("准备模型统计查询失败: {e}"))?;
|
||||
|
||||
let rows = stmt
|
||||
.query_map([], |row| {
|
||||
let model: String = row.get(0)?;
|
||||
let conversations: i64 = row.get(1)?;
|
||||
let tokens: i64 = row.get(2)?;
|
||||
Ok(RawModelUsage {
|
||||
model,
|
||||
conversations: clamp_i64_to_u64(conversations),
|
||||
tokens: clamp_i64_to_u64(tokens),
|
||||
})
|
||||
})
|
||||
let start_key = range_start.map(|start| start.format("%Y-%m-%d").to_string());
|
||||
let rows = OrchestratorDao::list_model_usage_aggregates(conn, start_key.as_deref(), 20)
|
||||
.map_err(|e| format!("执行模型统计查询失败: {e}"))?;
|
||||
|
||||
for row in rows {
|
||||
result.push(row.map_err(|e| format!("读取模型统计行失败: {e}"))?);
|
||||
}
|
||||
|
||||
Ok(result)
|
||||
Ok(rows
|
||||
.into_iter()
|
||||
.map(|row| RawModelUsage {
|
||||
model: row.model_id,
|
||||
conversations: clamp_i64_to_u64(row.request_count),
|
||||
tokens: clamp_i64_to_u64(row.total_tokens),
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
fn query_model_usage_from_agent_messages(
|
||||
conn: &Connection,
|
||||
range_start: Option<DateTime<Local>>,
|
||||
) -> Result<Vec<RawModelUsage>, String> {
|
||||
let mut result = Vec::new();
|
||||
let start_str = range_start.map(|start| start.format("%Y-%m-%d %H:%M:%S").to_string());
|
||||
let rows = AgentDao::list_model_usage_by_model_pattern(
|
||||
conn,
|
||||
GENERAL_MODE_PATTERN,
|
||||
AgentModelPatternMatch::NotLike,
|
||||
start_str.as_deref(),
|
||||
20,
|
||||
)
|
||||
.map_err(|e| format!("查询 Agent 模型排行失败: {e}"))?;
|
||||
|
||||
if let Some(start) = range_start {
|
||||
let start_str = start.format("%Y-%m-%d %H:%M:%S").to_string();
|
||||
let mut stmt = conn
|
||||
.prepare(
|
||||
"SELECT s.model,
|
||||
COUNT(DISTINCT m.session_id) AS conversations,
|
||||
COALESCE(SUM(LENGTH(m.content_json)), 0) AS content_chars
|
||||
FROM agent_messages m
|
||||
JOIN agent_sessions s ON s.id = m.session_id
|
||||
WHERE s.model NOT LIKE ?1
|
||||
AND datetime(m.timestamp) >= datetime(?2)
|
||||
GROUP BY s.model
|
||||
ORDER BY content_chars DESC, conversations DESC
|
||||
LIMIT 20",
|
||||
)
|
||||
.map_err(|e| format!("准备 Agent 模型排行查询失败: {e}"))?;
|
||||
|
||||
let rows = stmt
|
||||
.query_map(params![GENERAL_MODE_PATTERN, start_str], |row| {
|
||||
let model: String = row.get(0)?;
|
||||
let conversations: i64 = row.get(1)?;
|
||||
let chars: i64 = row.get(2)?;
|
||||
Ok(RawModelUsage {
|
||||
model,
|
||||
conversations: clamp_i64_to_u64(conversations),
|
||||
tokens: chars_to_estimated_tokens(chars),
|
||||
})
|
||||
})
|
||||
.map_err(|e| format!("执行 Agent 模型排行查询失败: {e}"))?;
|
||||
|
||||
for row in rows {
|
||||
result.push(row.map_err(|e| format!("读取 Agent 模型排行行失败: {e}"))?);
|
||||
}
|
||||
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
let mut stmt = conn
|
||||
.prepare(
|
||||
"SELECT s.model,
|
||||
COUNT(DISTINCT m.session_id) AS conversations,
|
||||
COALESCE(SUM(LENGTH(m.content_json)), 0) AS content_chars
|
||||
FROM agent_messages m
|
||||
JOIN agent_sessions s ON s.id = m.session_id
|
||||
WHERE s.model NOT LIKE ?1
|
||||
GROUP BY s.model
|
||||
ORDER BY content_chars DESC, conversations DESC
|
||||
LIMIT 20",
|
||||
)
|
||||
.map_err(|e| format!("准备 Agent 模型排行查询失败: {e}"))?;
|
||||
|
||||
let rows = stmt
|
||||
.query_map([GENERAL_MODE_PATTERN], |row| {
|
||||
let model: String = row.get(0)?;
|
||||
let conversations: i64 = row.get(1)?;
|
||||
let chars: i64 = row.get(2)?;
|
||||
Ok(RawModelUsage {
|
||||
model,
|
||||
conversations: clamp_i64_to_u64(conversations),
|
||||
tokens: chars_to_estimated_tokens(chars),
|
||||
})
|
||||
Ok(rows
|
||||
.into_iter()
|
||||
.map(|row| RawModelUsage {
|
||||
model: row.model,
|
||||
conversations: row.conversations,
|
||||
tokens: chars_to_estimated_tokens(row.content_chars as i64),
|
||||
})
|
||||
.map_err(|e| format!("执行 Agent 模型排行查询失败: {e}"))?;
|
||||
|
||||
for row in rows {
|
||||
result.push(row.map_err(|e| format!("读取 Agent 模型排行行失败: {e}"))?);
|
||||
}
|
||||
|
||||
Ok(result)
|
||||
.collect())
|
||||
}
|
||||
|
||||
fn build_model_usage_response(usages: Vec<RawModelUsage>) -> Vec<ModelUsage> {
|
||||
@@ -689,12 +545,8 @@ pub fn get_daily_usage_trends_from_db(
|
||||
) -> Result<Vec<DailyUsage>, String> {
|
||||
let days = resolve_range_days(time_range)?;
|
||||
|
||||
let has_model_usage_data: i64 = conn
|
||||
.query_row("SELECT COUNT(*) FROM model_usage_stats", [], |row| {
|
||||
row.get(0)
|
||||
})
|
||||
let use_actual_tokens = OrchestratorDao::has_model_usage_stats(conn)
|
||||
.map_err(|e| format!("检查 model_usage_stats 失败: {e}"))?;
|
||||
let use_actual_tokens = has_model_usage_data > 0;
|
||||
|
||||
let mut daily_usage = Vec::new();
|
||||
|
||||
@@ -718,12 +570,7 @@ pub fn get_daily_usage_trends_from_db(
|
||||
let total_conversations = conversations + agent_conversations;
|
||||
|
||||
let tokens = if use_actual_tokens {
|
||||
let day_tokens: i64 = conn
|
||||
.query_row(
|
||||
"SELECT COALESCE(SUM(total_tokens), 0) FROM model_usage_stats WHERE date = ?",
|
||||
[day_key.clone()],
|
||||
|row| row.get(0),
|
||||
)
|
||||
let day_tokens = OrchestratorDao::get_model_usage_tokens_on(conn, &day_key)
|
||||
.map_err(|e| format!("查询模型日 Token 失败: {e}"))?;
|
||||
|
||||
clamp_i64_to_u64(day_tokens)
|
||||
@@ -751,7 +598,10 @@ pub fn get_daily_usage_trends_from_db(
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{query_agent_chat_stats, query_general_chat_stats, start_of_day, start_of_month};
|
||||
use super::{
|
||||
get_model_usage_ranking_from_db, query_agent_chat_stats, query_general_chat_stats,
|
||||
start_of_day, start_of_month,
|
||||
};
|
||||
use chrono::{Local, TimeZone};
|
||||
use rusqlite::{params, Connection};
|
||||
|
||||
@@ -897,4 +747,60 @@ mod tests {
|
||||
assert_eq!(general_stats.monthly_conversations, 0);
|
||||
assert_eq!(general_stats.today_messages, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn model_usage_ranking_fallback_should_only_include_non_general_models() {
|
||||
let conn = Connection::open_in_memory().expect("open in memory db");
|
||||
create_test_schema(&conn);
|
||||
|
||||
conn.execute(
|
||||
"INSERT INTO agent_sessions (id, model, system_prompt, title, created_at, updated_at) VALUES (?1, ?2, NULL, ?3, ?4, ?5)",
|
||||
params![
|
||||
"general-1",
|
||||
"general:default",
|
||||
"通用会话",
|
||||
"2026-03-12T10:00:00+08:00",
|
||||
"2026-03-12T10:00:00+08:00"
|
||||
],
|
||||
)
|
||||
.unwrap();
|
||||
conn.execute(
|
||||
"INSERT INTO agent_sessions (id, model, system_prompt, title, created_at, updated_at) VALUES (?1, ?2, NULL, ?3, ?4, ?5)",
|
||||
params![
|
||||
"agent-1",
|
||||
"claude-sonnet-4",
|
||||
"Agent 会话",
|
||||
"2026-03-12T10:05:00+08:00",
|
||||
"2026-03-12T10:05:00+08:00"
|
||||
],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
conn.execute(
|
||||
"INSERT INTO agent_messages (session_id, role, content_json, timestamp) VALUES (?1, ?2, ?3, ?4)",
|
||||
params![
|
||||
"general-1",
|
||||
"user",
|
||||
r#"[{"type":"text","text":"这条 general 消息不应进入 Agent 排行"}]"#,
|
||||
"2026-03-12T10:00:00+08:00"
|
||||
],
|
||||
)
|
||||
.unwrap();
|
||||
conn.execute(
|
||||
"INSERT INTO agent_messages (session_id, role, content_json, timestamp) VALUES (?1, ?2, ?3, ?4)",
|
||||
params![
|
||||
"agent-1",
|
||||
"assistant",
|
||||
r#"[{"type":"text","text":"这是 agent 模型排行候选"}]"#,
|
||||
"2026-03-12T10:05:00+08:00"
|
||||
],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let ranking = get_model_usage_ranking_from_db("all", &conn).expect("load ranking");
|
||||
assert_eq!(ranking.len(), 1);
|
||||
assert_eq!(ranking[0].model, "claude-sonnet-4");
|
||||
assert_eq!(ranking[0].conversations, 1);
|
||||
assert!(ranking[0].tokens > 0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -212,6 +212,95 @@ struct OpenClawDirectUpgradeResult {
|
||||
package_spec: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
enum ResolvedOpenClawCommand {
|
||||
Binary {
|
||||
binary_path: PathBuf,
|
||||
},
|
||||
NodeCli {
|
||||
node_path: PathBuf,
|
||||
cli_path: PathBuf,
|
||||
package_version: Option<String>,
|
||||
},
|
||||
}
|
||||
|
||||
impl ResolvedOpenClawCommand {
|
||||
fn build_command(&self) -> Command {
|
||||
let command_path = self.command_path();
|
||||
let command_path_string = command_path.to_string_lossy().to_string();
|
||||
let mut command = Command::new(command_path);
|
||||
apply_binary_runtime_path(&mut command, &command_path_string);
|
||||
|
||||
if let Self::NodeCli { cli_path, .. } = self {
|
||||
command.arg(cli_path);
|
||||
}
|
||||
|
||||
command
|
||||
}
|
||||
|
||||
fn command_path(&self) -> &Path {
|
||||
match self {
|
||||
Self::Binary { binary_path } => binary_path.as_path(),
|
||||
Self::NodeCli { node_path, .. } => node_path.as_path(),
|
||||
}
|
||||
}
|
||||
|
||||
fn install_path_display(&self) -> String {
|
||||
match self {
|
||||
Self::Binary { binary_path } => binary_path.display().to_string(),
|
||||
Self::NodeCli { cli_path, .. } => cli_path.display().to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn invocation_display(&self) -> String {
|
||||
match self {
|
||||
Self::Binary { binary_path } => binary_path.display().to_string(),
|
||||
Self::NodeCli {
|
||||
node_path,
|
||||
cli_path,
|
||||
..
|
||||
} => {
|
||||
format!("{} {}", node_path.display(), cli_path.display())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn preview_invocation(&self) -> String {
|
||||
match self {
|
||||
Self::Binary { binary_path } => shell_escape(binary_path.to_string_lossy().as_ref()),
|
||||
Self::NodeCli {
|
||||
node_path,
|
||||
cli_path,
|
||||
..
|
||||
} => format!(
|
||||
"{} {}",
|
||||
shell_escape(node_path.to_string_lossy().as_ref()),
|
||||
shell_escape(cli_path.to_string_lossy().as_ref())
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
fn fallback_version(&self) -> Option<String> {
|
||||
match self {
|
||||
Self::Binary { .. } => None,
|
||||
Self::NodeCli {
|
||||
package_version, ..
|
||||
} => package_version.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
fn dedupe_key(&self) -> String {
|
||||
match self {
|
||||
Self::Binary { binary_path } => format!("binary:{}", binary_path.display()),
|
||||
Self::NodeCli {
|
||||
node_path,
|
||||
cli_path,
|
||||
..
|
||||
} => format!("node:{}:{}", node_path.display(), cli_path.display()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ChannelInfo {
|
||||
@@ -990,7 +1079,7 @@ impl OpenClawService {
|
||||
});
|
||||
}
|
||||
|
||||
let Some(binary) = find_command_in_shell("openclaw").await? else {
|
||||
let Some(openclaw_command) = resolve_openclaw_command().await? else {
|
||||
self.gateway_status = GatewayStatus::Error;
|
||||
if let Some(app) = app {
|
||||
emit_install_progress(app, "未检测到 OpenClaw 可执行文件,请先安装。", "error");
|
||||
@@ -1011,9 +1100,8 @@ impl OpenClawService {
|
||||
"info",
|
||||
);
|
||||
}
|
||||
let mut command = Command::new(&binary);
|
||||
let mut command = openclaw_command.build_command();
|
||||
let start_args = gateway_start_args(self.gateway_port, &self.gateway_auth_token);
|
||||
apply_binary_runtime_path(&mut command, &binary);
|
||||
command
|
||||
.args(&start_args)
|
||||
.env(OPENCLAW_CONFIG_ENV, &config_path)
|
||||
@@ -1162,8 +1250,8 @@ impl OpenClawService {
|
||||
});
|
||||
}
|
||||
|
||||
let stop_binaries = self.collect_gateway_stop_binaries().await?;
|
||||
if stop_binaries.is_empty() {
|
||||
let stop_commands = self.collect_gateway_stop_commands().await?;
|
||||
if stop_commands.is_empty() {
|
||||
if let Some(app) = app {
|
||||
emit_install_progress(
|
||||
app,
|
||||
@@ -1172,8 +1260,9 @@ impl OpenClawService {
|
||||
);
|
||||
}
|
||||
} else {
|
||||
for binary in &stop_binaries {
|
||||
self.request_gateway_stop_via_binary(binary, app).await;
|
||||
for command_spec in &stop_commands {
|
||||
self.request_gateway_stop_via_command(command_spec, app)
|
||||
.await;
|
||||
if self
|
||||
.wait_for_gateway_shutdown(Duration::from_secs(4))
|
||||
.await?
|
||||
@@ -1271,7 +1360,7 @@ impl OpenClawService {
|
||||
}
|
||||
|
||||
pub async fn check_update(&self) -> Result<UpdateInfo, String> {
|
||||
let Some(binary) = find_command_in_shell("openclaw").await? else {
|
||||
let Some(openclaw_command) = resolve_openclaw_command().await? else {
|
||||
return Ok(UpdateInfo {
|
||||
has_update: false,
|
||||
current_version: None,
|
||||
@@ -1288,7 +1377,7 @@ impl OpenClawService {
|
||||
.await?
|
||||
.and_then(|value| parse_openclaw_release_version(&value).or(Some(value)));
|
||||
|
||||
let payload = match read_openclaw_update_status_payload(&binary).await {
|
||||
let payload = match read_openclaw_update_status_payload(&openclaw_command).await {
|
||||
Ok(payload) => payload,
|
||||
Err(message) => {
|
||||
return Ok(UpdateInfo {
|
||||
@@ -1336,13 +1425,16 @@ impl OpenClawService {
|
||||
pub async fn perform_update(&mut self, app: &AppHandle) -> Result<ActionResult, String> {
|
||||
emit_install_progress(app, "开始执行 OpenClaw 升级。", "info");
|
||||
|
||||
let Some(binary) = find_command_in_shell("openclaw").await? else {
|
||||
let Some(openclaw_command) = resolve_openclaw_command().await? else {
|
||||
return Ok(ActionResult {
|
||||
success: false,
|
||||
message: "未检测到 OpenClaw 可执行文件,请先安装。".to_string(),
|
||||
});
|
||||
};
|
||||
let current_runtime_bin_dir = Path::new(&binary).parent().map(Path::to_path_buf);
|
||||
let current_runtime_bin_dir = openclaw_command
|
||||
.command_path()
|
||||
.parent()
|
||||
.map(Path::to_path_buf);
|
||||
|
||||
self.refresh_process_state().await?;
|
||||
let gateway_was_running = self.gateway_status == GatewayStatus::Running;
|
||||
@@ -1372,51 +1464,52 @@ impl OpenClawService {
|
||||
);
|
||||
}
|
||||
|
||||
let update_status_payload = match read_openclaw_update_status_payload(&binary).await {
|
||||
Ok(payload) => payload,
|
||||
Err(message) => {
|
||||
emit_install_progress(app, &message, "warn");
|
||||
match attempt_direct_openclaw_package_upgrade(
|
||||
app,
|
||||
current_runtime_bin_dir.as_deref(),
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(result) => {
|
||||
set_preferred_runtime_bin_dir(Some(result.runtime_bin_dir.clone()));
|
||||
emit_install_progress(
|
||||
app,
|
||||
&format!(
|
||||
"已自动切换后续执行环境到 {}。",
|
||||
result.runtime_bin_dir.display()
|
||||
),
|
||||
"info",
|
||||
);
|
||||
return self
|
||||
.finalize_successful_openclaw_update(
|
||||
let update_status_payload =
|
||||
match read_openclaw_update_status_payload(&openclaw_command).await {
|
||||
Ok(payload) => payload,
|
||||
Err(message) => {
|
||||
emit_install_progress(app, &message, "warn");
|
||||
match attempt_direct_openclaw_package_upgrade(
|
||||
app,
|
||||
current_runtime_bin_dir.as_deref(),
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(result) => {
|
||||
set_preferred_runtime_bin_dir(Some(result.runtime_bin_dir.clone()));
|
||||
emit_install_progress(
|
||||
app,
|
||||
gateway_was_running,
|
||||
Some(format!(
|
||||
"OpenClaw 已通过 {} 的 {} 全局升级完成({})。",
|
||||
result.runtime_source,
|
||||
result.package_manager,
|
||||
result.package_spec
|
||||
)),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
Err(fallback_error) => {
|
||||
emit_install_progress(app, &fallback_error, "error");
|
||||
return Ok(ActionResult {
|
||||
success: false,
|
||||
message,
|
||||
});
|
||||
&format!(
|
||||
"已自动切换后续执行环境到 {}。",
|
||||
result.runtime_bin_dir.display()
|
||||
),
|
||||
"info",
|
||||
);
|
||||
return self
|
||||
.finalize_successful_openclaw_update(
|
||||
app,
|
||||
gateway_was_running,
|
||||
Some(format!(
|
||||
"OpenClaw 已通过 {} 的 {} 全局升级完成({})。",
|
||||
result.runtime_source,
|
||||
result.package_manager,
|
||||
result.package_spec
|
||||
)),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
Err(fallback_error) => {
|
||||
emit_install_progress(app, &fallback_error, "error");
|
||||
return Ok(ActionResult {
|
||||
success: false,
|
||||
message,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
};
|
||||
let update_context = extract_openclaw_update_execution_context(&update_status_payload);
|
||||
if let Some(root) = update_context.root.as_ref().filter(|root| root.is_dir()) {
|
||||
emit_install_progress(
|
||||
@@ -1437,8 +1530,7 @@ impl OpenClawService {
|
||||
);
|
||||
}
|
||||
|
||||
let mut command = Command::new(&binary);
|
||||
apply_binary_runtime_path(&mut command, &binary);
|
||||
let mut command = openclaw_command.build_command();
|
||||
if let Some(root) = update_context.root.as_ref().filter(|root| root.is_dir()) {
|
||||
command.current_dir(root);
|
||||
}
|
||||
@@ -1696,26 +1788,30 @@ impl OpenClawService {
|
||||
Ok(false)
|
||||
}
|
||||
|
||||
async fn collect_gateway_stop_binaries(&self) -> Result<Vec<PathBuf>, String> {
|
||||
let mut binaries = Vec::new();
|
||||
async fn collect_gateway_stop_commands(&self) -> Result<Vec<ResolvedOpenClawCommand>, String> {
|
||||
let mut commands = Vec::new();
|
||||
|
||||
if let Some(binary) = find_command_in_shell("openclaw").await? {
|
||||
binaries.push(PathBuf::from(binary));
|
||||
if let Some(command) = resolve_openclaw_command().await? {
|
||||
commands.push(command);
|
||||
}
|
||||
|
||||
let mut runtime_candidates = list_openclaw_runtime_candidates().await?;
|
||||
runtime_candidates.sort_by(compare_openclaw_runtime_candidates);
|
||||
binaries.extend(
|
||||
commands.extend(
|
||||
runtime_candidates
|
||||
.into_iter()
|
||||
.filter_map(|candidate| candidate.openclaw_path.map(PathBuf::from)),
|
||||
.iter()
|
||||
.filter_map(resolve_openclaw_command_from_runtime_candidate),
|
||||
);
|
||||
|
||||
Ok(dedupe_paths(binaries))
|
||||
Ok(dedupe_openclaw_commands(commands))
|
||||
}
|
||||
|
||||
async fn request_gateway_stop_via_binary(&self, binary_path: &Path, app: Option<&AppHandle>) {
|
||||
let binary_label = binary_path.display().to_string();
|
||||
async fn request_gateway_stop_via_command(
|
||||
&self,
|
||||
command_spec: &ResolvedOpenClawCommand,
|
||||
app: Option<&AppHandle>,
|
||||
) {
|
||||
let binary_label = command_spec.invocation_display();
|
||||
if let Some(app) = app {
|
||||
emit_install_progress(
|
||||
app,
|
||||
@@ -1724,12 +1820,7 @@ impl OpenClawService {
|
||||
);
|
||||
}
|
||||
|
||||
let mut command = Command::new(binary_path);
|
||||
if let Some(binary) = binary_path.to_str() {
|
||||
apply_binary_runtime_path(&mut command, binary);
|
||||
} else {
|
||||
apply_windows_no_window(&mut command);
|
||||
}
|
||||
let mut command = command_spec.build_command();
|
||||
let output = timeout(
|
||||
Duration::from_secs(8),
|
||||
command
|
||||
@@ -1861,9 +1952,9 @@ impl OpenClawService {
|
||||
self.gateway_started_at = None;
|
||||
}
|
||||
|
||||
let binary = find_command_in_shell("openclaw").await?;
|
||||
let running =
|
||||
self.check_port_open().await || self.check_gateway_status(binary.as_deref()).await?;
|
||||
let openclaw_command = resolve_openclaw_command().await?;
|
||||
let running = self.check_port_open().await
|
||||
|| self.check_gateway_status(openclaw_command.as_ref()).await?;
|
||||
|
||||
self.gateway_status = if running {
|
||||
GatewayStatus::Running
|
||||
@@ -1891,13 +1982,15 @@ impl OpenClawService {
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
async fn check_gateway_status(&self, binary: Option<&str>) -> Result<bool, String> {
|
||||
let Some(openclaw_path) = binary else {
|
||||
async fn check_gateway_status(
|
||||
&self,
|
||||
command_spec: Option<&ResolvedOpenClawCommand>,
|
||||
) -> Result<bool, String> {
|
||||
let Some(command_spec) = command_spec else {
|
||||
return Ok(false);
|
||||
};
|
||||
|
||||
let mut command = Command::new(openclaw_path);
|
||||
apply_binary_runtime_path(&mut command, &openclaw_path);
|
||||
let mut command = command_spec.build_command();
|
||||
let output = command
|
||||
.arg("gateway")
|
||||
.arg("status")
|
||||
@@ -1925,26 +2018,11 @@ impl OpenClawService {
|
||||
}
|
||||
|
||||
async fn read_openclaw_version(&self) -> Result<Option<String>, String> {
|
||||
let Some(binary) = find_command_in_shell("openclaw").await? else {
|
||||
let Some(command_spec) = resolve_openclaw_command().await? else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
let mut command = Command::new(&binary);
|
||||
apply_binary_runtime_path(&mut command, &binary);
|
||||
let output = command
|
||||
.arg("--version")
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.output()
|
||||
.await
|
||||
.map_err(|e| format!("读取 OpenClaw 版本失败: {e}"))?;
|
||||
|
||||
let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||
if stdout.is_empty() {
|
||||
Ok(None)
|
||||
} else {
|
||||
Ok(Some(stdout))
|
||||
}
|
||||
read_openclaw_version_from_command(&command_spec).await
|
||||
}
|
||||
|
||||
fn gateway_ws_url(&self) -> String {
|
||||
@@ -1977,12 +2055,11 @@ impl OpenClawService {
|
||||
return None;
|
||||
}
|
||||
|
||||
let Some(openclaw_path) = find_command_in_shell("openclaw").await.ok().flatten() else {
|
||||
let Some(command_spec) = resolve_openclaw_command().await.ok().flatten() else {
|
||||
return None;
|
||||
};
|
||||
|
||||
let mut command = Command::new(&openclaw_path);
|
||||
apply_binary_runtime_path(&mut command, &openclaw_path);
|
||||
let mut command = command_spec.build_command();
|
||||
let output = command
|
||||
.arg("gateway")
|
||||
.arg("health")
|
||||
@@ -2154,7 +2231,7 @@ impl OpenClawService {
|
||||
if self.gateway_auth_token.is_empty() {
|
||||
self.gateway_auth_token = generate_auth_token();
|
||||
}
|
||||
let binary = find_command_in_shell("openclaw")
|
||||
let openclaw_command = resolve_openclaw_command()
|
||||
.await?
|
||||
.ok_or_else(|| "未检测到 OpenClaw 可执行文件,请先安装。".to_string())?;
|
||||
let config_path = openclaw_lime_config_path();
|
||||
@@ -2173,7 +2250,7 @@ impl OpenClawService {
|
||||
""
|
||||
},
|
||||
shell_escape(config_path.to_string_lossy().as_ref()),
|
||||
shell_escape(&binary),
|
||||
openclaw_command.preview_invocation(),
|
||||
command
|
||||
),
|
||||
})
|
||||
@@ -2187,7 +2264,7 @@ impl OpenClawService {
|
||||
self.gateway_port = next_port.max(1);
|
||||
}
|
||||
self.restore_auth_token_from_config();
|
||||
let binary = find_command_in_shell("openclaw")
|
||||
let openclaw_command = resolve_openclaw_command()
|
||||
.await?
|
||||
.ok_or_else(|| "未检测到 OpenClaw 可执行文件,请先安装。".to_string())?;
|
||||
let config_path = openclaw_lime_config_path();
|
||||
@@ -2196,7 +2273,7 @@ impl OpenClawService {
|
||||
command: format!(
|
||||
"OPENCLAW_CONFIG_PATH={} {} gateway stop --url {} --token {}",
|
||||
shell_escape(config_path.to_string_lossy().as_ref()),
|
||||
shell_escape(&binary),
|
||||
openclaw_command.preview_invocation(),
|
||||
self.gateway_ws_url(),
|
||||
shell_escape(&self.gateway_auth_token)
|
||||
),
|
||||
@@ -2474,7 +2551,7 @@ async fn inspect_git_dependency_status() -> Result<DependencyStatus, String> {
|
||||
}
|
||||
|
||||
async fn inspect_openclaw_dependency_status() -> Result<DependencyStatus, String> {
|
||||
let Some(path) = find_command_in_shell("openclaw").await? else {
|
||||
let Some(command) = resolve_openclaw_command().await? else {
|
||||
if let Some(status) = inspect_openclaw_package_reload_status().await? {
|
||||
return Ok(status);
|
||||
}
|
||||
@@ -2488,7 +2565,9 @@ async fn inspect_openclaw_dependency_status() -> Result<DependencyStatus, String
|
||||
});
|
||||
};
|
||||
|
||||
let version_text = read_command_version_text(&path, &["--version"]).await?;
|
||||
let version_text = read_openclaw_version_from_command(&command)
|
||||
.await?
|
||||
.unwrap_or_default();
|
||||
Ok(DependencyStatus {
|
||||
status: "ok".to_string(),
|
||||
version: if version_text.is_empty() {
|
||||
@@ -2496,8 +2575,14 @@ async fn inspect_openclaw_dependency_status() -> Result<DependencyStatus, String
|
||||
} else {
|
||||
Some(version_text.clone())
|
||||
},
|
||||
path: Some(path),
|
||||
message: if version_text.is_empty() {
|
||||
path: Some(command.install_path_display()),
|
||||
message: if matches!(command, ResolvedOpenClawCommand::NodeCli { .. }) {
|
||||
if version_text.is_empty() {
|
||||
"已检测到 OpenClaw 包,Lime 将通过当前 Node 运行时直接启动。".to_string()
|
||||
} else {
|
||||
format!("已检测到 OpenClaw 包,Lime 将通过当前 Node 运行时直接启动:{version_text}")
|
||||
}
|
||||
} else if version_text.is_empty() {
|
||||
"已检测到 OpenClaw。".to_string()
|
||||
} else {
|
||||
format!("已检测到 OpenClaw:{version_text}")
|
||||
@@ -3175,9 +3260,10 @@ fn parse_openclaw_release_version(value: &str) -> Option<String> {
|
||||
.and_then(|captures| captures.get(1).map(|value| value.as_str().to_string()))
|
||||
}
|
||||
|
||||
async fn read_openclaw_update_status_payload(binary_path: &str) -> Result<Value, String> {
|
||||
let mut command = Command::new(binary_path);
|
||||
apply_binary_runtime_path(&mut command, binary_path);
|
||||
async fn read_openclaw_update_status_payload(
|
||||
command_spec: &ResolvedOpenClawCommand,
|
||||
) -> Result<Value, String> {
|
||||
let mut command = command_spec.build_command();
|
||||
let output = command
|
||||
.arg("update")
|
||||
.arg("status")
|
||||
@@ -4415,6 +4501,81 @@ fn read_package_version(manifest_path: &Path) -> Option<String> {
|
||||
manifest.version.filter(|item| !item.trim().is_empty())
|
||||
}
|
||||
|
||||
fn resolve_openclaw_cli_entry_from_package_manifest(manifest_path: &Path) -> Option<PathBuf> {
|
||||
let package_root = manifest_path.parent()?;
|
||||
let content = std::fs::read_to_string(manifest_path).ok()?;
|
||||
let manifest = serde_json::from_str::<Value>(&content).ok()?;
|
||||
|
||||
let mut candidates = Vec::new();
|
||||
|
||||
if let Some(bin_value) = manifest.get("bin") {
|
||||
let bin_entry = match bin_value {
|
||||
Value::String(value) => Some(value.as_str()),
|
||||
Value::Object(entries) => entries
|
||||
.get("openclaw")
|
||||
.and_then(Value::as_str)
|
||||
.or_else(|| entries.values().find_map(Value::as_str)),
|
||||
_ => None,
|
||||
};
|
||||
|
||||
if let Some(entry) = bin_entry {
|
||||
candidates.push(package_root.join(entry));
|
||||
}
|
||||
}
|
||||
|
||||
candidates.push(package_root.join("dist").join("index.js"));
|
||||
candidates.push(package_root.join("dist").join("index.mjs"));
|
||||
candidates.push(package_root.join("dist").join("entry.js"));
|
||||
candidates.push(package_root.join("dist").join("entry.mjs"));
|
||||
|
||||
candidates.into_iter().find(|path| path.is_file())
|
||||
}
|
||||
|
||||
fn resolve_openclaw_command_from_runtime_candidate(
|
||||
candidate: &OpenClawRuntimeCandidate,
|
||||
) -> Option<ResolvedOpenClawCommand> {
|
||||
if let Some(openclaw_path) = candidate
|
||||
.openclaw_path
|
||||
.as_deref()
|
||||
.map(PathBuf::from)
|
||||
.filter(|path| path.is_file())
|
||||
{
|
||||
return Some(ResolvedOpenClawCommand::Binary {
|
||||
binary_path: openclaw_path,
|
||||
});
|
||||
}
|
||||
|
||||
let node_path = PathBuf::from(candidate.node_path.as_str());
|
||||
if !node_path.is_file() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let manifest_path = candidate
|
||||
.openclaw_package_path
|
||||
.as_deref()
|
||||
.map(PathBuf::from)?;
|
||||
let cli_path = resolve_openclaw_cli_entry_from_package_manifest(&manifest_path)?;
|
||||
|
||||
Some(ResolvedOpenClawCommand::NodeCli {
|
||||
node_path,
|
||||
cli_path,
|
||||
package_version: read_package_version(&manifest_path),
|
||||
})
|
||||
}
|
||||
|
||||
fn dedupe_openclaw_commands(
|
||||
commands: Vec<ResolvedOpenClawCommand>,
|
||||
) -> Vec<ResolvedOpenClawCommand> {
|
||||
let mut deduped = Vec::with_capacity(commands.len());
|
||||
let mut seen = HashSet::new();
|
||||
for command in commands {
|
||||
if seen.insert(command.dedupe_key()) {
|
||||
deduped.push(command);
|
||||
}
|
||||
}
|
||||
deduped
|
||||
}
|
||||
|
||||
fn dedupe_paths(candidates: Vec<PathBuf>) -> Vec<PathBuf> {
|
||||
let mut deduped = Vec::with_capacity(candidates.len());
|
||||
let mut seen = HashSet::new();
|
||||
@@ -4426,6 +4587,44 @@ fn dedupe_paths(candidates: Vec<PathBuf>) -> Vec<PathBuf> {
|
||||
deduped
|
||||
}
|
||||
|
||||
async fn resolve_openclaw_command() -> Result<Option<ResolvedOpenClawCommand>, String> {
|
||||
if let Some(binary) = find_command_in_shell("openclaw").await? {
|
||||
return Ok(Some(ResolvedOpenClawCommand::Binary {
|
||||
binary_path: PathBuf::from(binary),
|
||||
}));
|
||||
}
|
||||
|
||||
let mut runtime_candidates = list_openclaw_runtime_candidates().await?;
|
||||
runtime_candidates.sort_by(compare_openclaw_runtime_candidates);
|
||||
Ok(runtime_candidates
|
||||
.iter()
|
||||
.find_map(resolve_openclaw_command_from_runtime_candidate))
|
||||
}
|
||||
|
||||
async fn read_openclaw_version_from_command(
|
||||
command_spec: &ResolvedOpenClawCommand,
|
||||
) -> Result<Option<String>, String> {
|
||||
if let Some(version) = command_spec.fallback_version() {
|
||||
return Ok(Some(version));
|
||||
}
|
||||
|
||||
let output = command_spec
|
||||
.build_command()
|
||||
.arg("--version")
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.output()
|
||||
.await
|
||||
.map_err(|e| format!("读取 OpenClaw 版本失败: {e}"))?;
|
||||
|
||||
let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||
if stdout.is_empty() {
|
||||
Ok(None)
|
||||
} else {
|
||||
Ok(Some(stdout))
|
||||
}
|
||||
}
|
||||
|
||||
async fn list_openclaw_runtime_candidates() -> Result<Vec<OpenClawRuntimeCandidate>, String> {
|
||||
let node_candidates =
|
||||
dedupe_paths(collect_standard_command_candidates_without_preference("node").await?);
|
||||
@@ -5062,15 +5261,17 @@ mod tests {
|
||||
format_provider_base_url, gateway_start_args, has_api_version,
|
||||
infer_openclaw_package_name_from_path, npm_global_command_dirs_for,
|
||||
npm_global_node_modules_dirs_for, package_registry_for_package_spec,
|
||||
parse_semver_from_text, resolve_windows_dependency_install_plan,
|
||||
parse_semver_from_text, resolve_openclaw_cli_entry_from_package_manifest,
|
||||
resolve_openclaw_command_from_runtime_candidate, resolve_windows_dependency_install_plan,
|
||||
runtime_candidate_matches_install_root, sanitize_runtime_config,
|
||||
select_best_semver_candidate, select_gateway_start_failure_detail,
|
||||
select_openclaw_update_failure_detail, select_preferred_path_candidate,
|
||||
shell_command_escape_for, shell_npm_prefix_assignment_for, shell_path_assignment_for,
|
||||
trim_trailing_slash, windows_dependency_action_result, windows_dependency_setup_message,
|
||||
windows_install_block_result, windows_manual_install_message, DependencyKind,
|
||||
DependencyStatus, EnvironmentDiagnostics, OpenClawRuntimeCandidate, ShellPlatform,
|
||||
WindowsDependencyInstallPlan, NPM_MIRROR_CN, OPENCLAW_CN_PACKAGE, OPENCLAW_DEFAULT_PACKAGE,
|
||||
DependencyStatus, EnvironmentDiagnostics, OpenClawRuntimeCandidate,
|
||||
ResolvedOpenClawCommand, ShellPlatform, WindowsDependencyInstallPlan, NPM_MIRROR_CN,
|
||||
OPENCLAW_CN_PACKAGE, OPENCLAW_DEFAULT_PACKAGE,
|
||||
};
|
||||
use crate::database::dao::api_key_provider::{ApiKeyProvider, ApiProviderType, ProviderGroup};
|
||||
use chrono::Utc;
|
||||
@@ -5078,6 +5279,7 @@ mod tests {
|
||||
use std::ffi::OsString;
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
fn build_provider(provider_type: ApiProviderType, api_host: &str) -> ApiKeyProvider {
|
||||
ApiKeyProvider {
|
||||
@@ -5099,6 +5301,18 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
fn build_unique_temp_dir(prefix: &str) -> PathBuf {
|
||||
let nanos = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.expect("系统时间应晚于 Unix epoch")
|
||||
.as_nanos();
|
||||
|
||||
std::env::temp_dir().join(format!(
|
||||
"lime-openclaw-{prefix}-{}-{nanos}",
|
||||
std::process::id()
|
||||
))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trims_trailing_slash() {
|
||||
assert_eq!(
|
||||
@@ -5762,6 +5976,86 @@ mod tests {
|
||||
assert_eq!(detected, Some(("openclaw", Some("0.4.1".to_string()))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolves_openclaw_cli_entry_from_dist_index_when_bin_target_missing() {
|
||||
let temp_dir = build_unique_temp_dir("cli-entry");
|
||||
let package_dir = temp_dir
|
||||
.join("node_modules")
|
||||
.join("@qingchencloud/openclaw-zh");
|
||||
let dist_dir = package_dir.join("dist");
|
||||
fs::create_dir_all(&dist_dir).unwrap();
|
||||
fs::write(
|
||||
package_dir.join("package.json"),
|
||||
r#"{
|
||||
"name":"@qingchencloud/openclaw-zh",
|
||||
"version":"2026.3.13-zh.1",
|
||||
"bin":{"openclaw":"openclaw.mjs"}
|
||||
}"#,
|
||||
)
|
||||
.unwrap();
|
||||
fs::write(dist_dir.join("index.js"), "console.log('openclaw');").unwrap();
|
||||
|
||||
let resolved =
|
||||
resolve_openclaw_cli_entry_from_package_manifest(&package_dir.join("package.json"));
|
||||
|
||||
let _ = fs::remove_dir_all(&temp_dir);
|
||||
|
||||
assert_eq!(resolved, Some(package_dir.join("dist").join("index.js")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolves_openclaw_command_from_runtime_candidate_as_node_cli() {
|
||||
let temp_dir = build_unique_temp_dir("runtime-candidate");
|
||||
let node_bin_dir = temp_dir.join("bin");
|
||||
let package_dir = temp_dir
|
||||
.join("node_modules")
|
||||
.join("@qingchencloud/openclaw-zh");
|
||||
let dist_dir = package_dir.join("dist");
|
||||
fs::create_dir_all(&node_bin_dir).unwrap();
|
||||
fs::create_dir_all(&dist_dir).unwrap();
|
||||
|
||||
let node_path = node_bin_dir.join("node");
|
||||
fs::write(&node_path, "").unwrap();
|
||||
fs::write(
|
||||
package_dir.join("package.json"),
|
||||
r#"{
|
||||
"name":"@qingchencloud/openclaw-zh",
|
||||
"version":"2026.3.13-zh.1",
|
||||
"bin":{"openclaw":"openclaw.mjs"}
|
||||
}"#,
|
||||
)
|
||||
.unwrap();
|
||||
fs::write(dist_dir.join("index.js"), "console.log('openclaw');").unwrap();
|
||||
|
||||
let candidate = OpenClawRuntimeCandidate {
|
||||
id: temp_dir.display().to_string(),
|
||||
source: "nvm".to_string(),
|
||||
bin_dir: node_bin_dir.display().to_string(),
|
||||
node_path: node_path.display().to_string(),
|
||||
node_version: Some("23.4.0".to_string()),
|
||||
npm_path: None,
|
||||
npm_global_prefix: None,
|
||||
openclaw_path: None,
|
||||
openclaw_version: Some("2026.3.13-zh.1".to_string()),
|
||||
openclaw_package_path: Some(package_dir.join("package.json").display().to_string()),
|
||||
is_active: true,
|
||||
is_preferred: true,
|
||||
};
|
||||
|
||||
let resolved = resolve_openclaw_command_from_runtime_candidate(&candidate);
|
||||
|
||||
let _ = fs::remove_dir_all(&temp_dir);
|
||||
|
||||
assert_eq!(
|
||||
resolved,
|
||||
Some(ResolvedOpenClawCommand::NodeCli {
|
||||
node_path,
|
||||
cli_path: package_dir.join("dist").join("index.js"),
|
||||
package_version: Some("2026.3.13-zh.1".to_string()),
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn windows_node_prefers_winget_when_available() {
|
||||
assert_eq!(
|
||||
|
||||
@@ -4,5 +4,6 @@
|
||||
//! 本模块保留兼容导出。
|
||||
|
||||
pub use lime_core::tray_format::{
|
||||
format_api_address, format_credential_status, format_request_count, format_server_status,
|
||||
format_api_address, format_credential_status, format_current_model_status,
|
||||
format_request_count, format_server_status,
|
||||
};
|
||||
|
||||
@@ -19,7 +19,7 @@ use tauri::{
|
||||
AppHandle, Manager, Runtime,
|
||||
};
|
||||
use tokio::sync::RwLock;
|
||||
use tracing::{info, warn};
|
||||
use tracing::{debug, info, warn};
|
||||
|
||||
/// 托盘初始化错误
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
@@ -151,13 +151,17 @@ impl TrayIcons {
|
||||
|
||||
/// 从 PNG 文件加载图标
|
||||
fn load_png_file(path: &PathBuf) -> Option<Image<'static>> {
|
||||
if !path.is_file() {
|
||||
debug!("图标文件不存在,跳过加载: {:?}", path);
|
||||
return None;
|
||||
}
|
||||
|
||||
match Image::from_path(path) {
|
||||
Ok(image) => {
|
||||
info!("成功加载图标: {:?}", path);
|
||||
Some(image)
|
||||
}
|
||||
Err(e) => {
|
||||
// 文件不存在是正常情况(图标尚未创建)
|
||||
warn!("无法加载图标文件 {:?}: {}", path, e);
|
||||
None
|
||||
}
|
||||
@@ -223,7 +227,7 @@ impl<R: Runtime> TrayManager<R> {
|
||||
.icon(initial_icon.clone())
|
||||
.menu(&menu)
|
||||
.show_menu_on_left_click(false)
|
||||
.tooltip("Lime - AI API 代理")
|
||||
.tooltip("Lime | 青柠一下,灵感即来")
|
||||
.on_tray_icon_event(|tray, event| {
|
||||
let app = tray.app_handle();
|
||||
handle_tray_icon_event(app, event);
|
||||
@@ -406,6 +410,7 @@ mod tests {
|
||||
total_credentials: 5,
|
||||
today_requests: 100,
|
||||
auto_start_enabled: true,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
manager.update_state(new_state.clone()).await;
|
||||
|
||||
+128
-41
@@ -2,15 +2,20 @@
|
||||
//!
|
||||
//! 定义菜单项 ID 和菜单构建函数
|
||||
|
||||
use super::format::{format_credential_status, format_request_count, format_server_status};
|
||||
use super::format::{
|
||||
format_credential_status, format_current_model_status, format_request_count,
|
||||
format_server_status,
|
||||
};
|
||||
use super::state::TrayStateSnapshot;
|
||||
use tauri::{
|
||||
menu::{CheckMenuItem, Menu, MenuItem, PredefinedMenuItem},
|
||||
menu::{CheckMenuItem, IsMenuItem, Menu, MenuItem, PredefinedMenuItem, Submenu},
|
||||
AppHandle, Runtime,
|
||||
};
|
||||
|
||||
pub use lime_core::tray_menu_meta::menu_ids;
|
||||
pub use lime_core::tray_menu_meta::{get_menu_item_ids, parse_server_address};
|
||||
pub use lime_core::tray_menu_meta::{
|
||||
build_quick_model_item_id, get_menu_item_ids, parse_server_address,
|
||||
};
|
||||
|
||||
/// 托盘菜单构建错误
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
@@ -21,6 +26,68 @@ pub enum MenuBuildError {
|
||||
MenuError(String),
|
||||
}
|
||||
|
||||
fn build_quick_model_submenu<R: Runtime>(
|
||||
app: &AppHandle<R>,
|
||||
state: &TrayStateSnapshot,
|
||||
) -> Result<Option<Submenu<R>>, MenuBuildError> {
|
||||
let non_empty_groups: Vec<_> = state
|
||||
.quick_model_groups
|
||||
.iter()
|
||||
.filter(|group| !group.models.is_empty())
|
||||
.collect();
|
||||
|
||||
if non_empty_groups.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let mut provider_submenus: Vec<Submenu<R>> = Vec::new();
|
||||
|
||||
for group in non_empty_groups {
|
||||
let mut model_items: Vec<CheckMenuItem<R>> = Vec::new();
|
||||
|
||||
for item in &group.models {
|
||||
let checked = item.provider_type == state.current_model_provider_type
|
||||
&& item.model == state.current_model;
|
||||
let menu_item = CheckMenuItem::with_id(
|
||||
app,
|
||||
build_quick_model_item_id(&item.provider_type, &item.model),
|
||||
&item.model,
|
||||
true,
|
||||
checked,
|
||||
None::<&str>,
|
||||
)
|
||||
.map_err(|e| MenuBuildError::MenuItemError(e.to_string()))?;
|
||||
model_items.push(menu_item);
|
||||
}
|
||||
|
||||
let model_item_refs: Vec<&dyn IsMenuItem<R>> = model_items
|
||||
.iter()
|
||||
.map(|item| item as &dyn IsMenuItem<R>)
|
||||
.collect();
|
||||
|
||||
let provider_submenu =
|
||||
Submenu::with_items(app, &group.provider_label, true, &model_item_refs)
|
||||
.map_err(|e| MenuBuildError::MenuItemError(e.to_string()))?;
|
||||
provider_submenus.push(provider_submenu);
|
||||
}
|
||||
|
||||
let provider_refs: Vec<&dyn IsMenuItem<R>> = provider_submenus
|
||||
.iter()
|
||||
.map(|submenu| submenu as &dyn IsMenuItem<R>)
|
||||
.collect();
|
||||
|
||||
let submenu = Submenu::with_id_and_items(
|
||||
app,
|
||||
menu_ids::QUICK_MODEL_ROOT,
|
||||
"快速切换模型",
|
||||
true,
|
||||
&provider_refs,
|
||||
)
|
||||
.map_err(|e| MenuBuildError::MenuItemError(e.to_string()))?;
|
||||
|
||||
Ok(Some(submenu))
|
||||
}
|
||||
|
||||
/// 构建托盘菜单
|
||||
///
|
||||
/// 根据当前状态快照构建完整的托盘菜单,包含:
|
||||
@@ -45,6 +112,28 @@ pub fn build_tray_menu<R: Runtime>(
|
||||
// 解析服务器地址
|
||||
let (host, port) = parse_server_address(&state.server_address);
|
||||
|
||||
// === 当前模型信息 ===
|
||||
let current_model_text = format_current_model_status(
|
||||
&state.current_model_provider_label,
|
||||
&state.current_model,
|
||||
if state.current_theme_label.trim().is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(state.current_theme_label.as_str())
|
||||
},
|
||||
);
|
||||
let current_model_info = MenuItem::with_id(
|
||||
app,
|
||||
menu_ids::CURRENT_MODEL_INFO,
|
||||
¤t_model_text,
|
||||
false,
|
||||
None::<&str>,
|
||||
)
|
||||
.map_err(|e| MenuBuildError::MenuItemError(e.to_string()))?;
|
||||
let quick_model_submenu = build_quick_model_submenu(app, state)?;
|
||||
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(
|
||||
@@ -86,7 +175,7 @@ pub fn build_tray_menu<R: Runtime>(
|
||||
let start_server = MenuItem::with_id(
|
||||
app,
|
||||
menu_ids::START_SERVER,
|
||||
"▶️ 开启团队共享",
|
||||
"启动 Lime 网关",
|
||||
!state.server_running,
|
||||
None::<&str>,
|
||||
)
|
||||
@@ -96,7 +185,7 @@ pub fn build_tray_menu<R: Runtime>(
|
||||
let stop_server = MenuItem::with_id(
|
||||
app,
|
||||
menu_ids::STOP_SERVER,
|
||||
"⏹️ 关闭团队共享",
|
||||
"停止 Lime 网关",
|
||||
state.server_running,
|
||||
None::<&str>,
|
||||
)
|
||||
@@ -106,7 +195,7 @@ pub fn build_tray_menu<R: Runtime>(
|
||||
let refresh_tokens = MenuItem::with_id(
|
||||
app,
|
||||
menu_ids::REFRESH_TOKENS,
|
||||
"🔄 刷新所有 Token",
|
||||
"同步账号凭证",
|
||||
true,
|
||||
None::<&str>,
|
||||
)
|
||||
@@ -116,7 +205,7 @@ pub fn build_tray_menu<R: Runtime>(
|
||||
let health_check = MenuItem::with_id(
|
||||
app,
|
||||
menu_ids::HEALTH_CHECK,
|
||||
"🩺 健康检查",
|
||||
"执行健康检查",
|
||||
true,
|
||||
None::<&str>,
|
||||
)
|
||||
@@ -127,19 +216,14 @@ pub fn build_tray_menu<R: Runtime>(
|
||||
.map_err(|e| MenuBuildError::MenuItemError(e.to_string()))?;
|
||||
|
||||
// === 快捷工具区域 ===
|
||||
let open_window = MenuItem::with_id(
|
||||
app,
|
||||
menu_ids::OPEN_WINDOW,
|
||||
"🖥️ 打开主窗口",
|
||||
true,
|
||||
None::<&str>,
|
||||
)
|
||||
.map_err(|e| MenuBuildError::MenuItemError(e.to_string()))?;
|
||||
let open_window =
|
||||
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,
|
||||
"📋 复制 API 地址",
|
||||
"复制网关地址",
|
||||
state.server_running,
|
||||
None::<&str>,
|
||||
)
|
||||
@@ -148,7 +232,7 @@ pub fn build_tray_menu<R: Runtime>(
|
||||
let open_log_dir = MenuItem::with_id(
|
||||
app,
|
||||
menu_ids::OPEN_LOG_DIR,
|
||||
"📁 打开日志目录",
|
||||
"打开 Lime 日志",
|
||||
true,
|
||||
None::<&str>,
|
||||
)
|
||||
@@ -162,7 +246,7 @@ pub fn build_tray_menu<R: Runtime>(
|
||||
let auto_start = CheckMenuItem::with_id(
|
||||
app,
|
||||
menu_ids::AUTO_START,
|
||||
"🚀 开机自启",
|
||||
"登录时启动 Lime",
|
||||
true,
|
||||
state.auto_start_enabled,
|
||||
None::<&str>,
|
||||
@@ -174,32 +258,35 @@ pub fn build_tray_menu<R: Runtime>(
|
||||
.map_err(|e| MenuBuildError::MenuItemError(e.to_string()))?;
|
||||
|
||||
// === 退出 ===
|
||||
let quit = MenuItem::with_id(app, menu_ids::QUIT, "❌ 退出", true, None::<&str>)
|
||||
let quit = MenuItem::with_id(app, menu_ids::QUIT, "退出 Lime", true, None::<&str>)
|
||||
.map_err(|e| MenuBuildError::MenuItemError(e.to_string()))?;
|
||||
|
||||
// 构建菜单
|
||||
Menu::with_items(
|
||||
app,
|
||||
&[
|
||||
&status_info,
|
||||
&credential_info,
|
||||
&request_info,
|
||||
&separator_1,
|
||||
&start_server,
|
||||
&stop_server,
|
||||
&refresh_tokens,
|
||||
&health_check,
|
||||
&separator_2,
|
||||
&open_window,
|
||||
©_api_address,
|
||||
&open_log_dir,
|
||||
&separator_3,
|
||||
&auto_start,
|
||||
&separator_4,
|
||||
&quit,
|
||||
],
|
||||
)
|
||||
.map_err(|e| MenuBuildError::MenuError(e.to_string()))
|
||||
let mut items: Vec<&dyn IsMenuItem<R>> = vec![¤t_model_info];
|
||||
if let Some(submenu) = quick_model_submenu.as_ref() {
|
||||
items.push(submenu);
|
||||
}
|
||||
items.extend([
|
||||
&separator_0 as &dyn IsMenuItem<R>,
|
||||
&status_info,
|
||||
&credential_info,
|
||||
&request_info,
|
||||
&separator_1,
|
||||
&start_server,
|
||||
&stop_server,
|
||||
&refresh_tokens,
|
||||
&health_check,
|
||||
&separator_2,
|
||||
&open_window,
|
||||
©_api_address,
|
||||
&open_log_dir,
|
||||
&separator_3,
|
||||
&auto_start,
|
||||
&separator_4,
|
||||
&quit,
|
||||
]);
|
||||
|
||||
Menu::with_items(app, &items).map_err(|e| MenuBuildError::MenuError(e.to_string()))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -8,6 +8,8 @@
|
||||
//! - 5.1, 5.2: 设置切换事件处理
|
||||
|
||||
use super::menu::menu_ids;
|
||||
use lime_core::tray_menu_meta::parse_quick_model_item_id;
|
||||
use serde::Serialize;
|
||||
use tauri::{AppHandle, Emitter, Manager, Runtime};
|
||||
use tauri_plugin_autostart::ManagerExt;
|
||||
use tracing::{debug, error, info, warn};
|
||||
@@ -29,6 +31,15 @@ pub mod menu_events {
|
||||
pub const HEALTH_CHECK: &str = "tray-health-check";
|
||||
/// 自启动状态变更事件
|
||||
pub const AUTO_START_CHANGED: &str = "tray-auto-start-changed";
|
||||
/// 托盘快速切换模型事件
|
||||
pub const MODEL_SELECTED: &str = "tray-model-selected";
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct TrayModelSelectedPayload {
|
||||
provider_type: String,
|
||||
model: String,
|
||||
}
|
||||
|
||||
/// 处理菜单事件
|
||||
@@ -42,6 +53,11 @@ pub mod menu_events {
|
||||
pub fn handle_menu_event<R: Runtime>(app: &AppHandle<R>, menu_id: &str) {
|
||||
debug!("处理托盘菜单事件: {}", menu_id);
|
||||
|
||||
if let Some((provider_type, model)) = parse_quick_model_item_id(menu_id) {
|
||||
handle_model_selected(app, provider_type, model);
|
||||
return;
|
||||
}
|
||||
|
||||
match menu_id {
|
||||
// === 服务器控制 ===
|
||||
menu_ids::START_SERVER => handle_start_server(app),
|
||||
@@ -59,7 +75,10 @@ pub fn handle_menu_event<R: Runtime>(app: &AppHandle<R>, menu_id: &str) {
|
||||
menu_ids::AUTO_START => handle_auto_start_toggle(app),
|
||||
|
||||
// 忽略信息类菜单项和分隔符
|
||||
menu_ids::STATUS_INFO | menu_ids::CREDENTIAL_INFO | menu_ids::REQUEST_INFO => {
|
||||
menu_ids::CURRENT_MODEL_INFO
|
||||
| menu_ids::STATUS_INFO
|
||||
| menu_ids::CREDENTIAL_INFO
|
||||
| menu_ids::REQUEST_INFO => {
|
||||
debug!("忽略信息类菜单项: {}", menu_id);
|
||||
}
|
||||
|
||||
@@ -69,6 +88,22 @@ pub fn handle_menu_event<R: Runtime>(app: &AppHandle<R>, menu_id: &str) {
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_model_selected<R: Runtime>(app: &AppHandle<R>, provider_type: String, model: String) {
|
||||
info!(
|
||||
"[托盘] 用户请求切换模型: provider_type={}, model={}",
|
||||
provider_type, model
|
||||
);
|
||||
|
||||
let payload = TrayModelSelectedPayload {
|
||||
provider_type,
|
||||
model,
|
||||
};
|
||||
|
||||
if let Err(e) = app.emit(menu_events::MODEL_SELECTED, payload) {
|
||||
error!("[托盘] 发送模型切换事件失败: {}", e);
|
||||
}
|
||||
}
|
||||
|
||||
/// 处理启动服务器事件
|
||||
///
|
||||
/// # Requirements
|
||||
|
||||
@@ -4,5 +4,6 @@
|
||||
//! 本模块保留兼容导出。
|
||||
|
||||
pub use lime_core::tray_state::{
|
||||
calculate_icon_status, CredentialHealth, TrayIconStatus, TrayStateSnapshot,
|
||||
calculate_icon_status, CredentialHealth, TrayIconStatus, TrayQuickModelGroup,
|
||||
TrayQuickModelItem, TrayStateSnapshot,
|
||||
};
|
||||
|
||||
@@ -51,6 +51,7 @@ impl<R: Runtime> TraySynchronizer<R> {
|
||||
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);
|
||||
@@ -72,6 +73,11 @@ impl<R: Runtime> TraySynchronizer<R> {
|
||||
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,
|
||||
};
|
||||
|
||||
// 更新托盘状态
|
||||
|
||||
Reference in New Issue
Block a user