llm-agent-plugin/src/views/GraphView.ts
2025-10-05 19:11:41 +03:00

528 lines
22 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 { 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<string>; // Добавляем 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<string>(); // Инициализируем 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<void>}
*/
async onOpen(): Promise<void> {
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<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('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<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 });
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<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 });
}
/**
* Обработчик изменения состояния выделения узла.
* @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<void>}
*/
async handleGraphSelected(data: { graphId: string; currentNode: string | null }): Promise<void> {
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<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);
}
}