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}