1485 lines
62 KiB
JavaScript
1485 lines
62 KiB
JavaScript
import {createMessageRenderer} from "./messageRenderer.js";
|
|
import {createPersonaController} from "./personaController.js";
|
|
import {createConversationController} from "./conversationController.js";
|
|
import {createComposerController} from "./composerController.js";
|
|
import {createConversationState} from "./conversationState.js";
|
|
/**
|
|
* @file Steuert die Chat-Oberfläche mit Gesprächsauswahl, Nachrichteneingabe, Streaming und Persona-Verwaltung.
|
|
*/
|
|
import {del, get, post, put} from "./api.js?v=20260909-account-controls";
|
|
import { regenerateInline } from "./regeneration.js";
|
|
import {createWebSearchToggle} from "./webSearch.js";
|
|
import {createAttachmentComposer} from "./attachments.js?v=20260930-chat-usability";
|
|
import {createReasoningToggle} from "./reasoning.js?v=20260921-reasoning-capability";
|
|
import { closeChatDrawer, openChatDrawer } from "./chatDrawer.js?v=20260909-account-controls";
|
|
import { openModal, closeModal, topModal } from "./modalFocus.js";
|
|
import {onboardingPrompts} from "./constants.js?v=20260909-account-controls";
|
|
import { getModelProfile } from "./modelProfiles.js?v=20260909-account-controls";
|
|
import {
|
|
applyTheme as applySharedTheme,
|
|
applyThemeMode as applySharedThemeMode,
|
|
readStoredAppearance
|
|
} from "./themeController.js?v=20260909-account-controls";
|
|
import {
|
|
activeChatTitle,
|
|
chatActivityDetail,
|
|
chatActivityEyebrow,
|
|
chatActivityOverlay,
|
|
chatActivityTitle,
|
|
chatCharacterPill,
|
|
chatDiv,
|
|
chatList,
|
|
chatListLoading,
|
|
chatListLoadingText,
|
|
chatSidebar,
|
|
chatSidebarToggle,
|
|
chatSearchInput,
|
|
chatUserPersonaPill,
|
|
chatTemperatureInput,
|
|
chatContextLengthInput,
|
|
chatResponseLengthInput,
|
|
chatSystemInstructionInput,
|
|
clearChatBtn,
|
|
archiveChatBtn,
|
|
backupWorkspaceBtn,
|
|
clearUserPersonaBtn,
|
|
exportChatBtn,
|
|
importWorkspaceBtn,
|
|
manageChatMemoryBtn,
|
|
managePersonaStateBtn,
|
|
importWorkspaceFileInput,
|
|
headerModelName,
|
|
moveChatFolderBtn,
|
|
messagesDiv,
|
|
modelBadgeName,
|
|
modelCount,
|
|
modelHelpBadge,
|
|
modelHelpSummary,
|
|
modelHelpTitle,
|
|
modelSection,
|
|
modelSelect,
|
|
msgInput,
|
|
newChatBtn,
|
|
onboardingBack,
|
|
onboardingChoices,
|
|
onboardingContinue,
|
|
onboardingModal,
|
|
onboardingSkip,
|
|
onboardingSubtitle,
|
|
onboardingTitle,
|
|
pinChatBtn,
|
|
personaCloseBtn,
|
|
personaExampleDialoguesInput,
|
|
personaExamplesField,
|
|
personaForm,
|
|
personaModal,
|
|
personaSection,
|
|
personaTypeSelect,
|
|
popupCancelBtn,
|
|
popupCloseBtn,
|
|
popupConfirmBtn,
|
|
popupDescription,
|
|
popupEyebrow,
|
|
popupField,
|
|
popupInput,
|
|
popupInputLabel,
|
|
popupModal,
|
|
popupTitle,
|
|
renameChatBtn,
|
|
roleplayCharacterSelect,
|
|
roleplayNewPersonaBtn,
|
|
roleplayStarterBack,
|
|
roleplayStarterCancel,
|
|
roleplayStarterClose,
|
|
roleplayStarterConfirm,
|
|
roleplayStarterModal,
|
|
roleplayUserPersonaSelect,
|
|
sendBtn,
|
|
saveGenerationSettingsBtn,
|
|
brandLogoTargets,
|
|
brandNameTargets
|
|
} from "./dom.js?v=20260909-account-controls";
|
|
|
|
const conversation = createConversationState();
|
|
|
|
const headerModelButton = document.getElementById("headerModelButton");
|
|
const webSearchButton = document.getElementById("webSearch");
|
|
const webSearchToggle = createWebSearchToggle(webSearchButton);
|
|
const reasoningToggle = createReasoningToggle(document.getElementById("reasoning"));
|
|
|
|
const attachmentComposer = createAttachmentComposer({ onChange: () => updateSendState(), onError: message => setNotice(message, "error") });
|
|
|
|
let popupResolver = null, popupMode = null, popupLastFocus = null;
|
|
|
|
let workspaceMode = localStorage.getItem("krishd-workspace-mode") || "basic";
|
|
let onboardingStep = 1, onboardingIntent = "ask";
|
|
let chatLoadingDepth = 0;
|
|
let chatActivityDepth = 0;
|
|
|
|
let availableModelsById = new Map();
|
|
|
|
const {setMessageContent, scrollMessagesToBottom, updateJumpToLatest, addMessage, renderMessages} = createMessageRenderer({
|
|
getChatById: (...args) => getChatById(...args),
|
|
state: conversation,
|
|
setNotice: (...args) => setNotice(...args),
|
|
sendMessage: (...args) => sendMessage(...args),
|
|
editChatMessage: (...args) => editChatMessage(...args),
|
|
deleteChatMessage: (...args) => deleteChatMessage(...args),
|
|
branchFromMessage: (...args) => branchFromMessage(...args),
|
|
retryLatestAssistantMessage: (...args) => retryLatestAssistantMessage(...args),
|
|
switchResponseVersion: (...args) => switchResponseVersion(...args),
|
|
switchRetryVariant: (...args) => switchRetryVariant(...args),
|
|
renderDrawerSummary: (...args) => renderDrawerSummary(...args),
|
|
updateChatActionState: (...args) => updateChatActionState(...args),
|
|
updateComposerPlaceholder: (...args) => updateComposerPlaceholder(...args),
|
|
createNewChat: (...args) => createNewChat(...args),
|
|
openRoleplayStarter: (...args) => openRoleplayStarter(...args),
|
|
openPersonaForm: (...args) => openPersonaForm(...args)
|
|
});
|
|
|
|
const {
|
|
updateRoleplaySuggestions,
|
|
setRoleplayStarterNotice,
|
|
renderRoleplayStarterStep,
|
|
maybeShowPersonaNudge,
|
|
openPersonaForm,
|
|
closePersonaForm,
|
|
openRoleplayStarter,
|
|
closeRoleplayStarter,
|
|
loadPersonas,
|
|
clearPersona,
|
|
savePersona,
|
|
startRoleplay
|
|
} = createPersonaController({
|
|
state: conversation,
|
|
setNotice: (...args) => setNotice(...args),
|
|
closePopup: (...args) => closePopup(...args),
|
|
updateWorkspaceCopy: (...args) => updateWorkspaceCopy(...args),
|
|
loadChatSessions: (...args) => loadChatSessions(...args),
|
|
confirmPopup: (...args) => confirmPopup(...args),
|
|
setChatLoading: (...args) => setChatLoading(...args),
|
|
setChatActivity: (...args) => setChatActivity(...args),
|
|
setActiveChat: (...args) => setActiveChat(...args)
|
|
});
|
|
|
|
const {
|
|
getChatById,
|
|
openCreatedChat,
|
|
setActiveChat,
|
|
loadChatSessions,
|
|
scheduleChatSearch,
|
|
createNewChat
|
|
} = createConversationController({
|
|
state: conversation,
|
|
renderChatList: (...args) => renderChatList(...args),
|
|
attachmentComposer,
|
|
resizeComposerInput: (...args) => resizeComposerInput(...args),
|
|
isMobileLayout: (...args) => isMobileLayout(...args),
|
|
closeMobileSidebar: (...args) => closeMobileSidebar(...args),
|
|
setChatLoading: (...args) => setChatLoading(...args),
|
|
setChatActivity: (...args) => setChatActivity(...args),
|
|
updateWorkspaceCopy: (...args) => updateWorkspaceCopy(...args),
|
|
updateChatActionState: (...args) => updateChatActionState(...args),
|
|
setNotice: (...args) => setNotice(...args),
|
|
populateGenerationSettings: (...args) => populateGenerationSettings(...args),
|
|
renderMessages: (...args) => renderMessages(...args),
|
|
updateJumpToLatest: (...args) => updateJumpToLatest(...args),
|
|
updateSendState: (...args) => updateSendState(...args),
|
|
loadSummary: (...args) => loadSummary(...args)
|
|
});
|
|
|
|
const {updateSendState, resizeComposerInput, setLoadingState, stopGeneration, sendMessage} = createComposerController({
|
|
state: conversation,
|
|
elements: {msgInput, sendBtn, messagesDiv, modelSelect},
|
|
attachmentComposer,
|
|
webSearchButton,
|
|
reasoningToggle,
|
|
setChatActivity: (...args) => setChatActivity(...args),
|
|
updateComposerPlaceholder: (...args) => updateComposerPlaceholder(...args),
|
|
setNotice: (...args) => setNotice(...args),
|
|
webSearchToggle,
|
|
createNewChat: (...args) => createNewChat(...args),
|
|
getChatById: (...args) => getChatById(...args),
|
|
updateWorkspaceCopy: (...args) => updateWorkspaceCopy(...args),
|
|
renderChatList: (...args) => renderChatList(...args),
|
|
addMessage: (...args) => addMessage(...args),
|
|
updateChatActionState: (...args) => updateChatActionState(...args),
|
|
scrollMessagesToBottom: (...args) => scrollMessagesToBottom(...args),
|
|
setMessageContent: (...args) => setMessageContent(...args),
|
|
setActiveChat: (...args) => setActiveChat(...args),
|
|
loadSummary: (...args) => loadSummary(...args),
|
|
markOnboardingCompleted: (...args) => markOnboardingCompleted(...args),
|
|
maybeShowPersonaNudge: (...args) => maybeShowPersonaNudge(...args)
|
|
});
|
|
|
|
function setNotice(message = "", state = "") {
|
|
const notice = document.getElementById("workspaceNotice");
|
|
if (notice) { notice.textContent = state === "error" ? message : ""; notice.classList.toggle("hidden", state !== "error"); }
|
|
return { message, state };
|
|
}
|
|
|
|
function setChatLoading(active, message = "Loading chats...") {
|
|
if (!chatListLoading || !chatListLoadingText) return;
|
|
if (active) {
|
|
chatLoadingDepth += 1;
|
|
chatListLoadingText.textContent = message;
|
|
chatListLoading.classList.remove("hidden");
|
|
return;
|
|
}
|
|
chatLoadingDepth = Math.max(0, chatLoadingDepth - 1);
|
|
if (chatLoadingDepth === 0) {
|
|
chatListLoading.classList.add("hidden");
|
|
chatListLoadingText.textContent = "Loading chats...";
|
|
}
|
|
}
|
|
|
|
function setChatActivity(active, { eyebrow = "Please wait", title = "Preparing chat", detail = "The assistant is still working." } = {}) {
|
|
if (!chatActivityOverlay || !chatActivityEyebrow || !chatActivityTitle || !chatActivityDetail) return;
|
|
if (active) {
|
|
chatActivityDepth += 1;
|
|
chatActivityEyebrow.textContent = eyebrow;
|
|
chatActivityTitle.textContent = title;
|
|
chatActivityDetail.textContent = detail;
|
|
chatActivityOverlay.classList.remove("hidden");
|
|
return;
|
|
}
|
|
chatActivityDepth = Math.max(0, chatActivityDepth - 1);
|
|
if (chatActivityDepth === 0) {
|
|
chatActivityOverlay.classList.add("hidden");
|
|
chatActivityEyebrow.textContent = "Please wait";
|
|
chatActivityTitle.textContent = "Preparing chat";
|
|
chatActivityDetail.textContent = "The assistant is still working.";
|
|
}
|
|
}
|
|
|
|
function applyWorkspaceMode(nextMode, persist = true) {
|
|
workspaceMode = nextMode === "advanced" ? "advanced" : "basic";
|
|
document.body.dataset.workspaceMode = workspaceMode;
|
|
if (persist) localStorage.setItem("krishd-workspace-mode", workspaceMode);
|
|
if (modelSection) modelSection.classList.remove("hidden");
|
|
if (personaSection) personaSection.classList.toggle("hidden", workspaceMode === "basic");
|
|
updateComposerPlaceholder();
|
|
}
|
|
|
|
function isMobileLayout() {
|
|
return window.matchMedia("(max-width: 980px)").matches;
|
|
}
|
|
|
|
function closeMobileSidebar() {
|
|
if (!chatSidebar || !document.body.classList.contains("sidebar-open")) return;
|
|
document.body.classList.remove("sidebar-open");
|
|
if (chatSidebarToggle) {
|
|
chatSidebarToggle.setAttribute("aria-expanded", "false");
|
|
chatSidebarToggle.setAttribute("aria-label", "Open conversation history");
|
|
}
|
|
chatSidebar.inert = isMobileLayout();
|
|
chatSidebar.removeAttribute("aria-modal");
|
|
chatSidebar.removeAttribute("role");
|
|
const backdrop = document.getElementById("chatSidebarBackdrop");
|
|
if (backdrop) backdrop.hidden = true;
|
|
chatSidebarToggle?.focus();
|
|
}
|
|
|
|
function openMobileSidebar() {
|
|
if (!chatSidebar || !isMobileLayout()) return;
|
|
chatSidebar.inert = false;
|
|
chatSidebar.setAttribute("role", "dialog");
|
|
chatSidebar.setAttribute("aria-modal", "true");
|
|
document.body.classList.add("sidebar-open");
|
|
if (chatSidebarToggle) {
|
|
chatSidebarToggle.setAttribute("aria-expanded", "true");
|
|
chatSidebarToggle.setAttribute("aria-label", "Close conversation history");
|
|
}
|
|
const backdrop = document.getElementById("chatSidebarBackdrop");
|
|
if (backdrop) backdrop.hidden = false;
|
|
chatSearchInput?.focus();
|
|
}
|
|
|
|
function hasCompletedOnboarding() {
|
|
return localStorage.getItem("krishd-onboarding-complete") === "true";
|
|
}
|
|
|
|
function markOnboardingCompleted() {
|
|
localStorage.setItem("krishd-onboarding-complete", "true");
|
|
closeOnboarding();
|
|
}
|
|
|
|
function closeOnboarding() {
|
|
if (!onboardingModal) return;
|
|
closeModal(onboardingModal);
|
|
}
|
|
|
|
function renderOnboardingStep() {
|
|
if (!onboardingChoices || !onboardingTitle || !onboardingSubtitle) return;
|
|
onboardingChoices.innerHTML = "";
|
|
onboardingBack.classList.toggle("hidden", onboardingStep === 1);
|
|
onboardingContinue.classList.toggle("hidden", onboardingStep === 2);
|
|
if (onboardingStep === 1) {
|
|
onboardingTitle.textContent = "Where shall we start?";
|
|
onboardingSubtitle.textContent = "Ask a question, explore an idea, or step into a story.";
|
|
[["ask", "Ask questions"], ["brainstorm", "Brainstorm ideas"], ["roleplay", "Roleplay with a character"]].forEach(([key, label]) => {
|
|
const button = document.createElement("button");
|
|
button.type = "button";
|
|
button.className = `secondary-action${onboardingIntent === key ? " active" : ""}`;
|
|
button.textContent = label;
|
|
button.addEventListener("click", () => {
|
|
onboardingIntent = key;
|
|
renderOnboardingStep();
|
|
onboardingChoices.querySelector(".active")?.focus();
|
|
});
|
|
onboardingChoices.appendChild(button);
|
|
});
|
|
return;
|
|
}
|
|
onboardingTitle.textContent = "Choose a starter";
|
|
onboardingSubtitle.textContent = "Pick one template and send your first message.";
|
|
onboardingPrompts[onboardingIntent].forEach((template) => {
|
|
const button = document.createElement("button");
|
|
button.type = "button";
|
|
button.className = "secondary-action";
|
|
button.textContent = template;
|
|
button.addEventListener("click", async () => {
|
|
if (onboardingIntent === "roleplay") {
|
|
closeOnboarding();
|
|
if (conversation.assistantPersonas.length) {
|
|
openRoleplayStarter();
|
|
} else {
|
|
openPersonaForm(null, "assistant");
|
|
}
|
|
return;
|
|
}
|
|
if (!conversation.activeChatId) await createNewChat();
|
|
msgInput.value = template;
|
|
msgInput.dispatchEvent(new Event("input"));
|
|
msgInput.focus();
|
|
closeOnboarding();
|
|
});
|
|
onboardingChoices.appendChild(button);
|
|
});
|
|
}
|
|
|
|
function maybeShowOnboarding() {
|
|
if (!onboardingModal || hasCompletedOnboarding()) return;
|
|
onboardingStep = 1;
|
|
onboardingIntent = "ask";
|
|
renderOnboardingStep();
|
|
openModal(onboardingModal, onboardingChoices.querySelector("button"));
|
|
}
|
|
|
|
function updateComposerPlaceholder() {
|
|
const activeChat = getChatById(conversation.activeChatId);
|
|
if (!activeChat) {
|
|
msgInput.placeholder = "Create a chat to begin...";
|
|
return;
|
|
}
|
|
if (activeChat.assistant_persona_id) {
|
|
msgInput.placeholder = "Continue the scene...";
|
|
return;
|
|
}
|
|
msgInput.placeholder = workspaceMode === "basic" ? "Ask anything..." : "Type your message...";
|
|
}
|
|
|
|
function populateGenerationSettings(chat) {
|
|
if (!chat) return;
|
|
if (chat.preferred_model && Array.from(modelSelect.options).some((option) => option.value === chat.preferred_model)) {
|
|
modelSelect.value = chat.preferred_model;
|
|
}
|
|
chatTemperatureInput.value = chat.temperature ?? "";
|
|
chatContextLengthInput.value = chat.context_length ?? "";
|
|
chatResponseLengthInput.value = chat.response_length ?? "";
|
|
chatSystemInstructionInput.value = chat.system_instruction || "";
|
|
renderModelSection();
|
|
}
|
|
|
|
async function saveGenerationSettings() {
|
|
if (!conversation.activeChatId) return;
|
|
const payload = {
|
|
preferredModel: modelSelect.value || null,
|
|
temperature: chatTemperatureInput.value,
|
|
contextLength: chatContextLengthInput.value,
|
|
responseLength: chatResponseLengthInput.value,
|
|
systemInstruction: chatSystemInstructionInput.value
|
|
};
|
|
const res = await put(`/chats/${conversation.activeChatId}/generation-settings`, payload);
|
|
if (res.error || !res.chat) return setNotice(res.error || "Unable to save generation settings.", "error");
|
|
const chat = getChatById(conversation.activeChatId);
|
|
if (chat) Object.assign(chat, res.chat);
|
|
populateGenerationSettings(res.chat);
|
|
setNotice("Generation settings saved.", "success");
|
|
}
|
|
|
|
async function saveSelectedModel() {
|
|
if (!conversation.activeChatId || !modelSelect.value) return;
|
|
const chatId = conversation.activeChatId;
|
|
const selectedModel = modelSelect.value;
|
|
const res = await put(`/chats/${chatId}/model`, { model: selectedModel });
|
|
if (res.error || !res.chat) return setNotice(res.error || "Unable to switch models.", "error");
|
|
const chat = getChatById(chatId);
|
|
if (chat) Object.assign(chat, res.chat);
|
|
if (chatId === conversation.activeChatId && modelSelect.value === selectedModel) {
|
|
renderModelSection();
|
|
setNotice(`Model switched to ${modelSelect.selectedOptions[0]?.textContent || selectedModel}.`, "success");
|
|
}
|
|
}
|
|
|
|
messagesDiv.addEventListener("scroll", updateJumpToLatest, {passive: true});
|
|
document.getElementById("jumpToLatest").addEventListener("click", scrollMessagesToBottom);
|
|
|
|
function getMessageIndexById(messageId) {
|
|
return conversation.currentMessages.findIndex((message) => message.id === messageId);
|
|
}
|
|
|
|
async function resolveMessageTarget(messageId, fallbackIndex = -1) {
|
|
if (messageId) {
|
|
const index = getMessageIndexById(messageId);
|
|
if (index >= 0) {
|
|
return { index, message: conversation.currentMessages[index] };
|
|
}
|
|
}
|
|
if (conversation.activeChatId) {
|
|
await setActiveChat(conversation.activeChatId);
|
|
}
|
|
if (messageId) {
|
|
const index = getMessageIndexById(messageId);
|
|
if (index >= 0) {
|
|
return { index, message: conversation.currentMessages[index] };
|
|
}
|
|
}
|
|
if (fallbackIndex >= 0 && fallbackIndex < conversation.currentMessages.length) {
|
|
return { index: fallbackIndex, message: conversation.currentMessages[fallbackIndex] };
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function getMessageEndpoint(target, suffix = "") {
|
|
if (!conversation.activeChatId || !target) return "";
|
|
if (target.message?.id) return `/chats/${conversation.activeChatId}/messages/${target.message.id}${suffix}`;
|
|
if (target.index >= 0) return `/chats/${conversation.activeChatId}/messages/by-index/${target.index}${suffix}`;
|
|
return "";
|
|
}
|
|
|
|
function getPreviousUserMessageId(messageId) {
|
|
const index = getMessageIndexById(messageId);
|
|
if (index <= 0) return null;
|
|
for (let i = index - 1; i >= 0; i -= 1) {
|
|
if (conversation.currentMessages[i].role === "user" && conversation.currentMessages[i].id) {
|
|
return conversation.currentMessages[i].id;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function getRetryStateFromMessage(message) {
|
|
const variants = Array.isArray(message?.retryVariants) && message.retryVariants.length
|
|
? [...message.retryVariants]
|
|
: [message?.content || ""];
|
|
const requestedActiveIndex = Number(message?.retryActiveIndex);
|
|
const activeIndex = Number.isInteger(requestedActiveIndex)
|
|
? Math.min(Math.max(requestedActiveIndex, 0), variants.length - 1)
|
|
: 0;
|
|
return {
|
|
variants,
|
|
activeIndex,
|
|
retriesUsed: Number(message?.retryRetriesUsed || 0),
|
|
promptMessageId: message?.retryPromptMessageId ?? getPreviousUserMessageId(message?.id)
|
|
};
|
|
}
|
|
|
|
function syncRetryStateFromMessage(message) {
|
|
if (!message?.id) return null;
|
|
const state = getRetryStateFromMessage(message);
|
|
conversation.messageRetryState.set(message.id, state);
|
|
return state;
|
|
}
|
|
|
|
async function editChatMessage(messageId, fallbackIndex = -1) {
|
|
const target = await resolveMessageTarget(messageId, fallbackIndex);
|
|
if (!target) return setNotice("Message is not ready yet. Try again.", "error");
|
|
const { index, message } = target;
|
|
const sourceChatId = conversation.activeChatId;
|
|
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 || conversation.isProcessing) return;
|
|
conversation.regenerationChatId = sourceChatId;
|
|
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
|
|
}, {timeoutMs: 660000});
|
|
if (res.error) return setNotice(res.error, "error");
|
|
if (conversation.activeChatId === sourceChatId) await openCreatedChat(res.chat);
|
|
return setNotice("Created the edited chat branch.", "success");
|
|
} finally {
|
|
conversation.regenerationChatId = null;
|
|
setLoadingState(false);
|
|
}
|
|
}
|
|
const endpoint = getMessageEndpoint(target);
|
|
if (!endpoint) return setNotice("Message is not ready yet. Try again.", "error");
|
|
const res = await put(endpoint, { content });
|
|
if (res.error || !res.message) {
|
|
return setNotice(res.error || "Unable to edit message.", "error");
|
|
}
|
|
conversation.currentMessages[index] = { ...conversation.currentMessages[index], ...res.message };
|
|
syncRetryStateFromMessage(conversation.currentMessages[index]);
|
|
renderMessages();
|
|
setNotice("Message updated.", "success");
|
|
}
|
|
|
|
async function deleteChatMessage(messageId, fallbackIndex = -1) {
|
|
const target = await resolveMessageTarget(messageId, fallbackIndex);
|
|
if (!target) return setNotice("Message is not ready yet. Try again.", "error");
|
|
const { message } = target;
|
|
const confirmed = await confirmPopup({
|
|
eyebrow: message.role === "user" ? "Your message" : "Assistant message",
|
|
title: "Delete message",
|
|
description: "Delete this message from the chat?",
|
|
confirmLabel: "Delete",
|
|
danger: true
|
|
});
|
|
if (!confirmed) return;
|
|
const endpoint = getMessageEndpoint(target);
|
|
if (!endpoint) return setNotice("Message is not ready yet. Try again.", "error");
|
|
const res = await del(endpoint);
|
|
if (res.error) return setNotice(res.error, "error");
|
|
conversation.currentMessages = conversation.currentMessages.filter((item) => item.id !== message.id);
|
|
if (message.id) {
|
|
conversation.messageRetryState.delete(message.id);
|
|
for (const [key, state] of conversation.messageRetryState.entries()) {
|
|
if (state.promptMessageId === message.id) {
|
|
conversation.messageRetryState.delete(key);
|
|
}
|
|
}
|
|
}
|
|
renderMessages();
|
|
await loadSummary();
|
|
setNotice("Message deleted.", "success");
|
|
}
|
|
|
|
async function retryLatestAssistantMessage(messageId, fallbackIndex = -1) {
|
|
if (conversation.isProcessing) return;
|
|
const target = await resolveMessageTarget(messageId, fallbackIndex);
|
|
if (conversation.isProcessing) return;
|
|
if (!target) return setNotice("Message is not ready yet. Try again.", "error");
|
|
if (target.message.role !== "bot") return setNotice("Only assistant messages can be regenerated.", "error");
|
|
const retriesUsed = Math.max(0, Number(target.message.versionCount || 1) - 1);
|
|
if (retriesUsed >= 5) {
|
|
return setNotice("Retry limit reached (5).", "error");
|
|
}
|
|
const chatId = conversation.activeChatId;
|
|
const card = messagesDiv.querySelector(`[data-message-id="${target.message.id}"]`);
|
|
if (!card) return;
|
|
const payload = { model: modelSelect.value, webSearch: webSearchToggle.enabled, reasoning: reasoningToggle.enabled };
|
|
conversation.regenerationChatId = chatId;
|
|
setLoadingState(true, false);
|
|
try {
|
|
await regenerateInline({
|
|
card, scroller: messagesDiv, endpoint: `/chats/${chatId}/messages/${target.message.id}/retry/stream`, payload,
|
|
renderContent: setMessageContent,
|
|
complete: message => {
|
|
if (conversation.activeChatId !== chatId) return;
|
|
const index = getMessageIndexById(message.id);
|
|
if (index < 0) return;
|
|
conversation.currentMessages[index] = { ...conversation.currentMessages[index], ...message, thinking: message.thinking, sources: message.sources };
|
|
syncRetryStateFromMessage(conversation.currentMessages[index]);
|
|
const rendered = addMessage(conversation.currentMessages[index], false, false, { messageIndex: index, detached: true });
|
|
card.replaceChildren(...rendered.childNodes);
|
|
}
|
|
});
|
|
webSearchToggle.reset();
|
|
reasoningToggle.reset();
|
|
setNotice("Reply regenerated.", "success");
|
|
} catch (error) {
|
|
setNotice(error.message || "Unable to regenerate message.", "error");
|
|
} finally {
|
|
conversation.regenerationChatId = null;
|
|
setLoadingState(false);
|
|
}
|
|
}
|
|
|
|
async function switchResponseVersion(messageId, direction, fallbackIndex = -1) {
|
|
const target = await resolveMessageTarget(messageId, fallbackIndex);
|
|
if (!target) return setNotice("Message is not ready yet. Try again.", "error");
|
|
if (Number(target.message.versionCount) > 1) {
|
|
const chatId = conversation.activeChatId;
|
|
const card = messagesDiv.querySelector(`[data-message-id="${messageId}"]`);
|
|
if (!card || card.getAttribute("aria-busy") === "true") return;
|
|
const buttons = [...card.querySelectorAll("button")].map(button => [button, button.disabled]);
|
|
card.setAttribute("aria-busy", "true");
|
|
buttons.forEach(([button]) => { button.disabled = true; });
|
|
try {
|
|
const res = await post(`/chats/${chatId}/messages/${messageId}/versions/${target.message.versionIndex + direction}`, {});
|
|
if (res.error) return setNotice(res.error, "error");
|
|
if (conversation.activeChatId !== chatId) return;
|
|
const index = getMessageIndexById(messageId);
|
|
if (index < 0) return;
|
|
conversation.currentMessages[index] = { ...conversation.currentMessages[index], ...res.message, thinking: res.message.thinking, sources: res.message.sources };
|
|
syncRetryStateFromMessage(conversation.currentMessages[index]);
|
|
const rendered = addMessage(conversation.currentMessages[index], false, false, { messageIndex: index, detached: true });
|
|
card.replaceChildren(...rendered.childNodes);
|
|
} finally {
|
|
card.removeAttribute("aria-busy");
|
|
buttons.forEach(([button, disabled]) => { button.disabled = disabled; });
|
|
}
|
|
}
|
|
}
|
|
|
|
async function switchRetryVariant(messageId, direction, fallbackIndex = -1) {
|
|
const target = await resolveMessageTarget(messageId, fallbackIndex);
|
|
if (!target) return setNotice("Message is not ready yet. Try again.", "error");
|
|
const siblingId = direction < 0 ? target.message.previousSiblingId : target.message.nextSiblingId;
|
|
if (!siblingId) return;
|
|
const res = await post(`/chats/${conversation.activeChatId}/branches/${siblingId}/activate`, { includeDescendants: true });
|
|
if (res.error) return setNotice(res.error, "error");
|
|
conversation.currentMessages = res.messages || [];
|
|
renderMessages();
|
|
}
|
|
|
|
async function branchFromMessage(messageId) {
|
|
if (!conversation.activeChatId || !messageId) return;
|
|
const confirmed = await confirmPopup({
|
|
eyebrow: "Create branch",
|
|
title: "Create a new chat branch?",
|
|
description: "The new chat will contain the conversation through this message. The original chat stays unchanged.",
|
|
confirmLabel: "Create branch"
|
|
});
|
|
if (!confirmed) return;
|
|
const sourceChatId = conversation.activeChatId;
|
|
const res = await post(`/chats/${sourceChatId}/messages/${messageId}/branch`, { confirmed: true });
|
|
if (res.error) return setNotice(res.error, "error");
|
|
await openCreatedChat(res.chat);
|
|
msgInput.focus();
|
|
setNotice("Created and opened a new chat branch.", "success");
|
|
}
|
|
|
|
function updateWorkspaceCopy() {
|
|
const activeChat = getChatById(conversation.activeChatId);
|
|
if (activeChatTitle) {
|
|
activeChatTitle.textContent = activeChat ? activeChat.title : "New chat";
|
|
}
|
|
|
|
renderDrawerSummary();
|
|
updateComposerPlaceholder();
|
|
}
|
|
|
|
function renderDrawerSummary() {
|
|
const chat = getChatById(conversation.activeChatId);
|
|
if (!chatCharacterPill || !chatUserPersonaPill) return;
|
|
if (!chat) {
|
|
chatCharacterPill.classList.add("hidden");
|
|
chatUserPersonaPill.classList.add("hidden");
|
|
return;
|
|
}
|
|
if (chat.assistant_persona_id) {
|
|
chatCharacterPill.textContent = `Character: ${chat.assistant_persona_name || chat.title}`;
|
|
chatCharacterPill.classList.remove("hidden");
|
|
if (chat.user_persona_id) {
|
|
chatUserPersonaPill.textContent = `You as: ${chat.user_persona_name || "User persona"}`;
|
|
chatUserPersonaPill.classList.remove("hidden");
|
|
} else {
|
|
chatUserPersonaPill.textContent = "You as: yourself";
|
|
chatUserPersonaPill.classList.remove("hidden");
|
|
}
|
|
return;
|
|
}
|
|
chatCharacterPill.classList.add("hidden");
|
|
chatUserPersonaPill.classList.add("hidden");
|
|
}
|
|
|
|
function renderModelSection() {
|
|
reasoningToggle.setModel(modelSelect.value);
|
|
updateModelHelp();
|
|
return modelSelect?.selectedOptions[0]?.textContent || "";
|
|
}
|
|
|
|
function updateModelHelp() {
|
|
if (!modelSelect || !modelHelpTitle || !modelHelpBadge || !modelHelpSummary) return;
|
|
const selectedOption = modelSelect.selectedOptions[0];
|
|
if (!selectedOption || selectedOption.disabled) {
|
|
if (modelCount) modelCount.textContent = "No models";
|
|
if (modelBadgeName) modelBadgeName.textContent = "Unavailable";
|
|
if (headerModelName) headerModelName.textContent = "Unavailable";
|
|
modelHelpBadge.dataset.badge = "waiting";
|
|
modelHelpTitle.textContent = "Choose a model";
|
|
modelHelpBadge.textContent = "Waiting";
|
|
modelHelpSummary.textContent = "Pick a model to see what it is best suited for.";
|
|
return;
|
|
}
|
|
if (modelCount) {
|
|
const totalModels = Array.from(modelSelect.options).filter((option) => !option.disabled).length;
|
|
modelCount.textContent = `${totalModels} ${totalModels === 1 ? "model" : "models"}`;
|
|
}
|
|
const modelId = selectedOption.value;
|
|
const modelName = selectedOption.textContent || modelId;
|
|
const profile = getModelProfile(availableModelsById.get(modelId) || { model: modelId, name: modelName });
|
|
if (modelBadgeName) modelBadgeName.textContent = modelName;
|
|
if (headerModelName) headerModelName.textContent = modelName;
|
|
modelHelpTitle.textContent = modelName;
|
|
modelHelpBadge.textContent = profile.badge;
|
|
modelHelpBadge.dataset.badge = profile.badge.toLowerCase();
|
|
modelHelpSummary.textContent = profile.summary;
|
|
}
|
|
|
|
function applyTheme(themeKey, persist = true) {
|
|
applySharedTheme(themeKey, {
|
|
persist,
|
|
title: "Elsewhere",
|
|
nameTargets: brandNameTargets,
|
|
logoTargets: brandLogoTargets
|
|
});
|
|
updateWorkspaceCopy();
|
|
renderModelSection();
|
|
}
|
|
|
|
function applyThemeMode(modeKey, persist = true) {
|
|
applySharedThemeMode(modeKey, { persist });
|
|
}
|
|
|
|
function updateChatActionState() {
|
|
const activeChat = getChatById(conversation.activeChatId);
|
|
const hasChat = Boolean(activeChat);
|
|
renameChatBtn.disabled = !hasChat || Boolean(activeChat?.assistant_persona_id);
|
|
if (pinChatBtn) {
|
|
pinChatBtn.disabled = !hasChat;
|
|
if (hasChat) pinChatBtn.textContent = activeChat.is_pinned ? "Unpin" : "Pin";
|
|
}
|
|
if (moveChatFolderBtn) moveChatFolderBtn.disabled = !hasChat;
|
|
if (manageChatMemoryBtn) manageChatMemoryBtn.disabled = !hasChat;
|
|
if (managePersonaStateBtn) managePersonaStateBtn.disabled = !hasChat || !activeChat?.assistant_persona_id;
|
|
if (archiveChatBtn) {
|
|
archiveChatBtn.disabled = !hasChat;
|
|
if (hasChat) archiveChatBtn.textContent = activeChat.archived_at ? "Unarchive" : "Archive";
|
|
}
|
|
clearChatBtn.disabled = !hasChat;
|
|
exportChatBtn.disabled = !hasChat || conversation.currentMessages.length === 0;
|
|
if (backupWorkspaceBtn) backupWorkspaceBtn.disabled = !conversation.currentUsername;
|
|
if (importWorkspaceBtn) importWorkspaceBtn.disabled = !conversation.currentUsername;
|
|
if (headerModelButton) headerModelButton.disabled = !hasChat;
|
|
}
|
|
|
|
function openModelSettings() {
|
|
if (!conversation.activeChatId || !modelSection) return;
|
|
modelSection.classList.remove("hidden");
|
|
modelSection.open = true;
|
|
openChatDrawer();
|
|
requestAnimationFrame(() => modelSelect?.focus());
|
|
}
|
|
|
|
function renderChatList() {
|
|
const query = chatSearchInput.value.trim().toLowerCase();
|
|
const source = query ? (conversation.chatSearchResults ?? []) : conversation.chatSessions;
|
|
const filtered = source.filter((chat) => !query
|
|
|| chat.title.toLowerCase().includes(query)
|
|
|| String(chat.matched_content || "").toLowerCase().includes(query));
|
|
chatList.innerHTML = "";
|
|
if (!filtered.length) {
|
|
const empty = document.createElement("p");
|
|
empty.className = "status";
|
|
empty.textContent = query && conversation.chatSearchError ? conversation.chatSearchError
|
|
: query && conversation.chatSearchResults === null ? "Searching chats..."
|
|
: query ? "No chats match this search." : "No chats yet. Start a new one.";
|
|
chatList.appendChild(empty);
|
|
return;
|
|
}
|
|
const folderGroups = filtered
|
|
.filter((chat) => chat.folder_name && !chat.is_pinned && !chat.archived_at)
|
|
.reduce((map, chat) => {
|
|
const key = String(chat.folder_name).trim();
|
|
const items = map.get(key) || [];
|
|
items.push(chat);
|
|
map.set(key, items);
|
|
return map;
|
|
}, new Map());
|
|
const groups = [
|
|
{ label: "Pinned", items: filtered.filter((chat) => chat.is_pinned && !chat.archived_at) },
|
|
...Array.from(folderGroups.entries()).map(([label, items]) => ({ label, items })),
|
|
{ label: "Recent", items: filtered.filter((chat) => !chat.is_pinned && !chat.folder_name && !chat.archived_at) },
|
|
{ label: "Archived", items: filtered.filter((chat) => chat.archived_at) }
|
|
].filter((group) => group.items.length);
|
|
groups.forEach((group) => {
|
|
const section = document.createElement("section");
|
|
const heading = document.createElement("div");
|
|
const list = document.createElement("div");
|
|
section.className = "chat-list-section";
|
|
heading.className = "chat-list-section-header";
|
|
heading.textContent = group.label;
|
|
list.className = "chat-list-section-body";
|
|
list.setAttribute("role", "list");
|
|
section.append(heading, list);
|
|
group.items.forEach((chat) => {
|
|
const item = document.createElement("div"), content = document.createElement("button"), title = document.createElement("span"), meta = document.createElement("small"), remove = document.createElement("button");
|
|
item.className = `chat-item${chat.id === conversation.activeChatId ? " active" : ""}`;
|
|
item.setAttribute("role", "listitem");
|
|
content.type = "button";
|
|
content.className = "chat-item-copy secondary-action";
|
|
content.setAttribute("aria-label", `Open chat ${chat.title}`);
|
|
if (chat.id === conversation.activeChatId) content.setAttribute("aria-current", "page");
|
|
title.textContent = chat.title;
|
|
meta.className = "chat-item-meta";
|
|
const chatType = chat.assistant_persona_id
|
|
? `Roleplay${chat.user_persona_name ? ` · ${chat.user_persona_name}` : ""}`
|
|
: "Assistant chat";
|
|
meta.textContent = chat.source_chat_id ? `Branch · ${chatType}` : chatType;
|
|
remove.type = "button";
|
|
remove.className = "chat-item-delete";
|
|
remove.textContent = "Delete";
|
|
remove.setAttribute("aria-label", `Delete chat ${chat.title}`);
|
|
remove.addEventListener("click", async (event) => {
|
|
event.stopPropagation();
|
|
closeMobileSidebar();
|
|
await deleteChat(chat.id);
|
|
});
|
|
content.append(title, meta);
|
|
if (query && chat.matched_content) {
|
|
const excerpt = document.createElement("small");
|
|
const normalized = chat.matched_content.replace(/\s+/g, " ");
|
|
const matchAt = normalized.toLowerCase().indexOf(query);
|
|
const start = Math.max(0, matchAt - 35);
|
|
excerpt.className = "chat-item-excerpt";
|
|
excerpt.textContent = `${start ? "…" : ""}${normalized.slice(start, start + 110)}${normalized.length > start + 110 ? "…" : ""}`;
|
|
content.append(excerpt);
|
|
}
|
|
item.append(content, remove);
|
|
content.addEventListener("click", () => {
|
|
closeMobileSidebar();
|
|
void setActiveChat(chat.id, {matchMessageId: query ? chat.matched_message_id : null});
|
|
});
|
|
list.appendChild(item);
|
|
});
|
|
chatList.appendChild(section);
|
|
});
|
|
}
|
|
|
|
function isPopupOpen() { return popupModal && !popupModal.classList.contains("hidden"); }
|
|
function finishPopup(result) {
|
|
if (typeof popupResolver === "function") popupResolver(result);
|
|
popupResolver = null;
|
|
popupMode = null;
|
|
}
|
|
function closePopup(resolveValue = null) {
|
|
if (!popupModal || popupModal.classList.contains("hidden")) return;
|
|
closeModal(popupModal);
|
|
popupField.classList.add("hidden");
|
|
popupInput.value = "";
|
|
popupConfirmBtn.disabled = false;
|
|
const nextFocus = popupLastFocus;
|
|
popupLastFocus = null;
|
|
finishPopup(resolveValue);
|
|
if (nextFocus instanceof HTMLElement) nextFocus.focus();
|
|
}
|
|
function openPopup(options) {
|
|
if (!popupModal) return Promise.resolve(null);
|
|
if (isPopupOpen()) closePopup(null);
|
|
popupLastFocus = document.activeElement instanceof HTMLElement ? document.activeElement : null;
|
|
popupMode = options.mode;
|
|
popupEyebrow.textContent = options.eyebrow || "Action";
|
|
popupTitle.textContent = options.title || "Confirm action";
|
|
popupDescription.textContent = options.description || "";
|
|
popupConfirmBtn.textContent = options.confirmLabel || "Confirm";
|
|
popupCancelBtn.textContent = options.cancelLabel || "Cancel";
|
|
popupConfirmBtn.classList.toggle("danger", Boolean(options.danger));
|
|
popupField.classList.toggle("hidden", options.mode !== "prompt");
|
|
if (options.mode === "prompt") {
|
|
popupInputLabel.textContent = options.label || "Value";
|
|
popupInput.value = options.value || "";
|
|
popupInput.placeholder = options.placeholder || "";
|
|
} else {
|
|
popupInput.value = "";
|
|
}
|
|
openModal(popupModal, options.mode === "prompt" ? popupInput : popupConfirmBtn);
|
|
return new Promise((resolve) => {
|
|
popupResolver = resolve;
|
|
requestAnimationFrame(() => {
|
|
if (options.mode === "prompt") {
|
|
popupInput.focus();
|
|
popupInput.select();
|
|
} else {
|
|
popupConfirmBtn.focus();
|
|
}
|
|
});
|
|
});
|
|
}
|
|
function confirmPopup(options) {
|
|
return openPopup({ ...options, mode: "confirm" }).then((result) => Boolean(result));
|
|
}
|
|
function promptPopup(options) {
|
|
return openPopup({ ...options, mode: "prompt" }).then((result) => typeof result === "string" ? result : null);
|
|
}
|
|
|
|
async function loadSummary() {
|
|
updateWorkspaceCopy();
|
|
return conversation.currentSummary;
|
|
}
|
|
|
|
async function renameChat(id) {
|
|
const chat = getChatById(id);
|
|
const nextTitle = chat ? await promptPopup({
|
|
eyebrow: "Conversation",
|
|
title: "Rename chat",
|
|
description: "Choose a new title for this thread.",
|
|
label: "Chat title",
|
|
value: chat.title,
|
|
placeholder: "New chat",
|
|
confirmLabel: "Save"
|
|
}) : "";
|
|
const title = (nextTitle || "").trim();
|
|
if (!chat || !title) return;
|
|
const res = await put(`/chats/${id}`, { title });
|
|
if (res.error) return setNotice(res.error, "error");
|
|
chat.title = title; updateWorkspaceCopy(); renderChatList(); setNotice("Chat renamed.", "success");
|
|
}
|
|
|
|
async function updateChatOrganization(id, changes) {
|
|
const chat = getChatById(id);
|
|
if (!chat) return;
|
|
const payload = {
|
|
folderName: changes.folderName !== undefined ? changes.folderName : chat.folder_name,
|
|
isPinned: changes.isPinned !== undefined ? changes.isPinned : Boolean(chat.is_pinned),
|
|
archived: changes.archived !== undefined ? changes.archived : Boolean(chat.archived_at)
|
|
};
|
|
const res = await put(`/chats/${id}/organization`, payload);
|
|
if (res.error) return setNotice(res.error, "error");
|
|
const index = conversation.chatSessions.findIndex((item) => item.id === id);
|
|
if (index >= 0) conversation.chatSessions[index] = res.chat;
|
|
updateWorkspaceCopy();
|
|
renderChatList();
|
|
updateChatActionState();
|
|
}
|
|
|
|
async function clearChat(id) {
|
|
const chat = getChatById(id);
|
|
if (!chat) return;
|
|
const confirmed = await confirmPopup({
|
|
eyebrow: "Conversation",
|
|
title: "Clear messages",
|
|
description: `Clear all messages in "${chat.title}"? The chat will stay, but its history will be removed.`,
|
|
confirmLabel: "Clear",
|
|
danger: true
|
|
});
|
|
if (!confirmed) return;
|
|
const res = await post(`/chats/${id}/clear`, {});
|
|
if (res.error) return setNotice(res.error, "error");
|
|
conversation.messageRetryState = new Map();
|
|
conversation.currentMessages = []; renderMessages(); await loadSummary(); setNotice("Chat cleared.", "success");
|
|
}
|
|
|
|
function exportCurrentChat() {
|
|
const chat = getChatById(conversation.activeChatId);
|
|
if (!chat || !conversation.currentMessages.length) return;
|
|
void (async () => {
|
|
const format = await promptPopup({
|
|
eyebrow: "Export",
|
|
title: "Export format",
|
|
description: "Use txt, md, or json.",
|
|
label: "Format",
|
|
value: "md",
|
|
placeholder: "md",
|
|
confirmLabel: "Download"
|
|
});
|
|
const normalized = ["txt", "md", "json"].includes(String(format || "").trim().toLowerCase())
|
|
? String(format).trim().toLowerCase()
|
|
: "md";
|
|
const response = await fetch(`/exports/chats/${conversation.activeChatId}?format=${normalized}`);
|
|
const blob = await response.blob();
|
|
const url = URL.createObjectURL(blob);
|
|
const link = document.createElement("a");
|
|
link.href = url;
|
|
link.download = `${(chat.title.replace(/[^a-z0-9-_]+/gi, "-").replace(/^-+|-+$/g, "") || "chat")}.${normalized}`;
|
|
document.body.appendChild(link);
|
|
link.click();
|
|
link.remove();
|
|
URL.revokeObjectURL(url);
|
|
setNotice(`Chat exported as ${normalized}.`, "success");
|
|
})();
|
|
}
|
|
|
|
async function backupWorkspace() {
|
|
const response = await fetch("/exports/workspace/archive");
|
|
if (!response.ok) return setNotice("Workspace archive could not be created.", "error");
|
|
const blob = await response.blob();
|
|
const url = URL.createObjectURL(blob);
|
|
const link = document.createElement("a");
|
|
link.href = url;
|
|
link.download = `workspace-backup-${new Date().toISOString().slice(0, 10)}.tar.gz`;
|
|
document.body.appendChild(link);
|
|
link.click();
|
|
link.remove();
|
|
URL.revokeObjectURL(url);
|
|
setNotice("Workspace archive downloaded with images.", "success");
|
|
}
|
|
|
|
function formatImportPreview(preview, warnings = []) {
|
|
const counts = [
|
|
`${preview.personas || 0} persona${preview.personas === 1 ? "" : "s"}`,
|
|
`${preview.chats || 0} chat${preview.chats === 1 ? "" : "s"}`,
|
|
`${preview.messages || 0} message${preview.messages === 1 ? "" : "s"}`,
|
|
`${preview.templates || 0} template${preview.templates === 1 ? "" : "s"}`
|
|
].join(", ");
|
|
const warningText = warnings.length ? ` Warnings: ${warnings.slice(0, 4).join("; ")}${warnings.length > 4 ? "; ..." : ""}` : "";
|
|
return `Import preview: ${counts}.${warningText}`;
|
|
}
|
|
|
|
async function importWorkspaceFromFile(file) {
|
|
if (!file) return;
|
|
setChatLoading(true, "Reading workspace backup...");
|
|
const isArchive = file.name.toLowerCase().endsWith(".tar.gz");
|
|
if (isArchive) {
|
|
const submit = async (path, allowDuplicates = false) => {
|
|
const body = new FormData();
|
|
body.append("archive", file);
|
|
if (allowDuplicates) body.append("allowDuplicates", "true");
|
|
const response = await fetch(path, {method: "POST", body});
|
|
return response.json();
|
|
};
|
|
try {
|
|
const preview = await submit("/imports/workspace/archive/preview");
|
|
setChatLoading(false);
|
|
if (preview.error || preview.ok === false) return setNotice((preview.errors || []).slice(0, 4).join("; ") || preview.error || "Invalid archive.", "error");
|
|
const confirmed = await confirmPopup({eyebrow: "Workspace import", title: "Review import",
|
|
description: formatImportPreview(preview.preview || {}, preview.warnings || []), confirmLabel: "Import"});
|
|
if (!confirmed) return;
|
|
setChatLoading(true, "Importing workspace and images...");
|
|
const result = await submit("/imports/workspace/archive", Boolean(preview.preview?.duplicates?.length));
|
|
if (result.error) return setNotice(result.error, "error");
|
|
await Promise.all([loadPersonas(), loadChatSessions()]);
|
|
setNotice(`Workspace imported: ${result.imported.chats} chats with their images.`, "success");
|
|
} catch { setNotice("Workspace archive could not be imported.", "error"); }
|
|
finally { setChatLoading(false); }
|
|
return;
|
|
}
|
|
let workspace;
|
|
try {
|
|
workspace = JSON.parse(await file.text());
|
|
} catch {
|
|
setChatLoading(false);
|
|
return setNotice("Import failed: choose a valid JSON workspace backup.", "error");
|
|
}
|
|
|
|
const preview = await post("/imports/workspace/preview", { workspace });
|
|
if (preview.error || preview.ok === false) {
|
|
setChatLoading(false);
|
|
const detail = (preview.errors || []).slice(0, 4).join("; ");
|
|
return setNotice(detail || preview.error || "Workspace import validation failed.", "error");
|
|
}
|
|
|
|
setChatLoading(false);
|
|
const confirmed = await confirmPopup({
|
|
eyebrow: "Workspace import",
|
|
title: "Review import",
|
|
description: formatImportPreview(preview.preview || {}, preview.warnings || []),
|
|
confirmLabel: "Import"
|
|
});
|
|
if (!confirmed) return;
|
|
|
|
setChatLoading(true, "Importing workspace...");
|
|
const imported = await post("/imports/workspace", {
|
|
workspace,
|
|
allowDuplicates: Boolean(preview.preview?.duplicates?.length)
|
|
});
|
|
if (imported.error) {
|
|
setChatLoading(false);
|
|
const detail = (imported.errors || imported.warnings || []).slice(0, 4).join("; ");
|
|
return setNotice(detail || imported.error, "error");
|
|
}
|
|
await Promise.all([loadPersonas(), loadChatSessions()]);
|
|
setChatLoading(false);
|
|
setNotice(`Workspace imported: ${imported.imported.personas} personas, ${imported.imported.chats} chats, ${imported.imported.templates} templates.`, "success");
|
|
}
|
|
|
|
function formatMemoryList(memories) {
|
|
if (!memories.length) return "No memory facts saved for this chat yet.";
|
|
return memories.map((memory, index) => `${index + 1}. ${memory.fact}`).join("\n");
|
|
}
|
|
|
|
async function manageChatMemory() {
|
|
if (!conversation.activeChatId) return;
|
|
const loaded = await get(`/chats/${conversation.activeChatId}/memories`);
|
|
if (loaded.error) return setNotice(loaded.error, "error");
|
|
const memories = loaded.memories || [];
|
|
const action = await promptPopup({
|
|
eyebrow: "Memory",
|
|
title: "Memory facts",
|
|
description: `${formatMemoryList(memories)}\n\nType a new fact to add it, or type delete 1 to remove a fact.`,
|
|
label: "Fact or command",
|
|
placeholder: "The user prefers terse answers.",
|
|
confirmLabel: "Save"
|
|
});
|
|
const value = String(action || "").trim();
|
|
if (!value) return;
|
|
const deleteMatch = value.match(/^delete\s+(\d+)$/i);
|
|
if (deleteMatch) {
|
|
const target = memories[Number(deleteMatch[1]) - 1];
|
|
if (!target) return setNotice("Memory number not found.", "error");
|
|
const removed = await del(`/chats/${conversation.activeChatId}/memories/${target.id}`);
|
|
if (removed.error) return setNotice(removed.error, "error");
|
|
return setNotice("Memory fact deleted.", "success");
|
|
}
|
|
const saved = await post(`/chats/${conversation.activeChatId}/memories`, { fact: value });
|
|
if (saved.error) return setNotice(saved.error, "error");
|
|
return setNotice("Memory fact saved.", "success");
|
|
}
|
|
|
|
const personaStateFields = [
|
|
["relationship_notes", "Relationship notes"],
|
|
["tone", "Tone"],
|
|
["current_location", "Current location"],
|
|
["goals", "Goals"],
|
|
["unresolved_threads", "Unresolved threads"],
|
|
["boundaries", "Boundaries"]
|
|
];
|
|
|
|
function formatPersonaState(state = {}) {
|
|
const lines = personaStateFields.map(([key, label], index) => {
|
|
const value = String(state[key] || "").trim();
|
|
return `${index + 1}. ${label}: ${value || "Not set"}`;
|
|
});
|
|
return lines.join("\n");
|
|
}
|
|
|
|
async function managePersonaState() {
|
|
if (!conversation.activeChatId) return;
|
|
const loaded = await get(`/chats/${conversation.activeChatId}/persona-state`);
|
|
if (loaded.error) return setNotice(loaded.error, "error");
|
|
const state = loaded.state || {};
|
|
const fieldChoice = await promptPopup({
|
|
eyebrow: "Roleplay",
|
|
title: "Roleplay state",
|
|
description: `${formatPersonaState(state)}\n\nType a field number to edit it.`,
|
|
label: "Field number",
|
|
placeholder: "1",
|
|
confirmLabel: "Next"
|
|
});
|
|
const selected = personaStateFields[Number(String(fieldChoice || "").trim()) - 1];
|
|
if (!selected) return;
|
|
const [key, label] = selected;
|
|
const value = await promptPopup({
|
|
eyebrow: "Roleplay",
|
|
title: label,
|
|
description: "Leave blank to clear this field.",
|
|
label,
|
|
value: state[key] || "",
|
|
placeholder: label
|
|
});
|
|
if (value === null) return;
|
|
const nextState = { ...state, [key]: String(value || "").trim() || null };
|
|
const saved = await put(`/chats/${conversation.activeChatId}/persona-state`, nextState);
|
|
if (saved.error) return setNotice(saved.error, "error");
|
|
return setNotice("Roleplay state saved.", "success");
|
|
}
|
|
|
|
async function deleteChat(id) {
|
|
setChatLoading(true, "Deleting chat...");
|
|
const chat = getChatById(id);
|
|
if (!chat) {
|
|
setChatLoading(false);
|
|
return;
|
|
}
|
|
const confirmed = await confirmPopup({
|
|
eyebrow: "Conversation",
|
|
title: "Delete chat",
|
|
description: `Delete "${chat.title}"? This permanently removes the conversation.`,
|
|
confirmLabel: "Delete",
|
|
danger: true
|
|
});
|
|
if (!confirmed) {
|
|
setChatLoading(false);
|
|
return;
|
|
}
|
|
const res = await del(`/chats/${id}`);
|
|
if (res.error) {
|
|
setChatLoading(false);
|
|
return setNotice(res.error, "error");
|
|
}
|
|
conversation.chatSessions = conversation.chatSessions.filter((item) => item.id !== id);
|
|
conversation.chatDrafts.delete(id);
|
|
attachmentComposer.discardDraft(id);
|
|
if (conversation.activeChatId === id) { attachmentComposer.discard(); msgInput.value = ""; conversation.activeChatId = null; conversation.currentMessages = []; renderMessages(); }
|
|
conversation.messageRetryState = new Map();
|
|
await loadSummary(); renderChatList();
|
|
if (conversation.chatSessions.length) await setActiveChat(conversation.chatSessions[0].id); else await createNewChat();
|
|
setNotice("Chat deleted.", "success");
|
|
setChatLoading(false);
|
|
}
|
|
|
|
async function displayModels() {
|
|
const res = await get("/models");
|
|
const models = Array.isArray(res.models) ? res.models : null;
|
|
if (res.error || !models) {
|
|
availableModelsById = new Map();
|
|
modelSelect.innerHTML = "<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");
|
|
await Promise.all([displayModels(), loadSummary(), loadChatSessions(), loadPersonas()]);
|
|
// Starters are offered on workspace home.
|
|
const starter = new URLSearchParams(location.search).get("prompt");
|
|
if (starter) { msgInput.value = starter; resizeComposerInput(); updateSendState(); }
|
|
msgInput.focus();
|
|
if (!starter && !conversation.activeChatId) maybeShowOnboarding();
|
|
return true;
|
|
}
|
|
|
|
const chatActions = {
|
|
archive: async () => {
|
|
const chat = getChatById(conversation.activeChatId);
|
|
if (chat) await updateChatOrganization(chat.id, { archived: !chat.archived_at });
|
|
},
|
|
clear: async () => {
|
|
if (conversation.activeChatId) await clearChat(conversation.activeChatId);
|
|
},
|
|
export: () => {
|
|
exportCurrentChat();
|
|
},
|
|
folder: async () => {
|
|
const chat = getChatById(conversation.activeChatId);
|
|
if (!chat) return;
|
|
const folderName = await promptPopup({
|
|
eyebrow: "Folder",
|
|
title: "Move chat to folder",
|
|
description: "Leave blank to remove the folder.",
|
|
label: "Folder name",
|
|
value: chat.folder_name || "",
|
|
placeholder: "Research"
|
|
});
|
|
if (folderName === null) return;
|
|
await updateChatOrganization(chat.id, { folderName: String(folderName || "").trim() || null });
|
|
},
|
|
memory: async () => {
|
|
await manageChatMemory();
|
|
},
|
|
personaState: async () => {
|
|
await managePersonaState();
|
|
},
|
|
pin: async () => {
|
|
const chat = getChatById(conversation.activeChatId);
|
|
if (chat) await updateChatOrganization(chat.id, { isPinned: !chat.is_pinned });
|
|
},
|
|
rename: async () => {
|
|
if (conversation.activeChatId) await renameChat(conversation.activeChatId);
|
|
}
|
|
};
|
|
|
|
function bindChatDrawerActions() {
|
|
document.querySelectorAll("[data-chat-action]").forEach((button) => {
|
|
button.addEventListener("click", async () => {
|
|
closeChatDrawer();
|
|
const action = chatActions[button.dataset.chatAction];
|
|
if (typeof action === "function") await action();
|
|
});
|
|
});
|
|
}
|
|
|
|
sendBtn.addEventListener("click", () => { void (conversation.isProcessing ? stopGeneration() : sendMessage()); });
|
|
msgInput.addEventListener("keydown", (event) => { if (event.key === "Enter" && !event.shiftKey && !event.isComposing) { event.preventDefault(); void sendMessage(); } });
|
|
msgInput.addEventListener("input", function () {
|
|
resizeComposerInput();
|
|
updateSendState();
|
|
});
|
|
resizeComposerInput();
|
|
newChatBtn.addEventListener("click", () => {
|
|
closeChatDrawer();
|
|
closeMobileSidebar();
|
|
void createNewChat();
|
|
});
|
|
if (backupWorkspaceBtn) backupWorkspaceBtn.addEventListener("click", () => {
|
|
closeChatDrawer();
|
|
void backupWorkspace();
|
|
});
|
|
if (importWorkspaceBtn) importWorkspaceBtn.addEventListener("click", () => {
|
|
closeChatDrawer();
|
|
importWorkspaceFileInput.value = "";
|
|
importWorkspaceFileInput.click();
|
|
});
|
|
if (importWorkspaceFileInput) importWorkspaceFileInput.addEventListener("change", () => {
|
|
void importWorkspaceFromFile(importWorkspaceFileInput.files?.[0]);
|
|
});
|
|
chatSearchInput.addEventListener("input", scheduleChatSearch);
|
|
modelSelect.addEventListener("change", () => {
|
|
renderModelSection();
|
|
void saveSelectedModel();
|
|
});
|
|
if (headerModelButton) headerModelButton.addEventListener("click", (event) => {
|
|
event.stopPropagation();
|
|
openModelSettings();
|
|
});
|
|
saveGenerationSettingsBtn.addEventListener("click", () => { void saveGenerationSettings(); });
|
|
roleplayNewPersonaBtn.addEventListener("click", () => { closeRoleplayStarter(); openPersonaForm(null, "assistant"); });
|
|
clearUserPersonaBtn.addEventListener("click", () => {
|
|
closeChatDrawer();
|
|
void clearPersona("user");
|
|
});
|
|
personaForm.addEventListener("submit", (event) => { event.preventDefault(); void savePersona(); }); personaCloseBtn.addEventListener("click", closePersonaForm);
|
|
personaTypeSelect.addEventListener("change", () => {
|
|
const isAssistant = personaTypeSelect.value === "assistant";
|
|
personaExamplesField.classList.toggle("hidden", !isAssistant);
|
|
if (!isAssistant) personaExampleDialoguesInput.value = "";
|
|
});
|
|
personaModal.addEventListener("click", (event) => { if (event.target === personaModal) closePersonaForm(); });
|
|
roleplayStarterClose.addEventListener("click", closeRoleplayStarter);
|
|
roleplayStarterCancel.addEventListener("click", closeRoleplayStarter);
|
|
if (roleplayStarterBack) {
|
|
roleplayStarterBack.addEventListener("click", () => {
|
|
conversation.roleplayStarterStep = 1;
|
|
setRoleplayStarterNotice("");
|
|
renderRoleplayStarterStep();
|
|
roleplayCharacterSelect.focus();
|
|
});
|
|
}
|
|
roleplayStarterConfirm.addEventListener("click", () => { void startRoleplay(); });
|
|
roleplayCharacterSelect.addEventListener("change", updateRoleplaySuggestions);
|
|
roleplayUserPersonaSelect.addEventListener("change", updateRoleplaySuggestions);
|
|
roleplayStarterModal.addEventListener("click", (event) => { if (event.target === roleplayStarterModal) closeRoleplayStarter(); });
|
|
popupConfirmBtn.addEventListener("click", () => {
|
|
if (popupMode === "prompt") {
|
|
closePopup(popupInput.value);
|
|
return;
|
|
}
|
|
closePopup(true);
|
|
});
|
|
popupCancelBtn.addEventListener("click", () => closePopup(false));
|
|
popupCloseBtn.addEventListener("click", () => closePopup(false));
|
|
popupModal.addEventListener("click", (event) => { if (event.target === popupModal) closePopup(false); });
|
|
popupInput.addEventListener("keydown", (event) => {
|
|
if (event.key === "Enter") {
|
|
event.preventDefault();
|
|
closePopup(popupInput.value);
|
|
}
|
|
});
|
|
bindChatDrawerActions();
|
|
if (chatSidebar) chatSidebar.inert = isMobileLayout();
|
|
if (chatSidebar) {
|
|
chatSidebar.addEventListener("click", (event) => event.stopPropagation());
|
|
}
|
|
document.getElementById("chatSidebarBackdrop")?.addEventListener("click", closeMobileSidebar);
|
|
if (chatSidebarToggle) {
|
|
chatSidebarToggle.addEventListener("click", (event) => {
|
|
event.stopPropagation();
|
|
if (document.body.classList.contains("sidebar-open")) {
|
|
closeMobileSidebar();
|
|
return;
|
|
}
|
|
openMobileSidebar();
|
|
});
|
|
}
|
|
if (onboardingContinue) {
|
|
onboardingContinue.addEventListener("click", () => {
|
|
onboardingStep = 2;
|
|
renderOnboardingStep();
|
|
onboardingChoices.querySelector("button")?.focus();
|
|
});
|
|
}
|
|
if (onboardingBack) {
|
|
onboardingBack.addEventListener("click", () => {
|
|
onboardingStep = 1;
|
|
renderOnboardingStep();
|
|
(onboardingChoices.querySelector(".active") || onboardingChoices.querySelector("button"))?.focus();
|
|
});
|
|
}
|
|
if (onboardingSkip) {
|
|
onboardingSkip.addEventListener("click", markOnboardingCompleted);
|
|
}
|
|
if (onboardingModal) {
|
|
onboardingModal.addEventListener("click", (event) => {
|
|
if (event.target === onboardingModal) markOnboardingCompleted();
|
|
});
|
|
}
|
|
document.addEventListener("click", (event) => {
|
|
if (!event.target.closest(".msg-action-menu")) {
|
|
document.querySelectorAll(".msg-action-menu[open]").forEach((menu) => { menu.open = false; });
|
|
}
|
|
closeMobileSidebar();
|
|
});
|
|
window.addEventListener("resize", () => {
|
|
if (!isMobileLayout()) closeMobileSidebar();
|
|
if (chatSidebar) chatSidebar.inert = isMobileLayout() && !document.body.classList.contains("sidebar-open");
|
|
});
|
|
document.addEventListener("keydown", (event) => {
|
|
if (event.key === "Tab" && isMobileLayout() && document.body.classList.contains("sidebar-open")) {
|
|
const focusables = [...chatSidebar.querySelectorAll('button:not([disabled]), input:not([disabled]), [tabindex="0"]')]
|
|
.filter((element) => !element.hidden && !element.closest("[hidden]"));
|
|
const first = focusables[0], last = focusables.at(-1);
|
|
if (event.shiftKey && (document.activeElement === first || !chatSidebar.contains(document.activeElement))) {
|
|
event.preventDefault(); last?.focus();
|
|
} else if (!event.shiftKey && (document.activeElement === last || !chatSidebar.contains(document.activeElement))) {
|
|
event.preventDefault(); first?.focus();
|
|
}
|
|
}
|
|
if (event.key === "Escape") {
|
|
const activeModal = topModal();
|
|
if (activeModal) {
|
|
event.preventDefault();
|
|
if (activeModal === popupModal) closePopup(false);
|
|
else if (activeModal === personaModal) closePersonaForm();
|
|
else if (activeModal === roleplayStarterModal) closeRoleplayStarter();
|
|
else if (activeModal === onboardingModal) closeOnboarding();
|
|
return;
|
|
}
|
|
const openMenu = document.querySelector(".msg-action-menu[open]");
|
|
if (openMenu) {
|
|
openMenu.open = false;
|
|
openMenu.querySelector("summary")?.focus();
|
|
}
|
|
closeMobileSidebar();
|
|
closeRoleplayStarter();
|
|
closePersonaForm();
|
|
closePopup(false);
|
|
closeOnboarding();
|
|
}
|
|
});
|
|
window.addEventListener("load", async () => {
|
|
const appearance = readStoredAppearance();
|
|
applyTheme(appearance.theme, false);
|
|
applyThemeMode(appearance.mode, false);
|
|
applyWorkspaceMode(localStorage.getItem("krishd-workspace-mode") || "basic", false);
|
|
setNotice("Ready.");
|
|
updateChatActionState();
|
|
updateSendState();
|
|
await checkSession();
|
|
});
|