"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 图片格式: ![alt|||originalUrl|||size](thumbnailUrl) const markdownRegex = /!\[(?:thumb)?\|\|\|([^|]+)\|\|\|[^\]]*\]\([^)]+\)/g; let match; while ((match = markdownRegex.exec(text)) !== null) { if (match[1]) { urls.push(match[1]); } } // 匹配普通图片 URL 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 { 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) || []; // 默认选择第一个图片模型 useEffect(() => { if (imageModels.length > 0 && !selectedModelId) { setSelectedModelId(imageModels[0].id); } }, [imageModels, selectedModelId]); const currentModelIdRef = useRef(selectedModelId); const imageParamsRef = useRef(imageParams); useEffect(() => { currentModelIdRef.current = selectedModelId; }, [selectedModelId]); useEffect(() => { imageParamsRef.current = imageParams; }, [imageParams]); const { messages, setMessages, sendMessage, status, stop, } = useChat({ id: chatId, messages: initialMessages, experimental_throttle: 100, generateId: generateUUID, transport: new DefaultChatTransport({ api: "/api/chat", fetch: fetchWithErrorHandlers, prepareSendMessagesRequest(request) { return { body: { id: request.id, message: request.messages.at(-1), selectedChatModel: currentModelIdRef.current, selectedVisibilityType: "private", imageParams: imageParamsRef.current, // 传递图片参数 ...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 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 handleSend = useCallback(() => { if (!input.trim() && attachments.length === 0) return; if (!selectedModelId) { toast.error("请选择一个模型"); return; } const messageAttachments = attachments.map(a => ({ contentType: a.contentType, url: a.url, name: a.name, })); sendMessage({ role: "user" as const, parts: [{ type: "text", text: input }], attachments: messageAttachments, }); setInput(""); setAttachments([]); }, [input, attachments, selectedModelId, sendMessage]); // 处理文件选择 const handleFileSelect = async (e: React.ChangeEvent) => { const files = e.target.files; if (!files) return; 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(); setAttachments(prev => [...prev, { url: data.url, name: file.name, contentType: file.type, }]); } catch (error) { toast.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.attachments && message.attachments.length > 0 && (
{message.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) => (
))}
)}