mirror of
https://github.com/DrizzleTime/Foxel.git
synced 2026-09-06 16:17:06 +08:00
feat: add vector database index info query and display functionality
This commit is contained in:
+68
-1
@@ -20,6 +20,7 @@ from services.processors.registry import get as get_processor
|
|||||||
from services.tasks import task_service
|
from services.tasks import task_service
|
||||||
from services.logging import LogService
|
from services.logging import LogService
|
||||||
from services.config import ConfigCenter
|
from services.config import ConfigCenter
|
||||||
|
from services.vector_db import VectorDBService
|
||||||
|
|
||||||
|
|
||||||
CROSS_TRANSFER_TEMP_ROOT = Path("data/tmp/cross_transfer")
|
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")
|
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):
|
async def stat_file(path: str):
|
||||||
adapter_instance, _, root, rel = await resolve_adapter_and_rel(path)
|
adapter_instance, _, root, rel = await resolve_adapter_and_rel(path)
|
||||||
stat_func = getattr(adapter_instance, "stat_file", None)
|
stat_func = getattr(adapter_instance, "stat_file", None)
|
||||||
if not callable(stat_func):
|
if not callable(stat_func):
|
||||||
raise HTTPException(501, detail="Adapter does not implement stat_file")
|
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(
|
async def copy_path(
|
||||||
|
|||||||
@@ -220,6 +220,17 @@ export const en = {
|
|||||||
'Copy failed': 'Copy failed',
|
'Copy failed': 'Copy failed',
|
||||||
'Permissions': 'Permissions',
|
'Permissions': 'Permissions',
|
||||||
'EXIF Info': 'EXIF Info',
|
'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
|
// Search dialog
|
||||||
'Smart Search': 'Smart Search',
|
'Smart Search': 'Smart Search',
|
||||||
|
|||||||
@@ -220,6 +220,17 @@ export const zh = {
|
|||||||
'Copy failed': '复制失败',
|
'Copy failed': '复制失败',
|
||||||
'Permissions': '权限',
|
'Permissions': '权限',
|
||||||
'EXIF Info': 'EXIF信息',
|
'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
|
// Search dialog
|
||||||
'Smart Search': '智能搜索',
|
'Smart Search': '智能搜索',
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Modal, Typography, Spin, theme, Card, Descriptions, Divider, Badge, Space, message } from 'antd';
|
import { Modal, Typography, Spin, theme, Card, Descriptions, Divider, Badge, Space, message, Collapse, Tag } from 'antd';
|
||||||
import { FileOutlined, FolderOutlined, CameraOutlined, InfoCircleOutlined } from '@ant-design/icons';
|
import { FileOutlined, FolderOutlined, CameraOutlined, InfoCircleOutlined, DatabaseOutlined } from '@ant-design/icons';
|
||||||
import { useI18n } from '../../../i18n';
|
import { useI18n } from '../../../i18n';
|
||||||
import type { VfsEntry } from '../../../api/client';
|
import type { VfsEntry } from '../../../api/client';
|
||||||
|
|
||||||
@@ -80,6 +80,62 @@ function formatFileSize(size: number | string, t: (k: string)=>string): string {
|
|||||||
export const FileDetailModal: React.FC<Props> = ({ entry, loading, data, onClose }) => {
|
export const FileDetailModal: React.FC<Props> = ({ entry, loading, data, onClose }) => {
|
||||||
const { token } = theme.useToken();
|
const { token } = theme.useToken();
|
||||||
const { t } = useI18n();
|
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 (
|
||||||
|
<div
|
||||||
|
key={String(key)}
|
||||||
|
style={{
|
||||||
|
padding: '12px 0',
|
||||||
|
borderBottom: idx === total - 1 ? 'none' : `1px solid ${token.colorSplit}`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Space direction="vertical" size={6} style={{ width: '100%' }}>
|
||||||
|
<Space size={[4, 4]} wrap>
|
||||||
|
{entry?.chunk_id && (
|
||||||
|
<Tag color="blue">{t('Chunk ID')}: {entry.chunk_id}</Tag>
|
||||||
|
)}
|
||||||
|
{entry?.type && (
|
||||||
|
<Tag>{entry.type}</Tag>
|
||||||
|
)}
|
||||||
|
{entry?.mime && (
|
||||||
|
<Tag color="geekblue">{entry.mime}</Tag>
|
||||||
|
)}
|
||||||
|
{entry?.name && !previewText && (
|
||||||
|
<Tag color="purple">{entry.name}</Tag>
|
||||||
|
)}
|
||||||
|
</Space>
|
||||||
|
{hasOffsets && (
|
||||||
|
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||||
|
{t('Offset Range')}: {entry?.start_offset ?? '-'} ~ {entry?.end_offset ?? '-'}
|
||||||
|
</Typography.Text>
|
||||||
|
)}
|
||||||
|
{entry?.vector_id && (
|
||||||
|
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||||
|
{t('Vector ID')}: {entry.vector_id}
|
||||||
|
</Typography.Text>
|
||||||
|
)}
|
||||||
|
{previewText && (
|
||||||
|
<Typography.Paragraph
|
||||||
|
style={{ marginBottom: 0 }}
|
||||||
|
ellipsis={{ rows: 3, expandable: previewTruncated }}
|
||||||
|
>
|
||||||
|
{previewText}
|
||||||
|
</Typography.Paragraph>
|
||||||
|
)}
|
||||||
|
</Space>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal
|
<Modal
|
||||||
@@ -225,6 +281,82 @@ export const FileDetailModal: React.FC<Props> = ({ entry, loading, data, onClose
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
{!data.is_dir && vectorIndex && (
|
||||||
|
<Card
|
||||||
|
size="small"
|
||||||
|
style={{ borderRadius: 8, marginTop: 16 }}
|
||||||
|
title={
|
||||||
|
<Space>
|
||||||
|
<DatabaseOutlined />
|
||||||
|
{t('Index Info')}
|
||||||
|
</Space>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Descriptions
|
||||||
|
column={1}
|
||||||
|
size="small"
|
||||||
|
items={[
|
||||||
|
{
|
||||||
|
key: 'total',
|
||||||
|
label: t('Indexed Items'),
|
||||||
|
children: vectorIndex.total ?? 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'types',
|
||||||
|
label: t('Indexed Types'),
|
||||||
|
children: Object.keys(vectorIndex.by_type || {}).length > 0 ? (
|
||||||
|
<Space size={[4, 4]} wrap>
|
||||||
|
{Object.entries(vectorIndex.by_type || {}).map(([type, count]) => (
|
||||||
|
<Tag key={type}>{type} ({count as number})</Tag>
|
||||||
|
))}
|
||||||
|
</Space>
|
||||||
|
) : (
|
||||||
|
<Typography.Text type="secondary">{t('No index data')}</Typography.Text>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
contentStyle={{ fontSize: 14 }}
|
||||||
|
labelStyle={{ fontWeight: 500, color: token.colorTextSecondary, width: '30%' }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{vectorIndex.total ? (
|
||||||
|
<div style={{ marginTop: 12 }}>
|
||||||
|
<Typography.Text strong style={{ marginBottom: 8, display: 'block' }}>
|
||||||
|
{t('Indexed Chunks')}
|
||||||
|
</Typography.Text>
|
||||||
|
<div style={{ maxHeight: '40vh', overflowY: 'auto', paddingRight: 8 }}>
|
||||||
|
{primaryIndexEntries.map((entry: any, idx: number) => renderIndexEntry(entry, idx, primaryIndexEntries.length))}
|
||||||
|
{remainingIndexEntries.length > 0 && (
|
||||||
|
<Collapse
|
||||||
|
bordered={false}
|
||||||
|
size="small"
|
||||||
|
items={[{
|
||||||
|
key: 'more',
|
||||||
|
label: t('More Indexed Chunks'),
|
||||||
|
children: (
|
||||||
|
<div>
|
||||||
|
{remainingIndexEntries.map((entry: any, idx: number) => renderIndexEntry(entry, idx, remainingIndexEntries.length))}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
}]}
|
||||||
|
style={{ background: 'transparent' }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{vectorIndex.has_more && (
|
||||||
|
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||||
|
{t('Showing first {count} entries', { count: vectorEntries.length })}
|
||||||
|
</Typography.Text>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div style={{ marginTop: 12 }}>
|
||||||
|
<Typography.Text type="secondary">{t('No index data')}</Typography.Text>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 右侧:EXIF 信息 */}
|
{/* 右侧:EXIF 信息 */}
|
||||||
|
|||||||
Reference in New Issue
Block a user