"use client"; import type { UseChatHelpers } from "@ai-sdk/react"; // 动态模型类型定义 type DynamicModel = { id: string; displayName: string; description: string | null; supportsVision: boolean; isDefault: boolean; providerName: string; isSubModel?: boolean; // 🆕 是否为子模型 parentModelId?: string | null; // 🆕 父模型ID capabilities?: any; // 🆕 模型能力配置 }; const fetcher = (url: string) => fetch(url).then((res) => res.json()); 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 { SelectItem } from "@/components/ui/select"; 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"; function PureMultimodalInput({ chatId, input, setInput, status, stop, attachments, setAttachments, messages, setMessages, sendMessage, className, selectedVisibilityType, selectedModelId, onModelChange, usage, isVideoModel, videoModelName, videoParams, onVideoParamsChange, isImageModel, imageModelName, imageParams, onImageParamsChange, imageProviderType, }: { 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; onVideoParamsChange?: (params: VideoGenerationParams) => void; isImageModel?: boolean; imageModelName?: string; imageParams?: ImageGenerationParams | null; onImageParamsChange?: (params: ImageGenerationParams) => void; imageProviderType?: string; // 🆕 图片模型提供商类型 }) { // 🔍 调试日志:检查图片模型相关props console.log('[MultimodalInput] Render - isImageModel:', isImageModel); console.log('[MultimodalInput] Render - imageParams:', imageParams); console.log('[MultimodalInput] Render - onImageParamsChange:', !!onImageParamsChange); const textareaRef = useRef(null); const { width } = useWindowSize(); // 获取网站设置(用于时间限制检查) const { data: siteSettings } = useSWR("/api/site-settings", fetcher); // 检查当前时间是否在允许的使用时间段内 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]); // 每分钟更新一次检查 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 [uploadQueue, setUploadQueue] = useState([]); const [isDragging, setIsDragging] = useState(false); const [draggedIndex, setDraggedIndex] = useState(null); const submitForm = useCallback(() => { window.history.pushState({}, "", `/chat/${chatId}`); sendMessage({ role: "user", parts: [ ...attachments.map((attachment) => ({ type: "file" as const, url: attachment.url, name: attachment.name, mediaType: attachment.contentType, })), { type: "text", text: input, }, ], }); setAttachments([]); setLocalStorageInput(""); resetHeight(); setInput(""); if (width && width > 768) { textareaRef.current?.focus(); } }, [ 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) => { try { // 先压缩图片 console.log(`[上传] 开始处理文件: ${file.name}, 大小: ${(file.size / 1024 / 1024).toFixed(2)}MB`); const processedFile = await compressImage(file); const formData = new FormData(); formData.append("file", processedFile); const response = await fetch("/api/files/upload", { method: "POST", body: formData, }); if (response.ok) { 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, }; } const { error } = await response.json(); console.error(`[上传] 服务器返回错误:`, error); toast.error(error); } catch (_error) { console.error("[上传] 失败:", _error); toast.error(_error instanceof Error ? _error.message : "文件上传失败,请稍后重试"); } }, [compressImage]); const contextProps = useMemo( () => ({ usage, }), [usage] ); // 🆕 视频模式下的图片数量限制 const getVideoImageLimit = useCallback(() => { if (!isVideoModel || !videoParams) { return { max: Number.POSITIVE_INFINITY, label: "" as const }; } switch (videoParams.mode) { case "text-to-video": return { max: 0, label: "文生视频模式不支持图片" as const }; case "first-frame": return { max: 4, label: "智能多帧模式最多 4 张图" as const }; case "first-last-frame": return { max: 2, label: "首尾帧模式仅支持 2 张图" as const }; case "reference-images": return { max: 1, label: "主体参考模式仅支持 1 张图" as const }; default: return { max: Number.POSITIVE_INFINITY, label: "" as const }; } }, [isVideoModel, videoParams]); 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); setUploadQueue(filesToUpload.map((file) => file.name)); try { const uploadPromises = filesToUpload.map((file) => uploadFile(file)); const uploadedAttachments = await Promise.all(uploadPromises); const successfullyUploadedAttachments = uploadedAttachments.filter( (attachment) => attachment !== undefined ); setAttachments((currentAttachments) => [ ...currentAttachments, ...successfullyUploadedAttachments, ]); } catch (error) { console.error("Error uploading files!", error); } finally { setUploadQueue([]); if (fileInputRef.current) { fileInputRef.current.value = ""; } } }, [enforceVideoImageLimit, setAttachments, 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(); setUploadQueue((prev) => [...prev, "已粘贴图片"]); try { const allowedCount = enforceVideoImageLimit(imageItems.length); if (allowedCount <= 0) { setUploadQueue([]); return; } const uploadPromises = imageItems .map((item) => item.getAsFile()) .filter((file): file is File => file !== null) .slice(0, allowedCount) .map((file) => uploadFile(file)); const uploadedAttachments = await Promise.all(uploadPromises); const successfullyUploadedAttachments = uploadedAttachments.filter( (attachment) => attachment !== undefined && attachment.url !== undefined && attachment.contentType !== undefined ); setAttachments((curr) => [ ...curr, ...(successfullyUploadedAttachments as Attachment[]), ]); } catch (error) { console.error("Error uploading pasted images:", error); toast.error("粘贴的图片上传失败"); } finally { setUploadQueue([]); } }, [enforceVideoImageLimit, setAttachments, 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) { setUploadQueue([]); return; } const filesToUpload = imageFiles.slice(0, allowedCount); console.log("[拖拽] 开始处理文件:", imageFiles.length, "个"); // 添加到上传队列 setUploadQueue((prev) => [...prev, ...filesToUpload.map((f) => f.name)]); try { const uploadedAttachments = await Promise.all( filesToUpload.map((file) => uploadFile(file)) ); const validAttachments = uploadedAttachments.filter( (attachment): attachment is Attachment => attachment !== undefined ); setAttachments((currentAttachments) => [ ...currentAttachments, ...validAttachments, ]); } catch (error) { console.error("拖拽上传失败:", error); toast.error("拖拽的图片上传失败"); } finally { setUploadQueue([]); } }, [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 && (

