Browse Source

perf(knowledge): 文件管理页面性能优化 — 懒加载目录树 + 异步文件扫描

## 核心优化
- _list_dir_tree 全递归(depth 8)→ _list_dir_shallow 仅扫描一层
- 新增 GET /{kb_id}/dirs?path= 懒加载端点,前端展开时按需请求子目录
- _walk_dir + _list_dir_shallow 改为 run_in_executor 异步执行,不阻塞事件循环
- listFiles 支持 path(子目录浏览)和 q(文件名过滤)参数
- _walk_dir limit 从 2000 → 500(按目录浏览后不需要一次加载所有文件)

## 前端优化
- DirTreeNode: 展开时懒加载子节点(fetch /dirs?path=...),只在用户点击时发请求
  - 展开箭头只在 has_children=true 时显示
  - 加载中显示转圈 icon
- FileManager:
  - 面包屑导航:点击目录进入,点击面包屑返回上级,只加载当前层文件
  - 文件名搜索框:350ms 防抖,后端过滤
  - 刷新按钮 isFetching 时显示旋转动画
- KBDirNode 类型新增 has_children 字段

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
kenny67nju 5 months ago
parent
commit
be9a61c99f
3 changed files with 282 additions and 151 deletions
  1. 94 41
      agentpaas/api/v1/knowledge.py
  2. 16 3
      webui/src/api/knowledge.ts
  3. 172 107
      webui/src/pages/KnowledgeDetail.tsx

+ 94 - 41
agentpaas/api/v1/knowledge.py

@@ -79,20 +79,31 @@ class UpdateIgnoreDirsRequest(BaseModel):
 
 
 # ─── Helper: walk root_dir ───
 # ─── Helper: walk root_dir ───
 
 
-def _walk_dir(root_dir: str, added_paths: set, ignored_dirs: Optional[Set[str]] = None) -> List[Dict]:
+def _walk_dir(
+    root_dir: str,
+    added_paths: set,
+    ignored_dirs: Optional[Set[str]] = None,
+    subpath: str = "",
+    name_filter: str = "",
+    limit: int = 500,
+) -> List[Dict]:
     """
     """
-    Recursively list files under root_dir, skipping ignored directories.
-    ignored_dirs: set of relative paths (e.g. {'processed', 'raw', 'wiki'}).
-      Both bare names ('raw') and nested paths ('data/raw') are matched.
-    Returns up to 2000 file entries.
+    Recursively list files under root_dir (or subpath within root_dir).
+    - ignored_dirs: set of relative paths to skip
+    - subpath: only scan this subdirectory (relative to root_dir)
+    - name_filter: case-insensitive filename substring filter
+    - limit: max results (default 500, was 2000)
     """
     """
     ignored = ignored_dirs or set()
     ignored = ignored_dirs or set()
     items: List[Dict] = []
     items: List[Dict] = []
     root = Path(root_dir)
     root = Path(root_dir)
-    if not root.is_dir():
+    scan_root = (root / subpath) if subpath else root
+    if not scan_root.is_dir():
         return items
         return items
 
 
-    for dirpath, dirnames, filenames in os.walk(root_dir, topdown=True):
+    name_lower = name_filter.lower() if name_filter else ""
+
+    for dirpath, dirnames, filenames in os.walk(str(scan_root), topdown=True):
         rel_dir = Path(dirpath).relative_to(root)
         rel_dir = Path(dirpath).relative_to(root)
         parts = rel_dir.parts  # () for root itself
         parts = rel_dir.parts  # () for root itself
 
 
@@ -103,10 +114,10 @@ def _walk_dir(root_dir: str, added_paths: set, ignored_dirs: Optional[Set[str]]
                 for i in range(len(parts))
                 for i in range(len(parts))
             )
             )
             if ignored_hit:
             if ignored_hit:
-                dirnames.clear()   # prune: don't recurse further
+                dirnames.clear()
                 continue
                 continue
 
 
-        # Prune child dirs that match ignore list before recursing
+        # Prune child dirs that match ignore list
         dirnames[:] = sorted(
         dirnames[:] = sorted(
             d for d in dirnames
             d for d in dirnames
             if d not in ignored and (
             if d not in ignored and (
@@ -115,6 +126,8 @@ def _walk_dir(root_dir: str, added_paths: set, ignored_dirs: Optional[Set[str]]
         )
         )
 
 
         for fname in sorted(filenames):
         for fname in sorted(filenames):
+            if name_lower and name_lower not in fname.lower():
+                continue
             p = Path(dirpath) / fname
             p = Path(dirpath) / fname
             if p.suffix.lower() not in SUPPORTED_EXTS:
             if p.suffix.lower() not in SUPPORTED_EXTS:
                 continue
                 continue
@@ -132,43 +145,47 @@ def _walk_dir(root_dir: str, added_paths: set, ignored_dirs: Optional[Set[str]]
                 "modified": stat.st_mtime,
                 "modified": stat.st_mtime,
                 "added": str(p) in added_paths,
                 "added": str(p) in added_paths,
             })
             })
