From 80e97717901eab22f8e5a7e470865c14e6846648 Mon Sep 17 00:00:00 2001 From: 134 Date: Tue, 14 Jul 2026 14:10:18 +0800 Subject: [PATCH] feat: add Visionary image provider --- app/(admin)/settings/_lib/types.ts | 1 + .../settings/admin-settings-client.tsx | 76 +++ app/(chat)/api/chat/_lib/image-failover.ts | 2 + app/(chat)/api/chat/_lib/image-media.ts | 1 + app/(chat)/api/chat/_lib/stream.ts | 1 + app/(chat)/api/chat/route.ts | 13 + app/(chat)/api/chat/schema.ts | 1 + app/api/admin/ai-providers/route.ts | 4 + app/api/admin/visionary-models/route.ts | 139 +++++ app/api/canvas/text-edit/route.ts | 4 + app/api/scene-fusion/route.ts | 49 ++ components/image-generation-loading.tsx | 1 + components/image-params-bar.tsx | 25 +- components/image-preview.tsx | 3 + lib/ai/dynamic-provider.ts | 13 + lib/ai/model-classification.ts | 1 + lib/ai/model-router.ts | 13 +- lib/ai/provider-defaults.ts | 1 + lib/ai/providers/visionary.ts | 532 ++++++++++++++++++ lib/ai/runtime/generated-image-persistence.ts | 2 + lib/ai/visionary-models.ts | 161 ++++++ lib/canvas/generate-image.ts | 28 +- lib/db/migrate.ts | 3 +- .../0092_add_visionary_provider_type.sql | 39 ++ lib/db/migrations/meta/_journal.json | 7 + lib/db/queries.ts | 4 +- lib/db/schema.ts | 2 +- lib/external-image-api/service.ts | 5 + lib/image-url.ts | 6 +- lib/scene-fusion/output-capabilities.ts | 2 + 30 files changed, 1120 insertions(+), 19 deletions(-) create mode 100644 app/api/admin/visionary-models/route.ts create mode 100644 lib/ai/providers/visionary.ts create mode 100644 lib/ai/visionary-models.ts create mode 100644 lib/db/migrations/0092_add_visionary_provider_type.sql diff --git a/app/(admin)/settings/_lib/types.ts b/app/(admin)/settings/_lib/types.ts index 5b05bb5a6..7a89fcbe5 100644 --- a/app/(admin)/settings/_lib/types.ts +++ b/app/(admin)/settings/_lib/types.ts @@ -89,6 +89,7 @@ export type AdminAiProvider = { | "snapgen-image" | "openai-image" | "bananaimage" + | "visionary" | "stepfun-image" | "midjourney" | "fal-video" diff --git a/app/(admin)/settings/admin-settings-client.tsx b/app/(admin)/settings/admin-settings-client.tsx index 4a6be57e6..d26512e1f 100644 --- a/app/(admin)/settings/admin-settings-client.tsx +++ b/app/(admin)/settings/admin-settings-client.tsx @@ -166,6 +166,7 @@ import { FAL_IMAGE_MODEL_PRESETS } from "@/lib/ai/fal-image-models"; import { RUXA_IMAGE_MODEL_PRESETS } from "@/lib/ai/ruxa-image-models"; import { SNAPGEN_IMAGE_MODEL_PRESETS } from "@/lib/ai/snapgen-image-models"; import { BANANAIMAGE_MODEL_PRESETS } from "@/lib/ai/bananaimage-models"; +import { VISIONARY_MODEL_PRESETS } from "@/lib/ai/visionary-models"; import { formatImageModelCreditDiscountLabel, getGenerationCreditCostFromImageGenConfig, @@ -623,6 +624,7 @@ type AiProvider = { | "snapgen-image" | "openai-image" | "bananaimage" + | "visionary" | "stepfun-image" | "midjourney" | "fal-video" @@ -1582,6 +1584,7 @@ const PROVIDER_TYPES = [ { value: "snapgen-image", label: "SnapGen 生图" }, { value: "openai-image", label: "OpenAI 图片兼容" }, { value: "bananaimage", label: "BananaImage 生图" }, + { value: "visionary", label: "Visionary 生图" }, { value: "stepfun-image", label: "阶跃星辰 Step Image Edit" }, { value: "bltcy-video", label: "柏拉图统一视频" }, { value: "grsai-gpt-image", label: "Grsai GPT Image" }, @@ -1617,6 +1620,7 @@ const PROVIDER_DEFAULT_BASE_URL: Record = { "snapgen-image": "https://api.snapgen.ai", "openai-image": "https://api.openai.com/v1", bananaimage: "https://bananaimage.aigenmedia.art", + visionary: "https://api.visionary.beer", "stepfun-image": "https://api.stepfun.com", "bltcy-video": "https://api.bltcy.ai", "grsai-gpt-image": "https://grsai.dakka.com.cn", @@ -4034,6 +4038,7 @@ export function AdminSettingsClient() { | "snapgen-image" | "openai-image" | "bananaimage" + | "visionary" | "stepfun-image" | "midjourney" | "fal-video" @@ -6271,6 +6276,7 @@ export function AdminSettingsClient() { const isRuxaImageProvider = providerForm.type === "ruxa-image"; const isSnapgenImageProvider = providerForm.type === "snapgen-image"; const isBananaImageProvider = providerForm.type === "bananaimage"; + const isVisionaryProvider = providerForm.type === "visionary"; const isBltcyVideoProvider = providerForm.type === "bltcy-video"; const isLemonProvider = providerForm.type === "openai" && @@ -6424,6 +6430,17 @@ export function AdminSettingsClient() { } } + if (isNewProvider && isVisionaryProvider && providerId) { + try { + await handleQuickAddVisionaryModels(providerId, { skipConfirm: true }); + } catch (modelError) { + console.error("[Auto Add Visionary Models] Error:", modelError); + toast.warning( + 'Visionary 预设模型添加失败,请手动点击"快速添加 Visionary 图片预设"按钮' + ); + } + } + if (isNewProvider && isBltcyVideoProvider && providerId) { try { await handleQuickAddBltcyVideoModels(providerId, { @@ -7435,6 +7452,44 @@ export function AdminSettingsClient() { } }; + const handleQuickAddVisionaryModels = async ( + providerId: string, + options?: { skipConfirm?: boolean } + ) => { + if ( + !options?.skipConfirm && + !confirm( + `将同步 ${VISIONARY_MODEL_PRESETS.length} 个 Visionary 生图模型,支持最多 9 张参考图、比例选择、质量档位和 1K/2K/4K。\n\n继续吗?` + ) + ) { + return; + } + + try { + toast.info("正在同步 Visionary 生图预设模型..."); + const response = await fetch("/api/admin/visionary-models", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ providerId }), + }); + const data = await response.json(); + if (!response.ok) throw new Error(data.error || "同步失败"); + + const totalSynced = + Number(data.addedCount || 0) + Number(data.updatedCount || 0); + if (totalSynced > 0) { + toast.success(`成功同步 ${totalSynced} 个 Visionary 生图预设模型`); + mutateModels(); + } + if (data.errors?.length) { + toast.warning(`${data.errors.length} 个 Visionary 模型同步失败`); + } + } catch (error: any) { + console.error("[Quick Add Visionary Models] Error:", error); + toast.error(error.message || "同步 Visionary 生图预设模型失败"); + } + }; + const handleUseFalImageProviderPreset = () => { setProviderForm({ id: "", @@ -15643,6 +15698,14 @@ export function AdminSettingsClient() { 1K/2K/4K 输出。

)} + {providerForm.type === "visionary" && ( +

+ Visionary 使用 Bearer API Key 认证,默认地址 + https://api.visionary.beer。保存后自动同步 GPT Image 2 + 与 Nano Banana 全部开放模型,支持最多 9 张参考图、比例选择、 + 质量档位和 1K/2K/4K 输出。 +

+ )} {providerForm.type === "bfl-image" && isBltcyBflBaseUrl(providerForm.baseUrl) && (

@@ -15776,6 +15839,19 @@ export function AdminSettingsClient() { 快速添加 BananaImage 图片预设 )} + {provider.type === "visionary" && ( + + )} {provider.type === "bltcy-video" && ( ))} diff --git a/components/image-preview.tsx b/components/image-preview.tsx index 07d6e1a51..8ca8dfb6c 100644 --- a/components/image-preview.tsx +++ b/components/image-preview.tsx @@ -187,6 +187,9 @@ export function ImagePreview({ } else if (src.includes("bananaimage-thumb-")) { originalUrl = src.replace("bananaimage-thumb-", "bananaimage-orig-"); console.log("[ImagePreview] Inferred original URL from bananaimage-thumb filename"); + } else if (src.includes("visionary-thumb-")) { + originalUrl = src.replace("visionary-thumb-", "visionary-orig-"); + console.log("[ImagePreview] Inferred original URL from visionary-thumb filename"); } else if (src.includes("/thumb/") || src.includes("-thumb")) { // 尝试从S3 URL中推断原图URL(常见格式:xxx-thumb.jpg -> xxx.jpg 或 xxx/thumb/xxx -> xxx/xxx) originalUrl = src.replace(/-thumb\./g, ".").replace(/\/thumb\//g, "/"); diff --git a/lib/ai/dynamic-provider.ts b/lib/ai/dynamic-provider.ts index d73eec70e..015346fae 100644 --- a/lib/ai/dynamic-provider.ts +++ b/lib/ai/dynamic-provider.ts @@ -25,6 +25,7 @@ import { createFalImage } from "./providers/fal-image"; import { createSnapgenImage } from "./providers/snapgen-image"; import { createOpenAIImage } from "./providers/openai-image"; import { createBananaImage } from "./providers/bananaimage"; +import { createVisionary } from "./providers/visionary"; import { createStepfunImage } from "./providers/stepfun-image"; import { createMidjourney } from "./providers/midjourney"; import { createFalVideo } from "./providers/fal-video"; @@ -304,6 +305,15 @@ function getOrCreateProvider(provider: Pick = { // 图片编辑:优先 alibaba-image(灵魂4.5,支持提示词扩展) image_edit: { - providerTypes: ["alibaba-image", "bfl-image", "ruxa-image", "fal-image", "snapgen-image", "openai-image", "bananaimage", "stepfun-image", "runway", "krea", "gemini", "huanke-gemini"], + providerTypes: ["alibaba-image", "bfl-image", "ruxa-image", "fal-image", "snapgen-image", "openai-image", "bananaimage", "visionary", "stepfun-image", "runway", "krea", "gemini", "huanke-gemini"], requireVision: true, requireImageGen: true, description: "图片编辑", @@ -188,7 +189,7 @@ const INTENT_MODEL_MAP: Record< // 图片生成:优先灵魂4.5 image_gen: { - providerTypes: ["alibaba-image", "bfl-image", "ruxa-image", "fal-image", "snapgen-image", "openai-image", "bananaimage", "stepfun-image", "runway", "krea", "gemini", "huanke-gemini", "volcano-vision"], + providerTypes: ["alibaba-image", "bfl-image", "ruxa-image", "fal-image", "snapgen-image", "openai-image", "bananaimage", "visionary", "stepfun-image", "runway", "krea", "gemini", "huanke-gemini", "volcano-vision"], requireImageGen: true, description: "图片生成", }, @@ -216,25 +217,25 @@ const SUB_INTENT_OVERRIDES: Record< > = { // 抠图:alibaba-image 效果最好 remove_background: { - providerTypes: ["alibaba-image", "bfl-image", "ruxa-image", "fal-image", "snapgen-image", "openai-image", "bananaimage", "stepfun-image", "runway", "krea", "gemini"], + providerTypes: ["alibaba-image", "bfl-image", "ruxa-image", "fal-image", "snapgen-image", "openai-image", "bananaimage", "visionary", "stepfun-image", "runway", "krea", "gemini"], reason: "抠图首选灵魂4.5", }, // 换背景:alibaba-image 支持场景生成 replace_background: { - providerTypes: ["alibaba-image", "bfl-image", "ruxa-image", "fal-image", "snapgen-image", "openai-image", "bananaimage", "stepfun-image", "runway", "krea", "gemini"], + providerTypes: ["alibaba-image", "bfl-image", "ruxa-image", "fal-image", "snapgen-image", "openai-image", "bananaimage", "visionary", "stepfun-image", "runway", "krea", "gemini"], reason: "换背景首选灵魂4.5", }, // 图片增强 enhance: { - providerTypes: ["alibaba-image", "bfl-image", "ruxa-image", "fal-image", "snapgen-image", "openai-image", "bananaimage", "stepfun-image", "runway", "krea", "gemini", "volcano-vision"], + providerTypes: ["alibaba-image", "bfl-image", "ruxa-image", "fal-image", "snapgen-image", "openai-image", "bananaimage", "visionary", "stepfun-image", "runway", "krea", "gemini", "volcano-vision"], reason: "增强首选灵魂4.5", }, // 风格迁移:gemini 可能更有创意 style_transfer: { - providerTypes: ["gemini", "alibaba-image", "bfl-image", "ruxa-image", "fal-image", "snapgen-image", "openai-image", "bananaimage", "stepfun-image", "runway", "krea"], + providerTypes: ["gemini", "alibaba-image", "bfl-image", "ruxa-image", "fal-image", "snapgen-image", "openai-image", "bananaimage", "visionary", "stepfun-image", "runway", "krea"], reason: "风格迁移优选Gemini", }, }; diff --git a/lib/ai/provider-defaults.ts b/lib/ai/provider-defaults.ts index a21042d3e..8d7dfbbaa 100644 --- a/lib/ai/provider-defaults.ts +++ b/lib/ai/provider-defaults.ts @@ -18,6 +18,7 @@ const PROVIDER_DEFAULT_BASE_URL: Partial> = { "snapgen-image": "https://api.snapgen.ai", "openai-image": "https://api.openai.com/v1", bananaimage: "https://bananaimage.aigenmedia.art", + visionary: "https://api.visionary.beer", "stepfun-image": "https://api.stepfun.com", "bfl-image": "https://api.bfl.ai", midjourney: "https://your-midjourney-api.example.com", diff --git a/lib/ai/providers/visionary.ts b/lib/ai/providers/visionary.ts new file mode 100644 index 000000000..2e08dfd48 --- /dev/null +++ b/lib/ai/providers/visionary.ts @@ -0,0 +1,532 @@ +import "server-only"; + +import { randomUUID } from "node:crypto"; +import sharp from "sharp"; +import { + getVisionaryModelPresetById, + VISIONARY_BASE_URL, +} from "@/lib/ai/visionary-models"; +import { createDeferredUploadedImage } from "@/lib/storage/image-archive"; +import { uploadFile } from "@/lib/storage/service"; + +interface VisionaryConfig { + apiKey: string; + baseURL?: string; +} + +interface VisionaryImageParams { + aspectRatio?: string; + imageSize?: string; + imageCount?: number; + promptExtend?: boolean; + quality?: "auto" | "low" | "medium" | "high" | string; +} + +interface VisionaryModelConfig { + maxReferenceImages?: number; + supportedAspectRatios?: string[]; + supportedImageSizes?: string[]; + supportsPromptExtend?: boolean; + supportsQuality?: boolean; +} + +interface ReferenceImage { + apiValue: string; + height?: number; + width?: number; +} + +interface UploadedImage { + thumbnail: string; + original: string; +} + +const POLL_INTERVAL_MS = 3000; +const MAX_POLL_INTERVAL_MS = 15_000; +const POLL_TIMEOUT_MS = 18 * 60 * 1000; +const MAX_TRANSIENT_POLL_FAILURES = 8; +const IMAGE_DOWNLOAD_TIMEOUT_MS = 120_000; + +function joinApiPath(baseURL: string, path: string) { + const cleanBase = String(baseURL || VISIONARY_BASE_URL).replace(/\/+$/, ""); + const cleanPath = path.startsWith("/") ? path : `/${path}`; + return `${cleanBase}${cleanPath}`; +} + +function delay(ms: number) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +function normalizeModelId(modelId?: string) { + const normalized = String(modelId || "").trim(); + if (!normalized) throw new Error("Visionary 模型 ID 未配置"); + return normalized; +} + +function normalizeAspectRatio(value?: string) { + const normalized = String(value || "auto").trim().toLowerCase(); + if (normalized === "auto") return "auto"; + const match = normalized.match(/^(\d+)\s*:\s*(\d+)$/); + if (!match) return "auto"; + return `${Number(match[1])}:${Number(match[2])}`; +} + +function getSupportedAspectRatios( + modelId: string, + modelConfig?: VisionaryModelConfig | null +) { + const preset = getVisionaryModelPresetById(modelId); + const configured = modelConfig?.supportedAspectRatios; + const values = Array.isArray(configured) + ? configured + : (preset?.imageGenConfig?.supportedAspectRatios as string[] | undefined); + return Array.from( + new Set((values || ["auto", "1:1"]).map(normalizeAspectRatio)) + ); +} + +function resolveAspectRatio( + requested: string | undefined, + modelId: string, + modelConfig?: VisionaryModelConfig | null +) { + const supported = getSupportedAspectRatios(modelId, modelConfig); + const normalized = normalizeAspectRatio(requested); + if (supported.includes(normalized)) return normalized; + return supported.includes("auto") ? "auto" : supported[0] || "1:1"; +} + +function normalizeImageSize( + requested: string | undefined, + modelId: string, + modelConfig?: VisionaryModelConfig | null +) { + const preset = getVisionaryModelPresetById(modelId); + const configured = modelConfig?.supportedImageSizes; + const supported = (Array.isArray(configured) + ? configured + : (preset?.imageGenConfig?.supportedImageSizes as string[] | undefined) || ["1K"] + ) + .map((size) => String(size).trim().toUpperCase()) + .filter((size) => ["1K", "2K", "4K"].includes(size)); + const defaultSize = String(preset?.defaultParams?.imageSize || supported[0] || "1K") + .trim() + .toUpperCase(); + const normalized = String(requested || defaultSize).trim().toUpperCase(); + if (supported.includes(normalized)) return normalized; + return supported.includes(defaultSize) ? defaultSize : supported[0] || "1K"; +} + +function normalizeQuality(value?: string) { + const normalized = String(value || "auto").trim().toLowerCase(); + return ["auto", "low", "medium", "high"].includes(normalized) + ? normalized + : "auto"; +} + +function extractPromptAndImages( + messages: Array<{ role: string; content?: any; parts?: any[] }> +) { + const lastUserMessage = messages.filter((message) => message.role === "user").pop(); + if (!lastUserMessage) throw new Error("未找到用户消息"); + + const texts: string[] = []; + const imageUrls: string[] = []; + const seenImages = new Set(); + const pushText = (value: unknown) => { + if (typeof value === "string" && value.trim()) texts.push(value.trim()); + }; + const pushImage = (value: unknown) => { + let url = ""; + if (typeof value === "string") url = value; + else if (value instanceof URL) url = value.toString(); + else if (value && typeof value === "object") { + url = String( + (value as any).url || + (value as any).imageUrl || + (value as any).fileUrl || + (value as any).src || + "" + ); + } + url = url.trim(); + if (!url || seenImages.has(url)) return; + if (!url.startsWith("data:image/") && !/^https?:\/\//i.test(url)) return; + seenImages.add(url); + imageUrls.push(url); + }; + const visitPart = (part: any) => { + if (!part) return; + if (typeof part === "string") { + pushText(part); + return; + } + if (part.type === "text" || typeof part.text === "string") pushText(part.text); + if (part.type === "image_url") pushImage(part.image_url); + if (part.type === "image") pushImage(part.image || part.url); + if (part.type === "file") pushImage(part.url || part.file || part.data); + if (part.inlineData?.data) { + pushImage( + `data:${part.inlineData.mimeType || "image/png"};base64,${part.inlineData.data}` + ); + } + }; + + const content = lastUserMessage.content; + if (typeof content === "string") pushText(content); + else if (Array.isArray(content)) content.forEach(visitPart); + if (Array.isArray(lastUserMessage.parts)) lastUserMessage.parts.forEach(visitPart); + + const prompt = texts.join("\n").trim(); + if (!prompt) throw new Error("请输入图片生成或编辑要求"); + return { prompt, imageUrls }; +} + +function parseDataUrl(dataUrl: string) { + const match = dataUrl.match(/^data:(image\/(?:jpeg|png|webp));base64,(.+)$/s); + if (!match) throw new Error("Visionary 参考图仅支持 JPEG、PNG 或 WebP Data URI"); + const mimeType = match[1] || "image/png"; + const buffer = Buffer.from(match[2] || "", "base64"); + return { mimeType, buffer, dataUrl }; +} + +async function loadReferenceImage(imageUrl: string): Promise { + if (imageUrl.startsWith("data:")) { + const parsed = parseDataUrl(imageUrl); + const metadata = await sharp(parsed.buffer, { failOn: "none" }) + .metadata() + .catch(() => null); + return { + apiValue: parsed.dataUrl, + width: metadata?.width, + height: metadata?.height, + }; + } + + const response = await fetch(imageUrl, { + cache: "no-store", + redirect: "follow", + signal: AbortSignal.timeout(IMAGE_DOWNLOAD_TIMEOUT_MS), + headers: { + Accept: "image/avif,image/webp,image/png,image/jpeg,image/*,*/*;q=0.8", + "User-Agent": "yedir-ai-visionary/1.0", + }, + }); + if (!response.ok) { + throw new Error(`Visionary 参考图下载失败(HTTP ${response.status})`); + } + const mimeType = response.headers.get("content-type")?.split(";")[0] || ""; + if (!/^image\/(?:jpeg|png|webp)$/i.test(mimeType)) { + throw new Error(`Visionary 不支持此参考图格式:${mimeType || "unknown"}`); + } + const buffer = Buffer.from(await response.arrayBuffer()); + const metadata = await sharp(buffer, { failOn: "none" }) + .metadata() + .catch(() => null); + return { + apiValue: /^https:\/\//i.test(imageUrl) + ? imageUrl + : `data:${mimeType};base64,${buffer.toString("base64")}`, + width: metadata?.width, + height: metadata?.height, + }; +} + +function detectImageExtension(mimeType?: string | null) { + const normalized = String(mimeType || "").toLowerCase(); + if (normalized.includes("jpeg") || normalized.includes("jpg")) return "jpg"; + if (normalized.includes("webp")) return "webp"; + if (normalized.includes("gif")) return "gif"; + return "png"; +} + +function generateFilename(prefix: string, extension: string) { + return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}.${extension}`; +} + +function toArrayBuffer(buffer: Buffer) { + return buffer.buffer.slice( + buffer.byteOffset, + buffer.byteOffset + buffer.byteLength + ) as ArrayBuffer; +} + +async function uploadImageBuffer( + buffer: Buffer, + mimeType?: string | null +): Promise { + const extension = detectImageExtension(mimeType); + const thumbnailBuffer = await sharp(buffer, { failOn: "none" }) + .resize(400, 400, { fit: "inside", withoutEnlargement: true }) + .jpeg({ quality: 82 }) + .toBuffer() + .catch(() => buffer); + const [original, thumbnail] = await Promise.all([ + uploadFile(generateFilename("visionary-orig", extension), toArrayBuffer(buffer)), + uploadFile(generateFilename("visionary-thumb", "jpg"), toArrayBuffer(thumbnailBuffer)), + ]); + return { original: original.url, thumbnail: thumbnail.url }; +} + +async function archiveOutputImage(imageUrl: string): Promise { + if (imageUrl.startsWith("data:")) { + const parsed = parseDataUrl(imageUrl); + return uploadImageBuffer(parsed.buffer, parsed.mimeType); + } + + const deferred = await createDeferredUploadedImage(imageUrl, { + providerType: "visionary", + }); + if (deferred) return deferred; + + const response = await fetch(imageUrl, { + cache: "no-store", + redirect: "follow", + signal: AbortSignal.timeout(IMAGE_DOWNLOAD_TIMEOUT_MS), + headers: { "User-Agent": "yedir-ai-visionary/1.0" }, + }); + if (!response.ok) { + throw new Error(`Visionary 结果图片下载失败(HTTP ${response.status})`); + } + return uploadImageBuffer( + Buffer.from(await response.arrayBuffer()), + response.headers.get("content-type") + ); +} + +async function parseJsonResponse(response: Response) { + const rawText = await response.text(); + if (!rawText.trim()) return null; + try { + return JSON.parse(rawText); + } catch { + return rawText; + } +} + +function extractErrorMessage(payload: any, fallback = "Visionary 请求失败") { + if (typeof payload === "string" && payload.trim()) return payload.trim(); + const value = + payload?.error?.message || + payload?.data?.error?.message || + payload?.data?.error || + payload?.error || + payload?.message || + fallback; + return typeof value === "string" ? value : fallback; +} + +function extractSubmittedTask(payload: any) { + const task = Array.isArray(payload?.data) ? payload.data[0] : payload?.data || payload; + const taskId = String(task?.task_id || task?.taskId || task?.id || "").trim(); + const retryAfter = Number(task?.retry_after || task?.retryAfter || 3); + return { taskId, retryAfter }; +} + +function extractTaskImages(payload: any) { + const task = payload?.data || payload; + const images = task?.result?.images; + if (!Array.isArray(images)) return [] as string[]; + return Array.from( + new Set( + images.flatMap((image: any) => { + const value = image?.url; + if (Array.isArray(value)) return value; + return value ? [value] : []; + }) + ) + ) + .map((value) => String(value || "").trim()) + .filter((value) => /^https?:\/\//i.test(value) || value.startsWith("data:image/")); +} + +async function submitTask(params: { + apiKey: string; + aspectRatio: string; + baseURL: string; + imageSize: string; + modelId: string; + prompt: string; + promptExtend?: boolean; + quality?: string; + referenceImages: ReferenceImage[]; +}) { + const clientRequestId = `yedir-${randomUUID()}`; + const body: Record = { + model: params.modelId, + prompt: params.prompt, + images: params.referenceImages.map((image) => image.apiValue), + size: params.aspectRatio, + resolution: params.imageSize, + client_request_id: clientRequestId, + }; + if (params.modelId === "gpt-image-2") { + body.quality = normalizeQuality(params.quality); + } + if (params.modelId === "nano-banana-pro") { + body.optimizeChineseText = Boolean(params.promptExtend); + } + + const response = await fetch(joinApiPath(params.baseURL, "/v1/images/generations"), { + method: "POST", + headers: { + Authorization: `Bearer ${params.apiKey}`, + "Content-Type": "application/json", + "Idempotency-Key": clientRequestId, + "User-Agent": "yedir-ai-visionary/1.0", + }, + body: JSON.stringify(body), + signal: AbortSignal.timeout(30_000), + }); + const payload = await parseJsonResponse(response); + if (!response.ok) { + throw new Error(`${extractErrorMessage(payload)}(HTTP ${response.status})`); + } + const task = extractSubmittedTask(payload); + if (!task.taskId) throw new Error("Visionary 未返回 task_id"); + return task; +} + +async function waitForTask(params: { + apiKey: string; + baseURL: string; + initialRetryAfter: number; + taskId: string; +}) { + const startedAt = Date.now(); + let retryAfter = params.initialRetryAfter; + let transientFailures = 0; + + while (Date.now() - startedAt < POLL_TIMEOUT_MS) { + const waitMs = Math.min( + MAX_POLL_INTERVAL_MS, + Math.max(POLL_INTERVAL_MS, Math.round(retryAfter * 1000)) + ); + await delay(waitMs); + + try { + const response = await fetch( + joinApiPath(params.baseURL, `/v1/tasks/${encodeURIComponent(params.taskId)}`), + { + cache: "no-store", + headers: { + Authorization: `Bearer ${params.apiKey}`, + "User-Agent": "yedir-ai-visionary/1.0", + }, + signal: AbortSignal.timeout(30_000), + } + ); + const payload = await parseJsonResponse(response); + if (!response.ok) { + if (response.status >= 500 || response.status === 429) { + throw new Error(`HTTP ${response.status}`); + } + throw new Error(extractErrorMessage(payload)); + } + + transientFailures = 0; + const task = payload?.data || payload; + const status = String(task?.status || "").trim().toLowerCase(); + retryAfter = Number(task?.retry_after || retryAfter || 3); + if (status === "completed") { + const images = extractTaskImages(payload); + if (images.length === 0) throw new Error("Visionary 任务完成但未返回图片"); + return images; + } + if (["failed", "error", "cancelled", "canceled"].includes(status)) { + throw new Error(extractErrorMessage(payload, "Visionary 图片生成失败")); + } + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + if (!/^HTTP (?:429|5\d\d)$/.test(message) && !/fetch|network|abort/i.test(message)) { + throw error; + } + transientFailures += 1; + if (transientFailures >= MAX_TRANSIENT_POLL_FAILURES) throw error; + } + } + + throw new Error("Visionary 生成超时,任务可能仍在处理,请稍后重试"); +} + +function formatMarkdown(images: UploadedImage[], imageSize: string) { + return images + .map((image) => `![thumb|||${image.original}|||${imageSize}](${image.thumbnail})`) + .join("\n\n"); +} + +export function createVisionary({ apiKey, baseURL = VISIONARY_BASE_URL }: VisionaryConfig) { + return { + provider: "visionary", + chat: (modelId: string) => ({ + provider: "visionary", + doGenerate: async (options: { + messages: Array<{ role: string; content?: any; parts?: any[] }>; + imageParams?: VisionaryImageParams; + imageGenConfig?: VisionaryModelConfig | null; + overrideModelId?: string; + }) => { + const requestedModelId = normalizeModelId(options.overrideModelId || modelId); + const preset = getVisionaryModelPresetById(requestedModelId); + if (!preset) throw new Error(`Visionary 暂不支持模型:${requestedModelId}`); + + const { prompt, imageUrls } = extractPromptAndImages(options.messages); + const maxReferenceImages = Math.max( + 0, + Math.min(9, options.imageGenConfig?.maxReferenceImages || 9) + ); + const referenceImages = await Promise.all( + imageUrls.slice(0, maxReferenceImages).map(loadReferenceImage) + ); + const aspectRatio = resolveAspectRatio( + options.imageParams?.aspectRatio, + requestedModelId, + options.imageGenConfig + ); + const imageSize = normalizeImageSize( + options.imageParams?.imageSize, + requestedModelId, + options.imageGenConfig + ); + + const submitted = await submitTask({ + apiKey, + aspectRatio, + baseURL, + imageSize, + modelId: requestedModelId, + prompt, + promptExtend: options.imageParams?.promptExtend, + quality: options.imageParams?.quality, + referenceImages, + }); + const outputUrls = await waitForTask({ + apiKey, + baseURL, + initialRetryAfter: submitted.retryAfter, + taskId: submitted.taskId, + }); + const uploadedImages = await Promise.all( + outputUrls + .slice(0, Math.max(1, options.imageParams?.imageCount || 1)) + .map(archiveOutputImage) + ); + const text = formatMarkdown(uploadedImages, imageSize); + return { + text, + finishReason: "stop" as const, + usage: { promptTokens: 0, completionTokens: text.length }, + }; + }, + doStream: async function* (options: { + messages: Array<{ role: string; content?: any; parts?: any[] }>; + imageParams?: VisionaryImageParams; + imageGenConfig?: VisionaryModelConfig | null; + overrideModelId?: string; + }) { + const result = await this.doGenerate(options); + yield { type: "text-delta", textDelta: result.text }; + }, + }), + }; +} diff --git a/lib/ai/runtime/generated-image-persistence.ts b/lib/ai/runtime/generated-image-persistence.ts index 8203794af..449ed76f0 100644 --- a/lib/ai/runtime/generated-image-persistence.ts +++ b/lib/ai/runtime/generated-image-persistence.ts @@ -61,6 +61,8 @@ export function parseImageAltMetadata(altText: string, thumbnailUrl: string): { originalUrl = thumbnailUrl.replace("snapgen-image-thumb-", "snapgen-image-orig-"); } else if (thumbnailUrl.includes("bananaimage-thumb-")) { originalUrl = thumbnailUrl.replace("bananaimage-thumb-", "bananaimage-orig-"); + } else if (thumbnailUrl.includes("visionary-thumb-")) { + originalUrl = thumbnailUrl.replace("visionary-thumb-", "visionary-orig-"); } } diff --git a/lib/ai/visionary-models.ts b/lib/ai/visionary-models.ts new file mode 100644 index 000000000..277b4e890 --- /dev/null +++ b/lib/ai/visionary-models.ts @@ -0,0 +1,161 @@ +export interface VisionaryModelPreset { + modelId: string; + displayName: string; + description: string; + supportsVision: boolean; + supportsImageGen: boolean; + capabilities?: Record; + defaultParams?: Record; + imageGenConfig?: Record; + sortOrder: number; +} + +export const VISIONARY_BASE_URL = "https://api.visionary.beer"; + +const VISIONARY_GPT_IMAGE_RATIOS = [ + "auto", + "1:1", + "3:2", + "2:3", + "4:3", + "3:4", + "16:9", + "9:16", + "21:9", + "9:21", + "2:1", + "1:2", + "5:4", + "4:5", + "3:1", + "1:3", +]; + +const VISIONARY_NANO_BANANA_RATIOS = [ + "auto", + "1:1", + "4:3", + "3:4", + "3:2", + "2:3", + "16:9", + "9:16", + "21:9", + "5:4", + "4:5", + "1:4", + "4:1", + "1:8", + "8:1", +]; + +function buildVisionaryPreset(params: { + modelId: string; + displayName: string; + description: string; + supportedAspectRatios: string[]; + supportedImageSizes: string[]; + defaultImageSize: string; + supportsQuality?: boolean; + supportsPromptExtend?: boolean; + sortOrder: number; +}) { + const supportedModes = ["text-to-image", "image-to-image", "reference-images"]; + + return { + modelId: params.modelId, + displayName: params.displayName, + description: params.description, + supportsVision: true, + supportsImageGen: true, + capabilities: { + provider: "visionary", + apiFormat: "visionary-task", + supportedModes, + supportedAspectRatios: params.supportedAspectRatios, + supportedImageSizes: params.supportedImageSizes, + supportsMultiImageReference: true, + maxReferenceImages: 9, + supportsQuality: Boolean(params.supportsQuality), + }, + defaultParams: { + provider: "visionary", + apiFormat: "visionary-task", + endpoint: params.modelId, + imageSize: params.defaultImageSize, + aspectRatio: "auto", + quality: "auto", + supportedModes, + }, + imageGenConfig: { + apiFormat: "visionary-task", + maxImages: 1, + supportsSize: true, + supports1K: params.supportedImageSizes.includes("1K"), + supports2K: params.supportedImageSizes.includes("2K"), + supports4K: params.supportedImageSizes.includes("4K"), + supportsPromptExtend: Boolean(params.supportsPromptExtend), + supportsNegativePrompt: false, + supportsWatermark: false, + supportsSeed: false, + supportsQuality: Boolean(params.supportsQuality), + maxReferenceImages: 9, + supportedImageSizes: params.supportedImageSizes, + supportedAspectRatios: params.supportedAspectRatios, + enableLoadingPlaceholder: true, + loadingColor: "yellow", + }, + sortOrder: params.sortOrder, + } satisfies VisionaryModelPreset; +} + +export const VISIONARY_MODEL_PRESETS: VisionaryModelPreset[] = [ + buildVisionaryPreset({ + modelId: "gpt-image-2", + displayName: "Visionary GPT Image 2", + description: + "Visionary GPT Image 2。支持文生图、最多 9 张参考图、完整比例选择、质量档位和 1K/2K/4K 输出。", + supportedAspectRatios: VISIONARY_GPT_IMAGE_RATIOS, + supportedImageSizes: ["1K", "2K", "4K"], + defaultImageSize: "1K", + supportsQuality: true, + sortOrder: 10, + }), + buildVisionaryPreset({ + modelId: "nano-banana-pro", + displayName: "Visionary Nano Banana Pro", + description: + "Visionary Nano Banana Pro。支持文生图、多图编辑、中文文字增强、极端比例和 2K/4K 输出。", + supportedAspectRatios: VISIONARY_NANO_BANANA_RATIOS, + supportedImageSizes: ["2K", "4K"], + defaultImageSize: "2K", + supportsPromptExtend: true, + sortOrder: 20, + }), + buildVisionaryPreset({ + modelId: "nano-banana-pro-cl", + displayName: "Visionary Nano Banana Pro CL", + description: + "Visionary Nano Banana Pro CL 开放 API 高质量线路。支持文生图、多图编辑、极端比例和 2K/4K 输出。", + supportedAspectRatios: VISIONARY_NANO_BANANA_RATIOS, + supportedImageSizes: ["2K", "4K"], + defaultImageSize: "2K", + sortOrder: 30, + }), + buildVisionaryPreset({ + modelId: "nano-banana-2-lite", + displayName: "Visionary Nano Banana 2 Lite", + description: + "Visionary Nano Banana 2 Lite 低延迟线路。支持文生图、多图编辑和极端比例,固定输出 1K。", + supportedAspectRatios: VISIONARY_NANO_BANANA_RATIOS, + supportedImageSizes: ["1K"], + defaultImageSize: "1K", + sortOrder: 40, + }), +]; + +export function getVisionaryModelPresetById(modelId: string) { + return VISIONARY_MODEL_PRESETS.find( + (preset) => preset.modelId === String(modelId || "").trim() + ); +} diff --git a/lib/canvas/generate-image.ts b/lib/canvas/generate-image.ts index 022757552..25011ddfd 100644 --- a/lib/canvas/generate-image.ts +++ b/lib/canvas/generate-image.ts @@ -11,6 +11,7 @@ import { createFalImage } from "@/lib/ai/providers/fal-image"; import { createSnapgenImage } from "@/lib/ai/providers/snapgen-image"; import { createOpenAIImage } from "@/lib/ai/providers/openai-image"; import { createBananaImage } from "@/lib/ai/providers/bananaimage"; +import { createVisionary } from "@/lib/ai/providers/visionary"; import { createStepfunImage } from "@/lib/ai/providers/stepfun-image"; import { createMidjourney } from "@/lib/ai/providers/midjourney"; import { createRunway } from "@/lib/ai/providers/runway"; @@ -296,6 +297,7 @@ const SUPPORTED_CANVAS_PROVIDER_TYPES = new Set([ "snapgen-image", "openai-image", "bananaimage", + "visionary", "stepfun-image", "midjourney", "runway", @@ -4149,6 +4151,7 @@ function getProviderBatchLimit(providerType: string) { case "bfl-image": case "openai-image": case "bananaimage": + case "visionary": case "stepfun-image": case "midjourney": case "runway": @@ -4182,6 +4185,7 @@ function getCanvasScaleAdjustProviderFailoverPriority(providerType: string) { case "snapgen-image": return 20; case "bananaimage": + case "visionary": return 25; case "krea": return 30; @@ -4998,7 +5002,7 @@ export async function generateCanvasImage( if (!SUPPORTED_CANVAS_PROVIDER_TYPES.has(provider.type)) { if (request.allowFailover === false) { throw new Error( - "当前图片模型类型暂不支持画布生成,请在后台选择 Gemini/Grsai GPT Image/火山 Seedream/阿里/BFL/RUXA/FAL/SnapGen/OpenAI/BananaImage/StepFun/Runway/Krea 图片模型。" + "当前图片模型类型暂不支持画布生成,请在后台选择 Gemini/Grsai GPT Image/火山 Seedream/阿里/BFL/RUXA/FAL/SnapGen/OpenAI/BananaImage/Visionary/StepFun/Runway/Krea 图片模型。" ); } @@ -5031,7 +5035,7 @@ export async function generateCanvasImage( } if (!SUPPORTED_CANVAS_PROVIDER_TYPES.has(fallbackProvider.type)) { throw new Error( - "当前图片模型类型暂不支持画布生成,请在后台选择 Gemini/Grsai GPT Image/火山 Seedream/阿里/BFL/RUXA/FAL/SnapGen/OpenAI/BananaImage/StepFun/Runway/Krea 图片模型。" + "当前图片模型类型暂不支持画布生成,请在后台选择 Gemini/Grsai GPT Image/火山 Seedream/阿里/BFL/RUXA/FAL/SnapGen/OpenAI/BananaImage/Visionary/StepFun/Runway/Krea 图片模型。" ); } if (fallbackModel.model.id !== selectedModel.model.id) { @@ -5294,6 +5298,26 @@ export async function generateCanvasImage( }); return result.text || ""; }; + } else if (provider.type === "visionary") { + const visionaryProvider = createVisionary({ + apiKey: provider.apiKey, + baseURL: provider.baseUrl, + }); + const runtimeModelId = resolveModelRuntimeId(selectedModel.model, { + providerType: provider.type, + }); + const modelInstance = visionaryProvider.chat(runtimeModelId); + executeGenerateBatch = async (batchImageCount) => { + const result = await modelInstance.doGenerate({ + messages, + imageParams: { + ...imageParams, + imageCount: batchImageCount, + }, + imageGenConfig: selectedModel.model.imageGenConfig || null, + }); + return result.text || ""; + }; } else if (provider.type === "stepfun-image") { const stepfunProvider = createStepfunImage({ apiKey: provider.apiKey, diff --git a/lib/db/migrate.ts b/lib/db/migrate.ts index 7294b4cc7..2ac816402 100644 --- a/lib/db/migrate.ts +++ b/lib/db/migrate.ts @@ -34,6 +34,7 @@ const providerBaseUrlDefaults = [ { type: "snapgen-image", baseUrl: "https://api.snapgen.ai" }, { type: "openai-image", baseUrl: "https://api.openai.com/v1" }, { type: "bananaimage", baseUrl: "https://bananaimage.aigenmedia.art" }, + { type: "visionary", baseUrl: "https://api.visionary.beer" }, { type: "stepfun-image", baseUrl: "https://api.stepfun.com" }, { type: "midjourney", baseUrl: "https://your-midjourney-api.example.com" }, { type: "fal-video", baseUrl: "https://fal.run" }, @@ -151,7 +152,7 @@ async function runMigrationStatements(db: ReturnType) { ADD CONSTRAINT "AiProvider_type_check" CHECK (type IN ( 'alibaba', 'openai', 'custom', 'local', 'gemini', - 'grsai-gpt-image', 'grsai-video', 'huanke-gemini', 'qianwen-video', 'doubao-video', 'ruxa-video', 'volcano-vision', 'volcano-video', 'alibaba-image', 'bfl-image', 'ruxa-image', 'fal-image', 'snapgen-image', 'openai-image', 'bananaimage', 'stepfun-image', 'midjourney', 'fal-video', 'zhipu', 'tripo-3d', 'worldlabs-3d', 'kittycad-cad', 'runway', 'krea' + 'grsai-gpt-image', 'grsai-video', 'huanke-gemini', 'qianwen-video', 'doubao-video', 'ruxa-video', 'volcano-vision', 'volcano-video', 'alibaba-image', 'bfl-image', 'ruxa-image', 'fal-image', 'snapgen-image', 'openai-image', 'bananaimage', 'visionary', 'stepfun-image', 'midjourney', 'fal-video', 'zhipu', 'tripo-3d', 'worldlabs-3d', 'kittycad-cad', 'runway', 'krea' )); `); await db.execute(sql` diff --git a/lib/db/migrations/0092_add_visionary_provider_type.sql b/lib/db/migrations/0092_add_visionary_provider_type.sql new file mode 100644 index 000000000..bb281d8af --- /dev/null +++ b/lib/db/migrations/0092_add_visionary_provider_type.sql @@ -0,0 +1,39 @@ +ALTER TABLE "AiProvider" + DROP CONSTRAINT IF EXISTS "AiProvider_type_check"; + +ALTER TABLE "AiProvider" + ADD CONSTRAINT "AiProvider_type_check" + CHECK ( + "type" IN ( + 'alibaba', + 'openai', + 'custom', + 'local', + 'gemini', + 'grsai-gpt-image', + 'grsai-video', + 'huanke-gemini', + 'qianwen-video', + 'doubao-video', + 'ruxa-video', + 'volcano-vision', + 'volcano-video', + 'alibaba-image', + 'bfl-image', + 'ruxa-image', + 'fal-image', + 'snapgen-image', + 'openai-image', + 'bananaimage', + 'visionary', + 'stepfun-image', + 'midjourney', + 'fal-video', + 'zhipu', + 'tripo-3d', + 'worldlabs-3d', + 'kittycad-cad', + 'runway', + 'krea' + ) + ); diff --git a/lib/db/migrations/meta/_journal.json b/lib/db/migrations/meta/_journal.json index 4b4c8c170..f4a980c81 100644 --- a/lib/db/migrations/meta/_journal.json +++ b/lib/db/migrations/meta/_journal.json @@ -547,6 +547,13 @@ "when": 1783936800000, "tag": "0091_add_canvas_capability_tasks", "breakpoints": true + }, + { + "idx": 78, + "version": "7", + "when": 1784025600000, + "tag": "0092_add_visionary_provider_type", + "breakpoints": true } ] } diff --git a/lib/db/queries.ts b/lib/db/queries.ts index 9eaccd051..ae0717a79 100644 --- a/lib/db/queries.ts +++ b/lib/db/queries.ts @@ -3762,7 +3762,7 @@ export async function createAiProvider({ updatedBy, }: { name: string; - type: "alibaba" | "openai" | "custom" | "local" | "gemini" | "grsai-gpt-image" | "grsai-video" | "huanke-gemini" | "qianwen-video" | "doubao-video" | "ruxa-video" | "volcano-vision" | "volcano-video" | "alibaba-image" | "bfl-image" | "ruxa-image" | "fal-image" | "snapgen-image" | "openai-image" | "bananaimage" | "stepfun-image" | "midjourney" | "fal-video" | "zhipu" | "tripo-3d" | "worldlabs-3d" | "kittycad-cad" | "runway" | "krea"; + type: "alibaba" | "openai" | "custom" | "local" | "gemini" | "grsai-gpt-image" | "grsai-video" | "huanke-gemini" | "qianwen-video" | "doubao-video" | "ruxa-video" | "volcano-vision" | "volcano-video" | "alibaba-image" | "bfl-image" | "ruxa-image" | "fal-image" | "snapgen-image" | "openai-image" | "bananaimage" | "visionary" | "stepfun-image" | "midjourney" | "fal-video" | "zhipu" | "tripo-3d" | "worldlabs-3d" | "kittycad-cad" | "runway" | "krea"; baseUrl: string; apiKey: string; isEnabled?: boolean; @@ -3800,7 +3800,7 @@ export async function updateAiProvider({ }: { id: string; name?: string; - type?: "alibaba" | "openai" | "custom" | "local" | "gemini" | "grsai-gpt-image" | "grsai-video" | "huanke-gemini" | "qianwen-video" | "doubao-video" | "ruxa-video" | "volcano-vision" | "volcano-video" | "alibaba-image" | "bfl-image" | "ruxa-image" | "fal-image" | "snapgen-image" | "openai-image" | "bananaimage" | "stepfun-image" | "midjourney" | "fal-video" | "zhipu" | "tripo-3d" | "worldlabs-3d" | "kittycad-cad" | "runway" | "krea"; + type?: "alibaba" | "openai" | "custom" | "local" | "gemini" | "grsai-gpt-image" | "grsai-video" | "huanke-gemini" | "qianwen-video" | "doubao-video" | "ruxa-video" | "volcano-vision" | "volcano-video" | "alibaba-image" | "bfl-image" | "ruxa-image" | "fal-image" | "snapgen-image" | "openai-image" | "bananaimage" | "visionary" | "stepfun-image" | "midjourney" | "fal-video" | "zhipu" | "tripo-3d" | "worldlabs-3d" | "kittycad-cad" | "runway" | "krea"; baseUrl?: string; apiKey?: string; isEnabled?: boolean; diff --git a/lib/db/schema.ts b/lib/db/schema.ts index 296a6cc42..a94ec9cd2 100644 --- a/lib/db/schema.ts +++ b/lib/db/schema.ts @@ -308,7 +308,7 @@ export type ToolConfig = InferSelectModel; export const aiProvider = pgTable("AiProvider", { id: uuid("id").primaryKey().notNull().defaultRandom(), name: varchar("name", { length: 128 }).notNull(), // 提供商名称,如 "阿里云通义千问" - type: varchar("type", { enum: ["alibaba", "openai", "custom", "local", "gemini", "grsai-gpt-image", "grsai-video", "huanke-gemini", "qianwen-video", "doubao-video", "ruxa-video", "volcano-vision", "volcano-video", "alibaba-image", "ruxa-image", "fal-image", "snapgen-image", "openai-image", "bananaimage", "stepfun-image", "midjourney", "fal-video", "zhipu", "tripo-3d", "worldlabs-3d", "kittycad-cad", "runway", "krea", "bfl-image"] }) + type: varchar("type", { enum: ["alibaba", "openai", "custom", "local", "gemini", "grsai-gpt-image", "grsai-video", "huanke-gemini", "qianwen-video", "doubao-video", "ruxa-video", "volcano-vision", "volcano-video", "alibaba-image", "ruxa-image", "fal-image", "snapgen-image", "openai-image", "bananaimage", "visionary", "stepfun-image", "midjourney", "fal-video", "zhipu", "tripo-3d", "worldlabs-3d", "kittycad-cad", "runway", "krea", "bfl-image"] }) .notNull() .default("custom"), // 提供商类型 baseUrl: text("baseUrl").notNull(), // API 基础 URL diff --git a/lib/external-image-api/service.ts b/lib/external-image-api/service.ts index 7cd504406..fd44b6db8 100644 --- a/lib/external-image-api/service.ts +++ b/lib/external-image-api/service.ts @@ -346,6 +346,7 @@ function supportsImageEditForReferenceCount( const providerImpliesReferenceEdit = providerType === "openai-image" || providerType === "bananaimage" || + providerType === "visionary" || providerType === "grsai-gpt-image" || providerType === "ruxa-image" || providerType === "snapgen-image"; @@ -422,6 +423,10 @@ function getExternalImageApiPriority(entry: ImageModelEntry) { return 380; } + if (isGptImage2 && providerType === "visionary") { + return 370; + } + if (isGptImage2 && providerType === "grsai-gpt-image") { return isVip ? 350 : 340; } diff --git a/lib/image-url.ts b/lib/image-url.ts index ee98fd311..2ea6e7086 100644 --- a/lib/image-url.ts +++ b/lib/image-url.ts @@ -54,7 +54,8 @@ export function isLikelyThumbnailUrl(url: string): boolean { lowerUrl.includes("stepfun-image-thumb-") || lowerUrl.includes("fal-image-thumb-") || lowerUrl.includes("snapgen-image-thumb-") || - lowerUrl.includes("bananaimage-thumb-") + lowerUrl.includes("bananaimage-thumb-") || + lowerUrl.includes("visionary-thumb-") ) { return true; } @@ -100,6 +101,9 @@ export function inferOriginalImageUrl(url: string): string { if (url.includes("bananaimage-thumb-")) { return url.replace("bananaimage-thumb-", "bananaimage-orig-"); } + if (url.includes("visionary-thumb-")) { + return url.replace("visionary-thumb-", "visionary-orig-"); + } if (url.includes("/thumb/")) { return url.replace("/thumb/", "/"); diff --git a/lib/scene-fusion/output-capabilities.ts b/lib/scene-fusion/output-capabilities.ts index f5ad17330..ab49598d3 100644 --- a/lib/scene-fusion/output-capabilities.ts +++ b/lib/scene-fusion/output-capabilities.ts @@ -78,6 +78,7 @@ export function canProcessSceneFusionProviderType(providerType: string): boolean case "snapgen-image": case "openai-image": case "bananaimage": + case "visionary": case "gemini": case "volcano-vision": return true; @@ -133,6 +134,7 @@ function getSupportedAspectRatiosByProvider( case "fal-image": case "openai-image": case "bananaimage": + case "visionary": case "gemini": default: return [...ALL_ASPECT_RATIO_VALUES];