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

@ -4,7 +4,7 @@
//! [`AppState`], map `Result<Output, AppError>` to `Result<ResponseDto,
//! ErrorDto>`. No business logic lives here.
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use base64::engine::general_purpose::{STANDARD, STANDARD_NO_PAD, URL_SAFE, URL_SAFE_NO_PAD};
use base64::Engine;
use serde::{Deserialize, Serialize};
use tauri::ipc::Channel;
@ -2326,22 +2326,17 @@ async fn import_chat_attachments_for_session(
for path in legacy_paths {
if seen_paths.insert(path.clone()) {
items.push(application::ImportChatAttachmentItem {
path,
source: application::ImportChatAttachmentSource::Path(path),
mime: None,
source_kind: domain::ChatAttachmentSourceKind::LocalFile,
});
}
}
for attachment in attachments {
if seen_paths.insert(attachment.path.clone()) {
items.push(application::ImportChatAttachmentItem {
path: attachment.path,
mime: attachment.mime,
source_kind: attachment
.source_kind
.unwrap_or(domain::ChatAttachmentSourceKind::LocalFile),
});
}
let Some(item) = chat_attachment_input_to_item(attachment, &mut seen_paths)? else {
continue;
};
items.push(item);
}
let output = state
@ -2361,6 +2356,71 @@ async fn import_chat_attachments_for_session(
.collect())
}
fn chat_attachment_input_to_item(
attachment: ChatAttachmentInputDto,
seen_paths: &mut std::collections::HashSet<String>,
) -> Result<Option<application::ImportChatAttachmentItem>, ErrorDto> {
let has_path = attachment
.path
.as_deref()
.is_some_and(|path| !path.is_empty());
let has_content = attachment
.content_base64
.as_deref()
.is_some_and(|content| !content.is_empty());
if has_path && has_content {
return Err(ErrorDto::from(AppError::Invalid(
"chat attachment must provide either path or contentBase64, not both".to_owned(),
)));
}
if let Some(path) = attachment.path.filter(|path| !path.is_empty()) {
if !seen_paths.insert(path.clone()) {
return Ok(None);
}
return Ok(Some(application::ImportChatAttachmentItem {
source: application::ImportChatAttachmentSource::Path(path),
mime: attachment.mime,
source_kind: attachment
.source_kind
.unwrap_or(domain::ChatAttachmentSourceKind::LocalFile),
}));
}
if let Some(content_base64) = attachment
.content_base64
.filter(|content| !content.is_empty())
{
let filename = attachment.filename.ok_or_else(|| {
ErrorDto::from(AppError::Invalid(
"chat attachment bytes require filename".to_owned(),
))
})?;
let bytes = decode_attachment_content_base64(&content_base64)?;
return Ok(Some(application::ImportChatAttachmentItem {
source: application::ImportChatAttachmentSource::Bytes { filename, bytes },
mime: attachment.mime,
source_kind: attachment
.source_kind
.unwrap_or(domain::ChatAttachmentSourceKind::Clipboard),
}));
}
Err(ErrorDto::from(AppError::Invalid(
"chat attachment must provide path or contentBase64".to_owned(),
)))
}
fn decode_attachment_content_base64(raw: &str) -> Result<Vec<u8>, ErrorDto> {
STANDARD
.decode(raw)
.or_else(|_| STANDARD_NO_PAD.decode(raw))
.or_else(|_| URL_SAFE.decode(raw))
.or_else(|_| URL_SAFE_NO_PAD.decode(raw))
.map_err(|_| {
ErrorDto::from(AppError::Invalid(
"invalid chat attachment contentBase64".to_owned(),
))
})
}
fn prompt_with_imported_attachments(prompt: &str, attachments: &[ChatAttachmentDto]) -> String {
if attachments.is_empty() {
return prompt.to_owned();