fix(chat): restaure le collage des images dans le composer custom CLI (#184)
Le collage d'une image (ex. capture d'écran) échouait en environnement desktop : le presse-papier image n'est souvent pas exposé comme un File synchrone dans clipboardData.files, mais uniquement via l'Async Clipboard API. On ajoute donc un repli sur navigator.clipboard.read() (MIME image/*) quand l'événement de collage ne fournit aucun fichier. Le preventDefault() n'est émis que lorsqu'on a effectivement lu des fichiers issus de l'événement, afin de ne pas bloquer le collage texte quand le repli async ne remonte rien. - refactor clipboardFiles(event) -> dataTransferFiles(DataTransfer) réutilisable - dedupeKey robuste pour les images sans nom (mime + taille + contenu base64) - ReplyChunk::UserPrompt porte désormais les labels d'attachments (DTO + accounting reply_chunk_bytes, skip_serializing_if Vec::is_empty) - tests ciblés frontend (+219) et backend (chat_bridge/dto/attachments) verts QA : tests ciblés verts. Réserve : environnement headless, pas de validation manuelle desktop — validation AppImage à confirmer en suivi. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@ -145,7 +145,9 @@ impl ChatBridge {
|
|||||||
|
|
||||||
fn reply_chunk_bytes(chunk: &ReplyChunk) -> usize {
|
fn reply_chunk_bytes(chunk: &ReplyChunk) -> usize {
|
||||||
match chunk {
|
match chunk {
|
||||||
ReplyChunk::UserPrompt { text } => text.len(),
|
ReplyChunk::UserPrompt { text, attachments } => {
|
||||||
|
text.len() + attachments.iter().map(String::len).sum::<usize>()
|
||||||
|
}
|
||||||
ReplyChunk::TextDelta { text } => text.len(),
|
ReplyChunk::TextDelta { text } => text.len(),
|
||||||
ReplyChunk::ToolActivity { label } => label.len(),
|
ReplyChunk::ToolActivity { label } => label.len(),
|
||||||
ReplyChunk::Progress { progress } => {
|
ReplyChunk::Progress { progress } => {
|
||||||
|
|||||||
@ -2356,6 +2356,10 @@ pub async fn agent_send(
|
|||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
let prompt_for_model = prompt_with_imported_attachments(&prompt, &imported_attachments);
|
let prompt_for_model = prompt_with_imported_attachments(&prompt, &imported_attachments);
|
||||||
|
let attachment_labels = imported_attachments
|
||||||
|
.iter()
|
||||||
|
.map(|attachment| attachment.filename.clone())
|
||||||
|
.collect();
|
||||||
|
|
||||||
// Retain the human submit in the same live scrollback as model chunks so
|
// Retain the human submit in the same live scrollback as model chunks so
|
||||||
// `reattach_agent_chat` can repaint the whole in-flight conversation.
|
// `reattach_agent_chat` can repaint the whole in-flight conversation.
|
||||||
@ -2363,6 +2367,7 @@ pub async fn agent_send(
|
|||||||
&sid,
|
&sid,
|
||||||
ReplyChunk::UserPrompt {
|
ReplyChunk::UserPrompt {
|
||||||
text: prompt.clone(),
|
text: prompt.clone(),
|
||||||
|
attachments: attachment_labels,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@ -61,7 +61,9 @@ fn error_chunk(s: &str) -> ReplyChunk {
|
|||||||
|
|
||||||
fn chunk_bytes(chunk: &ReplyChunk) -> usize {
|
fn chunk_bytes(chunk: &ReplyChunk) -> usize {
|
||||||
match chunk {
|
match chunk {
|
||||||
ReplyChunk::UserPrompt { text } => text.len(),
|
ReplyChunk::UserPrompt { text, attachments } => {
|
||||||
|
text.len() + attachments.iter().map(String::len).sum::<usize>()
|
||||||
|
}
|
||||||
ReplyChunk::TextDelta { text } => text.len(),
|
ReplyChunk::TextDelta { text } => text.len(),
|
||||||
ReplyChunk::ToolActivity { label } => label.len(),
|
ReplyChunk::ToolActivity { label } => label.len(),
|
||||||
ReplyChunk::Progress { progress } => {
|
ReplyChunk::Progress { progress } => {
|
||||||
|
|||||||
@ -27,11 +27,29 @@ use uuid::Uuid;
|
|||||||
fn reply_chunk_user_prompt_serialises_exact_camel_case() {
|
fn reply_chunk_user_prompt_serialises_exact_camel_case() {
|
||||||
let v = serde_json::to_value(ReplyChunk::UserPrompt {
|
let v = serde_json::to_value(ReplyChunk::UserPrompt {
|
||||||
text: "run tests".into(),
|
text: "run tests".into(),
|
||||||
|
attachments: Vec::new(),
|
||||||
})
|
})
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(v, json!({ "kind": "userPrompt", "text": "run tests" }));
|
assert_eq!(v, json!({ "kind": "userPrompt", "text": "run tests" }));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn reply_chunk_user_prompt_serialises_attachment_labels() {
|
||||||
|
let v = serde_json::to_value(ReplyChunk::UserPrompt {
|
||||||
|
text: "describe this".into(),
|
||||||
|
attachments: vec!["clipboard.png".into()],
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
v,
|
||||||
|
json!({
|
||||||
|
"kind": "userPrompt",
|
||||||
|
"text": "describe this",
|
||||||
|
"attachments": ["clipboard.png"]
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn reply_chunk_text_delta_serialises_exact_camel_case() {
|
fn reply_chunk_text_delta_serialises_exact_camel_case() {
|
||||||
let v = serde_json::to_value(ReplyChunk::TextDelta {
|
let v = serde_json::to_value(ReplyChunk::TextDelta {
|
||||||
@ -105,7 +123,10 @@ fn reply_chunk_error_serialises_exact_camel_case() {
|
|||||||
#[test]
|
#[test]
|
||||||
fn reply_chunk_round_trips_through_json_for_every_variant() {
|
fn reply_chunk_round_trips_through_json_for_every_variant() {
|
||||||
for chunk in [
|
for chunk in [
|
||||||
ReplyChunk::UserPrompt { text: "u".into() },
|
ReplyChunk::UserPrompt {
|
||||||
|
text: "u".into(),
|
||||||
|
attachments: vec!["clipboard.png".into()],
|
||||||
|
},
|
||||||
ReplyChunk::TextDelta { text: "x".into() },
|
ReplyChunk::TextDelta { text: "x".into() },
|
||||||
ReplyChunk::ToolActivity {
|
ReplyChunk::ToolActivity {
|
||||||
label: "runs".into(),
|
label: "runs".into(),
|
||||||
@ -140,7 +161,13 @@ fn reply_chunk_deserialises_from_camel_case_wire_payload() {
|
|||||||
// The shape the frontend (or a mock gateway) emits.
|
// The shape the frontend (or a mock gateway) emits.
|
||||||
let back: ReplyChunk =
|
let back: ReplyChunk =
|
||||||
serde_json::from_value(json!({ "kind": "userPrompt", "text": "hi" })).unwrap();
|
serde_json::from_value(json!({ "kind": "userPrompt", "text": "hi" })).unwrap();
|
||||||
assert_eq!(back, ReplyChunk::UserPrompt { text: "hi".into() });
|
assert_eq!(
|
||||||
|
back,
|
||||||
|
ReplyChunk::UserPrompt {
|
||||||
|
text: "hi".into(),
|
||||||
|
attachments: Vec::new()
|
||||||
|
}
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@ -310,7 +337,10 @@ fn reattach_chat_dto_serialises_camel_case_with_typed_scrollback() {
|
|||||||
let dto = ReattachChatDto {
|
let dto = ReattachChatDto {
|
||||||
session_id: "sess-1".into(),
|
session_id: "sess-1".into(),
|
||||||
scrollback: vec![
|
scrollback: vec![
|
||||||
ReplyChunk::UserPrompt { text: "Hi".into() },
|
ReplyChunk::UserPrompt {
|
||||||
|
text: "Hi".into(),
|
||||||
|
attachments: Vec::new(),
|
||||||
|
},
|
||||||
ReplyChunk::TextDelta { text: "Hi".into() },
|
ReplyChunk::TextDelta { text: "Hi".into() },
|
||||||
ReplyChunk::Final {
|
ReplyChunk::Final {
|
||||||
content: "Hi".into(),
|
content: "Hi".into(),
|
||||||
|
|||||||
@ -318,7 +318,9 @@ async fn import_chat_attachments_rejects_empty_clipboard_bytes_before_store_call
|
|||||||
.unwrap_err();
|
.unwrap_err();
|
||||||
|
|
||||||
assert_eq!(err.code(), "INVALID");
|
assert_eq!(err.code(), "INVALID");
|
||||||
assert!(err.to_string().contains("chat attachment must not be empty"));
|
assert!(err
|
||||||
|
.to_string()
|
||||||
|
.contains("chat attachment must not be empty"));
|
||||||
assert!(store.path_calls.lock().unwrap().is_empty());
|
assert!(store.path_calls.lock().unwrap().is_empty());
|
||||||
assert!(store.bytes_calls.lock().unwrap().is_empty());
|
assert!(store.bytes_calls.lock().unwrap().is_empty());
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3347,6 +3347,9 @@ pub enum ReplyChunk {
|
|||||||
UserPrompt {
|
UserPrompt {
|
||||||
/// Submitted prompt text.
|
/// Submitted prompt text.
|
||||||
text: String,
|
text: String,
|
||||||
|
/// Display labels for attachments submitted with the prompt.
|
||||||
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||||
|
attachments: Vec<String>,
|
||||||
},
|
},
|
||||||
/// An assistant text fragment (incremental chat rendering).
|
/// An assistant text fragment (incremental chat rendering).
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
|
|||||||
@ -1674,7 +1674,7 @@ export interface ReplyProgress {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type ReplyChunk =
|
export type ReplyChunk =
|
||||||
| { kind: "userPrompt"; text: string }
|
| { kind: "userPrompt"; text: string; attachments?: string[] }
|
||||||
| { kind: "textDelta"; text: string }
|
| { kind: "textDelta"; text: string }
|
||||||
| { kind: "toolActivity"; label: string }
|
| { kind: "toolActivity"; label: string }
|
||||||
| { kind: "progress"; progress: ReplyProgress }
|
| { kind: "progress"; progress: ReplyProgress }
|
||||||
|
|||||||
@ -57,6 +57,50 @@ function addPluginCommand(
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe("CustomAgentChatView", () => {
|
describe("CustomAgentChatView", () => {
|
||||||
|
it("repaints attachment labels from user prompt scrollback", async () => {
|
||||||
|
const agent = {
|
||||||
|
launchAgentChat: vi.fn(),
|
||||||
|
reattachAgentChat: vi.fn(async (sessionId: string) => ({
|
||||||
|
sessionId,
|
||||||
|
scrollback: [
|
||||||
|
{
|
||||||
|
kind: "userPrompt" as const,
|
||||||
|
text: "describe this",
|
||||||
|
attachments: ["clipboard.png"],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})),
|
||||||
|
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>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(await screen.findByText("describe this")).toBeTruthy();
|
||||||
|
expect(screen.getByText("Fichier: clipboard.png")).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
it("cancels only the current turn and keeps the structured session alive", async () => {
|
it("cancels only the current turn and keeps the structured session alive", async () => {
|
||||||
const agent = {
|
const agent = {
|
||||||
launchAgentChat: vi.fn(),
|
launchAgentChat: vi.fn(),
|
||||||
@ -1406,6 +1450,181 @@ describe("CustomAgentChatView", () => {
|
|||||||
).toBe(true);
|
).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("falls back to the async clipboard API when pasted images are absent from DataTransfer", async () => {
|
||||||
|
const originalClipboard = Object.getOwnPropertyDescriptor(
|
||||||
|
window.navigator,
|
||||||
|
"clipboard",
|
||||||
|
);
|
||||||
|
const read = vi.fn(async () => [
|
||||||
|
{
|
||||||
|
types: ["image/png"],
|
||||||
|
getType: vi.fn(async () => new Blob([new Uint8Array([1, 2, 3])], {
|
||||||
|
type: "image/png",
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
Object.defineProperty(window.navigator, "clipboard", {
|
||||||
|
configurable: true,
|
||||||
|
value: { read },
|
||||||
|
});
|
||||||
|
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 () => {}),
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
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),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
fireEvent.paste(screen.getByLabelText(/message CLI custom/), {
|
||||||
|
clipboardData: {
|
||||||
|
items: [],
|
||||||
|
files: [],
|
||||||
|
getData: () => "",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await screen.findByText(/Fichier joint: clipboard-image-\d+\.png/);
|
||||||
|
expect(read).toHaveBeenCalledTimes(1);
|
||||||
|
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: expect.stringMatching(/^clipboard-image-\d+\.png$/),
|
||||||
|
contentBase64: "AQID",
|
||||||
|
mime: "image/png",
|
||||||
|
sourceKind: "clipboard",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
if (originalClipboard) {
|
||||||
|
Object.defineProperty(window.navigator, "clipboard", originalClipboard);
|
||||||
|
} else {
|
||||||
|
Object.defineProperty(window.navigator, "clipboard", {
|
||||||
|
configurable: true,
|
||||||
|
value: undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows a non-blocking error when the async clipboard image read fails", async () => {
|
||||||
|
const originalClipboard = Object.getOwnPropertyDescriptor(
|
||||||
|
window.navigator,
|
||||||
|
"clipboard",
|
||||||
|
);
|
||||||
|
Object.defineProperty(window.navigator, "clipboard", {
|
||||||
|
configurable: true,
|
||||||
|
value: { read: vi.fn(async () => { throw new Error("clipboard denied"); }) },
|
||||||
|
});
|
||||||
|
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 () => {}),
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
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),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
fireEvent.paste(screen.getByLabelText(/message CLI custom/), {
|
||||||
|
clipboardData: {
|
||||||
|
items: [],
|
||||||
|
files: [],
|
||||||
|
getData: () => "",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect((await screen.findByRole("alert")).textContent).toContain(
|
||||||
|
"clipboard denied",
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
(screen.getByRole("button", { name: "Envoyer" }) as HTMLButtonElement)
|
||||||
|
.disabled,
|
||||||
|
).toBe(true);
|
||||||
|
} finally {
|
||||||
|
if (originalClipboard) {
|
||||||
|
Object.defineProperty(window.navigator, "clipboard", originalClipboard);
|
||||||
|
} else {
|
||||||
|
Object.defineProperty(window.navigator, "clipboard", {
|
||||||
|
configurable: true,
|
||||||
|
value: undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it("sends a pasted clipboard image without requiring text", async () => {
|
it("sends a pasted clipboard image without requiring text", async () => {
|
||||||
const agent = {
|
const agent = {
|
||||||
launchAgentChat: vi.fn(),
|
launchAgentChat: vi.fn(),
|
||||||
|
|||||||
@ -134,10 +134,33 @@ function appendAgentDelta(turns: ChatTurn[], text: string): ChatTurn[] {
|
|||||||
return next;
|
return next;
|
||||||
}
|
}
|
||||||
|
|
||||||
function appendUserPrompt(turns: ChatTurn[], text: string): ChatTurn[] {
|
function attachmentLabels(value: unknown): string[] {
|
||||||
|
return Array.isArray(value)
|
||||||
|
? value.filter((item): item is string => typeof item === "string" && item.trim() !== "")
|
||||||
|
: [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function appendUserPrompt(
|
||||||
|
turns: ChatTurn[],
|
||||||
|
text: string,
|
||||||
|
attachments: string[] = [],
|
||||||
|
): ChatTurn[] {
|
||||||
const last = turns[turns.length - 1];
|
const last = turns[turns.length - 1];
|
||||||
if (last?.role === "user" && last.text === text) return turns;
|
if (
|
||||||
return [...turns, { role: "user", text }];
|
last?.role === "user" &&
|
||||||
|
last.text === text &&
|
||||||
|
JSON.stringify(last.attachments ?? []) === JSON.stringify(attachments)
|
||||||
|
) {
|
||||||
|
return turns;
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
...turns,
|
||||||
|
{
|
||||||
|
role: "user",
|
||||||
|
text,
|
||||||
|
...(attachments.length > 0 ? { attachments } : {}),
|
||||||
|
},
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
function isReplyProgressSource(value: unknown): value is ReplyProgressSource {
|
function isReplyProgressSource(value: unknown): value is ReplyProgressSource {
|
||||||
@ -341,19 +364,21 @@ function bytesToBase64(bytes: Uint8Array): string {
|
|||||||
|
|
||||||
async function clipboardFileToAttachment(file: File): Promise<AttachmentDraft> {
|
async function clipboardFileToAttachment(file: File): Promise<AttachmentDraft> {
|
||||||
const mime = file.type || "application/octet-stream";
|
const mime = file.type || "application/octet-stream";
|
||||||
|
const contentBase64 = bytesToBase64(new Uint8Array(await file.arrayBuffer()));
|
||||||
const filename =
|
const filename =
|
||||||
file.name ||
|
file.name ||
|
||||||
(mime.startsWith("image/")
|
(mime.startsWith("image/")
|
||||||
? `clipboard-image-${Date.now()}.${fileExtension(mime)}`
|
? `clipboard-image-${Date.now()}.${fileExtension(mime)}`
|
||||||
: `clipboard-file-${Date.now()}`);
|
: `clipboard-file-${Date.now()}`);
|
||||||
const contentBase64 = bytesToBase64(new Uint8Array(await file.arrayBuffer()));
|
|
||||||
return {
|
return {
|
||||||
id: `clipboard-${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
id: `clipboard-${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
||||||
label: filename,
|
label: filename,
|
||||||
previewUrl: mime.startsWith("image/")
|
previewUrl: mime.startsWith("image/")
|
||||||
? `data:${mime};base64,${contentBase64}`
|
? `data:${mime};base64,${contentBase64}`
|
||||||
: undefined,
|
: undefined,
|
||||||
dedupeKey: fileMetadataKey(file),
|
dedupeKey: file.name
|
||||||
|
? fileMetadataKey(file)
|
||||||
|
: `${mime}\u0000${file.size}\u0000${contentBase64}`,
|
||||||
input: {
|
input: {
|
||||||
filename,
|
filename,
|
||||||
contentBase64,
|
contentBase64,
|
||||||
@ -367,12 +392,12 @@ function fileMetadataKey(file: File): string {
|
|||||||
return `${file.name}\u0000${file.type}\u0000${file.size}\u0000${file.lastModified}`;
|
return `${file.name}\u0000${file.type}\u0000${file.size}\u0000${file.lastModified}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function clipboardFiles(event: ClipboardEvent<HTMLTextAreaElement>): File[] {
|
function dataTransferFiles(clipboardData: DataTransfer): File[] {
|
||||||
const clipboardItems = event.clipboardData.items
|
const clipboardItems = clipboardData.items
|
||||||
? Array.from(event.clipboardData.items)
|
? Array.from(clipboardData.items)
|
||||||
: [];
|
: [];
|
||||||
const clipboardFiles = event.clipboardData.files
|
const clipboardFiles = clipboardData.files
|
||||||
? Array.from(event.clipboardData.files)
|
? Array.from(clipboardData.files)
|
||||||
: [];
|
: [];
|
||||||
const fromItems = clipboardItems
|
const fromItems = clipboardItems
|
||||||
.filter((item) => item.kind === "file")
|
.filter((item) => item.kind === "file")
|
||||||
@ -387,6 +412,25 @@ function clipboardFiles(event: ClipboardEvent<HTMLTextAreaElement>): File[] {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function asyncClipboardImageFiles(): Promise<File[]> {
|
||||||
|
if (
|
||||||
|
typeof navigator === "undefined" ||
|
||||||
|
typeof navigator.clipboard?.read !== "function"
|
||||||
|
) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const items = await navigator.clipboard.read();
|
||||||
|
const files: File[] = [];
|
||||||
|
for (const item of items) {
|
||||||
|
for (const type of item.types) {
|
||||||
|
if (!type.startsWith("image/")) continue;
|
||||||
|
const blob = await item.getType(type);
|
||||||
|
files.push(new File([blob], "", { type, lastModified: 0 }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return files;
|
||||||
|
}
|
||||||
|
|
||||||
function slashCommandPrefix(draft: string): string | null {
|
function slashCommandPrefix(draft: string): string | null {
|
||||||
if (!draft.startsWith("/")) return null;
|
if (!draft.startsWith("/")) return null;
|
||||||
if (/\s/.test(draft)) return null;
|
if (/\s/.test(draft)) return null;
|
||||||
@ -453,7 +497,11 @@ function foldChunk(turns: ChatTurn[], raw: unknown): ChatTurn[] {
|
|||||||
}
|
}
|
||||||
case "userPrompt":
|
case "userPrompt":
|
||||||
case "UserPrompt":
|
case "UserPrompt":
|
||||||
return appendUserPrompt(turns, String(raw.text ?? raw.prompt ?? ""));
|
return appendUserPrompt(
|
||||||
|
turns,
|
||||||
|
String(raw.text ?? raw.prompt ?? ""),
|
||||||
|
attachmentLabels(raw.attachments),
|
||||||
|
);
|
||||||
default:
|
default:
|
||||||
return [...turns, { role: "unknown", text: unknownChunkLabel(raw) }];
|
return [...turns, { role: "unknown", text: unknownChunkLabel(raw) }];
|
||||||
}
|
}
|
||||||
@ -1059,14 +1107,17 @@ export function CustomAgentChatView({
|
|||||||
async function pasteClipboardFiles(
|
async function pasteClipboardFiles(
|
||||||
event: ClipboardEvent<HTMLTextAreaElement>,
|
event: ClipboardEvent<HTMLTextAreaElement>,
|
||||||
) {
|
) {
|
||||||
const files = clipboardFiles(event);
|
const target = event.currentTarget;
|
||||||
if (files.length === 0) return;
|
|
||||||
|
|
||||||
event.preventDefault();
|
|
||||||
const pastedText = event.clipboardData.getData("text/plain");
|
const pastedText = event.clipboardData.getData("text/plain");
|
||||||
const selectionStart = event.currentTarget.selectionStart;
|
const selectionStart = target.selectionStart;
|
||||||
const selectionEnd = event.currentTarget.selectionEnd;
|
const selectionEnd = target.selectionEnd;
|
||||||
|
const filesFromEvent = dataTransferFiles(event.clipboardData);
|
||||||
try {
|
try {
|
||||||
|
const files =
|
||||||
|
filesFromEvent.length > 0 ? filesFromEvent : await asyncClipboardImageFiles();
|
||||||
|
if (files.length === 0) return;
|
||||||
|
|
||||||
|
if (filesFromEvent.length > 0) event.preventDefault();
|
||||||
const nextAttachments = await Promise.all(
|
const nextAttachments = await Promise.all(
|
||||||
files.map(clipboardFileToAttachment),
|
files.map(clipboardFileToAttachment),
|
||||||
);
|
);
|
||||||
@ -1084,7 +1135,7 @@ export function CustomAgentChatView({
|
|||||||
});
|
});
|
||||||
return unique.length > 0 ? [...prev, ...unique] : prev;
|
return unique.length > 0 ? [...prev, ...unique] : prev;
|
||||||
});
|
});
|
||||||
if (pastedText) {
|
if (filesFromEvent.length > 0 && pastedText) {
|
||||||
if (!draftHydratedRef.current) draftEditedBeforeHydrationRef.current = true;
|
if (!draftHydratedRef.current) draftEditedBeforeHydrationRef.current = true;
|
||||||
setDraft((prev) =>
|
setDraft((prev) =>
|
||||||
prev.slice(0, selectionStart) +
|
prev.slice(0, selectionStart) +
|
||||||
|
|||||||
Reference in New Issue
Block a user