llm-agent-plugin/src/components/GraphPanel.ts
2025-10-05 00:41:46 +03:00

439 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* Этот файл содержит 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 })
)
)
);
};