150 lines
7.0 KiB
TypeScript
150 lines
7.0 KiB
TypeScript
import { Divider, Tag, Avatar } from 'antd';
|
||
import { useState, useEffect } from 'react';
|
||
import type { Agent, BranchInfo, ChatMessage } from '../../../api';
|
||
import Markdown from '../../../components/Markdown';
|
||
import type { StreamingState } from '../hooks/useChatSender';
|
||
import type { CopyMode } from '../utils/copy';
|
||
import MessageItem from './messages/MessageItem';
|
||
import { ReasoningView, RetrievedView, ToolCallView } from './messages/MetaViews';
|
||
import './ChatBody.css';
|
||
|
||
const isImageUrl = (url: string) => url?.startsWith('http') || url?.startsWith('/');
|
||
|
||
export default function ChatBody(props: {
|
||
bodyRef: React.RefObject<HTMLDivElement>;
|
||
agent: Agent;
|
||
agentList: Agent[];
|
||
currentAgentId: string;
|
||
messages: ChatMessage[];
|
||
branches: Record<string, BranchInfo>;
|
||
highlightId: string | null;
|
||
sending: boolean;
|
||
streaming: StreamingState;
|
||
onRegenerate: (assistantId: string) => void;
|
||
onSwitchBranch: (userMsgId: string, branchId: string) => void;
|
||
onCopy: (text: string, mode: CopyMode) => void;
|
||
isMobile?: boolean;
|
||
}) {
|
||
const { bodyRef, agent, agentList, currentAgentId, messages, branches, highlightId, sending, streaming, onRegenerate, onSwitchBranch, onCopy, isMobile } = props;
|
||
|
||
const [streamingReasoningExpanded, setStreamingReasoningExpanded] = useState(true);
|
||
|
||
// 当开始新的回答时,默认展开推理过程;当正式回答开始时,自动折叠推理过程
|
||
useEffect(() => {
|
||
if (streaming.active) {
|
||
if (!streaming.answerText) {
|
||
setStreamingReasoningExpanded(true);
|
||
} else {
|
||
setStreamingReasoningExpanded(false);
|
||
}
|
||
}
|
||
}, [streaming.active, !!streaming.answerText]);
|
||
|
||
return (
|
||
<div ref={bodyRef} className="chat-body">
|
||
<div className="messages-container">
|
||
{messages.length === 0 && !streaming.active ? (
|
||
<div className="chat-empty-welcome">
|
||
<div className="chat-welcome-avatar">
|
||
{isImageUrl(agent.avatar) ? (
|
||
<img src={agent.avatar} className="w-full h-full object-cover" alt="avatar" />
|
||
) : (
|
||
(agent.name?.charAt(0) || '?').toUpperCase()
|
||
)}
|
||
</div>
|
||
<h2 className="chat-welcome-title">你好,今天想一起完成什么?</h2>
|
||
<p className="chat-welcome-desc">{agent.description || '我是你的专属 AI 助手,随时准备为你服务。'}</p>
|
||
</div>
|
||
) : (
|
||
<>
|
||
{messages.map((m) => (
|
||
<MessageItem
|
||
isMobile={isMobile}
|
||
key={m.id}
|
||
message={m}
|
||
agentList={agentList}
|
||
currentAgentId={currentAgentId}
|
||
highlighted={highlightId === m.id}
|
||
branch={((m as any)?.speaker?.type ? (m as any).speaker.type === 'agent' : m.role === 'assistant') && m.parentId ? branches[m.parentId] : undefined}
|
||
busy={sending}
|
||
onRegenerate={onRegenerate}
|
||
onSwitchBranch={onSwitchBranch}
|
||
onCopy={onCopy}
|
||
/>
|
||
))}
|
||
|
||
{streaming.active && (
|
||
<div className="streaming-message">
|
||
<div style={{ display: 'flex', gap: 12, alignItems: 'flex-start' }}>
|
||
{(() => {
|
||
const streamingAgentId = streaming.targetAgentId || currentAgentId;
|
||
const streamingAgent = agentList.find(a => a.id === streamingAgentId);
|
||
return (
|
||
<Avatar src={streamingAgent?.avatar} size={36} className="message-item-avatar">
|
||
{streamingAgent?.name?.charAt(0)?.toUpperCase() || 'A'}
|
||
</Avatar>
|
||
);
|
||
})()}
|
||
<div className="message-item-content">
|
||
<div className="message-item-header">
|
||
<span className="message-item-name">
|
||
{agentList.find(a => a.id === (streaming.targetAgentId || currentAgentId))?.name || 'AI'}
|
||
</span>
|
||
</div>
|
||
<div className="bubble assistant">
|
||
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||
{!!streaming.retryInfo?.message && (
|
||
<div className="streaming-retry-card">
|
||
<div className="streaming-retry-header">
|
||
<span className="streaming-retry-title">
|
||
{streaming.retryInfo.stage === 'fallback_model' ? '自动切换模型' : '自动重试'}
|
||
</span>
|
||
{streaming.retryInfo.stage === 'fallback_model' ? (
|
||
<Tag color="processing" style={{ marginInlineEnd: 0 }}>
|
||
{String(streaming.retryInfo.fromModel || '')} → {String(streaming.retryInfo.toModel || '')}
|
||
</Tag>
|
||
) : (
|
||
<Tag color="processing" style={{ marginInlineEnd: 0 }}>
|
||
{String(streaming.retryInfo.model || '')}
|
||
{streaming.retryInfo.attempt ? ` · 第${streaming.retryInfo.attempt}次` : ''}
|
||
</Tag>
|
||
)}
|
||
</div>
|
||
<div style={{ fontSize: 12.5, color: 'var(--color-text-secondary)', lineHeight: 1.55 }}>{String(streaming.retryInfo.message)}</div>
|
||
</div>
|
||
)}
|
||
<div className="streaming-section">
|
||
<ReasoningView
|
||
reasoning={streaming.reasoningText || '等待推理…'}
|
||
expanded={streamingReasoningExpanded}
|
||
onToggle={() => setStreamingReasoningExpanded(!streamingReasoningExpanded)}
|
||
/>
|
||
</div>
|
||
{streaming.answerText && (
|
||
<>
|
||
<Divider style={{ margin: '8px 0' }} />
|
||
<div className="streaming-section">
|
||
<div className="streaming-section-label">正式回答</div>
|
||
<Markdown>{streaming.answerText + '▍'}</Markdown>
|
||
</div>
|
||
</>
|
||
)}
|
||
</div>
|
||
</div>
|
||
{(streaming.retrieved.length > 0 || streaming.toolCalls.length > 0) && (
|
||
<div style={{ marginTop: 8 }}>
|
||
{streaming.retrieved.length > 0 && <RetrievedView retrieved={streaming.retrieved} />}
|
||
{streaming.toolCalls.length > 0 && <ToolCallView calls={streaming.toolCalls} liveStyle />}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|