diff --git a/app/(admin)/settings/admin-settings-client.tsx b/app/(admin)/settings/admin-settings-client.tsx index 83ba0bb..5280863 100644 --- a/app/(admin)/settings/admin-settings-client.tsx +++ b/app/(admin)/settings/admin-settings-client.tsx @@ -486,6 +486,8 @@ export function AdminSettingsClient() { isEnabled: true, isDefault: false, sortOrder: 0, + fallbackModelId1: "", // 🆕 备用模型 ID 1 + fallbackModelId2: "", // 🆕 备用模型 ID 2 }); const [showModelDialog, setShowModelDialog] = useState(false); const [editingModel, setEditingModel] = useState(null); @@ -805,6 +807,8 @@ export function AdminSettingsClient() { isEnabled: true, isDefault: false, sortOrder: 0, + fallbackModelId1: "", + fallbackModelId2: "", }); setEditingModel(null); }; @@ -2471,7 +2475,7 @@ export function AdminSettingsClient() { {editingModel ? "编辑模型" : "添加模型"} -
+
setModelForm({ ...modelForm, fallbackModelId1: e.target.value })} + placeholder="例如: nano-banana-pro-backup" + /> +
+
+ + setModelForm({ ...modelForm, fallbackModelId2: e.target.value })} + placeholder="例如: nano-banana-lite" + /> +
+
+
{ - try { - // 🆕 只传递最后一条用户消息,避免上下文历史干扰生图效果 - // 用户反馈:带上下文记录会影响生图效果 + // 🆕 尝试生成图片的函数(支持备用模型切换) + const tryGenerateImage = async (modelIdToUse: string, isRetry = false): Promise => { + console.log(`[Chat API] ${isRetry ? "🔄 Retry with fallback" : "🚀 Trying"} model: ${modelIdToUse}`); + + // 只传递最后一条用户消息,避免上下文历史干扰生图效果 const lastUserMessage = uiMessages.filter((m) => m.role === "user").pop(); const messagesForGemini = lastUserMessage ? [lastUserMessage] : uiMessages; console.log("[Chat API] Gemini: Using only last user message (no history context)"); @@ -647,15 +657,50 @@ export async function POST(request: Request) { // 转换消息,Gemini 需要 base64 格式的图片 const modelMessages = await convertMessagesWithImages(messagesForGemini, "gemini"); - // 调用 doGenerate 获取图片 + // 调用 doGenerate 获取图片(如果是备用模型,传入 overrideModelId) const result = await (languageModel as any).doGenerate({ messages: modelMessages, - imageParams: requestBody.imageParams || { aspectRatio: "auto", imageSize: "2K" }, // 默认2K + imageParams: requestBody.imageParams || { aspectRatio: "auto", imageSize: "2K" }, + overrideModelId: isRetry ? modelIdToUse : undefined, // 🆕 使用备用模型 ID }); - const imageMarkdown = result.text || "图片生成失败"; + return result.text || "图片生成失败"; + }; + + try { + let imageMarkdown = ""; // 初始化为空字符串 + let usedModelId = actualModelId; + + try { + // 首先尝试主模型 + imageMarkdown = await tryGenerateImage(actualModelId, false); + } catch (primaryError) { + console.error("[Chat API] Primary model failed:", primaryError); + + // 🆕 尝试备用模型(使用同一个 provider,但切换 modelId) + let fallbackSuccess = false; + for (const fallbackModelId of fallbackModelIds) { + try { + console.log(`[Chat API] 🔄 Trying fallback model: ${fallbackModelId}`); + imageMarkdown = await tryGenerateImage(fallbackModelId, true); + usedModelId = fallbackModelId; + fallbackSuccess = true; + console.log(`[Chat API] ✅ Fallback model ${fallbackModelId} succeeded!`); + break; + } catch (fallbackError) { + console.error(`[Chat API] ❌ Fallback model ${fallbackModelId} failed:`, fallbackError); + } + } + + // 如果所有备用模型都失败,抛出原始错误触发火山引擎故障转移 + if (!fallbackSuccess) { + throw primaryError; + } + } + console.log("[Chat API] Gemini generated, length:", imageMarkdown.length); console.log("[Chat API] Gemini image markdown:", imageMarkdown.substring(0, 200)); + console.log("[Chat API] Used model ID:", usedModelId); // 生成一个统一的消息 ID const messageId = generateUUID(); diff --git a/app/api/admin/ai-models/route.ts b/app/api/admin/ai-models/route.ts index 8348597..fa8f605 100644 --- a/app/api/admin/ai-models/route.ts +++ b/app/api/admin/ai-models/route.ts @@ -21,6 +21,8 @@ const createModelSchema = z.object({ isEnabled: z.boolean().optional(), isDefault: z.boolean().optional(), sortOrder: z.number().optional(), + fallbackModelId1: z.string().max(128).optional(), // 🆕 备用模型 ID 1 + fallbackModelId2: z.string().max(128).optional(), // 🆕 备用模型 ID 2 }); const updateModelSchema = z.object({ @@ -34,6 +36,8 @@ const updateModelSchema = z.object({ isEnabled: z.boolean().optional(), isDefault: z.boolean().optional(), sortOrder: z.number().optional(), + fallbackModelId1: z.string().max(128).optional(), // 🆕 备用模型 ID 1 + fallbackModelId2: z.string().max(128).optional(), // 🆕 备用模型 ID 2 }); export async function GET(request: Request) { diff --git a/components/chat.tsx b/components/chat.tsx index 7701944..56bd278 100644 --- a/components/chat.tsx +++ b/components/chat.tsx @@ -478,6 +478,7 @@ export function Chat({ onImageEdit={handleImageEdit} onDrawingComplete={handleDrawingComplete} pendingGeneration={pendingGeneration} + imageCount={imageParams?.imageCount || 1} />
@@ -508,6 +509,7 @@ export function Chat({ imageModelName={currentModel?.displayName} imageParams={imageParams} onImageParamsChange={handleImageParamsChange} + imageProviderType={currentModel?.providerType} /> )}
diff --git a/components/gemini-image-loading.tsx b/components/gemini-image-loading.tsx index d98f04b..6ff7a39 100644 --- a/components/gemini-image-loading.tsx +++ b/components/gemini-image-loading.tsx @@ -3,11 +3,18 @@ import { memo } from "react"; import { SparklesIcon } from "./icons"; +interface GeminiImageLoadingProps { + imageCount?: number; // 🆕 用户选择的图片数量,默认1张 +} + /** * Gemini 图片生成加载占位符组件 - * 显示 2 个 170x200 像素的模糊动态效果 + * 显示动态数量的 170x200 像素模糊动态效果 */ -export const GeminiImageLoading = memo(() => { +export const GeminiImageLoading = memo(({ imageCount = 1 }: GeminiImageLoadingProps) => { + // 确保数量在 1-2 范围内 + const count = Math.min(Math.max(imageCount, 1), 2); + return (
{ {/* 文本提示 */}
- 正在生成 2 张图片 + 正在生成 {count} 张图片 {
- {/* 2 个图片占位符 - 横向排列,每个 170x200px */} + {/* 图片占位符 - 横向排列,每个 170x200px */}
- {[0, 1].map((index) => ( + {Array.from({ length: count }, (_, index) => index).map((index) => (
{ {/* 提示文字 */} - NanoBananaPro 正在创作 2 张图片,请稍候... + NanoBananaPro 正在创作 {count} 张图片,请稍候...
diff --git a/components/image-params-bar.tsx b/components/image-params-bar.tsx index b1c347b..60cfe40 100644 --- a/components/image-params-bar.tsx +++ b/components/image-params-bar.tsx @@ -8,13 +8,14 @@ import { DropdownMenuItem, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; -import { Check, Image, Ratio, Maximize2 } from "lucide-react"; +import { Check, Image, Ratio, Maximize2, Images } from "lucide-react"; import { cn } from "@/lib/utils"; // 图片生成参数接口 export interface ImageGenerationParams { aspectRatio: string; imageSize: string; + imageCount?: number; // 🆕 生成图片数量 } interface ImageParamsBarProps { @@ -22,6 +23,7 @@ interface ImageParamsBarProps { initialParams?: Partial; onParamsChange?: (params: ImageGenerationParams) => void; inline?: boolean; // 内联模式:只显示参数按钮,不显示外层容器 + providerType?: string; // 🆕 模型类型:gemini, volcano-vision 等 } // 图片比例选项(根据 Nano Banana 文档) @@ -51,13 +53,27 @@ export function ImageParamsBar({ initialParams, onParamsChange, inline = false, + providerType = "gemini", // 默认 gemini }: ImageParamsBarProps) { const [params, setParams] = useState({ aspectRatio: "auto", imageSize: "2K", // 默认 2K + imageCount: 1, // 🆕 默认 1 张 ...initialParams, }); + // 🆕 根据模型类型确定可选的图片数量 + const getImageCountOptions = () => { + if (providerType === "volcano-vision") { + // 即梦4.5(豆包):1~3 张 + return [1, 2, 3]; + } + // Gemini:1~2 张 + return [1, 2]; + }; + + const imageCountOptions = getImageCountOptions(); + // 🆕 监听 initialParams 变化,确保状态同步 useEffect(() => { console.log("[ImageParamsBar] Mount/Update - initialParams:", initialParams); @@ -65,6 +81,7 @@ export function ImageParamsBar({ setParams({ aspectRatio: "auto", imageSize: "2K", + imageCount: 1, ...initialParams, }); } @@ -131,6 +148,28 @@ export function ImageParamsBar({ ))} + + {/* 🆕 图片数量 */} + + + + + + {imageCountOptions.map((count) => ( + updateParam("imageCount", count)} + className="focus-visible:ring-0 focus-visible:ring-offset-0 focus:outline-none focus:ring-0" + > + + {count}张 + + ))} + +
); } diff --git a/components/messages.tsx b/components/messages.tsx index db1ec73..9c642b5 100644 --- a/components/messages.tsx +++ b/components/messages.tsx @@ -38,6 +38,7 @@ type MessagesProps = { onImageEdit?: (imageUrl: string) => void; onDrawingComplete?: (annotatedImageUrl: string) => void; pendingGeneration?: PendingGeneration | null; // 🆕 持久化的生成状态 + imageCount?: number; // 🆕 用户选择的图片数量 }; function PureMessages({ @@ -53,6 +54,7 @@ function PureMessages({ onImageEdit, onDrawingComplete, pendingGeneration, + imageCount = 1, }: MessagesProps) { const { containerRef: messagesContainerRef, @@ -258,8 +260,8 @@ function PureMessages({ const showVideo = modelType === "volcano-video" || modelType === "qianwen-video" || (!modelType && (isQianwenVideoModel || isVideoModel)); if (showVideo) return ; - if (showGemini) return ; - if (showVolcano) return ; + if (showGemini) return ; + if (showVolcano) return ; return ; })()} diff --git a/components/multimodal-input.tsx b/components/multimodal-input.tsx index 45e0c53..d47557d 100644 --- a/components/multimodal-input.tsx +++ b/components/multimodal-input.tsx @@ -87,6 +87,7 @@ function PureMultimodalInput({ imageModelName, imageParams, onImageParamsChange, + imageProviderType, }: { chatId: string; input: string; @@ -111,6 +112,7 @@ function PureMultimodalInput({ imageModelName?: string; imageParams?: ImageGenerationParams | null; onImageParamsChange?: (params: ImageGenerationParams) => void; + imageProviderType?: string; // 🆕 图片模型提供商类型 }) { // 🔍 调试日志:检查图片模型相关props console.log('[MultimodalInput] Render - isImageModel:', isImageModel); @@ -836,6 +838,7 @@ function PureMultimodalInput({ initialParams={imageParams || undefined} onParamsChange={onImageParamsChange} inline={true} + providerType={imageProviderType} /> )} diff --git a/components/volcano-vision-loading.tsx b/components/volcano-vision-loading.tsx index 0373e87..ff00e73 100644 --- a/components/volcano-vision-loading.tsx +++ b/components/volcano-vision-loading.tsx @@ -3,11 +3,18 @@ import { memo } from "react"; import { SparklesIcon } from "./icons"; +interface VolcanoVisionLoadingProps { + imageCount?: number; // 🆕 用户选择的图片数量,默认1张 +} + /** * 火山引擎视觉图片生成加载占位符组件 - * 显示一个 170x200 像素的模糊动态效果 + * 显示动态数量的 170x200 像素模糊动态效果 */ -export const VolcanoVisionLoading = memo(() => { +export const VolcanoVisionLoading = memo(({ imageCount = 1 }: VolcanoVisionLoadingProps) => { + // 确保数量在 1-3 范围内 + const count = Math.min(Math.max(imageCount, 1), 3); + return (
{ {/* 文本提示 */}
- 正在生成 3 张图片 + 正在生成 {count} 张图片 {
- {/* 3 个图片占位符 - 横向排列,每个 170x200px */} + {/* 图片占位符 - 横向排列,每个 170x200px */}
- {[0, 1, 2].map((index) => ( + {Array.from({ length: count }, (_, index) => index).map((index) => (
{ {/* 提示文字 */} - 火山引擎正在创作 3 张图片,请稍候... + 火山引擎正在创作 {count} 张图片,请稍候...
diff --git a/lib/ai/providers/gemini-image.ts b/lib/ai/providers/gemini-image.ts index 52c9c3f..6b01c53 100644 --- a/lib/ai/providers/gemini-image.ts +++ b/lib/ai/providers/gemini-image.ts @@ -563,7 +563,9 @@ export function createGeminiImage({ apiKey, baseURL }: GeminiImageConfig) { doGenerate: async (options: any) => { console.log("[Gemini Image] doGenerate called!"); // 调试日志 console.log("[Gemini Image] Messages received:", JSON.stringify(options.messages).substring(0, 500) + "..."); - console.log("[Gemini Image] Using modelId:", modelId); + // 🆕 支持覆盖 modelId(用于备用模型切换) + const effectiveModelId = options.overrideModelId || modelId; + console.log("[Gemini Image] Using modelId:", effectiveModelId, options.overrideModelId ? "(overridden)" : "(default)"); console.log("[Gemini Image] Image params from options:", options.imageParams); try { @@ -573,31 +575,34 @@ export function createGeminiImage({ apiKey, baseURL }: GeminiImageConfig) { aspectRatio: options.imageParams?.aspectRatio || "auto", imageSize: options.imageParams?.imageSize || "2K" }; + // 🆕 获取用户选择的图片数量,默认 1 张 + const imageCount = Math.min(Math.max(options.imageParams?.imageCount || 1, 1), 2); // Gemini 限制 1-2 张 console.log("[Gemini Image] Final imageParams after defaults:", imageParams); + console.log(`[Gemini Image] Image count: ${imageCount}`); - // 🆕 并发发起两个请求,生成2张图片(类似豆包模型) - console.log("[Gemini Image] Starting 2 concurrent requests..."); + // 🆕 根据用户选择的数量发起请求 + console.log(`[Gemini Image] Starting ${imageCount} concurrent request(s)...`); const startTime = Date.now(); - const [response1, response2] = await Promise.all([ - sendGeminiRequest(baseURL, apiKey, options.messages, modelId, imageParams), - sendGeminiRequest(baseURL, apiKey, options.messages, modelId, imageParams), - ]); + // 创建请求数组(使用 effectiveModelId) + const requestPromises = Array.from({ length: imageCount }, () => + sendGeminiRequest(baseURL, apiKey, options.messages, effectiveModelId, imageParams) + ); - console.log(`[Gemini Image] Both requests completed in ${Date.now() - startTime}ms`); + const responses = await Promise.all(requestPromises); + + console.log(`[Gemini Image] All ${imageCount} request(s) completed in ${Date.now() - startTime}ms`); // 提取图片并上传 base64 到 S3(并行处理) - const [imageUrls1, imageUrls2] = await Promise.all([ - extractAndUploadImages(response1), - extractAndUploadImages(response2), - ]); + const extractPromises = responses.map(response => extractAndUploadImages(response)); + const imageUrlsArrays = await Promise.all(extractPromises); - // 合并两次请求的图片结果 - const allImageUrls = [...imageUrls1, ...imageUrls2]; + // 合并所有请求的图片结果 + const allImageUrls = imageUrlsArrays.flat(); const markdownText = formatImageResponse(allImageUrls, imageParams.imageSize); console.log( - `[Gemini Image] Generated ${allImageUrls.length} images from 2 concurrent requests (uploaded to S3)` + `[Gemini Image] Generated ${allImageUrls.length} images from ${imageCount} concurrent request(s) (uploaded to S3)` ); // 返回 AI SDK v1 格式 @@ -620,7 +625,9 @@ export function createGeminiImage({ apiKey, baseURL }: GeminiImageConfig) { doStream: async function* (options: any) { console.log("[Gemini Image] ===== doStream ENTERED! ====="); // 调试日志 console.log("[Gemini Image] Messages received:", JSON.stringify(options.messages).substring(0, 500) + "..."); - console.log("[Gemini Image] Using modelId:", modelId); // 调试日志 + // 🆕 支持覆盖 modelId(用于备用模型切换) + const effectiveModelId = options.overrideModelId || modelId; + console.log("[Gemini Image] Using modelId:", effectiveModelId, options.overrideModelId ? "(overridden)" : "(default)"); console.log("[Gemini Image] Image params from options:", options.imageParams); try { @@ -630,31 +637,34 @@ export function createGeminiImage({ apiKey, baseURL }: GeminiImageConfig) { aspectRatio: options.imageParams?.aspectRatio || "auto", imageSize: options.imageParams?.imageSize || "2K" }; + // 🆕 获取用户选择的图片数量,默认 1 张 + const imageCount = Math.min(Math.max(options.imageParams?.imageCount || 1, 1), 2); // Gemini 限制 1-2 张 console.log("[Gemini Image] Final imageParams after defaults:", imageParams); + console.log(`[Gemini Image Stream] Image count: ${imageCount}`); - // 🆕 并发发起两个请求,生成2张图片(类似豆包模型) - console.log("[Gemini Image Stream] Starting 2 concurrent requests..."); + // 🆕 根据用户选择的数量发起请求 + console.log(`[Gemini Image Stream] Starting ${imageCount} concurrent request(s)...`); const startTime = Date.now(); - const [response1, response2] = await Promise.all([ - sendGeminiRequest(baseURL, apiKey, options.messages, modelId, imageParams), - sendGeminiRequest(baseURL, apiKey, options.messages, modelId, imageParams), - ]); + // 创建请求数组(使用 effectiveModelId) + const requestPromises = Array.from({ length: imageCount }, () => + sendGeminiRequest(baseURL, apiKey, options.messages, effectiveModelId, imageParams) + ); - console.log(`[Gemini Image Stream] Both requests completed in ${Date.now() - startTime}ms`); + const responses = await Promise.all(requestPromises); + + console.log(`[Gemini Image Stream] All ${imageCount} request(s) completed in ${Date.now() - startTime}ms`); // 提取图片并上传 base64 到 S3(并行处理) - const [imageUrls1, imageUrls2] = await Promise.all([ - extractAndUploadImages(response1), - extractAndUploadImages(response2), - ]); + const extractPromises = responses.map(response => extractAndUploadImages(response)); + const imageUrlsArrays = await Promise.all(extractPromises); - // 合并两次请求的图片结果 - const allImageUrls = [...imageUrls1, ...imageUrls2]; + // 合并所有请求的图片结果 + const allImageUrls = imageUrlsArrays.flat(); const markdownText = formatImageResponse(allImageUrls, imageParams.imageSize); console.log( - `[Gemini Image Stream] Generated ${allImageUrls.length} images from 2 concurrent requests (uploaded to S3)` + `[Gemini Image Stream] Generated ${allImageUrls.length} images from ${imageCount} concurrent request(s) (uploaded to S3)` ); // 一次性发送整个图片 markdown(不再分块,因为 base64 图片很大) diff --git a/lib/ai/providers/volcano-vision.ts b/lib/ai/providers/volcano-vision.ts index e763218..b899fdb 100644 --- a/lib/ai/providers/volcano-vision.ts +++ b/lib/ai/providers/volcano-vision.ts @@ -543,9 +543,11 @@ export function createVolcanoVision({ apiKey, baseURL }: VolcanoVisionConfig) { console.log("[Volcano Vision] doGenerate called!"); console.log("[Volcano Vision] Messages received:", JSON.stringify(options.messages).substring(0, 500) + "..."); console.log("[Volcano Vision] Using modelId:", modelId); + console.log("[Volcano Vision] Image params from options:", options.imageParams); - // 支持可配置的图片数量(故障转移时传 1,正常使用时传 3 或不传) - const maxImages = options.maxImages ?? 3; + // 🆕 优先使用用户选择的图片数量,其次是 maxImages 参数,默认 1 张 + const imageCount = options.imageParams?.imageCount || options.maxImages || 1; + const maxImages = Math.min(Math.max(imageCount, 1), 3); // 限制 1-3 张 console.log("[Volcano Vision] Max images:", maxImages); try { diff --git a/lib/db/migrations/0026_add_fallback_model_ids.sql b/lib/db/migrations/0026_add_fallback_model_ids.sql new file mode 100644 index 0000000..11fa574 --- /dev/null +++ b/lib/db/migrations/0026_add_fallback_model_ids.sql @@ -0,0 +1,10 @@ +-- 为 AI 模型添加备用模型 ID 字段 +-- 当主模型请求失败时,自动切换到备用模型 + +ALTER TABLE "AiModel" +ADD COLUMN IF NOT EXISTS "fallbackModelId1" VARCHAR(128), +ADD COLUMN IF NOT EXISTS "fallbackModelId2" VARCHAR(128); + +COMMENT ON COLUMN "AiModel"."fallbackModelId1" IS '备用模型 ID 1,当主模型请求失败时优先使用'; +COMMENT ON COLUMN "AiModel"."fallbackModelId2" IS '备用模型 ID 2,当备用模型 1 也失败时使用'; + diff --git a/lib/db/queries.ts b/lib/db/queries.ts index 9252d63..58a390e 100644 --- a/lib/db/queries.ts +++ b/lib/db/queries.ts @@ -1111,6 +1111,8 @@ export async function createAiModel({ isEnabled, isDefault, sortOrder, + fallbackModelId1, + fallbackModelId2, }: { providerId: string; modelId: string; @@ -1121,6 +1123,8 @@ export async function createAiModel({ isEnabled?: boolean; isDefault?: boolean; sortOrder?: number; + fallbackModelId1?: string; + fallbackModelId2?: string; }): Promise { try { // 如果设置为默认,先取消其他默认 @@ -1143,6 +1147,8 @@ export async function createAiModel({ isEnabled: isEnabled ?? true, isDefault: isDefault ?? false, sortOrder: sortOrder ?? 0, + fallbackModelId1: fallbackModelId1 || null, + fallbackModelId2: fallbackModelId2 || null, }) .returning(); } catch (_error) { @@ -1164,6 +1170,8 @@ export async function updateAiModel({ isEnabled, isDefault, sortOrder, + fallbackModelId1, + fallbackModelId2, }: { id: string; providerId?: string; @@ -1175,6 +1183,8 @@ export async function updateAiModel({ isEnabled?: boolean; isDefault?: boolean; sortOrder?: number; + fallbackModelId1?: string; + fallbackModelId2?: string; }): Promise { try { // 如果设置为默认,先取消其他默认 @@ -1196,6 +1206,9 @@ export async function updateAiModel({ if (isEnabled !== undefined) updateData.isEnabled = isEnabled; if (isDefault !== undefined) updateData.isDefault = isDefault; if (sortOrder !== undefined) updateData.sortOrder = sortOrder; + // 🆕 备用模型 ID(空字符串转为 null) + if (fallbackModelId1 !== undefined) updateData.fallbackModelId1 = fallbackModelId1 || null; + if (fallbackModelId2 !== undefined) updateData.fallbackModelId2 = fallbackModelId2 || null; return await db .update(aiModel) diff --git a/lib/db/schema.ts b/lib/db/schema.ts index c181006..cdfb019 100644 --- a/lib/db/schema.ts +++ b/lib/db/schema.ts @@ -247,6 +247,9 @@ export const aiModel = pgTable("AiModel", { // 🆕 子模型相关字段(用于火山引擎视频等需要智能选择的模型) isSubModel: boolean("is_sub_model").notNull().default(false), // 是否为子模型(不在模型列表中显示) parentModelId: uuid("parent_model_id"), // 父模型ID(如果是子模型),注:暂不添加外键约束避免循环引用 + // 🆕 备用模型 ID(当主模型请求失败时自动切换) + fallbackModelId1: varchar("fallbackModelId1", { length: 128 }), // 备用模型 ID 1 + fallbackModelId2: varchar("fallbackModelId2", { length: 128 }), // 备用模型 ID 2 createdAt: timestamp("createdAt").notNull().defaultNow(), updatedAt: timestamp("updatedAt").notNull().defaultNow(), });