初始化模版工程

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,80 @@
import React, { useState, useEffect, useRef } from 'react'
import type {
NovaKitContextValue,
ExtendedEvent,
TaskStatus,
NovaAPI,
TaskArtifact,
} from '../types'
import { TaskStatus as Status } from '../types'
import { NovaKitContext } from './context'
export interface NovaKitProviderProps {
children: React.ReactNode
/** 统一的 API 命名空间 */
api: NovaAPI
agentId: string
agentName?: string
conversationId?: string
onMessage?: (event: ExtendedEvent) => void
setLoading: (loading: boolean) => void
loading: boolean
mode?: 'chat' | 'share'
panelMode?: 'sidebar' | 'dialog'
}
/**
* NovaKit Provider - 提供消息列表和状态管理
*/
export function NovaKitProvider({
mode = 'chat',
panelMode = 'sidebar',
children,
api,
agentId,
agentName,
conversationId: propConversationId,
setLoading,
loading,
}: NovaKitProviderProps) {
const [messageList, setMessageList] = useState<ExtendedEvent[][]>([])
const [taskStatus] = useState<TaskStatus>(Status.PENDING)
const [isLoading ] = useState(false)
const [artifacts, setArtifacts] = useState<TaskArtifact[]>([])
const prevConversationIdRef = useRef<string | undefined>(propConversationId)
// 监听 conversationId 变化,重置相关状态
useEffect(() => {
if (propConversationId !== prevConversationIdRef.current) {
prevConversationIdRef.current = propConversationId
setMessageList([])
setArtifacts([])
}
}, [propConversationId])
// 直接使用 prop不需要内部 state
const conversationId = propConversationId || null
const value: NovaKitContextValue = {
api,
agentId,
agentName,
panelMode,
messageList: messageList,
taskStatus,
conversationId,
isLoading,
artifacts,
loading,
setLoading,
mode,
}
return (
<NovaKitContext.Provider value={value}>
{children}
</NovaKitContext.Provider>
)
}

View File

@@ -0,0 +1,25 @@
import { createContext } from 'react'
import type { NovaKitContextValue, NovaAPI } from '../types'
import { TaskStatus } from '../types'
const defaultApiStub: NovaAPI = {
apiClient: null,
getArtifactUrl: async () => ({ data: '' }),
stopChat: async () => {},
getConversationInfoList: async () => ({ data: [] }),
}
export const NovaKitContext = createContext<NovaKitContextValue>({
agentId: '',
agentName: 'Autonomous Agent',
messageList: [],
taskStatus: TaskStatus.PENDING,
conversationId: null,
isLoading: false,
artifacts: [],
api: defaultApiStub,
setLoading: () => {},
loading: false,
})

View File

@@ -0,0 +1,15 @@
import { useContext } from 'react'
import { NovaKitContext } from './context'
import type { NovaKitContextValue } from '../types'
/**
* Hook 获取 NovaKit Context
*/
export function useNovaKit(): NovaKitContextValue {
const context = useContext(NovaKitContext)
if (!context) {
throw new Error('useNovaKit must be used within a NovaKitProvider')
}
return context
}

View File

@@ -0,0 +1,10 @@
// Nova 事件管理 Hook
export { useNovaEvents, ReadyState } from './useNovaEvents'
export { useBuildConversationConnect } from './useBuildConversationConnect'
export type { PlatformConfig } from './useNovaEvents'
// 元素尺寸监听 Hook
export { useSize } from './useSize'
// Re-export API types for convenience
export type { ApiEvent } from '..'

View File

@@ -0,0 +1,117 @@
import { useMemo } from 'react'
import type { ApiEvent } from '../types'
import type { TaskArtifact, TaskStatus } from '../types'
import { TaskStatus as Status } from '../types'
/**
* 从事件中提取 artifacts 和 taskStatus
*/
export function useArtifactsExtractor(rawEvents: ApiEvent[]) {
return useMemo(() => {
const extractedArtifacts: TaskArtifact[] = []
const artifactKeys = new Set<string>() // 用于去重
let status: TaskStatus = Status.PENDING
for (const event of rawEvents) {
const content = event.content as Record<string, unknown> | undefined
const isUserInput = event.event_type === 'user_input' || event.role === 'user'
if (!content) continue
// 1. 提取 attachments
if (content.attachments) {
const attachments = Array.isArray(content.attachments)
? content.attachments
: [content.attachments]
for (const att of attachments) {
if (att?.file_name) {
const key = att.file_url || att.file_id || att.path || att.file_name
if (!artifactKeys.has(key)) {
artifactKeys.add(key)
extractedArtifacts.push({
path: att.path || att.file_url || att.file_id || '',
file_name: att.file_name,
file_type: att.file_type || att.file_name.split('.').pop() || '',
url: att.file_url,
from: isUserInput ? 'user' : 'assistant'
})
}
}
}
}
// 2. 提取 attachment_files
if (content.attachment_files && Array.isArray(content.attachment_files)) {
for (const file of content.attachment_files) {
if (file?.file_name) {
const key = file.url || file.path || file.file_name
if (!artifactKeys.has(key)) {
artifactKeys.add(key)
extractedArtifacts.push({
path: file.path || file.url || '',
file_name: file.file_name,
file_type: file.file_type || file.file_name.split('.').pop() || '',
url: file.url,
from: isUserInput ? 'user' : 'assistant'
})
}
}
}
}
// 3. 提取 generated_files (如 slide_create_in_batches 的输出)
const toolOutput = content.tool_output as Record<string, unknown> | undefined
if (toolOutput?.generated_files && Array.isArray(toolOutput.generated_files)) {
for (const file of toolOutput.generated_files) {
if (file?.index !== undefined && file?.content) {
const fileName = `slide_${file.index}.html`
const key = `generated_${event.event_id}_${file.index}`
if (!artifactKeys.has(key)) {
artifactKeys.add(key)
extractedArtifacts.push({
path: key,
file_name: fileName,
file_type: 'html',
event_type: 'generated_file',
tool_output: file.content,
from: isUserInput ? 'user' : 'assistant'
})
}
}
}
}
// 4. 提取 files (其他可能的文件字段)
if (content.files && Array.isArray(content.files)) {
for (const file of content.files) {
if (file?.name || file?.file_name) {
const fileName = file.name || file.file_name
const key = file.url || file.path || file.id || fileName
if (!artifactKeys.has(key)) {
artifactKeys.add(key)
extractedArtifacts.push({
path: file.path || file.url || file.id || '',
file_name: fileName,
file_type: file.type || file.file_type || fileName.split('.').pop() || '',
url: file.url,
from: isUserInput ? 'user' : 'assistant'
})
}
}
}
}
// 提取 taskStatus
const eventStatus = event.event_status as string | undefined
if (eventStatus === 'running' || eventStatus === 'in_progress') {
status = Status.IN_PROGRESS
} else if (eventStatus === 'success' || eventStatus === 'completed') {
status = Status.COMPLETED
} else if (eventStatus === 'failed' || eventStatus === 'error') {
status = Status.FAILED
}
}
return { artifacts: extractedArtifacts, taskStatus: status }
}, [rawEvents])
}

View File

@@ -0,0 +1,113 @@
import { useCallback } from 'react'
import type { Attachment, HandleImageAttachmentClick, TaskArtifact } from '../types'
import type { ApiEvent } from './useNovaEvents'
import { extractToolOutputArtifact } from '../task-panel/Preview/previewUtils'
/**
* 附件处理逻辑 Hook
*/
export function useAttachmentHandlers(onSelectAttachment: (artifact: TaskArtifact) => void) {
// 处理附件点击
const handleAttachmentClick = useCallback(
(attachment: Attachment) => {
const artifact: TaskArtifact = {
path: attachment.path || attachment.file_url || attachment.file_id || '',
file_name: attachment.file_name,
file_type: attachment.file_type,
url: attachment.file_url,
}
onSelectAttachment(artifact)
},
[onSelectAttachment]
)
// 处理图片附件点击
const handleImageAttachmentClick = useCallback<HandleImageAttachmentClick>(
(image, from = 'assistant') => {
const getFileExtension = (str?: string): string => {
if (!str) return 'jpg'
const parts = str.split('.')
const ext = parts.length > 1 ? parts[parts.length - 1].toLowerCase().replace(/\?.*$/, '') : ''
return ext || 'jpg'
}
const fileType =
getFileExtension(image.file_name) || getFileExtension(image.path) || getFileExtension(image.url) || 'jpg'
const artifact: TaskArtifact = {
path: image.path || image.url || '',
file_name: image.file_name || '图片',
file_type: fileType,
url: image.url,
from,
}
onSelectAttachment(artifact)
},
[onSelectAttachment]
)
// 处理工具调用点击
const handleToolCallClick = useCallback(
(event: ApiEvent) => {
const content = event.content as Record<string, unknown> | undefined
const actionType = (content?.action_type as string | undefined) || undefined
const toolName = content?.tool_name as string | undefined
const actionName = content?.action_name as string | undefined
const metaToolName = (content?.metadata as Record<string, unknown> | undefined)?.tool_name as string | undefined
const toLower = (v?: string) => v?.toLowerCase()
const isSkillLoader =
toLower(actionType) === 'skill_loader' ||
toLower(actionName) === 'skill_loader' ||
toLower(toolName) === 'skill_loader' ||
toLower(metaToolName) === 'skill_loader'
const base: TaskArtifact = {
path: event.event_id,
file_name: toolName || '工具调用',
file_type: 'tool_call',
event_type: 'tool_call',
action_type: isSkillLoader
? 'skill_loader'
: actionType || actionName || toolName,
tool_name: toolName,
event_arguments: content?.arguments,
tool_input: content?.tool_input,
tool_output: content?.tool_output,
}
// Skill Loader点击时按 Markdown 文档渲染
if (isSkillLoader) {
const output = content?.tool_output
const mdContent =
typeof output === 'string'
? output
: output != null
? JSON.stringify(output, null, 2)
: ''
onSelectAttachment({
...base,
file_type: 'md',
content: mdContent,
})
return
}
const outputArtifact = extractToolOutputArtifact(base)
if (outputArtifact) {
onSelectAttachment(outputArtifact)
return
}
onSelectAttachment(base)
},
[onSelectAttachment]
)
return {
handleAttachmentClick,
handleImageAttachmentClick,
handleToolCallClick,
}
}

View File

@@ -0,0 +1,93 @@
'use client'
import { request } from '@/http/request'
import { useCallback, useEffect, useState } from 'react';
export enum NovaState {
Pending,
Connected,
Failed,
}
type UrlOverrides = {
conversationId?: string
agentId?: string
}
type ConversationIdentifiers = {
agentId?: string
conversationId?: string
}
const parseUrlOverrides = (search: string): UrlOverrides => {
const params = new URLSearchParams(search)
return {
conversationId: params.get('conversationId') || undefined,
agentId: params.get('agentId') || undefined,
}
}
const resolveConversationIdentifiers = (
res: { agent_id: string; conversation_id: string },
overrides: UrlOverrides,
): ConversationIdentifiers => {
const { conversationId: urlConversationId, agentId: urlAgentId } = overrides
return {
agentId: urlAgentId ?? res?.agent_id,
conversationId: urlConversationId ?? res?.conversation_id,
}
}
export const useBuildConversationConnect = () => {
const [chatEnabled, setChatEnabled] = useState<NovaState>(NovaState.Pending)
const [agentId, setAgentId] = useState<string>()
const [conversationId, setConversationId] = useState<string>('')
const [platformConfig, setPlatformConfig] = useState({
wssUrl: '',
apiBaseUrl: '',
token: '',
tenantId: '',
agentId: '',
agentName: '',
})
const connect = useCallback(async () => {
try {
const res = await request.get('/info')
const search =
typeof window === 'undefined' ? '' : window.location.search
const urlOverrides = parseUrlOverrides(search)
const { agentId: finalAgentId, conversationId: finalConversationId } =
resolveConversationIdentifiers(res, urlOverrides)
setAgentId(finalAgentId)
setConversationId(finalConversationId ?? '')
setPlatformConfig({
wssUrl: res?.wssUrl,
apiBaseUrl: res?.apiBaseUrl,
token: res?.token,
tenantId: res?.tenantId,
agentId: finalAgentId ?? '',
agentName: res?.agent_name ?? '',
})
setChatEnabled(NovaState.Connected)
} catch {
setChatEnabled(NovaState.Failed)
}
}, [])
useEffect(() => {
connect()
}, [connect])
return {
chatEnabled,
agentId,
conversationId,
platformConfig,
connect,
}
}

View File

@@ -0,0 +1,323 @@
import { useMemo, useCallback } from 'react'
import type { ApiEvent, ExtendedEvent } from '../types'
import { processSlideEvents, attachSlideMetadata } from '../utils/slideEventHelpers'
import { getEventRenderProps } from '../utils/event-render-props'
type TodoListItem = {
id?: string
title?: string
status?: string
[key: string]: unknown
}
function getEventTaskId(event: ApiEvent | ExtendedEvent): string | undefined {
const directKeys = ['task_id', 'plan_id', 'conversation_id'] as const
for (const key of directKeys) {
if (key in event) {
const value = event[key]
if (value !== undefined && value !== null) {
return String(value)
}
}
}
const metadataTaskId =
event.metadata &&
typeof event.metadata === 'object' &&
(('task_id' in event.metadata && event.metadata.task_id) ||
('plan_id' in event.metadata && event.metadata.plan_id) ||
('conversation_id' in event.metadata && event.metadata.conversation_id))
? ((event.metadata.task_id ??
event.metadata.plan_id ??
event.metadata.conversation_id) as string | number | undefined | null)
: undefined
return metadataTaskId !== undefined && metadataTaskId !== null
? String(metadataTaskId)
: undefined
}
function isTodoListEvent(event: ExtendedEvent): boolean {
return (
event.event_type === 'task_update' &&
!!event.renderProps?.taskTodoConfig?.list?.length
)
}
function getTodoItemKey(item: TodoListItem, index: number): string {
if (item.id !== undefined && item.id !== null) {
return `id:${String(item.id)}`
}
if (typeof item.title === 'string' && item.title.trim()) {
return `title:${item.title.trim()}`
}
return `index:${index}`
}
function mergeTodoListItems(group: ExtendedEvent[]): TodoListItem[] {
const itemsByKey = new Map<string, TodoListItem>()
const firstSeenKeys: string[] = []
for (const event of group) {
const items = (event.renderProps?.taskTodoConfig?.list || []) as TodoListItem[]
items.forEach((item, index) => {
const key = getTodoItemKey(item, index)
if (!itemsByKey.has(key)) {
firstSeenKeys.push(key)
}
itemsByKey.set(key, {
...(itemsByKey.get(key) || {}),
...item,
})
})
}
const latestItems = (group[group.length - 1]?.renderProps?.taskTodoConfig?.list ||
[]) as TodoListItem[]
const orderedKeys = Array.from(
new Set([
...latestItems.map((item, index) => getTodoItemKey(item, index)),
...firstSeenKeys,
]),
)
return orderedKeys
.map(key => itemsByKey.get(key))
.filter((item): item is TodoListItem => !!item)
}
function mergeTodoListGroup(group: ExtendedEvent[]): ExtendedEvent {
const lastEvent = group[group.length - 1]
return {
...lastEvent,
renderProps: lastEvent.renderProps
? {
...lastEvent.renderProps,
taskTodoConfig: {
list: mergeTodoListItems(group),
},
}
: lastEvent.renderProps,
}
}
/**
* 将 ApiEvent 转换为 ExtendedEvent并填充 renderProps
*/
export function useEventConverter() {
return useCallback((event: ApiEvent): ExtendedEvent => {
const isUserInput = event.event_type === 'user_input' || event.role === 'user'
const extEvent: ExtendedEvent = {
event_id: event.event_id,
event_type: event.event_type || 'message',
timestamp: event.created_at
? new Date(event.created_at).getTime()
: typeof event.timestamp === 'number'
? event.timestamp
: event.timestamp
? new Date(event.timestamp).getTime()
: Date.now(),
content: {
text: (event.content?.text || event.content?.content || '') as string,
...event.content,
},
metadata: {
...event.metadata,
isUserInput,
task_id: getEventTaskId(event),
role: event.role,
},
event_status: event.event_status,
stream: event.stream,
plan_step_state: event.plan_step_state,
// 填充 renderProps核心将原始事件结构化为渲染所需属性
renderProps: getEventRenderProps(event),
}
return extEvent
}, [])
}
/**
* 处理原始事件数据,去重、合并流式事件,并生成 renderProps
*/
export function useEventProcessor(
rawEvents: ApiEvent[],
) {
const convertEvent = useEventConverter()
return useMemo(() => {
if (!rawEvents || rawEvents.length === 0) {
return [[] as ExtendedEvent[]]
}
// 按 event_id 去重,后来的替换先来的(流式事件也遵循此规则)
const eventsMap = new Map<string, ApiEvent>()
for (const event of rawEvents) {
eventsMap.set(event.event_id, event)
}
// 处理 slide 流式数据
const slideData = processSlideEvents(rawEvents)
// 转换、过滤、附加 slide 元数据
const displayEvents = Array.from(eventsMap.values())
.filter(event => event.is_display !== false)
// 对应 Remix task-event.ts notEmpty过滤 text 类型但内容为空的流式中间状态
.filter(event => {
if (event.event_type !== 'text') return true
const content = event.content?.content
return !(!content || (typeof content === 'string' && !content.trim()))
})
.map(event => {
const extEvent = convertEvent(event)
const slideMetadata = attachSlideMetadata(event, slideData)
if (slideMetadata) {
extEvent.metadata = {
...extEvent.metadata,
slide: slideMetadata,
}
}
return extEvent
})
// 按时间戳升序排列
displayEvents.sort(
(a: ExtendedEvent, b: ExtendedEvent) =>
(a.timestamp || 0) - (b.timestamp || 0),
)
// ─────────────────────────────────────────────
// browser_use 事件合并:
// - 按 tool_call_id 维度将同一次浏览器操作的多条事件合并为一条
// - 保留「最后一个事件」作为展示主体
// - 如果最后一个没有截图,则从整组中向前查找最近一个带截图的 tool_output 补上
// 这样从一开始就只渲染一条 BrowserUseAction不会出现多条拆散的浏览器卡片
// ─────────────────────────────────────────────
const browserGroups = new Map<string, ExtendedEvent[]>()
const nonBrowserEvents: ExtendedEvent[] = []
for (const event of displayEvents) {
const actionType = event.renderProps?.action?.action_type
if (actionType === 'browser_use') {
const toolCallId =
(event.content as any)?.tool_call_id ||
(event.renderProps?.action?.tool_input as any)?.tool_call_id ||
event.event_id
const group = browserGroups.get(toolCallId)
if (group) {
group.push(event)
} else {
browserGroups.set(toolCallId, [event])
}
} else {
nonBrowserEvents.push(event)
}
}
const mergedBrowserEvents: ExtendedEvent[] = []
for (const group of browserGroups.values()) {
if (group.length === 0) continue
const lastEvent = group[group.length - 1]
const lastAction = lastEvent.renderProps?.action
if (lastAction) {
const lastToolOutput = lastAction.tool_output as
| { result?: { clean_screenshot_path?: string; screenshot_path?: string } }
| undefined
const hasScreenshot =
lastToolOutput?.result?.clean_screenshot_path ||
lastToolOutput?.result?.screenshot_path
if (!hasScreenshot) {
// 从该组中找最近一个有截图的 tool_output
for (let j = group.length - 2; j >= 0; j--) {
const candidateOutput = group[j].renderProps?.action?.tool_output as
| { result?: { clean_screenshot_path?: string; screenshot_path?: string } }
| undefined
if (
candidateOutput?.result?.clean_screenshot_path ||
candidateOutput?.result?.screenshot_path
) {
lastEvent.renderProps!.action = {
...lastEvent.renderProps!.action!,
tool_output: candidateOutput,
}
break
}
}
}
}
mergedBrowserEvents.push(lastEvent)
}
const mergedEvents = [...nonBrowserEvents, ...mergedBrowserEvents]
// 合并后再次按时间排序,保证整体对话时间线正确
mergedEvents.sort(
(a: ExtendedEvent, b: ExtendedEvent) =>
(a.timestamp || 0) - (b.timestamp || 0),
)
// ─────────────────────────────────────────────
// TodoList(task_update) 事件合并:
// - 有 task_id 时,按 task_id 聚合同一条任务线的多次 task_update
// - 没有 task_id 时,回退到当前用户轮次,避免无 task_id 的卡片被全局误并
// - 保留最后一个事件作为展示主体
// - Todo 列表按 id/title 增量合并,兼容后续事件只返回局部步骤的情况
// 这样消息列表中只展示一张最新的 Todo 卡片,不会被中间更新刷出多条
// ─────────────────────────────────────────────
const todoGroups = new Map<string, ExtendedEvent[]>()
const nonTodoEvents: ExtendedEvent[] = []
let currentTurnKey = 'conversation-start'
for (const event of mergedEvents) {
if (event.metadata?.isUserInput) {
currentTurnKey = event.event_id
}
if (isTodoListEvent(event)) {
const taskKey = getEventTaskId(event)
const groupKey = taskKey ? `task:${taskKey}` : `${currentTurnKey}:no-task`
const group = todoGroups.get(groupKey)
if (group) {
group.push(event)
} else {
todoGroups.set(groupKey, [event])
}
} else {
nonTodoEvents.push(event)
}
}
const mergedTodoEvents = Array.from(todoGroups.entries()).map(
([, group]) => mergeTodoListGroup(group),
)
const timelineEvents = [...nonTodoEvents, ...mergedTodoEvents]
timelineEvents.sort(
(a: ExtendedEvent, b: ExtendedEvent) =>
(a.timestamp || 0) - (b.timestamp || 0),
)
return [timelineEvents]
}, [rawEvents, convertEvent])
}

View File

@@ -0,0 +1,166 @@
import { useCallback, useRef, useMemo } from 'react'
import type { UploadFile } from '../types'
import { request } from '@/http/request'
import { createCustomOSSUploader } from '@bty/uploader'
import { getSTSToken, getOssSignatureUrl } from '@apis/oss'
// Simple helper to mimic MIME type detection
export const ACCEPT_FILE_TYPE_LIST = [
'.pdf', '.doc', '.docx', '.xls', '.xlsx', '.ppt', '.pptx',
'.txt', '.json', '.csv', '.md',
'.png', '.jpg', '.jpeg', '.gif', '.bmp', '.webp', '.svg', '.ico',
'.html', '.py', '.jsonld', '.xml', '.zip',
'.mp3', '.mp4', '.mov', '.m4a',
'.pdb', '.mermaid',
]
export function getMimeByAcceptList(filename: string): string | undefined {
const ext = `.${(filename.split('.').pop() || '').toLowerCase()}`
const map: Record<string, string> = {
'.pdf': 'application/pdf',
'.doc': 'application/msword',
'.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'.xls': 'application/vnd.ms-excel',
'.xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'.ppt': 'application/vnd.ms-powerpoint',
'.pptx': 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
'.txt': 'text/plain',
'.json': 'application/json',
'.csv': 'text/csv',
'.md': 'text/markdown',
'.png': 'image/png',
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.gif': 'image/gif',
'.bmp': 'image/bmp',
'.webp': 'image/webp',
'.svg': 'image/svg+xml',
'.ico': 'image/x-icon',
'.html': 'text/html',
'.jsonld': 'application/ld+json',
'.pdb': 'application/vnd.microsoft.portable-executable',
'.mermaid': 'text/mermaid',
}
return map[ext] || undefined
}
export interface UseFileUploaderProps {
onUploadStart?: (file: UploadFile) => void
onUploadEnd?: (file: UploadFile) => void
onUploadError?: (error: Error, file?: UploadFile) => void
onFileUpdate?: (file: UploadFile) => void
}
export function useFileUploader({
onUploadStart,
onUploadEnd,
onUploadError,
onFileUpdate,
}: UseFileUploaderProps = {}) {
const uploadUUId = useRef<string>('')
// Initialize OSS Uploader with STS token provider from our local package
const ossUploader = useMemo(() => {
return createCustomOSSUploader(getSTSToken)
}, [])
const uploadFile = useCallback(async (file: File) => {
// 1. Validation
const isValidSize = file.size <= 100 * 1024 * 1024 // 100MB
if (!isValidSize) {
console.warn('File size exceeds 100MB limit')
return
}
// 2. Init file object and State
const uid = crypto.randomUUID()
const mimeType = getMimeByAcceptList(file.name) || file.type || 'application/octet-stream'
const tempFile: UploadFile = {
uid,
name: file.name,
type: mimeType,
byte_size: file.size,
uploadStatus: 'pending',
progress: 0,
url: URL.createObjectURL(file),
}
onUploadStart?.(tempFile)
try {
// 3. Construct File Path (Business Logic)
const timestamp = new Date().valueOf()
const uuid = crypto.randomUUID()
uploadUUId.current = uuid
const filePath = `super_agent/user_upload_file/${uuid}/_${timestamp}_${file.name}`
// 4. Upload to OSS using the shared package
await ossUploader.multipartUpload({
filePath,
file,
options: {
headers: {
'Content-Type': mimeType,
'Content-Disposition': 'inline',
},
progress: (progress: number) => {
// OSS SDK returns progress as 0-1
onFileUpdate?.({
...tempFile,
progress: Math.floor(progress * 100),
uploadStatus: 'uploading'
})
}
},
})
// 5. Get Signature URL (Optional / if private)
const signatureUrl = await getOssSignatureUrl(filePath)
// 6. Create File Upload Record (Backend Sync)
const lastDotIndex = file.name.lastIndexOf('.')
const splitName = lastDotIndex !== -1
? [file.name.substring(0, lastDotIndex), file.name.substring(lastDotIndex + 1)]
: [file.name]
const safeName = `${splitName[0]}-${Math.random().toString(36).substring(2, 5)}${splitName.length > 1 ? `.${splitName[1]}` : ''}`
const res = await request.post<{ file_upload_record_id: string }>('/file/record', {
file_url: filePath,
file_type: file.type || 'application/octet-stream',
file_name: safeName,
file_byte_size: file.size || 0,
conversation_id: uuid,
})
// 7. Finalize
const finalFile: UploadFile = {
...tempFile,
name: safeName,
url: signatureUrl,
upload_file_id: res.file_upload_record_id,
progress: 100,
uploadStatus: 'success',
}
onFileUpdate?.(finalFile)
onUploadEnd?.(finalFile)
} catch (error) {
console.error('Upload failed:', error)
const errorFile: UploadFile = {
...tempFile,
uploadStatus: 'error'
}
onFileUpdate?.(errorFile)
onUploadError?.(error as Error, errorFile)
}
}, [ossUploader, onUploadStart, onUploadEnd, onUploadError, onFileUpdate])
return {
uploadFile,
accept: ACCEPT_FILE_TYPE_LIST.join(','),
}
}

View File

@@ -0,0 +1,25 @@
import { useRef, useCallback } from 'react'
import type { MessageListRef } from '../message-list'
/**
* 消息滚动管理 Hook
*/
export function useMessageScroll() {
const messageListRef = useRef<MessageListRef>(null)
const scrollToBottom = useCallback((behavior: ScrollBehavior = 'smooth') => {
messageListRef.current?.scrollToBottom(behavior)
}, [])
const scrollToBottomDelayed = useCallback((delay = 100, behavior: ScrollBehavior = 'smooth') => {
setTimeout(() => {
messageListRef.current?.scrollToBottom(behavior)
}, delay)
}, [])
return {
messageListRef,
scrollToBottom,
scrollToBottomDelayed,
}
}

View File

@@ -0,0 +1,51 @@
import { useState, useCallback } from 'react'
import type { SendMessagePayload } from '../types'
import type { PlatformConfig } from './useNovaEvents'
interface UseMessageSenderProps {
conversationId?: string
platformConfig?: PlatformConfig
sendMessage?: (message: string) => void
agentId?: string
}
export function useMessageSender({
conversationId,
platformConfig,
sendMessage,
agentId,
}: UseMessageSenderProps) {
const [sendingMessage, setSendingMessage] = useState(false)
// Send Message Logic
const handleSend = useCallback(
(payload: SendMessagePayload) => {
// If platformConfig is provided, send via WebSocket
if (platformConfig && sendMessage) {
try {
setSendingMessage(true)
const message = {
message_type: 'chat',
conversation_id: conversationId,
agent_id: agentId,
agent_model: 'Nova Pro',
content: payload.content,
refer_content: payload.refer_content || '',
upload_file_ids: payload.upload_file_ids,
}
sendMessage(JSON.stringify(message))
} catch (error) {
console.error('Failed to send message via WebSocket:', error)
setSendingMessage(false)
}
}
},
[platformConfig, sendMessage, conversationId, agentId]
)
return {
sendingMessage,
setSendingMessage,
handleSend
}
}

