OpenWebUI VCP Tool Call Display Enhancer

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

// ==UserScript==
// @name         OpenWebUI VCP Tool Call Display Enhancer
// @version      1.0.3
// @description  Provides a graphical interface in OpenWebUI for the formatted toolcall output from VCPToolBox, developed for OpenWebUI v0.6.11. 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';

    function GM_addStyle(cssRules) {
        const head = document.head || document.getElementsByTagName('head')[0];
        if (head) {
            const styleElement = document.createElement('style');
            styleElement.type = 'text/css';
            if (styleElement.styleSheet) {
                styleElement.styleSheet.cssText = cssRules;
            } else {
                styleElement.appendChild(document.createTextNode(cssRules));
            }
            head.appendChild(styleElement);
        } else {
            console.error("Custom GM_addStyle: Could not find <head> element to inject CSS.");
        }
    }

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

    const PLACEHOLDER_CLASS = "tool-request-placeholder-custom-style";
    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;
                border-radius: 6px;
                padding: 6px 10px;
                margin: 8px 0; /* Will apply if placeholder is block/flex, careful if p has its own margin */
                background-color: #e6e6e6;
                font-family: sans-serif;
                font-size: 0.9em;
                color: #1a1a1a;
                line-height: 1.4;
                width: 400px; /* Or consider width: 100% or fitting to parent p's width */
                box-sizing: border-box;
            }
            .${PLACEHOLDER_CLASS} .trp-icon {
                margin-right: 8px;
                font-size: 1.1em;
                color: #1a1a1a;
                flex-shrink: 0;
            }
            .${PLACEHOLDER_CLASS} .trp-info {
                flex-grow: 1;
                margin-right: 8px;
                white-space: nowrap;
                overflow: hidden;
                text-overflow: ellipsis;
                color: #1a1a1a;
            }
            .${PLACEHOLDER_CLASS} .trp-info .trp-name {
                font-weight: 600;
                color: #1a1a1a;
            }
            .${PLACEHOLDER_CLASS} .trp-copy-btn {
                display: flex;
                align-items: center;
                background-color: #d7d7d7;
                color: #1a1a1a;
                border: 1px solid #b0b0b0;
                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;
            }
            .${PLACEHOLDER_CLASS} .trp-copy-btn:disabled {
                background-color: #c0e0c0;
                color: #336033;
                cursor: default;
                opacity: 0.9;
                border-color: #a0c0a0;
            }
            .${PLACEHOLDER_CLASS} .trp-copy-btn svg {
                margin-right: 4px;
                stroke-width: 2.5;
                stroke: #1a1a1a;
            }
            .${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) { // pElement is passed for context but not directly used if state.placeholderNode exists
        if (!state.placeholderNode) {
            // This case should ideally not be hit if placeholderNode is created in processParagraph
            // However, keeping it as a safeguard or for potential future refactoring
            state.placeholderNode = document.createElement('div');
            state.placeholderNode.className = PLACEHOLDER_CLASS;
             // If placeholder is created here, it needs to be inserted into the DOM appropriately
        }

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

        let displayName = "Loading...";
        if (state.toolName) {
            displayName = state.toolName;
        } else if (state.isComplete) {
            displayName = "Tool Call";
        }

        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(() => {
                            if (copyButton.isConnected) { // Check if button is still in DOM
                                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(() => {
                            if (copyButton.isConnected) {
                                originalButtonSpan.textContent = originalText;
                            }
                        }, 2000);
                    }
                } else {
                    const originalButtonSpan = copyButton.querySelector('span');
                    const originalText = originalButtonSpan.textContent;
                    originalButtonSpan.textContent = 'Empty!';
                    setTimeout(() => {
                        if (copyButton.isConnected) {
                            originalButtonSpan.textContent = originalText;
                        }
                    }, 2000);
                }
            };
        }
        // No return needed as state.placeholderNode is modified directly
    }

    // --- MODIFIED processParagraph Function (New Approach) ---
    function processParagraph(pElement) {
        // Initial checks for target element (depth, tag, attributes)
        if (getElementDepth(pElement) !== TARGET_P_DEPTH || pElement.tagName !== 'P' || !pElement.matches('p[dir="auto"]')) {
            return;
        }

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

        // Case 1: New paragraph containing START_MARKER, not yet processed
        if (!state && currentFullText.includes(START_MARKER)) {
            console.log(`${SCRIPT_NAME}: Processing new paragraph with START_MARKER`, pElement);
            state = {
                isActive: true,
                isComplete: false,
                placeholderNode: document.createElement('div'),
                hiddenWrapperNode: document.createElement('span'),
                hiddenContentBuffer: "",
                toolName: null
            };
            state.placeholderNode.className = PLACEHOLDER_CLASS;
            state.hiddenWrapperNode.className = HIDDEN_TEXT_WRAPPER_CLASS;
            pElementStates.set(pElement, state);

            // DOM Manipulation: Prepend placeholder and hidden wrapper, then move original content
            try {
                pElement.prepend(state.hiddenWrapperNode);   // Hidden wrapper first, then placeholder before it
                pElement.prepend(state.placeholderNode);     // Placeholder will be the first child visually

                // Move all *other* original children of pElement into the hiddenWrapperNode
                const nodesToMove = [];
                Array.from(pElement.childNodes).forEach(child => {
                    if (child !== state.placeholderNode && child !== state.hiddenWrapperNode) {
                        nodesToMove.push(child);
                    }
                });
                nodesToMove.forEach(node => {
                    state.hiddenWrapperNode.appendChild(node);
                });
            } catch (e) {
                console.error(`${SCRIPT_NAME}: Error during initial DOM manipulation in processParagraph:`, e, pElement);
                pElementStates.delete(pElement); // Clean up state if setup failed
                // Optionally, restore pElement to its original state if possible (complex)
                return;
            }

            // Update buffer from the now populated hiddenWrapperNode and update placeholder
            state.hiddenContentBuffer = state.hiddenWrapperNode.textContent || "";
            createOrUpdatePlaceholder(pElement, state); // Pass pElement for context if needed by CoUP

            if (state.hiddenContentBuffer.includes(END_MARKER)) {
                state.isComplete = true;
                createOrUpdatePlaceholder(pElement, state); // Update for completeness
            }
            return; // Initial processing done for this pElement
        }

        // Case 2: Paragraph already being processed, check for updates
        if (state && state.isActive && !state.isComplete) {
            // Content is now inside hiddenWrapperNode, so we get text from there
            const newRawHiddenText = state.hiddenWrapperNode.textContent || "";

            if (newRawHiddenText !== state.hiddenContentBuffer) {
                // console.log(`${SCRIPT_NAME}: Content update in hidden wrapper for`, pElement);
                state.hiddenContentBuffer = newRawHiddenText;
                createOrUpdatePlaceholder(pElement, state);

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

    const observer = new MutationObserver(mutationsList => {
        for (const mutation of mutationsList) {
            const processTarget = (target) => {
                if (!target) return;

                // If target itself is a P element matching criteria
                if (target.nodeType === Node.ELEMENT_NODE && target.tagName === 'P' &&
                    getElementDepth(target) === TARGET_P_DEPTH && target.matches('p[dir="auto"]')) {
                    processParagraph(target);
                }
                // If target is an element, also check its P descendants that match depth (but not dir="auto" here, as that's specific to the target paragraph)
                // The querySelectorAll below is more robust for finding relevant children.
                else if (target.nodeType === Node.ELEMENT_NODE && typeof target.querySelectorAll === 'function') {
                    target.querySelectorAll('p[dir="auto"]').forEach(pNode => {
                        if (getElementDepth(pNode) === TARGET_P_DEPTH) {
                            processParagraph(pNode);
                        }
                    });
                }
            };

            if (mutation.type === 'childList') {
                mutation.addedNodes.forEach(addedNode => {
                    processTarget(addedNode); // Process the added node itself
                    // If the added node is an element, also check its children
                    if (addedNode.nodeType === Node.ELEMENT_NODE && typeof addedNode.querySelectorAll === 'function') {
                        addedNode.querySelectorAll('p[dir="auto"]').forEach(pNode => {
                             if (getElementDepth(pNode) === TARGET_P_DEPTH) processParagraph(pNode);
                        });
                    }
                });
                // Also re-process the mutation target if it's relevant (e.g. children reordered, some removed)
                // This can be redundant if addedNodes covers it, but sometimes useful.
                 processTarget(mutation.target);

            } else if (mutation.type === 'characterData') {
                // Target of characterData mutation is the Text node itself.
                // We need to process its parent P element.
                if (mutation.target && mutation.target.parentNode) {
                    processTarget(mutation.target.parentNode);
                }
            }
        }
    });

    function activateScript() {
        console.log(`${SCRIPT_NAME} v${SCRIPT_VERSION} activating...`);
        injectStyles();

        // Initial scan for already present elements
        document.querySelectorAll('p[dir="auto"]').forEach(pElement => {
            if (getElementDepth(pElement) === TARGET_P_DEPTH) {
                processParagraph(pElement);
            }
        });

        // IMPORTANT: Consider observing a more specific container if possible, instead of document.body
        // For example: const chatArea = document.querySelector('#your-chat-area-id');
        // if (chatArea) { observer.observe(chatArea, { childList: true, subtree: true, characterData: true }); }
        // else { observer.observe(document.body, { childList: true, subtree: true, characterData: true }); }
        observer.observe(document.body, { childList: true, subtree: true, characterData: true });
        console.log(`${SCRIPT_NAME} v${SCRIPT_VERSION} activated and observing.`);
    }

    // --- New Script Activation Logic ---
    function waitForPageReady(callback) {
        // !!! IMPORTANT: Replace '#chat-messages-container-id' with the actual selector
        // for a stable parent element in OpenWebUI that contains the chat messages.
        // This element should exist when the chat interface is ready.
        const chatContainerSelector = 'body'; // Fallback to body, but a specific selector is MUCH better.
                                                // Example: 'main', '.chat-area', 'div[role="log"]' etc.
                                                // Please inspect OpenWebUI's DOM to find a suitable one.

        let attempts = 0;
        const maxAttempts = 60; // Approx 6 seconds (60 * 100ms)

        function check() {
            const chatContainer = document.querySelector(chatContainerSelector);
            // Check for readyState and the presence of the specific container
            if (document.readyState === 'complete' && chatContainer) {
                console.log(`${SCRIPT_NAME}: Page is complete and chat container ('${chatContainerSelector}') found. Activating script.`);
                callback();
            } else if (attempts < maxAttempts) {
                attempts++;
                setTimeout(check, 100);
            } else {
                console.warn(`${SCRIPT_NAME}: Page ready check timed out or chat container ('${chatContainerSelector}') not found. Attempting to activate anyway.`);
                // Fallback to activating anyway, or you could choose not to.
                callback();
            }
        }
        // Initial check, in case already ready
        if (document.readyState === 'complete') {
            check(); // Perform the container check directly if document is already complete
        } else {
            window.addEventListener('load', check, { once: true }); // Prefer 'load' over 'DOMContentLoaded' for more "idle" state
        }
    }

    waitForPageReady(activateScript);

})();