Add deletion

This commit is contained in:
dimitrievgs 2025-10-05 00:38:43 +03:00
parent 59ba254cbe
commit 47cff8a121
9 changed files with 3392 additions and 606 deletions

2186
main.js

File diff suppressed because one or more lines are too long

View File

@ -12,7 +12,8 @@ import { ReferenceParser, FileReference, ReferenceMatch } from '../references/Re
import { ReferenceBox } from '../references/ReferenceBox';
import { AutocompleteItem } from '../references/AutocompleteManager';
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 {
container: HTMLElement;
@ -101,7 +102,7 @@ export class ChatPanel {
if (spaceBelow < dropdownRect.height + 10 && spaceAbove > dropdownRect.height + 10) {
// Если снизу мало места и сверху достаточно, размещаем сверху
top = - dropdownRect.height;
top = -dropdownRect.height;
}
// Убедимся, что дропдаун не выходит за границы контейнера справа
@ -203,7 +204,9 @@ export class ChatPanel {
historyContainer.empty(); // Очищаем контейнер перед добавлением новых сообщений
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' });
@ -217,13 +220,15 @@ export class ChatPanel {
// Контент сообщения, рендерится Markdown
const contentDiv = messageEl.createDiv({ cls: 'chat-message-content' });
await MarkdownRenderer.renderMarkdown(
msg.content,
contentDiv,
'', // Путь к источнику, может быть пустым для сообщений чата
this.props.plugin // Компонент, необходимый для управления жизненным циклом рендера
);
this.createMessageToolset(messageEl, msg.content);
await MarkdownRenderer.renderMarkdown(
msg.content,
contentDiv,
'', // Путь к источнику, может быть пустым для сообщений чата
this.props.plugin // Компонент, необходимый для управления жизненным циклом рендера
);
// Передаем graphId и nodeId
// msg.node_id и this.props.plugin.activeGraphId используются для кнопок удаления
this.createMessageToolset(messageEl, msg.content, msg.node_id, msg.graph_id || '');
}
// Прокручиваем к самому низу родительского контейнера (view-content)
@ -526,9 +531,11 @@ export class ChatPanel {
let currentLength = 0;
const walker = document.createTreeWalker(this.messageInput, NodeFilter.SHOW_ALL); // SHOW_ALL, чтобы учитывать и элементы
let node;
let node: Node | null; // Указываем, что node может быть null
while ((node = walker.nextNode())) {
if (!node) break; // Добавляем проверку на null
const nodeTextLength = this.getNodeEquivalentLengthForReferenceParsing(node);
// Проверяем, начинается ли ссылка в этом узле
@ -852,50 +859,72 @@ export class ChatPanel {
}
}
/**
* Создает панель инструментов под сообщением с кнопками для взаимодействия.
* @param messageElement - Элемент сообщения, к которому добавляется панель инструментов.
* @param messageContent - Содержимое сообщения для функции копирования.
*/
private createMessageToolset(messageElement: HTMLElement, messageContent: string): void {
const toolsetWrapper = messageElement.createDiv({
cls: 'z-0 flex min-h-[46px] justify-start'
});
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]',
});
/**
* Создает панель инструментов под сообщением с кнопками для взаимодействия.
* @param messageElement - Элемент сообщения, к которому добавляется панель инструментов.
* @param messageContent - Содержимое сообщения для функции копирования.
* @param nodeId - ID узла, соответствующего сообщению, для функции удаления.
* @param graphId - ID графа, которому принадлежит сообщение, для функции удаления.
*/
private createMessageToolset(messageElement: HTMLElement, messageContent: string, nodeId: string, graphId: string): void {
const toolsetWrapper = messageElement.createDiv({
cls: 'z-0 flex min-h-[46px] justify-start'
});
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]'
});
// Кнопка "Копировать"
const copyButton = toolsetContainer.createEl('button', {
cls: 'text-token-text-secondary hover:bg-token-bg-secondary rounded-lg',
attr: {
'aria-label': 'Копировать',
'aria-pressed': 'false',
'data-testid': 'copy-turn-action-button',
'data-state': 'closed',
},
});
const copyIconContainer = copyButton.createSpan({ cls: 'flex items-center justify-center touch:w-10 h-8 w-8' });
setIcon(copyIconContainer, 'copy'); // Используем импортированную функцию setIcon
copyButton.addEventListener('click', () => {
this.copyMessageContent(messageContent)
.then(() => {
// В случае успешного копирования меняем иконку на галочку
setIcon(copyIconContainer, 'check');
// Возвращаем иконку копирования через 1 секунду
setTimeout(() => {
setIcon(copyIconContainer, 'copy');
}, 1000);
})
.catch(err => {
console.error('Не удалось скопировать сообщение:', err);
// Optionally: show an error notice
// new Notice('Не удалось скопировать сообщение.');
});
});
// Кнопка "Копировать"
const copyButton = toolsetContainer.createEl('button', {
cls: 'text-token-text-secondary hover:bg-token-bg-secondary rounded-lg',
attr: {
'aria-label': 'Копировать',
'aria-pressed': 'false',
'data-testid': 'copy-turn-action-button',
'data-state': 'closed'
}
});
const copyIconContainer = copyButton.createSpan({ cls: 'flex items-center justify-center touch:w-10 h-8 w-8' });
setIcon(copyIconContainer, 'copy'); // Используем импортированную функцию setIcon
copyButton.addEventListener('click', () => {
this.copyMessageContent(messageContent)
.then(() => {
// В случае успешного копирования меняем иконку на галочку
setIcon(copyIconContainer, 'check');
// Возвращаем иконку копирования через 1 секунду
setTimeout(() => {
setIcon(copyIconContainer, 'copy');
}, 1000);
})
.catch((err) => {
console.error('Не удалось скопировать сообщение:', err);
new Notice('Не удалось скопировать сообщение.', 3000); // Используем Notice
});
});
// Кнопка "Попробовать снова"
/*const retryButton = toolsetContainer.createEl('button', {
// Кнопка "Удалить"
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', {
cls: 'text-token-text-secondary hover:bg-token-bg-secondary rounded-lg',
attr: {
'aria-label': 'Попробовать снова',
@ -909,9 +938,9 @@ export class ChatPanel {
retryButton.addEventListener('click', () => {
this.copyMessageContent(messageContent);
});*/ // TODO: реализовать переотправку сообщения
}
}
private copyMessageContent(content: string): Promise<void> {
return navigator.clipboard.writeText(content);
}
private copyMessageContent(content: string): Promise<void> {
return navigator.clipboard.writeText(content);
}
}

