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

@ -37,16 +37,18 @@ import { Handle, Position } from 'reactflow';
// Простой компонент для узла типа 'user' // Простой компонент для узла типа 'user'
// Это базовый пример. Ты можешь стилизовать его по своему усмотрению. // Это базовый пример. Ты можешь стилизовать его по своему усмотрению.
const UserNode: React.FC<any> = ({ data, isConnectable }) => { const UserNode: React.FC<any> = ({ data, isConnectable }) => {
return React.createElement(React.Fragment, null, // Используем React.Fragment для возврата нескольких элементов без лишнего div return React.createElement(
React.Fragment,
null, // Используем React.Fragment для возврата нескольких элементов без лишнего div
React.createElement(Handle, { React.createElement(Handle, {
type: "source", type: 'source',
position: Position.Bottom, position: Position.Bottom,
isConnectable: isConnectable, isConnectable: isConnectable
}), }),
React.createElement(Handle, { React.createElement(Handle, {
type: "target", type: 'target',
position: Position.Top, position: Position.Top,
isConnectable: isConnectable, isConnectable: isConnectable
}), }),
data.label data.label
); );
@ -54,16 +56,18 @@ const UserNode: React.FC<any> = ({ data, isConnectable }) => {
// Простой компонент для узла типа 'llm' // Простой компонент для узла типа 'llm'
const LLMNode: React.FC<any> = ({ data, isConnectable }) => { const LLMNode: React.FC<any> = ({ data, isConnectable }) => {
return React.createElement(React.Fragment, null, return React.createElement(
React.Fragment,
null,
React.createElement(Handle, { React.createElement(Handle, {
type: "source", type: 'source',
position: Position.Bottom, position: Position.Bottom,
isConnectable: isConnectable, isConnectable: isConnectable
}), }),
React.createElement(Handle, { React.createElement(Handle, {
type: "target", type: 'target',
position: Position.Top, position: Position.Top,
isConnectable: isConnectable, isConnectable: isConnectable
}), }),
data.label data.label
); );
@ -71,16 +75,18 @@ const LLMNode: React.FC<any> = ({ data, isConnectable }) => {
// Простой компонент для узла типа 'tool' // Простой компонент для узла типа 'tool'
const ToolNode: React.FC<any> = ({ data, isConnectable }) => { const ToolNode: React.FC<any> = ({ data, isConnectable }) => {
return React.createElement(React.Fragment, null, return React.createElement(
React.Fragment,
null,
React.createElement(Handle, { React.createElement(Handle, {
type: "source", type: 'source',
position: Position.Bottom, position: Position.Bottom,
isConnectable: isConnectable, isConnectable: isConnectable
}), }),
React.createElement(Handle, { React.createElement(Handle, {
type: "target", type: 'target',
position: Position.Top, position: Position.Top,
isConnectable: isConnectable, isConnectable: isConnectable
}), }),
data.label data.label
); );
@ -88,16 +94,18 @@ const ToolNode: React.FC<any> = ({ data, isConnectable }) => {
// Простой компонент для узла типа 'error' // Простой компонент для узла типа 'error'
const ErrorNode: React.FC<any> = ({ data, isConnectable }) => { const ErrorNode: React.FC<any> = ({ data, isConnectable }) => {
return React.createElement(React.Fragment, null, return React.createElement(
React.Fragment,
null,
React.createElement(Handle, { React.createElement(Handle, {
type: "source", type: 'source',
position: Position.Bottom, position: Position.Bottom,
isConnectable: isConnectable, isConnectable: isConnectable
}), }),
React.createElement(Handle, { React.createElement(Handle, {
type: "target", type: 'target',
position: Position.Top, position: Position.Top,
isConnectable: isConnectable, isConnectable: isConnectable
}), }),
data.label data.label
); );
@ -108,7 +116,7 @@ const nodeTypes = {
user: UserNode, user: UserNode,
llm: LLMNode, llm: LLMNode,
tool: ToolNode, tool: ToolNode,
error: ErrorNode, error: ErrorNode
// Если у тебя есть node.type === 'default', то можешь его тоже определить, // Если у тебя есть node.type === 'default', то можешь его тоже определить,
// либо использовать встроенный 'default' React Flow. // либо использовать встроенный 'default' React Flow.
}; };
@ -171,8 +179,8 @@ const getLayoutedElements = (nodes: any[], edges: any[], direction: 'TB' | 'LR'
sourcePosition: isHorizontal ? 'right' : 'bottom', sourcePosition: isHorizontal ? 'right' : 'bottom',
position: { position: {
x: nodeWithPosition.x - nodeWidth / 2, // Корректировка X x: nodeWithPosition.x - nodeWidth / 2, // Корректировка X
y: nodeWithPosition.y - nodeHeight / 2, // Корректировка Y y: nodeWithPosition.y - nodeHeight / 2 // Корректировка Y
}, }
}; };
}); });
@ -185,7 +193,7 @@ const getLayoutedElements = (nodes: any[], edges: any[], direction: 'TB' | 'LR'
* @property {(nodeId: string) => void} onNodeClick - Обработчик клика по узлу. * @property {(nodeId: string) => void} onNodeClick - Обработчик клика по узлу.
*/ */
interface GraphPanelProps { interface GraphPanelProps {
graphData: { nodes: any[]; edges: any[]; }; graphData: { nodes: any[]; edges: any[] };
onNodeClick: (nodeId: string) => void; onNodeClick: (nodeId: string) => void;
} }
@ -204,30 +212,27 @@ export const GraphPanelComponent: React.FC<GraphPanelProps> = ({ graphData, onNo
React.useEffect(() => { React.useEffect(() => {
// Проверяем, есть ли данные для построения графа // Проверяем, есть ли данные для построения графа
if (graphData.nodes.length > 0 || graphData.edges.length > 0) { if (graphData.nodes.length > 0 || graphData.edges.length > 0) {
console.log("Applying Dagre layout with data:", graphData); console.log('Applying Dagre layout with data:', graphData);
// Преобразуем входящие данные в формат, необходимый для Dagre и React Flow // Преобразуем входящие данные в формат, необходимый для Dagre и React Flow
// Убеждаемся, что каждый узел имеет данные.label для отображения // Убеждаемся, что каждый узел имеет данные.label для отображения
const initialNodesForLayout = graphData.nodes.map(node => ({ const initialNodesForLayout = graphData.nodes.map((node) => ({
id: node.id, id: node.id,
// ДОБАВЛЕНО: используем node.type, если он есть, иначе 'default' // ДОБАВЛЕНО: используем node.type, если он есть, иначе 'default'
type: node.type || 'default', type: node.type || 'default',
data: { label: node.data?.label || `Node ${node.id}` }, data: { label: node.data?.label || `Node ${node.id}` },
position: { x: 0, y: 0 } // Временная позиция, будет перезаписана Dagre position: { x: 0, y: 0 } // Временная позиция, будет перезаписана Dagre
})); }));
const initialEdgesForLayout = graphData.edges.map(edge => ({ const initialEdgesForLayout = graphData.edges.map((edge) => ({
id: edge.id, id: edge.id,
source: edge.source, source: edge.source,
target: edge.target, target: edge.target,
// Для рёбер можно добавить тип 'default' или ваш кастомный // Для рёбер можно добавить тип 'default' или ваш кастомный
type: edge.type || 'default', type: edge.type || 'default'
})); }));
// Применяем Dagre layout // Применяем Dagre layout
const { nodes: layoutedNodes, edges: layoutedEdges } = getLayoutedElements( const { nodes: layoutedNodes, edges: layoutedEdges } = getLayoutedElements(initialNodesForLayout, initialEdgesForLayout);
initialNodesForLayout,
initialEdgesForLayout
);
// Обновляем состояния узлов и рёбер React Flow // Обновляем состояния узлов и рёбер React Flow
setNodes(layoutedNodes); setNodes(layoutedNodes);
@ -244,25 +249,31 @@ export const GraphPanelComponent: React.FC<GraphPanelProps> = ({ graphData, onNo
* Обработчик добавления нового соединения между узлами (если разрешено). * Обработчик добавления нового соединения между узлами (если разрешено).
* @param {Connection | Edge} params - Параметры нового соединения. * @param {Connection | Edge} params - Параметры нового соединения.
*/ */
const onConnect = React.useCallback( const onConnect = React.useCallback((params: Connection | Edge) => setEdges((eds) => addEdge(params, eds)), [setEdges]);
(params: Connection | Edge) => setEdges((eds) => addEdge(params, eds)),
[setEdges]
);
/** /**
* Обработчик клика по узлу. * Обработчик клика по узлу.
* @param {React.MouseEvent} event - Объект события клика. * @param {React.MouseEvent} event - Объект события клика.
* @param {any} node - Данные узла, по которому был клик. * @param {any} node - Данные узла, по которому был клик.
*/ */
const nodeClickHandler = React.useCallback((event: React.MouseEvent, node: any) => { const nodeClickHandler = React.useCallback(
(event: React.MouseEvent, node: any) => {
onNodeClick(node.id); onNodeClick(node.id);
}, [onNodeClick]); },
[onNodeClick]
);
// ----------------------------------------------- Render -------------------------------------------------------------------- // ----------------------------------------------- Render --------------------------------------------------------------------
return React.createElement('div', { className: 'graph-panel', style: { width: '100%', height: '100%' } }, return React.createElement(
'div',
{ className: 'graph-panel', style: { width: '100%', height: '100%' } },
// Оборачиваем ReactFlowProvider вокруг ReactFlow для доступа к контексту React Flow // Оборачиваем ReactFlowProvider вокруг ReactFlow для доступа к контексту React Flow
React.createElement(ReactFlowProvider, null, React.createElement(
React.createElement(ReactFlow, { ReactFlowProvider,
null,
React.createElement(
ReactFlow,
{
nodes, nodes,
edges, edges,
onNodesChange, // Обработчик изменения узлов onNodesChange, // Обработчик изменения узлов
@ -271,7 +282,12 @@ export const GraphPanelComponent: React.FC<GraphPanelProps> = ({ graphData, onNo
onNodeClick: nodeClickHandler, // Обработчик клика по узлу onNodeClick: nodeClickHandler, // Обработчик клика по узлу
fitView: true, // Автоматически центрирует и масштабирует граф при загрузке fitView: true, // Автоматически центрирует и масштабирует граф при загрузке
attributionPosition: 'bottom-right', // Позиция атрибуции React Flow attributionPosition: 'bottom-right', // Позиция атрибуции React Flow
nodeTypes, // <-- ИСПРАВЛЕНО: Передаем зарегистрированные типы узлов nodeTypes,
// Дополнительные параметры для лучшего отображения
zoomOnScroll: true,
panOnDrag: true,
minZoom: 0.1,
maxZoom: 2
}, },
// Компоненты Controls и Background являются дочерними для ReactFlow // Компоненты Controls и Background являются дочерними для ReactFlow
React.createElement(Controls, null), React.createElement(Controls, null),

View File

@ -9,7 +9,7 @@ export class HistoryPanel {
container: HTMLElement; container: HTMLElement;
props: { props: {
graphs: any[]; graphs: any[];
onGraphSelect: (graphId: string) => void; onGraphSelect: (data: { graphId: string; currentNode: string | null }) => void;
eventBus: EventBus; eventBus: EventBus;
}; };
graphs: any[]; graphs: any[];
@ -23,7 +23,7 @@ export class HistoryPanel {
container: HTMLElement, container: HTMLElement,
props: { props: {
graphs: any[]; graphs: any[];
onGraphSelect: (graphId: string) => void; onGraphSelect: (data: { graphId: string; currentNode: string | null }) => void;
eventBus: EventBus; eventBus: EventBus;
} }
) { ) {
@ -104,7 +104,7 @@ export class HistoryPanel {
if (!(e.target as HTMLElement).classList.contains('chat-item-options-button')) { if (!(e.target as HTMLElement).classList.contains('chat-item-options-button')) {
const graphId = (item as HTMLElement).dataset.graphId; const graphId = (item as HTMLElement).dataset.graphId;
if (graphId && this.props.onGraphSelect) { 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) { 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(); const result = await response.json();
// Обновляем graph_id и current_node_id всегда
this.selectedGraphId = result.graph_id; this.selectedGraphId = result.graph_id;
const newCurrentNodeId = result.graph_visualization_data.current_node_id; const newCurrentNodeId = result.graph_visualization_data.current_node_id;
this.currentNode = newCurrentNodeId; this.currentNode = newCurrentNodeId;
// Обновляем граф // Сначала обновим GraphView, чтобы он запросил последнюю версию графа
this.plugin.eventBus.emit('graph-updated', { // Это предотвратит состояние гонки, когда разные запросы возвращают частичные графы.
graphData: { // Вместо того, чтобы отправлять частичные данные, мы запрашиваем полный граф у бэкенда.
nodes: result.graph_visualization_data.nodes || [], this.plugin.eventBus.emit('graph-selected', { graphId: this.selectedGraphId, currentNode: this.currentNode });
edges: result.graph_visualization_data.edges || []
},
currentNode: newCurrentNodeId
});
// Загружаем сообщения // Затем обновим ChatView, загрузив историю до нового current_node_id
if (newCurrentNodeId) { if (newCurrentNodeId) {
await this.fetchMessagesFromRootToNode(newCurrentNodeId); await this.fetchMessagesFromRootToNode(newCurrentNodeId);
} else { } else if (this.selectedGraphId) {
// ИСПРАВЛЕНО: Убедиться, что type передается, если это возможно // Если newCurrentNodeId по какой-то причине не вернулся, но graph_id есть,
this.chatHistory = result.messages.map((msg: any) => ({ // пытаемся загрузить историю по последнему известному узлу или просто весь граф (чтобы отобразить пустую историю, если граф новый).
...msg, // В этом случае, если current_node_id отсутствует, historyPanel должна очиститься.
type: msg.type || msg.role // Используем type, если есть, иначе role как fallback this.currentNode = null; // Сбрасываем, если no current_node_id
})) || []; this.fetchMessagesFromRootToNode(''); // Передаем пустую строку или null, чтобы очистить историю
if (this.chatPanel) {
this.chatPanel.updateHistory(this.chatHistory);
}
} }
} catch (error) { } catch (error) {

View File

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