343 lines
16 KiB
JavaScript
343 lines
16 KiB
JavaScript
import {messagesDiv, msgInput} from "./dom.js?v=20260909-account-controls";
|
|
import {appendAttachments} from "./attachments.js?v=20260930-chat-usability";
|
|
import {updateReasoning} from "./reasoning.js?v=20260921-reasoning-capability";
|
|
import {appendSources} from "./webSearch.js";
|
|
|
|
export function createMessageRenderer({
|
|
getChatById,
|
|
state,
|
|
setNotice,
|
|
sendMessage,
|
|
editChatMessage,
|
|
deleteChatMessage,
|
|
branchFromMessage,
|
|
retryLatestAssistantMessage,
|
|
switchResponseVersion,
|
|
switchRetryVariant,
|
|
renderDrawerSummary,
|
|
updateChatActionState,
|
|
updateComposerPlaceholder,
|
|
createNewChat,
|
|
openRoleplayStarter,
|
|
openPersonaForm
|
|
}) {
|
|
const jumpToLatest = document.getElementById("jumpToLatest");
|
|
|
|
let scrollFrame = null;
|
|
|
|
let renderedChatId = null;
|
|
|
|
let renderedMessages = new Map();
|
|
|
|
function setMessageContent(el, content) {
|
|
const markdownParser = window.marked;
|
|
const purifier = window.DOMPurify;
|
|
if (markdownParser?.parse && purifier?.sanitize) {
|
|
el.innerHTML = purifier.sanitize(markdownParser.parse(content, { breaks: true }));
|
|
} else {
|
|
el.textContent = content;
|
|
}
|
|
}
|
|
|
|
function scrollMessagesToBottom() {
|
|
if (scrollFrame !== null) cancelAnimationFrame(scrollFrame);
|
|
scrollFrame = requestAnimationFrame(() => {
|
|
scrollFrame = null;
|
|
messagesDiv.scrollTop = messagesDiv.scrollHeight;
|
|
updateJumpToLatest();
|
|
});
|
|
}
|
|
|
|
function updateJumpToLatest() {
|
|
const distance = messagesDiv.scrollHeight - messagesDiv.scrollTop - messagesDiv.clientHeight;
|
|
jumpToLatest.classList.toggle("hidden", distance < 180);
|
|
}
|
|
|
|
function addMessage(contentOrMessage, isUser = false, isLoading = false, options = {}) {
|
|
const msgDiv = document.createElement("div");
|
|
msgDiv.className = `msg ${isUser ? "user" : "bot"}${isLoading ? " loading" : ""}`;
|
|
msgDiv.setAttribute("role", "article");
|
|
msgDiv.tabIndex = 0;
|
|
if (isLoading) {
|
|
msgDiv.setAttribute("aria-label", "Assistant is responding");
|
|
msgDiv.innerHTML = `
|
|
<div class="msg-header">Assistant</div>
|
|
<div class="loading-shell" aria-live="polite" aria-label="Assistant is responding">
|
|
<div class="loading-bars">
|
|
<span class="loading-bar"></span>
|
|
<span class="loading-bar"></span>
|
|
<span class="loading-bar"></span>
|
|
<span class="loading-bar"></span>
|
|
</div>
|
|
<div class="loading-copy">
|
|
<strong>Thinking</strong>
|
|
</div>
|
|
</div>
|
|
<div class="msg-content streaming-content"></div>`;
|
|
} else {
|
|
const message = typeof contentOrMessage === "object" && contentOrMessage
|
|
? 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");
|
|
header.className = "msg-header"; body.className = "msg-content";
|
|
actions.className = "msg-actions";
|
|
actions.setAttribute("aria-label", "Message actions");
|
|
const activeChat = getChatById(state.activeChatId);
|
|
header.textContent = isMessageUser ? "You" : (activeChat?.assistant_persona_name || "Assistant");
|
|
setMessageContent(body, message.content);
|
|
|
|
const copyBtn = document.createElement("button");
|
|
copyBtn.type = "button";
|
|
copyBtn.className = "msg-action-btn";
|
|
copyBtn.textContent = "Copy";
|
|
copyBtn.setAttribute("aria-label", `Copy ${isMessageUser ? "your" : "assistant"} message`);
|
|
copyBtn.addEventListener("click", async () => {
|
|
try {
|
|
await navigator.clipboard.writeText(message.content || "");
|
|
setNotice("Message copied.", "success");
|
|
} catch {
|
|
setNotice("Unable to copy message.", "error");
|
|
}
|
|
});
|
|
actions.appendChild(copyBtn);
|
|
|
|
if (message.deliveryStatus === "failed" && isMessageUser) {
|
|
const retryFailedBtn = document.createElement("button");
|
|
retryFailedBtn.type = "button";
|
|
retryFailedBtn.className = "msg-action-btn";
|
|
retryFailedBtn.dataset.messageAction = "retry-failed";
|
|
retryFailedBtn.textContent = "Retry";
|
|
retryFailedBtn.disabled = state.isProcessing;
|
|
retryFailedBtn.addEventListener("click", () => { void sendMessage({ retryMessage: message }); });
|
|
actions.appendChild(retryFailedBtn);
|
|
}
|
|
|
|
const menu = document.createElement("details");
|
|
menu.className = "msg-action-menu";
|
|
const menuTrigger = document.createElement("summary");
|
|
menuTrigger.className = "msg-action-btn";
|
|
menuTrigger.textContent = "More";
|
|
menuTrigger.setAttribute("aria-label", "More message actions");
|
|
const menuItems = document.createElement("div");
|
|
menuItems.className = "msg-action-menu-items";
|
|
menuItems.addEventListener("click", () => { menu.open = false; });
|
|
menu.append(menuTrigger, menuItems);
|
|
if (message.id) actions.appendChild(menu);
|
|
|
|
const editBtn = document.createElement("button");
|
|
editBtn.type = "button";
|
|
editBtn.className = "msg-action-btn";
|
|
editBtn.textContent = isMessageUser ? "Edit & resend" : "Edit";
|
|
editBtn.setAttribute("aria-label", `Edit ${isMessageUser ? "your" : "assistant"} message`);
|
|
editBtn.addEventListener("click", () => {
|
|
void editChatMessage(message.id, options.messageIndex ?? -1);
|
|
});
|
|
menuItems.appendChild(editBtn);
|
|
|
|
const deleteBtn = document.createElement("button");
|
|
deleteBtn.type = "button";
|
|
deleteBtn.className = "msg-action-btn";
|
|
deleteBtn.textContent = "Delete";
|
|
deleteBtn.setAttribute("aria-label", `Delete ${isMessageUser ? "your" : "assistant"} message`);
|
|
deleteBtn.addEventListener("click", () => {
|
|
void deleteChatMessage(message.id, options.messageIndex ?? -1);
|
|
});
|
|
menuItems.appendChild(deleteBtn);
|
|
|
|
const branchBtn = document.createElement("button");
|
|
branchBtn.type = "button";
|
|
branchBtn.className = "msg-action-btn";
|
|
branchBtn.textContent = "Branch here";
|
|
branchBtn.addEventListener("click", () => { void branchFromMessage(message.id); });
|
|
menuItems.appendChild(branchBtn);
|
|
|
|
if (message.role === "bot") {
|
|
const retriesUsed = Math.max(0, Number(message.versionCount || 1) - 1);
|
|
const retryBtn = document.createElement("button");
|
|
retryBtn.type = "button";
|
|
retryBtn.className = "msg-action-btn";
|
|
retryBtn.dataset.messageAction = "regenerate";
|
|
retryBtn.dataset.retryLimitReached = String(retriesUsed >= 5);
|
|
retryBtn.textContent = `Regenerate (${retriesUsed}/5)`;
|
|
retryBtn.setAttribute("aria-label", `Regenerate assistant message, ${retriesUsed} of 5 retries used`);
|
|
retryBtn.disabled = retriesUsed >= 5 || state.isProcessing;
|
|
retryBtn.addEventListener("click", () => {
|
|
void retryLatestAssistantMessage(message.id, options.messageIndex ?? -1);
|
|
});
|
|
actions.appendChild(retryBtn);
|
|
}
|
|
|
|
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", versioned ? "Show previous response version" : "Show previous branch");
|
|
prevBtn.disabled = versioned ? message.versionIndex <= 0 : !message.previousSiblingId;
|
|
prevBtn.addEventListener("click", () => {
|
|
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", versioned ? "Show next response version" : "Show next branch");
|
|
nextBtn.disabled = versioned ? message.versionIndex >= message.versionCount - 1 : !message.nextSiblingId;
|
|
nextBtn.addEventListener("click", () => {
|
|
void navigate(message.id, 1, options.messageIndex ?? -1);
|
|
});
|
|
|
|
const indexBadge = document.createElement("span");
|
|
indexBadge.className = "msg-variant-index";
|
|
indexBadge.textContent = versioned ? `${message.versionIndex + 1} / ${message.versionCount}` : `${Number(message.siblingIndex || 0) + 1}/${message.siblingCount}`;
|
|
actions.append(prevBtn, indexBadge, nextBtn);
|
|
}
|
|
|
|
msgDiv.append(header);
|
|
appendAttachments(msgDiv, message.attachments);
|
|
msgDiv.append(body);
|
|
if (!isMessageUser) updateReasoning(msgDiv, message.thinking);
|
|
if (!isMessageUser) appendSources(msgDiv, message.sources);
|
|
if (message.deliveryStatus === "failed" && message.errorMessage) {
|
|
const error = document.createElement("p");
|
|
error.className = "status error";
|
|
error.textContent = message.errorMessage;
|
|
msgDiv.appendChild(error);
|
|
}
|
|
if (actions.childElementCount) {
|
|
msgDiv.appendChild(actions);
|
|
}
|
|
}
|
|
if (!options.detached) {
|
|
messagesDiv.appendChild(msgDiv);
|
|
scrollMessagesToBottom();
|
|
}
|
|
return msgDiv;
|
|
}
|
|
|
|
function renderMessages() {
|
|
if (scrollFrame !== null) { cancelAnimationFrame(scrollFrame); scrollFrame = null; }
|
|
const sameChat = renderedChatId === state.activeChatId;
|
|
const wasReadingEarlier = sameChat && messagesDiv.scrollHeight - messagesDiv.scrollTop - messagesDiv.clientHeight >= 100;
|
|
const previousScrollTop = messagesDiv.scrollTop;
|
|
const chat = getChatById(state.activeChatId);
|
|
if (!state.currentMessages.length && (!chat || chat.assistant_persona_id)) {
|
|
messagesDiv.replaceChildren();
|
|
renderMessageEmptyState();
|
|
renderedMessages = new Map();
|
|
} else {
|
|
const fragment = document.createDocumentFragment();
|
|
const nextRendered = new Map();
|
|
state.currentMessages.forEach((msg, index) => {
|
|
const key = msg.id ? String(msg.id) : null;
|
|
const snapshot = JSON.stringify(msg);
|
|
const previous = sameChat && key ? renderedMessages.get(key) : null;
|
|
const card = previous?.snapshot === snapshot && previous.node.isConnected
|
|
? previous.node
|
|
: addMessage(msg, msg.role === "user", false, {
|
|
messageIndex: index,
|
|
isNewest: index === state.currentMessages.length - 1,
|
|
detached: true
|
|
});
|
|
fragment.append(card);
|
|
if (key) nextRendered.set(key, {snapshot, node: card});
|
|
});
|
|
messagesDiv.replaceChildren(fragment);
|
|
renderedMessages = nextRendered;
|
|
}
|
|
renderedChatId = state.activeChatId;
|
|
renderDrawerSummary();
|
|
updateChatActionState();
|
|
updateComposerPlaceholder();
|
|
if (wasReadingEarlier) {
|
|
messagesDiv.scrollTop = previousScrollTop;
|
|
updateJumpToLatest();
|
|
} else if (state.currentMessages.length) {
|
|
scrollMessagesToBottom();
|
|
} else {
|
|
updateJumpToLatest();
|
|
}
|
|
}
|
|
|
|
function createStarterAction(label, className, handler) {
|
|
const button = document.createElement("button");
|
|
button.type = "button";
|
|
button.className = className;
|
|
button.textContent = label;
|
|
button.addEventListener("click", handler);
|
|
return button;
|
|
}
|
|
|
|
function createMessageStarterPrompt(label, prompt) {
|
|
return createStarterAction(label, "secondary-action", async () => {
|
|
if (!state.activeChatId) await createNewChat();
|
|
msgInput.value = prompt;
|
|
msgInput.dispatchEvent(new Event("input"));
|
|
msgInput.focus();
|
|
});
|
|
}
|
|
|
|
function renderMessageEmptyState() {
|
|
const empty = document.createElement("section");
|
|
const title = document.createElement("h3");
|
|
const description = document.createElement("p");
|
|
const actions = document.createElement("div");
|
|
const chat = getChatById(state.activeChatId);
|
|
|
|
empty.className = "messages-empty";
|
|
title.className = "messages-empty-title";
|
|
description.className = "messages-empty-copy";
|
|
actions.className = "messages-empty-actions";
|
|
|
|
if (!chat) {
|
|
title.textContent = "Where shall we start?";
|
|
description.textContent = "Choose a starting point.";
|
|
actions.append(
|
|
createStarterAction("New chat", "primary-action", () => { void createNewChat(); }),
|
|
createStarterAction("Roleplay", "secondary-action", () => {
|
|
if (state.assistantPersonas.length) {
|
|
openRoleplayStarter();
|
|
return;
|
|
}
|
|
openPersonaForm(null, "assistant");
|
|
}),
|
|
createMessageStarterPrompt("Try a prompt", "Help me plan my day in 3 steps.")
|
|
);
|
|
} else if (chat.assistant_persona_id) {
|
|
title.textContent = "Roleplay is ready";
|
|
description.textContent = "Send a line or pick a prompt.";
|
|
actions.append(
|
|
createMessageStarterPrompt("Add tension", "Open with a tense line that raises the stakes immediately."),
|
|
createMessageStarterPrompt("Set the scene", "Describe what your character notices first in this scene."),
|
|
createStarterAction("Change setup", "secondary-action", () => openRoleplayStarter())
|
|
);
|
|
} else {
|
|
title.textContent = "What can I help with?";
|
|
description.textContent = "Write a message below or start with one of these prompts.";
|
|
actions.append(
|
|
createMessageStarterPrompt("Explain something", "Explain this topic in simple terms: "),
|
|
createMessageStarterPrompt("Plan a task", "Help me make a practical step-by-step plan for: "),
|
|
createMessageStarterPrompt("Brainstorm ideas", "Give me several creative ideas for: ")
|
|
);
|
|
}
|
|
|
|
empty.append(title, description, actions);
|
|
messagesDiv.appendChild(empty);
|
|
}
|
|
|
|
return {
|
|
setMessageContent,
|
|
scrollMessagesToBottom,
|
|
updateJumpToLatest,
|
|
addMessage,
|
|
renderMessages
|
|
};
|
|
}
|