-            if len(items) >= 2000:
+            if len(items) >= limit:
                 return items
                 return items
 
 
     return items
     return items
 
 
 
 
-def _list_dir_tree(root_dir: str, ignored: Set[str], max_depth: int = 8) -> List[Dict]:
+def _list_dir_shallow(root_dir: str, parent_rel: str, ignored: Set[str]) -> List[Dict]:
     """
     """
-    Recursively list all subdirectories as a nested tree.
-    Each node: {name, relative_path, ignored, children}.
-    `ignored` is True only if THIS node's own path is in the ignore set —
-    the frontend handles ancestor propagation for inherited-ignore display.
+    List ONE level of subdirectories under parent_rel (relative to root_dir).
+    Returns nodes with empty children=[]; has_children indicates whether the
+    node has any subdirectory children so the frontend can show an expand arrow.
+    This replaces the full-depth _list_dir_tree to support lazy loading.
     """
     """
     root = Path(root_dir)
     root = Path(root_dir)
-
-    def _scan(path: Path, rel: str, depth: int) -> List[Dict]:
-        if depth > max_depth:
-            return []
-        result = []
-        try:
-            for child in sorted(path.iterdir()):
-                if not child.is_dir():
-                    continue
-                child_rel = f"{rel}/{child.name}" if rel else child.name
-                self_ignored = child.name in ignored or child_rel in ignored
-                children = _scan(child, child_rel, depth + 1)
-                result.append({
-                    "name": child.name,
-                    "relative_path": child_rel,
-                    "ignored": self_ignored,
-                    "children": children,
-                })
-        except OSError:
-            pass
-        return result
-
-    return _scan(root, "", 0)
+    scan_path = (root / parent_rel) if parent_rel else root
+    result = []
+    try:
+        for child in sorted(scan_path.iterdir()):
+            if not child.is_dir():
+                continue
+            child_rel = f"{parent_rel}/{child.name}" if parent_rel else child.name
+            self_ignored = child.name in ignored or child_rel in ignored
+            # Peek one level to know if there are subdirs (cheap: stops at first hit)
+            has_children = False
+            try:
+                for gc in child.iterdir():
+                    if gc.is_dir():
+                        has_children = True
+                        break
+            except OSError:
+                pass
+            result.append({
+                "name": child.name,
+                "relative_path": child_rel,
+                "ignored": self_ignored,
+                "has_children": has_children,
+                "children": [],      # populated lazily by the frontend
+            })
+    except OSError:
+        pass
+    return result
 
 
 
 
 # ─── Helper: run index build in background ───
 # ─── Helper: run index build in background ───
