Add support for message history pagination, preserve scroll in rendering, and improve draft/message workflows.
This commit is contained in:
+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]");
|
||||
|
||||
Reference in New Issue
Block a user