348 lines
10 KiB
TypeScript
348 lines
10 KiB
TypeScript
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<string, BuildNode>
|
||
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<string, BuildNode> = {}
|
||
|
||
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<string, BuildNode>): 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<string>
|
||
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 (
|
||
<div>
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
if (isFolder) {
|
||
onToggle(node.path)
|
||
return
|
||
}
|
||
|
||
if (node.artifact) {
|
||
onSelect?.(node.artifact)
|
||
}
|
||
}}
|
||
className={cn(
|
||
'flex w-full cursor-pointer items-center gap-2 rounded-lg px-2 py-4 text-left transition-colors',
|
||
'hover:bg-accent/70',
|
||
selectedState && 'bg-primary/10 text-primary dark:bg-primary/18 dark:text-primary-foreground',
|
||
)}
|
||
style={{ paddingLeft: `${depth * 16 + 8}px` }}
|
||
>
|
||
{isFolder && (<span className="flex h-4 w-4 shrink-0 items-center justify-center">
|
||
<ChevronRight className={cn('h-4 w-4 text-muted-foreground transition-transform', isExpanded && 'rotate-90')} />
|
||
</span>
|
||
)}
|
||
|
||
<FileIcon className={cn('h-4 w-4 shrink-0', color)} strokeWidth={1.8} />
|
||
|
||
<span className="min-w-0 flex-1 truncate text-sm text-foreground">
|
||
{node.name}
|
||
</span>
|
||
|
||
<span className="shrink-0 text-[10px] text-muted-foreground">
|
||
{isFolder
|
||
? `${node.fileCount} 项`
|
||
: node.artifact
|
||
? getFileTypeLabel(node.artifact)
|
||
: '文件'}
|
||
</span>
|
||
</button>
|
||
|
||
{isFolder && isExpanded && node.children?.length ? (
|
||
<div className="ml-4 border-l border-border pl-2">
|
||
{node.children.map(child => (
|
||
<TreeItem
|
||
key={child.path}
|
||
node={child}
|
||
depth={depth + 1}
|
||
collapsedPaths={collapsedPaths}
|
||
selected={selected}
|
||
onToggle={onToggle}
|
||
onSelect={onSelect}
|
||
/>
|
||
))}
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
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<Set<string>>(() => new Set())
|
||
|
||
const tree = useMemo(
|
||
() => buildFileTree(files),
|
||
[files],
|
||
)
|
||
|
||
const fileCount = files.length
|
||
|
||
if (fileCount === 0) {
|
||
return (
|
||
<div className={cn('flex items-center justify-center h-full text-sm text-muted-foreground', className)}>
|
||
暂无文件
|
||
</div>
|
||
)
|
||
}
|
||
|
||
return (
|
||
<div
|
||
className={cn(
|
||
'flex h-full bg-card overflow-hidden border border-border',
|
||
className,
|
||
)}
|
||
>
|
||
<main className="flex-1 flex min-w-0 flex-col">
|
||
<header className="h-12 md:h-14 border-b border-border flex items-center justify-between px-4 md:px-6 bg-card">
|
||
<div className="flex items-center gap-3">
|
||
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-accent text-accent-foreground">
|
||
<FolderTreeIcon />
|
||
</div>
|
||
<div className="min-w-0">
|
||
<div className="text-sm font-semibold text-foreground">文件树</div>
|
||
<div className="text-xs text-muted-foreground">按目录结构查看产物</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="flex items-center gap-2 md:gap-3">
|
||
{onClose && (
|
||
<Button
|
||
variant="ghost"
|
||
size="icon"
|
||
className="h-7 w-7 rounded-lg text-muted-foreground hover:text-primary hover:bg-accent transition-all"
|
||
onClick={onClose}
|
||
title="关闭面板"
|
||
>
|
||
<X className="w-3.5 h-3.5" />
|
||
</Button>
|
||
)}
|
||
</div>
|
||
</header>
|
||
|
||
<ScrollArea className="flex-1">
|
||
<div className="px-2 py-4">
|
||
{tree.map(node => (
|
||
<TreeItem
|
||
key={node.path}
|
||
node={node}
|
||
collapsedPaths={collapsedPaths}
|
||
selected={selected}
|
||
onToggle={(path) => {
|
||
setCollapsedPaths(prev => {
|
||
const next = new Set(prev)
|
||
if (next.has(path)) {
|
||
next.delete(path)
|
||
} else {
|
||
next.add(path)
|
||
}
|
||
return next
|
||
})
|
||
}}
|
||
onSelect={onClick}
|
||
/>
|
||
))}
|
||
</div>
|
||
</ScrollArea>
|
||
|
||
<footer className="h-9 md:h-10 border-t border-border flex items-center px-4 md:px-6 text-[11px] text-muted-foreground bg-card">
|
||
<span>{fileCount} 个文件</span>
|
||
</footer>
|
||
</main>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function FolderTreeIcon() {
|
||
return (
|
||
<div className="relative h-4 w-4">
|
||
<div className="absolute left-0 top-0 h-1.5 w-1.5 rounded-sm bg-current opacity-70" />
|
||
<div className="absolute left-0 top-2.5 h-1.5 w-1.5 rounded-sm bg-current opacity-70" />
|
||
<div className="absolute left-2.5 top-1.25 h-1.5 w-1.5 rounded-sm bg-current" />
|
||
<div className="absolute left-[3px] top-[3px] h-px w-[7px] bg-current opacity-50" />
|
||
<div className="absolute left-[3px] top-[11px] h-px w-[7px] bg-current opacity-50" />
|
||
<div className="absolute left-[9px] top-[6px] h-[5px] w-px bg-current opacity-50" />
|
||
</div>
|
||
)
|
||
}
|
||
|
||
export const ArtifactList = React.memo(InnerArtifactList)
|
||
export default ArtifactList
|