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

1160 lines
36 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import "server-only";
import sharp from "sharp";
import { createDeferredUploadedImage } from "@/lib/storage/image-archive";
import { uploadFile } from "@/lib/storage/service";
import { buildReferenceImageOrderPrompt } from "@/lib/ai/providers/reference-image-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().toUpperCase();
}
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 mime = extension === "jpg" || extension === "jpeg" ? "image/jpeg" : `image/${extension}`;
const deferred = await createDeferredUploadedImage(`data:${mime};base64,${buffer.toString("base64")}`, { providerType: "snapgen-image" });
if (deferred) return deferred;
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 = buildReferenceImageOrderPrompt(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,
};
},
}),
};
}