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; // Setup streaming const source = new EventSource(`./api/llm/messages?sessionId=${this.sessionId}&format=stream`); let assistantResponse = ''; // Handle streaming response source.onmessage = (event) => { if (event.data === '[DONE]') { // Stream completed source.close(); this.hideLoadingIndicator(); return; } try { const data = JSON.parse(event.data); if (data.content) { 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); } // Scroll to the bottom this.chatContainer.scrollTop = this.chatContainer.scrollHeight; } } catch (e) { console.error('Error parsing SSE message:', e); } }; source.onerror = () => { source.close(); this.hideLoadingIndicator(); toastService.showError('Error connecting to the LLM service. Please try again.'); }; // Send the actual message const response = await server.post('llm/messages', { sessionId: this.sessionId, content, contextNoteId: this.currentNoteId, useAdvancedContext }); // Handle sources if returned in non-streaming response if (response && response.sources && response.sources.length > 0) { this.showSources(response.sources); } } 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 = ''; sources.forEach(source => { const sourceElement = document.createElement('div'); sourceElement.className = 'source-item p-1'; sourceElement.innerHTML = `${source.title}`; sourceElement.querySelector('.source-link')?.addEventListener('click', (e) => { e.preventDefault(); appContext.tabManager.openTabWithNoteWithHoisting(source.noteId); }); 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
'); } }