初始化模版工程

This commit is contained in:
Cloud Bot
2026-03-20 07:33:46 +00:00
commit 23717e0ecd
386 changed files with 51675 additions and 0 deletions

View 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 || ''} />
}
// 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