"use client"; import type { UseChatHelpers } from "@ai-sdk/react"; import { Bot, ImageIcon, Sparkles, Video } from "lucide-react"; import { useRouter } from "next/navigation"; import { isImageGenerationModelDescriptor, isVideoModelDescriptor, } from "@/lib/ai/model-classification"; import { getGenerationCreditCostFromImageGenConfig, getImageCreditCostFromConfig, getImageModelCreditDiscountLabel, } from "@/lib/ai/image-model-pricing"; // 动态模型类型定义 type DynamicModel = { id: string; modelId?: string; displayName: string; avatarUrl?: string | null; description: string | null; providerType?: string; supportsVision: boolean; supportsImageGen?: boolean; isMultimodal?: boolean; isDefault: boolean; providerName: string; isSubModel?: boolean; // 🆕 是否为子模型 parentModelId?: string | null; // 🆕 父模型ID capabilities?: any; // 🆕 模型能力配置 videoToolCapability?: VideoToolCapabilityContract | null; imageGenConfig?: { maxImages?: number; supportsSize?: boolean; supports1K?: boolean; supports2K?: boolean; supports4K?: boolean; supportsPromptExtend?: boolean; supportsNegativePrompt?: boolean; supportsWatermark?: boolean; supportsSeed?: boolean; supportedAspectRatios?: string[]; supportedImageSizes?: string[]; creditCostPerImage?: number; creditCostMultiplierBySize?: Record; creditDiscountRate?: number; creditCostPerVideo?: number; videoDurationOptions?: number[]; creditCostPerVideoByDuration?: Record; creditCostPerVideoByResolution?: Record; } | null; availabilityStatus?: "available" | "maintenance" | "testing"; availabilityLabel?: string | null; selectable?: boolean; disabledReason?: string | null; }; type PointsSummaryResponse = { isHighVip?: boolean; pointsSystemConfig?: { enabled?: boolean; blockOnInsufficient?: boolean; }; summary?: { currentCredits?: number; }; } | null; const fetcher = (url: string) => fetch(url).then((res) => res.json()); type UploadQueueStatus = "preparing" | "uploading" | "finalizing" | "failed"; type UploadQueueItem = { id: string; name: string; previewUrl?: string; progress: number; status: UploadQueueStatus; }; const UPLOAD_PROGRESS_HOLD_MS = 700; function createUploadQueueId(file: File) { return [ file.name || "image", file.size, file.lastModified, Date.now(), Math.random().toString(36).slice(2, 8), ].join("-"); } function createUploadQueueItem(file: File, fallbackName?: string): UploadQueueItem { 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"; return "uploading"; } function getUploadQueueStatusText(status: UploadQueueStatus) { if (status === "failed") return "上传失败"; if (status === "preparing") return "正在处理图片"; if (status === "finalizing") return "正在保存图片"; return "正在上传图片"; } function isVideoProviderType(model?: Partial | null) { return isVideoModelDescriptor({ providerType: model?.providerType, modelId: model?.modelId, capabilities: model?.capabilities, supportsImageGen: model?.supportsImageGen, }); } function isImageGenerationModel(model: DynamicModel) { return isImageGenerationModelDescriptor({ providerType: model.providerType, modelId: model.modelId, capabilities: model.capabilities, supportsImageGen: model.supportsImageGen, }); } function isNanoBananaProModel(model: { displayName?: string | null; modelId?: string | null }) { const displayName = String(model.displayName || "").toLowerCase(); const modelId = String(model.modelId || "").toLowerCase(); return ( displayName.includes("nano banana pro") || displayName.includes("nano-banana-pro") || modelId.includes("nano-banana-pro") || modelId.includes("nano_banana_pro") || modelId.includes("nano-banana") ); } import { Trigger } from "@radix-ui/react-select"; import type { UIMessage } from "ai"; import equal from "fast-deep-equal"; import { type ChangeEvent, type Dispatch, memo, type SetStateAction, startTransition, useCallback, useEffect, useMemo, useRef, useState, } from "react"; import { toast } from "sonner"; import useSWR from "swr"; import { useLocalStorage, useWindowSize } from "usehooks-ts"; import { saveChatModelAsCookie } from "@/app/(chat)/actions"; import { fetchUploadWithRetry, type UploadProgressPhase, } from "@/lib/client/upload-with-retry"; import { SelectItem } from "@/components/ui/select"; import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, } from "@/components/ui/alert-dialog"; import type { Attachment, ChatMessage } from "@/lib/types"; import type { AppUsage } from "@/lib/usage"; import { cn } from "@/lib/utils"; import { Context } from "./elements/context"; import { PromptInput, PromptInputModelSelect, PromptInputModelSelectContent, PromptInputSubmit, PromptInputTextarea, PromptInputToolbar, PromptInputTools, } from "./elements/prompt-input"; import { ArrowUpIcon, ChevronDownIcon, CpuIcon, PaperclipIcon, StopIcon, } from "./icons"; import { PreviewAttachment } from "./preview-attachment"; import { SuggestedActions } from "./suggested-actions"; import { Button } from "./ui/button"; import type { VisibilityType } from "./visibility-selector"; import { VideoParamsBar, type VideoGenerationParams } from "./video-params-bar"; import { ImageParamsBar, type ImageGenerationParams } from "./image-params-bar"; import type { VideoToolCapabilityContract } from "@/types/video-tool-capability"; function PureMultimodalInput({ chatId, input, setInput, status, stop, attachments, setAttachments, messages, setMessages, sendMessage, className, selectedVisibilityType, selectedModelId, onModelChange, usage, isVideoModel, videoModelName, videoParams, videoProviderType, videoToolCapability, onVideoParamsChange, isImageModel, imageModelName, imageParams, onImageParamsChange, imageProviderType, imageGenConfig, }: { chatId: string; input: string; setInput: Dispatch>; status: UseChatHelpers["status"]; stop: () => void; attachments: Attachment[]; setAttachments: Dispatch>; messages: UIMessage[]; setMessages: UseChatHelpers["setMessages"]; sendMessage: UseChatHelpers["sendMessage"]; className?: string; selectedVisibilityType: VisibilityType; selectedModelId: string; onModelChange?: (modelId: string) => void; usage?: AppUsage; isVideoModel?: boolean; videoModelName?: string; videoParams?: VideoGenerationParams | null; videoProviderType?: string; videoToolCapability?: VideoToolCapabilityContract | null; onVideoParamsChange?: (params: VideoGenerationParams) => void; isImageModel?: boolean; imageModelName?: string; imageParams?: ImageGenerationParams | null; onImageParamsChange?: (params: ImageGenerationParams) => void; imageProviderType?: string; // 🆕 图片模型提供商类型 imageGenConfig?: { maxImages?: number; supportsSize?: boolean; supports1K?: boolean; supports2K?: boolean; supports4K?: boolean; supportsPromptExtend?: boolean; supportsNegativePrompt?: boolean; supportsWatermark?: boolean; supportsSeed?: boolean; creditCostPerImage?: number; creditCostMultiplierBySize?: Record; creditDiscountRate?: number; creditCostPerVideo?: number; videoDurationOptions?: number[]; creditCostPerVideoByDuration?: Record; creditCostPerVideoByResolution?: Record; } | null; }) { // 🔍 调试日志:检查图片模型相关props console.log('[MultimodalInput] Render - isImageModel:', isImageModel); console.log('[MultimodalInput] Render - imageParams:', imageParams); console.log('[MultimodalInput] Render - onImageParamsChange:', !!onImageParamsChange); const isAgentMode = selectedModelId === "auto"; const router = useRouter(); const textareaRef = useRef(null); const { width } = useWindowSize(); const [insufficientPointsState, setInsufficientPointsState] = useState<{ balance: number; expectedCost: number; unitCost: number; imageCount: number; } | null>(null); // 获取网站设置(用于时间限制检查) const { data: siteSettings } = useSWR("/api/site-settings?scope=chat", fetcher); const { data: modelCatalog } = useSWR<{ models: DynamicModel[]; }>("/api/models?includeUnavailable=1", fetcher); const { data: membershipSummary } = useSWR( "/api/user/membership-summary", async (url: string) => { const response = await fetch(url, { cache: "no-store" }); if (!response.ok) return null; return response.json(); } ); // 检查当前时间是否在允许的使用时间段内 const isWithinUsageTime = useMemo(() => { if (!siteSettings?.usageTimeEnabled) return true; const now = new Date(); const currentTime = `${String(now.getHours()).padStart(2, "0")}:${String(now.getMinutes()).padStart(2, "0")}`; const startTime = siteSettings.usageTimeStart || "08:00"; const endTime = siteSettings.usageTimeEnd || "20:00"; return currentTime >= startTime && currentTime <= endTime; }, [siteSettings]); const currentSelectedModel = useMemo( () => modelCatalog?.models?.find((model) => model.id === selectedModelId), [modelCatalog?.models, selectedModelId] ); const quickSwitchTargets = useMemo(() => { const models = (modelCatalog?.models || []).filter( (model) => model.selectable !== false ); const nanoBananaImage = models.find( (model) => isImageGenerationModel(model) && isNanoBananaProModel(model) ); const imageMain = models.find( (model) => isImageGenerationModel(model) && !model.isSubModel ); const imageFallback = models.find( (model) => isImageGenerationModel(model) ); const videoMain = models.find( (model) => isVideoProviderType(model) && !model.isSubModel ); const videoFallback = models.find((model) => isVideoProviderType(model) ); return { image: nanoBananaImage || imageMain || imageFallback || null, video: videoMain || videoFallback || null, }; }, [modelCatalog?.models]); const activeQuickMode = useMemo<"image" | "video" | null>(() => { if (isVideoModel || isVideoProviderType(currentSelectedModel)) { return "video"; } if (isImageModel || (currentSelectedModel && isImageGenerationModel(currentSelectedModel))) { return "image"; } return null; }, [currentSelectedModel, isImageModel, isVideoModel]); const switchComposerMode = useCallback( (targetMode: "image" | "video") => { const targetModel = targetMode === "image" ? quickSwitchTargets.image : quickSwitchTargets.video; if (!targetModel) { toast.error(targetMode === "image" ? "当前没有可用的生图模型" : "当前没有可用的视频模型"); return; } if (targetModel.id === selectedModelId) return; onModelChange?.(targetModel.id); startTransition(() => { saveChatModelAsCookie(targetModel.id); }); toast.success( targetMode === "image" ? `已切换到生图模型:${targetModel.displayName}` : `已切换到视频模型:${targetModel.displayName}` ); }, [onModelChange, quickSwitchTargets.image, quickSwitchTargets.video, selectedModelId] ); const showQuickModeSwitch = Boolean(quickSwitchTargets.image || quickSwitchTargets.video); const estimatedPointsCost = useMemo(() => { if (membershipSummary?.isHighVip) return null; if (membershipSummary?.pointsSystemConfig?.enabled !== true) return null; if (isVideoModel) { const cost = getGenerationCreditCostFromImageGenConfig( imageGenConfig, "video", { duration: videoParams?.duration, resolution: videoParams?.resolution, } ); return cost > 0 ? { cost, title: `本次预计消耗 ${cost} 积分`, } : null; } if (!isImageModel) return null; const unitCost = getImageCreditCostFromConfig( imageGenConfig, imageParams?.imageSize ); const imageCount = Math.max( 1, Math.round(Number(imageParams?.imageCount || 1)) ); const cost = unitCost * imageCount; return cost > 0 ? { cost, title: `本次预计消耗 ${cost} 积分(${unitCost} × ${imageCount} 张)`, } : null; }, [ imageGenConfig, imageParams?.imageCount, imageParams?.imageSize, isImageModel, isVideoModel, membershipSummary?.isHighVip, membershipSummary?.pointsSystemConfig?.enabled, videoParams?.duration, videoParams?.resolution, ]); // 每分钟更新一次检查 useEffect(() => { if (!siteSettings?.usageTimeEnabled) return; const interval = setInterval(() => { // 强制重新渲染以更新 isWithinUsageTime startTransition(() => { setInput((prev) => prev); }); }, 60000); // 每分钟检查一次 return () => clearInterval(interval); }, [siteSettings?.usageTimeEnabled, setInput]); const adjustHeight = useCallback(() => { if (textareaRef.current) { textareaRef.current.style.height = "44px"; } }, []); useEffect(() => { if (textareaRef.current) { adjustHeight(); } }, [adjustHeight]); const resetHeight = useCallback(() => { if (textareaRef.current) { textareaRef.current.style.height = "44px"; } }, []); const [localStorageInput, setLocalStorageInput] = useLocalStorage( "input", "" ); useEffect(() => { if (textareaRef.current) { const domValue = textareaRef.current.value; // Prefer DOM value over localStorage to handle hydration const finalValue = domValue || localStorageInput || ""; setInput(finalValue); adjustHeight(); } // Only run once after hydration // eslint-disable-next-line react-hooks/exhaustive-deps }, [adjustHeight, localStorageInput, setInput]); useEffect(() => { setLocalStorageInput(input); }, [input, setLocalStorageInput]); const handleInput = (event: React.ChangeEvent) => { setInput(event.target.value); }; const fileInputRef = useRef(null); const videoSlotInputRefs = useRef>([]); const [uploadQueue, setUploadQueue] = useState([]); const uploadQueueRef = useRef([]); const [isDragging, setIsDragging] = useState(false); const [draggedIndex, setDraggedIndex] = useState(null); const [videoSlotUploading, setVideoSlotUploading] = useState(null); const [videoSlotUploadItemIds, setVideoSlotUploadItemIds] = useState>({}); const updateUploadQueueItem = useCallback( ( id: string | undefined, patch: Partial> ) => { if (!id) return; setUploadQueue((current) => current.map((item) => item.id === id ? { ...item, ...patch, progress: typeof patch.progress === "number" ? Math.max(0, Math.min(100, Math.round(patch.progress))) : item.progress, } : item ) ); }, [] ); const removeUploadQueueItems = useCallback((ids: string[]) => { if (ids.length === 0) return; const idSet = new Set(ids); 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( async (ids: string[]) => { if (ids.length === 0) return; await new Promise((resolve) => { globalThis.setTimeout(resolve, UPLOAD_PROGRESS_HOLD_MS); }); removeUploadQueueItems(ids); }, [removeUploadQueueItems] ); const uploadProgressSummary = useMemo(() => { if (uploadQueue.length === 0) return null; const totalProgress = Math.round( uploadQueue.reduce((sum, item) => sum + item.progress, 0) / 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"), }; }, [uploadQueue]); const ensureImagePointsBeforeSend = useCallback(async () => { if (!isImageModel || isVideoModel) return true; if (membershipSummary?.isHighVip) return true; const pointsEnabled = membershipSummary?.pointsSystemConfig?.enabled === true; const blockOnInsufficient = membershipSummary?.pointsSystemConfig?.blockOnInsufficient !== false; const unitCost = getImageCreditCostFromConfig( imageGenConfig, imageParams?.imageSize ); if (!pointsEnabled || !blockOnInsufficient || unitCost <= 0) { return true; } const requestedImageCount = Math.max( 1, Math.round(Number(imageParams?.imageCount || 1)) ); const expectedCost = unitCost * requestedImageCount; const currentBalance = Math.max( 0, Number(membershipSummary?.summary?.currentCredits ?? 0) ); if (currentBalance >= expectedCost) { return true; } setInsufficientPointsState({ balance: currentBalance, expectedCost, unitCost, imageCount: requestedImageCount, }); toast.error("积分不足,请先充值后再生图"); return false; }, [ imageGenConfig, imageParams?.imageCount, imageParams?.imageSize, isImageModel, isVideoModel, membershipSummary?.isHighVip, membershipSummary?.pointsSystemConfig?.blockOnInsufficient, membershipSummary?.pointsSystemConfig?.enabled, membershipSummary?.summary?.currentCredits, ]); const submitForm = useCallback(async () => { const canContinue = await ensureImagePointsBeforeSend(); if (!canContinue) return; sendMessage({ role: "user", parts: [ ...attachments.map((attachment) => ({ type: "file" as const, url: attachment.url, name: attachment.name, mediaType: attachment.contentType, assetId: attachment.assetId, })), { type: "text", text: input, }, ], }); setAttachments([]); setLocalStorageInput(""); resetHeight(); setInput(""); if (width && width > 768) { textareaRef.current?.focus(); } }, [ ensureImagePointsBeforeSend, input, setInput, attachments, sendMessage, setAttachments, setLocalStorageInput, width, chatId, resetHeight, ]); // 压缩图片函数(保持原格式:JPG保持JPG,PNG保持PNG) // 不限制分辨率,优先保留原图质量 const compressImage = useCallback(async (file: File, maxSizeMB = 50): Promise => { // 如果不是图片,直接返回 if (!file.type.startsWith('image/')) { return file; } // ✅ 保持原格式:只处理JPG和PNG,其他格式(如WebP)直接返回 const isJPEG = file.type === 'image/jpeg' || file.type === 'image/jpg'; const isPNG = file.type === 'image/png'; // 如果不是JPG/PNG,直接返回(WebP等格式不压缩) if (!isJPEG && !isPNG) { if (file.size <= maxSizeMB * 1024 * 1024) { return file; } // 如果WebP等格式太大,也返回(让服务器端处理或提示用户) console.log(`[压缩] 非JPG/PNG格式,跳过压缩: ${file.type}`); return file; } return new Promise((resolve, reject) => { const img = document.createElement('img'); const objectUrl = URL.createObjectURL(file); img.onload = () => { URL.revokeObjectURL(objectUrl); const width = img.width; const height = img.height; // ✅ 免检通道:50MB以内直接使用原图,避免分辨率与画质损失 if (file.size <= maxSizeMB * 1024 * 1024) { console.log(`[压缩] 免检通过: ${(file.size / 1024 / 1024).toFixed(2)}MB, ${width}x${height},使用原图`); resolve(file); return; } const canvas = document.createElement('canvas'); let newWidth = width; let newHeight = height; canvas.width = newWidth; canvas.height = newHeight; const ctx = canvas.getContext('2d', { alpha: isPNG, // PNG需要透明通道 desynchronized: false, // 同步渲染,保证质量 willReadFrequently: false, }); if (!ctx) { reject(new Error('无法创建 Canvas 上下文')); return; } // ✅ 启用高质量图像平滑,减少锯齿 ctx.imageSmoothingEnabled = true; ctx.imageSmoothingQuality = 'high'; ctx.drawImage(img, 0, 0, newWidth, newHeight); // ✅ 保持原格式压缩 // 策略:优先缩小尺寸,尽量保持高质量,让服务器端做最终优化 const outputFormat = isPNG ? 'image/png' : 'image/jpeg'; const TARGET_SIZE_MB = maxSizeMB * 0.9; // 目标大小(留10%余量给服务器端优化) // 如果尺寸已经调整过,使用高质量直接输出(让服务器端优化) const wasResized = newWidth !== width || newHeight !== height; let quality = wasResized ? 0.95 : 0.92; // 如果已缩小尺寸,使用更高质量 const minQuality = 0.90; // 最低质量(提高,避免画质损失) const tryCompress = () => { canvas.toBlob( (blob) => { if (!blob) { reject(new Error('图片压缩失败')); return; } console.log(`[压缩] 格式: ${outputFormat}, 质量: ${quality}, 大小: ${(blob.size / 1024 / 1024).toFixed(2)}MB`); // 目标 TARGET_SIZE_MB(留余量给服务器端优化) // 如果还是太大且质量可以降低,继续压缩 if (blob.size > TARGET_SIZE_MB * 1024 * 1024 && quality > minQuality) { quality -= 0.02; // 每次减少0.02(更精细的调整) tryCompress(); } else if (blob.size > TARGET_SIZE_MB * 1024 * 1024 && quality <= minQuality) { // 如果质量已到最低还是太大,缩小尺寸而不是继续降低质量 if (newWidth > 1000 || newHeight > 1000) { newWidth = Math.floor(newWidth * 0.95); newHeight = Math.floor(newHeight * 0.95); canvas.width = newWidth; canvas.height = newHeight; ctx.imageSmoothingEnabled = true; ctx.imageSmoothingQuality = 'high'; ctx.drawImage(img, 0, 0, newWidth, newHeight); quality = 0.95; // 重置质量(缩小尺寸后使用高质量) tryCompress(); } else { // 已经很小了,使用当前结果 const compressedFile = new File([blob], file.name, { type: outputFormat, // 保持原格式 lastModified: Date.now(), }); console.log(`[压缩] 原始: ${(file.size / 1024 / 1024).toFixed(2)}MB → 压缩后: ${(compressedFile.size / 1024 / 1024).toFixed(2)}MB`); resolve(compressedFile); } } else { // 大小合适,使用当前结果(让服务器端做最终优化) const compressedFile = new File([blob], file.name, { type: outputFormat, // 保持原格式 lastModified: Date.now(), }); console.log(`[压缩] 原始: ${(file.size / 1024 / 1024).toFixed(2)}MB → 压缩后: ${(compressedFile.size / 1024 / 1024).toFixed(2)}MB (将交由服务器端进一步优化)`); resolve(compressedFile); } }, outputFormat, // 保持原格式 isPNG ? undefined : quality // PNG不需要quality参数 ); }; tryCompress(); }; img.onerror = () => { URL.revokeObjectURL(objectUrl); reject(new Error('图片加载失败')); }; img.src = objectUrl; }); }, []); const uploadFile = useCallback(async ( file: File, uploadQueueItemId?: string ): Promise => { try { // 先压缩图片 console.log(`[上传] 开始处理文件: ${file.name}, 大小: ${(file.size / 1024 / 1024).toFixed(2)}MB`); updateUploadQueueItem(uploadQueueItemId, { progress: 2, status: "preparing", }); const processedFile = await compressImage(file); updateUploadQueueItem(uploadQueueItemId, { progress: 6, status: "uploading", }); const formData = new FormData(); formData.append("file", processedFile); const response = await fetchUploadWithRetry(formData, { onProgress: (progress) => { updateUploadQueueItem(uploadQueueItemId, { progress: progress.percent, status: getUploadQueueStatus(progress.phase), }); }, }); if (response.ok) { updateUploadQueueItem(uploadQueueItemId, { progress: 100, status: "finalizing", }); const data = await response.json(); const { url, pathname, contentType } = data; console.log(`[上传] 成功:`, { url, pathname, contentType }); toast.success("图片上传成功"); return { url, // 完整的可访问 URL name: file.name, // 使用原始文件名,不是 pathname contentType, assetId: typeof data.assetId === "string" ? data.assetId : undefined, }; } 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; } }, [compressImage, updateUploadQueueItem]); const contextProps = useMemo( () => ({ usage, }), [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; } 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 { 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, removeUploadQueueItems, updateAttachmentAtSlot, uploadFile, videoSlotUploadItemIds, ] ); // 🆕 视频模式下的图片数量限制 const getVideoImageLimit = useCallback(() => { if (!isVideoModel || !videoParams) { return { max: Number.POSITIVE_INFINITY, label: "" as const }; } if (videoToolCapability?.modeOptions?.length) { const modeOption = videoToolCapability.modeOptions.find( (item) => item.mode === videoParams.mode ); if (modeOption) { if (modeOption.imageMax === 0) { return { max: 0, label: `${modeOption.label}模式不支持图片` as const }; } if (modeOption.imageMin === modeOption.imageMax) { return { max: modeOption.imageMax, label: `${modeOption.label}模式仅支持 ${modeOption.imageMax} 张图` as const, }; } return { max: modeOption.imageMax, label: `${modeOption.label}模式最多 ${modeOption.imageMax} 张图` as const, }; } } if (videoProviderType === "fal-video" || videoProviderType === "ruxa-video") { const providerLabel = videoProviderType === "ruxa-video" ? "RUXA" : "FAL"; switch (videoParams.mode) { case "first-last-frame": return { max: 2, label: `${providerLabel} 首尾帧模式需要 2 张图(首帧 + 尾帧)` as const }; case "first-frame": default: return { max: 1, label: `${providerLabel} 图生视频模式需要 1 张起始图` as const }; } } switch (videoParams.mode) { case "text-to-video": return { max: 0, label: "文生视频模式不支持图片" as const }; case "first-frame": return { max: 1, label: "主体参考模式仅支持 1 张图" as const }; case "first-last-frame": return { max: 2, label: "首尾帧模式仅支持 2 张图" as const }; case "reference-images": return { max: 4, label: "智能多帧模式最多 4 张图" as const }; default: return { max: Number.POSITIVE_INFINITY, label: "" as const }; } }, [isVideoModel, videoParams, videoProviderType, videoToolCapability]); const enforceVideoImageLimit = useCallback( (incomingCount: number) => { const { max, label } = getVideoImageLimit(); if (!Number.isFinite(max)) { return incomingCount; } const remaining = max - attachments.length; if (max === 0) { toast.error(label || "当前模式不支持上传图片"); return 0; } if (remaining <= 0) { toast.error(`已达到图片上限(${max} 张)`); return 0; } if (incomingCount > remaining) { toast.error(`最多再添加 ${remaining} 张图片(总上限 ${max} 张)`); return remaining; } return incomingCount; }, [attachments.length, getVideoImageLimit] ); // 模式切换时自动清理不合法的图片 useEffect(() => { if (!isVideoModel || !videoParams) { return; } const { max, label } = getVideoImageLimit(); if (!Number.isFinite(max)) { return; } if (max === 0 && attachments.length > 0) { setAttachments([]); toast.error(label || "当前模式不支持图片,已移除附件"); return; } if (attachments.length > max) { setAttachments((curr) => curr.slice(0, max)); toast.error(`当前模式最多 ${max} 张图片,已自动保留前 ${max} 张`); } }, [attachments.length, getVideoImageLimit, isVideoModel, setAttachments, videoParams]); const handleFileChange = useCallback( async (event: ChangeEvent) => { const files = Array.from(event.target.files || []); const allowedCount = enforceVideoImageLimit(files.length); if (allowedCount <= 0) { if (fileInputRef.current) { fileInputRef.current.value = ""; } return; } const filesToUpload = files.slice(0, allowedCount); const queueItems = filesToUpload.map((file) => createUploadQueueItem(file)); 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(successfulUploadIds); if (fileInputRef.current) { fileInputRef.current.value = ""; } } }, [ enforceVideoImageLimit, holdAndRemoveUploadQueueItems, setAttachments, updateUploadQueueItem, uploadFile, ] ); const handlePaste = useCallback( async (event: ClipboardEvent) => { const items = event.clipboardData?.items; if (!items) { return; } const imageItems = Array.from(items).filter((item) => item.type.startsWith("image/") ); if (imageItems.length === 0) { return; } // Prevent default paste behavior for images event.preventDefault(); let queueItems: UploadQueueItem[] = []; try { const allowedCount = enforceVideoImageLimit(imageItems.length); if (allowedCount <= 0) { return; } const filesToUpload = imageItems .map((item) => item.getAsFile()) .filter((file): file is File => file !== null) .slice(0, allowedCount); queueItems = filesToUpload.map((file, index) => createUploadQueueItem(file, `已粘贴图片 ${index + 1}`) ); 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 && attachment.url !== undefined && 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("粘贴的图片上传失败"); } }, [ enforceVideoImageLimit, holdAndRemoveUploadQueueItems, setAttachments, updateUploadQueueItem, uploadFile, ] ); // Add paste event listener to textarea useEffect(() => { const textarea = textareaRef.current; if (!textarea) { return; } textarea.addEventListener("paste", handlePaste); return () => textarea.removeEventListener("paste", handlePaste); }, [handlePaste]); // Handle drag and drop const handleDragOver = useCallback((event: React.DragEvent) => { event.preventDefault(); event.stopPropagation(); // 🆕 只有拖动外部文件时才显示上传覆盖层(不影响图片排序) const isInternalDrag = event.dataTransfer.types.includes("application/x-attachment-sort"); if (!isInternalDrag) { setIsDragging(true); } }, []); const handleDragEnter = useCallback((event: React.DragEvent) => { event.preventDefault(); event.stopPropagation(); // 🆕 只有拖动外部文件时才显示上传覆盖层(不影响图片排序) const isInternalDrag = event.dataTransfer.types.includes("application/x-attachment-sort"); if (!isInternalDrag) { setIsDragging(true); } }, []); const handleDragLeave = useCallback((event: React.DragEvent) => { event.preventDefault(); event.stopPropagation(); // 只在离开整个容器时隐藏 if (event.currentTarget === event.target) { setIsDragging(false); } }, []); const handleDrop = useCallback( async (event: React.DragEvent) => { event.preventDefault(); event.stopPropagation(); setIsDragging(false); // 🆕 如果是内部拖动(图片排序),不处理上传逻辑 const isInternalDrag = event.dataTransfer.types.includes("application/x-attachment-sort"); if (isInternalDrag) { return; } const files = Array.from(event.dataTransfer.files); const imageFiles = files.filter((file) => file.type.startsWith("image/")); if (imageFiles.length === 0) { toast.error("请拖拽图片文件"); return; } const allowedCount = enforceVideoImageLimit(imageFiles.length); if (allowedCount <= 0) { return; } const filesToUpload = imageFiles.slice(0, allowedCount); const queueItems = filesToUpload.map((file) => createUploadQueueItem(file)); console.log("[拖拽] 开始处理文件:", imageFiles.length, "个"); // 添加到上传队列 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(successfulUploadIds); } }, [ enforceVideoImageLimit, holdAndRemoveUploadQueueItems, setAttachments, updateUploadQueueItem, uploadFile, ] ); // 🆕 拖拽排序处理函数 const handleAttachmentDragStart = useCallback((index: number) => (e: React.DragEvent) => { setDraggedIndex(index); e.dataTransfer.effectAllowed = "move"; // 🆕 设置标记,表明这是内部拖动(图片排序) e.dataTransfer.setData("application/x-attachment-sort", String(index)); }, []); const handleAttachmentDragEnd = useCallback(() => { setDraggedIndex(null); }, []); const handleAttachmentDragOver = useCallback((index: number) => (e: React.DragEvent) => { e.preventDefault(); e.dataTransfer.dropEffect = "move"; }, []); const handleAttachmentDrop = useCallback((dropIndex: number) => (e: React.DragEvent) => { e.preventDefault(); if (draggedIndex === null || draggedIndex === dropIndex) { return; } setAttachments((currentAttachments) => { const newAttachments = [...currentAttachments]; const draggedItem = newAttachments[draggedIndex]; // 移除被拖拽的项 newAttachments.splice(draggedIndex, 1); // 插入到新位置 newAttachments.splice(dropIndex, 0, draggedItem); return newAttachments; }); setDraggedIndex(null); }, [draggedIndex, setAttachments]); return (
{messages.length === 0 && attachments.length === 0 && uploadQueue.length === 0 && ( )} {/* 拖拽覆盖层提示 */} {isDragging && (

释放以上传图片

)} {videoFrameSlots.map((_, index) => ( handleVideoSlotFileChange(index, event)} ref={(element) => { videoSlotInputRefs.current[index] = element; }} /> ))} { event.preventDefault(); if (status === "submitted" || status === "streaming") { toast.error("请等待模型返回当前回复后再继续"); } else { void submitForm(); } }} > {uploadProgressSummary && (
{uploadProgressSummary.hasFailed ? uploadProgressSummary.statusText : uploadProgressSummary.count > 1 ? `正在上传 ${uploadProgressSummary.count} 张图片` : uploadProgressSummary.statusText} {uploadProgressSummary.percent}%
{uploadProgressSummary.fileName}
)} {videoFrameSlots.length > 0 && (
{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 isUploadFailed = uploadItem?.status === "failed"; const uploadProgress = uploadItem?.progress ?? 0; return ( ); })}
)} {(attachments.length > 0 || uploadQueue.length > 0) && videoFrameSlots.length === 0 && (
{attachments.map((attachment, index) => ( { setAttachments((currentAttachments) => currentAttachments.filter((a) => a.url !== attachment.url) ); if (fileInputRef.current) { fileInputRef.current.value = ""; } }} onAnnotate={(annotation) => { // 自动插入标注文本到输入框(用「」包裹,用于高亮显示) setInput((prev) => { const currentValue = prev || ""; const separator = currentValue ? " " : ""; return `${currentValue}${separator}「${annotation.formattedText}」`; }); toast.success(`已添加标注:${annotation.description}`); }} onDrawingComplete={async (annotatedImageUrl) => { // 将标注后的图片替换原图片(或添加为新附件) const fileName = `annotated-${Date.now()}.png`; // 如果仍然是 base64 格式,先上传到服务器 let finalUrl = annotatedImageUrl; let assetId: string | undefined; if (annotatedImageUrl.startsWith("data:")) { try { toast.info("正在上传图片..."); const response = await fetch(annotatedImageUrl); const blob = await response.blob(); const formData = new FormData(); formData.append("file", blob, fileName); const uploadResponse = await fetchUploadWithRetry(formData); if (uploadResponse.ok) { const result = await uploadResponse.json(); finalUrl = result.url; assetId = typeof result.assetId === "string" && result.assetId.trim() ? result.assetId.trim() : undefined; } } catch (error) { console.error("上传标注图片失败:", error); toast.error("上传失败"); return; } } setAttachments((prev) => [ ...prev, { url: finalUrl, name: fileName, contentType: "image/png", assetId, }, ]); // 如果输入框为空,自动填入提示词 setInput((prev) => prev || "请根据我的标注修改这张图片:"); }} imageIndex={index + 1} /> ))} {uploadQueue.map((item) => ( removeUploadQueueItems([item.id])} uploadProgress={item.progress} uploadStatus={item.status} /> ))}
)} {/* 超时提示 */} {!isWithinUsageTime && siteSettings?.usageTimeMessage && (
{siteSettings.usageTimeMessage}
)}
{" "}
{showQuickModeSwitch ? (
) : null} {isVideoModel ? ( <> {onVideoParamsChange && ( )} ) : ( <> {!isAgentMode ? ( ) : null} {isImageModel && onImageParamsChange && ( )} )}
{isAgentMode ? ( ) : null} {status === "submitted" || status === "streaming" ? ( ) : ( <> {estimatedPointsCost ? (
{estimatedPointsCost.cost} 积分
) : null} 0 || !isWithinUsageTime} status={status} > )}
{ if (!open) setInsufficientPointsState(null); }} > 积分不足 {insufficientPointsState ? `当前余额 ${insufficientPointsState.balance},本次预计消耗 ${insufficientPointsState.expectedCost}(${insufficientPointsState.unitCost} x ${insufficientPointsState.imageCount} 张)。请先充值后再发送生图需求。` : "当前积分不足,请先充值后再发送生图需求。"} 稍后再说 { setInsufficientPointsState(null); router.push("/recharge"); }} > 去充值
); } export const MultimodalInput = memo( PureMultimodalInput, (prevProps, nextProps) => { if (prevProps.input !== nextProps.input) { return false; } if (prevProps.status !== nextProps.status) { return false; } if (!equal(prevProps.attachments, nextProps.attachments)) { return false; } if (prevProps.selectedVisibilityType !== nextProps.selectedVisibilityType) { return false; } if (prevProps.selectedModelId !== nextProps.selectedModelId) { return false; } if (prevProps.isVideoModel !== nextProps.isVideoModel) { return false; } if (!equal(prevProps.videoParams, nextProps.videoParams)) { return false; } if (prevProps.videoProviderType !== nextProps.videoProviderType) { return false; } if (!equal(prevProps.videoToolCapability, nextProps.videoToolCapability)) { return false; } if (prevProps.isImageModel !== nextProps.isImageModel) { return false; } if (!equal(prevProps.imageParams, nextProps.imageParams)) { return false; } if (!equal(prevProps.imageGenConfig, nextProps.imageGenConfig)) { return false; } return true; } ); function PureAttachmentsButton({ fileInputRef, status, selectedModelId, }: { fileInputRef: React.MutableRefObject; status: UseChatHelpers["status"]; selectedModelId: string; }) { const isReasoningModel = selectedModelId === "chat-model-reasoning"; return ( ); } const AttachmentsButton = memo(PureAttachmentsButton); function PureModelSelectorCompact({ selectedModelId, onModelChange, attachments, avatarOnly = false, readOnly = false, modeFilter = "all", }: { selectedModelId: string; onModelChange?: (modelId: string) => void; attachments: Attachment[]; avatarOnly?: boolean; readOnly?: boolean; modeFilter?: "all" | "image" | "video"; }) { const [optimisticModelId, setOptimisticModelId] = useState(selectedModelId); // 从 API 获取动态模型配置 const { data: dynamicModelsData, isLoading } = useSWR<{ models: DynamicModel[]; defaultModelId: string | null; agentModel?: { id: string; displayName: string; avatarUrl?: string | null; providerType: string; } | null; }>("/api/models?includeUnavailable=1", fetcher); // 获取站点设置 const { data: siteSettings } = useSWR<{ showAutoModel: boolean; }>("/api/site-settings?scope=chat", fetcher); const allModels = useMemo( () => (dynamicModelsData?.models ?? []).filter( (model) => model.modelId !== "volcano-video-master" ), [dynamicModelsData?.models] ); // 🆕 将模型分为主模型和子模型,构建层级结构 const { mainModels, subModelsMap } = useMemo(() => { const main: DynamicModel[] = []; const subMap: Record = {}; allModels.forEach(model => { if (model.isSubModel && model.parentModelId) { if (!subMap[model.parentModelId]) { subMap[model.parentModelId] = []; } subMap[model.parentModelId].push(model); } else { main.push(model); } }); return { mainModels: main, subModelsMap: subMap }; }, [allModels]); // 🆕 展开显示:主模型 + 其子模型(缩进显示) const dynamicModels = useMemo(() => { const result: DynamicModel[] = []; mainModels.forEach(mainModel => { result.push(mainModel); // 如果有子模型,添加到主模型后面 if (subModelsMap[mainModel.id]) { result.push(...subModelsMap[mainModel.id]); } }); return result; }, [mainModels, subModelsMap]); // 定义自动模式 const autoModel: DynamicModel = { id: "auto", modelId: "auto", displayName: "Agent模式", avatarUrl: dynamicModelsData?.agentModel?.avatarUrl || null, description: "根据您的意图自动选择最合适的模型", providerType: "system", supportsVision: true, isDefault: false, providerName: "System", selectable: true, }; const modeFilteredModels = useMemo(() => { if (modeFilter === "video") { return dynamicModels.filter((model) => isVideoProviderType(model)); } if (modeFilter === "image") { return dynamicModels.filter((model) => isImageGenerationModel(model)); } return dynamicModels; }, [dynamicModels, modeFilter]); const selectableModeFilteredModels = useMemo( () => modeFilteredModels.filter((model) => model.selectable !== false), [modeFilteredModels] ); // 合并模型列表(根据设置决定是否包含自动模型) const showAutoModel = siteSettings?.showAutoModel ?? true; const canShowAutoModel = showAutoModel && modeFilter === "all"; const displayModels = canShowAutoModel ? [autoModel, ...modeFilteredModels] : modeFilteredModels; useEffect(() => { setOptimisticModelId(selectedModelId); }, [selectedModelId]); useEffect(() => { if (displayModels.length === 0) return; const currentExistsInDisplay = displayModels.some( (m) => m.id === selectedModelId && m.selectable !== false ); if (currentExistsInDisplay) return; const currentExistsInAll = allModels.some( (m) => m.id === selectedModelId && m.selectable !== false ); if (currentExistsInAll) { return; } const fallbackImageModel = selectableModeFilteredModels.find((model) => isNanoBananaProModel(model)) || selectableModeFilteredModels.find((model) => model.isDefault) || selectableModeFilteredModels[0]; const fallbackModelId = fallbackImageModel?.id ?? (canShowAutoModel ? "auto" : ""); if (!fallbackModelId) return; setOptimisticModelId(fallbackModelId); onModelChange?.(fallbackModelId); startTransition(() => { saveChatModelAsCookie(fallbackModelId); }); }, [ allModels, canShowAutoModel, displayModels, onModelChange, selectableModeFilteredModels, selectedModelId, ]); // 自动选择模型的逻辑 - 必须在所有条件 return 之前定义 const selectBestModel = useCallback(() => { const hasAttachments = attachments && attachments.length > 0; // 优先选择支持视觉的模型(如果有附件) if (hasAttachments) { const visionModel = dynamicModels.find( (m) => m.selectable !== false && (m.supportsVision || m.isMultimodal) ); if (visionModel) return visionModel; } // 否则选择默认模型 const defaultModel = dynamicModels.find( (m) => m.selectable !== false && m.isDefault ); if (defaultModel) return defaultModel; // 最后选择第一个可用模型 return dynamicModels.find((m) => m.selectable !== false); }, [dynamicModels, attachments]); // 从动态模型中查找当前选中的模型 const selectedModel = useMemo(() => { const dynamicModel = displayModels.find((m) => m.id === optimisticModelId); if (dynamicModel) { 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, avatarUrl: displayModels[0].avatarUrl || null, }; } return { id: "", name: "未配置模型", description: null, avatarUrl: null }; }, [optimisticModelId, displayModels]); if (isLoading) { if (avatarOnly) { return ( ); } return ( ); } if (displayModels.length === 0) { if (avatarOnly) { return ( ); } return ( ); } if (readOnly) { const readOnlyAvatarUrl = selectedModelId === "auto" ? dynamicModelsData?.agentModel?.avatarUrl || selectedModel?.avatarUrl || null : selectedModel?.avatarUrl || null; const readOnlyName = selectedModelId === "auto" ? dynamicModelsData?.agentModel?.displayName || "Agent模式" : selectedModel?.name || "模型"; if (avatarOnly) { return (
{readOnlyAvatarUrl ? ( {readOnlyName} ) : ( )}
); } return ( ); } return ( { const model = displayModels.find((m) => m.id === modelId); if (model) { if (model.selectable === false) { toast.info(model.disabledReason || "模型维护中,请稍后再试"); return; } // 🆕 如果选择的是"自动"模式,直接传递 "auto" 给后端 // 后端会使用智能分析器进行意图识别和模型选择 if (model.id === "auto") { console.log("[自动模式] 选择Agent模式,后端将进行意图分析和模型路由"); setOptimisticModelId("auto"); onModelChange?.("auto"); startTransition(() => { saveChatModelAsCookie("auto"); }); return; } setOptimisticModelId(model.id); onModelChange?.(model.id); startTransition(() => { saveChatModelAsCookie(model.id); }); } }} value={selectedModel?.id} > {avatarOnly ? ( ) : ( )}
{displayModels.map((model) => ( span:first-child]:hidden [&>span:last-child]:block [&>span:last-child]:w-full", "focus:bg-[#e7ebf0] focus:text-[#1f2937]", "data-[state=checked]:bg-[#e7ebf0] data-[state=checked]:text-[#1f2937]", model.isSubModel ? "pl-9" : "", model.selectable === false && "opacity-55" )} > {model.avatarUrl ? ( {model.displayName} ) : ( {(() => { if (isVideoProviderType(model)) { return )} {model.isSubModel ? `└ ${model.displayName}` : model.displayName} {getImageModelCreditDiscountLabel(model.imageGenConfig) ? ( {getImageModelCreditDiscountLabel(model.imageGenConfig)} ) : null} {model.availabilityLabel ? ( {model.availabilityLabel} ) : null} {optimisticModelId === model.id ? ( ✓ ) : null} ))}
); } const ModelSelectorCompact = memo( PureModelSelectorCompact, (prevProps, nextProps) => { if (prevProps.selectedModelId !== nextProps.selectedModelId) { return false; } if (prevProps.avatarOnly !== nextProps.avatarOnly) { return false; } if (prevProps.readOnly !== nextProps.readOnly) { return false; } if (prevProps.modeFilter !== nextProps.modeFilter) { return false; } if (!equal(prevProps.attachments, nextProps.attachments)) { return false; } return true; } ); function PureStopButton({ stop, setMessages, }: { stop: () => void; setMessages: UseChatHelpers["setMessages"]; }) { return ( ); } const StopButton = memo(PureStopButton);