1<?php
2if (!defined('DOKU_INC')) die();
3
4class action_plugin_llm extends DokuWiki_Action_Plugin {
5
6    public function register(Doku_Event_Handler $controller) {
7        $controller->register_hook('TPL_ACT_RENDER', 'BEFORE', $this, 'handle_edit_page');
8    }
9
10    public function handle_edit_page(Doku_Event &$event, $param) {
11        if ($event->data !== 'edit') return;
12
13        global $ID;
14        $textarea_id = 'wiki__text';
15
16        // Inject HTML and JavaScript
17        echo '<div id="llm-plugin-container" style="margin-bottom: 10px;">';
18        echo '<div id="llm-wizard-output" style="display: none; margin-top: 10px; max-height: 200px; overflow-y: auto; border: 1px solid #ccc; padding: 5px; resize: vertical; min-height: 100px;">';
19        echo '<div id="llm-output-container"></div>';
20        echo '<div style="margin-top: 5px;">';
21        echo '<button id="llm-clear-output" style="margin: 5px;" title="Clear all results">��️</button>';
22        echo '<button id="llm-copy-all" style="margin: 5px;" title="Copy all visible text to clipboard">��</button>';
23        echo '<button id="llm-toggle-buttons" style="margin: 5px;" title="Toggle visibility of line buttons">��️</button>';
24        echo '<button id="llm-paste-all" style="margin: 5px;" title="Paste all visible lines to main textarea">��</button>';
25        echo '</div>';
26        echo '</div>';
27        echo '<div id="llm-status" style="color: #555;"></div>';
28        echo '<select id="llm-model-select" style="margin-right: 10px;">';
29        echo '<option value="">Select a model</option>';
30        echo '<option value="https://huggingface.co/bartowski/Reasoning-0.5b-GGUF/resolve/main/Reasoning-0.5b-Q6_K.gguf|GGUF_CPU">Reasoning-0.5B-Q6_K|GGUF_CPU</option>';
31        echo '<option value="https://huggingface.co/Qwen/Qwen2-0.5B-Instruct-GGUF/resolve/main/qwen2-0_5b-instruct-q4_0.gguf|GGUF_CPU">Qwen2-0.5B-Instruct (353 MB)</option>';
32        echo '<option value="https://huggingface.co/rahuldshetty/llm.js/resolve/main/TinyMistral-248M-SFT-v4.Q8_0.gguf|GGUF_CPU">TinyMistral-248M-SFT-v4 (264 MB)</option>';
33        echo '<option value="https://huggingface.co/rahuldshetty/llm.js/resolve/main/llama2_xs_460m_experimental_evol_instruct.q4_k_m.gguf|GGUF_CPU">LLaMa Lite (289 MB)</option>';
34        echo '<option value="https://huggingface.co/rahuldshetty/llm.js/resolve/main/tiny-llama-miniguanaco-1.5t.q2_k.gguf|GGUF_CPU">TinyLLama 1.5T (482 MB)</option>';
35        echo '<option value="https://huggingface.co/afrideva/TinyMistral-248M-Alpaca-GGUF/resolve/main/tinymistral-248m-alpaca.q4_k_m.gguf|GGUF_CPU">TinyMistral-248M-Alpaca (156 MB)</option>';
36        echo '<option value="https://huggingface.co/unsloth/DeepSeek-R1-Distill-Qwen-1.5B-GGUF/resolve/main/DeepSeek-R1-Distill-Qwen-1.5B-Q6_K.gguf|GGUF_CPU">DeepSeek-R1-Distill-Qwen-1.5B|GGUF_CPU</option>';
37        echo '<option value="https://huggingface.co/bartowski/Reasoning-0.5b-GGUF/resolve/main/Reasoning-0.5b-Q4_K_M.gguf|GGUF_CPU">Reasoning-0.5B-Q4_K_M</option>';
38        // Additional models compatible with llm.js
39        echo '<option value="https://huggingface.co/QuantFactory/Phi-3-mini-4k-instruct-GGUF/resolve/main/Phi-3-mini-4k-instruct.Q4_K_M.gguf|GGUF_CPU">Phi-3-mini-4k-instruct (2.2 GB)</option>';
40        echo '<option value="https://huggingface.co/bartowski/Meta-Llama-3.1-8B-Instruct-GGUF/resolve/main/Meta-Llama-3.1-8B-Instruct-Q4_K_M.gguf|GGUF_CPU">Llama-3.1-8B-Instruct-Q4_K_M (4.7 GB)</option>';
41        echo '<option value="https://huggingface.co/bartowski/gemma-2-2b-it-GGUF/resolve/main/gemma-2-2b-it-Q4_K_M.gguf|GGUF_CPU">Gemma-2-2B-IT-Q4_K_M (1.3 GB)</option>';
42        echo '<option value="https://huggingface.co/bartowski/Mixtral-8x7B-Instruct-v0.1-GGUF/resolve/main/Mixtral-8x7B-Instruct-v0.1-Q2_K.gguf|GGUF_CPU">Mixtral-8x7B-Instruct-Q2_K (12 GB)</option>';
43        echo '<option value="custom">Custom Model (Hugging Face URL)</option>';
44        echo '</select>';
45        echo '<input type="text" id="llm-custom-url" placeholder="Enter Hugging Face GGUF URL" style="display: none; width: 300px; margin-left: 10px;">';
46        echo '<progress id="llm-progress" hidden style="width: 100%;"></progress>';
47        echo '<div id="llm-options" style="display: none; margin: 5px 0;">';
48        echo '<div style="display: flex; align-items: center; margin-bottom: 5px;">';
49        echo '<textarea id="llm-prompt" placeholder="Enter your prompt here..." style="width: 100%; height: 50px; margin-right: 5px;"></textarea>';
50        echo '<button id="llm-send-prompt" style="height: 50px;" title="Send prompt to AI">✈️</button>';
51        echo '</div>';
52        echo '<button id="llm-toggle-advanced" style="margin-bottom: 5px;">Show Advanced Options</button>';
53        echo '<div id="llm-advanced-options" style="display: none;">';
54        echo '<div style="display: flex; flex-wrap: wrap; gap: 10px;">';
55        echo '<label>Top-K (1-100): <span id="topk-value">1</span> <input type="range" id="llm-topk" min="1" max="100" step="1" value="1"></label>';
56        echo '<label>Temperature (0-2): <span id="temp-value">1</span> <input type="range" id="llm-temperature" min="0" max="2" step="0.1" value="1"></label>';
57        echo '<label>Max Tokens (1-500): <span id="maxtoken-value">50</span> <input type="range" id="llm-maxtoken" min="1" max="500" step="1" value="50"></label>';
58        echo '<label>Top-P (0-1): <span id="topp-value">0.9</span> <input type="range" id="llm-topp" min="0" max="1" step="0.05" value="0.9"></label>';
59        echo '<label>Context Size (1-2048): <span id="context-value">512</span> <input type="range" id="llm-context" min="1" max="2048" step="1" value="512"></label>';
60        echo '</div>';
61        echo '<textarea id="llm-grammar" placeholder="Optional GBNF grammar (e.g., root ::= [a-z]+)" style="width: 100%; height: 50px; margin-top: 5px;"></textarea>';
62        echo '<textarea id="llm-prompt-template" placeholder="Prompt template (use ${prompt} for input)" style="width: 100%; height: 50px; margin-top: 5px;"><|im_start|>user\n${prompt}<|im_end|>\n<|im_start|>assistant\n</textarea>';
63        echo '</div>';
64        echo '<button id="llm-summarizer" style="margin: 5px;" title="Summarize selected or all text">��</button>';
65        echo '<button id="llm-proofreader" style="margin: 5px;" title="Proofread selected or all text">✏️</button>';
66        echo '<button id="llm-translator" style="margin: 5px;" title="Translate selected or all text">��</button>';
67        echo '<select id="llm-language" style="display: none; margin: 5px;">';
68        echo '<option value="">Select Language</option>';
69        echo '<option value="en">English</option>';
70        echo '<option value="zh-TW">Traditional Chinese</option>';
71        echo '<option value="zh-CN">Simplified Chinese</option>';
72        echo '<option value="ja">Japanese</option>';
73        echo '<option value="fr">French</option>';
74        echo '<option value="es">Spanish</option>';
75        echo '<option value="de">German</option>';
76        echo '</select>';
77        echo '<select id="llm-output-format" style="margin: 5px;">';
78        echo '<option value="raw">Raw Text</option>';
79        echo '<option value="prefix">[Assistant] </option>';
80        echo '<option value="timestamp">[Time] </option>';
81        echo '</select>';
82        echo '</div>';
83        echo '</div>';
84
85        // Inject llm.js and custom script
86        ?>
87        <script type="module">
88            import { LLM } from '<?php echo DOKU_BASE; ?>lib/plugins/llm/llm.js/llm.js';
89
90            let LLMEngine;
91            const modelSelect = document.getElementById('llm-model-select');
92            const customUrl = document.getElementById('llm-custom-url');
93            const promptTextarea = document.getElementById('llm-prompt');
94            const sendPromptButton = document.getElementById('llm-send-prompt');
95            const progress = document.getElementById('llm-progress');
96            const optionsDiv = document.getElementById('llm-options');
97            const advancedOptionsDiv = document.getElementById('llm-advanced-options');
98            const toggleAdvancedButton = document.getElementById('llm-toggle-advanced');
99            const status = document.getElementById('llm-status');
100            const mainTextarea = document.getElementById('<?php echo $textarea_id; ?>');
101            const topkSlider = document.getElementById('llm-topk');
102            const tempSlider = document.getElementById('llm-temperature');
103            const maxTokenSlider = document.getElementById('llm-maxtoken');
104            const topPSlider = document.getElementById('llm-topp');
105            const contextSlider = document.getElementById('llm-context');
106            const grammarTextarea = document.getElementById('llm-grammar');
107            const topkValue = document.getElementById('topk-value');
108            const tempValue = document.getElementById('temp-value');
109            const maxTokenValue = document.getElementById('maxtoken-value');
110            const topPValue = document.getElementById('topp-value');
111            const contextValue = document.getElementById('context-value');
112            const wizardOutputDiv = document.getElementById('llm-wizard-output');
113            const outputContainer = document.getElementById('llm-output-container');
114            const clearOutputButton = document.getElementById('llm-clear-output');
115            const copyAllButton = document.getElementById('llm-copy-all');
116            const toggleButtons = document.getElementById('llm-toggle-buttons');
117            const pasteAllButton = document.getElementById('llm-paste-all');
118            const languageSelect = document.getElementById('llm-language');
119            const outputFormatSelect = document.getElementById('llm-output-format');
120            const promptTemplateTextarea = document.getElementById('llm-prompt-template');
121
122            let buttonsHidden = false;
123
124            // Update slider values
125            [topkSlider, tempSlider, maxTokenSlider, topPSlider, contextSlider].forEach(slider => {
126                slider.addEventListener('input', () => {
127                    document.getElementById(slider.id.replace('llm-', '') + '-value').textContent = slider.value;
128                });
129            });
130
131            // Model selection and custom URL
132            modelSelect.addEventListener('change', () => {
133                const value = modelSelect.value;
134                customUrl.style.display = value === 'custom' ? 'inline' : 'none';
135                optionsDiv.style.display = 'none';
136                wizardOutputDiv.style.display = 'none';
137                if (!value) return;
138
139                let url = value === 'custom' ? customUrl.value : value.split('|')[0];
140                const type = value === 'custom' ? 'GGUF_CPU' : value.split('|')[1];
141                if (!url) return;
142
143                status.textContent = 'Loading model...';
144                progress.hidden = false;
145
146                LLMEngine = new LLM(
147                    type,
148                    url,
149                    () => {
150                        status.textContent = 'Model loaded successfully!';
151                        progress.hidden = true;
152                        optionsDiv.style.display = 'block';
153                        wizardOutputDiv.style.display = 'block';
154                    },
155                    (line) => {
156                        console.log('Progress:', line);
157                        const cleanLine = line.replace(/<\|im_(start|end)\|>/g, '').trim();
158                        if (cleanLine) {
159                            const formattedText = formatOutput(cleanLine);
160                            if (formattedText) addOutput(formattedText, false);
161                        }
162                    },
163                    () => {
164                        status.textContent = 'Generation complete.';
165                    },
166                    {
167                        wasmUrl: '<?php echo DOKU_BASE; ?>lib/plugins/llm/llm.js/llamacpp-cpu.js',
168                        workerUrl: '<?php echo DOKU_BASE; ?>lib/plugins/llm/llm.js/llm.worker.js'
169                    }
170                );
171
172                try {
173                    LLMEngine.load_worker();
174                } catch (error) {
175                    status.textContent = `Error initializing worker: ${error.message}`;
176                    console.error('Worker initialization error:', error);
177                    progress.hidden = true;
178                }
179            });
180
181            // Send prompt function
182            function sendPrompt() {
183                const prompt = promptTextarea.value.trim();
184                if (prompt && LLMEngine) {
185                    status.textContent = 'Generating...';
186                    promptTextarea.value = '';
187                    const template = promptTemplateTextarea.value.trim();
188                    const formattedPrompt = template.replace('${prompt}', prompt);
189                    runLLM(formattedPrompt);
190                } else if (!LLMEngine) {
191                    status.textContent = 'Please select a model first.';
192                }
193            }
194
195            // Prompt generation on Enter
196            promptTextarea.addEventListener('keypress', (e) => {
197                if (e.key === 'Enter' && !e.shiftKey) {
198                    e.preventDefault();
199                    sendPrompt();
200                }
201            });
202
203            // Send prompt button
204            sendPromptButton.addEventListener('click', sendPrompt);
205
206            // Toggle advanced options
207            toggleAdvancedButton.addEventListener('click', () => {
208                const isHidden = advancedOptionsDiv.style.display === 'none';
209                advancedOptionsDiv.style.display = isHidden ? 'block' : 'none';
210                toggleAdvancedButton.textContent = isHidden ? 'Hide Advanced Options' : 'Show Advanced Options';
211            });
212
213            // AI Wizard buttons
214            document.getElementById('llm-summarizer').addEventListener('click', () => runWizard('summarize'));
215            document.getElementById('llm-proofreader').addEventListener('click', () => runWizard('proofread'));
216            document.getElementById('llm-translator').addEventListener('click', () => {
217                languageSelect.style.display = 'inline';
218                if (!languageSelect.value) {
219                    status.textContent = 'Please select a language for translation.';
220                    return;
221                }
222                runWizard('translate');
223            });
224
225            // Clear all results
226            clearOutputButton.addEventListener('click', () => {
227                outputContainer.innerHTML = '';
228                status.textContent = 'Results cleared.';
229            });
230
231            // Copy all visible text
232            copyAllButton.addEventListener('click', () => {
233                const visibleText = Array.from(outputContainer.querySelectorAll('.output-div'))
234                    .filter(div => div.style.display !== 'none')
235                    .map(div => div.querySelector('span').textContent)
236                    .join('');
237                navigator.clipboard.writeText(visibleText).then(() => {
238                    status.textContent = 'Copied all visible text to clipboard!';
239                    setTimeout(() => status.textContent = '', 2000);
240                });
241            });
242
243            // Paste all visible text to main textarea
244            pasteAllButton.addEventListener('click', () => {
245                const visibleText = Array.from(outputContainer.querySelectorAll('.output-div'))
246                    .filter(div => div.style.display !== 'none')
247                    .map(div => div.querySelector('span').textContent)
248                    .join('');
249                const startPos = mainTextarea.selectionStart;
250                const endPos = mainTextarea.selectionEnd;
251                mainTextarea.value = mainTextarea.value.substring(0, startPos) + visibleText + mainTextarea.value.substring(endPos);
252                status.textContent = 'Pasted all visible text to textarea!';
253                setTimeout(() => status.textContent = '', 2000);
254            });
255
256            // Toggle copy/remove/paste buttons
257            toggleButtons.addEventListener('click', () => {
258                buttonsHidden = !buttonsHidden;
259                const buttons = outputContainer.querySelectorAll('.output-div button');
260                buttons.forEach(button => {
261                    button.style.display = buttonsHidden ? 'none' : 'inline';
262                });
263                toggleButtons.textContent = buttonsHidden ? '��️' : '��️‍��️';
264            });
265
266            // Format output based on user selection
267            function formatOutput(text) {
268                const format = outputFormatSelect.value;
269                switch (format) {
270                    case 'raw': return text.trim() + '\n';
271                    case 'prefix': return '[Assistant] ' + text.trim() + '\n';
272                    case 'timestamp': return `[${new Date().toLocaleTimeString()}] ` + text.trim() + '\n';
273                    default: return text.trim() + '\n';
274                }
275            }
276
277            // LLM run function
278            function runLLM(prompt, callback = addOutput, onComplete = () => status.textContent = 'Generation complete.', isUserPrompt = false) {
279                LLMEngine.run({
280                    prompt: prompt,
281                    max_token_len: parseInt(maxTokenSlider.value),
282                    top_k: parseInt(topkSlider.value),
283                    top_p: parseFloat(topPSlider.value),
284                    temp: parseFloat(tempSlider.value),
285                    context_size: parseInt(contextSlider.value),
286                    grammar: grammarTextarea.value.trim() || '',
287                    write_result_callback: (line) => {
288                        const cleanLine = line.replace(/<\|im_(start|end)\|>/g, '').trim();
289                        if (cleanLine) {
290                            const formattedText = formatOutput(cleanLine);
291                            if (formattedText) callback(formattedText, isUserPrompt);
292                        }
293                    },
294                    on_complete_callback: onComplete
295                });
296            }
297
298            // Add output to wizard div
299            function addOutput(text, isUserPrompt = false) {
300                const outputDiv = document.createElement('div');
301                outputDiv.className = 'output-div';
302                outputDiv.style.border = '1px solid #ddd';
303                outputDiv.style.padding = '5px';
304                outputDiv.style.margin = '5px 0';
305                outputDiv.style.display = 'flex';
306                outputDiv.style.alignItems = 'center';
307                if (isUserPrompt) outputDiv.setAttribute('data-user-input', 'true');
308
309                const textSpan = document.createElement('span');
310                textSpan.textContent = text;
311                textSpan.style.flexGrow = '1';
312
313                const copyButton = document.createElement('button');
314                copyButton.textContent = '��';
315                copyButton.title = 'Copy this line to clipboard';
316                copyButton.style.marginLeft = '10px';
317                copyButton.addEventListener('click', () => {
318                    navigator.clipboard.writeText(text.trim()).then(() => {
319                        status.textContent = 'Copied to clipboard!';
320                        setTimeout(() => status.textContent = '', 2000);
321                    });
322                });
323
324                const removeButton = document.createElement('button');
325                removeButton.textContent = '��️';
326                removeButton.title = 'Remove this line';
327                removeButton.style.marginLeft = '10px';
328                removeButton.addEventListener('click', () => {
329                    outputDiv.remove();
330                    status.textContent = 'Line removed.';
331                });
332
333                const pasteButton = document.createElement('button');
334                pasteButton.textContent = '��';
335                pasteButton.title = 'Paste this line to main textarea';
336                pasteButton.style.marginLeft = '10px';
337                pasteButton.addEventListener('click', () => {
338                    const startPos = mainTextarea.selectionStart;
339                    const endPos = mainTextarea.selectionEnd;
340                    // Append newline after the pasted text
341                    mainTextarea.value = mainTextarea.value.substring(0, startPos) + text.trim() + '\n' + mainTextarea.value.substring(endPos);
342                    // Move cursor after the pasted text and newline
343                    mainTextarea.selectionStart = mainTextarea.selectionEnd = startPos + text.trim().length + 1;
344                    status.textContent = 'Pasted to textarea!';
345                    setTimeout(() => status.textContent = '', 2000);
346                });
347
348                outputDiv.appendChild(textSpan);
349                outputDiv.appendChild(copyButton);
350                outputDiv.appendChild(pasteButton);
351                outputDiv.appendChild(removeButton);
352                outputContainer.appendChild(outputDiv);
353                wizardOutputDiv.scrollTop = wizardOutputDiv.scrollHeight;
354            }
355
356            // AI Wizard logic
357            async function runWizard(mode) {
358                if (!LLMEngine) {
359                    status.textContent = 'Please load a model first.';
360                    return;
361                }
362                const text = mainTextarea.value;
363                const selectedText = text.substring(mainTextarea.selectionStart, mainTextarea.selectionEnd);
364
365                if (selectedText) {
366                    const prompt = getPrompt(mode, selectedText);
367                    status.textContent = `Running ${mode}...`;
368                    runLLM(prompt, addOutput, () => status.textContent = `${mode} complete.`);
369                } else {
370                    const lines = text.split('\n');
371                    const chunkSize = Math.ceil(parseInt(maxTokenSlider.value) / 2);
372                    const chunks = [];
373                    for (let i = 0; i < lines.length; i += chunkSize) {
374                        chunks.push(lines.slice(i, i + chunkSize).join('\n'));
375                    }
376                    for (let i = 0; i < chunks.length; i++) {
377                        const prompt = getPrompt(mode, chunks[i]);
378                        status.textContent = `Running ${mode} (${i + 1}/${chunks.length})...`;
379                        await new Promise(resolve => {
380                            runLLM(prompt, addOutput, () => resolve());
381                        });
382                    }
383                    status.textContent = `${mode} complete.`;
384                }
385            }
386
387            // Generate prompt based on mode
388            function getPrompt(mode, text) {
389                const template = promptTemplateTextarea.value.trim();
390                switch (mode) {
391                    case 'summarize': return template.replace('${prompt}', `Summarize the paragraphs:\n${text}`);
392                    case 'proofread': return template.replace('${prompt}', `Proofread this text and suggest corrections:\n${text}`);
393                    case 'translate': return template.replace('${prompt}', `Translate this text to ${languageSelect.value}:\n${text}`);
394                }
395            }
396        </script>
397        <?php
398    }
399}