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