handle race condition while making simultaneous changes

This commit is contained in:
dimitrievgs 2025-09-16 00:10:35 +03:00
parent 2613776c4c
commit 2bf5763eb1
5 changed files with 272 additions and 275 deletions

84
main.js

File diff suppressed because one or more lines are too long

View File

@ -9,15 +9,15 @@
import * as React from 'react';
import * as ReactDOM from 'react-dom/client'; // <-- ИСПРАВЛЕНО: для createRoot нужен 'react-dom/client'
import ReactFlow, {
addEdge,
useNodesState,
useEdgesState,
Background,
Controls,
ReactFlowProvider, // <-- ИСПРАВЛЕНО: импортируем прямо ReactFlowProvider без переименования
Connection,
Edge,
BackgroundVariant // <-- ИСПРАВЛЕНО: используем BackgroundVariant для типизации
addEdge,
useNodesState,
useEdgesState,
Background,
Controls,
ReactFlowProvider, // <-- ИСПРАВЛЕНО: импортируем прямо ReactFlowProvider без переименования
Connection,
Edge,
BackgroundVariant // <-- ИСПРАВЛЕНО: используем BackgroundVariant для типизации
} from 'reactflow';
// <-- ИСПРАВЛЕНО: Это ключевой момент. Убедитесь, что esbuild настроен на обработку CSS импортов.
@ -37,80 +37,88 @@ import { Handle, Position } from 'reactflow';
// Простой компонент для узла типа 'user'
// Это базовый пример. Ты можешь стилизовать его по своему усмотрению.
const UserNode: React.FC<any> = ({ data, isConnectable }) => {
return React.createElement(React.Fragment, null, // Используем React.Fragment для возврата нескольких элементов без лишнего div
React.createElement(Handle, {
type: "source",
position: Position.Bottom,
isConnectable: isConnectable,
}),
React.createElement(Handle, {
type: "target",
position: Position.Top,
isConnectable: isConnectable,
}),
data.label
);
return React.createElement(
React.Fragment,
null, // Используем React.Fragment для возврата нескольких элементов без лишнего div
React.createElement(Handle, {
type: 'source',
position: Position.Bottom,
isConnectable: isConnectable
}),
React.createElement(Handle, {
type: 'target',
position: Position.Top,
isConnectable: isConnectable
}),
data.label
);
};
// Простой компонент для узла типа 'llm'
const LLMNode: React.FC<any> = ({ data, isConnectable }) => {
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,
}),
data.label
);
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
}),
data.label
);
};
// Простой компонент для узла типа 'tool'
const ToolNode: React.FC<any> = ({ data, isConnectable }) => {
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,
}),
data.label
);
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
}),
data.label
);
};
// Простой компонент для узла типа 'error'
const ErrorNode: React.FC<any> = ({ data, isConnectable }) => {
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,
}),
data.label
);
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
}),
data.label
);
};
// Регистрируем кастомные типы узлов
const nodeTypes = {
user: UserNode,
llm: LLMNode,
tool: ToolNode,
error: ErrorNode,
// Если у тебя есть node.type === 'default', то можешь его тоже определить,
// либо использовать встроенный 'default' React Flow.
user: UserNode,
llm: LLMNode,
tool: ToolNode,
error: ErrorNode
// Если у тебя есть node.type === 'default', то можешь его тоже определить,
// либо использовать встроенный 'default' React Flow.
};
// ----------------------------------------------- Dagre Layout -----------------------------------------------------------------
@ -118,10 +126,10 @@ import dagre from 'dagre'; // Импортируем библиотеку dagre
// Типы для Dagre (были уже корректны)
interface DagreNode {
x: number;
y: number;
width: number;
height: number;
x: number;
y: number;
width: number;
height: number;
}
// Инициализируем объект Dagre Graph
@ -140,43 +148,43 @@ const nodeHeight = 36;
* @returns {{nodes: any[], edges: any[]}} Объект с обновлёнными узлами и рёбрами, содержащими позиции.
*/
const getLayoutedElements = (nodes: any[], edges: any[], direction: 'TB' | 'LR' = 'TB') => {
const isHorizontal = direction === 'LR'; // Проверяем, горизонтальное ли расположение
// ДОБАВЛЕНО: ranksep и nodesep для лучшего отступа между узлами
dagreGraph.setGraph({ rankdir: direction, ranksep: 100, nodesep: 50 });
const isHorizontal = direction === 'LR'; // Проверяем, горизонтальное ли расположение
// ДОБАВЛЕНО: ranksep и nodesep для лучшего отступа между узлами
dagreGraph.setGraph({ rankdir: direction, ranksep: 100, nodesep: 50 });
// Добавляем узлы в граф Dagre с их размерами
nodes.forEach((node) => {
// Убедимся, что узел имеет уникальный id и необходимые размеры для Dagre
dagreGraph.setNode(node.id, { width: nodeWidth, height: nodeHeight });
});
// Добавляем узлы в граф Dagre с их размерами
nodes.forEach((node) => {
// Убедимся, что узел имеет уникальный id и необходимые размеры для Dagre
dagreGraph.setNode(node.id, { width: nodeWidth, height: nodeHeight });
});
// Добавляем рёбра в граф Dagre
edges.forEach((edge) => {
// Dagre ожидает id, source, target
dagreGraph.setEdge(edge.source, edge.target);
});
// Добавляем рёбра в граф Dagre
edges.forEach((edge) => {
// Dagre ожидает id, source, target
dagreGraph.setEdge(edge.source, edge.target);
});
// Выполняем расчёт расположения графа
dagre.layout(dagreGraph);
// Выполняем расчёт расположения графа
dagre.layout(dagreGraph);
// Обновляем позиции узлов React Flow на основе расчётов Dagre
const newNodes = nodes.map((node) => {
const nodeWithPosition: DagreNode = dagreGraph.node(node.id); // Получаем данные узла из Dagre
// Смещаем позицию узла Dagre (якорь в центре) к верхнему левому углу,
// чтобы она соответствовала точке привязки узла React Flow (верхний левый угол).
return {
...node,
// Обновляем targetPosition и sourcePosition в зависимости от направления для корректного отображения ручек
targetPosition: isHorizontal ? 'left' : 'top',
sourcePosition: isHorizontal ? 'right' : 'bottom',
position: {
x: nodeWithPosition.x - nodeWidth / 2, // Корректировка X
y: nodeWithPosition.y - nodeHeight / 2, // Корректировка Y
},
};
});
// Обновляем позиции узлов React Flow на основе расчётов Dagre
const newNodes = nodes.map((node) => {
const nodeWithPosition: DagreNode = dagreGraph.node(node.id); // Получаем данные узла из Dagre
// Смещаем позицию узла Dagre (якорь в центре) к верхнему левому углу,
// чтобы она соответствовала точке привязки узла React Flow (верхний левый угол).
return {
...node,
// Обновляем targetPosition и sourcePosition в зависимости от направления для корректного отображения ручек
targetPosition: isHorizontal ? 'left' : 'top',
sourcePosition: isHorizontal ? 'right' : 'bottom',
position: {
x: nodeWithPosition.x - nodeWidth / 2, // Корректировка X
y: nodeWithPosition.y - nodeHeight / 2 // Корректировка Y
}
};
});
return { nodes: newNodes, edges }; // Возвращаем обновлённые узлы и рёбра
return { nodes: newNodes, edges }; // Возвращаем обновлённые узлы и рёбра
};
/**
@ -185,8 +193,8 @@ const getLayoutedElements = (nodes: any[], edges: any[], direction: 'TB' | 'LR'
* @property {(nodeId: string) => void} onNodeClick - Обработчик клика по узлу.
*/
interface GraphPanelProps {
graphData: { nodes: any[]; edges: any[]; };
onNodeClick: (nodeId: string) => void;
graphData: { nodes: any[]; edges: any[] };
onNodeClick: (nodeId: string) => void;
}
/**
@ -195,88 +203,96 @@ interface GraphPanelProps {
* @returns {React.ReactElement} Элемент React.
*/
export const GraphPanelComponent: React.FC<GraphPanelProps> = ({ graphData, onNodeClick }) => {
// ----------------------------------------------- React Hooks ---------------------------------------------------------------
// Инициализируем состояния для узлов и рёбер React Flow
const [nodes, setNodes, onNodesChange] = useNodesState([]);
const [edges, setEdges, onEdgesChange] = useEdgesState([]);
// ----------------------------------------------- React Hooks ---------------------------------------------------------------
// Инициализируем состояния для узлов и рёбер React Flow
const [nodes, setNodes, onNodesChange] = useNodesState([]);
const [edges, setEdges, onEdgesChange] = useEdgesState([]);
// Используем useEffect для применения Dagre layout при изменении данных графа
React.useEffect(() => {
// Проверяем, есть ли данные для построения графа
if (graphData.nodes.length > 0 || graphData.edges.length > 0) {
console.log("Applying Dagre layout with data:", graphData);
// Используем useEffect для применения Dagre layout при изменении данных графа
React.useEffect(() => {
// Проверяем, есть ли данные для построения графа
if (graphData.nodes.length > 0 || graphData.edges.length > 0) {
console.log('Applying Dagre layout with data:', graphData);
// Преобразуем входящие данные в формат, необходимый для Dagre и React Flow
// Убеждаемся, что каждый узел имеет данные.label для отображения
const initialNodesForLayout = graphData.nodes.map(node => ({
id: node.id,
// ДОБАВЛЕНО: используем node.type, если он есть, иначе 'default'
type: node.type || 'default',
data: { label: node.data?.label || `Node ${node.id}` },
position: { x: 0, y: 0 } // Временная позиция, будет перезаписана Dagre
}));
const initialEdgesForLayout = graphData.edges.map(edge => ({
id: edge.id,
source: edge.source,
target: edge.target,
// Для рёбер можно добавить тип 'default' или ваш кастомный
type: edge.type || 'default',
}));
// Преобразуем входящие данные в формат, необходимый для Dagre и React Flow
// Убеждаемся, что каждый узел имеет данные.label для отображения
const initialNodesForLayout = graphData.nodes.map((node) => ({
id: node.id,
// ДОБАВЛЕНО: используем node.type, если он есть, иначе 'default'
type: node.type || 'default',
data: { label: node.data?.label || `Node ${node.id}` },
position: { x: 0, y: 0 } // Временная позиция, будет перезаписана Dagre
}));
const initialEdgesForLayout = graphData.edges.map((edge) => ({
id: edge.id,
source: edge.source,
target: edge.target,
// Для рёбер можно добавить тип 'default' или ваш кастомный
type: edge.type || 'default'
}));
// Применяем Dagre layout
const { nodes: layoutedNodes, edges: layoutedEdges } = getLayoutedElements(
initialNodesForLayout,
initialEdgesForLayout
);
// Применяем Dagre layout
const { nodes: layoutedNodes, edges: layoutedEdges } = getLayoutedElements(initialNodesForLayout, initialEdgesForLayout);
// Обновляем состояния узлов и рёбер React Flow
setNodes(layoutedNodes);
setEdges(layoutedEdges);
} else {
// Если данных нет, очищаем граф
setNodes([]);
setEdges([]);
}
}, [graphData.nodes, graphData.edges]); // Зависимости: изменения в узлах и рёбрах графа
// Обновляем состояния узлов и рёбер React Flow
setNodes(layoutedNodes);
setEdges(layoutedEdges);
} else {
// Если данных нет, очищаем граф
setNodes([]);
setEdges([]);
}
}, [graphData.nodes, graphData.edges]); // Зависимости: изменения в узлах и рёбрах графа
// ----------------------------------------------- Event Handlers ------------------------------------------------------------
/**
* Обработчик добавления нового соединения между узлами (если разрешено).
* @param {Connection | Edge} params - Параметры нового соединения.
*/
const onConnect = React.useCallback(
(params: Connection | Edge) => setEdges((eds) => addEdge(params, eds)),
[setEdges]
);
// ----------------------------------------------- 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) => {
onNodeClick(node.id);
}, [onNodeClick]);
/**
* Обработчик клика по узлу.
* @param {React.MouseEvent} event - Объект события клика.
* @param {any} node - Данные узла, по которому был клик.
*/
const nodeClickHandler = React.useCallback(
(event: React.MouseEvent, node: any) => {
onNodeClick(node.id);
},
[onNodeClick]
);
// ----------------------------------------------- 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, // Обработчик клика по узлу
fitView: true, // Автоматически центрирует и масштабирует граф при загрузке
attributionPosition: 'bottom-right', // Позиция атрибуции React Flow
nodeTypes, // <-- ИСПРАВЛЕНО: Передаем зарегистрированные типы узлов
},
// Компоненты Controls и Background являются дочерними для ReactFlow
React.createElement(Controls, null),
React.createElement(Background, { variant: BackgroundVariant.Dots, gap: 12, size: 1 })
)
)
);
// ----------------------------------------------- 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, // Обработчик клика по узлу
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 })
)
)
);
};

