OpenWebUI VCP Tool Call Display Enhancer

Provides a graphical interface in OpenWebUI for the formatted toolcall output from VCPToolBox. VCPToolBox project repository: https://github.com/lioensky/VCPToolBox

Versión del día 26/5/2025. Echa un vistazo a la versión más reciente.

// ==UserScript==
// @name         OpenWebUI VCP Tool Call Display Enhancer
// @version      1.0.2
// @description  Provides a graphical interface in OpenWebUI for the formatted toolcall output from VCPToolBox. VCPToolBox project repository: https://github.com/lioensky/VCPToolBox
// @author       B3000Kcn
// @match        https://your.openwebui.url/*
// @run-at       document-idle
// @grant        GM_addStyle
// @license      MIT
// @namespace https://greasyfork.org/users/1474401
// ==/UserScript==

(function() {
    'use strict';

    const SCRIPT_NAME = 'OpenWebUI VCP Tool Call Display Enhancer';
    const SCRIPT_VERSION = '1.0.2';
    const TARGET_P_DEPTH = 23;
    const START_MARKER = "<<<[TOOL_REQUEST]>>>";
    const END_MARKER = "<<<[END_TOOL_REQUEST]>>>";

    const PLACEHOLDER_CLASS = "tool-request-placeholder-custom-style"; // New class name for new styles
    const HIDDEN_TEXT_WRAPPER_CLASS = "tool-request-hidden-text-wrapper";

    const pElementStates = new WeakMap();

    function getElementDepth(element) {
        let depth = 0; let el = element;
        while (el) { depth++; el = el.parentElement; }
        return depth;
    }

    function injectStyles() {
        GM_addStyle(`
            .${PLACEHOLDER_CLASS} {
                display: flex;
                align-items: center;
                justify-content: space-between;
                border: 1px solid #c5c5c5; /* Slightly darker border for new bg */
                border-radius: 6px;
                padding: 6px 10px;
                margin: 8px 0;
                background-color: #e6e6e6; /* User requested background */
                font-family: sans-serif;
                font-size: 0.9em;
                color: #1a1a1a; /* User requested text color */
                line-height: 1.4;
                width: 400px;
                box-sizing: border-box;
            }
            .${PLACEHOLDER_CLASS} .trp-icon {
                margin-right: 8px;
                font-size: 1.1em;
                color: #1a1a1a; /* Inherit or match main text color */
                flex-shrink: 0;
            }
            .${PLACEHOLDER_CLASS} .trp-info {
                flex-grow: 1;
                margin-right: 8px;
                white-space: nowrap;
                overflow: hidden;
                text-overflow: ellipsis;
                color: #1a1a1a; /* Ensure info text also uses this color */
            }
            .${PLACEHOLDER_CLASS} .trp-info .trp-name {
                font-weight: 600;
                color: #1a1a1a; /* Name also uses the main text color */
            }
            .${PLACEHOLDER_CLASS} .trp-copy-btn {
                display: flex;
                align-items: center;
                background-color: #d7d7d7; /* Adjusted for new placeholder bg */
                color: #1a1a1a; /* Button text color */
                border: 1px solid #b0b0b0; /* Adjusted border */
                border-radius: 4px;
                padding: 4px 8px;
                font-size: 0.9em;
                cursor: pointer;
                margin-left: auto;
                flex-shrink: 0;
                transition: background-color 0.2s;
            }
            .${PLACEHOLDER_CLASS} .trp-copy-btn:hover {
                background-color: #c8c8c8; /* Slightly darker hover */
            }
            .${PLACEHOLDER_CLASS} .trp-copy-btn:disabled {
                background-color: #c0e0c0;
                color: #336033; /* Darker green text for better contrast */
                cursor: default;
                opacity: 0.9;
                border-color: #a0c0a0;
            }
            .${PLACEHOLDER_CLASS} .trp-copy-btn svg {
                margin-right: 4px;
                stroke-width: 2.5;
                stroke: #1a1a1a; /* Icon stroke color */
            }
            .${HIDDEN_TEXT_WRAPPER_CLASS} {
                display: none !important;
            }
        `);
    }

    function parseToolName(rawText) {
        const toolNameMatch = rawText.match(/tool_name:\s*「始」(.*?)「末」/);
        return (toolNameMatch && toolNameMatch[1]) ? toolNameMatch[1].trim() : null;
    }

    function createOrUpdatePlaceholder(pElement, state) {
        if (!state.placeholderNode) {
            state.placeholderNode = document.createElement('div');
            state.placeholderNode.className = PLACEHOLDER_CLASS;
        }

        const parsedToolName = parseToolName(state.hiddenContentBuffer || "");
        if (parsedToolName) {
            state.toolName = parsedToolName;
        }

        let displayName = "Loading..."; // Default if no name yet
        if (state.toolName) {
            displayName = state.toolName;
        } else if (state.isComplete) { // If complete but name was never found
             displayName = "Tool Call"; // Generic fallback
        }


        const copyIconSvg = `
            <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round">
                <rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect>
                <path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path>
            </svg>`;

        state.placeholderNode.innerHTML = `
            <span class="trp-icon">⚙️</span>
            <span class="trp-info">
                VCP Tool Call: <strong class="trp-name">${displayName}</strong>
            </span>
            <button type="button" class="trp-copy-btn" title="Copy raw tool request content">
                ${copyIconSvg}
                <span>Copy</span>
            </button>
        `;

        const copyButton = state.placeholderNode.querySelector('.trp-copy-btn');
        if (copyButton) {
            copyButton.onclick = async (event) => {
                event.stopPropagation();
                let contentToCopy = state.hiddenContentBuffer || "";
                if (contentToCopy.includes(START_MARKER) && contentToCopy.includes(END_MARKER)) {
                    const startIndex = contentToCopy.indexOf(START_MARKER) + START_MARKER.length;
                    const endIndex = contentToCopy.lastIndexOf(END_MARKER);
                    if (endIndex > startIndex) {
                        contentToCopy = contentToCopy.substring(startIndex, endIndex).trim();
                    } else {
                        contentToCopy = contentToCopy.replace(START_MARKER, "").replace(END_MARKER, "").trim();
                    }
                } else {
                     contentToCopy = contentToCopy.replace(START_MARKER, "").replace(END_MARKER, "").trim();
                }

                if (contentToCopy) {
                    try {
                        await navigator.clipboard.writeText(contentToCopy);
                        const originalButtonSpan = copyButton.querySelector('span');
                        const originalText = originalButtonSpan.textContent;
                        originalButtonSpan.textContent = 'Copied!';
                        copyButton.disabled = true;
                        setTimeout(() => {
                            originalButtonSpan.textContent = originalText;
                            copyButton.disabled = false;
                        }, 2000);
                    } catch (err) {
                        console.error(`${SCRIPT_NAME}: Failed to copy: `, err);
                        const originalButtonSpan = copyButton.querySelector('span');
                        const originalText = originalButtonSpan.textContent;
                        originalButtonSpan.textContent = 'Error!';
                         setTimeout(() => {
                            originalButtonSpan.textContent = originalText;
                        }, 2000);
                    }
                } else {
                    // console.warn(`${SCRIPT_NAME}: No content to copy.`);
                    const originalButtonSpan = copyButton.querySelector('span');
                    const originalText = originalButtonSpan.textContent;
                    originalButtonSpan.textContent = 'Empty!';
                     setTimeout(() => {
                        originalButtonSpan.textContent = originalText;
                    }, 2000);
                }
            };
        }
        return state.placeholderNode;
    }

    function processParagraph(pElement) {
        if (getElementDepth(pElement) !== TARGET_P_DEPTH || pElement.tagName !== 'P') {
            return;
        }

        let state = pElementStates.get(pElement);
        const currentFullText = pElement.textContent || "";

        if (!state && currentFullText.includes(START_MARKER)) {
            state = {
                isActive: true, isComplete: false, placeholderNode: null,
                hiddenWrapperNode: null, hiddenContentBuffer: "", toolName: null
            };
            pElementStates.set(pElement, state);

            state.hiddenWrapperNode = document.createElement('span');
            state.hiddenWrapperNode.className = HIDDEN_TEXT_WRAPPER_CLASS;
            createOrUpdatePlaceholder(pElement, state);

            let foundStartMarkerNode = false;
            const childNodes = Array.from(pElement.childNodes);
            let insertionPointForPlaceholder = null;
            let nodesToMoveToHiddenWrapperInitially = [];

            for (let i = 0; i < childNodes.length; i++) {
                const node = childNodes[i];
                if (!foundStartMarkerNode && node.nodeType === Node.TEXT_NODE && node.nodeValue.includes(START_MARKER)) {
                    const text = node.nodeValue;
                    const markerIndex = text.indexOf(START_MARKER);
                    const beforeText = text.substring(0, markerIndex);
                    const afterTextAndMarker = text.substring(markerIndex);

                    if (beforeText.trim() !== "") {
                        node.nodeValue = beforeText;
                        insertionPointForPlaceholder = node.nextSibling;
                    } else {
                        insertionPointForPlaceholder = node.nextSibling;
                        nodesToMoveToHiddenWrapperInitially.push(node); // Mark for moving instead of direct removal
                    }
                    // The content from marker onwards will be part of the hidden buffer
                    if (afterTextAndMarker && (beforeText.trim() === "" || node.nodeValue !== afterTextAndMarker)) {
                         // If original node was kept for beforeText, add new text node for afterTextAndMarker
                         // If original node is to be moved, its content is already afterTextAndMarker
                         if (beforeText.trim() !== "") {
                             const afterNode = document.createTextNode(afterTextAndMarker);
                             nodesToMoveToHiddenWrapperInitially.push(afterNode);
                             // This insertion point will be tricky. Let's simplify by moving all affected nodes.
                         }
                    }
                    foundStartMarkerNode = true;
                    // Don't break, continue collecting subsequent nodes from this initial pass
                } else if (foundStartMarkerNode) {
                    nodesToMoveToHiddenWrapperInitially.push(node);
                }
            }

            if (foundStartMarkerNode) {
                 pElement.insertBefore(state.placeholderNode, insertionPointForPlaceholder);
                 pElement.insertBefore(state.hiddenWrapperNode, state.placeholderNode.nextSibling);
                 nodesToMoveToHiddenWrapperInitially.forEach(n => {
                     // If node was the one split and kept, we need its 'afterTextAndMarker' part
                     if (n.nodeValue && n.nodeValue.includes(START_MARKER) && n.parentNode === pElement) {
                         const text = n.nodeValue;
                         const markerIndex = text.indexOf(START_MARKER);
                         state.hiddenWrapperNode.appendChild(document.createTextNode(text.substring(markerIndex)));
                         n.nodeValue = text.substring(0, markerIndex); // Truncate original node
                         if(n.nodeValue.trim() === "") pElement.removeChild(n); // Remove if it became empty
                     } else if (n.parentNode === pElement) { // Only move if it's still a direct child
                        state.hiddenWrapperNode.appendChild(n);
                     } else { // It was already moved (e.g. the split node)
                        // Ensure its full intended content is in hiddenWrapper
                        // This part is complex due to live DOM changes. The observer should catch subsequent appends better.
                     }
                 });
            } else if (pElement.firstChild && currentFullText.includes(START_MARKER)) { // Fallback
                pElement.insertBefore(state.placeholderNode, pElement.firstChild);
                pElement.insertBefore(state.hiddenWrapperNode, state.placeholderNode.nextSibling);
                // Move all original children into hidden wrapper
                Array.from(pElement.childNodes).forEach(child => {
                    if (child !== state.placeholderNode && child !== state.hiddenWrapperNode) {
                        state.hiddenWrapperNode.appendChild(child);
                    }
                });
            } else if (currentFullText.includes(START_MARKER)) {
                pElement.appendChild(state.placeholderNode);
                pElement.appendChild(state.hiddenWrapperNode);
            } else {
                pElementStates.delete(pElement);
                return;
            }
        }

        if (state && state.isActive && !state.isComplete) {
            let newContentAddedToWrapper = false;
            let nodesNotYetInWrapper = [];

            // Collect all direct children of pElement that are NOT the placeholder or the hiddenWrapper itself.
            // These are assumed to be newly streamed content by OpenWebUI.
            Array.from(pElement.childNodes).forEach(node => {
                if (node !== state.placeholderNode && node !== state.hiddenWrapperNode) {
                    nodesNotYetInWrapper.push(node);
                }
            });

            if (nodesNotYetInWrapper.length > 0) {
                nodesNotYetInWrapper.forEach(nodeToMove => {
                    state.hiddenWrapperNode.appendChild(nodeToMove);
                    newContentAddedToWrapper = true;
                });
            }

            const currentRawHiddenText = state.hiddenWrapperNode.textContent || "";
            if (newContentAddedToWrapper || currentRawHiddenText !== state.hiddenContentBuffer) {
                 state.hiddenContentBuffer = currentRawHiddenText;
                 createOrUpdatePlaceholder(pElement, state);
            }

            if (state.hiddenContentBuffer.includes(END_MARKER)) {
                state.isComplete = true;
                createOrUpdatePlaceholder(pElement, state);
            }
        }
    }

    const observer = new MutationObserver(mutationsList => {
        for (const mutation of mutationsList) {
            const processTarget = (target) => {
                if (target && target.nodeType === Node.ELEMENT_NODE && target.tagName === 'P' &&
                    getElementDepth(target) === TARGET_P_DEPTH && target.matches('p[dir="auto"]')) {
                    processParagraph(target);
                } else if (target && target.nodeType === Node.ELEMENT_NODE) {
                    target.querySelectorAll('p[dir="auto"]').forEach(pNode => {
                         if (getElementDepth(pNode) === TARGET_P_DEPTH) {
                            processParagraph(pNode);
                        }
                    });
                }
            };

            if (mutation.type === 'childList') {
                mutation.addedNodes.forEach(addedNode => {
                    if (addedNode.nodeType === Node.ELEMENT_NODE) {
                       processTarget(addedNode);
                       if (addedNode.querySelectorAll) {
                           addedNode.querySelectorAll('p[dir="auto"]').forEach(pNode => {
                               if(getElementDepth(pNode) === TARGET_P_DEPTH) processParagraph(pNode);
                           });
                       }
                    } else if (addedNode.nodeType === Node.TEXT_NODE && addedNode.parentNode) {
                        processTarget(addedNode.parentNode);
                    }
                });
                if (mutation.target && mutation.target.nodeType === Node.ELEMENT_NODE) {
                     processTarget(mutation.target);
                }
            } else if (mutation.type === 'characterData') {
                 if (mutation.target && mutation.target.parentNode) {
                    processTarget(mutation.target.parentNode);
                }
            }
        }
    });

    function activateScript() {
        console.log(`${SCRIPT_NAME} v${SCRIPT_VERSION} activating...`);
        injectStyles();
        document.querySelectorAll(`p[dir="auto"]`).forEach(pElement => {
            if (getElementDepth(pElement) === TARGET_P_DEPTH) {
                processParagraph(pElement);
            }
        });
        observer.observe(document.body, { childList: true, subtree: true, characterData: true });
        console.log(`${SCRIPT_NAME} v${SCRIPT_VERSION} activated and observing.`);
    }

    if (document.readyState === 'complete' || document.readyState === 'interactive') {
        activateScript();
    } else {
        document.addEventListener('DOMContentLoaded', activateScript, { once: true });
    }

})();