1674 lines
56 KiB
TypeScript
1674 lines
56 KiB
TypeScript
"use client";
|
||
|
||
import type { UseChatHelpers } from "@ai-sdk/react";
|
||
import { Bot, ImageIcon, Sparkles, Video } from "lucide-react";
|
||
import { useRouter } from "next/navigation";
|
||
|
||
// 动态模型类型定义
|
||
type DynamicModel = {
|
||
id: string;
|
||
modelId?: string;
|
||
displayName: string;
|
||
avatarUrl?: string | null;
|
||
description: string | null;
|
||
providerType?: string;
|
||
supportsVision: boolean;
|
||
isDefault: boolean;
|
||
providerName: string;
|
||
isSubModel?: boolean; // 🆕 是否为子模型
|
||
parentModelId?: string | null; // 🆕 父模型ID
|
||
capabilities?: any; // 🆕 模型能力配置
|
||
imageGenConfig?: {
|
||
maxImages?: number;
|
||
supportsSize?: boolean;
|
||
supportsPromptExtend?: boolean;
|
||
supportsNegativePrompt?: boolean;
|
||
supportsWatermark?: boolean;
|
||
supportsSeed?: boolean;
|
||
creditCostPerImage?: number;
|
||
} | null;
|
||
};
|
||
|
||
type PointsSummaryResponse = {
|
||
pointsSystemConfig?: {
|
||
enabled?: boolean;
|
||
blockOnInsufficient?: boolean;
|
||
};
|
||
summary?: {
|
||
currentCredits?: number;
|
||
};
|
||
} | null;
|
||
|
||
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 {
|
||
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";
|
||
|
||
function PureMultimodalInput({
|
||
chatId,
|
||
input,
|
||
setInput,
|
||
status,
|
||
stop,
|
||
attachments,
|
||
setAttachments,
|
||
messages,
|
||
setMessages,
|
||
sendMessage,
|
||
className,
|
||
selectedVisibilityType,
|
||
selectedModelId,
|
||
onModelChange,
|
||
usage,
|
||
isVideoModel,
|
||
videoModelName,
|
||
videoParams,
|
||
videoProviderType,
|
||
onVideoParamsChange,
|
||
isImageModel,
|
||
imageModelName,
|
||
imageParams,
|
||
onImageParamsChange,
|
||
imageProviderType,
|
||
imageGenConfig,
|
||
}: {
|
||
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;
|
||
videoProviderType?: string;
|
||
onVideoParamsChange?: (params: VideoGenerationParams) => void;
|
||
isImageModel?: boolean;
|
||
imageModelName?: string;
|
||
imageParams?: ImageGenerationParams | null;
|
||
onImageParamsChange?: (params: ImageGenerationParams) => void;
|
||
imageProviderType?: string; // 🆕 图片模型提供商类型
|
||
imageGenConfig?: {
|
||
maxImages?: number;
|
||
supportsSize?: boolean;
|
||
supportsPromptExtend?: boolean;
|
||
supportsNegativePrompt?: boolean;
|
||
supportsWatermark?: boolean;
|
||
supportsSeed?: boolean;
|
||
creditCostPerImage?: number;
|
||
} | 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<HTMLTextAreaElement>(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", fetcher);
|
||
const { data: membershipSummary } = useSWR<PointsSummaryResponse>(
|
||
"/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]);
|
||
|
||
// 每分钟更新一次检查
|
||
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 videoSlotInputRefs = useRef<Array<HTMLInputElement | null>>([]);
|
||
const [uploadQueue, setUploadQueue] = useState<string[]>([]);
|
||
const [isDragging, setIsDragging] = useState(false);
|
||
const [draggedIndex, setDraggedIndex] = useState<number | null>(null);
|
||
const [videoSlotUploading, setVideoSlotUploading] = useState<number | null>(null);
|
||
|
||
const ensureImagePointsBeforeSend = useCallback(async () => {
|
||
if (!isImageModel || isVideoModel) return true;
|
||
|
||
const pointsEnabled = membershipSummary?.pointsSystemConfig?.enabled === true;
|
||
const blockOnInsufficient =
|
||
membershipSummary?.pointsSystemConfig?.blockOnInsufficient !== false;
|
||
const unitCost = Math.max(
|
||
0,
|
||
Math.round(Number(imageGenConfig?.creditCostPerImage ?? 0))
|
||
);
|
||
|
||
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?.creditCostPerImage,
|
||
imageParams?.imageCount,
|
||
isImageModel,
|
||
isVideoModel,
|
||
membershipSummary?.pointsSystemConfig?.blockOnInsufficient,
|
||
membershipSummary?.pointsSystemConfig?.enabled,
|
||
membershipSummary?.summary?.currentCredits,
|
||
]);
|
||
|
||
const submitForm = useCallback(async () => {
|
||
const canContinue = await ensureImagePointsBeforeSend();
|
||
if (!canContinue) return;
|
||
|
||
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();
|
||
}
|
||
}, [
|
||
ensureImagePointsBeforeSend,
|
||
input,
|
||
setInput,
|
||
attachments,
|
||
sendMessage,
|
||
setAttachments,
|
||
setLocalStorageInput,
|
||
width,
|
||
chatId,
|
||
resetHeight,
|
||
]);
|
||
|
||
// 压缩图片函数(保持原格式:JPG保持JPG,PNG保持PNG)
|
||
// 不限制分辨率,优先保留原图质量
|
||
const compressImage = useCallback(async (file: File, maxSizeMB = 50): Promise<File> => {
|
||
// 如果不是图片,直接返回
|
||
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 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<Attachment | null> = 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<HTMLInputElement>) => {
|
||
const file = event.target.files?.[0];
|
||
if (!file) return;
|
||
if (!file.type.startsWith("image/")) {
|
||
toast.error("请上传图片文件");
|
||
event.currentTarget.value = "";
|
||
return;
|
||
}
|
||
setVideoSlotUploading(slotIndex);
|
||
setUploadQueue((prev) => [...prev, file.name]);
|
||
try {
|
||
const uploaded = await uploadFile(file);
|
||
if (uploaded) {
|
||
updateAttachmentAtSlot(slotIndex, uploaded as Attachment);
|
||
}
|
||
} finally {
|
||
setVideoSlotUploading(null);
|
||
setUploadQueue((prev) => prev.filter((name) => name !== file.name));
|
||
event.currentTarget.value = "";
|
||
}
|
||
},
|
||
[updateAttachmentAtSlot, uploadFile]
|
||
);
|
||
|
||
// 🆕 视频模式下的图片数量限制
|
||
const getVideoImageLimit = useCallback(() => {
|
||
if (!isVideoModel || !videoParams) {
|
||
return { max: Number.POSITIVE_INFINITY, label: "" as const };
|
||
}
|
||
if (videoProviderType === "fal-video") {
|
||
switch (videoParams.mode) {
|
||
case "first-last-frame":
|
||
return { max: 2, label: "FAL 首尾帧模式需要 2 张图(首帧 + 尾帧)" as const };
|
||
case "first-frame":
|
||
default:
|
||
return { max: 1, label: "FAL 图生视频模式需要 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]);
|
||
|
||
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"
|
||
/>
|
||
{videoFrameSlots.map((_, index) => (
|
||
<input
|
||
key={`video-slot-input-${index}`}
|
||
className="hidden"
|
||
type="file"
|
||
accept="image/*"
|
||
onChange={(event) => handleVideoSlotFileChange(index, event)}
|
||
ref={(element) => {
|
||
videoSlotInputRefs.current[index] = element;
|
||
}}
|
||
/>
|
||
))}
|
||
|
||
<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 === "submitted" || status === "streaming") {
|
||
toast.error("请等待模型返回当前回复后再继续");
|
||
} else {
|
||
void submitForm();
|
||
}
|
||
}}
|
||
>
|
||
{videoFrameSlots.length > 0 && (
|
||
<div className="mb-2 flex flex-row gap-2">
|
||
{videoFrameSlots.map((label, index) => {
|
||
const slotAttachment = attachments[index];
|
||
const disabled = index > 0 && !attachments[index - 1];
|
||
const isUploading = videoSlotUploading === index;
|
||
return (
|
||
<button
|
||
key={`video-slot-${label}-${index}`}
|
||
type="button"
|
||
onClick={() => {
|
||
if (disabled || status !== "ready") return;
|
||
videoSlotInputRefs.current[index]?.click();
|
||
}}
|
||
className={cn(
|
||
"group relative flex h-24 w-24 shrink-0 items-center justify-center overflow-hidden rounded-xl border bg-muted/30 text-muted-foreground transition-colors",
|
||
!disabled && status === "ready" && "cursor-pointer hover:border-primary/60 hover:bg-muted/50",
|
||
disabled && "cursor-not-allowed opacity-55"
|
||
)}
|
||
>
|
||
{slotAttachment ? (
|
||
<>
|
||
<img
|
||
src={slotAttachment.url}
|
||
alt={slotAttachment.name || label}
|
||
className="h-full w-full object-cover"
|
||
/>
|
||
<div className="absolute inset-x-0 bottom-0 bg-black/55 px-1 py-0.5 text-center text-[11px] text-white">
|
||
{label}
|
||
</div>
|
||
<div
|
||
className="absolute right-1 top-1 rounded-full bg-black/65 px-1 text-[11px] text-white opacity-0 transition-opacity group-hover:opacity-100"
|
||
onClick={(event) => {
|
||
event.stopPropagation();
|
||
updateAttachmentAtSlot(index, null);
|
||
}}
|
||
>
|
||
x
|
||
</div>
|
||
</>
|
||
) : (
|
||
<div className="flex flex-col items-center gap-1">
|
||
<span className="text-xl leading-none">+</span>
|
||
<span className="text-sm">{isUploading ? "上传中" : label}</span>
|
||
</div>
|
||
)}
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
)}
|
||
|
||
{(attachments.length > 0 || uploadQueue.length > 0) && videoFrameSlots.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={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) => (
|
||
<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">
|
||
{isVideoModel ? (
|
||
<>
|
||
<Button
|
||
type="button"
|
||
variant="ghost"
|
||
className="h-8 rounded-full border border-blue-500 bg-blue-50 px-3 text-blue-600 hover:bg-blue-100"
|
||
>
|
||
<Video className="mr-1 h-4 w-4" />
|
||
<span className="text-sm font-medium">视频</span>
|
||
</Button>
|
||
{onVideoParamsChange && (
|
||
<VideoParamsBar
|
||
key={`video-params-${selectedModelId}-${isVideoModel}`}
|
||
modelName={videoModelName || "视频 3.0"}
|
||
initialParams={videoParams || undefined}
|
||
onParamsChange={onVideoParamsChange}
|
||
inline={true}
|
||
providerType={videoProviderType}
|
||
/>
|
||
)}
|
||
<ModelSelectorCompact
|
||
onModelChange={onModelChange}
|
||
selectedModelId={selectedModelId}
|
||
attachments={attachments}
|
||
avatarOnly={true}
|
||
/>
|
||
</>
|
||
) : (
|
||
<>
|
||
<AttachmentsButton
|
||
fileInputRef={fileInputRef}
|
||
selectedModelId={selectedModelId}
|
||
status={status}
|
||
/>
|
||
{!isAgentMode ? (
|
||
<ModelSelectorCompact
|
||
onModelChange={onModelChange}
|
||
selectedModelId={selectedModelId}
|
||
attachments={attachments}
|
||
/>
|
||
) : null}
|
||
{isImageModel && onImageParamsChange && (
|
||
<ImageParamsBar
|
||
key={`image-params-${selectedModelId}-${isImageModel}`}
|
||
modelName={imageModelName || "Gemini 图片生成"}
|
||
initialParams={imageParams || undefined}
|
||
onParamsChange={onImageParamsChange}
|
||
inline={true}
|
||
providerType={imageProviderType}
|
||
imageGenConfig={imageGenConfig || undefined}
|
||
/>
|
||
)}
|
||
</>
|
||
)}
|
||
</PromptInputTools>
|
||
|
||
{isAgentMode ? (
|
||
<ModelSelectorCompact
|
||
selectedModelId={selectedModelId}
|
||
attachments={attachments}
|
||
avatarOnly={true}
|
||
readOnly={true}
|
||
/>
|
||
) : null}
|
||
|
||
{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>
|
||
<AlertDialog
|
||
open={Boolean(insufficientPointsState)}
|
||
onOpenChange={(open) => {
|
||
if (!open) setInsufficientPointsState(null);
|
||
}}
|
||
>
|
||
<AlertDialogContent>
|
||
<AlertDialogHeader>
|
||
<AlertDialogTitle>积分不足</AlertDialogTitle>
|
||
<AlertDialogDescription>
|
||
{insufficientPointsState
|
||
? `当前余额 ${insufficientPointsState.balance},本次预计消耗 ${insufficientPointsState.expectedCost}(${insufficientPointsState.unitCost} x ${insufficientPointsState.imageCount} 张)。请先充值后再发送生图需求。`
|
||
: "当前积分不足,请先充值后再发送生图需求。"}
|
||
</AlertDialogDescription>
|
||
</AlertDialogHeader>
|
||
<AlertDialogFooter>
|
||
<AlertDialogCancel>稍后再说</AlertDialogCancel>
|
||
<AlertDialogAction
|
||
onClick={() => {
|
||
setInsufficientPointsState(null);
|
||
router.push("/recharge");
|
||
}}
|
||
>
|
||
去充值
|
||
</AlertDialogAction>
|
||
</AlertDialogFooter>
|
||
</AlertDialogContent>
|
||
</AlertDialog>
|
||
</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.videoProviderType !== nextProps.videoProviderType) {
|
||
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<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,
|
||
avatarOnly = false,
|
||
readOnly = false,
|
||
}: {
|
||
selectedModelId: string;
|
||
onModelChange?: (modelId: string) => void;
|
||
attachments: Attachment[];
|
||
avatarOnly?: boolean;
|
||
readOnly?: boolean;
|
||
}) {
|
||
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", fetcher);
|
||
|
||
// 获取站点设置
|
||
const { data: siteSettings } = useSWR<{
|
||
showAutoModel: boolean;
|
||
}>("/api/site-settings", 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<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",
|
||
modelId: "auto",
|
||
displayName: "Agent模式",
|
||
avatarUrl: dynamicModelsData?.agentModel?.avatarUrl || null,
|
||
description: "根据您的意图自动选择最合适的模型",
|
||
providerType: "system",
|
||
supportsVision: true,
|
||
isDefault: false,
|
||
providerName: "System",
|
||
};
|
||
|
||
// 合并模型列表(根据设置决定是否包含自动模型)
|
||
const showAutoModel = siteSettings?.showAutoModel ?? true;
|
||
const displayModels = showAutoModel ? [autoModel, ...dynamicModels] : dynamicModels;
|
||
|
||
useEffect(() => {
|
||
setOptimisticModelId(selectedModelId);
|
||
}, [selectedModelId]);
|
||
|
||
useEffect(() => {
|
||
if (displayModels.length === 0) return;
|
||
|
||
const currentExists = displayModels.some((m) => m.id === selectedModelId);
|
||
if (currentExists) return;
|
||
|
||
const fallbackModelId =
|
||
dynamicModels.find((m) => m.isDefault)?.id ??
|
||
dynamicModels[0]?.id ??
|
||
(showAutoModel ? "auto" : "");
|
||
|
||
if (!fallbackModelId) return;
|
||
|
||
setOptimisticModelId(fallbackModelId);
|
||
onModelChange?.(fallbackModelId);
|
||
startTransition(() => {
|
||
saveChatModelAsCookie(fallbackModelId);
|
||
});
|
||
}, [displayModels, dynamicModels, onModelChange, selectedModelId, showAutoModel]);
|
||
|
||
// 自动选择模型的逻辑 - 必须在所有条件 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,
|
||
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 (
|
||
<Button className="h-8 w-8 rounded-full border border-border p-0" variant="ghost" disabled>
|
||
<CpuIcon size={14} />
|
||
</Button>
|
||
);
|
||
}
|
||
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) {
|
||
if (avatarOnly) {
|
||
return (
|
||
<Button className="h-8 w-8 rounded-full border border-border p-0" variant="ghost" disabled>
|
||
<Bot className="h-4 w-4" />
|
||
</Button>
|
||
);
|
||
}
|
||
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 (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 (
|
||
<div
|
||
className="flex h-8 w-8 items-center justify-center rounded-full border border-border bg-background"
|
||
aria-label={readOnlyName}
|
||
title={readOnlyName}
|
||
>
|
||
{readOnlyAvatarUrl ? (
|
||
<img
|
||
src={readOnlyAvatarUrl}
|
||
alt={readOnlyName}
|
||
className="h-7 w-7 rounded-full object-cover"
|
||
/>
|
||
) : (
|
||
<Bot className="h-4 w-4" />
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<Button className="h-8 px-2" variant="ghost" disabled>
|
||
<CpuIcon size={16} />
|
||
<span className="hidden font-medium text-xs sm:block">{readOnlyName}</span>
|
||
</Button>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<PromptInputModelSelect
|
||
onValueChange={(modelId) => {
|
||
const model = displayModels.find((m) => m.id === modelId);
|
||
if (model) {
|
||
// 🆕 如果选择的是"自动"模式,直接传递 "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}
|
||
>
|
||
<Trigger asChild>
|
||
{avatarOnly ? (
|
||
<Button
|
||
className="h-8 w-8 rounded-full border border-border p-0 hover:bg-accent/60"
|
||
variant="ghost"
|
||
>
|
||
{selectedModel?.avatarUrl ? (
|
||
<img
|
||
src={selectedModel.avatarUrl}
|
||
alt={selectedModel.name}
|
||
className="h-7 w-7 rounded-full object-cover"
|
||
/>
|
||
) : (
|
||
<Bot className="h-4 w-4" />
|
||
)}
|
||
</Button>
|
||
) : (
|
||
<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="!w-[266px] max-h-[372px] overflow-hidden rounded-[22px] border border-[#bfc6cf] bg-[#f2f3f5]/90 p-2 shadow-[0_10px_24px_rgba(15,23,42,0.12)]">
|
||
<div className="max-h-[338px] space-y-0.5 overflow-y-auto pr-1">
|
||
{displayModels.map((model) => (
|
||
<SelectItem
|
||
key={model.id}
|
||
value={model.id}
|
||
className={cn(
|
||
"h-10 rounded-[14px] px-3 text-[#2f3640] [&>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-8" : ""
|
||
)}
|
||
>
|
||
<span className="flex w-full items-center justify-between gap-2">
|
||
<span className="flex min-w-0 items-center gap-2">
|
||
<span className="flex h-5 w-5 items-center justify-center text-[#2f3640]">
|
||
{model.avatarUrl ? (
|
||
<img
|
||
src={model.avatarUrl}
|
||
alt={model.displayName}
|
||
className="h-5 w-5 rounded-full object-cover"
|
||
/>
|
||
) : (
|
||
<span className="flex h-5 w-5 items-center justify-center">
|
||
{(() => {
|
||
const providerType = model.providerType || "";
|
||
if (providerType === "volcano-video" || providerType === "doubao-video" || providerType === "qianwen-video" || providerType === "fal-video") {
|
||
return <Video className="h-4 w-4" strokeWidth={2.2} />;
|
||
}
|
||
if (
|
||
providerType === "gemini" ||
|
||
providerType === "volcano-vision" ||
|
||
providerType === "alibaba-image" ||
|
||
providerType === "ruxa-image" ||
|
||
providerType === "fal-image"
|
||
) {
|
||
return <ImageIcon className="h-4 w-4" strokeWidth={2.2} />;
|
||
}
|
||
if (providerType === "system") {
|
||
return <Sparkles className="h-4 w-4" strokeWidth={2.2} />;
|
||
}
|
||
return <Bot className="h-4 w-4" strokeWidth={2.2} />;
|
||
})()}
|
||
</span>
|
||
)}
|
||
</span>
|
||
<span className="truncate text-[13px] font-medium leading-none">
|
||
{model.isSubModel ? `└ ${model.displayName}` : model.displayName}
|
||
</span>
|
||
</span>
|
||
{optimisticModelId === model.id ? (
|
||
<span className="text-[18px] font-semibold text-[#1f2937]">✓</span>
|
||
) : null}
|
||
</span>
|
||
</SelectItem>
|
||
))}
|
||
</div>
|
||
</PromptInputModelSelectContent>
|
||
</PromptInputModelSelect>
|
||
);
|
||
}
|
||
|
||
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 (!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);
|