feat: default external image api to img2img failover
This commit is contained in:
parent
11c2f8f423
commit
cc320f6334
175
app/api/external/image/generate/route.ts
vendored
175
app/api/external/image/generate/route.ts
vendored
@ -6,29 +6,145 @@ import { externalImageApiRequestLog } from "@/lib/db/schema";
|
|||||||
import { saveGeneratedImages } from "@/lib/ai/runtime/generated-image-persistence";
|
import { saveGeneratedImages } from "@/lib/ai/runtime/generated-image-persistence";
|
||||||
import {
|
import {
|
||||||
authenticateExternalImageApiRequest,
|
authenticateExternalImageApiRequest,
|
||||||
|
buildExternalImageApiModelCandidates,
|
||||||
buildExternalImageApiKeyFingerprint,
|
buildExternalImageApiKeyFingerprint,
|
||||||
ensureHostedImageMarkdown,
|
ensureHostedImageMarkdown,
|
||||||
|
ensureHostedReferenceImageUrls,
|
||||||
extractGeneratedImagesFromMarkdown,
|
extractGeneratedImagesFromMarkdown,
|
||||||
generateImagesWithModel,
|
generateImagesWithFailover,
|
||||||
getRequesterIp,
|
getRequesterIp,
|
||||||
resolveExternalImageApiModel,
|
|
||||||
resolveExternalImageApiOwnerUserId,
|
resolveExternalImageApiOwnerUserId,
|
||||||
serializeExternalImageApiModel,
|
serializeExternalImageApiModel,
|
||||||
|
uploadExternalImageApiFile,
|
||||||
} from "@/lib/external-image-api/service";
|
} from "@/lib/external-image-api/service";
|
||||||
|
|
||||||
export const runtime = "nodejs";
|
export const runtime = "nodejs";
|
||||||
export const maxDuration = 120;
|
export const maxDuration = 120;
|
||||||
|
|
||||||
const requestSchema = z.object({
|
const requestSchema = z.object({
|
||||||
model: z.string().trim().min(1).optional(),
|
|
||||||
prompt: z.string().trim().min(1, "prompt 不能为空"),
|
prompt: z.string().trim().min(1, "prompt 不能为空"),
|
||||||
negativePrompt: z.string().trim().optional(),
|
negativePrompt: z.string().trim().optional(),
|
||||||
imageCount: z.coerce.number().int().min(1).max(8).optional(),
|
imageCount: z.coerce.number().int().min(1).max(8).optional(),
|
||||||
aspectRatio: z.string().trim().max(32).optional(),
|
aspectRatio: z.string().trim().max(32).optional(),
|
||||||
imageSize: z.string().trim().max(32).optional(),
|
imageSize: z.string().trim().max(32).optional(),
|
||||||
metadata: z.record(z.string(), z.any()).optional(),
|
metadata: z.record(z.string(), z.any()).optional(),
|
||||||
|
referenceImages: z.array(z.string().trim().min(1)).max(14).optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function normalizeString(value: FormDataEntryValue | string | null | undefined) {
|
||||||
|
return String(value || "").trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseOptionalInteger(value: FormDataEntryValue | null) {
|
||||||
|
const normalized = normalizeString(value);
|
||||||
|
if (!normalized) return undefined;
|
||||||
|
const parsed = Number(normalized);
|
||||||
|
return Number.isFinite(parsed) ? parsed : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseOptionalJsonRecord(value: FormDataEntryValue | null) {
|
||||||
|
const normalized = normalizeString(value);
|
||||||
|
if (!normalized) return undefined;
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(normalized);
|
||||||
|
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
||||||
|
return parsed as Record<string, unknown>;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore invalid metadata JSON
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeReferenceImageInputs(value: unknown): string[] {
|
||||||
|
if (!value) return [];
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
return value
|
||||||
|
.flatMap((item) => normalizeReferenceImageInputs(item))
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
|
if (typeof value === "string") {
|
||||||
|
return value
|
||||||
|
.split(",")
|
||||||
|
.map((item) => item.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function parseRequestPayload(request: Request) {
|
||||||
|
const contentType = request.headers.get("content-type") || "";
|
||||||
|
if (contentType.toLowerCase().includes("multipart/form-data")) {
|
||||||
|
const formData = await request.formData();
|
||||||
|
const fileEntries = [
|
||||||
|
...formData.getAll("images"),
|
||||||
|
...formData.getAll("image"),
|
||||||
|
...formData.getAll("referenceImages"),
|
||||||
|
...formData.getAll("referenceImage"),
|
||||||
|
...formData.getAll("files"),
|
||||||
|
];
|
||||||
|
const files = fileEntries.filter((item): item is File => item instanceof File);
|
||||||
|
const uploadedReferenceImages = await Promise.all(
|
||||||
|
files.map((file) => uploadExternalImageApiFile(file)),
|
||||||
|
);
|
||||||
|
|
||||||
|
const inlineReferenceImages = [
|
||||||
|
...formData.getAll("imageUrls"),
|
||||||
|
...formData.getAll("imageUrl"),
|
||||||
|
...formData.getAll("referenceImageUrls"),
|
||||||
|
...formData.getAll("referenceImages"),
|
||||||
|
...formData.getAll("referenceImage"),
|
||||||
|
]
|
||||||
|
.filter((item): item is string => typeof item === "string")
|
||||||
|
.flatMap((item) => normalizeReferenceImageInputs(item));
|
||||||
|
|
||||||
|
const hostedInlineImages = await ensureHostedReferenceImageUrls(
|
||||||
|
inlineReferenceImages,
|
||||||
|
);
|
||||||
|
|
||||||
|
const parsed = requestSchema.parse({
|
||||||
|
prompt: normalizeString(formData.get("prompt")),
|
||||||
|
negativePrompt: normalizeString(formData.get("negativePrompt")) || undefined,
|
||||||
|
imageCount: parseOptionalInteger(formData.get("imageCount")),
|
||||||
|
aspectRatio: normalizeString(formData.get("aspectRatio")) || undefined,
|
||||||
|
imageSize: normalizeString(formData.get("imageSize")) || undefined,
|
||||||
|
metadata: parseOptionalJsonRecord(formData.get("metadata")),
|
||||||
|
referenceImages: [...uploadedReferenceImages, ...hostedInlineImages],
|
||||||
|
});
|
||||||
|
if (!parsed.referenceImages?.length) {
|
||||||
|
throw new z.ZodError([
|
||||||
|
{
|
||||||
|
code: "custom",
|
||||||
|
path: ["referenceImages"],
|
||||||
|
message: "至少上传 1 张参考图",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
const json = await request.json();
|
||||||
|
const parsed = requestSchema.parse({
|
||||||
|
...json,
|
||||||
|
referenceImages: await ensureHostedReferenceImageUrls([
|
||||||
|
...normalizeReferenceImageInputs((json as any)?.referenceImages),
|
||||||
|
...normalizeReferenceImageInputs((json as any)?.imageUrls),
|
||||||
|
...normalizeReferenceImageInputs((json as any)?.images),
|
||||||
|
...normalizeReferenceImageInputs((json as any)?.image),
|
||||||
|
]),
|
||||||
|
});
|
||||||
|
if (!parsed.referenceImages?.length) {
|
||||||
|
throw new z.ZodError([
|
||||||
|
{
|
||||||
|
code: "custom",
|
||||||
|
path: ["referenceImages"],
|
||||||
|
message: "至少上传 1 张参考图",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
|
||||||
export async function POST(request: Request) {
|
export async function POST(request: Request) {
|
||||||
const authResult = await authenticateExternalImageApiRequest(request);
|
const authResult = await authenticateExternalImageApiRequest(request);
|
||||||
if (!authResult.accessKey) {
|
if (!authResult.accessKey) {
|
||||||
@ -45,20 +161,11 @@ export async function POST(request: Request) {
|
|||||||
let logId: string | null = null;
|
let logId: string | null = null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const json = await request.json();
|
const payload = await parseRequestPayload(request);
|
||||||
const parsed = requestSchema.safeParse(json);
|
const candidateModels = await buildExternalImageApiModelCandidates({
|
||||||
if (!parsed.success) {
|
referenceImageCount: payload.referenceImages?.length || 0,
|
||||||
return NextResponse.json(
|
});
|
||||||
{
|
const selectedModel = candidateModels[0];
|
||||||
success: false,
|
|
||||||
error: parsed.error.errors[0]?.message || "参数错误",
|
|
||||||
},
|
|
||||||
{ status: 400 },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const payload = parsed.data;
|
|
||||||
const selectedModel = await resolveExternalImageApiModel(payload.model);
|
|
||||||
const ownerUserId = await resolveExternalImageApiOwnerUserId(
|
const ownerUserId = await resolveExternalImageApiOwnerUserId(
|
||||||
authResult.config.defaultOwnerEmail,
|
authResult.config.defaultOwnerEmail,
|
||||||
);
|
);
|
||||||
@ -84,6 +191,7 @@ export async function POST(request: Request) {
|
|||||||
requesterUserAgent,
|
requesterUserAgent,
|
||||||
requestPayload: {
|
requestPayload: {
|
||||||
...payload,
|
...payload,
|
||||||
|
candidateModels: candidateModels.map(serializeExternalImageApiModel),
|
||||||
apiKeyFingerprint: buildExternalImageApiKeyFingerprint(
|
apiKeyFingerprint: buildExternalImageApiKeyFingerprint(
|
||||||
authResult.accessKey,
|
authResult.accessKey,
|
||||||
),
|
),
|
||||||
@ -93,14 +201,17 @@ export async function POST(request: Request) {
|
|||||||
|
|
||||||
logId = createdLog?.id || null;
|
logId = createdLog?.id || null;
|
||||||
|
|
||||||
const rawMarkdown = await generateImagesWithModel({
|
const generationResult = await generateImagesWithFailover({
|
||||||
modelDbId: selectedModel.model.id,
|
candidates: candidateModels,
|
||||||
prompt: payload.prompt,
|
prompt: payload.prompt,
|
||||||
negativePrompt: payload.negativePrompt || null,
|
negativePrompt: payload.negativePrompt || null,
|
||||||
imageCount: payload.imageCount || 1,
|
imageCount: payload.imageCount || 1,
|
||||||
aspectRatio: payload.aspectRatio || null,
|
aspectRatio: payload.aspectRatio || null,
|
||||||
imageSize: payload.imageSize || null,
|
imageSize: payload.imageSize || null,
|
||||||
|
referenceImageUrls: payload.referenceImages || [],
|
||||||
});
|
});
|
||||||
|
const actualModel = generationResult.usedModel;
|
||||||
|
const rawMarkdown = generationResult.markdown;
|
||||||
|
|
||||||
const hostedMarkdown = await ensureHostedImageMarkdown(
|
const hostedMarkdown = await ensureHostedImageMarkdown(
|
||||||
rawMarkdown,
|
rawMarkdown,
|
||||||
@ -117,7 +228,7 @@ export async function POST(request: Request) {
|
|||||||
null,
|
null,
|
||||||
null,
|
null,
|
||||||
payload.prompt,
|
payload.prompt,
|
||||||
selectedModel.model.id,
|
actualModel.model.id,
|
||||||
{
|
{
|
||||||
sourceType: "external_api_generated",
|
sourceType: "external_api_generated",
|
||||||
userPrompt: payload.prompt,
|
userPrompt: payload.prompt,
|
||||||
@ -125,6 +236,8 @@ export async function POST(request: Request) {
|
|||||||
requestedImageCount: payload.imageCount || images.length,
|
requestedImageCount: payload.imageCount || images.length,
|
||||||
aspectRatio: payload.aspectRatio,
|
aspectRatio: payload.aspectRatio,
|
||||||
imageSize: payload.imageSize,
|
imageSize: payload.imageSize,
|
||||||
|
sourceImageUrls: payload.referenceImages || [],
|
||||||
|
relationType: "edited_from",
|
||||||
skipBilling: true,
|
skipBilling: true,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@ -133,11 +246,15 @@ export async function POST(request: Request) {
|
|||||||
await db
|
await db
|
||||||
.update(externalImageApiRequestLog)
|
.update(externalImageApiRequestLog)
|
||||||
.set({
|
.set({
|
||||||
|
modelDbId: actualModel.model.id,
|
||||||
|
modelRuntimeId: actualModel.model.modelId,
|
||||||
|
modelDisplayName: actualModel.model.displayName,
|
||||||
status: "succeeded",
|
status: "succeeded",
|
||||||
responsePayload: {
|
responsePayload: {
|
||||||
model: serializeExternalImageApiModel(selectedModel),
|
model: serializeExternalImageApiModel(actualModel),
|
||||||
markdown: hostedMarkdown,
|
markdown: hostedMarkdown,
|
||||||
images,
|
images,
|
||||||
|
attempts: generationResult.attempts,
|
||||||
},
|
},
|
||||||
generatedImageIds: persistResult.savedImageIds,
|
generatedImageIds: persistResult.savedImageIds,
|
||||||
generatedAssetIds: persistResult.savedAssetIds,
|
generatedAssetIds: persistResult.savedAssetIds,
|
||||||
@ -152,21 +269,35 @@ export async function POST(request: Request) {
|
|||||||
success: true,
|
success: true,
|
||||||
data: {
|
data: {
|
||||||
requestId: logId,
|
requestId: logId,
|
||||||
model: serializeExternalImageApiModel(selectedModel),
|
model: serializeExternalImageApiModel(actualModel),
|
||||||
images,
|
images,
|
||||||
generatedImageIds: persistResult.savedImageIds,
|
generatedImageIds: persistResult.savedImageIds,
|
||||||
generatedAssetIds: persistResult.savedAssetIds,
|
generatedAssetIds: persistResult.savedAssetIds,
|
||||||
|
attempts: generationResult.attempts,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("[External Image API] Generate failed:", error);
|
console.error("[External Image API] Generate failed:", error);
|
||||||
|
|
||||||
|
if (error instanceof z.ZodError) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error: error.errors[0]?.message || "参数错误",
|
||||||
|
},
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (logId) {
|
if (logId) {
|
||||||
await db
|
await db
|
||||||
.update(externalImageApiRequestLog)
|
.update(externalImageApiRequestLog)
|
||||||
.set({
|
.set({
|
||||||
status: "failed",
|
status: "failed",
|
||||||
errorMessage: error instanceof Error ? error.message : "图片生成失败",
|
errorMessage: error instanceof Error ? error.message : "图片生成失败",
|
||||||
|
responsePayload: {
|
||||||
|
error: error instanceof Error ? error.message : "图片生成失败",
|
||||||
|
},
|
||||||
durationMs: Date.now() - startedAt,
|
durationMs: Date.now() - startedAt,
|
||||||
completedAt: new Date(),
|
completedAt: new Date(),
|
||||||
})
|
})
|
||||||
|
|||||||
@ -12,7 +12,8 @@
|
|||||||
2. 发起生图
|
2. 发起生图
|
||||||
`POST /api/external/image/generate`
|
`POST /api/external/image/generate`
|
||||||
|
|
||||||
这套接口直接复用我们系统后台已启用的生图模型与模型名称。
|
这套接口是 **图生图接口**。第三方调用方只需要上传参考图和提示词,**不需要也不允许指定内部模型名**。
|
||||||
|
我们服务端会按后台默认优先级自动选择模型,优先尝试 `GPT-image-2`,失败后自动故障迁移到其他可用图生图模型,尽量返回图片。
|
||||||
生成结果图片会先上传到我们的储存桶,再写入我们的数据库与媒体资产表。
|
生成结果图片会先上传到我们的储存桶,再写入我们的数据库与媒体资产表。
|
||||||
|
|
||||||
## 2. 鉴权方式
|
## 2. 鉴权方式
|
||||||
@ -33,12 +34,12 @@ x-api-key: YOUR_API_KEY
|
|||||||
|
|
||||||
如果 API Key 无效、已停用,或后台未启用第三方生图 API,接口会返回 `401` 或 `403`。
|
如果 API Key 无效、已停用,或后台未启用第三方生图 API,接口会返回 `401` 或 `403`。
|
||||||
|
|
||||||
## 3. 获取模型列表
|
## 3. 获取模型列表(可选)
|
||||||
|
|
||||||
### 请求
|
### 请求
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -X GET "https://你的域名/api/external/image/models" \
|
curl -X GET "https://ai.yydd.ai/api/external/image/models" \
|
||||||
-H "Authorization: Bearer YOUR_API_KEY"
|
-H "Authorization: Bearer YOUR_API_KEY"
|
||||||
```
|
```
|
||||||
|
|
||||||
@ -56,7 +57,7 @@ curl -X GET "https://你的域名/api/external/image/models" \
|
|||||||
"description": "轻量版商品生图模型",
|
"description": "轻量版商品生图模型",
|
||||||
"providerType": "volcano-vision",
|
"providerType": "volcano-vision",
|
||||||
"providerName": "火山视觉",
|
"providerName": "火山视觉",
|
||||||
"avatarUrl": "https://你的域名/uploads/xxx.png",
|
"avatarUrl": "https://ai.yydd.ai/uploads/xxx.png",
|
||||||
"imageGenConfig": {
|
"imageGenConfig": {
|
||||||
"creditCost": 20
|
"creditCost": 20
|
||||||
}
|
}
|
||||||
@ -68,20 +69,25 @@ curl -X GET "https://你的域名/api/external/image/models" \
|
|||||||
|
|
||||||
### 字段说明
|
### 字段说明
|
||||||
|
|
||||||
- `id`:系统内部模型 UUID,推荐第三方优先使用这个值调用生成接口
|
- `id`:系统内部模型 UUID
|
||||||
- `modelId`:上游模型 ID
|
- `modelId`:上游模型 ID
|
||||||
- `displayName`:系统中展示给用户的模型名称
|
- `displayName`:系统中展示给用户的模型名称
|
||||||
- `providerType`:模型服务商类型
|
- `providerType`:模型服务商类型
|
||||||
- `providerName`:模型服务商显示名称
|
- `providerName`:模型服务商显示名称
|
||||||
- `imageGenConfig`:当前模型的生图配置,包含积分等后台配置
|
- `imageGenConfig`:当前模型的生图配置,包含积分等后台配置
|
||||||
|
|
||||||
|
说明:
|
||||||
|
|
||||||
|
- 这个接口主要用于联调排查或查看当前可用能力
|
||||||
|
- 正式调用生图接口时,第三方 **无需传模型名**
|
||||||
|
|
||||||
## 4. 发起生图
|
## 4. 发起生图
|
||||||
|
|
||||||
### 请求地址
|
### 请求地址
|
||||||
|
|
||||||
```http
|
```http
|
||||||
POST /api/external/image/generate
|
POST https://ai.yydd.ai/api/external/image/generate
|
||||||
Content-Type: application/json
|
Content-Type: application/json 或 multipart/form-data
|
||||||
Authorization: Bearer YOUR_API_KEY
|
Authorization: Bearer YOUR_API_KEY
|
||||||
```
|
```
|
||||||
|
|
||||||
@ -89,12 +95,15 @@ Authorization: Bearer YOUR_API_KEY
|
|||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"model": "9b8f7c10-1111-4d22-9abc-1234567890ab",
|
"prompt": "保留原空间结构,将这张衣帽间草图改成真实摄影效果,木色更高级,灯光柔和",
|
||||||
"prompt": "请生成一张高端现代客厅空间图,暖光,真实摄影感,适合商业首页展示",
|
|
||||||
"negativePrompt": "不要人物,不要文字水印,不要畸变",
|
"negativePrompt": "不要人物,不要文字水印,不要畸变",
|
||||||
"imageCount": 2,
|
"imageCount": 2,
|
||||||
"aspectRatio": "16:9",
|
"aspectRatio": "16:9",
|
||||||
"imageSize": "2K",
|
"imageSize": "2K",
|
||||||
|
"referenceImages": [
|
||||||
|
"https://ai.yydd.ai/uploads/source-1.jpg",
|
||||||
|
"https://ai.yydd.ai/uploads/source-2.jpg"
|
||||||
|
],
|
||||||
"metadata": {
|
"metadata": {
|
||||||
"bizId": "partner-order-20260518-001"
|
"bizId": "partner-order-20260518-001"
|
||||||
}
|
}
|
||||||
@ -103,17 +112,22 @@ Authorization: Bearer YOUR_API_KEY
|
|||||||
|
|
||||||
### 参数说明
|
### 参数说明
|
||||||
|
|
||||||
- `model`:可传以下任意一种
|
|
||||||
- 模型 UUID `id`
|
|
||||||
- 模型运行 ID `modelId`
|
|
||||||
- 模型显示名 `displayName`
|
|
||||||
- `prompt`:正向提示词,必填
|
- `prompt`:正向提示词,必填
|
||||||
- `negativePrompt`:反向提示词,可选
|
- `negativePrompt`:反向提示词,可选
|
||||||
- `imageCount`:生成张数,可选,默认 `1`,最大 `8`
|
- `imageCount`:生成张数,可选,默认 `1`,最大 `8`
|
||||||
- `aspectRatio`:宽高比,可选,例如 `1:1`、`16:9`
|
- `aspectRatio`:宽高比,可选,例如 `1:1`、`16:9`
|
||||||
- `imageSize`:图片规格,可选,例如 `1K`、`2K`
|
- `imageSize`:图片规格,可选,例如 `1K`、`2K`
|
||||||
|
- `referenceImages`:参考图地址数组,必填,至少 `1` 张,最多 `14` 张
|
||||||
- `metadata`:业务扩展字段,可选,会原样记录到请求日志里
|
- `metadata`:业务扩展字段,可选,会原样记录到请求日志里
|
||||||
|
|
||||||
|
### multipart/form-data 调用方式
|
||||||
|
|
||||||
|
可直接上传图片文件,不必先把图片上传到公网:
|
||||||
|
|
||||||
|
- 文本字段:`prompt`、`negativePrompt`、`imageCount`、`aspectRatio`、`imageSize`
|
||||||
|
- 文件字段:`images`(可重复上传多张)
|
||||||
|
- 也支持混合传参:文件字段 `images` + URL 字段 `imageUrls`
|
||||||
|
|
||||||
## 5. 生图成功响应
|
## 5. 生图成功响应
|
||||||
|
|
||||||
```json
|
```json
|
||||||
@ -136,14 +150,14 @@ Authorization: Bearer YOUR_API_KEY
|
|||||||
"images": [
|
"images": [
|
||||||
{
|
{
|
||||||
"id": 1,
|
"id": 1,
|
||||||
"originalUrl": "https://你的域名/uploads/2026xxxx-orig.jpg",
|
"originalUrl": "https://ai.yydd.ai/uploads/2026xxxx-orig.jpg",
|
||||||
"thumbnailUrl": "https://你的域名/uploads/2026xxxx-orig.jpg",
|
"thumbnailUrl": "https://ai.yydd.ai/uploads/2026xxxx-orig.jpg",
|
||||||
"imageSize": "2K"
|
"imageSize": "2K"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 2,
|
"id": 2,
|
||||||
"originalUrl": "https://你的域名/uploads/2026yyyy-orig.jpg",
|
"originalUrl": "https://ai.yydd.ai/uploads/2026yyyy-orig.jpg",
|
||||||
"thumbnailUrl": "https://你的域名/uploads/2026yyyy-orig.jpg",
|
"thumbnailUrl": "https://ai.yydd.ai/uploads/2026yyyy-orig.jpg",
|
||||||
"imageSize": "2K"
|
"imageSize": "2K"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
@ -152,6 +166,14 @@ Authorization: Bearer YOUR_API_KEY
|
|||||||
],
|
],
|
||||||
"generatedAssetIds": [
|
"generatedAssetIds": [
|
||||||
"b7b19e10-1111-4db8-aeb2-1234567890ab"
|
"b7b19e10-1111-4db8-aeb2-1234567890ab"
|
||||||
|
],
|
||||||
|
"attempts": [
|
||||||
|
{
|
||||||
|
"modelId": "07e2304a-a63a-4fd3-9c76-63ba98de464d",
|
||||||
|
"modelRuntimeId": "openai/gpt-image-2/edit",
|
||||||
|
"displayName": "GPT-image-2",
|
||||||
|
"success": true
|
||||||
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -166,6 +188,7 @@ Authorization: Bearer YOUR_API_KEY
|
|||||||
- `imageSize`:记录的图片规格
|
- `imageSize`:记录的图片规格
|
||||||
- `generatedImageIds`:写入 `GeneratedImage` 表后的记录 ID
|
- `generatedImageIds`:写入 `GeneratedImage` 表后的记录 ID
|
||||||
- `generatedAssetIds`:写入媒体资产表后的记录 ID
|
- `generatedAssetIds`:写入媒体资产表后的记录 ID
|
||||||
|
- `attempts`:本次自动选模 / 故障迁移尝试明细
|
||||||
|
|
||||||
## 6. 失败响应示例
|
## 6. 失败响应示例
|
||||||
|
|
||||||
@ -180,14 +203,14 @@ Authorization: Bearer YOUR_API_KEY
|
|||||||
|
|
||||||
- API Key 缺失或无效
|
- API Key 缺失或无效
|
||||||
- 第三方生图 API 后台未开启
|
- 第三方生图 API 后台未开启
|
||||||
- 请求的模型不存在或未启用
|
- 未上传参考图
|
||||||
- 上游模型生成失败
|
- 上游模型生成失败
|
||||||
- 上游返回内容中未解析出图片
|
- 上游返回内容中未解析出图片
|
||||||
|
|
||||||
## 7. 调用建议
|
## 7. 调用建议
|
||||||
|
|
||||||
1. 先调用模型列表接口,缓存可用模型
|
1. 这条接口按“图生图”设计,请始终上传至少 1 张参考图
|
||||||
2. 生成时优先传模型 UUID `id`,最稳定
|
2. 第三方不要传模型名,交给服务端按默认优先级和故障迁移处理
|
||||||
3. 将返回的 `requestId` 保存到业务日志,便于双方排查问题
|
3. 将返回的 `requestId` 保存到业务日志,便于双方排查问题
|
||||||
4. 返回的图片地址已经是我们系统储存桶地址,可以直接展示或下载
|
4. 返回的图片地址已经是我们系统储存桶地址,可以直接展示或下载
|
||||||
|
|
||||||
|
|||||||
@ -16,6 +16,13 @@ import { extractImageUrlsFromText } from "@/lib/image-url";
|
|||||||
import { uploadFile } from "@/lib/storage/service";
|
import { uploadFile } from "@/lib/storage/service";
|
||||||
|
|
||||||
type ImageModelEntry = Awaited<ReturnType<typeof getEnabledAiModelsWithProvider>>[number];
|
type ImageModelEntry = Awaited<ReturnType<typeof getEnabledAiModelsWithProvider>>[number];
|
||||||
|
type ExternalImageApiGenerateAttempt = {
|
||||||
|
modelId: string;
|
||||||
|
modelRuntimeId: string;
|
||||||
|
displayName: string;
|
||||||
|
success: boolean;
|
||||||
|
error?: string;
|
||||||
|
};
|
||||||
|
|
||||||
function normalizeText(value: unknown) {
|
function normalizeText(value: unknown) {
|
||||||
return String(value || "").trim();
|
return String(value || "").trim();
|
||||||
@ -30,6 +37,16 @@ function mimeTypeToExtension(mimeType: string) {
|
|||||||
return "jpg";
|
return "jpg";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function sanitizeUploadFilename(filename: string, fallbackPrefix: string) {
|
||||||
|
const normalized = String(filename || "")
|
||||||
|
.trim()
|
||||||
|
.replace(/[^\w.\-]+/g, "-")
|
||||||
|
.replace(/-+/g, "-")
|
||||||
|
.replace(/^-|-$/g, "");
|
||||||
|
if (normalized) return normalized;
|
||||||
|
return `${fallbackPrefix}-${Date.now()}.png`;
|
||||||
|
}
|
||||||
|
|
||||||
function getExtensionFromUrl(url: string): string {
|
function getExtensionFromUrl(url: string): string {
|
||||||
try {
|
try {
|
||||||
const pathname = new URL(url).pathname;
|
const pathname = new URL(url).pathname;
|
||||||
@ -136,6 +153,146 @@ async function ensureHostedImageUrl(imageUrl: string): Promise<string> {
|
|||||||
return uploadRemoteImageToStorage(normalizedUrl);
|
return uploadRemoteImageToStorage(normalizedUrl);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function toRecord(value: unknown): Record<string, unknown> | null {
|
||||||
|
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
|
||||||
|
return value as Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSupportedImageModes(capabilities: unknown) {
|
||||||
|
const raw = toRecord(capabilities)?.supportedModes;
|
||||||
|
if (!Array.isArray(raw)) return new Set<string>();
|
||||||
|
return new Set(
|
||||||
|
raw
|
||||||
|
.map((item) => normalizeText(item).toLowerCase())
|
||||||
|
.filter(Boolean),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getMaxReferenceImages(capabilities: unknown): number | null {
|
||||||
|
const raw = toRecord(capabilities)?.maxReferenceImages;
|
||||||
|
const parsed = Number(raw);
|
||||||
|
if (!Number.isFinite(parsed) || parsed <= 0) return null;
|
||||||
|
return Math.round(parsed);
|
||||||
|
}
|
||||||
|
|
||||||
|
function supportsImageEditForReferenceCount(
|
||||||
|
entry: ImageModelEntry,
|
||||||
|
referenceImageCount: number,
|
||||||
|
) {
|
||||||
|
if (referenceImageCount <= 0) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const capabilities = toRecord(entry.model.capabilities);
|
||||||
|
const kind = normalizeText(capabilities?.kind).toLowerCase();
|
||||||
|
const supportedModes = getSupportedImageModes(entry.model.capabilities);
|
||||||
|
const maxReferenceImages = getMaxReferenceImages(entry.model.capabilities);
|
||||||
|
const autoRouteEditEndpoint = capabilities?.autoRouteEditEndpoint === true;
|
||||||
|
const supportsMultiImageReference =
|
||||||
|
capabilities?.supportsMultiImageReference === true;
|
||||||
|
const runtimeModelId = normalizeText(entry.model.modelId).toLowerCase();
|
||||||
|
|
||||||
|
const supportsReferenceEdit =
|
||||||
|
kind === "image-edit" ||
|
||||||
|
supportedModes.has("image-edit") ||
|
||||||
|
supportedModes.has("reference-images") ||
|
||||||
|
autoRouteEditEndpoint ||
|
||||||
|
runtimeModelId.endsWith("/edit");
|
||||||
|
|
||||||
|
if (!supportsReferenceEdit) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
maxReferenceImages !== null &&
|
||||||
|
referenceImageCount > maxReferenceImages
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (referenceImageCount > 1) {
|
||||||
|
if (maxReferenceImages !== null) {
|
||||||
|
return referenceImageCount <= maxReferenceImages;
|
||||||
|
}
|
||||||
|
return supportsMultiImageReference || autoRouteEditEndpoint;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPreferredGptImage2Model(entry: ImageModelEntry) {
|
||||||
|
const candidates = [
|
||||||
|
entry.model.displayName,
|
||||||
|
entry.model.modelId,
|
||||||
|
entry.model.id,
|
||||||
|
]
|
||||||
|
.map((item) =>
|
||||||
|
normalizeText(item)
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[\s_]+/g, "-"),
|
||||||
|
)
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
|
return candidates.some(
|
||||||
|
(item) =>
|
||||||
|
item.includes("gpt-image-2") ||
|
||||||
|
item.includes("gptimage2") ||
|
||||||
|
item.includes("gpt-image2"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function sortExternalImageApiModels(
|
||||||
|
models: ImageModelEntry[],
|
||||||
|
referenceImageCount: number,
|
||||||
|
) {
|
||||||
|
return [...models].sort((left, right) => {
|
||||||
|
const leftPreferred = isPreferredGptImage2Model(left) ? 1 : 0;
|
||||||
|
const rightPreferred = isPreferredGptImage2Model(right) ? 1 : 0;
|
||||||
|
if (leftPreferred !== rightPreferred) {
|
||||||
|
return rightPreferred - leftPreferred;
|
||||||
|
}
|
||||||
|
|
||||||
|
const leftRefSupport = supportsImageEditForReferenceCount(
|
||||||
|
left,
|
||||||
|
referenceImageCount,
|
||||||
|
)
|
||||||
|
? 1
|
||||||
|
: 0;
|
||||||
|
const rightRefSupport = supportsImageEditForReferenceCount(
|
||||||
|
right,
|
||||||
|
referenceImageCount,
|
||||||
|
)
|
||||||
|
? 1
|
||||||
|
: 0;
|
||||||
|
if (leftRefSupport !== rightRefSupport) {
|
||||||
|
return rightRefSupport - leftRefSupport;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (left.model.sortOrder ?? 0) - (right.model.sortOrder ?? 0);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function findExternalImageApiModelMatch(
|
||||||
|
models: ImageModelEntry[],
|
||||||
|
modelRef?: string | null,
|
||||||
|
) {
|
||||||
|
const normalizedRef = normalizeText(modelRef).toLowerCase();
|
||||||
|
if (!normalizedRef) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
models.find((entry) => {
|
||||||
|
const candidates = [
|
||||||
|
entry.model.id,
|
||||||
|
entry.model.modelId,
|
||||||
|
entry.model.displayName,
|
||||||
|
]
|
||||||
|
.map((item) => normalizeText(item).toLowerCase())
|
||||||
|
.filter(Boolean);
|
||||||
|
return candidates.includes(normalizedRef);
|
||||||
|
}) || null
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export async function ensureHostedImageMarkdown(
|
export async function ensureHostedImageMarkdown(
|
||||||
markdown: string,
|
markdown: string,
|
||||||
imageSize = "2K",
|
imageSize = "2K",
|
||||||
@ -257,17 +414,23 @@ export async function authenticateExternalImageApiRequest(request: Request) {
|
|||||||
export async function getExternalImageApiModels() {
|
export async function getExternalImageApiModels() {
|
||||||
const models = await getEnabledAiModelsWithProvider(false, false);
|
const models = await getEnabledAiModelsWithProvider(false, false);
|
||||||
|
|
||||||
return models.filter((entry) =>
|
return sortExternalImageApiModels(
|
||||||
isImageGenerationModelDescriptor({
|
models.filter(
|
||||||
providerType: entry.provider.type,
|
(entry) =>
|
||||||
modelId: entry.model.modelId,
|
isImageGenerationModelDescriptor({
|
||||||
capabilities: entry.model.capabilities,
|
providerType: entry.provider.type,
|
||||||
supportsImageGen: entry.model.supportsImageGen,
|
modelId: entry.model.modelId,
|
||||||
}),
|
capabilities: entry.model.capabilities,
|
||||||
|
supportsImageGen: entry.model.supportsImageGen,
|
||||||
|
}) && supportsImageEditForReferenceCount(entry, 1),
|
||||||
|
),
|
||||||
|
1,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function serializeExternalImageApiModel(entry: ImageModelEntry) {
|
export function serializeExternalImageApiModel(entry: ImageModelEntry) {
|
||||||
|
const capabilities = toRecord(entry.model.capabilities);
|
||||||
|
const maxReferenceImages = getMaxReferenceImages(entry.model.capabilities);
|
||||||
return {
|
return {
|
||||||
id: entry.model.id,
|
id: entry.model.id,
|
||||||
modelId: entry.model.modelId,
|
modelId: entry.model.modelId,
|
||||||
@ -277,38 +440,127 @@ export function serializeExternalImageApiModel(entry: ImageModelEntry) {
|
|||||||
providerName: entry.provider.name,
|
providerName: entry.provider.name,
|
||||||
avatarUrl: entry.model.avatarUrl,
|
avatarUrl: entry.model.avatarUrl,
|
||||||
imageGenConfig: entry.model.imageGenConfig,
|
imageGenConfig: entry.model.imageGenConfig,
|
||||||
|
supportsReferenceImages: supportsImageEditForReferenceCount(entry, 1),
|
||||||
|
supportsMultiImageReference:
|
||||||
|
capabilities?.supportsMultiImageReference === true ||
|
||||||
|
(maxReferenceImages !== null && maxReferenceImages > 1),
|
||||||
|
maxReferenceImages,
|
||||||
|
preferred: isPreferredGptImage2Model(entry),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function resolveExternalImageApiModel(modelRef?: string | null) {
|
export async function resolveExternalImageApiModel(
|
||||||
const models = await getExternalImageApiModels();
|
modelRef?: string | null,
|
||||||
|
options?: {
|
||||||
|
referenceImageCount?: number;
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
const referenceImageCount = Math.max(0, options?.referenceImageCount ?? 1);
|
||||||
|
const models = sortExternalImageApiModels(
|
||||||
|
(
|
||||||
|
await getEnabledAiModelsWithProvider(false, false)
|
||||||
|
).filter((entry) =>
|
||||||
|
isImageGenerationModelDescriptor({
|
||||||
|
providerType: entry.provider.type,
|
||||||
|
modelId: entry.model.modelId,
|
||||||
|
capabilities: entry.model.capabilities,
|
||||||
|
supportsImageGen: entry.model.supportsImageGen,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
referenceImageCount,
|
||||||
|
);
|
||||||
if (!models.length) {
|
if (!models.length) {
|
||||||
throw new Error("当前没有启用可用于生图的模型");
|
throw new Error("当前没有启用可用于生图的模型");
|
||||||
}
|
}
|
||||||
|
|
||||||
const normalizedRef = normalizeText(modelRef).toLowerCase();
|
const matched = findExternalImageApiModelMatch(models, modelRef);
|
||||||
if (!normalizedRef) {
|
if (!modelRef) {
|
||||||
return models[0];
|
const preferred = models.find((entry) =>
|
||||||
|
supportsImageEditForReferenceCount(entry, referenceImageCount),
|
||||||
|
);
|
||||||
|
if (!preferred) {
|
||||||
|
throw new Error("当前没有启用可用于图生图的模型");
|
||||||
|
}
|
||||||
|
return preferred;
|
||||||
}
|
}
|
||||||
|
|
||||||
const matched = models.find((entry) => {
|
|
||||||
const candidates = [
|
|
||||||
entry.model.id,
|
|
||||||
entry.model.modelId,
|
|
||||||
entry.model.displayName,
|
|
||||||
]
|
|
||||||
.map((item) => normalizeText(item).toLowerCase())
|
|
||||||
.filter(Boolean);
|
|
||||||
return candidates.includes(normalizedRef);
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!matched) {
|
if (!matched) {
|
||||||
throw new Error("未找到匹配的生图模型");
|
throw new Error("未找到匹配的生图模型");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!supportsImageEditForReferenceCount(matched, referenceImageCount)) {
|
||||||
|
const maxReferenceImages = getMaxReferenceImages(matched.model.capabilities);
|
||||||
|
if (
|
||||||
|
referenceImageCount > 1 &&
|
||||||
|
maxReferenceImages !== null &&
|
||||||
|
referenceImageCount > maxReferenceImages
|
||||||
|
) {
|
||||||
|
throw new Error(
|
||||||
|
`模型「${matched.model.displayName}」最多支持 ${maxReferenceImages} 张参考图`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
throw new Error(`模型「${matched.model.displayName}」不支持当前图生图调用`);
|
||||||
|
}
|
||||||
|
|
||||||
return matched;
|
return matched;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function buildExternalImageApiModelCandidates(params?: {
|
||||||
|
modelRef?: string | null;
|
||||||
|
referenceImageCount?: number;
|
||||||
|
}) {
|
||||||
|
const referenceImageCount = Math.max(0, params?.referenceImageCount ?? 1);
|
||||||
|
const allModels = sortExternalImageApiModels(
|
||||||
|
(
|
||||||
|
await getEnabledAiModelsWithProvider(false, false)
|
||||||
|
).filter((entry) =>
|
||||||
|
isImageGenerationModelDescriptor({
|
||||||
|
providerType: entry.provider.type,
|
||||||
|
modelId: entry.model.modelId,
|
||||||
|
capabilities: entry.model.capabilities,
|
||||||
|
supportsImageGen: entry.model.supportsImageGen,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
referenceImageCount,
|
||||||
|
);
|
||||||
|
|
||||||
|
const supportedModels = allModels.filter((entry) =>
|
||||||
|
supportsImageEditForReferenceCount(entry, referenceImageCount),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!supportedModels.length) {
|
||||||
|
throw new Error("当前没有启用可用于图生图的模型");
|
||||||
|
}
|
||||||
|
|
||||||
|
const explicitMatch = findExternalImageApiModelMatch(allModels, params?.modelRef);
|
||||||
|
if (!params?.modelRef) {
|
||||||
|
return supportedModels;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!explicitMatch) {
|
||||||
|
throw new Error("未找到匹配的生图模型");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!supportsImageEditForReferenceCount(explicitMatch, referenceImageCount)) {
|
||||||
|
const maxReferenceImages = getMaxReferenceImages(explicitMatch.model.capabilities);
|
||||||
|
if (
|
||||||
|
referenceImageCount > 1 &&
|
||||||
|
maxReferenceImages !== null &&
|
||||||
|
referenceImageCount > maxReferenceImages
|
||||||
|
) {
|
||||||
|
throw new Error(
|
||||||
|
`模型「${explicitMatch.model.displayName}」最多支持 ${maxReferenceImages} 张参考图`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
throw new Error(`模型「${explicitMatch.model.displayName}」不支持当前图生图调用`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
explicitMatch,
|
||||||
|
...supportedModels.filter((entry) => entry.model.id !== explicitMatch.model.id),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
export async function resolveExternalImageApiOwnerUserId(ownerEmail?: string | null) {
|
export async function resolveExternalImageApiOwnerUserId(ownerEmail?: string | null) {
|
||||||
const normalizedEmail = normalizeText(ownerEmail).toLowerCase() ||
|
const normalizedEmail = normalizeText(ownerEmail).toLowerCase() ||
|
||||||
DEFAULT_EXTERNAL_IMAGE_API_OWNER_EMAIL;
|
DEFAULT_EXTERNAL_IMAGE_API_OWNER_EMAIL;
|
||||||
@ -348,6 +600,7 @@ export async function generateImagesWithModel(params: {
|
|||||||
imageCount: number;
|
imageCount: number;
|
||||||
aspectRatio?: string | null;
|
aspectRatio?: string | null;
|
||||||
imageSize?: string | null;
|
imageSize?: string | null;
|
||||||
|
referenceImageUrls?: string[];
|
||||||
}) {
|
}) {
|
||||||
const languageModel = await getLanguageModelById(params.modelDbId);
|
const languageModel = await getLanguageModelById(params.modelDbId);
|
||||||
if (!languageModel || typeof (languageModel as any).doGenerate !== "function") {
|
if (!languageModel || typeof (languageModel as any).doGenerate !== "function") {
|
||||||
@ -367,7 +620,13 @@ export async function generateImagesWithModel(params: {
|
|||||||
messages: [
|
messages: [
|
||||||
{
|
{
|
||||||
role: "user",
|
role: "user",
|
||||||
content: [{ type: "text", text: composedPrompt }],
|
content: [
|
||||||
|
{ type: "text", text: composedPrompt },
|
||||||
|
...(params.referenceImageUrls || []).map((imageUrl) => ({
|
||||||
|
type: "image",
|
||||||
|
image: imageUrl,
|
||||||
|
})),
|
||||||
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
imageParams: {
|
imageParams: {
|
||||||
@ -389,6 +648,83 @@ export async function generateImagesWithModel(params: {
|
|||||||
return text;
|
return text;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function generateImagesWithFailover(params: {
|
||||||
|
candidates: ImageModelEntry[];
|
||||||
|
prompt: string;
|
||||||
|
negativePrompt?: string | null;
|
||||||
|
imageCount: number;
|
||||||
|
aspectRatio?: string | null;
|
||||||
|
imageSize?: string | null;
|
||||||
|
referenceImageUrls?: string[];
|
||||||
|
}) {
|
||||||
|
const attempts: ExternalImageApiGenerateAttempt[] = [];
|
||||||
|
|
||||||
|
for (const candidate of params.candidates) {
|
||||||
|
try {
|
||||||
|
const markdown = await generateImagesWithModel({
|
||||||
|
modelDbId: candidate.model.id,
|
||||||
|
prompt: params.prompt,
|
||||||
|
negativePrompt: params.negativePrompt || null,
|
||||||
|
imageCount: params.imageCount,
|
||||||
|
aspectRatio: params.aspectRatio || null,
|
||||||
|
imageSize: params.imageSize || null,
|
||||||
|
referenceImageUrls: params.referenceImageUrls || [],
|
||||||
|
});
|
||||||
|
|
||||||
|
attempts.push({
|
||||||
|
modelId: candidate.model.id,
|
||||||
|
modelRuntimeId: candidate.model.modelId,
|
||||||
|
displayName: candidate.model.displayName,
|
||||||
|
success: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
markdown,
|
||||||
|
usedModel: candidate,
|
||||||
|
attempts,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
attempts.push({
|
||||||
|
modelId: candidate.model.id,
|
||||||
|
modelRuntimeId: candidate.model.modelId,
|
||||||
|
displayName: candidate.model.displayName,
|
||||||
|
success: false,
|
||||||
|
error: error instanceof Error ? error.message : "图片生成失败",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const summary = attempts
|
||||||
|
.filter((item) => !item.success)
|
||||||
|
.map((item) => `${item.displayName}: ${item.error || "图片生成失败"}`)
|
||||||
|
.join(";");
|
||||||
|
throw new Error(summary || "所有候选生图模型均调用失败");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function ensureHostedReferenceImageUrls(imageUrls: string[]) {
|
||||||
|
const deduped = imageUrls
|
||||||
|
.map((item) => normalizeText(item))
|
||||||
|
.filter(Boolean)
|
||||||
|
.filter((item, index, list) => list.indexOf(item) === index);
|
||||||
|
|
||||||
|
return Promise.all(deduped.map((url) => ensureHostedImageUrl(url)));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function uploadExternalImageApiFile(file: File) {
|
||||||
|
if (!(file instanceof File)) {
|
||||||
|
throw new Error("无效的图片文件");
|
||||||
|
}
|
||||||
|
|
||||||
|
const arrayBuffer = await file.arrayBuffer();
|
||||||
|
if (!arrayBuffer.byteLength) {
|
||||||
|
throw new Error("上传的图片文件为空");
|
||||||
|
}
|
||||||
|
|
||||||
|
const filename = sanitizeUploadFilename(file.name, "external-reference");
|
||||||
|
const uploaded = await uploadFile(filename, arrayBuffer);
|
||||||
|
return uploaded.url;
|
||||||
|
}
|
||||||
|
|
||||||
export function buildExternalImageApiKeyFingerprint(
|
export function buildExternalImageApiKeyFingerprint(
|
||||||
accessKey: ExternalImageApiAccessKey | null,
|
accessKey: ExternalImageApiAccessKey | null,
|
||||||
) {
|
) {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user