feat: add BLTCY Flux image providers
This commit is contained in:
parent
0b1062a31f
commit
0edd9872ea
@ -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>
|
||||
)}
|
||||
|
||||
|
||||
153
app/api/admin/bltcy-bfl-models/route.ts
Normal file
153
app/api/admin/bltcy-bfl-models/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
160
app/api/admin/bltcy-image-models/route.ts
Normal file
160
app/api/admin/bltcy-image-models/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
175
lib/ai/bltcy-image-models.ts
Normal file
175
lib/ai/bltcy-image-models.ts
Normal 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/次",
|
||||
}),
|
||||
];
|
||||
@ -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);
|
||||
|
||||
@ -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 图片模型`
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user