释放以上传图片

)} { event.preventDefault(); if (status === "submitted" || status === "streaming") { toast.error("请等待模型返回当前回复后再继续"); } else { submitForm(); } }} > {(attachments.length > 0 || uploadQueue.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; 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 fetch("/api/files/upload", { method: "POST", body: formData, }); if (uploadResponse.ok) { const result = await uploadResponse.json(); finalUrl = result.url; } } catch (error) { console.error("上传标注图片失败:", error); toast.error("上传失败"); return; } } setAttachments((prev) => [ ...prev, { url: finalUrl, name: fileName, contentType: "image/png", }, ]); // 如果输入框为空,自动填入提示词 setInput((prev) => prev || "请根据我的标注修改这张图片:"); }} imageIndex={index + 1} /> ))} {uploadQueue.map((filename) => ( ))}
)} {/* 超时提示 */} {!isWithinUsageTime && siteSettings?.usageTimeMessage && (
{siteSettings.usageTimeMessage}
)}
{" "}
{/* 🆕 视频参数栏(内嵌在模型选择器后面,同一行) */} {isVideoModel && onVideoParamsChange && ( )} {/* 🆕 图片参数栏(内嵌在模型选择器后面,同一行) */} {isImageModel && onImageParamsChange && ( )} {status === "submitted" || status === "streaming" ? ( ) : ( 0 || !isWithinUsageTime} status={status} > )}
); } 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.isImageModel !== nextProps.isImageModel) { return false; } if (!equal(prevProps.imageParams, nextProps.imageParams)) { 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, }: { selectedModelId: string; onModelChange?: (modelId: string) => void; attachments: Attachment[]; }) { const [optimisticModelId, setOptimisticModelId] = useState(selectedModelId); // 从 API 获取动态模型配置 const { data: dynamicModelsData, isLoading } = useSWR<{ models: DynamicModel[]; defaultModelId: string | null; }>("/api/models", fetcher); // 获取站点设置 const { data: siteSettings } = useSWR<{ showAutoModel: boolean; }>("/api/site-settings", fetcher); const allModels = 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", displayName: "✨ 智能自动 (Auto)", description: "根据您的意图自动选择最合适的模型", supportsVision: true, isDefault: false, providerName: "System", }; // 合并模型列表(根据设置决定是否包含自动模型) const showAutoModel = siteSettings?.showAutoModel ?? true; const displayModels = showAutoModel ? [autoModel, ...dynamicModels] : dynamicModels; useEffect(() => { setOptimisticModelId(selectedModelId); }, [selectedModelId]); // 自动选择模型的逻辑 - 必须在所有条件 return 之前定义 const selectBestModel = useCallback(() => { const hasAttachments = attachments && attachments.length > 0; // 优先选择支持视觉的模型(如果有附件) if (hasAttachments) { const visionModel = dynamicModels.find((m) => m.supportsVision); if (visionModel) return visionModel; } // 否则选择默认模型 const defaultModel = dynamicModels.find((m) => m.isDefault); if (defaultModel) return defaultModel; // 最后选择第一个可用模型 return dynamicModels[0]; }, [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 }; } if (displayModels.length > 0) { return { id: displayModels[0].id, name: displayModels[0].displayName, description: displayModels[0].description }; } return { id: "", name: "未配置模型", description: null }; }, [optimisticModelId, displayModels]); if (isLoading) { return ( ); } if (displayModels.length === 0) { return ( ); } return ( { const model = displayModels.find((m) => m.displayName === modelName); if (model) { // 🆕 如果选择的是"自动"模式,直接传递 "auto" 给后端 // 后端会使用智能分析器进行意图识别和模型选择 if (model.id === "auto") { console.log("[自动模式] 选择智能自动模式,后端将进行意图分析和模型路由"); setOptimisticModelId("auto"); onModelChange?.("auto"); startTransition(() => { saveChatModelAsCookie("auto"); }); return; } setOptimisticModelId(model.id); onModelChange?.(model.id); startTransition(() => { saveChatModelAsCookie(model.id); }); } }} value={selectedModel?.name} >
{displayModels.map((model) => (
{model.isSubModel && "└ "}{model.displayName}
{model.description ?? model.providerName}
))}
); } const ModelSelectorCompact = memo( PureModelSelectorCompact, (prevProps, nextProps) => { if (prevProps.selectedModelId !== nextProps.selectedModelId) { 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);