View File

@@ -0,0 +1,232 @@
import { useEffect, useMemo, useState } from 'react'
import { useRequest } from 'ahooks'
import { useNovaEvents } from './useNovaEvents'
import { useEventStore } from '../store/useEventStore'
import { useArtifactsExtractor } from './useArtifactsExtractor'
import { useEventProcessor } from './useEventProcessor'
import { usePanelState } from './usePanelState'
import { useAttachmentHandlers } from './useAttachmentHandlers'
import { useMessageSender } from './useMessageSender'
import { useNovaService } from './useNovaService'
import type { ConversationInfo } from './useNovaService'
import { TERMINAL_TASK_STATUS, TaskStatus } from '../types'
import type { PlatformConfig, ApiEvent } from './useNovaEvents'
export interface UseNovaChatLogicProps {
mode: 'chat' | 'share'
conversationId?: string
platformConfig?: PlatformConfig
reconnectLimit?: number
reconnectInterval?: number
agentId?: string
getToken?: () => string | undefined
getTenantId?: () => string | undefined
onEvent?: (event: ApiEvent) => void
onConnectionChange?: (connected: boolean) => void
onError?: (error: Error) => void
}
/**
* Nova Chat 主业务逻辑 Hook
* 采用原子化模块组合模式,提高代码鲁棒性和可维护性
*/
export function useNovaChatLogic({
mode,
conversationId,
platformConfig,
reconnectLimit = 3,
reconnectInterval = 3000,
getToken,
getTenantId,
onEvent,
onConnectionChange,
onError,
agentId,
}: UseNovaChatLogicProps) {
// 1. 核心事件与连接管理
const novaEvents = useNovaEvents({
mode,
conversationId,
platformConfig: platformConfig || { wssUrl: '', apiBaseUrl: '', agentId: '', agentName: '' },
reconnectLimit,
reconnectInterval,
getToken,
getTenantId,
onEvent,
onConnectionChange,
onError,
})
const { rawEvents } = novaEvents
// 2. 数据处理与提取
const { artifacts, taskStatus: eventTaskStatus } = useArtifactsExtractor(rawEvents)
const [polledTaskStatus, setPolledTaskStatus] = useState<string | undefined>()
const [initialTaskStatusLoading, setInitialTaskStatusLoading] = useState(false)
const taskStatus = useMemo(
() => (polledTaskStatus as typeof eventTaskStatus | undefined) || eventTaskStatus,
[polledTaskStatus, eventTaskStatus],
)
const processedMessages = useEventProcessor(rawEvents)
// 3. 面板与附件状态管理
const {
panelVisible,
selectedAttachment,
togglePanel,
closePanel,
selectAttachment
} = usePanelState()
// 4. 附件操作处理器
const {
handleAttachmentClick,
handleImageAttachmentClick,
handleToolCallClick
} = useAttachmentHandlers(selectAttachment)
// 5. 消息发送逻辑
const { sendingMessage, handleSend, setSendingMessage } = useMessageSender({
conversationId,
platformConfig,
sendMessage: novaEvents.sendMessage,
agentId,
})
// 6. 工件服务URL 获取等)
const service = useNovaService({
platformConfig,
getToken,
getTenantId,
conversationId
})
const { getConversationInfoList } = service
// 7. Store 同步(副作用管理)
const setEvents = useEventStore((state) => state.setEvents)
const setArtifacts = useEventStore((state) => state.setArtifacts)
// 8. WS 阶段轮询会话信息ahooks useRequest遇到终止状态时关闭 loading
const terminalSet = new Set(TERMINAL_TASK_STATUS.map((s) => String(s).toLowerCase()))
const getConversationTaskStatus = (
list: ConversationInfo[] | undefined,
currentConversationId?: string,
) => {
if (!currentConversationId) return undefined
return list?.find(
item => item.conversation_id === currentConversationId,
)?.task_status
}
const isTerminalTaskStatus = (status?: string) =>
!!status && terminalSet.has(String(status).toLowerCase())
const shouldPollTaskStatus =
!!conversationId &&
(sendingMessage || taskStatus === TaskStatus.IN_PROGRESS)
const stopChat = async () => {
await service.stopChat()
setSendingMessage(false)
setPolledTaskStatus(TaskStatus.STOPPED)
cancelPoll()
}
const { run: runPoll, cancel: cancelPoll } = useRequest(
(cid: string) =>
getConversationInfoList([cid]).then((res) => res.data ?? []),
{
manual: true,
pollingInterval: shouldPollTaskStatus ? 5000 : undefined,
ready: shouldPollTaskStatus,
onSuccess: (list) => {
const nextTaskStatus = getConversationTaskStatus(list, conversationId)
if (nextTaskStatus) {
setPolledTaskStatus(nextTaskStatus)
}
if (isTerminalTaskStatus(nextTaskStatus)) {
setSendingMessage(false)
cancelPoll()
}
},
}
)
useEffect(() => {
if (shouldPollTaskStatus && conversationId) {
runPoll(conversationId)
} else {
cancelPoll()
}
}, [shouldPollTaskStatus, conversationId, runPoll, cancelPoll])
useEffect(() => {
let cancelled = false
setPolledTaskStatus(undefined)
if (!conversationId) {
setInitialTaskStatusLoading(false)
return
}
setInitialTaskStatusLoading(true)
void getConversationInfoList([conversationId])
.then((res) => {
if (cancelled) return
const nextTaskStatus = getConversationTaskStatus(res.data ?? [], conversationId)
if (nextTaskStatus) {
setPolledTaskStatus(nextTaskStatus)
}
})
.catch(() => {})
.finally(() => {
if (!cancelled) {
setInitialTaskStatusLoading(false)
}
})
return () => {
cancelled = true
}
}, [conversationId, getConversationInfoList])
useEffect(() => {
setEvents(rawEvents)
}, [rawEvents, setEvents])
useEffect(() => {
setArtifacts(artifacts)
}, [artifacts, setArtifacts])
// 9. 统一对外接口
return {
// 状态数据
messages: processedMessages,
loading: sendingMessage || initialTaskStatusLoading,
taskStatus,
artifacts,
hasArtifacts: artifacts.length > 0,
panelVisible,
selectedAttachment,
// 操作方法
handleSend,
handlePanelToggle: togglePanel,
handlePanelClose: closePanel,
handleAttachmentClick,
handleImageAttachmentClick,
handleToolCallClick,
setLoading: setSendingMessage,
agentId,
// 统一的 API 命名空间
api: {
...service,
stopChat,
}
}
}

View File

@@ -0,0 +1,370 @@
import { useState, useEffect, useRef, useCallback } from 'react'
import { createWebSocketClient, ReadyState, type Result as WebSocketResult } from '@/app/api/websocket'
// import { createRequestClient, type RequestClient } from '@/api/request'
import { request } from '@/http/request'
import type { ApiEvent } from '../types'
export type { ApiEvent }
export interface PlatformConfig {
wssUrl: string
apiBaseUrl: string
agentId: string
agentName: string
}
interface UseNovaEventsOptions {
mode: 'chat' | 'share'
conversationId?: string
platformConfig: PlatformConfig
/** WebSocket 重连次数限制,默认 3 */
reconnectLimit?: number
/** WebSocket 重连间隔(毫秒),默认 3000 */
reconnectInterval?: number
/** 获取认证 Token */
getToken?: () => string | undefined
/** 获取租户 ID */
getTenantId?: () => string | undefined
/** 新事件回调 */
onEvent?: (event: ApiEvent) => void
/** 连接状态变化回调 */
onConnectionChange?: (connected: boolean) => void
/** 错误回调 */
onError?: (error: Error) => void
}
interface UseNovaEventsResult {
/** 原始事件列表 */
rawEvents: ApiEvent[]
/** 是否正在加载历史记录 */
loading: boolean
/** WebSocket 是否已连接 */
connected: boolean
/** WebSocket 连接状态 */
readyState: ReadyState
/** 错误信息 */
error: Error | null
/** 手动刷新历史记录 */
refresh: () => Promise<void>
/** 手动重连 WebSocket */
reconnect: () => void
/** 清空事件列表 */
clear: () => void
/** 切换会话 */
switchConversation: (conversationId: string) => void
/** 发送消息 */
sendMessage: WebSocket['send']
}
function isApiEventLike(value: unknown): value is ApiEvent {
return !!value && typeof value === 'object' && 'event_id' in value
}
function normalizeIncomingEvent(payload: unknown): ApiEvent | null {
if (isApiEventLike(payload)) {
return payload
}
if (
payload &&
typeof payload === 'object' &&
'data' in payload &&
isApiEventLike((payload as { data?: unknown }).data)
) {
return (payload as { data: ApiEvent }).data
}
return null
}
/**
* Nova 事件管理 Hook
*
* 负责:
* 1. 建立 WebSocket 连接接收实时事件
* 2. 请求 event_list 接口获取历史记录
* 3. 合并和管理事件列表
*/
export function useNovaEvents({
mode,
conversationId,
platformConfig,
reconnectLimit = 3,
reconnectInterval = 3000,
getToken,
getTenantId,
onEvent,
onConnectionChange,
onError,
}: UseNovaEventsOptions): UseNovaEventsResult {
// 状态
const [rawEvents, setRawEvents] = useState<ApiEvent[]>([])
const [loading, setLoading] = useState(false)
const [connected, setConnected] = useState(false)
const [readyState, setReadyState] = useState<ReadyState>(ReadyState.Closed)
const [error, setError] = useState<Error | null>(null)
// Refs
const wsClientRef = useRef<WebSocketResult | null>(null)
const isUnmountedRef = useRef(false)
const conversationIdRef = useRef(conversationId)
const mountCountRef = useRef(0)
// 保持 conversationId ref 同步
conversationIdRef.current = conversationId
// 回调 refs避免重新创建 WebSocket
const onEventRef = useRef(onEvent)
const onConnectionChangeRef = useRef(onConnectionChange)
const onErrorRef = useRef(onError)
onEventRef.current = onEvent
onConnectionChangeRef.current = onConnectionChange
onErrorRef.current = onError
// 更新连接状态
const updateConnected = useCallback((value: boolean) => {
setConnected(value)
onConnectionChangeRef.current?.(value)
}, [])
// 更新错误状态
const updateError = useCallback((err: Error | null) => {
setError(err)
if (err) {
onErrorRef.current?.(err)
}
}, [])
// 合并事件到列表(按 event_id 去重,新事件替换旧事件)
const mergeEvent = useCallback((newEvent: ApiEvent) => {
setRawEvents(prev => {
const index = prev.findIndex(e => e.event_id === newEvent.event_id)
if (index === -1) {
// 新事件,追加到末尾
return [...prev, newEvent]
}
// 已存在,替换
const updated = [...prev]
updated[index] = newEvent
return updated
})
}, [])
// 批量合并事件(用于历史记录)
const mergeEvents = useCallback((newEvents: ApiEvent[]) => {
setRawEvents((prev) => {
const eventsMap = new Map<string, ApiEvent>()
// 先放入现有事件
for (const event of prev) {
eventsMap.set(event.event_id, event)
}
// 合并新事件(会覆盖同 id 的旧事件)
for (const event of newEvents) {
eventsMap.set(event.event_id, event)
}
return Array.from(eventsMap.values())
})
}, [])
// 获取历史记录
const fetchEventList = useCallback(async () => {
if (!conversationIdRef.current) return
setLoading(true)
updateError(null)
try {
const payload = {
conversation_id: conversationIdRef.current,
page_no: 1,
page_size: 3000,
}
const response = mode === 'share'
? await request.post<{
data: {
chat_event_list: ApiEvent[]
}
}>('/v1/super_agent/chat/event_list_share', payload)
: await request.get<{
data: {
chat_event_list: ApiEvent[]
}
}>('/chat/event', payload)
// 提取 data.data.chat_event_list 字段
const events = response?.data?.chat_event_list || []
if (!isUnmountedRef.current) {
// 使用 mergeEvents 而不是直接 setRawEvents以保留实时推送的新事件
mergeEvents(events)
}
} catch (err) {
if (!isUnmountedRef.current) {
const error = err instanceof Error ? err : new Error('Failed to fetch event list')
updateError(error)
}
} finally {
if (!isUnmountedRef.current) {
setLoading(false)
}
}
}, [mergeEvents, updateError, mode])
// 创建 WebSocket 连接
const createWebSocket = useCallback(() => {
if (!conversationIdRef.current) return
// 已有连接,不重复创建
if (wsClientRef.current) return
// 构建 WebSocket URL
const wsUrl = new URL(platformConfig.wssUrl)
wsUrl.searchParams.set('conversation_id', conversationIdRef.current)
const wsClient = createWebSocketClient(wsUrl.toString(), {
reconnectLimit,
reconnectInterval,
manual: true, // 手动连接,避免 React Strict Mode 下的问题
getToken,
getTenantId,
onOpen: () => {
if (isUnmountedRef.current) return
setReadyState(ReadyState.Open)
updateConnected(true)
updateError(null)
// 连接成功后切换到当前会话
if (conversationIdRef.current && wsClientRef.current) {
wsClientRef.current.switchConversation(conversationIdRef.current)
}
},
onClose: () => {
if (isUnmountedRef.current) return
setReadyState(ReadyState.Closed)
updateConnected(false)
},
onError: () => {
if (isUnmountedRef.current) return
updateError(new Error('WebSocket connection error'))
},
onMessage: (message: ApiEvent) => {
if (isUnmountedRef.current) return
const normalizedEvent = normalizeIncomingEvent(message)
if (!normalizedEvent) return
mergeEvent(normalizedEvent)
onEventRef.current?.(normalizedEvent)
},
})
wsClientRef.current = wsClient
// 手动触发连接
wsClient.connect()
}, [
platformConfig.wssUrl,
reconnectLimit,
reconnectInterval,
getToken,
getTenantId,
mergeEvent,
updateConnected,
updateError,
])
// 手动重连
const reconnect = useCallback(() => {
if (wsClientRef.current) {
wsClientRef.current.connect()
} else {
createWebSocket()
}
}, [createWebSocket])
// 清空事件列表
const clear = useCallback(() => {
setRawEvents([])
}, [])
// 切换会话
const switchConversation = useCallback((newConversationId: string) => {
if (wsClientRef.current) {
wsClientRef.current.switchConversation(newConversationId)
}
}, [])
// 发送消息
const sendMessage = useCallback<WebSocket['send']>((data) => {
if (wsClientRef.current) {
wsClientRef.current.sendMessage(data)
} else {
throw new Error('WebSocket not connected')
}
}, [])
// 主 effect当 conversationId 或 platformConfig 变化时处理
useEffect(() => {
isUnmountedRef.current = false
console.log('conversationId', conversationId)
// 无会话 ID 时清空
if (!conversationId) {
setRawEvents([])
setConnected(false)
setReadyState(ReadyState.Closed)
return
}
// conversationId 存在,获取历史记录
fetchEventList()
// 如果已有连接且已打开,直接切换会话;否则创建新连接
if (wsClientRef.current && wsClientRef.current.readyState === ReadyState.Open) {
wsClientRef.current.switchConversation(conversationId)
} else {
createWebSocket()
}
// 清理函数
return () => {
isUnmountedRef.current = true
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [conversationId, platformConfig.wssUrl, platformConfig.apiBaseUrl])
// 组件卸载时清理
useEffect(() => {
mountCountRef.current += 1
const currentMount = mountCountRef.current
return () => {
// 延迟执行,如果 mountCount 变了说明重新 mount 了Strict Mode不清理
setTimeout(() => {
if (currentMount === mountCountRef.current && wsClientRef.current) {
wsClientRef.current.cleanup()
wsClientRef.current = null
}
}, 0)
}
}, [])
return {
rawEvents,
loading,
connected,
readyState,
error,
refresh: fetchEventList,
reconnect,
clear,
switchConversation,
sendMessage,
}
}
export { ReadyState }
export default useNovaEvents

View File

@@ -0,0 +1,159 @@
import { useMemo, useCallback } from 'react'
import { request } from '@/http/request'
import type { TaskArtifact } from '../types'
import type { PlatformConfig } from './useNovaEvents'
export interface ConversationInfo {
conversation_id: string
title?: string
task_status?: string
is_read?: boolean
is_favourite?: boolean
}
interface UseNovaServiceProps {
platformConfig?: PlatformConfig
getToken?: () => string | undefined
getTenantId?: () => string | undefined
conversationId?: string
}
interface DirFileItem {
desc?: string
file_name?: string
file_type?: string
last_modified?: number
path?: string
}
const toAbsoluteHttpUrl = (value: unknown): string | null => {
if (typeof value !== 'string' || !value) return null
if (value.startsWith('http://') || value.startsWith('https://')) return value
return null
}
const extractDirFileList = (payload: unknown): DirFileItem[] => {
if (Array.isArray(payload)) return payload
if (!payload || typeof payload !== 'object') return []
const obj = payload as Record<string, unknown>
if (Array.isArray(obj.data)) return obj.data as DirFileItem[]
return []
}
const normalizeFileName = (name: string): string => {
const raw = name.trim()
if (!raw) return ''
const withoutPrefix = raw.startsWith('/upload/') ? raw.slice('/upload/'.length) : raw
return withoutPrefix
}
const resolvePathByName = (files: DirFileItem[], fileName?: string): string | null => {
if (!fileName) return null
const normalizedName = normalizeFileName(fileName)
if (!normalizedName) return null
const expectedSegment = `upload/${normalizedName}`
const matched = files.find(item => {
if (typeof item.file_name !== 'string' || !item.file_name) return false
return item.file_name.includes(expectedSegment)
})
return matched?.path || null
}
export function useNovaService({
platformConfig,
conversationId,
}: UseNovaServiceProps) {
// API Client Singleton
const apiClient = useMemo(() => {
if (!platformConfig?.apiBaseUrl) {
return null
}
return request
}, [platformConfig])
// Get Artifact URL Method
const getArtifactUrl = useCallback(
async (
artifact: TaskArtifact,
params?: Record<string, string>
): Promise<{ data: string }> => {
try {
if (!apiClient) {
throw new Error('API client is not initialized')
}
const taskId = artifact.task_id || conversationId
let resolvedPath = artifact.path
if (taskId) {
const dirFilesResponse = await apiClient.post<unknown>(
'/v1/super_agent/chat/get_dir_file',
{ task_id: taskId }
)
const dirFiles = extractDirFileList(dirFilesResponse)
const matchedPath = resolvePathByName(dirFiles, artifact.file_name)
if (matchedPath) {
resolvedPath = matchedPath
}
}
// Call OSS URL interface
const response = await apiClient.post<string>('/chat/oss_url', {
file_path: resolvedPath,
task_id: taskId,
params
})
const signedUrl = toAbsoluteHttpUrl(response) || ''
const fallback = toAbsoluteHttpUrl(artifact.path) || ''
return { data: signedUrl || fallback }
} catch (error) {
console.error('Failed to fetch artifact URL:', error)
return { data: toAbsoluteHttpUrl(artifact.path) || '' }
}
},
[conversationId, apiClient]
)
const stopChat = useCallback(async () => {
try {
if (!apiClient) {
throw new Error('API client is not initialized')
}
if (!conversationId) {
throw new Error('Conversation ID is required')
}
await apiClient.get('/chat/stop', { conversation_id: conversationId })
} catch (error) {
console.error('Failed to stop chat:', error)
throw error
}
}, [conversationId, apiClient])
/** 获取会话信息列表(用于轮询 task_status使用 novaRequest */
const getConversationInfoList = useCallback(
async (conversationIds: string[]) => {
if (conversationIds.length === 0) {
return { data: [] as ConversationInfo[] }
}
const res = await request.post<{ data?: ConversationInfo[] } | ConversationInfo[]>(
'/conversation/info',
{ conversation_ids: conversationIds }
)
const list = Array.isArray(res) ? res : (res?.data ?? [])
return { data: list }
},
[]
)
return {
apiClient,
getArtifactUrl,
stopChat,
getConversationInfoList,
}
}

View File

@@ -0,0 +1,40 @@
import { useState, useCallback } from 'react'
import type { TaskArtifact } from '../types'
/**
* 面板状态管理 Hook
*/
export function usePanelState() {
const [panelVisible, setPanelVisible] = useState(false)
const [selectedAttachment, setSelectedAttachment] = useState<TaskArtifact | null>(null)
const togglePanel = useCallback(() => {
setPanelVisible((prev) => !prev)
}, [])
const openPanel = useCallback((attachment?: TaskArtifact) => {
setPanelVisible(true)
if (attachment) {
setSelectedAttachment(attachment)
}
}, [])
const closePanel = useCallback(() => {
setPanelVisible(false)
setSelectedAttachment(null)
}, [])
const selectAttachment = useCallback((attachment: TaskArtifact) => {
setSelectedAttachment(attachment)
setPanelVisible(true)
}, [])
return {
panelVisible,
selectedAttachment,
togglePanel,
openPanel,
closePanel,
selectAttachment,
}
}

View File

@@ -0,0 +1,32 @@
import { useState, useLayoutEffect } from 'react'
/**
* 监听元素尺寸变化的 Hook
*/
export function useSize(target: React.RefObject<HTMLElement | null>) {
const [size, setSize] = useState<{ width: number; height: number } | null>(null)
useLayoutEffect(() => {
const element = target.current
if (!element) {
console.log('useSize: element is null')
return
}
const resizeObserver = new ResizeObserver((entries) => {
for (const entry of entries) {
const { width, height } = entry.contentRect
console.log('useSize: resize', width, height)
setSize({ width, height })
}
})
resizeObserver.observe(element)
return () => {
resizeObserver.disconnect()
}
}, [target])
return size
}

View File

@@ -0,0 +1,37 @@
/**
* Nova SDK Kit - React 组件库
*
* 提供聊天界面的核心组件:
* - MessageInput: 消息输入框
* - MessageList: 消息列表/对话流
* - TaskPanel: 图片和文件展示面板
* - NovaChat: 整合上述组件的完整聊天界面
*/
// 类型导出
export * from './types'
// Context & Provider
export { NovaKitProvider } from './context/NovaKitProvider'
export type { NovaKitProviderProps } from './context/NovaKitProvider'
export { useNovaKit } from './context/useNovaKit'
// 消息输入框组件
export { MessageInput } from './message-input'
export type { MessageInputProps } from './message-input'
// 消息列表组件
export { MessageList, MessageItem } from './message-list'
export type { MessageListProps } from './message-list'
// 任务面板组件(图片/文件展示)
export { TaskPanel, ArtifactList, ArtifactPreview } from './task-panel'
export { isImageFile } from './task-panel/utils'
export type { TaskPanelProps } from './task-panel'
// 完整聊天组件
export { NovaChat } from './nova-chat'
export type { NovaChatProps, ApiEvent } from './nova-chat'
// 工具相关组件
export * from './tools'

View File

@@ -0,0 +1,114 @@
import { X, FileIcon, Loader2 } from 'lucide-react'
import type { UploadFile } from '../types'
import { cn } from '@/utils/cn'
import { ImagePreview } from '@/components/ui/image-preview'
import { Image } from '@/components/ui/image'
interface FilePreviewListProps {
files: UploadFile[]
onRemove: (uid: string) => void
disabled?: boolean
}
export function FilePreviewList({ files, onRemove, disabled }: FilePreviewListProps) {
if (files.length === 0) return null
return (
<div className="flex flex-wrap gap-2 border-b border-border/80 px-0 pb-3">
{files.map(file => {
const isImage = /\.(jpg|jpeg|png|gif|webp|svg|ico|bmp)$/i.test(file.name)
const isUploading = file.uploadStatus === 'uploading' || file.uploadStatus === 'pending'
const hasError = file.uploadStatus === 'error'
const showImagePreview = isImage && file.url
return (
<div
key={file.uid}
className={cn(
'relative group flex max-w-[200px] items-center gap-2 rounded-md border bg-card/82 px-3 py-1.5 text-xs backdrop-blur-sm',
hasError ? 'border-red-200 dark:border-red-800' : 'border-border/80',
file.url && !isUploading ? 'cursor-pointer hover:border-primary/50' : ''
)}
onClick={() => {
if (file.url && !isUploading && !hasError) {
if (!showImagePreview) {
window.open(file.url, '_blank')
}
}
}}
>
{/* Icon / Image Preview */}
<div
className="flex h-8 w-8 shrink-0 items-center justify-center overflow-hidden rounded bg-accent/60 text-muted-foreground"
>
{isUploading ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : showImagePreview ? (
<ImagePreview
src={file.url!}
alt={file.name}
className="w-full h-full flex items-center justify-center"
>
<Image
src={file.url!}
alt={file.name}
className="w-full h-full object-cover"
onError={(e) => {
e.currentTarget.style.display = 'none'
e.currentTarget.parentElement?.classList.add('fallback-icon')
}}
/>
</ImagePreview>
) : (
<FileIcon className="w-4 h-4" />
)}
</div>
<div className="flex min-w-0 flex-1 flex-col">
<span className="truncate font-medium text-foreground" title={file.name}>
{file.name}
</span>
<span className="text-[10px] text-muted-foreground">
{isUploading ? '上传中...' : hasError ? '上传失败' : formatFileSize(file.byte_size)}
</span>
</div>
<button
onClick={(e) => {
e.stopPropagation() // 防止触发打开文件
onRemove(file.uid)
}}
disabled={disabled}
className="shrink-0 rounded-full p-0.5 opacity-0 transition-opacity group-hover:opacity-100 hover:bg-accent"
>
<X className="h-3 w-3 text-muted-foreground" />
</button>
{/* Progress Bar */}
{isUploading && (
<div className="absolute bottom-0 left-0 h-0.5 w-full overflow-hidden rounded-b-md bg-accent">
<div
className="h-full bg-primary transition-all duration-300"
style={{ width: `${file.progress || 0}%` }}
/>
</div>
)}
</div>
)
})}
</div>
)
}
function formatFileSize(bytes?: number) {
if (!bytes) return ''
if (bytes < 1024) return bytes + ' B'
const k = 1024
const sizes = ['KB', 'MB', 'GB']
const index = Math.min(
Math.floor(Math.log(bytes) / Math.log(k)) - 1,
sizes.length - 1,
)
return parseFloat((bytes / Math.pow(k, index + 1)).toFixed(1)) + ' ' + sizes[index]
}

View File

@@ -0,0 +1,443 @@
import React, { useState, useCallback, useRef, type KeyboardEvent } from 'react'
import { ArrowUp, StopCircle, Paperclip, Wrench } from 'lucide-react'
import { cn } from '@/utils/cn'
import { Textarea } from '@/components/ui/textarea'
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
import type { SendMessagePayload } from '../types'
import { Button } from '@/components/ui'
import { useNovaKit } from '../context/useNovaKit'
import { useFileUploader } from '../hooks/useFileUploader'
import type { UploadFile } from '../types'
import { FilePreviewList } from './FilePreviewList'
import { request } from '@/http/request'
import {
McpStorePopover,
SkillForm,
type SkillFormState,
MCPJsonEditor,
} from '../tools'
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { toast } from 'sonner'
import { TaskStatus } from '../types'
import { getProjectId, getUserId } from '@/utils/getAuth'
interface InputToolsProps {
showUpload?: boolean
fileInputRef: React.RefObject<HTMLInputElement | null>
accept?: string
onFileSelect: (e: React.ChangeEvent<HTMLInputElement>) => void
onOpenSkillManager: () => void
onOpenMcpManager: () => void
}
const InputTools: React.FC<InputToolsProps> = ({
showUpload,
fileInputRef,
accept,
onFileSelect,
onOpenSkillManager,
onOpenMcpManager,
}) => {
if (!showUpload) return null
return (
<TooltipProvider>
<div className="flex items-center gap-2">
{/* 上传文件 */}
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={() => fileInputRef.current?.click()}
className="inline-flex items-center justify-center rounded-full w-7 h-7 bg-gray-100 text-gray-900 transition-colors hover:bg-gray-200"
>
<Paperclip className="w-3.5 h-3.5" />
</button>
</TooltipTrigger>
<TooltipContent>
<p></p>
</TooltipContent>
</Tooltip>
<input
type="file"
multiple
className="hidden"
ref={fileInputRef}
onChange={onFileSelect}
accept={accept}
/>
{/* 中间按钮Popover提供 Skill / MCP 选项 */}
<Popover>
<Tooltip>
<TooltipTrigger asChild>
<PopoverTrigger asChild>
<button
type="button"
className="inline-flex items-center justify-center rounded-full w-7 h-7 bg-gray-100 text-gray-900 transition-colors hover:bg-gray-200"
>
<Wrench className="w-3.5 h-3.5" />
</button>
</PopoverTrigger>
</TooltipTrigger>
<TooltipContent>
</TooltipContent>
</Tooltip>
<PopoverContent
align="start"
className="w-40 p-1 bg-popover border border-border"
>
<button
type="button"
className="w-full cursor-pointer rounded-md px-3 py-3 text-left text-xs text-foreground transition-colors hover:bg-accent"
onClick={onOpenSkillManager}
>
Skill
</button>
<button
type="button"
className="mt-0.5 w-full cursor-pointer rounded-md px-3 py-3 text-left text-xs text-foreground transition-colors hover:bg-accent"
onClick={onOpenMcpManager}
>
MCP
</button>
</PopoverContent>
</Popover>
{/* MCP 市场入口 */}
<McpStorePopover />
</div>
</TooltipProvider>
)
}
export interface MessageInputProps {
/** 占位符文本 */
placeholder?: string
/** 是否禁用 */
disabled?: boolean
/** 是否正在加载 */
loading?: boolean
/** 任务状态 */
taskStatus?: TaskStatus
/** 发送消息回调 */
onSend?: (payload: SendMessagePayload) => void
/** 终止消息回调 */
onStop?: () => void
/** 文件列表变化回调 */
onFilesChange?: (files: UploadFile[]) => void
/** 自定义类名 */
className?: string
/** 是否显示文件上传按钮 */
showUpload?: boolean
}
/**
* 消息输入框组件
*/
export function MessageInput({
placeholder = '请输入消息...',
taskStatus = TaskStatus.PENDING,
onSend,
className,
showUpload = true,
}: MessageInputProps) {
const { api: { stopChat }, loading, agentId } = useNovaKit()
const [content, setContent] = useState('')
const [files, setFiles] = useState<UploadFile[]>([])
const textareaRef = useRef<HTMLTextAreaElement>(null)
const fileInputRef = useRef<HTMLInputElement>(null)
const [skillDialogOpen, setSkillDialogOpen] = useState(false)
const [mcpDialogOpen, setMcpDialogOpen] = useState(false)
const [mcpJson, setMcpJson] = useState('')
const handleUploadEnd = useCallback((file: UploadFile) => {
setFiles(prev => prev.map(f => f.uid === file.uid ? file : f))
}, [])
const handleUploadStart = useCallback((file: UploadFile) => {
setFiles(prev => [...prev, file])
}, [])
const handleFileUpdate = useCallback((file: UploadFile) => {
setFiles(prev => prev.map(f => f.uid === file.uid ? file : f))
}, [])
const { uploadFile, accept } = useFileUploader({
onUploadStart: handleUploadStart,
onFileUpdate: handleFileUpdate,
onUploadEnd: handleUploadEnd,
})
// Filter valid files and check loading status
const uploading = files.some(f => f.uploadStatus === 'uploading' || f.uploadStatus === 'pending')
const contentEmpty = !content.trim() && files.length === 0
const showStopButton =
(loading || taskStatus === TaskStatus.IN_PROGRESS) &&
taskStatus !== TaskStatus.PAUSED
const handleFileSelect = useCallback(async (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files) {
const selectedFiles = Array.from(e.target.files)
for (const file of selectedFiles) {
await uploadFile(file)
}
// Reset input
if (fileInputRef.current) fileInputRef.current.value = ''
}
}, [uploadFile])
const removeFile = useCallback((uid: string) => {
setFiles(prev => prev.filter(f => f.uid !== uid))
}, [])
// 发送消息
const handleSend = useCallback(() => {
if (contentEmpty) return
// Check if any files are still uploading
if (uploading) {
console.warn('请等待文件上传完成')
return
}
// Filter out failed uploads
const validFiles = files.filter(f => f.uploadStatus === 'success')
const fileIds = validFiles.map(f => f.upload_file_id).filter(Boolean) as string[]
const payload: SendMessagePayload = {
content: content.trim(),
upload_file_ids: fileIds.length > 0 ? fileIds : undefined
}
// Ensure we are sending the file IDs as requested
console.log('Sending message payload:', payload)
onSend?.(payload)
setContent('')
setFiles([])
// 清空已上传的文件
}, [content, contentEmpty, onSend, files, uploading])
// 处理键盘事件
const handleKeyDown = useCallback(
(e: KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
handleSend()
}
},
[handleSend]
)
// 自动调整高度
const handleInput = useCallback((e: React.ChangeEvent<HTMLTextAreaElement>) => {
const textarea = e.target
setContent(textarea.value)
// 自动调整高度
textarea.style.height = 'auto'
textarea.style.height = `${Math.min(textarea.scrollHeight, 200)}px`
}, [])
return (
<>
<div className={cn('w-full', className)}>
<div className="max-w-2xl mx-auto w-full">
<div className="relative group/input">
{/* 渐变光晕背景层 */}
{/* 主输入容器:上方 textarea下方工具栏 / 发送按钮 */}
<div className="relative flex w-full flex-col rounded-2xl border border-gray-200 bg-white px-4 pt-3 pb-3">
{/* 文件预览区域 */}
<FilePreviewList
files={files}
onRemove={removeFile}
disabled={loading}
/>
{/* 文本输入区域(顶部整行) */}
<div className="flex-1 relative">
<Textarea
ref={textareaRef}
value={content}
onChange={handleInput}
onKeyDown={handleKeyDown}
placeholder={placeholder}
rows={1}
className={cn(
'w-full resize-none border-0 shadow-none focus-visible:ring-0 focus-visible:ring-offset-0',
'bg-transparent min-h-[40px] max-h-[160px] px-0',
'placeholder:text-muted-foreground/70',
'text-foreground text-sm leading-relaxed',
'overflow-y-auto transition-all duration-200',
'[&::-webkit-scrollbar]:w-1 [&::-webkit-scrollbar-thumb]:bg-muted-foreground/40 [&::-webkit-scrollbar-thumb]:rounded-full'
)}
/>
</div>
{/* 底部工具栏 + 发送按钮(参考截图布局) */}
<div className="mt-2 pt-2 flex items-center justify-between text-[11px] text-muted-foreground">
{/* 左侧Agent Input + 工具区 */}
<div className="flex items-center gap-3 text-muted-foreground">
<InputTools
showUpload={showUpload}
fileInputRef={fileInputRef}
accept={accept}
onFileSelect={handleFileSelect}
onOpenSkillManager={() => {
setSkillDialogOpen(true)
}}
onOpenMcpManager={() => {
setMcpDialogOpen(true)
}}
/>
</div>
{/* 右侧:终止 + 发送按钮(发送为黑色圆形按钮) */}
<div className="flex items-center gap-2 shrink-0">
{showStopButton && (
<Button
type="button"
size="sm"
variant="outline"
onClick={() => {
void stopChat().catch(() => {})
}}
title="终止"
className={cn(
'h-8 rounded-full px-2.5 pr-3 text-[11px] font-medium transition-all duration-200',
'border border-rose-200/80 bg-rose-50/90 text-rose-600 hover:bg-rose-100 hover:text-rose-700 hover:border-rose-300',
'dark:border-rose-500/40 dark:bg-rose-500/10 dark:text-rose-100 dark:hover:bg-rose-500/20 dark:hover:border-rose-400'
)}
>
<StopCircle className="w-3.5 h-3.5" />
</Button>
)}
<button
type="button"
onClick={handleSend}
disabled={contentEmpty}
title="发送"
className={cn(
'flex h-8 w-8 items-center justify-center rounded-full bg-gray-900 text-white transition-all',
'hover:scale-105 active:scale-95 hover:bg-black',
contentEmpty && 'opacity-30 cursor-not-allowed hover:scale-100 hover:bg-gray-900'
)}
>
<ArrowUp className="w-4 h-4" strokeWidth={2.3} />
</button>
</div>
</div>
</div>
</div>
</div>
</div>
{/* Skill 管理弹窗 */}
<Dialog open={skillDialogOpen} onOpenChange={setSkillDialogOpen}>
<DialogContent className="max-w-2xl border-border bg-card">
<DialogHeader>
<DialogTitle> Skill</DialogTitle>
</DialogHeader>
<div className="mt-4 overflow-hidden rounded-lg">
<SkillForm
teamId={agentId!}
onBack={() => setSkillDialogOpen(false)}
onSave={async (values: SkillFormState) => {
if (!agentId) {
toast.error('缺少 agentId无法创建 Skill')
return
}
if (!values.file?.file) {
toast.error('请先上传 Skill zip 文件')
return
}
try {
const formData = new FormData()
formData.append('name', values.name)
formData.append('agent_id', agentId)
formData.append('file', values.file.file)
/** 存在projectId的时候上传external_app_id */
if (getProjectId()) {
formData.append('external_app_id', getProjectId()!)
}
const userId = await getUserId()
/** 存在userId的时候上传external_user_id */
if (userId) {
formData.append('external_user_id', userId)
}
if (values.description) {
formData.append('description', values.description)
}
await request.post('/plugins/skill/upload', formData, {
headers: {
'Content-Type': 'multipart/form-data',
},
})
toast.success('Skill 添加成功')
setSkillDialogOpen(false)
} catch (error) {
console.error('创建 Skill 失败:', error)
toast.error('创建 Skill 失败')
}
}}
/>
</div>
</DialogContent>
</Dialog>
{/* MCP 管理弹窗 */}
<Dialog open={mcpDialogOpen} onOpenChange={setMcpDialogOpen}>
<DialogContent className="max-w-3xl">
<DialogHeader>
<DialogTitle> MCP</DialogTitle>
</DialogHeader>
<div className="mt-4">
<MCPJsonEditor
value={mcpJson}
onChange={setMcpJson}
onSave={async (parsedConfig: Record<string, unknown>) => {
console.log('保存 MCP', { agentId, parsedConfig })
if (!agentId) {
toast.error('缺少 agentId无法创建 MCP')
return
}
try {
await request.post(`/team/${agentId}/plugins`, {
name: parsedConfig.name,
code: parsedConfig.code,
plugin_type: 'MCP',
source: 'CLOUD',
config: parsedConfig,
})
toast.success('MCP 添加成功')
setMcpJson('')
setMcpDialogOpen(false)
} catch (error) {
console.error('创建 MCP 失败:', error)
toast.error('创建 MCP 失败')
}
}}
/>
</div>
</DialogContent>
</Dialog>
</>
)
}
export default React.memo(MessageInput)

