make some overall QoL improvements

This commit is contained in:
Frederik Spirgi
2026-09-30 11:05:13 +02:00
parent d15f7867d5
commit c69070a6e6
18 changed files with 256 additions and 78 deletions
+56 -35
View File
@@ -7,6 +7,7 @@ import { appendSources, createWebSearchToggle } from "./webSearch.js";
import { appendAttachments, createAttachmentComposer } from "./attachments.js?v=20260930-chat-usability";
import { createReasoningToggle, finishReasoning, updateReasoning } from "./reasoning.js?v=20260921-reasoning-capability";
import { closeChatDrawer, openChatDrawer } from "./chatDrawer.js?v=20260909-account-controls";
import { openModal, closeModal, topModal } from "./modalFocus.js";
import { onboardingPrompts, requestedChatId } from "./constants.js?v=20260909-account-controls";
import { getModelProfile } from "./modelProfiles.js?v=20260909-account-controls";
import { applyTheme as applySharedTheme, applyThemeMode as applySharedThemeMode, readStoredAppearance } from "./themeController.js?v=20260909-account-controls";
@@ -243,7 +244,7 @@ function markOnboardingCompleted() {
function closeOnboarding() {
if (!onboardingModal) return;
onboardingModal.classList.add("hidden");
closeModal(onboardingModal);
}
function renderOnboardingStep() {
@@ -262,6 +263,7 @@ function renderOnboardingStep() {
button.addEventListener("click", () => {
onboardingIntent = key;
renderOnboardingStep();
onboardingChoices.querySelector(".active")?.focus();
});
onboardingChoices.appendChild(button);
});
@@ -299,7 +301,7 @@ function maybeShowOnboarding() {
onboardingStep = 1;
onboardingIntent = "ask";
renderOnboardingStep();
onboardingModal.classList.remove("hidden");
openModal(onboardingModal, onboardingChoices.querySelector("button"));
}
function setMessageContent(el, content) {
const markdownParser = window.marked;
@@ -311,11 +313,6 @@ function setMessageContent(el, content) {
}
}
function getChatById(id) { return chatSessions.find((chat) => chat.id === id); }
function summarizeText(value, limit = 96) {
const normalized = String(value || "").replace(/\s+/g, " ").trim();
if (!normalized) return "";
return normalized.length > limit ? `${normalized.slice(0, limit - 3).trimEnd()}...` : normalized;
}
function getPersonaName(id, items) {
return items.find((persona) => persona.id === id)?.name || "";
}
@@ -434,10 +431,6 @@ function updateJumpToLatest() {
messagesDiv.addEventListener("scroll", updateJumpToLatest, {passive: true});
jumpToLatest.addEventListener("click", scrollMessagesToBottom);
function getNewestMessage() {
return currentMessages.length ? currentMessages[currentMessages.length - 1] : null;
}
function getMessageIndexById(messageId) {
return currentMessages.findIndex((message) => message.id === messageId);
}
@@ -505,15 +498,6 @@ function syncRetryStateFromMessage(message) {
return state;
}
function ensureRetryState(message) {
if (!message?.id) return null;
let state = messageRetryState.get(message.id);
if (!state) {
state = syncRetryStateFromMessage(message);
}
return state;
}
async function openCreatedChat(chat) {
if (!chat?.id) throw new Error("The server did not return the newly created chat");
if (chatSearchInput) {
@@ -587,7 +571,7 @@ async function editChatMessage(messageId, fallbackIndex = -1) {
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 { index, message } = target;
const { message } = target;
const confirmed = await confirmPopup({
eyebrow: message.role === "user" ? "Your message" : "Assistant message",
title: "Delete message",
@@ -1246,7 +1230,7 @@ function updateActivePersonaStatus(items, activeId, statusEl, emptyText, prefix)
function openPersonaForm(persona = null, personaType = "assistant") {
closeChatDrawer();
closePopup(false);
personaModal.classList.remove("hidden");
openModal(personaModal, personaNameInput);
if (persona) {
editingPersonaId = persona.id; editingPersonaType = persona.persona_type || "assistant";
personaFormTitle.textContent = `Edit ${editingPersonaType === "user" ? "your persona" : "AI Character"}`;
@@ -1266,7 +1250,7 @@ function openPersonaForm(persona = null, personaType = "assistant") {
personaNameInput.focus();
}
function closePersonaForm() { personaModal.classList.add("hidden"); editingPersonaId = null; editingPersonaType = "assistant"; personaTypeSelect.disabled = false; setPersonaFormNotice(""); }
function closePersonaForm() { closeModal(personaModal); editingPersonaId = null; editingPersonaType = "assistant"; personaTypeSelect.disabled = false; setPersonaFormNotice(""); }
function populateRoleplayStarter() {
if (!roleplayCharacterSelect || !roleplayUserPersonaSelect) return;
roleplayCharacterSelect.innerHTML = "";
@@ -1315,12 +1299,11 @@ function openRoleplayStarter(characterId = null) {
populateRoleplayStarter();
if (roleplayScenarioInput) roleplayScenarioInput.value = "";
setRoleplayStarterNotice("");
roleplayStarterModal.classList.remove("hidden");
if (roleplayCharacterSelect.options.length) roleplayCharacterSelect.focus();
openModal(roleplayStarterModal, roleplayCharacterSelect);
}
function closeRoleplayStarter() {
if (!roleplayStarterModal) return;
roleplayStarterModal.classList.add("hidden");
closeModal(roleplayStarterModal);
roleplayPresetCharacterId = null;
roleplayStarterStep = 1;
roleplayStarterConfirm.disabled = false;
@@ -1349,7 +1332,7 @@ function finishPopup(result) {
}
function closePopup(resolveValue = null) {
if (!popupModal || popupModal.classList.contains("hidden")) return;
popupModal.classList.add("hidden");
closeModal(popupModal);
popupField.classList.add("hidden");
popupInput.value = "";
popupConfirmBtn.disabled = false;
@@ -1377,7 +1360,7 @@ function openPopup(options) {
} else {
popupInput.value = "";
}
popupModal.classList.remove("hidden");
openModal(popupModal, options.mode === "prompt" ? popupInput : popupConfirmBtn);
return new Promise((resolve) => {
popupResolver = resolve;
requestAnimationFrame(() => {
@@ -1801,18 +1784,18 @@ function exportCurrentChat() {
}
async function backupWorkspace() {
const response = await fetch("/exports/workspace");
const data = await response.json();
const blob = new Blob([JSON.stringify(data.workspace, null, 2)], { type: "application/json;charset=utf-8" });
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)}.json`;
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 backup downloaded.", "success");
setNotice("Workspace archive downloaded with images.", "success");
}
function formatImportPreview(preview, warnings = []) {
@@ -1829,6 +1812,31 @@ function formatImportPreview(preview, warnings = []) {
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());
@@ -2020,7 +2028,9 @@ async function checkSession() {
// Starters are offered on workspace home.
const starter = new URLSearchParams(location.search).get("prompt");
if (starter) { msgInput.value = starter; resizeComposerInput(); updateSendState(); }
msgInput.focus(); return true;
msgInput.focus();
if (!starter && !activeChatId) maybeShowOnboarding();
return true;
}
async function sendMessage({ retryMessage = null } = {}) {
@@ -2219,7 +2229,7 @@ if (headerModelButton) headerModelButton.addEventListener("click", (event) => {
openModelSettings();
});
saveGenerationSettingsBtn.addEventListener("click", () => { void saveGenerationSettings(); });
roleplayNewPersonaBtn.addEventListener("click", () => openPersonaForm(null, "assistant"));
roleplayNewPersonaBtn.addEventListener("click", () => { closeRoleplayStarter(); openPersonaForm(null, "assistant"); });
clearUserPersonaBtn.addEventListener("click", () => {
closeChatDrawer();
void clearPersona("user");
@@ -2281,12 +2291,14 @@ if (onboardingContinue) {
onboardingContinue.addEventListener("click", () => {
onboardingStep = 2;
renderOnboardingStep();
onboardingChoices.querySelector("button")?.focus();
});
}
if (onboardingBack) {
onboardingBack.addEventListener("click", () => {
onboardingStep = 1;
renderOnboardingStep();
(onboardingChoices.querySelector(".active") || onboardingChoices.querySelector("button"))?.focus();
});
}
if (onboardingSkip) {
@@ -2319,6 +2331,15 @@ document.addEventListener("keydown", (event) => {
}
}
if (event.key === "Escape") {
const activeModal = topModal();
if (activeModal) {
event.preventDefault();
if (activeModal === popupModal) closePopup(false);
else if (activeModal === personaModal) closePersonaForm();
else if (activeModal === roleplayStarterModal) closeRoleplayStarter();
else if (activeModal === onboardingModal) closeOnboarding();
return;
}
const openMenu = document.querySelector(".msg-action-menu[open]");
if (openMenu) {
openMenu.open = false;