diff --git a/components/canvas/CanvasChatPanel.tsx b/components/canvas/CanvasChatPanel.tsx index 54543fdb1..8d4afb62d 100644 --- a/components/canvas/CanvasChatPanel.tsx +++ b/components/canvas/CanvasChatPanel.tsx @@ -60,8 +60,6 @@ import { cn } from "@/lib/utils"; import { fetcher, fetchWithErrorHandlers, generateUUID } from "@/lib/utils"; import { fetchUploadWithRetry } from "@/lib/client/upload-with-retry"; import { - extractImageUrlsFromText, - extractOriginalImageUrlFromAlt, inferOriginalImageUrl, } from "@/lib/image-url"; import { buildImageProxyUrl } from "@/lib/image-delivery"; @@ -125,6 +123,30 @@ import { sanitizeChatAttachment, toRawCanvasChatId, } from "@/components/canvas/chat-panel/canvas-chat-session"; +import { + buildCanvasHistoryMessagesSignature, + buildDownloadFilename, + extractAssistantImageUrls, + extractCanvasModelName, + extractImageUrls, + extractVideoUrls, + formatMessageDateLabel, + getAssistantImageNodeIdentityKey, + getCanvasAssistantMessagesAfterTargetUser, + getMessageDayKey, + getMessageTimestamp, + getNearestUserImageUrlKeys, + getNearestUserImageUrls, + hasCanvasImageMarkdown, + hasCanvasLikelyGenerationErrorMessage, + messageHasAssistantMedia, + normalizeImageIdentityUrl, + normalizeMediaUrl, + sortCanvasMessagesByCreatedAt, + stripCanvasModelRoutingLines, + stripMediaMarkup, + triggerBrowserDownload, +} from "@/components/canvas/chat-panel/canvas-chat-media"; // 标签悬停预览信息 interface TagPreviewInfo { @@ -396,22 +418,6 @@ function getImageDimensions(url: string): Promise<{ }); } -// 从消息中提取图片 URL(只提取原始图片,避免重复) -function extractImageUrls(text: string): string[] { - if (!/!\[[^\]]*\]\((https?:\/\/[^\s)]+)\)/i.test(text || "")) { - return []; - } - return extractImageUrlsFromText(text); -} - -function extractPlainImageUrls(text: string): string[] { - if (!text) return []; - const matches = String(text).match( - /(https?:\/\/[^\s"'<>]+?\.(?:png|jpe?g|webp|gif|bmp|tiff?|svg)(?:\?[^\s"'<>]+)?)/gi - ); - return matches ? Array.from(new Set(matches)) : []; -} - function buildPreviewImageUrl(url: string, width: number): string { return buildImageProxyUrl(url, { width, @@ -473,475 +479,6 @@ function getAgentProgressFallbackTitle(payload?: AgentProgressStreamPayload | nu } } -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(); - } -} - -function normalizeImageIdentityUrl(url: string): string { - const raw = String(url || "").trim(); - if (!raw) return ""; - const canonical = inferOriginalImageUrl(raw); - return normalizeMediaUrl(canonical || raw); -} - -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; -} - -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); -} - -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}`; -} - -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; -} - -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; -} - -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(); -} - -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 []; -} - -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) { - 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 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); -} - -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(); -} - -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 ""; -} - -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(); -} - -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 - ); - }); -} - -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; -} - -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}`; -} - -function formatMessageDateLabel(timestamp: number): string { - return new Intl.DateTimeFormat("en-US", { - month: "short", - day: "numeric", - year: "numeric", - }).format(new Date(timestamp)); -} - -function sortCanvasMessagesByCreatedAt(messages: ChatMessage[]): ChatMessage[] { - return [...messages].sort((a, b) => { - const timeA = getMessageTimestamp(a) ?? 0; - const timeB = getMessageTimestamp(b) ?? 0; - return timeA - timeB; - }); -} - -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("::"); -} - -function buildCanvasHistoryMessagesSignature( - rawChatId: string, - messages: ChatMessage[], -): string { - const recentFingerprint = messages - .slice(-12) - .map((message) => buildCanvasMessageFingerprint(message)) - .join("||"); - return `${rawChatId}:${messages.length}:${recentFingerprint}`; -} - -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; - }); -} - -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); -} - -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"); -} - const CANVAS_IMAGE_ANALYSIS_TITLE = "图片分析"; const CANVAS_IMAGE_DONE_SUMMARY = "已完成本次生成,如需微调可继续告诉我。"; const CANVAS_AGENT_SECTION_TITLES = new Set([ diff --git a/components/canvas/chat-panel/canvas-chat-media.ts b/components/canvas/chat-panel/canvas-chat-media.ts new file mode 100644 index 000000000..67073cc1d --- /dev/null +++ b/components/canvas/chat-panel/canvas-chat-media.ts @@ -0,0 +1,498 @@ +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) { + 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"); +}