import {createMessageRenderer} from "./messageRenderer.js"; import {createPersonaController} from "./personaController.js"; import {createConversationController} from "./conversationController.js"; import {createComposerController} from "./composerController.js"; import {createConversationState} from "./conversationState.js"; /** * @file Steuert die Chat-Oberfläche mit Gesprächsauswahl, Nachrichteneingabe, Streaming und Persona-Verwaltung. */ import {del, get, post, put} from "./api.js?v=20260909-account-controls"; import { regenerateInline } from "./regeneration.js"; import {createWebSearchToggle} from "./webSearch.js"; import {createAttachmentComposer} from "./attachments.js?v=20260930-chat-usability"; import {createReasoningToggle} from "./reasoning.js?v=20260921-reasoning-capability"; import { closeChatDrawer, openChatDrawer } from "./chatDrawer.js?v=20260909-account-controls"; import { openModal, closeModal, topModal } from "./modalFocus.js"; import {onboardingPrompts} from "./constants.js?v=20260909-account-controls"; import { getModelProfile } from "./modelProfiles.js?v=20260909-account-controls"; import { applyTheme as applySharedTheme, applyThemeMode as applySharedThemeMode, readStoredAppearance } from "./themeController.js?v=20260909-account-controls"; import { activeChatTitle, chatActivityDetail, chatActivityEyebrow, chatActivityOverlay, chatActivityTitle, chatCharacterPill, chatDiv, chatList, chatListLoading, chatListLoadingText, chatSidebar, chatSidebarToggle, chatSearchInput, chatUserPersonaPill, chatTemperatureInput, chatContextLengthInput, chatResponseLengthInput, chatSystemInstructionInput, clearChatBtn, archiveChatBtn, backupWorkspaceBtn, clearUserPersonaBtn, exportChatBtn, importWorkspaceBtn, manageChatMemoryBtn, managePersonaStateBtn, importWorkspaceFileInput, headerModelName, moveChatFolderBtn, messagesDiv, modelBadgeName, modelCount, modelHelpBadge, modelHelpSummary, modelHelpTitle, modelSection, modelSelect, msgInput, newChatBtn, onboardingBack, onboardingChoices, onboardingContinue, onboardingModal, onboardingSkip, onboardingSubtitle, onboardingTitle, pinChatBtn, personaCloseBtn, personaExampleDialoguesInput, personaExamplesField, personaForm, personaModal, personaSection, personaTypeSelect, popupCancelBtn, popupCloseBtn, popupConfirmBtn, popupDescription, popupEyebrow, popupField, popupInput, popupInputLabel, popupModal, popupTitle, renameChatBtn, roleplayCharacterSelect, roleplayNewPersonaBtn, roleplayStarterBack, roleplayStarterCancel, roleplayStarterClose, roleplayStarterConfirm, roleplayStarterModal, roleplayUserPersonaSelect, sendBtn, saveGenerationSettingsBtn, brandLogoTargets, brandNameTargets } from "./dom.js?v=20260909-account-controls"; const conversation = createConversationState(); const headerModelButton = document.getElementById("headerModelButton"); const webSearchButton = document.getElementById("webSearch"); const webSearchToggle = createWebSearchToggle(webSearchButton); const reasoningToggle = createReasoningToggle(document.getElementById("reasoning")); const attachmentComposer = createAttachmentComposer({ onChange: () => updateSendState(), onError: message => setNotice(message, "error") }); let popupResolver = null, popupMode = null, popupLastFocus = null; let workspaceMode = localStorage.getItem("krishd-workspace-mode") || "basic"; let onboardingStep = 1, onboardingIntent = "ask"; let chatLoadingDepth = 0; let chatActivityDepth = 0; let availableModelsById = new Map(); const {setMessageContent, scrollMessagesToBottom, updateJumpToLatest, addMessage, renderMessages} = createMessageRenderer({ getChatById: (...args) => getChatById(...args), state: conversation, setNotice: (...args) => setNotice(...args), sendMessage: (...args) => sendMessage(...args), editChatMessage: (...args) => editChatMessage(...args), deleteChatMessage: (...args) => deleteChatMessage(...args), branchFromMessage: (...args) => branchFromMessage(...args), retryLatestAssistantMessage: (...args) => retryLatestAssistantMessage(...args), switchResponseVersion: (...args) => switchResponseVersion(...args), switchRetryVariant: (...args) => switchRetryVariant(...args), renderDrawerSummary: (...args) => renderDrawerSummary(...args), updateChatActionState: (...args) => updateChatActionState(...args), updateComposerPlaceholder: (...args) => updateComposerPlaceholder(...args), createNewChat: (...args) => createNewChat(...args), openRoleplayStarter: (...args) => openRoleplayStarter(...args), openPersonaForm: (...args) => openPersonaForm(...args) }); const { updateRoleplaySuggestions, setRoleplayStarterNotice, renderRoleplayStarterStep, maybeShowPersonaNudge, openPersonaForm, closePersonaForm, openRoleplayStarter, closeRoleplayStarter, loadPersonas, clearPersona, savePersona, startRoleplay } = createPersonaController({ state: conversation, setNotice: (...args) => setNotice(...args), closePopup: (...args) => closePopup(...args), updateWorkspaceCopy: (...args) => updateWorkspaceCopy(...args), loadChatSessions: (...args) => loadChatSessions(...args), confirmPopup: (...args) => confirmPopup(...args), setChatLoading: (...args) => setChatLoading(...args), setChatActivity: (...args) => setChatActivity(...args), setActiveChat: (...args) => setActiveChat(...args) }); const { getChatById, openCreatedChat, setActiveChat, loadChatSessions, scheduleChatSearch, createNewChat } = createConversationController({ state: conversation, renderChatList: (...args) => renderChatList(...args), attachmentComposer, resizeComposerInput: (...args) => resizeComposerInput(...args), isMobileLayout: (...args) => isMobileLayout(...args), closeMobileSidebar: (...args) => closeMobileSidebar(...args), setChatLoading: (...args) => setChatLoading(...args), setChatActivity: (...args) => setChatActivity(...args), updateWorkspaceCopy: (...args) => updateWorkspaceCopy(...args), updateChatActionState: (...args) => updateChatActionState(...args), setNotice: (...args) => setNotice(...args), populateGenerationSettings: (...args) => populateGenerationSettings(...args), renderMessages: (...args) => renderMessages(...args), updateJumpToLatest: (...args) => updateJumpToLatest(...args), updateSendState: (...args) => updateSendState(...args), loadSummary: (...args) => loadSummary(...args) }); const {updateSendState, resizeComposerInput, setLoadingState, stopGeneration, sendMessage} = createComposerController({ state: conversation, elements: {msgInput, sendBtn, messagesDiv, modelSelect}, attachmentComposer, webSearchButton, reasoningToggle, setChatActivity: (...args) => setChatActivity(...args), updateComposerPlaceholder: (...args) => updateComposerPlaceholder(...args), setNotice: (...args) => setNotice(...args), webSearchToggle, createNewChat: (...args) => createNewChat(...args), getChatById: (...args) => getChatById(...args), updateWorkspaceCopy: (...args) => updateWorkspaceCopy(...args), renderChatList: (...args) => renderChatList(...args), addMessage: (...args) => addMessage(...args), updateChatActionState: (...args) => updateChatActionState(...args), scrollMessagesToBottom: (...args) => scrollMessagesToBottom(...args), setMessageContent: (...args) => setMessageContent(...args), setActiveChat: (...args) => setActiveChat(...args), loadSummary: (...args) => loadSummary(...args), markOnboardingCompleted: (...args) => markOnboardingCompleted(...args), maybeShowPersonaNudge: (...args) => maybeShowPersonaNudge(...args) }); 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 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 || !document.body.classList.contains("sidebar-open")) return; document.body.classList.remove("sidebar-open"); if (chatSidebarToggle) { chatSidebarToggle.setAttribute("aria-expanded", "false"); chatSidebarToggle.setAttribute("aria-label", "Open conversation history"); } chatSidebar.inert = isMobileLayout(); chatSidebar.removeAttribute("aria-modal"); chatSidebar.removeAttribute("role"); const backdrop = document.getElementById("chatSidebarBackdrop"); if (backdrop) backdrop.hidden = true; chatSidebarToggle?.focus(); } function openMobileSidebar() { if (!chatSidebar || !isMobileLayout()) return; chatSidebar.inert = false; chatSidebar.setAttribute("role", "dialog"); chatSidebar.setAttribute("aria-modal", "true"); document.body.classList.add("sidebar-open"); if (chatSidebarToggle) { chatSidebarToggle.setAttribute("aria-expanded", "true"); chatSidebarToggle.setAttribute("aria-label", "Close conversation history"); } const backdrop = document.getElementById("chatSidebarBackdrop"); if (backdrop) backdrop.hidden = false; chatSearchInput?.focus(); } function hasCompletedOnboarding() { return localStorage.getItem("krishd-onboarding-complete") === "true"; } function markOnboardingCompleted() { localStorage.setItem("krishd-onboarding-complete", "true"); closeOnboarding(); } function closeOnboarding() { if (!onboardingModal) return; closeModal(onboardingModal); } 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.querySelector(".active")?.focus(); }); 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 (conversation.assistantPersonas.length) { openRoleplayStarter(); } else { openPersonaForm(null, "assistant"); } return; } if (!conversation.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(); openModal(onboardingModal, onboardingChoices.querySelector("button")); } function updateComposerPlaceholder() { const activeChat = getChatById(conversation.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 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 (!conversation.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/${conversation.activeChatId}/generation-settings`, payload); if (res.error || !res.chat) return setNotice(res.error || "Unable to save generation settings.", "error"); const chat = getChatById(conversation.activeChatId); if (chat) Object.assign(chat, res.chat); populateGenerationSettings(res.chat); setNotice("Generation settings saved.", "success"); } async function saveSelectedModel() { if (!conversation.activeChatId || !modelSelect.value) return; const chatId = conversation.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 === conversation.activeChatId && modelSelect.value === selectedModel) { renderModelSection(); setNotice(`Model switched to ${modelSelect.selectedOptions[0]?.textContent || selectedModel}.`, "success"); } } messagesDiv.addEventListener("scroll", updateJumpToLatest, {passive: true}); document.getElementById("jumpToLatest").addEventListener("click", scrollMessagesToBottom); function getMessageIndexById(messageId) { return conversation.currentMessages.findIndex((message) => message.id === messageId); } async function resolveMessageTarget(messageId, fallbackIndex = -1) { if (messageId) { const index = getMessageIndexById(messageId); if (index >= 0) { return { index, message: conversation.currentMessages[index] }; } } if (conversation.activeChatId) { await setActiveChat(conversation.activeChatId); } if (messageId) { const index = getMessageIndexById(messageId); if (index >= 0) { return { index, message: conversation.currentMessages[index] }; } } if (fallbackIndex >= 0 && fallbackIndex < conversation.currentMessages.length) { return { index: fallbackIndex, message: conversation.currentMessages[fallbackIndex] }; } return null; } function getMessageEndpoint(target, suffix = "") { if (!conversation.activeChatId || !target) return ""; if (target.message?.id) return `/chats/${conversation.activeChatId}/messages/${target.message.id}${suffix}`; if (target.index >= 0) return `/chats/${conversation.activeChatId}/messages/by-index/${target.index}${suffix}`; return ""; } function getPreviousUserMessageId(messageId) { const index = getMessageIndexById(messageId); if (index <= 0) return null; for (let i = index - 1; i >= 0; i -= 1) { if (conversation.currentMessages[i].role === "user" && conversation.currentMessages[i].id) { return conversation.currentMessages[i].id; } } return null; } function getRetryStateFromMessage(message) { const variants = Array.isArray(message?.retryVariants) && message.retryVariants.length ? [...message.retryVariants] : [message?.content || ""]; const requestedActiveIndex = Number(message?.retryActiveIndex); const activeIndex = Number.isInteger(requestedActiveIndex) ? Math.min(Math.max(requestedActiveIndex, 0), variants.length - 1) : 0; return { variants, activeIndex, retriesUsed: Number(message?.retryRetriesUsed || 0), promptMessageId: message?.retryPromptMessageId ?? getPreviousUserMessageId(message?.id) }; } function syncRetryStateFromMessage(message) { if (!message?.id) return null; const state = getRetryStateFromMessage(message); conversation.messageRetryState.set(message.id, state); return state; } async function editChatMessage(messageId, fallbackIndex = -1) { const target = await resolveMessageTarget(messageId, fallbackIndex); if (!target) return setNotice("Message is not ready yet. Try again.", "error"); const { index, message } = target; const 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 = conversation.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"); } conversation.currentMessages[index] = { ...conversation.currentMessages[index], ...res.message }; syncRetryStateFromMessage(conversation.currentMessages[index]); renderMessages(); setNotice("Message updated.", "success"); } async function deleteChatMessage(messageId, fallbackIndex = -1) { const target = await resolveMessageTarget(messageId, fallbackIndex); if (!target) return setNotice("Message is not ready yet. Try again.", "error"); const { message } = target; const confirmed = await confirmPopup({ eyebrow: message.role === "user" ? "Your message" : "Assistant message", title: "Delete message", description: "Delete this message from the chat?", confirmLabel: "Delete", danger: true }); if (!confirmed) return; const endpoint = getMessageEndpoint(target); if (!endpoint) return setNotice("Message is not ready yet. Try again.", "error"); const res = await del(endpoint); if (res.error) return setNotice(res.error, "error"); conversation.currentMessages = conversation.currentMessages.filter((item) => item.id !== message.id); if (message.id) { conversation.messageRetryState.delete(message.id); for (const [key, state] of conversation.messageRetryState.entries()) { if (state.promptMessageId === message.id) { conversation.messageRetryState.delete(key); } } } renderMessages(); await loadSummary(); setNotice("Message deleted.", "success"); } async function retryLatestAssistantMessage(messageId, fallbackIndex = -1) { if (conversation.isProcessing) return; const target = await resolveMessageTarget(messageId, fallbackIndex); if (conversation.isProcessing) return; if (!target) return setNotice("Message is not ready yet. Try again.", "error"); if (target.message.role !== "bot") return setNotice("Only assistant messages can be regenerated.", "error"); const retriesUsed = Math.max(0, Number(target.message.versionCount || 1) - 1); if (retriesUsed >= 5) { return setNotice("Retry limit reached (5).", "error"); } const chatId = conversation.activeChatId; const card = messagesDiv.querySelector(`[data-message-id="${target.message.id}"]`); if (!card) return; const payload = { model: modelSelect.value, webSearch: webSearchToggle.enabled, reasoning: reasoningToggle.enabled }; conversation.regenerationChatId = chatId; setLoadingState(true, false); try { await regenerateInline({ card, scroller: messagesDiv, endpoint: `/chats/${chatId}/messages/${target.message.id}/retry/stream`, payload, renderContent: setMessageContent, complete: message => { if (conversation.activeChatId !== chatId) return; const index = getMessageIndexById(message.id); if (index < 0) return; conversation.currentMessages[index] = { ...conversation.currentMessages[index], ...message, thinking: message.thinking, sources: message.sources }; syncRetryStateFromMessage(conversation.currentMessages[index]); const rendered = addMessage(conversation.currentMessages[index], false, false, { messageIndex: index, detached: true }); card.replaceChildren(...rendered.childNodes); } }); webSearchToggle.reset(); reasoningToggle.reset(); setNotice("Reply regenerated.", "success"); } catch (error) { setNotice(error.message || "Unable to regenerate message.", "error"); } finally { conversation.regenerationChatId = null; setLoadingState(false); } } async function switchResponseVersion(messageId, direction, fallbackIndex = -1) { const target = await resolveMessageTarget(messageId, fallbackIndex); if (!target) return setNotice("Message is not ready yet. Try again.", "error"); if (Number(target.message.versionCount) > 1) { const chatId = conversation.activeChatId; const card = messagesDiv.querySelector(`[data-message-id="${messageId}"]`); if (!card || card.getAttribute("aria-busy") === "true") return; const buttons = [...card.querySelectorAll("button")].map(button => [button, button.disabled]); card.setAttribute("aria-busy", "true"); buttons.forEach(([button]) => { button.disabled = true; }); try { const res = await post(`/chats/${chatId}/messages/${messageId}/versions/${target.message.versionIndex + direction}`, {}); if (res.error) return setNotice(res.error, "error"); if (conversation.activeChatId !== chatId) return; const index = getMessageIndexById(messageId); if (index < 0) return; conversation.currentMessages[index] = { ...conversation.currentMessages[index], ...res.message, thinking: res.message.thinking, sources: res.message.sources }; syncRetryStateFromMessage(conversation.currentMessages[index]); const rendered = addMessage(conversation.currentMessages[index], false, false, { messageIndex: index, detached: true }); card.replaceChildren(...rendered.childNodes); } finally { card.removeAttribute("aria-busy"); buttons.forEach(([button, disabled]) => { button.disabled = disabled; }); } } } async function switchRetryVariant(messageId, direction, fallbackIndex = -1) { const target = await resolveMessageTarget(messageId, fallbackIndex); if (!target) return setNotice("Message is not ready yet. Try again.", "error"); const siblingId = direction < 0 ? target.message.previousSiblingId : target.message.nextSiblingId; if (!siblingId) return; const res = await post(`/chats/${conversation.activeChatId}/branches/${siblingId}/activate`, { includeDescendants: true }); if (res.error) return setNotice(res.error, "error"); conversation.currentMessages = res.messages || []; renderMessages(); } async function branchFromMessage(messageId) { if (!conversation.activeChatId || !messageId) return; const confirmed = await confirmPopup({ eyebrow: "Create branch", title: "Create a new chat branch?", description: "The new chat will contain the conversation through this message. The original chat stays unchanged.", confirmLabel: "Create branch" }); if (!confirmed) return; const sourceChatId = conversation.activeChatId; const res = await post(`/chats/${sourceChatId}/messages/${messageId}/branch`, { confirmed: true }); if (res.error) return setNotice(res.error, "error"); await openCreatedChat(res.chat); msgInput.focus(); setNotice("Created and opened a new chat branch.", "success"); } function updateWorkspaceCopy() { const activeChat = getChatById(conversation.activeChatId); if (activeChatTitle) { activeChatTitle.textContent = activeChat ? activeChat.title : "New chat"; } renderDrawerSummary(); updateComposerPlaceholder(); } function renderDrawerSummary() { const chat = getChatById(conversation.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(conversation.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 || conversation.currentMessages.length === 0; if (backupWorkspaceBtn) backupWorkspaceBtn.disabled = !conversation.currentUsername; if (importWorkspaceBtn) importWorkspaceBtn.disabled = !conversation.currentUsername; if (headerModelButton) headerModelButton.disabled = !hasChat; } function openModelSettings() { if (!conversation.activeChatId || !modelSection) return; modelSection.classList.remove("hidden"); modelSection.open = true; openChatDrawer(); requestAnimationFrame(() => modelSelect?.focus()); } function renderChatList() { const query = chatSearchInput.value.trim().toLowerCase(); const source = query ? (conversation.chatSearchResults ?? []) : conversation.chatSessions; const filtered = source.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 && conversation.chatSearchError ? conversation.chatSearchError : query && conversation.chatSearchResults === null ? "Searching chats..." : 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.is_pinned && !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("button"), title = document.createElement("span"), meta = document.createElement("small"), remove = document.createElement("button"); item.className = `chat-item${chat.id === conversation.activeChatId ? " active" : ""}`; item.setAttribute("role", "listitem"); content.type = "button"; content.className = "chat-item-copy secondary-action"; content.setAttribute("aria-label", `Open chat ${chat.title}`); if (chat.id === conversation.activeChatId) content.setAttribute("aria-current", "page"); 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(); closeMobileSidebar(); await deleteChat(chat.id); }); content.append(title, meta); if (query && chat.matched_content) { const excerpt = document.createElement("small"); const normalized = chat.matched_content.replace(/\s+/g, " "); const matchAt = normalized.toLowerCase().indexOf(query); const start = Math.max(0, matchAt - 35); excerpt.className = "chat-item-excerpt"; excerpt.textContent = `${start ? "…" : ""}${normalized.slice(start, start + 110)}${normalized.length > start + 110 ? "…" : ""}`; content.append(excerpt); } item.append(content, remove); content.addEventListener("click", () => { closeMobileSidebar(); void setActiveChat(chat.id, {matchMessageId: query ? chat.matched_message_id : null}); }); list.appendChild(item); }); chatList.appendChild(section); }); } 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; closeModal(popupModal); 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 = ""; } openModal(popupModal, options.mode === "prompt" ? popupInput : popupConfirmBtn); 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); } async function loadSummary() { updateWorkspaceCopy(); return conversation.currentSummary; } 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 = conversation.chatSessions.findIndex((item) => item.id === id); if (index >= 0) conversation.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"); conversation.messageRetryState = new Map(); conversation.currentMessages = []; renderMessages(); await loadSummary(); setNotice("Chat cleared.", "success"); } function exportCurrentChat() { const chat = getChatById(conversation.activeChatId); if (!chat || !conversation.currentMessages.length) return; void (async () => { const format = await promptPopup({ eyebrow: "Export", title: "Export format", description: "Use txt, md, or json.", label: "Format", value: "md", placeholder: "md", confirmLabel: "Download" }); const normalized = ["txt", "md", "json"].includes(String(format || "").trim().toLowerCase()) ? String(format).trim().toLowerCase() : "md"; const response = await fetch(`/exports/chats/${conversation.activeChatId}?format=${normalized}`); const blob = await response.blob(); const url = URL.createObjectURL(blob); const link = document.createElement("a"); link.href = url; link.download = `${(chat.title.replace(/[^a-z0-9-_]+/gi, "-").replace(/^-+|-+$/g, "") || "chat")}.${normalized}`; document.body.appendChild(link); link.click(); link.remove(); URL.revokeObjectURL(url); setNotice(`Chat exported as ${normalized}.`, "success"); })(); } async function backupWorkspace() { const response = await fetch("/exports/workspace/archive"); if (!response.ok) return setNotice("Workspace archive could not be created.", "error"); const blob = await response.blob(); const url = URL.createObjectURL(blob); const link = document.createElement("a"); link.href = url; link.download = `workspace-backup-${new Date().toISOString().slice(0, 10)}.tar.gz`; document.body.appendChild(link); link.click(); link.remove(); URL.revokeObjectURL(url); setNotice("Workspace archive downloaded with images.", "success"); } function formatImportPreview(preview, warnings = []) { const counts = [ `${preview.personas || 0} persona${preview.personas === 1 ? "" : "s"}`, `${preview.chats || 0} chat${preview.chats === 1 ? "" : "s"}`, `${preview.messages || 0} message${preview.messages === 1 ? "" : "s"}`, `${preview.templates || 0} template${preview.templates === 1 ? "" : "s"}` ].join(", "); const warningText = warnings.length ? ` Warnings: ${warnings.slice(0, 4).join("; ")}${warnings.length > 4 ? "; ..." : ""}` : ""; return `Import preview: ${counts}.${warningText}`; } async function importWorkspaceFromFile(file) { if (!file) return; setChatLoading(true, "Reading workspace backup..."); const isArchive = file.name.toLowerCase().endsWith(".tar.gz"); if (isArchive) { const submit = async (path, allowDuplicates = false) => { const body = new FormData(); body.append("archive", file); if (allowDuplicates) body.append("allowDuplicates", "true"); const response = await fetch(path, {method: "POST", body}); return response.json(); }; try { const preview = await submit("/imports/workspace/archive/preview"); setChatLoading(false); if (preview.error || preview.ok === false) return setNotice((preview.errors || []).slice(0, 4).join("; ") || preview.error || "Invalid archive.", "error"); const confirmed = await confirmPopup({eyebrow: "Workspace import", title: "Review import", description: formatImportPreview(preview.preview || {}, preview.warnings || []), confirmLabel: "Import"}); if (!confirmed) return; setChatLoading(true, "Importing workspace and images..."); const result = await submit("/imports/workspace/archive", Boolean(preview.preview?.duplicates?.length)); if (result.error) return setNotice(result.error, "error"); await Promise.all([loadPersonas(), loadChatSessions()]); setNotice(`Workspace imported: ${result.imported.chats} chats with their images.`, "success"); } catch { setNotice("Workspace archive could not be imported.", "error"); } finally { setChatLoading(false); } return; } let workspace; try { workspace = JSON.parse(await file.text()); } catch { setChatLoading(false); return setNotice("Import failed: choose a valid JSON workspace backup.", "error"); } const preview = await post("/imports/workspace/preview", { workspace }); if (preview.error || preview.ok === false) { setChatLoading(false); const detail = (preview.errors || []).slice(0, 4).join("; "); return setNotice(detail || preview.error || "Workspace import validation failed.", "error"); } setChatLoading(false); const confirmed = await confirmPopup({ eyebrow: "Workspace import", title: "Review import", description: formatImportPreview(preview.preview || {}, preview.warnings || []), confirmLabel: "Import" }); if (!confirmed) return; setChatLoading(true, "Importing workspace..."); const imported = await post("/imports/workspace", { workspace, allowDuplicates: Boolean(preview.preview?.duplicates?.length) }); if (imported.error) { setChatLoading(false); const detail = (imported.errors || imported.warnings || []).slice(0, 4).join("; "); return setNotice(detail || imported.error, "error"); } await Promise.all([loadPersonas(), loadChatSessions()]); setChatLoading(false); setNotice(`Workspace imported: ${imported.imported.personas} personas, ${imported.imported.chats} chats, ${imported.imported.templates} templates.`, "success"); } function formatMemoryList(memories) { if (!memories.length) return "No memory facts saved for this chat yet."; return memories.map((memory, index) => `${index + 1}. ${memory.fact}`).join("\n"); } async function manageChatMemory() { if (!conversation.activeChatId) return; const loaded = await get(`/chats/${conversation.activeChatId}/memories`); if (loaded.error) return setNotice(loaded.error, "error"); const memories = loaded.memories || []; const action = await promptPopup({ eyebrow: "Memory", title: "Memory facts", description: `${formatMemoryList(memories)}\n\nType a new fact to add it, or type delete 1 to remove a fact.`, label: "Fact or command", placeholder: "The user prefers terse answers.", confirmLabel: "Save" }); const value = String(action || "").trim(); if (!value) return; const deleteMatch = value.match(/^delete\s+(\d+)$/i); if (deleteMatch) { const target = memories[Number(deleteMatch[1]) - 1]; if (!target) return setNotice("Memory number not found.", "error"); const removed = await del(`/chats/${conversation.activeChatId}/memories/${target.id}`); if (removed.error) return setNotice(removed.error, "error"); return setNotice("Memory fact deleted.", "success"); } const saved = await post(`/chats/${conversation.activeChatId}/memories`, { fact: value }); if (saved.error) return setNotice(saved.error, "error"); return setNotice("Memory fact saved.", "success"); } const personaStateFields = [ ["relationship_notes", "Relationship notes"], ["tone", "Tone"], ["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 (!conversation.activeChatId) return; const loaded = await get(`/chats/${conversation.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/${conversation.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"); } conversation.chatSessions = conversation.chatSessions.filter((item) => item.id !== id); conversation.chatDrafts.delete(id); attachmentComposer.discardDraft(id); if (conversation.activeChatId === id) { attachmentComposer.discard(); msgInput.value = ""; conversation.activeChatId = null; conversation.currentMessages = []; renderMessages(); } conversation.messageRetryState = new Map(); await loadSummary(); renderChatList(); if (conversation.chatSessions.length) await setActiveChat(conversation.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(conversation.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; } conversation.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(); if (!starter && !conversation.activeChatId) maybeShowOnboarding(); return true; } const chatActions = { archive: async () => { const chat = getChatById(conversation.activeChatId); if (chat) await updateChatOrganization(chat.id, { archived: !chat.archived_at }); }, clear: async () => { if (conversation.activeChatId) await clearChat(conversation.activeChatId); }, export: () => { exportCurrentChat(); }, folder: async () => { const chat = getChatById(conversation.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(conversation.activeChatId); if (chat) await updateChatOrganization(chat.id, { isPinned: !chat.is_pinned }); }, rename: async () => { if (conversation.activeChatId) await renameChat(conversation.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 (conversation.isProcessing ? stopGeneration() : sendMessage()); }); msgInput.addEventListener("keydown", (event) => { if (event.key === "Enter" && !event.shiftKey && !event.isComposing) { 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", scheduleChatSearch); modelSelect.addEventListener("change", () => { renderModelSection(); void saveSelectedModel(); }); if (headerModelButton) headerModelButton.addEventListener("click", (event) => { event.stopPropagation(); openModelSettings(); }); saveGenerationSettingsBtn.addEventListener("click", () => { void saveGenerationSettings(); }); roleplayNewPersonaBtn.addEventListener("click", () => { closeRoleplayStarter(); 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", () => { conversation.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.inert = isMobileLayout(); if (chatSidebar) { chatSidebar.addEventListener("click", (event) => event.stopPropagation()); } document.getElementById("chatSidebarBackdrop")?.addEventListener("click", closeMobileSidebar); 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(); onboardingChoices.querySelector("button")?.focus(); }); } if (onboardingBack) { onboardingBack.addEventListener("click", () => { onboardingStep = 1; renderOnboardingStep(); (onboardingChoices.querySelector(".active") || onboardingChoices.querySelector("button"))?.focus(); }); } if (onboardingSkip) { onboardingSkip.addEventListener("click", markOnboardingCompleted); } if (onboardingModal) { onboardingModal.addEventListener("click", (event) => { if (event.target === onboardingModal) markOnboardingCompleted(); }); } document.addEventListener("click", (event) => { if (!event.target.closest(".msg-action-menu")) { document.querySelectorAll(".msg-action-menu[open]").forEach((menu) => { menu.open = false; }); } closeMobileSidebar(); }); window.addEventListener("resize", () => { if (!isMobileLayout()) closeMobileSidebar(); if (chatSidebar) chatSidebar.inert = isMobileLayout() && !document.body.classList.contains("sidebar-open"); }); document.addEventListener("keydown", (event) => { if (event.key === "Tab" && isMobileLayout() && document.body.classList.contains("sidebar-open")) { const focusables = [...chatSidebar.querySelectorAll('button:not([disabled]), input:not([disabled]), [tabindex="0"]')] .filter((element) => !element.hidden && !element.closest("[hidden]")); const first = focusables[0], last = focusables.at(-1); if (event.shiftKey && (document.activeElement === first || !chatSidebar.contains(document.activeElement))) { event.preventDefault(); last?.focus(); } else if (!event.shiftKey && (document.activeElement === last || !chatSidebar.contains(document.activeElement))) { event.preventDefault(); first?.focus(); } } if (event.key === "Escape") { const activeModal = topModal(); if (activeModal) { event.preventDefault(); if (activeModal === popupModal) closePopup(false); else if (activeModal === personaModal) closePersonaForm(); else if (activeModal === roleplayStarterModal) closeRoleplayStarter(); else if (activeModal === onboardingModal) closeOnboarding(); return; } const openMenu = document.querySelector(".msg-action-menu[open]"); if (openMenu) { openMenu.open = false; openMenu.querySelector("summary")?.focus(); } 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(); });