fix: keep uploaded image attachments visible

This commit is contained in:
134 2026-07-10 21:23:22 +08:00
parent 748273d917
commit 363bd1d2fe
3 changed files with 350 additions and 177 deletions

View File

@ -1239,6 +1239,15 @@ type CanvasUploadProgressOptions = {
target?: CanvasComposerUploadProgress["target"];
};
type ComposerUploadedImage = {
imageUrl: string;
name: string;
contentType: string;
assetId?: string;
width?: number;
height?: number;
};
function getCanvasUploadProgressPhaseLabel(phase?: UploadProgress["phase"]) {
switch (phase) {
case "preparing":
@ -3723,7 +3732,7 @@ export function CanvasChatPanel({
// 🆕 辅助函数:在 contenteditable 中插入图片标签
const insertImageTagInEditor = useCallback((url: string, name: string, index: number) => {
const editableDiv = textareaRef.current as HTMLDivElement | null;
if (!editableDiv || !editableDiv.isContentEditable) return;
if (!editableDiv) return;
if (!isValidChatHttpUrl(url)) {
console.warn('[CanvasChat] 跳过非 HTTP 图片标签:', String(url || '').slice(0, 40));
return;
@ -3742,6 +3751,7 @@ export function CanvasChatPanel({
tagSpan.contentEditable = 'false';
tagSpan.setAttribute('data-attachment-index', String(index));
tagSpan.setAttribute('data-attachment-url', url);
tagSpan.setAttribute('data-attachment-name', name);
tagSpan.className = 'inline-flex items-center gap-1 bg-muted/60 hover:bg-muted dark:bg-white/10 dark:hover:bg-white/15 dark:text-zinc-100 rounded-full pl-1 pr-2 py-0.5 mx-0.5 align-middle cursor-default select-none';
const shortName = name.length > 10 ? name.substring(0, 8) + '...' : name;
@ -3754,8 +3764,16 @@ export function CanvasChatPanel({
label.className = 'text-xs text-foreground/80';
label.textContent = shortName;
const removeButton = document.createElement('button');
removeButton.type = 'button';
removeButton.contentEditable = 'false';
removeButton.setAttribute('data-attachment-remove', 'true');
removeButton.className = 'ml-0.5 inline-flex h-4 w-4 items-center justify-center rounded-full text-[11px] leading-none text-foreground/45 hover:bg-black/10 hover:text-foreground dark:hover:bg-white/15';
removeButton.textContent = '×';
tagSpan.appendChild(image);
tagSpan.appendChild(label);
tagSpan.appendChild(removeButton);
// 在光标位置插入,或添加到末尾,并在标签后插入一个零宽空格,方便光标落在标签后继续输入
const selection = window.getSelection();
@ -3810,7 +3828,7 @@ export function CanvasChatPanel({
index: number
) => {
const editableDiv = textareaRef.current as HTMLDivElement | null;
if (!editableDiv || !editableDiv.isContentEditable) return;
if (!editableDiv) return;
// 检查 DOM 中是否已存在该物品的标签
const existingTags = editableDiv.querySelectorAll('[data-picked-object="true"]');
@ -3829,6 +3847,7 @@ export function CanvasChatPanel({
tagSpan.setAttribute('data-picked-object', 'true');
tagSpan.setAttribute('data-object-name', objectName);
tagSpan.setAttribute('data-object-position', objectPosition);
tagSpan.setAttribute('data-attachment-name', objectName);
// 样式与普通图片标签一致
tagSpan.className = 'inline-flex items-center gap-1 bg-muted/60 hover:bg-muted dark:bg-white/10 dark:hover:bg-white/15 dark:text-zinc-100 rounded-full pl-1 pr-2 py-0.5 mx-0.5 align-middle cursor-default select-none';
// 使用图片缩略图,不使用 emoji 或图标
@ -3837,6 +3856,7 @@ export function CanvasChatPanel({
<span class="text-xs text-foreground/80">${objectName}</span>
<span class="text-xs text-foreground/50">·</span>
<span class="text-xs text-foreground/70">${objectPosition}</span>
<button type="button" data-attachment-remove="true" class="ml-0.5 inline-flex h-4 w-4 items-center justify-center rounded-full text-[11px] leading-none text-foreground/45 hover:bg-black/10 hover:text-foreground dark:hover:bg-white/15">×</button>
`;
// 在光标位置插入,或添加到末尾,并在标签后插入一个零宽空格,方便光标落在标签后继续输入
@ -4072,6 +4092,7 @@ export function CanvasChatPanel({
const url = tagSpan.getAttribute('data-attachment-url');
const isPickedObject = tagSpan.getAttribute('data-picked-object') === 'true';
const objectName = tagSpan.getAttribute('data-object-name');
const attachmentName = tagSpan.getAttribute('data-attachment-name');
if (url) {
const rect = tagSpan.getBoundingClientRect();
@ -4096,7 +4117,7 @@ export function CanvasChatPanel({
setTagPreview({
url,
name: objectName || tagSpan.textContent || '图片',
name: objectName || attachmentName || tagSpan.textContent || '图片',
x: rect.left + rect.width / 2,
y: rect.top,
containerBounds,
@ -11631,10 +11652,64 @@ export function CanvasChatPanel({
}
}, [chatId]);
const addUploadedImageAttachmentToComposer = useCallback(
(uploaded: ComposerUploadedImage) => {
setAttachments((prev) => {
if (prev.some((attachment) => attachment.url === uploaded.imageUrl)) {
return prev;
}
const newIndex = prev.length;
setTimeout(() => {
insertImageTagInEditor(uploaded.imageUrl, uploaded.name, newIndex);
(textareaRef.current as HTMLDivElement | null)?.focus();
}, 0);
return [
...prev,
{
url: uploaded.imageUrl,
name: uploaded.name,
contentType: uploaded.contentType,
assetId: uploaded.assetId,
width: uploaded.width,
height: uploaded.height,
},
];
});
},
[insertImageTagInEditor]
);
const updateUploadedImageAttachmentDimensions = useCallback(
(
imageUrl: string,
dimensions: {
width?: number;
height?: number;
originalWidth?: number;
originalHeight?: number;
}
) => {
setAttachments((prev) =>
prev.map((attachment) =>
attachment.url === imageUrl
? {
...attachment,
width: dimensions.originalWidth || dimensions.width || attachment.width,
height: dimensions.originalHeight || dimensions.height || attachment.height,
}
: attachment
)
);
},
[]
);
const uploadImageFileAndAddToCanvas = useCallback(async (
file: File,
nodeIndex: number,
progressOptions?: CanvasUploadProgressOptions
progressOptions?: CanvasUploadProgressOptions,
onUploaded?: (uploaded: ComposerUploadedImage) => void
): Promise<{
imageUrl: string;
name: string;
@ -11680,6 +11755,13 @@ export function CanvasChatPanel({
const imageUrl = data.url as string;
const assetId =
typeof data.assetId === "string" && data.assetId.trim() ? data.assetId.trim() : undefined;
const uploadedBase: ComposerUploadedImage = {
imageUrl,
name: fileName,
contentType: file.type || 'image/jpeg',
assetId,
};
onUploaded?.(uploadedBase);
clearComposerUploadProgress(250);
const dimensions = await getImageDimensions(imageUrl);
const spacing = 20;
@ -11705,10 +11787,7 @@ export function CanvasChatPanel({
});
return {
imageUrl,
name: fileName,
contentType: file.type || 'image/jpeg',
assetId,
...uploadedBase,
width: dimensions.originalWidth || dimensions.width,
height: dimensions.originalHeight || dimensions.height,
};
@ -11769,15 +11848,12 @@ export function CanvasChatPanel({
? data.assetId.trim()
: undefined;
clearComposerUploadProgress(250);
const dimensions = await getImageDimensions(imageUrl);
return {
imageUrl,
name: fileName,
contentType: file.type || "image/jpeg",
assetId,
width: dimensions.originalWidth || dimensions.width,
height: dimensions.originalHeight || dimensions.height,
};
} catch (_error) {
toast.error("图片上传失败");
@ -11814,25 +11890,7 @@ export function CanvasChatPanel({
if (!uploaded) continue;
handledCount += 1;
setAttachments((prev) => {
const newIndex = prev.length;
setTimeout(() => {
insertImageTagInEditor(uploaded.imageUrl, uploaded.name, newIndex);
(textareaRef.current as HTMLDivElement | null)?.focus();
}, 0);
return [
...prev,
{
url: uploaded.imageUrl,
name: uploaded.name,
contentType: uploaded.contentType,
assetId: uploaded.assetId,
width: uploaded.width,
height: uploaded.height,
},
];
});
addUploadedImageAttachmentToComposer(uploaded);
}
} finally {
clearComposerUploadProgress(450);
@ -11962,6 +12020,7 @@ export function CanvasChatPanel({
let handledCount = 0;
try {
for (const [index, file] of files.entries()) {
let addedToComposer = false;
const uploaded = await uploadImageFileAndAddToCanvas(
file,
baseNodeCount + uploadIndex,
@ -11974,6 +12033,10 @@ export function CanvasChatPanel({
fileIndex: index,
fileTotal: files.length,
target: "composer-reference",
},
(instantUploaded) => {
addedToComposer = true;
addUploadedImageAttachmentToComposer(instantUploaded);
}
);
if (!uploaded) continue;
@ -11984,25 +12047,10 @@ export function CanvasChatPanel({
firstImageUrl = uploaded.imageUrl;
}
setAttachments((prev) => {
const newIndex = prev.length;
setTimeout(() => {
insertImageTagInEditor(uploaded.imageUrl, uploaded.name, newIndex);
(textareaRef.current as HTMLDivElement | null)?.focus();
}, 0);
return [
...prev,
{
url: uploaded.imageUrl,
name: uploaded.name,
contentType: uploaded.contentType,
assetId: uploaded.assetId,
width: uploaded.width,
height: uploaded.height,
},
];
});
if (!addedToComposer) {
addUploadedImageAttachmentToComposer(uploaded);
}
updateUploadedImageAttachmentDimensions(uploaded.imageUrl, uploaded);
}
} finally {
clearComposerUploadProgress(450);
@ -12014,17 +12062,18 @@ export function CanvasChatPanel({
void maybeAutoSetCanvasTitle(firstImageUrl);
}, [
addUploadedImageAttachmentToComposer,
agentUploadSlots,
attachments,
clearComposerUploadProgress,
hasAgentUploadSlots,
insertImageTagInEditor,
isClawdMode,
isVideoModel,
maybeAutoSetCanvasTitle,
nodes.length,
updateAttachmentAtAgentSlot,
updateAttachmentAtVideoSlot,
updateUploadedImageAttachmentDimensions,
uploadImageFileForComposerAttachment,
uploadImageFileAndAddToCanvas,
videoFrameSlots,
@ -12127,25 +12176,7 @@ export function CanvasChatPanel({
if (!uploaded) continue;
handledImageCount += 1;
setAttachments((prev) => {
const newIndex = prev.length;
setTimeout(() => {
insertImageTagInEditor(uploaded.imageUrl, uploaded.name, newIndex);
(textareaRef.current as HTMLDivElement | null)?.focus();
}, 0);
return [
...prev,
{
url: uploaded.imageUrl,
name: uploaded.name,
contentType: uploaded.contentType,
assetId: uploaded.assetId,
width: uploaded.width,
height: uploaded.height,
},
];
});
addUploadedImageAttachmentToComposer(uploaded);
}
} finally {
clearComposerUploadProgress(450);
@ -12280,30 +12311,21 @@ export function CanvasChatPanel({
const assetId =
typeof data.assetId === "string" && data.assetId.trim() ? data.assetId.trim() : undefined;
clearComposerUploadProgress(250);
const dimensions = await getImageDimensions(imageUrl);
const uploadedAttachment: ComposerUploadedImage = {
imageUrl,
name: file.name,
contentType: file.type,
assetId,
};
addUploadedImageAttachmentToComposer(uploadedAttachment);
// 记录第一张图片的 URL(用于自动命名和缩略图)
if (!firstImageUrl) {
firstImageUrl = imageUrl;
}
// 添加到附件列表并在编辑器中插入标签
setAttachments(prev => {
const newIndex = prev.length;
// 在编辑器中插入图片标签
setTimeout(() => {
insertImageTagInEditor(imageUrl, file.name, newIndex);
(textareaRef.current as HTMLDivElement | null)?.focus();
}, 0);
return [...prev, {
url: imageUrl,
name: file.name,
contentType: file.type,
assetId,
width: dimensions.originalWidth || dimensions.width,
height: dimensions.originalHeight || dimensions.height,
}];
});
const dimensions = await getImageDimensions(imageUrl);
updateUploadedImageAttachmentDimensions(imageUrl, dimensions);
// 获取图片原始尺寸后添加到画布
// 使用 baseNodeCount + uploadIndex 计算位置,确保多张图片不重叠
@ -15564,44 +15586,19 @@ export function CanvasChatPanel({
</div>
) : null}
{!isWorkspacePresentation && !isDetailGenerationMode && !isVideoWorkspace && activeComposerMode === "image" ? (
<div className="mx-4 mt-3 flex gap-2 overflow-x-auto pb-1 scrollbar-hide">
{attachments.map((attachment, index) => (
<div
key={`${attachment.url}-${index}`}
className="relative h-16 w-16 shrink-0 overflow-hidden rounded-[18px] bg-[#f3f3f4] dark:bg-white/8"
title={attachment.name || `参考图 ${index + 1}`}
>
<img
src={buildPreviewImageUrl(attachment.url, 180)}
alt={attachment.name || `参考图 ${index + 1}`}
className="h-full w-full object-cover"
loading="lazy"
decoding="async"
onError={(event) => {
if (event.currentTarget.src !== attachment.url) {
event.currentTarget.src = attachment.url;
}
}}
/>
</div>
))}
<div className="mx-4 mt-3 flex items-center">
<button
type="button"
className="relative flex h-16 w-16 shrink-0 flex-col items-center justify-center gap-1.5 overflow-hidden rounded-[18px] border border-dashed border-[#d9dde4] bg-[#f3f3f4] text-[#a8adb5] transition-colors hover:border-[#c6ccd6] hover:bg-[#ececee] hover:text-[#7d848e] disabled:cursor-not-allowed disabled:opacity-60 dark:border-white/12 dark:bg-white/8 dark:text-zinc-400 dark:hover:border-white/20 dark:hover:bg-white/12"
className="relative flex h-8 w-8 shrink-0 items-center justify-center overflow-hidden rounded-full border border-dashed border-[#d9dde4] bg-[#f3f3f4] text-[#8f97a3] transition-colors hover:border-[#c6ccd6] hover:bg-[#ececee] hover:text-[#20242a] disabled:cursor-not-allowed disabled:opacity-60 dark:border-white/12 dark:bg-white/8 dark:text-zinc-400 dark:hover:border-white/20 dark:hover:bg-white/12 dark:hover:text-white"
onClick={() => fileInputRef.current?.click()}
disabled={isSubmitting}
title="继续添加参考图"
aria-label="继续添加参考图"
title="添加图片"
aria-label="添加图片"
>
<ImagePlus className="h-5 w-5" />
<span className="text-[13px] font-medium leading-none">
参考图
</span>
<Plus className="h-4 w-4" />
{composerReferenceUploadProgress ? (
<div className="absolute inset-0 flex flex-col items-center justify-center gap-1 bg-black/65 px-1.5 text-white backdrop-blur-[1px]">
<Loader2 className="h-4 w-4 animate-spin" />
<span className="text-[10px] font-medium leading-none">上传中</span>
<span className="text-[11px] font-semibold leading-none tabular-nums">
<div className="absolute inset-0 flex items-center justify-center bg-black/70 text-white backdrop-blur-[1px]">
<span className="text-[10px] font-semibold leading-none tabular-nums">
{Math.max(
1,
Math.min(
@ -15611,7 +15608,7 @@ export function CanvasChatPanel({
)}
%
</span>
<span className="absolute inset-x-2 bottom-2 h-1 overflow-hidden rounded-full bg-white/20">
<span className="absolute inset-x-1 bottom-1 h-0.5 overflow-hidden rounded-full bg-white/20">
<span
className="block h-full rounded-full bg-[#e0ff3a] transition-all duration-300"
style={{
@ -15682,6 +15679,18 @@ export function CanvasChatPanel({
)}
data-placeholder={composerPlaceholder}
onDoubleClick={handleClawdPresetPlaceholderDoubleClick}
onClick={(event) => {
const target = event.target as HTMLElement;
const removeButton = target.closest('[data-attachment-remove]');
if (!removeButton) return;
const tagSpan = removeButton.closest('[data-attachment-url]') as HTMLElement | null;
if (!tagSpan) return;
event.preventDefault();
event.stopPropagation();
removeAttachmentByTagElement(tagSpan);
tagSpan.remove();
setTagPreview(null);
}}
onInput={(e) => {
// 从 contenteditable 中提取纯文本(排除附件标签文本)
const target = e.target as HTMLDivElement;

View File

@ -69,11 +69,12 @@ type PointsSummaryResponse = {
const fetcher = (url: string) => fetch(url).then((res) => res.json());
type UploadQueueStatus = "preparing" | "uploading" | "finalizing";
type UploadQueueStatus = "preparing" | "uploading" | "finalizing" | "failed";
type UploadQueueItem = {
id: string;
name: string;
previewUrl?: string;
progress: number;
status: UploadQueueStatus;
};
@ -94,11 +95,20 @@ function createUploadQueueItem(file: File, fallbackName?: string): UploadQueueIt
return {
id: createUploadQueueId(file),
name: file.name || fallbackName || "图片",
previewUrl: file.type.startsWith("image/") ? URL.createObjectURL(file) : undefined,
progress: 0,
status: "preparing",
};
}
function revokeUploadQueuePreviewUrls(items: UploadQueueItem[]) {
items.forEach((item) => {
if (item.previewUrl) {
URL.revokeObjectURL(item.previewUrl);
}
});
}
function getUploadQueueStatus(phase: UploadProgressPhase): UploadQueueStatus {
if (phase === "preparing") return "preparing";
if (phase === "finalizing" || phase === "complete") return "finalizing";
@ -106,6 +116,7 @@ function getUploadQueueStatus(phase: UploadProgressPhase): UploadQueueStatus {
}
function getUploadQueueStatusText(status: UploadQueueStatus) {
if (status === "failed") return "上传失败";
if (status === "preparing") return "正在处理图片";
if (status === "finalizing") return "正在保存图片";
return "正在上传图片";
@ -492,6 +503,7 @@ function PureMultimodalInput({
const fileInputRef = useRef<HTMLInputElement>(null);
const videoSlotInputRefs = useRef<Array<HTMLInputElement | null>>([]);
const [uploadQueue, setUploadQueue] = useState<UploadQueueItem[]>([]);
const uploadQueueRef = useRef<UploadQueueItem[]>([]);
const [isDragging, setIsDragging] = useState(false);
const [draggedIndex, setDraggedIndex] = useState<number | null>(null);
const [videoSlotUploading, setVideoSlotUploading] = useState<number | null>(null);
@ -524,7 +536,21 @@ function PureMultimodalInput({
const removeUploadQueueItems = useCallback((ids: string[]) => {
if (ids.length === 0) return;
const idSet = new Set(ids);
setUploadQueue((current) => current.filter((item) => !idSet.has(item.id)));
setUploadQueue((current) => {
const removedItems = current.filter((item) => idSet.has(item.id));
revokeUploadQueuePreviewUrls(removedItems);
return current.filter((item) => !idSet.has(item.id));
});
}, []);
useEffect(() => {
uploadQueueRef.current = uploadQueue;
}, [uploadQueue]);
useEffect(() => {
return () => {
revokeUploadQueuePreviewUrls(uploadQueueRef.current);
};
}, []);
const holdAndRemoveUploadQueueItems = useCallback(
@ -546,12 +572,15 @@ function PureMultimodalInput({
uploadQueue.length
);
const activeItem =
uploadQueue.find((item) => item.status === "failed") ||
uploadQueue.find((item) => item.progress < 100) ||
uploadQueue[uploadQueue.length - 1];
const hasFailed = uploadQueue.some((item) => item.status === "failed");
return {
count: uploadQueue.length,
fileName: activeItem?.name || "图片",
hasFailed,
percent: Math.max(0, Math.min(100, totalProgress)),
statusText: getUploadQueueStatusText(activeItem?.status || "uploading"),
};
@ -833,10 +862,16 @@ function PureMultimodalInput({
}
const { error } = await response.json();
console.error(`[上传] 服务器返回错误:`, error);
updateUploadQueueItem(uploadQueueItemId, {
status: "failed",
});
toast.error(error);
return null;
} catch (_error) {
console.error("[上传] 失败:", _error);
updateUploadQueueItem(uploadQueueItemId, {
status: "failed",
});
toast.error(_error instanceof Error ? _error.message : "文件上传失败,请稍后重试");
return null;
}
@ -888,29 +923,42 @@ function PureMultimodalInput({
return;
}
const queueItem = createUploadQueueItem(file);
const existingQueueItemId = videoSlotUploadItemIds[slotIndex];
if (existingQueueItemId) {
removeUploadQueueItems([existingQueueItemId]);
}
setVideoSlotUploading(slotIndex);
setVideoSlotUploadItemIds((current) => ({
...current,
[slotIndex]: queueItem.id,
}));
setUploadQueue((prev) => [...prev, queueItem]);
let uploaded: Attachment | null = null;
try {
const uploaded = await uploadFile(file, queueItem.id);
uploaded = await uploadFile(file, queueItem.id);
if (uploaded) {
updateAttachmentAtSlot(slotIndex, uploaded as Attachment);
}
} finally {
setVideoSlotUploading(null);
setVideoSlotUploadItemIds((current) => {
const next = { ...current };
delete next[slotIndex];
return next;
});
await holdAndRemoveUploadQueueItems([queueItem.id]);
if (uploaded) {
setVideoSlotUploadItemIds((current) => {
const next = { ...current };
delete next[slotIndex];
return next;
});
await holdAndRemoveUploadQueueItems([queueItem.id]);
}
event.currentTarget.value = "";
}
},
[holdAndRemoveUploadQueueItems, updateAttachmentAtSlot, uploadFile]
[
holdAndRemoveUploadQueueItems,
removeUploadQueueItems,
updateAttachmentAtSlot,
uploadFile,
videoSlotUploadItemIds,
]
);
// 🆕 视频模式下的图片数量限制
@ -1021,29 +1069,44 @@ function PureMultimodalInput({
setUploadQueue((prev) => [...prev, ...queueItems]);
let successfulUploadIds: string[] = [];
try {
const uploadPromises = filesToUpload.map((file, index) =>
uploadFile(file, queueItems[index]?.id)
);
const uploadedAttachments = await Promise.all(uploadPromises);
successfulUploadIds = uploadedAttachments.flatMap((attachment, index) =>
attachment ? [queueItems[index]!.id] : []
);
const successfullyUploadedAttachments = uploadedAttachments.filter(
(attachment): attachment is Attachment => attachment !== null
);
setAttachments((currentAttachments) => [
...currentAttachments,
...successfullyUploadedAttachments,
]);
if (successfullyUploadedAttachments.length > 0) {
setAttachments((currentAttachments) => [
...currentAttachments,
...successfullyUploadedAttachments,
]);
}
} catch (error) {
console.error("Error uploading files!", error);
queueItems.forEach((item) =>
updateUploadQueueItem(item.id, { status: "failed" })
);
} finally {
await holdAndRemoveUploadQueueItems(queueItems.map((item) => item.id));
await holdAndRemoveUploadQueueItems(successfulUploadIds);
if (fileInputRef.current) {
fileInputRef.current.value = "";
}
}
},
[enforceVideoImageLimit, holdAndRemoveUploadQueueItems, setAttachments, uploadFile]
[
enforceVideoImageLimit,
holdAndRemoveUploadQueueItems,
setAttachments,
updateUploadQueueItem,
uploadFile,
]
);
const handlePaste = useCallback(
@ -1079,11 +1142,15 @@ function PureMultimodalInput({
);
setUploadQueue((prev) => [...prev, ...queueItems]);
let successfulUploadIds: string[] = [];
const uploadPromises = filesToUpload.map((file, index) =>
uploadFile(file, queueItems[index]?.id)
);
const uploadedAttachments = await Promise.all(uploadPromises);
successfulUploadIds = uploadedAttachments.flatMap((attachment, index) =>
attachment ? [queueItems[index]!.id] : []
);
const successfullyUploadedAttachments = uploadedAttachments.filter(
(attachment): attachment is Attachment =>
attachment !== null &&
@ -1091,18 +1158,28 @@ function PureMultimodalInput({
attachment.contentType !== undefined
);
setAttachments((curr) => [
...curr,
...successfullyUploadedAttachments,
]);
if (successfullyUploadedAttachments.length > 0) {
setAttachments((curr) => [
...curr,
...successfullyUploadedAttachments,
]);
}
await holdAndRemoveUploadQueueItems(successfulUploadIds);
} catch (error) {
console.error("Error uploading pasted images:", error);
queueItems.forEach((item) =>
updateUploadQueueItem(item.id, { status: "failed" })
);
toast.error("粘贴的图片上传失败");
} finally {
await holdAndRemoveUploadQueueItems(queueItems.map((item) => item.id));
}
},
[enforceVideoImageLimit, holdAndRemoveUploadQueueItems, setAttachments, uploadFile]
[
enforceVideoImageLimit,
holdAndRemoveUploadQueueItems,
setAttachments,
updateUploadQueueItem,
uploadFile,
]
);
// Add paste event listener to textarea
@ -1179,27 +1256,42 @@ function PureMultimodalInput({
// 添加到上传队列
setUploadQueue((prev) => [...prev, ...queueItems]);
let successfulUploadIds: string[] = [];
try {
const uploadedAttachments = await Promise.all(
filesToUpload.map((file, index) => uploadFile(file, queueItems[index]?.id))
);
successfulUploadIds = uploadedAttachments.flatMap((attachment, index) =>
attachment ? [queueItems[index]!.id] : []
);
const validAttachments = uploadedAttachments.filter(
(attachment): attachment is Attachment => attachment !== null
);
setAttachments((currentAttachments) => [
...currentAttachments,
...validAttachments,
]);
if (validAttachments.length > 0) {
setAttachments((currentAttachments) => [
...currentAttachments,
...validAttachments,
]);
}
} catch (error) {
console.error("拖拽上传失败:", error);
queueItems.forEach((item) =>
updateUploadQueueItem(item.id, { status: "failed" })
);
toast.error("拖拽的图片上传失败");
} finally {
await holdAndRemoveUploadQueueItems(queueItems.map((item) => item.id));
await holdAndRemoveUploadQueueItems(successfulUploadIds);
}
},
[enforceVideoImageLimit, holdAndRemoveUploadQueueItems, setAttachments, uploadFile]
[
enforceVideoImageLimit,
holdAndRemoveUploadQueueItems,
setAttachments,
updateUploadQueueItem,
uploadFile,
]
);
// 🆕 拖拽排序处理函数
@ -1309,7 +1401,9 @@ function PureMultimodalInput({
>
<div className="mb-1.5 flex items-center justify-between gap-3 text-xs">
<span className="min-w-0 truncate font-medium text-foreground">
{uploadProgressSummary.count > 1
{uploadProgressSummary.hasFailed
? uploadProgressSummary.statusText
: uploadProgressSummary.count > 1
? `正在上传 ${uploadProgressSummary.count} 张图片`
: uploadProgressSummary.statusText}
</span>
@ -1334,10 +1428,11 @@ function PureMultimodalInput({
{videoFrameSlots.map((label, index) => {
const slotAttachment = attachments[index];
const disabled = index > 0 && !attachments[index - 1];
const uploadItem = uploadQueue.find(
(item) => item.id === videoSlotUploadItemIds[index]
);
const isUploading = videoSlotUploading === index;
const uploadItem = isUploading
? uploadQueue.find((item) => item.id === videoSlotUploadItemIds[index])
: null;
const isUploadFailed = uploadItem?.status === "failed";
const uploadProgress = uploadItem?.progress ?? 0;
return (
<button
@ -1373,6 +1468,55 @@ function PureMultimodalInput({
x
</div>
</>
) : uploadItem ? (
<>
{uploadItem.previewUrl ? (
<img
src={uploadItem.previewUrl}
alt={uploadItem.name || label}
className="h-full w-full object-cover"
/>
) : (
<div className="flex flex-col items-center gap-1">
<span className="text-xl leading-none">+</span>
<span className="text-sm">{label}</span>
</div>
)}
<div
className={cn(
"absolute inset-0 flex flex-col items-center justify-center gap-1 bg-black/60 px-2 text-center text-white",
isUploadFailed && "bg-destructive/80"
)}
>
<span className="text-[11px] font-medium">
{isUploadFailed ? "上传失败" : `${uploadProgress}%`}
</span>
{!isUploadFailed && (
<div className="h-1 w-full overflow-hidden rounded-full bg-white/25">
<div
className="h-full rounded-full bg-white transition-[width] duration-200"
style={{ width: `${uploadProgress}%` }}
/>
</div>
)}
</div>
{isUploadFailed && (
<div
className="absolute right-1 top-1 rounded-full bg-black/65 px-1 text-[11px] text-white opacity-90 transition-opacity hover:opacity-100"
onClick={(event) => {
event.stopPropagation();
removeUploadQueueItems([uploadItem.id]);
setVideoSlotUploadItemIds((current) => {
const next = { ...current };
delete next[index];
return next;
});
}}
>
x
</div>
)}
</>
) : (
<>
<div className="flex flex-col items-center gap-1">
@ -1477,12 +1621,13 @@ function PureMultimodalInput({
{uploadQueue.map((item) => (
<PreviewAttachment
attachment={{
url: "",
url: item.previewUrl || "",
name: item.name,
contentType: "",
contentType: item.previewUrl ? "image/*" : "",
}}
isUploading={true}
key={item.id}
onRemove={() => removeUploadQueueItems([item.id])}
uploadProgress={item.progress}
uploadStatus={item.status}
/>

View File

@ -28,7 +28,7 @@ export const PreviewAttachment = ({
attachment: Attachment;
isUploading?: boolean;
uploadProgress?: number;
uploadStatus?: "preparing" | "uploading" | "finalizing";
uploadStatus?: "preparing" | "uploading" | "finalizing" | "failed";
onRemove?: () => void;
onDragStart?: (e: React.DragEvent) => void;
onDragEnd?: (e: React.DragEvent) => void;
@ -44,12 +44,17 @@ export const PreviewAttachment = ({
imageIndex?: number;
}) => {
const { name, url, contentType } = attachment;
const isUploadFailed = uploadStatus === "failed";
const isLocalPreviewUrl = url?.startsWith("blob:") || url?.startsWith("data:");
const shouldShowImage = Boolean(url && contentType?.startsWith("image"));
const normalizedUploadProgress =
typeof uploadProgress === "number" && Number.isFinite(uploadProgress)
? Math.max(0, Math.min(100, Math.round(uploadProgress)))
: null;
const uploadStatusLabel =
uploadStatus === "preparing"
uploadStatus === "failed"
? "上传失败"
: uploadStatus === "preparing"
? "处理中"
: uploadStatus === "finalizing"
? "保存中"
@ -161,7 +166,7 @@ export const PreviewAttachment = ({
<div
className={`group relative size-16 overflow-hidden rounded-lg border bg-muted transition-all ${
draggable && !isUploading ? "cursor-move hover:scale-105 hover:shadow-lg" : "cursor-pointer hover:opacity-90"
} ${isDragging ? "opacity-50 scale-95" : ""}`}
} ${isDragging ? "opacity-50 scale-95" : ""} ${isUploadFailed ? "border-destructive" : ""}`}
data-testid="input-attachment-preview"
onClick={() => !isUploading && contentType?.startsWith("image") && setIsOpen(true)}
draggable={draggable && !isUploading}
@ -178,14 +183,22 @@ export const PreviewAttachment = ({
onDragOver={onDragOver}
onDrop={onDrop}
>
{!isUploading && contentType?.startsWith("image") ? (
<Image
alt={name ?? "图片附件"}
className="size-full object-cover"
height={64}
src={url}
width={64}
/>
{shouldShowImage ? (
isLocalPreviewUrl ? (
<img
alt={name ?? "图片附件"}
className="size-full object-cover"
src={url}
/>
) : (
<Image
alt={name ?? "图片附件"}
className="size-full object-cover"
height={64}
src={url}
width={64}
/>
)
) : (
<div className="flex size-full items-center justify-center text-muted-foreground text-xs">
文件
@ -194,25 +207,31 @@ export const PreviewAttachment = ({
{isUploading && (
<div
className="absolute inset-0 flex flex-col items-center justify-center gap-1.5 bg-black/60 px-2 text-white"
className={`absolute inset-0 flex flex-col items-center justify-center gap-1.5 px-2 text-white ${
isUploadFailed ? "bg-destructive/85" : "bg-black/60"
}`}
data-testid="input-attachment-loader"
>
<Loader size={16} />
{isUploadFailed ? <X size={16} /> : <Loader size={16} />}
<span className="text-[10px] font-medium leading-none">
{normalizedUploadProgress === null
{isUploadFailed
? uploadStatusLabel
: normalizedUploadProgress === null
? uploadStatusLabel
: `${normalizedUploadProgress}%`}
</span>
<div className="h-1 w-full overflow-hidden rounded-full bg-white/25">
<div
className="h-full rounded-full bg-white transition-[width] duration-200"
style={{ width: `${normalizedUploadProgress ?? 18}%` }}
/>
</div>
{!isUploadFailed && (
<div className="h-1 w-full overflow-hidden rounded-full bg-white/25">
<div
className="h-full rounded-full bg-white transition-[width] duration-200"
style={{ width: `${normalizedUploadProgress ?? 18}%` }}
/>
</div>
)}
</div>
)}
{onRemove && !isUploading && (
{onRemove && (!isUploading || isUploadFailed) && (
<Button
className="absolute top-0.5 right-0.5 size-4 rounded-full p-0 opacity-0 transition-opacity group-hover:opacity-100"
onClick={(e) => {