feat: add Visionary image provider
This commit is contained in:
parent
1b70c1d678
commit
80e9771790
@ -89,6 +89,7 @@ export type AdminAiProvider = {
|
||||
| "snapgen-image"
|
||||
| "openai-image"
|
||||
| "bananaimage"
|
||||
| "visionary"
|
||||
| "stepfun-image"
|
||||
| "midjourney"
|
||||
| "fal-video"
|
||||
|
||||
@ -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<string, string> = {
|
||||
"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 输出。
|
||||
</p>
|
||||
)}
|
||||
{providerForm.type === "visionary" && (
|
||||
<p className="text-muted-foreground text-xs">
|
||||
Visionary 使用 Bearer API Key 认证,默认地址
|
||||
https://api.visionary.beer。保存后自动同步 GPT Image 2
|
||||
与 Nano Banana 全部开放模型,支持最多 9 张参考图、比例选择、
|
||||
质量档位和 1K/2K/4K 输出。
|
||||
</p>
|
||||
)}
|
||||
{providerForm.type === "bfl-image" &&
|
||||
isBltcyBflBaseUrl(providerForm.baseUrl) && (
|
||||
<p className="text-muted-foreground text-xs">
|
||||
@ -15776,6 +15839,19 @@ export function AdminSettingsClient() {
|
||||
快速添加 BananaImage 图片预设
|
||||
</Button>
|
||||
)}
|
||||
{provider.type === "visionary" && (
|
||||
<Button
|
||||
className="mt-2"
|
||||
onClick={() =>
|
||||
handleQuickAddVisionaryModels(provider.id)
|
||||
}
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
>
|
||||
<ImageIcon className="mr-1 h-4 w-4" />
|
||||
快速添加 Visionary 图片预设
|
||||
</Button>
|
||||
)}
|
||||
{provider.type === "bltcy-video" && (
|
||||
<Button
|
||||
className="mt-2"
|
||||
|
||||
@ -16,6 +16,7 @@ export const IMAGE_FAILOVER_PROVIDER_ALLOWLIST = new Set([
|
||||
"snapgen-image",
|
||||
"openai-image",
|
||||
"bananaimage",
|
||||
"visionary",
|
||||
"stepfun-image",
|
||||
"runway",
|
||||
"krea",
|
||||
@ -245,6 +246,7 @@ export function getImageFailoverConvertProvider(providerType?: string | null) {
|
||||
case "snapgen-image":
|
||||
case "openai-image":
|
||||
case "bananaimage":
|
||||
case "visionary":
|
||||
case "stepfun-image":
|
||||
case "midjourney":
|
||||
case "runway":
|
||||
|
||||
@ -525,6 +525,7 @@ export async function convertMessagesWithImages(
|
||||
normalizedProviderType === "snapgen-image" ||
|
||||
normalizedProviderType === "openai-image" ||
|
||||
normalizedProviderType === "bananaimage" ||
|
||||
normalizedProviderType === "visionary" ||
|
||||
normalizedProviderType === "stepfun-image" ||
|
||||
normalizedProviderType === "midjourney" ||
|
||||
normalizedProviderType === "runway" ||
|
||||
|
||||
@ -189,6 +189,7 @@ function normalizeImageLoadingProviderType(providerType?: string | null): string
|
||||
normalized === "snapgen-image" ||
|
||||
normalized === "openai-image" ||
|
||||
normalized === "bananaimage" ||
|
||||
normalized === "visionary" ||
|
||||
normalized === "stepfun-image" ||
|
||||
normalized === "midjourney" ||
|
||||
normalized === "runway" ||
|
||||
|
||||
@ -9130,6 +9130,7 @@ export async function POST(request: Request) {
|
||||
"snapgen-image": 5,
|
||||
"openai-image": 6,
|
||||
bananaimage: 7,
|
||||
visionary: 7,
|
||||
"stepfun-image": 7,
|
||||
"grsai-gpt-image": 8,
|
||||
runway: 9,
|
||||
@ -9522,6 +9523,7 @@ export async function POST(request: Request) {
|
||||
"snapgen-image",
|
||||
"openai-image",
|
||||
"bananaimage",
|
||||
"visionary",
|
||||
"stepfun-image",
|
||||
"midjourney",
|
||||
"runway",
|
||||
@ -9746,6 +9748,8 @@ export async function POST(request: Request) {
|
||||
? "openai-image"
|
||||
: (languageModel as any).provider === "bananaimage"
|
||||
? "bananaimage"
|
||||
: (languageModel as any).provider === "visionary"
|
||||
? "visionary"
|
||||
: (languageModel as any).provider === "stepfun-image"
|
||||
? "stepfun-image"
|
||||
: (languageModel as any).provider === "midjourney"
|
||||
@ -9850,6 +9854,7 @@ export async function POST(request: Request) {
|
||||
"snapgen-image": 5,
|
||||
"openai-image": 6,
|
||||
bananaimage: 7,
|
||||
visionary: 7,
|
||||
"stepfun-image": 7,
|
||||
runway: 8,
|
||||
krea: 9,
|
||||
@ -9895,6 +9900,8 @@ export async function POST(request: Request) {
|
||||
? "openai-image"
|
||||
: candidate.provider.type === "bananaimage"
|
||||
? "bananaimage"
|
||||
: candidate.provider.type === "visionary"
|
||||
? "visionary"
|
||||
: candidate.provider.type === "stepfun-image"
|
||||
? "stepfun-image"
|
||||
: candidate.provider.type === "midjourney"
|
||||
@ -10282,6 +10289,7 @@ export async function POST(request: Request) {
|
||||
"snapgen-image": 4,
|
||||
"openai-image": 5,
|
||||
bananaimage: 6,
|
||||
visionary: 6,
|
||||
"stepfun-image": 6,
|
||||
runway: 7,
|
||||
krea: 8,
|
||||
@ -10328,6 +10336,8 @@ export async function POST(request: Request) {
|
||||
? "openai-image"
|
||||
: candidate.provider.type === "bananaimage"
|
||||
? "bananaimage"
|
||||
: candidate.provider.type === "visionary"
|
||||
? "visionary"
|
||||
: candidate.provider.type === "stepfun-image"
|
||||
? "stepfun-image"
|
||||
: candidate.provider.type === "midjourney"
|
||||
@ -11204,6 +11214,7 @@ export async function POST(request: Request) {
|
||||
"snapgen-image": 5,
|
||||
"openai-image": 6,
|
||||
bananaimage: 7,
|
||||
visionary: 7,
|
||||
"stepfun-image": 7,
|
||||
runway: 8,
|
||||
krea: 9,
|
||||
@ -11252,6 +11263,8 @@ export async function POST(request: Request) {
|
||||
? "openai-image"
|
||||
: candidate.provider.type === "bananaimage"
|
||||
? "bananaimage"
|
||||
: candidate.provider.type === "visionary"
|
||||
? "visionary"
|
||||
: candidate.provider.type === "stepfun-image"
|
||||
? "stepfun-image"
|
||||
: candidate.provider.type === "midjourney"
|
||||
|
||||
@ -48,6 +48,7 @@ const imageParamsSchema = z.object({
|
||||
aspectRatio: z.string().optional(),
|
||||
imageSize: z.preprocess(normalizeImageSizeInput, z.enum(imageSizeValues).optional()),
|
||||
imageCount: z.number().min(1).max(10).optional(),
|
||||
quality: z.enum(["auto", "low", "medium", "high"]).optional(),
|
||||
// 阿里生图专用参数
|
||||
size: z.string().optional(), // 自定义分辨率,如 "1024*1024"
|
||||
negativePrompt: z.string().optional(), // 反向提示词
|
||||
|
||||
@ -33,6 +33,7 @@ const providerTypes = [
|
||||
"snapgen-image",
|
||||
"openai-image",
|
||||
"bananaimage",
|
||||
"visionary",
|
||||
"stepfun-image",
|
||||
"midjourney",
|
||||
"fal-video",
|
||||
@ -75,6 +76,7 @@ const RUXA_IMAGE_BASE_URL = "https://api.ruxa.ai";
|
||||
const SNAPGEN_IMAGE_BASE_URL = "https://api.snapgen.ai";
|
||||
const OPENAI_IMAGE_BASE_URL = "https://api.openai.com/v1";
|
||||
const BANANAIMAGE_BASE_URL = "https://bananaimage.aigenmedia.art";
|
||||
const VISIONARY_BASE_URL = "https://api.visionary.beer";
|
||||
const STEPFUN_IMAGE_BASE_URL = "https://api.stepfun.com";
|
||||
const BFL_IMAGE_BASE_URL = "https://api.bfl.ai";
|
||||
const MIDJOURNEY_BASE_URL = "https://your-midjourney-api.example.com";
|
||||
@ -241,6 +243,8 @@ export async function POST(request: Request) {
|
||||
? data.baseUrl || OPENAI_IMAGE_BASE_URL
|
||||
: data.type === "bananaimage"
|
||||
? data.baseUrl || BANANAIMAGE_BASE_URL
|
||||
: data.type === "visionary"
|
||||
? data.baseUrl || VISIONARY_BASE_URL
|
||||
: data.type === "stepfun-image"
|
||||
? data.baseUrl || STEPFUN_IMAGE_BASE_URL
|
||||
: data.type === "midjourney"
|
||||
|
||||
139
app/api/admin/visionary-models/route.ts
Normal file
139
app/api/admin/visionary-models/route.ts
Normal file
@ -0,0 +1,139 @@
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { NextResponse } from "next/server";
|
||||
import { VISIONARY_MODEL_PRESETS } from "@/lib/ai/visionary-models";
|
||||
import { db } from "@/lib/db";
|
||||
import { getAiProviderById } from "@/lib/db/queries";
|
||||
import { type AiModel, aiModel } from "@/lib/db/schema";
|
||||
import { requireAdmin } from "@/lib/middleware/admin";
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
await requireAdmin();
|
||||
return NextResponse.json({
|
||||
presets: VISIONARY_MODEL_PRESETS,
|
||||
counts: { total: VISIONARY_MODEL_PRESETS.length },
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error("[Visionary Models API] GET error:", error);
|
||||
return NextResponse.json(
|
||||
{ error: error.message || "Failed to fetch Visionary model presets" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
await requireAdmin();
|
||||
const body = await request.json();
|
||||
const providerId = String(body?.providerId || "").trim();
|
||||
const modelIds = Array.isArray(body?.modelIds)
|
||||
? body.modelIds
|
||||
.map((item: unknown) => String(item || "").trim())
|
||||
.filter(Boolean)
|
||||
: null;
|
||||
|
||||
if (!providerId) {
|
||||
return NextResponse.json(
|
||||
{ error: "Provider ID is required" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const provider = await getAiProviderById({ id: providerId });
|
||||
if (!provider || provider.type !== "visionary") {
|
||||
return NextResponse.json(
|
||||
{ error: "Provider must be a Visionary provider" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const presets = modelIds
|
||||
? VISIONARY_MODEL_PRESETS.filter((preset) =>
|
||||
modelIds.includes(preset.modelId)
|
||||
)
|
||||
: VISIONARY_MODEL_PRESETS;
|
||||
const addedModels: AiModel[] = [];
|
||||
const updatedModels: AiModel[] = [];
|
||||
const errors: { modelId: string; error: string }[] = [];
|
||||
|
||||
for (const preset of presets) {
|
||||
try {
|
||||
const existing = await db.query.aiModel.findFirst({
|
||||
where: and(
|
||||
eq(aiModel.providerId, providerId),
|
||||
eq(aiModel.modelId, preset.modelId)
|
||||
),
|
||||
});
|
||||
const values = {
|
||||
displayName: preset.displayName,
|
||||
description: preset.description,
|
||||
supportsVision: preset.supportsVision,
|
||||
supportsImageGen: preset.supportsImageGen,
|
||||
isEnabled: true,
|
||||
isHidden: false,
|
||||
sortOrder: preset.sortOrder,
|
||||
capabilities: preset.capabilities || null,
|
||||
defaultParams: preset.defaultParams || null,
|
||||
imageGenConfig: preset.imageGenConfig || null,
|
||||
};
|
||||
|
||||
if (existing) {
|
||||
const [updated] = await db
|
||||
.update(aiModel)
|
||||
.set(values)
|
||||
.where(
|
||||
and(
|
||||
eq(aiModel.providerId, providerId),
|
||||
eq(aiModel.modelId, preset.modelId)
|
||||
)
|
||||
)
|
||||
.returning();
|
||||
if (updated) updatedModels.push(updated);
|
||||
continue;
|
||||
}
|
||||
|
||||
const [created] = await db
|
||||
.insert(aiModel)
|
||||
.values({
|
||||
providerId,
|
||||
modelId: preset.modelId,
|
||||
...values,
|
||||
isDefault: false,
|
||||
isSubModel: false,
|
||||
parentModelId: null,
|
||||
})
|
||||
.returning();
|
||||
if (created) addedModels.push(created);
|
||||
} catch (error: any) {
|
||||
console.error(
|
||||
`[Visionary Models API] Failed to sync ${preset.modelId}:`,
|
||||
error
|
||||
);
|
||||
errors.push({
|
||||
modelId: preset.modelId,
|
||||
error: error.message || "Sync failed",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
addedCount: addedModels.length,
|
||||
updatedCount: updatedModels.length,
|
||||
addedModels,
|
||||
updatedModels,
|
||||
errors: errors.length > 0 ? errors : undefined,
|
||||
counts: {
|
||||
requested: presets.length,
|
||||
total: VISIONARY_MODEL_PRESETS.length,
|
||||
},
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error("[Visionary Models API] POST error:", error);
|
||||
return NextResponse.json(
|
||||
{ error: error.message || "Failed to sync Visionary models" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -12,6 +12,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";
|
||||
@ -53,6 +54,7 @@ const SUPPORTED_TEXT_EDIT_PROVIDER_TYPES = new Set([
|
||||
"snapgen-image",
|
||||
"openai-image",
|
||||
"bananaimage",
|
||||
"visionary",
|
||||
"stepfun-image",
|
||||
"midjourney",
|
||||
"runway",
|
||||
@ -119,6 +121,8 @@ function createTextEditProvider(
|
||||
return createOpenAIImage({ baseURL, apiKey });
|
||||
case "bananaimage":
|
||||
return createBananaImage({ baseURL, apiKey });
|
||||
case "visionary":
|
||||
return createVisionary({ baseURL, apiKey });
|
||||
case "stepfun-image":
|
||||
return createStepfunImage({ baseURL, apiKey });
|
||||
case "midjourney":
|
||||
|
||||
@ -26,6 +26,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 { callQwen3Vl } from "@/lib/ai/qwen3-vl";
|
||||
import {
|
||||
canProcessSceneFusionProviderType,
|
||||
@ -1539,6 +1540,17 @@ async function processSceneWithProvider(
|
||||
imageSize,
|
||||
);
|
||||
|
||||
case "visionary":
|
||||
return processWithVisionary(
|
||||
productImageUrl,
|
||||
sceneImageUrl,
|
||||
prompt,
|
||||
provider,
|
||||
runtimeModelId || modelId,
|
||||
aspectRatio,
|
||||
imageSize,
|
||||
);
|
||||
|
||||
case "gemini":
|
||||
case "gemini-image":
|
||||
return processWithGemini(productImageUrl, sceneImageUrl, prompt, provider, modelId, aspectRatio, imageSize);
|
||||
@ -1831,6 +1843,43 @@ async function processWithBananaImage(
|
||||
return { thumbnail, original, prompt };
|
||||
}
|
||||
|
||||
async function processWithVisionary(
|
||||
productImageUrl: string,
|
||||
sceneImageUrl: string,
|
||||
prompt: string,
|
||||
provider: { baseUrl: string; apiKey: string },
|
||||
modelId: string,
|
||||
aspectRatio?: string,
|
||||
imageSize?: string
|
||||
): Promise<{ thumbnail: string; original: string; prompt: string }> {
|
||||
logger.info("provider_processing", { providerType: "visionary", modelId });
|
||||
|
||||
const visionaryProvider = createVisionary({
|
||||
baseURL: provider.baseUrl,
|
||||
apiKey: provider.apiKey,
|
||||
});
|
||||
const result = await visionaryProvider.chat(modelId).doGenerate({
|
||||
messages: [
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{ type: "image_url", image_url: { url: productImageUrl } },
|
||||
{ type: "image_url", image_url: { url: sceneImageUrl } },
|
||||
{ type: "text", text: prompt },
|
||||
],
|
||||
},
|
||||
],
|
||||
imageParams: {
|
||||
aspectRatio: aspectRatio || "auto",
|
||||
imageSize: imageSize || "2K",
|
||||
imageCount: 1,
|
||||
},
|
||||
});
|
||||
|
||||
const { thumbnail, original } = parseImageFromMarkdown(result.text);
|
||||
return { thumbnail, original, prompt };
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用既梦/火山模型处理
|
||||
*/
|
||||
|
||||
@ -27,6 +27,7 @@ const PROVIDER_MODEL_LABEL: Record<string, string> = {
|
||||
"snapgen-image": "SnapGen 图片",
|
||||
"openai-image": "OpenAI 图片",
|
||||
bananaimage: "BananaImage 图片",
|
||||
visionary: "Visionary 图片",
|
||||
"stepfun-image": "StepFun 图片",
|
||||
midjourney: "Midjourney",
|
||||
"volcano-vision": "即梦 4.5",
|
||||
|
||||
@ -18,6 +18,7 @@ export interface ImageGenerationParams {
|
||||
aspectRatio: string;
|
||||
imageSize: string;
|
||||
imageCount?: number; // 生成图片数量
|
||||
quality?: "auto" | "low" | "medium" | "high";
|
||||
// 🆕 阿里生图专用参数
|
||||
size?: string; // 自定义分辨率,如 "1024*1024"
|
||||
negativePrompt?: string; // 反向提示词
|
||||
@ -47,6 +48,7 @@ interface ImageParamsBarProps {
|
||||
supportsNegativePrompt?: boolean;
|
||||
supportsWatermark?: boolean;
|
||||
supportsSeed?: boolean;
|
||||
supportsQuality?: boolean;
|
||||
supportedAspectRatios?: string[];
|
||||
supportedImageSizes?: string[];
|
||||
};
|
||||
@ -144,6 +146,7 @@ export function ImageParamsBar({
|
||||
"aspectRatio",
|
||||
"imageSize",
|
||||
"imageCount",
|
||||
"quality",
|
||||
"size",
|
||||
"negativePrompt",
|
||||
"promptExtend",
|
||||
@ -172,6 +175,7 @@ export function ImageParamsBar({
|
||||
if (
|
||||
providerType === "openai-image" ||
|
||||
providerType === "bananaimage" ||
|
||||
providerType === "visionary" ||
|
||||
providerType === "stepfun-image" ||
|
||||
providerType === "midjourney"
|
||||
) {
|
||||
@ -281,6 +285,7 @@ export function ImageParamsBar({
|
||||
a.aspectRatio === b.aspectRatio &&
|
||||
a.imageSize === b.imageSize &&
|
||||
(a.imageCount || 1) === (b.imageCount || 1) &&
|
||||
(a.quality || "medium") === (b.quality || "medium") &&
|
||||
a.size === b.size &&
|
||||
a.negativePrompt === b.negativePrompt &&
|
||||
a.promptExtend === b.promptExtend &&
|
||||
@ -295,6 +300,7 @@ export function ImageParamsBar({
|
||||
aspectRatio: inlineTheme === "minimal" ? "1:1" : "auto",
|
||||
imageSize: "2K",
|
||||
imageCount: 1,
|
||||
quality: "medium",
|
||||
promptExtend: true,
|
||||
watermark: false,
|
||||
...source,
|
||||
@ -308,7 +314,14 @@ export function ImageParamsBar({
|
||||
...buildBaseParams(initialParams),
|
||||
});
|
||||
const [minimalParamsOpen, setMinimalParamsOpen] = useState(false);
|
||||
const [qualityLabel, setQualityLabel] = useState<"自动" | "高" | "中" | "低">("中");
|
||||
const qualityOptions = [
|
||||
{ label: "自动", value: "auto" },
|
||||
{ label: "高", value: "high" },
|
||||
{ label: "中", value: "medium" },
|
||||
{ label: "低", value: "low" },
|
||||
] as const;
|
||||
const qualityLabel =
|
||||
qualityOptions.find((option) => option.value === params.quality)?.label || "中";
|
||||
|
||||
const imageCountOptions = getImageCountOptions();
|
||||
const showImageCountSelector = !hideImageCount && imageCountOptions.length > 1;
|
||||
@ -499,19 +512,19 @@ export function ImageParamsBar({
|
||||
<div>
|
||||
<div className="mb-1.5 text-[12px] font-medium leading-none">质量</div>
|
||||
<div className="grid h-8 grid-cols-4 rounded-[var(--ui-radius-control)] bg-[#f4f4f5] p-0.5 dark:bg-white/8">
|
||||
{(["自动", "高", "中", "低"] as const).map((quality) => (
|
||||
{qualityOptions.map((quality) => (
|
||||
<button
|
||||
key={quality}
|
||||
key={quality.value}
|
||||
type="button"
|
||||
className={cn(
|
||||
"rounded-[6px] text-[12px] font-medium transition-colors",
|
||||
qualityLabel === quality
|
||||
params.quality === quality.value
|
||||
? "border border-[#c8cdd5] bg-white shadow-[0_1px_4px_rgba(15,23,42,0.1)] dark:border-white/12 dark:bg-white/16"
|
||||
: "text-[#3d434d] hover:bg-white/55 dark:text-zinc-300 dark:hover:bg-white/10"
|
||||
)}
|
||||
onClick={() => setQualityLabel(quality)}
|
||||
onClick={() => updateParam("quality", quality.value)}
|
||||
>
|
||||
{quality}
|
||||
{quality.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@ -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, "/");
|
||||
|
||||
@ -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<AiProvider, "id" | "type" | "baseUrl
|
||||
return bananaImageProvider as any;
|
||||
}
|
||||
|
||||
case "visionary": {
|
||||
const visionaryProvider = createVisionary({
|
||||
baseURL: resolvedBaseUrl,
|
||||
apiKey: provider.apiKey,
|
||||
});
|
||||
logProviderCreated("visionary", resolvedBaseUrl);
|
||||
return visionaryProvider as any;
|
||||
}
|
||||
|
||||
case "stepfun-image": {
|
||||
const stepfunImageProvider = createStepfunImage({
|
||||
baseURL: resolvedBaseUrl,
|
||||
@ -475,6 +485,9 @@ export async function getDynamicModel(
|
||||
} else if (provider.type === "bananaimage") {
|
||||
baseModel = providerInstance.chat(resolvedModelId);
|
||||
logChatModelResult("bananaimage", baseModel);
|
||||
} else if (provider.type === "visionary") {
|
||||
baseModel = providerInstance.chat(resolvedModelId);
|
||||
logChatModelResult("visionary", baseModel);
|
||||
} else if (provider.type === "stepfun-image") {
|
||||
baseModel = providerInstance.chat(resolvedModelId);
|
||||
logChatModelResult("stepfun-image", baseModel);
|
||||
|
||||
@ -20,6 +20,7 @@ const STATIC_IMAGE_PROVIDER_TYPES = new Set([
|
||||
"grsai-gpt-image",
|
||||
"openai-image",
|
||||
"bananaimage",
|
||||
"visionary",
|
||||
"stepfun-image",
|
||||
"midjourney",
|
||||
"volcano-vision",
|
||||
|
||||
@ -61,6 +61,7 @@ const IMAGE_ONLY_PROVIDER_TYPES = new Set([
|
||||
"snapgen-image",
|
||||
"openai-image",
|
||||
"bananaimage",
|
||||
"visionary",
|
||||
"stepfun-image",
|
||||
"midjourney",
|
||||
"gemini-image",
|
||||
@ -173,7 +174,7 @@ const INTENT_MODEL_MAP: Record<
|
||||
> = {
|
||||
// 图片编辑:优先 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",
|
||||
},
|
||||
};
|
||||
|
||||
@ -18,6 +18,7 @@ const PROVIDER_DEFAULT_BASE_URL: Partial<Record<AiProvider["type"], string>> = {
|
||||
"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",
|
||||
|
||||
532
lib/ai/providers/visionary.ts
Normal file
532
lib/ai/providers/visionary.ts
Normal file
@ -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<string>();
|
||||
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<ReferenceImage> {
|
||||
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<UploadedImage> {
|
||||
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<UploadedImage> {
|
||||
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<string, unknown> = {
|
||||
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) => ``)
|
||||
.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 };
|
||||
},
|
||||
}),
|
||||
};
|
||||
}
|
||||
@ -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-");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
161
lib/ai/visionary-models.ts
Normal file
161
lib/ai/visionary-models.ts
Normal file
@ -0,0 +1,161 @@
|
||||
export interface VisionaryModelPreset {
|
||||
modelId: string;
|
||||
displayName: string;
|
||||
description: string;
|
||||
supportsVision: boolean;
|
||||
supportsImageGen: boolean;
|
||||
capabilities?: Record<string, any>;
|
||||
defaultParams?: Record<string, any>;
|
||||
imageGenConfig?: Record<string, any>;
|
||||
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()
|
||||
);
|
||||
}
|
||||
@ -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,
|
||||
|
||||
@ -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<typeof drizzle>) {
|
||||
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`
|
||||
|
||||
39
lib/db/migrations/0092_add_visionary_provider_type.sql
Normal file
39
lib/db/migrations/0092_add_visionary_provider_type.sql
Normal file
@ -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'
|
||||
)
|
||||
);
|
||||
@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -308,7 +308,7 @@ export type ToolConfig = InferSelectModel<typeof toolConfig>;
|
||||
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
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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/", "/");
|
||||
|
||||
@ -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];
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user