/** * @file Steuert die Einstellungsoberfläche für Darstellung, Konto, Personas und Diagnosen. */ import { del, get, post, put, request } from "./api.js?v=20260909-account-controls"; import { applyTheme as applySharedTheme, applyThemeMode as applySharedThemeMode, initializeAppearance } from "./themeController.js?v=20260909-account-controls"; import { clearStatus, setStatus } from "./statusNotice.js?v=20260909-account-controls"; import { populatePersonaForm, readPersonaForm } from "./personaForm.js?v=20260909-account-controls"; import { openModal, closeModal, topModal } from "./modalFocus.js"; const $ = (id) => document.getElementById(id); const settingsNotice = $("settingsNotice"); const settingsThemeSelect = $("settingsThemeSelect"); const themeModeSelect = $("themeModeSelect"); const workspaceModeSelect = $("workspaceModeSelect"); const diagnosticsNavItem = $("diagnosticsNavItem"); const refreshDiagnosticsBtn = $("refreshDiagnostics"); const diagnosticsStatus = $("diagnosticsStatus"); const diagnosticsBackend = $("diagnosticsBackend"); const diagnosticsLatency = $("diagnosticsLatency"); const diagnosticsCheckedAt = $("diagnosticsCheckedAt"); const diagnosticsModelCount = $("diagnosticsModelCount"); const diagnosticsCacheAge = $("diagnosticsCacheAge"); const diagnosticsActiveRequests = $("diagnosticsActiveRequests"); const diagnosticsLastFailure = $("diagnosticsLastFailure"); const diagnosticsModels = $("diagnosticsModels"); const diagnosticsWarnings = $("diagnosticsWarnings"); const currentUsernameInput = $("currentUsername"); const newUsernameInput = $("newUsername"); const usernamePasswordInput = $("usernamePassword"); const currentPasswordInput = $("currentPassword"); const newPasswordInput = $("newPassword"); const repeatPasswordInput = $("repeatPassword"); const exportAccountBtn = $("exportAccount"); const logoutAllSessionsBtn = $("logoutAllSessions"); const deleteAccountBtn = $("deleteAccount"); const usernameForm = $("usernameForm"); const passwordForm = $("passwordForm"); const settingsNavItems = document.querySelectorAll("[data-settings-view]"); const settingsPanels = document.querySelectorAll("[data-settings-panel]"); const createAiCharacterBtn = $("createAiCharacter"); const createUserPersonaBtn = $("createUserPersona"); const settingsAiCharacterList = $("settingsAiCharacterList"); const settingsUserPersonaList = $("settingsUserPersonaList"); const settingsPersonaModal = $("settingsPersonaModal"); const settingsPersonaClose = $("settingsPersonaClose"); const settingsPersonaForm = $("settingsPersonaForm"); const settingsPersonaFormTitle = $("settingsPersonaFormTitle"); const settingsPersonaFormNotice = $("settingsPersonaFormNotice"); const settingsPersonaHelper = $("settingsPersonaHelper"); const settingsPersonaType = $("settingsPersonaType"); const settingsPersonaName = $("settingsPersonaName"); const settingsPersonaPronouns = $("settingsPersonaPronouns"); const settingsPersonaAppearance = $("settingsPersonaAppearance"); const settingsPersonaBackground = $("settingsPersonaBackground"); const settingsPersonaDetails = $("settingsPersonaDetails"); const settingsPersonaExamplesField = $("settingsPersonaExamplesField"); const settingsPersonaExampleDialogues = $("settingsPersonaExampleDialogues"); const settingsPopupModal = $("settingsPopupModal"); const settingsPopupClose = $("settingsPopupClose"); const settingsPopupCancel = $("settingsPopupCancel"); const settingsPopupConfirm = $("settingsPopupConfirm"); const settingsPopupTitle = $("settingsPopupTitle"); const settingsPopupEyebrow = $("settingsPopupEyebrow"); const settingsPopupDescription = $("settingsPopupDescription"); const settingsPopupInputLabel = $("settingsPopupInputLabel"); const settingsPopupInput = $("settingsPopupInput"); const settingsPersonaFields = { name: settingsPersonaName, pronouns: settingsPersonaPronouns, appearance: settingsPersonaAppearance, background: settingsPersonaBackground, details: settingsPersonaDetails, exampleDialogues: settingsPersonaExampleDialogues }; let assistantPersonas = []; let userPersonas = []; let publishedPersonaIds = new Set(); let editingPersonaId = null; let settingsPopupResolver = null; let isAdminUser = false; function setNotice(message, state = "") { setStatus(settingsNotice, message, state); } function setPersonaNotice(message = "", state = "") { if (!message) { clearStatus(settingsPersonaFormNotice); return; } setStatus(settingsPersonaFormNotice, message, state); } function closeSettingsPopup(value = null) { closeModal(settingsPopupModal); if (settingsPopupResolver) { const resolve = settingsPopupResolver; settingsPopupResolver = null; resolve(value); } } function promptSettingsPopup({ eyebrow = "Action", title = "Enter a value", description = "", label = "Value", value = "", placeholder = "", confirmLabel = "Confirm" } = {}) { settingsPopupEyebrow.textContent = eyebrow; settingsPopupTitle.textContent = title; settingsPopupDescription.textContent = description; settingsPopupInputLabel.textContent = label; settingsPopupInput.value = value; settingsPopupInput.placeholder = placeholder; settingsPopupConfirm.textContent = confirmLabel; openModal(settingsPopupModal, settingsPopupInput); settingsPopupInput.focus(); settingsPopupInput.select(); return new Promise((resolve) => { settingsPopupResolver = resolve; }); } function applyTheme(themeKey, persist = true) { return applySharedTheme(themeKey, { persist, title: "Elsewhere Settings", themeSelect: settingsThemeSelect }); } function applyThemeMode(modeKey, persist = true) { return applySharedThemeMode(modeKey, { persist, modeSelect: themeModeSelect }); } function setSettingsView(view) { const normalizedView = ["my-ai-characters", "my-personas"].includes(view) ? "my-roleplay-companions" : view; if (normalizedView === "my-roleplay-companions" && location.pathname !== "/app/personas") { location.assign("/app/personas" + location.search); return; } if (normalizedView === "diagnostics" && !isAdminUser) { setNotice("Diagnostics are available to admin users only.", "error"); return; } settingsNavItems.forEach((item) => { const isActive = item.dataset.settingsView === normalizedView; item.classList.toggle("active", isActive); item.setAttribute("aria-selected", String(isActive)); }); settingsPanels.forEach((panel) => { const isActive = panel.dataset.settingsPanel === normalizedView; panel.classList.toggle("active", isActive); panel.setAttribute("aria-hidden", String(!isActive)); }); const params = new URLSearchParams(window.location.search); params.set("view", normalizedView); history.replaceState(null, "", `${window.location.pathname}?${params.toString()}`); if (normalizedView === "diagnostics") void loadDiagnostics(); } function formatDuration(ms) { if (!Number.isFinite(ms)) return "n/a"; if (ms < 1000) return `${Math.round(ms)} ms`; return `${(ms / 1000).toFixed(1)} s`; } function renderDiagnosticsList(element, items, emptyText) { element.innerHTML = ""; if (!items.length) { const empty = document.createElement("p"); empty.className = "status persona-status"; empty.textContent = emptyText; element.appendChild(empty); return; } items.forEach((item) => { const row = document.createElement("div"); row.className = "diagnostics-list-item"; row.textContent = item; element.appendChild(row); }); } async function loadDiagnostics() { if (!isAdminUser) return false; diagnosticsStatus.textContent = "Checking..."; const res = await get("/admin/diagnostics"); if (res.error) { setNotice(res.error, "error"); diagnosticsStatus.textContent = "Unavailable"; return false; } const model = res.model || {}; const lastFailure = model.lastFailure; const activeRequests = (model.activeRequests || []).reduce((sum, item) => sum + Number(item.activeRequests || 0), 0); diagnosticsStatus.textContent = model.ok ? "Online" : "Degraded"; diagnosticsBackend.textContent = model.backendUrl || "Backend unavailable"; diagnosticsLatency.textContent = formatDuration(model.latencyMs ?? model.listLatencyMs); diagnosticsCheckedAt.textContent = model.checkedAt ? `Checked ${new Date(model.checkedAt).toLocaleString()}` : "Not checked yet"; diagnosticsModelCount.textContent = String(model.modelCount || 0); diagnosticsCacheAge.textContent = `Cache age ${formatDuration(model.cacheAgeMs)}`; diagnosticsActiveRequests.textContent = String(activeRequests); diagnosticsLastFailure.textContent = lastFailure ? `${lastFailure.error} (${new Date(lastFailure.at).toLocaleString()})` : "No recorded failures"; renderDiagnosticsList(diagnosticsModels, model.availableModels || [], "No models returned by the backend."); renderDiagnosticsList(diagnosticsWarnings, res.configWarnings || [], "No config warnings."); setNotice("Diagnostics loaded.", model.ok ? "success" : "error"); return true; } function formatMeta(persona) { return persona.pronouns ? `Pronouns: ${persona.pronouns}` : "Pronouns: n/a"; } function syncPersonaExamplesField() { const isAssistant = settingsPersonaType.value === "assistant"; settingsPersonaExamplesField.classList.toggle("hidden", !isAssistant); settingsPersonaHelper.textContent = isAssistant ? "Create the character you want to roleplay with." : "Create the persona you want to speak as in roleplay."; $("settingsPersonaSave").textContent = isAssistant ? "Save AI Character" : "Save persona"; } function openPersonaModal(persona = null, personaType = "assistant") { editingPersonaId = persona ? persona.id : null; settingsPersonaType.disabled = Boolean(persona); settingsPersonaType.value = persona ? persona.persona_type : personaType; settingsPersonaFormTitle.textContent = persona ? `Edit ${settingsPersonaType.value === "assistant" ? "AI Character" : "persona"}` : `Create ${personaType === "assistant" ? "AI Character" : "persona"}`; populatePersonaForm(settingsPersonaFields, persona || {}); syncPersonaExamplesField(); setPersonaNotice(""); openModal(settingsPersonaModal, settingsPersonaName); } function closePersonaModal() { closeModal(settingsPersonaModal); editingPersonaId = null; setPersonaNotice(""); } function renderPersonaList(personas, listElement, type) { listElement.innerHTML = ""; if (!personas.length) { const empty = document.createElement("div"); const message = document.createElement("p"); const button = document.createElement("button"); empty.className = "persona-empty-state"; message.className = "status persona-status"; message.textContent = type === "assistant" ? "No AI Characters yet." : "No user personas yet."; button.type = "button"; button.className = "secondary-action"; button.textContent = type === "assistant" ? "Create AI Character" : "Create user persona"; button.addEventListener("click", () => openPersonaModal(null, type)); empty.append(message, button); listElement.appendChild(empty); return; } personas.forEach((persona) => { const item = document.createElement("div"); const titleRow = document.createElement("div"); const title = document.createElement("h4"); const tag = document.createElement("span"); const meta = document.createElement("p"); const actions = document.createElement("div"); item.className = "persona-item"; titleRow.className = "persona-title-row"; actions.className = "persona-item-actions"; tag.className = `persona-role-tag ${type === "assistant" ? "ai" : "user"}`; tag.textContent = type === "assistant" ? "AI Character" : "User"; title.textContent = persona.name; meta.textContent = formatMeta(persona); [ ["Edit", () => openPersonaModal(persona)], ["Clone", () => clonePersona(persona.id)], ["History", () => showPersonaHistory(persona.id)], [publishedPersonaIds.has(persona.id) ? "Update listing" : "Publish", () => publishPersona(persona.id)], ["Delete", () => deletePersona(persona.id)] ] .filter(([label]) => Boolean(label)) .forEach(([label, handler]) => { const button = document.createElement("button"); button.type = "button"; button.textContent = label; button.addEventListener("click", handler); actions.appendChild(button); }); if (publishedPersonaIds.has(persona.id)) { const unpublishButton = document.createElement("button"); unpublishButton.type = "button"; unpublishButton.textContent = "Unpublish"; unpublishButton.addEventListener("click", () => unpublishPersona(persona.id)); actions.appendChild(unpublishButton); } titleRow.append(title, tag); item.append(titleRow, meta, actions); listElement.appendChild(item); }); } async function loadProfile() { const res = await get("/settings/profile"); if (res.error) { setNotice(res.error, "error"); return false; } const accountName = document.querySelector(".shell-username"); if (accountName) { accountName.textContent = res.username || ""; accountName.title = res.username || ""; } currentUsernameInput.value = res.username || ""; newUsernameInput.value = res.username || ""; isAdminUser = res.role === "admin"; diagnosticsNavItem.classList.toggle("hidden", !isAdminUser); return true; } async function loadPersonas() { const res = await get("/personas"); if (res.error) { setNotice(res.error, "error"); return false; } assistantPersonas = res.assistantPersonas || []; userPersonas = res.userPersonas || []; publishedPersonaIds = new Set(res.publishedPersonaIds || []); renderPersonaList(assistantPersonas, settingsAiCharacterList, "assistant"); renderPersonaList(userPersonas, settingsUserPersonaList, "user"); return true; } async function savePersona() { const wasEditing = Boolean(editingPersonaId); const payload = { ...readPersonaForm(settingsPersonaFields), personaType: settingsPersonaType.value, exampleDialogues: settingsPersonaType.value === "assistant" ? settingsPersonaExampleDialogues.value.trim() : "" }; if (!payload.name) { setPersonaNotice("Name is required.", "error"); return; } const res = editingPersonaId ? await put(`/personas/${editingPersonaId}`, payload) : await post("/personas", payload); if (res.error) { setPersonaNotice(res.error, "error"); return; } await loadPersonas(); closePersonaModal(); setSettingsView("my-roleplay-companions"); setNotice(wasEditing ? "Persona updated." : "Persona created.", "success"); } async function deletePersona(id) { const res = await del(`/personas/${id}`); if (res.error) { setNotice(res.error, "error"); return; } await loadPersonas(); setNotice("Persona deleted.", "success"); } async function publishPersona(id) { const tagsInput = await promptSettingsPopup({ eyebrow: "Marketplace", title: "Persona tags", description: "Add optional comma-separated tags to improve discovery.", label: "Tags", placeholder: "mentor, fantasy, villain", confirmLabel: "Publish" }); if (tagsInput === null) return; const tags = String(tagsInput || "").split(",").map((tag) => tag.trim()).filter(Boolean); const res = await post(`/personas/${id}/publish`, { tags }); if (res.error) { setNotice(res.error, "error"); return; } await loadPersonas(); setNotice("Persona published.", "success"); } async function clonePersona(id) { const res = await post(`/personas/${id}/clone`, {}); if (res.error) return setNotice(res.error, "error"); await loadPersonas(); setNotice("Persona cloned.", "success"); } async function showPersonaHistory(id) { const res = await get(`/personas/${id}/versions`); if (res.error) return setNotice(res.error, "error"); const versions = res.versions || []; const summary = versions.length ? versions.map((version) => `v${version.version_number} - ${new Date(version.created_at).toLocaleString()}`).join("\n") : "No saved versions yet."; const choice = await promptSettingsPopup({ eyebrow: "Persona history", title: "Restore a version", description: summary, label: "Version number", placeholder: "1", confirmLabel: "Restore" }); if (choice === null) return; const selected = Number(choice); if (!selected) return; const version = versions.find((item) => item.version_number === selected); if (!version) return setNotice("Version not found.", "error"); const restore = await post(`/personas/${id}/versions/${version.id}/restore`, {}); if (restore.error) return setNotice(restore.error, "error"); await loadPersonas(); setNotice(`Restored persona version ${selected}.`, "success"); } async function unpublishPersona(id) { const res = await post(`/personas/${id}/unpublish`, {}); if (res.error) { setNotice(res.error, "error"); return; } await loadPersonas(); setNotice("Persona unpublished.", "success"); } function downloadJson(filename, payload) { const blob = new Blob([JSON.stringify(payload, null, 2)], { type: "application/json;charset=utf-8" }); const url = URL.createObjectURL(blob); const link = document.createElement("a"); link.href = url; link.download = filename; document.body.appendChild(link); link.click(); link.remove(); URL.revokeObjectURL(url); } async function exportAccount() { const res = await get("/settings/export"); if (res.error) return setNotice(res.error, "error"); const username = currentUsernameInput.value || "account"; downloadJson(`account-export-${username}-${new Date().toISOString().slice(0, 10)}.json`, res.account); setNotice("Account export downloaded.", "success"); } async function logoutAllSessions() { const confirmed = await promptSettingsPopup({ eyebrow: "Security", title: "Logout all sessions", description: "Type logout to sign out this browser and every other active session.", label: "Confirmation", placeholder: "logout", confirmLabel: "Logout all" }); if (String(confirmed || "").trim().toLowerCase() !== "logout") return; const res = await post("/settings/logout-all-sessions", {}); if (res.error) return setNotice(res.error, "error"); window.location.href = "/"; } async function deleteAccount() { const currentPassword = await promptSettingsPopup({ eyebrow: "Danger zone", title: "Delete account", description: "Enter your current password to permanently delete your account and all account data.", label: "Current password", placeholder: "Password", confirmLabel: "Delete account" }); if (currentPassword === null) return; const res = await request("/settings/account", { currentPassword }, "DELETE"); if (res.error) return setNotice(res.error, "error"); window.location.href = "/"; } usernameForm.addEventListener("submit", async (event) => { event.preventDefault(); const username = newUsernameInput.value.trim(); const password = usernamePasswordInput.value; if (!username || !password) { setNotice("New username and current password are required.", "error"); return; } const res = await request("/settings/username", { username, password }, "PUT"); if (res.error) { setNotice(res.error, "error"); return; } usernamePasswordInput.value = ""; if (!await loadProfile()) return; setNotice("Username updated.", "success"); }); passwordForm.addEventListener("submit", async (event) => { event.preventDefault(); const currentPassword = currentPasswordInput.value; const nextPassword = newPasswordInput.value; const repeatPassword = repeatPasswordInput.value; if (!currentPassword || !nextPassword || !repeatPassword) { setNotice("Fill in all password fields.", "error"); return; } if (nextPassword !== repeatPassword) { setNotice("New passwords do not match.", "error"); return; } const res = await request("/settings/password", { currentPassword, newPassword: nextPassword }, "PUT"); if (res.error) { setNotice(res.error, "error"); return; } currentPasswordInput.value = ""; newPasswordInput.value = ""; repeatPasswordInput.value = ""; setNotice("Password updated.", "success"); }); settingsThemeSelect.addEventListener("change", (event) => { applyTheme(event.target.value); setNotice("Style updated.", "success"); }); themeModeSelect.addEventListener("change", (event) => { applyThemeMode(event.target.value); setNotice(`Appearance set to ${document.body.dataset.themeMode}.`, "success"); }); workspaceModeSelect.addEventListener("change", (event) => { const mode = event.target.value === "advanced" ? "advanced" : "basic"; localStorage.setItem("krishd-workspace-mode", mode); setNotice(`Workspace mode set to ${mode}.`, "success"); }); exportAccountBtn.addEventListener("click", () => { void exportAccount(); }); logoutAllSessionsBtn.addEventListener("click", () => { void logoutAllSessions(); }); deleteAccountBtn.addEventListener("click", () => { void deleteAccount(); }); refreshDiagnosticsBtn.addEventListener("click", () => { void loadDiagnostics(); }); settingsNavItems.forEach((item) => item.addEventListener("click", () => setSettingsView(item.dataset.settingsView))); createAiCharacterBtn.addEventListener("click", () => openPersonaModal(null, "assistant")); createUserPersonaBtn.addEventListener("click", () => openPersonaModal(null, "user")); settingsPersonaClose.addEventListener("click", closePersonaModal); settingsPersonaModal.addEventListener("click", (event) => { if (event.target === settingsPersonaModal) closePersonaModal(); }); settingsPersonaType.addEventListener("change", syncPersonaExamplesField); settingsPersonaForm.addEventListener("submit", (event) => { event.preventDefault(); void savePersona(); }); settingsPopupConfirm.addEventListener("click", () => closeSettingsPopup(settingsPopupInput.value)); settingsPopupCancel.addEventListener("click", () => closeSettingsPopup(null)); settingsPopupClose.addEventListener("click", () => closeSettingsPopup(null)); settingsPopupModal.addEventListener("click", (event) => { if (event.target === settingsPopupModal) closeSettingsPopup(null); }); settingsPopupInput.addEventListener("keydown", (event) => { if (event.key === "Enter") { event.preventDefault(); closeSettingsPopup(settingsPopupInput.value); } if (event.key === "Escape") { event.preventDefault(); event.stopPropagation(); closeSettingsPopup(null); } }); document.addEventListener("keydown", event => { if (event.key !== "Escape") return; const modal = topModal(); if (!modal) return; event.preventDefault(); if (modal === settingsPopupModal) closeSettingsPopup(null); else if (modal === settingsPersonaModal) closePersonaModal(); }); window.addEventListener("load", async () => { initializeAppearance({ title: "Elsewhere Settings", themeSelect: settingsThemeSelect, modeSelect: themeModeSelect }); workspaceModeSelect.value = localStorage.getItem("krishd-workspace-mode") || "basic"; const params = new URLSearchParams(window.location.search); const personaPage = location.pathname === "/app/personas"; const view = personaPage ? "my-roleplay-companions" : params.get("view") || "personal"; if (personaPage) { document.querySelector(".market-header .eyebrow").textContent = "Your collection"; document.querySelector("h1").textContent = "My personas"; document.title = "My personas - Elsewhere"; document.querySelector(".settings-nav").hidden = true; document.querySelector(".settings-layout").style.gridTemplateColumns = "1fr"; } const profileOk = await loadProfile(); setSettingsView(view); const personasOk = await loadPersonas(); if (profileOk && personasOk && view !== "diagnostics") setNotice(personaPage ? "Personas loaded." : "Settings loaded."); const create = params.get("create"); if (create === "assistant") { setSettingsView("my-roleplay-companions"); openPersonaModal(null, "assistant"); } else if (create === "user") { setSettingsView("my-roleplay-companions"); openPersonaModal(null, "user"); } });