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

@ -72,6 +72,52 @@ fn validate_filename(filename: &str) -> Result<(), ChatAttachmentStoreError> {
Ok(())
}
fn validate_size(size_bytes: u64) -> Result<(), ChatAttachmentStoreError> {
if size_bytes == 0 {
return Err(ChatAttachmentStoreError::Invalid(
"chat attachment must not be empty".to_owned(),
));
}
if size_bytes > CHAT_ATTACHMENT_MAX_BYTES {
return Err(ChatAttachmentStoreError::Invalid(format!(
"chat attachment exceeds {CHAT_ATTACHMENT_MAX_BYTES} bytes"
)));
}
Ok(())
}
fn build_attachment(
root: &ProjectPath,
project_id: ProjectId,
agent_id: AgentId,
session_id: SessionId,
size_bytes: u64,
import: ChatAttachmentImport,
) -> Result<(String, PathBuf, ChatAttachment), ChatAttachmentStoreError> {
validate_filename(&import.filename)?;
validate_size(size_bytes)?;
let stored_name = format!("{}-{}", import.id.as_str(), import.filename);
let dest = session_dir(root, session_id).join(&stored_name);
let storage_path = storage_relative(session_id, &stored_name);
let attachment = ChatAttachment {
id: import.id,
project_id,
agent_id,
session_id,
filename: import.filename,
mime: import.mime,
size_bytes,
source_kind: import.source_kind,
readable_path: readable_path(root, &storage_path),
storage_path,
created_at: import.created_at,
};
attachment
.validate()
.map_err(|err| ChatAttachmentStoreError::Invalid(err.to_string()))?;
Ok((stored_name, dest, attachment))
}
async fn write_metadata(
root: &ProjectPath,
session_id: SessionId,
@ -110,7 +156,6 @@ impl ChatAttachmentStore for FsChatAttachmentStore {
source: &LocalPath,
import: ChatAttachmentImport,
) -> Result<ChatAttachment, ChatAttachmentStoreError> {
validate_filename(&import.filename)?;
let source_path = PathBuf::from(source.as_str());
let meta = tokio::fs::metadata(&source_path).await.map_err(io_error)?;
if !meta.is_file() {
@ -118,37 +163,38 @@ impl ChatAttachmentStore for FsChatAttachmentStore {
"chat attachment source must be a file".to_owned(),
));
}
if meta.len() > CHAT_ATTACHMENT_MAX_BYTES {
return Err(ChatAttachmentStoreError::Invalid(format!(
"chat attachment exceeds {CHAT_ATTACHMENT_MAX_BYTES} bytes"
)));
}
let dir = session_dir(root, session_id);
tokio::fs::create_dir_all(&dir).await.map_err(io_error)?;
let stored_name = format!("{}-{}", import.id.as_str(), import.filename);
let dest = dir.join(&stored_name);
let (_, dest, attachment) =
build_attachment(root, project_id, agent_id, session_id, meta.len(), import)?;
tokio::fs::copy(&source_path, &dest)
.await
.map_err(io_error)?;
let storage_path = storage_relative(session_id, &stored_name);
let attachment = ChatAttachment {
id: import.id,
write_metadata(root, session_id, &attachment).await?;
Ok(attachment)
}
async fn import_from_bytes(
&self,
root: &ProjectPath,
project_id: ProjectId,
agent_id: AgentId,
session_id: SessionId,
bytes: &[u8],
import: ChatAttachmentImport,
) -> Result<ChatAttachment, ChatAttachmentStoreError> {
let dir = session_dir(root, session_id);
tokio::fs::create_dir_all(&dir).await.map_err(io_error)?;
let (_, dest, attachment) = build_attachment(
root,
project_id,
agent_id,
session_id,
filename: import.filename,
mime: import.mime,
size_bytes: meta.len(),
source_kind: import.source_kind,
readable_path: readable_path(root, &storage_path),
storage_path,
created_at: import.created_at,
};
attachment
.validate()
.map_err(|err| ChatAttachmentStoreError::Invalid(err.to_string()))?;
bytes.len() as u64,
import,
)?;
tokio::fs::write(dest, bytes).await.map_err(io_error)?;
write_metadata(root, session_id, &attachment).await?;
Ok(attachment)
}