/** * LLM Chat Panel Widget */ import BasicWidget from "../basic_widget.js"; import toastService from "../../services/toast.js"; import appContext from "../../components/app_context.js"; import server from "../../services/server.js"; import libraryLoader from "../../services/library_loader.js"; import { TPL, addMessageToChat, showSources, hideSources, showLoadingIndicator, hideLoadingIndicator } from "./ui.js"; import { formatMarkdown } from "./utils.js"; import { createChatSession, checkSessionExists, setupStreamingResponse, getDirectResponse } from "./communication.js"; import { extractInChatToolSteps } from "./message_processor.js"; import { validateEmbeddingProviders } from "./validation.js"; import type { MessageData, ToolExecutionStep, ChatData } from "./types.js"; import { applySyntaxHighlight } from "../../services/syntax_highlight.js"; // Import the LLM Chat CSS (async function() { await libraryLoader.requireCss('stylesheets/llm_chat.css'); })(); export default class LlmChatPanel extends BasicWidget { private noteContextChatMessages!: HTMLElement; private noteContextChatForm!: HTMLFormElement; private noteContextChatInput!: HTMLTextAreaElement; private noteContextChatSendButton!: HTMLButtonElement; private chatContainer!: HTMLElement; private loadingIndicator!: HTMLElement; private sourcesList!: HTMLElement; private sourcesContainer!: HTMLElement; private sourcesCount!: HTMLElement; private useAdvancedContextCheckbox!: HTMLInputElement; private showThinkingCheckbox!: HTMLInputElement; private validationWarning!: HTMLElement; private sessionId: string | null = null; private currentNoteId: string | null = null; private _messageHandlerId: number | null = null; private _messageHandler: any = null; // Callbacks for data persistence private onSaveData: ((data: any) => Promise) | null = null; private onGetData: (() => Promise) | null = null; private messages: MessageData[] = []; // Public getters and setters for private properties public getCurrentNoteId(): string | null { return this.currentNoteId; } public setCurrentNoteId(noteId: string | null): void { this.currentNoteId = noteId; } public getMessages(): MessageData[] { return this.messages; } public setMessages(messages: MessageData[]): void { this.messages = messages; } public getSessionId(): string | null { return this.sessionId; } public setSessionId(sessionId: string | null): void { this.sessionId = sessionId; } public getNoteContextChatMessages(): HTMLElement { return this.noteContextChatMessages; } public clearNoteContextChatMessages(): void { this.noteContextChatMessages.innerHTML = ''; } doRender() { this.$widget = $(TPL); const element = this.$widget[0]; this.noteContextChatMessages = element.querySelector('.note-context-chat-messages') as HTMLElement; this.noteContextChatForm = element.querySelector('.note-context-chat-form') as HTMLFormElement; this.noteContextChatInput = element.querySelector('.note-context-chat-input') as HTMLTextAreaElement; this.noteContextChatSendButton = element.querySelector('.note-context-chat-send-button') as HTMLButtonElement; this.chatContainer = element.querySelector('.note-context-chat-container') as HTMLElement; this.loadingIndicator = element.querySelector('.loading-indicator') as HTMLElement; this.sourcesList = element.querySelector('.sources-list') as HTMLElement; this.sourcesContainer = element.querySelector('.sources-container') as HTMLElement; this.sourcesCount = element.querySelector('.sources-count') as HTMLElement; this.useAdvancedContextCheckbox = element.querySelector('.use-advanced-context-checkbox') as HTMLInputElement; this.showThinkingCheckbox = element.querySelector('.show-thinking-checkbox') as HTMLInputElement; this.validationWarning = element.querySelector('.provider-validation-warning') as HTMLElement; // Set up event delegation for the settings link this.validationWarning.addEventListener('click', (e) => { const target = e.target as HTMLElement; if (target.classList.contains('settings-link') || target.closest('.settings-link')) { console.log('Settings link clicked, navigating to AI settings URL'); window.location.href = '#root/_hidden/_options/_optionsAi'; } }); this.initializeEventListeners(); return this.$widget; } cleanup() { console.log(`LlmChatPanel cleanup called, removing any active WebSocket subscriptions`); this._messageHandler = null; this._messageHandlerId = null; } /** * Set the callbacks for data persistence */ setDataCallbacks( saveDataCallback: (data: any) => Promise, getDataCallback: () => Promise ) { this.onSaveData = saveDataCallback; this.onGetData = getDataCallback; } /** * Save current chat data to the note attribute */ async saveCurrentData() { if (!this.onSaveData) { return; } try { // Extract current tool execution steps if any exist const toolSteps = extractInChatToolSteps(this.noteContextChatMessages); const dataToSave: ChatData = { messages: this.messages, sessionId: this.sessionId, toolSteps: toolSteps }; console.log(`Saving chat data with sessionId: ${this.sessionId} and ${toolSteps.length} tool steps`); await this.onSaveData(dataToSave); } catch (error) { console.error('Failed to save chat data', error); } } /** * Load saved chat data from the note attribute */ async loadSavedData(): Promise { if (!this.onGetData) { return false; } try { const savedData = await this.onGetData() as ChatData; if (savedData?.messages?.length > 0) { // Load messages this.messages = savedData.messages; // Clear and rebuild the chat UI this.noteContextChatMessages.innerHTML = ''; this.messages.forEach(message => { const role = message.role as 'user' | 'assistant'; this.addMessageToChat(role, message.content); }); // Restore tool execution steps if they exist if (savedData.toolSteps && Array.isArray(savedData.toolSteps) && savedData.toolSteps.length > 0) { console.log(`Restoring ${savedData.toolSteps.length} saved tool steps`); this.restoreInChatToolSteps(savedData.toolSteps); } // Load session ID if available if (savedData.sessionId) { try { // Verify the session still exists const sessionExists = await checkSessionExists(savedData.sessionId); if (sessionExists) { console.log(`Restored session ${savedData.sessionId}`); this.sessionId = savedData.sessionId; } else { console.log(`Saved session ${savedData.sessionId} not found, will create new one`); this.sessionId = null; await this.createChatSession(); } } catch (error) { console.log(`Error checking saved session ${savedData.sessionId}, creating a new one`); this.sessionId = null; await this.createChatSession(); } } else { // No saved session ID, create a new one this.sessionId = null; await this.createChatSession(); } return true; } } catch (error) { console.error('Failed to load saved chat data', error); } return false; } /** * Restore tool execution steps in the chat UI */ private restoreInChatToolSteps(steps: ToolExecutionStep[]) { if (!steps || steps.length === 0) return; // Create the tool execution element const toolExecutionElement = document.createElement('div'); toolExecutionElement.className = 'chat-tool-execution mb-3'; // Insert before the assistant message if it exists const assistantMessage = this.noteContextChatMessages.querySelector('.assistant-message:last-child'); if (assistantMessage) { this.noteContextChatMessages.insertBefore(toolExecutionElement, assistantMessage); } else { // Otherwise append to the end this.noteContextChatMessages.appendChild(toolExecutionElement); } // Fill with tool execution content toolExecutionElement.innerHTML = `
Tool Execution
${this.renderToolStepsHtml(steps)}
`; // Add event listener for the clear button const clearButton = toolExecutionElement.querySelector('.tool-execution-chat-clear'); if (clearButton) { clearButton.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); toolExecutionElement.remove(); }); } } /** * Render HTML for tool execution steps */ private renderToolStepsHtml(steps: ToolExecutionStep[]): string { if (!steps || steps.length === 0) return ''; return steps.map(step => { let icon = 'bx-info-circle'; let className = 'info'; let content = ''; if (step.type === 'executing') { icon = 'bx-code-block'; className = 'executing'; content = `
${step.content || 'Executing tools...'}
`; } else if (step.type === 'result') { icon = 'bx-terminal'; className = 'result'; content = `
Tool: ${step.name || 'unknown'}
${step.content || ''}
`; } else if (step.type === 'error') { icon = 'bx-error-circle'; className = 'error'; content = `
Tool: ${step.name || 'unknown'}
${step.content || 'Error occurred'}
`; } else if (step.type === 'generating') { icon = 'bx-message-dots'; className = 'generating'; content = `
${step.content || 'Generating response...'}
`; } return `
${content}
`; }).join(''); } async refresh() { if (!this.isVisible()) { return; } // Check for any provider validation issues when refreshing await validateEmbeddingProviders(this.validationWarning); // Get current note context if needed const currentActiveNoteId = appContext.tabManager.getActiveContext()?.note?.noteId || null; // If we're switching to a different note, we need to reset if (this.currentNoteId !== currentActiveNoteId) { console.log(`Note ID changed from ${this.currentNoteId} to ${currentActiveNoteId}, resetting chat panel`); // Reset the UI and data this.noteContextChatMessages.innerHTML = ''; this.messages = []; this.sessionId = null; this.hideSources(); // Hide any sources from previous note // Update our current noteId this.currentNoteId = currentActiveNoteId; } // Always try to load saved data for the current note const hasSavedData = await this.loadSavedData(); // Only create a new session if we don't have a session or saved data if (!this.sessionId || !hasSavedData) { // Create a new chat session await this.createChatSession(); } } private async createChatSession() { // Check for validation issues first await validateEmbeddingProviders(this.validationWarning); try { const sessionId = await createChatSession(); if (sessionId) { this.sessionId = sessionId; } } catch (error) { console.error('Failed to create chat session:', error); toastService.showError('Failed to create chat session'); } } /** * Handle sending a user message to the LLM service */ private async sendMessage(content: string) { if (!content.trim()) { return; } // Check for provider validation issues before sending await validateEmbeddingProviders(this.validationWarning); // Make sure we have a valid session if (!this.sessionId) { // If no session ID, create a new session await this.createChatSession(); if (!this.sessionId) { // If still no session ID, show error and return console.error("Failed to create chat session"); toastService.showError("Failed to create chat session"); return; } } else { // Verify the session exists on the server try { const sessionExists = await checkSessionExists(this.sessionId); if (!sessionExists) { console.log(`Session ${this.sessionId} not found, creating a new one`); await this.createChatSession(); } } catch (error) { console.log(`Error checking session ${this.sessionId}, creating a new one`); await this.createChatSession(); } } // Process the user message await this.processUserMessage(content); // Clear input and show loading state this.noteContextChatInput.value = ''; showLoadingIndicator(this.loadingIndicator); this.hideSources(); try { const useAdvancedContext = this.useAdvancedContextCheckbox.checked; const showThinking = this.showThinkingCheckbox.checked; // Add logging to verify parameters console.log(`Sending message with: useAdvancedContext=${useAdvancedContext}, showThinking=${showThinking}, noteId=${this.currentNoteId}, sessionId=${this.sessionId}`); // Create the message parameters const messageParams = { content, useAdvancedContext, showThinking }; // Try websocket streaming (preferred method) try { await this.setupStreamingResponse(messageParams); } catch (streamingError) { console.warn("WebSocket streaming failed, falling back to direct response:", streamingError); // If streaming fails, fall back to direct response const handled = await this.handleDirectResponse(messageParams); if (!handled) { // If neither method works, show an error throw new Error("Failed to get response from server"); } } } catch (error) { this.handleError(error as Error); } } /** * Process a new user message - add to UI and save */ private async processUserMessage(content: string) { // Add user message to the chat UI this.addMessageToChat('user', content); // Add to our local message array too this.messages.push({ role: 'user', content, timestamp: new Date() }); // Save to note this.saveCurrentData().catch(err => { console.error("Failed to save user message to note:", err); }); } /** * Try to get a direct response from the server */ private async handleDirectResponse(messageParams: any): Promise { try { if (!this.sessionId) return false; // Get a direct response from the server const postResponse = await getDirectResponse(this.sessionId, messageParams); // If the POST request returned content directly, display it if (postResponse && postResponse.content) { this.processAssistantResponse(postResponse.content); // If there are sources, show them if (postResponse.sources && postResponse.sources.length > 0) { this.showSources(postResponse.sources); } hideLoadingIndicator(this.loadingIndicator); return true; } return false; } catch (error) { console.error("Error with direct response:", error); return false; } } /** * Process an assistant response - add to UI and save */ private async processAssistantResponse(content: string) { // Add the response to the chat UI this.addMessageToChat('assistant', content); // Add to our local message array too this.messages.push({ role: 'assistant', content, timestamp: new Date() }); // Save to note this.saveCurrentData().catch(err => { console.error("Failed to save assistant response to note:", err); }); } /** * Set up streaming response via WebSocket */ private async setupStreamingResponse(messageParams: any): Promise { if (!this.sessionId) { throw new Error("No session ID available"); } return setupStreamingResponse( this.sessionId, messageParams, // Content update handler (content: string) => { this.updateStreamingUI(content); }, // Thinking update handler (thinking: string) => { this.showThinkingState(thinking); }, // Tool execution handler (toolData: any) => { this.showToolExecutionInfo(toolData); }, // Complete handler () => { hideLoadingIndicator(this.loadingIndicator); }, // Error handler (error: Error) => { this.handleError(error); } ); } /** * Update the UI with streaming content */ private updateStreamingUI(assistantResponse: string) { const logId = `LlmChatPanel-${Date.now()}`; console.log(`[${logId}] Updating UI with response text: ${assistantResponse.length} chars`); if (!this.noteContextChatMessages) { console.error(`[${logId}] noteContextChatMessages element not available`); return; } // With our new structured message approach, we don't need to extract tool steps from // the assistantResponse anymore, as tool execution is handled separately via dedicated messages // Find existing assistant message or create one if needed let assistantElement = this.noteContextChatMessages.querySelector('.assistant-message:last-child .message-content'); // Now update or create the assistant message with the response if (assistantResponse) { if (assistantElement) { console.log(`[${logId}] Found existing assistant message element, updating with response`); try { // Format the response with markdown const formattedResponse = formatMarkdown(assistantResponse); // Update the content assistantElement.innerHTML = formattedResponse || ''; // Apply syntax highlighting to any code blocks in the updated content applySyntaxHighlight($(assistantElement as HTMLElement)); console.log(`[${logId}] Successfully updated existing element with response`); } catch (err) { console.error(`[${logId}] Error updating existing element:`, err); // Fallback to text content if HTML update fails try { assistantElement.textContent = assistantResponse; console.log(`[${logId}] Fallback to text content successful`); } catch (fallbackErr) { console.error(`[${logId}] Even fallback update failed:`, fallbackErr); } } } else { console.log(`[${logId}] No existing assistant message element found, creating new one`); // Create a new message in the chat this.addMessageToChat('assistant', assistantResponse); console.log(`[${logId}] Successfully added new assistant message`); } } // Always try to scroll to the latest content try { if (this.chatContainer) { this.chatContainer.scrollTop = this.chatContainer.scrollHeight; console.log(`[${logId}] Scrolled to latest content`); } } catch (scrollErr) { console.error(`[${logId}] Error scrolling to latest content:`, scrollErr); } } /** * Handle general errors in the send message flow */ private handleError(error: Error) { hideLoadingIndicator(this.loadingIndicator); toastService.showError('Error sending message: ' + error.message); } private addMessageToChat(role: 'user' | 'assistant', content: string) { addMessageToChat(this.noteContextChatMessages, this.chatContainer, role, content); } private showSources(sources: Array<{noteId: string, title: string}>) { showSources( this.sourcesList, this.sourcesContainer, this.sourcesCount, sources, (noteId: string) => { // Open the note in a new tab but don't switch to it appContext.tabManager.openTabWithNoteWithHoisting(noteId, { activate: false }); } ); } private hideSources() { hideSources(this.sourcesContainer); } /** * Handle tool execution updates */ private showToolExecutionInfo(toolExecutionData: any) { console.log(`Showing tool execution info: ${JSON.stringify(toolExecutionData)}`); // Create or get the tool execution container let toolExecutionElement = this.noteContextChatMessages.querySelector('.chat-tool-execution'); if (!toolExecutionElement) { toolExecutionElement = document.createElement('div'); toolExecutionElement.className = 'chat-tool-execution mb-3'; // Create header with title and controls const header = document.createElement('div'); header.className = 'tool-execution-header d-flex align-items-center p-2 rounded'; header.innerHTML = ` Tool Execution `; toolExecutionElement.appendChild(header); // Add click handler for clear button const clearButton = toolExecutionElement.querySelector('.tool-execution-chat-clear'); if (clearButton) { clearButton.addEventListener('click', () => { const stepsContainer = toolExecutionElement?.querySelector('.tool-execution-container'); if (stepsContainer) { stepsContainer.innerHTML = ''; } }); } // Create container for tool steps const stepsContainer = document.createElement('div'); stepsContainer.className = 'tool-execution-container p-2 rounded mb-2'; toolExecutionElement.appendChild(stepsContainer); // Add to chat messages this.noteContextChatMessages.appendChild(toolExecutionElement); } // Get the steps container const stepsContainer = toolExecutionElement.querySelector('.tool-execution-container'); if (!stepsContainer) return; // Process based on action type const action = toolExecutionData.action || ''; if (action === 'start' || action === 'executing') { // Tool execution started const step = document.createElement('div'); step.className = 'tool-step executing p-2 mb-2 rounded'; step.innerHTML = `
Executing tool: ${toolExecutionData.tool || 'unknown'}
${toolExecutionData.args ? `
Args: ${JSON.stringify(toolExecutionData.args || {}, null, 2)}
` : ''} `; stepsContainer.appendChild(step); } else if (action === 'result' || action === 'complete') { // Tool execution completed with results const step = document.createElement('div'); step.className = 'tool-step result p-2 mb-2 rounded'; let resultDisplay = ''; // Special handling for search_notes tool which has a specific structure if (toolExecutionData.tool === 'search_notes' && typeof toolExecutionData.result === 'object' && toolExecutionData.result.results) { const results = toolExecutionData.result.results; if (results.length === 0) { resultDisplay = `
No notes found matching the search criteria.
`; } else { resultDisplay = `
Found ${results.length} notes:
    ${results.map((note: any) => `
  • ${note.title} ${note.similarity < 1 ? `(similarity: ${(note.similarity * 100).toFixed(0)}%)` : ''}
  • `).join('')}
