初始化模版工程

This commit is contained in:
Cloud Bot
2026-03-20 07:33:46 +00:00
commit 23717e0ecd
386 changed files with 51675 additions and 0 deletions

View File

@@ -0,0 +1,347 @@
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