xref: /plugin/dokullm/script.js (revision 347ea982e60de0617a20d275e9ad502571efc369)
1/**
2 * JavaScript for LLM Integration Plugin
3 *
4 * This script adds LLM processing capabilities to DokuWiki's edit interface.
5 * It creates a toolbar with buttons for various text processing operations
6 * and handles the communication with the backend plugin.
7 *
8 * Features:
9 * - Context-aware text processing with metadata support
10 * - Template content insertion
11 * - Custom prompt input
12 * - Selected text processing
13 * - Full page content processing
14 * - Text analysis in modal dialog
15 */
16
17(function() {
18    'use strict';
19
20    /**
21     * Initialize the plugin when the DOM is ready
22     *
23     * This is the main initialization function that runs when the DOM is fully loaded.
24     * It checks if we're on an edit page and adds the LLM tools if so.
25     * Only runs on pages with the wiki text editor.
26     * Also sets up the copy page button event listener.
27     *
28     * Complex logic includes:
29     * 1. Checking for the presence of the wiki text editor element
30     * 2. Conditionally adding LLM tools based on page context
31     * 3. Setting up event listeners for the copy page functionality
32     */
33    document.addEventListener('DOMContentLoaded', function() {
34        console.log('DokuLLM: DOM loaded, initializing plugin');
35        // Only run on edit pages
36        if (document.getElementById('wiki__text')) {
37            // Add LLM tools to the editor
38            console.log('DokuLLM: Adding LLM tools to editor');
39            addLLMTools();
40        }
41
42        // Add event listener for copy button
43        const copyButton = document.querySelector('.dokullmplugin__copy');
44        if (copyButton) {
45            copyButton.addEventListener('click', function(event) {
46                event.preventDefault();
47                copyPage();
48            });
49        }
50
51        // Dirty hack to handle selection of mobile menu
52        // See: https://github.com/splitbrain/dokuwiki/blob/release_stable_2018-04-22/lib/scripts/behaviour.js#L102-L115
53        const quickSelect = jQuery('select.quickselect');
54        if (quickSelect.length > 0) {
55            quickSelect
56                .unbind('change')  // Remove dokuwiki's default handler to override its behavior
57                .change(function(e) {
58                    if (e.target.value != 'dokullmplugin__copy') {
59                        // do the default action
60                        e.target.form.submit();
61                        return;
62                    }
63
64                    e.target.value = '';  // Reset selection to enable re-select when a prompt is canceled
65                    copyPage();
66                });
67        }
68    });
69
70    /**
71     * Add the LLM toolbar to the editor interface
72     *
73     * Creates a toolbar with buttons for each LLM operation and inserts
74     * it before the wiki text editor. Also adds a custom prompt input
75     * below the editor.
76     *
77     * Dynamically adds a template button when template metadata is present.
78     *
79     * Complex logic includes:
80     * 1. Creating and positioning the main toolbar container
81     * 2. Dynamically adding a template button based on metadata presence
82     * 3. Creating standard LLM operation buttons with event handlers
83     * 4. Adding a custom prompt input field with Enter key handling
84     * 5. Inserting all UI elements at appropriate positions in the DOM
85     * 6. Applying CSS styles for consistent appearance
86     */
87    function addLLMTools() {
88        const editor = document.getElementById('wiki__text');
89        if (!editor) {
90            console.log('DokuLLM: Editor div not found');
91            return;
92        }
93
94        console.log('DokuLLM: Creating LLM toolbar');
95        // Create toolbar container
96        const toolbar = document.createElement('div');
97        toolbar.id = 'llm-toolbar';
98        toolbar.className = 'toolbar';
99
100        // Get metadata to check if template exists
101        const metadata = getMetadata();
102        console.log('DokuLLM: Page metadata retrieved', metadata);
103
104        // Add "Insert template" button if template is defined
105        if (metadata.template) {
106            console.log('DokuLLM: Adding insert template button for', metadata.template);
107            const templateBtn = document.createElement('button');
108            templateBtn.type = 'button';
109            templateBtn.className = 'toolbutton';
110            templateBtn.textContent = 'Insert Template';
111            templateBtn.addEventListener('click', () => insertTemplateContent(metadata.template));
112            toolbar.appendChild(templateBtn);
113        } else {
114            // Add "Find Template" button if no template is defined and ChromaDB is enabled
115            // Check if ChromaDB is enabled through JSINFO
116            const chromaDBEnabled = typeof JSINFO !== 'undefined' && JSINFO.plugins && JSINFO.plugins.dokullm && JSINFO.plugins.dokullm.enable_chromadb;
117            if (chromaDBEnabled) {
118                console.log('DokuLLM: Adding find template button');
119                const findTemplateBtn = document.createElement('button');
120                findTemplateBtn.type = 'button';
121                findTemplateBtn.className = 'toolbutton';
122                findTemplateBtn.textContent = 'Find Template';
123                findTemplateBtn.addEventListener('click', findTemplate);
124                toolbar.appendChild(findTemplateBtn);
125            }
126        }
127
128        // Add loading indicator while fetching actions
129        const loadingIndicator = document.createElement('span');
130        loadingIndicator.textContent = 'Loading LLM actions...';
131        loadingIndicator.id = 'llm-loading';
132        toolbar.appendChild(loadingIndicator);
133
134        // Insert toolbar before the editor
135        editor.parentNode.insertBefore(toolbar, editor);
136
137        // Add custom prompt input below the editor
138        console.log('DokuLLM: Adding custom prompt input below editor');
139        const customPromptContainer = document.createElement('div');
140        customPromptContainer.className = 'llm-custom-prompt';
141        customPromptContainer.id = 'llm-custom-prompt';
142
143        const promptInput = document.createElement('input');
144        promptInput.type = 'text';
145        promptInput.placeholder = 'Enter custom prompt...';
146        promptInput.className = 'llm-prompt-input';
147
148        // Add event listener for Enter key
149        promptInput.addEventListener('keypress', function(e) {
150            if (e.key === 'Enter') {
151                processCustomPrompt(promptInput.value);
152            }
153        });
154
155        const sendButton = document.createElement('button');
156        sendButton.type = 'button';
157        sendButton.className = 'toolbutton';
158        sendButton.textContent = 'Send';
159        sendButton.addEventListener('click', () => processCustomPrompt(promptInput.value));
160
161        customPromptContainer.appendChild(promptInput);
162        customPromptContainer.appendChild(sendButton);
163
164        // Insert custom prompt container after the editor
165        editor.parentNode.insertBefore(customPromptContainer, editor.nextSibling);
166
167        // Fetch action definitions from the API
168        getActions()
169            .then(actions => {
170                // Remove loading indicator
171                const loadingElement = document.getElementById('llm-loading');
172                if (loadingElement) {
173                    loadingElement.remove();
174                }
175
176                // Add buttons based on fetched actions
177                actions.forEach(action => {
178                    const btn = document.createElement('button');
179                    btn.type = 'button';
180                    btn.className = 'toolbutton';
181                    btn.textContent = action.label;
182                    btn.title = action.description || '';
183                    btn.dataset.action = action.id;
184                    btn.dataset.result = action.result;
185                    btn.addEventListener('click', function(event) {
186                        processLLMAction(action.id, event);
187                    });
188                    toolbar.appendChild(btn);
189                });
190
191                console.log('DokuLLM: LLM toolbars added successfully');
192            })
193            .catch(error => {
194                console.error('DokuLLM: Error fetching action definitions:', error);
195                // Remove loading indicator and show error
196                const loadingElement = document.getElementById('llm-loading');
197                if (loadingElement) {
198                    loadingElement.textContent = 'Failed to load LLM actions';
199                }
200            });
201    }
202
203    /**
204     * Copy the current page to a new page ID
205     *
206     * Prompts the user for a new page ID and redirects to the edit page
207     * with the current page content pre-filled.
208     *
209     * Validates that the new ID is different from the current page ID.
210     * Handles user cancellation of the prompt dialog.
211     *
212     * Complex logic includes:
213     * 1. Prompting user for new page ID with current ID as default
214     * 2. Validating that new ID is different from current ID
215     * 3. Handling browser differences in prompt cancellation (null vs empty string)
216     * 4. Constructing the redirect URL with proper encoding
217     * 5. Redirecting to the new page edit view with copyfrom parameter
218     *
219     * Based on the DokuWiki CopyPage plugin
220     * @see https://www.dokuwiki.org/plugin:copypage
221     */
222    function copyPage() {
223        // Original code: https://www.dokuwiki.org/plugin:copypage
224        var oldId = JSINFO.id;
225        while (true) {
226           var newId = prompt('Enter the new page ID:', oldId);
227           // Note: When a user canceled, most browsers return the null, but Safari returns the empty string
228           if (newId) {
229               if (newId === oldId) {
230                   alert('The new page ID must be different from the current page ID.');
231                   continue;
232               }
233               var url = DOKU_BASE + 'doku.php?id=' + encodeURIComponent(newId) +
234                         '&do=edit&copyfrom=' + encodeURIComponent(oldId);
235               location.href = url;
236           }
237           break;
238        }
239    }
240
241
242    /**
243     * Process text using the specified LLM action
244     *
245     * Gets the selected text (or full editor content), sends it to the
246     * backend for processing, and handles the result based on the button's
247     * dataset.result property ('replace', 'append', 'insert', 'show').
248     *
249     * Preserves page metadata when doing full page updates.
250     * Shows loading indicators during processing.
251     *
252     * Complex logic includes:
253     * 1. Determining text to process (selected vs full content)
254     * 2. Managing UI state during processing (loading indicators, readonly)
255     * 3. Constructing and sending AJAX requests with proper metadata
256     * 4. Handling response processing and error conditions
257     * 5. Updating editor content while preserving metadata based on result handling mode
258     * 6. Restoring UI state after processing
259     *
260     * @param {string} action - The action to perform (create, rewrite, etc.)
261     */
262    // Store selection range for processing
263    let currentSelectionRange = null;
264
265    function processLLMAction(action, event) {
266        console.log('DokuLLM: Processing text with action:', action);
267        const editor = document.getElementById('wiki__text');
268        if (!editor) {
269            console.log('DokuLLM: Editor not found');
270            return;
271        }
272
273        // Store the current selection range
274        currentSelectionRange = {
275            start: editor.selectionStart,
276            end: editor.selectionEnd
277        };
278
279        // Get metadata from the page
280        const metadata = getMetadata();
281        console.log('DokuLLM: Retrieved metadata:', metadata);
282
283        const selectedText = getSelectedText(editor);
284        const fullText = editor.value;
285        const textToProcess = selectedText || fullText;
286        console.log('DokuLLM: Text to process length:', textToProcess.length);
287
288        if (!textToProcess.trim()) {
289            console.log('DokuLLM: No text to process');
290            alert('Please select text or enter content to process');
291            return;
292        }
293
294        // Disable the entire toolbar and prompt input
295        const toolbar = document.getElementById('llm-toolbar');
296        const promptContainer = document.getElementById('llm-custom-prompt');
297        const promptInput = promptContainer ? promptContainer.querySelector('.llm-prompt-input') : null;
298        const buttons = toolbar.querySelectorAll('button:not(.llm-modal-close)');
299
300        // Store original states for restoration
301        const originalStates = {
302            promptInput: promptInput ? promptInput.disabled : false,
303            buttons: []
304        };
305
306        // Disable prompt input if it exists
307        if (promptInput) {
308            originalStates.promptInput = promptInput.disabled;
309            promptInput.disabled = true;
310        }
311
312        // Disable all buttons and store their original states
313        buttons.forEach(button => {
314            originalStates.buttons.push({
315                element: button,
316                text: button.textContent,
317                disabled: button.disabled
318            });
319            // Only change text of the button that triggered the action
320            if (event && event.target === button) {
321                button.textContent = 'Processing...';
322            }
323            button.disabled = true;
324        });
325        console.log('DokuLLM: Toolbar disabled, showing processing state');
326
327        // Make textarea readonly during processing
328        editor.readOnly = true;
329
330        // Send AJAX request
331        console.log('DokuLLM: Sending AJAX request to backend');
332        const formData = new FormData();
333        formData.append('call', 'plugin_dokullm');
334        formData.append('action', action);
335        formData.append('text', textToProcess);
336        formData.append('prompt', '');
337        // Append metadata fields generically
338        for (const [key, value] of Object.entries(metadata)) {
339            if (Array.isArray(value)) {
340                formData.append(key, value.join('\n'));
341            } else if (value) {
342                formData.append(key, value);
343            }
344        }
345
346        fetch(DOKU_BASE + 'lib/exe/ajax.php', {
347            method: 'POST',
348            body: formData
349        })
350        .then(response => {
351            if (!response.ok) {
352                return response.text().then(text => {
353                    throw new Error(`Network response was not ok: ${response.status} ${response.statusText} - ${text}`);
354                });
355            }
356            console.log('DokuLLM: Received response from backend');
357            return response.json();
358        })
359        .then(data => {
360            if (data.error) {
361                console.log('DokuLLM: Error from backend:', data.error);
362                throw new Error(data.error);
363            }
364
365            console.log('DokuLLM: Processing successful, result length:', data.result.length);
366
367            // Remove some part
368            const [thinkingContent, cleanedResult] = removeBetweenXmlTags(data.result, 'think');
369
370            // Determine how to handle the result based on button's dataset.result property
371            const resultHandling = event.target.dataset.result || 'replace';
372
373            // Replace selected text or handle result based on resultHandling
374            if (resultHandling === 'show') {
375                console.log('DokuLLM: Showing result in modal');
376                const buttonTitle = event.target.title || action;
377                showModal(cleanedResult, action, buttonTitle);
378            } else if (resultHandling === 'append') {
379                console.log('DokuLLM: Appending result to existing text');
380                // Append to the end of existing content (preserving metadata)
381                const metadata = extractMetadata(editor.value);
382                const contentWithoutMetadata = editor.value.substring(metadata.length);
383                editor.value = metadata + contentWithoutMetadata + '\n\n' + cleanedResult;
384                // Show thinking content in modal if it exists and thinking is enabled
385                if (thinkingContent) {
386                    showModal(thinkingContent, 'thinking', 'AI Thinking Process');
387                }
388            } else if (resultHandling === 'insert') {
389                console.log('DokuLLM: Inserting result before existing text');
390                // Insert before existing content (preserving metadata)
391                const metadata = extractMetadata(editor.value);
392                const contentWithoutMetadata = editor.value.substring(metadata.length);
393                editor.value = metadata + cleanedResult + '\n\n' + contentWithoutMetadata;
394                // Show thinking content in modal if it exists and thinking is enabled
395                if (thinkingContent) {
396                    showModal(thinkingContent, 'thinking', 'AI Thinking Process');
397                }
398            } else if (selectedText) {
399                console.log('DokuLLM: Replacing selected text');
400                replaceSelectedText(editor, cleanedResult);
401                // Show thinking content in modal if it exists and thinking is enabled
402                if (thinkingContent) {
403                    showModal(thinkingContent, 'thinking', 'AI Thinking Process');
404                }
405            } else {
406                console.log('DokuLLM: Replacing full text content');
407                // Preserve metadata when doing full page update
408                const metadata = extractMetadata(editor.value);
409                editor.value = metadata + cleanedResult;
410                // Show thinking content in modal if it exists and thinking is enabled
411                if (thinkingContent) {
412                    showModal(thinkingContent, 'thinking', 'AI Thinking Process');
413                }
414            }
415        })
416        .catch(error => {
417            console.log('DokuLLM: Error during processing:', error.message);
418            alert('Error: ' + error.message);
419        })
420        .finally(() => {
421            console.log('DokuLLM: Resetting toolbar and enabling editor');
422            // Re-enable the toolbar and prompt input
423            if (promptInput) {
424                promptInput.disabled = originalStates.promptInput;
425            }
426            originalStates.buttons.forEach(buttonState => {
427                buttonState.element.textContent = buttonState.text;
428                buttonState.element.disabled = buttonState.disabled;
429            });
430            editor.readOnly = false;
431        });
432    }
433
434    /**
435     * Convert markdown/DokuWiki text to HTML
436     *
437     * Performs basic conversion of markdown/DokuWiki syntax to HTML.
438     * Supports headings, lists, inline formatting, and code blocks.
439     *
440     * @param {string} text - The markdown/DokuWiki text to convert
441     * @returns {string} The converted HTML
442     */
443    function convertToHtml(text) {
444        // Process code blocks first (```code```)
445        let html = text.replace(/```([\s\S]*?)```/g, '<pre><code>$1</code></pre>');
446
447        // Process DokuWiki file blocks ({{file>page#section}})
448        html = html.replace(/\{\{file>([^}]+)\}\}/g, '<div class="include">$1</div>');
449
450        // Process DokuWiki includes ({{page}})
451        html = html.replace(/\{\{([^}]+)\}\}/g, '<div class="include">$1</div>');
452
453        // Process inline code (`code`)
454        html = html.replace(/`([^`]+)`/g, '<code>$1</code>');
455
456        // Process DokuWiki headings (====== Heading ======)
457        html = html.replace(/^====== (.*?) ======$/gm, '<h1>$1</h1>');
458        html = html.replace(/^===== (.*?) =====$/gm, '<h2>$1</h2>');
459        html = html.replace(/^==== (.*?) ====$/gm, '<h3>$1</h3>');
460        html = html.replace(/^=== (.*?) ===$/gm, '<h4>$1</h4>');
461        html = html.replace(/^== (.*?) ==$/gm, '<h5>$1</h5>');
462        html = html.replace(/^= (.*?) =$/gm, '<h6>$1</h6>');
463
464        // Process markdown headings (# Heading, ## Heading, etc.)
465        html = html.replace(/^###### (.*$)/gm, '<h6>$1</h6>');
466        html = html.replace(/^##### (.*$)/gm, '<h5>$1</h5>');
467        html = html.replace(/^#### (.*$)/gm, '<h4>$1</h4>');
468        html = html.replace(/^### (.*$)/gm, '<h3>$1</h3>');
469        html = html.replace(/^## (.*$)/gm, '<h2>$1</h2>');
470        html = html.replace(/^# (.*$)/gm, '<h1>$1</h1>');
471
472        // Process DokuWiki bold (**text**)
473        html = html.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>');
474
475        // Process DokuWiki italic (//text//)
476        html = html.replace(/\/\/(.*?)\/\//g, '<em>$1</em>');
477
478        // Process markdown bold (__text__)
479        html = html.replace(/__(.*?)__/g, '<strong>$1</strong>');
480
481        // Process markdown italic (*text* or _text_)
482        html = html.replace(/\*(.*?)\*/g, '<em>$1</em>');
483        html = html.replace(/_(.*?)_/g, '<em>$1</em>');
484
485        // Process DokuWiki external links ([[http://example.com|text]])
486        html = html.replace(/\[\[(https?:\/\/[^\]|]+)\|([^\]]+)\]\]/g, '<a href="$1" target="_blank">$2</a>');
487
488        // Process DokuWiki external links ([[http://example.com]])
489        html = html.replace(/\[\[(https?:\/\/[^\]]+)\]\]/g, '<a href="$1" target="_blank">$1</a>');
490
491        // Process DokuWiki internal links ([[page|text]])
492        html = html.replace(/\[\[([^\]|]+)\|([^\]]+)\]\]/g, '<a href="?id=$1">$2</a>');
493
494        // Process DokuWiki internal links ([[page]])
495        html = html.replace(/\[\[([^\]]+)\]\]/g, '<a href="?id=$1">$1</a>');
496
497        // Process unordered lists (* item or - item) with role attribute
498        html = html.replace(/^\* (.*$)/gm, '<li role="ul">$1</li>');
499        html = html.replace(/^- (.*$)/gm, '<li role="ul">$1</li>');
500
501        // Process ordered lists (1. item) with role attribute
502        html = html.replace(/^\d+\. (.*$)/gm, '<li role="ol">$1</li>');
503
504        // Wrap consecutive <li role="ul"> elements in <ul>
505        html = html.replace(/(<li role="ul">.*<\/li>(\s*<li role="ul">.*<\/li>)*)/g, '<ul>$1</ul>');
506
507        // Wrap consecutive <li role="ol"> elements in <ol>
508        html = html.replace(/(<li role="ol">.*<\/li>(\s*<li role="ol">.*<\/li>)*)/g, '<ol>$1</ol>');
509
510        // Remove role attributes from li elements (they were only used for identification)
511        html = html.replace(/<li role="(ul|ol)">/g, '<li>');
512
513        return html;
514    }
515
516    /**
517     * Show analysis or summarize results in a modal dialog
518     *
519     * Creates and displays a modal dialog with the analysis or summarize results.
520     * Includes a close button and proper styling.
521     *
522     * @param {string} contentText - The content text to display
523     * @param {string} action - The action type ('analyze' or 'summarize')
524     * @param {string} titleText - The title to display in the modal
525     */
526    function showModal(contentText, action = 'analyze', titleText = '') {
527        // Create modal container
528        const modal = document.createElement('div');
529        modal.id = 'llm-' + action + '-modal';
530        modal.className = 'llm-modal';
531
532        // Create modal content
533        const modalContent = document.createElement('div');
534        modalContent.className = 'llm-modal-content';
535
536        // Create close button
537        const closeButton = document.createElement('button');
538        closeButton.textContent = 'Close';
539        closeButton.className = 'llm-modal-close';
540        closeButton.addEventListener('click', () => {
541            document.body.removeChild(modal);
542        });
543
544        // Create append button
545        const appendButton = document.createElement('button');
546        appendButton.textContent = 'Append';
547        appendButton.title = 'Append to report';
548        appendButton.className = 'llm-modal-append';
549        appendButton.addEventListener('click', () => {
550            appendToReport(contentText);
551            document.body.removeChild(modal);
552        });
553
554        // Create title based on action or use provided title
555        const title = document.createElement('h3');
556        if (titleText) {
557            title.textContent = titleText;
558        } else {
559            title.textContent = action.charAt(0).toUpperCase() + action.slice(1);
560        }
561        title.style.marginTop = '0';
562
563        // Create content area
564        const content = document.createElement('div');
565        content.innerHTML = convertToHtml(contentText);
566        content.style.cssText = `
567            margin-top: 20px;
568            white-space: pre-wrap;
569        `;
570
571        // Assemble modal
572        modalContent.appendChild(closeButton);
573        modalContent.appendChild(appendButton);
574        modalContent.appendChild(title);
575        modalContent.appendChild(content);
576        modal.appendChild(modalContent);
577
578        // Add to document and set up close event
579        document.body.appendChild(modal);
580        modal.addEventListener('click', (e) => {
581            if (e.target === modal) {
582                document.body.removeChild(modal);
583            }
584        });
585    }
586
587    /**
588     * Append content to the end of the report
589     *
590     * Adds the provided content to the end of the editor content,
591     * preserving metadata at the beginning.
592     *
593     * @param {string} content - The content to append
594     */
595    function appendToReport(content) {
596        const editor = document.getElementById('wiki__text');
597        if (!editor) {
598            console.log('DokuLLM: Editor not found for appending content');
599            return;
600        }
601
602        // Preserve metadata when appending content
603        const metadata = extractMetadata(editor.value);
604        const contentWithoutMetadata = editor.value.substring(metadata.length);
605
606        // Append new content with proper spacing
607        editor.value = metadata + contentWithoutMetadata + '\n\n' + content;
608
609        // Focus the editor at the end
610        editor.focus();
611        editor.setSelectionRange(editor.value.length, editor.value.length);
612    }
613
614    /**
615     * Process text with a custom user prompt
616     *
617     * Sends selected or full text content to the backend with a user-provided
618     * custom prompt for processing.
619     *
620     * Clears the prompt input after successful processing.
621     * Shows loading indicators during processing.
622     *
623     * Complex logic includes:
624     * 1. Validating custom prompt input
625     * 2. Determining text to process (selected vs full content)
626     * 3. Managing UI state for the custom prompt interface
627     * 4. Constructing and sending AJAX requests with custom prompts
628     * 5. Handling response processing and error conditions
629     * 6. Updating editor content and clearing input fields
630     * 7. Restoring UI state after processing
631     *
632     * @param {string} customPrompt - The user's custom prompt
633     */
634    function processCustomPrompt(customPrompt) {
635        console.log('DokuLLM: Processing custom prompt:', customPrompt);
636        if (!customPrompt.trim()) {
637            console.log('DokuLLM: No custom prompt provided');
638            alert('Please enter a prompt');
639            return;
640        }
641
642        const editor = document.getElementById('wiki__text');
643        if (!editor) {
644            console.log('DokuLLM: Editor not found for custom prompt');
645            return;
646        }
647
648        // Store the current selection range
649        currentSelectionRange = {
650            start: editor.selectionStart,
651            end: editor.selectionEnd
652        };
653
654        const selectedText = getSelectedText(editor);
655        const fullText = editor.value;
656        const textToProcess = selectedText || fullText;
657        console.log('DokuLLM: Text to process length:', textToProcess.length);
658
659        if (!textToProcess.trim()) {
660            console.log('DokuLLM: No text to process for custom prompt');
661            alert('Please select text or enter content to process');
662            return;
663        }
664
665        // Get metadata from the page
666        const metadata = getMetadata();
667        console.log('DokuLLM: Retrieved metadata for custom prompt:', metadata);
668
669        // Find the Send button and show loading state
670        const toolbar = document.getElementById('llm-custom-prompt');
671        const sendButton = toolbar.querySelector('.toolbutton');
672        const originalText = sendButton.textContent;
673        sendButton.textContent = 'Processing...';
674        sendButton.disabled = true;
675        console.log('DokuLLM: Send button disabled, showing processing state');
676
677        // Make textarea readonly during processing
678        editor.readOnly = true;
679
680        // Send AJAX request
681        console.log('DokuLLM: Sending custom prompt AJAX request to backend');
682        const formData = new FormData();
683        formData.append('call', 'plugin_dokullm');
684        formData.append('action', 'custom');
685        formData.append('text', textToProcess);
686        formData.append('prompt', customPrompt);
687        // Append metadata fields generically
688        for (const [key, value] of Object.entries(metadata)) {
689            if (Array.isArray(value)) {
690                formData.append(key, value.join('\n'));
691            } else if (value) {
692                formData.append(key, value);
693            }
694        }
695
696        fetch(DOKU_BASE + 'lib/exe/ajax.php', {
697            method: 'POST',
698            body: formData
699        })
700        .then(response => {
701            if (!response.ok) {
702                return response.text().then(text => {
703                    throw new Error(`Network response was not ok: ${response.status} ${response.statusText} - ${text}`);
704                });
705            }
706            console.log('DokuLLM: Received response for custom prompt');
707            return response.json();
708        })
709        .then(data => {
710            if (data.error) {
711                console.log('DokuLLM: Error from backend for custom prompt:', data.error);
712                throw new Error(data.error);
713            }
714
715            console.log('DokuLLM: Custom prompt processing successful, result length:', data.result.length);
716            // Extract AI thinking parts (between <think> tags) from the result
717            let thinkingContent = '';
718            const thinkingMatch = data.result.match(/<think>([\s\S]*?)<\/think>/);
719            if (thinkingMatch && thinkingMatch[1]) {
720                thinkingContent = thinkingMatch[1].trim();
721            }
722
723            // Remove AI thinking parts (between <think> tags) from the result
724            const cleanedResult = data.result.replace(/<think>[\s\S]*?<\/think>/g, '').trim();
725
726            // Replace selected text or append to editor
727            if (selectedText) {
728                console.log('DokuLLM: Replacing selected text for custom prompt');
729                replaceSelectedText(editor, cleanedResult);
730            } else {
731                console.log('DokuLLM: Replacing full text content for custom prompt');
732                // Preserve metadata when doing full page update
733                const metadata = extractMetadata(editor.value);
734                editor.value = metadata + cleanedResult;
735            }
736
737            // Clear the input field
738            const promptInput = toolbar.querySelector('.llm-prompt-input');
739            if (promptInput) {
740                promptInput.value = '';
741            }
742            // Show thinking content in modal if it exists and thinking is enabled
743            if (thinkingContent) {
744                showModal(thinkingContent, 'thinking', 'AI Thinking Process');
745            }
746        })
747        .catch(error => {
748            console.log('DokuLLM: Error during custom prompt processing:', error.message);
749            alert('Error: ' + error.message);
750        })
751        .finally(() => {
752            console.log('DokuLLM: Resetting send button and enabling editor');
753            if (sendButton) {
754                resetButton(sendButton, originalText);
755            }
756            editor.readOnly = false;
757        });
758    }
759
760    /**
761     * Get the currently selected text in the textarea
762     *
763     * Uses the textarea's selectionStart and selectionEnd properties
764     * to extract the selected portion of text.
765     *
766     * @param {HTMLTextAreaElement} textarea - The textarea element
767     * @returns {string} The selected text
768     */
769    function getSelectedText(textarea) {
770        const start = textarea.selectionStart;
771        const end = textarea.selectionEnd;
772        return textarea.value.substring(start, end);
773    }
774
775    /**
776     * Replace the selected text in the textarea with new text
777     *
778     * When replacing the entire content, preserves metadata directives
779     * at the beginning of the page.
780     *
781     * Complex logic includes:
782     * 1. Determining if text is selected or if it's a full content replacement
783     * 2. Preserving metadata directives when doing full content replacement
784     * 3. Properly replacing only selected text when applicable
785     * 4. Managing cursor position after text replacement
786     * 5. Maintaining focus on the textarea
787     *
788     * @param {HTMLTextAreaElement} textarea - The textarea element
789     * @param {string} newText - The new text to insert
790     */
791    function replaceSelectedText(textarea, newText) {
792        // Use stored selection range if available, otherwise use current selection
793        const start = currentSelectionRange ? currentSelectionRange.start : textarea.selectionStart;
794        const end = currentSelectionRange ? currentSelectionRange.end : textarea.selectionEnd;
795        const text = textarea.value;
796
797        // Reset the stored selection range
798        currentSelectionRange = null;
799
800        // If there's no selection (start === end), it's not a replacement of selected text
801        if (start === end) {
802            // No selection, so we're processing the full text
803            const metadata = extractMetadata(text);
804            textarea.value = metadata + newText;
805        } else {
806            // There is a selection, replace only the selected text
807            textarea.value = text.substring(0, start) + newText + text.substring(end);
808
809            // Set cursor position after inserted text
810            const newCursorPos = start + newText.length;
811            textarea.setSelectionRange(newCursorPos, newCursorPos);
812        }
813
814        textarea.focus();
815    }
816
817    /**
818     * Extract metadata directives from the beginning of the text
819     *
820     * Finds and returns LLM metadata directives (~~LLM_*~~) that appear
821     * at the beginning of the page content.
822     *
823     * @param {string} text - The full text content
824     * @returns {string} The metadata directives
825     */
826    function extractMetadata(text) {
827        const metadataRegex = /^(~~LLM_[A-Z]+:[^~]+~~\s*)*/;
828        const match = text.match(metadataRegex);
829        return match ? match[0] : '';
830    }
831
832    /**
833     * Reset a button to its original state
834     *
835     * Restores the button's text content and enables it.
836     *
837     * @param {HTMLButtonElement} button - The button to reset
838     * @param {string} originalText - The original button text
839     */
840    function resetButton(button, originalText) {
841        button.textContent = originalText;
842        button.disabled = false;
843    }
844
845    /**
846     * Get page metadata for LLM context
847     *
848     * Extracts template and example page information from page metadata
849     * directives in the page content.
850     *
851     * Looks for:
852     * - ~~LLM_TEMPLATE:page_id~~ for template page reference
853     * - ~~LLM_EXAMPLES:page1,page2~~ for example page references
854     *
855     * Complex logic includes:
856     * 1. Initializing metadata structure with default values
857     * 2. Safely accessing page content from the editor
858     * 3. Using regular expressions to extract metadata directives
859     * 4. Parsing comma-separated example page lists
860     * 5. Trimming whitespace from extracted values
861     *
862     * @returns {Object} Metadata object with template and examples
863     */
864    function getMetadata() {
865        const metadata = {
866            template: '',
867            examples: [],
868            previous: ''
869        };
870
871        // Look for metadata in the page content
872        const pageContent = document.getElementById('wiki__text')?.value || '';
873
874        // Extract template page from metadata
875        const templateMatch = pageContent.match(/~~LLM_TEMPLATE:([^~]+)~~/);
876        if (templateMatch) {
877            metadata.template = templateMatch[1].trim();
878        }
879
880        // Extract example pages from metadata
881        const exampleMatches = pageContent.match(/~~LLM_EXAMPLES:([^~]+)~~/);
882        if (exampleMatches) {
883            metadata.examples = exampleMatches[1].split(',').map(example => example.trim());
884        }
885
886        // Extract previous report page from metadata
887        const previousReportMatch = pageContent.match(/~~LLM_PREVIOUS:([^~]+)~~/);
888        if (previousReportMatch) {
889            metadata.previous = previousReportMatch[1].trim();
890        }
891
892        return metadata;
893    }
894
895    /**
896     * Insert metadata after the first title in the text
897     *
898     * Checks if the first line is a title (starts with = in DokuWiki)
899     * and inserts the metadata line after it, otherwise inserts at the beginning.
900     *
901     * @param {string} text - The text content
902     * @param {string} metadataLine - The metadata line to insert
903     * @returns {string} The text with metadata inserted
904     */
905    function insertMetadataAfterTitle(text, metadataLine) {
906        // Check if the first line is a title (starts with = in DokuWiki)
907        const lines = text.split('\n');
908        if (lines.length > 0 && lines[0].trim() !== '' && lines[0].trim()[0] === '=') {
909            // Insert after the first line (the title)
910            lines.splice(1, 0, metadataLine);
911            return lines.join('\n');
912        } else {
913            // Insert at the very beginning
914            return metadataLine + '\n' + text;
915        }
916    }
917
918    /**
919     * Find and insert template metadata
920     *
921     * Searches for an appropriate template based on the current content
922     * and inserts the LLM_TEMPLATE metadata at the top of the text.
923     *
924     * Shows loading indicators during the search operation.
925     *
926     * @param {Event} event - The click event
927     */
928    function findTemplate(event) {
929        console.log('DokuLLM: Finding and inserting template');
930        const editor = document.getElementById('wiki__text');
931        if (!editor) {
932            console.log('DokuLLM: Editor not found for template search');
933            return;
934        }
935
936        // Disable the entire toolbar and prompt input
937        const toolbar = document.getElementById('llm-toolbar');
938        const promptContainer = document.getElementById('llm-custom-prompt');
939        const promptInput = promptContainer ? promptContainer.querySelector('.llm-prompt-input') : null;
940        const buttons = toolbar.querySelectorAll('button:not(.llm-modal-close)');
941
942        // Store original states for restoration
943        const originalStates = {
944            promptInput: promptInput ? promptInput.disabled : false,
945            buttons: []
946        };
947
948        // Disable prompt input if it exists
949        if (promptInput) {
950            originalStates.promptInput = promptInput.disabled;
951            promptInput.disabled = true;
952        }
953
954        // Disable all buttons and store their original states
955        buttons.forEach(button => {
956            originalStates.buttons.push({
957                element: button,
958                text: button.textContent,
959                disabled: button.disabled
960            });
961            button.textContent = 'Searching...';
962            button.disabled = true;
963        });
964        editor.readOnly = true;
965        console.log('DokuLLM: Showing loading indicator for template search');
966
967        // Get the current text to use for template search
968        const currentText = editor.value;
969
970        // Send AJAX request to find template
971        console.log('DokuLLM: Sending AJAX request to find template');
972        const formData = new FormData();
973        formData.append('call', 'plugin_dokullm');
974        formData.append('action', 'find_template');
975        formData.append('text', currentText);
976
977        fetch(DOKU_BASE + 'lib/exe/ajax.php', {
978            method: 'POST',
979            body: formData
980        })
981        .then(response => {
982            console.log('DokuLLM: Received template search response');
983            return response.json();
984        })
985        .then(data => {
986            if (data.error) {
987                console.log('DokuLLM: Error finding template:', data.error);
988                throw new Error(data.error);
989            }
990
991            if (data.result && data.result.template) {
992                console.log('DokuLLM: Template found:', data.result.template);
993                // Insert template metadata at the top of the text, but after title if present
994                const metadataLine = `~~LLM_TEMPLATE:${data.result.template}~~`;
995                editor.value = insertMetadataAfterTitle(editor.value, metadataLine);
996
997                // Show success message
998                alert(`Template found and inserted: ${data.result.template}`);
999            } else {
1000                console.log('DokuLLM: No template found');
1001                alert('No suitable template found for this content.');
1002            }
1003        })
1004        .catch(error => {
1005            console.log('DokuLLM: Error during template search:', error.message);
1006            alert('Error: ' + error.message);
1007        })
1008        .finally(() => {
1009            console.log('DokuLLM: Restoring toolbar and enabling editor');
1010            // Re-enable the toolbar and prompt input
1011            if (promptInput) {
1012                promptInput.disabled = originalStates.promptInput;
1013            }
1014            originalStates.buttons.forEach(buttonState => {
1015                buttonState.element.textContent = buttonState.text;
1016                buttonState.element.disabled = buttonState.disabled;
1017            });
1018            editor.readOnly = false;
1019        });
1020    }
1021
1022    /**
1023     * Insert template content into the editor
1024     *
1025     * Fetches template content from the backend and inserts it into the editor
1026     * at the current cursor position.
1027     *
1028     * Shows loading indicators during the fetch operation.
1029     *
1030     * Complex logic includes:
1031     * 1. Managing UI state during template loading (loading indicator, readonly)
1032     * 2. Constructing and sending AJAX requests for template content
1033     * 3. Handling response processing and error conditions
1034     * 4. Inserting template content at the correct cursor position
1035     * 5. Managing cursor position after content insertion
1036     * 6. Restoring UI state after template insertion
1037     *
1038     * @param {string} templateId - The template page ID
1039     */
1040    function insertTemplateContent(templateId) {
1041        console.log('DokuLLM: Inserting template content for:', templateId);
1042        const editor = document.getElementById('wiki__text');
1043        if (!editor) {
1044            console.log('DokuLLM: Editor not found for template insertion');
1045            return;
1046        }
1047
1048        // Show loading indicator
1049        const toolbar = document.getElementById('llm-toolbar');
1050        const originalContent = toolbar.innerHTML;
1051        toolbar.innerHTML = '<span>Loading template...</span>';
1052        editor.readOnly = true;
1053        console.log('DokuLLM: Showing loading indicator for template');
1054
1055        // Send AJAX request to get template content
1056        console.log('DokuLLM: Sending AJAX request to get template content');
1057        const formData = new FormData();
1058        formData.append('call', 'plugin_dokullm');
1059        formData.append('action', 'get_template');
1060        formData.append('template', templateId);
1061
1062        fetch(DOKU_BASE + 'lib/exe/ajax.php', {
1063            method: 'POST',
1064            body: formData
1065        })
1066        .then(response => {
1067            console.log('DokuLLM: Received template response');
1068            return response.json();
1069        })
1070        .then(data => {
1071            if (data.error) {
1072                console.log('DokuLLM: Error retrieving template:', data.error);
1073                throw new Error(data.error);
1074            }
1075
1076            console.log('DokuLLM: Template retrieved successfully, content length:', data.result.content.length);
1077            // Insert template content at cursor position or at the beginning
1078            const cursorPos = editor.selectionStart;
1079            const text = editor.value;
1080            editor.value = text.substring(0, cursorPos) + data.result.content + text.substring(cursorPos);
1081
1082            // Set cursor position after inserted content
1083            const newCursorPos = cursorPos + data.result.content.length;
1084            editor.setSelectionRange(newCursorPos, newCursorPos);
1085            editor.focus();
1086        })
1087        .catch(error => {
1088            console.log('DokuLLM: Error during template insertion:', error.message);
1089            alert('Error: ' + error.message);
1090        })
1091        .finally(() => {
1092            console.log('DokuLLM: Restoring toolbar and enabling editor');
1093            toolbar.innerHTML = originalContent;
1094            editor.readOnly = false;
1095        });
1096    }
1097
1098    /**
1099     * Fetch action definitions from the API endpoint
1100     *
1101     * Makes an AJAX request to get the LLM action definitions from the backend
1102     *
1103     * @returns {Promise<Array>} Promise that resolves to an array of action definitions
1104     */
1105    function getActions() {
1106        return new Promise((resolve, reject) => {
1107            const formData = new FormData();
1108            formData.append('call', 'plugin_dokullm');
1109            formData.append('action', 'get_actions');
1110
1111            fetch(DOKU_BASE + 'lib/exe/ajax.php', {
1112                method: 'POST',
1113                body: formData
1114            })
1115            .then(response => {
1116                if (!response.ok) {
1117                    return response.text().then(text => {
1118                        throw new Error(`Network response was not ok: ${response.status} ${response.statusText} - ${text}`);
1119                    });
1120                }
1121                return response.json();
1122            })
1123            .then(data => {
1124                if (data.error) {
1125                    throw new Error(data.error);
1126                }
1127                resolve(data.result);
1128            })
1129            .catch(error => {
1130                reject(error);
1131            });
1132        });
1133    }
1134
1135    /**
1136     * Extract content between think tags from text
1137     *
1138     * @param {string} text - The text to extract thinking content from
1139     * @returns {string} The content between think tags, or empty string if not found
1140     */
1141    function extractThinkingContent(text) {
1142        const thinkingMatch = text.match(/<think>([\s\S]*?)<\/think>/);
1143        return (thinkingMatch && thinkingMatch[1]) ? thinkingMatch[1].trim() : '';
1144    }
1145
1146    function cleanThinking(text) {
1147        // Extract AI thinking parts (between 'think' tags) from the result
1148        let thinkingContent = '';
1149        const thinkingMatch = text.match(/<th_ink>([\s\S]*?)<\/th_ink>/);
1150        if (thinkingMatch && thinkingMatch[1]) {
1151            thinkingContent = thinkingMatch[1].trim();
1152        }
1153        // Remove AI thinking parts (between 'think' tags) from the result
1154        const cleanedResult = text.replace(/<th_ink>[\s\S]*?<\/th_ink>/g, '').trim();
1155        return [cleanedResult, thinkingContent];
1156    }
1157
1158    /**
1159     * Remove everything between two XML tags from a text
1160     *
1161     * This function extracts content between specified XML opening and closing tags,
1162     * and returns both the content between tags and the rest of the text without the tags.
1163     *
1164     * @param {string} text - The text to process
1165     * @param {string} tagName - The name of the XML tag to extract content from
1166     * @returns {Array} An array with two elements: [contentBetweenTags, restOfText]
1167     */
1168    function removeBetweenXmlTags(text, tagName) {
1169        const regex = new RegExp(`<${tagName}[^>]*>([\\s\\S]*?)<\/${tagName}>`, 'g');
1170        let contentBetweenTags = '';
1171        let match;
1172
1173        // Extract content between tags
1174        while ((match = regex.exec(text)) !== null) {
1175            contentBetweenTags += match[1];
1176        }
1177
1178        // Remove all occurrences of the tags and their content
1179        const restOfText = text.replace(regex, '').trim();
1180
1181        return [contentBetweenTags, restOfText];
1182    }
1183
1184})();
1185