初始化模版工程
This commit is contained in:
229
components/nova-sdk/message-list/index.tsx
Normal file
229
components/nova-sdk/message-list/index.tsx
Normal file
@@ -0,0 +1,229 @@
|
||||
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'
|
||||
Reference in New Issue
Block a user