View File

@@ -0,0 +1,229 @@
import React, { useRef, useEffect, useCallback, useState, forwardRef, useImperativeHandle } from 'react'
import { ArrowDown, MessageCircle } from 'lucide-react'
import { cn } from '@/utils/cn'
import { Button } from '@/components/ui/button'
import { ScrollArea } from '@/components/ui/scroll-area'
import type { ExtendedEvent, Attachment, HandleImageAttachmentClick } from '../types'
import { TaskStatus } from '../types'
import type { ApiEvent } from '../hooks/useNovaEvents'
import { MessageItem } from './message-item'
import useEventStore from '../store/useEventStore'
export interface MessageListProps {
/** 消息列表(按步骤分组) */
messages: ExtendedEvent[][]
/** 任务状态 */
taskStatus?: TaskStatus
/** 自定义类名 */
className?: string
/** 是否自动滚动到底部 */
autoScroll?: boolean
/** 空状态渲染 */
emptyRender?: React.ReactNode
/** 加载中状态 */
loading?: boolean
/** 附件点击回调 */
onAttachmentClick?: (attachment: Attachment) => void
/** 图片附件点击回调 */
onImageAttachmentClick?: HandleImageAttachmentClick
/** 工具调用点击回调 */
onToolCallClick?: (event: ApiEvent) => void
/** 发送消息(用户交互回调) */
onSendMessage?: (content: string) => void
}
export interface MessageListRef {
scrollToBottom: (behavior?: ScrollBehavior) => void
}
/**
* 消息列表组件 - 展示对话流
*/
const InnerMessageList = forwardRef<MessageListRef, MessageListProps>(({
messages,
taskStatus = TaskStatus.PENDING,
className,
autoScroll = true,
emptyRender,
loading = false,
onAttachmentClick,
onImageAttachmentClick,
onToolCallClick,
onSendMessage,
}, ref) => {
const setProcessEvents = useEventStore(store=>store.setProcessEvents)
const scrollRef = useRef<HTMLDivElement>(null)
const bottomRef = useRef<HTMLDivElement>(null)
const [showScrollButton, setShowScrollButton] = useState(false)
const [showScrollTopButton, setShowScrollTopButton] = useState(false)
const userTouchedRef = useRef(false)
const autoScrollEnabledRef = useRef(autoScroll)
// 扁平化消息列表
const flatMessages = messages.flat()
useEffect(() => {
setProcessEvents(flatMessages)
},[flatMessages])
// 根据 taskStatus + loading 统一计算「是否展示 loading」
const isLoading =
loading || taskStatus === TaskStatus.IN_PROGRESS
// 滚动到底部(使用 scrollIntoView
const scrollToBottom = useCallback((behavior: ScrollBehavior = 'smooth') => {
// 等下一帧,确保 DOM 和高度都已经更新
requestAnimationFrame(() => {
if (bottomRef.current) {
bottomRef.current.scrollIntoView({
behavior,
block: 'end',
})
}
userTouchedRef.current = false
autoScrollEnabledRef.current = true
})
}, [])
// 暴露滚动方法给父组件
useImperativeHandle(ref, () => ({
scrollToBottom,
}), [scrollToBottom])
// 监听滚动事件
const handleScroll = useCallback(() => {
if (!scrollRef.current) return
const { scrollTop, scrollHeight, clientHeight } = scrollRef.current
const distanceFromBottom = scrollHeight - scrollTop - clientHeight
const isAtBottom = distanceFromBottom < 50
// 用户主动滚动
if (!isAtBottom && !userTouchedRef.current) {
userTouchedRef.current = true
autoScrollEnabledRef.current = false
}
setShowScrollButton(!isAtBottom && flatMessages.length > 3)
setShowScrollTopButton(scrollTop > 50)
}, [flatMessages.length])
// 消息变化时自动滚动
useEffect(() => {
if (autoScroll && autoScrollEnabledRef.current && !userTouchedRef.current) {
const behavior = taskStatus === TaskStatus.IN_PROGRESS ? 'smooth' : 'auto'
scrollToBottom(behavior)
}
}, [flatMessages.length, autoScroll, taskStatus, scrollToBottom])
// // 初始化滚动 - 延迟一段时间,等待历史消息和布局稳定
useEffect(() => {
const timer = setTimeout(() => {
if (flatMessages.length > 0) {
scrollToBottom('auto')
}
}, 0)
return () => clearTimeout(timer)
}, [flatMessages.length])
// 空状态
if (flatMessages.length === 0 && !isLoading) {
return (
<div className={cn('flex-1 flex items-center justify-center', className)}>
{emptyRender || (
<div className="text-center text-muted-foreground">
<MessageCircle className="w-16 h-16 mx-auto mb-4 text-muted" />
<p></p>
</div>
)}
</div>
)
}
return (
<div className={cn('relative flex-1 h-full overflow-hidden', className)}>
{/* 底部渐变遮罩,消息穿过时淡出 */}
<div className="pointer-events-none absolute bottom-0 left-0 right-0 h-32 z-10 bg-gradient-to-t from-white to-transparent backdrop-blur-[2px] [mask-image:linear-gradient(to_top,black_40%,transparent)]" />
{/* 消息列表滚动区域 - 使用 shadcn ScrollArearef 绑定在 Viewport 上 */}
<ScrollArea
ref={scrollRef}
className="h-full"
onScroll={handleScroll}
>
<div className="h-full px-4 pt-4 pb-36">
<div className="px-4 max-w-2xl mx-auto w-full">
{flatMessages.map((event, index) => (
<MessageItem
key={event.event_id || index}
event={event}
onAttachmentClick={onAttachmentClick}
onImageAttachmentClick={onImageAttachmentClick}
onToolCallClick={onToolCallClick}
onSendMessage={onSendMessage}
/>
))}
{/* 加载中指示器task_status === "in_progress" 或 loading=true 时显示,"paused" 时不显示 */}
{isLoading && taskStatus !== TaskStatus.PAUSED && (
<div className="flex items-center gap-3 mt-2 mb-2">
<div className="w-7 h-7 rounded-lg bg-primary/10 flex items-center justify-center shrink-0 border border-primary/20">
<span className="w-3 h-3 rounded-full border-2 border-primary border-t-transparent animate-spin inline-block" />
</div>
<div className="flex items-center gap-1.5">
<span className="w-1.5 h-1.5 bg-primary/60 rounded-full animate-bounce" />
<span
className="w-1.5 h-1.5 bg-primary/60 rounded-full animate-bounce"
style={{ animationDelay: '0.15s' }}
/>
<span
className="w-1.5 h-1.5 bg-primary/60 rounded-full animate-bounce"
style={{ animationDelay: '0.3s' }}
/>
<span className="ml-1 text-xs text-muted-foreground">...</span>
</div>
</div>
)}
</div>
{/* 滚动锚点,保证始终能精确滚到底部,并预留 10px 间距 */}
<div ref={bottomRef} className="h-[10px] w-full" />
</div>
</ScrollArea>
{/* 右侧中部:滚动置顶 / 滚动到底部控制条 */}
{(showScrollButton) && (
<div className="pointer-events-none absolute zoom-in transition-all h-max bottom-2 left-0 right-0 mx-auto flex items-center justify-center">
<div className="pointer-events-auto flex flex-col items-center gap-1 rounded-full border border-border bg-popover/88 p-1 backdrop-blur">
{/* 底部 */}
<Button
variant="ghost"
size="icon"
className={cn(
'h-8 w-8 rounded-full text-muted-foreground hover:text-primary hover:bg-accent transition-all',
!showScrollButton && 'opacity-40 cursor-default hover:bg-transparent hover:text-muted-foreground'
)}
onClick={() => {
if (!showScrollButton) return
scrollToBottom()
}}
>
<ArrowDown className="w-5 h-5" />
</Button>
</div>
</div>
)}
</div>
)
})
InnerMessageList.displayName = 'MessageList'
export const MessageList = React.memo(InnerMessageList)
export default MessageList
// 导出 MessageItem
export { MessageItem } from './message-item'

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
}

View File

@@ -0,0 +1,33 @@
import { describe, it, expect } from 'vitest'
import { render, screen } from '@testing-library/react'
import { ChatHeader } from './ChatHeader'
describe('ChatHeader', () => {
it('returns null when no header provided', () => {
const { container } = render(<ChatHeader />)
expect(container.firstChild).toBeNull()
})
it('renders string header as CardTitle', () => {
render(<ChatHeader header="Test Title" />)
expect(screen.getByText('Test Title')).toBeInTheDocument()
})
it('renders custom React node header', () => {
render(<ChatHeader header={<span data-testid="custom">Custom Header</span>} />)
expect(screen.getByTestId('custom')).toBeInTheDocument()
expect(screen.getByText('Custom Header')).toBeInTheDocument()
})
it('renders string header inside px-6 container', () => {
render(<ChatHeader header="Title" />)
const title = screen.getByText('Title')
expect(title.closest('.px-6')).not.toBeNull()
})
it('renders custom header inside px-4 container', () => {
render(<ChatHeader header={<div>Custom</div>} />)
const custom = screen.getByText('Custom')
expect(custom.closest('.px-4')).not.toBeNull()
})
})

View File

@@ -0,0 +1,27 @@
import React from 'react'
import { Card, CardHeader, CardTitle } from '@/components/ui/card'
export interface ChatHeaderProps {
/** 头部内容:可以是字符串标题或自定义 React 节点 */
header?: React.ReactNode
}
export function ChatHeader({ header }: ChatHeaderProps) {
if (!header) return null
return (
<Card className="rounded-none border-x-0 border-t-0">
<CardHeader className="h-[50px] p-0 justify-center">
{typeof header === 'string' ? (
<div className="px-6 flex items-center h-full">
<CardTitle className="text-base">{header}</CardTitle>
</div>
) : (
<div className="px-4 py-3 flex items-center h-full">
<h1 className="text-lg font-semibold">{header}</h1>
</div>
)}
</CardHeader>
</Card>
)
}

View File

