import { extractImageUrlsFromText, extractOriginalImageUrlFromAlt, inferOriginalImageUrl, } from "@/lib/image-url"; import { getMessageImagePartUrl } from "@/lib/chat/message-image-attachments"; import type { ChatMessage } from "@/lib/types"; export { collectMessageImageAttachments } from "@/lib/chat/message-image-attachments"; 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; pushUrl(getMessageImagePartUrl(part)); 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; const direct = getMessageImagePartUrl(part); if (direct) { 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); }); } function isMarkdownTableRow(line: string): boolean { const trimmed = String(line || "").trim(); if (!trimmed || !trimmed.includes("|")) return false; const cells = trimmed .replace(/^\|/, "") .replace(/\|$/, "") .split("|"); return cells.length >= 2; } function isMarkdownTableSeparator(line: string): boolean { if (!isMarkdownTableRow(line)) return false; const cells = String(line || "") .trim() .replace(/^\|/, "") .replace(/\|$/, "") .split("|") .map((cell) => cell.trim().replace(/\s+/g, "")); return cells.length >= 2 && cells.every((cell) => /^:?-{3,}:?$/.test(cell)); } export function getMarkdownTableImageUrlKeys(text: string): Set { const urlKeys = new Set(); const lines = String(text || "").replace(/\r\n/g, "\n").split("\n"); let inCodeFence = false; const addUrl = (url: string) => { const key = normalizeImageIdentityUrl(url); if (key) urlKeys.add(key); }; const addUrlsFromTableText = (tableText: string) => { extractAssistantImageUrls(tableText).forEach(addUrl); extractPlainImageUrls(tableText).forEach(addUrl); }; for (let index = 0; index < lines.length - 1; index += 1) { const line = lines[index] || ""; if (/^\s*```/.test(line)) { inCodeFence = !inCodeFence; continue; } if ( inCodeFence || !isMarkdownTableRow(line) || !isMarkdownTableSeparator(lines[index + 1] || "") ) { continue; } let endIndex = index + 2; while (endIndex < lines.length && isMarkdownTableRow(lines[endIndex] || "")) { endIndex += 1; } addUrlsFromTableText(lines.slice(index, endIndex).join("\n")); index = endIndex - 1; } // Claw may emit pipe tables without a separator row while streaming. Treat a // contiguous block of at least two pipe rows as a table for media deduping. for (let index = 0; index < lines.length; ) { if (!isMarkdownTableRow(lines[index] || "")) { index += 1; continue; } let endIndex = index + 1; while ( endIndex < lines.length && isMarkdownTableRow(lines[endIndex] || "") ) { endIndex += 1; } if (endIndex - index >= 2) { addUrlsFromTableText(lines.slice(index, endIndex).join("\n")); } index = endIndex; } const htmlTablePattern = /]*>[\s\S]*?<\/table>/gi; let htmlTableMatch: RegExpExecArray | null = null; while ((htmlTableMatch = htmlTablePattern.exec(String(text || ""))) !== null) { addUrlsFromTableText(htmlTableMatch[0] || ""); } return urlKeys; } export function filterStandaloneAssistantImageUrls( text: string, imageUrls: string[] ): string[] { const tableImageUrlKeys = getMarkdownTableImageUrlKeys(text); if (tableImageUrlKeys.size === 0) return imageUrls; return imageUrls.filter((url) => { const key = normalizeImageIdentityUrl(url); return !key || !tableImageUrlKeys.has(key); }); } const AUDIO_MEDIA_EXTENSIONS = new Set([ "aac", "flac", "m4a", "mp3", "oga", "ogg", "opus", "wav", ]); const VIDEO_MEDIA_EXTENSIONS = new Set([ "m3u8", "m4v", "mov", "mp4", "ogv", "webm", ]); type ChatMediaKind = "audio" | "video"; function sanitizeExtractedMediaUrl(value: string): string { return String(value || "") .trim() .replace(/&/gi, "&") .replace(/[),.;!?,。;!?]+$/u, ""); } function getChatMediaUrlExtension(value: string): string { const raw = sanitizeExtractedMediaUrl(value); if (!raw) return ""; try { const parsed = new URL(raw); if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return ""; const pathnameExtension = parsed.pathname.match(/\.([a-z0-9]+)$/i)?.[1]; if (pathnameExtension) return pathnameExtension.toLowerCase(); for (const key of ["format", "ext", "type", "filename", "file", "name"]) { const candidate = parsed.searchParams.get(key) || ""; const queryExtension = candidate.match(/(?:^|\.)([a-z0-9]+)$/i)?.[1]; if (queryExtension) return queryExtension.toLowerCase(); } } catch { return ""; } return ""; } function isChatMediaUrl(value: string, kind: ChatMediaKind): boolean { const extension = getChatMediaUrlExtension(value); return kind === "audio" ? AUDIO_MEDIA_EXTENSIONS.has(extension) : VIDEO_MEDIA_EXTENSIONS.has(extension); } function extractChatMediaUrls(text: string, kind: ChatMediaKind): string[] { if (!text) return []; const urls = new Map(); const candidates: Array<{ forceKind?: ChatMediaKind; index: number; value: string; }> = []; const addUrl = (value: string, forceKind?: ChatMediaKind) => { const url = sanitizeExtractedMediaUrl(value); if (!url || (forceKind !== kind && !isChatMediaUrl(url, kind))) return; try { const parsed = new URL(url); if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return; } catch { return; } if (!urls.has(url)) urls.set(url, url); }; const tagPattern = /<(audio|video)\b[^>]*\bsrc\s*=\s*["']([^"']+)["'][^>]*>/gi; let match: RegExpExecArray | null = null; while ((match = tagPattern.exec(text)) !== null) { candidates.push({ forceKind: match[1]?.toLowerCase() as ChatMediaKind, index: match.index, value: match[2] || "", }); } const containerPattern = /<(audio|video)\b[^>]*>([\s\S]*?)<\/\1>/gi; while ((match = containerPattern.exec(text)) !== null) { const containerKind = match[1]?.toLowerCase() as ChatMediaKind; const sourcePattern = /]*\bsrc\s*=\s*["']([^"']+)["'][^>]*>/gi; let sourceMatch: RegExpExecArray | null = null; while ((sourceMatch = sourcePattern.exec(match[2] || "")) !== null) { candidates.push({ forceKind: containerKind, index: match.index + sourceMatch.index, value: sourceMatch[1] || "", }); } } const markdownLinkPattern = /\[([^\]]*)]\((https?:\/\/[^)\s]+)\)/gi; while ((match = markdownLinkPattern.exec(text)) !== null) { const label = String(match[1] || ""); const hintedKind = /音频|语音|声音|audio|mp3/i.test(label) ? "audio" : /视频|影片|播放|video/i.test(label) ? "video" : undefined; candidates.push({ forceKind: hintedKind, index: match.index, value: match[2] || "", }); } const plainUrlPattern = /https?:\/\/[^\s"'<>\[\]]+/gi; while ((match = plainUrlPattern.exec(text)) !== null) { candidates.push({ index: match.index, value: match[0] || "" }); } candidates .sort((left, right) => left.index - right.index) .forEach((candidate) => addUrl(candidate.value, candidate.forceKind)); return Array.from(urls.values()); } export function extractAudioUrls(text: string): string[] { return extractChatMediaUrls(text, "audio"); } export function extractVideoUrls(text: string): string[] { return extractChatMediaUrls(text, "video"); } export function stripMediaMarkup(text: string): string { return String(text || "") .replace(/!\[(?:thumb)?\|\|\|[^|]+\|\|\|[^\]]*\]\([^)]+\)/g, "") .replace(//gi, "") .replace(/]*\/?\s*>/gi, "") .replace(//gi, "") .replace(/]*\/?\s*>/gi, "") .replace(//gi, "") .replace(/\[([^\]]*)]\((https?:\/\/[^)\s]+)\)/gi, (match, label, url) => { const hintedMedia = /音频|语音|声音|audio|mp3|视频|影片|播放|video/i.test( String(label || "") ); return hintedMedia || isChatMediaUrl(url, "audio") || isChatMediaUrl(url, "video") ? "" : match; }) .replace(/https?:\/\/[^\s"'<>\[\]]+/gi, (match) => { const url = sanitizeExtractedMediaUrl(match); if (!isChatMediaUrl(url, "audio") && !isChatMediaUrl(url, "video")) { return match; } return match.slice(url.length); }) .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 || extractAudioUrls(text).length > 0 || extractVideoUrls(text).length > 0 ); }); } export function getMessageTimestamp(message: ChatMessage): number | null { const metadata = (message as any).metadata && typeof (message as any).metadata === "object" ? ((message as any).metadata as Record) : null; const rawValue = (message as any).createdAt ?? (message as any).created_at ?? (message as any).timestamp ?? metadata?.createdAt ?? metadata?.created_at ?? metadata?.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 .map((message, index) => ({ message, index })) .sort((a, b) => { const messageA = a.message; const messageB = b.message; const timeA = getMessageTimestamp(messageA); const timeB = getMessageTimestamp(messageB); const hasTimeA = timeA !== null; const hasTimeB = timeB !== null; if (hasTimeA && hasTimeB && timeA !== timeB) return timeA - timeB; if (hasTimeA !== hasTimeB) return hasTimeA ? -1 : 1; if (hasTimeA && hasTimeB && messageA.role !== messageB.role) { if (messageA.role === "user") return -1; if (messageB.role === "user") return 1; } return a.index - b.index; }) .map((item) => item.message); } 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"); }