Files
st-react/web-app/src/components/ChatArea.tsx
2026-03-03 03:40:03 +08:00

780 lines
32 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.

import {
Check,
ChevronDown,
Copy,
Download,
Mic,
MoreVertical,
Paperclip,
RefreshCw,
Send,
Settings,
Trash2,
Waves,
Zap
} from 'lucide-react'
import {useEffect, useRef, useState} from 'react'
import {type Conversation, conversationApi, type Message} from '../api/conversation'
import {type Character} from '../api/character'
import {type AIConfig, aiConfigApi} from '../api/aiConfig'
import {type Preset, presetApi} from '../api/preset'
import MessageContent from './MessageContent'
interface ChatAreaProps {
conversation: Conversation
character: Character
onConversationUpdate: (conversation: Conversation) => void
}
export default function ChatArea({ conversation, character, onConversationUpdate }: ChatAreaProps) {
const [messages, setMessages] = useState<Message[]>([])
const [inputValue, setInputValue] = useState('')
const [sending, setSending] = useState(false)
const [loading, setLoading] = useState(true)
const [showMenu, setShowMenu] = useState(false)
const [copiedId, setCopiedId] = useState<number | null>(null)
const [aiConfigs, setAiConfigs] = useState<AIConfig[]>([])
const [selectedConfigId, setSelectedConfigId] = useState<number>()
const [showModelSelector, setShowModelSelector] = useState(false)
const [streamEnabled, setStreamEnabled] = useState(true)
const [presets, setPresets] = useState<Preset[]>([])
const [selectedPresetId, setSelectedPresetId] = useState<number>()
const [showPresetSelector, setShowPresetSelector] = useState(false)
const messagesEndRef = useRef<HTMLDivElement>(null)
const textareaRef = useRef<HTMLTextAreaElement>(null)
const modelSelectorRef = useRef<HTMLDivElement>(null)
const presetSelectorRef = useRef<HTMLDivElement>(null)
const menuRef = useRef<HTMLDivElement>(null)
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
const target = event.target as HTMLElement
if (showModelSelector && modelSelectorRef.current && !modelSelectorRef.current.contains(target)) {
setShowModelSelector(false)
}
if (showPresetSelector && presetSelectorRef.current && !presetSelectorRef.current.contains(target)) {
setShowPresetSelector(false)
}
if (showMenu && menuRef.current && !menuRef.current.contains(target)) {
setShowMenu(false)
}
}
document.addEventListener('mousedown', handleClickOutside)
return () => document.removeEventListener('mousedown', handleClickOutside)
}, [showModelSelector, showPresetSelector, showMenu])
useEffect(() => {
loadMessages()
loadAIConfigs()
loadCurrentConfig()
loadPresets()
loadCurrentPreset()
}, [conversation.id])
useEffect(() => {
scrollToBottom()
}, [messages])
// 监听状态栏按钮点击事件
useEffect(() => {
const handleStatusBarAction = (event: CustomEvent) => {
const action = event.detail
if (action && typeof action === 'string' && !sending) {
console.log('[ChatArea] 收到状态栏操作,自动发送消息:', action)
setInputValue(action)
// 延迟发送,确保 inputValue 已更新
setTimeout(() => {
handleSendMessage(action)
}, 100)
}
}
window.addEventListener('sendMessageFromStatusBar', handleStatusBarAction as EventListener)
return () => window.removeEventListener('sendMessageFromStatusBar', handleStatusBarAction as EventListener)
}, [sending])
const loadMessages = async () => {
try {
setLoading(true)
const response = await conversationApi.getMessageList(conversation.id, { page: 1, pageSize: 100 })
setMessages(response.data.list || [])
} catch (err) {
console.error('加载消息失败:', err)
} finally {
setLoading(false)
}
}
const loadAIConfigs = async () => {
try {
const response = await aiConfigApi.getAIConfigList()
setAiConfigs(response.data.list.filter(config => config.isActive))
} catch (err) {
console.error('加载 AI 配置失败:', err)
}
}
const loadCurrentConfig = () => {
if (conversation.settings) {
try {
const settings = typeof conversation.settings === 'string'
? JSON.parse(conversation.settings)
: conversation.settings
if (settings.aiConfigId) setSelectedConfigId(settings.aiConfigId)
} catch (e) {
console.error('解析设置失败:', e)
}
}
}
const loadPresets = async () => {
try {
const response = await presetApi.getPresetList({ page: 1, pageSize: 100 })
setPresets(response.data.list)
} catch (err) {
console.error('加载预设失败:', err)
}
}
const loadCurrentPreset = () => {
if (conversation.presetId) {
setSelectedPresetId(conversation.presetId)
return
}
if (conversation.settings) {
try {
const settings = typeof conversation.settings === 'string'
? JSON.parse(conversation.settings)
: conversation.settings
if (settings.presetId) {
setSelectedPresetId(settings.presetId)
return
}
} catch (e) {
console.error('解析设置失败:', e)
}
}
setSelectedPresetId(undefined)
}
const handlePresetChange = async (presetId: number | null) => {
try {
const settings = conversation.settings
? (typeof conversation.settings === 'string' ? JSON.parse(conversation.settings) : { ...conversation.settings })
: {}
if (presetId === null) {
delete settings.presetId
} else {
settings.presetId = presetId
}
await conversationApi.updateConversationSettings(conversation.id, { settings })
setSelectedPresetId(presetId ?? undefined)
setShowPresetSelector(false)
const convResp = await conversationApi.getConversationById(conversation.id)
onConversationUpdate(convResp.data)
} catch (err) {
console.error('更新预设失败:', err)
alert('更新失败,请重试')
}
}
const handleModelChange = async (configId: number | null) => {
try {
const settings = conversation.settings
? (typeof conversation.settings === 'string' ? JSON.parse(conversation.settings) : { ...conversation.settings })
: {}
if (configId === null) {
delete settings.aiConfigId
} else {
settings.aiConfigId = configId
}
await conversationApi.updateConversationSettings(conversation.id, { settings })
setSelectedConfigId(configId ?? undefined)
setShowModelSelector(false)
const convResp = await conversationApi.getConversationById(conversation.id)
onConversationUpdate(convResp.data)
} catch (err) {
console.error('更新模型配置失败:', err)
alert('更新失败,请重试')
}
}
const scrollToBottom = () => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })
}
const handleSendMessage = async (message: string) => {
if (!message.trim() || sending) return
const userMessage = message.trim()
setSending(true)
const tempUserMessage: Message = {
id: Date.now(),
conversationId: conversation.id,
role: 'user',
content: userMessage,
tokenCount: 0,
createdAt: new Date().toISOString(),
}
setMessages(prev => [...prev, tempUserMessage])
const tempAIMessage: Message = {
id: Date.now() + 1,
conversationId: conversation.id,
role: 'assistant',
content: '',
tokenCount: 0,
createdAt: new Date().toISOString(),
}
try {
if (streamEnabled) {
setMessages(prev => [...prev, tempAIMessage])
const response = await fetch(
`${import.meta.env.VITE_API_BASE_URL || '/api'}/app/conversation/${conversation.id}/message?stream=true`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${localStorage.getItem('token')}`,
},
body: JSON.stringify({ content: userMessage }),
}
)
if (!response.ok) throw new Error('流式传输失败')
const reader = response.body?.getReader()
const decoder = new TextDecoder()
if (reader) {
let fullContent = ''
let buffer = ''
while (true) {
const { done, value } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })
const lines = buffer.split('\n')
buffer = lines.pop() || ''
let currentEvent = ''
for (const line of lines) {
if (line.startsWith('event: ')) {
currentEvent = line.slice(7).trim()
} else if (line.startsWith('data: ')) {
const data = line.slice(6).trim()
if (currentEvent === 'message') {
fullContent += data
setMessages(prev =>
prev.map(m => m.id === tempAIMessage.id ? { ...m, content: fullContent } : m)
)
} else if (currentEvent === 'done') {
await loadMessages()
break
} else if (currentEvent === 'error') {
throw new Error(data)
}
currentEvent = ''
}
}
}
}
const convResp = await conversationApi.getConversationById(conversation.id)
onConversationUpdate(convResp.data)
} else {
await conversationApi.sendMessage(conversation.id, { content: userMessage })
await loadMessages()
const convResp = await conversationApi.getConversationById(conversation.id)
onConversationUpdate(convResp.data)
}
} catch (err: any) {
console.error('发送消息失败:', err)
alert(err.response?.data?.msg || '发送消息失败,请重试')
setMessages(prev => prev.filter(m => m.id !== tempUserMessage.id && m.id !== tempAIMessage.id))
} finally {
setSending(false)
}
}
const handleSend = async () => {
if (!inputValue.trim() || sending) return
const userMessage = inputValue.trim()
setInputValue('')
await handleSendMessage(userMessage)
}
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === 'Enter' && !e.shiftKey && !sending && inputValue.trim()) {
e.preventDefault()
handleSend()
}
}
const handleCopyMessage = (content: string, id: number) => {
navigator.clipboard.writeText(content)
setCopiedId(id)
setTimeout(() => setCopiedId(null), 2000)
}
const handleRegenerateResponse = async () => {
if (messages.length === 0 || sending) return
const hasAssistantMsg = messages.some(m => m.role === 'assistant')
if (!hasAssistantMsg) return
setSending(true)
const lastAssistantIndex = [...messages].map(m => m.role).lastIndexOf('assistant')
if (lastAssistantIndex !== -1) {
setMessages(prev => prev.filter((_, i) => i !== lastAssistantIndex))
}
const tempAIMessage: Message = {
id: Date.now(),
conversationId: conversation.id,
role: 'assistant',
content: '',
tokenCount: 0,
createdAt: new Date().toISOString(),
}
try {
if (streamEnabled) {
setMessages(prev => [...prev, tempAIMessage])
const response = await fetch(
`${import.meta.env.VITE_API_BASE_URL || '/api'}/app/conversation/${conversation.id}/regenerate?stream=true`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${localStorage.getItem('token')}`,
},
}
)
if (!response.ok) throw new Error('重新生成失败')
const reader = response.body?.getReader()
const decoder = new TextDecoder()
if (reader) {
let fullContent = ''
let buffer = ''
while (true) {
const { done, value } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })
const lines = buffer.split('\n')
buffer = lines.pop() || ''
let currentEvent = ''
for (const line of lines) {
if (line.startsWith('event: ')) {
currentEvent = line.slice(7).trim()
} else if (line.startsWith('data: ')) {
const data = line.slice(6).trim()
if (currentEvent === 'message') {
fullContent += data
setMessages(prev =>
prev.map(m => m.id === tempAIMessage.id ? { ...m, content: fullContent } : m)
)
} else if (currentEvent === 'done') {
await loadMessages()
break
} else if (currentEvent === 'error') {
throw new Error(data)
}
currentEvent = ''
}
}
}
}
} else {
await conversationApi.regenerateMessage(conversation.id)
await loadMessages()
}
const convResp = await conversationApi.getConversationById(conversation.id)
onConversationUpdate(convResp.data)
} catch (err: any) {
console.error('重新生成失败:', err)
alert(err.message || '重新生成失败,请重试')
await loadMessages()
} finally {
setSending(false)
}
}
const handleDeleteConversation = async () => {
if (!confirm('确定要删除这个对话吗?')) return
try {
await conversationApi.deleteConversation(conversation.id)
window.location.href = '/chat'
} catch (err) {
console.error('删除对话失败:', err)
alert('删除失败')
}
}
const handleExportConversation = () => {
const content = messages.map(msg => `[${msg.role}] ${msg.content}`).join('\n\n')
const blob = new Blob([content], { type: 'text/plain' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `${conversation.title}.txt`
a.click()
URL.revokeObjectURL(url)
}
const formatTime = (dateString: string) => {
const date = new Date(dateString)
const now = new Date()
const diff = now.getTime() - date.getTime()
const minutes = Math.floor(diff / 60000)
const hours = Math.floor(diff / 3600000)
const days = Math.floor(diff / 86400000)
if (minutes < 1) return '刚刚'
if (minutes < 60) return `${minutes}分钟前`
if (hours < 24) return `${hours}小时前`
if (days < 7) return `${days}天前`
return date.toLocaleDateString('zh-CN')
}
const selectedConfig = aiConfigs.find(c => c.id === selectedConfigId)
const selectedPreset = presets.find(p => p.id === selectedPresetId)
const lastAssistantMsgId = [...messages].reverse().find(m => m.role === 'assistant')?.id
return (
<div className="flex-1 flex flex-col min-w-0">
{/* 顶部工具栏 */}
<div className="px-4 py-3 glass border-b border-white/10">
<div className="flex items-center justify-between gap-3">
{/* 左侧:标题 */}
<div className="min-w-0 flex-1">
<h2 className="text-base font-semibold truncate">{conversation.title}</h2>
<p className="text-xs text-white/50 truncate"> {character.name} </p>
</div>
{/* 右侧:工具按钮组 */}
<div className="flex items-center gap-1 flex-shrink-0">
{/* 模型选择器 */}
<div className="relative" ref={modelSelectorRef}>
<button
onClick={() => {
setShowModelSelector(v => !v)
setShowPresetSelector(false)
setShowMenu(false)
}}
className={`flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg text-xs font-medium transition-all cursor-pointer
${showModelSelector ? 'bg-white/15 text-white' : 'glass-hover text-white/70 hover:text-white'}`}
title="切换模型"
>
<Zap className="w-3.5 h-3.5 text-yellow-400 flex-shrink-0" />
<span className="max-w-[80px] truncate">
{selectedConfig ? selectedConfig.name : '默认模型'}
</span>
<ChevronDown className={`w-3 h-3 flex-shrink-0 transition-transform ${showModelSelector ? 'rotate-180' : ''}`} />
</button>
{showModelSelector && (
<div className="absolute right-0 top-full mt-1 z-50 w-56 glass rounded-xl shadow-2xl border border-white/15 overflow-hidden">
<div className="p-1">
<button
onClick={() => handleModelChange(null)}
className={`w-full flex items-center gap-2 px-3 py-2 rounded-lg text-sm text-left cursor-pointer transition-all
${!selectedConfigId ? 'bg-primary/20 text-white' : 'text-white/60 hover:bg-white/10 hover:text-white'}`}
>
<span className="flex-1"></span>
{!selectedConfigId && <Check className="w-3.5 h-3.5 text-primary" />}
</button>
{aiConfigs.length > 0 && <div className="my-1 border-t border-white/10" />}
{aiConfigs.map(config => (
<button
key={config.id}
onClick={() => handleModelChange(config.id)}
className={`w-full flex items-center gap-2 px-3 py-2 rounded-lg text-sm text-left cursor-pointer transition-all
${selectedConfigId === config.id ? 'bg-primary/20 text-white' : 'text-white/70 hover:bg-white/10 hover:text-white'}`}
>
<div className="flex-1 min-w-0">
<div className="font-medium truncate">{config.name}</div>
<div className="text-xs text-white/40 truncate">{config.provider} · {config.defaultModel}</div>
</div>
{selectedConfigId === config.id && <Check className="w-3.5 h-3.5 text-primary flex-shrink-0" />}
</button>
))}
{aiConfigs.length === 0 && (
<div className="px-3 py-3 text-xs text-white/40 text-center"></div>
)}
</div>
</div>
)}
</div>
{/* 预设选择器 */}
<div className="relative" ref={presetSelectorRef}>
<button
onClick={() => {
setShowPresetSelector(v => !v)
setShowModelSelector(false)
setShowMenu(false)
}}
className={`flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg text-xs font-medium transition-all cursor-pointer
${showPresetSelector ? 'bg-white/15 text-white' : 'glass-hover text-white/70 hover:text-white'}`}
title="选择预设"
>
<Settings className="w-3.5 h-3.5 text-purple-400 flex-shrink-0" />
<span className="max-w-[80px] truncate">
{selectedPreset ? selectedPreset.name : '默认预设'}
</span>
<ChevronDown className={`w-3 h-3 flex-shrink-0 transition-transform ${showPresetSelector ? 'rotate-180' : ''}`} />
</button>
{showPresetSelector && (
<div className="absolute right-0 top-full mt-1 z-50 w-56 glass rounded-xl shadow-2xl border border-white/15 overflow-hidden">
<div className="p-1">
<button
onClick={() => handlePresetChange(null)}
className={`w-full flex items-center gap-2 px-3 py-2 rounded-lg text-sm text-left cursor-pointer transition-all
${!selectedPresetId ? 'bg-primary/20 text-white' : 'text-white/60 hover:bg-white/10 hover:text-white'}`}
>
<span className="flex-1"></span>
{!selectedPresetId && <Check className="w-3.5 h-3.5 text-primary" />}
</button>
{presets.length > 0 && <div className="my-1 border-t border-white/10" />}
{presets.map(preset => (
<button
key={preset.id}
onClick={() => handlePresetChange(preset.id)}
className={`w-full flex items-center gap-2 px-3 py-2 rounded-lg text-sm text-left cursor-pointer transition-all
${selectedPresetId === preset.id ? 'bg-primary/20 text-white' : 'text-white/70 hover:bg-white/10 hover:text-white'}`}
>
<div className="flex-1 min-w-0">
<div className="font-medium truncate">{preset.name}</div>
<div className="text-xs text-white/40"> {preset.temperature}</div>
</div>
{selectedPresetId === preset.id && <Check className="w-3.5 h-3.5 text-primary flex-shrink-0" />}
</button>
))}
{presets.length === 0 && (
<div className="px-3 py-3 text-xs text-white/40 text-center"></div>
)}
</div>
</div>
)}
</div>
{/* 分隔线 */}
<div className="w-px h-5 bg-white/10 mx-1" />
{/* 流式传输切换 */}
<button
onClick={() => setStreamEnabled(v => !v)}
className={`p-1.5 rounded-lg cursor-pointer transition-all ${
streamEnabled
? 'text-emerald-400 bg-emerald-400/10 hover:bg-emerald-400/20'
: 'text-white/30 glass-hover hover:text-white/60'
}`}
title={streamEnabled ? '流式传输已启用(点击关闭)' : '流式传输已关闭(点击开启)'}
>
<Waves className="w-4 h-4" />
</button>
{/* 更多菜单 */}
<div className="relative" ref={menuRef}>
<button
onClick={() => {
setShowMenu(v => !v)
setShowModelSelector(false)
setShowPresetSelector(false)
}}
className={`p-1.5 rounded-lg cursor-pointer transition-all
${showMenu ? 'bg-white/15 text-white' : 'glass-hover text-white/60 hover:text-white'}`}
>
<MoreVertical className="w-4 h-4" />
</button>
{showMenu && (
<div className="absolute right-0 top-full mt-1 z-50 w-44 glass rounded-xl shadow-2xl border border-white/15 overflow-hidden">
<div className="p-1">
<button
onClick={() => { handleExportConversation(); setShowMenu(false) }}
className="w-full flex items-center gap-2.5 px-3 py-2 rounded-lg text-sm text-left cursor-pointer text-white/70 hover:bg-white/10 hover:text-white transition-all"
>
<Download className="w-4 h-4" />
</button>
<div className="my-1 border-t border-white/10" />
<button
onClick={() => { handleDeleteConversation(); setShowMenu(false) }}
className="w-full flex items-center gap-2.5 px-3 py-2 rounded-lg text-sm text-left cursor-pointer text-red-400 hover:bg-red-400/10 transition-all"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
</div>
)}
</div>
</div>
</div>
</div>
{/* 消息列表 */}
<div className="flex-1 overflow-y-auto px-4 py-6 space-y-5">
{loading ? (
<div className="flex justify-center items-center py-12">
<div className="flex gap-1.5">
{[0, 0.15, 0.3].map((delay, i) => (
<div key={i} className="w-2 h-2 bg-primary/60 rounded-full animate-bounce" style={{ animationDelay: `${delay}s` }} />
))}
</div>
</div>
) : messages.length === 0 ? (
<div className="flex flex-col items-center justify-center py-16 text-center">
{character.avatar && (
<img src={character.avatar} alt={character.name} className="w-16 h-16 rounded-full object-cover mb-4 ring-2 ring-white/10" />
)}
<p className="text-white/50 text-sm"> {character.name} </p>
</div>
) : (
messages.map((msg) => {
const isLastAssistant = msg.id === lastAssistantMsgId
return (
<div
key={msg.id}
className={`flex ${msg.role === 'user' ? 'justify-end' : 'justify-start'} group`}
>
{/* 助手头像 */}
{msg.role === 'assistant' && (
<div className="flex-shrink-0 mr-2.5 mt-1">
{character.avatar ? (
<img src={character.avatar} alt={character.name} className="w-8 h-8 rounded-full object-cover ring-1 ring-white/10" />
) : (
<div className="w-8 h-8 rounded-full bg-primary/30 flex items-center justify-center text-xs font-medium">
{character.name[0]}
</div>
)}
</div>
)}
<div className={`min-w-0 flex flex-col ${msg.role === 'user' ? 'max-w-[70%] items-end' : 'w-[70%] items-start'}`}>
{/* 助手名称 */}
{msg.role === 'assistant' && (
<span className="text-xs text-white/40 mb-1 ml-1">{character.name}</span>
)}
{/* 消息气泡 */}
<div
className={`relative px-4 py-3 rounded-2xl ${
msg.role === 'user'
? 'bg-primary/25 border border-primary/20 rounded-br-md'
: 'glass rounded-bl-md w-full'
}`}
style={{ wordBreak: 'break-word', overflowWrap: 'anywhere' }}
>
<MessageContent
content={msg.content}
role={msg.role as 'user' | 'assistant'}
onChoiceSelect={(choice) => {
setInputValue(choice)
textareaRef.current?.focus()
}}
/>
</div>
{/* 底部操作栏 */}
<div className={`flex items-center gap-1 mt-1 px-1 opacity-0 group-hover:opacity-100 transition-opacity ${msg.role === 'user' ? 'flex-row-reverse' : ''}`}>
<span className="text-xs text-white/25">{formatTime(msg.createdAt)}</span>
<button
onClick={() => handleCopyMessage(msg.content, msg.id)}
className="p-1 rounded hover:bg-white/10 transition-colors cursor-pointer"
title="复制"
>
{copiedId === msg.id
? <Check className="w-3.5 h-3.5 text-emerald-400" />
: <Copy className="w-3.5 h-3.5 text-white/40 hover:text-white/70" />
}
</button>
{/* 最后一条 AI 消息显示重新生成按钮 */}
{msg.role === 'assistant' && isLastAssistant && (
<button
onClick={handleRegenerateResponse}
disabled={sending}
className="p-1 rounded hover:bg-white/10 transition-colors cursor-pointer disabled:opacity-40 disabled:cursor-not-allowed"
title="重新生成"
>
<RefreshCw className={`w-3.5 h-3.5 text-white/40 hover:text-white/70 ${sending ? 'animate-spin' : ''}`} />
</button>
)}
</div>
</div>
{/* 用户头像占位 */}
{msg.role === 'user' && <div className="flex-shrink-0 ml-2.5 mt-1 w-8 h-8 rounded-full bg-white/10 flex items-center justify-center text-xs"></div>}
</div>
)
})
)}
{/* 发送中动画(流式时不需要,已有临时消息) */}
{sending && !streamEnabled && (
<div className="flex justify-start">
<div className="flex-shrink-0 mr-2.5 mt-1 w-8 h-8 rounded-full bg-primary/30 flex items-center justify-center text-xs">
{character.name[0]}
</div>
<div className="glass rounded-2xl rounded-bl-md px-4 py-3">
<div className="flex items-center gap-1.5">
{[0, 0.2, 0.4].map((delay, i) => (
<div key={i} className="w-1.5 h-1.5 bg-primary/60 rounded-full animate-bounce" style={{ animationDelay: `${delay}s` }} />
))}
</div>
</div>
</div>
)}
<div ref={messagesEndRef} />
</div>
{/* 输入区域 */}
<div className="px-4 pb-4 pt-2 glass border-t border-white/10">
<div className="flex items-end gap-2">
<button className="p-2.5 glass-hover rounded-xl cursor-not-allowed opacity-30" title="附件(开发中)" disabled>
<Paperclip className="w-5 h-5" />
</button>
<div className="flex-1 glass rounded-2xl px-4 py-2.5 focus-within:ring-1 focus-within:ring-primary/50 transition-all">
<textarea
ref={textareaRef}
value={inputValue}
onChange={(e) => {
setInputValue(e.target.value)
if (textareaRef.current) {
textareaRef.current.style.height = 'auto'
textareaRef.current.style.height = Math.min(textareaRef.current.scrollHeight, 120) + 'px'
}
}}
onKeyDown={handleKeyDown}
placeholder={sending ? 'AI 正在思考...' : `${character.name} 说点什么... (Enter 发送Shift+Enter 换行)`}
rows={1}
className="w-full bg-transparent resize-none focus:outline-none text-sm placeholder:text-white/25"
style={{ maxHeight: '120px', minHeight: '22px' }}
disabled={sending}
/>
</div>
<button className="p-2.5 glass-hover rounded-xl cursor-not-allowed opacity-30" title="语音(开发中)" disabled>
<Mic className="w-5 h-5" />
</button>
<button
onClick={handleSend}
disabled={!inputValue.trim() || sending}
className="p-2.5 bg-gradient-to-br from-primary to-secondary rounded-xl hover:opacity-90 active:scale-95 transition-all cursor-pointer disabled:opacity-40 disabled:cursor-not-allowed disabled:active:scale-100"
title="发送 (Enter)"
>
<Send className="w-5 h-5" />
</button>
</div>
<div className="flex items-center justify-between mt-2 px-1 text-xs text-white/25">
<span>{conversation.messageCount} · {conversation.tokenCount} tokens</span>
{sending && <span className="text-primary/70 animate-pulse">AI ...</span>}
</div>
</div>
</div>
)
}