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

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

133
package-lock.json generated
View File

@ -8,6 +8,9 @@
"name": "obsidian-sample-plugin",
"version": "1.0.0",
"license": "MIT",
"dependencies": {
"socket.io-client": "^4.8.1"
},
"devDependencies": {
"@types/dagre": "^0.7.52",
"@types/node": "^16.11.6",
@ -646,6 +649,11 @@
"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": {
"version": "5.60.8",
"resolved": "https://registry.npmjs.org/@types/codemirror/-/codemirror-5.60.8.tgz",
@ -1539,6 +1547,42 @@
"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": {
"version": "0.17.3",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.17.3.tgz",
@ -2292,8 +2336,7 @@
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"dev": true
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="
},
"node_modules/natural-compare": {
"version": "1.4.0",
@ -2648,6 +2691,64 @@
"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": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
@ -2838,6 +2939,34 @@
"dev": 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": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",

View File

@ -27,5 +27,8 @@
"reactflow": "^11.11.3",
"tslib": "^2.4.0",
"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', {
cls: 'text-token-text-secondary hover:bg-token-bg-secondary rounded-lg',

View File

@ -45,6 +45,7 @@ interface BaseNodeWrapperData {
title_generated?: boolean;
isSelected: boolean;
nodeId: string;
graphId: string;
onNodeDelete: (nodeId: string) => Promise<void>;
app: App;
}
@ -78,7 +79,7 @@ const BaseNodeWrapper: React.FC<BaseNodeWrapperProps> = ({ data, isConnectable,
color: 'var(--text-muted)', // Используем цвет текста Obsidian
padding: '0 5px',
opacity: data.isSelected ? 1 : 0, // Показываем только если узел выделен
transition: 'opacity 0.2s ease-in-out',
transition: 'opacity 0.2s ease-in-out'
};
// ----------------------------------------------- Event Handlers ------------------------------------------------------------
@ -89,15 +90,23 @@ const BaseNodeWrapper: React.FC<BaseNodeWrapperProps> = ({ data, isConnectable,
const handleNodeDelete = async (event: React.MouseEvent) => {
event.stopPropagation(); // Предотвращаем срабатывание onNodeClick на родительском узле
const confirmed = await Dialog.confirm(
data.app,
'Вы уверены, что хотите удалить этот узел?'
);
const confirmed = await Dialog.confirm(data.app, 'Вы уверены, что хотите удалить этот узел?');
if (confirmed) {
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 --------------------------------------------------------------------
return React.createElement(
React.Fragment,
@ -113,12 +122,28 @@ const BaseNodeWrapper: React.FC<BaseNodeWrapperProps> = ({ data, isConnectable,
isConnectable: isConnectable
}),
children, // Здесь будет отображаться специфическое содержимое узла
data.isSelected && React.createElement('button', {
data.isSelected && React.createElement('div', {
style: {
position: 'absolute',
bottom: '-30px',
left: '5px',
display: 'flex',
gap: '5px'
}
},
React.createElement('button', {
style: deleteButtonStyle,
onClick: handleNodeDelete,
'aria-label': 'Удалить узел',
className: 'node-delete-button' // Добавляем класс для стилизации
}, '🗑️') // Можно использовать SVG иконку
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 - Свойства компонента.
* @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 Flow
const [nodes, setNodes, onNodesChange] = useNodesState([]);
@ -313,16 +345,14 @@ export const GraphPanelComponent: React.FC<GraphPanelProps> = ({ graphData, onNo
label: node.data?.label || `Node ${node.id}`,
title: node.data?.title,
title_generated: node.data?.title_generated || false,
// Передаем колбэк onNodeDelete в данные узла, чтобы он был доступен в CustomNode
onNodeDelete: onNodeDelete,
isSelected: selectedNodeIds.has(node.id), // Передаем состояние выделения
// Добавляем graphId и nodeId для кнопки удаления
isSelected: selectedNodeIds.has(node.id),
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 },
className: selectedNodeIds.has(node.id) ? 'selected-node' : '', // Применяем класс для выделения
className: selectedNodeIds.has(node.id) ? 'selected-node' : '',
}));
const initialEdgesForLayout = graphData.edges.map((edge) => ({
@ -375,24 +405,27 @@ export const GraphPanelComponent: React.FC<GraphPanelProps> = ({ graphData, onNo
);
// Добавляем обработчик для 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')) {
// Если клик был не по узлу и не по ребру, сбрасываем выделение
if (selectedNodeIds.size > 0) {
// Вызываем колбэк для сброса всех выделений
selectedNodeIds.forEach(nodeId => onNodeSelectionChange(nodeId, false));
selectedNodeIds.forEach((nodeId) => onNodeSelectionChange(nodeId, false));
}
}
}, [selectedNodeIds, onNodeSelectionChange]);
},
[selectedNodeIds, onNodeSelectionChange]
);
// Обработчик для клавиатуры (Escape)
React.useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
if (selectedNodeIds.size > 0) {
selectedNodeIds.forEach(nodeId => onNodeSelectionChange(nodeId, false));
selectedNodeIds.forEach((nodeId) => onNodeSelectionChange(nodeId, false));
}
}
};

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-агенту и обрабатывает ответы, обновляя состояние чата и графа.
*/
import { ItemView, WorkspaceLeaf } from 'obsidian';
import { ItemView, Notice, WorkspaceLeaf } from 'obsidian';
import { ChatPanel } from '../components/ChatPanel';
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('new-chat', this.handleNewChat.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> {
if (this.chatPanel) {
this.chatPanel.destroy();
@ -78,6 +80,7 @@ export class ChatView extends ItemView {
this.plugin.eventBus.off('node-selected', this.handleNodeSelected);
this.plugin.eventBus.off('new-chat', this.handleNewChat);
this.plugin.eventBus.off('graph-selected', this.handleGraphSelected);
this.plugin.eventBus.off('regenerate-message', this.handleRegenerateMessage);
}
// ----------------------------------------------- API Methods ---------------------------------------------------------------
@ -145,62 +148,42 @@ export class ChatView extends ItemView {
async handleSendMessage(message: string, selectedModel?: string): Promise<void> {
try {
const response = await fetch(`${this.plugin.settings.apiBaseUrl}/chat`, {
// Шаг 1: Создаём узел пользователя
const sendResponse = await fetch(`${this.plugin.settings.apiBaseUrl}/chat/send`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
message: message,
graph_id: this.selectedGraphId,
parent_node_id: this.currentNode,
system_prompt: this.plugin.settings.systemPrompt,
model: selectedModel || 'gemini-2.5-flash'
parent_node_id: this.currentNode
})
});
if (!response.ok) {
// Пытаемся прочитать ошибку из тела ответа, если это возможно
const errorData = await response.json().catch(() => ({ message: response.statusText }));
throw new Error(`HTTP ошибка при отправке сообщения! Статус: ${response.status} - ${errorData.message}`);
if (!sendResponse.ok) {
throw new Error(`Ошибка при отправке сообщения: ${sendResponse.status}`);
}
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;
const newCurrentNodeId = result.graph_visualization_data.current_node_id;
this.currentNode = newCurrentNodeId;
// Сначала обновим GraphView, чтобы он запросил последнюю версию графа
// Это предотвратит состояние гонки, когда разные запросы возвращают частичные графы.
// Вместо того, чтобы отправлять частичные данные, мы запрашиваем полный граф у бэкенда.
this.plugin.eventBus.emit('graph-selected', { graphId: this.selectedGraphId, currentNode: this.currentNode });
// TODO: сделать аккуратнее - устанавливаем таймер на 30 секунд для обновления заголовков
this.titleUpdateTimer = setTimeout(() => {
console.log('Обновление графа для получения сгенерированных заголовков...');
if (this.selectedGraphId && this.currentNode) {
// Обновляем граф с новым узлом пользователя
this.plugin.eventBus.emit('graph-selected', {
graphId: this.selectedGraphId,
currentNode: this.currentNode
currentNode: userNodeId
});
}
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, чтобы очистить историю
// Шаг 2: Запускаем стриминг ответа
if (this.selectedGraphId) {
// Проверка на null
await this.streamLLMResponse(this.selectedGraphId, userNodeId, selectedModel);
} else {
throw new Error('Graph ID не определён после отправки сообщения');
}
} catch (error) {
} catch (error: any) {
// Указываем тип any для error
console.error('Ошибка отправки сообщения:', error);
new Notice(`Ошибка: ${error?.message || error}`, 5000);
}
}
@ -240,4 +223,140 @@ export class ChatView extends ItemView {
this.currentNode = 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('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();
}
@ -143,6 +147,10 @@ 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('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 ---------------------------------------------------
@ -437,7 +445,7 @@ export class GraphView extends ItemView {
* Обработчик события удаления узла из ChatPanel.
* @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) {
await this.handleDeleteNode(data.nodeId);
} else {
@ -459,7 +467,7 @@ export class GraphView extends ItemView {
// Необходимо пройтись по nodes в graphData, чтобы получить актуальные данные сообщений
const orderedNodeIds = Array.from(this.selectedNodeIds); // Сохраняем порядок
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) {
messagesToCopy.push(node.data.message.content);
}
@ -496,8 +504,7 @@ export class GraphView extends ItemView {
this.plugin.app,
`Вы уверены, что хотите удалить ${this.selectedNodeIds.size} выделенных узлов?`
);
if (!confirmed)
return;
if (!confirmed) return;
new Notice(`Начинаю удаление ${this.selectedNodeIds.size} узлов...`, 2000);
const nodesToDelete = Array.from(this.selectedNodeIds); // Сохраняем порядок выделения
@ -525,4 +532,46 @@ export class GraphView extends ItemView {
await this.fetchGraphData(this.selectedGraphId); // Полностью обновляем граф и чат-панель
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}"`);
}
}
}