From version 14.1
edited by Jiahao Lai
on 2026/03/26 15:50
on 2026/03/26 15:50
Change comment:
There is no comment for this version
To version 15.1
edited by Jiahao Lai
on 2026/03/26 16:08
on 2026/03/26 16:08
Change comment:
There is no comment for this version
Summary
-
Page properties (1 modified, 0 added, 0 removed)
Details
- Page properties
-
- Content
-
... ... @@ -185,3 +185,632 @@ 185 185 #display4Cards($Inverter) 186 186 {{/html}} 187 187 {{/velocity}} 188 + 189 + 190 + 191 +{{html clean="false"}} 192 + 193 +// Initialize marked library 194 +marked.use({ breaks: true }); 195 + 196 +// Initialize variables 197 +let currentRequest = null; 198 +let abortController = null; 199 +let conversationHistory = []; 200 +let userSettings = { 201 + model: '', 202 + temperature: 0, 203 + stream: true, 204 + settingsCollapsed: false 205 +}; 206 +let chatHistory; 207 +let chatInput; 208 +let sendButton; 209 +let stopButton; 210 +let modelSelect; 211 +let temperatureInput; 212 +let streamCheckbox; 213 +let chatWidget; 214 +let toggleChatButton; 215 +let settingsContainer; 216 +let settingsToggle; 217 +let newConvButton; 218 +let isResizing = false; 219 +let startX, startY, startWidth, startHeight; 220 + 221 +// Get the script tag 222 +const scriptTag = document.getElementById('chat-widget'); 223 + 224 +XWikiAiAPI.setBaseURL(scriptTag.dataset.baseUrl || ''); 225 + 226 +// Set the wiki name 227 +if (scriptTag && scriptTag.dataset.wikiName) { 228 + XWikiAiAPI.setWikiName(scriptTag.dataset.wikiName); 229 +} else { 230 + // Set default wiki name to 'xwiki' if not provided 231 + XWikiAiAPI.setWikiName('xwiki'); 232 +} 233 + 234 + 235 +// Create the chat widget HTML dynamically 236 +function createChatWidget() { 237 + const chatWidgetElement = document.createElement('div'); 238 + chatWidgetElement.id = 'chat-widget'; 239 + chatWidgetElement.innerHTML = ` 240 + <div id="resize-handle"></div> 241 + <div id="chat-widget-divider"></div> 242 + <div id="chat-container"> 243 + <h2>XWiki AI Chat</h2> 244 + <div class="settings-container"> 245 + <button id="new-conv">New</button> 246 + <button id="settings-toggle">Settings</button> 247 + <div class="settings-wrapper"> 248 + <div class="settings-top-line"></div> 249 + <div class="settings"> 250 + <div> 251 + <label for="model-select">Model:</label> 252 + <select id="model-select"></select> 253 + </div> 254 + <div> 255 + <label for="temperature-input">Temp:</label> 256 + <input type="number" id="temperature-input" min="0" max="2" step="0.1" value="0"> 257 + </div> 258 + <div> 259 + <label for="stream-checkbox">Stream:</label> 260 + <input type="checkbox" id="stream-checkbox" checked> 261 + </div> 262 + </div> 263 + <div class="settings-bottom-line"></div> 264 + </div> 265 + </div> 266 + <div id="chat-history"></div> 267 + <textarea id="chat-input" placeholder="Type your message..."></textarea> 268 + <div id="button-container"> 269 + <button id="send-button">Send</button> 270 + <button id="stop-button" style="display: none;">Stop</button> 271 + </div> 272 + </div> 273 + `; 274 + document.body.appendChild(chatWidgetElement); 275 + return chatWidgetElement; 276 +} 277 + 278 +// Create the toggle chat button HTML dynamically 279 +function createToggleChatButton() { 280 + const toggleChatButtonElement = document.createElement('button'); 281 + toggleChatButtonElement.id = 'toggle-chat-button'; 282 + toggleChatButtonElement.textContent = '✨ Chat'; 283 + document.body.appendChild(toggleChatButtonElement); 284 + return toggleChatButtonElement; 285 +} 286 + 287 +// Create an expandable bubble with sources 288 +function createSourcesBubble(sources) { 289 + const sourcesBubble = document.createElement('div'); 290 + sourcesBubble.classList.add('sources-bubble'); 291 + const sourceLinks = sources.split('\n').map(source => { 292 + const trimmedSource = source.trim(); 293 + return `<li><a href="${trimmedSource}" target="_blank">${trimmedSource}</a></li>`; 294 + }).join(''); 295 + sourcesBubble.innerHTML = ` 296 + <div class="sources-header">Sources <span class="expand-icon">+</span></div> 297 + <div class="sources-content hidden"><ul>${sourceLinks}</ul></div> 298 + `; 299 + sourcesBubble.querySelector('.sources-header').addEventListener('click', () => { 300 + sourcesBubble.querySelector('.sources-content').classList.toggle('hidden'); 301 + sourcesBubble.querySelector('.expand-icon').textContent = sourcesBubble.querySelector('.sources-content').classList.contains('hidden') ? '+' : '-'; 302 + }); 303 + return sourcesBubble; 304 +} 305 + 306 +// Fetch available models and populate the select dropdown 307 +async function populateModelSelect() { 308 + try { 309 + const response = await XWikiAiAPI.getModels(); 310 + const models = response.data; 311 + modelSelect.innerHTML = ''; 312 + models.forEach(model => { 313 + const option = document.createElement('option'); 314 + option.value = model.id; 315 + option.text = model.name; 316 + modelSelect.appendChild(option); 317 + }); 318 + // Set the selected model to the user's stored setting or the first option 319 + const storedModel = userSettings.model; 320 + if (storedModel && models.some(model => model.id === storedModel)) { 321 + modelSelect.value = storedModel; 322 + } else { 323 + modelSelect.selectedIndex = 0; 324 + userSettings.model = modelSelect.value; 325 + } 326 + } catch (error) { 327 + console.error('Failed to fetch models:', error); 328 + } 329 +} 330 + 331 +// Initialize the chat widget and toggle button 332 +async function initializeChatWidget() { 333 + chatWidget = createChatWidget(); 334 + toggleChatButton = createToggleChatButton(); 335 + 336 + // Get references to the dynamically created elements 337 + chatHistory = document.getElementById('chat-history'); 338 + chatInput = document.getElementById('chat-input'); 339 + sendButton = document.getElementById('send-button'); 340 + stopButton = document.getElementById('stop-button'); 341 + modelSelect = document.getElementById('model-select'); 342 + temperatureInput = document.getElementById('temperature-input'); 343 + streamCheckbox = document.getElementById('stream-checkbox'); 344 + settingsContainer = document.querySelector('.settings-container'); 345 + settingsToggle = document.getElementById('settings-toggle'); 346 + newConvButton = document.getElementById('new-conv'); 347 + const resizeHandle = document.getElementById('resize-handle'); 348 + resizeHandle.addEventListener('mousedown', initResize); 349 + document.addEventListener('mousemove', resize); 350 + document.addEventListener('mouseup', stopResize); 351 + 352 + // Populate the model select dropdown 353 + await populateModelSelect(); 354 + 355 + // Load user settings from local storage 356 + loadUserSettings(); 357 + 358 + // Update user settings when changed 359 + modelSelect.addEventListener('change', updateUserSettings); 360 + temperatureInput.addEventListener('input', updateUserSettings); 361 + streamCheckbox.addEventListener('change', updateUserSettings); 362 + 363 + // Add event listeners 364 + newConvButton.addEventListener('click', startNewConversation); 365 + sendButton.addEventListener('click', sendMessage); 366 + chatInput.addEventListener('keydown', handleChatInputKeydown); 367 + toggleChatButton.addEventListener('click', toggleChatWidget); 368 + chatInput.addEventListener('focus', handleChatInputFocus); 369 + chatInput.addEventListener('blur', handleChatInputBlur); 370 + settingsToggle.addEventListener('click', toggleSettings); 371 + 372 + // Load last conversation from local storage 373 + loadLastConversation(); 374 +} 375 + 376 + 377 +function initResize(e) { 378 + isResizing = true; 379 + startX = e.clientX; 380 + startY = e.clientY; 381 + startWidth = parseInt(document.defaultView.getComputedStyle(chatWidget).width, 10); 382 + startHeight = parseInt(document.defaultView.getComputedStyle(chatWidget).height, 10); 383 + e.preventDefault(); 384 +} 385 + 386 +function startResize(e) { 387 + isResizing = true; 388 + startX = e.clientX; 389 + startY = e.clientY; 390 + startWidth = parseInt(document.defaultView.getComputedStyle(chatWidget).width, 10); 391 + startHeight = parseInt(document.defaultView.getComputedStyle(chatWidget).height, 10); 392 + document.addEventListener('mousemove', resize); 393 + document.addEventListener('mouseup', stopResize); 394 + e.preventDefault(); 395 +} 396 + 397 +function resize(e) { 398 + if (!isResizing) return; 399 + const width = startWidth - (e.clientX - startX); 400 + const height = startHeight - (e.clientY - startY); 401 + if (width > 300 && height > 400) { 402 + chatWidget.style.width = width + 'px'; 403 + chatWidget.style.height = height + 'px'; 404 + } 405 +} 406 + 407 +function stopResize() { 408 + isResizing = false; 409 +} 410 + 411 +// Modify the toggleChatWidget function 412 +function toggleChatWidget() { 413 + if (isPanelMode) { 414 + chatWidget.style.display = chatWidget.style.display === 'none' ? 'flex' : 'none'; 415 + } else { 416 + chatWidget.style.display = chatWidget.style.display === 'none' ? 'block' : 'none'; 417 + } 418 +} 419 + 420 +// Handle new conversation 421 +function startNewConversation() { 422 + // Clear the conversation history 423 + conversationHistory = []; 424 + saveConversationHistory(); 425 + 426 + // Clear the chat history 427 + chatHistory.innerHTML = ''; 428 +} 429 + 430 +// Handle chat input keydown event 431 +function handleChatInputKeydown(event) { 432 + if (event.key === 'Enter' && !event.shiftKey) { 433 + event.preventDefault(); 434 + sendMessage(); 435 + } 436 +} 437 + 438 +// Toggle the visibility of the chat widget 439 +function toggleChatWidget() { 440 + chatWidget.style.display = chatWidget.style.display === 'none' ? 'block' : 'none'; 441 +} 442 + 443 +// Handle chat input focus event 444 +function handleChatInputFocus() { 445 + chatWidget.classList.add('keyboard-open'); 446 +} 447 + 448 +// Handle chat input blur event 449 +function handleChatInputBlur() { 450 + chatWidget.classList.remove('keyboard-open'); 451 +} 452 + 453 +// Change the state of the send and stop buttons 454 +function changeBtnState(enabled) { 455 + sendButton.disabled = !enabled; 456 + stopButton.style.display = enabled ? 'none' : 'inline-block'; 457 +} 458 + 459 +// Show the waiting animation 460 +function showWaitingAnimation() { 461 + const waitingLine = document.createElement('div'); 462 + waitingLine.classList.add('waiting-line'); 463 + waitingLine.innerHTML = ` 464 + <div class="dot dot1"></div> 465 + <div class="dot dot2"></div> 466 + <div class="dot dot3"></div> 467 + `; 468 + chatHistory.appendChild(waitingLine); 469 + chatHistory.scrollTop = chatHistory.scrollHeight; 470 +} 471 + 472 +// Remove the waiting animation 473 +function removeWaitingAnimation() { 474 + const waitingLine = chatHistory.querySelector('.waiting-line'); 475 + if (waitingLine) { 476 + waitingLine.remove(); 477 + } 478 +} 479 + 480 +// Send a message to the assistant 481 +function sendMessage() { 482 + const userMessage = chatInput.value.trim(); 483 + if (userMessage === '') return; 484 + 485 + // Add user message to conversation history 486 + conversationHistory.push({ role: 'user', content: userMessage }); 487 + 488 + // Display user message in the chat history 489 + displayUserMessage(userMessage); 490 + 491 + // Clear the chat input 492 + chatInput.value = ''; 493 + 494 + // Create a new abort controller for the request 495 + abortController = new AbortController(); 496 + const signal = abortController.signal; 497 + 498 + // Create a new request with the full conversation history 499 + const request = new ChatCompletionRequest( 500 + modelSelect.value, 501 + parseFloat(temperatureInput.value), 502 + conversationHistory, 503 + streamCheckbox.checked 504 + ); 505 + 506 + // Display the assistant message container in the chat history 507 + const assistantMessageElement = displayAssistantMessage('', modelSelect.options[modelSelect.selectedIndex].text, 0); 508 + 509 + // Show the waiting animation 510 + showWaitingAnimation(); 511 + 512 + // Send the request to the API 513 + if (request.stream) { 514 + handleStreamingRequest(request, signal, assistantMessageElement); 515 + } else { 516 + handleNonStreamingRequest(request, signal, assistantMessageElement); 517 + } 518 + 519 + // Disable the send button and show the stop button 520 + changeBtnState(false); 521 + 522 + // Add event listener to the stop button 523 + stopButton.addEventListener('click', stopRequest); 524 +} 525 + 526 +// Handle streaming request 527 +function handleStreamingRequest(request, signal, assistantMessageElement) { 528 + let messageText = ''; 529 + let sourcesText = ''; 530 + const startTime = new Date().getTime(); 531 + let updateTimer; 532 + let sourcesBubble = null; 533 + 534 + const assistantInfoElement = assistantMessageElement.previousElementSibling; 535 + updateTimer = setInterval(() => { 536 + updateResponseTime(startTime, assistantInfoElement); 537 + }, 100); 538 + 539 + XWikiAiAPI.getCompletions(request, messageChunk => { 540 + if (messageChunk.choices.length > 0 && messageChunk.choices[0].delta.content !== null) { 541 + const content = messageChunk.choices[0].delta.content; 542 + 543 + if (content.startsWith("Sources:")) { 544 + sourcesText += content.replace("Sources:", "").trim(); 545 + if (!sourcesBubble) { 546 + sourcesBubble = createSourcesBubble(sourcesText); 547 + assistantMessageElement.parentElement.insertBefore(sourcesBubble, assistantMessageElement); 548 + } else { 549 + sourcesBubble.querySelector('.sources-content').innerHTML = sourcesText; 550 + } 551 + } else { 552 + messageText += content; 553 + assistantMessageElement.innerHTML = DOMPurify.sanitize(marked.parse(messageText), { FORBID_TAGS: ['style'], FORBID_ATTR: ['src'] }); 554 + } 555 + 556 + removeWaitingAnimation(); 557 + } 558 + }, signal) 559 + .then((usageData) => { 560 + if (messageText !== '') { 561 + conversationHistory.push({ role: 'assistant', content: assistantMessageElement.textContent }); 562 + saveConversationHistory(); 563 + } 564 + clearInterval(updateTimer); 565 + 566 + if (usageData) { 567 + displayUsageInfo(assistantInfoElement, usageData, startTime); 568 + } 569 + }) 570 + .catch(error => { 571 + handleRequestError(error, updateTimer); 572 + }) 573 + .finally(() => { 574 + changeBtnState(true); 575 + }); 576 +} 577 + 578 +// Display usage information 579 +function displayUsageInfo(assistantLabel, usageData, startTime) { 580 + const responseTime = endTimer(startTime); 581 + assistantLabel.innerHTML = `<strong>Assistant (${modelSelect.options[modelSelect.selectedIndex].text})</strong><em> - ΔT ${responseTime.toFixed(1)}s </em>`; 582 + 583 + const usageSpan = document.createElement('span'); 584 + usageSpan.classList.add('usage'); 585 + usageSpan.innerHTML = '<em> - tokens<span class="usage-info">(📊)</span></em>'; 586 + assistantLabel.appendChild(usageSpan); 587 + 588 + const usageInfo = document.createElement('div'); 589 + usageInfo.classList.add('usage-info-box'); 590 + usageInfo.innerHTML = ` 591 + <p>Prompt tokens: ${usageData.prompt_tokens}</p> 592 + <p>Completion tokens: ${usageData.completion_tokens}</p> 593 + <p>Total tokens: ${usageData.total_tokens}</p> 594 + `; 595 + usageSpan.appendChild(usageInfo); 596 +} 597 + 598 + 599 +// Handle non-streaming request 600 +function handleNonStreamingRequest(request, signal, assistantMessageElement) { 601 + const startTime = new Date().getTime(); 602 + 603 + XWikiAiAPI.getCompletions(request, null, signal) 604 + .then(response => { 605 + handleNonStreamingResponse(response, startTime, assistantMessageElement); 606 + }) 607 + .catch(error => { 608 + handleRequestError(error); 609 + }) 610 + .finally(() => { 611 + changeBtnState(true); 612 + }); 613 +} 614 + 615 +// Handle non-streaming response 616 +function handleNonStreamingResponse(response, startTime, assistantMessageElement) { 617 + const endTime = new Date().getTime(); 618 + const responseTime = (endTime - startTime) / 1000; 619 + 620 + const assistantMessage = response.choices[0].message.content; 621 + const llmMemory = response.choices[0].message.memory; 622 + console.debug('LLM memory before response:', llmMemory); 623 + 624 + const sourcesMatch = assistantMessage.match(/Sources:([\s\S]*?)(?=\n\n|$)/); 625 + if (sourcesMatch) { 626 + const sources = sourcesMatch[1].trim(); 627 + const content = assistantMessage.replace(sourcesMatch[0], '').trim(); 628 + const sourcesBubble = createSourcesBubble(sources); 629 + assistantMessageElement.parentElement.insertBefore(sourcesBubble, assistantMessageElement); 630 + assistantMessageElement.innerHTML = DOMPurify.sanitize(marked.parse(content), { FORBID_TAGS: ['style'], FORBID_ATTR: ['src'] }); 631 + } else { 632 + assistantMessageElement.innerHTML = DOMPurify.sanitize(marked.parse(assistantMessage), { FORBID_TAGS: ['style'], FORBID_ATTR: ['src'] }); 633 + } 634 + 635 + conversationHistory.push({ role: 'assistant', content: assistantMessage }); 636 + chatHistory.scrollTop = chatHistory.scrollHeight; 637 + removeWaitingAnimation(); 638 + saveConversationHistory(); 639 + 640 + const assistantInfoElement = assistantMessageElement.previousElementSibling.querySelector('.msg-info'); 641 + if (assistantInfoElement) { 642 + assistantInfoElement.innerHTML = `<strong>Assistant (${modelSelect.options[modelSelect.selectedIndex].text})</strong> - <em>ΔT ${responseTime.toFixed(1)}s </em>`; 643 + 644 + if (response.usage) { 645 + displayUsageInfo(assistantInfoElement, response.usage, startTime); 646 + } 647 + } else { 648 + console.warn('Assistant info element not found'); 649 + } 650 +} 651 + 652 +function endTimer(startTime) { 653 + const endTime = new Date().getTime(); 654 + const responseTime = (endTime - startTime) / 1000; 655 + return responseTime; 656 +} 657 + 658 +// Handle request error 659 +function handleRequestError(error, updateTimer) { 660 + if (error.name === 'AbortError') { 661 + console.log('Request aborted'); 662 + } else { 663 + console.error('Failed to get chat completions:', error); 664 + displayErrorMessage('An error occurred: ' + error.message); 665 + } 666 + removeWaitingAnimation(); 667 + clearInterval(updateTimer); 668 +} 669 + 670 +// Stop the current request 671 +function stopRequest() { 672 + if (abortController) { 673 + console.log('Aborting request...'); 674 + abortController.abort(); 675 + abortController = null; 676 + removeWaitingAnimation(); 677 + changeBtnState(true); 678 + } 679 +} 680 + 681 +// Display user message in the chat history 682 +function displayUserMessage(message) { 683 + const messageElement = document.createElement('div'); 684 + messageElement.classList.add('user-message'); 685 + 686 + const userLabel = document.createElement('div'); 687 + userLabel.classList.add('msg-info'); 688 + userLabel.textContent = 'User:'; 689 + 690 + const userMessageContent = document.createElement('div'); 691 + userMessageContent.classList.add('message-content'); 692 + userMessageContent.textContent = message; 693 + 694 + messageElement.appendChild(userLabel); 695 + messageElement.appendChild(userMessageContent); 696 + 697 + chatHistory.appendChild(messageElement); 698 + chatHistory.scrollTop = chatHistory.scrollHeight; 699 +} 700 + 701 +// Display assistant message in the chat history 702 +function displayAssistantMessage(message, modelName = '', responseTime = null) { 703 + const messageElement = document.createElement('div'); 704 + messageElement.classList.add('assistant'); 705 + 706 + const assistantLabel = document.createElement('div'); 707 + assistantLabel.classList.add('msg-info'); 708 + 709 + if (modelName && responseTime !== null) { 710 + assistantLabel.innerHTML = `<strong>Assistant (${modelName})</strong><em> - ΔT ${responseTime.toFixed(1)}s:</em>`; 711 + } else { 712 + assistantLabel.innerHTML = '<strong>Assistant:</strong>'; 713 + } 714 + 715 + const assistantMessageContent = document.createElement('div'); 716 + assistantMessageContent.classList.add('message-text'); 717 + assistantMessageContent.innerHTML = DOMPurify.sanitize(marked.parse(message), { FORBID_TAGS: ['style'], FORBID_ATTR: ['src'] }); 718 + 719 + messageElement.appendChild(assistantLabel); 720 + messageElement.appendChild(assistantMessageContent); 721 + 722 + chatHistory.appendChild(messageElement); 723 + chatHistory.scrollTop = chatHistory.scrollHeight; 724 + 725 + return assistantMessageContent; 726 +} 727 + 728 + 729 + 730 +// Update the response time in the assistant label 731 +function updateResponseTime(startTime, assistantInfoElement) { 732 + const currentTime = new Date().getTime(); 733 + const elapsedTime = (currentTime - startTime) / 1000; 734 + assistantInfoElement.innerHTML = `<strong>Assistant (${modelSelect.options[modelSelect.selectedIndex].text})</strong> - ΔT ${elapsedTime.toFixed(1)}s:`; 735 +} 736 + 737 +// Display error message in the chat history 738 +function displayErrorMessage(message) { 739 + const messageElement = document.createElement('div'); 740 + messageElement.classList.add('error-message'); 741 + messageElement.textContent = message; 742 + 743 + chatHistory.appendChild(messageElement); 744 + chatHistory.scrollTop = chatHistory.scrollHeight; 745 +} 746 + 747 +// Update user settings 748 +function updateUserSettings() { 749 + userSettings.model = modelSelect.value; 750 + userSettings.temperature = parseFloat(temperatureInput.value); 751 + userSettings.stream = streamCheckbox.checked; 752 + saveUserSettings(); 753 +} 754 + 755 +// Save user settings to local storage 756 +function saveUserSettings() { 757 + localStorage.setItem('userSettings', JSON.stringify(userSettings)); 758 +} 759 + 760 +// Load user settings from local storage 761 +function loadUserSettings() { 762 + const storedSettings = localStorage.getItem('userSettings'); 763 + if (storedSettings) { 764 + userSettings = JSON.parse(storedSettings); 765 + modelSelect.value = userSettings.model; 766 + temperatureInput.value = userSettings.temperature; 767 + streamCheckbox.checked = userSettings.stream; 768 + 769 + // Apply the collapsed state to the settings section 770 + const settingsWrapper = document.querySelector('.settings-wrapper'); 771 + if (userSettings.settingsCollapsed) { 772 + settingsWrapper.classList.add('collapsed'); 773 + } else { 774 + settingsWrapper.classList.remove('collapsed'); 775 + } 776 + } 777 +} 778 + 779 + 780 +// Save conversation history to local storage 781 +function saveConversationHistory() { 782 + if (conversationHistory.length === 0) { 783 + localStorage.removeItem('conversationHistory'); 784 + } else { 785 + localStorage.setItem('conversationHistory', JSON.stringify(conversationHistory)); 786 + } 787 +} 788 + 789 +// Load last conversation from local storage 790 +function loadLastConversation() { 791 + const storedConversation = localStorage.getItem('conversationHistory'); 792 + if (storedConversation) { 793 + conversationHistory = JSON.parse(storedConversation); 794 + conversationHistory.forEach(message => { 795 + if (message.role === 'user') { 796 + displayUserMessage(message.content); 797 + } else if (message.role === 'assistant') { 798 + displayAssistantMessage(message.content); 799 + } 800 + }); 801 +} 802 +} 803 + 804 +// Toggle settings visibility 805 +function toggleSettings() { 806 + const settingsWrapper = document.querySelector('.settings-wrapper'); 807 + settingsWrapper.classList.toggle('collapsed'); 808 + userSettings.settingsCollapsed = settingsWrapper.classList.contains('collapsed'); 809 + saveUserSettings(); 810 +} 811 + 812 + 813 +// Call the initialization function when the DOM content is loaded 814 +document.addEventListener('DOMContentLoaded', initializeChatWidget); 815 + 816 +{{/html}}