@@ -0,0 +1,41 @@
import { MessageInput } from '../message-input'
import type { SendMessagePayload, TaskStatus } from '../types'
export interface ChatInputAreaProps {
/** 输入框占位符 */
placeholder?: string
/** 是否禁用 */
disabled?: boolean
/** 任务状态 */
taskStatus?: TaskStatus
/** 发送消息回调 */
onSend: (payload: SendMessagePayload) => void
/** 是否有工件(决定是否显示面板切换按钮) */
hasArtifacts: boolean
/** 面板是否可见 */
panelVisible: boolean
/** 面板切换回调 */
onPanelToggle: () => void
}
export function ChatInputArea({
placeholder,
disabled,
taskStatus,
onSend,
}: ChatInputAreaProps) {
return (
<div className="mt-2 shrink-0 px-6 pb-5">
<div className="mx-auto flex items-end gap-3">
<div className="flex-1">
<MessageInput
placeholder={placeholder}
disabled={disabled}
taskStatus={taskStatus}
onSend={onSend}
/>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,250 @@
import React, { useRef, useCallback, useMemo, useState } from 'react'
import { FolderOpen, Share2, Loader2 } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { cn } from '@/utils/cn'
import { MessageList, type MessageListRef } from '../message-list'
import { TaskPanel } from '../task-panel'
import { NovaKitProvider } from '../context/NovaKitProvider'
import { useNovaChatLogic } from '../hooks/useNovaChatLogic'
import { ChatInputArea } from './ChatInputArea'
import type { PlatformConfig, ApiEvent } from '../hooks/useNovaEvents'
import { toast } from 'sonner'
import { request } from '@/http/request'
// 导出类型供外部使用
export type { ApiEvent, PlatformConfig }
export interface NovaChatProps {
/** 模式 */
mode?: 'chat' | 'share'
/** 是否显示文件面板 */
panelMode?: 'sidebar' | 'dialog'
/** 会话 ID */
conversationId?: string
/** 代理 ID */
agentId?: string
/** 侧边面板宽度 */
panelWidth?: number | string
/** 是否正在加载 */
loading?: boolean
/** 输入框占位符 */
placeholder?: string
/** 是否禁用输入 */
disabled?: boolean
/** 空状态渲染 */
emptyRender?: React.ReactNode
/** 自定义类名 */
className?: string
// useNovaEvents 相关配置
/** 平台配置(如果提供,则自动建立 WebSocket 连接和获取历史记录) */
platformConfig?: PlatformConfig
/** WebSocket 重连次数限制,默认 3 */
reconnectLimit?: number
/** WebSocket 重连间隔(毫秒),默认 3000 */
reconnectInterval?: number
/** 获取认证 Token */
getToken?: () => string | undefined
/** 获取租户 ID */
getTenantId?: () => string | undefined
/** 新事件回调 */
onEvent?: (event: ApiEvent) => void
/** 连接状态变化回调 */
onConnectionChange?: (connected: boolean) => void
/** 错误回调 */
onError?: (error: Error) => void
}
/**
* Nova 聊天组件 - 整合消息列表、输入框和文件面板
*/
function InnerNovaChat({
mode = 'chat',
panelMode = 'sidebar',
conversationId,
agentId,
panelWidth = '50%',
placeholder,
disabled = false,
emptyRender,
className,
platformConfig,
reconnectLimit = 3,
reconnectInterval = 3000,
getToken,
getTenantId,
onEvent,
onConnectionChange,
onError,
}: NovaChatProps) {
const messageListRef = useRef<MessageListRef>(null)
// 使用主业务逻辑 Hook
const {
messages,
loading,
taskStatus,
artifacts,
hasArtifacts,
panelVisible,
selectedAttachment,
handleSend,
handlePanelToggle,
handlePanelClose,
handleAttachmentClick,
handleImageAttachmentClick,
handleToolCallClick,
setLoading,
api,
} = useNovaChatLogic({
conversationId,
agentId,
platformConfig,
reconnectLimit,
reconnectInterval,
getToken,
getTenantId,
onEvent,
onConnectionChange,
onError,
mode
})
const [shareLoading, setShareLoading] = useState(false)
// 包装 handleSend 以添加滚动逻辑
const handleSendWithScroll = useCallback(
(payload: Parameters<typeof handleSend>[0]) => {
handleSend(payload)
// 延迟滚动到底部,确保消息已添加到列表中
setTimeout(() => {
messageListRef.current?.scrollToBottom('smooth')
}, 100)
},
[handleSend]
)
const providerAgentId = agentId ?? platformConfig?.agentId ?? ''
const shareUrl = useMemo(() => {
if (!conversationId || !providerAgentId) return ''
return `${window.location.origin}/share?conversationId=${encodeURIComponent(conversationId)}&agentId=${encodeURIComponent(providerAgentId)}`
}, [conversationId, providerAgentId])
return (
<NovaKitProvider panelMode={panelMode} conversationId={conversationId} api={api} agentName={platformConfig?.agentName || 'Autonomous Agent'} agentId={providerAgentId} setLoading={setLoading} loading={loading} mode={mode}>
<div className={cn('flex h-full w-full overflow-hidden text-foreground bg-white', className)}>
{/* 主聊天区域 */}
<div className="relative flex flex-col h-full min-w-0 w-full flex-1">
{/* 头部通栏:白色磨砂,图标黑色 */}
<div className="shrink-0 z-20 flex items-center justify-end px-4 py-2 bg-white">
{hasArtifacts && (
<Button
type="button"
onClick={handlePanelToggle}
title={panelVisible ? '关闭文件面板' : '打开文件面板'}
variant="outline"
size="icon"
className={cn(
'w-8 h-8 rounded-xl',
'border-none bg-transparent',
'shadow-none flex items-center justify-center',
'text-gray-800 hover:text-black hover:scale-105 transition-all duration-200 hover:bg-gray-100'
)}
>
<FolderOpen className="w-4 h-4" />
</Button>
)}
{mode === 'chat' && (
<Button
type="button"
variant="outline"
size="icon"
disabled={!shareUrl || shareLoading}
title={shareUrl ? '复制分享链接' : '缺少会话或 Agent 信息,无法分享'}
className={cn(
'w-8 h-8 rounded-xl',
'border-none bg-transparent',
'shadow-none flex items-center justify-center',
'text-gray-800 hover:text-black hover:scale-105 transition-all duration-200 hover:bg-gray-100'
)}
onClick={async () => {
if (!shareUrl || shareLoading) return
setShareLoading(true)
try {
await request.post('/v1/super_agent/chat/update_public', {
conversation_id: conversationId,
is_public: true,
})
try {
await navigator.clipboard?.writeText?.(shareUrl)
toast.success('分享链接已复制到剪贴板')
} catch {
window.open(shareUrl, '_blank', 'noopener,noreferrer')
}
} catch {
toast.error('分享链接更新失败')
} finally {
setShareLoading(false)
}
}}
>
{shareLoading ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<Share2 className="w-6 h-6" />
)}
</Button>
)}
</div>
{/* 消息列表 - 铺满全部高度,消息可滚动到输入框下方 */}
<MessageList
ref={messageListRef}
messages={messages}
taskStatus={taskStatus}
loading={loading}
emptyRender={emptyRender}
onAttachmentClick={handleAttachmentClick}
onImageAttachmentClick={handleImageAttachmentClick}
onToolCallClick={handleToolCallClick}
onSendMessage={content => handleSendWithScroll({ content })}
/>
{/* 输入区域 - absolute 固定在底部,磨砂透明,消息可穿过 */}
{mode === 'chat' && (
<div className="absolute bottom-0 left-0 right-0 z-10">
<ChatInputArea
placeholder={placeholder}
disabled={disabled}
taskStatus={taskStatus}
onSend={handleSendWithScroll}
hasArtifacts={hasArtifacts}
panelVisible={panelVisible}
onPanelToggle={handlePanelToggle}
/>
</div>
)}
</div>
{/* 文件面板:支持 sidebar 与 dialog 两种模式 */}
{(hasArtifacts || selectedAttachment) && panelVisible && (
<TaskPanel
artifacts={selectedAttachment ? [selectedAttachment] : artifacts}
visible={panelVisible}
width={panelWidth}
onClose={handlePanelClose}
initialSelected={selectedAttachment}
/>
)}
</div>
</NovaKitProvider>
)
}
export const NovaChat = React.memo(InnerNovaChat)
export default NovaChat

View File

@@ -0,0 +1,48 @@
import { create } from 'zustand'
import type { ApiEvent } from '../hooks/useNovaEvents'
import type { ExtendedEvent, TaskArtifact } from '../types'
export interface EventStoreState {
/** 所有事件 */
events: ApiEvent[]
/** processEvents */
processEvents: ExtendedEvent[]
/** 所有 artifacts文件和工具调用 */
artifacts: TaskArtifact[]
/** 选中的事件(用于预览) */
selectedEvent: ApiEvent | null
/** 设置事件列表 */
setEvents: (events: ApiEvent[]) => void
/** 设置 artifacts 列表 */
setArtifacts: (artifacts: TaskArtifact[]) => void
/** 选中一个事件 */
selectEvent: (event: ApiEvent | null) => void
/** 清空所有数据 */
reset: () => void
/** */
setProcessEvents: (processEvents: ExtendedEvent[]) => void
}
/**
* 事件状态管理 Store
* 用于在组件之间传递和共享事件数据和 artifacts
*/
export const useEventStore = create<EventStoreState>((set) => ({
events: [],
processEvents: [],
artifacts: [],
selectedEvent: null,
setEvents: (events) => set({ events }),
setArtifacts: (artifacts) => set({ artifacts }),
selectEvent: (event) => set({ selectedEvent: event }),
reset: () => set({ events: [], artifacts: [], selectedEvent: null }),
setProcessEvents: (processEvents) => set({ processEvents }),
}))
export default useEventStore

View File

@@ -0,0 +1,32 @@
import useEventStore from '@/components/nova-sdk/store/useEventStore';
import { TaskArtifact } from '../types';
import { RefObject, useEffect } from 'react';
import { ImageEditorHandle } from '@/components/image-editor';
export const useImages = (imageEditorRef: RefObject<ImageEditorHandle | null>) => {
const processEvents = useEventStore((store) => store.processEvents);
// 提取事件流中的图片产物资源
const images = processEvents.reduce((pre, cur) => {
const renderProps = cur.renderProps || {};
const imageAttachment = renderProps.imageAttachment;
if (imageAttachment) {
const taskArtifacts = imageAttachment.map((item: any) => {
return {
path: item.path,
file_name: item.file_name,
file_type:
item.file_type || (item.file_name || '').split('.').pop() || '',
};
});
pre.push(...taskArtifacts);
}
return pre;
}, [] as TaskArtifact[]);
// 响应式监听图片资源变化,并批量同步至编辑器
useEffect(() => {
if (images.length > 0) {
imageEditorRef.current?.addArtifacts(images);
}
}, [images, imageEditorRef]);
}

View File

@@ -0,0 +1,347 @@
import React, { useMemo, useState } from 'react'
import { ChevronRight, Folder, FolderOpen, X } from 'lucide-react'
import { cn } from '@/utils/cn'
import type { TaskArtifact } from '../types'
import { getFileIconConfig } from '../utils/fileIcons'
import { Button } from '@/components/ui/button'
import { ScrollArea } from '@/components/ui/scroll-area'
export interface ArtifactListProps {
artifacts: TaskArtifact[]
onClick?: (artifact: TaskArtifact) => void
onClose?: () => void
selected?: TaskArtifact | null
className?: string
}
function getFileTypeLabel(artifact: TaskArtifact) {
const raw =
artifact.file_type ||
artifact.file_name.split('.').pop() ||
artifact.path?.split('.').pop() ||
''
const ext = raw.toLowerCase()
if (!ext) return '文件'
switch (ext) {
case 'py':
return 'Python 脚本'
case 'html':
return 'HTML 文档'
case 'md':
case 'markdown':
return 'Markdown 文件'
case 'png':
case 'jpg':
case 'jpeg':
case 'gif':
case 'webp':
return '图片文件'
case 'pdf':
return 'PDF 文件'
case 'csv':
return 'CSV 文件'
case 'ppt':
case 'pptx':
return '演示文稿'
default:
return `${ext.toUpperCase()} 文件`
}
}
interface TreeNode {
name: string
path: string
isFolder: boolean
fileCount: number
children?: TreeNode[]
artifact?: TaskArtifact
}
type BuildNode = {
name: string
path: string
isFolder: boolean
fileCount: number
children?: Record<string, BuildNode>
artifact?: TaskArtifact
}
// 只要是 http/https 链接,一律视为「网页」,不在文件列表中展示
function isHttpUrlArtifact(artifact: TaskArtifact): boolean {
const rawPath = artifact.path || artifact.file_name || ''
return /^https?:\/\//.test(rawPath)
}
function buildFileTree(artifacts: TaskArtifact[]): TreeNode[] {
const root: Record<string, BuildNode> = {}
artifacts.forEach(artifact => {
const fullPath = artifact.path || artifact.file_name
// 对于 http/https URL去掉协议和域名部分只使用路径来构建「目录」
let normalizedPath = fullPath
if (fullPath.startsWith('http://') || fullPath.startsWith('https://')) {
try {
const url = new URL(fullPath)
normalizedPath = url.pathname && url.pathname !== '/' ? url.pathname : url.hostname
} catch {
// 如果 URL 解析失败,退回原始 fullPath
normalizedPath = fullPath
}
}
const parts = normalizedPath.split('/').filter(Boolean)
let cur = root
let curPath = ''
parts.forEach((part, i) => {
curPath = curPath ? `${curPath}/${part}` : part
const isLast = i === parts.length - 1
if (!cur[part]) {
cur[part] = {
name: part,
path: curPath,
isFolder: !isLast,
fileCount: 0,
children: isLast ? undefined : {},
artifact: isLast ? artifact : undefined,
}
} else if (isLast) {
cur[part].artifact = artifact
}
if (!isLast && cur[part].children) {
cur = cur[part].children!
}
})
})
const toArr = (obj: Record<string, BuildNode>): TreeNode[] =>
Object.values(obj)
.map((n) => {
const children = n.children ? toArr(n.children) : undefined
const fileCount = n.isFolder
? (children || []).reduce((sum, child) => sum + child.fileCount, 0)
: 1
return {
name: n.name,
path: n.path,
isFolder: n.isFolder,
fileCount,
artifact: n.artifact,
children,
}
})
.sort((a, b) => {
if (a.isFolder !== b.isFolder) return a.isFolder ? -1 : 1
return a.name.localeCompare(b.name)
})
return toArr(root)
}
function isSelectedArtifact(node: TreeNode, selected?: TaskArtifact | null) {
return !node.isFolder && !!node.artifact && selected?.path === node.artifact.path
}
interface TreeItemProps {
node: TreeNode
depth?: number
collapsedPaths: Set<string>
selected?: TaskArtifact | null
onToggle: (path: string) => void
onSelect?: (artifact: TaskArtifact) => void
}
function TreeItem({
node,
depth = 0,
collapsedPaths,
selected,
onToggle,
onSelect,
}: TreeItemProps) {
const isFolder = node.isFolder
const isExpanded = isFolder && !collapsedPaths.has(node.path)
const selectedState = isSelectedArtifact(node, selected)
const { icon: FileIcon, color } = isFolder
? { icon: isExpanded ? FolderOpen : Folder, color: 'text-primary' }
: getFileIconConfig(
node.artifact?.file_type || node.artifact?.file_name.split('.').pop() || '',
)
return (
<div>
<button
type="button"
onClick={() => {
if (isFolder) {
onToggle(node.path)
return
}
if (node.artifact) {
onSelect?.(node.artifact)
}
}}
className={cn(
'flex w-full cursor-pointer items-center gap-2 rounded-lg px-2 py-4 text-left transition-colors',
'hover:bg-accent/70',
selectedState && 'bg-primary/10 text-primary dark:bg-primary/18 dark:text-primary-foreground',
)}
style={{ paddingLeft: `${depth * 16 + 8}px` }}
>
{isFolder && (<span className="flex h-4 w-4 shrink-0 items-center justify-center">
<ChevronRight className={cn('h-4 w-4 text-muted-foreground transition-transform', isExpanded && 'rotate-90')} />
</span>
)}
<FileIcon className={cn('h-4 w-4 shrink-0', color)} strokeWidth={1.8} />
<span className="min-w-0 flex-1 truncate text-sm text-foreground">
{node.name}
</span>
<span className="shrink-0 text-[10px] text-muted-foreground">
{isFolder
? `${node.fileCount}`
: node.artifact
? getFileTypeLabel(node.artifact)
: '文件'}
</span>
</button>
{isFolder && isExpanded && node.children?.length ? (
<div className="ml-4 border-l border-border pl-2">
{node.children.map(child => (
<TreeItem
key={child.path}
node={child}
depth={depth + 1}
collapsedPaths={collapsedPaths}
selected={selected}
onToggle={onToggle}
onSelect={onSelect}
/>
))}
</div>
) : null}
</div>
)
}
function InnerArtifactList({ artifacts, onClick, onClose, selected, className }: ArtifactListProps) {
const files = useMemo(
() =>
artifacts.filter(
(a) =>
a.event_type !== 'tool_call' &&
!isHttpUrlArtifact(a),
),
[artifacts],
)
const [collapsedPaths, setCollapsedPaths] = useState<Set<string>>(() => new Set())
const tree = useMemo(
() => buildFileTree(files),
[files],
)
const fileCount = files.length
if (fileCount === 0) {
return (
<div className={cn('flex items-center justify-center h-full text-sm text-muted-foreground', className)}>
</div>
)
}
return (
<div
className={cn(
'flex h-full bg-card overflow-hidden border border-border',
className,
)}
>
<main className="flex-1 flex min-w-0 flex-col">
<header className="h-12 md:h-14 border-b border-border flex items-center justify-between px-4 md:px-6 bg-card">
<div className="flex items-center gap-3">
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-accent text-accent-foreground">
<FolderTreeIcon />
</div>
<div className="min-w-0">
<div className="text-sm font-semibold text-foreground"></div>
<div className="text-xs text-muted-foreground"></div>
</div>
</div>
<div className="flex items-center gap-2 md:gap-3">
{onClose && (
<Button
variant="ghost"
size="icon"
className="h-7 w-7 rounded-lg text-muted-foreground hover:text-primary hover:bg-accent transition-all"
onClick={onClose}
title="关闭面板"
>
<X className="w-3.5 h-3.5" />
</Button>
)}
</div>
</header>
<ScrollArea className="flex-1">
<div className="px-2 py-4">
{tree.map(node => (
<TreeItem
key={node.path}
node={node}
collapsedPaths={collapsedPaths}
selected={selected}
onToggle={(path) => {
setCollapsedPaths(prev => {
const next = new Set(prev)
if (next.has(path)) {
next.delete(path)
} else {
next.add(path)
}
return next
})
}}
onSelect={onClick}
/>
))}
</div>
</ScrollArea>
<footer className="h-9 md:h-10 border-t border-border flex items-center px-4 md:px-6 text-[11px] text-muted-foreground bg-card">
<span>{fileCount} </span>
</footer>
</main>
</div>
)
}
function FolderTreeIcon() {
return (
<div className="relative h-4 w-4">
<div className="absolute left-0 top-0 h-1.5 w-1.5 rounded-sm bg-current opacity-70" />
<div className="absolute left-0 top-2.5 h-1.5 w-1.5 rounded-sm bg-current opacity-70" />
<div className="absolute left-2.5 top-1.25 h-1.5 w-1.5 rounded-sm bg-current" />
<div className="absolute left-[3px] top-[3px] h-px w-[7px] bg-current opacity-50" />
<div className="absolute left-[3px] top-[11px] h-px w-[7px] bg-current opacity-50" />
<div className="absolute left-[9px] top-[6px] h-[5px] w-px bg-current opacity-50" />
</div>
)
}
export const ArtifactList = React.memo(InnerArtifactList)
export default ArtifactList

View File

@@ -0,0 +1,364 @@
import React, { useState } from 'react'
import { ChevronLeft, Download, X } from 'lucide-react'
import { cn } from '@/utils/cn'
import { Button } from '@/components/ui/button'
import { Card } from '@/components/ui/card'
import type { TaskArtifact, ImageAttachment } from '../types'
import { isImageFile } from './utils'
import { useNovaKit } from '../context/useNovaKit'
import { MarkdownPreview, MarkdownContent, CsvPreview, ToolCallPreview, ShellExecutePreview, UrlScriptPreview, VirtualPdfPreview } from './Preview'
import { detectToolType, ToolType, extractShellOutput, extractScriptCode, getDisplayTitle, isScriptLikeFile } from './Preview/previewUtils'
import { ImageEditor } from '@/components/image-editor'
import { TaskArtifactHtml } from '@/components/html-editor'
import PptPreview from '@/components/ppt-editor'
import { ImageAttachmentItem } from '../message-list/message-item/ImageAttachmentItem'
export interface ArtifactPreviewProps {
/** 当前展示的文件 */
artifact: TaskArtifact | null
/** 所有图片(用于切换) */
images?: TaskArtifact[]
/** 获取文件 URL 的函数 */
getUrl?: (artifact: TaskArtifact) => string | Promise<string>
/** 返回按钮点击回调 */
onBack?: () => void
/** 是否从文件列表点击进入(用于决定是否显示悬浮工具栏) */
fromFileList?: boolean
/** 下载按钮点击回调 */
onDownload?: (artifact: TaskArtifact) => void
/** 关闭面板回调 */
onClose?: () => void
/** 自定义类名 */
className?: string
}
const PREVIEW_MIME_TYPES = ['xlsx', 'xls', 'doc', 'docx']
/**
* 文件预览组件
*/
function FilePreview({ artifact }: { artifact: TaskArtifact }) {
const { api,conversationId } = useNovaKit()
const [url, setUrl] = React.useState<string>('')
const [isUrlLoading, setIsUrlLoading] = React.useState(false)
// 检查是否是工具调用
const isToolCall =
artifact.event_type?.toLowerCase() === 'tool_call' ||
artifact.file_type?.toLowerCase() === 'tool_call' ||
artifact.file_type?.toLowerCase() === 'tool' ||
!!artifact.tool_name
// Skill Loader按 Markdown 文档渲染
const isSkillLoader = artifact.action_type === 'skill_loader'
// 检测工具类型
const toolType = isToolCall ? detectToolType(artifact) : ToolType.OTHER
const isMarkdown = artifact.file_type?.toLowerCase() === 'md' ||
artifact.file_name?.toLowerCase().endsWith('.md')
// 仅当文件 path 变化时才重新获取 URL避免流式推送时对象引用变化引起重复请求
React.useEffect(() => {
if (artifact.path && artifact.event_type !== 'tool_call') {
setIsUrlLoading(true)
setUrl('')
api
.getArtifactUrl?.(
artifact,
PREVIEW_MIME_TYPES.includes(artifact.file_type)
? {
'x-oss-process': 'doc/preview,print_1,copy_1,export_1',
}
: undefined,
)
.then(res => {
const originUrl = typeof res?.data === 'string' ? res.data : ''
if (PREVIEW_MIME_TYPES.includes(artifact.file_type)) {
// Office 文件:走文档预览服务并切到 betteryeah 域名
const shortUrl = originUrl.replace(
'oss-cn-hangzhou.aliyuncs.com',
'betteryeah.com',
)
setUrl(
shortUrl
? `${shortUrl}&x-oss-process=doc%2Fpreview%2Cprint_1%2Ccopy_1%2Cexport_1`
: '',
)
} else {
// 其他类型:直接使用后端返回的 URL
setUrl(originUrl)
}
setIsUrlLoading(false)
})
.catch(() => {
setIsUrlLoading(false)
})
} else {
setIsUrlLoading(false)
}
// 用 artifact.path 而非整个 artifact 对象api 不加入依赖Context 每次渲染都会返回新引用)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [artifact.path, toolType])
// Skill Loader直接将 tool_output 作为 Markdown 内容展示
if (isSkillLoader) {
const output = artifact.tool_output ?? artifact.content ?? ''
const content =
typeof output === 'string'
? output
: output != null
? JSON.stringify(output, null, 2)
: ''
return (
<div className="flex-1 h-full overflow-hidden">
<div className="h-full overflow-y-auto p-6">
<MarkdownContent content={content} />
</div>
</div>
)
}
// 脚本执行:使用自定义 SHELL_EXECUTE 预览样式组件
if (toolType === ToolType.SHELL_EXECUTE) {
const outputText = extractShellOutput(artifact.tool_output || '')
return <ShellExecutePreview output={outputText} />
}
if (toolType === ToolType.SCRIPT_FILE) {
const code = extractScriptCode(artifact.tool_input)
const displayTitle = getDisplayTitle(artifact)
// 这里复用 ShellExecutePreview 的终端风格,而不是普通 ScriptPreview
return <ShellExecutePreview output={code} toolLabel={displayTitle} />
}
// 其他工具调用:使用 ToolCallPreview
if (isToolCall) {
return (
<ToolCallPreview
toolName={artifact.tool_name}
eventArguments={artifact.event_arguments}
toolInput={artifact.tool_input}
toolOutput={artifact.tool_output}
/>
)
}
// Markdown用 URL fetch 内容后渲染
if (isMarkdown && url) {
return <MarkdownPreview url={url} />
}
// PPT如果是 PPT 文件且有 slideList使用 PPT 预览
const isPpt = artifact.file_type?.toLowerCase() === 'ppt' ||
artifact.file_type?.toLowerCase() === 'pptx' ||
artifact.file_name?.toLowerCase().endsWith('.ppt') ||
artifact.file_name?.toLowerCase().endsWith('.pptx')
if (isPpt && url) {
return <PptPreview url={url} artifact={artifact} taskId={conversationId || ''} />
}
// CSVfetch 内容后渲染为表格
const isCsv = artifact.file_type?.toLowerCase() === 'csv' ||
artifact.file_name?.toLowerCase().endsWith('.csv')
if (isCsv && url) {
return <CsvPreview url={url} />
}
const isPdf = artifact.file_type?.toLowerCase() === 'pdf' ||
artifact.file_name?.toLowerCase().endsWith('.pdf')
if (isPdf && url) {
return <VirtualPdfPreview url={url} />
}
const isScriptFileByExt = isScriptLikeFile(artifact)
if (isScriptFileByExt && url) {
return <UrlScriptPreview url={url} title={artifact.file_name} />
}
if (url) {
return (
<iframe
src={url}
className="w-full h-full border-0"
title={artifact.file_name}
/>
)
}
// URL 解析中:避免先闪出“不支持预览”
if (isUrlLoading) {
return (
<div className="flex-1 flex items-center justify-center p-8 text-muted-foreground h-full">
<div className="text-sm">...</div>
</div>
)
}
// 不支持预览的文件类型
return (
<div className="flex-1 flex flex-col items-center justify-center p-8 text-muted-foreground">
<div className="text-6xl mb-4">📄</div>
<div className="text-lg font-medium text-foreground mb-2">{artifact.file_name}</div>
<div className="text-sm mb-4">
{artifact.file_type?.toUpperCase() || '未知'}
</div>
<p className="text-sm"></p>
</div>
)
}
/**
* 文件预览组件
*/
function InnerArtifactPreview({
artifact,
onBack,
fromFileList,
onDownload,
onClose,
className,
}: ArtifactPreviewProps) {
const [currentArtifact, setCurrentArtifact] = useState<TaskArtifact | null>(artifact)
const { conversationId, panelMode } = useNovaKit()
// 仅当 artifact 的“身份”变化时才同步,避免对象引用变化引起不必要的预览刷新
React.useEffect(() => {
const incomingIdentity = artifact
? `${artifact.path}|${artifact.file_name}|${artifact.file_type}|${artifact.tool_name}|${artifact.event_type}`
: ''
const currentIdentity = currentArtifact
? `${currentArtifact.path}|${currentArtifact.file_name}|${currentArtifact.file_type}|${currentArtifact.tool_name}|${currentArtifact.event_type}`
: ''
if (incomingIdentity !== currentIdentity) {
setCurrentArtifact(artifact)
}
// currentArtifact 不加入依赖,避免 setCurrentArtifact 触发自身
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [artifact])
if (!currentArtifact) {
return (
<div className={cn('flex items-center justify-center h-full text-muted-foreground', className)}>
</div>
)
}
// 判断是否是图片:检查 path、file_name 和 file_type
const isImage =
isImageFile(currentArtifact.path) ||
isImageFile(currentArtifact.file_name) ||
['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg', 'bmp'].includes(currentArtifact.file_type?.toLowerCase() || '')
// html预览
const isHtml =
currentArtifact.file_type?.toLowerCase() === 'html' ||
currentArtifact.file_name?.toLowerCase().endsWith('.html');
// 只用稳定标识避免同一文件流式更新时重复触发入场动画
const previewIdentity = currentArtifact.path || currentArtifact.file_name
return (
<Card className={cn('flex flex-col h-full border-0 rounded-none relative', className)}>
{/* 悬浮工具栏 */}
{(panelMode === 'sidebar' || fromFileList) && <div className="absolute left-4 top-4 z-30 flex items-center gap-1.5 rounded-lg border border-border/50 bg-background/70 p-1 backdrop-blur-xl animate-in fade-in-0 slide-in-from-top-4 duration-300">
{onBack && (
<Button
variant="ghost"
size="icon"
onClick={onBack}
className="h-8 w-8 rounded-md transition-all hover:bg-primary hover:text-white"
>
<ChevronLeft className="w-4 h-4 transition-transform hover:-translate-x-0.5" />
</Button>
)}
<span className="text-sm font-medium truncate max-w-[200px] px-2 text-foreground/80 select-none">
{currentArtifact.file_name}
</span>
<div className="flex items-center gap-1 pl-1.5 border-l border-border/50">
{onDownload && (
<Button
variant="ghost"
size="icon"
onClick={() => onDownload(currentArtifact)}
title="下载"
className="h-8 w-8 rounded-md transition-all hover:bg-primary/20"
>
<Download className="w-4 h-4 transition-transform hover:translate-y-0.5" />
</Button>
)}
{onClose && (
<Button
variant="ghost"
size="icon"
onClick={onClose}
title="关闭面板"
className="h-8 w-8 rounded-md transition-all hover:bg-destructive/20 hover:text-destructive text-muted-foreground"
>
<X className="w-4 h-4 transition-transform hover:rotate-90" />
</Button>
)}
</div>
</div>}
{/* 预览内容 */}
<div className="flex-1 overflow-hidden">
{isImage ? (
panelMode === 'dialog' ? (
<ImageAttachmentItem
assetsType={currentArtifact.from!}
image={
{
url: currentArtifact.url,
path: currentArtifact.path,
file_name: currentArtifact.file_name,
file_url: (currentArtifact as unknown as { file_url?: string }).file_url,
file_type: currentArtifact.file_type,
} as ImageAttachment
}
/>
) : (
<ImageEditor
taskId={conversationId || ''}
currentArtifact={currentArtifact}
readOnly={false}
onBack={() => {}}
onClose={() => {}}
expand={false}
onToggleExpand={() => {}}
/>
)
) : isHtml ? (
<TaskArtifactHtml
taskId={conversationId || ''}
taskArtifact={currentArtifact}
editable={true}
type="web"
onStateChange={(state) => {
console.log(state)
}}
/>
) : (
<div
key={previewIdentity}
className="animate-in fade-in-0 zoom-in-95 duration-300 h-full"
>
<FilePreview artifact={currentArtifact} />
</div>
)}
</div>
</Card>
)
}
export const ArtifactPreview = React.memo(InnerArtifactPreview)
export default ArtifactPreview

View File

@@ -0,0 +1,171 @@
import React, { useEffect, useState } from 'react'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import { ScrollArea, ScrollBar } from '@/components/ui/scroll-area'
export interface CsvPreviewProps {
/** CSV 文件的远程 URL */
url?: string
/** 直接传入的 CSV 内容 */
content?: string
}
function parseCsv(text: string): string[][] {
const lines = text.split(/\r?\n/)
return lines
.filter(line => line.trim() !== '')
.map(line => {
const row: string[] = []
let inQuotes = false
let cell = ''
for (let i = 0; i < line.length; i++) {
const ch = line[i]
if (ch === '"') {
if (inQuotes && line[i + 1] === '"') {
cell += '"'
i++
} else {
inQuotes = !inQuotes
}
} else if (ch === ',' && !inQuotes) {
row.push(cell)
cell = ''
} else {
cell += ch
}
}
row.push(cell)
return row
})
}
export function CsvPreview({ url, content }: CsvPreviewProps) {
const [rows, setRows] = useState<string[][]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
let cancelled = false
// 在异步回调里更新本地 state避免在 effect 体内同步 setState
queueMicrotask(() => {
if (cancelled) return
setLoading(true)
setError(null)
})
if (content != null) {
queueMicrotask(() => {
if (cancelled) return
setRows(parseCsv(content))
setLoading(false)
})
return () => {
cancelled = true
}
}
if (!url) {
queueMicrotask(() => {
if (cancelled) return
setRows([])
setLoading(false)
})
return () => {
cancelled = true
}
}
fetch(url)
.then(res => {
if (!res.ok) throw new Error(`HTTP ${res.status}`)
return res.text()
})
.then(text => {
if (cancelled) return
setRows(parseCsv(text))
})
.catch(err => {
if (cancelled) return
setError(err.message || '加载失败')
})
.finally(() => {
if (cancelled) return
setLoading(false)
})
return () => {
cancelled = true
}
}, [content, url])
if (loading) {
return (
<div className="flex items-center justify-center h-full text-muted-foreground">
<div className="w-6 h-6 border-2 border-muted border-t-primary rounded-full animate-spin mr-2" />
<span className="text-sm">...</span>
</div>
)
}
if (error) {
return (
<div className="flex items-center justify-center h-full text-destructive text-sm">
{error}
</div>
)
}
if (rows.length === 0) {
return (
<div className="flex items-center justify-center h-full text-muted-foreground text-sm">
</div>
)
}
const [header, ...body] = rows
const colCount = Math.max(...rows.map(r => r.length))
const paddedHeader = header.concat(Array(colCount - header.length).fill(''))
return (
<ScrollArea className="w-full h-full">
<div className="p-4">
<Table>
<TableHeader>
<TableRow>
{paddedHeader.map((col, i) => (
<TableHead key={i} className="whitespace-nowrap font-medium text-foreground">
{col || `${i + 1}`}
</TableHead>
))}
</TableRow>
</TableHeader>
<TableBody>
{body.map((row, ri) => {
const paddedRow = row.concat(Array(colCount - row.length).fill(''))
return (
<TableRow key={ri}>
{paddedRow.map((cell, ci) => (
<TableCell key={ci} className="whitespace-nowrap text-sm">
{cell}
</TableCell>
))}
</TableRow>
)
})}
</TableBody>
</Table>
</div>
<ScrollBar orientation="horizontal" />
</ScrollArea>
)
}

View File

@@ -0,0 +1,64 @@
import React, { createContext, useEffect, useState } from 'react'
import { createHighlighterCore, type HighlighterCore } from 'shiki/core'
import { createOnigurumaEngine } from 'shiki/engine/oniguruma'
export const HighlighterContext = createContext<HighlighterCore | null>(null)
export interface HighlighterProviderProps {
children: React.ReactNode
}
/**
* Shiki Highlighter Provider - 提供全局的代码高亮器实例
*/
export function HighlighterProvider({ children }: HighlighterProviderProps) {
const [highlighter, setHighlighter] = useState<HighlighterCore | null>(null)
useEffect(() => {
let mounted = true
createHighlighterCore({
themes: [
import('@shikijs/themes/github-light'),
import('@shikijs/themes/github-dark-default'),
import('@shikijs/themes/vitesse-dark'),
import('@shikijs/themes/one-light'),
import('@shikijs/themes/snazzy-light'),
import('@shikijs/themes/everforest-light'),
],
langs: [
import('@shikijs/langs/css'),
import('@shikijs/langs/javascript'),
import('@shikijs/langs/tsx'),
import('@shikijs/langs/jsx'),
import('@shikijs/langs/xml'),
import('@shikijs/langs/html'),
import('@shikijs/langs/python'),
import('@shikijs/langs/sh'),
import('@shikijs/langs/json'),
import('@shikijs/langs/sql'),
import('@shikijs/langs/nginx'),
import('@shikijs/langs/mermaid'),
import('@shikijs/langs/markdown'),
],
engine: createOnigurumaEngine(import('shiki/wasm')),
}).then(h => {
if (mounted) {
setHighlighter(h)
}
})
return () => {
mounted = false
highlighter?.dispose()
}
}, [])
return (
<HighlighterContext.Provider value={highlighter}>
{children}
</HighlighterContext.Provider>
)
}
export default HighlighterProvider

View File

@@ -0,0 +1,65 @@
import React from 'react'
import ReactMarkdown from 'react-markdown'
import { ScrollArea } from '@/components/ui/scroll-area'
import remarkGfm from 'remark-gfm'
import { cn } from '@/utils/cn'
export interface MarkdownPreviewProps {
/** Markdown 文件的 URL */
url: string
}
export interface MarkdownContentProps {
/** 直接传入 Markdown 字符串 */
content: string
className?: string
}
/**
* 内联 Markdown 渲染组件 - 接收字符串内容直接渲染
*/
export function MarkdownContent({ content, className }: MarkdownContentProps) {
return (
<div className={cn('prose prose-sm max-w-none dark:prose-invert break-words', className)}>
<ReactMarkdown remarkPlugins={[remarkGfm]}>{content}</ReactMarkdown>
</div>
)
}
/**
* Markdown 预览组件 - 接收 URLfetch 内容后渲染
*/
export function MarkdownPreview({ url }: MarkdownPreviewProps) {
const [content, setContent] = React.useState<string>('')
const [loading, setLoading] = React.useState(true)
React.useEffect(() => {
if (!url) return
setLoading(true)
fetch(url)
.then(res => res.text())
.then(text => setContent(text))
.catch(() => setContent('加载失败'))
.finally(() => setLoading(false))
}, [url])
if (loading) {
return (
<div className="flex-1 flex flex-col items-center justify-center p-8 text-muted-foreground h-full">
<div className="w-8 h-8 border-2 border-muted border-t-primary rounded-full animate-spin" />
<span className="text-sm mt-2">...</span>
</div>
)
}
return (
<ScrollArea className="h-full">
<div className="p-6 pt-14 prose prose-sm max-w-none dark:prose-invert">
<ReactMarkdown remarkPlugins={[remarkGfm]}>{content}</ReactMarkdown>
</div>
</ScrollArea>
)
}
export default MarkdownPreview

View File

@@ -0,0 +1,211 @@
import React, { useState, useRef } from 'react'
import { ChevronLeft, ChevronRight } from 'lucide-react'
import { cn } from '@/utils/cn'
import { Button } from '@/components/ui/button'
import { ScrollArea, ScrollBar } from '@/components/ui/scroll-area'
import { useSize } from '../../hooks/useSize'
export interface SlideItem {
content: string
[key: string]: unknown
}
export interface PptPreviewProps {
/** PPT 文件的 URL */
url: string
}
/**
* PPT 预览组件
*/
export function PptPreview({ url }: PptPreviewProps) {
const [currentIndex, setCurrentIndex] = useState(0)
const [slideList, setSlideList] = useState<SlideItem[]>([])
const [loading, setLoading] = useState(true)
React.useEffect(() => {
if (!url) return
setLoading(true)
fetch(url)
.then(res => res.json())
.then(data => {
const slides = data.slide_list || []
setSlideList(slides)
})
.catch(() => setSlideList([]))
.finally(() => setLoading(false))
}, [url])
if (loading) {
return (
<div className="flex-1 flex flex-col items-center justify-center p-8 text-muted-foreground h-full">
<div className="w-8 h-8 border-2 border-muted border-t-primary rounded-full animate-spin" />
<span className="text-sm mt-2">...</span>
</div>
)
}
if (!slideList || slideList.length === 0) {
return (
<div className="flex-1 flex flex-col items-center justify-center p-8 text-muted-foreground">
<div className="text-6xl mb-4">📊</div>
<p className="text-sm"></p>
</div>
)
}
return <PptSlideViewer slideList={slideList} currentIndex={currentIndex} setCurrentIndex={setCurrentIndex} />
}
function PptSlideViewer({
slideList,
currentIndex,
setCurrentIndex
}: {
slideList: SlideItem[]
currentIndex: number
setCurrentIndex: (index: number) => void
}) {
const containerRef = useRef<HTMLDivElement>(null)
const iframeRef = useRef<HTMLIFrameElement>(null)
const size = useSize(containerRef)
const [iframeHeight, setIframeHeight] = useState(720)
const [loadState, setLoadState] = useState<'loading' | 'loaded' | 'error'>('loading')
const currentSlide = slideList[currentIndex]
const scale = size ? size.width / 1280 : 1
const handleIframeLoad = (event: React.SyntheticEvent<HTMLIFrameElement>) => {
const iframe = event.currentTarget
try {
const actualHeight = iframe.contentDocument?.documentElement.scrollHeight
if (actualHeight && actualHeight > 0) {
setIframeHeight(actualHeight)
}
setLoadState('loaded')
} catch (error) {
console.warn('Cannot access iframe content:', error)
setLoadState('loaded')
}
}
const handleIframeError = () => {
setLoadState('error')
}
// 切换幻灯片时重置加载状态
React.useEffect(() => {
setLoadState('loading')
setIframeHeight(720)
}, [currentIndex])
return (
<div className="flex flex-col h-full">
{/* 主预览区 */}
<div className="flex flex-1 flex-col items-center overflow-hidden p-4">
<ScrollArea className="flex-1 w-full">
<div className="flex min-h-full w-full justify-center">
<div
ref={containerRef}
className="relative w-full flex-none overflow-hidden rounded-lg border border-solid border-border bg-card"
style={{
height: scale ? `${iframeHeight * scale}px` : '720px',
}}
>
{loadState === 'loading' && (
<div className="absolute inset-0 flex flex-col items-center justify-center bg-card/92 backdrop-blur-sm">
<div className="w-8 h-8 border-2 border-muted border-t-primary rounded-full animate-spin" />
<p className="text-muted-foreground text-sm mt-2">...</p>
</div>
)}
<iframe
ref={iframeRef}
srcDoc={currentSlide.content}
className={cn(
'w-[1280px] border-0 origin-top-left transition-opacity duration-300 ease-in-out',
loadState === 'loading' ? 'opacity-0' : 'opacity-100'
)}
title={`Slide ${currentIndex + 1}`}
sandbox="allow-same-origin allow-scripts"
onLoad={handleIframeLoad}
onError={handleIframeError}
style={{
height: `${iframeHeight}px`,
transform: `scale(${scale})`,
}}
/>
</div>
</div>
</ScrollArea>
{/* 页码和导航 */}
<div className="mt-4 flex shrink-0 items-center gap-4">
<Button
variant="outline"
size="icon"
onClick={() => setCurrentIndex(Math.max(0, currentIndex - 1))}
disabled={currentIndex === 0}
>
<ChevronLeft className="w-4 h-4" />
</Button>
<span className="text-sm font-medium text-foreground">
{currentIndex + 1} / {slideList.length}
</span>
<Button
variant="outline"
size="icon"
onClick={() => setCurrentIndex(Math.min(slideList.length - 1, currentIndex + 1))}
disabled={currentIndex === slideList.length - 1}
>
<ChevronRight className="w-4 h-4" />
</Button>
</div>
</div>
{/* 缩略图列表 */}
{slideList.length > 1 && (
<div className="shrink-0 border-t border-border/80 bg-secondary/35 p-3">
<ScrollArea className="w-full">
<div className="flex gap-2 pb-1 w-max">
{slideList.map((slide, index) => (
<button
key={index}
type="button"
className={cn(
'relative aspect-video w-48 shrink-0 overflow-hidden rounded-lg border-2 bg-card transition-all',
currentIndex === index
? 'border-primary ring-2 ring-primary/20'
: 'border-transparent hover:border-muted'
)}
onClick={() => setCurrentIndex(index)}
>
<div className="w-full h-full overflow-hidden">
<iframe
srcDoc={slide.content}
className="w-full h-full border-0 pointer-events-none origin-top-left"
title={`Thumbnail ${index + 1}`}
sandbox="allow-same-origin"
style={{
transform: 'scale(1)',
}}
/>
</div>
<div className="absolute inset-0 bg-transparent pointer-events-none" />
<div className="pointer-events-none absolute bottom-0 left-0 right-0 bg-black/55 py-1 text-center text-xs text-white">
{index + 1}
</div>
</button>
))}
</div>
<ScrollBar orientation="horizontal" />
</ScrollArea>
</div>
)}
</div>
)
}
export default PptPreview

View File

@@ -0,0 +1,162 @@
import { useState, useEffect } from 'react'
import type { Element } from 'hast'
import { ScrollArea } from '@/components/ui/scroll-area'
import { cn } from '@/utils/cn'
import { useHighlighter } from './useHighlighter'
export interface ScriptPreviewProps {
/** 脚本代码 */
code: string
/** 执行结果 */
output?: string
/** 语言类型 */
language?: string
/** 脚本名称/标题 */
title?: string
/** 自定义类名 */
className?: string
/** 主题 */
theme?: string
}
/**
* 根据工具名称或内容检测语言
*/
function detectScriptLanguage(code: string, hint?: string): string {
// 优先使用提示
if (hint) {
const lowerHint = hint.toLowerCase()
if (lowerHint.includes('python') || lowerHint.includes('py')) return 'python'
if (lowerHint.includes('javascript') || lowerHint.includes('js')) return 'javascript'
if (lowerHint.includes('typescript') || lowerHint.includes('ts')) return 'typescript'
if (lowerHint.includes('shell') || lowerHint.includes('bash') || lowerHint.includes('sh')) return 'bash'
if (lowerHint.includes('sql')) return 'sql'
}
const trimmed = code.trim()
// Python
if (
/^(def|class|import|from|if __name__|async def|@\w+)\s/.test(trimmed) ||
/\bprint\s*\(/.test(trimmed)
) {
return 'python'
}
// Bash/Shell
if (
/^(#!\/bin\/(bash|sh)|curl|wget|npm|yarn|cd|ls|echo|sudo)\s/.test(trimmed) ||
/^\$\s/.test(trimmed)
) {
return 'bash'
}
// JavaScript/Node
if (
/^(const|let|var|function|async|import|export)\s/.test(trimmed) ||
/console\.(log|error|warn)/.test(trimmed)
) {
return 'javascript'
}
// SQL
if (/^(SELECT|INSERT|UPDATE|DELETE|CREATE|ALTER|DROP)\s/i.test(trimmed)) {
return 'sql'
}
return 'plaintext'
}
/**
* 代码块组件
*/
function CodeBlock({
code,
lang,
theme = 'one-light',
}: {
code: string
lang: string
theme?: string
title?: string
}) {
const highlighter = useHighlighter()
const [highlightedHtml, setHighlightedHtml] = useState('')
useEffect(() => {
const generateHighlightedHtml = async () => {
if (!highlighter || !code) {
return ''
}
return highlighter.codeToHtml(code, {
lang,
theme,
transformers: [
{
code(node: Element) {
const className = node.properties.className
if (Array.isArray(className)) {
className.push('whitespace-pre-wrap', 'break-all')
} else {
node.properties.className = ['whitespace-pre-wrap', 'break-all']
}
},
pre(node: Element) {
node.tagName = 'div'
const className = node.properties.className
if (Array.isArray(className)) {
className.push('overflow-auto')
} else {
node.properties.className = ['overflow-auto']
}
// 移除背景色
delete node.properties.style
},
},
],
})
}
generateHighlightedHtml().then(html => {
setHighlightedHtml(html)
})
}, [code, lang, theme, highlighter])
if (!highlightedHtml) {
return (
<div className="flex items-center justify-center p-8 text-muted-foreground bg-transparent rounded-lg">
<div className="w-6 h-6 rounded-full animate-spin" />
</div>
)
}
return (
<div className="rounded-lg overflow-hidden bg-transparent">
<div dangerouslySetInnerHTML={{ __html: highlightedHtml }} />
</div>
)
}
/**
* 脚本预览组件 - 专门用于显示脚本代码
*/
export function ScriptPreview({
code,
language,
title = '脚本代码',
className,
theme = 'one-light',
}: Omit<ScriptPreviewProps, 'output'>) {
const detectedLang = language || detectScriptLanguage(code, title)
return (
<div className={cn('flex flex-col h-full', className)}>
<ScrollArea className="flex-1">
<CodeBlock code={code} lang={detectedLang} theme={theme} />
</ScrollArea>
</div>
)
}
export default ScriptPreview

View File

@@ -0,0 +1,110 @@
import { Loader2 } from 'lucide-react'
import ScriptPreview from './ScriptPreview'
export interface ShellExecutePreviewProps {
/** shell_execute 的输出文本(已做 ANSI 处理) */
output: string
/** 顶部和终端中展示的工具名称,默认 shell_execute */
toolLabel?: string
/** 是否处于加载中(统一在终端里展示 loading */
loading?: boolean
}
export function ShellExecutePreview({
output,
toolLabel = 'shell_execute',
loading = false,
}: ShellExecutePreviewProps) {
const safeOutput = output || 'No output'
if (loading) {
return (
<div className="flex items-center justify-center py-10 h-full">
<Loader2 className="h-6 w-6 animate-spin text-primary" />
</div>
)
}
return (
<div
className={`flex w-full bg-background text-foreground ${
!loading ? 'animate-in fade-in-0 slide-in-from-bottom-2 duration-300' : ''
}`}
style={{ height: 'calc(100% + 20px)' }}
>
{/* 右侧主体区域 */}
<div className="flex-1 flex flex-col h-full">
{/* 内容区域:一张终端卡片 */}
<main className="flex-1 overflow-y-auto custom-scrollbar px-3 md:px-6 py-4 md:py-6 flex items-center justify-center">
<div className="max-w-4xl mx-auto space-y-4 md:space-y-6 min-w-[60%]">
{/* 终端卡片,主进场:从下浮现 + 略微缩放 */}
<div
className={`min-h-[200px] overflow-hidden rounded-xl border ${
!loading
? 'animate-in fade-in-0 zoom-in-95 slide-in-from-bottom-4 duration-500'
: ''
}`}
style={{
backgroundColor: 'var(--terminal-background)',
borderColor: 'var(--terminal-border)',
...( !loading ? { animationTimingFunction: 'cubic-bezier(0.16, 1, 0.3, 1)' } : {}),
}}
>
<div
className="flex items-center justify-between border-b px-3 py-2 md:px-4"
style={{
backgroundColor: 'var(--terminal-surface)',
borderColor: 'var(--terminal-border)',
}}
>
<div
className={`flex gap-1.5 ${
!loading ? 'animate-in fade-in zoom-in-75 duration-700 fill-mode-both' : ''
}`}
>
<div className="h-2.5 w-2.5 rounded-full transition-colors" style={{ backgroundColor: 'color-mix(in srgb, var(--destructive) 72%, transparent)' }} />
<div className="h-2.5 w-2.5 rounded-full transition-colors" style={{ backgroundColor: 'color-mix(in srgb, var(--warning) 72%, transparent)' }} />
<div className="h-2.5 w-2.5 rounded-full transition-colors" style={{ backgroundColor: 'color-mix(in srgb, var(--success) 72%, transparent)' }} />
</div>
<span
className={`text-[10px] font-mono ${
!loading ? 'animate-in fade-in slide-in-from-top-1 duration-700 fill-mode-both' : ''
}`}
style={{ color: 'var(--terminal-text-muted)' }}
>
bash {toolLabel}
</span>
</div>
<div className="relative space-y-2 py-3 font-mono text-[11px] md:py-4 md:text-xs" style={{ color: 'var(--terminal-text)' }}>
<div
className={`flex gap-2 mb-1 px-3 md:px-4 ${
!loading ? 'animate-in fade-in-0 slide-in-from-left-2 duration-500' : ''
}`}
>
<span style={{ color: 'var(--terminal-prompt)' }}>$</span>
<span className="truncate" style={{ color: 'var(--terminal-text)' }}>{toolLabel}</span>
</div>
<div
className={`mt-2 max-h-[420px] overflow-auto whitespace-pre-wrap border-t px-3 pt-2 custom-scrollbar md:px-4 ${
!loading ? 'animate-in fade-in-0 slide-in-from-bottom-2 duration-500' : ''
}`}
style={{
borderColor: 'var(--terminal-border)',
color: 'var(--terminal-text)',
}}
>
<ScriptPreview
code={safeOutput}
language="bash"
title={toolLabel}
theme="github-dark-default"
/>
</div>
</div>
</div>
</div>
</main>
</div>
</div>
)
}

View File

@@ -0,0 +1,420 @@
import React from 'react'
import { ScrollArea } from '@/components/ui/scroll-area'
import { cn } from '@/utils/cn'
import type { TaskArtifact } from '../../types'
import { UrlScriptPreview } from './UrlScriptPreview'
import { ShellExecutePreview } from './ShellExecutePreview'
import { WebSearchPreview } from './WebSearchPreview'
import { extractToolOutputArtifact, normalizeArtifactFileType } from './previewUtils'
import { ToolOutputArtifactPreview } from './ToolOutputArtifactPreview'
export interface ToolCallPreviewProps {
/** 工具名称 */
toolName?: string
/** 原始事件参数event.content.arguments */
eventArguments?: unknown
/** 工具输入参数 */
toolInput?: unknown
/** 工具输出结果 */
toolOutput?: unknown
/** 自定义类名 */
className?: string
/** 主题 */
theme?: string
}
function getBaseName(path?: string): string {
if (!path) return ''
const cleanPath = path.split(/[?#]/)[0]
const segments = cleanPath.split('/').filter(Boolean)
return segments[segments.length - 1] || ''
}
function extractArrayToolOutputArtifact(
toolOutput: unknown,
toolName?: string,
): TaskArtifact | null {
if (!Array.isArray(toolOutput) || toolOutput.length === 0) {
return null
}
const firstObject = toolOutput.find(
item => item && typeof item === 'object' && !Array.isArray(item),
) as Record<string, unknown> | undefined
if (!firstObject) {
return null
}
const outputPathFields = ['image_path', 'path', 'file_path', 'file_url', 'url', 'download_url']
const outputNameFields = ['file_name', 'name', 'title']
const outputTypeFields = ['file_type', 'type', 'mime_type']
const outputPath = outputPathFields.find(
field => typeof firstObject[field] === 'string' && firstObject[field],
)
const pathValue = outputPath ? String(firstObject[outputPath]) : ''
if (!pathValue) {
return null
}
const outputName = outputNameFields.find(
field => typeof firstObject[field] === 'string' && firstObject[field],
)
const fileName = outputName
? String(firstObject[outputName])
: getBaseName(pathValue) || `${toolName || 'tool_output'}`
const outputType = outputTypeFields.find(
field => typeof firstObject[field] === 'string' && firstObject[field],
)
const fileType = normalizeArtifactFileType(
outputType ? String(firstObject[outputType]) : '',
fileName,
pathValue,
)
return {
path: pathValue,
file_name: fileName,
file_type: fileType,
url: /^https?:\/\//.test(pathValue) ? pathValue : undefined,
}
}
/**
* 从工具输入/输出中提取代码内容
*/
function extractCodeFromData(data: unknown): string | null {
if (!data || typeof data !== 'object') {
return null
}
const obj = data as Record<string, unknown>
// 常见的代码字段
const codeFields = [
'code',
'script',
'command',
'cmd',
'content',
'source',
'text',
'body',
]
for (const field of codeFields) {
if (typeof obj[field] === 'string' && obj[field]) {
return obj[field] as string
}
}
return null
}
/**
* 从工具输出中智能提取脚本 URL
* 优先支持几种常见字段url / script_url / file_url / download_url
*/
function extractScriptUrl(data: unknown): string | null {
if (!data) return null
// 纯字符串且是 URL
if (typeof data === 'string') {
return /^https?:\/\//.test(data) ? data : null
}
if (typeof data !== 'object' || data === null) return null
const obj = data as Record<string, unknown>
const urlLikeFields = ['script_url', 'url', 'file_url', 'download_url']
for (const field of urlLikeFields) {
const val = obj[field]
if (typeof val === 'string' && /^https?:\/\//.test(val)) {
return val
}
}
return null
}
/**
* 提取 event.content.arguments[0] 作为搜索词
*/
function extractFirstArgument(args: unknown): string | null {
if (!args) return null
let normalized: unknown[] = []
if (Array.isArray(args)) {
normalized = args
} else if (typeof args === 'string') {
try {
const parsed = JSON.parse(args)
if (Array.isArray(parsed)) {
normalized = parsed
} else {
normalized = [args]
}
} catch {
normalized = [args]
}
} else {
return null
}
if (!normalized.length) return null
const first = normalized[0]
if (typeof first === 'string') return first
if (typeof first === 'number' || typeof first === 'boolean') return String(first)
return null
}
/**
* 格式化数据为字符串,尝试保持原始格式
*/
function formatData(data: unknown, toolName?: string): string {
// 如果是字符串,直接返回
if (typeof data === 'string') {
return data
}
// 如果是脚本执行类工具,尝试提取代码
const isCodeExecutionTool = toolName && (
toolName.includes('execute') ||
toolName.includes('shell') ||
toolName.includes('code') ||
toolName.includes('script') ||
toolName.includes('python') ||
toolName.includes('javascript') ||
toolName.includes('node') ||
toolName.includes('bash')
)
if (isCodeExecutionTool) {
const code = extractCodeFromData(data)
if (code) {
return code
}
}
// 默认格式化为 JSON
return JSON.stringify(data, null, 2)
}
/**
* 将任意值拍平为 [key, displayValue][] 用于 table 渲染
* - 对象:每个 key-value 一行
* - 其他:单行 value
*/
function flattenToRows(data: unknown): Array<[string, string]> {
if (!data) return []
// 特殊处理:当只有 value 且它是 JSON 字符串时,优先解析并展开内部字段
if (
typeof data === 'object' &&
data !== null &&
!Array.isArray(data) &&
'value' in (data as Record<string, unknown>)
) {
const rawValue = (data as Record<string, unknown>).value
if (typeof rawValue === 'string') {
try {
const parsed = JSON.parse(rawValue)
// 解析成功且是对象/数组时,递归拍平内部结构
if (parsed && typeof parsed === 'object') {
return flattenToRows(parsed)
}
} catch {
// 解析失败则继续按下面的逻辑处理
}
}
}
// 普通对象:按 key → value 展开
if (typeof data === 'object' && data !== null && !Array.isArray(data)) {
return Object.entries(data as Record<string, unknown>).map(([k, v]) => [
k,
typeof v === 'string' ? v : JSON.stringify(v, null, 2),
])
}
// 根就是 JSON 字符串:尝试解析并展开
if (typeof data === 'string') {
try {
const parsed = JSON.parse(data)
if (parsed && typeof parsed === 'object') {
return flattenToRows(parsed)
}
} catch {
// 不是合法 JSON则按原始字符串展示
}
}
const str = typeof data === 'string' ? data : JSON.stringify(data, null, 2)
return [['value', str]]
}
function ToolLabel({ label, desc }: { label: string, desc: string }) {
return <div className="flex items-baseline justify-between mb-4">
<h2 className="text-[14px] font-bold text-muted-foreground uppercase tracking-[0.2em]">
{label}
</h2>
<div className="mx-4 h-px flex-1 bg-border/80" />
<span className="text-[11px] font-mono text-muted-foreground">
{desc}
</span>
</div>
}
/**
* 输入参数详情面板(参考设计稿样式)
*/
function InputPanel({ data }: { data: unknown }) {
const rows = flattenToRows(data)
if (!rows.length) return null
const [first, ...rest] = rows
const pairs: Array<Array<[string, string]>> = []
for (let i = 0; i < rest.length; i += 2) {
pairs.push(rest.slice(i, i + 2))
}
return (
<div className="space-y-6">
{/* 顶部标题行 */}
<ToolLabel label="输入参数" desc={`${rows.length} keys`} />
<div className="grid grid-cols-1 gap-y-6">
{/* 首行:做成醒目的大块 */}
{first && (
<div className="group">
<label className="mb-1.5 block text-[10px] font-bold uppercase tracking-wider text-muted-foreground transition-colors group-hover:text-primary">
{first[0]}
</label>
<div className="rounded-lg border border-border bg-card/84 px-3 py-2.5 font-mono text-sm text-foreground transition-all group-hover:border-primary/40">
{first[1]}
</div>
</div>
)}
{/* 后续参数,两列栅格,自动铺排 */}
{pairs.map((rowGroup, idx) => (
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 md:gap-8" key={idx}>
{rowGroup.map(([key, val]) => (
<div className="group" key={key}>
<label className="mb-1.5 block text-[10px] font-bold uppercase tracking-wider text-muted-foreground transition-colors group-hover:text-primary">
{key}
</label>
<div className="break-all font-mono text-sm text-foreground">
{val}
</div>
</div>
))}
</div>
))}
</div>
</div>
)
}
/**
* 工具调用预览组件
* - 输入参数table 展示
* - 输出结果:浅色主题代码块
*/
export function ToolCallPreview({
toolName,
eventArguments,
toolInput,
toolOutput,
className,
}: ToolCallPreviewProps) {
// info_search_web复刻 Remix 的 WebSearch 展示逻辑(列表 + 可点击链接)
if (toolName === 'info_search_web') {
const searchQuery = extractFirstArgument(eventArguments)
return (
<WebSearchPreview
results={toolOutput}
searchQuery={searchQuery}
className={className}
/>
)
}
const outputCode = formatData(toolOutput, toolName)
const outputArtifact = extractToolOutputArtifact({
path: '',
file_name: toolName || 'tool_output',
file_type: 'tool_call',
event_type: 'tool_call',
tool_name: toolName,
event_arguments: eventArguments,
tool_input: toolInput,
tool_output: toolOutput,
}) ?? extractArrayToolOutputArtifact(toolOutput, toolName)
if (outputArtifact) {
return (
<div className={cn('h-full overflow-hidden', className)}>
<ToolOutputArtifactPreview artifact={outputArtifact} />
</div>
)
}
// 对脚本类工具,优先尝试用 UrlScriptPreview复用 ShellExecutePreview 风格)
const isScriptTool = !!toolName && toolName.toLowerCase().includes('script')
const scriptUrl = isScriptTool ? extractScriptUrl(toolOutput) : null
return (
<div className={cn('flex flex-col h-full', className)}>
<ScrollArea className="flex-1">
<div className="p-6 space-y-8 mt-[50px]">
{/* 工具名称 */}
{toolName && (
<div className="mb-6">
<ToolLabel label="工具调用" desc={toolName} />
<div className="inline-flex items-center rounded-md bg-primary/10 px-3 py-1.5 font-mono text-sm text-primary">
{toolName}
</div>
</div>
)}
{/* 输入参数 - Table */}
{toolInput != null && (
<div>
<InputPanel data={toolInput} />
</div>
)}
{/* 输出结果 */}
{toolOutput != null && (
<div>
<ToolLabel label="输出结果" desc="200 ok" />
{/* 脚本工具 + 有 URL用 UrlScriptPreview内部再用 ShellExecutePreview */}
{!outputArtifact && scriptUrl && (
<div className="rounded-lg border border-border overflow-hidden">
<UrlScriptPreview url={scriptUrl} title={toolName} />
</div>
)}
{/* 其他情况:走原来的浅色代码高亮 */}
{!scriptUrl && (
<ShellExecutePreview output={outputCode} />
)}
</div>
)}
</div>
</ScrollArea>
</div>
)
}
export default ToolCallPreview

View File

@@ -0,0 +1,233 @@
import React from 'react'
import { cn } from '@/utils/cn'
import type { ImageAttachment, TaskArtifact } from '../../types'
import { useNovaKit } from '../../context/useNovaKit'
import { isImageFile } from '../utils'
import { TaskArtifactHtml } from '@/components/html-editor'
import { Html } from '@/components/html-editor/components/html-render/task-html'
import PptPreview from '@/components/ppt-editor'
import { ImageAttachmentItem } from '../../message-list/message-item/ImageAttachmentItem'
import { UrlScriptPreview } from './UrlScriptPreview'
import { ShellExecutePreview } from './ShellExecutePreview'
import { MarkdownContent, MarkdownPreview } from './MarkdownPreview'
import { CsvPreview } from './CsvPreview'
import { VirtualPdfPreview } from './VirtualPdfPreview'
import { isScriptLikeFile, normalizeArtifactFileType } from './previewUtils'
export interface ToolOutputArtifactPreviewProps {
artifact: TaskArtifact
className?: string
}
const PREVIEW_FILE_TYPES = ['xlsx', 'xls', 'doc', 'docx']
const TEXT_LIKE_FILE_TYPES = ['txt', 'text', 'json', 'log', 'xml', 'yaml', 'yml']
export function ToolOutputArtifactPreview({
artifact,
className,
}: ToolOutputArtifactPreviewProps) {
const { api, conversationId, mode } = useNovaKit()
const [url, setUrl] = React.useState('')
const [isUrlLoading, setIsUrlLoading] = React.useState(false)
const editable = mode === 'chat'
const normalizedFileType = normalizeArtifactFileType(
artifact.file_type,
artifact.file_name,
artifact.path,
)
React.useEffect(() => {
const directUrl = artifact.url || (/^https?:\/\//.test(artifact.path) ? artifact.path : '')
if (directUrl) {
setUrl(directUrl)
setIsUrlLoading(false)
return
}
if (artifact.path) {
setIsUrlLoading(true)
setUrl('')
api
.getArtifactUrl?.(
artifact,
PREVIEW_FILE_TYPES.includes(normalizedFileType)
? {
'x-oss-process': 'doc/preview,print_1,copy_1,export_1',
}
: undefined,
)
.then(res => {
const originUrl = typeof res?.data === 'string' ? res.data : ''
if (PREVIEW_FILE_TYPES.includes(normalizedFileType)) {
const shortUrl = originUrl.replace(
'oss-cn-hangzhou.aliyuncs.com',
'betteryeah.com',
)
setUrl(
shortUrl
? `${shortUrl}&x-oss-process=doc%2Fpreview%2Cprint_1%2Ccopy_1%2Cexport_1`
: '',
)
} else {
setUrl(originUrl)
}
setIsUrlLoading(false)
})
.catch(() => {
setIsUrlLoading(false)
})
return
}
setIsUrlLoading(false)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [artifact.path, artifact.url, normalizedFileType])
const isImage =
isImageFile(artifact.path) ||
isImageFile(artifact.file_name) ||
['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg', 'bmp'].includes(normalizedFileType)
if (isImage) {
const imageAttachment: ImageAttachment = {
url: artifact.url || '',
path: artifact.path,
file_name: artifact.file_name,
file_url: artifact.url,
}
return (
<div className={cn('flex h-full items-center justify-center p-6 bg-muted/10', className)}>
<ImageAttachmentItem image={imageAttachment} />
</div>
)
}
const isHtml =
normalizedFileType === 'html' ||
artifact.file_name?.toLowerCase().endsWith('.html')
if (isHtml && artifact.content && !artifact.path) {
return <Html className={cn('h-full', className)} content={artifact.content} />
}
if (isHtml) {
return (
<div className={cn('h-full', className)}>
<TaskArtifactHtml
taskId={conversationId || ''}
taskArtifact={artifact}
editable={editable}
type="web"
/>
</div>
)
}
const isMarkdown =
normalizedFileType === 'md' ||
normalizedFileType === 'markdown' ||
artifact.file_name?.toLowerCase().endsWith('.md')
if (isMarkdown && url) {
return <div className={cn('h-full', className)}><MarkdownPreview url={url} /></div>
}
if (isMarkdown && artifact.content) {
return (
<div className={cn('h-full overflow-y-auto p-6', className)}>
<MarkdownContent content={artifact.content} />
</div>
)
}
const isPpt =
normalizedFileType === 'ppt' ||
normalizedFileType === 'pptx' ||
artifact.file_name?.toLowerCase().endsWith('.ppt') ||
artifact.file_name?.toLowerCase().endsWith('.pptx')
if (isPpt && url) {
return (
<div className={cn('h-full', className)}>
<PptPreview
url={url}
artifact={artifact}
taskId={conversationId || ''}
editable={editable}
/>
</div>
)
}
const isCsv =
normalizedFileType === 'csv' ||
artifact.file_name?.toLowerCase().endsWith('.csv')
if (isCsv && artifact.content) {
return <div className={cn('h-full', className)}><CsvPreview content={artifact.content} /></div>
}
if (isCsv && url) {
return <div className={cn('h-full', className)}><CsvPreview url={url} /></div>
}
const isPdf =
normalizedFileType === 'pdf' ||
artifact.file_name?.toLowerCase().endsWith('.pdf')
if (isPdf && url) {
return <div className={cn('h-full', className)}><VirtualPdfPreview url={url} /></div>
}
const isScript = isScriptLikeFile(artifact)
if (isScript && artifact.content) {
return (
<div className={cn('h-full', className)}>
<ShellExecutePreview output={artifact.content} toolLabel={artifact.file_name} />
</div>
)
}
const isTextLike = TEXT_LIKE_FILE_TYPES.includes(normalizedFileType)
if (isTextLike && artifact.content) {
return (
<div className={cn('h-full', className)}>
<ShellExecutePreview output={artifact.content} toolLabel={artifact.file_name} />
</div>
)
}
if ((isScript || isTextLike) && url) {
return <div className={cn('h-full', className)}><UrlScriptPreview url={url} title={artifact.file_name} /></div>
}
if (url) {
return (
<iframe
src={url}
className={cn('w-full h-full border-0', className)}
title={artifact.file_name}
/>
)
}
if (isUrlLoading) {
return (
<div className={cn('flex h-full items-center justify-center p-8 text-muted-foreground', className)}>
<div className="text-sm">...</div>
</div>
)
}
return (
<div className={cn('flex h-full items-center justify-center p-8 text-muted-foreground', className)}>
<div className="text-sm"></div>
</div>
)
}
export default ToolOutputArtifactPreview

View File

@@ -0,0 +1,70 @@
import React from 'react'
import { ShellExecutePreview } from './ShellExecutePreview'
export interface UrlScriptPreviewProps {
url: string
title?: string
}
/**
* 基于 URL 加载脚本内容并用 ScriptPreview 渲染
* - 保持 nova-sdk 自包含,不依赖宿主应用的 Preview 组件
* - 由外部控制传入的 url通常来自后端签名地址
*/
export function UrlScriptPreview({ url, title }: UrlScriptPreviewProps) {
const [code, setCode] = React.useState('')
const [loading, setLoading] = React.useState(true)
const [error, setError] = React.useState(false)
React.useEffect(() => {
let cancelled = false
setLoading(true)
setError(false)
fetch(url)
.then(res => {
if (!res.ok) {
throw new Error(`Failed to fetch script file: ${res.status}`)
}
return res.text()
})
.then(text => {
if (!cancelled) {
setCode(text)
}
})
.catch(() => {
if (!cancelled) {
setError(true)
}
})
.finally(() => {
if (!cancelled) {
setLoading(false)
}
})
return () => {
cancelled = true
}
}, [url])
if (error) {
return (
<div className="flex-1 flex flex-col items-center justify-center p-8 text-muted-foreground">
<span className="text-sm"></span>
</div>
)
}
return (
<ShellExecutePreview
output={code}
toolLabel={title || 'script_file'}
loading={loading}
/>
)
}
export default UrlScriptPreview

View File

@@ -0,0 +1,217 @@
'use client'
import { useEffect, useRef, useState } from 'react'
import { useDebounce, useSize } from 'ahooks'
import { useVirtualizer } from '@tanstack/react-virtual'
import type { pdfjs as PdfJsType } from 'react-pdf'
import 'react-pdf/dist/Page/TextLayer.css'
import 'react-pdf/dist/Page/AnnotationLayer.css'
type ReactPdfModule = typeof import('react-pdf')
type PdfComponents = {
Document: ReactPdfModule['Document'] | null
Page: ReactPdfModule['Page'] | null
pdfjs: ReactPdfModule['pdfjs'] | null
}
type PdfLike = {
numPages: number
getPage: (pageNumber: number) => Promise<{ view: number[] }>
}
function useReactPdf() {
const [components, setComponents] = useState<PdfComponents>({
Document: null,
Page: null,
pdfjs: null,
})
useEffect(() => {
// 仅在浏览器环境下加载 react-pdf避免 Node.js 中触发 pdf.js 的 DOM 依赖
if (typeof window === 'undefined') return
let cancelled = false
;(async () => {
try {
const mod: ReactPdfModule = await import('react-pdf')
if (cancelled) return
const { Document, Page, pdfjs } = mod
// 配置 pdf.js worker
;(pdfjs as typeof PdfJsType).GlobalWorkerOptions.workerSrc =
`https://unpkg.com/pdfjs-dist@${pdfjs.version}/build/pdf.worker.min.mjs`
setComponents({
Document,
Page,
pdfjs,
})
} catch (error) {
console.error('Failed to load react-pdf:', error)
}
})()
return () => {
cancelled = true
}
}, [])
return components
}
export function VirtualPdfPreview({ url }: { url: string }) {
const [numPages, setNumPages] = useState<number | null>(null)
const [renderedPages, setRenderedPages] = useState<number[]>([])
const [pageHeight, setPageHeight] = useState(0)
const [aspectRatio, setAspectRatio] = useState(0)
const [errorText, setErrorText] = useState<string | null>(null)
const wrapperRef = useRef<HTMLDivElement>(null)
const parentRef = useRef<HTMLDivElement>(null)
const oldPageHeight = useRef(0)
const size = useSize(wrapperRef)
const containerWidth = useDebounce(size?.width, { wait: 200 })
const { Document, Page } = useReactPdf()
const virtualizer = useVirtualizer({
count: numPages || 0,
getScrollElement: () => parentRef.current,
estimateSize: () => (pageHeight || 800) + 10,
overscan: 4,
enabled: !!pageHeight,
})
useEffect(() => {
setRenderedPages([])
setErrorText(null)
}, [containerWidth, url])
useEffect(() => {
if (containerWidth && aspectRatio) {
virtualizer.shouldAdjustScrollPositionOnItemSizeChange = () => false
const newHeight = !aspectRatio || !containerWidth ? 800 : containerWidth / aspectRatio
const lastPageIndex = oldPageHeight.current
? Number(virtualizer.scrollOffset ?? 0) / oldPageHeight.current
: 0
setPageHeight(newHeight)
oldPageHeight.current = newHeight
virtualizer.measure()
if (parentRef.current) {
setTimeout(() => {
parentRef.current?.scrollTo({
top: lastPageIndex * newHeight,
behavior: 'auto',
})
}, 100)
}
}
}, [containerWidth, aspectRatio, virtualizer])
const onDocumentLoadSuccess = async (pdf: PdfLike) => {
setErrorText(null)
setNumPages(pdf.numPages)
const pageObj = await pdf.getPage(1)
const pageWidth = pageObj.view[2]
const firstPageHeight = pageObj.view[3]
const ratio = Number((pageWidth / firstPageHeight).toFixed(2))
setAspectRatio(ratio)
setRenderedPages([])
}
const handlePageRenderSuccess = (pageNumber: number) => {
setRenderedPages(prev => (prev.includes(pageNumber) ? prev : [...prev, pageNumber]))
}
if (errorText) {
return (
<div className="h-full w-full flex items-center justify-center text-sm text-destructive px-4 text-center">
PDF {errorText}
</div>
)
}
// 浏览器端尚未加载到 react-pdf给一个轻量的占位
if (!Document || !Page) {
return (
<div className="h-full w-full flex items-center justify-center text-sm text-muted-foreground px-4 text-center">
PDF ...
</div>
)
}
return (
<div ref={wrapperRef} className="w-full h-full mt-[80px]">
<div
ref={parentRef}
className="h-full overflow-scroll [&_.react-pdf__message.react-pdf__message--loading]:h-full [&::-webkit-scrollbar]:hidden scroll-smooth [-webkit-overflow-scrolling:touch]"
>
<Document
file={url}
loading={
<div className="h-full w-full flex items-center justify-center text-sm text-muted-foreground">
PDF ...
</div>
}
onLoadError={(error) => {
console.error('react-pdf load failed:', error)
setErrorText(error instanceof Error ? error.message : '未知错误')
}}
onLoadSuccess={onDocumentLoadSuccess}
>
<div
style={{
height: `${virtualizer.getTotalSize()}px`,
width: '100%',
position: 'relative',
}}
>
{virtualizer.getVirtualItems().map(virtualRow => (
<div
key={virtualRow.index}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
transform: `translateY(${virtualRow.start}px)`,
}}
className="w-full flex justify-center duration-200 transition-transform"
>
{!renderedPages.includes(virtualRow.index + 1) && (
<div
className="absolute top-0 left-0 right-0 z-1 flex items-center justify-center bg-card/94 text-sm text-muted-foreground duration-200 transition-transform backdrop-blur-sm"
style={{
height: `${pageHeight}px`,
}}
>
PDF ...
</div>
)}
<Page
loading={
<div className="h-full w-full flex items-center justify-center text-sm text-muted-foreground">
PDF ...
</div>
}
pageNumber={virtualRow.index + 1}
width={containerWidth}
onRenderSuccess={() => {
handlePageRenderSuccess(virtualRow.index + 1)
}}
/>
</div>
))}
</div>
</Document>
</div>
</div>
)
}

View File

@@ -0,0 +1,211 @@
import {
Clock3,
Globe,
Search,
Sparkles,
} from 'lucide-react'
import { ScrollArea } from '@/components/ui/scroll-area'
import { cn } from '@/utils/cn'
export interface WebSearchItem {
url?: string
title?: string
snippet?: string
date?: string
position?: number
[key: string]: unknown
}
export interface WebSearchPreviewProps {
/** 搜索结果列表(通常来自 info_search_web 的 tool_output */
results?: unknown
/** 查询词,优先来自 tool_input.arguments[0] */
searchQuery?: string | null
className?: string
}
function normalizeResults(results: unknown): WebSearchItem[] {
if (!Array.isArray(results)) return []
return results
.filter(item => item && typeof item === 'object')
.map(item => item as WebSearchItem)
.filter(item => typeof item.url === 'string' && !!item.url)
}
function getHostText(url?: string): string {
if (!url) return 'Unknown source'
try {
return new URL(url).hostname.replace(/^www\./, '')
} catch {
return 'Unknown source'
}
}
function getFaviconUrl(url?: string): string | null {
if (!url) return null
try {
const host = new URL(url).hostname
return `https://www.google.com/s2/favicons?domain=${host}&sz=64`
} catch {
return null
}
}
function formatDateLabel(date?: string): string {
if (!date) return ''
const normalized = date.trim()
if (!normalized) return ''
// 常见相对时间(如 "3 days ago")直接保留
if (/\b(ago|yesterday|today)\b/i.test(normalized)) {
return normalized
}
const parsed = new Date(normalized)
if (Number.isNaN(parsed.getTime())) {
return normalized
}
return new Intl.DateTimeFormat('zh-CN', {
year: 'numeric',
month: 'short',
day: 'numeric',
}).format(parsed)
}
export function WebSearchPreview({ results, searchQuery, className }: WebSearchPreviewProps) {
const items = normalizeResults(results)
const queryText = searchQuery?.trim() || items[0]?.title || 'Search for premium insights...'
if (!items.length) {
return (
<div className={cn('flex-1 h-full flex items-center justify-center p-8 text-muted-foreground', className)}>
<div className="flex items-center gap-2 text-sm">
<Search className="h-4 w-4" />
</div>
</div>
)
}
return (
<div
className={cn(
'flex h-full flex-col bg-background pt-[56px] text-foreground',
className,
)}
>
<ScrollArea className="flex-1">
<div className="min-h-full">
<header className="sticky top-0 z-10 border-b border-border/80 bg-background/92 backdrop-blur-sm">
<div className="px-4 md:px-8 py-4">
<div className="flex items-center justify-between gap-4">
<div className="flex items-center gap-4 flex-1">
<div className="flex shrink-0 items-center gap-2 text-primary">
<Sparkles className="h-6 w-6" />
<h2 className="hidden md:block text-lg font-bold tracking-tight">Search</h2>
</div>
<div className="relative group flex-1">
<Search className="absolute left-4 top-1/2 h-4 w-4 -translate-y-1/2 text-primary" />
<input
readOnly
value={queryText}
className="w-full rounded-full border-2 border-primary/30 bg-card/76 py-2.5 pl-11 pr-4 text-sm text-foreground outline-none backdrop-blur-sm placeholder:text-muted-foreground focus-visible:outline-none md:text-base"
/>
</div>
</div>
<div className="flex items-center gap-2">
<div className="flex h-9 w-9 items-center justify-center rounded-full border border-primary/20 bg-primary/10 text-xs font-semibold text-primary">
AI
</div>
</div>
</div>
</div>
</header>
<main className="px-4 md:px-8 py-4">
<div className="mb-6 flex items-center justify-between">
<p className="text-sm text-muted-foreground">
About {items.length.toLocaleString()} results
</p>
</div>
<div className="space-y-9">
{items.map((item, idx) => {
const favicon = getFaviconUrl(item.url)
const host = getHostText(item.url)
return (
<article key={`${item.url}-${idx}`} className="group max-w-3xl">
<div className="flex items-center gap-3 mb-2">
<div className="flex h-6 w-6 items-center justify-center overflow-hidden rounded-md border border-border/80 bg-card">
{favicon ? (
<img src={favicon} alt={`${host} favicon`} className="w-full h-full object-contain" />
) : (
<Globe className="h-3.5 w-3.5 text-muted-foreground" />
)}
</div>
<div className="flex flex-col min-w-0">
<span className="truncate text-xs font-medium text-foreground/78">
{host}
</span>
<span className="truncate text-[10px] text-muted-foreground">
{item.url}
</span>
</div>
</div>
<a
href={item.url}
target="_blank"
rel="noopener noreferrer"
className="mb-2 inline-block text-lg font-semibold leading-tight text-primary underline-offset-4 hover:underline md:text-xl wrap-break-word"
>
{item.title || item.url}
</a>
{item.snippet && (
<p className="line-clamp-3 text-sm leading-relaxed text-foreground/74 md:text-base">
{item.snippet}
</p>
)}
<div className="mt-2 flex items-center gap-3 text-xs text-muted-foreground">
{item.date && (
<span className="flex items-center gap-1">
<Clock3 className="h-3.5 w-3.5" />
{formatDateLabel(item.date)}
</span>
)}
{typeof item.position === 'number' && item.position <= 3 && (
<span className="inline-flex items-center text-[10px] font-semibold uppercase tracking-wider text-primary">
Top Result
</span>
)}
</div>
</article>
)
})}
</div>
</main>
<footer className="sticky bottom-0 mt-auto border-t border-border/80 bg-secondary/55 px-4 py-6 backdrop-blur-sm md:px-8">
<div className="flex flex-col md:flex-row justify-between items-center gap-5">
<div className="flex items-center gap-5 text-sm text-muted-foreground">
<span className="cursor-default transition-colors hover:text-foreground">Help Center</span>
<span className="cursor-default transition-colors hover:text-foreground">Privacy</span>
<span className="cursor-default transition-colors hover:text-foreground">Terms</span>
</div>
<div className="text-xs text-muted-foreground">
Powered by Web Search Tool
</div>
</div>
</footer>
</div>
</ScrollArea>
</div>
)
}
export default WebSearchPreview

View File

@@ -0,0 +1,31 @@
export { ToolCallPreview } from './ToolCallPreview'
export type { ToolCallPreviewProps } from './ToolCallPreview'
export { ScriptPreview } from './ScriptPreview'
export type { ScriptPreviewProps } from './ScriptPreview'
export { UrlScriptPreview } from './UrlScriptPreview'
export type { UrlScriptPreviewProps } from './UrlScriptPreview'
export { ShellExecutePreview } from './ShellExecutePreview'
export type { ShellExecutePreviewProps } from './ShellExecutePreview'
export { MarkdownPreview, MarkdownContent } from './MarkdownPreview'
export type { MarkdownPreviewProps, MarkdownContentProps } from './MarkdownPreview'
export { PptPreview } from './PptPreview'
export type { PptPreviewProps, SlideItem } from './PptPreview'
export { CsvPreview } from './CsvPreview'
export type { CsvPreviewProps } from './CsvPreview'
export { VirtualPdfPreview } from './VirtualPdfPreview'
export { HighlighterProvider, HighlighterContext } from './HighlighterProvider'
export type { HighlighterProviderProps } from './HighlighterProvider'
export { useHighlighter } from './useHighlighter'
export * from './previewUtils'
export { WebSearchPreview } from './WebSearchPreview'
export type { WebSearchPreviewProps, WebSearchItem } from './WebSearchPreview'

View File

@@ -0,0 +1,82 @@
import { describe, expect, it } from 'vitest'
import type { TaskArtifact } from '../../types'
import { extractToolOutputArtifact, normalizeArtifactFileType } from './previewUtils'
describe('normalizeArtifactFileType', () => {
it('normalizes common mime types', () => {
expect(normalizeArtifactFileType('text/markdown')).toBe('md')
expect(normalizeArtifactFileType('image/svg+xml')).toBe('svg')
expect(
normalizeArtifactFileType(
'application/vnd.openxmlformats-officedocument.presentationml.presentation',
),
).toBe('pptx')
})
it('falls back to file extension', () => {
expect(normalizeArtifactFileType('', 'report.csv')).toBe('csv')
expect(normalizeArtifactFileType(undefined, undefined, '/tmp/demo.py')).toBe('py')
})
})
describe('extractToolOutputArtifact', () => {
const baseArtifact: TaskArtifact = {
path: 'tool-call-1',
file_name: '工具调用',
file_type: 'tool_call',
event_type: 'tool_call',
tool_name: 'file_create',
}
it('extracts remote file artifacts from tool_output', () => {
const artifact = extractToolOutputArtifact({
...baseArtifact,
tool_output: {
path: '/upload/result/index.html',
file_name: 'index.html',
file_type: 'text/html',
},
})
expect(artifact).toEqual({
path: '/upload/result/index.html',
file_name: 'index.html',
file_type: 'html',
content: undefined,
url: undefined,
task_id: undefined,
})
})
it('extracts inline preview content when only file_type and content exist', () => {
const artifact = extractToolOutputArtifact({
...baseArtifact,
tool_output: {
file_name: 'notes.md',
file_type: 'text/markdown',
content: '# Title',
},
})
expect(artifact).toEqual({
path: '',
file_name: 'notes.md',
file_type: 'md',
content: '# Title',
url: undefined,
task_id: undefined,
})
})
it('ignores non-file tool outputs', () => {
expect(
extractToolOutputArtifact({
...baseArtifact,
tool_output: {
status: 'ok',
message: 'done',
},
}),
).toBeNull()
})
})

View File

@@ -0,0 +1,392 @@
import type { TaskArtifact } from '../../types'
/**
* 工具类型枚举
*/
export enum ToolType {
SHELL_EXECUTE = 'shell_execute',
SCRIPT_FILE = 'script_file',
OTHER = 'other',
}
/**
* 脚本文件扩展名
*/
const SCRIPT_EXTENSIONS = ['py', 'js', 'ts', 'sh', 'bash', 'zsh', 'fish', 'rb', 'php', 'go', 'rs', 'java', 'kt', 'swift']
const INLINE_PREVIEW_EXTENSIONS = [
'md',
'markdown',
'txt',
'html',
'csv',
'json',
...SCRIPT_EXTENSIONS,
]
const MIME_TYPE_ALIASES: Record<string, string> = {
'text/markdown': 'md',
'text/x-markdown': 'md',
'application/markdown': 'md',
'text/html': 'html',
'application/pdf': 'pdf',
'text/csv': 'csv',
'application/csv': 'csv',
'application/json': 'json',
'text/plain': 'txt',
'image/jpeg': 'jpg',
'image/jpg': 'jpg',
'image/png': 'png',
'image/gif': 'gif',
'image/webp': 'webp',
'image/svg+xml': 'svg',
'image/bmp': 'bmp',
'application/vnd.ms-powerpoint': 'ppt',
'application/vnd.openxmlformats-officedocument.presentationml.presentation': 'pptx',
'application/vnd.ms-excel': 'xls',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': 'xlsx',
'application/msword': 'doc',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document': 'docx',
}
const TOOL_OUTPUT_FILE_TYPE_FIELDS = ['file_type', 'type', 'mime_type']
const TOOL_OUTPUT_FILE_NAME_FIELDS = ['file_name', 'name', 'title']
const TOOL_OUTPUT_PATH_FIELDS = ['path', 'file_path', 'file_url', 'url', 'download_url']
const TOOL_OUTPUT_CONTENT_FIELDS = ['content', 'text', 'body', 'source', 'code', 'file_content']
function toObject(data: unknown): Record<string, unknown> | null {
if (!data) return null
if (typeof data === 'string') {
try {
const parsed = JSON.parse(data)
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
return parsed as Record<string, unknown>
}
} catch {
return null
}
}
if (typeof data === 'object' && !Array.isArray(data)) {
return data as Record<string, unknown>
}
return null
}
function getCandidateObjects(data: Record<string, unknown>): Record<string, unknown>[] {
const candidates = [data]
for (const key of ['result', 'data', 'file']) {
const nested = data[key]
if (nested && typeof nested === 'object' && !Array.isArray(nested)) {
candidates.push(nested as Record<string, unknown>)
}
}
return candidates
}
function pickFirstString(
objects: Record<string, unknown>[],
fields: string[],
): string {
for (const obj of objects) {
for (const field of fields) {
const value = obj[field]
if (typeof value === 'string' && value.trim()) {
return value.trim()
}
}
}
return ''
}
function getBaseName(path?: string): string {
if (!path) return ''
const cleanPath = path.split(/[?#]/)[0]
const segments = cleanPath.split('/').filter(Boolean)
return segments[segments.length - 1] || ''
}
function isInlinePreviewFileType(fileType: string): boolean {
return INLINE_PREVIEW_EXTENSIONS.includes(fileType)
}
function isAbsoluteHttpUrl(value?: string): boolean {
return !!value && /^https?:\/\//.test(value)
}
/**
* 从路径中提取文件扩展名
*/
export function getFileExtension(path?: string): string {
if (!path) return ''
const baseName = getBaseName(path) || path
const match = baseName.match(/\.([^.]+)$/)
return match ? match[1].toLowerCase() : ''
}
/**
* 统一文件类型,兼容扩展名与 MIME type
*/
export function normalizeArtifactFileType(
fileType?: string,
fileName?: string,
path?: string,
): string {
const normalizedFileType = (fileType || '')
.trim()
.toLowerCase()
.split(';')[0]
if (normalizedFileType) {
if (MIME_TYPE_ALIASES[normalizedFileType]) {
return MIME_TYPE_ALIASES[normalizedFileType]
}
if (normalizedFileType.includes('/')) {
const subtype = normalizedFileType.split('/').pop() || ''
if (subtype === 'svg+xml') return 'svg'
if (subtype.includes('markdown')) return 'md'
if (subtype.includes('presentationml.presentation')) return 'pptx'
if (subtype.includes('ms-powerpoint')) return 'ppt'
if (subtype.includes('spreadsheetml.sheet')) return 'xlsx'
if (subtype.includes('ms-excel')) return 'xls'
if (subtype.includes('wordprocessingml.document')) return 'docx'
if (subtype.includes('msword')) return 'doc'
return subtype
}
return normalizedFileType
}
return getFileExtension(fileName) || getFileExtension(path)
}
/**
* 从 tool_input 中提取 file_path
*/
export function getFilePathFromInput(input: unknown): string {
try {
let obj: Record<string, unknown> | null = null
if (typeof input === 'string') {
obj = JSON.parse(input)
} else if (input && typeof input === 'object') {
obj = input as Record<string, unknown>
}
if (obj && typeof obj.file_path === 'string') {
return obj.file_path
}
} catch {
// ignore
}
return ''
}
/**
* 判断普通文件是否是脚本类型(根据扩展名)
*/
export function isScriptLikeFile(artifact: TaskArtifact): boolean {
const extFromPath = getFileExtension(artifact.path)
const extFromName = getFileExtension(artifact.file_name)
const extFromType = normalizeArtifactFileType(
artifact.file_type,
artifact.file_name,
artifact.path,
)
const ext = extFromPath || extFromName || extFromType
if (!ext) return false
return SCRIPT_EXTENSIONS.includes(ext)
}
/**
* 从 tool_output 中提取可直接预览的文件 artifact
*/
export function extractToolOutputArtifact(
artifact: TaskArtifact,
): TaskArtifact | null {
const rawOutput = toObject(artifact.tool_output)
if (!rawOutput) return null
const candidates = getCandidateObjects(rawOutput)
const outputPath = pickFirstString(candidates, TOOL_OUTPUT_PATH_FIELDS)
const outputName = pickFirstString(candidates, TOOL_OUTPUT_FILE_NAME_FIELDS)
const outputType = pickFirstString(candidates, TOOL_OUTPUT_FILE_TYPE_FIELDS)
const normalizedType = normalizeArtifactFileType(outputType, outputName, outputPath)
const outputContent = pickFirstString(candidates, TOOL_OUTPUT_CONTENT_FIELDS)
if (!normalizedType && !outputPath && !outputName) {
return null
}
const canPreviewInline = !!outputContent && isInlinePreviewFileType(normalizedType)
if (!outputPath && !canPreviewInline) {
return null
}
const fallbackBaseName = artifact.tool_name || artifact.file_name || 'tool_output'
const fallbackFileName = normalizedType
? `${fallbackBaseName}.${normalizedType}`
: fallbackBaseName
const fileName = outputName || getBaseName(outputPath) || fallbackFileName
return {
path: outputPath || '',
file_name: fileName,
file_type: normalizedType || getFileExtension(fileName),
content: canPreviewInline ? outputContent : undefined,
url: isAbsoluteHttpUrl(outputPath) ? outputPath : undefined,
task_id: artifact.task_id,
}
}
/**
* 判断工具类型
*/
export function detectToolType(artifact: TaskArtifact): ToolType {
const toolName = artifact.tool_name
// 1. shell_execute 特殊处理
if (toolName === 'shell_execute') {
return ToolType.SHELL_EXECUTE
}
// 2. 检查文件扩展名
const filePath = getFilePathFromInput(artifact.tool_input)
const fileExt =
getFileExtension(filePath) ||
getFileExtension(artifact.path) ||
getFileExtension(artifact.file_name) ||
normalizeArtifactFileType(artifact.file_type, artifact.file_name, artifact.path)
if (SCRIPT_EXTENSIONS.includes(fileExt)) {
return ToolType.SCRIPT_FILE
}
// 3. 检查工具名称关键字
if (toolName && (
toolName.toLowerCase().includes('execute') ||
toolName.toLowerCase().includes('shell') ||
toolName.toLowerCase().includes('code') ||
toolName.toLowerCase().includes('script') ||
toolName.toLowerCase().includes('python') ||
toolName.toLowerCase().includes('javascript') ||
toolName.toLowerCase().includes('node') ||
toolName.toLowerCase().includes('bash') ||
toolName.toLowerCase().includes('cmd')
)) {
return ToolType.SCRIPT_FILE
}
return ToolType.OTHER
}
/**
* 移除 ANSI 转义序列
*/
export function removeAnsiCodes(text: string): string {
return text.replace(/\x1b\[[0-9;]*m/g, '')
}
/**
* 从数据中提取字符串
*/
function extractFromObject(data: unknown, fields: string[]): string | null {
let obj: Record<string, unknown> | null = null
// 解析 JSON 字符串
if (typeof data === 'string') {
try {
const parsed = JSON.parse(data)
if (parsed && typeof parsed === 'object') {
obj = parsed
} else if (typeof parsed === 'string') {
return parsed
}
} catch {
return data
}
} else if (data && typeof data === 'object') {
obj = data as Record<string, unknown>
}
// 从对象中提取字段
if (obj) {
for (const field of fields) {
const value = obj[field]
if (typeof value === 'string' && value) {
return value
}
}
}
return null
}
/**
* 提取 shell_execute 输出
*/
export function extractShellOutput(output: unknown): string {
// 处理数组
if (Array.isArray(output)) {
if (output.length === 1 && typeof output[0] === 'string') {
return removeAnsiCodes(output[0])
}
return removeAnsiCodes(output.filter(item => typeof item === 'string').join('\n'))
}
// 解析 JSON
if (typeof output === 'string') {
try {
const parsed = JSON.parse(output)
if (Array.isArray(parsed)) {
if (parsed.length === 1 && typeof parsed[0] === 'string') {
return removeAnsiCodes(parsed[0])
}
return removeAnsiCodes(parsed.filter(item => typeof item === 'string').join('\n'))
}
} catch {
return removeAnsiCodes(output)
}
}
// 从对象中提取
const result = extractFromObject(output, ['output', 'result', 'stdout'])
if (result) {
return removeAnsiCodes(result)
}
return JSON.stringify(output, null, 2)
}
/**
* 提取脚本代码
*/
export function extractScriptCode(input: unknown): string {
const codeFields = [
'file_content',
'content',
'code',
'script',
'command',
'cmd',
'source',
'text',
'body',
]
const result = extractFromObject(input, codeFields)
return result || JSON.stringify(input, null, 2)
}
/**
* 获取显示标题
*/
export function getDisplayTitle(artifact: TaskArtifact): string {
const filePath = getFilePathFromInput(artifact.tool_input)
return filePath || artifact.file_name || artifact.tool_name || '未命名'
}

View File

@@ -0,0 +1,49 @@
import { useEffect, useState } from 'react'
import { createHighlighterCore, type HighlighterCore } from 'shiki/core'
import { createOnigurumaEngine } from 'shiki/engine/oniguruma'
export function useHighlighter(_highlighter?: HighlighterCore | null) {
const [highlighter, setHighlighter] = useState<HighlighterCore | null>(null)
useEffect(() => {
if (_highlighter) {
return
}
createHighlighterCore({
themes: [
import('@shikijs/themes/one-light'),
import('@shikijs/themes/vitesse-dark'),
import('@shikijs/themes/snazzy-light'),
import('@shikijs/themes/everforest-light'),
import('@shikijs/themes/github-dark-default'),
],
langs: [
import('@shikijs/langs/css'),
import('@shikijs/langs/javascript'),
import('@shikijs/langs/tsx'),
import('@shikijs/langs/jsx'),
import('@shikijs/langs/xml'),
import('@shikijs/langs/html'),
import('@shikijs/langs/python'),
import('@shikijs/langs/sh'),
import('@shikijs/langs/json'),
import('@shikijs/langs/sql'),
import('@shikijs/langs/nginx'),
import('@shikijs/langs/mermaid'),
import('@shikijs/langs/markdown'),
],
engine: createOnigurumaEngine(import('shiki/wasm')),
}).then(highlighter => {
setHighlighter(highlighter)
})
}, [_highlighter])
useEffect(() => {
return () => {
highlighter?.dispose()
}
}, [highlighter])
return _highlighter || highlighter
}

View File

@@ -0,0 +1,257 @@
import React, { useState, useCallback, useMemo, useEffect, useRef } from 'react'
import { cn } from '@/utils/cn'
import type { TaskArtifact } from '../types'
import { ArtifactList } from './ArtifactList'
import { isImageFile } from './utils'
import { ArtifactPreview } from './ArtifactPreview'
import { useEventStore } from '../store/useEventStore'
import { extractToolOutputArtifact } from './Preview/previewUtils'
import { useNovaKit } from '../context/useNovaKit'
import { Dialog, DialogContent } from '@/components/ui/dialog'
export interface TaskPanelProps {
/** 文件列表 */
artifacts: TaskArtifact[]
/** 是否可见 */
visible?: boolean
/** 面板宽度 */
width?: number | string
/** 获取文件 URL 的函数 */
getUrl?: (artifact: TaskArtifact) => string | Promise<string>
/** 下载文件回调 */
onDownload?: (artifact: TaskArtifact) => void
/** 关闭面板回调 */
onClose?: () => void
/** 自定义类名 */
className?: string
/** 初始选中的文件 */
initialSelected?: TaskArtifact | null
}
/**
* 任务面板组件 - 展示图片和文件
*/
function InnerTaskPanel({
artifacts: artifactsProp,
visible = true,
width = '50%',
getUrl,
onDownload,
onClose,
className,
initialSelected,
}: TaskPanelProps) {
const { panelMode } = useNovaKit()
const [selectedArtifact, setSelectedArtifact] = useState<TaskArtifact | null>(initialSelected || null)
const [fromFileList, setFromFileList] = useState(false)
// 记录用户是否已主动选择过文件,避免流式消息到来时强制刷新预览
const userSelectedRef = useRef(false)
// 从 store 获取 events 和 artifacts
const events = useEventStore((state) => state.events)
const artifactsFromStore = useEventStore((state) => state.artifacts)
// 将 tool_call 类型的 events 转换为 artifacts
const toolCallArtifacts = useMemo((): TaskArtifact[] => {
return events
.filter((event) => event.event_type === 'tool_call')
.map((event) => {
const actionType = (event.content?.action_type as string | undefined) || undefined
const toolName = event.content?.tool_name as string | undefined
const actionName = event.content?.action_name as string | undefined
const isSkillLoader =
actionType === 'skill_loader' ||
actionName === 'skill_loader' ||
toolName === 'skill_loader'
const base: TaskArtifact = {
path: event.event_id,
file_name: toolName || '工具调用',
file_type: 'tool_call',
event_type: 'tool_call',
action_type: isSkillLoader
? 'skill_loader'
: actionType || actionName || toolName,
tool_name: toolName,
event_arguments: event.content?.arguments,
tool_input: event.content?.tool_input,
tool_output: event.content?.tool_output,
}
// Skill Loader按照 remix 中的逻辑,作为 Markdown 文件渲染
if (isSkillLoader) {
const metaToolName = (event.content?.metadata as Record<string, unknown>)?.tool_name as string | undefined
const output = event.content?.tool_output
const content =
typeof output === 'string'
? output
: output != null
? JSON.stringify(output, null, 2)
: ''
return {
...base,
file_type: 'md',
file_name: metaToolName || base.file_name || 'Skill 文档',
content,
}
}
const outputArtifact = extractToolOutputArtifact(base)
if (outputArtifact) {
return outputArtifact
}
return base
})
}, [events])
// 合并所有 artifacts优先使用 store 中的,然后是 props 传入的,最后是 tool_call
const allArtifacts = useMemo(() => {
// 如果 store 中有数据,优先使用 store
const baseArtifacts = artifactsFromStore.length > 0 ? artifactsFromStore : artifactsProp
const merged = [...baseArtifacts, ...toolCallArtifacts]
// 过滤掉 http(s) URL 中,看起来不像「文件」的条目(避免普通网页链接出现在文件列表)
return merged.filter((artifact) => {
const path = artifact.path || artifact.file_name || ''
// 不是 http(s) 链接的,一律保留
if (!/^https?:\/\//.test(path)) return true
try {
const url = new URL(path)
const pathname = url.pathname || ''
// 没有路径(如 https://example.com——按非文件处理过滤掉
if (!pathname || pathname === '/') return false
// 根据路径末尾扩展名判断是否是「文件」
const lastSegment = pathname.split('/').filter(Boolean).pop() || ''
const match = lastSegment.match(/\.([a-zA-Z0-9]+)$/)
if (!match) return false
const ext = match[1].toLowerCase()
const fileLikeExts = [
'pdf',
'png',
'jpg',
'jpeg',
'gif',
'webp',
'csv',
'ppt',
'pptx',
'doc',
'docx',
'xls',
'xlsx',
'md',
'markdown',
'txt',
'json',
]
// 只有在扩展名属于常见文件类型时,才当作文件保留;否则视为网页链接,过滤掉
return fileLikeExts.includes(ext)
} catch {
// URL 解析失败时保留,避免误删正常路径
return true
}
})
}, [artifactsFromStore, artifactsProp, toolCallArtifacts])
// 仅在 initialSelected 实际变化时同步(不依赖 allArtifacts避免流推时反复触发
useEffect(() => {
if (initialSelected) {
userSelectedRef.current = false
setSelectedArtifact(initialSelected)
setFromFileList(false)
}
}, [initialSelected])
// 只有「当前没有选中」时才自动选中单一文件,防止流式推送中途重置预览
useEffect(() => {
if (!initialSelected && allArtifacts.length === 1) {
setSelectedArtifact(prev => prev ?? allArtifacts[0])
setFromFileList(false)
}
// 用 length 而非整个 allArtifacts避免每次新事件导致数组引用变化而触发
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [allArtifacts.length, initialSelected])
// 筛选出所有图片
const images = useMemo(() => {
return allArtifacts.filter(a => isImageFile(a.path))
}, [allArtifacts])
// 选择文件
const handleSelect = useCallback((artifact: TaskArtifact) => {
userSelectedRef.current = true
setSelectedArtifact(artifact)
setFromFileList(true)
}, [])
// 返回列表
const handleBack = useCallback(() => {
setSelectedArtifact(null)
setFromFileList(false)
}, [])
const panel = (
<div
className={cn(
'h-full flex flex-col rounded-none border-l border-border',
'transition-all duration-300 ease-in-out',
className
)}
style={{ width: panelMode === 'dialog' ? '100%' : width }}
>
{/* 内容区 */}
<div className="flex-1 overflow-hidden relative">
{selectedArtifact || (allArtifacts.length === 1 && allArtifacts[0]) ? (
<div className="absolute inset-0 animate-in fade-in-0 slide-in-from-right-4 duration-300">
<ArtifactPreview
artifact={selectedArtifact || allArtifacts[0]}
fromFileList={fromFileList}
images={images}
getUrl={getUrl}
onBack={allArtifacts.length > 1 ? handleBack : undefined}
onDownload={onDownload}
onClose={onClose}
/>
</div>
) : (
<div className="absolute inset-0 animate-in fade-in-0 slide-in-from-left-4 duration-300">
<ArtifactList
artifacts={allArtifacts}
onClick={handleSelect}
selected={selectedArtifact}
className="h-full"
/>
</div>
)}
</div>
</div>
)
if (panelMode === 'dialog') {
return (
<Dialog open={visible} onOpenChange={(open) => { if (!open) onClose?.() }}>
<DialogContent className="max-w-5xl w-[90vw] h-[80vh] p-0 overflow-hidden">
{panel}
</DialogContent>
</Dialog>
)
}
return panel
}
export const TaskPanel = React.memo(InnerTaskPanel)
export default TaskPanel
// 导出子组件
export { ArtifactList } from './ArtifactList'
export { ArtifactPreview } from './ArtifactPreview'

View File

@@ -0,0 +1,24 @@
/**
* 获取文件扩展名
*/
function getFileExtension(path: string): string {
const parts = path.split('.')
return parts.length > 1 ? parts[parts.length - 1].toLowerCase() : ''
}
/**
* 判断是否是图片文件
*/
export function isImageFile(path: string): boolean {
const ext = getFileExtension(path.replace(/\?.*$/, ''))
return ['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg', 'bmp'].includes(ext)
}
export function safeParse(val: string) {
try {
return JSON.parse(val)
} catch {
return {}
}
}

View File

@@ -0,0 +1,150 @@
# Nova SDK Tools
这个目录包含了从 Remix 迁移的工具相关组件用于管理自定义工具SKILL、MCP、API
## 目录结构
```
tools/
├── components/ # 主要组件
│ ├── api-form.tsx # API 工具表单
│ ├── custom-list-view.tsx # 自定义工具列表视图
│ ├── message-mcp.tsx # MCP 消息组件
│ ├── mcp-json-editor.tsx # MCP JSON 编辑器
│ ├── skill-form.tsx # SKILL 工具表单
│ ├── tool-item.tsx # 工具项组件
│ └── tool-type-badge.tsx # 工具类型徽章
├── settings/ # 设置相关组件
│ ├── ai-parse-config.tsx # AI 解析配置
│ ├── example-popover.tsx # 示例弹出框
│ ├── mcp-editor-modal.tsx # MCP 编辑模态框
│ └── mcp-store-popover.tsx # MCP 商店弹出框
├── hooks/ # React Hooks
│ └── useExampleList.ts # 获取示例列表
├── form-components/ # 表单组件
│ ├── path-form-item.tsx # 路径表单项
│ ├── form-header-item.tsx # 表单头项
│ ├── nest-form-request-form-item/ # 嵌套表单请求项
│ ├── env-manage/ # 环境管理
│ └── parse-curl/ # 解析 curl
└── index.ts # 主导出文件
```
## 主要功能
### 1. SKILL 工具管理
- `SkillForm`: 创建和编辑 SKILL 工具的表单组件
### 2. MCP 工具管理
- `MCPEditorModal`: MCP 服务编辑模态框
- `MCPJsonEditor`: JSON 格式的 MCP 配置编辑器
- `MessageMCP`: MCP 工具消息展示组件
- `ExamplePopover`: MCP 示例选择器
- `AiParseConfig`: AI 辅助解析 MCP 配置
- `McpStorePopover`: MCP 市场链接
### 3. API 工具管理
- `ApiForm`: 创建和编辑 API 工具的表单组件
- 各种表单组件支持 API 参数配置
### 4. 工具列表视图
- `MCPListView`: 自定义工具列表视图,包含 SKILL、MCP、API 三种类型
## 使用示例
### 导入组件
```typescript
// 导入所有工具组件
import { SkillForm, MCPEditorModal, MCPListView } from '@/nova-sdk/tools'
// 或者按需导入
import { SkillForm } from '@/nova-sdk/tools/components/skill-form'
import { MCPEditorModal } from '@/nova-sdk/tools/settings/mcp-editor-modal'
import { MCPListView } from '@/nova-sdk/tools/components/custom-list-view'
```
### 使用 SKILL 表单
```typescript
import { SkillForm } from '@/nova-sdk/tools'
import type { SkillFormState } from '@/nova-sdk/tools'
function MyComponent() {
const handleSave = async (values: SkillFormState) => {
// 处理保存逻辑
console.log(values)
}
return (
<SkillForm
teamId="your-team-id"
onBack={() => console.log('back')}
onSave={handleSave}
/>
)
}
```
### 使用 MCP 编辑器
```typescript
import { MCPEditorModal } from '@/nova-sdk/tools'
function MyComponent() {
const handleFinish = async (values: any) => {
// 保存 MCP 配置
console.log(values)
}
return (
<MCPEditorModal
value={mcpConfig}
isEdit={false}
exampleList={exampleList}
onFinish={handleFinish}
/>
)
}
```
### 使用工具列表视图
```typescript
import { MCPListView } from '@/nova-sdk/tools'
function MyComponent() {
const handleViewChange = (view: ViewType) => {
console.log('View changed:', view)
}
return (
<MCPListView
teamId="your-team-id"
onViewChange={handleViewChange}
onClose={() => console.log('close')}
/>
)
}
```
## 依赖说明
这些组件依赖于以下外部库:
- `antd`: UI 组件库
- `ahooks`: React Hooks 工具库
- `@ebay/nice-modal-react`: 模态框管理
以及内部依赖:
- `@/store/tool`: 工具状态管理
- `@/store/mcp`: MCP 状态管理
- `@/components/icon`: 图标组件
- `@/components/modal`: 模态框组件
- `@apis/mindnote/*`: API 接口
## 注意事项
1. 迁移的组件保留了原有的功能和接口
2. 所有组件都使用 TypeScript 类型定义
3. 表单验证和错误处理逻辑保持不变
4. 确保在使用前正确配置相关 Store 和 API

View File

@@ -0,0 +1,61 @@
# Nova SDK Tools - 组件替换总结
## ✅ 已完成工作
### 1. 清理和简化
- ✅ 删除了所有 API 相关组件api-form, form-components
- ✅ 删除了列表查看功能custom-list-view, tool-item, tool-type-badge
- ✅ 删除了示例功能useExampleList, example-popover
- ✅ 删除了所有 localize 相关代码
### 2. 只保留 SKILL 和 MCP 组件
**剩余文件6个**
```
tools/
├── components/
│ ├── skill-form.tsx ✅ 已替换为 shadcn + lucide-react
│ ├── mcp-json-editor.tsx ✅ 已替换为 shadcn + lucide-react
│ └── message-mcp.tsx 🔄 待替换
├── settings/
│ ├── mcp-editor-modal.tsx 🔄 待替换
│ ├── ai-parse-config.tsx 🔄 待替换
│ └── mcp-store-popover.tsx 🔄 待替换
└── index.ts
```
### 3. UI 框架替换进度
**已完成2/6**
- ✅ skill-form.tsx - Button, Input, Textarea, Label, toast (shadcn) + Inbox (lucide-react)
- ✅ mcp-json-editor.tsx - Button, Textarea, toast (shadcn)
**待完成4/6**
- 🔄 message-mcp.tsx - 需替换 Spin, Button
- 🔄 mcp-editor-modal.tsx - 需替换 Button, Form, Input, message, Select, Tooltip
- 🔄 ai-parse-config.tsx - 需替换 Button, Form, Input, message, Popover, Tooltip
- 🔄 mcp-store-popover.tsx - 需替换 Popover, Tooltip
## 📋 替换方案
### antd → shadcn/ui 映射表
| antd | shadcn/ui |
|------|-----------|
| Button | Button |
| Input | Input |
| Input.TextArea | Textarea |
| Form | Label + Input/Textarea + 自定义验证 |
| Select | Select |
| message | toast (from @/hooks/use-toast) |
| Popover | Popover |
| Tooltip | Tooltip |
| Spin | Loader2 (from lucide-react) |
| Upload | 自定义文件上传 |
### @ant-design/icons → lucide-react 映射表
| antd icons | lucide-react |
|------------|--------------|
| InboxOutlined | Inbox |
| 其他图标 | 相应的 lucide-react 图标 |
## 🎯 下一步
继续替换剩余 4 个文件中的 antd 组件为 shadcn/ui

View File

@@ -0,0 +1,7 @@
// Skill Form
export { SkillForm } from './skill-form'
export type { SkillFormState } from './skill-form'
// MCP Components
export { MCPJsonEditor } from './mcp-json-editor'
export type { MCPJsonEditorRef } from './mcp-json-editor'

View File

@@ -0,0 +1,148 @@
import { useState, forwardRef, useImperativeHandle } from 'react'
import { Loader2 } from 'lucide-react'
import { useMemoizedFn } from 'ahooks'
import { Button } from '@/components/ui/button'
import { Textarea } from '@/components/ui/textarea'
import { toast } from 'sonner'
interface MCPJsonEditorProps {
value: string
onChange: (value: string) => void
onSave?: (parsedConfig: Record<string, unknown>) => void | Promise<void>
showButtons?: boolean
}
export interface MCPJsonEditorRef {
triggerSave: () => Promise<void>
}
export const MCPJsonEditor = forwardRef<MCPJsonEditorRef, MCPJsonEditorProps>(
({ value, onChange, onSave, showButtons = true }, ref) => {
const [jsonValue, setJsonValue] = useState(value)
const [isSaving, setIsSaving] = useState(false)
const parseJsonToConfig = useMemoizedFn(async () => {
if (isSaving) return
setIsSaving(true)
try {
const res = JSON.parse(jsonValue)
const { mcpServers = {} as Record<string, unknown> } = res
const mcpServer = Object.entries(mcpServers)[0]
if (!mcpServer) {
toast.error('未检测到合法的MCP配置')
return
}
const [key, value] = mcpServer as [string, Record<string, unknown>]
const code = key
const command = value.command ?? ''
const args = value.args ?? []
const env = value.env ?? {}
const url = value.url ?? ''
if ((!url && !command) || !code) {
toast.error('非法配置')
return
}
const type = command ? 'command' : 'sse'
const argsString = (args as string[]).join(' ')
const envString = Object.entries(env)
.map(([key, value]) => `${key}=${value}`)
.join('\n')
if (
type === 'command' &&
!['npx', 'uv', 'uvx'].includes(value.command as string ?? '')
) {
toast.error(`非法配置,不支持的命令: ${value.command}`)
return
}
const parsedConfig = {
type,
command,
code,
name: code,
args: argsString,
env: envString,
url,
headers: value.headers ?? '',
}
onChange(jsonValue)
await onSave?.(parsedConfig)
} catch {
toast.error('解析失败请检查JSON格式')
} finally {
setIsSaving(false)
}
})
const handleJsonChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
const newValue = e.target.value
setJsonValue(newValue)
onChange(newValue)
}
useImperativeHandle(ref, () => ({
triggerSave: parseJsonToConfig,
}))
return (
<div className='w-full h-full flex flex-col' style={{ pointerEvents: 'auto' }}>
<div className='flex-1 flex flex-col' style={{ pointerEvents: 'auto' }}>
<Textarea
className='flex-1 rounded-xl border border-input bg-card focus:border-primary/40 focus:ring-2 focus:ring-ring'
style={{
resize: 'none',
fontSize: '14px',
lineHeight: '1.5',
fontFamily: 'Monaco, Menlo, "Ubuntu Mono", consolas, "source-code-pro", monospace',
minHeight: '300px',
pointerEvents: 'auto',
zIndex: 1,
position: 'relative',
cursor: 'text',
caretColor: 'var(--primary)',
outline: 'none',
userSelect: 'text',
}}
autoFocus
placeholder={`仅支持解析一个MCP如果有多个仅解析第一个示例
{
"mcpServers": {
"RedNote MCP": {
"command": "npx",
"args": ["rednote-mcp", "--stdio"]
}
}
}
或者 SSE 方式:
{
"mcpServers": {
"Weather MCP": {
"url": "http://localhost:8000/mcp"
}
}
}`}
value={jsonValue}
onChange={handleJsonChange}
/>
</div>
{showButtons && (
<div className='shrink-0 pt-3 flex items-center justify-end gap-3'>
<Button onClick={parseJsonToConfig} disabled={isSaving}>
{isSaving && <Loader2 className='h-4 w-4 animate-spin' />}
{isSaving ? '解析中...' : '解析并保存'}
</Button>
</div>
)}
</div>
)
},
)
MCPJsonEditor.displayName = 'MCPJsonEditor'

View File

@@ -0,0 +1,147 @@
import { memo, useEffect, useState } from 'react'
import { useMemoizedFn } from 'ahooks'
import { cn } from '@/utils/cn'
import { Inbox } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Textarea } from '@/components/ui/textarea'
import { Label } from '@/components/ui/label'
import { toast } from 'sonner'
export interface SkillFormState {
name: string
description?: string
file?: { file: File; fileList: File[] }
}
interface SkillFormProps {
values?: { name: string; description?: string }
className?: string
teamId: string
onBack: () => void
onSave: (values: SkillFormState) => Promise<void>
}
const name_required_text = '请输入SKILL工具名称'
const file_tooltip_text = '仅支持上传 .zip 文件'
export const SkillForm = memo<SkillFormProps>(
({ className, values, onBack, onSave }) => {
const [name, setName] = useState(values?.name || '')
const [description, setDescription] = useState(values?.description || '')
const [file, setFile] = useState<File | null>(null)
const [loading, setLoading] = useState(false)
const isEdit = !!values
useEffect(() => {
if (values) {
setName(values.name)
setDescription(values.description || '')
}
}, [values])
const handleSave = useMemoizedFn(async () => {
if (!name.trim()) {
toast.error('错误')
return
}
if (!isEdit && !file) {
toast.error('错误')
return
}
try {
setLoading(true)
await onSave({
name,
description,
file: file ? { file: file as File, fileList: [file as File] } : undefined
})
} finally {
setLoading(false)
}
})
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const selectedFile = e.target.files?.[0]
if (selectedFile) {
const isZip = selectedFile.type === 'application/zip' || selectedFile.name.endsWith('.zip')
if (!isZip) {
toast.error('错误')
return
}
setFile(selectedFile)
}
}
return (
<div className='flex h-full flex-col bg-transparent text-foreground'>
<div className={cn('flex-1 p-24px overflow-y-auto space-y-6', className)}>
<div className='space-y-2 px-[4px]'>
<Label htmlFor='name' className='mb-[] text-sm font-medium text-foreground'></Label>
<Input
id='name'
value={name}
onChange={(e) => setName(e.target.value)}
placeholder={name_required_text}
maxLength={100}
className='mt-[8px] border-border bg-card text-foreground placeholder:text-muted-foreground focus-visible:border-primary focus-visible:ring-2 focus-visible:ring-primary/20 focus-visible:ring-offset-0'
/>
</div>
<div className='space-y-2 px-[4px]'>
<Label htmlFor='description' className='text-sm font-medium text-foreground'></Label>
<Textarea
id='description'
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder='请输入SKILL工具的描述'
rows={3}
className='mt-[8px] min-h-[120px] resize-y border-border bg-card text-foreground placeholder:text-muted-foreground focus-visible:border-primary focus-visible:ring-2 focus-visible:ring-primary/20 focus-visible:ring-offset-0'
/>
</div>
<div className='space-y-2 px-[4px]'>
<Label htmlFor='file' className='text-sm font-medium text-foreground'></Label>
<div className='relative mt-[8px] cursor-pointer rounded-lg border-2 border-dashed border-border bg-card p-8 text-center transition-colors hover:border-primary'>
<Inbox className='mx-auto mb-4 h-12 w-12 cursor-pointer text-muted-foreground' />
<input
id='file'
type='file'
accept='.zip,application/zip'
onChange={handleFileChange}
className='opacity-0 absolute top-0 left-0 w-full h-full'
/>
<label htmlFor='file' className='cursor-pointer'>
<p className='text-sm text-foreground'>
{file ? file.name : '点击或拖拽文件到这里上传'}
</p>
<p className='mt-2 text-xs text-muted-foreground'>{file_tooltip_text}</p>
</label>
</div>
</div>
</div>
<div className='mt-[16px] flex justify-end gap-8px border-t border-border/70 bg-transparent px-24px pb-24px pt-16px'>
<Button
variant='outline'
onClick={onBack}
className='mr-[8px] hover:bg-transparent hover:border-border hover:text-foreground'
>
</Button>
<Button
onClick={handleSave}
disabled={loading}
>
{loading ? '保存中...' : '保存'}
</Button>
</div>
</div>
)
},
)
SkillForm.displayName = 'SkillForm'

View File

@@ -0,0 +1,2 @@
// Hooks
// useExampleList removed - only create functionality needed

View File

@@ -0,0 +1,8 @@
// Skill Components
export * from './components/skill-form'
// MCP Components
export * from './components/mcp-json-editor'
// MCP Settings
export * from './settings/mcp-store-popover'

View File

@@ -0,0 +1,2 @@
// Settings Components
export { McpStorePopover } from './mcp-store-popover'

View File

@@ -0,0 +1,68 @@
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
import { memo, useState } from 'react'
import { ExternalLink, Store, Globe } from 'lucide-react'
const storeList = [
{
name: 'modelscope.cn',
link: 'https://www.modelscope.cn/mcp',
},
{
name: 'mcpmarket.cn',
link: 'https://mcpmarket.cn',
},
{
name: 'mcp.so',
link: 'https://mcp.so',
},
]
export const McpStorePopover = memo(() => {
const [menuOpen, setMenuOpen] = useState(false)
return (
<Popover open={menuOpen} onOpenChange={setMenuOpen}>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<PopoverTrigger asChild>
<button className="inline-flex items-center justify-center rounded-full w-7 h-7 bg-gray-100 text-gray-900 transition-colors hover:bg-gray-200">
<Store className="h-3.5 w-3.5" />
</button>
</PopoverTrigger>
</TooltipTrigger>
<TooltipContent>
<p>MCP </p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
<PopoverContent align="start" className="w-60 border border-border bg-popover p-1">
<div className="px-3 py-2 text-sm font-medium text-foreground">
MCP
</div>
<div className="flex flex-col gap-1 mt-1">
{storeList.map((item) => (
<button
type="button"
key={item.name}
className="group flex cursor-pointer items-center justify-between rounded-lg px-3 py-3 transition-colors hover:bg-accent"
onClick={() => {
setMenuOpen(false)
if (typeof window !== 'undefined') {
window.open(item.link, '_blank')
}
}}
>
<div className="flex items-center gap-1.5">
<Globe className="h-4 w-4 text-muted-foreground" />
<div className="text-sm text-foreground">{item.name}</div>
</div>
<ExternalLink className="h-4 w-4 text-muted-foreground opacity-0 transition-opacity group-hover:opacity-100" />
</button>
))}
</div>
</PopoverContent>
</Popover>
)
})

View File

@@ -0,0 +1,58 @@
import { describe, it, expect } from 'vitest'
import { EventType, TaskStatus } from './types'
describe('EventType constants', () => {
it('has correct UserInput value', () => {
expect(EventType.UserInput).toBe('user_input')
})
it('has correct Message value', () => {
expect(EventType.Message).toBe('message')
})
it('has correct ToolCall value', () => {
expect(EventType.ToolCall).toBe('tool_call')
})
it('has correct TaskUpdate value', () => {
expect(EventType.TaskUpdate).toBe('task_update')
})
it('has correct TaskEnd value', () => {
expect(EventType.TaskEnd).toBe('task_end')
})
it('has correct Error value', () => {
expect(EventType.Error).toBe('error')
})
})
describe('TaskStatus constants', () => {
it('has correct PENDING value', () => {
expect(TaskStatus.PENDING).toBe('pending')
})
it('has correct IN_PROGRESS value', () => {
expect(TaskStatus.IN_PROGRESS).toBe('in_progress')
})
it('has correct COMPLETED value', () => {
expect(TaskStatus.COMPLETED).toBe('completed')
})
it('has correct STOPPED value', () => {
expect(TaskStatus.STOPPED).toBe('stopped')
})
it('has correct PAUSED value', () => {
expect(TaskStatus.PAUSED).toBe('paused')
})
it('has correct FAILED value', () => {
expect(TaskStatus.FAILED).toBe('failed')
})
it('has correct CANCELLED value', () => {
expect(TaskStatus.CANCELLED).toBe('cancelled')
})
})

View File

@@ -0,0 +1,300 @@
/**
* Nova SDK Kit 组件类型定义
*/
import { useNovaService } from './hooks/useNovaService'
// 事件类型常量
export const EventType = {
UserInput: 'user_input',
Message: 'message',
ToolCall: 'tool_call',
TaskUpdate: 'task_update',
TaskEnd: 'task_end',
Error: 'error',
} as const
// 事件类型联合类型
export type EventType = typeof EventType[keyof typeof EventType]
// 任务状态常量(与接口 task_status 一致pending / in_progress / completed / stopped / paused / failed
export const TaskStatus = {
/** 等待开始 */
PENDING: 'pending',
/** 进行中 */
IN_PROGRESS: 'in_progress',
/** 已完成 */
COMPLETED: 'completed',
/** 已停止 */
STOPPED: 'stopped',
/** 暂停 */
PAUSED: 'paused',
/** 失败 */
FAILED: 'failed',
/** 已取消 */
CANCELLED: 'cancelled',
} as const
// 任务状态联合类型
export type TaskStatus = (typeof TaskStatus)[keyof typeof TaskStatus]
/** 终止态:遇到这些状态时关闭 loadingcompleted / stopped / failed / cancelled */
export const TERMINAL_TASK_STATUS: TaskStatus[] = [
TaskStatus.COMPLETED,
TaskStatus.STOPPED,
TaskStatus.FAILED,
TaskStatus.CANCELLED,
TaskStatus.PAUSED,
]
// 基础事件接口
export interface BaseEvent {
event_id: string
event_type: EventType | string
/** 规范化后的 action_type便于前端直接使用 */
action_type?: string
timestamp?: number
content?: {
text?: string
action_type?: string
metadata?: Record<string, unknown>
tool_name?: string
tool_input?: unknown
tool_output?: unknown
[key: string]: unknown
}
metadata?: {
isUserInput?: boolean
isTemp?: boolean
agent_id?: string
template_type?: string
template_id?: string
isTempSystemReply?: boolean
is_summary?: boolean
[key: string]: unknown
}
plan_step_state?: string
event_status?: string
stream?: boolean
}
// 消息内容
export interface MessageContent {
content?: string
text?: string
refer_content?: string
}
// Action 信息(工具调用/操作)
export interface ActionInfo {
name?: string
arguments?: string[]
action_type?: string
block?: boolean
tool_input?: unknown
tool_output?: unknown
}
// 操作
export interface Operation {
[key: string]: unknown
}
// MCP 内容
export interface McpContent {
[key: string]: unknown
}
// 计划配置
export interface PlanConfig {
steps?: Array<{
id: string
title: string
status?: string
[key: string]: unknown
}>
[key: string]: unknown
}
// Todo 列表配置
export interface TaskTodoConfig {
list: Array<{
id?: string
title?: string
status?: string
[key: string]: unknown
}>
}
// 用户交互(如选项、确认等)
export interface SlideQuestion {
question: string
type?: 'input' | 'single' | 'multiple'
options?: string[]
answer?: string
}
export interface UserInteraction {
type?: string
content?: string
/** 选项按钮choice 类型) */
choice_options?: Array<string | { label: string; disabled?: boolean; tooltip?: string }>
/** 幻灯片表单问题列表 */
questions?: SlideQuestion[]
/** 浏览器类型sandbox / local_browser */
browser_type?: string
/** 授权类型browser auth */
authorization_type?: string
tool_code?: string
event_id?: string
origin_text?: string
select_action?: string
[key: string]: unknown
}
// 附件
export interface Attachment {
file_name: string
file_type: string
file_url: string
path?: string
file_id?: string
}
// 图片附件
export interface ImageAttachment {
url: string
path?: string
file_name?: string
file_url?: string
}
// 上传文件
export interface UploadFile {
uid: string
name: string
type: string
byte_size?: number
url?: string
upload_file_id?: string
progress?: number
uploadStatus?: 'pending' | 'uploading' | 'success' | 'error'
}
// 消息项属性
export interface MessageItemProps {
base?: BaseEvent
content?: MessageContent
attachment?: Attachment[]
imageAttachment?: ImageAttachment[]
taskState?: TaskStatus
action?: ActionInfo
mcpContent?: McpContent
planConfig?: PlanConfig
taskTodoConfig?: TaskTodoConfig
operation?: Operation
userInteraction?: UserInteraction
}
// 扩展事件(用于渲染)
export interface ExtendedEvent extends BaseEvent {
renderProps?: MessageItemProps
}
/**
* API 原始事件类型 - 对应服务端 Event 结构(次于 next-agent-chat.type.ts 的 Event
* nova-sdk 内部以此类型处理原始事件流
*/
export interface ApiEvent {
event_id: string
event_type: string
event_status?: string
stream?: boolean
role?: string
task_id?: string
created_at?: string | number
timestamp?: string | number
plan_step_state?: string
is_display?: boolean
metadata?: Record<string, unknown>
content?: {
text?: string
content?: unknown
action_type?: string
tool_name?: string
action_name?: string
arguments?: unknown
tool_input?: unknown
tool_output?: unknown
tool_call_id?: string
metadata?: Record<string, unknown>
attachment_files?: Array<{
file_name?: string
file_type?: string
path?: string
[key: string]: unknown
}>
refer_content?: string
timestamp?: string | number
fast_mode?: boolean
[key: string]: unknown
}
}
// 发送消息的 Payload
export interface SendMessagePayload {
content: string
agent_id?: string
config?: {
label_ids?: string[]
search_custom_knowledge_enabled?: boolean
template_type?: string
template_id?: string
brand_id?: string
[key: string]: unknown
}
refer_content?: string
upload_file_ids?: string[]
}
// Artifact 类型
export interface TaskArtifact {
path: string
file_name: string
file_type: string
last_modified?: number
url?: string
content?: string
task_id?: string
// 工具调用相关
event_type?: string
/** 工具 action_type例如 skill_loader 等 */
action_type?: string
tool_name?: string
event_arguments?: unknown
tool_input?: unknown
tool_output?: unknown
from?: 'assistant' | 'user'
}
// Nova API 接口 - 从 useArtifactService 返回值自动推导
export type NovaAPI = ReturnType<typeof useNovaService>
// 消息列表 Provider 值
export interface NovaKitContextValue {
agentId?: string
agentName?: string
/** API 命名空间 */
api: NovaAPI
messageList: ExtendedEvent[][]
panelMode?: 'sidebar' | 'dialog'
taskStatus: TaskStatus
conversationId: string | null
isLoading: boolean
artifacts: TaskArtifact[]
setLoading: (loading: boolean) => void
loading: boolean
mode?: 'chat' | 'share'
}
export type HandleImageAttachmentClick = (image: ImageAttachment, from: 'assistant' | 'user') => void

View File

@@ -0,0 +1,420 @@
/**
* 事件渲染属性生成器
*
* 完整复刻 remix next-agent 的 getEventRenderProps 逻辑:
* event-types.ts → event-transformers.ts → event-processors.ts → event-handlers.ts
*
* 用于在 useEventProcessor 中将 ApiEvent 转换为 ExtendedEvent 时填充 renderProps。
*/
import type {
ApiEvent,
MessageItemProps,
ActionInfo,
McpContent,
PlanConfig,
TaskTodoConfig,
Operation,
UserInteraction,
Attachment,
ImageAttachment,
BaseEvent,
MessageContent,
} from '../types'
// ─────────────────────────────────────────────
// 事件类型 / 动作类型常量(对应 next-agent-chat.type.ts
// ─────────────────────────────────────────────
const ET = {
UserInput: 'user_input',
Text: 'text',
ToolCall: 'tool_call',
TaskUpdate: 'task_update',
TaskEnd: 'task_end',
UserInteraction: 'user_interaction',
MessageAskUser: 'message_ask_user',
AskUserReply: 'ask_user_reply',
StepSummary: 'step_summary',
Summary: 'summary',
} as const
const EA = {
TextJson: 'text_json',
Mcp: 'mcp',
McpTool: 'mcp_tool',
BrowserUseTakeover: 'browser_use_takeover',
SlideInit: 'slide_init',
MessageNotifyUser: 'message_notify_user',
StepSummary: 'step_summary',
Summary: 'summary',
AgentScheduleTask: 'agent_schedule_task',
} as const
const ES = {
RUNNING: 'running',
SUCCESS: 'success',
FAILED: 'failed',
} as const
/** task_update 等无 content 的事件类型集合 */
const CONTENTLESS_EVENTS = new Set<string>([ET.TaskUpdate])
// ─────────────────────────────────────────────
// 事件类型判断(对应 event-types.ts
// ─────────────────────────────────────────────
function isUserInputEvent(e: ApiEvent) {
return e.event_type === ET.UserInput
}
function isToolCallEvent(e: ApiEvent) {
return e.event_type === ET.ToolCall
}
function isPlanEvent(e: ApiEvent) {
return e.event_type === ET.Text && e.content?.action_type === EA.TextJson
}
function isMcpEvent(e: ApiEvent) {
return (
isToolCallEvent(e) &&
(e.content?.action_type === EA.Mcp || e.content?.action_type === EA.McpTool)
)
}
function isBrowserUseTakeoverEvent(e: ApiEvent) {
return e.content?.action_type === EA.BrowserUseTakeover
}
function isSlideOutlineEvent(e: ApiEvent) {
return isToolCallEvent(e) && e.content?.action_type === EA.SlideInit
}
function isAgentScheduleTaskEvent(e: ApiEvent) {
return e.content?.action_type === EA.AgentScheduleTask
}
function isMessageNotifyUserEvent(e: ApiEvent) {
return isToolCallEvent(e) && e.content?.action_type === EA.MessageNotifyUser
}
function isStepSummaryEvent(e: ApiEvent) {
return (
e.event_type === ET.StepSummary && e.content?.action_type === EA.StepSummary
)
}
function isStepSummaryLegacy(e: ApiEvent) {
return (
e.event_type === ET.Summary && e.content?.action_type === EA.Summary
)
}
/** 任务结束带文件text 事件 + attachment_files */
function isTaskEndWithFiles(e: ApiEvent) {
return (
e.event_type === ET.Text && !!e.content?.attachment_files?.length
)
}
function isImageFile(fileType?: string) {
return ['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg', 'bmp'].includes(
(fileType || '').toLowerCase(),
)
}
// ─────────────────────────────────────────────
// 数据转换工具(对应 event-transformers.ts
// ─────────────────────────────────────────────
/** 规范化动作类型:优先取 tool_namefallback action_type */
function normalizeActionType(e: ApiEvent): string {
return (e.content?.tool_name || e.content?.action_type || '') as string
}
/** 创建 base 属性(对应 createBaseEventProps */
function createBaseProps(e: ApiEvent): { base: BaseEvent } {
const normalizedTimestamp =
typeof e.timestamp === 'number'
? e.timestamp
: typeof e.content?.timestamp === 'number'
? e.content.timestamp
: undefined
return {
base: {
event_id: e.event_id,
event_type: e.event_type,
event_status: e.event_status,
stream: e.stream,
plan_step_state: e.plan_step_state,
action_type: normalizeActionType(e),
timestamp: normalizedTimestamp,
metadata: {
...(e.metadata as Record<string, unknown>),
...(e.content?.metadata as Record<string, unknown>),
isUserInput: isUserInputEvent(e),
isScheduleTask: isAgentScheduleTaskEvent(e),
},
},
}
}
/** 创建 action 属性(对应 createActionProps */
function createAction(e: ApiEvent): ActionInfo {
return {
name: (e.content?.action_name as string) || '',
arguments: e.content?.arguments as string[] | undefined,
action_type: normalizeActionType(e),
block: isPlanEvent(e) || isSlideOutlineEvent(e),
tool_input: e.content?.tool_input,
tool_output: e.content?.tool_output,
}
}
// ─────────────────────────────────────────────
// 附件处理(对应 event-processors.ts processEventAttachments
// ─────────────────────────────────────────────
function processAttachmentFiles(
files: NonNullable<ApiEvent['content']>['attachment_files'],
): Attachment[] {
if (!files?.length) return []
return files
.filter(f => f.file_name && f.file_type && f.path)
.map(f => ({
file_name: f.file_name!,
file_type: f.file_type!,
file_url: f.path!,
}))
}
function processEventAttachments(e: ApiEvent): [Attachment[], ImageAttachment[]] {
const attachments: Attachment[] = []
const imageAttachments: ImageAttachment[] = []
const toolOutput = e.content?.tool_output
// tool_output 中的单文件附件
if (
toolOutput &&
typeof toolOutput === 'object' &&
!Array.isArray(toolOutput)
) {
const to = toolOutput as Record<string, unknown>
if (to.path && to.file_type && to.file_name) {
attachments.push({
file_name: to.file_name as string,
file_type: to.file_type as string,
file_url: to.path as string,
})
}
}
// user_input / message_notify_user / step_summary 的 attachment_files
if (
(isUserInputEvent(e) ||
isMessageNotifyUserEvent(e) ||
isStepSummaryEvent(e) ||
isStepSummaryLegacy(e)) &&
e.content?.attachment_files?.length
) {
attachments.push(...processAttachmentFiles(e.content.attachment_files))
}
// 任务结束带文件text + attachment_files
if (isTaskEndWithFiles(e) && e.content?.attachment_files?.length) {
const all = processAttachmentFiles(e.content.attachment_files)
const imageFiles = all.filter(a => isImageFile(a.file_type))
imageAttachments.push(
...imageFiles.map(a => ({
url: a.file_url,
path: a.file_url,
file_name: a.file_name,
})),
)
const rest = all.filter(a => !isImageFile(a.file_type))
if (rest.length > 0) attachments.push(...rest.slice(0, 3))
}
return [attachments, imageAttachments]
}
// ─────────────────────────────────────────────
// 通用事件属性(对应 event-processors.ts createCommonEventProps
// ─────────────────────────────────────────────
function tryParseObject(str: string): Record<string, unknown> {
try {
const parsed = JSON.parse(str)
if (typeof parsed === 'object' && parsed !== null) return parsed
} catch {
// ignore
}
return {}
}
function createCommonProps(e: ApiEvent): Partial<MessageItemProps> {
const base = createBaseProps(e)
const [attachments, imageAttachments] = processEventAttachments(e)
// 计划事件text + text_json
if (isPlanEvent(e)) {
return {
...base,
planConfig: {
...tryParseObject(
typeof e.content?.content === 'string' ? e.content.content : '{}',
),
fast_mode: e.content?.fast_mode,
} as PlanConfig,
}
}
const result: Partial<MessageItemProps> = { ...base }
if (attachments.length > 0) result.attachment = attachments
if (imageAttachments.length > 0) result.imageAttachment = imageAttachments
// 文本内容
if (
!CONTENTLESS_EVENTS.has(e.event_type) &&
e.content?.content &&
typeof e.content.content === 'string'
) {
result.content = {
content: e.content.content,
refer_content: e.content.refer_content,
} as MessageContent
}
return result
}
// ─────────────────────────────────────────────
// 各事件类型处理器(对应 event-handlers.ts
// ─────────────────────────────────────────────
function handleDefault(e: ApiEvent): MessageItemProps {
return {
action: createAction(e),
...createCommonProps(e),
}
}
function handleToolCall(e: ApiEvent): MessageItemProps {
// 规范化 action_type
if (e.content) {
(e.content as Record<string, unknown>).action_type = normalizeActionType(e)
}
const baseAction = { action: createAction(e) }
// MCP 工具事件
if (isMcpEvent(e)) {
const meta = e.content?.metadata as Record<string, unknown> | undefined
return {
...createBaseProps(e),
...baseAction,
mcpContent: {
metadata: {
...meta,
name: (meta?.name || meta?.mcp_name) as string | undefined,
},
status:
!(meta?.is_auto_executed) &&
e.event_status !== ES.SUCCESS &&
e.event_status !== ES.FAILED
? 'ready'
: e.event_status,
tool_call_id: e.content?.tool_call_id,
action_type: e.content?.action_type,
tool_input: e.content?.tool_input,
tool_output: e.content?.tool_output,
} as McpContent,
}
}
// 浏览器接管事件
if (isBrowserUseTakeoverEvent(e)) {
return {
...createBaseProps(e),
...baseAction,
content: { content: '' },
operation: {
operation_type: e.content?.action_type,
content:
typeof e.content?.content === 'string' ? e.content.content : '',
} as Operation,
}
}
return { ...baseAction, ...createCommonProps(e) }
}
function handleTaskUpdate(e: ApiEvent): MessageItemProps {
const rawContent = e.content?.content
const list = Array.isArray(rawContent)
? rawContent
: rawContent &&
typeof rawContent === 'object' &&
Array.isArray((rawContent as { list?: unknown }).list)
? ((rawContent as { list: Record<string, unknown>[] }).list ?? [])
: []
return {
action: createAction(e),
...createCommonProps(e),
taskTodoConfig: {
list,
} as TaskTodoConfig,
}
}
function handleUserInteraction(e: ApiEvent): MessageItemProps {
if (e.content) {
(e.content as Record<string, unknown>).action_type = normalizeActionType(e)
}
const interactionContent = (
(e.content?.content as Record<string, unknown>) || {}
)
const { text, ...rest } = interactionContent
return {
action: createAction(e),
...createBaseProps(e),
userInteraction: { content: text as string | undefined, ...rest } as UserInteraction,
}
}
// ─────────────────────────────────────────────
// 主入口(对应 event-handlers.ts getEventRenderProps
// ─────────────────────────────────────────────
export function getEventRenderProps(event: ApiEvent): MessageItemProps {
switch (event.event_type) {
case ET.UserInput:
case ET.Text:
case ET.MessageAskUser:
case ET.AskUserReply:
return handleDefault(event)
case ET.ToolCall:
return handleToolCall(event)
case ET.TaskUpdate:
return handleTaskUpdate(event)
case ET.UserInteraction:
return handleUserInteraction(event)
default:
return {
action: {
action_type: normalizeActionType(event),
name: (event.content?.action_name as string) || '',
block: isSlideOutlineEvent(event),
},
...createCommonProps(event),
}
}
}

View File

@@ -0,0 +1,47 @@
import React from 'react'
import {
FileText,
ImageIcon,
Table2,
FileSpreadsheet,
Video,
Music,
Code,
File,
Globe,
PresentationIcon,
} from 'lucide-react'
export interface FileIconConfig {
icon: React.ElementType
color: string
}
export function getFileIconConfig(fileType: string): FileIconConfig {
const t = (fileType || '').toLowerCase()
if (['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg', 'bmp'].includes(t))
return { icon: ImageIcon, color: 'text-chart-2' }
if (t === 'pdf')
return { icon: FileText, color: 'text-destructive' }
if (['xls', 'xlsx'].includes(t))
return { icon: FileSpreadsheet, color: 'text-success' }
if (t === 'csv')
return { icon: Table2, color: 'text-success' }
if (['ppt', 'pptx'].includes(t))
return { icon: PresentationIcon, color: 'text-warning' }
if (['doc', 'docx'].includes(t))
return { icon: FileText, color: 'text-primary' }
if (['mp4', 'mov', 'avi', 'webm'].includes(t))
return { icon: Video, color: 'text-chart-3' }
if (['mp3', 'm4a', 'wav', 'ogg'].includes(t))
return { icon: Music, color: 'text-chart-4' }
if (['js', 'ts', 'tsx', 'jsx', 'py', 'sh', 'bash', 'json'].includes(t))
return { icon: Code, color: 'text-muted-foreground' }
if (['html', 'htm'].includes(t))
return { icon: Globe, color: 'text-brand' }
if (['md', 'txt'].includes(t))
return { icon: FileText, color: 'text-muted-foreground' }
return { icon: File, color: 'text-muted-foreground' }
}

View File

@@ -0,0 +1,87 @@
import type { ApiEvent } from '../types'
/**
* 检查是否为 slide_create_in_batches 事件
*/
export function isSlideCreateInBatchesEvent(
event: ApiEvent,
state?: 'start' | 'running' | 'done'
): boolean {
if (event.content?.action_type !== 'slide_create_in_batches') {
return false
}
if (!state) return true
const eventStatus = event.event_status as string | undefined
const isStream = !!event.stream
if (state === 'start') return !isStream && eventStatus === 'running'
if (state === 'running') return isStream && eventStatus === 'running'
if (state === 'done') return eventStatus === 'success'
return false
}
/**
* Slide 流式数据累积结果
*/
export interface SlideStreamData {
slideChunks: Map<string, string>
slidePages?: Array<{ html: string; status: 'completed' }>
}
/**
* 处理 slide 事件的流式数据累积
*/
export function processSlideEvents(rawEvents: ApiEvent[]): SlideStreamData {
const slideChunks = new Map<string, string>() // index -> content
let slidePages: Array<{ html: string; status: 'completed' }> | undefined
for (const event of rawEvents) {
// slide_create_in_batches 特殊处理:累积 chunks
if (isSlideCreateInBatchesEvent(event, 'running')) {
const chunk = event.content?.tool_input as { index?: number; content?: string } | undefined
if (chunk?.content) {
const key = (chunk.index || 1).toString()
const existing = slideChunks.get(key) || ''
slideChunks.set(key, existing + chunk.content)
}
}
if (isSlideCreateInBatchesEvent(event, 'done')) {
const output = event.content?.tool_output as { generated_files?: Array<{ index: number; content: string }> } | undefined
if (output?.generated_files) {
slidePages = output.generated_files.map((slide) => ({
html: slide.content,
status: 'completed' as const,
}))
}
}
}
return { slideChunks, slidePages }
}
/**
* 为事件附加 slide 元数据
*/
export function attachSlideMetadata(
event: ApiEvent,
slideData: SlideStreamData
): { pages?: Array<{ html: string; status: 'running' | 'completed' }>; isStreaming: boolean } | undefined {
if (!isSlideCreateInBatchesEvent(event)) {
return undefined
}
const { slideChunks, slidePages } = slideData
const streamPages = slideChunks.size > 0
? Array.from(slideChunks.entries())
.sort(([a], [b]) => Number(a) - Number(b))
.map(([, content]) => ({ html: content, status: 'running' as const }))
: undefined
return {
pages: slidePages || streamPages,
isStreaming: !slidePages && !!streamPages,
}
}