Files
Agent-System/web/src/components/chat/MessageStream.tsx
T

171 lines
5.1 KiB
TypeScript

import React, { useRef, useLayoutEffect, useEffect, useCallback } from 'react';
import { ChatMessage, ChatDayBucket } from '../../types/workbench';
import { MessageBubble } from './MessageBubble';
import { DayDivider } from '../history/DayDivider';
import { Loader2 } from 'lucide-react';
export interface MessageStreamProps {
messages?: ChatMessage[];
dayStack?: ChatDayBucket[];
isLoadingOlder?: boolean;
hasMoreBefore?: boolean;
onLoadOlder?: (container: HTMLElement) => void;
onLoadFullDay?: (dayKey: string) => void;
onOpenCase?: (docId: string) => void;
onCardAction?: (cardId: string, actionId: string, payload?: any) => void;
onErrorAction?: (messageId: string, action: 'retry' | 'continue' | 'restart') => void;
}
export const MessageStream: React.FC<MessageStreamProps> = ({
messages,
dayStack,
isLoadingOlder = false,
hasMoreBefore = false,
onLoadOlder,
onLoadFullDay,
onOpenCase,
onCardAction,
onErrorAction,
}) => {
const containerRef = useRef<HTMLDivElement>(null);
const isInitialMountRef = useRef<boolean>(true);
const prevMsgCountRef = useRef<number>(0);
const isNearBottomRef = useRef<boolean>(true);
// 计算当前有效日分片集
const activeBuckets: ChatDayBucket[] = React.useMemo(() => {
if (dayStack && dayStack.length > 0) {
return dayStack;
}
if (messages && messages.length > 0) {
// 兼容老调用:将 messages 包装为单个桶
return [
{
dayKey: messages[0].dayKey || '',
count: messages.length,
loadedCount: messages.length,
truncated: false,
firstMessageId: messages[0].serverId || 0,
lastMessageId: messages[messages.length - 1].serverId || 0,
messages,
},
];
}
return [];
}, [dayStack, messages]);
const totalMessagesCount = React.useMemo(() => {
return activeBuckets.reduce((sum, b) => sum + b.messages.length, 0);
}, [activeBuckets]);
// 监听用户当前是否在底部
const handleScroll = useCallback(() => {
const el = containerRef.current;
if (!el) return;
const distanceFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight;
isNearBottomRef.current = distanceFromBottom < 80;
// 触顶加载判定 (scrollTop < 120 且还有更早历史且当前未在加载中)
if (el.scrollTop < 120 && hasMoreBefore && !isLoadingOlder && onLoadOlder) {
onLoadOlder(el);
}
}, [hasMoreBefore, isLoadingOlder, onLoadOlder]);
// 首屏挂载 / 切换会话时瞬时落底 (无动画、无滑动闪烁)
useLayoutEffect(() => {
const el = containerRef.current;
if (!el) return;
if (isInitialMountRef.current && totalMessagesCount > 0) {
el.scrollTop = el.scrollHeight;
isInitialMountRef.current = false;
prevMsgCountRef.current = totalMessagesCount;
}
}, [totalMessagesCount]);
// 新消息到达时的滚动逻辑
useEffect(() => {
const el = containerRef.current;
if (!el) return;
if (totalMessagesCount > prevMsgCountRef.current) {
// 仅当用户本来就靠近底部时才平滑跟随,不干扰向上翻阅历史的用户
if (isNearBottomRef.current) {
el.scrollTo({
top: el.scrollHeight,
behavior: 'smooth',
});
}
prevMsgCountRef.current = totalMessagesCount;
}
}, [totalMessagesCount]);
return (
<div
ref={containerRef}
onScroll={handleScroll}
style={{
flex: 1,
overflowY: 'auto',
padding: '16px 0',
display: 'flex',
flexDirection: 'column',
position: 'relative',
}}
>
{/* 触顶加载指示器 */}
{isLoadingOlder && (
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: '8px',
padding: '8px 0',
fontSize: '12px',
color: 'var(--text-faint, #9ca3af)',
}}
>
<Loader2 size={14} className="animate-spin" />
<span>正在加载更早历史消息…</span>
</div>
)}
{activeBuckets.length === 0 ? (
<div
style={{
flex: 1,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
color: 'var(--text-faint, #9ca3af)',
gap: '8px',
fontSize: '13px',
}}
>
<span>暂无对话记录,请在下方发送立项需求</span>
</div>
) : (
activeBuckets.map((bucket) => (
<React.Fragment key={bucket.dayKey || 'bucket-default'}>
{bucket.dayKey && (
<DayDivider bucket={bucket} onLoadFullDay={onLoadFullDay} />
)}
{bucket.messages.map((msg) => (
<MessageBubble
key={msg.id}
message={msg}
onOpenCase={onOpenCase}
onCardAction={onCardAction}
onErrorAction={onErrorAction}
/>
))}
</React.Fragment>
))
)}
</div>
);
};