406 lines
11 KiB
TypeScript
406 lines
11 KiB
TypeScript
import { createHash, timingSafeEqual } from "node:crypto";
|
|
import { sql } from "drizzle-orm";
|
|
import { getLanguageModelById } from "@/lib/ai/dynamic-provider";
|
|
import { isImageGenerationModelDescriptor } from "@/lib/ai/model-classification";
|
|
import { db } from "@/lib/db";
|
|
import { getEnabledAiModelsWithProvider, getSystemSetting } from "@/lib/db/queries";
|
|
import { user } from "@/lib/db/schema";
|
|
import { generateDummyPassword } from "@/lib/db/utils";
|
|
import {
|
|
DEFAULT_EXTERNAL_IMAGE_API_OWNER_EMAIL,
|
|
EXTERNAL_IMAGE_API_CONFIG_KEY,
|
|
normalizeExternalImageApiConfig,
|
|
type ExternalImageApiAccessKey,
|
|
} from "@/lib/external-image-api/config";
|
|
import { extractImageUrlsFromText } from "@/lib/image-url";
|
|
import { uploadFile } from "@/lib/storage/service";
|
|
|
|
type ImageModelEntry = Awaited<ReturnType<typeof getEnabledAiModelsWithProvider>>[number];
|
|
|
|
function normalizeText(value: unknown) {
|
|
return String(value || "").trim();
|
|
}
|
|
|
|
function mimeTypeToExtension(mimeType: string) {
|
|
const normalized = mimeType.toLowerCase();
|
|
if (normalized.includes("png")) return "png";
|
|
if (normalized.includes("webp")) return "webp";
|
|
if (normalized.includes("gif")) return "gif";
|
|
if (normalized.includes("bmp")) return "bmp";
|
|
return "jpg";
|
|
}
|
|
|
|
function getExtensionFromUrl(url: string): string {
|
|
try {
|
|
const pathname = new URL(url).pathname;
|
|
const ext = pathname.split(".").pop()?.toLowerCase();
|
|
if (!ext) return "jpg";
|
|
if (["jpg", "jpeg", "png", "webp", "gif", "bmp"].includes(ext)) {
|
|
return ext;
|
|
}
|
|
} catch {
|
|
// ignore
|
|
}
|
|
return "jpg";
|
|
}
|
|
|
|
function safeTokenCompare(left: string, right: string) {
|
|
const a = Buffer.from(left);
|
|
const b = Buffer.from(right);
|
|
if (a.length !== b.length) return false;
|
|
return timingSafeEqual(a, b);
|
|
}
|
|
|
|
function extractBearerToken(request: Request) {
|
|
const authorization = normalizeText(request.headers.get("authorization"));
|
|
if (/^bearer\s+/i.test(authorization)) {
|
|
return authorization.replace(/^bearer\s+/i, "").trim();
|
|
}
|
|
return normalizeText(request.headers.get("x-api-key"));
|
|
}
|
|
|
|
async function uploadRemoteImageToStorage(imageUrl: string): Promise<string> {
|
|
const response = await fetch(imageUrl, {
|
|
method: "GET",
|
|
signal: AbortSignal.timeout(30_000),
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw new Error(
|
|
`Remote image fetch failed: ${response.status} ${response.statusText}`,
|
|
);
|
|
}
|
|
|
|
const contentType = response.headers.get("content-type")?.toLowerCase() || "";
|
|
if (contentType && !contentType.startsWith("image/")) {
|
|
throw new Error(`Remote url is not an image: ${contentType}`);
|
|
}
|
|
|
|
const arrayBuffer = await response.arrayBuffer();
|
|
if (!arrayBuffer.byteLength) {
|
|
throw new Error("Remote image content is empty");
|
|
}
|
|
|
|
const ext = contentType
|
|
? mimeTypeToExtension(contentType)
|
|
: getExtensionFromUrl(imageUrl);
|
|
const fileName = `external-image-api-${Date.now()}-${Math.random()
|
|
.toString(36)
|
|
.slice(2, 8)}.${ext}`;
|
|
const uploaded = await uploadFile(fileName, arrayBuffer);
|
|
return uploaded.url;
|
|
}
|
|
|
|
async function uploadDataUrlImage(dataUrl: string): Promise<string> {
|
|
const match = dataUrl.match(/^data:([^;]+);base64,([\s\S]+)$/i);
|
|
if (!match) {
|
|
throw new Error("Invalid data URL image format");
|
|
}
|
|
|
|
const mimeType = match[1] || "image/jpeg";
|
|
const base64Data = normalizeText(match[2]).replace(/\s+/g, "");
|
|
if (!base64Data) {
|
|
throw new Error("Empty base64 image content");
|
|
}
|
|
|
|
const buffer = Buffer.from(base64Data, "base64");
|
|
const fileName = `external-inline-${Date.now()}-${Math.random()
|
|
.toString(36)
|
|
.slice(2, 8)}.${mimeTypeToExtension(mimeType)}`;
|
|
const arrayBuffer = buffer.buffer.slice(
|
|
buffer.byteOffset,
|
|
buffer.byteOffset + buffer.byteLength,
|
|
) as ArrayBuffer;
|
|
const uploaded = await uploadFile(fileName, arrayBuffer);
|
|
return uploaded.url;
|
|
}
|
|
|
|
async function ensureHostedImageUrl(imageUrl: string): Promise<string> {
|
|
const normalizedUrl = normalizeText(imageUrl);
|
|
if (!normalizedUrl) {
|
|
throw new Error("Image URL is empty");
|
|
}
|
|
|
|
if (normalizedUrl.startsWith("data:image")) {
|
|
return uploadDataUrlImage(normalizedUrl);
|
|
}
|
|
|
|
if (!/^https?:\/\//i.test(normalizedUrl)) {
|
|
throw new Error(`Unsupported image URL: ${normalizedUrl}`);
|
|
}
|
|
|
|
if (/\/uploads\//i.test(normalizedUrl)) {
|
|
return normalizedUrl;
|
|
}
|
|
|
|
return uploadRemoteImageToStorage(normalizedUrl);
|
|
}
|
|
|
|
export async function ensureHostedImageMarkdown(
|
|
markdown: string,
|
|
imageSize = "2K",
|
|
) {
|
|
const pattern = /!\[([^\]]*)\]\(([^)]+)\)/gim;
|
|
const matches = [...markdown.matchAll(pattern)];
|
|
|
|
if (!matches.length) {
|
|
const plainUrls = extractImageUrlsFromText(markdown);
|
|
if (!plainUrls.length) {
|
|
return markdown;
|
|
}
|
|
|
|
const hostedUrls = await Promise.all(
|
|
plainUrls.map((url) => ensureHostedImageUrl(url)),
|
|
);
|
|
return hostedUrls
|
|
.map((url) => ``)
|
|
.join("\n");
|
|
}
|
|
|
|
let rebuilt = "";
|
|
let cursor = 0;
|
|
|
|
for (const match of matches) {
|
|
const raw = match[0];
|
|
const sourceUrl = match[2] || "";
|
|
const start = match.index ?? -1;
|
|
if (start < 0) continue;
|
|
|
|
rebuilt += markdown.slice(cursor, start);
|
|
const hostedUrl = await ensureHostedImageUrl(sourceUrl);
|
|
rebuilt += ``;
|
|
cursor = start + raw.length;
|
|
}
|
|
|
|
rebuilt += markdown.slice(cursor);
|
|
return rebuilt;
|
|
}
|
|
|
|
export function extractGeneratedImagesFromMarkdown(markdown: string) {
|
|
const pattern = /!\[([^\]]*)\]\(([^)]+)\)/gim;
|
|
const matches = [...markdown.matchAll(pattern)];
|
|
|
|
return matches.map((match, index) => {
|
|
const alt = match[1] || "";
|
|
const thumbnailUrl = match[2] || "";
|
|
let originalUrl = thumbnailUrl;
|
|
let imageSize = "2K";
|
|
const metaMatch = alt.match(/thumb\|\|\|([^|]+)\|\|\|(.+)$/i);
|
|
if (metaMatch) {
|
|
originalUrl = normalizeText(metaMatch[1]) || thumbnailUrl;
|
|
imageSize = normalizeText(metaMatch[2]) || imageSize;
|
|
}
|
|
return {
|
|
id: index + 1,
|
|
originalUrl,
|
|
thumbnailUrl,
|
|
imageSize,
|
|
};
|
|
});
|
|
}
|
|
|
|
export async function getExternalImageApiConfig() {
|
|
const setting = await getSystemSetting({
|
|
key: EXTERNAL_IMAGE_API_CONFIG_KEY,
|
|
}).catch(() => null);
|
|
return normalizeExternalImageApiConfig(setting?.value);
|
|
}
|
|
|
|
export async function authenticateExternalImageApiRequest(request: Request) {
|
|
const config = await getExternalImageApiConfig();
|
|
const token = extractBearerToken(request);
|
|
|
|
if (!config.enabled) {
|
|
return {
|
|
config,
|
|
accessKey: null,
|
|
token,
|
|
error: "第三方生图 API 未启用",
|
|
status: 403 as const,
|
|
};
|
|
}
|
|
|
|
if (!token) {
|
|
return {
|
|
config,
|
|
accessKey: null,
|
|
token,
|
|
error: "缺少 API Key",
|
|
status: 401 as const,
|
|
};
|
|
}
|
|
|
|
const accessKey =
|
|
config.apiKeys.find(
|
|
(item) => item.enabled && safeTokenCompare(item.apiKey, token),
|
|
) || null;
|
|
|
|
if (!accessKey) {
|
|
return {
|
|
config,
|
|
accessKey,
|
|
token,
|
|
error: "API Key 无效或已禁用",
|
|
status: 401 as const,
|
|
};
|
|
}
|
|
|
|
return {
|
|
config,
|
|
accessKey,
|
|
token,
|
|
error: null,
|
|
status: 200 as const,
|
|
};
|
|
}
|
|
|
|
export async function getExternalImageApiModels() {
|
|
const models = await getEnabledAiModelsWithProvider(false, false);
|
|
|
|
return models.filter((entry) =>
|
|
isImageGenerationModelDescriptor({
|
|
providerType: entry.provider.type,
|
|
modelId: entry.model.modelId,
|
|
capabilities: entry.model.capabilities,
|
|
supportsImageGen: entry.model.supportsImageGen,
|
|
}),
|
|
);
|
|
}
|
|
|
|
export function serializeExternalImageApiModel(entry: ImageModelEntry) {
|
|
return {
|
|
id: entry.model.id,
|
|
modelId: entry.model.modelId,
|
|
displayName: entry.model.displayName,
|
|
description: entry.model.description,
|
|
providerType: entry.provider.type,
|
|
providerName: entry.provider.name,
|
|
avatarUrl: entry.model.avatarUrl,
|
|
imageGenConfig: entry.model.imageGenConfig,
|
|
};
|
|
}
|
|
|
|
export async function resolveExternalImageApiModel(modelRef?: string | null) {
|
|
const models = await getExternalImageApiModels();
|
|
if (!models.length) {
|
|
throw new Error("当前没有启用可用于生图的模型");
|
|
}
|
|
|
|
const normalizedRef = normalizeText(modelRef).toLowerCase();
|
|
if (!normalizedRef) {
|
|
return models[0];
|
|
}
|
|
|
|
const matched = models.find((entry) => {
|
|
const candidates = [
|
|
entry.model.id,
|
|
entry.model.modelId,
|
|
entry.model.displayName,
|
|
]
|
|
.map((item) => normalizeText(item).toLowerCase())
|
|
.filter(Boolean);
|
|
return candidates.includes(normalizedRef);
|
|
});
|
|
|
|
if (!matched) {
|
|
throw new Error("未找到匹配的生图模型");
|
|
}
|
|
|
|
return matched;
|
|
}
|
|
|
|
export async function resolveExternalImageApiOwnerUserId(ownerEmail?: string | null) {
|
|
const normalizedEmail = normalizeText(ownerEmail).toLowerCase() ||
|
|
DEFAULT_EXTERNAL_IMAGE_API_OWNER_EMAIL;
|
|
|
|
const existing = await db
|
|
.select({ id: user.id })
|
|
.from(user)
|
|
.where(sql`lower(${user.email}) = ${normalizedEmail}`)
|
|
.limit(1);
|
|
|
|
if (existing[0]?.id) {
|
|
return existing[0].id;
|
|
}
|
|
|
|
const [created] = await db
|
|
.insert(user)
|
|
.values({
|
|
email: normalizedEmail,
|
|
password: generateDummyPassword(),
|
|
role: "guest",
|
|
name: "第三方生图 API",
|
|
isActive: true,
|
|
})
|
|
.returning({ id: user.id });
|
|
|
|
if (!created?.id) {
|
|
throw new Error("创建第三方 API 挂载用户失败");
|
|
}
|
|
|
|
return created.id;
|
|
}
|
|
|
|
export async function generateImagesWithModel(params: {
|
|
modelDbId: string;
|
|
prompt: string;
|
|
negativePrompt?: string | null;
|
|
imageCount: number;
|
|
aspectRatio?: string | null;
|
|
imageSize?: string | null;
|
|
}) {
|
|
const languageModel = await getLanguageModelById(params.modelDbId);
|
|
if (!languageModel || typeof (languageModel as any).doGenerate !== "function") {
|
|
throw new Error("当前模型暂不支持第三方生图调用");
|
|
}
|
|
|
|
const composedPrompt = [
|
|
normalizeText(params.prompt),
|
|
normalizeText(params.negativePrompt)
|
|
? `Negative prompt:\n${normalizeText(params.negativePrompt)}`
|
|
: "",
|
|
]
|
|
.filter(Boolean)
|
|
.join("\n\n");
|
|
|
|
const result = await (languageModel as any).doGenerate({
|
|
messages: [
|
|
{
|
|
role: "user",
|
|
content: [{ type: "text", text: composedPrompt }],
|
|
},
|
|
],
|
|
imageParams: {
|
|
imageCount: params.imageCount,
|
|
aspectRatio: normalizeText(params.aspectRatio) || undefined,
|
|
imageSize: normalizeText(params.imageSize) || undefined,
|
|
},
|
|
});
|
|
|
|
if (result?.finishReason === "error") {
|
|
throw new Error(result.text || "图片生成失败");
|
|
}
|
|
|
|
const text = normalizeText(result?.text);
|
|
if (!text) {
|
|
throw new Error("模型未返回生成图片");
|
|
}
|
|
|
|
return text;
|
|
}
|
|
|
|
export function buildExternalImageApiKeyFingerprint(
|
|
accessKey: ExternalImageApiAccessKey | null,
|
|
) {
|
|
if (!accessKey?.apiKey) return null;
|
|
return createHash("sha256").update(accessKey.apiKey).digest("hex").slice(0, 16);
|
|
}
|
|
|
|
export function getRequesterIp(request: Request) {
|
|
const forwarded = normalizeText(request.headers.get("x-forwarded-for"));
|
|
if (forwarded) {
|
|
return forwarded.split(",")[0]?.trim() || null;
|
|
}
|
|
return normalizeText(request.headers.get("x-real-ip")) || null;
|
|
}
|