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:
@ -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();
|
||||
|
||||
@ -119,7 +119,26 @@ fn chat_attachment_input_dto_deserialises_camel_case() {
|
||||
}))
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(dto.path, "/tmp/picked.png");
|
||||
assert_eq!(dto.path.as_deref(), Some("/tmp/picked.png"));
|
||||
assert_eq!(dto.filename, None);
|
||||
assert_eq!(dto.content_base64, None);
|
||||
assert_eq!(dto.mime.as_deref(), Some("image/png"));
|
||||
assert_eq!(dto.source_kind, Some(ChatAttachmentSourceKind::Clipboard));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chat_attachment_input_dto_deserialises_clipboard_bytes_camel_case() {
|
||||
let dto: ChatAttachmentInputDto = serde_json::from_value(json!({
|
||||
"filename": "clipboard.png",
|
||||
"contentBase64": "cG5nIGJ5dGVz",
|
||||
"mime": "image/png",
|
||||
"sourceKind": "clipboard"
|
||||
}))
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(dto.path, None);
|
||||
assert_eq!(dto.filename.as_deref(), Some("clipboard.png"));
|
||||
assert_eq!(dto.content_base64.as_deref(), Some("cG5nIGJ5dGVz"));
|
||||
assert_eq!(dto.mime.as_deref(), Some("image/png"));
|
||||
assert_eq!(dto.source_kind, Some(ChatAttachmentSourceKind::Clipboard));
|
||||
}
|
||||
@ -154,6 +173,11 @@ fn import_chat_attachments_request_response_use_camel_case() {
|
||||
"path": "/tmp/picked.png",
|
||||
"mime": "image/png",
|
||||
"sourceKind": "dragDrop"
|
||||
}, {
|
||||
"filename": "clipboard.png",
|
||||
"contentBase64": "cG5nIGJ5dGVz",
|
||||
"mime": "image/png",
|
||||
"sourceKind": "clipboard"
|
||||
}]
|
||||
}))
|
||||
.unwrap();
|
||||
@ -164,6 +188,10 @@ fn import_chat_attachments_request_response_use_camel_case() {
|
||||
request.attachments[0].source_kind,
|
||||
Some(ChatAttachmentSourceKind::DragDrop)
|
||||
);
|
||||
assert_eq!(
|
||||
request.attachments[1].content_base64.as_deref(),
|
||||
Some("cG5nIGJ5dGVz")
|
||||
);
|
||||
|
||||
let response = ImportChatAttachmentsResponseDto {
|
||||
attachments: vec![ChatAttachmentDto {
|
||||
|
||||
Reference in New Issue
Block a user