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,
|
//! [`AppState`], map `Result<Output, AppError>` to `Result<ResponseDto,
|
||||||
//! ErrorDto>`. No business logic lives here.
|
//! 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 base64::Engine;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use tauri::ipc::Channel;
|
use tauri::ipc::Channel;
|
||||||
@ -2326,22 +2326,17 @@ async fn import_chat_attachments_for_session(
|
|||||||
for path in legacy_paths {
|
for path in legacy_paths {
|
||||||
if seen_paths.insert(path.clone()) {
|
if seen_paths.insert(path.clone()) {
|
||||||
items.push(application::ImportChatAttachmentItem {
|
items.push(application::ImportChatAttachmentItem {
|
||||||
path,
|
source: application::ImportChatAttachmentSource::Path(path),
|
||||||
mime: None,
|
mime: None,
|
||||||
source_kind: domain::ChatAttachmentSourceKind::LocalFile,
|
source_kind: domain::ChatAttachmentSourceKind::LocalFile,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for attachment in attachments {
|
for attachment in attachments {
|
||||||
if seen_paths.insert(attachment.path.clone()) {
|
let Some(item) = chat_attachment_input_to_item(attachment, &mut seen_paths)? else {
|
||||||
items.push(application::ImportChatAttachmentItem {
|
continue;
|
||||||
path: attachment.path,
|
};
|
||||||
mime: attachment.mime,
|
items.push(item);
|
||||||
source_kind: attachment
|
|
||||||
.source_kind
|
|
||||||
.unwrap_or(domain::ChatAttachmentSourceKind::LocalFile),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let output = state
|
let output = state
|
||||||
@ -2361,6 +2356,71 @@ async fn import_chat_attachments_for_session(
|
|||||||
.collect())
|
.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 {
|
fn prompt_with_imported_attachments(prompt: &str, attachments: &[ChatAttachmentDto]) -> String {
|
||||||
if attachments.is_empty() {
|
if attachments.is_empty() {
|
||||||
return prompt.to_owned();
|
return prompt.to_owned();
|
||||||
|
|||||||
@ -119,7 +119,26 @@ fn chat_attachment_input_dto_deserialises_camel_case() {
|
|||||||
}))
|
}))
|
||||||
.unwrap();
|
.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.mime.as_deref(), Some("image/png"));
|
||||||
assert_eq!(dto.source_kind, Some(ChatAttachmentSourceKind::Clipboard));
|
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",
|
"path": "/tmp/picked.png",
|
||||||
"mime": "image/png",
|
"mime": "image/png",
|
||||||
"sourceKind": "dragDrop"
|
"sourceKind": "dragDrop"
|
||||||
|
}, {
|
||||||
|
"filename": "clipboard.png",
|
||||||
|
"contentBase64": "cG5nIGJ5dGVz",
|
||||||
|
"mime": "image/png",
|
||||||
|
"sourceKind": "clipboard"
|
||||||
}]
|
}]
|
||||||
}))
|
}))
|
||||||
.unwrap();
|
.unwrap();
|
||||||
@ -164,6 +188,10 @@ fn import_chat_attachments_request_response_use_camel_case() {
|
|||||||
request.attachments[0].source_kind,
|
request.attachments[0].source_kind,
|
||||||
Some(ChatAttachmentSourceKind::DragDrop)
|
Some(ChatAttachmentSourceKind::DragDrop)
|
||||||
);
|
);
|
||||||
|
assert_eq!(
|
||||||
|
request.attachments[1].content_base64.as_deref(),
|
||||||
|
Some("cG5nIGJ5dGVz")
|
||||||
|
);
|
||||||
|
|
||||||
let response = ImportChatAttachmentsResponseDto {
|
let response = ImportChatAttachmentsResponseDto {
|
||||||
attachments: vec![ChatAttachmentDto {
|
attachments: vec![ChatAttachmentDto {
|
||||||
|
|||||||
@ -30,14 +30,28 @@ pub struct ImportChatAttachmentsInput {
|
|||||||
/// One source attachment import request.
|
/// One source attachment import request.
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub struct ImportChatAttachmentItem {
|
pub struct ImportChatAttachmentItem {
|
||||||
/// Local source path supplied by the driving adapter.
|
/// Source payload supplied by the driving adapter.
|
||||||
pub path: String,
|
pub source: ImportChatAttachmentSource,
|
||||||
/// Optional MIME type supplied by the driving adapter.
|
/// Optional MIME type supplied by the driving adapter.
|
||||||
pub mime: Option<String>,
|
pub mime: Option<String>,
|
||||||
/// Origin kind.
|
/// Origin kind.
|
||||||
pub source_kind: ChatAttachmentSourceKind,
|
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`].
|
/// Output of [`ImportChatAttachments::execute`].
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub struct ImportChatAttachmentsOutput {
|
pub struct ImportChatAttachmentsOutput {
|
||||||
@ -70,28 +84,44 @@ impl ImportChatAttachments {
|
|||||||
) -> Result<ImportChatAttachmentsOutput, AppError> {
|
) -> Result<ImportChatAttachmentsOutput, AppError> {
|
||||||
let mut imported = Vec::with_capacity(input.attachments.len());
|
let mut imported = Vec::with_capacity(input.attachments.len());
|
||||||
for item in input.attachments {
|
for item in input.attachments {
|
||||||
let filename = filename_from_path(&item.path)?;
|
let filename = filename_from_source(&item.source)?;
|
||||||
validate_attachment_filename(&filename)?;
|
validate_attachment_filename(&filename)?;
|
||||||
let mime = sanitize_mime(item.mime.as_deref(), &filename)?;
|
let mime = sanitize_mime(item.mime.as_deref(), &filename)?;
|
||||||
let id = ChatAttachmentId::new(self.ids.new_uuid().to_string())
|
let id = ChatAttachmentId::new(self.ids.new_uuid().to_string())
|
||||||
.map_err(|err| AppError::Invalid(err.to_string()))?;
|
.map_err(|err| AppError::Invalid(err.to_string()))?;
|
||||||
let attachment = self
|
let import = ChatAttachmentImport {
|
||||||
.store
|
id,
|
||||||
.import_from_path(
|
filename,
|
||||||
&input.project.root,
|
mime,
|
||||||
input.project.id,
|
source_kind: item.source_kind,
|
||||||
input.agent_id,
|
created_at: now(&self.clock),
|
||||||
input.session_id,
|
};
|
||||||
&LocalPath::new(item.path),
|
let attachment = match item.source {
|
||||||
ChatAttachmentImport {
|
ImportChatAttachmentSource::Path(path) => {
|
||||||
id,
|
self.store
|
||||||
filename,
|
.import_from_path(
|
||||||
mime,
|
&input.project.root,
|
||||||
source_kind: item.source_kind,
|
input.project.id,
|
||||||
created_at: now(&self.clock),
|
input.agent_id,
|
||||||
},
|
input.session_id,
|
||||||
)
|
&LocalPath::new(path),
|
||||||
.await?;
|
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);
|
imported.push(attachment);
|
||||||
}
|
}
|
||||||
Ok(ImportChatAttachmentsOutput {
|
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()))
|
.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> {
|
fn validate_attachment_filename(filename: &str) -> Result<(), AppError> {
|
||||||
let lowered = filename.to_ascii_lowercase();
|
let lowered = filename.to_ascii_lowercase();
|
||||||
let blocked = [
|
let blocked = [
|
||||||
|
|||||||
@ -72,8 +72,8 @@ pub use background::{
|
|||||||
SpawnBackgroundCommandOutput,
|
SpawnBackgroundCommandOutput,
|
||||||
};
|
};
|
||||||
pub use chat_attachments::{
|
pub use chat_attachments::{
|
||||||
ImportChatAttachmentItem, ImportChatAttachments, ImportChatAttachmentsInput,
|
ImportChatAttachmentItem, ImportChatAttachmentSource, ImportChatAttachments,
|
||||||
ImportChatAttachmentsOutput, CHAT_ATTACHMENT_MAX_BYTES,
|
ImportChatAttachmentsInput, ImportChatAttachmentsOutput, CHAT_ATTACHMENT_MAX_BYTES,
|
||||||
};
|
};
|
||||||
pub use conversation::{
|
pub use conversation::{
|
||||||
ConversationArchiveProvider, ReadConversationPage, ReadConversationPageInput, RecordTurn,
|
ConversationArchiveProvider, ReadConversationPage, ReadConversationPageInput, RecordTurn,
|
||||||
|
|||||||
@ -10,11 +10,15 @@ use domain::{
|
|||||||
};
|
};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use application::{ImportChatAttachmentItem, ImportChatAttachments, ImportChatAttachmentsInput};
|
use application::{
|
||||||
|
ImportChatAttachmentItem, ImportChatAttachmentSource, ImportChatAttachments,
|
||||||
|
ImportChatAttachmentsInput,
|
||||||
|
};
|
||||||
|
|
||||||
#[derive(Default)]
|
#[derive(Default)]
|
||||||
struct FakeChatAttachments {
|
struct FakeChatAttachments {
|
||||||
calls: Mutex<Vec<(LocalPath, ChatAttachmentImport)>>,
|
path_calls: Mutex<Vec<(LocalPath, ChatAttachmentImport)>>,
|
||||||
|
bytes_calls: Mutex<Vec<(Vec<u8>, ChatAttachmentImport)>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
@ -28,7 +32,7 @@ impl ChatAttachmentStore for FakeChatAttachments {
|
|||||||
source: &LocalPath,
|
source: &LocalPath,
|
||||||
import: ChatAttachmentImport,
|
import: ChatAttachmentImport,
|
||||||
) -> Result<ChatAttachment, ChatAttachmentStoreError> {
|
) -> Result<ChatAttachment, ChatAttachmentStoreError> {
|
||||||
self.calls
|
self.path_calls
|
||||||
.lock()
|
.lock()
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.push((source.clone(), import.clone()));
|
.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(
|
async fn list_for_session(
|
||||||
&self,
|
&self,
|
||||||
_root: &ProjectPath,
|
_root: &ProjectPath,
|
||||||
@ -113,7 +156,7 @@ async fn import_chat_attachments_allocates_metadata_and_uses_store_port() {
|
|||||||
agent_id,
|
agent_id,
|
||||||
session_id,
|
session_id,
|
||||||
attachments: vec![ImportChatAttachmentItem {
|
attachments: vec![ImportChatAttachmentItem {
|
||||||
path: "/outside/photo.png".to_owned(),
|
source: ImportChatAttachmentSource::Path("/outside/photo.png".to_owned()),
|
||||||
mime: None,
|
mime: None,
|
||||||
source_kind: ChatAttachmentSourceKind::Clipboard,
|
source_kind: ChatAttachmentSourceKind::Clipboard,
|
||||||
}],
|
}],
|
||||||
@ -134,11 +177,49 @@ async fn import_chat_attachments_allocates_metadata_and_uses_store_port() {
|
|||||||
.readable_path
|
.readable_path
|
||||||
.contains("/.ideai/attachments/agent-chat/"));
|
.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.len(), 1);
|
||||||
assert_eq!(calls[0].0.as_str(), "/outside/photo.png");
|
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]
|
#[tokio::test]
|
||||||
async fn import_chat_attachments_rejects_executable_sources_before_store_call() {
|
async fn import_chat_attachments_rejects_executable_sources_before_store_call() {
|
||||||
let store = Arc::new(FakeChatAttachments::default());
|
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(),
|
agent_id: AgentId::new_random(),
|
||||||
session_id: SessionId::new_random(),
|
session_id: SessionId::new_random(),
|
||||||
attachments: vec![ImportChatAttachmentItem {
|
attachments: vec![ImportChatAttachmentItem {
|
||||||
path: "/outside/run.sh".to_owned(),
|
source: ImportChatAttachmentSource::Path("/outside/run.sh".to_owned()),
|
||||||
mime: None,
|
mime: None,
|
||||||
source_kind: ChatAttachmentSourceKind::LocalFile,
|
source_kind: ChatAttachmentSourceKind::LocalFile,
|
||||||
}],
|
}],
|
||||||
@ -163,5 +244,6 @@ async fn import_chat_attachments_rejects_executable_sources_before_store_call()
|
|||||||
.unwrap_err();
|
.unwrap_err();
|
||||||
|
|
||||||
assert_eq!(err.code(), "INVALID");
|
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")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct ChatAttachmentInputDto {
|
pub struct ChatAttachmentInputDto {
|
||||||
/// Local source path selected or staged by the driving adapter.
|
/// 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.
|
/// Optional MIME type known by the frontend/OS picker.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub mime: Option<String>,
|
pub mime: Option<String>,
|
||||||
|
|||||||
@ -1110,6 +1110,17 @@ pub trait ChatAttachmentStore: Send + Sync {
|
|||||||
import: ChatAttachmentImport,
|
import: ChatAttachmentImport,
|
||||||
) -> Result<ChatAttachment, ChatAttachmentStoreError>;
|
) -> 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.
|
/// Lists attachments imported for a structured/chat session.
|
||||||
async fn list_for_session(
|
async fn list_for_session(
|
||||||
&self,
|
&self,
|
||||||
|
|||||||
@ -72,6 +72,52 @@ fn validate_filename(filename: &str) -> Result<(), ChatAttachmentStoreError> {
|
|||||||
Ok(())
|
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(
|
async fn write_metadata(
|
||||||
root: &ProjectPath,
|
root: &ProjectPath,
|
||||||
session_id: SessionId,
|
session_id: SessionId,
|
||||||
@ -110,7 +156,6 @@ impl ChatAttachmentStore for FsChatAttachmentStore {
|
|||||||
source: &LocalPath,
|
source: &LocalPath,
|
||||||
import: ChatAttachmentImport,
|
import: ChatAttachmentImport,
|
||||||
) -> Result<ChatAttachment, ChatAttachmentStoreError> {
|
) -> Result<ChatAttachment, ChatAttachmentStoreError> {
|
||||||
validate_filename(&import.filename)?;
|
|
||||||
let source_path = PathBuf::from(source.as_str());
|
let source_path = PathBuf::from(source.as_str());
|
||||||
let meta = tokio::fs::metadata(&source_path).await.map_err(io_error)?;
|
let meta = tokio::fs::metadata(&source_path).await.map_err(io_error)?;
|
||||||
if !meta.is_file() {
|
if !meta.is_file() {
|
||||||
@ -118,37 +163,38 @@ impl ChatAttachmentStore for FsChatAttachmentStore {
|
|||||||
"chat attachment source must be a file".to_owned(),
|
"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);
|
let dir = session_dir(root, session_id);
|
||||||
tokio::fs::create_dir_all(&dir).await.map_err(io_error)?;
|
tokio::fs::create_dir_all(&dir).await.map_err(io_error)?;
|
||||||
let stored_name = format!("{}-{}", import.id.as_str(), import.filename);
|
let (_, dest, attachment) =
|
||||||
let dest = dir.join(&stored_name);
|
build_attachment(root, project_id, agent_id, session_id, meta.len(), import)?;
|
||||||
tokio::fs::copy(&source_path, &dest)
|
tokio::fs::copy(&source_path, &dest)
|
||||||
.await
|
.await
|
||||||
.map_err(io_error)?;
|
.map_err(io_error)?;
|
||||||
|
|
||||||
let storage_path = storage_relative(session_id, &stored_name);
|
write_metadata(root, session_id, &attachment).await?;
|
||||||
let attachment = ChatAttachment {
|
Ok(attachment)
|
||||||
id: import.id,
|
}
|
||||||
|
|
||||||
|
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,
|
project_id,
|
||||||
agent_id,
|
agent_id,
|
||||||
session_id,
|
session_id,
|
||||||
filename: import.filename,
|
bytes.len() as u64,
|
||||||
mime: import.mime,
|
import,
|
||||||
size_bytes: meta.len(),
|
)?;
|
||||||
source_kind: import.source_kind,
|
tokio::fs::write(dest, bytes).await.map_err(io_error)?;
|
||||||
readable_path: readable_path(root, &storage_path),
|
|
||||||
storage_path,
|
|
||||||
created_at: import.created_at,
|
|
||||||
};
|
|
||||||
attachment
|
|
||||||
.validate()
|
|
||||||
.map_err(|err| ChatAttachmentStoreError::Invalid(err.to_string()))?;
|
|
||||||
write_metadata(root, session_id, &attachment).await?;
|
write_metadata(root, session_id, &attachment).await?;
|
||||||
Ok(attachment)
|
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;
|
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]
|
#[tokio::test]
|
||||||
async fn fs_chat_attachment_store_rejects_directories() {
|
async fn fs_chat_attachment_store_rejects_directories() {
|
||||||
let project_root = temp_dir("project-dir");
|
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(project_root).await;
|
||||||
let _ = tokio::fs::remove_dir_all(outside_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;
|
||||||
|
}
|
||||||
|
|||||||
@ -171,6 +171,38 @@ describe("TauriAgentGateway invoke payloads", () => {
|
|||||||
expect(invoke).not.toHaveBeenCalledWith("close_agent_session", expect.anything());
|
expect(invoke).not.toHaveBeenCalledWith("close_agent_session", expect.anything());
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("sendAgentChat forwards structured clipboard attachments to agent_send", async () => {
|
||||||
|
await new TauriAgentGateway().sendAgentChat(
|
||||||
|
"chat-session-1",
|
||||||
|
"",
|
||||||
|
vi.fn(),
|
||||||
|
{
|
||||||
|
attachments: [
|
||||||
|
{
|
||||||
|
filename: "clipboard.png",
|
||||||
|
contentBase64: "AQID",
|
||||||
|
mime: "image/png",
|
||||||
|
sourceKind: "clipboard",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(invoke).toHaveBeenCalledWith("agent_send", {
|
||||||
|
sessionId: "chat-session-1",
|
||||||
|
prompt: "",
|
||||||
|
attachments: [
|
||||||
|
{
|
||||||
|
filename: "clipboard.png",
|
||||||
|
contentBase64: "AQID",
|
||||||
|
mime: "image/png",
|
||||||
|
sourceKind: "clipboard",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
onReply: expect.anything(),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("launchAgentChat returns a handle only when launch_agent confirms cellKind chat", async () => {
|
it("launchAgentChat returns a handle only when launch_agent confirms cellKind chat", async () => {
|
||||||
invoke.mockResolvedValueOnce({
|
invoke.mockResolvedValueOnce({
|
||||||
sessionId: "chat-session-1",
|
sessionId: "chat-session-1",
|
||||||
|
|||||||
@ -32,6 +32,7 @@ import type {
|
|||||||
OpenTerminalOptions,
|
OpenTerminalOptions,
|
||||||
ReattachAgentChatResult,
|
ReattachAgentChatResult,
|
||||||
ReattachResult,
|
ReattachResult,
|
||||||
|
SendAgentChatOptions,
|
||||||
StoppedLiveAgent,
|
StoppedLiveAgent,
|
||||||
TerminalHandle,
|
TerminalHandle,
|
||||||
} from "@/ports";
|
} from "@/ports";
|
||||||
@ -261,12 +262,14 @@ export class TauriAgentGateway implements AgentGateway {
|
|||||||
sessionId: string,
|
sessionId: string,
|
||||||
prompt: string,
|
prompt: string,
|
||||||
onChunk: (chunk: ReplyChunk) => void,
|
onChunk: (chunk: ReplyChunk) => void,
|
||||||
|
options: SendAgentChatOptions = {},
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const channel = new Channel<ReplyChunk>();
|
const channel = new Channel<ReplyChunk>();
|
||||||
channel.onmessage = onChunk;
|
channel.onmessage = onChunk;
|
||||||
await invoke("agent_send", {
|
await invoke("agent_send", {
|
||||||
sessionId,
|
sessionId,
|
||||||
prompt,
|
prompt,
|
||||||
|
...(options.attachments ? { attachments: options.attachments } : {}),
|
||||||
onReply: channel,
|
onReply: channel,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@ -150,6 +150,7 @@ import type {
|
|||||||
RemoteGateway,
|
RemoteGateway,
|
||||||
ReviewPluginPackageInput,
|
ReviewPluginPackageInput,
|
||||||
SaveOpenCodeProviderProfileInput,
|
SaveOpenCodeProviderProfileInput,
|
||||||
|
SendAgentChatOptions,
|
||||||
SkillGateway,
|
SkillGateway,
|
||||||
StoppedLiveAgent,
|
StoppedLiveAgent,
|
||||||
SystemGateway,
|
SystemGateway,
|
||||||
@ -826,6 +827,7 @@ export class MockAgentGateway implements AgentGateway {
|
|||||||
sessionId: string,
|
sessionId: string,
|
||||||
prompt: string,
|
prompt: string,
|
||||||
onChunk: (chunk: ReplyChunk) => void,
|
onChunk: (chunk: ReplyChunk) => void,
|
||||||
|
_options: SendAgentChatOptions = {},
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const chunks = this.chatScrollback.get(sessionId);
|
const chunks = this.chatScrollback.get(sessionId);
|
||||||
if (!chunks) {
|
if (!chunks) {
|
||||||
|
|||||||
@ -205,6 +205,159 @@ describe("CustomAgentChatView", () => {
|
|||||||
expect(screen.getAllByText("hello agent")).toHaveLength(1);
|
expect(screen.getAllByText("hello agent")).toHaveLength(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("pastes a clipboard image as a removable preview chip", async () => {
|
||||||
|
const agent = {
|
||||||
|
launchAgentChat: vi.fn(),
|
||||||
|
reattachAgentChat: vi.fn(async (sessionId: string) => ({
|
||||||
|
sessionId,
|
||||||
|
scrollback: [],
|
||||||
|
})),
|
||||||
|
sendAgentChat: vi.fn(() => new Promise<void>(() => {})),
|
||||||
|
cancelAgentChat: vi.fn(async () => {}),
|
||||||
|
closeAgentChat: vi.fn(async () => {}),
|
||||||
|
};
|
||||||
|
|
||||||
|
render(
|
||||||
|
<DIProvider
|
||||||
|
gateways={{
|
||||||
|
agent,
|
||||||
|
system: { pickFile: vi.fn(async () => null) },
|
||||||
|
} as unknown as Gateways}
|
||||||
|
>
|
||||||
|
<CustomAgentChatView
|
||||||
|
projectId="project-1"
|
||||||
|
agentId="agent-1"
|
||||||
|
agentName="Worker"
|
||||||
|
profile={profile}
|
||||||
|
cwd="/repo"
|
||||||
|
nodeId="node-1"
|
||||||
|
sessionId="chat-session-1"
|
||||||
|
conversationId="conversation-1"
|
||||||
|
onSessionId={vi.fn()}
|
||||||
|
onConversationId={vi.fn()}
|
||||||
|
/>
|
||||||
|
</DIProvider>,
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(agent.reattachAgentChat).toHaveBeenCalledWith(
|
||||||
|
"chat-session-1",
|
||||||
|
expect.any(Function),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const file = new File(["ignored"], "clipboard.png", { type: "image/png" });
|
||||||
|
Object.defineProperty(file, "arrayBuffer", {
|
||||||
|
value: vi.fn(async () => new Uint8Array([1, 2, 3]).buffer),
|
||||||
|
});
|
||||||
|
fireEvent.paste(screen.getByLabelText(/message CLI custom/), {
|
||||||
|
clipboardData: {
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
kind: "file",
|
||||||
|
type: "image/png",
|
||||||
|
getAsFile: () => file,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
getData: () => "",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(await screen.findByText("Fichier joint: clipboard.png")).toBeTruthy();
|
||||||
|
expect(screen.getByTestId("attachment-preview-clipboard.png")).toBeTruthy();
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "retirer clipboard.png" }));
|
||||||
|
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(screen.queryByText("Fichier joint: clipboard.png")).toBeNull(),
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
(screen.getByRole("button", { name: "Envoyer" }) as HTMLButtonElement)
|
||||||
|
.disabled,
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sends a pasted clipboard image without requiring text", async () => {
|
||||||
|
const agent = {
|
||||||
|
launchAgentChat: vi.fn(),
|
||||||
|
reattachAgentChat: vi.fn(async (sessionId: string) => ({
|
||||||
|
sessionId,
|
||||||
|
scrollback: [],
|
||||||
|
})),
|
||||||
|
sendAgentChat: vi.fn(async () => {}),
|
||||||
|
cancelAgentChat: vi.fn(async () => {}),
|
||||||
|
closeAgentChat: vi.fn(async () => {}),
|
||||||
|
};
|
||||||
|
|
||||||
|
render(
|
||||||
|
<DIProvider
|
||||||
|
gateways={{
|
||||||
|
agent,
|
||||||
|
system: { pickFile: vi.fn(async () => null) },
|
||||||
|
} as unknown as Gateways}
|
||||||
|
>
|
||||||
|
<CustomAgentChatView
|
||||||
|
projectId="project-1"
|
||||||
|
agentId="agent-1"
|
||||||
|
agentName="Worker"
|
||||||
|
profile={profile}
|
||||||
|
cwd="/repo"
|
||||||
|
nodeId="node-1"
|
||||||
|
sessionId="chat-session-1"
|
||||||
|
conversationId="conversation-1"
|
||||||
|
onSessionId={vi.fn()}
|
||||||
|
onConversationId={vi.fn()}
|
||||||
|
/>
|
||||||
|
</DIProvider>,
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(agent.reattachAgentChat).toHaveBeenCalledWith(
|
||||||
|
"chat-session-1",
|
||||||
|
expect.any(Function),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const file = new File(["ignored"], "paste-image.png", { type: "image/png" });
|
||||||
|
Object.defineProperty(file, "arrayBuffer", {
|
||||||
|
value: vi.fn(async () => new Uint8Array([1, 2, 3]).buffer),
|
||||||
|
});
|
||||||
|
fireEvent.paste(screen.getByLabelText(/message CLI custom/), {
|
||||||
|
clipboardData: {
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
kind: "file",
|
||||||
|
type: "image/png",
|
||||||
|
getAsFile: () => file,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
getData: () => "",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await screen.findByText("Fichier joint: paste-image.png");
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Envoyer" }));
|
||||||
|
|
||||||
|
await waitFor(() => expect(agent.sendAgentChat).toHaveBeenCalledTimes(1));
|
||||||
|
expect(agent.sendAgentChat).toHaveBeenCalledWith(
|
||||||
|
"chat-session-1",
|
||||||
|
"",
|
||||||
|
expect.any(Function),
|
||||||
|
{
|
||||||
|
attachments: [
|
||||||
|
{
|
||||||
|
filename: "paste-image.png",
|
||||||
|
contentBase64: "AQID",
|
||||||
|
mime: "image/png",
|
||||||
|
sourceKind: "clipboard",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
expect(screen.getByText("Pièce jointe")).toBeTruthy();
|
||||||
|
expect(screen.getByText("Fichier: paste-image.png")).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
it("keeps the chat shell bounded with a scrollable message area and fixed composer", async () => {
|
it("keeps the chat shell bounded with a scrollable message area and fixed composer", async () => {
|
||||||
const agent = {
|
const agent = {
|
||||||
launchAgentChat: vi.fn(() => new Promise<never>(() => {})),
|
launchAgentChat: vi.fn(() => new Promise<never>(() => {})),
|
||||||
|
|||||||
@ -6,11 +6,19 @@
|
|||||||
* deliberately does not try to parse PTY bytes.
|
* deliberately does not try to parse PTY bytes.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import {
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
type ClipboardEvent,
|
||||||
|
} from "react";
|
||||||
|
|
||||||
import type { AgentProfile, GatewayError, ReplyChunk } from "@/domain";
|
import type { AgentProfile, GatewayError, ReplyChunk } from "@/domain";
|
||||||
import { useGateways } from "@/app/di";
|
import { useGateways } from "@/app/di";
|
||||||
import { Button, Spinner, cn } from "@/shared";
|
import { Button, Spinner, cn } from "@/shared";
|
||||||
|
import type { ChatAttachmentInput } from "@/ports";
|
||||||
|
|
||||||
export interface CustomAgentChatViewProps {
|
export interface CustomAgentChatViewProps {
|
||||||
projectId: string;
|
projectId: string;
|
||||||
@ -26,13 +34,20 @@ export interface CustomAgentChatViewProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type ChatTurn =
|
type ChatTurn =
|
||||||
| { role: "user"; text: string; attachment?: string }
|
| { role: "user"; text: string; attachments?: string[] }
|
||||||
| { role: "agent"; text: string; pending?: boolean }
|
| { role: "agent"; text: string; pending?: boolean }
|
||||||
| { role: "tool"; label: string }
|
| { role: "tool"; label: string }
|
||||||
| { role: "final"; text: string }
|
| { role: "final"; text: string }
|
||||||
| { role: "error"; text: string }
|
| { role: "error"; text: string }
|
||||||
| { role: "unknown"; text: string };
|
| { role: "unknown"; text: string };
|
||||||
|
|
||||||
|
interface AttachmentDraft {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
input: ChatAttachmentInput;
|
||||||
|
previewUrl?: string;
|
||||||
|
}
|
||||||
|
|
||||||
function describe(e: unknown): string {
|
function describe(e: unknown): string {
|
||||||
if (e && typeof e === "object" && "message" in e) {
|
if (e && typeof e === "object" && "message" in e) {
|
||||||
return String((e as GatewayError).message);
|
return String((e as GatewayError).message);
|
||||||
@ -99,6 +114,56 @@ function appendUserPrompt(turns: ChatTurn[], text: string): ChatTurn[] {
|
|||||||
return [...turns, { role: "user", text }];
|
return [...turns, { role: "user", text }];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function fileExtension(mime: string): string {
|
||||||
|
if (mime === "image/png") return "png";
|
||||||
|
if (mime === "image/jpeg") return "jpg";
|
||||||
|
if (mime === "image/gif") return "gif";
|
||||||
|
if (mime === "image/webp") return "webp";
|
||||||
|
return "img";
|
||||||
|
}
|
||||||
|
|
||||||
|
function pathBasename(path: string): string {
|
||||||
|
return path.split(/[\\/]/).filter(Boolean).at(-1) ?? path;
|
||||||
|
}
|
||||||
|
|
||||||
|
function bytesToBase64(bytes: Uint8Array): string {
|
||||||
|
let binary = "";
|
||||||
|
const chunkSize = 0x8000;
|
||||||
|
for (let i = 0; i < bytes.length; i += chunkSize) {
|
||||||
|
const chunk = bytes.subarray(i, i + chunkSize);
|
||||||
|
binary += String.fromCharCode(...chunk);
|
||||||
|
}
|
||||||
|
return btoa(binary);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function clipboardImageToAttachment(file: File): Promise<AttachmentDraft> {
|
||||||
|
const mime = file.type || "application/octet-stream";
|
||||||
|
const filename =
|
||||||
|
file.name ||
|
||||||
|
`clipboard-image-${Date.now()}.${fileExtension(mime)}`;
|
||||||
|
const contentBase64 = bytesToBase64(new Uint8Array(await file.arrayBuffer()));
|
||||||
|
return {
|
||||||
|
id: `clipboard-${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
||||||
|
label: filename,
|
||||||
|
previewUrl: mime.startsWith("image/")
|
||||||
|
? `data:${mime};base64,${contentBase64}`
|
||||||
|
: undefined,
|
||||||
|
input: {
|
||||||
|
filename,
|
||||||
|
contentBase64,
|
||||||
|
mime,
|
||||||
|
sourceKind: "clipboard",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function clipboardImageFiles(event: ClipboardEvent<HTMLTextAreaElement>): File[] {
|
||||||
|
return Array.from(event.clipboardData.items)
|
||||||
|
.filter((item) => item.kind === "file" && item.type.startsWith("image/"))
|
||||||
|
.map((item) => item.getAsFile())
|
||||||
|
.filter((file): file is File => Boolean(file));
|
||||||
|
}
|
||||||
|
|
||||||
function foldChunk(turns: ChatTurn[], raw: unknown): ChatTurn[] {
|
function foldChunk(turns: ChatTurn[], raw: unknown): ChatTurn[] {
|
||||||
if (!isReplyRecord(raw)) {
|
if (!isReplyRecord(raw)) {
|
||||||
return [...turns, { role: "unknown", text: unknownChunkLabel(raw) }];
|
return [...turns, { role: "unknown", text: unknownChunkLabel(raw) }];
|
||||||
@ -148,7 +213,7 @@ export function CustomAgentChatView({
|
|||||||
const [currentSession, setCurrentSession] = useState(sessionId);
|
const [currentSession, setCurrentSession] = useState(sessionId);
|
||||||
const [externalSessionId, setExternalSessionId] = useState(sessionId);
|
const [externalSessionId, setExternalSessionId] = useState(sessionId);
|
||||||
const [draft, setDraft] = useState("");
|
const [draft, setDraft] = useState("");
|
||||||
const [attachment, setAttachment] = useState<string | null>(null);
|
const [attachments, setAttachments] = useState<AttachmentDraft[]>([]);
|
||||||
const [opening, setOpening] = useState(false);
|
const [opening, setOpening] = useState(false);
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
@ -412,26 +477,76 @@ export function CustomAgentChatView({
|
|||||||
const canSend = useMemo(
|
const canSend = useMemo(
|
||||||
() =>
|
() =>
|
||||||
supported &&
|
supported &&
|
||||||
Boolean(draft.trim()) &&
|
(Boolean(draft.trim()) || attachments.length > 0) &&
|
||||||
!busy &&
|
!busy &&
|
||||||
!opening,
|
!opening,
|
||||||
[supported, draft, busy, opening],
|
[supported, draft, attachments.length, busy, opening],
|
||||||
);
|
);
|
||||||
|
|
||||||
async function pickAttachment() {
|
async function pickAttachment() {
|
||||||
const path = await system.pickFile();
|
const path = await system.pickFile();
|
||||||
if (path) setAttachment(path);
|
if (path) {
|
||||||
|
setAttachments((prev) => [
|
||||||
|
...prev,
|
||||||
|
{
|
||||||
|
id: `path-${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
||||||
|
label: pathBasename(path),
|
||||||
|
input: { path, sourceKind: "localFile" },
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pasteClipboardImages(
|
||||||
|
event: ClipboardEvent<HTMLTextAreaElement>,
|
||||||
|
) {
|
||||||
|
const files = clipboardImageFiles(event);
|
||||||
|
if (files.length === 0) return;
|
||||||
|
|
||||||
|
event.preventDefault();
|
||||||
|
const pastedText = event.clipboardData.getData("text/plain");
|
||||||
|
const selectionStart = event.currentTarget.selectionStart;
|
||||||
|
const selectionEnd = event.currentTarget.selectionEnd;
|
||||||
|
try {
|
||||||
|
const nextAttachments = await Promise.all(
|
||||||
|
files.map(clipboardImageToAttachment),
|
||||||
|
);
|
||||||
|
setAttachments((prev) => [...prev, ...nextAttachments]);
|
||||||
|
if (pastedText) {
|
||||||
|
setDraft((prev) =>
|
||||||
|
prev.slice(0, selectionStart) +
|
||||||
|
pastedText +
|
||||||
|
prev.slice(selectionEnd),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
setError(describe(e));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function send() {
|
async function send() {
|
||||||
const text = draft.trim();
|
const text = draft.trim();
|
||||||
if (!canSend || !agent.sendAgentChat) return;
|
if (!canSend || !agent.sendAgentChat) return;
|
||||||
const prompt = attachment ? `${text}\n\n[Fichier joint: ${attachment}]` : text;
|
const outgoingAttachments = attachments;
|
||||||
|
const attachmentInputs = outgoingAttachments.map((item) => item.input);
|
||||||
|
const attachmentLabels = outgoingAttachments.map((item) => item.label);
|
||||||
|
const displayText = text || "Pièce jointe";
|
||||||
setDraft("");
|
setDraft("");
|
||||||
setAttachment(null);
|
setAttachments([]);
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
setTurns((prev) => [...prev, { role: "user", text, attachment: attachment ?? undefined }]);
|
setTurns((prev) => [
|
||||||
|
...prev,
|
||||||
|
{
|
||||||
|
role: "user",
|
||||||
|
text: displayText,
|
||||||
|
...(attachmentLabels.length > 0 ? { attachments: attachmentLabels } : {}),
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
const sendTurn = (sid: string) =>
|
||||||
|
attachmentInputs.length > 0
|
||||||
|
? agent.sendAgentChat!(sid, text, receive, { attachments: attachmentInputs })
|
||||||
|
: agent.sendAgentChat!(sid, text, receive);
|
||||||
try {
|
try {
|
||||||
const sid =
|
const sid =
|
||||||
currentSession ??
|
currentSession ??
|
||||||
@ -439,7 +554,7 @@ export function CustomAgentChatView({
|
|||||||
applyScrollback: false,
|
applyScrollback: false,
|
||||||
retryAttachNotFound: true,
|
retryAttachNotFound: true,
|
||||||
}));
|
}));
|
||||||
await agent.sendAgentChat(sid, prompt, receive);
|
await sendTurn(sid);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (isNotFound(e)) {
|
if (isNotFound(e)) {
|
||||||
try {
|
try {
|
||||||
@ -449,7 +564,7 @@ export function CustomAgentChatView({
|
|||||||
applyScrollback: false,
|
applyScrollback: false,
|
||||||
retryAttachNotFound: true,
|
retryAttachNotFound: true,
|
||||||
});
|
});
|
||||||
await agent.sendAgentChat(recoveredSession, prompt, receive);
|
await sendTurn(recoveredSession);
|
||||||
return;
|
return;
|
||||||
} catch (recoveryError) {
|
} catch (recoveryError) {
|
||||||
setBusy(false);
|
setBusy(false);
|
||||||
@ -548,12 +663,36 @@ export function CustomAgentChatView({
|
|||||||
data-testid="custom-agent-chat-composer"
|
data-testid="custom-agent-chat-composer"
|
||||||
className="flex shrink-0 flex-col gap-2 border-t border-border bg-raised/40 p-2"
|
className="flex shrink-0 flex-col gap-2 border-t border-border bg-raised/40 p-2"
|
||||||
>
|
>
|
||||||
{attachment && (
|
{attachments.length > 0 && (
|
||||||
<div className="flex items-center justify-between gap-2 rounded-md border border-border bg-surface px-2 py-1 text-xs text-muted">
|
<div className="flex min-w-0 flex-wrap gap-2">
|
||||||
<span className="truncate">Fichier joint: {attachment}</span>
|
{attachments.map((item) => (
|
||||||
<button type="button" className="text-content" onClick={() => setAttachment(null)}>
|
<div
|
||||||
Retirer
|
key={item.id}
|
||||||
</button>
|
className="flex max-w-full items-center gap-2 rounded-md border border-border bg-surface px-2 py-1 text-xs text-muted"
|
||||||
|
>
|
||||||
|
{item.previewUrl && (
|
||||||
|
<img
|
||||||
|
src={item.previewUrl}
|
||||||
|
alt=""
|
||||||
|
data-testid={`attachment-preview-${item.label}`}
|
||||||
|
className="h-8 w-8 shrink-0 rounded border border-border object-cover"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<span className="min-w-0 truncate">Fichier joint: {item.label}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label={`retirer ${item.label}`}
|
||||||
|
className="shrink-0 text-content"
|
||||||
|
onClick={() =>
|
||||||
|
setAttachments((prev) =>
|
||||||
|
prev.filter((candidate) => candidate.id !== item.id),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Retirer
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="flex min-w-0 items-end gap-2">
|
<div className="flex min-w-0 items-end gap-2">
|
||||||
@ -568,6 +707,7 @@ export function CustomAgentChatView({
|
|||||||
disabled={!supported || opening || busy}
|
disabled={!supported || opening || busy}
|
||||||
placeholder="Message à l'agent…"
|
placeholder="Message à l'agent…"
|
||||||
onChange={(e) => setDraft(e.target.value)}
|
onChange={(e) => setDraft(e.target.value)}
|
||||||
|
onPaste={(e) => void pasteClipboardImages(e)}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === "Enter" && !e.shiftKey) {
|
if (e.key === "Enter" && !e.shiftKey) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@ -640,8 +780,14 @@ function ChatBubble({ turn }: { turn: ChatTurn }) {
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<p className="whitespace-pre-wrap break-words">{turn.text}</p>
|
<p className="whitespace-pre-wrap break-words">{turn.text}</p>
|
||||||
{user && turn.attachment && (
|
{user && turn.attachments && turn.attachments.length > 0 && (
|
||||||
<p className="mt-1 truncate text-xs text-muted">Fichier: {turn.attachment}</p>
|
<div className="mt-1 flex flex-col gap-0.5 text-xs text-muted">
|
||||||
|
{turn.attachments.map((attachment) => (
|
||||||
|
<p key={attachment} className="truncate">
|
||||||
|
Fichier: {attachment}
|
||||||
|
</p>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
{!user && turn.pending && (
|
{!user && turn.pending && (
|
||||||
<span className="mt-1 inline-flex items-center gap-1 text-xs text-muted">
|
<span className="mt-1 inline-flex items-center gap-1 text-xs text-muted">
|
||||||
|
|||||||
@ -145,6 +145,25 @@ export interface CreateAgentInput {
|
|||||||
initialContent?: string;
|
initialContent?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type ChatAttachmentSourceKind =
|
||||||
|
| "localFile"
|
||||||
|
| "clipboard"
|
||||||
|
| "dragDrop"
|
||||||
|
| "other";
|
||||||
|
|
||||||
|
/** Structured attachment intent accepted by `agent_send`. */
|
||||||
|
export interface ChatAttachmentInput {
|
||||||
|
path?: string;
|
||||||
|
filename?: string;
|
||||||
|
contentBase64?: string;
|
||||||
|
mime?: string;
|
||||||
|
sourceKind?: ChatAttachmentSourceKind;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SendAgentChatOptions {
|
||||||
|
attachments?: ChatAttachmentInput[];
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Best-effort enriched details about a CLI conversation (T7), used to enrich the
|
* Best-effort enriched details about a CLI conversation (T7), used to enrich the
|
||||||
* resume popup. Both fields are optional: a missing inspector or a missing
|
* resume popup. Both fields are optional: a missing inspector or a missing
|
||||||
@ -284,6 +303,7 @@ export interface AgentGateway {
|
|||||||
sessionId: string,
|
sessionId: string,
|
||||||
prompt: string,
|
prompt: string,
|
||||||
onChunk: (chunk: ReplyChunk) => void,
|
onChunk: (chunk: ReplyChunk) => void,
|
||||||
|
options?: SendAgentChatOptions,
|
||||||
): Promise<void>;
|
): Promise<void>;
|
||||||
/** Interrupts only the current turn of a live structured session. */
|
/** Interrupts only the current turn of a live structured session. */
|
||||||
cancelAgentChat?(sessionId: string): Promise<void>;
|
cancelAgentChat?(sessionId: string): Promise<void>;
|
||||||
|
|||||||
Reference in New Issue
Block a user