diff --git a/components/canvas/CanvasChatPanel.tsx b/components/canvas/CanvasChatPanel.tsx index 2afa9051b..4157b4028 100644 --- a/components/canvas/CanvasChatPanel.tsx +++ b/components/canvas/CanvasChatPanel.tsx @@ -5,7 +5,7 @@ import { DefaultChatTransport } from "ai"; import { useEffect, useRef, useState, useCallback, useMemo } from "react"; import { createPortal } from "react-dom"; import useSWR, { useSWRConfig } from "swr"; -import { Send, Loader2, ImagePlus, Paperclip, X, Plus, History, LayoutGrid, Maximize2, RefreshCw, Globe, Box, ArrowUp, Video, Sparkles, Check, ChevronDown, ChevronUp, AlertCircle, CheckCircle2, Brain, ThumbsUp, ThumbsDown, Eye, Trash2 } from "lucide-react"; +import { Send, Loader2, ImagePlus, Paperclip, X, Plus, History, LayoutGrid, Maximize2, RefreshCw, Globe, Box, ArrowUp, Video, Sparkles, Check, ChevronDown, ChevronUp, AlertCircle, CheckCircle2, Brain, ThumbsUp, ThumbsDown, Eye, Trash2, Download } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Textarea } from "@/components/ui/textarea"; import { @@ -39,7 +39,11 @@ import { toast } from "sonner"; import { useCanvasStore } from "@/store/canvas"; import { cn } from "@/lib/utils"; import { fetcher, fetchWithErrorHandlers, generateUUID } from "@/lib/utils"; -import { extractImageUrlsFromText } from "@/lib/image-url"; +import { + extractImageUrlsFromText, + extractOriginalImageUrlFromAlt, + inferOriginalImageUrl, +} from "@/lib/image-url"; import type { Attachment, ChatMessage } from "@/lib/types"; import { type CanvasLaunchPayload, getCanvasLaunchStorageKey } from "@/lib/canvas-launch"; import { ImageParamsBar, type ImageGenerationParams } from "@/components/image-params-bar"; @@ -152,6 +156,43 @@ function normalizeMediaUrl(url: string): string { } } +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 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; @@ -198,6 +239,43 @@ function getNearestUserImageUrlKeys(messages: ChatMessage[], assistantIndex: num } 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; @@ -1343,6 +1421,27 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF const videoParamsRef = useRef(videoParams); const deepThinkingEnabledRef = useRef(deepThinkingEnabled); const imageAutoTitleCacheRef = useRef>(new Map()); + const sendGuardRef = useRef<{ + inFlight: boolean; + signature: string; + timestamp: number; + }>({ + inFlight: false, + signature: "", + timestamp: 0, + }); + const releaseSendGuard = useCallback(() => { + sendGuardRef.current.inFlight = false; + }, []); + + const shouldBlockDuplicateSend = useCallback((signature: string) => { + const now = Date.now(); + const previous = sendGuardRef.current; + return ( + previous.signature === signature && + now - previous.timestamp < 4500 + ); + }, []); const handleTypewriterActiveChange = useCallback((typingKey: string, active: boolean) => { setActiveTypingSections((prev) => { @@ -1547,6 +1646,7 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF // Canvas store const addNode = useCanvasStore((state) => state.addNode); + const deleteNode = useCanvasStore((state) => state.deleteNode); const updateNode = useCanvasStore((state) => state.updateNode); const nodes = useCanvasStore((state) => state.nodes); const projectTitle = useCanvasStore((state) => state.projectTitle); @@ -1563,6 +1663,40 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF const voteSWRKey = rawChatId ? `/api/vote?chatId=${rawChatId}` : null; const { mutate } = useSWRConfig(); const [votingMessageId, setVotingMessageId] = useState(null); + const handleDownloadOriginalImage = useCallback(async (imageUrl: string) => { + const raw = String(imageUrl || "").trim(); + if (!raw) { + toast.error("图片地址无效"); + return; + } + + const filename = buildDownloadFilename(raw); + try { + const response = await fetch(raw); + if (!response.ok) { + throw new Error(`download failed: ${response.status}`); + } + const blob = await response.blob(); + const blobUrl = window.URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = blobUrl; + link.download = filename; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + window.URL.revokeObjectURL(blobUrl); + return; + } catch { + const link = document.createElement("a"); + link.href = raw; + link.target = "_blank"; + link.rel = "noopener noreferrer"; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + toast.message("已打开原图,可右键另存为"); + } + }, []); // 从 API 加载历史消息 const { data: historyData } = useSWR<{ @@ -2730,6 +2864,12 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF }, }); + useEffect(() => { + if (status === "ready") { + releaseSendGuard(); + } + }, [releaseSendGuard, status]); + useEffect(() => { const launchPayload = pendingLaunchRef.current; if (!launchPayload?.autoSend) return; @@ -2898,7 +3038,23 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF const existingImageUrlKeys = new Set( nodes .filter((n) => n.type === "image" && (n.data as any)?.src) - .map((n) => normalizeMediaUrl((n.data as any).src)) + .map((n) => normalizeImageIdentityUrl((n.data as any).src)) + .filter(Boolean) + ); + const existingMessageImageKeys = new Set( + nodes + .filter((n) => n.type === "image") + .map((n) => { + const data = ((n.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}`; + }) .filter(Boolean) ); const existingVideoUrlKeys = new Set( @@ -2916,7 +3072,8 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF } const content = message.parts?.map((p: any) => p.text || "").join("") || ""; - const imageUrls = extractImageUrls(content); + const nearestUserImageKeys = getNearestUserImageUrlKeys(messages, messageIndex); + const imageUrls = extractAssistantImageUrls(content, nearestUserImageKeys); const videoUrls = extractVideoUrls(content); if (imageUrls.length === 0 && videoUrls.length === 0) continue; @@ -2926,16 +3083,52 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF let addedImageCount = 0; let addedVideoCount = 0; - const newImageUrls = imageUrls.filter((url) => { - const key = normalizeMediaUrl(url); - if (!key || existingImageUrlKeys.has(key)) return false; - existingImageUrlKeys.add(key); - return true; - }); + const newImageEntries = imageUrls.reduce>( + (acc, url) => { + const sourceImageKey = normalizeImageIdentityUrl(url); + if (!sourceImageKey || existingImageUrlKeys.has(sourceImageKey)) { + return acc; + } + const messageImageKey = `${message.id}::${sourceImageKey}`; + if (existingMessageImageKeys.has(messageImageKey)) { + return acc; + } + existingImageUrlKeys.add(sourceImageKey); + existingMessageImageKeys.add(messageImageKey); + acc.push({ url, sourceImageKey }); + return acc; + }, + [] + ); - for (let index = 0; index < newImageUrls.length; index += 1) { + for (let index = 0; index < newImageEntries.length; index += 1) { if (cancelled) return; - const url = newImageUrls[index]; + const imageEntry = newImageEntries[index]; + const url = imageEntry.url; + const messageImageKey = `${message.id}::${imageEntry.sourceImageKey}`; + const hasDuplicateInStore = useCanvasStore + .getState() + .nodes.some((node) => { + if (node.type !== "image") return false; + const data = ((node.data as any) || {}) as Record; + const metadata = ((data.metadata as any) || {}) as Record; + const sourceMessageId = String(metadata.sourceMessageId || "").trim(); + const sourceImageKey = normalizeImageIdentityUrl( + String(metadata.sourceImageKey || data.src || "").trim() + ); + if (!sourceImageKey) return false; + const nodeMessageImageKey = sourceMessageId + ? `${sourceMessageId}::${sourceImageKey}` + : ""; + return ( + nodeMessageImageKey === messageImageKey || + sourceImageKey === imageEntry.sourceImageKey + ); + }); + if (hasDuplicateInStore) { + continue; + } + const dimensions = await getImageDimensions(url); const existingNodes = nodes.length + addedImageCount + addedVideoCount; const spacing = 20; @@ -2956,6 +3149,7 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF originalHeight: dimensions.originalHeight, generationPrompt: generationPrompt || undefined, sourceMessageId: message.id, + sourceImageKey: imageEntry.sourceImageKey, }, }, connections: [], @@ -3009,6 +3203,31 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF }; }, [messages, addNode, nodes, autoNameGeneratedImageNode]); + useEffect(() => { + if (nodes.length < 2) return; + + const seenKeys = new Set(); + const duplicateNodeIds: string[] = []; + + for (const node of nodes) { + const identityKey = getAssistantImageNodeIdentityKey(node); + if (!identityKey) continue; + if (seenKeys.has(identityKey)) { + duplicateNodeIds.push(node.id); + continue; + } + seenKeys.add(identityKey); + } + + if (duplicateNodeIds.length === 0) return; + + duplicateNodeIds.forEach((nodeId) => deleteNode(nodeId)); + console.warn( + "[CanvasChat] Removed duplicate assistant image nodes:", + duplicateNodeIds.length + ); + }, [deleteNode, nodes]); + // 为历史节点补全可编辑的原始提示词(旧数据里可能只有“对话生成”占位文案) useEffect(() => { if (!messages.length || !nodes.length) return; @@ -4966,8 +5185,21 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF // 处理发送(图片已在上传时添加到画布,这里只发送消息) const handleSend = useCallback(async () => { + if (sendGuardRef.current.inFlight || status === "streaming" || status === "submitted") { + return; + } + if (isDetailGenerationMode && aiDetailGenerationEnabled) { - await handleDetailGeneration(); + sendGuardRef.current = { + inFlight: true, + signature: `detail:${activeChatId}:${Date.now()}`, + timestamp: Date.now(), + }; + try { + await handleDetailGeneration(); + } finally { + releaseSendGuard(); + } return; } @@ -5097,11 +5329,41 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF currentModelIdRef.current = effectiveModelId; } - // 使用与 multimodal-input 相同的格式发送消息 - sendMessage({ - role: "user", - parts: parts, + const normalizedAttachmentKeys = effectiveAttachments + .map((attachment) => normalizeMediaUrl(attachment.url)) + .filter(Boolean) + .sort(); + const sendSignature = JSON.stringify({ + chatId: activeChatId, + model: effectiveModelId, + text: textContent, + attachments: normalizedAttachmentKeys, + mode: isVideoModel ? "video" : "image", }); + + if (shouldBlockDuplicateSend(sendSignature)) { + toast.message("检测到重复发送,已自动忽略"); + return; + } + + sendGuardRef.current = { + inFlight: true, + signature: sendSignature, + timestamp: Date.now(), + }; + + // 使用与 multimodal-input 相同的格式发送消息 + try { + sendMessage({ + role: "user", + parts: parts, + }); + } catch (error) { + releaseSendGuard(); + console.error("[CanvasChat] sendMessage failed:", error); + toast.error("发送失败,请重试"); + return; + } hasHydratedHistoryRef.current = true; // 清空状态 @@ -5118,6 +5380,7 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF console.log('[CanvasChat] 已清空输入框和选中状态'); } }, [ + activeChatId, input, attachments, selectedModelId, @@ -5133,6 +5396,10 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF aiDetailGenerationEnabled, handleDetailGeneration, ensureImagePointsBeforeSend, + isVideoModel, + releaseSendGuard, + shouldBlockDuplicateSend, + status, ]); const maybeAutoSetCanvasTitle = useCallback(async (firstImageUrl: string | null) => { @@ -5671,10 +5938,10 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF }, [activeSessionTitle, projectTitle]); return ( -
+
{/* 全局遮罩提示 - 当处于画布选图模式时显示 */} {detailSlotPickerIndex !== null && ( -
+
@@ -5737,7 +6004,7 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF {isSessionMenuOpen && ( -
+
聊天窗口记录
{chatSessions.length === 0 ? ( @@ -6187,6 +6454,19 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF setFullscreenPreview({ url }); }} /> +
))}
@@ -6310,6 +6590,18 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF + +