Files
zclaw_openfang/desktop/src-tauri/src/memory_commands.rs
iven 33c1bd3866 fix(memory): memory_search 空查询时默认 min_similarity=0.0 触发表扫描
根因: FTS5 空查询返回 0 条,而 memory_stats 因设 min_similarity=Some(0.0)
走表扫描才正确计数。统一空查询行为。
2026-04-11 12:32:18 +08:00

518 lines
16 KiB
Rust

//! Memory Commands - Tauri commands for persistent memory operations
//!
//! Unified storage: All operations delegate to VikingStorage (SqliteStorage),
//! which provides FTS5 full-text search, TF-IDF scoring, and optional embedding.
//!
//! The previous dual-write to PersistentMemoryStore has been removed.
//! PersistentMemory type is retained for frontend API compatibility.
use crate::memory::{PersistentMemory, PersistentMemoryStore, MemoryStats, configure_embedding_client, is_embedding_configured, EmbedFn};
use serde::{Deserialize, Serialize};
use std::sync::Arc;
use tauri::{AppHandle, State};
use tokio::sync::Mutex;
/// Shared memory store state
/// NOTE: PersistentMemoryStore is kept only for embedding configuration.
/// All actual storage goes through VikingStorage (SqliteStorage).
pub type MemoryStoreState = Arc<Mutex<Option<PersistentMemoryStore>>>;
/// Memory entry for frontend API
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MemoryEntryInput {
pub agent_id: String,
pub memory_type: String,
pub content: String,
pub importance: Option<i32>,
pub source: Option<String>,
pub tags: Option<Vec<String>>,
pub conversation_id: Option<String>,
}
/// Memory search options for frontend API
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MemorySearchOptions {
pub agent_id: Option<String>,
pub memory_type: Option<String>,
pub tags: Option<Vec<String>>,
pub query: Option<String>,
pub min_importance: Option<i32>,
pub limit: Option<usize>,
pub offset: Option<usize>,
}
/// Initialize the memory store
///
/// Now a no-op for storage (VikingStorage initializes itself in viking_commands).
/// Only initializes PersistentMemoryStore for backward-compatible embedding config.
// @connected
#[tauri::command]
pub async fn memory_init(
app_handle: AppHandle,
state: State<'_, MemoryStoreState>,
) -> Result<(), String> {
let store = PersistentMemoryStore::new(&app_handle).await?;
let mut state_guard = state.lock().await;
*state_guard = Some(store);
Ok(())
}
/// Store a new memory
///
/// Writes to VikingStorage (SqliteStorage) with FTS5 + TF-IDF indexing.
// @connected
#[tauri::command]
pub async fn memory_store(
entry: MemoryEntryInput,
_state: State<'_, MemoryStoreState>,
) -> Result<String, String> {
let storage = crate::viking_commands::get_storage().await?;
let memory_type = parse_memory_type(&entry.memory_type);
let keywords = entry.tags.unwrap_or_default();
let importance = entry.importance.unwrap_or(5).clamp(1, 10) as u8;
let viking_entry = zclaw_growth::MemoryEntry::new(
&entry.agent_id,
memory_type,
&entry.memory_type,
entry.content,
)
.with_importance(importance)
.with_keywords(keywords);
let uri = viking_entry.uri.clone();
zclaw_growth::VikingStorage::store(storage.as_ref(), &viking_entry).await
.map_err(|e| format!("Failed to store memory: {}", e))?;
Ok(uri)
}
/// Parse a string memory_type into zclaw_growth::MemoryType
fn parse_memory_type(type_str: &str) -> zclaw_growth::MemoryType {
match type_str.to_lowercase().as_str() {
"preference" => zclaw_growth::MemoryType::Preference,
"knowledge" | "fact" | "task" | "todo" | "lesson" | "event" => zclaw_growth::MemoryType::Knowledge,
"skill" | "experience" => zclaw_growth::MemoryType::Experience,
"session" | "conversation" => zclaw_growth::MemoryType::Session,
_ => zclaw_growth::MemoryType::Knowledge,
}
}
/// Convert zclaw_growth::MemoryEntry to PersistentMemory (frontend compatibility)
fn to_persistent(entry: &zclaw_growth::MemoryEntry) -> PersistentMemory {
// Extract agent_id from URI: "agent://{agent_id}/{type}/{category}"
let agent_id = entry.uri
.strip_prefix("agent://")
.and_then(|s| s.split('/').next())
.unwrap_or("unknown")
.to_string();
PersistentMemory {
id: entry.uri.clone(),
agent_id,
memory_type: entry.memory_type.to_string(),
content: entry.content.clone(),
importance: entry.importance as i32,
source: "auto".to_string(),
tags: serde_json::to_string(&entry.keywords).unwrap_or_else(|_| "[]".to_string()),
conversation_id: None,
created_at: entry.created_at.to_rfc3339(),
last_accessed_at: entry.last_accessed.to_rfc3339(),
access_count: entry.access_count as i32,
embedding: None,
overview: entry.overview.clone(),
}
}
/// Get a memory by ID (URI)
// @connected
#[tauri::command]
pub async fn memory_get(
id: String,
_state: State<'_, MemoryStoreState>,
) -> Result<Option<PersistentMemory>, String> {
let storage = crate::viking_commands::get_storage().await?;
let entry = zclaw_growth::VikingStorage::get(storage.as_ref(), &id).await
.map_err(|e| format!("Failed to get memory: {}", e))?;
Ok(entry.map(|e| to_persistent(&e)))
}
/// Search memories
///
/// Uses VikingStorage::find() for FTS5 + TF-IDF + optional embedding search.
// @connected
#[tauri::command]
pub async fn memory_search(
options: MemorySearchOptions,
_state: State<'_, MemoryStoreState>,
) -> Result<Vec<PersistentMemory>, String> {
let storage = crate::viking_commands::get_storage().await?;
// Build scope from agent_id
let scope = options.agent_id.map(|id| format!("agent://{}/", id));
// Build search query
let query = options.query.unwrap_or_default();
// When query is empty, use min_similarity=0.0 to trigger table scan
// (FTS5 requires non-empty query; without this, empty query returns 0 results)
let min_similarity = if query.trim().is_empty() {
Some(0.0)
} else {
options.min_importance.map(|i| (i as f32) / 10.0)
};
let find_options = zclaw_growth::FindOptions {
scope,
limit: options.limit.or(Some(50)),
min_similarity,
};
let entries = zclaw_growth::VikingStorage::find(storage.as_ref(), &query, find_options).await
.map_err(|e| format!("Failed to search memories: {}", e))?;
// Filter by memory_type if specified
let filtered: Vec<PersistentMemory> = if let Some(ref type_filter) = options.memory_type {
entries
.into_iter()
.filter(|e| e.memory_type.to_string().eq_ignore_ascii_case(type_filter))
.map(|e| to_persistent(&e))
.collect()
} else {
entries.into_iter().map(|e| to_persistent(&e)).collect()
};
// Apply offset
let offset = options.offset.unwrap_or(0);
Ok(filtered.into_iter().skip(offset).collect())
}
/// Delete a memory by ID (URI)
///
/// Deletes from VikingStorage only (PersistentMemoryStore is no longer primary).
// @connected
#[tauri::command]
pub async fn memory_delete(
id: String,
_state: State<'_, MemoryStoreState>,
) -> Result<(), String> {
let storage = crate::viking_commands::get_storage().await?;
zclaw_growth::VikingStorage::delete(storage.as_ref(), &id).await
.map_err(|e| format!("Failed to delete memory: {}", e))?;
Ok(())
}
/// Delete all memories for an agent
// @connected
#[tauri::command]
pub async fn memory_delete_all(
agent_id: String,
_state: State<'_, MemoryStoreState>,
) -> Result<usize, String> {
let storage = crate::viking_commands::get_storage().await?;
// Find all entries for this agent
let options = zclaw_growth::FindOptions {
scope: Some(format!("agent://{}/", agent_id)),
limit: Some(10000),
min_similarity: Some(0.0),
};
let entries = zclaw_growth::VikingStorage::find(storage.as_ref(), "", options).await
.map_err(|e| format!("Failed to find memories: {}", e))?;
let count = entries.len();
for entry in &entries {
zclaw_growth::VikingStorage::delete(storage.as_ref(), &entry.uri).await
.map_err(|e| format!("Failed to delete memory: {}", e))?;
}
Ok(count)
}
/// Get memory statistics
// @connected
#[tauri::command]
pub async fn memory_stats(
_state: State<'_, MemoryStoreState>,
) -> Result<MemoryStats, String> {
let storage = crate::viking_commands::get_storage().await?;
// Fetch all entries to compute stats
let options = zclaw_growth::FindOptions {
scope: None,
limit: Some(100000),
min_similarity: Some(0.0),
};
let entries = zclaw_growth::VikingStorage::find(storage.as_ref(), "", options).await
.map_err(|e| format!("Failed to get memories for stats: {}", e))?;
let mut by_type: std::collections::HashMap<String, i64> = std::collections::HashMap::new();
let mut by_agent: std::collections::HashMap<String, i64> = std::collections::HashMap::new();
let mut oldest: Option<String> = None;
let mut newest: Option<String> = None;
for entry in &entries {
let type_key = entry.memory_type.to_string();
*by_type.entry(type_key).or_insert(0) += 1;
let agent = entry.uri
.strip_prefix("agent://")
.and_then(|s| s.split('/').next())
.unwrap_or("unknown")
.to_string();
*by_agent.entry(agent).or_insert(0) += 1;
let created = entry.created_at.to_rfc3339();
if oldest.as_ref().map_or(true, |o| &created < o) {
oldest = Some(created.clone());
}
if newest.as_ref().map_or(true, |n| &created > n) {
newest = Some(created);
}
}
let storage_size = entries.iter()
.map(|e| e.content.len() + e.keywords.len() * 10)
.sum::<usize>() as i64;
Ok(MemoryStats {
total_entries: entries.len() as i64,
by_type,
by_agent,
oldest_entry: oldest,
newest_entry: newest,
storage_size_bytes: storage_size,
})
}
/// Export all memories for backup
// @connected
#[tauri::command]
pub async fn memory_export(
_state: State<'_, MemoryStoreState>,
) -> Result<Vec<PersistentMemory>, String> {
let storage = crate::viking_commands::get_storage().await?;
let options = zclaw_growth::FindOptions {
scope: None,
limit: Some(100000),
min_similarity: Some(0.0),
};
let entries = zclaw_growth::VikingStorage::find(storage.as_ref(), "", options).await
.map_err(|e| format!("Failed to export memories: {}", e))?;
Ok(entries.into_iter().map(|e| to_persistent(&e)).collect())
}
/// Import memories from backup
///
/// Converts PersistentMemory entries to VikingStorage MemoryEntry and stores them.
// @connected
#[tauri::command]
pub async fn memory_import(
memories: Vec<PersistentMemory>,
_state: State<'_, MemoryStoreState>,
) -> Result<usize, String> {
let storage = crate::viking_commands::get_storage().await?;
let mut count = 0;
for memory in &memories {
let memory_type = parse_memory_type(&memory.memory_type);
let keywords: Vec<String> = serde_json::from_str(&memory.tags).unwrap_or_default();
let viking_entry = zclaw_growth::MemoryEntry::new(
&memory.agent_id,
memory_type,
&memory.memory_type,
memory.content.clone(),
)
.with_importance(memory.importance.clamp(1, 10) as u8)
.with_keywords(keywords);
// Set overview if present
let viking_entry = if let Some(ref overview) = memory.overview {
if !overview.is_empty() {
viking_entry.with_overview(overview)
} else {
viking_entry
}
} else {
viking_entry
};
match zclaw_growth::VikingStorage::store(storage.as_ref(), &viking_entry).await {
Ok(()) => count += 1,
Err(e) => tracing::warn!("[memory_import] Failed to import {}: {}", memory.id, e),
}
}
Ok(count)
}
/// Get the database path
///
/// Now returns the VikingStorage (SqliteStorage) path.
// @connected
#[tauri::command]
pub async fn memory_db_path(
_state: State<'_, MemoryStoreState>,
) -> Result<String, String> {
let storage_dir = crate::viking_commands::get_storage_dir();
let db_path = storage_dir.join("memories.db");
Ok(db_path.to_string_lossy().to_string())
}
/// @reserved — no frontend UI yet
/// Configure embedding for PersistentMemoryStore (chat memory search)
/// This is called alongside viking_configure_embedding to enable vector search in chat flow
#[tauri::command]
pub async fn memory_configure_embedding(
provider: String,
api_key: String,
model: Option<String>,
endpoint: Option<String>,
) -> Result<bool, String> {
let config = crate::llm::EmbeddingConfig {
provider,
api_key,
endpoint,
model,
};
let client = std::sync::Arc::new(crate::llm::EmbeddingClient::new(config));
let embed_fn: EmbedFn = {
let client = client.clone();
Arc::new(move |text: &str| {
let client = client.clone();
let text = text.to_string();
Box::pin(async move {
let response = client.embed(&text).await?;
Ok(response.embedding)
})
})
};
configure_embedding_client(embed_fn);
tracing::info!("[MemoryCommands] Embedding configured");
Ok(true)
}
/// @reserved — no frontend UI yet
/// Check if embedding is configured for PersistentMemoryStore
#[tauri::command]
pub fn memory_is_embedding_configured() -> bool {
is_embedding_configured()
}
/// Build layered memory context for chat prompt injection
///
/// Uses VikingStorage (SqliteStorage) with FTS5 + TF-IDF + optional Embedding.
// @connected
#[tauri::command]
pub async fn memory_build_context(
agent_id: String,
query: String,
max_tokens: Option<usize>,
_state: State<'_, MemoryStoreState>,
) -> Result<BuildContextResult, String> {
let budget = max_tokens.unwrap_or(500);
let storage = crate::viking_commands::get_storage().await?;
let options = zclaw_growth::FindOptions {
scope: Some(format!("agent://{}/", agent_id)),
limit: Some((budget / 25).max(8)),
min_similarity: Some(0.2),
};
let entries = match zclaw_growth::VikingStorage::find(storage.as_ref(), &query, options).await {
Ok(entries) => entries,
Err(e) => {
tracing::warn!("[memory_build_context] Search failed: {}", e);
Vec::new()
}
};
if entries.is_empty() {
return Ok(BuildContextResult {
system_prompt_addition: String::new(),
total_tokens: 0,
memories_used: 0,
});
}
let mut used_tokens = 0;
let mut items: Vec<String> = Vec::new();
let mut memories_used = 0;
for entry in &entries {
if used_tokens >= budget {
break;
}
let overview_str = entry.overview.as_deref().unwrap_or("");
let display_content = if !overview_str.is_empty() {
overview_str.to_string()
} else {
truncate_for_l1(&entry.content)
};
let item_tokens = estimate_tokens_text(&display_content);
if used_tokens + item_tokens > budget {
continue;
}
items.push(format!("- [{}] {}", entry.memory_type, display_content));
used_tokens += item_tokens;
memories_used += 1;
}
let system_prompt_addition = if items.is_empty() {
String::new()
} else {
format!("## 相关记忆\n{}", items.join("\n"))
};
Ok(BuildContextResult {
system_prompt_addition,
total_tokens: used_tokens,
memories_used,
})
}
/// Result of building layered memory context
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct BuildContextResult {
pub system_prompt_addition: String,
pub total_tokens: usize,
pub memories_used: usize,
}
/// Truncate content for L1 overview display (~50 tokens)
fn truncate_for_l1(content: &str) -> String {
let char_limit = 100;
if content.chars().count() <= char_limit {
content.to_string()
} else {
let truncated: String = content.chars().take(char_limit).collect();
format!("{}...", truncated)
}
}
/// Estimate token count for text
fn estimate_tokens_text(text: &str) -> usize {
let cjk_count = text.chars().filter(|c| ('\u{4E00}'..='\u{9FFF}').contains(c)).count();
let other_count = text.chars().count() - cjk_count;
(cjk_count as f32 * 1.5 + other_count as f32 * 0.4).ceil() as usize
}