226 lines
7.4 KiB
TypeScript
226 lines
7.4 KiB
TypeScript
import React from 'react'
|
||
import { cn } from '@/utils/cn'
|
||
import type { ExtendedEvent, Attachment, HandleImageAttachmentClick } from '../../types'
|
||
import type { ApiEvent } from '../../hooks/useNovaEvents'
|
||
import { MessageHeader } from './MessageHeader'
|
||
import { MessageFooter } from './MessageFooter'
|
||
import { ToolCallAction } from './ToolCallAction'
|
||
import { BrowserUseAction } from './BrowserUseAction'
|
||
import { ContentMessage } from './ContentMessage'
|
||
import { FactCheck } from './FactCheck'
|
||
import { UserMessage } from './UserMessage'
|
||
import { SystemMessage } from './SystemMessage'
|
||
import { getMessageType, SILENT_ACTION_TYPES } from './utils'
|
||
|
||
export interface MessageItemProps {
|
||
event: ExtendedEvent
|
||
className?: string
|
||
shouldShowTimestamp?: boolean
|
||
shouldShowCopyButton?: boolean
|
||
onCopyMessage?: () => void
|
||
onAttachmentClick?: (attachment: Attachment) => void
|
||
onImageAttachmentClick?: HandleImageAttachmentClick
|
||
onToolCallClick?: (event: ApiEvent) => void
|
||
onSendMessage?: (content: string) => void
|
||
}
|
||
|
||
/**
|
||
* 消息项组件 - 结构严格对齐 next-agent message-body.tsx
|
||
*
|
||
* 渲染顺序:
|
||
* MessageHeader → ActionMessage → ContentMessage → FactCheck
|
||
* → UserMessage → SystemMessage → MessageFooter
|
||
*/
|
||
function InnerMessageItem({
|
||
event,
|
||
className,
|
||
shouldShowTimestamp,
|
||
shouldShowCopyButton = false,
|
||
onCopyMessage,
|
||
onAttachmentClick,
|
||
onImageAttachmentClick,
|
||
onToolCallClick,
|
||
onSendMessage,
|
||
}: MessageItemProps) {
|
||
// task_end 不渲染
|
||
if (event.event_type === 'task_end') return null
|
||
|
||
const messageType = getMessageType(event)
|
||
const {
|
||
action,
|
||
content,
|
||
userInteraction,
|
||
attachment,
|
||
imageAttachment,
|
||
operation,
|
||
mcpContent,
|
||
planConfig,
|
||
taskTodoConfig,
|
||
base,
|
||
} = event.renderProps || {}
|
||
|
||
// plan_step_state 为 canceled 时显示"用户取消"
|
||
const planStepState = (event as ExtendedEvent & { plan_step_state?: string })
|
||
.plan_step_state
|
||
if (planStepState === 'canceled') {
|
||
return (
|
||
<div
|
||
className={cn('flex flex-col text-sm relative w-full items-start', className)}
|
||
data-event-id={event.event_id}
|
||
>
|
||
<div className="text-sm text-muted-foreground italic px-1">用户取消</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// 对应 message-body.tsx showContentMessage 逻辑(完全对齐 Remix)
|
||
const showContentMessage =
|
||
base &&
|
||
(messageType.showContent || messageType.showUserInteraction) &&
|
||
!messageType.isFactCheck &&
|
||
(!!content || !!userInteraction)
|
||
|
||
// 对应 Remix useMessageActions.shouldShowTimestamp:
|
||
// 只有 isUserInput 或 isSummary 且有内容时才显示时间戳
|
||
const timestamp = base?.timestamp ?? event.timestamp
|
||
const showTimestamp =
|
||
shouldShowTimestamp !== undefined
|
||
? shouldShowTimestamp
|
||
: !!(messageType.showTimestamp && content?.content && (messageType.isSummary || messageType.isUserInput))
|
||
|
||
// 对应 Remix useMessageActions.shouldShowCopyButton
|
||
const showCopyButton =
|
||
shouldShowCopyButton !== undefined
|
||
? shouldShowCopyButton
|
||
: !!(content?.content && (messageType.isUserInput || messageType.isSummary))
|
||
|
||
// 判断 action 是否实际会渲染(排除 silent 类型和空 action)
|
||
// 对应 Remix Action 组件: if (!name || !action_type) return null
|
||
const hasRenderableAction =
|
||
messageType.showAction &&
|
||
!!action &&
|
||
!!(action.action_type || action.name) &&
|
||
!SILENT_ACTION_TYPES.has(action.action_type || '')
|
||
|
||
// 完全空的事件不渲染(Remix 靠 show/notEmpty 提前过滤,SDK 在此兜底)
|
||
if (
|
||
!hasRenderableAction &&
|
||
!showContentMessage &&
|
||
!messageType.isFactCheck &&
|
||
!messageType.showUserFile &&
|
||
!messageType.showTemplate &&
|
||
!messageType.showSystemAttachments &&
|
||
!messageType.showMcpContent &&
|
||
!messageType.showOperation &&
|
||
!messageType.showPlanConfig &&
|
||
!messageType.showTaskTodoList
|
||
) {
|
||
return null
|
||
}
|
||
|
||
return (
|
||
<div
|
||
className={cn(
|
||
'group flex flex-col text-sm relative w-full',
|
||
// 进入时轻微淡入 + 左右滑入,靠近样例中的对话动画
|
||
'animate-in fade-in-0 duration-200',
|
||
messageType.isUserInput ? 'slide-in-from-right-2' : 'slide-in-from-left-2',
|
||
messageType.containerAlignment,
|
||
className,
|
||
)}
|
||
data-event-id={event.event_id}
|
||
>
|
||
{/* MessageHeader:对应 <MessageHeader avatarId={base?.metadata?.agent_id} /> */}
|
||
<MessageHeader avatarId={base?.metadata?.agent_id} />
|
||
|
||
{/* ActionMessage:对应 messageType.showAction && action */}
|
||
{messageType.showAction && action && (
|
||
action.action_type === 'browser_use' ? (
|
||
<BrowserUseAction
|
||
name={action.name}
|
||
arguments={action.arguments}
|
||
toolOutput={action.tool_output}
|
||
/>
|
||
) : (
|
||
<ToolCallAction
|
||
name={action.name}
|
||
arguments={action.arguments}
|
||
action_type={action.action_type}
|
||
event={event as ApiEvent}
|
||
onClick={onToolCallClick}
|
||
/>
|
||
)
|
||
)}
|
||
|
||
{/* ContentMessage:对应 showContentMessage */}
|
||
{showContentMessage && (
|
||
<ContentMessage
|
||
content={content}
|
||
userInteraction={userInteraction}
|
||
base={base}
|
||
isUserInput={messageType.isUserInput}
|
||
showUserFile={messageType.showUserFile || messageType.showTemplate}
|
||
onSendMessage={onSendMessage}
|
||
/>
|
||
)}
|
||
|
||
{/* FactCheck:对应 messageType.isFactCheck */}
|
||
{messageType.isFactCheck && (
|
||
<FactCheck
|
||
content={content}
|
||
userInteraction={userInteraction}
|
||
base={base}
|
||
isUserInput={messageType.isUserInput}
|
||
showUserFile={messageType.showUserFile}
|
||
/>
|
||
)}
|
||
|
||
{/* UserMessage:对应 messageType.showUserFile || messageType.showTemplate */}
|
||
{(messageType.showUserFile || messageType.showTemplate) && (
|
||
<UserMessage
|
||
base={base}
|
||
loading={base?.metadata?.isTemp}
|
||
attachment={attachment}
|
||
isUserInput={messageType.isUserInput}
|
||
showTemplate={messageType.showTemplate}
|
||
onAttachmentClick={onAttachmentClick}
|
||
onImageAttachmentClick={onImageAttachmentClick}
|
||
/>
|
||
)}
|
||
|
||
{/* SystemMessage */}
|
||
<SystemMessage
|
||
attachment={attachment}
|
||
imageAttachment={imageAttachment}
|
||
operation={operation}
|
||
mcpContent={mcpContent}
|
||
planConfig={planConfig}
|
||
taskTodoConfig={taskTodoConfig}
|
||
base={base}
|
||
showSystemAttachments={messageType.showSystemAttachments}
|
||
showOperation={messageType.showOperation}
|
||
showMcpContent={messageType.showMcpContent}
|
||
showPlanConfig={messageType.showPlanConfig}
|
||
showTaskTodoList={messageType.showTaskTodoList}
|
||
onAttachmentClick={onAttachmentClick}
|
||
onImageAttachmentClick={onImageAttachmentClick}
|
||
/>
|
||
|
||
{/* MessageFooter:对应 <MessageFooter ... /> */}
|
||
<MessageFooter
|
||
shouldShowTimestamp={showTimestamp}
|
||
shouldShowCopyButton={showCopyButton}
|
||
showUserFile={messageType.showUserFile}
|
||
timestamp={timestamp}
|
||
isUserInput={messageType.isUserInput}
|
||
isSummary={messageType.isSummary}
|
||
showSystemAttachments={messageType.showSystemAttachments}
|
||
onCopyMessage={onCopyMessage ?? (() => {})}
|
||
/>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
export const MessageItem = React.memo(InnerMessageItem)
|
||
export default MessageItem
|