work on separation / streaming

This commit is contained in:
dimitrievgs 2025-10-11 23:48:49 +03:00
parent b0d802241b
commit 826b9222d3
9 changed files with 5091 additions and 1028 deletions

5369
main.js

File diff suppressed because one or more lines are too long

View File

@ -2,6 +2,7 @@ import { App, Editor, MarkdownView, Modal, Notice, Plugin, PluginSettingTab, Set
import { EventBus } from './src/utils/EventBus'; import { EventBus } from './src/utils/EventBus';
import { GraphView, GRAPH_VIEW_TYPE } from './src/views/GraphView'; import { GraphView, GRAPH_VIEW_TYPE } from './src/views/GraphView';
import { ChatView, CHAT_VIEW_TYPE } from './src/views/ChatView'; import { ChatView, CHAT_VIEW_TYPE } from './src/views/ChatView';
import { WebSocketService } from './src/utils/WebSocketService';
import 'src/css/styles.css'; // Путь к твоему новому CSS файлу import 'src/css/styles.css'; // Путь к твоему новому CSS файлу
@ -32,12 +33,16 @@ const DEFAULT_SETTINGS: LLMAgentSettings = {
export default class LLMAgentPlugin extends Plugin { export default class LLMAgentPlugin extends Plugin {
settings: LLMAgentSettings; settings: LLMAgentSettings;
eventBus: EventBus; eventBus: EventBus;
webSocketService: WebSocketService;
async onload() { async onload() {
await this.loadSettings(); await this.loadSettings();
this.eventBus = new EventBus(); this.eventBus = new EventBus();
this.webSocketService = new WebSocketService(this.eventBus, this.settings.apiBaseUrl);
this.webSocketService.connect();
// Регистрируем кастомные views // Регистрируем кастомные views
this.registerView(GRAPH_VIEW_TYPE, (leaf) => new GraphView(leaf, this)); this.registerView(GRAPH_VIEW_TYPE, (leaf) => new GraphView(leaf, this));
this.registerView(CHAT_VIEW_TYPE, (leaf) => new ChatView(leaf, this)); this.registerView(CHAT_VIEW_TYPE, (leaf) => new ChatView(leaf, this));
@ -74,6 +79,10 @@ export default class LLMAgentPlugin extends Plugin {
} }
onunload() { onunload() {
if (this.webSocketService) {
this.webSocketService.disconnect();
}
console.log('LLM Agent plugin выгружен'); console.log('LLM Agent plugin выгружен');
} }

133
package-lock.json generated
View File

@ -8,6 +8,9 @@
"name": "obsidian-sample-plugin", "name": "obsidian-sample-plugin",
"version": "1.0.0", "version": "1.0.0",
"license": "MIT", "license": "MIT",
"dependencies": {
"socket.io-client": "^4.8.1"
},
"devDependencies": { "devDependencies": {
"@types/dagre": "^0.7.52", "@types/dagre": "^0.7.52",
"@types/node": "^16.11.6", "@types/node": "^16.11.6",
@ -646,6 +649,11 @@
"react-dom": ">=17" "react-dom": ">=17"
} }
}, },
"node_modules/@socket.io/component-emitter": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz",
"integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA=="
},
"node_modules/@types/codemirror": { "node_modules/@types/codemirror": {
"version": "5.60.8", "version": "5.60.8",
"resolved": "https://registry.npmjs.org/@types/codemirror/-/codemirror-5.60.8.tgz", "resolved": "https://registry.npmjs.org/@types/codemirror/-/codemirror-5.60.8.tgz",
@ -1539,6 +1547,42 @@
"node": ">=6.0.0" "node": ">=6.0.0"
} }
}, },
"node_modules/engine.io-client": {
"version": "6.6.3",
"resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-6.6.3.tgz",
"integrity": "sha512-T0iLjnyNWahNyv/lcjS2y4oE358tVS/SYQNxYXGAJ9/GLgH4VCvOQ/mhTjqU88mLZCQgiG8RIegFHYCdVC+j5w==",
"dependencies": {
"@socket.io/component-emitter": "~3.1.0",
"debug": "~4.3.1",
"engine.io-parser": "~5.2.1",
"ws": "~8.17.1",
"xmlhttprequest-ssl": "~2.1.1"
}
},
"node_modules/engine.io-client/node_modules/debug": {
"version": "4.3.7",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz",
"integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==",
"dependencies": {
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/engine.io-parser": {
"version": "5.2.3",
"resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.2.3.tgz",
"integrity": "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==",
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/esbuild": { "node_modules/esbuild": {
"version": "0.17.3", "version": "0.17.3",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.17.3.tgz", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.17.3.tgz",
@ -2292,8 +2336,7 @@
"node_modules/ms": { "node_modules/ms": {
"version": "2.1.3", "version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="
"dev": true
}, },
"node_modules/natural-compare": { "node_modules/natural-compare": {
"version": "1.4.0", "version": "1.4.0",
@ -2648,6 +2691,64 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/socket.io-client": {
"version": "4.8.1",
"resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.8.1.tgz",
"integrity": "sha512-hJVXfu3E28NmzGk8o1sHhN3om52tRvwYeidbj7xKy2eIIse5IoKX3USlS6Tqt3BHAtflLIkCQBkzVrEEfWUyYQ==",
"dependencies": {
"@socket.io/component-emitter": "~3.1.0",
"debug": "~4.3.2",
"engine.io-client": "~6.6.1",
"socket.io-parser": "~4.2.4"
},
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/socket.io-client/node_modules/debug": {
"version": "4.3.7",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz",
"integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==",
"dependencies": {
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/socket.io-parser": {
"version": "4.2.4",
"resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.4.tgz",
"integrity": "sha512-/GbIKmo8ioc+NIWIhwdecY0ge+qVBSMdgxGygevmdHj24bsfgtCmcUUcQ5ZzcylGFHsN3k4HB4Cgkl96KVnuew==",
"dependencies": {
"@socket.io/component-emitter": "~3.1.0",
"debug": "~4.3.1"
},
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/socket.io-parser/node_modules/debug": {
"version": "4.3.7",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz",
"integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==",
"dependencies": {
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/strip-ansi": { "node_modules/strip-ansi": {
"version": "6.0.1", "version": "6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
@ -2838,6 +2939,34 @@
"dev": true, "dev": true,
"peer": true "peer": true
}, },
"node_modules/ws": {
"version": "8.17.1",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.17.1.tgz",
"integrity": "sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==",
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
},
"node_modules/xmlhttprequest-ssl": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.1.2.tgz",
"integrity": "sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ==",
"engines": {
"node": ">=0.4.0"
}
},
"node_modules/yocto-queue": { "node_modules/yocto-queue": {
"version": "0.1.0", "version": "0.1.0",
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",

View File

@ -27,5 +27,8 @@
"reactflow": "^11.11.3", "reactflow": "^11.11.3",
"tslib": "^2.4.0", "tslib": "^2.4.0",
"typescript": "^5.4.5" "typescript": "^5.4.5"
},
"dependencies": {
"socket.io-client": "^4.8.1"
} }
} }

View File

@ -923,6 +923,21 @@ export class ChatPanel {
} }
}); });
// Кнопка "Регенерировать"
const regenerateButton = toolsetContainer.createEl('button', {
cls: 'text-token-text-secondary hover:bg-token-bg-secondary rounded-lg ml-1',
attr: {
'aria-label': 'Регенерировать сообщение',
'data-testid': 'regenerate-turn-action-button',
'data-state': 'closed'
}
});
const regenerateIconContainer = regenerateButton.createSpan({ cls: 'flex items-center justify-center touch:w-10 h-8 w-8' });
setIcon(regenerateIconContainer, 'refresh-cw');
regenerateButton.addEventListener('click', async () => {
this.props.plugin.eventBus.emit('regenerate-message', { 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',

View File

@ -17,7 +17,7 @@ import ReactFlow, {
ReactFlowProvider, ReactFlowProvider,
Connection, Connection,
Edge, Edge,
BackgroundVariant BackgroundVariant
} from 'reactflow'; } from 'reactflow';
import { App } from 'obsidian'; import { App } from 'obsidian';
import { Dialog } from 'src/utils/Dialog'; import { Dialog } from 'src/utils/Dialog';
@ -45,6 +45,7 @@ interface BaseNodeWrapperData {
title_generated?: boolean; title_generated?: boolean;
isSelected: boolean; isSelected: boolean;
nodeId: string; nodeId: string;
graphId: string;
onNodeDelete: (nodeId: string) => Promise<void>; onNodeDelete: (nodeId: string) => Promise<void>;
app: App; app: App;
} }
@ -66,59 +67,83 @@ interface BaseNodeWrapperProps {
*/ */
const BaseNodeWrapper: React.FC<BaseNodeWrapperProps> = ({ data, isConnectable, children }) => { const BaseNodeWrapper: React.FC<BaseNodeWrapperProps> = ({ data, isConnectable, children }) => {
// ----------------------------------------------- Styles -------------------------------------------------------------------- // ----------------------------------------------- Styles --------------------------------------------------------------------
// Стили для кнопки удаления. Вынесены для переиспользования. // Стили для кнопки удаления. Вынесены для переиспользования.
const deleteButtonStyle: React.CSSProperties = { const deleteButtonStyle: React.CSSProperties = {
position: 'absolute', position: 'absolute',
bottom: '-30px', // Размещаем под узлом bottom: '-30px', // Размещаем под узлом
left: '5px', left: '5px',
background: 'transparent', background: 'transparent',
border: 'none', border: 'none',
cursor: 'pointer', cursor: 'pointer',
fontSize: '1em', fontSize: '1em',
color: 'var(--text-muted)', // Используем цвет текста Obsidian color: 'var(--text-muted)', // Используем цвет текста Obsidian
padding: '0 5px', padding: '0 5px',
opacity: data.isSelected ? 1 : 0, // Показываем только если узел выделен opacity: data.isSelected ? 1 : 0, // Показываем только если узел выделен
transition: 'opacity 0.2s ease-in-out', transition: 'opacity 0.2s ease-in-out'
}; };
// ----------------------------------------------- Event Handlers ------------------------------------------------------------ // ----------------------------------------------- Event Handlers ------------------------------------------------------------
/** /**
* Обработчик удаления узла. * Обработчик удаления узла.
* @param {React.MouseEvent} event - Событие клика. * @param {React.MouseEvent} event - Событие клика.
*/ */
const handleNodeDelete = async (event: React.MouseEvent) => { const handleNodeDelete = async (event: React.MouseEvent) => {
event.stopPropagation(); // Предотвращаем срабатывание onNodeClick на родительском узле event.stopPropagation(); // Предотвращаем срабатывание onNodeClick на родительском узле
const confirmed = await Dialog.confirm( const confirmed = await Dialog.confirm(data.app, 'Вы уверены, что хотите удалить этот узел?');
data.app,
'Вы уверены, что хотите удалить этот узел?'
);
if (confirmed) { if (confirmed) {
await data.onNodeDelete(data.nodeId); await data.onNodeDelete(data.nodeId);
} }
}; };
const handleRegenerate = async (event: React.MouseEvent) => {
event.stopPropagation();
// Используем EventBus вместо workspace.trigger
const eventBus = (data.app as any).plugins?.plugins?.['llm-agent-plugin']?.eventBus;
if (eventBus) {
eventBus.emit('regenerate-message', {
graphId: data.graphId,
nodeId: data.nodeId
});
}
};
// ----------------------------------------------- Render -------------------------------------------------------------------- // ----------------------------------------------- Render --------------------------------------------------------------------
return React.createElement( return React.createElement(
React.Fragment, React.Fragment,
null, null,
React.createElement(Handle, { React.createElement(Handle, {
type: 'source', type: 'source',
position: Position.Bottom, position: Position.Bottom,
isConnectable: isConnectable isConnectable: isConnectable
}), }),
React.createElement(Handle, { React.createElement(Handle, {
type: 'target', type: 'target',
position: Position.Top, position: Position.Top,
isConnectable: isConnectable isConnectable: isConnectable
}), }),
children, // Здесь будет отображаться специфическое содержимое узла children, // Здесь будет отображаться специфическое содержимое узла
data.isSelected && React.createElement('button', { data.isSelected && React.createElement('div', {
style: deleteButtonStyle, style: {
onClick: handleNodeDelete, position: 'absolute',
'aria-label': 'Удалить узел', bottom: '-30px',
className: 'node-delete-button' // Добавляем класс для стилизации left: '5px',
}, '🗑️') // Можно использовать SVG иконку display: 'flex',
gap: '5px'
}
},
React.createElement('button', {
style: deleteButtonStyle,
onClick: handleNodeDelete,
'aria-label': 'Удалить узел',
className: 'node-delete-button'
}, '🗑️'),
React.createElement('button', {
style: deleteButtonStyle,
onClick: handleRegenerate,
'aria-label': 'Регенерировать',
className: 'node-regenerate-button'
}, '🔄')
)
); );
}; };
@ -294,7 +319,14 @@ interface GraphPanelProps {
* @param {GraphPanelProps} props - Свойства компонента. * @param {GraphPanelProps} props - Свойства компонента.
* @returns {React.ReactElement} Элемент React. * @returns {React.ReactElement} Элемент React.
*/ */
export const GraphPanelComponent: React.FC<GraphPanelProps> = ({ graphData, onNodeClick, onNodeSelectionChange, onNodeDelete, selectedNodeIds, app }) => { 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([]);
@ -313,16 +345,14 @@ export const GraphPanelComponent: React.FC<GraphPanelProps> = ({ graphData, onNo
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, onNodeDelete: onNodeDelete,
isSelected: selectedNodeIds.has(node.id), // Передаем состояние выделения isSelected: selectedNodeIds.has(node.id),
// Добавляем graphId и nodeId для кнопки удаления
graphId: (node.data && node.data.message && node.data.message.graph_id) ? node.data.message.graph_id : 'unknown', graphId: (node.data && node.data.message && node.data.message.graph_id) ? node.data.message.graph_id : 'unknown',
nodeId: node.id, nodeId: node.id,
app: app app: app
}, },
position: { x: 0, y: 0 }, position: { x: 0, y: 0 },
className: selectedNodeIds.has(node.id) ? 'selected-node' : '', // Применяем класс для выделения className: selectedNodeIds.has(node.id) ? 'selected-node' : '',
})); }));
const initialEdgesForLayout = graphData.edges.map((edge) => ({ const initialEdgesForLayout = graphData.edges.map((edge) => ({
@ -364,43 +394,46 @@ 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) { if (event.shiftKey) {
// Если зажат Shift, переключаем выделение // Если зажат Shift, переключаем выделение
onNodeSelectionChange(node.id, !selectedNodeIds.has(node.id)); onNodeSelectionChange(node.id, !selectedNodeIds.has(node.id));
} else { } else {
// Иначе, это обычный клик по узлу // Иначе, это обычный клик по узлу
onNodeClick(node.id); onNodeClick(node.id);
} }
}, },
[onNodeClick, onNodeSelectionChange, selectedNodeIds] [onNodeClick, onNodeSelectionChange, selectedNodeIds]
); );
// Добавляем обработчик для ReactFlow, чтобы отслеживать клики по пустому пространству // Добавляем обработчик для ReactFlow, чтобы отслеживать клики по пустому пространству
const onPaneClick = React.useCallback((event: React.MouseEvent) => { const onPaneClick = React.useCallback(
// Проверяем, был ли клик по пустому пространству (не по узлу или ребру) (event: React.MouseEvent) => {
const target = event.target as HTMLElement; // Проверяем, был ли клик по пустому пространству (не по узлу или ребру)
if (!target.closest('.react-flow__node') && !target.closest('.react-flow__edge')) { const target = event.target as HTMLElement;
// Если клик был не по узлу и не по ребру, сбрасываем выделение if (!target.closest('.react-flow__node') && !target.closest('.react-flow__edge')) {
if (selectedNodeIds.size > 0) { // Если клик был не по узлу и не по ребру, сбрасываем выделение
// Вызываем колбэк для сброса всех выделений if (selectedNodeIds.size > 0) {
selectedNodeIds.forEach(nodeId => onNodeSelectionChange(nodeId, false)); // Вызываем колбэк для сброса всех выделений
} selectedNodeIds.forEach((nodeId) => onNodeSelectionChange(nodeId, false));
} }
}, [selectedNodeIds, onNodeSelectionChange]); }
},
[selectedNodeIds, onNodeSelectionChange]
);
// Обработчик для клавиатуры (Escape) // Обработчик для клавиатуры (Escape)
React.useEffect(() => { React.useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => { const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') { if (event.key === 'Escape') {
if (selectedNodeIds.size > 0) { if (selectedNodeIds.size > 0) {
selectedNodeIds.forEach(nodeId => onNodeSelectionChange(nodeId, false)); selectedNodeIds.forEach((nodeId) => onNodeSelectionChange(nodeId, false));
} }
} }
}; };
document.addEventListener('keydown', handleKeyDown); document.addEventListener('keydown', handleKeyDown);
return () => { return () => {
document.removeEventListener('keydown', handleKeyDown); document.removeEventListener('keydown', handleKeyDown);
}; };
}, [selectedNodeIds, onNodeSelectionChange]); }, [selectedNodeIds, onNodeSelectionChange]);
// ----------------------------------------------- Render -------------------------------------------------------------------- // ----------------------------------------------- Render --------------------------------------------------------------------
return React.createElement( return React.createElement(
@ -419,7 +452,7 @@ export const GraphPanelComponent: React.FC<GraphPanelProps> = ({ graphData, onNo
onEdgesChange, // Обработчик изменения рёбер onEdgesChange, // Обработчик изменения рёбер
onConnect, // Обработчик подключения onConnect, // Обработчик подключения
onNodeClick: nodeClickHandler, // Обработчик клика по узлу onNodeClick: nodeClickHandler, // Обработчик клика по узлу
onPaneClick: onPaneClick, // Обработчик клика по панели onPaneClick: onPaneClick, // Обработчик клика по панели
fitView: true, // Автоматически центрирует и масштабирует граф при загрузке fitView: true, // Автоматически центрирует и масштабирует граф при загрузке
attributionPosition: 'bottom-right', // Позиция атрибуции React Flow attributionPosition: 'bottom-right', // Позиция атрибуции React Flow
nodeTypes, nodeTypes,

View File

@ -0,0 +1,85 @@
import { io, Socket } from 'socket.io-client';
import { EventBus } from './EventBus';
export class WebSocketService {
private socket: Socket | null = null;
private eventBus: EventBus;
private apiBaseUrl: string;
constructor(eventBus: EventBus, apiBaseUrl: string) {
this.eventBus = eventBus;
this.apiBaseUrl = apiBaseUrl;
}
/**
* Подключается к WebSocket серверу
*/
connect(): void {
if (this.socket?.connected) {
console.log('WebSocket уже подключен');
return;
}
// Извлекаем базовый URL без /api
const wsUrl = this.apiBaseUrl.replace('/api', '');
this.socket = io(wsUrl, {
transports: ['websocket', 'polling'],
reconnection: true,
reconnectionDelay: 1000,
reconnectionDelayMax: 5000,
reconnectionAttempts: 5
});
this.setupEventHandlers();
}
/**
* Настраивает обработчики WebSocket событий
*/
private setupEventHandlers(): void {
if (!this.socket) return;
this.socket.on('connect', () => {
console.log('✅ WebSocket подключен');
});
this.socket.on('disconnect', (reason) => {
console.log('❌ WebSocket отключен:', reason);
});
this.socket.on('connect_error', (error) => {
console.error('Ошибка подключения WebSocket:', error);
});
// ----------------------------------------------- Title Update Events ---------------------------------------------------------------
this.socket.on('graph_title_updated', (data: { graph_id: string; title: string }) => {
console.log('📊 Получено обновление заголовка графа:', data);
this.eventBus.emit('ws-graph-title-updated', data);
});
this.socket.on('node_title_updated', (data: { graph_id: string; node_id: string; title: string }) => {
console.log('📝 Получено обновление заголовка узла:', data);
this.eventBus.emit('ws-node-title-updated', data);
});
}
/**
* Отключается от WebSocket сервера
*/
disconnect(): void {
if (this.socket) {
this.socket.disconnect();
this.socket = null;
console.log('WebSocket отключен');
}
}
/**
* Проверяет статус подключения
*/
isConnected(): boolean {
return this.socket?.connected ?? false;
}
}

View File

@ -3,7 +3,7 @@
* отправляет запросы LLM-агенту и обрабатывает ответы, обновляя состояние чата и графа. * отправляет запросы LLM-агенту и обрабатывает ответы, обновляя состояние чата и графа.
*/ */
import { ItemView, WorkspaceLeaf } from 'obsidian'; import { ItemView, Notice, WorkspaceLeaf } from 'obsidian';
import { ChatPanel } from '../components/ChatPanel'; import { ChatPanel } from '../components/ChatPanel';
import LLMAgentPlugin from 'main'; import LLMAgentPlugin from 'main';
@ -69,7 +69,9 @@ export class ChatView extends ItemView {
this.plugin.eventBus.on('node-selected', this.handleNodeSelected.bind(this)); this.plugin.eventBus.on('node-selected', this.handleNodeSelected.bind(this));
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('regenerate-message', this.handleRegenerateMessage.bind(this)); // Добавлено
} }
async onClose(): Promise<void> { async onClose(): Promise<void> {
if (this.chatPanel) { if (this.chatPanel) {
this.chatPanel.destroy(); this.chatPanel.destroy();
@ -78,6 +80,7 @@ export class ChatView extends ItemView {
this.plugin.eventBus.off('node-selected', this.handleNodeSelected); this.plugin.eventBus.off('node-selected', this.handleNodeSelected);
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('regenerate-message', this.handleRegenerateMessage);
} }
// ----------------------------------------------- API Methods --------------------------------------------------------------- // ----------------------------------------------- API Methods ---------------------------------------------------------------
@ -145,62 +148,42 @@ export class ChatView extends ItemView {
async handleSendMessage(message: string, selectedModel?: string): Promise<void> { async handleSendMessage(message: string, selectedModel?: string): Promise<void> {
try { try {
const response = await fetch(`${this.plugin.settings.apiBaseUrl}/chat`, { // Шаг 1: Создаём узел пользователя
const sendResponse = await fetch(`${this.plugin.settings.apiBaseUrl}/chat/send`, {
method: 'POST', method: 'POST',
headers: { headers: { 'Content-Type': 'application/json' },
'Content-Type': 'application/json'
},
body: JSON.stringify({ body: JSON.stringify({
message: message, message: message,
graph_id: this.selectedGraphId, graph_id: this.selectedGraphId,
parent_node_id: this.currentNode, parent_node_id: this.currentNode
system_prompt: this.plugin.settings.systemPrompt,
model: selectedModel || 'gemini-2.5-flash'
}) })
}); });
if (!response.ok) { if (!sendResponse.ok) {
// Пытаемся прочитать ошибку из тела ответа, если это возможно throw new Error(`Ошибка при отправке сообщения: ${sendResponse.status}`);
const errorData = await response.json().catch(() => ({ message: response.statusText }));
throw new Error(`HTTP ошибка при отправке сообщения! Статус: ${response.status} - ${errorData.message}`);
} }
const result = await response.json(); const sendResult = await sendResponse.json();
this.selectedGraphId = sendResult.graph_id;
const userNodeId = sendResult.node_id;
// Обновляем graph_id и current_node_id всегда // Обновляем граф с новым узлом пользователя
this.selectedGraphId = result.graph_id; this.plugin.eventBus.emit('graph-selected', {
const newCurrentNodeId = result.graph_visualization_data.current_node_id; graphId: this.selectedGraphId,
this.currentNode = newCurrentNodeId; currentNode: userNodeId
});
// Сначала обновим GraphView, чтобы он запросил последнюю версию графа // Шаг 2: Запускаем стриминг ответа
// Это предотвратит состояние гонки, когда разные запросы возвращают частичные графы. if (this.selectedGraphId) {
// Вместо того, чтобы отправлять частичные данные, мы запрашиваем полный граф у бэкенда. // Проверка на null
this.plugin.eventBus.emit('graph-selected', { graphId: this.selectedGraphId, currentNode: this.currentNode }); await this.streamLLMResponse(this.selectedGraphId, userNodeId, selectedModel);
} else {
// TODO: сделать аккуратнее - устанавливаем таймер на 30 секунд для обновления заголовков throw new Error('Graph ID не определён после отправки сообщения');
this.titleUpdateTimer = setTimeout(() => {
console.log('Обновление графа для получения сгенерированных заголовков...');
if (this.selectedGraphId && this.currentNode) {
this.plugin.eventBus.emit('graph-selected', {
graphId: this.selectedGraphId,
currentNode: this.currentNode
});
}
this.titleUpdateTimer = null;
}, 20000); // 20 секунд, эмпирически
// Затем обновим ChatView, загрузив историю до нового current_node_id
if (newCurrentNodeId) {
await this.fetchMessagesFromRootToNode(newCurrentNodeId);
} else if (this.selectedGraphId) {
// Если newCurrentNodeId по какой-то причине не вернулся, но graph_id есть,
// пытаемся загрузить историю по последнему известному узлу или просто весь граф (чтобы отобразить пустую историю, если граф новый).
// В этом случае, если current_node_id отсутствует, historyPanel должна очиститься.
this.currentNode = null; // Сбрасываем, если no current_node_id
this.fetchMessagesFromRootToNode(''); // Передаем пустую строку или null, чтобы очистить историю
} }
} catch (error) { } catch (error: any) {
// Указываем тип any для error
console.error('Ошибка отправки сообщения:', error); console.error('Ошибка отправки сообщения:', error);
new Notice(`Ошибка: ${error?.message || error}`, 5000);
} }
} }
@ -240,4 +223,140 @@ export class ChatView extends ItemView {
this.currentNode = data.currentNode; // Может быть пустым, но это не проблема this.currentNode = data.currentNode; // Может быть пустым, но это не проблема
await this.fetchMessagesFromRootToNode(data.currentNode); await this.fetchMessagesFromRootToNode(data.currentNode);
} }
async streamLLMResponse(graphId: string, userNodeId: string, selectedModel?: string): Promise<void> {
const params = new URLSearchParams({
graph_id: graphId,
user_node_id: userNodeId,
system_prompt: this.plugin.settings.systemPrompt || '',
model: selectedModel || this.plugin.settings.defaultModel
});
const response = await fetch(`${this.plugin.settings.apiBaseUrl}/chat/stream`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
graph_id: graphId,
user_node_id: userNodeId,
system_prompt: this.plugin.settings.systemPrompt,
model: selectedModel || this.plugin.settings.defaultModel
})
});
if (!response.ok) {
throw new Error(`Ошибка стриминга: ${response.status}`);
}
const reader = response.body?.getReader();
if (!reader) {
throw new Error('Response body is null');
}
const decoder = new TextDecoder();
let assistantNodeId: string | null = null;
let accumulatedContent = '';
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
const chunk = decoder.decode(value, { stream: true });
const lines = chunk.split('\n');
for (const line of lines) {
if (line.startsWith('data: ')) {
const data = JSON.parse(line.slice(6));
if (data.type === 'node_created') {
assistantNodeId = data.node_id;
this.plugin.eventBus.emit('graph-selected', {
graphId: graphId,
currentNode: assistantNodeId
});
} else if (data.type === 'chunk' && assistantNodeId) {
accumulatedContent += data.content;
this.updateStreamingMessage(assistantNodeId, accumulatedContent);
} else if (data.type === 'error') {
console.error('Ошибка стриминга:', data.error);
if (assistantNodeId) {
this.markNodeAsError(assistantNodeId, data.error);
}
break;
} else if (data.type === 'done') {
this.currentNode = assistantNodeId;
this.plugin.eventBus.emit('graph-selected', {
graphId: graphId,
currentNode: assistantNodeId
});
break;
}
}
}
}
} finally {
reader.releaseLock();
}
}
updateStreamingMessage(nodeId: string, content: string): void {
const lastMessage = this.chatHistory[this.chatHistory.length - 1];
if (lastMessage && lastMessage.node_id === nodeId) {
lastMessage.content = content;
} else {
this.chatHistory.push({
role: 'assistant',
content: content,
node_id: nodeId,
graph_id: this.selectedGraphId || ''
});
}
if (this.chatPanel) {
this.chatPanel.updateHistory(this.chatHistory);
}
}
markNodeAsError(nodeId: string, errorMessage: string): void {
const lastMessage = this.chatHistory[this.chatHistory.length - 1];
if (lastMessage && lastMessage.node_id === nodeId) {
lastMessage.type = 'error';
lastMessage.content = errorMessage;
}
if (this.chatPanel) {
this.chatPanel.updateHistory(this.chatHistory);
}
}
async handleRegenerateMessage(data: { graphId: string; nodeId: string }): Promise<void> {
try {
const response = await fetch(`${this.plugin.settings.apiBaseUrl}/chat/regenerate`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
graph_id: data.graphId,
node_id: data.nodeId,
system_prompt: this.plugin.settings.systemPrompt,
model: this.plugin.settings.defaultModel
})
});
if (!response.ok) {
throw new Error(`Ошибка регенерации: ${response.status}`);
}
const result = await response.json();
const newNodeId = result.new_node_id;
const parentNodeId = result.parent_node_id;
// Запускаем стриминг для нового узла
if (data.graphId) {
await this.streamLLMResponse(data.graphId, parentNodeId, this.plugin.settings.defaultModel);
}
} catch (error: any) {
console.error('Ошибка регенерации:', error);
new Notice(`Ошибка регенерации: ${error?.message || error}`, 5000);
}
}
} }

