初始化模版工程
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'
|
||||
@@ -0,0 +1,41 @@
|
||||
import React from 'react'
|
||||
import type { Attachment } from '../../types'
|
||||
import { getFileIconConfig } from '../../utils/fileIcons'
|
||||
|
||||
export interface AttachmentItemProps {
|
||||
attachment: Attachment
|
||||
onClick?: (attachment: Attachment) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* 附件展示组件
|
||||
*/
|
||||
export function AttachmentItem({ attachment, onClick }: AttachmentItemProps) {
|
||||
const handleClick = (e: React.MouseEvent) => {
|
||||
if (onClick) {
|
||||
e.preventDefault()
|
||||
onClick(attachment)
|
||||
}
|
||||
}
|
||||
|
||||
const fileTypeFromMeta = attachment.file_type?.split('/')?.at(-1) ?? attachment.file_type
|
||||
const extFromName = attachment.file_name.split('.').pop() || ''
|
||||
const { icon: Icon, color } = getFileIconConfig(fileTypeFromMeta || extFromName)
|
||||
|
||||
return (
|
||||
<a
|
||||
href={attachment.file_url}
|
||||
target={onClick ? undefined : '_blank'}
|
||||
rel="noopener noreferrer"
|
||||
onClick={handleClick}
|
||||
className="inline-flex items-center gap-2 px-3 py-4 rounded-lg bg-background border border-border hover:bg-muted/70 transition-colors cursor-pointer"
|
||||
>
|
||||
<span className="flex items-center justify-center w-7 h-7 rounded-md bg-primary/10">
|
||||
<Icon className={`w-4 h-4 ${color}`} />
|
||||
</span>
|
||||
<span className="text-sm text-foreground truncate max-w-[220px]">
|
||||
{attachment.file_name}
|
||||
</span>
|
||||
</a>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { Globe, Monitor } from 'lucide-react'
|
||||
import { cn } from '@/utils/cn'
|
||||
import { useNovaKit } from '../../context/useNovaKit'
|
||||
import { ImagePreview } from '@/components/ui/image-preview'
|
||||
|
||||
export interface BrowserUseActionProps {
|
||||
/** 显示名称(action_name,如"正在浏览") */
|
||||
name?: string
|
||||
/** 参数数组,第一个通常是 URL */
|
||||
arguments?: string[]
|
||||
/** 工具原始输出(tool_output) */
|
||||
toolOutput?: unknown
|
||||
className?: string
|
||||
}
|
||||
|
||||
interface BrowserUseOutput {
|
||||
result?: {
|
||||
clean_screenshot_path?: string
|
||||
screenshot_path?: string
|
||||
url?: string
|
||||
title?: string
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* browser_use 工具调用专属渲染组件
|
||||
* 对应原始 task-computer-use.tsx,去除了 Antd / UnoCSS / store 依赖
|
||||
*/
|
||||
function InnerBrowserUseAction({
|
||||
name,
|
||||
arguments: args,
|
||||
toolOutput,
|
||||
className,
|
||||
}: BrowserUseActionProps) {
|
||||
const { api } = useNovaKit()
|
||||
const [screenshotUrl, setScreenshotUrl] = useState<string>()
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
const url = args?.[0] || ''
|
||||
|
||||
// 提取为字符串原始值作为 effect 依赖,只有路径真正变化才重新请求
|
||||
const screenshotPath =
|
||||
(toolOutput as BrowserUseOutput)?.result?.clean_screenshot_path ||
|
||||
(toolOutput as BrowserUseOutput)?.result?.screenshot_path
|
||||
|
||||
// 加载截图,api 不作为依赖(Context 每次渲染都会返回新引用,加入会反复触发)
|
||||
useEffect(() => {
|
||||
if (!screenshotPath) return
|
||||
|
||||
let cancelled = false
|
||||
|
||||
queueMicrotask(() => {
|
||||
if (cancelled) return
|
||||
setLoading(true)
|
||||
})
|
||||
|
||||
api
|
||||
.getArtifactUrl?.({ path: screenshotPath, file_name: '', file_type: 'png' })
|
||||
.then(res => {
|
||||
if (cancelled) return
|
||||
if (res?.data) setScreenshotUrl(res.data)
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => {
|
||||
if (cancelled) return
|
||||
setLoading(false)
|
||||
})
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [screenshotPath])
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-col rounded-xl border border-gray-200 overflow-hidden bg-white w-full max-w-[480px] mb-4',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{/* 标题栏 */}
|
||||
<div className="flex items-center gap-2 px-3 py-2 bg-gray-50 border-b border-gray-100">
|
||||
<Globe className="w-4 h-4 text-muted-foreground shrink-0" />
|
||||
<div className="flex flex-col min-w-0 flex-1">
|
||||
<span className="text-sm font-medium leading-tight">
|
||||
{name || '正在浏览'}
|
||||
</span>
|
||||
{url && (
|
||||
<span className="text-xs text-muted-foreground truncate">
|
||||
{url}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 内容区 */}
|
||||
<div
|
||||
className={cn(
|
||||
'relative bg-white',
|
||||
screenshotUrl || loading ? 'pt-[56.25%]' : 'py-8',
|
||||
)}
|
||||
>
|
||||
{loading && (
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<div className="w-6 h-6 border-2 border-muted border-t-primary rounded-full animate-spin" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{screenshotUrl && !loading && (
|
||||
<ImagePreview src={screenshotUrl} alt="browser screenshot">
|
||||
<img
|
||||
className="absolute inset-0 w-full h-full object-contain cursor-zoom-in"
|
||||
src={screenshotUrl}
|
||||
alt="browser screenshot"
|
||||
/>
|
||||
</ImagePreview>
|
||||
)}
|
||||
|
||||
{!screenshotUrl && !loading && (
|
||||
<div className="flex flex-col items-center justify-center gap-2 text-muted-foreground">
|
||||
<Monitor className="w-8 h-8 opacity-40" />
|
||||
<span className="text-xs">正在执行浏览器任务…</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const BrowserUseAction = React.memo(InnerBrowserUseAction)
|
||||
export default BrowserUseAction
|
||||
@@ -0,0 +1,72 @@
|
||||
import React from 'react'
|
||||
import { cn } from '@/utils/cn'
|
||||
import type { MessageContent, UserInteraction, BaseEvent } from '../../types'
|
||||
import { MarkdownContent } from '../../task-panel/Preview/MarkdownPreview'
|
||||
import { UserInteractionWidget } from './user-interaction'
|
||||
|
||||
export interface ContentMessageProps {
|
||||
content?: MessageContent
|
||||
userInteraction?: UserInteraction
|
||||
base?: BaseEvent
|
||||
isUserInput: boolean
|
||||
showUserFile: boolean
|
||||
onSendMessage?: (content: string) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* 内容消息组件 - 渲染文本内容与用户交互,对应 next-agent ContentMessage/FactCheck
|
||||
*/
|
||||
function InnerContentMessage({
|
||||
content,
|
||||
userInteraction,
|
||||
isUserInput,
|
||||
showUserFile,
|
||||
onSendMessage,
|
||||
}: ContentMessageProps) {
|
||||
const text = content?.content || content?.text
|
||||
|
||||
if (!text && !userInteraction) return null
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn('flex flex-col w-full', {
|
||||
'items-end': isUserInput,
|
||||
'items-start': !isUserInput,
|
||||
'mt-9 mb-0 px-3': showUserFile,
|
||||
'my-9': isUserInput && !showUserFile,
|
||||
})}
|
||||
>
|
||||
{text && (
|
||||
<div
|
||||
className={cn(
|
||||
'max-w-[80%]',
|
||||
isUserInput
|
||||
? 'bg-gray-900 text-white rounded-2xl rounded-tr-sm'
|
||||
: 'text-gray-900',
|
||||
)}
|
||||
>
|
||||
{isUserInput ? (
|
||||
<div className="whitespace-pre-wrap py-3 px-4 break-words leading-relaxed text-sm">
|
||||
{text}
|
||||
</div>
|
||||
) : (
|
||||
<MarkdownContent content={text} className="[&_p]:mt-0" />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{userInteraction && (
|
||||
<div className="w-full max-w-[80%]">
|
||||
<UserInteractionWidget
|
||||
userInteraction={userInteraction}
|
||||
disabled={false}
|
||||
onSendMessage={onSendMessage}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const ContentMessage = React.memo(InnerContentMessage)
|
||||
export default ContentMessage
|
||||
52
components/nova-sdk/message-list/message-item/FactCheck.tsx
Normal file
52
components/nova-sdk/message-list/message-item/FactCheck.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
import React from 'react'
|
||||
import { cn } from '@/utils/cn'
|
||||
import type { MessageContent, UserInteraction } from '../../types'
|
||||
import type { BaseEvent } from '../../types'
|
||||
import { ContentMessage } from './ContentMessage'
|
||||
|
||||
export interface FactCheckProps {
|
||||
content?: MessageContent
|
||||
userInteraction?: UserInteraction
|
||||
base?: BaseEvent
|
||||
isUserInput: boolean
|
||||
showUserFile: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* 事实核查消息组件 - 带引用内容的消息展示,对应 next-agent FactCheck
|
||||
*/
|
||||
function InnerFactCheck({
|
||||
content,
|
||||
userInteraction,
|
||||
isUserInput,
|
||||
showUserFile,
|
||||
}: FactCheckProps) {
|
||||
if (!content && !userInteraction) return null
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn('flex flex-col rounded-xl w-full', {
|
||||
'mt-9 mb-0 px-3': showUserFile,
|
||||
'my-9': isUserInput && !showUserFile,
|
||||
})}
|
||||
>
|
||||
<ContentMessage
|
||||
content={content}
|
||||
userInteraction={userInteraction}
|
||||
isUserInput={isUserInput}
|
||||
showUserFile={showUserFile}
|
||||
/>
|
||||
{content?.refer_content && (
|
||||
<div className="mt-3 w-full">
|
||||
<div className="px-3 py-2 rounded-lg border border-border/60 bg-muted/40 text-xs text-muted-foreground leading-relaxed">
|
||||
<span className="font-medium text-foreground/70 mr-1">引用:</span>
|
||||
{content.refer_content}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const FactCheck = React.memo(InnerFactCheck)
|
||||
export default FactCheck
|
||||
@@ -0,0 +1,101 @@
|
||||
import { useState, useEffect, memo } from 'react'
|
||||
import { Loader2 } from 'lucide-react'
|
||||
import { Image } from '@/components/ui/image'
|
||||
import { ImagePreview } from '@/components/ui/image-preview'
|
||||
import type { HandleImageAttachmentClick, ImageAttachment, TaskArtifact } from '../../types'
|
||||
import { useNovaKit } from '../../context/useNovaKit'
|
||||
|
||||
export interface ImageAttachmentItemProps {
|
||||
image: ImageAttachment
|
||||
assetsType: 'assistant' | 'user'
|
||||
onClick?: HandleImageAttachmentClick
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断是否需要加载 OSS URL
|
||||
*/
|
||||
function needsOssUrl(image: ImageAttachment): boolean {
|
||||
if (image.url && image.url.startsWith('http')) {
|
||||
return false
|
||||
}
|
||||
return !!(image.path || image.url)
|
||||
}
|
||||
|
||||
/**
|
||||
* 图片附件展示组件
|
||||
*/
|
||||
export const ImageAttachmentItem = memo(function ImageAttachmentItem({
|
||||
image,
|
||||
assetsType,
|
||||
onClick,
|
||||
}: ImageAttachmentItemProps) {
|
||||
const { api } = useNovaKit()
|
||||
const initialUrl = image.url?.startsWith('http') ? image.url : ''
|
||||
const [url, setUrl] = useState<string>(initialUrl)
|
||||
const [loading, setLoading] = useState(needsOssUrl(image))
|
||||
|
||||
useEffect(() => {
|
||||
if (image.url && image.url.startsWith('http')) {
|
||||
return
|
||||
}
|
||||
|
||||
const filePath = image.path || image.url || image.file_url
|
||||
if (filePath) {
|
||||
api
|
||||
.getArtifactUrl?.({ ...image, path: filePath } as TaskArtifact)
|
||||
.then((res: { data: string }) => {
|
||||
const fileUrls = res?.data
|
||||
if (fileUrls) {
|
||||
setUrl(fileUrls)
|
||||
image.url = fileUrls
|
||||
}
|
||||
})
|
||||
.catch((err: Error) => {
|
||||
console.error('获取图片 URL 失败:', err)
|
||||
})
|
||||
.finally(() => {
|
||||
setLoading(false)
|
||||
})
|
||||
}
|
||||
}, [image, image.url, image.path, api])
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-w-32 h-full min-h-32 rounded-lg">
|
||||
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!url) return null
|
||||
|
||||
const handleClick = () => {
|
||||
if (onClick) {
|
||||
onClick({ ...image, url: url || image.url }, assetsType)
|
||||
}
|
||||
}
|
||||
|
||||
if (onClick) {
|
||||
return (
|
||||
<div onClick={handleClick} className="cursor-pointer">
|
||||
<Image
|
||||
src={url}
|
||||
alt={image.file_name || '图片'}
|
||||
className="max-w-full max-h-[300px] rounded-lg hover:opacity-80 transition-opacity"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-center w-full h-full">
|
||||
<ImagePreview src={url} alt={image.file_name || '图片'}>
|
||||
<Image
|
||||
src={url}
|
||||
alt={image.file_name || '图片'}
|
||||
className="max-w-full max-h-[300px] rounded-lg hover:opacity-80 transition-opacity"
|
||||
/>
|
||||
</ImagePreview>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
105
components/nova-sdk/message-list/message-item/MessageFooter.tsx
Normal file
105
components/nova-sdk/message-list/message-item/MessageFooter.tsx
Normal file
@@ -0,0 +1,105 @@
|
||||
import React, { useMemo } from 'react'
|
||||
import { Copy } from 'lucide-react'
|
||||
import { cn } from '@/utils/cn'
|
||||
|
||||
export interface MessageFooterProps {
|
||||
shouldShowTimestamp: boolean
|
||||
shouldShowCopyButton: boolean
|
||||
timestamp?: string | number
|
||||
isUserInput: boolean
|
||||
isSummary: boolean
|
||||
showSystemAttachments: boolean
|
||||
showUserFile: boolean
|
||||
onCopyMessage: () => void
|
||||
}
|
||||
|
||||
function parseTimestamp(timestamp: string | number): Date {
|
||||
if (typeof timestamp === 'number') return new Date(timestamp)
|
||||
const num = Number(timestamp)
|
||||
if (!isNaN(num)) return new Date(num)
|
||||
// 截断超过3位的小数秒(Python 微秒精度 → JS 毫秒精度)
|
||||
const normalized = timestamp.replace(/(\.\d{3})\d+/, '$1')
|
||||
return new Date(normalized)
|
||||
}
|
||||
|
||||
function formatTimestamp(timestamp: string | number): string {
|
||||
const date = parseTimestamp(timestamp)
|
||||
|
||||
const now = new Date()
|
||||
const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate())
|
||||
const dateStart = new Date(date.getFullYear(), date.getMonth(), date.getDate())
|
||||
const diffDays = Math.round(
|
||||
(todayStart.getTime() - dateStart.getTime()) / (1000 * 60 * 60 * 24),
|
||||
)
|
||||
|
||||
const hhmm = date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', hour12: false })
|
||||
|
||||
if (diffDays === 0) {
|
||||
return hhmm
|
||||
} else if (diffDays >= 1) {
|
||||
return `${date.getMonth() + 1}/${date.getDate()} ${hhmm}`
|
||||
} else if (now.getFullYear() === date.getFullYear()) {
|
||||
return `${date.getMonth() + 1}/${date.getDate()}`
|
||||
} else {
|
||||
return `${date.getFullYear()}`
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 消息底部 - 显示时间戳和复制按钮,对应 next-agent MessageFooter
|
||||
*/
|
||||
export const MessageFooter: React.FC<MessageFooterProps> = ({
|
||||
shouldShowTimestamp,
|
||||
shouldShowCopyButton,
|
||||
timestamp,
|
||||
isUserInput,
|
||||
isSummary,
|
||||
showSystemAttachments,
|
||||
showUserFile,
|
||||
onCopyMessage,
|
||||
}) => {
|
||||
// hooks 必须在任何 return 之前调用
|
||||
const displayText = useMemo(
|
||||
() => (timestamp != null ? formatTimestamp(timestamp) : ''),
|
||||
[timestamp],
|
||||
)
|
||||
|
||||
const fullTime = useMemo(() => {
|
||||
if (timestamp == null) return ''
|
||||
return parseTimestamp(timestamp).toLocaleString()
|
||||
}, [timestamp])
|
||||
|
||||
if (!shouldShowTimestamp) return null
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'opacity-0 group-hover:opacity-100 flex items-center bottom-1 gap-3',
|
||||
{
|
||||
'right-0 absolute': isUserInput,
|
||||
'flex-row-reverse': isSummary,
|
||||
'mt-2.5': isSummary && showSystemAttachments,
|
||||
'px-3': showUserFile,
|
||||
},
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className="text-xs text-muted-foreground whitespace-nowrap"
|
||||
title={fullTime}
|
||||
>
|
||||
{displayText}
|
||||
</span>
|
||||
{shouldShowCopyButton && (
|
||||
<button
|
||||
className="w-6 h-6 rounded flex items-center justify-center hover:bg-muted transition-colors"
|
||||
onClick={onCopyMessage}
|
||||
type="button"
|
||||
>
|
||||
<Copy className="w-3.5 h-3.5 text-muted-foreground cursor-pointer" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(MessageFooter)
|
||||
@@ -0,0 +1,19 @@
|
||||
import React from 'react'
|
||||
import { Bot } from 'lucide-react'
|
||||
import { useNovaKit } from '../../context/useNovaKit'
|
||||
|
||||
interface MessageHeaderProps {
|
||||
avatarId?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 消息头部 - 显示 Agent 头像,对应 next-agent MessageHeader
|
||||
*/
|
||||
export const MessageHeader: React.FC<MessageHeaderProps> = ({ avatarId }) => {
|
||||
const { agentName } = useNovaKit()
|
||||
if (!avatarId) return null
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export default React.memo(MessageHeader)
|
||||
@@ -0,0 +1,122 @@
|
||||
import React from 'react'
|
||||
import { GalleryHorizontal } from 'lucide-react'
|
||||
import { cn } from '@/utils/cn'
|
||||
|
||||
interface SlideFile {
|
||||
id: string
|
||||
title?: string
|
||||
summary?: string
|
||||
path?: string
|
||||
filename?: string
|
||||
}
|
||||
|
||||
interface SlideOutlineOutput {
|
||||
slide_files?: SlideFile[]
|
||||
project_path?: string
|
||||
}
|
||||
|
||||
export interface SlideOutlineActionProps {
|
||||
/** action 名称(如"正在初始化PPT大纲") */
|
||||
name?: string
|
||||
/** 参数(如主标题) */
|
||||
arguments?: string[]
|
||||
/** tool_output,包含 slide_files */
|
||||
toolOutput?: unknown
|
||||
/** 点击整体卡片的回调(对应 BlockAction 的 onClick) */
|
||||
onClick?: () => void
|
||||
className?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* slide_init 专属渲染组件
|
||||
* 对应 Remix: BlockAction + SlideOutline (compact)
|
||||
*/
|
||||
function InnerSlideOutlineAction({
|
||||
name,
|
||||
arguments: args,
|
||||
toolOutput,
|
||||
onClick,
|
||||
className,
|
||||
}: SlideOutlineActionProps) {
|
||||
const output = toolOutput as SlideOutlineOutput | undefined
|
||||
const slideFiles = output?.slide_files || []
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-col my-2 rounded-lg border border-border/60 bg-background overflow-hidden',
|
||||
'w-full max-w-[480px]',
|
||||
onClick && 'cursor-pointer hover:border-border transition-colors',
|
||||
className,
|
||||
)}
|
||||
onClick={onClick}
|
||||
role={onClick ? 'button' : undefined}
|
||||
tabIndex={onClick ? 0 : undefined}
|
||||
onKeyDown={onClick ? e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onClick() } } : undefined}
|
||||
>
|
||||
{/* 标题栏 */}
|
||||
<div className="shrink-0 h-9 px-3 flex items-center gap-1.5 border-b border-border/50 bg-muted/40">
|
||||
<GalleryHorizontal className="w-4 h-4 text-muted-foreground shrink-0" />
|
||||
<span className="text-sm font-medium text-foreground shrink-0">
|
||||
{name || '初始化幻灯片'}
|
||||
</span>
|
||||
{args && args.length > 0 && (
|
||||
<span className="text-sm text-muted-foreground truncate">
|
||||
{args.join(' ')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 幻灯片大纲列表 */}
|
||||
<div className="flex-1 overflow-auto max-h-52">
|
||||
{slideFiles.length === 0 ? (
|
||||
<div className="flex flex-col gap-2 p-3">
|
||||
{[1, 2, 3].map(i => (
|
||||
<div key={i} className="flex gap-3 animate-pulse">
|
||||
<div className="w-7 h-4 bg-muted rounded mt-1 shrink-0" />
|
||||
<div className="flex-1 space-y-1.5">
|
||||
<div className="h-4 bg-muted rounded w-3/4" />
|
||||
<div className="h-3 bg-muted rounded w-full" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-border/40">
|
||||
{slideFiles.map((slide, index) => (
|
||||
<div key={slide.id || index} className="flex bg-background hover:bg-muted/20 transition-colors">
|
||||
<span className="shrink-0 w-8 py-3 pl-3 text-sm text-muted-foreground italic">
|
||||
P{index + 1}
|
||||
</span>
|
||||
<div className="flex-1 px-3 py-3 flex flex-col gap-1 min-w-0">
|
||||
{slide.title ? (
|
||||
<h3 className="text-sm font-medium text-foreground leading-tight">
|
||||
{slide.title}
|
||||
</h3>
|
||||
) : (
|
||||
<div className="h-4 bg-muted rounded animate-pulse w-1/2" />
|
||||
)}
|
||||
{slide.summary ? (
|
||||
<p className="text-xs text-muted-foreground leading-relaxed line-clamp-2">
|
||||
{slide.summary}
|
||||
</p>
|
||||
) : (
|
||||
<div className="h-3 bg-muted rounded animate-pulse w-3/4" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 底部渐变遮罩(对应 Remix BlockAction 的渐变效果) */}
|
||||
{slideFiles.length > 3 && (
|
||||
<div className="h-8 absolute left-0 bottom-0 bg-gradient-to-b from-transparent to-background pointer-events-none" />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const SlideOutlineAction = React.memo(InnerSlideOutlineAction)
|
||||
export default SlideOutlineAction
|
||||
129
components/nova-sdk/message-list/message-item/SystemMessage.tsx
Normal file
129
components/nova-sdk/message-list/message-item/SystemMessage.tsx
Normal file
@@ -0,0 +1,129 @@
|
||||
import React from 'react'
|
||||
import type {
|
||||
Attachment,
|
||||
ImageAttachment,
|
||||
McpContent,
|
||||
PlanConfig,
|
||||
TaskTodoConfig,
|
||||
Operation,
|
||||
BaseEvent,
|
||||
HandleImageAttachmentClick,
|
||||
} from '../../types'
|
||||
import { AttachmentItem } from './AttachmentItem'
|
||||
import { ImageAttachmentItem } from './ImageAttachmentItem'
|
||||
import { TodoList } from './TodoList'
|
||||
|
||||
export interface SystemMessageProps {
|
||||
attachment?: Attachment[]
|
||||
imageAttachment?: ImageAttachment[]
|
||||
operation?: Operation
|
||||
mcpContent?: McpContent
|
||||
planConfig?: PlanConfig
|
||||
taskTodoConfig?: TaskTodoConfig
|
||||
base?: BaseEvent
|
||||
showSystemAttachments: boolean
|
||||
showOperation: boolean
|
||||
showMcpContent: boolean
|
||||
showPlanConfig: boolean
|
||||
showTaskTodoList: boolean
|
||||
onAttachmentClick?: (attachment: Attachment) => void
|
||||
onImageAttachmentClick?: HandleImageAttachmentClick
|
||||
}
|
||||
|
||||
/**
|
||||
* 系统消息组件 - 渲染系统附件、待办列表、MCP内容、计划配置、操作信息
|
||||
* 对应 next-agent SystemMessage
|
||||
*/
|
||||
function InnerSystemMessage({
|
||||
attachment,
|
||||
imageAttachment,
|
||||
operation,
|
||||
mcpContent,
|
||||
planConfig,
|
||||
taskTodoConfig,
|
||||
showSystemAttachments,
|
||||
showOperation,
|
||||
showMcpContent,
|
||||
showPlanConfig,
|
||||
showTaskTodoList,
|
||||
onAttachmentClick,
|
||||
onImageAttachmentClick,
|
||||
}: SystemMessageProps) {
|
||||
const hasContent =
|
||||
showTaskTodoList ||
|
||||
(imageAttachment?.length ?? 0) > 0 ||
|
||||
(showSystemAttachments && (attachment?.length ?? 0) > 0) ||
|
||||
(showMcpContent && !!mcpContent) ||
|
||||
(showOperation && !!operation) ||
|
||||
(showPlanConfig && !!planConfig)
|
||||
|
||||
if (!hasContent) return null
|
||||
|
||||
return (
|
||||
<div className="w-full space-y-2 mt-1">
|
||||
{/* Todo 列表 */}
|
||||
{showTaskTodoList && taskTodoConfig?.list?.length && (
|
||||
<TodoList items={taskTodoConfig.list} />
|
||||
)}
|
||||
|
||||
{/* 图片附件(系统消息里的) */}
|
||||
{(imageAttachment?.length ?? 0) > 0 && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{imageAttachment!.map((img, i) => (
|
||||
<ImageAttachmentItem
|
||||
assetsType="assistant"
|
||||
key={img.url || img.path || i}
|
||||
image={img}
|
||||
onClick={onImageAttachmentClick}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 文件附件(系统消息里的) */}
|
||||
{showSystemAttachments && (attachment?.length ?? 0) > 0 && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{attachment!.map((att, i) => (
|
||||
<AttachmentItem
|
||||
key={att.file_id || att.file_url || i}
|
||||
attachment={att}
|
||||
onClick={onAttachmentClick}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* MCP 内容 */}
|
||||
{showMcpContent && mcpContent && (
|
||||
<div className="p-2 rounded-lg bg-muted border border-border/50">
|
||||
<pre className="text-xs text-muted-foreground whitespace-pre-wrap break-all">
|
||||
{JSON.stringify(mcpContent, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 操作信息 */}
|
||||
{showOperation && operation && (
|
||||
<div className="p-2 rounded-lg bg-muted border border-border/50">
|
||||
<pre className="text-xs text-muted-foreground whitespace-pre-wrap break-all">
|
||||
{JSON.stringify(operation, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 计划配置 */}
|
||||
{showPlanConfig && planConfig?.steps && (
|
||||
<TodoList
|
||||
items={planConfig.steps.map(s => ({
|
||||
id: s.id,
|
||||
title: s.title,
|
||||
status: s.status,
|
||||
}))}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const SystemMessage = React.memo(InnerSystemMessage)
|
||||
export default SystemMessage
|
||||
156
components/nova-sdk/message-list/message-item/TodoList.tsx
Normal file
156
components/nova-sdk/message-list/message-item/TodoList.tsx
Normal file
@@ -0,0 +1,156 @@
|
||||
import { memo } from 'react'
|
||||
import { ArrowRight, Check, ClipboardList, Circle } from 'lucide-react'
|
||||
import { cn } from '@/utils/cn'
|
||||
import { useNovaKit } from '../../context/useNovaKit'
|
||||
|
||||
export interface TodoItem {
|
||||
id?: string | number
|
||||
title?: string
|
||||
status?: 'pending' | 'completed' | 'in_progress' | string
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
export interface TodoListProps {
|
||||
items: TodoItem[]
|
||||
className?: string
|
||||
}
|
||||
|
||||
function TodoListComponent({ items = [], className }: TodoListProps) {
|
||||
const { agentName } = useNovaKit()
|
||||
const todoItems = items
|
||||
const totalCount = todoItems.length
|
||||
const completedCount = todoItems.filter(item => item.status === 'completed').length
|
||||
const isAllCompleted = totalCount > 0 && completedCount === totalCount
|
||||
const displayCompletedCount =
|
||||
totalCount === 0
|
||||
? 0
|
||||
: Math.min(totalCount, isAllCompleted ? completedCount : completedCount + 1)
|
||||
const progressRatio =
|
||||
totalCount === 0
|
||||
? 0
|
||||
: displayCompletedCount / totalCount
|
||||
const progressPercent = Math.max(0, Math.min(100, progressRatio * 100))
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'relative my-1 mb-4 max-w-3xl overflow-hidden rounded-xl border border-gray-200 bg-white',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{/* Header:任务执行流水线 */}
|
||||
<div className="flex items-center justify-between bg-gray-50 border-b border-gray-100 px-3 py-[5px]">
|
||||
<div className="flex min-w-0 flex-1 items-center gap-2">
|
||||
<div className="flex h-4 w-4 flex-none items-center justify-center text-muted-foreground">
|
||||
<ClipboardList className="h-4 w-4" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<h2 className="truncate text-[14px] leading-[22px] text-foreground">
|
||||
任务执行流水线
|
||||
</h2>
|
||||
<p className="truncate text-[12px] text-muted-foreground">{agentName}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 pl-3">
|
||||
<span className="text-[12px] font-medium text-muted-foreground">Progress</span>
|
||||
<span className="text-[12px] font-medium text-muted-foreground">
|
||||
{displayCompletedCount} / {totalCount || 0}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 列表内容:卡片列表布局 */}
|
||||
<div className="relative overflow-hidden transition-all duration-200 ease-in-out">
|
||||
<div className="relative overflow-visible bg-white px-3 py-2.5">
|
||||
<div className="absolute left-0 top-0 h-[2px] w-full bg-border/70">
|
||||
<div
|
||||
className="h-full bg-primary transition-all duration-700"
|
||||
style={{ width: `${progressPercent}%` }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col pt-1">
|
||||
{todoItems.map((item, index) => {
|
||||
const status = item.status as string | undefined
|
||||
const isCompleted = status === 'completed'
|
||||
const isActive = status === 'in_progress'
|
||||
const isPending = !isCompleted && !isActive
|
||||
const stepLabel = `STEP ${index + 1} OF ${totalCount || 0}`
|
||||
|
||||
return (
|
||||
<div
|
||||
key={(item.id as string) ?? index}
|
||||
className={cn(
|
||||
'flex items-center justify-between gap-3 py-1',
|
||||
isCompleted && 'opacity-70',
|
||||
isPending && 'opacity-60',
|
||||
)}
|
||||
>
|
||||
{/* 左侧:状态圆点 + 文案 */}
|
||||
<div className="flex min-w-0 flex-1 items-center gap-2">
|
||||
{/* 状态圆点 */}
|
||||
<div className="relative flex flex-none items-center justify-center">
|
||||
{isCompleted && (
|
||||
<div className="flex h-4 w-4 items-center justify-center rounded-full bg-primary">
|
||||
<Check className="h-2.5 w-2.5 text-white" />
|
||||
</div>
|
||||
)}
|
||||
{isActive && (
|
||||
<div className="flex h-4 w-4 items-center justify-center rounded-full bg-primary/12 text-primary">
|
||||
<ArrowRight className="h-2.5 w-2.5" />
|
||||
</div>
|
||||
)}
|
||||
{isPending && !isCompleted && !isActive && (
|
||||
<div className="flex h-4 w-4 items-center justify-center rounded-full border border-gray-200 bg-white">
|
||||
<Circle className="h-2.5 w-2.5 text-muted-foreground/55" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 文案 */}
|
||||
<div className="min-w-0 space-y-0.5">
|
||||
<p
|
||||
className={cn(
|
||||
'truncate text-[14px] leading-[22px] font-normal',
|
||||
isActive
|
||||
? 'text-foreground'
|
||||
: 'text-muted-foreground',
|
||||
isCompleted && 'line-through decoration-border',
|
||||
)}
|
||||
>
|
||||
{item.title as string}
|
||||
</p>
|
||||
{typeof (item as any).description === 'string' && (
|
||||
<p className="truncate text-[12px] text-muted-foreground">
|
||||
{(item as any).description as string}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{isActive && (
|
||||
<span className="h-1 w-1 flex-none rounded-full bg-primary animate-pulse" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 右侧:步骤标签 */}
|
||||
<span
|
||||
className={cn(
|
||||
'flex-none text-[10px] font-mono font-medium tracking-tight',
|
||||
isActive
|
||||
? 'text-primary'
|
||||
: 'text-muted-foreground',
|
||||
)}
|
||||
>
|
||||
{stepLabel}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const TodoList = memo(TodoListComponent)
|
||||
export default TodoList
|
||||
131
components/nova-sdk/message-list/message-item/ToolCallAction.tsx
Normal file
131
components/nova-sdk/message-list/message-item/ToolCallAction.tsx
Normal file
@@ -0,0 +1,131 @@
|
||||
import React from 'react'
|
||||
import {
|
||||
Code,
|
||||
Terminal,
|
||||
Search,
|
||||
FileCode,
|
||||
Globe,
|
||||
FileText,
|
||||
FileEdit,
|
||||
Image,
|
||||
GalleryHorizontal,
|
||||
Puzzle,
|
||||
Bot,
|
||||
RefreshCw,
|
||||
Eye,
|
||||
Layers,
|
||||
Wrench,
|
||||
} from 'lucide-react'
|
||||
import { cn } from '@/utils/cn'
|
||||
import type { ApiEvent } from '../../types'
|
||||
import { SILENT_ACTION_TYPES } from './utils'
|
||||
|
||||
export interface ToolCallActionProps {
|
||||
name?: string
|
||||
arguments?: string[]
|
||||
action_type?: string
|
||||
event?: ApiEvent
|
||||
onClick?: (event: ApiEvent) => void
|
||||
}
|
||||
|
||||
// ─── action_type → { icon, label } ──────────────────────────────────────────
|
||||
const ACTION_TYPE_META: Record<string, { icon: React.ReactNode; label: string }> = {
|
||||
shell_execute: { icon: <Terminal className="w-4 h-4" />, label: '执行命令' },
|
||||
terminal_operator: { icon: <Terminal className="w-4 h-4" />, label: '终端操作' },
|
||||
code_execute: { icon: <Code className="w-4 h-4" />, label: '运行代码' },
|
||||
file_operator: { icon: <FileCode className="w-4 h-4" />, label: '文件操作' },
|
||||
file_create: { icon: <FileText className="w-4 h-4" />, label: '创建文件' },
|
||||
file_read: { icon: <FileText className="w-4 h-4" />, label: '读取文件' },
|
||||
file_replace_text: { icon: <FileEdit className="w-4 h-4" />, label: '编辑文件' },
|
||||
file_write_text: { icon: <FileEdit className="w-4 h-4" />, label: '写入文件' },
|
||||
str_replace: { icon: <FileEdit className="w-4 h-4" />, label: '替换内容' },
|
||||
info_search_web: { icon: <Search className="w-4 h-4" />, label: '搜索网页' },
|
||||
info_fetch_webpage: { icon: <Globe className="w-4 h-4" />, label: '获取网页' },
|
||||
news_search: { icon: <Search className="w-4 h-4" />, label: '新闻搜索' },
|
||||
image_search: { icon: <Image className="w-4 h-4" />, label: '图片搜索' },
|
||||
info_search_custom_knowledge: { icon: <Search className="w-4 h-4" />, label: '知识检索' },
|
||||
search_custom_knowledge: { icon: <Search className="w-4 h-4" />, label: '知识检索' },
|
||||
browser_use: { icon: <Globe className="w-4 h-4" />, label: '浏览器操作' },
|
||||
slide_init: { icon: <GalleryHorizontal className="w-4 h-4" />, label: '初始化幻灯片' },
|
||||
slide_template: { icon: <GalleryHorizontal className="w-4 h-4" />, label: '选择模板' },
|
||||
slide_create: { icon: <GalleryHorizontal className="w-4 h-4" />, label: '生成幻灯片' },
|
||||
slide_create_batch: { icon: <Layers className="w-4 h-4" />, label: '批量生成幻灯片' },
|
||||
slide_present: { icon: <GalleryHorizontal className="w-4 h-4" />, label: '展示幻灯片' },
|
||||
media_generate_image: { icon: <Image className="w-4 h-4" />, label: '生成图片' },
|
||||
media_vision_image: { icon: <Eye className="w-4 h-4" />, label: '识别图片' },
|
||||
generate_image: { icon: <Image className="w-4 h-4" />, label: '生成图片' },
|
||||
call_flow: { icon: <RefreshCw className="w-4 h-4" />, label: '调用流程' },
|
||||
integrated_app: { icon: <Puzzle className="w-4 h-4" />, label: '集成应用' },
|
||||
custom_api: { icon: <Wrench className="w-4 h-4" />, label: '自定义工具' },
|
||||
skill_loader: { icon: <Bot className="w-4 h-4" />, label: '加载技能' },
|
||||
brand_search: { icon: <Search className="w-4 h-4" />, label: '品牌检索' },
|
||||
xiaohongshu_search: { icon: <Search className="w-4 h-4" />, label: '小红书搜索' },
|
||||
e_commerce: { icon: <Search className="w-4 h-4" />, label: '电商搜索' },
|
||||
experience_query: { icon: <Search className="w-4 h-4" />, label: '经验查询' },
|
||||
writer: { icon: <FileEdit className="w-4 h-4" />, label: '文档创作' },
|
||||
parallel_map: { icon: <Layers className="w-4 h-4" />, label: '并行任务' },
|
||||
media_comments: { icon: <Search className="w-4 h-4" />, label: '媒体搜索' },
|
||||
system_api: { icon: <Wrench className="w-4 h-4" />, label: '系统接口' },
|
||||
}
|
||||
|
||||
/**
|
||||
* 工具调用 Action 组件
|
||||
* - 静默事件类型 → 不渲染
|
||||
* - name / label / argsText 全空 → 不渲染
|
||||
* - 点击触发外部 onClick(打开右侧面板)
|
||||
*/
|
||||
export function ToolCallAction({
|
||||
name,
|
||||
arguments: args,
|
||||
action_type,
|
||||
event,
|
||||
onClick,
|
||||
}: ToolCallActionProps) {
|
||||
// 静默类型直接跳过
|
||||
if (action_type && SILENT_ACTION_TYPES.has(action_type)) return null
|
||||
|
||||
const meta = action_type ? ACTION_TYPE_META[action_type] : undefined
|
||||
const icon = meta?.icon ?? <Code className="w-4 h-4" />
|
||||
const label = name || meta?.label || ''
|
||||
const argsText = args?.filter(Boolean).join(' ') || ''
|
||||
|
||||
// 没有任何可展示内容时不渲染
|
||||
if (!label && !argsText) return null
|
||||
|
||||
const handleClick = () => {
|
||||
if (event && onClick) onClick(event)
|
||||
}
|
||||
const isClickable = !!(event && onClick)
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'inline-flex items-center gap-2 px-3 py-1.5 rounded-lg max-w-full mb-2',
|
||||
'border border-gray-200 bg-white/80',
|
||||
'text-sm',
|
||||
isClickable && 'cursor-pointer transition-all duration-150 hover:bg-gray-50 hover:border-gray-300',
|
||||
)}
|
||||
onClick={isClickable ? handleClick : undefined}
|
||||
role={isClickable ? 'button' : undefined}
|
||||
tabIndex={isClickable ? 0 : undefined}
|
||||
onKeyDown={
|
||||
isClickable
|
||||
? e => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault()
|
||||
handleClick()
|
||||
}
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<span className="shrink-0 text-gray-500">{icon}</span>
|
||||
{label && <span className="shrink-0 font-medium text-gray-800">{label}:</span>}
|
||||
{argsText && (
|
||||
<code className="min-w-0 flex-1 truncate font-mono text-xs text-gray-500">
|
||||
{argsText}
|
||||
</code>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import React from 'react'
|
||||
import { cn } from '@/utils/cn'
|
||||
import type { Attachment, ImageAttachment, BaseEvent, HandleImageAttachmentClick } from '../../types'
|
||||
import { AttachmentItem } from './AttachmentItem'
|
||||
import { ImageAttachmentItem } from './ImageAttachmentItem'
|
||||
|
||||
export interface UserMessageProps {
|
||||
base?: BaseEvent
|
||||
attachment?: Attachment[]
|
||||
imageAttachment?: ImageAttachment[]
|
||||
loading?: boolean
|
||||
isUserInput: boolean
|
||||
showTemplate?: boolean
|
||||
onAttachmentClick?: (attachment: Attachment) => void
|
||||
onImageAttachmentClick?: HandleImageAttachmentClick
|
||||
}
|
||||
|
||||
/**
|
||||
* 用户消息附件组件 - 展示用户上传的文件/图片,对应 next-agent UserMessage
|
||||
*/
|
||||
function InnerUserMessage({
|
||||
attachment,
|
||||
imageAttachment,
|
||||
loading,
|
||||
isUserInput,
|
||||
onAttachmentClick,
|
||||
onImageAttachmentClick,
|
||||
}: UserMessageProps) {
|
||||
const hasImages = !!(imageAttachment?.length)
|
||||
const hasFiles = !!(attachment?.length)
|
||||
|
||||
if (!hasImages && !hasFiles) return null
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-col rounded-xl mt-2 mb-6 px-3',
|
||||
{ 'opacity-60': loading },
|
||||
isUserInput ? 'items-end' : 'items-start',
|
||||
)}
|
||||
>
|
||||
{hasImages && (
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-wrap gap-2 mb-2',
|
||||
isUserInput ? 'justify-end' : 'justify-start',
|
||||
)}
|
||||
>
|
||||
{imageAttachment!.map((img, i) => (
|
||||
<ImageAttachmentItem
|
||||
assetsType="user"
|
||||
key={img.url || img.path || i}
|
||||
image={img}
|
||||
onClick={onImageAttachmentClick}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{hasFiles && (
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-wrap gap-2',
|
||||
isUserInput ? 'justify-end' : 'justify-start',
|
||||
)}
|
||||
>
|
||||
{attachment!.map((att, i) => (
|
||||
<AttachmentItem
|
||||
key={att.file_id || att.file_url || i}
|
||||
attachment={att}
|
||||
onClick={onAttachmentClick}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const UserMessage = React.memo(InnerUserMessage)
|
||||
export default UserMessage
|
||||
225
components/nova-sdk/message-list/message-item/index.tsx
Normal file
225
components/nova-sdk/message-list/message-item/index.tsx
Normal file
@@ -0,0 +1,225 @@
|
||||
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
|
||||
@@ -0,0 +1,30 @@
|
||||
import { memo, useState } from 'react'
|
||||
import { InteractionButtons } from './InteractionButtons'
|
||||
|
||||
const TAKEOVER_ACTIONS = ['尝试接管', '立即跳过'] as const
|
||||
|
||||
interface BrowserTakeoverInteractionProps {
|
||||
disabled?: boolean
|
||||
browser_type?: string
|
||||
onSendMessage?: (content: string) => void
|
||||
}
|
||||
|
||||
export const BrowserTakeoverInteraction = memo(
|
||||
({ disabled, onSendMessage }: BrowserTakeoverInteractionProps) => {
|
||||
const [isClicked, setIsClicked] = useState(false)
|
||||
|
||||
const handleClick = (action: string) => {
|
||||
setIsClicked(true)
|
||||
onSendMessage?.(action)
|
||||
}
|
||||
|
||||
return (
|
||||
<InteractionButtons
|
||||
items={[...TAKEOVER_ACTIONS]}
|
||||
disabled={disabled}
|
||||
isClicked={isClicked}
|
||||
onClick={handleClick}
|
||||
/>
|
||||
)
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,31 @@
|
||||
import { memo, useState } from 'react'
|
||||
import type { UserInteraction } from '../../../types'
|
||||
import { InteractionButtons } from './InteractionButtons'
|
||||
|
||||
interface ChoiceInteractionProps {
|
||||
choice_options?: UserInteraction['choice_options']
|
||||
disabled?: boolean
|
||||
onSendMessage?: (content: string) => void
|
||||
}
|
||||
|
||||
export const ChoiceInteraction = memo(
|
||||
({ choice_options, disabled, onSendMessage }: ChoiceInteractionProps) => {
|
||||
const [isClicked, setIsClicked] = useState(false)
|
||||
|
||||
const handleClick = (label: string) => {
|
||||
setIsClicked(true)
|
||||
onSendMessage?.(label)
|
||||
}
|
||||
|
||||
if (!choice_options?.length) return null
|
||||
|
||||
return (
|
||||
<InteractionButtons
|
||||
items={choice_options}
|
||||
disabled={disabled}
|
||||
isClicked={isClicked}
|
||||
onClick={handleClick}
|
||||
/>
|
||||
)
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,61 @@
|
||||
import { memo } from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip'
|
||||
|
||||
interface ButtonItem {
|
||||
label: string
|
||||
disabled?: boolean
|
||||
tooltip?: string
|
||||
}
|
||||
|
||||
interface InteractionButtonsProps {
|
||||
items: Array<string | ButtonItem>
|
||||
disabled?: boolean
|
||||
isClicked?: boolean
|
||||
onClick: (label: string) => void
|
||||
}
|
||||
|
||||
export const InteractionButtons = memo(
|
||||
({ items, disabled, isClicked, onClick }: InteractionButtonsProps) => {
|
||||
if (!items?.length) return null
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<div className="flex items-center flex-wrap gap-2 mt-2">
|
||||
{items.map((item, idx) => {
|
||||
const label = typeof item === 'string' ? item : item.label
|
||||
const btnDisabled = typeof item === 'string' ? false : !!item.disabled
|
||||
const tooltip = typeof item === 'string' ? undefined : item.tooltip
|
||||
|
||||
const button = (
|
||||
<Button
|
||||
key={`${label}_${idx}`}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={isClicked || disabled || btnDisabled}
|
||||
onClick={() => onClick(label)}
|
||||
className="h-auto cursor-pointer border-border bg-secondary/60 px-3 py-1.5 text-sm font-normal text-foreground hover:border-primary/40 hover:bg-accent hover:text-primary"
|
||||
>
|
||||
{label}
|
||||
</Button>
|
||||
)
|
||||
|
||||
return tooltip ? (
|
||||
<Tooltip key={`${label}_${idx}`}>
|
||||
<TooltipTrigger asChild>{button}</TooltipTrigger>
|
||||
<TooltipContent>{tooltip}</TooltipContent>
|
||||
</Tooltip>
|
||||
) : (
|
||||
button
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
)
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,33 @@
|
||||
import { memo } from 'react'
|
||||
import { cn } from '@/utils/cn'
|
||||
import { MarkdownContent } from '../../../task-panel/Preview/MarkdownPreview'
|
||||
|
||||
interface InteractionWrapperProps {
|
||||
content?: string
|
||||
isLatest?: boolean
|
||||
className?: string
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
export const InteractionWrapper = memo(
|
||||
({ content, children, className }: InteractionWrapperProps) => {
|
||||
const trimmedContent = content?.trim()
|
||||
|
||||
return (
|
||||
<div className={cn('mt-2 w-full', className)}>
|
||||
<div className="relative rounded-r-lg border border-solid border-border/60 bg-card/90 py-2 pl-4 pr-3">
|
||||
{/* 左侧主色竖条 */}
|
||||
<div className="absolute left-0 top-0 bottom-0 w-1 bg-primary rounded-l-lg" />
|
||||
|
||||
{trimmedContent && (
|
||||
<div className="mb-2">
|
||||
<MarkdownContent content={trimmedContent} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,166 @@
|
||||
import { memo, useEffect, useRef, useState } from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { cn } from '@/utils/cn'
|
||||
import type { SlideQuestion } from '../../../types'
|
||||
|
||||
interface SlideFormInteractionProps {
|
||||
questions?: SlideQuestion[]
|
||||
disabled?: boolean
|
||||
messageTime?: string
|
||||
onSendMessage?: (content: string) => void
|
||||
}
|
||||
|
||||
const COUNTDOWN_SECONDS = 30
|
||||
|
||||
export const SlideFormInteraction = memo(
|
||||
({
|
||||
questions,
|
||||
disabled,
|
||||
messageTime,
|
||||
onSendMessage,
|
||||
}: SlideFormInteractionProps) => {
|
||||
const [answers, setAnswers] = useState<Record<number, string>>({})
|
||||
const [submitted, setSubmitted] = useState(false)
|
||||
const [seconds, setSeconds] = useState(messageTime ? COUNTDOWN_SECONDS : 0)
|
||||
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
||||
|
||||
// 初始化已有答案
|
||||
useEffect(() => {
|
||||
if (!questions) return
|
||||
const initial: Record<number, string> = {}
|
||||
questions.forEach((q, i) => {
|
||||
if (q.answer != null) initial[i] = q.answer
|
||||
})
|
||||
setAnswers(initial)
|
||||
}, [questions])
|
||||
|
||||
// 倒计时
|
||||
useEffect(() => {
|
||||
if (!messageTime || seconds <= 0) return
|
||||
timerRef.current = setInterval(() => {
|
||||
setSeconds(s => {
|
||||
if (s <= 1) {
|
||||
clearInterval(timerRef.current!)
|
||||
handleSubmit()
|
||||
return 0
|
||||
}
|
||||
return s - 1
|
||||
})
|
||||
}, 1000)
|
||||
return () => { if (timerRef.current) clearInterval(timerRef.current) }
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [messageTime])
|
||||
|
||||
function handleSubmit() {
|
||||
if (submitted) return
|
||||
setSubmitted(true)
|
||||
if (timerRef.current) clearInterval(timerRef.current)
|
||||
|
||||
const content = {
|
||||
questions: questions?.map((q, idx) => ({
|
||||
...q,
|
||||
answer: answers[idx] ?? '',
|
||||
})),
|
||||
expected_user_action: 'slide_message_reply',
|
||||
}
|
||||
onSendMessage?.(JSON.stringify(content))
|
||||
}
|
||||
|
||||
if (!questions?.length) return null
|
||||
|
||||
const isDisabled = disabled || submitted || seconds === 0
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn('mt-2 w-full overflow-hidden rounded-xl border border-border')}
|
||||
style={{
|
||||
background:
|
||||
'radial-gradient(58% 12% at 97% 1%, var(--page-glow-2) 0%, rgba(0,0,0,0) 100%), radial-gradient(108% 26% at 10% -4%, var(--page-glow-1) 0%, rgba(0,0,0,0) 100%), var(--card)',
|
||||
}}
|
||||
>
|
||||
{/* 标题 */}
|
||||
<div className="flex items-center gap-1 px-3 py-2.5 font-medium text-primary">
|
||||
<span className="text-sm">✦ 补充信息</span>
|
||||
</div>
|
||||
|
||||
{/* 问题列表 */}
|
||||
<div className="px-3 pb-3 flex flex-col gap-3">
|
||||
{questions.map((q, idx) => (
|
||||
<div key={idx} className="flex flex-col gap-1.5">
|
||||
<label className="text-sm font-medium text-foreground">{q.question}</label>
|
||||
|
||||
{q.type === 'input' || !q.options?.length ? (
|
||||
<Input
|
||||
value={answers[idx] ?? ''}
|
||||
onChange={e =>
|
||||
setAnswers(prev => ({ ...prev, [idx]: e.target.value }))
|
||||
}
|
||||
disabled={isDisabled}
|
||||
className="h-8 text-sm"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{q.options.map(opt => {
|
||||
const selected =
|
||||
q.type === 'multiple'
|
||||
? (answers[idx] ?? '').split(',').includes(opt)
|
||||
: answers[idx] === opt
|
||||
return (
|
||||
<button
|
||||
key={opt}
|
||||
type="button"
|
||||
disabled={isDisabled}
|
||||
onClick={() => {
|
||||
if (q.type === 'multiple') {
|
||||
const current = (answers[idx] ?? '').split(',').filter(Boolean)
|
||||
const next = selected
|
||||
? current.filter(v => v !== opt)
|
||||
: [...current, opt]
|
||||
setAnswers(prev => ({ ...prev, [idx]: next.join(',') }))
|
||||
} else {
|
||||
setAnswers(prev => ({ ...prev, [idx]: opt }))
|
||||
}
|
||||
}}
|
||||
className={cn(
|
||||
'rounded-lg border px-3 py-1.5 text-sm transition-colors',
|
||||
selected
|
||||
? 'border-primary/40 bg-primary/10 text-primary'
|
||||
: 'border-border/60 bg-secondary/60 text-foreground hover:bg-accent',
|
||||
isDisabled && 'opacity-50 cursor-not-allowed',
|
||||
)}
|
||||
>
|
||||
{opt}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* 底部:倒计时 + 提交 */}
|
||||
<div className="flex items-center justify-between mt-1">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{submitted || seconds === 0 ? (
|
||||
'已自动运行'
|
||||
) : (
|
||||
<>
|
||||
<span className="text-primary">{seconds}s</span> 后自动运行
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleSubmit}
|
||||
disabled={isDisabled}
|
||||
className="text-sm h-7"
|
||||
>
|
||||
确认
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,83 @@
|
||||
import { memo } from 'react'
|
||||
import type { UserInteraction as UserInteractionData } from '../../../types'
|
||||
import { InteractionWrapper } from './InteractionWrapper'
|
||||
import { ChoiceInteraction } from './ChoiceInteraction'
|
||||
import { BrowserTakeoverInteraction } from './BrowserTakeoverInteraction'
|
||||
import { SlideFormInteraction } from './SlideFormInteraction'
|
||||
|
||||
export { InteractionWrapper } from './InteractionWrapper'
|
||||
export { InteractionButtons } from './InteractionButtons'
|
||||
export { ChoiceInteraction } from './ChoiceInteraction'
|
||||
export { BrowserTakeoverInteraction } from './BrowserTakeoverInteraction'
|
||||
export { SlideFormInteraction } from './SlideFormInteraction'
|
||||
|
||||
interface UserInteractionProps {
|
||||
userInteraction: UserInteractionData
|
||||
disabled?: boolean
|
||||
isLatest?: boolean
|
||||
onSendMessage?: (content: string) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* 用户交互主入口组件
|
||||
* 根据 userInteraction.type 分发到对应的交互子组件
|
||||
*/
|
||||
export const UserInteractionWidget = memo(
|
||||
({ userInteraction, disabled, isLatest, onSendMessage }: UserInteractionProps) => {
|
||||
const {
|
||||
type,
|
||||
content,
|
||||
choice_options,
|
||||
questions,
|
||||
browser_type,
|
||||
expected_user_action,
|
||||
take_over_type,
|
||||
messageTime,
|
||||
} = userInteraction as UserInteractionData & { messageTime?: string }
|
||||
|
||||
let inner: React.ReactNode = null
|
||||
|
||||
// 浏览器接管交互:与 Remix 一致,优先根据 expected_user_action 判断
|
||||
if (
|
||||
expected_user_action === 'take_over_web_browser' ||
|
||||
type === 'browser_takeover' ||
|
||||
type === 'browser_use_takeover'
|
||||
) {
|
||||
if (take_over_type !== 'reback') {
|
||||
inner = (
|
||||
<BrowserTakeoverInteraction
|
||||
disabled={disabled}
|
||||
browser_type={browser_type}
|
||||
onSendMessage={onSendMessage}
|
||||
/>
|
||||
)
|
||||
}
|
||||
} else if (choice_options?.length) {
|
||||
inner = (
|
||||
<ChoiceInteraction
|
||||
choice_options={choice_options}
|
||||
disabled={disabled}
|
||||
onSendMessage={onSendMessage}
|
||||
/>
|
||||
)
|
||||
} else if (questions?.length) {
|
||||
inner = (
|
||||
<SlideFormInteraction
|
||||
questions={questions}
|
||||
disabled={disabled}
|
||||
messageTime={messageTime}
|
||||
onSendMessage={onSendMessage}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
// 有文本内容或有交互子组件时才渲染
|
||||
if (!inner && !content?.trim()) return null
|
||||
|
||||
return (
|
||||
<InteractionWrapper content={content} isLatest={isLatest}>
|
||||
{inner}
|
||||
</InteractionWrapper>
|
||||
)
|
||||
},
|
||||
)
|
||||
258
components/nova-sdk/message-list/message-item/utils.ts
Normal file
258
components/nova-sdk/message-list/message-item/utils.ts
Normal file
@@ -0,0 +1,258 @@
|
||||
import type { ExtendedEvent, Attachment, ImageAttachment } from '../../types'
|
||||
|
||||
// ---- 静默 action 类型:有 action 也不渲染 ToolCallAction ----
|
||||
// 与 ToolCallAction.tsx 中的 SILENT_ACTION_TYPES 保持一致
|
||||
export const SILENT_ACTION_TYPES = new Set([
|
||||
'agent_advance_phase',
|
||||
'agent_end_task',
|
||||
'agent_update_plan',
|
||||
'finish_task',
|
||||
'substep_complete',
|
||||
'reback',
|
||||
'agent_think',
|
||||
'agent_schedule_task',
|
||||
'text_json',
|
||||
'message_notify_user',
|
||||
'browser_use_takeover',
|
||||
'mcp',
|
||||
'mcp_tool',
|
||||
])
|
||||
|
||||
// ---- MessageType ----
|
||||
|
||||
export interface MessageTypeState {
|
||||
isUserInput: boolean
|
||||
isTaskEnd: boolean
|
||||
isSummary: boolean
|
||||
showAction: boolean
|
||||
showContent: boolean
|
||||
showUserFile: boolean
|
||||
showSystemAttachments: boolean
|
||||
showMcpContent: boolean
|
||||
showOperation: boolean
|
||||
showPlanConfig: boolean
|
||||
showTaskTodoList: boolean
|
||||
showUserInteraction: boolean
|
||||
showTemplate: boolean
|
||||
/** 对应 Remix useMessageType.showTimestamp: !!base?.timestamp */
|
||||
showTimestamp: boolean
|
||||
containerAlignment: 'items-start' | 'items-end'
|
||||
isFactCheck: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据事件计算消息显示类型标志位,对应 next-agent useMessageType 逻辑
|
||||
*/
|
||||
export function getMessageType(event: ExtendedEvent): MessageTypeState {
|
||||
const props = event.renderProps || {}
|
||||
const {
|
||||
action,
|
||||
content,
|
||||
attachment,
|
||||
imageAttachment,
|
||||
mcpContent,
|
||||
planConfig,
|
||||
taskTodoConfig,
|
||||
operation,
|
||||
userInteraction,
|
||||
base,
|
||||
} = props
|
||||
|
||||
const isUserInput =
|
||||
base?.metadata?.isUserInput ?? event.event_type === 'user_input'
|
||||
const isTaskEnd = event.event_type === 'task_end'
|
||||
// isSummary 对应 Remix: !!metadata?.is_summary
|
||||
const isSummary = !!base?.metadata?.is_summary
|
||||
|
||||
const hasAttachment = !!(attachment?.length || imageAttachment?.length)
|
||||
const hasUserFile = isUserInput && hasAttachment
|
||||
const isFactCheck = !!content?.refer_content
|
||||
const showTemplate = !!(
|
||||
base?.metadata?.template_type && base?.metadata?.template_id
|
||||
)
|
||||
|
||||
// 完全对齐 Remix useMessageType
|
||||
const eventType = base?.event_type || event.event_type
|
||||
const actionType = action?.action_type || ''
|
||||
const isSummaryMessage =
|
||||
actionType === 'step_summary' || actionType === 'summary'
|
||||
const isStepCompleted = base?.plan_step_state === 'completed'
|
||||
|
||||
return {
|
||||
isUserInput,
|
||||
isTaskEnd,
|
||||
isSummary,
|
||||
// 完全对齐 Remix: showAction: !!action && !mcpContent && !planConfig
|
||||
showAction: !!action && !mcpContent && !planConfig,
|
||||
showContent: !!content && !planConfig,
|
||||
showUserFile: hasUserFile,
|
||||
// 完全对齐 Remix: (hasAttachment && isStepCompleted && isSummaryMessage) || (hasAttachment && eventType === 'text')
|
||||
showSystemAttachments:
|
||||
(hasAttachment && isStepCompleted && isSummaryMessage) ||
|
||||
(hasAttachment && eventType === 'text'),
|
||||
showMcpContent: !!mcpContent,
|
||||
showOperation: !!operation,
|
||||
showPlanConfig: !!planConfig,
|
||||
showTaskTodoList: !!(taskTodoConfig && taskTodoConfig.list?.length > 0),
|
||||
showUserInteraction: !!userInteraction,
|
||||
// 完全对齐 Remix: showTimestamp: !!base?.timestamp
|
||||
showTimestamp: !!base?.timestamp,
|
||||
showTemplate,
|
||||
containerAlignment:
|
||||
hasUserFile || isFactCheck || isUserInput ? 'items-end' : 'items-start',
|
||||
isFactCheck,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断是否是用户输入
|
||||
*/
|
||||
export function isUserInput(event: ExtendedEvent): boolean {
|
||||
return event.event_type === 'user_input' || !!event.metadata?.isUserInput
|
||||
}
|
||||
|
||||
/**
|
||||
* 提取文本内容
|
||||
*/
|
||||
export function extractText(obj: unknown): string {
|
||||
if (typeof obj === 'string') {
|
||||
return obj
|
||||
}
|
||||
if (obj && typeof obj === 'object') {
|
||||
const o = obj as Record<string, unknown>
|
||||
if (typeof o.text === 'string') {
|
||||
return o.text
|
||||
}
|
||||
if (o.content) {
|
||||
return extractText(o.content)
|
||||
}
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取消息内容
|
||||
*/
|
||||
export function getMessageContent(event: ExtendedEvent): string {
|
||||
const renderContent =
|
||||
event.renderProps?.content?.content || event.renderProps?.content?.text
|
||||
if (renderContent) {
|
||||
return extractText(renderContent)
|
||||
}
|
||||
if (event.content) {
|
||||
return extractText(event.content)
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
/** 图片文件扩展名 */
|
||||
export const IMAGE_EXTENSIONS = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg', 'bmp']
|
||||
|
||||
/**
|
||||
* 判断是否是图片文件
|
||||
*/
|
||||
export function isImageFile(path: string): boolean {
|
||||
const ext = path.split('.').pop()?.toLowerCase() || ''
|
||||
return IMAGE_EXTENSIONS.includes(ext)
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 content 中提取附件列表
|
||||
*/
|
||||
function extractAttachmentFiles(content: unknown): Array<{
|
||||
path: string
|
||||
file_name: string
|
||||
file_type: string
|
||||
desc?: string
|
||||
url?: string
|
||||
}> {
|
||||
if (!content || typeof content !== 'object') return []
|
||||
const c = content as Record<string, unknown>
|
||||
|
||||
if (Array.isArray(c.attachment_files)) {
|
||||
return c.attachment_files
|
||||
}
|
||||
if (c.content && typeof c.content === 'object') {
|
||||
return extractAttachmentFiles(c.content)
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取附件列表(非图片文件)
|
||||
*/
|
||||
export function getAttachments(event: ExtendedEvent): Attachment[] {
|
||||
if (event.renderProps?.attachment?.length) {
|
||||
return event.renderProps.attachment
|
||||
}
|
||||
const files = extractAttachmentFiles(event.content)
|
||||
return files
|
||||
.filter(f => !isImageFile(f.path || f.file_name))
|
||||
.map(f => ({
|
||||
file_id: f.path,
|
||||
file_name: f.file_name,
|
||||
file_type: f.file_type || f.file_name.split('.').pop() || '',
|
||||
file_url: f.url || f.path,
|
||||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取图片附件列表
|
||||
*/
|
||||
export function getImageAttachments(event: ExtendedEvent): ImageAttachment[] {
|
||||
if (event.renderProps?.imageAttachment?.length) {
|
||||
return event.renderProps.imageAttachment
|
||||
}
|
||||
const files = extractAttachmentFiles(event.content)
|
||||
return files
|
||||
.filter(f => isImageFile(f.path || f.file_name))
|
||||
.map(f => ({
|
||||
url: f.url || f.path,
|
||||
file_name: f.file_name,
|
||||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取工具调用的 action 信息
|
||||
*/
|
||||
export function getToolCallAction(event: ExtendedEvent): {
|
||||
name?: string
|
||||
arguments?: string[]
|
||||
action_type?: string
|
||||
} | null {
|
||||
if (event.event_type !== 'tool_call') return null
|
||||
|
||||
const content = event.content as Record<string, unknown> | undefined
|
||||
if (!content) return null
|
||||
|
||||
const actionName =
|
||||
(content.action_name as string) || (content.action_type as string) || ''
|
||||
const actionType =
|
||||
(content.tool_name as string) || (content.action_type as string) || ''
|
||||
|
||||
let args: string[] = []
|
||||
if (Array.isArray(content.arguments)) {
|
||||
args = content.arguments as string[]
|
||||
} else if (typeof content.arguments === 'string') {
|
||||
try {
|
||||
const parsed = JSON.parse(content.arguments)
|
||||
if (Array.isArray(parsed)) {
|
||||
args = parsed
|
||||
}
|
||||
} catch {
|
||||
args = [content.arguments]
|
||||
}
|
||||
}
|
||||
|
||||
if (!actionName && !actionType) return null
|
||||
|
||||
return { name: actionName, arguments: args, action_type: actionType }
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取计划步骤状态
|
||||
*/
|
||||
export function getPlanStepState(event: ExtendedEvent): string | null {
|
||||
const e = event as ExtendedEvent & { plan_step_state?: string }
|
||||
return e.plan_step_state || null
|
||||
}
|
||||
Reference in New Issue
Block a user