/
alexefan136
/
flowstack
Обзор
Документация
Войти
/
alexefan136
/
flowstack
Код
Запросы
0
Задачи
Вики
Пакеты
0
Релизы
0
CI/CD
Аналитика
Безопасность
main
ui/src/components/knowledge/KnowledgeFreshness.tsx
524 строки
17 KB
Alexander Efanov
Обновление репозитория
15 июл 2026, 12:19
15 июл 2026, 12:19
76704c6
Код
Авторство
О чём код?
// src/components/knowledge/KnowledgeFreshness.tsx import { useState, useEffect, useCallback, useMemo } from 'react'; import { useParams } from 'react-router-dom'; import { AlertCircle, Sparkles, Loader2, CheckCircle, RefreshCw, X } from 'lucide-react'; import { useAppStore } from '../../lib/stores/app-store'; import { getRagStats, analyzeDocumentFreshness, regenerateDocumentWithLLM } from '../../lib/api'; import { getFreshnessLevel, getFreshnessConfig } from '../../lib/knowledge'; import { cn } from '../../lib/utils'; import { FreshnessBadge } from './FreshnessBadge'; // ============================================================================ // Types // ============================================================================ interface FreshnessDoc { document_id: string; title: string; freshness_score: number; reason: string; recommendation: 'update' | 'archive' | 'keep'; } interface RagStats { total: number; docs: number; } type FreshnessLevel = 'excellent' | 'good' | 'stale' | 'outdated'; // ============================================================================ // Constants // ============================================================================ const LEVEL_LABELS: Record<FreshnessLevel, string> = { excellent: '80-100%', good: '60-79%', stale: '40-59%', outdated: '<40%', }; // ============================================================================ // Custom Hooks // ============================================================================ /** * Custom hook для загрузки RAG статистики. * Инкапсулирует data fetching согласно React best practices. * * ✅ Correct pattern: isLoading initialized via useState, not setState in effect */ function useRagStats(workspace: string) { const [stats, setStats] = useState<RagStats>({ total: 0, docs: 0 }); const [isLoading, setIsLoading] = useState(true); // ✅ Initialize here, not in effect useEffect(() => { let ignore = false; getRagStats(workspace) .then((data) => { if (!ignore) { setStats({ total: data.total_chunks, docs: data.total_documents, }); } }) .catch((err) => { if (!ignore) { console.error('[Freshness] Failed to load stats:', err); } }) .finally(() => { if (!ignore) { setIsLoading(false); } }); return () => { ignore = true; }; }, [workspace]); return { stats, isLoading }; } // ============================================================================ // Sub-components // ============================================================================ /** * Initial state - показывает кнопку запуска анализа. */ function InitialState({ docsCount, isLoading, onAnalyze, }: { docsCount: number; isLoading: boolean; onAnalyze: () => void; }) { if (isLoading) { return <FreshnessSkeleton />; } return ( <div className="flex flex-col items-center justify-center py-20 text-center fadein"> <div className="w-20 h-20 rounded-3xl gradient-primary flex items-center justify-center mb-4 shadow-lime"> <Sparkles className="w-10 h-10 text-text-on-accent" aria-hidden="true" /> </div> <h3 className="text-lg font-semibold text-text-primary mb-2"> Анализ свежести с помощью LLM </h3> <p className="text-sm text-text-muted max-w-md mb-6"> Нейросеть проанализирует {docsCount} документов из вашей базы знаний и определит какие требуют обновления. </p> <button onClick={onAnalyze} disabled={docsCount === 0} className="btn btn-primary" > <Sparkles className="w-4 h-4" aria-hidden="true" /> Запустить анализ </button> {docsCount === 0 && ( <p className="text-xs text-text-muted mt-3"> Сначала загрузите документы в базу знаний </p> )} </div> ); } /** * Analyzing state - показывает прогресс анализа. */ function AnalyzingState() { return ( <div className="flex flex-col items-center justify-center py-20 fadein"> <div className="relative"> <div className="w-16 h-16 rounded-full gradient-primary flex items-center justify-center shadow-lime"> <Loader2 className="w-8 h-8 text-text-on-accent animate-spin" aria-hidden="true" /> </div> <div className="absolute inset-0 rounded-full gradient-primary blur-xl opacity-50 -z-10" /> </div> <p className="text-sm text-text-muted mt-4"> Нейросеть анализирует документы... </p> <p className="text-xs text-text-muted mt-1"> Это может занять несколько секунд </p> </div> ); } /** * Skeleton loader для карточек свежести. */ function FreshnessSkeleton() { return ( <div className="flex flex-col items-center justify-center py-20 fadein" role="status" aria-label="Загрузка статистики" > <div className="w-16 h-16 rounded-full bg-(--glass-bg-strong) flex items-center justify-center mb-4 animate-pulse"> <Loader2 className="w-8 h-8 text-text-muted" aria-hidden="true" /> </div> <div className="space-y-3 w-full max-w-md"> <div className="h-4 bg-(--glass-bg-strong) rounded animate-pulse" /> <div className="h-4 bg-(--glass-bg-strong) rounded w-3/4 animate-pulse" /> <div className="h-4 bg-(--glass-bg-strong) rounded w-1/2 animate-pulse" /> </div> <span className="sr-only">Загрузка статистики базы знаний...</span> </div> ); } /** * Distribution chart - визуализация распределения по уровням свежести. */ function DistributionChart({ documents, byLevel, avgFreshness, onRefresh, isRefreshing, }: { documents: FreshnessDoc[]; byLevel: Record<FreshnessLevel, FreshnessDoc[]>; avgFreshness: number; onRefresh: () => void; isRefreshing: boolean; }) { return ( <div className="glass rounded-xl p-5"> <div className="flex items-center justify-between mb-4"> <h3 className="text-text-primary font-semibold text-sm flex items-center gap-2"> <Sparkles className="w-4 h-4 text-accent-lime" aria-hidden="true" /> Распределение по свежести </h3> <div className="flex items-center gap-2"> <div className="text-xs text-text-muted"> Средняя:{' '} <span className="text-text-primary font-medium">{avgFreshness}%</span> </div> <button onClick={onRefresh} disabled={isRefreshing} className="btn btn-ghost btn-icon btn-sm" title="Переанализировать" aria-label="Переанализировать документы" > <RefreshCw className={cn('w-3.5 h-3.5', isRefreshing && 'animate-spin')} aria-hidden="true" /> </button> </div> </div> <div className="space-y-3"> {(Object.keys(byLevel) as FreshnessLevel[]).map((level) => { const config = getFreshnessConfig(level); const count = byLevel[level].length; const pct = documents.length > 0 ? (count / documents.length) * 100 : 0; return ( <div key={level}> <div className="flex justify-between text-xs mb-1"> <span className={config.color}> {config.label} ({LEVEL_LABELS[level]}) </span> <span className="text-text-muted">{count} документов</span> </div> <div className="h-2 bg-(--glass-bg-strong) rounded-full overflow-hidden"> <div className={cn( 'h-full rounded-full transition-all duration-500 ease-out', config.bg )} style={{ width: `${pct}%` }} role="progressbar" aria-valuenow={pct} aria-valuemin={0} aria-valuemax={100} aria-label={`${config.label}: ${pct.toFixed(1)}%`} /> </div> </div> ); })} </div> </div> ); } /** * Document card - карточка документа требующего обновления. */ function DocumentCard({ doc, isRegenerating, onRegenerate, }: { doc: FreshnessDoc; isRegenerating: boolean; onRegenerate: () => void; }) { return ( <div className="flex items-center gap-3 p-2.5 rounded-lg glass hover:border-border-accent transition-all group"> <AlertCircle className="w-4 h-4 text-status-error shrink-0" aria-hidden="true" /> <div className="flex-1 min-w-0"> <div className="text-xs text-text-primary truncate font-medium"> {doc.title} </div> <div className="text-[10px] text-text-muted mt-0.5"> {doc.reason} </div> <FreshnessBadge score={doc.freshness_score} showLabel className="mt-1" /> </div> <button onClick={onRegenerate} disabled={isRegenerating} className={cn( 'btn btn-ghost btn-sm text-xs', isRegenerating && 'opacity-50 cursor-not-allowed' )} aria-label={`Обновить документ "${doc.title}"`} > {isRegenerating ? ( <Loader2 className="w-3 h-3 animate-spin" aria-hidden="true" /> ) : ( <Sparkles className="w-3 h-3" aria-hidden="true" /> )} Обновить </button> </div> ); } /** * Needs update list - список документов требующих обновления. */ function NeedsUpdateList({ documents, regeneratingId, onRegenerate, }: { documents: FreshnessDoc[]; regeneratingId: string | null; onRegenerate: (doc: FreshnessDoc) => void; }) { return ( <div className="glass rounded-xl p-5"> <div className="flex items-center justify-between mb-4"> <h3 className="text-text-primary font-semibold text-sm flex items-center gap-2"> <AlertCircle className="w-4 h-4 text-status-error" aria-hidden="true" /> Требуют обновления </h3> <span className="badge badge-solid-lime badge-sm"> {documents.length} </span> </div> <div className="space-y-2 max-h-80 overflow-y-auto"> {documents.length === 0 ? ( <div className="text-center py-8 text-accent-lime text-sm flex flex-col items-center gap-2"> <div className="w-12 h-12 rounded-full bg-accent-lime-soft flex items-center justify-center"> <CheckCircle className="w-6 h-6" aria-hidden="true" /> </div> <p className="font-medium">Все документы актуальны</p> <p className="text-xs text-text-muted"> Отличная работа! База знаний в отличном состоянии. </p> </div> ) : ( documents.map((doc) => ( <DocumentCard key={doc.document_id} doc={doc} isRegenerating={regeneratingId === doc.document_id} onRegenerate={() => onRegenerate(doc)} /> )) )} </div> </div> ); } /** * Regenerated content preview - превью обновлённого документа. */ function RegeneratedContent({ content, onClose, }: { content: string; onClose: () => void; }) { return ( <div className="lg:col-span-2 glass rounded-xl p-5 slide-up"> <div className="flex items-center justify-between mb-3"> <h3 className="text-text-primary font-semibold text-sm flex items-center gap-2"> <CheckCircle className="w-4 h-4 text-accent-lime" aria-hidden="true" /> Обновлённый документ </h3> <button onClick={onClose} className="btn btn-ghost btn-icon btn-sm" aria-label="Закрыть превью" > <X className="w-4 h-4" aria-hidden="true" /> </button> </div> <div className="prose prose-invert prose-sm max-w-none max-h-96 overflow-y-auto glass rounded-lg p-4"> {content} </div> </div> ); } // ============================================================================ // Main Component // ============================================================================ export function KnowledgeFreshness() { const { workspace = 'default' } = useParams(); const { toast } = useAppStore(); const [documents, setDocuments] = useState<FreshnessDoc[]>([]); const [isAnalyzing, setIsAnalyzing] = useState(false); const [analyzed, setAnalyzed] = useState(false); const [regeneratingId, setRegeneratingId] = useState<string | null>(null); const [regeneratedContent, setRegeneratedContent] = useState(''); const { stats: realStats, isLoading: statsLoading } = useRagStats(workspace); // ======================================================================== // LLM Analysis // ======================================================================== const handleAnalyze = useCallback(async () => { setIsAnalyzing(true); try { const result = await analyzeDocumentFreshness(workspace); setDocuments(result.documents); setAnalyzed(true); toast(`Проанализировано ${result.documents.length} документов`, 'success'); } catch (error) { console.error('[Freshness] Analysis failed:', error); toast( error instanceof Error ? error.message : 'Ошибка анализа', 'warning' ); } finally { setIsAnalyzing(false); } }, [workspace, toast]); // ======================================================================== // LLM Regeneration // ======================================================================== const handleRegenerate = useCallback( async (doc: FreshnessDoc) => { setRegeneratingId(doc.document_id); setRegeneratedContent(''); try { for await (const event of regenerateDocumentWithLLM(doc.document_id, workspace)) { if (event.type === 'content' && event.content) { setRegeneratedContent((prev) => prev + event.content); } } toast(`Документ "${doc.title}" обновлён`, 'success'); } catch (error) { console.error('[Freshness] Regeneration failed:', error); toast( error instanceof Error ? error.message : 'Ошибка обновления', 'warning' ); } finally { setRegeneratingId(null); } }, [workspace, toast] ); // ======================================================================== // Memoized Computations // ======================================================================== const byLevel = useMemo(() => { const groups: Record<FreshnessLevel, FreshnessDoc[]> = { excellent: [], good: [], stale: [], outdated: [], }; documents.forEach((d) => { const level = getFreshnessLevel(d.freshness_score); groups[level].push(d); }); return groups; }, [documents]); const needsUpdate = useMemo( () => [...byLevel.stale, ...byLevel.outdated], [byLevel] ); const avgFreshness = useMemo(() => { if (documents.length === 0) return 0; return Math.round( documents.reduce((s, d) => s + d.freshness_score, 0) / documents.length ); }, [documents]); // ======================================================================== // Render // ======================================================================== if (!analyzed && !isAnalyzing) { return ( <InitialState docsCount={realStats.docs} isLoading={statsLoading} onAnalyze={handleAnalyze} /> ); } if (isAnalyzing) { return <AnalyzingState />; } return ( <div className="grid grid-cols-1 lg:grid-cols-2 gap-4 fadein"> <DistributionChart documents={documents} byLevel={byLevel} avgFreshness={avgFreshness} onRefresh={handleAnalyze} isRefreshing={isAnalyzing} /> <NeedsUpdateList documents={needsUpdate} regeneratingId={regeneratingId} onRegenerate={handleRegenerate} /> {regeneratedContent && ( <RegeneratedContent content={regeneratedContent} onClose={() => setRegeneratedContent('')} /> )} </div> ); }