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 = `
Assistant
`; } 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({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 }; }