import { extractImageUrlsFromText, extractOriginalImageUrlFromAlt, inferOriginalImageUrl, } from "@/lib/image-url"; import type { ChatMessage } from "@/lib/types"; export function extractImageUrls(text: string): string[] { if (!text) return []; return extractImageUrlsFromText(text); } function extractPlainImageUrls(text: string): string[] { if (!text) return []; const urls = new Set(); const pattern = /(https?:\/\/[^\s"'<>]+?\.(?:png|jpe?g|webp|gif|bmp|svg)(?:\?[^\s"'<>]+)?)/gi; let match: RegExpExecArray | null = null; while ((match = pattern.exec(text)) !== null) { if (match[1]) urls.add(match[1]); } return Array.from(urls); } export function normalizeMediaUrl(url: string): string { const raw = String(url || "").trim(); if (!raw) return ""; try { const parsed = new URL(raw); return `${parsed.origin}${parsed.pathname}`.replace(/\/+$/, "").toLowerCase(); } catch { return raw .split("#")[0] .split("?")[0] .replace(/\/+$/, "") .toLowerCase(); } } export function normalizeImageIdentityUrl(url: string): string { const raw = String(url || "").trim(); if (!raw) return ""; const canonical = inferOriginalImageUrl(raw); return normalizeMediaUrl(canonical || raw); } export function buildDownloadFilename(url: string): string { const fallback = `canvas-image-${Date.now()}.png`; const raw = String(url || "").trim(); if (!raw) return fallback; try { const pathname = new URL(raw).pathname || ""; const candidate = pathname.split("/").pop() || ""; if (candidate) return candidate; } catch { const candidate = raw.split("/").pop()?.split("?")[0] || ""; if (candidate) return candidate; } return fallback; } export function triggerBrowserDownload( url: string, filename?: string, options?: { openInNewTab?: boolean } ) { const link = document.createElement("a"); link.href = url; if (filename) { link.download = filename; } if (options?.openInNewTab) { link.target = "_blank"; } link.rel = "noopener noreferrer"; document.body.appendChild(link); link.click(); document.body.removeChild(link); } export function getAssistantImageNodeIdentityKey(node: any): string { if (!node || node.type !== "image") return ""; const data = ((node.data as any) || {}) as Record; const metadata = ((data.metadata as any) || {}) as Record; const sourceMessageId = String(metadata.sourceMessageId || "").trim(); if (!sourceMessageId) return ""; const sourceImageKey = normalizeImageIdentityUrl( String(metadata.sourceImageKey || data.src || "").trim() ); if (!sourceImageKey) return ""; return `${sourceMessageId}::${sourceImageKey}`; } export function collectMessageImageUrlKeys(message: ChatMessage | undefined): Set { const urlKeys = new Set(); if (!message) return urlKeys; const pushUrl = (url?: string | null) => { const raw = String(url || "").trim(); if (!raw) return; const normalized = normalizeMediaUrl(raw); if (!normalized) return; urlKeys.add(normalized); }; const parts = (message as any).parts || []; for (const part of parts) { if (!part) continue; if ( (part.type === "file" || part.type === "image" || part.type === "input_image") && (typeof part.url === "string" || typeof part.data === "string") ) { pushUrl(typeof part.url === "string" ? part.url : part.data); } if (part.type === "text" && typeof part.text === "string") { extractImageUrls(part.text).forEach((url) => pushUrl(url)); extractPlainImageUrls(part.text).forEach((url) => pushUrl(url)); } } const attachments = (message as any).attachments || []; for (const attachment of attachments) { pushUrl(attachment?.url); } return urlKeys; } export function collectMessageImageUrls(message: ChatMessage | undefined): string[] { const urls: string[] = []; const seen = new Set(); if (!message) return urls; const pushUrl = (url?: string | null) => { const raw = String(url || "").trim(); if (!raw) return; const normalized = normalizeMediaUrl(raw); if (!normalized || seen.has(normalized)) return; seen.add(normalized); urls.push(raw); }; const parts = (message as any).parts || []; for (const part of parts) { if (!part) continue; if ( (part.type === "file" || part.type === "image" || part.type === "input_image") && (typeof part.url === "string" || typeof part.data === "string") ) { const direct = typeof part.url === "string" ? part.url : part.data; pushUrl(inferOriginalImageUrl(String(direct || "").trim())); } if (part.type === "text" && typeof part.text === "string") { extractImageUrls(part.text).forEach((url) => pushUrl(url)); extractPlainImageUrls(part.text).forEach((url) => pushUrl(url)); } } const attachments = (message as any).attachments || []; for (const attachment of attachments) { pushUrl(inferOriginalImageUrl(String(attachment?.url || "").trim())); } const legacyContent = typeof (message as any).content === "string" ? (message as any).content : ""; if (legacyContent) { extractImageUrls(legacyContent).forEach((url) => pushUrl(url)); extractPlainImageUrls(legacyContent).forEach((url) => pushUrl(url)); } return urls; } export function getNearestUserImageUrlKeys( messages: ChatMessage[], assistantIndex: number ): Set { for (let i = assistantIndex - 1; i >= 0; i -= 1) { const message = messages[i]; if (message?.role !== "user") continue; return collectMessageImageUrlKeys(message); } return new Set(); } export function getNearestUserImageUrls( messages: ChatMessage[], assistantIndex: number ): string[] { for (let i = assistantIndex - 1; i >= 0; i -= 1) { const message = messages[i]; if (message?.role !== "user") continue; return collectMessageImageUrls(message); } return []; } export function extractAssistantImageUrls( text: string, referenceUrlKeys?: Set ): string[] { const markdownPattern = /!\[([^\]]*)\]\((https?:\/\/[^\s)]+)\)/gim; const markdownUrls: string[] = []; const seenMarkdownUrls = new Set(); let match: RegExpExecArray | null = null; while ((match = markdownPattern.exec(text)) !== null) { const altText = String(match[1] || ""); const markdownSrc = String(match[2] || "").trim(); if (!markdownSrc) continue; const originalFromAlt = extractOriginalImageUrlFromAlt(altText, markdownSrc); const candidates = [originalFromAlt, inferOriginalImageUrl(markdownSrc), markdownSrc]; let selected = ""; for (const candidate of candidates) { const normalized = normalizeMediaUrl(candidate); if (!normalized) continue; if (referenceUrlKeys?.size && referenceUrlKeys.has(normalized)) continue; selected = candidate; break; } if (!selected && !referenceUrlKeys?.size) { selected = candidates.find((candidate) => normalizeMediaUrl(candidate)) || ""; } if (!selected) continue; const dedupeKey = normalizeMediaUrl(selected); if (!dedupeKey || seenMarkdownUrls.has(dedupeKey)) continue; seenMarkdownUrls.add(dedupeKey); markdownUrls.push(selected); } if (markdownUrls.length > 0) { return markdownUrls; } const urls = extractImageUrls(text); if (!referenceUrlKeys || referenceUrlKeys.size === 0) return urls; return urls.filter((url) => { const normalized = normalizeMediaUrl(url); if (!normalized) return false; return !referenceUrlKeys.has(normalized); }); } export function extractVideoUrls(text: string): string[] { if (!text) return []; const urls = new Set(); const videoTagPattern = /]+src="([^"]+)"/gi; let match: RegExpExecArray | null = null; while ((match = videoTagPattern.exec(text)) !== null) { if (match[1]) urls.add(match[1]); } const markdownVideoPattern = /\[[^\]]*]\((https?:\/\/[^)\s]+\.mp4(?:\?[^)\s]*)?)\)/gi; while ((match = markdownVideoPattern.exec(text)) !== null) { if (match[1]) urls.add(match[1]); } const plainVideoPattern = /(https?:\/\/[^\s"'<>]+\.mp4(?:\?[^\s"'<>]+)?)/gi; while ((match = plainVideoPattern.exec(text)) !== null) { if (match[1]) urls.add(match[1]); } return Array.from(urls); } export function stripMediaMarkup(text: string): string { return String(text || "") .replace(/!\[(?:thumb)?\|\|\|[^|]+\|\|\|[^\]]*\]\([^)]+\)/g, "") .replace(//gi, "") .replace(//gi, "") .replace(/\[[^\]]*]\((https?:\/\/[^)\s]+\.mp4(?:\?[^)\s]*)?)\)/gi, "") .replace(/https?:\/\/[^\s"'<>]+\.mp4(?:\?[^\s"'<>]+)?/gi, "") .trim(); } export function extractCanvasModelName(text: string): string { const source = String(text || ""); if (!source) return ""; const badgeMatch = source.match(/^\s*[◈◇◆🔹⬡✦•·]\s*([^\n]+?)\s*$/im); if (badgeMatch?.[1]) { return badgeMatch[1].trim(); } const routedMatch = source.match( /^\s*(?:Routed to|Using model)\s*[::]\s*([^\n(]+).*$/im ); if (routedMatch?.[1]) { return routedMatch[1].trim(); } return ""; } export function stripCanvasModelRoutingLines(text: string): string { return String(text || "") .replace(/^\s*(?:Routed to|Using model)\s*[::].*$/gim, "") .replace(/^\s*[◈◇◆🔹⬡✦•·]\s*[^\n]+\s*$/gim, "") .replace(/\n{3,}/g, "\n\n") .trim(); } export function messageHasAssistantMedia( message: ChatMessage | undefined, referenceUrlKeys?: Set ): boolean { if (!message || message.role !== "assistant") return false; const textParts = (message.parts || []).filter( (part: any) => part.type === "text" && part.text ); return textParts.some((part: any) => { const text = String(part.text || ""); return ( extractAssistantImageUrls(text, referenceUrlKeys).length > 0 || extractVideoUrls(text).length > 0 ); }); } export function getMessageTimestamp(message: ChatMessage): number | null { const rawValue = (message as any).createdAt ?? (message as any).created_at ?? (message as any).timestamp ?? null; if (rawValue instanceof Date) { return rawValue.getTime(); } if (typeof rawValue === "number" && Number.isFinite(rawValue)) { return rawValue; } if (typeof rawValue === "string") { const parsed = Date.parse(rawValue); if (Number.isFinite(parsed)) { return parsed; } } return null; } export function getMessageDayKey(timestamp: number): string { const date = new Date(timestamp); const year = date.getFullYear(); const month = String(date.getMonth() + 1).padStart(2, "0"); const day = String(date.getDate()).padStart(2, "0"); return `${year}-${month}-${day}`; } export function formatMessageDateLabel(timestamp: number): string { return new Intl.DateTimeFormat("en-US", { month: "short", day: "numeric", year: "numeric", }).format(new Date(timestamp)); } export function sortCanvasMessagesByCreatedAt(messages: ChatMessage[]): ChatMessage[] { return [...messages].sort((a, b) => { const timeA = getMessageTimestamp(a) ?? 0; const timeB = getMessageTimestamp(b) ?? 0; return timeA - timeB; }); } export function buildCanvasMessageFingerprint(message: ChatMessage): string { const parts = Array.isArray(message.parts) ? message.parts : []; const partFingerprint = parts .slice(0, 8) .map((part: any) => { if (!part) return "part:null"; if (part.type === "text") { const text = String(part.text || "").trim(); return `text:${text.length}:${text.slice(0, 80)}`; } const url = typeof part.url === "string" && part.url.trim() ? part.url.trim() : typeof part.data === "string" && part.data.trim() ? part.data.trim() : ""; return `${String(part.type || "part")}:${String( part.mediaType || part.mimeType || part.contentType || "" )}:${url.slice(0, 120)}`; }) .join("|"); const attachmentFingerprint = (((message as any).attachments || []) as any[]) .slice(0, 6) .map((attachment) => { const url = String(attachment?.url || "").trim(); return `${String(attachment?.contentType || "")}:${url.slice(0, 120)}`; }) .join("|"); return [ message.id, message.role, String(getMessageTimestamp(message) ?? 0), partFingerprint, attachmentFingerprint, ].join("::"); } export function buildCanvasHistoryMessagesSignature( rawChatId: string, messages: ChatMessage[] ): string { const recentFingerprint = messages .slice(-12) .map((message) => buildCanvasMessageFingerprint(message)) .join("||"); return `${rawChatId}:${messages.length}:${recentFingerprint}`; } export function hasCanvasImageMarkdown(parts?: Array) { if (!parts || parts.length === 0) return false; return parts.some((part) => { if (!part) return false; if ( part?.type === "text" && typeof part.text === "string" && (part.text.includes("![thumb|") || part.text.includes("![thumb|||") || part.text.includes("![Generated Image")) ) { return true; } if (part?.type === "image" && typeof part.url === "string" && part.url.length > 0) { return true; } if ( part?.type === "file" && typeof part.url === "string" && ((typeof part.mediaType === "string" && part.mediaType.startsWith("image/")) || /\.(png|jpe?g|webp|gif|bmp|svg)(\?|$)/i.test(part.url)) ) { return true; } return false; }); } export function hasCanvasLikelyGenerationErrorMessage(message?: ChatMessage | null) { if (!message?.parts || message.parts.length === 0) return false; const text = message.parts .filter((part: any) => part?.type === "text" && typeof part.text === "string") .map((part: any) => part.text) .join("\n") .trim(); if (!text) return false; return /失败|超时|重试|error|failed/i.test(text); } export function getCanvasAssistantMessagesAfterTargetUser( dbMessages: ChatMessage[], targetUserMessageId?: string | null ) { if (!Array.isArray(dbMessages) || dbMessages.length === 0) { return [] as ChatMessage[]; } let targetUserIndex = -1; if (targetUserMessageId) { targetUserIndex = dbMessages.findIndex( (message) => message.role === "user" && message.id === targetUserMessageId ); } if (targetUserIndex < 0) { targetUserIndex = dbMessages.findLastIndex((message) => message.role === "user"); } if (targetUserIndex < 0) { return dbMessages.filter((message) => message.role === "assistant"); } return dbMessages .slice(targetUserIndex + 1) .filter((message) => message.role === "assistant"); }