text-generation-webui/js/global_scope_js.js
oobabooga a4bef860b6 UI: Optimize chat streaming by batching morphdom to one update per animation frame
The monitor physically cannot paint faster than its refresh rate, so
intermediate morphdom calls between frames do redundant parsing, diffing,
and patching work that is never displayed.
2026-03-13 06:45:47 -03:00

393 lines
13 KiB
JavaScript

// -------------------------------------------------
// Event handlers
// -------------------------------------------------
function copyToClipboard(element) {
if (!element) return;
const messageElement = element.closest(".message, .user-message, .assistant-message");
if (!messageElement) return;
const rawText = messageElement.getAttribute("data-raw");
if (!rawText) return;
const copyPromise = navigator.clipboard && window.isSecureContext
? navigator.clipboard.writeText(rawText)
: fallbackCopyToClipboard(rawText);
copyPromise.then(function() {
const originalSvg = element.innerHTML;
element.innerHTML = "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"20\" height=\"20\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" class=\"text-green-500 dark:text-green-400\"><path d=\"M5 12l5 5l10 -10\"></path></svg>";
setTimeout(() => {
element.innerHTML = originalSvg;
}, 1000);
}).catch(function(err) {
console.error("Failed to copy text: ", err);
});
}
function fallbackCopyToClipboard(text) {
return new Promise((resolve, reject) => {
const textArea = document.createElement("textarea");
textArea.value = text;
textArea.style.position = "fixed";
textArea.style.left = "-9999px";
textArea.style.top = "-9999px";
document.body.appendChild(textArea);
textArea.focus();
textArea.select();
try {
const successful = document.execCommand("copy");
document.body.removeChild(textArea);
successful ? resolve() : reject();
} catch (err) {
document.body.removeChild(textArea);
reject(err);
}
});
}
function branchHere(element) {
if (!element) return;
const messageElement = element.closest(".message, .user-message, .assistant-message");
if (!messageElement) return;
const index = messageElement.getAttribute("data-index");
if (!index) return;
const branchIndexInput = document.getElementById("Branch-index").querySelector("input");
if (!branchIndexInput) {
console.error("Element with ID 'Branch-index' not found.");
return;
}
const branchButton = document.getElementById("Branch");
if (!branchButton) {
console.error("Required element 'Branch' not found.");
return;
}
branchIndexInput.value = index;
// Trigger any 'change' or 'input' events Gradio might be listening for
const event = new Event("input", { bubbles: true });
branchIndexInput.dispatchEvent(event);
branchButton.click();
}
// -------------------------------------------------
// Message Editing Functions
// -------------------------------------------------
function editHere(buttonElement) {
if (!buttonElement) return;
const messageElement = buttonElement.closest(".message, .user-message, .assistant-message");
if (!messageElement) return;
const messageBody = messageElement.querySelector(".message-body");
if (!messageBody) return;
// If already editing, focus the textarea
const existingTextarea = messageBody.querySelector(".editing-textarea");
if (existingTextarea) {
existingTextarea.focus();
return;
}
// Determine role based on message element - handle different chat modes
const isUserMessage = messageElement.classList.contains("user-message") ||
messageElement.querySelector(".text-you") !== null ||
messageElement.querySelector(".circle-you") !== null;
startEditing(messageElement, messageBody, isUserMessage);
}
function startEditing(messageElement, messageBody, isUserMessage) {
const rawText = messageElement.getAttribute("data-raw") || messageBody.textContent;
const originalHTML = messageBody.innerHTML;
// Create editing interface
const editingInterface = createEditingInterface(rawText);
// Replace message content
messageBody.innerHTML = "";
messageBody.appendChild(editingInterface.textarea);
messageBody.appendChild(editingInterface.controls);
editingInterface.textarea.focus();
editingInterface.textarea.setSelectionRange(rawText.length, rawText.length);
// Temporarily mark as scrolled to prevent auto-scroll
const wasScrolled = window.isScrolled;
window.isScrolled = true;
// Scroll the textarea into view
editingInterface.textarea.scrollIntoView({
behavior: "smooth",
block: "center"
});
// Restore the original scroll state after animation
setTimeout(() => {
window.isScrolled = wasScrolled;
}, 500);
// Setup event handlers
setupEditingHandlers(editingInterface.textarea, messageElement, originalHTML, messageBody, isUserMessage);
}
function createEditingInterface(text) {
const textarea = document.createElement("textarea");
textarea.value = text;
textarea.className = "editing-textarea";
textarea.rows = Math.max(3, text.split("\n").length);
const controls = document.createElement("div");
controls.className = "edit-controls-container";
const saveButton = document.createElement("button");
saveButton.textContent = "Save";
saveButton.className = "edit-control-button";
saveButton.type = "button";
const cancelButton = document.createElement("button");
cancelButton.textContent = "Cancel";
cancelButton.className = "edit-control-button edit-cancel-button";
cancelButton.type = "button";
controls.appendChild(saveButton);
controls.appendChild(cancelButton);
return { textarea, controls, saveButton, cancelButton };
}
function setupEditingHandlers(textarea, messageElement, originalHTML, messageBody, isUserMessage) {
const saveButton = messageBody.querySelector(".edit-control-button:not(.edit-cancel-button)");
const cancelButton = messageBody.querySelector(".edit-cancel-button");
const submitEdit = () => {
const index = messageElement.getAttribute("data-index");
if (!index || !submitMessageEdit(index, textarea.value, isUserMessage)) {
cancelEdit();
}
};
const cancelEdit = () => {
messageBody.innerHTML = originalHTML;
};
// Event handlers
saveButton.onclick = submitEdit;
cancelButton.onclick = cancelEdit;
textarea.onkeydown = (e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
submitEdit();
} else if (e.key === "Escape") {
e.preventDefault();
cancelEdit();
}
};
}
function submitMessageEdit(index, newText, isUserMessage) {
const editIndexInput = document.getElementById("Edit-message-index")?.querySelector("input");
const editTextInput = document.getElementById("Edit-message-text")?.querySelector("textarea");
const editRoleInput = document.getElementById("Edit-message-role")?.querySelector("textarea");
const editButton = document.getElementById("Edit-message");
if (!editIndexInput || !editTextInput || !editRoleInput || !editButton) {
console.error("Edit elements not found");
return false;
}
editIndexInput.value = index;
editTextInput.value = newText;
editRoleInput.value = isUserMessage ? "user" : "assistant";
editIndexInput.dispatchEvent(new Event("input", { bubbles: true }));
editTextInput.dispatchEvent(new Event("input", { bubbles: true }));
editRoleInput.dispatchEvent(new Event("input", { bubbles: true }));
editButton.click();
return true;
}
function navigateVersion(element, direction) {
if (!element) return;
const messageElement = element.closest(".message, .user-message, .assistant-message");
if (!messageElement) return;
const index = messageElement.getAttribute("data-index");
if (!index) return;
// Determine role based on message element classes
let role = "assistant"; // Default role
if (messageElement.classList.contains("user-message") ||
messageElement.querySelector(".text-you") ||
messageElement.querySelector(".circle-you")) {
role = "user";
}
const indexInput = document.getElementById("Navigate-message-index")?.querySelector("input");
const directionInput = document.getElementById("Navigate-direction")?.querySelector("textarea");
const roleInput = document.getElementById("Navigate-message-role")?.querySelector("textarea");
const navigateButton = document.getElementById("Navigate-version");
if (!indexInput || !directionInput || !roleInput || !navigateButton) {
console.error("Navigation control elements (index, direction, role, or button) not found.");
return;
}
indexInput.value = index;
directionInput.value = direction;
roleInput.value = role;
// Trigger 'input' events for Gradio to pick up changes
const event = new Event("input", { bubbles: true });
indexInput.dispatchEvent(event);
directionInput.dispatchEvent(event);
roleInput.dispatchEvent(event);
navigateButton.click();
}
function regenerateClick() {
document.getElementById("Regenerate").click();
}
function continueClick() {
document.getElementById("Continue").click();
}
function removeLastClick() {
document.getElementById("Remove-last").click();
}
let pendingMorphdomData = null;
let morphdomRafId = null;
function handleMorphdomUpdate(data) {
pendingMorphdomData = data;
if (!morphdomRafId) {
morphdomRafId = requestAnimationFrame(() => {
morphdomRafId = null;
applyMorphdomUpdate(pendingMorphdomData);
pendingMorphdomData = null;
});
}
}
function applyMorphdomUpdate(data) {
// Determine target element and use it as query scope
var target_element, target_html;
if (data.last_message_only) {
const childNodes = document.getElementsByClassName("messages")[0].childNodes;
target_element = childNodes[childNodes.length - 1];
target_html = data.html;
} else {
target_element = document.getElementById("chat").parentNode;
target_html = "<div class=\"prose svelte-1ybaih5\">" + data.html + "</div>";
}
const queryScope = target_element;
// Track open blocks
const openBlocks = new Set();
queryScope.querySelectorAll(".thinking-block").forEach(block => {
const blockId = block.getAttribute("data-block-id");
// If block exists and is open, add to open set
if (blockId && block.hasAttribute("open")) {
openBlocks.add(blockId);
}
});
// Store scroll positions for any open blocks
const scrollPositions = {};
queryScope.querySelectorAll(".thinking-block[open]").forEach(block => {
const content = block.querySelector(".thinking-content");
const blockId = block.getAttribute("data-block-id");
if (content && blockId) {
const isAtBottom = Math.abs((content.scrollHeight - content.scrollTop) - content.clientHeight) < 5;
scrollPositions[blockId] = {
position: content.scrollTop,
isAtBottom: isAtBottom
};
}
});
morphdom(
target_element,
target_html,
{
onBeforeElUpdated: function(fromEl, toEl) {
// Preserve code highlighting
if (fromEl.tagName === "PRE" && fromEl.querySelector("code[data-highlighted]")) {
const fromCode = fromEl.querySelector("code");
const toCode = toEl.querySelector("code");
if (fromCode && toCode && fromCode.textContent === toCode.textContent) {
toEl.className = fromEl.className;
toEl.innerHTML = fromEl.innerHTML;
return false;
}
}
// For thinking blocks, assume closed by default
if (fromEl.classList && fromEl.classList.contains("thinking-block") &&
toEl.classList && toEl.classList.contains("thinking-block")) {
const blockId = toEl.getAttribute("data-block-id");
// Remove open attribute by default
toEl.removeAttribute("open");
// If this block was explicitly opened by user, keep it open
if (blockId && openBlocks.has(blockId)) {
toEl.setAttribute("open", "");
}
}
return !fromEl.isEqualNode(toEl);
},
onElUpdated: function(el) {
// Restore scroll positions for open thinking blocks
if (el.classList && el.classList.contains("thinking-block") && el.hasAttribute("open")) {
const blockId = el.getAttribute("data-block-id");
const content = el.querySelector(".thinking-content");
if (content && blockId && scrollPositions[blockId]) {
setTimeout(() => {
if (scrollPositions[blockId].isAtBottom) {
content.scrollTop = content.scrollHeight;
} else {
content.scrollTop = scrollPositions[blockId].position;
}
}, 0);
}
}
}
}
);
// Add toggle listeners for new blocks
queryScope.querySelectorAll(".thinking-block").forEach(block => {
if (!block._hasToggleListener) {
block.addEventListener("toggle", function(e) {
if (this.open) {
const content = this.querySelector(".thinking-content");
if (content) {
setTimeout(() => {
content.scrollTop = content.scrollHeight;
}, 0);
}
}
});
block._hasToggleListener = true;
}
});
}