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 } 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, AgentAllowedLLMResponse, BranchInfo, ChatAPI, ChatAttachment, ChatAttachmentsAPI, ChatMessage, ImageAPI, LLMProviderAPI, 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; text: string; retrieved: RetrievedSnippet[]; toolCalls: ToolCallTrace[]; } const parseAllowedModels = (payload?: Pick | null) => String(payload?.model || '') .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 [allowedModels, setAllowedModels] = useState([]); const [attachments, setAttachments] = useState([]); const [imageUrls, setImageUrls] = useState([]); const [uploadingAtt, setUploadingAtt] = useState(false); const [streaming, setStreaming] = useState({ active: false, text: '', retrieved: [], toolCalls: [] }); const bodyRef = useRef(null); const abortRef = useRef(null); // 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 = () => { requestAnimationFrame(() => { bodyRef.current?.scrollTo({ top: bodyRef.current.scrollHeight, behavior: 'smooth' }); }); }; const loadAgent = async () => { if (!id) { setAgent(null); setMessages([]); return; } const a = await AgentAPI.detail(id); setAgent(a); }; const loadAgentList = async () => { try { const list = await AgentAPI.list(); setAgentList(list); } catch (e) { // ignore } }; const loadAllowedModels = async (agentId: string) => { try { const result = await LLMProviderAPI.allowedModels(agentId); setAllowedModels(parseAllowedModels(result)); } catch (e: any) { setAllowedModels([]); msg.error('加载智能体模型失败:' + (e?.message ?? e)); } }; const loadMessages = async () => { if (!id || !sessionId) return; const his = await ChatAPI.history(id, sessionId); setMessages(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([]); setAllowedModels([]); setOverrides({}); return () => abortRef.current?.abort(); } loadAgent(); loadAllowedModels(id); setOverrides({}); return () => abortRef.current?.abort(); // eslint-disable-next-line react-hooks/exhaustive-deps }, [id]); 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, text: '', retrieved: [], toolCalls: [] }); scrollBottom(); abortRef.current?.abort(); const ctrl = new AbortController(); abortRef.current = ctrl; const attText = buildAttachmentsText(); try { await streamChat( id, text, { onMeta: (m) => setStreaming((s) => ({ ...s, retrieved: m.retrieved || [] })), onDelta: (chunk) => setStreaming((s) => { const next = { ...s, text: s.text + 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) => { setMessages((m) => [...m.filter((x) => x.id !== tempUser.id), data.user, data.assistant]); setStreaming({ active: false, text: '', retrieved: [], toolCalls: [] }); setSessionRefresh((t) => t + 1); setAttachments([]); // 用完即清 setImageUrls([]); scrollBottom(); }, onAborted: (data) => { // 已停止:保留已生成内容;user 消息也留下(用 tempUser 占位) setMessages((m) => [ ...m.filter((x) => x.id !== tempUser.id), { ...tempUser, id: 'u-' + data.assistant.id }, // 占位 data.assistant ]); setStreaming({ active: false, text: '', 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, text: '', retrieved: [], toolCalls: [] }); } }, ctrl.signal, sessionId, overrides, attText || undefined, imageUrls.length > 0 ? imageUrls : undefined ); } catch (e: any) { if (e?.name !== 'AbortError') { msg.error('请求失败:' + (e?.message ?? e)); } setMessages((m) => m.filter((x) => x.id !== tempUser.id)); setStreaming({ active: false, text: '', 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(); const attText = buildAttachmentsText(); try { const res = await ChatAPI.send( id, text, sessionId, overrides, attText || undefined, imageUrls.length > 0 ? imageUrls : undefined ); 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, text: '', 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 || [] })), onDelta: (chunk) => setStreaming((s) => { const next = { ...s, text: s.text + 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, text: '', retrieved: [], toolCalls: [] }); loadMessages(); }, onAborted: () => { setStreaming({ active: false, text: '', retrieved: [], toolCalls: [] }); loadMessages(); }, onError: (errMsg) => { msg.error('重新生成失败:' + errMsg); setStreaming({ active: false, text: '', 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 modelOptions = allowedModels.map((modelName) => ({ value: modelName, label: modelName })); const activeModelValue = overrides.model || ''; const activeModelOption = modelOptions.find((item) => item.value === activeModelValue); const defaultModelLabel = allowedModels.length > 0 ? allowedModels.join(', ') : agentModel || '默认模型'; const currentModelName = activeModelOption?.label || activeModelValue || defaultModelLabel; 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.text ? ( {streaming.text + '▍'} ) : ( 思考中… )}
{(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 }} onPressEnter={(e) => { if (!e.shiftKey) { e.preventDefault(); handleSend(); } }} className="chat-input-textarea" disabled={sending} style={{ width: '100%' }} />
setOverrides((o) => ({ ...o, model: String(key) === '__default__' ? undefined : String(key) })), items: [ { key: '__default__', label: `跟随默认 · ${defaultModelLabel}` }, ...modelOptions.map((item) => ({ key: item.value, label: `${item.label}` })) ] }} > { handleAttach(files as File[]); return false; }} showUploadList={false} accept=".txt,.md,.markdown,.json,.csv,.pdf,.docx,.html,.htm,image/png,image/jpeg,image/webp,image/gif" >
{sending ? (
AI 可能会产生错误信息,请核实重要信息。
)}
{/* ---- 抽屉组件保持不变 ---- */} {id && ( setMcpDrawerOpen(false)} onUse={(text) => setInput((cur) => cur + text)} /> )} setTplDrawerOpen(false)} width={520} > { setInput((cur) => (cur ? cur + '\n\n' : '') + t.body); setTplDrawerOpen(false); msg.success('已插入到输入框'); }} /> setParamsDrawerOpen(false)} width={380} >
💡 这里设置的参数会临时覆盖智能体的默认配置,仅作用于当前浏览器会话,不会写回到智能体本身。
关闭页面或清空字段即可恢复默认。
🌡 Temperature(创造性) 默认:{agent?.temperature ?? 0.7}
setOverrides((o) => ({ ...o, temperature: v as number }))} marks={{ 0: '严谨', 0.7: '默认', 1.4: '发散', 2: '混沌' }} /> {overrides.temperature !== undefined && ( )}
🎯 Top P(核采样) 未设置时跟随模型默认
setOverrides((o) => ({ ...o, topP: v as number }))} disabled={overrides.topP === undefined} /> {overrides.topP === undefined ? ( ) : ( )}
📏 Max Tokens(生成长度上限) 未设置时由模型决定
setOverrides((o) => ({ ...o, maxTokens: v == null ? undefined : Number(v) }))} min={1} max={16384} placeholder="例如 2048" style={{ width: '100%' }} />
setHistoryDrawerOpen(false)} open={historyDrawerOpen} bodyStyle={{ padding: 0 }} > {id && ( { setSessionId(sid); setHighlightId(opts?.highlightMessageId || null); setHistoryDrawerOpen(false); }} refreshTick={sessionRefresh} /> )}
); } /** 输入栏子组件 */ function MonicaInputBar({ input, setInput, sending, attachments, imageUrls, uploadingAtt, onSend, onStop, onAttach, onRemoveAtt, onRemoveImg, onOpenTpl }: { input: string; setInput: (v: string) => void; sending: boolean; attachments: ChatAttachment[]; imageUrls: string[]; uploadingAtt: boolean; onSend: () => void; onStop: () => void; onAttach: (files: File[]) => void; onRemoveAtt: (i: number) => void; onRemoveImg: (i: number) => void; onOpenTpl: () => void; }) { return (
{/* 附件/图片预览 */} {(attachments.length > 0 || imageUrls.length > 0) && (
{attachments.map((a, i) => ( onRemoveAtt(i)} > 📎 {a.name} ))} {imageUrls.map((u, i) => (
))}
)} {/* 输入行 */}
setInput(e.target.value)} placeholder="问我任何问题..." autoSize={{ minRows: 1, maxRows: 6 }} onPressEnter={(e) => { if (!e.shiftKey) { e.preventDefault(); onSend(); } }} disabled={sending} /> {sending ? ( )}
{/* 工具栏 */}
{ onAttach(files as File[]); return false; }} showUploadList={false} accept=".txt,.md,.markdown,.json,.csv,.pdf,.docx,.html,.htm,image/png,image/jpeg,image/webp,image/gif" >
); } function MessageItem({ message, highlighted, branch, busy, onRegenerate, onSwitchBranch, onCopy }: { message: ChatMessage; highlighted?: boolean; branch?: BranchInfo; busy?: boolean; onRegenerate?: (id: string) => void; onSwitchBranch?: (userMsgId: string, branchId: string) => void; onCopy?: (text: string) => void; }) { const hasBranches = !!branch && branch.total > 1; const activeIdx = branch?.activeIndex ?? 0; const total = branch?.total ?? 1; const goPrev = () => { if (!branch || !message.parentId) return; const i = Math.max(0, activeIdx - 1); onSwitchBranch?.(message.parentId, branch.ids[i]); }; const goNext = () => { if (!branch || !message.parentId) return; const i = Math.min(total - 1, activeIdx + 1); onSwitchBranch?.(message.parentId, branch.ids[i]); }; return (
{message.role === 'assistant' ? ( {message.content} ) : message.content.includes('![image](') ? ( // user 消息含图片:用 markdown 渲染 {message.content} ) : ( message.content )}
{message.role === 'assistant' && (
{hasBranches && ( {activeIdx + 1} / {total} )} {message.meta?.aborted && 已停止}
)} {message.role === 'assistant' && message.meta && (
{!!message.meta.retrieved?.length && } {!!message.meta.toolCalls?.length && }
)}
); } function RetrievedView({ retrieved, liveStyle }: { retrieved: RetrievedSnippet[]; liveStyle?: boolean }) { return ( 🔍 RAG 命中片段 ({retrieved.length}) ), children: (
{retrieved.map((r, i) => (
📄 {r.fileName} · 切片#{r.chunkIndex} · score {r.score.toFixed(3)}
{r.preview} {r.preview.length >= 200 ? '…' : ''}
))}
) } ]} /> ); } function ToolCallView({ calls, liveStyle }: { calls: ToolCallTrace[]; liveStyle?: boolean }) { return ( 🛠 工具调用 ({calls.length}) ), children: (
{calls.map((t, i) => { const isPending = (t.result as any)?.pending; const isFailed = t.result?.ok === false; return (
⚙️ {t.name}{' '} {isPending && 运行中…} {!isPending && t.result?.durationMs != null && {t.result.durationMs}ms} {isFailed && 失败}
args: {JSON.stringify(t.args)}
{!isPending && (
result:{' '} {JSON.stringify(t.result?.result ?? t.result?.error ?? t.result).slice(0, 500)}
)}
); })}
) } ]} /> ); }