🎉 初始化项目

Signed-off-by: Echo <1711788888@qq.com>
This commit is contained in:
2026-02-27 21:52:00 +08:00
commit f4e166c5ee
482 changed files with 55079 additions and 0 deletions

View File

@@ -0,0 +1,719 @@
import {
Check,
Copy,
Download,
Mic,
MoreVertical,
Paperclip,
RefreshCw,
Send,
Settings,
Trash2,
Waves,
Zap
} from 'lucide-react'
import {useEffect, useRef, useState} from 'react'
import {createPortal} from 'react-dom'
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])
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()
const activeConfigs = response.data.list.filter(config => config.isActive)
setAiConfigs(activeConfigs)
} 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.settings) {
try {
const settings = typeof conversation.settings === 'string'
? JSON.parse(conversation.settings)
: conversation.settings
if (settings.presetId) {
setSelectedPresetId(settings.presetId)
}
} catch (e) {
console.error('解析设置失败:', e)
}
}
}
const handlePresetChange = async (presetId: number) => {
try {
const settings = conversation.settings
? (typeof conversation.settings === 'string' ? JSON.parse(conversation.settings) : conversation.settings)
: {}
settings.presetId = presetId
await conversationApi.updateConversationSettings(conversation.id, settings)
setSelectedPresetId(presetId)
setShowPresetSelector(false)
const convResp = await conversationApi.getConversationById(conversation.id)
onConversationUpdate(convResp.data)
} catch (err) {
console.error('更新预设失败:', err)
alert('更新失败,请重试')
}
}
const handleModelChange = async (configId: number) => {
try {
const settings = conversation.settings
? (typeof conversation.settings === 'string' ? JSON.parse(conversation.settings) : conversation.settings)
: {}
settings.aiConfigId = configId
await conversationApi.updateConversationSettings(conversation.id, settings)
setSelectedConfigId(configId)
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 handleSend = async () => {
// 防止重复发送
if (!inputValue.trim() || sending) return
const userMessage = inputValue.trim()
// 立即清空输入框和设置发送状态,防止重复触发
setInputValue('')
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])
// 创建临时AI消息用于流式显示
const tempAIMessage: Message = {
id: Date.now() + 1,
conversationId: conversation.id,
role: 'assistant',
content: '',
tokenCount: 0,
createdAt: new Date().toISOString(),
}
try {
if (streamEnabled) {
// 流式传输
console.log('[Stream] 开始流式传输...')
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('流式传输失败')
}
console.log('[Stream] 连接成功,开始接收数据...')
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) {
console.log('[Stream] 传输完成')
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()
console.log('[Stream] 事件类型:', currentEvent)
} else if (line.startsWith('data: ')) {
const data = line.slice(6).trim()
if (currentEvent === 'message') {
// 消息内容 - 后端现在发送的是纯文本不再是JSON
fullContent += data
console.log('[Stream] 接收内容片段:', data)
// 实时更新临时AI消息的内容
setMessages((prev) =>
prev.map((m) =>
m.id === tempAIMessage.id ? { ...m, content: fullContent } : m
)
)
} else if (currentEvent === 'done') {
// 流式传输完成
console.log('[Stream] 收到完成信号,重新加载消息')
await loadMessages()
break
} else if (currentEvent === 'error') {
// 错误处理
console.error('[Stream] 错误:', data)
throw new Error(data)
}
currentEvent = ''
}
}
}
}
// 更新对话信息
const convResp = await conversationApi.getConversationById(conversation.id)
onConversationUpdate(convResp.data)
} else {
// 普通传输
const response = await conversationApi.sendMessage(conversation.id, {
content: userMessage,
})
// 更新消息列表包含AI回复
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 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 lastUserMessage = [...messages].reverse().find(m => m.role === 'user')
if (!lastUserMessage) return
setSending(true)
try {
await conversationApi.sendMessage(conversation.id, {
content: lastUserMessage.content,
})
await loadMessages()
const convResp = await conversationApi.getConversationById(conversation.id)
onConversationUpdate(convResp.data)
} catch (err) {
console.error('重新生成失败:', err)
alert('重新生成失败,请重试')
} 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')
}
return (
<div className="flex-1 flex flex-col">
<div className="p-4 glass border-b border-white/10">
<div className="flex items-center justify-between">
<div className="flex-1 min-w-0">
<h2 className="text-lg font-semibold truncate">{conversation.title}</h2>
<div className="flex items-center gap-2 text-sm text-white/60">
<span> {character.name} </span>
</div>
</div>
<div className="flex items-center gap-2">
<div className="relative" ref={modelSelectorRef}>
<button
onClick={(e) => {
e.stopPropagation()
setShowModelSelector(!showModelSelector)
setShowPresetSelector(false)
setShowMenu(false)
}}
className="flex items-center gap-2 px-3 py-2 glass-hover rounded-lg cursor-pointer text-sm"
title="切换模型"
>
<Zap className="w-4 h-4 text-secondary" />
<span className="text-xs">
{selectedConfigId
? aiConfigs.find(c => c.id === selectedConfigId)?.name || '默认模型'
: '默认模型'}
</span>
</button>
{showModelSelector && createPortal(
<div
className="fixed glass rounded-xl p-2 min-w-[200px] shadow-xl"
style={{
top: modelSelectorRef.current ? modelSelectorRef.current.getBoundingClientRect().bottom + 8 : 0,
left: modelSelectorRef.current ? modelSelectorRef.current.getBoundingClientRect().right - 200 : 0,
zIndex: 9999
}}
onClick={(e) => e.stopPropagation()}
>
{aiConfigs.length === 0 ? (
<div className="px-4 py-3 text-xs text-white/60 text-center">
</div>
) : (
aiConfigs.map((config) => (
<button
key={config.id}
onClick={(e) => {
e.stopPropagation()
handleModelChange(config.id)
}}
className={`w-full px-3 py-2 glass-hover rounded-lg text-sm text-left cursor-pointer ${
selectedConfigId === config.id ? 'bg-primary/20 ring-1 ring-primary' : ''
}`}
>
<div className="font-medium">{config.name}</div>
<div className="text-xs text-white/60 mt-0.5">
{config.provider} {config.defaultModel}
</div>
</button>
))
)}
</div>,
document.body
)}
</div>
{/* 预设选择器 */}
<div className="relative" ref={presetSelectorRef}>
<button
onClick={(e) => {
e.stopPropagation()
setShowPresetSelector(!showPresetSelector)
setShowModelSelector(false)
setShowMenu(false)
}}
className="flex items-center gap-2 px-3 py-2 glass-hover rounded-lg cursor-pointer text-sm"
title="选择预设"
>
<Settings className="w-4 h-4 text-primary" />
<span className="text-xs">
{selectedPresetId
? presets.find(p => p.id === selectedPresetId)?.name || '默认预设'
: '默认预设'}
</span>
</button>
{showPresetSelector && createPortal(
<div
className="fixed glass rounded-xl p-2 min-w-[200px] shadow-xl"
style={{
top: presetSelectorRef.current ? presetSelectorRef.current.getBoundingClientRect().bottom + 8 : 0,
left: presetSelectorRef.current ? presetSelectorRef.current.getBoundingClientRect().right - 200 : 0,
zIndex: 9999
}}
onClick={(e) => e.stopPropagation()}
>
{presets.length === 0 ? (
<div className="px-4 py-3 text-xs text-white/60 text-center">
</div>
) : (
presets.map((preset) => (
<button
key={preset.id}
onClick={(e) => {
e.stopPropagation()
handlePresetChange(preset.id)
}}
className={`w-full px-3 py-2 glass-hover rounded-lg text-sm text-left cursor-pointer ${
selectedPresetId === preset.id ? 'bg-primary/20 ring-1 ring-primary' : ''
}`}
>
<div className="font-medium">{preset.name}</div>
<div className="text-xs text-white/60 mt-0.5">
{preset.description || `温度: ${preset.temperature}`}
</div>
</button>
))
)}
</div>,
document.body
)}
</div>
<button
onClick={() => setStreamEnabled(!streamEnabled)}
className={`p-2 glass-hover rounded-lg cursor-pointer ${
streamEnabled ? 'text-green-400' : 'text-white/60'
}`}
title={streamEnabled ? '流式传输已启用' : '流式传输已禁用'}
>
<Waves className="w-5 h-5" />
</button>
<button
onClick={handleRegenerateResponse}
disabled={sending || messages.length === 0}
className="p-2 glass-hover rounded-lg cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed"
title="重新生成回复"
>
<RefreshCw className="w-5 h-5" />
</button>
<div className="relative" ref={menuRef}>
<button
onClick={(e) => {
e.stopPropagation()
setShowMenu(!showMenu)
setShowModelSelector(false)
setShowPresetSelector(false)
}}
className="p-2 glass-hover rounded-lg cursor-pointer"
>
<MoreVertical className="w-5 h-5" />
</button>
{showMenu && createPortal(
<div
className="fixed glass rounded-xl p-2 min-w-[160px] shadow-xl"
style={{
top: menuRef.current ? menuRef.current.getBoundingClientRect().bottom + 8 : 0,
left: menuRef.current ? menuRef.current.getBoundingClientRect().right - 160 : 0,
zIndex: 9999
}}
onClick={(e) => e.stopPropagation()}
>
<button
onClick={handleExportConversation}
className="w-full px-4 py-2 glass-hover rounded-lg text-sm text-left cursor-pointer flex items-center gap-2"
>
<Download className="w-4 h-4" />
</button>
<button
onClick={handleDeleteConversation}
className="w-full px-4 py-2 glass-hover rounded-lg text-sm text-left cursor-pointer flex items-center gap-2 text-red-400"
>
<Trash2 className="w-4 h-4" />
</button>
</div>,
document.body
)}
</div>
</div>
</div>
</div>
<div className="flex-1 overflow-y-auto p-6 space-y-6">
{loading ? (
<div className="text-center text-white/60">...</div>
) : messages.length === 0 ? (
<div className="text-center text-white/60 py-12">
<p className="mb-2"></p>
<p className="text-sm"></p>
</div>
) : (
messages.map((msg) => (
<div
key={msg.id}
className={`flex ${msg.role === 'user' ? 'justify-end' : 'justify-start'} group`}
>
<div
className={`max-w-2xl min-w-0 relative ${
msg.role === 'user'
? 'glass-hover rounded-2xl rounded-br-md p-4'
: 'glass-hover rounded-2xl rounded-bl-md p-4'
}`}
style={{ wordBreak: 'break-word', overflowWrap: 'anywhere' }}
>
{msg.role === 'assistant' && (
<div className="flex items-center gap-2 mb-2">
{character.avatar && (
<img
src={character.avatar}
alt={character.name}
className="w-6 h-6 rounded-full object-cover"
/>
)}
<span className="text-sm font-medium text-primary">{character.name}</span>
</div>
)}
<MessageContent
content={msg.content}
role={msg.role}
onChoiceSelect={(choice) => {
setInputValue(choice)
// 自动聚焦到输入框
textareaRef.current?.focus()
}}
/>
<div className="flex items-center justify-between mt-2">
<span className="text-xs text-white/40">{formatTime(msg.createdAt)}</span>
<button
onClick={() => handleCopyMessage(msg.content, msg.id)}
className="opacity-0 group-hover:opacity-100 p-1 glass-hover rounded transition-opacity cursor-pointer"
title="复制消息"
>
{copiedId === msg.id ? (
<Check className="w-3 h-3 text-green-400" />
) : (
<Copy className="w-3 h-3" />
)}
</button>
</div>
</div>
</div>
))
)}
{sending && (
<div className="flex justify-start">
<div className="glass-hover rounded-2xl rounded-bl-md p-4">
<div className="flex items-center gap-2">
<div className="w-2 h-2 bg-primary rounded-full animate-pulse"></div>
<div className="w-2 h-2 bg-primary rounded-full animate-pulse" style={{ animationDelay: '0.2s' }}></div>
<div className="w-2 h-2 bg-primary rounded-full animate-pulse" style={{ animationDelay: '0.4s' }}></div>
</div>
</div>
</div>
)}
<div ref={messagesEndRef} />
</div>
<div className="p-4 glass border-t border-white/10">
<div className="flex items-end gap-2">
<button
className="p-3 glass-hover rounded-lg cursor-pointer opacity-50"
title="附件(开发中)"
disabled
>
<Paperclip className="w-5 h-5" />
</button>
<div className="flex-1 glass rounded-2xl p-3 focus-within:ring-2 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 ? '正在发送...' : '输入消息... (Enter发送Shift+Enter换行)'}
rows={1}
className="w-full bg-transparent resize-none focus:outline-none text-sm"
style={{ maxHeight: '120px', minHeight: '24px' }}
disabled={sending}
/>
</div>
<button
className="p-3 glass-hover rounded-lg cursor-pointer opacity-50"
title="语音输入(开发中)"
disabled
>
<Mic className="w-5 h-5" />
</button>
<button
onClick={handleSend}
disabled={!inputValue.trim() || sending}
className="p-3 bg-gradient-to-r from-primary to-secondary rounded-lg hover:opacity-90 transition-opacity cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed"
title="发送消息"
>
<Send className="w-5 h-5" />
</button>
</div>
<div className="mt-2 flex items-center justify-between text-xs text-white/40">
<span>: {conversation.messageCount} | Token: {conversation.tokenCount}</span>
{sending && <span className="text-primary animate-pulse">AI ...</span>}
</div>
</div>
</div>
)
}