490 lines
23 KiB
JavaScript
490 lines
23 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";
|
|
import {get} from "./api.js";
|
|
|
|
export function createMessageRenderer({
|
|
getChatById,
|
|
state,
|
|
setNotice,
|
|
sendMessage,
|
|
editChatMessage,
|
|
deleteChatMessage,
|
|
branchFromMessage,
|
|
retryLatestAssistantMessage,
|
|
switchResponseVersion,
|
|
switchRetryVariant,
|
|
renderDrawerSummary,
|
|
updateChatActionState,
|
|
updateComposerPlaceholder,
|
|
createNewChat,
|
|
openMessage
|
|
}) {
|
|
const jumpToLatest = document.getElementById("jumpToLatest");
|
|
const searchInput = document.getElementById("conversationSearch");
|
|
const searchCount = document.getElementById("conversationSearchCount");
|
|
const previousMatch = document.getElementById("conversationSearchPrevious");
|
|
const nextMatch = document.getElementById("conversationSearchNext");
|
|
const replyAnnouncement = document.getElementById("replyAnnouncement");
|
|
|
|
let scrollFrame = null;
|
|
|
|
let renderedChatId = null;
|
|
|
|
let renderedMessages = new Map();
|
|
let searchMatches = [];
|
|
let conversationMatches = null;
|
|
let selectedMatch = 0;
|
|
let searchChatId = null;
|
|
let searchFrame = null;
|
|
let searchTimer = null;
|
|
let searchRequest = 0;
|
|
let searchNavigation = 0;
|
|
|
|
function clearSearchMarks() {
|
|
messagesDiv.querySelectorAll("mark.conversation-search-match").forEach(mark => {
|
|
mark.replaceWith(document.createTextNode(mark.textContent));
|
|
});
|
|
messagesDiv.normalize();
|
|
}
|
|
|
|
function updateSearchCount() {
|
|
const total = conversationMatches?.length ?? searchMatches.length;
|
|
const label = total ? `${selectedMatch + 1} of ${total} matches` : "0 matches";
|
|
if (searchCount.textContent !== label) searchCount.textContent = label;
|
|
previousMatch.disabled = nextMatch.disabled = !total;
|
|
const target = conversationMatches?.[selectedMatch];
|
|
searchMatches.forEach((mark, index) => mark.classList.toggle("current", target
|
|
? String(target.messageId) === mark.dataset.messageId && Number(mark.dataset.occurrence) === target.occurrence
|
|
: index === selectedMatch));
|
|
}
|
|
|
|
function refreshConversationSearch() {
|
|
if (searchFrame !== null) { cancelAnimationFrame(searchFrame); searchFrame = null; }
|
|
clearSearchMarks();
|
|
searchMatches = [];
|
|
const query = searchInput.value.trim();
|
|
if (query) {
|
|
const needle = query.toLocaleLowerCase();
|
|
const occurrences = new Map();
|
|
messagesDiv.querySelectorAll(".msg-content").forEach(content => {
|
|
const messageId = content.closest(".msg")?.dataset.messageId || "";
|
|
const walker = document.createTreeWalker(content, NodeFilter.SHOW_TEXT);
|
|
const nodes = [];
|
|
while (walker.nextNode()) nodes.push(walker.currentNode);
|
|
nodes.forEach(node => {
|
|
const value = node.nodeValue;
|
|
const lower = value.toLocaleLowerCase();
|
|
let start = lower.indexOf(needle);
|
|
if (start < 0) return;
|
|
const fragment = document.createDocumentFragment();
|
|
let cursor = 0;
|
|
while (start >= 0) {
|
|
fragment.append(document.createTextNode(value.slice(cursor, start)));
|
|
const mark = document.createElement("mark");
|
|
mark.className = "conversation-search-match";
|
|
mark.textContent = value.slice(start, start + query.length);
|
|
mark.dataset.messageId = messageId;
|
|
mark.dataset.occurrence = String(occurrences.get(messageId) || 0);
|
|
occurrences.set(messageId, (occurrences.get(messageId) || 0) + 1);
|
|
fragment.append(mark);
|
|
searchMatches.push(mark);
|
|
cursor = start + query.length;
|
|
start = lower.indexOf(needle, cursor);
|
|
}
|
|
fragment.append(document.createTextNode(value.slice(cursor)));
|
|
node.replaceWith(fragment);
|
|
});
|
|
});
|
|
}
|
|
selectedMatch = Math.min(selectedMatch, Math.max(0, (conversationMatches?.length ?? searchMatches.length) - 1));
|
|
updateSearchCount();
|
|
}
|
|
|
|
function scheduleConversationSearch(reset = false, focusMatch = false) {
|
|
clearTimeout(searchTimer);
|
|
const request = ++searchRequest;
|
|
const chatId = state.activeChatId;
|
|
const query = searchInput.value.trim();
|
|
if (reset || !query) conversationMatches = null;
|
|
updateSearchCount();
|
|
if (!chatId || !query) return;
|
|
searchTimer = setTimeout(async () => {
|
|
const result = await get(`/chats/${chatId}/messages/search?q=${encodeURIComponent(query)}`);
|
|
if (request !== searchRequest || chatId !== state.activeChatId || query !== searchInput.value.trim()) return;
|
|
if (result.error) { searchCount.textContent = "Search unavailable"; return; }
|
|
conversationMatches = result.matches || [];
|
|
selectedMatch = Math.min(selectedMatch, Math.max(0, conversationMatches.length - 1));
|
|
updateSearchCount();
|
|
if (focusMatch && conversationMatches.length) void moveSearchMatch(0);
|
|
}, 200);
|
|
}
|
|
|
|
function scheduleSearchRefresh() {
|
|
if (!searchInput.value.trim() || searchFrame !== null) return;
|
|
searchFrame = requestAnimationFrame(refreshConversationSearch);
|
|
}
|
|
|
|
async function moveSearchMatch(direction) {
|
|
const navigation = ++searchNavigation;
|
|
const hits = conversationMatches || searchMatches;
|
|
if (!hits.length) return;
|
|
selectedMatch = (selectedMatch + direction + hits.length) % hits.length;
|
|
updateSearchCount();
|
|
const hit = conversationMatches?.[selectedMatch];
|
|
if (hit && !searchMatches.some(mark => mark.dataset.messageId === String(hit.messageId))) {
|
|
await openMessage(hit.messageId);
|
|
if (navigation !== searchNavigation || state.activeChatId !== searchChatId) return;
|
|
refreshConversationSearch();
|
|
}
|
|
const mark = hit
|
|
? searchMatches.find(item => item.dataset.messageId === String(hit.messageId) && Number(item.dataset.occurrence) === hit.occurrence)
|
|
|| searchMatches.find(item => item.dataset.messageId === String(hit.messageId))
|
|
: searchMatches[selectedMatch];
|
|
mark?.scrollIntoView({block: "center", behavior: "smooth"});
|
|
}
|
|
|
|
searchInput.addEventListener("input", () => {
|
|
searchNavigation++;
|
|
selectedMatch = 0;
|
|
conversationMatches = null;
|
|
refreshConversationSearch();
|
|
searchMatches[0]?.scrollIntoView({block: "center", behavior: "smooth"});
|
|
scheduleConversationSearch(false, true);
|
|
});
|
|
searchInput.addEventListener("keydown", event => {
|
|
if (event.key === "Enter") { event.preventDefault(); moveSearchMatch(event.shiftKey ? -1 : 1); }
|
|
});
|
|
previousMatch.addEventListener("click", () => moveSearchMatch(-1));
|
|
nextMatch.addEventListener("click", () => moveSearchMatch(1));
|
|
|
|
function announceResponding() { replyAnnouncement.textContent = "Assistant is responding."; }
|
|
function announceResponseStatus(status) { replyAnnouncement.textContent = status; }
|
|
function announceReply(content) {
|
|
const plain = document.createElement("div");
|
|
renderMarkdown(plain, content || "");
|
|
replyAnnouncement.textContent = `Assistant: ${plain.textContent.trim() || "Response complete."}`;
|
|
if (searchInput.value.trim()) scheduleConversationSearch();
|
|
}
|
|
|
|
function renderMarkdown(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 setMessageContent(el, content) {
|
|
renderMarkdown(el, content);
|
|
scheduleSearchRefresh();
|
|
}
|
|
|
|
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-hidden="true">
|
|
<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 (["failed", "pending"].includes(message.deliveryStatus) && isMessageUser) {
|
|
const retryFailedBtn = document.createElement("button");
|
|
retryFailedBtn.type = "button";
|
|
retryFailedBtn.className = "msg-action-btn";
|
|
retryFailedBtn.dataset.messageAction = "retry-failed";
|
|
retryFailedBtn.textContent = message.deliveryStatus === "pending" ? "Resume" : "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({preserveScroll = false} = {}) {
|
|
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;
|
|
if (searchChatId !== state.activeChatId) {
|
|
searchNavigation++;
|
|
searchChatId = state.activeChatId;
|
|
selectedMatch = 0;
|
|
scheduleConversationSearch(true, true);
|
|
} else if (searchInput.value.trim()) scheduleConversationSearch();
|
|
refreshConversationSearch();
|
|
renderDrawerSummary();
|
|
updateChatActionState();
|
|
updateComposerPlaceholder();
|
|
if (wasReadingEarlier || preserveScroll) {
|
|
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("Browse Personalities", "secondary-action", () => { location.assign("/app/personas"); }),
|
|
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.")
|
|
);
|
|
} 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,
|
|
announceResponding,
|
|
announceResponseStatus,
|
|
announceReply,
|
|
scrollMessagesToBottom,
|
|
updateJumpToLatest,
|
|
addMessage,
|
|
renderMessages
|
|
};
|
|
}
|