diff --git a/services/virtual_fs.py b/services/virtual_fs.py index cdde974..e7a009e 100644 --- a/services/virtual_fs.py +++ b/services/virtual_fs.py @@ -20,6 +20,7 @@ from services.processors.registry import get as get_processor from services.tasks import task_service from services.logging import LogService from services.config import ConfigCenter +from services.vector_db import VectorDBService CROSS_TRANSFER_TEMP_ROOT = Path("data/tmp/cross_transfer") @@ -508,12 +509,78 @@ async def stream_file(path: str, range_header: str | None): return Response(content=data, media_type=mime or "application/octet-stream") +async def _gather_vector_index(full_path: str, limit: int = 20): + """查询与文件相关的索引信息。失败时返回 None。""" + vector_db = VectorDBService() + try: + raw_results = await vector_db.search_by_path("vector_collection", full_path, max(limit * 2, 20)) + except Exception: + return None + + matched = [] + if raw_results: + buckets = raw_results if isinstance(raw_results, list) else [raw_results] + for bucket in buckets: + if not bucket: + continue + for record in bucket: + entity = dict((record or {}).get("entity") or {}) + source_path = entity.get("source_path") or entity.get("path") or "" + if source_path != full_path: + continue + entry = { + "chunk_id": str(entity.get("chunk_id")) if entity.get("chunk_id") is not None else None, + "type": entity.get("type"), + "mime": entity.get("mime"), + "name": entity.get("name"), + "start_offset": entity.get("start_offset"), + "end_offset": entity.get("end_offset"), + "vector_id": entity.get("vector_id"), + } + text = entity.get("text") or entity.get("description") + if text: + preview_limit = 400 + entry["preview"] = text[:preview_limit] + entry["preview_truncated"] = len(text) > preview_limit + matched.append(entry) + + if not matched: + return {"total": 0, "entries": [], "by_type": {}, "has_more": False} + + type_counts: Dict[str, int] = {} + for item in matched: + key = item.get("type") or "unknown" + type_counts[key] = type_counts.get(key, 0) + 1 + + has_more = len(matched) > limit + return { + "total": len(matched), + "entries": matched[:limit], + "by_type": type_counts, + "has_more": has_more, + "limit": limit, + } + + async def stat_file(path: str): adapter_instance, _, root, rel = await resolve_adapter_and_rel(path) stat_func = getattr(adapter_instance, "stat_file", None) if not callable(stat_func): raise HTTPException(501, detail="Adapter does not implement stat_file") - return await stat_func(root, rel) + info = await stat_func(root, rel) + + if isinstance(info, dict): + info.setdefault("path", path) + try: + is_dir = bool(info.get("is_dir")) + except Exception: + is_dir = False + if not is_dir: + vector_index = await _gather_vector_index(path) + if vector_index is not None: + info["vector_index"] = vector_index + + return info async def copy_path( diff --git a/web/src/i18n/locales/en.ts b/web/src/i18n/locales/en.ts index e8aca2c..60d654f 100644 --- a/web/src/i18n/locales/en.ts +++ b/web/src/i18n/locales/en.ts @@ -220,6 +220,17 @@ export const en = { 'Copy failed': 'Copy failed', 'Permissions': 'Permissions', 'EXIF Info': 'EXIF Info', + 'Index Info': 'Index Info', + 'Indexed Items': 'Indexed Items', + 'Indexed Types': 'Indexed Types', + 'No index data': 'No index data', + 'Indexed Chunks': 'Indexed Chunks', + 'More Indexed Chunks': 'More Indexed Chunks', + 'Chunk ID': 'Chunk ID', + 'Offset Range': 'Offset Range', + 'Vector ID': 'Vector ID', + 'Preview': 'Preview', + 'Showing first {count} entries': 'Showing first {count} entries', // Search dialog 'Smart Search': 'Smart Search', diff --git a/web/src/i18n/locales/zh.ts b/web/src/i18n/locales/zh.ts index f1fc28a..bf0c3b3 100644 --- a/web/src/i18n/locales/zh.ts +++ b/web/src/i18n/locales/zh.ts @@ -220,6 +220,17 @@ export const zh = { 'Copy failed': '复制失败', 'Permissions': '权限', 'EXIF Info': 'EXIF信息', + 'Index Info': '索引信息', + 'Indexed Items': '索引条目数', + 'Indexed Types': '索引类型统计', + 'No index data': '暂无索引数据', + 'Indexed Chunks': '索引条目', + 'More Indexed Chunks': '更多索引条目', + 'Chunk ID': '分片ID', + 'Offset Range': '偏移范围', + 'Vector ID': '向量ID', + 'Preview': '内容预览', + 'Showing first {count} entries': '仅展示前 {count} 条', // Search dialog 'Smart Search': '智能搜索', diff --git a/web/src/pages/FileExplorerPage/components/FileDetailModal.tsx b/web/src/pages/FileExplorerPage/components/FileDetailModal.tsx index 93797de..3abce68 100644 --- a/web/src/pages/FileExplorerPage/components/FileDetailModal.tsx +++ b/web/src/pages/FileExplorerPage/components/FileDetailModal.tsx @@ -1,6 +1,6 @@ import React from 'react'; -import { Modal, Typography, Spin, theme, Card, Descriptions, Divider, Badge, Space, message } from 'antd'; -import { FileOutlined, FolderOutlined, CameraOutlined, InfoCircleOutlined } from '@ant-design/icons'; +import { Modal, Typography, Spin, theme, Card, Descriptions, Divider, Badge, Space, message, Collapse, Tag } from 'antd'; +import { FileOutlined, FolderOutlined, CameraOutlined, InfoCircleOutlined, DatabaseOutlined } from '@ant-design/icons'; import { useI18n } from '../../../i18n'; import type { VfsEntry } from '../../../api/client'; @@ -80,7 +80,63 @@ function formatFileSize(size: number | string, t: (k: string)=>string): string { export const FileDetailModal: React.FC = ({ entry, loading, data, onClose }) => { const { token } = theme.useToken(); const { t } = useI18n(); - + const vectorIndex = data?.vector_index; + const vectorEntries = Array.isArray(vectorIndex?.entries) ? vectorIndex.entries : []; + const primaryIndexEntries = vectorEntries.slice(0, 3); + const remainingIndexEntries = vectorEntries.slice(3); + + const renderIndexEntry = (entry: any, idx: number, total: number) => { + const key = entry?.chunk_id ?? entry?.vector_id ?? idx; + const hasOffsets = entry?.start_offset !== undefined || entry?.end_offset !== undefined; + const previewText = entry?.preview; + const previewTruncated = Boolean(entry?.preview_truncated && previewText); + + return ( +
+ + + {entry?.chunk_id && ( + {t('Chunk ID')}: {entry.chunk_id} + )} + {entry?.type && ( + {entry.type} + )} + {entry?.mime && ( + {entry.mime} + )} + {entry?.name && !previewText && ( + {entry.name} + )} + + {hasOffsets && ( + + {t('Offset Range')}: {entry?.start_offset ?? '-'} ~ {entry?.end_offset ?? '-'} + + )} + {entry?.vector_id && ( + + {t('Vector ID')}: {entry.vector_id} + + )} + {previewText && ( + + {previewText} + + )} + +
+ ); + }; + return ( = ({ entry, loading, data, onClose )} + + {!data.is_dir && vectorIndex && ( + + + {t('Index Info')} + + } + > + 0 ? ( + + {Object.entries(vectorIndex.by_type || {}).map(([type, count]) => ( + {type} ({count as number}) + ))} + + ) : ( + {t('No index data')} + ), + }, + ]} + contentStyle={{ fontSize: 14 }} + labelStyle={{ fontWeight: 500, color: token.colorTextSecondary, width: '30%' }} + /> + + {vectorIndex.total ? ( +
+ + {t('Indexed Chunks')} + +
+ {primaryIndexEntries.map((entry: any, idx: number) => renderIndexEntry(entry, idx, primaryIndexEntries.length))} + {remainingIndexEntries.length > 0 && ( + + {remainingIndexEntries.map((entry: any, idx: number) => renderIndexEntry(entry, idx, remainingIndexEntries.length))} +
+ ), + }]} + style={{ background: 'transparent' }} + /> + )} +
+ {vectorIndex.has_more && ( + + {t('Showing first {count} entries', { count: vectorEntries.length })} + + )} + + ) : ( +
+ {t('No index data')} +
+ )} +
+ )} {/* 右侧:EXIF 信息 */}