"use client"; import { useChat } from "@ai-sdk/react"; import { DefaultChatTransport } from "ai"; import { useEffect, useRef, useState, useCallback } from "react"; import { createPortal } from "react-dom"; import { useRouter } from "next/navigation"; import useSWR from "swr"; import { Send, Loader2, ImagePlus, Paperclip, X, Sparkles, User, Bot, Plus, History, Share2, LayoutGrid, Maximize2, RefreshCw, Zap, Globe, Box, ArrowUp } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Textarea } from "@/components/ui/textarea"; import { ScrollArea } from "@/components/ui/scroll-area"; import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select"; import { toast } from "sonner"; import { useCanvasStore } from "@/store/canvas"; import { cn } from "@/lib/utils"; import { fetcher, fetchWithErrorHandlers, generateUUID } from "@/lib/utils"; import type { Attachment, ChatMessage } from "@/lib/types"; import { ImageParamsBar, type ImageGenerationParams } from "@/components/image-params-bar"; import { ImageGenerationLoading } from "@/components/image-generation-loading"; // 标签悬停预览信息 interface TagPreviewInfo { url: string; name: string; x: number; // 标签中心x位置 y: number; // 标签顶部y位置 // 聊天面板容器边界 containerBounds?: { left: number; right: number; top: number; bottom: number; }; // 物品标签额外信息 pickedObject?: { objectName: string; objectPosition: string; clickX: number; clickY: number; }; } interface CanvasChatPanelProps { chatId: string; initialMessages?: ChatMessage[]; onClose?: () => void; onOpenFileList?: () => void; } // 建议卡片数据 - 已移除 const SUGGESTIONS: { title: string; desc: string; image: string }[] = []; // 获取图片的原始尺寸 function getImageDimensions(url: string): Promise<{ width: number; height: number }> { return new Promise((resolve) => { const img = new Image(); img.onload = () => { // 限制最大尺寸,避免节点过大 const maxSize = 500; let width = img.naturalWidth; let height = img.naturalHeight; // 如果图片太大,按比例缩小 if (width > maxSize || height > maxSize) { const scale = Math.min(maxSize / width, maxSize / height); width = Math.round(width * scale); height = Math.round(height * scale); } // 设置最小尺寸 const minSize = 150; if (width < minSize && height < minSize) { const scale = minSize / Math.min(width, height); width = Math.round(width * scale); height = Math.round(height * scale); } resolve({ width, height }); }; img.onerror = () => { // 加载失败时使用默认尺寸 resolve({ width: 300, height: 300 }); }; img.src = url; }); } // 从消息中提取图片 URL(只提取原始图片,避免重复) function extractImageUrls(text: string): string[] { const urls: string[] = []; // 匹配 markdown 图片格式: ![thumb|||originalUrl|||size](thumbnailUrl) // 只提取原始图片 URL(第一个 ||| 和第二个 ||| 之间的 URL) const markdownRegex = /!\[(?:thumb)?\|\|\|([^|]+)\|\|\|[^\]]*\]\(([^)]+)\)/g; let match; let hasMarkdownImages = false; while ((match = markdownRegex.exec(text)) !== null) { if (match[1]) { urls.push(match[1]); // 原始图片 URL hasMarkdownImages = true; } } // 只有在没有 markdown 格式图片时,才匹配普通图片 URL // 这样可以避免重复匹配缩略图 URL if (!hasMarkdownImages) { const urlRegex = /https?:\/\/[^\s"'<>]+\.(?:jpg|jpeg|png|gif|webp)/gi; const urlMatches = text.match(urlRegex); if (urlMatches) { urls.push(...urlMatches.filter(url => !urls.includes(url))); } } return urls; } // 🆕 拾取物品的附件类型扩展 interface PickedObjectAttachment extends Attachment { pickedObject?: { objectName: string; objectPosition: string; clickX: number; clickY: number; boundingBox: { x: number; y: number; width: number; height: number }; }; } export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenFileList }: CanvasChatPanelProps) { const router = useRouter(); const [input, setInput] = useState(""); const [attachments, setAttachments] = useState([]); const [selectedModelId, setSelectedModelId] = useState(""); const messagesEndRef = useRef(null); const fileInputRef = useRef(null); const textareaRef = useRef(null); const hiddenPromptRef = useRef(null); // 🆕 标签悬停预览状态 const [tagPreview, setTagPreview] = useState(null); const previewTimeoutRef = useRef(null); // 🆕 图片参数状态 const [imageParams, setImageParams] = useState({ aspectRatio: "auto", imageSize: "2K", imageCount: 1, }); // 🆕 保存图片参数到 localStorage const handleImageParamsChange = (params: ImageGenerationParams) => { setImageParams(params); try { localStorage.setItem("canvas-image-params", JSON.stringify(params)); } catch (e) { console.error("Failed to save image params:", e); } }; // 加载图片参数 useEffect(() => { try { const saved = localStorage.getItem("canvas-image-params"); if (saved) { const parsed = JSON.parse(saved); setImageParams(prev => ({ ...prev, ...parsed })); } } catch (e) { console.error("Failed to load image params:", e); } }, []); // Canvas store const addNode = useCanvasStore((state) => state.addNode); const nodes = useCanvasStore((state) => state.nodes); const selectedNodeIds = useCanvasStore((state) => state.selectedNodeIds); const clearSelection = useCanvasStore((state) => state.clearSelection); const pendingChatAttachments = useCanvasStore((state) => state.pendingChatAttachments); const clearPendingChatAttachments = useCanvasStore((state) => state.clearPendingChatAttachments); // 🆕 物品拾取 const pendingPickedObjects = useCanvasStore((state) => state.pendingPickedObjects); const clearPendingPickedObjects = useCanvasStore((state) => state.clearPendingPickedObjects); // 获取纯 UUID(去掉 canvas- 前缀)用于 API 调用 const rawChatId = chatId.startsWith('canvas-') ? chatId.replace('canvas-', '') : chatId; // 从 API 加载历史消息 const { data: historyData, isLoading: isLoadingHistory } = useSWR<{ messages: ChatMessage[]; }>(rawChatId ? `/api/messages?chatId=${rawChatId}` : null, fetcher); // 获取选中的图片节点 const selectedImageNodes = nodes.filter( n => selectedNodeIds.includes(n.id) && n.type === 'image' && (n.data as any)?.src ); // 🆕 辅助函数:在 contenteditable 中插入图片标签 const insertImageTagInEditor = useCallback((url: string, name: string, index: number) => { const editableDiv = textareaRef.current as HTMLDivElement | null; if (!editableDiv || !editableDiv.isContentEditable) return; // 检查 DOM 中是否已存在该 URL 的标签(如果存在则不重复添加) const existing = editableDiv.querySelector(`[data-attachment-url="${url}"]`); if (existing) { console.log('[CanvasChat] 标签已存在于 DOM 中,跳过:', url.substring(0, 50)); return; } const tagSpan = document.createElement('span'); tagSpan.contentEditable = 'false'; tagSpan.setAttribute('data-attachment-index', String(index)); tagSpan.setAttribute('data-attachment-url', url); tagSpan.className = 'inline-flex items-center gap-1 bg-muted/60 hover:bg-muted rounded-full pl-1 pr-2 py-0.5 mx-0.5 align-middle cursor-default select-none'; const shortName = name.length > 10 ? name.substring(0, 8) + '...' : name; tagSpan.innerHTML = ` ${shortName} `; // 在光标位置插入,或添加到末尾,并在标签后插入一个零宽空格,方便光标落在标签后继续输入 const selection = window.getSelection(); if (selection && selection.rangeCount > 0 && editableDiv.contains(selection.anchorNode)) { const range = selection.getRangeAt(0); range.deleteContents(); const spaceNode = document.createTextNode("\u200b"); range.insertNode(spaceNode); range.insertNode(tagSpan); range.setStartAfter(spaceNode); range.setEndAfter(spaceNode); selection.removeAllRanges(); selection.addRange(range); } else { editableDiv.appendChild(tagSpan); editableDiv.appendChild(document.createTextNode("\u200b")); } console.log('[CanvasChat] 已插入图片标签:', url.substring(0, 50)); }, []); // 🆕 辅助函数:在 contenteditable 中插入物品标签(带图片缩略图,样式与普通图片标签一致) const insertPickedObjectTagInEditor = useCallback(( url: string, objectName: string, objectPosition: string, index: number ) => { const editableDiv = textareaRef.current as HTMLDivElement | null; if (!editableDiv || !editableDiv.isContentEditable) return; // 检查 DOM 中是否已存在该物品的标签 const existingTags = editableDiv.querySelectorAll('[data-picked-object="true"]'); for (const tag of existingTags) { if (tag.getAttribute('data-attachment-url') === url && tag.getAttribute('data-object-name') === objectName) { console.log('[CanvasChat] 物品标签已存在,跳过'); return; } } const tagSpan = document.createElement('span'); tagSpan.contentEditable = 'false'; tagSpan.setAttribute('data-attachment-index', String(index)); tagSpan.setAttribute('data-attachment-url', url); tagSpan.setAttribute('data-picked-object', 'true'); tagSpan.setAttribute('data-object-name', objectName); tagSpan.setAttribute('data-object-position', objectPosition); // 样式与普通图片标签一致 tagSpan.className = 'inline-flex items-center gap-1 bg-muted/60 hover:bg-muted rounded-full pl-1 pr-2 py-0.5 mx-0.5 align-middle cursor-default select-none'; // 使用图片缩略图,不使用 emoji 或图标 tagSpan.innerHTML = ` ${objectName} · ${objectPosition} `; // 在光标位置插入,或添加到末尾,并在标签后插入一个零宽空格,方便光标落在标签后继续输入 const selection = window.getSelection(); if (selection && selection.rangeCount > 0 && editableDiv.contains(selection.anchorNode)) { const range = selection.getRangeAt(0); range.deleteContents(); const spaceNode = document.createTextNode("\u200b"); range.insertNode(spaceNode); range.insertNode(tagSpan); range.setStartAfter(spaceNode); range.setEndAfter(spaceNode); selection.removeAllRanges(); selection.addRange(range); } else { editableDiv.appendChild(tagSpan); editableDiv.appendChild(document.createTextNode("\u200b")); } console.log('[CanvasChat] 已插入物品标签:', objectName, objectPosition); }, []); // 🆕 监听 pendingChatAttachments,当用户点击"添加到对话"按钮时添加标签 useEffect(() => { if (pendingChatAttachments.length > 0) { // 获取当前附件列表 const currentAttachments = [...attachments]; const existingUrls = new Set(currentAttachments.map(a => a.url)); // 过滤出需要添加的图片(不在当前附件列表中的) const toAdd = pendingChatAttachments.filter(a => !existingUrls.has(a.url)); if (toAdd.length > 0) { // 插入图片标签到编辑器 toAdd.forEach((att, i) => { insertImageTagInEditor(att.url, att.name, currentAttachments.length + i); }); // 更新附件列表 setAttachments([...currentAttachments, ...toAdd]); // 聚焦到输入框 setTimeout(() => { (textareaRef.current as HTMLDivElement | null)?.focus(); }, 0); } // 清空待添加列表 clearPendingChatAttachments(); } }, [pendingChatAttachments, attachments, insertImageTagInEditor, clearPendingChatAttachments]); // 🆕 监听 pendingPickedObjects,当用户确认拾取物品时添加标签 useEffect(() => { if (pendingPickedObjects.length > 0) { const currentAttachments = [...attachments]; pendingPickedObjects.forEach((picked, i) => { // 检查是否已存在相同的物品 const exists = currentAttachments.some(a => a.url === picked.imageUrl && a.pickedObject?.objectName === picked.objectName ); if (!exists) { // 插入物品标签到编辑器 insertPickedObjectTagInEditor( picked.imageUrl, picked.objectName, picked.objectPosition, currentAttachments.length + i ); // 添加到附件列表(带物品信息,原图片 URL 会发送给 AI 用于多图融合编辑) currentAttachments.push({ url: picked.imageUrl, // 原图片 URL name: `${picked.objectName}-${picked.objectPosition}`, // 物品名称-位置 contentType: 'image/jpeg', pickedObject: { objectName: picked.objectName, objectPosition: picked.objectPosition, clickX: picked.clickX, clickY: picked.clickY, boundingBox: picked.boundingBox, }, }); } }); setAttachments(currentAttachments); // 聚焦到输入框 setTimeout(() => { (textareaRef.current as HTMLDivElement | null)?.focus(); }, 0); // 清空待添加列表 clearPendingPickedObjects(); } }, [pendingPickedObjects, attachments, insertPickedObjectTagInEditor, clearPendingPickedObjects]); // 🆕 标签悬停预览 - 使用全局事件监听 useEffect(() => { let lastTagElement: HTMLElement | null = null; const handleMouseMove = (e: MouseEvent) => { const target = e.target as HTMLElement; const tagSpan = target.closest('[data-attachment-url]') as HTMLElement | null; // 检查是否在预览弹窗内 const inPreview = target.closest('[data-tag-preview]'); if (inPreview) { // 在预览弹窗内,保持显示 if (previewTimeoutRef.current) { clearTimeout(previewTimeoutRef.current); previewTimeoutRef.current = null; } return; } if (tagSpan && tagSpan !== lastTagElement) { lastTagElement = tagSpan; // 清除之前的延迟隐藏 if (previewTimeoutRef.current) { clearTimeout(previewTimeoutRef.current); previewTimeoutRef.current = null; } const url = tagSpan.getAttribute('data-attachment-url'); const isPickedObject = tagSpan.getAttribute('data-picked-object') === 'true'; const objectName = tagSpan.getAttribute('data-object-name'); if (url) { const rect = tagSpan.getBoundingClientRect(); // 从 attachments 中获取物品信息 const attachment = attachments.find(a => a.url === url && (isPickedObject ? a.pickedObject?.objectName === objectName : true) ); // 获取聊天面板容器边界(向上查找最近的面板容器) const panelContainer = tagSpan.closest('.flex.flex-col.h-full') as HTMLElement | null; let containerBounds: TagPreviewInfo['containerBounds'] = undefined; if (panelContainer) { const panelRect = panelContainer.getBoundingClientRect(); containerBounds = { left: panelRect.left, right: panelRect.right, top: panelRect.top, bottom: panelRect.bottom, }; } setTagPreview({ url, name: objectName || tagSpan.textContent || '图片', x: rect.left + rect.width / 2, y: rect.top, containerBounds, pickedObject: attachment?.pickedObject ? { objectName: attachment.pickedObject.objectName, objectPosition: attachment.pickedObject.objectPosition, clickX: attachment.pickedObject.clickX, clickY: attachment.pickedObject.clickY, } : undefined, }); } } else if (!tagSpan && lastTagElement) { // 离开标签 lastTagElement = null; previewTimeoutRef.current = setTimeout(() => { setTagPreview(null); }, 200); } }; document.addEventListener('mousemove', handleMouseMove); return () => { document.removeEventListener('mousemove', handleMouseMove); if (previewTimeoutRef.current) { clearTimeout(previewTimeoutRef.current); } }; }, [attachments]); // 获取模型列表 const { data: modelsData } = useSWR<{ models: Array<{ id: string; modelId: string; displayName: string; providerType: string; supportsVision?: boolean; supportsImageGen?: boolean; }>; }>("/api/models", fetcher); // 获取网站设置(包含广告横幅配置和画布设置) const { data: siteSettings } = useSWR<{ bannerEnabled?: boolean; bannerText?: string; bannerLink?: string | null; canvasEnabled?: boolean; canvasDefaultModelId?: string | null; }>("/api/site-settings", fetcher); // 过滤支持图片生成的模型 const imageModels = modelsData?.models?.filter(m => m.supportsImageGen) || []; // 智能自动模型选项 const SMART_AUTO_MODEL = { id: "auto", modelId: "auto", displayName: "智能自动", providerType: "auto", supportsImageGen: true, }; // 所有可选模型(智能自动 + 生图模型) const allModels = [SMART_AUTO_MODEL, ...imageModels]; // 默认选择模型(根据系统设置) useEffect(() => { if (!selectedModelId && siteSettings) { // 使用系统设置的默认模型,如果没有设置则使用智能自动 const defaultModel = siteSettings.canvasDefaultModelId || "auto"; setSelectedModelId(defaultModel); } }, [selectedModelId, siteSettings]); const currentModelIdRef = useRef(selectedModelId); const imageParamsRef = useRef(imageParams); useEffect(() => { currentModelIdRef.current = selectedModelId; }, [selectedModelId]); useEffect(() => { imageParamsRef.current = imageParams; }, [imageParams]); // 合并 initialMessages 和历史消息 const mergedInitialMessages = historyData?.messages?.length ? historyData.messages : initialMessages; const { messages, setMessages, sendMessage, status, stop, } = useChat({ id: chatId, messages: mergedInitialMessages, experimental_throttle: 100, generateId: generateUUID, transport: new DefaultChatTransport({ api: "/api/chat", fetch: fetchWithErrorHandlers, prepareSendMessagesRequest(request) { // 从 canvas-{uuid} 格式中提取纯 UUID,后端 schema 要求纯 UUID const rawId = request.id.startsWith('canvas-') ? request.id.replace('canvas-', '') : request.id; console.log("[CanvasChat] Preparing request:", { id: rawId, modelId: currentModelIdRef.current, imageParams: imageParamsRef.current, lastMessage: request.messages.at(-1), }); const hiddenPrompt = hiddenPromptRef.current || undefined; hiddenPromptRef.current = null; return { body: { id: rawId, message: request.messages.at(-1), selectedChatModel: currentModelIdRef.current, selectedVisibilityType: "private", videoParams: undefined, // 必须传递,即使是 undefined imageParams: imageParamsRef.current || undefined, // 传递图片参数 hiddenPrompt, ...request.body, }, }; }, }), onData: (dataPart: any) => { // 处理后端返回的自定义消息 if (dataPart.type === "data-appendMessage") { try { const messageData = typeof dataPart.data === 'string' ? JSON.parse(dataPart.data) : dataPart.data; setMessages((prevMessages) => { if (prevMessages.some(m => m.id === messageData.id)) { return prevMessages; } const newMessage: any = { id: messageData.id, role: messageData.role, content: messageData.parts?.[0]?.text || "", createdAt: new Date(), parts: messageData.parts || [], attachments: [], }; return [...prevMessages, newMessage]; }); } catch (e) { console.error("[CanvasChat] Failed to parse data-appendMessage:", e); } } }, onError: (error) => { toast.error(error.message || "生成失败"); }, }); // 当历史消息加载完成后,更新消息列表 const hasLoadedHistory = useRef(false); useEffect(() => { if (historyData?.messages?.length && !hasLoadedHistory.current) { hasLoadedHistory.current = true; setMessages(historyData.messages); console.log('[CanvasChat] 已加载历史消息:', historyData.messages.length); } }, [historyData, setMessages]); // 监听消息变化,提取图片并添加到画布 // 只处理新生成的图片,不处理历史消息中的图片 const processedMessageIds = useRef>(new Set()); const isInitialLoad = useRef(true); useEffect(() => { // 首次加载历史消息时,只记录已处理的消息 ID,不添加图片到画布 // (画布数据应该从 canvasData 中加载,而不是从消息中提取) if (isInitialLoad.current) { messages.forEach(message => { if (message.role === 'assistant') { processedMessageIds.current.add(message.id); } }); isInitialLoad.current = false; return; } // 只处理新消息 messages.forEach(async (message) => { if (message.role === 'assistant' && !processedMessageIds.current.has(message.id)) { const content = message.parts?.map((p: any) => p.text || '').join('') || ''; const imageUrls = extractImageUrls(content); if (imageUrls.length > 0) { processedMessageIds.current.add(message.id); // 检查画布中是否已存在相同 URL 的图片 const existingUrls = new Set( nodes.filter(n => n.type === 'image' && (n.data as any)?.src) .map(n => (n.data as any).src) ); // 只添加不存在的图片 const newUrls = imageUrls.filter(url => !existingUrls.has(url)); // 异步获取每张图片的尺寸并添加到画布 for (let index = 0; index < newUrls.length; index++) { const url = newUrls[index]; // 获取图片原始尺寸 const dimensions = await getImageDimensions(url); // 计算位置(避免重叠,根据图片宽度动态调整间距) const existingNodes = nodes.length + index; const spacing = 20; // 图片间距 const x = 100 + (existingNodes % 3) * (dimensions.width + spacing); const y = 100 + Math.floor(existingNodes / 3) * (dimensions.height + spacing); addNode({ type: 'image', position: { x, y }, size: { width: dimensions.width, height: dimensions.height }, data: { src: url, status: 'completed', prompt: '对话生成', }, connections: [], }); } if (newUrls.length > 0) { toast.success(`已将 ${newUrls.length} 张图片添加到画布`); } } } }); }, [messages, addNode, nodes]); // 滚动到底部 useEffect(() => { messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }); }, [messages]); // 标记是否已经为当前画布设置过标题(避免重复调用) const hasSetCanvasTitle = useRef(false); // 获取最近的图片 URL(支持多张图片) const getRecentImageUrls = useCallback((): string[] => { const urls: string[] = []; const addedUrls = new Set(); // 用于去重 // 辅助函数:添加图片 URL 并去重 const addUrl = (url: string) => { if (url && !addedUrls.has(url)) { addedUrls.add(url); urls.push(url); } }; // 1. 优先使用画布中选中的图片(支持多选) if (selectedImageNodes.length > 0) { selectedImageNodes.forEach(node => { const src = (node.data as any)?.src; if (src) addUrl(src); }); // 如果选中了图片,只返回选中的 if (urls.length > 0) { console.log('[CanvasChat] 使用选中的图片:', urls.length, '张'); return urls; } } // 2. 其次从历史对话中查找最近一次消息的所有图片 for (let i = messages.length - 1; i >= 0; i--) { const msg = messages[i]; const msgUrls: string[] = []; // 检查用户消息的附件 if (msg.role === 'user' && (msg as any).attachments?.length > 0) { for (const attachment of (msg as any).attachments) { if (attachment.url && attachment.contentType?.startsWith('image/')) { msgUrls.push(attachment.url); } } } // 检查用户消息的 parts if (msg.role === 'user' && (msg as any).parts) { for (const part of (msg as any).parts) { if (part.type === 'file' && part.url && part.mediaType?.startsWith('image/')) { msgUrls.push(part.url); } } } // 如果这条消息有图片,返回该消息的所有图片 if (msgUrls.length > 0) { msgUrls.forEach(addUrl); console.log('[CanvasChat] 使用历史对话图片:', urls.length, '张'); return urls; } } // 3. 最后使用画布中最近添加的图片 const imageNodes = nodes.filter(n => n.type === 'image' && (n.data as any)?.src); if (imageNodes.length > 0) { // 取最后几张(最多5张) const recentNodes = imageNodes.slice(-5); recentNodes.forEach(node => { const src = (node.data as any)?.src; if (src) addUrl(src); }); console.log('[CanvasChat] 使用画布图片:', urls.length, '张'); } return urls; }, [selectedImageNodes, nodes, messages]); // 处理发送(图片已在上传时添加到画布,这里只发送消息) const handleSend = useCallback(async () => { if (!input.trim() && attachments.length === 0) return; if (!selectedModelId) { toast.error("请选择一个模型"); return; } // 构建 parts 数组:文件在前,文本在后(与 multimodal-input 一致) const parts: any[] = []; // 🆕 如果没有附件,自动使用最近的图片(支持多张) let effectiveAttachments = [...attachments]; if (effectiveAttachments.length === 0 && input.trim()) { const recentImageUrls = getRecentImageUrls(); if (recentImageUrls.length > 0) { // 自动添加所有最近的图片作为附件 recentImageUrls.forEach((url, index) => { effectiveAttachments.push({ url: url, name: `图片 ${index + 1}`, contentType: 'image/jpeg', }); }); console.log('[CanvasChat] 自动使用最近图片:', recentImageUrls.length, '张'); } } // 🆕 检查是否有标注的图片 const annotatedNodes = selectedImageNodes.filter(node => (node.data as any)?.hasAnnotations); const hasAnnotatedImages = annotatedNodes.length > 0; // 添加文件部分(作为 file 类型的 part) // 🆕 收集物品信息用于构建隐藏提示 const pickedObjectInfos: string[] = []; effectiveAttachments.forEach((a) => { const att = a as PickedObjectAttachment; parts.push({ type: "file" as const, mediaType: a.contentType, name: a.name, url: a.url, hasAnnotations: hasAnnotatedImages, // 🆕 标记图片是否有标注 pickedObject: att.pickedObject, // 🆕 包含物品信息 }); // 收集物品信息 if (att.pickedObject) { const { objectName, objectPosition, boundingBox } = att.pickedObject; pickedObjectInfos.push( `【物品:${objectName}】位置:${objectPosition},边界框:(${Math.round(boundingBox.x * 100)}%, ${Math.round(boundingBox.y * 100)}%) - (${Math.round((boundingBox.x + boundingBox.width) * 100)}%, ${Math.round((boundingBox.y + boundingBox.height) * 100)}%)` ); } }); // 添加文本部分(用户可见) let textContent = input.trim() || (effectiveAttachments.length > 0 ? "请处理这张图片" : ""); // 🆕 隐藏提示词(不在 UI 中展示) const hiddenSegments: string[] = []; if (pickedObjectInfos.length > 0) { const objectsPrompt = pickedObjectInfos.join('\n'); hiddenSegments.push( `【用户指定的物品】\n${objectsPrompt}\n\n请精准地只处理上述指定的物品区域,保持其他部分不变。` ); console.log('[CanvasChat] 检测到拾取物品,已生成隐藏提示:', pickedObjectInfos.length, '个'); } if (hasAnnotatedImages) { hiddenSegments.push("【注意:图片中有标注(箭头、圆圈、文字等)标记的区域是需要重点关注或修改的部分,请根据标注进行处理】"); console.log('[CanvasChat] 检测到标注图片,已生成隐藏提示'); } const hiddenPrompt = hiddenSegments.join('\n\n').trim(); hiddenPromptRef.current = hiddenPrompt || null; if (textContent) { parts.push({ type: "text" as const, text: textContent }); } // 使用与 multimodal-input 相同的格式发送消息 sendMessage({ role: "user", parts: parts, }); // 清空状态 setInput(""); setAttachments([]); // 🆕 清空画布选中状态(防止 useEffect 重新添加标签) clearSelection(); // 🆕 清空 contenteditable div 的内容 const editableDiv = textareaRef.current as HTMLDivElement | null; if (editableDiv) { editableDiv.innerHTML = ''; console.log('[CanvasChat] 已清空输入框和选中状态'); } }, [input, attachments, selectedModelId, sendMessage, getRecentImageUrls, clearSelection]); // 处理文件选择 - 上传后立即添加到画布 const handleFileSelect = async (e: React.ChangeEvent) => { const files = e.target.files; if (!files) return; let firstImageUrl: string | null = null; // 记录本次上传开始时的节点数量,用于计算新图片位置 const baseNodeCount = nodes.length; let uploadIndex = 0; for (const file of Array.from(files)) { if (!file.type.startsWith('image/')) { toast.error('请选择图片文件'); continue; } if (file.size > 20 * 1024 * 1024) { toast.error('图片大小不能超过 20MB'); continue; } // 上传文件 try { const formData = new FormData(); formData.append('file', file); const response = await fetch('/api/files/upload', { method: 'POST', body: formData, }); if (!response.ok) { throw new Error('上传失败'); } const data = await response.json(); const imageUrl = data.url; // 记录第一张图片的 URL(用于自动命名和缩略图) if (!firstImageUrl) { firstImageUrl = imageUrl; } // 添加到附件列表并在编辑器中插入标签 setAttachments(prev => { const newIndex = prev.length; // 在编辑器中插入图片标签 setTimeout(() => { insertImageTagInEditor(imageUrl, file.name, newIndex); (textareaRef.current as HTMLDivElement | null)?.focus(); }, 0); return [...prev, { url: imageUrl, name: file.name, contentType: file.type, }]; }); // 获取图片原始尺寸后添加到画布 const dimensions = await getImageDimensions(imageUrl); // 使用 baseNodeCount + uploadIndex 计算位置,确保多张图片不重叠 const nodeIndex = baseNodeCount + uploadIndex; const spacing = 20; const x = 100 + (nodeIndex % 3) * (dimensions.width + spacing); const y = 100 + Math.floor(nodeIndex / 3) * (dimensions.height + spacing); uploadIndex++; // 递增索引 addNode({ type: 'image', position: { x, y }, size: { width: dimensions.width, height: dimensions.height }, data: { src: imageUrl, status: 'completed', prompt: '用户上传', }, connections: [], }); toast.success('图片已添加到画布'); } catch (error) { toast.error('图片上传失败'); } } // 如果是第一次上传图片,自动识别并设置画布标题和缩略图 console.log('[CanvasChat] 检查是否需要自动命名:', { firstImageUrl: !!firstImageUrl, hasSetCanvasTitle: hasSetCanvasTitle.current }); if (firstImageUrl && !hasSetCanvasTitle.current) { hasSetCanvasTitle.current = true; console.log('[CanvasChat] 开始调用 auto-title API...'); // 异步调用 AI 识别图片主体并更新画布 (async () => { try { // 调用 auto-title API 识别图片主体 console.log('[CanvasChat] 发送 auto-title 请求:', firstImageUrl); const response = await fetch('/api/canvas/auto-title', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ imageUrl: firstImageUrl }), }); console.log('[CanvasChat] auto-title 响应状态:', response.status); if (response.ok) { const data = await response.json(); const title = data.title || '未命名画布'; console.log('[CanvasChat] 识别到的标题:', title); // 提取纯 UUID(去掉 canvas- 前缀) const rawId = chatId.startsWith('canvas-') ? chatId.replace('canvas-', '') : chatId; console.log('[CanvasChat] 更新画布:', rawId); // 更新画布标题和缩略图 const updateResponse = await fetch(`/api/canvas/${rawId}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ title: title, thumbnail: firstImageUrl, }), }); console.log('[CanvasChat] 画布更新响应:', updateResponse.status); if (updateResponse.ok) { console.log('[CanvasChat] 画布已自动命名为:', title); toast.success(`画布已命名为: ${title}`); } else { console.error('[CanvasChat] 画布更新失败'); } } else { console.error('[CanvasChat] auto-title API 请求失败:', response.status); } } catch (error) { console.error('[CanvasChat] 自动命名失败:', error); } })(); } e.target.value = ''; }; // 移除附件(通过标签元素删除,支持同图多物品) const removeAttachmentByTagElement = (tagEl: HTMLElement) => { const url = tagEl.getAttribute('data-attachment-url') || ''; const isPicked = tagEl.getAttribute('data-picked-object') === 'true'; const objectName = tagEl.getAttribute('data-object-name') || ''; const objectPosition = tagEl.getAttribute('data-object-position') || ''; setAttachments(prev => prev.filter(a => { if (!url) return true; if (isPicked) { return !( a.url === url && a.pickedObject?.objectName === objectName && a.pickedObject?.objectPosition === objectPosition ); } return a.url !== url; })); if (url) { console.log('[CanvasChat] 已移除附件:', url.substring(0, 50)); } }; // 从编辑器 DOM 同步附件列表,避免删除标签后仍发送 const syncAttachmentsWithEditor = useCallback((editableDiv?: HTMLDivElement | null) => { const container = editableDiv || (textareaRef.current as HTMLDivElement | null); if (!container) return; const tagElements = Array.from(container.querySelectorAll('[data-attachment-url]')) as HTMLElement[]; const tagKeys = new Set(); tagElements.forEach((el) => { const url = el.getAttribute('data-attachment-url') || ''; if (!url) return; const isPicked = el.getAttribute('data-picked-object') === 'true'; if (isPicked) { const objectName = el.getAttribute('data-object-name') || ''; const objectPosition = el.getAttribute('data-object-position') || ''; tagKeys.add(`picked|${url}|${objectName}|${objectPosition}`); } else { tagKeys.add(`image|${url}`); } }); setAttachments(prev => prev.filter((att) => { if (att.pickedObject) { const key = `picked|${att.url}|${att.pickedObject.objectName}|${att.pickedObject.objectPosition}`; return tagKeys.has(key); } return tagKeys.has(`image|${att.url}`); })); }, []); // 移除附件(通过索引删除,保留兼容性) const removeAttachment = (index: number) => { setAttachments(prev => prev.filter((_, i) => i !== index)); }; const isLoading = status === 'streaming' || status === 'submitted'; const lastUserIndexForMarkers = messages.findLastIndex(m => m.role === "user"); const messagesAfterLastUser = lastUserIndexForMarkers >= 0 ? messages.slice(lastUserIndexForMarkers + 1) : messages; const hasGenerationStart = messagesAfterLastUser.some(msg => msg.role === "assistant" && msg.parts?.some( (part: any) => part.type === "text" && part.text?.includes("") ) ); const hasGenerationEnd = messagesAfterLastUser.some(msg => msg.role === "assistant" && msg.parts?.some( (part: any) => part.type === "text" && part.text?.includes("") ) ); const currentModel = allModels.find(m => m.id === selectedModelId); const shouldShowImageLoading = hasGenerationStart && !hasGenerationEnd && !!currentModel; const loadingImageCount = imageParams.imageCount || 1; return (
{/* Header - Minimal with Icons */}
{onClose && ( )}
{/* 消息列表 */}
{messages.length === 0 && (
{/* Greeting */}

Hi, 我是你的AI设计师

让我们开始今天的创作吧!

{/* Suggestions - Removed */} {/* Refresh - Removed */} {/* Banner - 从系统设置中获取 */} {(siteSettings?.bannerEnabled ?? true) && (siteSettings?.bannerText || "升级会员, Nano Banana Pro 免费365天!") && (
{ if (siteSettings?.bannerLink) { window.open(siteSettings.bannerLink, '_blank'); } }} >
{siteSettings?.bannerText || "升级会员, Nano Banana Pro 免费365天!"}
e.stopPropagation()} />
)}
)} {messages.map((message) => { // 🆕 清理消息中的内部标记并检查是否有实际内容 const rawParts = message.parts || []; const cleanedParts = rawParts.map((part: any) => { if (part.type === 'text' && part.text) { return { ...part, text: part.text .replace(/\n*\n*/g, "") .replace(/\n*\n*/g, "") .replace(/\n*\n*/g, "") .trim() }; } return part; }); // 检查清理后是否有实际内容(更严格的检查) let hasContent = false; for (const part of cleanedParts) { if (part.type === 'text' && part.text && part.text.trim().length > 0) { hasContent = true; break; } if ((part.type === 'file' || part.type === 'image') && part.url) { hasContent = true; break; } } // 同时检查附件 const attachmentsList = (message as any).attachments || []; if (attachmentsList.length > 0) { hasContent = true; } // 没有内容则不渲染(包括空 parts 的情况) if (!hasContent) { console.log('[CanvasChat] 跳过空消息:', message.id); return null; } // 使用清理后的消息 const cleanedMessage = { ...message, parts: cleanedParts }; return (
{/* 头像 */} {message.role === 'user' ? ( <> ) : ( <> )} {/* 消息气泡 */}
{message.role === 'user' ? ( // 🆕 用户消息:使用图片标签样式(类似竞品,按 parts 原始顺序渲染)
{/* 按 parts 原始顺序渲染图片标签和文字 */} {cleanedMessage.parts?.map((part: any, idx: number) => { if (part.type === 'file' && part.url) { // 图片标签 return ( {part.name || 'Image'} ); } if (part.type === 'text' && part.text) { // 文字(去掉标注提示前缀) let text = part.text.replace(/【注意:图片中有标注(箭头、圆圈、文字等)标记的区域是需要重点关注或修改的部分,请根据标注进行处理】\n?/g, '').trim(); if (!text) return null; return ( {text} ); } return null; })} {/* 如果有 attachments 但没有 file parts,也显示 */} {(!cleanedMessage.parts?.some((p: any) => p.type === 'file') && (message as any).attachments?.length > 0) && (message as any).attachments.map((att: any, idx: number) => ( {att.name || 'Image'} )) }
) : ( // AI 消息:保持原样式
{/* 消息内容 */}
{cleanedMessage.parts?.map((part: any, i: number) => { if (part.type === 'text') { // 提取并显示图片 const imageUrls = extractImageUrls(part.text || ''); const textWithoutImages = (part.text || '') .replace(/!\[(?:thumb)?\|\|\|[^|]+\|\|\|[^\]]*\]\([^)]+\)/g, '') .trim(); return (
{textWithoutImages && {textWithoutImages}} {imageUrls.length > 0 && (
{imageUrls.map((url, idx) => (
window.open(url, '_blank')} />
))}
)}
); } return null; })}
)}
); })} {shouldShowImageLoading && ( )} {isLoading && !shouldShowImageLoading && (
正在思考中...
)}
{/* 底部输入区域 - 混合输入(图片标签+文字可自由混合) */}
{/* 混合输入区域:使用 contenteditable 实现真正的混合编辑 */}
{ // 从 contenteditable 中提取纯文本(排除图片标签的 data-url) const target = e.target as HTMLDivElement; // 获取所有文本节点的内容 let textContent = ''; const walker = document.createTreeWalker(target, NodeFilter.SHOW_TEXT, null); let node; while (node = walker.nextNode()) { textContent += node.textContent; } setInput(textContent.trim()); syncAttachmentsWithEditor(target); }} onKeyDown={(e) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); handleSend(); } // 处理 Backspace 删除图片标签(只删除聊天框中的标签,不影响画布) if (e.key === 'Backspace') { const selection = window.getSelection(); if (selection && selection.rangeCount > 0) { const range = selection.getRangeAt(0); if (range.collapsed && range.startOffset === 0) { // 检查前一个兄弟节点是否是图片标签 const prevSibling = range.startContainer.previousSibling as HTMLElement; if (prevSibling && prevSibling.getAttribute?.('data-attachment-url')) { e.preventDefault(); e.stopPropagation(); // 阻止事件冒泡,防止触发全局删除 // 使用 URL 来删除附件,更可靠 removeAttachmentByTagElement(prevSibling); prevSibling.remove(); } } } } }} onPaste={(e) => { // 只粘贴纯文本 e.preventDefault(); const text = e.clipboardData.getData('text/plain'); document.execCommand('insertText', false, text); }} style={{ whiteSpace: 'pre-wrap', wordBreak: 'break-word' }} />
{/* Bottom Toolbar */}
{/* 左侧:附件和模型选择 */}
{/* Agent Selector (Model) - 包含智能自动选项 */}
{/* 中间:图片参数(可收缩) */}
m.id === selectedModelId)?.displayName} initialParams={imageParams} onParamsChange={handleImageParamsChange} inline={true} providerType={allModels.find(m => m.id === selectedModelId)?.providerType} />
{/* 右侧:发送按钮(固定不收缩) */}
{/* 🆕 标签悬停预览弹窗 - 固定显示在标签上方 */} {tagPreview && (() => { // 计算弹窗位置 const previewWidth = 240; const previewHeight = 280; const padding = 16; // 简化:直接使用标签位置,在标签上方居中显示 // tagPreview.x 是标签中心的x坐标 // tagPreview.y 是标签顶部的y坐标 let left = tagPreview.x - previewWidth / 2; let top = tagPreview.y - previewHeight - 12; // 确保不超出视口左边界 if (left < padding) { left = padding; } // 确保不超出视口右边界 if (typeof window !== 'undefined' && left + previewWidth > window.innerWidth - padding) { left = window.innerWidth - previewWidth - padding; } // 如果上方空间不够,显示在标签下方 if (top < padding) { top = tagPreview.y + 36; // 标签下方 } // 确保不超出视口底部 if (typeof window !== 'undefined' && top + previewHeight > window.innerHeight - padding) { top = window.innerHeight - previewHeight - padding; } // 最终确保 top 不为负 if (top < padding) { top = padding; } // 使用 Portal 渲染到 body,避免 backdrop-blur 影响 fixed 定位 return createPortal(
{ // 鼠标进入预览弹窗,取消隐藏 if (previewTimeoutRef.current) { clearTimeout(previewTimeoutRef.current); previewTimeoutRef.current = null; } }} onMouseLeave={() => { // 鼠标离开预览弹窗,隐藏 setTagPreview(null); }} > {/* 图片全貌 - 使用 inline-block + 居中,让标注点相对于图片本身定位 */}
{tagPreview.name} {/* 如果是物品标签,显示标注位置 */} {tagPreview.pickedObject && ( <> {/* 标注点 */}
{/* 序号标记 */}
{/* 脉冲动画背景 */}
{/* 主标记点 */}
{/* 物品名称标签 */}
{tagPreview.pickedObject.objectName}
)}
{/* 底部信息 */}
{tagPreview.pickedObject ? tagPreview.pickedObject.objectName : tagPreview.name}
{tagPreview.pickedObject && (
{tagPreview.pickedObject.objectPosition}
)}
, document.body ); })()}
); }