diff --git a/components/canvas/CanvasChatPanel.tsx b/components/canvas/CanvasChatPanel.tsx index dcd61eed5..529f4d53c 100644 --- a/components/canvas/CanvasChatPanel.tsx +++ b/components/canvas/CanvasChatPanel.tsx @@ -756,6 +756,25 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF const selectedImageNodes = nodes.filter( n => selectedNodeIds.includes(n.id) && n.type === 'image' && (n.data as any)?.src ); + + const getCanvasNodeAttachment = useCallback( + (node: any, fallbackName: string): PickedObjectAttachment | null => { + const src = (node?.data as any)?.src; + if (!src || typeof src !== "string") return null; + + const nodeTitle = + String((node?.data as any)?.title || "").trim() || + String((node?.data as any)?.prompt || "").trim() || + fallbackName; + + return { + url: src, + name: nodeTitle, + contentType: "image/jpeg", + }; + }, + [] + ); // 🆕 辅助函数:在 contenteditable 中插入图片标签 const insertImageTagInEditor = useCallback((url: string, name: string, index: number) => { @@ -945,36 +964,6 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF } }, [canvasId, addNode, insertImageTagInEditor]); - // 🆕 监听 pendingChatAttachments,当用户点击"添加到对话"按钮时添加标签 - useEffect(() => { - if (pendingChatAttachments.length > 0) { - // 获取当前附件列表 - const currentAttachments = [...attachments]; - const existingUrls = new Set(currentAttachments.map(a => a.url)); - - // 过滤出需要添加的图片(不在当前附件列表中的) - const toAdd = pendingChatAttachments.filter(a => !existingUrls.has(a.url)); - - if (toAdd.length > 0) { - // 插入图片标签到编辑器 - toAdd.forEach((att, i) => { - insertImageTagInEditor(att.url, att.name, currentAttachments.length + i); - }); - - // 更新附件列表 - setAttachments([...currentAttachments, ...toAdd]); - - // 聚焦到输入框 - setTimeout(() => { - (textareaRef.current as HTMLDivElement | null)?.focus(); - }, 0); - } - - // 清空待添加列表 - clearPendingChatAttachments(); - } - }, [pendingChatAttachments, attachments, insertImageTagInEditor, clearPendingChatAttachments]); - // 🆕 监听 pendingPickedObjects,当用户确认拾取物品时添加标签 useEffect(() => { if (pendingPickedObjects.length > 0) { @@ -1189,6 +1178,82 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF return [] as string[]; }, [isVideoModel, videoParams]); + // 监听 pendingChatAttachments:视频模式时填充首尾帧槽位,非视频模式插入对话标签 + useEffect(() => { + if (pendingChatAttachments.length === 0) return; + + if (isVideoModel && videoFrameSlots.length > 0) { + const slotCount = videoFrameSlots.length; + const currentSlots: Array = Array.from( + { length: slotCount }, + (_, index) => attachments[index] ?? null + ); + const existingUrls = new Set(currentSlots.filter(Boolean).map((item) => item!.url)); + const toAdd = pendingChatAttachments + .filter((item) => item?.url && !existingUrls.has(item.url)) + .map((item) => ({ + url: item.url, + name: item.name || "参考图", + contentType: item.contentType || "image/jpeg", + })) as PickedObjectAttachment[]; + + let cursor = 0; + let inserted = 0; + for (let index = 0; index < slotCount && cursor < toAdd.length; index += 1) { + if (currentSlots[index]) continue; + currentSlots[index] = toAdd[cursor]!; + cursor += 1; + inserted += 1; + } + + if (inserted > 0) { + const next = currentSlots + .slice(0, slotCount) + .filter((item): item is PickedObjectAttachment => Boolean(item)); + setAttachments(next); + toast.success(slotCount === 1 ? "已添加到视频首帧" : "已添加到首尾帧槽位"); + } else if (toAdd.length > 0) { + toast.info("首尾帧槽位已满,请先替换或移除"); + } + + if (toAdd.length - inserted > 0) { + toast.info(`还有 ${toAdd.length - inserted} 张未添加,可继续替换首尾帧`); + } + + setTimeout(() => { + (textareaRef.current as HTMLDivElement | null)?.focus(); + }, 0); + + clearPendingChatAttachments(); + return; + } + + const currentAttachments = [...attachments]; + const existingUrls = new Set(currentAttachments.map((item) => item.url)); + const toAdd = pendingChatAttachments.filter((item) => !existingUrls.has(item.url)); + + if (toAdd.length > 0) { + toAdd.forEach((item, index) => { + insertImageTagInEditor(item.url, item.name, currentAttachments.length + index); + }); + + setAttachments([...currentAttachments, ...toAdd]); + + setTimeout(() => { + (textareaRef.current as HTMLDivElement | null)?.focus(); + }, 0); + } + + clearPendingChatAttachments(); + }, [ + pendingChatAttachments, + isVideoModel, + videoFrameSlots.length, + attachments, + insertImageTagInEditor, + clearPendingChatAttachments, + ]); + const firstImageModelId = useMemo( () => allModels.find( @@ -1911,6 +1976,20 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF [videoFrameSlots.length] ); + const fillVideoSlotFromCanvasSelection = useCallback( + (slotIndex: number) => { + const preferredNode = selectedImageNodes[slotIndex] || selectedImageNodes[0]; + const attachment = preferredNode + ? getCanvasNodeAttachment(preferredNode, `参考图 ${slotIndex + 1}`) + : null; + if (!attachment) return false; + + updateAttachmentAtVideoSlot(slotIndex, attachment); + return true; + }, + [getCanvasNodeAttachment, selectedImageNodes, updateAttachmentAtVideoSlot] + ); + const handleVideoSlotFileChange = useCallback( async (slotIndex: number, event: React.ChangeEvent) => { const file = event.target.files?.[0]; @@ -3756,6 +3835,10 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF type="button" onClick={() => { if (isDisabled) return; + if (fillVideoSlotFromCanvasSelection(index)) { + toast.success(`${label}已从画布填充`); + return; + } videoSlotInputRefs.current[index]?.click(); }} className={cn( @@ -3794,6 +3877,9 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF ); })} +
+ 已选中画布图片时,点击首帧/尾帧可直接填充;未选中则打开上传。 +
)}