add response versioning support in messages for regeneration improvements

This commit is contained in:
2026-09-22 14:48:13 +02:00
parent 7602692341
commit 72e429b1b3
15 changed files with 591 additions and 86 deletions
+52
View File
@@ -0,0 +1,52 @@
import {stream} from "./api.js";
import {updateReasoning, finishReasoning} from "./reasoning.js";
import {appendSources} from "./webSearch.js";
// Keep the original nodes (including action handlers) until a committed answer arrives.
export async function regenerateInline({card, scroller, endpoint, payload, renderContent, complete}) {
const original = [...card.childNodes];
const status = document.createElement("div");
status.className = "loading-shell";
status.setAttribute("role", "status");
status.textContent = "Generating...";
const body = document.createElement("div");
body.className = "msg-content streaming-content";
let committed = false;
const update = operation => {
const bottom = card.getBoundingClientRect().bottom;
const viewport = scroller.getBoundingClientRect();
const follow = bottom >= viewport.top && Math.abs(bottom - viewport.bottom) < 100;
operation();
if (follow) scroller.scrollTop += card.getBoundingClientRect().bottom - bottom;
};
// Anchor to this response, including when a long old answer collapses.
update(() => card.replaceChildren(original[0].cloneNode(true), status, body));
card.setAttribute("aria-busy", "true");
try {
await stream(endpoint, payload, {
onStatus: data => update(() => {
status.textContent = data.status === "searching" ? "Searching the web..."
: data.status === "analyzing" ? "Analyzing attachments..." : "Generating...";
if ("sources" in data) {
card.querySelector(".msg-sources")?.remove();
appendSources(card, data.sources);
}
}),
onThinking: ({fullThinking}) => update(() => updateReasoning(card, fullThinking, true)),
onChunk: ({fullReply}) => update(() => {
status.hidden = Boolean(fullReply);
if (fullReply) finishReasoning(card);
renderContent(body, fullReply);
}),
onError: ({error}) => { throw new Error(error || "Unable to regenerate message."); },
onDone: ({message}) => {
if (!message) throw new Error("Missing regenerated response.");
update(() => complete(message));
committed = true;
}
});
} finally {
if (!committed) update(() => card.replaceChildren(...original));
card.removeAttribute("aria-busy");
}
}
+75 -42
View File
@@ -1,4 +1,5 @@
import {del, get, post, put, stream} from "./app/api.js?v=20260909-account-controls";
import {regenerateInline} from "./app/regeneration.js";
import {appendSources, createWebSearchToggle} from "./app/webSearch.js";
import {appendAttachments, createAttachmentComposer} from "./app/attachments.js";
import {createReasoningToggle, finishReasoning, updateReasoning} from "./app/reasoning.js?v=20260921-reasoning-capability";
@@ -110,6 +111,7 @@ const webSearchToggle = createWebSearchToggle(webSearchButton);
const reasoningToggle = createReasoningToggle(document.getElementById("reasoning"));
let isProcessing = false, activeChatId = null, editingPersonaId = null, editingPersonaType = "assistant", currentUsername = "";
let regenerationChatId = null;
const attachmentComposer = createAttachmentComposer({onChange: updateSendState, onError: message => setNotice(message, "error")});
let chatSessions = [], currentMessages = [], assistantPersonas = [], userPersonas = [];
let activeUserPersonaId = null, currentSummary = null, publishedPersonaIds = new Set();
@@ -568,45 +570,70 @@ async function deleteChatMessage(messageId, fallbackIndex = -1) {
}
async function retryLatestAssistantMessage(messageId, fallbackIndex = -1) {
if (isProcessing) return;
const target = await resolveMessageTarget(messageId, fallbackIndex);
if (isProcessing) return;
if (!target) return setNotice("Message is not ready yet. Try again.", "error");
if (target.message.role !== "bot") return setNotice("Only assistant messages can be regenerated.", "error");
const retriesUsed = Math.max(0, Number(target.message.siblingCount || 1) - 1);
const retriesUsed = Math.max(0, Number(target.message.versionCount || 1) - 1);
if (retriesUsed >= 5) {
return setNotice("Retry limit reached (5).", "error");
}
setLoadingState(true, {
eyebrow: "Regenerating",
title: "Generating another version",
detail: "The assistant is rewriting the latest reply."
});
setNotice("Regenerating reply...");
const chatId = activeChatId;
const card = messagesDiv.querySelector(`[data-message-id="${target.message.id}"]`);
if (!card) return;
const payload = {model: modelSelect.value, webSearch: webSearchToggle.enabled, reasoning: reasoningToggle.enabled};
regenerationChatId = chatId;
setLoadingState(true, false);
try {
const endpoint = getMessageEndpoint(target, "/retry");
if (!endpoint) return setNotice("Message is not ready yet. Try again.", "error");
const style = await promptPopup({
eyebrow: "Retry style",
title: "Retry modifier",
description: "Leave empty for a normal retry, or use shorter, direct, emotional, stay-in-character, or dialogue.",
label: "Style",
value: "",
placeholder: "shorter",
confirmLabel: "Retry"
await regenerateInline({
card, scroller: messagesDiv, endpoint: `/chats/${chatId}/messages/${target.message.id}/retry/stream`, payload,
renderContent: setMessageContent,
complete: message => {
if (activeChatId !== chatId) return;
const index = getMessageIndexById(message.id);
if (index < 0) return;
currentMessages[index] = {...currentMessages[index], ...message, thinking: message.thinking, sources: message.sources};
syncRetryStateFromMessage(currentMessages[index]);
const rendered = addMessage(currentMessages[index], false, false, {messageIndex: index, detached: true});
card.replaceChildren(...rendered.childNodes);
}
});
const res = await post(endpoint, {model: modelSelect.value, style: String(style || "").trim()});
if (res.error || !res.message?.content) {
return setNotice(res.error || "Unable to retry message.", "error");
}
const updateIndex = target.message?.id ? getMessageIndexById(target.message.id) : target.index;
if (updateIndex >= 0) {
currentMessages[updateIndex] = {...currentMessages[updateIndex], ...res.message};
syncRetryStateFromMessage(currentMessages[updateIndex]);
}
await setActiveChat(activeChatId);
setNotice(`Reply regenerated (${retriesUsed + 1}/5).`, "success");
webSearchToggle.reset();
reasoningToggle.reset();
setNotice("Reply regenerated.", "success");
} catch (error) {
setNotice(error.message || "Unable to regenerate message.", "error");
} finally {
regenerationChatId = null;
setLoadingState(false);
renderMessages();
}
}
async function switchResponseVersion(messageId, direction, fallbackIndex = -1) {
const target = await resolveMessageTarget(messageId, fallbackIndex);
if (!target) return setNotice("Message is not ready yet. Try again.", "error");
if (Number(target.message.versionCount) > 1) {
const chatId = activeChatId;
const card = messagesDiv.querySelector(`[data-message-id="${messageId}"]`);
if (!card || card.getAttribute("aria-busy") === "true") return;
const buttons = [...card.querySelectorAll("button")].map(button => [button, button.disabled]);
card.setAttribute("aria-busy", "true");
buttons.forEach(([button]) => { button.disabled = true; });
try {
const res = await post(`/chats/${chatId}/messages/${messageId}/versions/${target.message.versionIndex + direction}`, {});
if (res.error) return setNotice(res.error, "error");
if (activeChatId !== chatId) return;
const index = getMessageIndexById(messageId);
if (index < 0) return;
currentMessages[index] = {...currentMessages[index], ...res.message, thinking: res.message.thinking, sources: res.message.sources};
syncRetryStateFromMessage(currentMessages[index]);
const rendered = addMessage(currentMessages[index], false, false, {messageIndex: index, detached: true});
card.replaceChildren(...rendered.childNodes);
} finally {
card.removeAttribute("aria-busy");
buttons.forEach(([button, disabled]) => { button.disabled = disabled; });
}
}
}
@@ -664,6 +691,7 @@ function addMessage(contentOrMessage, isUser = false, isLoading = false, options
? contentOrMessage
: {id: null, role: isUser ? "user" : "bot", content: String(contentOrMessage || "")};
const isMessageUser = message.role === "user";
msgDiv.dataset.messageId = message.id;
msgDiv.className = `msg ${isMessageUser ? "user" : "bot"}`;
msgDiv.setAttribute("aria-label", isMessageUser ? "Your message" : "Assistant message");
const header = document.createElement("div"), body = document.createElement("div"), actions = document.createElement("div");
@@ -702,7 +730,7 @@ function addMessage(contentOrMessage, isUser = false, isLoading = false, options
actions.appendChild(branchBtn);
if (message.role === "bot") {
const retriesUsed = Math.max(0, Number(message.siblingCount || 1) - 1);
const retriesUsed = Math.max(0, Number(message.versionCount || 1) - 1);
const retryBtn = document.createElement("button");
retryBtn.type = "button";
retryBtn.className = "msg-action-btn";
@@ -717,30 +745,32 @@ function addMessage(contentOrMessage, isUser = false, isLoading = false, options
actions.appendChild(retryBtn);
}
if (Number(message.siblingCount || 1) > 1) {
for (const versioned of [true, false]) {
if (Number((versioned ? message.versionCount : message.siblingCount) || 1) <= 1) continue;
const navigate = versioned ? switchResponseVersion : switchRetryVariant;
const prevBtn = document.createElement("button");
prevBtn.type = "button";
prevBtn.className = "msg-action-btn";
prevBtn.textContent = "Prev";
prevBtn.setAttribute("aria-label", "Show previous retry variant");
prevBtn.disabled = !message.previousSiblingId;
prevBtn.setAttribute("aria-label", versioned ? "Show previous response version" : "Show previous branch");
prevBtn.disabled = versioned ? message.versionIndex <= 0 : !message.previousSiblingId;
prevBtn.addEventListener("click", () => {
void switchRetryVariant(message.id, -1, options.messageIndex ?? -1);
void navigate(message.id, -1, options.messageIndex ?? -1);
});
const nextBtn = document.createElement("button");
nextBtn.type = "button";
nextBtn.className = "msg-action-btn";
nextBtn.textContent = "Next";
nextBtn.setAttribute("aria-label", "Show next retry variant");
nextBtn.disabled = !message.nextSiblingId;
nextBtn.setAttribute("aria-label", versioned ? "Show next response version" : "Show next branch");
nextBtn.disabled = versioned ? message.versionIndex >= message.versionCount - 1 : !message.nextSiblingId;
nextBtn.addEventListener("click", () => {
void switchRetryVariant(message.id, 1, options.messageIndex ?? -1);
void navigate(message.id, 1, options.messageIndex ?? -1);
});
const indexBadge = document.createElement("span");
indexBadge.className = "msg-variant-index";
indexBadge.textContent = `${Number(message.siblingIndex || 0) + 1}/${message.siblingCount}`;
indexBadge.textContent = versioned ? `${message.versionIndex + 1} / ${message.versionCount}` : `${Number(message.siblingIndex || 0) + 1}/${message.siblingCount}`;
actions.append(prevBtn, indexBadge, nextBtn);
}
@@ -759,8 +789,10 @@ function addMessage(contentOrMessage, isUser = false, isLoading = false, options
msgDiv.appendChild(actions);
}
}
messagesDiv.appendChild(msgDiv);
scrollMessagesToBottom();
if (!options.detached) {
messagesDiv.appendChild(msgDiv);
scrollMessagesToBottom();
}
return msgDiv;
}
@@ -1388,8 +1420,9 @@ function setLoadingState(loading, overlayOptions = null) {
}
async function stopGeneration() {
if (!isProcessing || !activeChatId) return;
const res = await post(`/chats/${activeChatId}/stop`, {});
const chatId = regenerationChatId ?? activeChatId;
if (!isProcessing || !chatId) return;
const res = await post(`/chats/${chatId}/stop`, {});
if (res.error) return setNotice(res.error, "error");
setNotice(res.stopped ? "Stopping generation..." : "Generation already finished.");
}