diff --git a/src/public/app/widgets/llm_chat/communication.ts b/src/public/app/widgets/llm_chat/communication.ts new file mode 100644 index 000000000..2330940a5 --- /dev/null +++ b/src/public/app/widgets/llm_chat/communication.ts @@ -0,0 +1,256 @@ +/** + * Communication functions for LLM Chat + */ +import server from "../../services/server.js"; +import type { SessionResponse } from "./types.js"; + +/** + * Create a new chat session + */ +export async function createChatSession(): Promise { + try { + const resp = await server.post('llm/sessions', { + title: 'Note Chat' + }); + + if (resp && resp.id) { + return resp.id; + } + } catch (error) { + console.error('Failed to create chat session:', error); + } + + return null; +} + +/** + * Check if a session exists + */ +export async function checkSessionExists(sessionId: string): Promise { + try { + const sessionCheck = await server.get(`llm/sessions/${sessionId}`); + return !!(sessionCheck && sessionCheck.id); + } catch (error) { + console.log(`Error checking session ${sessionId}:`, error); + return false; + } +} + +/** + * Set up streaming response via WebSocket + */ +export async function setupStreamingResponse( + sessionId: string, + messageParams: any, + onContentUpdate: (content: string) => void, + onThinkingUpdate: (thinking: string) => void, + onToolExecution: (toolData: any) => void, + onComplete: () => void, + onError: (error: Error) => void +): Promise { + return new Promise((resolve, reject) => { + let assistantResponse = ''; + let receivedAnyContent = false; + let timeoutId: number | null = null; + let initialTimeoutId: number | null = null; + let receivedAnyMessage = false; + let eventListener: ((event: Event) => void) | null = null; + + // Create a unique identifier for this response process + const responseId = `llm-stream-${Date.now()}-${Math.floor(Math.random() * 1000)}`; + console.log(`[${responseId}] Setting up WebSocket streaming for session ${sessionId}`); + + // Create a message handler for CustomEvents + eventListener = (event: Event) => { + const customEvent = event as CustomEvent; + const message = customEvent.detail; + + // Only process messages for our session + if (!message || message.sessionId !== sessionId) { + return; + } + + console.log(`[${responseId}] LLM Stream message received via CustomEvent: session=${sessionId}, content=${!!message.content}, contentLength=${message.content?.length || 0}, thinking=${!!message.thinking}, toolExecution=${!!message.toolExecution}, done=${!!message.done}`); + + // Mark first message received + if (!receivedAnyMessage) { + receivedAnyMessage = true; + console.log(`[${responseId}] First message received for session ${sessionId}`); + + // Clear the initial timeout since we've received a message + if (initialTimeoutId !== null) { + window.clearTimeout(initialTimeoutId); + initialTimeoutId = null; + } + } + + // Handle content updates + if (message.content) { + receivedAnyContent = true; + assistantResponse += message.content; + + // Update the UI immediately + onContentUpdate(assistantResponse); + + // Reset timeout since we got content + if (timeoutId !== null) { + window.clearTimeout(timeoutId); + } + + // Set new timeout + timeoutId = window.setTimeout(() => { + console.warn(`[${responseId}] Stream timeout for session ${sessionId}`); + + // Clean up + cleanupEventListener(eventListener); + reject(new Error('Stream timeout')); + }, 30000); + } + + // Handle tool execution updates + if (message.toolExecution) { + console.log(`[${responseId}] Received tool execution update: action=${message.toolExecution.action || 'unknown'}`); + onToolExecution(message.toolExecution); + } + + // Handle thinking state updates + if (message.thinking) { + console.log(`[${responseId}] Received thinking update: ${message.thinking.substring(0, 50)}...`); + onThinkingUpdate(message.thinking); + } + + // Handle completion + if (message.done) { + console.log(`[${responseId}] Stream completed for session ${sessionId}, has content: ${!!message.content}, content length: ${message.content?.length || 0}, current response: ${assistantResponse.length} chars`); + + // Dump message content to console for debugging + if (message.content) { + console.log(`[${responseId}] CONTENT IN DONE MESSAGE (first 200 chars): "${message.content.substring(0, 200)}..."`); + } + + // Clear timeout if set + if (timeoutId !== null) { + window.clearTimeout(timeoutId); + timeoutId = null; + } + + // Check if we have content in the done message + if (message.content) { + console.log(`[${responseId}] Processing content in done message: ${message.content.length} chars`); + receivedAnyContent = true; + + // Replace current response if we didn't have content before or if it's empty + if (assistantResponse.length === 0) { + console.log(`[${responseId}] Using content from done message as full response`); + assistantResponse = message.content; + } + // Otherwise append it if it's different + else if (message.content !== assistantResponse) { + console.log(`[${responseId}] Appending content from done message to existing response`); + assistantResponse += message.content; + } + else { + console.log(`[${responseId}] Content in done message is identical to existing response, not appending`); + } + + onContentUpdate(assistantResponse); + } + + // Clean up and resolve + cleanupEventListener(eventListener); + onComplete(); + resolve(); + } + }; + + // Register event listener for the custom event + try { + window.addEventListener('llm-stream-message', eventListener); + console.log(`[${responseId}] Event listener added for llm-stream-message events`); + } catch (err) { + console.error(`[${responseId}] Error setting up event listener:`, err); + reject(err); + return; + } + + // Set initial timeout for receiving any message + initialTimeoutId = window.setTimeout(() => { + console.warn(`[${responseId}] No messages received for initial period in session ${sessionId}`); + if (!receivedAnyMessage) { + console.error(`[${responseId}] WebSocket connection not established for session ${sessionId}`); + + if (timeoutId !== null) { + window.clearTimeout(timeoutId); + } + + // Clean up + cleanupEventListener(eventListener); + + // Show error message to user + reject(new Error('WebSocket connection not established')); + } + }, 10000); + + // Send the streaming request to start the process + console.log(`[${responseId}] Sending HTTP POST request to initiate streaming: /llm/sessions/${sessionId}/messages/stream`); + server.post(`llm/sessions/${sessionId}/messages/stream`, { + ...messageParams, + stream: true // Explicitly indicate this is a streaming request + }).catch(err => { + console.error(`[${responseId}] HTTP error sending streaming request for session ${sessionId}:`, err); + + // Clean up timeouts + if (initialTimeoutId !== null) { + window.clearTimeout(initialTimeoutId); + initialTimeoutId = null; + } + + if (timeoutId !== null) { + window.clearTimeout(timeoutId); + timeoutId = null; + } + + // Clean up event listener + cleanupEventListener(eventListener); + + reject(err); + }); + }); +} + +/** + * Clean up an event listener + */ +function cleanupEventListener(listener: ((event: Event) => void) | null): void { + if (listener) { + try { + window.removeEventListener('llm-stream-message', listener); + console.log(`Successfully removed event listener`); + } catch (err) { + console.error(`Error removing event listener:`, err); + } + } +} + +/** + * Get a direct response from the server + */ +export async function getDirectResponse(sessionId: string, messageParams: any): Promise { + // Create a copy of the params without any streaming flags + const postParams = { + ...messageParams, + stream: false // Explicitly set to false to ensure we get a direct response + }; + + console.log(`Sending direct POST request for session ${sessionId}`); + + // Send the message via POST request with the updated params + return server.post(`llm/sessions/${sessionId}/messages`, postParams); +} + +/** + * Get embedding statistics + */ +export async function getEmbeddingStats(): Promise { + return server.get('llm/embeddings/stats'); +} diff --git a/src/public/app/widgets/llm_chat/index.ts b/src/public/app/widgets/llm_chat/index.ts new file mode 100644 index 000000000..8f0eb9f2d --- /dev/null +++ b/src/public/app/widgets/llm_chat/index.ts @@ -0,0 +1,6 @@ +/** + * LLM Chat Panel Widget Module + */ +import LlmChatPanel from './llm_chat_panel.js'; + +export default LlmChatPanel; diff --git a/src/public/app/widgets/llm_chat/llm_chat_panel.ts b/src/public/app/widgets/llm_chat/llm_chat_panel.ts new file mode 100644 index 000000000..9cfaed741 --- /dev/null +++ b/src/public/app/widgets/llm_chat/llm_chat_panel.ts @@ -0,0 +1,682 @@ +/** + * 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, renderToolStepsHtml } from "./ui.js"; +import { formatMarkdown } from "./utils.js"; +import { createChatSession, checkSessionExists, setupStreamingResponse, getDirectResponse } from "./communication.js"; +import { extractToolExecutionSteps, extractFinalResponse, 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 +
+ +
+
+ ${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(); + }); + } + } + + 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 as it arrives + */ + private updateStreamingUI(assistantResponse: string) { + const logId = `ui-update-${Date.now()}`; + console.log(`[${logId}] Updating UI with response text: ${assistantResponse.length} chars`); + + if (!this.noteContextChatMessages) { + console.error(`[${logId}] noteContextChatMessages element not available`); + return; + } + + // Extract the tool execution steps and final response + const toolSteps = extractToolExecutionSteps(assistantResponse); + const finalResponseText = extractFinalResponse(assistantResponse); + + // Find existing assistant message or create one if needed + let assistantElement = this.noteContextChatMessages.querySelector('.assistant-message:last-child .message-content'); + + // First, check if we need to add the tool execution steps to the chat flow + if (toolSteps.length > 0) { + // Look for an existing tool execution element in the chat flow + let toolExecutionElement = this.noteContextChatMessages.querySelector('.chat-tool-execution'); + + if (!toolExecutionElement) { + // Create a new tool execution element in the chat flow + // Place it right before the assistant message if it exists, or at the end of chat + toolExecutionElement = document.createElement('div'); + toolExecutionElement.className = 'chat-tool-execution mb-3'; + + // If there's an assistant message, insert before it + 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); + } + } + + // Update the tool execution content + toolExecutionElement.innerHTML = ` +
+
+
+ + Tool Execution +
+ +
+
+ ${renderToolStepsHtml(toolSteps)} +
+
+ `; + + // 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(); + }); + } + } + + // Now update or create the assistant message with the final response + if (finalResponseText) { + if (assistantElement) { + console.log(`[${logId}] Found existing assistant message element, updating with final response`); + try { + // Format the final response with markdown + const formattedResponse = formatMarkdown(finalResponseText); + + // 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 final response`); + } catch (err) { + console.error(`[${logId}] Error updating existing element:`, err); + // Fallback to text content if HTML update fails + try { + assistantElement.textContent = finalResponseText; + 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', finalResponseText); + 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); + } + + /** + * Show tool execution information in the UI + */ + private showToolExecutionInfo(toolExecutionData: any) { + console.log(`Showing tool execution info: ${JSON.stringify(toolExecutionData)}`); + + // We'll update the in-chat tool execution area in the updateStreamingUI method + // This method is now just a hook for the WebSocket handlers + + // Make sure the loading indicator is shown during tool execution + this.loadingIndicator.style.display = 'flex'; + } + + /** + * 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')); + } + }); + } +} diff --git a/src/public/app/widgets/llm_chat/message_processor.ts b/src/public/app/widgets/llm_chat/message_processor.ts new file mode 100644 index 000000000..cc20df084 --- /dev/null +++ b/src/public/app/widgets/llm_chat/message_processor.ts @@ -0,0 +1,119 @@ +/** + * Message processing functions for LLM Chat + */ +import type { ToolExecutionStep } from "./types.js"; + +/** + * Extract tool execution steps from the response + */ +export function extractToolExecutionSteps(content: string): ToolExecutionStep[] { + if (!content) return []; + + const steps: ToolExecutionStep[] = []; + + // Check for executing tools marker + if (content.includes('[Executing tools...]')) { + steps.push({ + type: 'executing', + content: 'Executing tools...' + }); + } + + // Extract tool results with regex + const toolResultRegex = /\[Tool: ([^\]]+)\]([\s\S]*?)(?=\[|$)/g; + let match; + + while ((match = toolResultRegex.exec(content)) !== null) { + const toolName = match[1]; + const toolContent = match[2].trim(); + + steps.push({ + type: toolContent.includes('Error:') ? 'error' : 'result', + name: toolName, + content: toolContent + }); + } + + // Check for generating response marker + if (content.includes('[Generating response with tool results...]')) { + steps.push({ + type: 'generating', + content: 'Generating response with tool results...' + }); + } + + return steps; +} + +/** + * Extract the final response without tool execution steps + */ +export function extractFinalResponse(content: string): string { + if (!content) return ''; + + // Remove all tool execution markers and their content + let finalResponse = content + .replace(/\[Executing tools\.\.\.\]\n*/g, '') + .replace(/\[Tool: [^\]]+\][\s\S]*?(?=\[|$)/g, '') + .replace(/\[Generating response with tool results\.\.\.\]\n*/g, ''); + + // Trim any extra whitespace + finalResponse = finalResponse.trim(); + + return finalResponse; +} + +/** + * Extract tool execution steps from the DOM that are within the chat flow + */ +export function extractInChatToolSteps(chatMessagesElement: HTMLElement): ToolExecutionStep[] { + const steps: ToolExecutionStep[] = []; + + // Look for tool execution in the chat flow + const toolExecutionElement = chatMessagesElement.querySelector('.chat-tool-execution'); + + if (toolExecutionElement) { + // Find all tool step elements + const stepElements = toolExecutionElement.querySelectorAll('.tool-step'); + + stepElements.forEach(stepEl => { + const stepHtml = stepEl.innerHTML; + + // Determine the step type based on icons or classes present + let type = 'info'; + let name: string | undefined; + let content = ''; + + if (stepHtml.includes('bx-code-block')) { + type = 'executing'; + content = 'Executing tools...'; + } else if (stepHtml.includes('bx-terminal')) { + type = 'result'; + // Extract the tool name from the step + const nameMatch = stepHtml.match(/]*>Tool: ([^<]+)<\/span>/); + name = nameMatch ? nameMatch[1] : 'unknown'; + + // Extract the content from the div with class mt-1 ps-3 + const contentEl = stepEl.querySelector('.mt-1.ps-3'); + content = contentEl ? contentEl.innerHTML : ''; + } else if (stepHtml.includes('bx-error-circle')) { + type = 'error'; + const nameMatch = stepHtml.match(/]*>Tool: ([^<]+)<\/span>/); + name = nameMatch ? nameMatch[1] : 'unknown'; + + const contentEl = stepEl.querySelector('.mt-1.ps-3.text-danger'); + content = contentEl ? contentEl.innerHTML : ''; + } else if (stepHtml.includes('bx-message-dots')) { + type = 'generating'; + content = 'Generating response with tool results...'; + } else if (stepHtml.includes('bx-loader-alt')) { + // Skip the initializing spinner + return; + } + + steps.push({ type, name, content }); + }); + } + + return steps; +} diff --git a/src/public/app/widgets/llm_chat/types.ts b/src/public/app/widgets/llm_chat/types.ts new file mode 100644 index 000000000..69222b389 --- /dev/null +++ b/src/public/app/widgets/llm_chat/types.ts @@ -0,0 +1,32 @@ +/** + * Types for LLM Chat Panel + */ + +export interface ChatResponse { + id: string; + messages: Array<{role: string; content: string}>; + sources?: Array<{noteId: string; title: string}>; +} + +export interface SessionResponse { + id: string; + title: string; +} + +export interface ToolExecutionStep { + type: string; + name?: string; + content: string; +} + +export interface MessageData { + role: string; + content: string; + timestamp?: Date; +} + +export interface ChatData { + messages: MessageData[]; + sessionId: string | null; + toolSteps: ToolExecutionStep[]; +} diff --git a/src/public/app/widgets/llm_chat/ui.ts b/src/public/app/widgets/llm_chat/ui.ts new file mode 100644 index 000000000..b4c9c9208 --- /dev/null +++ b/src/public/app/widgets/llm_chat/ui.ts @@ -0,0 +1,251 @@ +/** + * UI-related functions for LLM Chat + */ +import { t } from "../../services/i18n.js"; +import type { ToolExecutionStep } from "./types.js"; +import { formatMarkdown, applyHighlighting } from "./utils.js"; + +// Template for the chat widget +export const TPL = ` +
+ + + +
+
+ +
+ + + +
+
+ + +
+
+ Options: +
+ + +
+
+ + +
+
+
+
+`; + +/** + * Add a message to the chat UI + */ +export function addMessageToChat(messagesContainer: HTMLElement, chatContainer: HTMLElement, role: 'user' | 'assistant', content: string) { + const messageElement = document.createElement('div'); + messageElement.className = `chat-message ${role}-message mb-3 d-flex`; + + const avatarElement = document.createElement('div'); + avatarElement.className = 'message-avatar d-flex align-items-center justify-content-center me-2'; + + if (role === 'user') { + avatarElement.innerHTML = ''; + avatarElement.classList.add('user-avatar'); + } else { + avatarElement.innerHTML = ''; + avatarElement.classList.add('assistant-avatar'); + } + + const contentElement = document.createElement('div'); + contentElement.className = 'message-content p-3 rounded flex-grow-1'; + + if (role === 'user') { + contentElement.classList.add('user-content', 'bg-light'); + } else { + contentElement.classList.add('assistant-content'); + } + + // Format the content with markdown + contentElement.innerHTML = formatMarkdown(content); + + messageElement.appendChild(avatarElement); + messageElement.appendChild(contentElement); + + messagesContainer.appendChild(messageElement); + + // Apply syntax highlighting to any code blocks in the message + applyHighlighting(contentElement); + + // Scroll to bottom + chatContainer.scrollTop = chatContainer.scrollHeight; +} + +/** + * Show sources in the UI + */ +export function showSources( + sourcesList: HTMLElement, + sourcesContainer: HTMLElement, + sourcesCount: HTMLElement, + sources: Array<{noteId: string, title: string}>, + onSourceClick: (noteId: string) => void +) { + sourcesList.innerHTML = ''; + sourcesCount.textContent = sources.length.toString(); + + sources.forEach(source => { + const sourceElement = document.createElement('div'); + sourceElement.className = 'source-item p-2 mb-1 border rounded d-flex align-items-center'; + + // Create the direct link to the note + sourceElement.innerHTML = ` + `; + + // Add click handler + sourceElement.querySelector('.source-link')?.addEventListener('click', (e) => { + e.preventDefault(); + e.stopPropagation(); + onSourceClick(source.noteId); + return false; + }); + + sourcesList.appendChild(sourceElement); + }); + + sourcesContainer.style.display = 'block'; +} + +/** + * Hide sources in the UI + */ +export function hideSources(sourcesContainer: HTMLElement) { + sourcesContainer.style.display = 'none'; +} + +/** + * Show loading indicator + */ +export function showLoadingIndicator(loadingIndicator: HTMLElement) { + const logId = `ui-${Date.now()}`; + console.log(`[${logId}] Showing loading indicator`); + + try { + loadingIndicator.style.display = 'flex'; + const forceUpdate = loadingIndicator.offsetHeight; + console.log(`[${logId}] Loading indicator initialized`); + } catch (err) { + console.error(`[${logId}] Error showing loading indicator:`, err); + } +} + +/** + * Hide loading indicator + */ +export function hideLoadingIndicator(loadingIndicator: HTMLElement) { + const logId = `ui-${Date.now()}`; + console.log(`[${logId}] Hiding loading indicator`); + + try { + loadingIndicator.style.display = 'none'; + const forceUpdate = loadingIndicator.offsetHeight; + console.log(`[${logId}] Loading indicator hidden`); + } catch (err) { + console.error(`[${logId}] Error hiding loading indicator:`, err); + } +} + +/** + * Render tool steps as HTML for display in chat + */ +export function renderToolStepsHtml(steps: ToolExecutionStep[]): string { + if (!steps || steps.length === 0) return ''; + + let html = ''; + + steps.forEach(step => { + let icon, labelClass, content; + + switch (step.type) { + case 'executing': + icon = 'bx-code-block text-primary'; + labelClass = ''; + content = `
+ + ${step.content} +
`; + break; + + case 'result': + icon = 'bx-terminal text-success'; + labelClass = 'fw-bold'; + content = `
+ + Tool: ${step.name || 'unknown'} +
+
${step.content}
`; + break; + + case 'error': + icon = 'bx-error-circle text-danger'; + labelClass = 'fw-bold text-danger'; + content = `
+ + Tool: ${step.name || 'unknown'} +
+
${step.content}
`; + break; + + case 'generating': + icon = 'bx-message-dots text-info'; + labelClass = ''; + content = `
+ + ${step.content} +
`; + break; + + default: + icon = 'bx-info-circle text-muted'; + labelClass = ''; + content = `
+ + ${step.content} +
`; + } + + html += `
${content}
`; + }); + + return html; +} diff --git a/src/public/app/widgets/llm_chat/utils.ts b/src/public/app/widgets/llm_chat/utils.ts new file mode 100644 index 000000000..f7a880d69 --- /dev/null +++ b/src/public/app/widgets/llm_chat/utils.ts @@ -0,0 +1,93 @@ +/** + * Utility functions for LLM Chat + */ +import { marked } from "marked"; +import { applySyntaxHighlight } from "../../services/syntax_highlight.js"; + +/** + * Format markdown content for display + */ +export function formatMarkdown(content: string): string { + if (!content) return ''; + + // First, extract HTML thinking visualization to protect it from replacements + const thinkingBlocks: string[] = []; + let processedContent = content.replace(/
/g, (match) => { + const placeholder = `__THINKING_BLOCK_${thinkingBlocks.length}__`; + thinkingBlocks.push(match); + return placeholder; + }); + + // Use marked library to parse the markdown + const markedContent = marked(processedContent, { + breaks: true, // Convert line breaks to
+ gfm: true, // Enable GitHub Flavored Markdown + silent: true // Ignore errors + }); + + // Handle potential promise (though it shouldn't be with our options) + if (typeof markedContent === 'string') { + processedContent = markedContent; + } else { + console.warn('Marked returned a promise unexpectedly'); + // Use the original content as fallback + processedContent = content; + } + + // Restore thinking visualization blocks + thinkingBlocks.forEach((block, index) => { + processedContent = processedContent.replace(`__THINKING_BLOCK_${index}__`, block); + }); + + return processedContent; +} + +/** + * Simple HTML escaping for safer content display + */ +export function escapeHtml(text: string): string { + if (typeof text !== 'string') { + text = String(text || ''); + } + + return text + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +/** + * Apply syntax highlighting to content + */ +export function applyHighlighting(element: HTMLElement): void { + applySyntaxHighlight($(element)); +} + +/** + * Format tool arguments for display + */ +export function formatToolArgs(args: any): string { + if (!args || typeof args !== 'object') return ''; + + return Object.entries(args) + .map(([key, value]) => { + // Format the value based on its type + let displayValue; + if (typeof value === 'string') { + displayValue = value.length > 50 ? `"${value.substring(0, 47)}..."` : `"${value}"`; + } else if (value === null) { + displayValue = 'null'; + } else if (Array.isArray(value)) { + displayValue = '[...]'; // Simplified array representation + } else if (typeof value === 'object') { + displayValue = '{...}'; // Simplified object representation + } else { + displayValue = String(value); + } + + return `${escapeHtml(key)}: ${escapeHtml(displayValue)}`; + }) + .join(', '); +} diff --git a/src/public/app/widgets/llm_chat/validation.ts b/src/public/app/widgets/llm_chat/validation.ts new file mode 100644 index 000000000..294ae8018 --- /dev/null +++ b/src/public/app/widgets/llm_chat/validation.ts @@ -0,0 +1,104 @@ +/** + * Validation functions for LLM Chat + */ +import options from "../../services/options.js"; +import { getEmbeddingStats } from "./communication.js"; + +/** + * Validate embedding providers configuration + */ +export async function validateEmbeddingProviders(validationWarning: HTMLElement): Promise { + try { + // Check if AI is enabled + const aiEnabled = options.is('aiEnabled'); + if (!aiEnabled) { + validationWarning.style.display = 'none'; + return; + } + + // Get provider precedence + const precedenceStr = options.get('aiProviderPrecedence') || 'openai,anthropic,ollama'; + let precedenceList: string[] = []; + + if (precedenceStr) { + if (precedenceStr.startsWith('[') && precedenceStr.endsWith(']')) { + precedenceList = JSON.parse(precedenceStr); + } else if (precedenceStr.includes(',')) { + precedenceList = precedenceStr.split(',').map(p => p.trim()); + } else { + precedenceList = [precedenceStr]; + } + } + + // Get enabled providers - this is a simplification since we don't have direct DB access + // We'll determine enabled status based on the presence of keys or settings + const enabledProviders: string[] = []; + + // OpenAI is enabled if API key is set + const openaiKey = options.get('openaiApiKey'); + if (openaiKey) { + enabledProviders.push('openai'); + } + + // Anthropic is enabled if API key is set + const anthropicKey = options.get('anthropicApiKey'); + if (anthropicKey) { + enabledProviders.push('anthropic'); + } + + // Ollama is enabled if base URL is set + const ollamaBaseUrl = options.get('ollamaBaseUrl'); + if (ollamaBaseUrl) { + enabledProviders.push('ollama'); + } + + // Local is always available + enabledProviders.push('local'); + + // Perform validation checks + const allPrecedenceEnabled = precedenceList.every((p: string) => enabledProviders.includes(p)); + + // Get embedding queue status + const embeddingStats = await getEmbeddingStats() as { + success: boolean, + stats: { + totalNotesCount: number; + embeddedNotesCount: number; + queuedNotesCount: number; + failedNotesCount: number; + lastProcessedDate: string | null; + percentComplete: number; + } + }; + const queuedNotes = embeddingStats?.stats?.queuedNotesCount || 0; + const hasEmbeddingsInQueue = queuedNotes > 0; + + // Show warning if there are issues + if (!allPrecedenceEnabled || hasEmbeddingsInQueue) { + let message = 'AI Provider Configuration Issues'; + + message += '
    '; + + if (!allPrecedenceEnabled) { + const disabledProviders = precedenceList.filter((p: string) => !enabledProviders.includes(p)); + message += `
  • The following providers in your precedence list are not enabled: ${disabledProviders.join(', ')}.
  • `; + } + + if (hasEmbeddingsInQueue) { + message += `
  • Currently processing embeddings for ${queuedNotes} notes. Some AI features may produce incomplete results until processing completes.
  • `; + } + + message += '
'; + message += ''; + + // Update HTML content + validationWarning.innerHTML = message; + validationWarning.style.display = 'block'; + } else { + validationWarning.style.display = 'none'; + } + } catch (error) { + console.error('Error validating embedding providers:', error); + validationWarning.style.display = 'none'; + } +} diff --git a/src/public/app/widgets/llm_chat_panel.ts b/src/public/app/widgets/llm_chat_panel.ts index be0041c8a..fd26850cc 100644 --- a/src/public/app/widgets/llm_chat_panel.ts +++ b/src/public/app/widgets/llm_chat_panel.ts @@ -1,1475 +1,7 @@ -import BasicWidget from "./basic_widget.js"; -import toastService from "../services/toast.js"; -import server from "../services/server.js"; -import appContext from "../components/app_context.js"; -import utils from "../services/utils.js"; -import { t } from "../services/i18n.js"; -import libraryLoader from "../services/library_loader.js"; -import { applySyntaxHighlight } from "../services/syntax_highlight.js"; -import options from "../services/options.js"; -import ws from "../services/ws.js"; -import { marked } from "marked"; - -// Import the LLM Chat CSS -(async function() { - await libraryLoader.requireCss('stylesheets/llm_chat.css'); -})(); - -const TPL = ` -
- - - -
-
- -
- - - -
-
- - -
-
- Options: -
- - -
-
- - -
-
-
-
-`; - -interface ChatResponse { - id: string; - messages: Array<{role: string; content: string}>; - sources?: Array<{noteId: string; title: string}>; -} - -interface SessionResponse { - id: string; - title: string; -} - -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 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: Array<{role: string; content: string; timestamp?: Date}> = []; - - // 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(): Array<{role: string; content: string; timestamp?: Date}> { - return this.messages; - } - - public setMessages(messages: Array<{role: string; content: string; timestamp?: Date}>): 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.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(); - - // Don't create a session here - wait for refresh - // This prevents the wrong session from being created for the wrong note - - return this.$widget; - } - - cleanup() { - console.log(`LlmChatPanel cleanup called, removing any active WebSocket subscriptions`); - - // No need to manually clean up the event listeners, as they will be garbage collected - // when the component is destroyed. We only need to clean up references. - 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 = this.extractInChatToolSteps(); - - const dataToSave = { - messages: this.messages, - sessionId: this.sessionId, - toolSteps: toolSteps // Save tool execution steps alongside messages - }; - - 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); - } - } - - /** - * Extract tool execution steps from the DOM that are within the chat flow - */ - private extractInChatToolSteps(): Array<{type: string, name?: string, content: string}> { - const steps: Array<{type: string, name?: string, content: string}> = []; - - // Look for tool execution in the chat flow - const toolExecutionElement = this.noteContextChatMessages.querySelector('.chat-tool-execution'); - - if (toolExecutionElement) { - // Find all tool step elements - const stepElements = toolExecutionElement.querySelectorAll('.tool-step'); - - stepElements.forEach(stepEl => { - const stepHtml = stepEl.innerHTML; - - // Determine the step type based on icons or classes present - let type = 'info'; - let name: string | undefined; - let content = ''; - - if (stepHtml.includes('bx-code-block')) { - type = 'executing'; - content = 'Executing tools...'; - } else if (stepHtml.includes('bx-terminal')) { - type = 'result'; - // Extract the tool name from the step - const nameMatch = stepHtml.match(/]*>Tool: ([^<]+)<\/span>/); - name = nameMatch ? nameMatch[1] : 'unknown'; - - // Extract the content from the div with class mt-1 ps-3 - const contentEl = stepEl.querySelector('.mt-1.ps-3'); - content = contentEl ? contentEl.innerHTML : ''; - } else if (stepHtml.includes('bx-error-circle')) { - type = 'error'; - const nameMatch = stepHtml.match(/]*>Tool: ([^<]+)<\/span>/); - name = nameMatch ? nameMatch[1] : 'unknown'; - - const contentEl = stepEl.querySelector('.mt-1.ps-3.text-danger'); - content = contentEl ? contentEl.innerHTML : ''; - } else if (stepHtml.includes('bx-message-dots')) { - type = 'generating'; - content = 'Generating response with tool results...'; - } else if (stepHtml.includes('bx-loader-alt')) { - // Skip the initializing spinner - return; - } - - steps.push({ type, name, content }); - }); - } - - return steps; - } - - /** - * Load saved chat data from the note attribute - */ - async loadSavedData(): Promise { - if (!this.onGetData) { - return false; - } - - try { - const savedData = await this.onGetData(); - - 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 sessionCheck = await server.get(`llm/sessions/${savedData.sessionId}`); - - if (sessionCheck && sessionCheck.id) { - 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: Array<{type: string, name?: string, content: string}>) { - 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(); - }); - } - } - - async refresh() { - if (!this.isVisible()) { - return; - } - - // Check for any provider validation issues when refreshing - await this.validateEmbeddingProviders(); - - // 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 this.validateEmbeddingProviders(); - - try { - const resp = await server.post('llm/sessions', { - title: 'Note Chat' - }); - - if (resp && resp.id) { - this.sessionId = resp.id; - } - } 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 this.validateEmbeddingProviders(); - - // 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 sessionCheck = await server.get(`llm/sessions/${this.sessionId}`); - if (!sessionCheck || !sessionCheck.id) { - 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 = ''; - this.showLoadingIndicator(); - 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 - * @returns true if response was handled, false if streaming should be used - */ - private async handleDirectResponse(messageParams: any): Promise { - try { - // Create a copy of the params without any streaming flags - const postParams = { - ...messageParams, - stream: false // Explicitly set to false to ensure we get a direct response - }; - - console.log(`Sending direct POST request for session ${this.sessionId}`); - - // Send the message via POST request with the updated params - const postResponse = await server.post(`llm/sessions/${this.sessionId}/messages`, postParams); - - // 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); - } - - this.hideLoadingIndicator(); - 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 { - const content = messageParams.content || ''; - const useAdvancedContext = messageParams.useAdvancedContext; - const showThinking = messageParams.showThinking; - - return new Promise((resolve, reject) => { - let assistantResponse = ''; - let receivedAnyContent = false; - let timeoutId: number | null = null; - let initialTimeoutId: number | null = null; - let receivedAnyMessage = false; - let eventListener: ((event: Event) => void) | null = null; - - // Create a unique identifier for this response process - const responseId = `llm-stream-${Date.now()}-${Math.floor(Math.random() * 1000)}`; - console.log(`[${responseId}] Setting up WebSocket streaming for session ${this.sessionId}`); - - // Create a message handler for CustomEvents - eventListener = (event: Event) => { - const customEvent = event as CustomEvent; - const message = customEvent.detail; - - // Only process messages for our session - if (!message || message.sessionId !== this.sessionId) { - return; - } - - console.log(`[${responseId}] LLM Stream message received via CustomEvent: session=${this.sessionId}, content=${!!message.content}, contentLength=${message.content?.length || 0}, thinking=${!!message.thinking}, toolExecution=${!!message.toolExecution}, done=${!!message.done}`); - - // Mark first message received - if (!receivedAnyMessage) { - receivedAnyMessage = true; - console.log(`[${responseId}] First message received for session ${this.sessionId}`); - - // Clear the initial timeout since we've received a message - if (initialTimeoutId !== null) { - window.clearTimeout(initialTimeoutId); - initialTimeoutId = null; - } - } - - // Handle content updates - if (message.content) { - receivedAnyContent = true; - assistantResponse += message.content; - - // Update the UI immediately - this.updateStreamingUI(assistantResponse); - - // Reset timeout since we got content - if (timeoutId !== null) { - window.clearTimeout(timeoutId); - } - - // Set new timeout - timeoutId = window.setTimeout(() => { - console.warn(`[${responseId}] Stream timeout for session ${this.sessionId}`); - - // Save what we have - if (assistantResponse) { - console.log(`[${responseId}] Saving partial response due to timeout (${assistantResponse.length} chars)`); - this.messages.push({ - role: 'assistant', - content: assistantResponse, - timestamp: new Date() - }); - this.saveCurrentData().catch(err => { - console.error(`[${responseId}] Failed to save partial response:`, err); - }); - } - - // Clean up - this.cleanupEventListener(eventListener); - this.hideLoadingIndicator(); - reject(new Error('Stream timeout')); - }, 30000); - } - - // Handle tool execution updates - if (message.toolExecution) { - console.log(`[${responseId}] Received tool execution update: action=${message.toolExecution.action || 'unknown'}`); - this.showToolExecutionInfo(message.toolExecution); - this.loadingIndicator.style.display = 'flex'; - } - - // Handle thinking state updates - if (message.thinking) { - console.log(`[${responseId}] Received thinking update: ${message.thinking.substring(0, 50)}...`); - this.showThinkingState(message.thinking); - this.loadingIndicator.style.display = 'flex'; - } - - // Handle completion - if (message.done) { - console.log(`[${responseId}] Stream completed for session ${this.sessionId}, has content: ${!!message.content}, content length: ${message.content?.length || 0}, current response: ${assistantResponse.length} chars`); - - // Dump message content to console for debugging - if (message.content) { - console.log(`[${responseId}] CONTENT IN DONE MESSAGE (first 200 chars): "${message.content.substring(0, 200)}..."`); - } - - // Clear timeout if set - if (timeoutId !== null) { - window.clearTimeout(timeoutId); - timeoutId = null; - } - - // Check if we have content in the done message - // This is particularly important for Ollama which often sends the entire response in one message - if (message.content) { - console.log(`[${responseId}] Processing content in done message: ${message.content.length} chars`); - receivedAnyContent = true; - - // Replace current response if we didn't have content before or if it's empty - if (assistantResponse.length === 0) { - console.log(`[${responseId}] Using content from done message as full response`); - assistantResponse = message.content; - } - // Otherwise append it if it's different - else if (message.content !== assistantResponse) { - console.log(`[${responseId}] Appending content from done message to existing response`); - assistantResponse += message.content; - } - else { - console.log(`[${responseId}] Content in done message is identical to existing response, not appending`); - } - - this.updateStreamingUI(assistantResponse); - } - - // Save the final response - if (assistantResponse) { - console.log(`[${responseId}] Saving final response of ${assistantResponse.length} chars`); - this.messages.push({ - role: 'assistant', - content: assistantResponse, - timestamp: new Date() - }); - - this.saveCurrentData().catch(err => { - console.error(`[${responseId}] Failed to save final response:`, err); - }); - } else { - // If we didn't receive any content at all, show a generic message - console.log(`[${responseId}] No content received for session ${this.sessionId}`); - const defaultMessage = 'I processed your request, but I don\'t have any specific information to share at the moment.'; - this.processAssistantResponse(defaultMessage); - } - - // Clean up and resolve - this.cleanupEventListener(eventListener); - this.hideLoadingIndicator(); - resolve(); - } - }; - - // Register event listener for the custom event - try { - window.addEventListener('llm-stream-message', eventListener); - console.log(`[${responseId}] Event listener added for llm-stream-message events`); - } catch (err) { - console.error(`[${responseId}] Error setting up event listener:`, err); - reject(err); - return; - } - - // Set initial timeout for receiving any message - initialTimeoutId = window.setTimeout(() => { - console.warn(`[${responseId}] No messages received for initial period in session ${this.sessionId}`); - if (!receivedAnyMessage) { - console.error(`[${responseId}] WebSocket connection not established for session ${this.sessionId}`); - - if (timeoutId !== null) { - window.clearTimeout(timeoutId); - } - - // Clean up - this.cleanupEventListener(eventListener); - this.hideLoadingIndicator(); - - // Show error message to user - const errorMessage = 'Connection error: Unable to establish WebSocket streaming.'; - this.processAssistantResponse(errorMessage); - reject(new Error('WebSocket connection not established')); - } - }, 10000); - - // Send the streaming request to start the process - console.log(`[${responseId}] Sending HTTP POST request to initiate streaming: /llm/sessions/${this.sessionId}/messages/stream`); - server.post(`llm/sessions/${this.sessionId}/messages/stream`, { - content, - useAdvancedContext, - showThinking, - stream: true // Explicitly indicate this is a streaming request - }).catch(err => { - console.error(`[${responseId}] HTTP error sending streaming request for session ${this.sessionId}:`, err); - - // Clean up timeouts - if (initialTimeoutId !== null) { - window.clearTimeout(initialTimeoutId); - initialTimeoutId = null; - } - - if (timeoutId !== null) { - window.clearTimeout(timeoutId); - timeoutId = null; - } - - // Clean up event listener - this.cleanupEventListener(eventListener); - - reject(err); - }); - }); - } - - /** - * Clean up an event listener - */ - private cleanupEventListener(listener: ((event: Event) => void) | null): void { - if (listener) { - try { - window.removeEventListener('llm-stream-message', listener); - console.log(`Successfully removed event listener`); - } catch (err) { - console.error(`Error removing event listener:`, err); - } - } - } - - /** - * Update the UI with streaming content as it arrives - */ - private updateStreamingUI(assistantResponse: string) { - const logId = `ui-update-${Date.now()}`; - console.log(`[${logId}] Updating UI with response text: ${assistantResponse.length} chars`); - - if (!this.noteContextChatMessages) { - console.error(`[${logId}] noteContextChatMessages element not available`); - return; - } - - // Extract the tool execution steps and final response - const toolSteps = this.extractToolExecutionSteps(assistantResponse); - const finalResponseText = this.extractFinalResponse(assistantResponse); - - // Find existing assistant message or create one if needed - let assistantElement = this.noteContextChatMessages.querySelector('.assistant-message:last-child .message-content'); - - // First, check if we need to add the tool execution steps to the chat flow - if (toolSteps.length > 0) { - // Look for an existing tool execution element in the chat flow - let toolExecutionElement = this.noteContextChatMessages.querySelector('.chat-tool-execution'); - - if (!toolExecutionElement) { - // Create a new tool execution element in the chat flow - // Place it right before the assistant message if it exists, or at the end of chat - toolExecutionElement = document.createElement('div'); - toolExecutionElement.className = 'chat-tool-execution mb-3'; - - // If there's an assistant message, insert before it - 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); - } - } - - // Update the tool execution content - toolExecutionElement.innerHTML = ` -
-
-
- - Tool Execution -
- -
-
- ${this.renderToolStepsHtml(toolSteps)} -
-
- `; - - // 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(); - }); - } - } - - // Now update or create the assistant message with the final response - if (finalResponseText) { - if (assistantElement) { - console.log(`[${logId}] Found existing assistant message element, updating with final response`); - try { - // Format the final response with markdown - const formattedResponse = this.formatMarkdown(finalResponseText); - - // 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 final response`); - } catch (err) { - console.error(`[${logId}] Error updating existing element:`, err); - // Fallback to text content if HTML update fails - try { - assistantElement.textContent = finalResponseText; - 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`); - try { - // Create new message element - const messageElement = document.createElement('div'); - messageElement.className = 'chat-message assistant-message mb-3 d-flex'; - - const avatarElement = document.createElement('div'); - avatarElement.className = 'message-avatar d-flex align-items-center justify-content-center me-2 assistant-avatar'; - avatarElement.innerHTML = ''; - - const contentElement = document.createElement('div'); - contentElement.className = 'message-content p-3 rounded flex-grow-1 assistant-content'; - - // Only show the final response in the message content - contentElement.innerHTML = this.formatMarkdown(finalResponseText) || ''; - - messageElement.appendChild(avatarElement); - messageElement.appendChild(contentElement); - - this.noteContextChatMessages.appendChild(messageElement); - - // Apply syntax highlighting to any code blocks in the message - applySyntaxHighlight($(contentElement)); - - console.log(`[${logId}] Successfully added new assistant message`); - } catch (err) { - console.error(`[${logId}] Error adding new message:`, err); - - // Last resort emergency approach - create element directly - try { - console.log(`[${logId}] Attempting emergency DOM update`); - const emergencyElement = document.createElement('div'); - emergencyElement.className = 'chat-message assistant-message mb-3 d-flex'; - emergencyElement.innerHTML = ` -
- -
-
- ${finalResponseText} -
- `; - this.noteContextChatMessages.appendChild(emergencyElement); - console.log(`[${logId}] Emergency DOM update successful`); - } catch (emergencyErr) { - console.error(`[${logId}] Emergency DOM update failed:`, emergencyErr); - } - } - } - } - - // 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); - } - } - - /** - * Render tool steps as HTML for display in chat - */ - private renderToolStepsHtml(steps: Array<{type: string, name?: string, content: string}>): string { - if (!steps || steps.length === 0) return ''; - - let html = ''; - - steps.forEach(step => { - let icon, labelClass, content; - - switch (step.type) { - case 'executing': - icon = 'bx-code-block text-primary'; - labelClass = ''; - content = `
- - ${step.content} -
`; - break; - - case 'result': - icon = 'bx-terminal text-success'; - labelClass = 'fw-bold'; - content = `
- - Tool: ${step.name || 'unknown'} -
-
${step.content}
`; - break; - - case 'error': - icon = 'bx-error-circle text-danger'; - labelClass = 'fw-bold text-danger'; - content = `
- - Tool: ${step.name || 'unknown'} -
-
${step.content}
`; - break; - - case 'generating': - icon = 'bx-message-dots text-info'; - labelClass = ''; - content = `
- - ${step.content} -
`; - break; - - default: - icon = 'bx-info-circle text-muted'; - labelClass = ''; - content = `
- - ${step.content} -
`; - } - - html += `
${content}
`; - }); - - return html; - } - - /** - * Extract tool execution steps from the response - */ - private extractToolExecutionSteps(content: string): Array<{type: string, name?: string, content: string}> { - if (!content) return []; - - const steps = []; - - // Check for executing tools marker - if (content.includes('[Executing tools...]')) { - steps.push({ - type: 'executing', - content: 'Executing tools...' - }); - } - - // Extract tool results with regex - const toolResultRegex = /\[Tool: ([^\]]+)\]([\s\S]*?)(?=\[|$)/g; - let match; - - while ((match = toolResultRegex.exec(content)) !== null) { - const toolName = match[1]; - const toolContent = match[2].trim(); - - steps.push({ - type: toolContent.includes('Error:') ? 'error' : 'result', - name: toolName, - content: toolContent - }); - } - - // Check for generating response marker - if (content.includes('[Generating response with tool results...]')) { - steps.push({ - type: 'generating', - content: 'Generating response with tool results...' - }); - } - - return steps; - } - - /** - * Extract the final response without tool execution steps - */ - private extractFinalResponse(content: string): string { - if (!content) return ''; - - // Remove all tool execution markers and their content - let finalResponse = content - .replace(/\[Executing tools\.\.\.\]\n*/g, '') - .replace(/\[Tool: [^\]]+\][\s\S]*?(?=\[|$)/g, '') - .replace(/\[Generating response with tool results\.\.\.\]\n*/g, ''); - - // Trim any extra whitespace - finalResponse = finalResponse.trim(); - - return finalResponse; - } - - /** - * Handle general errors in the send message flow - */ - private handleError(error: Error) { - this.hideLoadingIndicator(); - toastService.showError('Error sending message: ' + error.message); - } - - private addMessageToChat(role: 'user' | 'assistant', content: string) { - const messageElement = document.createElement('div'); - messageElement.className = `chat-message ${role}-message mb-3 d-flex`; - - const avatarElement = document.createElement('div'); - avatarElement.className = 'message-avatar d-flex align-items-center justify-content-center me-2'; - - if (role === 'user') { - avatarElement.innerHTML = ''; - avatarElement.classList.add('user-avatar'); - } else { - avatarElement.innerHTML = ''; - avatarElement.classList.add('assistant-avatar'); - } - - const contentElement = document.createElement('div'); - contentElement.className = 'message-content p-3 rounded flex-grow-1'; - - if (role === 'user') { - contentElement.classList.add('user-content', 'bg-light'); - } else { - contentElement.classList.add('assistant-content'); - } - - // Format the content with markdown - contentElement.innerHTML = this.formatMarkdown(content); - - messageElement.appendChild(avatarElement); - messageElement.appendChild(contentElement); - - this.noteContextChatMessages.appendChild(messageElement); - - // Apply syntax highlighting to any code blocks in the message - applySyntaxHighlight($(contentElement)); - - // Scroll to bottom - this.chatContainer.scrollTop = this.chatContainer.scrollHeight; - } - - private showSources(sources: Array<{noteId: string, title: string}>) { - this.sourcesList.innerHTML = ''; - - // Update the sources count - const sourcesCount = this.$widget[0].querySelector('.sources-count') as HTMLElement; - if (sourcesCount) { - sourcesCount.textContent = sources.length.toString(); - } - - sources.forEach(source => { - const sourceElement = document.createElement('div'); - sourceElement.className = 'source-item p-2 mb-1 border rounded d-flex align-items-center'; - - // Create the direct link to the note - sourceElement.innerHTML = ` - `; - - // Add click handler for better user experience - sourceElement.querySelector('.source-link')?.addEventListener('click', (e) => { - e.preventDefault(); - e.stopPropagation(); - - // Open the note in a new tab but don't switch to it - appContext.tabManager.openTabWithNoteWithHoisting(source.noteId, { activate: false }); - - return false; // Additional measure to prevent the event from bubbling up - }); - - this.sourcesList.appendChild(sourceElement); - }); - - const sourcesContainer = this.$widget[0].querySelector('.sources-container') as HTMLElement; - if (sourcesContainer) { - sourcesContainer.style.display = 'block'; - } - } - - private hideSources() { - const sourcesContainer = this.$widget[0].querySelector('.sources-container') as HTMLElement; - if (sourcesContainer) { - sourcesContainer.style.display = 'none'; - } - } - - private showLoadingIndicator() { - const logId = `ui-${Date.now()}`; - console.log(`[${logId}] Showing loading indicator`); - - // Ensure the loading indicator element exists - if (!this.loadingIndicator) { - console.error(`[${logId}] Loading indicator element not properly initialized`); - return; - } - - // Show the loading indicator - try { - this.loadingIndicator.style.display = 'flex'; - - // Force a UI update - const forceUpdate = this.loadingIndicator.offsetHeight; - - console.log(`[${logId}] Loading indicator initialized`); - } catch (err) { - console.error(`[${logId}] Error showing loading indicator:`, err); - } - } - - private hideLoadingIndicator() { - const logId = `ui-${Date.now()}`; - console.log(`[${logId}] Hiding loading indicator`); - - // Ensure elements exist before trying to modify them - if (!this.loadingIndicator) { - console.error(`[${logId}] Loading indicator element not properly initialized`); - return; - } - - // Properly reset DOM elements - try { - // Hide just the loading indicator but NOT the tool execution info - this.loadingIndicator.style.display = 'none'; - - // Force a UI update by accessing element properties - const forceUpdate = this.loadingIndicator.offsetHeight; - - // Tool execution info is now independent and may remain visible - console.log(`[${logId}] Loading indicator hidden, tool execution info remains visible if needed`); - } catch (err) { - console.error(`[${logId}] Error hiding loading indicator:`, err); - } - } - - /** - * Show tool execution information in the UI - */ - private showToolExecutionInfo(toolExecutionData: any) { - console.log(`Showing tool execution info: ${JSON.stringify(toolExecutionData)}`); - - // We'll update the in-chat tool execution area in the updateStreamingUI method - // This method is now just a legacy hook for the WebSocket handlers - - // Make sure the loading indicator is shown during tool execution - this.loadingIndicator.style.display = 'flex'; - } - - /** - * 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 legacy hook for the WebSocket handlers - - // Show the loading indicator - this.loadingIndicator.style.display = 'flex'; - } - - /** - * Format tool arguments for display - */ - private formatToolArgs(args: any): string { - if (!args || typeof args !== 'object') return ''; - - return Object.entries(args) - .map(([key, value]) => { - // Format the value based on its type - let displayValue; - if (typeof value === 'string') { - displayValue = value.length > 50 ? `"${value.substring(0, 47)}..."` : `"${value}"`; - } else if (value === null) { - displayValue = 'null'; - } else if (Array.isArray(value)) { - displayValue = '[...]'; // Simplified array representation - } else if (typeof value === 'object') { - displayValue = '{...}'; // Simplified object representation - } else { - displayValue = String(value); - } - - return `${this.escapeHtml(key)}: ${this.escapeHtml(displayValue)}`; - }) - .join(', '); - } - - /** - * Simple HTML escaping for safer content display - */ - private escapeHtml(text: string): string { - if (typeof text !== 'string') { - text = String(text || ''); - } - - return text - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/"/g, '"') - .replace(/'/g, '''); - } - - 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')); - } - }); - } - - /** - * Format markdown content for display - */ - private formatMarkdown(content: string): string { - if (!content) return ''; - - // First, extract HTML thinking visualization to protect it from replacements - const thinkingBlocks: string[] = []; - let processedContent = content.replace(/
/g, (match) => { - const placeholder = `__THINKING_BLOCK_${thinkingBlocks.length}__`; - thinkingBlocks.push(match); - return placeholder; - }); - - // Use marked library to parse the markdown - const markedContent = marked(processedContent, { - breaks: true, // Convert line breaks to
- gfm: true, // Enable GitHub Flavored Markdown - silent: true // Ignore errors - }); - - // Handle potential promise (though it shouldn't be with our options) - if (typeof markedContent === 'string') { - processedContent = markedContent; - } else { - console.warn('Marked returned a promise unexpectedly'); - // Use the original content as fallback - processedContent = content; - } - - // Restore thinking visualization blocks - thinkingBlocks.forEach((block, index) => { - processedContent = processedContent.replace(`__THINKING_BLOCK_${index}__`, block); - }); - - return processedContent; - } - - /** - * Validate embedding providers configuration - * Check if there are issues with the embedding providers that might affect LLM functionality - */ - async validateEmbeddingProviders() { - try { - // Check if AI is enabled - const aiEnabled = options.is('aiEnabled'); - if (!aiEnabled) { - this.validationWarning.style.display = 'none'; - return; - } - - // Get provider precedence - const precedenceStr = options.get('aiProviderPrecedence') || 'openai,anthropic,ollama'; - let precedenceList: string[] = []; - - if (precedenceStr) { - if (precedenceStr.startsWith('[') && precedenceStr.endsWith(']')) { - precedenceList = JSON.parse(precedenceStr); - } else if (precedenceStr.includes(',')) { - precedenceList = precedenceStr.split(',').map(p => p.trim()); - } else { - precedenceList = [precedenceStr]; - } - } - - // Get enabled providers - this is a simplification since we don't have direct DB access - // We'll determine enabled status based on the presence of keys or settings - const enabledProviders: string[] = []; - - // OpenAI is enabled if API key is set - const openaiKey = options.get('openaiApiKey'); - if (openaiKey) { - enabledProviders.push('openai'); - } - - // Anthropic is enabled if API key is set - const anthropicKey = options.get('anthropicApiKey'); - if (anthropicKey) { - enabledProviders.push('anthropic'); - } - - // Ollama is enabled if base URL is set - const ollamaBaseUrl = options.get('ollamaBaseUrl'); - if (ollamaBaseUrl) { - enabledProviders.push('ollama'); - } - - // Local is always available - enabledProviders.push('local'); - - // Perform validation checks - const allPrecedenceEnabled = precedenceList.every((p: string) => enabledProviders.includes(p)); - - // Get embedding queue status - const embeddingStats = await server.get('llm/embeddings/stats') as { - success: boolean, - stats: { - totalNotesCount: number; - embeddedNotesCount: number; - queuedNotesCount: number; - failedNotesCount: number; - lastProcessedDate: string | null; - percentComplete: number; - } - }; - const queuedNotes = embeddingStats?.stats?.queuedNotesCount || 0; - const hasEmbeddingsInQueue = queuedNotes > 0; - - // Show warning if there are issues - if (!allPrecedenceEnabled || hasEmbeddingsInQueue) { - let message = 'AI Provider Configuration Issues'; - - message += '
    '; - - if (!allPrecedenceEnabled) { - const disabledProviders = precedenceList.filter((p: string) => !enabledProviders.includes(p)); - message += `
  • The following providers in your precedence list are not enabled: ${disabledProviders.join(', ')}.
  • `; - } - - if (hasEmbeddingsInQueue) { - message += `
  • Currently processing embeddings for ${queuedNotes} notes. Some AI features may produce incomplete results until processing completes.
  • `; - } - - message += '
'; - message += ''; - - // Update HTML content - no need to attach event listeners here anymore - this.validationWarning.innerHTML = message; - this.validationWarning.style.display = 'block'; - } else { - this.validationWarning.style.display = 'none'; - } - } catch (error) { - console.error('Error validating embedding providers:', error); - this.validationWarning.style.display = 'none'; - } - } -} +/** + * LLM Chat Panel Widget + * This file is preserved for backward compatibility. + * The actual implementation has been moved to the llm_chat/ folder. + */ +import LlmChatPanel from './llm_chat/index.js'; +export default LlmChatPanel;