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