初始化模版工程
This commit is contained in:
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
|
||||
Reference in New Issue
Block a user