257 lines
13 KiB
JavaScript
257 lines
13 KiB
JavaScript
import {post, put, del} from "./api.js";
|
|
import {regenerateInline} from "./regeneration.js";
|
|
|
|
export function createMessageActions({conversation, setNotice, promptPopup, confirmPopup, setActiveChat, openCreatedChat, setLoadingState, loadSummary, renderMessages, addMessage, setMessageContent, webSearchToggle, reasoningToggle, modelSelect, messagesDiv, msgInput}) {
|
|
function getMessageIndexById(messageId) {
|
|
return conversation.currentMessages.findIndex((message) => message.id === messageId);
|
|
}
|
|
|
|
async function resolveMessageTarget(messageId, fallbackIndex = -1) {
|
|
if (messageId) {
|
|
const index = getMessageIndexById(messageId);
|
|
if (index >= 0) {
|
|
return { index, message: conversation.currentMessages[index] };
|
|
}
|
|
}
|
|
if (conversation.activeChatId) {
|
|
await setActiveChat(conversation.activeChatId);
|
|
}
|
|
if (messageId) {
|
|
const index = getMessageIndexById(messageId);
|
|
if (index >= 0) {
|
|
return { index, message: conversation.currentMessages[index] };
|
|
}
|
|
}
|
|
if (fallbackIndex >= 0 && fallbackIndex < conversation.currentMessages.length) {
|
|
return { index: fallbackIndex, message: conversation.currentMessages[fallbackIndex] };
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function getMessageEndpoint(target, suffix = "") {
|
|
if (!conversation.activeChatId || !target) return "";
|
|
if (target.message?.id) return `/chats/${conversation.activeChatId}/messages/${target.message.id}${suffix}`;
|
|
if (target.index >= 0) return `/chats/${conversation.activeChatId}/messages/by-index/${target.index}${suffix}`;
|
|
return "";
|
|
}
|
|
|
|
function getPreviousUserMessageId(messageId) {
|
|
const index = getMessageIndexById(messageId);
|
|
if (index <= 0) return null;
|
|
for (let i = index - 1; i >= 0; i -= 1) {
|
|
if (conversation.currentMessages[i].role === "user" && conversation.currentMessages[i].id) {
|
|
return conversation.currentMessages[i].id;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function getRetryStateFromMessage(message) {
|
|
const variants = Array.isArray(message?.retryVariants) && message.retryVariants.length
|
|
? [...message.retryVariants]
|
|
: [message?.content || ""];
|
|
const requestedActiveIndex = Number(message?.retryActiveIndex);
|
|
const activeIndex = Number.isInteger(requestedActiveIndex)
|
|
? Math.min(Math.max(requestedActiveIndex, 0), variants.length - 1)
|
|
: 0;
|
|
return {
|
|
variants,
|
|
activeIndex,
|
|
retriesUsed: Number(message?.retryRetriesUsed || 0),
|
|
promptMessageId: message?.retryPromptMessageId ?? getPreviousUserMessageId(message?.id)
|
|
};
|
|
}
|
|
|
|
function syncRetryStateFromMessage(message) {
|
|
if (!message?.id) return null;
|
|
const state = getRetryStateFromMessage(message);
|
|
conversation.messageRetryState.set(message.id, state);
|
|
return state;
|
|
}
|
|
|
|
async function editChatMessage(messageId, fallbackIndex = -1) {
|
|
const target = await resolveMessageTarget(messageId, fallbackIndex);
|
|
if (!target) return setNotice("Message is not ready yet. Try again.", "error");
|
|
const { index, message } = target;
|
|
const sourceChatId = conversation.activeChatId;
|
|
const nextContent = await promptPopup({
|
|
eyebrow: message.role === "user" ? "Your message" : "Assistant message",
|
|
title: "Edit message",
|
|
description: "Update this message.",
|
|
label: "Message",
|
|
value: message.content,
|
|
confirmLabel: "Save"
|
|
});
|
|
const content = (nextContent || "").trim();
|
|
if (!content) return;
|
|
if (message.role === "user") {
|
|
const confirmed = await confirmPopup({
|
|
eyebrow: "Create branch",
|
|
title: "Create an edited chat branch?",
|
|
description: "This creates a new chat from the messages before this one and sends your edited message there. The original chat stays unchanged.",
|
|
confirmLabel: "Create branch"
|
|
});
|
|
if (!confirmed || conversation.isProcessing) return;
|
|
conversation.regenerationChatId = sourceChatId;
|
|
setLoadingState(true, {
|
|
eyebrow: "Branching",
|
|
title: "Editing and resending",
|
|
detail: "Creating and opening a new chat branch."
|
|
});
|
|
try {
|
|
const res = await post(`/chats/${sourceChatId}/messages/${message.id}/edit-resend`, {
|
|
chatId: sourceChatId,
|
|
message: content,
|
|
model: modelSelect.value,
|
|
webSearch: webSearchToggle.enabled,
|
|
confirmed: true
|
|
}, {timeoutMs: 660000});
|
|
if (res.error) return setNotice(res.error, "error");
|
|
if (conversation.activeChatId === sourceChatId) await openCreatedChat(res.chat);
|
|
return setNotice("Created the edited chat branch.", "success");
|
|
} finally {
|
|
conversation.regenerationChatId = null;
|
|
setLoadingState(false);
|
|
}
|
|
}
|
|
const endpoint = getMessageEndpoint(target);
|
|
if (!endpoint) return setNotice("Message is not ready yet. Try again.", "error");
|
|
const res = await put(endpoint, { content });
|
|
if (res.error || !res.message) {
|
|
return setNotice(res.error || "Unable to edit message.", "error");
|
|
}
|
|
conversation.currentMessages[index] = { ...conversation.currentMessages[index], ...res.message };
|
|
syncRetryStateFromMessage(conversation.currentMessages[index]);
|
|
renderMessages();
|
|
setNotice("Message updated.", "success");
|
|
}
|
|
|
|
async function deleteChatMessage(messageId, fallbackIndex = -1) {
|
|
const target = await resolveMessageTarget(messageId, fallbackIndex);
|
|
if (!target) return setNotice("Message is not ready yet. Try again.", "error");
|
|
const { message } = target;
|
|
const confirmed = await confirmPopup({
|
|
eyebrow: message.role === "user" ? "Your message" : "Assistant message",
|
|
title: "Delete message",
|
|
description: "Delete this message from the chat?",
|
|
confirmLabel: "Delete",
|
|
danger: true
|
|
});
|
|
if (!confirmed) return;
|
|
const endpoint = getMessageEndpoint(target);
|
|
if (!endpoint) return setNotice("Message is not ready yet. Try again.", "error");
|
|
const res = await del(endpoint);
|
|
if (res.error) return setNotice(res.error, "error");
|
|
conversation.currentMessages = conversation.currentMessages.filter((item) => item.id !== message.id);
|
|
if (message.id) {
|
|
conversation.messageRetryState.delete(message.id);
|
|
for (const [key, state] of conversation.messageRetryState.entries()) {
|
|
if (state.promptMessageId === message.id) {
|
|
conversation.messageRetryState.delete(key);
|
|
}
|
|
}
|
|
}
|
|
renderMessages();
|
|
await loadSummary();
|
|
setNotice("Message deleted.", "success");
|
|
}
|
|
|
|
async function retryLatestAssistantMessage(messageId, fallbackIndex = -1) {
|
|
if (conversation.isProcessing) return;
|
|
const target = await resolveMessageTarget(messageId, fallbackIndex);
|
|
if (conversation.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.versionCount || 1) - 1);
|
|
if (retriesUsed >= 5) {
|
|
return setNotice("Retry limit reached (5).", "error");
|
|
}
|
|
const chatId = conversation.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 };
|
|
conversation.regenerationChatId = chatId;
|
|
setLoadingState(true, false);
|
|
try {
|
|
await regenerateInline({
|
|
card, scroller: messagesDiv, endpoint: `/chats/${chatId}/messages/${target.message.id}/retry/stream`, payload,
|
|
renderContent: setMessageContent,
|
|
complete: message => {
|
|
if (conversation.activeChatId !== chatId) return;
|
|
const index = getMessageIndexById(message.id);
|
|
if (index < 0) return;
|
|
conversation.currentMessages[index] = { ...conversation.currentMessages[index], ...message, thinking: message.thinking, sources: message.sources };
|
|
syncRetryStateFromMessage(conversation.currentMessages[index]);
|
|
const rendered = addMessage(conversation.currentMessages[index], false, false, { messageIndex: index, detached: true });
|
|
card.replaceChildren(...rendered.childNodes);
|
|
}
|
|
});
|
|
reasoningToggle.reset();
|
|
setNotice("Reply regenerated.", "success");
|
|
} catch (error) {
|
|
setNotice(error.message || "Unable to regenerate message.", "error");
|
|
} finally {
|
|
conversation.regenerationChatId = null;
|
|
setLoadingState(false);
|
|
}
|
|
}
|
|
|
|
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 = conversation.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 (conversation.activeChatId !== chatId) return;
|
|
const index = getMessageIndexById(messageId);
|
|
if (index < 0) return;
|
|
conversation.currentMessages[index] = { ...conversation.currentMessages[index], ...res.message, thinking: res.message.thinking, sources: res.message.sources };
|
|
syncRetryStateFromMessage(conversation.currentMessages[index]);
|
|
const rendered = addMessage(conversation.currentMessages[index], false, false, { messageIndex: index, detached: true });
|
|
card.replaceChildren(...rendered.childNodes);
|
|
} finally {
|
|
card.removeAttribute("aria-busy");
|
|
buttons.forEach(([button, disabled]) => { button.disabled = disabled; });
|
|
}
|
|
}
|
|
}
|
|
|
|
async function switchRetryVariant(messageId, direction, fallbackIndex = -1) {
|
|
const target = await resolveMessageTarget(messageId, fallbackIndex);
|
|
if (!target) return setNotice("Message is not ready yet. Try again.", "error");
|
|
const siblingId = direction < 0 ? target.message.previousSiblingId : target.message.nextSiblingId;
|
|
if (!siblingId) return;
|
|
const res = await post(`/chats/${conversation.activeChatId}/branches/${siblingId}/activate`, { includeDescendants: true });
|
|
if (res.error) return setNotice(res.error, "error");
|
|
conversation.currentMessages = res.messages || [];
|
|
conversation.historyPage = res.pagination || null;
|
|
renderMessages();
|
|
}
|
|
|
|
async function branchFromMessage(messageId) {
|
|
if (!conversation.activeChatId || !messageId) return;
|
|
const confirmed = await confirmPopup({
|
|
eyebrow: "Create branch",
|
|
title: "Create a new chat branch?",
|
|
description: "The new chat will contain the conversation through this message. The original chat stays unchanged.",
|
|
confirmLabel: "Create branch"
|
|
});
|
|
if (!confirmed) return;
|
|
const sourceChatId = conversation.activeChatId;
|
|
const res = await post(`/chats/${sourceChatId}/messages/${messageId}/branch`, { confirmed: true });
|
|
if (res.error) return setNotice(res.error, "error");
|
|
await openCreatedChat(res.chat);
|
|
msgInput.focus();
|
|
setNotice("Created and opened a new chat branch.", "success");
|
|
}
|
|
|
|
return {editChatMessage, deleteChatMessage, retryLatestAssistantMessage, switchResponseVersion, switchRetryVariant, branchFromMessage};
|
|
}
|