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

1639 lines
59 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

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 (
<div className="chat-thinking-panel active">
<div className="chat-thinking-timer">
<LoadingOutlined spin /> 思考中 · {formatDuration(elapsed)}
</div>
{log.length > 0 && (
<div className="chat-thinking-log">
{log.map((entry, index) => (
<div key={index} className="chat-thinking-log-item">
<span>{entry.message}</span>
{entry.duration_ms != null && (
<span className="chat-thinking-log-time">用时 {formatDuration(entry.duration_ms)}</span>
)}
</div>
))}
</div>
)}
</div>
)
}
const hasLog = log.length > 0
const hasDuration = durationMs != null
if (!hasLog && !hasDuration) return null
return (
<div className="chat-thinking-panel collapsed">
<button
type="button"
className="chat-thinking-toggle"
onClick={onToggle}
aria-expanded={visible}
>
<DownOutlined rotate={visible ? 180 : 0} className="chat-thinking-chevron" />
<span>思考过程</span>
{status === 'interrupted' && <span className="chat-thinking-badge">已停止</span>}
{hasDuration && <span className="chat-thinking-duration">· {formatDuration(durationMs)}</span>}
</button>
{visible && (
<div className="chat-thinking-log">
{log.map((entry, index) => (
<div key={index} className="chat-thinking-log-item">
<span>{entry.message}</span>
{entry.duration_ms != null && (
<span className="chat-thinking-log-time">用时 {formatDuration(entry.duration_ms)}</span>
)}
</div>
))}
{hasDuration && (
<div className="chat-thinking-log-item chat-thinking-log-final">
{status === 'interrupted' ? '已停止' : (status === 'error' ? '生成失败' : '完成')} · 用时 {formatDuration(durationMs)}
</div>
)}
</div>
)}
</div>
)
}
// rehype 插件:将正文中的 [n] 引用编号转换为上角标 <sup>,便于与正文区分。
// 跳过 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, '&lt;').replace(/>/g, '&gt;')
}
// 渲染引用片段命中的分块区间用「证据条」标出chat-chunk-band
// 提问关键词不再高亮——检索是语义匹配,提问词与原文命中并无字面对应。
function highlightCitation(text, chunkText) {
const escaped = escapeAngleBrackets(text)
const chunk = escapeAngleBrackets(chunkText)
// CommonMark 会在空行处断开段落,跨多段的分块不能整体包 <mark>,否则标签错位
if (!chunk || /\r?\n\s*\r?\n/.test(chunk) || !escaped.includes(chunk)) {
return escaped
}
const idx = escaped.indexOf(chunk)
return `${escaped.slice(0, idx)}<mark class="chat-chunk-band">${chunk}</mark>${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 (
<Highlighter
autoEscape
highlightClassName="chat-search-highlight"
searchWords={[keyword]}
textToHighlight={value}
/>
)
}
function CitationMarkdown({ children }) {
return (
<ReactMarkdown
remarkPlugins={[remarkGfm]}
rehypePlugins={[rehypeHighlight, rehypeRaw]}
components={{
mark: ({ children, node: _node, ...props }) => <mark {...props}>{children}</mark>,
}}
>
{children}
</ReactMarkdown>
)
}
function ReferenceCard({ reference, projectId }) {
return (
<span
className="chat-reference-chip"
role="button"
tabIndex={0}
title="打开原文"
onClick={() => openDocument(reference, projectId)}
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault()
openDocument(reference, projectId)
}
}}
>
<span className="chat-reference-number">[{reference.citation_id}]</span>
<span className="chat-reference-file" title={reference.file_path || reference.file_name}>
{reference.file_name || reference.file_path}
</span>
</span>
)
}
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 (
<div className="chat-message-actions">
{onRegenerate && (
<button
type="button"
className="chat-message-action"
onClick={onRegenerate}
aria-label="重新生成"
title="重新生成"
>
<ReloadOutlined />
</button>
)}
<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() {
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)
// 流式过程中后端会回传真实消息 idids 事件),这里用可变变量持续追踪当前
// 助手消息的 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 = () => (
<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>
))
)}
</div>
)
const renderMessages = () => (
<>
<div className="chat-message-list" ref={messageListRef} onScroll={handleMessageListScroll}>
{loadingMessages ? (
<div className="chat-panel-loading">
<Spin />
</div>
) : messages.length === 0 ? (
<Empty description="开始输入问题" />
) : (
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 (
<div
key={item.id}
ref={(node) => {
if (node) {
messageRefs.current.set(String(item.id), node)
} else {
messageRefs.current.delete(String(item.id))
}
}}
className={`chat-message-row ${isUser ? 'user' : 'assistant'}`}
>
<div className="chat-message-column">
<div className={`chat-message-bubble ${isUser ? 'user' : 'assistant'}`}>
{isUser ? (
<div className="chat-plain-text">{displayContent}</div>
) : (
<>
{isError && !hasContent ? (
<div className="chat-message-error">
{item.error || '回答生成失败,请稍后重试'}
</div>
) : isInterrupted && !hasContent ? (
<div className="chat-message-stopped">已停止生成</div>
) : isThinking ? (
<ThinkingPanel active log={thinkingLog} startedAt={item.thinkingStartedAt} />
) : (
<>
{isStreaming && (
<ThinkingPanel active log={thinkingLog} startedAt={item.thinkingStartedAt} />
)}
{!isLive && (thinkingLog.length > 0 || item.durationMs != null) && (
<ThinkingPanel
log={thinkingLog}
durationMs={item.durationMs}
status={isInterrupted ? 'interrupted' : (isError ? 'error' : 'done')}
visible={item.thinkingVisible}
onToggle={() => toggleThinking(item.id)}
/>
)}
<div className={`chat-markdown${isStreaming ? ' streaming' : ''}`}>
<ReactMarkdown
remarkPlugins={[remarkGfm]}
rehypePlugins={[rehypeHighlight, rehypeCitationSup]}
components={{
sup: ({ children, ...props }) => {
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 <sup {...props}>{children}</sup>
}
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 (
<Popover
trigger="hover"
placement="top"
overlayClassName="chat-citation-popover"
content={(
<div className="chat-citation-preview">
<div className="chat-citation-preview-head">
<Text type="secondary" className="chat-citation-preview-file" ellipsis={{ tooltip: ref.file_path || ref.file_name }}>
{ref.file_name || ref.file_path}
</Text>
</div>
<div className="chat-citation-preview-content">
{quotes.length > 0 ? (
<div className="chat-citation-quotes">
{claim && (
<div className="chat-citation-claim">回答{claim}</div>
)}
{quotes.map((quote, quoteIdx) => (
<div key={quoteIdx} className="chat-citation-quote-row">
<span className="chat-citation-quote">原文{quote.text}</span>
<LinkOutlined
className="chat-citation-quote-open"
title="查看原文"
onClick={() => openDocument(ref, projectId, occData)}
/>
</div>
))}
</div>
) : (
<div className="chat-citation-fallback">
<CitationMarkdown>
{highlightCitation(context, hasBand ? excerpt : '') || '暂无引用片段'}
</CitationMarkdown>
<LinkOutlined
className="chat-citation-quote-open"
title="查看原文"
onClick={() => openDocument(ref, projectId, occData)}
/>
</div>
)}
</div>
</div>
)}
>
<sup {...props}>{children}</sup>
</Popover>
)
},
}}
>
{displayContent}
</ReactMarkdown>
{isError && (
<div className="chat-message-error chat-message-error-inline">
{item.error || '回答生成中断,请稍后重试'}
</div>
)}
{isInterrupted && hasContent && (
<div className="chat-message-stopped">已停止生成</div>
)}
</div>
</>
)}
{showReferences && (
<div className="chat-references">
<div className="chat-references-head">
<Text type="secondary" className="chat-references-label">引用来源</Text>
<Text type="secondary" className="chat-references-count">
{refs.length} 个来源
</Text>
</div>
<div className="chat-references-list">
{refs.map((ref) => {
return (
<ReferenceCard
key={`${ref.citation_id}-${ref.file_path || ref.file_name}`}
reference={ref}
projectId={projectId}
/>
)
})}
</div>
</div>
)}
</>
)}
</div>
{(isUser || !isLive) && (
<MessageActions
content={displayContent}
onDelete={canDelete ? () => handleDeleteMessage(item) : undefined}
onRegenerate={!isUser && (isInterrupted || isError) ? () => handleRegenerate(item) : undefined}
/>
)}
</div>
</div>
)
})
)}
</div>
<div className="chat-composer-shell">
<TextArea
ref={composerRef}
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>
{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"
/>
)}
</div>
</div>
</div>
</>
)
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>
)
const renderNewShell = () => (
<div className="chat-start-shell">
<div className="chat-new-page-card">
<div className="chat-start-title">你希望了解什么</div>
{projects.length === 0 && (
<Alert
type="warning"
showIcon
message="暂无可用知识库"
description="请先在「项目空间」创建项目,再进入这里提问。"
/>
)}
{projects.length > 0 && models.length === 0 && (
<Alert
type="warning"
showIcon
message="暂无可用对话模型"
description="请先到「系统管理 - 模型配置」添加并启用对话模型。"
/>
)}
<div className="chat-composer-shell chat-new-composer">
<TextArea
value={newQuestion}
onChange={(e) => setNewQuestion(e.target.value)}
className="chat-composer-input"
autoFocus
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>
}
>
<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>
<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"
/>
{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>
</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">
{openingExistingSession ? (
<div className="chat-panel-loading">
<Spin />
</div>
) : 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