438 lines
15 KiB
TypeScript
438 lines
15 KiB
TypeScript
import {
|
||
buildJimengPayload,
|
||
buildJimengQueryPayload,
|
||
getEnabledImageCapability,
|
||
isRetryableVisualCode
|
||
} from "@/lib/jimeng/capabilities";
|
||
import {
|
||
buildEvolinkImagePayload,
|
||
extractEvolinkResultUrls,
|
||
getEffectiveImageEngine,
|
||
getEvolinkImageSettings,
|
||
getEvolinkTaskId,
|
||
mapEvolinkStatus,
|
||
queryEvolinkTask,
|
||
shouldMockEvolinkApi,
|
||
submitEvolinkImageTask,
|
||
type ImageCreationEngine,
|
||
type EvolinkTaskResponse
|
||
} from "@/lib/evolink/image-client";
|
||
import {
|
||
createGenerationJob,
|
||
getGenerationJob,
|
||
recordUsageEvent,
|
||
updateGenerationJob
|
||
} from "@/lib/server/data-store";
|
||
import { createMockImageBuffer } from "@/lib/server/mock-image";
|
||
import { importRemoteImageAsAsset, saveGeneratedAsset } from "@/lib/server/storage";
|
||
import { DEFAULT_OWNER_ID, toAbsoluteUrl } from "@/lib/server/runtime";
|
||
import type { EnabledImageCapability, GenerationJob, VisualTaskQueryResponse } from "@/lib/types";
|
||
import { queryVisualTask, shouldMockVisualApi, submitVisualTask } from "@/lib/volcengine/visual-client";
|
||
import {
|
||
bailianResultUrls,
|
||
bailianStatus,
|
||
bailianTaskId,
|
||
buildBailianImagePayload,
|
||
getBailianConfig,
|
||
queryBailianTask,
|
||
shouldMockBailian,
|
||
submitBailianTask
|
||
} from "@/lib/bailian/client";
|
||
|
||
export type SubmitImageJobInput = {
|
||
ownerId?: string;
|
||
externalClientId?: string;
|
||
capability: EnabledImageCapability;
|
||
engine?: ImageCreationEngine;
|
||
prompt?: string;
|
||
imageUrls?: string[];
|
||
inputAssetIds?: string[];
|
||
scale?: number;
|
||
width?: number;
|
||
height?: number;
|
||
min_ratio?: number;
|
||
max_ratio?: number;
|
||
force_single?: boolean;
|
||
resolution?: "4k" | "8k";
|
||
quality?: string;
|
||
seed?: number;
|
||
retryOf?: string;
|
||
idempotencyKey?: string;
|
||
idempotencyFingerprint?: string;
|
||
priority?: number;
|
||
maxAttempts?: number;
|
||
webhookUrl?: string;
|
||
};
|
||
|
||
export async function submitImageJob(input: SubmitImageJobInput, origin: string): Promise<GenerationJob> {
|
||
const ownerId = input.ownerId || DEFAULT_OWNER_ID;
|
||
const capability = getEnabledImageCapability(input.capability);
|
||
const normalizedUrls = (input.imageUrls || []).map((url) => toAbsoluteUrl(url, origin));
|
||
const engine = getEffectiveImageEngine(input.capability, input.engine);
|
||
const providerPayload = engine === "bailian"
|
||
? buildBailianImagePayload(input.capability, { ...input, imageUrls: normalizedUrls })
|
||
: engine === "evolink"
|
||
? buildEvolinkImagePayload(input.capability, { ...input, imageUrls: normalizedUrls })
|
||
: buildJimengPayload(input.capability, capability.reqKey, { ...input, imageUrls: normalizedUrls });
|
||
const bailianConfig = getBailianConfig();
|
||
const mock = engine === "bailian" ? shouldMockBailian() : engine === "evolink" ? shouldMockEvolinkApi() : shouldMockVisualApi();
|
||
const missingBailianKey = engine === "bailian" && !mock && !bailianConfig.apiKey;
|
||
const reqKey = engine === "bailian" ? bailianConfig.imageModel : engine === "evolink" ? getEvolinkImageSettings().model : capability.reqKey;
|
||
let job = await createGenerationJob({
|
||
ownerId,
|
||
externalClientId: input.externalClientId,
|
||
capability: input.capability,
|
||
provider: mock ? "mock" : engine === "bailian" ? "bailian" : engine === "evolink" ? "evolink" : "volcengine-visual",
|
||
reqKey,
|
||
status: missingBailianKey ? "failed" : "queued",
|
||
prompt: input.prompt,
|
||
inputAssetIds: input.inputAssetIds || [],
|
||
inputUrls: normalizedUrls,
|
||
outputAssetIds: [],
|
||
requestPayload: {
|
||
engine,
|
||
input,
|
||
providerPayload
|
||
},
|
||
error: missingBailianKey ? { message: "缺少 BAILIAN_API_KEY,请先在设置页配置阿里云百炼 API Key。", retryable: true } : undefined,
|
||
retryOf: input.retryOf,
|
||
idempotencyKey: input.idempotencyKey,
|
||
idempotencyFingerprint: input.idempotencyFingerprint,
|
||
priority: input.priority,
|
||
maxAttempts: input.maxAttempts,
|
||
webhookUrl: input.webhookUrl
|
||
});
|
||
|
||
return job;
|
||
}
|
||
|
||
export async function advanceImageJob(jobId: string, origin: string): Promise<GenerationJob> {
|
||
const job = await getGenerationJob(jobId);
|
||
if (!job) throw new Error(`Generation job not found: ${jobId}`);
|
||
if (["succeeded", "failed", "expired", "cancelled"].includes(job.status)) return job;
|
||
if (job.provider === "mock") return completeMockJob(job, origin);
|
||
if (!job.providerTaskId) return dispatchImageJob(job);
|
||
return syncImageJob(job.id, origin);
|
||
}
|
||
|
||
async function dispatchImageJob(job: GenerationJob): Promise<GenerationJob> {
|
||
const providerPayload = asRecord(job.requestPayload.providerPayload);
|
||
try {
|
||
if (job.provider === "bailian") {
|
||
const response = await submitBailianTask("image", providerPayload);
|
||
const taskId = bailianTaskId(response);
|
||
if (!taskId) throw new Error(response.message || "百炼图片任务响应缺少 task_id。");
|
||
return updateGenerationJob(job.id, { status: "queued", providerTaskId: taskId, responsePayload: response });
|
||
}
|
||
if (job.provider === "evolink") {
|
||
const response = await submitEvolinkImageTask(providerPayload);
|
||
const taskId = getEvolinkTaskId(response);
|
||
if (!taskId) {
|
||
job = await updateGenerationJob(job.id, {
|
||
status: "failed",
|
||
responsePayload: response as unknown as Record<string, unknown>,
|
||
error: {
|
||
code: response.error?.code,
|
||
message: response.error?.message || response.message || "EvoLink task submission failed.",
|
||
retryable: false
|
||
}
|
||
});
|
||
return job;
|
||
}
|
||
return updateGenerationJob(job.id, {
|
||
status: "queued",
|
||
providerTaskId: taskId,
|
||
responsePayload: response as unknown as Record<string, unknown>
|
||
});
|
||
}
|
||
|
||
const response = await submitVisualTask(providerPayload);
|
||
if (response.code !== 10000 || !response.data?.task_id) {
|
||
job = await updateGenerationJob(job.id, {
|
||
status: "failed",
|
||
responsePayload: response as unknown as Record<string, unknown>,
|
||
error: {
|
||
code: response.code,
|
||
message: response.message || "Volcengine Visual task submission failed.",
|
||
retryable: isRetryableVisualCode(response.code)
|
||
}
|
||
});
|
||
return job;
|
||
}
|
||
return updateGenerationJob(job.id, {
|
||
status: "queued",
|
||
providerTaskId: response.data.task_id,
|
||
responsePayload: response as unknown as Record<string, unknown>
|
||
});
|
||
} catch (error) {
|
||
return updateGenerationJob(job.id, {
|
||
status: "failed",
|
||
error: {
|
||
message: error instanceof Error ? error.message : String(error),
|
||
retryable: false
|
||
}
|
||
});
|
||
}
|
||
}
|
||
|
||
export async function syncImageJob(jobId: string, origin: string): Promise<GenerationJob> {
|
||
const job = await getGenerationJob(jobId);
|
||
if (!job) throw new Error(`Generation job not found: ${jobId}`);
|
||
if (["succeeded", "failed", "expired", "cancelled"].includes(job.status)) return job;
|
||
if (job.provider === "mock") return completeMockJob(job, origin);
|
||
if (!job.providerTaskId) return job;
|
||
|
||
if (job.provider === "evolink") {
|
||
return syncEvolinkImageJob(job, origin);
|
||
}
|
||
if (job.provider === "bailian") return syncBailianImageJob(job, origin);
|
||
|
||
const queryPayload = buildJimengQueryPayload(job.reqKey, job.providerTaskId);
|
||
let response: VisualTaskQueryResponse;
|
||
try {
|
||
response = await queryVisualTask(queryPayload);
|
||
} catch (error) {
|
||
return updateGenerationJob(job.id, {
|
||
status: "failed",
|
||
error: {
|
||
message: error instanceof Error ? error.message : String(error),
|
||
retryable: false
|
||
}
|
||
});
|
||
}
|
||
|
||
if (response.data?.status === "in_queue") {
|
||
return updateGenerationJob(job.id, { status: "queued", responsePayload: response as unknown as Record<string, unknown> });
|
||
}
|
||
if (response.data?.status === "generating") {
|
||
return updateGenerationJob(job.id, { status: "running", responsePayload: response as unknown as Record<string, unknown> });
|
||
}
|
||
if (response.data?.status === "expired" || response.data?.status === "not_found") {
|
||
return updateGenerationJob(job.id, {
|
||
status: "expired",
|
||
responsePayload: response as unknown as Record<string, unknown>,
|
||
error: {
|
||
code: response.code,
|
||
message: response.message || "Volcengine Visual task expired or was not found.",
|
||
retryable: true
|
||
}
|
||
});
|
||
}
|
||
if (response.code !== 10000) {
|
||
return updateGenerationJob(job.id, {
|
||
status: "failed",
|
||
responsePayload: response as unknown as Record<string, unknown>,
|
||
error: {
|
||
code: response.code,
|
||
message: response.message || "Volcengine Visual task failed.",
|
||
retryable: isRetryableVisualCode(response.code)
|
||
}
|
||
});
|
||
}
|
||
|
||
const urls = response.data?.image_urls || [];
|
||
const assets = [];
|
||
for (let index = 0; index < urls.length; index += 1) {
|
||
assets.push(await importRemoteImageAsAsset({
|
||
ownerId: job.ownerId,
|
||
url: urls[index],
|
||
origin,
|
||
source: sourceForCapability(job.capability),
|
||
capability: job.capability,
|
||
jobId: job.id,
|
||
index,
|
||
tags: assetTagsForJob(job)
|
||
}));
|
||
}
|
||
if (assets.length) {
|
||
await recordUsageEvent({
|
||
ownerId: job.ownerId,
|
||
jobId: job.id,
|
||
capability: job.capability,
|
||
quantity: assets.length,
|
||
estimatedUnit: "image"
|
||
});
|
||
}
|
||
return updateGenerationJob(job.id, {
|
||
status: "succeeded",
|
||
outputAssetIds: assets.map((asset) => asset.id),
|
||
responsePayload: response as unknown as Record<string, unknown>
|
||
});
|
||
}
|
||
|
||
async function syncBailianImageJob(job: GenerationJob, origin: string): Promise<GenerationJob> {
|
||
const response = await queryBailianTask(job.providerTaskId!);
|
||
const status = bailianStatus(response);
|
||
if (status !== "succeeded") {
|
||
return updateGenerationJob(job.id, {
|
||
status,
|
||
responsePayload: response,
|
||
error: status === "failed" ? { message: response.message || "百炼图片任务失败。", retryable: false } : undefined
|
||
});
|
||
}
|
||
const urls = bailianResultUrls(response, "image");
|
||
if (!urls.length) return updateGenerationJob(job.id, { status: "failed", responsePayload: response, error: { message: "百炼图片任务未返回结果地址。", retryable: true } });
|
||
const assets = [];
|
||
for (let index = 0; index < urls.length; index += 1) {
|
||
assets.push(await importRemoteImageAsAsset({ ownerId: job.ownerId, url: urls[index], origin, source: sourceForCapability(job.capability), capability: job.capability, jobId: job.id, index, tags: assetTagsForJob(job) }));
|
||
}
|
||
await recordUsageEvent({ ownerId: job.ownerId, jobId: job.id, capability: job.capability, quantity: assets.length, estimatedUnit: "image" });
|
||
return updateGenerationJob(job.id, { status: "succeeded", outputAssetIds: assets.map((asset) => asset.id), responsePayload: response });
|
||
}
|
||
|
||
async function syncEvolinkImageJob(job: GenerationJob, origin: string): Promise<GenerationJob> {
|
||
if (!job.providerTaskId) return job;
|
||
let response: EvolinkTaskResponse;
|
||
try {
|
||
response = await queryEvolinkTask(job.providerTaskId);
|
||
} catch (error) {
|
||
return updateGenerationJob(job.id, {
|
||
status: "failed",
|
||
error: {
|
||
message: error instanceof Error ? error.message : String(error),
|
||
retryable: false
|
||
}
|
||
});
|
||
}
|
||
|
||
const status = mapEvolinkStatus(response);
|
||
if (status === "queued" || status === "running") {
|
||
return updateGenerationJob(job.id, {
|
||
status,
|
||
responsePayload: response as unknown as Record<string, unknown>
|
||
});
|
||
}
|
||
if (status === "expired" || status === "cancelled") {
|
||
return updateGenerationJob(job.id, {
|
||
status,
|
||
responsePayload: response as unknown as Record<string, unknown>,
|
||
error: {
|
||
code: response.error?.code,
|
||
message: response.error?.message || response.message || "EvoLink image task was not completed.",
|
||
retryable: status === "expired"
|
||
}
|
||
});
|
||
}
|
||
if (status === "failed") {
|
||
return updateGenerationJob(job.id, {
|
||
status: "failed",
|
||
responsePayload: response as unknown as Record<string, unknown>,
|
||
error: {
|
||
code: response.error?.code,
|
||
message: response.error?.message || response.message || "EvoLink image task failed.",
|
||
retryable: false
|
||
}
|
||
});
|
||
}
|
||
|
||
const urls = extractEvolinkResultUrls(response);
|
||
if (!urls.length) {
|
||
return updateGenerationJob(job.id, {
|
||
status: "failed",
|
||
responsePayload: response as unknown as Record<string, unknown>,
|
||
error: {
|
||
message: "EvoLink image task completed without result URLs.",
|
||
retryable: true
|
||
}
|
||
});
|
||
}
|
||
|
||
const assets = [];
|
||
for (let index = 0; index < urls.length; index += 1) {
|
||
assets.push(await importRemoteImageAsAsset({
|
||
ownerId: job.ownerId,
|
||
url: urls[index],
|
||
origin,
|
||
source: sourceForCapability(job.capability),
|
||
capability: job.capability,
|
||
jobId: job.id,
|
||
index,
|
||
tags: assetTagsForJob(job)
|
||
}));
|
||
}
|
||
await recordUsageEvent({
|
||
ownerId: job.ownerId,
|
||
jobId: job.id,
|
||
capability: job.capability,
|
||
quantity: assets.length,
|
||
estimatedUnit: "image"
|
||
});
|
||
return updateGenerationJob(job.id, {
|
||
status: "succeeded",
|
||
outputAssetIds: assets.map((asset) => asset.id),
|
||
responsePayload: response as unknown as Record<string, unknown>
|
||
});
|
||
}
|
||
|
||
export async function retryImageJob(jobId: string, origin: string, ownerId?: string): Promise<GenerationJob> {
|
||
const job = await getGenerationJob(jobId);
|
||
if (!job) throw new Error(`Generation job not found: ${jobId}`);
|
||
if (ownerId && job.ownerId !== ownerId) throw new Error(`Generation job not found: ${jobId}`);
|
||
const input = (job.requestPayload.input || {}) as SubmitImageJobInput;
|
||
return submitImageJob({
|
||
...input,
|
||
ownerId: ownerId || job.ownerId,
|
||
capability: job.capability as EnabledImageCapability,
|
||
retryOf: job.id
|
||
}, origin);
|
||
}
|
||
|
||
async function completeMockJob(job: GenerationJob, origin: string): Promise<GenerationJob> {
|
||
if (job.status === "succeeded" && job.outputAssetIds.length > 0) return job;
|
||
const requestInput = (job.requestPayload.input || {}) as SubmitImageJobInput;
|
||
const buffer = createMockImageBuffer({
|
||
title: "智念AIGC生成结果",
|
||
prompt: job.prompt || requestInput.prompt,
|
||
capability: job.capability,
|
||
resolution: requestInput.resolution
|
||
});
|
||
const asset = await saveGeneratedAsset({
|
||
ownerId: job.ownerId,
|
||
bytes: buffer,
|
||
fileName: `${job.capability.replace(/\W+/g, "-")}-${job.id}.svg`,
|
||
contentType: "image/svg+xml",
|
||
origin,
|
||
source: sourceForCapability(job.capability),
|
||
capability: job.capability,
|
||
jobId: job.id,
|
||
tags: assetTagsForJob(job),
|
||
metadata: {
|
||
mock: true
|
||
}
|
||
});
|
||
await recordUsageEvent({
|
||
ownerId: job.ownerId,
|
||
jobId: job.id,
|
||
capability: job.capability,
|
||
quantity: 1,
|
||
estimatedUnit: "image"
|
||
});
|
||
return updateGenerationJob(job.id, {
|
||
status: "succeeded",
|
||
outputAssetIds: [asset.id],
|
||
responsePayload: {
|
||
mock: true,
|
||
data: {
|
||
status: "done",
|
||
image_urls: [asset.url]
|
||
}
|
||
}
|
||
});
|
||
}
|
||
|
||
function sourceForCapability(capability: string) {
|
||
if (capability === "image.inpaint") return "edited";
|
||
if (capability === "image.upscale") return "upscaled";
|
||
return "generated";
|
||
}
|
||
|
||
function assetTagsForJob(job: GenerationJob): string[] {
|
||
return job.externalClientId ? [job.capability, `api-client:${job.externalClientId}`] : [job.capability];
|
||
}
|
||
|
||
function asRecord(value: unknown): Record<string, unknown> {
|
||
return typeof value === "object" && value !== null && !Array.isArray(value)
|
||
? value as Record<string, unknown>
|
||
: {};
|
||
}
|