2026-07-24 07:37:22 +00:00
|
|
|
|
import { useEffect, useMemo, useRef, useState } from 'react'
|
|
|
|
|
|
import { useLocation, useNavigate, useSearchParams } from 'react-router-dom'
|
|
|
|
|
|
import {
|
|
|
|
|
|
Avatar,
|
2026-08-05 14:18:25 +00:00
|
|
|
|
Alert,
|
2026-07-24 07:37:22 +00:00
|
|
|
|
Button,
|
|
|
|
|
|
Dropdown,
|
|
|
|
|
|
Empty,
|
|
|
|
|
|
Input,
|
|
|
|
|
|
List,
|
|
|
|
|
|
Modal,
|
|
|
|
|
|
Popover,
|
|
|
|
|
|
Select,
|
|
|
|
|
|
Space,
|
|
|
|
|
|
Spin,
|
|
|
|
|
|
Tag,
|
|
|
|
|
|
Typography,
|
|
|
|
|
|
message,
|
|
|
|
|
|
} from 'antd'
|
|
|
|
|
|
import {
|
|
|
|
|
|
FolderOutlined,
|
|
|
|
|
|
EditOutlined,
|
|
|
|
|
|
DeleteOutlined,
|
|
|
|
|
|
MoreOutlined,
|
|
|
|
|
|
PlusOutlined,
|
|
|
|
|
|
RobotOutlined,
|
|
|
|
|
|
SearchOutlined,
|
|
|
|
|
|
ArrowUpOutlined,
|
|
|
|
|
|
UserOutlined,
|
|
|
|
|
|
DownOutlined,
|
|
|
|
|
|
CopyOutlined,
|
|
|
|
|
|
CheckOutlined,
|
2026-08-05 14:18:25 +00:00
|
|
|
|
StopOutlined,
|
|
|
|
|
|
ReloadOutlined,
|
2026-08-07 08:31:54 +00:00
|
|
|
|
LinkOutlined,
|
|
|
|
|
|
LoadingOutlined,
|
2026-07-24 07:37:22 +00:00
|
|
|
|
} from '@ant-design/icons'
|
|
|
|
|
|
import ReactMarkdown from 'react-markdown'
|
|
|
|
|
|
import Highlighter from 'react-highlight-words'
|
|
|
|
|
|
import remarkGfm from 'remark-gfm'
|
|
|
|
|
|
import rehypeHighlight from 'rehype-highlight'
|
2026-08-07 08:31:54 +00:00
|
|
|
|
import rehypeRaw from 'rehype-raw'
|
2026-07-24 07:37:22 +00:00
|
|
|
|
import 'highlight.js/styles/github.css'
|
2026-08-05 14:18:25 +00:00
|
|
|
|
import { createChatSession, deleteChatMessage, deleteChatSession, getChatMessages, getChatSessions, markMessageInterrupted, searchChatMessages, sendChatMessageStream, updateChatSessionTitle } from '@/api/chat'
|
2026-06-23 13:58:16 +00:00
|
|
|
|
import { getMyProjects } from '@/api/project'
|
|
|
|
|
|
import { getLLMModelConfigs } from '@/api/llmModelConfigs'
|
|
|
|
|
|
import './Chat.css'
|
|
|
|
|
|
|
2026-07-24 07:37:22 +00:00
|
|
|
|
const { TextArea } = Input
|
|
|
|
|
|
const { Text } = Typography
|
|
|
|
|
|
|
|
|
|
|
|
const CITATION_RE = /\[(\d+)\]/g
|
2026-08-05 14:18:25 +00:00
|
|
|
|
|
2026-08-07 08:31:54 +00:00
|
|
|
|
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} 秒`
|
2026-08-05 14:18:25 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-07 08:31:54 +00:00
|
|
|
|
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>
|
|
|
|
|
|
)
|
2026-08-05 14:18:25 +00:00
|
|
|
|
}
|
2026-08-07 08:31:54 +00:00
|
|
|
|
|
|
|
|
|
|
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>
|
|
|
|
|
|
)
|
2026-08-05 14:18:25 +00:00
|
|
|
|
}
|
2026-07-24 07:37:22 +00:00
|
|
|
|
|
|
|
|
|
|
// rehype 插件:将正文中的 [n] 引用编号转换为上角标 <sup>,便于与正文区分。
|
|
|
|
|
|
// 跳过 code/pre 节点,避免破坏代码块中的方括号内容。
|
|
|
|
|
|
function rehypeCitationSup() {
|
2026-08-07 08:31:54 +00:00
|
|
|
|
// 按引用编号统计出现次数,使同一编号的多处引用可以精确区分
|
|
|
|
|
|
const occurrenceCounters = {}
|
2026-07-24 07:37:22 +00:00
|
|
|
|
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'
|
2026-08-07 08:31:54 +00:00
|
|
|
|
if (child.type === 'text' && CITATION_RE.test(child.value)) {
|
2026-07-24 07:37:22 +00:00
|
|
|
|
CITATION_RE.lastIndex = 0
|
2026-08-07 08:31:54 +00:00
|
|
|
|
let lastPush = 0
|
2026-07-24 07:37:22 +00:00
|
|
|
|
let match
|
|
|
|
|
|
while ((match = CITATION_RE.exec(child.value)) !== null) {
|
2026-08-07 08:31:54 +00:00
|
|
|
|
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
|
2026-07-24 07:37:22 +00:00
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-08-07 08:31:54 +00:00
|
|
|
|
if (childInCode) {
|
|
|
|
|
|
nextChildren.push(child)
|
|
|
|
|
|
} else if (lastPush < child.value.length) {
|
|
|
|
|
|
nextChildren.push({ type: 'text', value: child.value.slice(lastPush) })
|
2026-07-24 07:37:22 +00:00
|
|
|
|
}
|
|
|
|
|
|
} else {
|
|
|
|
|
|
if (child.type === 'element') walk(child, childInCode)
|
|
|
|
|
|
nextChildren.push(child)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
node.children = nextChildren
|
|
|
|
|
|
}
|
|
|
|
|
|
return (tree) => walk(tree, false)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function formatTime(value) {
|
|
|
|
|
|
if (!value) return ''
|
|
|
|
|
|
return new Date(value).toLocaleString('zh-CN', {
|
|
|
|
|
|
month: '2-digit',
|
|
|
|
|
|
day: '2-digit',
|
|
|
|
|
|
hour: '2-digit',
|
|
|
|
|
|
minute: '2-digit',
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function getDateKey(value) {
|
|
|
|
|
|
if (!value) return 'unknown'
|
|
|
|
|
|
const date = new Date(value)
|
|
|
|
|
|
if (Number.isNaN(date.getTime())) return 'unknown'
|
|
|
|
|
|
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function formatDateGroup(value) {
|
|
|
|
|
|
if (!value || value === 'unknown') return '未知日期'
|
|
|
|
|
|
const date = new Date(`${value}T00:00:00`)
|
|
|
|
|
|
const today = new Date()
|
|
|
|
|
|
const yesterday = new Date()
|
|
|
|
|
|
yesterday.setDate(today.getDate() - 1)
|
|
|
|
|
|
const todayKey = getDateKey(today)
|
|
|
|
|
|
const yesterdayKey = getDateKey(yesterday)
|
|
|
|
|
|
|
|
|
|
|
|
if (value === todayKey) return '今天'
|
|
|
|
|
|
if (value === yesterdayKey) return '昨天'
|
|
|
|
|
|
if (date.getFullYear() === today.getFullYear()) {
|
|
|
|
|
|
return `${date.getMonth() + 1}月${date.getDate()}日`
|
|
|
|
|
|
}
|
|
|
|
|
|
return `${date.getFullYear()}年${date.getMonth() + 1}月${date.getDate()}日`
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function stripMarkdown(text) {
|
|
|
|
|
|
return (text || '')
|
|
|
|
|
|
.replace(/```[\s\S]*?```/g, ' ')
|
|
|
|
|
|
.replace(/[#>*_`~\-\[\]()]/g, ' ')
|
|
|
|
|
|
.replace(/\s+/g, ' ')
|
|
|
|
|
|
.trim()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-07 08:31:54 +00:00
|
|
|
|
// 去掉 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) {
|
2026-07-24 07:37:22 +00:00
|
|
|
|
if (!projectId || !filePath) return ''
|
2026-08-07 08:31:54 +00:00
|
|
|
|
const params = new URLSearchParams()
|
|
|
|
|
|
params.set('file', filePath)
|
|
|
|
|
|
if (highlight) params.set('hl', highlight)
|
|
|
|
|
|
return `/projects/${projectId}/docs?${params.toString()}`
|
2026-07-24 07:37:22 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-07 08:31:54 +00:00
|
|
|
|
// 跳转原文时优先用「支撑句原文」作为定位关键词,文档页据此高亮并滚动到命中位置
|
|
|
|
|
|
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) {
|
2026-07-24 07:37:22 +00:00
|
|
|
|
const previewUrl = buildDocumentPreviewUrl(
|
|
|
|
|
|
ref?.project_id || projectId,
|
2026-08-07 08:31:54 +00:00
|
|
|
|
ref?.file_path,
|
|
|
|
|
|
getDocumentJumpKeyword(ref, occData)
|
2026-07-24 07:37:22 +00:00
|
|
|
|
)
|
|
|
|
|
|
if (previewUrl) window.open(previewUrl, '_blank', 'noopener,noreferrer')
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function getReferenceExcerpt(ref) {
|
|
|
|
|
|
return (ref?.excerpt || ref?.anchor_text || '').trim()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-07 08:31:54 +00:00
|
|
|
|
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)}`
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-05 14:18:25 +00:00
|
|
|
|
function loadPendingStops() {
|
|
|
|
|
|
try {
|
|
|
|
|
|
return JSON.parse(sessionStorage.getItem('nex-chat-pending-stops') || '{}')
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
return {}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function savePendingStops(map) {
|
|
|
|
|
|
try {
|
|
|
|
|
|
sessionStorage.setItem('nex-chat-pending-stops', JSON.stringify(map))
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
// 忽略存储不可用的情况
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-24 07:37:22 +00:00
|
|
|
|
function SearchHighlight({ text, keyword }) {
|
|
|
|
|
|
const value = text || ''
|
|
|
|
|
|
if (!keyword) return value
|
|
|
|
|
|
return (
|
|
|
|
|
|
<Highlighter
|
|
|
|
|
|
autoEscape
|
|
|
|
|
|
highlightClassName="chat-search-highlight"
|
|
|
|
|
|
searchWords={[keyword]}
|
|
|
|
|
|
textToHighlight={value}
|
|
|
|
|
|
/>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-07 08:31:54 +00:00
|
|
|
|
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>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-24 07:37:22 +00:00
|
|
|
|
async function copyText(text) {
|
|
|
|
|
|
const value = text || ''
|
|
|
|
|
|
try {
|
|
|
|
|
|
if (navigator.clipboard?.writeText) {
|
|
|
|
|
|
await navigator.clipboard.writeText(value)
|
|
|
|
|
|
return true
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
// 回退到 execCommand
|
|
|
|
|
|
}
|
|
|
|
|
|
try {
|
|
|
|
|
|
const textarea = document.createElement('textarea')
|
|
|
|
|
|
textarea.value = value
|
|
|
|
|
|
textarea.style.position = 'fixed'
|
|
|
|
|
|
textarea.style.opacity = '0'
|
|
|
|
|
|
document.body.appendChild(textarea)
|
|
|
|
|
|
textarea.select()
|
|
|
|
|
|
const ok = document.execCommand('copy')
|
|
|
|
|
|
document.body.removeChild(textarea)
|
|
|
|
|
|
return ok
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
return false
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-05 14:18:25 +00:00
|
|
|
|
function MessageActions({ content, onCopy, onDelete, onRegenerate }) {
|
2026-07-24 07:37:22 +00:00
|
|
|
|
const [copied, setCopied] = useState(false)
|
|
|
|
|
|
const handleCopy = async () => {
|
|
|
|
|
|
const ok = await copyText(content)
|
|
|
|
|
|
if (ok) {
|
|
|
|
|
|
setCopied(true)
|
|
|
|
|
|
window.setTimeout(() => setCopied(false), 1500)
|
|
|
|
|
|
} else {
|
|
|
|
|
|
message.error('复制失败')
|
|
|
|
|
|
}
|
|
|
|
|
|
onCopy?.(ok)
|
|
|
|
|
|
}
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div className="chat-message-actions">
|
2026-08-05 14:18:25 +00:00
|
|
|
|
{onRegenerate && (
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
className="chat-message-action"
|
|
|
|
|
|
onClick={onRegenerate}
|
|
|
|
|
|
aria-label="重新生成"
|
|
|
|
|
|
title="重新生成"
|
|
|
|
|
|
>
|
|
|
|
|
|
<ReloadOutlined />
|
|
|
|
|
|
</button>
|
|
|
|
|
|
)}
|
2026-07-24 07:37:22 +00:00
|
|
|
|
<button type="button" className="chat-message-action" onClick={handleCopy} aria-label="复制">
|
|
|
|
|
|
{copied ? <CheckOutlined /> : <CopyOutlined />}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
{onDelete && (
|
|
|
|
|
|
<button type="button" className="chat-message-action danger" onClick={onDelete} aria-label="删除">
|
|
|
|
|
|
<DeleteOutlined />
|
|
|
|
|
|
</button>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
2026-06-23 13:58:16 +00:00
|
|
|
|
|
|
|
|
|
|
function Chat() {
|
2026-07-24 07:37:22 +00:00
|
|
|
|
const navigate = useNavigate()
|
|
|
|
|
|
const location = useLocation()
|
|
|
|
|
|
const [searchParams, setSearchParams] = useSearchParams()
|
2026-06-23 13:58:16 +00:00
|
|
|
|
const [sessions, setSessions] = useState([])
|
2026-07-24 07:37:22 +00:00
|
|
|
|
const [projects, setProjects] = useState([])
|
|
|
|
|
|
const [models, setModels] = useState([])
|
2026-06-23 13:58:16 +00:00
|
|
|
|
const [currentSession, setCurrentSession] = useState(null)
|
|
|
|
|
|
const [messages, setMessages] = useState([])
|
2026-08-07 08:31:54 +00:00
|
|
|
|
// 刷新直达已有会话时,首帧即为加载态,避免闪现「新建对话」页
|
|
|
|
|
|
const [loadingSessions, setLoadingSessions] = useState(
|
|
|
|
|
|
() => /[?&](session_id|sessionId)=/.test(window.location.search)
|
|
|
|
|
|
)
|
2026-07-24 07:37:22 +00:00
|
|
|
|
const [loadingMessages, setLoadingMessages] = useState(false)
|
|
|
|
|
|
const [sending, setSending] = useState(false)
|
2026-06-23 13:58:16 +00:00
|
|
|
|
const [inputValue, setInputValue] = useState('')
|
2026-07-24 07:37:22 +00:00
|
|
|
|
const [searchVisible, setSearchVisible] = useState(false)
|
|
|
|
|
|
const [searchKeyword, setSearchKeyword] = useState('')
|
|
|
|
|
|
const [searchResults, setSearchResults] = useState([])
|
|
|
|
|
|
const [searchLoading, setSearchLoading] = useState(false)
|
|
|
|
|
|
const [searchedKeyword, setSearchedKeyword] = useState('')
|
|
|
|
|
|
const [hasSearched, setHasSearched] = useState(false)
|
|
|
|
|
|
const [newQuestion, setNewQuestion] = useState('')
|
|
|
|
|
|
const [newProjectId, setNewProjectId] = useState(undefined)
|
|
|
|
|
|
const [newModelId, setNewModelId] = useState(undefined)
|
|
|
|
|
|
const [projectPickerOpen, setProjectPickerOpen] = useState(false)
|
|
|
|
|
|
const [renameVisible, setRenameVisible] = useState(false)
|
|
|
|
|
|
const [renameSession, setRenameSession] = useState(null)
|
|
|
|
|
|
const [renameTitle, setRenameTitle] = useState('')
|
|
|
|
|
|
const messageRefs = useRef(new Map())
|
2026-08-05 14:18:25 +00:00
|
|
|
|
const messageListRef = useRef(null)
|
|
|
|
|
|
const nearBottomRef = useRef(true)
|
|
|
|
|
|
const composerRef = useRef(null)
|
|
|
|
|
|
const abortControllerRef = useRef(null)
|
|
|
|
|
|
const pendingStopsRef = useRef(loadPendingStops())
|
2026-07-24 07:37:22 +00:00
|
|
|
|
const skipNextOpenSessionRef = useRef(null)
|
|
|
|
|
|
const searchRequestIdRef = useRef(0)
|
|
|
|
|
|
const newMode = location.pathname === '/chat/new'
|
2026-06-23 13:58:16 +00:00
|
|
|
|
|
2026-07-24 07:37:22 +00:00
|
|
|
|
const sessionId = useMemo(() => {
|
|
|
|
|
|
const value = searchParams.get('session_id') || searchParams.get('sessionId')
|
|
|
|
|
|
return value ? Number(value) : null
|
|
|
|
|
|
}, [searchParams])
|
2026-08-07 08:31:54 +00:00
|
|
|
|
// 刷新/直达已有会话时,会话与消息尚未加载完成前不要闪现「新建对话」页
|
|
|
|
|
|
const openingExistingSession = Boolean(
|
|
|
|
|
|
sessionId && !newMode && !currentSession && (loadingSessions || loadingMessages)
|
|
|
|
|
|
)
|
2026-07-24 07:37:22 +00:00
|
|
|
|
|
|
|
|
|
|
const currentProject = useMemo(
|
|
|
|
|
|
() => projects.find((item) => item.id === newProjectId),
|
|
|
|
|
|
[projects, newProjectId]
|
|
|
|
|
|
)
|
|
|
|
|
|
const currentModel = useMemo(
|
|
|
|
|
|
() => models.find((item) => item.config_id === newModelId),
|
|
|
|
|
|
[models, newModelId]
|
|
|
|
|
|
)
|
|
|
|
|
|
const canCreateSession = Boolean(newQuestion.trim() && currentProject && currentModel)
|
|
|
|
|
|
const canSendMessage = Boolean(inputValue.trim() && currentSession)
|
2026-06-23 13:58:16 +00:00
|
|
|
|
|
2026-08-05 14:18:25 +00:00
|
|
|
|
const markPendingStop = (sessionId) => {
|
|
|
|
|
|
pendingStopsRef.current[String(sessionId)] = true
|
|
|
|
|
|
savePendingStops(pendingStopsRef.current)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const clearPendingStop = (sessionId) => {
|
|
|
|
|
|
if (pendingStopsRef.current[String(sessionId)]) {
|
|
|
|
|
|
delete pendingStopsRef.current[String(sessionId)]
|
|
|
|
|
|
savePendingStops(pendingStopsRef.current)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-24 07:37:22 +00:00
|
|
|
|
const groupedSessions = useMemo(() => {
|
|
|
|
|
|
const groups = []
|
|
|
|
|
|
const groupMap = new Map()
|
|
|
|
|
|
sessions.forEach((item) => {
|
|
|
|
|
|
const key = getDateKey(item.updated_at || item.created_at)
|
|
|
|
|
|
if (!groupMap.has(key)) {
|
|
|
|
|
|
const group = {
|
|
|
|
|
|
key,
|
|
|
|
|
|
label: formatDateGroup(key),
|
|
|
|
|
|
items: [],
|
|
|
|
|
|
}
|
|
|
|
|
|
groupMap.set(key, group)
|
|
|
|
|
|
groups.push(group)
|
|
|
|
|
|
}
|
|
|
|
|
|
groupMap.get(key).items.push(item)
|
|
|
|
|
|
})
|
|
|
|
|
|
return groups
|
|
|
|
|
|
}, [sessions])
|
|
|
|
|
|
|
|
|
|
|
|
const loadProjects = async () => {
|
2026-06-23 13:58:16 +00:00
|
|
|
|
try {
|
|
|
|
|
|
const res = await getMyProjects()
|
2026-07-24 07:37:22 +00:00
|
|
|
|
setProjects(res.data || [])
|
2026-06-23 13:58:16 +00:00
|
|
|
|
} catch (error) {
|
2026-07-24 07:37:22 +00:00
|
|
|
|
console.error(error)
|
2026-06-23 13:58:16 +00:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-24 07:37:22 +00:00
|
|
|
|
const loadModels = async () => {
|
2026-06-23 13:58:16 +00:00
|
|
|
|
try {
|
2026-07-24 07:37:22 +00:00
|
|
|
|
const res = await getLLMModelConfigs({ page: 1, page_size: 100, model_type: 'chat', is_active: true })
|
2026-08-03 05:59:52 +00:00
|
|
|
|
const nextModels = res.data || []
|
|
|
|
|
|
setModels(nextModels)
|
|
|
|
|
|
const defaultModel = nextModels.find((item) => item.is_default) || nextModels[0]
|
|
|
|
|
|
setNewModelId((current) => current || defaultModel?.config_id)
|
2026-06-23 13:58:16 +00:00
|
|
|
|
} catch (error) {
|
2026-07-24 07:37:22 +00:00
|
|
|
|
console.error(error)
|
2026-06-23 13:58:16 +00:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-24 07:37:22 +00:00
|
|
|
|
const loadSessions = async () => {
|
|
|
|
|
|
setLoadingSessions(true)
|
2026-06-23 13:58:16 +00:00
|
|
|
|
try {
|
2026-07-24 07:37:22 +00:00
|
|
|
|
const res = await getChatSessions()
|
|
|
|
|
|
setSessions(res.data || [])
|
2026-06-23 13:58:16 +00:00
|
|
|
|
} catch (error) {
|
2026-07-24 07:37:22 +00:00
|
|
|
|
message.error('加载对话列表失败')
|
2026-06-23 13:58:16 +00:00
|
|
|
|
} finally {
|
2026-07-24 07:37:22 +00:00
|
|
|
|
setLoadingSessions(false)
|
2026-06-23 13:58:16 +00:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-05 14:18:25 +00:00
|
|
|
|
// 会话产生新活动后,本地更新 updated_at 并重排,避免整表重载导致的闪烁/跳动
|
|
|
|
|
|
const touchSession = (sessionId) => {
|
|
|
|
|
|
const now = new Date().toISOString()
|
|
|
|
|
|
setSessions((prev) => {
|
|
|
|
|
|
const next = prev.map((item) => (
|
|
|
|
|
|
item.session_id === sessionId ? { ...item, updated_at: now } : item
|
|
|
|
|
|
))
|
|
|
|
|
|
return next.sort((a, b) => (
|
|
|
|
|
|
new Date(b.updated_at || 0).getTime() - new Date(a.updated_at || 0).getTime()
|
|
|
|
|
|
))
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-24 07:37:22 +00:00
|
|
|
|
const openSession = async (id, extraQuery = {}) => {
|
|
|
|
|
|
if (!id) return
|
|
|
|
|
|
const nextQuery = new URLSearchParams()
|
|
|
|
|
|
nextQuery.set('session_id', String(id))
|
|
|
|
|
|
if (extraQuery.message_id) {
|
|
|
|
|
|
nextQuery.set('message_id', String(extraQuery.message_id))
|
|
|
|
|
|
}
|
|
|
|
|
|
navigate({ pathname: '/chat', search: `?${nextQuery.toString()}` }, { replace: true })
|
2026-08-05 14:18:25 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const loadSessionMessages = async (id) => {
|
2026-07-24 07:37:22 +00:00
|
|
|
|
setLoadingMessages(true)
|
2026-06-23 13:58:16 +00:00
|
|
|
|
try {
|
2026-07-24 07:37:22 +00:00
|
|
|
|
const res = await getChatMessages(id)
|
2026-08-05 14:18:25 +00:00
|
|
|
|
const rawMessages = res.data || []
|
|
|
|
|
|
const nextMessages = rawMessages.map((item) => {
|
2026-08-07 08:31:54 +00:00
|
|
|
|
// 后端返回 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,
|
|
|
|
|
|
}
|
2026-08-05 14:18:25 +00:00
|
|
|
|
})
|
|
|
|
|
|
|
2026-08-07 08:31:54 +00:00
|
|
|
|
// 停止后后端写入中断状态需要一点时间:若该会话存在未确认的停止,
|
|
|
|
|
|
// 且最后一条助手消息仍为空,则把它标记为“已停止”,避免显示“思考中”。
|
2026-08-05 14:18:25 +00:00
|
|
|
|
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()) {
|
2026-08-07 08:31:54 +00:00
|
|
|
|
nextMessages[idx] = { ...lastAssistant, status: 'interrupted' }
|
|
|
|
|
|
// 后端中断状态尚未写入:保留待确认标记,下次加载继续兜底
|
2026-08-05 14:18:25 +00:00
|
|
|
|
} else {
|
|
|
|
|
|
clearPendingStop(id)
|
|
|
|
|
|
}
|
|
|
|
|
|
} else {
|
|
|
|
|
|
clearPendingStop(id)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
setMessages(nextMessages)
|
2026-07-24 07:37:22 +00:00
|
|
|
|
setCurrentSession(sessions.find((item) => item.session_id === id) || null)
|
2026-06-23 13:58:16 +00:00
|
|
|
|
} catch (error) {
|
2026-07-24 07:37:22 +00:00
|
|
|
|
message.error('加载对话失败')
|
2026-06-23 13:58:16 +00:00
|
|
|
|
} finally {
|
2026-07-24 07:37:22 +00:00
|
|
|
|
setLoadingMessages(false)
|
2026-06-23 13:58:16 +00:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-24 07:37:22 +00:00
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
loadProjects()
|
|
|
|
|
|
loadModels()
|
|
|
|
|
|
loadSessions()
|
|
|
|
|
|
}, [])
|
|
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
if (newMode) {
|
|
|
|
|
|
setCurrentSession(null)
|
|
|
|
|
|
setMessages([])
|
|
|
|
|
|
}
|
|
|
|
|
|
}, [newMode])
|
|
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
if (!sessionId || newMode) {
|
|
|
|
|
|
if (!newMode) {
|
|
|
|
|
|
setCurrentSession(null)
|
|
|
|
|
|
setMessages([])
|
|
|
|
|
|
}
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
if (skipNextOpenSessionRef.current === sessionId) {
|
|
|
|
|
|
skipNextOpenSessionRef.current = null
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
2026-08-05 14:18:25 +00:00
|
|
|
|
loadSessionMessages(sessionId)
|
2026-07-24 07:37:22 +00:00
|
|
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
|
|
|
|
}, [sessionId, sessions.length, newMode])
|
|
|
|
|
|
|
2026-08-05 14:18:25 +00:00
|
|
|
|
// 新会话打开或消息生成/更新时自动滚动到对话底部;用户向上翻阅时保持不动
|
2026-08-04 01:31:48 +00:00
|
|
|
|
useEffect(() => {
|
2026-08-05 14:18:25 +00:00
|
|
|
|
const el = messageListRef.current
|
|
|
|
|
|
if (!el || !nearBottomRef.current) return
|
|
|
|
|
|
el.scrollTo({ top: el.scrollHeight, behavior: 'smooth' })
|
2026-08-04 01:31:48 +00:00
|
|
|
|
}, [messages, currentSession])
|
|
|
|
|
|
|
2026-08-05 14:18:25 +00:00
|
|
|
|
const handleMessageListScroll = () => {
|
|
|
|
|
|
const el = messageListRef.current
|
|
|
|
|
|
if (!el) return
|
|
|
|
|
|
nearBottomRef.current = el.scrollHeight - el.scrollTop - el.clientHeight < 120
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 切换会话后自动聚焦输入框
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
if (currentSession && !newMode) {
|
|
|
|
|
|
composerRef.current?.focus()
|
|
|
|
|
|
}
|
|
|
|
|
|
}, [currentSession?.session_id, newMode])
|
|
|
|
|
|
|
2026-07-24 07:37:22 +00:00
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
const target = searchParams.get('message_id')
|
|
|
|
|
|
if (!target) return
|
|
|
|
|
|
const targetMessage = messageRefs.current.get(String(target))
|
|
|
|
|
|
if (!targetMessage) return
|
2026-06-23 13:58:16 +00:00
|
|
|
|
|
2026-07-24 07:37:22 +00:00
|
|
|
|
targetMessage.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
|
|
|
|
|
const nextSearchParams = new URLSearchParams(searchParams)
|
|
|
|
|
|
nextSearchParams.delete('message_id')
|
|
|
|
|
|
setSearchParams(nextSearchParams, { replace: true })
|
|
|
|
|
|
}, [messages, searchParams, setSearchParams])
|
|
|
|
|
|
|
|
|
|
|
|
const handleStartNew = () => {
|
|
|
|
|
|
setNewQuestion('')
|
|
|
|
|
|
setNewProjectId(undefined)
|
2026-08-03 05:59:52 +00:00
|
|
|
|
setNewModelId(models.find((item) => item.is_default)?.config_id || models[0]?.config_id)
|
2026-07-24 07:37:22 +00:00
|
|
|
|
navigate('/chat/new')
|
2026-06-23 13:58:16 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const handleCreateSession = async () => {
|
2026-07-24 07:37:22 +00:00
|
|
|
|
if (sending) return
|
|
|
|
|
|
const question = newQuestion.trim()
|
|
|
|
|
|
if (!question) {
|
|
|
|
|
|
message.warning('请输入问题')
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
if (!newProjectId) {
|
|
|
|
|
|
message.warning('请选择项目')
|
2026-06-23 13:58:16 +00:00
|
|
|
|
return
|
|
|
|
|
|
}
|
2026-07-24 07:37:22 +00:00
|
|
|
|
if (!newModelId) {
|
|
|
|
|
|
message.warning('请选择模型')
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
const title = question.length > 24 ? `${question.slice(0, 24)}...` : question
|
|
|
|
|
|
setSending(true)
|
|
|
|
|
|
let session = null
|
2026-06-23 13:58:16 +00:00
|
|
|
|
try {
|
2026-07-24 07:37:22 +00:00
|
|
|
|
const res = await createChatSession(newProjectId, newModelId, title)
|
|
|
|
|
|
session = res.data
|
2026-06-23 13:58:16 +00:00
|
|
|
|
} catch (error) {
|
2026-07-24 07:37:22 +00:00
|
|
|
|
message.error(error.response?.data?.detail || error.message || '新建对话失败')
|
|
|
|
|
|
setSending(false)
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const nextSession = {
|
|
|
|
|
|
session_id: session.session_id,
|
|
|
|
|
|
project_id: session.project_id ?? newProjectId,
|
|
|
|
|
|
llm_config_id: session.llm_config_id ?? newModelId,
|
|
|
|
|
|
title: session.title || title,
|
|
|
|
|
|
created_at: session.created_at,
|
|
|
|
|
|
updated_at: session.updated_at || session.created_at,
|
|
|
|
|
|
}
|
|
|
|
|
|
const optimisticMessage = {
|
|
|
|
|
|
id: `tmp-${Date.now()}`,
|
|
|
|
|
|
role: 'user',
|
|
|
|
|
|
content: question,
|
|
|
|
|
|
created_at: new Date().toISOString(),
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
setSessions((prev) => [
|
|
|
|
|
|
nextSession,
|
|
|
|
|
|
...prev.filter((item) => item.session_id !== nextSession.session_id),
|
|
|
|
|
|
])
|
|
|
|
|
|
setCurrentSession(nextSession)
|
|
|
|
|
|
setNewQuestion('')
|
|
|
|
|
|
skipNextOpenSessionRef.current = session.session_id
|
|
|
|
|
|
navigate(`/chat?session_id=${session.session_id}`, { replace: true })
|
|
|
|
|
|
|
2026-08-05 14:18:25 +00:00
|
|
|
|
clearPendingStop(nextSession.session_id)
|
2026-07-24 07:37:22 +00:00
|
|
|
|
try {
|
|
|
|
|
|
await sendMessageWithStream(nextSession, question, {
|
|
|
|
|
|
initialMessages: [optimisticMessage],
|
|
|
|
|
|
initialUserMessageId: optimisticMessage.id,
|
|
|
|
|
|
includeUserMessage: false,
|
|
|
|
|
|
})
|
2026-08-05 14:18:25 +00:00
|
|
|
|
touchSession(nextSession.session_id)
|
2026-07-24 07:37:22 +00:00
|
|
|
|
} catch (error) {
|
|
|
|
|
|
message.error(error.response?.data?.detail || error.message || '对话已创建,但首条消息发送失败')
|
2026-06-23 13:58:16 +00:00
|
|
|
|
} finally {
|
2026-07-24 07:37:22 +00:00
|
|
|
|
setSending(false)
|
2026-06-23 13:58:16 +00:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-24 07:37:22 +00:00
|
|
|
|
const handleSend = async () => {
|
|
|
|
|
|
if (sending) return
|
|
|
|
|
|
const content = inputValue.trim()
|
|
|
|
|
|
if (!content || !currentSession) return
|
|
|
|
|
|
setSending(true)
|
2026-06-23 13:58:16 +00:00
|
|
|
|
setInputValue('')
|
2026-08-05 14:18:25 +00:00
|
|
|
|
clearPendingStop(currentSession.session_id)
|
2026-07-24 07:37:22 +00:00
|
|
|
|
try {
|
|
|
|
|
|
await sendMessageWithStream(currentSession, content)
|
2026-08-05 14:18:25 +00:00
|
|
|
|
touchSession(currentSession.session_id)
|
2026-07-24 07:37:22 +00:00
|
|
|
|
} catch (error) {
|
|
|
|
|
|
message.error(error.response?.data?.detail || error.message || '发送消息失败')
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
setSending(false)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-05 14:18:25 +00:00
|
|
|
|
const handleStopGenerating = () => {
|
|
|
|
|
|
abortControllerRef.current?.abort()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const handleRegenerate = async (item) => {
|
|
|
|
|
|
if (sending) return
|
|
|
|
|
|
const index = messages.findIndex((m) => m.id === item.id)
|
|
|
|
|
|
if (index < 0) return
|
|
|
|
|
|
const userMsg = [...messages.slice(0, index)].reverse().find((m) => m.role === 'user')
|
|
|
|
|
|
if (!userMsg || !currentSession) return
|
|
|
|
|
|
|
|
|
|
|
|
const isRealId = typeof item.id === 'number' || /^\d+$/.test(String(item.id))
|
|
|
|
|
|
if (isRealId) {
|
|
|
|
|
|
try {
|
|
|
|
|
|
await deleteChatMessage(item.id)
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
message.error(error.response?.data?.detail || '删除旧回复失败')
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const baseMessages = messages.filter((m) => m.id !== item.id)
|
|
|
|
|
|
setMessages(baseMessages)
|
|
|
|
|
|
clearPendingStop(currentSession.session_id)
|
|
|
|
|
|
setSending(true)
|
|
|
|
|
|
try {
|
|
|
|
|
|
await sendMessageWithStream(currentSession, userMsg.content, {
|
|
|
|
|
|
initialMessages: baseMessages,
|
|
|
|
|
|
includeUserMessage: false,
|
|
|
|
|
|
insertUserMessage: false,
|
|
|
|
|
|
})
|
|
|
|
|
|
touchSession(currentSession.session_id)
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
message.error(error.response?.data?.detail || error.message || '重新生成失败')
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
setSending(false)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-24 07:37:22 +00:00
|
|
|
|
const sendMessageWithStream = async (session, content, options = {}) => {
|
|
|
|
|
|
const userMessage = {
|
|
|
|
|
|
id: `tmp-user-${Date.now()}`,
|
|
|
|
|
|
role: 'user',
|
|
|
|
|
|
content,
|
|
|
|
|
|
created_at: new Date().toISOString(),
|
|
|
|
|
|
}
|
|
|
|
|
|
const assistantMessage = {
|
|
|
|
|
|
id: `tmp-assistant-${Date.now()}`,
|
|
|
|
|
|
role: 'assistant',
|
|
|
|
|
|
content: '',
|
|
|
|
|
|
references: [],
|
|
|
|
|
|
status: 'thinking',
|
|
|
|
|
|
referencesVisible: false,
|
2026-08-07 08:31:54 +00:00
|
|
|
|
thinkingLog: [],
|
|
|
|
|
|
thinkingStartedAt: Date.now(),
|
|
|
|
|
|
durationMs: null,
|
|
|
|
|
|
thinkingVisible: false,
|
2026-07-24 07:37:22 +00:00
|
|
|
|
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
|
2026-06-23 13:58:16 +00:00
|
|
|
|
|
2026-08-05 14:18:25 +00:00
|
|
|
|
const controller = new AbortController()
|
|
|
|
|
|
abortControllerRef.current = controller
|
|
|
|
|
|
|
2026-06-23 13:58:16 +00:00
|
|
|
|
try {
|
2026-07-24 07:37:22 +00:00
|
|
|
|
await sendChatMessageStream(session.session_id, content, {
|
2026-08-05 14:18:25 +00:00
|
|
|
|
signal: controller.signal,
|
|
|
|
|
|
insertUserMessage: options.insertUserMessage,
|
2026-07-24 07:37:22 +00:00
|
|
|
|
onIds: (data) => {
|
|
|
|
|
|
const realUserId = data?.user_message_id
|
|
|
|
|
|
const realAssistantId = data?.assistant_message_id
|
|
|
|
|
|
if (realAssistantId != null) assistantMsgId = realAssistantId
|
|
|
|
|
|
setMessages((prev) => prev.map((item) => {
|
|
|
|
|
|
if (realUserId != null && item.id === tmpUserId) {
|
|
|
|
|
|
return { ...item, id: realUserId }
|
|
|
|
|
|
}
|
|
|
|
|
|
if (realAssistantId != null && item.id === assistantMessage.id) {
|
|
|
|
|
|
return { ...item, id: realAssistantId }
|
|
|
|
|
|
}
|
|
|
|
|
|
return item
|
|
|
|
|
|
}))
|
|
|
|
|
|
},
|
|
|
|
|
|
onReferences: (refs) => {
|
|
|
|
|
|
setMessages((prev) => prev.map((item) => (
|
|
|
|
|
|
item.id === assistantMsgId ? { ...item, references: refs || [] } : item
|
|
|
|
|
|
)))
|
2026-06-23 13:58:16 +00:00
|
|
|
|
},
|
2026-08-07 08:31:54 +00:00
|
|
|
|
onThinking: (entry) => {
|
|
|
|
|
|
setMessages((prev) => prev.map((item) => (
|
|
|
|
|
|
item.id === assistantMsgId
|
|
|
|
|
|
? {
|
|
|
|
|
|
...item,
|
|
|
|
|
|
thinkingLog: [...(item.thinkingLog || []), entry],
|
|
|
|
|
|
thinkingStartedAt: item.thinkingStartedAt || Date.now(),
|
|
|
|
|
|
}
|
|
|
|
|
|
: item
|
|
|
|
|
|
)))
|
|
|
|
|
|
},
|
2026-07-24 07:37:22 +00:00
|
|
|
|
onChunk: (chunk) => {
|
|
|
|
|
|
setMessages((prev) => prev.map((item) => (
|
|
|
|
|
|
item.id === assistantMsgId ? { ...item, content: `${item.content}${chunk}`, status: 'streaming' } : item
|
|
|
|
|
|
)))
|
2026-06-23 13:58:16 +00:00
|
|
|
|
},
|
2026-07-24 07:37:22 +00:00
|
|
|
|
onTitle: (data) => {
|
|
|
|
|
|
const nextTitle = data?.title
|
|
|
|
|
|
if (!nextTitle) return
|
|
|
|
|
|
const targetId = data.session_id ?? session.session_id
|
|
|
|
|
|
setSessions((prev) => prev.map((item) => (
|
|
|
|
|
|
item.session_id === targetId ? { ...item, title: nextTitle } : item
|
|
|
|
|
|
)))
|
|
|
|
|
|
setCurrentSession((prev) => (
|
|
|
|
|
|
prev && prev.session_id === targetId ? { ...prev, title: nextTitle } : prev
|
|
|
|
|
|
))
|
|
|
|
|
|
},
|
|
|
|
|
|
onDone: (data) => {
|
|
|
|
|
|
setMessages((prev) => prev.map((item) => (
|
|
|
|
|
|
item.id === assistantMsgId
|
|
|
|
|
|
? {
|
|
|
|
|
|
...item,
|
|
|
|
|
|
content: data?.content ?? item.content,
|
|
|
|
|
|
status: 'done',
|
2026-08-07 08:31:54 +00:00
|
|
|
|
durationMs: data?.duration_ms ?? item.durationMs,
|
|
|
|
|
|
thinkingLog: Array.isArray(data?.thinking_log) && data.thinking_log.length > 0
|
|
|
|
|
|
? data.thinking_log
|
|
|
|
|
|
: item.thinkingLog,
|
|
|
|
|
|
thinkingVisible: false,
|
2026-07-24 07:37:22 +00:00
|
|
|
|
referencesVisible: true,
|
|
|
|
|
|
}
|
|
|
|
|
|
: item
|
|
|
|
|
|
)))
|
|
|
|
|
|
},
|
|
|
|
|
|
})
|
2026-06-23 13:58:16 +00:00
|
|
|
|
} catch (error) {
|
2026-08-05 14:18:25 +00:00
|
|
|
|
const aborted = error?.name === 'AbortError'
|
|
|
|
|
|
if (aborted) {
|
|
|
|
|
|
// 会话级待确认标记:兜底覆盖后端中断标记写入前的时间窗口
|
|
|
|
|
|
markPendingStop(session.session_id)
|
|
|
|
|
|
|
|
|
|
|
|
// 立即把“已停止”写入数据库,保证切换会话/刷新后依然可识别
|
|
|
|
|
|
const stoppedId = String(assistantMsgId)
|
|
|
|
|
|
if (/^\d+$/.test(stoppedId)) {
|
|
|
|
|
|
markMessageInterrupted(Number(stoppedId)).catch(() => {})
|
|
|
|
|
|
} else {
|
|
|
|
|
|
// ids 事件尚未到达(例如在检索阶段就点了停止):先从服务端解析真实 id
|
|
|
|
|
|
;(async () => {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const res = await getChatMessages(session.session_id)
|
|
|
|
|
|
const freshMessages = res.data || []
|
|
|
|
|
|
const lastAssistant = [...freshMessages].reverse().find((m) => m.role === 'assistant')
|
|
|
|
|
|
if (lastAssistant) {
|
|
|
|
|
|
const realId = Number(lastAssistant.id)
|
|
|
|
|
|
await markMessageInterrupted(realId)
|
|
|
|
|
|
setMessages((prev) => prev.map((item) => (
|
|
|
|
|
|
item.id === assistantMessage.id || item.id === realId
|
2026-08-07 08:31:54 +00:00
|
|
|
|
? { ...item, id: realId, status: 'interrupted' }
|
2026-08-05 14:18:25 +00:00
|
|
|
|
: item
|
|
|
|
|
|
)))
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
// 标记失败不阻塞停止流程,会话级待确认标记继续兜底
|
|
|
|
|
|
}
|
|
|
|
|
|
})()
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-07-24 07:37:22 +00:00
|
|
|
|
setMessages((prev) => prev.map((item) => (
|
|
|
|
|
|
item.id === assistantMsgId
|
|
|
|
|
|
? {
|
|
|
|
|
|
...item,
|
2026-08-07 08:31:54 +00:00
|
|
|
|
status: aborted ? 'interrupted' : 'error',
|
2026-08-05 14:18:25 +00:00
|
|
|
|
error: aborted ? undefined : (error.message || '回答生成失败,请稍后重试'),
|
2026-08-07 08:31:54 +00:00
|
|
|
|
durationMs: aborted
|
|
|
|
|
|
? Date.now() - (item.thinkingStartedAt || Date.now())
|
|
|
|
|
|
: item.durationMs,
|
|
|
|
|
|
thinkingVisible: false,
|
2026-07-24 07:37:22 +00:00
|
|
|
|
referencesVisible: true,
|
|
|
|
|
|
}
|
|
|
|
|
|
: item
|
|
|
|
|
|
)))
|
2026-08-05 14:18:25 +00:00
|
|
|
|
if (!aborted) {
|
|
|
|
|
|
throw error
|
|
|
|
|
|
}
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
if (abortControllerRef.current === controller) {
|
|
|
|
|
|
abortControllerRef.current = null
|
|
|
|
|
|
}
|
2026-06-23 13:58:16 +00:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-24 07:37:22 +00:00
|
|
|
|
const handleDeleteMessage = (item) => {
|
|
|
|
|
|
const isTemp = typeof item.id === 'string' && item.id.startsWith('tmp-')
|
|
|
|
|
|
if (isTemp) {
|
|
|
|
|
|
setMessages((prev) => prev.filter((m) => m.id !== item.id))
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
2026-06-23 13:58:16 +00:00
|
|
|
|
Modal.confirm({
|
2026-07-24 07:37:22 +00:00
|
|
|
|
title: '删除消息',
|
|
|
|
|
|
content: '确定删除这条消息吗?删除后无法恢复。',
|
|
|
|
|
|
okText: '删除',
|
|
|
|
|
|
okType: 'danger',
|
|
|
|
|
|
cancelText: '取消',
|
2026-06-23 13:58:16 +00:00
|
|
|
|
onOk: async () => {
|
|
|
|
|
|
try {
|
2026-07-24 07:37:22 +00:00
|
|
|
|
await deleteChatMessage(item.id)
|
|
|
|
|
|
setMessages((prev) => prev.filter((m) => m.id !== item.id))
|
2026-06-23 13:58:16 +00:00
|
|
|
|
} catch (error) {
|
2026-07-24 07:37:22 +00:00
|
|
|
|
message.error(error.response?.data?.detail || '删除失败')
|
2026-06-23 13:58:16 +00:00
|
|
|
|
}
|
|
|
|
|
|
},
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-07 08:31:54 +00:00
|
|
|
|
const toggleThinking = (id) => {
|
|
|
|
|
|
setMessages((prev) => prev.map((item) => (
|
|
|
|
|
|
item.id === id ? { ...item, thinkingVisible: !item.thinkingVisible } : item
|
|
|
|
|
|
)))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-24 07:37:22 +00:00
|
|
|
|
const handleSearch = async () => {
|
|
|
|
|
|
const keyword = searchKeyword.trim()
|
|
|
|
|
|
if (!keyword) {
|
|
|
|
|
|
searchRequestIdRef.current += 1
|
|
|
|
|
|
setSearchResults([])
|
|
|
|
|
|
setSearchedKeyword('')
|
|
|
|
|
|
setHasSearched(false)
|
|
|
|
|
|
setSearchLoading(false)
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
const requestId = ++searchRequestIdRef.current
|
|
|
|
|
|
setSearchLoading(true)
|
|
|
|
|
|
try {
|
|
|
|
|
|
const res = await searchChatMessages(keyword)
|
|
|
|
|
|
if (requestId !== searchRequestIdRef.current) return
|
|
|
|
|
|
setSearchResults((res.data || []).filter((item) => item.role === 'user' || item.role === 'assistant'))
|
|
|
|
|
|
setSearchedKeyword(keyword)
|
|
|
|
|
|
setHasSearched(true)
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
if (requestId === searchRequestIdRef.current) {
|
|
|
|
|
|
message.error(error.response?.data?.detail || error.message || '搜索失败')
|
|
|
|
|
|
}
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
if (requestId === searchRequestIdRef.current) {
|
|
|
|
|
|
setSearchLoading(false)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const handleSearchKeywordChange = (event) => {
|
|
|
|
|
|
const value = event.target.value
|
|
|
|
|
|
setSearchKeyword(value)
|
|
|
|
|
|
if (!value.trim()) {
|
|
|
|
|
|
searchRequestIdRef.current += 1
|
|
|
|
|
|
setSearchResults([])
|
|
|
|
|
|
setSearchedKeyword('')
|
|
|
|
|
|
setHasSearched(false)
|
|
|
|
|
|
setSearchLoading(false)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const handleSelectSearchResult = async (item) => {
|
|
|
|
|
|
setSearchVisible(false)
|
|
|
|
|
|
await openSession(item.session_id, { message_id: item.message_id })
|
2026-06-23 13:58:16 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-24 07:37:22 +00:00
|
|
|
|
const handleRenameSession = async () => {
|
|
|
|
|
|
const title = renameTitle.trim()
|
|
|
|
|
|
if (!renameSession || !title) {
|
|
|
|
|
|
message.warning('请输入对话名称')
|
2026-06-23 13:58:16 +00:00
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
try {
|
2026-07-24 07:37:22 +00:00
|
|
|
|
await updateChatSessionTitle(renameSession.session_id, title)
|
|
|
|
|
|
setSessions((prev) => prev.map((item) => (
|
|
|
|
|
|
item.session_id === renameSession.session_id ? { ...item, title } : item
|
|
|
|
|
|
)))
|
|
|
|
|
|
if (currentSession?.session_id === renameSession.session_id) {
|
|
|
|
|
|
setCurrentSession((prev) => ({ ...prev, title }))
|
|
|
|
|
|
}
|
|
|
|
|
|
setRenameVisible(false)
|
|
|
|
|
|
setRenameSession(null)
|
|
|
|
|
|
setRenameTitle('')
|
2026-06-23 13:58:16 +00:00
|
|
|
|
} catch (error) {
|
2026-07-24 07:37:22 +00:00
|
|
|
|
message.error(error.response?.data?.detail || '重命名失败')
|
2026-06-23 13:58:16 +00:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-24 07:37:22 +00:00
|
|
|
|
const handleDeleteSession = (session) => {
|
|
|
|
|
|
Modal.confirm({
|
|
|
|
|
|
title: '删除对话',
|
|
|
|
|
|
content: `确定删除「${session.title}」吗?删除后无法恢复。`,
|
|
|
|
|
|
okText: '删除',
|
|
|
|
|
|
okType: 'danger',
|
|
|
|
|
|
cancelText: '取消',
|
|
|
|
|
|
onOk: async () => {
|
|
|
|
|
|
try {
|
|
|
|
|
|
await deleteChatSession(session.session_id)
|
2026-08-05 14:18:25 +00:00
|
|
|
|
setSessions((prev) => prev.filter((item) => item.session_id !== session.session_id))
|
2026-07-24 07:37:22 +00:00
|
|
|
|
if (currentSession?.session_id === session.session_id) {
|
|
|
|
|
|
setCurrentSession(null)
|
|
|
|
|
|
setMessages([])
|
|
|
|
|
|
navigate('/chat', { replace: true })
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
message.error(error.response?.data?.detail || '删除失败')
|
|
|
|
|
|
}
|
|
|
|
|
|
},
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
2026-06-23 13:58:16 +00:00
|
|
|
|
|
2026-07-24 07:37:22 +00:00
|
|
|
|
const renderSessionList = () => (
|
|
|
|
|
|
<div className="chat-history-panel">
|
|
|
|
|
|
{loadingSessions ? (
|
|
|
|
|
|
<div className="chat-panel-loading">
|
|
|
|
|
|
<Spin />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
) : sessions.length === 0 ? (
|
|
|
|
|
|
<Empty description="暂无对话记录" />
|
|
|
|
|
|
) : (
|
|
|
|
|
|
groupedSessions.map((group) => (
|
|
|
|
|
|
<div className="chat-session-group" key={group.key}>
|
|
|
|
|
|
<div className="chat-session-group-title">{group.label}</div>
|
|
|
|
|
|
<List
|
|
|
|
|
|
dataSource={group.items}
|
|
|
|
|
|
renderItem={(item) => {
|
|
|
|
|
|
const active = currentSession?.session_id === item.session_id
|
|
|
|
|
|
const project = projects.find((project) => project.id === item.project_id)
|
|
|
|
|
|
const menuItems = [
|
|
|
|
|
|
{
|
|
|
|
|
|
key: 'rename',
|
|
|
|
|
|
icon: <EditOutlined />,
|
|
|
|
|
|
label: '重命名',
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
key: 'delete',
|
|
|
|
|
|
icon: <DeleteOutlined />,
|
|
|
|
|
|
label: '删除',
|
|
|
|
|
|
danger: true,
|
|
|
|
|
|
},
|
|
|
|
|
|
]
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div
|
|
|
|
|
|
className={`chat-session-item${active ? ' active' : ''}`}
|
|
|
|
|
|
onClick={() => openSession(item.session_id)}
|
|
|
|
|
|
>
|
|
|
|
|
|
<div className="chat-session-item-top">
|
|
|
|
|
|
<div className="chat-session-title-area">
|
|
|
|
|
|
<div className="chat-session-title-row">
|
|
|
|
|
|
<div className="chat-session-title">{item.title}</div>
|
|
|
|
|
|
<Dropdown
|
|
|
|
|
|
menu={{
|
|
|
|
|
|
items: menuItems,
|
|
|
|
|
|
onClick: ({ key, domEvent }) => {
|
|
|
|
|
|
domEvent.stopPropagation()
|
|
|
|
|
|
if (key === 'rename') {
|
|
|
|
|
|
setRenameSession(item)
|
|
|
|
|
|
setRenameTitle(item.title)
|
|
|
|
|
|
setRenameVisible(true)
|
|
|
|
|
|
}
|
|
|
|
|
|
if (key === 'delete') {
|
|
|
|
|
|
handleDeleteSession(item)
|
|
|
|
|
|
}
|
|
|
|
|
|
},
|
|
|
|
|
|
}}
|
|
|
|
|
|
trigger={['click']}
|
|
|
|
|
|
>
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
className="chat-session-more"
|
|
|
|
|
|
onClick={(event) => event.stopPropagation()}
|
|
|
|
|
|
aria-label="更多操作"
|
|
|
|
|
|
>
|
|
|
|
|
|
<MoreOutlined />
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</Dropdown>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="chat-session-meta">
|
|
|
|
|
|
<Tag className="chat-session-meta-text" icon={<FolderOutlined />}>{project?.name || '未命名项目'}</Tag>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)
|
|
|
|
|
|
}}
|
2026-06-23 13:58:16 +00:00
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
2026-07-24 07:37:22 +00:00
|
|
|
|
))
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)
|
2026-06-23 13:58:16 +00:00
|
|
|
|
|
2026-07-24 07:37:22 +00:00
|
|
|
|
const renderMessages = () => (
|
|
|
|
|
|
<>
|
2026-08-05 14:18:25 +00:00
|
|
|
|
<div className="chat-message-list" ref={messageListRef} onScroll={handleMessageListScroll}>
|
2026-07-24 07:37:22 +00:00
|
|
|
|
{loadingMessages ? (
|
|
|
|
|
|
<div className="chat-panel-loading">
|
2026-06-23 13:58:16 +00:00
|
|
|
|
<Spin />
|
2026-07-24 07:37:22 +00:00
|
|
|
|
</div>
|
|
|
|
|
|
) : messages.length === 0 ? (
|
|
|
|
|
|
<Empty description="开始输入问题" />
|
|
|
|
|
|
) : (
|
|
|
|
|
|
messages.map((item) => {
|
|
|
|
|
|
const isUser = item.role === 'user'
|
|
|
|
|
|
const refs = item.references || []
|
2026-08-05 14:18:25 +00:00
|
|
|
|
const rawContent = item.content || ''
|
2026-08-07 08:31:54 +00:00
|
|
|
|
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')
|
2026-08-05 14:18:25 +00:00
|
|
|
|
const hasContent = Boolean(displayContent.trim())
|
2026-08-07 08:31:54 +00:00
|
|
|
|
const isThinking = !isUser && !hasContent && isLive
|
|
|
|
|
|
const isStreaming = !isUser && status === 'streaming'
|
|
|
|
|
|
const thinkingLog = Array.isArray(item.thinkingLog) ? item.thinkingLog : []
|
2026-07-24 07:37:22 +00:00
|
|
|
|
const showReferences = !isUser && item.referencesVisible !== false && refs.length > 0
|
|
|
|
|
|
const canDelete = typeof item.id === 'number' || /^\d+$/.test(String(item.id))
|
|
|
|
|
|
const projectId = currentSession?.project_id
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div
|
|
|
|
|
|
key={item.id}
|
|
|
|
|
|
ref={(node) => {
|
|
|
|
|
|
if (node) {
|
|
|
|
|
|
messageRefs.current.set(String(item.id), node)
|
|
|
|
|
|
} else {
|
|
|
|
|
|
messageRefs.current.delete(String(item.id))
|
|
|
|
|
|
}
|
2026-06-23 13:58:16 +00:00
|
|
|
|
}}
|
2026-07-24 07:37:22 +00:00
|
|
|
|
className={`chat-message-row ${isUser ? 'user' : 'assistant'}`}
|
2026-06-23 13:58:16 +00:00
|
|
|
|
>
|
2026-07-24 07:37:22 +00:00
|
|
|
|
<div className="chat-message-column">
|
|
|
|
|
|
<div className={`chat-message-bubble ${isUser ? 'user' : 'assistant'}`}>
|
|
|
|
|
|
{isUser ? (
|
2026-08-05 14:18:25 +00:00
|
|
|
|
<div className="chat-plain-text">{displayContent}</div>
|
2026-06-23 13:58:16 +00:00
|
|
|
|
) : (
|
2026-07-24 07:37:22 +00:00
|
|
|
|
<>
|
|
|
|
|
|
{isError && !hasContent ? (
|
|
|
|
|
|
<div className="chat-message-error">
|
|
|
|
|
|
{item.error || '回答生成失败,请稍后重试'}
|
|
|
|
|
|
</div>
|
2026-08-07 08:31:54 +00:00
|
|
|
|
) : isInterrupted && !hasContent ? (
|
2026-08-05 14:18:25 +00:00
|
|
|
|
<div className="chat-message-stopped">已停止生成</div>
|
2026-07-24 07:37:22 +00:00
|
|
|
|
) : isThinking ? (
|
2026-08-07 08:31:54 +00:00
|
|
|
|
<ThinkingPanel active log={thinkingLog} startedAt={item.thinkingStartedAt} />
|
2026-07-24 07:37:22 +00:00
|
|
|
|
) : (
|
2026-08-07 08:31:54 +00:00
|
|
|
|
<>
|
|
|
|
|
|
{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' : ''}`}>
|
2026-07-24 07:37:22 +00:00
|
|
|
|
<ReactMarkdown
|
|
|
|
|
|
remarkPlugins={[remarkGfm]}
|
|
|
|
|
|
rehypePlugins={[rehypeHighlight, rehypeCitationSup]}
|
|
|
|
|
|
components={{
|
|
|
|
|
|
sup: ({ children, ...props }) => {
|
|
|
|
|
|
const citationId = Number(props['data-citation-id'])
|
2026-08-07 08:31:54 +00:00
|
|
|
|
const occIndex = Number(props['data-citation-occ'] ?? 0)
|
2026-07-24 07:37:22 +00:00
|
|
|
|
const ref = refs.find((itemRef) => Number(itemRef.citation_id) === citationId)
|
|
|
|
|
|
if (!ref) {
|
|
|
|
|
|
return <sup {...props}>{children}</sup>
|
|
|
|
|
|
}
|
2026-08-07 08:31:54 +00:00
|
|
|
|
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))
|
2026-07-24 07:37:22 +00:00
|
|
|
|
return (
|
|
|
|
|
|
<Popover
|
|
|
|
|
|
trigger="hover"
|
|
|
|
|
|
placement="top"
|
|
|
|
|
|
overlayClassName="chat-citation-popover"
|
|
|
|
|
|
content={(
|
|
|
|
|
|
<div className="chat-citation-preview">
|
2026-08-07 08:31:54 +00:00
|
|
|
|
<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>
|
2026-07-24 07:37:22 +00:00
|
|
|
|
<div className="chat-citation-preview-content">
|
2026-08-07 08:31:54 +00:00
|
|
|
|
{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>
|
|
|
|
|
|
)}
|
2026-07-24 07:37:22 +00:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
>
|
|
|
|
|
|
<sup {...props}>{children}</sup>
|
|
|
|
|
|
</Popover>
|
|
|
|
|
|
)
|
|
|
|
|
|
},
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
2026-08-05 14:18:25 +00:00
|
|
|
|
{displayContent}
|
2026-07-24 07:37:22 +00:00
|
|
|
|
</ReactMarkdown>
|
|
|
|
|
|
{isError && (
|
|
|
|
|
|
<div className="chat-message-error chat-message-error-inline">
|
|
|
|
|
|
{item.error || '回答生成中断,请稍后重试'}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
2026-08-07 08:31:54 +00:00
|
|
|
|
{isInterrupted && hasContent && (
|
2026-08-05 14:18:25 +00:00
|
|
|
|
<div className="chat-message-stopped">已停止生成</div>
|
|
|
|
|
|
)}
|
2026-08-07 08:31:54 +00:00
|
|
|
|
</div>
|
|
|
|
|
|
</>
|
2026-07-24 07:37:22 +00:00
|
|
|
|
)}
|
|
|
|
|
|
{showReferences && (
|
|
|
|
|
|
<div className="chat-references">
|
2026-08-07 08:31:54 +00:00
|
|
|
|
<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">
|
2026-07-24 07:37:22 +00:00
|
|
|
|
{refs.map((ref) => {
|
|
|
|
|
|
return (
|
2026-08-07 08:31:54 +00:00
|
|
|
|
<ReferenceCard
|
2026-07-24 07:37:22 +00:00
|
|
|
|
key={`${ref.citation_id}-${ref.file_path || ref.file_name}`}
|
2026-08-07 08:31:54 +00:00
|
|
|
|
reference={ref}
|
|
|
|
|
|
projectId={projectId}
|
|
|
|
|
|
/>
|
2026-07-24 07:37:22 +00:00
|
|
|
|
)
|
|
|
|
|
|
})}
|
2026-08-07 08:31:54 +00:00
|
|
|
|
</div>
|
2026-07-24 07:37:22 +00:00
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</>
|
2026-06-23 13:58:16 +00:00
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
2026-08-07 08:31:54 +00:00
|
|
|
|
{(isUser || !isLive) && (
|
2026-07-24 07:37:22 +00:00
|
|
|
|
<MessageActions
|
2026-08-05 14:18:25 +00:00
|
|
|
|
content={displayContent}
|
2026-07-24 07:37:22 +00:00
|
|
|
|
onDelete={canDelete ? () => handleDeleteMessage(item) : undefined}
|
2026-08-07 08:31:54 +00:00
|
|
|
|
onRegenerate={!isUser && (isInterrupted || isError) ? () => handleRegenerate(item) : undefined}
|
2026-06-23 13:58:16 +00:00
|
|
|
|
/>
|
2026-07-24 07:37:22 +00:00
|
|
|
|
)}
|
2026-06-23 13:58:16 +00:00
|
|
|
|
</div>
|
2026-07-24 07:37:22 +00:00
|
|
|
|
</div>
|
|
|
|
|
|
)
|
|
|
|
|
|
})
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="chat-composer-shell">
|
|
|
|
|
|
<TextArea
|
2026-08-05 14:18:25 +00:00
|
|
|
|
ref={composerRef}
|
2026-07-24 07:37:22 +00:00
|
|
|
|
className="chat-composer-input"
|
|
|
|
|
|
value={inputValue}
|
|
|
|
|
|
onChange={(e) => setInputValue(e.target.value)}
|
|
|
|
|
|
autoSize={{ minRows: 1, maxRows: 8 }}
|
|
|
|
|
|
placeholder="随心输入"
|
|
|
|
|
|
onPressEnter={(e) => {
|
|
|
|
|
|
if (!e.shiftKey) {
|
|
|
|
|
|
e.preventDefault()
|
|
|
|
|
|
handleSend()
|
|
|
|
|
|
}
|
|
|
|
|
|
}}
|
|
|
|
|
|
/>
|
|
|
|
|
|
<div className="chat-composer-actions">
|
|
|
|
|
|
<div className="chat-composer-left">
|
|
|
|
|
|
<span className="chat-composer-pill chat-composer-kb">
|
|
|
|
|
|
<FolderOutlined className="chat-composer-pill-icon" />
|
|
|
|
|
|
<span className="chat-composer-pill-text">
|
|
|
|
|
|
{projects.find((project) => project.id === currentSession?.project_id)?.name || '-'}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="chat-composer-right">
|
|
|
|
|
|
<span className="chat-composer-pill chat-composer-model">
|
|
|
|
|
|
<RobotOutlined className="chat-composer-pill-icon" />
|
|
|
|
|
|
<span className="chat-composer-pill-text">
|
|
|
|
|
|
{models.find((model) => model.config_id === currentSession?.llm_config_id)?.model_name || '-'}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</span>
|
2026-08-05 14:18:25 +00:00
|
|
|
|
{sending ? (
|
|
|
|
|
|
<Button
|
|
|
|
|
|
shape="circle"
|
|
|
|
|
|
icon={<StopOutlined />}
|
|
|
|
|
|
onClick={handleStopGenerating}
|
|
|
|
|
|
title="停止生成"
|
|
|
|
|
|
aria-label="停止生成"
|
|
|
|
|
|
className="chat-stop-button"
|
|
|
|
|
|
/>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<Button
|
|
|
|
|
|
shape="circle"
|
|
|
|
|
|
icon={<ArrowUpOutlined />}
|
|
|
|
|
|
onClick={handleSend}
|
|
|
|
|
|
disabled={!canSendMessage}
|
|
|
|
|
|
aria-disabled={!canSendMessage}
|
|
|
|
|
|
className="chat-send-button"
|
|
|
|
|
|
/>
|
|
|
|
|
|
)}
|
2026-07-24 07:37:22 +00:00
|
|
|
|
</div>
|
2026-06-23 13:58:16 +00:00
|
|
|
|
</div>
|
2026-07-24 07:37:22 +00:00
|
|
|
|
</div>
|
|
|
|
|
|
</>
|
|
|
|
|
|
)
|
2026-06-23 13:58:16 +00:00
|
|
|
|
|
2026-07-24 07:37:22 +00:00
|
|
|
|
const renderChatShell = () => (
|
|
|
|
|
|
<div className="chat-shell">
|
|
|
|
|
|
<div className="chat-header">
|
|
|
|
|
|
<div className="chat-header-main">
|
|
|
|
|
|
<Text className="chat-header-title">{currentSession?.title || '对话'}</Text>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
{renderMessages()}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)
|
2026-06-23 13:58:16 +00:00
|
|
|
|
|
2026-07-24 07:37:22 +00:00
|
|
|
|
const renderNewShell = () => (
|
|
|
|
|
|
<div className="chat-start-shell">
|
|
|
|
|
|
<div className="chat-new-page-card">
|
|
|
|
|
|
<div className="chat-start-title">你希望了解什么?</div>
|
2026-08-05 14:18:25 +00:00
|
|
|
|
{projects.length === 0 && (
|
|
|
|
|
|
<Alert
|
|
|
|
|
|
type="warning"
|
|
|
|
|
|
showIcon
|
|
|
|
|
|
message="暂无可用知识库"
|
|
|
|
|
|
description="请先在「项目空间」创建项目,再进入这里提问。"
|
|
|
|
|
|
/>
|
|
|
|
|
|
)}
|
|
|
|
|
|
{projects.length > 0 && models.length === 0 && (
|
|
|
|
|
|
<Alert
|
|
|
|
|
|
type="warning"
|
|
|
|
|
|
showIcon
|
|
|
|
|
|
message="暂无可用对话模型"
|
|
|
|
|
|
description="请先到「系统管理 - 模型配置」添加并启用对话模型。"
|
|
|
|
|
|
/>
|
|
|
|
|
|
)}
|
2026-07-24 07:37:22 +00:00
|
|
|
|
<div className="chat-composer-shell chat-new-composer">
|
|
|
|
|
|
<TextArea
|
|
|
|
|
|
value={newQuestion}
|
|
|
|
|
|
onChange={(e) => setNewQuestion(e.target.value)}
|
|
|
|
|
|
className="chat-composer-input"
|
2026-08-05 14:18:25 +00:00
|
|
|
|
autoFocus
|
2026-07-24 07:37:22 +00:00
|
|
|
|
autoSize={{ minRows: 2, maxRows: 10 }}
|
|
|
|
|
|
placeholder="随心输入"
|
|
|
|
|
|
onPressEnter={(e) => {
|
|
|
|
|
|
if (!e.shiftKey) {
|
|
|
|
|
|
e.preventDefault()
|
|
|
|
|
|
handleCreateSession()
|
|
|
|
|
|
}
|
|
|
|
|
|
}}
|
|
|
|
|
|
/>
|
|
|
|
|
|
<div className="chat-composer-actions">
|
|
|
|
|
|
<div className="chat-composer-left">
|
|
|
|
|
|
<Popover
|
|
|
|
|
|
open={projectPickerOpen}
|
|
|
|
|
|
onOpenChange={setProjectPickerOpen}
|
|
|
|
|
|
trigger="click"
|
|
|
|
|
|
placement="topLeft"
|
|
|
|
|
|
content={
|
|
|
|
|
|
<div className="chat-project-picker">
|
|
|
|
|
|
<Select
|
|
|
|
|
|
value={newProjectId}
|
|
|
|
|
|
placeholder="选择知识库"
|
|
|
|
|
|
options={projects.map((item) => ({ label: item.name, value: item.id }))}
|
|
|
|
|
|
onChange={(value) => {
|
|
|
|
|
|
setNewProjectId(value)
|
|
|
|
|
|
setProjectPickerOpen(false)
|
|
|
|
|
|
}}
|
|
|
|
|
|
suffixIcon={<DownOutlined />}
|
|
|
|
|
|
showSearch
|
|
|
|
|
|
optionFilterProp="label"
|
|
|
|
|
|
className="chat-project-picker-select"
|
|
|
|
|
|
/>
|
2026-06-23 13:58:16 +00:00
|
|
|
|
</div>
|
2026-07-24 07:37:22 +00:00
|
|
|
|
}
|
|
|
|
|
|
>
|
|
|
|
|
|
<button type="button" className="chat-composer-plus" aria-label="选择知识库">
|
|
|
|
|
|
<PlusOutlined />
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</Popover>
|
|
|
|
|
|
{currentProject && (
|
|
|
|
|
|
<span className="chat-composer-pill chat-composer-kb">
|
|
|
|
|
|
<FolderOutlined className="chat-composer-pill-icon" />
|
|
|
|
|
|
<span className="chat-composer-pill-text">{currentProject.name}</span>
|
|
|
|
|
|
</span>
|
2026-06-23 13:58:16 +00:00
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
2026-07-24 07:37:22 +00:00
|
|
|
|
<div className="chat-composer-right">
|
|
|
|
|
|
<Select
|
|
|
|
|
|
value={newModelId}
|
|
|
|
|
|
placeholder="选择模型"
|
|
|
|
|
|
options={models.map((item) => ({ label: item.model_name, value: item.config_id }))}
|
|
|
|
|
|
onChange={setNewModelId}
|
|
|
|
|
|
suffixIcon={<DownOutlined />}
|
|
|
|
|
|
showSearch
|
|
|
|
|
|
optionFilterProp="label"
|
|
|
|
|
|
className="chat-composer-model-select"
|
2026-06-23 13:58:16 +00:00
|
|
|
|
/>
|
2026-08-05 14:18:25 +00:00
|
|
|
|
{sending ? (
|
|
|
|
|
|
<Button
|
|
|
|
|
|
shape="circle"
|
|
|
|
|
|
icon={<StopOutlined />}
|
|
|
|
|
|
onClick={handleStopGenerating}
|
|
|
|
|
|
title="停止生成"
|
|
|
|
|
|
aria-label="停止生成"
|
|
|
|
|
|
className="chat-stop-button"
|
|
|
|
|
|
/>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<Button
|
|
|
|
|
|
shape="circle"
|
|
|
|
|
|
icon={<ArrowUpOutlined />}
|
|
|
|
|
|
onClick={handleCreateSession}
|
|
|
|
|
|
disabled={!canCreateSession}
|
|
|
|
|
|
aria-disabled={!canCreateSession}
|
|
|
|
|
|
className="chat-send-button"
|
|
|
|
|
|
/>
|
|
|
|
|
|
)}
|
2026-06-23 13:58:16 +00:00
|
|
|
|
</div>
|
2026-07-24 07:37:22 +00:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div className="chat-page-shell">
|
|
|
|
|
|
<aside className="chat-left-panel">
|
|
|
|
|
|
<div className="chat-left-actions">
|
|
|
|
|
|
<button className="chat-action-entry" onClick={handleStartNew}>
|
|
|
|
|
|
<EditOutlined />
|
|
|
|
|
|
<span>新建对话</span>
|
|
|
|
|
|
</button>
|
|
|
|
|
|
<button className="chat-action-entry" onClick={() => setSearchVisible(true)}>
|
|
|
|
|
|
<SearchOutlined />
|
|
|
|
|
|
<span>搜索对话</span>
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
{renderSessionList()}
|
|
|
|
|
|
</aside>
|
|
|
|
|
|
|
|
|
|
|
|
<main className="chat-main-panel">
|
2026-08-07 08:31:54 +00:00
|
|
|
|
{openingExistingSession ? (
|
|
|
|
|
|
<div className="chat-panel-loading">
|
|
|
|
|
|
<Spin />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
) : currentSession && !newMode ? renderChatShell() : renderNewShell()}
|
2026-07-24 07:37:22 +00:00
|
|
|
|
</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>
|
2026-06-23 13:58:16 +00:00
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export default Chat
|