View File

@ -120,6 +120,10 @@ export class GraphView extends ItemView {
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 this.plugin.eventBus.on('delete-node', this.handleDeleteNodeFromChat.bind(this)); // Слушаем событие из ChatPanel
// WebSocket Event Handlers
this.plugin.eventBus.on('ws-graph-title-updated', this.handleGraphTitleUpdated.bind(this));
this.plugin.eventBus.on('ws-node-title-updated', this.handleNodeTitleUpdated.bind(this));
// Загружаем список графов // Загружаем список графов
await this.fetchGraphsList(); await this.fetchGraphsList();
} }
@ -143,21 +147,25 @@ 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); this.plugin.eventBus.off('delete-node', this.handleDeleteNodeFromChat);
// WebSocket Event Cleanup
this.plugin.eventBus.off('ws-graph-title-updated', this.handleGraphTitleUpdated);
this.plugin.eventBus.off('ws-node-title-updated', this.handleNodeTitleUpdated);
} }
// ----------------------------------------------- Custom Title Update --------------------------------------------------- // ----------------------------------------------- Custom Title Update ---------------------------------------------------
/** /**
* Обновляет текстовое содержимое заголовка представления. * Обновляет текстовое содержимое заголовка представления.
* Этот метод должен быть вызван для динамического изменения заголовка * Этот метод должен быть вызван для динамического изменения заголовка
* после первоначальной отрисовки представления. * после первоначальной отрисовки представления.
*/ */
updateTitle(): void { updateTitle(): void {
const titleElement = this.containerEl.querySelector('.view-header-title'); const titleElement = this.containerEl.querySelector('.view-header-title');
if (titleElement) { if (titleElement) {
titleElement.textContent = this.selectedGraphTitle || 'LLM Agent Graph'; titleElement.textContent = this.selectedGraphTitle || 'LLM Agent Graph';
} }
} }
// ----------------------------------------------- History Panel Toggle -------------------------------------------------- // ----------------------------------------------- History Panel Toggle --------------------------------------------------
@ -300,7 +308,7 @@ export class GraphView extends ItemView {
this.currentNode = null; this.currentNode = null;
this.selectedNodeIds.clear(); // Очищаем выделения this.selectedNodeIds.clear(); // Очищаем выделения
this.selectedGraphTitle = null; // Сброс заголовка this.selectedGraphTitle = null; // Сброс заголовка
this.updateTitle(); // Обновляем заголовок this.updateTitle(); // Обновляем заголовок
this.renderGraphPanel(); this.renderGraphPanel();
this.plugin.eventBus.emit('chat-history-updated', { graphId: null, messages: [], current_node_id: null }); this.plugin.eventBus.emit('chat-history-updated', { graphId: null, messages: [], current_node_id: null });
return; return;
@ -321,7 +329,7 @@ export class GraphView extends ItemView {
// Устанавливаем заголовок графа // Устанавливаем заголовок графа
this.selectedGraphTitle = data.title || data.first_message || `Граф ${graphId.substring(0, 8)}...`; this.selectedGraphTitle = data.title || data.first_message || `Граф ${graphId.substring(0, 8)}...`;
this.updateTitle(); // Обновляем заголовок this.updateTitle(); // Обновляем заголовок
// Перерендериваем граф // Перерендериваем граф
this.renderGraphPanel(); this.renderGraphPanel();
@ -334,7 +342,7 @@ export class GraphView extends ItemView {
} catch (error) { } catch (error) {
console.error('Ошибка загрузки данных графа:', error); console.error('Ошибка загрузки данных графа:', error);
this.selectedGraphTitle = null; this.selectedGraphTitle = null;
this.updateTitle(); // Обновляем заголовок this.updateTitle(); // Обновляем заголовок
// Optionally: show a notice to the user // Optionally: show a notice to the user
// new Notice(`Ошибка загрузки данных графа: ${error.message}`); // new Notice(`Ошибка загрузки данных графа: ${error.message}`);
} }
@ -413,12 +421,12 @@ 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.selectedNodeIds.clear(); // Очищаем выделения
this.selectedGraphTitle = null; // Сброс заголовка при новом чате this.selectedGraphTitle = null; // Сброс заголовка при новом чате
this.updateTitle(); // Обновляем заголовок this.updateTitle(); // Обновляем заголовок
this.renderGraphPanel(); this.renderGraphPanel();
this.fetchGraphsList(); // Обновить список графов, чтобы показать новый пустой чат this.fetchGraphsList(); // Обновить список графов, чтобы показать новый пустой чат
this.plugin.eventBus.emit('chat-history-updated', { graphId: null, messages: [], current_node_id: null }); this.plugin.eventBus.emit('chat-history-updated', { graphId: null, messages: [], current_node_id: null });
} }
/** /**
@ -437,7 +445,7 @@ export class GraphView extends ItemView {
* Обработчик события удаления узла из ChatPanel. * Обработчик события удаления узла из ChatPanel.
* @param {{ graphId: string, nodeId: string }} data - Данные об удаляемом узле. * @param {{ graphId: string, nodeId: string }} data - Данные об удаляемом узле.
*/ */
async handleDeleteNodeFromChat(data: { graphId: string, nodeId: string }): Promise<void> { async handleDeleteNodeFromChat(data: { graphId: string; nodeId: string }): Promise<void> {
if (data.graphId === this.selectedGraphId) { if (data.graphId === this.selectedGraphId) {
await this.handleDeleteNode(data.nodeId); await this.handleDeleteNode(data.nodeId);
} else { } else {
@ -459,7 +467,7 @@ export class GraphView extends ItemView {
// Необходимо пройтись по nodes в graphData, чтобы получить актуальные данные сообщений // Необходимо пройтись по nodes в graphData, чтобы получить актуальные данные сообщений
const orderedNodeIds = Array.from(this.selectedNodeIds); // Сохраняем порядок const orderedNodeIds = Array.from(this.selectedNodeIds); // Сохраняем порядок
for (const nodeId of orderedNodeIds) { for (const nodeId of orderedNodeIds) {
const node = this.graphData.nodes.find(n => n.id === nodeId); const node = this.graphData.nodes.find((n) => n.id === nodeId);
if (node && node.data && node.data.message && node.data.message.content) { if (node && node.data && node.data.message && node.data.message.content) {
messagesToCopy.push(node.data.message.content); messagesToCopy.push(node.data.message.content);
} }
@ -496,8 +504,7 @@ export class GraphView extends ItemView {
this.plugin.app, this.plugin.app,
`Вы уверены, что хотите удалить ${this.selectedNodeIds.size} выделенных узлов?` `Вы уверены, что хотите удалить ${this.selectedNodeIds.size} выделенных узлов?`
); );
if (!confirmed) if (!confirmed) return;
return;
new Notice(`Начинаю удаление ${this.selectedNodeIds.size} узлов...`, 2000); new Notice(`Начинаю удаление ${this.selectedNodeIds.size} узлов...`, 2000);
const nodesToDelete = Array.from(this.selectedNodeIds); // Сохраняем порядок выделения const nodesToDelete = Array.from(this.selectedNodeIds); // Сохраняем порядок выделения
@ -525,4 +532,46 @@ export class GraphView extends ItemView {
await this.fetchGraphData(this.selectedGraphId); // Полностью обновляем граф и чат-панель await this.fetchGraphData(this.selectedGraphId); // Полностью обновляем граф и чат-панель
new Notice('Операция удаления завершена.', 2000); new Notice('Операция удаления завершена.', 2000);
} }
}
// ----------------------------------------------- WebSocket Event Handlers ---------------------------------------------------------------
/**
* Обработчик обновления заголовка графа через WebSocket
*/
handleGraphTitleUpdated(data: { graph_id: string; title: string }): void {
// Обновляем только если это текущий граф
if (this.selectedGraphId === data.graph_id) {
this.selectedGraphTitle = data.title;
this.updateTitle();
// Также обновляем в списке истории
if (this.historyPanel) {
this.fetchGraphsList(); // Перезагружаем список графов
}
}
}
/**
* Обработчик обновления заголовка узла через WebSocket
*/
handleNodeTitleUpdated(data: { graph_id: string; node_id: string; title: string }): void {
// Обновляем только если это узел текущего графа
if (this.selectedGraphId !== data.graph_id) {
return;
}
// Находим и обновляем узел в graphData
const nodeIndex = this.graphData.nodes.findIndex((n) => n.id === data.node_id);
if (nodeIndex !== -1) {
// Обновляем заголовок и флаг генерации
this.graphData.nodes[nodeIndex].data.title = data.title;
this.graphData.nodes[nodeIndex].data.title_generated = true;
// Перерисовываем граф с обновленными данными
// React Flow автоматически обновит только измененный узел
this.renderGraphPanel();
console.log(`✅ Обновлен заголовок узла ${data.node_id}: "${data.title}"`);
}
}
}