1157 lines
36 KiB
TypeScript
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) => ``)
|
|
.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,
|
|
};
|
|
},
|
|
}),
|
|
};
|
|
}
|