release: v0.91.0

This commit is contained in:
coso
2026-03-19 19:05:53 +08:00
parent ad8427be93
commit a414f44872
175 changed files with 10467 additions and 11013 deletions
+17 -20
View File
@@ -10,7 +10,7 @@ use futures::StreamExt;
use lime_agent::{convert_agent_event, TauriAgentEvent, WriteArtifactEventEmitter};
use tauri::{AppHandle, Emitter};
pub use lime_agent::session_store::{
pub use lime_agent::{
PersistedSessionMetadata, SessionDetail, SessionInfo, SessionTitlePreviewMessage,
};
@@ -121,30 +121,31 @@ impl AsterAgentWrapper {
workspace_id: String,
execution_strategy: Option<String>,
) -> Result<String, String> {
lime_agent::session_store::create_session_sync(
db,
name,
working_dir,
workspace_id,
execution_strategy,
)
lime_agent::create_session_sync(db, name, working_dir, workspace_id, execution_strategy)
}
/// 列出所有会话
pub fn list_sessions_sync(db: &DbConnection) -> Result<Vec<SessionInfo>, String> {
lime_agent::session_store::list_sessions_sync(db)
lime_agent::list_sessions_sync(db)
}
/// 获取会话详情
pub fn get_session_sync(db: &DbConnection, session_id: &str) -> Result<SessionDetail, String> {
lime_agent::session_store::get_session_sync(db, session_id)
lime_agent::get_session_sync(db, session_id)
}
pub async fn get_runtime_session_detail(
db: &DbConnection,
session_id: &str,
) -> Result<SessionDetail, String> {
lime_agent::get_runtime_session_detail(db, session_id).await
}
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)
lime_agent::get_persisted_session_metadata_sync(db, session_id)
}
pub fn list_title_preview_messages_sync(
@@ -152,7 +153,7 @@ impl AsterAgentWrapper {
session_id: &str,
limit: usize,
) -> Result<Vec<SessionTitlePreviewMessage>, String> {
lime_agent::session_store::list_title_preview_messages_sync(db, session_id, limit)
lime_agent::list_title_preview_messages_sync(db, session_id, limit)
}
/// 重命名会话
@@ -161,7 +162,7 @@ impl AsterAgentWrapper {
session_id: &str,
name: &str,
) -> Result<(), String> {
lime_agent::session_store::rename_session_sync(db, session_id, name)
lime_agent::rename_session_sync(db, session_id, name)
}
pub fn update_session_working_dir_sync(
@@ -169,7 +170,7 @@ impl AsterAgentWrapper {
session_id: &str,
working_dir: &str,
) -> Result<(), String> {
lime_agent::session_store::update_session_working_dir_sync(db, session_id, working_dir)
lime_agent::update_session_working_dir_sync(db, session_id, working_dir)
}
pub fn update_session_execution_strategy_sync(
@@ -177,16 +178,12 @@ impl AsterAgentWrapper {
session_id: &str,
execution_strategy: &str,
) -> Result<(), String> {
lime_agent::session_store::update_session_execution_strategy_sync(
db,
session_id,
execution_strategy,
)
lime_agent::update_session_execution_strategy_sync(db, session_id, execution_strategy)
}
/// 删除会话
pub async fn delete_session(db: &DbConnection, session_id: &str) -> Result<(), String> {
lime_agent::session_store::delete_session(db, session_id).await
lime_agent::delete_session(db, session_id).await
}
}
+4
View File
@@ -0,0 +1,4 @@
//! 旧 agent integration 壳层已退出编译图。
//!
//! Aster runtime 的启动初始化与全局 session store 注册
//! 已统一收口到 `lime_agent::initialize_aster_runtime`。
+6 -5
View File
@@ -3,10 +3,11 @@
//! 纯逻辑部分已迁移到 lime-agent crate,
//! 本模块保留深耦合部分(Aster 状态与 Tauri 桥接)。
pub mod aster_agent;
mod aster_agent;
pub mod aster_state;
pub mod credential_bridge;
pub mod subagent_scheduler;
mod credential_bridge;
pub mod runtime_queue_service;
mod subagent_scheduler;
// 从 lime-agent crate re-export
pub use lime_agent::event_converter;
@@ -23,8 +24,8 @@ pub use credential_bridge::{
create_aster_provider, AsterProviderConfig, CredentialBridge, CredentialBridgeError,
};
pub use lime_agent::{
convert_agent_event, convert_to_tauri_message, QueueInsertResult, QueuedTurnSnapshot,
QueuedTurnTask, SessionTurnQueueManager, TauriAgentEvent,
convert_agent_event, convert_to_tauri_message, initialize_aster_runtime, QueuedTurnSnapshot,
QueuedTurnTask, TauriAgentEvent,
};
pub use subagent_scheduler::{
LimeScheduler, LimeSubAgentExecutor, SubAgentProgressEvent, SubAgentRole,
@@ -0,0 +1,209 @@
//! Agent runtime queue 共享服务边界。
//!
//! 命令层只保留 Tauri 状态装配;
//! queue 的纯调度与数据事实源统一委托给 `lime-agent`。
use super::aster_state::AsterAgentState;
use crate::commands::api_key_provider_cmd::ApiKeyProviderServiceState;
use crate::config::GlobalConfigManagerState;
use crate::database::DbConnection;
use crate::mcp::McpManagerState;
use crate::services::automation_service::AutomationServiceState;
use crate::LogState;
use aster::session::QueuedTurnRuntime;
use lime_agent::{
clear_runtime_queue as clear_runtime_queue_impl,
list_runtime_queue_snapshots as list_runtime_queue_snapshots_impl,
remove_runtime_queued_turn as remove_runtime_queued_turn_impl,
resume_persisted_runtime_queues_on_startup as resume_persisted_runtime_queues_on_startup_impl,
resume_runtime_queue_if_needed as resume_runtime_queue_if_needed_impl,
submit_runtime_turn as submit_runtime_turn_impl, QueuedTurnSnapshot, QueuedTurnTask,
RuntimeQueueEventEmitter, RuntimeQueueExecutor as SharedRuntimeQueueExecutor, TauriAgentEvent,
};
use serde_json::Value;
use tauri::{AppHandle, Emitter};
pub(crate) type RuntimeQueueExecutor = SharedRuntimeQueueExecutor<AgentRuntimeQueueContext>;
pub(crate) struct AgentRuntimeQueueContext {
pub(crate) app: AppHandle,
pub(crate) state: AsterAgentState,
pub(crate) db: DbConnection,
pub(crate) api_key_provider_service: ApiKeyProviderServiceState,
pub(crate) logs: LogState,
pub(crate) config_manager: GlobalConfigManagerState,
pub(crate) mcp_manager: McpManagerState,
pub(crate) automation_state: AutomationServiceState,
}
impl Clone for AgentRuntimeQueueContext {
fn clone(&self) -> Self {
Self {
app: self.app.clone(),
state: self.state.clone(),
db: self.db.clone(),
api_key_provider_service: ApiKeyProviderServiceState(
self.api_key_provider_service.0.clone(),
),
logs: self.logs.clone(),
config_manager: GlobalConfigManagerState(self.config_manager.0.clone()),
mcp_manager: self.mcp_manager.clone(),
automation_state: self.automation_state.clone(),
}
}
}
fn build_runtime_queue_context(
app: AppHandle,
state: &AsterAgentState,
db: &DbConnection,
api_key_provider_service: &ApiKeyProviderServiceState,
logs: &LogState,
config_manager: &GlobalConfigManagerState,
mcp_manager: &McpManagerState,
automation_state: &AutomationServiceState,
) -> AgentRuntimeQueueContext {
AgentRuntimeQueueContext {
app,
state: state.clone(),
db: db.clone(),
api_key_provider_service: ApiKeyProviderServiceState(api_key_provider_service.0.clone()),
logs: logs.clone(),
config_manager: GlobalConfigManagerState(config_manager.0.clone()),
mcp_manager: mcp_manager.clone(),
automation_state: automation_state.clone(),
}
}
fn build_runtime_queue_event_emitter(app: &AppHandle) -> RuntimeQueueEventEmitter {
let app = app.clone();
std::sync::Arc::new(move |event_name: String, event: TauriAgentEvent| {
if let Err(error) = app.emit(&event_name, &event) {
tracing::warn!(
"[AsterAgent][Queue] 发送队列事件失败: event_name={}, error={}",
event_name,
error
);
}
})
}
pub(crate) async fn resume_runtime_queue_if_needed(
app: AppHandle,
state: &AsterAgentState,
db: &DbConnection,
api_key_provider_service: &ApiKeyProviderServiceState,
logs: &LogState,
config_manager: &GlobalConfigManagerState,
mcp_manager: &McpManagerState,
automation_state: &AutomationServiceState,
session_id: String,
executor: RuntimeQueueExecutor,
) -> Result<bool, String> {
let context = build_runtime_queue_context(
app,
state,
db,
api_key_provider_service,
logs,
config_manager,
mcp_manager,
automation_state,
);
resume_runtime_queue_if_needed_impl(
session_id,
context.clone(),
executor,
build_runtime_queue_event_emitter(&context.app),
)
.await
}
pub(crate) async fn submit_runtime_turn(
app: AppHandle,
state: &AsterAgentState,
db: &DbConnection,
api_key_provider_service: &ApiKeyProviderServiceState,
logs: &LogState,
config_manager: &GlobalConfigManagerState,
mcp_manager: &McpManagerState,
automation_state: &AutomationServiceState,
queued_task: QueuedTurnTask<Value>,
queue_if_busy: bool,
executor: RuntimeQueueExecutor,
) -> Result<(), String> {
let context = build_runtime_queue_context(
app,
state,
db,
api_key_provider_service,
logs,
config_manager,
mcp_manager,
automation_state,
);
submit_runtime_turn_impl(
queued_task,
queue_if_busy,
context.clone(),
executor,
build_runtime_queue_event_emitter(&context.app),
)
.await
}
pub(crate) async fn clear_runtime_queue(
app: &AppHandle,
session_id: &str,
) -> Result<Vec<QueuedTurnRuntime>, String> {
clear_runtime_queue_impl(session_id, build_runtime_queue_event_emitter(app)).await
}
pub(crate) async fn list_runtime_queue_snapshots(
session_id: &str,
) -> Result<Vec<QueuedTurnSnapshot>, String> {
list_runtime_queue_snapshots_impl(session_id).await
}
pub(crate) async fn remove_runtime_queued_turn(
app: &AppHandle,
session_id: &str,
queued_turn_id: &str,
) -> Result<bool, String> {
remove_runtime_queued_turn_impl(
session_id,
queued_turn_id,
build_runtime_queue_event_emitter(app),
)
.await
}
pub(crate) async fn resume_persisted_runtime_queues_on_startup(
app: AppHandle,
state: &AsterAgentState,
db: &DbConnection,
api_key_provider_service: &ApiKeyProviderServiceState,
logs: &LogState,
config_manager: &GlobalConfigManagerState,
mcp_manager: &McpManagerState,
automation_state: &AutomationServiceState,
executor: RuntimeQueueExecutor,
) -> Result<usize, String> {
let context = build_runtime_queue_context(
app.clone(),
state,
db,
api_key_provider_service,
logs,
config_manager,
mcp_manager,
automation_state,
);
resume_persisted_runtime_queues_on_startup_impl(
context,
executor,
build_runtime_queue_event_emitter(&app),
)
.await
}
+4 -51
View File
@@ -5,7 +5,7 @@
use std::sync::Arc;
use tokio::sync::RwLock;
use crate::agent::AsterAgentState;
use crate::agent::{initialize_aster_runtime, AsterAgentState};
use crate::commands::api_key_provider_cmd::ApiKeyProviderServiceState;
use crate::commands::connect_cmd::ConnectStateWrapper;
use crate::commands::context_memory::ContextMemoryServiceState;
@@ -19,7 +19,6 @@ use crate::commands::resilience_cmd::ResilienceConfigState;
use crate::commands::session_files_cmd::SessionFilesState;
use crate::commands::skill_cmd::SkillServiceState;
use crate::commands::terminal_cmd::TerminalManagerState;
use crate::commands::tool_hooks::ToolHooksServiceState;
use crate::commands::webview_cmd::{
ChromeProfileManagerWrapper, WebviewManagerState, WebviewManagerWrapper,
};
@@ -36,12 +35,10 @@ use lime_core::config::{Config, ConfigManager};
use lime_scheduler::AgentScheduler;
use lime_server as server;
use lime_services::api_key_provider_service::ApiKeyProviderService;
use lime_services::aster_session_store::LimeSessionStore;
use lime_services::context_memory_service::{ContextMemoryConfig, ContextMemoryService};
use lime_services::provider_pool_service::ProviderPoolService;
use lime_services::skill_service::SkillService;
use lime_services::token_cache_service::TokenCacheService;
use lime_services::tool_hooks_service::ToolHooksService;
use lime_services::update_check_service::UpdateCheckServiceState;
use super::types::{AppState, LogState, TokenCacheServiceState};
@@ -75,7 +72,6 @@ pub struct AppStates {
pub update_check_service: UpdateCheckServiceState,
pub session_files: SessionFilesState,
pub context_memory_service: ContextMemoryServiceState,
pub tool_hooks_service: ToolHooksServiceState,
pub recording_service: RecordingServiceState,
pub mcp_manager: McpManagerState,
pub automation_service: AutomationServiceState,
@@ -131,6 +127,8 @@ pub fn init_states(config: &Config) -> Result<AppStates, String> {
}
}
initialize_aster_runtime(db.clone()).map_err(|e| format!("Aster 运行时初始化失败: {e}"))?;
// 服务状态
let skill_service = SkillService::new().map_err(|e| format!("SkillService 初始化失败: {e}"))?;
let skill_service_state = SkillServiceState(Arc::new(skill_service));
@@ -181,44 +179,6 @@ pub fn init_states(config: &Config) -> Result<AppStates, String> {
let (telemetry_state, shared_stats, shared_tokens, shared_logger) = init_telemetry(config)?;
// 其他状态
// 设置 Aster 全局 session store(使用 Lime 数据库)
let session_store = Arc::new(LimeSessionStore::new(db.clone()));
// 使用 tokio runtime 来设置全局 store
// 使用 Builder 模式以获得更好的跨平台兼容性
let rt = tokio::runtime::Handle::try_current().unwrap_or_else(|_| {
// Windows: IOCP, macOS: kqueue, Linux: epoll/io-uring
#[cfg(target_os = "windows")]
tracing::info!("[Bootstrap] Windows 平台 - 创建 Tokio Runtime (IOCP)");
#[cfg(target_os = "macos")]
tracing::info!("[Bootstrap] macOS 平台 - 创建 Tokio Runtime (kqueue)");
#[cfg(target_os = "linux")]
tracing::info!("[Bootstrap] Linux 平台 - 创建 Tokio Runtime (epoll)");
// 使用 Builder 模式获得更多控制,提高 Windows 兼容性
tokio::runtime::Builder::new_multi_thread()
.worker_threads(2) // 限制线程数,避免 Windows 资源问题
.thread_name("lime-runtime")
.enable_io()
.enable_time()
.build()
.expect("Failed to create tokio runtime: 系统资源不足或配置错误")
.handle()
.clone()
});
rt.block_on(async {
if let Err(e) = aster::session::set_global_session_store(session_store).await {
tracing::warn!(
"[Bootstrap] 设置全局 session store 失败(可能已设置): {}",
e
);
} else {
tracing::info!("[Bootstrap] 已设置 Aster 全局 session store");
}
});
let aster_agent_state = AsterAgentState::new();
let orchestrator_state = OrchestratorState::new();
@@ -282,13 +242,7 @@ pub fn init_states(config: &Config) -> Result<AppStates, String> {
let context_memory_config = build_context_memory_config(config);
let context_memory_service = ContextMemoryService::new(context_memory_config)
.map_err(|e| format!("ContextMemoryService 初始化失败: {e}"))?;
let context_memory_service_arc = Arc::new(context_memory_service);
let context_memory_service_state =
ContextMemoryServiceState(context_memory_service_arc.clone());
// 初始化工具钩子服务
let tool_hooks_service = ToolHooksService::new(context_memory_service_arc.clone());
let tool_hooks_service_state = ToolHooksServiceState(Arc::new(tool_hooks_service));
let context_memory_service_state = ContextMemoryServiceState(Arc::new(context_memory_service));
// 录音服务(使用独立线程 + channel 通信解决 cpal::Stream 不是 Send 的问题)
let recording_service_state = create_recording_service_state();
@@ -339,7 +293,6 @@ pub fn init_states(config: &Config) -> Result<AppStates, String> {
update_check_service: update_check_service_state,
session_files: session_files_state,
context_memory_service: context_memory_service_state,
tool_hooks_service: tool_hooks_service_state,
recording_service: recording_service_state,
mcp_manager: mcp_manager_state,
automation_service: automation_service_state,
+1 -4
View File
@@ -5,24 +5,21 @@
//! ## 模块结构
//! - `types` - 核心类型定义(ProviderType 等)
//! - `state` - 状态类型和初始化
//! - `setup` - Tauri setup hook
//! - `commands` - 内置 Tauri 命令
//! - `utils` - 辅助函数
//! - `bootstrap` - 应用启动引导(配置验证、状态初始化)
//! - `runner` - 应用运行器(Tauri Builder 配置和命令注册)
//! - `runner` - 应用运行器(Tauri Builder 配置、setup 和命令注册)
pub mod bootstrap;
pub mod commands;
pub mod runner;
pub mod scheduler_service;
mod setup;
mod state;
mod types;
mod utils;
pub use runner::run;
pub use scheduler_service::{SchedulerService, SchedulerServiceConfig};
pub use setup::setup_app;
pub use state::*;
pub use types::*;
pub use utils::*;
+41 -59
View File
@@ -21,6 +21,19 @@ fn should_minimize_to_tray(window_label: &str, minimize_to_tray: bool) -> bool {
minimize_to_tray && window_label == MAIN_WINDOW_LABEL
}
fn reveal_main_window(window: &tauri::WebviewWindow) {
let run_action = |action: &str, operation: &dyn Fn() -> tauri::Result<()>| {
if let Err(error) = operation() {
tracing::warn!("[启动] 主窗口{}失败: {}", action, error);
}
};
run_action("取消最小化", &|| window.unminimize());
run_action("最大化", &|| window.maximize());
run_action("显示", &|| window.show());
run_action("聚焦", &|| window.set_focus());
}
/// 运行 Tauri 应用
///
/// 这是应用的主入口点,负责:
@@ -87,7 +100,6 @@ pub fn run() {
update_check_service: update_check_service_state,
session_files: session_files_state,
context_memory_service,
tool_hooks_service,
recording_service,
mcp_manager: mcp_manager_state,
automation_service: automation_service_state,
@@ -144,10 +156,7 @@ pub fn run() {
// 将窗口带到前台
if let Some(window) = app.get_webview_window("main") {
let _ = window.unminimize();
let _ = window.maximize();
let _ = window.show();
let _ = window.set_focus();
reveal_main_window(&window);
}
}));
@@ -177,7 +186,6 @@ pub fn run() {
.manage(update_check_service_state)
.manage(session_files_state)
.manage(context_memory_service)
.manage(tool_hooks_service)
.manage(recording_service)
.manage(mcp_manager_state)
.manage(automation_service_state)
@@ -216,20 +224,17 @@ pub fn run() {
}
})
.setup(move |app| {
// 启动时先最大化再显示,避免用户看到“先小窗后展开”的过程
// 启动时先最大化再显示,避免用户看到“先小窗后展开”的过程。
if let Some(main_window) = app.get_webview_window("main") {
if let Err(e) = main_window.maximize() {
tracing::warn!("[启动] 主窗口最大化失败: {}", e);
}
if let Err(e) = main_window.show() {
tracing::warn!("[启动] 主窗口显示失败: {}", e);
}
reveal_main_window(&main_window);
#[cfg(debug_assertions)]
if crate::profiling::should_open_webview_devtools() {
main_window.open_devtools();
tracing::info!("[Profiling] 已自动打开主窗口 WebView DevTools");
}
} else {
tracing::warn!("[启动] 未找到主窗口,无法执行启动展示流程");
}
#[cfg(target_os = "windows")]
@@ -360,26 +365,30 @@ pub fn run() {
automation_state,
)) = startup_runtime_resume
{
match crate::commands::aster_agent_cmd::resume_persisted_runtime_queues_on_startup(
app_handle,
&state,
&db,
&api_key_provider_service,
&logs,
&config_manager,
&mcp_manager,
&automation_state,
) {
Ok(resumed) if resumed > 0 => {
tracing::info!("[启动] 已恢复 {} 个会话的排队执行", resumed);
tauri::async_runtime::spawn(async move {
match crate::commands::aster_agent_cmd::resume_persisted_runtime_queues_on_startup(
app_handle,
&state,
&db,
&api_key_provider_service,
&logs,
&config_manager,
&mcp_manager,
&automation_state,
)
.await
{
Ok(resumed) if resumed > 0 => {
tracing::info!("[启动] 已恢复 {} 个会话的排队执行", resumed);
}
Ok(_) => {
tracing::debug!("[启动] 无需恢复持久化排队执行");
}
Err(error) => {
tracing::warn!("[启动] 恢复持久化排队执行失败: {}", error);
}
}
Ok(_) => {
tracing::debug!("[启动] 无需恢复持久化排队执行");
}
Err(error) => {
tracing::warn!("[启动] 恢复持久化排队执行失败: {}", error);
}
}
});
}
#[cfg(debug_assertions)]
@@ -1585,16 +1594,6 @@ pub fn run() {
commands::document_import_cmd::import_document,
commands::document_import_cmd::import_document_to_session,
commands::document_import_cmd::save_exported_document,
// Unified Chat commands(统一对话 API,后续治理收口入口)
commands::unified_chat_cmd::chat_create_session,
commands::unified_chat_cmd::chat_list_sessions,
commands::unified_chat_cmd::chat_get_session,
commands::unified_chat_cmd::chat_delete_session,
commands::unified_chat_cmd::chat_rename_session,
commands::unified_chat_cmd::chat_get_messages,
commands::unified_chat_cmd::chat_send_message,
commands::unified_chat_cmd::chat_stop_generation,
commands::unified_chat_cmd::chat_configure_provider,
// Workspace commands
commands::workspace_cmd::workspace_create,
commands::workspace_cmd::workspace_list,
@@ -1716,15 +1715,6 @@ pub fn run() {
commands::memory_cmd::outline_node_update,
commands::memory_cmd::outline_node_delete,
commands::memory_cmd::project_memory_get,
// Context Memory commands
commands::context_memory::save_memory_entry,
commands::context_memory::get_session_memories,
commands::context_memory::get_memory_context,
commands::context_memory::record_error,
commands::context_memory::should_avoid_operation,
commands::context_memory::mark_error_resolved,
commands::context_memory::get_memory_stats,
commands::context_memory::cleanup_expired_memories,
// Usage Stats commands
commands::usage_stats_cmd::get_usage_stats,
commands::usage_stats_cmd::get_model_usage_ranking,
@@ -1757,14 +1747,6 @@ pub fn run() {
// File Upload commands
commands::file_upload_cmd::upload_avatar,
commands::file_upload_cmd::delete_avatar,
// Tool Hooks commands
commands::tool_hooks::execute_hooks,
commands::tool_hooks::add_hook_rule,
commands::tool_hooks::remove_hook_rule,
commands::tool_hooks::toggle_hook_rule,
commands::tool_hooks::get_hook_rules,
commands::tool_hooks::get_hook_execution_stats,
commands::tool_hooks::clear_hook_execution_stats,
// ASR commands
commands::asr_cmd::get_asr_credentials,
commands::asr_cmd::add_asr_credential,
-289
View File
@@ -1,289 +0,0 @@
//! Tauri Setup Hook
//!
//! 包含应用启动时的初始化逻辑。
use std::sync::Arc;
use tauri::{App, Manager};
// use crate::agent::tools::{set_term_scrollback_tool_app_handle, set_terminal_tool_app_handle};
use crate::agent::AsterAgentState;
use crate::database;
use crate::skills::ensure_default_local_skills;
use crate::telemetry;
use crate::tray::{TrayIconStatus, TrayManager, TrayStateSnapshot};
use lime_scheduler::AgentScheduler;
use lime_services::aster_session_store::LimeSessionStore;
use lime_services::provider_pool_service::ProviderPoolService;
use lime_services::token_cache_service::TokenCacheService;
use super::scheduler_service::{SchedulerService, SchedulerServiceConfig};
use super::types::{AppState, LogState, TrayManagerState};
/// Tauri setup hook
///
/// 在应用启动时执行初始化逻辑
#[allow(clippy::too_many_arguments)]
pub fn setup_app(
app: &mut App,
state: AppState,
logs: LogState,
db: database::DbConnection,
pool_service: Arc<ProviderPoolService>,
token_cache: Arc<TokenCacheService>,
shared_stats: Arc<parking_lot::RwLock<telemetry::StatsAggregator>>,
shared_tokens: Arc<parking_lot::RwLock<telemetry::TokenTracker>>,
shared_logger: Arc<telemetry::RequestLogger>,
) -> Result<(), Box<dyn std::error::Error>> {
// 注册全局 SessionStore(作为后备方案)
// 注意:主要的 SessionStore 注入在 AsterAgentState::init_agent_with_db() 中完成
// 这里的全局注册是为了兼容可能直接使用 SessionManager 静态方法的代码
let session_store = Arc::new(LimeSessionStore::new(db.clone()));
tauri::async_runtime::block_on(async {
if let Err(e) = aster::session::set_global_session_store(session_store).await {
tracing::warn!("[启动] 注册全局 SessionStore 失败(可能已注册): {}", e);
} else {
tracing::info!("[启动] 全局 LimeSessionStore 已注册(后备方案)");
}
});
// 初始化托盘管理器
match TrayManager::new(app.handle()) {
Ok(tray_manager) => {
tracing::info!("[启动] 托盘管理器初始化成功");
let tray_state: TrayManagerState<tauri::Wry> =
TrayManagerState(Arc::new(tokio::sync::RwLock::new(Some(tray_manager))));
app.manage(tray_state);
}
Err(e) => {
tracing::error!("[启动] 托盘管理器初始化失败: {}", e);
let tray_state: TrayManagerState<tauri::Wry> =
TrayManagerState(Arc::new(tokio::sync::RwLock::new(None)));
app.manage(tray_state);
}
}
// 初始化 AsterAgentState
let aster_agent_state = AsterAgentState::new();
app.manage(aster_agent_state);
// TODO: 重新实现 TerminalTool 和 TermScrollbackTool 的 AppHandle 设置
// 当前暂时注释掉,等待适配 aster-rust 工具系统
// set_terminal_tool_app_handle(app.handle().clone());
// tracing::info!("[启动] TerminalTool AppHandle 已设置");
// set_term_scrollback_tool_app_handle(app.handle().clone());
// tracing::info!("[启动] TermScrollbackTool AppHandle 已设置");
// 初始化默认 skill repos
{
let conn = lime_core::database::lock_db(&db)?;
database::dao::skills::SkillDao::init_default_skill_repos(&conn)
.expect("Failed to initialize default skill repos");
}
match ensure_default_local_skills() {
Ok(installed) if installed.is_empty() => {
tracing::info!("[启动] 默认本地 Skills 已存在,跳过写入");
}
Ok(installed) => {
tracing::info!("[启动] 默认本地 Skills 安装完成: {}", installed.join(", "));
}
Err(error) => {
tracing::warn!("[启动] 安装默认本地 Skills 失败: {}", error);
}
}
// 初始化调度器数据库表
if let Err(e) = AgentScheduler::init_tables(&db) {
tracing::error!("[启动] 调度器表初始化失败: {}", e);
} else {
tracing::info!("[启动] 调度器表初始化成功");
}
// 启动调度器服务
let scheduler_config = SchedulerServiceConfig::default();
let scheduler_service = SchedulerService::new(db.clone(), scheduler_config);
scheduler_service.start(db.clone());
tracing::info!("[启动] 调度器服务已启动");
// 将调度器服务注册为 Tauri 状态,以便后续访问
app.manage(Arc::new(scheduler_service));
// 自动启动服务器
let app_handle = app.handle().clone();
tauri::async_runtime::spawn(async move {
start_server_async(
state,
logs,
db,
pool_service,
token_cache,
shared_stats,
shared_tokens,
shared_logger,
app_handle,
)
.await;
});
Ok(())
}
/// 异步启动服务器
#[allow(clippy::too_many_arguments)]
async fn start_server_async(
state: AppState,
logs: LogState,
db: database::DbConnection,
pool_service: Arc<ProviderPoolService>,
token_cache: Arc<TokenCacheService>,
shared_stats: Arc<parking_lot::RwLock<telemetry::StatsAggregator>>,
shared_tokens: Arc<parking_lot::RwLock<telemetry::TokenTracker>>,
shared_logger: Arc<telemetry::RequestLogger>,
app_handle: tauri::AppHandle,
) {
let mut available_credentials = 0usize;
let mut total_credentials = 0usize;
// 先加载凭证池中的凭证
{
logs.write().await.add("info", "[启动] 正在加载凭证池...");
match pool_service.get_overview(&db) {
Ok(overview) => {
let mut loaded_types = Vec::new();
for provider_overview in overview {
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",
"qwen" => "通义千问",
"antigravity" => "Antigravity",
"openai" => "OpenAI",
"claude" => "Claude",
"codex" => "Codex",
"claude_oauth" => "Claude OAuth",
"iflow" => "iFlow",
_ => &provider_overview.provider_type,
};
loaded_types.push(format!("{provider_name} ({count} 个)"));
}
}
if loaded_types.is_empty() {
logs.write().await.add("warn", "[启动] 未找到任何可用凭证");
} else {
let message = format!(
"[启动] 凭证已加载: {} (共 {} 个)",
loaded_types.join(", "),
total_credentials
);
logs.write().await.add("info", &message);
}
}
Err(e) => {
logs.write()
.await
.add("warn", &format!("[启动] 获取凭证池信息失败: {e}"));
}
}
// 兼容性:仍然尝试加载旧的 Kiro 凭证(如果存在)
let mut s = state.write().await;
if let Err(e) = s.kiro_provider.load_credentials().await {
logs.write()
.await
.add("debug", &format!("[启动] 旧版 Kiro 凭证加载失败: {e}"));
}
}
// 启动服务器
let server_started;
let server_address;
{
let mut s = state.write().await;
logs.write()
.await
.add("info", "[启动] 正在自动启动服务器...");
match s
.start_with_telemetry(
logs.clone(),
pool_service,
token_cache,
Some(db),
Some(shared_stats),
Some(shared_tokens),
Some(shared_logger),
)
.await
{
Ok(_) => {
// 获取服务器实际使用的地址(可能已经自动切换到有效的 IP)
let status = s.status();
let host = status.host;
let port = status.port;
logs.write()
.await
.add("info", &format!("[启动] 服务器已启动: {host}:{port}"));
server_started = true;
server_address = format!("{host}:{port}");
}
Err(e) => {
logs.write()
.await
.add("error", &format!("[启动] 服务器启动失败: {e}"));
server_started = false;
server_address = String::new();
}
}
}
// 更新托盘状态
if let Some(tray_state) = app_handle.try_state::<TrayManagerState<tauri::Wry>>() {
let tray_guard = tray_state.0.read().await;
if let Some(tray_manager) = tray_guard.as_ref() {
let current_state = tray_manager.get_state().await;
let icon_status = if !server_started {
TrayIconStatus::Stopped
} else if total_credentials > 0 && available_credentials == 0 {
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,
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 {
tracing::error!("[启动] 更新托盘状态失败: {}", e);
} else {
tracing::info!("[启动] 托盘状态已更新");
}
}
}
}
-8
View File
@@ -14,7 +14,6 @@ use crate::commands::plugin_install_cmd::PluginInstallerState;
use crate::commands::provider_pool_cmd::{CredentialSyncServiceState, ProviderPoolServiceState};
use crate::commands::resilience_cmd::ResilienceConfigState;
use crate::commands::skill_cmd::SkillServiceState;
use crate::commands::tool_hooks::ToolHooksServiceState;
use crate::config::{GlobalConfigManager, GlobalConfigManagerState};
use crate::database;
use crate::plugin;
@@ -26,7 +25,6 @@ use lime_services::context_memory_service::{ContextMemoryConfig, ContextMemorySe
use lime_services::provider_pool_service::ProviderPoolService;
use lime_services::skill_service::SkillService;
use lime_services::token_cache_service::TokenCacheService;
use lime_services::tool_hooks_service::ToolHooksService;
use super::types::{AppState, LogState, TokenCacheServiceState};
use crate::logger;
@@ -61,7 +59,6 @@ pub struct ServiceStates {
pub plugin_installer: PluginInstallerState,
pub orchestrator: OrchestratorState,
pub context_memory_service: ContextMemoryServiceState,
pub tool_hooks_service: ToolHooksServiceState,
pub workflow_service: Arc<RwLock<WorkflowService>>,
pub progress_store: Arc<RwLock<ProgressStore>>,
}
@@ -113,10 +110,6 @@ pub fn init_service_states() -> ServiceStates {
.expect("Failed to initialize ContextMemoryService");
let context_memory_service_state = ContextMemoryServiceState(Arc::new(context_memory_service));
// Initialize ToolHooksService
let tool_hooks_service = ToolHooksService::new(context_memory_service_state.0.clone());
let tool_hooks_service_state = ToolHooksServiceState(Arc::new(tool_hooks_service));
// Initialize WorkflowService
let workflow_service = WorkflowService::new();
let workflow_service_state = Arc::new(RwLock::new(workflow_service));
@@ -138,7 +131,6 @@ pub fn init_service_states() -> ServiceStates {
plugin_installer: plugin_installer_state,
orchestrator: orchestrator_state,
context_memory_service: context_memory_service_state,
tool_hooks_service: tool_hooks_service_state,
workflow_service: workflow_service_state,
progress_store: progress_store_state,
}
File diff suppressed because it is too large Load Diff
+2 -175
View File
@@ -1,179 +1,6 @@
//! 上下文记忆管理相关的 Tauri 命令
//! 上下文记忆运行时共享状态。
use crate::config::GlobalConfigManagerState;
use lime_services::context_memory_service::{
ContextMemoryService, MemoryEntry, MemoryFileType, MemoryStats,
};
use serde::{Deserialize, Serialize};
use lime_services::context_memory_service::ContextMemoryService;
use std::sync::Arc;
use tauri::State;
use tracing::{debug, info};
pub struct ContextMemoryServiceState(pub Arc<ContextMemoryService>);
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SaveMemoryRequest {
pub session_id: String,
pub file_type: MemoryFileType,
pub title: String,
pub content: String,
pub tags: Vec<String>,
pub priority: u8,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RecordErrorRequest {
pub session_id: String,
pub error_description: String,
pub attempted_solution: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ResolveErrorRequest {
pub session_id: String,
pub error_description: String,
pub resolution: String,
}
#[tauri::command]
pub async fn save_memory_entry(
memory_service: State<'_, ContextMemoryServiceState>,
request: SaveMemoryRequest,
) -> Result<(), String> {
debug!(
"保存记忆条目: {} (会话: {})",
request.title, request.session_id
);
let entry = MemoryEntry {
id: uuid::Uuid::new_v4().to_string(),
session_id: request.session_id.clone(),
file_type: request.file_type,
title: request.title,
content: request.content,
tags: request.tags,
priority: request.priority,
created_at: chrono::Utc::now().timestamp_millis(),
updated_at: chrono::Utc::now().timestamp_millis(),
archived: false,
};
memory_service.0.save_memory_entry(&entry)?;
info!("记忆条目保存成功: {}", entry.title);
Ok(())
}
#[tauri::command]
pub async fn get_session_memories(
memory_service: State<'_, ContextMemoryServiceState>,
session_id: String,
file_type: Option<MemoryFileType>,
) -> Result<Vec<MemoryEntry>, String> {
debug!("获取会话记忆: {} (类型: {:?})", session_id, file_type);
let memories = memory_service
.0
.get_session_memories(&session_id, file_type)?;
info!("获取到 {} 个记忆条目", memories.len());
Ok(memories)
}
#[tauri::command]
pub async fn get_memory_context(
memory_service: State<'_, ContextMemoryServiceState>,
session_id: String,
) -> Result<String, String> {
debug!("获取记忆上下文: {}", session_id);
let context = memory_service.0.get_memory_context(&session_id)?;
info!("记忆上下文长度: {} 字符", context.len());
Ok(context)
}
#[tauri::command]
pub async fn record_error(
memory_service: State<'_, ContextMemoryServiceState>,
request: RecordErrorRequest,
) -> Result<(), String> {
debug!(
"记录错误: {} (会话: {})",
request.error_description, request.session_id
);
memory_service.0.record_error(
&request.session_id,
&request.error_description,
&request.attempted_solution,
)?;
info!("错误记录成功");
Ok(())
}
#[tauri::command]
pub async fn should_avoid_operation(
memory_service: State<'_, ContextMemoryServiceState>,
session_id: String,
operation_description: String,
) -> Result<bool, String> {
debug!(
"检查是否避免操作: {} (会话: {})",
operation_description, session_id
);
let should_avoid = memory_service
.0
.should_avoid_operation(&session_id, &operation_description);
if should_avoid {
info!("建议避免操作: {}", operation_description);
}
Ok(should_avoid)
}
#[tauri::command]
pub async fn mark_error_resolved(
memory_service: State<'_, ContextMemoryServiceState>,
request: ResolveErrorRequest,
) -> Result<(), String> {
debug!(
"标记错误已解决: {} (会话: {})",
request.error_description, request.session_id
);
memory_service.0.mark_error_resolved(
&request.session_id,
&request.error_description,
&request.resolution,
)?;
info!("错误已标记为解决");
Ok(())
}
#[tauri::command]
pub async fn get_memory_stats(
memory_service: State<'_, ContextMemoryServiceState>,
session_id: String,
) -> Result<MemoryStats, String> {
debug!("获取记忆统计: {}", session_id);
let stats = memory_service.0.get_memory_stats(&session_id)?;
info!(
"记忆统计: {} 个活跃记忆, {} 个未解决错误",
stats.active_memories, stats.unresolved_errors
);
Ok(stats)
}
#[tauri::command]
pub async fn cleanup_expired_memories(
memory_service: State<'_, ContextMemoryServiceState>,
global_config: State<'_, GlobalConfigManagerState>,
) -> Result<(), String> {
debug!("清理过期记忆");
let memory_config = global_config.config().memory;
if matches!(memory_config.auto_cleanup, Some(false)) {
info!("自动清理已关闭,跳过过期记忆清理");
return Ok(());
}
let retention_days = memory_config.retention_days.unwrap_or(30).clamp(1, 3650);
memory_service
.0
.cleanup_expired_memories_with_retention_days(retention_days)?;
info!("过期记忆清理完成");
Ok(())
}
@@ -7,9 +7,9 @@ use tauri::State;
use crate::agent::AsterAgentState;
use crate::commands::api_key_provider_cmd::ApiKeyProviderServiceState;
use crate::commands::skill_exec_cmd::{execute_skill, SkillExecutionResult};
use crate::config::GlobalConfigManagerState;
use crate::database::DbConnection;
use crate::skills::{execute_named_skill, SkillExecutionRequest, SkillExecutionResult};
/// 电商差评回复请求
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -72,19 +72,20 @@ pub async fn execute_ecommerce_review_reply(
.unwrap_or_default()
);
// 调用通用的 execute_skill
execute_skill(
app_handle,
db,
api_key_provider_service,
config_manager,
aster_state,
"ecommerce-review-reply".to_string(),
user_input,
Some("anthropic".to_string()), // 优先使用 Anthropic
request.model,
request.execution_id,
None, // session_id
execute_named_skill(
&app_handle,
db.inner(),
api_key_provider_service.inner(),
config_manager.inner(),
aster_state.inner(),
SkillExecutionRequest {
skill_name: "ecommerce-review-reply".to_string(),
user_input,
provider_override: Some("anthropic".to_string()),
model_override: request.model,
execution_id: request.execution_id,
session_id: None,
},
)
.await
}
-2
View File
@@ -64,9 +64,7 @@ pub mod telemetry_cmd;
pub mod template_cmd;
pub mod terminal_cmd;
pub mod theme_context_cmd;
pub mod tool_hooks;
pub mod tray_cmd;
pub mod unified_chat_cmd;
pub mod unified_memory_cmd;
pub mod update_cmd;
pub mod usage_cmd;
+2 -3
View File
@@ -25,6 +25,7 @@ use crate::models::project_model::{
BrandPersona, BrandPersonaExtension, BrandPersonaTemplate, CreateBrandExtensionRequest,
CreatePersonaRequest, Persona, PersonaTemplate, PersonaUpdate, UpdateBrandExtensionRequest,
};
use crate::services::memory_profile_prompt_service::{build_memory_prompt, MemoryPromptContext};
use lime_services::persona_service::PersonaService;
// ============================================================================
@@ -361,9 +362,7 @@ pub async fn generate_persona(
crate::agent::aster_state::SessionConfigBuilder::new(&session_id)
.include_context_trace(true);
if let Some(memory_prompt) =
crate::services::memory_profile_prompt_service::build_memory_profile_prompt(
&config_manager.config(),
)
build_memory_prompt(&config_manager.config(), MemoryPromptContext::default())
{
session_config_builder = session_config_builder.system_prompt(memory_prompt);
}
-1
View File
@@ -7,7 +7,6 @@ pub const SKILL_ERR_CATALOG_UNAVAILABLE: &str = "skill_catalog_unavailable";
pub const SKILL_ERR_NOT_FOUND: &str = "skill_not_found";
pub const SKILL_ERR_SESSION_INIT_FAILED: &str = "skill_session_init_failed";
pub const SKILL_ERR_PROVIDER_UNAVAILABLE: &str = "skill_provider_unavailable";
pub const SKILL_ERR_STREAM_FAILED: &str = "skill_stream_failed";
pub const SKILL_ERR_EXECUTE_FAILED: &str = "skill_execute_failed";
pub fn format_skill_error(code: &str, message: impl AsRef<str>) -> String {
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -9,7 +9,7 @@ use tokio::sync::RwLock;
use aster::agents::context::AgentContext;
use aster::agents::subagent_scheduler::{SchedulerConfig, SchedulerExecutionResult, SubAgentTask};
use crate::agent::subagent_scheduler::{LimeScheduler, SubAgentRole};
use crate::agent::{LimeScheduler, SubAgentRole};
use crate::database::DbConnection;
/// SubAgent 调度器状态
+4 -6
View File
@@ -7,7 +7,7 @@ use crate::agent::{AsterAgentState, AsterAgentWrapper};
use crate::config::GlobalConfigManagerState;
use crate::database::DbConnection;
use crate::services::memory_profile_prompt_service::{
merge_system_prompt_with_memory_profile, merge_system_prompt_with_memory_sources,
merge_system_prompt_with_memory_context, MemoryPromptContext,
};
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;
@@ -386,14 +386,12 @@ pub async fn aster_agent_theme_context_search(
Some(RequestToolPolicyMode::Required),
false,
);
let working_dir = std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from("."));
let system_prompt = lime_agent::merge_system_prompt_with_request_tool_policy(
merge_system_prompt_with_web_search(
merge_system_prompt_with_memory_sources(
merge_system_prompt_with_memory_profile(project_prompt, &runtime_config),
merge_system_prompt_with_memory_context(
project_prompt,
&runtime_config,
&working_dir,
None,
MemoryPromptContext::with_working_dir(Path::new(&workspace_root)),
),
&runtime_config,
),
-124
View File
@@ -1,124 +0,0 @@
//! 工具钩子管理相关的 Tauri 命令
use lime_services::tool_hooks_service::{
HookContext, HookExecutionStats, HookRule, HookTrigger, ToolHooksService,
};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::sync::Arc;
use tauri::State;
use tracing::{debug, info};
pub struct ToolHooksServiceState(pub Arc<ToolHooksService>);
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ExecuteHooksRequest {
pub trigger: HookTrigger,
pub context: HookContextData,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HookContextData {
pub session_id: String,
pub tool_name: Option<String>,
pub tool_parameters: Option<HashMap<String, String>>,
pub tool_result: Option<String>,
pub message_content: Option<String>,
pub message_count: usize,
pub error_info: Option<String>,
pub metadata: HashMap<String, String>,
}
impl From<HookContextData> for HookContext {
fn from(data: HookContextData) -> Self {
Self {
session_id: data.session_id,
tool_name: data.tool_name,
tool_parameters: data.tool_parameters,
tool_result: data.tool_result,
message_content: data.message_content,
message_count: data.message_count,
error_info: data.error_info,
metadata: data.metadata,
}
}
}
#[tauri::command]
pub async fn execute_hooks(
hooks_service: State<'_, ToolHooksServiceState>,
request: ExecuteHooksRequest,
) -> Result<(), String> {
debug!(
"执行钩子: {:?} (会话: {})",
request.trigger, request.context.session_id
);
let context: HookContext = request.context.into();
hooks_service.0.execute_hooks(request.trigger, &context)?;
info!("钩子执行完成");
Ok(())
}
#[tauri::command]
pub async fn add_hook_rule(
hooks_service: State<'_, ToolHooksServiceState>,
rule: HookRule,
) -> Result<(), String> {
debug!("添加钩子规则: {}", rule.name);
hooks_service.0.add_hook_rule(rule.clone())?;
info!("钩子规则添加成功: {}", rule.name);
Ok(())
}
#[tauri::command]
pub async fn remove_hook_rule(
hooks_service: State<'_, ToolHooksServiceState>,
rule_id: String,
) -> Result<(), String> {
debug!("移除钩子规则: {}", rule_id);
hooks_service.0.remove_hook_rule(&rule_id)?;
info!("钩子规则移除成功: {}", rule_id);
Ok(())
}
#[tauri::command]
pub async fn toggle_hook_rule(
hooks_service: State<'_, ToolHooksServiceState>,
rule_id: String,
enabled: bool,
) -> Result<(), String> {
debug!("切换钩子规则状态: {} -> {}", rule_id, enabled);
hooks_service.0.toggle_hook_rule(&rule_id, enabled)?;
info!("钩子规则状态切换成功: {} -> {}", rule_id, enabled);
Ok(())
}
#[tauri::command]
pub async fn get_hook_rules(
hooks_service: State<'_, ToolHooksServiceState>,
) -> Result<Vec<HookRule>, String> {
debug!("获取所有钩子规则");
let rules = hooks_service.0.get_hook_rules()?;
info!("获取到 {} 个钩子规则", rules.len());
Ok(rules)
}
#[tauri::command]
pub async fn get_hook_execution_stats(
hooks_service: State<'_, ToolHooksServiceState>,
) -> Result<HashMap<String, HookExecutionStats>, String> {
debug!("获取钩子执行统计");
let stats = hooks_service.0.get_execution_stats()?;
info!("获取到 {} 个规则的执行统计", stats.len());
Ok(stats)
}
#[tauri::command]
pub async fn clear_hook_execution_stats(
hooks_service: State<'_, ToolHooksServiceState>,
) -> Result<(), String> {
debug!("清理钩子执行统计");
hooks_service.0.clear_execution_stats()?;
info!("钩子执行统计清理完成");
Ok(())
}
-847
View File
@@ -1,847 +0,0 @@
//! 统一对话命令模块
//!
//! 提供统一的对话 API,支持多种对话模式:
//! - Agent: AI Agent 模式,支持工具调用
//! - General: 通用对话模式,纯文本
//! - Creator: 内容创作模式,支持画布输出
//!
//! ## 设计原则
//! - 单一入口:所有对话场景使用同一套 API
//! - 模式化设计:通过 ChatMode 区分不同场景
//! - Aster 引擎:底层使用 Aster Agent 处理对话
//!
//! ## 参考文档
//! - `docs/prd/chat-architecture-redesign.md`
use crate::agent::aster_state::SessionConfigBuilder;
use crate::agent::{AsterAgentState, TauriAgentEvent};
use crate::commands::aster_agent_cmd::ensure_browser_mcp_tools_registered;
use crate::config::GlobalConfigManagerState;
use crate::database::dao::chat::{ChatDao, ChatMessage, ChatMode, ChatSession};
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 aster::agents::extension::ExtensionConfig;
use aster::conversation::message::Message;
use futures::StreamExt;
use lime_agent::{
convert_agent_event, execute_web_search_preflight_if_needed,
merge_system_prompt_with_request_tool_policy,
merge_system_prompt_with_web_search_preflight_context, resolve_request_tool_policy_with_mode,
RequestToolPolicy, RequestToolPolicyMode, WebSearchExecutionTracker, WriteArtifactEventEmitter,
};
use serde::{Deserialize, Serialize};
use tauri::{AppHandle, Emitter, State};
use tracing::Instrument;
const CODE_EXECUTION_EXTENSION_NAME: &str = "code_execution";
// ============================================================================
// 请求/响应结构
// ============================================================================
/// 创建会话请求
#[derive(Debug, Deserialize)]
pub struct CreateSessionRequest {
/// 对话模式
pub mode: ChatMode,
/// 会话标题(可选)
pub title: Option<String>,
/// 系统提示词(可选)
pub system_prompt: Option<String>,
/// Provider 类型(可选)
pub provider_type: Option<String>,
/// 模型名称(可选)
pub model: Option<String>,
/// 扩展元数据(可选)
pub metadata: Option<serde_json::Value>,
}
/// 发送消息请求
#[derive(Debug, Deserialize)]
pub struct SendMessageRequest {
/// 会话 ID
#[serde(alias = "sessionId")]
pub session_id: String,
/// 消息内容
pub message: String,
/// 事件名称(用于前端监听)
#[serde(alias = "eventName")]
pub event_name: String,
/// 图片输入(可选,用于多模态对话)
/// TODO: 实现图片处理逻辑,将图片转换为 Aster Message 的 ImageContent
pub images: Option<Vec<ImageInput>>,
/// 请求级联网搜索开关
#[serde(default, alias = "webSearch")]
pub web_search: Option<bool>,
/// 联网搜索模式(disabled / allowed / required)
#[serde(default, alias = "searchMode")]
pub search_mode: Option<RequestToolPolicyMode>,
}
/// 图片输入
#[derive(Debug, Deserialize)]
pub struct ImageInput {
/// Base64 编码的图片数据
pub data: String,
/// 图片 MIME 类型,如 "image/png", "image/jpeg"
pub media_type: String,
}
/// 会话信息响应
#[derive(Debug, Serialize)]
pub struct SessionResponse {
pub id: String,
pub mode: ChatMode,
pub title: Option<String>,
pub model: Option<String>,
pub created_at: String,
pub updated_at: String,
pub message_count: usize,
}
impl From<ChatSession> for SessionResponse {
fn from(session: ChatSession) -> Self {
Self {
id: session.id,
mode: session.mode,
title: session.title,
model: session.model,
created_at: session.created_at,
updated_at: session.updated_at,
message_count: 0,
}
}
}
// ============================================================================
// 会话管理命令
// ============================================================================
/// 创建新会话
///
/// 统一的会话创建入口,支持所有对话模式
#[tauri::command]
pub async fn chat_create_session(
db: State<'_, DbConnection>,
agent_state: State<'_, AsterAgentState>,
config_manager: State<'_, GlobalConfigManagerState>,
request: CreateSessionRequest,
) -> Result<SessionResponse, String> {
let now = chrono::Utc::now().to_rfc3339();
let session_id = uuid::Uuid::new_v4().to_string();
let config = config_manager.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(request.system_prompt.clone(), &config),
&config,
&working_dir,
None,
),
&config,
);
// 创建会话
let session = ChatSession {
id: session_id.clone(),
mode: request.mode,
title: request.title,
system_prompt: merged_system_prompt,
model: request.model.clone(),
provider_type: request.provider_type.clone(),
credential_uuid: None,
metadata: request.metadata,
created_at: now.clone(),
updated_at: now,
};
// 保存到数据库(异步化)
{
let db = db.inner().clone();
let session_clone = session.clone();
tokio::task::spawn_blocking(move || {
let conn = db.lock().map_err(|e| format!("数据库锁定失败: {e}"))?;
ChatDao::create_session(&conn, &session_clone).map_err(|e| format!("创建会话失败: {e}"))
})
.await
.map_err(|e| format!("任务执行失败: {e}"))??;
}
// 初始化 Aster Agent(如果是 Agent 或 Creator 模式)
if matches!(request.mode, ChatMode::Agent | ChatMode::Creator) {
agent_state.init_agent_with_db(&db).await?;
// 如果指定了 Provider,配置它
if let (Some(provider_type), Some(model)) = (&request.provider_type, &request.model) {
agent_state
.configure_provider_from_pool(&db, provider_type, model, &session_id)
.await?;
}
}
tracing::info!(
"[UnifiedChat] 创建会话: id={}, mode={:?}",
session_id,
request.mode
);
Ok(SessionResponse::from(session))
}
/// 获取会话列表
///
/// 可选按模式过滤
#[tauri::command]
pub async fn chat_list_sessions(
db: State<'_, DbConnection>,
mode: Option<ChatMode>,
) -> Result<Vec<SessionResponse>, String> {
let db = db.inner().clone();
tokio::task::spawn_blocking(move || {
let conn = db.lock().map_err(|e| format!("数据库锁定失败: {e}"))?;
let sessions =
ChatDao::list_sessions(&conn, mode).map_err(|e| format!("获取会话列表失败: {e}"))?;
let mut result: Vec<SessionResponse> = Vec::new();
for session in sessions {
let message_count = ChatDao::get_message_count(&conn, &session.id).unwrap_or(0);
let mut resp = SessionResponse::from(session);
resp.message_count = message_count;
result.push(resp);
}
Ok(result)
})
.await
.map_err(|e| format!("任务执行失败: {e}"))?
}
/// 获取会话详情
#[tauri::command]
pub async fn chat_get_session(
db: State<'_, DbConnection>,
session_id: String,
) -> Result<SessionResponse, String> {
let db = db.inner().clone();
tokio::task::spawn_blocking(move || {
let conn = db.lock().map_err(|e| format!("数据库锁定失败: {e}"))?;
let session = ChatDao::get_session(&conn, &session_id)
.map_err(|e| format!("获取会话失败: {e}"))?
.ok_or_else(|| "会话不存在".to_string())?;
let message_count = ChatDao::get_message_count(&conn, &session_id).unwrap_or(0);
let mut resp = SessionResponse::from(session);
resp.message_count = message_count;
Ok(resp)
})
.await
.map_err(|e| format!("任务执行失败: {e}"))?
}
/// 删除会话
#[tauri::command]
pub async fn chat_delete_session(
db: State<'_, DbConnection>,
session_id: String,
) -> Result<bool, String> {
let db = db.inner().clone();
tokio::task::spawn_blocking(move || {
let conn = db.lock().map_err(|e| format!("数据库锁定失败: {e}"))?;
let deleted = ChatDao::delete_session(&conn, &session_id)
.map_err(|e| format!("删除会话失败: {e}"))?;
if deleted {
tracing::info!("[UnifiedChat] 删除会话: id={}", session_id);
}
Ok(deleted)
})
.await
.map_err(|e| format!("任务执行失败: {e}"))?
}
/// 重命名会话
#[tauri::command]
pub async fn chat_rename_session(
db: State<'_, DbConnection>,
session_id: String,
title: String,
) -> Result<(), String> {
let db = db.inner().clone();
tokio::task::spawn_blocking(move || {
let conn = db.lock().map_err(|e| format!("数据库锁定失败: {e}"))?;
ChatDao::update_title(&conn, &session_id, &title)
.map_err(|e| format!("重命名会话失败: {e}"))?;
tracing::info!(
"[UnifiedChat] 重命名会话: id={}, title={}",
session_id,
title
);
Ok(())
})
.await
.map_err(|e| format!("任务执行失败: {e}"))?
}
// ============================================================================
// 消息管理命令
// ============================================================================
/// 获取会话消息列表
#[tauri::command]
pub async fn chat_get_messages(
db: State<'_, DbConnection>,
session_id: String,
limit: Option<i32>,
) -> Result<Vec<ChatMessage>, String> {
let db = db.inner().clone();
tokio::task::spawn_blocking(move || {
let conn = db.lock().map_err(|e| format!("数据库锁定失败: {e}"))?;
let messages = ChatDao::get_messages(&conn, &session_id, limit)
.map_err(|e| format!("获取消息失败: {e}"))?;
Ok(messages)
})
.await
.map_err(|e| format!("任务执行失败: {e}"))?
}
/// 发送消息并获取流式响应
///
/// 统一的消息发送入口,根据会话模式选择处理方式
#[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,
db: State<'_, DbConnection>,
agent_state: State<'_, AsterAgentState>,
config_manager: State<'_, GlobalConfigManagerState>,
request: SendMessageRequest,
) -> Result<(), String> {
let start_time = std::time::Instant::now();
let image_count = request.images.as_ref().map(|v| v.len()).unwrap_or(0);
tracing::info!(
"[UnifiedChat] 发送消息: session={}, event={}, images={}",
request.session_id,
request.event_name,
image_count
);
// TODO: 实现图片处理逻辑,将图片转换为 Aster Message 的 ImageContent
if let Some(images) = &request.images {
for (i, img) in images.iter().enumerate() {
tracing::debug!(
"[UnifiedChat] 图片 {}: media_type={}, data_len={}",
i,
img.media_type,
img.data.len()
);
}
if !images.is_empty() {
tracing::warn!(
"[UnifiedChat] 图片输入暂未实现,忽略 {} 张图片",
images.len()
);
}
}
// 获取会话信息(异步化数据库操作)
let db_start = std::time::Instant::now();
let session = {
let db = db.inner().clone();
let session_id = request.session_id.clone();
tokio::task::spawn_blocking(move || {
let conn = db.lock().map_err(|e| format!("数据库锁定失败: {e}"))?;
ChatDao::get_session(&conn, &session_id)
.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}"))??
};
let db_elapsed = db_start.elapsed();
tracing::debug!("[UnifiedChat] 数据库查询耗时: {:?}", db_elapsed);
// 根据模式处理
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 = 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(
request.web_search,
request.search_mode,
mode_default_web_search,
);
tracing::info!(
"[UnifiedChat][WebSearchGuard] session={}, mode={:?}, request_web_search={:?}, request_search_mode={:?}, mode_default_web_search={}, effective_web_search={}, search_mode={}",
request.session_id,
session.mode,
request.web_search,
request.search_mode,
mode_default_web_search,
request_tool_policy.effective_web_search,
request_tool_policy.search_mode.as_str()
);
let result = send_message_with_aster(
&app,
&db,
&agent_state,
&request.session_id,
&request.message,
&request.event_name,
merged_system_prompt.as_deref(),
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();
tracing::info!(
"[UnifiedChat] 消息发送完成: session={}, 总耗时={:?}",
request.session_id,
total_elapsed
);
result
}
/// 使用 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,
agent_state: &AsterAgentState,
session_id: &str,
message: &str,
event_name: &str,
system_prompt: Option<&str>,
include_context_trace: bool,
request_tool_policy: &RequestToolPolicy,
) -> Result<(), String> {
let start_time = std::time::Instant::now();
tracing::info!(
"[UnifiedChat][WebSearchGuard] session={}, effective_web_search={}",
session_id,
request_tool_policy.effective_web_search
);
// 确保 Agent 已初始化
let init_start = std::time::Instant::now();
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>(())
}
.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();
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();
tracing::debug!(
"[UnifiedChat] Provider 配置检查耗时: {:?}",
provider_check_elapsed
);
// 创建取消令牌
let cancel_token = agent_state.create_cancel_token(session_id).await;
let effective_system_prompt = merge_system_prompt_with_request_tool_policy(
system_prompt.map(|prompt| prompt.to_string()),
request_tool_policy,
);
let user_message = Message::user().with_text(message);
let mut session_config_builder = SessionConfigBuilder::new(session_id);
if let Some(prompt) = effective_system_prompt {
session_config_builder = session_config_builder.system_prompt(prompt);
}
let mut session_config = session_config_builder
.include_context_trace(include_context_trace)
.build();
// 获取 Agent 引用
let agent_arc = agent_state.get_agent_arc();
let guard = agent_arc.read().await;
let agent = guard.as_ref().ok_or("Agent 未初始化")?;
let mut removed_extension: Option<ExtensionConfig> = None;
if request_tool_policy.requires_web_search() {
let extension_configs = agent.get_extension_configs().await;
if let Some(extension) = extension_configs
.into_iter()
.find(|extension| extension.name() == CODE_EXECUTION_EXTENSION_NAME)
{
match agent.remove_extension(CODE_EXECUTION_EXTENSION_NAME).await {
Ok(_) => {
removed_extension = Some(extension);
tracing::info!(
"[UnifiedChat] 当前会话优先联网搜索,临时关闭 {} 扩展",
CODE_EXECUTION_EXTENSION_NAME
);
}
Err(error) => {
tracing::warn!(
"[UnifiedChat] 移除 {} 扩展失败: {}",
CODE_EXECUTION_EXTENSION_NAME,
error
);
}
}
} else {
tracing::info!(
"[UnifiedChat][WebSearchGuard] session={}, 未检测到 {} 扩展,无需移除",
session_id,
CODE_EXECUTION_EXTENSION_NAME
);
}
}
// 调用 Agent
let reply_start = std::time::Instant::now();
let mut web_search_tracker = WebSearchExecutionTracker::default();
let preflight = execute_web_search_preflight_if_needed(
agent,
session_id,
message,
None,
Some(cancel_token.clone()),
request_tool_policy,
&mut web_search_tracker,
)
.instrument(tracing::info_span!(
"send_message_with_aster.web_search_preflight"
))
.await;
match preflight {
Ok(preflight_execution) => {
session_config.system_prompt = merge_system_prompt_with_web_search_preflight_context(
session_config.system_prompt.take(),
preflight_execution.system_prompt_appendix.clone(),
);
if let Some(summary) = preflight_execution.coverage_summary.as_deref() {
tracing::info!(
"[UnifiedChat][WebSearchPrefetch] session={}, expanded_news_search={}, summary={}",
session_id,
preflight_execution.expanded_news_search,
summary
);
}
for event in preflight_execution.events {
if let Err(error) = app.emit(event_name, &event) {
tracing::error!("[UnifiedChat] 发送预调用事件失败: {}", error);
}
}
}
Err(error) => {
let error_event = TauriAgentEvent::Error {
message: format!(
"{error}\n尝试记录: {}",
web_search_tracker.format_attempts()
),
};
let _ = app.emit(event_name, &error_event);
agent_state.remove_cancel_token(session_id).await;
if let Some(extension) = removed_extension {
if let Err(restore_error) = agent.add_extension(extension).await {
tracing::warn!(
"[UnifiedChat] 预调用失败后恢复 {} 扩展失败: {}",
CODE_EXECUTION_EXTENSION_NAME,
restore_error
);
}
}
return Err(error);
}
}
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;
let mut chunk_count = 0;
let mut stream_error: Option<String> = None;
let mut text_output = String::new();
let mut write_artifact_emitter = WriteArtifactEventEmitter::new(session_id);
match stream_result {
Ok(mut stream) => {
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)
if first_chunk_time.is_none() {
first_chunk_time = Some(std::time::Instant::now());
let ttfb = first_chunk_time.unwrap() - reply_start;
tracing::info!("[UnifiedChat] TTFB (首字节时间): {:?}", ttfb);
}
chunk_count += 1;
let tauri_events = convert_agent_event(agent_event);
for mut tauri_event in tauri_events {
let extra_events =
write_artifact_emitter.process_event(&mut tauri_event);
for extra_event in &extra_events {
if let Err(e) = app.emit(event_name, extra_event) {
tracing::error!("[UnifiedChat] 发送补充事件失败: {}", e);
}
}
match &tauri_event {
TauriAgentEvent::TextDelta { text } => {
if !text.is_empty() {
text_output.push_str(text);
}
}
TauriAgentEvent::ToolStart {
tool_name, tool_id, ..
} => web_search_tracker.record_tool_start(
request_tool_policy,
tool_id,
tool_name,
),
TauriAgentEvent::ToolEnd { tool_id, result } => {
web_search_tracker.record_tool_end(
request_tool_policy,
tool_id,
result.success,
result.error.as_deref(),
);
}
_ => {}
}
if let Err(e) = app.emit(event_name, &tauri_event) {
tracing::error!("[UnifiedChat] 发送事件失败: {}", e);
}
}
}
Err(e) => {
let message = format!("流错误: {e}");
let error_event = TauriAgentEvent::Error {
message: message.clone(),
};
let _ = app.emit(event_name, &error_event);
stream_error = Some(message);
}
}
}
if stream_error.is_none() {
if let Err(validation_error) =
web_search_tracker.validate_web_search_requirement(request_tool_policy)
{
let error_event = TauriAgentEvent::Error {
message: validation_error.clone(),
};
let _ = app.emit(event_name, &error_event);
stream_error = Some(validation_error);
}
}
if stream_error.is_none() && text_output.trim().is_empty() {
let message = format!(
"已完成当前回合的工具执行,但模型未输出最终答复。\n尝试记录: {}",
web_search_tracker.format_attempts()
);
let error_event = TauriAgentEvent::Error {
message: message.clone(),
};
let _ = app.emit(event_name, &error_event);
stream_error = Some(message);
}
if stream_error.is_none() {
// 发送完成事件
let done_event = TauriAgentEvent::FinalDone { usage: None };
let _ = app.emit(event_name, &done_event);
}
let stream_elapsed = start_time.elapsed();
tracing::info!(
"[UnifiedChat] 流式传输完成: session={}, chunks={}, 总耗时={:?}",
session_id,
chunk_count,
stream_elapsed
);
}
Err(e) => {
let message = format!("Agent 错误: {e}");
let error_event = TauriAgentEvent::Error {
message: message.clone(),
};
let _ = app.emit(event_name, &error_event);
stream_error = Some(message);
}
}
if let Some(extension) = removed_extension {
if let Err(error) = agent.add_extension(extension).await {
tracing::warn!(
"[UnifiedChat] 恢复 {} 扩展失败: {}",
CODE_EXECUTION_EXTENSION_NAME,
error
);
}
}
// 清理取消令牌
agent_state.remove_cancel_token(session_id).await;
if let Some(error) = stream_error {
return Err(error);
}
Ok(())
}
/// 停止生成
#[tauri::command]
pub async fn chat_stop_generation(
agent_state: State<'_, AsterAgentState>,
session_id: String,
) -> Result<bool, String> {
tracing::info!("[UnifiedChat] 停止生成: session={}", session_id);
Ok(agent_state.cancel_session(&session_id).await)
}
/// 配置会话的 Provider
#[tauri::command]
pub async fn chat_configure_provider(
db: State<'_, DbConnection>,
agent_state: State<'_, AsterAgentState>,
session_id: String,
provider_type: String,
model: String,
) -> Result<(), String> {
tracing::info!(
"[UnifiedChat] 配置 Provider: session={}, provider={}, model={}",
session_id,
provider_type,
model
);
// 确保 Agent 已初始化
agent_state.init_agent_with_db(&db).await?;
// 配置 Provider
agent_state
.configure_provider_from_pool(&db, &provider_type, &model, &session_id)
.await?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use lime_agent::resolve_request_tool_policy;
#[test]
fn test_send_message_request_deserialize_web_search_camel_case() {
let payload = serde_json::json!({
"sessionId": "session-1",
"message": "hello",
"eventName": "event-1",
"webSearch": true
});
let request: SendMessageRequest =
serde_json::from_value(payload).expect("deserialize request");
assert_eq!(request.web_search, Some(true));
assert_eq!(request.session_id, "session-1");
assert_eq!(request.event_name, "event-1");
}
#[test]
fn test_send_message_request_deserialize_web_search_snake_case() {
let payload = serde_json::json!({
"session_id": "session-1",
"message": "hello",
"event_name": "event-1",
"web_search": false
});
let request: SendMessageRequest =
serde_json::from_value(payload).expect("deserialize request");
assert_eq!(request.web_search, Some(false));
}
#[test]
fn test_unified_effective_web_search_uses_request_override() {
let mode_default = true;
let policy = resolve_request_tool_policy(Some(false), mode_default);
assert!(!policy.effective_web_search);
}
}
@@ -104,7 +104,13 @@ pub async fn unified_memory_list(
info!("[Unified Memory] List memories: {:?}", filters);
let conn = db.lock().map_err(|e| format!("数据库锁定失败: {e}"))?;
list_unified_memories(&conn, filters)
}
pub(crate) fn list_unified_memories(
conn: &rusqlite::Connection,
filters: ListFilters,
) -> Result<Vec<UnifiedMemory>, String> {
let archived = filters.archived.unwrap_or(false);
let sort_by = normalize_sort_by(filters.sort_by.as_deref());
let order = normalize_sort_order(filters.order.as_deref());
@@ -374,7 +380,12 @@ pub async fn unified_memory_stats(
info!("[Unified Memory] Stats");
let conn = db.lock().map_err(|e| format!("数据库锁定失败: {e}"))?;
collect_unified_memory_stats(&conn)
}
pub(crate) fn collect_unified_memory_stats(
conn: &rusqlite::Connection,
) -> Result<MemoryStatsResponse, String> {
let (total_entries, memory_count, storage_used): (i64, i64, i64) = conn
.query_row(
"SELECT COUNT(*), COUNT(DISTINCT session_id), COALESCE(SUM(length(title) + length(content) + length(summary) + length(tags)), 0) FROM unified_memory WHERE archived = 0",
+5 -50
View File
@@ -19,7 +19,10 @@ use crate::services::workspace_health_service::{
use crate::workspace::{
Workspace, WorkspaceManager, WorkspaceSettings, WorkspaceType, WorkspaceUpdate,
};
use lime_core::app_paths;
use crate::workspace_support::{
get_or_create_default_project as load_or_create_default_project,
get_workspace_projects_root_dir, sanitize_project_dir_name,
};
use lime_core::database::lock_db;
use lime_services::project_context_builder::ProjectContextBuilder;
use serde::{Deserialize, Serialize};
@@ -28,31 +31,6 @@ use std::sync::Arc;
use tauri::State;
use tokio::sync::RwLock;
/// 获取统一的项目根目录
fn get_workspace_projects_root_dir() -> Result<PathBuf, String> {
app_paths::resolve_projects_dir()
}
/// 规范化项目目录名,避免非法路径字符
fn sanitize_project_dir_name(name: &str) -> String {
let sanitized: String = name
.trim()
.chars()
.map(|ch| match ch {
'/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' => '_',
_ if ch.is_control() => '_',
_ => ch,
})
.collect();
let trimmed = sanitized.trim().trim_matches('.').to_string();
if trimmed.is_empty() {
"未命名项目".to_string()
} else {
trimmed
}
}
/// Workspace 管理器状态
#[allow(dead_code)]
pub struct WorkspaceManagerState(pub Arc<RwLock<Option<WorkspaceManager>>>);
@@ -386,30 +364,7 @@ pub async fn get_or_create_default_project(
db: State<'_, DbConnection>,
) -> Result<WorkspaceListItem, String> {
let manager = WorkspaceManager::new(db.inner().clone());
// 先尝试获取默认项目
if let Some(workspace) = manager.get_default()? {
return Ok(workspace.into());
}
// 不存在则创建默认项目
let default_project_path = get_workspace_projects_root_dir()?.join("default");
std::fs::create_dir_all(&default_project_path)
.map_err(|e| format!("创建默认项目目录失败: {e}"))?;
let workspace = manager.create_with_type(
"默认项目".to_string(),
default_project_path,
WorkspaceType::Persistent,
)?;
// 设置为默认
manager.set_default(&workspace.id)?;
// 重新获取以确保 is_default 标志正确
let workspace = manager.get(&workspace.id)?.ok_or("创建默认项目失败")?;
Ok(workspace.into())
Ok(load_or_create_default_project(&manager)?.into())
}
/// 获取项目上下文
+6 -204
View File
@@ -1,112 +1,12 @@
use super::{args_or_default, parse_optional_nested_arg};
use crate::commands::unified_memory_cmd::{
collect_unified_memory_stats, list_unified_memories, ListFilters,
};
use crate::dev_bridge::DevBridgeState;
use lime_memory::{MemoryCategory, MemoryMetadata, MemorySource, MemoryType, UnifiedMemory};
use rusqlite::{params_from_iter, types::Value};
use serde_json::Value as JsonValue;
type DynError = Box<dyn std::error::Error>;
fn parse_unified_memory_row(row: &rusqlite::Row) -> Result<UnifiedMemory, rusqlite::Error> {
let id: String = row.get(0)?;
let session_id: String = row.get(1)?;
let memory_type_json: String = row.get(2)?;
let category_json: String = row.get(3)?;
let title: String = row.get(4)?;
let content: String = row.get(5)?;
let summary: String = row.get(6)?;
let tags_json: String = row.get(7)?;
let confidence: f32 = row.get(8)?;
let importance: i64 = row.get(9)?;
let access_count: i64 = row.get(10)?;
let last_accessed_at: Option<i64> = row.get(11)?;
let source_json: String = row.get(12)?;
let created_at: i64 = row.get(13)?;
let updated_at: i64 = row.get(14)?;
let archived: i64 = row.get(15)?;
let memory_type: MemoryType = serde_json::from_str(&memory_type_json)
.map_err(|e| rusqlite::Error::ToSqlConversionFailure(Box::new(e)))?;
let category: MemoryCategory = serde_json::from_str(&category_json)
.map_err(|e| rusqlite::Error::ToSqlConversionFailure(Box::new(e)))?;
let tags: Vec<String> = serde_json::from_str(&tags_json)
.map_err(|e| rusqlite::Error::ToSqlConversionFailure(Box::new(e)))?;
let source: MemorySource = serde_json::from_str(&source_json)
.map_err(|e| rusqlite::Error::ToSqlConversionFailure(Box::new(e)))?;
Ok(UnifiedMemory {
id,
session_id,
memory_type,
category,
title,
content,
summary,
tags,
metadata: MemoryMetadata {
confidence,
importance: importance.clamp(0, 10) as u8,
access_count: access_count.max(0) as u32,
last_accessed_at,
source,
embedding: None,
},
created_at,
updated_at,
archived: archived != 0,
})
}
fn unified_memory_category_to_key(category: &MemoryCategory) -> &'static str {
match category {
MemoryCategory::Identity => "identity",
MemoryCategory::Context => "context",
MemoryCategory::Preference => "preference",
MemoryCategory::Experience => "experience",
MemoryCategory::Activity => "activity",
}
}
fn ordered_unified_categories() -> [&'static str; 5] {
[
"identity",
"context",
"preference",
"experience",
"activity",
]
}
fn normalize_unified_category_value(value: &str) -> Option<&'static str> {
if let Ok(category) = serde_json::from_str::<MemoryCategory>(value) {
return Some(unified_memory_category_to_key(&category));
}
match value.trim_matches('"').to_lowercase().as_str() {
"identity" | "身份" => Some("identity"),
"context" | "情境" | "上下文" => Some("context"),
"preference" | "偏好" => Some("preference"),
"experience" | "经验" => Some("experience"),
"activity" | "活动" => Some("activity"),
_ => None,
}
}
fn normalize_unified_sort_by(sort_by: Option<&str>) -> &'static str {
match sort_by.unwrap_or("updated_at") {
"created_at" => "created_at",
"importance" => "importance",
"access_count" => "access_count",
_ => "updated_at",
}
}
fn normalize_unified_sort_order(order: Option<&str>) -> &'static str {
match order.unwrap_or("desc").to_lowercase().as_str() {
"asc" => "ASC",
_ => "DESC",
}
}
pub(super) fn try_handle(
state: &DevBridgeState,
cmd: &str,
@@ -124,116 +24,18 @@ pub(super) fn try_handle(
};
let conn = db.lock().map_err(|e| format!("数据库锁定失败: {e}"))?;
let (total_entries, memory_count, storage_used): (i64, i64, i64) = conn
.query_row(
"SELECT COUNT(*), COUNT(DISTINCT session_id), COALESCE(SUM(length(title) + length(content) + length(summary) + length(tags)), 0) FROM unified_memory WHERE archived = 0",
[],
|row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)),
)
.map_err(|e| format!("统计记忆失败: {e}"))?;
let mut category_counts: std::collections::HashMap<String, u32> =
std::collections::HashMap::new();
let mut stmt = conn
.prepare(
"SELECT category, COUNT(*) FROM unified_memory WHERE archived = 0 GROUP BY category",
)
.map_err(|e| format!("构建分类统计查询失败: {e}"))?;
let rows = stmt
.query_map([], |row| {
let category_raw: String = row.get(0)?;
let count: i64 = row.get(1)?;
Ok((category_raw, count))
})
.map_err(|e| format!("分类统计查询失败: {e}"))?;
for row in rows.flatten() {
if let Some(category) = normalize_unified_category_value(&row.0) {
category_counts.insert(category.to_string(), row.1.max(0) as u32);
}
}
let categories = ordered_unified_categories()
.iter()
.map(
|category| crate::commands::unified_memory_cmd::MemoryCategoryStat {
category: (*category).to_string(),
count: *category_counts.get(*category).unwrap_or(&0),
},
)
.collect();
let response = crate::commands::unified_memory_cmd::MemoryStatsResponse {
total_entries: total_entries.max(0) as u32,
storage_used: storage_used.max(0) as u64,
memory_count: memory_count.max(0) as u32,
categories,
};
serde_json::to_value(response)?
serde_json::to_value(collect_unified_memory_stats(&conn)?)?
}
"unified_memory_list" => {
let args = args_or_default(args);
let filters: Option<crate::commands::unified_memory_cmd::ListFilters> =
parse_optional_nested_arg(&args, "filters")?;
let filters = filters.unwrap_or_default();
let filters: Option<ListFilters> = parse_optional_nested_arg(&args, "filters")?;
let Some(db) = &state.db else {
return Ok(Some(serde_json::json!([])));
};
let conn = db.lock().map_err(|e| format!("数据库锁定失败: {e}"))?;
let archived = filters.archived.unwrap_or(false);
let sort_by = normalize_unified_sort_by(filters.sort_by.as_deref());
let order = normalize_unified_sort_order(filters.order.as_deref());
let limit = filters.limit.unwrap_or(120).clamp(1, 1000) as i64;
let offset = filters.offset.unwrap_or(0) as i64;
let mut where_parts = vec!["archived = ?".to_string()];
let mut values: Vec<Value> = vec![Value::from(if archived { 1 } else { 0 })];
if let Some(session_id) = filters.session_id.filter(|value| !value.trim().is_empty()) {
where_parts.push("session_id = ?".to_string());
values.push(Value::from(session_id));
}
if let Some(memory_type) = filters.memory_type {
let encoded = serde_json::to_string(&memory_type)
.map_err(|e| format!("序列化 memory_type 失败: {e}"))?;
where_parts.push("memory_type = ?".to_string());
values.push(Value::from(encoded));
}
if let Some(category) = filters.category {
let encoded = serde_json::to_string(&category)
.map_err(|e| format!("序列化 category 失败: {e}"))?;
where_parts.push("category = ?".to_string());
values.push(Value::from(encoded));
}
let sql = format!(
"SELECT id, session_id, memory_type, category, title, content, summary, tags, confidence, importance, access_count, last_accessed_at, source, created_at, updated_at, archived FROM unified_memory WHERE {} ORDER BY {} {} LIMIT ? OFFSET ?",
where_parts.join(" AND "),
sort_by,
order,
);
values.push(Value::from(limit));
values.push(Value::from(offset));
let mut stmt = conn
.prepare(&sql)
.map_err(|e| format!("构建查询失败: {e}"))?;
let memories = stmt
.query_map(params_from_iter(values), parse_unified_memory_row)
.map_err(|e| format!("查询记忆失败: {e}"))?
.collect::<Result<Vec<_>, rusqlite::Error>>()
.map_err(|e| format!("解析记忆失败: {e}"))?;
serde_json::to_value(memories)?
serde_json::to_value(list_unified_memories(&conn, filters.unwrap_or_default())?)?
}
_ => return Ok(None),
};
@@ -26,29 +26,6 @@ fn get_optional_bool_arg(args: &JsonValue, primary: &str, secondary: &str) -> Op
.and_then(|value| value.as_bool())
}
fn get_workspace_projects_root_dir() -> Result<PathBuf, String> {
lime_core::app_paths::resolve_projects_dir()
}
fn sanitize_project_dir_name(name: &str) -> String {
let sanitized: String = name
.trim()
.chars()
.map(|ch| match ch {
'/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' => '_',
_ if ch.is_control() => '_',
_ => ch,
})
.collect();
let trimmed = sanitized.trim().trim_matches('.').to_string();
if trimmed.is_empty() {
"未命名项目".to_string()
} else {
trimmed
}
}
fn to_workspace_list_item_json<T>(workspace: T) -> Result<JsonValue, DynError>
where
WorkspaceListItem: From<T>,
@@ -75,25 +52,6 @@ fn build_ensure_result(
}
}
fn create_default_project_if_missing(manager: &WorkspaceManager) -> Result<JsonValue, DynError> {
if let Some(workspace) = manager.get_default()? {
return to_workspace_list_item_json(workspace);
}
let default_project_path = get_workspace_projects_root_dir()?.join("default");
std::fs::create_dir_all(&default_project_path)
.map_err(|e| format!("创建默认项目目录失败: {e}"))?;
let workspace = manager.create_with_type(
"默认项目".to_string(),
default_project_path,
WorkspaceType::Persistent,
)?;
manager.set_default(&workspace.id)?;
let workspace = manager.get(&workspace.id)?.ok_or("创建默认项目失败")?;
to_workspace_list_item_json(workspace)
}
fn remove_workspace_directory_if_requested(
manager: &WorkspaceManager,
workspace_id: &str,
@@ -1,11 +1,11 @@
use super::{
args_or_default, create_default_project_if_missing, ensure_update_root_path,
ensure_valid_workspace_root, get_optional_bool_arg, get_string_arg, parse_nested_arg,
remove_workspace_directory_if_requested, to_workspace_list_item_json, workspace_manager,
CreateWorkspaceRequest, DynError, PathBuf, UpdateWorkspaceRequest, WorkspaceType,
WorkspaceUpdate,
args_or_default, ensure_update_root_path, ensure_valid_workspace_root, get_optional_bool_arg,
get_string_arg, parse_nested_arg, remove_workspace_directory_if_requested,
to_workspace_list_item_json, workspace_manager, CreateWorkspaceRequest, DynError, PathBuf,
UpdateWorkspaceRequest, WorkspaceType, WorkspaceUpdate,
};
use crate::dev_bridge::DevBridgeState;
use crate::workspace_support::get_or_create_default_project;
use serde_json::Value as JsonValue;
pub(super) fn try_handle(
@@ -75,7 +75,7 @@ pub(super) fn try_handle(
}
"get_or_create_default_project" => {
let manager = workspace_manager(state)?;
create_default_project_if_missing(&manager)?
to_workspace_list_item_json(get_or_create_default_project(&manager)?)?
}
_ => return Ok(None),
};
@@ -1,8 +1,8 @@
use super::{
args_or_default, get_string_arg, get_workspace_projects_root_dir, sanitize_project_dir_name,
workspace_manager, DynError, PathBuf, WorkspaceListItem,
args_or_default, get_string_arg, workspace_manager, DynError, PathBuf, WorkspaceListItem,
};
use crate::dev_bridge::DevBridgeState;
use crate::workspace_support::{get_workspace_projects_root_dir, sanitize_project_dir_name};
use serde_json::Value as JsonValue;
pub(super) fn try_handle(
+4
View File
@@ -16,6 +16,9 @@
// 该警告来自 cocoa/objc 依赖的 msg_send! 宏,是已知的 issue
#![allow(unexpected_cfgs)]
#[cfg(target_os = "linux")]
compile_error!("Lime 已暂停 Linux 桌面端支持,请使用 macOS 或 Windows 构建。");
// 从 providers crate 重新导出(保持 crate::xxx 路径兼容)
pub use lime_providers::providers;
@@ -56,6 +59,7 @@ mod dev_bridge;
mod logger;
mod profiling;
mod theme;
mod workspace_support;
use lime_core::models;
// 测试模块
@@ -5,139 +5,16 @@ use lime_core::database::dao::agent_timeline::{
AgentThreadTurnStatus, AgentTimelineDao,
};
use lime_core::database::{lock_db, DbConnection};
use serde_json::{json, Value};
use serde_json::Value;
use std::collections::HashMap;
use tauri::{AppHandle, Emitter};
const PROPOSED_PLAN_OPEN: &str = "<proposed_plan>";
const PROPOSED_PLAN_CLOSE: &str = "</proposed_plan>";
fn format_runtime_status_text(title: &str, detail: &str, checkpoints: &[String]) -> String {
let mut lines = Vec::new();
let trimmed_title = title.trim();
if !trimmed_title.is_empty() {
lines.push(trimmed_title.to_string());
}
let trimmed_detail = detail.trim();
if !trimmed_detail.is_empty() {
lines.push(trimmed_detail.to_string());
}
for checkpoint in checkpoints {
let trimmed = checkpoint.trim();
if !trimmed.is_empty() {
lines.push(format!("• {trimmed}"));
}
}
lines.join("\n")
}
fn emit_event(app: &AppHandle, event_name: &str, event: &TauriAgentEvent) {
if let Err(error) = app.emit(event_name, event) {
tracing::error!("[AgentTimeline] 发送事件失败: {}", error);
}
}
fn as_object(value: &Value) -> Option<&serde_json::Map<String, Value>> {
value.as_object()
}
#[derive(Debug, Clone)]
struct ExtractedFileArtifact {
path: String,
artifact_id: Option<String>,
}
fn push_unique_file_path(target: &mut Vec<String>, raw: &str) {
let trimmed = raw.trim();
if trimmed.is_empty() || target.iter().any(|item| item == trimmed) {
return;
}
target.push(trimmed.to_string());
}
fn collect_string_values(value: &Value) -> Vec<String> {
match value {
Value::String(text) => {
let trimmed = text.trim();
if trimmed.is_empty() {
Vec::new()
} else {
vec![trimmed.to_string()]
}
}
Value::Array(items) => items
.iter()
.filter_map(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_string)
.collect(),
_ => Vec::new(),
}
}
fn extract_file_artifacts(
arguments: Option<&Value>,
metadata: Option<&Value>,
) -> Vec<ExtractedFileArtifact> {
let mut paths = Vec::new();
for source in [arguments, metadata] {
let Some(object) = source.and_then(as_object) else {
continue;
};
for key in [
"path",
"file_path",
"filePath",
"output_file",
"output_path",
"outputPath",
"artifact_path",
"artifact_paths",
"absolute_path",
"absolutePath",
] {
let Some(value) = object.get(key) else {
continue;
};
for path in collect_string_values(value) {
push_unique_file_path(&mut paths, path.as_str());
}
}
}
let metadata_object = metadata.and_then(as_object);
let artifact_ids = metadata_object
.and_then(|object| object.get("artifact_ids"))
.map(collect_string_values)
.unwrap_or_default();
let single_artifact_id = metadata_object
.and_then(|object| {
object
.get("artifact_id")
.or_else(|| object.get("artifactId"))
})
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_string);
paths
.into_iter()
.enumerate()
.map(|(index, path)| ExtractedFileArtifact {
path,
artifact_id: artifact_ids.get(index).cloned().or_else(|| {
if index == 0 {
single_artifact_id.clone()
} else {
None
}
}),
})
.collect()
}
fn resolve_artifact_item_status(metadata: Option<&Value>) -> AgentThreadItemStatus {
let write_phase = metadata
.and_then(|value| value.get("writePhase"))
@@ -165,18 +42,6 @@ fn resolve_artifact_item_source(metadata: Option<&Value>) -> String {
.unwrap_or_else(|| "artifact_snapshot".to_string())
}
fn extract_proposed_plan_block(text: &str) -> Option<String> {
let start = text.find(PROPOSED_PLAN_OPEN)?;
let remainder = &text[start + PROPOSED_PLAN_OPEN.len()..];
let end = remainder.find(PROPOSED_PLAN_CLOSE)?;
let content = remainder[..end].trim();
if content.is_empty() {
None
} else {
Some(content.to_string())
}
}
#[derive(Debug)]
pub struct AgentTimelineRecorder {
db: DbConnection,
@@ -187,7 +52,6 @@ pub struct AgentTimelineRecorder {
item_sequences: HashMap<String, i64>,
item_statuses: HashMap<String, AgentThreadItemStatus>,
plan_text: Option<String>,
turn_summary_text: Option<String>,
}
impl AgentTimelineRecorder {
@@ -228,7 +92,6 @@ impl AgentTimelineRecorder {
item_sequences: HashMap::new(),
item_statuses: HashMap::new(),
plan_text: None,
turn_summary_text: None,
})
}
@@ -265,7 +128,6 @@ impl AgentTimelineRecorder {
item.clone(),
TauriAgentEvent::ItemStarted { item: item.clone() },
)?;
self.maybe_project_plan_item(app, event_name, item)?;
}
TauriAgentEvent::ItemUpdated { item } => {
self.persist_runtime_item(
@@ -274,7 +136,6 @@ impl AgentTimelineRecorder {
item.clone(),
TauriAgentEvent::ItemUpdated { item: item.clone() },
)?;
self.maybe_project_plan_item(app, event_name, item)?;
}
TauriAgentEvent::ItemCompleted { item } => {
self.persist_runtime_item(
@@ -283,52 +144,9 @@ impl AgentTimelineRecorder {
item.clone(),
TauriAgentEvent::ItemCompleted { item: item.clone() },
)?;
self.maybe_project_plan_item(app, event_name, item)?;
}
TauriAgentEvent::RuntimeStatus { status } => {
let text =
format_runtime_status_text(&status.title, &status.detail, &status.checkpoints);
if !text.is_empty() {
self.turn_summary_text = Some(text.clone());
let item = self.build_item(
format!("turn_summary:{}", self.turn_id),
AgentThreadItemStatus::InProgress,
None,
AgentThreadItemPayload::TurnSummary { text },
);
self.persist_and_emit_item(app, event_name, item)?;
}
}
TauriAgentEvent::ToolEnd { tool_id, result } => {
let metadata_value = result
.metadata
.as_ref()
.and_then(|metadata| serde_json::to_value(metadata).ok());
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
.clone()
.unwrap_or_else(|| format!("artifact:{}:{}", tool_id, artifact_path)),
status.clone(),
if matches!(status, AgentThreadItemStatus::InProgress) {
None
} else {
Some(Utc::now().to_rfc3339())
},
AgentThreadItemPayload::FileArtifact {
path: artifact_path,
source: "tool_result".to_string(),
content: None,
metadata: metadata_value.clone(),
},
);
self.persist_and_emit_item(app, event_name, file_item)?;
}
}
TauriAgentEvent::RuntimeStatus { .. } => {}
TauriAgentEvent::ToolEnd { .. } => {}
TauriAgentEvent::ArtifactSnapshot { artifact } => {
let metadata_value = artifact
.metadata
@@ -480,18 +298,6 @@ impl AgentTimelineRecorder {
self.persist_and_emit_item(app, event_name, item)?;
}
if let Some(turn_summary_text) = self.turn_summary_text.clone() {
let item = self.build_item(
format!("turn_summary:{}", self.turn_id),
status,
Some(Utc::now().to_rfc3339()),
AgentThreadItemPayload::TurnSummary {
text: turn_summary_text,
},
);
self.persist_and_emit_item(app, event_name, item)?;
}
Ok(())
}
@@ -592,98 +398,8 @@ impl AgentTimelineRecorder {
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);
if let AgentThreadItemPayload::Plan { text } = &item.payload {
self.plan_text = Some(text.clone());
}
}
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(
db: &DbConnection,
request_id: &str,
response: Option<Value>,
) -> Result<(), String> {
let conn = lock_db(db)?;
let Some(mut item) = AgentTimelineDao::get_item(&conn, request_id)
.map_err(|e| format!("读取 action item 失败: {e}"))?
else {
return Ok(());
};
let payload = match item.payload {
AgentThreadItemPayload::ApprovalRequest {
request_id,
action_type,
prompt,
tool_name,
arguments,
..
} => AgentThreadItemPayload::ApprovalRequest {
request_id,
action_type,
prompt,
tool_name,
arguments,
response,
},
AgentThreadItemPayload::RequestUserInput {
request_id,
action_type,
prompt,
questions,
..
} => AgentThreadItemPayload::RequestUserInput {
request_id,
action_type,
prompt,
questions,
response,
},
other => other,
};
let now = Utc::now().to_rfc3339();
item.status = AgentThreadItemStatus::Completed;
item.completed_at = Some(now.clone());
item.updated_at = now;
item.payload = payload;
AgentTimelineDao::upsert_item(&conn, &item).map_err(|e| format!("更新 action item 失败: {e}"))
}
pub fn build_action_response_value(
confirmed: bool,
response: Option<&str>,
user_data: Option<&Value>,
) -> Option<Value> {
if let Some(value) = user_data {
return Some(value.clone());
}
if !confirmed {
return Some(json!({ "confirmed": false }));
}
response.map(|value| Value::String(value.to_string()))
}
+3 -69
View File
@@ -1,5 +1,4 @@
use crate::database::dao::agent::{AgentDao, AgentModelPatternMatch};
use crate::database::load_pending_general_messages;
use chrono::{Local, TimeZone};
use rusqlite::Connection;
use std::collections::HashSet;
@@ -24,15 +23,6 @@ pub fn load_memory_source_candidates(
let mut candidates = Vec::new();
let mut seen = HashSet::new();
load_pending_general_candidates(
conn,
from_timestamp,
to_timestamp,
limit,
min_message_length,
&mut candidates,
&mut seen,
)?;
load_unified_general_candidates(
conn,
from_timestamp,
@@ -58,33 +48,6 @@ pub fn load_memory_source_candidates(
Ok(candidates)
}
fn load_pending_general_candidates(
conn: &Connection,
from_timestamp: Option<i64>,
to_timestamp: Option<i64>,
limit: usize,
min_message_length: usize,
candidates: &mut Vec<MemorySourceCandidate>,
seen: &mut HashSet<String>,
) -> Result<(), String> {
let rows = load_pending_general_messages(conn, from_timestamp, to_timestamp, limit)
.map_err(|e| format!("读取待迁移 general 消息失败: {e}"))?;
for row in rows {
push_candidate(
candidates,
seen,
row.session_id,
row.role,
row.content,
normalize_timestamp(row.created_at),
min_message_length,
);
}
Ok(())
}
fn load_unified_general_candidates(
conn: &Connection,
from_timestamp: Option<i64>,
@@ -274,7 +237,7 @@ mod tests {
}
#[test]
fn load_memory_source_candidates_merges_unified_and_legacy_without_duplicates() {
fn load_memory_source_candidates_only_reads_unified_general_and_agent_messages() {
let conn = Connection::open_in_memory().expect("open in memory db");
create_test_schema(&conn);
@@ -299,28 +262,6 @@ mod tests {
)
.unwrap();
conn.execute(
"INSERT INTO general_chat_sessions (id, name, created_at, updated_at) VALUES (?1, ?2, ?3, ?4)",
params!["general-migrated", "旧会话", 1_741_744_000_000i64, 1_741_744_000_000i64],
)
.unwrap();
conn.execute(
"INSERT INTO general_chat_sessions (id, name, created_at, updated_at) VALUES (?1, ?2, ?3, ?4)",
params!["legacy-only", "旧会话2", 1_741_744_100_000i64, 1_741_744_100_000i64],
)
.unwrap();
conn.execute(
"INSERT INTO general_chat_messages (id, session_id, role, content, created_at) VALUES (?1, ?2, ?3, ?4, ?5)",
params!["g1", "general-migrated", "user", "这条消息已经迁移", 1_741_744_000_000i64],
)
.unwrap();
conn.execute(
"INSERT INTO general_chat_messages (id, session_id, role, content, created_at) VALUES (?1, ?2, ?3, ?4, ?5)",
params!["g2", "legacy-only", "assistant", "这条消息仍在旧表中", 1_741_744_100_000i64],
)
.unwrap();
conn.execute(
"INSERT INTO agent_messages (session_id, role, content_json, timestamp) VALUES (?1, ?2, ?3, ?4)",
params![
@@ -349,23 +290,16 @@ mod tests {
.iter()
.map(|item| item.session_id.as_str())
.collect::<Vec<_>>();
assert_eq!(candidates.len(), 3);
assert_eq!(candidates.len(), 2);
assert!(session_ids.contains(&"general-migrated"));
assert!(session_ids.contains(&"legacy-only"));
assert!(session_ids.contains(&"agent-1"));
}
#[test]
fn load_memory_source_candidates_skips_legacy_general_after_migration_completed() {
fn load_memory_source_candidates_ignores_pending_general_tables() {
let conn = Connection::open_in_memory().expect("open in memory db");
create_test_schema(&conn);
conn.execute(
"INSERT INTO settings (key, value) VALUES (?1, ?2)",
params!["migrated_general_chat_to_unified", "true"],
)
.unwrap();
conn.execute(
"INSERT INTO agent_sessions (id, model, created_at, updated_at) VALUES (?1, ?2, ?3, ?4)",
params![
@@ -4,7 +4,7 @@
use crate::database::dao::agent::{AgentDao, AgentModelPatternMatch};
use crate::database::dao::orchestrator::OrchestratorDao;
use crate::database::{summarize_pending_general, ConversationWindowSummary};
use crate::database::ConversationWindowSummary;
use chrono::{DateTime, Datelike, Duration, Local, TimeZone, Timelike};
use rusqlite::Connection;
use serde::{Deserialize, Serialize};
@@ -242,16 +242,12 @@ fn summarize_general_window(
from_timestamp_ms: Option<i64>,
to_timestamp_ms: Option<i64>,
) -> Result<ConversationWindowSummary, String> {
let unified = summarize_unified_window(
summarize_unified_window(
conn,
AgentModelPatternMatch::Like,
from_timestamp_ms,
to_timestamp_ms,
)?;
let pending = summarize_pending_general(conn, from_timestamp_ms, to_timestamp_ms)
.map_err(|e| format!("查询待迁移 general 摘要失败: {e}"))?;
Ok(unified.merge(pending))
)
}
fn summarize_agent_window(
@@ -298,6 +294,7 @@ fn build_conversation_stats(windows: ConversationWindowTriplet) -> ConversationS
}
}
#[cfg(test)]
fn query_general_chat_stats(
conn: &Connection,
today_start: &DateTime<Local>,
@@ -307,6 +304,7 @@ fn query_general_chat_stats(
.map(build_conversation_stats)
}
#[cfg(test)]
fn query_agent_chat_stats(
conn: &Connection,
today_start: &DateTime<Local>,
@@ -641,7 +639,7 @@ mod tests {
}
#[test]
fn stats_ignore_legacy_general_after_migration_completed() {
fn stats_ignore_legacy_general_tables_during_runtime() {
let conn = Connection::open_in_memory().expect("open in memory db");
create_test_schema(&conn);
@@ -651,12 +649,6 @@ mod tests {
.expect("build datetime");
let now_ms = now.timestamp_millis();
conn.execute(
"INSERT INTO settings (key, value) VALUES (?1, ?2)",
params!["migrated_general_chat_to_unified", "true"],
)
.unwrap();
conn.execute(
"INSERT INTO general_chat_sessions (id, name, created_at, updated_at) VALUES (?1, ?2, ?3, ?4)",
params!["legacy-only", "旧通用会话", now_ms, now_ms],
@@ -1,7 +1,7 @@
//! 记忆画像提示词服务
//! 记忆提示词装配服务
//!
//! 将设置页中的记忆画像(学习状态、擅长领域、解释偏好、难题偏好)
//! 转换为可注入到系统提示词中的统一指令片段。
//! 将设置页中的记忆画像与配置化记忆来源统一装配为可注入到 system prompt
//! 的单一记忆指令片段,避免调用方继续各自决定拼装顺序。
use lime_core::config::Config;
use std::path::Path;
@@ -11,6 +11,26 @@ use crate::services::memory_source_resolver_service::build_memory_sources_prompt
const MEMORY_PROFILE_PROMPT_MARKER: &str = "【用户记忆画像偏好】";
const MEMORY_SOURCE_PROMPT_MARKER: &str = "【记忆来源补充指令】";
#[derive(Debug, Clone, Copy, Default)]
pub struct MemoryPromptContext<'a> {
pub working_dir: Option<&'a Path>,
pub active_relative_path: Option<&'a str>,
}
impl<'a> MemoryPromptContext<'a> {
pub fn with_working_dir(working_dir: &'a Path) -> Self {
Self {
working_dir: Some(working_dir),
active_relative_path: None,
}
}
pub fn with_active_relative_path(mut self, active_relative_path: Option<&'a str>) -> Self {
self.active_relative_path = active_relative_path;
self
}
}
fn normalize_text(input: &str) -> Option<String> {
let trimmed = input.trim();
if trimmed.is_empty() {
@@ -32,7 +52,7 @@ fn normalize_list(items: &[String]) -> Vec<String> {
/// 仅在以下条件满足时返回:
/// - 记忆功能已启用
/// - 至少有一项画像字段有值
pub fn build_memory_profile_prompt(config: &Config) -> Option<String> {
fn build_memory_profile_prompt(config: &Config) -> Option<String> {
let memory = &config.memory;
if !memory.enabled {
return None;
@@ -83,59 +103,73 @@ pub fn build_memory_profile_prompt(config: &Config) -> Option<String> {
Some(lines.join("\n"))
}
/// 合并基础系统提示词与记忆画像提示词
///
/// - 已包含画像标记时不会重复追加
/// - 任一方为空时返回另一方
pub fn merge_system_prompt_with_memory_profile(
base_prompt: Option<String>,
fn build_memory_sources_prompt_for_context(
config: &Config,
context: MemoryPromptContext<'_>,
) -> Option<String> {
let memory_prompt = build_memory_profile_prompt(config);
let working_dir = context.working_dir?;
if !config.memory.enabled {
return None;
}
match (base_prompt, memory_prompt) {
(Some(base), Some(memory)) => {
if base.contains(MEMORY_PROFILE_PROMPT_MARKER) {
build_memory_sources_prompt(config, working_dir, context.active_relative_path, 4000)
}
fn merge_prompt_section(
base_prompt: Option<String>,
section_prompt: Option<String>,
marker: &str,
) -> Option<String> {
match (base_prompt, section_prompt) {
(Some(base), Some(section)) => {
if base.contains(marker) {
Some(base)
} else if base.trim().is_empty() {
Some(memory)
Some(section)
} else {
Some(format!("{base}\n\n{memory}"))
Some(format!("{base}\n\n{section}"))
}
}
(Some(base), None) => Some(base),
(None, Some(memory)) => Some(memory),
(None, Some(section)) => Some(section),
(None, None) => None,
}
}
pub fn merge_system_prompt_with_memory_sources(
pub fn build_memory_prompt(config: &Config, context: MemoryPromptContext<'_>) -> Option<String> {
let with_profile = merge_prompt_section(
None,
build_memory_profile_prompt(config),
MEMORY_PROFILE_PROMPT_MARKER,
);
merge_prompt_section(
with_profile,
build_memory_sources_prompt_for_context(config, context),
MEMORY_SOURCE_PROMPT_MARKER,
)
}
/// 合并基础系统提示词与统一记忆提示词。
///
/// - 画像与来源统一在同一边界内拼装
/// - 已包含对应 marker 时不会重复追加
pub fn merge_system_prompt_with_memory_context(
base_prompt: Option<String>,
config: &Config,
working_dir: &Path,
active_relative_path: Option<&str>,
context: MemoryPromptContext<'_>,
) -> Option<String> {
if !config.memory.enabled {
return base_prompt;
}
let with_profile = merge_prompt_section(
base_prompt,
build_memory_profile_prompt(config),
MEMORY_PROFILE_PROMPT_MARKER,
);
let memory_sources_prompt =
build_memory_sources_prompt(config, working_dir, active_relative_path, 4000);
match (base_prompt, memory_sources_prompt) {
(Some(base), Some(source_prompt)) => {
if base.contains(MEMORY_SOURCE_PROMPT_MARKER) {
Some(base)
} else if base.trim().is_empty() {
Some(source_prompt)
} else {
Some(format!("{base}\n\n{source_prompt}"))
}
}
(Some(base), None) => Some(base),
(None, Some(source_prompt)) => Some(source_prompt),
(None, None) => None,
}
merge_prompt_section(
with_profile,
build_memory_sources_prompt_for_context(config, context),
MEMORY_SOURCE_PROMPT_MARKER,
)
}
#[cfg(test)]
@@ -192,7 +226,11 @@ mod tests {
config.memory.profile = Some(profile);
let base = Some("前置内容\n\n【用户记忆画像偏好】\n已有内容".to_string());
let merged = merge_system_prompt_with_memory_profile(base.clone(), &config);
let merged = merge_system_prompt_with_memory_context(
base.clone(),
&config,
MemoryPromptContext::default(),
);
assert_eq!(merged, base);
}
@@ -210,10 +248,40 @@ mod tests {
config.memory.sources.project_memory_paths = vec!["AGENTS.md".to_string()];
config.memory.sources.project_rule_dirs = Vec::new();
let merged = merge_system_prompt_with_memory_sources(None, &config, tmp.path(), None)
.expect("should build sources prompt");
let merged = merge_system_prompt_with_memory_context(
None,
&config,
MemoryPromptContext::with_working_dir(tmp.path()),
)
.expect("should build sources prompt");
assert!(merged.contains("【记忆来源补充指令】"));
assert!(merged.contains("偏好简洁输出"));
}
#[test]
fn should_build_combined_memory_prompt() {
let tmp = TempDir::new().expect("create temp dir");
fs::write(tmp.path().join("AGENTS.md"), "# 项目记忆\n- 保持简洁")
.expect("write memory file");
let mut config = Config::default();
config.memory.enabled = true;
let mut profile = config.memory.profile.clone().unwrap_or_default();
profile.current_status = Some("高级开发者".to_string());
config.memory.profile = Some(profile);
config.memory.sources.project_memory_paths = vec!["AGENTS.md".to_string()];
config.memory.sources.project_rule_dirs = Vec::new();
config.memory.sources.managed_policy_path = Some("missing-managed.md".to_string());
config.memory.sources.user_memory_path = Some("missing-user.md".to_string());
let prompt =
build_memory_prompt(&config, MemoryPromptContext::with_working_dir(tmp.path()))
.expect("should build combined prompt");
assert!(prompt.contains("【用户记忆画像偏好】"));
assert!(prompt.contains("高级开发者"));
assert!(prompt.contains("【记忆来源补充指令】"));
assert!(prompt.contains("保持简洁"));
}
}
+193 -73
View File
@@ -125,6 +125,12 @@ pub struct EnvironmentDiagnostics {
pub supplemental_search_dirs: Vec<String>,
#[serde(default)]
pub supplemental_command_candidates: Vec<String>,
#[serde(default)]
pub git_where_candidates: Vec<String>,
#[serde(default)]
pub git_supplemental_search_dirs: Vec<String>,
#[serde(default)]
pub git_supplemental_command_candidates: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -4089,23 +4095,30 @@ fn find_command_in_bin_dir(command_name: &str, bin_dir: &Path) -> Option<PathBuf
))
}
async fn collect_preferred_runtime_command_dirs(
command_name: &str,
preferred_bin_dir: &Path,
) -> Result<Vec<PathBuf>, String> {
fn collect_existing_unique_dirs<I>(candidates: I) -> Vec<PathBuf>
where
I: IntoIterator<Item = PathBuf>,
{
let mut dirs = Vec::new();
let mut seen = HashSet::new();
let mut push_dir = |dir: PathBuf| {
for dir in candidates {
if dir.as_os_str().is_empty() || !dir.exists() {
return;
continue;
}
if seen.insert(dir.clone()) {
dirs.push(dir);
}
};
}
push_dir(preferred_bin_dir.to_path_buf());
dirs
}
async fn collect_preferred_runtime_command_dirs(
command_name: &str,
preferred_bin_dir: &Path,
) -> Result<Vec<PathBuf>, String> {
let mut candidate_dirs = vec![preferred_bin_dir.to_path_buf()];
if command_name == "openclaw" {
if let Some(npm_path) = find_command_in_bin_dir("npm", preferred_bin_dir)
@@ -4113,13 +4126,13 @@ async fn collect_preferred_runtime_command_dirs(
{
if let Some(prefix) = detect_npm_global_prefix(&npm_path).await {
for dir in npm_global_command_dirs(&prefix) {
push_dir(dir);
candidate_dirs.push(dir);
}
}
}
}
Ok(dirs)
Ok(collect_existing_unique_dirs(candidate_dirs))
}
async fn collect_preferred_runtime_command_candidates(
@@ -4252,9 +4265,17 @@ async fn select_command_candidate(
return select_node_runtime_candidate(candidates).await;
}
if command_name == "git" {
return Ok(select_best_git_candidate(candidates));
}
Ok(candidates.into_iter().next())
}
fn select_best_git_candidate(candidates: Vec<PathBuf>) -> Option<PathBuf> {
select_preferred_path_candidate(candidates.clone()).or_else(|| candidates.into_iter().next())
}
async fn select_preferred_runtime_candidate(
command_name: &str,
candidates: &[PathBuf],
@@ -4287,68 +4308,61 @@ async fn select_preferred_runtime_candidate(
}
fn find_all_commands_in_known_locations(command_name: &str) -> Vec<PathBuf> {
let search_dirs = collect_known_command_search_dirs();
let search_dirs = collect_known_command_search_dirs(command_name);
find_all_commands_in_paths(command_name, &search_dirs)
}
fn collect_known_command_search_dirs() -> Vec<PathBuf> {
fn collect_known_command_search_dirs(_command_name: &str) -> Vec<PathBuf> {
let mut search_dirs = Vec::new();
let mut seen = HashSet::new();
let mut push_dir = |dir: PathBuf| {
if dir.as_os_str().is_empty() || !dir.exists() {
return;
}
if seen.insert(dir.clone()) {
search_dirs.push(dir);
}
};
if let Some(path_var) = std::env::var_os("PATH") {
for dir in std::env::split_paths(&path_var) {
push_dir(dir);
}
search_dirs.extend(std::env::split_paths(&path_var));
}
if let Some(home) = home_dir() {
push_dir(home.join(".npm-global/bin"));
push_dir(home.join(".local/bin"));
push_dir(home.join(".bun/bin"));
push_dir(home.join(".volta/bin"));
push_dir(home.join(".asdf/shims"));
push_dir(home.join(".local/share/mise/shims"));
push_dir(home.join("Library/PhpWebStudy/env/node/bin"));
search_dirs.extend([
home.join(".npm-global/bin"),
home.join(".local/bin"),
home.join(".bun/bin"),
home.join(".volta/bin"),
home.join(".asdf/shims"),
home.join(".local/share/mise/shims"),
home.join("Library/PhpWebStudy/env/node/bin"),
]);
let nvm_versions = home.join(".nvm/versions/node");
if let Ok(entries) = std::fs::read_dir(nvm_versions) {
for entry in entries.flatten() {
push_dir(entry.path().join("bin"));
search_dirs.push(entry.path().join("bin"));
}
}
let fnm_versions = home.join(".fnm/node-versions");
if let Ok(entries) = std::fs::read_dir(fnm_versions) {
for entry in entries.flatten() {
push_dir(entry.path().join("installation/bin"));
search_dirs.push(entry.path().join("installation/bin"));
}
}
}
#[cfg(target_os = "windows")]
{
for dir in windows_known_command_dirs_from_env() {
push_dir(dir);
search_dirs.extend(windows_known_command_dirs_from_env());
if _command_name == "git" {
search_dirs.extend(windows_known_git_command_dirs_from_env());
}
}
if cfg!(target_os = "macos") {
push_dir(PathBuf::from("/opt/homebrew/bin"));
push_dir(PathBuf::from("/usr/local/bin"));
push_dir(PathBuf::from("/usr/bin"));
push_dir(PathBuf::from("/bin"));
search_dirs.extend([
PathBuf::from("/opt/homebrew/bin"),
PathBuf::from("/usr/local/bin"),
PathBuf::from("/usr/bin"),
PathBuf::from("/bin"),
]);
}
search_dirs
collect_existing_unique_dirs(search_dirs)
}
#[cfg(target_os = "windows")]
@@ -4386,17 +4400,64 @@ fn windows_known_command_dirs_from_env() -> Vec<PathBuf> {
dirs
}
fn find_all_commands_in_paths(command_name: &str, search_dirs: &[PathBuf]) -> Vec<PathBuf> {
#[cfg(target_os = "windows")]
let candidates = [
format!("{command_name}.exe"),
format!("{command_name}.cmd"),
format!("{command_name}.bat"),
command_name.to_string(),
];
#[cfg(any(target_os = "windows", test))]
fn windows_git_install_dir_variants(root: PathBuf) -> Vec<PathBuf> {
vec![
root.join("cmd"),
root.join("bin"),
root.join("mingw64").join("bin"),
]
}
#[cfg(not(target_os = "windows"))]
let candidates = [command_name.to_string()];
#[cfg(target_os = "windows")]
fn windows_known_git_command_dirs_from_env() -> Vec<PathBuf> {
let mut dirs = Vec::new();
if let Some(program_files) = std::env::var_os("ProgramFiles") {
dirs.extend(windows_git_install_dir_variants(
PathBuf::from(program_files).join("Git"),
));
}
if let Some(program_files_x86) = std::env::var_os("ProgramFiles(x86)") {
dirs.extend(windows_git_install_dir_variants(
PathBuf::from(program_files_x86).join("Git"),
));
}
if let Some(localappdata) = std::env::var_os("LOCALAPPDATA") {
dirs.extend(windows_git_install_dir_variants(
PathBuf::from(localappdata).join("Programs").join("Git"),
));
}
if let Some(home) = home_dir() {
dirs.extend(windows_git_install_dir_variants(
home.join("scoop").join("apps").join("git").join("current"),
));
}
dirs
}
fn find_all_commands_in_paths(command_name: &str, search_dirs: &[PathBuf]) -> Vec<PathBuf> {
find_all_commands_in_paths_for(current_shell_platform(), command_name, search_dirs)
}
fn find_all_commands_in_paths_for(
platform: ShellPlatform,
command_name: &str,
search_dirs: &[PathBuf],
) -> Vec<PathBuf> {
let candidates = match platform {
ShellPlatform::Windows => vec![
format!("{command_name}.exe"),
format!("{command_name}.cmd"),
format!("{command_name}.bat"),
command_name.to_string(),
],
ShellPlatform::Unix => vec![command_name.to_string()],
};
let mut matches = Vec::new();
let mut seen = HashSet::new();
@@ -4844,6 +4905,24 @@ async fn collect_environment_diagnostics() -> EnvironmentDiagnostics {
.and_then(find_installed_openclaw_package_details)
.map(|package| package.path.display().to_string());
#[cfg(target_os = "windows")]
let git_where_candidates = find_commands_via_where("git")
.await
.unwrap_or_default()
.into_iter()
.map(|path| path.display().to_string())
.collect();
#[cfg(not(target_os = "windows"))]
let git_where_candidates = Vec::new();
let git_supplemental_search_dirs = collect_supplemental_git_search_dirs();
let git_supplemental_command_candidates =
find_all_commands_in_paths("git", &git_supplemental_search_dirs)
.into_iter()
.map(|path| path.display().to_string())
.collect();
EnvironmentDiagnostics {
npm_path,
npm_global_prefix,
@@ -4854,36 +4933,40 @@ async fn collect_environment_diagnostics() -> EnvironmentDiagnostics {
.map(|path| path.display().to_string())
.collect(),
supplemental_command_candidates,
git_where_candidates,
git_supplemental_search_dirs: git_supplemental_search_dirs
.into_iter()
.map(|path| path.display().to_string())
.collect(),
git_supplemental_command_candidates,
}
}
fn collect_supplemental_openclaw_search_dirs(npm_global_prefix: Option<&str>) -> Vec<PathBuf> {
let mut dirs = Vec::new();
let mut seen = HashSet::new();
let mut push_dir = |dir: PathBuf| {
if dir.as_os_str().is_empty() || !dir.exists() {
return;
}
if seen.insert(dir.clone()) {
dirs.push(dir);
}
};
#[cfg(target_os = "windows")]
{
for dir in windows_known_command_dirs_from_env() {
push_dir(dir);
}
dirs.extend(windows_known_command_dirs_from_env());
}
if let Some(prefix) = npm_global_prefix {
for dir in npm_global_command_dirs(prefix) {
push_dir(dir);
}
dirs.extend(npm_global_command_dirs(prefix));
}
dirs
collect_existing_unique_dirs(dirs)
}
fn collect_supplemental_git_search_dirs() -> Vec<PathBuf> {
#[cfg(target_os = "windows")]
{
return collect_existing_unique_dirs(windows_known_git_command_dirs_from_env());
}
#[cfg(not(target_os = "windows"))]
{
Vec::new()
}
}
async fn select_best_node_candidate(candidates: Vec<PathBuf>) -> Result<Option<PathBuf>, String> {
@@ -5263,15 +5346,15 @@ mod tests {
npm_global_node_modules_dirs_for, package_registry_for_package_spec,
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,
runtime_candidate_matches_install_root, sanitize_runtime_config, select_best_git_candidate,
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,
ResolvedOpenClawCommand, ShellPlatform, WindowsDependencyInstallPlan, NPM_MIRROR_CN,
OPENCLAW_CN_PACKAGE, OPENCLAW_DEFAULT_PACKAGE,
windows_git_install_dir_variants, windows_install_block_result,
windows_manual_install_message, DependencyKind, 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;
@@ -5924,6 +6007,43 @@ mod tests {
);
}
#[test]
fn git_candidate_selection_prefers_executable_extension() {
let preferred = select_best_git_candidate(vec![
PathBuf::from(r"C:\Program Files\Git\cmd\git.cmd"),
PathBuf::from(r"C:\Program Files\Git\cmd\git.exe"),
]);
assert_eq!(
preferred,
Some(PathBuf::from(r"C:\Program Files\Git\cmd\git.exe"))
);
}
#[test]
fn windows_git_install_dir_variants_cover_common_layouts() {
let git_root = build_unique_temp_dir("git-layout-root");
let cmd_dir = git_root.join("cmd");
let bin_dir = git_root.join("bin");
fs::create_dir_all(&cmd_dir).unwrap();
fs::create_dir_all(&bin_dir).unwrap();
fs::write(cmd_dir.join("git.exe"), "").unwrap();
fs::write(bin_dir.join("git.cmd"), "").unwrap();
let matches = super::find_all_commands_in_paths_for(
ShellPlatform::Windows,
"git",
&windows_git_install_dir_variants(git_root.clone()),
);
let _ = fs::remove_dir_all(&git_root);
assert_eq!(
matches,
vec![cmd_dir.join("git.exe"), bin_dir.join("git.cmd")]
);
}
#[test]
fn windows_npm_global_command_dirs_use_prefix_root() {
assert_eq!(
+27
View File
@@ -7,8 +7,12 @@
| 文件 | 说明 |
|------|------|
| `mod.rs` | 模块导出 |
| `catalog.rs` | skill catalog 枚举、详情 DTO 与标准合规校验边界 |
| `execution.rs` | Skill prompt/workflow 的 Tauri emitter、execution_mode 路由、按 skill_name 执行主链与错误码适配壳(纯执行主链位于 `crates/agent/src/skill_execution.rs`) |
| `llm_provider.rs` | 桥接层(纯逻辑已迁移到 `crates/skills/src/lime_llm_provider.rs`) |
| `execution_callback.rs` | TauriExecutionCallback 实现(保留在主 crate) |
| `runtime.rs` | skill 执行前置准备、provider fallback、run metadata 投影边界 |
| `social_post.rs` | `social_post_with_cover` 的后处理与产物事件投影 |
## Skills 集成架构
@@ -78,6 +82,23 @@ agent/aster_state.rs
└── reload_lime_skills()
skills/
├── catalog.rs
│ ├── 可执行 skill 列表枚举
│ ├── skill 详情 DTO 投影
│ └── 标准合规校验错误归一
├── execution.rs (Tauri 适配壳)
│ ├── execute_named_skill 统一技能执行入口
│ └── crates/agent/src/skill_execution.rs
│ ├── prompt/workflow 执行主链
│ ├── Aster reply 流桥接
│ └── SkillExecutionResult / StepResult
├── runtime.rs
│ ├── agent/tool 初始化准备
│ ├── provider fallback 与统一 memory prompt
│ └── skill run start/finish metadata 投影
├── social_post.rs
│ ├── social_post_with_cover 结果标准化
│ └── 社媒产物 Tool/Artifact 事件补投影
├── llm_provider.rs (桥接)
│ └── crates/skills/src/lime_llm_provider.rs
│ ├── ProviderPoolService (凭证池管理)
@@ -103,6 +124,12 @@ commands/skill_cmd.rs
- Agent Skills 是唯一标准格式
- Lime 私有能力统一写入 `metadata.lime_*`
- Workflow 不再推荐使用 `steps-json` 内联,优先通过 `metadata.lime_workflow_ref` 指向 `references/` 下文件
- skill catalog 的枚举、详情 DTO 与标准合规校验统一收口到 `skills/catalog.rs`
- execution_mode -> prompt/workflow 的路由统一收口到 `skills/execution.rs`
- 按 `skill_name` 执行、execution tracker 包装与场景命令复用统一收口到 `skills/execution.rs`
- prompt/workflow 的执行、session 构建、流事件桥接统一收口到 `lime-agent::skill_execution`
- skill 执行前的 agent/tool 初始化、provider fallback 与 execution tracker metadata 统一收口到 `skills/runtime.rs`
- `social_post_with_cover` 的结果标准化与补充 Artifact 事件统一收口到 `skills/social_post.rs`
- 服务层和执行层共用 `SkillService::inspect_*` inspection 结果作为标准合规事实源,并向前端暴露标准合规状态与资源摘要
- 无效 Skill 仍可在管理页中看到检查结果,但不会进入运行时自动加载和可执行列表
- 管理链路支持创建最小标准 Skill 脚手架,新建结果会立即经过统一 inspection 校验
+532
View File
@@ -0,0 +1,532 @@
use serde::{Deserialize, Serialize};
use crate::commands::skill_error::{
format_skill_error, map_find_skill_error, SKILL_ERR_CATALOG_UNAVAILABLE,
SKILL_ERR_EXECUTE_FAILED,
};
use lime_skills::{
find_skill_by_name, get_skill_roots, load_skills_from_directory, LoadedSkillDefinition,
};
/// 可执行 Skill 信息
///
/// 用于 list_executable_skills 命令的返回类型
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ExecutableSkillInfo {
/// Skill 名称(唯一标识)
pub name: String,
/// 显示名称
pub display_name: String,
/// Skill 描述
pub description: String,
/// 执行模式:prompt, workflow, agent
pub execution_mode: String,
/// 是否有 workflow 定义
pub has_workflow: bool,
/// 指定的 Provider(可选)
pub provider: Option<String>,
/// 指定的 Model(可选)
pub model: Option<String>,
/// 参数提示(可选)
pub argument_hint: Option<String>,
}
/// Workflow 步骤信息
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WorkflowStepInfo {
/// 步骤 ID
pub id: String,
/// 步骤名称
pub name: String,
/// 依赖的步骤 ID 列表
pub dependencies: Vec<String>,
}
/// Skill 详情信息
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SkillDetailInfo {
/// 基本信息
#[serde(flatten)]
pub basic: ExecutableSkillInfo,
/// Markdown 内容
pub markdown_content: String,
/// Workflow 步骤(如果有)
pub workflow_steps: Option<Vec<WorkflowStepInfo>>,
/// 允许的工具列表(可选)
pub allowed_tools: Option<Vec<String>>,
/// 使用场景说明(可选)
pub when_to_use: Option<String>,
}
pub fn invalid_skill_message(skill: &LoadedSkillDefinition) -> Option<String> {
if skill.standard_compliance.validation_errors.is_empty() {
return None;
}
Some(format!(
"Skill '{}' 未通过标准校验: {}",
skill.skill_name,
skill.standard_compliance.validation_errors.join("; ")
))
}
pub fn load_executable_skill_definition(skill_name: &str) -> Result<LoadedSkillDefinition, String> {
let skill = find_skill_by_name(skill_name).map_err(map_find_skill_error)?;
if let Some(message) = invalid_skill_message(&skill) {
return Err(format_skill_error(SKILL_ERR_EXECUTE_FAILED, message));
}
if skill.disable_model_invocation {
return Err(format_skill_error(
SKILL_ERR_EXECUTE_FAILED,
format!("Skill '{skill_name}' 已禁用模型调用,无法执行"),
));
}
Ok(skill)
}
fn to_executable_skill_info(skill: LoadedSkillDefinition) -> ExecutableSkillInfo {
ExecutableSkillInfo {
name: skill.skill_name,
display_name: skill.display_name,
description: skill.description,
execution_mode: skill.execution_mode.clone(),
has_workflow: skill.execution_mode == "workflow",
provider: skill.provider,
model: skill.model,
argument_hint: skill.argument_hint,
}
}
pub fn list_executable_skill_catalog() -> Result<Vec<ExecutableSkillInfo>, String> {
let skill_roots = get_skill_roots();
if skill_roots.is_empty() {
return Err(format_skill_error(
SKILL_ERR_CATALOG_UNAVAILABLE,
"无法获取 Skills 目录",
));
}
let mut all_skills = Vec::new();
let mut seen = std::collections::HashSet::new();
for skill_root in skill_roots {
for skill in load_skills_from_directory(&skill_root) {
if seen.insert(skill.skill_name.clone()) {
all_skills.push(skill);
}
}
}
let executable_skills: Vec<ExecutableSkillInfo> = all_skills
.into_iter()
.filter(|skill| !skill.disable_model_invocation)
.map(to_executable_skill_info)
.collect();
tracing::info!(
"[list_executable_skills] 返回 {} 个可执行 Skills",
executable_skills.len()
);
Ok(executable_skills)
}
pub fn get_skill_detail_info(skill_name: &str) -> Result<SkillDetailInfo, String> {
let skill = load_executable_skill_definition(skill_name)?;
let detail = SkillDetailInfo {
basic: ExecutableSkillInfo {
name: skill.skill_name,
display_name: skill.display_name,
description: skill.description,
execution_mode: skill.execution_mode.clone(),
has_workflow: skill.execution_mode == "workflow",
provider: skill.provider,
model: skill.model,
argument_hint: skill.argument_hint,
},
markdown_content: skill.markdown_content,
workflow_steps: if skill.workflow_steps.is_empty() {
None
} else {
Some(
skill
.workflow_steps
.iter()
.map(|step| WorkflowStepInfo {
id: step.id.clone(),
name: step.name.clone(),
dependencies: Vec::new(),
})
.collect(),
)
},
allowed_tools: skill.allowed_tools,
when_to_use: skill.when_to_use,
};
tracing::info!("[get_skill_detail] 返回 Skill 详情: name={}", skill_name);
Ok(detail)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::skills::{SkillExecutionResult, StepResult};
use lime_skills::{
load_skill_from_file, parse_allowed_tools, parse_boolean, parse_skill_frontmatter,
};
#[test]
fn test_executable_skill_info_serialization() {
let info = ExecutableSkillInfo {
name: "test-skill".to_string(),
display_name: "Test Skill".to_string(),
description: "A test skill".to_string(),
execution_mode: "prompt".to_string(),
has_workflow: false,
provider: None,
model: None,
argument_hint: Some("Enter your query".to_string()),
};
let json = serde_json::to_string(&info).unwrap();
assert!(json.contains("test-skill"));
assert!(json.contains("Test Skill"));
}
#[test]
fn test_skill_execution_result_serialization() {
let result = SkillExecutionResult {
success: true,
output: Some("Hello, world!".to_string()),
error: None,
steps_completed: vec![StepResult {
step_id: "step-1".to_string(),
step_name: "Process".to_string(),
success: true,
output: Some("Done".to_string()),
error: None,
}],
};
let json = serde_json::to_string(&result).unwrap();
assert!(json.contains("\"success\":true"));
assert!(json.contains("Hello, world!"));
assert!(json.contains("step-1"));
}
#[test]
fn test_skill_detail_info_serialization() {
let detail = SkillDetailInfo {
basic: ExecutableSkillInfo {
name: "workflow-skill".to_string(),
display_name: "Workflow Skill".to_string(),
description: "A workflow skill".to_string(),
execution_mode: "workflow".to_string(),
has_workflow: true,
provider: Some("claude".to_string()),
model: Some("claude-sonnet-4-5-20250514".to_string()),
argument_hint: None,
},
markdown_content: "# Workflow Skill\n\nThis is a workflow skill.".to_string(),
workflow_steps: Some(vec![
WorkflowStepInfo {
id: "step-1".to_string(),
name: "Initialize".to_string(),
dependencies: vec![],
},
WorkflowStepInfo {
id: "step-2".to_string(),
name: "Process".to_string(),
dependencies: vec!["step-1".to_string()],
},
]),
allowed_tools: Some(vec!["read_file".to_string(), "write_file".to_string()]),
when_to_use: Some("Use this skill for complex workflows".to_string()),
};
let json = serde_json::to_string(&detail).unwrap();
assert!(json.contains("workflow-skill"));
assert!(json.contains("workflow_steps"));
assert!(json.contains("step-1"));
assert!(json.contains("step-2"));
}
#[test]
fn test_parse_skill_frontmatter_basic() {
let content = r#"---
name: test-skill
description: A test skill
metadata:
lime_model_preference: claude-sonnet-4-5-20250514
lime_provider_preference: claude
---
# Test Skill
This is the body content.
"#;
let (fm, body) = parse_skill_frontmatter(content);
assert_eq!(fm.name, Some("test-skill".to_string()));
assert_eq!(fm.description, Some("A test skill".to_string()));
assert_eq!(fm.model, Some("claude-sonnet-4-5-20250514".to_string()));
assert_eq!(fm.provider, Some("claude".to_string()));
assert!(body.contains("# Test Skill"));
assert!(body.contains("This is the body content."));
}
#[test]
fn test_parse_skill_frontmatter_no_frontmatter() {
let content = "# Just content\nNo frontmatter here.";
let (fm, body) = parse_skill_frontmatter(content);
assert!(fm.name.is_none());
assert_eq!(body, content);
}
#[test]
fn test_parse_skill_frontmatter_with_quotes() {
let content = r#"---
name: "quoted-name"
description: 'single quoted'
---
Body
"#;
let (fm, _) = parse_skill_frontmatter(content);
assert_eq!(fm.name, Some("quoted-name".to_string()));
assert_eq!(fm.description, Some("single quoted".to_string()));
}
#[test]
fn test_parse_allowed_tools() {
assert_eq!(parse_allowed_tools(None), None);
assert_eq!(parse_allowed_tools(Some("")), None);
assert_eq!(
parse_allowed_tools(Some("tool1")),
Some(vec!["tool1".to_string()])
);
assert_eq!(
parse_allowed_tools(Some("tool1, tool2, tool3")),
Some(vec![
"tool1".to_string(),
"tool2".to_string(),
"tool3".to_string()
])
);
}
#[test]
fn test_parse_boolean() {
assert!(!parse_boolean(None, false));
assert!(parse_boolean(None, true));
assert!(parse_boolean(Some("true"), false));
assert!(parse_boolean(Some("TRUE"), false));
assert!(parse_boolean(Some("1"), false));
assert!(parse_boolean(Some("yes"), false));
assert!(!parse_boolean(Some("false"), true));
assert!(!parse_boolean(Some("no"), true));
}
#[test]
fn test_load_skill_from_file() {
use tempfile::TempDir;
let temp_dir = TempDir::new().unwrap();
let skill_dir = temp_dir.path().join("my-skill");
std::fs::create_dir(&skill_dir).unwrap();
let skill_file = skill_dir.join("SKILL.md");
std::fs::write(
&skill_file,
r#"---
name: my-skill
description: Test skill description
allowed-tools: tool1, tool2
metadata:
lime_model_preference: gpt-4
lime_provider_preference: openai
---
# My Skill
Instructions here.
"#,
)
.unwrap();
let skill = load_skill_from_file("my-skill", &skill_file).unwrap();
assert_eq!(skill.skill_name, "my-skill");
assert_eq!(skill.display_name, "my-skill");
assert_eq!(skill.description, "Test skill description");
assert_eq!(
skill.allowed_tools,
Some(vec!["tool1".to_string(), "tool2".to_string()])
);
assert_eq!(skill.model, Some("gpt-4".to_string()));
assert_eq!(skill.provider, Some("openai".to_string()));
assert!(!skill.disable_model_invocation);
assert_eq!(skill.execution_mode, "prompt");
assert!(skill.standard_compliance.is_standard);
}
#[test]
fn test_load_skill_from_file_should_surface_invalid_workflow_reference() {
use tempfile::TempDir;
let temp_dir = TempDir::new().unwrap();
let skill_dir = temp_dir.path().join("workflow-skill");
std::fs::create_dir(&skill_dir).unwrap();
let skill_file = skill_dir.join("SKILL.md");
std::fs::write(
&skill_file,
r#"---
name: workflow-skill
description: Workflow skill
metadata:
lime_workflow_ref: references/missing.json
---
# Workflow Skill
"#,
)
.unwrap();
let skill = load_skill_from_file("workflow-skill", &skill_file).unwrap();
assert!(!skill.standard_compliance.is_standard);
assert!(skill
.standard_compliance
.validation_errors
.iter()
.any(|error| error.contains("metadata.lime_workflow_ref")));
assert!(skill.workflow_steps.is_empty());
}
#[test]
fn test_load_skills_from_directory() {
use tempfile::TempDir;
let temp_dir = TempDir::new().unwrap();
let skills_dir = temp_dir.path();
let skill1_dir = skills_dir.join("skill-one");
std::fs::create_dir(&skill1_dir).unwrap();
std::fs::write(
skill1_dir.join("SKILL.md"),
r#"---
name: skill-one
description: First skill
---
Content 1
"#,
)
.unwrap();
let skill2_dir = skills_dir.join("skill-two");
std::fs::create_dir(&skill2_dir).unwrap();
std::fs::write(
skill2_dir.join("SKILL.md"),
r#"---
name: skill-two
description: Second skill
disable-model-invocation: true
---
Content 2
"#,
)
.unwrap();
let skills = load_skills_from_directory(skills_dir);
assert_eq!(skills.len(), 2);
let names: Vec<_> = skills
.iter()
.map(|skill| skill.skill_name.as_str())
.collect();
assert!(names.contains(&"skill-one"));
assert!(names.contains(&"skill-two"));
let skill_two = skills
.iter()
.find(|skill| skill.skill_name == "skill-two")
.unwrap();
assert!(skill_two.disable_model_invocation);
}
#[test]
fn test_load_skills_from_directory_should_skip_invalid_skill_packages() {
use tempfile::TempDir;
let temp_dir = TempDir::new().unwrap();
let skills_dir = temp_dir.path();
let valid_dir = skills_dir.join("skill-valid");
std::fs::create_dir(&valid_dir).unwrap();
std::fs::write(
valid_dir.join("SKILL.md"),
r#"---
name: skill-valid
description: Valid skill
---
Valid content
"#,
)
.unwrap();
let invalid_dir = skills_dir.join("skill-invalid");
std::fs::create_dir(&invalid_dir).unwrap();
std::fs::write(
invalid_dir.join("SKILL.md"),
r#"---
name: skill-invalid
description: Invalid skill
metadata:
lime_workflow_ref: references/missing.json
---
Invalid content
"#,
)
.unwrap();
let skills = load_skills_from_directory(skills_dir);
assert_eq!(skills.len(), 1);
assert_eq!(skills[0].skill_name, "skill-valid");
}
#[test]
fn test_load_skills_from_nonexistent_directory() {
let skills = load_skills_from_directory(std::path::Path::new("/nonexistent/path"));
assert!(skills.is_empty());
}
#[test]
fn test_bundled_social_post_with_cover_skill_contract() {
let skill_file = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("resources/default-skills/social_post_with_cover/SKILL.md");
assert!(skill_file.exists());
let content = std::fs::read_to_string(&skill_file).unwrap();
let skill = load_skill_from_file("social_post_with_cover", &skill_file).unwrap();
assert_eq!(skill.skill_name, "social_post_with_cover");
assert_eq!(skill.execution_mode, "workflow");
assert_eq!(
skill.workflow_ref,
Some("references/workflow.json".to_string())
);
assert_eq!(
skill.allowed_tools,
Some(vec![
"social_generate_cover_image".to_string(),
"search_query".to_string(),
])
);
assert!(content.contains("<write_file") && content.contains("social-posts/"));
assert!(!skill.disable_model_invocation);
assert!(skill.standard_compliance.is_standard);
}
}
+358
View File
@@ -0,0 +1,358 @@
//! Skill 执行编排适配层
//!
//! prompt/workflow 纯逻辑已下沉到 `lime-agent`,
//! 本模块只保留 Tauri emitter 与错误码映射。
use lime_agent::{
execute_skill_prompt as execute_agent_skill_prompt,
execute_skill_workflow as execute_agent_skill_workflow, AsterAgentState, SkillEventEmitter,
SkillExecutionError, SkillWorkflowExecution, TauriAgentEvent,
};
use lime_skills::{ExecutionCallback, LoadedSkillDefinition};
use std::sync::{Arc, Mutex};
use tauri::{AppHandle, Emitter};
use uuid::Uuid;
use crate::commands::api_key_provider_cmd::ApiKeyProviderServiceState;
use crate::commands::skill_error::{
format_skill_error, SKILL_ERR_EXECUTE_FAILED, SKILL_ERR_SESSION_INIT_FAILED,
};
use crate::config::GlobalConfigManagerState;
use crate::database::DbConnection;
use crate::services::execution_tracker_service::{ExecutionTracker, RunSource};
use super::execution_callback::TauriExecutionCallback;
use super::load_executable_skill_definition;
use super::runtime::{
build_skill_run_finish_decision, build_skill_run_start_metadata, prepare_skill_execution,
};
use super::social_post::finalize_skill_output;
#[derive(Debug, Clone)]
pub struct SkillExecutionRequest {
pub skill_name: String,
pub user_input: String,
pub provider_override: Option<String>,
pub model_override: Option<String>,
pub execution_id: Option<String>,
pub session_id: Option<String>,
}
fn ensure_skill_error_code(code: &str, message: &str) -> String {
if message.contains('|') {
message.to_string()
} else {
format_skill_error(code, message)
}
}
struct TauriExecutionCallbackAdapter<'a> {
inner: &'a TauriExecutionCallback,
}
impl<'a> TauriExecutionCallbackAdapter<'a> {
fn new(inner: &'a TauriExecutionCallback) -> Self {
Self { inner }
}
}
impl ExecutionCallback for TauriExecutionCallbackAdapter<'_> {
fn on_step_start(
&self,
step_id: &str,
step_name: &str,
current_step: usize,
total_steps: usize,
) {
self.inner
.on_step_start(step_id, step_name, current_step, total_steps);
}
fn on_step_complete(&self, step_id: &str, output: &str) {
self.inner.on_step_complete(step_id, output);
}
fn on_step_error(&self, step_id: &str, error: &str, will_retry: bool) {
self.inner.on_step_error(step_id, error, will_retry);
}
fn on_complete(&self, success: bool, final_output: Option<&str>, error: Option<&str>) {
let mapped_error = if success {
error.map(|value| value.to_string())
} else {
error.map(|value| ensure_skill_error_code(SKILL_ERR_EXECUTE_FAILED, value))
};
self.inner
.on_complete(success, final_output, mapped_error.as_deref());
}
}
fn create_skill_event_emitter(app_handle: &AppHandle) -> SkillEventEmitter {
let app_handle = app_handle.clone();
Arc::new(move |event_name: String, event: TauriAgentEvent| {
if let Err(error) = app_handle.emit(&event_name, &event) {
tracing::error!("[execute_skill_workflow] 发送事件失败: {}", error);
}
})
}
fn emit_skill_final_done(app_handle: &AppHandle, execution_id: &str) {
let event_name = format!("skill-exec-{execution_id}");
if let Err(error) = app_handle.emit(&event_name, TauriAgentEvent::FinalDone { usage: None }) {
tracing::error!("[execute_skill] 发送完成事件失败: {}", error);
}
}
fn map_execution_error(error: SkillExecutionError) -> String {
match error {
SkillExecutionError::SessionInitFailed(message) => {
format_skill_error(SKILL_ERR_SESSION_INIT_FAILED, message)
}
}
}
fn map_execution_result(mut result: SkillExecutionResult) -> SkillExecutionResult {
if !result.success {
result.error = result
.error
.take()
.map(|error| ensure_skill_error_code(SKILL_ERR_EXECUTE_FAILED, &error));
}
result
}
pub async fn execute_named_skill(
app_handle: &AppHandle,
db: &DbConnection,
api_key_provider_service: &ApiKeyProviderServiceState,
config_manager: &GlobalConfigManagerState,
aster_state: &AsterAgentState,
request: SkillExecutionRequest,
) -> Result<SkillExecutionResult, String> {
let SkillExecutionRequest {
skill_name,
user_input,
provider_override,
model_override,
execution_id,
session_id,
} = request;
let execution_id = execution_id.unwrap_or_else(|| Uuid::new_v4().to_string());
let session_id = session_id.unwrap_or_else(|| format!("skill-exec-{}", Uuid::new_v4()));
let tracker = ExecutionTracker::new(db.clone());
let provider_selection = Arc::new(Mutex::new(None));
let start_metadata = build_skill_run_start_metadata(
skill_name.as_str(),
execution_id.as_str(),
user_input.as_str(),
provider_override.as_deref(),
model_override.as_deref(),
);
let provider_selection_for_run = Arc::clone(&provider_selection);
let provider_selection_for_finalize = Arc::clone(&provider_selection);
let skill_name_for_run = skill_name.clone();
let execution_id_for_run = execution_id.clone();
let session_id_for_run = session_id.clone();
let user_input_for_run = user_input.clone();
let provider_override_for_run = provider_override.clone();
let model_override_for_run = model_override.clone();
let skill_name_for_finalize = skill_name.clone();
let execution_id_for_finalize = execution_id.clone();
let provider_override_for_finalize = provider_override.clone();
let model_override_for_finalize = model_override.clone();
let app_handle = app_handle.clone();
let db = db.clone();
let api_key_provider_service = ApiKeyProviderServiceState(api_key_provider_service.0.clone());
let config_manager = GlobalConfigManagerState(config_manager.0.clone());
let aster_state = aster_state.clone();
tracker
.with_run_custom(
RunSource::Skill,
Some(skill_name.clone()),
Some(session_id.clone()),
Some(start_metadata),
async move {
tracing::info!(
"[execute_skill] 开始执行 Skill: name={}, execution_id={}, session_id={}, provider_override={:?}, model_override={:?}",
skill_name_for_run,
execution_id_for_run,
session_id_for_run,
provider_override_for_run,
model_override_for_run
);
let skill = load_executable_skill_definition(&skill_name_for_run)?;
let prepared = prepare_skill_execution(
&app_handle,
&db,
&api_key_provider_service,
&config_manager,
&aster_state,
&skill,
&execution_id_for_run,
&session_id_for_run,
provider_override_for_run.as_deref(),
model_override_for_run.as_deref(),
)
.await?;
if let Ok(mut slot) = provider_selection_for_run.lock() {
*slot = Some(prepared.provider_selection.clone());
} else {
tracing::warn!(
"[execute_skill] provider 选择状态锁定失败,运行记录将缺少 resolved provider 元数据"
);
}
execute_skill_definition(
&app_handle,
&aster_state,
&skill,
&user_input_for_run,
&execution_id_for_run,
&session_id_for_run,
&prepared.callback,
prepared.memory_prompt.as_deref(),
)
.await
},
move |result| {
let provider_selection = provider_selection_for_finalize
.lock()
.ok()
.and_then(|slot| slot.as_ref().cloned());
build_skill_run_finish_decision(
&skill_name_for_finalize,
&execution_id_for_finalize,
provider_override_for_finalize.as_deref(),
model_override_for_finalize.as_deref(),
provider_selection.as_ref(),
result,
)
},
)
.await
}
pub async fn execute_skill_prompt(
app_handle: &AppHandle,
aster_state: &AsterAgentState,
skill: &LoadedSkillDefinition,
user_input: &str,
execution_id: &str,
session_id: &str,
callback: &TauriExecutionCallback,
memory_prompt: Option<&str>,
) -> Result<SkillExecutionResult, String> {
let callback_adapter = TauriExecutionCallbackAdapter::new(callback);
callback_adapter.on_step_start("main", &skill.display_name, 1, 1);
let mut result = map_execution_result(
execute_agent_skill_prompt(
aster_state,
skill,
user_input,
execution_id,
session_id,
memory_prompt,
create_skill_event_emitter(app_handle),
)
.await
.map_err(map_execution_error)?,
);
if !result.success {
let error_message = result
.error
.clone()
.unwrap_or_else(|| format_skill_error(SKILL_ERR_EXECUTE_FAILED, "Unknown error"));
callback_adapter.on_step_error("main", &error_message, false);
callback_adapter.on_complete(false, None, Some(&error_message));
emit_skill_final_done(app_handle, execution_id);
return Ok(result);
}
let final_output = finalize_skill_output(
app_handle,
&skill.skill_name,
user_input,
execution_id,
result.output.as_deref().unwrap_or(""),
);
result.output = Some(final_output.clone());
if let Some(step_result) = result.steps_completed.get_mut(0) {
step_result.output = Some(final_output.clone());
}
callback_adapter.on_step_complete("main", &final_output);
callback_adapter.on_complete(true, Some(&final_output), None);
emit_skill_final_done(app_handle, execution_id);
Ok(result)
}
pub async fn execute_skill_workflow(
app_handle: &AppHandle,
aster_state: &AsterAgentState,
skill: &LoadedSkillDefinition,
user_input: &str,
execution_id: &str,
session_id: &str,
callback: &TauriExecutionCallback,
memory_prompt: Option<&str>,
) -> Result<SkillExecutionResult, String> {
let callback_adapter = TauriExecutionCallbackAdapter::new(callback);
execute_agent_skill_workflow(SkillWorkflowExecution {
aster_state,
skill,
user_input,
execution_id,
session_id,
callback: &callback_adapter,
memory_prompt,
emitter: create_skill_event_emitter(app_handle),
})
.await
.map(map_execution_result)
.map_err(map_execution_error)
}
pub async fn execute_skill_definition(
app_handle: &AppHandle,
aster_state: &AsterAgentState,
skill: &LoadedSkillDefinition,
user_input: &str,
execution_id: &str,
session_id: &str,
callback: &TauriExecutionCallback,
memory_prompt: Option<&str>,
) -> Result<SkillExecutionResult, String> {
if skill.execution_mode == "workflow" && !skill.workflow_steps.is_empty() {
execute_skill_workflow(
app_handle,
aster_state,
skill,
user_input,
execution_id,
session_id,
callback,
memory_prompt,
)
.await
} else {
execute_skill_prompt(
app_handle,
aster_state,
skill,
user_input,
execution_id,
session_id,
callback,
memory_prompt,
)
.await
}
}
pub use lime_agent::{SkillExecutionResult, StepResult};
+19 -1
View File
@@ -1,12 +1,30 @@
//! Skills 集成模块
//!
//! 纯逻辑已迁移到 `lime-skills` crate,
//! 本模块保留 Tauri 相关实现和兼容导出层。
//! workflow 执行主链已继续下沉到 `lime-agent`,
//! 本模块只保留 Tauri 适配与兼容导出层。
mod catalog;
mod default_skills;
mod execution;
mod execution_callback;
mod llm_provider;
mod runtime;
mod social_post;
pub use catalog::{
get_skill_detail_info, list_executable_skill_catalog, load_executable_skill_definition,
ExecutableSkillInfo, SkillDetailInfo, WorkflowStepInfo,
};
pub use execution::{
execute_named_skill, execute_skill_definition, execute_skill_prompt, execute_skill_workflow,
SkillExecutionRequest, SkillExecutionResult, StepResult,
};
pub use runtime::{
build_skill_run_finish_decision, build_skill_run_start_metadata, prepare_skill_execution,
PreparedSkillExecution, SkillProviderSelection,
};
pub use social_post::{collect_social_artifact_paths_from_output, infer_theme_workbench_gate_key};
// Tauri 实现(留在主 crate)
pub use default_skills::ensure_default_local_skills;
pub use execution_callback::TauriExecutionCallback;
+368
View File
@@ -0,0 +1,368 @@
use crate::agent::AsterAgentState;
use crate::commands::api_key_provider_cmd::ApiKeyProviderServiceState;
use crate::commands::aster_agent_cmd::{
ensure_browser_mcp_tools_registered, ensure_creation_task_tools_registered,
ensure_social_image_tool_registered,
};
use crate::commands::skill_error::{
format_skill_error, SKILL_ERR_PROVIDER_UNAVAILABLE, SKILL_ERR_SESSION_INIT_FAILED,
};
use crate::config::GlobalConfigManagerState;
use crate::database::dao::agent_run::AgentRunStatus;
use crate::database::DbConnection;
use crate::services::execution_tracker_service::RunFinishDecision;
use crate::services::memory_profile_prompt_service::{build_memory_prompt, MemoryPromptContext};
use lime_skills::LoadedSkillDefinition;
use std::path::Path;
use super::execution::SkillExecutionResult;
use super::execution_callback::TauriExecutionCallback;
use super::social_post::{
collect_social_artifact_paths_from_output, infer_theme_workbench_gate_key,
};
#[derive(Debug, Clone)]
pub struct SkillProviderSelection {
pub requested_provider: String,
pub requested_model: String,
pub resolved_provider: String,
pub resolved_model: String,
}
pub struct PreparedSkillExecution {
pub callback: TauriExecutionCallback,
pub memory_prompt: Option<String>,
pub provider_selection: SkillProviderSelection,
}
const DEFAULT_SKILL_PROVIDER: &str = "anthropic";
const DEFAULT_SKILL_MODEL: &str = "claude-sonnet-4-20250514";
const FALLBACK_TOOL_CAPABLE_PROVIDERS: &[(&str, &str)] = &[
("anthropic", "claude-sonnet-4-20250514"),
("openai", "gpt-4o"),
("gemini", "gemini-2.0-flash"),
];
const SOCIAL_POST_WITH_COVER_SKILL_NAME: &str = "social_post_with_cover";
fn build_skill_memory_prompt(
db: &DbConnection,
config_manager: &GlobalConfigManagerState,
session_id: &str,
) -> Option<String> {
let config = config_manager.config();
let session_working_dir = lime_agent::get_session_sync(db, session_id)
.ok()
.and_then(|session| session.working_dir)
.filter(|path| !path.trim().is_empty());
let context = MemoryPromptContext {
working_dir: session_working_dir.as_deref().map(Path::new),
active_relative_path: None,
};
build_memory_prompt(&config, context)
}
fn resolve_requested_provider(
skill: &LoadedSkillDefinition,
provider_override: Option<&str>,
model_override: Option<&str>,
) -> (String, String) {
let requested_provider = provider_override
.map(|value| value.to_string())
.or_else(|| skill.provider.clone())
.unwrap_or_else(|| DEFAULT_SKILL_PROVIDER.to_string());
let requested_model = model_override
.map(|value| value.to_string())
.or_else(|| skill.model.clone())
.unwrap_or_else(|| DEFAULT_SKILL_MODEL.to_string());
(requested_provider, requested_model)
}
async fn ensure_skill_agent_ready(
app_handle: &tauri::AppHandle,
db: &DbConnection,
api_key_provider_service: &ApiKeyProviderServiceState,
config_manager: &GlobalConfigManagerState,
aster_state: &AsterAgentState,
) -> Result<(), String> {
if !aster_state.is_initialized().await {
tracing::info!("[execute_skill] Agent 未初始化,开始初始化...");
aster_state.init_agent_with_db(db).await.map_err(|error| {
format_skill_error(
SKILL_ERR_SESSION_INIT_FAILED,
format!("初始化 Agent 失败: {error}"),
)
})?;
tracing::info!("[execute_skill] Agent 初始化完成");
}
ensure_browser_mcp_tools_registered(aster_state)
.await
.map_err(|error| {
format_skill_error(
SKILL_ERR_SESSION_INIT_FAILED,
format!("注册浏览器工具失败: {error}"),
)
})?;
ensure_social_image_tool_registered(aster_state, config_manager)
.await
.map_err(|error| {
format_skill_error(
SKILL_ERR_SESSION_INIT_FAILED,
format!("注册社媒生图工具失败: {error}"),
)
})?;
ensure_creation_task_tools_registered(aster_state, db, api_key_provider_service, app_handle)
.await
.map_err(|error| {
format_skill_error(
SKILL_ERR_SESSION_INIT_FAILED,
format!("注册创作任务工具失败: {error}"),
)
})?;
Ok(())
}
async fn configure_skill_provider_with_fallback(
aster_state: &AsterAgentState,
db: &DbConnection,
session_id: &str,
requested_provider: &str,
requested_model: &str,
) -> Result<SkillProviderSelection, String> {
let mut configure_result = aster_state
.configure_provider_from_pool(db, requested_provider, requested_model, session_id)
.await;
if configure_result.is_err() {
tracing::warn!(
"[execute_skill] 首选 Provider {} 配置失败: {:?},尝试 fallback",
requested_provider,
configure_result.as_ref().err()
);
for (fallback_provider, fallback_model) in FALLBACK_TOOL_CAPABLE_PROVIDERS {
if *fallback_provider == requested_provider {
continue;
}
match aster_state
.configure_provider_from_pool(db, fallback_provider, fallback_model, session_id)
.await
{
Ok(config) => {
tracing::info!(
"[execute_skill] Fallback 到 {} / {} 成功",
fallback_provider,
fallback_model
);
configure_result = Ok(config);
break;
}
Err(error) => {
tracing::warn!(
"[execute_skill] Fallback {} 也失败: {}",
fallback_provider,
error
);
}
}
}
}
let configured_provider = configure_result.map_err(|error| {
format_skill_error(
SKILL_ERR_PROVIDER_UNAVAILABLE,
format!(
"无法配置任何可用的 Provider(需要支持工具调用的 Provider,如 Anthropic、OpenAI 或 Google): {error}"
),
)
})?;
Ok(SkillProviderSelection {
requested_provider: requested_provider.to_string(),
requested_model: requested_model.to_string(),
resolved_provider: configured_provider.provider_name,
resolved_model: configured_provider.model_name,
})
}
pub async fn prepare_skill_execution(
app_handle: &tauri::AppHandle,
db: &DbConnection,
api_key_provider_service: &ApiKeyProviderServiceState,
config_manager: &GlobalConfigManagerState,
aster_state: &AsterAgentState,
skill: &LoadedSkillDefinition,
execution_id: &str,
session_id: &str,
provider_override: Option<&str>,
model_override: Option<&str>,
) -> Result<PreparedSkillExecution, String> {
ensure_skill_agent_ready(
app_handle,
db,
api_key_provider_service,
config_manager,
aster_state,
)
.await?;
let (requested_provider, requested_model) =
resolve_requested_provider(skill, provider_override, model_override);
let provider_selection = configure_skill_provider_with_fallback(
aster_state,
db,
session_id,
&requested_provider,
&requested_model,
)
.await?;
tracing::info!(
"[execute_skill] Provider 配置成功: requested={} / {}, resolved={} / {}",
provider_selection.requested_provider,
provider_selection.requested_model,
provider_selection.resolved_provider,
provider_selection.resolved_model
);
Ok(PreparedSkillExecution {
callback: TauriExecutionCallback::new(app_handle.clone(), execution_id.to_string()),
memory_prompt: build_skill_memory_prompt(db, config_manager, session_id),
provider_selection,
})
}
pub fn build_skill_run_start_metadata(
skill_name: &str,
execution_id: &str,
user_input: &str,
provider_override: Option<&str>,
model_override: Option<&str>,
) -> serde_json::Value {
serde_json::json!({
"execution_id": execution_id,
"skill_name": skill_name,
"gate_key": infer_theme_workbench_gate_key(skill_name, user_input),
"provider_override": provider_override,
"model_override": model_override,
})
}
fn build_success_metadata(
skill_name: &str,
execution_id: &str,
provider_override: Option<&str>,
model_override: Option<&str>,
provider_selection: Option<&SkillProviderSelection>,
artifact_paths: Vec<String>,
) -> serde_json::Value {
let mut metadata = serde_json::json!({
"skill_name": skill_name,
"execution_id": execution_id,
"provider_override": provider_override,
"model_override": model_override,
});
if let Some(selection) = provider_selection {
metadata["requested_provider"] = serde_json::json!(selection.requested_provider);
metadata["requested_model"] = serde_json::json!(selection.requested_model);
metadata["resolved_provider"] = serde_json::json!(selection.resolved_provider);
metadata["resolved_model"] = serde_json::json!(selection.resolved_model);
} else {
metadata["requested_provider"] = serde_json::json!(provider_override);
metadata["requested_model"] = serde_json::json!(model_override);
}
if skill_name == SOCIAL_POST_WITH_COVER_SKILL_NAME {
metadata["workflow"] = serde_json::json!("social_content_pipeline_v1");
metadata["version_id"] = serde_json::json!(execution_id);
metadata["stages"] = serde_json::json!(["topic_select", "write_mode", "publish_confirm"]);
metadata["artifact_paths"] = serde_json::json!(artifact_paths);
}
metadata
}
fn build_error_metadata(
skill_name: &str,
execution_id: &str,
provider_override: Option<&str>,
model_override: Option<&str>,
provider_selection: Option<&SkillProviderSelection>,
success: Option<bool>,
) -> serde_json::Value {
let mut metadata = serde_json::json!({
"skill_name": skill_name,
"execution_id": execution_id,
"provider_override": provider_override,
"model_override": model_override,
});
if let Some(value) = success {
metadata["success"] = serde_json::json!(value);
}
if let Some(selection) = provider_selection {
metadata["requested_provider"] = serde_json::json!(selection.requested_provider);
metadata["requested_model"] = serde_json::json!(selection.requested_model);
metadata["resolved_provider"] = serde_json::json!(selection.resolved_provider);
metadata["resolved_model"] = serde_json::json!(selection.resolved_model);
} else {
metadata["requested_provider"] = serde_json::json!(provider_override);
metadata["requested_model"] = serde_json::json!(model_override);
}
metadata
}
pub fn build_skill_run_finish_decision(
skill_name: &str,
execution_id: &str,
provider_override: Option<&str>,
model_override: Option<&str>,
provider_selection: Option<&SkillProviderSelection>,
result: &Result<SkillExecutionResult, String>,
) -> RunFinishDecision {
match result {
Ok(execution) if execution.success => RunFinishDecision {
status: AgentRunStatus::Success,
error_code: None,
error_message: None,
metadata: Some(build_success_metadata(
skill_name,
execution_id,
provider_override,
model_override,
provider_selection,
collect_social_artifact_paths_from_output(execution.output.as_deref()),
)),
},
Ok(execution) => RunFinishDecision {
status: AgentRunStatus::Error,
error_code: Some("skill_execute_failed".to_string()),
error_message: execution.error.clone(),
metadata: Some(build_error_metadata(
skill_name,
execution_id,
provider_override,
model_override,
provider_selection,
Some(false),
)),
},
Err(error) => RunFinishDecision {
status: AgentRunStatus::Error,
error_code: Some("skill_execute_failed".to_string()),
error_message: Some(error.clone()),
metadata: Some(build_error_metadata(
skill_name,
execution_id,
provider_override,
model_override,
provider_selection,
None,
)),
},
}
}
+561
View File
@@ -0,0 +1,561 @@
use crate::agent::TauriAgentEvent;
use chrono::Utc;
use lime_agent::event_converter::{TauriArtifactSnapshot, TauriToolResult};
use tauri::{AppHandle, Emitter};
const SOCIAL_POST_WITH_COVER_SKILL_NAME: &str = "social_post_with_cover";
const SOCIAL_POST_OUTPUT_DIR: &str = "social-posts";
const SOCIAL_POST_WRITE_TOOL_NAME: &str = "write_file";
const SOCIAL_POST_EMPTY_FALLBACK_CONTENT: &str = "# 社媒文案\n\n(生成结果为空,请重试。)";
const SOCIAL_POST_FALLBACK_COVER_URL: &str = "cover-generation-failed";
const SOCIAL_POST_FALLBACK_COVER_NOTE: &str = "封面图生成失败,可稍后仅重试配图。";
const SOCIAL_POST_DEFAULT_IMAGE_SIZE: &str = "1024x1024";
#[derive(Debug, Clone)]
struct SocialSkillOutputEnvelope {
final_output: String,
file_path: String,
file_content: String,
}
pub fn infer_theme_workbench_gate_key(skill_name: &str, user_input: &str) -> &'static str {
let probe = format!("{} {}", skill_name, user_input).to_lowercase();
if probe.contains("publish")
|| probe.contains("adapt")
|| probe.contains("distribution")
|| probe.contains("release")
|| probe.contains("发布")
|| probe.contains("分发")
|| probe.contains("平台适配")
{
return "publish_confirm";
}
if probe.contains("topic")
|| probe.contains("research")
|| probe.contains("trend")
|| probe.contains("idea")
|| probe.contains("选题")
|| probe.contains("方向")
|| probe.contains("调研")
|| probe.contains("洞察")
{
return "topic_select";
}
"write_mode"
}
pub fn finalize_skill_output(
app_handle: &AppHandle,
skill_name: &str,
user_input: &str,
execution_id: &str,
raw_output: &str,
) -> String {
let Some(social_output) =
normalize_social_post_output(skill_name, user_input, execution_id, raw_output)
else {
return raw_output.to_string();
};
emit_social_write_file_events(
app_handle,
execution_id,
&social_output.file_path,
&social_output.file_content,
);
for (artifact_path, artifact_content) in build_social_auxiliary_file_payloads(
execution_id,
user_input,
&social_output.file_path,
&social_output.file_content,
) {
emit_social_write_file_events(app_handle, execution_id, &artifact_path, &artifact_content);
}
social_output.final_output
}
pub fn collect_social_artifact_paths_from_output(output: Option<&str>) -> Vec<String> {
let Some(raw_output) = output else {
return Vec::new();
};
let Some((_, maybe_path, _)) = extract_first_write_file_block(raw_output) else {
return Vec::new();
};
let Some(article_path) = maybe_path else {
return Vec::new();
};
let (cover_meta_path, publish_pack_path) = derive_social_auxiliary_paths(&article_path);
vec![article_path, cover_meta_path, publish_pack_path]
}
fn normalize_social_post_output(
skill_name: &str,
user_input: &str,
execution_id: &str,
raw_output: &str,
) -> Option<SocialSkillOutputEnvelope> {
if skill_name != SOCIAL_POST_WITH_COVER_SKILL_NAME {
return None;
}
let generated_path = build_social_post_file_path(user_input, execution_id);
if let Some((range, existing_path, content)) = extract_first_write_file_block(raw_output) {
let normalized_content = normalize_social_markdown_contract(&content);
let has_existing_path = existing_path.is_some();
let path = existing_path.unwrap_or_else(|| generated_path.clone());
if has_existing_path {
if normalized_content != content {
let normalized_block = build_write_file_block(&path, &normalized_content);
let mut rebuilt = String::new();
rebuilt.push_str(&raw_output[..range.start]);
rebuilt.push_str(&normalized_block);
rebuilt.push_str(&raw_output[range.end..]);
return Some(SocialSkillOutputEnvelope {
final_output: rebuilt,
file_path: path,
file_content: normalized_content,
});
}
return Some(SocialSkillOutputEnvelope {
final_output: raw_output.to_string(),
file_path: path,
file_content: normalized_content,
});
}
let normalized_block = build_write_file_block(&path, &normalized_content);
let mut rebuilt = String::new();
rebuilt.push_str(&raw_output[..range.start]);
rebuilt.push_str(&normalized_block);
rebuilt.push_str(&raw_output[range.end..]);
return Some(SocialSkillOutputEnvelope {
final_output: rebuilt,
file_path: path,
file_content: normalized_content,
});
}
let normalized_content = normalize_social_markdown_contract(raw_output);
Some(SocialSkillOutputEnvelope {
final_output: build_write_file_block(&generated_path, &normalized_content),
file_path: generated_path,
file_content: normalized_content,
})
}
fn extract_first_write_file_block(
raw_output: &str,
) -> Option<(std::ops::Range<usize>, Option<String>, String)> {
let open_start = raw_output.find("<write_file")?;
let open_end_offset = raw_output[open_start..].find('>')?;
let open_end = open_start + open_end_offset;
let open_tag = &raw_output[open_start..=open_end];
let content_start = open_end + 1;
let close_tag = "</write_file>";
let close_offset = raw_output[content_start..].find(close_tag)?;
let close_start = content_start + close_offset;
let block_end = close_start + close_tag.len();
let content = raw_output[content_start..close_start].trim().to_string();
let path = extract_write_file_path(open_tag);
Some((open_start..block_end, path, content))
}
fn extract_write_file_path(open_tag: &str) -> Option<String> {
let path_idx = open_tag.find("path")?;
let after_path = &open_tag[path_idx + "path".len()..];
let equal_idx = after_path.find('=')?;
let value = after_path[equal_idx + 1..].trim_start();
let quote = value.chars().next()?;
if quote != '"' && quote != '\'' {
return None;
}
let rest = &value[quote.len_utf8()..];
let end_idx = rest.find(quote)?;
let path = rest[..end_idx].trim();
if path.is_empty() {
None
} else {
Some(path.to_string())
}
}
fn normalize_social_output_content(content: &str) -> String {
let trimmed = content.trim();
if trimmed.is_empty() {
SOCIAL_POST_EMPTY_FALLBACK_CONTENT.to_string()
} else {
trimmed.to_string()
}
}
fn normalize_social_markdown_contract(content: &str) -> String {
let mut normalized = normalize_social_output_content(content);
if !normalized.contains("![封面图](") {
normalized = format!("{normalized}\n\n![封面图]({SOCIAL_POST_FALLBACK_COVER_URL})");
}
normalized
}
fn extract_cover_url_from_markdown(content: &str) -> Option<String> {
for line in content.lines() {
let trimmed = line.trim();
if !trimmed.starts_with("![") {
continue;
}
let open = trimmed.find("](")?;
let close = trimmed.rfind(')')?;
if close <= open + 2 {
continue;
}
let url = trimmed[(open + 2)..close].trim();
if !url.is_empty() {
return Some(url.to_string());
}
}
None
}
fn extract_detail_value(content: &str, label: &str) -> Option<String> {
let probe = format!("- {label}:");
for line in content.lines() {
let trimmed = line.trim();
if let Some(value) = trimmed.strip_prefix(&probe) {
let value = value.trim();
if !value.is_empty() {
return Some(value.to_string());
}
}
}
None
}
fn derive_social_auxiliary_paths(article_path: &str) -> (String, String) {
let base = article_path.strip_suffix(".md").unwrap_or(article_path);
(
format!("{base}.cover.json"),
format!("{base}.publish-pack.json"),
)
}
fn summarize_social_content(content: &str) -> String {
let compact = content
.lines()
.filter(|line| !line.trim().starts_with('#'))
.collect::<Vec<_>>()
.join(" ");
let compact = compact.split_whitespace().collect::<Vec<_>>().join(" ");
compact.chars().take(180).collect()
}
fn build_social_auxiliary_file_payloads(
execution_id: &str,
user_input: &str,
article_path: &str,
article_content: &str,
) -> Vec<(String, String)> {
let (cover_meta_path, publish_pack_path) = derive_social_auxiliary_paths(article_path);
let cover_url = extract_cover_url_from_markdown(article_content)
.unwrap_or_else(|| SOCIAL_POST_FALLBACK_COVER_URL.to_string());
let cover_prompt =
extract_detail_value(article_content, "提示词").unwrap_or_else(|| "未提供".to_string());
let cover_size = extract_detail_value(article_content, "尺寸")
.unwrap_or_else(|| SOCIAL_POST_DEFAULT_IMAGE_SIZE.to_string());
let cover_status = extract_detail_value(article_content, "状态").unwrap_or_else(|| {
if cover_url == SOCIAL_POST_FALLBACK_COVER_URL {
"失败".to_string()
} else {
"成功".to_string()
}
});
let cover_remark = extract_detail_value(article_content, "备注").unwrap_or_else(|| {
if cover_status == "失败" {
SOCIAL_POST_FALLBACK_COVER_NOTE.to_string()
} else {
"".to_string()
}
});
let cover_meta = serde_json::json!({
"execution_id": execution_id,
"article_path": article_path,
"cover_url": cover_url,
"prompt": cover_prompt,
"size": cover_size,
"status": cover_status,
"remark": cover_remark,
"generated_at": Utc::now().to_rfc3339(),
});
let publish_pack = serde_json::json!({
"execution_id": execution_id,
"pipeline": ["topic_select", "write_mode", "publish_confirm"],
"article_path": article_path,
"cover_meta_path": cover_meta_path,
"source_input": user_input,
"recommended_channels": ["xiaohongshu", "wechat"],
"summary": summarize_social_content(article_content),
"generated_at": Utc::now().to_rfc3339(),
});
vec![
(
cover_meta_path,
serde_json::to_string_pretty(&cover_meta).unwrap_or_else(|_| cover_meta.to_string()),
),
(
publish_pack_path,
serde_json::to_string_pretty(&publish_pack)
.unwrap_or_else(|_| publish_pack.to_string()),
),
]
}
fn build_write_file_block(file_path: &str, file_content: &str) -> String {
format!("<write_file path=\"{file_path}\">\n{file_content}\n</write_file>")
}
fn build_social_post_file_path(user_input: &str, execution_id: &str) -> String {
let timestamp = Utc::now().format("%Y%m%d-%H%M%S");
let slug = build_social_post_slug(user_input);
let suffix = build_execution_suffix(execution_id);
format!("{SOCIAL_POST_OUTPUT_DIR}/{timestamp}-{slug}-{suffix}.md")
}
fn build_social_post_slug(user_input: &str) -> String {
let mut normalized = String::new();
let mut last_was_dash = false;
for ch in user_input.chars() {
if ch.is_ascii_alphanumeric() {
normalized.push(ch.to_ascii_lowercase());
last_was_dash = false;
continue;
}
if !last_was_dash {
normalized.push('-');
last_was_dash = true;
}
}
let trimmed = normalized.trim_matches('-');
let truncated: String = trimmed.chars().take(24).collect();
if truncated.is_empty() {
"post".to_string()
} else {
truncated
}
}
fn build_execution_suffix(execution_id: &str) -> String {
let normalized: String = execution_id
.chars()
.filter(|ch| ch.is_ascii_alphanumeric())
.take(6)
.collect();
if normalized.is_empty() {
"run".to_string()
} else {
normalized.to_ascii_lowercase()
}
}
fn build_social_tool_event_id(execution_id: &str, file_path: &str) -> String {
let mut hash: u32 = 0x811c9dc5;
for byte in file_path.as_bytes() {
hash ^= u32::from(*byte);
hash = hash.wrapping_mul(0x01000193);
}
format!("social-write-{execution_id}-{hash:08x}")
}
fn emit_social_write_file_events(
app_handle: &AppHandle,
execution_id: &str,
file_path: &str,
file_content: &str,
) {
let event_name = format!("skill-exec-{execution_id}");
let tool_id = build_social_tool_event_id(execution_id, file_path);
let artifact_id = format!("{tool_id}:artifact");
let arguments = serde_json::json!({
"path": file_path,
"content": file_content,
})
.to_string();
let preview_text = file_content.trim().chars().take(480).collect::<String>();
let latest_chunk = file_content
.trim()
.chars()
.rev()
.take(240)
.collect::<Vec<_>>()
.into_iter()
.rev()
.collect::<String>();
let mut artifact_metadata = std::collections::HashMap::from([
("complete".to_string(), serde_json::json!(true)),
("writePhase".to_string(), serde_json::json!("persisted")),
("isPartial".to_string(), serde_json::json!(false)),
(
"lastUpdateSource".to_string(),
serde_json::json!("tool_result"),
),
]);
if !preview_text.is_empty() {
artifact_metadata.insert("previewText".to_string(), serde_json::json!(preview_text));
}
if !latest_chunk.is_empty() {
artifact_metadata.insert("latestChunk".to_string(), serde_json::json!(latest_chunk));
}
let tool_start = TauriAgentEvent::ToolStart {
tool_name: SOCIAL_POST_WRITE_TOOL_NAME.to_string(),
tool_id: tool_id.clone(),
arguments: Some(arguments),
};
if let Err(err) = app_handle.emit(&event_name, &tool_start) {
tracing::warn!("[execute_skill] 发送社媒写入工具开始事件失败: {}", err);
}
let artifact_snapshot = TauriAgentEvent::ArtifactSnapshot {
artifact: TauriArtifactSnapshot {
artifact_id: artifact_id.clone(),
file_path: file_path.to_string(),
content: Some(file_content.to_string()),
metadata: Some(artifact_metadata.clone()),
},
};
if let Err(err) = app_handle.emit(&event_name, &artifact_snapshot) {
tracing::warn!("[execute_skill] 发送社媒产物快照事件失败: {}", err);
}
let mut tool_end_metadata = artifact_metadata;
tool_end_metadata.insert("artifact_streamed".to_string(), serde_json::json!(true));
tool_end_metadata.insert("artifact_id".to_string(), serde_json::json!(artifact_id));
tool_end_metadata.insert("artifact_path".to_string(), serde_json::json!(file_path));
tool_end_metadata.insert("path".to_string(), serde_json::json!(file_path));
tool_end_metadata.insert("file_path".to_string(), serde_json::json!(file_path));
let tool_end = TauriAgentEvent::ToolEnd {
tool_id,
result: TauriToolResult {
success: true,
output: format!("写入社媒文稿: {file_path}"),
error: None,
images: None,
metadata: Some(tool_end_metadata),
},
};
if let Err(err) = app_handle.emit(&event_name, &tool_end) {
tracing::warn!("[execute_skill] 发送社媒写入工具完成事件失败: {}", err);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_normalize_social_post_output_wraps_plain_markdown() {
let normalized = normalize_social_post_output(
SOCIAL_POST_WITH_COVER_SKILL_NAME,
"春季上新",
"exec123456",
"# 标题\n\n正文内容",
)
.expect("should normalize");
assert!(normalized
.final_output
.contains("<write_file path=\"social-posts/"));
assert!(normalized.final_output.contains("# 标题"));
assert!(normalized.file_content.contains("# 标题"));
assert!(normalized.file_content.contains("![封面图]("));
assert!(normalized.file_path.starts_with("social-posts/"));
assert!(normalized.file_path.ends_with(".md"));
}
#[test]
fn test_normalize_social_post_output_keeps_existing_write_file_block() {
let raw_output =
"<write_file path=\"social-posts/custom-post.md\">\n# 标题\n\n正文\n</write_file>";
let normalized = normalize_social_post_output(
SOCIAL_POST_WITH_COVER_SKILL_NAME,
"春季上新",
"exec123456",
raw_output,
)
.expect("should normalize");
assert_eq!(normalized.file_path, "social-posts/custom-post.md");
assert!(normalized
.final_output
.contains("social-posts/custom-post.md"));
assert!(normalized.file_content.contains("# 标题"));
assert!(normalized.file_content.contains("![封面图]("));
}
#[test]
fn test_normalize_social_post_output_injects_missing_path() {
let raw_output = "前置说明\n<write_file>\n# 标题\n\n正文\n</write_file>\n后置说明";
let normalized = normalize_social_post_output(
SOCIAL_POST_WITH_COVER_SKILL_NAME,
"spring launch",
"exec123456",
raw_output,
)
.expect("should normalize");
assert!(normalized.final_output.contains("前置说明"));
assert!(normalized.final_output.contains("后置说明"));
assert!(normalized
.final_output
.contains("<write_file path=\"social-posts/"));
assert!(normalized.file_content.contains("# 标题"));
assert!(normalized.file_content.contains("![封面图]("));
}
#[test]
fn test_build_social_auxiliary_file_payloads_should_include_cover_and_publish_pack() {
let payloads = build_social_auxiliary_file_payloads(
"exec123",
"新品发布",
"social-posts/demo.md",
"# 标题\n\n![封面图](https://img.example/cover.png)\n\n## 配图说明\n- 提示词:简洁科技风\n- 尺寸:1024x1024\n- 状态:成功\n- 备注:\n",
);
assert_eq!(payloads.len(), 2);
assert!(payloads
.iter()
.any(|(path, _)| path.ends_with(".cover.json")));
assert!(payloads
.iter()
.any(|(path, _)| path.ends_with(".publish-pack.json")));
}
#[test]
fn test_collect_social_artifact_paths_from_output_should_expand_auxiliary_files() {
let output = "<write_file path=\"social-posts/demo.md\">\n# 标题\n\n正文\n</write_file>";
let paths = collect_social_artifact_paths_from_output(Some(output));
assert_eq!(paths.len(), 3);
assert_eq!(paths[0], "social-posts/demo.md");
assert!(paths[1].ends_with(".cover.json"));
assert!(paths[2].ends_with(".publish-pack.json"));
}
#[test]
fn test_build_social_post_slug_fallback_to_post() {
assert_eq!(build_social_post_slug(""), "post");
assert_eq!(build_social_post_slug("!!!"), "post");
assert_eq!(
build_social_post_slug("Spring Launch 2026"),
"spring-launch-2026"
);
}
}
+68
View File
@@ -0,0 +1,68 @@
use crate::workspace::{Workspace, WorkspaceManager, WorkspaceType};
use lime_core::app_paths;
use std::path::PathBuf;
pub(crate) fn get_workspace_projects_root_dir() -> Result<PathBuf, String> {
app_paths::resolve_projects_dir()
}
pub(crate) fn resolve_default_project_path() -> Result<PathBuf, String> {
app_paths::resolve_default_project_dir()
}
pub(crate) fn sanitize_project_dir_name(name: &str) -> String {
let sanitized: String = name
.trim()
.chars()
.map(|ch| match ch {
'/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' => '_',
_ if ch.is_control() => '_',
_ => ch,
})
.collect();
let trimmed = sanitized.trim().trim_matches('.').to_string();
if trimmed.is_empty() {
"未命名项目".to_string()
} else {
trimmed
}
}
pub(crate) fn get_or_create_default_project(
manager: &WorkspaceManager,
) -> Result<Workspace, String> {
if let Some(workspace) = manager.get_default()? {
return Ok(workspace);
}
let default_project_path = resolve_default_project_path()?;
std::fs::create_dir_all(&default_project_path)
.map_err(|e| format!("创建默认项目目录失败: {e}"))?;
let workspace = manager.create_with_type(
"默认项目".to_string(),
default_project_path,
WorkspaceType::Persistent,
)?;
manager.set_default(&workspace.id)?;
manager
.get(&workspace.id)?
.ok_or_else(|| "创建默认项目失败".to_string())
}
#[cfg(test)]
mod tests {
use super::sanitize_project_dir_name;
#[test]
fn sanitize_project_dir_name_should_replace_invalid_chars() {
assert_eq!(sanitize_project_dir_name(" a/b:c*?d "), "a_b_c__d");
}
#[test]
fn sanitize_project_dir_name_should_fallback_when_empty() {
assert_eq!(sanitize_project_dir_name(" .. "), "未命名项目");
}
}