Files
codeg/src-tauri/src/commands/conversations.rs
helwd 596ac1eb58 feat: 扫描 Gemini history/ 目录并支持按模型名搜索会话
- gemini.rs: list_chat_files() 同时扫描 tmp/(进行中)和 history/
  (已归档)目录,修复已完成的 Gemini 会话不可见的问题
- conversations.rs: 搜索过滤新增 model 字段匹配,
  支持按模型名搜索会话(如 'gemini-2.5-pro'、'claude-sonnet')
2026-03-12 23:13:34 +08:00

432 lines
14 KiB
Rust

use std::collections::{HashMap, HashSet};
use crate::app_error::AppCommandError;
use crate::db::entities::conversation;
use crate::db::service::{conversation_service, folder_service, import_service};
use crate::db::AppDatabase;
use crate::models::*;
use crate::parsers::claude::ClaudeParser;
use crate::parsers::codex::CodexParser;
use crate::parsers::gemini::GeminiParser;
use crate::parsers::opencode::OpenCodeParser;
use crate::parsers::{path_eq_for_matching, AgentParser, ParseError};
#[tauri::command]
pub async fn list_folder_conversations(
db: tauri::State<'_, AppDatabase>,
folder_id: i32,
agent_type: Option<AgentType>,
search: Option<String>,
sort_by: Option<String>,
status: Option<String>,
) -> Result<Vec<DbConversationSummary>, AppCommandError> {
conversation_service::list_by_folder(&db.conn, folder_id, agent_type, search, sort_by, status)
.await
.map_err(AppCommandError::from)
}
/// Synchronous implementation shared by list_conversations, list_folders, and get_stats.
fn list_conversations_sync(
agent_type: Option<AgentType>,
search: Option<String>,
sort_by: Option<String>,
folder_path: Option<String>,
) -> Vec<ConversationSummary> {
let mut all_conversations = Vec::new();
let mut seen_keys = HashSet::new();
let parsers: Vec<(AgentType, Box<dyn AgentParser>)> = vec![
(AgentType::ClaudeCode, Box::new(ClaudeParser::new())),
(AgentType::Codex, Box::new(CodexParser::new())),
(AgentType::OpenCode, Box::new(OpenCodeParser::new())),
(AgentType::Gemini, Box::new(GeminiParser::new())),
];
for (at, parser) in &parsers {
if let Some(ref filter) = agent_type {
if filter != at {
continue;
}
}
match parser.list_conversations() {
Ok(conversations) => {
// Deduplicate conversations based on (agent_type, id) combination
for conversation in conversations {
let key = format!("{:?}-{}", conversation.agent_type, conversation.id);
if seen_keys.insert(key) {
all_conversations.push(conversation);
}
}
}
Err(e) => {
eprintln!("Error listing {} conversations: {}", at, e);
}
}
}
// Apply search filter
if let Some(ref query) = search {
let query_lower = query.to_lowercase();
all_conversations.retain(|s| {
s.title
.as_ref()
.map(|t| t.to_lowercase().contains(&query_lower))
.unwrap_or(false)
|| s.folder_name
.as_ref()
.map(|p| p.to_lowercase().contains(&query_lower))
.unwrap_or(false)
|| s.folder_path
.as_ref()
.map(|p| p.to_lowercase().contains(&query_lower))
.unwrap_or(false)
|| s.git_branch
.as_ref()
.map(|b| b.to_lowercase().contains(&query_lower))
.unwrap_or(false)
|| s.model
.as_ref()
.map(|m| m.to_lowercase().contains(&query_lower))
.unwrap_or(false)
});
}
// Apply folder path filter
if let Some(ref fp) = folder_path {
all_conversations.retain(|s| {
s.folder_path
.as_deref()
.map(|p| path_eq_for_matching(p, fp.as_str()))
.unwrap_or(false)
});
}
// Apply sorting
match sort_by.as_deref() {
Some("oldest") => all_conversations.sort_by(|a, b| a.started_at.cmp(&b.started_at)),
Some("messages") => all_conversations.sort_by(|a, b| b.message_count.cmp(&a.message_count)),
_ => all_conversations.sort_by(|a, b| b.started_at.cmp(&a.started_at)), // default: newest first
}
all_conversations
}
#[tauri::command]
pub async fn list_conversations(
agent_type: Option<AgentType>,
search: Option<String>,
sort_by: Option<String>,
folder_path: Option<String>,
) -> Result<Vec<ConversationSummary>, AppCommandError> {
tokio::task::spawn_blocking(move || {
list_conversations_sync(agent_type, search, sort_by, folder_path)
})
.await
.map_err(|e| {
AppCommandError::task_execution_failed("Failed to list conversations")
.with_detail(e.to_string())
})
}
#[tauri::command]
pub async fn get_conversation(
agent_type: AgentType,
conversation_id: String,
) -> Result<ConversationDetail, AppCommandError> {
tokio::task::spawn_blocking(move || -> Result<ConversationDetail, AppCommandError> {
let parser: Box<dyn AgentParser> = match agent_type {
AgentType::ClaudeCode => Box::new(ClaudeParser::new()),
AgentType::Codex => Box::new(CodexParser::new()),
AgentType::OpenCode => Box::new(OpenCodeParser::new()),
AgentType::Gemini => Box::new(GeminiParser::new()),
_ => {
return Err(AppCommandError::invalid_input(
"Conversation parsing is not supported for this agent",
)
.with_detail(format!("agent_type={agent_type}")))
}
};
parser
.get_conversation(&conversation_id)
.map_err(parse_error_to_app_error)
})
.await
.map_err(|e| {
AppCommandError::task_execution_failed("Failed to load conversation")
.with_detail(e.to_string())
})?
}
#[tauri::command]
pub async fn list_folders() -> Result<Vec<FolderInfo>, AppCommandError> {
tokio::task::spawn_blocking(move || -> Result<Vec<FolderInfo>, AppCommandError> {
let all_conversations = list_conversations_sync(None, None, None, None);
Ok(compute_folders(&all_conversations))
})
.await
.map_err(|e| {
AppCommandError::task_execution_failed("Failed to list folders").with_detail(e.to_string())
})?
}
#[tauri::command]
pub async fn get_stats() -> Result<AgentStats, AppCommandError> {
tokio::task::spawn_blocking(move || -> Result<AgentStats, AppCommandError> {
let all_conversations = list_conversations_sync(None, None, None, None);
Ok(compute_stats(&all_conversations))
})
.await
.map_err(|e| {
AppCommandError::task_execution_failed("Failed to compute conversation stats")
.with_detail(e.to_string())
})?
}
#[tauri::command]
pub async fn get_sidebar_data() -> Result<SidebarData, AppCommandError> {
tokio::task::spawn_blocking(move || -> Result<SidebarData, AppCommandError> {
let all_conversations = list_conversations_sync(None, None, None, None);
let folders = compute_folders(&all_conversations);
let stats = compute_stats(&all_conversations);
Ok(SidebarData { folders, stats })
})
.await
.map_err(|e| {
AppCommandError::task_execution_failed("Failed to build sidebar data")
.with_detail(e.to_string())
})?
}
fn compute_folders(all_conversations: &[ConversationSummary]) -> Vec<FolderInfo> {
let mut folder_map: HashMap<String, FolderInfo> = HashMap::new();
for conversation in all_conversations {
let path = conversation
.folder_path
.clone()
.unwrap_or_else(|| "unknown".to_string());
let name = conversation
.folder_name
.clone()
.unwrap_or_else(|| "unknown".to_string());
let entry = folder_map
.entry(path.clone())
.or_insert_with(|| FolderInfo {
path: path.clone(),
name,
agent_types: Vec::new(),
conversation_count: 0,
});
entry.conversation_count += 1;
if !entry.agent_types.contains(&conversation.agent_type) {
entry.agent_types.push(conversation.agent_type);
}
}
let mut folders: Vec<FolderInfo> = folder_map.into_values().collect();
folders.sort_by(|a, b| b.conversation_count.cmp(&a.conversation_count));
folders
}
#[tauri::command]
pub async fn import_local_conversations(
db: tauri::State<'_, AppDatabase>,
folder_id: i32,
) -> Result<ImportResult, AppCommandError> {
let folder = folder_service::get_folder_by_id(&db.conn, folder_id)
.await
.map_err(AppCommandError::from)?
.ok_or_else(|| {
AppCommandError::not_found("Folder not found")
.with_detail(format!("folder_id={folder_id}"))
})?;
import_service::import_local_conversations(&db.conn, folder_id, &folder.path)
.await
.map_err(AppCommandError::from)
}
#[tauri::command]
pub async fn get_folder_conversation(
db: tauri::State<'_, AppDatabase>,
conversation_id: i32,
) -> Result<DbConversationDetail, AppCommandError> {
let summary = conversation_service::get_by_id(&db.conn, conversation_id)
.await
.map_err(AppCommandError::from)?;
let (turns, session_stats) = if let Some(ref ext_id) = summary.external_id {
let at = summary.agent_type;
let eid = ext_id.clone();
tokio::task::spawn_blocking(move || -> Result<_, AppCommandError> {
let parser: Box<dyn AgentParser> = match at {
AgentType::ClaudeCode => Box::new(ClaudeParser::new()),
AgentType::Codex => Box::new(CodexParser::new()),
AgentType::OpenCode => Box::new(OpenCodeParser::new()),
AgentType::Gemini => Box::new(GeminiParser::new()),
_ => return Ok((vec![], None)),
};
// If the external session file doesn't exist yet (e.g., new ACP session
// not yet synced to disk), return empty turns instead of failing.
match parser.get_conversation(&eid) {
Ok(d) => Ok((d.turns, d.session_stats)),
Err(crate::parsers::ParseError::ConversationNotFound(_)) => Ok((vec![], None)),
Err(e) => Err(parse_error_to_app_error(e)),
}
})
.await
.map_err(|e| {
AppCommandError::task_execution_failed(
"Failed to read conversation turns from session file",
)
.with_detail(e.to_string())
})??
} else {
(vec![], None)
};
let mut summary = summary;
summary.message_count = turns.len() as u32;
Ok(DbConversationDetail {
summary,
turns,
session_stats,
})
}
#[tauri::command]
pub async fn create_conversation(
db: tauri::State<'_, AppDatabase>,
folder_id: i32,
agent_type: AgentType,
title: Option<String>,
) -> Result<i32, AppCommandError> {
// Detect current git branch from the folder path
let git_branch = if let Some(folder) = folder_service::get_folder_by_id(&db.conn, folder_id)
.await
.map_err(AppCommandError::from)?
{
detect_git_branch(&folder.path).await
} else {
None
};
let model = conversation_service::create(&db.conn, folder_id, agent_type, title, git_branch)
.await
.map_err(AppCommandError::from)?;
Ok(model.id)
}
async fn detect_git_branch(path: &str) -> Option<String> {
let output = crate::process::tokio_command("git")
.args(["rev-parse", "--abbrev-ref", "HEAD"])
.current_dir(path)
.output()
.await
.ok()?;
if !output.status.success() {
return None;
}
let branch = String::from_utf8_lossy(&output.stdout).trim().to_string();
if branch.is_empty() || branch == "HEAD" {
return None;
}
Some(branch)
}
#[tauri::command]
pub async fn update_conversation_status(
db: tauri::State<'_, AppDatabase>,
conversation_id: i32,
status: String,
) -> Result<(), AppCommandError> {
let status_enum: conversation::ConversationStatus =
serde_json::from_value(serde_json::Value::String(status)).map_err(|e| {
AppCommandError::invalid_input("Invalid conversation status").with_detail(e.to_string())
})?;
conversation_service::update_status(&db.conn, conversation_id, status_enum)
.await
.map_err(AppCommandError::from)
}
#[tauri::command]
pub async fn update_conversation_title(
db: tauri::State<'_, AppDatabase>,
conversation_id: i32,
title: String,
) -> Result<(), AppCommandError> {
conversation_service::update_title(&db.conn, conversation_id, title)
.await
.map_err(AppCommandError::from)
}
#[tauri::command]
pub async fn update_conversation_external_id(
db: tauri::State<'_, AppDatabase>,
conversation_id: i32,
external_id: String,
) -> Result<(), AppCommandError> {
conversation_service::update_external_id(&db.conn, conversation_id, external_id)
.await
.map_err(AppCommandError::from)
}
#[tauri::command]
pub async fn delete_conversation(
db: tauri::State<'_, AppDatabase>,
conversation_id: i32,
) -> Result<(), AppCommandError> {
conversation_service::soft_delete(&db.conn, conversation_id)
.await
.map_err(AppCommandError::from)
}
fn compute_stats(all_conversations: &[ConversationSummary]) -> AgentStats {
let mut total_messages: u32 = 0;
let mut counts: HashMap<AgentType, u32> = HashMap::new();
for conversation in all_conversations {
total_messages += conversation.message_count;
*counts.entry(conversation.agent_type).or_insert(0) += 1;
}
let mut by_agent: Vec<AgentConversationCount> = counts
.into_iter()
.map(|(agent_type, conversation_count)| AgentConversationCount {
agent_type,
conversation_count,
})
.collect();
by_agent.sort_by(|a, b| b.conversation_count.cmp(&a.conversation_count));
AgentStats {
total_conversations: all_conversations.len() as u32,
total_messages,
by_agent,
}
}
fn parse_error_to_app_error(error: ParseError) -> AppCommandError {
match error {
ParseError::ConversationNotFound(id) => {
AppCommandError::not_found("Conversation not found").with_detail(id)
}
ParseError::InvalidData(message) => {
AppCommandError::invalid_input("Invalid conversation data").with_detail(message)
}
ParseError::Io(err) => AppCommandError::io(err),
ParseError::Json(err) => {
AppCommandError::invalid_input("Failed to parse conversation file")
.with_detail(err.to_string())
}
ParseError::Db(err) => AppCommandError::database_error("Database operation failed")
.with_detail(err.to_string()),
}
}