import {createMemoryPanel} from "./memoryPanel.js"; import {createBranchExplorer} from "./branchExplorer.js"; import {createWorkspaceTransfer} from "./workspaceTransfer.js"; import {createDraftStore} from "./draftStore.js"; import {createMessageRenderer} from "./messageRenderer.js"; import {createPersonaController} from "./personaController.js"; import {createConversationController} from "./conversationController.js"; 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 {createMessageActions} from "./messageActions.js"; import {createWebSearchToggle} from "./webSearch.js"; import {createAttachmentComposer} from "./attachments.js?v=20260930-chat-usability"; import {createReasoningToggle} from "./reasoning.js?v=20260921-reasoning-capability"; 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 stopBtn = document.getElementById("stopGeneration"); 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 noticeTimer = null; let availableModelsById = new Map(); const personalitySelect = document.getElementById("personalitySelect"); let personalities = []; const {setMessageContent, scrollMessagesToBottom, updateJumpToLatest, addMessage, renderMessages, announceResponding, announceReply, announceResponseStatus} = 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), openMessage: messageId => setActiveChat(conversation.activeChatId, {matchMessageId: messageId}) }); const { updateRoleplaySuggestions, setRoleplayStarterNotice, renderRoleplayStarterStep, maybeShowPersonaNudge, closePersonaForm, closeRoleplayStarter, clearChatUserPersona, renderPersonaSection, 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, loadOlderMessages } = 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), updateHistoryControls: () => updateHistoryControls(), getPersonalityId: () => personalitySelect?.value || "general-assistant" }); const {updateSendState, resizeComposerInput, setLoadingState, stopGeneration, sendMessage} = createComposerController({ state: conversation, elements: {msgInput, sendBtn, stopBtn, 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), announceResponding, announceReply, announceResponseStatus, setActiveChat: (...args) => setActiveChat(...args), loadSummary: (...args) => loadSummary(...args), markOnboardingCompleted: (...args) => markOnboardingCompleted(...args), maybeShowPersonaNudge: (...args) => maybeShowPersonaNudge(...args) }); const {exportCurrentChat, backupWorkspace, importWorkspaceFromFile} = createWorkspaceTransfer({ conversation, getChatById, setNotice, promptPopup, confirmPopup, setChatLoading, loadChatSessions }); const {editChatMessage, deleteChatMessage, retryLatestAssistantMessage, switchResponseVersion, switchRetryVariant, branchFromMessage} = createMessageActions({ conversation, setNotice, promptPopup, confirmPopup, setActiveChat, openCreatedChat, setLoadingState, loadSummary, renderMessages, addMessage, setMessageContent, announceReply, announceResponseStatus, webSearchToggle, reasoningToggle, modelSelect, messagesDiv, msgInput }); const memoryPanel = createMemoryPanel({state: conversation}); const branchExplorer = createBranchExplorer({state: conversation, setActiveChat, setMessageContent}); function setNotice(message = "", state = "") { const notice = document.getElementById("workspaceNotice"); clearTimeout(noticeTimer); if (notice) { const visible = state === "error" || state === "success"; notice.textContent = visible ? message : ""; notice.classList.toggle("hidden", !visible); notice.classList.toggle("error", state === "error"); notice.classList.toggle("success", state === "success"); if (state === "success") noticeTimer = setTimeout(() => setNotice(), 3500); } 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", "Explore ideas"], ["write", "Write something"], ["learn", "Learn a topic"]].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 (!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() { if (conversation.isProcessing) { msgInput.placeholder = "Draft your next message..."; return; } 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 updateWorkspaceCopy() { const activeChat = getChatById(conversation.activeChatId); if (activeChatTitle) { activeChatTitle.textContent = activeChat ? activeChat.title : "New chat"; } if (personalitySelect) { personalitySelect.value = activeChat?.personality_id || personalitySelect.value || "general-assistant"; syncPersonalityIdentity(); } renderDrawerSummary(); renderPersonaSection(); updateComposerPlaceholder(); } function syncPersonalityIdentity() { const personality = personalities.find(item => item.id === personalitySelect?.value); if (personality && personalitySelect) personalitySelect.style.setProperty("--personality-accent", personality.accentColor); } async function loadPersonalities() { const response = await get("/personalities"); if (response.error) return setNotice(response.error, "error"); personalities = response.personalities || []; if (!personalitySelect) return; personalitySelect.replaceChildren(...personalities.map(item => { const option = document.createElement("option"); option.value = item.id; option.textContent = `${item.name} · ${item.specialization}${item.isDefault ? " · Default" : ""}`; option.title = `${item.description} Inspired by ${item.historicalInspiration}.`; return option; })); const requested = new URLSearchParams(location.search).get("personality"); personalitySelect.value = getChatById(conversation.activeChatId)?.personality_id || (personalities.some(item => item.id === requested) ? requested : response.defaultPersonalityId) || "general-assistant"; syncPersonalityIdentity(); } personalitySelect?.addEventListener("change", async () => { const chatId = conversation.activeChatId; const selected = personalitySelect.value; syncPersonalityIdentity(); if (!chatId) return; const response = await put(`/chats/${chatId}/personality`, {personalityId: selected}); if (response.error || !response.chat) { updateWorkspaceCopy(); return setNotice(response.error || "Unable to change personality.", "error"); } Object.assign(getChatById(chatId) || {}, response.chat); updateWorkspaceCopy(); setNotice(`Personality changed to ${response.personality.name}.`, "success"); }); 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() { updateHistoryControls(); 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; document.getElementById("exploreBranches").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 updateHistoryControls() { const older = document.getElementById("loadOlderMessages"); older.hidden = !conversation.historyPage?.hasMore; older.disabled = conversation.historyLoading; older.textContent = conversation.historyLoading ? "Loading messages..." : "Load older messages"; document.getElementById("loadLatestMessages").hidden = !conversation.historyPage?.hasNewer; } function openModelSettings() { if (!conversation.activeChatId || !modelSection) return; modelSection.classList.remove("hidden"); modelSection.open = true; openChatDrawer(); requestAnimationFrame(() => modelSelect?.focus()); } function formatChatActivity(value) { if (!value) return ""; const date = new Date(value.includes("T") ? value : `${value.replace(" ", "T")}Z`); if (Number.isNaN(date.getTime())) return ""; const today = new Date(); const sameDay = (a, b) => a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate(); if (sameDay(date, today)) return new Intl.DateTimeFormat(undefined, {hour: "numeric", minute: "2-digit"}).format(date); const yesterday = new Date(today); yesterday.setDate(today.getDate() - 1); if (sameDay(date, yesterday)) return "Yesterday"; return new Intl.DateTimeFormat(undefined, {month: "short", day: "numeric", ...(date.getFullYear() === today.getFullYear() ? {} : {year: "numeric"})}).format(date); } 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"; const activity = formatChatActivity(chat.updated_at); meta.textContent = [chat.source_chat_id ? `Branch · ${chatType}` : chatType, activity].filter(Boolean).join(" · "); 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); const preview = query && chat.matched_content ? chat.matched_content : chat.last_message_preview; if (preview) { const excerpt = document.createElement("small"); const normalized = preview.replace(/\s+/g, " ").trim(); const matchAt = query ? normalized.toLowerCase().indexOf(query) : -1; 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] = {...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.historyPage = null; chat.last_message_preview = null; chat.updated_at = new Date().toISOString(); conversation.currentMessages = []; renderMessages(); renderChatList(); await loadSummary(); setNotice("Chat cleared.", "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"); const drafts = createDraftStore(res.user, {onError: message => setNotice(message, "error")}); conversation.chatDrafts = drafts.textDrafts; attachmentComposer.setDraftStore(drafts); if (location.pathname.endsWith("/new")) { msgInput.value = drafts.textDrafts.get("new") || ""; attachmentComposer.restoreDraft(null); resizeComposerInput(); } await Promise.all([displayModels(), loadSummary(), loadChatSessions(), loadPersonalities()]); // Starters are offered on workspace home. const starter = new URLSearchParams(location.search).get("prompt"); if (starter && !msgInput.value) { msgInput.value = starter; msgInput.dispatchEvent(new Event("input")); } 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 memoryPanel.open(); }, branches: () => branchExplorer.open(), 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 sendMessage(); }); stopBtn.addEventListener("click", () => { void stopGeneration(); }); msgInput.addEventListener("keydown", (event) => { if (event.key === "Enter" && !event.shiftKey && !event.isComposing && !conversation.isProcessing) { event.preventDefault(); void sendMessage(); } }); msgInput.addEventListener("input", function () { if (conversation.currentUsername) conversation.chatDrafts.set(conversation.activeChatId ?? "new", msgInput.value); resizeComposerInput(); updateSendState(); }); document.getElementById("loadOlderMessages").addEventListener("click", () => { void loadOlderMessages(); }); document.getElementById("loadLatestMessages").addEventListener("click", () => { void setActiveChat(conversation.activeChatId); }); resizeComposerInput(); newChatBtn.addEventListener("click", () => { closeChatDrawer(); 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); if (clearUserPersonaBtn) clearUserPersonaBtn.addEventListener("click", () => { closeChatDrawer(); void clearChatUserPersona(); }); 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(); else closeModal(activeModal); 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(); });