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:
@ -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)
|
||||
}
|
||||
|
||||
@ -72,6 +72,57 @@ async fn fs_chat_attachment_store_imports_outside_file_to_project_managed_path()
|
||||
let _ = tokio::fs::remove_dir_all(outside_root).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn fs_chat_attachment_store_imports_bytes_to_project_managed_path() {
|
||||
let project_root = temp_dir("project-bytes");
|
||||
tokio::fs::create_dir_all(&project_root).await.unwrap();
|
||||
|
||||
let store = FsChatAttachmentStore::new();
|
||||
let project_path = ProjectPath::new(project_root.to_string_lossy().into_owned()).unwrap();
|
||||
let project_id = ProjectId::new_random();
|
||||
let agent_id = AgentId::new_random();
|
||||
let session_id = SessionId::new_random();
|
||||
let attachment_id = ChatAttachmentId::new("attach-bytes").unwrap();
|
||||
|
||||
let attachment = store
|
||||
.import_from_bytes(
|
||||
&project_path,
|
||||
project_id,
|
||||
agent_id,
|
||||
session_id,
|
||||
b"clipboard png bytes",
|
||||
ChatAttachmentImport {
|
||||
id: attachment_id,
|
||||
filename: "clipboard.png".to_owned(),
|
||||
mime: "image/png".to_owned(),
|
||||
source_kind: ChatAttachmentSourceKind::Clipboard,
|
||||
created_at: 43,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(attachment.project_id, project_id);
|
||||
assert_eq!(attachment.agent_id, agent_id);
|
||||
assert_eq!(attachment.session_id, session_id);
|
||||
assert_eq!(attachment.filename, "clipboard.png");
|
||||
assert_eq!(attachment.size_bytes, 19);
|
||||
assert_eq!(attachment.source_kind, ChatAttachmentSourceKind::Clipboard);
|
||||
assert!(attachment
|
||||
.readable_path
|
||||
.starts_with(&format!("{}/.ideai/attachments/", project_root.display())));
|
||||
let stored_bytes = tokio::fs::read(&attachment.readable_path).await.unwrap();
|
||||
assert_eq!(stored_bytes, b"clipboard png bytes");
|
||||
|
||||
let listed = store
|
||||
.list_for_session(&project_path, session_id)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(listed, vec![attachment]);
|
||||
|
||||
let _ = tokio::fs::remove_dir_all(project_root).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn fs_chat_attachment_store_rejects_directories() {
|
||||
let project_root = temp_dir("project-dir");
|
||||
@ -103,3 +154,32 @@ async fn fs_chat_attachment_store_rejects_directories() {
|
||||
let _ = tokio::fs::remove_dir_all(project_root).await;
|
||||
let _ = tokio::fs::remove_dir_all(outside_root).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn fs_chat_attachment_store_rejects_empty_bytes() {
|
||||
let project_root = temp_dir("project-empty-bytes");
|
||||
tokio::fs::create_dir_all(&project_root).await.unwrap();
|
||||
|
||||
let store = FsChatAttachmentStore::new();
|
||||
let err = store
|
||||
.import_from_bytes(
|
||||
&ProjectPath::new(project_root.to_string_lossy().into_owned()).unwrap(),
|
||||
ProjectId::new_random(),
|
||||
AgentId::new_random(),
|
||||
SessionId::new_random(),
|
||||
&[],
|
||||
ChatAttachmentImport {
|
||||
id: ChatAttachmentId::new("attach-empty").unwrap(),
|
||||
filename: "empty.png".to_owned(),
|
||||
mime: "image/png".to_owned(),
|
||||
source_kind: ChatAttachmentSourceKind::Clipboard,
|
||||
created_at: 42,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
|
||||
assert!(matches!(err, ChatAttachmentStoreError::Invalid(_)));
|
||||
|
||||
let _ = tokio::fs::remove_dir_all(project_root).await;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user