refactor: extract canvas chat media helpers
This commit is contained in:
parent
a8f5dff6f8
commit
0f058207bb
@ -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([
|
||||
|
||||
498
components/canvas/chat-panel/canvas-chat-media.ts
Normal file
498
components/canvas/chat-panel/canvas-chat-media.ts
Normal 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");
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user