Files
2026-03-20 07:33:46 +00:00

230 lines
8.1 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<MessageListRef, MessageListProps>(({
messages,
taskStatus = TaskStatus.PENDING,
className,
autoScroll = true,
emptyRender,
loading = false,
onAttachmentClick,
onImageAttachmentClick,
onToolCallClick,
onSendMessage,
}, ref) => {
const setProcessEvents = useEventStore(store=>store.setProcessEvents)
const scrollRef = useRef<HTMLDivElement>(null)
const bottomRef = useRef<HTMLDivElement>(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 (
<div className={cn('flex-1 flex items-center justify-center', className)}>
{emptyRender || (
<div className="text-center text-muted-foreground">
<MessageCircle className="w-16 h-16 mx-auto mb-4 text-muted" />
<p></p>
</div>
)}
</div>
)
}
return (
<div className={cn('relative flex-1 h-full overflow-hidden', className)}>
{/* 底部渐变遮罩,消息穿过时淡出 */}
<div className="pointer-events-none absolute bottom-0 left-0 right-0 h-32 z-10 bg-gradient-to-t from-white to-transparent backdrop-blur-[2px] [mask-image:linear-gradient(to_top,black_40%,transparent)]" />
{/* 消息列表滚动区域 - 使用 shadcn ScrollArearef 绑定在 Viewport 上 */}
<ScrollArea
ref={scrollRef}
className="h-full"
onScroll={handleScroll}
>
<div className="h-full px-4 pt-4 pb-36">
<div className="px-4 max-w-2xl mx-auto w-full">
{flatMessages.map((event, index) => (
<MessageItem
key={event.event_id || index}
event={event}
onAttachmentClick={onAttachmentClick}
onImageAttachmentClick={onImageAttachmentClick}
onToolCallClick={onToolCallClick}
onSendMessage={onSendMessage}
/>
))}
{/* 加载中指示器task_status === "in_progress" 或 loading=true 时显示,"paused" 时不显示 */}
{isLoading && taskStatus !== TaskStatus.PAUSED && (
<div className="flex items-center gap-3 mt-2 mb-2">
<div className="w-7 h-7 rounded-lg bg-primary/10 flex items-center justify-center shrink-0 border border-primary/20">
<span className="w-3 h-3 rounded-full border-2 border-primary border-t-transparent animate-spin inline-block" />
</div>
<div className="flex items-center gap-1.5">
<span className="w-1.5 h-1.5 bg-primary/60 rounded-full animate-bounce" />
<span
className="w-1.5 h-1.5 bg-primary/60 rounded-full animate-bounce"
style={{ animationDelay: '0.15s' }}
/>
<span
className="w-1.5 h-1.5 bg-primary/60 rounded-full animate-bounce"
style={{ animationDelay: '0.3s' }}
/>
<span className="ml-1 text-xs text-muted-foreground">...</span>
</div>
</div>
)}
</div>
{/* 滚动锚点,保证始终能精确滚到底部,并预留 10px 间距 */}
<div ref={bottomRef} className="h-[10px] w-full" />
</div>
</ScrollArea>
{/* 右侧中部:滚动置顶 / 滚动到底部控制条 */}
{(showScrollButton) && (
<div className="pointer-events-none absolute zoom-in transition-all h-max bottom-2 left-0 right-0 mx-auto flex items-center justify-center">
<div className="pointer-events-auto flex flex-col items-center gap-1 rounded-full border border-border bg-popover/88 p-1 backdrop-blur">
{/* 底部 */}
<Button
variant="ghost"
size="icon"
className={cn(
'h-8 w-8 rounded-full text-muted-foreground hover:text-primary hover:bg-accent transition-all',
!showScrollButton && 'opacity-40 cursor-default hover:bg-transparent hover:text-muted-foreground'
)}
onClick={() => {
if (!showScrollButton) return
scrollToBottom()
}}
>
<ArrowDown className="w-5 h-5" />
</Button>
</div>
</div>
)}
</div>
)
})
InnerMessageList.displayName = 'MessageList'
export const MessageList = React.memo(InnerMessageList)
export default MessageList
// 导出 MessageItem
export { MessageItem } from './message-item'