Add support for message history pagination, preserve scroll in rendering, and improve draft/message workflows.
This commit is contained in:
+16
-4
@@ -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
@@ -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}`);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
}};
|
||||
}
|
||||
@@ -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()) {
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -9,6 +9,8 @@ export function createConversationState() {
|
||||
regenerationChatId: null,
|
||||
chatSessions: [],
|
||||
currentMessages: [],
|
||||
historyPage: null,
|
||||
historyLoading: false,
|
||||
assistantPersonas: [],
|
||||
userPersonas: [],
|
||||
activeUserPersonaId: null,
|
||||
|
||||
@@ -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(); }
|
||||
};
|
||||
}
|
||||
@@ -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();
|
||||
}};
|
||||
}
|
||||
@@ -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};
|
||||
}
|
||||
@@ -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
@@ -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]");
|
||||
|
||||
@@ -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};
|
||||
}
|
||||
Reference in New Issue
Block a user