add response versioning support in messages for regeneration improvements
This commit is contained in:
@@ -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
@@ -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.");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user