171 lines
5.1 KiB
TypeScript
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>
|
|
);
|
|
};
|