230 lines
8.1 KiB
TypeScript
230 lines
8.1 KiB
TypeScript
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 ScrollArea,ref 绑定在 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'
|