fix: keep uploaded image attachments visible
This commit is contained in:
parent
748273d917
commit
363bd1d2fe
@ -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;
|
||||
|
||||
@ -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);
|
||||
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
|
||||
);
|
||||
|
||||
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
|
||||
);
|
||||
|
||||
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
|
||||
);
|
||||
|
||||
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}
|
||||
/>
|
||||
|
||||
@ -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,7 +183,14 @@ export const PreviewAttachment = ({
|
||||
onDragOver={onDragOver}
|
||||
onDrop={onDrop}
|
||||
>
|
||||
{!isUploading && contentType?.startsWith("image") ? (
|
||||
{shouldShowImage ? (
|
||||
isLocalPreviewUrl ? (
|
||||
<img
|
||||
alt={name ?? "图片附件"}
|
||||
className="size-full object-cover"
|
||||
src={url}
|
||||
/>
|
||||
) : (
|
||||
<Image
|
||||
alt={name ?? "图片附件"}
|
||||
className="size-full object-cover"
|
||||
@ -186,6 +198,7 @@ export const PreviewAttachment = ({
|
||||
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>
|
||||
{!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) => {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user