ai-chatbot/lib/ai/providers/snapgen-image.ts

1157 lines
36 KiB
TypeScript

import "server-only";
import sharp from "sharp";
import { createDeferredUploadedImage } from "@/lib/storage/image-archive";
import { uploadFile } from "@/lib/storage/service";
import { buildSnapgenReferenceOrderPrompt } from "@/lib/ai/providers/snapgen-reference-order";
import {
SNAPGEN_GPT_IMAGE_2_REQUEST_ASPECT_RATIOS,
SNAPGEN_STANDARD_REQUEST_ASPECT_RATIOS,
resolveSnapgenImageRuntimeModelId,
} from "@/lib/ai/snapgen-image-models";
interface SnapgenImageConfig {
apiKey: string;
baseURL: string;
}
interface SnapgenImageParams {
aspectRatio?: string;
imageSize?: string;
imageCount?: number;
outputFormat?: "png" | "jpg" | "jpeg" | "webp";
quality?: string;
requestTimeoutMs?: number;
pollTimeoutMs?: number;
historyRequestTimeoutMs?: number;
imageDownloadTimeoutMs?: number;
}
interface SnapgenImageModelConfig {
maxImages?: number;
supportsSize?: boolean;
supports1K?: boolean;
supports2K?: boolean;
supports4K?: boolean;
supportedAspectRatios?: string[];
}
interface UploadedImage {
thumbnail: string;
original: string;
}
type SnapgenApiFormat =
| "snapgen-generic-image"
| "snapgen-grok-image"
| "snapgen-meta-ai-image"
| "snapgen-gpt-image-2";
const DEFAULT_OUTPUT_FORMAT = "png";
const POLL_INTERVAL_MS = 2000;
const POLL_MAX_ATTEMPTS = 240;
const REQUEST_TIMEOUT_MS = 120_000;
const HISTORY_REQUEST_TIMEOUT_MS = 30_000;
const IMAGE_DOWNLOAD_TIMEOUT_MS = 120_000;
const REFERENCE_ASPECT_DOWNLOAD_TIMEOUT_MS = 30_000;
const REFERENCE_FILE_DOWNLOAD_TIMEOUT_MS = 90_000;
const REFERENCE_FILE_DOWNLOAD_RETRIES = 2;
function joinApiPath(baseURL: string, path: string) {
const cleanBase = baseURL.replace(/\/+$/, "");
const cleanPath = path.startsWith("/") ? path : `/${path}`;
return `${cleanBase}${cleanPath}`;
}
function normalizeSnapgenModelId(modelId?: string) {
const raw = String(modelId || "").trim();
if (!raw) {
throw new Error("SnapGen 模型 ID 未配置");
}
return raw;
}
function normalizeUrlForCompare(url: string) {
try {
const parsed = new URL(url);
parsed.search = "";
parsed.hash = "";
return parsed.toString();
} catch {
return url.split("?")[0]?.split("#")[0] || url;
}
}
function filterSourceImages(urls: string[], sourceUrls: string[]) {
if (sourceUrls.length === 0) return urls;
const sourceSet = new Set(sourceUrls.map((url) => normalizeUrlForCompare(url)));
return urls.filter((url) => !sourceSet.has(normalizeUrlForCompare(url)));
}
function normalizeResolution(imageSize?: string, apiFormat?: SnapgenApiFormat) {
const defaultResolution = apiFormat === "snapgen-gpt-image-2" ? "2K" : "1K";
if (!imageSize) return defaultResolution;
const normalized = imageSize.trim().toUpperCase();
if (apiFormat === "snapgen-gpt-image-2") {
if (["1K", "2K", "4K", "8K", "10K", "12K"].includes(normalized)) {
return normalized;
}
return defaultResolution;
}
if (["1K", "2K", "4K"].includes(normalized)) return normalized;
return defaultResolution;
}
function toSnapgenResolutionParam(resolution: string) {
return resolution.trim().toLowerCase();
}
function normalizeOutputFormat(outputFormat?: string): "png" | "jpeg" {
if (!outputFormat) return DEFAULT_OUTPUT_FORMAT;
const normalized = outputFormat.trim().toLowerCase();
if (normalized === "jpg" || normalized === "jpeg") return "jpeg";
return "png";
}
function normalizeTimeoutMs(value: unknown, fallback: number, min = 1000) {
const numeric = Number(value);
if (!Number.isFinite(numeric) || numeric <= 0) return fallback;
return Math.max(min, Math.floor(numeric));
}
async function fetchWithTimeout(
url: string,
init: RequestInit,
timeoutMs: number
) {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), timeoutMs);
try {
return await fetch(url, {
...init,
signal: controller.signal,
});
} finally {
clearTimeout(timeout);
}
}
function normalizeOrientation(aspectRatio: string): "landscape" | "portrait" | "square" {
if (aspectRatio === "1:1") return "square";
if (["9:16", "3:4", "2:3"].includes(aspectRatio)) return "portrait";
return "landscape";
}
function normalizeAspectRatioLabel(value?: string) {
const normalized = String(value || "").trim().toLowerCase();
if (!normalized) return "";
if (normalized === "auto") return "auto";
const match = normalized.match(/^(\d+)\s*:\s*(\d+)$/);
if (!match) return "";
return `${Number(match[1])}:${Number(match[2])}`;
}
function parseAspectRatioValue(aspectRatio: string) {
const normalized = normalizeAspectRatioLabel(aspectRatio);
const match = normalized.match(/^(\d+):(\d+)$/);
if (!match) return null;
const width = Number(match[1]);
const height = Number(match[2]);
if (!width || !height) return null;
return width / height;
}
function getFallbackRequestAspectRatios(apiFormat: SnapgenApiFormat) {
if (apiFormat === "snapgen-gpt-image-2") {
return SNAPGEN_GPT_IMAGE_2_REQUEST_ASPECT_RATIOS;
}
return SNAPGEN_STANDARD_REQUEST_ASPECT_RATIOS;
}
function getRequestAspectRatios(
apiFormat: SnapgenApiFormat,
configuredAspectRatios?: string[]
) {
const configured = Array.isArray(configuredAspectRatios)
? configuredAspectRatios
.map((ratio) => normalizeAspectRatioLabel(ratio))
.filter((ratio) => ratio && ratio !== "auto" && parseAspectRatioValue(ratio) !== null)
: [];
if (configured.length > 0) {
return Array.from(new Set(configured));
}
return getFallbackRequestAspectRatios(apiFormat);
}
function getDefaultRequestAspectRatio(supportedAspectRatios: string[]) {
return supportedAspectRatios.includes("1:1")
? "1:1"
: supportedAspectRatios[0] || "1:1";
}
function findClosestAspectRatio(targetAspect: number, supportedAspectRatios: string[]) {
const fallback = getDefaultRequestAspectRatio(supportedAspectRatios);
if (!Number.isFinite(targetAspect) || targetAspect <= 0) return fallback;
let closest = fallback;
let closestScore = Number.POSITIVE_INFINITY;
for (const aspectRatio of supportedAspectRatios) {
const aspectValue = parseAspectRatioValue(aspectRatio);
if (!aspectValue) continue;
const score = Math.abs(Math.log(targetAspect / aspectValue));
if (score < closestScore) {
closest = aspectRatio;
closestScore = score;
}
}
return closest;
}
async function inferImageDimensionsFromUrl(
imageUrl: string
): Promise<{ width: number; height: number; aspectRatio: number } | null> {
if (!/^https?:\/\//i.test(imageUrl)) return null;
const controller = new AbortController();
const timeout = setTimeout(
() => controller.abort(),
REFERENCE_ASPECT_DOWNLOAD_TIMEOUT_MS
);
try {
const response = await fetch(imageUrl, {
cache: "no-store",
redirect: "follow",
signal: controller.signal,
headers: {
"User-Agent": "ai-chatbot-snapgen/1.0",
},
});
if (!response.ok) return null;
const buffer = Buffer.from(await response.arrayBuffer());
if (!buffer.length) return null;
const metadata = await sharp(buffer, { failOn: "none" }).metadata();
if (!metadata.width || !metadata.height || metadata.height <= 0) return null;
return {
width: metadata.width,
height: metadata.height,
aspectRatio: metadata.width / metadata.height,
};
} catch (error) {
console.warn("[SnapGen Image] Failed to infer reference aspect ratio:", error);
return null;
} finally {
clearTimeout(timeout);
}
}
async function resolveAspectRatio({
requestedAspectRatio,
imageUrls,
apiFormat,
configuredAspectRatios,
}: {
requestedAspectRatio?: string;
imageUrls: string[];
apiFormat: SnapgenApiFormat;
configuredAspectRatios?: string[];
}) {
const supportedAspectRatios = getRequestAspectRatios(apiFormat, configuredAspectRatios);
const normalizedRequest = normalizeAspectRatioLabel(requestedAspectRatio) || "auto";
const fallback = getDefaultRequestAspectRatio(supportedAspectRatios);
if (normalizedRequest !== "auto") {
const aspectRatio = supportedAspectRatios.includes(normalizedRequest)
? normalizedRequest
: fallback;
return {
requestedAspectRatio: normalizedRequest,
aspectRatio,
aspectRatioSource: aspectRatio === normalizedRequest ? "manual" : "fallback",
};
}
const referenceImageUrl = imageUrls[0];
if (referenceImageUrl) {
const dimensions = await inferImageDimensionsFromUrl(referenceImageUrl);
if (dimensions) {
return {
requestedAspectRatio: "auto",
aspectRatio: findClosestAspectRatio(dimensions.aspectRatio, supportedAspectRatios),
aspectRatioSource: "reference",
referenceImageSize: `${dimensions.width}x${dimensions.height}`,
};
}
}
return {
requestedAspectRatio: "auto",
aspectRatio: fallback,
aspectRatioSource: "fallback",
};
}
function normalizeGrokMode(value?: string): "SPEED" | "QUALITY" {
const normalized = String(value || "").trim().toUpperCase();
return normalized === "QUALITY" ? "QUALITY" : "SPEED";
}
function normalizeGptImage2Mode(value?: string): "low" | "medium" | "high" {
const normalized = String(value || "").trim().toLowerCase();
if (normalized === "low" || normalized === "medium" || normalized === "high") {
return normalized;
}
return "low";
}
function clampImageCount(count?: number, maxImages = 1) {
const normalized = Math.floor(Number(count || 1));
if (!Number.isFinite(normalized) || normalized < 1) return 1;
return Math.min(normalized, Math.max(1, maxImages));
}
function inferApiFormat(modelId: string): SnapgenApiFormat {
const normalized = modelId.toLowerCase();
if (normalized.includes("gpt-image-2")) return "snapgen-gpt-image-2";
if (normalized.includes("meta_ai") || normalized.includes("meta-ai") || normalized.includes("meta_ai/generate")) {
return "snapgen-meta-ai-image";
}
if (normalized.includes("grok")) return "snapgen-grok-image";
return "snapgen-generic-image";
}
function resolveSnapgenRuntimeModelId(modelId: string) {
const normalized = modelId.trim();
return resolveSnapgenImageRuntimeModelId(normalized);
}
function getEndpointPath(apiFormat: SnapgenApiFormat) {
switch (apiFormat) {
case "snapgen-grok-image":
return "/uapi/v1/imagen/grok";
case "snapgen-meta-ai-image":
return "/uapi/v1/meta_ai/generate";
case "snapgen-gpt-image-2":
return "/uapi/v1/imagen/gpt-image-2";
case "snapgen-generic-image":
default:
return "/uapi/v1/generate_image";
}
}
function getGenericEndpointModel(runtimeModelId: string) {
const normalized = runtimeModelId.trim();
if (normalized === "gemini-3.1-flash-image-preview") return "nano-banana-2";
if (normalized === "gemini-3-pro-image-preview") return "nano-banana-pro";
if (normalized.includes("/")) return normalized.split("/").pop() || normalized;
return normalized;
}
function extractPromptAndImages(messages: Array<{ role: string; content?: any; parts?: any[] }>) {
const lastUserMessage = messages.filter((message) => message.role === "user").pop();
if (!lastUserMessage) {
throw new Error("No user message found");
}
let prompt = "";
const imageUrls: string[] = [];
const pushImageUrl = (url?: string) => {
if (!url) return;
const normalized = String(url).trim();
if (normalized.startsWith("data:")) {
throw new Error("SnapGen 暂不支持直接传输 Base64 图片,请先上传图片后再作为参考图使用");
}
if (!normalized.startsWith("http")) return;
if (!imageUrls.includes(normalized)) {
imageUrls.push(normalized);
}
};
const readPart = (part: any) => {
if (part?.type === "text" && typeof part.text === "string") {
prompt = `${prompt} ${part.text}`.trim();
} else if (part?.type === "image_url" && part.image_url?.url) {
pushImageUrl(part.image_url.url);
} else if (part?.type === "image") {
pushImageUrl(part.image || part.url || part.imageUrl);
} else if (part?.type === "file") {
pushImageUrl(part.url || part.data);
}
};
if (typeof lastUserMessage.content === "string") {
prompt = lastUserMessage.content.trim();
} else if (Array.isArray(lastUserMessage.content)) {
for (const part of lastUserMessage.content) {
readPart(part);
}
}
if (Array.isArray(lastUserMessage.parts)) {
for (const part of lastUserMessage.parts) {
readPart(part);
}
}
if (!prompt) {
throw new Error("No prompt found in user message");
}
return { prompt, imageUrls };
}
function extractErrorMessage(payload: any): string {
return (
payload?.detail?.error_message ||
payload?.detail?.error ||
payload?.detail?.message ||
payload?.detail?.status_desc ||
payload?.data?.error_message ||
payload?.data?.error?.message ||
payload?.data?.error ||
payload?.data?.message ||
payload?.data?.msg ||
payload?.data?.status_desc ||
payload?.data?.state_detail ||
payload?.data?.err_message ||
payload?.data?.err_info ||
payload?.error_message ||
payload?.error?.message ||
payload?.error ||
payload?.status_desc ||
payload?.state_detail ||
payload?.err_message ||
payload?.err_info ||
payload?.message ||
payload?.msg ||
"SnapGen 任务执行失败"
);
}
function compactPayloadSnippet(payload: any): string {
try {
const json = JSON.stringify(payload);
return json.length > 320 ? `${json.slice(0, 320)}...` : json;
} catch {
return String(payload || "");
}
}
function extractUuid(payload: any): string | undefined {
const raw =
payload?.uuid ||
payload?.conversion_uuid ||
payload?.id ||
payload?.data?.uuid ||
payload?.data?.conversion_uuid ||
payload?.data?.id;
return typeof raw === "string" && raw.trim() ? raw.trim() : undefined;
}
function extractStatusCode(payload: any): number | undefined {
const raw =
payload?.status ||
payload?.data?.status ||
payload?.detail?.status ||
payload?.state ||
payload?.data?.state;
if (typeof raw === "number" && Number.isFinite(raw)) return raw;
if (typeof raw === "string") {
const normalized = raw.trim().toLowerCase();
if (normalized === "processing" || normalized === "pending" || normalized === "running") return 1;
if (normalized === "completed" || normalized === "complete" || normalized === "success" || normalized === "succeeded") return 2;
if (normalized === "failed" || normalized === "fail" || normalized === "error") return 3;
const numeric = Number(normalized);
if (Number.isFinite(numeric)) return numeric;
}
return undefined;
}
function isLikelyImageUrl(value: string, parentKey = "") {
if (!/^https?:\/\//i.test(value)) return false;
return (
/\.(png|jpg|jpeg|webp|gif)(\?|#|$)/i.test(value) ||
/image|img|url|output|result|artifact|file|download|thumbnail|uri/i.test(parentKey) ||
/\/(image|img|generated|download|media|files?)\//i.test(value)
);
}
function collectImageUrls(payload: any): string[] {
const urls = new Set<string>();
const walk = (value: any, parentKey = "") => {
if (typeof value === "string") {
if (isLikelyImageUrl(value, parentKey)) {
urls.add(value);
return;
}
const trimmed = value.trim();
if (
trimmed.length > 1 &&
trimmed.length < 200000 &&
(trimmed.startsWith("{") || trimmed.startsWith("["))
) {
try {
walk(JSON.parse(trimmed), parentKey);
} catch {
// ignore non-JSON strings
}
}
return;
}
if (Array.isArray(value)) {
for (const item of value) {
walk(item, parentKey);
}
return;
}
if (value && typeof value === "object") {
const preferredKeys = [
"image_url",
"file_download_url",
"image_uri",
"generate_result",
"url",
];
for (const key of preferredKeys) {
const nested = (value as Record<string, unknown>)[key];
if (typeof nested === "string" && isLikelyImageUrl(nested, key)) {
urls.add(nested);
}
}
for (const [key, nestedValue] of Object.entries(value)) {
walk(nestedValue, key);
}
}
};
const outputRoots = [
payload?.generate_result,
payload?.generated_image,
payload?.generated_images,
payload?.image_urls,
payload?.images,
payload?.output,
payload?.outputs,
payload?.result,
payload?.results,
payload?.data?.generate_result,
payload?.data?.generated_image,
payload?.data?.generated_images,
payload?.data?.image_urls,
payload?.data?.images,
payload?.data?.output,
payload?.data?.outputs,
payload?.data?.result,
payload?.data?.results,
payload?.detail?.generate_result,
payload?.detail?.generated_image,
payload?.detail?.generated_images,
].filter((value) => value !== undefined && value !== null);
if (outputRoots.length > 0) {
for (const root of outputRoots) {
walk(root);
}
} else if (typeof payload === "string" || Array.isArray(payload)) {
walk(payload);
}
return Array.from(urls);
}
function parseBase64Image(value: string): { buffer: Buffer; mimeType: string } | null {
const trimmed = value.trim();
const dataUrlMatch = trimmed.match(/^data:([^;,]+);base64,(.+)$/i);
const mimeType = dataUrlMatch?.[1] || "image/png";
const base64Value = dataUrlMatch?.[2] || trimmed;
if (!/^[A-Za-z0-9+/=\s]+$/.test(base64Value) || base64Value.length < 64) {
return null;
}
try {
return { buffer: Buffer.from(base64Value.replace(/\s/g, ""), "base64"), mimeType };
} catch {
return null;
}
}
function collectBase64Images(payload: any): Array<{ buffer: Buffer; mimeType: string }> {
const images: Array<{ buffer: Buffer; mimeType: string }> = [];
const seen = new Set<string>();
const walk = (value: any, parentKey = "") => {
if (typeof value === "string") {
if (/base64/i.test(parentKey) || value.trim().startsWith("data:image/")) {
const parsed = parseBase64Image(value);
if (parsed) {
const key = `${parsed.mimeType}:${parsed.buffer.length}:${parsed.buffer.subarray(0, 16).toString("hex")}`;
if (!seen.has(key)) {
seen.add(key);
images.push(parsed);
}
}
}
return;
}
if (Array.isArray(value)) {
for (const item of value) {
walk(item, parentKey);
}
return;
}
if (value && typeof value === "object") {
for (const [key, nestedValue] of Object.entries(value)) {
walk(nestedValue, key);
}
}
};
walk(payload);
return images;
}
function detectImageExtension(imageUrl: string, contentType?: string | null) {
const type = (contentType || "").toLowerCase();
if (type.includes("jpeg") || type.includes("jpg")) return "jpg";
if (type.includes("webp")) return "webp";
if (type.includes("gif")) return "gif";
if (type.includes("png")) return "png";
const cleanUrl = imageUrl.split("?")[0]?.toLowerCase() || "";
if (cleanUrl.endsWith(".jpg") || cleanUrl.endsWith(".jpeg")) return "jpg";
if (cleanUrl.endsWith(".webp")) return "webp";
if (cleanUrl.endsWith(".gif")) return "gif";
if (cleanUrl.endsWith(".png")) return "png";
return "png";
}
function detectMimeExtension(mimeType?: string | null) {
const type = (mimeType || "").toLowerCase();
if (type.includes("jpeg") || type.includes("jpg")) return "jpg";
if (type.includes("webp")) return "webp";
if (type.includes("gif")) return "gif";
return "png";
}
async function createThumbnail(buffer: Buffer): Promise<Buffer> {
try {
return await sharp(buffer)
.resize(400, 400, { fit: "inside", withoutEnlargement: true })
.jpeg({ quality: 80 })
.toBuffer();
} catch (error) {
console.error("[SnapGen Image] Failed to create thumbnail:", error);
return buffer;
}
}
function generateFilename(prefix: string, extension: string) {
const ts = Date.now();
const rand = Math.random().toString(36).slice(2, 8);
return `${prefix}-${ts}-${rand}.${extension}`;
}
async function uploadImageBuffer(buffer: Buffer, extension: string): Promise<UploadedImage> {
const thumbnailBuffer = await createThumbnail(buffer);
const originalFilename = generateFilename("snapgen-image-orig", extension);
const originalArrayBuffer = buffer.buffer.slice(
buffer.byteOffset,
buffer.byteOffset + buffer.byteLength
) as ArrayBuffer;
const originalUploaded = await uploadFile(originalFilename, originalArrayBuffer);
const thumbFilename = generateFilename("snapgen-image-thumb", "jpg");
const thumbArrayBuffer = thumbnailBuffer.buffer.slice(
thumbnailBuffer.byteOffset,
thumbnailBuffer.byteOffset + thumbnailBuffer.byteLength
) as ArrayBuffer;
const thumbUploaded = await uploadFile(thumbFilename, thumbArrayBuffer);
return {
thumbnail: thumbUploaded.url,
original: originalUploaded.url,
};
}
async function downloadAndUploadImage(
imageUrl: string,
timeoutMs = IMAGE_DOWNLOAD_TIMEOUT_MS
): Promise<UploadedImage> {
const deferred = await createDeferredUploadedImage(imageUrl, {
providerType: "snapgen-image",
});
if (deferred) return deferred;
const response = await fetchWithTimeout(imageUrl, {}, timeoutMs);
if (!response.ok) {
throw new Error(`Failed to download SnapGen image: ${response.status}`);
}
const arrayBuffer = await response.arrayBuffer();
const buffer = Buffer.from(arrayBuffer);
const originalExt = detectImageExtension(imageUrl, response.headers.get("content-type"));
return uploadImageBuffer(buffer, originalExt);
}
async function downloadReferenceImageFile(imageUrl: string, index: number) {
let lastError: unknown = null;
for (let attempt = 1; attempt <= REFERENCE_FILE_DOWNLOAD_RETRIES + 1; attempt += 1) {
const controller = new AbortController();
const timeout = setTimeout(
() => controller.abort(),
REFERENCE_FILE_DOWNLOAD_TIMEOUT_MS
);
try {
const response = await fetch(imageUrl, { signal: controller.signal });
if (!response.ok) {
throw new Error(`Failed to download reference image: ${response.status}`);
}
const mimeType = response.headers.get("content-type") || "image/png";
const buffer = Buffer.from(await response.arrayBuffer());
const extension = detectImageExtension(imageUrl, mimeType);
return {
blob: new Blob([new Uint8Array(buffer)], { type: mimeType }),
filename: `snapgen-reference-${index + 1}.${extension}`,
};
} catch (error) {
lastError = error;
if (attempt <= REFERENCE_FILE_DOWNLOAD_RETRIES) {
console.warn("[SnapGen Image] Retrying reference image download:", {
attempt,
imageIndex: index + 1,
error: error instanceof Error ? error.message : String(error),
});
await new Promise((resolve) => setTimeout(resolve, attempt * 1500));
continue;
}
} finally {
clearTimeout(timeout);
}
}
const message =
lastError instanceof Error ? lastError.message : String(lastError || "unknown");
throw new Error(
`SnapGen 参考图下载失败(第 ${index + 1} 张,已重试 ${REFERENCE_FILE_DOWNLOAD_RETRIES} 次):${message}`
);
}
function buildHeaders(apiKey: string) {
return {
"x-api-key": apiKey,
};
}
async function parseJsonResponse(response: Response) {
const rawText = await response.text();
if (!rawText.trim()) return null;
try {
return JSON.parse(rawText);
} catch {
return rawText;
}
}
async function requestSnapgenMultipart({
baseURL,
apiKey,
path,
formData,
timeoutMs,
}: {
baseURL: string;
apiKey: string;
path: string;
formData: FormData;
timeoutMs: number;
}) {
const response = await fetchWithTimeout(joinApiPath(baseURL, path), {
method: "POST",
headers: buildHeaders(apiKey),
body: formData,
}, timeoutMs);
const payload = await parseJsonResponse(response);
if (!response.ok) {
throw new Error(`${extractErrorMessage(payload)} (HTTP ${response.status})`);
}
return payload;
}
function appendGenericFields({
formData,
prompt,
runtimeModelId,
imageUrls,
aspectRatio,
resolution,
resolutionParam,
outputFormat,
}: {
formData: FormData;
prompt: string;
runtimeModelId: string;
imageUrls: string[];
aspectRatio: string;
resolution: string;
resolutionParam: string;
outputFormat: string;
}) {
formData.append("prompt", prompt);
formData.append("model", getGenericEndpointModel(runtimeModelId));
formData.append("aspect_ratio", aspectRatio);
formData.append("resolution", resolutionParam || toSnapgenResolutionParam(resolution));
formData.append("output_format", outputFormat);
for (const imageUrl of imageUrls.slice(0, 8)) {
formData.append("file_urls", imageUrl);
}
}
async function appendReferenceFiles(formData: FormData, imageUrls: string[]) {
for (const [index, imageUrl] of imageUrls.slice(0, 8).entries()) {
const file = await downloadReferenceImageFile(imageUrl, index);
formData.append("files", file.blob, file.filename);
}
}
async function buildRequestForm({
apiFormat,
prompt,
runtimeModelId,
imageUrls,
aspectRatio,
resolution,
resolutionParam,
imageCount,
outputFormat,
quality,
}: {
apiFormat: SnapgenApiFormat;
prompt: string;
runtimeModelId: string;
imageUrls: string[];
aspectRatio: string;
resolution: string;
resolutionParam: string;
imageCount: number;
outputFormat: string;
quality?: string;
}) {
const formData = new FormData();
switch (apiFormat) {
case "snapgen-grok-image": {
formData.append("prompt", prompt);
formData.append("orientation", normalizeOrientation(aspectRatio));
formData.append("num_result", String(imageCount));
formData.append("mode", normalizeGrokMode(quality));
await appendReferenceFiles(formData, imageUrls);
return formData;
}
case "snapgen-meta-ai-image": {
formData.append("prompt", prompt);
formData.append("orientation", normalizeOrientation(aspectRatio));
formData.append("num_result", String(imageCount));
await appendReferenceFiles(formData, imageUrls);
return formData;
}
case "snapgen-gpt-image-2": {
formData.append("prompt", prompt);
formData.append("aspect_ratio", aspectRatio);
formData.append("resolution", resolutionParam || toSnapgenResolutionParam(resolution));
formData.append("mode", normalizeGptImage2Mode(quality));
await appendReferenceFiles(formData, imageUrls);
return formData;
}
case "snapgen-generic-image":
default:
appendGenericFields({
formData,
prompt,
runtimeModelId,
imageUrls,
aspectRatio,
resolution,
resolutionParam,
outputFormat,
});
return formData;
}
}
async function waitForHistoryResult(
baseURL: string,
apiKey: string,
uuid: string,
sourceUrls: string[],
context: {
apiFormat: SnapgenApiFormat;
model: string;
pollTimeoutMs: number;
requestTimeoutMs: number;
}
) {
const maxAttempts = Math.max(
1,
Math.ceil(context.pollTimeoutMs / POLL_INTERVAL_MS)
);
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
const response = await fetchWithTimeout(
joinApiPath(baseURL, `/uapi/v1/history/${encodeURIComponent(uuid)}`),
{ headers: buildHeaders(apiKey) },
context.requestTimeoutMs
);
const payload = await parseJsonResponse(response);
if (!response.ok) {
if (attempt === maxAttempts) {
const errorMessage = extractErrorMessage(payload);
console.error("[SnapGen Image] History request failed:", {
uuid,
model: context.model,
apiFormat: context.apiFormat,
attempt,
status: response.status,
error: errorMessage,
responseSnippet: compactPayloadSnippet(payload),
});
throw new Error(`${errorMessage} (HTTP ${response.status})`);
}
await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS));
continue;
}
const imageUrls = filterSourceImages(collectImageUrls(payload), sourceUrls);
const base64Images = collectBase64Images(payload);
if (imageUrls.length > 0 || base64Images.length > 0) {
return { imageUrls, base64Images };
}
const statusCode = extractStatusCode(payload);
if (statusCode === 3) {
const errorMessage = extractErrorMessage(payload);
console.error("[SnapGen Image] History task failed:", {
uuid,
model: context.model,
apiFormat: context.apiFormat,
attempt,
statusCode,
error: errorMessage,
responseSnippet: compactPayloadSnippet(payload),
});
throw new Error(errorMessage);
}
if (statusCode === 2) {
console.error("[SnapGen Image] History task completed without images:", {
uuid,
model: context.model,
apiFormat: context.apiFormat,
attempt,
statusCode,
responseSnippet: compactPayloadSnippet(payload),
});
throw new Error("SnapGen 任务已完成但未返回图片地址");
}
await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS));
}
throw new Error(
`SnapGen 任务超时(${Math.round(context.pollTimeoutMs / 1000)}秒),请稍后重试`
);
}
function formatMarkdown(images: UploadedImage[], resolution: string) {
return images
.map((image) => `![thumb|||${image.original}|||${resolution}](${image.thumbnail})`)
.join("\n\n");
}
export function createSnapgenImage({ apiKey, baseURL }: SnapgenImageConfig) {
return {
provider: "snapgen-image",
chat: (modelId: string) => ({
provider: "snapgen-image",
doGenerate: async (options: {
messages: Array<{ role: string; content?: any; parts?: any[] }>;
imageParams?: SnapgenImageParams;
imageGenConfig?: SnapgenImageModelConfig | null;
overrideModelId?: string;
}) => {
const { prompt, imageUrls } = extractPromptAndImages(options.messages);
const requestedModelId = normalizeSnapgenModelId(options.overrideModelId || modelId);
const runtimeModelId = resolveSnapgenRuntimeModelId(requestedModelId);
const apiFormat = inferApiFormat(runtimeModelId);
const resolvedAspectRatio = await resolveAspectRatio({
requestedAspectRatio: options.imageParams?.aspectRatio,
imageUrls,
apiFormat,
configuredAspectRatios: options.imageGenConfig?.supportedAspectRatios,
});
const aspectRatio = resolvedAspectRatio.aspectRatio;
const resolution = normalizeResolution(options.imageParams?.imageSize, apiFormat);
const resolutionParam = toSnapgenResolutionParam(resolution);
const outputFormat = normalizeOutputFormat(options.imageParams?.outputFormat);
const requestTimeoutMs = normalizeTimeoutMs(
options.imageParams?.requestTimeoutMs,
REQUEST_TIMEOUT_MS
);
const pollTimeoutMs = normalizeTimeoutMs(
options.imageParams?.pollTimeoutMs,
POLL_INTERVAL_MS * POLL_MAX_ATTEMPTS,
POLL_INTERVAL_MS
);
const historyRequestTimeoutMs = normalizeTimeoutMs(
options.imageParams?.historyRequestTimeoutMs,
HISTORY_REQUEST_TIMEOUT_MS
);
const imageDownloadTimeoutMs = normalizeTimeoutMs(
options.imageParams?.imageDownloadTimeoutMs,
IMAGE_DOWNLOAD_TIMEOUT_MS
);
const maxImages = options.imageGenConfig?.maxImages || (apiFormat === "snapgen-grok-image" ? 8 : apiFormat === "snapgen-meta-ai-image" ? 4 : 1);
const imageCount = clampImageCount(options.imageParams?.imageCount, maxImages);
const promptForRequest = buildSnapgenReferenceOrderPrompt(prompt, imageUrls);
const formData = await buildRequestForm({
apiFormat,
prompt: promptForRequest,
runtimeModelId,
imageUrls,
aspectRatio,
resolution,
resolutionParam,
imageCount,
outputFormat,
quality: options.imageParams?.quality,
});
console.log("[SnapGen Image] Creating task:", {
model: runtimeModelId,
apiFormat,
requestedAspectRatio: resolvedAspectRatio.requestedAspectRatio,
aspectRatio,
aspectRatioSource: resolvedAspectRatio.aspectRatioSource,
referenceImageSize: resolvedAspectRatio.referenceImageSize,
requestedImageSize: options.imageParams?.imageSize,
resolution,
resolutionParam,
imageCount,
referenceImageCount: imageUrls.length,
multiImageReference: imageUrls.length > 1,
referenceTransport:
imageUrls.length === 0
? "none"
: apiFormat === "snapgen-generic-image"
? "file_urls"
: "files",
referenceOrderPrompt: promptForRequest !== prompt,
requestTimeoutMs,
pollTimeoutMs,
historyRequestTimeoutMs,
imageDownloadTimeoutMs,
});
const createPayload = await requestSnapgenMultipart({
baseURL,
apiKey,
path: getEndpointPath(apiFormat),
formData,
timeoutMs: requestTimeoutMs,
});
const statusCode = extractStatusCode(createPayload);
const uuid = extractUuid(createPayload);
console.log("[SnapGen Image] Create task response:", {
model: runtimeModelId,
apiFormat,
statusCode,
uuid: uuid || null,
responseSnippet: compactPayloadSnippet(createPayload),
});
let outputUrls: string[] = [];
let base64Images: Array<{ buffer: Buffer; mimeType: string }> = [];
if (statusCode === 3) {
throw new Error(extractErrorMessage(createPayload));
}
if (statusCode === 2 || !uuid) {
outputUrls = filterSourceImages(collectImageUrls(createPayload), imageUrls);
base64Images = collectBase64Images(createPayload);
}
if ((uuid && statusCode !== 2) || (outputUrls.length === 0 && base64Images.length === 0)) {
if (!uuid) {
throw new Error(
`SnapGen 创建任务成功,但未返回 uuid 或图片结果,响应片段: ${compactPayloadSnippet(createPayload)}`
);
}
const historyResult = await waitForHistoryResult(baseURL, apiKey, uuid, imageUrls, {
apiFormat,
model: runtimeModelId,
pollTimeoutMs,
requestTimeoutMs: historyRequestTimeoutMs,
});
outputUrls = historyResult.imageUrls;
base64Images = historyResult.base64Images;
}
const uploadedImages: UploadedImage[] = [];
for (const outputUrl of outputUrls.slice(0, imageCount)) {
uploadedImages.push(
await downloadAndUploadImage(outputUrl, imageDownloadTimeoutMs)
);
}
if (uploadedImages.length < imageCount) {
for (const image of base64Images.slice(0, imageCount - uploadedImages.length)) {
uploadedImages.push(
await uploadImageBuffer(image.buffer, detectMimeExtension(image.mimeType))
);
}
}
if (uploadedImages.length === 0) {
throw new Error("SnapGen 未返回可用图片");
}
const text = formatMarkdown(uploadedImages, resolution);
return {
text,
finishReason: "stop" as const,
usage: {
promptTokens: 0,
completionTokens: text.length,
},
};
},
doStream: async function* (options: {
messages: Array<{ role: string; content?: any; parts?: any[] }>;
imageParams?: SnapgenImageParams;
imageGenConfig?: SnapgenImageModelConfig | null;
overrideModelId?: string;
}) {
const result = await this.doGenerate(options);
yield {
type: "text-delta",
textDelta: result.text,
};
},
}),
};
}