您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
Provides a graphical interface in OpenWebUI for the formatted toolcall output from VCPToolBox. VCPToolBox project repository: https://github.com/lioensky/VCPToolBox
当前为
// ==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 }); } })();