159 lines
6.1 KiB
TypeScript
159 lines
6.1 KiB
TypeScript
import { Button, Dropdown, Space, Tag, Tooltip, Avatar } from 'antd';
|
||
import type { BranchInfo, ChatMessage } from '../../../../api';
|
||
import type { Agent } from '../../../../api/agents';
|
||
import Markdown from '../../../../components/Markdown';
|
||
import { ProductCopyIcon } from '../../../../components/icons';
|
||
import type { CopyMode } from '../../utils/copy';
|
||
import { ReasoningView, RetrievedView, ToolCallView } from './MetaViews';
|
||
|
||
export default function MessageItem(props: {
|
||
message: ChatMessage;
|
||
agentList: Agent[];
|
||
currentAgentId: string;
|
||
highlighted?: boolean;
|
||
branch?: BranchInfo;
|
||
busy?: boolean;
|
||
onRegenerate?: (id: string) => void;
|
||
onSwitchBranch?: (userMsgId: string, branchId: string) => void;
|
||
onCopy?: (text: string, mode: CopyMode) => void;
|
||
}) {
|
||
const { message, agentList, currentAgentId, highlighted, branch, busy, onRegenerate, onSwitchBranch, onCopy } = props;
|
||
|
||
// 获取回答者 Agent 信息
|
||
const answerAgentId = message.agent_id || (message.role === 'assistant' ? currentAgentId : undefined);
|
||
const answerAgent = answerAgentId ? agentList.find(a => a.id === answerAgentId) : undefined;
|
||
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: 20,
|
||
padding: highlighted ? 8 : 0,
|
||
borderRadius: highlighted ? 10 : 0,
|
||
background: highlighted ? 'rgba(254, 243, 199, 0.6)' : 'transparent',
|
||
transition: 'background 0.4s, padding 0.4s'
|
||
}}
|
||
>
|
||
{message.role === 'assistant' ? (
|
||
<div style={{ display: 'flex', gap: 12, alignItems: 'flex-start' }}>
|
||
{/* AGENT: 头像在左侧,内容在右侧(靠左对齐) */}
|
||
<Avatar
|
||
src={answerAgent?.avatar}
|
||
size={36}
|
||
style={{ flexShrink: 0, marginTop: 2, backgroundColor: '#52c41a' }}
|
||
>
|
||
{answerAgent?.name?.charAt(0)?.toUpperCase() || 'A'}
|
||
</Avatar>
|
||
<div style={{ flex: 1, minWidth: 0 }}>
|
||
<div style={{
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
gap: 8,
|
||
marginBottom: 6
|
||
}}>
|
||
<span style={{
|
||
fontSize: 12,
|
||
fontWeight: 400,
|
||
color: 'var(--color-text-secondary)'
|
||
}}>
|
||
{answerAgent?.name || 'AI'}
|
||
</span>
|
||
</div>
|
||
<div className={`bubble ${message.role}`}>
|
||
<Markdown>{message.content}</Markdown>
|
||
</div>
|
||
<div className="monica-msg-actions">
|
||
{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>}
|
||
<Dropdown
|
||
trigger={['click']}
|
||
menu={{
|
||
items: [
|
||
{ key: 'plain', label: '复制纯文本', onClick: () => onCopy?.(message.content, 'plain') },
|
||
{ key: 'markdown', label: '复制 Markdown', onClick: () => onCopy?.(message.content, 'markdown') }
|
||
]
|
||
}}
|
||
>
|
||
<Tooltip title="复制">
|
||
<span className="chat-copy-icon" aria-label="copy">
|
||
<ProductCopyIcon />
|
||
</span>
|
||
</Tooltip>
|
||
</Dropdown>
|
||
<Tooltip title="重新生成(开新分支)">
|
||
<Button size="small" type="text" disabled={busy} onClick={() => onRegenerate?.(message.id)}>
|
||
🔄
|
||
</Button>
|
||
</Tooltip>
|
||
</div>
|
||
{message.meta && (
|
||
<div>
|
||
{!!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>
|
||
</div>
|
||
) : (
|
||
<div style={{ display: 'flex', gap: 12, alignItems: 'flex-start', justifyContent: 'flex-end' }}>
|
||
{/* 用户: 头像在右侧,内容在左侧(靠右对齐) */}
|
||
<div style={{ flex: 1, minWidth: 0, maxWidth: '78%', display: 'flex', flexDirection: 'column', alignItems: 'flex-end' }}>
|
||
<div style={{
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
gap: 8,
|
||
marginBottom: 6
|
||
}}>
|
||
<span style={{
|
||
fontSize: 12,
|
||
fontWeight: 400,
|
||
color: 'var(--color-text-secondary)'
|
||
}}>
|
||
我
|
||
</span>
|
||
</div>
|
||
<div className={`bubble ${message.role}`}>
|
||
{message.content.includes(' ? (
|
||
<Markdown>{message.content}</Markdown>
|
||
) : (
|
||
<span dangerouslySetInnerHTML={{
|
||
__html: message.content.replace(/@([^\s]+)/g, '<span class="mention">@$1</span>')
|
||
}} />
|
||
)}
|
||
</div>
|
||
</div>
|
||
<Avatar style={{ flexShrink: 0, marginTop: 2, backgroundColor: '#1890ff' }} size={36}>我</Avatar>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|