aura-web/src/pages/ChatPage.tsx

1398 lines
50 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters!

This file contains invisible Unicode characters that may be processed differently from what appears below. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to reveal hidden characters.

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

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('![image](') ? (
// 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>
)
}
]}
/>
);
}