From abadcdaa181911e47e6835390e85967cb90452a3 Mon Sep 17 00:00:00 2001 From: Your Name Date: Mon, 2 Mar 2026 21:45:52 +0800 Subject: [PATCH] feat(chat): default to Nano Banana Pro and add mode switch --- app/(chat)/api/models/route.ts | 34 ++++++++- app/(chat)/page.tsx | 90 +++++++++++++++++------ components/multimodal-input.tsx | 123 ++++++++++++++++++++++++++++++-- 3 files changed, 218 insertions(+), 29 deletions(-) diff --git a/app/(chat)/api/models/route.ts b/app/(chat)/api/models/route.ts index 2338c03dd..6c7c51b15 100644 --- a/app/(chat)/api/models/route.ts +++ b/app/(chat)/api/models/route.ts @@ -8,6 +8,25 @@ import { import { ChatSDKError } from "@/lib/errors"; import { isAiModelAgentAuto } from "@/lib/db/ai-model-flags"; +const VIDEO_PROVIDER_TYPES = new Set([ + "volcano-video", + "doubao-video", + "qianwen-video", + "fal-video", +]); + +function isNanoBananaProModel(model: { displayName?: string | null; modelId?: string | null }) { + const displayName = String(model.displayName || "").toLowerCase(); + const modelId = String(model.modelId || "").toLowerCase(); + return ( + displayName.includes("nano banana pro") || + displayName.includes("nano-banana-pro") || + modelId.includes("nano-banana-pro") || + modelId.includes("nano_banana_pro") || + modelId.includes("nano-banana") + ); +} + function sortModelsForDisplay( models: T[] ) { @@ -74,13 +93,26 @@ export async function GET() { imageGenConfig: model.imageGenConfig, sortOrder: model.sortOrder ?? 0, })); + const preferredNanoBananaModel = models.find( + (model) => + model.supportsImageGen && + !VIDEO_PROVIDER_TYPES.has(model.providerType || "") && + isNanoBananaProModel(model) + ); + const preferredImageModel = models.find( + (model) => + model.supportsImageGen && + !VIDEO_PROVIDER_TYPES.has(model.providerType || "") + ); const resolvedDefaultModelId = + (preferredNanoBananaModel?.id ?? + preferredImageModel?.id ?? (defaultModel && !isAiModelAgentAuto(defaultModel) && models.some((model) => model.id === defaultModel.id) ? defaultModel.id - : models.find((model) => model.isDefault)?.id) ?? + : models.find((model) => model.isDefault)?.id)) ?? models[0]?.id ?? null; diff --git a/app/(chat)/page.tsx b/app/(chat)/page.tsx index 62dff8f89..6a6775e68 100644 --- a/app/(chat)/page.tsx +++ b/app/(chat)/page.tsx @@ -15,6 +15,25 @@ import { } from "@/lib/db/queries"; import { isAiModelAgentAuto } from "@/lib/db/ai-model-flags"; +const VIDEO_PROVIDER_TYPES = new Set([ + "volcano-video", + "doubao-video", + "qianwen-video", + "fal-video", +]); + +function isNanoBananaProModel(model: { displayName?: string | null; modelId?: string | null }) { + const displayName = String(model.displayName || "").toLowerCase(); + const modelId = String(model.modelId || "").toLowerCase(); + return ( + displayName.includes("nano banana pro") || + displayName.includes("nano-banana-pro") || + modelId.includes("nano-banana-pro") || + modelId.includes("nano_banana_pro") || + modelId.includes("nano-banana") + ); +} + export default function Page() { return ( }> @@ -73,36 +92,61 @@ async function NewChatPage() { const modelIdFromCookie = cookieStore.get("chat-model"); - // 如果没有保存的模型 ID,从数据库获取默认模型 let initialModelId = modelIdFromCookie?.value; - - if (!initialModelId) { - // 尝试获取默认模型 - const defaultModel = await getDefaultAiModel(); + const currentUser = session?.user?.id + ? await getUserById({ id: session.user.id }).catch(() => undefined) + : undefined; + const allowHiddenModels = + session?.user?.type === "admin" || Boolean(currentUser?.isHighVip); + const defaultModel = await getDefaultAiModel(); + const enabledModels = await getEnabledAiModelsWithProvider(false, true); + const visibleModels = enabledModels.filter( + ({ model }) => + !isAiModelAgentAuto(model) && + (allowHiddenModels || !model.isHidden) + ); + const preferredImageModels = visibleModels.filter( + ({ model, provider }) => + model.supportsImageGen && + !VIDEO_PROVIDER_TYPES.has(provider.type) + ); + const nanoBananaModel = preferredImageModels.find(({ model }) => + isNanoBananaProModel(model) + ); + const cookieModel = initialModelId + ? visibleModels.find(({ model }) => model.id === initialModelId) + : undefined; + const cookieIsVideoModel = Boolean( + cookieModel && VIDEO_PROVIDER_TYPES.has(cookieModel.provider.type) + ); - const currentUser = session?.user?.id - ? await getUserById({ id: session.user.id }).catch(() => undefined) - : undefined; - const allowHiddenModels = - session?.user?.type === "admin" || Boolean(currentUser?.isHighVip); - - if ( + if ( + !initialModelId || + !cookieModel || + (cookieIsVideoModel && Boolean(nanoBananaModel)) + ) { + if (nanoBananaModel) { + initialModelId = nanoBananaModel.model.id; + } else if ( + defaultModel && + !isAiModelAgentAuto(defaultModel) && + (allowHiddenModels || !defaultModel.isHidden) && + preferredImageModels.some(({ model }) => model.id === defaultModel.id) + ) { + initialModelId = defaultModel.id; + } else if (preferredImageModels.length > 0) { + const preferredDefaultImageModel = preferredImageModels.find( + ({ model }) => model.isDefault + ); + initialModelId = (preferredDefaultImageModel || preferredImageModels[0]).model.id; + } else if ( defaultModel && !isAiModelAgentAuto(defaultModel) && (allowHiddenModels || !defaultModel.isHidden) ) { initialModelId = defaultModel.id; - } else { - // 如果没有设置默认模型,获取第一个启用的模型 - const enabledModels = await getEnabledAiModelsWithProvider(false, true); - const visibleModels = enabledModels.filter( - ({ model }) => - !isAiModelAgentAuto(model) && - (allowHiddenModels || !model.isHidden) - ); - if (visibleModels.length > 0) { - initialModelId = visibleModels[0].model.id; - } + } else if (visibleModels.length > 0) { + initialModelId = visibleModels[0].model.id; } } diff --git a/components/multimodal-input.tsx b/components/multimodal-input.tsx index 5bd17da1c..b7c90c2f0 100644 --- a/components/multimodal-input.tsx +++ b/components/multimodal-input.tsx @@ -13,6 +13,7 @@ type DynamicModel = { description: string | null; providerType?: string; supportsVision: boolean; + supportsImageGen?: boolean; isDefault: boolean; providerName: string; isSubModel?: boolean; // 🆕 是否为子模型 @@ -43,6 +44,25 @@ type PointsSummaryResponse = { } | null; const fetcher = (url: string) => fetch(url).then((res) => res.json()); + +const VIDEO_PROVIDER_TYPES = new Set([ + "volcano-video", + "doubao-video", + "qianwen-video", + "fal-video", +]); + +function isNanoBananaProModel(model: { displayName?: string | null; modelId?: string | null }) { + const displayName = String(model.displayName || "").toLowerCase(); + const modelId = String(model.modelId || "").toLowerCase(); + return ( + displayName.includes("nano banana pro") || + displayName.includes("nano-banana-pro") || + modelId.includes("nano-banana-pro") || + modelId.includes("nano_banana_pro") || + modelId.includes("nano-banana") + ); +} import { Trigger } from "@radix-ui/react-select"; import type { UIMessage } from "ai"; import equal from "fast-deep-equal"; @@ -184,6 +204,9 @@ function PureMultimodalInput({ // 获取网站设置(用于时间限制检查) const { data: siteSettings } = useSWR("/api/site-settings", fetcher); + const { data: modelCatalog } = useSWR<{ + models: DynamicModel[]; + }>("/api/models", fetcher); const { data: membershipSummary } = useSWR( "/api/user/membership-summary", async (url: string) => { @@ -205,6 +228,70 @@ function PureMultimodalInput({ return currentTime >= startTime && currentTime <= endTime; }, [siteSettings]); + const currentSelectedModel = useMemo( + () => modelCatalog?.models?.find((model) => model.id === selectedModelId), + [modelCatalog?.models, selectedModelId] + ); + + const quickSwitchTargets = useMemo(() => { + const models = modelCatalog?.models || []; + const nanoBananaImage = models.find( + (model) => + model.supportsImageGen && + !VIDEO_PROVIDER_TYPES.has(model.providerType || "") && + isNanoBananaProModel(model) + ); + const imageMain = models.find( + (model) => model.supportsImageGen && !VIDEO_PROVIDER_TYPES.has(model.providerType || "") && !model.isSubModel + ); + const imageFallback = models.find( + (model) => model.supportsImageGen && !VIDEO_PROVIDER_TYPES.has(model.providerType || "") + ); + const videoMain = models.find( + (model) => VIDEO_PROVIDER_TYPES.has(model.providerType || "") && !model.isSubModel + ); + const videoFallback = models.find((model) => + VIDEO_PROVIDER_TYPES.has(model.providerType || "") + ); + return { + image: nanoBananaImage || imageMain || imageFallback || null, + video: videoMain || videoFallback || null, + }; + }, [modelCatalog?.models]); + + const activeQuickMode = useMemo<"image" | "video" | null>(() => { + if (isVideoModel || VIDEO_PROVIDER_TYPES.has(currentSelectedModel?.providerType || "")) { + return "video"; + } + if (isImageModel || currentSelectedModel?.supportsImageGen) { + return "image"; + } + return null; + }, [currentSelectedModel?.providerType, currentSelectedModel?.supportsImageGen, isImageModel, isVideoModel]); + + const switchComposerMode = useCallback( + (targetMode: "image" | "video") => { + const targetModel = targetMode === "image" ? quickSwitchTargets.image : quickSwitchTargets.video; + if (!targetModel) { + toast.error(targetMode === "image" ? "当前没有可用的生图模型" : "当前没有可用的视频模型"); + return; + } + if (targetModel.id === selectedModelId) return; + onModelChange?.(targetModel.id); + startTransition(() => { + saveChatModelAsCookie(targetModel.id); + }); + toast.success( + targetMode === "image" + ? `已切换到生图模型:${targetModel.displayName}` + : `已切换到视频模型:${targetModel.displayName}` + ); + }, + [onModelChange, quickSwitchTargets.image, quickSwitchTargets.video, selectedModelId] + ); + + const showQuickModeSwitch = Boolean(quickSwitchTargets.image || quickSwitchTargets.video); + // 每分钟更新一次检查 useEffect(() => { if (!siteSettings?.usageTimeEnabled) return; @@ -1117,16 +1204,42 @@ function PureMultimodalInput({ - {isVideoModel ? ( - <> + {showQuickModeSwitch ? ( +
+ +
+ ) : null} + {isVideoModel ? ( + <> {onVideoParamsChange && (