1398 lines
50 KiB
TypeScript
1398 lines
50 KiB
TypeScript
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<Agent | null>(null);
|
||
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
||
const [branches, setBranches] = useState<Record<string, BranchInfo>>({});
|
||
const [input, setInput] = useState('');
|
||
const [sending, setSending] = useState(false);
|
||
const [useStream, setUseStream] = useState(true);
|
||
const [sessionId, setSessionId] = useState<string | null>(null);
|
||
const [highlightId, setHighlightId] = useState<string | null>(null);
|
||
const [sessionRefresh, setSessionRefresh] = useState(0);
|
||
const [agentList, setAgentList] = useState<Agent[]>([]);
|
||
const [historyDrawerOpen, setHistoryDrawerOpen] = useState(false);
|
||
const [mcpDrawerOpen, setMcpDrawerOpen] = useState(false);
|
||
const [paramsDrawerOpen, setParamsDrawerOpen] = useState(false);
|
||
const [tplDrawerOpen, setTplDrawerOpen] = useState(false);
|
||
const [overrides, setOverrides] = useState<ModelOverrides>({});
|
||
const [attachments, setAttachments] = useState<ChatAttachment[]>([]);
|
||
const [imageUrls, setImageUrls] = useState<string[]>([]);
|
||
const [uploadingAtt, setUploadingAtt] = useState(false);
|
||
const [streaming, setStreaming] = useState<StreamingState>({
|
||
active: false,
|
||
reasoningText: '',
|
||
answerText: '',
|
||
errorMessage: null,
|
||
retryInfo: null,
|
||
retrieved: [],
|
||
toolCalls: []
|
||
});
|
||
const bodyRef = useRef<HTMLDivElement>(null);
|
||
const abortRef = useRef<AbortController | null>(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 (
|
||
<div className="chat-shell">
|
||
{/* 1. 二级侧边栏:智能体列表 */}
|
||
<aside className="chat-side">
|
||
<div style={{ padding: '16px', borderBottom: '1px solid var(--color-border)' }}>
|
||
<Button block type="dashed" onClick={() => navigate('/agents/new')}>+ 创建智能体</Button>
|
||
</div>
|
||
<div style={{ flex: 1, overflowY: 'auto', padding: '8px 0' }}>
|
||
{agentList.map((a) => {
|
||
const isActive = a.id === id;
|
||
return (
|
||
<div
|
||
key={a.id}
|
||
onClick={() => navigate(`/chat/${a.id}`)}
|
||
style={{
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
gap: 12,
|
||
padding: '10px 16px',
|
||
cursor: 'pointer',
|
||
background: isActive ? 'var(--color-surface-2)' : 'transparent',
|
||
borderLeft: `3px solid ${isActive ? 'var(--color-brand)' : 'transparent'}`,
|
||
transition: 'background 0.2s'
|
||
}}
|
||
>
|
||
<div
|
||
style={{
|
||
width: 32,
|
||
height: 32,
|
||
borderRadius: '50%',
|
||
background: a.avatar || 'var(--gradient-brand)',
|
||
color: '#fff',
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
fontWeight: 700,
|
||
fontSize: 14,
|
||
overflow: 'hidden'
|
||
}}
|
||
>
|
||
{isImageUrl(a.avatar) ? (
|
||
<img src={a.avatar} className="w-full h-full object-cover" alt="avatar" />
|
||
) : (
|
||
(a.name?.charAt(0) || '?').toUpperCase()
|
||
)}
|
||
</div>
|
||
<div style={{ flex: 1, minWidth: 0 }}>
|
||
<div style={{ fontWeight: isActive ? 600 : 500, fontSize: 14, color: 'var(--color-text)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||
{a.name}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
</aside>
|
||
|
||
{/* 2. 主聊天区 */}
|
||
<section className="chat-main">
|
||
{!agent ? (
|
||
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||
<Empty description="请在左侧选择一个智能体开始对话" />
|
||
</div>
|
||
) : (
|
||
<>
|
||
{/* Header */}
|
||
<div className="chat-header">
|
||
<div>
|
||
<div style={{ fontWeight: 600, fontSize: 16, color: 'var(--color-text)' }}>{agent.name}</div>
|
||
<div style={{ fontSize: 12, color: 'var(--color-text-tertiary)' }}>
|
||
{agent.model || '默认模型'} · T={agent.temperature}
|
||
</div>
|
||
</div>
|
||
<Space>
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginRight: 12 }}>
|
||
<span style={{ fontSize: 12, color: 'var(--color-text-secondary)' }}>流式输出</span>
|
||
<Switch size="small" checked={useStream} onChange={setUseStream} />
|
||
</div>
|
||
<Button size="small" onClick={() => setHistoryDrawerOpen(true)}>历史对话</Button>
|
||
<Dropdown
|
||
menu={{
|
||
items: [
|
||
{ key: 'params', label: '模型参数', icon: <SettingOutlined />, onClick: () => setParamsDrawerOpen(true) },
|
||
{ key: 'mcp', label: 'MCP 资源', icon: <ApiOutlined />, onClick: () => setMcpDrawerOpen(true) },
|
||
{ key: 'edit', label: '管理 Agent', icon: <EditOutlined />, onClick: () => navigate(`/agents/${id}`) },
|
||
{ type: 'divider' },
|
||
{ key: 'clear', label: '清空对话', icon: <DeleteOutlined />, danger: true, onClick: () => {
|
||
Modal.confirm({
|
||
title: '清空当前会话所有消息?',
|
||
onOk: handleClear
|
||
});
|
||
}
|
||
}
|
||
]
|
||
}}
|
||
>
|
||
<Button size="small">更多 <DownOutlined /></Button>
|
||
</Dropdown>
|
||
</Space>
|
||
</div>
|
||
|
||
{/* Body */}
|
||
<div ref={bodyRef} className="chat-body">
|
||
<div className="messages-container">
|
||
{messages.length === 0 && !streaming.active ? (
|
||
<div style={{ textAlign: 'center', marginTop: 120 }}>
|
||
<div
|
||
style={{
|
||
width: 68, height: 68, borderRadius: '50%',
|
||
background: agent.avatar || 'var(--gradient-brand)',
|
||
color: '#fff', display: 'flex', alignItems: 'center',
|
||
justifyContent: 'center', fontWeight: 700, fontSize: 32,
|
||
margin: '0 auto 20px', boxShadow: 'var(--shadow-lg)',
|
||
overflow: 'hidden'
|
||
}}>
|
||
{isImageUrl(agent.avatar) ? (
|
||
<img src={agent.avatar} className="w-full h-full object-cover" alt="avatar" />
|
||
) : (
|
||
(agent.name?.charAt(0) || '?').toUpperCase()
|
||
)}
|
||
</div>
|
||
<h2
|
||
style={{
|
||
fontSize: 28,
|
||
fontWeight: 700,
|
||
color: 'var(--color-text)',
|
||
marginBottom: 8,
|
||
letterSpacing: '-0.02em'
|
||
}}
|
||
>
|
||
你好,今天想一起完成什么?
|
||
</h2>
|
||
<p style={{ color: 'var(--color-text-secondary)', fontSize: 15, lineHeight: 1.7 }}>
|
||
{agent.description || '我是你的专属 AI 助手,随时准备为你服务。'}
|
||
</p>
|
||
</div>
|
||
) : (
|
||
<>
|
||
{messages.map((m) => (
|
||
<MessageItem
|
||
key={m.id}
|
||
message={m}
|
||
highlighted={highlightId === m.id}
|
||
branch={m.role === 'assistant' && m.parentId ? branches[m.parentId] : undefined}
|
||
busy={sending}
|
||
onRegenerate={handleRegenerate}
|
||
onSwitchBranch={handleSwitchBranch}
|
||
onCopy={(text) => {
|
||
navigator.clipboard?.writeText(text).then(() => msg.success('已复制'));
|
||
}}
|
||
/>
|
||
))}
|
||
{streaming.active && (
|
||
<div style={{ marginBottom: 24 }}>
|
||
<div className="bubble assistant">
|
||
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||
{!!streaming.retryInfo?.message && (
|
||
<div
|
||
style={{
|
||
padding: '8px 10px',
|
||
borderRadius: 10,
|
||
background: 'rgba(59, 130, 246, 0.08)',
|
||
border: '1px solid rgba(59, 130, 246, 0.18)'
|
||
}}
|
||
>
|
||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 10 }}>
|
||
<span style={{ fontSize: 12.5, color: 'var(--color-text)', fontWeight: 600 }}>
|
||
{streaming.retryInfo.stage === 'fallback_model' ? '自动切换模型' : '自动重试'}
|
||
</span>
|
||
{streaming.retryInfo.stage === 'fallback_model' ? (
|
||
<Tag color="processing" style={{ marginInlineEnd: 0 }}>
|
||
{String(streaming.retryInfo.fromModel || '')} → {String(streaming.retryInfo.toModel || '')}
|
||
</Tag>
|
||
) : (
|
||
<Tag color="processing" style={{ marginInlineEnd: 0 }}>
|
||
{String(streaming.retryInfo.model || '')}
|
||
{streaming.retryInfo.attempt ? ` · 第${streaming.retryInfo.attempt}次` : ''}
|
||
</Tag>
|
||
)}
|
||
</div>
|
||
<div style={{ marginTop: 4, fontSize: 12.5, color: 'var(--color-text-secondary)', lineHeight: 1.55 }}>
|
||
{String(streaming.retryInfo.message)}
|
||
</div>
|
||
{!!streaming.retryInfo.reason && (
|
||
<div style={{ marginTop: 4, fontSize: 12, color: 'var(--color-text-tertiary)', lineHeight: 1.5 }}>
|
||
{String(streaming.retryInfo.reason)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
<div>
|
||
<div style={{ fontSize: 12, color: 'var(--color-text-tertiary)', marginBottom: 4 }}>
|
||
推理过程
|
||
</div>
|
||
{streaming.reasoningText ? (
|
||
<ReactMarkdown>{streaming.reasoningText + '▍'}</ReactMarkdown>
|
||
) : (
|
||
<span style={{ color: 'var(--color-text-tertiary)' }}>等待推理…</span>
|
||
)}
|
||
</div>
|
||
<Divider style={{ margin: '6px 0' }} />
|
||
<div>
|
||
<div style={{ fontSize: 12, color: 'var(--color-text-tertiary)', marginBottom: 4 }}>
|
||
正式回答
|
||
</div>
|
||
{streaming.answerText ? (
|
||
<ReactMarkdown>{streaming.answerText + '▍'}</ReactMarkdown>
|
||
) : (
|
||
<span style={{ color: 'var(--color-text-tertiary)' }}>等待输出…</span>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
{(streaming.retrieved.length > 0 || streaming.toolCalls.length > 0) && (
|
||
<div style={{ maxWidth: '85%' }}>
|
||
{streaming.retrieved.length > 0 && (
|
||
<RetrievedView retrieved={streaming.retrieved} liveStyle />
|
||
)}
|
||
{streaming.toolCalls.length > 0 && (
|
||
<ToolCallView calls={streaming.toolCalls} liveStyle />
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
</>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Input Footer */}
|
||
<div className="chat-input-wrapper">
|
||
{/* Attachments Preview */}
|
||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8, marginBottom: 8 }}>
|
||
{attachments.map((a, i) => (
|
||
<Tag
|
||
key={i}
|
||
color="blue"
|
||
closable
|
||
style={{ borderRadius: 6, padding: '4px 8px' }}
|
||
onClose={() => setAttachments((arr) => arr.filter((_, j) => j !== i))}
|
||
>
|
||
📎 {a.name}
|
||
</Tag>
|
||
))}
|
||
{imageUrls.map((u, i) => (
|
||
<div key={i} style={{ position: 'relative' }}>
|
||
<AntImage
|
||
src={u}
|
||
width={48}
|
||
height={48}
|
||
style={{ objectFit: 'cover', borderRadius: 8, border: '1px solid var(--color-border)' }}
|
||
/>
|
||
<Button
|
||
size="small"
|
||
type="primary"
|
||
shape="circle"
|
||
icon={<CloseOutlined style={{ fontSize: 8 }} />}
|
||
style={{
|
||
position: 'absolute', top: -6, right: -6,
|
||
width: 16, height: 16, display: 'flex', alignItems: 'center', justifyContent: 'center'
|
||
}}
|
||
onClick={() => setImageUrls((arr) => arr.filter((_, j) => j !== i))}
|
||
/>
|
||
</div>
|
||
))}
|
||
</div>
|
||
|
||
<div className="chat-input-card">
|
||
<div className="chat-input-stack">
|
||
<Input.TextArea
|
||
value={input}
|
||
onChange={(e) => 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}
|
||
/>
|
||
|
||
<div className="chat-input-toolbar">
|
||
<div className="chat-input-toolbar-left">
|
||
<Select
|
||
value={activeModelValue || undefined}
|
||
className="chat-model-select"
|
||
popupMatchSelectWidth={false}
|
||
options={modelOptions}
|
||
suffixIcon={<DownOutlined className="chat-model-select-arrow" />}
|
||
placeholder="选择模型"
|
||
onChange={(value) => setOverrides((o) => ({ ...o, model: String(value) }))}
|
||
/>
|
||
|
||
<Upload
|
||
className="chat-upload"
|
||
multiple
|
||
beforeUpload={(_f, files) => {
|
||
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"
|
||
>
|
||
<Button type="text" className="chat-tool-button" icon={<PaperClipOutlined style={{ fontSize: 18 }} />} />
|
||
</Upload>
|
||
<Button
|
||
type="text"
|
||
className="chat-tool-button"
|
||
icon={<BookOutlined style={{ fontSize: 18 }} />}
|
||
onClick={() => setTplDrawerOpen(true)}
|
||
/>
|
||
</div>
|
||
|
||
{sending ? (
|
||
<Button
|
||
danger
|
||
shape="circle"
|
||
onClick={handleStop}
|
||
icon={<span className="chat-stop-icon" />}
|
||
className="chat-send-button"
|
||
/>
|
||
) : (
|
||
<Button
|
||
type="primary"
|
||
shape="circle"
|
||
onClick={handleSend}
|
||
icon={<ArrowUpOutlined />}
|
||
disabled={!input.trim()}
|
||
className="chat-send-button chat-send-button-primary"
|
||
/>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div className="chat-disclaimer">
|
||
AI 可能会产生错误信息,请核实重要信息。
|
||
</div>
|
||
</div>
|
||
</>
|
||
)}
|
||
</section>
|
||
|
||
{/* ---- 抽屉组件保持不变 ---- */}
|
||
{id && (
|
||
<McpResourcesDrawer
|
||
agentId={id}
|
||
open={mcpDrawerOpen}
|
||
onClose={() => setMcpDrawerOpen(false)}
|
||
onUse={(text) => setInput((cur) => cur + text)}
|
||
/>
|
||
)}
|
||
|
||
<Drawer
|
||
title="📚 选择 Prompt 模板"
|
||
open={tplDrawerOpen}
|
||
onClose={() => setTplDrawerOpen(false)}
|
||
width={520}
|
||
>
|
||
<PromptLibraryPage
|
||
onSelect={(t) => {
|
||
setInput((cur) => (cur ? cur + '\n\n' : '') + t.body);
|
||
setTplDrawerOpen(false);
|
||
msg.success('已插入到输入框');
|
||
}}
|
||
/>
|
||
</Drawer>
|
||
|
||
<Drawer
|
||
title="⚙️ 模型参数(仅本会话临时生效)"
|
||
open={paramsDrawerOpen}
|
||
onClose={() => setParamsDrawerOpen(false)}
|
||
width={380}
|
||
>
|
||
<div
|
||
style={{
|
||
marginBottom: 16,
|
||
padding: 12,
|
||
background: 'var(--color-warning-soft)',
|
||
borderRadius: 10,
|
||
fontSize: 12,
|
||
color: 'var(--color-warning)',
|
||
border: '1px solid var(--color-border)'
|
||
}}
|
||
>
|
||
💡 这里设置的参数会临时覆盖智能体的默认配置,<b>仅作用于当前浏览器会话</b>,不会写回到智能体本身。<br />
|
||
关闭页面或清空字段即可恢复默认。
|
||
</div>
|
||
|
||
<div style={{ marginBottom: 24 }}>
|
||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 6 }}>
|
||
<span>🌡 Temperature(创造性)</span>
|
||
<span style={{ fontSize: 12, color: 'var(--color-text-secondary)' }}>
|
||
默认:{agent?.temperature ?? 0.7}
|
||
</span>
|
||
</div>
|
||
<Slider
|
||
min={0}
|
||
max={2}
|
||
step={0.05}
|
||
value={overrides.temperature ?? agent?.temperature ?? 0.7}
|
||
onChange={(v) => setOverrides((o) => ({ ...o, temperature: v as number }))}
|
||
marks={{ 0: '严谨', 0.7: '默认', 1.4: '发散', 2: '混沌' }}
|
||
/>
|
||
{overrides.temperature !== undefined && (
|
||
<Button size="small" type="link" style={{ padding: 0 }} onClick={() => setOverrides((o) => ({ ...o, temperature: undefined }))}>
|
||
↺ 恢复默认
|
||
</Button>
|
||
)}
|
||
</div>
|
||
|
||
<div style={{ marginBottom: 24 }}>
|
||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 6 }}>
|
||
<span>🎯 Top P(核采样)</span>
|
||
<span style={{ fontSize: 12, color: 'var(--color-text-secondary)' }}>未设置时跟随模型默认</span>
|
||
</div>
|
||
<Slider
|
||
min={0.1}
|
||
max={1}
|
||
step={0.05}
|
||
value={overrides.topP ?? 1}
|
||
onChange={(v) => setOverrides((o) => ({ ...o, topP: v as number }))}
|
||
disabled={overrides.topP === undefined}
|
||
/>
|
||
{overrides.topP === undefined ? (
|
||
<Button size="small" type="link" style={{ padding: 0 }} onClick={() => setOverrides((o) => ({ ...o, topP: 1 }))}>
|
||
✚ 启用 Top P
|
||
</Button>
|
||
) : (
|
||
<Button size="small" type="link" style={{ padding: 0 }} onClick={() => setOverrides((o) => ({ ...o, topP: undefined }))}>
|
||
↺ 取消
|
||
</Button>
|
||
)}
|
||
</div>
|
||
|
||
<div style={{ marginBottom: 24 }}>
|
||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 6 }}>
|
||
<span>📏 Max Tokens(生成长度上限)</span>
|
||
<span style={{ fontSize: 12, color: 'var(--color-text-secondary)' }}>未设置时由模型决定</span>
|
||
</div>
|
||
<InputNumber
|
||
value={overrides.maxTokens}
|
||
onChange={(v) => setOverrides((o) => ({ ...o, maxTokens: v == null ? undefined : Number(v) }))}
|
||
min={1}
|
||
max={16384}
|
||
placeholder="例如 2048"
|
||
style={{ width: '100%' }}
|
||
/>
|
||
</div>
|
||
|
||
<Button block onClick={() => setOverrides({})}>
|
||
🔄 全部恢复默认
|
||
</Button>
|
||
</Drawer>
|
||
|
||
<Drawer
|
||
title="历史对话"
|
||
placement="right"
|
||
width={320}
|
||
onClose={() => setHistoryDrawerOpen(false)}
|
||
open={historyDrawerOpen}
|
||
bodyStyle={{ padding: 0 }}
|
||
>
|
||
{id && (
|
||
<SessionSidebar
|
||
agentId={id}
|
||
activeSessionId={sessionId}
|
||
onChange={(sid, opts) => {
|
||
setSessionId(sid);
|
||
setHighlightId(opts?.highlightMessageId || null);
|
||
setHistoryDrawerOpen(false);
|
||
}}
|
||
refreshTick={sessionRefresh}
|
||
/>
|
||
)}
|
||
</Drawer>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/** 输入栏子组件 */
|
||
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 (
|
||
<div className="monica-input-card">
|
||
{/* 附件/图片预览 */}
|
||
{(attachments.length > 0 || imageUrls.length > 0) && (
|
||
<div className="monica-attach-area">
|
||
{attachments.map((a, i) => (
|
||
<Tag
|
||
key={`att-${i}`}
|
||
color="blue"
|
||
closable
|
||
style={{ borderRadius: 6, padding: '2px 8px', margin: 0 }}
|
||
onClose={() => onRemoveAtt(i)}
|
||
>
|
||
📎 {a.name}
|
||
</Tag>
|
||
))}
|
||
{imageUrls.map((u, i) => (
|
||
<div key={`img-${i}`} style={{ position: 'relative' }}>
|
||
<AntImage
|
||
src={u}
|
||
width={44}
|
||
height={44}
|
||
style={{ objectFit: 'cover', borderRadius: 8, border: '1px solid #e5e7eb' }}
|
||
/>
|
||
<Button
|
||
size="small"
|
||
type="primary"
|
||
shape="circle"
|
||
danger
|
||
style={{
|
||
position: 'absolute', top: -6, right: -6,
|
||
width: 18, height: 18, fontSize: 8, padding: 0, minWidth: 18
|
||
}}
|
||
onClick={() => onRemoveImg(i)}
|
||
>
|
||
✕
|
||
</Button>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
|
||
{/* 输入行 */}
|
||
<div className="monica-input-row">
|
||
<Input.TextArea
|
||
className="monica-input-textarea"
|
||
value={input}
|
||
onChange={(e) => setInput(e.target.value)}
|
||
placeholder="问我任何问题..."
|
||
autoSize={{ minRows: 1, maxRows: 6 }}
|
||
onPressEnter={(e) => {
|
||
if (!e.shiftKey) {
|
||
e.preventDefault();
|
||
onSend();
|
||
}
|
||
}}
|
||
disabled={sending}
|
||
/>
|
||
{sending ? (
|
||
<Button danger shape="circle" onClick={onStop} icon="■" style={{ marginBottom: 4 }} />
|
||
) : (
|
||
<button
|
||
className="monica-send-btn"
|
||
onClick={onSend}
|
||
disabled={!input.trim()}
|
||
title="发送"
|
||
>
|
||
↑
|
||
</button>
|
||
)}
|
||
</div>
|
||
|
||
{/* 工具栏 */}
|
||
<div className="monica-toolbar">
|
||
<Upload
|
||
multiple
|
||
beforeUpload={(_f, files) => {
|
||
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"
|
||
>
|
||
<Button type="text" icon="📎" size="small" style={{ color: '#6b7280' }} loading={uploadingAtt} />
|
||
</Upload>
|
||
<Button type="text" icon="📚" size="small" style={{ color: '#6b7280' }} onClick={onOpenTpl} />
|
||
<div style={{ flex: 1 }} />
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
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 (
|
||
<div
|
||
id={'msg-' + message.id}
|
||
style={{
|
||
marginBottom: 12,
|
||
padding: highlighted ? 8 : 0,
|
||
borderRadius: highlighted ? 10 : 0,
|
||
background: highlighted ? 'rgba(254, 243, 199, 0.6)' : 'transparent',
|
||
transition: 'background 0.4s, padding 0.4s'
|
||
}}
|
||
>
|
||
<div className={`bubble ${message.role}`}>
|
||
{message.role === 'assistant' ? (
|
||
<ReactMarkdown>{message.content}</ReactMarkdown>
|
||
) : message.content.includes(' ? (
|
||
// user 消息含图片:用 markdown 渲染
|
||
<ReactMarkdown>{message.content}</ReactMarkdown>
|
||
) : (
|
||
message.content
|
||
)}
|
||
</div>
|
||
{message.role === 'assistant' && (
|
||
<div className="monica-msg-actions" style={{ maxWidth: '78%' }}>
|
||
{hasBranches && (
|
||
<Space size={2}>
|
||
<Button size="small" type="text" disabled={activeIdx === 0} onClick={goPrev}>
|
||
‹
|
||
</Button>
|
||
<span>
|
||
{activeIdx + 1} / {total}
|
||
</span>
|
||
<Button size="small" type="text" disabled={activeIdx === total - 1} onClick={goNext}>
|
||
›
|
||
</Button>
|
||
</Space>
|
||
)}
|
||
{message.meta?.aborted && <Tag color="orange">已停止</Tag>}
|
||
<Tooltip title="复制">
|
||
<Button size="small" type="text" onClick={() => onCopy?.(message.content)}>
|
||
📋
|
||
</Button>
|
||
</Tooltip>
|
||
<Tooltip title="重新生成(开新分支)">
|
||
<Button
|
||
size="small"
|
||
type="text"
|
||
disabled={busy}
|
||
onClick={() => onRegenerate?.(message.id)}
|
||
>
|
||
🔄
|
||
</Button>
|
||
</Tooltip>
|
||
</div>
|
||
)}
|
||
{message.role === 'assistant' && message.meta && (
|
||
<div style={{ maxWidth: '78%' }}>
|
||
{!!message.meta.reasoning && <ReasoningView reasoning={message.meta.reasoning} />}
|
||
{!!message.meta.retrieved?.length && <RetrievedView retrieved={message.meta.retrieved} />}
|
||
{!!message.meta.toolCalls?.length && <ToolCallView calls={message.meta.toolCalls} />}
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function ReasoningView({ reasoning }: { reasoning: string }) {
|
||
return (
|
||
<Collapse
|
||
size="small"
|
||
ghost
|
||
items={[
|
||
{
|
||
key: 'reasoning',
|
||
label: (
|
||
<span style={{ fontSize: 12, color: 'var(--color-text-secondary)' }}>
|
||
🧠 推理过程
|
||
</span>
|
||
),
|
||
children: (
|
||
<div style={{ fontSize: 12, color: 'var(--color-text-secondary)', lineHeight: 1.6, maxHeight: 240, overflow: 'auto' }}>
|
||
<ReactMarkdown>{reasoning}</ReactMarkdown>
|
||
</div>
|
||
)
|
||
}
|
||
]}
|
||
/>
|
||
);
|
||
}
|
||
|
||
function RetrievedView({ retrieved, liveStyle }: { retrieved: RetrievedSnippet[]; liveStyle?: boolean }) {
|
||
return (
|
||
<Collapse
|
||
size="small"
|
||
ghost
|
||
defaultActiveKey={liveStyle ? ['rag'] : undefined}
|
||
items={[
|
||
{
|
||
key: 'rag',
|
||
label: (
|
||
<span style={{ fontSize: 12, color: '#6366f1' }}>
|
||
🔍 RAG 命中片段 ({retrieved.length})
|
||
</span>
|
||
),
|
||
children: (
|
||
<div style={{ fontSize: 12 }}>
|
||
{retrieved.map((r, i) => (
|
||
<div
|
||
key={i}
|
||
style={{
|
||
padding: 8,
|
||
background: '#f6f8ff',
|
||
borderRadius: 6,
|
||
marginBottom: 6,
|
||
borderLeft: '3px solid #6366f1'
|
||
}}
|
||
>
|
||
<div style={{ color: '#6366f1', fontWeight: 600, marginBottom: 4 }}>
|
||
📄 {r.fileName} · 切片#{r.chunkIndex} · score {r.score.toFixed(3)}
|
||
</div>
|
||
<div style={{ color: '#374151', whiteSpace: 'pre-wrap' }}>
|
||
{r.preview}
|
||
{r.preview.length >= 200 ? '…' : ''}
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)
|
||
}
|
||
]}
|
||
/>
|
||
);
|
||
}
|
||
|
||
function ToolCallView({ calls, liveStyle }: { calls: ToolCallTrace[]; liveStyle?: boolean }) {
|
||
return (
|
||
<Collapse
|
||
size="small"
|
||
ghost
|
||
defaultActiveKey={liveStyle ? ['tc'] : undefined}
|
||
items={[
|
||
{
|
||
key: 'tc',
|
||
label: (
|
||
<span style={{ fontSize: 12, color: '#f97316' }}>
|
||
🛠 工具调用 ({calls.length})
|
||
</span>
|
||
),
|
||
children: (
|
||
<div style={{ fontSize: 12 }}>
|
||
{calls.map((t, i) => {
|
||
const isPending = (t.result as any)?.pending;
|
||
const isFailed = t.result?.ok === false;
|
||
return (
|
||
<div
|
||
key={i}
|
||
style={{
|
||
padding: 8,
|
||
background: '#fff7ed',
|
||
borderRadius: 6,
|
||
marginBottom: 6,
|
||
borderLeft: '3px solid #f97316'
|
||
}}
|
||
>
|
||
<div style={{ color: '#c2410c', fontWeight: 600, marginBottom: 4 }}>
|
||
⚙️ {t.name}{' '}
|
||
{isPending && <Tag color="processing">运行中…</Tag>}
|
||
{!isPending && t.result?.durationMs != null && <Tag>{t.result.durationMs}ms</Tag>}
|
||
{isFailed && <Tag color="error">失败</Tag>}
|
||
</div>
|
||
<div style={{ color: '#6b7280' }}>
|
||
<b>args:</b> {JSON.stringify(t.args)}
|
||
</div>
|
||
{!isPending && (
|
||
<div style={{ color: '#374151', marginTop: 4 }}>
|
||
<b>result:</b>{' '}
|
||
<code style={{ wordBreak: 'break-all' }}>
|
||
{JSON.stringify(t.result?.result ?? t.result?.error ?? t.result).slice(0, 500)}
|
||
</code>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
)
|
||
}
|
||
]}
|
||
/>
|
||
);
|
||
}
|