"use client"; import { useChat } from "@ai-sdk/react"; import { DefaultChatTransport } from "ai"; import { useEffect, useRef, useState, useCallback } from "react"; 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"; interface CanvasChatPanelProps { chatId: string; initialMessages?: ChatMessage[]; onClose?: () => void; onOpenFileList?: () => void; } // 建议卡片数据 - 已移除 const SUGGESTIONS: { title: string; desc: string; image: string }[] = []; // 从消息中提取图片 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; } export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenFileList }: CanvasChatPanelProps) { 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 [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); // 获取纯 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 ); // 当选中画布中的图片时,自动添加到附件 useEffect(() => { if (selectedImageNodes.length > 0) { const newAttachments: Attachment[] = selectedImageNodes.map(node => { const data = node.data as any; return { url: data.src, name: data.prompt || '画布图片', contentType: 'image/jpeg', }; }); // 只添加还没在附件中的图片 setAttachments(prev => { const existingUrls = new Set(prev.map(a => a.url)); const toAdd = newAttachments.filter(a => !existingUrls.has(a.url)); if (toAdd.length > 0) { return [...prev, ...toAdd]; } return prev; }); } }, [selectedImageNodes.length, selectedNodeIds]); // 获取模型列表 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; }>("/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) { setSelectedModelId("auto"); // 默认使用智能自动模式 } }, [selectedModelId]); 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), }); return { body: { id: rawId, message: request.messages.at(-1), selectedChatModel: currentModelIdRef.current, selectedVisibilityType: "private", videoParams: undefined, // 必须传递,即使是 undefined imageParams: imageParamsRef.current || undefined, // 传递图片参数 ...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()); useEffect(() => { messages.forEach(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); // 为每张图片创建画布节点 imageUrls.forEach((url, index) => { // 计算位置(避免重叠) const existingNodes = nodes.length; const x = 100 + ((existingNodes + index) % 4) * 320; const y = 100 + Math.floor((existingNodes + index) / 4) * 320; addNode({ type: 'image', position: { x, y }, size: { width: 300, height: 300 }, data: { src: url, status: 'completed', prompt: '对话生成', }, connections: [], }); }); if (imageUrls.length > 0) { toast.success(`已将 ${imageUrls.length} 张图片添加到画布`); } } } }); }, [messages, addNode, nodes.length]); // 滚动到底部 useEffect(() => { messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }); }, [messages]); // 标记是否已经为当前画布设置过标题(避免重复调用) const hasSetCanvasTitle = useRef(false); // 处理发送(图片已在上传时添加到画布,这里只发送消息) const handleSend = useCallback(async () => { if (!input.trim() && attachments.length === 0) return; if (!selectedModelId) { toast.error("请选择一个模型"); return; } // 构建 parts 数组:文件在前,文本在后(与 multimodal-input 一致) const parts: any[] = []; // 添加文件部分(作为 file 类型的 part) attachments.forEach((a) => { parts.push({ type: "file" as const, mediaType: a.contentType, name: a.name, url: a.url, }); }); // 添加文本部分 const textContent = input.trim() || (attachments.length > 0 ? "请处理这张图片" : ""); if (textContent) { parts.push({ type: "text" as const, text: textContent }); } // 使用与 multimodal-input 相同的格式发送消息 sendMessage({ role: "user", parts: parts, }); setInput(""); setAttachments([]); }, [input, attachments, selectedModelId, sendMessage]); // 处理文件选择 - 上传后立即添加到画布 const handleFileSelect = async (e: React.ChangeEvent) => { const files = e.target.files; if (!files) return; let firstImageUrl: string | null = null; for (const file of Array.from(files)) { if (!file.type.startsWith('image/')) { toast.error('请选择图片文件'); continue; } if (file.size > 8 * 1024 * 1024) { toast.error('图片大小不能超过 8MB'); 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 => [...prev, { url: imageUrl, name: file.name, contentType: file.type, }]); // 立即将图片添加到画布 const existingNodes = nodes.length; const x = 100 + (existingNodes % 4) * 320; const y = 100 + Math.floor(existingNodes / 4) * 320; addNode({ type: 'image', position: { x, y }, size: { width: 300, height: 300 }, 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 removeAttachment = (index: number) => { setAttachments(prev => prev.filter((_, i) => i !== index)); }; const isLoading = status === 'streaming' || status === 'submitted'; 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) => (
{/* 头像 */} {message.role === 'user' ? ( <> ) : ( <> )} {/* 消息气泡 */}
{/* 附件预览 */} {(message as any).attachments && (message as any).attachments.length > 0 && (
{(message as any).attachments.map((att: any, i: number) => ( ))}
)} {/* 消息内容 */}
{message.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; })}
))} {isLoading && (
正在思考中...
)}
{/* 底部输入区域 - Redesigned */}
{/* 附件预览 */} {attachments.length > 0 && (
{attachments.map((att, index) => (
))}
)}