llm-agent-plugin/src/views/GraphView.ts
2026-05-10 18:14:56 +03:00

827 lines
33 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.

/**
* Отображает и управляет интерактивным графом 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<string>; // Добавляем 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<string, AbortController>;
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<string>();
this.selectedGraphTitle = null;
this.currentGraphCustomSystemPrompt = null;
this.activeStreamControllers = new Map<string, AbortController>();
}
/**
* @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<void>}
*/
async onOpen(): Promise<void> {
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<void>}
*/
async onClose(): Promise<void> {
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<void>}
*/
async saveGraphCustomSystemPrompt(graphId: string, customSystemPrompt: string | null): Promise<void> {
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<void>}
*/
async fetchGraphsList(): Promise<void> {
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<void>}
*/
async fetchGraphData(graphId: string): Promise<void> {
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<void>}
*/
async handleDeleteNode(nodeId: string): Promise<void> {
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<void> {
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<void> {
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<void> {
if (data.graphId === this.selectedGraphId) {
await this.handleDeleteNode(data.nodeId);
} else {
new Notice('Сообщение относится к другому графу. Удаление невозможно из текущего контекста.', 3000);
}
}
/**
* Обработчик для кнопки "Копировать выделенные узлы".
*/
async handleCopySelectedNodes(): Promise<void> {
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<void> {
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}"`);
}
}
}