mirror of
https://github.com/DrizzleTime/Foxel.git
synced 2026-05-21 00:00:18 +08:00
feat: Enhance vector database providers with source path handling and improved search functionality
This commit is contained in:
@@ -21,9 +21,29 @@ export interface DirListing {
|
||||
}
|
||||
|
||||
export interface SearchResultItem {
|
||||
id: number;
|
||||
id: string;
|
||||
path: string;
|
||||
score: number;
|
||||
chunk_id?: string;
|
||||
snippet?: string;
|
||||
mime?: string;
|
||||
source_type?: string;
|
||||
start_offset?: number;
|
||||
end_offset?: number;
|
||||
metadata?: Record<string, any>;
|
||||
}
|
||||
|
||||
export interface SearchPagination {
|
||||
page: number;
|
||||
page_size: number;
|
||||
has_more: boolean;
|
||||
}
|
||||
|
||||
export interface SearchResponse {
|
||||
items: SearchResultItem[];
|
||||
query: string;
|
||||
mode?: string;
|
||||
pagination?: SearchPagination;
|
||||
}
|
||||
|
||||
export const vfsApi = {
|
||||
@@ -105,6 +125,20 @@ export const vfsApi = {
|
||||
xhr.send(fd);
|
||||
});
|
||||
},
|
||||
searchFiles: (q: string, top_k: number = 10, mode: 'vector' | 'filename' = 'vector') =>
|
||||
request<{ items: SearchResultItem[]; query: string }>(`/search?q=${encodeURIComponent(q)}&top_k=${top_k}&mode=${mode}`),
|
||||
searchFiles: (
|
||||
q: string,
|
||||
top_k: number = 10,
|
||||
mode: 'vector' | 'filename' = 'vector',
|
||||
page?: number,
|
||||
page_size?: number,
|
||||
) => {
|
||||
const params = new URLSearchParams({
|
||||
q,
|
||||
top_k: String(top_k),
|
||||
mode,
|
||||
});
|
||||
if (page !== undefined) params.set('page', String(page));
|
||||
if (page_size !== undefined) params.set('page_size', String(page_size));
|
||||
return request<SearchResponse>(`/search?${params.toString()}`);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,128 +1,313 @@
|
||||
import { Modal, Input, List, Divider, Spin, Select, Space } from 'antd';
|
||||
import { Modal, Input, List, Divider, Spin, Space, Tag, Typography, Empty, Flex, Segmented, Pagination } from 'antd';
|
||||
import { SearchOutlined, FileTextOutlined } from '@ant-design/icons';
|
||||
import React, { useState } from 'react';
|
||||
import React, { useRef, useState } from 'react';
|
||||
import { vfsApi, type SearchResultItem } from '../api/vfs';
|
||||
import { useI18n } from '../i18n';
|
||||
import { useNavigate } from 'react-router';
|
||||
|
||||
|
||||
interface SearchDialogProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const SEARCH_MODES = (t: (k: string)=>string) => [
|
||||
{ label: t('Smart Search'), value: 'vector' },
|
||||
{ label: t('Name Search'), value: 'filename' },
|
||||
];
|
||||
type SearchMode = 'vector' | 'filename';
|
||||
const PAGE_SIZE = 10;
|
||||
|
||||
const SearchDialog: React.FC<SearchDialogProps> = ({ open, onClose }) => {
|
||||
const [search, setSearch] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [results, setResults] = useState<SearchResultItem[]>([]);
|
||||
const [searched, setSearched] = useState(false);
|
||||
const [searchMode, setSearchMode] = useState<'vector' | 'filename'>('vector');
|
||||
const [searchMode, setSearchMode] = useState<SearchMode>('vector');
|
||||
const [page, setPage] = useState(1);
|
||||
const [hasMore, setHasMore] = useState(false);
|
||||
const requestIdRef = useRef(0);
|
||||
const { t } = useI18n();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleSearch = async () => {
|
||||
if (!search.trim()) return;
|
||||
const renderSourceLabel = (value?: string) => {
|
||||
switch ((value || '').toLowerCase()) {
|
||||
case 'vector':
|
||||
return t('Vector Search');
|
||||
case 'filename':
|
||||
return t('Name Search');
|
||||
case 'text':
|
||||
return t('Text Chunk');
|
||||
case 'image':
|
||||
return t('Image Description');
|
||||
default:
|
||||
return t('Vector Search');
|
||||
}
|
||||
};
|
||||
|
||||
const sourceColor = (value?: string) => {
|
||||
switch ((value || '').toLowerCase()) {
|
||||
case 'vector':
|
||||
return 'blue';
|
||||
case 'filename':
|
||||
return 'green';
|
||||
case 'image':
|
||||
return 'volcano';
|
||||
case 'text':
|
||||
return 'geekblue';
|
||||
default:
|
||||
return 'purple';
|
||||
}
|
||||
};
|
||||
|
||||
const performSearch = async (options?: { page?: number; mode?: SearchMode }) => {
|
||||
const query = search.trim();
|
||||
if (!query) {
|
||||
setSearched(false);
|
||||
setResults([]);
|
||||
setHasMore(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const currentMode = options?.mode ?? searchMode;
|
||||
const targetPage = currentMode === 'filename' ? (options?.page ?? (currentMode === searchMode ? page : 1)) : 1;
|
||||
|
||||
const requestId = requestIdRef.current + 1;
|
||||
requestIdRef.current = requestId;
|
||||
|
||||
setLoading(true);
|
||||
setSearched(true);
|
||||
try {
|
||||
const res = await vfsApi.searchFiles(search, 10, searchMode);
|
||||
setResults(res.items);
|
||||
} catch (e) {
|
||||
setResults([]);
|
||||
if (currentMode === 'filename') {
|
||||
setPage(targetPage);
|
||||
} else {
|
||||
setPage(1);
|
||||
setHasMore(false);
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await vfsApi.searchFiles(
|
||||
query,
|
||||
currentMode === 'filename' ? PAGE_SIZE : 10,
|
||||
currentMode,
|
||||
currentMode === 'filename' ? targetPage : undefined,
|
||||
currentMode === 'filename' ? PAGE_SIZE : undefined,
|
||||
);
|
||||
if (requestId !== requestIdRef.current) {
|
||||
return;
|
||||
}
|
||||
setResults(res.items);
|
||||
if (currentMode === 'filename') {
|
||||
const pagination = res.pagination;
|
||||
setHasMore(Boolean(pagination?.has_more));
|
||||
if (pagination?.page) {
|
||||
setPage(pagination.page);
|
||||
}
|
||||
} else {
|
||||
setHasMore(false);
|
||||
}
|
||||
} catch (e) {
|
||||
if (requestId !== requestIdRef.current) {
|
||||
return;
|
||||
}
|
||||
setResults([]);
|
||||
if (currentMode === 'filename') {
|
||||
setHasMore(false);
|
||||
}
|
||||
} finally {
|
||||
if (requestId === requestIdRef.current) {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
const handleSearch = () => {
|
||||
if (!search.trim()) {
|
||||
setResults([]);
|
||||
setSearched(false);
|
||||
setHasMore(false);
|
||||
setPage(1);
|
||||
return;
|
||||
}
|
||||
void performSearch({ page: searchMode === 'filename' ? 1 : undefined });
|
||||
};
|
||||
|
||||
const handleModeChange = (value: string | number) => {
|
||||
const nextMode = value as SearchMode;
|
||||
setHasMore(false);
|
||||
setPage(1);
|
||||
setSearchMode(nextMode);
|
||||
if (search.trim()) {
|
||||
void performSearch({ mode: nextMode, page: nextMode === 'filename' ? 1 : undefined });
|
||||
} else {
|
||||
setResults([]);
|
||||
setSearched(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
setSearch('');
|
||||
setResults([]);
|
||||
setSearched(false);
|
||||
setSearchMode('vector');
|
||||
setPage(1);
|
||||
setHasMore(false);
|
||||
requestIdRef.current = 0;
|
||||
setLoading(false);
|
||||
onClose();
|
||||
};
|
||||
|
||||
const totalItems = searchMode === 'filename'
|
||||
? (hasMore ? page * PAGE_SIZE + 1 : (page - 1) * PAGE_SIZE + results.length)
|
||||
: results.length;
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
onCancel={onClose}
|
||||
onCancel={handleClose}
|
||||
footer={null}
|
||||
width={600}
|
||||
width={720}
|
||||
centered
|
||||
title={null}
|
||||
closable={false}
|
||||
styles={{
|
||||
body: {
|
||||
padding: '12px 16px 16px',
|
||||
maxHeight: '70vh',
|
||||
overflow: 'hidden',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 12,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Space.Compact style={{ marginBottom: 0, width: '100%' }}>
|
||||
<Select
|
||||
options={SEARCH_MODES(t)}
|
||||
value={searchMode}
|
||||
onChange={v => setSearchMode(v as 'vector' | 'filename')}
|
||||
style={{
|
||||
width: 120,
|
||||
fontSize: 18,
|
||||
height: 40,
|
||||
lineHeight: '40px',
|
||||
borderTopRightRadius: 0,
|
||||
borderBottomRightRadius: 0,
|
||||
borderRight: 0,
|
||||
verticalAlign: 'top',
|
||||
}}
|
||||
styles={{ popup: { root: { fontSize: 18 } } }}
|
||||
popupMatchSelectWidth={false}
|
||||
/>
|
||||
<Input
|
||||
allowClear
|
||||
prefix={<SearchOutlined />}
|
||||
placeholder={t('Search files / tags / types')}
|
||||
value={search}
|
||||
onChange={e => setSearch(e.target.value)}
|
||||
style={{
|
||||
fontSize: 18,
|
||||
height: 40,
|
||||
width: 'calc(100% - 120px)',
|
||||
borderTopLeftRadius: 0,
|
||||
borderBottomLeftRadius: 0,
|
||||
verticalAlign: 'top',
|
||||
}}
|
||||
autoFocus
|
||||
onPressEnter={handleSearch}
|
||||
/>
|
||||
</Space.Compact>
|
||||
{searched && (
|
||||
<>
|
||||
<Divider style={{ margin: '12px 0' }}>{t('Search Results')}</Divider>
|
||||
{loading ? (
|
||||
<Spin />
|
||||
) : (
|
||||
<List
|
||||
itemLayout="horizontal"
|
||||
dataSource={results}
|
||||
locale={{ emptyText: t('No files found') }}
|
||||
renderItem={item => {
|
||||
const fullPath = item.path || '';
|
||||
const trimmed = fullPath.replace(/\/+$/, '');
|
||||
const parts = trimmed.split('/');
|
||||
const filename = parts.pop() || '';
|
||||
const dir = parts.length ? '/' + parts.join('/') : '/';
|
||||
return (
|
||||
<List.Item>
|
||||
<List.Item.Meta
|
||||
avatar={<FileTextOutlined />}
|
||||
title={
|
||||
<a
|
||||
onClick={() => {
|
||||
navigate(`/files${dir === '/' ? '' : dir}`, { state: { highlight: { name: filename } } });
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
{fullPath}
|
||||
</a>
|
||||
}
|
||||
description={`${t('Relevance')}: ${item.score.toFixed(2)}`}
|
||||
/>
|
||||
</List.Item>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<Flex vertical style={{ gap: 12, flex: 1, minHeight: 0 }}>
|
||||
<Flex align="center" style={{ width: '100%', gap: 12, flexWrap: 'wrap' }}>
|
||||
<Segmented
|
||||
options={[
|
||||
{ label: t('Smart Search'), value: 'vector' },
|
||||
{ label: t('Name Search'), value: 'filename' },
|
||||
]}
|
||||
value={searchMode}
|
||||
onChange={handleModeChange}
|
||||
style={{
|
||||
minWidth: 160,
|
||||
height: 40,
|
||||
borderRadius: 20,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
size="large"
|
||||
/>
|
||||
<Input
|
||||
allowClear
|
||||
prefix={<SearchOutlined />}
|
||||
placeholder={t('Search files / tags / types')}
|
||||
value={search}
|
||||
onChange={e => {
|
||||
const value = e.target.value;
|
||||
setSearch(value);
|
||||
if (!value.trim()) {
|
||||
setResults([]);
|
||||
setSearched(false);
|
||||
setHasMore(false);
|
||||
setPage(1);
|
||||
requestIdRef.current += 1;
|
||||
setLoading(false);
|
||||
}
|
||||
}}
|
||||
style={{ fontSize: 18, height: 40, flex: 1, minWidth: 240 }}
|
||||
styles={{
|
||||
input: {
|
||||
borderRadius: 20,
|
||||
},
|
||||
}}
|
||||
autoFocus
|
||||
onPressEnter={handleSearch}
|
||||
/>
|
||||
</Flex>
|
||||
|
||||
{!searched ? null : (
|
||||
<Flex vertical style={{ flex: 1, minHeight: 0 }}>
|
||||
<Divider style={{ margin: 0, padding: '0 0 12px' }}>{t('Search Results')}</Divider>
|
||||
{loading ? (
|
||||
<Flex align="center" justify="center" style={{ flex: 1 }}>
|
||||
<Spin />
|
||||
</Flex>
|
||||
) : results.length === 0 ? (
|
||||
<Flex align="center" justify="center" style={{ flex: 1 }}>
|
||||
<Empty description={t('No files found')} image={Empty.PRESENTED_IMAGE_SIMPLE} />
|
||||
</Flex>
|
||||
) : (
|
||||
<div style={{ flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column' }}>
|
||||
<div style={{ flex: 1, minHeight: 0, overflowY: 'auto', paddingRight: 6 }}>
|
||||
<List
|
||||
itemLayout="horizontal"
|
||||
dataSource={results}
|
||||
split={false}
|
||||
renderItem={item => {
|
||||
const fullPath = item.path || '';
|
||||
const trimmed = fullPath.replace(/\/+$/, '');
|
||||
const parts = trimmed.split('/');
|
||||
const filename = parts.pop() || '';
|
||||
const dir = parts.length ? '/' + parts.join('/') : '/';
|
||||
const snippet = item.snippet || '';
|
||||
const retrieval = item.metadata?.retrieval_source || item.source_type;
|
||||
const retrievalLabel = renderSourceLabel(retrieval);
|
||||
const scoreText = Number.isFinite(item.score) ? item.score.toFixed(2) : '-';
|
||||
|
||||
return (
|
||||
<List.Item style={{ padding: '10px 12px', borderRadius: 6, background: '#fafafa', marginBottom: 8 }}>
|
||||
<List.Item.Meta
|
||||
avatar={<FileTextOutlined style={{ fontSize: 18, color: '#8c8c8c' }} />}
|
||||
title={
|
||||
<a
|
||||
onClick={() => {
|
||||
navigate(`/files${dir === '/' ? '' : dir}`, { state: { highlight: { name: filename } } });
|
||||
handleClose();
|
||||
}}
|
||||
style={{ fontSize: 16 }}
|
||||
>
|
||||
{fullPath}
|
||||
</a>
|
||||
}
|
||||
description={(
|
||||
<Space direction="vertical" size={6} style={{ width: '100%' }}>
|
||||
{snippet ? (
|
||||
<Typography.Paragraph ellipsis={{ rows: 3 }} style={{ marginBottom: 0 }}>
|
||||
{snippet}
|
||||
</Typography.Paragraph>
|
||||
) : null}
|
||||
<Space size={10} wrap>
|
||||
{retrieval ? (
|
||||
<Tag color={sourceColor(retrieval)} style={{ marginRight: 0 }}>
|
||||
{retrievalLabel}
|
||||
</Tag>
|
||||
) : null}
|
||||
<Typography.Text type="secondary">
|
||||
{t('Relevance')}: {scoreText}
|
||||
</Typography.Text>
|
||||
</Space>
|
||||
</Space>
|
||||
)}
|
||||
/>
|
||||
</List.Item>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{searchMode === 'filename' && results.length > 0 ? (
|
||||
<Pagination
|
||||
current={page}
|
||||
pageSize={PAGE_SIZE}
|
||||
total={Math.max(totalItems, 1)}
|
||||
showSizeChanger={false}
|
||||
size="small"
|
||||
style={{ marginTop: 12, textAlign: 'right' }}
|
||||
onChange={(nextPage) => {
|
||||
void performSearch({ page: nextPage });
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</Flex>
|
||||
)}
|
||||
</Flex>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -8,14 +8,23 @@ import { useTheme } from '../../contexts/ThemeContext';
|
||||
import '../../styles/settings-tabs.css';
|
||||
import { useI18n } from '../../i18n';
|
||||
|
||||
const APP_CONFIG_KEYS: {key: string, label: string, default?: string}[] = [
|
||||
const APP_CONFIG_KEYS: { key: string, label: string, default?: string }[] = [
|
||||
{ key: 'APP_NAME', label: 'App Name' },
|
||||
{ key: 'APP_LOGO', label: 'Logo URL' },
|
||||
{ key: 'APP_DOMAIN', label: 'App Domain' },
|
||||
{ key: 'FILE_DOMAIN', label: 'File Domain' },
|
||||
];
|
||||
|
||||
const VISION_CONFIG_KEYS = [
|
||||
interface AiConfigKeyBase {
|
||||
key: string;
|
||||
default?: string | number;
|
||||
}
|
||||
|
||||
interface AiConfigKeyWithLabel extends AiConfigKeyBase {
|
||||
label: string;
|
||||
}
|
||||
|
||||
const VISION_CONFIG_KEYS: AiConfigKeyWithLabel[] = [
|
||||
{ key: 'AI_VISION_API_URL', label: 'Vision API URL' },
|
||||
{ key: 'AI_VISION_MODEL', label: 'Vision Model', default: 'Qwen/Qwen2.5-VL-32B-Instruct' },
|
||||
{ key: 'AI_VISION_API_KEY', label: 'Vision API Key' },
|
||||
@@ -24,13 +33,24 @@ const VISION_CONFIG_KEYS = [
|
||||
const DEFAULT_EMBED_DIMENSION = 4096;
|
||||
const EMBED_DIM_KEY = 'AI_EMBED_DIM';
|
||||
|
||||
const EMBED_CONFIG_KEYS = [
|
||||
const EMBED_CONFIG_KEYS: AiConfigKeyWithLabel[] = [
|
||||
{ key: 'AI_EMBED_API_URL', label: 'Embedding API URL' },
|
||||
{ key: 'AI_EMBED_MODEL', label: 'Embedding Model', default: 'Qwen/Qwen3-Embedding-8B' },
|
||||
{ key: 'AI_EMBED_API_KEY', label: 'Embedding API Key' },
|
||||
];
|
||||
|
||||
const ALL_AI_KEYS = [...VISION_CONFIG_KEYS, ...EMBED_CONFIG_KEYS, { key: EMBED_DIM_KEY, default: DEFAULT_EMBED_DIMENSION }];
|
||||
const RERANK_CONFIG_KEYS: AiConfigKeyWithLabel[] = [
|
||||
{ key: 'AI_RERANK_API_URL', label: 'Rerank API URL' },
|
||||
{ key: 'AI_RERANK_MODEL', label: 'Rerank Model' },
|
||||
{ key: 'AI_RERANK_API_KEY', label: 'Rerank API Key' },
|
||||
];
|
||||
|
||||
const ALL_AI_KEYS: AiConfigKeyBase[] = [
|
||||
...VISION_CONFIG_KEYS,
|
||||
...EMBED_CONFIG_KEYS,
|
||||
...RERANK_CONFIG_KEYS,
|
||||
{ key: EMBED_DIM_KEY, default: DEFAULT_EMBED_DIMENSION },
|
||||
];
|
||||
|
||||
const formatBytes = (bytes?: number | null) => {
|
||||
if (bytes === null || bytes === undefined) return '-';
|
||||
@@ -194,6 +214,8 @@ export default function SystemSettingsPage() {
|
||||
}
|
||||
}, [buildProviderConfigValues, message, t, vectorConfigForm, vectorProviders]);
|
||||
|
||||
const vectorSectionLoading = vectorStatsLoading || vectorConfigLoading;
|
||||
|
||||
// 离开“外观设置”时,恢复后端持久化配置(取消未保存的预览)
|
||||
useEffect(() => {
|
||||
if (activeTab !== 'appearance') {
|
||||
@@ -303,7 +325,7 @@ export default function SystemSettingsPage() {
|
||||
</Form.Item>
|
||||
</Card>
|
||||
<Card title={t('Advanced')} style={{ marginTop: 24 }}>
|
||||
<Form.Item name={THEME_KEYS.TOKENS} label={t('Override AntD Tokens (JSON)')} tooltip={t('e.g. {"colorText": "#222"}') }>
|
||||
<Form.Item name={THEME_KEYS.TOKENS} label={t('Override AntD Tokens (JSON)')} tooltip={t('e.g. {"colorText": "#222"}')}>
|
||||
<Input.TextArea autoSize={{ minRows: 4 }} placeholder='{ "colorText": "#222" }' />
|
||||
</Form.Item>
|
||||
<Form.Item name={THEME_KEYS.CSS} label={t('Custom CSS')}>
|
||||
@@ -402,6 +424,13 @@ export default function SystemSettingsPage() {
|
||||
<InputNumber min={1} max={32768} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
</Card>
|
||||
<Card title={t('Rerank Model')} style={{ marginTop: 24 }}>
|
||||
{RERANK_CONFIG_KEYS.map(({ key, label }) => (
|
||||
<Form.Item key={key} name={key} label={t(label)}>
|
||||
<Input size="large" />
|
||||
</Form.Item>
|
||||
))}
|
||||
</Card>
|
||||
<Form.Item style={{ marginTop: 24 }}>
|
||||
<Button type="primary" htmlType="submit" loading={loading} block>
|
||||
{t('Save')}
|
||||
@@ -428,178 +457,180 @@ export default function SystemSettingsPage() {
|
||||
{t('Refresh')}
|
||||
</Button>
|
||||
</div>
|
||||
{vectorMetaError ? (
|
||||
<Alert type="error" showIcon message={vectorMetaError} />
|
||||
) : null}
|
||||
{vectorStatsLoading && !vectorStats ? (
|
||||
<Spin />
|
||||
) : vectorStats ? (
|
||||
<Space direction="vertical" size={16} style={{ width: '100%' }}>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 24 }}>
|
||||
<div>
|
||||
<div style={{ color: '#888' }}>{t('Collections')}</div>
|
||||
<div style={{ fontSize: 20, fontWeight: 600 }}>{vectorStats.collection_count}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ color: '#888' }}>{t('Vectors')}</div>
|
||||
<div style={{ fontSize: 20, fontWeight: 600 }}>{vectorStats.total_vectors}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ color: '#888' }}>{t('Database Size')}</div>
|
||||
<div style={{ fontSize: 20, fontWeight: 600 }}>{formatBytes(vectorStats.db_file_size_bytes)}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ color: '#888' }}>{t('Estimated Memory')}</div>
|
||||
<div style={{ fontSize: 20, fontWeight: 600 }}>{formatBytes(vectorStats.estimated_total_memory_bytes)}</div>
|
||||
</div>
|
||||
</div>
|
||||
{vectorStats.collections.length ? (
|
||||
<Space direction="vertical" style={{ width: '100%' }} size={16}>
|
||||
{vectorStats.collections.map((collection) => (
|
||||
<div key={collection.name} style={{ border: '1px solid #f0f0f0', borderRadius: 8, padding: 16 }}>
|
||||
<Space direction="vertical" size={12} style={{ width: '100%' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', flexWrap: 'wrap', gap: 12 }}>
|
||||
<strong>{collection.name}</strong>
|
||||
<span style={{ color: '#888' }}>
|
||||
{collection.is_vector_collection && collection.dimension
|
||||
? `${t('Dimension')}: ${collection.dimension}`
|
||||
: t('Non-vector collection')}
|
||||
</span>
|
||||
</div>
|
||||
<div>{t('Vectors')}: {collection.row_count}</div>
|
||||
{collection.is_vector_collection ? (
|
||||
<div>{t('Estimated memory')}: {formatBytes(collection.estimated_memory_bytes)}</div>
|
||||
) : null}
|
||||
{collection.indexes.length ? (
|
||||
<Space direction="vertical" size={4} style={{ width: '100%' }}>
|
||||
<span>{t('Indexes')}:</span>
|
||||
<ul style={{ paddingLeft: 20, margin: 0 }}>
|
||||
{collection.indexes.map((index) => (
|
||||
<li key={`${collection.name}-${index.index_name || 'default'}`}>
|
||||
<span>{index.index_name || t('Unnamed index')}</span>
|
||||
<span>{' · '}{index.index_type || '-'}</span>
|
||||
<span>{' · '}{index.metric_type || '-'}</span>
|
||||
<span>{' · '}{t('Indexed rows')}: {index.indexed_rows}</span>
|
||||
<span>{' · '}{t('Pending rows')}: {index.pending_index_rows}</span>
|
||||
<span>{' · '}{t('Status')}: {index.state || '-'}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Space>
|
||||
) : null}
|
||||
</Space>
|
||||
{vectorSectionLoading ? (
|
||||
<div style={{ display: 'flex', justifyContent: 'center', padding: '24px 0' }}>
|
||||
<Spin />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{vectorMetaError ? (
|
||||
<Alert type="error" showIcon message={vectorMetaError} />
|
||||
) : null}
|
||||
{vectorStats ? (
|
||||
<Space direction="vertical" size={16} style={{ width: '100%' }}>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 24 }}>
|
||||
<div>
|
||||
<div style={{ color: '#888' }}>{t('Collections')}</div>
|
||||
<div style={{ fontSize: 20, fontWeight: 600 }}>{vectorStats.collection_count}</div>
|
||||
</div>
|
||||
))}
|
||||
<div>
|
||||
<div style={{ color: '#888' }}>{t('Vectors')}</div>
|
||||
<div style={{ fontSize: 20, fontWeight: 600 }}>{vectorStats.total_vectors}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ color: '#888' }}>{t('Database Size')}</div>
|
||||
<div style={{ fontSize: 20, fontWeight: 600 }}>{formatBytes(vectorStats.db_file_size_bytes)}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ color: '#888' }}>{t('Estimated Memory')}</div>
|
||||
<div style={{ fontSize: 20, fontWeight: 600 }}>{formatBytes(vectorStats.estimated_total_memory_bytes)}</div>
|
||||
</div>
|
||||
</div>
|
||||
{vectorStats.collections.length ? (
|
||||
<Space direction="vertical" style={{ width: '100%' }} size={16}>
|
||||
{vectorStats.collections.map((collection) => (
|
||||
<div key={collection.name} style={{ border: '1px solid #f0f0f0', borderRadius: 8, padding: 16 }}>
|
||||
<Space direction="vertical" size={12} style={{ width: '100%' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', flexWrap: 'wrap', gap: 12 }}>
|
||||
<strong>{collection.name}</strong>
|
||||
<span style={{ color: '#888' }}>
|
||||
{collection.is_vector_collection && collection.dimension
|
||||
? `${t('Dimension')}: ${collection.dimension}`
|
||||
: t('Non-vector collection')}
|
||||
</span>
|
||||
</div>
|
||||
<div>{t('Vectors')}: {collection.row_count}</div>
|
||||
{collection.is_vector_collection ? (
|
||||
<div>{t('Estimated memory')}: {formatBytes(collection.estimated_memory_bytes)}</div>
|
||||
) : null}
|
||||
{collection.indexes.length ? (
|
||||
<Space direction="vertical" size={4} style={{ width: '100%' }}>
|
||||
<span>{t('Indexes')}:</span>
|
||||
<ul style={{ paddingLeft: 20, margin: 0 }}>
|
||||
{collection.indexes.map((index) => (
|
||||
<li key={`${collection.name}-${index.index_name || 'default'}`}>
|
||||
<span>{index.index_name || t('Unnamed index')}</span>
|
||||
<span>{' · '}{index.index_type || '-'}</span>
|
||||
<span>{' · '}{index.metric_type || '-'}</span>
|
||||
<span>{' · '}{t('Indexed rows')}: {index.indexed_rows}</span>
|
||||
<span>{' · '}{t('Pending rows')}: {index.pending_index_rows}</span>
|
||||
<span>{' · '}{t('Status')}: {index.state || '-'}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Space>
|
||||
) : null}
|
||||
</Space>
|
||||
</div>
|
||||
))}
|
||||
</Space>
|
||||
) : (
|
||||
<Empty description={t('No collections')} />
|
||||
)}
|
||||
<div style={{ color: '#888' }}>
|
||||
{t('Estimated memory is calculated as vectors x dimension x 4 bytes (float32).')}
|
||||
</div>
|
||||
</Space>
|
||||
) : vectorStatsError ? (
|
||||
<div style={{ color: '#ff4d4f' }}>{vectorStatsError}</div>
|
||||
) : (
|
||||
<Empty description={t('No collections')} />
|
||||
)}
|
||||
<div style={{ color: '#888' }}>
|
||||
{t('Estimated memory is calculated as vectors x dimension x 4 bytes (float32).')}
|
||||
</div>
|
||||
</Space>
|
||||
) : vectorStatsError ? (
|
||||
<div style={{ color: '#ff4d4f' }}>{vectorStatsError}</div>
|
||||
) : (
|
||||
<Empty description={t('No collections')} />
|
||||
<Form
|
||||
layout="vertical"
|
||||
form={vectorConfigForm}
|
||||
onFinish={handleVectorConfigSave}
|
||||
initialValues={{ type: selectedProviderType || undefined, config: {} }}
|
||||
>
|
||||
<Form.Item
|
||||
name="type"
|
||||
label={t('Database Provider')}
|
||||
rules={[{ required: true, message: t('Please select a provider') }]}
|
||||
>
|
||||
<Select
|
||||
size="large"
|
||||
options={vectorProviders.map((provider) => ({
|
||||
value: provider.type,
|
||||
label: provider.enabled ? provider.label : `${provider.label} (${t('Coming soon')})`,
|
||||
disabled: !provider.enabled,
|
||||
}))}
|
||||
onChange={handleProviderChange}
|
||||
loading={vectorConfigLoading && !vectorProviders.length}
|
||||
/>
|
||||
</Form.Item>
|
||||
{selectedProvider?.description ? (
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
message={t(selectedProvider.description)}
|
||||
style={{ marginBottom: 16 }}
|
||||
/>
|
||||
) : null}
|
||||
{selectedProvider?.config_schema?.map((field) => (
|
||||
<Form.Item
|
||||
key={field.key}
|
||||
name={['config', field.key]}
|
||||
label={t(field.label)}
|
||||
rules={field.required ? [{ required: true, message: t('Please input {label}', { label: t(field.label) }) }] : []}
|
||||
>
|
||||
{field.type === 'password' ? (
|
||||
<Input.Password size="large" placeholder={field.placeholder ? t(field.placeholder) : undefined} />
|
||||
) : (
|
||||
<Input size="large" placeholder={field.placeholder ? t(field.placeholder) : undefined} />
|
||||
)}
|
||||
</Form.Item>
|
||||
))}
|
||||
{selectedProvider && !selectedProvider.enabled ? (
|
||||
<Alert
|
||||
type="warning"
|
||||
showIcon
|
||||
message={t('This provider is not available yet')}
|
||||
style={{ marginBottom: 16 }}
|
||||
/>
|
||||
) : null}
|
||||
<Form.Item>
|
||||
<Space direction="vertical" style={{ width: '100%' }}>
|
||||
<Button
|
||||
type="primary"
|
||||
htmlType="submit"
|
||||
loading={vectorConfigSaving}
|
||||
block
|
||||
disabled={!selectedProvider?.enabled}
|
||||
>
|
||||
{t('Save')}
|
||||
</Button>
|
||||
<Button
|
||||
danger
|
||||
htmlType="button"
|
||||
block
|
||||
onClick={() => {
|
||||
Modal.confirm({
|
||||
title: t('Confirm clear vector database?'),
|
||||
content: t('This will delete all collections irreversibly.'),
|
||||
okText: t('Confirm Clear'),
|
||||
okType: 'danger',
|
||||
cancelText: t('Cancel'),
|
||||
onOk: async () => {
|
||||
try {
|
||||
await vectorDBApi.clearAll();
|
||||
message.success(t('Vector database cleared'));
|
||||
await fetchVectorStats();
|
||||
await fetchVectorMeta();
|
||||
} catch (e: any) {
|
||||
message.error(e.message || t('Clear failed'));
|
||||
}
|
||||
},
|
||||
});
|
||||
}}
|
||||
>
|
||||
{t('Clear Vector DB')}
|
||||
</Button>
|
||||
</Space>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</>
|
||||
)}
|
||||
</Space>
|
||||
{vectorConfigLoading && !vectorProviders.length ? (
|
||||
<Spin />
|
||||
) : (
|
||||
<Form
|
||||
layout="vertical"
|
||||
form={vectorConfigForm}
|
||||
onFinish={handleVectorConfigSave}
|
||||
initialValues={{ type: selectedProviderType || undefined, config: {} }}
|
||||
>
|
||||
<Form.Item
|
||||
name="type"
|
||||
label={t('Database Provider')}
|
||||
rules={[{ required: true, message: t('Please select a provider') }]}
|
||||
>
|
||||
<Select
|
||||
size="large"
|
||||
options={vectorProviders.map((provider) => ({
|
||||
value: provider.type,
|
||||
label: provider.enabled ? provider.label : `${provider.label} (${t('Coming soon')})`,
|
||||
disabled: !provider.enabled,
|
||||
}))}
|
||||
onChange={handleProviderChange}
|
||||
loading={vectorConfigLoading && !vectorProviders.length}
|
||||
/>
|
||||
</Form.Item>
|
||||
{selectedProvider?.description ? (
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
message={t(selectedProvider.description)}
|
||||
style={{ marginBottom: 16 }}
|
||||
/>
|
||||
) : null}
|
||||
{selectedProvider?.config_schema?.map((field) => (
|
||||
<Form.Item
|
||||
key={field.key}
|
||||
name={['config', field.key]}
|
||||
label={t(field.label)}
|
||||
rules={field.required ? [{ required: true, message: t('Please input {label}', { label: t(field.label) }) }] : []}
|
||||
>
|
||||
{field.type === 'password' ? (
|
||||
<Input.Password size="large" placeholder={field.placeholder ? t(field.placeholder) : undefined} />
|
||||
) : (
|
||||
<Input size="large" placeholder={field.placeholder ? t(field.placeholder) : undefined} />
|
||||
)}
|
||||
</Form.Item>
|
||||
))}
|
||||
{selectedProvider && !selectedProvider.enabled ? (
|
||||
<Alert
|
||||
type="warning"
|
||||
showIcon
|
||||
message={t('This provider is not available yet')}
|
||||
style={{ marginBottom: 16 }}
|
||||
/>
|
||||
) : null}
|
||||
<Form.Item>
|
||||
<Space direction="vertical" style={{ width: '100%' }}>
|
||||
<Button
|
||||
type="primary"
|
||||
htmlType="submit"
|
||||
loading={vectorConfigSaving}
|
||||
block
|
||||
disabled={!selectedProvider?.enabled}
|
||||
>
|
||||
{t('Save')}
|
||||
</Button>
|
||||
<Button
|
||||
danger
|
||||
htmlType="button"
|
||||
block
|
||||
onClick={() => {
|
||||
Modal.confirm({
|
||||
title: t('Confirm clear vector database?'),
|
||||
content: t('This will delete all collections irreversibly.'),
|
||||
okText: t('Confirm Clear'),
|
||||
okType: 'danger',
|
||||
cancelText: t('Cancel'),
|
||||
onOk: async () => {
|
||||
try {
|
||||
await vectorDBApi.clearAll();
|
||||
message.success(t('Vector database cleared'));
|
||||
await fetchVectorStats();
|
||||
await fetchVectorMeta();
|
||||
} catch (e: any) {
|
||||
message.error(e.message || t('Clear failed'));
|
||||
}
|
||||
},
|
||||
});
|
||||
}}
|
||||
>
|
||||
{t('Clear Vector DB')}
|
||||
</Button>
|
||||
</Space>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
)}
|
||||
</Space>
|
||||
</Card>
|
||||
),
|
||||
|
||||
Reference in New Issue
Block a user