'use client' import { useState, useEffect, useRef, useCallback } from 'react' import { useSession } from 'next-auth/react' import Image from 'next/image' import { hasDragLeftDropZone, prepareFileDrag, validateImageFiles, } from '@/components/upload/drag-drop-utils' const AI_CHAT_MAX_IMAGES = 5 const AI_CHAT_MAX_IMAGE_BYTES = 10 * 1024 * 1024 // 添加动画样式 const shimmerStyle = ` @keyframes shimmer { 0% { background-position: -200% 0; } 100% { background-position: 200% 0; } } @keyframes breathe { 0%, 100% { filter: blur(15px) brightness(0.7); transform: scale(1); } 50% { filter: blur(12px) brightness(0.8); transform: scale(1.02); } } ` // 图片加载占位符组件 const ImageLoadingPlaceholder = () => { return (
{/* 动态模糊背景 */}
{/* 中心图标 */}
{/* 旋转的光圈 */}
{/* 中心图标 */}

正在生成图片...

AI正在为您创作中

) } interface Message { id: string role: 'user' | 'assistant' | 'system' content: string images?: string[] timestamp: Date isGenerating?: boolean // 是否正在生成图片 imageCount?: number // 预期生成的图片数量 sourceImages?: string[] // 用户发送的原始图片(用于显示模糊占位符) } interface PendingImage { id: string file: File previewUrl: string } function readFileAsDataUrl(file: File): Promise { return new Promise((resolve, reject) => { const reader = new FileReader() reader.onerror = () => reject(new Error(`无法读取图片:${file.name}`)) reader.onload = () => resolve(reader.result as string) reader.readAsDataURL(file) }) } export default function AIChatWidget() { const { data: session } = useSession() const [isOpen, setIsOpen] = useState(false) const [messages, setMessages] = useState([]) const [input, setInput] = useState('') const [sending, setSending] = useState(false) const [pendingImages, setPendingImages] = useState([]) const [imageError, setImageError] = useState('') const [isDragging, setIsDragging] = useState(false) const [windowDragging, setWindowDragging] = useState(false) const [position, setPosition] = useState({ x: 0, y: 0 }) const [dragStart, setDragStart] = useState({ x: 0, y: 0 }) const messagesEndRef = useRef(null) const fileInputRef = useRef(null) const containerRef = useRef(null) const dropZoneRef = useRef(null) const headerRef = useRef(null) const pendingImagesRef = useRef([]) // 窗口拖拽功能 const handleMouseDown = (e: React.MouseEvent) => { if (e.button !== 0) return // 只响应左键 setWindowDragging(true) setDragStart({ x: e.clientX - position.x, y: e.clientY - position.y }) } useEffect(() => { const handleMouseMove = (e: MouseEvent) => { if (!windowDragging) return setPosition({ x: e.clientX - dragStart.x, y: e.clientY - dragStart.y }) } const handleMouseUp = () => { setWindowDragging(false) } if (windowDragging) { document.addEventListener('mousemove', handleMouseMove) document.addEventListener('mouseup', handleMouseUp) } return () => { document.removeEventListener('mousemove', handleMouseMove) document.removeEventListener('mouseup', handleMouseUp) } }, [windowDragging, dragStart]) // 点击外部关闭 useEffect(() => { const handleClickOutside = (event: MouseEvent) => { if (containerRef.current && !containerRef.current.contains(event.target as Node)) { setIsOpen(false) } } if (isOpen) { document.addEventListener('mousedown', handleClickOutside) } return () => document.removeEventListener('mousedown', handleClickOutside) }, [isOpen]) // 自动滚动到底部 useEffect(() => { messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }) }, [messages]) const replacePendingImages = useCallback((nextImages: PendingImage[]) => { pendingImagesRef.current = nextImages setPendingImages(nextImages) }, []) const addImageFiles = useCallback((selectedFiles: FileList | File[]) => { if (sending) { setImageError('消息正在发送,请等待完成后再添加图片') return } const { acceptedFiles, errors } = validateImageFiles(selectedFiles, { maxBytes: AI_CHAT_MAX_IMAGE_BYTES, maxCount: AI_CHAT_MAX_IMAGES, existingCount: pendingImagesRef.current.length, }) if (acceptedFiles.length > 0) { const nextImages = [ ...pendingImagesRef.current, ...acceptedFiles.map((file, index) => ({ id: `${Date.now()}-${index}-${Math.random().toString(36).slice(2, 8)}`, file, previewUrl: URL.createObjectURL(file), })), ] replacePendingImages(nextImages) } setImageError(errors.join(';')) }, [replacePendingImages, sending]) useEffect(() => { return () => { pendingImagesRef.current.forEach((image) => URL.revokeObjectURL(image.previewUrl)) } }, []) // 处理粘贴图片 useEffect(() => { const handlePaste = (e: ClipboardEvent) => { if (!isOpen || sending) return const items = e.clipboardData?.items if (!items) return const pastedFiles: File[] = [] for (let i = 0; i < items.length; i++) { if (items[i].type.indexOf('image') !== -1) { const file = items[i].getAsFile() if (file) { pastedFiles.push(file) } } } if (pastedFiles.length > 0) { e.preventDefault() addImageFiles(pastedFiles) } } if (isOpen) { document.addEventListener('paste', handlePaste) } return () => document.removeEventListener('paste', handlePaste) }, [addImageFiles, isOpen, sending]) // 处理图片选择 const handleImageSelect = (e: React.ChangeEvent) => { addImageFiles(e.target.files || []) e.currentTarget.value = '' } // 移除图片 const removeImage = (index: number) => { if (sending) return const removedImage = pendingImagesRef.current[index] if (removedImage) { URL.revokeObjectURL(removedImage.previewUrl) } replacePendingImages(pendingImagesRef.current.filter((_, imageIndex) => imageIndex !== index)) setImageError('') } // 处理拖拽上传 const handleDragOver = (e: React.DragEvent) => { prepareFileDrag(e, sending) if (sending) return setIsDragging(true) } const handleDragLeave = (e: React.DragEvent) => { e.preventDefault() e.stopPropagation() if (hasDragLeftDropZone(e)) { setIsDragging(false) } } const handleDrop = (e: React.DragEvent) => { prepareFileDrag(e, sending) setIsDragging(false) if (sending) { setImageError('消息正在发送,请等待完成后再添加图片') return } addImageFiles(e.dataTransfer.files) } // 发送消息 const handleSend = async () => { const selectedImages = pendingImagesRef.current if ((!input.trim() && selectedImages.length === 0) || sending || !session) return setSending(true) try { const currentImages = await Promise.all( selectedImages.map((image) => readFileAsDataUrl(image.file)), ) const userMessage: Message = { id: Date.now().toString(), role: 'user', content: input.trim(), images: currentImages, timestamp: new Date() } setMessages(prev => [...prev, userMessage]) setInput('') selectedImages.forEach((image) => URL.revokeObjectURL(image.previewUrl)) replacePendingImages([]) setImageError('') // 检测是否需要生成图片 const shouldShowImagePlaceholder = currentImages.length > 0 || userMessage.content.includes('生成') || userMessage.content.includes('创建') || userMessage.content.includes('画') || userMessage.content.includes('图片') || userMessage.content.includes('图') // 如果需要生成图片,在用户消息中添加指令 let finalMessageContent = userMessage.content if (shouldShowImagePlaceholder && !userMessage.content.includes('4张') && !userMessage.content.includes('四张')) { finalMessageContent += '\n\n(请生成4张不同风格或角度的图片)' } // 构建消息内容 let messageContent: any = [] if (finalMessageContent) { messageContent.push({ type: 'text', text: finalMessageContent }) } // 添加图片 currentImages.forEach(img => { messageContent.push({ type: 'image_url', image_url: { url: img } }) }) // 构建对话历史(保留图片信息,支持上下文连续对话) const conversationHistory = messages .filter(msg => !msg.isGenerating) // 过滤掉正在生成的临时消息 .map(msg => { if (msg.images && msg.images.length > 0) { const parts: any[] = [] if (msg.content) { parts.push({ type: 'text', text: msg.content }) } msg.images.forEach(img => { parts.push({ type: 'image_url', image_url: { url: img } }) }) return { role: msg.role, content: parts } } return { role: msg.role, content: msg.content } }) conversationHistory.push({ role: 'user', content: messageContent.length === 1 && messageContent[0].type === 'text' ? messageContent[0].text : messageContent }) // 先添加一个加载中的占位消息 const tempAiMessageId = (Date.now() + 1).toString() const tempAiMessage: Message = { id: tempAiMessageId, role: 'assistant', content: '', timestamp: new Date(), isGenerating: shouldShowImagePlaceholder, imageCount: shouldShowImagePlaceholder ? 4 : 0, sourceImages: currentImages.length > 0 ? currentImages : undefined // 保存用户发送的原图 } setMessages(prev => [...prev, tempAiMessage]) // 调用API const response = await fetch('/api/ai-chat', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ messages: conversationHistory }) }) if (!response.ok) { const { parseFetchError } = await import('@/lib/error-handler') const error = await parseFetchError(response) throw new Error(error.message) } const data = await response.json() // 解析AI响应 let aiContent = '' let aiImages: string[] = [] if (data.candidates && data.candidates.length > 0) { const candidate = data.candidates[0] if (candidate.content && candidate.content.parts) { for (const part of candidate.content.parts) { if (part.text) { // 检查是否包含图片 const base64ImageMatch = part.text.match(/!\[image\]\(data:image\/([^;]+);base64,([^)]+)\)/) if (base64ImageMatch) { const mimeType = base64ImageMatch[1] const base64Data = base64ImageMatch[2] aiImages.push(`data:image/${mimeType};base64,${base64Data}`) } // 移除Markdown图片语法 const cleanText = part.text .replace(/!\[image\]\(data:image\/[^;]+;base64,[^)]+\)/g, '') .replace(/!\[Image\]\([^)]+\)/g, '') .trim() if (cleanText) { aiContent += cleanText } } else if (part.inlineData && part.inlineData.data) { const mimeType = part.inlineData.mimeType || 'image/png' aiImages.push(`data:${mimeType};base64,${part.inlineData.data}`) } } } } // 更新临时占位消息为实际内容 setMessages(prev => prev.map(msg => msg.id === tempAiMessageId ? { ...msg, content: aiContent || '收到您的消息了', images: aiImages.length > 0 ? aiImages : undefined, isGenerating: false } : msg )) } catch (error) { console.error('发送失败:', error) const errorMessage: Message = { id: (Date.now() + 1).toString(), role: 'system', content: '发送失败,请重试', timestamp: new Date() } setMessages(prev => [...prev, errorMessage]) } finally { setSending(false) } } if (!session) return null return (
{/* AI聊天窗口 - 苹果风格 */} {isOpen && ( <>