From 2c48a70bfbd4ec3c32598657060e656e36f81187 Mon Sep 17 00:00:00 2001 From: perf3ct Date: Sun, 1 Jun 2025 03:03:26 +0000 Subject: [PATCH] feat(llm): use ckeditor for text input area for mention support instead of textinput --- .../src/widgets/llm_chat/llm_chat_panel.ts | 230 ++++++++++++++++-- apps/client/src/widgets/llm_chat/types.ts | 5 + apps/client/src/widgets/llm_chat/ui.ts | 10 +- apps/server/src/routes/api/llm.ts | 46 +++- 4 files changed, 258 insertions(+), 33 deletions(-) diff --git a/apps/client/src/widgets/llm_chat/llm_chat_panel.ts b/apps/client/src/widgets/llm_chat/llm_chat_panel.ts index 5c6550346..53de9a2d7 100644 --- a/apps/client/src/widgets/llm_chat/llm_chat_panel.ts +++ b/apps/client/src/widgets/llm_chat/llm_chat_panel.ts @@ -5,6 +5,7 @@ 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 noteAutocompleteService from "../../services/note_autocomplete.js"; import { TPL, addMessageToChat, showSources, hideSources, showLoadingIndicator, hideLoadingIndicator } from "./ui.js"; import { formatMarkdown } from "./utils.js"; @@ -13,13 +14,16 @@ import { extractInChatToolSteps } from "./message_processor.js"; import { validateEmbeddingProviders } from "./validation.js"; import type { MessageData, ToolExecutionStep, ChatData } from "./types.js"; import { formatCodeBlocks } from "../../services/syntax_highlight.js"; +import { ClassicEditor, type CKTextEditor, type MentionFeed } from "@triliumnext/ckeditor5"; +import type { Suggestion } from "../../services/note_autocomplete.js"; import "../../stylesheets/llm_chat.css"; export default class LlmChatPanel extends BasicWidget { private noteContextChatMessages!: HTMLElement; private noteContextChatForm!: HTMLFormElement; - private noteContextChatInput!: HTMLTextAreaElement; + private noteContextChatInput!: HTMLElement; + private noteContextChatInputEditor!: CKTextEditor; private noteContextChatSendButton!: HTMLButtonElement; private chatContainer!: HTMLElement; private loadingIndicator!: HTMLElement; @@ -104,7 +108,7 @@ export default class LlmChatPanel extends BasicWidget { 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.noteContextChatInput = element.querySelector('.note-context-chat-input') as HTMLElement; 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; @@ -124,15 +128,81 @@ export default class LlmChatPanel extends BasicWidget { } }); - this.initializeEventListeners(); + // Initialize CKEditor with mention support (async) + this.initializeCKEditor().then(() => { + this.initializeEventListeners(); + }).catch(error => { + console.error('Failed to initialize CKEditor, falling back to basic event listeners:', error); + this.initializeBasicEventListeners(); + }); return this.$widget; } + private async initializeCKEditor() { + const mentionSetup: MentionFeed[] = [ + { + marker: "@", + feed: (queryText: string) => noteAutocompleteService.autocompleteSourceForCKEditor(queryText), + itemRenderer: (item) => { + const suggestion = item as Suggestion; + const itemElement = document.createElement("button"); + itemElement.innerHTML = `${suggestion.highlightedNotePathTitle} `; + return itemElement; + }, + minimumCharacters: 0 + } + ]; + + this.noteContextChatInputEditor = await ClassicEditor.create(this.noteContextChatInput, { + toolbar: { + items: [] // No toolbar for chat input + }, + placeholder: this.noteContextChatInput.getAttribute('data-placeholder') || 'Enter your message...', + mention: { + feeds: mentionSetup + }, + licenseKey: "GPL" + }); + + // Set minimal height + const editorElement = this.noteContextChatInputEditor.ui.getEditableElement(); + if (editorElement) { + editorElement.style.minHeight = '60px'; + editorElement.style.maxHeight = '200px'; + editorElement.style.overflowY = 'auto'; + } + + // Set up keybindings after editor is ready + this.setupEditorKeyBindings(); + + console.log('CKEditor initialized successfully for LLM chat input'); + } + + private initializeBasicEventListeners() { + // Fallback event listeners for when CKEditor fails to initialize + this.noteContextChatForm.addEventListener('submit', (e) => { + e.preventDefault(); + // In fallback mode, the noteContextChatInput should contain a textarea + const textarea = this.noteContextChatInput.querySelector('textarea'); + if (textarea) { + const content = textarea.value; + this.sendMessage(content); + } + }); + } + cleanup() { console.log(`LlmChatPanel cleanup called, removing any active WebSocket subscriptions`); this._messageHandler = null; this._messageHandlerId = null; + + // Clean up CKEditor instance + if (this.noteContextChatInputEditor) { + this.noteContextChatInputEditor.destroy().catch(error => { + console.error('Error destroying CKEditor:', error); + }); + } } /** @@ -531,18 +601,31 @@ export default class LlmChatPanel extends BasicWidget { private async sendMessage(content: string) { if (!content.trim()) return; + // Extract mentions from the content if using CKEditor + let mentions: Array<{noteId: string; title: string; notePath: string}> = []; + let plainTextContent = content; + + if (this.noteContextChatInputEditor) { + const extracted = this.extractMentionsAndContent(content); + mentions = extracted.mentions; + plainTextContent = extracted.content; + } + // Add the user message to the UI and data model - this.addMessageToChat('user', content); + this.addMessageToChat('user', plainTextContent); this.messages.push({ role: 'user', - content: content + content: plainTextContent, + mentions: mentions.length > 0 ? mentions : undefined }); // Save the data immediately after a user message await this.saveCurrentData(); // Clear input and show loading state - this.noteContextChatInput.value = ''; + if (this.noteContextChatInputEditor) { + this.noteContextChatInputEditor.setData(''); + } showLoadingIndicator(this.loadingIndicator); this.hideSources(); @@ -555,9 +638,10 @@ export default class LlmChatPanel extends BasicWidget { // Create the message parameters const messageParams = { - content, + content: plainTextContent, useAdvancedContext, - showThinking + showThinking, + mentions: mentions.length > 0 ? mentions : undefined }; // Try websocket streaming (preferred method) @@ -621,7 +705,9 @@ export default class LlmChatPanel extends BasicWidget { } // Clear input and show loading state - this.noteContextChatInput.value = ''; + if (this.noteContextChatInputEditor) { + this.noteContextChatInputEditor.setData(''); + } showLoadingIndicator(this.loadingIndicator); this.hideSources(); @@ -1213,22 +1299,122 @@ export default class LlmChatPanel extends BasicWidget { 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`; - }); + let content = ''; - // 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')); + if (this.noteContextChatInputEditor && this.noteContextChatInputEditor.getData) { + // Use CKEditor content + content = this.noteContextChatInputEditor.getData(); + } else { + // Fallback: check if there's a textarea (fallback mode) + const textarea = this.noteContextChatInput.querySelector('textarea'); + if (textarea) { + content = textarea.value; + } else { + // Last resort: try to get text content from the div + content = this.noteContextChatInput.textContent || this.noteContextChatInput.innerText || ''; + } + } + + if (content.trim()) { + this.sendMessage(content); } }); + + // Handle Enter key (send on Enter, new line on Shift+Enter) via CKEditor + // We'll set this up after CKEditor is initialized + this.setupEditorKeyBindings(); + } + + private setupEditorKeyBindings() { + if (this.noteContextChatInputEditor && this.noteContextChatInputEditor.keystrokes) { + try { + this.noteContextChatInputEditor.keystrokes.set('Enter', (key, stop) => { + if (!key.shiftKey) { + stop(); + this.noteContextChatForm.dispatchEvent(new Event('submit')); + } + }); + console.log('CKEditor keybindings set up successfully'); + } catch (error) { + console.warn('Failed to set up CKEditor keybindings:', error); + } + } + } + + /** + * Extract note mentions and content from CKEditor + */ + private extractMentionsAndContent(editorData: string): { content: string; mentions: Array<{noteId: string; title: string; notePath: string}> } { + const mentions: Array<{noteId: string; title: string; notePath: string}> = []; + + // Parse the HTML content to extract mentions + const tempDiv = document.createElement('div'); + tempDiv.innerHTML = editorData; + + // Find all mention elements - CKEditor uses specific patterns for mentions + // Look for elements with data-mention attribute or specific mention classes + const mentionElements = tempDiv.querySelectorAll('[data-mention], .mention, span[data-id]'); + + mentionElements.forEach(mentionEl => { + try { + // Try different ways to extract mention data based on CKEditor's format + let mentionData: any = null; + + // Method 1: data-mention attribute (JSON format) + if (mentionEl.hasAttribute('data-mention')) { + mentionData = JSON.parse(mentionEl.getAttribute('data-mention') || '{}'); + } + // Method 2: data-id attribute (simple format) + else if (mentionEl.hasAttribute('data-id')) { + const dataId = mentionEl.getAttribute('data-id'); + const textContent = mentionEl.textContent || ''; + + // Parse the dataId to extract note information + if (dataId && dataId.startsWith('@')) { + const cleanId = dataId.substring(1); // Remove the @ + mentionData = { + id: cleanId, + name: textContent, + notePath: cleanId // Assume the ID contains the path + }; + } + } + // Method 3: Check if this is a reference link (href=#notePath) + else if (mentionEl.tagName === 'A' && mentionEl.hasAttribute('href')) { + const href = mentionEl.getAttribute('href'); + if (href && href.startsWith('#')) { + const notePath = href.substring(1); + mentionData = { + notePath: notePath, + noteTitle: mentionEl.textContent || 'Unknown Note' + }; + } + } + + if (mentionData && (mentionData.notePath || mentionData.link)) { + const notePath = mentionData.notePath || mentionData.link?.substring(1); // Remove # from link + const noteId = notePath ? notePath.split('/').pop() : null; + const title = mentionData.noteTitle || mentionData.name || mentionEl.textContent || 'Unknown Note'; + + if (noteId) { + mentions.push({ + noteId: noteId, + title: title, + notePath: notePath + }); + console.log(`Extracted mention: noteId=${noteId}, title=${title}, notePath=${notePath}`); + } + } + } catch (e) { + console.warn('Failed to parse mention data:', e, mentionEl); + } + }); + + // Convert to plain text for the LLM, but preserve the structure + const content = tempDiv.textContent || tempDiv.innerText || ''; + + console.log(`Extracted ${mentions.length} mentions from editor content`); + return { content, mentions }; } } diff --git a/apps/client/src/widgets/llm_chat/types.ts b/apps/client/src/widgets/llm_chat/types.ts index dc19f38d3..300a7856a 100644 --- a/apps/client/src/widgets/llm_chat/types.ts +++ b/apps/client/src/widgets/llm_chat/types.ts @@ -24,6 +24,11 @@ export interface MessageData { role: string; content: string; timestamp?: Date; + mentions?: Array<{ + noteId: string; + title: string; + notePath: string; + }>; } export interface ChatData { diff --git a/apps/client/src/widgets/llm_chat/ui.ts b/apps/client/src/widgets/llm_chat/ui.ts index b4c9c9208..15e427cb8 100644 --- a/apps/client/src/widgets/llm_chat/ui.ts +++ b/apps/client/src/widgets/llm_chat/ui.ts @@ -31,11 +31,11 @@ export const TPL = `
- +
diff --git a/apps/server/src/routes/api/llm.ts b/apps/server/src/routes/api/llm.ts index adda7cd26..013ce779d 100644 --- a/apps/server/src/routes/api/llm.ts +++ b/apps/server/src/routes/api/llm.ts @@ -808,7 +808,7 @@ async function streamMessage(req: Request, res: Response) { log.info("=== Starting streamMessage ==="); try { const chatNoteId = req.params.chatNoteId; - const { content, useAdvancedContext, showThinking } = req.body; + const { content, useAdvancedContext, showThinking, mentions } = req.body; if (!content || typeof content !== 'string' || content.trim().length === 0) { throw new Error('Content cannot be empty'); @@ -823,17 +823,51 @@ async function streamMessage(req: Request, res: Response) { // Update last active timestamp session.lastActive = new Date(); - // Add user message to the session + // Process mentions if provided + let enhancedContent = content; + if (mentions && Array.isArray(mentions) && mentions.length > 0) { + log.info(`Processing ${mentions.length} note mentions`); + + // Import note service to get note content + const becca = (await import('../../becca/becca.js')).default; + + const mentionContexts: string[] = []; + + for (const mention of mentions) { + try { + const note = becca.getNote(mention.noteId); + if (note && !note.isDeleted) { + const noteContent = note.getContent(); + if (noteContent && typeof noteContent === 'string' && noteContent.trim()) { + mentionContexts.push(`\n\n--- Content from "${mention.title}" (${mention.noteId}) ---\n${noteContent}\n--- End of "${mention.title}" ---`); + log.info(`Added content from note "${mention.title}" (${mention.noteId})`); + } + } else { + log.info(`Referenced note not found or deleted: ${mention.noteId}`); + } + } catch (error) { + log.error(`Error retrieving content for note ${mention.noteId}: ${error}`); + } + } + + // Enhance the content with note references + if (mentionContexts.length > 0) { + enhancedContent = `${content}\n\n=== Referenced Notes ===\n${mentionContexts.join('\n')}`; + log.info(`Enhanced content with ${mentionContexts.length} note references`); + } + } + + // Add user message to the session (with enhanced content for processing) session.messages.push({ role: 'user', - content, + content: enhancedContent, timestamp: new Date() }); // Create request parameters for the pipeline const requestParams = { chatNoteId: chatNoteId, - content, + content: enhancedContent, useAdvancedContext: useAdvancedContext === true, showThinking: showThinking === true, stream: true // Always stream for this endpoint @@ -851,9 +885,9 @@ async function streamMessage(req: Request, res: Response) { params: { chatNoteId: chatNoteId }, - // Make sure the original content is available to the handler + // Make sure the enhanced content is available to the handler body: { - content, + content: enhancedContent, useAdvancedContext: useAdvancedContext === true, showThinking: showThinking === true }