release: v0.91.0

This commit is contained in:
coso
2026-03-19 19:05:53 +08:00
parent ad8427be93
commit a414f44872
175 changed files with 10467 additions and 11013 deletions
@@ -18,10 +18,12 @@ use aster::session::{
};
use async_trait::async_trait;
use chrono::Utc;
use lime_core::app_paths;
use lime_core::database::DbConnection;
use lime_core::workspace::WorkspaceManager;
use serde::de::DeserializeOwned;
use std::collections::HashMap;
use std::fs;
use std::path::PathBuf;
use std::path::{Path, PathBuf};
/// Lime 的 SessionStore 实现
///
@@ -36,6 +38,107 @@ impl LimeSessionStore {
Self { db }
}
pub fn load_extension_data_from_conn(
conn: &rusqlite::Connection,
session_id: &str,
) -> Result<ExtensionData> {
let extension_data_json: String = conn
.query_row(
"SELECT extension_data_json FROM agent_sessions WHERE id = ?1",
rusqlite::params![session_id],
|row| row.get(0),
)
.map_err(|e| anyhow!("读取 extension_data 失败: {e}"))?;
Ok(serde_json::from_str(&extension_data_json).unwrap_or_default())
}
pub fn load_extension_data_sync(db: &DbConnection, session_id: &str) -> Result<ExtensionData> {
let conn = db.lock().map_err(|e| anyhow!("数据库锁定失败: {e}"))?;
Self::load_extension_data_from_conn(&conn, session_id)
}
fn normalize_optional_text(value: Option<String>) -> Option<String> {
let value = value?;
let trimmed = value.trim();
if trimmed.is_empty() {
None
} else {
Some(trimmed.to_string())
}
}
fn parse_optional_json<T: DeserializeOwned>(raw: Option<String>) -> Option<T> {
raw.and_then(|text| serde_json::from_str(&text).ok())
}
fn resolve_session_type(raw: Option<String>, model: &str) -> SessionType {
let parsed_model = model.parse::<SessionType>().ok();
match raw
.as_deref()
.and_then(|value| value.parse::<SessionType>().ok())
{
Some(SessionType::User) if matches!(parsed_model, Some(parsed) if parsed != SessionType::User) => {
parsed_model.unwrap_or(SessionType::User)
}
Some(session_type) => session_type,
None => parsed_model.unwrap_or(SessionType::User),
}
}
fn default_model_name() -> String {
"agent:default".to_string()
}
fn insert_session_row(
conn: &rusqlite::Connection,
id: &str,
title: &str,
working_dir: &Path,
session_type: SessionType,
) -> Result<()> {
let now = Utc::now().to_rfc3339();
conn.execute(
"INSERT INTO agent_sessions (
id, model, system_prompt, title, created_at, updated_at, working_dir,
execution_strategy, session_type, user_set_name, extension_data_json
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)",
rusqlite::params![
id,
Self::default_model_name(),
None::<String>,
title,
now,
now,
working_dir.to_string_lossy().to_string(),
"react",
session_type.to_string(),
false,
serde_json::to_string(&ExtensionData::default())
.map_err(|e| anyhow!("序列化 extension_data 失败: {e}"))?,
],
)
.map_err(|e| anyhow!("创建会话失败: {e}"))?;
Ok(())
}
fn ensure_session_row(conn: &rusqlite::Connection, session_id: &str) -> Result<()> {
let session_exists: bool = conn
.query_row(
"SELECT 1 FROM agent_sessions WHERE id = ?",
[session_id],
|_| Ok(true),
)
.unwrap_or(false);
if session_exists {
return Ok(());
}
let working_dir = Self::resolve_session_working_dir(conn);
Self::insert_session_row(conn, session_id, "新对话", &working_dir, SessionType::User)
}
/// 将 Message 的 role 转换为字符串
/// 通过检查 Message::user() 和 Message::assistant() 的 role 来判断
fn message_role_to_string(message: &Message) -> String {
@@ -50,38 +153,23 @@ impl LimeSessionStore {
/// 解析会话 working_dir(优先默认 workspace,其次应用默认项目目录)
fn resolve_session_working_dir(conn: &rusqlite::Connection) -> PathBuf {
// 1) 优先使用默认 workspace(is_default = 1)
let default_workspace_path: Option<String> = conn
.query_row(
"SELECT root_path FROM workspaces WHERE is_default = 1 LIMIT 1",
[],
|row| row.get(0),
)
.ok();
if let Some(path) = default_workspace_path {
if !path.trim().is_empty() {
let pb = PathBuf::from(path);
return if pb.is_absolute() {
pb
} else {
std::env::current_dir()
.unwrap_or_else(|_| PathBuf::from("."))
.join(pb)
};
if let Some(path) = WorkspaceManager::get_default_root_path_from_conn(conn)
.ok()
.flatten()
{
let normalized = Self::normalize_working_dir(path);
if !normalized.as_os_str().is_empty() {
return normalized;
}
}
// 2) 回退到 ~/.lime/projects/default
if let Some(home) = dirs::home_dir() {
let fallback = home.join(".lime").join("projects").join("default");
if !fallback.exists() {
let _ = fs::create_dir_all(&fallback);
}
return fallback;
if let Ok(default_project_dir) = app_paths::resolve_default_project_dir() {
return default_project_dir;
}
// 3) 最终回退到进程当前目录
tracing::warn!(
"[SessionStore] 解析默认 working_dir 失败,已回退当前目录;建议检查 app_paths 配置"
);
std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))
}
@@ -120,26 +208,8 @@ impl SessionStore for LimeSessionStore {
) -> Result<Session> {
let id = uuid::Uuid::new_v4().to_string();
let now = Utc::now();
let now_str = now.to_rfc3339();
let conn = self.db.lock().map_err(|e| anyhow!("数据库锁定失败: {e}"))?;
let type_str = session_type.to_string();
conn.execute(
"INSERT INTO agent_sessions (id, model, system_prompt, title, created_at, updated_at, working_dir)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
rusqlite::params![
id,
type_str,
None::<String>,
name,
now_str,
now_str,
working_dir.to_string_lossy().to_string()
],
)
.map_err(|e| anyhow!("创建会话失败: {e}"))?;
Self::insert_session_row(&conn, &id, &name, &working_dir, session_type)?;
Ok(Session {
id,
@@ -174,40 +244,17 @@ impl SessionStore for LimeSessionStore {
);
let conn = self.db.lock().map_err(|e| anyhow!("数据库锁定失败: {e}"))?;
// 检查 session 是否存在
let session_exists: bool = conn
.query_row("SELECT 1 FROM agent_sessions WHERE id = ?", [id], |_| {
Ok(true)
})
.unwrap_or(false);
tracing::info!("[SessionStore] session_exists={}", session_exists);
// 如果不存在,自动创建
if !session_exists {
let now = Utc::now().to_rfc3339();
let working_dir = Self::resolve_session_working_dir(&conn);
conn.execute(
"INSERT INTO agent_sessions (id, model, system_prompt, title, created_at, updated_at, working_dir)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
rusqlite::params![
id,
"agent:default",
None::<String>,
"新对话",
now,
now,
working_dir.to_string_lossy().to_string()
],
)
.map_err(|e| anyhow!("自动创建会话失败: {e}"))?;
tracing::info!("[SessionStore] get_session 自动创建会话: {}", id);
}
Self::ensure_session_row(&conn, id)?;
tracing::info!("[SessionStore] get_session 已确保会话存在: {}", id);
let mut stmt = conn
.prepare(
"SELECT id, model, system_prompt, title, created_at, updated_at, working_dir
"SELECT id, model, system_prompt, title, created_at, updated_at, working_dir,
session_type, user_set_name, extension_data_json,
total_tokens, input_tokens, output_tokens,
accumulated_total_tokens, accumulated_input_tokens, accumulated_output_tokens,
schedule_id, recipe_json, user_recipe_values_json,
provider_name, model_config_json
FROM agent_sessions WHERE id = ?",
)
.map_err(|e| anyhow!("准备查询失败: {e}"))?;
@@ -222,12 +269,47 @@ impl SessionStore for LimeSessionStore {
row.get::<_, String>(4)?,
row.get::<_, String>(5)?,
row.get::<_, Option<String>>(6)?,
row.get::<_, Option<String>>(7)?,
row.get::<_, bool>(8)?,
row.get::<_, String>(9)?,
row.get::<_, Option<i32>>(10)?,
row.get::<_, Option<i32>>(11)?,
row.get::<_, Option<i32>>(12)?,
row.get::<_, Option<i32>>(13)?,
row.get::<_, Option<i32>>(14)?,
row.get::<_, Option<i32>>(15)?,
row.get::<_, Option<String>>(16)?,
row.get::<_, Option<String>>(17)?,
row.get::<_, Option<String>>(18)?,
row.get::<_, Option<String>>(19)?,
row.get::<_, Option<String>>(20)?,
))
})
.map_err(|e| anyhow!("会话不存在: {e}"))?;
let (id, model, _system_prompt, title, created_at, updated_at, db_working_dir) =
session_row;
let (
id,
model,
_system_prompt,
title,
created_at,
updated_at,
db_working_dir,
session_type_raw,
user_set_name,
extension_data_json,
total_tokens,
input_tokens,
output_tokens,
accumulated_total_tokens,
accumulated_input_tokens,
accumulated_output_tokens,
schedule_id,
recipe_json,
user_recipe_values_json,
provider_name,
model_config_json,
) = session_row;
let created_at = chrono::DateTime::parse_from_rfc3339(&created_at)
.map(|dt| dt.with_timezone(&Utc))
@@ -236,7 +318,7 @@ impl SessionStore for LimeSessionStore {
.map(|dt| dt.with_timezone(&Utc))
.unwrap_or_else(|_| Utc::now());
let session_type = model.parse().unwrap_or(SessionType::User);
let session_type = Self::resolve_session_type(session_type_raw, &model);
let working_dir = Self::parse_session_working_dir(&conn, db_working_dir);
let conversation = if include_messages {
@@ -251,27 +333,29 @@ impl SessionStore for LimeSessionStore {
id: id.to_string(),
working_dir,
name: title.unwrap_or_else(|| "未命名会话".to_string()),
user_set_name: false,
user_set_name,
session_type,
created_at,
updated_at,
extension_data: ExtensionData::default(),
total_tokens: None,
input_tokens: None,
output_tokens: None,
accumulated_total_tokens: None,
accumulated_input_tokens: None,
accumulated_output_tokens: None,
schedule_id: None,
recipe: None,
user_recipe_values: None,
extension_data: serde_json::from_str(&extension_data_json).unwrap_or_default(),
total_tokens,
input_tokens,
output_tokens,
accumulated_total_tokens,
accumulated_input_tokens,
accumulated_output_tokens,
schedule_id,
recipe: Self::parse_optional_json(recipe_json),
user_recipe_values: Self::parse_optional_json(user_recipe_values_json),
conversation,
message_count,
provider_name: None,
model_config: match model.trim() {
"" | "agent:default" => None,
normalized => ModelConfig::new(normalized).ok(),
},
provider_name,
model_config: Self::parse_optional_json(model_config_json).or_else(|| {
match model.trim() {
"" | "agent:default" => None,
normalized => ModelConfig::new(normalized).ok(),
}
}),
})
}
@@ -282,35 +366,7 @@ impl SessionStore for LimeSessionStore {
);
let conn = self.db.lock().map_err(|e| anyhow!("数据库锁定失败: {e}"))?;
// 检查会话是否存在,如果不存在则自动创建
let session_exists: bool = conn
.query_row(
"SELECT 1 FROM agent_sessions WHERE id = ?",
[session_id],
|_| Ok(true),
)
.unwrap_or(false);
if !session_exists {
let now = Utc::now().to_rfc3339();
let working_dir = Self::resolve_session_working_dir(&conn);
conn.execute(
"INSERT INTO agent_sessions (id, model, system_prompt, title, created_at, updated_at, working_dir)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
rusqlite::params![
session_id,
"agent:default",
None::<String>,
"新对话",
now,
now,
working_dir.to_string_lossy().to_string()
],
)
.map_err(|e| anyhow!("自动创建会话失败: {e}"))?;
tracing::info!("[SessionStore] 自动创建会话: {}", session_id);
}
Self::ensure_session_row(&conn, session_id)?;
let role = Self::message_role_to_string(message);
let content_json = serde_json::to_string(&message.content)
@@ -425,7 +481,12 @@ impl SessionStore for LimeSessionStore {
let conn = self.db.lock().map_err(|e| anyhow!("数据库锁定失败: {e}"))?;
let mut stmt = conn.prepare(
"SELECT id, model, system_prompt, title, created_at, updated_at, working_dir
"SELECT id, model, system_prompt, title, created_at, updated_at, working_dir,
session_type, user_set_name, extension_data_json,
total_tokens, input_tokens, output_tokens,
accumulated_total_tokens, accumulated_input_tokens, accumulated_output_tokens,
schedule_id, recipe_json, user_recipe_values_json,
provider_name, model_config_json
FROM agent_sessions ORDER BY updated_at DESC",
)?;
@@ -437,43 +498,106 @@ impl SessionStore for LimeSessionStore {
let created_at: String = row.get(4)?;
let updated_at: String = row.get(5)?;
let working_dir: Option<String> = row.get(6)?;
let session_type: Option<String> = row.get(7)?;
let user_set_name: bool = row.get(8)?;
let extension_data_json: String = row.get(9)?;
let total_tokens: Option<i32> = row.get(10)?;
let input_tokens: Option<i32> = row.get(11)?;
let output_tokens: Option<i32> = row.get(12)?;
let accumulated_total_tokens: Option<i32> = row.get(13)?;
let accumulated_input_tokens: Option<i32> = row.get(14)?;
let accumulated_output_tokens: Option<i32> = row.get(15)?;
let schedule_id: Option<String> = row.get(16)?;
let recipe_json: Option<String> = row.get(17)?;
let user_recipe_values_json: Option<String> = row.get(18)?;
let provider_name: Option<String> = row.get(19)?;
let model_config_json: Option<String> = row.get(20)?;
Ok((id, model, title, created_at, updated_at, working_dir))
Ok((
id,
model,
title,
created_at,
updated_at,
working_dir,
session_type,
user_set_name,
extension_data_json,
total_tokens,
input_tokens,
output_tokens,
accumulated_total_tokens,
accumulated_input_tokens,
accumulated_output_tokens,
schedule_id,
recipe_json,
user_recipe_values_json,
provider_name,
model_config_json,
))
})?
.filter_map(|r| r.ok())
.map(
|(id, model, title, created_at, updated_at, db_working_dir)| {
|(
id,
model,
title,
created_at,
updated_at,
db_working_dir,
session_type_raw,
user_set_name,
extension_data_json,
total_tokens,
input_tokens,
output_tokens,
accumulated_total_tokens,
accumulated_input_tokens,
accumulated_output_tokens,
schedule_id,
recipe_json,
user_recipe_values_json,
provider_name,
model_config_json,
)| {
let created_at = chrono::DateTime::parse_from_rfc3339(&created_at)
.map(|dt| dt.with_timezone(&Utc))
.unwrap_or_else(|_| Utc::now());
let updated_at = chrono::DateTime::parse_from_rfc3339(&updated_at)
.map(|dt| dt.with_timezone(&Utc))
.unwrap_or_else(|_| Utc::now());
let session_type = model.parse().unwrap_or(SessionType::User);
let session_type = Self::resolve_session_type(session_type_raw, &model);
let working_dir = Self::parse_session_working_dir(&conn, db_working_dir);
let message_count = self.count_messages(&conn, &id).unwrap_or(0);
Session {
id,
working_dir,
name: title.unwrap_or_else(|| "未命名会话".to_string()),
user_set_name: false,
user_set_name,
session_type,
created_at,
updated_at,
extension_data: ExtensionData::default(),
total_tokens: None,
input_tokens: None,
output_tokens: None,
accumulated_total_tokens: None,
accumulated_input_tokens: None,
accumulated_output_tokens: None,
schedule_id: None,
recipe: None,
user_recipe_values: None,
extension_data: serde_json::from_str(&extension_data_json)
.unwrap_or_default(),
total_tokens,
input_tokens,
output_tokens,
accumulated_total_tokens,
accumulated_input_tokens,
accumulated_output_tokens,
schedule_id,
recipe: Self::parse_optional_json(recipe_json),
user_recipe_values: Self::parse_optional_json(user_recipe_values_json),
conversation: None,
message_count: 0,
provider_name: None,
model_config: None,
message_count,
provider_name,
model_config: Self::parse_optional_json(model_config_json).or_else(|| {
match model.trim() {
"" | "agent:default" => None,
normalized => ModelConfig::new(normalized).ok(),
}
}),
}
},
)
@@ -504,10 +628,16 @@ impl SessionStore for LimeSessionStore {
let total_sessions: i64 =
conn.query_row("SELECT COUNT(*) FROM agent_sessions", [], |row| row.get(0))?;
let total_tokens: i64 = conn.query_row(
"SELECT COALESCE(SUM(COALESCE(accumulated_total_tokens, total_tokens, 0)), 0)
FROM agent_sessions",
[],
|row| row.get(0),
)?;
Ok(SessionInsights {
total_sessions: total_sessions as usize,
total_tokens: 0,
total_tokens,
})
}
@@ -528,6 +658,36 @@ impl SessionStore for LimeSessionStore {
)
.await?;
self.update_session_name(&new_session.id, session.name.clone(), session.user_set_name)
.await?;
self.update_extension_data(&new_session.id, session.extension_data.clone())
.await?;
self.update_token_stats(
&new_session.id,
TokenStatsUpdate {
schedule_id: session.schedule_id.clone(),
total_tokens: session.total_tokens,
input_tokens: session.input_tokens,
output_tokens: session.output_tokens,
accumulated_total: session.accumulated_total_tokens,
accumulated_input: session.accumulated_input_tokens,
accumulated_output: session.accumulated_output_tokens,
},
)
.await?;
self.update_provider_config(
&new_session.id,
session.provider_name.clone(),
session.model_config.clone(),
)
.await?;
self.update_recipe(
&new_session.id,
session.recipe.clone(),
session.user_recipe_values.clone(),
)
.await?;
if let Some(conversation) = &session.conversation {
self.replace_conversation(&new_session.id, conversation)
.await?;
@@ -538,15 +698,47 @@ impl SessionStore for LimeSessionStore {
async fn copy_session(&self, session_id: &str, new_name: String) -> Result<Session> {
let original = self.get_session(session_id, true).await?;
let created_session_name = new_name.clone();
let persisted_session_name = new_name.clone();
let new_session = self
.create_session(
original.working_dir.clone(),
new_name,
created_session_name,
original.session_type,
)
.await?;
self.update_session_name(&new_session.id, persisted_session_name, true)
.await?;
self.update_extension_data(&new_session.id, original.extension_data.clone())
.await?;
self.update_token_stats(
&new_session.id,
TokenStatsUpdate {
schedule_id: original.schedule_id.clone(),
total_tokens: original.total_tokens,
input_tokens: original.input_tokens,
output_tokens: original.output_tokens,
accumulated_total: original.accumulated_total_tokens,
accumulated_input: original.accumulated_input_tokens,
accumulated_output: original.accumulated_output_tokens,
},
)
.await?;
self.update_provider_config(
&new_session.id,
original.provider_name.clone(),
original.model_config.clone(),
)
.await?;
self.update_recipe(
&new_session.id,
original.recipe.clone(),
original.user_recipe_values.clone(),
)
.await?;
if let Some(conversation) = &original.conversation {
self.replace_conversation(&new_session.id, conversation)
.await?;
@@ -573,25 +765,59 @@ impl SessionStore for LimeSessionStore {
&self,
session_id: &str,
name: String,
_user_set: bool,
user_set: bool,
) -> Result<()> {
let conn = self.db.lock().map_err(|e| anyhow!("数据库锁定失败: {e}"))?;
let now = Utc::now().to_rfc3339();
conn.execute(
"UPDATE agent_sessions SET title = ? WHERE id = ?",
rusqlite::params![name, session_id],
"UPDATE agent_sessions SET title = ?1, user_set_name = ?2, updated_at = ?3 WHERE id = ?4",
rusqlite::params![name, user_set, now, session_id],
)?;
Ok(())
}
async fn update_extension_data(
&self,
_session_id: &str,
_extension_data: ExtensionData,
session_id: &str,
extension_data: ExtensionData,
) -> Result<()> {
let conn = self.db.lock().map_err(|e| anyhow!("数据库锁定失败: {e}"))?;
let now = Utc::now().to_rfc3339();
let extension_data_json = serde_json::to_string(&extension_data)
.map_err(|e| anyhow!("序列化 extension_data 失败: {e}"))?;
conn.execute(
"UPDATE agent_sessions SET extension_data_json = ?1, updated_at = ?2 WHERE id = ?3",
rusqlite::params![extension_data_json, now, session_id],
)?;
Ok(())
}
async fn update_token_stats(&self, _session_id: &str, _stats: TokenStatsUpdate) -> Result<()> {
async fn update_token_stats(&self, session_id: &str, stats: TokenStatsUpdate) -> Result<()> {
let conn = self.db.lock().map_err(|e| anyhow!("数据库锁定失败: {e}"))?;
let now = Utc::now().to_rfc3339();
conn.execute(
"UPDATE agent_sessions SET
total_tokens = COALESCE(?1, total_tokens),
input_tokens = COALESCE(?2, input_tokens),
output_tokens = COALESCE(?3, output_tokens),
accumulated_total_tokens = COALESCE(?4, accumulated_total_tokens),
accumulated_input_tokens = COALESCE(?5, accumulated_input_tokens),
accumulated_output_tokens = COALESCE(?6, accumulated_output_tokens),
schedule_id = COALESCE(?7, schedule_id),
updated_at = ?8
WHERE id = ?9",
rusqlite::params![
stats.total_tokens,
stats.input_tokens,
stats.output_tokens,
stats.accumulated_total,
stats.accumulated_input,
stats.accumulated_output,
Self::normalize_optional_text(stats.schedule_id),
now,
session_id,
],
)?;
Ok(())
}
@@ -601,27 +827,67 @@ impl SessionStore for LimeSessionStore {
provider_name: Option<String>,
model_config: Option<ModelConfig>,
) -> Result<()> {
if let Some(model_name) = model_config
let normalized_provider_name = Self::normalize_optional_text(provider_name);
let normalized_model_name = model_config
.as_ref()
.map(|config| config.model_name.trim().to_string())
.filter(|value| !value.is_empty())
.or(provider_name.filter(|value| !value.trim().is_empty()))
{
let conn = self.db.lock().map_err(|e| anyhow!("数据库锁定失败: {e}"))?;
conn.execute(
"UPDATE agent_sessions SET model = ? WHERE id = ?",
rusqlite::params![model_name, session_id],
)?;
.filter(|value| !value.is_empty());
let model_config_json = model_config
.as_ref()
.map(serde_json::to_string)
.transpose()
.map_err(|e| anyhow!("序列化 model_config 失败: {e}"))?;
if normalized_provider_name.is_none() && normalized_model_name.is_none() {
return Ok(());
}
let conn = self.db.lock().map_err(|e| anyhow!("数据库锁定失败: {e}"))?;
let now = Utc::now().to_rfc3339();
conn.execute(
"UPDATE agent_sessions SET
provider_name = COALESCE(?1, provider_name),
model = COALESCE(?2, model),
model_config_json = CASE WHEN ?3 IS NULL THEN model_config_json ELSE ?3 END,
updated_at = ?4
WHERE id = ?5",
rusqlite::params![
normalized_provider_name,
normalized_model_name,
model_config_json,
now,
session_id,
],
)?;
Ok(())
}
async fn update_recipe(
&self,
_session_id: &str,
_recipe: Option<Recipe>,
_user_recipe_values: Option<HashMap<String, String>>,
session_id: &str,
recipe: Option<Recipe>,
user_recipe_values: Option<HashMap<String, String>>,
) -> Result<()> {
let conn = self.db.lock().map_err(|e| anyhow!("数据库锁定失败: {e}"))?;
let now = Utc::now().to_rfc3339();
let recipe_json = recipe
.as_ref()
.map(serde_json::to_string)
.transpose()
.map_err(|e| anyhow!("序列化 recipe 失败: {e}"))?;
let user_recipe_values_json = user_recipe_values
.as_ref()
.map(serde_json::to_string)
.transpose()
.map_err(|e| anyhow!("序列化 user_recipe_values 失败: {e}"))?;
conn.execute(
"UPDATE agent_sessions SET
recipe_json = ?1,
user_recipe_values_json = ?2,
updated_at = ?3
WHERE id = ?4",
rusqlite::params![recipe_json, user_recipe_values_json, now, session_id],
)?;
Ok(())
}
@@ -788,7 +1054,41 @@ mod tests {
use aster::session::{SessionStore, SessionType};
use lime_core::database::schema::create_tables;
use rusqlite::Connection;
use std::ffi::OsString;
use std::sync::{Arc, Mutex};
use tempfile::tempdir;
fn env_lock() -> &'static Mutex<()> {
static LOCK: std::sync::OnceLock<Mutex<()>> = std::sync::OnceLock::new();
LOCK.get_or_init(|| Mutex::new(()))
}
struct EnvGuard {
values: Vec<(&'static str, Option<OsString>)>,
}
impl EnvGuard {
fn set(entries: &[(&'static str, OsString)]) -> Self {
let mut values = Vec::new();
for (key, value) in entries {
values.push((*key, std::env::var_os(key)));
std::env::set_var(key, value);
}
Self { values }
}
}
impl Drop for EnvGuard {
fn drop(&mut self) {
for (key, previous) in self.values.drain(..) {
if let Some(value) = previous {
std::env::set_var(key, value);
} else {
std::env::remove_var(key);
}
}
}
}
fn setup_test_store() -> LimeSessionStore {
let conn = Connection::open_in_memory().expect("创建内存数据库失败");
@@ -828,4 +1128,175 @@ mod tests {
assert_eq!(persisted_model, "gpt-4.1");
}
#[tokio::test]
async fn get_session_should_prefer_default_workspace_root_when_missing_row() {
let store = setup_test_store();
let workspace_root = std::env::temp_dir().join("lime-aster-default-workspace");
let conn = store.db.lock().expect("锁数据库");
conn.execute(
"INSERT INTO workspaces (id, name, workspace_type, root_path, is_default, settings_json, created_at, updated_at)
VALUES (?1, ?2, ?3, ?4, 1, '{}', 0, 0)",
rusqlite::params![
"workspace-default",
"默认工作区",
"general",
workspace_root.to_string_lossy().to_string(),
],
)
.expect("插入默认 workspace 失败");
drop(conn);
let session = store
.get_session("missing-default-workspace-session", false)
.await
.expect("读取缺失会话失败");
assert_eq!(session.working_dir, workspace_root);
}
#[tokio::test]
async fn get_session_should_fallback_to_app_paths_default_project_dir() {
let _env_guard = env_lock().lock().expect("锁环境变量");
let temp = tempdir().expect("创建临时目录失败");
let home = temp.path().join("home");
let app_data = temp.path().join("appdata");
std::fs::create_dir_all(&home).expect("创建 home 目录失败");
std::fs::create_dir_all(&app_data).expect("创建 appdata 目录失败");
let _guard = EnvGuard::set(&[
("HOME", home.as_os_str().to_os_string()),
("XDG_DATA_HOME", app_data.as_os_str().to_os_string()),
("APPDATA", app_data.as_os_str().to_os_string()),
("LOCALAPPDATA", app_data.as_os_str().to_os_string()),
]);
let store = setup_test_store();
let session = store
.get_session("missing-fallback-session", false)
.await
.expect("读取缺失会话失败");
let expected = app_paths::resolve_default_project_dir().expect("解析默认项目目录失败");
assert_eq!(session.working_dir, expected);
assert!(session.working_dir.is_absolute());
assert!(session
.working_dir
.ends_with(PathBuf::from("projects").join("default")));
assert!(!session
.working_dir
.to_string_lossy()
.contains(".lime/projects/default"));
}
#[tokio::test]
async fn update_session_metadata_should_roundtrip() {
let store = setup_test_store();
let session = store
.create_session(
PathBuf::from("."),
"元数据测试".to_string(),
SessionType::SubAgent,
)
.await
.expect("创建会话失败");
let mut extension_data = ExtensionData::new();
extension_data.set_extension_state("todo", "v0", serde_json::json!({"items":["a"]}));
store
.update_session_name(&session.id, "已命名会话".to_string(), true)
.await
.expect("更新名称失败");
store
.update_extension_data(&session.id, extension_data.clone())
.await
.expect("更新 extension_data 失败");
store
.update_token_stats(
&session.id,
TokenStatsUpdate {
schedule_id: Some("job-1".to_string()),
total_tokens: Some(100),
input_tokens: Some(60),
output_tokens: Some(40),
accumulated_total: Some(300),
accumulated_input: Some(180),
accumulated_output: Some(120),
},
)
.await
.expect("更新 token 统计失败");
store
.update_provider_config(
&session.id,
Some("openai".to_string()),
Some(ModelConfig::new("gpt-4.1").expect("model config")),
)
.await
.expect("更新 provider 配置失败");
store
.update_recipe(
&session.id,
Some(Recipe {
version: "1.0.0".to_string(),
title: "demo".to_string(),
description: "demo recipe".to_string(),
instructions: None,
prompt: None,
extensions: None,
settings: None,
activities: None,
author: None,
parameters: None,
response: None,
sub_recipes: None,
retry: None,
}),
Some(HashMap::from([(
"temperature".to_string(),
"0.2".to_string(),
)])),
)
.await
.expect("更新 recipe 失败");
let loaded = store
.get_session(&session.id, false)
.await
.expect("读取会话失败");
assert_eq!(loaded.name, "已命名会话");
assert!(loaded.user_set_name);
assert_eq!(loaded.session_type, SessionType::SubAgent);
assert_eq!(loaded.total_tokens, Some(100));
assert_eq!(loaded.accumulated_total_tokens, Some(300));
assert_eq!(loaded.schedule_id.as_deref(), Some("job-1"));
assert_eq!(loaded.provider_name.as_deref(), Some("openai"));
assert_eq!(
loaded
.model_config
.as_ref()
.map(|config| config.model_name.as_str()),
Some("gpt-4.1")
);
assert_eq!(
loaded
.extension_data
.get_extension_state("todo", "v0")
.cloned(),
extension_data.get_extension_state("todo", "v0").cloned()
);
assert_eq!(
loaded.recipe.as_ref().map(|recipe| recipe.title.as_str()),
Some("demo")
);
assert_eq!(
loaded
.user_recipe_values
.as_ref()
.and_then(|values| values.get("temperature"))
.map(String::as_str),
Some("0.2")
);
}
}
-2
View File
@@ -43,7 +43,6 @@
//! - `session_context_service` - 会话上下文服务
//! - `ai_summary_service` - AI 摘要服务
//! - `project_context_builder` - 项目上下文构建器
//! - `tool_hooks_service` - 工具钩子服务
//! - `kiro_event_service` - Kiro 事件服务
//! - `api_key_provider_service` - API Key Provider 服务
//! - `provider_pool_service` - Provider 池服务
@@ -90,7 +89,6 @@ pub mod content_creator;
pub mod ai_summary_service;
pub mod project_context_builder;
pub mod session_context_service;
pub mod tool_hooks_service;
// 事件服务
pub mod kiro_event_service;
@@ -4,7 +4,6 @@
use crate::ai_summary_service::AISummaryService;
use lime_core::database::dao::chat::{ChatDao, ChatMessage as UnifiedChatMessage, ChatMode};
use lime_core::database::load_pending_general_session_messages;
use lime_core::general_chat::{ChatMessage, MessageRole};
use rusqlite::Connection;
use serde::{Deserialize, Serialize};
@@ -184,52 +183,27 @@ impl SessionContextService {
let session =
ChatDao::get_session(conn, session_id).map_err(|e| format!("获取统一会话失败: {e}"))?;
if let Some(session) = session {
if session.mode != ChatMode::General {
debug!(
"会话 {} 不是 general 模式,跳过上下文加载: {:?}",
session_id, session.mode
);
return Ok(vec![]);
}
let Some(session) = session else {
debug!("会话 {} 未命中 unified chat,跳过上下文加载", session_id);
return Ok(vec![]);
};
let unified_messages = ChatDao::get_messages(conn, session_id, None)
.map_err(|e| format!("获取统一消息失败: {e}"))?;
return Ok(unified_messages
.into_iter()
.map(Self::convert_unified_message)
.collect());
if session.mode != ChatMode::General {
debug!(
"会话 {} 不是 general 模式,跳过上下文加载: {:?}",
session_id, session.mode
);
return Ok(vec![]);
}
debug!(
"会话 {} 未命中 unified chat,回退待迁移 general 历史表",
session_id
);
Self::load_pending_general_messages(conn, session_id)
}
fn load_pending_general_messages(
conn: &Connection,
session_id: &str,
) -> Result<Vec<ChatMessage>, String> {
let messages = load_pending_general_session_messages(conn, session_id)
.map_err(|e| format!("查询待迁移 general 消息失败: {e}"))?;
Ok(messages
.into_iter()
.map(|message| ChatMessage {
id: message.id,
session_id: message.session_id,
role: Self::convert_legacy_role(&message.role),
content: message.content,
blocks: None,
status: "complete".to_string(),
created_at: message.created_at,
metadata: None,
ChatDao::get_messages(conn, session_id, None)
.map_err(|e| format!("获取统一消息失败: {e}"))
.map(|messages| {
messages
.into_iter()
.map(Self::convert_unified_message)
.collect()
})
.collect())
}
fn convert_unified_message(message: UnifiedChatMessage) -> ChatMessage {
@@ -253,14 +227,6 @@ impl SessionContextService {
}
}
fn convert_legacy_role(role: &str) -> MessageRole {
match role {
"assistant" => MessageRole::Assistant,
"system" => MessageRole::System,
_ => MessageRole::User,
}
}
fn extract_unified_text_content(content: &serde_json::Value) -> String {
match content {
serde_json::Value::Null => String::new(),
@@ -604,7 +570,6 @@ mod tests {
use lime_core::database::dao::chat::{
ChatDao, ChatMessage as UnifiedChatMessage, ChatMode, ChatSession as UnifiedChatSession,
};
use lime_core::general_chat::ChatSession;
use rusqlite::Connection;
fn setup_test_db() -> Connection {
@@ -649,36 +614,6 @@ mod tests {
)
.unwrap();
// 创建会话表
conn.execute(
"CREATE TABLE general_chat_sessions (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
metadata TEXT
)",
[],
)
.unwrap();
// 创建消息表
conn.execute(
"CREATE TABLE general_chat_messages (
id TEXT PRIMARY KEY,
session_id TEXT NOT NULL,
role TEXT NOT NULL CHECK (role IN ('user', 'assistant', 'system')),
content TEXT NOT NULL,
blocks TEXT,
status TEXT NOT NULL DEFAULT 'complete',
created_at INTEGER NOT NULL,
metadata TEXT,
FOREIGN KEY (session_id) REFERENCES general_chat_sessions(id) ON DELETE CASCADE
)",
[],
)
.unwrap();
conn.execute("PRAGMA foreign_keys = ON", []).unwrap();
conn
}
@@ -749,45 +684,6 @@ mod tests {
.collect()
}
fn insert_legacy_session(conn: &Connection, session: &ChatSession) {
conn.execute(
"INSERT INTO general_chat_sessions (id, name, created_at, updated_at, metadata)
VALUES (?1, ?2, ?3, ?4, ?5)",
rusqlite::params![
session.id,
session.name,
session.created_at,
session.updated_at,
Option::<String>::None,
],
)
.unwrap();
}
fn insert_legacy_message(conn: &Connection, message: &ChatMessage) {
let role = match message.role {
MessageRole::User => "user",
MessageRole::Assistant => "assistant",
MessageRole::System => "system",
};
conn.execute(
"INSERT INTO general_chat_messages (id, session_id, role, content, blocks, status, created_at, metadata)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
rusqlite::params![
message.id,
message.session_id,
role,
message.content,
Option::<String>::None,
message.status,
message.created_at,
Option::<String>::None,
],
)
.unwrap();
}
#[tokio::test]
async fn test_context_service_creation() {
let conn = Arc::new(Mutex::new(setup_test_db()));
@@ -880,47 +776,22 @@ mod tests {
}
#[tokio::test]
async fn test_get_effective_context_falls_back_to_legacy_messages() {
async fn test_get_effective_context_returns_empty_when_unified_session_missing() {
let conn = Arc::new(Mutex::new(setup_test_db()));
let service = SessionContextService::new(conn.clone(), ContextWindowConfig::default());
let legacy_session = ChatSession {
id: "legacy-session".to_string(),
name: "旧会话".to_string(),
created_at: chrono::Utc::now().timestamp_millis(),
updated_at: chrono::Utc::now().timestamp_millis(),
metadata: None,
};
{
let conn_guard = conn.lock().unwrap();
insert_legacy_session(&conn_guard, &legacy_session);
for message in create_test_messages("legacy-session", 3) {
insert_legacy_message(&conn_guard, &message);
}
}
let context = service
.get_effective_context("legacy-session")
.await
.unwrap();
assert_eq!(context.len(), 3);
assert_eq!(context[0].content, "这是第 1 条消息,包含一些测试内容");
assert!(context.is_empty());
}
#[tokio::test]
async fn test_get_effective_context_skips_legacy_fallback_after_general_migration_completed() {
async fn test_get_effective_context_keeps_empty_when_general_migration_completed() {
let conn = Arc::new(Mutex::new(setup_test_db()));
let service = SessionContextService::new(conn.clone(), ContextWindowConfig::default());
let legacy_session = ChatSession {
id: "legacy-session".to_string(),
name: "旧会话".to_string(),
created_at: chrono::Utc::now().timestamp_millis(),
updated_at: chrono::Utc::now().timestamp_millis(),
metadata: None,
};
{
let conn_guard = conn.lock().unwrap();
conn_guard
@@ -929,10 +800,6 @@ mod tests {
rusqlite::params!["migrated_general_chat_to_unified", "true"],
)
.unwrap();
insert_legacy_session(&conn_guard, &legacy_session);
for message in create_test_messages("legacy-session", 3) {
insert_legacy_message(&conn_guard, &message);
}
}
let context = service
@@ -1,715 +0,0 @@
//! 工具钩子管理服务
//!
//! 提供工具执行前后的钩子机制,用于自动化上下文记忆管理
use crate::context_memory_service::{ContextMemoryService, MemoryEntry, MemoryFileType};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use tracing::{debug, error, info};
/// 钩子触发时机
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum HookTrigger {
/// 会话开始时
SessionStart,
/// 工具使用前
PreToolUse,
/// 工具使用后
PostToolUse,
/// 会话停止时
Stop,
}
/// 钩子动作类型
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum HookAction {
/// 保存发现
SaveFinding {
title: String,
content: String,
tags: Vec<String>,
priority: u8,
},
/// 更新任务计划
UpdateTaskPlan {
title: String,
content: String,
priority: u8,
},
/// 记录进度
LogProgress { title: String, content: String },
/// 记录错误
RecordError {
error_description: String,
attempted_solution: String,
},
/// 自定义动作
Custom {
action_type: String,
parameters: HashMap<String, String>,
},
}
/// 钩子规则
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HookRule {
/// 规则 ID
pub id: String,
/// 规则名称
pub name: String,
/// 描述
pub description: String,
/// 触发时机
pub trigger: HookTrigger,
/// 触发条件
pub conditions: Vec<HookCondition>,
/// 执行动作
pub actions: Vec<HookAction>,
/// 是否启用
pub enabled: bool,
/// 优先级 (数字越小优先级越高)
pub priority: u32,
/// 创建时间
pub created_at: i64,
}
/// 钩子条件
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum HookCondition {
/// 工具名称匹配
ToolNameEquals(String),
/// 工具名称包含
ToolNameContains(String),
/// 消息内容包含
MessageContains(String),
/// 会话消息数量大于
MessageCountGreaterThan(usize),
/// 错误次数大于
ErrorCountGreaterThan(u32),
/// 自定义条件
Custom {
condition_type: String,
parameters: HashMap<String, String>,
},
}
/// 钩子执行上下文
#[derive(Debug, Clone)]
pub struct HookContext {
/// 会话 ID
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>,
}
/// 工具钩子管理器
pub struct ToolHooksService {
/// 钩子规则
rules: Arc<Mutex<Vec<HookRule>>>,
/// 上下文记忆服务
memory_service: Arc<ContextMemoryService>,
/// 执行统计
execution_stats: Arc<Mutex<HashMap<String, HookExecutionStats>>>,
}
/// 钩子执行统计
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct HookExecutionStats {
/// 执行次数
pub execution_count: u64,
/// 成功次数
pub success_count: u64,
/// 失败次数
pub failure_count: u64,
/// 最后执行时间
pub last_execution_at: i64,
/// 平均执行时间(毫秒)
pub average_execution_time_ms: f64,
}
impl ToolHooksService {
/// 创建新的工具钩子服务
pub fn new(memory_service: Arc<ContextMemoryService>) -> Self {
let service = Self {
rules: Arc::new(Mutex::new(Vec::new())),
memory_service,
execution_stats: Arc::new(Mutex::new(HashMap::new())),
};
// 注册默认钩子规则
service.register_default_hooks();
service
}
/// 注册默认钩子规则
fn register_default_hooks(&self) {
let default_rules = vec![
// 会话开始时创建任务计划
HookRule {
id: "session-start-task-plan".to_string(),
name: "会话开始任务计划".to_string(),
description: "会话开始时自动创建任务计划记录".to_string(),
trigger: HookTrigger::SessionStart,
conditions: vec![],
actions: vec![HookAction::UpdateTaskPlan {
title: "会话任务计划".to_string(),
content: "新会话开始,等待用户输入任务目标...".to_string(),
priority: 3,
}],
enabled: true,
priority: 1,
created_at: chrono::Utc::now().timestamp_millis(),
},
// 工具使用后记录进度(2-Action 规则)
HookRule {
id: "post-tool-progress-log".to_string(),
name: "工具使用进度记录".to_string(),
description: "工具使用后自动记录进度(2-Action 规则)".to_string(),
trigger: HookTrigger::PostToolUse,
conditions: vec![],
actions: vec![HookAction::LogProgress {
title: "工具执行记录".to_string(),
content: "工具执行完成,结果已记录".to_string(),
}],
enabled: true,
priority: 2,
created_at: chrono::Utc::now().timestamp_millis(),
},
// 重要发现自动保存
HookRule {
id: "important-finding-save".to_string(),
name: "重要发现保存".to_string(),
description: "检测到重要信息时自动保存".to_string(),
trigger: HookTrigger::PostToolUse,
conditions: vec![
HookCondition::MessageContains("重要".to_string()),
HookCondition::MessageContains("发现".to_string()),
],
actions: vec![HookAction::SaveFinding {
title: "重要发现".to_string(),
content: "检测到重要信息,已自动保存".to_string(),
tags: vec!["重要".to_string(), "自动保存".to_string()],
priority: 4,
}],
enabled: true,
priority: 3,
created_at: chrono::Utc::now().timestamp_millis(),
},
// 错误自动记录
HookRule {
id: "error-auto-record".to_string(),
name: "错误自动记录".to_string(),
description: "检测到错误时自动记录".to_string(),
trigger: HookTrigger::PostToolUse,
conditions: vec![HookCondition::MessageContains("错误".to_string())],
actions: vec![HookAction::RecordError {
error_description: "检测到错误".to_string(),
attempted_solution: "正在尝试解决".to_string(),
}],
enabled: true,
priority: 1,
created_at: chrono::Utc::now().timestamp_millis(),
},
// 会话停止时保存摘要
HookRule {
id: "session-stop-summary".to_string(),
name: "会话停止摘要".to_string(),
description: "会话停止时保存会话摘要".to_string(),
trigger: HookTrigger::Stop,
conditions: vec![HookCondition::MessageCountGreaterThan(5)],
actions: vec![HookAction::SaveFinding {
title: "会话摘要".to_string(),
content: "会话已结束,主要成果和发现已记录".to_string(),
tags: vec!["摘要".to_string(), "会话结束".to_string()],
priority: 3,
}],
enabled: true,
priority: 2,
created_at: chrono::Utc::now().timestamp_millis(),
},
];
let mut rules = self.rules.lock().unwrap();
rules.extend(default_rules);
info!("已注册 {} 个默认钩子规则", rules.len());
}
/// 执行钩子
pub fn execute_hooks(&self, trigger: HookTrigger, context: &HookContext) -> Result<(), String> {
let rules = self.rules.lock().map_err(|e| e.to_string())?;
// 获取匹配的规则并按优先级排序
let mut matching_rules: Vec<_> = rules
.iter()
.filter(|rule| rule.enabled && rule.trigger == trigger)
.filter(|rule| self.evaluate_conditions(rule, context))
.collect();
matching_rules.sort_by_key(|rule| rule.priority);
debug!(
"触发钩子 {:?},匹配到 {} 个规则 (会话: {})",
trigger,
matching_rules.len(),
context.session_id
);
// 执行匹配的规则
for rule in matching_rules {
if let Err(e) = self.execute_rule(rule, context) {
error!("执行钩子规则失败 {}: {}", rule.name, e);
self.update_execution_stats(&rule.id, false, 0.0);
} else {
self.update_execution_stats(&rule.id, true, 0.0);
}
}
Ok(())
}
/// 评估钩子条件
fn evaluate_conditions(&self, rule: &HookRule, context: &HookContext) -> bool {
if rule.conditions.is_empty() {
return true;
}
for condition in &rule.conditions {
if !self.evaluate_single_condition(condition, context) {
return false;
}
}
true
}
/// 评估单个条件
fn evaluate_single_condition(&self, condition: &HookCondition, context: &HookContext) -> bool {
match condition {
HookCondition::ToolNameEquals(name) => context.tool_name.as_ref() == Some(name),
HookCondition::ToolNameContains(substring) => context
.tool_name
.as_ref()
.is_some_and(|tn| tn.contains(substring)),
HookCondition::MessageContains(substring) => {
context
.message_content
.as_ref()
.is_some_and(|mc| mc.contains(substring))
|| context
.tool_result
.as_ref()
.is_some_and(|tr| tr.contains(substring))
}
HookCondition::MessageCountGreaterThan(count) => context.message_count > *count,
HookCondition::ErrorCountGreaterThan(_count) => {
// 这里可以从 memory_service 获取错误计数
context.error_info.is_some()
}
HookCondition::Custom {
condition_type: _,
parameters: _,
} => {
// 自定义条件的实现
true
}
}
}
/// 执行钩子规则
fn execute_rule(&self, rule: &HookRule, context: &HookContext) -> Result<(), String> {
let start_time = std::time::Instant::now();
for action in &rule.actions {
self.execute_action(action, context)?;
}
let execution_time = start_time.elapsed().as_millis() as f64;
self.update_execution_stats(&rule.id, true, execution_time);
debug!(
"执行钩子规则成功: {} (耗时: {:.2}ms)",
rule.name, execution_time
);
Ok(())
}
/// 执行钩子动作
fn execute_action(&self, action: &HookAction, context: &HookContext) -> Result<(), String> {
match action {
HookAction::SaveFinding {
title,
content,
tags,
priority,
} => {
let entry = MemoryEntry {
id: uuid::Uuid::new_v4().to_string(),
session_id: context.session_id.clone(),
file_type: MemoryFileType::Findings,
title: self.interpolate_template(title, context),
content: self.interpolate_template(content, context),
tags: tags.clone(),
priority: *priority,
created_at: chrono::Utc::now().timestamp_millis(),
updated_at: chrono::Utc::now().timestamp_millis(),
archived: false,
};
self.memory_service.save_memory_entry(&entry)?;
}
HookAction::UpdateTaskPlan {
title,
content,
priority,
} => {
let entry = MemoryEntry {
id: uuid::Uuid::new_v4().to_string(),
session_id: context.session_id.clone(),
file_type: MemoryFileType::TaskPlan,
title: self.interpolate_template(title, context),
content: self.interpolate_template(content, context),
tags: vec!["任务计划".to_string()],
priority: *priority,
created_at: chrono::Utc::now().timestamp_millis(),
updated_at: chrono::Utc::now().timestamp_millis(),
archived: false,
};
self.memory_service.save_memory_entry(&entry)?;
}
HookAction::LogProgress { title, content } => {
let entry = MemoryEntry {
id: uuid::Uuid::new_v4().to_string(),
session_id: context.session_id.clone(),
file_type: MemoryFileType::Progress,
title: self.interpolate_template(title, context),
content: self.interpolate_template(content, context),
tags: vec!["进度".to_string()],
priority: 2,
created_at: chrono::Utc::now().timestamp_millis(),
updated_at: chrono::Utc::now().timestamp_millis(),
archived: false,
};
self.memory_service.save_memory_entry(&entry)?;
}
HookAction::RecordError {
error_description,
attempted_solution,
} => {
let error_desc = self.interpolate_template(error_description, context);
let solution = self.interpolate_template(attempted_solution, context);
self.memory_service
.record_error(&context.session_id, &error_desc, &solution)?;
}
HookAction::Custom {
action_type: _,
parameters: _,
} => {
// 自定义动作的实现
debug!("执行自定义钩子动作");
}
}
Ok(())
}
/// 模板插值
fn interpolate_template(&self, template: &str, context: &HookContext) -> String {
let mut result = template.to_string();
// 替换常见的模板变量
result = result.replace("{session_id}", &context.session_id);
if let Some(tool_name) = &context.tool_name {
result = result.replace("{tool_name}", tool_name);
}
if let Some(message_content) = &context.message_content {
let preview = if message_content.len() > 100 {
format!("{}...", &message_content[..100])
} else {
message_content.clone()
};
result = result.replace("{message_preview}", &preview);
}
if let Some(tool_result) = &context.tool_result {
let preview = if tool_result.len() > 200 {
format!("{}...", &tool_result[..200])
} else {
tool_result.clone()
};
result = result.replace("{tool_result_preview}", &preview);
}
result = result.replace("{message_count}", &context.message_count.to_string());
result = result.replace(
"{timestamp}",
&chrono::Utc::now().format("%Y-%m-%d %H:%M:%S").to_string(),
);
// 替换元数据变量
for (key, value) in &context.metadata {
result = result.replace(&format!("{{{key}}}"), value);
}
result
}
/// 更新执行统计
fn update_execution_stats(&self, rule_id: &str, success: bool, execution_time_ms: f64) {
let mut stats = self.execution_stats.lock().unwrap();
let entry = stats.entry(rule_id.to_string()).or_default();
entry.execution_count += 1;
if success {
entry.success_count += 1;
} else {
entry.failure_count += 1;
}
entry.last_execution_at = chrono::Utc::now().timestamp_millis();
// 更新平均执行时间
if execution_time_ms > 0.0 {
let total_time = entry.average_execution_time_ms * (entry.execution_count - 1) as f64;
entry.average_execution_time_ms =
(total_time + execution_time_ms) / entry.execution_count as f64;
}
}
/// 添加钩子规则
pub fn add_hook_rule(&self, rule: HookRule) -> Result<(), String> {
let mut rules = self.rules.lock().map_err(|e| e.to_string())?;
// 检查是否已存在相同 ID 的规则
if rules.iter().any(|r| r.id == rule.id) {
return Err(format!("钩子规则 ID 已存在: {}", rule.id));
}
rules.push(rule.clone());
info!("已添加钩子规则: {}", rule.name);
Ok(())
}
/// 移除钩子规则
pub fn remove_hook_rule(&self, rule_id: &str) -> Result<(), String> {
let mut rules = self.rules.lock().map_err(|e| e.to_string())?;
let initial_len = rules.len();
rules.retain(|r| r.id != rule_id);
if rules.len() == initial_len {
return Err(format!("未找到钩子规则: {rule_id}"));
}
info!("已移除钩子规则: {}", rule_id);
Ok(())
}
/// 启用/禁用钩子规则
pub fn toggle_hook_rule(&self, rule_id: &str, enabled: bool) -> Result<(), String> {
let mut rules = self.rules.lock().map_err(|e| e.to_string())?;
if let Some(rule) = rules.iter_mut().find(|r| r.id == rule_id) {
rule.enabled = enabled;
info!(
"钩子规则 {} 已{}",
rule.name,
if enabled { "启用" } else { "禁用" }
);
Ok(())
} else {
Err(format!("未找到钩子规则: {rule_id}"))
}
}
/// 获取所有钩子规则
pub fn get_hook_rules(&self) -> Result<Vec<HookRule>, String> {
let rules = self.rules.lock().map_err(|e| e.to_string())?;
Ok(rules.clone())
}
/// 获取执行统计
pub fn get_execution_stats(&self) -> Result<HashMap<String, HookExecutionStats>, String> {
let stats = self.execution_stats.lock().map_err(|e| e.to_string())?;
Ok(stats.clone())
}
/// 清理执行统计
pub fn clear_execution_stats(&self) -> Result<(), String> {
let mut stats = self.execution_stats.lock().map_err(|e| e.to_string())?;
stats.clear();
info!("已清理钩子执行统计");
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::context_memory_service::ContextMemoryConfig;
use tempfile::TempDir;
fn create_test_services() -> (Arc<ContextMemoryService>, ToolHooksService, TempDir) {
let temp_dir = TempDir::new().unwrap();
let memory_config = ContextMemoryConfig {
memory_dir: temp_dir.path().to_path_buf(),
..Default::default()
};
let memory_service = Arc::new(ContextMemoryService::new(memory_config).unwrap());
let hooks_service = ToolHooksService::new(memory_service.clone());
(memory_service, hooks_service, temp_dir)
}
#[test]
fn test_hooks_service_creation() {
let (_memory_service, hooks_service, _temp_dir) = create_test_services();
let rules = hooks_service.get_hook_rules().unwrap();
assert!(!rules.is_empty()); // 应该有默认规则
}
#[test]
fn test_session_start_hook() {
let (_memory_service, hooks_service, _temp_dir) = create_test_services();
let context = HookContext {
session_id: "test-session".to_string(),
tool_name: None,
tool_parameters: None,
tool_result: None,
message_content: None,
message_count: 0,
error_info: None,
metadata: HashMap::new(),
};
hooks_service
.execute_hooks(HookTrigger::SessionStart, &context)
.unwrap();
// 验证任务计划是否被创建
let memories = _memory_service
.get_session_memories("test-session", Some(MemoryFileType::TaskPlan))
.unwrap();
assert!(!memories.is_empty());
}
#[test]
fn test_error_recording_hook() {
let (_memory_service, hooks_service, _temp_dir) = create_test_services();
let context = HookContext {
session_id: "test-session".to_string(),
tool_name: Some("test_tool".to_string()),
tool_parameters: None,
tool_result: Some("发生了一个错误".to_string()),
message_content: Some("这里有一个错误需要处理".to_string()),
message_count: 5,
error_info: Some("测试错误".to_string()),
metadata: HashMap::new(),
};
hooks_service
.execute_hooks(HookTrigger::PostToolUse, &context)
.unwrap();
// 验证错误是否被记录
let stats = _memory_service.get_memory_stats("test-session").unwrap();
assert!(stats.unresolved_errors > 0);
}
#[test]
fn test_custom_hook_rule() {
let (_memory_service, hooks_service, _temp_dir) = create_test_services();
let custom_rule = HookRule {
id: "custom-test-rule".to_string(),
name: "自定义测试规则".to_string(),
description: "测试自定义钩子规则".to_string(),
trigger: HookTrigger::PostToolUse,
conditions: vec![HookCondition::ToolNameEquals("custom_tool".to_string())],
actions: vec![HookAction::SaveFinding {
title: "自定义发现".to_string(),
content: "这是一个自定义钩子触发的发现".to_string(),
tags: vec!["自定义".to_string()],
priority: 3,
}],
enabled: true,
priority: 1,
created_at: chrono::Utc::now().timestamp_millis(),
};
hooks_service.add_hook_rule(custom_rule).unwrap();
let context = HookContext {
session_id: "test-session".to_string(),
tool_name: Some("custom_tool".to_string()),
tool_parameters: None,
tool_result: None,
message_content: None,
message_count: 0,
error_info: None,
metadata: HashMap::new(),
};
hooks_service
.execute_hooks(HookTrigger::PostToolUse, &context)
.unwrap();
// 验证自定义发现是否被保存
let memories = _memory_service
.get_session_memories("test-session", Some(MemoryFileType::Findings))
.unwrap();
assert!(memories.iter().any(|m| m.title == "自定义发现"));
}
#[test]
fn test_template_interpolation() {
let (_memory_service, hooks_service, _temp_dir) = create_test_services();
let context = HookContext {
session_id: "test-session-123".to_string(),
tool_name: Some("test_tool".to_string()),
tool_parameters: None,
tool_result: None,
message_content: Some("这是测试消息".to_string()),
message_count: 42,
error_info: None,
metadata: {
let mut map = HashMap::new();
map.insert("custom_var".to_string(), "custom_value".to_string());
map
},
};
let template = "会话 {session_id} 使用工具 {tool_name},消息数量: {message_count},自定义变量: {custom_var}";
let result = hooks_service.interpolate_template(template, &context);
assert!(result.contains("test-session-123"));
assert!(result.contains("test_tool"));
assert!(result.contains("42"));
assert!(result.contains("custom_value"));
}
}