Add deletion
This commit is contained in:
parent
59ba254cbe
commit
47cff8a121
|
|
@ -12,7 +12,8 @@ import { ReferenceParser, FileReference, ReferenceMatch } from '../references/Re
|
||||||
import { ReferenceBox } from '../references/ReferenceBox';
|
import { ReferenceBox } from '../references/ReferenceBox';
|
||||||
import { AutocompleteItem } from '../references/AutocompleteManager';
|
import { AutocompleteItem } from '../references/AutocompleteManager';
|
||||||
import LLMAgentPlugin from 'main';
|
import LLMAgentPlugin from 'main';
|
||||||
import { MarkdownRenderer, setIcon } from 'obsidian'; // Добавляем импорт MarkdownRenderer
|
import { MarkdownRenderer, setIcon, Notice } from 'obsidian'; // Добавляем импорт MarkdownRenderer
|
||||||
|
import { Dialog } from 'src/utils/Dialog';
|
||||||
|
|
||||||
export class ChatPanel {
|
export class ChatPanel {
|
||||||
container: HTMLElement;
|
container: HTMLElement;
|
||||||
|
|
@ -101,7 +102,7 @@ export class ChatPanel {
|
||||||
|
|
||||||
if (spaceBelow < dropdownRect.height + 10 && spaceAbove > dropdownRect.height + 10) {
|
if (spaceBelow < dropdownRect.height + 10 && spaceAbove > dropdownRect.height + 10) {
|
||||||
// Если снизу мало места и сверху достаточно, размещаем сверху
|
// Если снизу мало места и сверху достаточно, размещаем сверху
|
||||||
top = - dropdownRect.height;
|
top = -dropdownRect.height;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Убедимся, что дропдаун не выходит за границы контейнера справа
|
// Убедимся, что дропдаун не выходит за границы контейнера справа
|
||||||
|
|
@ -203,7 +204,9 @@ export class ChatPanel {
|
||||||
historyContainer.empty(); // Очищаем контейнер перед добавлением новых сообщений
|
historyContainer.empty(); // Очищаем контейнер перед добавлением новых сообщений
|
||||||
|
|
||||||
for (const msg of this.chatHistory) {
|
for (const msg of this.chatHistory) {
|
||||||
const messageEl = historyContainer.createDiv({ cls: `message ${msg.role} message-type-${msg.type || 'default'} group/turn-messages` });
|
const messageEl = historyContainer.createDiv({
|
||||||
|
cls: `message ${msg.role} message-type-${msg.type || 'default'} group/turn-messages`
|
||||||
|
});
|
||||||
|
|
||||||
// Информация об отправителе
|
// Информация об отправителе
|
||||||
const metaDiv = messageEl.createDiv({ cls: 'chat-meta' });
|
const metaDiv = messageEl.createDiv({ cls: 'chat-meta' });
|
||||||
|
|
@ -223,7 +226,9 @@ export class ChatPanel {
|
||||||
'', // Путь к источнику, может быть пустым для сообщений чата
|
'', // Путь к источнику, может быть пустым для сообщений чата
|
||||||
this.props.plugin // Компонент, необходимый для управления жизненным циклом рендера
|
this.props.plugin // Компонент, необходимый для управления жизненным циклом рендера
|
||||||
);
|
);
|
||||||
this.createMessageToolset(messageEl, msg.content);
|
// Передаем graphId и nodeId
|
||||||
|
// msg.node_id и this.props.plugin.activeGraphId используются для кнопок удаления
|
||||||
|
this.createMessageToolset(messageEl, msg.content, msg.node_id, msg.graph_id || '');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Прокручиваем к самому низу родительского контейнера (view-content)
|
// Прокручиваем к самому низу родительского контейнера (view-content)
|
||||||
|
|
@ -526,9 +531,11 @@ export class ChatPanel {
|
||||||
let currentLength = 0;
|
let currentLength = 0;
|
||||||
|
|
||||||
const walker = document.createTreeWalker(this.messageInput, NodeFilter.SHOW_ALL); // SHOW_ALL, чтобы учитывать и элементы
|
const walker = document.createTreeWalker(this.messageInput, NodeFilter.SHOW_ALL); // SHOW_ALL, чтобы учитывать и элементы
|
||||||
let node;
|
let node: Node | null; // Указываем, что node может быть null
|
||||||
|
|
||||||
while ((node = walker.nextNode())) {
|
while ((node = walker.nextNode())) {
|
||||||
|
if (!node) break; // Добавляем проверку на null
|
||||||
|
|
||||||
const nodeTextLength = this.getNodeEquivalentLengthForReferenceParsing(node);
|
const nodeTextLength = this.getNodeEquivalentLengthForReferenceParsing(node);
|
||||||
|
|
||||||
// Проверяем, начинается ли ссылка в этом узле
|
// Проверяем, начинается ли ссылка в этом узле
|
||||||
|
|
@ -856,13 +863,15 @@ export class ChatPanel {
|
||||||
* Создает панель инструментов под сообщением с кнопками для взаимодействия.
|
* Создает панель инструментов под сообщением с кнопками для взаимодействия.
|
||||||
* @param messageElement - Элемент сообщения, к которому добавляется панель инструментов.
|
* @param messageElement - Элемент сообщения, к которому добавляется панель инструментов.
|
||||||
* @param messageContent - Содержимое сообщения для функции копирования.
|
* @param messageContent - Содержимое сообщения для функции копирования.
|
||||||
|
* @param nodeId - ID узла, соответствующего сообщению, для функции удаления.
|
||||||
|
* @param graphId - ID графа, которому принадлежит сообщение, для функции удаления.
|
||||||
*/
|
*/
|
||||||
private createMessageToolset(messageElement: HTMLElement, messageContent: string): void {
|
private createMessageToolset(messageElement: HTMLElement, messageContent: string, nodeId: string, graphId: string): void {
|
||||||
const toolsetWrapper = messageElement.createDiv({
|
const toolsetWrapper = messageElement.createDiv({
|
||||||
cls: 'z-0 flex min-h-[46px] justify-start'
|
cls: 'z-0 flex min-h-[46px] justify-start'
|
||||||
});
|
});
|
||||||
const toolsetContainer = toolsetWrapper.createDiv({
|
const toolsetContainer = toolsetWrapper.createDiv({
|
||||||
cls: 'touch:-me-2 touch:-ms-3.5 -ms-2.5 -me-1 flex flex-wrap items-center gap-y-4 p-1 select-none touch:w-[calc(100%+--spacing(3.5))] -mt-1 w-[calc(100%+--spacing(2.5))] duration-[1.5s] focus-within:transition-none hover:transition-none pointer-events-none [mask-image:linear-gradient(to_right,black_33%,transparent_66%)] [mask-size:300%_100%] [mask-position:100%_0%] motion-safe:transition-[mask-position] group-hover/turn-messages:pointer-events-auto group-hover/turn-messages:[mask-position:0_0] group-focus-within/turn-messages:pointer-events-auto group-focus-within/turn-messages:[mask-position:0_0] has-data-[state=open]:pointer-events-auto has-data-[state=open]:[mask-position:0_0]',
|
cls: 'touch:-me-2 touch:-ms-3.5 -ms-2.5 -me-1 flex flex-wrap items-center gap-y-4 p-1 select-none touch:w-[calc(100%+--spacing(3.5))] -mt-1 w-[calc(100%+--spacing(2.5))] duration-[1.5s] focus-within:transition-none hover:transition-none pointer-events-none [mask-image:linear-gradient(to_right,black_33%,transparent_66%)] [mask-size:300%_100%] [mask-position:100%_0%] motion-safe:transition-[mask-position] group-hover/turn-messages:pointer-events-auto group-hover/turn-messages:[mask-position:0_0] group-focus-within/turn-messages:pointer-events-auto group-focus-within/turn-messages:[mask-position:0_0] has-data-[state=open]:pointer-events-auto has-data-[state=open]:[mask-position:0_0]'
|
||||||
});
|
});
|
||||||
|
|
||||||
// Кнопка "Копировать"
|
// Кнопка "Копировать"
|
||||||
|
|
@ -872,8 +881,8 @@ export class ChatPanel {
|
||||||
'aria-label': 'Копировать',
|
'aria-label': 'Копировать',
|
||||||
'aria-pressed': 'false',
|
'aria-pressed': 'false',
|
||||||
'data-testid': 'copy-turn-action-button',
|
'data-testid': 'copy-turn-action-button',
|
||||||
'data-state': 'closed',
|
'data-state': 'closed'
|
||||||
},
|
}
|
||||||
});
|
});
|
||||||
const copyIconContainer = copyButton.createSpan({ cls: 'flex items-center justify-center touch:w-10 h-8 w-8' });
|
const copyIconContainer = copyButton.createSpan({ cls: 'flex items-center justify-center touch:w-10 h-8 w-8' });
|
||||||
setIcon(copyIconContainer, 'copy'); // Используем импортированную функцию setIcon
|
setIcon(copyIconContainer, 'copy'); // Используем импортированную функцию setIcon
|
||||||
|
|
@ -887,13 +896,33 @@ export class ChatPanel {
|
||||||
setIcon(copyIconContainer, 'copy');
|
setIcon(copyIconContainer, 'copy');
|
||||||
}, 1000);
|
}, 1000);
|
||||||
})
|
})
|
||||||
.catch(err => {
|
.catch((err) => {
|
||||||
console.error('Не удалось скопировать сообщение:', err);
|
console.error('Не удалось скопировать сообщение:', err);
|
||||||
// Optionally: show an error notice
|
new Notice('Не удалось скопировать сообщение.', 3000); // Используем Notice
|
||||||
// new Notice('Не удалось скопировать сообщение.');
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Кнопка "Удалить"
|
||||||
|
const deleteButton = toolsetContainer.createEl('button', {
|
||||||
|
cls: 'text-token-text-secondary hover:bg-token-bg-secondary rounded-lg ml-1', // Добавляем немного отступа
|
||||||
|
attr: {
|
||||||
|
'aria-label': 'Удалить сообщение',
|
||||||
|
'data-testid': 'delete-turn-action-button',
|
||||||
|
'data-state': 'closed'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const deleteIconContainer = deleteButton.createSpan({ cls: 'flex items-center justify-center touch:w-10 h-8 w-8' });
|
||||||
|
setIcon(deleteIconContainer, 'trash-2'); // Иконка корзины
|
||||||
|
deleteButton.addEventListener('click', async () => {
|
||||||
|
const confirmed = await Dialog.confirm(
|
||||||
|
this.props.plugin.app,
|
||||||
|
'Вы уверены, что хотите удалить это сообщение? Это также удалит соответствующий узел в графе.'
|
||||||
|
);
|
||||||
|
if (confirmed) {
|
||||||
|
this.props.plugin.eventBus.emit('delete-node', { graphId, nodeId });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Кнопка "Попробовать снова"
|
// Кнопка "Попробовать снова"
|
||||||
/*const retryButton = toolsetContainer.createEl('button', {
|
/*const retryButton = toolsetContainer.createEl('button', {
|
||||||
cls: 'text-token-text-secondary hover:bg-token-bg-secondary rounded-lg',
|
cls: 'text-token-text-secondary hover:bg-token-bg-secondary rounded-lg',
|
||||||
|
|
|
||||||
|
|
@ -14,11 +14,13 @@ import ReactFlow, {
|
||||||
useEdgesState,
|
useEdgesState,
|
||||||
Background,
|
Background,
|
||||||
Controls,
|
Controls,
|
||||||
ReactFlowProvider, // <-- ИСПРАВЛЕНО: импортируем прямо ReactFlowProvider без переименования
|
ReactFlowProvider,
|
||||||
Connection,
|
Connection,
|
||||||
Edge,
|
Edge,
|
||||||
BackgroundVariant // <-- ИСПРАВЛЕНО: используем BackgroundVariant для типизации
|
BackgroundVariant
|
||||||
} from 'reactflow';
|
} from 'reactflow';
|
||||||
|
import { App } from 'obsidian';
|
||||||
|
import { Dialog } from 'src/utils/Dialog';
|
||||||
|
|
||||||
// <-- ИСПРАВЛЕНО: Это ключевой момент. Убедитесь, что esbuild настроен на обработку CSS импортов.
|
// <-- ИСПРАВЛЕНО: Это ключевой момент. Убедитесь, что esbuild настроен на обработку CSS импортов.
|
||||||
// Обычно, при использовании esbuild для React-проектов, вы импортируете CSS прямо в JS/TS файл.
|
// Обычно, при использовании esbuild для React-проектов, вы импортируете CSS прямо в JS/TS файл.
|
||||||
|
|
@ -34,35 +36,69 @@ import 'reactflow/dist/style.css'; // Импортируем стили ReactFlo
|
||||||
|
|
||||||
import { Handle, Position } from 'reactflow';
|
import { Handle, Position } from 'reactflow';
|
||||||
|
|
||||||
// Простой компонент для узла типа 'user'
|
/**
|
||||||
// Это базовый пример. Ты можешь стилизовать его по своему усмотрению.
|
* Интерфейс, описывающий структуру данных, передаваемых в BaseNodeWrapper.
|
||||||
const UserNode: React.FC<any> = ({ data, isConnectable }) => {
|
*/
|
||||||
const displayLabel = data.title || data.label || 'Пользователь';
|
interface BaseNodeWrapperData {
|
||||||
const titleGenerated = data.title_generated;
|
label: string;
|
||||||
|
title?: string;
|
||||||
|
title_generated?: boolean;
|
||||||
|
isSelected: boolean;
|
||||||
|
nodeId: string;
|
||||||
|
onNodeDelete: (nodeId: string) => Promise<void>;
|
||||||
|
app: App;
|
||||||
|
}
|
||||||
|
|
||||||
return React.createElement(
|
/**
|
||||||
React.Fragment,
|
* Интерфейс, описывающий свойства компонента BaseNodeWrapper.
|
||||||
// className: `custom-node user-node ${titleGenerated ? 'title-generated' : 'title-pending'}`,
|
*/
|
||||||
null, // Используем React.Fragment для возврата нескольких элементов без лишнего div
|
interface BaseNodeWrapperProps {
|
||||||
React.createElement(Handle, {
|
data: BaseNodeWrapperData;
|
||||||
type: 'source',
|
isConnectable: boolean;
|
||||||
position: Position.Bottom,
|
// children: React.ReactNode; // Содержимое, специфичное для типа узла (например, метка)
|
||||||
isConnectable: isConnectable
|
}
|
||||||
}),
|
|
||||||
React.createElement(Handle, {
|
/**
|
||||||
type: 'target',
|
* BaseNodeWrapper - Общая обёртка для всех кастомных узлов,
|
||||||
position: Position.Top,
|
* предоставляющая хэндлы для соединений и функциональность удаления.
|
||||||
isConnectable: isConnectable
|
* @param {BaseNodeWrapperProps} props - Свойства компонента.
|
||||||
}),
|
* @returns {React.ReactElement} Элемент React.
|
||||||
displayLabel
|
*/
|
||||||
|
const BaseNodeWrapper: React.FC<BaseNodeWrapperProps> = ({ data, isConnectable, children }) => {
|
||||||
|
// ----------------------------------------------- Styles --------------------------------------------------------------------
|
||||||
|
// Стили для кнопки удаления. Вынесены для переиспользования.
|
||||||
|
const deleteButtonStyle: React.CSSProperties = {
|
||||||
|
position: 'absolute',
|
||||||
|
bottom: '-30px', // Размещаем под узлом
|
||||||
|
left: '5px',
|
||||||
|
background: 'transparent',
|
||||||
|
border: 'none',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: '1em',
|
||||||
|
color: 'var(--text-muted)', // Используем цвет текста Obsidian
|
||||||
|
padding: '0 5px',
|
||||||
|
opacity: data.isSelected ? 1 : 0, // Показываем только если узел выделен
|
||||||
|
transition: 'opacity 0.2s ease-in-out',
|
||||||
|
};
|
||||||
|
|
||||||
|
// ----------------------------------------------- Event Handlers ------------------------------------------------------------
|
||||||
|
/**
|
||||||
|
* Обработчик удаления узла.
|
||||||
|
* @param {React.MouseEvent} event - Событие клика.
|
||||||
|
*/
|
||||||
|
const handleNodeDelete = async (event: React.MouseEvent) => {
|
||||||
|
event.stopPropagation(); // Предотвращаем срабатывание onNodeClick на родительском узле
|
||||||
|
|
||||||
|
const confirmed = await Dialog.confirm(
|
||||||
|
data.app,
|
||||||
|
'Вы уверены, что хотите удалить этот узел?'
|
||||||
);
|
);
|
||||||
};
|
if (confirmed) {
|
||||||
|
await data.onNodeDelete(data.nodeId);
|
||||||
// Простой компонент для узла типа 'llm'
|
}
|
||||||
const LLMNode: React.FC<any> = ({ data, isConnectable }) => {
|
};
|
||||||
const displayLabel = data.title || data.label || 'LLM';
|
|
||||||
const titleGenerated = data.title_generated;
|
|
||||||
|
|
||||||
|
// ----------------------------------------------- Render --------------------------------------------------------------------
|
||||||
return React.createElement(
|
return React.createElement(
|
||||||
React.Fragment,
|
React.Fragment,
|
||||||
null,
|
null,
|
||||||
|
|
@ -76,51 +112,62 @@ const LLMNode: React.FC<any> = ({ data, isConnectable }) => {
|
||||||
position: Position.Top,
|
position: Position.Top,
|
||||||
isConnectable: isConnectable
|
isConnectable: isConnectable
|
||||||
}),
|
}),
|
||||||
displayLabel
|
children, // Здесь будет отображаться специфическое содержимое узла
|
||||||
|
data.isSelected && React.createElement('button', {
|
||||||
|
style: deleteButtonStyle,
|
||||||
|
onClick: handleNodeDelete,
|
||||||
|
'aria-label': 'Удалить узел',
|
||||||
|
className: 'node-delete-button' // Добавляем класс для стилизации
|
||||||
|
}, '🗑️') // Можно использовать SVG иконку
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Обновляем CustomNode, чтобы включить кнопку удаления
|
||||||
|
// Простой компонент для узла типа 'user'
|
||||||
|
const UserNode: React.FC<any> = ({ data, isConnectable }) => {
|
||||||
|
const displayLabel = data.title || data.label || 'Пользователь';
|
||||||
|
// ... (titleGenerated больше не нужен в каждом узле, если не используется для рендеринга)
|
||||||
|
|
||||||
|
return React.createElement(
|
||||||
|
BaseNodeWrapper,
|
||||||
|
{ data: data, isConnectable: isConnectable },
|
||||||
|
React.createElement('div', null, displayLabel)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Обновите другие типы узлов (LLMNode, ToolNode, ErrorNode) аналогично
|
||||||
|
const LLMNode: React.FC<any> = ({ data, isConnectable }) => {
|
||||||
|
const displayLabel = data.title || data.label || 'LLM';
|
||||||
|
// ...
|
||||||
|
|
||||||
|
return React.createElement(
|
||||||
|
BaseNodeWrapper,
|
||||||
|
{ data: data, isConnectable: isConnectable },
|
||||||
|
React.createElement('div', null, displayLabel)
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Простой компонент для узла типа 'tool'
|
// Простой компонент для узла типа 'tool'
|
||||||
const ToolNode: React.FC<any> = ({ data, isConnectable }) => {
|
const ToolNode: React.FC<any> = ({ data, isConnectable }) => {
|
||||||
const displayLabel = data.title || data.label || 'Инструмент';
|
const displayLabel = data.title || data.label || 'Инструмент';
|
||||||
const titleGenerated = data.title_generated;
|
// ...
|
||||||
|
|
||||||
return React.createElement(
|
return React.createElement(
|
||||||
React.Fragment,
|
BaseNodeWrapper,
|
||||||
null,
|
{ data: data, isConnectable: isConnectable },
|
||||||
React.createElement(Handle, {
|
React.createElement('div', null, displayLabel)
|
||||||
type: 'source',
|
|
||||||
position: Position.Bottom,
|
|
||||||
isConnectable: isConnectable
|
|
||||||
}),
|
|
||||||
React.createElement(Handle, {
|
|
||||||
type: 'target',
|
|
||||||
position: Position.Top,
|
|
||||||
isConnectable: isConnectable
|
|
||||||
}),
|
|
||||||
displayLabel
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Простой компонент для узла типа 'error'
|
// Простой компонент для узла типа 'error'
|
||||||
const ErrorNode: React.FC<any> = ({ data, isConnectable }) => {
|
const ErrorNode: React.FC<any> = ({ data, isConnectable }) => {
|
||||||
const displayLabel = data.title || data.label || 'Ошибка';
|
const displayLabel = data.title || data.label || 'Ошибка';
|
||||||
const titleGenerated = data.title_generated;
|
// ...
|
||||||
|
|
||||||
return React.createElement(
|
return React.createElement(
|
||||||
React.Fragment,
|
BaseNodeWrapper,
|
||||||
null,
|
{ data: data, isConnectable: isConnectable },
|
||||||
React.createElement(Handle, {
|
React.createElement('div', null, displayLabel)
|
||||||
type: 'source',
|
|
||||||
position: Position.Bottom,
|
|
||||||
isConnectable: isConnectable
|
|
||||||
}),
|
|
||||||
React.createElement(Handle, {
|
|
||||||
type: 'target',
|
|
||||||
position: Position.Top,
|
|
||||||
isConnectable: isConnectable
|
|
||||||
}),
|
|
||||||
displayLabel
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -229,10 +276,17 @@ const getNodeDimensions = (nodeElement: HTMLElement | null): { width: number; he
|
||||||
* @typedef {object} GraphPanelProps
|
* @typedef {object} GraphPanelProps
|
||||||
* @property {{ nodes: any[]; edges: any[]; }} graphData - Данные графа для отображения (узлы и ребра).
|
* @property {{ nodes: any[]; edges: any[]; }} graphData - Данные графа для отображения (узлы и ребра).
|
||||||
* @property {(nodeId: string) => void} onNodeClick - Обработчик клика по узлу.
|
* @property {(nodeId: string) => void} onNodeClick - Обработчик клика по узлу.
|
||||||
|
* @property {(nodeId: string, isSelected: boolean) => void} onNodeSelectionChange - Обработчик изменения выделения узла.
|
||||||
|
* @property {(nodeId: string) => Promise<void>} onNodeDelete - Обработчик удаления узла.
|
||||||
|
* @property {Set<string>} selectedNodeIds - Набор ID выделенных узлов.
|
||||||
*/
|
*/
|
||||||
interface GraphPanelProps {
|
interface GraphPanelProps {
|
||||||
graphData: { nodes: any[]; edges: any[] };
|
graphData: { nodes: any[]; edges: any[] };
|
||||||
onNodeClick: (nodeId: string) => void;
|
onNodeClick: (nodeId: string) => void;
|
||||||
|
onNodeSelectionChange: (nodeId: string, isSelected: boolean) => void;
|
||||||
|
onNodeDelete: (nodeId: string) => Promise<void>;
|
||||||
|
selectedNodeIds: Set<string>;
|
||||||
|
app: App;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -240,11 +294,11 @@ interface GraphPanelProps {
|
||||||
* @param {GraphPanelProps} props - Свойства компонента.
|
* @param {GraphPanelProps} props - Свойства компонента.
|
||||||
* @returns {React.ReactElement} Элемент React.
|
* @returns {React.ReactElement} Элемент React.
|
||||||
*/
|
*/
|
||||||
export const GraphPanelComponent: React.FC<GraphPanelProps> = ({ graphData, onNodeClick }) => {
|
export const GraphPanelComponent: React.FC<GraphPanelProps> = ({ graphData, onNodeClick, onNodeSelectionChange, onNodeDelete, selectedNodeIds, app }) => {
|
||||||
// ----------------------------------------------- React Hooks ---------------------------------------------------------------
|
// ----------------------------------------------- React Hooks ---------------------------------------------------------------
|
||||||
// Инициализируем состояния для узлов и рёбер React Flow
|
// Инициализируем состояния для узлов и рёбер React Flow
|
||||||
const [nodes, setNodes, onNodesChange] = useNodesState([]);
|
const [nodes, setNodes, onNodesChange] = useNodesState([]);
|
||||||
const [edges, setEdges, onEdgesChange] = useEdgesState([]);
|
const [edges, setEdges, onEdgesChange] = useEdgesState([]); // onEdgesState переименовано на onEdgesChange
|
||||||
|
|
||||||
// Используем useEffect для применения Dagre layout при изменении данных графа
|
// Используем useEffect для применения Dagre layout при изменении данных графа
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
|
|
@ -258,9 +312,17 @@ export const GraphPanelComponent: React.FC<GraphPanelProps> = ({ graphData, onNo
|
||||||
data: {
|
data: {
|
||||||
label: node.data?.label || `Node ${node.id}`,
|
label: node.data?.label || `Node ${node.id}`,
|
||||||
title: node.data?.title,
|
title: node.data?.title,
|
||||||
title_generated: node.data?.title_generated || false
|
title_generated: node.data?.title_generated || false,
|
||||||
|
// Передаем колбэк onNodeDelete в данные узла, чтобы он был доступен в CustomNode
|
||||||
|
onNodeDelete: onNodeDelete,
|
||||||
|
isSelected: selectedNodeIds.has(node.id), // Передаем состояние выделения
|
||||||
|
// Добавляем graphId и nodeId для кнопки удаления
|
||||||
|
graphId: (node.data && node.data.message && node.data.message.graph_id) ? node.data.message.graph_id : 'unknown',
|
||||||
|
nodeId: node.id,
|
||||||
|
app: app
|
||||||
},
|
},
|
||||||
position: { x: 0, y: 0 }
|
position: { x: 0, y: 0 },
|
||||||
|
className: selectedNodeIds.has(node.id) ? 'selected-node' : '', // Применяем класс для выделения
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const initialEdgesForLayout = graphData.edges.map((edge) => ({
|
const initialEdgesForLayout = graphData.edges.map((edge) => ({
|
||||||
|
|
@ -285,7 +347,7 @@ export const GraphPanelComponent: React.FC<GraphPanelProps> = ({ graphData, onNo
|
||||||
setNodes([]);
|
setNodes([]);
|
||||||
setEdges([]);
|
setEdges([]);
|
||||||
}
|
}
|
||||||
}, [graphData.nodes, graphData.edges]); // Зависимости: изменения в узлах и рёбрах графа
|
}, [graphData.nodes, graphData.edges, onNodeDelete, selectedNodeIds]); // Зависимости: изменения в узлах и рёбрах графа, колбэк удаления, выделенные узлы
|
||||||
|
|
||||||
// ----------------------------------------------- Event Handlers ------------------------------------------------------------
|
// ----------------------------------------------- Event Handlers ------------------------------------------------------------
|
||||||
/**
|
/**
|
||||||
|
|
@ -301,11 +363,45 @@ export const GraphPanelComponent: React.FC<GraphPanelProps> = ({ graphData, onNo
|
||||||
*/
|
*/
|
||||||
const nodeClickHandler = React.useCallback(
|
const nodeClickHandler = React.useCallback(
|
||||||
(event: React.MouseEvent, node: any) => {
|
(event: React.MouseEvent, node: any) => {
|
||||||
|
if (event.shiftKey) {
|
||||||
|
// Если зажат Shift, переключаем выделение
|
||||||
|
onNodeSelectionChange(node.id, !selectedNodeIds.has(node.id));
|
||||||
|
} else {
|
||||||
|
// Иначе, это обычный клик по узлу
|
||||||
onNodeClick(node.id);
|
onNodeClick(node.id);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
[onNodeClick]
|
[onNodeClick, onNodeSelectionChange, selectedNodeIds]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Добавляем обработчик для ReactFlow, чтобы отслеживать клики по пустому пространству
|
||||||
|
const onPaneClick = React.useCallback((event: React.MouseEvent) => {
|
||||||
|
// Проверяем, был ли клик по пустому пространству (не по узлу или ребру)
|
||||||
|
const target = event.target as HTMLElement;
|
||||||
|
if (!target.closest('.react-flow__node') && !target.closest('.react-flow__edge')) {
|
||||||
|
// Если клик был не по узлу и не по ребру, сбрасываем выделение
|
||||||
|
if (selectedNodeIds.size > 0) {
|
||||||
|
// Вызываем колбэк для сброса всех выделений
|
||||||
|
selectedNodeIds.forEach(nodeId => onNodeSelectionChange(nodeId, false));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [selectedNodeIds, onNodeSelectionChange]);
|
||||||
|
|
||||||
|
// Обработчик для клавиатуры (Escape)
|
||||||
|
React.useEffect(() => {
|
||||||
|
const handleKeyDown = (event: KeyboardEvent) => {
|
||||||
|
if (event.key === 'Escape') {
|
||||||
|
if (selectedNodeIds.size > 0) {
|
||||||
|
selectedNodeIds.forEach(nodeId => onNodeSelectionChange(nodeId, false));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener('keydown', handleKeyDown);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('keydown', handleKeyDown);
|
||||||
|
};
|
||||||
|
}, [selectedNodeIds, onNodeSelectionChange]);
|
||||||
|
|
||||||
// ----------------------------------------------- Render --------------------------------------------------------------------
|
// ----------------------------------------------- Render --------------------------------------------------------------------
|
||||||
return React.createElement(
|
return React.createElement(
|
||||||
'div',
|
'div',
|
||||||
|
|
@ -323,6 +419,7 @@ export const GraphPanelComponent: React.FC<GraphPanelProps> = ({ graphData, onNo
|
||||||
onEdgesChange, // Обработчик изменения рёбер
|
onEdgesChange, // Обработчик изменения рёбер
|
||||||
onConnect, // Обработчик подключения
|
onConnect, // Обработчик подключения
|
||||||
onNodeClick: nodeClickHandler, // Обработчик клика по узлу
|
onNodeClick: nodeClickHandler, // Обработчик клика по узлу
|
||||||
|
onPaneClick: onPaneClick, // Обработчик клика по панели
|
||||||
fitView: true, // Автоматически центрирует и масштабирует граф при загрузке
|
fitView: true, // Автоматически центрирует и масштабирует граф при загрузке
|
||||||
attributionPosition: 'bottom-right', // Позиция атрибуции React Flow
|
attributionPosition: 'bottom-right', // Позиция атрибуции React Flow
|
||||||
nodeTypes,
|
nodeTypes,
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,8 @@ export interface ChatHistoryItem {
|
||||||
role: string;
|
role: string;
|
||||||
content: string;
|
content: string;
|
||||||
type?: 'user' | 'llm' | 'tool' | 'error' | 'default';
|
type?: 'user' | 'llm' | 'tool' | 'error' | 'default';
|
||||||
node_id?: string;
|
node_id: string;
|
||||||
|
graph_id?: string;
|
||||||
// TODO: добавить поля для снапшотов файлов при реализации
|
// TODO: добавить поля для снапшотов файлов при реализации
|
||||||
// fileSnapshots?: Record<string, string>;
|
// fileSnapshots?: Record<string, string>;
|
||||||
// expandedContent?: string; // содержимое после раскрытия ссылок
|
// expandedContent?: string; // содержимое после раскрытия ссылок
|
||||||
|
|
|
||||||
|
|
@ -165,69 +165,6 @@
|
||||||
user-select: text; /* Разрешить выделение текста внутри содержимого */
|
user-select: text; /* Разрешить выделение текста внутри содержимого */
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Стили для узлов графа */
|
|
||||||
.react-flow__node-default,
|
|
||||||
.react-flow__node-user,
|
|
||||||
.react-flow__node-llm,
|
|
||||||
.react-flow__node-tool,
|
|
||||||
.react-flow__node-error,
|
|
||||||
.react-flow__node-assistant {
|
|
||||||
padding: var(--node-padding);
|
|
||||||
border-radius: var(--node-border-radius);
|
|
||||||
text-align: var(--node-text-align);
|
|
||||||
font-size: var(--node-font-size);
|
|
||||||
font-weight: var(--node-font-weight);
|
|
||||||
color: var(--node-text-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.react-flow__node-user {
|
|
||||||
border: 1px solid var(--llm-user-border-color); /* Цвет для user */
|
|
||||||
background-color: var(
|
|
||||||
--llm-user-background-color
|
|
||||||
); /* Светло-голубой фон для user */
|
|
||||||
}
|
|
||||||
|
|
||||||
.react-flow__node-llm {
|
|
||||||
border: 1px solid var(--llm-llm-border-color); /* Цвет для llm */
|
|
||||||
background-color: var(
|
|
||||||
--llm-llm-background-color
|
|
||||||
); /* Светло-зеленый фон для llm */
|
|
||||||
}
|
|
||||||
|
|
||||||
.react-flow__node-tool {
|
|
||||||
border: 1px solid var(--llm-tool-border-color); /* Цвет для tool */
|
|
||||||
background-color: var(
|
|
||||||
--llm-tool-background-color
|
|
||||||
); /* Светло-оранжевый фон для tool */
|
|
||||||
}
|
|
||||||
|
|
||||||
.react-flow__node-error {
|
|
||||||
border: 1px solid var(--llm-error-border-color); /* Красный цвет для error */
|
|
||||||
background-color: var(
|
|
||||||
--llm-error-background-color
|
|
||||||
); /* Светло-красный фон для error */
|
|
||||||
}
|
|
||||||
|
|
||||||
.react-flow__node-assistant {
|
|
||||||
border: 1px solid var(--llm-assistant-border-color); /* Нейтральный серый для ассистента */
|
|
||||||
background-color: var(--llm-assistant-background-color); /* Светло-серый фон */
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Компактные стили для узлов React Flow */
|
|
||||||
.react-flow__node {
|
|
||||||
font-size: 12px !important;
|
|
||||||
padding: 4px 8px !important;
|
|
||||||
min-width: 100px !important;
|
|
||||||
max-width: 130px !important;
|
|
||||||
min-height: 30px !important;
|
|
||||||
border-radius: 6px !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.react-flow__node-default {
|
|
||||||
width: auto !important;
|
|
||||||
height: auto !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ----------------------------------------------- Chat Panel Styles -------------------------------------------------------- */
|
/* ----------------------------------------------- Chat Panel Styles -------------------------------------------------------- */
|
||||||
|
|
||||||
.llm-agent-chat-panel {
|
.llm-agent-chat-panel {
|
||||||
|
|
@ -559,73 +496,6 @@
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ----------------------------------------------- History Panel Styles --------------------------------------------------- */
|
|
||||||
|
|
||||||
.llm-agent-history-sidebar {
|
|
||||||
border-right: 1px solid var(--background-modifier-border);
|
|
||||||
overflow-y: auto;
|
|
||||||
background-color: var(--background-secondary);
|
|
||||||
transition: width 0.3s ease, min-width 0.3s ease, border-right 0.3s ease;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.llm-agent-history-sidebar.expanded {
|
|
||||||
width: 200px;
|
|
||||||
min-width: 200px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.llm-agent-history-sidebar.collapsed {
|
|
||||||
width: 0;
|
|
||||||
min-width: 0;
|
|
||||||
border-right: none;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Стили для заголовков графов в панели истории */
|
|
||||||
.graph-title-generated {
|
|
||||||
color: var(--text-normal);
|
|
||||||
}
|
|
||||||
|
|
||||||
.graph-title-pending {
|
|
||||||
color: var(--text-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ----------------------------------------------- Toggle Button Styles --------------------------------------------------- */
|
|
||||||
|
|
||||||
.history-toggle-button {
|
|
||||||
position: absolute;
|
|
||||||
top: 10px;
|
|
||||||
left: 10px;
|
|
||||||
z-index: 9999; /* Увеличиваем z-index, чтобы быть выше React Flow */
|
|
||||||
width: 32px;
|
|
||||||
height: 32px;
|
|
||||||
border: none;
|
|
||||||
border-radius: 6px;
|
|
||||||
background-color: var(--background-primary);
|
|
||||||
border: 1px solid var(--border-color-light);
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: 16px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
|
|
||||||
transition: all 0.2s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.history-toggle-button:hover {
|
|
||||||
background-color: var(--background-modifier-hover);
|
|
||||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.2);
|
|
||||||
transform: translateY(-1px);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ----------------------------------------------- Graph Container Styles -------------------------------------------------- */
|
|
||||||
|
|
||||||
.llm-agent-graph-container {
|
|
||||||
flex: 1;
|
|
||||||
position: relative;
|
|
||||||
background-color: var(--background-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ----------------------------------------------- Reference System Styles --------------------------------------------------- */
|
/* ----------------------------------------------- Reference System Styles --------------------------------------------------- */
|
||||||
|
|
||||||
/* Reference Boxes */
|
/* Reference Boxes */
|
||||||
|
|
@ -864,3 +734,183 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ----------------------------------------------- Graph Panel Styles --------------------------------------------------------------- */
|
||||||
|
|
||||||
|
.llm-agent-graph-main {
|
||||||
|
display: flex;
|
||||||
|
height: 100%;
|
||||||
|
width: 100%;
|
||||||
|
/* background-color: var(--background-primary); */
|
||||||
|
}
|
||||||
|
|
||||||
|
.llm-agent-history-sidebar {
|
||||||
|
border-right: 1px solid var(--background-modifier-border);
|
||||||
|
overflow-y: auto;
|
||||||
|
background-color: var(--background-secondary);
|
||||||
|
transition: width 0.3s ease, min-width 0.3s ease, border-right 0.3s ease;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.llm-agent-history-sidebar.expanded {
|
||||||
|
width: 200px;
|
||||||
|
min-width: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.llm-agent-history-sidebar.collapsed {
|
||||||
|
width: 0;
|
||||||
|
min-width: 0;
|
||||||
|
border-right: none;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.graph-title-generated {
|
||||||
|
color: var(--text-normal);
|
||||||
|
}
|
||||||
|
|
||||||
|
.graph-title-pending {
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.react-flow__node-default,
|
||||||
|
.react-flow__node-user,
|
||||||
|
.react-flow__node-llm,
|
||||||
|
.react-flow__node-tool,
|
||||||
|
.react-flow__node-error,
|
||||||
|
.react-flow__node-assistant {
|
||||||
|
padding: var(--node-padding);
|
||||||
|
border-radius: var(--node-border-radius);
|
||||||
|
text-align: var(--node-text-align);
|
||||||
|
font-size: var(--node-font-size);
|
||||||
|
font-weight: var(--node-font-weight);
|
||||||
|
color: var(--node-text-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.react-flow__node-user {
|
||||||
|
border: 1px solid var(--llm-user-border-color);
|
||||||
|
background-color: var(
|
||||||
|
--llm-user-background-color
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
.react-flow__node-llm {
|
||||||
|
border: 1px solid var(--llm-llm-border-color);
|
||||||
|
background-color: var(
|
||||||
|
--llm-llm-background-color
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
.react-flow__node-tool {
|
||||||
|
border: 1px solid var(--llm-tool-border-color);
|
||||||
|
background-color: var(
|
||||||
|
--llm-tool-background-color
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
.react-flow__node-error {
|
||||||
|
border: 1px solid var(--llm-error-border-color);
|
||||||
|
background-color: var(
|
||||||
|
--llm-error-background-color
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
.react-flow__node-assistant {
|
||||||
|
border: 1px solid var(--llm-assistant-border-color);
|
||||||
|
background-color: var(--llm-assistant-background-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.react-flow__node {
|
||||||
|
font-size: 12px !important;
|
||||||
|
padding: 4px 8px !important;
|
||||||
|
min-width: 100px !important;
|
||||||
|
max-width: 130px !important;
|
||||||
|
min-height: 30px !important;
|
||||||
|
border-radius: 6px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.react-flow__node-default {
|
||||||
|
width: auto !important;
|
||||||
|
height: auto !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* Стиль для выделенных узлов */
|
||||||
|
.react-flow__node.selected-node {
|
||||||
|
border-color: var(--interactive-accent-hover); /* Яркая рамка */
|
||||||
|
box-shadow: 0 0 0 2px var(--interactive-accent-hover); /* Более заметная тень */
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* Стиль для кнопок внутри узла (например, кнопки удаления) */
|
||||||
|
.react-flow__node button.node-delete-button {
|
||||||
|
background: var(--background-primary);
|
||||||
|
border: 1px solid var(--background-modifier-border);
|
||||||
|
border-radius: 4px;
|
||||||
|
color: var(--text-error); /* Цвет для кнопки удаления */
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.8em;
|
||||||
|
padding: 3px 6px;
|
||||||
|
margin-top: 5px;
|
||||||
|
transition: all 0.2s ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.react-flow__node button.node-delete-button:hover {
|
||||||
|
background-color: var(--background-modifier-error-hover);
|
||||||
|
color: var(--text-on-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* Стиль для рёбер */
|
||||||
|
.react-flow__edge-path {
|
||||||
|
stroke: var(--background-modifier-border);
|
||||||
|
stroke-width: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Общие стили для глобальных кнопок действий */
|
||||||
|
.graph-view-toolbar {
|
||||||
|
position: absolute;
|
||||||
|
top: 10px;
|
||||||
|
left: 10px; /* Рядом с кнопкой переключения */
|
||||||
|
z-index: 50;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column; /* Вертикальное расположение */
|
||||||
|
gap: 10px; /* Отступ между кнопками */
|
||||||
|
}
|
||||||
|
|
||||||
|
.global-action-button {
|
||||||
|
background-color: var(--background-secondary-alt);
|
||||||
|
border: 1px solid var(--background-modifier-border);
|
||||||
|
height: auto;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 10px 10px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 1.2em;
|
||||||
|
color: var(--text-normal);
|
||||||
|
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.1);
|
||||||
|
transition: background-color 0.2s ease, color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.global-action-button:hover {
|
||||||
|
background-color: var(--background-modifier-hover);
|
||||||
|
color: var(--interactive-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tooltip стили для глобальных кнопок */
|
||||||
|
[data-tooltip]:hover:after {
|
||||||
|
content: attr(data-tooltip);
|
||||||
|
position: absolute;
|
||||||
|
left: calc(100% + 10px); /* Справа от кнопки */
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
white-space: nowrap;
|
||||||
|
background-color: var(--background-modifier-hover);
|
||||||
|
color: var(--text-normal);
|
||||||
|
padding: 5px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.8em;
|
||||||
|
z-index: 100; /* Чтобы быть над всем */
|
||||||
|
}
|
||||||
69
src/docs/Deletion-Spec.md
Normal file
69
src/docs/Deletion-Spec.md
Normal file
|
|
@ -0,0 +1,69 @@
|
||||||
|
Привет! Отличные идеи для расширения функциональности. Давай обсудим каждую из них, чтобы найти оптимальный подход, учитывая сложность реализации, особенно в части удаления узлов графа.
|
||||||
|
|
||||||
|
### 1. Удаление узла на графе и сообщения в чате
|
||||||
|
|
||||||
|
Это действительно важное улучшение для управления диалогами.
|
||||||
|
|
||||||
|
**А. Удаление сообщения в ChatPanel:**
|
||||||
|
|
||||||
|
* **UI:** Добавляем кнопку "Удалить" рядом с кнопкой "Копировать" для каждого сообщения.
|
||||||
|
* **Действие:** Когда пользователь нажимает "Удалить" сообщение в чате, это означает удаление соответствующего узла в графе.
|
||||||
|
* **Сложность переподключения узлов:** Это самый сложный момент. Ты предложил: "если это не лист графа а в серединке, то нужно детей узла привязать к детям отцовского узла, причем так, что сначала идут родные дети отца а потом добавляются дети удаляемого узла".
|
||||||
|
* Узел может иметь несколько "отцов" (входящих ребер) и несколько "детей" (исходящих ребер).
|
||||||
|
* При удалении узла X, который является промежуточным между родителем P и ребенком C:
|
||||||
|
* Мы должны удалить узел X и все ребра, связанные с X.
|
||||||
|
* Затем создать новые ребра, соединяющие P с C.
|
||||||
|
* Если у X несколько родителей (P1, P2) и несколько детей (C1, C2), то нужно будет создать ребра P1->C1, P1->C2, P2->C1, P2->C2.
|
||||||
|
* **Порядок детей:** Твоё требование про сохранение порядка "родные дети отца, потом дети удаляемого узла" очень специфично и сложно в реализации, так как порядок ребер в графовых библиотеках обычно не имеет смысла, и визуальное расположение Dagre будет его определять. Возможно, достаточно будет просто создать новые ребра между родителями удаляемого узла и его детьми. Как ты думаешь, будет ли простое переподключение без сохранения строгой последовательности (полагаясь на Dagre для компоновки) достаточным?
|
||||||
|
* **Обновление `current_node_id`:** Если удаленный узел был `current_node_id`, то `current_node_id` должен быть установлен на последнего родителя удаленного узла (в пределах текущей ветки чата). Если родителей нет (удалили корень), то чат становится пустым.
|
||||||
|
* **Влияние на другие панели:** Удаление узла должно обновить историю в `ChatPanel` (удалив сообщение) и перерисовать граф в `GraphView`.
|
||||||
|
* **API:** Потребуется новый API-эндпоинт на бэкенде: `DELETE /api/graphs/<graph_id>/nodes/<node_id>`. Бэкенд должен будет реализовать логику переподключения узлов и соответствующего обновления ребер.
|
||||||
|
|
||||||
|
**Б. Удаление узла в GraphPanel:**
|
||||||
|
|
||||||
|
* **UI:** Аналогично, кнопка удаления на каждом узле.
|
||||||
|
* **Действие:** То же, что и при удалении сообщения.
|
||||||
|
* **Backend:** Тот же API-эндпоинт и логика переподключения.
|
||||||
|
|
||||||
|
### 2. Множественное выделение узлов на графе
|
||||||
|
|
||||||
|
Это отличная фича для удобной работы с большим графом!
|
||||||
|
|
||||||
|
* **Выделение:**
|
||||||
|
* `Shift + ЛКМ`: Согласен, это стандартное поведение для множественного выделения.
|
||||||
|
* **Визуальное выделение:** Выделенные узлы должны как-то выделяться (например, изменением цвета рамки или фона). Это чисто фронтенд-логика.
|
||||||
|
* **Порядок выделения:** Да, важно сохранять порядок для операции "Копировать все узлы". Будем хранить массив ID выделенных узлов.
|
||||||
|
* **Сброс выделения:**
|
||||||
|
* `Escape`: Хорошая идея. Нажатие `Escape` должно сбрасывать все выделения.
|
||||||
|
* `ЛКМ` вне узлов: Также можно добавить сброс выделения при клике левой кнопкой мыши на пустом пространстве графа.
|
||||||
|
* **Глобальные кнопки действий:**
|
||||||
|
* Где их разместить? Предлагаю добавить панель действий в `GraphView` (возможно, внизу или сверху `graphContainer`), которая будет появляться, когда есть хотя бы один выделенный узел.
|
||||||
|
* **"Копировать все узлы"**:
|
||||||
|
* Действие: Собирает `content` из `node.data.message.content` всех выделенных узлов в порядке их выделения, объединяет их (например, через `\n\n`) и копирует в буфер обмена. Это чисто фронтенд-логика.
|
||||||
|
* **"Удалить все выделенные узлы"**:
|
||||||
|
* **Сложность:** Это будет еще более сложной операцией, чем удаление одиночного узла, особенно если выделенные узлы образуют цепочку или подграф.
|
||||||
|
* **Предложение по упрощению:** На первом этапе, предлагаю реализовать удаление для **листовых узлов** или узлов, у которых **нет потомков среди выделенных узлов**, чтобы избежать сложной рекурсивной логики переподключения. Или, если у выделенного узла есть дети, то эти дети также должны быть удалены. Это делает удаление "каскадным". Это нужно обсудить.
|
||||||
|
* Для начала, предлагаю сосредоточиться на **одиночном удалении с переподключением** и **множественном выделении с копированием**. Множественное удаление промежуточных узлов отложим на следующий этап.
|
||||||
|
|
||||||
|
### 3. Перерисовка графа после удаления
|
||||||
|
|
||||||
|
* **React Flow + Dagre:** React Flow сам по себе очень эффективен и инкрементально обновляет DOM при изменении пропсов `nodes` и `edges`.
|
||||||
|
* **Dagre Layout:** Однако, когда ты меняешь структуру графа (добавляешь/удаляешь узлы/ребра), перерасчет расположения узлов с помощью Dagre (`getLayoutedElements`) обычно желателен для поддержания читаемости графа.
|
||||||
|
* **Вывод:** Да, после удаления узла и изменения связей, нам, скорее всего, потребуется полный перерасчет расположения графа с помощью Dagre, чтобы он выглядел аккуратно. Это не будет критичной проблемой производительности для типичных размеров наших графов.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Предлагаемый план действий для обсуждения:**
|
||||||
|
|
||||||
|
1. **Удаление одиночного узла/сообщения:**
|
||||||
|
* Согласовать логику переподключения узлов: достаточно ли просто создать ребра между родителями и детьми удаляемого узла, или строгий порядок критичен? Предлагаю упрощенное переподключение.
|
||||||
|
* Как должен меняться `current_node_id` после удаления? (Предложение: к ближайшему родителю).
|
||||||
|
* Подтвердить, что это потребует изменений в `ChatPanel`, `GraphView`, `api.py` и `graph_history_manager.py`.
|
||||||
|
2. **Множественное выделение (frontend):**
|
||||||
|
* Подтвердить `Shift + ЛКМ`, визуальное выделение, `Escape` для сброса.
|
||||||
|
* Согласовать место для кнопок "Копировать все" и "Удалить все".
|
||||||
|
3. **Множественное удаление (отложенный функционал):**
|
||||||
|
* Признать высокую сложность этой операции для нелистовых узлов.
|
||||||
|
* Предложить отложить её до полной реализации и стабилизации одиночного удаления и множественного копирования.
|
||||||
|
|
||||||
|
Давай начнем с обсуждения логики переподключения узлов при одиночном удалении и твоего видения по поводу порядка детей!
|
||||||
101
src/utils/Dialog.ts
Normal file
101
src/utils/Dialog.ts
Normal file
|
|
@ -0,0 +1,101 @@
|
||||||
|
import { App, Modal } from 'obsidian';
|
||||||
|
|
||||||
|
type DialogType = 'alert' | 'confirm' | 'prompt';
|
||||||
|
|
||||||
|
export class Dialog extends Modal {
|
||||||
|
private type: DialogType;
|
||||||
|
private message: string;
|
||||||
|
private resolveFn: (value: any) => void;
|
||||||
|
private inputValue: string = '';
|
||||||
|
private placeholder: string;
|
||||||
|
|
||||||
|
constructor(app: App, type: DialogType, message: string, placeholder = '') {
|
||||||
|
super(app);
|
||||||
|
this.type = type;
|
||||||
|
this.message = message;
|
||||||
|
this.placeholder = placeholder;
|
||||||
|
}
|
||||||
|
|
||||||
|
open(): Promise<any> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
this.resolveFn = resolve;
|
||||||
|
super.open();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
onOpen() {
|
||||||
|
const { contentEl } = this;
|
||||||
|
contentEl.empty();
|
||||||
|
|
||||||
|
// Создаем сообщение
|
||||||
|
contentEl.createEl('div', { text: this.message, cls: 'dialog-message' });
|
||||||
|
|
||||||
|
// Если prompt — добавляем input
|
||||||
|
if (this.type === 'prompt') {
|
||||||
|
const input = contentEl.createEl('input', {
|
||||||
|
type: 'text',
|
||||||
|
cls: 'dialog-input',
|
||||||
|
attr: { placeholder: this.placeholder }
|
||||||
|
}) as HTMLInputElement;
|
||||||
|
|
||||||
|
input.oninput = () => {
|
||||||
|
this.inputValue = input.value;
|
||||||
|
};
|
||||||
|
|
||||||
|
input.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Контейнер для кнопок
|
||||||
|
const buttonsDiv = contentEl.createDiv({ cls: 'dialog-buttons' });
|
||||||
|
|
||||||
|
if (this.type === 'alert') {
|
||||||
|
// Только OK
|
||||||
|
const okBtn = buttonsDiv.createEl('button', { text: 'OK', cls: 'mod-cta' });
|
||||||
|
okBtn.onclick = () => {
|
||||||
|
this.resolveFn(undefined);
|
||||||
|
this.close();
|
||||||
|
};
|
||||||
|
} else if (this.type === 'confirm' || this.type === 'prompt') {
|
||||||
|
// Отмена
|
||||||
|
const cancelBtn = buttonsDiv.createEl('button', { text: 'Отмена' });
|
||||||
|
cancelBtn.onclick = () => {
|
||||||
|
this.resolveFn(this.type === 'prompt' ? null : false);
|
||||||
|
this.close();
|
||||||
|
};
|
||||||
|
|
||||||
|
// OK
|
||||||
|
const okBtn = buttonsDiv.createEl('button', { text: 'OK', cls: 'mod-cta' });
|
||||||
|
okBtn.onclick = () => {
|
||||||
|
if (this.type === 'prompt') {
|
||||||
|
this.resolveFn(this.inputValue);
|
||||||
|
} else {
|
||||||
|
this.resolveFn(true);
|
||||||
|
}
|
||||||
|
this.close();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onClose() {
|
||||||
|
this.contentEl.empty();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Статические методы для удобства вызова
|
||||||
|
|
||||||
|
static show(app: App, type: DialogType, message: string, placeholder = ''): Promise<any> {
|
||||||
|
const dialog = new Dialog(app, type, message, placeholder);
|
||||||
|
return dialog.open();
|
||||||
|
}
|
||||||
|
|
||||||
|
static alert(app: App, message: string): Promise<void> {
|
||||||
|
return Dialog.show(app, 'alert', message);
|
||||||
|
}
|
||||||
|
|
||||||
|
static confirm(app: App, message: string): Promise<boolean> {
|
||||||
|
return Dialog.show(app, 'confirm', message);
|
||||||
|
}
|
||||||
|
|
||||||
|
static prompt(app: App, message: string, placeholder = ''): Promise<string | null> {
|
||||||
|
return Dialog.show(app, 'prompt', message, placeholder);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -4,13 +4,14 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
// ----------------------------------------------- Obsidian Imports ----------------------------------------------------------
|
// ----------------------------------------------- Obsidian Imports ----------------------------------------------------------
|
||||||
import { ItemView, WorkspaceLeaf } from 'obsidian';
|
import { ItemView, WorkspaceLeaf, Notice } from 'obsidian';
|
||||||
// ----------------------------------------------- Plugin Imports ------------------------------------------------------------
|
// ----------------------------------------------- Plugin Imports ------------------------------------------------------------
|
||||||
import LLMAgentPlugin from 'main'; // Предполагаем, что main.ts экспортирует LLMAgentPlugin по умолчанию
|
import LLMAgentPlugin from 'main'; // Предполагаем, что main.ts экспортирует LLMAgentPlugin по умолчанию
|
||||||
import { HistoryPanel } from '../components/HistoryPanel'; // Предполагаем, что HistoryPanel это JS-класс
|
import { HistoryPanel } from '../components/HistoryPanel'; // Предполагаем, что HistoryPanel это JS-класс
|
||||||
import { GraphPanelComponent } from '../components/GraphPanel'; // Импортируем React-компонент и ReactDOM
|
import { GraphPanelComponent } from '../components/GraphPanel'; // Импортируем React-компонент и ReactDOM
|
||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import * as ReactDOM from 'react-dom/client'; // <-- ИСПРАВЛЕНО: Импортируем 'react-dom/client' для createRoot
|
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';
|
export const GRAPH_VIEW_TYPE = 'llm-agent-graph-view';
|
||||||
|
|
||||||
|
|
@ -22,6 +23,7 @@ export class GraphView extends ItemView {
|
||||||
currentNode: string | null;
|
currentNode: string | null;
|
||||||
graphsList: any[];
|
graphsList: any[];
|
||||||
selectedGraphId: string | null;
|
selectedGraphId: string | null;
|
||||||
|
selectedNodeIds: Set<string>; // Добавляем Set для хранения ID выделенных узлов
|
||||||
|
|
||||||
// UI Elements
|
// UI Elements
|
||||||
historyPanel: HistoryPanel | null;
|
historyPanel: HistoryPanel | null;
|
||||||
|
|
@ -31,6 +33,10 @@ export class GraphView extends ItemView {
|
||||||
toggleButton: HTMLButtonElement;
|
toggleButton: HTMLButtonElement;
|
||||||
isHistoryCollapsed: boolean;
|
isHistoryCollapsed: boolean;
|
||||||
|
|
||||||
|
// Глобальные кнопки действий
|
||||||
|
copySelectedButton: HTMLButtonElement | null = null;
|
||||||
|
deleteSelectedButton: HTMLButtonElement | null = null;
|
||||||
|
|
||||||
constructor(leaf: WorkspaceLeaf, plugin: LLMAgentPlugin) {
|
constructor(leaf: WorkspaceLeaf, plugin: LLMAgentPlugin) {
|
||||||
super(leaf);
|
super(leaf);
|
||||||
this.plugin = plugin;
|
this.plugin = plugin;
|
||||||
|
|
@ -39,8 +45,9 @@ export class GraphView extends ItemView {
|
||||||
this.graphsList = [];
|
this.graphsList = [];
|
||||||
this.selectedGraphId = null;
|
this.selectedGraphId = null;
|
||||||
this.historyPanel = null;
|
this.historyPanel = null;
|
||||||
this.reactRoot = null; // Инициализируем null
|
this.reactRoot = null;
|
||||||
this.isHistoryCollapsed = true;
|
this.isHistoryCollapsed = true;
|
||||||
|
this.selectedNodeIds = new Set<string>(); // Инициализируем Set
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -78,12 +85,11 @@ export class GraphView extends ItemView {
|
||||||
display: flex;
|
display: flex;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
background-color: var(--background-primary); /* Убедимся, что фон установлен */
|
background-color: var(--background-primary);
|
||||||
`;
|
`;
|
||||||
|
|
||||||
// Левая панель для истории
|
// Левая панель для истории
|
||||||
this.historyContainer = mainContainer.createDiv('llm-agent-history-sidebar') as HTMLDivElement;
|
this.historyContainer = mainContainer.createDiv('llm-agent-history-sidebar') as HTMLDivElement;
|
||||||
// Убираем инлайн стили - теперь в CSS
|
|
||||||
this.historyContainer.classList.add(this.isHistoryCollapsed ? 'collapsed' : 'expanded');
|
this.historyContainer.classList.add(this.isHistoryCollapsed ? 'collapsed' : 'expanded');
|
||||||
|
|
||||||
// Правая панель для графа
|
// Правая панель для графа
|
||||||
|
|
@ -91,7 +97,7 @@ export class GraphView extends ItemView {
|
||||||
this.graphContainer.style.cssText = `
|
this.graphContainer.style.cssText = `
|
||||||
flex: 1;
|
flex: 1;
|
||||||
position: relative;
|
position: relative;
|
||||||
background-color: var(--background-primary); /* Убедимся, что фон установлен */
|
background-color: var(--background-primary);
|
||||||
`;
|
`;
|
||||||
|
|
||||||
// Инициализируем панель истории
|
// Инициализируем панель истории
|
||||||
|
|
@ -100,15 +106,17 @@ export class GraphView extends ItemView {
|
||||||
eventBus: this.plugin.eventBus
|
eventBus: this.plugin.eventBus
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Создаем кнопки переключения и глобальные кнопки действий
|
||||||
|
this.addToggleButton();
|
||||||
|
this.addGlobalActionButtons(); // Добавляем вызов новой функции
|
||||||
|
|
||||||
// Инициализируем React Flow
|
// Инициализируем React Flow
|
||||||
this.initializeGraphPanel();
|
this.initializeGraphPanel();
|
||||||
|
|
||||||
// Создаем кнопку переключения
|
|
||||||
this.addToggleButton();
|
|
||||||
|
|
||||||
// Подписываемся на события
|
// Подписываемся на события
|
||||||
this.plugin.eventBus.on('new-chat', this.handleNewChat.bind(this));
|
this.plugin.eventBus.on('new-chat', this.handleNewChat.bind(this));
|
||||||
this.plugin.eventBus.on('graph-selected', this.handleGraphSelected.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();
|
await this.fetchGraphsList();
|
||||||
|
|
@ -131,7 +139,8 @@ export class GraphView extends ItemView {
|
||||||
}
|
}
|
||||||
|
|
||||||
this.plugin.eventBus.off('new-chat', this.handleNewChat);
|
this.plugin.eventBus.off('new-chat', this.handleNewChat);
|
||||||
this.plugin.eventBus.off('graph-selected', this.handleGraphSelected); // Отписываемся и отсюда
|
this.plugin.eventBus.off('graph-selected', this.handleGraphSelected);
|
||||||
|
this.plugin.eventBus.off('delete-node', this.handleDeleteNodeFromChat);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ----------------------------------------------- History Panel Toggle --------------------------------------------------
|
// ----------------------------------------------- History Panel Toggle --------------------------------------------------
|
||||||
|
|
@ -155,14 +164,50 @@ export class GraphView extends ItemView {
|
||||||
|
|
||||||
// ----------------------------------------------- Graph Panel Integration --------------------------------------------------
|
// ----------------------------------------------- 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.
|
* Инициализирует GraphPanelComponent (React компонент) в graphContainer.
|
||||||
* Использует ReactDOM.createRoot для React 18.
|
* Использует ReactDOM.createRoot для React 18.
|
||||||
*/
|
*/
|
||||||
initializeGraphPanel(): void {
|
initializeGraphPanel(): void {
|
||||||
// Создаем контейнер для React Flow
|
this.graphContainer.empty();
|
||||||
// Убедимся, что id уникален или используем ref для React 18
|
// Перемещаем кнопки обратно после очистки, если они были частью graphContainer
|
||||||
this.graphContainer.empty(); // Очищаем контейнер перед добавлением нового элемента
|
this.addToggleButton();
|
||||||
|
this.addGlobalActionButtons();
|
||||||
|
|
||||||
const wrapper = this.graphContainer.createDiv();
|
const wrapper = this.graphContainer.createDiv();
|
||||||
wrapper.style.cssText = 'width: 100%; height: 100%;';
|
wrapper.style.cssText = 'width: 100%; height: 100%;';
|
||||||
|
|
||||||
|
|
@ -171,7 +216,6 @@ export class GraphView extends ItemView {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Создаем корневой элемент React 18
|
|
||||||
if (ReactDOM && typeof ReactDOM.createRoot === 'function') {
|
if (ReactDOM && typeof ReactDOM.createRoot === 'function') {
|
||||||
this.reactRoot = ReactDOM.createRoot(wrapper);
|
this.reactRoot = ReactDOM.createRoot(wrapper);
|
||||||
this.renderGraphPanel();
|
this.renderGraphPanel();
|
||||||
|
|
@ -180,16 +224,6 @@ export class GraphView extends ItemView {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
addToggleButton(): void {
|
|
||||||
console.log('Creating toggle button...');
|
|
||||||
this.toggleButton = document.createElement('button');
|
|
||||||
this.toggleButton.className = 'history-toggle-button';
|
|
||||||
this.toggleButton.textContent = this.isHistoryCollapsed ? '📋' : '❌';
|
|
||||||
this.toggleButton.addEventListener('click', this.toggleHistoryPanel.bind(this));
|
|
||||||
this.graphContainer.appendChild(this.toggleButton);
|
|
||||||
console.log('Toggle button created and appended:', this.toggleButton);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Рендерит или обновляет GraphPanelComponent в DOM.
|
* Рендерит или обновляет GraphPanelComponent в DOM.
|
||||||
*/
|
*/
|
||||||
|
|
@ -201,14 +235,18 @@ export class GraphView extends ItemView {
|
||||||
nodes: this.graphData.nodes,
|
nodes: this.graphData.nodes,
|
||||||
edges: this.graphData.edges
|
edges: this.graphData.edges
|
||||||
},
|
},
|
||||||
onNodeClick: this.handleNodeClick.bind(this)
|
onNodeClick: this.handleNodeClick.bind(this),
|
||||||
|
onNodeSelectionChange: this.handleNodeSelectionChange.bind(this), // Передаем обработчик изменения выделения
|
||||||
|
onNodeDelete: this.handleDeleteNode.bind(this), // Передаем колбэк для удаления одиночного узла
|
||||||
|
selectedNodeIds: this.selectedNodeIds, // Передаем Set выделенных узлов
|
||||||
|
app: this.plugin.app
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
console.warn('React root не инициализирован для рендера GraphPanelComponent.');
|
console.warn('React root не инициализирован для рендера GraphPanelComponent.');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// ... (остальные методы API и Event Handlers без изменений)
|
|
||||||
// ----------------------------------------------- API Methods ---------------------------------------------------------------
|
// ----------------------------------------------- API Methods ---------------------------------------------------------------
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -242,10 +280,11 @@ export class GraphView extends ItemView {
|
||||||
async fetchGraphData(graphId: string): Promise<void> {
|
async fetchGraphData(graphId: string): Promise<void> {
|
||||||
try {
|
try {
|
||||||
if (!graphId) {
|
if (!graphId) {
|
||||||
// Если graphId пуст, очищаем граф
|
|
||||||
this.graphData = { nodes: [], edges: [] };
|
this.graphData = { nodes: [], edges: [] };
|
||||||
this.currentNode = null;
|
this.currentNode = null;
|
||||||
|
this.selectedNodeIds.clear(); // Очищаем выделения
|
||||||
this.renderGraphPanel();
|
this.renderGraphPanel();
|
||||||
|
this.plugin.eventBus.emit('chat-history-updated', { graphId: null, messages: [], current_node_id: null });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -264,6 +303,14 @@ export class GraphView extends ItemView {
|
||||||
|
|
||||||
// Перерендериваем граф
|
// Перерендериваем граф
|
||||||
this.renderGraphPanel();
|
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) {
|
} catch (error) {
|
||||||
console.error('Ошибка загрузки данных графа:', error);
|
console.error('Ошибка загрузки данных графа:', error);
|
||||||
// Optionally: show a notice to the user
|
// Optionally: show a notice to the user
|
||||||
|
|
@ -271,6 +318,42 @@ export class GraphView extends ItemView {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Удаляет узел графа по его 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 ------------------------------------------------------------
|
// ----------------------------------------------- Event Handlers ------------------------------------------------------------
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -280,7 +363,25 @@ export class GraphView extends ItemView {
|
||||||
*/
|
*/
|
||||||
handleNodeClick(nodeId: string): void {
|
handleNodeClick(nodeId: string): void {
|
||||||
this.currentNode = nodeId;
|
this.currentNode = nodeId;
|
||||||
this.plugin.eventBus.emit('node-selected', { 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(); // Перерендериваем для обновления визуального состояния выделения
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -291,8 +392,10 @@ export class GraphView extends ItemView {
|
||||||
this.graphData = { nodes: [], edges: [] };
|
this.graphData = { nodes: [], edges: [] };
|
||||||
this.currentNode = null;
|
this.currentNode = null;
|
||||||
this.selectedGraphId = null;
|
this.selectedGraphId = null;
|
||||||
|
this.selectedNodeIds.clear(); // Очищаем выделения
|
||||||
this.renderGraphPanel();
|
this.renderGraphPanel();
|
||||||
this.fetchGraphsList(); // Обновить список графов, чтобы показать новый пустой чат
|
this.fetchGraphsList(); // Обновить список графов, чтобы показать новый пустой чат
|
||||||
|
this.plugin.eventBus.emit('chat-history-updated', { graphId: null, messages: [], current_node_id: null });
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -303,9 +406,100 @@ export class GraphView extends ItemView {
|
||||||
*/
|
*/
|
||||||
async handleGraphSelected(data: { graphId: string; currentNode: string | null }): Promise<void> {
|
async handleGraphSelected(data: { graphId: string; currentNode: string | null }): Promise<void> {
|
||||||
this.selectedGraphId = data.graphId;
|
this.selectedGraphId = data.graphId;
|
||||||
// currentNode здесь может быть неактуальным, fetchGraphData загрузит актуальный current_node_id
|
this.selectedNodeIds.clear(); // Очищаем выделения при смене графа
|
||||||
await this.fetchGraphData(data.graphId);
|
await this.fetchGraphData(data.graphId);
|
||||||
// Note: Больше не нужно эмитить 'graph-selected' обратно в ChatView, т.к. ChatView уже инициировал это.
|
}
|
||||||
// ChatView должен сам обновить свою историю после вызова этого метода или после своего собственного fetch.
|
|
||||||
|
/**
|
||||||
|
* Обработчик события удаления узла из 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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
633
styles.css
633
styles.css
File diff suppressed because one or more lines are too long
Loading…
Reference in New Issue
Block a user