827 lines
33 KiB
TypeScript
827 lines
33 KiB
TypeScript
/**
|
||
* Отображает и управляет интерактивным графом 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}"`);
|
||
}
|
||
}
|
||
}
|