feat(domain,application,infrastructure,backend,web-server,app-tauri,frontend): persistance et reprise des conversations de CLI custom par agent — index durable <projet>/.ideai/conversations/custom-index.json borné aux conversations non vides par agent (conversation_id logique, titre dérivé de la première invite, created/updated_at_ms), port CustomConversationIndex + adaptateur FsCustomConversationIndex, persistence des messages utilisateur et finals agent dans le log de conversation existant, DTO/commandes app-tauri et routes web-server pour lister/rouvrir, CustomAgentChatView: ouverture par défaut sur une nouvelle conversation, menu reprise d'une conversation passée ou nouvelle conversation, replay de l'historique sauvegardé (user+finals) à la réouverture, affichage normal des nouveaux messages après reprise, /clean ouvre une nouvelle conversation, aucune entrée créée si la conversation courante est vide, injection de l'historique dans le contexte agent différée au premier nouveau message après reprise — #363 (QA verte: git diff --check, cargo fmt --all -- --check, cargo tests ciblés infrastructure/application/web-server/app-tauri/backend, npm run typecheck, tests frontend ciblés et CustomAgentChatView, npm run build)
This commit is contained in:
124
crates/infrastructure/src/conversation_log/custom_index.rs
Normal file
124
crates/infrastructure/src/conversation_log/custom_index.rs
Normal file
@ -0,0 +1,124 @@
|
||||
//! Filesystem adapter for the custom CLI conversation catalogue.
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use tokio::io::AsyncWriteExt;
|
||||
|
||||
use domain::ports::StoreError;
|
||||
use domain::ProjectPath;
|
||||
use domain::{AgentId, ConversationId, CustomConversationEntry, CustomConversationIndex};
|
||||
|
||||
use super::{CONVERSATIONS_DIR, IDEAI_DIR};
|
||||
|
||||
const INDEX_FILE: &str = "custom-index.json";
|
||||
|
||||
/// JSON-backed project-scoped custom conversation catalogue.
|
||||
pub struct FsCustomConversationIndex {
|
||||
path: PathBuf,
|
||||
lock: Arc<tokio::sync::Mutex<()>>,
|
||||
}
|
||||
|
||||
impl FsCustomConversationIndex {
|
||||
/// Creates an index rooted below `<project>/.ideai/conversations/`.
|
||||
#[must_use]
|
||||
pub fn new(root: &ProjectPath) -> Self {
|
||||
Self {
|
||||
path: PathBuf::from(root.as_str())
|
||||
.join(IDEAI_DIR)
|
||||
.join(CONVERSATIONS_DIR)
|
||||
.join(INDEX_FILE),
|
||||
lock: Arc::new(tokio::sync::Mutex::new(())),
|
||||
}
|
||||
}
|
||||
|
||||
async fn read_entries(&self) -> Result<Vec<CustomConversationEntry>, StoreError> {
|
||||
match tokio::fs::read(&self.path).await {
|
||||
Ok(bytes) => serde_json::from_slice(&bytes)
|
||||
.map_err(|error| StoreError::Serialization(error.to_string())),
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(Vec::new()),
|
||||
Err(error) => Err(StoreError::Io(error.to_string())),
|
||||
}
|
||||
}
|
||||
|
||||
async fn write_entries(&self, entries: &[CustomConversationEntry]) -> Result<(), StoreError> {
|
||||
let parent = self.path.parent().expect("custom index path has parent");
|
||||
tokio::fs::create_dir_all(parent)
|
||||
.await
|
||||
.map_err(|error| StoreError::Io(error.to_string()))?;
|
||||
let bytes = serde_json::to_vec_pretty(entries)
|
||||
.map_err(|error| StoreError::Serialization(error.to_string()))?;
|
||||
let temporary = self.path.with_extension("json.tmp");
|
||||
let mut file = tokio::fs::File::create(&temporary)
|
||||
.await
|
||||
.map_err(|error| StoreError::Io(error.to_string()))?;
|
||||
file.write_all(&bytes)
|
||||
.await
|
||||
.map_err(|error| StoreError::Io(error.to_string()))?;
|
||||
file.sync_all()
|
||||
.await
|
||||
.map_err(|error| StoreError::Io(error.to_string()))?;
|
||||
drop(file);
|
||||
tokio::fs::rename(&temporary, &self.path)
|
||||
.await
|
||||
.map_err(|error| StoreError::Io(error.to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl CustomConversationIndex for FsCustomConversationIndex {
|
||||
async fn list(
|
||||
&self,
|
||||
agent_id: AgentId,
|
||||
limit: usize,
|
||||
) -> Result<Vec<CustomConversationEntry>, StoreError> {
|
||||
let _guard = self.lock.lock().await;
|
||||
let mut entries = self
|
||||
.read_entries()
|
||||
.await?
|
||||
.into_iter()
|
||||
.filter(|entry| entry.agent_id == agent_id)
|
||||
.collect::<Vec<_>>();
|
||||
entries.sort_by(|left, right| {
|
||||
right
|
||||
.updated_at_ms
|
||||
.cmp(&left.updated_at_ms)
|
||||
.then_with(|| right.created_at_ms.cmp(&left.created_at_ms))
|
||||
});
|
||||
entries.truncate(limit);
|
||||
Ok(entries)
|
||||
}
|
||||
|
||||
async fn get(
|
||||
&self,
|
||||
conversation_id: ConversationId,
|
||||
) -> Result<Option<CustomConversationEntry>, StoreError> {
|
||||
let _guard = self.lock.lock().await;
|
||||
Ok(self
|
||||
.read_entries()
|
||||
.await?
|
||||
.into_iter()
|
||||
.find(|entry| entry.conversation_id == conversation_id))
|
||||
}
|
||||
|
||||
async fn upsert(&self, entry: CustomConversationEntry) -> Result<(), StoreError> {
|
||||
let _guard = self.lock.lock().await;
|
||||
let mut entries = self.read_entries().await?;
|
||||
if let Some(existing) = entries
|
||||
.iter_mut()
|
||||
.find(|candidate| candidate.conversation_id == entry.conversation_id)
|
||||
{
|
||||
// Ownership and the first-prompt title are immutable.
|
||||
if existing.agent_id != entry.agent_id {
|
||||
return Err(StoreError::Serialization(
|
||||
"custom conversation ownership mismatch".to_owned(),
|
||||
));
|
||||
}
|
||||
existing.updated_at_ms = existing.updated_at_ms.max(entry.updated_at_ms);
|
||||
} else {
|
||||
entries.push(entry);
|
||||
}
|
||||
self.write_entries(&entries).await
|
||||
}
|
||||
}
|
||||
@ -46,10 +46,12 @@ use domain::conversation_log::{
|
||||
use domain::ports::StoreError;
|
||||
use domain::project::ProjectPath;
|
||||
|
||||
mod custom_index;
|
||||
mod handoff;
|
||||
mod providers;
|
||||
mod summarizer;
|
||||
|
||||
pub use custom_index::FsCustomConversationIndex;
|
||||
pub use handoff::FsHandoffStore;
|
||||
pub use providers::FsProviderSessionStore;
|
||||
pub use summarizer::{HeuristicHandoffSummarizer, TURN_LINE_MAX_CHARS, WINDOW};
|
||||
|
||||
@ -62,8 +62,8 @@ pub use chat_drafts::FsChatDraftStore;
|
||||
pub use clock::SystemClock;
|
||||
pub use conversation::InMemoryConversationRegistry;
|
||||
pub use conversation_log::{
|
||||
FsConversationLog, FsHandoffStore, FsProviderSessionStore, HeuristicHandoffSummarizer,
|
||||
TURN_LINE_MAX_CHARS, WINDOW,
|
||||
FsConversationLog, FsCustomConversationIndex, FsHandoffStore, FsProviderSessionStore,
|
||||
HeuristicHandoffSummarizer, TURN_LINE_MAX_CHARS, WINDOW,
|
||||
};
|
||||
pub use eventbus::TokioBroadcastEventBus;
|
||||
pub use fileguard::RwFileGuard;
|
||||
|
||||
Reference in New Issue
Block a user