/** * AIChatWidget 组件 * * AI聊天助手组件 * 支持文字和图片输入,AI生成图片和内容 * 支持窗口拖拽、图片上传、粘贴等功能 * 使用三层架构样式系统,支持深浅色主题 * * @created 2025-01-XX * @component */ 'use client' import { useState, useEffect, useRef } from 'react' import { useSession } from 'next-auth/react' import Image from 'next/image' import { cn } from '@/lib/ui-styles' // 图片加载占位符组件 const ImageLoadingPlaceholder = () => { return (

图片处理中

) } interface Message { id: string role: 'user' | 'assistant' | 'system' content: string images?: string[] timestamp: Date isGenerating?: boolean // 是否正在生成图片 imageCount?: number // 预期生成的图片数量 sourceImages?: string[] // 用户发送的原始图片(用于显示模糊占位符) } 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 [images, setImages] = useState([]) const [imagePreviews, setImagePreviews] = 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 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]) // 处理粘贴图片 useEffect(() => { const handlePaste = (e: ClipboardEvent) => { if (!isOpen) return const items = e.clipboardData?.items if (!items) return for (let i = 0; i < items.length; i++) { if (items[i].type.indexOf('image') !== -1) { const file = items[i].getAsFile() if (file) { setImages(prev => [...prev, file].slice(0, 5)) const reader = new FileReader() reader.onloadend = () => { setImagePreviews(prev => [...prev, reader.result as string]) } reader.readAsDataURL(file) } } } } if (isOpen) { document.addEventListener('paste', handlePaste) } return () => document.removeEventListener('paste', handlePaste) }, [isOpen]) // 处理图片选择 const handleImageSelect = (e: React.ChangeEvent) => { const files = Array.from(e.target.files || []) if (files.length > 0) { setImages(prev => [...prev, ...files].slice(0, 5)) // 最多5张图片 // 生成预览 files.forEach(file => { const reader = new FileReader() reader.onloadend = () => { setImagePreviews(prev => [...prev, reader.result as string]) } reader.readAsDataURL(file) }) } } // 移除图片 const removeImage = (index: number) => { setImages(prev => prev.filter((_, i) => i !== index)) setImagePreviews(prev => prev.filter((_, i) => i !== index)) } // 处理拖拽上传 const handleDragOver = (e: React.DragEvent) => { e.preventDefault() setIsDragging(true) } const handleDragLeave = (e: React.DragEvent) => { e.preventDefault() setIsDragging(false) } const handleDrop = (e: React.DragEvent) => { e.preventDefault() setIsDragging(false) const files = Array.from(e.dataTransfer.files).filter(file => file.type.startsWith('image/')) if (files.length > 0) { setImages(prev => [...prev, ...files].slice(0, 5)) files.forEach(file => { const reader = new FileReader() reader.onloadend = () => { setImagePreviews(prev => [...prev, reader.result as string]) } reader.readAsDataURL(file) }) } } // 发送消息 const handleSend = async () => { if ((!input.trim() && images.length === 0) || sending || !session) return setSending(true) const userMessage: Message = { id: Date.now().toString(), role: 'user', content: input.trim(), images: imagePreviews, timestamp: new Date() } setMessages(prev => [...prev, userMessage]) setInput('') const currentImages = imagePreviews setImagePreviews([]) setImages([]) try { // 检测是否需要生成图片 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) { throw new Error('发送失败') } 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 && ( <>
{/* 头部 - 可拖拽 */}

助手

文字和图片处理

{/* 消息列表 */}
{/* 拖拽提示 */} {isDragging && (

松开鼠标上传图片

最多 5 张

)} {messages.length === 0 && !isDragging && (

助手

可以发送文字或图片

支持图片处理和内容生成

拖拽图片到此处快速上传
)} {messages.map((msg) => (
{msg.content &&
{msg.content}
} {/* 显示图片加载占位符 */} {msg.isGenerating && (
{/* 如果有原图,显示原图的模糊占位符 */} {msg.sourceImages && msg.sourceImages.length > 0 ? (
{msg.sourceImages.map((img, idx) => (
{`Processing
处理中...
))}
) : ( /* 没有原图,显示4个空白占位符(生成任务) */ msg.imageCount && msg.imageCount > 0 && (
{Array.from({ length: msg.imageCount }).map((_, idx) => (
))}
) )}
)} {/* 显示实际图片 - 添加淡入动画 */} {!msg.isGenerating && msg.images && msg.images.length > 0 && (
{msg.images.map((img, idx) => (
{`Image { const win = window.open() if (win) { win.document.write(``) } }} /> {/* 下载按钮 */}
))}
)}
{msg.timestamp.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })}
))}
{/* 图片预览区 */} {imagePreviews.length > 0 && (
{imagePreviews.map((preview, idx) => (
{`Preview
))}
)} {/* 输入区 */}
{/* 上传按钮 */} {/* 输入框 */}