@@ -665,6 +682,8 @@ async def list_chunks(
 @router.get("/{kb_id}/files")
 @router.get("/{kb_id}/files")
 async def list_files(
 async def list_files(
     kb_id: str,
     kb_id: str,
+    path: str = Query(default="", description="Subdirectory relative path to list files in"),
+    q: str = Query(default="", description="Filename filter (case-insensitive substring)"),
     tenant: TenantContext = Depends(get_tenant),
     tenant: TenantContext = Depends(get_tenant),
     db: Database = Depends(get_database),
     db: Database = Depends(get_database),
 ):
 ):
@@ -681,18 +700,52 @@ async def list_files(
 
 
     added = db.fetchall("SELECT file_path FROM kb_files WHERE kb_id=?", (kb_id,))
     added = db.fetchall("SELECT file_path FROM kb_files WHERE kb_id=?", (kb_id,))
     added_paths = {r["file_path"] for r in added}
     added_paths = {r["file_path"] for r in added}
-    files = _walk_dir(kb["root_dir"], added_paths, ignored)
-    dirs = _list_dir_tree(kb["root_dir"], ignored)
+
+    root_dir = kb["root_dir"]
+    # Run blocking I/O off the event loop
+    files, dirs = await asyncio.get_event_loop().run_in_executor(
+        None,
+        lambda: (
+            _walk_dir(root_dir, added_paths, ignored, subpath=path, name_filter=q),
+            _list_dir_shallow(root_dir, path, ignored),
+        )
+    )
 
 
     return {
     return {
         "kb_id": kb_id,
         "kb_id": kb_id,
-        "root_dir": kb["root_dir"],
+        "root_dir": root_dir,
         "files": files,
         "files": files,
         "dirs": dirs,
         "dirs": dirs,
         "ignored_dirs": sorted(ignored),
         "ignored_dirs": sorted(ignored),
+        "current_path": path,
     }
     }
 
 
 
 
+@router.get("/{kb_id}/dirs")
+async def list_dirs(
+    kb_id: str,
+    path: str = Query(default="", description="Parent directory relative path"),
+    tenant: TenantContext = Depends(get_tenant),
+    db: Database = Depends(get_database),
+):
+    """Return immediate subdirectories of `path` (lazy tree expansion)."""
+    kb = db.fetchone("SELECT * FROM knowledge_bases WHERE id=? AND tenant_id=?", (kb_id, tenant.tenant_id))
+    if not kb:
+        raise HTTPException(status_code=404, detail="Knowledge base not found")
+
+    raw_ignored = kb.get("ignore_dirs") or "[]"
+    try:
+        ignored: Set[str] = set(json.loads(raw_ignored))
+    except Exception:
+        ignored = set()
+
+    dirs = await asyncio.get_event_loop().run_in_executor(
+        None,
+        lambda: _list_dir_shallow(kb["root_dir"], path, ignored),
+    )
+    return {"dirs": dirs, "path": path}
+
+
 @router.get("/{kb_id}/ignore-dirs")
 @router.get("/{kb_id}/ignore-dirs")
 async def get_ignore_dirs(
 async def get_ignore_dirs(
     kb_id: str,
     kb_id: str,

+ 16 - 3
webui/src/api/knowledge.ts

@@ -28,7 +28,8 @@ export interface KBDirNode {
   name: string
   name: string
   relative_path: string
   relative_path: string
   ignored: boolean          // true if this specific path is in the ignore list
   ignored: boolean          // true if this specific path is in the ignore list
-  children: KBDirNode[]    // nested subdirectories
+  has_children: boolean     // whether subdirs exist (for lazy expand arrow)
+  children: KBDirNode[]    // loaded lazily on expand
 }
 }
 
 
 export interface KBJob {
 export interface KBJob {
@@ -85,8 +86,20 @@ export const knowledgeApi = {
     api.post<KnowledgeBase>('/knowledge', data),
     api.post<KnowledgeBase>('/knowledge', data),
   delete: (id: string) => api.delete(`/knowledge/${id}`),
   delete: (id: string) => api.delete(`/knowledge/${id}`),
 
 
-  listFiles: (kbId: string) =>
-    api.get<{ kb_id: string; root_dir: string; files: KBFile[]; dirs: KBDirNode[]; ignored_dirs: string[] }>(`/knowledge/${kbId}/files`),
+  listFiles: (kbId: string, params?: { path?: string; q?: string }) => {
+    const qs = new URLSearchParams()
+    if (params?.path) qs.set('path', params.path)
+    if (params?.q)    qs.set('q', params.q)
+    const query = qs.toString()
+    return api.get<{ kb_id: string; root_dir: string; files: KBFile[]; dirs: KBDirNode[]; ignored_dirs: string[]; current_path: string }>(
+      `/knowledge/${kbId}/files${query ? '?' + query : ''}`
+    )
+  },
+
+  listDirs: (kbId: string, path?: string) => {
+    const qs = path ? `?path=${encodeURIComponent(path)}` : ''
+    return api.get<{ dirs: KBDirNode[]; path: string }>(`/knowledge/${kbId}/dirs${qs}`)
+  },
 
 
   getIgnoreDirs: (kbId: string) =>
   getIgnoreDirs: (kbId: string) =>
     api.get<{ ignored_dirs: string[] }>(`/knowledge/${kbId}/ignore-dirs`),
     api.get<{ ignored_dirs: string[] }>(`/knowledge/${kbId}/ignore-dirs`),

+ 172 - 107
webui/src/pages/KnowledgeDetail.tsx

@@ -36,6 +36,7 @@ function JobStatusBadge({ status }: { status: KBJob['status'] | string }) {
 
 
 // Recursive tree node for a single directory
 // Recursive tree node for a single directory
 function DirTreeNode({
 function DirTreeNode({
+  kbId,
   node,
   node,
   ignoredSet,
   ignoredSet,
   ancestorIgnored,
   ancestorIgnored,
@@ -43,6 +44,7 @@ function DirTreeNode({
   onToggle,
   onToggle,
   saving,
   saving,
 }: {
 }: {
+  kbId: string
   node: KBDirNode
   node: KBDirNode
   ignoredSet: Set<string>
   ignoredSet: Set<string>
   ancestorIgnored: boolean
   ancestorIgnored: boolean
@@ -50,10 +52,32 @@ function DirTreeNode({
   onToggle: (path: string, shouldIgnore: boolean) => void
   onToggle: (path: string, shouldIgnore: boolean) => void
   saving: boolean
   saving: boolean
 }) {
 }) {
-  const [expanded, setExpanded] = useState(depth === 0)
+  const [expanded, setExpanded] = useState(false)
+  // Children are loaded lazily: null = not yet fetched, [] = fetched (possibly empty)
+  const [lazyChildren, setLazyChildren] = useState<KBDirNode[] | null>(
+    node.children.length > 0 ? node.children : null
+  )
+  const [loadingChildren, setLoadingChildren] = useState(false)
+
   const selfIgnored = ignoredSet.has(node.relative_path)
   const selfIgnored = ignoredSet.has(node.relative_path)
   const effectivelyIgnored = selfIgnored || ancestorIgnored
   const effectivelyIgnored = selfIgnored || ancestorIgnored
-  const hasChildren = node.children.length > 0
+  const canExpand = node.has_children || node.children.length > 0
+
+  const handleExpand = async () => {
+    const next = !expanded
+    setExpanded(next)
+    if (next && lazyChildren === null) {
+      setLoadingChildren(true)
+      try {
+        const res = await knowledgeApi.listDirs(kbId, node.relative_path)
+        setLazyChildren(res.dirs)
+      } catch {
+        setLazyChildren([])
+      } finally {
+        setLoadingChildren(false)
+      }
+    }
+  }
 
 
   return (
   return (
     <div>
     <div>
@@ -64,19 +88,19 @@ function DirTreeNode({
       >
       >
         {/* Expand / collapse arrow */}
         {/* Expand / collapse arrow */}
         <button
         <button
-          onClick={() => setExpanded(e => !e)}
-          className={`shrink-0 text-gray-400 hover:text-gray-600 transition-transform ${hasChildren ? '' : 'invisible'}`}
+          onClick={handleExpand}
+          className={`shrink-0 text-gray-400 hover:text-gray-600 ${canExpand ? '' : 'invisible'}`}
         >
         >
-          {expanded ? <ChevronDown size={13} /> : <ChevronRight size={13} />}
+          {loadingChildren
+            ? <RefreshCw size={13} className="animate-spin text-indigo-400"/>
+            : expanded ? <ChevronDown size={13}/> : <ChevronRight size={13}/>}
         </button>
         </button>
 
 
-        {/* Folder icon */}
         <Folder
         <Folder
           size={14}
           size={14}
           className={selfIgnored ? 'text-orange-300 shrink-0' : ancestorIgnored ? 'text-gray-300 shrink-0' : 'text-indigo-400 shrink-0'}
           className={selfIgnored ? 'text-orange-300 shrink-0' : ancestorIgnored ? 'text-gray-300 shrink-0' : 'text-indigo-400 shrink-0'}
         />
         />
 
 
-        {/* Dir name */}
         <span
         <span
           className={`flex-1 text-sm font-mono truncate
           className={`flex-1 text-sm font-mono truncate
             ${selfIgnored ? 'line-through text-gray-400'
             ${selfIgnored ? 'line-through text-gray-400'
@@ -86,12 +110,10 @@ function DirTreeNode({
           {node.name}/
           {node.name}/
         </span>
         </span>
 
 
-        {/* Inherited-ignore tag */}
         {ancestorIgnored && !selfIgnored && (
         {ancestorIgnored && !selfIgnored && (
           <span className="text-xs text-gray-400 px-1.5">继承</span>
           <span className="text-xs text-gray-400 px-1.5">继承</span>
         )}
         )}
 
 
-        {/* Eye toggle — only when not hidden by an ancestor */}
         {!ancestorIgnored && (
         {!ancestorIgnored && (
           <button
           <button
             onClick={() => onToggle(node.relative_path, !selfIgnored)}
             onClick={() => onToggle(node.relative_path, !selfIgnored)}
@@ -102,15 +124,16 @@ function DirTreeNode({
                 ? 'text-orange-500 hover:bg-orange-100 !opacity-100'
                 ? 'text-orange-500 hover:bg-orange-100 !opacity-100'
                 : 'text-gray-400 hover:text-gray-700 hover:bg-gray-200'}`}
                 : 'text-gray-400 hover:text-gray-700 hover:bg-gray-200'}`}
           >
           >
-            {selfIgnored ? <Eye size={14} /> : <EyeOff size={14} />}
+            {selfIgnored ? <Eye size={14}/> : <EyeOff size={14}/>}
           </button>
           </button>
         )}
         )}
       </div>
       </div>
 
 
-      {/* Children */}
-      {expanded && hasChildren && node.children.map(child => (
+      {/* Lazily-loaded children */}
+      {expanded && lazyChildren && lazyChildren.map(child => (
         <DirTreeNode
         <DirTreeNode
           key={child.relative_path}
           key={child.relative_path}
+          kbId={kbId}
           node={child}
           node={child}
           ignoredSet={ignoredSet}
           ignoredSet={ignoredSet}
           ancestorIgnored={effectivelyIgnored}
           ancestorIgnored={effectivelyIgnored}
@@ -143,12 +166,14 @@ function collectIgnoredPaths(nodes: KBDirNode[], ignoredSet: Set<string>, acc: s
 }
 }
 
 
 function DirIgnorePanel({
 function DirIgnorePanel({
+  kbId,
   dirs,
   dirs,
   ignoredDirs,
   ignoredDirs,
   onToggle,
   onToggle,
   onClearAll,
   onClearAll,
   saving,
   saving,
 }: {
 }: {
+  kbId: string
   dirs: KBDirNode[]
   dirs: KBDirNode[]
   ignoredDirs: string[]
   ignoredDirs: string[]
   onToggle: (path: string, shouldIgnore: boolean) => void
   onToggle: (path: string, shouldIgnore: boolean) => void
@@ -161,7 +186,6 @@ function DirIgnorePanel({
 
 
   return (
   return (
     <div className="border border-gray-200 rounded-xl overflow-hidden">
     <div className="border border-gray-200 rounded-xl overflow-hidden">
-      {/* Header */}
       <button
       <button
         onClick={() => setOpen(o => !o)}
         onClick={() => setOpen(o => !o)}
         className="w-full flex items-center justify-between px-4 py-2.5 bg-gray-50 hover:bg-gray-100 transition-colors"
         className="w-full flex items-center justify-between px-4 py-2.5 bg-gray-50 hover:bg-gray-100 transition-colors"
@@ -171,14 +195,13 @@ function DirIgnorePanel({
           目录过滤
           目录过滤
           {ignoredCount > 0 && (
           {ignoredCount > 0 && (
             <span className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded-full text-xs bg-orange-100 text-orange-700 font-medium">
             <span className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded-full text-xs bg-orange-100 text-orange-700 font-medium">
-              <EyeOff size={10} /> {ignoredCount} 已隐藏
+              <EyeOff size={10}/> {ignoredCount} 已隐藏
             </span>
             </span>
           )}
           )}
         </div>
         </div>
-        <ChevronDown size={14} className={`text-gray-400 transition-transform ${open ? 'rotate-180' : ''}`} />
+        <ChevronDown size={14} className={`text-gray-400 transition-transform ${open ? 'rotate-180' : ''}`}/>
       </button>
       </button>
 
 
-      {/* Tree */}
       {open && (
       {open && (
         <>
         <>
           {dirs.length === 0 ? (
           {dirs.length === 0 ? (
@@ -188,6 +211,7 @@ function DirIgnorePanel({
               {dirs.map(node => (
               {dirs.map(node => (
                 <DirTreeNode
                 <DirTreeNode
                   key={node.relative_path}
                   key={node.relative_path}
+                  kbId={kbId}
                   node={node}
                   node={node}
                   ignoredSet={ignoredSet}
                   ignoredSet={ignoredSet}
                   ancestorIgnored={false}
                   ancestorIgnored={false}
@@ -198,17 +222,11 @@ function DirIgnorePanel({
               ))}
               ))}
             </div>
             </div>
           )}
           )}
-
           {ignoredCount > 0 && (
           {ignoredCount > 0 && (
             <div className="px-4 py-2 bg-orange-50 border-t border-orange-100 flex items-center justify-between">
             <div className="px-4 py-2 bg-orange-50 border-t border-orange-100 flex items-center justify-between">
-              <p className="text-xs text-orange-600">
-                已隐藏的目录不会出现在文件列表中,也不会被加入知识体。
-              </p>
-              <button
-                onClick={onClearAll}
-                disabled={saving}
-                className="text-xs text-orange-700 hover:underline ml-4 shrink-0 disabled:opacity-50"
-              >
+              <p className="text-xs text-orange-600">已隐藏的目录不会出现在文件列表中,也不会被加入知识体。</p>
+              <button onClick={onClearAll} disabled={saving}
+                className="text-xs text-orange-700 hover:underline ml-4 shrink-0 disabled:opacity-50">
                 全部恢复
                 全部恢复
               </button>
               </button>
             </div>
             </div>
@@ -222,11 +240,23 @@ function DirIgnorePanel({
 function FileManager({ kbId }: { kbId: string }) {
 function FileManager({ kbId }: { kbId: string }) {
   const qc = useQueryClient()
   const qc = useQueryClient()
   const [selected, setSelected] = useState<Set<string>>(new Set())
   const [selected, setSelected] = useState<Set<string>>(new Set())
+  // Current browsed subdirectory path (empty = root)
+  const [currentPath, setCurrentPath] = useState('')
+  // Filename search (debounced to query)
+  const [nameInput, setNameInput] = useState('')
+  const [nameFilter, setNameFilter] = useState('')
 
 
-  const { data, isLoading, refetch } = useQuery({
-    queryKey: ['kb-files', kbId],
-    queryFn: () => knowledgeApi.listFiles(kbId),
-  })
+  // Debounce name filter
+  useEffect(() => {
+    const t = setTimeout(() => setNameFilter(nameInput), 350)
+    return () => clearTimeout(t)
+  }, [nameInput])
+
+  const { data, isLoading, isFetching, refetch } = useQuery({
+    queryKey: ['kb-files', kbId, currentPath, nameFilter],
+    queryFn: () => knowledgeApi.listFiles(kbId, { path: currentPath, q: nameFilter }),
+    keepPreviousData: true,
+  } as any)
 
 
   const addMut = useMutation({
   const addMut = useMutation({
     mutationFn: (paths: string[]) => knowledgeApi.addFiles(kbId, paths),
     mutationFn: (paths: string[]) => knowledgeApi.addFiles(kbId, paths),
@@ -245,55 +275,54 @@ function FileManager({ kbId }: { kbId: string }) {
   })
   })
 
 
   const handleToggleDir = (dirPath: string, shouldIgnore: boolean) => {
   const handleToggleDir = (dirPath: string, shouldIgnore: boolean) => {
-    const current = new Set(data?.ignored_dirs ?? [])
+    const current = new Set((data as any)?.ignored_dirs ?? [])
     if (shouldIgnore) current.add(dirPath)
     if (shouldIgnore) current.add(dirPath)
     else current.delete(dirPath)
     else current.delete(dirPath)
-    ignoreMut.mutate(Array.from(current))
+    ignoreMut.mutate(Array.from(current) as string[])
   }
   }
 
 
   const handleClearAll = () => ignoreMut.mutate([])
   const handleClearAll = () => ignoreMut.mutate([])
 
 
-  const files = data?.files ?? []
-  const dirs = data?.dirs ?? []
-  const ignoredDirs = data?.ignored_dirs ?? []
+  const d = data as any
+  const files: KBFile[] = d?.files ?? []
+  const dirs = d?.dirs ?? []
+  const ignoredDirs: string[] = d?.ignored_dirs ?? []
   const notAdded = files.filter(f => !f.added)
   const notAdded = files.filter(f => !f.added)
   const added = files.filter(f => f.added)
   const added = files.filter(f => f.added)
 
 
-  const toggle = (path: string) => {
-    setSelected(s => {
-      const n = new Set(s)
-      n.has(path) ? n.delete(path) : n.add(path)
-      return n
-    })
-  }
-
+  const toggle = (path: string) => setSelected(s => {
+    const n = new Set(s); n.has(path) ? n.delete(path) : n.add(path); return n
+  })
   const toggleAll = () => {
   const toggleAll = () => {
     if (selected.size === notAdded.length) setSelected(new Set())
     if (selected.size === notAdded.length) setSelected(new Set())
     else setSelected(new Set(notAdded.map(f => f.path)))
     else setSelected(new Set(notAdded.map(f => f.path)))
   }
   }
 
 
   const extIcon = (type: string) => {
   const extIcon = (type: string) => {
-    if (type === 'pdf') return <FileText size={14} className="text-red-400" />
-    if (type === 'md') return <FileText size={14} className="text-blue-400" />
-    return <File size={14} className="text-gray-400" />
+    if (type === 'pdf') return <FileText size={14} className="text-red-400"/>
+    if (type === 'md') return <FileText size={14} className="text-blue-400"/>
+    return <File size={14} className="text-gray-400"/>
   }
   }
-
   const fmtSize = (b: number) => b > 1024*1024 ? `${(b/1024/1024).toFixed(1)}MB` : `${(b/1024).toFixed(0)}KB`
   const fmtSize = (b: number) => b > 1024*1024 ? `${(b/1024/1024).toFixed(1)}MB` : `${(b/1024).toFixed(0)}KB`
 
 
-  if (isLoading) return <div className="flex justify-center p-8"><Spinner /></div>
+  // Breadcrumb parts
+  const crumbs = currentPath ? currentPath.split('/') : []
+  const navigateTo = (idx: number) => {
+    setCurrentPath(idx < 0 ? '' : crumbs.slice(0, idx + 1).join('/'))
+    setSelected(new Set())
+  }
 
 
   return (
   return (
     <div className="space-y-4">
     <div className="space-y-4">
-      {/* Header */}
-      <div className="flex items-center justify-between">
-        <p className="text-sm text-gray-600">
-          根目录: <code className="bg-gray-100 px-1 rounded text-xs">{data?.root_dir}</code>
-           共 <b>{files.length}</b> 个文件(<b>{added.length}</b> 已加入 / <b>{notAdded.length}</b> 待加入)
+      {/* Header row */}
+      <div className="flex items-center justify-between gap-2 flex-wrap">
+        <p className="text-xs text-gray-500">
+          根目录: <code className="bg-gray-100 px-1 rounded">{d?.root_dir}</code>
         </p>
         </p>
-        <div className="flex gap-2">
-          <Button variant="secondary" size="sm" icon={<RefreshCw size={13} />} onClick={() => refetch()}>刷新</Button>
+        <div className="flex gap-2 flex-wrap">
+          <Button variant="secondary" size="sm" icon={<RefreshCw size={13} className={isFetching ? 'animate-spin' : ''}/>} onClick={() => refetch()}>刷新</Button>
           {selected.size > 0 && (
           {selected.size > 0 && (
-            <Button size="sm" icon={<Play size={13} />}
+            <Button size="sm" icon={<Play size={13}/>}
               onClick={() => addMut.mutate(Array.from(selected))}
               onClick={() => addMut.mutate(Array.from(selected))}
               disabled={addMut.isPending}>
               disabled={addMut.isPending}>
               加入选中 ({selected.size})
               加入选中 ({selected.size})
@@ -303,15 +332,46 @@ function FileManager({ kbId }: { kbId: string }) {
             <Button size="sm" variant="secondary"
             <Button size="sm" variant="secondary"
               onClick={() => addMut.mutate(notAdded.map(f => f.path))}
               onClick={() => addMut.mutate(notAdded.map(f => f.path))}
               disabled={addMut.isPending}>
               disabled={addMut.isPending}>
-              一键加入全部
+              一键加入全部 ({notAdded.length})
             </Button>
             </Button>
           )}
           )}
         </div>
         </div>
       </div>
       </div>
 
 
+      {/* Breadcrumb + search row */}
+      <div className="flex items-center gap-2 flex-wrap">
+        {/* Breadcrumb */}
+        <div className="flex items-center gap-1 text-sm min-w-0 flex-1">
+          <button onClick={() => navigateTo(-1)}
+            className={`flex items-center gap-1 px-2 py-1 rounded hover:bg-gray-100 transition-colors shrink-0 ${currentPath === '' ? 'text-gray-400 cursor-default pointer-events-none' : 'text-indigo-600'}`}>
+            <FolderOpen size={13}/> <span className="text-xs">根目录</span>
+          </button>
+          {crumbs.map((part, idx) => (
+            <span key={idx} className="flex items-center gap-1 min-w-0">
+              <ChevronRight size={12} className="text-gray-300 shrink-0"/>
+              <button onClick={() => navigateTo(idx)}
+                className={`px-1.5 py-0.5 rounded text-xs font-mono hover:bg-gray-100 truncate max-w-[140px] ${idx === crumbs.length - 1 ? 'text-gray-700 font-semibold' : 'text-indigo-600'}`}>
+                {part}
+              </button>
+            </span>
+          ))}
+        </div>
+        {/* Search box */}
+        <div className="relative shrink-0">
+          <Search size={13} className="absolute left-2.5 top-1/2 -translate-y-1/2 text-gray-400 pointer-events-none"/>
+          <input
+            value={nameInput}
+            onChange={e => setNameInput(e.target.value)}
+            placeholder="文件名过滤..."
+            className="pl-7 pr-3 py-1.5 text-xs border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-400 outline-none w-44"
+          />
+        </div>
+      </div>
+
       {/* Directory ignore panel */}
       {/* Directory ignore panel */}
       {dirs.length > 0 && (
       {dirs.length > 0 && (
         <DirIgnorePanel
         <DirIgnorePanel
+          kbId={kbId}
           dirs={dirs}
           dirs={dirs}
           ignoredDirs={ignoredDirs}
           ignoredDirs={ignoredDirs}
           onToggle={handleToggleDir}
           onToggle={handleToggleDir}
@@ -320,62 +380,67 @@ function FileManager({ kbId }: { kbId: string }) {
         />
         />
       )}
       )}
 
 
-      {/* Already added */}
-      {added.length > 0 && (
-        <div className="bg-green-50 border border-green-200 rounded-lg overflow-hidden">
-          <div className="px-4 py-2 bg-green-100 text-xs font-semibold text-green-700">
-            已加入知识体 ({added.length})
-          </div>
-          <div className="divide-y divide-green-100 max-h-48 overflow-y-auto">
-            {added.map(f => (
-              <div key={f.path} className="flex items-center gap-2 px-4 py-2 text-sm">
-                {extIcon(f.type)}
-                <span className="flex-1 truncate text-gray-700">{f.relative_path}</span>
-                <span className="text-xs text-gray-400">{fmtSize(f.size)}</span>
-                <CheckCircle size={14} className="text-green-500" />
+      {isLoading ? (
+        <div className="flex justify-center py-8"><Spinner /></div>
+      ) : (
+        <>
+          {/* Already added */}
+          {added.length > 0 && (
+            <div className="bg-green-50 border border-green-200 rounded-lg overflow-hidden">
+              <div className="px-4 py-2 bg-green-100 text-xs font-semibold text-green-700">
+                已加入知识体 ({added.length})
               </div>
               </div>
-            ))}
-          </div>
-        </div>
-      )}
+              <div className="divide-y divide-green-100 max-h-48 overflow-y-auto">
+                {added.map(f => (
+                  <div key={f.path} className="flex items-center gap-2 px-4 py-2 text-sm">
+                    {extIcon(f.type)}
+                    <span className="flex-1 truncate text-gray-700 font-mono text-xs">{f.relative_path}</span>
+                    <span className="text-xs text-gray-400 shrink-0">{fmtSize(f.size)}</span>
+                    <CheckCircle size={14} className="text-green-500 shrink-0"/>
+                  </div>
+                ))}
+              </div>
+            </div>
+          )}
 
 
-      {/* Not yet added */}
-      {notAdded.length > 0 && (
-        <div className="border border-gray-200 rounded-lg overflow-hidden">
-          <div className="px-4 py-2 bg-gray-50 text-xs font-semibold text-gray-600 flex items-center gap-2">
-            <button onClick={toggleAll} className="text-indigo-600 hover:text-indigo-800">
-              {selected.size === notAdded.length ? <CheckSquare size={14}/> : <Square size={14}/>}
-            </button>
-            待加入 ({notAdded.length})
-          </div>
-          <div className="divide-y divide-gray-100 max-h-80 overflow-y-auto">
-            {notAdded.map(f => (
-              <div
-                key={f.path}
-                onClick={() => toggle(f.path)}
-                className={`flex items-center gap-2 px-4 py-2 text-sm cursor-pointer hover:bg-gray-50 ${selected.has(f.path) ? 'bg-indigo-50' : ''}`}
-              >
-                <button onClick={e => { e.stopPropagation(); toggle(f.path) }}>
-                  {selected.has(f.path) ? <CheckSquare size={14} className="text-indigo-500"/> : <Square size={14} className="text-gray-300"/>}
+          {/* Not yet added */}
+          {notAdded.length > 0 && (
+            <div className="border border-gray-200 rounded-lg overflow-hidden">
+              <div className="px-4 py-2 bg-gray-50 text-xs font-semibold text-gray-600 flex items-center gap-2">
+                <button onClick={toggleAll} className="text-indigo-600 hover:text-indigo-800">
+                  {selected.size === notAdded.length ? <CheckSquare size={14}/> : <Square size={14}/>}
                 </button>
                 </button>
-                {extIcon(f.type)}
-                <span className="flex-1 truncate text-gray-700">{f.relative_path}</span>
-                <span className="text-xs text-gray-400">{fmtSize(f.size)}</span>
+                待加入 ({notAdded.length})
+                {nameFilter && <span className="font-normal text-gray-400">(含过滤条件"{nameFilter}")</span>}
               </div>
               </div>
-            ))}
-          </div>
-        </div>
-      )}
+              <div className="divide-y divide-gray-100 max-h-96 overflow-y-auto">
+                {notAdded.map(f => (
+                  <div key={f.path} onClick={() => toggle(f.path)}
+                    className={`flex items-center gap-2 px-4 py-2 text-sm cursor-pointer hover:bg-gray-50 ${selected.has(f.path) ? 'bg-indigo-50' : ''}`}>
+                    <button onClick={e => { e.stopPropagation(); toggle(f.path) }}>
+                      {selected.has(f.path) ? <CheckSquare size={14} className="text-indigo-500"/> : <Square size={14} className="text-gray-300"/>}
+                    </button>
+                    {extIcon(f.type)}
+                    <span className="flex-1 truncate text-gray-700 font-mono text-xs">{f.relative_path}</span>
+                    <span className="text-xs text-gray-400 shrink-0">{fmtSize(f.size)}</span>
+                  </div>
+                ))}
+              </div>
+            </div>
+          )}
 
 
-      {files.length === 0 && !isLoading && (
-        <div className="text-center py-10 text-gray-400">
-          <FolderOpen size={32} className="mx-auto mb-2" />
-          <p className="text-sm">
-            {ignoredDirs.length > 0
-              ? `根目录中未找到文件(${ignoredDirs.length} 个目录已隐藏,展开"目录过滤"可恢复)`
-              : '根目录中没有找到支持的文件(PDF/TXT/DOCX/MD/HTML/CSV)'}
-          </p>
-        </div>
+          {files.length === 0 && (
+            <div className="text-center py-10 text-gray-400">
+              <FolderOpen size={32} className="mx-auto mb-2"/>
+              <p className="text-sm">
+                {nameFilter ? `没有匹配"${nameFilter}"的文件` :
+                  ignoredDirs.length > 0
+                    ? `此目录下未找到文件(${ignoredDirs.length} 个目录已隐藏)`
+                    : '此目录没有支持的文件(PDF/TXT/DOCX/MD/HTML/CSV)'}
+              </p>
+            </div>
+          )}
+        </>
       )}
       )}
     </div>
     </div>
   )
   )