ai-chatbot/components/canvas/chat-panel/canvas-chat-media.ts

749 lines
22 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import {
extractImageUrlsFromText,
extractOriginalImageUrlFromAlt,
inferOriginalImageUrl,
} from "@/lib/image-url";
import { getMessageImagePartUrl } from "@/lib/chat/message-image-attachments";
import type { ChatMessage } from "@/lib/types";
export { collectMessageImageAttachments } from "@/lib/chat/message-image-attachments";
export function extractImageUrls(text: string): string[] {
if (!text) return [];
return extractImageUrlsFromText(text);
}
function extractPlainImageUrls(text: string): string[] {
if (!text) return [];
const urls = new Set<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;
pushUrl(getMessageImagePartUrl(part));
if (part.type === "text" && typeof part.text === "string") {
extractImageUrls(part.text).forEach((url) => pushUrl(url));
extractPlainImageUrls(part.text).forEach((url) => pushUrl(url));
}
}
const attachments = (message as any).attachments || [];
for (const attachment of attachments) {
pushUrl(attachment?.url);
}
return urlKeys;
}
export function collectMessageImageUrls(message: ChatMessage | undefined): string[] {
const urls: string[] = [];
const seen = new Set<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;
const direct = getMessageImagePartUrl(part);
if (direct) {
pushUrl(inferOriginalImageUrl(String(direct || "").trim()));
}
if (part.type === "text" && typeof part.text === "string") {
extractImageUrls(part.text).forEach((url) => pushUrl(url));
extractPlainImageUrls(part.text).forEach((url) => pushUrl(url));
}
}
const attachments = (message as any).attachments || [];
for (const attachment of attachments) {
pushUrl(inferOriginalImageUrl(String(attachment?.url || "").trim()));
}
const legacyContent =
typeof (message as any).content === "string" ? (message as any).content : "";
if (legacyContent) {
extractImageUrls(legacyContent).forEach((url) => pushUrl(url));
extractPlainImageUrls(legacyContent).forEach((url) => pushUrl(url));
}
return urls;
}
export function getNearestUserImageUrlKeys(
messages: ChatMessage[],
assistantIndex: number
): Set<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 && !referenceUrlKeys?.size) {
selected = candidates.find((candidate) => normalizeMediaUrl(candidate)) || "";
}
if (!selected) continue;
const dedupeKey = normalizeMediaUrl(selected);
if (!dedupeKey || seenMarkdownUrls.has(dedupeKey)) continue;
seenMarkdownUrls.add(dedupeKey);
markdownUrls.push(selected);
}
if (markdownUrls.length > 0) {
return markdownUrls;
}
const urls = extractImageUrls(text);
if (!referenceUrlKeys || referenceUrlKeys.size === 0) return urls;
return urls.filter((url) => {
const normalized = normalizeMediaUrl(url);
if (!normalized) return false;
return !referenceUrlKeys.has(normalized);
});
}
function isMarkdownTableRow(line: string): boolean {
const trimmed = String(line || "").trim();
if (!trimmed || !trimmed.includes("|")) return false;
const cells = trimmed
.replace(/^\|/, "")
.replace(/\|$/, "")
.split("|");
return cells.length >= 2;
}
function isMarkdownTableSeparator(line: string): boolean {
if (!isMarkdownTableRow(line)) return false;
const cells = String(line || "")
.trim()
.replace(/^\|/, "")
.replace(/\|$/, "")
.split("|")
.map((cell) => cell.trim().replace(/\s+/g, ""));
return cells.length >= 2 && cells.every((cell) => /^:?-{3,}:?$/.test(cell));
}
export function getMarkdownTableImageUrlKeys(text: string): Set<string> {
const urlKeys = new Set<string>();
const lines = String(text || "").replace(/\r\n/g, "\n").split("\n");
let inCodeFence = false;
const addUrl = (url: string) => {
const key = normalizeImageIdentityUrl(url);
if (key) urlKeys.add(key);
};
const addUrlsFromTableText = (tableText: string) => {
extractAssistantImageUrls(tableText).forEach(addUrl);
extractPlainImageUrls(tableText).forEach(addUrl);
};
for (let index = 0; index < lines.length - 1; index += 1) {
const line = lines[index] || "";
if (/^\s*```/.test(line)) {
inCodeFence = !inCodeFence;
continue;
}
if (
inCodeFence ||
!isMarkdownTableRow(line) ||
!isMarkdownTableSeparator(lines[index + 1] || "")
) {
continue;
}
let endIndex = index + 2;
while (endIndex < lines.length && isMarkdownTableRow(lines[endIndex] || "")) {
endIndex += 1;
}
addUrlsFromTableText(lines.slice(index, endIndex).join("\n"));
index = endIndex - 1;
}
// Claw may emit pipe tables without a separator row while streaming. Treat a
// contiguous block of at least two pipe rows as a table for media deduping.
for (let index = 0; index < lines.length; ) {
if (!isMarkdownTableRow(lines[index] || "")) {
index += 1;
continue;
}
let endIndex = index + 1;
while (
endIndex < lines.length &&
isMarkdownTableRow(lines[endIndex] || "")
) {
endIndex += 1;
}
if (endIndex - index >= 2) {
addUrlsFromTableText(lines.slice(index, endIndex).join("\n"));
}
index = endIndex;
}
const htmlTablePattern = /<table\b[^>]*>[\s\S]*?<\/table>/gi;
let htmlTableMatch: RegExpExecArray | null = null;
while ((htmlTableMatch = htmlTablePattern.exec(String(text || ""))) !== null) {
addUrlsFromTableText(htmlTableMatch[0] || "");
}
return urlKeys;
}
export function filterStandaloneAssistantImageUrls(
text: string,
imageUrls: string[]
): string[] {
const tableImageUrlKeys = getMarkdownTableImageUrlKeys(text);
if (tableImageUrlKeys.size === 0) return imageUrls;
return imageUrls.filter((url) => {
const key = normalizeImageIdentityUrl(url);
return !key || !tableImageUrlKeys.has(key);
});
}
const AUDIO_MEDIA_EXTENSIONS = new Set([
"aac",
"flac",
"m4a",
"mp3",
"oga",
"ogg",
"opus",
"wav",
]);
const VIDEO_MEDIA_EXTENSIONS = new Set([
"m3u8",
"m4v",
"mov",
"mp4",
"ogv",
"webm",
]);
type ChatMediaKind = "audio" | "video";
function sanitizeExtractedMediaUrl(value: string): string {
return String(value || "")
.trim()
.replace(/&amp;/gi, "&")
.replace(/[),.;!?,。;!?]+$/u, "");
}
function getChatMediaUrlExtension(value: string): string {
const raw = sanitizeExtractedMediaUrl(value);
if (!raw) return "";
try {
const parsed = new URL(raw);
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return "";
const pathnameExtension = parsed.pathname.match(/\.([a-z0-9]+)$/i)?.[1];
if (pathnameExtension) return pathnameExtension.toLowerCase();
for (const key of ["format", "ext", "type", "filename", "file", "name"]) {
const candidate = parsed.searchParams.get(key) || "";
const queryExtension = candidate.match(/(?:^|\.)([a-z0-9]+)$/i)?.[1];
if (queryExtension) return queryExtension.toLowerCase();
}
} catch {
return "";
}
return "";
}
function isChatMediaUrl(value: string, kind: ChatMediaKind): boolean {
const extension = getChatMediaUrlExtension(value);
return kind === "audio"
? AUDIO_MEDIA_EXTENSIONS.has(extension)
: VIDEO_MEDIA_EXTENSIONS.has(extension);
}
function extractChatMediaUrls(text: string, kind: ChatMediaKind): string[] {
if (!text) return [];
const urls = new Map<string, string>();
const candidates: Array<{
forceKind?: ChatMediaKind;
index: number;
value: string;
}> = [];
const addUrl = (value: string, forceKind?: ChatMediaKind) => {
const url = sanitizeExtractedMediaUrl(value);
if (!url || (forceKind !== kind && !isChatMediaUrl(url, kind))) return;
try {
const parsed = new URL(url);
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return;
} catch {
return;
}
if (!urls.has(url)) urls.set(url, url);
};
const tagPattern = /<(audio|video)\b[^>]*\bsrc\s*=\s*["']([^"']+)["'][^>]*>/gi;
let match: RegExpExecArray | null = null;
while ((match = tagPattern.exec(text)) !== null) {
candidates.push({
forceKind: match[1]?.toLowerCase() as ChatMediaKind,
index: match.index,
value: match[2] || "",
});
}
const containerPattern = /<(audio|video)\b[^>]*>([\s\S]*?)<\/\1>/gi;
while ((match = containerPattern.exec(text)) !== null) {
const containerKind = match[1]?.toLowerCase() as ChatMediaKind;
const sourcePattern = /<source\b[^>]*\bsrc\s*=\s*["']([^"']+)["'][^>]*>/gi;
let sourceMatch: RegExpExecArray | null = null;
while ((sourceMatch = sourcePattern.exec(match[2] || "")) !== null) {
candidates.push({
forceKind: containerKind,
index: match.index + sourceMatch.index,
value: sourceMatch[1] || "",
});
}
}
const markdownLinkPattern = /\[([^\]]*)]\((https?:\/\/[^)\s]+)\)/gi;
while ((match = markdownLinkPattern.exec(text)) !== null) {
const label = String(match[1] || "");
const hintedKind = /音频|语音|声音|audio|mp3/i.test(label)
? "audio"
: /视频|影片|播放|video/i.test(label)
? "video"
: undefined;
candidates.push({
forceKind: hintedKind,
index: match.index,
value: match[2] || "",
});
}
const plainUrlPattern = /https?:\/\/[^\s"'<>\[\]]+/gi;
while ((match = plainUrlPattern.exec(text)) !== null) {
candidates.push({ index: match.index, value: match[0] || "" });
}
candidates
.sort((left, right) => left.index - right.index)
.forEach((candidate) => addUrl(candidate.value, candidate.forceKind));
return Array.from(urls.values());
}
export function extractAudioUrls(text: string): string[] {
return extractChatMediaUrls(text, "audio");
}
export function extractVideoUrls(text: string): string[] {
return extractChatMediaUrls(text, "video");
}
export function stripMediaMarkup(text: string): string {
return String(text || "")
.replace(/!\[(?:thumb)?\|\|\|[^|]+\|\|\|[^\]]*\]\([^)]+\)/g, "")
.replace(/<video[\s\S]*?<\/video>/gi, "")
.replace(/<video\b[^>]*\/?\s*>/gi, "")
.replace(/<audio[\s\S]*?<\/audio>/gi, "")
.replace(/<audio\b[^>]*\/?\s*>/gi, "")
.replace(/<a[\s\S]*?<\/a>/gi, "")
.replace(/\[([^\]]*)]\((https?:\/\/[^)\s]+)\)/gi, (match, label, url) => {
const hintedMedia = /音频|语音|声音|audio|mp3|视频|影片|播放|video/i.test(
String(label || "")
);
return hintedMedia || isChatMediaUrl(url, "audio") || isChatMediaUrl(url, "video")
? ""
: match;
})
.replace(/https?:\/\/[^\s"'<>\[\]]+/gi, (match) => {
const url = sanitizeExtractedMediaUrl(match);
if (!isChatMediaUrl(url, "audio") && !isChatMediaUrl(url, "video")) {
return match;
}
return match.slice(url.length);
})
.trim();
}
export function extractCanvasModelName(text: string): string {
const source = String(text || "");
if (!source) return "";
const badgeMatch = source.match(/^\s*[◈◇◆🔹⬡✦•·]\s*([^\n]+?)\s*$/im);
if (badgeMatch?.[1]) {
return badgeMatch[1].trim();
}
const routedMatch = source.match(
/^\s*(?:Routed to|Using model)\s*[::]\s*([^\n(]+).*$/im
);
if (routedMatch?.[1]) {
return routedMatch[1].trim();
}
return "";
}
export function stripCanvasModelRoutingLines(text: string): string {
return String(text || "")
.replace(/^\s*(?:Routed to|Using model)\s*[::].*$/gim, "")
.replace(/^\s*[◈◇◆🔹⬡✦•·]\s*[^\n]+\s*$/gim, "")
.replace(/\n{3,}/g, "\n\n")
.trim();
}
export function messageHasAssistantMedia(
message: ChatMessage | undefined,
referenceUrlKeys?: Set<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 ||
extractAudioUrls(text).length > 0 ||
extractVideoUrls(text).length > 0
);
});
}
export function getMessageTimestamp(message: ChatMessage): number | null {
const metadata =
(message as any).metadata && typeof (message as any).metadata === "object"
? ((message as any).metadata as Record<string, unknown>)
: null;
const rawValue =
(message as any).createdAt ??
(message as any).created_at ??
(message as any).timestamp ??
metadata?.createdAt ??
metadata?.created_at ??
metadata?.timestamp ??
null;
if (rawValue instanceof Date) {
return rawValue.getTime();
}
if (typeof rawValue === "number" && Number.isFinite(rawValue)) {
return rawValue;
}
if (typeof rawValue === "string") {
const parsed = Date.parse(rawValue);
if (Number.isFinite(parsed)) {
return parsed;
}
}
return null;
}
export function getMessageDayKey(timestamp: number): string {
const date = new Date(timestamp);
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, "0");
const day = String(date.getDate()).padStart(2, "0");
return `${year}-${month}-${day}`;
}
export function formatMessageDateLabel(timestamp: number): string {
return new Intl.DateTimeFormat("en-US", {
month: "short",
day: "numeric",
year: "numeric",
}).format(new Date(timestamp));
}
export function sortCanvasMessagesByCreatedAt(messages: ChatMessage[]): ChatMessage[] {
return messages
.map((message, index) => ({ message, index }))
.sort((a, b) => {
const messageA = a.message;
const messageB = b.message;
const timeA = getMessageTimestamp(messageA);
const timeB = getMessageTimestamp(messageB);
const hasTimeA = timeA !== null;
const hasTimeB = timeB !== null;
if (hasTimeA && hasTimeB && timeA !== timeB) return timeA - timeB;
if (hasTimeA !== hasTimeB) return hasTimeA ? -1 : 1;
if (hasTimeA && hasTimeB && messageA.role !== messageB.role) {
if (messageA.role === "user") return -1;
if (messageB.role === "user") return 1;
}
return a.index - b.index;
})
.map((item) => item.message);
}
export function buildCanvasMessageFingerprint(message: ChatMessage): string {
const parts = Array.isArray(message.parts) ? message.parts : [];
const partFingerprint = parts
.slice(0, 8)
.map((part: any) => {
if (!part) return "part:null";
if (part.type === "text") {
const text = String(part.text || "").trim();
return `text:${text.length}:${text.slice(0, 80)}`;
}
const url =
typeof part.url === "string" && part.url.trim()
? part.url.trim()
: typeof part.data === "string" && part.data.trim()
? part.data.trim()
: "";
return `${String(part.type || "part")}:${String(
part.mediaType || part.mimeType || part.contentType || ""
)}:${url.slice(0, 120)}`;
})
.join("|");
const attachmentFingerprint = (((message as any).attachments || []) as any[])
.slice(0, 6)
.map((attachment) => {
const url = String(attachment?.url || "").trim();
return `${String(attachment?.contentType || "")}:${url.slice(0, 120)}`;
})
.join("|");
return [
message.id,
message.role,
String(getMessageTimestamp(message) ?? 0),
partFingerprint,
attachmentFingerprint,
].join("::");
}
export function buildCanvasHistoryMessagesSignature(
rawChatId: string,
messages: ChatMessage[]
): string {
const recentFingerprint = messages
.slice(-12)
.map((message) => buildCanvasMessageFingerprint(message))
.join("||");
return `${rawChatId}:${messages.length}:${recentFingerprint}`;
}
export function hasCanvasImageMarkdown(parts?: Array<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");
}