mirror of
https://github.com/aiclientproxy/proxycast.git
synced 2026-09-24 23:10:56 +08:00
release: v0.91.0
This commit is contained in:
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
//! 旧 agent integration 壳层已退出编译图。
|
||||
//!
|
||||
//! Aster runtime 的启动初始化与全局 session store 注册
|
||||
//! 已统一收口到 `lime_agent::initialize_aster_runtime`。
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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!("[启动] 托盘状态已更新");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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 调度器状态
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
/// 获取项目上下文
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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()))
|
||||
}
|
||||
|
||||
@@ -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("保持简洁"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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!(
|
||||
|
||||
@@ -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 校验
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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};
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
)),
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
normalized
|
||||
}
|
||||
|
||||
fn extract_cover_url_from_markdown(content: &str) -> Option<String> {
|
||||
for line in content.lines() {
|
||||
let trimmed = line.trim();
|
||||
if !trimmed.starts_with("?;
|
||||
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\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"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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(" .. "), "未命名项目");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user