ai-chatbot/components/multimodal-input.tsx
Your Name 4c25d433f8
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled
优化图片预览和标注功能
2025-12-19 01:12:08 +08:00

1142 lines
36 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"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,
}: {
chatId: string;
input: string;
setInput: Dispatch<SetStateAction<string>>;
status: UseChatHelpers<ChatMessage>["status"];
stop: () => void;
attachments: Attachment[];
setAttachments: Dispatch<SetStateAction<Attachment[]>>;
messages: UIMessage[];
setMessages: UseChatHelpers<ChatMessage>["setMessages"];
sendMessage: UseChatHelpers<ChatMessage>["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;
}) {
// 🔍 调试日志:检查图片模型相关props
console.log('[MultimodalInput] Render - isImageModel:', isImageModel);
console.log('[MultimodalInput] Render - imageParams:', imageParams);
console.log('[MultimodalInput] Render - onImageParamsChange:', !!onImageParamsChange);
const textareaRef = useRef<HTMLTextAreaElement>(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<HTMLTextAreaElement>) => {
setInput(event.target.value);
};
const fileInputRef = useRef<HTMLInputElement>(null);
const [uploadQueue, setUploadQueue] = useState<string[]>([]);
const [isDragging, setIsDragging] = useState(false);
const [draggedIndex, setDraggedIndex] = useState<number | null>(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,
]);
// 压缩图片函数
const compressImage = useCallback(async (file: File, maxSizeMB = 8): Promise<File> => {
// 如果不是图片,直接返回
if (!file.type.startsWith('image/')) {
return file;
}
// 如果已经是 WebP 且小于限制,直接返回
if (file.type === 'image/webp' && file.size <= maxSizeMB * 1024 * 1024) {
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;
const maxDimension = 4096; // 4K
// ✅ 免检通道:如果图片小于 5MB 且分辨率在 4K 以内,直接返回原图
// 这样可以避免不必要的压缩导致的画质损失
if (file.size <= 5 * 1024 * 1024 && width <= maxDimension && height <= maxDimension) {
console.log(`[压缩] 免检通过: ${(file.size / 1024 / 1024).toFixed(2)}MB, ${width}x${height} (小于5MB且小于4K),使用原图`);
resolve(file);
return;
}
const canvas = document.createElement('canvas');
let newWidth = width;
let newHeight = height;
// 计算缩放比例以达到目标大小
if (newWidth > maxDimension || newHeight > maxDimension) {
if (newWidth > newHeight) {
newHeight = (newHeight / newWidth) * maxDimension;
newWidth = maxDimension;
} else {
newWidth = (newWidth / newHeight) * maxDimension;
newHeight = maxDimension;
}
}
canvas.width = newWidth;
canvas.height = newHeight;
const ctx = canvas.getContext('2d');
if (!ctx) {
reject(new Error('无法创建 Canvas 上下文'));
return;
}
ctx.drawImage(img, 0, 0, newWidth, newHeight);
// 尝试不同的质量等级,直到文件大小小于限制
let quality = 0.9;
const tryCompress = () => {
canvas.toBlob(
(blob) => {
if (!blob) {
reject(new Error('图片压缩失败'));
return;
}
console.log(`[压缩] 格式: image/webp, 质量: ${quality}, 大小: ${(blob.size / 1024 / 1024).toFixed(2)}MB`);
// 目标 maxSizeMB (默认8MB)
// 如果还是太大且质量可以降低,继续压缩
if (blob.size > maxSizeMB * 1024 * 1024 && quality > 0.6) { // 最低质量 0.6
quality -= 0.1;
tryCompress();
} else {
// 创建新文件,强制使用 .webp 扩展名
const newName = file.name.replace(/\.[^/.]+$/, "") + ".webp";
const compressedFile = new File([blob], newName, {
type: "image/webp",
lastModified: Date.now(),
});
console.log(`[压缩] 原始: ${(file.size / 1024 / 1024).toFixed(2)}MB → 压缩后: ${(compressedFile.size / 1024 / 1024).toFixed(2)}MB`);
resolve(compressedFile);
}
},
"image/webp",
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<HTMLInputElement>) => {
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 (
<div
className={cn("relative flex w-full flex-col gap-4", className)}
onDragOver={handleDragOver}
onDragEnter={handleDragEnter}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
>
{messages.length === 0 &&
attachments.length === 0 &&
uploadQueue.length === 0 && (
<SuggestedActions
chatId={chatId}
selectedVisibilityType={selectedVisibilityType}
sendMessage={sendMessage}
/>
)}
{/* 拖拽覆盖层提示 */}
{isDragging && (
<div className="absolute inset-0 z-50 flex items-center justify-center rounded-xl border-2 border-dashed border-primary bg-primary/10 backdrop-blur-sm">
<div className="flex flex-col items-center gap-2 text-primary">
<ArrowUpIcon className="h-8 w-8" />
<p className="text-lg font-medium">释放以上传图片</p>
</div>
</div>
)}
<input
className="-top-4 -left-4 pointer-events-none fixed size-0.5 opacity-0"
multiple
onChange={handleFileChange}
ref={fileInputRef}
tabIndex={-1}
type="file"
/>
<PromptInput
className="rounded-xl border border-border bg-background p-3 shadow-xs transition-all duration-200 focus-within:border-border hover:border-muted-foreground/50"
onSubmit={(event) => {
event.preventDefault();
if (status !== "ready") {
toast.error("请等待模型返回当前回复后再继续");
} else {
submitForm();
}
}}
>
{(attachments.length > 0 || uploadQueue.length > 0) && (
<div
className="flex flex-row items-end gap-2 overflow-x-scroll"
data-testid="attachments-preview"
>
{attachments.map((attachment, index) => (
<PreviewAttachment
attachment={attachment}
key={attachment.url}
draggable={true}
onDragStart={handleAttachmentDragStart(index)}
onDragEnd={handleAttachmentDragEnd}
onDragOver={handleAttachmentDragOver(index)}
onDrop={handleAttachmentDrop(index)}
onRemove={() => {
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={(annotatedImageUrl) => {
// 将标注后的图片替换原图片(或添加为新附件)
const fileName = `annotated-${Date.now()}.png`;
setAttachments((prev) => [
...prev,
{
url: annotatedImageUrl,
name: fileName,
contentType: "image/png",
},
]);
// 如果输入框为空,自动填入提示词
setInput((prev) => prev || "请根据我的标注修改这张图片:");
}}
imageIndex={index + 1}
/>
))}
{uploadQueue.map((filename) => (
<PreviewAttachment
attachment={{
url: "",
name: filename,
contentType: "",
}}
isUploading={true}
key={filename}
/>
))}
</div>
)}
{/* 超时提示 */}
{!isWithinUsageTime && siteSettings?.usageTimeMessage && (
<div className="px-3 py-2 mb-2 text-sm text-center text-muted-foreground bg-muted/50 rounded-md">
{siteSettings.usageTimeMessage}
</div>
)}
<div className="flex flex-row items-start gap-1 sm:gap-2">
<PromptInputTextarea
autoFocus
className={cn(
"grow resize-none border-0! border-none! bg-transparent p-2 text-sm outline-none ring-0 [-ms-overflow-style:none] [scrollbar-width:none] placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-0 focus-visible:ring-offset-0 [&::-webkit-scrollbar]:hidden",
!isWithinUsageTime && "opacity-50 cursor-not-allowed"
)}
data-testid="multimodal-input"
disableAutoResize={true}
maxHeight={200}
minHeight={44}
onChange={handleInput}
placeholder={isWithinUsageTime ? "输入消息..." : "当前不在使用时间段内"}
ref={textareaRef}
rows={1}
value={input}
disabled={!isWithinUsageTime}
/>{" "}
<Context {...contextProps} />
</div>
<PromptInputToolbar className="!border-top-0 border-t-0! p-0 shadow-none dark:border-0 dark:border-transparent!">
<PromptInputTools className="gap-0 sm:gap-0.5 flex-1 overflow-x-auto">
<AttachmentsButton
fileInputRef={fileInputRef}
selectedModelId={selectedModelId}
status={status}
/>
<ModelSelectorCompact
onModelChange={onModelChange}
selectedModelId={selectedModelId}
attachments={attachments}
/>
{/* 🆕 视频参数栏(内嵌在模型选择器后面,同一行) */}
{isVideoModel && onVideoParamsChange && (
<VideoParamsBar
key={`video-params-${selectedModelId}-${isVideoModel}`}
modelName={videoModelName || "视频 3.0"}
initialParams={videoParams || undefined}
onParamsChange={onVideoParamsChange}
inline={true}
/>
)}
{/* 🆕 图片参数栏(内嵌在模型选择器后面,同一行) */}
{isImageModel && onImageParamsChange && (
<ImageParamsBar
key={`image-params-${selectedModelId}-${isImageModel}`}
modelName={imageModelName || "Gemini 图片生成"}
initialParams={imageParams || undefined}
onParamsChange={onImageParamsChange}
inline={true}
/>
)}
</PromptInputTools>
{status === "submitted" || status === "streaming" ? (
<StopButton setMessages={setMessages} stop={stop} />
) : (
<PromptInputSubmit
className="size-8 rounded-full bg-primary text-primary-foreground transition-colors duration-200 hover:bg-primary/90 disabled:bg-muted disabled:text-muted-foreground"
data-testid="send-button"
disabled={!input.trim() || uploadQueue.length > 0 || !isWithinUsageTime}
status={status}
>
<ArrowUpIcon size={14} />
</PromptInputSubmit>
)}
</PromptInputToolbar>
</PromptInput>
</div>
);
}
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<HTMLInputElement | null>;
status: UseChatHelpers<ChatMessage>["status"];
selectedModelId: string;
}) {
const isReasoningModel = selectedModelId === "chat-model-reasoning";
return (
<Button
className="aspect-square h-8 rounded-lg p-1 transition-colors hover:bg-accent"
data-testid="attachments-button"
disabled={status !== "ready" || isReasoningModel}
onClick={(event) => {
event.preventDefault();
fileInputRef.current?.click();
}}
variant="ghost"
>
<PaperclipIcon size={14} style={{ width: 14, height: 14 }} />
</Button>
);
}
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<string, DynamicModel[]> = {};
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 (
<Button className="h-8 px-2" variant="ghost" disabled>
<CpuIcon size={16} />
<span className="hidden font-medium text-xs sm:block">加载中...</span>
</Button>
);
}
if (displayModels.length === 0) {
return (
<Button className="h-8 px-2" variant="ghost" disabled>
<CpuIcon size={16} />
<span className="hidden font-medium text-xs sm:block">⚠️ 未配置</span>
</Button>
);
}
return (
<PromptInputModelSelect
onValueChange={(modelName) => {
const model = displayModels.find((m) => m.displayName === modelName);
if (model) {
// 如果选择的是"自动",则智能选择一个真实模型
if (model.id === "auto") {
const bestModel = selectBestModel();
if (bestModel) {
console.log(`[自动选择] 根据${attachments.length > 0 ? '有附件' : '无附件'}情况,选择模型: ${bestModel.displayName}`);
setOptimisticModelId(bestModel.id);
onModelChange?.(bestModel.id);
startTransition(() => {
saveChatModelAsCookie(bestModel.id);
});
return;
}
}
setOptimisticModelId(model.id);
onModelChange?.(model.id);
startTransition(() => {
saveChatModelAsCookie(model.id);
});
}
}}
value={selectedModel?.name}
>
<Trigger asChild>
<Button className="h-8 px-2" variant="ghost">
<CpuIcon size={16} />
<span className="hidden font-medium text-xs sm:block">
{selectedModel?.name}
</span>
<ChevronDownIcon size={16} />
</Button>
</Trigger>
<PromptInputModelSelectContent className="min-w-[260px] p-0">
<div className="flex flex-col gap-px">
{displayModels.map((model) => (
<SelectItem
key={model.id}
value={model.displayName}
className={model.isSubModel ? "pl-6" : ""}
>
<div className="truncate font-medium text-xs">
{model.isSubModel && "└ "}{model.displayName}
</div>
<div className="mt-px truncate text-[10px] text-muted-foreground leading-tight">
{model.description ?? model.providerName}
</div>
</SelectItem>
))}
</div>
</PromptInputModelSelectContent>
</PromptInputModelSelect>
);
}
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<ChatMessage>["setMessages"];
}) {
return (
<Button
className="size-8 rounded-full bg-foreground p-1 text-background transition-colors duration-200 hover:bg-foreground/90 disabled:bg-muted disabled:text-muted-foreground"
data-testid="stop-button"
onClick={(event) => {
event.preventDefault();
stop();
setMessages((messages) => messages);
}}
>
<StopIcon size={14} />
</Button>
);
}
const StopButton = memo(PureStopButton);