@@ -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`)
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user