diff --git a/components/multimodal-input.tsx b/components/multimodal-input.tsx index 3cde3e157..b900665d5 100644 --- a/components/multimodal-input.tsx +++ b/components/multimodal-input.tsx @@ -201,9 +201,11 @@ function PureMultimodalInput({ }; const fileInputRef = useRef(null); + const videoSlotInputRefs = useRef>([]); const [uploadQueue, setUploadQueue] = useState([]); const [isDragging, setIsDragging] = useState(false); const [draggedIndex, setDraggedIndex] = useState(null); + const [videoSlotUploading, setVideoSlotUploading] = useState(null); const submitForm = useCallback(() => { window.history.pushState({}, "", `/chat/${chatId}`); @@ -422,6 +424,60 @@ function PureMultimodalInput({ [usage] ); + const videoFrameSlots = useMemo(() => { + if (!isVideoModel || !videoParams) return [] as string[]; + if (videoParams.mode === "first-last-frame") { + return ["首帧", "尾帧"]; + } + if (videoParams.mode === "first-frame") { + return ["首帧"]; + } + return [] as string[]; + }, [isVideoModel, videoParams]); + + const updateAttachmentAtSlot = useCallback( + (slotIndex: number, attachment: Attachment | 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 Attachment => Boolean(item)); + }); + }, + [setAttachments, videoFrameSlots.length] + ); + + const handleVideoSlotFileChange = useCallback( + async (slotIndex: number, event: ChangeEvent) => { + const file = event.target.files?.[0]; + if (!file) return; + if (!file.type.startsWith("image/")) { + toast.error("请上传图片文件"); + event.currentTarget.value = ""; + return; + } + setVideoSlotUploading(slotIndex); + setUploadQueue((prev) => [...prev, file.name]); + try { + const uploaded = await uploadFile(file); + if (uploaded) { + updateAttachmentAtSlot(slotIndex, uploaded as Attachment); + } + } finally { + setVideoSlotUploading(null); + setUploadQueue((prev) => prev.filter((name) => name !== file.name)); + event.currentTarget.value = ""; + } + }, + [updateAttachmentAtSlot, uploadFile] + ); + // 🆕 视频模式下的图片数量限制 const getVideoImageLimit = useCallback(() => { if (!isVideoModel || !videoParams) { @@ -757,6 +813,18 @@ function PureMultimodalInput({ tabIndex={-1} type="file" /> + {videoFrameSlots.map((_, index) => ( + handleVideoSlotFileChange(index, event)} + ref={(element) => { + videoSlotInputRefs.current[index] = element; + }} + /> + ))} - {(attachments.length > 0 || uploadQueue.length > 0) && ( + {videoFrameSlots.length > 0 && ( +
+ {videoFrameSlots.map((label, index) => { + const slotAttachment = attachments[index]; + const disabled = index > 0 && !attachments[index - 1]; + const isUploading = videoSlotUploading === index; + return ( + + ); + })} +
+ )} + + {(attachments.length > 0 || uploadQueue.length > 0) && videoFrameSlots.length === 0 && (
- - - {/* 🆕 视频参数栏(内嵌在模型选择器后面,同一行) */} - {isVideoModel && onVideoParamsChange && ( - - )} - {/* 🆕 图片参数栏(内嵌在模型选择器后面,同一行) */} - {isImageModel && onImageParamsChange && ( - + {isVideoModel ? ( + <> + + {onVideoParamsChange && ( + + )} + + + ) : ( + <> + + + {isImageModel && onImageParamsChange && ( + + )} + )} @@ -1007,10 +1146,12 @@ function PureModelSelectorCompact({ selectedModelId, onModelChange, attachments, + avatarOnly = false, }: { selectedModelId: string; onModelChange?: (modelId: string) => void; attachments: Attachment[]; + avatarOnly?: boolean; }) { const [optimisticModelId, setOptimisticModelId] = useState(selectedModelId); @@ -1018,6 +1159,12 @@ function PureModelSelectorCompact({ const { data: dynamicModelsData, isLoading } = useSWR<{ models: DynamicModel[]; defaultModelId: string | null; + agentModel?: { + id: string; + displayName: string; + avatarUrl?: string | null; + providerType: string; + } | null; }>("/api/models", fetcher); // 获取站点设置 @@ -1064,7 +1211,7 @@ function PureModelSelectorCompact({ id: "auto", modelId: "auto", displayName: "Agent模式", - avatarUrl: null, + avatarUrl: dynamicModelsData?.agentModel?.avatarUrl || null, description: "根据您的意图自动选择最合适的模型", providerType: "system", supportsVision: true, @@ -1122,15 +1269,32 @@ function PureModelSelectorCompact({ const selectedModel = useMemo(() => { const dynamicModel = displayModels.find((m) => m.id === optimisticModelId); if (dynamicModel) { - return { id: dynamicModel.id, name: dynamicModel.displayName, description: dynamicModel.description }; + return { + id: dynamicModel.id, + name: dynamicModel.displayName, + description: dynamicModel.description, + avatarUrl: dynamicModel.avatarUrl || null, + }; } if (displayModels.length > 0) { - return { id: displayModels[0].id, name: displayModels[0].displayName, description: displayModels[0].description }; + return { + id: displayModels[0].id, + name: displayModels[0].displayName, + description: displayModels[0].description, + avatarUrl: displayModels[0].avatarUrl || null, + }; } - return { id: "", name: "未配置模型", description: null }; + return { id: "", name: "未配置模型", description: null, avatarUrl: null }; }, [optimisticModelId, displayModels]); if (isLoading) { + if (avatarOnly) { + return ( + + ); + } return ( + ); + } return ( + {avatarOnly ? ( + + ) : ( + + )}
@@ -1251,6 +1439,9 @@ const ModelSelectorCompact = memo( if (prevProps.selectedModelId !== nextProps.selectedModelId) { return false; } + if (prevProps.avatarOnly !== nextProps.avatarOnly) { + return false; + } if (!equal(prevProps.attachments, nextProps.attachments)) { return false; }