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 {
|
||||
|
||||
@ -30,14 +30,28 @@ pub struct ImportChatAttachmentsInput {
|
||||
/// One source attachment import request.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ImportChatAttachmentItem {
|
||||
/// Local source path supplied by the driving adapter.
|
||||
pub path: String,
|
||||
/// Source payload supplied by the driving adapter.
|
||||
pub source: ImportChatAttachmentSource,
|
||||
/// Optional MIME type supplied by the driving adapter.
|
||||
pub mime: Option<String>,
|
||||
/// Origin kind.
|
||||
pub source_kind: ChatAttachmentSourceKind,
|
||||
}
|
||||
|
||||
/// Source payload for a chat attachment import.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum ImportChatAttachmentSource {
|
||||
/// Local source path supplied by the driving adapter.
|
||||
Path(String),
|
||||
/// In-memory bytes supplied by the driving adapter.
|
||||
Bytes {
|
||||
/// Display filename to persist in metadata and storage name.
|
||||
filename: String,
|
||||
/// Raw content bytes.
|
||||
bytes: Vec<u8>,
|
||||
},
|
||||
}
|
||||
|
||||
/// Output of [`ImportChatAttachments::execute`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ImportChatAttachmentsOutput {
|
||||
@ -70,28 +84,44 @@ impl ImportChatAttachments {
|
||||
) -> Result<ImportChatAttachmentsOutput, AppError> {
|
||||
let mut imported = Vec::with_capacity(input.attachments.len());
|
||||
for item in input.attachments {
|
||||
let filename = filename_from_path(&item.path)?;
|
||||
let filename = filename_from_source(&item.source)?;
|
||||
validate_attachment_filename(&filename)?;
|
||||
let mime = sanitize_mime(item.mime.as_deref(), &filename)?;
|
||||
let id = ChatAttachmentId::new(self.ids.new_uuid().to_string())
|
||||
.map_err(|err| AppError::Invalid(err.to_string()))?;
|
||||
let attachment = self
|
||||
.store
|
||||
.import_from_path(
|
||||
&input.project.root,
|
||||
input.project.id,
|
||||
input.agent_id,
|
||||
input.session_id,
|
||||
&LocalPath::new(item.path),
|
||||
ChatAttachmentImport {
|
||||
id,
|
||||
filename,
|
||||
mime,
|
||||
source_kind: item.source_kind,
|
||||
created_at: now(&self.clock),
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
let import = ChatAttachmentImport {
|
||||
id,
|
||||
filename,
|
||||
mime,
|
||||
source_kind: item.source_kind,
|
||||
created_at: now(&self.clock),
|
||||
};
|
||||
let attachment = match item.source {
|
||||
ImportChatAttachmentSource::Path(path) => {
|
||||
self.store
|
||||
.import_from_path(
|
||||
&input.project.root,
|
||||
input.project.id,
|
||||
input.agent_id,
|
||||
input.session_id,
|
||||
&LocalPath::new(path),
|
||||
import,
|
||||
)
|
||||
.await?
|
||||
}
|
||||
ImportChatAttachmentSource::Bytes { bytes, .. } => {
|
||||
self.store
|
||||
.import_from_bytes(
|
||||
&input.project.root,
|
||||
input.project.id,
|
||||
input.agent_id,
|
||||
input.session_id,
|
||||
&bytes,
|
||||
import,
|
||||
)
|
||||
.await?
|
||||
}
|
||||
};
|
||||
imported.push(attachment);
|
||||
}
|
||||
Ok(ImportChatAttachmentsOutput {
|
||||
@ -112,6 +142,13 @@ fn filename_from_path(path: &str) -> Result<String, AppError> {
|
||||
.ok_or_else(|| AppError::Invalid("invalid attachment filename".to_owned()))
|
||||
}
|
||||
|
||||
fn filename_from_source(source: &ImportChatAttachmentSource) -> Result<String, AppError> {
|
||||
match source {
|
||||
ImportChatAttachmentSource::Path(path) => filename_from_path(path),
|
||||
ImportChatAttachmentSource::Bytes { filename, .. } => Ok(filename.clone()),
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_attachment_filename(filename: &str) -> Result<(), AppError> {
|
||||
let lowered = filename.to_ascii_lowercase();
|
||||
let blocked = [
|
||||
|
||||
@ -72,8 +72,8 @@ pub use background::{
|
||||
SpawnBackgroundCommandOutput,
|
||||
};
|
||||
pub use chat_attachments::{
|
||||
ImportChatAttachmentItem, ImportChatAttachments, ImportChatAttachmentsInput,
|
||||
ImportChatAttachmentsOutput, CHAT_ATTACHMENT_MAX_BYTES,
|
||||
ImportChatAttachmentItem, ImportChatAttachmentSource, ImportChatAttachments,
|
||||
ImportChatAttachmentsInput, ImportChatAttachmentsOutput, CHAT_ATTACHMENT_MAX_BYTES,
|
||||
};
|
||||
pub use conversation::{
|
||||
ConversationArchiveProvider, ReadConversationPage, ReadConversationPageInput, RecordTurn,
|
||||
|
||||
@ -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());
|
||||
}
|
||||
|
||||
@ -2971,7 +2971,14 @@ impl From<TerminalSession> for TerminalSessionDto {
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ChatAttachmentInputDto {
|
||||
/// Local source path selected or staged by the driving adapter.
|
||||
pub path: String,
|
||||
#[serde(default)]
|
||||
pub path: Option<String>,
|
||||
/// Display filename for in-memory content, for example clipboard images.
|
||||
#[serde(default)]
|
||||
pub filename: Option<String>,
|
||||
/// Base64-encoded raw content for in-memory attachment import.
|
||||
#[serde(default)]
|
||||
pub content_base64: Option<String>,
|
||||
/// Optional MIME type known by the frontend/OS picker.
|
||||
#[serde(default)]
|
||||
pub mime: Option<String>,
|
||||
|
||||
@ -1110,6 +1110,17 @@ pub trait ChatAttachmentStore: Send + Sync {
|
||||
import: ChatAttachmentImport,
|
||||
) -> Result<ChatAttachment, ChatAttachmentStoreError>;
|
||||
|
||||
/// Writes in-memory bytes into IdeA-managed durable attachment storage.
|
||||
async fn import_from_bytes(
|
||||
&self,
|
||||
root: &ProjectPath,
|
||||
project_id: ProjectId,
|
||||
agent_id: AgentId,
|
||||
session_id: SessionId,
|
||||
bytes: &[u8],
|
||||
import: ChatAttachmentImport,
|
||||
) -> Result<ChatAttachment, ChatAttachmentStoreError>;
|
||||
|
||||
/// Lists attachments imported for a structured/chat session.
|
||||
async fn list_for_session(
|
||||
&self,
|
||||
|
||||
@ -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