/
alexefan136
/
flowstack
Обзор
Документация
Войти
/
alexefan136
/
flowstack
Код
Запросы
0
Задачи
Вики
Пакеты
0
Релизы
0
CI/CD
Аналитика
Безопасность
main
ui/src/components/knowledge/KnowledgeGraph.tsx
550 строк
19 KB
Alexander Efanov
Обновление репозитория
15 июл 2026, 12:19
15 июл 2026, 12:19
76704c6
Код
Авторство
О чём код?
// src/components/knowledge/KnowledgeGraph.tsx import { useState, useEffect, useMemo, useCallback, useRef } from 'react'; import { useParams } from 'react-router-dom'; import { ZoomIn, ZoomOut, Search, Sparkles, AlertCircle, Loader2, } from 'lucide-react'; import { getRagStats, getKnowledgeGraph } from '../../lib/api'; import type { GraphNode, GraphEdge } from '../../lib/knowledge'; import { cn } from '../../lib/utils'; // ============================================================================ // Types // ============================================================================ type NodeType = 'concept' | 'document' | 'entity' | 'decision'; type FilterType = 'all' | NodeType; // ============================================================================ // Constants // ============================================================================ const MIN_ZOOM = 0.5; const MAX_ZOOM = 2; const ZOOM_STEP = 0.1; const SVG_WIDTH = 800; const SVG_HEIGHT = 500; // ============================================================================ // Helpers // ============================================================================ function safeNumber(value: unknown): number { if (typeof value === 'number' && !Number.isNaN(value)) return value; if (typeof value === 'string') { const parsed = Number(value); return Number.isNaN(parsed) ? 0 : parsed; } return 0; } // ============================================================================ // Sub-components // ============================================================================ function NodeItem({ node, isSelected, onClick, }: { node: GraphNode; isSelected: boolean; onClick: () => void; }) { return ( <g onClick={onClick} style={{ cursor: 'pointer' }} role="button" tabIndex={0} aria-label={`Узел ${node.label}`} > <circle cx={node.x} cy={node.y} r={node.size} fill={node.color} opacity={isSelected ? 1 : 0.85} stroke={isSelected ? 'white' : 'transparent'} strokeWidth="2" /> <text x={node.x} y={node.y + node.size + 14} textAnchor="middle" fill="rgba(255,255,255,0.8)" fontSize="11" fontWeight={isSelected ? '600' : '400'} > {node.label} </text> </g> ); } function ConnectionButton({ node, onClick, }: { node: GraphNode; onClick: () => void; }) { return ( <button onClick={onClick} className="w-full flex items-center gap-2 px-2.5 py-1.5 rounded-md bg-white/5 hover:bg-white/10 transition text-left" aria-label={`Перейти к узлу ${node.label}`} > <div className="w-2 h-2 rounded-full shrink-0" style={{ background: node.color }} /> <span className="text-xs text-white/80 flex-1">{node.label}</span> </button> ); } function GraphSkeleton() { return ( <div className="lg:col-span-3 bg-bg-card border border-border-subtle rounded-xl p-6 relative overflow-hidden animate-pulse" style={{ height: 600 }}> <div className="flex items-center gap-2 mb-4"> <div className="h-8 w-48 bg-bg-elevated rounded-lg" /> <div className="h-8 w-32 bg-bg-elevated rounded-md ml-auto" /> </div> <div className="h-[520px] bg-bg-elevated/50 rounded-lg" /> </div> ); } // ============================================================================ // Main Component // ============================================================================ export function KnowledgeGraph() { const { workspace = 'default' } = useParams(); // ========================================================================== // State // ========================================================================== const [selectedNodeId, setSelectedNodeId] = useState<string | null>(null); const [zoom, setZoom] = useState(1); const [filter, setFilter] = useState<FilterType>('all'); const [searchQuery, setSearchQuery] = useState(''); // Данные графа const [nodes, setNodes] = useState<GraphNode[]>([]); const [edges, setEdges] = useState<GraphEdge[]>([]); const [isGraphLoading, setIsGraphLoading] = useState(true); const [graphError, setGraphError] = useState<string | null>(null); // Статистика const [totalDocuments, setTotalDocuments] = useState(0); const [totalChunks, setTotalChunks] = useState(0); const [isStatsLoading, setIsStatsLoading] = useState(true); const [statsError, setStatsError] = useState<string | null>(null); // Refs для предотвращения race conditions const fetchIdRef = useRef(0); const initializedRef = useRef(false); // ========================================================================== // Data Fetching // ========================================================================== useEffect(() => { if (!initializedRef.current) { initializedRef.current = true; const fetchId = ++fetchIdRef.current; async function fetchData() { try { // Загружаем граф и статистику параллельно const [graphData, statsData] = await Promise.all([ getKnowledgeGraph(workspace), getRagStats(workspace), ]); if (fetchId !== fetchIdRef.current) return; setNodes(graphData.nodes); setEdges(graphData.edges); setGraphError(null); setTotalDocuments(safeNumber(statsData?.total_documents)); setTotalChunks(safeNumber(statsData?.total_chunks)); setStatsError(null); } catch (err) { if (fetchId !== fetchIdRef.current) return; console.error('[KnowledgeGraph] Failed to fetch data:', err); const message = err instanceof Error ? err.message : 'Не удалось загрузить данные'; setGraphError(message); setStatsError(message); } finally { if (fetchId === fetchIdRef.current) { setIsGraphLoading(false); setIsStatsLoading(false); } } } void fetchData(); } }, [workspace]); // ========================================================================== // Event Handlers // ========================================================================== const handleZoomIn = useCallback(() => { setZoom((z) => Math.min(MAX_ZOOM, z + ZOOM_STEP)); }, []); const handleZoomOut = useCallback(() => { setZoom((z) => Math.max(MIN_ZOOM, z - ZOOM_STEP)); }, []); const handleFilterChange = useCallback( (e: React.ChangeEvent<HTMLSelectElement>) => { setFilter(e.target.value as FilterType); }, [] ); const handleSelectNode = useCallback((nodeId: string) => { setSelectedNodeId(nodeId); }, []); // ========================================================================== // Memoized Computations // ========================================================================== const nodesById = useMemo(() => { const map = new Map<string, GraphNode>(); for (const node of nodes) { map.set(node.id, node); } return map; }, [nodes]); const filteredNodes = useMemo(() => { let result = nodes; // Фильтр по типу if (filter !== 'all') { result = result.filter((n) => n.type === filter); } // Фильтр по поиску if (searchQuery.trim()) { const q = searchQuery.trim().toLowerCase(); result = result.filter((n) => n.label.toLowerCase().includes(q)); } return result; }, [nodes, filter, searchQuery]); const filteredNodeIds = useMemo( () => new Set(filteredNodes.map((n) => n.id)), [filteredNodes] ); const filteredEdges = useMemo( () => edges.filter( (e) => filteredNodeIds.has(e.source) && filteredNodeIds.has(e.target) ), [edges, filteredNodeIds] ); const selectedNode = useMemo( () => (selectedNodeId ? nodesById.get(selectedNodeId) ?? null : null), [selectedNodeId, nodesById] ); const connections = useMemo(() => { if (!selectedNode) return []; return filteredEdges.filter( (e) => e.source === selectedNode.id || e.target === selectedNode.id ); }, [selectedNode, filteredEdges]); const connectedNodes = useMemo(() => { if (!selectedNode) return []; return connections .map((edge) => { const otherId = edge.source === selectedNode.id ? edge.target : edge.source; return nodesById.get(otherId) ?? null; }) .filter((n): n is GraphNode => n !== null); }, [selectedNode, connections, nodesById]); // ========================================================================== // Loading state // ========================================================================== if (isGraphLoading) { return ( <div className="grid grid-cols-1 lg:grid-cols-4 gap-4"> <GraphSkeleton /> <div className="bg-bg-card border border-border-subtle rounded-xl p-5 animate-pulse"> <div className="h-4 w-24 bg-bg-elevated rounded mb-4" /> <div className="space-y-3"> <div className="h-20 bg-bg-elevated rounded-lg" /> <div className="h-16 bg-bg-elevated rounded-lg" /> </div> </div> </div> ); } // ========================================================================== // Error state // ========================================================================== if (graphError && nodes.length === 0) { return ( <div className="flex flex-col items-center justify-center py-20"> <div className="w-16 h-16 rounded-full bg-status-error-soft flex items-center justify-center mb-4"> <AlertCircle className="w-8 h-8 text-status-error" aria-hidden="true" /> </div> <h3 className="text-lg font-semibold text-text-primary mb-2"> Ошибка загрузки графа </h3> <p className="text-text-muted text-sm mb-4 text-center max-w-md"> {graphError} </p> </div> ); } // ========================================================================== // Render // ========================================================================== return ( <div className="grid grid-cols-1 lg:grid-cols-4 gap-4"> {/* ==================================================================== */} {/* Graph canvas */} {/* ==================================================================== */} <div className="lg:col-span-3 bg-bg-card border border-border-subtle rounded-xl p-6 relative overflow-hidden" style={{ height: 600 }} > {/* Grid background */} <div className="absolute inset-0 opacity-30 pointer-events-none" style={{ backgroundImage: 'radial-gradient(circle, rgba(255,255,255,0.08) 1px, transparent 1px)', backgroundSize: '24px 24px', }} /> {/* Controls */} <div className="relative flex items-center gap-2 mb-4 flex-wrap"> <div className="relative"> <Search className="w-3.5 h-3.5 text-text-muted absolute left-2.5 top-1/2 -translate-y-1/2" /> <input value={searchQuery} onChange={(e) => setSearchQuery(e.target.value)} placeholder="Поиск узлов..." className="bg-bg-elevated border border-border-subtle rounded-lg pl-8 pr-3 py-1.5 text-xs text-white placeholder:text-text-muted focus:outline-none w-48" aria-label="Поиск узлов в графе" /> </div> <select value={filter} onChange={handleFilterChange} className="bg-bg-elevated border border-border-subtle rounded-md px-2.5 py-1.5 text-xs text-white focus:outline-none" aria-label="Фильтр по типу узла" > <option value="all">Все типы</option> <option value="decision">Решения</option> <option value="concept">Концепции</option> <option value="entity">Сущности</option> <option value="document">Документы</option> </select> <div className="ml-auto flex items-center gap-2"> <button onClick={handleZoomOut} disabled={zoom <= MIN_ZOOM} className={cn( 'p-1.5 rounded text-text-muted', zoom <= MIN_ZOOM ? 'opacity-30 cursor-not-allowed' : 'bg-white/5 hover:bg-white/10' )} aria-label="Уменьшить масштаб" > <ZoomOut className="w-3.5 h-3.5" /> </button> <span className="text-xs text-text-muted font-mono w-12 text-center"> {Math.round(zoom * 100)}% </span> <button onClick={handleZoomIn} disabled={zoom >= MAX_ZOOM} className={cn( 'p-1.5 rounded text-text-muted', zoom >= MAX_ZOOM ? 'opacity-30 cursor-not-allowed' : 'bg-white/5 hover:bg-white/10' )} aria-label="Увеличить масштаб" > <ZoomIn className="w-3.5 h-3.5" /> </button> </div> </div> {/* SVG Graph */} {nodes.length > 0 ? ( <svg className="w-full h-[520px] cursor-move" viewBox={`0 0 ${SVG_WIDTH} ${SVG_HEIGHT}`} style={{ transform: `scale(${zoom})`, transformOrigin: 'center', transition: 'transform 0.2s', }} role="img" aria-label="Граф знаний" > {/* Edges */} <g stroke="rgba(255,255,255,0.2)" strokeWidth="1"> {filteredEdges.map((edge, i) => { const s = nodesById.get(edge.source); const t = nodesById.get(edge.target); if (!s || !t) return null; return <line key={i} x1={s.x} y1={s.y} x2={t.x} y2={t.y} />; })} </g> {/* Nodes */} <g> {filteredNodes.map((node) => ( <NodeItem key={node.id} node={node} isSelected={selectedNodeId === node.id} onClick={() => handleSelectNode(node.id)} /> ))} </g> </svg> ) : ( <div className="h-[520px] flex items-center justify-center text-text-muted text-sm"> Нет данных для отображения графа </div> )} {/* Stats footer */} <div className="absolute bottom-4 left-4 right-4 flex items-center justify-between text-[10px] text-text-muted"> <span> {filteredNodes.length} узлов · {filteredEdges.length} связей </span> <span className="flex items-center gap-2"> {isStatsLoading && ( <> <Loader2 className="w-3 h-3 animate-spin" /> Загрузка статистики... </> )} {statsError && ( <> <AlertCircle className="w-3 h-3 text-red-400" /> <span className="text-red-400">{statsError}</span> </> )} {!isStatsLoading && !statsError && ( <> <span className="w-1.5 h-1.5 rounded-full bg-accent-emerald" /> {totalDocuments.toLocaleString()} документов · {totalChunks.toLocaleString()} чанков в базе </> )} </span> </div> </div> {/* ==================================================================== */} {/* Details panel */} {/* ==================================================================== */} <div className="bg-bg-card border border-border-subtle rounded-xl p-5 h-fit sticky top-4"> <div className="text-xs font-medium text-text-muted uppercase tracking-wider mb-3"> Детали узла </div> {selectedNode ? ( <div> <div className="w-12 h-12 rounded-xl flex items-center justify-center mb-3" style={{ background: `${selectedNode.color}30` }} > <div className="w-6 h-6 rounded-full" style={{ background: selectedNode.color }} /> </div> <h3 className="text-white font-semibold mb-1"> {selectedNode.label} </h3> <div className="text-xs text-text-muted mb-4"> {selectedNode.type === 'decision' && 'Решение'} {selectedNode.type === 'concept' && 'Концепция'} {selectedNode.type === 'entity' && 'Сущность'} {selectedNode.type === 'document' && 'Документ'} </div> <div className="space-y-2 text-xs pb-4 border-b border-border-subtle"> <div className="flex justify-between"> <span className="text-text-muted">Связей</span> <span className="text-white">{connections.length}</span> </div> <div className="flex justify-between"> <span className="text-text-muted">ID</span> <span className="text-white font-mono">{selectedNode.id}</span> </div> </div> {connectedNodes.length > 0 && ( <div className="mt-4"> <div className="text-[10px] uppercase tracking-wider text-text-muted mb-2"> Связанные узлы </div> <div className="space-y-1.5"> {connectedNodes.map((node) => ( <ConnectionButton key={node.id} node={node} onClick={() => handleSelectNode(node.id)} /> ))} </div> </div> )} </div> ) : ( <div className="text-center py-8 text-text-muted text-sm"> Кликните на узел </div> )} {/* Note about real graph */} <div className="mt-4 pt-4 border-t border-border-subtle"> <div className="text-[10px] text-text-muted flex items-start gap-2"> <Sparkles className="w-3 h-3 shrink-0 mt-0.5" /> <span> Граф знаний строится на основе NER-анализа документов. {nodes.length === 0 && ' Сейчас используется демонстрационный пример структуры.'} </span> </div> </div> </div> </div> ); }