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}
/>
)}
)
})
)}
>
)
const renderChatShell = () => (
{currentSession?.title || '对话'}
{renderMessages()}
)
const renderNewShell = () => (
你希望了解什么?
{projects.length === 0 && (
)}
{projects.length > 0 && models.length === 0 && (
)}
)
return (
{openingExistingSession ? (
) : currentSession && !newMode ? renderChatShell() : renderNewShell()}
setSearchVisible(false)}
footer={null}
width={840}
destroyOnClose
className="chat-search-modal"
>
} loading={searchLoading} onClick={handleSearch}>
搜索
{searchLoading ? (
) : (
),
}}
renderItem={(item) => (
handleSelectSearchResult(item)}>
: } />}
title={
{item.session_title}
{item.project_name}
{item.model_name}
}
description={
<>
{formatTime(item.created_at)}
>
}
/>
)}
/>
)}
setRenameVisible(false)}
onOk={handleRenameSession}
okText="保存"
cancelText="取消"
>
setRenameTitle(event.target.value)}
placeholder="输入新的对话名称"
maxLength={80}
showCount
/>
)
}
export default Chat