feat: add BLTCY Flux image providers
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled

This commit is contained in:
Your Name 2026-04-18 10:29:30 +08:00
parent 0b1062a31f
commit 0edd9872ea
6 changed files with 973 additions and 65 deletions

View File

@ -158,6 +158,14 @@ import {
defaultAgentWorkflowUiConfig,
normalizeAgentWorkflowUiConfig,
} from "@/lib/agents/workflow-ui-config";
import {
BLTCY_BFL_BASE_URL,
BLTCY_BFL_MODEL_PRESETS,
BLTCY_BFL_PROVIDER_PRESET,
BLTCY_IMAGE_MODEL_PRESETS,
BLTCY_IMAGE_OPENAI_BASE_URL,
BLTCY_IMAGE_PROVIDER_PRESET,
} from "@/lib/ai/bltcy-image-models";
import {
CANVAS_SKILL_ICON_OPTIONS,
getCanvasSkillIconComponent,
@ -170,6 +178,7 @@ import {
import {
buildVideoDurationCostMap,
getConfiguredVideoDurationOptions,
getVideoCreditCostFromConfig,
normalizeVideoDurationOptions,
recommendedVideoDurationOptions,
recommendedVideoDurationReferenceCosts,
@ -182,6 +191,7 @@ const DEFAULT_BFL_REALTIME_MODEL = "flux-2-klein-4b";
const DEFAULT_BFL_BASE_URL = "https://api.bfl.ai";
const HOME_CHAT_ANIMATION_MAX_BYTES = 1024 * 1024;
const HOME_CHAT_ANIMATION_CONFIG_KEY = "home_chat_animation_config";
const VIDEO_PRICING_PREVIEW_RESOLUTIONS = ["480p", "720p", "1080p"] as const;
function createEmptyCanvasSkillTag(): CanvasSkillTag {
return {
@ -438,6 +448,21 @@ function applyRecommendedVideoPricing(config: ImageGenConfig | undefined) {
};
}
function applyRecommendedEnhanceProPricing(
pricing: PointsSystemConfig["enhanceProPricing"]
): PointsSystemConfig["enhanceProPricing"] {
const recommended = defaultPointsSystemConfig.enhanceProPricing;
return {
...pricing,
chargeMode: recommended.chargeMode,
fixedCost: recommended.fixedCost,
chargeOnlyWhenUpscaling: recommended.chargeOnlyWhenUpscaling,
batchSizeMultiplierEnabled: recommended.batchSizeMultiplierEnabled,
scaleFactorCosts: { ...recommended.scaleFactorCosts },
modelMultipliers: { ...recommended.modelMultipliers },
};
}
type AiModel = {
id: string;
providerId: string;
@ -785,6 +810,21 @@ const PROVIDER_DEFAULT_BASE_URL: Record<string, string> = {
};
const LEMON_OPENAI_BASE_URL = "https://new.lemonapi.site/v1";
const HTTP_URL_REGEX = /^https?:\/\//i;
const TRAILING_SLASHES_REGEX = /\/+$/;
const TRAILING_V1_REGEX = /\/v1$/i;
const BLTCY_IMAGE_NORMALIZED_BASE_URL = BLTCY_IMAGE_OPENAI_BASE_URL.replace(
TRAILING_SLASHES_REGEX,
""
);
const BLTCY_IMAGE_BASE_URL_WITHOUT_V1 = BLTCY_IMAGE_OPENAI_BASE_URL.replace(
TRAILING_V1_REGEX,
""
);
const BLTCY_BFL_NORMALIZED_BASE_URL = BLTCY_BFL_BASE_URL.replace(
TRAILING_SLASHES_REGEX,
""
);
const LEMON_PROVIDER_PRESET = {
name: "LemonAPI Gemini",
type: "openai" as const,
@ -808,23 +848,42 @@ function getProviderDefaultBaseUrl(type?: string) {
}
function isHttpUrlLike(value: string) {
return /^https?:\/\//i.test(value.trim());
return HTTP_URL_REGEX.test(value.trim());
}
function isLemonApiBaseUrl(value?: string | null) {
const normalized = String(value || "")
.trim()
.replace(TRAILING_SLASHES_REGEX, "");
return normalized === LEMON_OPENAI_BASE_URL;
}
function isBltcyImageBaseUrl(value?: string | null) {
const normalized = String(value || "")
.trim()
.replace(TRAILING_SLASHES_REGEX, "");
return (
String(value || "")
.trim()
.replace(/\/+$/, "") === LEMON_OPENAI_BASE_URL
normalized === BLTCY_IMAGE_NORMALIZED_BASE_URL ||
normalized === BLTCY_IMAGE_BASE_URL_WITHOUT_V1
);
}
function isBltcyBflBaseUrl(value?: string | null) {
const normalized = String(value || "")
.trim()
.replace(TRAILING_SLASHES_REGEX, "");
return (
normalized === BLTCY_BFL_NORMALIZED_BASE_URL ||
normalized === `${BLTCY_BFL_NORMALIZED_BASE_URL}/bfl` ||
normalized === `${BLTCY_BFL_NORMALIZED_BASE_URL}/bfl/v1`
);
}
function isMoonshotOfficialBaseUrl(value?: string | null) {
return (
String(value || "")
.trim()
.replace(/\/+$/, "") === DETAIL_AGENT_LOCAL_MOONSHOT_BASE_URL
);
const normalized = String(value || "")
.trim()
.replace(TRAILING_SLASHES_REGEX, "");
return normalized === DETAIL_AGENT_LOCAL_MOONSHOT_BASE_URL;
}
export function AdminSettingsClient() {
@ -4276,6 +4335,16 @@ export function AdminSettingsClient() {
isLemonApiBaseUrl(
providerForm.baseUrl || getProviderDefaultBaseUrl(providerForm.type)
);
const isBltcyImageProvider =
providerForm.type === "openai-image" &&
isBltcyImageBaseUrl(
providerForm.baseUrl || getProviderDefaultBaseUrl(providerForm.type)
);
const isBltcyBflProvider =
providerForm.type === "bfl-image" &&
isBltcyBflBaseUrl(
providerForm.baseUrl || getProviderDefaultBaseUrl(providerForm.type)
);
const defaultBaseUrl = getProviderDefaultBaseUrl(providerForm.type);
const normalizedBaseUrl = providerForm.baseUrl.trim();
@ -4375,6 +4444,28 @@ export function AdminSettingsClient() {
);
}
}
if (isNewProvider && isBltcyImageProvider && providerId) {
try {
await handleQuickAddBltcyImageModels(providerId, { skipConfirm: true });
} catch (modelError) {
console.error("[Auto Add BLTCY Image Models] Error:", modelError);
toast.warning(
'柏拉图 Flux 预设模型添加失败,请手动点击"快速添加图片编辑预设"按钮'
);
}
}
if (isNewProvider && isBltcyBflProvider && providerId) {
try {
await handleQuickAddBltcyBflModels(providerId, { skipConfirm: true });
} catch (modelError) {
console.error("[Auto Add BLTCY BFL Models] Error:", modelError);
toast.warning(
'柏拉图 BFL 预设模型添加失败,请手动点击"快速添加 BFL 异步预设"按钮'
);
}
}
} catch (error) {
toast.error("保存提供商失败");
}
@ -5028,6 +5119,120 @@ export function AdminSettingsClient() {
}
};
const handleUseBltcyImageProviderPreset = () => {
setProviderForm({
id: "",
name: BLTCY_IMAGE_PROVIDER_PRESET.name,
type: BLTCY_IMAGE_PROVIDER_PRESET.type,
baseUrl: BLTCY_IMAGE_PROVIDER_PRESET.baseUrl,
apiKey: "",
isEnabled: true,
});
setEditingProvider(null);
setShowProviderDialog(true);
};
const handleUseBltcyBflProviderPreset = () => {
setProviderForm({
id: "",
name: BLTCY_BFL_PROVIDER_PRESET.name,
type: BLTCY_BFL_PROVIDER_PRESET.type,
baseUrl: BLTCY_BFL_PROVIDER_PRESET.baseUrl,
apiKey: "",
isEnabled: true,
});
setEditingProvider(null);
setShowProviderDialog(true);
};
const handleQuickAddBltcyImageModels = async (
providerId: string,
options?: { skipConfirm?: boolean }
) => {
if (
!options?.skipConfirm &&
!confirm(
`将为此服务商添加 ${BLTCY_IMAGE_MODEL_PRESETS.length} 个柏拉图 Flux OpenAI Images 兼容图片编辑模型预设。\n\n继续吗?`
)
) {
return;
}
try {
toast.info("正在添加柏拉图 Flux 图片编辑预设模型...");
const response = await fetch("/api/admin/bltcy-image-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 { addedCount, errors } = data;
if (addedCount > 0) {
toast.success(`成功添加 ${addedCount} 个柏拉图 Flux 预设模型`);
mutateModels();
}
if (errors && errors.length > 0) {
toast.warning(`${errors.length} 个模型添加失败(可能已存在)`);
}
} catch (error: any) {
console.error("[Quick Add BLTCY Image Models] Error:", error);
toast.error(error.message || "添加柏拉图 Flux 预设模型失败");
}
};
const handleQuickAddBltcyBflModels = async (
providerId: string,
options?: { skipConfirm?: boolean }
) => {
if (
!options?.skipConfirm &&
!confirm(
`将为此服务商添加 ${BLTCY_BFL_MODEL_PRESETS.length} 个柏拉图 BFL 异步图片编辑模型预设。\n\n继续吗?`
)
) {
return;
}
try {
toast.info("正在添加柏拉图 BFL 异步图片编辑预设模型...");
const response = await fetch("/api/admin/bltcy-bfl-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 { addedCount, errors } = data;
if (addedCount > 0) {
toast.success(`成功添加 ${addedCount} 个柏拉图 BFL 预设模型`);
mutateModels();
}
if (errors && errors.length > 0) {
toast.warning(`${errors.length} 个模型添加失败(可能已存在)`);
}
} catch (error: any) {
console.error("[Quick Add BLTCY BFL Models] Error:", error);
toast.error(error.message || "添加柏拉图 BFL 预设模型失败");
}
};
const handleUseLemonProviderPreset = () => {
setProviderForm({
id: "",
@ -8329,21 +8534,42 @@ export function AdminSettingsClient() {
默认按基础 20 积分计算;启用放大后按放大倍数折算,例如 1.25x 为 25 积分、2x 为 40 积分。
</div>
</div>
<Switch
checked={pointsSystemConfig.enhanceProPricing.enabled === true}
onCheckedChange={(checked) =>
setSiteSettings({
...siteSettings,
pointsSystemConfig: {
...pointsSystemConfig,
enhanceProPricing: {
...pointsSystemConfig.enhanceProPricing,
enabled: checked,
<div className="flex shrink-0 items-center gap-2">
<Button
type="button"
variant="outline"
size="sm"
onClick={() => {
setSiteSettings({
...siteSettings,
pointsSystemConfig: {
...pointsSystemConfig,
enhanceProPricing: applyRecommendedEnhanceProPricing(
pointsSystemConfig.enhanceProPricing
),
},
},
})
}
/>
});
toast.success("已套用图像增强 Pro 推荐价格,可继续手动调整");
}}
>
套用推荐价格
</Button>
<Switch
checked={pointsSystemConfig.enhanceProPricing.enabled === true}
onCheckedChange={(checked) =>
setSiteSettings({
...siteSettings,
pointsSystemConfig: {
...pointsSystemConfig,
enhanceProPricing: {
...pointsSystemConfig.enhanceProPricing,
enabled: checked,
},
},
})
}
/>
</div>
</div>
<div className="grid grid-cols-1 gap-3 md:grid-cols-2">
@ -11253,6 +11479,18 @@ export function AdminSettingsClient() {
<CardDescription>配置 AI 服务提供商</CardDescription>
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
onClick={handleUseBltcyImageProviderPreset}
>
<ImageIcon className="mr-2 h-4 w-4" /> 柏拉图图片编辑
</Button>
<Button
variant="outline"
onClick={handleUseBltcyBflProviderPreset}
>
<ImageIcon className="mr-2 h-4 w-4" /> 柏拉图BFL异步
</Button>
<Button
variant="outline"
onClick={handleUseLemonProviderPreset}
@ -11404,6 +11642,21 @@ export function AdminSettingsClient() {
OpenAPI 中的图片、图片增强和视频模型预设。
</p>
)}
{providerForm.type === "openai-image" &&
isBltcyImageBaseUrl(providerForm.baseUrl) && (
<p className="text-xs text-muted-foreground">
柏拉图 Flux 使用 OpenAI Images 兼容接口和 Bearer
Token 认证。保存后会自动添加所有同步图片编辑预设模型。
</p>
)}
{providerForm.type === "bfl-image" &&
isBltcyBflBaseUrl(providerForm.baseUrl) && (
<p className="text-xs text-muted-foreground">
柏拉图 BFL 使用 Bearer Token 认证,通过
/bfl/v1 提交异步任务并轮询结果。保存后会自动添加
Kontext 异步图片编辑预设模型。
</p>
)}
</div>
<div className="flex items-center gap-2">
<Switch
@ -11479,6 +11732,34 @@ export function AdminSettingsClient() {
快速添加 Krea 预设模型
</Button>
)}
{provider.type === "openai-image" &&
isBltcyImageBaseUrl(provider.baseUrl) && (
<Button
variant="secondary"
size="sm"
className="mt-2"
onClick={() =>
handleQuickAddBltcyImageModels(provider.id)
}
>
<ImageIcon className="h-4 w-4 mr-1" />
快速添加图片编辑预设
</Button>
)}
{provider.type === "bfl-image" &&
isBltcyBflBaseUrl(provider.baseUrl) && (
<Button
variant="secondary"
size="sm"
className="mt-2"
onClick={() =>
handleQuickAddBltcyBflModels(provider.id)
}
>
<ImageIcon className="h-4 w-4 mr-1" />
快速添加 BFL 异步预设
</Button>
)}
{provider.type === "openai" &&
isLemonApiBaseUrl(provider.baseUrl) && (
<Button
@ -12090,7 +12371,7 @@ export function AdminSettingsClient() {
{configuredVideoDurationOptions.length > 0 ? (
<div className="space-y-3">
<div className="text-sm font-medium">
各秒数积分消耗
时长倍率参考积分
</div>
<div className="grid grid-cols-2 gap-4">
{configuredVideoDurationOptions.map((duration) => {
@ -12100,7 +12381,7 @@ export function AdminSettingsClient() {
key={durationKey}
className="space-y-2"
>
<Label>{duration} 秒积分</Label>
<Label>{duration} 秒参考积分</Label>
<Input
type="number"
min={0}
@ -12147,8 +12428,8 @@ export function AdminSettingsClient() {
})}
</div>
<p className="text-xs text-muted-foreground">
如果同时配置了分辨率积分,系统会把最短秒数的积分作为 1
倍参考,其它秒数按比例乘分辨率基础价。
系统会把最短秒数的参考积分作为 1
倍,其它秒数按比例乘下方分辨率基础价。
</p>
</div>
) : (
@ -12159,16 +12440,16 @@ export function AdminSettingsClient() {
<div className="space-y-3 border-t pt-4">
<div className="text-sm font-medium">
各分辨率积分消耗
分辨率基础积分
</div>
<div className="grid grid-cols-3 gap-4">
{(["480p", "720p", "1080p"] as const).map(
{VIDEO_PRICING_PREVIEW_RESOLUTIONS.map(
(resolutionKey) => (
<div
key={resolutionKey}
className="space-y-2"
>
<Label>{resolutionKey} 积分</Label>
<Label>{resolutionKey} 基础积分</Label>
<Input
type="number"
min={0}
@ -12212,6 +12493,64 @@ export function AdminSettingsClient() {
配置后按用户选择的视频分辨率取基础价,再叠加上方秒数倍率;未命中再回退到秒数或统一价格。
</p>
</div>
<div className="space-y-3 border-t pt-4">
<div className="space-y-1">
<div className="text-sm font-medium">
实际价格预览
</div>
<p className="text-xs text-muted-foreground">
按当前手动输入值实时计算,保存后前台会按这个矩阵扣积分。
</p>
</div>
<div className="overflow-x-auto rounded-lg border">
<table className="w-full min-w-[520px] text-sm">
<thead className="bg-muted/60">
<tr>
<th className="px-3 py-2 text-left font-medium">
分辨率
</th>
{recommendedVideoDurationOptions.map(
(duration) => (
<th
key={duration}
className="px-3 py-2 text-left font-medium"
>
{duration} 秒
</th>
)
)}
</tr>
</thead>
<tbody>
{VIDEO_PRICING_PREVIEW_RESOLUTIONS.map(
(resolution) => (
<tr key={resolution} className="border-t">
<td className="px-3 py-2 font-medium">
{resolution}
</td>
{recommendedVideoDurationOptions.map(
(duration) => (
<td
key={`${resolution}-${duration}`}
className="px-3 py-2"
>
{getVideoCreditCostFromConfig(
modelForm.imageGenConfig,
duration,
resolution
)}{" "}
积分
</td>
)
)}
</tr>
)
)}
</tbody>
</table>
</div>
</div>
</div>
)}

View File

@ -0,0 +1,153 @@
import { and, eq } from "drizzle-orm";
import { NextResponse } from "next/server";
import {
BLTCY_BFL_BASE_URL,
BLTCY_BFL_MODEL_PRESETS,
} from "@/lib/ai/bltcy-image-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";
const TRAILING_SLASHES_REGEX = /\/+$/;
const BLTCY_BFL_BASE = BLTCY_BFL_BASE_URL.replace(TRAILING_SLASHES_REGEX, "");
function isBltcyBflBaseUrl(value?: string | null) {
const normalized = String(value || "")
.trim()
.replace(TRAILING_SLASHES_REGEX, "");
return (
normalized === BLTCY_BFL_BASE ||
normalized === `${BLTCY_BFL_BASE}/bfl` ||
normalized === `${BLTCY_BFL_BASE}/bfl/v1`
);
}
export async function GET() {
try {
await requireAdmin();
return NextResponse.json({
presets: BLTCY_BFL_MODEL_PRESETS,
counts: {
imageEdit: BLTCY_BFL_MODEL_PRESETS.length,
total: BLTCY_BFL_MODEL_PRESETS.length,
},
});
} catch (error: any) {
console.error("[BLTCY BFL Models API] GET error:", error);
return NextResponse.json(
{ error: error.message || "Failed to fetch BLTCY BFL 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 !== "bfl-image" ||
!isBltcyBflBaseUrl(provider.baseUrl)
) {
return NextResponse.json(
{ error: "Provider must be a 柏拉图 BFL 图片 provider" },
{ status: 400 }
);
}
const presetsToAdd = modelIds
? BLTCY_BFL_MODEL_PRESETS.filter((preset) =>
modelIds.includes(preset.modelId)
)
: BLTCY_BFL_MODEL_PRESETS;
const addedModels: AiModel[] = [];
const errors: { modelId: string; error: string }[] = [];
for (const preset of presetsToAdd) {
try {
const existing = await db.query.aiModel.findFirst({
where: and(
eq(aiModel.providerId, providerId),
eq(aiModel.modelId, preset.modelId)
),
});
if (existing) {
errors.push({
modelId: preset.modelId,
error: "Model already exists",
});
continue;
}
const [newModel] = await db
.insert(aiModel)
.values({
providerId,
modelId: preset.modelId,
displayName: preset.displayName,
description: preset.description,
supportsVision: preset.supportsVision,
supportsImageGen: preset.supportsImageGen,
isEnabled: true,
isHidden: false,
isDefault: false,
sortOrder: preset.sortOrder,
capabilities: preset.capabilities || null,
defaultParams: preset.defaultParams || null,
imageGenConfig: preset.imageGenConfig || null,
isSubModel: false,
parentModelId: null,
})
.returning();
addedModels.push(newModel);
} catch (error: any) {
console.error(
`[BLTCY BFL Models API] Failed to add ${preset.modelId}:`,
error
);
errors.push({
modelId: preset.modelId,
error: error.message || "Insert failed",
});
}
}
return NextResponse.json({
success: true,
addedCount: addedModels.length,
addedModels,
errors: errors.length > 0 ? errors : undefined,
counts: {
requested: presetsToAdd.length,
total: BLTCY_BFL_MODEL_PRESETS.length,
},
});
} catch (error: any) {
console.error("[BLTCY BFL Models API] POST error:", error);
return NextResponse.json(
{ error: error.message || "Failed to add BLTCY BFL models" },
{ status: 500 }
);
}
}

View File

@ -0,0 +1,160 @@
import { and, eq } from "drizzle-orm";
import { NextResponse } from "next/server";
import {
BLTCY_IMAGE_MODEL_PRESETS,
BLTCY_IMAGE_OPENAI_BASE_URL,
} from "@/lib/ai/bltcy-image-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";
const TRAILING_SLASHES_REGEX = /\/+$/;
const TRAILING_V1_REGEX = /\/v1$/i;
const BLTCY_IMAGE_BASE_URL = BLTCY_IMAGE_OPENAI_BASE_URL.replace(
TRAILING_SLASHES_REGEX,
""
);
const BLTCY_IMAGE_BASE_URL_WITHOUT_V1 = BLTCY_IMAGE_OPENAI_BASE_URL.replace(
TRAILING_V1_REGEX,
""
);
function isBltcyImageBaseUrl(value?: string | null) {
const normalized = String(value || "")
.trim()
.replace(TRAILING_SLASHES_REGEX, "");
return (
normalized === BLTCY_IMAGE_BASE_URL ||
normalized === BLTCY_IMAGE_BASE_URL_WITHOUT_V1
);
}
export async function GET() {
try {
await requireAdmin();
return NextResponse.json({
presets: BLTCY_IMAGE_MODEL_PRESETS,
counts: {
imageEdit: BLTCY_IMAGE_MODEL_PRESETS.length,
total: BLTCY_IMAGE_MODEL_PRESETS.length,
},
});
} catch (error: any) {
console.error("[BLTCY Image Models API] GET error:", error);
return NextResponse.json(
{ error: error.message || "Failed to fetch BLTCY image 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 !== "openai-image" ||
!isBltcyImageBaseUrl(provider.baseUrl)
) {
return NextResponse.json(
{ error: "Provider must be a 柏拉图 OpenAI 图片兼容 provider" },
{ status: 400 }
);
}
const presetsToAdd = modelIds
? BLTCY_IMAGE_MODEL_PRESETS.filter((preset) =>
modelIds.includes(preset.modelId)
)
: BLTCY_IMAGE_MODEL_PRESETS;
const addedModels: AiModel[] = [];
const errors: { modelId: string; error: string }[] = [];
for (const preset of presetsToAdd) {
try {
const existing = await db.query.aiModel.findFirst({
where: and(
eq(aiModel.providerId, providerId),
eq(aiModel.modelId, preset.modelId)
),
});
if (existing) {
errors.push({
modelId: preset.modelId,
error: "Model already exists",
});
continue;
}
const [newModel] = await db
.insert(aiModel)
.values({
providerId,
modelId: preset.modelId,
displayName: preset.displayName,
description: preset.description,
supportsVision: preset.supportsVision,
supportsImageGen: preset.supportsImageGen,
isEnabled: true,
isHidden: false,
isDefault: false,
sortOrder: preset.sortOrder,
capabilities: preset.capabilities || null,
defaultParams: preset.defaultParams || null,
imageGenConfig: preset.imageGenConfig || null,
isSubModel: false,
parentModelId: null,
})
.returning();
addedModels.push(newModel);
} catch (error: any) {
console.error(
`[BLTCY Image Models API] Failed to add ${preset.modelId}:`,
error
);
errors.push({
modelId: preset.modelId,
error: error.message || "Insert failed",
});
}
}
return NextResponse.json({
success: true,
addedCount: addedModels.length,
addedModels,
errors: errors.length > 0 ? errors : undefined,
counts: {
requested: presetsToAdd.length,
total: BLTCY_IMAGE_MODEL_PRESETS.length,
},
});
} catch (error: any) {
console.error("[BLTCY Image Models API] POST error:", error);
return NextResponse.json(
{ error: error.message || "Failed to add BLTCY image models" },
{ status: 500 }
);
}
}

View File

@ -0,0 +1,175 @@
export type BltcyImageModelKind = "flux-image-edit" | "flux-bfl-async-edit";
export type BltcyImageModelPreset = {
modelId: string;
displayName: string;
description: string;
kind: BltcyImageModelKind;
supportsVision: boolean;
supportsImageGen: boolean;
capabilities?: Record<string, any>;
defaultParams: Record<string, any>;
imageGenConfig: Record<string, any>;
sortOrder: number;
};
export const BLTCY_IMAGE_OPENAI_BASE_URL = "https://api.bltcy.ai/v1";
export const BLTCY_BFL_BASE_URL = "https://api.bltcy.ai";
export const BLTCY_FLUX_PRICE_PAGE_URL =
"https://api.bltcy.ai/models?category=%E7%BB%98%E5%9B%BE&provider=Flux";
export const BLTCY_FLUX_GENERATIONS_DOC_URL =
"https://gpt-best.apifox.cn/api-258068997";
export const BLTCY_FLUX_EDITS_DOC_URL =
"https://gpt-best.apifox.cn/api-303213093";
export const BLTCY_BFL_GET_RESULT_DOC_URL =
"https://gpt-best.apifox.cn/api-306433290";
export const BLTCY_BFL_FLUX_DEV_DOC_URL =
"https://gpt-best.apifox.cn/api-306433294";
export const BLTCY_BFL_FLUX_KONTEXT_DOC_URL =
"https://gpt-best.apifox.cn/api-306433308";
export const BLTCY_FLUX_PRICE_CHECKED_AT = "2026-04-18";
export const BLTCY_IMAGE_PROVIDER_PRESET = {
name: "柏拉图 Flux 图片编辑",
type: "openai-image" as const,
baseUrl: BLTCY_IMAGE_OPENAI_BASE_URL,
};
export const BLTCY_BFL_PROVIDER_PRESET = {
name: "柏拉图 BFL 异步编辑",
type: "bfl-image" as const,
baseUrl: BLTCY_BFL_BASE_URL,
};
const DEFAULT_IMAGE_CONFIG = {
maxImages: 1,
supportsSize: true,
supports1K: true,
supports2K: true,
supports4K: true,
supportsPromptExtend: false,
supportsNegativePrompt: false,
supportsWatermark: false,
supportsSeed: false,
creditCostPerImage: 0,
enableLoadingPlaceholder: true,
loadingColor: "cyan",
};
function fluxEditPreset(
sortOrder: number,
modelId: string,
displayName: string,
priceNote: string
): BltcyImageModelPreset {
return {
modelId,
displayName,
description: `${displayName},柏拉图 Flux OpenAI Images 兼容图片生成/编辑模型。${priceNote},实际计费以柏拉图价格页为准。`,
kind: "flux-image-edit",
supportsVision: true,
supportsImageGen: true,
capabilities: {
provider: "bltcy",
family: "flux",
apiFormat: "openai-images",
supportedModes: ["text-to-image", "image-edit"],
endpoints: {
generations: "/v1/images/generations",
edits: "/v1/images/edits",
},
maxReferenceImages: 1,
pricePage: BLTCY_FLUX_PRICE_PAGE_URL,
priceCheckedAt: BLTCY_FLUX_PRICE_CHECKED_AT,
},
defaultParams: {
provider: "bltcy",
family: "flux",
kind: "flux-image-edit",
apiFormat: "openai-images",
generationsEndpoint: "/v1/images/generations",
editsEndpoint: "/v1/images/edits",
docs: {
generations: BLTCY_FLUX_GENERATIONS_DOC_URL,
edits: BLTCY_FLUX_EDITS_DOC_URL,
},
pricePage: BLTCY_FLUX_PRICE_PAGE_URL,
priceCheckedAt: BLTCY_FLUX_PRICE_CHECKED_AT,
priceNote,
},
imageGenConfig: DEFAULT_IMAGE_CONFIG,
sortOrder,
};
}
function bflAsyncEditPreset(options: {
sortOrder: number;
modelId: string;
displayName: string;
endpoint: string;
priceNote: string;
}): BltcyImageModelPreset {
const { sortOrder, modelId, displayName, endpoint, priceNote } = options;
return {
modelId,
displayName,
description: `${displayName},柏拉图 BFL 官方格式异步图片编辑模型。${priceNote},实际计费以柏拉图价格页为准。`,
kind: "flux-bfl-async-edit",
supportsVision: true,
supportsImageGen: true,
capabilities: {
provider: "bltcy",
family: "flux",
apiFormat: "bfl-official",
asyncTask: true,
supportedModes: ["text-to-image", "image-edit"],
endpoints: {
submit: endpoint,
getResult: "/bfl/v1/get_result",
},
maxReferenceImages: 1,
pricePage: BLTCY_FLUX_PRICE_PAGE_URL,
priceCheckedAt: BLTCY_FLUX_PRICE_CHECKED_AT,
},
defaultParams: {
provider: "bltcy",
family: "flux",
kind: "flux-bfl-async-edit",
apiFormat: "bfl-official",
asyncTask: true,
submitEndpoint: endpoint,
getResultEndpoint: "/bfl/v1/get_result",
docs: {
submit: BLTCY_BFL_FLUX_KONTEXT_DOC_URL,
getResult: BLTCY_BFL_GET_RESULT_DOC_URL,
},
pricePage: BLTCY_FLUX_PRICE_PAGE_URL,
priceCheckedAt: BLTCY_FLUX_PRICE_CHECKED_AT,
priceNote,
},
imageGenConfig: DEFAULT_IMAGE_CONFIG,
sortOrder,
};
}
export const BLTCY_IMAGE_MODEL_PRESETS: BltcyImageModelPreset[] = [
fluxEditPreset(10, "flux-kontext-pro", "Flux Kontext Pro", "fal 组 0.12/次"),
fluxEditPreset(20, "flux-kontext-max", "Flux Kontext Max", "fal 组 0.24/次"),
];
export const BLTCY_BFL_MODEL_PRESETS: BltcyImageModelPreset[] = [
bflAsyncEditPreset({
sortOrder: 10,
modelId: "bfl/flux-kontext-pro",
displayName: "BFL Flux Kontext Pro",
endpoint: "/bfl/v1/flux-kontext-pro",
priceNote: "fal 组 0.12/次",
}),
bflAsyncEditPreset({
sortOrder: 20,
modelId: "bfl/flux-kontext-max",
displayName: "BFL Flux Kontext Max",
endpoint: "/bfl/v1/flux-kontext-max",
priceNote: "fal 组 0.24/次",
}),
];

View File

@ -3,10 +3,10 @@ import "server-only";
import sharp from "sharp";
import { uploadFile } from "@/lib/storage/service";
interface BflImageConfig {
type BflImageConfig = {
apiKey: string;
baseURL: string;
}
};
type UploadedImage = {
thumbnail: string;
@ -41,7 +41,12 @@ type BflSubmitResponse = {
detail?: string;
};
const DEFAULT_TIMEOUT_MS = 180000;
const DEFAULT_TIMEOUT_MS = 180_000;
const TRAILING_SLASHES_REGEX = /\/+$/;
const TRAILING_V1_REGEX = /\/v1$/i;
const BLTCY_HOST_REGEX = /(^|\.)bltcy\.ai$/i;
const BFL_MODEL_PREFIX_REGEX = /^bfl\//i;
const KONTEXT_MODEL_REGEX = /kontext/i;
const SUPPORTED_ASPECT_RATIOS = [
"1:1",
"2:3",
@ -55,9 +60,30 @@ const SUPPORTED_ASPECT_RATIOS = [
] as const;
function normalizeBaseURL(baseURL: string) {
const normalized = String(baseURL || "").trim().replace(/\/+$/, "");
if (!normalized) return "https://api.bfl.ai";
return /\/v1$/i.test(normalized) ? normalized.slice(0, -3) : normalized;
const normalized = String(baseURL || "").trim().replace(TRAILING_SLASHES_REGEX, "");
if (!normalized) {
return "https://api.bfl.ai";
}
return TRAILING_V1_REGEX.test(normalized) ? normalized.slice(0, -3) : normalized;
}
function isBltcyBaseURL(baseURL: string) {
try {
const parsed = new URL(String(baseURL || "").trim() || "https://api.bfl.ai");
return BLTCY_HOST_REGEX.test(parsed.hostname);
} catch {
return false;
}
}
function normalizeBltcyBaseURL(baseURL: string) {
const fallback = "https://api.bltcy.ai";
try {
const parsed = new URL(String(baseURL || "").trim() || fallback);
return `${parsed.protocol}//${parsed.host}`;
} catch {
return fallback;
}
}
function encodeModelPath(modelId: string) {
@ -68,6 +94,51 @@ function encodeModelPath(modelId: string) {
.join("/");
}
function normalizeBflModelPath(modelId: string) {
return String(modelId || "").trim().replace(BFL_MODEL_PREFIX_REGEX, "");
}
function createAuthHeaders(baseURL: string, apiKey: string): Record<string, string> {
if (isBltcyBaseURL(baseURL)) {
return {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
Accept: "application/json",
};
}
return {
"x-key": apiKey,
"Content-Type": "application/json",
Accept: "application/json",
};
}
function createSubmitEndpoint(baseURL: string, model: string) {
const modelPath = encodeModelPath(normalizeBflModelPath(model));
if (isBltcyBaseURL(baseURL)) {
return `${normalizeBltcyBaseURL(baseURL)}/bfl/v1/${modelPath}`;
}
return `${normalizeBaseURL(baseURL)}/v1/${modelPath}`;
}
function createPollingEndpoint(baseURL: string, pollingUrl?: string, taskId?: string) {
const normalizedTaskId = String(taskId || "").trim();
if (isBltcyBaseURL(baseURL) && normalizedTaskId) {
return `${normalizeBltcyBaseURL(baseURL)}/bfl/v1/get_result?id=${encodeURIComponent(
normalizedTaskId
)}`;
}
const normalizedPollingUrl = String(pollingUrl || "").trim();
if (normalizedPollingUrl) {
return normalizedPollingUrl;
}
return `${normalizeBaseURL(baseURL)}/v1/get_result?id=${encodeURIComponent(
normalizedTaskId
)}`;
}
function normalizeImageSizeLabel(imageSize?: string): "1K" | "2K" | "4K" {
const normalized = String(imageSize || "").trim().toUpperCase();
if (normalized === "1K" || normalized === "2K" || normalized === "4K") return normalized;
@ -302,28 +373,27 @@ async function submitBflImageRequest(params: {
apiKey: string;
model: string;
prompt: string;
imageUrl: string;
imageUrl?: string;
width: number;
height: number;
seed?: number;
}) {
const endpoint = `${normalizeBaseURL(params.baseURL)}/v1/${encodeModelPath(params.model)}`;
const endpoint = createSubmitEndpoint(params.baseURL, params.model);
const normalizedModel = normalizeBflModelPath(params.model);
const isKontextModel = KONTEXT_MODEL_REGEX.test(normalizedModel);
const requestBody = {
prompt: params.prompt,
...(params.imageUrl ? { input_image: params.imageUrl } : {}),
...(!isKontextModel ? { width: params.width, height: params.height } : {}),
output_format: "jpeg",
safety_tolerance: 2,
...(typeof params.seed === "number" ? { seed: params.seed } : {}),
};
const response = await fetch(endpoint, {
method: "POST",
headers: {
"x-key": params.apiKey,
"Content-Type": "application/json",
Accept: "application/json",
},
body: JSON.stringify({
prompt: params.prompt,
input_image: params.imageUrl,
width: params.width,
height: params.height,
output_format: "jpeg",
safety_tolerance: 2,
...(typeof params.seed === "number" ? { seed: params.seed } : {}),
}),
headers: createAuthHeaders(params.baseURL, params.apiKey),
body: JSON.stringify(requestBody),
cache: "no-store",
});
@ -357,16 +427,15 @@ async function pollBflResult(params: {
}) {
const deadline = Date.now() + DEFAULT_TIMEOUT_MS;
while (Date.now() < deadline) {
const pollingUrl =
String(params.pollingUrl || "").trim() ||
`${normalizeBaseURL(params.baseURL)}/v1/get_result?id=${encodeURIComponent(String(params.taskId || ""))}`;
const pollingUrl = createPollingEndpoint(
params.baseURL,
params.pollingUrl,
params.taskId
);
const response = await fetch(pollingUrl, {
method: "GET",
headers: {
"x-key": params.apiKey,
Accept: "application/json",
},
headers: createAuthHeaders(params.baseURL, params.apiKey),
cache: "no-store",
});
@ -409,10 +478,6 @@ export function createBflImage({ apiKey, baseURL }: BflImageConfig) {
overrideModelId?: string;
}) => {
const { prompt, imageUrls } = extractPromptAndImages(options.messages);
if (imageUrls.length === 0) {
throw new Error("BFL 图片编辑模型需要至少上传一张图片。");
}
const activeModelId = options.overrideModelId || modelId;
const imageSize = normalizeImageSizeLabel(options.imageParams?.imageSize);
const normalizedAspectRatio = normalizeAspectRatio(options.imageParams?.aspectRatio);

View File

@ -3,6 +3,7 @@ import "server-only";
import { getEnabledAiModelsWithProvider, getAiProviderById } from "@/lib/db/queries";
import { createGeminiImage } from "@/lib/ai/providers/gemini-image";
import { createAlibabaImage } from "@/lib/ai/providers/alibaba-image";
import { createBflImage } from "@/lib/ai/providers/bfl-image";
import { createRuxaImage } from "@/lib/ai/providers/ruxa-image";
import { createFalImage } from "@/lib/ai/providers/fal-image";
import { createOpenAIImage } from "@/lib/ai/providers/openai-image";
@ -39,6 +40,7 @@ export interface CanvasGenerateImageResult {
const SUPPORTED_CANVAS_PROVIDER_TYPES = new Set([
"gemini",
"alibaba-image",
"bfl-image",
"ruxa-image",
"fal-image",
"openai-image",
@ -80,6 +82,7 @@ function parseImagesFromMarkdown(markdown: string): ParsedImage[] {
function getProviderBatchLimit(providerType: string) {
switch (providerType) {
case "ruxa-image":
case "bfl-image":
case "openai-image":
case "midjourney":
case "runway":
@ -166,7 +169,7 @@ export async function generateCanvasImage(
}
if (!SUPPORTED_CANVAS_PROVIDER_TYPES.has(fallbackProvider.type)) {
throw new Error(
"当前图片模型类型暂不支持画布生成,请在后台选择 Gemini/阿里/RUXA/FAL/OpenAI/Runway/Krea 图片模型。"
"当前图片模型类型暂不支持画布生成,请在后台选择 Gemini/阿里/BFL/RUXA/FAL/OpenAI/Runway/Krea 图片模型。"
);
}
if (fallbackModel.model.id !== selectedModel.model.id) {
@ -276,6 +279,19 @@ export async function generateCanvasImage(
});
return result.text || "";
};
} else if (provider.type === "bfl-image") {
const bflProvider = createBflImage({
apiKey: provider.apiKey,
baseURL: provider.baseUrl,
});
const modelInstance = bflProvider.chat(selectedModel.model.modelId);
executeGenerateBatch = async (_batchImageCount) => {
const result = await modelInstance.doGenerate({
messages,
imageParams,
});
return result.text || "";
};
} else if (provider.type === "ruxa-image") {
const ruxaProvider = createRuxaImage({
apiKey: provider.apiKey,
@ -371,7 +387,7 @@ export async function generateCanvasImage(
};
} else {
throw new Error(
`不支持的图片生成模型类型: ${provider.type || "unknown"},请改用 Gemini/阿里/RUXA/FAL/OpenAI/Midjourney/Runway/Krea 图片模型`
`不支持的图片生成模型类型: ${provider.type || "unknown"},请改用 Gemini/阿里/BFL/RUXA/FAL/OpenAI/Midjourney/Runway/Krea 图片模型`
);
}