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:
@ -134,10 +134,33 @@ function appendAgentDelta(turns: ChatTurn[], text: string): ChatTurn[] {
|
||||
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];
|
||||
if (last?.role === "user" && last.text === text) return turns;
|
||||
return [...turns, { role: "user", text }];
|
||||
if (
|
||||
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 {
|
||||
@ -341,19 +364,21 @@ function bytesToBase64(bytes: Uint8Array): string {
|
||||
|
||||
async function clipboardFileToAttachment(file: File): Promise<AttachmentDraft> {
|
||||
const mime = file.type || "application/octet-stream";
|
||||
const contentBase64 = bytesToBase64(new Uint8Array(await file.arrayBuffer()));
|
||||
const filename =
|
||||
file.name ||
|
||||
(mime.startsWith("image/")
|
||||
? `clipboard-image-${Date.now()}.${fileExtension(mime)}`
|
||||
: `clipboard-file-${Date.now()}`);
|
||||
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,
|
||||
dedupeKey: fileMetadataKey(file),
|
||||
dedupeKey: file.name
|
||||
? fileMetadataKey(file)
|
||||
: `${mime}\u0000${file.size}\u0000${contentBase64}`,
|
||||
input: {
|
||||
filename,
|
||||
contentBase64,
|
||||
@ -367,12 +392,12 @@ function fileMetadataKey(file: File): string {
|
||||
return `${file.name}\u0000${file.type}\u0000${file.size}\u0000${file.lastModified}`;
|
||||
}
|
||||
|
||||
function clipboardFiles(event: ClipboardEvent<HTMLTextAreaElement>): File[] {
|
||||
const clipboardItems = event.clipboardData.items
|
||||
? Array.from(event.clipboardData.items)
|
||||
function dataTransferFiles(clipboardData: DataTransfer): File[] {
|
||||
const clipboardItems = clipboardData.items
|
||||
? Array.from(clipboardData.items)
|
||||
: [];
|
||||
const clipboardFiles = event.clipboardData.files
|
||||
? Array.from(event.clipboardData.files)
|
||||
const clipboardFiles = clipboardData.files
|
||||
? Array.from(clipboardData.files)
|
||||
: [];
|
||||
const fromItems = clipboardItems
|
||||
.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 {
|
||||
if (!draft.startsWith("/")) return null;
|
||||
if (/\s/.test(draft)) return null;
|
||||
@ -453,7 +497,11 @@ function foldChunk(turns: ChatTurn[], raw: unknown): ChatTurn[] {
|
||||
}
|
||||
case "userPrompt":
|
||||
case "UserPrompt":
|
||||
return appendUserPrompt(turns, String(raw.text ?? raw.prompt ?? ""));
|
||||
return appendUserPrompt(
|
||||
turns,
|
||||
String(raw.text ?? raw.prompt ?? ""),
|
||||
attachmentLabels(raw.attachments),
|
||||
);
|
||||
default:
|
||||
return [...turns, { role: "unknown", text: unknownChunkLabel(raw) }];
|
||||
}
|
||||
@ -1059,14 +1107,17 @@ export function CustomAgentChatView({
|
||||
async function pasteClipboardFiles(
|
||||
event: ClipboardEvent<HTMLTextAreaElement>,
|
||||
) {
|
||||
const files = clipboardFiles(event);
|
||||
if (files.length === 0) return;
|
||||
|
||||
event.preventDefault();
|
||||
const target = event.currentTarget;
|
||||
const pastedText = event.clipboardData.getData("text/plain");
|
||||
const selectionStart = event.currentTarget.selectionStart;
|
||||
const selectionEnd = event.currentTarget.selectionEnd;
|
||||
const selectionStart = target.selectionStart;
|
||||
const selectionEnd = target.selectionEnd;
|
||||
const filesFromEvent = dataTransferFiles(event.clipboardData);
|
||||
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(
|
||||
files.map(clipboardFileToAttachment),
|
||||
);
|
||||
@ -1084,7 +1135,7 @@ export function CustomAgentChatView({
|
||||
});
|
||||
return unique.length > 0 ? [...prev, ...unique] : prev;
|
||||
});
|
||||
if (pastedText) {
|
||||
if (filesFromEvent.length > 0 && pastedText) {
|
||||
if (!draftHydratedRef.current) draftEditedBeforeHydrationRef.current = true;
|
||||
setDraft((prev) =>
|
||||
prev.slice(0, selectionStart) +
|
||||
|
||||
Reference in New Issue
Block a user