Files
2026-03-20 07:33:46 +00:00

258 lines
8.4 KiB
TypeScript
Raw Permalink 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, 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'