1639 lines
59 KiB
JavaScript
1639 lines
59 KiB
JavaScript
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, '<').replace(/>/g, '>')
|
||
}
|
||
|
||
// 渲染引用片段:命中的分块区间用「证据条」标出(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)
|
||
|
||
// 流式过程中后端会回传真实消息 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 = () => (
|
||
<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
|