439 lines
20 KiB
TypeScript
439 lines
20 KiB
TypeScript
/**
|
||
* Этот файл содержит 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<void>;
|
||
app: App;
|
||
}
|
||
|
||
/**
|
||
* Интерфейс, описывающий свойства компонента BaseNodeWrapper.
|
||
*/
|
||
interface BaseNodeWrapperProps {
|
||
data: BaseNodeWrapperData;
|
||
isConnectable: boolean;
|
||
// children: React.ReactNode; // Содержимое, специфичное для типа узла (например, метка)
|
||
}
|
||
|
||
/**
|
||
* BaseNodeWrapper - Общая обёртка для всех кастомных узлов,
|
||
* предоставляющая хэндлы для соединений и функциональность удаления.
|
||
* @param {BaseNodeWrapperProps} props - Свойства компонента.
|
||
* @returns {React.ReactElement} Элемент React.
|
||
*/
|
||
const BaseNodeWrapper: React.FC<BaseNodeWrapperProps> = ({ 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<any> = ({ 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<any> = ({ 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<any> = ({ 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<any> = ({ 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<any>} nodes - Массив узлов React Flow.
|
||
* @param {Array<any>} 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<void>} onNodeDelete - Обработчик удаления узла.
|
||
* @property {Set<string>} selectedNodeIds - Набор ID выделенных узлов.
|
||
*/
|
||
interface GraphPanelProps {
|
||
graphData: { nodes: any[]; edges: any[] };
|
||
onNodeClick: (nodeId: string) => void;
|
||
onNodeSelectionChange: (nodeId: string, isSelected: boolean) => void;
|
||
onNodeDelete: (nodeId: string) => Promise<void>;
|
||
selectedNodeIds: Set<string>;
|
||
app: App;
|
||
}
|
||
|
||
/**
|
||
* GraphPanelComponent - React компонент для отображения графа диалогов с использованием React Flow и Dagre.
|
||
* @param {GraphPanelProps} props - Свойства компонента.
|
||
* @returns {React.ReactElement} Элемент React.
|
||
*/
|
||
export const GraphPanelComponent: React.FC<GraphPanelProps> = ({ 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 })
|
||
)
|
||
)
|
||
);
|
||
};
|