Velo / src /components /pages /KnowledgeOverview.tsx
Leon4gr45's picture
Upload folder using huggingface_hub
55847d9 verified
Raw History Blame Contribute Delete
13.5 kB
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<KBDocument[]>([])
const [stats, setStats] = useState<KBStatsData>({ documentCount: 0, totalChunks: 0, vectorDimension: 0 })
const [searchQuery, setSearchQuery] = useState('')
const [searchResults, setSearchResults] = useState<SearchResult[]>([])
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 (
<div className="h-full flex flex-col overflow-hidden bg-[var(--color-bg)]">
<div
className="flex items-center justify-between gap-2 px-3 h-9 flex-shrink-0"
style={{
borderBottom: '1px solid var(--color-border)',
backgroundColor: 'var(--color-editor-bg)',
}}
>
<div className="flex items-center gap-1.5 min-w-0">
<span className="text-xs font-medium truncate text-[var(--color-text-secondary)]">
{t('title')}
</span>
</div>
</div>
<div className="flex-1 overflow-y-auto relative">
<EmptyState icon={<BookOpen size={36} />} message={t('openProjectFirst')} opacity={0.4} />
</div>
</div>
)
}
/** 语义检索 */
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 (
<div className="h-full overflow-y-auto" style={{ backgroundColor: 'var(--color-editor-bg)' }}>
<div className="max-w-4xl mx-auto px-8 py-6">
{/* ===== 标题 ===== */}
<div className="flex items-center gap-3 mb-6">
<div
className="w-10 h-10 rounded-xl flex items-center justify-center flex-shrink-0"
style={{ background: 'linear-gradient(135deg, var(--color-accent), var(--color-accent-hover))' }}
>
<Database size={20} className="text-white" />
</div>
<div>
<h2 className="text-lg font-bold text-[var(--color-text)]">{t('title')}</h2>
<p className="text-xs text-[var(--color-text-muted)]">
{t('description')}
</p>
</div>
</div>
{/* ===== 统计卡片 ===== */}
<div className="grid grid-cols-4 gap-3 mb-6">
<StatCard icon={<FileText size={14} />} label={t('docCount')} value={stats.documentCount} />
<StatCard icon={<Layers size={14} />} label={t('chunkCount')} value={stats.totalChunks} />
<StatCard
icon={<Server size={14} />}
label={t('engine')}
value="LanceDB"
accent
/>
<StatCard
icon={<Activity size={14} />}
label={t('searchMode')}
value={hasVectors ? t('ftsVector') : t('ftsOnly')}
badge={hasVectors ? t('hybrid') : t('basic')}
badgeColor={hasVectors ? '#22c55e' : '#3b82f6'}
/>
</div>
{/* ===== 向量回填卡片 ===== */}
{vectorlessCount > 0 && (
<div
className="rounded-xl border border-amber-500/20 mb-6 overflow-hidden"
style={{ backgroundColor: 'rgba(245, 158, 11, 0.06)' }}
>
<div className="flex items-center justify-between px-4 py-3">
<div className="flex items-center gap-2">
<div className="w-8 h-8 rounded-lg bg-amber-500/15 flex items-center justify-center">
<Zap size={16} className="text-amber-400" />
</div>
<div>
<div className="text-sm font-medium text-amber-300">{t('indexUpgrade')}</div>
<div className="text-[0.7rem] text-amber-400/70">
{t('vectorlessCount', { count: vectorlessCount })}
</div>
</div>
</div>
<Button
variant="outline"
className="text-xs border-amber-500/30 text-amber-400 hover:bg-amber-500/20"
onClick={handleBackfill}
disabled={backfilling}
>
{backfilling ? (
<><RefreshCw size={12} className="animate-spin mr-1.5" />{t('rebuilding')}</>
) : (
<>{t('rebuildIndex')}</>
)}
</Button>
</div>
{/* 进度条(回填时显示) */}
{backfilling && (
<div className="h-1 w-full bg-amber-500/10">
<div className="h-full bg-gradient-to-r from-amber-500 to-amber-300 animate-pulse rounded-full w-full" />
</div>
)}
</div>
)}
{/* ===== 语义检索区域 ===== */}
<div
className="rounded-xl border border-[var(--color-border)] mb-6 overflow-hidden"
style={{ backgroundColor: 'var(--color-sidebar)' }}
>
<div className="flex items-center gap-2 px-4 py-3 border-b border-[var(--color-border)]">
<Search size={14} className="text-[var(--color-accent)] flex-shrink-0" />
<span className="text-sm font-semibold text-[var(--color-text)]">{t('semanticSearch')}</span>
{/* 检索模式标签 */}
<span className={cn(
'text-[0.65rem] px-1.5 py-0.5 rounded-full font-medium',
hasVectors
? 'bg-emerald-500/15 text-emerald-400'
: 'bg-blue-500/15 text-blue-400'
)}>
{searchMode}
</span>
<span className="text-[0.7rem] text-[var(--color-text-muted)] ml-auto">
{hasVectors ? t('hybridHint') : t('basicHint')}
</span>
</div>
<div className="px-4 py-3">
<div className="flex items-center gap-2">
<Input
className="flex-1 h-9"
placeholder={t('searchPlaceholder')}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
/>
<div className="flex items-center gap-1 flex-shrink-0">
<span className="text-[0.7rem] text-[var(--color-text-muted)]">Top</span>
<Input
type="number"
min={1}
max={50}
value={topK}
onChange={(e) => 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"
/>
</div>
<Button
variant="ai"
onClick={handleSearch}
disabled={searching}
>
{searching ? <RefreshCw size={13} className="animate-spin" /> : <Search size={13} />}
{t('search')}
</Button>
</div>
</div>
{/* 检索结果 */}
{searchResults.length > 0 && (
<div className="border-t border-[var(--color-border)]">
<div className="px-4 py-2 flex items-center justify-between">
<span className="text-xs font-medium text-[var(--color-text-muted)]">
{t('searchResults', { count: searchResults.length })}
</span>
<button
className="text-[0.7rem] text-[var(--color-text-muted)] hover:text-[var(--color-text)] transition-colors"
onClick={() => setSearchResults([])}
>
{t('clear')}
</button>
</div>
<div className="max-h-[400px] overflow-y-auto">
{[...searchResults].reverse().map((r, i) => (
<div
key={i}
className="px-4 py-3 border-t border-[var(--color-border)] hover:bg-[var(--color-hover)] transition-colors"
>
<div className="flex items-center justify-between mb-1.5">
<span className="text-xs text-[var(--color-text-muted)] flex items-center gap-1.5">
<FileText size={10} />
{r.fileName}
</span>
<span className={cn(
'text-[0.7rem] px-1.5 py-0.5 rounded font-mono',
r.score > 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) })}
</span>
</div>
<p className="text-xs text-[var(--color-text-secondary)] leading-relaxed whitespace-pre-wrap">
{r.text}
</p>
</div>
))}
</div>
</div>
)}
</div>
</div>
</div>
)
}
/** 统计卡片子组件 */
function StatCard({ icon, label, value, accent, badge, badgeColor }: {
icon: React.ReactNode
label: string
value: number | string
accent?: boolean
badge?: string
badgeColor?: string
}) {
return (
<div
className="rounded-xl p-4 border border-[var(--color-border)]"
style={{ backgroundColor: 'var(--color-sidebar)' }}
>
<div className="flex items-center gap-2 mb-2">
<span className="text-[var(--color-text-muted)]">{icon}</span>
<span className="text-xs text-[var(--color-text-muted)]">{label}</span>
</div>
<div className="flex items-center gap-2">
<div className={cn(
'text-2xl font-bold',
accent ? 'text-[var(--color-accent)]' : 'text-[var(--color-text)]'
)}>
{value}
</div>
{badge && (
<span
className="text-[0.6rem] px-1.5 py-0.5 rounded-full font-medium"
style={{ backgroundColor: `${badgeColor}20`, color: badgeColor }}
>
{badge}
</span>
)}
</div>
</div>
)
}