import { del, get, post, put, stream } from "./js/api.js?v=20260909-account-controls"; import { regenerateInline } from "./js/regeneration.js"; import { appendSources, createWebSearchToggle } from "./js/webSearch.js"; import { appendAttachments, createAttachmentComposer } from "./js/attachments.js"; import { createReasoningToggle, finishReasoning, updateReasoning } from "./js/reasoning.js?v=20260921-reasoning-capability"; import { closeChatDrawer, openChatDrawer } from "./js/chatDrawer.js?v=20260909-account-controls"; import { onboardingPrompts, requestedChatId } from "./js/constants.js?v=20260909-account-controls"; import { getModelProfile } from "./js/modelProfiles.js?v=20260909-account-controls"; import { applyTheme as applySharedTheme, applyThemeMode as applySharedThemeMode, readStoredAppearance } from "./js/themeController.js?v=20260909-account-controls"; import { activeChatTitle, activeUserPersonaStatus, chatActivityDetail, chatActivityEyebrow, chatActivityOverlay, chatActivityTitle, chatCharacterPill, chatDrawerPersonaValue, chatDiv, chatList, chatListLoading, chatListLoadingText, chatSidebar, chatSidebarToggle, chatSearchInput, chatUserPersonaPill, chatTemperatureInput, chatContextLengthInput, chatResponseLengthInput, chatSystemInstructionInput, clearChatBtn, archiveChatBtn, backupWorkspaceBtn, clearUserPersonaBtn, exportChatBtn, importWorkspaceBtn, manageChatMemoryBtn, managePersonaStateBtn, importWorkspaceFileInput, headerModelName, headerPersonaName, moveChatFolderBtn, messagesDiv, modelBadgeName, modelCount, modelHelpBadge, modelHelpSummary, modelHelpTitle, modelSection, modelSelect, msgInput, newChatBtn, onboardingBack, onboardingChoices, onboardingContinue, onboardingModal, onboardingSkip, onboardingSubtitle, onboardingTitle, pinChatBtn, personaCloseBtn, personaDetailsInput, personaExampleDialoguesInput, personaExamplesField, personaForm, personaFormNotice, personaFormTitle, personaModal, personaNameInput, personaSection, personaPronounsInput, personaTypeSelect, personaAppearanceInput, personaBackgroundInput, popupCancelBtn, popupCloseBtn, popupConfirmBtn, popupDescription, popupEyebrow, popupField, popupInput, popupInputLabel, popupModal, popupTitle, renameChatBtn, roleplayCharacterSelect, roleplayNewPersonaBtn, roleplayScenarioInput, roleplayStarterBack, roleplayStarterCancel, roleplayStarterClose, roleplayStarterConfirm, roleplayStarterIntroCopy, roleplayStarterModal, roleplayStarterNotice, roleplayStarterStepOne, roleplayStarterStepTwo, roleplayStarterSuggestions, roleplayStarterTitle, roleplayUserPersonaSelect, sendBtn, saveGenerationSettingsBtn, brandLogoTargets, brandNameTargets, userPersonaList } from "./js/dom.js?v=20260909-account-controls"; const headerModelButton = document.getElementById("headerModelButton"); const webSearchButton = document.getElementById("webSearch"); const webSearchToggle = createWebSearchToggle(webSearchButton); const reasoningToggle = createReasoningToggle(document.getElementById("reasoning")); let isProcessing = false, activeChatId = null, editingPersonaId = null, editingPersonaType = "assistant", currentUsername = ""; let regenerationChatId = null; const attachmentComposer = createAttachmentComposer({ onChange: updateSendState, onError: message => setNotice(message, "error") }); let chatSessions = [], currentMessages = [], assistantPersonas = [], userPersonas = []; let activeUserPersonaId = null, currentSummary = null, publishedPersonaIds = new Set(); let popupResolver = null, popupMode = null, popupLastFocus = null; let roleplayPresetCharacterId = null; let roleplayStarterStep = 1; let workspaceMode = localStorage.getItem("krishd-workspace-mode") || "basic"; let onboardingStep = 1, onboardingIntent = "ask"; let chatLoadingDepth = 0; let chatActivityDepth = 0; let messageRetryState = new Map(); let availableModelsById = new Map(); function setNotice(message = "", state = "") { const notice = document.getElementById("workspaceNotice"); if (notice) { notice.textContent = state === "error" ? message : ""; notice.classList.toggle("hidden", state !== "error"); } return { message, state }; } function setPersonaFormNotice(message = "", state = "") { if (!personaFormNotice) return; if (!message) { personaFormNotice.textContent = ""; personaFormNotice.className = "status hidden"; return; } personaFormNotice.textContent = message; personaFormNotice.className = state ? `status ${state}` : "status"; } function setChatLoading(active, message = "Loading chats...") { if (!chatListLoading || !chatListLoadingText) return; if (active) { chatLoadingDepth += 1; chatListLoadingText.textContent = message; chatListLoading.classList.remove("hidden"); return; } chatLoadingDepth = Math.max(0, chatLoadingDepth - 1); if (chatLoadingDepth === 0) { chatListLoading.classList.add("hidden"); chatListLoadingText.textContent = "Loading chats..."; } } function setChatActivity(active, { eyebrow = "Please wait", title = "Preparing chat", detail = "The assistant is still working." } = {}) { if (!chatActivityOverlay || !chatActivityEyebrow || !chatActivityTitle || !chatActivityDetail) return; if (active) { chatActivityDepth += 1; chatActivityEyebrow.textContent = eyebrow; chatActivityTitle.textContent = title; chatActivityDetail.textContent = detail; chatActivityOverlay.classList.remove("hidden"); return; } chatActivityDepth = Math.max(0, chatActivityDepth - 1); if (chatActivityDepth === 0) { chatActivityOverlay.classList.add("hidden"); chatActivityEyebrow.textContent = "Please wait"; chatActivityTitle.textContent = "Preparing chat"; chatActivityDetail.textContent = "The assistant is still working."; } } function applyWorkspaceMode(nextMode, persist = true) { workspaceMode = nextMode === "advanced" ? "advanced" : "basic"; document.body.dataset.workspaceMode = workspaceMode; if (persist) localStorage.setItem("krishd-workspace-mode", workspaceMode); if (modelSection) modelSection.classList.remove("hidden"); if (personaSection) personaSection.classList.toggle("hidden", workspaceMode === "basic"); updateComposerPlaceholder(); } function isMobileLayout() { return window.matchMedia("(max-width: 980px)").matches; } function closeMobileSidebar() { if (!chatSidebar) return; document.body.classList.remove("sidebar-open"); if (chatSidebarToggle) chatSidebarToggle.setAttribute("aria-expanded", "false"); } function openMobileSidebar() { if (!chatSidebar) return; document.body.classList.add("sidebar-open"); if (chatSidebarToggle) chatSidebarToggle.setAttribute("aria-expanded", "true"); } function hasCompletedOnboarding() { return localStorage.getItem("krishd-onboarding-complete") === "true"; } function markOnboardingCompleted() { localStorage.setItem("krishd-onboarding-complete", "true"); closeOnboarding(); } function closeOnboarding() { if (!onboardingModal) return; onboardingModal.classList.add("hidden"); } function renderOnboardingStep() { if (!onboardingChoices || !onboardingTitle || !onboardingSubtitle) return; onboardingChoices.innerHTML = ""; onboardingBack.classList.toggle("hidden", onboardingStep === 1); onboardingContinue.classList.toggle("hidden", onboardingStep === 2); if (onboardingStep === 1) { onboardingTitle.textContent = "Where shall we start?"; onboardingSubtitle.textContent = "Ask a question, explore an idea, or step into a story."; [["ask", "Ask questions"], ["brainstorm", "Brainstorm ideas"], ["roleplay", "Roleplay with a character"]].forEach(([key, label]) => { const button = document.createElement("button"); button.type = "button"; button.className = `secondary-action${onboardingIntent === key ? " active" : ""}`; button.textContent = label; button.addEventListener("click", () => { onboardingIntent = key; renderOnboardingStep(); }); onboardingChoices.appendChild(button); }); return; } onboardingTitle.textContent = "Choose a starter"; onboardingSubtitle.textContent = "Pick one template and send your first message."; onboardingPrompts[onboardingIntent].forEach((template) => { const button = document.createElement("button"); button.type = "button"; button.className = "secondary-action"; button.textContent = template; button.addEventListener("click", async () => { if (onboardingIntent === "roleplay") { closeOnboarding(); if (assistantPersonas.length) { openRoleplayStarter(); } else { openPersonaForm(null, "assistant"); } return; } if (!activeChatId) await createNewChat(); msgInput.value = template; msgInput.dispatchEvent(new Event("input")); msgInput.focus(); closeOnboarding(); }); onboardingChoices.appendChild(button); }); } function maybeShowOnboarding() { if (!onboardingModal || hasCompletedOnboarding()) return; onboardingStep = 1; onboardingIntent = "ask"; renderOnboardingStep(); onboardingModal.classList.remove("hidden"); } function setMessageContent(el, content) { const markdownParser = window.marked; const purifier = window.DOMPurify; if (markdownParser?.parse && purifier?.sanitize) { el.innerHTML = purifier.sanitize(markdownParser.parse(content, { breaks: true })); } else { el.textContent = 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 || ""; } function getRoleplayStarterSuggestions() { const assistantPersonaId = Number(roleplayCharacterSelect?.value); const userPersonaId = roleplayUserPersonaSelect?.value ? Number(roleplayUserPersonaSelect.value) : null; const assistantName = getPersonaName(assistantPersonaId, assistantPersonas) || "the character"; const userName = userPersonaId ? getPersonaName(userPersonaId, userPersonas) : "me"; return [ `${assistantName} unexpectedly crosses paths with ${userName} in a place that matters to them both.`, `${assistantName} already knows something important about ${userName} and confronts them with it.`, `${assistantName} and ${userName} are forced to cooperate while something goes wrong around them.`, `${assistantName} meets ${userName} in a quiet, intimate scene with unresolved tension.`, `${assistantName} draws ${userName} into a high-stakes problem that starts immediately.` ]; } function updateRoleplaySuggestions() { if (!roleplayStarterSuggestions) return; roleplayStarterSuggestions.innerHTML = ""; getRoleplayStarterSuggestions().forEach((suggestion) => { const button = document.createElement("button"); button.type = "button"; button.className = "prompt-chip"; button.textContent = suggestion; button.addEventListener("click", () => { if (roleplayScenarioInput) { roleplayScenarioInput.value = suggestion; roleplayScenarioInput.focus(); } }); roleplayStarterSuggestions.appendChild(button); }); } function updateComposerPlaceholder() { const activeChat = getChatById(activeChatId); if (!activeChat) { msgInput.placeholder = "Create a chat to begin..."; return; } if (activeChat.assistant_persona_id) { msgInput.placeholder = "Continue the scene..."; return; } msgInput.placeholder = workspaceMode === "basic" ? "Ask anything..." : "Type your message..."; } function updateSendState() { const hasText = Boolean(msgInput.value.trim()); sendBtn.disabled = isProcessing ? false : attachmentComposer.pending || (!hasText && !attachmentComposer.attachments.length); sendBtn.setAttribute("aria-label", isProcessing ? "Stop generation" : "Send message"); } function populateGenerationSettings(chat) { if (!chat) return; if (chat.preferred_model && Array.from(modelSelect.options).some((option) => option.value === chat.preferred_model)) { modelSelect.value = chat.preferred_model; } chatTemperatureInput.value = chat.temperature ?? ""; chatContextLengthInput.value = chat.context_length ?? ""; chatResponseLengthInput.value = chat.response_length ?? ""; chatSystemInstructionInput.value = chat.system_instruction || ""; renderModelSection(); } async function saveGenerationSettings() { if (!activeChatId) return; const payload = { preferredModel: modelSelect.value || null, temperature: chatTemperatureInput.value, contextLength: chatContextLengthInput.value, responseLength: chatResponseLengthInput.value, systemInstruction: chatSystemInstructionInput.value }; const res = await put(`/chats/${activeChatId}/generation-settings`, payload); if (res.error || !res.chat) return setNotice(res.error || "Unable to save generation settings.", "error"); const chat = getChatById(activeChatId); if (chat) Object.assign(chat, res.chat); populateGenerationSettings(res.chat); setNotice("Generation settings saved.", "success"); } async function saveSelectedModel() { if (!activeChatId || !modelSelect.value) return; const chatId = activeChatId; const selectedModel = modelSelect.value; const res = await put(`/chats/${chatId}/model`, { model: selectedModel }); if (res.error || !res.chat) return setNotice(res.error || "Unable to switch models.", "error"); const chat = getChatById(chatId); if (chat) Object.assign(chat, res.chat); if (chatId === activeChatId && modelSelect.value === selectedModel) { renderModelSection(); setNotice(`Model switched to ${modelSelect.selectedOptions[0]?.textContent || selectedModel}.`, "success"); } } function resizeComposerInput() { msgInput.style.height = "auto"; const nextHeight = Math.min(Math.max(msgInput.scrollHeight, 46), 96); msgInput.style.height = `${nextHeight}px`; } function scrollMessagesToBottom() { requestAnimationFrame(() => { messagesDiv.scrollTop = messagesDiv.scrollHeight; }); } function getNewestMessage() { return currentMessages.length ? currentMessages[currentMessages.length - 1] : null; } function getMessageIndexById(messageId) { return currentMessages.findIndex((message) => message.id === messageId); } async function resolveMessageTarget(messageId, fallbackIndex = -1) { if (messageId) { const index = getMessageIndexById(messageId); if (index >= 0) { return { index, message: currentMessages[index] }; } } if (activeChatId) { await setActiveChat(activeChatId); } if (messageId) { const index = getMessageIndexById(messageId); if (index >= 0) { return { index, message: currentMessages[index] }; } } if (fallbackIndex >= 0 && fallbackIndex < currentMessages.length) { return { index: fallbackIndex, message: currentMessages[fallbackIndex] }; } return null; } function getMessageEndpoint(target, suffix = "") { if (!activeChatId || !target) return ""; if (target.message?.id) return `/chats/${activeChatId}/messages/${target.message.id}${suffix}`; if (target.index >= 0) return `/chats/${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 (currentMessages[i].role === "user" && currentMessages[i].id) { return 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); messageRetryState.set(message.id, state); 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) chatSearchInput.value = ""; const chatsResponse = await get("/chats"); if (chatsResponse.error) throw new Error(chatsResponse.error); chatSessions = chatsResponse.chats || []; if (!chatSessions.some((item) => item.id === chat.id)) { throw new Error("The newly created chat was not persisted"); } renderChatList(); await setActiveChat(chat.id); } 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 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) return; const sourceChatId = activeChatId; 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 }); if (res.error) return setNotice(res.error, "error"); await openCreatedChat(res.chat); return setNotice("Created and opened the edited chat branch.", "success"); } finally { 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"); } currentMessages[index] = { ...currentMessages[index], ...res.message }; syncRetryStateFromMessage(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 { index, 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"); currentMessages = currentMessages.filter((item) => item.id !== message.id); if (message.id) { messageRetryState.delete(message.id); for (const [key, state] of messageRetryState.entries()) { if (state.promptMessageId === message.id) { messageRetryState.delete(key); } } } renderMessages(); await loadSummary(); setNotice("Message deleted.", "success"); } async function retryLatestAssistantMessage(messageId, fallbackIndex = -1) { if (isProcessing) return; const target = await resolveMessageTarget(messageId, fallbackIndex); if (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 = 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 }; 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 (activeChatId !== chatId) return; const index = getMessageIndexById(message.id); if (index < 0) return; currentMessages[index] = { ...currentMessages[index], ...message, thinking: message.thinking, sources: message.sources }; syncRetryStateFromMessage(currentMessages[index]); const rendered = addMessage(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 { 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 = 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 (activeChatId !== chatId) return; const index = getMessageIndexById(messageId); if (index < 0) return; currentMessages[index] = { ...currentMessages[index], ...res.message, thinking: res.message.thinking, sources: res.message.sources }; syncRetryStateFromMessage(currentMessages[index]); const rendered = addMessage(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/${activeChatId}/branches/${siblingId}/activate`, { includeDescendants: true }); if (res.error) return setNotice(res.error, "error"); currentMessages = res.messages || []; renderMessages(); } async function branchFromMessage(messageId) { if (!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 = 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 addMessage(contentOrMessage, isUser = false, isLoading = false, options = {}) { const msgDiv = document.createElement("div"); msgDiv.className = `msg ${isUser ? "user" : "bot"}${isLoading ? " loading" : ""}`; msgDiv.setAttribute("role", "article"); msgDiv.tabIndex = 0; if (isLoading) { msgDiv.setAttribute("aria-label", "Assistant is responding"); msgDiv.innerHTML = `
Assistant
Thinking
`; } else { const message = typeof contentOrMessage === "object" && contentOrMessage ? contentOrMessage : { id: null, role: isUser ? "user" : "bot", content: String(contentOrMessage || "") }; const isMessageUser = message.role === "user"; msgDiv.dataset.messageId = message.id; msgDiv.className = `msg ${isMessageUser ? "user" : "bot"}`; msgDiv.setAttribute("aria-label", isMessageUser ? "Your message" : "Assistant message"); const header = document.createElement("div"), body = document.createElement("div"), actions = document.createElement("div"); header.className = "msg-header"; body.className = "msg-content"; actions.className = "msg-actions"; actions.setAttribute("aria-label", "Message actions"); const activeChat = getChatById(activeChatId); header.textContent = isMessageUser ? "You" : (activeChat?.assistant_persona_name || "Assistant"); setMessageContent(body, message.content); const editBtn = document.createElement("button"); editBtn.type = "button"; editBtn.className = "msg-action-btn"; editBtn.textContent = isMessageUser ? "Edit & resend" : "Edit"; editBtn.setAttribute("aria-label", `Edit ${isMessageUser ? "your" : "assistant"} message`); editBtn.addEventListener("click", () => { void editChatMessage(message.id, options.messageIndex ?? -1); }); actions.appendChild(editBtn); const deleteBtn = document.createElement("button"); deleteBtn.type = "button"; deleteBtn.className = "msg-action-btn"; deleteBtn.textContent = "Delete"; deleteBtn.setAttribute("aria-label", `Delete ${isMessageUser ? "your" : "assistant"} message`); deleteBtn.addEventListener("click", () => { void deleteChatMessage(message.id, options.messageIndex ?? -1); }); actions.appendChild(deleteBtn); const branchBtn = document.createElement("button"); branchBtn.type = "button"; branchBtn.className = "msg-action-btn"; branchBtn.textContent = "Branch here"; branchBtn.addEventListener("click", () => { void branchFromMessage(message.id); }); actions.appendChild(branchBtn); if (message.role === "bot") { const retriesUsed = Math.max(0, Number(message.versionCount || 1) - 1); const retryBtn = document.createElement("button"); retryBtn.type = "button"; retryBtn.className = "msg-action-btn"; retryBtn.dataset.messageAction = "regenerate"; retryBtn.dataset.retryLimitReached = String(retriesUsed >= 5); retryBtn.textContent = `Regenerate (${retriesUsed}/5)`; retryBtn.setAttribute("aria-label", `Regenerate assistant message, ${retriesUsed} of 5 retries used`); retryBtn.disabled = retriesUsed >= 5 || isProcessing; retryBtn.addEventListener("click", () => { void retryLatestAssistantMessage(message.id, options.messageIndex ?? -1); }); actions.appendChild(retryBtn); } for (const versioned of [true, false]) { if (Number((versioned ? message.versionCount : message.siblingCount) || 1) <= 1) continue; const navigate = versioned ? switchResponseVersion : switchRetryVariant; const prevBtn = document.createElement("button"); prevBtn.type = "button"; prevBtn.className = "msg-action-btn"; prevBtn.textContent = "Prev"; prevBtn.setAttribute("aria-label", versioned ? "Show previous response version" : "Show previous branch"); prevBtn.disabled = versioned ? message.versionIndex <= 0 : !message.previousSiblingId; prevBtn.addEventListener("click", () => { void navigate(message.id, -1, options.messageIndex ?? -1); }); const nextBtn = document.createElement("button"); nextBtn.type = "button"; nextBtn.className = "msg-action-btn"; nextBtn.textContent = "Next"; nextBtn.setAttribute("aria-label", versioned ? "Show next response version" : "Show next branch"); nextBtn.disabled = versioned ? message.versionIndex >= message.versionCount - 1 : !message.nextSiblingId; nextBtn.addEventListener("click", () => { void navigate(message.id, 1, options.messageIndex ?? -1); }); const indexBadge = document.createElement("span"); indexBadge.className = "msg-variant-index"; indexBadge.textContent = versioned ? `${message.versionIndex + 1} / ${message.versionCount}` : `${Number(message.siblingIndex || 0) + 1}/${message.siblingCount}`; actions.append(prevBtn, indexBadge, nextBtn); } msgDiv.append(header); appendAttachments(msgDiv, message.attachments); msgDiv.append(body); if (!isMessageUser) updateReasoning(msgDiv, message.thinking); if (!isMessageUser) appendSources(msgDiv, message.sources); if (message.deliveryStatus === "failed" && message.errorMessage) { const error = document.createElement("p"); error.className = "status"; error.textContent = message.errorMessage; msgDiv.appendChild(error); } if (actions.childElementCount) { msgDiv.appendChild(actions); } } if (!options.detached) { messagesDiv.appendChild(msgDiv); scrollMessagesToBottom(); } return msgDiv; } function renderMessages() { messagesDiv.innerHTML = ""; const chat = getChatById(activeChatId); if (!currentMessages.length && (!chat || chat.assistant_persona_id)) { renderMessageEmptyState(); } else { currentMessages.forEach((msg, index) => addMessage(msg, msg.role === "user", false, { messageIndex: index, isNewest: index === currentMessages.length - 1 })); } renderDrawerSummary(); updateChatActionState(); updateComposerPlaceholder(); if (currentMessages.length) { scrollMessagesToBottom(); } } function createStarterAction(label, className, handler) { const button = document.createElement("button"); button.type = "button"; button.className = className; button.textContent = label; button.addEventListener("click", handler); return button; } function createMessageStarterPrompt(label, prompt) { return createStarterAction(label, "secondary-action", async () => { if (!activeChatId) await createNewChat(); msgInput.value = prompt; msgInput.dispatchEvent(new Event("input")); msgInput.focus(); }); } function renderMessageEmptyState() { const empty = document.createElement("section"); const title = document.createElement("h3"); const description = document.createElement("p"); const actions = document.createElement("div"); const chat = getChatById(activeChatId); empty.className = "messages-empty"; title.className = "messages-empty-title"; description.className = "messages-empty-copy"; actions.className = "messages-empty-actions"; if (!chat) { title.textContent = "Where shall we start?"; description.textContent = "Choose a starting point."; actions.append( createStarterAction("New chat", "primary-action", () => { void createNewChat(); }), createStarterAction("Roleplay", "secondary-action", () => { if (assistantPersonas.length) { openRoleplayStarter(); return; } openPersonaForm(null, "assistant"); }), createMessageStarterPrompt("Try a prompt", "Help me plan my day in 3 steps.") ); } else if (chat.assistant_persona_id) { title.textContent = "Roleplay is ready"; description.textContent = "Send a line or pick a prompt."; actions.append( createMessageStarterPrompt("Add tension", "Open with a tense line that raises the stakes immediately."), createMessageStarterPrompt("Set the scene", "Describe what your character notices first in this scene."), createStarterAction("Change setup", "secondary-action", () => openRoleplayStarter()) ); } else { title.textContent = "What can I help with?"; description.textContent = "Write a message below or start with one of these prompts."; actions.append( createMessageStarterPrompt("Explain something", "Explain this topic in simple terms: "), createMessageStarterPrompt("Plan a task", "Help me make a practical step-by-step plan for: "), createMessageStarterPrompt("Brainstorm ideas", "Give me several creative ideas for: ") ); } empty.append(title, description, actions); messagesDiv.appendChild(empty); } function updateWorkspaceCopy() { const activeChat = getChatById(activeChatId); if (activeChatTitle) { activeChatTitle.textContent = activeChat ? activeChat.title : "New chat"; } renderDrawerSummary(); updateComposerPlaceholder(); } function setRoleplayStarterNotice(message = "", state = "") { if (!roleplayStarterNotice) return; if (!message) { roleplayStarterNotice.textContent = ""; roleplayStarterNotice.className = "status hidden"; return; } roleplayStarterNotice.textContent = message; roleplayStarterNotice.className = state ? `status ${state}` : "status"; } function renderRoleplayStarterStep() { const isSceneStep = roleplayStarterStep === 2; if (roleplayStarterStepOne) roleplayStarterStepOne.classList.toggle("hidden", isSceneStep); if (roleplayStarterStepTwo) roleplayStarterStepTwo.classList.toggle("hidden", !isSceneStep); if (roleplayStarterBack) roleplayStarterBack.classList.toggle("hidden", !isSceneStep); if (roleplayStarterTitle) { roleplayStarterTitle.textContent = isSceneStep ? "Shape the opening scene" : "Choose your roleplay"; } if (roleplayStarterIntroCopy) { roleplayStarterIntroCopy.textContent = isSceneStep ? "Add an optional custom scenario. If you leave it empty, the AI Character will invent its own opening scene." : "Pick a character and choose who you are before moving on to the opening scene."; } if (roleplayStarterCancel) { roleplayStarterCancel.textContent = isSceneStep ? "Close" : "Cancel"; } if (roleplayStarterConfirm) { roleplayStarterConfirm.textContent = isSceneStep ? "Start roleplay" : "Continue"; } } function renderDrawerSummary() { const chat = getChatById(activeChatId); if (!chatCharacterPill || !chatUserPersonaPill) return; if (!chat) { chatCharacterPill.classList.add("hidden"); chatUserPersonaPill.classList.add("hidden"); return; } if (chat.assistant_persona_id) { chatCharacterPill.textContent = `Character: ${chat.assistant_persona_name || chat.title}`; chatCharacterPill.classList.remove("hidden"); if (chat.user_persona_id) { chatUserPersonaPill.textContent = `You as: ${chat.user_persona_name || "User persona"}`; chatUserPersonaPill.classList.remove("hidden"); } else { chatUserPersonaPill.textContent = "You as: yourself"; chatUserPersonaPill.classList.remove("hidden"); } return; } chatCharacterPill.classList.add("hidden"); chatUserPersonaPill.classList.add("hidden"); } function renderModelSection() { reasoningToggle.setModel(modelSelect.value); updateModelHelp(); return modelSelect?.selectedOptions[0]?.textContent || ""; } function updateModelHelp() { if (!modelSelect || !modelHelpTitle || !modelHelpBadge || !modelHelpSummary) return; const selectedOption = modelSelect.selectedOptions[0]; if (!selectedOption || selectedOption.disabled) { if (modelCount) modelCount.textContent = "No models"; if (modelBadgeName) modelBadgeName.textContent = "Unavailable"; if (headerModelName) headerModelName.textContent = "Unavailable"; modelHelpBadge.dataset.badge = "waiting"; modelHelpTitle.textContent = "Choose a model"; modelHelpBadge.textContent = "Waiting"; modelHelpSummary.textContent = "Pick a model to see what it is best suited for."; return; } if (modelCount) { const totalModels = Array.from(modelSelect.options).filter((option) => !option.disabled).length; modelCount.textContent = `${totalModels} ${totalModels === 1 ? "model" : "models"}`; } const modelId = selectedOption.value; const modelName = selectedOption.textContent || modelId; const profile = getModelProfile(availableModelsById.get(modelId) || { model: modelId, name: modelName }); if (modelBadgeName) modelBadgeName.textContent = modelName; if (headerModelName) headerModelName.textContent = modelName; modelHelpTitle.textContent = modelName; modelHelpBadge.textContent = profile.badge; modelHelpBadge.dataset.badge = profile.badge.toLowerCase(); modelHelpSummary.textContent = profile.summary; } function applyTheme(themeKey, persist = true) { applySharedTheme(themeKey, { persist, title: "Elsewhere", nameTargets: brandNameTargets, logoTargets: brandLogoTargets }); updateWorkspaceCopy(); renderModelSection(); } function applyThemeMode(modeKey, persist = true) { applySharedThemeMode(modeKey, { persist }); } function updateChatActionState() { const activeChat = getChatById(activeChatId); const hasChat = Boolean(activeChat); renameChatBtn.disabled = !hasChat || Boolean(activeChat?.assistant_persona_id); if (pinChatBtn) { pinChatBtn.disabled = !hasChat; if (hasChat) pinChatBtn.textContent = activeChat.is_pinned ? "Unpin" : "Pin"; } if (moveChatFolderBtn) moveChatFolderBtn.disabled = !hasChat; if (manageChatMemoryBtn) manageChatMemoryBtn.disabled = !hasChat; if (managePersonaStateBtn) managePersonaStateBtn.disabled = !hasChat || !activeChat?.assistant_persona_id; if (archiveChatBtn) { archiveChatBtn.disabled = !hasChat; if (hasChat) archiveChatBtn.textContent = activeChat.archived_at ? "Unarchive" : "Archive"; } clearChatBtn.disabled = !hasChat; exportChatBtn.disabled = !hasChat || currentMessages.length === 0; if (backupWorkspaceBtn) backupWorkspaceBtn.disabled = !currentUsername; if (importWorkspaceBtn) importWorkspaceBtn.disabled = !currentUsername; if (headerModelButton) headerModelButton.disabled = !hasChat; } function openModelSettings() { if (!activeChatId || !modelSection) return; modelSection.classList.remove("hidden"); modelSection.open = true; openChatDrawer(); requestAnimationFrame(() => modelSelect?.focus()); } function renderPersonaSection() { updateActivePersonaStatus(userPersonas, activeUserPersonaId, activeUserPersonaStatus, "No default user persona set.", "Default user persona"); if (chatDrawerPersonaValue) { const active = userPersonas.find((persona) => persona.id === activeUserPersonaId); chatDrawerPersonaValue.textContent = active ? active.name : "None"; if (headerPersonaName) headerPersonaName.textContent = active ? active.name : "None"; } if (clearUserPersonaBtn) clearUserPersonaBtn.disabled = !activeUserPersonaId; } function maybeShowPersonaNudge() { if (localStorage.getItem("krishd-persona-nudge-shown") === "true") return; const assistantOnlyChats = chatSessions.filter((chat) => !chat.assistant_persona_id).length; if (assistantOnlyChats < 3) return; localStorage.setItem("krishd-persona-nudge-shown", "true"); setNotice("Want to personalize responses? Try personas in Tools.", "success"); } function renderChatList() { const query = chatSearchInput.value.trim().toLowerCase(); const filtered = chatSessions.filter((chat) => !query || chat.title.toLowerCase().includes(query) || String(chat.matched_content || "").toLowerCase().includes(query)); chatList.innerHTML = ""; if (!filtered.length) { const empty = document.createElement("p"); empty.className = "status"; empty.textContent = query ? "No chats match this search." : "No chats yet. Start a new one."; chatList.appendChild(empty); return; } const folderGroups = filtered .filter((chat) => chat.folder_name && !chat.archived_at) .reduce((map, chat) => { const key = String(chat.folder_name).trim(); const items = map.get(key) || []; items.push(chat); map.set(key, items); return map; }, new Map()); const groups = [ { label: "Pinned", items: filtered.filter((chat) => chat.is_pinned && !chat.archived_at) }, ...Array.from(folderGroups.entries()).map(([label, items]) => ({ label, items })), { label: "Recent", items: filtered.filter((chat) => !chat.is_pinned && !chat.folder_name && !chat.archived_at) }, { label: "Archived", items: filtered.filter((chat) => chat.archived_at) } ].filter((group) => group.items.length); groups.forEach((group) => { const section = document.createElement("section"); const heading = document.createElement("div"); const list = document.createElement("div"); section.className = "chat-list-section"; heading.className = "chat-list-section-header"; heading.textContent = group.label; list.className = "chat-list-section-body"; list.setAttribute("role", "list"); section.append(heading, list); group.items.forEach((chat) => { const item = document.createElement("div"), content = document.createElement("div"), title = document.createElement("span"), meta = document.createElement("small"), remove = document.createElement("button"); item.className = `chat-item${chat.id === activeChatId ? " active" : ""}`; item.setAttribute("role", "button"); item.tabIndex = 0; item.setAttribute("aria-label", `Open chat ${chat.title}`); if (chat.id === activeChatId) item.setAttribute("aria-current", "page"); content.className = "chat-item-copy"; title.textContent = chat.title; meta.className = "chat-item-meta"; const chatType = chat.assistant_persona_id ? `Roleplay${chat.user_persona_name ? ` · ${chat.user_persona_name}` : ""}` : "Assistant chat"; meta.textContent = chat.source_chat_id ? `Branch · ${chatType}` : chatType; remove.type = "button"; remove.className = "chat-item-delete"; remove.textContent = "Delete"; remove.setAttribute("aria-label", `Delete chat ${chat.title}`); remove.addEventListener("click", async (event) => { event.stopPropagation(); await deleteChat(chat.id); }); content.append(title, meta); item.append(content, remove); item.addEventListener("click", () => { closeMobileSidebar(); setActiveChat(chat.id); }); item.addEventListener("keydown", (event) => { if (event.key !== "Enter" && event.key !== " ") return; event.preventDefault(); closeMobileSidebar(); void setActiveChat(chat.id); }); list.appendChild(item); }); chatList.appendChild(section); }); } function updateActivePersonaStatus(items, activeId, statusEl, emptyText, prefix) { if (!statusEl) return; const active = items.find((persona) => persona.id === activeId); statusEl.textContent = active ? `${prefix}: ${active.name}` : emptyText; } function openPersonaForm(persona = null, personaType = "assistant") { closeChatDrawer(); closePopup(false); personaModal.classList.remove("hidden"); if (persona) { editingPersonaId = persona.id; editingPersonaType = persona.persona_type || "assistant"; personaFormTitle.textContent = `Edit ${editingPersonaType === "user" ? "your persona" : "AI Character"}`; personaTypeSelect.value = editingPersonaType; personaTypeSelect.disabled = true; personaNameInput.value = persona.name || ""; personaPronounsInput.value = persona.pronouns || ""; personaAppearanceInput.value = persona.appearance || ""; personaBackgroundInput.value = persona.background || ""; personaDetailsInput.value = persona.details || ""; personaExampleDialoguesInput.value = persona.example_dialogues || ""; } else { editingPersonaId = null; editingPersonaType = personaType; personaFormTitle.textContent = `Create ${personaType === "user" ? "your persona" : "AI Character"}`; personaTypeSelect.value = personaType; personaTypeSelect.disabled = false; personaNameInput.value = ""; personaPronounsInput.value = ""; personaAppearanceInput.value = ""; personaBackgroundInput.value = ""; personaDetailsInput.value = ""; personaExampleDialoguesInput.value = ""; } personaExamplesField.classList.toggle("hidden", personaTypeSelect.value !== "assistant"); setPersonaFormNotice(""); personaNameInput.focus(); } function closePersonaForm() { personaModal.classList.add("hidden"); editingPersonaId = null; editingPersonaType = "assistant"; personaTypeSelect.disabled = false; setPersonaFormNotice(""); } function populateRoleplayStarter() { if (!roleplayCharacterSelect || !roleplayUserPersonaSelect) return; roleplayCharacterSelect.innerHTML = ""; const placeholder = document.createElement("option"); placeholder.value = ""; placeholder.textContent = "Choose AI Character"; placeholder.disabled = true; placeholder.selected = true; roleplayCharacterSelect.appendChild(placeholder); assistantPersonas.forEach((persona) => { const option = document.createElement("option"); option.value = String(persona.id); option.textContent = persona.name; option.selected = persona.id === roleplayPresetCharacterId; roleplayCharacterSelect.appendChild(option); }); if (roleplayPresetCharacterId) { roleplayCharacterSelect.value = String(roleplayPresetCharacterId); } roleplayUserPersonaSelect.innerHTML = ""; const noneOption = document.createElement("option"); noneOption.value = ""; noneOption.textContent = "Myself (no user persona)"; roleplayUserPersonaSelect.appendChild(noneOption); userPersonas.forEach((persona) => { const option = document.createElement("option"); option.value = String(persona.id); option.textContent = persona.name; option.selected = persona.id === activeUserPersonaId; roleplayUserPersonaSelect.appendChild(option); }); if (assistantPersonas.length === 0) { setRoleplayStarterNotice("Create an AI Character first, then start roleplay.", "error"); roleplayStarterConfirm.disabled = true; } else { setRoleplayStarterNotice(""); roleplayStarterConfirm.disabled = false; } updateRoleplaySuggestions(); renderRoleplayStarterStep(); } function openRoleplayStarter(characterId = null) { closeChatDrawer(); roleplayPresetCharacterId = characterId; roleplayStarterStep = 1; populateRoleplayStarter(); if (roleplayScenarioInput) roleplayScenarioInput.value = ""; setRoleplayStarterNotice(""); roleplayStarterModal.classList.remove("hidden"); if (roleplayCharacterSelect.options.length) roleplayCharacterSelect.focus(); } function closeRoleplayStarter() { if (!roleplayStarterModal) return; roleplayStarterModal.classList.add("hidden"); roleplayPresetCharacterId = null; roleplayStarterStep = 1; roleplayStarterConfirm.disabled = false; if (roleplayScenarioInput) roleplayScenarioInput.value = ""; setRoleplayStarterNotice(""); renderRoleplayStarterStep(); } function goToRoleplayStarterSceneStep() { if (!assistantPersonas.length) { return setRoleplayStarterNotice("Create an AI Character first, then start roleplay.", "error"); } if (!roleplayCharacterSelect.value) { return setRoleplayStarterNotice("Choose a character to continue.", "error"); } roleplayStarterStep = 2; setRoleplayStarterNotice(""); renderRoleplayStarterStep(); if (roleplayScenarioInput) roleplayScenarioInput.focus(); } function isPopupOpen() { return popupModal && !popupModal.classList.contains("hidden"); } function finishPopup(result) { if (typeof popupResolver === "function") popupResolver(result); popupResolver = null; popupMode = null; } function closePopup(resolveValue = null) { if (!popupModal || popupModal.classList.contains("hidden")) return; popupModal.classList.add("hidden"); popupField.classList.add("hidden"); popupInput.value = ""; popupConfirmBtn.disabled = false; const nextFocus = popupLastFocus; popupLastFocus = null; finishPopup(resolveValue); if (nextFocus instanceof HTMLElement) nextFocus.focus(); } function openPopup(options) { if (!popupModal) return Promise.resolve(null); if (isPopupOpen()) closePopup(null); popupLastFocus = document.activeElement instanceof HTMLElement ? document.activeElement : null; popupMode = options.mode; popupEyebrow.textContent = options.eyebrow || "Action"; popupTitle.textContent = options.title || "Confirm action"; popupDescription.textContent = options.description || ""; popupConfirmBtn.textContent = options.confirmLabel || "Confirm"; popupCancelBtn.textContent = options.cancelLabel || "Cancel"; popupConfirmBtn.classList.toggle("danger", Boolean(options.danger)); popupField.classList.toggle("hidden", options.mode !== "prompt"); if (options.mode === "prompt") { popupInputLabel.textContent = options.label || "Value"; popupInput.value = options.value || ""; popupInput.placeholder = options.placeholder || ""; } else { popupInput.value = ""; } popupModal.classList.remove("hidden"); return new Promise((resolve) => { popupResolver = resolve; requestAnimationFrame(() => { if (options.mode === "prompt") { popupInput.focus(); popupInput.select(); } else { popupConfirmBtn.focus(); } }); }); } function confirmPopup(options) { return openPopup({ ...options, mode: "confirm" }).then((result) => Boolean(result)); } function promptPopup(options) { return openPopup({ ...options, mode: "prompt" }).then((result) => typeof result === "string" ? result : null); } function renderPersonaList(items, activeId, listElement, personaType) { if (!listElement) { renderPersonaSection(); return; } listElement.innerHTML = ""; if (!items.length) { const empty = document.createElement("p"); empty.className = "status persona-status"; empty.textContent = "No personas yet. Create one to get started."; listElement.appendChild(empty); renderPersonaSection(); return; } items.forEach((persona) => { const item = document.createElement("div"), titleRow = document.createElement("div"), title = document.createElement("h4"); const tag = document.createElement("span"), meta = document.createElement("p"), actions = document.createElement("div"); item.className = `persona-item${personaType === "user" && persona.id === activeId ? " active" : ""}`; titleRow.className = "persona-title-row"; actions.className = "persona-item-actions"; tag.className = `persona-role-tag ${personaType === "user" ? "user" : "ai"}`; tag.textContent = personaType === "user" ? "You" : "AI"; title.textContent = persona.name; meta.textContent = persona.pronouns ? `Pronouns: ${persona.pronouns}` : "Pronouns: n/a"; [[personaType === "assistant" ? "Start roleplay" : "Set default", () => equipPersona(persona.id, personaType), personaType === "user" && persona.id === activeId], ["Edit", () => openPersonaForm(persona), false], [publishedPersonaIds.has(persona.id) ? "Update listing" : "Publish", () => publishPersona(persona.id), false], ["Delete", () => deletePersona(persona.id), false]] .forEach(([label, handler, disabled]) => { const btn = document.createElement("button"); btn.type = "button"; btn.textContent = label; btn.disabled = disabled; btn.addEventListener("click", handler); actions.appendChild(btn); }); if (publishedPersonaIds.has(persona.id)) { const btn = document.createElement("button"); btn.type = "button"; btn.textContent = "Unpublish"; btn.addEventListener("click", () => unpublishPersona(persona.id)); actions.appendChild(btn); } titleRow.append(title, tag); item.append(titleRow, meta, actions); listElement.appendChild(item); }); renderPersonaSection(); } async function loadSummary() { updateWorkspaceCopy(); return currentSummary; } async function loadPersonas() { const res = await get("/personas"); if (res.error) return setNotice(res.error, "error"); assistantPersonas = res.assistantPersonas || []; userPersonas = res.userPersonas || []; activeUserPersonaId = res.activeUserPersonaId || null; publishedPersonaIds = new Set(res.publishedPersonaIds || []); renderPersonaList(userPersonas, activeUserPersonaId, userPersonaList, "user"); populateRoleplayStarter(); renderPersonaSection(); updateWorkspaceCopy(); } async function equipPersona(id, type) { if (type === "assistant") { openRoleplayStarter(id); return; } const res = await post(`/personas/${id}/equip-user`, {}); if (res.error) return setNotice(res.error, "error"); activeUserPersonaId = id; renderPersonaList(userPersonas, activeUserPersonaId, userPersonaList, "user"); setNotice("User persona updated.", "success"); } async function clearPersona(type) { if (type !== "user") return; const res = await post("/personas/user/clear", {}); if (res.error) return setNotice(res.error, "error"); activeUserPersonaId = null; renderPersonaList(userPersonas, activeUserPersonaId, userPersonaList, "user"); setNotice("Persona cleared.", "success"); } async function savePersona() { const payload = { personaType: personaTypeSelect.value, name: personaNameInput.value.trim(), pronouns: personaPronounsInput.value.trim(), appearance: personaAppearanceInput.value.trim(), background: personaBackgroundInput.value.trim(), details: personaDetailsInput.value.trim(), exampleDialogues: personaTypeSelect.value === "assistant" ? personaExampleDialoguesInput.value.trim() : "" }; if (!payload.name) return setPersonaFormNotice("Persona name is required.", "error"); const res = editingPersonaId ? await put(`/personas/${editingPersonaId}`, payload) : await post("/personas", payload); if (res.error) return setPersonaFormNotice(res.error, "error"); const wasEditing = Boolean(editingPersonaId); await loadPersonas(); if (payload.personaType === "assistant") await loadChatSessions(); closePersonaForm(); setNotice(wasEditing ? "Persona updated." : "Persona created.", "success"); } async function deletePersona(id) { const persona = assistantPersonas.find((item) => item.id === id) || userPersonas.find((item) => item.id === id); if (!persona) return; const confirmed = await confirmPopup({ eyebrow: persona.persona_type === "user" ? "Your persona" : "AI Character", title: "Delete persona", description: `Delete "${persona.name}"? This removes it from your library and any active slot.`, confirmLabel: "Delete", danger: true }); if (!confirmed) return; const res = await del(`/personas/${id}`); if (res.error) return setNotice(res.error, "error"); await loadPersonas(); if (persona.persona_type === "assistant") await loadChatSessions(); setNotice("Persona deleted.", "success"); } async function publishPersona(id) { const res = await post(`/personas/${id}/publish`, {}); if (res.error) return setNotice(res.error, "error"); await loadPersonas(); setNotice("Persona published.", "success"); } async function unpublishPersona(id) { const res = await post(`/personas/${id}/unpublish`, {}); if (res.error) return setNotice(res.error, "error"); await loadPersonas(); setNotice("Persona unpublished.", "success"); } function setLoadingState(loading, overlayOptions = null) { isProcessing = loading; msgInput.disabled = loading; webSearchButton.disabled = loading; reasoningToggle.setBusy(loading); attachmentComposer.setBusy(loading); sendBtn.classList.toggle("loading", loading); messagesDiv.querySelectorAll('[data-message-action="regenerate"]').forEach((button) => { button.disabled = loading || button.dataset.retryLimitReached === "true"; }); if (loading) { msgInput.placeholder = "Processing response..."; if (overlayOptions === false) { setChatActivity(false); } else { setChatActivity(true, overlayOptions || undefined); } } else { setChatActivity(false); updateComposerPlaceholder(); } updateSendState(); } async function stopGeneration() { const chatId = regenerationChatId ?? activeChatId; if (!isProcessing || !chatId) return; const res = await post(`/chats/${chatId}/stop`, {}); if (res.error) return setNotice(res.error, "error"); setNotice(res.stopped ? "Stopping generation..." : "Generation already finished."); } async function setActiveChat(id) { if (isMobileLayout()) closeMobileSidebar(); setChatLoading(true, "Opening chat..."); setChatActivity(true, { eyebrow: "Opening chat", title: "Loading conversation", detail: "Fetching messages and restoring the current scene." }); activeChatId = id; history.replaceState(null, "", `/app/chats/${id}`); messageRetryState = new Map(); updateWorkspaceCopy(); renderChatList(); updateChatActionState(); const res = await get(`/chats/${id}/messages`); if (res.error) { setChatLoading(false); setChatActivity(false); return setNotice(res.error, "error"); } const existing = getChatById(id); if (res.chat && existing) Object.assign(existing, res.chat); populateGenerationSettings(res.chat || existing); currentMessages = res.messages || []; renderMessages(); updateSendState(); setChatLoading(false); setChatActivity(false); } async function loadChatSessions() { setChatLoading(true, "Loading chats..."); const query = chatSearchInput?.value.trim(); const res = query ? await get(`/chats/search?q=${encodeURIComponent(query)}`) : await get("/chats"); if (res.error) { setChatLoading(false); return setNotice(res.error, "error"); } chatSessions = res.chats || []; renderChatList(); if (location.pathname.endsWith("/new") && !activeChatId) { currentMessages = []; renderMessages(); updateWorkspaceCopy(); updateSendState(); setChatLoading(false); return; } if (requestedChatId && !getChatById(requestedChatId) && !activeChatId) { setChatLoading(false); return setNotice("Conversation not found. Choose a chat from the sidebar or start a new one.", "error"); } if (chatSessions.length) { const preferredChatId = activeChatId && getChatById(activeChatId) ? activeChatId : (requestedChatId && getChatById(requestedChatId)) ? requestedChatId : chatSessions[0].id; setChatLoading(false); return setActiveChat(preferredChatId); } activeChatId = null; currentMessages = []; renderMessages(); updateWorkspaceCopy(); updateSendState(); setChatLoading(false); return setNotice("Pick a starting action to begin.", "success"); } async function createNewChat() { setChatLoading(true, "Creating chat..."); setChatActivity(true, { eyebrow: "Creating chat", title: "Opening a new conversation", detail: "Setting up a fresh chat so you can start sending messages." }); const res = await post("/chats", { title: "New chat" }); if (res.error || !res.chat) { setChatLoading(false); setChatActivity(false); return setNotice(res.error || "Unable to create chat.", "error"); } chatSessions.unshift(res.chat); await loadSummary(); renderChatList(); await setActiveChat(res.chat.id); setNotice("New chat created.", "success"); msgInput.focus(); setChatLoading(false); setChatActivity(false); } async function startRoleplay() { if (roleplayStarterStep === 1) { goToRoleplayStarterSceneStep(); return; } setChatLoading(true, "Starting roleplay..."); setChatActivity(true, { eyebrow: "Starting roleplay", title: "Building the opening scene", detail: "Generating the first beat and opening the roleplay chat." }); const assistantPersonaId = Number(roleplayCharacterSelect.value); const userPersonaId = roleplayUserPersonaSelect.value ? Number(roleplayUserPersonaSelect.value) : null; const scenarioPrompt = roleplayScenarioInput?.value.trim() || ""; if (!assistantPersonaId) { setChatLoading(false); setChatActivity(false); return setRoleplayStarterNotice("Choose a character to start the roleplay.", "error"); } roleplayStarterConfirm.disabled = true; setRoleplayStarterNotice("Building the opening scene..."); const res = await post("/roleplays/start", { assistantPersonaId, userPersonaId, scenarioPrompt, model: modelSelect.value }); roleplayStarterConfirm.disabled = false; if (res.error || !res.chat) { setChatLoading(false); setChatActivity(false); return setRoleplayStarterNotice(res.error || "Unable to start roleplay.", "error"); } closeRoleplayStarter(); await loadChatSessions(); await setActiveChat(res.chat.id); setNotice( res.degraded ? "Roleplay started, but the opening scene timed out. Send the first message to continue." : res.generatedInitialMessage ? "Roleplay started with a fresh scene." : "Roleplay reopened.", res.degraded ? "error" : "success" ); setChatLoading(false); setChatActivity(false); } async function renameChat(id) { const chat = getChatById(id); const nextTitle = chat ? await promptPopup({ eyebrow: "Conversation", title: "Rename chat", description: "Choose a new title for this thread.", label: "Chat title", value: chat.title, placeholder: "New chat", confirmLabel: "Save" }) : ""; const title = (nextTitle || "").trim(); if (!chat || !title) return; const res = await put(`/chats/${id}`, { title }); if (res.error) return setNotice(res.error, "error"); chat.title = title; updateWorkspaceCopy(); renderChatList(); setNotice("Chat renamed.", "success"); } async function updateChatOrganization(id, changes) { const chat = getChatById(id); if (!chat) return; const payload = { folderName: changes.folderName !== undefined ? changes.folderName : chat.folder_name, isPinned: changes.isPinned !== undefined ? changes.isPinned : Boolean(chat.is_pinned), archived: changes.archived !== undefined ? changes.archived : Boolean(chat.archived_at) }; const res = await put(`/chats/${id}/organization`, payload); if (res.error) return setNotice(res.error, "error"); const index = chatSessions.findIndex((item) => item.id === id); if (index >= 0) chatSessions[index] = res.chat; updateWorkspaceCopy(); renderChatList(); updateChatActionState(); } async function clearChat(id) { const chat = getChatById(id); if (!chat) return; const confirmed = await confirmPopup({ eyebrow: "Conversation", title: "Clear messages", description: `Clear all messages in "${chat.title}"? The chat will stay, but its history will be removed.`, confirmLabel: "Clear", danger: true }); if (!confirmed) return; const res = await post(`/chats/${id}/clear`, {}); if (res.error) return setNotice(res.error, "error"); messageRetryState = new Map(); currentMessages = []; renderMessages(); await loadSummary(); setNotice("Chat cleared.", "success"); } function exportCurrentChat() { const chat = getChatById(activeChatId); if (!chat || !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/${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"); const data = await response.json(); const blob = new Blob([JSON.stringify(data.workspace, null, 2)], { type: "application/json;charset=utf-8" }); const url = URL.createObjectURL(blob); const link = document.createElement("a"); link.href = url; link.download = `workspace-backup-${new Date().toISOString().slice(0, 10)}.json`; document.body.appendChild(link); link.click(); link.remove(); URL.revokeObjectURL(url); setNotice("Workspace backup downloaded.", "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..."); 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 (!activeChatId) return; const loaded = await get(`/chats/${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/${activeChatId}/memories/${target.id}`); if (removed.error) return setNotice(removed.error, "error"); return setNotice("Memory fact deleted.", "success"); } const saved = await post(`/chats/${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"], ["current_location", "Current location"], ["goals", "Goals"], ["unresolved_threads", "Unresolved threads"], ["boundaries", "Boundaries"] ]; function formatPersonaState(state = {}) { const lines = personaStateFields.map(([key, label], index) => { const value = String(state[key] || "").trim(); return `${index + 1}. ${label}: ${value || "Not set"}`; }); return lines.join("\n"); } async function managePersonaState() { if (!activeChatId) return; const loaded = await get(`/chats/${activeChatId}/persona-state`); if (loaded.error) return setNotice(loaded.error, "error"); const state = loaded.state || {}; const fieldChoice = await promptPopup({ eyebrow: "Roleplay", title: "Roleplay state", description: `${formatPersonaState(state)}\n\nType a field number to edit it.`, label: "Field number", placeholder: "1", confirmLabel: "Next" }); const selected = personaStateFields[Number(String(fieldChoice || "").trim()) - 1]; if (!selected) return; const [key, label] = selected; const value = await promptPopup({ eyebrow: "Roleplay", title: label, description: "Leave blank to clear this field.", label, value: state[key] || "", placeholder: label }); if (value === null) return; const nextState = { ...state, [key]: String(value || "").trim() || null }; const saved = await put(`/chats/${activeChatId}/persona-state`, nextState); if (saved.error) return setNotice(saved.error, "error"); return setNotice("Roleplay state saved.", "success"); } async function deleteChat(id) { setChatLoading(true, "Deleting chat..."); const chat = getChatById(id); if (!chat) { setChatLoading(false); return; } const confirmed = await confirmPopup({ eyebrow: "Conversation", title: "Delete chat", description: `Delete "${chat.title}"? This permanently removes the conversation.`, confirmLabel: "Delete", danger: true }); if (!confirmed) { setChatLoading(false); return; } const res = await del(`/chats/${id}`); if (res.error) { setChatLoading(false); return setNotice(res.error, "error"); } chatSessions = chatSessions.filter((item) => item.id !== id); if (activeChatId === id) { activeChatId = null; currentMessages = []; renderMessages(); } messageRetryState = new Map(); await loadSummary(); renderChatList(); if (chatSessions.length) await setActiveChat(chatSessions[0].id); else await createNewChat(); setNotice("Chat deleted.", "success"); setChatLoading(false); } async function displayModels() { const res = await get("/models"); const models = Array.isArray(res.models) ? res.models : null; if (res.error || !models) { availableModelsById = new Map(); modelSelect.innerHTML = ""; renderModelSection(); return setNotice("Models could not be loaded.", "error"); } modelSelect.innerHTML = ""; availableModelsById = new Map(models.map((model) => [String(model.model || model.name || ""), model])); const preferredModelId = "qwen3:14b"; const preferredIndex = models.findIndex((model) => (model.model || model.name) === preferredModelId); models.forEach((model, index) => { const modelId = String(model.model || model.name || ""); const option = document.createElement("option"); option.value = modelId; option.textContent = model.name || modelId; option.title = getModelProfile(model).summary; option.selected = preferredIndex >= 0 ? index === preferredIndex : index === 0; modelSelect.appendChild(option); }); const activeChat = getChatById(activeChatId); if (activeChat?.preferred_model && models.some((model) => (model.model || model.name) === activeChat.preferred_model)) { modelSelect.value = activeChat.preferred_model; } renderModelSection(); } async function checkSession() { const res = await get("/session"); if (!res.user) { window.location.replace(`/login?next=${encodeURIComponent(location.pathname + location.search)}`); return false; } currentUsername = res.user; chatDiv.classList.remove("hidden"); 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(); } msgInput.focus(); return true; } async function sendMessage() { if (isProcessing) return; if (attachmentComposer.pending) return; const attachments = attachmentComposer.attachments; const message = msgInput.value.trim() || (attachments.length ? "Describe the attached images." : ""); if (!message) return; const webSearch = webSearchToggle.enabled; const reasoning = reasoningToggle.enabled; if (!activeChatId) await createNewChat(); const currentChat = getChatById(activeChatId); if (currentChat && !currentChat.assistant_persona_id && currentChat.title === "New chat") { const autoTitle = message.length > 40 ? `${message.slice(0, 40)}...` : message; currentChat.title = autoTitle; updateWorkspaceCopy(); renderChatList(); await put(`/chats/${activeChatId}`, { title: autoTitle }); } const userMessage = { role: "user", content: message, attachments }; currentMessages.push(userMessage); addMessage(userMessage, true); updateChatActionState(); msgInput.value = ""; resizeComposerInput(); setLoadingState(true, false); setNotice("Generating reply..."); const loadingMsg = addMessage({ role: "bot", content: "" }, false, true, { isNewest: true }); const loadingBody = loadingMsg.querySelector(".msg-content"); const loadingShell = loadingMsg.querySelector(".loading-shell"); const loadingLabel = loadingMsg.querySelector(".loading-copy strong"); loadingLabel.textContent = "Preparing response..."; try { let streamError = null; await stream("/chat/stream", { message, attachments: attachments.map(item => item.id), model: modelSelect.value, chatId: activeChatId, webSearch, reasoning }, { onStatus: ({ status, sourceCount, searchState }) => { if (status === "accepted") { attachmentComposer.clear(); webSearchToggle.reset(); reasoningToggle.reset(); return; } loadingLabel.textContent = status === "analyzing" ? "Analyzing images..." : status === "searching" ? "Searching the web..." : searchState === "no_relevant_results" ? "No sufficiently relevant results. Generating response..." : sourceCount === 0 ? "No web results found. Generating response..." : "Generating response..."; }, onThinking: ({ fullThinking }) => { if (!reasoning) return; const follow = messagesDiv.scrollHeight - messagesDiv.scrollTop - messagesDiv.clientHeight < 100; updateReasoning(loadingMsg, fullThinking, true); loadingShell?.classList.toggle("hidden", Boolean(fullThinking)); if (follow) scrollMessagesToBottom(); }, onChunk: ({ fullReply }) => { const follow = messagesDiv.scrollHeight - messagesDiv.scrollTop - messagesDiv.clientHeight < 100; if (fullReply) finishReasoning(loadingMsg); if (loadingBody) { loadingShell?.classList.toggle("hidden", Boolean(fullReply)); loadingLabel.textContent = "Generating response..."; loadingBody.classList.add("has-content"); setMessageContent(loadingBody, fullReply); } if (follow) scrollMessagesToBottom(); }, onDone: () => { webSearchToggle.reset(); reasoningToggle.reset(); finishReasoning(loadingMsg); }, onError: ({ error }) => { streamError = error || "Failed to generate reply."; } }); const followOutput = messagesDiv.scrollHeight - messagesDiv.scrollTop - messagesDiv.clientHeight < 100; const previousScrollTop = messagesDiv.scrollTop; if (messagesDiv.contains(loadingMsg)) messagesDiv.removeChild(loadingMsg); if (streamError) { if (webSearch) { msgInput.value = message; resizeComposerInput(); } addMessage(`[Error: ${streamError}]`); setNotice(streamError, "error"); return; } await setActiveChat(activeChatId); await loadSummary(); markOnboardingCompleted(); if (!followOutput) requestAnimationFrame(() => { messagesDiv.scrollTop = previousScrollTop; }); maybeShowPersonaNudge(); setNotice("Reply received.", "success"); } catch (error) { if (messagesDiv.contains(loadingMsg)) messagesDiv.removeChild(loadingMsg); if (webSearch) { msgInput.value = message; resizeComposerInput(); } const errorMessage = error?.message || "Network error while sending message."; addMessage(`[Error: ${errorMessage}]`); setNotice(errorMessage, "error"); } finally { setLoadingState(false); msgInput.focus(); updateChatActionState(); } } const chatActions = { archive: async () => { const chat = getChatById(activeChatId); if (chat) await updateChatOrganization(chat.id, { archived: !chat.archived_at }); }, clear: async () => { if (activeChatId) await clearChat(activeChatId); }, export: () => { exportCurrentChat(); }, folder: async () => { const chat = getChatById(activeChatId); if (!chat) return; const folderName = await promptPopup({ eyebrow: "Folder", title: "Move chat to folder", description: "Leave blank to remove the folder.", label: "Folder name", value: chat.folder_name || "", placeholder: "Research" }); if (folderName === null) return; await updateChatOrganization(chat.id, { folderName: String(folderName || "").trim() || null }); }, memory: async () => { await manageChatMemory(); }, personaState: async () => { await managePersonaState(); }, pin: async () => { const chat = getChatById(activeChatId); if (chat) await updateChatOrganization(chat.id, { isPinned: !chat.is_pinned }); }, rename: async () => { if (activeChatId) await renameChat(activeChatId); } }; function bindChatDrawerActions() { document.querySelectorAll("[data-chat-action]").forEach((button) => { button.addEventListener("click", async () => { closeChatDrawer(); const action = chatActions[button.dataset.chatAction]; if (typeof action === "function") await action(); }); }); } sendBtn.addEventListener("click", () => { void (isProcessing ? stopGeneration() : sendMessage()); }); msgInput.addEventListener("keydown", (event) => { if (event.key === "Enter" && !event.shiftKey) { event.preventDefault(); void sendMessage(); } }); msgInput.addEventListener("input", function () { resizeComposerInput(); updateSendState(); }); resizeComposerInput(); newChatBtn.addEventListener("click", () => { closeChatDrawer(); closeMobileSidebar(); void createNewChat(); }); if (backupWorkspaceBtn) backupWorkspaceBtn.addEventListener("click", () => { closeChatDrawer(); void backupWorkspace(); }); if (importWorkspaceBtn) importWorkspaceBtn.addEventListener("click", () => { closeChatDrawer(); importWorkspaceFileInput.value = ""; importWorkspaceFileInput.click(); }); if (importWorkspaceFileInput) importWorkspaceFileInput.addEventListener("change", () => { void importWorkspaceFromFile(importWorkspaceFileInput.files?.[0]); }); chatSearchInput.addEventListener("input", () => { void loadChatSessions(); }); modelSelect.addEventListener("change", () => { renderModelSection(); void saveSelectedModel(); }); if (headerModelButton) headerModelButton.addEventListener("click", (event) => { event.stopPropagation(); openModelSettings(); }); saveGenerationSettingsBtn.addEventListener("click", () => { void saveGenerationSettings(); }); roleplayNewPersonaBtn.addEventListener("click", () => openPersonaForm(null, "assistant")); clearUserPersonaBtn.addEventListener("click", () => { closeChatDrawer(); void clearPersona("user"); }); personaForm.addEventListener("submit", (event) => { event.preventDefault(); void savePersona(); }); personaCloseBtn.addEventListener("click", closePersonaForm); personaTypeSelect.addEventListener("change", () => { const isAssistant = personaTypeSelect.value === "assistant"; personaExamplesField.classList.toggle("hidden", !isAssistant); if (!isAssistant) personaExampleDialoguesInput.value = ""; }); personaModal.addEventListener("click", (event) => { if (event.target === personaModal) closePersonaForm(); }); roleplayStarterClose.addEventListener("click", closeRoleplayStarter); roleplayStarterCancel.addEventListener("click", closeRoleplayStarter); if (roleplayStarterBack) { roleplayStarterBack.addEventListener("click", () => { roleplayStarterStep = 1; setRoleplayStarterNotice(""); renderRoleplayStarterStep(); roleplayCharacterSelect.focus(); }); } roleplayStarterConfirm.addEventListener("click", () => { void startRoleplay(); }); roleplayCharacterSelect.addEventListener("change", updateRoleplaySuggestions); roleplayUserPersonaSelect.addEventListener("change", updateRoleplaySuggestions); roleplayStarterModal.addEventListener("click", (event) => { if (event.target === roleplayStarterModal) closeRoleplayStarter(); }); popupConfirmBtn.addEventListener("click", () => { if (popupMode === "prompt") { closePopup(popupInput.value); return; } closePopup(true); }); popupCancelBtn.addEventListener("click", () => closePopup(false)); popupCloseBtn.addEventListener("click", () => closePopup(false)); popupModal.addEventListener("click", (event) => { if (event.target === popupModal) closePopup(false); }); popupInput.addEventListener("keydown", (event) => { if (event.key === "Enter") { event.preventDefault(); closePopup(popupInput.value); } }); bindChatDrawerActions(); if (chatSidebar) { chatSidebar.addEventListener("click", (event) => event.stopPropagation()); } if (chatSidebarToggle) { chatSidebarToggle.addEventListener("click", (event) => { event.stopPropagation(); if (document.body.classList.contains("sidebar-open")) { closeMobileSidebar(); return; } openMobileSidebar(); }); } if (onboardingContinue) { onboardingContinue.addEventListener("click", () => { onboardingStep = 2; renderOnboardingStep(); }); } if (onboardingBack) { onboardingBack.addEventListener("click", () => { onboardingStep = 1; renderOnboardingStep(); }); } if (onboardingSkip) { onboardingSkip.addEventListener("click", markOnboardingCompleted); } if (onboardingModal) { onboardingModal.addEventListener("click", (event) => { if (event.target === onboardingModal) markOnboardingCompleted(); }); } document.addEventListener("click", () => { closeMobileSidebar(); }); window.addEventListener("resize", () => { if (!isMobileLayout()) closeMobileSidebar(); }); document.addEventListener("keydown", (event) => { if (event.key === "Escape") { closeMobileSidebar(); closeRoleplayStarter(); closePersonaForm(); closePopup(false); closeOnboarding(); } }); window.addEventListener("load", async () => { const appearance = readStoredAppearance(); applyTheme(appearance.theme, false); applyThemeMode(appearance.mode, false); applyWorkspaceMode(localStorage.getItem("krishd-workspace-mode") || "basic", false); setNotice("Ready."); updateChatActionState(); updateSendState(); await checkSession(); });