View File

@ -14,11 +14,13 @@ import ReactFlow, {
useEdgesState,
Background,
Controls,
ReactFlowProvider, // <-- ИСПРАВЛЕНО: импортируем прямо ReactFlowProvider без переименования
ReactFlowProvider,
Connection,
Edge,
BackgroundVariant // <-- ИСПРАВЛЕНО: используем BackgroundVariant для типизации
BackgroundVariant
} from 'reactflow';
import { App } from 'obsidian';
import { Dialog } from 'src/utils/Dialog';
// <-- ИСПРАВЛЕНО: Это ключевой момент. Убедитесь, что esbuild настроен на обработку CSS импортов.
// Обычно, при использовании esbuild для React-проектов, вы импортируете CSS прямо в JS/TS файл.
@ -34,93 +36,138 @@ import 'reactflow/dist/style.css'; // Импортируем стили ReactFlo
import { Handle, Position } from 'reactflow';
/**
* Интерфейс, описывающий структуру данных, передаваемых в BaseNodeWrapper.
*/
interface BaseNodeWrapperData {
label: string;
title?: string;
title_generated?: boolean;
isSelected: boolean;
nodeId: string;
onNodeDelete: (nodeId: string) => Promise<void>;
app: App;
}
/**
* Интерфейс, описывающий свойства компонента BaseNodeWrapper.
*/
interface BaseNodeWrapperProps {
data: BaseNodeWrapperData;
isConnectable: boolean;
// children: React.ReactNode; // Содержимое, специфичное для типа узла (например, метка)
}
/**
* BaseNodeWrapper - Общая обёртка для всех кастомных узлов,
* предоставляющая хэндлы для соединений и функциональность удаления.
* @param {BaseNodeWrapperProps} props - Свойства компонента.
* @returns {React.ReactElement} Элемент React.
*/
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);
}
};
// ----------------------------------------------- Render --------------------------------------------------------------------
return React.createElement(
React.Fragment,
null,
React.createElement(Handle, {
type: 'source',
position: Position.Bottom,
isConnectable: isConnectable
}),
React.createElement(Handle, {
type: 'target',
position: Position.Top,
isConnectable: isConnectable
}),
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 || 'Пользователь';
const titleGenerated = data.title_generated;
// ... (titleGenerated больше не нужен в каждом узле, если не используется для рендеринга)
return React.createElement(
React.Fragment,
// className: `custom-node user-node ${titleGenerated ? 'title-generated' : 'title-pending'}`,
null, // Используем React.Fragment для возврата нескольких элементов без лишнего div
React.createElement(Handle, {
type: 'source',
position: Position.Bottom,
isConnectable: isConnectable
}),
React.createElement(Handle, {
type: 'target',
position: Position.Top,
isConnectable: isConnectable
}),
displayLabel
BaseNodeWrapper,
{ data: data, isConnectable: isConnectable },
React.createElement('div', null, displayLabel)
);
};
// Простой компонент для узла типа 'llm'
// Обновите другие типы узлов (LLMNode, ToolNode, ErrorNode) аналогично
const LLMNode: React.FC<any> = ({ data, isConnectable }) => {
const displayLabel = data.title || data.label || 'LLM';
const titleGenerated = data.title_generated;
// ...
return React.createElement(
React.Fragment,
null,
React.createElement(Handle, {
type: 'source',
position: Position.Bottom,
isConnectable: isConnectable
}),
React.createElement(Handle, {
type: 'target',
position: Position.Top,
isConnectable: isConnectable
}),
displayLabel
BaseNodeWrapper,
{ data: data, isConnectable: isConnectable },
React.createElement('div', null, displayLabel)
);
};
// Простой компонент для узла типа 'tool'
const ToolNode: React.FC<any> = ({ data, isConnectable }) => {
const displayLabel = data.title || data.label || 'Инструмент';
const titleGenerated = data.title_generated;
// ...
return React.createElement(
React.Fragment,
null,
React.createElement(Handle, {
type: 'source',
position: Position.Bottom,
isConnectable: isConnectable
}),
React.createElement(Handle, {
type: 'target',
position: Position.Top,
isConnectable: isConnectable
}),
displayLabel
BaseNodeWrapper,
{ data: data, isConnectable: isConnectable },
React.createElement('div', null, displayLabel)
);
};
// Простой компонент для узла типа 'error'
const ErrorNode: React.FC<any> = ({ data, isConnectable }) => {
const displayLabel = data.title || data.label || 'Ошибка';
const titleGenerated = data.title_generated;
// ...
return React.createElement(
React.Fragment,
null,
React.createElement(Handle, {
type: 'source',
position: Position.Bottom,
isConnectable: isConnectable
}),
React.createElement(Handle, {
type: 'target',
position: Position.Top,
isConnectable: isConnectable
}),
displayLabel
BaseNodeWrapper,
{ data: data, isConnectable: isConnectable },
React.createElement('div', null, displayLabel)
);
};
@ -229,10 +276,17 @@ const getNodeDimensions = (nodeElement: HTMLElement | null): { width: number; he
* @typedef {object} GraphPanelProps
* @property {{ nodes: any[]; edges: any[]; }} graphData - Данные графа для отображения (узлы и ребра).
* @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 {
graphData: { nodes: any[]; edges: any[] };
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 - Свойства компонента.
* @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 Flow
const [nodes, setNodes, onNodesChange] = useNodesState([]);
const [edges, setEdges, onEdgesChange] = useEdgesState([]);
const [edges, setEdges, onEdgesChange] = useEdgesState([]); // onEdgesState переименовано на onEdgesChange
// Используем useEffect для применения Dagre layout при изменении данных графа
React.useEffect(() => {
@ -258,9 +312,17 @@ export const GraphPanelComponent: React.FC<GraphPanelProps> = ({ graphData, onNo
data: {
label: node.data?.label || `Node ${node.id}`,
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) => ({
@ -285,7 +347,7 @@ export const GraphPanelComponent: React.FC<GraphPanelProps> = ({ graphData, onNo
setNodes([]);
setEdges([]);
}
}, [graphData.nodes, graphData.edges]); // Зависимости: изменения в узлах и рёбрах графа
}, [graphData.nodes, graphData.edges, onNodeDelete, selectedNodeIds]); // Зависимости: изменения в узлах и рёбрах графа, колбэк удаления, выделенные узлы
// ----------------------------------------------- Event Handlers ------------------------------------------------------------
/**
@ -301,11 +363,45 @@ export const GraphPanelComponent: React.FC<GraphPanelProps> = ({ graphData, onNo
*/
const nodeClickHandler = React.useCallback(
(event: React.MouseEvent, node: any) => {
onNodeClick(node.id);
if (event.shiftKey) {
// Если зажат Shift, переключаем выделение
onNodeSelectionChange(node.id, !selectedNodeIds.has(node.id));
} else {
// Иначе, это обычный клик по узлу
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 --------------------------------------------------------------------
return React.createElement(
'div',
@ -323,6 +419,7 @@ export const GraphPanelComponent: React.FC<GraphPanelProps> = ({ graphData, onNo
onEdgesChange, // Обработчик изменения рёбер
onConnect, // Обработчик подключения
onNodeClick: nodeClickHandler, // Обработчик клика по узлу
onPaneClick: onPaneClick, // Обработчик клика по панели
fitView: true, // Автоматически центрирует и масштабирует граф при загрузке
attributionPosition: 'bottom-right', // Позиция атрибуции React Flow
nodeTypes,

View File

@ -7,7 +7,8 @@ export interface ChatHistoryItem {
role: string;
content: string;
type?: 'user' | 'llm' | 'tool' | 'error' | 'default';
node_id?: string;
node_id: string;
graph_id?: string;
// TODO: добавить поля для снапшотов файлов при реализации
// fileSnapshots?: Record<string, string>;
// expandedContent?: string; // содержимое после раскрытия ссылок

View File

@ -165,69 +165,6 @@
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 -------------------------------------------------------- */
.llm-agent-chat-panel {
@ -559,73 +496,6 @@
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 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
View 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
View 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);
}
}

View File

@ -4,13 +4,14 @@
*/
// ----------------------------------------------- Obsidian Imports ----------------------------------------------------------
import { ItemView, WorkspaceLeaf } from 'obsidian';
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';
@ -22,6 +23,7 @@ export class GraphView extends ItemView {
currentNode: string | null;
graphsList: any[];
selectedGraphId: string | null;
selectedNodeIds: Set<string>; // Добавляем Set для хранения ID выделенных узлов
// UI Elements
historyPanel: HistoryPanel | null;
@ -31,6 +33,10 @@ export class GraphView extends ItemView {
toggleButton: HTMLButtonElement;
isHistoryCollapsed: boolean;
// Глобальные кнопки действий
copySelectedButton: HTMLButtonElement | null = null;
deleteSelectedButton: HTMLButtonElement | null = null;
constructor(leaf: WorkspaceLeaf, plugin: LLMAgentPlugin) {
super(leaf);
this.plugin = plugin;
@ -39,8 +45,9 @@ export class GraphView extends ItemView {
this.graphsList = [];
this.selectedGraphId = null;
this.historyPanel = null;
this.reactRoot = null; // Инициализируем null
this.reactRoot = null;
this.isHistoryCollapsed = true;
this.selectedNodeIds = new Set<string>(); // Инициализируем Set
}
/**
@ -78,12 +85,11 @@ export class GraphView extends ItemView {
display: flex;
height: 100%;
width: 100%;
background-color: var(--background-primary); /* Убедимся, что фон установлен */
background-color: var(--background-primary);
`;
// Левая панель для истории
this.historyContainer = mainContainer.createDiv('llm-agent-history-sidebar') as HTMLDivElement;
// Убираем инлайн стили - теперь в CSS
this.historyContainer.classList.add(this.isHistoryCollapsed ? 'collapsed' : 'expanded');
// Правая панель для графа
@ -91,7 +97,7 @@ export class GraphView extends ItemView {
this.graphContainer.style.cssText = `
flex: 1;
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
});
// Создаем кнопки переключения и глобальные кнопки действий
this.addToggleButton();
this.addGlobalActionButtons(); // Добавляем вызов новой функции
// Инициализируем React Flow
this.initializeGraphPanel();
// Создаем кнопку переключения
this.addToggleButton();
// Подписываемся на события
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();
@ -131,7 +139,8 @@ export class GraphView extends ItemView {
}
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 --------------------------------------------------
@ -155,14 +164,50 @@ export class GraphView extends ItemView {
// ----------------------------------------------- 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 {
// Создаем контейнер для React Flow
// Убедимся, что id уникален или используем ref для React 18
this.graphContainer.empty(); // Очищаем контейнер перед добавлением нового элемента
this.graphContainer.empty();
// Перемещаем кнопки обратно после очистки, если они были частью graphContainer
this.addToggleButton();
this.addGlobalActionButtons();
const wrapper = this.graphContainer.createDiv();
wrapper.style.cssText = 'width: 100%; height: 100%;';
@ -171,7 +216,6 @@ export class GraphView extends ItemView {
return;
}
// Создаем корневой элемент React 18
if (ReactDOM && typeof ReactDOM.createRoot === 'function') {
this.reactRoot = ReactDOM.createRoot(wrapper);
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.
*/
@ -201,14 +235,18 @@ export class GraphView extends ItemView {
nodes: this.graphData.nodes,
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 {
console.warn('React root не инициализирован для рендера GraphPanelComponent.');
}
}
// ... (остальные методы API и Event Handlers без изменений)
// ----------------------------------------------- API Methods ---------------------------------------------------------------
/**
@ -234,42 +272,87 @@ export class GraphView extends ItemView {
}
}
/**
* Загружает данные конкретного графа по его ID.
* @param {string} graphId - ID графа для загрузки.
* @returns {Promise<void>}
*/
async fetchGraphData(graphId: string): Promise<void> {
try {
if (!graphId) {
// Если graphId пуст, очищаем граф
this.graphData = { nodes: [], edges: [] };
this.currentNode = null;
this.renderGraphPanel();
return;
}
/**
* Загружает данные конкретного графа по его 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.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();
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.graphData = {
nodes: data.graph_nodes || [],
edges: data.graph_edges || []
};
this.currentNode = data.current_node_id;
// Перерендериваем граф
this.renderGraphPanel();
} catch (error) {
console.error('Ошибка загрузки данных графа:', error);
// Optionally: show a notice to the user
// new Notice(`Ошибка загрузки данных графа: ${error.message}`);
}
}
// Перерендериваем граф
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);
// 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 ------------------------------------------------------------
@ -280,9 +363,27 @@ export class GraphView extends ItemView {
*/
handleNodeClick(nodeId: string): void {
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.currentNode = null;
this.selectedGraphId = null;
this.selectedNodeIds.clear(); // Очищаем выделения
this.renderGraphPanel();
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> {
this.selectedGraphId = data.graphId;
// currentNode здесь может быть неактуальным, fetchGraphData загрузит актуальный current_node_id
this.selectedNodeIds.clear(); // Очищаем выделения при смене графа
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);
}
}

File diff suppressed because one or more lines are too long