Release v0.90.0

This commit is contained in:
coso
2026-03-18 10:39:02 +08:00
parent 157e400c97
commit 8c2cf9f97b
225 changed files with 11935 additions and 7890 deletions
+4 -3
View File
@@ -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 |
## 凭证池桥接
+40 -3
View File
@@ -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
}
}
+64
View File
@@ -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
View File
@@ -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,
+30 -9
View File
@@ -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 {
+7 -359
View File
@@ -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())
+260 -161
View File
@@ -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,
},
+67 -10
View File
@@ -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;
+3 -3
View File
@@ -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(),
+30 -8
View File
@@ -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, "调用工具失败");
+37 -1
View File
@@ -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)
}
}
}
+1 -1
View File
@@ -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,
+47 -1
View File
@@ -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(())
}
+67 -14
View File
@@ -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)
+5 -5
View File
@@ -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())
}
+1 -1
View File
@@ -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),
};
+1
View File
@@ -54,6 +54,7 @@ mod data;
#[allow(dead_code)]
mod dev_bridge;
mod logger;
mod profiling;
mod theme;
use lime_core::models;
+348
View File
@@ -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())
}
+103 -372
View File
@@ -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(
+40 -155
View File
@@ -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);
}
}
+406 -112
View File
@@ -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!(
+2 -1
View File
@@ -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,
};
+8 -3
View File
@@ -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
View File
@@ -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,
&current_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,
&copy_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![&current_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,
&copy_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)]
+36 -1
View File
@@ -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
+2 -1
View File
@@ -4,5 +4,6 @@
//! 本模块保留兼容导出。
pub use lime_core::tray_state::{
calculate_icon_status, CredentialHealth, TrayIconStatus, TrayStateSnapshot,
calculate_icon_status, CredentialHealth, TrayIconStatus, TrayQuickModelGroup,
TrayQuickModelItem, TrayStateSnapshot,
};
+6
View File
@@ -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,
};
// 更新托盘状态