View File

@ -9,7 +9,7 @@ export class HistoryPanel {
container: HTMLElement;
props: {
graphs: any[];
onGraphSelect: (graphId: string) => void;
onGraphSelect: (data: { graphId: string; currentNode: string | null }) => void;
eventBus: EventBus;
};
graphs: any[];
@ -23,7 +23,7 @@ export class HistoryPanel {
container: HTMLElement,
props: {
graphs: any[];
onGraphSelect: (graphId: string) => void;
onGraphSelect: (data: { graphId: string; currentNode: string | null }) => void;
eventBus: EventBus;
}
) {
@ -104,7 +104,7 @@ export class HistoryPanel {
if (!(e.target as HTMLElement).classList.contains('chat-item-options-button')) {
const graphId = (item as HTMLElement).dataset.graphId;
if (graphId && this.props.onGraphSelect) {
this.props.onGraphSelect(graphId);
this.props.onGraphSelect({ graphId: graphId, currentNode: null });
}
}
});

View File

@ -124,36 +124,32 @@ export class ChatView extends ItemView {
});
if (!response.ok) {
throw new Error(`HTTP ошибка! статус: ${response.status}`);
// Пытаемся прочитать ошибку из тела ответа, если это возможно
const errorData = await response.json().catch(() => ({ message: response.statusText }));
throw new Error(`HTTP ошибка при отправке сообщения! Статус: ${response.status} - ${errorData.message}`);
}
const result = await response.json();
// Обновляем graph_id и current_node_id всегда
this.selectedGraphId = result.graph_id;
const newCurrentNodeId = result.graph_visualization_data.current_node_id;
this.currentNode = newCurrentNodeId;
// Обновляем граф
this.plugin.eventBus.emit('graph-updated', {
graphData: {
nodes: result.graph_visualization_data.nodes || [],
edges: result.graph_visualization_data.edges || []
},
currentNode: newCurrentNodeId
});
// Сначала обновим GraphView, чтобы он запросил последнюю версию графа
// Это предотвратит состояние гонки, когда разные запросы возвращают частичные графы.
// Вместо того, чтобы отправлять частичные данные, мы запрашиваем полный граф у бэкенда.
this.plugin.eventBus.emit('graph-selected', { graphId: this.selectedGraphId, currentNode: this.currentNode });
// Загружаем сообщения
// Затем обновим ChatView, загрузив историю до нового current_node_id
if (newCurrentNodeId) {
await this.fetchMessagesFromRootToNode(newCurrentNodeId);
} else {
// ИСПРАВЛЕНО: Убедиться, что type передается, если это возможно
this.chatHistory = result.messages.map((msg: any) => ({
...msg,
type: msg.type || msg.role // Используем type, если есть, иначе role как fallback
})) || [];
if (this.chatPanel) {
this.chatPanel.updateHistory(this.chatHistory);
}
} else if (this.selectedGraphId) {
// Если newCurrentNodeId по какой-то причине не вернулся, но graph_id есть,
// пытаемся загрузить историю по последнему известному узлу или просто весь граф (чтобы отобразить пустую историю, если граф новый).
// В этом случае, если current_node_id отсутствует, historyPanel должна очиститься.
this.currentNode = null; // Сбрасываем, если no current_node_id
this.fetchMessagesFromRootToNode(''); // Передаем пустую строку или null, чтобы очистить историю
}
} catch (error) {

View File

@ -99,7 +99,7 @@ export class GraphView extends ItemView {
// Инициализируем панель истории
this.historyPanel = new HistoryPanel(this.historyContainer, {
graphs: this.graphsList,
onGraphSelect: this.handleGraphSelect.bind(this),
onGraphSelect: this.handleGraphSelected.bind(this),
eventBus: this.plugin.eventBus
});
@ -107,8 +107,8 @@ export class GraphView extends ItemView {
this.initializeGraphPanel();
// Подписываемся на события
this.plugin.eventBus.on('graph-updated', this.handleGraphUpdate.bind(this));
this.plugin.eventBus.on('new-chat', this.handleNewChat.bind(this));
this.plugin.eventBus.on('graph-selected', this.handleGraphSelected.bind(this));
// Загружаем список графов
await this.fetchGraphsList();
@ -130,8 +130,8 @@ export class GraphView extends ItemView {
this.reactRoot = null;
}
this.plugin.eventBus.off('graph-updated', this.handleGraphUpdate);
this.plugin.eventBus.off('new-chat', this.handleNewChat);
this.plugin.eventBus.off('graph-selected', this.handleGraphSelected); // Отписываемся и отсюда
}
// ----------------------------------------------- Graph Panel Integration --------------------------------------------------
@ -212,6 +212,14 @@ export class GraphView extends ItemView {
*/
async fetchGraphData(graphId: string): Promise<void> {
try {
if (!graphId) {
// Если graphId пуст, очищаем граф
this.graphData = { nodes: [], edges: [] };
this.currentNode = null;
this.renderGraphPanel();
return;
}
const response = await fetch(`${this.plugin.settings.apiBaseUrl}/graphs/${graphId}`);
if (!response.ok) {
const errorText = await response.text();
@ -246,17 +254,6 @@ export class GraphView extends ItemView {
this.plugin.eventBus.emit('node-selected', { nodeId });
}
/**
* Обработчик обновления данных графа (например, после нового сообщения).
* Обновляет `graphData` и перерендеривает граф.
* @param {{ graphData: { nodes: any[], edges: any[] }, currentNode: string | null }} data - Обновленные данные графа.
*/
handleGraphUpdate(data: { graphData: { nodes: any[]; edges: any[] }; currentNode: string | null }): void {
this.graphData = data.graphData;
this.currentNode = data.currentNode;
this.renderGraphPanel();
}
/**
* Обработчик начала нового чата.
* Очищает текущие данные графа и перерендеривает его.
@ -272,12 +269,14 @@ export class GraphView extends ItemView {
/**
* Обработчик выбора графа из панели истории.
* Устанавливает выбранный граф и загружает его данные.
* @param {string} graphId - ID выбранного графа.
* @param {{ graphId: string; currentNode: string | null }} data - Объект, содержащий ID выбранного графа и текущий узел.
* @returns {Promise<void>}
*/
async handleGraphSelect(graphId: string): Promise<void> {
this.selectedGraphId = graphId;
await this.fetchGraphData(graphId);
// Также сообщаем ChatView, что выбран новый граф и он должен обновить свою историю
this.plugin.eventBus.emit('graph-selected', { graphId, currentNode: this.currentNode });
async handleGraphSelected(data: { graphId: string; currentNode: string | null }): Promise<void> {
this.selectedGraphId = data.graphId;
// currentNode здесь может быть неактуальным, fetchGraphData загрузит актуальный current_node_id
await this.fetchGraphData(data.graphId);
// Note: Больше не нужно эмитить 'graph-selected' обратно в ChatView, т.к. ChatView уже инициировал это.
// ChatView должен сам обновить свою историю после вызова этого метода или после своего собственного fetch.
}
}