初始化模版工程

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,257 @@
import React, { useState, useCallback, useMemo, useEffect, useRef } from 'react'
import { cn } from '@/utils/cn'
import type { TaskArtifact } from '../types'
import { ArtifactList } from './ArtifactList'
import { isImageFile } from './utils'
import { ArtifactPreview } from './ArtifactPreview'
import { useEventStore } from '../store/useEventStore'
import { extractToolOutputArtifact } from './Preview/previewUtils'
import { useNovaKit } from '../context/useNovaKit'
import { Dialog, DialogContent } from '@/components/ui/dialog'
export interface TaskPanelProps {
/** 文件列表 */
artifacts: TaskArtifact[]
/** 是否可见 */
visible?: boolean
/** 面板宽度 */
width?: number | string
/** 获取文件 URL 的函数 */
getUrl?: (artifact: TaskArtifact) => string | Promise<string>
/** 下载文件回调 */
onDownload?: (artifact: TaskArtifact) => void
/** 关闭面板回调 */
onClose?: () => void
/** 自定义类名 */
className?: string
/** 初始选中的文件 */
initialSelected?: TaskArtifact | null
}
/**
* 任务面板组件 - 展示图片和文件
*/
function InnerTaskPanel({
artifacts: artifactsProp,
visible = true,
width = '50%',
getUrl,
onDownload,
onClose,
className,
initialSelected,
}: TaskPanelProps) {
const { panelMode } = useNovaKit()
const [selectedArtifact, setSelectedArtifact] = useState<TaskArtifact | null>(initialSelected || null)
const [fromFileList, setFromFileList] = useState(false)
// 记录用户是否已主动选择过文件,避免流式消息到来时强制刷新预览
const userSelectedRef = useRef(false)
// 从 store 获取 events 和 artifacts
const events = useEventStore((state) => state.events)
const artifactsFromStore = useEventStore((state) => state.artifacts)
// 将 tool_call 类型的 events 转换为 artifacts
const toolCallArtifacts = useMemo((): TaskArtifact[] => {
return events
.filter((event) => event.event_type === 'tool_call')
.map((event) => {
const actionType = (event.content?.action_type as string | undefined) || undefined
const toolName = event.content?.tool_name as string | undefined
const actionName = event.content?.action_name as string | undefined
const isSkillLoader =
actionType === 'skill_loader' ||
actionName === 'skill_loader' ||
toolName === 'skill_loader'
const base: TaskArtifact = {
path: event.event_id,
file_name: toolName || '工具调用',
file_type: 'tool_call',
event_type: 'tool_call',
action_type: isSkillLoader
? 'skill_loader'
: actionType || actionName || toolName,
tool_name: toolName,
event_arguments: event.content?.arguments,
tool_input: event.content?.tool_input,
tool_output: event.content?.tool_output,
}
// Skill Loader按照 remix 中的逻辑,作为 Markdown 文件渲染
if (isSkillLoader) {
const metaToolName = (event.content?.metadata as Record<string, unknown>)?.tool_name as string | undefined
const output = event.content?.tool_output
const content =
typeof output === 'string'
? output
: output != null
? JSON.stringify(output, null, 2)
: ''
return {
...base,
file_type: 'md',
file_name: metaToolName || base.file_name || 'Skill 文档',
content,
}
}
const outputArtifact = extractToolOutputArtifact(base)
if (outputArtifact) {
return outputArtifact
}
return base
})
}, [events])
// 合并所有 artifacts优先使用 store 中的,然后是 props 传入的,最后是 tool_call
const allArtifacts = useMemo(() => {
// 如果 store 中有数据,优先使用 store
const baseArtifacts = artifactsFromStore.length > 0 ? artifactsFromStore : artifactsProp
const merged = [...baseArtifacts, ...toolCallArtifacts]
// 过滤掉 http(s) URL 中,看起来不像「文件」的条目(避免普通网页链接出现在文件列表)
return merged.filter((artifact) => {
const path = artifact.path || artifact.file_name || ''
// 不是 http(s) 链接的,一律保留
if (!/^https?:\/\//.test(path)) return true
try {
const url = new URL(path)
const pathname = url.pathname || ''
// 没有路径(如 https://example.com——按非文件处理过滤掉
if (!pathname || pathname === '/') return false
// 根据路径末尾扩展名判断是否是「文件」
const lastSegment = pathname.split('/').filter(Boolean).pop() || ''
const match = lastSegment.match(/\.([a-zA-Z0-9]+)$/)
if (!match) return false
const ext = match[1].toLowerCase()
const fileLikeExts = [
'pdf',
'png',
'jpg',
'jpeg',
'gif',
'webp',
'csv',
'ppt',
'pptx',
'doc',
'docx',
'xls',
'xlsx',
'md',
'markdown',
'txt',
'json',
]
// 只有在扩展名属于常见文件类型时,才当作文件保留;否则视为网页链接,过滤掉
return fileLikeExts.includes(ext)
} catch {
// URL 解析失败时保留,避免误删正常路径
return true
}
})
}, [artifactsFromStore, artifactsProp, toolCallArtifacts])
// 仅在 initialSelected 实际变化时同步(不依赖 allArtifacts避免流推时反复触发
useEffect(() => {
if (initialSelected) {
userSelectedRef.current = false
setSelectedArtifact(initialSelected)
setFromFileList(false)
}
}, [initialSelected])
// 只有「当前没有选中」时才自动选中单一文件,防止流式推送中途重置预览
useEffect(() => {
if (!initialSelected && allArtifacts.length === 1) {
setSelectedArtifact(prev => prev ?? allArtifacts[0])
setFromFileList(false)
}
// 用 length 而非整个 allArtifacts避免每次新事件导致数组引用变化而触发
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [allArtifacts.length, initialSelected])
// 筛选出所有图片
const images = useMemo(() => {
return allArtifacts.filter(a => isImageFile(a.path))
}, [allArtifacts])
// 选择文件
const handleSelect = useCallback((artifact: TaskArtifact) => {
userSelectedRef.current = true
setSelectedArtifact(artifact)
setFromFileList(true)
}, [])
// 返回列表
const handleBack = useCallback(() => {
setSelectedArtifact(null)
setFromFileList(false)
}, [])
const panel = (
<div
className={cn(
'h-full flex flex-col rounded-none border-l border-border',
'transition-all duration-300 ease-in-out',
className
)}
style={{ width: panelMode === 'dialog' ? '100%' : width }}
>
{/* 内容区 */}
<div className="flex-1 overflow-hidden relative">
{selectedArtifact || (allArtifacts.length === 1 && allArtifacts[0]) ? (
<div className="absolute inset-0 animate-in fade-in-0 slide-in-from-right-4 duration-300">
<ArtifactPreview
artifact={selectedArtifact || allArtifacts[0]}
fromFileList={fromFileList}
images={images}
getUrl={getUrl}
onBack={allArtifacts.length > 1 ? handleBack : undefined}
onDownload={onDownload}
onClose={onClose}
/>
</div>
) : (
<div className="absolute inset-0 animate-in fade-in-0 slide-in-from-left-4 duration-300">
<ArtifactList
artifacts={allArtifacts}
onClick={handleSelect}
selected={selectedArtifact}
className="h-full"
/>
</div>
)}
</div>
</div>
)
if (panelMode === 'dialog') {
return (
<Dialog open={visible} onOpenChange={(open) => { if (!open) onClose?.() }}>
<DialogContent className="max-w-5xl w-[90vw] h-[80vh] p-0 overflow-hidden">
{panel}
</DialogContent>
</Dialog>
)
}
return panel
}
export const TaskPanel = React.memo(InnerTaskPanel)
export default TaskPanel
// 导出子组件
export { ArtifactList } from './ArtifactList'
export { ArtifactPreview } from './ArtifactPreview'