import {get, post} from "./api.js"; import {createConversationPanel, actionButton} from "./conversationPanel.js"; export function createBranchExplorer({state, setActiveChat, setMessageContent}) { const panel = createConversationPanel("branchExplorer", "Branches & answer comparison"); let request = 0; return {async open() { const chatId = state.activeChatId; if (!chatId) return; const current = ++request; panel.open(); panel.notice("Loading branches..."); panel.content.replaceChildren(); const nodes = [], cache = new Map(); let next = 0, activeLeafId; const layout = document.createElement("div"); layout.className = "branch-layout"; const sidebar = document.createElement("div"), comparison = document.createElement("div"); sidebar.className = "branch-sidebar"; comparison.className = "answer-comparison"; layout.append(sidebar, comparison); panel.content.append(layout); const tree = document.createElement("ol"); tree.className = "branch-tree"; tree.setAttribute("aria-label", "Conversation branches"); const linked = state.chatSessions.filter(chat => chat.source_chat_id === chatId || chat.id === state.chatSessions.find(item => item.id === chatId)?.source_chat_id); if (linked.length) { const heading = document.createElement("h4"); heading.textContent = "Related conversations"; sidebar.append(heading); for (const chat of linked) sidebar.append(actionButton(chat.title, async () => { const result = await setActiveChat(chat.id); if (!result?.error) panel.close(); else panel.notice(result.error, true); })); } const hint = document.createElement("p"); hint.className = "subtitle"; hint.textContent = "Select a message to compare answers or versions. Opening this panel keeps your active branch unchanged."; sidebar.append(hint, tree); const panes = ["Left answer", "Right answer"].map(label => { const section = document.createElement("section"); section.className = "comparison-pane"; const heading = document.createElement("h4"); heading.textContent = label; const message = document.createElement("select"); message.setAttribute("aria-label", `${label} message`); const version = document.createElement("select"); version.setAttribute("aria-label", `${label} version`); const body = document.createElement("div"); body.className = "msg-content"; let revision = 0; const render = async () => { const token = ++revision, id = Number(message.value); use.disabled = true; activate.disabled = true; version.disabled = true; body.replaceChildren(); version.replaceChildren(); if (!id) return; let data = cache.get(id); if (!data) { const loaded = await get(`/chats/${chatId}/messages/${id}/versions`); if (token !== revision || current !== request) return; if (loaded.error) return panel.notice(loaded.error, true); data = loaded.message; cache.set(id, data); } const versions = data.responseVersions?.length ? data.responseVersions : [{content: data.content, modelName: data.modelName}]; use.hidden = data.role !== "bot" || versions.length < 2; use.disabled = false; activate.disabled = false; version.disabled = false; version.replaceChildren(...versions.map((item, index) => { const option = document.createElement("option"); option.value = index; option.textContent = `Version ${index + 1}${item.modelName ? ` · ${item.modelName}` : ""}${index === data.versionIndex ? " (selected)" : ""}`; return option; })); version.value = String(label === "Left answer" && versions.length > 1 ? 0 : data.versionIndex || 0); const show = () => setMessageContent(body, versions[Number(version.value)]?.content || ""); version.onchange = show; show(); }; message.onchange = render; const activate = actionButton("Open this branch", async () => { const result = await post(`/chats/${chatId}/branches/${message.value}/activate`, {includeDescendants: true}); if (result.error) return panel.notice(result.error, true); const opened = await setActiveChat(chatId); if (opened?.error) return panel.notice(opened.error, true); panel.close(); }); const use = actionButton("Use this version", async () => { const result = await post(`/chats/${chatId}/messages/${message.value}/versions/${version.value}`, {}); if (result.error) return panel.notice(result.error, true); cache.delete(Number(message.value)); const refreshed = await setActiveChat(chatId); if (refreshed?.error) return panel.notice(refreshed.error, true); panel.notice("Selected answer version saved."); }); const actions = document.createElement("div"); actions.className = "panel-actions"; actions.append(activate, use); section.append(heading, message, version, actions, body); comparison.append(section); return {message, render}; }); const loadMore = actionButton("Load more branches", async () => { const result = await get(`/chats/${chatId}/branches?after=${next}`); if (current !== request) return; if (result.error) return panel.notice(result.error, true); nodes.push(...result.nodes); next = result.next; activeLeafId = result.activeLeafId; tree.replaceChildren(); const known = new Set(nodes.map(node => node.id)), children = new Map(); for (const node of nodes) { const parent = known.has(node.parentMessageId) ? node.parentMessageId : null; children.set(parent, [...(children.get(parent) || []), node]); } const stack = (children.get(null) || []).map(node => ({node, level: 0})).reverse(); while (stack.length) { const {node, level} = stack.pop(); stack.push(...(children.get(node.id) || []).map(child => ({node: child, level: level + 1})).reverse()); const row = document.createElement("li"); row.style.setProperty("--branch-depth", Math.min(level, 8)); row.title = node.parentMessageId ? `Continues message #${node.parentMessageId}` : "Conversation root"; const button = actionButton(`${node.role === "bot" ? "Assistant" : "You"} #${node.id}${node.id === activeLeafId ? " · active tip" : ""} — ${node.preview}`, async () => { panes[1].message.value = String(node.id); await panes[1].render(); }); if (node.id === activeLeafId) button.setAttribute("aria-current", "true"); row.append(button); tree.append(row); } for (const pane of panes) { const selected = pane.message.value; pane.message.replaceChildren(...nodes.map(node => { const option = document.createElement("option"); option.value = node.id; option.textContent = `${node.role === "bot" ? "Assistant" : "You"} #${node.id} — ${node.preview.slice(0, 65)}`; return option; })); pane.message.value = selected || String(nodes.filter(node => node.role === "bot").at(-1)?.id || nodes.at(-1)?.id || ""); await pane.render(); } loadMore.hidden = next == null; panel.notice(nodes.length ? "" : "Send a message to start a conversation branch."); }); sidebar.append(loadMore); await loadMore.onclick(); }}; }