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 /** 返回按钮点击回调 */ 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('') 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 (
) } // 脚本执行:使用自定义 SHELL_EXECUTE 预览样式组件 if (toolType === ToolType.SHELL_EXECUTE) { const outputText = extractShellOutput(artifact.tool_output || '') return } if (toolType === ToolType.SCRIPT_FILE) { const code = extractScriptCode(artifact.tool_input) const displayTitle = getDisplayTitle(artifact) // 这里复用 ShellExecutePreview 的终端风格,而不是普通 ScriptPreview return } // 其他工具调用:使用 ToolCallPreview if (isToolCall) { return ( ) } // Markdown:用 URL fetch 内容后渲染 if (isMarkdown && url) { return } // 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 } // CSV:fetch 内容后渲染为表格 const isCsv = artifact.file_type?.toLowerCase() === 'csv' || artifact.file_name?.toLowerCase().endsWith('.csv') if (isCsv && url) { return } const isPdf = artifact.file_type?.toLowerCase() === 'pdf' || artifact.file_name?.toLowerCase().endsWith('.pdf') if (isPdf && url) { return } const isScriptFileByExt = isScriptLikeFile(artifact) if (isScriptFileByExt && url) { return } if (url) { return (