`; } } // Format the result based on type for other tools else if (typeof toolExecutionData.result === 'object') { // For objects, format as pretty JSON resultDisplay = `
${JSON.stringify(toolExecutionData.result, null, 2)}
`; } else { // For simple values, display as text resultDisplay = `
${String(toolExecutionData.result)}
`; } step.innerHTML = `
Tool: ${toolExecutionData.tool || 'unknown'}
${resultDisplay}
`; stepsContainer.appendChild(step); // Add event listeners for note links if this is a search_notes result if (toolExecutionData.tool === 'search_notes') { const noteLinks = step.querySelectorAll('.note-link'); noteLinks.forEach(link => { link.addEventListener('click', (e) => { e.preventDefault(); const noteId = (e.currentTarget as HTMLElement).getAttribute('data-note-id'); if (noteId) { // Open the note in a new tab but don't switch to it appContext.tabManager.openTabWithNoteWithHoisting(noteId, { activate: false }); } }); }); } } else if (action === 'error') { // Tool execution failed const step = document.createElement('div'); step.className = 'tool-step error p-2 mb-2 rounded'; step.innerHTML = `
Error in tool: ${toolExecutionData.tool || 'unknown'}
${toolExecutionData.error || 'Unknown error'}
`; stepsContainer.appendChild(step); } else if (action === 'generating') { // Generating final response with tool results const step = document.createElement('div'); step.className = 'tool-step generating p-2 mb-2 rounded'; step.innerHTML = `
Generating response with tool results...
`; stepsContainer.appendChild(step); } // Make sure the loading indicator is shown during tool execution this.loadingIndicator.style.display = 'flex'; // Scroll the chat container to show the tool execution this.chatContainer.scrollTop = this.chatContainer.scrollHeight; } /** * Show thinking state in the UI */ private showThinkingState(thinkingData: string) { // Thinking state is now updated via the in-chat UI in updateStreamingUI // This method is now just a hook for the WebSocket handlers // Show the loading indicator this.loadingIndicator.style.display = 'flex'; } private initializeEventListeners() { this.noteContextChatForm.addEventListener('submit', (e) => { e.preventDefault(); const content = this.noteContextChatInput.value; this.sendMessage(content); }); // Add auto-resize functionality to the textarea this.noteContextChatInput.addEventListener('input', () => { this.noteContextChatInput.style.height = 'auto'; this.noteContextChatInput.style.height = `${this.noteContextChatInput.scrollHeight}px`; }); // Handle Enter key (send on Enter, new line on Shift+Enter) this.noteContextChatInput.addEventListener('keydown', (e) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); this.noteContextChatForm.dispatchEvent(new Event('submit')); } }); } }