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"; 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 sessionId: string | null = null; private currentNoteId: string | null = null; doRender() { this.$widget = $(`
${t('ai.advanced_context_helps')}
`); 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.initializeEventListeners(); // Create a session when first loaded this.createChatSession(); return this.$widget; } async refresh() { if (!this.isVisible()) { return; } // Get current note context if needed this.currentNoteId = appContext.tabManager.getActiveContext()?.note?.noteId || null; if (!this.sessionId) { // Create a new chat session await this.createChatSession(); } } private async createChatSession() { 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'); } } private async sendMessage(content: string) { if (!content.trim() || !this.sessionId) { return; } this.addMessageToChat('user', content); this.noteContextChatInput.value = ''; this.showLoadingIndicator(); this.hideSources(); try { const useAdvancedContext = this.useAdvancedContextCheckbox.checked; // Create the message parameters const messageParams = { content, contextNoteId: this.currentNoteId, useAdvancedContext }; // First, send the message via POST request const postResponse = await server.post(`llm/sessions/${this.sessionId}/messages`, messageParams); // If the POST request returned content directly, display it if (postResponse && postResponse.content) { this.addMessageToChat('assistant', postResponse.content); // If there are sources, show them if (postResponse.sources && postResponse.sources.length > 0) { this.showSources(postResponse.sources); } this.hideLoadingIndicator(); return; } // Then set up streaming via EventSource const streamUrl = `./api/llm/sessions/${this.sessionId}/messages?format=stream&useAdvancedContext=${useAdvancedContext}`; const source = new EventSource(streamUrl); let assistantResponse = ''; let receivedAnyContent = false; let timeoutId: number | null = null; // Set a timeout to handle case where streaming doesn't work properly timeoutId = window.setTimeout(() => { if (!receivedAnyContent) { // If we haven't received any content after a reasonable timeout (10 seconds), // add a fallback message and close the stream this.hideLoadingIndicator(); this.addMessageToChat('assistant', 'I\'m having trouble generating a response right now. Please try again later.'); source.close(); } }, 10000); // Handle streaming response source.onmessage = (event) => { if (event.data === '[DONE]') { // Stream completed source.close(); this.hideLoadingIndicator(); // Clear the timeout since we're done if (timeoutId !== null) { window.clearTimeout(timeoutId); } // If we didn't receive any content but the stream completed normally, // display a message to the user if (!receivedAnyContent) { this.addMessageToChat('assistant', 'I processed your request, but I don\'t have any specific information to share at the moment.'); } return; } try { const data = JSON.parse(event.data); console.log("Received streaming data:", data); // Debug log // Handle both content and error cases if (data.content) { receivedAnyContent = true; assistantResponse += data.content; // Update the UI with the accumulated response const assistantElement = this.noteContextChatMessages.querySelector('.assistant-message:last-child .message-content'); if (assistantElement) { assistantElement.innerHTML = this.formatMarkdown(assistantResponse); } else { this.addMessageToChat('assistant', assistantResponse); } } else if (data.error) { // Handle error message this.hideLoadingIndicator(); this.addMessageToChat('assistant', `Error: ${data.error}`); receivedAnyContent = true; source.close(); if (timeoutId !== null) { window.clearTimeout(timeoutId); } } // Scroll to the bottom this.chatContainer.scrollTop = this.chatContainer.scrollHeight; } catch (e) { console.error('Error parsing SSE message:', e, 'Raw data:', event.data); } }; source.onerror = () => { source.close(); this.hideLoadingIndicator(); // Clear the timeout if there was an error if (timeoutId !== null) { window.clearTimeout(timeoutId); } // Only show error message if we haven't received any content yet if (!receivedAnyContent) { this.addMessageToChat('assistant', 'Error connecting to the LLM service. Please try again.'); } }; } catch (error) { this.hideLoadingIndicator(); toastService.showError('Error sending message: ' + (error as Error).message); } } private addMessageToChat(role: 'user' | 'assistant', content: string) { const messageElement = document.createElement('div'); messageElement.className = `chat-message ${role}-message mb-3`; const avatarElement = document.createElement('div'); avatarElement.className = 'message-avatar'; avatarElement.innerHTML = role === 'user' ? '' : ''; const contentElement = document.createElement('div'); contentElement.className = 'message-content p-3'; // Use a simple markdown formatter if utils.formatMarkdown is not available let formattedContent = content .replace(/```([\s\S]*?)```/g, '
$1
') .replace(/`([^`]+)`/g, '$1') .replace(/\*\*([^*]+)\*\*/g, '$1') .replace(/\*([^*]+)\*/g, '$1') .replace(/\n/g, '
'); contentElement.innerHTML = formattedContent; messageElement.appendChild(avatarElement); messageElement.appendChild(contentElement); this.noteContextChatMessages.appendChild(messageElement); // 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'; // 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() { this.loadingIndicator.style.display = 'flex'; } private hideLoadingIndicator() { this.loadingIndicator.style.display = 'none'; } 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 { // Simple markdown formatting - could be replaced with a proper markdown library return content .replace(/\*\*(.*?)\*\*/g, '$1') .replace(/\*(.*?)\*/g, '$1') .replace(/`(.*?)`/g, '$1') .replace(/\n/g, '
') .replace(/```(.*?)```/gs, '
$1
'); } }