diff --git a/public/index.html b/public/index.html index 21172d0..980152d 100644 --- a/public/index.html +++ b/public/index.html @@ -216,7 +216,6 @@

Enter to send ยท Shift+Enter for new line

-
diff --git a/public/market.html b/public/market.html index 54beb93..21b7efc 100644 --- a/public/market.html +++ b/public/market.html @@ -108,6 +108,10 @@ You in this roleplay +
diff --git a/public/market.js b/public/market.js index 1961767..875119b 100644 --- a/public/market.js +++ b/public/market.js @@ -18,6 +18,8 @@ const marketPreviewDetails = $("marketPreviewDetails"); const marketPreviewExampleDialogues = $("marketPreviewExampleDialogues"); const marketPreviewUserPersonaField = $("marketPreviewUserPersonaField"); const marketPreviewUserPersonaSelect = $("marketPreviewUserPersonaSelect"); +const marketPreviewScenarioField = $("marketPreviewScenarioField"); +const marketPreviewScenario = $("marketPreviewScenario"); const marketPreviewConfirm = $("marketPreviewConfirm"); const marketPreviewClose = $("marketPreviewClose"); const marketPersonaModal = $("marketPersonaModal"); @@ -112,10 +114,45 @@ function formatPersonaField(value) { return value && value.trim() ? value.trim() : "Not provided."; } +function setCollapsiblePreviewText(element, value, maxLength = 220) { + const text = formatPersonaField(value); + element.dataset.fullText = text; + let toggle = element.nextElementSibling; + if (!toggle || !toggle.classList.contains("market-preview-toggle")) { + toggle = document.createElement("button"); + toggle.type = "button"; + toggle.className = "market-preview-toggle hidden"; + element.insertAdjacentElement("afterend", toggle); + toggle.addEventListener("click", () => { + const isExpanded = element.dataset.expanded === "true"; + element.dataset.expanded = isExpanded ? "false" : "true"; + if (element.dataset.expanded === "true") { + element.textContent = element.dataset.fullText; + toggle.textContent = "Show less"; + } else { + element.textContent = `${element.dataset.fullText.slice(0, maxLength).trimEnd()}...`; + toggle.textContent = "Show more"; + } + }); + } + + const shouldCollapse = text !== "Not provided." && text.length > maxLength; + element.dataset.expanded = "false"; + if (shouldCollapse) { + element.textContent = `${text.slice(0, maxLength).trimEnd()}...`; + toggle.textContent = "Show more"; + toggle.classList.remove("hidden"); + } else { + element.textContent = text; + toggle.classList.add("hidden"); + } +} + function closeMarketPreview() { marketPreviewModal.classList.add("hidden"); pendingMarketPersona = null; marketPreviewUserPersonaSelect.innerHTML = ""; + marketPreviewScenario.value = ""; } function populateUserPersonaChoices() { @@ -151,14 +188,15 @@ function openMarketPreview(persona) { pendingMarketPersona = persona; marketPreviewName.textContent = persona.name; marketPreviewMeta.textContent = buildPersonaMeta(persona); - marketPreviewPronouns.textContent = formatPersonaField(persona.pronouns); - marketPreviewAppearance.textContent = formatPersonaField(persona.appearance); - marketPreviewBackground.textContent = formatPersonaField(persona.background); - marketPreviewDetails.textContent = formatPersonaField(persona.details); - marketPreviewExampleDialogues.textContent = formatPersonaField(persona.example_dialogues); + setCollapsiblePreviewText(marketPreviewPronouns, persona.pronouns, 80); + setCollapsiblePreviewText(marketPreviewAppearance, persona.appearance); + setCollapsiblePreviewText(marketPreviewBackground, persona.background); + setCollapsiblePreviewText(marketPreviewDetails, persona.details); + setCollapsiblePreviewText(marketPreviewExampleDialogues, persona.example_dialogues); const isAssistant = persona.persona_type === "assistant"; marketPreviewUserPersonaField.classList.toggle("hidden", !isAssistant); + marketPreviewScenarioField.classList.toggle("hidden", !isAssistant); marketPreviewConfirm.textContent = isAssistant ? "Start roleplay" : "Collect persona"; if (isAssistant) { populateUserPersonaChoices(); @@ -303,7 +341,7 @@ async function collectMarketPersona(marketId) { setMarketStatus("User Persona collected.", "success"); } -async function startMarketPersonaChat(marketId, userPersonaSelection) { +async function startMarketPersonaChat(marketId, userPersonaSelection, scenarioPrompt = "") { if (!userPersonaSelection) { setMarketStatus("Choose who you are before starting roleplay.", "error"); return; @@ -315,7 +353,10 @@ async function startMarketPersonaChat(marketId, userPersonaSelection) { setMarketStatus("Starting AI Character chat..."); const userPersonaId = userPersonaSelection === "self" ? null : Number(userPersonaSelection); - const res = await post(`/personas/market/${marketId}/chat`, {userPersonaId}); + const res = await post(`/personas/market/${marketId}/chat`, { + userPersonaId, + scenarioPrompt: scenarioPrompt.trim() + }); if (res.error || !res.chat) { setMarketStatus(res.error || "Unable to start chat.", "error"); return; @@ -385,7 +426,11 @@ marketPreviewConfirm.addEventListener("click", async () => { if (!pendingMarketPersona) return; const marketId = pendingMarketPersona.id; if (pendingMarketPersona.persona_type === "assistant") { - await startMarketPersonaChat(marketId, marketPreviewUserPersonaSelect.value || "self"); + await startMarketPersonaChat( + marketId, + marketPreviewUserPersonaSelect.value || "self", + marketPreviewScenario.value || "" + ); closeMarketPreview(); return; } diff --git a/public/script.js b/public/script.js index 4ff20ab..5bdf75d 100644 --- a/public/script.js +++ b/public/script.js @@ -10,6 +10,7 @@ const registerUsernameInput = $("registerUsername"), registerPasswordInput = $(" const loginSubmit = $("loginSubmit"), registerSubmit = $("registerSubmit"); const chatList = $("chatList"), chatSearchInput = $("chatSearch"), newChatBtn = $("newChat"); const startRoleplayBtn = $("startRoleplay"); +const chatListLoading = $("chatListLoading"), chatListLoadingText = $("chatListLoadingText"); const renameChatBtn = $("renameChat"), clearChatBtn = $("clearChat"), exportChatBtn = $("exportChat"); const activeChatTitle = $("activeChatTitle"), sessionUser = $("sessionUser"); const contextChipButton = $("contextChipButton"), contextPopover = $("contextPopover"), contextSummaryLabel = $("contextSummaryLabel"); @@ -46,6 +47,8 @@ let roleplayPresetCharacterId = null; let noticeTimer = null; let workspaceMode = localStorage.getItem("krishd-workspace-mode") || "basic"; let onboardingStep = 1, onboardingIntent = "ask"; +let chatLoadingDepth = 0; +let messageRetryState = new Map(); const onboardingPrompts = { ask: ["Explain this topic in simple terms.", "Compare these options and recommend one."], @@ -113,6 +116,21 @@ function setPersonaFormNotice(message = "", state = "") { 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 applyWorkspaceMode(nextMode, persist = true) { workspaceMode = nextMode === "advanced" ? "advanced" : "basic"; document.body.dataset.workspaceMode = workspaceMode; @@ -245,16 +263,6 @@ function updateRoleplaySuggestions() { }); } function getQuickPromptsForChat(chat) { - if (chat?.assistant_persona_id) { - const characterName = chat.assistant_persona_name || chat.title || "the character"; - const userName = chat.user_persona_name || "me"; - return [ - `Stay in character and describe what ${characterName} notices about ${userName} right now.`, - `Push the scene forward with a new complication.`, - `Respond with sharper tension and more specific detail.`, - `Let ${characterName} reveal what they actually want from me.` - ]; - } return []; } function renderQuickPrompts() { @@ -295,7 +303,195 @@ function updateSendState() { sendBtn.disabled = isProcessing || !hasText; } -function addMessage(content, isUser = false, isLoading = false) { +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 ensureRetryState(message) { + if (!message?.id) return null; + let state = messageRetryState.get(message.id); + if (!state) { + state = { + variants: [message.content || ""], + activeIndex: 0, + retriesUsed: 0, + promptMessageId: getPreviousUserMessageId(message.id) + }; + messageRetryState.set(message.id, state); + } + return state; +} + +async function editChatMessage(messageId, fallbackIndex = -1) { + const target = await resolveMessageTarget(messageId, fallbackIndex); + if (!target) return setNotice("Message is not ready yet. Try again.", "error"); + const {index, message} = target; + const nextContent = await promptPopup({ + eyebrow: message.role === "user" ? "Your message" : "Assistant message", + title: "Edit message", + description: "Update this message.", + label: "Message", + value: message.content, + confirmLabel: "Save" + }); + const content = (nextContent || "").trim(); + if (!content) return; + 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].id = res.message.id || currentMessages[index].id; + currentMessages[index].content = res.message.content; + const retryState = message.id ? messageRetryState.get(message.id) : null; + if (retryState) { + retryState.variants[retryState.activeIndex] = res.message.content; + } + 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) { + const target = await resolveMessageTarget(messageId, fallbackIndex); + if (!target) return setNotice("Message is not ready yet. Try again.", "error"); + const newest = getNewestMessage(); + const sameNewest = target.index === currentMessages.length - 1; + if (!newest || !sameNewest || newest.role !== "bot") { + return setNotice("Only the newest assistant message can be retried.", "error"); + } + const state = ensureRetryState(newest) || {variants: [newest.content || ""], activeIndex: 0, retriesUsed: 0, promptMessageId: null}; + if (!state || state.retriesUsed >= 5) { + return setNotice("Retry limit reached (5).", "error"); + } + setLoadingState(true); + setNotice("Regenerating reply..."); + try { + const endpoint = getMessageEndpoint(target, "/retry"); + if (!endpoint) return setNotice("Message is not ready yet. Try again.", "error"); + const res = await post(endpoint, {model: modelSelect.value}); + if (res.error || !res.message?.content) { + return setNotice(res.error || "Unable to retry message.", "error"); + } + state.retriesUsed += 1; + state.promptMessageId = res.promptMessageId || state.promptMessageId; + state.variants.push(res.message.content); + state.activeIndex = state.variants.length - 1; + const updateIndex = target.message?.id ? getMessageIndexById(target.message.id) : target.index; + if (updateIndex >= 0) { + currentMessages[updateIndex].id = res.message.id || currentMessages[updateIndex].id; + currentMessages[updateIndex].content = res.message.content; + } + renderMessages(); + setNotice(`Reply regenerated (${state.retriesUsed}/5).`, "success"); + } finally { + setLoadingState(false); + } +} + +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 newest = getNewestMessage(); + const sameNewest = target.index === currentMessages.length - 1; + if (!newest || !sameNewest || newest.role !== "bot") { + return setNotice("Variants can only be switched on the newest assistant message.", "error"); + } + const state = target.message?.id ? messageRetryState.get(target.message.id) : null; + if (!state || state.variants.length < 2) return; + const nextIndex = state.activeIndex + direction; + if (nextIndex < 0 || nextIndex >= state.variants.length) return; + const targetContent = state.variants[nextIndex]; + const endpoint = getMessageEndpoint(target); + if (!endpoint) return setNotice("Message is not ready yet. Try again.", "error"); + const res = await put(endpoint, {content: targetContent}); + if (res.error || !res.message) { + return setNotice(res.error || "Unable to switch variant.", "error"); + } + state.activeIndex = nextIndex; + const updateIndex = target.message?.id ? getMessageIndexById(target.message.id) : target.index; + if (updateIndex >= 0) { + currentMessages[updateIndex].id = res.message.id || currentMessages[updateIndex].id; + currentMessages[updateIndex].content = res.message.content; + } + renderMessages(); +} + +function addMessage(contentOrMessage, isUser = false, isLoading = false, options = {}) { const msgDiv = document.createElement("div"); msgDiv.className = `msg ${isUser ? "user" : "bot"}${isLoading ? " loading" : ""}`; if (isLoading) { @@ -312,12 +508,78 @@ function addMessage(content, isUser = false, isLoading = false) { `; } else { - const header = document.createElement("div"), body = document.createElement("div"); + const message = typeof contentOrMessage === "object" && contentOrMessage + ? contentOrMessage + : {id: null, role: isUser ? "user" : "bot", content: String(contentOrMessage || "")}; + const isMessageUser = message.role === "user"; + msgDiv.className = `msg ${isMessageUser ? "user" : "bot"}`; + 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"; const activeChat = getChatById(activeChatId); - header.textContent = isUser ? "You" : (activeChat?.assistant_persona_name || "Assistant"); - setMessageContent(body, content); + 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 = "Edit"; + 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.addEventListener("click", () => { + void deleteChatMessage(message.id, options.messageIndex ?? -1); + }); + actions.appendChild(deleteBtn); + + if (options.isNewest && message.role === "bot") { + const state = message.id ? ensureRetryState(message) : {retriesUsed: 0, variants: [], activeIndex: 0}; + const retryBtn = document.createElement("button"); + retryBtn.type = "button"; + retryBtn.className = "msg-action-btn"; + retryBtn.textContent = `Retry (${state.retriesUsed}/5)`; + retryBtn.disabled = state.retriesUsed >= 5 || isProcessing; + retryBtn.addEventListener("click", () => { + void retryLatestAssistantMessage(message.id, options.messageIndex ?? -1); + }); + actions.appendChild(retryBtn); + + if (state.variants.length > 1) { + const prevBtn = document.createElement("button"); + prevBtn.type = "button"; + prevBtn.className = "msg-action-btn"; + prevBtn.textContent = "Prev"; + prevBtn.disabled = state.activeIndex <= 0; + prevBtn.addEventListener("click", () => { + void switchRetryVariant(message.id, -1, options.messageIndex ?? -1); + }); + + const nextBtn = document.createElement("button"); + nextBtn.type = "button"; + nextBtn.className = "msg-action-btn"; + nextBtn.textContent = "Next"; + nextBtn.disabled = state.activeIndex >= state.variants.length - 1; + nextBtn.addEventListener("click", () => { + void switchRetryVariant(message.id, 1, options.messageIndex ?? -1); + }); + + const indexBadge = document.createElement("span"); + indexBadge.className = "msg-variant-index"; + indexBadge.textContent = `${state.activeIndex + 1}/${state.variants.length}`; + actions.append(prevBtn, indexBadge, nextBtn); + } + } + msgDiv.append(header, body); + if (actions.childElementCount) { + msgDiv.appendChild(actions); + } } messagesDiv.appendChild(msgDiv); messagesDiv.scrollTop = messagesDiv.scrollHeight; @@ -330,7 +592,10 @@ function renderMessages() { if (!currentMessages.length && (!chat || chat.assistant_persona_id)) { renderMessageEmptyState(); } else { - currentMessages.forEach((msg) => addMessage(msg.content, msg.role === "user")); + currentMessages.forEach((msg, index) => addMessage(msg, msg.role === "user", false, { + messageIndex: index, + isNewest: index === currentMessages.length - 1 + })); } updateChatParticipants(); updateChatActionState(); @@ -889,22 +1154,33 @@ function showAuthScreen(target) { } async function setActiveChat(id) { + setChatLoading(true, "Opening chat..."); activeChatId = id; + messageRetryState = new Map(); updateWorkspaceCopy(); renderChatList(); updateChatActionState(); const res = await get(`/chats/${id}/messages`); - if (res.error) return setNotice(res.error, "error"); + if (res.error) { + setChatLoading(false); + return setNotice(res.error, "error"); + } const existing = getChatById(id); if (res.chat && existing) Object.assign(existing, res.chat); currentMessages = res.messages || []; renderMessages(); updateSendState(); + setChatLoading(false); } async function loadChatSessions() { + setChatLoading(true, "Loading chats..."); const res = await get("/chats"); - if (res.error) return setNotice(res.error, "error"); + if (res.error) { + setChatLoading(false); + return setNotice(res.error, "error"); + } chatSessions = res.chats || []; renderChatList(); if (chatSessions.length) { const preferredChatId = (requestedChatId && getChatById(requestedChatId)) ? requestedChatId : activeChatId && getChatById(activeChatId) ? activeChatId : chatSessions[0].id; + setChatLoading(false); return setActiveChat(preferredChatId); } activeChatId = null; @@ -912,21 +1188,29 @@ async function loadChatSessions() { renderMessages(); updateWorkspaceCopy(); updateSendState(); + setChatLoading(false); return setNotice("Pick a starting action to begin.", "success"); } async function createNewChat() { + setChatLoading(true, "Creating chat..."); const res = await post("/chats", {title: "New chat"}); - if (res.error || !res.chat) return setNotice(res.error || "Unable to create chat.", "error"); + if (res.error || !res.chat) { + setChatLoading(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); } async function startRoleplay() { + setChatLoading(true, "Starting roleplay..."); const assistantPersonaId = Number(roleplayCharacterSelect.value); const userPersonaId = roleplayUserPersonaSelect.value ? Number(roleplayUserPersonaSelect.value) : null; const scenarioPrompt = roleplayScenarioInput?.value.trim() || ""; if (!assistantPersonaId) { + setChatLoading(false); return setRoleplayStarterNotice("Choose a character to start the roleplay.", "error"); } roleplayStarterConfirm.disabled = true; @@ -939,12 +1223,14 @@ async function startRoleplay() { }); roleplayStarterConfirm.disabled = false; if (res.error || !res.chat) { + setChatLoading(false); return setRoleplayStarterNotice(res.error || "Unable to start roleplay.", "error"); } closeRoleplayStarter(); await loadChatSessions(); await setActiveChat(res.chat.id); setNotice(res.generatedInitialMessage ? "Roleplay started with a fresh scene." : "Roleplay reopened.", "success"); + setChatLoading(false); } async function renameChat(id) { @@ -978,6 +1264,7 @@ async function clearChat(id) { 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"); } @@ -992,8 +1279,12 @@ function exportCurrentChat() { } async function deleteChat(id) { + setChatLoading(true, "Deleting chat..."); const chat = getChatById(id); - if (!chat) return; + if (!chat) { + setChatLoading(false); + return; + } const confirmed = await confirmPopup({ eyebrow: "Conversation", title: "Delete chat", @@ -1001,14 +1292,22 @@ async function deleteChat(id) { confirmLabel: "Delete", danger: true }); - if (!confirmed) return; + if (!confirmed) { + setChatLoading(false); + return; + } const res = await del(`/chats/${id}`); - if (res.error) return setNotice(res.error, "error"); + 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() { @@ -1073,8 +1372,7 @@ async function sendMessage() { const res = await post("/chat", {message, model: modelSelect.value, chatId: activeChatId}); if (messagesDiv.contains(loadingMsg)) messagesDiv.removeChild(loadingMsg); if (res.reply) { - currentMessages.push({role: "bot", content: res.reply}); - addMessage(res.reply); + await setActiveChat(activeChatId); await loadSummary(); markOnboardingCompleted(); maybeShowPersonaNudge(); @@ -1138,6 +1436,7 @@ registerForm.addEventListener("submit", (event) => { event.preventDefault(); voi $("logout").addEventListener("click", async () => { await post("/logout", {}); chatDiv.style.display = "none"; authDiv.style.display = "grid"; activeChatId = null; currentUsername = ""; currentSummary = null; chatSessions = []; currentMessages = []; assistantPersonas = []; userPersonas = []; + messageRetryState = new Map(); activeUserPersonaId = null; publishedPersonaIds = new Set(); messagesDiv.innerHTML = ""; renderChatList(); updateChatActionState(); updateComposerPlaceholder(); updateSendState(); diff --git a/public/style.css b/public/style.css index fed4a35..363891d 100644 --- a/public/style.css +++ b/public/style.css @@ -1264,6 +1264,35 @@ select:focus { opacity: 0.86; } +.msg-actions { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; +} + +.msg-action-btn { + min-height: 30px; + padding: 4px 10px; + border-radius: 10px; + border: 1px solid rgba(255, 255, 255, 0.26); + background: rgba(255, 255, 255, 0.12); + color: inherit; + font-size: 0.78rem; + font-weight: 700; + cursor: pointer; +} + +.msg-action-btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.msg-variant-index { + font-size: 0.74rem; + opacity: 0.86; +} + .typing-indicator { display: flex; align-items: center; @@ -1508,6 +1537,10 @@ select:focus { width: 100%; } +.market-preview-scenario-field { + width: 100%; +} + .persona-item-actions button { padding: 8px 11px; font-size: 0.84rem; @@ -1729,6 +1762,21 @@ select:focus { white-space: pre-wrap; } +.market-preview-toggle { + margin-top: 6px; + padding: 0; + border: none; + background: transparent; + color: var(--brand-strong); + font-size: 0.82rem; + font-weight: 700; + text-align: left; +} + +.market-preview-toggle:hover { + text-decoration: underline; +} + .settings-shell { width: min(1180px, 100%); margin: 0 auto; diff --git a/server.js b/server.js index 57774a6..dc12f8c 100644 --- a/server.js +++ b/server.js @@ -448,10 +448,10 @@ const insertChatMessageStmt = db.prepare( ); const getChatMessagesStmt = db.prepare(` - SELECT role, content + SELECT id, role, content FROM chat_messages WHERE chat_id = ? - ORDER BY datetime(created_at) + ORDER BY id ` ) ; @@ -459,12 +459,72 @@ const getRecentChatMessagesStmt = db.prepare(` SELECT role, content FROM chat_messages WHERE chat_id = ? - ORDER BY datetime(created_at) DESC + ORDER BY id DESC LIMIT 20 ` ) ; +const getRecentChatMessagesUpToIdStmt = db.prepare(` + SELECT role, content + FROM chat_messages + WHERE chat_id = ? + AND id <= ? + ORDER BY id DESC + LIMIT 20 + ` + ) +; + +const getChatMessageByIdStmt = db.prepare(` + SELECT id, role, content + FROM chat_messages + WHERE id = ? + AND chat_id = ? + ` + ) +; + +const getChatMessageByIndexStmt = db.prepare(` + SELECT id, role, content + FROM chat_messages + WHERE chat_id = ? + ORDER BY id + LIMIT 1 OFFSET ? + ` + ) +; + +const getLatestChatMessageStmt = db.prepare(` + SELECT id, role, content + FROM chat_messages + WHERE chat_id = ? + ORDER BY id DESC + LIMIT 1 + ` + ) +; + +const getPreviousUserMessageStmt = db.prepare(` + SELECT id, role, content + FROM chat_messages + WHERE chat_id = ? + AND role = 'user' + AND id < ? + ORDER BY id DESC + LIMIT 1 + ` + ) +; + +const updateChatMessageStmt = db.prepare( + "UPDATE chat_messages SET content = ? WHERE id = ? AND chat_id = ?" +); + +const deleteChatMessageStmt = db.prepare( + "DELETE FROM chat_messages WHERE id = ? AND chat_id = ?" +); + const deleteChatMessagesStmt = db.prepare( "DELETE FROM chat_messages WHERE chat_id = ?" ); @@ -1072,7 +1132,9 @@ const buildRoleplayDirectionPrompt = ({assistantPersona, userPersona, scenarioPr "Make the assistant's first instinct, language, and priorities fit their persona exactly.", "Use the user persona as interaction context so the assistant addresses them in a fitting way.", "Avoid generic openings, meta commentary, and requests for permission to begin.", - "Do not write dialogue or internal thoughts for the user.", + "Do not write dialogue, internal thoughts, choices, or actions for the user.", + "Never output any lines like 'User:' or 'You:' and never narrate what the user does.", + "The opening must contain only the assistant character's own spoken words and optional self-actions.", "Prefer a concrete opening beat over vague exposition.", "Include a subtle hook, tension, invitation, or problem that gives the user something to respond to.", "", @@ -1103,11 +1165,67 @@ const buildRoleplayOpenerPrompt = ({assistantPersona, userPersona, scenarioPromp `Address ${userName} naturally within the scene.`, "The message should establish the situation immediately instead of explaining setup out of character.", "Use 1 to 3 short paragraphs. Sensory detail is allowed, but keep momentum.", + "Write only the assistant character's words and optional self-actions.", + "Do not write any user dialogue, user thoughts, or user actions.", + "Do not use labels such as 'User:' or 'You:'.", "End with a line, question, action, or reveal that gives the user an obvious way to answer.", `Keep this scene continuity in mind: ${sceneSummary}` ].join("\n"); }; +const escapeRegExp = (value) => String(value || "").replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + +const containsUserVoiceInRoleplayOpener = (opener, userPersona) => { + const text = String(opener || "").trim(); + if (!text) return true; + + const userName = (userPersona?.name || "").trim(); + const nameParts = userName ? userName.split(/\s+/).filter(Boolean).slice(0, 2) : []; + const tokens = ["user", "you", ...nameParts].filter(Boolean).map(escapeRegExp); + if (!tokens.length) return false; + + const labelPattern = new RegExp(`(^|\\n)\\s*(?:${tokens.join("|")})\\s*:`, "i"); + if (labelPattern.test(text)) return true; + + const userActionPattern = new RegExp( + `(^|\\n)\\s*(?:\\*\\s*)?(?:${tokens.join("|")})\\s+` + + "(?:say|says|said|ask|asks|asked|reply|replies|replied|think|thinks|thought|feel|feels|felt|walk|walks|walked|step|steps|stepped|look|looks|looked|nod|nods|nodded|smile|smiles|smiled|enter|enters|entered|turn|turns|turned)\\b", + "i" + ); + return userActionPattern.test(text); +}; + +const generateRoleplayOpener = async ({selectedModel = "mistral:latest", assistantPersona, userPersona, scenarioPrompt, sceneSummary}) => { + const baseMessages = [ + {role: "system", content: buildPersonaPrompt(assistantPersona)}, + ...(userPersona ? [{role: "system", content: buildUserPersonaPrompt(userPersona)}] : []), + {role: "system", content: buildRoleplayDirectionPrompt({assistantPersona, userPersona, scenarioPrompt, sceneSummary})}, + {role: "user", content: buildRoleplayOpenerPrompt({assistantPersona, userPersona, scenarioPrompt, sceneSummary})} + ]; + + let opener = await generateModelReply(selectedModel, baseMessages); + if (!containsUserVoiceInRoleplayOpener(opener, userPersona)) { + return opener; + } + + const retryMessages = [ + ...baseMessages, + {role: "assistant", content: opener || ""}, + { + role: "user", + content: "Rewrite this opening. Hard rule: only the assistant character may speak or act. Never write user speech, thoughts, actions, or labels like 'User:'/'You:'." + } + ]; + opener = await generateModelReply(selectedModel, retryMessages); + return opener; +}; + +const getChatMessageByIndex = (chatId, index) => { + const safeIndex = Number(index); + if (!Number.isInteger(safeIndex) || safeIndex < 0) return null; + return getChatMessageByIndexStmt.get(chatId, safeIndex) || null; +}; + const createPersonaChat = (user, persona, userPersonaId = null, scenarioPrompt = null, scenarioSummary = null) => { const chatId = insertChatSessionStmt.run(user, persona.name, persona.id, userPersonaId, scenarioPrompt, scenarioSummary).lastInsertRowid; return getChatSessionStmt.get(chatId, user); @@ -1270,6 +1388,80 @@ app.get("/chats/:id/messages", requireLogin, (req, res) => { res.json({messages, chat: session}); }); +app.put("/chats/:id/messages/:messageId", requireLogin, (req, res) => { + const user = req.session.user; + const chatId = Number(req.params.id); + const messageId = Number(req.params.messageId); + const content = String(req.body?.content || "").trim(); + const session = getChatSessionStmt.get(chatId, user); + if (!session) { + return res.status(404).json({error: "Chat not found"}); + } + if (!content) { + return res.status(400).json({error: "Message content is required"}); + } + const result = updateChatMessageStmt.run(content, messageId, chatId); + if (result.changes === 0) { + return res.status(404).json({error: "Message not found"}); + } + touchChatSessionStmt.run(chatId, user); + const message = getChatMessageByIdStmt.get(messageId, chatId); + res.json({message}); +}); + +app.put("/chats/:id/messages/by-index/:index", requireLogin, (req, res) => { + const user = req.session.user; + const chatId = Number(req.params.id); + const content = String(req.body?.content || "").trim(); + const session = getChatSessionStmt.get(chatId, user); + if (!session) { + return res.status(404).json({error: "Chat not found"}); + } + if (!content) { + return res.status(400).json({error: "Message content is required"}); + } + const target = getChatMessageByIndex(chatId, req.params.index); + if (!target) { + return res.status(404).json({error: "Message not found"}); + } + updateChatMessageStmt.run(content, target.id, chatId); + touchChatSessionStmt.run(chatId, user); + const message = getChatMessageByIdStmt.get(target.id, chatId); + res.json({message}); +}); + +app.delete("/chats/:id/messages/:messageId", requireLogin, (req, res) => { + const user = req.session.user; + const chatId = Number(req.params.id); + const messageId = Number(req.params.messageId); + const session = getChatSessionStmt.get(chatId, user); + if (!session) { + return res.status(404).json({error: "Chat not found"}); + } + const result = deleteChatMessageStmt.run(messageId, chatId); + if (result.changes === 0) { + return res.status(404).json({error: "Message not found"}); + } + touchChatSessionStmt.run(chatId, user); + res.json({message: "Message deleted"}); +}); + +app.delete("/chats/:id/messages/by-index/:index", requireLogin, (req, res) => { + const user = req.session.user; + const chatId = Number(req.params.id); + const session = getChatSessionStmt.get(chatId, user); + if (!session) { + return res.status(404).json({error: "Chat not found"}); + } + const target = getChatMessageByIndex(chatId, req.params.index); + if (!target) { + return res.status(404).json({error: "Message not found"}); + } + deleteChatMessageStmt.run(target.id, chatId); + touchChatSessionStmt.run(chatId, user); + res.json({message: "Message deleted"}); +}); + app.post("/chats/:id/clear", requireLogin, (req, res) => { const user = req.session.user; const chatId = Number(req.params.id); @@ -1511,7 +1703,7 @@ app.post("/personas/market/:id/collect", requireLogin, (req, res) => { res.json({persona, activeUserPersonaId}); }); -app.post("/personas/market/:id/chat", requireLogin, (req, res) => { +app.post("/personas/market/:id/chat", requireLogin, async (req, res) => { const user = req.session.user; const marketId = Number(req.params.id); const userPersonaId = req.body?.userPersonaId ? Number(req.body.userPersonaId) : null; @@ -1565,18 +1757,23 @@ app.post("/personas/market/:id/chat", requireLogin, (req, res) => { } updateChatSessionSceneStmt.run(scenarioPrompt, sceneSummary, chat.id, user); chat = getChatSessionStmt.get(chat.id, user); - Promise.resolve(generateModelReply("mistral:latest", [ - {role: "system", content: buildPersonaPrompt(persona)}, - ...(userPersona ? [{role: "system", content: buildUserPersonaPrompt(userPersona)}] : []), - {role: "system", content: buildRoleplayDirectionPrompt({assistantPersona: persona, userPersona, scenarioPrompt, sceneSummary})}, - {role: "user", content: buildRoleplayOpenerPrompt({assistantPersona: persona, userPersona, scenarioPrompt, sceneSummary})} - ])).then((opener) => { + try { + const opener = await generateRoleplayOpener({ + selectedModel: "mistral:latest", + assistantPersona: persona, + userPersona, + scenarioPrompt, + sceneSummary + }); + if (!opener || containsUserVoiceInRoleplayOpener(opener, userPersona)) { + return res.json({persona, chat, existing: false, generatedInitialMessage: false}); + } insertChatMessageStmt.run(chat.id, "bot", opener); touchChatSessionStmt.run(chat.id, user); - res.json({persona, chat: getChatSessionStmt.get(chat.id, user), existing: false, generatedInitialMessage: true, opener}); - }).catch(() => { - res.json({persona, chat, existing: false, generatedInitialMessage: false}); - }); + return res.json({persona, chat: getChatSessionStmt.get(chat.id, user), existing: false, generatedInitialMessage: true, opener}); + } catch { + return res.json({persona, chat, existing: false, generatedInitialMessage: false}); + } }); app.post("/roleplays/start", requireLogin, async (req, res) => { @@ -1621,24 +1818,15 @@ app.post("/roleplays/start", requireLogin, async (req, res) => { updateChatSessionSceneStmt.run(scenarioPrompt, sceneSummary, chat.id, user); chat = getChatSessionStmt.get(chat.id, user); - const messagesPayload = [ - {role: "system", content: buildPersonaPrompt(assistantPersona)} - ]; - if (userPersona) { - messagesPayload.push({role: "system", content: buildUserPersonaPrompt(userPersona)}); - } - messagesPayload.push({ - role: "system", - content: buildRoleplayDirectionPrompt({assistantPersona, userPersona, scenarioPrompt, sceneSummary}) - }); - messagesPayload.push({ - role: "user", - content: buildRoleplayOpenerPrompt({assistantPersona, userPersona, scenarioPrompt, sceneSummary}) - }); - try { - const opener = await generateModelReply(selectedModel, messagesPayload); - if (!opener) { + const opener = await generateRoleplayOpener({ + selectedModel, + assistantPersona, + userPersona, + scenarioPrompt, + sceneSummary + }); + if (!opener || containsUserVoiceInRoleplayOpener(opener, userPersona)) { return res.status(500).json({error: "Failed to generate the character opener"}); } insertChatMessageStmt.run(chat.id, "bot", opener); @@ -1715,6 +1903,151 @@ app.post("/chat", requireLogin, async (req, res) => { } }); +app.post("/chats/:id/messages/:messageId/retry", requireLogin, async (req, res) => { + const user = req.session.user; + const chatId = Number(req.params.id); + const messageId = Number(req.params.messageId); + const selectedModel = req.body?.model || "mistral:latest"; + const session = getChatSessionStmt.get(chatId, user); + if (!session) { + return res.status(404).json({error: "Chat not found"}); + } + + const targetMessage = getChatMessageByIdStmt.get(messageId, chatId); + if (!targetMessage) { + return res.status(404).json({error: "Message not found"}); + } + const latestMessage = getLatestChatMessageStmt.get(chatId); + if (!latestMessage || latestMessage.id !== messageId || latestMessage.role !== "bot") { + return res.status(400).json({error: "Only the newest assistant message can be retried"}); + } + const promptMessage = getPreviousUserMessageStmt.get(chatId, messageId); + if (!promptMessage) { + return res.status(400).json({error: "No previous user prompt found for retry"}); + } + + try { + const activePersona = getAssistantPersonaForChatStmt.get(chatId, user); + const activeUserPersona = getUserPersonaForChatStmt.get(chatId, user) || getActiveUserPersonaStmt.get(user); + const sceneSummary = session.scenario_summary; + const scenarioPrompt = session.scenario_prompt; + const conversation = getRecentChatMessagesUpToIdStmt.all(chatId, promptMessage.id).reverse(); + const messagesPayload = []; + if (activePersona) { + messagesPayload.push({ + role: "system", + content: buildPersonaPrompt(activePersona) + }); + } + if (activeUserPersona) { + messagesPayload.push({ + role: "system", + content: buildUserPersonaPrompt(activeUserPersona) + }); + } + if (activePersona && sceneSummary) { + messagesPayload.push({ + role: "system", + content: buildRoleplayDirectionPrompt({ + assistantPersona: activePersona, + userPersona: activeUserPersona, + scenarioPrompt, + sceneSummary + }) + }); + } + messagesPayload.push( + ...conversation.map((m) => ({ + role: m.role === "bot" ? "assistant" : "user", + content: m.content + })) + ); + + const fullReply = await generateModelReply(selectedModel, messagesPayload); + if (!fullReply) { + return res.status(500).json({error: "Failed to regenerate message"}); + } + updateChatMessageStmt.run(fullReply, messageId, chatId); + touchChatSessionStmt.run(chatId, user); + return res.json({message: {id: messageId, role: "bot", content: fullReply}, promptMessageId: promptMessage.id}); + } catch (err) { + console.error("Retry error:", err); + return res.status(500).json({error: "Failed to regenerate message"}); + } +}); + +app.post("/chats/:id/messages/by-index/:index/retry", requireLogin, async (req, res) => { + const user = req.session.user; + const chatId = Number(req.params.id); + const selectedModel = req.body?.model || "mistral:latest"; + const session = getChatSessionStmt.get(chatId, user); + if (!session) { + return res.status(404).json({error: "Chat not found"}); + } + + const targetMessage = getChatMessageByIndex(chatId, req.params.index); + if (!targetMessage) { + return res.status(404).json({error: "Message not found"}); + } + const latestMessage = getLatestChatMessageStmt.get(chatId); + if (!latestMessage || latestMessage.id !== targetMessage.id || latestMessage.role !== "bot") { + return res.status(400).json({error: "Only the newest assistant message can be retried"}); + } + const promptMessage = getPreviousUserMessageStmt.get(chatId, targetMessage.id); + if (!promptMessage) { + return res.status(400).json({error: "No previous user prompt found for retry"}); + } + + try { + const activePersona = getAssistantPersonaForChatStmt.get(chatId, user); + const activeUserPersona = getUserPersonaForChatStmt.get(chatId, user) || getActiveUserPersonaStmt.get(user); + const sceneSummary = session.scenario_summary; + const scenarioPrompt = session.scenario_prompt; + const conversation = getRecentChatMessagesUpToIdStmt.all(chatId, promptMessage.id).reverse(); + const messagesPayload = []; + if (activePersona) { + messagesPayload.push({ + role: "system", + content: buildPersonaPrompt(activePersona) + }); + } + if (activeUserPersona) { + messagesPayload.push({ + role: "system", + content: buildUserPersonaPrompt(activeUserPersona) + }); + } + if (activePersona && sceneSummary) { + messagesPayload.push({ + role: "system", + content: buildRoleplayDirectionPrompt({ + assistantPersona: activePersona, + userPersona: activeUserPersona, + scenarioPrompt, + sceneSummary + }) + }); + } + messagesPayload.push( + ...conversation.map((m) => ({ + role: m.role === "bot" ? "assistant" : "user", + content: m.content + })) + ); + + const fullReply = await generateModelReply(selectedModel, messagesPayload); + if (!fullReply) { + return res.status(500).json({error: "Failed to regenerate message"}); + } + updateChatMessageStmt.run(fullReply, targetMessage.id, chatId); + touchChatSessionStmt.run(chatId, user); + return res.json({message: {id: targetMessage.id, role: "bot", content: fullReply}, promptMessageId: promptMessage.id}); + } catch (err) { + console.error("Retry error:", err); + return res.status(500).json({error: "Failed to regenerate message"}); + } +}); + app.get("/session", (req, res) => { if (req.session.user) res.json({user: req.session.user}); else res.json({user: null});