import React, { useRef, useEffect, useCallback, useState, forwardRef, useImperativeHandle } from 'react' import { ArrowDown, MessageCircle } from 'lucide-react' import { cn } from '@/utils/cn' import { Button } from '@/components/ui/button' import { ScrollArea } from '@/components/ui/scroll-area' import type { ExtendedEvent, Attachment, HandleImageAttachmentClick } from '../types' import { TaskStatus } from '../types' import type { ApiEvent } from '../hooks/useNovaEvents' import { MessageItem } from './message-item' import useEventStore from '../store/useEventStore' export interface MessageListProps { /** 消息列表(按步骤分组) */ messages: ExtendedEvent[][] /** 任务状态 */ taskStatus?: TaskStatus /** 自定义类名 */ className?: string /** 是否自动滚动到底部 */ autoScroll?: boolean /** 空状态渲染 */ emptyRender?: React.ReactNode /** 加载中状态 */ loading?: boolean /** 附件点击回调 */ onAttachmentClick?: (attachment: Attachment) => void /** 图片附件点击回调 */ onImageAttachmentClick?: HandleImageAttachmentClick /** 工具调用点击回调 */ onToolCallClick?: (event: ApiEvent) => void /** 发送消息(用户交互回调) */ onSendMessage?: (content: string) => void } export interface MessageListRef { scrollToBottom: (behavior?: ScrollBehavior) => void } /** * 消息列表组件 - 展示对话流 */ const InnerMessageList = forwardRef(({ messages, taskStatus = TaskStatus.PENDING, className, autoScroll = true, emptyRender, loading = false, onAttachmentClick, onImageAttachmentClick, onToolCallClick, onSendMessage, }, ref) => { const setProcessEvents = useEventStore(store=>store.setProcessEvents) const scrollRef = useRef(null) const bottomRef = useRef(null) const [showScrollButton, setShowScrollButton] = useState(false) const [showScrollTopButton, setShowScrollTopButton] = useState(false) const userTouchedRef = useRef(false) const autoScrollEnabledRef = useRef(autoScroll) // 扁平化消息列表 const flatMessages = messages.flat() useEffect(() => { setProcessEvents(flatMessages) },[flatMessages]) // 根据 taskStatus + loading 统一计算「是否展示 loading」 const isLoading = loading || taskStatus === TaskStatus.IN_PROGRESS // 滚动到底部(使用 scrollIntoView) const scrollToBottom = useCallback((behavior: ScrollBehavior = 'smooth') => { // 等下一帧,确保 DOM 和高度都已经更新 requestAnimationFrame(() => { if (bottomRef.current) { bottomRef.current.scrollIntoView({ behavior, block: 'end', }) } userTouchedRef.current = false autoScrollEnabledRef.current = true }) }, []) // 暴露滚动方法给父组件 useImperativeHandle(ref, () => ({ scrollToBottom, }), [scrollToBottom]) // 监听滚动事件 const handleScroll = useCallback(() => { if (!scrollRef.current) return const { scrollTop, scrollHeight, clientHeight } = scrollRef.current const distanceFromBottom = scrollHeight - scrollTop - clientHeight const isAtBottom = distanceFromBottom < 50 // 用户主动滚动 if (!isAtBottom && !userTouchedRef.current) { userTouchedRef.current = true autoScrollEnabledRef.current = false } setShowScrollButton(!isAtBottom && flatMessages.length > 3) setShowScrollTopButton(scrollTop > 50) }, [flatMessages.length]) // 消息变化时自动滚动 useEffect(() => { if (autoScroll && autoScrollEnabledRef.current && !userTouchedRef.current) { const behavior = taskStatus === TaskStatus.IN_PROGRESS ? 'smooth' : 'auto' scrollToBottom(behavior) } }, [flatMessages.length, autoScroll, taskStatus, scrollToBottom]) // // 初始化滚动 - 延迟一段时间,等待历史消息和布局稳定 useEffect(() => { const timer = setTimeout(() => { if (flatMessages.length > 0) { scrollToBottom('auto') } }, 0) return () => clearTimeout(timer) }, [flatMessages.length]) // 空状态 if (flatMessages.length === 0 && !isLoading) { return (
{emptyRender || (

暂无消息

)}
) } return (
{/* 底部渐变遮罩,消息穿过时淡出 */}
{/* 消息列表滚动区域 - 使用 shadcn ScrollArea,ref 绑定在 Viewport 上 */}
{flatMessages.map((event, index) => ( ))} {/* 加载中指示器:task_status === "in_progress" 或 loading=true 时显示,"paused" 时不显示 */} {isLoading && taskStatus !== TaskStatus.PAUSED && (
正在处理...
)}
{/* 滚动锚点,保证始终能精确滚到底部,并预留 10px 间距 */}
{/* 右侧中部:滚动置顶 / 滚动到底部控制条 */} {(showScrollButton) && (
{/* 底部 */}
)}
) }) InnerMessageList.displayName = 'MessageList' export const MessageList = React.memo(InnerMessageList) export default MessageList // 导出 MessageItem export { MessageItem } from './message-item'