Files
KrisHD_AI/public/js/script.js
T
Frederik Spirgi 8666e09049
CI / check (22) (push) Failing after 16m26s
CI / check (24) (push) Failing after 15m50s
make some overall QoL improvements
2026-10-08 11:02:32 +02:00

1160 lines
48 KiB
JavaScript

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 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");
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", "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 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.historyPage = null;
conversation.currentMessages = []; renderMessages(); 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 = "<option>Error loading models</option>";
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();
});