初始化模版工程
This commit is contained in:
80
components/nova-sdk/context/NovaKitProvider.tsx
Normal file
80
components/nova-sdk/context/NovaKitProvider.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
25
components/nova-sdk/context/context.ts
Normal file
25
components/nova-sdk/context/context.ts
Normal 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,
|
||||
})
|
||||
|
||||
|
||||
15
components/nova-sdk/context/useNovaKit.ts
Normal file
15
components/nova-sdk/context/useNovaKit.ts
Normal 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
|
||||
}
|
||||
|
||||
10
components/nova-sdk/hooks/index.ts
Normal file
10
components/nova-sdk/hooks/index.ts
Normal 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 '..'
|
||||
117
components/nova-sdk/hooks/useArtifactsExtractor.ts
Normal file
117
components/nova-sdk/hooks/useArtifactsExtractor.ts
Normal 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])
|
||||
}
|
||||
113
components/nova-sdk/hooks/useAttachmentHandlers.ts
Normal file
113
components/nova-sdk/hooks/useAttachmentHandlers.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
93
components/nova-sdk/hooks/useBuildConversationConnect.ts
Normal file
93
components/nova-sdk/hooks/useBuildConversationConnect.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
323
components/nova-sdk/hooks/useEventProcessor.ts
Normal file
323
components/nova-sdk/hooks/useEventProcessor.ts
Normal 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])
|
||||
}
|
||||
166
components/nova-sdk/hooks/useFileUploader.ts
Normal file
166
components/nova-sdk/hooks/useFileUploader.ts
Normal 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(','),
|
||||
}
|
||||
}
|
||||
25
components/nova-sdk/hooks/useMessageScroll.ts
Normal file
25
components/nova-sdk/hooks/useMessageScroll.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
51
components/nova-sdk/hooks/useMessageSender.ts
Normal file
51
components/nova-sdk/hooks/useMessageSender.ts
Normal 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
|
||||
}
|
||||
}
|
||||
232
components/nova-sdk/hooks/useNovaChatLogic.ts
Normal file
232
components/nova-sdk/hooks/useNovaChatLogic.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
}
|
||||
370
components/nova-sdk/hooks/useNovaEvents.ts
Normal file
370
components/nova-sdk/hooks/useNovaEvents.ts
Normal 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
|
||||
159
components/nova-sdk/hooks/useNovaService.ts
Normal file
159
components/nova-sdk/hooks/useNovaService.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
40
components/nova-sdk/hooks/usePanelState.ts
Normal file
40
components/nova-sdk/hooks/usePanelState.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
32
components/nova-sdk/hooks/useSize.ts
Normal file
32
components/nova-sdk/hooks/useSize.ts
Normal 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
|
||||
}
|
||||
37
components/nova-sdk/index.ts
Normal file
37
components/nova-sdk/index.ts
Normal 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'
|
||||
114
components/nova-sdk/message-input/FilePreviewList.tsx
Normal file
114
components/nova-sdk/message-input/FilePreviewList.tsx
Normal 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]
|
||||
}
|
||||
443
components/nova-sdk/message-input/index.tsx
Normal file
443
components/nova-sdk/message-input/index.tsx
Normal 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)
|
||||
229
components/nova-sdk/message-list/index.tsx
Normal file
229
components/nova-sdk/message-list/index.tsx
Normal file
@@ -0,0 +1,229 @@
|
||||
import React, { useRef, useEffect, useCallback, useState, forwardRef, useImperativeHandle } from 'react'
|
||||
import { ArrowDown, MessageCircle } from 'lucide-react'
|
||||
import { cn } from '@/utils/cn'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||
import type { ExtendedEvent, Attachment, HandleImageAttachmentClick } from '../types'
|
||||
import { TaskStatus } from '../types'
|
||||
import type { ApiEvent } from '../hooks/useNovaEvents'
|
||||
import { MessageItem } from './message-item'
|
||||
import useEventStore from '../store/useEventStore'
|
||||
|
||||
export interface MessageListProps {
|
||||
/** 消息列表(按步骤分组) */
|
||||
messages: ExtendedEvent[][]
|
||||
/** 任务状态 */
|
||||
taskStatus?: TaskStatus
|
||||
/** 自定义类名 */
|
||||
className?: string
|
||||
/** 是否自动滚动到底部 */
|
||||
autoScroll?: boolean
|
||||
/** 空状态渲染 */
|
||||
emptyRender?: React.ReactNode
|
||||
/** 加载中状态 */
|
||||
loading?: boolean
|
||||
/** 附件点击回调 */
|
||||
onAttachmentClick?: (attachment: Attachment) => void
|
||||
/** 图片附件点击回调 */
|
||||
onImageAttachmentClick?: HandleImageAttachmentClick
|
||||
/** 工具调用点击回调 */
|
||||
onToolCallClick?: (event: ApiEvent) => void
|
||||
/** 发送消息(用户交互回调) */
|
||||
onSendMessage?: (content: string) => void
|
||||
}
|
||||
|
||||
export interface MessageListRef {
|
||||
scrollToBottom: (behavior?: ScrollBehavior) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* 消息列表组件 - 展示对话流
|
||||
*/
|
||||
const InnerMessageList = forwardRef<MessageListRef, MessageListProps>(({
|
||||
messages,
|
||||
taskStatus = TaskStatus.PENDING,
|
||||
className,
|
||||
autoScroll = true,
|
||||
emptyRender,
|
||||
loading = false,
|
||||
onAttachmentClick,
|
||||
onImageAttachmentClick,
|
||||
onToolCallClick,
|
||||
onSendMessage,
|
||||
}, ref) => {
|
||||
const setProcessEvents = useEventStore(store=>store.setProcessEvents)
|
||||
const scrollRef = useRef<HTMLDivElement>(null)
|
||||
const bottomRef = useRef<HTMLDivElement>(null)
|
||||
const [showScrollButton, setShowScrollButton] = useState(false)
|
||||
const [showScrollTopButton, setShowScrollTopButton] = useState(false)
|
||||
const userTouchedRef = useRef(false)
|
||||
const autoScrollEnabledRef = useRef(autoScroll)
|
||||
|
||||
// 扁平化消息列表
|
||||
const flatMessages = messages.flat()
|
||||
|
||||
useEffect(() => {
|
||||
setProcessEvents(flatMessages)
|
||||
},[flatMessages])
|
||||
|
||||
// 根据 taskStatus + loading 统一计算「是否展示 loading」
|
||||
const isLoading =
|
||||
loading || taskStatus === TaskStatus.IN_PROGRESS
|
||||
|
||||
// 滚动到底部(使用 scrollIntoView)
|
||||
const scrollToBottom = useCallback((behavior: ScrollBehavior = 'smooth') => {
|
||||
// 等下一帧,确保 DOM 和高度都已经更新
|
||||
requestAnimationFrame(() => {
|
||||
if (bottomRef.current) {
|
||||
bottomRef.current.scrollIntoView({
|
||||
behavior,
|
||||
block: 'end',
|
||||
})
|
||||
}
|
||||
userTouchedRef.current = false
|
||||
autoScrollEnabledRef.current = true
|
||||
})
|
||||
}, [])
|
||||
|
||||
// 暴露滚动方法给父组件
|
||||
useImperativeHandle(ref, () => ({
|
||||
scrollToBottom,
|
||||
}), [scrollToBottom])
|
||||
|
||||
// 监听滚动事件
|
||||
const handleScroll = useCallback(() => {
|
||||
if (!scrollRef.current) return
|
||||
|
||||
const { scrollTop, scrollHeight, clientHeight } = scrollRef.current
|
||||
const distanceFromBottom = scrollHeight - scrollTop - clientHeight
|
||||
const isAtBottom = distanceFromBottom < 50
|
||||
|
||||
// 用户主动滚动
|
||||
if (!isAtBottom && !userTouchedRef.current) {
|
||||
userTouchedRef.current = true
|
||||
autoScrollEnabledRef.current = false
|
||||
}
|
||||
|
||||
setShowScrollButton(!isAtBottom && flatMessages.length > 3)
|
||||
setShowScrollTopButton(scrollTop > 50)
|
||||
}, [flatMessages.length])
|
||||
|
||||
// 消息变化时自动滚动
|
||||
useEffect(() => {
|
||||
if (autoScroll && autoScrollEnabledRef.current && !userTouchedRef.current) {
|
||||
const behavior = taskStatus === TaskStatus.IN_PROGRESS ? 'smooth' : 'auto'
|
||||
scrollToBottom(behavior)
|
||||
}
|
||||
}, [flatMessages.length, autoScroll, taskStatus, scrollToBottom])
|
||||
|
||||
|
||||
|
||||
// // 初始化滚动 - 延迟一段时间,等待历史消息和布局稳定
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
if (flatMessages.length > 0) {
|
||||
scrollToBottom('auto')
|
||||
}
|
||||
}, 0)
|
||||
|
||||
return () => clearTimeout(timer)
|
||||
}, [flatMessages.length])
|
||||
|
||||
// 空状态
|
||||
if (flatMessages.length === 0 && !isLoading) {
|
||||
return (
|
||||
<div className={cn('flex-1 flex items-center justify-center', className)}>
|
||||
{emptyRender || (
|
||||
<div className="text-center text-muted-foreground">
|
||||
<MessageCircle className="w-16 h-16 mx-auto mb-4 text-muted" />
|
||||
<p>暂无消息</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn('relative flex-1 h-full overflow-hidden', className)}>
|
||||
|
||||
{/* 底部渐变遮罩,消息穿过时淡出 */}
|
||||
<div className="pointer-events-none absolute bottom-0 left-0 right-0 h-32 z-10 bg-gradient-to-t from-white to-transparent backdrop-blur-[2px] [mask-image:linear-gradient(to_top,black_40%,transparent)]" />
|
||||
|
||||
{/* 消息列表滚动区域 - 使用 shadcn ScrollArea,ref 绑定在 Viewport 上 */}
|
||||
<ScrollArea
|
||||
ref={scrollRef}
|
||||
className="h-full"
|
||||
onScroll={handleScroll}
|
||||
>
|
||||
<div className="h-full px-4 pt-4 pb-36">
|
||||
<div className="px-4 max-w-2xl mx-auto w-full">
|
||||
{flatMessages.map((event, index) => (
|
||||
<MessageItem
|
||||
key={event.event_id || index}
|
||||
event={event}
|
||||
onAttachmentClick={onAttachmentClick}
|
||||
onImageAttachmentClick={onImageAttachmentClick}
|
||||
onToolCallClick={onToolCallClick}
|
||||
onSendMessage={onSendMessage}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* 加载中指示器:task_status === "in_progress" 或 loading=true 时显示,"paused" 时不显示 */}
|
||||
{isLoading && taskStatus !== TaskStatus.PAUSED && (
|
||||
<div className="flex items-center gap-3 mt-2 mb-2">
|
||||
<div className="w-7 h-7 rounded-lg bg-primary/10 flex items-center justify-center shrink-0 border border-primary/20">
|
||||
<span className="w-3 h-3 rounded-full border-2 border-primary border-t-transparent animate-spin inline-block" />
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="w-1.5 h-1.5 bg-primary/60 rounded-full animate-bounce" />
|
||||
<span
|
||||
className="w-1.5 h-1.5 bg-primary/60 rounded-full animate-bounce"
|
||||
style={{ animationDelay: '0.15s' }}
|
||||
/>
|
||||
<span
|
||||
className="w-1.5 h-1.5 bg-primary/60 rounded-full animate-bounce"
|
||||
style={{ animationDelay: '0.3s' }}
|
||||
/>
|
||||
<span className="ml-1 text-xs text-muted-foreground">正在处理...</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* 滚动锚点,保证始终能精确滚到底部,并预留 10px 间距 */}
|
||||
<div ref={bottomRef} className="h-[10px] w-full" />
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
{/* 右侧中部:滚动置顶 / 滚动到底部控制条 */}
|
||||
{(showScrollButton) && (
|
||||
<div className="pointer-events-none absolute zoom-in transition-all h-max bottom-2 left-0 right-0 mx-auto flex items-center justify-center">
|
||||
<div className="pointer-events-auto flex flex-col items-center gap-1 rounded-full border border-border bg-popover/88 p-1 backdrop-blur">
|
||||
{/* 底部 */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={cn(
|
||||
'h-8 w-8 rounded-full text-muted-foreground hover:text-primary hover:bg-accent transition-all',
|
||||
!showScrollButton && 'opacity-40 cursor-default hover:bg-transparent hover:text-muted-foreground'
|
||||
)}
|
||||
onClick={() => {
|
||||
if (!showScrollButton) return
|
||||
scrollToBottom()
|
||||
}}
|
||||
>
|
||||
<ArrowDown className="w-5 h-5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
InnerMessageList.displayName = 'MessageList'
|
||||
|
||||
export const MessageList = React.memo(InnerMessageList)
|
||||
export default MessageList
|
||||
|
||||
// 导出 MessageItem
|
||||
export { MessageItem } from './message-item'
|
||||
@@ -0,0 +1,41 @@
|
||||
import React from 'react'
|
||||
import type { Attachment } from '../../types'
|
||||
import { getFileIconConfig } from '../../utils/fileIcons'
|
||||
|
||||
export interface AttachmentItemProps {
|
||||
attachment: Attachment
|
||||
onClick?: (attachment: Attachment) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* 附件展示组件
|
||||
*/
|
||||
export function AttachmentItem({ attachment, onClick }: AttachmentItemProps) {
|
||||
const handleClick = (e: React.MouseEvent) => {
|
||||
if (onClick) {
|
||||
e.preventDefault()
|
||||
onClick(attachment)
|
||||
}
|
||||
}
|
||||
|
||||
const fileTypeFromMeta = attachment.file_type?.split('/')?.at(-1) ?? attachment.file_type
|
||||
const extFromName = attachment.file_name.split('.').pop() || ''
|
||||
const { icon: Icon, color } = getFileIconConfig(fileTypeFromMeta || extFromName)
|
||||
|
||||
return (
|
||||
<a
|
||||
href={attachment.file_url}
|
||||
target={onClick ? undefined : '_blank'}
|
||||
rel="noopener noreferrer"
|
||||
onClick={handleClick}
|
||||
className="inline-flex items-center gap-2 px-3 py-4 rounded-lg bg-background border border-border hover:bg-muted/70 transition-colors cursor-pointer"
|
||||
>
|
||||
<span className="flex items-center justify-center w-7 h-7 rounded-md bg-primary/10">
|
||||
<Icon className={`w-4 h-4 ${color}`} />
|
||||
</span>
|
||||
<span className="text-sm text-foreground truncate max-w-[220px]">
|
||||
{attachment.file_name}
|
||||
</span>
|
||||
</a>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { Globe, Monitor } from 'lucide-react'
|
||||
import { cn } from '@/utils/cn'
|
||||
import { useNovaKit } from '../../context/useNovaKit'
|
||||
import { ImagePreview } from '@/components/ui/image-preview'
|
||||
|
||||
export interface BrowserUseActionProps {
|
||||
/** 显示名称(action_name,如"正在浏览") */
|
||||
name?: string
|
||||
/** 参数数组,第一个通常是 URL */
|
||||
arguments?: string[]
|
||||
/** 工具原始输出(tool_output) */
|
||||
toolOutput?: unknown
|
||||
className?: string
|
||||
}
|
||||
|
||||
interface BrowserUseOutput {
|
||||
result?: {
|
||||
clean_screenshot_path?: string
|
||||
screenshot_path?: string
|
||||
url?: string
|
||||
title?: string
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* browser_use 工具调用专属渲染组件
|
||||
* 对应原始 task-computer-use.tsx,去除了 Antd / UnoCSS / store 依赖
|
||||
*/
|
||||
function InnerBrowserUseAction({
|
||||
name,
|
||||
arguments: args,
|
||||
toolOutput,
|
||||
className,
|
||||
}: BrowserUseActionProps) {
|
||||
const { api } = useNovaKit()
|
||||
const [screenshotUrl, setScreenshotUrl] = useState<string>()
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
const url = args?.[0] || ''
|
||||
|
||||
// 提取为字符串原始值作为 effect 依赖,只有路径真正变化才重新请求
|
||||
const screenshotPath =
|
||||
(toolOutput as BrowserUseOutput)?.result?.clean_screenshot_path ||
|
||||
(toolOutput as BrowserUseOutput)?.result?.screenshot_path
|
||||
|
||||
// 加载截图,api 不作为依赖(Context 每次渲染都会返回新引用,加入会反复触发)
|
||||
useEffect(() => {
|
||||
if (!screenshotPath) return
|
||||
|
||||
let cancelled = false
|
||||
|
||||
queueMicrotask(() => {
|
||||
if (cancelled) return
|
||||
setLoading(true)
|
||||
})
|
||||
|
||||
api
|
||||
.getArtifactUrl?.({ path: screenshotPath, file_name: '', file_type: 'png' })
|
||||
.then(res => {
|
||||
if (cancelled) return
|
||||
if (res?.data) setScreenshotUrl(res.data)
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => {
|
||||
if (cancelled) return
|
||||
setLoading(false)
|
||||
})
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [screenshotPath])
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-col rounded-xl border border-gray-200 overflow-hidden bg-white w-full max-w-[480px] mb-4',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{/* 标题栏 */}
|
||||
<div className="flex items-center gap-2 px-3 py-2 bg-gray-50 border-b border-gray-100">
|
||||
<Globe className="w-4 h-4 text-muted-foreground shrink-0" />
|
||||
<div className="flex flex-col min-w-0 flex-1">
|
||||
<span className="text-sm font-medium leading-tight">
|
||||
{name || '正在浏览'}
|
||||
</span>
|
||||
{url && (
|
||||
<span className="text-xs text-muted-foreground truncate">
|
||||
{url}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 内容区 */}
|
||||
<div
|
||||
className={cn(
|
||||
'relative bg-white',
|
||||
screenshotUrl || loading ? 'pt-[56.25%]' : 'py-8',
|
||||
)}
|
||||
>
|
||||
{loading && (
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<div className="w-6 h-6 border-2 border-muted border-t-primary rounded-full animate-spin" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{screenshotUrl && !loading && (
|
||||
<ImagePreview src={screenshotUrl} alt="browser screenshot">
|
||||
<img
|
||||
className="absolute inset-0 w-full h-full object-contain cursor-zoom-in"
|
||||
src={screenshotUrl}
|
||||
alt="browser screenshot"
|
||||
/>
|
||||
</ImagePreview>
|
||||
)}
|
||||
|
||||
{!screenshotUrl && !loading && (
|
||||
<div className="flex flex-col items-center justify-center gap-2 text-muted-foreground">
|
||||
<Monitor className="w-8 h-8 opacity-40" />
|
||||
<span className="text-xs">正在执行浏览器任务…</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const BrowserUseAction = React.memo(InnerBrowserUseAction)
|
||||
export default BrowserUseAction
|
||||
@@ -0,0 +1,72 @@
|
||||
import React from 'react'
|
||||
import { cn } from '@/utils/cn'
|
||||
import type { MessageContent, UserInteraction, BaseEvent } from '../../types'
|
||||
import { MarkdownContent } from '../../task-panel/Preview/MarkdownPreview'
|
||||
import { UserInteractionWidget } from './user-interaction'
|
||||
|
||||
export interface ContentMessageProps {
|
||||
content?: MessageContent
|
||||
userInteraction?: UserInteraction
|
||||
base?: BaseEvent
|
||||
isUserInput: boolean
|
||||
showUserFile: boolean
|
||||
onSendMessage?: (content: string) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* 内容消息组件 - 渲染文本内容与用户交互,对应 next-agent ContentMessage/FactCheck
|
||||
*/
|
||||
function InnerContentMessage({
|
||||
content,
|
||||
userInteraction,
|
||||
isUserInput,
|
||||
showUserFile,
|
||||
onSendMessage,
|
||||
}: ContentMessageProps) {
|
||||
const text = content?.content || content?.text
|
||||
|
||||
if (!text && !userInteraction) return null
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn('flex flex-col w-full', {
|
||||
'items-end': isUserInput,
|
||||
'items-start': !isUserInput,
|
||||
'mt-9 mb-0 px-3': showUserFile,
|
||||
'my-9': isUserInput && !showUserFile,
|
||||
})}
|
||||
>
|
||||
{text && (
|
||||
<div
|
||||
className={cn(
|
||||
'max-w-[80%]',
|
||||
isUserInput
|
||||
? 'bg-gray-900 text-white rounded-2xl rounded-tr-sm'
|
||||
: 'text-gray-900',
|
||||
)}
|
||||
>
|
||||
{isUserInput ? (
|
||||
<div className="whitespace-pre-wrap py-3 px-4 break-words leading-relaxed text-sm">
|
||||
{text}
|
||||
</div>
|
||||
) : (
|
||||
<MarkdownContent content={text} className="[&_p]:mt-0" />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{userInteraction && (
|
||||
<div className="w-full max-w-[80%]">
|
||||
<UserInteractionWidget
|
||||
userInteraction={userInteraction}
|
||||
disabled={false}
|
||||
onSendMessage={onSendMessage}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const ContentMessage = React.memo(InnerContentMessage)
|
||||
export default ContentMessage
|
||||
52
components/nova-sdk/message-list/message-item/FactCheck.tsx
Normal file
52
components/nova-sdk/message-list/message-item/FactCheck.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
import React from 'react'
|
||||
import { cn } from '@/utils/cn'
|
||||
import type { MessageContent, UserInteraction } from '../../types'
|
||||
import type { BaseEvent } from '../../types'
|
||||
import { ContentMessage } from './ContentMessage'
|
||||
|
||||
export interface FactCheckProps {
|
||||
content?: MessageContent
|
||||
userInteraction?: UserInteraction
|
||||
base?: BaseEvent
|
||||
isUserInput: boolean
|
||||
showUserFile: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* 事实核查消息组件 - 带引用内容的消息展示,对应 next-agent FactCheck
|
||||
*/
|
||||
function InnerFactCheck({
|
||||
content,
|
||||
userInteraction,
|
||||
isUserInput,
|
||||
showUserFile,
|
||||
}: FactCheckProps) {
|
||||
if (!content && !userInteraction) return null
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn('flex flex-col rounded-xl w-full', {
|
||||
'mt-9 mb-0 px-3': showUserFile,
|
||||
'my-9': isUserInput && !showUserFile,
|
||||
})}
|
||||
>
|
||||
<ContentMessage
|
||||
content={content}
|
||||
userInteraction={userInteraction}
|
||||
isUserInput={isUserInput}
|
||||
showUserFile={showUserFile}
|
||||
/>
|
||||
{content?.refer_content && (
|
||||
<div className="mt-3 w-full">
|
||||
<div className="px-3 py-2 rounded-lg border border-border/60 bg-muted/40 text-xs text-muted-foreground leading-relaxed">
|
||||
<span className="font-medium text-foreground/70 mr-1">引用:</span>
|
||||
{content.refer_content}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const FactCheck = React.memo(InnerFactCheck)
|
||||
export default FactCheck
|
||||
@@ -0,0 +1,101 @@
|
||||
import { useState, useEffect, memo } from 'react'
|
||||
import { Loader2 } from 'lucide-react'
|
||||
import { Image } from '@/components/ui/image'
|
||||
import { ImagePreview } from '@/components/ui/image-preview'
|
||||
import type { HandleImageAttachmentClick, ImageAttachment, TaskArtifact } from '../../types'
|
||||
import { useNovaKit } from '../../context/useNovaKit'
|
||||
|
||||
export interface ImageAttachmentItemProps {
|
||||
image: ImageAttachment
|
||||
assetsType: 'assistant' | 'user'
|
||||
onClick?: HandleImageAttachmentClick
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断是否需要加载 OSS URL
|
||||
*/
|
||||
function needsOssUrl(image: ImageAttachment): boolean {
|
||||
if (image.url && image.url.startsWith('http')) {
|
||||
return false
|
||||
}
|
||||
return !!(image.path || image.url)
|
||||
}
|
||||
|
||||
/**
|
||||
* 图片附件展示组件
|
||||
*/
|
||||
export const ImageAttachmentItem = memo(function ImageAttachmentItem({
|
||||
image,
|
||||
assetsType,
|
||||
onClick,
|
||||
}: ImageAttachmentItemProps) {
|
||||
const { api } = useNovaKit()
|
||||
const initialUrl = image.url?.startsWith('http') ? image.url : ''
|
||||
const [url, setUrl] = useState<string>(initialUrl)
|
||||
const [loading, setLoading] = useState(needsOssUrl(image))
|
||||
|
||||
useEffect(() => {
|
||||
if (image.url && image.url.startsWith('http')) {
|
||||
return
|
||||
}
|
||||
|
||||
const filePath = image.path || image.url || image.file_url
|
||||
if (filePath) {
|
||||
api
|
||||
.getArtifactUrl?.({ ...image, path: filePath } as TaskArtifact)
|
||||
.then((res: { data: string }) => {
|
||||
const fileUrls = res?.data
|
||||
if (fileUrls) {
|
||||
setUrl(fileUrls)
|
||||
image.url = fileUrls
|
||||
}
|
||||
})
|
||||
.catch((err: Error) => {
|
||||
console.error('获取图片 URL 失败:', err)
|
||||
})
|
||||
.finally(() => {
|
||||
setLoading(false)
|
||||
})
|
||||
}
|
||||
}, [image, image.url, image.path, api])
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-w-32 h-full min-h-32 rounded-lg">
|
||||
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!url) return null
|
||||
|
||||
const handleClick = () => {
|
||||
if (onClick) {
|
||||
onClick({ ...image, url: url || image.url }, assetsType)
|
||||
}
|
||||
}
|
||||
|
||||
if (onClick) {
|
||||
return (
|
||||
<div onClick={handleClick} className="cursor-pointer">
|
||||
<Image
|
||||
src={url}
|
||||
alt={image.file_name || '图片'}
|
||||
className="max-w-full max-h-[300px] rounded-lg hover:opacity-80 transition-opacity"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-center w-full h-full">
|
||||
<ImagePreview src={url} alt={image.file_name || '图片'}>
|
||||
<Image
|
||||
src={url}
|
||||
alt={image.file_name || '图片'}
|
||||
className="max-w-full max-h-[300px] rounded-lg hover:opacity-80 transition-opacity"
|
||||
/>
|
||||
</ImagePreview>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
105
components/nova-sdk/message-list/message-item/MessageFooter.tsx
Normal file
105
components/nova-sdk/message-list/message-item/MessageFooter.tsx
Normal file
@@ -0,0 +1,105 @@
|
||||
import React, { useMemo } from 'react'
|
||||
import { Copy } from 'lucide-react'
|
||||
import { cn } from '@/utils/cn'
|
||||
|
||||
export interface MessageFooterProps {
|
||||
shouldShowTimestamp: boolean
|
||||
shouldShowCopyButton: boolean
|
||||
timestamp?: string | number
|
||||
isUserInput: boolean
|
||||
isSummary: boolean
|
||||
showSystemAttachments: boolean
|
||||
showUserFile: boolean
|
||||
onCopyMessage: () => void
|
||||
}
|
||||
|
||||
function parseTimestamp(timestamp: string | number): Date {
|
||||
if (typeof timestamp === 'number') return new Date(timestamp)
|
||||
const num = Number(timestamp)
|
||||
if (!isNaN(num)) return new Date(num)
|
||||
// 截断超过3位的小数秒(Python 微秒精度 → JS 毫秒精度)
|
||||
const normalized = timestamp.replace(/(\.\d{3})\d+/, '$1')
|
||||
return new Date(normalized)
|
||||
}
|
||||
|
||||
function formatTimestamp(timestamp: string | number): string {
|
||||
const date = parseTimestamp(timestamp)
|
||||
|
||||
const now = new Date()
|
||||
const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate())
|
||||
const dateStart = new Date(date.getFullYear(), date.getMonth(), date.getDate())
|
||||
const diffDays = Math.round(
|
||||
(todayStart.getTime() - dateStart.getTime()) / (1000 * 60 * 60 * 24),
|
||||
)
|
||||
|
||||
const hhmm = date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', hour12: false })
|
||||
|
||||
if (diffDays === 0) {
|
||||
return hhmm
|
||||
} else if (diffDays >= 1) {
|
||||
return `${date.getMonth() + 1}/${date.getDate()} ${hhmm}`
|
||||
} else if (now.getFullYear() === date.getFullYear()) {
|
||||
return `${date.getMonth() + 1}/${date.getDate()}`
|
||||
} else {
|
||||
return `${date.getFullYear()}`
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 消息底部 - 显示时间戳和复制按钮,对应 next-agent MessageFooter
|
||||
*/
|
||||
export const MessageFooter: React.FC<MessageFooterProps> = ({
|
||||
shouldShowTimestamp,
|
||||
shouldShowCopyButton,
|
||||
timestamp,
|
||||
isUserInput,
|
||||
isSummary,
|
||||
showSystemAttachments,
|
||||
showUserFile,
|
||||
onCopyMessage,
|
||||
}) => {
|
||||
// hooks 必须在任何 return 之前调用
|
||||
const displayText = useMemo(
|
||||
() => (timestamp != null ? formatTimestamp(timestamp) : ''),
|
||||
[timestamp],
|
||||
)
|
||||
|
||||
const fullTime = useMemo(() => {
|
||||
if (timestamp == null) return ''
|
||||
return parseTimestamp(timestamp).toLocaleString()
|
||||
}, [timestamp])
|
||||
|
||||
if (!shouldShowTimestamp) return null
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'opacity-0 group-hover:opacity-100 flex items-center bottom-1 gap-3',
|
||||
{
|
||||
'right-0 absolute': isUserInput,
|
||||
'flex-row-reverse': isSummary,
|
||||
'mt-2.5': isSummary && showSystemAttachments,
|
||||
'px-3': showUserFile,
|
||||
},
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className="text-xs text-muted-foreground whitespace-nowrap"
|
||||
title={fullTime}
|
||||
>
|
||||
{displayText}
|
||||
</span>
|
||||
{shouldShowCopyButton && (
|
||||
<button
|
||||
className="w-6 h-6 rounded flex items-center justify-center hover:bg-muted transition-colors"
|
||||
onClick={onCopyMessage}
|
||||
type="button"
|
||||
>
|
||||
<Copy className="w-3.5 h-3.5 text-muted-foreground cursor-pointer" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(MessageFooter)
|
||||
@@ -0,0 +1,19 @@
|
||||
import React from 'react'
|
||||
import { Bot } from 'lucide-react'
|
||||
import { useNovaKit } from '../../context/useNovaKit'
|
||||
|
||||
interface MessageHeaderProps {
|
||||
avatarId?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 消息头部 - 显示 Agent 头像,对应 next-agent MessageHeader
|
||||
*/
|
||||
export const MessageHeader: React.FC<MessageHeaderProps> = ({ avatarId }) => {
|
||||
const { agentName } = useNovaKit()
|
||||
if (!avatarId) return null
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export default React.memo(MessageHeader)
|
||||
@@ -0,0 +1,122 @@
|
||||
import React from 'react'
|
||||
import { GalleryHorizontal } from 'lucide-react'
|
||||
import { cn } from '@/utils/cn'
|
||||
|
||||
interface SlideFile {
|
||||
id: string
|
||||
title?: string
|
||||
summary?: string
|
||||
path?: string
|
||||
filename?: string
|
||||
}
|
||||
|
||||
interface SlideOutlineOutput {
|
||||
slide_files?: SlideFile[]
|
||||
project_path?: string
|
||||
}
|
||||
|
||||
export interface SlideOutlineActionProps {
|
||||
/** action 名称(如"正在初始化PPT大纲") */
|
||||
name?: string
|
||||
/** 参数(如主标题) */
|
||||
arguments?: string[]
|
||||
/** tool_output,包含 slide_files */
|
||||
toolOutput?: unknown
|
||||
/** 点击整体卡片的回调(对应 BlockAction 的 onClick) */
|
||||
onClick?: () => void
|
||||
className?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* slide_init 专属渲染组件
|
||||
* 对应 Remix: BlockAction + SlideOutline (compact)
|
||||
*/
|
||||
function InnerSlideOutlineAction({
|
||||
name,
|
||||
arguments: args,
|
||||
toolOutput,
|
||||
onClick,
|
||||
className,
|
||||
}: SlideOutlineActionProps) {
|
||||
const output = toolOutput as SlideOutlineOutput | undefined
|
||||
const slideFiles = output?.slide_files || []
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-col my-2 rounded-lg border border-border/60 bg-background overflow-hidden',
|
||||
'w-full max-w-[480px]',
|
||||
onClick && 'cursor-pointer hover:border-border transition-colors',
|
||||
className,
|
||||
)}
|
||||
onClick={onClick}
|
||||
role={onClick ? 'button' : undefined}
|
||||
tabIndex={onClick ? 0 : undefined}
|
||||
onKeyDown={onClick ? e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onClick() } } : undefined}
|
||||
>
|
||||
{/* 标题栏 */}
|
||||
<div className="shrink-0 h-9 px-3 flex items-center gap-1.5 border-b border-border/50 bg-muted/40">
|
||||
<GalleryHorizontal className="w-4 h-4 text-muted-foreground shrink-0" />
|
||||
<span className="text-sm font-medium text-foreground shrink-0">
|
||||
{name || '初始化幻灯片'}
|
||||
</span>
|
||||
{args && args.length > 0 && (
|
||||
<span className="text-sm text-muted-foreground truncate">
|
||||
{args.join(' ')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 幻灯片大纲列表 */}
|
||||
<div className="flex-1 overflow-auto max-h-52">
|
||||
{slideFiles.length === 0 ? (
|
||||
<div className="flex flex-col gap-2 p-3">
|
||||
{[1, 2, 3].map(i => (
|
||||
<div key={i} className="flex gap-3 animate-pulse">
|
||||
<div className="w-7 h-4 bg-muted rounded mt-1 shrink-0" />
|
||||
<div className="flex-1 space-y-1.5">
|
||||
<div className="h-4 bg-muted rounded w-3/4" />
|
||||
<div className="h-3 bg-muted rounded w-full" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-border/40">
|
||||
{slideFiles.map((slide, index) => (
|
||||
<div key={slide.id || index} className="flex bg-background hover:bg-muted/20 transition-colors">
|
||||
<span className="shrink-0 w-8 py-3 pl-3 text-sm text-muted-foreground italic">
|
||||
P{index + 1}
|
||||
</span>
|
||||
<div className="flex-1 px-3 py-3 flex flex-col gap-1 min-w-0">
|
||||
{slide.title ? (
|
||||
<h3 className="text-sm font-medium text-foreground leading-tight">
|
||||
{slide.title}
|
||||
</h3>
|
||||
) : (
|
||||
<div className="h-4 bg-muted rounded animate-pulse w-1/2" />
|
||||
)}
|
||||
{slide.summary ? (
|
||||
<p className="text-xs text-muted-foreground leading-relaxed line-clamp-2">
|
||||
{slide.summary}
|
||||
</p>
|
||||
) : (
|
||||
<div className="h-3 bg-muted rounded animate-pulse w-3/4" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 底部渐变遮罩(对应 Remix BlockAction 的渐变效果) */}
|
||||
{slideFiles.length > 3 && (
|
||||
<div className="h-8 absolute left-0 bottom-0 bg-gradient-to-b from-transparent to-background pointer-events-none" />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const SlideOutlineAction = React.memo(InnerSlideOutlineAction)
|
||||
export default SlideOutlineAction
|
||||
129
components/nova-sdk/message-list/message-item/SystemMessage.tsx
Normal file
129
components/nova-sdk/message-list/message-item/SystemMessage.tsx
Normal file
@@ -0,0 +1,129 @@
|
||||
import React from 'react'
|
||||
import type {
|
||||
Attachment,
|
||||
ImageAttachment,
|
||||
McpContent,
|
||||
PlanConfig,
|
||||
TaskTodoConfig,
|
||||
Operation,
|
||||
BaseEvent,
|
||||
HandleImageAttachmentClick,
|
||||
} from '../../types'
|
||||
import { AttachmentItem } from './AttachmentItem'
|
||||
import { ImageAttachmentItem } from './ImageAttachmentItem'
|
||||
import { TodoList } from './TodoList'
|
||||
|
||||
export interface SystemMessageProps {
|
||||
attachment?: Attachment[]
|
||||
imageAttachment?: ImageAttachment[]
|
||||
operation?: Operation
|
||||
mcpContent?: McpContent
|
||||
planConfig?: PlanConfig
|
||||
taskTodoConfig?: TaskTodoConfig
|
||||
base?: BaseEvent
|
||||
showSystemAttachments: boolean
|
||||
showOperation: boolean
|
||||
showMcpContent: boolean
|
||||
showPlanConfig: boolean
|
||||
showTaskTodoList: boolean
|
||||
onAttachmentClick?: (attachment: Attachment) => void
|
||||
onImageAttachmentClick?: HandleImageAttachmentClick
|
||||
}
|
||||
|
||||
/**
|
||||
* 系统消息组件 - 渲染系统附件、待办列表、MCP内容、计划配置、操作信息
|
||||
* 对应 next-agent SystemMessage
|
||||
*/
|
||||
function InnerSystemMessage({
|
||||
attachment,
|
||||
imageAttachment,
|
||||
operation,
|
||||
mcpContent,
|
||||
planConfig,
|
||||
taskTodoConfig,
|
||||
showSystemAttachments,
|
||||
showOperation,
|
||||
showMcpContent,
|
||||
showPlanConfig,
|
||||
showTaskTodoList,
|
||||
onAttachmentClick,
|
||||
onImageAttachmentClick,
|
||||
}: SystemMessageProps) {
|
||||
const hasContent =
|
||||
showTaskTodoList ||
|
||||
(imageAttachment?.length ?? 0) > 0 ||
|
||||
(showSystemAttachments && (attachment?.length ?? 0) > 0) ||
|
||||
(showMcpContent && !!mcpContent) ||
|
||||
(showOperation && !!operation) ||
|
||||
(showPlanConfig && !!planConfig)
|
||||
|
||||
if (!hasContent) return null
|
||||
|
||||
return (
|
||||
<div className="w-full space-y-2 mt-1">
|
||||
{/* Todo 列表 */}
|
||||
{showTaskTodoList && taskTodoConfig?.list?.length && (
|
||||
<TodoList items={taskTodoConfig.list} />
|
||||
)}
|
||||
|
||||
{/* 图片附件(系统消息里的) */}
|
||||
{(imageAttachment?.length ?? 0) > 0 && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{imageAttachment!.map((img, i) => (
|
||||
<ImageAttachmentItem
|
||||
assetsType="assistant"
|
||||
key={img.url || img.path || i}
|
||||
image={img}
|
||||
onClick={onImageAttachmentClick}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 文件附件(系统消息里的) */}
|
||||
{showSystemAttachments && (attachment?.length ?? 0) > 0 && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{attachment!.map((att, i) => (
|
||||
<AttachmentItem
|
||||
key={att.file_id || att.file_url || i}
|
||||
attachment={att}
|
||||
onClick={onAttachmentClick}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* MCP 内容 */}
|
||||
{showMcpContent && mcpContent && (
|
||||
<div className="p-2 rounded-lg bg-muted border border-border/50">
|
||||
<pre className="text-xs text-muted-foreground whitespace-pre-wrap break-all">
|
||||
{JSON.stringify(mcpContent, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 操作信息 */}
|
||||
{showOperation && operation && (
|
||||
<div className="p-2 rounded-lg bg-muted border border-border/50">
|
||||
<pre className="text-xs text-muted-foreground whitespace-pre-wrap break-all">
|
||||
{JSON.stringify(operation, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 计划配置 */}
|
||||
{showPlanConfig && planConfig?.steps && (
|
||||
<TodoList
|
||||
items={planConfig.steps.map(s => ({
|
||||
id: s.id,
|
||||
title: s.title,
|
||||
status: s.status,
|
||||
}))}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const SystemMessage = React.memo(InnerSystemMessage)
|
||||
export default SystemMessage
|
||||
156
components/nova-sdk/message-list/message-item/TodoList.tsx
Normal file
156
components/nova-sdk/message-list/message-item/TodoList.tsx
Normal file
@@ -0,0 +1,156 @@
|
||||
import { memo } from 'react'
|
||||
import { ArrowRight, Check, ClipboardList, Circle } from 'lucide-react'
|
||||
import { cn } from '@/utils/cn'
|
||||
import { useNovaKit } from '../../context/useNovaKit'
|
||||
|
||||
export interface TodoItem {
|
||||
id?: string | number
|
||||
title?: string
|
||||
status?: 'pending' | 'completed' | 'in_progress' | string
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
export interface TodoListProps {
|
||||
items: TodoItem[]
|
||||
className?: string
|
||||
}
|
||||
|
||||
function TodoListComponent({ items = [], className }: TodoListProps) {
|
||||
const { agentName } = useNovaKit()
|
||||
const todoItems = items
|
||||
const totalCount = todoItems.length
|
||||
const completedCount = todoItems.filter(item => item.status === 'completed').length
|
||||
const isAllCompleted = totalCount > 0 && completedCount === totalCount
|
||||
const displayCompletedCount =
|
||||
totalCount === 0
|
||||
? 0
|
||||
: Math.min(totalCount, isAllCompleted ? completedCount : completedCount + 1)
|
||||
const progressRatio =
|
||||
totalCount === 0
|
||||
? 0
|
||||
: displayCompletedCount / totalCount
|
||||
const progressPercent = Math.max(0, Math.min(100, progressRatio * 100))
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'relative my-1 mb-4 max-w-3xl overflow-hidden rounded-xl border border-gray-200 bg-white',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{/* Header:任务执行流水线 */}
|
||||
<div className="flex items-center justify-between bg-gray-50 border-b border-gray-100 px-3 py-[5px]">
|
||||
<div className="flex min-w-0 flex-1 items-center gap-2">
|
||||
<div className="flex h-4 w-4 flex-none items-center justify-center text-muted-foreground">
|
||||
<ClipboardList className="h-4 w-4" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<h2 className="truncate text-[14px] leading-[22px] text-foreground">
|
||||
任务执行流水线
|
||||
</h2>
|
||||
<p className="truncate text-[12px] text-muted-foreground">{agentName}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 pl-3">
|
||||
<span className="text-[12px] font-medium text-muted-foreground">Progress</span>
|
||||
<span className="text-[12px] font-medium text-muted-foreground">
|
||||
{displayCompletedCount} / {totalCount || 0}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 列表内容:卡片列表布局 */}
|
||||
<div className="relative overflow-hidden transition-all duration-200 ease-in-out">
|
||||
<div className="relative overflow-visible bg-white px-3 py-2.5">
|
||||
<div className="absolute left-0 top-0 h-[2px] w-full bg-border/70">
|
||||
<div
|
||||
className="h-full bg-primary transition-all duration-700"
|
||||
style={{ width: `${progressPercent}%` }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col pt-1">
|
||||
{todoItems.map((item, index) => {
|
||||
const status = item.status as string | undefined
|
||||
const isCompleted = status === 'completed'
|
||||
const isActive = status === 'in_progress'
|
||||
const isPending = !isCompleted && !isActive
|
||||
const stepLabel = `STEP ${index + 1} OF ${totalCount || 0}`
|
||||
|
||||
return (
|
||||
<div
|
||||
key={(item.id as string) ?? index}
|
||||
className={cn(
|
||||
'flex items-center justify-between gap-3 py-1',
|
||||
isCompleted && 'opacity-70',
|
||||
isPending && 'opacity-60',
|
||||
)}
|
||||
>
|
||||
{/* 左侧:状态圆点 + 文案 */}
|
||||
<div className="flex min-w-0 flex-1 items-center gap-2">
|
||||
{/* 状态圆点 */}
|
||||
<div className="relative flex flex-none items-center justify-center">
|
||||
{isCompleted && (
|
||||
<div className="flex h-4 w-4 items-center justify-center rounded-full bg-primary">
|
||||
<Check className="h-2.5 w-2.5 text-white" />
|
||||
</div>
|
||||
)}
|
||||
{isActive && (
|
||||
<div className="flex h-4 w-4 items-center justify-center rounded-full bg-primary/12 text-primary">
|
||||
<ArrowRight className="h-2.5 w-2.5" />
|
||||
</div>
|
||||
)}
|
||||
{isPending && !isCompleted && !isActive && (
|
||||
<div className="flex h-4 w-4 items-center justify-center rounded-full border border-gray-200 bg-white">
|
||||
<Circle className="h-2.5 w-2.5 text-muted-foreground/55" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 文案 */}
|
||||
<div className="min-w-0 space-y-0.5">
|
||||
<p
|
||||
className={cn(
|
||||
'truncate text-[14px] leading-[22px] font-normal',
|
||||
isActive
|
||||
? 'text-foreground'
|
||||
: 'text-muted-foreground',
|
||||
isCompleted && 'line-through decoration-border',
|
||||
)}
|
||||
>
|
||||
{item.title as string}
|
||||
</p>
|
||||
{typeof (item as any).description === 'string' && (
|
||||
<p className="truncate text-[12px] text-muted-foreground">
|
||||
{(item as any).description as string}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{isActive && (
|
||||
<span className="h-1 w-1 flex-none rounded-full bg-primary animate-pulse" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 右侧:步骤标签 */}
|
||||
<span
|
||||
className={cn(
|
||||
'flex-none text-[10px] font-mono font-medium tracking-tight',
|
||||
isActive
|
||||
? 'text-primary'
|
||||
: 'text-muted-foreground',
|
||||
)}
|
||||
>
|
||||
{stepLabel}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const TodoList = memo(TodoListComponent)
|
||||
export default TodoList
|
||||
131
components/nova-sdk/message-list/message-item/ToolCallAction.tsx
Normal file
131
components/nova-sdk/message-list/message-item/ToolCallAction.tsx
Normal file
@@ -0,0 +1,131 @@
|
||||
import React from 'react'
|
||||
import {
|
||||
Code,
|
||||
Terminal,
|
||||
Search,
|
||||
FileCode,
|
||||
Globe,
|
||||
FileText,
|
||||
FileEdit,
|
||||
Image,
|
||||
GalleryHorizontal,
|
||||
Puzzle,
|
||||
Bot,
|
||||
RefreshCw,
|
||||
Eye,
|
||||
Layers,
|
||||
Wrench,
|
||||
} from 'lucide-react'
|
||||
import { cn } from '@/utils/cn'
|
||||
import type { ApiEvent } from '../../types'
|
||||
import { SILENT_ACTION_TYPES } from './utils'
|
||||
|
||||
export interface ToolCallActionProps {
|
||||
name?: string
|
||||
arguments?: string[]
|
||||
action_type?: string
|
||||
event?: ApiEvent
|
||||
onClick?: (event: ApiEvent) => void
|
||||
}
|
||||
|
||||
// ─── action_type → { icon, label } ──────────────────────────────────────────
|
||||
const ACTION_TYPE_META: Record<string, { icon: React.ReactNode; label: string }> = {
|
||||
shell_execute: { icon: <Terminal className="w-4 h-4" />, label: '执行命令' },
|
||||
terminal_operator: { icon: <Terminal className="w-4 h-4" />, label: '终端操作' },
|
||||
code_execute: { icon: <Code className="w-4 h-4" />, label: '运行代码' },
|
||||
file_operator: { icon: <FileCode className="w-4 h-4" />, label: '文件操作' },
|
||||
file_create: { icon: <FileText className="w-4 h-4" />, label: '创建文件' },
|
||||
file_read: { icon: <FileText className="w-4 h-4" />, label: '读取文件' },
|
||||
file_replace_text: { icon: <FileEdit className="w-4 h-4" />, label: '编辑文件' },
|
||||
file_write_text: { icon: <FileEdit className="w-4 h-4" />, label: '写入文件' },
|
||||
str_replace: { icon: <FileEdit className="w-4 h-4" />, label: '替换内容' },
|
||||
info_search_web: { icon: <Search className="w-4 h-4" />, label: '搜索网页' },
|
||||
info_fetch_webpage: { icon: <Globe className="w-4 h-4" />, label: '获取网页' },
|
||||
news_search: { icon: <Search className="w-4 h-4" />, label: '新闻搜索' },
|
||||
image_search: { icon: <Image className="w-4 h-4" />, label: '图片搜索' },
|
||||
info_search_custom_knowledge: { icon: <Search className="w-4 h-4" />, label: '知识检索' },
|
||||
search_custom_knowledge: { icon: <Search className="w-4 h-4" />, label: '知识检索' },
|
||||
browser_use: { icon: <Globe className="w-4 h-4" />, label: '浏览器操作' },
|
||||
slide_init: { icon: <GalleryHorizontal className="w-4 h-4" />, label: '初始化幻灯片' },
|
||||
slide_template: { icon: <GalleryHorizontal className="w-4 h-4" />, label: '选择模板' },
|
||||
slide_create: { icon: <GalleryHorizontal className="w-4 h-4" />, label: '生成幻灯片' },
|
||||
slide_create_batch: { icon: <Layers className="w-4 h-4" />, label: '批量生成幻灯片' },
|
||||
slide_present: { icon: <GalleryHorizontal className="w-4 h-4" />, label: '展示幻灯片' },
|
||||
media_generate_image: { icon: <Image className="w-4 h-4" />, label: '生成图片' },
|
||||
media_vision_image: { icon: <Eye className="w-4 h-4" />, label: '识别图片' },
|
||||
generate_image: { icon: <Image className="w-4 h-4" />, label: '生成图片' },
|
||||
call_flow: { icon: <RefreshCw className="w-4 h-4" />, label: '调用流程' },
|
||||
integrated_app: { icon: <Puzzle className="w-4 h-4" />, label: '集成应用' },
|
||||
custom_api: { icon: <Wrench className="w-4 h-4" />, label: '自定义工具' },
|
||||
skill_loader: { icon: <Bot className="w-4 h-4" />, label: '加载技能' },
|
||||
brand_search: { icon: <Search className="w-4 h-4" />, label: '品牌检索' },
|
||||
xiaohongshu_search: { icon: <Search className="w-4 h-4" />, label: '小红书搜索' },
|
||||
e_commerce: { icon: <Search className="w-4 h-4" />, label: '电商搜索' },
|
||||
experience_query: { icon: <Search className="w-4 h-4" />, label: '经验查询' },
|
||||
writer: { icon: <FileEdit className="w-4 h-4" />, label: '文档创作' },
|
||||
parallel_map: { icon: <Layers className="w-4 h-4" />, label: '并行任务' },
|
||||
media_comments: { icon: <Search className="w-4 h-4" />, label: '媒体搜索' },
|
||||
system_api: { icon: <Wrench className="w-4 h-4" />, label: '系统接口' },
|
||||
}
|
||||
|
||||
/**
|
||||
* 工具调用 Action 组件
|
||||
* - 静默事件类型 → 不渲染
|
||||
* - name / label / argsText 全空 → 不渲染
|
||||
* - 点击触发外部 onClick(打开右侧面板)
|
||||
*/
|
||||
export function ToolCallAction({
|
||||
name,
|
||||
arguments: args,
|
||||
action_type,
|
||||
event,
|
||||
onClick,
|
||||
}: ToolCallActionProps) {
|
||||
// 静默类型直接跳过
|
||||
if (action_type && SILENT_ACTION_TYPES.has(action_type)) return null
|
||||
|
||||
const meta = action_type ? ACTION_TYPE_META[action_type] : undefined
|
||||
const icon = meta?.icon ?? <Code className="w-4 h-4" />
|
||||
const label = name || meta?.label || ''
|
||||
const argsText = args?.filter(Boolean).join(' ') || ''
|
||||
|
||||
// 没有任何可展示内容时不渲染
|
||||
if (!label && !argsText) return null
|
||||
|
||||
const handleClick = () => {
|
||||
if (event && onClick) onClick(event)
|
||||
}
|
||||
const isClickable = !!(event && onClick)
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'inline-flex items-center gap-2 px-3 py-1.5 rounded-lg max-w-full mb-2',
|
||||
'border border-gray-200 bg-white/80',
|
||||
'text-sm',
|
||||
isClickable && 'cursor-pointer transition-all duration-150 hover:bg-gray-50 hover:border-gray-300',
|
||||
)}
|
||||
onClick={isClickable ? handleClick : undefined}
|
||||
role={isClickable ? 'button' : undefined}
|
||||
tabIndex={isClickable ? 0 : undefined}
|
||||
onKeyDown={
|
||||
isClickable
|
||||
? e => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault()
|
||||
handleClick()
|
||||
}
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<span className="shrink-0 text-gray-500">{icon}</span>
|
||||
{label && <span className="shrink-0 font-medium text-gray-800">{label}:</span>}
|
||||
{argsText && (
|
||||
<code className="min-w-0 flex-1 truncate font-mono text-xs text-gray-500">
|
||||
{argsText}
|
||||
</code>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import React from 'react'
|
||||
import { cn } from '@/utils/cn'
|
||||
import type { Attachment, ImageAttachment, BaseEvent, HandleImageAttachmentClick } from '../../types'
|
||||
import { AttachmentItem } from './AttachmentItem'
|
||||
import { ImageAttachmentItem } from './ImageAttachmentItem'
|
||||
|
||||
export interface UserMessageProps {
|
||||
base?: BaseEvent
|
||||
attachment?: Attachment[]
|
||||
imageAttachment?: ImageAttachment[]
|
||||
loading?: boolean
|
||||
isUserInput: boolean
|
||||
showTemplate?: boolean
|
||||
onAttachmentClick?: (attachment: Attachment) => void
|
||||
onImageAttachmentClick?: HandleImageAttachmentClick
|
||||
}
|
||||
|
||||
/**
|
||||
* 用户消息附件组件 - 展示用户上传的文件/图片,对应 next-agent UserMessage
|
||||
*/
|
||||
function InnerUserMessage({
|
||||
attachment,
|
||||
imageAttachment,
|
||||
loading,
|
||||
isUserInput,
|
||||
onAttachmentClick,
|
||||
onImageAttachmentClick,
|
||||
}: UserMessageProps) {
|
||||
const hasImages = !!(imageAttachment?.length)
|
||||
const hasFiles = !!(attachment?.length)
|
||||
|
||||
if (!hasImages && !hasFiles) return null
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-col rounded-xl mt-2 mb-6 px-3',
|
||||
{ 'opacity-60': loading },
|
||||
isUserInput ? 'items-end' : 'items-start',
|
||||
)}
|
||||
>
|
||||
{hasImages && (
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-wrap gap-2 mb-2',
|
||||
isUserInput ? 'justify-end' : 'justify-start',
|
||||
)}
|
||||
>
|
||||
{imageAttachment!.map((img, i) => (
|
||||
<ImageAttachmentItem
|
||||
assetsType="user"
|
||||
key={img.url || img.path || i}
|
||||
image={img}
|
||||
onClick={onImageAttachmentClick}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{hasFiles && (
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-wrap gap-2',
|
||||
isUserInput ? 'justify-end' : 'justify-start',
|
||||
)}
|
||||
>
|
||||
{attachment!.map((att, i) => (
|
||||
<AttachmentItem
|
||||
key={att.file_id || att.file_url || i}
|
||||
attachment={att}
|
||||
onClick={onAttachmentClick}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const UserMessage = React.memo(InnerUserMessage)
|
||||
export default UserMessage
|
||||
225
components/nova-sdk/message-list/message-item/index.tsx
Normal file
225
components/nova-sdk/message-list/message-item/index.tsx
Normal file
@@ -0,0 +1,225 @@
|
||||
import React from 'react'
|
||||
import { cn } from '@/utils/cn'
|
||||
import type { ExtendedEvent, Attachment, HandleImageAttachmentClick } from '../../types'
|
||||
import type { ApiEvent } from '../../hooks/useNovaEvents'
|
||||
import { MessageHeader } from './MessageHeader'
|
||||
import { MessageFooter } from './MessageFooter'
|
||||
import { ToolCallAction } from './ToolCallAction'
|
||||
import { BrowserUseAction } from './BrowserUseAction'
|
||||
import { ContentMessage } from './ContentMessage'
|
||||
import { FactCheck } from './FactCheck'
|
||||
import { UserMessage } from './UserMessage'
|
||||
import { SystemMessage } from './SystemMessage'
|
||||
import { getMessageType, SILENT_ACTION_TYPES } from './utils'
|
||||
|
||||
export interface MessageItemProps {
|
||||
event: ExtendedEvent
|
||||
className?: string
|
||||
shouldShowTimestamp?: boolean
|
||||
shouldShowCopyButton?: boolean
|
||||
onCopyMessage?: () => void
|
||||
onAttachmentClick?: (attachment: Attachment) => void
|
||||
onImageAttachmentClick?: HandleImageAttachmentClick
|
||||
onToolCallClick?: (event: ApiEvent) => void
|
||||
onSendMessage?: (content: string) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* 消息项组件 - 结构严格对齐 next-agent message-body.tsx
|
||||
*
|
||||
* 渲染顺序:
|
||||
* MessageHeader → ActionMessage → ContentMessage → FactCheck
|
||||
* → UserMessage → SystemMessage → MessageFooter
|
||||
*/
|
||||
function InnerMessageItem({
|
||||
event,
|
||||
className,
|
||||
shouldShowTimestamp,
|
||||
shouldShowCopyButton = false,
|
||||
onCopyMessage,
|
||||
onAttachmentClick,
|
||||
onImageAttachmentClick,
|
||||
onToolCallClick,
|
||||
onSendMessage,
|
||||
}: MessageItemProps) {
|
||||
// task_end 不渲染
|
||||
if (event.event_type === 'task_end') return null
|
||||
|
||||
const messageType = getMessageType(event)
|
||||
const {
|
||||
action,
|
||||
content,
|
||||
userInteraction,
|
||||
attachment,
|
||||
imageAttachment,
|
||||
operation,
|
||||
mcpContent,
|
||||
planConfig,
|
||||
taskTodoConfig,
|
||||
base,
|
||||
} = event.renderProps || {}
|
||||
|
||||
// plan_step_state 为 canceled 时显示"用户取消"
|
||||
const planStepState = (event as ExtendedEvent & { plan_step_state?: string })
|
||||
.plan_step_state
|
||||
if (planStepState === 'canceled') {
|
||||
return (
|
||||
<div
|
||||
className={cn('flex flex-col text-sm relative w-full items-start', className)}
|
||||
data-event-id={event.event_id}
|
||||
>
|
||||
<div className="text-sm text-muted-foreground italic px-1">用户取消</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// 对应 message-body.tsx showContentMessage 逻辑(完全对齐 Remix)
|
||||
const showContentMessage =
|
||||
base &&
|
||||
(messageType.showContent || messageType.showUserInteraction) &&
|
||||
!messageType.isFactCheck &&
|
||||
(!!content || !!userInteraction)
|
||||
|
||||
// 对应 Remix useMessageActions.shouldShowTimestamp:
|
||||
// 只有 isUserInput 或 isSummary 且有内容时才显示时间戳
|
||||
const timestamp = base?.timestamp ?? event.timestamp
|
||||
const showTimestamp =
|
||||
shouldShowTimestamp !== undefined
|
||||
? shouldShowTimestamp
|
||||
: !!(messageType.showTimestamp && content?.content && (messageType.isSummary || messageType.isUserInput))
|
||||
|
||||
// 对应 Remix useMessageActions.shouldShowCopyButton
|
||||
const showCopyButton =
|
||||
shouldShowCopyButton !== undefined
|
||||
? shouldShowCopyButton
|
||||
: !!(content?.content && (messageType.isUserInput || messageType.isSummary))
|
||||
|
||||
// 判断 action 是否实际会渲染(排除 silent 类型和空 action)
|
||||
// 对应 Remix Action 组件: if (!name || !action_type) return null
|
||||
const hasRenderableAction =
|
||||
messageType.showAction &&
|
||||
!!action &&
|
||||
!!(action.action_type || action.name) &&
|
||||
!SILENT_ACTION_TYPES.has(action.action_type || '')
|
||||
|
||||
// 完全空的事件不渲染(Remix 靠 show/notEmpty 提前过滤,SDK 在此兜底)
|
||||
if (
|
||||
!hasRenderableAction &&
|
||||
!showContentMessage &&
|
||||
!messageType.isFactCheck &&
|
||||
!messageType.showUserFile &&
|
||||
!messageType.showTemplate &&
|
||||
!messageType.showSystemAttachments &&
|
||||
!messageType.showMcpContent &&
|
||||
!messageType.showOperation &&
|
||||
!messageType.showPlanConfig &&
|
||||
!messageType.showTaskTodoList
|
||||
) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'group flex flex-col text-sm relative w-full',
|
||||
// 进入时轻微淡入 + 左右滑入,靠近样例中的对话动画
|
||||
'animate-in fade-in-0 duration-200',
|
||||
messageType.isUserInput ? 'slide-in-from-right-2' : 'slide-in-from-left-2',
|
||||
messageType.containerAlignment,
|
||||
className,
|
||||
)}
|
||||
data-event-id={event.event_id}
|
||||
>
|
||||
{/* MessageHeader:对应 <MessageHeader avatarId={base?.metadata?.agent_id} /> */}
|
||||
<MessageHeader avatarId={base?.metadata?.agent_id} />
|
||||
|
||||
{/* ActionMessage:对应 messageType.showAction && action */}
|
||||
{messageType.showAction && action && (
|
||||
action.action_type === 'browser_use' ? (
|
||||
<BrowserUseAction
|
||||
name={action.name}
|
||||
arguments={action.arguments}
|
||||
toolOutput={action.tool_output}
|
||||
/>
|
||||
) : (
|
||||
<ToolCallAction
|
||||
name={action.name}
|
||||
arguments={action.arguments}
|
||||
action_type={action.action_type}
|
||||
event={event as ApiEvent}
|
||||
onClick={onToolCallClick}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
|
||||
{/* ContentMessage:对应 showContentMessage */}
|
||||
{showContentMessage && (
|
||||
<ContentMessage
|
||||
content={content}
|
||||
userInteraction={userInteraction}
|
||||
base={base}
|
||||
isUserInput={messageType.isUserInput}
|
||||
showUserFile={messageType.showUserFile || messageType.showTemplate}
|
||||
onSendMessage={onSendMessage}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* FactCheck:对应 messageType.isFactCheck */}
|
||||
{messageType.isFactCheck && (
|
||||
<FactCheck
|
||||
content={content}
|
||||
userInteraction={userInteraction}
|
||||
base={base}
|
||||
isUserInput={messageType.isUserInput}
|
||||
showUserFile={messageType.showUserFile}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* UserMessage:对应 messageType.showUserFile || messageType.showTemplate */}
|
||||
{(messageType.showUserFile || messageType.showTemplate) && (
|
||||
<UserMessage
|
||||
base={base}
|
||||
loading={base?.metadata?.isTemp}
|
||||
attachment={attachment}
|
||||
isUserInput={messageType.isUserInput}
|
||||
showTemplate={messageType.showTemplate}
|
||||
onAttachmentClick={onAttachmentClick}
|
||||
onImageAttachmentClick={onImageAttachmentClick}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* SystemMessage */}
|
||||
<SystemMessage
|
||||
attachment={attachment}
|
||||
imageAttachment={imageAttachment}
|
||||
operation={operation}
|
||||
mcpContent={mcpContent}
|
||||
planConfig={planConfig}
|
||||
taskTodoConfig={taskTodoConfig}
|
||||
base={base}
|
||||
showSystemAttachments={messageType.showSystemAttachments}
|
||||
showOperation={messageType.showOperation}
|
||||
showMcpContent={messageType.showMcpContent}
|
||||
showPlanConfig={messageType.showPlanConfig}
|
||||
showTaskTodoList={messageType.showTaskTodoList}
|
||||
onAttachmentClick={onAttachmentClick}
|
||||
onImageAttachmentClick={onImageAttachmentClick}
|
||||
/>
|
||||
|
||||
{/* MessageFooter:对应 <MessageFooter ... /> */}
|
||||
<MessageFooter
|
||||
shouldShowTimestamp={showTimestamp}
|
||||
shouldShowCopyButton={showCopyButton}
|
||||
showUserFile={messageType.showUserFile}
|
||||
timestamp={timestamp}
|
||||
isUserInput={messageType.isUserInput}
|
||||
isSummary={messageType.isSummary}
|
||||
showSystemAttachments={messageType.showSystemAttachments}
|
||||
onCopyMessage={onCopyMessage ?? (() => {})}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const MessageItem = React.memo(InnerMessageItem)
|
||||
export default MessageItem
|
||||
@@ -0,0 +1,30 @@
|
||||
import { memo, useState } from 'react'
|
||||
import { InteractionButtons } from './InteractionButtons'
|
||||
|
||||
const TAKEOVER_ACTIONS = ['尝试接管', '立即跳过'] as const
|
||||
|
||||
interface BrowserTakeoverInteractionProps {
|
||||
disabled?: boolean
|
||||
browser_type?: string
|
||||
onSendMessage?: (content: string) => void
|
||||
}
|
||||
|
||||
export const BrowserTakeoverInteraction = memo(
|
||||
({ disabled, onSendMessage }: BrowserTakeoverInteractionProps) => {
|
||||
const [isClicked, setIsClicked] = useState(false)
|
||||
|
||||
const handleClick = (action: string) => {
|
||||
setIsClicked(true)
|
||||
onSendMessage?.(action)
|
||||
}
|
||||
|
||||
return (
|
||||
<InteractionButtons
|
||||
items={[...TAKEOVER_ACTIONS]}
|
||||
disabled={disabled}
|
||||
isClicked={isClicked}
|
||||
onClick={handleClick}
|
||||
/>
|
||||
)
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,31 @@
|
||||
import { memo, useState } from 'react'
|
||||
import type { UserInteraction } from '../../../types'
|
||||
import { InteractionButtons } from './InteractionButtons'
|
||||
|
||||
interface ChoiceInteractionProps {
|
||||
choice_options?: UserInteraction['choice_options']
|
||||
disabled?: boolean
|
||||
onSendMessage?: (content: string) => void
|
||||
}
|
||||
|
||||
export const ChoiceInteraction = memo(
|
||||
({ choice_options, disabled, onSendMessage }: ChoiceInteractionProps) => {
|
||||
const [isClicked, setIsClicked] = useState(false)
|
||||
|
||||
const handleClick = (label: string) => {
|
||||
setIsClicked(true)
|
||||
onSendMessage?.(label)
|
||||
}
|
||||
|
||||
if (!choice_options?.length) return null
|
||||
|
||||
return (
|
||||
<InteractionButtons
|
||||
items={choice_options}
|
||||
disabled={disabled}
|
||||
isClicked={isClicked}
|
||||
onClick={handleClick}
|
||||
/>
|
||||
)
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,61 @@
|
||||
import { memo } from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip'
|
||||
|
||||
interface ButtonItem {
|
||||
label: string
|
||||
disabled?: boolean
|
||||
tooltip?: string
|
||||
}
|
||||
|
||||
interface InteractionButtonsProps {
|
||||
items: Array<string | ButtonItem>
|
||||
disabled?: boolean
|
||||
isClicked?: boolean
|
||||
onClick: (label: string) => void
|
||||
}
|
||||
|
||||
export const InteractionButtons = memo(
|
||||
({ items, disabled, isClicked, onClick }: InteractionButtonsProps) => {
|
||||
if (!items?.length) return null
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<div className="flex items-center flex-wrap gap-2 mt-2">
|
||||
{items.map((item, idx) => {
|
||||
const label = typeof item === 'string' ? item : item.label
|
||||
const btnDisabled = typeof item === 'string' ? false : !!item.disabled
|
||||
const tooltip = typeof item === 'string' ? undefined : item.tooltip
|
||||
|
||||
const button = (
|
||||
<Button
|
||||
key={`${label}_${idx}`}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={isClicked || disabled || btnDisabled}
|
||||
onClick={() => onClick(label)}
|
||||
className="h-auto cursor-pointer border-border bg-secondary/60 px-3 py-1.5 text-sm font-normal text-foreground hover:border-primary/40 hover:bg-accent hover:text-primary"
|
||||
>
|
||||
{label}
|
||||
</Button>
|
||||
)
|
||||
|
||||
return tooltip ? (
|
||||
<Tooltip key={`${label}_${idx}`}>
|
||||
<TooltipTrigger asChild>{button}</TooltipTrigger>
|
||||
<TooltipContent>{tooltip}</TooltipContent>
|
||||
</Tooltip>
|
||||
) : (
|
||||
button
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
)
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,33 @@
|
||||
import { memo } from 'react'
|
||||
import { cn } from '@/utils/cn'
|
||||
import { MarkdownContent } from '../../../task-panel/Preview/MarkdownPreview'
|
||||
|
||||
interface InteractionWrapperProps {
|
||||
content?: string
|
||||
isLatest?: boolean
|
||||
className?: string
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
export const InteractionWrapper = memo(
|
||||
({ content, children, className }: InteractionWrapperProps) => {
|
||||
const trimmedContent = content?.trim()
|
||||
|
||||
return (
|
||||
<div className={cn('mt-2 w-full', className)}>
|
||||
<div className="relative rounded-r-lg border border-solid border-border/60 bg-card/90 py-2 pl-4 pr-3">
|
||||
{/* 左侧主色竖条 */}
|
||||
<div className="absolute left-0 top-0 bottom-0 w-1 bg-primary rounded-l-lg" />
|
||||
|
||||
{trimmedContent && (
|
||||
<div className="mb-2">
|
||||
<MarkdownContent content={trimmedContent} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,166 @@
|
||||
import { memo, useEffect, useRef, useState } from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { cn } from '@/utils/cn'
|
||||
import type { SlideQuestion } from '../../../types'
|
||||
|
||||
interface SlideFormInteractionProps {
|
||||
questions?: SlideQuestion[]
|
||||
disabled?: boolean
|
||||
messageTime?: string
|
||||
onSendMessage?: (content: string) => void
|
||||
}
|
||||
|
||||
const COUNTDOWN_SECONDS = 30
|
||||
|
||||
export const SlideFormInteraction = memo(
|
||||
({
|
||||
questions,
|
||||
disabled,
|
||||
messageTime,
|
||||
onSendMessage,
|
||||
}: SlideFormInteractionProps) => {
|
||||
const [answers, setAnswers] = useState<Record<number, string>>({})
|
||||
const [submitted, setSubmitted] = useState(false)
|
||||
const [seconds, setSeconds] = useState(messageTime ? COUNTDOWN_SECONDS : 0)
|
||||
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
||||
|
||||
// 初始化已有答案
|
||||
useEffect(() => {
|
||||
if (!questions) return
|
||||
const initial: Record<number, string> = {}
|
||||
questions.forEach((q, i) => {
|
||||
if (q.answer != null) initial[i] = q.answer
|
||||
})
|
||||
setAnswers(initial)
|
||||
}, [questions])
|
||||
|
||||
// 倒计时
|
||||
useEffect(() => {
|
||||
if (!messageTime || seconds <= 0) return
|
||||
timerRef.current = setInterval(() => {
|
||||
setSeconds(s => {
|
||||
if (s <= 1) {
|
||||
clearInterval(timerRef.current!)
|
||||
handleSubmit()
|
||||
return 0
|
||||
}
|
||||
return s - 1
|
||||
})
|
||||
}, 1000)
|
||||
return () => { if (timerRef.current) clearInterval(timerRef.current) }
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [messageTime])
|
||||
|
||||
function handleSubmit() {
|
||||
if (submitted) return
|
||||
setSubmitted(true)
|
||||
if (timerRef.current) clearInterval(timerRef.current)
|
||||
|
||||
const content = {
|
||||
questions: questions?.map((q, idx) => ({
|
||||
...q,
|
||||
answer: answers[idx] ?? '',
|
||||
})),
|
||||
expected_user_action: 'slide_message_reply',
|
||||
}
|
||||
onSendMessage?.(JSON.stringify(content))
|
||||
}
|
||||
|
||||
if (!questions?.length) return null
|
||||
|
||||
const isDisabled = disabled || submitted || seconds === 0
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn('mt-2 w-full overflow-hidden rounded-xl border border-border')}
|
||||
style={{
|
||||
background:
|
||||
'radial-gradient(58% 12% at 97% 1%, var(--page-glow-2) 0%, rgba(0,0,0,0) 100%), radial-gradient(108% 26% at 10% -4%, var(--page-glow-1) 0%, rgba(0,0,0,0) 100%), var(--card)',
|
||||
}}
|
||||
>
|
||||
{/* 标题 */}
|
||||
<div className="flex items-center gap-1 px-3 py-2.5 font-medium text-primary">
|
||||
<span className="text-sm">✦ 补充信息</span>
|
||||
</div>
|
||||
|
||||
{/* 问题列表 */}
|
||||
<div className="px-3 pb-3 flex flex-col gap-3">
|
||||
{questions.map((q, idx) => (
|
||||
<div key={idx} className="flex flex-col gap-1.5">
|
||||
<label className="text-sm font-medium text-foreground">{q.question}</label>
|
||||
|
||||
{q.type === 'input' || !q.options?.length ? (
|
||||
<Input
|
||||
value={answers[idx] ?? ''}
|
||||
onChange={e =>
|
||||
setAnswers(prev => ({ ...prev, [idx]: e.target.value }))
|
||||
}
|
||||
disabled={isDisabled}
|
||||
className="h-8 text-sm"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{q.options.map(opt => {
|
||||
const selected =
|
||||
q.type === 'multiple'
|
||||
? (answers[idx] ?? '').split(',').includes(opt)
|
||||
: answers[idx] === opt
|
||||
return (
|
||||
<button
|
||||
key={opt}
|
||||
type="button"
|
||||
disabled={isDisabled}
|
||||
onClick={() => {
|
||||
if (q.type === 'multiple') {
|
||||
const current = (answers[idx] ?? '').split(',').filter(Boolean)
|
||||
const next = selected
|
||||
? current.filter(v => v !== opt)
|
||||
: [...current, opt]
|
||||
setAnswers(prev => ({ ...prev, [idx]: next.join(',') }))
|
||||
} else {
|
||||
setAnswers(prev => ({ ...prev, [idx]: opt }))
|
||||
}
|
||||
}}
|
||||
className={cn(
|
||||
'rounded-lg border px-3 py-1.5 text-sm transition-colors',
|
||||
selected
|
||||
? 'border-primary/40 bg-primary/10 text-primary'
|
||||
: 'border-border/60 bg-secondary/60 text-foreground hover:bg-accent',
|
||||
isDisabled && 'opacity-50 cursor-not-allowed',
|
||||
)}
|
||||
>
|
||||
{opt}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* 底部:倒计时 + 提交 */}
|
||||
<div className="flex items-center justify-between mt-1">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{submitted || seconds === 0 ? (
|
||||
'已自动运行'
|
||||
) : (
|
||||
<>
|
||||
<span className="text-primary">{seconds}s</span> 后自动运行
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleSubmit}
|
||||
disabled={isDisabled}
|
||||
className="text-sm h-7"
|
||||
>
|
||||
确认
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,83 @@
|
||||
import { memo } from 'react'
|
||||
import type { UserInteraction as UserInteractionData } from '../../../types'
|
||||
import { InteractionWrapper } from './InteractionWrapper'
|
||||
import { ChoiceInteraction } from './ChoiceInteraction'
|
||||
import { BrowserTakeoverInteraction } from './BrowserTakeoverInteraction'
|
||||
import { SlideFormInteraction } from './SlideFormInteraction'
|
||||
|
||||
export { InteractionWrapper } from './InteractionWrapper'
|
||||
export { InteractionButtons } from './InteractionButtons'
|
||||
export { ChoiceInteraction } from './ChoiceInteraction'
|
||||
export { BrowserTakeoverInteraction } from './BrowserTakeoverInteraction'
|
||||
export { SlideFormInteraction } from './SlideFormInteraction'
|
||||
|
||||
interface UserInteractionProps {
|
||||
userInteraction: UserInteractionData
|
||||
disabled?: boolean
|
||||
isLatest?: boolean
|
||||
onSendMessage?: (content: string) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* 用户交互主入口组件
|
||||
* 根据 userInteraction.type 分发到对应的交互子组件
|
||||
*/
|
||||
export const UserInteractionWidget = memo(
|
||||
({ userInteraction, disabled, isLatest, onSendMessage }: UserInteractionProps) => {
|
||||
const {
|
||||
type,
|
||||
content,
|
||||
choice_options,
|
||||
questions,
|
||||
browser_type,
|
||||
expected_user_action,
|
||||
take_over_type,
|
||||
messageTime,
|
||||
} = userInteraction as UserInteractionData & { messageTime?: string }
|
||||
|
||||
let inner: React.ReactNode = null
|
||||
|
||||
// 浏览器接管交互:与 Remix 一致,优先根据 expected_user_action 判断
|
||||
if (
|
||||
expected_user_action === 'take_over_web_browser' ||
|
||||
type === 'browser_takeover' ||
|
||||
type === 'browser_use_takeover'
|
||||
) {
|
||||
if (take_over_type !== 'reback') {
|
||||
inner = (
|
||||
<BrowserTakeoverInteraction
|
||||
disabled={disabled}
|
||||
browser_type={browser_type}
|
||||
onSendMessage={onSendMessage}
|
||||
/>
|
||||
)
|
||||
}
|
||||
} else if (choice_options?.length) {
|
||||
inner = (
|
||||
<ChoiceInteraction
|
||||
choice_options={choice_options}
|
||||
disabled={disabled}
|
||||
onSendMessage={onSendMessage}
|
||||
/>
|
||||
)
|
||||
} else if (questions?.length) {
|
||||
inner = (
|
||||
<SlideFormInteraction
|
||||
questions={questions}
|
||||
disabled={disabled}
|
||||
messageTime={messageTime}
|
||||
onSendMessage={onSendMessage}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
// 有文本内容或有交互子组件时才渲染
|
||||
if (!inner && !content?.trim()) return null
|
||||
|
||||
return (
|
||||
<InteractionWrapper content={content} isLatest={isLatest}>
|
||||
{inner}
|
||||
</InteractionWrapper>
|
||||
)
|
||||
},
|
||||
)
|
||||
258
components/nova-sdk/message-list/message-item/utils.ts
Normal file
258
components/nova-sdk/message-list/message-item/utils.ts
Normal file
@@ -0,0 +1,258 @@
|
||||
import type { ExtendedEvent, Attachment, ImageAttachment } from '../../types'
|
||||
|
||||
// ---- 静默 action 类型:有 action 也不渲染 ToolCallAction ----
|
||||
// 与 ToolCallAction.tsx 中的 SILENT_ACTION_TYPES 保持一致
|
||||
export const SILENT_ACTION_TYPES = new Set([
|
||||
'agent_advance_phase',
|
||||
'agent_end_task',
|
||||
'agent_update_plan',
|
||||
'finish_task',
|
||||
'substep_complete',
|
||||
'reback',
|
||||
'agent_think',
|
||||
'agent_schedule_task',
|
||||
'text_json',
|
||||
'message_notify_user',
|
||||
'browser_use_takeover',
|
||||
'mcp',
|
||||
'mcp_tool',
|
||||
])
|
||||
|
||||
// ---- MessageType ----
|
||||
|
||||
export interface MessageTypeState {
|
||||
isUserInput: boolean
|
||||
isTaskEnd: boolean
|
||||
isSummary: boolean
|
||||
showAction: boolean
|
||||
showContent: boolean
|
||||
showUserFile: boolean
|
||||
showSystemAttachments: boolean
|
||||
showMcpContent: boolean
|
||||
showOperation: boolean
|
||||
showPlanConfig: boolean
|
||||
showTaskTodoList: boolean
|
||||
showUserInteraction: boolean
|
||||
showTemplate: boolean
|
||||
/** 对应 Remix useMessageType.showTimestamp: !!base?.timestamp */
|
||||
showTimestamp: boolean
|
||||
containerAlignment: 'items-start' | 'items-end'
|
||||
isFactCheck: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据事件计算消息显示类型标志位,对应 next-agent useMessageType 逻辑
|
||||
*/
|
||||
export function getMessageType(event: ExtendedEvent): MessageTypeState {
|
||||
const props = event.renderProps || {}
|
||||
const {
|
||||
action,
|
||||
content,
|
||||
attachment,
|
||||
imageAttachment,
|
||||
mcpContent,
|
||||
planConfig,
|
||||
taskTodoConfig,
|
||||
operation,
|
||||
userInteraction,
|
||||
base,
|
||||
} = props
|
||||
|
||||
const isUserInput =
|
||||
base?.metadata?.isUserInput ?? event.event_type === 'user_input'
|
||||
const isTaskEnd = event.event_type === 'task_end'
|
||||
// isSummary 对应 Remix: !!metadata?.is_summary
|
||||
const isSummary = !!base?.metadata?.is_summary
|
||||
|
||||
const hasAttachment = !!(attachment?.length || imageAttachment?.length)
|
||||
const hasUserFile = isUserInput && hasAttachment
|
||||
const isFactCheck = !!content?.refer_content
|
||||
const showTemplate = !!(
|
||||
base?.metadata?.template_type && base?.metadata?.template_id
|
||||
)
|
||||
|
||||
// 完全对齐 Remix useMessageType
|
||||
const eventType = base?.event_type || event.event_type
|
||||
const actionType = action?.action_type || ''
|
||||
const isSummaryMessage =
|
||||
actionType === 'step_summary' || actionType === 'summary'
|
||||
const isStepCompleted = base?.plan_step_state === 'completed'
|
||||
|
||||
return {
|
||||
isUserInput,
|
||||
isTaskEnd,
|
||||
isSummary,
|
||||
// 完全对齐 Remix: showAction: !!action && !mcpContent && !planConfig
|
||||
showAction: !!action && !mcpContent && !planConfig,
|
||||
showContent: !!content && !planConfig,
|
||||
showUserFile: hasUserFile,
|
||||
// 完全对齐 Remix: (hasAttachment && isStepCompleted && isSummaryMessage) || (hasAttachment && eventType === 'text')
|
||||
showSystemAttachments:
|
||||
(hasAttachment && isStepCompleted && isSummaryMessage) ||
|
||||
(hasAttachment && eventType === 'text'),
|
||||
showMcpContent: !!mcpContent,
|
||||
showOperation: !!operation,
|
||||
showPlanConfig: !!planConfig,
|
||||
showTaskTodoList: !!(taskTodoConfig && taskTodoConfig.list?.length > 0),
|
||||
showUserInteraction: !!userInteraction,
|
||||
// 完全对齐 Remix: showTimestamp: !!base?.timestamp
|
||||
showTimestamp: !!base?.timestamp,
|
||||
showTemplate,
|
||||
containerAlignment:
|
||||
hasUserFile || isFactCheck || isUserInput ? 'items-end' : 'items-start',
|
||||
isFactCheck,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断是否是用户输入
|
||||
*/
|
||||
export function isUserInput(event: ExtendedEvent): boolean {
|
||||
return event.event_type === 'user_input' || !!event.metadata?.isUserInput
|
||||
}
|
||||
|
||||
/**
|
||||
* 提取文本内容
|
||||
*/
|
||||
export function extractText(obj: unknown): string {
|
||||
if (typeof obj === 'string') {
|
||||
return obj
|
||||
}
|
||||
if (obj && typeof obj === 'object') {
|
||||
const o = obj as Record<string, unknown>
|
||||
if (typeof o.text === 'string') {
|
||||
return o.text
|
||||
}
|
||||
if (o.content) {
|
||||
return extractText(o.content)
|
||||
}
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取消息内容
|
||||
*/
|
||||
export function getMessageContent(event: ExtendedEvent): string {
|
||||
const renderContent =
|
||||
event.renderProps?.content?.content || event.renderProps?.content?.text
|
||||
if (renderContent) {
|
||||
return extractText(renderContent)
|
||||
}
|
||||
if (event.content) {
|
||||
return extractText(event.content)
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
/** 图片文件扩展名 */
|
||||
export const IMAGE_EXTENSIONS = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg', 'bmp']
|
||||
|
||||
/**
|
||||
* 判断是否是图片文件
|
||||
*/
|
||||
export function isImageFile(path: string): boolean {
|
||||
const ext = path.split('.').pop()?.toLowerCase() || ''
|
||||
return IMAGE_EXTENSIONS.includes(ext)
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 content 中提取附件列表
|
||||
*/
|
||||
function extractAttachmentFiles(content: unknown): Array<{
|
||||
path: string
|
||||
file_name: string
|
||||
file_type: string
|
||||
desc?: string
|
||||
url?: string
|
||||
}> {
|
||||
if (!content || typeof content !== 'object') return []
|
||||
const c = content as Record<string, unknown>
|
||||
|
||||
if (Array.isArray(c.attachment_files)) {
|
||||
return c.attachment_files
|
||||
}
|
||||
if (c.content && typeof c.content === 'object') {
|
||||
return extractAttachmentFiles(c.content)
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取附件列表(非图片文件)
|
||||
*/
|
||||
export function getAttachments(event: ExtendedEvent): Attachment[] {
|
||||
if (event.renderProps?.attachment?.length) {
|
||||
return event.renderProps.attachment
|
||||
}
|
||||
const files = extractAttachmentFiles(event.content)
|
||||
return files
|
||||
.filter(f => !isImageFile(f.path || f.file_name))
|
||||
.map(f => ({
|
||||
file_id: f.path,
|
||||
file_name: f.file_name,
|
||||
file_type: f.file_type || f.file_name.split('.').pop() || '',
|
||||
file_url: f.url || f.path,
|
||||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取图片附件列表
|
||||
*/
|
||||
export function getImageAttachments(event: ExtendedEvent): ImageAttachment[] {
|
||||
if (event.renderProps?.imageAttachment?.length) {
|
||||
return event.renderProps.imageAttachment
|
||||
}
|
||||
const files = extractAttachmentFiles(event.content)
|
||||
return files
|
||||
.filter(f => isImageFile(f.path || f.file_name))
|
||||
.map(f => ({
|
||||
url: f.url || f.path,
|
||||
file_name: f.file_name,
|
||||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取工具调用的 action 信息
|
||||
*/
|
||||
export function getToolCallAction(event: ExtendedEvent): {
|
||||
name?: string
|
||||
arguments?: string[]
|
||||
action_type?: string
|
||||
} | null {
|
||||
if (event.event_type !== 'tool_call') return null
|
||||
|
||||
const content = event.content as Record<string, unknown> | undefined
|
||||
if (!content) return null
|
||||
|
||||
const actionName =
|
||||
(content.action_name as string) || (content.action_type as string) || ''
|
||||
const actionType =
|
||||
(content.tool_name as string) || (content.action_type as string) || ''
|
||||
|
||||
let args: string[] = []
|
||||
if (Array.isArray(content.arguments)) {
|
||||
args = content.arguments as string[]
|
||||
} else if (typeof content.arguments === 'string') {
|
||||
try {
|
||||
const parsed = JSON.parse(content.arguments)
|
||||
if (Array.isArray(parsed)) {
|
||||
args = parsed
|
||||
}
|
||||
} catch {
|
||||
args = [content.arguments]
|
||||
}
|
||||
}
|
||||
|
||||
if (!actionName && !actionType) return null
|
||||
|
||||
return { name: actionName, arguments: args, action_type: actionType }
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取计划步骤状态
|
||||
*/
|
||||
export function getPlanStepState(event: ExtendedEvent): string | null {
|
||||
const e = event as ExtendedEvent & { plan_step_state?: string }
|
||||
return e.plan_step_state || null
|
||||
}
|
||||
33
components/nova-sdk/nova-chat/ChatHeader.test.tsx
Normal file
33
components/nova-sdk/nova-chat/ChatHeader.test.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
27
components/nova-sdk/nova-chat/ChatHeader.tsx
Normal file
27
components/nova-sdk/nova-chat/ChatHeader.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
41
components/nova-sdk/nova-chat/ChatInputArea.tsx
Normal file
41
components/nova-sdk/nova-chat/ChatInputArea.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
250
components/nova-sdk/nova-chat/index.tsx
Normal file
250
components/nova-sdk/nova-chat/index.tsx
Normal 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
|
||||
48
components/nova-sdk/store/useEventStore.ts
Normal file
48
components/nova-sdk/store/useEventStore.ts
Normal 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
|
||||
32
components/nova-sdk/store/useImages.ts
Normal file
32
components/nova-sdk/store/useImages.ts
Normal 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]);
|
||||
}
|
||||
347
components/nova-sdk/task-panel/ArtifactList.tsx
Normal file
347
components/nova-sdk/task-panel/ArtifactList.tsx
Normal 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
|
||||
364
components/nova-sdk/task-panel/ArtifactPreview.tsx
Normal file
364
components/nova-sdk/task-panel/ArtifactPreview.tsx
Normal 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 || ''} />
|
||||
}
|
||||
|
||||
// CSV:fetch 内容后渲染为表格
|
||||
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
|
||||
171
components/nova-sdk/task-panel/Preview/CsvPreview.tsx
Normal file
171
components/nova-sdk/task-panel/Preview/CsvPreview.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
65
components/nova-sdk/task-panel/Preview/MarkdownPreview.tsx
Normal file
65
components/nova-sdk/task-panel/Preview/MarkdownPreview.tsx
Normal 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 预览组件 - 接收 URL,fetch 内容后渲染
|
||||
*/
|
||||
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
|
||||
211
components/nova-sdk/task-panel/Preview/PptPreview.tsx
Normal file
211
components/nova-sdk/task-panel/Preview/PptPreview.tsx
Normal 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
|
||||
162
components/nova-sdk/task-panel/Preview/ScriptPreview.tsx
Normal file
162
components/nova-sdk/task-panel/Preview/ScriptPreview.tsx
Normal 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
|
||||
110
components/nova-sdk/task-panel/Preview/ShellExecutePreview.tsx
Normal file
110
components/nova-sdk/task-panel/Preview/ShellExecutePreview.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
420
components/nova-sdk/task-panel/Preview/ToolCallPreview.tsx
Normal file
420
components/nova-sdk/task-panel/Preview/ToolCallPreview.tsx
Normal 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
|
||||
@@ -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
|
||||
70
components/nova-sdk/task-panel/Preview/UrlScriptPreview.tsx
Normal file
70
components/nova-sdk/task-panel/Preview/UrlScriptPreview.tsx
Normal 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
|
||||
|
||||
217
components/nova-sdk/task-panel/Preview/VirtualPdfPreview.tsx
Normal file
217
components/nova-sdk/task-panel/Preview/VirtualPdfPreview.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
211
components/nova-sdk/task-panel/Preview/WebSearchPreview.tsx
Normal file
211
components/nova-sdk/task-panel/Preview/WebSearchPreview.tsx
Normal 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
|
||||
31
components/nova-sdk/task-panel/Preview/index.ts
Normal file
31
components/nova-sdk/task-panel/Preview/index.ts
Normal 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'
|
||||
82
components/nova-sdk/task-panel/Preview/previewUtils.test.ts
Normal file
82
components/nova-sdk/task-panel/Preview/previewUtils.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
392
components/nova-sdk/task-panel/Preview/previewUtils.ts
Normal file
392
components/nova-sdk/task-panel/Preview/previewUtils.ts
Normal 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 || '未命名'
|
||||
}
|
||||
49
components/nova-sdk/task-panel/Preview/useHighlighter.ts
Normal file
49
components/nova-sdk/task-panel/Preview/useHighlighter.ts
Normal 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
|
||||
}
|
||||
257
components/nova-sdk/task-panel/index.tsx
Normal file
257
components/nova-sdk/task-panel/index.tsx
Normal 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'
|
||||
24
components/nova-sdk/task-panel/utils.ts
Normal file
24
components/nova-sdk/task-panel/utils.ts
Normal 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 {}
|
||||
}
|
||||
}
|
||||
150
components/nova-sdk/tools/README.md
Normal file
150
components/nova-sdk/tools/README.md
Normal 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
|
||||
61
components/nova-sdk/tools/REPLACE_SUMMARY.md
Normal file
61
components/nova-sdk/tools/REPLACE_SUMMARY.md
Normal 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
|
||||
7
components/nova-sdk/tools/components/index.ts
Normal file
7
components/nova-sdk/tools/components/index.ts
Normal 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'
|
||||
148
components/nova-sdk/tools/components/mcp-json-editor.tsx
Normal file
148
components/nova-sdk/tools/components/mcp-json-editor.tsx
Normal 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'
|
||||
147
components/nova-sdk/tools/components/skill-form.tsx
Normal file
147
components/nova-sdk/tools/components/skill-form.tsx
Normal 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'
|
||||
2
components/nova-sdk/tools/hooks/index.ts
Normal file
2
components/nova-sdk/tools/hooks/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
// Hooks
|
||||
// useExampleList removed - only create functionality needed
|
||||
8
components/nova-sdk/tools/index.ts
Normal file
8
components/nova-sdk/tools/index.ts
Normal 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'
|
||||
2
components/nova-sdk/tools/settings/index.ts
Normal file
2
components/nova-sdk/tools/settings/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
// Settings Components
|
||||
export { McpStorePopover } from './mcp-store-popover'
|
||||
68
components/nova-sdk/tools/settings/mcp-store-popover.tsx
Normal file
68
components/nova-sdk/tools/settings/mcp-store-popover.tsx
Normal 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>
|
||||
)
|
||||
})
|
||||
58
components/nova-sdk/types.test.ts
Normal file
58
components/nova-sdk/types.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
300
components/nova-sdk/types.ts
Normal file
300
components/nova-sdk/types.ts
Normal 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]
|
||||
|
||||
/** 终止态:遇到这些状态时关闭 loading(completed / 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
|
||||
420
components/nova-sdk/utils/event-render-props.ts
Normal file
420
components/nova-sdk/utils/event-render-props.ts
Normal 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_name,fallback 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),
|
||||
}
|
||||
}
|
||||
}
|
||||
47
components/nova-sdk/utils/fileIcons.ts
Normal file
47
components/nova-sdk/utils/fileIcons.ts
Normal 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' }
|
||||
}
|
||||
87
components/nova-sdk/utils/slideEventHelpers.ts
Normal file
87
components/nova-sdk/utils/slideEventHelpers.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user