/** * Этот файл содержит React компонент GraphPanelComponent, предназначенный для визуализации * графов диалогов. Он использует библиотеку React Flow для интерактивного отображения * узлов и рёбер, а также библиотеку Dagre для автоматического расположения элементов графа. * Включает кастомные типы узлов для различных сущностей графа. */ // ----------------------------------------------- React & React Flow Imports --------------------------------------------------- import * as React from 'react'; import * as ReactDOM from 'react-dom/client'; // <-- ИСПРАВЛЕНО: для createRoot нужен 'react-dom/client' import ReactFlow, { addEdge, useNodesState, useEdgesState, Background, Controls, ReactFlowProvider, Connection, Edge, BackgroundVariant } from 'reactflow'; import { App } from 'obsidian'; import { Dialog } from 'src/utils/Dialog'; // <-- ИСПРАВЛЕНО: Это ключевой момент. Убедитесь, что esbuild настроен на обработку CSS импортов. // Обычно, при использовании esbuild для React-проектов, вы импортируете CSS прямо в JS/TS файл. // esbuild должен быть настроен для копирования или встраивания этого CSS. // Если стили не применяются, проверьте конфигурацию esbuild (например, через плагин esbuild-plugin-css-bundle). import 'reactflow/dist/style.css'; // Импортируем стили ReactFlow // Если вы используете какие-либо кастомные ноды, импортируйте их здесь // import CustomNode from './CustomNode'; // const nodeTypes = { custom: CustomNode }; // ----------------------------------------------- Custom Nodes ----------------------------------------------------------------- import { Handle, Position } from 'reactflow'; /** * Интерфейс, описывающий структуру данных, передаваемых в BaseNodeWrapper. */ interface BaseNodeWrapperData { label: string; title?: string; title_generated?: boolean; isSelected: boolean; nodeId: string; onNodeDelete: (nodeId: string) => Promise; app: App; } /** * Интерфейс, описывающий свойства компонента BaseNodeWrapper. */ interface BaseNodeWrapperProps { data: BaseNodeWrapperData; isConnectable: boolean; // children: React.ReactNode; // Содержимое, специфичное для типа узла (например, метка) } /** * BaseNodeWrapper - Общая обёртка для всех кастомных узлов, * предоставляющая хэндлы для соединений и функциональность удаления. * @param {BaseNodeWrapperProps} props - Свойства компонента. * @returns {React.ReactElement} Элемент React. */ const BaseNodeWrapper: React.FC = ({ data, isConnectable, children }) => { // ----------------------------------------------- Styles -------------------------------------------------------------------- // Стили для кнопки удаления. Вынесены для переиспользования. const deleteButtonStyle: React.CSSProperties = { position: 'absolute', bottom: '-30px', // Размещаем под узлом left: '5px', background: 'transparent', border: 'none', cursor: 'pointer', fontSize: '1em', color: 'var(--text-muted)', // Используем цвет текста Obsidian padding: '0 5px', opacity: data.isSelected ? 1 : 0, // Показываем только если узел выделен transition: 'opacity 0.2s ease-in-out', }; // ----------------------------------------------- Event Handlers ------------------------------------------------------------ /** * Обработчик удаления узла. * @param {React.MouseEvent} event - Событие клика. */ const handleNodeDelete = async (event: React.MouseEvent) => { event.stopPropagation(); // Предотвращаем срабатывание onNodeClick на родительском узле const confirmed = await Dialog.confirm( data.app, 'Вы уверены, что хотите удалить этот узел?' ); if (confirmed) { await data.onNodeDelete(data.nodeId); } }; // ----------------------------------------------- Render -------------------------------------------------------------------- return React.createElement( React.Fragment, null, React.createElement(Handle, { type: 'source', position: Position.Bottom, isConnectable: isConnectable }), React.createElement(Handle, { type: 'target', position: Position.Top, isConnectable: isConnectable }), children, // Здесь будет отображаться специфическое содержимое узла data.isSelected && React.createElement('button', { style: deleteButtonStyle, onClick: handleNodeDelete, 'aria-label': 'Удалить узел', className: 'node-delete-button' // Добавляем класс для стилизации }, '🗑️') // Можно использовать SVG иконку ); }; // Обновляем CustomNode, чтобы включить кнопку удаления // Простой компонент для узла типа 'user' const UserNode: React.FC = ({ data, isConnectable }) => { const displayLabel = data.title || data.label || 'Пользователь'; // ... (titleGenerated больше не нужен в каждом узле, если не используется для рендеринга) return React.createElement( BaseNodeWrapper, { data: data, isConnectable: isConnectable }, React.createElement('div', null, displayLabel) ); }; // Обновите другие типы узлов (LLMNode, ToolNode, ErrorNode) аналогично const LLMNode: React.FC = ({ data, isConnectable }) => { const displayLabel = data.title || data.label || 'LLM'; // ... return React.createElement( BaseNodeWrapper, { data: data, isConnectable: isConnectable }, React.createElement('div', null, displayLabel) ); }; // Простой компонент для узла типа 'tool' const ToolNode: React.FC = ({ data, isConnectable }) => { const displayLabel = data.title || data.label || 'Инструмент'; // ... return React.createElement( BaseNodeWrapper, { data: data, isConnectable: isConnectable }, React.createElement('div', null, displayLabel) ); }; // Простой компонент для узла типа 'error' const ErrorNode: React.FC = ({ data, isConnectable }) => { const displayLabel = data.title || data.label || 'Ошибка'; // ... return React.createElement( BaseNodeWrapper, { data: data, isConnectable: isConnectable }, React.createElement('div', null, displayLabel) ); }; // Регистрируем кастомные типы узлов const nodeTypes = { user: UserNode, llm: LLMNode, tool: ToolNode, error: ErrorNode // Если у тебя есть node.type === 'default', то можешь его тоже определить, // либо использовать встроенный 'default' React Flow. }; // ----------------------------------------------- Dagre Layout ----------------------------------------------------------------- import dagre from 'dagre'; // Импортируем библиотеку dagre // Типы для Dagre (были уже корректны) interface DagreNode { x: number; y: number; width: number; height: number; } // Инициализируем объект Dagre Graph const dagreGraph = new dagre.graphlib.Graph().setDefaultEdgeLabel(() => ({})); // Определяем размеры узлов для корректного расположения. // Может потребоваться корректировка в зависимости от реального размера узла в CSS. const nodeWidth = 120; const nodeHeight = 40; /** * Применяет алгоритм расположения Dagre к узлам и рёбрам графа. * @param {Array} nodes - Массив узлов React Flow. * @param {Array} edges - Массив рёбер React Flow. * @param {'TB' | 'LR'} [direction='TB'] - Направление графа ('TB' - сверху вниз, 'LR' - слева направо). * @returns {{nodes: any[], edges: any[]}} Объект с обновлёнными узлами и рёбрами, содержащими позиции. */ const getLayoutedElements = (nodes: any[], edges: any[], direction: 'TB' | 'LR' = 'TB') => { const isHorizontal = direction === 'LR'; // Компактные настройки для расположения в 2 ряда dagreGraph.setGraph({ rankdir: direction, ranksep: 40, // Уменьшенное расстояние между рангами (рядами) nodesep: 10, // Уменьшенное расстояние между узлами в одном ранге edgesep: 20, // Расстояние между рёбрами marginx: 20, // Отступы по X marginy: 20 // Отступы по Y }); // Добавляем узлы в граф Dagre с динамическими размерами nodes.forEach((node) => { // Пытаемся найти элемент узла в DOM для получения реальных размеров const nodeElement = document.querySelector(`[data-id="${node.id}"]`) as HTMLElement; const dimensions = getNodeDimensions(nodeElement); dagreGraph.setNode(node.id, { width: Math.min(dimensions.width + 10, 140), // Ограничиваем максимальную ширину height: Math.min(dimensions.height + 10, 50) // Ограничиваем максимальную высоту }); }); // Добавляем рёбра в граф Dagre edges.forEach((edge) => { // Dagre ожидает id, source, target dagreGraph.setEdge(edge.source, edge.target); }); // Выполняем расчёт расположения графа dagre.layout(dagreGraph); // Обновляем позиции узлов React Flow на основе расчётов Dagre const newNodes = nodes.map((node) => { const nodeWithPosition: DagreNode = dagreGraph.node(node.id); const nodeDimensions = dagreGraph.node(node.id); return { ...node, targetPosition: isHorizontal ? 'left' : 'top', sourcePosition: isHorizontal ? 'right' : 'bottom', position: { x: nodeWithPosition.x - nodeDimensions.width / 2, y: nodeWithPosition.y - nodeDimensions.height / 2 } }; }); return { nodes: newNodes, edges }; }; // Функция для получения реальных размеров узла из DOM const getNodeDimensions = (nodeElement: HTMLElement | null): { width: number; height: number } => { if (!nodeElement) { return { width: 120, height: 40 }; // Fallback размеры } const rect = nodeElement.getBoundingClientRect(); return { width: Math.max(Math.min(rect.width, 130), 100), // Ограничиваем ширину: мин 100px, макс 130px height: Math.max(Math.min(rect.height, 45), 30) // Ограничиваем высоту: мин 30px, макс 45px }; }; /** * @typedef {object} GraphPanelProps * @property {{ nodes: any[]; edges: any[]; }} graphData - Данные графа для отображения (узлы и ребра). * @property {(nodeId: string) => void} onNodeClick - Обработчик клика по узлу. * @property {(nodeId: string, isSelected: boolean) => void} onNodeSelectionChange - Обработчик изменения выделения узла. * @property {(nodeId: string) => Promise} onNodeDelete - Обработчик удаления узла. * @property {Set} selectedNodeIds - Набор ID выделенных узлов. */ interface GraphPanelProps { graphData: { nodes: any[]; edges: any[] }; onNodeClick: (nodeId: string) => void; onNodeSelectionChange: (nodeId: string, isSelected: boolean) => void; onNodeDelete: (nodeId: string) => Promise; selectedNodeIds: Set; app: App; } /** * GraphPanelComponent - React компонент для отображения графа диалогов с использованием React Flow и Dagre. * @param {GraphPanelProps} props - Свойства компонента. * @returns {React.ReactElement} Элемент React. */ export const GraphPanelComponent: React.FC = ({ graphData, onNodeClick, onNodeSelectionChange, onNodeDelete, selectedNodeIds, app }) => { // ----------------------------------------------- React Hooks --------------------------------------------------------------- // Инициализируем состояния для узлов и рёбер React Flow const [nodes, setNodes, onNodesChange] = useNodesState([]); const [edges, setEdges, onEdgesChange] = useEdgesState([]); // onEdgesState переименовано на onEdgesChange // Используем useEffect для применения Dagre layout при изменении данных графа React.useEffect(() => { // Проверяем, есть ли данные для построения графа if (graphData.nodes.length > 0 || graphData.edges.length > 0) { console.log('Applying Dagre layout with data:', graphData); const initialNodesForLayout = graphData.nodes.map((node) => ({ id: node.id, type: node.type || 'default', data: { label: node.data?.label || `Node ${node.id}`, title: node.data?.title, title_generated: node.data?.title_generated || false, // Передаем колбэк onNodeDelete в данные узла, чтобы он был доступен в CustomNode onNodeDelete: onNodeDelete, isSelected: selectedNodeIds.has(node.id), // Передаем состояние выделения // Добавляем graphId и nodeId для кнопки удаления graphId: (node.data && node.data.message && node.data.message.graph_id) ? node.data.message.graph_id : 'unknown', nodeId: node.id, app: app }, position: { x: 0, y: 0 }, className: selectedNodeIds.has(node.id) ? 'selected-node' : '', // Применяем класс для выделения })); const initialEdgesForLayout = graphData.edges.map((edge) => ({ id: edge.id, source: edge.source, target: edge.target, type: edge.type || 'default' })); // Сначала устанавливаем узлы без layout setNodes(initialNodesForLayout); setEdges(initialEdgesForLayout); // Применяем layout с задержкой, чтобы DOM успел отрендерить узлы setTimeout(() => { const { nodes: layoutedNodes, edges: layoutedEdges } = getLayoutedElements(initialNodesForLayout, initialEdgesForLayout); setNodes(layoutedNodes); setEdges(layoutedEdges); }, 100); // Задержка 100ms для рендеринга DOM } else { // Если данных нет, очищаем граф setNodes([]); setEdges([]); } }, [graphData.nodes, graphData.edges, onNodeDelete, selectedNodeIds]); // Зависимости: изменения в узлах и рёбрах графа, колбэк удаления, выделенные узлы // ----------------------------------------------- Event Handlers ------------------------------------------------------------ /** * Обработчик добавления нового соединения между узлами (если разрешено). * @param {Connection | Edge} params - Параметры нового соединения. */ const onConnect = React.useCallback((params: Connection | Edge) => setEdges((eds) => addEdge(params, eds)), [setEdges]); /** * Обработчик клика по узлу. * @param {React.MouseEvent} event - Объект события клика. * @param {any} node - Данные узла, по которому был клик. */ const nodeClickHandler = React.useCallback( (event: React.MouseEvent, node: any) => { if (event.shiftKey) { // Если зажат Shift, переключаем выделение onNodeSelectionChange(node.id, !selectedNodeIds.has(node.id)); } else { // Иначе, это обычный клик по узлу onNodeClick(node.id); } }, [onNodeClick, onNodeSelectionChange, selectedNodeIds] ); // Добавляем обработчик для ReactFlow, чтобы отслеживать клики по пустому пространству const onPaneClick = React.useCallback((event: React.MouseEvent) => { // Проверяем, был ли клик по пустому пространству (не по узлу или ребру) const target = event.target as HTMLElement; if (!target.closest('.react-flow__node') && !target.closest('.react-flow__edge')) { // Если клик был не по узлу и не по ребру, сбрасываем выделение if (selectedNodeIds.size > 0) { // Вызываем колбэк для сброса всех выделений selectedNodeIds.forEach(nodeId => onNodeSelectionChange(nodeId, false)); } } }, [selectedNodeIds, onNodeSelectionChange]); // Обработчик для клавиатуры (Escape) React.useEffect(() => { const handleKeyDown = (event: KeyboardEvent) => { if (event.key === 'Escape') { if (selectedNodeIds.size > 0) { selectedNodeIds.forEach(nodeId => onNodeSelectionChange(nodeId, false)); } } }; document.addEventListener('keydown', handleKeyDown); return () => { document.removeEventListener('keydown', handleKeyDown); }; }, [selectedNodeIds, onNodeSelectionChange]); // ----------------------------------------------- Render -------------------------------------------------------------------- return React.createElement( 'div', { className: 'graph-panel', style: { width: '100%', height: '100%' } }, // Оборачиваем ReactFlowProvider вокруг ReactFlow для доступа к контексту React Flow React.createElement( ReactFlowProvider, null, React.createElement( ReactFlow, { nodes, edges, onNodesChange, // Обработчик изменения узлов onEdgesChange, // Обработчик изменения рёбер onConnect, // Обработчик подключения onNodeClick: nodeClickHandler, // Обработчик клика по узлу onPaneClick: onPaneClick, // Обработчик клика по панели fitView: true, // Автоматически центрирует и масштабирует граф при загрузке attributionPosition: 'bottom-right', // Позиция атрибуции React Flow nodeTypes, // Дополнительные параметры для лучшего отображения zoomOnScroll: true, panOnDrag: true, minZoom: 0.1, maxZoom: 2 }, // Компоненты Controls и Background являются дочерними для ReactFlow React.createElement(Controls, null), React.createElement(Background, { variant: BackgroundVariant.Dots, gap: 12, size: 1 }) ) ) ); };