flnxi-Wu/components/AIChatWidget.tsx
WuYongJie d1c6efc79d
Some checks failed
CI Check / check (push) Has been cancelled
feat: NAS 部署与 Harbor 推送流程
2025-12-03 14:39:06 +08:00

738 lines
31 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.

/**
* 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 { useThemeStyles, cn } from '@/lib/ui-styles'
// 添加动画样式
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 (
<div className="relative w-full aspect-square rounded-2xl overflow-hidden bg-gradient-to-br from-slate-100 to-slate-200 dark:from-slate-700 dark:to-slate-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-slate-300 dark:border-slate-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-slate-400 dark:text-slate-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-slate-500 dark:text-slate-400">正在生成图片...</p>
<p className="mt-1 text-xs text-slate-400 dark:text-slate-500">AI正在为您创作中</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 styles = useThemeStyles()
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 && (
<>
<style dangerouslySetInnerHTML={{ __html: shimmerStyle }} />
<div
className="fixed w-[420px] h-[680px] bg-white dark:bg-slate-900 rounded-2xl 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',
boxShadow: '0 20px 60px -15px rgba(0, 0, 0, 0.3), 0 10px 20px -10px rgba(0, 0, 0, 0.2)'
}}
>
{/* 头部 - 渐变风格 - 可拖拽 */}
<div
ref={headerRef}
onMouseDown={handleMouseDown}
className="px-6 py-4 bg-gradient-to-r from-blue-500 to-purple-600 flex items-center justify-between cursor-move select-none"
>
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-full bg-white/20 backdrop-blur-sm flex items-center justify-center ring-2 ring-white/30">
<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-white">AI助手</h3>
<p className="text-xs text-white/80">智能对话 · 图片生成</p>
</div>
</div>
<button
onClick={() => setIsOpen(false)}
className="text-white/80 hover:text-white hover:bg-white/20 backdrop-blur-sm rounded-full p-2 transition-all"
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-gradient-to-b from-slate-50 to-white dark:from-slate-900 dark:to-slate-950 relative"
style={{
scrollbarWidth: 'thin',
scrollbarColor: '#cbd5e1 transparent',
backgroundImage: 'radial-gradient(circle at 1px 1px, rgb(209 213 219 / 0.3) 1px, transparent 0)',
backgroundSize: '20px 20px'
}}
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 mt-12">
<div className="w-20 h-20 mx-auto mb-4 rounded-full bg-gradient-to-br from-blue-400 to-purple-500 flex items-center justify-center shadow-lg">
<svg className="w-10 h-10 text-white" 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-lg font-semibold text-slate-800 dark:text-slate-200 mb-2">👋 你好!我是AI助手</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-4 py-2 bg-blue-50 dark:bg-blue-900/20 text-blue-600 dark:text-blue-400 rounded-full 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'} animate-in fade-in slide-in-from-bottom-2 duration-300`}
>
<div className={`max-w-[85%] ${
msg.role === 'user'
? 'bg-gradient-to-r from-blue-500 to-blue-600 text-white shadow-lg'
: msg.role === 'assistant'
? 'bg-white dark:bg-slate-800 text-slate-900 dark:text-white border border-slate-200 dark:border-slate-700 shadow-md'
: 'bg-yellow-50 dark:bg-yellow-900/20 text-yellow-800 dark:text-yellow-200 border border-yellow-200 dark:border-yellow-800'
} rounded-2xl 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-2xl overflow-hidden animate-pulse">
<Image
src={img}
alt={`Processing ${idx + 1}`}
width={200}
height={200}
unoptimized
className="w-full max-w-[200px] h-auto object-contain rounded-2xl transition-all duration-1000"
style={{
filter: 'blur(15px) brightness(0.7)',
animation: 'breathe 2s ease-in-out infinite'
}}
/>
<div className="absolute inset-0 flex items-center justify-center bg-gradient-to-br from-blue-500/10 to-purple-500/10">
<div className="bg-black/60 backdrop-blur-xl px-5 py-3 rounded-full shadow-2xl border border-white/10">
<div className="flex items-center gap-3 text-white text-sm">
<div className="relative">
<div className="w-5 h-5 border-3 border-white/20 border-t-white rounded-full animate-spin" />
<div className="absolute inset-0 w-5 h-5 border-3 border-transparent border-r-white/40 rounded-full animate-spin" style={{ animationDirection: 'reverse', animationDuration: '1.5s' }} />
</div>
<span className="font-medium">AI处理中...</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 animate-in fade-in duration-500 ${
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-slate-100 dark:bg-slate-700 animate-in fade-in slide-in-from-bottom-2"
style={{ animationDelay: `${idx * 100}ms`, animationDuration: '400ms' }}
>
<Image
src={img}
alt={`Image ${idx + 1}`}
width={400}
height={400}
unoptimized
className={`w-full rounded-2xl cursor-pointer hover:opacity-90 transition-all hover:scale-[1.02] ${
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-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-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-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-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-blue-600 dark:hover:text-blue-400 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-blue-500 focus:border-blue-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-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-slate-500 dark:text-slate-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>
)
}