import { useState, useEffect, useCallback } from 'react' import { Database, BookOpen, FileText, Search, RefreshCw, Layers, Zap, Server, Activity, } from 'lucide-react' import { useTranslation } from 'react-i18next' import { Button } from '../ui/Button' import { Input } from '../ui/Input' import { EmptyState } from '../ui/EmptyState' import { useProjectStore } from '../../stores/project-store' import { cn } from '../../lib/utils' import { toast } from '../ui/Toast' import { globalEventBus } from '../../shared/event-bus' import { loadKBData, getVectorlessCount, searchKB, backfillVectors, type KBDocument, type SearchResult, type KBStatsData, } from '../../services/knowledge-service' /** * 知识库概览页面 — LanceDB 向量数据库的管理中心 * 当侧栏视图为"知识库"时,作为中间编辑区的固定内容展示。 */ export default function KnowledgeOverview() { const { t } = useTranslation('pages', { keyPrefix: 'knowledgeOverview' }) const [documents, setDocuments] = useState([]) const [stats, setStats] = useState({ documentCount: 0, totalChunks: 0, vectorDimension: 0 }) const [searchQuery, setSearchQuery] = useState('') const [searchResults, setSearchResults] = useState([]) const [searching, setSearching] = useState(false) const [topK, setTopK] = useState(10) const [vectorlessCount, setVectorlessCount] = useState(0) const [backfilling, setBackfilling] = useState(false) const currentProject = useProjectStore(s => s.currentProject) const loadData = useCallback(async () => { if (!currentProject) return try { const { documents: docs, stats: s } = await loadKBData() setDocuments(docs) setStats(s) } catch { /* 忽略 */ } // eslint-disable-next-line react-hooks/exhaustive-deps }, [currentProject?.path]) const checkVectorless = useCallback(async () => { if (!currentProject) return try { setVectorlessCount(await getVectorlessCount()) } catch { /* 忽略 */ } // eslint-disable-next-line react-hooks/exhaustive-deps }, [currentProject?.path]) useEffect(() => { loadData() checkVectorless() }, [loadData, checkVectorless]) useEffect(() => { checkVectorless() }, [checkVectorless, documents]) // 通过 EventBus 监听资源刷新和定稿完成事件 useEffect(() => { const unsub1 = globalEventBus.on('REFRESH_RESOURCE', (payload: { resources: string[] }) => { if (payload.resources.includes('all') || payload.resources.includes('fileTree')) { loadData() checkVectorless() } }) const unsub2 = globalEventBus.on('FINALIZE_COMPLETE', () => { loadData() checkVectorless() }) return () => { unsub1(); unsub2() } }, [loadData, checkVectorless]) // 判断检索模式 const hasVectors = stats.vectorDimension > 0 const searchMode = hasVectors ? t('hybridSearch') : t('bm25Search') if (!currentProject) { return (
{t('title')}
} message={t('openProjectFirst')} opacity={0.4} />
) } /** 语义检索 */ const handleSearch = async () => { setSearching(true) try { const results = await searchKB(searchQuery, topK) setSearchResults(results) } catch { /* 忽略 */ } setSearching(false) } /** 向量回填 */ const handleBackfill = async () => { setBackfilling(true) try { const result = await backfillVectors() if (result.success) { toast.success(t('rebuildSuccess', { processed: result.processed, failed: result.failed > 0 ? t('failedCount', { count: result.failed }) : '' })) } else { toast.error(result.error || t('rebuildFailed')) } } catch (e) { toast.error(t('rebuildFailed') + ': ' + String(e)) } finally { setBackfilling(false) globalEventBus.emit('REFRESH_RESOURCE', { resources: ['all'] }) } } return (
{/* ===== 标题 ===== */}

{t('title')}

{t('description')}

{/* ===== 统计卡片 ===== */}
} label={t('docCount')} value={stats.documentCount} /> } label={t('chunkCount')} value={stats.totalChunks} /> } label={t('engine')} value="LanceDB" accent /> } label={t('searchMode')} value={hasVectors ? t('ftsVector') : t('ftsOnly')} badge={hasVectors ? t('hybrid') : t('basic')} badgeColor={hasVectors ? '#22c55e' : '#3b82f6'} />
{/* ===== 向量回填卡片 ===== */} {vectorlessCount > 0 && (
{t('indexUpgrade')}
{t('vectorlessCount', { count: vectorlessCount })}
{/* 进度条(回填时显示) */} {backfilling && (
)}
)} {/* ===== 语义检索区域 ===== */}
{t('semanticSearch')} {/* 检索模式标签 */} {searchMode} {hasVectors ? t('hybridHint') : t('basicHint')}
setSearchQuery(e.target.value)} onKeyDown={(e) => e.key === 'Enter' && handleSearch()} />
Top setTopK(Math.max(1, Math.min(50, Number(e.target.value) || 10)))} className="w-12 h-7 text-xs rounded px-1.5 text-center" />
{/* 检索结果 */} {searchResults.length > 0 && (
{t('searchResults', { count: searchResults.length })}
{[...searchResults].reverse().map((r, i) => (
{r.fileName} 0.8 ? 'bg-green-500/20 text-green-400' : r.score > 0.6 ? 'bg-yellow-500/20 text-yellow-400' : 'bg-[var(--color-hover)] text-[var(--color-text-muted)]' )}> {r.score === 0.5 ? t('fullTextMatch') : t('similarity', { score: (r.score * 100).toFixed(1) })}

{r.text}

))}
)}
) } /** 统计卡片子组件 */ function StatCard({ icon, label, value, accent, badge, badgeColor }: { icon: React.ReactNode label: string value: number | string accent?: boolean badge?: string badgeColor?: string }) { return (
{icon} {label}
{value}
{badge && ( {badge} )}
) }