import React, { useMemo, useState } from 'react' import { ChevronRight, Folder, FolderOpen, X } from 'lucide-react' import { cn } from '@/utils/cn' import type { TaskArtifact } from '../types' import { getFileIconConfig } from '../utils/fileIcons' import { Button } from '@/components/ui/button' import { ScrollArea } from '@/components/ui/scroll-area' export interface ArtifactListProps { artifacts: TaskArtifact[] onClick?: (artifact: TaskArtifact) => void onClose?: () => void selected?: TaskArtifact | null className?: string } function getFileTypeLabel(artifact: TaskArtifact) { const raw = artifact.file_type || artifact.file_name.split('.').pop() || artifact.path?.split('.').pop() || '' const ext = raw.toLowerCase() if (!ext) return '文件' switch (ext) { case 'py': return 'Python 脚本' case 'html': return 'HTML 文档' case 'md': case 'markdown': return 'Markdown 文件' case 'png': case 'jpg': case 'jpeg': case 'gif': case 'webp': return '图片文件' case 'pdf': return 'PDF 文件' case 'csv': return 'CSV 文件' case 'ppt': case 'pptx': return '演示文稿' default: return `${ext.toUpperCase()} 文件` } } interface TreeNode { name: string path: string isFolder: boolean fileCount: number children?: TreeNode[] artifact?: TaskArtifact } type BuildNode = { name: string path: string isFolder: boolean fileCount: number children?: Record artifact?: TaskArtifact } // 只要是 http/https 链接,一律视为「网页」,不在文件列表中展示 function isHttpUrlArtifact(artifact: TaskArtifact): boolean { const rawPath = artifact.path || artifact.file_name || '' return /^https?:\/\//.test(rawPath) } function buildFileTree(artifacts: TaskArtifact[]): TreeNode[] { const root: Record = {} artifacts.forEach(artifact => { const fullPath = artifact.path || artifact.file_name // 对于 http/https URL,去掉协议和域名部分,只使用路径来构建「目录」 let normalizedPath = fullPath if (fullPath.startsWith('http://') || fullPath.startsWith('https://')) { try { const url = new URL(fullPath) normalizedPath = url.pathname && url.pathname !== '/' ? url.pathname : url.hostname } catch { // 如果 URL 解析失败,退回原始 fullPath normalizedPath = fullPath } } const parts = normalizedPath.split('/').filter(Boolean) let cur = root let curPath = '' parts.forEach((part, i) => { curPath = curPath ? `${curPath}/${part}` : part const isLast = i === parts.length - 1 if (!cur[part]) { cur[part] = { name: part, path: curPath, isFolder: !isLast, fileCount: 0, children: isLast ? undefined : {}, artifact: isLast ? artifact : undefined, } } else if (isLast) { cur[part].artifact = artifact } if (!isLast && cur[part].children) { cur = cur[part].children! } }) }) const toArr = (obj: Record): TreeNode[] => Object.values(obj) .map((n) => { const children = n.children ? toArr(n.children) : undefined const fileCount = n.isFolder ? (children || []).reduce((sum, child) => sum + child.fileCount, 0) : 1 return { name: n.name, path: n.path, isFolder: n.isFolder, fileCount, artifact: n.artifact, children, } }) .sort((a, b) => { if (a.isFolder !== b.isFolder) return a.isFolder ? -1 : 1 return a.name.localeCompare(b.name) }) return toArr(root) } function isSelectedArtifact(node: TreeNode, selected?: TaskArtifact | null) { return !node.isFolder && !!node.artifact && selected?.path === node.artifact.path } interface TreeItemProps { node: TreeNode depth?: number collapsedPaths: Set selected?: TaskArtifact | null onToggle: (path: string) => void onSelect?: (artifact: TaskArtifact) => void } function TreeItem({ node, depth = 0, collapsedPaths, selected, onToggle, onSelect, }: TreeItemProps) { const isFolder = node.isFolder const isExpanded = isFolder && !collapsedPaths.has(node.path) const selectedState = isSelectedArtifact(node, selected) const { icon: FileIcon, color } = isFolder ? { icon: isExpanded ? FolderOpen : Folder, color: 'text-primary' } : getFileIconConfig( node.artifact?.file_type || node.artifact?.file_name.split('.').pop() || '', ) return (
{isFolder && isExpanded && node.children?.length ? (
{node.children.map(child => ( ))}
) : null}
) } function InnerArtifactList({ artifacts, onClick, onClose, selected, className }: ArtifactListProps) { const files = useMemo( () => artifacts.filter( (a) => a.event_type !== 'tool_call' && !isHttpUrlArtifact(a), ), [artifacts], ) const [collapsedPaths, setCollapsedPaths] = useState>(() => new Set()) const tree = useMemo( () => buildFileTree(files), [files], ) const fileCount = files.length if (fileCount === 0) { return (
暂无文件
) } return (
文件树
按目录结构查看产物
{onClose && ( )}
{tree.map(node => ( { setCollapsedPaths(prev => { const next = new Set(prev) if (next.has(path)) { next.delete(path) } else { next.add(path) } return next }) }} onSelect={onClick} /> ))}
{fileCount} 个文件
) } function FolderTreeIcon() { return (
) } export const ArtifactList = React.memo(InnerArtifactList) export default ArtifactList