ai-chatbot/app/(chat)/api/chat/_lib/image-media.ts

1003 lines
31 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

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

import { createLogger } from "@/lib/observability/logger";
import { convertToModelMessages } from "ai";
import { inlineVisionImages } from "@/lib/ai/vision-inline";
import { extractImageUrlsFromText } from "@/lib/image-url";
import { parseImageAltMetadata } from "@/lib/ai/runtime/generated-image-persistence";
import { uploadFile } from "@/lib/storage/service";
import type { ChatMessage } from "@/lib/types";
import { ANNOTATION_SYSTEM_PROMPT, hasAnnotatedImage } from "./message-preparation";
const chatApiLogger = createLogger("chat.imageMedia");
export function guessImageMediaType(url: string): string {
const cleanUrl = url.split("?")[0]?.toLowerCase() || "";
if (cleanUrl.endsWith(".jpg") || cleanUrl.endsWith(".jpeg")) return "image/jpeg";
if (cleanUrl.endsWith(".webp")) return "image/webp";
if (cleanUrl.endsWith(".gif")) return "image/gif";
if (cleanUrl.endsWith(".bmp")) return "image/bmp";
if (cleanUrl.endsWith(".svg")) return "image/svg+xml";
return "image/png";
}
export function injectHistoryImagesToLastUserMessage(
messages: ChatMessage[],
historyImages: string[]
): ChatMessage[] {
if (!historyImages || historyImages.length === 0) return messages;
let lastUserIndex = -1;
for (let i = messages.length - 1; i >= 0; i--) {
if (messages[i]?.role === "user") {
lastUserIndex = i;
break;
}
}
if (lastUserIndex < 0) return messages;
const lastUserMessage = messages[lastUserIndex];
const hasExistingImage = lastUserMessage.parts?.some(
(p: any) => p.type === "file" || p.type === "image" || p.type === "image_url"
);
if (hasExistingImage) return messages;
const imageParts = historyImages.map((url, index) => ({
type: "file" as const,
url,
name: `history-image-${index + 1}`,
mediaType: guessImageMediaType(url),
mimeType: guessImageMediaType(url),
}));
const updatedMessage: ChatMessage = {
...lastUserMessage,
parts: [...lastUserMessage.parts, ...imageParts],
};
return messages.map((msg, idx) => (idx === lastUserIndex ? updatedMessage : msg));
}
export function isImageInputPart(part: any) {
return part?.type === "file" || part?.type === "image" || part?.type === "image_url";
}
export function normalizeResolvedTargetImageUrls(imageUrls?: string[] | null) {
return Array.from(
new Set(
(imageUrls || [])
.map((url) => String(url || "").trim())
.filter((url) => /^https?:\/\//i.test(url))
)
).slice(0, 8);
}
export function summarizeTargetImageUrl(url?: string) {
const normalized = String(url || "").trim();
if (!normalized) return undefined;
try {
const parsed = new URL(normalized);
return `${parsed.origin}${parsed.pathname}`.slice(0, 160);
} catch {
return normalized.slice(0, 160);
}
}
export function applyResolvedTargetImagesToLastUserMessage(
messages: ChatMessage[],
targetImageUrls: string[]
): ChatMessage[] {
const normalizedTargetImageUrls = normalizeResolvedTargetImageUrls(targetImageUrls);
if (normalizedTargetImageUrls.length === 0) return messages;
let lastUserIndex = -1;
for (let i = messages.length - 1; i >= 0; i--) {
if (messages[i]?.role === "user") {
lastUserIndex = i;
break;
}
}
if (lastUserIndex < 0) return messages;
const lastUserMessage = messages[lastUserIndex];
const nonImageParts = (lastUserMessage.parts || []).filter((part: any) => !isImageInputPart(part));
const imageParts = normalizedTargetImageUrls.map((url, index) => ({
type: "file" as const,
url,
name: `target-image-${index + 1}`,
mediaType: guessImageMediaType(url),
mimeType: guessImageMediaType(url),
}));
const updatedMessage: ChatMessage = {
...lastUserMessage,
parts: [...imageParts, ...nonImageParts],
};
return messages.map((msg, idx) => (idx === lastUserIndex ? updatedMessage : msg));
}
export function compactAutoDemand(text: string, maxLength = 56): string {
const normalized = text.replace(/\s+/g, " ").trim();
if (!normalized) return "按你刚才的描述执行";
if (normalized.length <= maxLength) return normalized;
return `${normalized.slice(0, maxLength)}...`;
}
export function buildAutoModeFallbackMessage(params: {
text: string;
hasImage: boolean;
fromHistory: boolean;
imageSummary?: string;
}): string {
const demand = compactAutoDemand(params.text);
if (params.hasImage) {
const sourceLine = params.fromHistory
? "基于上一轮图片继续处理。"
: "基于当前上传图片处理。";
return [
"### 意图分析",
`- ${demand}`,
"### 图片分析",
params.imageSummary
? `- ${params.imageSummary.replace(/\s+/g, " ").trim()}`
: `- ${sourceLine}`,
"- 我会优先保持主体、构图和整体风格一致,只处理你指定的改动。",
"### 任务规划",
"- 先完成本轮核心改动,再检查边缘与光影一致性。",
"- 若结果偏差,我会按“保留A,修改B”的方式继续迭代。",
"### 输出设置",
"- 默认单图输出,优先稳定命中。",
].join("\n");
}
return [
"### 意图分析",
`- ${demand}`,
"### 任务规划",
"- 先锁定主体与场景,再细化风格、材质与光影。",
"### 输出设置",
"- 默认单图输出,优先稳定命中。",
].join("\n");
}
export function sanitizePromptOnlyUserIntent(text: string): string {
return String(text || "")
.replace(/先?分析(?:一下)?(?:这张)?(?:图片|图像|这张图)?/g, "")
.replace(/帮我|给我|先给我|只给我|仅给我/g, "")
.replace(/优化(?:一下)?提示词|提示词优化|提示词|prompt/gi, "")
.replace(/不要生图|先别生图|不需要生图|不要生成|别生成|不直接生成/g, "")
.replace(/[,。;:,.!!??]/g, " ")
.replace(/\s+/g, " ")
.trim();
}
export function buildPromptOnlyOptimizationSeed(params: {
text: string;
imageSummary?: string;
}): string {
const cleanedIntent = sanitizePromptOnlyUserIntent(params.text);
const summary = String(params.imageSummary || "").replace(/\s+/g, " ").trim();
if (cleanedIntent && summary) {
return `${cleanedIntent}。参考图关键特征:${summary}`;
}
if (summary) {
return `基于参考图生成一段可直接复现核心主体、场景、风格与光线特征的中文提示词。参考图关键特征:${summary}`;
}
if (cleanedIntent) {
return cleanedIntent;
}
return "基于当前参考图整理一段可直接使用的中文提示词。";
}
export function buildPromptOnlyReply(params: {
userText: string;
imageSummary?: string;
optimizedPrompt: string;
}): string {
const demand = sanitizePromptOnlyUserIntent(params.userText) || "你希望我先分析这张图并整理可直接使用的提示词";
const summaryLines = String(params.imageSummary || "")
.split(/\r?\n/)
.map((line) => line.replace(/\s+/g, " ").trim())
.filter(Boolean);
const formattedSummary =
summaryLines.length > 0
? summaryLines.map((line) => `- ${line}`).join("\n")
: "- 我已基于当前图片梳理主体、场景、风格与光线特征。";
return [
"【需求理解】",
`- ${demand}`,
"【图片分析】",
formattedSummary,
"【优化后提示词】",
`- ${params.optimizedPrompt.replace(/\s+/g, " ").trim()}`,
"【使用建议】",
"- 本轮仅输出优化后的提示词,不直接执行生图。",
].join("\n");
}
export function hasStructuredAgentSections(message?: string): boolean {
if (!message) return false;
return /(?:###\s*意图分析|【\s*意图分析\s*】|###\s*任务规划|【\s*任务规划\s*】|###\s*需求理解|【\s*需求理解\s*】|###\s*图片分析|【\s*图片分析\s*】|###\s*执行计划|【\s*执行计划\s*】|###\s*优化后提示词|【\s*优化后提示词\s*】)/.test(
message,
);
}
export function ensureStructuredAgentFriendlyMessage(params: {
message?: string;
text: string;
hasImage: boolean;
fromHistory: boolean;
}): string {
const existingMessage = String(params.message || "").trim();
if (hasStructuredAgentSections(existingMessage)) {
return existingMessage;
}
const fallback = buildAutoModeFallbackMessage({
text: params.text,
hasImage: params.hasImage,
fromHistory: params.fromHistory,
});
if (!existingMessage) {
return fallback;
}
const normalizedSupplement = existingMessage.replace(/\s+/g, " ").trim();
if (!normalizedSupplement) {
return fallback;
}
if (fallback.includes("### 任务规划\n")) {
return fallback.replace(
"### 任务规划\n",
`### 任务规划\n- 补充说明:${normalizedSupplement}\n`,
);
}
return fallback.replace(
"### 执行计划\n",
`### 执行计划\n- 补充说明:${normalizedSupplement}\n`,
);
}
export function extractMessageImageUrls(parts: any[]): string[] {
return (parts || [])
.map((part: any) => {
if (!part) return null;
if (part.type === "file") return part.url || part.data || null;
if (part.type === "image") return part.url || part.image || null;
if (part.type === "image_url") return part.image_url?.url || null;
return null;
})
.filter((url: any): url is string => typeof url === "string" && /^https?:\/\//i.test(url));
}
export function extractMessageImageAssetIds(parts: any[]): string[] {
return Array.from(
new Set(
(parts || [])
.map((part: any) => {
if (!part || typeof part !== "object") return null;
return typeof part.assetId === "string" && part.assetId.trim()
? part.assetId.trim()
: null;
})
.filter((assetId: any): assetId is string => typeof assetId === "string" && assetId.length > 0),
),
);
}
export function shouldEnrichImageAnalysisText(message?: string): boolean {
if (!message) return false;
if (!/(###\s*图片分析|【\s*图片分析\s*】)/.test(message)) return false;
return /(基于当前上传图片处理|基于你选中的原图处理|基于上一轮图片继续处理)/.test(message);
}
export function injectImageAnalysisSummary(message: string, summary: string): string {
const normalizedSummaryLines = summary
.split(/\r?\n/)
.map((line) => line.replace(/\s+/g, " ").trim())
.filter(Boolean);
if (normalizedSummaryLines.length === 0) return message;
const lines = message.split("\n");
const analysisHeaderIndex = lines.findIndex((line) =>
/^(###\s*图片分析|【\s*图片分析\s*】)/.test(line.trim())
);
if (analysisHeaderIndex < 0) return message;
for (let i = analysisHeaderIndex + 1; i < lines.length; i += 1) {
const current = lines[i]?.trim() || "";
if (/^(###\s*|【\s*[^】]+\s*】)/.test(current)) break;
if (/^- /.test(current)) {
lines.splice(i, 1, ...normalizedSummaryLines.map((line) => `- ${line}`));
return lines.join("\n");
}
}
const insertIndex = analysisHeaderIndex + 1;
lines.splice(insertIndex, 0, ...normalizedSummaryLines.map((line) => `- ${line}`));
return lines.join("\n");
}
export function ensureImageAnalysisSummarySection(message: string, summary: string): string {
const normalizedSummaryLines = summary
.split(/\r?\n/)
.map((line) => line.replace(/\s+/g, " ").trim())
.filter(Boolean);
if (normalizedSummaryLines.length === 0) return message;
if (/(###\s*图片分析|【\s*图片分析\s*】)/.test(message)) {
return injectImageAnalysisSummary(message, normalizedSummaryLines.join("\n"));
}
const trimmed = message.trim();
const summaryBlock = normalizedSummaryLines.map((line) => `- ${line}`).join("\n");
if (!trimmed) return `【图片分析】\n${summaryBlock}`;
return `${trimmed}\n【图片分析】\n${summaryBlock}`;
}
/**
* 自定义消息转换函数
* 注意:gemini-image 和 huanke-gemini 都需要 URL 格式(不是 Base64)
* 因为这些 provider 内部会自行处理图片上传到 S3
*/
export async function convertMessagesWithImages(
messages: ChatMessage[],
providerType:
| string
| {
providerType?: string;
providerBaseUrl?: string | null;
modelId?: string | null;
displayName?: string | null;
forceInlineImageUrls?: boolean;
loggerPrefix?: string;
},
) {
type ImageConversionTarget = {
providerType?: string;
providerBaseUrl?: string | null;
modelId?: string | null;
displayName?: string | null;
forceInlineImageUrls?: boolean;
loggerPrefix?: string;
};
const normalizeTarget = (
target: string | ImageConversionTarget,
): Required<Pick<ImageConversionTarget, "providerType" | "forceInlineImageUrls" | "loggerPrefix">> &
Omit<ImageConversionTarget, "providerType" | "forceInlineImageUrls" | "loggerPrefix"> => {
if (typeof target === "string") {
return {
providerType: target,
providerBaseUrl: null,
modelId: null,
displayName: null,
forceInlineImageUrls: false,
loggerPrefix: "[Chat API] Vision inline",
};
}
return {
providerType: String(target.providerType || ""),
providerBaseUrl: target.providerBaseUrl || null,
modelId: target.modelId || null,
displayName: target.displayName || null,
forceInlineImageUrls: target.forceInlineImageUrls === true,
loggerPrefix: target.loggerPrefix || "[Chat API] Vision inline",
};
};
const extractImagePartUrl = (part: unknown): string | null => {
const partAny = part as any;
const candidateUrl =
partAny?.type === "file"
? partAny.url || partAny.data
: partAny?.type === "image"
? partAny.image || partAny.url
: partAny?.type === "image_url"
? partAny.image_url?.url
: undefined;
if (typeof candidateUrl !== "string") return null;
const normalized = candidateUrl.trim();
if (!/^https?:\/\//i.test(normalized)) return null;
return normalized;
};
const replaceImagePartUrl = (part: unknown, nextUrl: string): unknown => {
const partAny = part as any;
if (partAny?.type === "file") {
return {
...partAny,
url: nextUrl,
data: nextUrl,
mediaType: String(partAny.mediaType || "").startsWith("image/")
? partAny.mediaType
: "image/jpeg",
mimeType: String(partAny.mimeType || "").startsWith("image/")
? partAny.mimeType
: "image/jpeg",
};
}
if (partAny?.type === "image") {
return {
...partAny,
image: nextUrl,
url: nextUrl,
};
}
if (partAny?.type === "image_url") {
return {
...partAny,
image_url: {
...(partAny.image_url || {}),
url: nextUrl,
},
};
}
return part;
};
const inlineImageUrlsInMessages = async (
sourceMessages: ChatMessage[],
target: ReturnType<typeof normalizeTarget>,
) => {
const uniqueUrls = Array.from(
new Set(
sourceMessages
.flatMap((message) => message.parts || [])
.map((part) => extractImagePartUrl(part))
.filter((url): url is string => Boolean(url)),
),
);
if (uniqueUrls.length === 0) {
return sourceMessages;
}
const { inlineDataUrls } = await inlineVisionImages(uniqueUrls, {
maxImages: Math.min(8, uniqueUrls.length),
maxSide: 1200,
timeoutMs: 12000,
retries: 2,
loggerPrefix: target.loggerPrefix,
}).catch((error) => {
chatApiLogger.warn(`${target.loggerPrefix} failed to inline message images:`, error);
return { inlineDataUrls: [], skipped: [] };
});
if (inlineDataUrls.length === 0) {
return sourceMessages;
}
const inlineMap = new Map<string, string>();
for (let index = 0; index < inlineDataUrls.length; index += 1) {
const originalUrl = uniqueUrls[index];
const inlineUrl = inlineDataUrls[index];
if (originalUrl && inlineUrl) {
inlineMap.set(originalUrl, inlineUrl);
}
}
if (inlineMap.size === 0) {
return sourceMessages;
}
chatApiLogger.info(`${target.loggerPrefix} inlined chat message images:`, {
total: uniqueUrls.length,
inlined: inlineMap.size,
providerType: target.providerType,
modelId: target.modelId,
displayName: target.displayName,
});
return sourceMessages.map((message) => ({
...message,
parts: (message.parts || []).map((part) => {
const originalUrl = extractImagePartUrl(part);
if (!originalUrl) return part;
const inlineUrl = inlineMap.get(originalUrl);
return inlineUrl ? replaceImagePartUrl(part, inlineUrl) : part;
}) as typeof message.parts,
})) as ChatMessage[];
};
const target = normalizeTarget(providerType);
let preparedMessages = messages;
if (target.forceInlineImageUrls) {
preparedMessages = await inlineImageUrlsInMessages(preparedMessages, target);
}
const normalizedProviderType = target.providerType;
if (
normalizedProviderType === "bfl-image" ||
normalizedProviderType === "ruxa-image" ||
normalizedProviderType === "fal-image" ||
normalizedProviderType === "snapgen-image" ||
normalizedProviderType === "openai-image" ||
normalizedProviderType === "bananaimage" ||
normalizedProviderType === "visionary" ||
normalizedProviderType === "atlascloud" ||
normalizedProviderType === "vip98k" ||
normalizedProviderType === "stepfun-image" ||
normalizedProviderType === "midjourney" ||
normalizedProviderType === "runway" ||
normalizedProviderType === "krea"
) {
const convertedMessages = [];
for (const message of preparedMessages) {
const content: any[] = [];
for (const part of message.parts) {
const partAny = part as any;
if (partAny.type === "text" && partAny.text) {
content.push({
type: "text",
text: partAny.text,
});
continue;
}
const candidateUrl =
partAny.type === "file"
? (partAny.url || partAny.data)
: partAny.type === "image"
? (partAny.image || partAny.url)
: partAny.type === "image_url"
? partAny.image_url?.url
: undefined;
if (!candidateUrl || typeof candidateUrl !== "string") continue;
if (candidateUrl.startsWith("data:")) {
throw new Error("当前模型不支持 Base64 图片传输,请先上传图片后再编辑");
}
if (!candidateUrl.startsWith("http")) continue;
content.push({
type: "image_url",
image_url: {
url: candidateUrl,
},
});
}
convertedMessages.push({
role: message.role,
content: content.length === 1 && content[0].type === "text" ? content[0].text : content,
});
}
return convertedMessages;
}
// 所有 provider 都使用 URL 格式传输图片
// gemini-image 和 huanke-gemini 内部会将 Base64 转换为 S3 URL
// 这里不再进行 Base64 转换,直接返回标准格式
if (normalizedProviderType !== "gemini") {
return convertToModelMessages(preparedMessages);
}
// 🆕 检测是否有标注图片
const hasAnnotation = hasAnnotatedImage(messages);
if (hasAnnotation) {
chatApiLogger.info("[Message Convert] 🎨 检测到标注图片,将自动添加系统提示");
}
// 对于 Gemini,需要特殊处理图片
const convertedMessages = [];
for (const message of preparedMessages) {
const content: any[] = [];
// 🆕 如果是用户消息且有标注图片,在文本前添加系统提示
let hasAddedAnnotationPrompt = false;
// 处理消息的各个部分
for (const part of message.parts) {
if (part.type === "text" && part.text) {
let textContent = part.text;
// 🆕 如果有标注图片且是用户消息,在用户文本前添加系统提示
if (hasAnnotation && message.role === "user" && !hasAddedAnnotationPrompt) {
textContent = ANNOTATION_SYSTEM_PROMPT + "\n\n用户请求:" + textContent;
hasAddedAnnotationPrompt = true;
chatApiLogger.info("[Message Convert] 🎨 已注入标注提示词");
}
content.push({
type: "text",
text: textContent,
});
continue;
}
const typedPart = part as {
type: string;
url?: string;
image?: string;
image_url?: { url?: string };
data?: string;
};
const candidateImageUrl =
typedPart.type === "file"
? typedPart.url || typedPart.data
: typedPart.type === "image"
? typedPart.image || typedPart.url
: typedPart.type === "image_url"
? typedPart.image_url?.url || typedPart.url
: undefined;
if (typeof candidateImageUrl !== "string" || !candidateImageUrl.trim()) {
continue;
}
const normalizedImageUrl = candidateImageUrl.trim();
// 兼容 file/image/image_url 三类输入;Gemini 内部会将图片交给 S3 后再请求生图网关。
if (typedPart.type === "file" || typedPart.type === "image" || typedPart.type === "image_url") {
content.push({
type: "image_url",
image_url: {
url: normalizedImageUrl,
},
});
chatApiLogger.info(
"[Message Convert] Image kept as URL format:",
normalizedImageUrl.substring(0, 80) + "..."
);
}
}
// 🆕 如果有标注图片但消息中没有文本部分,添加一个纯提示文本
if (hasAnnotation && message.role === "user" && !hasAddedAnnotationPrompt && content.length > 0) {
content.unshift({
type: "text",
text: ANNOTATION_SYSTEM_PROMPT,
});
chatApiLogger.info("[Message Convert] 🎨 已在消息开头注入标注提示词");
}
convertedMessages.push({
role: message.role,
content: content.length === 1 && content[0].type === "text" ? content[0].text : content,
});
}
return convertedMessages;
}
export function extractOriginalImageUrlsFromText(text: string): string[] {
return extractImageUrlsFromText(text);
}
export function normalizeImageInputRef(rawValue: unknown, requestOrigin?: string): string | null {
if (typeof rawValue !== "string") return null;
const trimmed = rawValue.trim();
if (!trimmed) return null;
if (/^data:image\/[a-z0-9+.-]+;base64,/i.test(trimmed)) {
return trimmed.replace(/\s+/g, "");
}
if (/^https?:\/\//i.test(trimmed)) {
return trimmed;
}
if (requestOrigin && trimmed.startsWith("/")) {
try {
return new URL(trimmed, requestOrigin).toString();
} catch {
return null;
}
}
return null;
}
export function collectImageInputsFromMessageParts(
parts: Array<Record<string, any>> | undefined,
requestOrigin?: string,
): string[] {
if (!Array.isArray(parts) || parts.length === 0) return [];
const result: string[] = [];
const pushInput = (rawValue: unknown) => {
const normalized = normalizeImageInputRef(rawValue, requestOrigin);
if (!normalized) return;
if (!result.includes(normalized)) {
result.push(normalized);
}
};
for (const part of parts) {
if (!part) continue;
if (part.type === "file") {
pushInput(part.url);
pushInput(part.data);
continue;
}
if (part.type === "image") {
pushInput(part.url);
pushInput(part.image);
pushInput(part.data);
continue;
}
if (part.type === "image_url") {
pushInput(part.image_url?.url);
pushInput(part.url);
continue;
}
if (part.type === "text" && typeof part.text === "string") {
const parsedUrls = extractOriginalImageUrlsFromText(part.text);
for (const parsedUrl of parsedUrls) {
pushInput(parsedUrl);
}
}
}
return result;
}
export 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";
}
export 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";
}
export 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 || arrayBuffer.byteLength === 0) {
throw new Error("Remote image content is empty");
}
const ext = contentType ? mimeTypeToExtension(contentType) : getExtensionFromUrl(imageUrl);
const fileName = `remote-image-${Date.now()}-${Math.random().toString(36).slice(2, 8)}.${ext}`;
const uploaded = await uploadFile(fileName, arrayBuffer);
return uploaded.url;
}
export async function isRemoteImageAccessible(imageUrl: string): Promise<boolean> {
try {
const headResponse = await fetch(imageUrl, {
method: "HEAD",
signal: AbortSignal.timeout(10_000),
});
if (headResponse.ok) {
return true;
}
if (headResponse.status !== 405 && headResponse.status !== 403) {
return false;
}
const probeResponse = await fetch(imageUrl, {
method: "GET",
headers: {
Range: "bytes=0-0",
},
signal: AbortSignal.timeout(10_000),
});
return probeResponse.ok;
} catch {
return false;
}
}
export async function ensureHostedImageUrl(imageUrl: string): Promise<string> {
const normalizedUrl = imageUrl.trim();
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)) {
const accessible = await isRemoteImageAccessible(normalizedUrl);
if (accessible) {
return normalizedUrl;
}
}
return uploadRemoteImageToStorage(normalizedUrl);
}
export 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 = (match[2] || "").replace(/\s+/g, "");
if (!base64Data) {
throw new Error("Empty base64 image content");
}
const buffer = Buffer.from(base64Data, "base64");
const fileExt = mimeTypeToExtension(mimeType);
const fileName = `inline-image-${Date.now()}-${Math.random().toString(36).slice(2, 8)}.${fileExt}`;
const arrayBuffer = buffer.buffer.slice(
buffer.byteOffset,
buffer.byteOffset + buffer.byteLength
) as ArrayBuffer;
const uploaded = await uploadFile(fileName, arrayBuffer);
return uploaded.url;
}
export async function normalizeAssistantImageMarkdown(
text: string,
imageSize = "2K"
): Promise<string> {
if (!text) return text;
const markdownDataUrlRegex = /!\[([^\]]*)\]\s*\(\s*(data:image\/[a-z0-9+.-]+;base64,[^)]+)\s*\)/gim;
const markdownMatches = [...text.matchAll(markdownDataUrlRegex)];
let normalizedText = text;
const uploadedUrls: string[] = [];
if (markdownMatches.length > 0) {
let rebuilt = "";
let cursor = 0;
for (const match of markdownMatches) {
const matchText = match[0];
const dataUrl = match[2];
const start = match.index ?? -1;
if (start < 0) continue;
rebuilt += normalizedText.slice(cursor, start);
try {
const uploadedUrl = await uploadDataUrlImage(dataUrl);
uploadedUrls.push(uploadedUrl);
rebuilt += `![thumb|||${uploadedUrl}|||${imageSize}](${uploadedUrl})`;
} catch (error) {
chatApiLogger.error("[Chat API] Failed to upload inline data URL image:", error);
rebuilt += matchText;
}
cursor = start + matchText.length;
}
rebuilt += normalizedText.slice(cursor);
normalizedText = rebuilt;
}
if (normalizedText.includes("data:image")) {
const rawDataUrlRegex = /data:image\/[a-z0-9+.-]+;base64,[a-z0-9+/=\s]+/gim;
const rawMatches = normalizedText.match(rawDataUrlRegex) ?? [];
for (const rawDataUrl of rawMatches) {
try {
const uploadedUrl = await uploadDataUrlImage(rawDataUrl);
uploadedUrls.push(uploadedUrl);
normalizedText = normalizedText.replace(rawDataUrl, uploadedUrl);
} catch (error) {
chatApiLogger.error("[Chat API] Failed to normalize raw data URL image:", error);
}
}
}
const markdownHttpImageRegex = /!\[([^\]]*)\]\s*\(\s*(https?:\/\/[^)\s]+)\s*\)/gim;
const markdownHttpMatches = [...normalizedText.matchAll(markdownHttpImageRegex)];
if (markdownHttpMatches.length > 0) {
let rebuilt = "";
let cursor = 0;
for (const match of markdownHttpMatches) {
const matchText = match[0];
const altText = match[1] || "";
const thumbnailUrl = (match[2] || "").trim();
const start = match.index ?? -1;
if (start < 0) continue;
rebuilt += normalizedText.slice(cursor, start);
try {
const { originalUrl, imageSize: parsedImageSize } = parseImageAltMetadata(
altText,
thumbnailUrl
);
let hostedThumbnailUrl = thumbnailUrl;
let hostedOriginalUrl =
originalUrl && originalUrl !== thumbnailUrl ? originalUrl : thumbnailUrl;
try {
hostedThumbnailUrl = await ensureHostedImageUrl(thumbnailUrl);
} catch (error) {
chatApiLogger.error("[Chat API] Failed to normalize remote thumbnail URL:", error);
}
if (originalUrl && originalUrl !== thumbnailUrl) {
try {
hostedOriginalUrl = await ensureHostedImageUrl(originalUrl);
} catch (error) {
chatApiLogger.error("[Chat API] Failed to normalize remote original URL:", error);
hostedOriginalUrl = hostedThumbnailUrl || originalUrl;
}
} else {
hostedOriginalUrl = hostedThumbnailUrl;
}
const finalThumbnailUrl = hostedThumbnailUrl || thumbnailUrl;
const finalOriginalUrl = hostedOriginalUrl || finalThumbnailUrl;
uploadedUrls.push(finalThumbnailUrl);
uploadedUrls.push(finalOriginalUrl);
rebuilt += `![thumb|||${finalOriginalUrl}|||${parsedImageSize || imageSize}](${finalThumbnailUrl})`;
} catch (error) {
chatApiLogger.error("[Chat API] Failed to parse remote image markdown metadata:", error);
rebuilt += matchText;
}
cursor = start + matchText.length;
}
rebuilt += normalizedText.slice(cursor);
normalizedText = rebuilt;
}
const hasMarkdownImage = /!\[[^\]]*\]\((https?:\/\/[^)]+)\)/i.test(normalizedText);
if (!hasMarkdownImage && uploadedUrls.length > 0) {
const uniqueUrls = [...new Set(uploadedUrls)];
chatApiLogger.info(`[Chat API] Converted ${uniqueUrls.length} image link(s) to hosted link(s)`);
return uniqueUrls
.map((url) => `![thumb|||${url}|||${imageSize}](${url})`)
.join("\n");
}
return normalizedText;
}