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:
@ -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());
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user