131 lines
5.3 KiB
TypeScript
131 lines
5.3 KiB
TypeScript
import { Button, Dropdown, Space, Tag, Tooltip, Avatar } from 'antd';
|
||
import { CopyOutlined, SyncOutlined } from '@ant-design/icons';
|
||
import type { BranchInfo, ChatMessage } from '../../../../api';
|
||
import type { Agent } from '../../../../api/agents';
|
||
import Markdown from '../../../../components/Markdown';
|
||
import type { CopyMode } from '../../utils/copy';
|
||
import { ReasoningView, RetrievedView, ToolCallView } from './MetaViews';
|
||
import './MessageItem.css';
|
||
|
||
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;
|
||
isMobile?: boolean;
|
||
}) {
|
||
const { message, agentList, currentAgentId, highlighted, branch, busy, onRegenerate, onSwitchBranch, onCopy, isMobile } = props;
|
||
|
||
const speakerType = (message as any)?.speaker?.type as ('user' | 'agent' | undefined);
|
||
const speakerId = (message as any)?.speaker?.id as string | undefined;
|
||
const isUser = speakerType ? speakerType === 'user' : message.role === 'user';
|
||
const bubbleRole = isUser ? 'user' : 'assistant';
|
||
|
||
// 获取回答者 Agent 信息
|
||
const answerAgentId = !isUser ? (speakerType === 'agent' ? speakerId : undefined) || message.agent_id || 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}
|
||
className={`message-item-container ${highlighted ? 'highlighted' : ''}`}
|
||
>
|
||
{!isUser ? (
|
||
<div className="message-item-assistant">
|
||
<Avatar
|
||
src={answerAgent?.avatar}
|
||
size={36}
|
||
className="message-item-assistant-avatar"
|
||
>
|
||
{answerAgent?.name?.charAt(0)?.toUpperCase() || 'A'}
|
||
</Avatar>
|
||
<div className="message-item-assistant-content">
|
||
<div className="message-item-assistant-header">
|
||
<span className="message-item-assistant-name">
|
||
{answerAgent?.name || 'AI'}
|
||
</span>
|
||
</div>
|
||
<div className={`bubble ${bubbleRole}`}>
|
||
<Markdown>{message.content}</Markdown>
|
||
<div className="message-item-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="复制">
|
||
<Button size="small" className='actions-btn' type="text" icon={<CopyOutlined />} />
|
||
</Tooltip>
|
||
</Dropdown>
|
||
<Tooltip title="重新生成">
|
||
<Button size="small" className='actions-btn' type="text" icon={<SyncOutlined />} disabled={busy} onClick={() => onRegenerate?.(message.id)} />
|
||
</Tooltip>
|
||
</div>
|
||
</div>
|
||
{!isMobile && 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 className="message-item-user">
|
||
<div className="message-item-user-content-wrapper">
|
||
<div className={`bubble ${bubbleRole}`}>
|
||
{message.content.includes(' ? (
|
||
<Markdown>{message.content}</Markdown>
|
||
) : (
|
||
<span dangerouslySetInnerHTML={{
|
||
__html: message.content.replace(/@([^\s]+)/g, '<span class="mention">@$1</span>')
|
||
}} />
|
||
)}
|
||
</div>
|
||
</div>
|
||
<Avatar className="message-item-user-avatar" size={36}>我</Avatar>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|