365 lines
12 KiB
TypeScript
365 lines
12 KiB
TypeScript
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
|