初始化模版工程

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,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>
)
}

View File

@@ -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

View File

@@ -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

View 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

View File

@@ -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>
)
})

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

View File

@@ -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)

View File

@@ -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

View 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

View 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

View 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>
)
}

View File

@@ -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

View 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

View File

@@ -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}
/>
)
},
)

View File

@@ -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}
/>
)
},
)

View File

@@ -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>
)
},
)

View File

@@ -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>
)
},
)

View File

@@ -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>
)
},
)

View File

@@ -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>
)
},
)

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