aura-web/src/pages/chat/components/messages/MessageItem.tsx

159 lines
6.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode 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 { 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('![image](') ? (
<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>
);
}