import { useEffect, useMemo, useRef, useState } from 'react' import { useLocation, useNavigate, useSearchParams } from 'react-router-dom' import { Avatar, Alert, 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, StopOutlined, ReloadOutlined, LinkOutlined, LoadingOutlined, } 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 rehypeRaw from 'rehype-raw' import 'highlight.js/styles/github.css' import { createChatSession, deleteChatMessage, deleteChatSession, getChatMessages, getChatSessions, markMessageInterrupted, searchChatMessages, sendChatMessageStream, updateChatSessionTitle } from '@/api/chat' import { getMyProjects } from '@/api/project' import { getLLMModelConfigs } from '@/api/llmModelConfigs' import './Chat.css' const { TextArea } = Input const { Text } = Typography const CITATION_RE = /\[(\d+)\]/g function formatDuration(ms) { if (ms == null) return '' const totalSeconds = Math.max(0, ms) / 1000 if (totalSeconds < 60) return `${totalSeconds.toFixed(1)} 秒` const minutes = Math.floor(totalSeconds / 60) const seconds = Math.round(totalSeconds % 60) return `${minutes} 分 ${seconds} 秒` } function useElapsedTimer(startedAt, active) { const [now, setNow] = useState(Date.now()) useEffect(() => { if (!active) return undefined setNow(Date.now()) const timer = window.setInterval(() => setNow(Date.now()), 500) return () => window.clearInterval(timer) }, [active, startedAt]) return active ? Math.max(0, now - (startedAt || now)) : null } function ThinkingPanel({ active, log = [], startedAt, durationMs, status, visible, onToggle }) { const elapsed = useElapsedTimer(startedAt, active) if (active) { return (
思考中 · {formatDuration(elapsed)}
{log.length > 0 && (
{log.map((entry, index) => (
{entry.message} {entry.duration_ms != null && ( 用时 {formatDuration(entry.duration_ms)} )}
))}
)}
) } const hasLog = log.length > 0 const hasDuration = durationMs != null if (!hasLog && !hasDuration) return null return (
{visible && (
{log.map((entry, index) => (
{entry.message} {entry.duration_ms != null && ( 用时 {formatDuration(entry.duration_ms)} )}
))} {hasDuration && (
{status === 'interrupted' ? '已停止' : (status === 'error' ? '生成失败' : '完成')} · 用时 {formatDuration(durationMs)}
)}
)}
) } // rehype 插件:将正文中的 [n] 引用编号转换为上角标 ,便于与正文区分。 // 跳过 code/pre 节点,避免破坏代码块中的方括号内容。 function rehypeCitationSup() { // 按引用编号统计出现次数,使同一编号的多处引用可以精确区分 const occurrenceCounters = {} 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' && CITATION_RE.test(child.value)) { CITATION_RE.lastIndex = 0 let lastPush = 0 let match while ((match = CITATION_RE.exec(child.value)) !== null) { const citationId = match[1] // 即使标记位于代码块内也要计数,保持与后端按文本全文计数的顺序一致 const occIndex = occurrenceCounters[citationId] = (occurrenceCounters[citationId] ?? -1) + 1 if (!childInCode) { if (match.index > lastPush) { nextChildren.push({ type: 'text', value: child.value.slice(lastPush, match.index) }) } nextChildren.push({ type: 'element', tagName: 'sup', properties: { className: ['chat-citation'], 'data-citation-id': citationId, 'data-citation-occ': occIndex, }, children: [{ type: 'text', value: `[${citationId}]` }], }) lastPush = match.index + match[0].length } } if (childInCode) { nextChildren.push(child) } else if (lastPush < child.value.length) { nextChildren.push({ type: 'text', value: child.value.slice(lastPush) }) } } 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() } // 去掉 markdown 语法标记,使关键词能与渲染后的纯文本精确匹配(用于原文定位) function stripMarkdownForHighlight(text) { return String(text || '') .replace(/```[\s\S]*?```/g, ' ') .replace(/`([^`]*)`/g, '$1') .replace(/\*\*([^*]+)\*\*/g, '$1') .replace(/\*([^*]+)\*/g, '$1') .replace(/__([^_]+)__/g, '$1') .replace(/_([^_]+)_/g, '$1') .replace(/!\[([^\]]*)\]\([^)]*\)/g, '$1') .replace(/\[([^\]]+)\]\([^)]*\)/g, '$1') .replace(/^#{1,6}\s+/gm, '') .replace(/^\s*[-*+]\s+/gm, '') .replace(/^\s*\d+\.\s+/gm, '') .replace(/^\s*>\s+/gm, '') .replace(/~~([^~]+)~~/g, '$1') .trim() } function buildDocumentPreviewUrl(projectId, filePath, highlight) { if (!projectId || !filePath) return '' const params = new URLSearchParams() params.set('file', filePath) if (highlight) params.set('hl', highlight) return `/projects/${projectId}/docs?${params.toString()}` } // 跳转原文时优先用「支撑句原文」作为定位关键词,文档页据此高亮并滚动到命中位置 function getDocumentJumpKeyword(ref, occData) { const quoteText = (occData?.quotes?.[0]?.text || ref?.quotes?.[0]?.text || '').trim() if (quoteText) return stripMarkdownForHighlight(quoteText) const anchor = (ref?.anchor_text || '').trim() return anchor ? stripMarkdownForHighlight(anchor).slice(0, 32) : '' } function openDocument(ref, projectId, occData) { const previewUrl = buildDocumentPreviewUrl( ref?.project_id || projectId, ref?.file_path, getDocumentJumpKeyword(ref, occData) ) if (previewUrl) window.open(previewUrl, '_blank', 'noopener,noreferrer') } function getReferenceExcerpt(ref) { return (ref?.excerpt || ref?.anchor_text || '').trim() } function getReferenceContext(ref) { return (ref?.content || ref?.excerpt || ref?.anchor_text || '').trim() } function escapeAngleBrackets(value) { return String(value ?? '').replace(//g, '>') } // 渲染引用片段:命中的分块区间用「证据条」标出(chat-chunk-band)。 // 提问关键词不再高亮——检索是语义匹配,提问词与原文命中并无字面对应。 function highlightCitation(text, chunkText) { const escaped = escapeAngleBrackets(text) const chunk = escapeAngleBrackets(chunkText) // CommonMark 会在空行处断开段落,跨多段的分块不能整体包 ,否则标签错位 if (!chunk || /\r?\n\s*\r?\n/.test(chunk) || !escaped.includes(chunk)) { return escaped } const idx = escaped.indexOf(chunk) return `${escaped.slice(0, idx)}${chunk}${escaped.slice(idx + chunk.length)}` } function loadPendingStops() { try { return JSON.parse(sessionStorage.getItem('nex-chat-pending-stops') || '{}') } catch { return {} } } function savePendingStops(map) { try { sessionStorage.setItem('nex-chat-pending-stops', JSON.stringify(map)) } catch { // 忽略存储不可用的情况 } } function SearchHighlight({ text, keyword }) { const value = text || '' if (!keyword) return value return ( ) } function CitationMarkdown({ children }) { return ( {children}, }} > {children} ) } function ReferenceCard({ reference, projectId }) { return ( openDocument(reference, projectId)} onKeyDown={(event) => { if (event.key === 'Enter' || event.key === ' ') { event.preventDefault() openDocument(reference, projectId) } }} > [{reference.citation_id}] {reference.file_name || reference.file_path} ) } 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, onRegenerate }) { 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 (
{onRegenerate && ( )} {onDelete && ( )}
) } function Chat() { const navigate = useNavigate() const location = useLocation() const [searchParams, setSearchParams] = useSearchParams() const [sessions, setSessions] = useState([]) const [projects, setProjects] = useState([]) const [models, setModels] = useState([]) const [currentSession, setCurrentSession] = useState(null) const [messages, setMessages] = useState([]) // 刷新直达已有会话时,首帧即为加载态,避免闪现「新建对话」页 const [loadingSessions, setLoadingSessions] = useState( () => /[?&](session_id|sessionId)=/.test(window.location.search) ) const [loadingMessages, setLoadingMessages] = useState(false) const [sending, setSending] = useState(false) const [inputValue, setInputValue] = useState('') 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 messageListRef = useRef(null) const nearBottomRef = useRef(true) const composerRef = useRef(null) const abortControllerRef = useRef(null) const pendingStopsRef = useRef(loadPendingStops()) const skipNextOpenSessionRef = useRef(null) const searchRequestIdRef = useRef(0) const newMode = location.pathname === '/chat/new' const sessionId = useMemo(() => { const value = searchParams.get('session_id') || searchParams.get('sessionId') return value ? Number(value) : null }, [searchParams]) // 刷新/直达已有会话时,会话与消息尚未加载完成前不要闪现「新建对话」页 const openingExistingSession = Boolean( sessionId && !newMode && !currentSession && (loadingSessions || loadingMessages) ) 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) const markPendingStop = (sessionId) => { pendingStopsRef.current[String(sessionId)] = true savePendingStops(pendingStopsRef.current) } const clearPendingStop = (sessionId) => { if (pendingStopsRef.current[String(sessionId)]) { delete pendingStopsRef.current[String(sessionId)] savePendingStops(pendingStopsRef.current) } } 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 () => { try { const res = await getMyProjects() setProjects(res.data || []) } catch (error) { console.error(error) } } const loadModels = async () => { try { const res = await getLLMModelConfigs({ page: 1, page_size: 100, model_type: 'chat', is_active: true }) const nextModels = res.data || [] setModels(nextModels) const defaultModel = nextModels.find((item) => item.is_default) || nextModels[0] setNewModelId((current) => current || defaultModel?.config_id) } catch (error) { console.error(error) } } const loadSessions = async () => { setLoadingSessions(true) try { const res = await getChatSessions() setSessions(res.data || []) } catch (error) { message.error('加载对话列表失败') } finally { setLoadingSessions(false) } } // 会话产生新活动后,本地更新 updated_at 并重排,避免整表重载导致的闪烁/跳动 const touchSession = (sessionId) => { const now = new Date().toISOString() setSessions((prev) => { const next = prev.map((item) => ( item.session_id === sessionId ? { ...item, updated_at: now } : item )) return next.sort((a, b) => ( new Date(b.updated_at || 0).getTime() - new Date(a.updated_at || 0).getTime() )) }) } 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 }) } const loadSessionMessages = async (id) => { setLoadingMessages(true) try { const res = await getChatMessages(id) const rawMessages = res.data || [] const nextMessages = rawMessages.map((item) => { // 后端返回 completed/interrupted/error/pending,前端统一映射为展示状态 let status = item.status || 'pending' if (status === 'completed') status = 'done' if (item.role !== 'assistant') status = 'done' return { ...item, status, durationMs: item.duration_ms ?? null, thinkingLog: Array.isArray(item.thinking_log) ? item.thinking_log : [], thinkingVisible: false, } }) // 停止后后端写入中断状态需要一点时间:若该会话存在未确认的停止, // 且最后一条助手消息仍为空,则把它标记为“已停止”,避免显示“思考中”。 if (pendingStopsRef.current[String(id)]) { const lastAssistantIndex = [...nextMessages].reverse().findIndex((m) => m.role === 'assistant') if (lastAssistantIndex !== -1) { const idx = nextMessages.length - 1 - lastAssistantIndex const lastAssistant = nextMessages[idx] if (!(lastAssistant.content || '').trim()) { nextMessages[idx] = { ...lastAssistant, status: 'interrupted' } // 后端中断状态尚未写入:保留待确认标记,下次加载继续兜底 } else { clearPendingStop(id) } } else { clearPendingStop(id) } } setMessages(nextMessages) setCurrentSession(sessions.find((item) => item.session_id === id) || null) } catch (error) { message.error('加载对话失败') } finally { setLoadingMessages(false) } } 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 } loadSessionMessages(sessionId) // eslint-disable-next-line react-hooks/exhaustive-deps }, [sessionId, sessions.length, newMode]) // 新会话打开或消息生成/更新时自动滚动到对话底部;用户向上翻阅时保持不动 useEffect(() => { const el = messageListRef.current if (!el || !nearBottomRef.current) return el.scrollTo({ top: el.scrollHeight, behavior: 'smooth' }) }, [messages, currentSession]) const handleMessageListScroll = () => { const el = messageListRef.current if (!el) return nearBottomRef.current = el.scrollHeight - el.scrollTop - el.clientHeight < 120 } // 切换会话后自动聚焦输入框 useEffect(() => { if (currentSession && !newMode) { composerRef.current?.focus() } }, [currentSession?.session_id, newMode]) useEffect(() => { const target = searchParams.get('message_id') if (!target) return const targetMessage = messageRefs.current.get(String(target)) if (!targetMessage) return 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(models.find((item) => item.is_default)?.config_id || models[0]?.config_id) navigate('/chat/new') } const handleCreateSession = async () => { if (sending) return const question = newQuestion.trim() if (!question) { message.warning('请输入问题') return } if (!newProjectId) { message.warning('请选择项目') return } if (!newModelId) { message.warning('请选择模型') return } const title = question.length > 24 ? `${question.slice(0, 24)}...` : question setSending(true) let session = null try { const res = await createChatSession(newProjectId, newModelId, title) session = res.data } catch (error) { 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 }) clearPendingStop(nextSession.session_id) try { await sendMessageWithStream(nextSession, question, { initialMessages: [optimisticMessage], initialUserMessageId: optimisticMessage.id, includeUserMessage: false, }) touchSession(nextSession.session_id) } catch (error) { message.error(error.response?.data?.detail || error.message || '对话已创建,但首条消息发送失败') } finally { setSending(false) } } const handleSend = async () => { if (sending) return const content = inputValue.trim() if (!content || !currentSession) return setSending(true) setInputValue('') clearPendingStop(currentSession.session_id) try { await sendMessageWithStream(currentSession, content) touchSession(currentSession.session_id) } catch (error) { message.error(error.response?.data?.detail || error.message || '发送消息失败') } finally { setSending(false) } } const handleStopGenerating = () => { abortControllerRef.current?.abort() } const handleRegenerate = async (item) => { if (sending) return const index = messages.findIndex((m) => m.id === item.id) if (index < 0) return const userMsg = [...messages.slice(0, index)].reverse().find((m) => m.role === 'user') if (!userMsg || !currentSession) return const isRealId = typeof item.id === 'number' || /^\d+$/.test(String(item.id)) if (isRealId) { try { await deleteChatMessage(item.id) } catch (error) { message.error(error.response?.data?.detail || '删除旧回复失败') return } } const baseMessages = messages.filter((m) => m.id !== item.id) setMessages(baseMessages) clearPendingStop(currentSession.session_id) setSending(true) try { await sendMessageWithStream(currentSession, userMsg.content, { initialMessages: baseMessages, includeUserMessage: false, insertUserMessage: false, }) touchSession(currentSession.session_id) } 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, thinkingLog: [], thinkingStartedAt: Date.now(), durationMs: null, thinkingVisible: 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 const controller = new AbortController() abortControllerRef.current = controller try { await sendChatMessageStream(session.session_id, content, { signal: controller.signal, insertUserMessage: options.insertUserMessage, 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 ))) }, onThinking: (entry) => { setMessages((prev) => prev.map((item) => ( item.id === assistantMsgId ? { ...item, thinkingLog: [...(item.thinkingLog || []), entry], thinkingStartedAt: item.thinkingStartedAt || Date.now(), } : item ))) }, onChunk: (chunk) => { setMessages((prev) => prev.map((item) => ( item.id === assistantMsgId ? { ...item, content: `${item.content}${chunk}`, status: 'streaming' } : item ))) }, 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', durationMs: data?.duration_ms ?? item.durationMs, thinkingLog: Array.isArray(data?.thinking_log) && data.thinking_log.length > 0 ? data.thinking_log : item.thinkingLog, thinkingVisible: false, referencesVisible: true, } : item ))) }, }) } catch (error) { const aborted = error?.name === 'AbortError' if (aborted) { // 会话级待确认标记:兜底覆盖后端中断标记写入前的时间窗口 markPendingStop(session.session_id) // 立即把“已停止”写入数据库,保证切换会话/刷新后依然可识别 const stoppedId = String(assistantMsgId) if (/^\d+$/.test(stoppedId)) { markMessageInterrupted(Number(stoppedId)).catch(() => {}) } else { // ids 事件尚未到达(例如在检索阶段就点了停止):先从服务端解析真实 id ;(async () => { try { const res = await getChatMessages(session.session_id) const freshMessages = res.data || [] const lastAssistant = [...freshMessages].reverse().find((m) => m.role === 'assistant') if (lastAssistant) { const realId = Number(lastAssistant.id) await markMessageInterrupted(realId) setMessages((prev) => prev.map((item) => ( item.id === assistantMessage.id || item.id === realId ? { ...item, id: realId, status: 'interrupted' } : item ))) } } catch (err) { // 标记失败不阻塞停止流程,会话级待确认标记继续兜底 } })() } } setMessages((prev) => prev.map((item) => ( item.id === assistantMsgId ? { ...item, status: aborted ? 'interrupted' : 'error', error: aborted ? undefined : (error.message || '回答生成失败,请稍后重试'), durationMs: aborted ? Date.now() - (item.thinkingStartedAt || Date.now()) : item.durationMs, thinkingVisible: false, referencesVisible: true, } : item ))) if (!aborted) { throw error } } finally { if (abortControllerRef.current === controller) { abortControllerRef.current = null } } } 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 } Modal.confirm({ title: '删除消息', content: '确定删除这条消息吗?删除后无法恢复。', okText: '删除', okType: 'danger', cancelText: '取消', onOk: async () => { try { await deleteChatMessage(item.id) setMessages((prev) => prev.filter((m) => m.id !== item.id)) } catch (error) { message.error(error.response?.data?.detail || '删除失败') } }, }) } const toggleThinking = (id) => { setMessages((prev) => prev.map((item) => ( item.id === id ? { ...item, thinkingVisible: !item.thinkingVisible } : item ))) } 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) await openSession(item.session_id, { message_id: item.message_id }) } const handleRenameSession = async () => { const title = renameTitle.trim() if (!renameSession || !title) { message.warning('请输入对话名称') return } try { 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('') } catch (error) { message.error(error.response?.data?.detail || '重命名失败') } } const handleDeleteSession = (session) => { Modal.confirm({ title: '删除对话', content: `确定删除「${session.title}」吗?删除后无法恢复。`, okText: '删除', okType: 'danger', cancelText: '取消', onOk: async () => { try { await deleteChatSession(session.session_id) setSessions((prev) => prev.filter((item) => item.session_id !== session.session_id)) if (currentSession?.session_id === session.session_id) { setCurrentSession(null) setMessages([]) navigate('/chat', { replace: true }) } } catch (error) { message.error(error.response?.data?.detail || '删除失败') } }, }) } const renderSessionList = () => (
{loadingSessions ? (
) : sessions.length === 0 ? ( ) : ( groupedSessions.map((group) => (
{group.label}
{ const active = currentSession?.session_id === item.session_id const project = projects.find((project) => project.id === item.project_id) const menuItems = [ { key: 'rename', icon: , label: '重命名', }, { key: 'delete', icon: , label: '删除', danger: true, }, ] return (
openSession(item.session_id)} >
{item.title}
{ domEvent.stopPropagation() if (key === 'rename') { setRenameSession(item) setRenameTitle(item.title) setRenameVisible(true) } if (key === 'delete') { handleDeleteSession(item) } }, }} trigger={['click']} >
}>{project?.name || '未命名项目'}
) }} />
)) )}
) const renderMessages = () => ( <>
{loadingMessages ? (
) : messages.length === 0 ? ( ) : ( messages.map((item) => { const isUser = item.role === 'user' const refs = item.references || [] const rawContent = item.content || '' const displayContent = rawContent // 生成是否完成/被中断由后端 status 字段决定,不再比对回复内容 const status = item.status || 'pending' const isInterrupted = !isUser && status === 'interrupted' const isError = !isUser && status === 'error' const isLive = !isUser && (status === 'thinking' || status === 'streaming' || status === 'pending') const hasContent = Boolean(displayContent.trim()) const isThinking = !isUser && !hasContent && isLive const isStreaming = !isUser && status === 'streaming' const thinkingLog = Array.isArray(item.thinkingLog) ? item.thinkingLog : [] 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 (
{ if (node) { messageRefs.current.set(String(item.id), node) } else { messageRefs.current.delete(String(item.id)) } }} className={`chat-message-row ${isUser ? 'user' : 'assistant'}`} >
{isUser ? (
{displayContent}
) : ( <> {isError && !hasContent ? (
{item.error || '回答生成失败,请稍后重试'}
) : isInterrupted && !hasContent ? (
已停止生成
) : isThinking ? ( ) : ( <> {isStreaming && ( )} {!isLive && (thinkingLog.length > 0 || item.durationMs != null) && ( toggleThinking(item.id)} /> )}
{ const citationId = Number(props['data-citation-id']) const occIndex = Number(props['data-citation-occ'] ?? 0) const ref = refs.find((itemRef) => Number(itemRef.citation_id) === citationId) if (!ref) { return {children} } const occData = Array.isArray(ref?.quote_occurrences) ? ref.quote_occurrences[occIndex] : null const quotes = occData?.quotes?.length ? occData.quotes : (Array.isArray(ref?.quotes) ? ref.quotes : []) const claim = occData?.claim || '' const excerpt = getReferenceExcerpt(ref) const context = getReferenceContext(ref) const hasBand = Boolean(ref?.content && excerpt && context.includes(excerpt)) return (
{ref.file_name || ref.file_path}
{quotes.length > 0 ? (
{claim && (
回答:{claim}
)} {quotes.map((quote, quoteIdx) => (
原文:{quote.text} openDocument(ref, projectId, occData)} />
))}
) : (
{highlightCitation(context, hasBand ? excerpt : '') || '暂无引用片段'} openDocument(ref, projectId, occData)} />
)}
)} > {children} ) }, }} > {displayContent} {isError && (
{item.error || '回答生成中断,请稍后重试'}
)} {isInterrupted && hasContent && (
已停止生成
)}
)} {showReferences && (
引用来源 {refs.length} 个来源
{refs.map((ref) => { return ( ) })}
)} )}
{(isUser || !isLive) && ( handleDeleteMessage(item) : undefined} onRegenerate={!isUser && (isInterrupted || isError) ? () => handleRegenerate(item) : undefined} /> )}
) }) )}