diff --git a/components/canvas/CanvasChatPanel.tsx b/components/canvas/CanvasChatPanel.tsx index 7fdceff7f..669c41431 100644 --- a/components/canvas/CanvasChatPanel.tsx +++ b/components/canvas/CanvasChatPanel.tsx @@ -300,6 +300,7 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF const [isSessionMenuOpen, setIsSessionMenuOpen] = useState(false); const messagesEndRef = useRef(null); const fileInputRef = useRef(null); + const videoSlotInputRefs = useRef>([]); const textareaRef = useRef(null); const hiddenPromptRef = useRef(null); const autoContextRef = useRef(null); @@ -321,6 +322,7 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF imageCount: 1, }); const [videoParams, setVideoParams] = useState(null); + const [videoSlotUploading, setVideoSlotUploading] = useState(null); // 🆕 保存图片参数到 localStorage const handleImageParamsChange = (params: ImageGenerationParams) => { @@ -864,6 +866,12 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF const allModels = [SMART_AUTO_MODEL, ...visualModels]; const currentModel = allModels.find((model) => model.id === selectedModelId); const isVideoModel = Boolean(currentModel && VIDEO_PROVIDER_TYPES.has(currentModel.providerType)); + const videoFrameSlots = useMemo(() => { + if (!isVideoModel || !videoParams) return [] as string[]; + if (videoParams.mode === "first-last-frame") return ["首帧", "尾帧"] as string[]; + if (videoParams.mode === "first-frame") return ["首帧"] as string[]; + return [] as string[]; + }, [isVideoModel, videoParams]); // 默认选择模型(根据系统设置) useEffect(() => { @@ -903,6 +911,22 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF } }, [isVideoModel, currentModel?.providerType, videoParams]); + useEffect(() => { + if (!isVideoModel || videoFrameSlots.length === 0) return; + const maxAttachments = videoFrameSlots.length; + setAttachments((current) => { + if (current.length <= maxAttachments) return current; + return current.slice(0, maxAttachments); + }); + }, [isVideoModel, videoFrameSlots.length]); + + useEffect(() => { + if (videoFrameSlots.length === 0) return; + const editableDiv = textareaRef.current as HTMLDivElement | null; + if (!editableDiv) return; + editableDiv.querySelectorAll("[data-attachment-url]").forEach((element) => element.remove()); + }, [videoFrameSlots.length]); + // 合并 initialMessages 和历史消息 const mergedInitialMessages = historyData?.messages ?? (activeChatId === chatId ? initialMessages : []); @@ -1388,6 +1412,96 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF return lines.join("\n"); }, [nodes, selectedImageNodes]); + const updateAttachmentAtVideoSlot = useCallback( + (slotIndex: number, attachment: PickedObjectAttachment | null) => { + const slotCount = Math.max(videoFrameSlots.length, 2); + setAttachments((current) => { + const slots: Array = Array.from( + { length: slotCount }, + (_, index) => current[index] ?? null + ); + slots[slotIndex] = attachment; + while (slots.length > 0 && !slots[slots.length - 1]) { + slots.pop(); + } + return slots.filter((item): item is PickedObjectAttachment => Boolean(item)); + }); + }, + [videoFrameSlots.length] + ); + + const handleVideoSlotFileChange = useCallback( + async (slotIndex: number, event: React.ChangeEvent) => { + const file = event.target.files?.[0]; + if (!file) return; + + if (!file.type.startsWith("image/")) { + toast.error("请上传图片文件"); + event.currentTarget.value = ""; + return; + } + + if (file.size > 50 * 1024 * 1024) { + toast.error("图片大小不能超过 50MB"); + event.currentTarget.value = ""; + return; + } + + setVideoSlotUploading(slotIndex); + try { + const formData = new FormData(); + formData.append("file", file); + + const response = await fetch("/api/files/upload", { + method: "POST", + body: formData, + }); + + if (!response.ok) { + throw new Error("上传失败"); + } + + const data = await response.json(); + const imageUrl = data.url as string; + + updateAttachmentAtVideoSlot(slotIndex, { + url: imageUrl, + name: file.name, + contentType: file.type || "image/jpeg", + }); + + const dimensions = await getImageDimensions(imageUrl); + const nodeIndex = nodes.length + slotIndex; + const spacing = 20; + const x = 100 + (nodeIndex % 3) * (dimensions.width + spacing); + const y = 100 + Math.floor(nodeIndex / 3) * (dimensions.height + spacing); + addNode({ + type: "image", + position: { x, y }, + size: { width: dimensions.width, height: dimensions.height }, + data: { + src: imageUrl, + status: "completed", + title: file.name.replace(/\.[^.]+$/, ""), + prompt: file.name.replace(/\.[^.]+$/, ""), + metadata: { + originalWidth: dimensions.originalWidth, + originalHeight: dimensions.originalHeight, + }, + }, + connections: [], + }); + toast.success(`${slotIndex === 0 ? "首帧" : "尾帧"}上传成功`); + } catch (_error) { + toast.error("图片上传失败"); + } finally { + setVideoSlotUploading(null); + event.currentTarget.value = ""; + } + }, + [addNode, nodes.length, updateAttachmentAtVideoSlot] + ); + // 处理发送(图片已在上传时添加到画布,这里只发送消息) const handleSend = useCallback(async () => { if (!input.trim() && attachments.length === 0) return; @@ -1408,7 +1522,8 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF if ( effectiveAttachments.length === 0 && input.trim() && - (!videoImageLimit || videoImageLimit.max > 0) + (!videoImageLimit || videoImageLimit.max > 0) && + videoFrameSlots.length === 0 ) { const recentAttachments = getRecentImageAttachments(); if (recentAttachments.length > 0) { @@ -1546,6 +1661,7 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF buildAutoContext, isVideoModel, videoParams, + videoFrameSlots.length, currentModel?.providerType, selectedImageNodes, ]); @@ -1761,6 +1877,15 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF }; const isLoading = status === 'streaming' || status === 'submitted'; + const currentVideoImageLimit = isVideoModel + ? getVideoImageLimit(videoParams, currentModel?.providerType) + : null; + const showVideoAttachmentButton = Boolean( + isVideoModel && + currentVideoImageLimit && + currentVideoImageLimit.max > 0 && + videoFrameSlots.length === 0 + ); const lastUserIndexForMarkers = messages.findLastIndex(m => m.role === "user"); const messagesAfterLastUser = lastUserIndexForMarkers >= 0 ? messages.slice(lastUserIndexForMarkers + 1) : messages; const hasGenerationStart = messagesAfterLastUser.some(msg => @@ -2235,7 +2360,12 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF data-canvas-chat-input-anchor="true" > {/* 混合输入区域:使用 contenteditable 实现真正的混合编辑 */} -
+
0 ? "pb-40" : "pb-12" + )} + >
{ if (e.key === 'Enter' && !e.shiftKey) { @@ -2260,7 +2392,7 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF handleSend(); } // 处理 Backspace 删除图片标签(只删除聊天框中的标签,不影响画布) - if (e.key === 'Backspace') { + if (e.key === 'Backspace' && (!isVideoModel || videoFrameSlots.length === 0)) { const selection = window.getSelection(); if (selection && selection.rangeCount > 0) { const range = selection.getRangeAt(0); @@ -2287,6 +2419,60 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF style={{ whiteSpace: 'pre-wrap', wordBreak: 'break-word' }} />
+ + {isVideoModel && videoFrameSlots.length > 0 && ( +
+
+ {videoFrameSlots.map((label, index) => { + const slotAttachment = attachments[index]; + const isUploading = videoSlotUploading === index; + const isDisabled = isLoading || (index > 0 && !attachments[index - 1]); + return ( + + ); + })} +
+
+ )} {/* Bottom Toolbar */}
@@ -2299,99 +2485,90 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF className="hidden" onChange={handleFileSelect} /> - {isVideoModel ? ( - <> - -
- -
- - ) : ( - <> -
- - -
-
- -
- + {videoFrameSlots.map((_, index) => ( + { + videoSlotInputRefs.current[index] = element; + }} + onChange={(event) => { + void handleVideoSlotFileChange(index, event); + }} + /> + ))} + + + {(!isVideoModel || showVideoAttachmentButton) && ( + )} + +
+ {isVideoModel ? ( + + ) : ( + + )} +
- {isVideoModel && ( - - )} +