/** * Отображает и управляет интерактивным графом LLM агента. * Интегрирует историю чатов и визуализацию графа на основе React Flow. */ // ----------------------------------------------- Obsidian Imports ---------------------------------------------------------- import { ItemView, WorkspaceLeaf, Notice } from 'obsidian'; // ----------------------------------------------- Plugin Imports ------------------------------------------------------------ import LLMAgentPlugin from 'main'; // Предполагаем, что main.ts экспортирует LLMAgentPlugin по умолчанию import * as React from 'react'; import * as ReactDOM from 'react-dom/client'; import { HistoryPanel } from '../components/HistoryPanel'; // Предполагаем, что HistoryPanel это JS-класс import { GraphPanelComponent } from '../components/GraphPanel'; // Импортируем React-компонент и ReactDOM import { Dialog } from 'src/utils/Dialog'; import { GraphSettingsModal } from '../modals/GraphSettingsModal'; import { VoicePanel } from '../components/VoicePanel'; export const GRAPH_VIEW_TYPE = 'llm-agent-graph-view'; // ----------------------------------------------- Graph View --------------------------------------------------------------- export class GraphView extends ItemView { plugin: LLMAgentPlugin; graphData: { nodes: any[]; edges: any[] }; currentNode: string | null; graphsList: any[]; selectedGraphId: string | null; selectedNodeIds: Set; // Добавляем Set для хранения ID выделенных узлов selectedGraphTitle: string | null; currentGraphCustomSystemPrompt: string | null; // UI Elements historyPanel: HistoryPanel | null; historyContainer: HTMLDivElement; graphContainer: HTMLDivElement; reactRoot: ReactDOM.Root | null; toggleButton: HTMLButtonElement; isHistoryCollapsed: boolean; // Глобальные кнопки действий copySelectedButton: HTMLButtonElement | null = null; deleteSelectedButton: HTMLButtonElement | null = null; newIndependentChatActionButton: HTMLButtonElement | null = null; graphSettingsButton: HTMLButtonElement | null = null; activeStreamControllers: Map; constructor(leaf: WorkspaceLeaf, plugin: LLMAgentPlugin) { super(leaf); this.plugin = plugin; this.graphData = { nodes: [], edges: [] }; this.currentNode = null; this.graphsList = []; this.selectedGraphId = null; this.historyPanel = null; this.reactRoot = null; this.isHistoryCollapsed = true; this.selectedNodeIds = new Set(); this.selectedGraphTitle = null; this.currentGraphCustomSystemPrompt = null; this.activeStreamControllers = new Map(); } /** * @returns {string} Тип представления. */ getViewType(): string { return GRAPH_VIEW_TYPE; } /** * @returns {string} Отображаемое имя представления. */ getDisplayText(): string { return this.selectedGraphTitle || 'LLM Agent Graph'; } /** * @returns {string} Иконка представления. */ getIcon(): string { return 'brain-circuit'; } /** * Вызывается при открытии представления. Инициализирует UI и загружает данные. * @returns {Promise} */ async onOpen(): Promise { const container = this.containerEl.children[1] as HTMLElement; container.empty(); const mainContainer = container.createDiv('llm-agent-graph-main'); mainContainer.style.cssText = ` display: flex; flex-direction: column; height: 100%; width: 100%; overflow: hidden; `; // 1. Верхняя часть const upperContent = mainContainer.createDiv('upper-content-wrapper'); upperContent.style.cssText = ` display: flex; flex-direction: row; flex: 1; width: 100%; min-height: 0; `; this.historyContainer = upperContent.createDiv('llm-agent-history-sidebar'); this.historyContainer.classList.add(this.isHistoryCollapsed ? 'collapsed' : 'expanded'); this.graphContainer = upperContent.createDiv('llm-agent-graph-container'); this.graphContainer.style.cssText = `flex: 1; position: relative; background-color: var(--background-primary);`; // 2. РАЗДЕЛИТЕЛЬ (Resizer) const resizer = mainContainer.createDiv('voice-panel-resizer'); resizer.style.cssText = ` height: 4px; width: 100%; cursor: ns-resize; background-color: var(--divider-color); flex-shrink: 0; transition: background-color 0.2s; `; // 3. Нижняя панель const voiceContainer = mainContainer.createDiv('voice-panel-fixed-bottom'); let initialHeight = 150; // Высота по умолчанию voiceContainer.style.cssText = ` height: ${initialHeight}px; width: 100%; background-color: var(--background-secondary); overflow: hidden; flex-shrink: 0; `; // ЛОГИКА ИЗМЕНЕНИЯ ВЫСОТЫ let isResizing = false; resizer.addEventListener('mousedown', (e) => { isResizing = true; document.body.style.cursor = 'ns-resize'; // Чтобы iframe/график не перехватывали события мыши mainContainer.style.pointerEvents = 'none'; }); document.addEventListener('mousemove', (e) => { if (!isResizing) return; // Вычисляем расстояние от верха контейнера const containerRect = mainContainer.getBoundingClientRect(); const newHeight = containerRect.bottom - e.clientY; // Ограничения (мин 50px, макс 70% высоты) if (newHeight > 50 && newHeight < containerRect.height * 0.7) { voiceContainer.style.height = `${newHeight}px`; } }); document.addEventListener('mouseup', () => { if (isResizing) { isResizing = false; document.body.style.cursor = 'default'; mainContainer.style.pointerEvents = 'all'; } }); // Инициализация остальных компонентов this.historyPanel = new HistoryPanel( this.historyContainer, { graphs: this.graphsList, eventBus: this.plugin.eventBus }, this.plugin ); this.addToggleButton(); this.addGlobalActionButtons(); this.initializeGraphPanel(); // Инициализируем VoicePanel new VoicePanel(voiceContainer, this.plugin); // Подписки на события this.setupEventListeners(); // Загружаем данные await this.fetchGraphsList(); } // Вынес для чистоты private setupEventListeners() { this.plugin.eventBus.on('new-chat', this.handleNewChat.bind(this)); this.plugin.eventBus.on('graph-selected', this.handleGraphSelected.bind(this)); this.plugin.eventBus.on('node-selected', this.handleNodeSelectedFromBus); // слушаем выбор узла извне this.plugin.eventBus.on('delete-node', this.handleDeleteNodeFromChat.bind(this)); this.plugin.eventBus.on('ws-graph-title-updated', this.handleGraphTitleUpdated.bind(this)); this.plugin.eventBus.on('ws-node-title-updated', this.handleNodeTitleUpdated.bind(this)); this.plugin.eventBus.on('ws-node-type-changed', this.handleNodeTypeChanged.bind(this)); this.plugin.eventBus.on('stream-started', this.handleStreamStarted.bind(this)); this.plugin.eventBus.on('stream-ended', this.handleStreamEnded.bind(this)); } /** * Вызывается при закрытии представления. Очищает ресурсы. * @returns {Promise} */ async onClose(): Promise { if (this.historyPanel) { this.historyPanel.destroy(); this.historyPanel = null; } // Отмонтируем React-компонент if (this.reactRoot) { this.reactRoot.unmount(); this.reactRoot = null; } this.plugin.eventBus.off('new-chat', this.handleNewChat); this.plugin.eventBus.off('graph-selected', this.handleGraphSelected); this.plugin.eventBus.off('node-selected', this.handleNodeSelectedFromBus); this.plugin.eventBus.off('delete-node', this.handleDeleteNodeFromChat); // WebSocket Event Cleanup this.plugin.eventBus.off('ws-graph-title-updated', this.handleGraphTitleUpdated); this.plugin.eventBus.off('ws-node-title-updated', this.handleNodeTitleUpdated); this.plugin.eventBus.off('ws-node-type-changed', this.handleNodeTypeChanged); this.plugin.eventBus.off('stream-started', this.handleStreamStarted); this.plugin.eventBus.off('stream-ended', this.handleStreamEnded); } // ----------------------------------------------- Custom Title Update --------------------------------------------------- /** * Обновляет текстовое содержимое заголовка представления. * Этот метод должен быть вызван для динамического изменения заголовка * после первоначальной отрисовки представления. */ updateTitle(): void { const titleElement = this.containerEl.querySelector('.view-header-title'); if (titleElement) { titleElement.textContent = this.selectedGraphTitle || 'LLM Agent Graph'; } } // ----------------------------------------------- History Panel Toggle -------------------------------------------------- /** * Переключает состояние панели истории между свернутым и развернутым. */ toggleHistoryPanel(): void { this.isHistoryCollapsed = !this.isHistoryCollapsed; if (this.isHistoryCollapsed) { this.historyContainer.classList.remove('expanded'); this.historyContainer.classList.add('collapsed'); this.toggleButton.textContent = '📋'; } else { this.historyContainer.classList.remove('collapsed'); this.historyContainer.classList.add('expanded'); this.toggleButton.textContent = '❌'; } } // ----------------------------------------------- Graph Panel Integration -------------------------------------------------- addToggleButton(): void { const buttonWrapper = this.graphContainer.createDiv({ cls: 'graph-view-toolbar' }); // Создаем контейнер для кнопок this.toggleButton = buttonWrapper.createEl('button', { cls: 'global-action-button' }); // history-toggle-button this.toggleButton.textContent = this.isHistoryCollapsed ? '📋' : '❌'; this.toggleButton.addEventListener('click', this.toggleHistoryPanel.bind(this)); console.log('Toggle button created and appended:', this.toggleButton); } /** * Добавляет глобальные кнопки действий для выделенных узлов. */ addGlobalActionButtons(): void { const toolbar = this.graphContainer.querySelector('.graph-view-toolbar'); if (!toolbar) return; this.copySelectedButton = toolbar.createEl('button', { cls: 'global-action-button', attr: { 'aria-label': 'Копировать выделенные узлы', 'data-tooltip': 'Копировать выделенные узлы' }, text: '📄' }); this.copySelectedButton.addEventListener('click', this.handleCopySelectedNodes.bind(this)); this.deleteSelectedButton = toolbar.createEl('button', { cls: 'global-action-button', attr: { 'aria-label': 'Удалить выделенные узлы', 'data-tooltip': 'Удалить выделенные узлы' }, text: '🗑️' }); this.deleteSelectedButton.addEventListener('click', this.handleDeleteSelectedNodes.bind(this)); this.graphSettingsButton = toolbar.createEl('button', { cls: 'global-action-button', attr: { 'aria-label': 'Настройки графа', 'data-tooltip': 'Настроить системный промпт для текущего графа' }, text: '⚙️' // Иконка шестеренки }); this.graphSettingsButton.addEventListener('click', this.handleGraphSettingsClick.bind(this)); // Деактивируем кнопку по умолчанию, пока граф не выбран this.graphSettingsButton.setAttr('disabled', 'true'); this.newIndependentChatActionButton = toolbar.createEl('button', { cls: 'global-action-button', attr: { 'aria-label': 'Новый независимый чат', 'data-tooltip': 'Начать новую беседу, формируя отдельный, независимый подграф' }, text: '✨' // Или 'Новый чат' }); this.newIndependentChatActionButton.addEventListener('click', () => { // Эмитируем новое событие, которое будет слушать ТОЛЬКО ChatView this.plugin.eventBus.emit('start-new-independent-chat'); }); } // ----------------------------------------------- Graph Panel Integration -------------------------------------------------- /** * Обработчик клика по кнопке "Настройки графа". */ handleGraphSettingsClick(): void { if (!this.selectedGraphId || !this.selectedGraphTitle) { new Notice('Сначала выберите граф для настройки.', 3000); return; } new GraphSettingsModal(this.app, { app: this.app, graphId: this.selectedGraphId, graphTitle: this.selectedGraphTitle, initialCustomSystemPrompt: this.currentGraphCustomSystemPrompt, onSave: this.saveGraphCustomSystemPrompt.bind(this) }).open(); } /** * Сохраняет пользовательский системный промпт для текущего графа. * @param {string} graphId - ID графа. * @param {string | null} customSystemPrompt - Новый системный промпт или null для сброса. * @returns {Promise} */ async saveGraphCustomSystemPrompt(graphId: string, customSystemPrompt: string | null): Promise { if (!graphId) { new Notice('Граф не выбран для сохранения настроек.', 3000); return; } try { const response = await fetch(`${this.plugin.settings.apiBaseUrl}/graphs/${graphId}/settings`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ custom_system_prompt: customSystemPrompt }) }); if (!response.ok) { const errorText = await response.text(); throw new Error(`Ошибка сохранения системного промпта: ${response.status} - ${errorText}`); } new Notice('Системный промпт графа успешно обновлен.', 2000); // Обновляем данные графа, чтобы получить актуальный промпт и перерисовать UI await this.fetchGraphData(graphId); } catch (error) { console.error(`Ошибка при сохранении системного промпта для графа ${graphId}:`, error); new Notice(`Ошибка при сохранении системного промпта: ${error.message}`, 5000); } } /** * Инициализирует GraphPanelComponent (React компонент) в graphContainer. * Использует ReactDOM.createRoot для React 18. */ initializeGraphPanel(): void { this.graphContainer.empty(); // Перемещаем кнопки обратно после очистки, если они были частью graphContainer this.addToggleButton(); this.addGlobalActionButtons(); const wrapper = this.graphContainer.createDiv(); wrapper.style.cssText = 'width: 100%; height: 100%;'; if (!wrapper) { console.error('Не удалось создать wrapper для монтирования React Flow.'); return; } if (ReactDOM && typeof ReactDOM.createRoot === 'function') { this.reactRoot = ReactDOM.createRoot(wrapper); this.renderGraphPanel(); } else { console.error('ReactDOM.createRoot не определен. Убедитесь, что "react-dom/client" правильно импортирован.'); } } /** * Рендерит или обновляет GraphPanelComponent в DOM. */ renderGraphPanel(): void { if (this.reactRoot) { this.reactRoot.render( React.createElement(GraphPanelComponent, { graphData: { nodes: this.graphData.nodes, edges: this.graphData.edges }, onNodeClick: this.handleNodeClick.bind(this), onNodeSelectionChange: this.handleNodeSelectionChange.bind(this), onNodeDelete: this.handleDeleteNode.bind(this), selectedNodeIds: this.selectedNodeIds, activeNodeId: this.currentNode, app: this.plugin.app }) ); } else { console.warn('React root не инициализирован для рендера GraphPanelComponent.'); } } // ----------------------------------------------- API Methods --------------------------------------------------------------- /** * Загружает список всех графов с бэкенда. * @returns {Promise} */ async fetchGraphsList(): Promise { try { const response = await fetch(`${this.plugin.settings.apiBaseUrl}/graphs`); if (!response.ok) { const errorText = await response.text(); throw new Error(`Ошибка загрузки списка графов: ${response.status} - ${errorText}`); } const data = await response.json(); this.graphsList = data; if (this.historyPanel) { this.historyPanel.updateGraphs(this.graphsList); } } catch (error) { console.error('Ошибка загрузки списка графов:', error); // Optionally: show a notice to the user // new Notice(`Ошибка загрузки списка графов: ${error.message}`); } } /** * Загружает данные конкретного графа по его ID. * @param {string} graphId - ID графа для загрузки. * @returns {Promise} */ async fetchGraphData(graphId: string): Promise { try { if (!graphId) { this.graphData = { nodes: [], edges: [] }; this.currentNode = null; this.selectedNodeIds.clear(); // Очищаем выделения this.selectedGraphTitle = null; // Сброс заголовка this.updateTitle(); // Обновляем заголовок this.renderGraphPanel(); // Нет подписчиков: this.plugin.eventBus.emit('chat-history-updated', { graphId: null, messages: [], current_node_id: null }); this.plugin.eventBus.emit('graph-custom-prompt-updated', { graph_id: null, custom_system_prompt: null }); // Уведомляем ChatView о сбросе if (this.graphSettingsButton) { this.graphSettingsButton.setAttr('disabled', 'true'); } return; } const response = await fetch(`${this.plugin.settings.apiBaseUrl}/graphs/${graphId}`); if (!response.ok) { const errorText = await response.text(); throw new Error(`Ошибка загрузки данных графа: ${response.status} - ${errorText}`); } const data = await response.json(); // Чтобы не раздувать отправляемые эндпоинтом данные const nodesWithGraphId = data.graph_nodes ? data.graph_nodes.map((node: any) => ({ ...node, graph_id: graphId })) : []; this.graphData = { nodes: nodesWithGraphId, edges: data.graph_edges || [] }; this.currentNode = data.current_node_id; // Устанавливаем заголовок графа this.selectedGraphTitle = data.title || data.first_message || `Граф ${graphId.substring(0, 8)}...`; this.updateTitle(); this.currentGraphCustomSystemPrompt = data.custom_system_prompt || null; if (this.graphSettingsButton) { this.graphSettingsButton.setAttr('disabled', null); // Активируем } // Перерендериваем граф this.renderGraphPanel(); // Также обновляем ChatView с полученными сообщениями и текущим узлом /* Нет подписчиков: this.plugin.eventBus.emit('chat-history-updated', { graphId: graphId, messages: data.messages || [], current_node_id: data.current_node_id // Передаем актуальный current_node_id });*/ this.plugin.eventBus.emit('graph-custom-prompt-updated', { graph_id: graphId, custom_system_prompt: this.currentGraphCustomSystemPrompt }); } catch (error) { console.error('Ошибка загрузки данных графа:', error); this.selectedGraphTitle = null; this.currentGraphCustomSystemPrompt = null; // Сброс кастомного промпта при ошибке this.updateTitle(); // Обновляем заголовок if (this.graphSettingsButton) { this.graphSettingsButton.setAttr('disabled', 'true'); } this.plugin.eventBus.emit('graph-custom-prompt-updated', { graph_id: graphId, custom_system_prompt: null }); } } /** * Удаляет узел графа по его ID. * @param {string} graphId - ID графа. * @param {string} nodeId - ID узла для удаления. * @returns {Promise} */ async handleDeleteNode(nodeId: string): Promise { if (!this.selectedGraphId) { new Notice('Не выбран граф для удаления узла.', 3000); return; } try { const response = await fetch(`${this.plugin.settings.apiBaseUrl}/graphs/${this.selectedGraphId}/nodes/${nodeId}`, { method: 'DELETE' }); if (!response.ok) { const errorText = await response.text(); throw new Error(`Ошибка удаления узла: ${response.status} - ${errorText}`); } new Notice('Узел успешно удален.', 2000); // После удаления узла, нужно обновить данные графа и перерисовать его. // Это также обновит ChatPanel, если удаленное сообщение было частью текущей ветки. await this.fetchGraphData(this.selectedGraphId); // Сбрасываем выделение, если удаленный узел был выделен this.selectedNodeIds.delete(nodeId); } catch (error) { console.error(`Ошибка при удалении узла ${nodeId}:`, error); new Notice(`Ошибка при удалении узла: ${error.message}`, 5000); } } // ----------------------------------------------- Event Handlers ------------------------------------------------------------ /** * Обработчик клика по узлу графа. * Устанавливает текущий узел и генерирует событие 'node-selected'. * @param {string} nodeId - ID выбранного узла. */ handleNodeClick(nodeId: string): void { this.currentNode = nodeId; // Очищаем предыдущие выделения при обычном клике if (this.selectedNodeIds.size > 0) { this.selectedNodeIds.clear(); } this.plugin.eventBus.emit('node-selected', { nodeId, graphId: this.selectedGraphId }); this.renderGraphPanel(); // Временное "жирное" решение с полной перерисовкой графа при каждом выборе узла } /** * Обработчик изменения состояния выделения узла. * @param {string} nodeId - ID узла. * @param {boolean} isSelected - Новое состояние выделения. */ handleNodeSelectionChange(nodeId: string, isSelected: boolean): void { if (isSelected) { this.selectedNodeIds.add(nodeId); } else { this.selectedNodeIds.delete(nodeId); } this.renderGraphPanel(); // Перерендериваем для обновления визуального состояния выделения } /** * Обработчик начала нового чата. * Очищает текущие данные графа и перерендеривает его. */ async handleNewChat(): Promise { this.graphData = { nodes: [], edges: [] }; this.currentNode = null; this.selectedNodeIds.clear(); this.selectedGraphTitle = null; this.currentGraphCustomSystemPrompt = null; try { const response = await fetch(`${this.plugin.settings.apiBaseUrl}/graphs/new`, { method: 'POST', headers: { 'Content-Type': 'application/json' } }); if (!response.ok) { throw new Error(`Ошибка при создании нового графа на бэкенде: ${response.status}`); } const result = await response.json(); const newGraphId = result.graph_id; this.selectedGraphId = newGraphId; this.selectedGraphTitle = 'Новый граф'; // Будет обновлен fetchGraphData, если бэкенд вернет title this.plugin.eventBus.emit('graph-selected', { graphId: this.selectedGraphId, currentNode: null }); this.fetchGraphsList(); // Обновляем список графов в HistoryPanel // Нет подписчиков: this.plugin.eventBus.emit('chat-history-updated', { graphId: this.selectedGraphId, messages: [], current_node_id: null }); this.plugin.eventBus.emit('graph-custom-prompt-updated', { graph_id: this.selectedGraphId, custom_system_prompt: null }); if (this.graphSettingsButton) { this.graphSettingsButton.setAttr('disabled', null); } } catch (error: any) { console.error('Ошибка при создании нового чата:', error); new Notice(`Ошибка при создании нового чата: ${error?.message || error}`, 5000); // Сброс состояния в случае ошибки, чтобы не было "фантомного" графа this.selectedGraphId = null; this.selectedGraphTitle = null; this.updateTitle(); if (this.graphSettingsButton) { this.graphSettingsButton.setAttr('disabled', 'true'); } } } /** * Обработчик выбора графа из панели истории. */ async handleGraphSelected(data: { graphId: string; currentNode: string | null }): Promise { this.selectedGraphId = data.graphId; this.selectedNodeIds.clear(); await this.fetchGraphData(data.graphId); } /** * Обработчик события выбора узла из других компонентов (например, ChatPanel). * Синхронизирует визуальное состояние графа (подсветку текущего узла). */ private handleNodeSelectedFromBus = (data: { nodeId: string; graphId: string }) => { // Реагируем только если это для нашего текущего графа if (this.selectedGraphId === data.graphId) { this.currentNode = data.nodeId; // Если какие-то узлы подсвечены руками (через Shift), сбрасываем это if (this.selectedNodeIds.size > 0) { this.selectedNodeIds.clear(); } // Перерисовываем канвас (это передаст новый activeNodeId в ReactFlow и сдвинет фокус) this.renderGraphPanel(); } }; /** * Обработчик события удаления узла из ChatPanel. */ async handleDeleteNodeFromChat(data: { graphId: string; nodeId: string }): Promise { if (data.graphId === this.selectedGraphId) { await this.handleDeleteNode(data.nodeId); } else { new Notice('Сообщение относится к другому графу. Удаление невозможно из текущего контекста.', 3000); } } /** * Обработчик для кнопки "Копировать выделенные узлы". */ async handleCopySelectedNodes(): Promise { if (this.selectedNodeIds.size === 0) { new Notice('Выберите узлы для копирования.', 2000); return; } const messagesToCopy: string[] = []; // Собираем сообщения в порядке выделения // Необходимо пройтись по nodes в graphData, чтобы получить актуальные данные сообщений const orderedNodeIds = Array.from(this.selectedNodeIds); // Сохраняем порядок for (const nodeId of orderedNodeIds) { const node = this.graphData.nodes.find((n) => n.id === nodeId); if (node && node.data && node.data.message && node.data.message.content) { messagesToCopy.push(node.data.message.content); } } if (messagesToCopy.length > 0) { try { await navigator.clipboard.writeText(messagesToCopy.join('\n\n--- Разделитель --- \n\n')); new Notice(`Скопировано ${messagesToCopy.length} сообщений.`, 2000); } catch (err) { console.error('Не удалось скопировать сообщения:', err); new Notice('Ошибка при копировании сообщений.', 3000); } } else { new Notice('Нет содержимого для копирования в выделенных узлах.', 2000); } } /** * Обработчик для кнопки "Удалить выделенные узлы". */ async handleDeleteSelectedNodes(): Promise { if (this.selectedNodeIds.size === 0) { new Notice('Выберите узлы для удаления.', 2000); return; } if (!this.selectedGraphId) { new Notice('Не выбран граф для удаления узлов.', 3000); return; } const confirmed = await Dialog.confirm( this.plugin.app, `Вы уверены, что хотите удалить ${this.selectedNodeIds.size} выделенных узлов?` ); if (!confirmed) return; new Notice(`Начинаю удаление ${this.selectedNodeIds.size} узлов...`, 2000); const nodesToDelete = Array.from(this.selectedNodeIds); // Сохраняем порядок выделения for (const nodeId of nodesToDelete) { try { const response = await fetch(`${this.plugin.settings.apiBaseUrl}/graphs/${this.selectedGraphId}/nodes/${nodeId}`, { method: 'DELETE' }); if (!response.ok) { const errorText = await response.text(); throw new Error(`Ошибка удаления узла ${nodeId}: ${response.status} - ${errorText}`); } new Notice(`Узел ${nodeId.substring(0, 8)}... успешно удален.`, 1000); } catch (error) { console.error(`Ошибка при удалении узла ${nodeId}:`, error); new Notice(`Ошибка при удалении узла ${nodeId.substring(0, 8)}...: ${error.message}`, 5000); // Продолжаем попытки удаления остальных узлов, но фиксируем ошибку } } // После всех удалений очищаем выделение и обновляем граф полностью this.selectedNodeIds.clear(); await this.fetchGraphData(this.selectedGraphId); // Полностью обновляем граф и чат-панель new Notice('Операция удаления завершена.', 2000); } handleStreamStarted(data: { nodeId: string; controller: AbortController }): void { this.activeStreamControllers.set(data.nodeId, data.controller); } handleStreamEnded(data: { nodeId: string }): void { this.activeStreamControllers.delete(data.nodeId); } // ----------------------------------------------- WebSocket Event Handlers --------------------------------------------------------------- /** * Обработчик изменения типа узла через WebSocket */ handleNodeTypeChanged(data: { graphId: string; nodeId: string; nodeType: string }): void { // Обновляем только если это узел текущего графа if (this.selectedGraphId !== data.graphId) { return; } // Находим и обновляем тип узла в graphData const nodeIndex = this.graphData.nodes.findIndex((n) => n.id === data.nodeId); if (nodeIndex !== -1) { this.graphData.nodes[nodeIndex].type = data.nodeType; // Перерисовываем граф с обновленным типом узла this.renderGraphPanel(); console.log(`✅ Обновлен тип узла ${data.nodeId}: "${data.nodeType}"`); } } /** * Обработчик обновления заголовка графа через WebSocket */ handleGraphTitleUpdated(data: { graph_id: string; title: string }): void { // Обновляем только если это текущий граф if (this.selectedGraphId === data.graph_id) { this.selectedGraphTitle = data.title; this.updateTitle(); // Также обновляем в списке истории if (this.historyPanel) { this.fetchGraphsList(); // Перезагружаем список графов } } } /** * Обработчик обновления заголовка узла через WebSocket */ handleNodeTitleUpdated(data: { graph_id: string; node_id: string; title: string }): void { // Обновляем только если это узел текущего графа if (this.selectedGraphId !== data.graph_id) { return; } // Находим и обновляем узел в graphData const nodeIndex = this.graphData.nodes.findIndex((n) => n.id === data.node_id); if (nodeIndex !== -1) { // Обновляем заголовок и флаг генерации this.graphData.nodes[nodeIndex].data.title = data.title; this.graphData.nodes[nodeIndex].data.title_generated = true; // Перерисовываем граф с обновленными данными // React Flow автоматически обновит только измененный узел this.renderGraphPanel(); console.log(`✅ Обновлен заголовок узла ${data.node_id}: "${data.title}"`); } } }