2026-07-24 07:37:22 +00:00
|
|
|
|
import { useEffect, useMemo, useRef, useState } from 'react'
|
|
|
|
|
|
import { useLocation, useNavigate, useSearchParams } from 'react-router-dom'
|
|
|
|
|
|
import {
|
|
|
|
|
|
Avatar,
|
2026-08-05 14:18:25 +00:00
|
|
|
|
Alert,
|
2026-07-24 07:37:22 +00:00
|
|
|
|
Button,
|
|
|
|
|
|
Dropdown,
|
|
|
|
|
|
Empty,
|
|
|
|
|
|
Input,
|
|
|
|
|
|
List,
|
|
|
|
|
|
Modal,
|
|
|
|
|
|
Popover,
|
|
|
|
|
|
Select,
|
|
|
|
|
|
Space,
|
|
|
|
|
|
Spin,
|
|
|
|
|
|
Tag,
|
|
|
|
|
|
Typography,
|
|
|
|
|
|
message,
|
|
|
|
|
|
} from 'antd'
|
|
|
|
|
|
import {
|
|
|
|
|
|
FolderOutlined,
|
|
|
|
|
|
EditOutlined,
|
|
|
|
|
|
DeleteOutlined,
|
|
|
|
|
|
MoreOutlined,
|
|
|
|
|
|
PlusOutlined,
|
|
|
|
|
|
RobotOutlined,
|
|
|
|
|
|
SearchOutlined,
|
|
|
|
|
|
ArrowUpOutlined,
|
|
|
|
|
|
UserOutlined,
|
|
|
|
|
|
DownOutlined,
|
|
|
|
|
|
CopyOutlined,
|
|
|
|
|
|
CheckOutlined,
|
2026-08-05 14:18:25 +00:00
|
|
|
|
StopOutlined,
|
|
|
|
|
|
ReloadOutlined,
|
2026-07-24 07:37:22 +00:00
|
|
|
|
} from '@ant-design/icons'
|
|
|
|
|
|
import ReactMarkdown from 'react-markdown'
|
|
|
|
|
|
import Highlighter from 'react-highlight-words'
|
|
|
|
|
|
import remarkGfm from 'remark-gfm'
|
|
|
|
|
|
import rehypeHighlight from 'rehype-highlight'
|
|
|
|
|
|
import 'highlight.js/styles/github.css'
|
2026-08-05 14:18:25 +00:00
|
|
|
|
import { createChatSession, deleteChatMessage, deleteChatSession, getChatMessages, getChatSessions, markMessageInterrupted, searchChatMessages, sendChatMessageStream, updateChatSessionTitle } from '@/api/chat'
|
2026-06-23 13:58:16 +00:00
|
|
|
|
import { getMyProjects } from '@/api/project'
|
|
|
|
|
|
import { getLLMModelConfigs } from '@/api/llmModelConfigs'
|
2026-08-04 01:31:48 +00:00
|
|
|
|
import useUserStore from '@/stores/userStore'
|
2026-06-23 13:58:16 +00:00
|
|
|
|
import './Chat.css'
|
|
|
|
|
|
|
2026-07-24 07:37:22 +00:00
|
|
|
|
const { TextArea } = Input
|
|
|
|
|
|
const { Text } = Typography
|
|
|
|
|
|
|
|
|
|
|
|
const CITATION_RE = /\[(\d+)\]/g
|
2026-08-05 14:18:25 +00:00
|
|
|
|
// 与后端 INTERRUPTED_RESPONSE_MARKER 保持一致,用于识别“已停止生成”的消息
|
|
|
|
|
|
const STOPPED_MARKER = 'interrupt'
|
|
|
|
|
|
const STOPPED_SUFFIX = `\n\n${STOPPED_MARKER}`
|
|
|
|
|
|
|
|
|
|
|
|
function isStoppedContent(content) {
|
|
|
|
|
|
return content === STOPPED_MARKER || content.endsWith(STOPPED_SUFFIX)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function stripStoppedContent(content) {
|
|
|
|
|
|
if (content === STOPPED_MARKER) return ''
|
|
|
|
|
|
if (content.endsWith(STOPPED_SUFFIX)) {
|
|
|
|
|
|
return content.slice(0, -STOPPED_SUFFIX.length).replace(/\s+$/g, '')
|
|
|
|
|
|
}
|
|
|
|
|
|
return content
|
|
|
|
|
|
}
|
2026-07-24 07:37:22 +00:00
|
|
|
|
|
|
|
|
|
|
// rehype 插件:将正文中的 [n] 引用编号转换为上角标 <sup>,便于与正文区分。
|
|
|
|
|
|
// 跳过 code/pre 节点,避免破坏代码块中的方括号内容。
|
|
|
|
|
|
function rehypeCitationSup() {
|
|
|
|
|
|
const walk = (node, inCode) => {
|
|
|
|
|
|
if (!node.children) return
|
|
|
|
|
|
const nextChildren = []
|
|
|
|
|
|
for (const child of node.children) {
|
|
|
|
|
|
const childInCode = inCode || child.tagName === 'code' || child.tagName === 'pre'
|
|
|
|
|
|
if (child.type === 'text' && !childInCode && CITATION_RE.test(child.value)) {
|
|
|
|
|
|
CITATION_RE.lastIndex = 0
|
|
|
|
|
|
let lastIndex = 0
|
|
|
|
|
|
let match
|
|
|
|
|
|
while ((match = CITATION_RE.exec(child.value)) !== null) {
|
|
|
|
|
|
if (match.index > lastIndex) {
|
|
|
|
|
|
nextChildren.push({ type: 'text', value: child.value.slice(lastIndex, match.index) })
|
|
|
|
|
|
}
|
|
|
|
|
|
nextChildren.push({
|
|
|
|
|
|
type: 'element',
|
|
|
|
|
|
tagName: 'sup',
|
|
|
|
|
|
properties: { className: ['chat-citation'], 'data-citation-id': match[1] },
|
|
|
|
|
|
children: [{ type: 'text', value: `[${match[1]}]` }],
|
|
|
|
|
|
})
|
|
|
|
|
|
lastIndex = match.index + match[0].length
|
|
|
|
|
|
}
|
|
|
|
|
|
if (lastIndex < child.value.length) {
|
|
|
|
|
|
nextChildren.push({ type: 'text', value: child.value.slice(lastIndex) })
|
|
|
|
|
|
}
|
|
|
|
|
|
} else {
|
|
|
|
|
|
if (child.type === 'element') walk(child, childInCode)
|
|
|
|
|
|
nextChildren.push(child)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
node.children = nextChildren
|
|
|
|
|
|
}
|
|
|
|
|
|
return (tree) => walk(tree, false)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function formatTime(value) {
|
|
|
|
|
|
if (!value) return ''
|
|
|
|
|
|
return new Date(value).toLocaleString('zh-CN', {
|
|
|
|
|
|
month: '2-digit',
|
|
|
|
|
|
day: '2-digit',
|
|
|
|
|
|
hour: '2-digit',
|
|
|
|
|
|
minute: '2-digit',
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function getDateKey(value) {
|
|
|
|
|
|
if (!value) return 'unknown'
|
|
|
|
|
|
const date = new Date(value)
|
|
|
|
|
|
if (Number.isNaN(date.getTime())) return 'unknown'
|
|
|
|
|
|
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function formatDateGroup(value) {
|
|
|
|
|
|
if (!value || value === 'unknown') return '未知日期'
|
|
|
|
|
|
const date = new Date(`${value}T00:00:00`)
|
|
|
|
|
|
const today = new Date()
|
|
|
|
|
|
const yesterday = new Date()
|
|
|
|
|
|
yesterday.setDate(today.getDate() - 1)
|
|
|
|
|
|
const todayKey = getDateKey(today)
|
|
|
|
|
|
const yesterdayKey = getDateKey(yesterday)
|
|
|
|
|
|
|
|
|
|
|
|
if (value === todayKey) return '今天'
|
|
|
|
|
|
if (value === yesterdayKey) return '昨天'
|
|
|
|
|
|
if (date.getFullYear() === today.getFullYear()) {
|
|
|
|
|
|
return `${date.getMonth() + 1}月${date.getDate()}日`
|
|
|
|
|
|
}
|
|
|
|
|
|
return `${date.getFullYear()}年${date.getMonth() + 1}月${date.getDate()}日`
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function stripMarkdown(text) {
|
|
|
|
|
|
return (text || '')
|
|
|
|
|
|
.replace(/```[\s\S]*?```/g, ' ')
|
|
|
|
|
|
.replace(/[#>*_`~\-\[\]()]/g, ' ')
|
|
|
|
|
|
.replace(/\s+/g, ' ')
|
|
|
|
|
|
.trim()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-03 05:59:52 +00:00
|
|
|
|
function buildDocumentPreviewUrl(projectId, filePath) {
|
2026-07-24 07:37:22 +00:00
|
|
|
|
if (!projectId || !filePath) return ''
|
2026-08-03 05:59:52 +00:00
|
|
|
|
return `/projects/${projectId}/docs?file=${encodeURIComponent(filePath)}`
|
2026-07-24 07:37:22 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function openDocument(ref, projectId) {
|
|
|
|
|
|
const previewUrl = buildDocumentPreviewUrl(
|
|
|
|
|
|
ref?.project_id || projectId,
|
2026-08-03 05:59:52 +00:00
|
|
|
|
ref?.file_path
|
2026-07-24 07:37:22 +00:00
|
|
|
|
)
|
|
|
|
|
|
if (previewUrl) window.open(previewUrl, '_blank', 'noopener,noreferrer')
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function getReferenceExcerpt(ref) {
|
|
|
|
|
|
return (ref?.excerpt || ref?.anchor_text || '').trim()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-05 14:18:25 +00:00
|
|
|
|
function loadPendingStops() {
|
|
|
|
|
|
try {
|
|
|
|
|
|
return JSON.parse(sessionStorage.getItem('nex-chat-pending-stops') || '{}')
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
return {}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function savePendingStops(map) {
|
|
|
|
|
|
try {
|
|
|
|
|
|
sessionStorage.setItem('nex-chat-pending-stops', JSON.stringify(map))
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
// 忽略存储不可用的情况
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-24 07:37:22 +00:00
|
|
|
|
function SearchHighlight({ text, keyword }) {
|
|
|
|
|
|
const value = text || ''
|
|
|
|
|
|
if (!keyword) return value
|
|
|
|
|
|
return (
|
|
|
|
|
|
<Highlighter
|
|
|
|
|
|
autoEscape
|
|
|
|
|
|
highlightClassName="chat-search-highlight"
|
|
|
|
|
|
searchWords={[keyword]}
|
|
|
|
|
|
textToHighlight={value}
|
|
|
|
|
|
/>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function copyText(text) {
|
|
|
|
|
|
const value = text || ''
|
|
|
|
|
|
try {
|
|
|
|
|
|
if (navigator.clipboard?.writeText) {
|
|
|
|
|
|
await navigator.clipboard.writeText(value)
|
|
|
|
|
|
return true
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
// 回退到 execCommand
|
|
|
|
|
|
}
|
|
|
|
|
|
try {
|
|
|
|
|
|
const textarea = document.createElement('textarea')
|
|
|
|
|
|
textarea.value = value
|
|
|
|
|
|
textarea.style.position = 'fixed'
|
|
|
|
|
|
textarea.style.opacity = '0'
|
|
|
|
|
|
document.body.appendChild(textarea)
|
|
|
|
|
|
textarea.select()
|
|
|
|
|
|
const ok = document.execCommand('copy')
|
|
|
|
|
|
document.body.removeChild(textarea)
|
|
|
|
|
|
return ok
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
return false
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-05 14:18:25 +00:00
|
|
|
|
function MessageActions({ content, onCopy, onDelete, onRegenerate }) {
|
2026-07-24 07:37:22 +00:00
|
|
|
|
const [copied, setCopied] = useState(false)
|
|
|
|
|
|
const handleCopy = async () => {
|
|
|
|
|
|
const ok = await copyText(content)
|
|
|
|
|
|
if (ok) {
|
|
|
|
|
|
setCopied(true)
|
|
|
|
|
|
window.setTimeout(() => setCopied(false), 1500)
|
|
|
|
|
|
} else {
|
|
|
|
|
|
message.error('复制失败')
|
|
|
|
|
|
}
|
|
|
|
|
|
onCopy?.(ok)
|
|
|
|
|
|
}
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div className="chat-message-actions">
|
2026-08-05 14:18:25 +00:00
|
|
|
|
{onRegenerate && (
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
className="chat-message-action"
|
|
|
|
|
|
onClick={onRegenerate}
|
|
|
|
|
|
aria-label="重新生成"
|
|
|
|
|
|
title="重新生成"
|
|
|
|
|
|
>
|
|
|
|
|
|
<ReloadOutlined />
|
|
|
|
|
|
</button>
|
|
|
|
|
|
)}
|
2026-07-24 07:37:22 +00:00
|
|
|
|
<button type="button" className="chat-message-action" onClick={handleCopy} aria-label="复制">
|
|
|
|
|
|
{copied ? <CheckOutlined /> : <CopyOutlined />}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
{onDelete && (
|
|
|
|
|
|
<button type="button" className="chat-message-action danger" onClick={onDelete} aria-label="删除">
|
|
|
|
|
|
<DeleteOutlined />
|
|
|
|
|
|
</button>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
2026-06-23 13:58:16 +00:00
|
|
|
|
|
|
|
|
|
|
function Chat() {
|
2026-07-24 07:37:22 +00:00
|
|
|
|
const navigate = useNavigate()
|
|
|
|
|
|
const location = useLocation()
|
2026-08-04 01:31:48 +00:00
|
|
|
|
const { user: currentUser } = useUserStore()
|
2026-07-24 07:37:22 +00:00
|
|
|
|
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-07-24 07:37:22 +00:00
|
|
|
|
const [loadingSessions, setLoadingSessions] = useState(false)
|
|
|
|
|
|
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])
|
|
|
|
|
|
|
|
|
|
|
|
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-08-04 01:31:48 +00:00
|
|
|
|
// 与全站侧边栏/个人资料一致的用户头像:avatar 字段是相对路径时转换为 API 端点
|
|
|
|
|
|
const userAvatarUrl = useMemo(() => {
|
|
|
|
|
|
const avatar = currentUser?.avatar
|
|
|
|
|
|
if (!avatar) return null
|
|
|
|
|
|
if (avatar.startsWith('http')) return avatar
|
|
|
|
|
|
const parts = avatar.split('/')
|
|
|
|
|
|
if (parts.length >= 3) {
|
|
|
|
|
|
return `/api/v1/auth/avatar/${parts[0]}/${parts[2]}`
|
|
|
|
|
|
}
|
|
|
|
|
|
return null
|
|
|
|
|
|
}, [currentUser])
|
|
|
|
|
|
const userDisplayName = currentUser?.nickname || currentUser?.username
|
|
|
|
|
|
|
2026-07-24 07:37:22 +00:00
|
|
|
|
const groupedSessions = useMemo(() => {
|
|
|
|
|
|
const groups = []
|
|
|
|
|
|
const groupMap = new Map()
|
|
|
|
|
|
sessions.forEach((item) => {
|
|
|
|
|
|
const key = getDateKey(item.updated_at || item.created_at)
|
|
|
|
|
|
if (!groupMap.has(key)) {
|
|
|
|
|
|
const group = {
|
|
|
|
|
|
key,
|
|
|
|
|
|
label: formatDateGroup(key),
|
|
|
|
|
|
items: [],
|
|
|
|
|
|
}
|
|
|
|
|
|
groupMap.set(key, group)
|
|
|
|
|
|
groups.push(group)
|
|
|
|
|
|
}
|
|
|
|
|
|
groupMap.get(key).items.push(item)
|
|
|
|
|
|
})
|
|
|
|
|
|
return groups
|
|
|
|
|
|
}, [sessions])
|
|
|
|
|
|
|
|
|
|
|
|
const loadProjects = async () => {
|
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) => {
|
|
|
|
|
|
const contentStopped = item.role === 'assistant' && isStoppedContent(item.content || '')
|
|
|
|
|
|
return contentStopped
|
|
|
|
|
|
? { ...item, stopped: true, status: 'done' }
|
|
|
|
|
|
: item
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
// 停止后后端写入中断标记需要一点时间:若该会话存在未确认的停止,
|
|
|
|
|
|
// 且最后一条助手消息仍为空,则把它标记为“已停止”,避免显示“正在思考”。
|
|
|
|
|
|
if (pendingStopsRef.current[String(id)]) {
|
|
|
|
|
|
const lastAssistantIndex = [...nextMessages].reverse().findIndex((m) => m.role === 'assistant')
|
|
|
|
|
|
if (lastAssistantIndex !== -1) {
|
|
|
|
|
|
const idx = nextMessages.length - 1 - lastAssistantIndex
|
|
|
|
|
|
const lastAssistant = nextMessages[idx]
|
|
|
|
|
|
if (!(lastAssistant.content || '').trim()) {
|
|
|
|
|
|
nextMessages[idx] = { ...lastAssistant, stopped: true, status: 'done' }
|
|
|
|
|
|
// 后端中断标记尚未写入:保留待确认标记,下次加载继续兜底
|
|
|
|
|
|
} else {
|
|
|
|
|
|
clearPendingStop(id)
|
|
|
|
|
|
}
|
|
|
|
|
|
} else {
|
|
|
|
|
|
clearPendingStop(id)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
setMessages(nextMessages)
|
2026-07-24 07:37:22 +00:00
|
|
|
|
setCurrentSession(sessions.find((item) => item.session_id === id) || null)
|
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,
|
|
|
|
|
|
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-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',
|
|
|
|
|
|
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
|
|
|
|
|
|
? { ...item, id: realId, stopped: true, status: 'done' }
|
|
|
|
|
|
: item
|
|
|
|
|
|
)))
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
// 标记失败不阻塞停止流程,会话级待确认标记继续兜底
|
|
|
|
|
|
}
|
|
|
|
|
|
})()
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-07-24 07:37:22 +00:00
|
|
|
|
setMessages((prev) => prev.map((item) => (
|
|
|
|
|
|
item.id === assistantMsgId
|
|
|
|
|
|
? {
|
|
|
|
|
|
...item,
|
2026-08-05 14:18:25 +00:00
|
|
|
|
status: aborted ? 'done' : 'error',
|
|
|
|
|
|
stopped: aborted,
|
|
|
|
|
|
error: aborted ? undefined : (error.message || '回答生成失败,请稍后重试'),
|
2026-07-24 07:37:22 +00:00
|
|
|
|
referencesVisible: true,
|
|
|
|
|
|
}
|
|
|
|
|
|
: item
|
|
|
|
|
|
)))
|
2026-08-05 14:18:25 +00:00
|
|
|
|
if (!aborted) {
|
|
|
|
|
|
throw error
|
|
|
|
|
|
}
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
if (abortControllerRef.current === controller) {
|
|
|
|
|
|
abortControllerRef.current = null
|
|
|
|
|
|
}
|
2026-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-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 || ''
|
|
|
|
|
|
// 后端会在被中断的内容中追加统一标记,展示时剥离,仅保留“已停止生成”状态
|
|
|
|
|
|
const isStopped = !isUser && (item.stopped === true || isStoppedContent(rawContent))
|
|
|
|
|
|
const displayContent = isStopped ? stripStoppedContent(rawContent) : rawContent
|
|
|
|
|
|
const hasContent = Boolean(displayContent.trim())
|
|
|
|
|
|
// 仅真正等待生成的消息(status=thinking 或刷新后无状态的空占位)显示「思考中」
|
|
|
|
|
|
const isThinking = !isUser && !hasContent && (item.status === 'thinking' || item.status === undefined)
|
2026-07-24 07:37:22 +00:00
|
|
|
|
const isStreaming = !isUser && item.status === 'streaming'
|
|
|
|
|
|
const isError = !isUser && item.status === 'error'
|
|
|
|
|
|
const showReferences = !isUser && item.referencesVisible !== false && refs.length > 0
|
|
|
|
|
|
const canDelete = typeof item.id === 'number' || /^\d+$/.test(String(item.id))
|
|
|
|
|
|
const projectId = currentSession?.project_id
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div
|
|
|
|
|
|
key={item.id}
|
|
|
|
|
|
ref={(node) => {
|
|
|
|
|
|
if (node) {
|
|
|
|
|
|
messageRefs.current.set(String(item.id), node)
|
|
|
|
|
|
} else {
|
|
|
|
|
|
messageRefs.current.delete(String(item.id))
|
|
|
|
|
|
}
|
2026-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
|
|
|
|
{!isUser && <Avatar className="chat-avatar assistant" icon={<RobotOutlined />} />}
|
|
|
|
|
|
<div className="chat-message-column">
|
|
|
|
|
|
<div className={`chat-message-bubble ${isUser ? 'user' : 'assistant'}`}>
|
|
|
|
|
|
{isUser ? (
|
2026-08-05 14:18:25 +00:00
|
|
|
|
<div className="chat-plain-text">{displayContent}</div>
|
2026-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-05 14:18:25 +00:00
|
|
|
|
) : isStopped && !hasContent ? (
|
|
|
|
|
|
<div className="chat-message-stopped">已停止生成</div>
|
2026-07-24 07:37:22 +00:00
|
|
|
|
) : isThinking ? (
|
|
|
|
|
|
<div className="chat-thinking">
|
|
|
|
|
|
<span className="chat-thinking-text">正在思考</span>
|
|
|
|
|
|
<span className="chat-thinking-dots">
|
|
|
|
|
|
<i />
|
|
|
|
|
|
<i />
|
|
|
|
|
|
<i />
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<div className={`chat-markdown${isStreaming ? ' streaming' : ''}`}>
|
|
|
|
|
|
<ReactMarkdown
|
|
|
|
|
|
remarkPlugins={[remarkGfm]}
|
|
|
|
|
|
rehypePlugins={[rehypeHighlight, rehypeCitationSup]}
|
|
|
|
|
|
components={{
|
|
|
|
|
|
sup: ({ children, ...props }) => {
|
|
|
|
|
|
const citationId = Number(props['data-citation-id'])
|
|
|
|
|
|
const ref = refs.find((itemRef) => Number(itemRef.citation_id) === citationId)
|
|
|
|
|
|
if (!ref) {
|
|
|
|
|
|
return <sup {...props}>{children}</sup>
|
|
|
|
|
|
}
|
|
|
|
|
|
return (
|
|
|
|
|
|
<Popover
|
|
|
|
|
|
trigger="hover"
|
|
|
|
|
|
placement="top"
|
|
|
|
|
|
overlayClassName="chat-citation-popover"
|
|
|
|
|
|
content={(
|
|
|
|
|
|
<div className="chat-citation-preview">
|
|
|
|
|
|
<Text type="secondary" className="chat-citation-preview-file">
|
|
|
|
|
|
{ref.file_name || ref.file_path}
|
|
|
|
|
|
</Text>
|
|
|
|
|
|
<div className="chat-citation-preview-content">
|
|
|
|
|
|
<ReactMarkdown
|
|
|
|
|
|
remarkPlugins={[remarkGfm]}
|
|
|
|
|
|
rehypePlugins={[rehypeHighlight]}
|
|
|
|
|
|
>
|
|
|
|
|
|
{getReferenceExcerpt(ref) || '暂无引用片段'}
|
|
|
|
|
|
</ReactMarkdown>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
>
|
|
|
|
|
|
<sup {...props}>{children}</sup>
|
|
|
|
|
|
</Popover>
|
|
|
|
|
|
)
|
|
|
|
|
|
},
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
2026-08-05 14:18:25 +00:00
|
|
|
|
{displayContent}
|
2026-07-24 07:37:22 +00:00
|
|
|
|
</ReactMarkdown>
|
|
|
|
|
|
{isError && (
|
|
|
|
|
|
<div className="chat-message-error chat-message-error-inline">
|
|
|
|
|
|
{item.error || '回答生成中断,请稍后重试'}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
2026-08-05 14:18:25 +00:00
|
|
|
|
{isStopped && hasContent && (
|
|
|
|
|
|
<div className="chat-message-stopped">已停止生成</div>
|
|
|
|
|
|
)}
|
2026-07-24 07:37:22 +00:00
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
{showReferences && (
|
|
|
|
|
|
<div className="chat-references">
|
|
|
|
|
|
<Text type="secondary" className="chat-references-label">关联文档</Text>
|
|
|
|
|
|
<Space wrap>
|
|
|
|
|
|
{refs.map((ref) => {
|
|
|
|
|
|
return (
|
|
|
|
|
|
<Tag
|
|
|
|
|
|
key={`${ref.citation_id}-${ref.file_path || ref.file_name}`}
|
|
|
|
|
|
color="blue"
|
|
|
|
|
|
className="chat-reference-link"
|
|
|
|
|
|
onClick={() => {
|
|
|
|
|
|
openDocument(ref, projectId)
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
|
|
|
|
|
[{ref.citation_id}] {ref.file_name}
|
|
|
|
|
|
</Tag>
|
|
|
|
|
|
)
|
|
|
|
|
|
})}
|
|
|
|
|
|
</Space>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</>
|
2026-06-23 13:58:16 +00:00
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
2026-07-24 07:37:22 +00:00
|
|
|
|
{(isUser || (!isThinking && !isStreaming)) && (
|
|
|
|
|
|
<MessageActions
|
2026-08-05 14:18:25 +00:00
|
|
|
|
content={displayContent}
|
2026-07-24 07:37:22 +00:00
|
|
|
|
onDelete={canDelete ? () => handleDeleteMessage(item) : undefined}
|
2026-08-05 14:18:25 +00:00
|
|
|
|
onRegenerate={!isUser && (isStopped || isError) ? () => handleRegenerate(item) : undefined}
|
2026-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-08-04 01:31:48 +00:00
|
|
|
|
{isUser && (
|
|
|
|
|
|
<Avatar className="chat-avatar user" src={userAvatarUrl} style={{ backgroundColor: '#1677ff' }}>
|
|
|
|
|
|
{userDisplayName?.[0]?.toUpperCase() || 'U'}
|
|
|
|
|
|
</Avatar>
|
|
|
|
|
|
)}
|
2026-07-24 07:37:22 +00:00
|
|
|
|
</div>
|
|
|
|
|
|
)
|
|
|
|
|
|
})
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="chat-composer-shell">
|
|
|
|
|
|
<TextArea
|
2026-08-05 14:18:25 +00:00
|
|
|
|
ref={composerRef}
|
2026-07-24 07:37:22 +00:00
|
|
|
|
className="chat-composer-input"
|
|
|
|
|
|
value={inputValue}
|
|
|
|
|
|
onChange={(e) => setInputValue(e.target.value)}
|
|
|
|
|
|
autoSize={{ minRows: 1, maxRows: 8 }}
|
|
|
|
|
|
placeholder="随心输入"
|
|
|
|
|
|
onPressEnter={(e) => {
|
|
|
|
|
|
if (!e.shiftKey) {
|
|
|
|
|
|
e.preventDefault()
|
|
|
|
|
|
handleSend()
|
|
|
|
|
|
}
|
|
|
|
|
|
}}
|
|
|
|
|
|
/>
|
|
|
|
|
|
<div className="chat-composer-actions">
|
|
|
|
|
|
<div className="chat-composer-left">
|
|
|
|
|
|
<span className="chat-composer-pill chat-composer-kb">
|
|
|
|
|
|
<FolderOutlined className="chat-composer-pill-icon" />
|
|
|
|
|
|
<span className="chat-composer-pill-text">
|
|
|
|
|
|
{projects.find((project) => project.id === currentSession?.project_id)?.name || '-'}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="chat-composer-right">
|
|
|
|
|
|
<span className="chat-composer-pill chat-composer-model">
|
|
|
|
|
|
<RobotOutlined className="chat-composer-pill-icon" />
|
|
|
|
|
|
<span className="chat-composer-pill-text">
|
|
|
|
|
|
{models.find((model) => model.config_id === currentSession?.llm_config_id)?.model_name || '-'}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</span>
|
2026-08-05 14:18:25 +00:00
|
|
|
|
{sending ? (
|
|
|
|
|
|
<Button
|
|
|
|
|
|
shape="circle"
|
|
|
|
|
|
icon={<StopOutlined />}
|
|
|
|
|
|
onClick={handleStopGenerating}
|
|
|
|
|
|
title="停止生成"
|
|
|
|
|
|
aria-label="停止生成"
|
|
|
|
|
|
className="chat-stop-button"
|
|
|
|
|
|
/>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<Button
|
|
|
|
|
|
shape="circle"
|
|
|
|
|
|
icon={<ArrowUpOutlined />}
|
|
|
|
|
|
onClick={handleSend}
|
|
|
|
|
|
disabled={!canSendMessage}
|
|
|
|
|
|
aria-disabled={!canSendMessage}
|
|
|
|
|
|
className="chat-send-button"
|
|
|
|
|
|
/>
|
|
|
|
|
|
)}
|
2026-07-24 07:37:22 +00:00
|
|
|
|
</div>
|
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">
|
|
|
|
|
|
{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>
|
2026-06-23 13:58:16 +00:00
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export default Chat
|