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(); 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)[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(); 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 { 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 { 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 { 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, }; }, }), }; }