/** * Отображает и управляет интерактивным графом LLM агента. * Интегрирует историю чатов и визуализацию графа на основе React Flow. */ // ----------------------------------------------- Obsidian Imports ---------------------------------------------------------- import { ItemView, WorkspaceLeaf, Notice } from 'obsidian'; // ----------------------------------------------- Plugin Imports ------------------------------------------------------------ import LLMAgentPlugin from 'main'; // Предполагаем, что main.ts экспортирует LLMAgentPlugin по умолчанию import { HistoryPanel } from '../components/HistoryPanel'; // Предполагаем, что HistoryPanel это JS-класс import { GraphPanelComponent } from '../components/GraphPanel'; // Импортируем React-компонент и ReactDOM import * as React from 'react'; import * as ReactDOM from 'react-dom/client'; // <-- ИСПРАВЛЕНО: Импортируем 'react-dom/client' для createRoot import { Dialog } from 'src/utils/Dialog'; 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; // 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; 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(); // Инициализируем Set this.selectedGraphTitle = null; } /** * @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(); // Создаем основной контейнер с flexbox layout const mainContainer = container.createDiv('llm-agent-graph-main'); mainContainer.style.cssText = ` display: flex; height: 100%; width: 100%; background-color: var(--background-primary); `; // Левая панель для истории this.historyContainer = mainContainer.createDiv('llm-agent-history-sidebar') as HTMLDivElement; this.historyContainer.classList.add(this.isHistoryCollapsed ? 'collapsed' : 'expanded'); // Правая панель для графа this.graphContainer = mainContainer.createDiv('llm-agent-graph-container') as HTMLDivElement; this.graphContainer.style.cssText = ` flex: 1; position: relative; background-color: var(--background-primary); `; // Инициализируем панель истории this.historyPanel = new HistoryPanel(this.historyContainer, { graphs: this.graphsList, eventBus: this.plugin.eventBus }); // Создаем кнопки переключения и глобальные кнопки действий this.addToggleButton(); this.addGlobalActionButtons(); // Добавляем вызов новой функции // Инициализируем React Flow this.initializeGraphPanel(); // Подписываемся на события this.plugin.eventBus.on('new-chat', this.handleNewChat.bind(this)); this.plugin.eventBus.on('graph-selected', this.handleGraphSelected.bind(this)); this.plugin.eventBus.on('delete-node', this.handleDeleteNodeFromChat.bind(this)); // Слушаем событие из ChatPanel // Загружаем список графов await this.fetchGraphsList(); } /** * Вызывается при закрытии представления. Очищает ресурсы. * @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('delete-node', this.handleDeleteNodeFromChat); } // ----------------------------------------------- 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)); } // ----------------------------------------------- Graph Panel Integration -------------------------------------------------- /** * Инициализирует 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, // Передаем Set выделенных узлов 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 }); 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(); this.graphData = { nodes: data.graph_nodes || [], edges: data.graph_edges || [] }; this.currentNode = data.current_node_id; // Устанавливаем заголовок графа this.selectedGraphTitle = data.title || data.first_message || `Граф ${graphId.substring(0, 8)}...`; this.updateTitle(); // Обновляем заголовок // Перерендериваем граф 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 }); } catch (error) { console.error('Ошибка загрузки данных графа:', error); this.selectedGraphTitle = null; this.updateTitle(); // Обновляем заголовок // Optionally: show a notice to the user // new Notice(`Ошибка загрузки данных графа: ${error.message}`); } } /** * Удаляет узел графа по его 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 }); } /** * Обработчик изменения состояния выделения узла. * @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(); // Перерендериваем для обновления визуального состояния выделения } /** * Обработчик начала нового чата. * Очищает текущие данные графа и перерендеривает его. */ handleNewChat(): void { this.graphData = { nodes: [], edges: [] }; this.currentNode = null; this.selectedGraphId = null; this.selectedNodeIds.clear(); // Очищаем выделения this.selectedGraphTitle = null; // Сброс заголовка при новом чате this.updateTitle(); // Обновляем заголовок this.renderGraphPanel(); this.fetchGraphsList(); // Обновить список графов, чтобы показать новый пустой чат this.plugin.eventBus.emit('chat-history-updated', { graphId: null, messages: [], current_node_id: null }); } /** * Обработчик выбора графа из панели истории. * Устанавливает выбранный граф и загружает его данные. * @param {{ graphId: string; currentNode: string | null }} data - Объект, содержащий ID выбранного графа и текущий узел. * @returns {Promise} */ async handleGraphSelected(data: { graphId: string; currentNode: string | null }): Promise { this.selectedGraphId = data.graphId; this.selectedNodeIds.clear(); // Очищаем выделения при смене графа await this.fetchGraphData(data.graphId); } /** * Обработчик события удаления узла из ChatPanel. * @param {{ graphId: string, nodeId: string }} data - Данные об удаляемом узле. */ 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); } }