feat(llm): use ckeditor for text input area for mention support instead of textinput

This commit is contained in:
perf3ct 2025-06-01 03:03:26 +00:00
parent 3fae664877
commit 2c48a70bfb
No known key found for this signature in database
GPG Key ID: 569C4EEC436F5232
4 changed files with 258 additions and 33 deletions

View File

@ -5,6 +5,7 @@ import BasicWidget from "../basic_widget.js";
import toastService from "../../services/toast.js"; import toastService from "../../services/toast.js";
import appContext from "../../components/app_context.js"; import appContext from "../../components/app_context.js";
import server from "../../services/server.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 { TPL, addMessageToChat, showSources, hideSources, showLoadingIndicator, hideLoadingIndicator } from "./ui.js";
import { formatMarkdown } from "./utils.js"; import { formatMarkdown } from "./utils.js";
@ -13,13 +14,16 @@ import { extractInChatToolSteps } from "./message_processor.js";
import { validateEmbeddingProviders } from "./validation.js"; import { validateEmbeddingProviders } from "./validation.js";
import type { MessageData, ToolExecutionStep, ChatData } from "./types.js"; import type { MessageData, ToolExecutionStep, ChatData } from "./types.js";
import { formatCodeBlocks } from "../../services/syntax_highlight.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"; import "../../stylesheets/llm_chat.css";
export default class LlmChatPanel extends BasicWidget { export default class LlmChatPanel extends BasicWidget {
private noteContextChatMessages!: HTMLElement; private noteContextChatMessages!: HTMLElement;
private noteContextChatForm!: HTMLFormElement; private noteContextChatForm!: HTMLFormElement;
private noteContextChatInput!: HTMLTextAreaElement; private noteContextChatInput!: HTMLElement;
private noteContextChatInputEditor!: CKTextEditor;
private noteContextChatSendButton!: HTMLButtonElement; private noteContextChatSendButton!: HTMLButtonElement;
private chatContainer!: HTMLElement; private chatContainer!: HTMLElement;
private loadingIndicator!: HTMLElement; private loadingIndicator!: HTMLElement;
@ -104,7 +108,7 @@ export default class LlmChatPanel extends BasicWidget {
const element = this.$widget[0]; const element = this.$widget[0];
this.noteContextChatMessages = element.querySelector('.note-context-chat-messages') as HTMLElement; this.noteContextChatMessages = element.querySelector('.note-context-chat-messages') as HTMLElement;
this.noteContextChatForm = element.querySelector('.note-context-chat-form') as HTMLFormElement; 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.noteContextChatSendButton = element.querySelector('.note-context-chat-send-button') as HTMLButtonElement;
this.chatContainer = element.querySelector('.note-context-chat-container') as HTMLElement; this.chatContainer = element.querySelector('.note-context-chat-container') as HTMLElement;
this.loadingIndicator = element.querySelector('.loading-indicator') 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; 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() { cleanup() {
console.log(`LlmChatPanel cleanup called, removing any active WebSocket subscriptions`); console.log(`LlmChatPanel cleanup called, removing any active WebSocket subscriptions`);
this._messageHandler = null; this._messageHandler = null;
this._messageHandlerId = 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) { private async sendMessage(content: string) {
if (!content.trim()) return; 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 // Add the user message to the UI and data model
this.addMessageToChat('user', content); this.addMessageToChat('user', plainTextContent);
this.messages.push({ this.messages.push({
role: 'user', role: 'user',
content: content content: plainTextContent,
mentions: mentions.length > 0 ? mentions : undefined
}); });
// Save the data immediately after a user message // Save the data immediately after a user message
await this.saveCurrentData(); await this.saveCurrentData();
// Clear input and show loading state // Clear input and show loading state
this.noteContextChatInput.value = ''; if (this.noteContextChatInputEditor) {
this.noteContextChatInputEditor.setData('');
}
showLoadingIndicator(this.loadingIndicator); showLoadingIndicator(this.loadingIndicator);
this.hideSources(); this.hideSources();
@ -555,9 +638,10 @@ export default class LlmChatPanel extends BasicWidget {
// Create the message parameters // Create the message parameters
const messageParams = { const messageParams = {
content, content: plainTextContent,
useAdvancedContext, useAdvancedContext,
showThinking showThinking,
mentions: mentions.length > 0 ? mentions : undefined
}; };
// Try websocket streaming (preferred method) // Try websocket streaming (preferred method)
@ -621,7 +705,9 @@ export default class LlmChatPanel extends BasicWidget {
} }
// Clear input and show loading state // Clear input and show loading state
this.noteContextChatInput.value = ''; if (this.noteContextChatInputEditor) {
this.noteContextChatInputEditor.setData('');
}
showLoadingIndicator(this.loadingIndicator); showLoadingIndicator(this.loadingIndicator);
this.hideSources(); this.hideSources();
@ -1213,22 +1299,122 @@ export default class LlmChatPanel extends BasicWidget {
private initializeEventListeners() { private initializeEventListeners() {
this.noteContextChatForm.addEventListener('submit', (e) => { this.noteContextChatForm.addEventListener('submit', (e) => {
e.preventDefault(); e.preventDefault();
const content = this.noteContextChatInput.value;
this.sendMessage(content);
});
// Add auto-resize functionality to the textarea let content = '';
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) if (this.noteContextChatInputEditor && this.noteContextChatInputEditor.getData) {
this.noteContextChatInput.addEventListener('keydown', (e) => { // Use CKEditor content
if (e.key === 'Enter' && !e.shiftKey) { content = this.noteContextChatInputEditor.getData();
e.preventDefault(); } else {
this.noteContextChatForm.dispatchEvent(new Event('submit')); // 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 };
} }
} }

View File

@ -24,6 +24,11 @@ export interface MessageData {
role: string; role: string;
content: string; content: string;
timestamp?: Date; timestamp?: Date;
mentions?: Array<{
noteId: string;
title: string;
notePath: string;
}>;
} }
export interface ChatData { export interface ChatData {

View File

@ -31,11 +31,11 @@ export const TPL = `
<form class="note-context-chat-form d-flex flex-column border-top p-2"> <form class="note-context-chat-form d-flex flex-column border-top p-2">
<div class="d-flex chat-input-container mb-2"> <div class="d-flex chat-input-container mb-2">
<textarea <div
class="form-control note-context-chat-input" class="form-control note-context-chat-input flex-grow-1"
placeholder="${t('ai_llm.enter_message')}" style="min-height: 60px; max-height: 200px; overflow-y: auto;"
rows="2" data-placeholder="${t('ai_llm.enter_message')}"
></textarea> ></div>
<button type="submit" class="btn btn-primary note-context-chat-send-button ms-2 d-flex align-items-center justify-content-center"> <button type="submit" class="btn btn-primary note-context-chat-send-button ms-2 d-flex align-items-center justify-content-center">
<i class="bx bx-send"></i> <i class="bx bx-send"></i>
</button> </button>

View File

@ -808,7 +808,7 @@ async function streamMessage(req: Request, res: Response) {
log.info("=== Starting streamMessage ==="); log.info("=== Starting streamMessage ===");
try { try {
const chatNoteId = req.params.chatNoteId; 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) { if (!content || typeof content !== 'string' || content.trim().length === 0) {
throw new Error('Content cannot be empty'); throw new Error('Content cannot be empty');
@ -823,17 +823,51 @@ async function streamMessage(req: Request, res: Response) {
// Update last active timestamp // Update last active timestamp
session.lastActive = new Date(); 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({ session.messages.push({
role: 'user', role: 'user',
content, content: enhancedContent,
timestamp: new Date() timestamp: new Date()
}); });
// Create request parameters for the pipeline // Create request parameters for the pipeline
const requestParams = { const requestParams = {
chatNoteId: chatNoteId, chatNoteId: chatNoteId,
content, content: enhancedContent,
useAdvancedContext: useAdvancedContext === true, useAdvancedContext: useAdvancedContext === true,
showThinking: showThinking === true, showThinking: showThinking === true,
stream: true // Always stream for this endpoint stream: true // Always stream for this endpoint
@ -851,9 +885,9 @@ async function streamMessage(req: Request, res: Response) {
params: { params: {
chatNoteId: chatNoteId chatNoteId: chatNoteId
}, },
// Make sure the original content is available to the handler // Make sure the enhanced content is available to the handler
body: { body: {
content, content: enhancedContent,
useAdvancedContext: useAdvancedContext === true, useAdvancedContext: useAdvancedContext === true,
showThinking: showThinking === true showThinking: showThinking === true
} }