🎨 优化前端对话页面 && 优化ai流式传输

Signed-off-by: Echo <1711788888@qq.com>
This commit is contained in:
2026-02-27 22:50:26 +08:00
parent f4e166c5ee
commit 689e8af3df
7 changed files with 721 additions and 320 deletions

View File

@@ -106,4 +106,9 @@ export const conversationApi = {
updateConversationSettings: (conversationId: number, settings: Record<string, any>) => {
return apiClient.put(`/app/conversation/${conversationId}/settings`, { settings })
},
// 重新生成最后一条 AI 回复(非流式)
regenerateMessage: (conversationId: number) => {
return apiClient.post<Message>(`/app/conversation/${conversationId}/regenerate`)
},
}

View File

@@ -1,5 +1,6 @@
import {
Check,
ChevronDown,
Copy,
Download,
Mic,
@@ -13,7 +14,6 @@ import {
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'
@@ -36,7 +36,7 @@ export default function ChatArea({ conversation, character, onConversationUpdate
const [aiConfigs, setAiConfigs] = useState<AIConfig[]>([])
const [selectedConfigId, setSelectedConfigId] = useState<number>()
const [showModelSelector, setShowModelSelector] = useState(false)
const [streamEnabled, setStreamEnabled] = useState(true) // 默认启用流式传输
const [streamEnabled, setStreamEnabled] = useState(true)
const [presets, setPresets] = useState<Preset[]>([])
const [selectedPresetId, setSelectedPresetId] = useState<number>()
const [showPresetSelector, setShowPresetSelector] = useState(false)
@@ -46,27 +46,19 @@ export default function ChatArea({ conversation, character, onConversationUpdate
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])
@@ -86,10 +78,7 @@ export default function ChatArea({ conversation, character, onConversationUpdate
const loadMessages = async () => {
try {
setLoading(true)
const response = await conversationApi.getMessageList(conversation.id, {
page: 1,
pageSize: 100,
})
const response = await conversationApi.getMessageList(conversation.id, { page: 1, pageSize: 100 })
setMessages(response.data.list || [])
} catch (err) {
console.error('加载消息失败:', err)
@@ -101,8 +90,7 @@ export default function ChatArea({ conversation, character, onConversationUpdate
const loadAIConfigs = async () => {
try {
const response = await aiConfigApi.getAIConfigList()
const activeConfigs = response.data.list.filter(config => config.isActive)
setAiConfigs(activeConfigs)
setAiConfigs(response.data.list.filter(config => config.isActive))
} catch (err) {
console.error('加载 AI 配置失败:', err)
}
@@ -114,9 +102,7 @@ export default function ChatArea({ conversation, character, onConversationUpdate
const settings = typeof conversation.settings === 'string'
? JSON.parse(conversation.settings)
: conversation.settings
if (settings.aiConfigId) {
setSelectedConfigId(settings.aiConfigId)
}
if (settings.aiConfigId) setSelectedConfigId(settings.aiConfigId)
} catch (e) {
console.error('解析设置失败:', e)
}
@@ -133,6 +119,10 @@ export default function ChatArea({ conversation, character, onConversationUpdate
}
const loadCurrentPreset = () => {
if (conversation.presetId) {
setSelectedPresetId(conversation.presetId)
return
}
if (conversation.settings) {
try {
const settings = typeof conversation.settings === 'string'
@@ -140,25 +130,28 @@ export default function ChatArea({ conversation, character, onConversationUpdate
: conversation.settings
if (settings.presetId) {
setSelectedPresetId(settings.presetId)
return
}
} catch (e) {
console.error('解析设置失败:', e)
}
}
setSelectedPresetId(undefined)
}
const handlePresetChange = async (presetId: number) => {
const handlePresetChange = async (presetId: number | null) => {
try {
const settings = conversation.settings
? (typeof conversation.settings === 'string' ? JSON.parse(conversation.settings) : conversation.settings)
? (typeof conversation.settings === 'string' ? JSON.parse(conversation.settings) : { ...conversation.settings })
: {}
settings.presetId = presetId
if (presetId === null) {
delete settings.presetId
} else {
settings.presetId = presetId
}
await conversationApi.updateConversationSettings(conversation.id, settings)
setSelectedPresetId(presetId)
setSelectedPresetId(presetId ?? undefined)
setShowPresetSelector(false)
const convResp = await conversationApi.getConversationById(conversation.id)
onConversationUpdate(convResp.data)
} catch (err) {
@@ -167,18 +160,19 @@ export default function ChatArea({ conversation, character, onConversationUpdate
}
}
const handleModelChange = async (configId: number) => {
const handleModelChange = async (configId: number | null) => {
try {
const settings = conversation.settings
? (typeof conversation.settings === 'string' ? JSON.parse(conversation.settings) : conversation.settings)
? (typeof conversation.settings === 'string' ? JSON.parse(conversation.settings) : { ...conversation.settings })
: {}
settings.aiConfigId = configId
if (configId === null) {
delete settings.aiConfigId
} else {
settings.aiConfigId = configId
}
await conversationApi.updateConversationSettings(conversation.id, settings)
setSelectedConfigId(configId)
setSelectedConfigId(configId ?? undefined)
setShowModelSelector(false)
const convResp = await conversationApi.getConversationById(conversation.id)
onConversationUpdate(convResp.data)
} catch (err) {
@@ -192,16 +186,12 @@ export default function ChatArea({ conversation, character, onConversationUpdate
}
const handleSend = async () => {
// 防止重复发送
if (!inputValue.trim() || sending) return
const userMessage = inputValue.trim()
// 立即清空输入框和设置发送状态,防止重复触发
setInputValue('')
setSending(true)
// 立即显示用户消息
const tempUserMessage: Message = {
id: Date.now(),
conversationId: conversation.id,
@@ -210,9 +200,8 @@ export default function ChatArea({ conversation, character, onConversationUpdate
tokenCount: 0,
createdAt: new Date().toISOString(),
}
setMessages((prev) => [...prev, tempUserMessage])
setMessages(prev => [...prev, tempUserMessage])
// 创建临时AI消息用于流式显示
const tempAIMessage: Message = {
id: Date.now() + 1,
conversationId: conversation.id,
@@ -224,10 +213,7 @@ export default function ChatArea({ conversation, character, onConversationUpdate
try {
if (streamEnabled) {
// 流式传输
console.log('[Stream] 开始流式传输...')
setMessages((prev) => [...prev, tempAIMessage])
setMessages(prev => [...prev, tempAIMessage])
const response = await fetch(
`${import.meta.env.VITE_API_BASE_URL || '/api'}/app/conversation/${conversation.id}/message?stream=true`,
{
@@ -239,58 +225,34 @@ export default function ChatArea({ conversation, character, onConversationUpdate
body: JSON.stringify({ content: userMessage }),
}
)
if (!response.ok) throw new Error('流式传输失败')
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
}
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()
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
)
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 = ''
@@ -298,28 +260,18 @@ export default function ChatArea({ conversation, character, onConversationUpdate
}
}
}
// 更新对话信息
const convResp = await conversationApi.getConversationById(conversation.id)
onConversationUpdate(convResp.data)
} else {
// 普通传输
const response = await conversationApi.sendMessage(conversation.id, {
content: userMessage,
})
// 更新消息列表包含AI回复
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))
setMessages(prev => prev.filter(m => m.id !== tempUserMessage.id && m.id !== tempAIMessage.id))
} finally {
setSending(false)
}
@@ -340,22 +292,82 @@ export default function ChatArea({ conversation, character, onConversationUpdate
const handleRegenerateResponse = async () => {
if (messages.length === 0 || sending) return
// 找到最后一条用户消息
const lastUserMessage = [...messages].reverse().find(m => m.role === 'user')
if (!lastUserMessage) 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 {
await conversationApi.sendMessage(conversation.id, {
content: lastUserMessage.content,
})
await loadMessages()
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) {
} catch (err: any) {
console.error('重新生成失败:', err)
alert('重新生成失败,请重试')
alert(err.message || '重新生成失败,请重试')
await loadMessages()
} finally {
setSending(false)
}
@@ -363,7 +375,6 @@ export default function ChatArea({ conversation, character, onConversationUpdate
const handleDeleteConversation = async () => {
if (!confirm('确定要删除这个对话吗?')) return
try {
await conversationApi.deleteConversation(conversation.id)
window.location.href = '/chat'
@@ -374,9 +385,7 @@ export default function ChatArea({ conversation, character, onConversationUpdate
}
const handleExportConversation = () => {
const content = messages
.map((msg) => `[${msg.role}] ${msg.content}`)
.join('\n\n')
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')
@@ -393,7 +402,6 @@ export default function ChatArea({ conversation, character, onConversationUpdate
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}小时前`
@@ -401,259 +409,294 @@ export default function ChatArea({ conversation, character, onConversationUpdate
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">
<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 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-2">
{/* 右侧:工具按钮组 */}
<div className="flex items-center gap-1 flex-shrink-0">
{/* 模型选择器 */}
<div className="relative" ref={modelSelectorRef}>
<button
onClick={(e) => {
e.stopPropagation()
setShowModelSelector(!showModelSelector)
onClick={() => {
setShowModelSelector(v => !v)
setShowPresetSelector(false)
setShowMenu(false)
}}
className="flex items-center gap-2 px-3 py-2 glass-hover rounded-lg cursor-pointer text-sm"
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-4 h-4 text-secondary" />
<span className="text-xs">
{selectedConfigId
? aiConfigs.find(c => c.id === selectedConfigId)?.name || '默认模型'
: '默认模型'}
<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 && 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) => (
{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={(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' : ''
}`}
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="font-medium">{config.name}</div>
<div className="text-xs text-white/60 mt-0.5">
{config.provider} {config.defaultModel}
<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>
))
)}
</div>,
document.body
))}
{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={(e) => {
e.stopPropagation()
setShowPresetSelector(!showPresetSelector)
onClick={() => {
setShowPresetSelector(v => !v)
setShowModelSelector(false)
setShowMenu(false)
}}
className="flex items-center gap-2 px-3 py-2 glass-hover rounded-lg cursor-pointer text-sm"
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-4 h-4 text-primary" />
<span className="text-xs">
{selectedPresetId
? presets.find(p => p.id === selectedPresetId)?.name || '默认预设'
: '默认预设'}
<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 && 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) => (
{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={(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' : ''
}`}
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="font-medium">{preset.name}</div>
<div className="text-xs text-white/60 mt-0.5">
{preset.description || `温度: ${preset.temperature}`}
<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>
))
)}
</div>,
document.body
))}
{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(!streamEnabled)}
className={`p-2 glass-hover rounded-lg cursor-pointer ${
streamEnabled ? 'text-green-400' : 'text-white/60'
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 ? '流式传输已启用' : '流式传输已禁用'}
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" />
<Waves className="w-4 h-4" />
</button>
{/* 更多菜单 */}
<div className="relative" ref={menuRef}>
<button
onClick={(e) => {
e.stopPropagation()
setShowMenu(!showMenu)
onClick={() => {
setShowMenu(v => !v)
setShowModelSelector(false)
setShowPresetSelector(false)
}}
className="p-2 glass-hover rounded-lg cursor-pointer"
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-5 h-5" />
<MoreVertical className="w-4 h-4" />
</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
{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 p-6 space-y-6">
{/* 消息列表 */}
<div className="flex-1 overflow-y-auto px-4 py-6 space-y-5">
{loading ? (
<div className="text-center text-white/60">...</div>
<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="text-center text-white/60 py-12">
<p className="mb-2"></p>
<p className="text-sm"></p>
<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) => (
<div
key={msg.id}
className={`flex ${msg.role === 'user' ? 'justify-end' : 'justify-start'} group`}
>
messages.map((msg) => {
const isLastAssistant = msg.id === lastAssistantMsgId
return (
<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' }}
key={msg.id}
className={`flex ${msg.role === 'user' ? 'justify-end' : 'justify-start'} group`}
>
{/* 助手头像 */}
{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"
/>
<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>
)}
<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="复制消息"
<div className={`max-w-[70%] min-w-0 flex flex-col ${msg.role === 'user' ? 'items-end' : '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'
}`}
style={{ wordBreak: 'break-word', overflowWrap: 'anywhere' }}
>
{copiedId === msg.id ? (
<Check className="w-3 h-3 text-green-400" />
) : (
<Copy className="w-3 h-3" />
<MessageContent
content={msg.content}
role={msg.role}
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>
)}
</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>
</div>
))
)
})
)}
{sending && (
{/* 发送中动画(流式时不需要,已有临时消息) */}
{sending && !streamEnabled && (
<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 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>
@@ -661,57 +704,50 @@ export default function ChatArea({ conversation, character, onConversationUpdate
<div ref={messagesEndRef} />
</div>
<div className="p-4 glass border-t border-white/10">
{/* 输入区域 */}
<div className="px-4 pb-4 pt-2 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
>
<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 p-3 focus-within:ring-2 focus-within:ring-primary/50 transition-all">
<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 ? '正在发送...' : '输入消息... (Enter发送Shift+Enter换行)'}
placeholder={sending ? 'AI 正在思考...' : `${character.name} 说点什么... (Enter 发送Shift+Enter 换行)`}
rows={1}
className="w-full bg-transparent resize-none focus:outline-none text-sm"
style={{ maxHeight: '120px', minHeight: '24px' }}
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-3 glass-hover rounded-lg cursor-pointer opacity-50"
title="语音输入(开发中)"
disabled
>
<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-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="发送消息"
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="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 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>