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*】)/.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; } 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> & Omit => { 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, ) => { 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(); 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 === "openai-image" || 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, }); } else if (part.type === "file" && part.url) { // 直接使用 URL 格式(不再转换为 Base64) // gemini-image 和 huanke-gemini provider 内部会处理图片上传 content.push({ type: "image_url", image_url: { url: part.url, }, }); chatApiLogger.info("[Message Convert] Image kept as URL format:", part.url.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> | 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 { 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 { 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 { 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 { 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 { 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; }