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

1398 lines
49 KiB
React
Raw Normal View History

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,
2026-08-05 14:18:25 +00:00
Alert,
2026-07-24 07:37:22 +00:00
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,
2026-08-05 14:18:25 +00:00
StopOutlined,
ReloadOutlined,
2026-07-24 07:37:22 +00:00
} 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'
2026-08-05 14:18:25 +00:00
import { createChatSession, deleteChatMessage, deleteChatSession, getChatMessages, getChatSessions, markMessageInterrupted, searchChatMessages, sendChatMessageStream, updateChatSessionTitle } from '@/api/chat'
import { getMyProjects } from '@/api/project'
import { getLLMModelConfigs } from '@/api/llmModelConfigs'
import useUserStore from '@/stores/userStore'
import './Chat.css'
2026-07-24 07:37:22 +00:00
const { TextArea } = Input
const { Text } = Typography
const CITATION_RE = /\[(\d+)\]/g
2026-08-05 14:18:25 +00:00
// 与后端 INTERRUPTED_RESPONSE_MARKER 保持一致,用于识别“已停止生成”的消息
const STOPPED_MARKER = 'interrupt'
const STOPPED_SUFFIX = `\n\n${STOPPED_MARKER}`
function isStoppedContent(content) {
return content === STOPPED_MARKER || content.endsWith(STOPPED_SUFFIX)
}
function stripStoppedContent(content) {
if (content === STOPPED_MARKER) return ''
if (content.endsWith(STOPPED_SUFFIX)) {
return content.slice(0, -STOPPED_SUFFIX.length).replace(/\s+$/g, '')
}
return content
}
2026-07-24 07:37:22 +00:00
// 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()
}
2026-08-03 05:59:52 +00:00
function buildDocumentPreviewUrl(projectId, filePath) {
2026-07-24 07:37:22 +00:00
if (!projectId || !filePath) return ''
2026-08-03 05:59:52 +00:00
return `/projects/${projectId}/docs?file=${encodeURIComponent(filePath)}`
2026-07-24 07:37:22 +00:00
}
function openDocument(ref, projectId) {
const previewUrl = buildDocumentPreviewUrl(
ref?.project_id || projectId,
2026-08-03 05:59:52 +00:00
ref?.file_path
2026-07-24 07:37:22 +00:00
)
if (previewUrl) window.open(previewUrl, '_blank', 'noopener,noreferrer')
}
function getReferenceExcerpt(ref) {
return (ref?.excerpt || ref?.anchor_text || '').trim()
}
2026-08-05 14:18:25 +00:00
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 {
// 忽略存储不可用的情况
}
}
2026-07-24 07:37:22 +00:00
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
}
}
2026-08-05 14:18:25 +00:00
function MessageActions({ content, onCopy, onDelete, onRegenerate }) {
2026-07-24 07:37:22 +00:00
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">
2026-08-05 14:18:25 +00:00
{onRegenerate && (
<button
type="button"
className="chat-message-action"
onClick={onRegenerate}
aria-label="重新生成"
title="重新生成"
>
<ReloadOutlined />
</button>
)}
2026-07-24 07:37:22 +00:00
<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>
)
}
function Chat() {
2026-07-24 07:37:22 +00:00
const navigate = useNavigate()
const location = useLocation()
const { user: currentUser } = useUserStore()
2026-07-24 07:37:22 +00:00
const [searchParams, setSearchParams] = useSearchParams()
const [sessions, setSessions] = useState([])
2026-07-24 07:37:22 +00:00
const [projects, setProjects] = useState([])
const [models, setModels] = useState([])
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)
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())
2026-08-05 14:18:25 +00:00
const messageListRef = useRef(null)
const nearBottomRef = useRef(true)
const composerRef = useRef(null)
const abortControllerRef = useRef(null)
const pendingStopsRef = useRef(loadPendingStops())
2026-07-24 07:37:22 +00:00
const skipNextOpenSessionRef = useRef(null)
const searchRequestIdRef = useRef(0)
const newMode = location.pathname === '/chat/new'
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-08-05 14:18:25 +00:00
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)
}
}
// 与全站侧边栏/个人资料一致的用户头像avatar 字段是相对路径时转换为 API 端点
const userAvatarUrl = useMemo(() => {
const avatar = currentUser?.avatar
if (!avatar) return null
if (avatar.startsWith('http')) return avatar
const parts = avatar.split('/')
if (parts.length >= 3) {
return `/api/v1/auth/avatar/${parts[0]}/${parts[2]}`
}
return null
}, [currentUser])
const userDisplayName = currentUser?.nickname || currentUser?.username
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 () => {
try {
const res = await getMyProjects()
2026-07-24 07:37:22 +00:00
setProjects(res.data || [])
} catch (error) {
2026-07-24 07:37:22 +00:00
console.error(error)
}
}
2026-07-24 07:37:22 +00:00
const loadModels = async () => {
try {
2026-07-24 07:37:22 +00:00
const res = await getLLMModelConfigs({ page: 1, page_size: 100, model_type: 'chat', is_active: true })
2026-08-03 05:59:52 +00:00
const nextModels = res.data || []
setModels(nextModels)
const defaultModel = nextModels.find((item) => item.is_default) || nextModels[0]
setNewModelId((current) => current || defaultModel?.config_id)
} catch (error) {
2026-07-24 07:37:22 +00:00
console.error(error)
}
}
2026-07-24 07:37:22 +00:00
const loadSessions = async () => {
setLoadingSessions(true)
try {
2026-07-24 07:37:22 +00:00
const res = await getChatSessions()
setSessions(res.data || [])
} catch (error) {
2026-07-24 07:37:22 +00:00
message.error('加载对话列表失败')
} finally {
2026-07-24 07:37:22 +00:00
setLoadingSessions(false)
}
}
2026-08-05 14:18:25 +00:00
// 会话产生新活动后,本地更新 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()
))
})
}
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 })
2026-08-05 14:18:25 +00:00
}
const loadSessionMessages = async (id) => {
2026-07-24 07:37:22 +00:00
setLoadingMessages(true)
try {
2026-07-24 07:37:22 +00:00
const res = await getChatMessages(id)
2026-08-05 14:18:25 +00:00
const rawMessages = res.data || []
const nextMessages = rawMessages.map((item) => {
const contentStopped = item.role === 'assistant' && isStoppedContent(item.content || '')
return contentStopped
? { ...item, stopped: true, status: 'done' }
: item
})
// 停止后后端写入中断标记需要一点时间:若该会话存在未确认的停止,
// 且最后一条助手消息仍为空,则把它标记为“已停止”,避免显示“正在思考”。
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, stopped: true, status: 'done' }
// 后端中断标记尚未写入:保留待确认标记,下次加载继续兜底
} else {
clearPendingStop(id)
}
} else {
clearPendingStop(id)
}
}
setMessages(nextMessages)
2026-07-24 07:37:22 +00:00
setCurrentSession(sessions.find((item) => item.session_id === id) || null)
} catch (error) {
2026-07-24 07:37:22 +00:00
message.error('加载对话失败')
} finally {
2026-07-24 07:37:22 +00:00
setLoadingMessages(false)
}
}
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
}
2026-08-05 14:18:25 +00:00
loadSessionMessages(sessionId)
2026-07-24 07:37:22 +00:00
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [sessionId, sessions.length, newMode])
2026-08-05 14:18:25 +00:00
// 新会话打开或消息生成/更新时自动滚动到对话底部;用户向上翻阅时保持不动
useEffect(() => {
2026-08-05 14:18:25 +00:00
const el = messageListRef.current
if (!el || !nearBottomRef.current) return
el.scrollTo({ top: el.scrollHeight, behavior: 'smooth' })
}, [messages, currentSession])
2026-08-05 14:18:25 +00:00
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])
2026-07-24 07:37:22 +00:00
useEffect(() => {
const target = searchParams.get('message_id')
if (!target) return
const targetMessage = messageRefs.current.get(String(target))
if (!targetMessage) return
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)
2026-08-03 05:59:52 +00:00
setNewModelId(models.find((item) => item.is_default)?.config_id || models[0]?.config_id)
2026-07-24 07:37:22 +00:00
navigate('/chat/new')
}
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('请选择项目')
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
try {
2026-07-24 07:37:22 +00:00
const res = await createChatSession(newProjectId, newModelId, title)
session = res.data
} 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 })
2026-08-05 14:18:25 +00:00
clearPendingStop(nextSession.session_id)
2026-07-24 07:37:22 +00:00
try {
await sendMessageWithStream(nextSession, question, {
initialMessages: [optimisticMessage],
initialUserMessageId: optimisticMessage.id,
includeUserMessage: false,
})
2026-08-05 14:18:25 +00:00
touchSession(nextSession.session_id)
2026-07-24 07:37:22 +00:00
} catch (error) {
message.error(error.response?.data?.detail || error.message || '对话已创建,但首条消息发送失败')
} finally {
2026-07-24 07:37:22 +00:00
setSending(false)
}
}
2026-07-24 07:37:22 +00:00
const handleSend = async () => {
if (sending) return
const content = inputValue.trim()
if (!content || !currentSession) return
setSending(true)
setInputValue('')
2026-08-05 14:18:25 +00:00
clearPendingStop(currentSession.session_id)
2026-07-24 07:37:22 +00:00
try {
await sendMessageWithStream(currentSession, content)
2026-08-05 14:18:25 +00:00
touchSession(currentSession.session_id)
2026-07-24 07:37:22 +00:00
} catch (error) {
message.error(error.response?.data?.detail || error.message || '发送消息失败')
} finally {
setSending(false)
}
}
2026-08-05 14:18:25 +00:00
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)
}
}
2026-07-24 07:37:22 +00:00
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)
// 流式过程中后端会回传真实消息 idids 事件),这里用可变变量持续追踪当前
// 助手消息的 id替换为真实 id 后,后续 onChunk/onDone 仍能命中同一条消息。
let assistantMsgId = assistantMessage.id
const tmpUserId = options.initialUserMessageId ?? userMessage.id
2026-08-05 14:18:25 +00:00
const controller = new AbortController()
abortControllerRef.current = controller
try {
2026-07-24 07:37:22 +00:00
await sendChatMessageStream(session.session_id, content, {
2026-08-05 14:18:25 +00:00
signal: controller.signal,
insertUserMessage: options.insertUserMessage,
2026-07-24 07:37:22 +00:00
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-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-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
)))
},
})
} catch (error) {
2026-08-05 14:18:25 +00:00
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, stopped: true, status: 'done' }
: item
)))
}
} catch (err) {
// 标记失败不阻塞停止流程,会话级待确认标记继续兜底
}
})()
}
}
2026-07-24 07:37:22 +00:00
setMessages((prev) => prev.map((item) => (
item.id === assistantMsgId
? {
...item,
2026-08-05 14:18:25 +00:00
status: aborted ? 'done' : 'error',
stopped: aborted,
error: aborted ? undefined : (error.message || '回答生成失败,请稍后重试'),
2026-07-24 07:37:22 +00:00
referencesVisible: true,
}
: item
)))
2026-08-05 14:18:25 +00:00
if (!aborted) {
throw error
}
} finally {
if (abortControllerRef.current === controller) {
abortControllerRef.current = null
}
}
}
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
}
Modal.confirm({
2026-07-24 07:37:22 +00:00
title: '删除消息',
content: '确定删除这条消息吗?删除后无法恢复。',
okText: '删除',
okType: 'danger',
cancelText: '取消',
onOk: async () => {
try {
2026-07-24 07:37:22 +00:00
await deleteChatMessage(item.id)
setMessages((prev) => prev.filter((m) => m.id !== item.id))
} catch (error) {
2026-07-24 07:37:22 +00:00
message.error(error.response?.data?.detail || '删除失败')
}
},
})
}
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)
await openSession(item.session_id, { message_id: item.message_id })
}
2026-07-24 07:37:22 +00:00
const handleRenameSession = async () => {
const title = renameTitle.trim()
if (!renameSession || !title) {
message.warning('请输入对话名称')
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('')
} catch (error) {
2026-07-24 07:37:22 +00:00
message.error(error.response?.data?.detail || '重命名失败')
}
}
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)
2026-08-05 14:18:25 +00:00
setSessions((prev) => prev.filter((item) => item.session_id !== session.session_id))
2026-07-24 07:37:22 +00:00
if (currentSession?.session_id === session.session_id) {
setCurrentSession(null)
setMessages([])
navigate('/chat', { replace: true })
}
} catch (error) {
message.error(error.response?.data?.detail || '删除失败')
}
},
})
}
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>
)
}}
/>
</div>
2026-07-24 07:37:22 +00:00
))
)}
</div>
)
2026-07-24 07:37:22 +00:00
const renderMessages = () => (
<>
2026-08-05 14:18:25 +00:00
<div className="chat-message-list" ref={messageListRef} onScroll={handleMessageListScroll}>
2026-07-24 07:37:22 +00:00
{loadingMessages ? (
<div className="chat-panel-loading">
<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 || []
2026-08-05 14:18:25 +00:00
const rawContent = item.content || ''
// 后端会在被中断的内容中追加统一标记,展示时剥离,仅保留“已停止生成”状态
const isStopped = !isUser && (item.stopped === true || isStoppedContent(rawContent))
const displayContent = isStopped ? stripStoppedContent(rawContent) : rawContent
const hasContent = Boolean(displayContent.trim())
// 仅真正等待生成的消息status=thinking 或刷新后无状态的空占位)显示「思考中」
const isThinking = !isUser && !hasContent && (item.status === 'thinking' || item.status === undefined)
2026-07-24 07:37:22 +00:00
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-07-24 07:37:22 +00:00
className={`chat-message-row ${isUser ? 'user' : 'assistant'}`}
>
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 ? (
2026-08-05 14:18:25 +00:00
<div className="chat-plain-text">{displayContent}</div>
) : (
2026-07-24 07:37:22 +00:00
<>
{isError && !hasContent ? (
<div className="chat-message-error">
{item.error || '回答生成失败,请稍后重试'}
</div>
2026-08-05 14:18:25 +00:00
) : isStopped && !hasContent ? (
<div className="chat-message-stopped">已停止生成</div>
2026-07-24 07:37:22 +00:00
) : 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>
)
},
}}
>
2026-08-05 14:18:25 +00:00
{displayContent}
2026-07-24 07:37:22 +00:00
</ReactMarkdown>
{isError && (
<div className="chat-message-error chat-message-error-inline">
{item.error || '回答生成中断,请稍后重试'}
</div>
)}
2026-08-05 14:18:25 +00:00
{isStopped && hasContent && (
<div className="chat-message-stopped">已停止生成</div>
)}
2026-07-24 07:37:22 +00:00
</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>
)}
</>
)}
</div>
2026-07-24 07:37:22 +00:00
{(isUser || (!isThinking && !isStreaming)) && (
<MessageActions
2026-08-05 14:18:25 +00:00
content={displayContent}
2026-07-24 07:37:22 +00:00
onDelete={canDelete ? () => handleDeleteMessage(item) : undefined}
2026-08-05 14:18:25 +00:00
onRegenerate={!isUser && (isStopped || isError) ? () => handleRegenerate(item) : undefined}
/>
2026-07-24 07:37:22 +00:00
)}
</div>
{isUser && (
<Avatar className="chat-avatar user" src={userAvatarUrl} style={{ backgroundColor: '#1677ff' }}>
{userDisplayName?.[0]?.toUpperCase() || 'U'}
</Avatar>
)}
2026-07-24 07:37:22 +00:00
</div>
)
})
)}
</div>
<div className="chat-composer-shell">
<TextArea
2026-08-05 14:18:25 +00:00
ref={composerRef}
2026-07-24 07:37:22 +00:00
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>
2026-08-05 14:18:25 +00:00
{sending ? (
<Button
shape="circle"
icon={<StopOutlined />}
onClick={handleStopGenerating}
title="停止生成"
aria-label="停止生成"
className="chat-stop-button"
/>
) : (
<Button
shape="circle"
icon={<ArrowUpOutlined />}
onClick={handleSend}
disabled={!canSendMessage}
aria-disabled={!canSendMessage}
className="chat-send-button"
/>
)}
2026-07-24 07:37:22 +00:00
</div>
</div>
2026-07-24 07:37:22 +00:00
</div>
</>
)
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-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>
2026-08-05 14:18:25 +00:00
{projects.length === 0 && (
<Alert
type="warning"
showIcon
message="暂无可用知识库"
description="请先在「项目空间」创建项目,再进入这里提问。"
/>
)}
{projects.length > 0 && models.length === 0 && (
<Alert
type="warning"
showIcon
message="暂无可用对话模型"
description="请先到「系统管理 - 模型配置」添加并启用对话模型。"
/>
)}
2026-07-24 07:37:22 +00:00
<div className="chat-composer-shell chat-new-composer">
<TextArea
value={newQuestion}
onChange={(e) => setNewQuestion(e.target.value)}
className="chat-composer-input"
2026-08-05 14:18:25 +00:00
autoFocus
2026-07-24 07:37:22 +00:00
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"
/>
</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>
)}
</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-08-05 14:18:25 +00:00
{sending ? (
<Button
shape="circle"
icon={<StopOutlined />}
onClick={handleStopGenerating}
title="停止生成"
aria-label="停止生成"
className="chat-stop-button"
/>
) : (
<Button
shape="circle"
icon={<ArrowUpOutlined />}
onClick={handleCreateSession}
disabled={!canCreateSession}
aria-disabled={!canCreateSession}
className="chat-send-button"
/>
)}
</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>
)
}
export default Chat