Files
test1/components/nova-sdk/task-panel/ArtifactPreview.tsx
2026-03-20 07:33:46 +00:00

365 lines
12 KiB
TypeScript
Raw Blame History

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