feat(chat): persistance bornee du brouillon custom chat par projet/layout (#182)

This commit is contained in:
2026-08-11 16:24:20 +02:00
parent 25ad44a3d9
commit 94b4ce7e5f
23 changed files with 1552 additions and 70 deletions

View File

@ -0,0 +1,125 @@
//! Filesystem custom chat draft store.
//!
//! Drafts are persisted per project under `<root>/.ideai/chat-drafts.json`.
use std::path::PathBuf;
use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use domain::ports::{ChatDraftStore, StoreError};
use domain::{ChatDraft, ChatDraftKey, ProjectPath};
const IDEAI_DIR: &str = ".ideai";
const CHAT_DRAFTS_FILE: &str = "chat-drafts.json";
const CHAT_DRAFTS_TMP_FILE: &str = "chat-drafts.json.tmp";
const CHAT_DRAFTS_VERSION: u32 = 1;
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct ChatDraftsDoc {
version: u32,
drafts: Vec<ChatDraft>,
}
impl Default for ChatDraftsDoc {
fn default() -> Self {
Self {
version: CHAT_DRAFTS_VERSION,
drafts: Vec::new(),
}
}
}
/// Filesystem-backed [`ChatDraftStore`].
#[derive(Debug, Clone, Default)]
pub struct FsChatDraftStore;
impl FsChatDraftStore {
/// Builds a store.
#[must_use]
pub const fn new() -> Self {
Self
}
}
fn dir(root: &ProjectPath) -> PathBuf {
PathBuf::from(root.as_str()).join(IDEAI_DIR)
}
fn path(root: &ProjectPath) -> PathBuf {
dir(root).join(CHAT_DRAFTS_FILE)
}
fn tmp_path(root: &ProjectPath) -> PathBuf {
dir(root).join(CHAT_DRAFTS_TMP_FILE)
}
fn io_error(err: std::io::Error) -> StoreError {
StoreError::Io(err.to_string())
}
async fn read_doc(root: &ProjectPath) -> Result<ChatDraftsDoc, StoreError> {
match tokio::fs::read(path(root)).await {
Ok(bytes) => {
let doc: ChatDraftsDoc = serde_json::from_slice(&bytes)
.map_err(|err| StoreError::Serialization(err.to_string()))?;
if doc.version != CHAT_DRAFTS_VERSION {
return Err(StoreError::Invalid(format!(
"unsupported chat drafts version {}",
doc.version
)));
}
Ok(doc)
}
Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(ChatDraftsDoc::default()),
Err(err) => Err(io_error(err)),
}
}
async fn write_doc(root: &ProjectPath, doc: &ChatDraftsDoc) -> Result<(), StoreError> {
let dir = dir(root);
tokio::fs::create_dir_all(&dir).await.map_err(io_error)?;
let bytes =
serde_json::to_vec_pretty(doc).map_err(|err| StoreError::Serialization(err.to_string()))?;
let tmp = tmp_path(root);
tokio::fs::write(&tmp, bytes).await.map_err(io_error)?;
tokio::fs::rename(tmp, path(root)).await.map_err(io_error)
}
#[async_trait]
impl ChatDraftStore for FsChatDraftStore {
async fn read(
&self,
root: &ProjectPath,
key: ChatDraftKey,
) -> Result<Option<ChatDraft>, StoreError> {
Ok(read_doc(root)
.await?
.drafts
.into_iter()
.find(|draft| draft.key == key))
}
async fn save(&self, root: &ProjectPath, draft: ChatDraft) -> Result<(), StoreError> {
let mut doc = read_doc(root).await?;
if let Some(slot) = doc
.drafts
.iter_mut()
.find(|existing| existing.key == draft.key)
{
*slot = draft;
} else {
doc.drafts.push(draft);
}
doc.drafts
.sort_by_key(|draft| (draft.key.project_id, draft.key.layout_id, draft.key.node_id));
write_doc(root, &doc).await
}
async fn clear(&self, root: &ProjectPath, key: ChatDraftKey) -> Result<(), StoreError> {
let mut doc = read_doc(root).await?;
doc.drafts.retain(|draft| draft.key != key);
write_doc(root, &doc).await
}
}

View File

@ -15,6 +15,7 @@
pub mod assistant;
pub mod background_task;
pub mod chat_attachments;
pub mod chat_drafts;
pub mod clock;
pub mod conversation;
pub mod conversation_log;
@ -55,6 +56,7 @@ pub use background_task::{
CommandBackgroundRunner,
};
pub use chat_attachments::FsChatAttachmentStore;
pub use chat_drafts::FsChatDraftStore;
pub use clock::SystemClock;
pub use conversation::InMemoryConversationRegistry;
pub use conversation_log::{