feat(chat): default to Nano Banana Pro and add mode switch
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled

This commit is contained in:
Your Name 2026-03-02 21:45:52 +08:00
parent 26244a724c
commit abadcdaa18
3 changed files with 218 additions and 29 deletions

View File

@ -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<T extends { sortOrder?: number | null; displayName?: string | null }>(
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;

View File

@ -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 (
<Suspense fallback={<div className="flex h-dvh" />}>
@ -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;
}
}

View File

@ -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<PointsSummaryResponse>(
"/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({
</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 ? (
<>
{showQuickModeSwitch ? (
<div className="mr-1 flex items-center gap-1 rounded-full border border-border/70 bg-muted/35 p-1">
<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"
className={cn(
"h-7 rounded-full px-2 text-xs",
activeQuickMode === "image"
? "bg-white text-[#2563eb] shadow-sm"
: "text-muted-foreground hover:text-foreground"
)}
disabled={!quickSwitchTargets.image}
onClick={() => switchComposerMode("image")}
>
<Video className="mr-1 h-4 w-4" />
<span className="text-sm font-medium">视频</span>
<ImageIcon className="mr-1 h-3.5 w-3.5" />
生图
</Button>
<Button
type="button"
variant="ghost"
className={cn(
"h-7 rounded-full px-2 text-xs",
activeQuickMode === "video"
? "bg-white text-[#2563eb] shadow-sm"
: "text-muted-foreground hover:text-foreground"
)}
disabled={!quickSwitchTargets.video}
onClick={() => switchComposerMode("video")}
>
<Video className="mr-1 h-3.5 w-3.5" />
视频
</Button>
</div>
) : null}
{isVideoModel ? (
<>
{onVideoParamsChange && (
<VideoParamsBar
key={`video-params-${selectedModelId}-${isVideoModel}`}