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 });
}
})();