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:
2026-09-16 23:20:31 +02:00
parent 83b78f438f
commit 943c4525d7
30 changed files with 1794 additions and 109 deletions

View 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
}
}

View File

@ -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};

View File

@ -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;