feat: add BananaImage image provider
This commit is contained in:
parent
5a16072a3d
commit
ede6f080a2
@ -88,6 +88,7 @@ export type AdminAiProvider = {
|
|||||||
| "fal-image"
|
| "fal-image"
|
||||||
| "snapgen-image"
|
| "snapgen-image"
|
||||||
| "openai-image"
|
| "openai-image"
|
||||||
|
| "bananaimage"
|
||||||
| "stepfun-image"
|
| "stepfun-image"
|
||||||
| "midjourney"
|
| "midjourney"
|
||||||
| "fal-video"
|
| "fal-video"
|
||||||
|
|||||||
@ -165,6 +165,7 @@ import {
|
|||||||
import { FAL_IMAGE_MODEL_PRESETS } from "@/lib/ai/fal-image-models";
|
import { FAL_IMAGE_MODEL_PRESETS } from "@/lib/ai/fal-image-models";
|
||||||
import { RUXA_IMAGE_MODEL_PRESETS } from "@/lib/ai/ruxa-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 { SNAPGEN_IMAGE_MODEL_PRESETS } from "@/lib/ai/snapgen-image-models";
|
||||||
|
import { BANANAIMAGE_MODEL_PRESETS } from "@/lib/ai/bananaimage-models";
|
||||||
import {
|
import {
|
||||||
formatImageModelCreditDiscountLabel,
|
formatImageModelCreditDiscountLabel,
|
||||||
getGenerationCreditCostFromImageGenConfig,
|
getGenerationCreditCostFromImageGenConfig,
|
||||||
@ -621,6 +622,7 @@ type AiProvider = {
|
|||||||
| "fal-image"
|
| "fal-image"
|
||||||
| "snapgen-image"
|
| "snapgen-image"
|
||||||
| "openai-image"
|
| "openai-image"
|
||||||
|
| "bananaimage"
|
||||||
| "stepfun-image"
|
| "stepfun-image"
|
||||||
| "midjourney"
|
| "midjourney"
|
||||||
| "fal-video"
|
| "fal-video"
|
||||||
@ -1579,6 +1581,7 @@ const PROVIDER_TYPES = [
|
|||||||
{ value: "fal-image", label: "FAL 图片生成/编辑" },
|
{ value: "fal-image", label: "FAL 图片生成/编辑" },
|
||||||
{ value: "snapgen-image", label: "SnapGen 生图" },
|
{ value: "snapgen-image", label: "SnapGen 生图" },
|
||||||
{ value: "openai-image", label: "OpenAI 图片兼容" },
|
{ value: "openai-image", label: "OpenAI 图片兼容" },
|
||||||
|
{ value: "bananaimage", label: "BananaImage 生图" },
|
||||||
{ value: "stepfun-image", label: "阶跃星辰 Step Image Edit" },
|
{ value: "stepfun-image", label: "阶跃星辰 Step Image Edit" },
|
||||||
{ value: "bltcy-video", label: "柏拉图统一视频" },
|
{ value: "bltcy-video", label: "柏拉图统一视频" },
|
||||||
{ value: "grsai-gpt-image", label: "Grsai GPT Image" },
|
{ value: "grsai-gpt-image", label: "Grsai GPT Image" },
|
||||||
@ -1613,6 +1616,7 @@ const PROVIDER_DEFAULT_BASE_URL: Record<string, string> = {
|
|||||||
"fal-image": "https://fal.run",
|
"fal-image": "https://fal.run",
|
||||||
"snapgen-image": "https://api.snapgen.ai",
|
"snapgen-image": "https://api.snapgen.ai",
|
||||||
"openai-image": "https://api.openai.com/v1",
|
"openai-image": "https://api.openai.com/v1",
|
||||||
|
bananaimage: "https://bananaimage.aigenmedia.art",
|
||||||
"stepfun-image": "https://api.stepfun.com",
|
"stepfun-image": "https://api.stepfun.com",
|
||||||
"bltcy-video": "https://api.bltcy.ai",
|
"bltcy-video": "https://api.bltcy.ai",
|
||||||
"grsai-gpt-image": "https://grsai.dakka.com.cn",
|
"grsai-gpt-image": "https://grsai.dakka.com.cn",
|
||||||
@ -4029,6 +4033,7 @@ export function AdminSettingsClient() {
|
|||||||
| "fal-image"
|
| "fal-image"
|
||||||
| "snapgen-image"
|
| "snapgen-image"
|
||||||
| "openai-image"
|
| "openai-image"
|
||||||
|
| "bananaimage"
|
||||||
| "stepfun-image"
|
| "stepfun-image"
|
||||||
| "midjourney"
|
| "midjourney"
|
||||||
| "fal-video"
|
| "fal-video"
|
||||||
@ -6265,6 +6270,7 @@ export function AdminSettingsClient() {
|
|||||||
const isFalImageProvider = providerForm.type === "fal-image";
|
const isFalImageProvider = providerForm.type === "fal-image";
|
||||||
const isRuxaImageProvider = providerForm.type === "ruxa-image";
|
const isRuxaImageProvider = providerForm.type === "ruxa-image";
|
||||||
const isSnapgenImageProvider = providerForm.type === "snapgen-image";
|
const isSnapgenImageProvider = providerForm.type === "snapgen-image";
|
||||||
|
const isBananaImageProvider = providerForm.type === "bananaimage";
|
||||||
const isBltcyVideoProvider = providerForm.type === "bltcy-video";
|
const isBltcyVideoProvider = providerForm.type === "bltcy-video";
|
||||||
const isLemonProvider =
|
const isLemonProvider =
|
||||||
providerForm.type === "openai" &&
|
providerForm.type === "openai" &&
|
||||||
@ -6407,6 +6413,17 @@ export function AdminSettingsClient() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isNewProvider && isBananaImageProvider && providerId) {
|
||||||
|
try {
|
||||||
|
await handleQuickAddBananaImageModels(providerId, { skipConfirm: true });
|
||||||
|
} catch (modelError) {
|
||||||
|
console.error("[Auto Add BananaImage Models] Error:", modelError);
|
||||||
|
toast.warning(
|
||||||
|
'BananaImage 预设模型添加失败,请手动点击"快速添加 BananaImage 图片预设"按钮'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (isNewProvider && isBltcyVideoProvider && providerId) {
|
if (isNewProvider && isBltcyVideoProvider && providerId) {
|
||||||
try {
|
try {
|
||||||
await handleQuickAddBltcyVideoModels(providerId, {
|
await handleQuickAddBltcyVideoModels(providerId, {
|
||||||
@ -7381,6 +7398,43 @@ export function AdminSettingsClient() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleQuickAddBananaImageModels = async (
|
||||||
|
providerId: string,
|
||||||
|
options?: { skipConfirm?: boolean }
|
||||||
|
) => {
|
||||||
|
if (
|
||||||
|
!options?.skipConfirm &&
|
||||||
|
!confirm(
|
||||||
|
`将同步 ${BANANAIMAGE_MODEL_PRESETS.length} 个 BananaImage 生图模型,支持多图参考、比例选择和 1K/2K/4K。\n\n继续吗?`
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
toast.info("正在同步 BananaImage 生图预设模型...");
|
||||||
|
const response = await fetch("/api/admin/bananaimage-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} 个 BananaImage 生图预设模型`);
|
||||||
|
mutateModels();
|
||||||
|
}
|
||||||
|
if (data.errors?.length) {
|
||||||
|
toast.warning(`${data.errors.length} 个 BananaImage 模型同步失败`);
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("[Quick Add BananaImage Models] Error:", error);
|
||||||
|
toast.error(error.message || "同步 BananaImage 生图预设模型失败");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleUseFalImageProviderPreset = () => {
|
const handleUseFalImageProviderPreset = () => {
|
||||||
setProviderForm({
|
setProviderForm({
|
||||||
id: "",
|
id: "",
|
||||||
@ -15581,6 +15635,14 @@ export function AdminSettingsClient() {
|
|||||||
https://api.snapgen.ai。保存后会自动添加文档里的全部生图模型预设。
|
https://api.snapgen.ai。保存后会自动添加文档里的全部生图模型预设。
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
{providerForm.type === "bananaimage" && (
|
||||||
|
<p className="text-muted-foreground text-xs">
|
||||||
|
BananaImage 使用 Bearer API Key 认证,默认地址
|
||||||
|
https://bananaimage.aigenmedia.art。保存后自动同步 Nano
|
||||||
|
Banana 与 GPT Image 2 全部模型,支持最多 14 张参考图、比例选择和
|
||||||
|
1K/2K/4K 输出。
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
{providerForm.type === "bfl-image" &&
|
{providerForm.type === "bfl-image" &&
|
||||||
isBltcyBflBaseUrl(providerForm.baseUrl) && (
|
isBltcyBflBaseUrl(providerForm.baseUrl) && (
|
||||||
<p className="text-muted-foreground text-xs">
|
<p className="text-muted-foreground text-xs">
|
||||||
@ -15701,6 +15763,19 @@ export function AdminSettingsClient() {
|
|||||||
快速添加 SnapGen 图片预设
|
快速添加 SnapGen 图片预设
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
{provider.type === "bananaimage" && (
|
||||||
|
<Button
|
||||||
|
className="mt-2"
|
||||||
|
onClick={() =>
|
||||||
|
handleQuickAddBananaImageModels(provider.id)
|
||||||
|
}
|
||||||
|
size="sm"
|
||||||
|
variant="secondary"
|
||||||
|
>
|
||||||
|
<ImageIcon className="mr-1 h-4 w-4" />
|
||||||
|
快速添加 BananaImage 图片预设
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
{provider.type === "bltcy-video" && (
|
{provider.type === "bltcy-video" && (
|
||||||
<Button
|
<Button
|
||||||
className="mt-2"
|
className="mt-2"
|
||||||
|
|||||||
@ -15,6 +15,7 @@ export const IMAGE_FAILOVER_PROVIDER_ALLOWLIST = new Set([
|
|||||||
"fal-image",
|
"fal-image",
|
||||||
"snapgen-image",
|
"snapgen-image",
|
||||||
"openai-image",
|
"openai-image",
|
||||||
|
"bananaimage",
|
||||||
"stepfun-image",
|
"stepfun-image",
|
||||||
"runway",
|
"runway",
|
||||||
"krea",
|
"krea",
|
||||||
@ -243,6 +244,7 @@ export function getImageFailoverConvertProvider(providerType?: string | null) {
|
|||||||
case "fal-image":
|
case "fal-image":
|
||||||
case "snapgen-image":
|
case "snapgen-image":
|
||||||
case "openai-image":
|
case "openai-image":
|
||||||
|
case "bananaimage":
|
||||||
case "stepfun-image":
|
case "stepfun-image":
|
||||||
case "midjourney":
|
case "midjourney":
|
||||||
case "runway":
|
case "runway":
|
||||||
|
|||||||
@ -524,6 +524,7 @@ export async function convertMessagesWithImages(
|
|||||||
normalizedProviderType === "fal-image" ||
|
normalizedProviderType === "fal-image" ||
|
||||||
normalizedProviderType === "snapgen-image" ||
|
normalizedProviderType === "snapgen-image" ||
|
||||||
normalizedProviderType === "openai-image" ||
|
normalizedProviderType === "openai-image" ||
|
||||||
|
normalizedProviderType === "bananaimage" ||
|
||||||
normalizedProviderType === "stepfun-image" ||
|
normalizedProviderType === "stepfun-image" ||
|
||||||
normalizedProviderType === "midjourney" ||
|
normalizedProviderType === "midjourney" ||
|
||||||
normalizedProviderType === "runway" ||
|
normalizedProviderType === "runway" ||
|
||||||
|
|||||||
@ -187,6 +187,7 @@ function normalizeImageLoadingProviderType(providerType?: string | null): string
|
|||||||
normalized === "fal-image" ||
|
normalized === "fal-image" ||
|
||||||
normalized === "snapgen-image" ||
|
normalized === "snapgen-image" ||
|
||||||
normalized === "openai-image" ||
|
normalized === "openai-image" ||
|
||||||
|
normalized === "bananaimage" ||
|
||||||
normalized === "stepfun-image" ||
|
normalized === "stepfun-image" ||
|
||||||
normalized === "midjourney" ||
|
normalized === "midjourney" ||
|
||||||
normalized === "runway" ||
|
normalized === "runway" ||
|
||||||
|
|||||||
@ -9104,6 +9104,7 @@ export async function POST(request: Request) {
|
|||||||
"fal-image": 4,
|
"fal-image": 4,
|
||||||
"snapgen-image": 5,
|
"snapgen-image": 5,
|
||||||
"openai-image": 6,
|
"openai-image": 6,
|
||||||
|
bananaimage: 7,
|
||||||
"stepfun-image": 7,
|
"stepfun-image": 7,
|
||||||
"grsai-gpt-image": 8,
|
"grsai-gpt-image": 8,
|
||||||
runway: 9,
|
runway: 9,
|
||||||
@ -9495,6 +9496,7 @@ export async function POST(request: Request) {
|
|||||||
"fal-image",
|
"fal-image",
|
||||||
"snapgen-image",
|
"snapgen-image",
|
||||||
"openai-image",
|
"openai-image",
|
||||||
|
"bananaimage",
|
||||||
"stepfun-image",
|
"stepfun-image",
|
||||||
"midjourney",
|
"midjourney",
|
||||||
"runway",
|
"runway",
|
||||||
@ -9717,6 +9719,8 @@ export async function POST(request: Request) {
|
|||||||
? "snapgen-image"
|
? "snapgen-image"
|
||||||
: (languageModel as any).provider === "openai-image"
|
: (languageModel as any).provider === "openai-image"
|
||||||
? "openai-image"
|
? "openai-image"
|
||||||
|
: (languageModel as any).provider === "bananaimage"
|
||||||
|
? "bananaimage"
|
||||||
: (languageModel as any).provider === "stepfun-image"
|
: (languageModel as any).provider === "stepfun-image"
|
||||||
? "stepfun-image"
|
? "stepfun-image"
|
||||||
: (languageModel as any).provider === "midjourney"
|
: (languageModel as any).provider === "midjourney"
|
||||||
@ -9820,6 +9824,7 @@ export async function POST(request: Request) {
|
|||||||
"fal-image": 4,
|
"fal-image": 4,
|
||||||
"snapgen-image": 5,
|
"snapgen-image": 5,
|
||||||
"openai-image": 6,
|
"openai-image": 6,
|
||||||
|
bananaimage: 7,
|
||||||
"stepfun-image": 7,
|
"stepfun-image": 7,
|
||||||
runway: 8,
|
runway: 8,
|
||||||
krea: 9,
|
krea: 9,
|
||||||
@ -9863,6 +9868,8 @@ export async function POST(request: Request) {
|
|||||||
? "snapgen-image"
|
? "snapgen-image"
|
||||||
: candidate.provider.type === "openai-image"
|
: candidate.provider.type === "openai-image"
|
||||||
? "openai-image"
|
? "openai-image"
|
||||||
|
: candidate.provider.type === "bananaimage"
|
||||||
|
? "bananaimage"
|
||||||
: candidate.provider.type === "stepfun-image"
|
: candidate.provider.type === "stepfun-image"
|
||||||
? "stepfun-image"
|
? "stepfun-image"
|
||||||
: candidate.provider.type === "midjourney"
|
: candidate.provider.type === "midjourney"
|
||||||
@ -10249,6 +10256,7 @@ export async function POST(request: Request) {
|
|||||||
"fal-image": 3,
|
"fal-image": 3,
|
||||||
"snapgen-image": 4,
|
"snapgen-image": 4,
|
||||||
"openai-image": 5,
|
"openai-image": 5,
|
||||||
|
bananaimage: 6,
|
||||||
"stepfun-image": 6,
|
"stepfun-image": 6,
|
||||||
runway: 7,
|
runway: 7,
|
||||||
krea: 8,
|
krea: 8,
|
||||||
@ -10293,6 +10301,8 @@ export async function POST(request: Request) {
|
|||||||
? "snapgen-image"
|
? "snapgen-image"
|
||||||
: candidate.provider.type === "openai-image"
|
: candidate.provider.type === "openai-image"
|
||||||
? "openai-image"
|
? "openai-image"
|
||||||
|
: candidate.provider.type === "bananaimage"
|
||||||
|
? "bananaimage"
|
||||||
: candidate.provider.type === "stepfun-image"
|
: candidate.provider.type === "stepfun-image"
|
||||||
? "stepfun-image"
|
? "stepfun-image"
|
||||||
: candidate.provider.type === "midjourney"
|
: candidate.provider.type === "midjourney"
|
||||||
@ -11122,6 +11132,7 @@ export async function POST(request: Request) {
|
|||||||
"fal-image": 4,
|
"fal-image": 4,
|
||||||
"snapgen-image": 5,
|
"snapgen-image": 5,
|
||||||
"openai-image": 6,
|
"openai-image": 6,
|
||||||
|
bananaimage: 7,
|
||||||
"stepfun-image": 7,
|
"stepfun-image": 7,
|
||||||
runway: 8,
|
runway: 8,
|
||||||
krea: 9,
|
krea: 9,
|
||||||
@ -11168,6 +11179,8 @@ export async function POST(request: Request) {
|
|||||||
? "snapgen-image"
|
? "snapgen-image"
|
||||||
: candidate.provider.type === "openai-image"
|
: candidate.provider.type === "openai-image"
|
||||||
? "openai-image"
|
? "openai-image"
|
||||||
|
: candidate.provider.type === "bananaimage"
|
||||||
|
? "bananaimage"
|
||||||
: candidate.provider.type === "stepfun-image"
|
: candidate.provider.type === "stepfun-image"
|
||||||
? "stepfun-image"
|
? "stepfun-image"
|
||||||
: candidate.provider.type === "midjourney"
|
: candidate.provider.type === "midjourney"
|
||||||
|
|||||||
@ -32,6 +32,7 @@ const providerTypes = [
|
|||||||
"fal-image",
|
"fal-image",
|
||||||
"snapgen-image",
|
"snapgen-image",
|
||||||
"openai-image",
|
"openai-image",
|
||||||
|
"bananaimage",
|
||||||
"stepfun-image",
|
"stepfun-image",
|
||||||
"midjourney",
|
"midjourney",
|
||||||
"fal-video",
|
"fal-video",
|
||||||
@ -73,6 +74,7 @@ const RUXA_VIDEO_BASE_URL = "https://api.ruxa.ai";
|
|||||||
const RUXA_IMAGE_BASE_URL = "https://api.ruxa.ai";
|
const RUXA_IMAGE_BASE_URL = "https://api.ruxa.ai";
|
||||||
const SNAPGEN_IMAGE_BASE_URL = "https://api.snapgen.ai";
|
const SNAPGEN_IMAGE_BASE_URL = "https://api.snapgen.ai";
|
||||||
const OPENAI_IMAGE_BASE_URL = "https://api.openai.com/v1";
|
const OPENAI_IMAGE_BASE_URL = "https://api.openai.com/v1";
|
||||||
|
const BANANAIMAGE_BASE_URL = "https://bananaimage.aigenmedia.art";
|
||||||
const STEPFUN_IMAGE_BASE_URL = "https://api.stepfun.com";
|
const STEPFUN_IMAGE_BASE_URL = "https://api.stepfun.com";
|
||||||
const BFL_IMAGE_BASE_URL = "https://api.bfl.ai";
|
const BFL_IMAGE_BASE_URL = "https://api.bfl.ai";
|
||||||
const MIDJOURNEY_BASE_URL = "https://your-midjourney-api.example.com";
|
const MIDJOURNEY_BASE_URL = "https://your-midjourney-api.example.com";
|
||||||
@ -237,6 +239,8 @@ export async function POST(request: Request) {
|
|||||||
? data.baseUrl || SNAPGEN_IMAGE_BASE_URL
|
? data.baseUrl || SNAPGEN_IMAGE_BASE_URL
|
||||||
: data.type === "openai-image"
|
: data.type === "openai-image"
|
||||||
? data.baseUrl || OPENAI_IMAGE_BASE_URL
|
? data.baseUrl || OPENAI_IMAGE_BASE_URL
|
||||||
|
: data.type === "bananaimage"
|
||||||
|
? data.baseUrl || BANANAIMAGE_BASE_URL
|
||||||
: data.type === "stepfun-image"
|
: data.type === "stepfun-image"
|
||||||
? data.baseUrl || STEPFUN_IMAGE_BASE_URL
|
? data.baseUrl || STEPFUN_IMAGE_BASE_URL
|
||||||
: data.type === "midjourney"
|
: data.type === "midjourney"
|
||||||
|
|||||||
128
app/api/admin/bananaimage-models/route.ts
Normal file
128
app/api/admin/bananaimage-models/route.ts
Normal file
@ -0,0 +1,128 @@
|
|||||||
|
import { and, eq } from "drizzle-orm";
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { BANANAIMAGE_MODEL_PRESETS } from "@/lib/ai/bananaimage-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: BANANAIMAGE_MODEL_PRESETS,
|
||||||
|
counts: { total: BANANAIMAGE_MODEL_PRESETS.length },
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("[BananaImage Models API] GET error:", error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: error.message || "Failed to fetch BananaImage 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 !== "bananaimage") {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Provider must be a BananaImage provider" },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const presets = modelIds
|
||||||
|
? BANANAIMAGE_MODEL_PRESETS.filter((preset) => modelIds.includes(preset.modelId))
|
||||||
|
: BANANAIMAGE_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(`[BananaImage 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: BANANAIMAGE_MODEL_PRESETS.length },
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("[BananaImage Models API] POST error:", error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: error.message || "Failed to sync BananaImage models" },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -11,6 +11,7 @@ import { resolveModelRuntimeId } from "@/lib/ai/model-runtime";
|
|||||||
import { createFalImage } from "@/lib/ai/providers/fal-image";
|
import { createFalImage } from "@/lib/ai/providers/fal-image";
|
||||||
import { createSnapgenImage } from "@/lib/ai/providers/snapgen-image";
|
import { createSnapgenImage } from "@/lib/ai/providers/snapgen-image";
|
||||||
import { createOpenAIImage } from "@/lib/ai/providers/openai-image";
|
import { createOpenAIImage } from "@/lib/ai/providers/openai-image";
|
||||||
|
import { createBananaImage } from "@/lib/ai/providers/bananaimage";
|
||||||
import { createStepfunImage } from "@/lib/ai/providers/stepfun-image";
|
import { createStepfunImage } from "@/lib/ai/providers/stepfun-image";
|
||||||
import { createMidjourney } from "@/lib/ai/providers/midjourney";
|
import { createMidjourney } from "@/lib/ai/providers/midjourney";
|
||||||
import { createRunway } from "@/lib/ai/providers/runway";
|
import { createRunway } from "@/lib/ai/providers/runway";
|
||||||
@ -51,6 +52,7 @@ const SUPPORTED_TEXT_EDIT_PROVIDER_TYPES = new Set([
|
|||||||
"fal-image",
|
"fal-image",
|
||||||
"snapgen-image",
|
"snapgen-image",
|
||||||
"openai-image",
|
"openai-image",
|
||||||
|
"bananaimage",
|
||||||
"stepfun-image",
|
"stepfun-image",
|
||||||
"midjourney",
|
"midjourney",
|
||||||
"runway",
|
"runway",
|
||||||
@ -115,6 +117,8 @@ function createTextEditProvider(
|
|||||||
return createSnapgenImage({ baseURL, apiKey });
|
return createSnapgenImage({ baseURL, apiKey });
|
||||||
case "openai-image":
|
case "openai-image":
|
||||||
return createOpenAIImage({ baseURL, apiKey });
|
return createOpenAIImage({ baseURL, apiKey });
|
||||||
|
case "bananaimage":
|
||||||
|
return createBananaImage({ baseURL, apiKey });
|
||||||
case "stepfun-image":
|
case "stepfun-image":
|
||||||
return createStepfunImage({ baseURL, apiKey });
|
return createStepfunImage({ baseURL, apiKey });
|
||||||
case "midjourney":
|
case "midjourney":
|
||||||
|
|||||||
@ -25,6 +25,7 @@ import { createRuxaImage } from "@/lib/ai/providers/ruxa-image";
|
|||||||
import { createFalImage } from "@/lib/ai/providers/fal-image";
|
import { createFalImage } from "@/lib/ai/providers/fal-image";
|
||||||
import { createSnapgenImage } from "@/lib/ai/providers/snapgen-image";
|
import { createSnapgenImage } from "@/lib/ai/providers/snapgen-image";
|
||||||
import { createOpenAIImage } from "@/lib/ai/providers/openai-image";
|
import { createOpenAIImage } from "@/lib/ai/providers/openai-image";
|
||||||
|
import { createBananaImage } from "@/lib/ai/providers/bananaimage";
|
||||||
import { callQwen3Vl } from "@/lib/ai/qwen3-vl";
|
import { callQwen3Vl } from "@/lib/ai/qwen3-vl";
|
||||||
import {
|
import {
|
||||||
canProcessSceneFusionProviderType,
|
canProcessSceneFusionProviderType,
|
||||||
@ -1527,6 +1528,17 @@ async function processSceneWithProvider(
|
|||||||
case "openai-image":
|
case "openai-image":
|
||||||
return processWithOpenAIImage(productImageUrl, sceneImageUrl, prompt, provider, modelId, aspectRatio, imageSize);
|
return processWithOpenAIImage(productImageUrl, sceneImageUrl, prompt, provider, modelId, aspectRatio, imageSize);
|
||||||
|
|
||||||
|
case "bananaimage":
|
||||||
|
return processWithBananaImage(
|
||||||
|
productImageUrl,
|
||||||
|
sceneImageUrl,
|
||||||
|
prompt,
|
||||||
|
provider,
|
||||||
|
runtimeModelId || modelId,
|
||||||
|
aspectRatio,
|
||||||
|
imageSize,
|
||||||
|
);
|
||||||
|
|
||||||
case "gemini":
|
case "gemini":
|
||||||
case "gemini-image":
|
case "gemini-image":
|
||||||
return processWithGemini(productImageUrl, sceneImageUrl, prompt, provider, modelId, aspectRatio, imageSize);
|
return processWithGemini(productImageUrl, sceneImageUrl, prompt, provider, modelId, aspectRatio, imageSize);
|
||||||
@ -1782,6 +1794,43 @@ async function processWithOpenAIImage(
|
|||||||
return { thumbnail, original, prompt };
|
return { thumbnail, original, prompt };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function processWithBananaImage(
|
||||||
|
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: "bananaimage", modelId });
|
||||||
|
|
||||||
|
const bananaImageProvider = createBananaImage({
|
||||||
|
baseURL: provider.baseUrl,
|
||||||
|
apiKey: provider.apiKey,
|
||||||
|
});
|
||||||
|
const result = await bananaImageProvider.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 };
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 使用既梦/火山模型处理
|
* 使用既梦/火山模型处理
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -26,6 +26,7 @@ const PROVIDER_MODEL_LABEL: Record<string, string> = {
|
|||||||
"fal-image": "FAL 图片",
|
"fal-image": "FAL 图片",
|
||||||
"snapgen-image": "SnapGen 图片",
|
"snapgen-image": "SnapGen 图片",
|
||||||
"openai-image": "OpenAI 图片",
|
"openai-image": "OpenAI 图片",
|
||||||
|
bananaimage: "BananaImage 图片",
|
||||||
"stepfun-image": "StepFun 图片",
|
"stepfun-image": "StepFun 图片",
|
||||||
midjourney: "Midjourney",
|
midjourney: "Midjourney",
|
||||||
"volcano-vision": "即梦 4.5",
|
"volcano-vision": "即梦 4.5",
|
||||||
|
|||||||
@ -171,6 +171,7 @@ export function ImageParamsBar({
|
|||||||
|
|
||||||
if (
|
if (
|
||||||
providerType === "openai-image" ||
|
providerType === "openai-image" ||
|
||||||
|
providerType === "bananaimage" ||
|
||||||
providerType === "stepfun-image" ||
|
providerType === "stepfun-image" ||
|
||||||
providerType === "midjourney"
|
providerType === "midjourney"
|
||||||
) {
|
) {
|
||||||
|
|||||||
@ -184,6 +184,9 @@ export function ImagePreview({
|
|||||||
} else if (src.includes("snapgen-image-thumb-")) {
|
} else if (src.includes("snapgen-image-thumb-")) {
|
||||||
originalUrl = src.replace("snapgen-image-thumb-", "snapgen-image-orig-");
|
originalUrl = src.replace("snapgen-image-thumb-", "snapgen-image-orig-");
|
||||||
console.log("[ImagePreview] Inferred original URL from snapgen-image-thumb filename");
|
console.log("[ImagePreview] Inferred original URL from snapgen-image-thumb filename");
|
||||||
|
} 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("/thumb/") || src.includes("-thumb")) {
|
} else if (src.includes("/thumb/") || src.includes("-thumb")) {
|
||||||
// 尝试从S3 URL中推断原图URL(常见格式:xxx-thumb.jpg -> xxx.jpg 或 xxx/thumb/xxx -> xxx/xxx)
|
// 尝试从S3 URL中推断原图URL(常见格式:xxx-thumb.jpg -> xxx.jpg 或 xxx/thumb/xxx -> xxx/xxx)
|
||||||
originalUrl = src.replace(/-thumb\./g, ".").replace(/\/thumb\//g, "/");
|
originalUrl = src.replace(/-thumb\./g, ".").replace(/\/thumb\//g, "/");
|
||||||
|
|||||||
184
lib/ai/bananaimage-models.ts
Normal file
184
lib/ai/bananaimage-models.ts
Normal file
@ -0,0 +1,184 @@
|
|||||||
|
export interface BananaImageModelPreset {
|
||||||
|
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 BANANAIMAGE_BASE_URL = "https://bananaimage.aigenmedia.art";
|
||||||
|
|
||||||
|
const BANANAIMAGE_STANDARD_RATIOS = [
|
||||||
|
"auto",
|
||||||
|
"1:1",
|
||||||
|
"2:3",
|
||||||
|
"3:2",
|
||||||
|
"3:4",
|
||||||
|
"4:3",
|
||||||
|
"4:5",
|
||||||
|
"5:4",
|
||||||
|
"9:16",
|
||||||
|
"16:9",
|
||||||
|
"21:9",
|
||||||
|
];
|
||||||
|
|
||||||
|
const BANANAIMAGE_EXTENDED_RATIOS = [
|
||||||
|
"auto",
|
||||||
|
"1:1",
|
||||||
|
"1:4",
|
||||||
|
"1:8",
|
||||||
|
"2:3",
|
||||||
|
"3:2",
|
||||||
|
"3:4",
|
||||||
|
"4:1",
|
||||||
|
"4:3",
|
||||||
|
"4:5",
|
||||||
|
"5:4",
|
||||||
|
"8:1",
|
||||||
|
"9:16",
|
||||||
|
"16:9",
|
||||||
|
"21:9",
|
||||||
|
];
|
||||||
|
|
||||||
|
const BANANAIMAGE_GPT_IMAGE_2_RATIOS = [
|
||||||
|
"auto",
|
||||||
|
"1:1",
|
||||||
|
"4:5",
|
||||||
|
"5:4",
|
||||||
|
"3:4",
|
||||||
|
"4:3",
|
||||||
|
"2:3",
|
||||||
|
"3:2",
|
||||||
|
"9:16",
|
||||||
|
"16:9",
|
||||||
|
"1:2",
|
||||||
|
"2:1",
|
||||||
|
"9:21",
|
||||||
|
"21:9",
|
||||||
|
];
|
||||||
|
|
||||||
|
const BANANAIMAGE_IMAGE_SIZES = ["1K", "2K", "4K"];
|
||||||
|
|
||||||
|
const BANANAIMAGE_IMAGE_CONFIG = {
|
||||||
|
maxImages: 1,
|
||||||
|
supportsSize: true,
|
||||||
|
supports1K: true,
|
||||||
|
supports2K: true,
|
||||||
|
supports4K: true,
|
||||||
|
supportsPromptExtend: false,
|
||||||
|
supportsNegativePrompt: false,
|
||||||
|
supportsWatermark: false,
|
||||||
|
supportsSeed: false,
|
||||||
|
maxReferenceImages: 14,
|
||||||
|
supportedImageSizes: BANANAIMAGE_IMAGE_SIZES,
|
||||||
|
enableLoadingPlaceholder: true,
|
||||||
|
loadingColor: "yellow",
|
||||||
|
};
|
||||||
|
|
||||||
|
function buildBananaImagePreset(params: {
|
||||||
|
modelId: string;
|
||||||
|
displayName: string;
|
||||||
|
description: string;
|
||||||
|
apiFormat: "bananaimage-gemini" | "bananaimage-gpt-image-2";
|
||||||
|
supportedAspectRatios: string[];
|
||||||
|
sortOrder: number;
|
||||||
|
}) {
|
||||||
|
return {
|
||||||
|
modelId: params.modelId,
|
||||||
|
displayName: params.displayName,
|
||||||
|
description: params.description,
|
||||||
|
supportsVision: true,
|
||||||
|
supportsImageGen: true,
|
||||||
|
capabilities: {
|
||||||
|
provider: "bananaimage",
|
||||||
|
apiFormat: params.apiFormat,
|
||||||
|
supportedModes: ["text-to-image", "image-to-image", "reference-images"],
|
||||||
|
supportedAspectRatios: params.supportedAspectRatios,
|
||||||
|
supportedImageSizes: BANANAIMAGE_IMAGE_SIZES,
|
||||||
|
supportsMultiImageReference: true,
|
||||||
|
maxReferenceImages: 14,
|
||||||
|
fourKAllowedRatios:
|
||||||
|
params.apiFormat === "bananaimage-gpt-image-2"
|
||||||
|
? BANANAIMAGE_GPT_IMAGE_2_RATIOS.filter((ratio) => ratio !== "auto")
|
||||||
|
: undefined,
|
||||||
|
},
|
||||||
|
defaultParams: {
|
||||||
|
provider: "bananaimage",
|
||||||
|
apiFormat: params.apiFormat,
|
||||||
|
endpoint: params.modelId,
|
||||||
|
imageSize: "2K",
|
||||||
|
aspectRatio: "auto",
|
||||||
|
supportedModes: ["text-to-image", "image-to-image", "reference-images"],
|
||||||
|
},
|
||||||
|
imageGenConfig: {
|
||||||
|
...BANANAIMAGE_IMAGE_CONFIG,
|
||||||
|
apiFormat: params.apiFormat,
|
||||||
|
supportedAspectRatios: params.supportedAspectRatios,
|
||||||
|
},
|
||||||
|
sortOrder: params.sortOrder,
|
||||||
|
} satisfies BananaImageModelPreset;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const BANANAIMAGE_MODEL_PRESETS: BananaImageModelPreset[] = [
|
||||||
|
buildBananaImagePreset({
|
||||||
|
modelId: "gemini-3-pro-image-preview",
|
||||||
|
displayName: "BananaImage Nano Banana Pro",
|
||||||
|
description: "BananaImage Nano Banana Pro。支持文生图、图片编辑、多图参考、比例选择和 1K/2K/4K 输出。",
|
||||||
|
apiFormat: "bananaimage-gemini",
|
||||||
|
supportedAspectRatios: BANANAIMAGE_STANDARD_RATIOS,
|
||||||
|
sortOrder: 10,
|
||||||
|
}),
|
||||||
|
buildBananaImagePreset({
|
||||||
|
modelId: "gemini-3.1-flash-image-preview",
|
||||||
|
displayName: "BananaImage Nano Banana 2",
|
||||||
|
description: "BananaImage Nano Banana 2。支持文生图、图片编辑、多图参考、极端比例和 1K/2K/4K 输出。",
|
||||||
|
apiFormat: "bananaimage-gemini",
|
||||||
|
supportedAspectRatios: BANANAIMAGE_EXTENDED_RATIOS,
|
||||||
|
sortOrder: 20,
|
||||||
|
}),
|
||||||
|
buildBananaImagePreset({
|
||||||
|
modelId: "gemini-3-pro-image-preview-plus",
|
||||||
|
displayName: "BananaImage Nano Banana Pro+",
|
||||||
|
description: "BananaImage Nano Banana Pro+ 放宽限制版。支持文生图、图片编辑、多图参考、比例选择和 1K/2K/4K 输出。",
|
||||||
|
apiFormat: "bananaimage-gemini",
|
||||||
|
supportedAspectRatios: BANANAIMAGE_STANDARD_RATIOS,
|
||||||
|
sortOrder: 30,
|
||||||
|
}),
|
||||||
|
buildBananaImagePreset({
|
||||||
|
modelId: "gemini-3.1-flash-image-preview-plus",
|
||||||
|
displayName: "BananaImage Nano Banana 2+",
|
||||||
|
description: "BananaImage Nano Banana 2+ 放宽限制版。支持文生图、图片编辑、多图参考、极端比例和 1K/2K/4K 输出。",
|
||||||
|
apiFormat: "bananaimage-gemini",
|
||||||
|
supportedAspectRatios: BANANAIMAGE_EXTENDED_RATIOS,
|
||||||
|
sortOrder: 40,
|
||||||
|
}),
|
||||||
|
buildBananaImagePreset({
|
||||||
|
modelId: "gpt-image-2",
|
||||||
|
displayName: "BananaImage GPT Image 2",
|
||||||
|
description: "BananaImage GPT Image 2。支持文生图、图片编辑、最多 14 张参考图、比例选择和 1K/2K/4K 输出。",
|
||||||
|
apiFormat: "bananaimage-gpt-image-2",
|
||||||
|
supportedAspectRatios: BANANAIMAGE_GPT_IMAGE_2_RATIOS,
|
||||||
|
sortOrder: 50,
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
|
||||||
|
export function getBananaImageModelPresetById(modelId: string) {
|
||||||
|
return BANANAIMAGE_MODEL_PRESETS.find(
|
||||||
|
(preset) => preset.modelId === String(modelId || "").trim()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveBananaImageRuntimeModelId(modelId: string) {
|
||||||
|
const preset = getBananaImageModelPresetById(modelId);
|
||||||
|
const endpoint = preset?.defaultParams?.endpoint;
|
||||||
|
|
||||||
|
if (typeof endpoint === "string" && endpoint.trim()) {
|
||||||
|
return endpoint.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
return modelId;
|
||||||
|
}
|
||||||
@ -24,6 +24,7 @@ import { createRuxaImage } from "./providers/ruxa-image";
|
|||||||
import { createFalImage } from "./providers/fal-image";
|
import { createFalImage } from "./providers/fal-image";
|
||||||
import { createSnapgenImage } from "./providers/snapgen-image";
|
import { createSnapgenImage } from "./providers/snapgen-image";
|
||||||
import { createOpenAIImage } from "./providers/openai-image";
|
import { createOpenAIImage } from "./providers/openai-image";
|
||||||
|
import { createBananaImage } from "./providers/bananaimage";
|
||||||
import { createStepfunImage } from "./providers/stepfun-image";
|
import { createStepfunImage } from "./providers/stepfun-image";
|
||||||
import { createMidjourney } from "./providers/midjourney";
|
import { createMidjourney } from "./providers/midjourney";
|
||||||
import { createFalVideo } from "./providers/fal-video";
|
import { createFalVideo } from "./providers/fal-video";
|
||||||
@ -294,6 +295,15 @@ function getOrCreateProvider(provider: Pick<AiProvider, "id" | "type" | "baseUrl
|
|||||||
return openAIImageProvider as any;
|
return openAIImageProvider as any;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
case "bananaimage": {
|
||||||
|
const bananaImageProvider = createBananaImage({
|
||||||
|
baseURL: resolvedBaseUrl,
|
||||||
|
apiKey: provider.apiKey,
|
||||||
|
});
|
||||||
|
logProviderCreated("bananaimage", resolvedBaseUrl);
|
||||||
|
return bananaImageProvider as any;
|
||||||
|
}
|
||||||
|
|
||||||
case "stepfun-image": {
|
case "stepfun-image": {
|
||||||
const stepfunImageProvider = createStepfunImage({
|
const stepfunImageProvider = createStepfunImage({
|
||||||
baseURL: resolvedBaseUrl,
|
baseURL: resolvedBaseUrl,
|
||||||
@ -462,6 +472,9 @@ export async function getDynamicModel(
|
|||||||
// OpenAI Images 兼容图片生成/编辑使用 chat() 方法
|
// OpenAI Images 兼容图片生成/编辑使用 chat() 方法
|
||||||
baseModel = providerInstance.chat(resolvedModelId);
|
baseModel = providerInstance.chat(resolvedModelId);
|
||||||
logChatModelResult("openai-image", baseModel);
|
logChatModelResult("openai-image", baseModel);
|
||||||
|
} else if (provider.type === "bananaimage") {
|
||||||
|
baseModel = providerInstance.chat(resolvedModelId);
|
||||||
|
logChatModelResult("bananaimage", baseModel);
|
||||||
} else if (provider.type === "stepfun-image") {
|
} else if (provider.type === "stepfun-image") {
|
||||||
baseModel = providerInstance.chat(resolvedModelId);
|
baseModel = providerInstance.chat(resolvedModelId);
|
||||||
logChatModelResult("stepfun-image", baseModel);
|
logChatModelResult("stepfun-image", baseModel);
|
||||||
|
|||||||
@ -19,6 +19,7 @@ const STATIC_IMAGE_PROVIDER_TYPES = new Set([
|
|||||||
"snapgen-image",
|
"snapgen-image",
|
||||||
"grsai-gpt-image",
|
"grsai-gpt-image",
|
||||||
"openai-image",
|
"openai-image",
|
||||||
|
"bananaimage",
|
||||||
"stepfun-image",
|
"stepfun-image",
|
||||||
"midjourney",
|
"midjourney",
|
||||||
"volcano-vision",
|
"volcano-vision",
|
||||||
|
|||||||
@ -60,6 +60,7 @@ const IMAGE_ONLY_PROVIDER_TYPES = new Set([
|
|||||||
"fal-image",
|
"fal-image",
|
||||||
"snapgen-image",
|
"snapgen-image",
|
||||||
"openai-image",
|
"openai-image",
|
||||||
|
"bananaimage",
|
||||||
"stepfun-image",
|
"stepfun-image",
|
||||||
"midjourney",
|
"midjourney",
|
||||||
"gemini-image",
|
"gemini-image",
|
||||||
@ -172,7 +173,7 @@ const INTENT_MODEL_MAP: Record<
|
|||||||
> = {
|
> = {
|
||||||
// 图片编辑:优先 alibaba-image(灵魂4.5,支持提示词扩展)
|
// 图片编辑:优先 alibaba-image(灵魂4.5,支持提示词扩展)
|
||||||
image_edit: {
|
image_edit: {
|
||||||
providerTypes: ["alibaba-image", "bfl-image", "ruxa-image", "fal-image", "snapgen-image", "openai-image", "stepfun-image", "runway", "krea", "gemini", "huanke-gemini"],
|
providerTypes: ["alibaba-image", "bfl-image", "ruxa-image", "fal-image", "snapgen-image", "openai-image", "bananaimage", "stepfun-image", "runway", "krea", "gemini", "huanke-gemini"],
|
||||||
requireVision: true,
|
requireVision: true,
|
||||||
requireImageGen: true,
|
requireImageGen: true,
|
||||||
description: "图片编辑",
|
description: "图片编辑",
|
||||||
@ -187,7 +188,7 @@ const INTENT_MODEL_MAP: Record<
|
|||||||
|
|
||||||
// 图片生成:优先灵魂4.5
|
// 图片生成:优先灵魂4.5
|
||||||
image_gen: {
|
image_gen: {
|
||||||
providerTypes: ["alibaba-image", "bfl-image", "ruxa-image", "fal-image", "snapgen-image", "openai-image", "stepfun-image", "runway", "krea", "gemini", "huanke-gemini", "volcano-vision"],
|
providerTypes: ["alibaba-image", "bfl-image", "ruxa-image", "fal-image", "snapgen-image", "openai-image", "bananaimage", "stepfun-image", "runway", "krea", "gemini", "huanke-gemini", "volcano-vision"],
|
||||||
requireImageGen: true,
|
requireImageGen: true,
|
||||||
description: "图片生成",
|
description: "图片生成",
|
||||||
},
|
},
|
||||||
@ -215,25 +216,25 @@ const SUB_INTENT_OVERRIDES: Record<
|
|||||||
> = {
|
> = {
|
||||||
// 抠图:alibaba-image 效果最好
|
// 抠图:alibaba-image 效果最好
|
||||||
remove_background: {
|
remove_background: {
|
||||||
providerTypes: ["alibaba-image", "bfl-image", "ruxa-image", "fal-image", "snapgen-image", "openai-image", "stepfun-image", "runway", "krea", "gemini"],
|
providerTypes: ["alibaba-image", "bfl-image", "ruxa-image", "fal-image", "snapgen-image", "openai-image", "bananaimage", "stepfun-image", "runway", "krea", "gemini"],
|
||||||
reason: "抠图首选灵魂4.5",
|
reason: "抠图首选灵魂4.5",
|
||||||
},
|
},
|
||||||
|
|
||||||
// 换背景:alibaba-image 支持场景生成
|
// 换背景:alibaba-image 支持场景生成
|
||||||
replace_background: {
|
replace_background: {
|
||||||
providerTypes: ["alibaba-image", "bfl-image", "ruxa-image", "fal-image", "snapgen-image", "openai-image", "stepfun-image", "runway", "krea", "gemini"],
|
providerTypes: ["alibaba-image", "bfl-image", "ruxa-image", "fal-image", "snapgen-image", "openai-image", "bananaimage", "stepfun-image", "runway", "krea", "gemini"],
|
||||||
reason: "换背景首选灵魂4.5",
|
reason: "换背景首选灵魂4.5",
|
||||||
},
|
},
|
||||||
|
|
||||||
// 图片增强
|
// 图片增强
|
||||||
enhance: {
|
enhance: {
|
||||||
providerTypes: ["alibaba-image", "bfl-image", "ruxa-image", "fal-image", "snapgen-image", "openai-image", "stepfun-image", "runway", "krea", "gemini", "volcano-vision"],
|
providerTypes: ["alibaba-image", "bfl-image", "ruxa-image", "fal-image", "snapgen-image", "openai-image", "bananaimage", "stepfun-image", "runway", "krea", "gemini", "volcano-vision"],
|
||||||
reason: "增强首选灵魂4.5",
|
reason: "增强首选灵魂4.5",
|
||||||
},
|
},
|
||||||
|
|
||||||
// 风格迁移:gemini 可能更有创意
|
// 风格迁移:gemini 可能更有创意
|
||||||
style_transfer: {
|
style_transfer: {
|
||||||
providerTypes: ["gemini", "alibaba-image", "bfl-image", "ruxa-image", "fal-image", "snapgen-image", "openai-image", "stepfun-image", "runway", "krea"],
|
providerTypes: ["gemini", "alibaba-image", "bfl-image", "ruxa-image", "fal-image", "snapgen-image", "openai-image", "bananaimage", "stepfun-image", "runway", "krea"],
|
||||||
reason: "风格迁移优选Gemini",
|
reason: "风格迁移优选Gemini",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@ -17,6 +17,7 @@ const PROVIDER_DEFAULT_BASE_URL: Partial<Record<AiProvider["type"], string>> = {
|
|||||||
"fal-image": "https://fal.run",
|
"fal-image": "https://fal.run",
|
||||||
"snapgen-image": "https://api.snapgen.ai",
|
"snapgen-image": "https://api.snapgen.ai",
|
||||||
"openai-image": "https://api.openai.com/v1",
|
"openai-image": "https://api.openai.com/v1",
|
||||||
|
bananaimage: "https://bananaimage.aigenmedia.art",
|
||||||
"stepfun-image": "https://api.stepfun.com",
|
"stepfun-image": "https://api.stepfun.com",
|
||||||
"bfl-image": "https://api.bfl.ai",
|
"bfl-image": "https://api.bfl.ai",
|
||||||
midjourney: "https://your-midjourney-api.example.com",
|
midjourney: "https://your-midjourney-api.example.com",
|
||||||
|
|||||||
664
lib/ai/providers/bananaimage.ts
Normal file
664
lib/ai/providers/bananaimage.ts
Normal file
@ -0,0 +1,664 @@
|
|||||||
|
import "server-only";
|
||||||
|
|
||||||
|
import sharp from "sharp";
|
||||||
|
import {
|
||||||
|
getBananaImageModelPresetById,
|
||||||
|
resolveBananaImageRuntimeModelId,
|
||||||
|
} from "@/lib/ai/bananaimage-models";
|
||||||
|
import { createDeferredUploadedImage } from "@/lib/storage/image-archive";
|
||||||
|
import { uploadFile } from "@/lib/storage/service";
|
||||||
|
|
||||||
|
interface BananaImageConfig {
|
||||||
|
apiKey: string;
|
||||||
|
baseURL: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BananaImageParams {
|
||||||
|
aspectRatio?: string;
|
||||||
|
imageSize?: string;
|
||||||
|
imageCount?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BananaImageModelConfig {
|
||||||
|
apiFormat?: "bananaimage-gemini" | "bananaimage-gpt-image-2";
|
||||||
|
maxReferenceImages?: number;
|
||||||
|
supportedAspectRatios?: string[];
|
||||||
|
supportedImageSizes?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ReferenceImage {
|
||||||
|
base64: string;
|
||||||
|
dataUrl: string;
|
||||||
|
mimeType: string;
|
||||||
|
width?: number;
|
||||||
|
height?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UploadedImage {
|
||||||
|
thumbnail: string;
|
||||||
|
original: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const GEMINI_POLL_INTERVAL_MS = 3000;
|
||||||
|
const GEMINI_POLL_MAX_ATTEMPTS = 600;
|
||||||
|
const GPT_POLL_INTERVAL_MS = 4000;
|
||||||
|
const GPT_POLL_MAX_ATTEMPTS = 90;
|
||||||
|
const MAX_TRANSIENT_POLL_FAILURES = 8;
|
||||||
|
const REFERENCE_DOWNLOAD_TIMEOUT_MS = 120_000;
|
||||||
|
|
||||||
|
function joinApiPath(baseURL: string, path: string) {
|
||||||
|
const cleanBase = baseURL.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("BananaImage 模型 ID 未配置");
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeAspectRatioLabel(value?: string) {
|
||||||
|
const normalized = String(value || "").trim().toLowerCase();
|
||||||
|
if (!normalized) return "auto";
|
||||||
|
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 parseAspectRatio(value: string) {
|
||||||
|
const match = value.match(/^(\d+):(\d+)$/);
|
||||||
|
if (!match) return null;
|
||||||
|
const width = Number(match[1]);
|
||||||
|
const height = Number(match[2]);
|
||||||
|
if (!width || !height) return null;
|
||||||
|
return width / height;
|
||||||
|
}
|
||||||
|
|
||||||
|
function findClosestAspectRatio(
|
||||||
|
width: number,
|
||||||
|
height: number,
|
||||||
|
supportedAspectRatios: string[]
|
||||||
|
) {
|
||||||
|
const target = width / height;
|
||||||
|
let closest = supportedAspectRatios.includes("1:1")
|
||||||
|
? "1:1"
|
||||||
|
: supportedAspectRatios[0] || "1:1";
|
||||||
|
let score = Number.POSITIVE_INFINITY;
|
||||||
|
|
||||||
|
for (const ratio of supportedAspectRatios) {
|
||||||
|
const value = parseAspectRatio(ratio);
|
||||||
|
if (!value) continue;
|
||||||
|
const candidateScore = Math.abs(Math.log(target / value));
|
||||||
|
if (candidateScore < score) {
|
||||||
|
closest = ratio;
|
||||||
|
score = candidateScore;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return closest;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSupportedAspectRatios(
|
||||||
|
modelId: string,
|
||||||
|
modelConfig?: BananaImageModelConfig | null
|
||||||
|
) {
|
||||||
|
const configured = modelConfig?.supportedAspectRatios;
|
||||||
|
const preset = getBananaImageModelPresetById(modelId);
|
||||||
|
const ratios = Array.isArray(configured)
|
||||||
|
? configured
|
||||||
|
: (preset?.imageGenConfig?.supportedAspectRatios as string[] | undefined);
|
||||||
|
|
||||||
|
const normalized = (ratios || [])
|
||||||
|
.map((ratio) => normalizeAspectRatioLabel(ratio))
|
||||||
|
.filter((ratio) => ratio !== "auto" && parseAspectRatio(ratio) !== null);
|
||||||
|
|
||||||
|
return Array.from(new Set(normalized.length > 0 ? normalized : ["1:1"]));
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveAspectRatio({
|
||||||
|
requested,
|
||||||
|
referenceImages,
|
||||||
|
supportedAspectRatios,
|
||||||
|
}: {
|
||||||
|
requested?: string;
|
||||||
|
referenceImages: ReferenceImage[];
|
||||||
|
supportedAspectRatios: string[];
|
||||||
|
}) {
|
||||||
|
const normalized = normalizeAspectRatioLabel(requested);
|
||||||
|
if (normalized !== "auto" && supportedAspectRatios.includes(normalized)) {
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
const firstReference = referenceImages[0];
|
||||||
|
if (firstReference?.width && firstReference?.height) {
|
||||||
|
return findClosestAspectRatio(
|
||||||
|
firstReference.width,
|
||||||
|
firstReference.height,
|
||||||
|
supportedAspectRatios
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return supportedAspectRatios.includes("1:1")
|
||||||
|
? "1:1"
|
||||||
|
: supportedAspectRatios[0] || "1:1";
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeImageSize(
|
||||||
|
requested?: string,
|
||||||
|
modelConfig?: BananaImageModelConfig | null
|
||||||
|
) {
|
||||||
|
const supported = (modelConfig?.supportedImageSizes || ["1K", "2K", "4K"])
|
||||||
|
.map((size) => String(size).trim().toUpperCase())
|
||||||
|
.filter((size) => ["1K", "2K", "4K"].includes(size));
|
||||||
|
const normalized = String(requested || "2K").trim().toUpperCase();
|
||||||
|
if (supported.includes(normalized)) return normalized;
|
||||||
|
return supported.includes("2K") ? "2K" : supported[0] || "2K";
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveApiFormat(
|
||||||
|
modelId: string,
|
||||||
|
modelConfig?: BananaImageModelConfig | null
|
||||||
|
) {
|
||||||
|
if (modelConfig?.apiFormat) return modelConfig.apiFormat;
|
||||||
|
const preset = getBananaImageModelPresetById(modelId);
|
||||||
|
const configured = preset?.imageGenConfig?.apiFormat;
|
||||||
|
if (
|
||||||
|
configured === "bananaimage-gemini" ||
|
||||||
|
configured === "bananaimage-gpt-image-2"
|
||||||
|
) {
|
||||||
|
return configured;
|
||||||
|
}
|
||||||
|
return modelId === "gpt-image-2"
|
||||||
|
? "bananaimage-gpt-image-2"
|
||||||
|
: "bananaimage-gemini";
|
||||||
|
}
|
||||||
|
|
||||||
|
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:([^;,]+);base64,(.+)$/s);
|
||||||
|
if (!match) throw new Error("参考图 Base64 格式无效");
|
||||||
|
const mimeType = match[1] || "image/png";
|
||||||
|
const base64 = match[2] || "";
|
||||||
|
return { mimeType, base64, buffer: Buffer.from(base64, "base64") };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadReferenceImage(imageUrl: string): Promise<ReferenceImage> {
|
||||||
|
let mimeType = "image/png";
|
||||||
|
let base64 = "";
|
||||||
|
let buffer: Buffer;
|
||||||
|
|
||||||
|
if (imageUrl.startsWith("data:")) {
|
||||||
|
const parsed = parseDataUrl(imageUrl);
|
||||||
|
mimeType = parsed.mimeType;
|
||||||
|
base64 = parsed.base64;
|
||||||
|
buffer = parsed.buffer;
|
||||||
|
} else {
|
||||||
|
const response = await fetch(imageUrl, {
|
||||||
|
cache: "no-store",
|
||||||
|
redirect: "follow",
|
||||||
|
signal: AbortSignal.timeout(REFERENCE_DOWNLOAD_TIMEOUT_MS),
|
||||||
|
headers: {
|
||||||
|
Accept: "image/avif,image/webp,image/png,image/jpeg,image/*,*/*;q=0.8",
|
||||||
|
"User-Agent": "yedir-ai-bananaimage/1.0",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`BananaImage 参考图下载失败(HTTP ${response.status})`);
|
||||||
|
}
|
||||||
|
mimeType = response.headers.get("content-type")?.split(";")[0] || "image/png";
|
||||||
|
buffer = Buffer.from(await response.arrayBuffer());
|
||||||
|
base64 = buffer.toString("base64");
|
||||||
|
}
|
||||||
|
|
||||||
|
const metadata = await sharp(buffer, { failOn: "none" }).metadata().catch(() => null);
|
||||||
|
return {
|
||||||
|
base64,
|
||||||
|
dataUrl: `data:${mimeType};base64,${base64}`,
|
||||||
|
mimeType,
|
||||||
|
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 = await uploadFile(
|
||||||
|
generateFilename("bananaimage-orig", extension),
|
||||||
|
toArrayBuffer(buffer)
|
||||||
|
);
|
||||||
|
const thumbnail = await uploadFile(
|
||||||
|
generateFilename("bananaimage-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: "bananaimage",
|
||||||
|
});
|
||||||
|
if (deferred) return deferred;
|
||||||
|
|
||||||
|
const response = await fetch(imageUrl, {
|
||||||
|
cache: "no-store",
|
||||||
|
redirect: "follow",
|
||||||
|
signal: AbortSignal.timeout(REFERENCE_DOWNLOAD_TIMEOUT_MS),
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`BananaImage 结果图片下载失败(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 = "BananaImage 请求失败") {
|
||||||
|
if (typeof payload === "string" && payload.trim()) return payload.trim();
|
||||||
|
return (
|
||||||
|
payload?.error?.message ||
|
||||||
|
payload?.error ||
|
||||||
|
payload?.message ||
|
||||||
|
payload?.detail ||
|
||||||
|
fallback
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractTaskId(payload: any) {
|
||||||
|
const value =
|
||||||
|
payload?.task_id ||
|
||||||
|
payload?.taskId ||
|
||||||
|
payload?.id ||
|
||||||
|
payload?.data?.task_id ||
|
||||||
|
payload?.data?.taskId ||
|
||||||
|
payload?.data?.id;
|
||||||
|
return typeof value === "string" && value.trim() ? value.trim() : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractGeminiImages(payload: any) {
|
||||||
|
const parts = payload?.candidates?.[0]?.content?.parts;
|
||||||
|
if (!Array.isArray(parts)) return [];
|
||||||
|
const images: string[] = [];
|
||||||
|
for (const part of parts) {
|
||||||
|
if (part?.inlineData?.data) {
|
||||||
|
images.push(
|
||||||
|
`data:${part.inlineData.mimeType || "image/png"};base64,${part.inlineData.data}`
|
||||||
|
);
|
||||||
|
} else if (part?.fileData?.fileUri) {
|
||||||
|
images.push(String(part.fileData.fileUri));
|
||||||
|
} else if (/^https?:\/\/\S+$/i.test(String(part?.text || "").trim())) {
|
||||||
|
images.push(String(part.text).trim());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Array.from(new Set(images));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitGeminiTask({
|
||||||
|
baseURL,
|
||||||
|
apiKey,
|
||||||
|
model,
|
||||||
|
prompt,
|
||||||
|
referenceImages,
|
||||||
|
aspectRatio,
|
||||||
|
imageSize,
|
||||||
|
}: {
|
||||||
|
baseURL: string;
|
||||||
|
apiKey: string;
|
||||||
|
model: string;
|
||||||
|
prompt: string;
|
||||||
|
referenceImages: ReferenceImage[];
|
||||||
|
aspectRatio: string;
|
||||||
|
imageSize: string;
|
||||||
|
}) {
|
||||||
|
const response = await fetch(
|
||||||
|
joinApiPath(baseURL, "/api/gemini/v1beta/asyncGenerateContent"),
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${apiKey}`,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"X-Client-Source": "web",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
contents: [
|
||||||
|
{
|
||||||
|
role: "user",
|
||||||
|
parts: [
|
||||||
|
{ text: prompt },
|
||||||
|
...referenceImages.map((image) => ({
|
||||||
|
inlineData: { mimeType: image.mimeType, data: image.base64 },
|
||||||
|
})),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
generationConfig: {
|
||||||
|
responseModalities: ["IMAGE"],
|
||||||
|
imageConfig: { aspectRatio, imageSize },
|
||||||
|
},
|
||||||
|
enableFallback: true,
|
||||||
|
model,
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
const payload = await parseJsonResponse(response);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`${extractErrorMessage(payload)}(HTTP ${response.status})`);
|
||||||
|
}
|
||||||
|
const taskId = extractTaskId(payload);
|
||||||
|
if (!taskId) throw new Error("BananaImage 未返回 task_id");
|
||||||
|
return taskId;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitForGeminiTask(baseURL: string, apiKey: string, taskId: string) {
|
||||||
|
let transientFailures = 0;
|
||||||
|
for (let attempt = 0; attempt < GEMINI_POLL_MAX_ATTEMPTS; attempt += 1) {
|
||||||
|
await delay(GEMINI_POLL_INTERVAL_MS);
|
||||||
|
try {
|
||||||
|
const response = await fetch(
|
||||||
|
joinApiPath(baseURL, `/api/gemini/v1beta/tasks/${encodeURIComponent(taskId)}`),
|
||||||
|
{ headers: { Authorization: `Bearer ${apiKey}` }, cache: "no-store" }
|
||||||
|
);
|
||||||
|
if (!response.ok) {
|
||||||
|
if (response.status >= 500) throw new Error(`HTTP ${response.status}`);
|
||||||
|
const payload = await parseJsonResponse(response);
|
||||||
|
throw new Error(extractErrorMessage(payload));
|
||||||
|
}
|
||||||
|
const payload = await parseJsonResponse(response);
|
||||||
|
transientFailures = 0;
|
||||||
|
if (payload?.status === "completed") {
|
||||||
|
const images = extractGeminiImages(payload.response);
|
||||||
|
if (images.length === 0) throw new Error("BananaImage 任务完成但未返回图片");
|
||||||
|
return images;
|
||||||
|
}
|
||||||
|
if (payload?.status === "failed") {
|
||||||
|
throw new Error(extractErrorMessage(payload, "BananaImage 图片生成失败"));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
if (!/^HTTP 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("BananaImage 生成超时,任务可能仍在处理,请稍后重试");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitGptImageTask({
|
||||||
|
baseURL,
|
||||||
|
apiKey,
|
||||||
|
prompt,
|
||||||
|
referenceImages,
|
||||||
|
aspectRatio,
|
||||||
|
imageSize,
|
||||||
|
}: {
|
||||||
|
baseURL: string;
|
||||||
|
apiKey: string;
|
||||||
|
prompt: string;
|
||||||
|
referenceImages: ReferenceImage[];
|
||||||
|
aspectRatio: string;
|
||||||
|
imageSize: string;
|
||||||
|
}) {
|
||||||
|
const body: Record<string, unknown> = {
|
||||||
|
model: "gpt-image-2",
|
||||||
|
prompt,
|
||||||
|
size: aspectRatio.toLowerCase(),
|
||||||
|
resolution: imageSize.toLowerCase(),
|
||||||
|
};
|
||||||
|
if (referenceImages.length > 0) {
|
||||||
|
body.image_urls = referenceImages.map((image) => image.dataUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(joinApiPath(baseURL, "/api/v1/images/generations"), {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${apiKey}`,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"X-Client-Source": "web",
|
||||||
|
},
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
const payload = await parseJsonResponse(response);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`${extractErrorMessage(payload)}(HTTP ${response.status})`);
|
||||||
|
}
|
||||||
|
const taskId = extractTaskId(payload);
|
||||||
|
if (!taskId) throw new Error("BananaImage GPT Image 2 未返回 task_id");
|
||||||
|
return taskId;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitForGptImageTask(baseURL: string, apiKey: string, taskId: string) {
|
||||||
|
let transientFailures = 0;
|
||||||
|
for (let attempt = 0; attempt < GPT_POLL_MAX_ATTEMPTS; attempt += 1) {
|
||||||
|
await delay(GPT_POLL_INTERVAL_MS);
|
||||||
|
try {
|
||||||
|
const response = await fetch(
|
||||||
|
joinApiPath(baseURL, `/api/v1/images/tasks/${encodeURIComponent(taskId)}`),
|
||||||
|
{ headers: { Authorization: `Bearer ${apiKey}` }, cache: "no-store" }
|
||||||
|
);
|
||||||
|
if (!response.ok) {
|
||||||
|
if (response.status >= 500) throw new Error(`HTTP ${response.status}`);
|
||||||
|
const payload = await parseJsonResponse(response);
|
||||||
|
throw new Error(extractErrorMessage(payload));
|
||||||
|
}
|
||||||
|
const payload = await parseJsonResponse(response);
|
||||||
|
transientFailures = 0;
|
||||||
|
if (payload?.status === "completed") {
|
||||||
|
const url = String(payload?.url || "").trim();
|
||||||
|
if (!url) throw new Error("BananaImage GPT Image 2 任务完成但未返回图片");
|
||||||
|
return [url];
|
||||||
|
}
|
||||||
|
if (payload?.status === "failed") {
|
||||||
|
throw new Error(extractErrorMessage(payload, "BananaImage GPT Image 2 生成失败"));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
if (!/^HTTP 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("BananaImage GPT Image 2 生成超时,请稍后重试");
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatMarkdown(images: UploadedImage[], imageSize: string) {
|
||||||
|
return images
|
||||||
|
.map((image) => ``)
|
||||||
|
.join("\n\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createBananaImage({ apiKey, baseURL }: BananaImageConfig) {
|
||||||
|
return {
|
||||||
|
provider: "bananaimage",
|
||||||
|
chat: (modelId: string) => ({
|
||||||
|
provider: "bananaimage",
|
||||||
|
doGenerate: async (options: {
|
||||||
|
messages: Array<{ role: string; content?: any; parts?: any[] }>;
|
||||||
|
imageParams?: BananaImageParams;
|
||||||
|
imageGenConfig?: BananaImageModelConfig | null;
|
||||||
|
overrideModelId?: string;
|
||||||
|
}) => {
|
||||||
|
const requestedModelId = normalizeModelId(options.overrideModelId || modelId);
|
||||||
|
const runtimeModelId = resolveBananaImageRuntimeModelId(requestedModelId);
|
||||||
|
const apiFormat = resolveApiFormat(requestedModelId, options.imageGenConfig);
|
||||||
|
const { prompt, imageUrls } = extractPromptAndImages(options.messages);
|
||||||
|
const maxReferenceImages = Math.max(
|
||||||
|
0,
|
||||||
|
Math.min(14, options.imageGenConfig?.maxReferenceImages || 14)
|
||||||
|
);
|
||||||
|
const referenceImages = await Promise.all(
|
||||||
|
imageUrls.slice(0, maxReferenceImages).map(loadReferenceImage)
|
||||||
|
);
|
||||||
|
const supportedAspectRatios = getSupportedAspectRatios(
|
||||||
|
requestedModelId,
|
||||||
|
options.imageGenConfig
|
||||||
|
);
|
||||||
|
const aspectRatio = resolveAspectRatio({
|
||||||
|
requested: options.imageParams?.aspectRatio,
|
||||||
|
referenceImages,
|
||||||
|
supportedAspectRatios,
|
||||||
|
});
|
||||||
|
const imageSize = normalizeImageSize(
|
||||||
|
options.imageParams?.imageSize,
|
||||||
|
options.imageGenConfig
|
||||||
|
);
|
||||||
|
|
||||||
|
let outputUrls: string[];
|
||||||
|
if (apiFormat === "bananaimage-gpt-image-2") {
|
||||||
|
const taskId = await submitGptImageTask({
|
||||||
|
baseURL,
|
||||||
|
apiKey,
|
||||||
|
prompt,
|
||||||
|
referenceImages,
|
||||||
|
aspectRatio,
|
||||||
|
imageSize,
|
||||||
|
});
|
||||||
|
outputUrls = await waitForGptImageTask(baseURL, apiKey, taskId);
|
||||||
|
} else {
|
||||||
|
const taskId = await submitGeminiTask({
|
||||||
|
baseURL,
|
||||||
|
apiKey,
|
||||||
|
model: runtimeModelId,
|
||||||
|
prompt,
|
||||||
|
referenceImages,
|
||||||
|
aspectRatio,
|
||||||
|
imageSize,
|
||||||
|
});
|
||||||
|
outputUrls = await waitForGeminiTask(baseURL, apiKey, 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?: BananaImageParams;
|
||||||
|
imageGenConfig?: BananaImageModelConfig | null;
|
||||||
|
overrideModelId?: string;
|
||||||
|
}) {
|
||||||
|
const result = await this.doGenerate(options);
|
||||||
|
yield { type: "text-delta", textDelta: result.text };
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
@ -59,6 +59,8 @@ export function parseImageAltMetadata(altText: string, thumbnailUrl: string): {
|
|||||||
originalUrl = thumbnailUrl.replace("fal-image-thumb-", "fal-image-orig-");
|
originalUrl = thumbnailUrl.replace("fal-image-thumb-", "fal-image-orig-");
|
||||||
} else if (thumbnailUrl.includes("snapgen-image-thumb-")) {
|
} else if (thumbnailUrl.includes("snapgen-image-thumb-")) {
|
||||||
originalUrl = thumbnailUrl.replace("snapgen-image-thumb-", "snapgen-image-orig-");
|
originalUrl = thumbnailUrl.replace("snapgen-image-thumb-", "snapgen-image-orig-");
|
||||||
|
} else if (thumbnailUrl.includes("bananaimage-thumb-")) {
|
||||||
|
originalUrl = thumbnailUrl.replace("bananaimage-thumb-", "bananaimage-orig-");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -10,6 +10,7 @@ import { createRuxaImage } from "@/lib/ai/providers/ruxa-image";
|
|||||||
import { createFalImage } from "@/lib/ai/providers/fal-image";
|
import { createFalImage } from "@/lib/ai/providers/fal-image";
|
||||||
import { createSnapgenImage } from "@/lib/ai/providers/snapgen-image";
|
import { createSnapgenImage } from "@/lib/ai/providers/snapgen-image";
|
||||||
import { createOpenAIImage } from "@/lib/ai/providers/openai-image";
|
import { createOpenAIImage } from "@/lib/ai/providers/openai-image";
|
||||||
|
import { createBananaImage } from "@/lib/ai/providers/bananaimage";
|
||||||
import { createStepfunImage } from "@/lib/ai/providers/stepfun-image";
|
import { createStepfunImage } from "@/lib/ai/providers/stepfun-image";
|
||||||
import { createMidjourney } from "@/lib/ai/providers/midjourney";
|
import { createMidjourney } from "@/lib/ai/providers/midjourney";
|
||||||
import { createRunway } from "@/lib/ai/providers/runway";
|
import { createRunway } from "@/lib/ai/providers/runway";
|
||||||
@ -273,6 +274,7 @@ const SUPPORTED_CANVAS_PROVIDER_TYPES = new Set([
|
|||||||
"fal-image",
|
"fal-image",
|
||||||
"snapgen-image",
|
"snapgen-image",
|
||||||
"openai-image",
|
"openai-image",
|
||||||
|
"bananaimage",
|
||||||
"stepfun-image",
|
"stepfun-image",
|
||||||
"midjourney",
|
"midjourney",
|
||||||
"runway",
|
"runway",
|
||||||
@ -4095,6 +4097,7 @@ function getProviderBatchLimit(providerType: string) {
|
|||||||
case "ruxa-image":
|
case "ruxa-image":
|
||||||
case "bfl-image":
|
case "bfl-image":
|
||||||
case "openai-image":
|
case "openai-image":
|
||||||
|
case "bananaimage":
|
||||||
case "stepfun-image":
|
case "stepfun-image":
|
||||||
case "midjourney":
|
case "midjourney":
|
||||||
case "runway":
|
case "runway":
|
||||||
@ -4127,6 +4130,8 @@ function getCanvasScaleAdjustProviderFailoverPriority(providerType: string) {
|
|||||||
return 10;
|
return 10;
|
||||||
case "snapgen-image":
|
case "snapgen-image":
|
||||||
return 20;
|
return 20;
|
||||||
|
case "bananaimage":
|
||||||
|
return 25;
|
||||||
case "krea":
|
case "krea":
|
||||||
return 30;
|
return 30;
|
||||||
case "gemini":
|
case "gemini":
|
||||||
@ -4735,7 +4740,7 @@ export async function generateCanvasImage(
|
|||||||
if (!SUPPORTED_CANVAS_PROVIDER_TYPES.has(provider.type)) {
|
if (!SUPPORTED_CANVAS_PROVIDER_TYPES.has(provider.type)) {
|
||||||
if (request.allowFailover === false) {
|
if (request.allowFailover === false) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
"当前图片模型类型暂不支持画布生成,请在后台选择 Gemini/Grsai GPT Image/火山 Seedream/阿里/BFL/RUXA/FAL/SnapGen/OpenAI/StepFun/Runway/Krea 图片模型。"
|
"当前图片模型类型暂不支持画布生成,请在后台选择 Gemini/Grsai GPT Image/火山 Seedream/阿里/BFL/RUXA/FAL/SnapGen/OpenAI/BananaImage/StepFun/Runway/Krea 图片模型。"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -4768,7 +4773,7 @@ export async function generateCanvasImage(
|
|||||||
}
|
}
|
||||||
if (!SUPPORTED_CANVAS_PROVIDER_TYPES.has(fallbackProvider.type)) {
|
if (!SUPPORTED_CANVAS_PROVIDER_TYPES.has(fallbackProvider.type)) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
"当前图片模型类型暂不支持画布生成,请在后台选择 Gemini/Grsai GPT Image/火山 Seedream/阿里/BFL/RUXA/FAL/SnapGen/OpenAI/StepFun/Runway/Krea 图片模型。"
|
"当前图片模型类型暂不支持画布生成,请在后台选择 Gemini/Grsai GPT Image/火山 Seedream/阿里/BFL/RUXA/FAL/SnapGen/OpenAI/BananaImage/StepFun/Runway/Krea 图片模型。"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (fallbackModel.model.id !== selectedModel.model.id) {
|
if (fallbackModel.model.id !== selectedModel.model.id) {
|
||||||
@ -5011,6 +5016,26 @@ export async function generateCanvasImage(
|
|||||||
});
|
});
|
||||||
return result.text || "";
|
return result.text || "";
|
||||||
};
|
};
|
||||||
|
} else if (provider.type === "bananaimage") {
|
||||||
|
const bananaImageProvider = createBananaImage({
|
||||||
|
apiKey: provider.apiKey,
|
||||||
|
baseURL: provider.baseUrl,
|
||||||
|
});
|
||||||
|
const runtimeModelId = resolveModelRuntimeId(selectedModel.model, {
|
||||||
|
providerType: provider.type,
|
||||||
|
});
|
||||||
|
const modelInstance = bananaImageProvider.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") {
|
} else if (provider.type === "stepfun-image") {
|
||||||
const stepfunProvider = createStepfunImage({
|
const stepfunProvider = createStepfunImage({
|
||||||
apiKey: provider.apiKey,
|
apiKey: provider.apiKey,
|
||||||
|
|||||||
@ -33,6 +33,7 @@ const providerBaseUrlDefaults = [
|
|||||||
{ type: "fal-image", baseUrl: "https://fal.run" },
|
{ type: "fal-image", baseUrl: "https://fal.run" },
|
||||||
{ type: "snapgen-image", baseUrl: "https://api.snapgen.ai" },
|
{ type: "snapgen-image", baseUrl: "https://api.snapgen.ai" },
|
||||||
{ type: "openai-image", baseUrl: "https://api.openai.com/v1" },
|
{ type: "openai-image", baseUrl: "https://api.openai.com/v1" },
|
||||||
|
{ type: "bananaimage", baseUrl: "https://bananaimage.aigenmedia.art" },
|
||||||
{ type: "stepfun-image", baseUrl: "https://api.stepfun.com" },
|
{ type: "stepfun-image", baseUrl: "https://api.stepfun.com" },
|
||||||
{ type: "midjourney", baseUrl: "https://your-midjourney-api.example.com" },
|
{ type: "midjourney", baseUrl: "https://your-midjourney-api.example.com" },
|
||||||
{ type: "fal-video", baseUrl: "https://fal.run" },
|
{ type: "fal-video", baseUrl: "https://fal.run" },
|
||||||
@ -150,7 +151,7 @@ async function runMigrationStatements(db: ReturnType<typeof drizzle>) {
|
|||||||
ADD CONSTRAINT "AiProvider_type_check"
|
ADD CONSTRAINT "AiProvider_type_check"
|
||||||
CHECK (type IN (
|
CHECK (type IN (
|
||||||
'alibaba', 'openai', 'custom', 'local', 'gemini',
|
'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', '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', 'stepfun-image', 'midjourney', 'fal-video', 'zhipu', 'tripo-3d', 'worldlabs-3d', 'kittycad-cad', 'runway', 'krea'
|
||||||
));
|
));
|
||||||
`);
|
`);
|
||||||
await db.execute(sql`
|
await db.execute(sql`
|
||||||
|
|||||||
38
lib/db/migrations/0090_add_bananaimage_provider_type.sql
Normal file
38
lib/db/migrations/0090_add_bananaimage_provider_type.sql
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
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',
|
||||||
|
'stepfun-image',
|
||||||
|
'midjourney',
|
||||||
|
'fal-video',
|
||||||
|
'zhipu',
|
||||||
|
'tripo-3d',
|
||||||
|
'worldlabs-3d',
|
||||||
|
'kittycad-cad',
|
||||||
|
'runway',
|
||||||
|
'krea'
|
||||||
|
)
|
||||||
|
);
|
||||||
@ -533,6 +533,13 @@
|
|||||||
"when": 1783681200000,
|
"when": 1783681200000,
|
||||||
"tag": "0089_add_access_ip_blocklist",
|
"tag": "0089_add_access_ip_blocklist",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 76,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1783946203000,
|
||||||
|
"tag": "0090_add_bananaimage_provider_type",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3762,7 +3762,7 @@ export async function createAiProvider({
|
|||||||
updatedBy,
|
updatedBy,
|
||||||
}: {
|
}: {
|
||||||
name: 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" | "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" | "stepfun-image" | "midjourney" | "fal-video" | "zhipu" | "tripo-3d" | "worldlabs-3d" | "kittycad-cad" | "runway" | "krea";
|
||||||
baseUrl: string;
|
baseUrl: string;
|
||||||
apiKey: string;
|
apiKey: string;
|
||||||
isEnabled?: boolean;
|
isEnabled?: boolean;
|
||||||
@ -3800,7 +3800,7 @@ export async function updateAiProvider({
|
|||||||
}: {
|
}: {
|
||||||
id: string;
|
id: string;
|
||||||
name?: 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" | "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" | "stepfun-image" | "midjourney" | "fal-video" | "zhipu" | "tripo-3d" | "worldlabs-3d" | "kittycad-cad" | "runway" | "krea";
|
||||||
baseUrl?: string;
|
baseUrl?: string;
|
||||||
apiKey?: string;
|
apiKey?: string;
|
||||||
isEnabled?: boolean;
|
isEnabled?: boolean;
|
||||||
|
|||||||
@ -308,7 +308,7 @@ export type ToolConfig = InferSelectModel<typeof toolConfig>;
|
|||||||
export const aiProvider = pgTable("AiProvider", {
|
export const aiProvider = pgTable("AiProvider", {
|
||||||
id: uuid("id").primaryKey().notNull().defaultRandom(),
|
id: uuid("id").primaryKey().notNull().defaultRandom(),
|
||||||
name: varchar("name", { length: 128 }).notNull(), // 提供商名称,如 "阿里云通义千问"
|
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", "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", "stepfun-image", "midjourney", "fal-video", "zhipu", "tripo-3d", "worldlabs-3d", "kittycad-cad", "runway", "krea", "bfl-image"] })
|
||||||
.notNull()
|
.notNull()
|
||||||
.default("custom"), // 提供商类型
|
.default("custom"), // 提供商类型
|
||||||
baseUrl: text("baseUrl").notNull(), // API 基础 URL
|
baseUrl: text("baseUrl").notNull(), // API 基础 URL
|
||||||
|
|||||||
@ -345,6 +345,7 @@ function supportsImageEditForReferenceCount(
|
|||||||
const runtimeModelId = normalizeText(entry.model.modelId).toLowerCase();
|
const runtimeModelId = normalizeText(entry.model.modelId).toLowerCase();
|
||||||
const providerImpliesReferenceEdit =
|
const providerImpliesReferenceEdit =
|
||||||
providerType === "openai-image" ||
|
providerType === "openai-image" ||
|
||||||
|
providerType === "bananaimage" ||
|
||||||
providerType === "grsai-gpt-image" ||
|
providerType === "grsai-gpt-image" ||
|
||||||
providerType === "ruxa-image" ||
|
providerType === "ruxa-image" ||
|
||||||
providerType === "snapgen-image";
|
providerType === "snapgen-image";
|
||||||
@ -417,6 +418,10 @@ function getExternalImageApiPriority(entry: ImageModelEntry) {
|
|||||||
return 400;
|
return 400;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isGptImage2 && providerType === "bananaimage") {
|
||||||
|
return 380;
|
||||||
|
}
|
||||||
|
|
||||||
if (isGptImage2 && providerType === "grsai-gpt-image") {
|
if (isGptImage2 && providerType === "grsai-gpt-image") {
|
||||||
return isVip ? 350 : 340;
|
return isVip ? 350 : 340;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -53,7 +53,8 @@ export function isLikelyThumbnailUrl(url: string): boolean {
|
|||||||
lowerUrl.includes("ruxa-image-thumb-") ||
|
lowerUrl.includes("ruxa-image-thumb-") ||
|
||||||
lowerUrl.includes("stepfun-image-thumb-") ||
|
lowerUrl.includes("stepfun-image-thumb-") ||
|
||||||
lowerUrl.includes("fal-image-thumb-") ||
|
lowerUrl.includes("fal-image-thumb-") ||
|
||||||
lowerUrl.includes("snapgen-image-thumb-")
|
lowerUrl.includes("snapgen-image-thumb-") ||
|
||||||
|
lowerUrl.includes("bananaimage-thumb-")
|
||||||
) {
|
) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@ -96,6 +97,9 @@ export function inferOriginalImageUrl(url: string): string {
|
|||||||
if (url.includes("snapgen-image-thumb-")) {
|
if (url.includes("snapgen-image-thumb-")) {
|
||||||
return url.replace("snapgen-image-thumb-", "snapgen-image-orig-");
|
return url.replace("snapgen-image-thumb-", "snapgen-image-orig-");
|
||||||
}
|
}
|
||||||
|
if (url.includes("bananaimage-thumb-")) {
|
||||||
|
return url.replace("bananaimage-thumb-", "bananaimage-orig-");
|
||||||
|
}
|
||||||
|
|
||||||
if (url.includes("/thumb/")) {
|
if (url.includes("/thumb/")) {
|
||||||
return url.replace("/thumb/", "/");
|
return url.replace("/thumb/", "/");
|
||||||
|
|||||||
@ -77,6 +77,7 @@ export function canProcessSceneFusionProviderType(providerType: string): boolean
|
|||||||
case "fal-image":
|
case "fal-image":
|
||||||
case "snapgen-image":
|
case "snapgen-image":
|
||||||
case "openai-image":
|
case "openai-image":
|
||||||
|
case "bananaimage":
|
||||||
case "gemini":
|
case "gemini":
|
||||||
case "volcano-vision":
|
case "volcano-vision":
|
||||||
return true;
|
return true;
|
||||||
@ -131,6 +132,7 @@ function getSupportedAspectRatiosByProvider(
|
|||||||
case "alibaba-image":
|
case "alibaba-image":
|
||||||
case "fal-image":
|
case "fal-image":
|
||||||
case "openai-image":
|
case "openai-image":
|
||||||
|
case "bananaimage":
|
||||||
case "gemini":
|
case "gemini":
|
||||||
default:
|
default:
|
||||||
return [...ALL_ASPECT_RATIO_VALUES];
|
return [...ALL_ASPECT_RATIO_VALUES];
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user