/* * ChatView управляет пользовательским интерфейсом чата, отображает историю сообщений, * отправляет запросы LLM-агенту и обрабатывает ответы, обновляя состояние чата и графа. */ import { ItemView, Notice, WorkspaceLeaf } from 'obsidian'; import { ChatPanel } from '../components/ChatPanel'; import LLMAgentPlugin from 'main'; import { Attachment } from 'src/components/models/ChatHistoryItem'; import { TemplateEngine } from '../references/TemplateEngine'; export const CHAT_VIEW_TYPE = 'llm-agent-chat-view'; import { LLM_DEFAULTS } from 'src/constants'; // ----------------------------------------------- Chat View --------------------------------------------------------------- export class ChatView extends ItemView { plugin: LLMAgentPlugin; chatHistory: any[]; selectedGraphId: string | null; currentNode: string | null; chatContainer: HTMLDivElement; chatPanel: ChatPanel | null; currentGraphCustomSystemPrompt: string | null; currentStreamingNodeId: string | null = null; constructor(leaf: WorkspaceLeaf, plugin: LLMAgentPlugin) { super(leaf); this.plugin = plugin; this.chatHistory = []; this.selectedGraphId = null; this.currentNode = null; this.chatPanel = null; this.currentGraphCustomSystemPrompt = null; } getViewType(): string { return CHAT_VIEW_TYPE; } getDisplayText(): string { return 'LLM Agent Chat'; } getIcon(): string { return 'message-circle'; } async onOpen(): Promise { const container = this.containerEl.children[1] as HTMLElement; container.empty(); // Создаем только контейнер для чата (без истории) this.chatContainer = container.createDiv() as HTMLDivElement; this.chatContainer.addClass('llm-agent-chat-container'); // Загружаем доступные модели (TODO: проверить, что здесь это нужно, что не сделали этого ранее) // await this.plugin.fetchAvailableModels(); // Инициализируем только чат панель с передачей plugin this.chatPanel = new ChatPanel(this.chatContainer, { chatHistory: this.chatHistory, onSendMessage: this.handleSendMessage.bind(this), selectedModel: this.plugin.settings.defaultModel || LLM_DEFAULTS.MODEL, plugin: this.plugin, currentGraphCustomSystemPrompt: this.currentGraphCustomSystemPrompt }); this.plugin.eventBus.on('settings-changed', this.handleSettingsChanged.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('graph-selected', this.handleGraphSelected.bind(this)); this.plugin.eventBus.on('regenerate-message', this.handleRegenerateMessage.bind(this)); this.plugin.eventBus.on('ws-node-type-changed', this.handleNodeTypeChanged.bind(this)); this.plugin.eventBus.on('start-new-independent-chat', this.handleStartNewIndependentChat.bind(this)); this.plugin.eventBus.on('graph-custom-prompt-updated', this.handleGraphCustomPromptUpdated.bind(this)); this.plugin.eventBus.on('stop-stream-request', this.handleStopStreamRequest.bind(this)); // НОВОЕ this.plugin.eventBus.on('stream-started', this.handleStreamStarted.bind(this)); this.plugin.eventBus.on('stream-ended', this.handleStreamEnded.bind(this)); } async onClose(): Promise { if (this.chatPanel) { this.chatPanel.destroy(); this.chatPanel = null; } this.plugin.eventBus.off('settings-changed', this.handleSettingsChanged); 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); this.plugin.eventBus.off('ws-node-type-changed', this.handleNodeTypeChanged); this.plugin.eventBus.off('start-new-independent-chat', this.handleStartNewIndependentChat); this.plugin.eventBus.off('graph-custom-prompt-updated', this.handleGraphCustomPromptUpdated); this.plugin.eventBus.off('stop-stream-request', this.handleStopStreamRequest); // НОВОЕ this.plugin.eventBus.off('stream-started', this.handleStreamStarted); this.plugin.eventBus.off('stream-ended', this.handleStreamEnded); } // ----------------------------------------------- API Methods --------------------------------------------------------------- // TODO: проблемное место /** * Загружает историю сообщений от корня до указанного узла графа. * Обновляет `chatHistory` и отображение в `chatPanel`. * @param {string} nodeId - ID конечного узла для загрузки истории. * @returns {Promise} */ async fetchMessagesFromRootToNode(nodeId: string | null): Promise { try { // Убедимся, что selectedGraphId установлен if (!this.selectedGraphId) { console.warn('selectedGraphId не установлен. Невозможно загрузить сообщения.'); return; } const url = nodeId ? `${this.plugin.settings.apiBaseUrl}/messages/${this.selectedGraphId}/${nodeId}` : `${this.plugin.settings.apiBaseUrl}/messages/${this.selectedGraphId}/last`; const response = await fetch(url); if (!response.ok) { const errorText = await response.text(); throw new Error(`Ошибка загрузки сообщений: ${response.status} - ${errorText}`); } const data = await response.json(); this.chatHistory = data.map((msg: any) => ({ ...msg, type: msg.type || msg.role // Используем type, если есть, иначе role как fallback })) || []; // Уточнение, чтобы после выбора графа мы создавали узлы с не-пустым родителем if (this.chatHistory.length > 0) { // Устанавливаем currentNode на ID последнего сообщения в загруженной истории this.currentNode = this.chatHistory[this.chatHistory.length - 1].node_id; } else { this.currentNode = null; // Если история пуста, current_node_id также null } if (this.chatPanel) { await this.chatPanel.updateHistory(this.chatHistory); } } catch (error) { console.error('Ошибка загрузки сообщений:', error); // Optionally: show a notice to the user // new Notice(`Ошибка загрузки сообщений: ${error.message}`); /* Спорно, пока закомментил: this.chatHistory = []; // Очищаем историю при ошибке this.currentNode = null; // Сбрасываем currentNode при ошибке if (this.chatPanel) { await this.chatPanel.updateHistory(this.chatHistory); }*/ } } // ----------------------------------------------- Event Handlers --------------------------------------------------------------- async handleSendMessage(message: string, selectedModel?: string, attachments?: Attachment[], customMaxTokens?: number, customTemperature?: number, agencyMode: boolean = false): Promise { try { // ЗАЩИТА: Если граф не выбран, создаем его на лету, чтобы не потерять текст if (!this.selectedGraphId) { const createResponse = await fetch(`${this.plugin.settings.apiBaseUrl}/graphs/new`, { method: 'POST', headers: { 'Content-Type': 'application/json' } }); if (!createResponse.ok) { throw new Error(`Ошибка автоматического создания графа: ${createResponse.status}`); } const createResult = await createResponse.json(); this.selectedGraphId = createResult.graph_id; // Уведомляем другие компоненты о том, что выбран новый граф this.plugin.eventBus.emit('graph-selected', { graphId: this.selectedGraphId, currentNode: null }); } const cacheFolder = this.getCacheFolderPath(); // Шаг 1: Создаём узел пользователя с вложениями const sendResponse = await fetch(`${this.plugin.settings.apiBaseUrl}/chat/send`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ message: message, graph_id: this.selectedGraphId, parent_node_id: this.currentNode, attachments: attachments || [], cache_folder: cacheFolder }) }); if (!sendResponse.ok) { throw new Error(`Ошибка при отправке сообщения: ${sendResponse.status}`); } const sendResult = await sendResponse.json(); const userNodeId = sendResult.node_id; this.plugin.eventBus.emit('graph-selected', { graphId: this.selectedGraphId, currentNode: userNodeId }); // Шаг 2: Запускаем стриминг ответа if (this.selectedGraphId) { const systemPromptToUse = this.currentGraphCustomSystemPrompt !== null ? this.currentGraphCustomSystemPrompt : this.plugin.settings.systemPrompt; await this.streamLLMResponse(this.selectedGraphId!, userNodeId, selectedModel, null, systemPromptToUse, cacheFolder, customMaxTokens, customTemperature, agencyMode); } else { throw new Error('Graph ID не определён после отправки сообщения'); } } catch (error: any) { console.error('Ошибка отправки сообщения:', error); new Notice(`Ошибка: ${error?.message || error}`, 5000); } } handleSettingsChanged(): void { if (this.chatPanel) { // 1. Обновляем модель this.chatPanel.setSelectedModel(this.plugin.settings.defaultModel); // 2. Обновляем состояние тумблера из настроек плагина if (this.chatPanel.agencyToggle) { this.chatPanel.agencyToggle.checked = !!this.plugin.settings.agencyModeEnabled; } // 3. Уведомляем панель об обновлении всего объекта плагина (и настроек внутри) this.chatPanel.updateSettings(this.plugin); } } /** * Обрабатывает выбор узла на графе. Загружает историю сообщений до выбранного узла * и обновляет панель чата. * @param {{ nodeId: string }} data - Объект, содержащий ID выбранного узла. */ handleNodeSelected(data: { nodeId: string }): void { this.currentNode = data.nodeId; if (this.selectedGraphId) { this.fetchMessagesFromRootToNode(data.nodeId).then(() => { // После загрузки новой ветки проверяем, есть ли стриминг в ней if (this.currentStreamingNodeId) { const isInNewBranch = this.chatHistory.some((msg) => msg.node_id === this.currentStreamingNodeId); if (this.chatPanel) { this.chatPanel.updateStopButtonVisibility(isInNewBranch, isInNewBranch ? this.currentStreamingNodeId : null); } } }); } } /** * Обрабатывает событие начала нового чата. Сбрасывает текущее состояние чата * и очищает отображение истории. */ async handleNewChat(): Promise { this.chatHistory = []; this.currentNode = null; //this.selectedGraphId = null; //this.currentGraphCustomSystemPrompt = null; this.currentGraphCustomSystemPrompt = null; if (this.chatPanel) { await this.chatPanel.updateHistory(this.chatHistory); this.chatPanel.updateCustomSystemPromptStatus(null); } } /** * Обрабатывает выбор графа из `GraphView`. Устанавливает выбранный граф и обновляет */ async handleGraphSelected(data: { graphId: string; currentNode: string | null }): Promise { this.selectedGraphId = data.graphId; this.currentNode = data.currentNode; // Важно: custom_system_prompt будет обновлен через 'graph-custom-prompt-updated' событие, // которое генерируется в GraphView после fetchGraphData. // Здесь мы пока не трогаем this.currentGraphCustomSystemPrompt напрямую, // но ожидаем, что GraphView его обновит. await this.fetchMessagesFromRootToNode(data.currentNode); } /** * Стримит ответ LLM. */ async streamLLMResponse( graphId: string, userNodeId: string, selectedModel?: string, existingAssistantNodeId: string | null = null, systemPrompt: string | null = null, cacheFolder?: string, customMaxTokens?: number, customTemperature?: number, agencyMode: boolean = false ): Promise { // Разворачиваем системный промпт let finalSystemPrompt = systemPrompt; if (systemPrompt) { const templateEngine = new TemplateEngine(this.plugin); finalSystemPrompt = await templateEngine.processRawSettingValue(systemPrompt); } const temperature = customTemperature !== undefined ? customTemperature : ((this.plugin.settings as any).temperatureChat !== undefined ? (this.plugin.settings as any).temperatureChat : LLM_DEFAULTS.TEMPERATURE); const body = { graph_id: graphId, user_node_id: userNodeId, system_prompt: finalSystemPrompt, model: selectedModel || this.plugin.settings.defaultModel, cache_folder: cacheFolder, temperature: temperature, max_tokens: customMaxTokens, agency_mode: agencyMode }; if (existingAssistantNodeId) { Object.assign(body, { existing_assistant_node_id: existingAssistantNodeId }); } const abortController = new AbortController(); let assistantNodeId: string | null = null; let reader: ReadableStreamDefaultReader | null = null; try { const response = await fetch(`${this.plugin.settings.apiBaseUrl}/chat/stream`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), signal: abortController.signal }); if (!response.ok) { throw new Error(`Ошибка стриминга: ${response.status}`); } if (!response.body) { throw new Error('Response body is null'); } reader = response.body.getReader(); const decoder = new TextDecoder(); let accumulatedContent = ''; while (true) { if (abortController.signal.aborted) { break; } 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('stream-started', { nodeId: assistantNodeId, controller: abortController }); if (data.reused) { this.clearStreamingMessage(assistantNodeId!); } this.plugin.eventBus.emit('graph-selected', { graphId: graphId, currentNode: assistantNodeId }); } else if (data.type === 'tool_start' && assistantNodeId) { if (this.chatPanel) this.chatPanel.toolExecutionUpdate(assistantNodeId, data.name, 'start', data.step); } else if (data.type === 'tool_end' && assistantNodeId) { if (this.chatPanel) this.chatPanel.toolExecutionUpdate(assistantNodeId, data.name, 'end', data.step); } 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); this.plugin.eventBus.emit('graph-selected', { graphId: graphId, currentNode: assistantNodeId }); this.plugin.eventBus.emit('stream-ended', { nodeId: assistantNodeId }); } break; } else if (data.type === 'done') { this.currentNode = assistantNodeId; this.plugin.eventBus.emit('graph-selected', { graphId: graphId, currentNode: assistantNodeId }); if (assistantNodeId) { this.plugin.eventBus.emit('stream-ended', { nodeId: assistantNodeId }); } break; } } } } } catch (error: any) { if (error.name === 'AbortError') { console.log('Стрим был отменен пользователем'); if (assistantNodeId) { this.plugin.eventBus.emit('stream-ended', { nodeId: assistantNodeId }); } } else { throw error; // Пробрасываем другие ошибки } } finally { reader?.releaseLock(); if (assistantNodeId) { this.plugin.eventBus.emit('stream-ended', { nodeId: assistantNodeId }); } } } /** * Очищает содержимое сообщения в истории чата, помечая его как заглушку. * Используется для подготовки к регенерации. * @param {string} nodeId - ID узла, содержимое которого нужно очистить. */ clearStreamingMessage(nodeId: string): void { const message = this.chatHistory.find((msg) => msg.node_id === nodeId); if (message) { message.content = 'Получение ответа...'; message.type = 'llm'; // Точечное обновление вместо полного рендера всей панели if (this.chatPanel) { this.chatPanel.streamUpdate(nodeId, message.content); } } } updateStreamingMessage(nodeId: string, content: string): void { // Надежный поиск по ID, а не по длине массива (предотвращает создание дублей) let message = this.chatHistory.find((msg) => msg.node_id === nodeId); if (message) { message.content = content; // Отправляем точечное обновление в панель чата if (this.chatPanel) { this.chatPanel.streamUpdate(nodeId, content); } } else { // Если узла еще нет (первый чанк стрима), добавляем его ОДИН РАЗ this.chatHistory.push({ role: 'assistant', content: content, node_id: nodeId, graph_id: this.selectedGraphId || '' }); // При самом первом появлении отрисовываем штатным способом (создается HTML-контейнер) 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; } else { // Если узел не последний, попробуйте найти его и обновить const messageIndex = this.chatHistory.findIndex((msg) => msg.node_id === nodeId); if (messageIndex !== -1) { this.chatHistory[messageIndex].type = 'error'; this.chatHistory[messageIndex].content = errorMessage; } else { // Если узел не найден, добавьте новое сообщение об ошибке this.chatHistory.push({ role: 'assistant', content: errorMessage, node_id: nodeId, graph_id: this.selectedGraphId || '', type: 'error' }); } } if (this.chatPanel) { this.chatPanel.updateHistory(this.chatHistory); } } async handleRegenerateMessage(data: { graphId: string; nodeId: string; model: string, maxTokens?: number, temperature?: number, agencyMode?: boolean }): Promise { try { const systemPromptToUse = this.currentGraphCustomSystemPrompt !== null ? this.currentGraphCustomSystemPrompt : this.plugin.settings.systemPrompt; const cacheFolder = this.getCacheFolderPath(); const temperature = data.temperature !== undefined ? data.temperature : ((this.plugin.settings as any).temperatureChat !== undefined ? (this.plugin.settings as any).temperatureChat : LLM_DEFAULTS.TEMPERATURE); 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, model: data.model, system_prompt: systemPromptToUse, cache_folder: cacheFolder, temperature: temperature, max_tokens: data.maxTokens }) }); if (!response.ok) { throw new Error(`Ошибка регенерации: ${response.status}`); } const result = await response.json(); const existingAssistantNodeId = result.existing_assistant_node_id; const userNodeId = result.user_node_id; const modelToUse = result.model; const systemPromptFromBackend = result.system_prompt; const maxTokensFromBackend = result.max_tokens; const temperatureFromBackend = result.temperature; if (data.graphId) { await this.streamLLMResponse(data.graphId, userNodeId, modelToUse, existingAssistantNodeId, systemPromptFromBackend, cacheFolder, maxTokensFromBackend, temperatureFromBackend, data.agencyMode || false); } } catch (error: any) { console.error('Ошибка регенерации:', error); new Notice(`Ошибка регенерации: ${error?.message || error}`, 5000); } } /** * Обрабатывает событие начала нового независимого чата. * Сбрасывает текущее состояние ChatView, но не влияет на GraphView. */ async handleStartNewIndependentChat(): Promise { this.chatHistory = []; this.currentNode = null; // Оставляем this.selectedGraphId без изменений if (this.chatPanel) { await this.chatPanel.updateHistory(this.chatHistory); this.chatPanel.updateCustomSystemPromptStatus(null); } // Опционально: можно уведомить пользователя о начале нового чата new Notice('Начат новый чат. Текущий граф не изменен.', 2000); } handleStopStreamRequest(data: { nodeId: string }): void { const controller = (this.plugin as any).activeStreamControllers?.get(data.nodeId); if (controller) { controller.abort(); new Notice('Генерация остановлена пользователем.', 2000); } } handleStreamStarted(data: { nodeId: string; controller: AbortController }): void { this.currentStreamingNodeId = data.nodeId; // TODO: Временно не проверяем, что блок сообщения уже отрисован в панели, // т.к. у нас пока к моменту вызова 'stream-started' сообщение ещё не отрисовано // const isInCurrentBranch = this.chatHistory.some((msg) => msg.node_id === data.nodeId); // if (this.chatPanel && isInCurrentBranch) { if (this.chatPanel) { this.chatPanel.updateStopButtonVisibility(true, data.nodeId); } } handleStreamEnded(data: { nodeId: string }): void { if (this.currentStreamingNodeId === data.nodeId) { this.currentStreamingNodeId = null; if (this.chatPanel) { this.chatPanel.updateStopButtonVisibility(false, null); } } } // ----------------------------------------------- WebSocket Event Handlers --------------------------------------------------------------- handleNodeTypeChanged(data: { graphId: string; nodeId: string; nodeType: string }): void { // Обновляем тип узла в истории чата const messageIndex = this.chatHistory.findIndex((msg) => msg.node_id === data.nodeId); if (messageIndex !== -1) { this.chatHistory[messageIndex].type = data.nodeType; if (this.chatPanel) { this.chatPanel.updateHistory(this.chatHistory); } } } handleGraphCustomPromptUpdated(data: { graph_id: string; custom_system_prompt: string | null }): void { if (this.selectedGraphId === data.graph_id) { this.currentGraphCustomSystemPrompt = data.custom_system_prompt; if (this.chatPanel) { this.chatPanel.updateCustomSystemPromptStatus(this.currentGraphCustomSystemPrompt); } } else if (this.selectedGraphId === null && data.graph_id === null) { // Сброс промпта, если граф не выбран this.currentGraphCustomSystemPrompt = null; if (this.chatPanel) { this.chatPanel.updateCustomSystemPromptStatus(null); } } } // ----------------------------------------------- Helpers --------------------------------------------------------------- /** * Возвращает абсолютный путь к папке кеша */ private getCacheFolderPath(): string { // TODO: Нужно обработать случай, когда cacheFolder пустой return this.chatPanel?.cacheManager?.getCacheFolderPath() ?? ''; } }