nex_docus/frontend/src/pages/Chat/Chat.jsx

387 lines
13 KiB
React
Raw Normal View History

import { useState, useEffect, useRef } from 'react'
import { Layout, Select, Button, Input, Empty, Card, Space, Spin, Message, Divider, Tag, Modal, Form } from 'antd'
import { SendOutlined, DeleteOutlined, EditOutlined, PlusOutlined } from '@ant-design/icons'
import { getMyProjects } from '@/api/project'
import { getLLMModelConfigs } from '@/api/llmModelConfigs'
import {
createChatSession,
getChatSessions,
getChatMessages,
sendChatMessage,
deleteChatSession,
updateChatSessionTitle,
} from '@/api/chat'
import './Chat.css'
const { Sider, Content } = Layout
function Chat() {
const [projects, setProjects] = useState([])
const [llmConfigs, setLlmConfigs] = useState([])
const [selectedProject, setSelectedProject] = useState(null)
const [selectedModel, setSelectedModel] = useState(null)
const [sessions, setSessions] = useState([])
const [currentSession, setCurrentSession] = useState(null)
const [messages, setMessages] = useState([])
const [loading, setLoading] = useState(false)
const [sessionLoading, setSessionLoading] = useState(false)
const [messageLoading, setMessageLoading] = useState(false)
const [inputValue, setInputValue] = useState('')
const messagesEndRef = useRef(null)
const [editingSessionId, setEditingSessionId] = useState(null)
const [editingTitle, setEditingTitle] = useState('')
useEffect(() => {
fetchProjects()
fetchLlmConfigs()
}, [])
const fetchProjects = async () => {
try {
const res = await getMyProjects()
setProjects(res.data?.data || [])
} catch (error) {
console.error('Failed to fetch projects:', error)
}
}
const fetchLlmConfigs = async () => {
try {
const res = await getLLMModelConfigs()
setLlmConfigs(res.data?.data || [])
if (res.data?.data?.length > 0) {
setSelectedModel(res.data.data[0].config_id)
}
} catch (error) {
console.error('Failed to fetch LLM configs:', error)
}
}
const fetchSessions = async (projectId) => {
if (!projectId) return
setSessionLoading(true)
try {
const res = await getChatSessions(projectId)
setSessions(res.data?.data || [])
if (res.data?.data?.length > 0) {
setCurrentSession(res.data.data[0])
fetchMessages(res.data.data[0].session_id)
} else {
setMessages([])
setCurrentSession(null)
}
} catch (error) {
console.error('Failed to fetch sessions:', error)
} finally {
setSessionLoading(false)
}
}
const fetchMessages = async (sessionId) => {
setMessageLoading(true)
try {
const res = await getChatMessages(sessionId)
setMessages(res.data?.data || [])
setTimeout(scrollToBottom, 100)
} catch (error) {
console.error('Failed to fetch messages:', error)
} finally {
setMessageLoading(false)
}
}
const scrollToBottom = () => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })
}
const handleProjectChange = (projectId) => {
setSelectedProject(projectId)
setCurrentSession(null)
setMessages([])
fetchSessions(projectId)
}
const handleCreateSession = async () => {
if (!selectedProject || !selectedModel) {
Message.warning('请先选择项目和模型')
return
}
setLoading(true)
try {
const res = await createChatSession(selectedProject, selectedModel)
const newSession = res.data?.data
setSessions([newSession, ...sessions])
setCurrentSession(newSession)
setMessages([])
} catch (error) {
Message.error('创建会话失败')
console.error(error)
} finally {
setLoading(false)
}
}
const handleSendMessage = async () => {
if (!inputValue.trim() || !currentSession) {
return
}
const userMsg = inputValue
setInputValue('')
setMessageLoading(true)
try {
const res = await sendChatMessage(currentSession.session_id, userMsg)
const { user_message, assistant_message } = res.data?.data || {}
const newMessages = [
...messages,
{
id: messages.length + 1,
role: 'user',
content: user_message,
created_at: new Date().toISOString(),
},
{
id: messages.length + 2,
role: 'assistant',
content: assistant_message,
created_at: new Date().toISOString(),
},
]
setMessages(newMessages)
setTimeout(scrollToBottom, 100)
} catch (error) {
Message.error('发送消息失败')
setInputValue(userMsg)
console.error(error)
} finally {
setMessageLoading(false)
}
}
const handleDeleteSession = (sessionId) => {
Modal.confirm({
title: '确认删除',
content: '确定要删除这个对话会话吗?',
onOk: async () => {
try {
await deleteChatSession(sessionId)
setSessions(sessions.filter(s => s.session_id !== sessionId))
if (currentSession?.session_id === sessionId) {
setCurrentSession(null)
setMessages([])
}
Message.success('会话已删除')
} catch (error) {
Message.error('删除失败')
console.error(error)
}
},
})
}
const handleEditTitle = (session) => {
setEditingSessionId(session.session_id)
setEditingTitle(session.title || `对话 ${session.session_id}`)
}
const handleSaveTitle = async () => {
if (!editingSessionId || !editingTitle.trim()) {
return
}
try {
await updateChatSessionTitle(editingSessionId, editingTitle)
setSessions(sessions.map(s =>
s.session_id === editingSessionId
? { ...s, title: editingTitle }
: s
))
setEditingSessionId(null)
Message.success('标题已更新')
} catch (error) {
Message.error('更新失败')
console.error(error)
}
}
const currentModel = llmConfigs.find(m => m.config_id === selectedModel)
return (
<Layout style={{ height: '100vh' }}>
<Sider width={280} style={{ background: '#fafafa', borderRight: '1px solid #e8e8e8', overflow: 'auto' }}>
<div style={{ padding: '16px' }}>
<div style={{ marginBottom: '16px' }}>
<div style={{ fontSize: '12px', color: '#666', marginBottom: '8px' }}>项目</div>
<Select
placeholder="选择项目"
value={selectedProject}
onChange={handleProjectChange}
style={{ width: '100%' }}
options={projects.map(p => ({ label: p.name, value: p.id }))}
/>
</div>
<div style={{ marginBottom: '16px' }}>
<div style={{ fontSize: '12px', color: '#666', marginBottom: '8px' }}>模型</div>
<Select
placeholder="选择模型"
value={selectedModel}
onChange={setSelectedModel}
style={{ width: '100%' }}
options={llmConfigs.map(m => ({
label: m.model_name,
value: m.config_id
}))}
/>
</div>
<Button
type="primary"
block
icon={<PlusOutlined />}
onClick={handleCreateSession}
loading={loading}
>
新建对话
</Button>
</div>
<Divider style={{ margin: '0' }} />
<div style={{ padding: '8px', maxHeight: 'calc(100vh - 200px)', overflow: 'auto' }}>
{sessionLoading ? (
<Spin />
) : sessions.length === 0 ? (
<Empty description="暂无对话" style={{ marginTop: '32px' }} />
) : (
sessions.map(session => (
<Card
key={session.session_id}
size="small"
hoverable
onClick={() => {
setCurrentSession(session)
fetchMessages(session.session_id)
}}
style={{
marginBottom: '8px',
cursor: 'pointer',
background: currentSession?.session_id === session.session_id ? '#e6f7ff' : '#fff',
border: currentSession?.session_id === session.session_id ? '1px solid #1890ff' : '1px solid #d9d9d9',
}}
>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<div style={{ flex: 1, minWidth: 0 }}>
{editingSessionId === session.session_id ? (
<Input
value={editingTitle}
onChange={(e) => setEditingTitle(e.target.value)}
size="small"
onBlur={handleSaveTitle}
onPressEnter={handleSaveTitle}
autoFocus
/>
) : (
<div style={{ fontSize: '13px', fontWeight: '500', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
{session.title || `对话 ${session.session_id}`}
</div>
)}
<div style={{ fontSize: '11px', color: '#999', marginTop: '4px' }}>
{new Date(session.created_at).toLocaleDateString()}
</div>
</div>
<Space size="small">
<EditOutlined
style={{ cursor: 'pointer', color: '#1890ff' }}
onClick={(e) => {
e.stopPropagation()
handleEditTitle(session)
}}
/>
<DeleteOutlined
style={{ cursor: 'pointer', color: '#ff4d4f' }}
onClick={(e) => {
e.stopPropagation()
handleDeleteSession(session.session_id)
}}
/>
</Space>
</div>
</Card>
))
)}
</div>
</Sider>
<Content style={{ display: 'flex', flexDirection: 'column', background: '#fff' }}>
{!currentSession ? (
<Empty
description="选择或创建一个对话会话"
style={{ marginTop: '100px' }}
/>
) : (
<>
<div style={{ padding: '16px', borderBottom: '1px solid #e8e8e8', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<div>
<div style={{ fontSize: '16px', fontWeight: '600' }}>
{currentSession.title || `对话 ${currentSession.session_id}`}
</div>
{currentModel && (
<Tag style={{ marginTop: '4px' }} color="blue">{currentModel.model_name}</Tag>
)}
</div>
</div>
<div style={{ flex: 1, overflow: 'auto', padding: '16px', display: 'flex', flexDirection: 'column' }}>
{messageLoading ? (
<Spin />
) : messages.length === 0 ? (
<Empty description="暂无消息" />
) : (
messages.map((msg) => (
<div key={msg.id} style={{ marginBottom: '12px', display: 'flex', justifyContent: msg.role === 'user' ? 'flex-end' : 'flex-start' }}>
<div style={{
maxWidth: '70%',
padding: '8px 12px',
borderRadius: '6px',
background: msg.role === 'user' ? '#1890ff' : '#f0f0f0',
color: msg.role === 'user' ? '#fff' : '#000',
wordBreak: 'break-word',
whiteSpace: 'pre-wrap',
}}>
{msg.content}
</div>
</div>
))
)}
<div ref={messagesEndRef} />
</div>
<div style={{ padding: '16px', borderTop: '1px solid #e8e8e8', display: 'flex', gap: '8px' }}>
<Input
placeholder="输入消息..."
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
onPressEnter={handleSendMessage}
disabled={messageLoading}
autoFocus
/>
<Button
type="primary"
icon={<SendOutlined />}
onClick={handleSendMessage}
loading={messageLoading}
disabled={!inputValue.trim()}
>
发送
</Button>
</div>
</>
)}
</Content>
</Layout>
)
}
export default Chat