Files
test1/components/nova-sdk/task-panel/ArtifactList.tsx
2026-03-20 07:33:46 +00:00

348 lines
10 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, { 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