初始化模版工程

This commit is contained in:
Cloud Bot
2026-03-20 07:33:46 +00:00
commit 23717e0ecd
386 changed files with 51675 additions and 0 deletions

View 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 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'