Add support for message history pagination, preserve scroll in rendering, and improve draft/message workflows.
CI / check (22) (push) Successful in 9m34s
CI / check (24) (push) Successful in 9m34s

This commit is contained in:
2026-10-06 00:32:18 +02:00
parent 1c0f06716e
commit 387c0ac2e7
40 changed files with 2374 additions and 1314 deletions
+16 -4
View File
@@ -82,18 +82,19 @@ function normalizeError(error, scope, response) {
});
}
export async function request(url, data, method = "POST", options = {}) {
async function bufferedRequest(url, {method, body, headers}, options = {}, asBlob = false) {
const scope = requestScope(options);
let response;
try {
response = await abortable(() => fetch(url, {
method,
credentials: "same-origin",
headers: {"Accept": "application/json", "Content-Type": "application/json"},
body: data == null ? undefined : JSON.stringify(data),
headers,
body,
signal: scope.signal
}), scope.signal);
return await readJson(response, scope.signal);
if (!asBlob || !response.ok) return await readJson(response, scope.signal);
return {blob: await abortable(() => response.blob(), scope.signal)};
} catch (error) {
return normalizeError(error, scope, response).toJSON();
} finally {
@@ -101,6 +102,17 @@ export async function request(url, data, method = "POST", options = {}) {
}
}
export function request(url, data, method = "POST", options = {}) {
return bufferedRequest(url, {method, headers: {"Accept": "application/json", "Content-Type": "application/json"},
body: data == null ? undefined : JSON.stringify(data)}, options);
}
// Let fetch set the multipart boundary; share deadlines, cancellation and errors.
export const multipart = (url, form, options = {}) => bufferedRequest(url,
{method: "POST", headers: {"Accept": "application/json"}, body: form}, {timeoutMs: 120000, ...options});
export const download = (url, options = {}) => bufferedRequest(url,
{method: "GET", headers: {"Accept": "*/*"}}, {timeoutMs: 120000, ...options}, true);
export const get = (url, options) => request(url, null, "GET", options);
export const post = (url, data, options) => request(url, data, "POST", options);
export const put = (url, data, options) => request(url, data, "PUT", options);
+52 -17
View File
@@ -1,6 +1,8 @@
/**
* @file Verwaltet Bildauswahl, Uploads und Vorschauen sowie die Anzeige gespeicherter Anhänge.
*/
import {get, del, multipart, ApiError} from "./api.js";
export function appendAttachments(container, attachments = []) {
if (!attachments.length) return;
const gallery = document.createElement("div");
@@ -27,11 +29,17 @@ export function createAttachmentComposer({onChange, onError}) {
const preview = document.getElementById("uploadPreviews");
const composer = document.querySelector(".input-container");
let items = [];
let draftStore = null;
let currentDraftId = "new";
const persist = (id = currentDraftId, values = items) => {
if (id != null) draftStore?.setAttachments(id, values.filter(item => item.metadata).map(item => item.metadata));
};
const drafts = new Map();
let busy = false;
let limits = {maxSizeMb: 10, maxFiles: 5};
fetch("/uploads/config").then(r => r.ok ? r.json() : limits).then(value => { limits = value; }).catch(() => {});
void get("/uploads/config").then(value => { if (!value.error) limits = value; });
const render = () => {
persist();
preview.replaceChildren();
preview.hidden = !items.length;
for (const item of items) {
@@ -39,11 +47,22 @@ export function createAttachmentComposer({onChange, onError}) {
card.className = "upload-preview";
const image = document.createElement("img");
image.src = item.preview;
image.alt = item.file.name;
image.alt = item.file?.name || item.metadata?.originalName || "Recovered image";
image.onload = () => {
if (item.verifying) { item.verifying = false; onChange(); }
};
image.onerror = () => {
if (item.recovered && !item.error) {
item.error = "Image unavailable. Remove it and attach it again.";
item.verifying = false;
item.unavailable = true;
render();
}
};
const label = document.createElement("span");
label.textContent = `${item.file.name} — ${item.error || (item.metadata ? "Ready" : "Uploading…")}`;
label.textContent = `${item.file?.name || item.metadata?.originalName || "Recovered image"} — ${item.error || (item.metadata ? "Ready" : "Uploading…")}`;
card.append(image, label);
if (item.error) {
if (item.error && item.file) {
const retry = document.createElement("button");
retry.type = "button"; retry.textContent = "Retry"; retry.disabled = busy;
retry.onclick = () => { void upload(item); };
@@ -51,11 +70,11 @@ export function createAttachmentComposer({onChange, onError}) {
}
const remove = document.createElement("button");
remove.type = "button"; remove.textContent = "×"; remove.disabled = busy;
remove.setAttribute("aria-label", `Remove ${item.file.name}`);
remove.setAttribute("aria-label", `Remove ${item.file?.name || item.metadata?.originalName || "image"}`);
remove.onclick = () => {
items = items.filter(v => v !== item);
item.controller?.abort(); URL.revokeObjectURL(item.preview);
if (item.metadata) void fetch(`/attachments/${item.metadata.id}`, {method: "DELETE"});
if (item.metadata) void del(`/attachments/${item.metadata.id}`);
render();
};
card.append(remove); preview.append(card);
@@ -70,16 +89,16 @@ export function createAttachmentComposer({onChange, onError}) {
render();
try {
const form = new FormData(); form.append("image", item.file);
const response = await fetch("/uploads", {method: "POST", body: form, signal: item.controller.signal});
const result = await response.json();
if (!response.ok) throw new Error(result.error || "Upload failed");
const result = await multipart("/uploads", form, {signal: item.controller.signal});
if (result.error) throw new ApiError(result.error, result);
item.metadata = result;
if (![items, ...drafts.values()].some(draft => draft.includes(item))) void fetch(`/attachments/${result.id}`, {method: "DELETE"});
if (![items, ...drafts.values()].some(draft => draft.includes(item))) void del(`/attachments/${result.id}`);
} catch (error) {
if (error.name !== "AbortError") item.error = error.message || "Upload failed";
if (error.code !== "ABORTED") item.error = error.message || "Upload failed";
}
item.uploading = false;
item.controller = null;
for (const [id, draft] of drafts) if (draft.includes(item)) persist(id, draft);
if (items.includes(item)) render();
};
const add = files => {
@@ -106,26 +125,39 @@ export function createAttachmentComposer({onChange, onError}) {
if (images.length) { if (!event.clipboardData.getData("text/plain")) event.preventDefault(); add(images); }
});
return {
get attachments() { return items.filter(v => v.metadata).map(v => v.metadata); },
get pending() { return items.some(v => !v.metadata); },
setDraftStore(store) { draftStore = store; },
adoptDraft(chatId) {
if (currentDraftId !== chatId) {
if (currentDraftId === "new") draftStore?.setAttachments("new", []);
currentDraftId = chatId;
persist();
}
},
get attachments() { return items.filter(v => v.metadata && !v.unavailable).map(v => v.metadata); },
get pending() { return items.some(v => !v.metadata || v.verifying || v.unavailable); },
setBusy(value) { busy = value; button.disabled = value; picker.disabled = value; render(); },
clear() { items.forEach(v => URL.revokeObjectURL(v.preview)); items = []; render(); },
discard() {
for (const item of items) {
item.controller?.abort();
URL.revokeObjectURL(item.preview);
if (item.metadata) void fetch(`/attachments/${item.metadata.id}`, {method: "DELETE"});
if (item.metadata) void del(`/attachments/${item.metadata.id}`);
}
items = [];
render();
},
saveDraft(chatId) {
if (chatId != null) drafts.set(chatId, items);
chatId ??= "new";
drafts.set(chatId, items);
persist(chatId);
items = [];
currentDraftId = null;
render();
},
restoreDraft(chatId) {
items = drafts.get(chatId) || [];
currentDraftId = chatId ?? "new";
chatId ??= "new";
items = drafts.get(chatId) || (draftStore?.attachments(chatId) || []).map(metadata => ({metadata, recovered: true, verifying: true, preview: `/attachments/${metadata.id}`}));
drafts.delete(chatId);
render();
},
@@ -137,14 +169,17 @@ export function createAttachmentComposer({onChange, onError}) {
URL.revokeObjectURL(item.preview);
return false;
}));
const remaining = (draftStore?.attachments(chatId) || []).filter(item => !sent.has(item.id));
draftStore?.setAttachments(chatId, remaining);
},
discardDraft(chatId) {
const saved = drafts.get(chatId) || [];
drafts.delete(chatId);
draftStore?.setAttachments(chatId, []);
for (const item of saved) {
item.controller?.abort();
URL.revokeObjectURL(item.preview);
if (item.metadata) void fetch(`/attachments/${item.metadata.id}`, {method: "DELETE"});
if (item.metadata) void del(`/attachments/${item.metadata.id}`);
}
}
};
+120
View File
@@ -0,0 +1,120 @@
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();
}};
}
+27 -14
View File
@@ -74,7 +74,12 @@ export function createComposerController({
const {signal} = sendController;
const visible = () => state.activeChatId === chatId;
let userCard, loadingMsg, rendering, completedReply = null;
let completedThinking = "", completedSources = null;
const userMessage = {role: "user", content: message, attachments};
const clearSentDraft = () => {
if (state.chatDrafts.get(chatId)?.trim() === message) state.chatDrafts.delete(chatId);
attachmentComposer.clearSentDraft(chatId, attachments.map(item => item.id));
};
try {
setLoadingState(true, false);
if (retryMessage && !msgInput.value.trim()) {
@@ -115,11 +120,11 @@ export function createComposerController({
if (follow) scrollMessagesToBottom();
};
rendering = createStreamRenderer({
onThinking: text => render(() => {
onThinking: text => { completedThinking = text; render(() => {
if (!reasoning) return;
updateReasoning(loadingMsg, text, true);
loadingShell?.classList.toggle("hidden", Boolean(text));
}),
}); },
onReply: text => render(() => {
if (text) finishReasoning(loadingMsg);
loadingShell?.classList.toggle("hidden", Boolean(text));
@@ -136,24 +141,36 @@ export function createComposerController({
: searchState === "no_relevant_results" ? "No sufficiently relevant results. Generating response..."
: sourceCount === 0 ? "No web results found. Generating response..." : "Generating response...";
},
onDone: ({reply}) => { completedReply = reply; }
onDone: ({reply, sources}) => { completedReply = reply; completedSources = sources; }
}, {signal});
rendering.flush();
rendering.dispose();
loadingMsg?.remove();
// Keep a completed answer in both state and the DOM until history replaces it.
if (visible()) {
const reply = {role: "bot", content: completedReply, modelName: model,
...(completedThinking ? {thinking: completedThinking} : {}), ...(completedSources ? {sources: completedSources} : {})};
state.currentMessages.push(reply);
if (loadingMsg?.isConnected) loadingMsg.replaceWith(addMessage(reply, false, false, {detached: true}));
else addMessage(reply);
} else loadingMsg?.remove();
webSearchToggle.reset(); reasoningToggle.reset();
if (visible()) {
const followOutput = messagesDiv.scrollHeight - messagesDiv.scrollTop - messagesDiv.clientHeight < 100;
const previousScrollTop = messagesDiv.scrollTop;
if (msgInput.value.trim() === message) { msgInput.value = ""; resizeComposerInput(); }
const currentAttachments = attachmentComposer.attachments;
if (currentAttachments.length === attachments.length && currentAttachments.every((item, index) => item.id === attachments[index].id)) attachmentComposer.clear();
await setActiveChat(chatId);
const refreshed = await setActiveChat(chatId);
if (refreshed?.error) throw new Error(refreshed.error);
if (visible()) {
if (msgInput.value.trim() === message) { msgInput.value = ""; resizeComposerInput(); }
if (msgInput.value) state.chatDrafts.set(chatId, msgInput.value);
else state.chatDrafts.delete(chatId);
const currentAttachments = attachmentComposer.attachments;
if (currentAttachments.length === attachments.length && currentAttachments.every((item, index) => item.id === attachments[index].id)) attachmentComposer.clear();
} else clearSentDraft();
if (!followOutput && visible()) requestAnimationFrame(() => {
if (visible()) messagesDiv.scrollTop = previousScrollTop;
});
} else {
if (state.chatDrafts.get(chatId)?.trim() === message) state.chatDrafts.delete(chatId);
attachmentComposer.clearSentDraft(chatId, attachments.map(item => item.id));
clearSentDraft();
}
await loadSummary();
markOnboardingCompleted(); maybeShowPersonaNudge();
@@ -162,10 +179,6 @@ export function createComposerController({
loadingMsg?.remove();
const messageText = signal.aborted ? "Generation stopped." : error?.message || "Unable to send message.";
if (completedReply !== null) {
if (visible() && userCard?.isConnected) {
state.currentMessages.push({role: "bot", content: completedReply});
addMessage(completedReply);
}
setNotice(`Reply sent, but the conversation could not be refreshed: ${messageText}`, "error");
} else {
if (visible()) {
+60 -15
View File
@@ -18,8 +18,10 @@ export function createConversationController({
renderMessages,
updateJumpToLatest,
updateSendState,
loadSummary
loadSummary,
updateHistoryControls = () => {}
}) {
let opening = false;
function getChatById(id) { return state.chatSessions.find((chat) => chat.id === id); }
async function openCreatedChat(chat) {
@@ -35,28 +37,34 @@ export function createConversationController({
throw new Error("The newly created chat was not persisted");
}
renderChatList();
await setActiveChat(chat.id);
const opened = await setActiveChat(chat.id);
if (opened?.error) throw new Error(opened.error);
}
async function setActiveChat(id, {preserveComposer = false, matchMessageId = null} = {}) {
const failed = response => { setNotice(response.error, "error"); return response; };
const openRequest = ++state.chatOpenRequest;
if (isMobileLayout()) closeMobileSidebar();
setChatLoading(true, "Opening chat...");
setChatActivity(true, {
eyebrow: "Opening chat", title: "Loading conversation",
detail: "Fetching messages and restoring the current scene."
});
if (!opening) {
opening = true;
setChatLoading(true, "Opening chat...");
setChatActivity(true, {
eyebrow: "Opening chat", title: "Loading conversation",
detail: "Fetching messages and restoring the current scene."
});
}
try {
let res = await get(`/chats/${id}/messages`);
const historyUrl = `/chats/${id}/messages${matchMessageId ? `?around=${matchMessageId}` : ""}`;
let res = await get(historyUrl);
if (openRequest !== state.chatOpenRequest) return;
if (res.error) return setNotice(res.error, "error");
if (res.error) return failed(res);
if (matchMessageId && !res.messages?.some(message => message.id === matchMessageId)) {
const activated = await post(`/chats/${id}/branches/${matchMessageId}/activate`, {includeDescendants: true});
if (openRequest !== state.chatOpenRequest) return;
if (activated.error) return setNotice(activated.error, "error");
res = await get(`/chats/${id}/messages`);
if (activated.error) return failed(activated);
res = await get(historyUrl);
if (openRequest !== state.chatOpenRequest) return;
if (res.error) return setNotice(res.error, "error");
if (res.error) return failed(res);
}
// Commit identity, content and drafts together, only for the latest successful load.
if (!preserveComposer && id !== state.activeChatId) {
@@ -66,7 +74,11 @@ export function createConversationController({
msgInput.value = state.chatDrafts.get(id) || "";
attachmentComposer.restoreDraft(id);
resizeComposerInput();
} else if (!msgInput.value && !attachmentComposer.pending && !attachmentComposer.attachments.length) {
} else {
if (msgInput.value || attachmentComposer.pending || attachmentComposer.attachments.length) {
state.chatDrafts.set("new", msgInput.value);
attachmentComposer.saveDraft(null);
}
msgInput.value = state.chatDrafts.get(id) || "";
attachmentComposer.restoreDraft(id);
resizeComposerInput();
@@ -75,7 +87,13 @@ export function createConversationController({
const existing = getChatById(id);
if (res.chat && existing) Object.assign(existing, res.chat);
state.activeChatId = id;
attachmentComposer.adoptDraft?.(id);
if (preserveComposer) state.chatDrafts.delete("new");
state.chatDrafts.set(id, msgInput.value);
state.currentMessages = res.messages || [];
state.historyPage = res.pagination || null;
state.historyLoading = false;
updateHistoryControls();
history.replaceState(null, "", `/app/chats/${id}`);
state.messageRetryState = new Map();
updateWorkspaceCopy(); renderChatList(); updateChatActionState();
@@ -88,14 +106,39 @@ export function createConversationController({
if (target) { target.scrollIntoView({block: "center"}); updateJumpToLatest(); }
});
updateSendState();
return {ok: true};
} finally {
if (openRequest === state.chatOpenRequest) {
opening = false;
setChatLoading(false);
setChatActivity(false);
}
}
}
async function loadOlderMessages() {
if (state.historyLoading || !state.historyPage?.hasMore || !state.activeChatId) return;
const chatId = state.activeChatId, requestId = state.chatOpenRequest, page = state.historyPage;
state.historyLoading = true;
updateHistoryControls();
try {
const result = await get(`/chats/${chatId}/messages?before=${page.before}&leaf=${page.leafId}`);
if (chatId !== state.activeChatId || requestId !== state.chatOpenRequest) return;
if (result.error) { setNotice(result.error, "error"); return result; }
const oldHeight = messagesDiv.scrollHeight, oldTop = messagesDiv.scrollTop;
const existing = new Set(state.currentMessages.map(message => message.id));
state.currentMessages = [...result.messages.filter(message => !existing.has(message.id)), ...state.currentMessages];
state.historyPage = {...result.pagination, hasNewer: page.hasNewer};
renderMessages({preserveScroll: true});
messagesDiv.scrollTop = oldTop + messagesDiv.scrollHeight - oldHeight;
} finally {
if (state.historyPage === page || requestId === state.chatOpenRequest) {
state.historyLoading = false;
updateHistoryControls();
}
}
}
async function loadChatSessions() {
setChatLoading(true, "Loading chats...");
const res = await get("/chats");
@@ -169,7 +212,8 @@ export function createConversationController({
state.chatSessions.unshift(res.chat);
await loadSummary(); renderChatList();
if (!signal?.aborted && state.activeChatId === previousChatId) {
await setActiveChat(res.chat.id, {preserveComposer});
const opened = await setActiveChat(res.chat.id, {preserveComposer});
if (opened?.error) return opened;
msgInput.focus();
}
setNotice("New chat created.", "success");
@@ -185,6 +229,7 @@ export function createConversationController({
setActiveChat,
loadChatSessions,
scheduleChatSearch,
createNewChat
createNewChat,
loadOlderMessages
};
}
+31
View File
@@ -0,0 +1,31 @@
import {openModal, closeModal} from "./modalFocus.js";
export function createConversationPanel(id, title) {
const overlay = document.createElement("div");
overlay.id = id;
overlay.className = "modal-overlay hidden";
overlay.innerHTML = `<section class="modal-card conversation-panel" role="dialog" aria-modal="true" aria-labelledby="${id}Title" tabindex="-1">
<div class="modal-header"><h3 id="${id}Title"></h3><button type="button" class="secondary-action">Close</button></div>
<p class="panel-notice status" role="status"></p><div class="panel-content"></div></section>`;
overlay.querySelector("h3").textContent = title;
document.body.append(overlay);
const close = () => closeModal(overlay);
overlay.querySelector("button").onclick = close;
overlay.onclick = event => { if (event.target === overlay) close(); };
return {element: overlay, content: overlay.querySelector(".panel-content"), close,
open() { if (overlay.classList.contains("hidden")) openModal(overlay); },
notice(text = "", error = false) {
const notice = overlay.querySelector(".panel-notice");
notice.textContent = text;
notice.classList.toggle("error", error);
}};
}
export function actionButton(label, action) {
const button = document.createElement("button");
button.type = "button";
button.className = "secondary-action";
button.textContent = label;
button.onclick = async () => { button.disabled = true; try { await action(); } finally { button.disabled = false; } };
return button;
}
+2
View File
@@ -9,6 +9,8 @@ export function createConversationState() {
regenerationChatId: null,
chatSessions: [],
currentMessages: [],
historyPage: null,
historyLoading: false,
assistantPersonas: [],
userPersonas: [],
activeUserPersonaId: null,
+37
View File
@@ -0,0 +1,37 @@
/** Text and uploaded attachment references only, scoped to the signed-in account. */
export function createDraftStore(username, {storage = globalThis.localStorage, onError = () => {}} = {}) {
const key = `krishd-drafts-v1:${encodeURIComponent(username)}`;
let records = {};
let warned = false;
const report = () => { if (!warned) onError("Draft recovery is unavailable in this browser. Keep this page open to retain your draft."); warned = true; };
try {
const saved = JSON.parse(storage.getItem(key) || "{}");
if (saved && typeof saved === "object" && !Array.isArray(saved)) records = saved;
} catch { report(); }
const normalizeId = id => id == null ? "new" : String(id);
const save = () => {
for (const [id, record] of Object.entries(records)) {
if (!record.text && !record.attachments?.length) delete records[id];
}
try { storage.setItem(key, JSON.stringify(records)); } catch { report(); }
};
const update = (id, changes) => {
id = normalizeId(id);
records[id] = {...records[id], ...changes};
save();
};
const textDrafts = new Map(Object.entries(records).filter(([, record]) => typeof record?.text === "string")
.map(([id, record]) => [id === "new" ? id : Number(id), record.text]));
const set = textDrafts.set.bind(textDrafts), remove = textDrafts.delete.bind(textDrafts);
textDrafts.set = (id, text) => { update(id, {text}); return set(id, text); };
textDrafts.delete = id => { update(id, {text: ""}); return remove(id); };
return {
textDrafts,
attachments(id) {
const items = records[normalizeId(id)]?.attachments;
return Array.isArray(items) ? items.filter(item => typeof item?.id === "string" && /^[a-f0-9-]{36}$/.test(item.id)) : [];
},
setAttachments(id, attachments) { update(id, {attachments}); },
clear(id) { delete records[normalizeId(id)]; remove(id); save(); }
};
}
+82
View File
@@ -0,0 +1,82 @@
import {get, post, put, del} from "./api.js";
import {createConversationPanel, actionButton} from "./conversationPanel.js";
export function createMemoryPanel({state}) {
const panel = createConversationPanel("memoryPanel", "Conversation memory");
let chatId, request = 0;
function field(label, value = "", maxLength = 2000, key = "") {
const wrapper = document.createElement("label"); wrapper.className = "field";
const caption = document.createElement("span"); caption.textContent = label;
const input = document.createElement("textarea"); input.value = value; input.maxLength = maxLength; input.rows = 3;
input.dataset.draftKey = key; input.dataset.savedValue = value;
wrapper.append(caption, input);
return {wrapper, input};
}
async function load(edits = new Map()) {
const current = ++request, id = chatId;
panel.notice("Loading memory...");
const result = await get(`/chats/${id}/memories`);
if (current !== request) return;
if (result.error) { panel.notice(result.error, true); return result; }
panel.notice();
panel.content.replaceChildren();
const usage = document.createElement("p"); usage.className = "memory-context subtitle";
const context = result.context;
usage.textContent = `Estimated context: ${context.estimatedTokens.toLocaleString()} tokens${context.contextLength ? ` of ${context.contextLength.toLocaleString()}` : " (model context size not set)"}. Includes saved facts, summary, instructions and up to ${context.recentMessageLimit} recent messages. Token counts are approximate; your next message and new search results add to this.`;
panel.content.append(usage);
if (context.contextLength) {
const meter = document.createElement("meter"); meter.min = 0; meter.max = context.contextLength;
meter.value = Math.min(context.estimatedTokens, context.contextLength);
meter.setAttribute("aria-label", "Estimated context usage"); panel.content.append(meter);
}
const run = async (operation, savedKey) => {
const unsaved = new Map([...panel.content.querySelectorAll("textarea")]
.filter(input => input.value !== input.dataset.savedValue).map(input => [input.dataset.draftKey, input.value]));
const controls = [...panel.content.querySelectorAll("button, textarea")];
controls.forEach(control => { control.disabled = true; });
const response = await operation;
if (current !== request) return;
if (response.error) {
controls.forEach(control => { control.disabled = false; });
return panel.notice(response.error, true);
}
unsaved.delete(savedKey);
const refreshed = await load(unsaved);
if (refreshed?.error) {
controls.forEach(control => { control.disabled = false; });
return panel.notice(`Memory saved, but it could not be refreshed: ${refreshed.error}`, true);
}
panel.notice("Memory saved.");
};
const summary = field("Running summary", result.summary, result.summaryMaxChars, "summary");
summary.input.rows = 6;
const summaryHelp = document.createElement("p"); summaryHelp.className = "subtitle";
summaryHelp.textContent = "Correct outdated information here. Future automatic summaries build on your corrections. Clear the field and save to remove the summary.";
panel.content.append(summary.wrapper, summaryHelp, actionButton("Save summary", () => run(put(`/chats/${id}/summary`, {summary: summary.input.value, previousSummary: result.summary}), "summary")));
const heading = document.createElement("h4"); heading.textContent = "Saved facts";
const hint = document.createElement("p"); hint.className = "subtitle";
hint.textContent = `The ${context.memoryFactLimit} most recently updated facts are included in context. Edit or remove facts that are no longer accurate.`;
panel.content.append(heading, hint);
for (const [index, memory] of result.memories.entries()) {
const row = document.createElement("section"); row.className = "memory-fact";
const key = `fact-${memory.id}`;
const fact = field(`Fact ${index + 1}${index >= context.memoryFactLimit ? " (outside current context)" : ""}`, memory.fact, 800, key);
const actions = document.createElement("div"); actions.className = "panel-actions";
actions.append(actionButton("Save fact", () => run(put(`/chats/${id}/memories/${memory.id}`, {fact: fact.input.value}), key)),
actionButton("Remove fact", () => run(del(`/chats/${id}/memories/${memory.id}`), key)));
row.append(fact.wrapper, actions); panel.content.append(row);
}
const fresh = field("New fact", "", 800, "new");
panel.content.append(fresh.wrapper, actionButton("Add fact", () => run(post(`/chats/${id}/memories`, {fact: fresh.input.value}), "new")));
for (const input of panel.content.querySelectorAll("textarea")) {
if (edits.has(input.dataset.draftKey)) input.value = edits.get(input.dataset.draftKey);
}
return {ok: true};
}
return {async open() {
if (!state.activeChatId) return;
chatId = state.activeChatId;
panel.content.replaceChildren(); panel.open();
await load();
}};
}
+256
View File
@@ -0,0 +1,256 @@
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,
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);
}
});
webSearchToggle.reset();
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};
}
+2 -2
View File
@@ -222,7 +222,7 @@ export function createMessageRenderer({
return msgDiv;
}
function renderMessages() {
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;
@@ -256,7 +256,7 @@ export function createMessageRenderer({
renderDrawerSummary();
updateChatActionState();
updateComposerPlaceholder();
if (wasReadingEarlier) {
if (wasReadingEarlier || preserveScroll) {
messagesDiv.scrollTop = previousScrollTop;
updateJumpToLatest();
} else if (state.currentMessages.length) {
+45 -410
View File
@@ -1,3 +1,7 @@
import {createMemoryPanel} from "./memoryPanel.js";
import {createBranchExplorer} from "./branchExplorer.js";
import {createWorkspaceTransfer} from "./workspaceTransfer.js";
import {createDraftStore} from "./draftStore.js";
import {createMessageRenderer} from "./messageRenderer.js";
import {createPersonaController} from "./personaController.js";
import {createConversationController} from "./conversationController.js";
@@ -7,7 +11,7 @@ import {createConversationState} from "./conversationState.js";
* @file Steuert die Chat-Oberfläche mit Gesprächsauswahl, Nachrichteneingabe, Streaming und Persona-Verwaltung.
*/
import {del, get, post, put} from "./api.js?v=20260909-account-controls";
import { regenerateInline } from "./regeneration.js";
import {createMessageActions} from "./messageActions.js";
import {createWebSearchToggle} from "./webSearch.js";
import {createAttachmentComposer} from "./attachments.js?v=20260930-chat-usability";
import {createReasoningToggle} from "./reasoning.js?v=20260921-reasoning-capability";
@@ -168,7 +172,8 @@ const {
setActiveChat,
loadChatSessions,
scheduleChatSearch,
createNewChat
createNewChat,
loadOlderMessages
} = createConversationController({
state: conversation,
renderChatList: (...args) => renderChatList(...args),
@@ -185,7 +190,8 @@ const {
renderMessages: (...args) => renderMessages(...args),
updateJumpToLatest: (...args) => updateJumpToLatest(...args),
updateSendState: (...args) => updateSendState(...args),
loadSummary: (...args) => loadSummary(...args)
loadSummary: (...args) => loadSummary(...args),
updateHistoryControls: () => updateHistoryControls()
});
const {updateSendState, resizeComposerInput, setLoadingState, stopGeneration, sendMessage} = createComposerController({
@@ -212,6 +218,16 @@ const {updateSendState, resizeComposerInput, setLoadingState, stopGeneration, se
maybeShowPersonaNudge: (...args) => maybeShowPersonaNudge(...args)
});
const {exportCurrentChat, backupWorkspace, importWorkspaceFromFile} = createWorkspaceTransfer({
conversation, getChatById, setNotice, promptPopup, confirmPopup, setChatLoading, loadPersonas, loadChatSessions
});
const {editChatMessage, deleteChatMessage, retryLatestAssistantMessage, switchResponseVersion, switchRetryVariant, branchFromMessage} = createMessageActions({
conversation, setNotice, promptPopup, confirmPopup, setActiveChat, openCreatedChat, setLoadingState, loadSummary, renderMessages, addMessage, setMessageContent, webSearchToggle, reasoningToggle, modelSelect, messagesDiv, msgInput
});
const memoryPanel = createMemoryPanel({state: conversation});
const branchExplorer = createBranchExplorer({state: conversation, setActiveChat, setMessageContent});
function setNotice(message = "", state = "") {
const notice = document.getElementById("workspaceNotice");
if (notice) { notice.textContent = state === "error" ? message : ""; notice.classList.toggle("hidden", state !== "error"); }
@@ -425,255 +441,6 @@ async function saveSelectedModel() {
messagesDiv.addEventListener("scroll", updateJumpToLatest, {passive: true});
document.getElementById("jumpToLatest").addEventListener("click", scrollMessagesToBottom);
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,
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);
}
});
webSearchToggle.reset();
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 || [];
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");
}
function updateWorkspaceCopy() {
const activeChat = getChatById(conversation.activeChatId);
if (activeChatTitle) {
@@ -758,6 +525,7 @@ function applyThemeMode(modeKey, persist = true) {
}
function updateChatActionState() {
updateHistoryControls();
const activeChat = getChatById(conversation.activeChatId);
const hasChat = Boolean(activeChat);
renameChatBtn.disabled = !hasChat || Boolean(activeChat?.assistant_persona_id);
@@ -767,6 +535,7 @@ function updateChatActionState() {
}
if (moveChatFolderBtn) moveChatFolderBtn.disabled = !hasChat;
if (manageChatMemoryBtn) manageChatMemoryBtn.disabled = !hasChat;
document.getElementById("exploreBranches").disabled = !hasChat;
if (managePersonaStateBtn) managePersonaStateBtn.disabled = !hasChat || !activeChat?.assistant_persona_id;
if (archiveChatBtn) {
archiveChatBtn.disabled = !hasChat;
@@ -779,6 +548,14 @@ function updateChatActionState() {
if (headerModelButton) headerModelButton.disabled = !hasChat;
}
function updateHistoryControls() {
const older = document.getElementById("loadOlderMessages");
older.hidden = !conversation.historyPage?.hasMore;
older.disabled = conversation.historyLoading;
older.textContent = conversation.historyLoading ? "Loading messages..." : "Load older messages";
document.getElementById("loadLatestMessages").hidden = !conversation.historyPage?.hasNewer;
}
function openModelSettings() {
if (!conversation.activeChatId || !modelSection) return;
modelSection.classList.remove("hidden");
@@ -982,165 +759,10 @@ async function clearChat(id) {
const res = await post(`/chats/${id}/clear`, {});
if (res.error) return setNotice(res.error, "error");
conversation.messageRetryState = new Map();
conversation.historyPage = null;
conversation.currentMessages = []; renderMessages(); await loadSummary(); setNotice("Chat cleared.", "success");
}
function exportCurrentChat() {
const chat = getChatById(conversation.activeChatId);
if (!chat || !conversation.currentMessages.length) return;
void (async () => {
const format = await promptPopup({
eyebrow: "Export",
title: "Export format",
description: "Use txt, md, or json.",
label: "Format",
value: "md",
placeholder: "md",
confirmLabel: "Download"
});
const normalized = ["txt", "md", "json"].includes(String(format || "").trim().toLowerCase())
? String(format).trim().toLowerCase()
: "md";
const response = await fetch(`/exports/chats/${conversation.activeChatId}?format=${normalized}`);
const blob = await response.blob();
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = `${(chat.title.replace(/[^a-z0-9-_]+/gi, "-").replace(/^-+|-+$/g, "") || "chat")}.${normalized}`;
document.body.appendChild(link);
link.click();
link.remove();
URL.revokeObjectURL(url);
setNotice(`Chat exported as ${normalized}.`, "success");
})();
}
async function backupWorkspace() {
const response = await fetch("/exports/workspace/archive");
if (!response.ok) return setNotice("Workspace archive could not be created.", "error");
const blob = await response.blob();
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = `workspace-backup-${new Date().toISOString().slice(0, 10)}.tar.gz`;
document.body.appendChild(link);
link.click();
link.remove();
URL.revokeObjectURL(url);
setNotice("Workspace archive downloaded with images.", "success");
}
function formatImportPreview(preview, warnings = []) {
const counts = [
`${preview.personas || 0} persona${preview.personas === 1 ? "" : "s"}`,
`${preview.chats || 0} chat${preview.chats === 1 ? "" : "s"}`,
`${preview.messages || 0} message${preview.messages === 1 ? "" : "s"}`,
`${preview.templates || 0} template${preview.templates === 1 ? "" : "s"}`
].join(", ");
const warningText = warnings.length ? ` Warnings: ${warnings.slice(0, 4).join("; ")}${warnings.length > 4 ? "; ..." : ""}` : "";
return `Import preview: ${counts}.${warningText}`;
}
async function importWorkspaceFromFile(file) {
if (!file) return;
setChatLoading(true, "Reading workspace backup...");
const isArchive = file.name.toLowerCase().endsWith(".tar.gz");
if (isArchive) {
const submit = async (path, allowDuplicates = false) => {
const body = new FormData();
body.append("archive", file);
if (allowDuplicates) body.append("allowDuplicates", "true");
const response = await fetch(path, {method: "POST", body});
return response.json();
};
try {
const preview = await submit("/imports/workspace/archive/preview");
setChatLoading(false);
if (preview.error || preview.ok === false) return setNotice((preview.errors || []).slice(0, 4).join("; ") || preview.error || "Invalid archive.", "error");
const confirmed = await confirmPopup({eyebrow: "Workspace import", title: "Review import",
description: formatImportPreview(preview.preview || {}, preview.warnings || []), confirmLabel: "Import"});
if (!confirmed) return;
setChatLoading(true, "Importing workspace and images...");
const result = await submit("/imports/workspace/archive", Boolean(preview.preview?.duplicates?.length));
if (result.error) return setNotice(result.error, "error");
await Promise.all([loadPersonas(), loadChatSessions()]);
setNotice(`Workspace imported: ${result.imported.chats} chats with their images.`, "success");
} catch { setNotice("Workspace archive could not be imported.", "error"); }
finally { setChatLoading(false); }
return;
}
let workspace;
try {
workspace = JSON.parse(await file.text());
} catch {
setChatLoading(false);
return setNotice("Import failed: choose a valid JSON workspace backup.", "error");
}
const preview = await post("/imports/workspace/preview", { workspace });
if (preview.error || preview.ok === false) {
setChatLoading(false);
const detail = (preview.errors || []).slice(0, 4).join("; ");
return setNotice(detail || preview.error || "Workspace import validation failed.", "error");
}
setChatLoading(false);
const confirmed = await confirmPopup({
eyebrow: "Workspace import",
title: "Review import",
description: formatImportPreview(preview.preview || {}, preview.warnings || []),
confirmLabel: "Import"
});
if (!confirmed) return;
setChatLoading(true, "Importing workspace...");
const imported = await post("/imports/workspace", {
workspace,
allowDuplicates: Boolean(preview.preview?.duplicates?.length)
});
if (imported.error) {
setChatLoading(false);
const detail = (imported.errors || imported.warnings || []).slice(0, 4).join("; ");
return setNotice(detail || imported.error, "error");
}
await Promise.all([loadPersonas(), loadChatSessions()]);
setChatLoading(false);
setNotice(`Workspace imported: ${imported.imported.personas} personas, ${imported.imported.chats} chats, ${imported.imported.templates} templates.`, "success");
}
function formatMemoryList(memories) {
if (!memories.length) return "No memory facts saved for this chat yet.";
return memories.map((memory, index) => `${index + 1}. ${memory.fact}`).join("\n");
}
async function manageChatMemory() {
if (!conversation.activeChatId) return;
const loaded = await get(`/chats/${conversation.activeChatId}/memories`);
if (loaded.error) return setNotice(loaded.error, "error");
const memories = loaded.memories || [];
const action = await promptPopup({
eyebrow: "Memory",
title: "Memory facts",
description: `${formatMemoryList(memories)}\n\nType a new fact to add it, or type delete 1 to remove a fact.`,
label: "Fact or command",
placeholder: "The user prefers terse answers.",
confirmLabel: "Save"
});
const value = String(action || "").trim();
if (!value) return;
const deleteMatch = value.match(/^delete\s+(\d+)$/i);
if (deleteMatch) {
const target = memories[Number(deleteMatch[1]) - 1];
if (!target) return setNotice("Memory number not found.", "error");
const removed = await del(`/chats/${conversation.activeChatId}/memories/${target.id}`);
if (removed.error) return setNotice(removed.error, "error");
return setNotice("Memory fact deleted.", "success");
}
const saved = await post(`/chats/${conversation.activeChatId}/memories`, { fact: value });
if (saved.error) return setNotice(saved.error, "error");
return setNotice("Memory fact saved.", "success");
}
const personaStateFields = [
["relationship_notes", "Relationship notes"],
["tone", "Tone"],
@@ -1256,10 +878,18 @@ async function checkSession() {
const res = await get("/session");
if (!res.user) { window.location.replace(`/login?next=${encodeURIComponent(location.pathname + location.search)}`); return false; }
conversation.currentUsername = res.user; chatDiv.classList.remove("hidden");
const drafts = createDraftStore(res.user, {onError: message => setNotice(message, "error")});
conversation.chatDrafts = drafts.textDrafts;
attachmentComposer.setDraftStore(drafts);
if (location.pathname.endsWith("/new")) {
msgInput.value = drafts.textDrafts.get("new") || "";
attachmentComposer.restoreDraft(null);
resizeComposerInput();
}
await Promise.all([displayModels(), loadSummary(), loadChatSessions(), loadPersonas()]);
// Starters are offered on workspace home.
const starter = new URLSearchParams(location.search).get("prompt");
if (starter) { msgInput.value = starter; resizeComposerInput(); updateSendState(); }
if (starter && !msgInput.value) { msgInput.value = starter; msgInput.dispatchEvent(new Event("input")); }
msgInput.focus();
if (!starter && !conversation.activeChatId) maybeShowOnboarding();
return true;
@@ -1291,8 +921,9 @@ const chatActions = {
await updateChatOrganization(chat.id, { folderName: String(folderName || "").trim() || null });
},
memory: async () => {
await manageChatMemory();
await memoryPanel.open();
},
branches: () => branchExplorer.open(),
personaState: async () => {
await managePersonaState();
},
@@ -1318,9 +949,12 @@ function bindChatDrawerActions() {
sendBtn.addEventListener("click", () => { void (conversation.isProcessing ? stopGeneration() : sendMessage()); });
msgInput.addEventListener("keydown", (event) => { if (event.key === "Enter" && !event.shiftKey && !event.isComposing) { event.preventDefault(); void sendMessage(); } });
msgInput.addEventListener("input", function () {
if (conversation.currentUsername) conversation.chatDrafts.set(conversation.activeChatId ?? "new", msgInput.value);
resizeComposerInput();
updateSendState();
});
document.getElementById("loadOlderMessages").addEventListener("click", () => { void loadOlderMessages(); });
document.getElementById("loadLatestMessages").addEventListener("click", () => { void setActiveChat(conversation.activeChatId); });
resizeComposerInput();
newChatBtn.addEventListener("click", () => {
closeChatDrawer();
@@ -1458,6 +1092,7 @@ document.addEventListener("keydown", (event) => {
else if (activeModal === personaModal) closePersonaForm();
else if (activeModal === roleplayStarterModal) closeRoleplayStarter();
else if (activeModal === onboardingModal) closeOnboarding();
else closeModal(activeModal);
return;
}
const openMenu = document.querySelector(".msg-action-menu[open]");
+127
View File
@@ -0,0 +1,127 @@
import {download, multipart, post} from "./api.js";
export function createWorkspaceTransfer({conversation, getChatById, setNotice, promptPopup, confirmPopup, setChatLoading, loadPersonas, loadChatSessions}) {
async function exportCurrentChat() {
const chat = getChatById(conversation.activeChatId);
if (!chat || !conversation.currentMessages.length) return;
const format = await promptPopup({
eyebrow: "Export",
title: "Export format",
description: "Use txt, md, or json.",
label: "Format",
value: "md",
placeholder: "md",
confirmLabel: "Download"
});
if (format === null) return;
const normalized = ["txt", "md", "json"].includes(String(format || "").trim().toLowerCase())
? String(format).trim().toLowerCase()
: "md";
const response = await download(`/exports/chats/${chat.id}?format=${normalized}`);
if (response.error) return setNotice(response.error, "error");
const {blob} = response;
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = `${(chat.title.replace(/[^a-z0-9-_]+/gi, "-").replace(/^-+|-+$/g, "") || "chat")}.${normalized}`;
document.body.appendChild(link);
link.click();
link.remove();
URL.revokeObjectURL(url);
setNotice(`Chat exported as ${normalized}.`, "success");
}
async function backupWorkspace() {
const response = await download("/exports/workspace/archive");
if (response.error) return setNotice(response.error, "error");
const {blob} = response;
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = `workspace-backup-${new Date().toISOString().slice(0, 10)}.tar.gz`;
document.body.appendChild(link);
link.click();
link.remove();
URL.revokeObjectURL(url);
setNotice("Workspace archive downloaded with images.", "success");
}
function formatImportPreview(preview, warnings = []) {
const counts = [
`${preview.personas || 0} persona${preview.personas === 1 ? "" : "s"}`,
`${preview.chats || 0} chat${preview.chats === 1 ? "" : "s"}`,
`${preview.messages || 0} message${preview.messages === 1 ? "" : "s"}`,
`${preview.templates || 0} template${preview.templates === 1 ? "" : "s"}`
].join(", ");
const warningText = warnings.length ? ` Warnings: ${warnings.slice(0, 4).join("; ")}${warnings.length > 4 ? "; ..." : ""}` : "";
return `Import preview: ${counts}.${warningText}`;
}
async function importWorkspaceFromFile(file) {
if (!file) return;
setChatLoading(true, "Reading workspace backup...");
const isArchive = file.name.toLowerCase().endsWith(".tar.gz");
if (isArchive) {
const submit = async (path, allowDuplicates = false) => {
const body = new FormData();
body.append("archive", file);
if (allowDuplicates) body.append("allowDuplicates", "true");
return multipart(path, body);
};
try {
const preview = await submit("/imports/workspace/archive/preview");
setChatLoading(false);
if (preview.error || preview.ok === false) return setNotice((preview.errors || []).slice(0, 4).join("; ") || preview.error || "Invalid archive.", "error");
const confirmed = await confirmPopup({eyebrow: "Workspace import", title: "Review import",
description: formatImportPreview(preview.preview || {}, preview.warnings || []), confirmLabel: "Import"});
if (!confirmed) return;
setChatLoading(true, "Importing workspace and images...");
const result = await submit("/imports/workspace/archive", Boolean(preview.preview?.duplicates?.length));
if (result.error) return setNotice(result.error, "error");
await Promise.all([loadPersonas(), loadChatSessions()]);
setNotice(`Workspace imported: ${result.imported.chats} chats with their images.`, "success");
} catch { setNotice("Workspace archive could not be imported.", "error"); }
finally { setChatLoading(false); }
return;
}
let workspace;
try {
workspace = JSON.parse(await file.text());
} catch {
setChatLoading(false);
return setNotice("Import failed: choose a valid JSON workspace backup.", "error");
}
const preview = await post("/imports/workspace/preview", { workspace });
if (preview.error || preview.ok === false) {
setChatLoading(false);
const detail = (preview.errors || []).slice(0, 4).join("; ");
return setNotice(detail || preview.error || "Workspace import validation failed.", "error");
}
setChatLoading(false);
const confirmed = await confirmPopup({
eyebrow: "Workspace import",
title: "Review import",
description: formatImportPreview(preview.preview || {}, preview.warnings || []),
confirmLabel: "Import"
});
if (!confirmed) return;
setChatLoading(true, "Importing workspace...");
const imported = await post("/imports/workspace", {
workspace,
allowDuplicates: Boolean(preview.preview?.duplicates?.length)
});
if (imported.error) {
setChatLoading(false);
const detail = (imported.errors || imported.warnings || []).slice(0, 4).join("; ");
return setNotice(detail || imported.error, "error");
}
await Promise.all([loadPersonas(), loadChatSessions()]);
setChatLoading(false);
setNotice(`Workspace imported: ${imported.imported.personas} personas, ${imported.imported.chats} chats, ${imported.imported.templates} templates.`, "success");
}
return {exportCurrentChat, backupWorkspace, importWorkspaceFromFile};
}