685 lines
29 KiB
TypeScript
685 lines
29 KiB
TypeScript
/**
|
||
* 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 (
|
||
<div className="relative w-full aspect-square rounded-lg overflow-hidden border border-dashed border-slate-300 bg-slate-50 dark:border-slate-700 dark:bg-slate-800">
|
||
<div className="absolute inset-0 flex items-center justify-center">
|
||
<div className="text-center">
|
||
<div className="mx-auto h-6 w-6 rounded-full border-2 border-slate-300 border-t-slate-700 animate-spin dark:border-slate-600 dark:border-t-slate-200" />
|
||
<p className="mt-3 text-xs font-medium text-slate-500 dark:text-slate-400">图片处理中</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
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<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 [windowDragging, setWindowDragging] = useState(false)
|
||
const [position, setPosition] = useState({ x: 0, y: 0 })
|
||
const [dragStart, setDragStart] = useState({ x: 0, y: 0 })
|
||
const messagesEndRef = useRef<HTMLDivElement>(null)
|
||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||
const containerRef = useRef<HTMLDivElement>(null)
|
||
const dropZoneRef = useRef<HTMLDivElement>(null)
|
||
const headerRef = useRef<HTMLDivElement>(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<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,
|
||
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 (
|
||
<div ref={containerRef} className="fixed bottom-6 right-6 z-50">
|
||
{/* AI聊天窗口 */}
|
||
{isOpen && (
|
||
<>
|
||
<div
|
||
className="fixed w-[420px] h-[680px] bg-white dark:bg-slate-900 rounded-lg border border-slate-200 dark:border-slate-700 shadow-sm overflow-hidden flex flex-col"
|
||
style={{
|
||
left: position.x ? `${position.x}px` : 'auto',
|
||
top: position.y ? `${position.y}px` : 'auto',
|
||
right: position.x ? 'auto' : '24px',
|
||
bottom: position.y ? 'auto' : '96px',
|
||
maxHeight: '80vh'
|
||
}}
|
||
>
|
||
{/* 头部 - 可拖拽 */}
|
||
<div
|
||
ref={headerRef}
|
||
onMouseDown={handleMouseDown}
|
||
className="px-5 py-3 border-b border-slate-200 bg-white dark:border-slate-700 dark:bg-slate-900 flex items-center justify-between cursor-move select-none"
|
||
>
|
||
<div className="flex items-center gap-3">
|
||
<div className="w-9 h-9 rounded-md bg-slate-100 dark:bg-slate-800 flex items-center justify-center text-slate-600 dark:text-slate-300">
|
||
<svg className="w-5 h-5" 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-slate-900 dark:text-slate-100">助手</h3>
|
||
<p className="text-xs text-slate-500 dark:text-slate-400">文字和图片处理</p>
|
||
</div>
|
||
</div>
|
||
<button
|
||
onClick={() => setIsOpen(false)}
|
||
className="text-slate-500 hover:text-slate-700 hover:bg-slate-100 dark:text-slate-400 dark:hover:text-slate-200 dark:hover:bg-slate-800 rounded-md 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-4 bg-slate-50 dark:bg-slate-950 relative"
|
||
style={{
|
||
scrollbarWidth: 'thin',
|
||
scrollbarColor: '#cbd5e1 transparent'
|
||
}}
|
||
onDragOver={handleDragOver}
|
||
onDragLeave={handleDragLeave}
|
||
onDrop={handleDrop}
|
||
>
|
||
{/* 拖拽提示 */}
|
||
{isDragging && (
|
||
<div className="absolute inset-0 bg-slate-100/90 dark:bg-slate-900/90 z-10 flex items-center justify-center border border-dashed border-slate-400 dark:border-slate-600 m-2 rounded-lg">
|
||
<div className="text-center">
|
||
<svg className="w-10 h-10 mx-auto text-slate-500 dark:text-slate-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-base font-semibold text-slate-700 dark:text-slate-200">松开鼠标上传图片</p>
|
||
<p className="text-sm text-slate-500 dark:text-slate-400 mt-1">最多 5 张</p>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{messages.length === 0 && !isDragging && (
|
||
<div className="text-center mt-12">
|
||
<div className="w-14 h-14 mx-auto mb-4 rounded-lg bg-slate-100 dark:bg-slate-800 flex items-center justify-center text-slate-500 dark:text-slate-400">
|
||
<svg className="w-7 h-7" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 10h.01M12 10h.01M16 10h.01M9 16H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-5l-5 5v-5z" />
|
||
</svg>
|
||
</div>
|
||
<h3 className="text-base font-semibold text-slate-800 dark:text-slate-200 mb-2">助手</h3>
|
||
<p className="text-sm text-slate-600 dark:text-slate-400 mb-1">可以发送文字或图片</p>
|
||
<p className="text-sm text-slate-600 dark:text-slate-400 mb-3">支持图片处理和内容生成</p>
|
||
<div className="inline-flex items-center gap-2 px-3 py-1.5 bg-slate-100 dark:bg-slate-800 text-slate-600 dark:text-slate-300 rounded-md text-sm">
|
||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
|
||
</svg>
|
||
拖拽图片到此处快速上传
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{messages.map((msg) => (
|
||
<div
|
||
key={msg.id}
|
||
className={`flex ${msg.role === 'user' ? 'justify-end' : 'justify-start'}`}
|
||
>
|
||
<div className={`max-w-[85%] ${
|
||
msg.role === 'user'
|
||
? 'bg-slate-900 text-white dark:bg-slate-100 dark:text-slate-900'
|
||
: msg.role === 'assistant'
|
||
? 'bg-white dark:bg-slate-800 text-slate-900 dark:text-white border border-slate-200 dark:border-slate-700'
|
||
: '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>}
|
||
|
||
{/* 显示图片加载占位符 */}
|
||
{msg.isGenerating && (
|
||
<div className="mt-2">
|
||
{/* 如果有原图,显示原图的模糊占位符 */}
|
||
{msg.sourceImages && msg.sourceImages.length > 0 ? (
|
||
<div className={`grid gap-2 ${
|
||
msg.sourceImages.length === 1 ? 'grid-cols-1' :
|
||
msg.sourceImages.length === 2 ? 'grid-cols-2' :
|
||
'grid-cols-3'
|
||
}`}>
|
||
{msg.sourceImages.map((img, idx) => (
|
||
<div key={idx} className="relative rounded-lg overflow-hidden">
|
||
<Image
|
||
src={img}
|
||
alt={`Processing ${idx + 1}`}
|
||
width={200}
|
||
height={200}
|
||
unoptimized
|
||
className="w-full max-w-[200px] h-auto object-contain rounded-lg opacity-70"
|
||
/>
|
||
<div className="absolute inset-0 flex items-center justify-center bg-slate-900/50">
|
||
<div className="bg-slate-900 px-3 py-2 rounded-md">
|
||
<div className="flex items-center gap-3 text-white text-sm">
|
||
<div className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" />
|
||
<span className="font-medium">处理中...</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
) : (
|
||
/* 没有原图,显示4个空白占位符(生成任务) */
|
||
msg.imageCount && msg.imageCount > 0 && (
|
||
<div className="grid grid-cols-4 gap-2">
|
||
{Array.from({ length: msg.imageCount }).map((_, idx) => (
|
||
<div key={idx}>
|
||
<ImageLoadingPlaceholder />
|
||
</div>
|
||
))}
|
||
</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-lg overflow-hidden bg-slate-100 dark:bg-slate-700"
|
||
>
|
||
<Image
|
||
src={img}
|
||
alt={`Image ${idx + 1}`}
|
||
width={400}
|
||
height={400}
|
||
unoptimized
|
||
className={`w-full rounded-lg cursor-pointer hover:opacity-90 transition-opacity ${
|
||
msg.role === 'user'
|
||
? 'max-w-[200px] h-auto object-contain'
|
||
: 'aspect-square object-cover'
|
||
}`}
|
||
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-slate-900/80 hover:bg-slate-900 text-white p-2 rounded-md opacity-0 group-hover:opacity-100 transition-opacity"
|
||
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-slate-800 border-t border-slate-200 dark:border-slate-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">
|
||
<Image
|
||
src={preview}
|
||
alt={`Preview ${idx}`}
|
||
width={64}
|
||
height={64}
|
||
unoptimized
|
||
className="w-16 h-16 object-cover rounded-lg border border-slate-200 dark:border-slate-700"
|
||
/>
|
||
<button
|
||
onClick={() => removeImage(idx)}
|
||
className="absolute -top-1 -right-1 bg-red-500 text-white rounded-md 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-slate-800 border-t border-slate-200 dark:border-slate-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-slate-600 dark:text-slate-400 hover:text-slate-900 dark:hover:text-slate-200 hover:bg-slate-100 dark:hover:bg-slate-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-slate-300 dark:border-slate-600 bg-white dark:bg-slate-700 dark:text-white rounded-lg focus:ring-2 focus:ring-slate-200 dark:focus:ring-slate-700 focus:border-slate-900 dark:focus:border-slate-500 outline-none text-sm resize-none placeholder:text-slate-400 dark:placeholder:text-slate-500"
|
||
rows={2}
|
||
disabled={sending}
|
||
/>
|
||
{/* 发送按钮 */}
|
||
<button
|
||
onClick={handleSend}
|
||
disabled={(!input.trim() && images.length === 0) || sending}
|
||
className="px-4 py-2 bg-slate-900 dark:bg-slate-100 text-white dark:text-slate-900 rounded-lg hover:bg-slate-800 dark:hover:bg-white disabled:opacity-50 disabled:cursor-not-allowed font-medium text-sm h-[72px] flex items-center justify-center min-w-[70px] transition-colors"
|
||
>
|
||
{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-slate-500 dark:text-slate-400 mt-2 text-center">
|
||
支持文字+图片聊天 · 可上传/拖拽/粘贴图片 · 最多5张
|
||
</p>
|
||
</div>
|
||
</div>
|
||
</>
|
||
)}
|
||
|
||
{/* 悬浮按钮 */}
|
||
<button
|
||
onClick={() => setIsOpen(!isOpen)}
|
||
className="w-12 h-12 rounded-lg border border-slate-300 bg-white text-slate-700 shadow-sm hover:bg-slate-50 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-200 dark:hover:bg-slate-800 transition-colors 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>
|
||
)
|
||
}
|
||
|