aura-web/src/pages/chat/components/ChatBody.tsx

150 lines
7.0 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 { 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>
);
}