gongxiangp2/components/AIChatWidget.tsx
chenpei 2bd55963fd fix: 优化AI聊天界面风格和图片布局
🎨 UI优化:
- 调整聊天窗口风格,更简洁,与首页风格匹配
- 移除过度华丽的渐变和毛玻璃效果
- 使用更简洁的白色/灰色配色方案
- 统一圆角大小(rounded-lg)

📐 图片布局修复:
- 4张图片加载占位符改为横向一排(grid-cols-4)
- 4张实际图片也改为横向一排显示
- 优化图片卡片样式

🔧 组件优化:
- 简化头部设计,使用蓝色图标
- 简化按钮样式,移除过度动画
- 优化输入框和发送按钮
- 调整悬浮按钮为简洁风格
2025-10-27 18:16:33 +08:00

589 lines
24 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'
// 添加shimmer动画样式
const shimmerStyle = `
@keyframes shimmer {
0% { background-position: -200% 0; }
100% { background-position: 200% 0; }
}
`
// 图片加载占位符组件
const ImageLoadingPlaceholder = () => {
return (
<div className="relative w-full aspect-square rounded-2xl overflow-hidden bg-gradient-to-br from-gray-100 to-gray-200 dark:from-gray-700 dark:to-gray-800">
{/* 动态模糊背景 */}
<div className="absolute inset-0 animate-pulse">
<div className="absolute inset-0 bg-gradient-to-r from-transparent via-white/40 dark:via-white/10 to-transparent animate-shimmer"
style={{
backgroundSize: '200% 100%',
animation: 'shimmer 2s infinite'
}}
/>
</div>
{/* 中心图标 */}
<div className="absolute inset-0 flex items-center justify-center">
<div className="text-center">
<div className="relative">
{/* 旋转的光圈 */}
<div className="w-16 h-16 border-4 border-gray-300 dark:border-gray-600 border-t-blue-500 rounded-full animate-spin" />
{/* 中心图标 */}
<div className="absolute inset-0 flex items-center justify-center">
<svg className="w-8 h-8 text-gray-400 dark:text-gray-500" 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>
</div>
</div>
<p className="mt-4 text-sm font-medium text-gray-500 dark:text-gray-400">正在生成图片...</p>
<p className="mt-1 text-xs text-gray-400 dark:text-gray-500">AI正在为您创作中</p>
</div>
</div>
</div>
)
}
interface Message {
id: string
role: 'user' | 'assistant' | 'system'
content: string
images?: string[]
timestamp: Date
isGenerating?: boolean // 是否正在生成图片
imageCount?: number // 预期生成的图片数量
}
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 {
// 检测是否需要生成图片
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
}
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 (
<div ref={containerRef} className="fixed bottom-6 right-6 z-50">
{/* AI聊天窗口 - 苹果风格 */}
{isOpen && (
<>
<style dangerouslySetInnerHTML={{ __html: shimmerStyle }} />
<div className="absolute bottom-16 right-0 w-[546px] h-[884px] bg-white dark:bg-gray-900 rounded-lg shadow-xl border border-gray-200 dark:border-gray-700 overflow-hidden flex flex-col">
{/* 头部 - 简洁风格 */}
<div className="px-6 py-4 bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700 flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-lg bg-blue-500 flex items-center justify-center">
<svg className="w-6 h-6 text-white" 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>
</div>
<div>
<h3 className="font-semibold text-gray-900 dark:text-white">AI助手</h3>
<p className="text-xs text-gray-500 dark:text-gray-400">智能对话 · 图片生成</p>
</div>
</div>
<button
onClick={() => setIsOpen(false)}
className="text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg p-2 transition-colors"
aria-label="关闭"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
{/* 消息列表 */}
<div
ref={dropZoneRef}
className="flex-1 overflow-y-auto p-4 space-y-3 bg-gray-50 dark:bg-gray-900 relative"
style={{ scrollbarWidth: 'thin', scrollbarColor: '#cbd5e1 transparent' }}
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'} animate-in fade-in slide-in-from-bottom-2 duration-300`}
>
<div className={`max-w-[85%] ${
msg.role === 'user'
? 'bg-blue-500 text-white shadow-sm'
: msg.role === 'assistant'
? 'bg-white dark:bg-gray-800 text-gray-900 dark:text-white border border-gray-200 dark:border-gray-700 shadow-sm'
: 'bg-yellow-50 dark:bg-yellow-900/20 text-yellow-800 dark:text-yellow-200 border border-yellow-200 dark:border-yellow-800'
} rounded-lg px-4 py-3`}>
{msg.content && <div className="whitespace-pre-wrap text-sm">{msg.content}</div>}
{/* 显示图片加载占位符 - 横向一排4个 */}
{msg.isGenerating && msg.imageCount && msg.imageCount > 0 && (
<div className="mt-2 grid grid-cols-4 gap-2">
{Array.from({ length: msg.imageCount }).map((_, idx) => (
<div key={idx}>
<ImageLoadingPlaceholder />
</div>
))}
</div>
)}
{/* 显示实际图片 - 横向一排显示 */}
{!msg.isGenerating && msg.images && msg.images.length > 0 && (
<div className={`mt-2 grid gap-2 ${
msg.images.length === 1 ? 'grid-cols-1' :
msg.images.length === 2 ? 'grid-cols-2' :
msg.images.length === 3 ? 'grid-cols-3' :
'grid-cols-4'
}`}>
{msg.images.map((img, idx) => (
<div key={idx} className="relative group rounded-2xl overflow-hidden bg-gray-100 dark:bg-gray-700">
<img
src={img}
alt={`Image ${idx + 1}`}
className="w-full aspect-square object-cover rounded-2xl cursor-pointer hover:opacity-90 transition-all hover:scale-[1.02]"
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()}-${idx + 1}.png`
link.click()
}}
className="absolute top-2 right-2 bg-black/60 hover:bg-black/80 backdrop-blur-sm text-white p-2 rounded-xl opacity-0 group-hover:opacity-100 transition-all shadow-lg"
title="下载图片"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" 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-3 bg-white dark:bg-gray-800 border-t border-gray-200 dark:border-gray-700">
<div className="flex gap-2 overflow-x-auto pb-1" style={{ scrollbarWidth: 'thin' }}>
{imagePreviews.map((preview, idx) => (
<div key={idx} className="relative flex-shrink-0 group">
<img
src={preview}
alt={`Preview ${idx}`}
className="w-16 h-16 object-cover rounded-lg border border-gray-200 dark:border-gray-700"
/>
<button
onClick={() => removeImage(idx)}
className="absolute -top-1 -right-1 bg-red-500 text-white rounded-full w-5 h-5 flex items-center justify-center text-xs hover:bg-red-600"
aria-label="删除图片"
>
×
</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-3">
<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-blue-600 dark:hover:text-blue-400 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors"
title="上传图片"
aria-label="上传图片"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" 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="输入消息..."
className="flex-1 px-3 py-2 border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 dark:text-white rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none text-sm resize-none placeholder:text-gray-400 dark:placeholder:text-gray-500"
rows={2}
disabled={sending}
/>
{/* 发送按钮 */}
<button
onClick={handleSend}
disabled={(!input.trim() && images.length === 0) || sending}
className="px-4 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600 disabled:opacity-50 disabled:cursor-not-allowed font-medium text-sm h-[72px] flex items-center justify-center min-w-[70px]"
>
{sending ? (
<div className="flex items-center gap-2">
<div className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" />
<span>发送中</span>
</div>
) : (
<div className="flex items-center gap-2">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={2.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 19l9 2-9-18-9 18 9-2zm0 0v-8" />
</svg>
<span>发送</span>
</div>
)}
</button>
</div>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-2 text-center">
支持文字+图片聊天 · 可上传/拖拽/粘贴图片 · 最多5张
</p>
</div>
</div>
</>
)}
{/* 悬浮按钮 */}
<button
onClick={() => setIsOpen(!isOpen)}
className="w-14 h-14 rounded-full bg-blue-500 text-white shadow-lg hover:bg-blue-600 hover:shadow-xl transition-all flex items-center justify-center"
aria-label="AI助手"
>
{isOpen ? (
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
) : (
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" 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>
)
}