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([]) const [inputValue, setInputValue] = useState('') const [sending, setSending] = useState(false) const [loading, setLoading] = useState(true) const [showMenu, setShowMenu] = useState(false) const [copiedId, setCopiedId] = useState(null) const [aiConfigs, setAiConfigs] = useState([]) const [selectedConfigId, setSelectedConfigId] = useState() const [showModelSelector, setShowModelSelector] = useState(false) const [streamEnabled, setStreamEnabled] = useState(true) const [presets, setPresets] = useState([]) const [selectedPresetId, setSelectedPresetId] = useState() const [showPresetSelector, setShowPresetSelector] = useState(false) const messagesEndRef = useRef(null) const textareaRef = useRef(null) const modelSelectorRef = useRef(null) const presetSelectorRef = useRef(null) const menuRef = useRef(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) => { 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 (
{/* 顶部工具栏 */}
{/* 左侧:标题 */}

{conversation.title}

与 {character.name} 对话中

{/* 右侧:工具按钮组 */}
{/* 模型选择器 */}
{showModelSelector && (
{aiConfigs.length > 0 &&
} {aiConfigs.map(config => ( ))} {aiConfigs.length === 0 && (
暂无可用配置
)}
)}
{/* 预设选择器 */}
{showPresetSelector && (
{presets.length > 0 &&
} {presets.map(preset => ( ))} {presets.length === 0 && (
暂无可用预设
)}
)}
{/* 分隔线 */}
{/* 流式传输切换 */} {/* 更多菜单 */}
{showMenu && (
)}
{/* 消息列表 */}
{loading ? (
{[0, 0.15, 0.3].map((delay, i) => (
))}
) : messages.length === 0 ? (
{character.avatar && ( {character.name} )}

发送第一条消息,开始和 {character.name} 对话吧

) : ( messages.map((msg) => { const isLastAssistant = msg.id === lastAssistantMsgId return (
{/* 助手头像 */} {msg.role === 'assistant' && (
{character.avatar ? ( {character.name} ) : (
{character.name[0]}
)}
)}
{/* 助手名称 */} {msg.role === 'assistant' && ( {character.name} )} {/* 消息气泡 */}
{ setInputValue(choice) textareaRef.current?.focus() }} />
{/* 底部操作栏 */}
{formatTime(msg.createdAt)} {/* 最后一条 AI 消息显示重新生成按钮 */} {msg.role === 'assistant' && isLastAssistant && ( )}
{/* 用户头像占位 */} {msg.role === 'user' &&
}
) }) )} {/* 发送中动画(流式时不需要,已有临时消息) */} {sending && !streamEnabled && (
{character.name[0]}
{[0, 0.2, 0.4].map((delay, i) => (
))}
)}
{/* 输入区域 */}