diff --git a/components/image-params-bar.tsx b/components/image-params-bar.tsx index 479c0d0e5..d3f28f606 100644 --- a/components/image-params-bar.tsx +++ b/components/image-params-bar.tsx @@ -3,6 +3,7 @@ import { useEffect, useState } from "react"; import { Button } from "@/components/ui/button"; import { Link2, Scan, SlidersHorizontal, Sparkles } from "lucide-react"; +import { getGrsaiGptImageOutputSize } from "@/lib/ai/grsai-gpt-image-sizes"; import { cn } from "@/lib/utils"; import { Input } from "@/components/ui/input"; import { Switch } from "@/components/ui/switch"; @@ -204,6 +205,10 @@ export function ImageParamsBar({ }); const getComputedOutputSize = (aspectRatio: string, imageSize: string) => { + if (providerType === "grsai-gpt-image") { + return getGrsaiGptImageOutputSize(aspectRatio, imageSize); + } + const size = IMAGE_SIZE_PIXEL_MAP[imageSize] || IMAGE_SIZE_PIXEL_MAP["2K"]; if (aspectRatio === "auto" || aspectRatio === "1:1") { return { width: size, height: size }; diff --git a/lib/ai/auto-agent-graph.ts b/lib/ai/auto-agent-graph.ts index 8a4c982eb..13d875138 100644 --- a/lib/ai/auto-agent-graph.ts +++ b/lib/ai/auto-agent-graph.ts @@ -781,14 +781,24 @@ function inferExplicitAspectRatioFromText(text: string): string | undefined { return undefined; } +function inferExplicitImageSizeFromText(text: string): "1K" | "2K" | "4K" | undefined { + const normalized = String(text || "").toLowerCase(); + if (/(^|[^0-9a-z])4\s*k([^0-9a-z]|$)/.test(normalized)) return "4K"; + if (/(^|[^0-9a-z])2\s*k([^0-9a-z]|$)/.test(normalized)) return "2K"; + if (/(^|[^0-9a-z])1\s*k([^0-9a-z]|$)/.test(normalized)) return "1K"; + return undefined; +} + function extractExplicitImageParamsFromText( text: string, -): Pick { - const output: Pick = {}; +): Pick { + const output: Pick = {}; const aspectRatio = inferExplicitAspectRatioFromText(text); const imageCount = inferExplicitImageCountFromText(text); + const imageSize = inferExplicitImageSizeFromText(text); if (aspectRatio) output.aspectRatio = aspectRatio; if (imageCount) output.imageCount = imageCount; + if (imageSize) output.imageSize = imageSize; return output; } @@ -1050,8 +1060,9 @@ function heuristicImageParams( analysis: AnalysisResult, ): AgentRecommendedImageParams | undefined { const normalized = text.toLowerCase(); + const explicitImageSize = inferExplicitImageSizeFromText(text); const params: AgentRecommendedImageParams = { - imageSize: "2K", + imageSize: explicitImageSize || (analysis.intent === "image_edit" ? "4K" : "2K"), promptExtend: analysis.intent === "image_edit" ? false : true, }; diff --git a/lib/ai/grsai-gpt-image-sizes.ts b/lib/ai/grsai-gpt-image-sizes.ts new file mode 100644 index 000000000..a7820d10b --- /dev/null +++ b/lib/ai/grsai-gpt-image-sizes.ts @@ -0,0 +1,94 @@ +export type GrsaiGptImageSizeBucket = "1K" | "2K" | "4K"; + +type GrsaiGptImageSizeOption = { + bucket: GrsaiGptImageSizeBucket; + width: number; + height: number; +}; + +const GRSAI_GPT_IMAGE_SIZE_OPTIONS: GrsaiGptImageSizeOption[] = [ + { bucket: "1K", width: 1024, height: 1024 }, + { bucket: "1K", width: 1774, height: 887 }, + { bucket: "1K", width: 887, height: 1774 }, + { bucket: "1K", width: 1536, height: 1024 }, + { bucket: "1K", width: 1024, height: 1536 }, + { bucket: "2K", width: 2048, height: 2048 }, + { bucket: "2K", width: 2048, height: 1152 }, + { bucket: "2K", width: 1152, height: 2048 }, + { bucket: "2K", width: 2048, height: 1360 }, + { bucket: "2K", width: 1360, height: 2048 }, + { bucket: "2K", width: 2048, height: 880 }, + { bucket: "2K", width: 880, height: 2048 }, + { bucket: "2K", width: 2048, height: 688 }, + { bucket: "2K", width: 688, height: 2048 }, + { bucket: "2K", width: 2048, height: 1024 }, + { bucket: "2K", width: 1024, height: 2048 }, + { bucket: "4K", width: 2880, height: 2880 }, + { bucket: "4K", width: 3840, height: 2160 }, + { bucket: "4K", width: 2160, height: 3840 }, + { bucket: "4K", width: 3504, height: 2336 }, + { bucket: "4K", width: 2336, height: 3504 }, + { bucket: "4K", width: 3840, height: 1648 }, + { bucket: "4K", width: 1648, height: 3840 }, + { bucket: "4K", width: 3840, height: 1280 }, + { bucket: "4K", width: 1280, height: 3840 }, + { bucket: "4K", width: 3840, height: 1920 }, + { bucket: "4K", width: 1920, height: 3840 }, +]; + +const GRSAI_GPT_IMAGE_SIZE_VALUES = new Set( + GRSAI_GPT_IMAGE_SIZE_OPTIONS.map((option) => `${option.width}x${option.height}`) +); + +function normalizeGrsaiGptImageBucket(imageSize?: string): GrsaiGptImageSizeBucket { + const normalized = String(imageSize || "").trim().toUpperCase(); + if (normalized === "1K" || normalized === "2K" || normalized === "4K") { + return normalized; + } + return "2K"; +} + +function parseAspectRatioValue(aspectRatio?: string): number { + const normalized = String(aspectRatio || "").trim().toLowerCase(); + if (!normalized || normalized === "auto") { + return 1; + } + + const [widthRaw, heightRaw] = normalized.split(":").map((part) => Number(part)); + if (!Number.isFinite(widthRaw) || !Number.isFinite(heightRaw) || widthRaw <= 0 || heightRaw <= 0) { + return 1; + } + + return widthRaw / heightRaw; +} + +export function getGrsaiGptImageOutputSize(aspectRatio?: string, imageSize?: string) { + const bucket = normalizeGrsaiGptImageBucket(imageSize); + const targetRatio = parseAspectRatioValue(aspectRatio); + const candidates = GRSAI_GPT_IMAGE_SIZE_OPTIONS.filter((option) => option.bucket === bucket); + const best = candidates.reduce((currentBest, option) => { + const currentDelta = Math.abs(currentBest.width / currentBest.height - targetRatio); + const optionDelta = Math.abs(option.width / option.height - targetRatio); + if (optionDelta < currentDelta) { + return option; + } + if (optionDelta === currentDelta) { + const currentArea = currentBest.width * currentBest.height; + const optionArea = option.width * option.height; + return optionArea > currentArea ? option : currentBest; + } + return currentBest; + }, candidates[0]); + + return { width: best.width, height: best.height }; +} + +export function resolveGrsaiGptImageSize(aspectRatio?: string, imageSize?: string): string { + const requestedSize = String(imageSize || "").trim().toLowerCase(); + if (/^\d+x\d+$/.test(requestedSize) && GRSAI_GPT_IMAGE_SIZE_VALUES.has(requestedSize)) { + return requestedSize; + } + + const { width, height } = getGrsaiGptImageOutputSize(aspectRatio, imageSize); + return `${width}x${height}`; +} diff --git a/lib/ai/providers/gemini-image.ts b/lib/ai/providers/gemini-image.ts index 8b2e87978..3d6708ffc 100644 --- a/lib/ai/providers/gemini-image.ts +++ b/lib/ai/providers/gemini-image.ts @@ -1,5 +1,6 @@ import "server-only"; +import { resolveGrsaiGptImageSize } from "@/lib/ai/grsai-gpt-image-sizes"; import { uploadFile } from "@/lib/storage/service"; interface GeminiImageConfig { @@ -482,8 +483,11 @@ async function sendGeminiRequest( } if (isGptImageModel) { - requestBody.size = imageParams?.aspectRatio || "auto"; + requestBody.size = resolveGrsaiGptImageSize(imageParams?.aspectRatio, imageParams?.imageSize); requestBody.shutProgress = false; + console.log( + `${apiLogPrefix} Resolved size: aspectRatio=${imageParams?.aspectRatio || "auto"}, imageSize=${imageParams?.imageSize || "2K"} => ${requestBody.size}` + ); } else { // 添加 Nano Banana 特定参数 if (imageParams?.aspectRatio) { @@ -602,8 +606,11 @@ async function sendGeminiRequest( } catch (error) { clearTimeout(timeoutId); if (error instanceof Error && error.name === "AbortError") { - console.error("[Gemini Image] Request timeout after 120 seconds"); - throw new Error("Gemini 请求超时(120秒),将自动切换备用模型"); + const timeoutSeconds = Math.round(timeout / 1000); + console.error(`${apiLogPrefix} Request timeout after ${timeoutSeconds} seconds`); + throw new Error( + `${isGptImageModel ? "Grsai GPT Image" : "Gemini"} 请求超时(${timeoutSeconds}秒),将自动切换备用模型` + ); } throw error; }