170 lines
6.8 KiB
TypeScript
170 lines
6.8 KiB
TypeScript
import { Button, Dropdown, Space, Tag, Tooltip, Avatar, Divider } from 'antd';
|
||
import { useMemo, useState } from 'react';
|
||
import { CopyOutlined, SyncOutlined } from '@ant-design/icons';
|
||
import dayjs from 'dayjs';
|
||
import type { BranchInfo, ChatMessage } from '../../../../api';
|
||
import type { Agent } from '../../../../api/agents';
|
||
import Markdown from '../../../../components/Markdown';
|
||
import { formatMessageContent } from '../../utils/format';
|
||
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 [reasoningExpanded, setReasoningExpanded] = useState(false);
|
||
|
||
const formattedContent = useMemo(() => formatMessageContent(message.content), [message.content]);
|
||
|
||
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 timeStr = useMemo(() => {
|
||
if (!message.createdAt) return '';
|
||
return dayjs(message.createdAt * 1000).format('HH:mm');
|
||
}, [message.createdAt]);
|
||
|
||
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' : ''} ${bubbleRole}`}
|
||
data-msg-id={message.id}
|
||
data-is-agent={!isUser}
|
||
>
|
||
<div className={isUser ? 'message-item-user' : 'message-item-assistant'}>
|
||
<Avatar
|
||
src={isUser ? undefined : answerAgent?.avatar}
|
||
size={36}
|
||
className="message-item-avatar"
|
||
>
|
||
{isUser ? '我' : (answerAgent?.name?.charAt(0)?.toUpperCase() || 'A')}
|
||
</Avatar>
|
||
|
||
<div className="message-item-content">
|
||
<div className="message-item-header">
|
||
<span className="message-item-name">
|
||
{isUser ? '我' : (answerAgent?.name || 'AI')}
|
||
</span>
|
||
<span className="message-item-time">{timeStr}</span>
|
||
</div>
|
||
|
||
<div className={`bubble ${bubbleRole}`}>
|
||
{isUser && !formattedContent.includes(' ? (
|
||
<span dangerouslySetInnerHTML={{
|
||
__html: formattedContent.replace(/@([^\s]+)/g, '<span class="mention">@$1</span>')
|
||
}} />
|
||
) : (
|
||
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||
{/* 如果有推理过程且不是用户消息,展示推理部分 */}
|
||
{!isUser && message.meta?.reasoning && (
|
||
<div className="message-reasoning-section">
|
||
<ReasoningView
|
||
reasoning={message.meta.reasoning}
|
||
expanded={reasoningExpanded}
|
||
onToggle={() => setReasoningExpanded(!reasoningExpanded)}
|
||
/>
|
||
{reasoningExpanded && <Divider style={{ margin: '8px 0' }} />}
|
||
</div>
|
||
)}
|
||
<Markdown>{formattedContent}</Markdown>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
<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>}
|
||
{isUser ? (
|
||
<Tooltip title="复制">
|
||
<Button
|
||
size="small"
|
||
className='actions-btn'
|
||
type="text"
|
||
icon={<CopyOutlined />}
|
||
onClick={() => onCopy?.(formattedContent, 'plain')}
|
||
/>
|
||
</Tooltip>
|
||
) : (
|
||
<Dropdown
|
||
trigger={['click']}
|
||
menu={{
|
||
items: [
|
||
{ key: 'plain', label: '复制纯文本', onClick: () => onCopy?.(formattedContent, 'plain') },
|
||
{ key: 'markdown', label: '复制 Markdown', onClick: () => onCopy?.(formattedContent, 'markdown') }
|
||
]
|
||
}}
|
||
>
|
||
<Tooltip title="复制">
|
||
<Button size="small" className='actions-btn' type="text" icon={<CopyOutlined />} />
|
||
</Tooltip>
|
||
</Dropdown>
|
||
)}
|
||
{!isUser && (
|
||
<Tooltip title="重新生成">
|
||
<Button size="small" className='actions-btn' type="text" icon={<SyncOutlined />} disabled={busy} onClick={() => onRegenerate?.(message.id)} />
|
||
</Tooltip>
|
||
)}
|
||
</div>
|
||
|
||
{/* 底部 Meta 信息展示 (RAG / Tool) */}
|
||
{!isMobile && message.meta && !isUser && (
|
||
<div className="message-meta-section">
|
||
{message.meta.retrieved && message.meta.retrieved.length > 0 && (
|
||
<RetrievedView retrieved={message.meta.retrieved} />
|
||
)}
|
||
{message.meta.toolCalls && message.meta.toolCalls.length > 0 && (
|
||
<ToolCallView calls={message.meta.toolCalls} />
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|