refactor: extract canvas chat media helpers

This commit is contained in:
Your Name 2026-05-27 10:15:15 +08:00
parent a8f5dff6f8
commit 0f058207bb
2 changed files with 522 additions and 487 deletions

View File

@ -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<string, any>;
const metadata = ((data.metadata as any) || {}) as Record<string, any>;
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<string> {
const urlKeys = new Set<string>();
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<string>();
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<string> {
for (let i = assistantIndex - 1; i >= 0; i -= 1) {
const message = messages[i];
if (message?.role !== "user") continue;
return collectMessageImageUrlKeys(message);
}
return new Set<string>();
}
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>): string[] {
const markdownPattern = /!\[([^\]]*)\]\((https?:\/\/[^\s)]+)\)/gim;
const markdownUrls: string[] = [];
const seenMarkdownUrls = new Set<string>();
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<string>();
const videoTagPattern = /<video[^>]+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(/<video[\s\S]*?<\/video>/gi, "")
.replace(/<a[\s\S]*?<\/a>/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<string>
): 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<any>) {
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([

View File

@ -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<string>();
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<string, any>;
const metadata = ((data.metadata as any) || {}) as Record<string, any>;
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<string> {
const urlKeys = new Set<string>();
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<string>();
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<string> {
for (let i = assistantIndex - 1; i >= 0; i -= 1) {
const message = messages[i];
if (message?.role !== "user") continue;
return collectMessageImageUrlKeys(message);
}
return new Set<string>();
}
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>
): string[] {
const markdownPattern = /!\[([^\]]*)\]\((https?:\/\/[^\s)]+)\)/gim;
const markdownUrls: string[] = [];
const seenMarkdownUrls = new Set<string>();
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<string>();
const videoTagPattern = /<video[^>]+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(/<video[\s\S]*?<\/video>/gi, "")
.replace(/<a[\s\S]*?<\/a>/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<string>
): 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<any>) {
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");
}