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

170 lines
6.8 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, 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('![image](') ? (
<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>
);
}