387 lines
13 KiB
React
387 lines
13 KiB
React
|
|
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
|