work on separation / streaming
This commit is contained in:
parent
b0d802241b
commit
826b9222d3
9
main.ts
9
main.ts
|
|
@ -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
133
package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -27,5 +27,8 @@
|
|||
"reactflow": "^11.11.3",
|
||||
"tslib": "^2.4.0",
|
||||
"typescript": "^5.4.5"
|
||||
},
|
||||
"dependencies": {
|
||||
"socket.io-client": "^4.8.1"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
|
|
|||
85
src/utils/WebSocketService.ts
Normal file
85
src/utils/WebSocketService.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}"`);
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user