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 /** 下载文件回调 */ 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(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)?.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 = (
{/* 内容区 */}
{selectedArtifact || (allArtifacts.length === 1 && allArtifacts[0]) ? (
1 ? handleBack : undefined} onDownload={onDownload} onClose={onClose} />
) : (
)}
) if (panelMode === 'dialog') { return ( { if (!open) onClose?.() }}> {panel} ) } return panel } export const TaskPanel = React.memo(InnerTaskPanel) export default TaskPanel // 导出子组件 export { ArtifactList } from './ArtifactList' export { ArtifactPreview } from './ArtifactPreview'