gongxiangp2/components/AIChatWidget.tsx
chenpei b3de06ea39 feat: 增强AI聊天助手 - 完整图片支持
新增功能:
✅ 支持发送图片(点击上传、拖拽、粘贴)
✅ 支持接收AI返回的图片(Base64、inline data)
✅ 图片预览和下载功能
✅ 点击图片新窗口查看
✅ 拖拽上传可视化提示
✅ 支持粘贴剪贴板图片
✅ 最多支持5张图片同时发送
✅ 对话历史保留图片信息

图片处理:
- 自动转换为Base64格式
- 支持多种图片格式(PNG、JPG、GIF等)
- 图片预览带删除按钮
- 图片下载功能
- Hover显示下载按钮

用户体验:
- 拖拽区域高亮提示
- 实时图片预览
- 图片点击放大
- 支持键盘快捷键
- 响应式设计

这是一个功能完整的AI图片编辑助手!
2025-10-27 17:49:50 +08:00

459 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'use client'
import { useState, useEffect, useRef } from 'react'
import { useSession } from 'next-auth/react'
import Image from 'next/image'
interface Message {
id: string
role: 'user' | 'assistant' | 'system'
content: string
images?: string[]
timestamp: Date
}
export default function AIChatWidget() {
const { data: session } = useSession()
const [isOpen, setIsOpen] = useState(false)
const [messages, setMessages] = useState<Message[]>([])
const [input, setInput] = useState('')
const [sending, setSending] = useState(false)
const [images, setImages] = useState<File[]>([])
const [imagePreviews, setImagePreviews] = useState<string[]>([])
const [isDragging, setIsDragging] = useState(false)
const messagesEndRef = useRef<HTMLDivElement>(null)
const fileInputRef = useRef<HTMLInputElement>(null)
const containerRef = useRef<HTMLDivElement>(null)
const dropZoneRef = useRef<HTMLDivElement>(null)
// 点击外部关闭
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<HTMLInputElement>) => {
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 {
// 构建消息内容
let messageContent: any = []
if (userMessage.content) {
messageContent.push({
type: 'text',
text: userMessage.content
})
}
// 添加图片
currentImages.forEach(img => {
messageContent.push({
type: 'image_url',
image_url: { url: img }
})
})
// 构建对话历史(保留图片信息)
const conversationHistory = messages.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
})
// 调用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}`)
}
}
}
}
const aiMessage: Message = {
id: (Date.now() + 1).toString(),
role: 'assistant',
content: aiContent || '收到您的消息了',
images: aiImages.length > 0 ? aiImages : undefined,
timestamp: new Date()
}
setMessages(prev => [...prev, aiMessage])
} 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 (
<div ref={containerRef} className="fixed bottom-6 right-6 z-50">
{/* AI聊天窗口 */}
{isOpen && (
<div className="absolute bottom-16 right-0 w-[420px] h-[600px] bg-white dark:bg-gray-800 rounded-xl shadow-2xl border border-gray-200 dark:border-gray-700 overflow-hidden flex flex-col">
{/* 头部 */}
<div className="px-4 py-3 bg-gradient-to-r from-purple-500 to-blue-500 text-white flex items-center justify-between">
<div className="flex items-center gap-2">
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z" />
</svg>
<span className="font-semibold text-lg">AI灵魂助手</span>
</div>
<button
onClick={() => setIsOpen(false)}
className="hover:bg-white/20 rounded-full p-1 transition-colors"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
{/* 消息列表 */}
<div
ref={dropZoneRef}
className="flex-1 overflow-y-auto p-4 space-y-4 bg-gray-50 dark:bg-gray-900 relative"
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
>
{/* 拖拽提示 */}
{isDragging && (
<div className="absolute inset-0 bg-blue-500/20 dark:bg-blue-400/20 backdrop-blur-sm z-10 flex items-center justify-center border-2 border-dashed border-blue-500 dark:border-blue-400 m-2 rounded-lg">
<div className="text-center">
<svg className="w-16 h-16 mx-auto text-blue-500 dark:text-blue-400 mb-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
<p className="text-lg font-semibold text-blue-600 dark:text-blue-400">松开鼠标上传图片</p>
<p className="text-sm text-blue-500 dark:text-blue-300 mt-1">支持多张图片(最多5张)</p>
</div>
</div>
)}
{messages.length === 0 && !isDragging && (
<div className="text-center text-gray-500 dark:text-gray-400 mt-8">
<p className="mb-2">👋 你好!我是AI助手</p>
<p className="text-sm">可以发送文字或图片与我聊天</p>
<p className="text-sm">支持图片编辑、内容生成等功能</p>
<p className="text-sm text-blue-500 dark:text-blue-400 mt-2">💡 拖拽图片到此处快速上传</p>
</div>
)}
{messages.map((msg) => (
<div
key={msg.id}
className={`flex ${msg.role === 'user' ? 'justify-end' : 'justify-start'}`}
>
<div className={`max-w-[80%] ${
msg.role === 'user'
? 'bg-blue-500 text-white'
: msg.role === 'assistant'
? 'bg-white dark:bg-gray-800 text-gray-900 dark:text-white border border-gray-200 dark:border-gray-700'
: 'bg-yellow-50 dark:bg-yellow-900/20 text-yellow-800 dark:text-yellow-200'
} rounded-lg p-3 shadow-sm`}>
{msg.content && <div className="whitespace-pre-wrap text-sm">{msg.content}</div>}
{msg.images && msg.images.length > 0 && (
<div className="mt-2 space-y-2">
{msg.images.map((img, idx) => (
<div key={idx} className="relative group rounded overflow-hidden bg-gray-100 dark:bg-gray-700">
<img
src={img}
alt={`Image ${idx + 1}`}
className="w-full rounded cursor-pointer hover:opacity-90 transition-opacity"
onClick={() => {
// 点击图片在新窗口打开
const win = window.open()
if (win) {
win.document.write(`<img src="${img}" style="max-width:100%;height:auto;" />`)
}
}}
/>
{/* 下载按钮 */}
<button
onClick={(e) => {
e.stopPropagation()
const link = document.createElement('a')
link.href = img
link.download = `image-${Date.now()}.png`
link.click()
}}
className="absolute top-2 right-2 bg-black/50 hover:bg-black/70 text-white p-1.5 rounded opacity-0 group-hover:opacity-100 transition-opacity"
title="下载图片"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
</svg>
</button>
</div>
))}
</div>
)}
<div className="text-xs opacity-60 mt-1">
{msg.timestamp.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })}
</div>
</div>
</div>
))}
<div ref={messagesEndRef} />
</div>
{/* 图片预览区 */}
{imagePreviews.length > 0 && (
<div className="px-4 py-2 bg-gray-100 dark:bg-gray-800 border-t border-gray-200 dark:border-gray-700">
<div className="flex gap-2 overflow-x-auto">
{imagePreviews.map((preview, idx) => (
<div key={idx} className="relative flex-shrink-0">
<img src={preview} alt={`Preview ${idx}`} className="w-16 h-16 object-cover rounded" />
<button
onClick={() => removeImage(idx)}
className="absolute -top-2 -right-2 bg-red-500 text-white rounded-full w-5 h-5 flex items-center justify-center text-xs hover:bg-red-600"
>
×
</button>
</div>
))}
</div>
</div>
)}
{/* 输入区 */}
<div className="p-4 bg-white dark:bg-gray-800 border-t border-gray-200 dark:border-gray-700">
<div className="flex items-end gap-2">
<input
type="file"
ref={fileInputRef}
onChange={handleImageSelect}
accept="image/*"
multiple
className="hidden"
/>
<button
onClick={() => fileInputRef.current?.click()}
className="p-2 text-gray-600 dark:text-gray-400 hover:text-purple-600 dark:hover:text-purple-400 transition-colors"
title="上传图片"
>
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
</button>
<textarea
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
handleSend()
}
}}
placeholder="输入消息... (Enter发送,Shift+Enter换行)"
className="flex-1 px-3 py-2 border border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent outline-none text-sm resize-none"
rows={2}
disabled={sending}
/>
<button
onClick={handleSend}
disabled={(!input.trim() && images.length === 0) || sending}
className="px-4 py-2 bg-gradient-to-r from-purple-500 to-blue-500 text-white rounded-lg hover:from-purple-600 hover:to-blue-600 transition-all disabled:opacity-50 disabled:cursor-not-allowed font-medium text-sm h-[72px]"
>
{sending ? '发送中...' : '发送'}
</button>
</div>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-2">
💡 支持文字+图片聊天 · 可上传/拖拽/粘贴图片 · 最多5张
</p>
</div>
</div>
)}
{/* 悬浮按钮 */}
<button
onClick={() => setIsOpen(!isOpen)}
className={`w-14 h-14 rounded-full bg-gradient-to-r from-purple-500 to-blue-500 text-white shadow-lg hover:shadow-xl transition-all duration-300 flex items-center justify-center ${
isOpen ? 'rotate-90' : ''
}`}
>
<svg className="w-7 h-7" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z" />
</svg>
</button>
</div>
)
}