feat(chat): #155 paste image depuis presse-papier dans le composer custom (QA verte)

- frontend: onPaste sur CustomAgentChatView, détection MIME image, chip/preview d'attachment, envoi via le contrat #154 (adapters/agent, ports)

- backend: import d'image par bytes dans le store attachments (commands, chat_attachments app+infra, dto, ports) + tests
This commit is contained in:
2026-08-06 11:01:50 +02:00
parent 88dfbfe471
commit 790c4755be
15 changed files with 791 additions and 84 deletions

View File

@ -30,14 +30,28 @@ pub struct ImportChatAttachmentsInput {
/// One source attachment import request.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ImportChatAttachmentItem {
/// Local source path supplied by the driving adapter.
pub path: String,
/// Source payload supplied by the driving adapter.
pub source: ImportChatAttachmentSource,
/// Optional MIME type supplied by the driving adapter.
pub mime: Option<String>,
/// Origin kind.
pub source_kind: ChatAttachmentSourceKind,
}
/// Source payload for a chat attachment import.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ImportChatAttachmentSource {
/// Local source path supplied by the driving adapter.
Path(String),
/// In-memory bytes supplied by the driving adapter.
Bytes {
/// Display filename to persist in metadata and storage name.
filename: String,
/// Raw content bytes.
bytes: Vec<u8>,
},
}
/// Output of [`ImportChatAttachments::execute`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ImportChatAttachmentsOutput {
@ -70,28 +84,44 @@ impl ImportChatAttachments {
) -> Result<ImportChatAttachmentsOutput, AppError> {
let mut imported = Vec::with_capacity(input.attachments.len());
for item in input.attachments {
let filename = filename_from_path(&item.path)?;
let filename = filename_from_source(&item.source)?;
validate_attachment_filename(&filename)?;
let mime = sanitize_mime(item.mime.as_deref(), &filename)?;
let id = ChatAttachmentId::new(self.ids.new_uuid().to_string())
.map_err(|err| AppError::Invalid(err.to_string()))?;
let attachment = self
.store
.import_from_path(
&input.project.root,
input.project.id,
input.agent_id,
input.session_id,
&LocalPath::new(item.path),
ChatAttachmentImport {
id,
filename,
mime,
source_kind: item.source_kind,
created_at: now(&self.clock),
},
)
.await?;
let import = ChatAttachmentImport {
id,
filename,
mime,
source_kind: item.source_kind,
created_at: now(&self.clock),
};
let attachment = match item.source {
ImportChatAttachmentSource::Path(path) => {
self.store
.import_from_path(
&input.project.root,
input.project.id,
input.agent_id,
input.session_id,
&LocalPath::new(path),
import,
)
.await?
}
ImportChatAttachmentSource::Bytes { bytes, .. } => {
self.store
.import_from_bytes(
&input.project.root,
input.project.id,
input.agent_id,
input.session_id,
&bytes,
import,
)
.await?
}
};
imported.push(attachment);
}
Ok(ImportChatAttachmentsOutput {
@ -112,6 +142,13 @@ fn filename_from_path(path: &str) -> Result<String, AppError> {
.ok_or_else(|| AppError::Invalid("invalid attachment filename".to_owned()))
}
fn filename_from_source(source: &ImportChatAttachmentSource) -> Result<String, AppError> {
match source {
ImportChatAttachmentSource::Path(path) => filename_from_path(path),
ImportChatAttachmentSource::Bytes { filename, .. } => Ok(filename.clone()),
}
}
fn validate_attachment_filename(filename: &str) -> Result<(), AppError> {
let lowered = filename.to_ascii_lowercase();
let blocked = [

View File

@ -72,8 +72,8 @@ pub use background::{
SpawnBackgroundCommandOutput,
};
pub use chat_attachments::{
ImportChatAttachmentItem, ImportChatAttachments, ImportChatAttachmentsInput,
ImportChatAttachmentsOutput, CHAT_ATTACHMENT_MAX_BYTES,
ImportChatAttachmentItem, ImportChatAttachmentSource, ImportChatAttachments,
ImportChatAttachmentsInput, ImportChatAttachmentsOutput, CHAT_ATTACHMENT_MAX_BYTES,
};
pub use conversation::{
ConversationArchiveProvider, ReadConversationPage, ReadConversationPageInput, RecordTurn,

View File

@ -10,11 +10,15 @@ use domain::{
};
use uuid::Uuid;
use application::{ImportChatAttachmentItem, ImportChatAttachments, ImportChatAttachmentsInput};
use application::{
ImportChatAttachmentItem, ImportChatAttachmentSource, ImportChatAttachments,
ImportChatAttachmentsInput,
};
#[derive(Default)]
struct FakeChatAttachments {
calls: Mutex<Vec<(LocalPath, ChatAttachmentImport)>>,
path_calls: Mutex<Vec<(LocalPath, ChatAttachmentImport)>>,
bytes_calls: Mutex<Vec<(Vec<u8>, ChatAttachmentImport)>>,
}
#[async_trait]
@ -28,7 +32,7 @@ impl ChatAttachmentStore for FakeChatAttachments {
source: &LocalPath,
import: ChatAttachmentImport,
) -> Result<ChatAttachment, ChatAttachmentStoreError> {
self.calls
self.path_calls
.lock()
.unwrap()
.push((source.clone(), import.clone()));
@ -58,6 +62,45 @@ impl ChatAttachmentStore for FakeChatAttachments {
})
}
async fn import_from_bytes(
&self,
root: &ProjectPath,
project_id: ProjectId,
agent_id: AgentId,
session_id: SessionId,
bytes: &[u8],
import: ChatAttachmentImport,
) -> Result<ChatAttachment, ChatAttachmentStoreError> {
self.bytes_calls
.lock()
.unwrap()
.push((bytes.to_vec(), import.clone()));
let storage_path = format!(
"agent-chat/{session_id}/{}-{}",
import.id.as_str(),
import.filename
);
let readable_path = format!(
"{}/.ideai/attachments/agent-chat/{session_id}/{}-{}",
root.as_str(),
import.id.as_str(),
import.filename
);
Ok(ChatAttachment {
id: import.id,
project_id,
agent_id,
session_id,
filename: import.filename.clone(),
mime: import.mime.clone(),
size_bytes: bytes.len() as u64,
source_kind: import.source_kind,
storage_path,
readable_path,
created_at: import.created_at,
})
}
async fn list_for_session(
&self,
_root: &ProjectPath,
@ -113,7 +156,7 @@ async fn import_chat_attachments_allocates_metadata_and_uses_store_port() {
agent_id,
session_id,
attachments: vec![ImportChatAttachmentItem {
path: "/outside/photo.png".to_owned(),
source: ImportChatAttachmentSource::Path("/outside/photo.png".to_owned()),
mime: None,
source_kind: ChatAttachmentSourceKind::Clipboard,
}],
@ -134,11 +177,49 @@ async fn import_chat_attachments_allocates_metadata_and_uses_store_port() {
.readable_path
.contains("/.ideai/attachments/agent-chat/"));
let calls = store.calls.lock().unwrap();
let calls = store.path_calls.lock().unwrap();
assert_eq!(calls.len(), 1);
assert_eq!(calls[0].0.as_str(), "/outside/photo.png");
}
#[tokio::test]
async fn import_chat_attachments_routes_clipboard_bytes_to_store_port() {
let store = Arc::new(FakeChatAttachments::default());
let usecase = ImportChatAttachments::new(
Arc::clone(&store) as Arc<dyn ChatAttachmentStore>,
Arc::new(FixedIds),
Arc::new(FixedClock),
);
let output = usecase
.execute(ImportChatAttachmentsInput {
project: project(),
agent_id: AgentId::new_random(),
session_id: SessionId::new_random(),
attachments: vec![ImportChatAttachmentItem {
source: ImportChatAttachmentSource::Bytes {
filename: "clipboard.png".to_owned(),
bytes: b"png bytes".to_vec(),
},
mime: Some("image/png".to_owned()),
source_kind: ChatAttachmentSourceKind::Clipboard,
}],
})
.await
.unwrap();
assert_eq!(output.attachments[0].filename, "clipboard.png");
assert_eq!(output.attachments[0].mime, "image/png");
assert_eq!(
output.attachments[0].source_kind,
ChatAttachmentSourceKind::Clipboard
);
let calls = store.bytes_calls.lock().unwrap();
assert_eq!(calls.len(), 1);
assert_eq!(calls[0].0, b"png bytes");
assert_eq!(calls[0].1.filename, "clipboard.png");
}
#[tokio::test]
async fn import_chat_attachments_rejects_executable_sources_before_store_call() {
let store = Arc::new(FakeChatAttachments::default());
@ -154,7 +235,7 @@ async fn import_chat_attachments_rejects_executable_sources_before_store_call()
agent_id: AgentId::new_random(),
session_id: SessionId::new_random(),
attachments: vec![ImportChatAttachmentItem {
path: "/outside/run.sh".to_owned(),
source: ImportChatAttachmentSource::Path("/outside/run.sh".to_owned()),
mime: None,
source_kind: ChatAttachmentSourceKind::LocalFile,
}],
@ -163,5 +244,6 @@ async fn import_chat_attachments_rejects_executable_sources_before_store_call()
.unwrap_err();
assert_eq!(err.code(), "INVALID");
assert!(store.calls.lock().unwrap().is_empty());
assert!(store.path_calls.lock().unwrap().is_empty());
assert!(store.bytes_calls.lock().unwrap().is_empty());
}