780 lines
32 KiB
TypeScript
780 lines
32 KiB
TypeScript
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>
|
||
)
|
||
}
|