2026-07-24 07:37:22 +00:00
|
|
|
|
import { useEffect, useMemo, useRef, useState } from 'react'
|
|
|
|
|
|
import { useLocation, useNavigate, useSearchParams } from 'react-router-dom'
|
|
|
|
|
|
import {
|
|
|
|
|
|
Avatar,
|
|
|
|
|
|
Button,
|
|
|
|
|
|
Dropdown,
|
|
|
|
|
|
Empty,
|
|
|
|
|
|
Input,
|
|
|
|
|
|
List,
|
|
|
|
|
|
Modal,
|
|
|
|
|
|
Popover,
|
|
|
|
|
|
Select,
|
|
|
|
|
|
Space,
|
|
|
|
|
|
Spin,
|
|
|
|
|
|
Tag,
|
|
|
|
|
|
Typography,
|
|
|
|
|
|
message,
|
|
|
|
|
|
} from 'antd'
|
|
|
|
|
|
import {
|
|
|
|
|
|
FolderOutlined,
|
|
|
|
|
|
EditOutlined,
|
|
|
|
|
|
DeleteOutlined,
|
|
|
|
|
|
MoreOutlined,
|
|
|
|
|
|
PlusOutlined,
|
|
|
|
|
|
RobotOutlined,
|
|
|
|
|
|
SearchOutlined,
|
|
|
|
|
|
ArrowUpOutlined,
|
|
|
|
|
|
UserOutlined,
|
|
|
|
|
|
DownOutlined,
|
|
|
|
|
|
CopyOutlined,
|
|
|
|
|
|
CheckOutlined,
|
|
|
|
|
|
} from '@ant-design/icons'
|
|
|
|
|
|
import ReactMarkdown from 'react-markdown'
|
|
|
|
|
|
import Highlighter from 'react-highlight-words'
|
|
|
|
|
|
import remarkGfm from 'remark-gfm'
|
|
|
|
|
|
import rehypeHighlight from 'rehype-highlight'
|
|
|
|
|
|
import 'highlight.js/styles/github.css'
|
|
|
|
|
|
import { createChatSession, deleteChatMessage, deleteChatSession, getChatMessages, getChatSessions, searchChatMessages, sendChatMessageStream, updateChatSessionTitle } from '@/api/chat'
|
2026-06-23 13:58:16 +00:00
|
|
|
|
import { getMyProjects } from '@/api/project'
|
|
|
|
|
|
import { getLLMModelConfigs } from '@/api/llmModelConfigs'
|
|
|
|
|
|
import './Chat.css'
|
|
|
|
|
|
|
2026-07-24 07:37:22 +00:00
|
|
|
|
const { TextArea } = Input
|
|
|
|
|
|
const { Text } = Typography
|
|
|
|
|
|
|
|
|
|
|
|
const CITATION_RE = /\[(\d+)\]/g
|
|
|
|
|
|
|
|
|
|
|
|
// rehype 插件:将正文中的 [n] 引用编号转换为上角标 <sup>,便于与正文区分。
|
|
|
|
|
|
// 跳过 code/pre 节点,避免破坏代码块中的方括号内容。
|
|
|
|
|
|
function rehypeCitationSup() {
|
|
|
|
|
|
const walk = (node, inCode) => {
|
|
|
|
|
|
if (!node.children) return
|
|
|
|
|
|
const nextChildren = []
|
|
|
|
|
|
for (const child of node.children) {
|
|
|
|
|
|
const childInCode = inCode || child.tagName === 'code' || child.tagName === 'pre'
|
|
|
|
|
|
if (child.type === 'text' && !childInCode && CITATION_RE.test(child.value)) {
|
|
|
|
|
|
CITATION_RE.lastIndex = 0
|
|
|
|
|
|
let lastIndex = 0
|
|
|
|
|
|
let match
|
|
|
|
|
|
while ((match = CITATION_RE.exec(child.value)) !== null) {
|
|
|
|
|
|
if (match.index > lastIndex) {
|
|
|
|
|
|
nextChildren.push({ type: 'text', value: child.value.slice(lastIndex, match.index) })
|
|
|
|
|
|
}
|
|
|
|
|
|
nextChildren.push({
|
|
|
|
|
|
type: 'element',
|
|
|
|
|
|
tagName: 'sup',
|
|
|
|
|
|
properties: { className: ['chat-citation'], 'data-citation-id': match[1] },
|
|
|
|
|
|
children: [{ type: 'text', value: `[${match[1]}]` }],
|
|
|
|
|
|
})
|
|
|
|
|
|
lastIndex = match.index + match[0].length
|
|
|
|
|
|
}
|
|
|
|
|
|
if (lastIndex < child.value.length) {
|
|
|
|
|
|
nextChildren.push({ type: 'text', value: child.value.slice(lastIndex) })
|
|
|
|
|
|
}
|
|
|
|
|
|
} else {
|
|
|
|
|
|
if (child.type === 'element') walk(child, childInCode)
|
|
|
|
|
|
nextChildren.push(child)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
node.children = nextChildren
|
|
|
|
|
|
}
|
|
|
|
|
|
return (tree) => walk(tree, false)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function formatTime(value) {
|
|
|
|
|
|
if (!value) return ''
|
|
|
|
|
|
return new Date(value).toLocaleString('zh-CN', {
|
|
|
|
|
|
month: '2-digit',
|
|
|
|
|
|
day: '2-digit',
|
|
|
|
|
|
hour: '2-digit',
|
|
|
|
|
|
minute: '2-digit',
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function getDateKey(value) {
|
|
|
|
|
|
if (!value) return 'unknown'
|
|
|
|
|
|
const date = new Date(value)
|
|
|
|
|
|
if (Number.isNaN(date.getTime())) return 'unknown'
|
|
|
|
|
|
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function formatDateGroup(value) {
|
|
|
|
|
|
if (!value || value === 'unknown') return '未知日期'
|
|
|
|
|
|
const date = new Date(`${value}T00:00:00`)
|
|
|
|
|
|
const today = new Date()
|
|
|
|
|
|
const yesterday = new Date()
|
|
|
|
|
|
yesterday.setDate(today.getDate() - 1)
|
|
|
|
|
|
const todayKey = getDateKey(today)
|
|
|
|
|
|
const yesterdayKey = getDateKey(yesterday)
|
|
|
|
|
|
|
|
|
|
|
|
if (value === todayKey) return '今天'
|
|
|
|
|
|
if (value === yesterdayKey) return '昨天'
|
|
|
|
|
|
if (date.getFullYear() === today.getFullYear()) {
|
|
|
|
|
|
return `${date.getMonth() + 1}月${date.getDate()}日`
|
|
|
|
|
|
}
|
|
|
|
|
|
return `${date.getFullYear()}年${date.getMonth() + 1}月${date.getDate()}日`
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function stripMarkdown(text) {
|
|
|
|
|
|
return (text || '')
|
|
|
|
|
|
.replace(/```[\s\S]*?```/g, ' ')
|
|
|
|
|
|
.replace(/[#>*_`~\-\[\]()]/g, ' ')
|
|
|
|
|
|
.replace(/\s+/g, ' ')
|
|
|
|
|
|
.trim()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function buildDocumentPreviewUrl(projectId, filePath, anchorText = '') {
|
|
|
|
|
|
if (!projectId || !filePath) return ''
|
|
|
|
|
|
const base = `/projects/${projectId}/docs?file=${encodeURIComponent(filePath)}`
|
|
|
|
|
|
// 携带锚点文本作为 keyword,文档页据此高亮并滚动到被引用的段落
|
|
|
|
|
|
const anchor = (anchorText || '').trim()
|
|
|
|
|
|
return anchor ? `${base}&keyword=${encodeURIComponent(anchor)}` : base
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function openDocument(ref, projectId) {
|
|
|
|
|
|
const anchorText = ref?.anchor_text || getReferenceExcerpt(ref)
|
|
|
|
|
|
const previewUrl = buildDocumentPreviewUrl(
|
|
|
|
|
|
ref?.project_id || projectId,
|
|
|
|
|
|
ref?.file_path,
|
|
|
|
|
|
anchorText
|
|
|
|
|
|
)
|
|
|
|
|
|
if (previewUrl) window.open(previewUrl, '_blank', 'noopener,noreferrer')
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function getReferenceExcerpt(ref) {
|
|
|
|
|
|
return (ref?.excerpt || ref?.anchor_text || '').trim()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function SearchHighlight({ text, keyword }) {
|
|
|
|
|
|
const value = text || ''
|
|
|
|
|
|
if (!keyword) return value
|
|
|
|
|
|
return (
|
|
|
|
|
|
<Highlighter
|
|
|
|
|
|
autoEscape
|
|
|
|
|
|
highlightClassName="chat-search-highlight"
|
|
|
|
|
|
searchWords={[keyword]}
|
|
|
|
|
|
textToHighlight={value}
|
|
|
|
|
|
/>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function copyText(text) {
|
|
|
|
|
|
const value = text || ''
|
|
|
|
|
|
try {
|
|
|
|
|
|
if (navigator.clipboard?.writeText) {
|
|
|
|
|
|
await navigator.clipboard.writeText(value)
|
|
|
|
|
|
return true
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
// 回退到 execCommand
|
|
|
|
|
|
}
|
|
|
|
|
|
try {
|
|
|
|
|
|
const textarea = document.createElement('textarea')
|
|
|
|
|
|
textarea.value = value
|
|
|
|
|
|
textarea.style.position = 'fixed'
|
|
|
|
|
|
textarea.style.opacity = '0'
|
|
|
|
|
|
document.body.appendChild(textarea)
|
|
|
|
|
|
textarea.select()
|
|
|
|
|
|
const ok = document.execCommand('copy')
|
|
|
|
|
|
document.body.removeChild(textarea)
|
|
|
|
|
|
return ok
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
return false
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function MessageActions({ content, onCopy, onDelete }) {
|
|
|
|
|
|
const [copied, setCopied] = useState(false)
|
|
|
|
|
|
const handleCopy = async () => {
|
|
|
|
|
|
const ok = await copyText(content)
|
|
|
|
|
|
if (ok) {
|
|
|
|
|
|
setCopied(true)
|
|
|
|
|
|
window.setTimeout(() => setCopied(false), 1500)
|
|
|
|
|
|
} else {
|
|
|
|
|
|
message.error('复制失败')
|
|
|
|
|
|
}
|
|
|
|
|
|
onCopy?.(ok)
|
|
|
|
|
|
}
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div className="chat-message-actions">
|
|
|
|
|
|
<button type="button" className="chat-message-action" onClick={handleCopy} aria-label="复制">
|
|
|
|
|
|
{copied ? <CheckOutlined /> : <CopyOutlined />}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
{onDelete && (
|
|
|
|
|
|
<button type="button" className="chat-message-action danger" onClick={onDelete} aria-label="删除">
|
|
|
|
|
|
<DeleteOutlined />
|
|
|
|
|
|
</button>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
2026-06-23 13:58:16 +00:00
|
|
|
|
|
|
|
|
|
|
function Chat() {
|
2026-07-24 07:37:22 +00:00
|
|
|
|
const navigate = useNavigate()
|
|
|
|
|
|
const location = useLocation()
|
|
|
|
|
|
const [searchParams, setSearchParams] = useSearchParams()
|
2026-06-23 13:58:16 +00:00
|
|
|
|
const [sessions, setSessions] = useState([])
|
2026-07-24 07:37:22 +00:00
|
|
|
|
const [projects, setProjects] = useState([])
|
|
|
|
|
|
const [models, setModels] = useState([])
|
2026-06-23 13:58:16 +00:00
|
|
|
|
const [currentSession, setCurrentSession] = useState(null)
|
|
|
|
|
|
const [messages, setMessages] = useState([])
|
2026-07-24 07:37:22 +00:00
|
|
|
|
const [loadingSessions, setLoadingSessions] = useState(false)
|
|
|
|
|
|
const [loadingMessages, setLoadingMessages] = useState(false)
|
|
|
|
|
|
const [sending, setSending] = useState(false)
|
2026-06-23 13:58:16 +00:00
|
|
|
|
const [inputValue, setInputValue] = useState('')
|
2026-07-24 07:37:22 +00:00
|
|
|
|
const [searchVisible, setSearchVisible] = useState(false)
|
|
|
|
|
|
const [searchKeyword, setSearchKeyword] = useState('')
|
|
|
|
|
|
const [searchResults, setSearchResults] = useState([])
|
|
|
|
|
|
const [searchLoading, setSearchLoading] = useState(false)
|
|
|
|
|
|
const [searchedKeyword, setSearchedKeyword] = useState('')
|
|
|
|
|
|
const [hasSearched, setHasSearched] = useState(false)
|
|
|
|
|
|
const [newQuestion, setNewQuestion] = useState('')
|
|
|
|
|
|
const [newProjectId, setNewProjectId] = useState(undefined)
|
|
|
|
|
|
const [newModelId, setNewModelId] = useState(undefined)
|
|
|
|
|
|
const [projectPickerOpen, setProjectPickerOpen] = useState(false)
|
|
|
|
|
|
const [renameVisible, setRenameVisible] = useState(false)
|
|
|
|
|
|
const [renameSession, setRenameSession] = useState(null)
|
|
|
|
|
|
const [renameTitle, setRenameTitle] = useState('')
|
|
|
|
|
|
const messageRefs = useRef(new Map())
|
|
|
|
|
|
const skipNextOpenSessionRef = useRef(null)
|
|
|
|
|
|
const searchRequestIdRef = useRef(0)
|
|
|
|
|
|
const newMode = location.pathname === '/chat/new'
|
2026-06-23 13:58:16 +00:00
|
|
|
|
|
2026-07-24 07:37:22 +00:00
|
|
|
|
const sessionId = useMemo(() => {
|
|
|
|
|
|
const value = searchParams.get('session_id') || searchParams.get('sessionId')
|
|
|
|
|
|
return value ? Number(value) : null
|
|
|
|
|
|
}, [searchParams])
|
|
|
|
|
|
|
|
|
|
|
|
const currentProject = useMemo(
|
|
|
|
|
|
() => projects.find((item) => item.id === newProjectId),
|
|
|
|
|
|
[projects, newProjectId]
|
|
|
|
|
|
)
|
|
|
|
|
|
const currentModel = useMemo(
|
|
|
|
|
|
() => models.find((item) => item.config_id === newModelId),
|
|
|
|
|
|
[models, newModelId]
|
|
|
|
|
|
)
|
|
|
|
|
|
const canCreateSession = Boolean(newQuestion.trim() && currentProject && currentModel)
|
|
|
|
|
|
const canSendMessage = Boolean(inputValue.trim() && currentSession)
|
2026-06-23 13:58:16 +00:00
|
|
|
|
|
2026-07-24 07:37:22 +00:00
|
|
|
|
const groupedSessions = useMemo(() => {
|
|
|
|
|
|
const groups = []
|
|
|
|
|
|
const groupMap = new Map()
|
|
|
|
|
|
sessions.forEach((item) => {
|
|
|
|
|
|
const key = getDateKey(item.updated_at || item.created_at)
|
|
|
|
|
|
if (!groupMap.has(key)) {
|
|
|
|
|
|
const group = {
|
|
|
|
|
|
key,
|
|
|
|
|
|
label: formatDateGroup(key),
|
|
|
|
|
|
items: [],
|
|
|
|
|
|
}
|
|
|
|
|
|
groupMap.set(key, group)
|
|
|
|
|
|
groups.push(group)
|
|
|
|
|
|
}
|
|
|
|
|
|
groupMap.get(key).items.push(item)
|
|
|
|
|
|
})
|
|
|
|
|
|
return groups
|
|
|
|
|
|
}, [sessions])
|
|
|
|
|
|
|
|
|
|
|
|
const loadProjects = async () => {
|
2026-06-23 13:58:16 +00:00
|
|
|
|
try {
|
|
|
|
|
|
const res = await getMyProjects()
|
2026-07-24 07:37:22 +00:00
|
|
|
|
setProjects(res.data || [])
|
2026-06-23 13:58:16 +00:00
|
|
|
|
} catch (error) {
|
2026-07-24 07:37:22 +00:00
|
|
|
|
console.error(error)
|
2026-06-23 13:58:16 +00:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-24 07:37:22 +00:00
|
|
|
|
const loadModels = async () => {
|
2026-06-23 13:58:16 +00:00
|
|
|
|
try {
|
2026-07-24 07:37:22 +00:00
|
|
|
|
const res = await getLLMModelConfigs({ page: 1, page_size: 100, model_type: 'chat', is_active: true })
|
|
|
|
|
|
setModels(res.data || [])
|
2026-06-23 13:58:16 +00:00
|
|
|
|
} catch (error) {
|
2026-07-24 07:37:22 +00:00
|
|
|
|
console.error(error)
|
2026-06-23 13:58:16 +00:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-24 07:37:22 +00:00
|
|
|
|
const loadSessions = async () => {
|
|
|
|
|
|
setLoadingSessions(true)
|
2026-06-23 13:58:16 +00:00
|
|
|
|
try {
|
2026-07-24 07:37:22 +00:00
|
|
|
|
const res = await getChatSessions()
|
|
|
|
|
|
setSessions(res.data || [])
|
2026-06-23 13:58:16 +00:00
|
|
|
|
} catch (error) {
|
2026-07-24 07:37:22 +00:00
|
|
|
|
message.error('加载对话列表失败')
|
2026-06-23 13:58:16 +00:00
|
|
|
|
} finally {
|
2026-07-24 07:37:22 +00:00
|
|
|
|
setLoadingSessions(false)
|
2026-06-23 13:58:16 +00:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-24 07:37:22 +00:00
|
|
|
|
const openSession = async (id, extraQuery = {}) => {
|
|
|
|
|
|
if (!id) return
|
|
|
|
|
|
const nextQuery = new URLSearchParams()
|
|
|
|
|
|
nextQuery.set('session_id', String(id))
|
|
|
|
|
|
if (extraQuery.message_id) {
|
|
|
|
|
|
nextQuery.set('message_id', String(extraQuery.message_id))
|
|
|
|
|
|
}
|
|
|
|
|
|
navigate({ pathname: '/chat', search: `?${nextQuery.toString()}` }, { replace: true })
|
|
|
|
|
|
setLoadingMessages(true)
|
2026-06-23 13:58:16 +00:00
|
|
|
|
try {
|
2026-07-24 07:37:22 +00:00
|
|
|
|
const res = await getChatMessages(id)
|
|
|
|
|
|
setMessages(res.data || [])
|
|
|
|
|
|
setCurrentSession(sessions.find((item) => item.session_id === id) || null)
|
2026-06-23 13:58:16 +00:00
|
|
|
|
} catch (error) {
|
2026-07-24 07:37:22 +00:00
|
|
|
|
message.error('加载对话失败')
|
2026-06-23 13:58:16 +00:00
|
|
|
|
} finally {
|
2026-07-24 07:37:22 +00:00
|
|
|
|
setLoadingMessages(false)
|
2026-06-23 13:58:16 +00:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-24 07:37:22 +00:00
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
loadProjects()
|
|
|
|
|
|
loadModels()
|
|
|
|
|
|
loadSessions()
|
|
|
|
|
|
}, [])
|
|
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
if (newMode) {
|
|
|
|
|
|
setCurrentSession(null)
|
|
|
|
|
|
setMessages([])
|
|
|
|
|
|
}
|
|
|
|
|
|
}, [newMode])
|
|
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
if (!sessionId || newMode) {
|
|
|
|
|
|
if (!newMode) {
|
|
|
|
|
|
setCurrentSession(null)
|
|
|
|
|
|
setMessages([])
|
|
|
|
|
|
}
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
if (skipNextOpenSessionRef.current === sessionId) {
|
|
|
|
|
|
skipNextOpenSessionRef.current = null
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
openSession(sessionId)
|
|
|
|
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
|
|
|
|
}, [sessionId, sessions.length, newMode])
|
|
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
const target = searchParams.get('message_id')
|
|
|
|
|
|
if (!target) return
|
|
|
|
|
|
const targetMessage = messageRefs.current.get(String(target))
|
|
|
|
|
|
if (!targetMessage) return
|
2026-06-23 13:58:16 +00:00
|
|
|
|
|
2026-07-24 07:37:22 +00:00
|
|
|
|
targetMessage.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
|
|
|
|
|
const nextSearchParams = new URLSearchParams(searchParams)
|
|
|
|
|
|
nextSearchParams.delete('message_id')
|
|
|
|
|
|
setSearchParams(nextSearchParams, { replace: true })
|
|
|
|
|
|
}, [messages, searchParams, setSearchParams])
|
|
|
|
|
|
|
|
|
|
|
|
const handleStartNew = () => {
|
|
|
|
|
|
setNewQuestion('')
|
|
|
|
|
|
setNewProjectId(undefined)
|
|
|
|
|
|
setNewModelId(undefined)
|
|
|
|
|
|
navigate('/chat/new')
|
2026-06-23 13:58:16 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const handleCreateSession = async () => {
|
2026-07-24 07:37:22 +00:00
|
|
|
|
if (sending) return
|
|
|
|
|
|
const question = newQuestion.trim()
|
|
|
|
|
|
if (!question) {
|
|
|
|
|
|
message.warning('请输入问题')
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
if (!newProjectId) {
|
|
|
|
|
|
message.warning('请选择项目')
|
2026-06-23 13:58:16 +00:00
|
|
|
|
return
|
|
|
|
|
|
}
|
2026-07-24 07:37:22 +00:00
|
|
|
|
if (!newModelId) {
|
|
|
|
|
|
message.warning('请选择模型')
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
const title = question.length > 24 ? `${question.slice(0, 24)}...` : question
|
|
|
|
|
|
setSending(true)
|
|
|
|
|
|
let session = null
|
2026-06-23 13:58:16 +00:00
|
|
|
|
try {
|
2026-07-24 07:37:22 +00:00
|
|
|
|
const res = await createChatSession(newProjectId, newModelId, title)
|
|
|
|
|
|
session = res.data
|
2026-06-23 13:58:16 +00:00
|
|
|
|
} catch (error) {
|
2026-07-24 07:37:22 +00:00
|
|
|
|
message.error(error.response?.data?.detail || error.message || '新建对话失败')
|
|
|
|
|
|
setSending(false)
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const nextSession = {
|
|
|
|
|
|
session_id: session.session_id,
|
|
|
|
|
|
project_id: session.project_id ?? newProjectId,
|
|
|
|
|
|
llm_config_id: session.llm_config_id ?? newModelId,
|
|
|
|
|
|
title: session.title || title,
|
|
|
|
|
|
created_at: session.created_at,
|
|
|
|
|
|
updated_at: session.updated_at || session.created_at,
|
|
|
|
|
|
}
|
|
|
|
|
|
const optimisticMessage = {
|
|
|
|
|
|
id: `tmp-${Date.now()}`,
|
|
|
|
|
|
role: 'user',
|
|
|
|
|
|
content: question,
|
|
|
|
|
|
created_at: new Date().toISOString(),
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
setSessions((prev) => [
|
|
|
|
|
|
nextSession,
|
|
|
|
|
|
...prev.filter((item) => item.session_id !== nextSession.session_id),
|
|
|
|
|
|
])
|
|
|
|
|
|
setCurrentSession(nextSession)
|
|
|
|
|
|
setNewQuestion('')
|
|
|
|
|
|
skipNextOpenSessionRef.current = session.session_id
|
|
|
|
|
|
navigate(`/chat?session_id=${session.session_id}`, { replace: true })
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
await sendMessageWithStream(nextSession, question, {
|
|
|
|
|
|
initialMessages: [optimisticMessage],
|
|
|
|
|
|
initialUserMessageId: optimisticMessage.id,
|
|
|
|
|
|
includeUserMessage: false,
|
|
|
|
|
|
})
|
|
|
|
|
|
await loadSessions()
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
message.error(error.response?.data?.detail || error.message || '对话已创建,但首条消息发送失败')
|
2026-06-23 13:58:16 +00:00
|
|
|
|
} finally {
|
2026-07-24 07:37:22 +00:00
|
|
|
|
setSending(false)
|
2026-06-23 13:58:16 +00:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-24 07:37:22 +00:00
|
|
|
|
const handleSend = async () => {
|
|
|
|
|
|
if (sending) return
|
|
|
|
|
|
const content = inputValue.trim()
|
|
|
|
|
|
if (!content || !currentSession) return
|
|
|
|
|
|
setSending(true)
|
2026-06-23 13:58:16 +00:00
|
|
|
|
setInputValue('')
|
2026-07-24 07:37:22 +00:00
|
|
|
|
try {
|
|
|
|
|
|
await sendMessageWithStream(currentSession, content)
|
|
|
|
|
|
await loadSessions()
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
message.error(error.response?.data?.detail || error.message || '发送消息失败')
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
setSending(false)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const sendMessageWithStream = async (session, content, options = {}) => {
|
|
|
|
|
|
const userMessage = {
|
|
|
|
|
|
id: `tmp-user-${Date.now()}`,
|
|
|
|
|
|
role: 'user',
|
|
|
|
|
|
content,
|
|
|
|
|
|
created_at: new Date().toISOString(),
|
|
|
|
|
|
}
|
|
|
|
|
|
const assistantMessage = {
|
|
|
|
|
|
id: `tmp-assistant-${Date.now()}`,
|
|
|
|
|
|
role: 'assistant',
|
|
|
|
|
|
content: '',
|
|
|
|
|
|
references: [],
|
|
|
|
|
|
status: 'thinking',
|
|
|
|
|
|
referencesVisible: false,
|
|
|
|
|
|
created_at: new Date().toISOString(),
|
|
|
|
|
|
}
|
|
|
|
|
|
const baseMessages = options.initialMessages ?? messages
|
|
|
|
|
|
const nextMessages = options.includeUserMessage === false
|
|
|
|
|
|
? [...baseMessages, assistantMessage]
|
|
|
|
|
|
: [...baseMessages, userMessage, assistantMessage]
|
|
|
|
|
|
|
|
|
|
|
|
setMessages(nextMessages)
|
|
|
|
|
|
|
|
|
|
|
|
// 流式过程中后端会回传真实消息 id(ids 事件),这里用可变变量持续追踪当前
|
|
|
|
|
|
// 助手消息的 id:替换为真实 id 后,后续 onChunk/onDone 仍能命中同一条消息。
|
|
|
|
|
|
let assistantMsgId = assistantMessage.id
|
|
|
|
|
|
const tmpUserId = options.initialUserMessageId ?? userMessage.id
|
2026-06-23 13:58:16 +00:00
|
|
|
|
|
|
|
|
|
|
try {
|
2026-07-24 07:37:22 +00:00
|
|
|
|
await sendChatMessageStream(session.session_id, content, {
|
|
|
|
|
|
onIds: (data) => {
|
|
|
|
|
|
const realUserId = data?.user_message_id
|
|
|
|
|
|
const realAssistantId = data?.assistant_message_id
|
|
|
|
|
|
if (realAssistantId != null) assistantMsgId = realAssistantId
|
|
|
|
|
|
setMessages((prev) => prev.map((item) => {
|
|
|
|
|
|
if (realUserId != null && item.id === tmpUserId) {
|
|
|
|
|
|
return { ...item, id: realUserId }
|
|
|
|
|
|
}
|
|
|
|
|
|
if (realAssistantId != null && item.id === assistantMessage.id) {
|
|
|
|
|
|
return { ...item, id: realAssistantId }
|
|
|
|
|
|
}
|
|
|
|
|
|
return item
|
|
|
|
|
|
}))
|
|
|
|
|
|
},
|
|
|
|
|
|
onReferences: (refs) => {
|
|
|
|
|
|
setMessages((prev) => prev.map((item) => (
|
|
|
|
|
|
item.id === assistantMsgId ? { ...item, references: refs || [] } : item
|
|
|
|
|
|
)))
|
2026-06-23 13:58:16 +00:00
|
|
|
|
},
|
2026-07-24 07:37:22 +00:00
|
|
|
|
onChunk: (chunk) => {
|
|
|
|
|
|
setMessages((prev) => prev.map((item) => (
|
|
|
|
|
|
item.id === assistantMsgId ? { ...item, content: `${item.content}${chunk}`, status: 'streaming' } : item
|
|
|
|
|
|
)))
|
2026-06-23 13:58:16 +00:00
|
|
|
|
},
|
2026-07-24 07:37:22 +00:00
|
|
|
|
onTitle: (data) => {
|
|
|
|
|
|
const nextTitle = data?.title
|
|
|
|
|
|
if (!nextTitle) return
|
|
|
|
|
|
const targetId = data.session_id ?? session.session_id
|
|
|
|
|
|
setSessions((prev) => prev.map((item) => (
|
|
|
|
|
|
item.session_id === targetId ? { ...item, title: nextTitle } : item
|
|
|
|
|
|
)))
|
|
|
|
|
|
setCurrentSession((prev) => (
|
|
|
|
|
|
prev && prev.session_id === targetId ? { ...prev, title: nextTitle } : prev
|
|
|
|
|
|
))
|
|
|
|
|
|
},
|
|
|
|
|
|
onDone: (data) => {
|
|
|
|
|
|
setMessages((prev) => prev.map((item) => (
|
|
|
|
|
|
item.id === assistantMsgId
|
|
|
|
|
|
? {
|
|
|
|
|
|
...item,
|
|
|
|
|
|
content: data?.content ?? item.content,
|
|
|
|
|
|
status: 'done',
|
|
|
|
|
|
referencesVisible: true,
|
|
|
|
|
|
}
|
|
|
|
|
|
: item
|
|
|
|
|
|
)))
|
|
|
|
|
|
},
|
|
|
|
|
|
})
|
2026-06-23 13:58:16 +00:00
|
|
|
|
} catch (error) {
|
2026-07-24 07:37:22 +00:00
|
|
|
|
setMessages((prev) => prev.map((item) => (
|
|
|
|
|
|
item.id === assistantMsgId
|
|
|
|
|
|
? {
|
|
|
|
|
|
...item,
|
|
|
|
|
|
status: 'error',
|
|
|
|
|
|
error: error.message || '回答生成失败,请稍后重试',
|
|
|
|
|
|
referencesVisible: true,
|
|
|
|
|
|
}
|
|
|
|
|
|
: item
|
|
|
|
|
|
)))
|
|
|
|
|
|
throw error
|
2026-06-23 13:58:16 +00:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-24 07:37:22 +00:00
|
|
|
|
const handleDeleteMessage = (item) => {
|
|
|
|
|
|
const isTemp = typeof item.id === 'string' && item.id.startsWith('tmp-')
|
|
|
|
|
|
if (isTemp) {
|
|
|
|
|
|
setMessages((prev) => prev.filter((m) => m.id !== item.id))
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
2026-06-23 13:58:16 +00:00
|
|
|
|
Modal.confirm({
|
2026-07-24 07:37:22 +00:00
|
|
|
|
title: '删除消息',
|
|
|
|
|
|
content: '确定删除这条消息吗?删除后无法恢复。',
|
|
|
|
|
|
okText: '删除',
|
|
|
|
|
|
okType: 'danger',
|
|
|
|
|
|
cancelText: '取消',
|
2026-06-23 13:58:16 +00:00
|
|
|
|
onOk: async () => {
|
|
|
|
|
|
try {
|
2026-07-24 07:37:22 +00:00
|
|
|
|
await deleteChatMessage(item.id)
|
|
|
|
|
|
setMessages((prev) => prev.filter((m) => m.id !== item.id))
|
2026-06-23 13:58:16 +00:00
|
|
|
|
} catch (error) {
|
2026-07-24 07:37:22 +00:00
|
|
|
|
message.error(error.response?.data?.detail || '删除失败')
|
2026-06-23 13:58:16 +00:00
|
|
|
|
}
|
|
|
|
|
|
},
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-24 07:37:22 +00:00
|
|
|
|
const handleSearch = async () => {
|
|
|
|
|
|
const keyword = searchKeyword.trim()
|
|
|
|
|
|
if (!keyword) {
|
|
|
|
|
|
searchRequestIdRef.current += 1
|
|
|
|
|
|
setSearchResults([])
|
|
|
|
|
|
setSearchedKeyword('')
|
|
|
|
|
|
setHasSearched(false)
|
|
|
|
|
|
setSearchLoading(false)
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
const requestId = ++searchRequestIdRef.current
|
|
|
|
|
|
setSearchLoading(true)
|
|
|
|
|
|
try {
|
|
|
|
|
|
const res = await searchChatMessages(keyword)
|
|
|
|
|
|
if (requestId !== searchRequestIdRef.current) return
|
|
|
|
|
|
setSearchResults((res.data || []).filter((item) => item.role === 'user' || item.role === 'assistant'))
|
|
|
|
|
|
setSearchedKeyword(keyword)
|
|
|
|
|
|
setHasSearched(true)
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
if (requestId === searchRequestIdRef.current) {
|
|
|
|
|
|
message.error(error.response?.data?.detail || error.message || '搜索失败')
|
|
|
|
|
|
}
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
if (requestId === searchRequestIdRef.current) {
|
|
|
|
|
|
setSearchLoading(false)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const handleSearchKeywordChange = (event) => {
|
|
|
|
|
|
const value = event.target.value
|
|
|
|
|
|
setSearchKeyword(value)
|
|
|
|
|
|
if (!value.trim()) {
|
|
|
|
|
|
searchRequestIdRef.current += 1
|
|
|
|
|
|
setSearchResults([])
|
|
|
|
|
|
setSearchedKeyword('')
|
|
|
|
|
|
setHasSearched(false)
|
|
|
|
|
|
setSearchLoading(false)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const handleSelectSearchResult = async (item) => {
|
|
|
|
|
|
setSearchVisible(false)
|
|
|
|
|
|
skipNextOpenSessionRef.current = item.session_id
|
|
|
|
|
|
await openSession(item.session_id, { message_id: item.message_id })
|
2026-06-23 13:58:16 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-24 07:37:22 +00:00
|
|
|
|
const handleRenameSession = async () => {
|
|
|
|
|
|
const title = renameTitle.trim()
|
|
|
|
|
|
if (!renameSession || !title) {
|
|
|
|
|
|
message.warning('请输入对话名称')
|
2026-06-23 13:58:16 +00:00
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
try {
|
2026-07-24 07:37:22 +00:00
|
|
|
|
await updateChatSessionTitle(renameSession.session_id, title)
|
|
|
|
|
|
setSessions((prev) => prev.map((item) => (
|
|
|
|
|
|
item.session_id === renameSession.session_id ? { ...item, title } : item
|
|
|
|
|
|
)))
|
|
|
|
|
|
if (currentSession?.session_id === renameSession.session_id) {
|
|
|
|
|
|
setCurrentSession((prev) => ({ ...prev, title }))
|
|
|
|
|
|
}
|
|
|
|
|
|
setRenameVisible(false)
|
|
|
|
|
|
setRenameSession(null)
|
|
|
|
|
|
setRenameTitle('')
|
2026-06-23 13:58:16 +00:00
|
|
|
|
} catch (error) {
|
2026-07-24 07:37:22 +00:00
|
|
|
|
message.error(error.response?.data?.detail || '重命名失败')
|
2026-06-23 13:58:16 +00:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-24 07:37:22 +00:00
|
|
|
|
const handleDeleteSession = (session) => {
|
|
|
|
|
|
Modal.confirm({
|
|
|
|
|
|
title: '删除对话',
|
|
|
|
|
|
content: `确定删除「${session.title}」吗?删除后无法恢复。`,
|
|
|
|
|
|
okText: '删除',
|
|
|
|
|
|
okType: 'danger',
|
|
|
|
|
|
cancelText: '取消',
|
|
|
|
|
|
onOk: async () => {
|
|
|
|
|
|
try {
|
|
|
|
|
|
await deleteChatSession(session.session_id)
|
|
|
|
|
|
await loadSessions()
|
|
|
|
|
|
if (currentSession?.session_id === session.session_id) {
|
|
|
|
|
|
setCurrentSession(null)
|
|
|
|
|
|
setMessages([])
|
|
|
|
|
|
navigate('/chat', { replace: true })
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
message.error(error.response?.data?.detail || '删除失败')
|
|
|
|
|
|
}
|
|
|
|
|
|
},
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
2026-06-23 13:58:16 +00:00
|
|
|
|
|
2026-07-24 07:37:22 +00:00
|
|
|
|
const renderSessionList = () => (
|
|
|
|
|
|
<div className="chat-history-panel">
|
|
|
|
|
|
{loadingSessions ? (
|
|
|
|
|
|
<div className="chat-panel-loading">
|
|
|
|
|
|
<Spin />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
) : sessions.length === 0 ? (
|
|
|
|
|
|
<Empty description="暂无对话记录" />
|
|
|
|
|
|
) : (
|
|
|
|
|
|
groupedSessions.map((group) => (
|
|
|
|
|
|
<div className="chat-session-group" key={group.key}>
|
|
|
|
|
|
<div className="chat-session-group-title">{group.label}</div>
|
|
|
|
|
|
<List
|
|
|
|
|
|
dataSource={group.items}
|
|
|
|
|
|
renderItem={(item) => {
|
|
|
|
|
|
const active = currentSession?.session_id === item.session_id
|
|
|
|
|
|
const project = projects.find((project) => project.id === item.project_id)
|
|
|
|
|
|
const menuItems = [
|
|
|
|
|
|
{
|
|
|
|
|
|
key: 'rename',
|
|
|
|
|
|
icon: <EditOutlined />,
|
|
|
|
|
|
label: '重命名',
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
key: 'delete',
|
|
|
|
|
|
icon: <DeleteOutlined />,
|
|
|
|
|
|
label: '删除',
|
|
|
|
|
|
danger: true,
|
|
|
|
|
|
},
|
|
|
|
|
|
]
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div
|
|
|
|
|
|
className={`chat-session-item${active ? ' active' : ''}`}
|
|
|
|
|
|
onClick={() => openSession(item.session_id)}
|
|
|
|
|
|
>
|
|
|
|
|
|
<div className="chat-session-item-top">
|
|
|
|
|
|
<div className="chat-session-title-area">
|
|
|
|
|
|
<div className="chat-session-title-row">
|
|
|
|
|
|
<div className="chat-session-title">{item.title}</div>
|
|
|
|
|
|
<Dropdown
|
|
|
|
|
|
menu={{
|
|
|
|
|
|
items: menuItems,
|
|
|
|
|
|
onClick: ({ key, domEvent }) => {
|
|
|
|
|
|
domEvent.stopPropagation()
|
|
|
|
|
|
if (key === 'rename') {
|
|
|
|
|
|
setRenameSession(item)
|
|
|
|
|
|
setRenameTitle(item.title)
|
|
|
|
|
|
setRenameVisible(true)
|
|
|
|
|
|
}
|
|
|
|
|
|
if (key === 'delete') {
|
|
|
|
|
|
handleDeleteSession(item)
|
|
|
|
|
|
}
|
|
|
|
|
|
},
|
|
|
|
|
|
}}
|
|
|
|
|
|
trigger={['click']}
|
|
|
|
|
|
>
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
className="chat-session-more"
|
|
|
|
|
|
onClick={(event) => event.stopPropagation()}
|
|
|
|
|
|
aria-label="更多操作"
|
|
|
|
|
|
>
|
|
|
|
|
|
<MoreOutlined />
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</Dropdown>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="chat-session-meta">
|
|
|
|
|
|
<Tag className="chat-session-meta-text" icon={<FolderOutlined />}>{project?.name || '未命名项目'}</Tag>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)
|
|
|
|
|
|
}}
|
2026-06-23 13:58:16 +00:00
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
2026-07-24 07:37:22 +00:00
|
|
|
|
))
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)
|
2026-06-23 13:58:16 +00:00
|
|
|
|
|
2026-07-24 07:37:22 +00:00
|
|
|
|
const renderMessages = () => (
|
|
|
|
|
|
<>
|
|
|
|
|
|
<div className="chat-message-list">
|
|
|
|
|
|
{loadingMessages ? (
|
|
|
|
|
|
<div className="chat-panel-loading">
|
2026-06-23 13:58:16 +00:00
|
|
|
|
<Spin />
|
2026-07-24 07:37:22 +00:00
|
|
|
|
</div>
|
|
|
|
|
|
) : messages.length === 0 ? (
|
|
|
|
|
|
<Empty description="开始输入问题" />
|
|
|
|
|
|
) : (
|
|
|
|
|
|
messages.map((item) => {
|
|
|
|
|
|
const isUser = item.role === 'user'
|
|
|
|
|
|
const refs = item.references || []
|
|
|
|
|
|
const hasContent = Boolean((item.content || '').trim())
|
|
|
|
|
|
// 流式中的占位(status=thinking)或刷新后读到的空助手消息都显示「思考中」
|
|
|
|
|
|
const isThinking = !isUser && !hasContent && !['streaming', 'error'].includes(item.status)
|
|
|
|
|
|
const isStreaming = !isUser && item.status === 'streaming'
|
|
|
|
|
|
const isError = !isUser && item.status === 'error'
|
|
|
|
|
|
const showReferences = !isUser && item.referencesVisible !== false && refs.length > 0
|
|
|
|
|
|
const canDelete = typeof item.id === 'number' || /^\d+$/.test(String(item.id))
|
|
|
|
|
|
const projectId = currentSession?.project_id
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div
|
|
|
|
|
|
key={item.id}
|
|
|
|
|
|
ref={(node) => {
|
|
|
|
|
|
if (node) {
|
|
|
|
|
|
messageRefs.current.set(String(item.id), node)
|
|
|
|
|
|
} else {
|
|
|
|
|
|
messageRefs.current.delete(String(item.id))
|
|
|
|
|
|
}
|
2026-06-23 13:58:16 +00:00
|
|
|
|
}}
|
2026-07-24 07:37:22 +00:00
|
|
|
|
className={`chat-message-row ${isUser ? 'user' : 'assistant'}`}
|
2026-06-23 13:58:16 +00:00
|
|
|
|
>
|
2026-07-24 07:37:22 +00:00
|
|
|
|
{!isUser && <Avatar className="chat-avatar assistant" icon={<RobotOutlined />} />}
|
|
|
|
|
|
<div className="chat-message-column">
|
|
|
|
|
|
<div className={`chat-message-bubble ${isUser ? 'user' : 'assistant'}`}>
|
|
|
|
|
|
{isUser ? (
|
|
|
|
|
|
<div className="chat-plain-text">{item.content}</div>
|
2026-06-23 13:58:16 +00:00
|
|
|
|
) : (
|
2026-07-24 07:37:22 +00:00
|
|
|
|
<>
|
|
|
|
|
|
{isError && !hasContent ? (
|
|
|
|
|
|
<div className="chat-message-error">
|
|
|
|
|
|
{item.error || '回答生成失败,请稍后重试'}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
) : isThinking ? (
|
|
|
|
|
|
<div className="chat-thinking">
|
|
|
|
|
|
<span className="chat-thinking-text">正在思考</span>
|
|
|
|
|
|
<span className="chat-thinking-dots">
|
|
|
|
|
|
<i />
|
|
|
|
|
|
<i />
|
|
|
|
|
|
<i />
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<div className={`chat-markdown${isStreaming ? ' streaming' : ''}`}>
|
|
|
|
|
|
<ReactMarkdown
|
|
|
|
|
|
remarkPlugins={[remarkGfm]}
|
|
|
|
|
|
rehypePlugins={[rehypeHighlight, rehypeCitationSup]}
|
|
|
|
|
|
components={{
|
|
|
|
|
|
sup: ({ children, ...props }) => {
|
|
|
|
|
|
const citationId = Number(props['data-citation-id'])
|
|
|
|
|
|
const ref = refs.find((itemRef) => Number(itemRef.citation_id) === citationId)
|
|
|
|
|
|
if (!ref) {
|
|
|
|
|
|
return <sup {...props}>{children}</sup>
|
|
|
|
|
|
}
|
|
|
|
|
|
return (
|
|
|
|
|
|
<Popover
|
|
|
|
|
|
trigger="hover"
|
|
|
|
|
|
placement="top"
|
|
|
|
|
|
overlayClassName="chat-citation-popover"
|
|
|
|
|
|
content={(
|
|
|
|
|
|
<div className="chat-citation-preview">
|
|
|
|
|
|
<Text type="secondary" className="chat-citation-preview-file">
|
|
|
|
|
|
{ref.file_name || ref.file_path}
|
|
|
|
|
|
</Text>
|
|
|
|
|
|
<div className="chat-citation-preview-content">
|
|
|
|
|
|
<ReactMarkdown
|
|
|
|
|
|
remarkPlugins={[remarkGfm]}
|
|
|
|
|
|
rehypePlugins={[rehypeHighlight]}
|
|
|
|
|
|
>
|
|
|
|
|
|
{getReferenceExcerpt(ref) || '暂无引用片段'}
|
|
|
|
|
|
</ReactMarkdown>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
>
|
|
|
|
|
|
<sup {...props}>{children}</sup>
|
|
|
|
|
|
</Popover>
|
|
|
|
|
|
)
|
|
|
|
|
|
},
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
|
|
|
|
|
{item.content}
|
|
|
|
|
|
</ReactMarkdown>
|
|
|
|
|
|
{isError && (
|
|
|
|
|
|
<div className="chat-message-error chat-message-error-inline">
|
|
|
|
|
|
{item.error || '回答生成中断,请稍后重试'}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
{showReferences && (
|
|
|
|
|
|
<div className="chat-references">
|
|
|
|
|
|
<Text type="secondary" className="chat-references-label">关联文档</Text>
|
|
|
|
|
|
<Space wrap>
|
|
|
|
|
|
{refs.map((ref) => {
|
|
|
|
|
|
return (
|
|
|
|
|
|
<Tag
|
|
|
|
|
|
key={`${ref.citation_id}-${ref.file_path || ref.file_name}`}
|
|
|
|
|
|
color="blue"
|
|
|
|
|
|
className="chat-reference-link"
|
|
|
|
|
|
onClick={() => {
|
|
|
|
|
|
openDocument(ref, projectId)
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
|
|
|
|
|
[{ref.citation_id}] {ref.file_name}
|
|
|
|
|
|
</Tag>
|
|
|
|
|
|
)
|
|
|
|
|
|
})}
|
|
|
|
|
|
</Space>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</>
|
2026-06-23 13:58:16 +00:00
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
2026-07-24 07:37:22 +00:00
|
|
|
|
{(isUser || (!isThinking && !isStreaming)) && (
|
|
|
|
|
|
<MessageActions
|
|
|
|
|
|
content={item.content}
|
|
|
|
|
|
onDelete={canDelete ? () => handleDeleteMessage(item) : undefined}
|
2026-06-23 13:58:16 +00:00
|
|
|
|
/>
|
2026-07-24 07:37:22 +00:00
|
|
|
|
)}
|
2026-06-23 13:58:16 +00:00
|
|
|
|
</div>
|
2026-07-24 07:37:22 +00:00
|
|
|
|
{isUser && <Avatar className="chat-avatar user" icon={<UserOutlined />} />}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)
|
|
|
|
|
|
})
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="chat-composer-shell">
|
|
|
|
|
|
<TextArea
|
|
|
|
|
|
className="chat-composer-input"
|
|
|
|
|
|
value={inputValue}
|
|
|
|
|
|
onChange={(e) => setInputValue(e.target.value)}
|
|
|
|
|
|
autoSize={{ minRows: 1, maxRows: 8 }}
|
|
|
|
|
|
placeholder="随心输入"
|
|
|
|
|
|
onPressEnter={(e) => {
|
|
|
|
|
|
if (!e.shiftKey) {
|
|
|
|
|
|
e.preventDefault()
|
|
|
|
|
|
handleSend()
|
|
|
|
|
|
}
|
|
|
|
|
|
}}
|
|
|
|
|
|
/>
|
|
|
|
|
|
<div className="chat-composer-actions">
|
|
|
|
|
|
<div className="chat-composer-left">
|
|
|
|
|
|
<span className="chat-composer-pill chat-composer-kb">
|
|
|
|
|
|
<FolderOutlined className="chat-composer-pill-icon" />
|
|
|
|
|
|
<span className="chat-composer-pill-text">
|
|
|
|
|
|
{projects.find((project) => project.id === currentSession?.project_id)?.name || '-'}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="chat-composer-right">
|
|
|
|
|
|
<span className="chat-composer-pill chat-composer-model">
|
|
|
|
|
|
<RobotOutlined className="chat-composer-pill-icon" />
|
|
|
|
|
|
<span className="chat-composer-pill-text">
|
|
|
|
|
|
{models.find((model) => model.config_id === currentSession?.llm_config_id)?.model_name || '-'}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</span>
|
|
|
|
|
|
<Button
|
|
|
|
|
|
shape="circle"
|
|
|
|
|
|
icon={<ArrowUpOutlined />}
|
|
|
|
|
|
onClick={handleSend}
|
|
|
|
|
|
loading={sending}
|
|
|
|
|
|
disabled={!canSendMessage}
|
|
|
|
|
|
aria-disabled={!canSendMessage || sending}
|
|
|
|
|
|
className="chat-send-button"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
2026-06-23 13:58:16 +00:00
|
|
|
|
</div>
|
2026-07-24 07:37:22 +00:00
|
|
|
|
</div>
|
|
|
|
|
|
</>
|
|
|
|
|
|
)
|
2026-06-23 13:58:16 +00:00
|
|
|
|
|
2026-07-24 07:37:22 +00:00
|
|
|
|
const renderChatShell = () => (
|
|
|
|
|
|
<div className="chat-shell">
|
|
|
|
|
|
<div className="chat-header">
|
|
|
|
|
|
<div className="chat-header-main">
|
|
|
|
|
|
<Text className="chat-header-title">{currentSession?.title || '对话'}</Text>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
{renderMessages()}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)
|
2026-06-23 13:58:16 +00:00
|
|
|
|
|
2026-07-24 07:37:22 +00:00
|
|
|
|
const renderNewShell = () => (
|
|
|
|
|
|
<div className="chat-start-shell">
|
|
|
|
|
|
<div className="chat-new-page-card">
|
|
|
|
|
|
<div className="chat-start-title">你希望了解什么?</div>
|
|
|
|
|
|
<div className="chat-composer-shell chat-new-composer">
|
|
|
|
|
|
<TextArea
|
|
|
|
|
|
value={newQuestion}
|
|
|
|
|
|
onChange={(e) => setNewQuestion(e.target.value)}
|
|
|
|
|
|
className="chat-composer-input"
|
|
|
|
|
|
autoSize={{ minRows: 2, maxRows: 10 }}
|
|
|
|
|
|
placeholder="随心输入"
|
|
|
|
|
|
onPressEnter={(e) => {
|
|
|
|
|
|
if (!e.shiftKey) {
|
|
|
|
|
|
e.preventDefault()
|
|
|
|
|
|
handleCreateSession()
|
|
|
|
|
|
}
|
|
|
|
|
|
}}
|
|
|
|
|
|
/>
|
|
|
|
|
|
<div className="chat-composer-actions">
|
|
|
|
|
|
<div className="chat-composer-left">
|
|
|
|
|
|
<Popover
|
|
|
|
|
|
open={projectPickerOpen}
|
|
|
|
|
|
onOpenChange={setProjectPickerOpen}
|
|
|
|
|
|
trigger="click"
|
|
|
|
|
|
placement="topLeft"
|
|
|
|
|
|
content={
|
|
|
|
|
|
<div className="chat-project-picker">
|
|
|
|
|
|
<Select
|
|
|
|
|
|
value={newProjectId}
|
|
|
|
|
|
placeholder="选择知识库"
|
|
|
|
|
|
options={projects.map((item) => ({ label: item.name, value: item.id }))}
|
|
|
|
|
|
onChange={(value) => {
|
|
|
|
|
|
setNewProjectId(value)
|
|
|
|
|
|
setProjectPickerOpen(false)
|
|
|
|
|
|
}}
|
|
|
|
|
|
suffixIcon={<DownOutlined />}
|
|
|
|
|
|
showSearch
|
|
|
|
|
|
optionFilterProp="label"
|
|
|
|
|
|
className="chat-project-picker-select"
|
|
|
|
|
|
/>
|
2026-06-23 13:58:16 +00:00
|
|
|
|
</div>
|
2026-07-24 07:37:22 +00:00
|
|
|
|
}
|
|
|
|
|
|
>
|
|
|
|
|
|
<button type="button" className="chat-composer-plus" aria-label="选择知识库">
|
|
|
|
|
|
<PlusOutlined />
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</Popover>
|
|
|
|
|
|
{currentProject && (
|
|
|
|
|
|
<span className="chat-composer-pill chat-composer-kb">
|
|
|
|
|
|
<FolderOutlined className="chat-composer-pill-icon" />
|
|
|
|
|
|
<span className="chat-composer-pill-text">{currentProject.name}</span>
|
|
|
|
|
|
</span>
|
2026-06-23 13:58:16 +00:00
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
2026-07-24 07:37:22 +00:00
|
|
|
|
<div className="chat-composer-right">
|
|
|
|
|
|
<Select
|
|
|
|
|
|
value={newModelId}
|
|
|
|
|
|
placeholder="选择模型"
|
|
|
|
|
|
options={models.map((item) => ({ label: item.model_name, value: item.config_id }))}
|
|
|
|
|
|
onChange={setNewModelId}
|
|
|
|
|
|
suffixIcon={<DownOutlined />}
|
|
|
|
|
|
showSearch
|
|
|
|
|
|
optionFilterProp="label"
|
|
|
|
|
|
className="chat-composer-model-select"
|
2026-06-23 13:58:16 +00:00
|
|
|
|
/>
|
|
|
|
|
|
<Button
|
2026-07-24 07:37:22 +00:00
|
|
|
|
shape="circle"
|
|
|
|
|
|
icon={<ArrowUpOutlined />}
|
|
|
|
|
|
onClick={handleCreateSession}
|
|
|
|
|
|
loading={sending}
|
|
|
|
|
|
disabled={!canCreateSession}
|
|
|
|
|
|
aria-disabled={!canCreateSession || sending}
|
|
|
|
|
|
className="chat-send-button"
|
|
|
|
|
|
/>
|
2026-06-23 13:58:16 +00:00
|
|
|
|
</div>
|
2026-07-24 07:37:22 +00:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div className="chat-page-shell">
|
|
|
|
|
|
<aside className="chat-left-panel">
|
|
|
|
|
|
<div className="chat-left-actions">
|
|
|
|
|
|
<button className="chat-action-entry" onClick={handleStartNew}>
|
|
|
|
|
|
<EditOutlined />
|
|
|
|
|
|
<span>新建对话</span>
|
|
|
|
|
|
</button>
|
|
|
|
|
|
<button className="chat-action-entry" onClick={() => setSearchVisible(true)}>
|
|
|
|
|
|
<SearchOutlined />
|
|
|
|
|
|
<span>搜索对话</span>
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
{renderSessionList()}
|
|
|
|
|
|
</aside>
|
|
|
|
|
|
|
|
|
|
|
|
<main className="chat-main-panel">
|
|
|
|
|
|
{currentSession && !newMode ? renderChatShell() : renderNewShell()}
|
|
|
|
|
|
</main>
|
|
|
|
|
|
|
|
|
|
|
|
<Modal
|
|
|
|
|
|
title="搜索聊天内容"
|
|
|
|
|
|
open={searchVisible}
|
|
|
|
|
|
onCancel={() => setSearchVisible(false)}
|
|
|
|
|
|
footer={null}
|
|
|
|
|
|
width={840}
|
|
|
|
|
|
destroyOnClose
|
|
|
|
|
|
className="chat-search-modal"
|
|
|
|
|
|
>
|
|
|
|
|
|
<Space.Compact className="chat-search-bar">
|
|
|
|
|
|
<Input
|
|
|
|
|
|
value={searchKeyword}
|
|
|
|
|
|
onChange={handleSearchKeywordChange}
|
|
|
|
|
|
placeholder="输入聊天关键词,仅搜索对话内容"
|
|
|
|
|
|
allowClear
|
|
|
|
|
|
onPressEnter={handleSearch}
|
|
|
|
|
|
/>
|
|
|
|
|
|
<Button type="primary" icon={<SearchOutlined />} loading={searchLoading} onClick={handleSearch}>
|
|
|
|
|
|
搜索
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</Space.Compact>
|
|
|
|
|
|
<div className="chat-search-result-list">
|
|
|
|
|
|
{searchLoading ? (
|
|
|
|
|
|
<div className="chat-panel-loading">
|
|
|
|
|
|
<Spin />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<List
|
|
|
|
|
|
dataSource={searchResults}
|
|
|
|
|
|
locale={{
|
|
|
|
|
|
emptyText: (
|
|
|
|
|
|
<Empty description={hasSearched ? `未找到“${searchedKeyword}”相关内容` : '输入关键词搜索聊天内容'} />
|
|
|
|
|
|
),
|
|
|
|
|
|
}}
|
|
|
|
|
|
renderItem={(item) => (
|
|
|
|
|
|
<List.Item className="chat-search-result-item" onClick={() => handleSelectSearchResult(item)}>
|
|
|
|
|
|
<List.Item.Meta
|
|
|
|
|
|
avatar={<Avatar icon={item.role === 'assistant' ? <RobotOutlined /> : <UserOutlined />} />}
|
|
|
|
|
|
title={
|
|
|
|
|
|
<Space wrap>
|
|
|
|
|
|
<Text strong>{item.session_title}</Text>
|
|
|
|
|
|
<Tag>{item.project_name}</Tag>
|
|
|
|
|
|
<Tag>{item.model_name}</Tag>
|
|
|
|
|
|
</Space>
|
|
|
|
|
|
}
|
|
|
|
|
|
description={
|
|
|
|
|
|
<>
|
|
|
|
|
|
<div className="chat-search-result-snippet">
|
|
|
|
|
|
<SearchHighlight
|
|
|
|
|
|
text={item.snippet || stripMarkdown(item.content)}
|
|
|
|
|
|
keyword={searchedKeyword}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="chat-search-result-time">{formatTime(item.created_at)}</div>
|
|
|
|
|
|
</>
|
|
|
|
|
|
}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</List.Item>
|
|
|
|
|
|
)}
|
|
|
|
|
|
/>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</Modal>
|
|
|
|
|
|
<Modal
|
|
|
|
|
|
title="重命名对话"
|
|
|
|
|
|
open={renameVisible}
|
|
|
|
|
|
onCancel={() => setRenameVisible(false)}
|
|
|
|
|
|
onOk={handleRenameSession}
|
|
|
|
|
|
okText="保存"
|
|
|
|
|
|
cancelText="取消"
|
|
|
|
|
|
>
|
|
|
|
|
|
<Input
|
|
|
|
|
|
value={renameTitle}
|
|
|
|
|
|
onChange={(event) => setRenameTitle(event.target.value)}
|
|
|
|
|
|
placeholder="输入新的对话名称"
|
|
|
|
|
|
maxLength={80}
|
|
|
|
|
|
showCount
|
|
|
|
|
|
/>
|
|
|
|
|
|
</Modal>
|
|
|
|
|
|
</div>
|
2026-06-23 13:58:16 +00:00
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export default Chat
|