Files
KrisHD_AI/public/script.js
T

2127 lines
89 KiB
JavaScript

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