import { useEffect, useRef, useState } from 'react'; import { Button, Input, Space, Tag, App as AntApp, Popconfirm, Empty, Collapse, Switch, Drawer, Slider, InputNumber, Upload, Tooltip, Modal, Image as AntImage, Divider, Dropdown, Select } from 'antd'; import { SettingOutlined, ApiOutlined, EditOutlined, DeleteOutlined, PaperClipOutlined, BookOutlined, ArrowUpOutlined, CloseOutlined, DownOutlined } from '@ant-design/icons'; import { useNavigate, useParams, useSearchParams } from 'react-router-dom'; import ReactMarkdown from 'react-markdown'; import { Agent, AgentAPI, BranchInfo, ChatAPI, ChatAttachment, ChatAttachmentsAPI, ChatMessage, ImageAPI, ModelOverrides, RetrievedSnippet, SessionAPI, regenerateMessage, streamChat, ToolCallTrace } from '../api'; import SessionSidebar from '../components/SessionSidebar'; import McpResourcesDrawer from '../components/McpResourcesDrawer'; import PromptLibraryPage from './PromptLibraryPage'; interface StreamingState { active: boolean; reasoningText: string; answerText: string; errorMessage: string | null; retryInfo: any | null; retrieved: RetrievedSnippet[]; toolCalls: ToolCallTrace[]; } const parseAgentModels = (value?: string) => String(value || '') .split(',') .map((item) => item.trim()) .filter(Boolean); export default function ChatPage() { const { id } = useParams(); const navigate = useNavigate(); const [searchParams, setSearchParams] = useSearchParams(); const { message: msg } = AntApp.useApp(); const [agent, setAgent] = useState(null); const [messages, setMessages] = useState([]); const [branches, setBranches] = useState>({}); const [input, setInput] = useState(''); const [sending, setSending] = useState(false); const [useStream, setUseStream] = useState(true); const [sessionId, setSessionId] = useState(null); const [highlightId, setHighlightId] = useState(null); const [sessionRefresh, setSessionRefresh] = useState(0); const [agentList, setAgentList] = useState([]); const [historyDrawerOpen, setHistoryDrawerOpen] = useState(false); const [mcpDrawerOpen, setMcpDrawerOpen] = useState(false); const [paramsDrawerOpen, setParamsDrawerOpen] = useState(false); const [tplDrawerOpen, setTplDrawerOpen] = useState(false); const [overrides, setOverrides] = useState({}); const [attachments, setAttachments] = useState([]); const [imageUrls, setImageUrls] = useState([]); const [uploadingAtt, setUploadingAtt] = useState(false); const [streaming, setStreaming] = useState({ active: false, reasoningText: '', answerText: '', errorMessage: null, retryInfo: null, retrieved: [], toolCalls: [] }); const bodyRef = useRef(null); const abortRef = useRef(null); const creatingSessionRef = useRef(false); const autoScrollRef = useRef(true); // URL 参数 ?session=xxx&msg=yyy useEffect(() => { const s = searchParams.get('session'); const m = searchParams.get('msg'); if (s) { setSessionId(s); if (m) setHighlightId(m); const next = new URLSearchParams(searchParams); next.delete('session'); next.delete('msg'); setSearchParams(next, { replace: true }); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [searchParams]); const scrollBottom = (force = false) => { if (!force && !autoScrollRef.current) return; requestAnimationFrame(() => { bodyRef.current?.scrollTo({ top: bodyRef.current.scrollHeight, behavior: 'smooth' }); }); }; useEffect(() => { const el = bodyRef.current; if (!el) return; const onScroll = () => { const distance = el.scrollHeight - el.scrollTop - el.clientHeight; autoScrollRef.current = distance < 32; }; el.addEventListener('scroll', onScroll, { passive: true }); onScroll(); return () => el.removeEventListener('scroll', onScroll); }, []); const loadAgent = async () => { if (!id) { setAgent(null); setMessages([]); return; } const a = await AgentAPI.detail(id); setAgent(a); const firstModel = parseAgentModels(a.model)[0]; if (firstModel) { setOverrides((o) => ({ ...o, model: o.model || firstModel })); } }; const loadAgentList = async () => { try { const list = await AgentAPI.list(); setAgentList(list); } catch (e) { // ignore } }; const loadMessages = async () => { if (!id || !sessionId) return; const his = await ChatAPI.history(id, sessionId); setMessages(Array.isArray(his.messages) ? his.messages : []); setBranches(his.branches || {}); requestAnimationFrame(() => { if (highlightId) { const el = document.getElementById('msg-' + highlightId); if (el) { el.scrollIntoView({ behavior: 'smooth', block: 'center' }); setTimeout(() => setHighlightId(null), 3000); return; } } scrollBottom(); }); }; useEffect(() => { loadAgentList(); }, []); useEffect(() => { if (!id) { setAgent(null); setMessages([]); setOverrides({}); setSessionId(null); return () => abortRef.current?.abort(); } loadAgent(); setOverrides({}); return () => abortRef.current?.abort(); // eslint-disable-next-line react-hooks/exhaustive-deps }, [id]); useEffect(() => { if (!id || sessionId || creatingSessionRef.current) return; creatingSessionRef.current = true; SessionAPI.create(id) .then((s) => setSessionId(s.id)) .catch(() => msg.error('创建会话失败')) .finally(() => { creatingSessionRef.current = false; }); }, [id, msg, sessionId]); useEffect(() => { if (!id) return; loadMessages(); // eslint-disable-next-line react-hooks/exhaustive-deps }, [sessionId, highlightId, id]); /** 把附件文本拼成 system 注入字符串 */ const buildAttachmentsText = () => { if (!attachments.length) return ''; return attachments .map( (a, i) => `### 附件 ${i + 1}: ${a.name}${a.truncated ? ' (已截断)' : ''}\n${a.text}` ) .join('\n\n---\n\n'); }; const handleSendStream = async (text: string) => { if (!id || !sessionId) return; const tempUser: ChatMessage = { id: 'tmp-' + Date.now(), role: 'user', content: text, createdAt: Date.now() }; setMessages((m) => [...(m || []), tempUser]); setStreaming({ active: true, reasoningText: '', answerText: '', errorMessage: null, retryInfo: null, retrieved: [], toolCalls: [] }); scrollBottom(true); abortRef.current?.abort(); const ctrl = new AbortController(); abortRef.current = ctrl; const model = overrides.model || parseAgentModels(agent?.model)[0] || ''; const attText = buildAttachmentsText(); const content = attText ? `${text}\n\n${attText}` : text; try { await streamChat( id, content, { onMeta: (m) => setStreaming((s) => ({ ...s, retrieved: m.retrieved || [] })), onRetry: (data) => { setStreaming((s) => ({ ...s, retryInfo: data })); if (data?.stage === 'fallback_model' && data?.toModel) { setOverrides((o) => ({ ...o, model: String(data.toModel) })); } }, onReasoningDelta: (chunk) => setStreaming((s) => { const next = { ...s, reasoningText: s.reasoningText + chunk }; scrollBottom(); return next; }), onDelta: (chunk) => setStreaming((s) => { const next = { ...s, answerText: s.answerText + chunk }; scrollBottom(); return next; }), onToolCall: (data) => setStreaming((s) => ({ ...s, toolCalls: [...s.toolCalls, { name: data.name, args: data.args, result: { pending: true } }] })), onToolResult: (data) => setStreaming((s) => { const list = [...s.toolCalls]; for (let i = list.length - 1; i >= 0; i--) { if (list[i].name === data.name && (list[i].result as any)?.pending) { list[i] = { ...list[i], result: data.result }; break; } } return { ...s, toolCalls: list }; }), onDone: (data) => { setStreaming((s) => { const assistant: any = data.assistant; const reasoningText = assistant?.reasoning || assistant?.meta?.reasoning || assistant?.meta?.reasoningText || s.reasoningText; const nextAssistant: ChatMessage = { ...data.assistant, meta: { ...(data.assistant.meta || {}), retrieved: data.assistant.meta?.retrieved || s.retrieved, toolCalls: data.assistant.meta?.toolCalls || s.toolCalls, reasoning: reasoningText || undefined } }; setMessages((m) => [...(m || []).filter((x) => x.id !== tempUser.id), data.user, nextAssistant]); return { active: false, reasoningText: '', answerText: '', errorMessage: null, retryInfo: null, retrieved: [], toolCalls: [] }; }); setSessionRefresh((t) => t + 1); setAttachments([]); // 用完即清 setImageUrls([]); scrollBottom(true); }, onAborted: (data) => { // 已停止:保留已生成内容;user 消息也留下(用 tempUser 占位) const assistant: ChatMessage = { ...data.assistant, meta: { ...(data.assistant.meta || {}), aborted: true } }; setMessages((m) => [ ...(m || []).filter((x) => x.id !== tempUser.id), { ...tempUser, id: 'u-' + data.assistant.id }, // 占位 assistant ]); setStreaming({ active: false, reasoningText: '', answerText: '', errorMessage: null, retryInfo: null, retrieved: [], toolCalls: [] }); setSessionRefresh((t) => t + 1); // 重新拉一次确保和后端一致(user 真实 id 在那边) loadMessages(); }, onError: (errMsg) => { msg.error('流式失败:' + errMsg); setMessages((m) => (m || []).filter((x) => x.id !== tempUser.id)); setStreaming({ active: false, reasoningText: '', answerText: '', errorMessage: errMsg, retryInfo: null, retrieved: [], toolCalls: [] }); } }, ctrl.signal, sessionId, model, imageUrls ); } catch (e: any) { if (e?.name !== 'AbortError') { msg.error('请求失败:' + (e?.message ?? e)); } setMessages((m) => (m || []).filter((x) => x.id !== tempUser.id)); setStreaming({ active: false, reasoningText: '', answerText: '', errorMessage: e?.message ?? String(e), retryInfo: null, retrieved: [], toolCalls: [] }); } }; const handleSendNonStream = async (text: string) => { if (!id || !sessionId) return; const tempUser: ChatMessage = { id: 'tmp-' + Date.now(), role: 'user', content: text, createdAt: Date.now() }; setMessages((m) => [...(m || []), tempUser]); scrollBottom(true); const attText = buildAttachmentsText(); const content = attText ? `${text}\n\n${attText}` : text; const model = overrides.model || parseAgentModels(agent?.model)[0] || ''; try { const res = await ChatAPI.send( id, content, sessionId, model, imageUrls ); setMessages((m) => [...(m || []).filter((x) => x.id !== tempUser.id), res.user, res.assistant]); setSessionRefresh((t) => t + 1); setAttachments([]); setImageUrls([]); scrollBottom(); } catch (e: any) { msg.error('发送失败:' + (e?.message ?? e)); setMessages((m) => (m || []).filter((x) => x.id !== tempUser.id)); } }; const handleSend = async () => { const text = input.trim(); if (!text || !id || sending || !sessionId) return; setInput(''); setSending(true); try { if (useStream) await handleSendStream(text); else await handleSendNonStream(text); } finally { setSending(false); } }; const handleStop = () => { abortRef.current?.abort(); setSending(false); }; const handleClear = async () => { if (!id || !sessionId) return; await ChatAPI.clear(id, sessionId); setMessages([]); setBranches({}); msg.success('对话已清空'); }; const handleRegenerate = async (assistantId: string) => { if (!id || sending) return; setSending(true); setStreaming({ active: true, reasoningText: '', answerText: '', errorMessage: null, retryInfo: null, retrieved: [], toolCalls: [] }); abortRef.current?.abort(); const ctrl = new AbortController(); abortRef.current = ctrl; try { await regenerateMessage( id, assistantId, { onMeta: (m) => setStreaming((s) => ({ ...s, retrieved: m.retrieved || [] })), onRetry: (data) => { setStreaming((s) => ({ ...s, retryInfo: data })); if (data?.stage === 'fallback_model' && data?.toModel) { setOverrides((o) => ({ ...o, model: String(data.toModel) })); } }, onReasoningDelta: (chunk) => setStreaming((s) => { const next = { ...s, reasoningText: s.reasoningText + chunk }; scrollBottom(); return next; }), onDelta: (chunk) => setStreaming((s) => { const next = { ...s, answerText: s.answerText + chunk }; scrollBottom(); return next; }), onToolCall: (data) => setStreaming((s) => ({ ...s, toolCalls: [...s.toolCalls, { name: data.name, args: data.args, result: { pending: true } }] })), onToolResult: (data) => setStreaming((s) => { const list = [...s.toolCalls]; for (let i = list.length - 1; i >= 0; i--) { if (list[i].name === data.name && (list[i].result as any)?.pending) { list[i] = { ...list[i], result: data.result }; break; } } return { ...s, toolCalls: list }; }), onDone: () => { setStreaming({ active: false, reasoningText: '', answerText: '', errorMessage: null, retryInfo: null, retrieved: [], toolCalls: [] }); loadMessages(); }, onAborted: () => { setStreaming({ active: false, reasoningText: '', answerText: '', errorMessage: null, retryInfo: null, retrieved: [], toolCalls: [] }); loadMessages(); }, onError: (errMsg) => { msg.error('重新生成失败:' + errMsg); setStreaming({ active: false, reasoningText: '', answerText: '', errorMessage: errMsg, retryInfo: null, retrieved: [], toolCalls: [] }); loadMessages(); } }, ctrl.signal, overrides ); } finally { setSending(false); } }; const handleSwitchBranch = async (userMsgId: string, branchId: string) => { if (!id) return; await ChatAPI.switchBranch(id, userMsgId, branchId); await loadMessages(); }; const handleAttach = async (files: File[]) => { if (!files.length) return; setUploadingAtt(true); try { // v1.0: 自动按类型分流。图片 → 图床;其他 → 文本附件 const images = files.filter((f) => f.type.startsWith('image/')); const docs = files.filter((f) => !f.type.startsWith('image/')); let imgN = 0, docN = 0; if (images.length) { const r = await ImageAPI.upload(images); setImageUrls((arr) => [...arr, ...r.files.map((f) => f.url)]); imgN = r.files.length; } if (docs.length) { const r = await ChatAttachmentsAPI.upload(docs); setAttachments((a) => [...a, ...r.files]); docN = r.files.length; } const parts = []; if (imgN) parts.push(`${imgN} 张图片`); if (docN) parts.push(`${docN} 个文档`); if (parts.length) msg.success(`已附加 ${parts.join(' + ')}`); } catch (e: any) { msg.error('附件上传失败:' + (e?.message ?? e)); } finally { setUploadingAtt(false); } }; const isImageUrl = (url: string) => url?.startsWith('http') || url?.startsWith('/'); const agentModel = agent?.model || ''; const agentModels = parseAgentModels(agentModel); const modelOptions = agentModels.map((modelName) => ({ value: modelName, label: modelName })); const activeModelValue = overrides.model || ''; return (
{/* 1. 二级侧边栏:智能体列表 */} {/* 2. 主聊天区 */}
{!agent ? (
) : ( <> {/* Header */}
{agent.name}
{agent.model || '默认模型'} · T={agent.temperature}
流式输出
, onClick: () => setParamsDrawerOpen(true) }, { key: 'mcp', label: 'MCP 资源', icon: , onClick: () => setMcpDrawerOpen(true) }, { key: 'edit', label: '管理 Agent', icon: , onClick: () => navigate(`/agents/${id}`) }, { type: 'divider' }, { key: 'clear', label: '清空对话', icon: , danger: true, onClick: () => { Modal.confirm({ title: '清空当前会话所有消息?', onOk: handleClear }); } } ] }} >
{/* Body */}
{messages.length === 0 && !streaming.active ? (
{isImageUrl(agent.avatar) ? ( avatar ) : ( (agent.name?.charAt(0) || '?').toUpperCase() )}

你好,今天想一起完成什么?

{agent.description || '我是你的专属 AI 助手,随时准备为你服务。'}

) : ( <> {messages.map((m) => ( { navigator.clipboard?.writeText(text).then(() => msg.success('已复制')); }} /> ))} {streaming.active && (
{!!streaming.retryInfo?.message && (
{streaming.retryInfo.stage === 'fallback_model' ? '自动切换模型' : '自动重试'} {streaming.retryInfo.stage === 'fallback_model' ? ( {String(streaming.retryInfo.fromModel || '')} → {String(streaming.retryInfo.toModel || '')} ) : ( {String(streaming.retryInfo.model || '')} {streaming.retryInfo.attempt ? ` · 第${streaming.retryInfo.attempt}次` : ''} )}
{String(streaming.retryInfo.message)}
{!!streaming.retryInfo.reason && (
{String(streaming.retryInfo.reason)}
)}
)}
推理过程
{streaming.reasoningText ? ( {streaming.reasoningText + '▍'} ) : ( 等待推理… )}
正式回答
{streaming.answerText ? ( {streaming.answerText + '▍'} ) : ( 等待输出… )}
{(streaming.retrieved.length > 0 || streaming.toolCalls.length > 0) && (
{streaming.retrieved.length > 0 && ( )} {streaming.toolCalls.length > 0 && ( )}
)}
)} )}
{/* Input Footer */}
{/* Attachments Preview */}
{attachments.map((a, i) => ( setAttachments((arr) => arr.filter((_, j) => j !== i))} > 📎 {a.name} ))} {imageUrls.map((u, i) => (
))}
setInput(e.target.value)} placeholder="问我任何问题..." autoSize={{ minRows: 3, maxRows: 10 }} onKeyDown={(e) => { if (e.key !== 'Enter') return; if ((e as any).isComposing) return; if (e.metaKey || e.ctrlKey) { e.preventDefault(); const el = e.currentTarget; const start = el.selectionStart ?? input.length; const end = el.selectionEnd ?? input.length; const next = input.slice(0, start) + '\n' + input.slice(end); setInput(next); requestAnimationFrame(() => { el.selectionStart = el.selectionEnd = start + 1; }); return; } if (!e.shiftKey && !e.altKey) { e.preventDefault(); handleSend(); } }} className="chat-input-textarea" disabled={sending} />