diff --git a/public/index.html b/public/index.html index 980152d..2713e1a 100644 --- a/public/index.html +++ b/public/index.html @@ -211,6 +211,19 @@ + +
diff --git a/public/market.html b/public/market.html index 21b7efc..a19386d 100644 --- a/public/market.html +++ b/public/market.html @@ -169,6 +169,19 @@
+ + diff --git a/public/market.js b/public/market.js index 875119b..79d3c42 100644 --- a/public/market.js +++ b/public/market.js @@ -33,6 +33,10 @@ const marketPersonaAppearance = $("marketPersonaAppearance"); const marketPersonaBackground = $("marketPersonaBackground"); const marketPersonaDetails = $("marketPersonaDetails"); const marketPersonaExampleDialogues = $("marketPersonaExampleDialogues"); +const marketActivityOverlay = $("marketActivityOverlay"); +const marketActivityEyebrow = $("marketActivityEyebrow"); +const marketActivityTitle = $("marketActivityTitle"); +const marketActivityDetail = $("marketActivityDetail"); const themeNameTargets = document.querySelectorAll("[data-theme-name]"); const themes = { @@ -51,6 +55,7 @@ let pendingMarketPersona = null; let editingPersonaId = null; let activeUserPersonaId = null; let activeMarketView = "ai-characters"; +let marketActivityDepth = 0; async function request(url, data, method = "POST") { try { @@ -93,6 +98,29 @@ function setPersonaFormNotice(message = "", state = "") { marketPersonaFormNotice.className = state ? `status ${state}` : "status"; } +function setMarketActivity(active, { + eyebrow = "Please wait", + title = "Opening roleplay", + detail = "The AI character is preparing the first message." +} = {}) { + if (!marketActivityOverlay || !marketActivityEyebrow || !marketActivityTitle || !marketActivityDetail) return; + if (active) { + marketActivityDepth += 1; + marketActivityEyebrow.textContent = eyebrow; + marketActivityTitle.textContent = title; + marketActivityDetail.textContent = detail; + marketActivityOverlay.classList.remove("hidden"); + return; + } + marketActivityDepth = Math.max(0, marketActivityDepth - 1); + if (marketActivityDepth === 0) { + marketActivityOverlay.classList.add("hidden"); + marketActivityEyebrow.textContent = "Please wait"; + marketActivityTitle.textContent = "Opening roleplay"; + marketActivityDetail.textContent = "The AI character is preparing the first message."; + } +} + function setMarketView(view) { const normalizedView = view === "user-personas" ? "user-personas" : "ai-characters"; activeMarketView = normalizedView; @@ -352,12 +380,18 @@ async function startMarketPersonaChat(marketId, userPersonaSelection, scenarioPr } setMarketStatus("Starting AI Character chat..."); + setMarketActivity(true, { + eyebrow: "Starting roleplay", + title: "Opening AI Character chat", + detail: "Generating the initial message and preparing the chat window." + }); const userPersonaId = userPersonaSelection === "self" ? null : Number(userPersonaSelection); const res = await post(`/personas/market/${marketId}/chat`, { userPersonaId, scenarioPrompt: scenarioPrompt.trim() }); if (res.error || !res.chat) { + setMarketActivity(false); setMarketStatus(res.error || "Unable to start chat.", "error"); return; } diff --git a/public/script.js b/public/script.js index 5bdf75d..3fa45a4 100644 --- a/public/script.js +++ b/public/script.js @@ -13,6 +13,7 @@ const startRoleplayBtn = $("startRoleplay"); const chatListLoading = $("chatListLoading"), chatListLoadingText = $("chatListLoadingText"); const renameChatBtn = $("renameChat"), clearChatBtn = $("clearChat"), exportChatBtn = $("exportChat"); const activeChatTitle = $("activeChatTitle"), sessionUser = $("sessionUser"); +const chatActivityOverlay = $("chatActivityOverlay"), chatActivityEyebrow = $("chatActivityEyebrow"), chatActivityTitle = $("chatActivityTitle"), chatActivityDetail = $("chatActivityDetail"); const contextChipButton = $("contextChipButton"), contextPopover = $("contextPopover"), contextSummaryLabel = $("contextSummaryLabel"); const chatActionsMenuButton = $("chatActionsMenuButton"), chatActionsPopover = $("chatActionsPopover"); const chatModePill = $("chatModePill"), chatCharacterPill = $("chatCharacterPill"), chatUserPersonaPill = $("chatUserPersonaPill"), chatScenePill = $("chatScenePill"); @@ -48,6 +49,7 @@ let noticeTimer = null; let workspaceMode = localStorage.getItem("krishd-workspace-mode") || "basic"; let onboardingStep = 1, onboardingIntent = "ask"; let chatLoadingDepth = 0; +let chatActivityDepth = 0; let messageRetryState = new Map(); const onboardingPrompts = { @@ -131,6 +133,25 @@ function setChatLoading(active, message = "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; @@ -351,17 +372,34 @@ function getPreviousUserMessageId(messageId) { return null; } +function getRetryStateFromMessage(message) { + const variants = Array.isArray(message?.retryVariants) && message.retryVariants.length + ? [...message.retryVariants] + : [message?.content || ""]; + const requestedActiveIndex = Number(message?.retryActiveIndex); + const activeIndex = Number.isInteger(requestedActiveIndex) + ? Math.min(Math.max(requestedActiveIndex, 0), variants.length - 1) + : 0; + return { + variants, + activeIndex, + retriesUsed: Number(message?.retryRetriesUsed || 0), + promptMessageId: message?.retryPromptMessageId ?? getPreviousUserMessageId(message?.id) + }; +} + +function syncRetryStateFromMessage(message) { + if (!message?.id) return null; + const state = getRetryStateFromMessage(message); + messageRetryState.set(message.id, state); + return state; +} + function ensureRetryState(message) { if (!message?.id) return null; let state = messageRetryState.get(message.id); if (!state) { - state = { - variants: [message.content || ""], - activeIndex: 0, - retriesUsed: 0, - promptMessageId: getPreviousUserMessageId(message.id) - }; - messageRetryState.set(message.id, state); + state = syncRetryStateFromMessage(message); } return state; } @@ -386,12 +424,8 @@ async function editChatMessage(messageId, fallbackIndex = -1) { 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; - } + currentMessages[index] = {...currentMessages[index], ...res.message}; + syncRetryStateFromMessage(currentMessages[index]); renderMessages(); setNotice("Message updated.", "success"); } @@ -438,7 +472,11 @@ async function retryLatestAssistantMessage(messageId, fallbackIndex = -1) { if (!state || state.retriesUsed >= 5) { return setNotice("Retry limit reached (5).", "error"); } - setLoadingState(true); + setLoadingState(true, { + eyebrow: "Regenerating", + title: "Generating another version", + detail: "The assistant is rewriting the latest reply." + }); setNotice("Regenerating reply..."); try { const endpoint = getMessageEndpoint(target, "/retry"); @@ -447,19 +485,16 @@ async function retryLatestAssistantMessage(messageId, fallbackIndex = -1) { 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; + currentMessages[updateIndex] = {...currentMessages[updateIndex], ...res.message}; + syncRetryStateFromMessage(currentMessages[updateIndex]); } - renderMessages(); - setNotice(`Reply regenerated (${state.retriesUsed}/5).`, "success"); + const nextState = res.message?.id ? messageRetryState.get(res.message.id) : state; + setNotice(`Reply regenerated (${nextState?.retriesUsed || state.retriesUsed}/5).`, "success"); } finally { setLoadingState(false); + renderMessages(); } } @@ -478,15 +513,14 @@ async function switchRetryVariant(messageId, direction, fallbackIndex = -1) { 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}); + const res = await put(endpoint, {content: targetContent, retryActiveIndex: nextIndex}); 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; + currentMessages[updateIndex] = {...currentMessages[updateIndex], ...res.message}; + syncRetryStateFromMessage(currentMessages[updateIndex]); } renderMessages(); } @@ -1135,13 +1169,15 @@ async function deletePersona(id) { async function publishPersona(id) { const res = await post(`/personas/${id}/publish`, {}); if (res.error) return setNotice(res.error, "error"); await loadPersonas(); setNotice("Persona published.", "success"); } async function unpublishPersona(id) { const res = await post(`/personas/${id}/unpublish`, {}); if (res.error) return setNotice(res.error, "error"); await loadPersonas(); setNotice("Persona unpublished.", "success"); } -function setLoadingState(loading) { +function setLoadingState(loading, overlayOptions = null) { isProcessing = loading; msgInput.disabled = loading; sendBtn.classList.toggle("loading", loading); if (loading) { msgInput.placeholder = "Processing response..."; + setChatActivity(true, overlayOptions || undefined); } else { + setChatActivity(false); updateComposerPlaceholder(); } updateSendState(); @@ -1155,12 +1191,18 @@ function showAuthScreen(target) { async function setActiveChat(id) { setChatLoading(true, "Opening chat..."); + setChatActivity(true, { + eyebrow: "Opening chat", + title: "Loading conversation", + detail: "Fetching messages and restoring the current scene." + }); activeChatId = id; messageRetryState = new Map(); updateWorkspaceCopy(); renderChatList(); updateChatActionState(); const res = await get(`/chats/${id}/messages`); if (res.error) { setChatLoading(false); + setChatActivity(false); return setNotice(res.error, "error"); } const existing = getChatById(id); @@ -1168,6 +1210,7 @@ async function setActiveChat(id) { currentMessages = res.messages || []; renderMessages(); updateSendState(); setChatLoading(false); + setChatActivity(false); } async function loadChatSessions() { @@ -1194,23 +1237,36 @@ async function loadChatSessions() { async function createNewChat() { setChatLoading(true, "Creating chat..."); + setChatActivity(true, { + eyebrow: "Creating chat", + title: "Opening a new conversation", + detail: "Setting up a fresh chat so you can start sending messages." + }); const res = await post("/chats", {title: "New chat"}); if (res.error || !res.chat) { setChatLoading(false); + setChatActivity(false); return setNotice(res.error || "Unable to create chat.", "error"); } chatSessions.unshift(res.chat); await loadSummary(); renderChatList(); await setActiveChat(res.chat.id); setNotice("New chat created.", "success"); msgInput.focus(); setChatLoading(false); + setChatActivity(false); } async function startRoleplay() { setChatLoading(true, "Starting roleplay..."); + setChatActivity(true, { + eyebrow: "Starting roleplay", + title: "Building the opening scene", + detail: "Generating the first beat and opening the roleplay chat." + }); const assistantPersonaId = Number(roleplayCharacterSelect.value); const userPersonaId = roleplayUserPersonaSelect.value ? Number(roleplayUserPersonaSelect.value) : null; const scenarioPrompt = roleplayScenarioInput?.value.trim() || ""; if (!assistantPersonaId) { setChatLoading(false); + setChatActivity(false); return setRoleplayStarterNotice("Choose a character to start the roleplay.", "error"); } roleplayStarterConfirm.disabled = true; @@ -1224,6 +1280,7 @@ async function startRoleplay() { roleplayStarterConfirm.disabled = false; if (res.error || !res.chat) { setChatLoading(false); + setChatActivity(false); return setRoleplayStarterNotice(res.error || "Unable to start roleplay.", "error"); } closeRoleplayStarter(); @@ -1231,6 +1288,7 @@ async function startRoleplay() { await setActiveChat(res.chat.id); setNotice(res.generatedInitialMessage ? "Roleplay started with a fresh scene." : "Roleplay reopened.", "success"); setChatLoading(false); + setChatActivity(false); } async function renameChat(id) { @@ -1366,7 +1424,11 @@ async function sendMessage() { currentChat.title = autoTitle; updateWorkspaceCopy(); renderChatList(); await put(`/chats/${activeChatId}`, {title: autoTitle}); } currentMessages.push({role: "user", content: message}); addMessage(message, true); updateChatActionState(); - msgInput.value = ""; msgInput.style.height = "auto"; setLoadingState(true); setNotice("Generating reply..."); + msgInput.value = ""; msgInput.style.height = "auto"; setLoadingState(true, { + eyebrow: "Assistant replying", + title: "Generating response", + detail: "The assistant is reading your message and preparing a reply." + }); setNotice("Generating reply..."); const loadingMsg = addMessage("", false, true); try { const res = await post("/chat", {message, model: modelSelect.value, chatId: activeChatId}); diff --git a/public/style.css b/public/style.css index 363891d..aade007 100644 --- a/public/style.css +++ b/public/style.css @@ -811,6 +811,7 @@ select:focus { gap: 16px; min-height: 0; overflow: hidden; + position: relative; } .chat-layout-focus { @@ -831,6 +832,68 @@ select:focus { min-height: 58vh; } +.chat-activity-overlay { + position: fixed; + inset: 0; + z-index: 60; + display: grid; + place-items: center; + padding: 28px; + background: + linear-gradient(180deg, rgba(244, 239, 231, 0.78), rgba(248, 245, 239, 0.86)); + backdrop-filter: blur(10px); +} + +.chat-activity-card { + width: min(440px, 100%); + padding: 28px; + border-radius: 26px; + border: 1px solid rgba(24, 32, 31, 0.1); + background: + linear-gradient(155deg, rgba(255, 253, 249, 0.96), rgba(248, 241, 233, 0.94)); + box-shadow: 0 24px 70px rgba(56, 39, 21, 0.16); + text-align: center; +} + +.chat-activity-card h3 { + margin-top: 10px; + font-size: clamp(1.4rem, 2vw, 1.8rem); +} + +.chat-activity-card .subtitle { + margin-top: 12px; +} + +.chat-activity-mark { + display: inline-flex; + align-items: flex-end; + justify-content: center; + gap: 8px; + min-height: 34px; + margin-bottom: 10px; +} + +.chat-activity-mark span { + width: 8px; + border-radius: 999px; + background: linear-gradient(180deg, var(--brand), var(--brand-strong)); + animation: loadingWave 1.1s ease-in-out infinite; +} + +.chat-activity-mark span:nth-child(1) { + height: 14px; +} + +.chat-activity-mark span:nth-child(2) { + height: 28px; + animation-delay: 0.12s; +} + +.chat-activity-mark span:nth-child(3) { + height: 18px; + animation-delay: 0.24s; +} + .input-shell-focus { border-top: 1px solid rgba(24, 32, 31, 0.07); } diff --git a/server.js b/server.js index c69b85e..c5a0109 100644 --- a/server.js +++ b/server.js @@ -185,6 +185,16 @@ db.prepare(` TEXT NOT NULL, + retry_variants + TEXT, + retry_active_index + INTEGER + DEFAULT 0, + retry_retries_used + INTEGER + DEFAULT 0, + retry_prompt_message_id + INTEGER, created_at DATETIME DEFAULT @@ -444,11 +454,11 @@ const clearUserPersonaFromChatsStmt = db.prepare(` `); const insertChatMessageStmt = db.prepare( - "INSERT INTO chat_messages (chat_id, role, content) VALUES (?, ?, ?)" + "INSERT INTO chat_messages (chat_id, role, content, retry_variants, retry_active_index, retry_retries_used, retry_prompt_message_id) VALUES (?, ?, ?, ?, ?, ?, ?)" ); const getChatMessagesStmt = db.prepare(` - SELECT id, role, content + SELECT id, role, content, retry_variants, retry_active_index, retry_retries_used, retry_prompt_message_id FROM chat_messages WHERE chat_id = ? ORDER BY id @@ -477,7 +487,7 @@ const getRecentChatMessagesUpToIdStmt = db.prepare(` ; const getChatMessageByIdStmt = db.prepare(` - SELECT id, role, content + SELECT id, role, content, retry_variants, retry_active_index, retry_retries_used, retry_prompt_message_id FROM chat_messages WHERE id = ? AND chat_id = ? @@ -486,7 +496,7 @@ const getChatMessageByIdStmt = db.prepare(` ; const getChatMessageByIndexStmt = db.prepare(` - SELECT id, role, content + SELECT id, role, content, retry_variants, retry_active_index, retry_retries_used, retry_prompt_message_id FROM chat_messages WHERE chat_id = ? ORDER BY id @@ -496,7 +506,7 @@ const getChatMessageByIndexStmt = db.prepare(` ; const getLatestChatMessageStmt = db.prepare(` - SELECT id, role, content + SELECT id, role, content, retry_variants, retry_active_index, retry_retries_used, retry_prompt_message_id FROM chat_messages WHERE chat_id = ? ORDER BY id DESC @@ -506,7 +516,7 @@ const getLatestChatMessageStmt = db.prepare(` ; const getPreviousUserMessageStmt = db.prepare(` - SELECT id, role, content + SELECT id, role, content, retry_variants, retry_active_index, retry_retries_used, retry_prompt_message_id FROM chat_messages WHERE chat_id = ? AND role = 'user' @@ -521,6 +531,10 @@ const updateChatMessageStmt = db.prepare( "UPDATE chat_messages SET content = ? WHERE id = ? AND chat_id = ?" ); +const updateChatMessageWithRetryStateStmt = db.prepare( + "UPDATE chat_messages SET content = ?, retry_variants = ?, retry_active_index = ?, retry_retries_used = ?, retry_prompt_message_id = ? WHERE id = ? AND chat_id = ?" +); + const deleteChatMessageStmt = db.prepare( "DELETE FROM chat_messages WHERE id = ? AND chat_id = ?" ); @@ -810,7 +824,7 @@ if (legacyChatsExist && migrationNeeded.count === 0) { const sessionId = insertChatSessionStmt.run(username, "Imported chat", null, null).lastInsertRowid; const legacyMessages = getLegacyChatsStmt.all(username); legacyMessages.forEach(msg => { - insertChatMessageStmt.run(sessionId, msg.role, msg.content); + insertChatMessage(sessionId, msg.role, msg.content); }); }); } @@ -858,6 +872,26 @@ if (!hasMarketExampleDialogues) { } db.prepare("UPDATE persona_market SET usage_count = 0 WHERE usage_count IS NULL").run(); +const chatMessageColumns = db.prepare("PRAGMA table_info(chat_messages)").all(); +const hasRetryVariants = chatMessageColumns.some(column => column.name === "retry_variants"); +if (!hasRetryVariants) { + db.prepare("ALTER TABLE chat_messages ADD COLUMN retry_variants TEXT").run(); +} +const hasRetryActiveIndex = chatMessageColumns.some(column => column.name === "retry_active_index"); +if (!hasRetryActiveIndex) { + db.prepare("ALTER TABLE chat_messages ADD COLUMN retry_active_index INTEGER DEFAULT 0").run(); +} +const hasRetryRetriesUsed = chatMessageColumns.some(column => column.name === "retry_retries_used"); +if (!hasRetryRetriesUsed) { + db.prepare("ALTER TABLE chat_messages ADD COLUMN retry_retries_used INTEGER DEFAULT 0").run(); +} +const hasRetryPromptMessageId = chatMessageColumns.some(column => column.name === "retry_prompt_message_id"); +if (!hasRetryPromptMessageId) { + db.prepare("ALTER TABLE chat_messages ADD COLUMN retry_prompt_message_id INTEGER").run(); +} +db.prepare("UPDATE chat_messages SET retry_active_index = 0 WHERE retry_active_index IS NULL").run(); +db.prepare("UPDATE chat_messages SET retry_retries_used = 0 WHERE retry_retries_used IS NULL").run(); + const chatSessionColumns = db.prepare("PRAGMA table_info(chat_sessions)").all(); const hasAssistantPersonaLink = chatSessionColumns.some(column => column.name === "assistant_persona_id"); if (!hasAssistantPersonaLink) { @@ -1132,6 +1166,10 @@ const buildRoleplayDirectionPrompt = ({assistantPersona, userPersona, scenarioPr "Ground the interaction in a specific scenario, place, and immediate dramatic situation.", "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.", + "Format scene description, body language, and nonverbal actions in italics using *word*.", + "Format direct speech in double quotes like \"word\".", + "Format shouted words, sharp emphasis, or explosive expressions in bold using **word**.", + "Keep formatting readable and intentional instead of wrapping every sentence in markdown.", "Avoid generic openings, meta commentary, and requests for permission to begin.", "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.", @@ -1166,6 +1204,9 @@ 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 scene description, body language, and nonverbal actions in italics using *word*.", + "Write direct speech in double quotes like \"word\".", + "Use bold markdown like **word** only for loud, forceful, or strongly emphasized expressions.", "Write only the assistant character's words and optional self-actions.", "Do not write any user dialogue, thoughts, feelings, choices, or actions.", "Do not use labels such as 'User:' or 'You:'.", @@ -1221,6 +1262,139 @@ const generateRoleplayOpener = async ({selectedModel = "mistral:latest", assista return opener; }; +const buildRetryPayload = async ({chatId, user, session, selectedModel, targetMessage}) => { + 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 promptMessage = getPreviousUserMessageStmt.get(chatId, targetMessage.id); + + if (!promptMessage) { + if (!activePersona || !sceneSummary) { + return {error: "No previous user prompt found for retry"}; + } + const opener = await generateRoleplayOpener({ + selectedModel, + assistantPersona: activePersona, + userPersona: activeUserPersona, + scenarioPrompt, + sceneSummary + }); + if (!opener || containsUserVoiceInRoleplayOpener(opener, activeUserPersona)) { + return {error: "Failed to regenerate message"}; + } + return {fullReply: opener, promptMessageId: null}; + } + + 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 {error: "Failed to regenerate message"}; + } + return {fullReply, promptMessageId: promptMessage.id}; +}; + +const parseRetryVariants = (rawValue, content) => { + if (!rawValue) return [content || ""]; + try { + const parsed = JSON.parse(rawValue); + if (Array.isArray(parsed)) { + const normalized = parsed + .map(item => String(item || "").trim()) + .filter(Boolean); + if (normalized.length) return normalized; + } + } catch { + // Ignore invalid stored retry history and fall back to current content. + } + return [content || ""]; +}; + +const clampRetryActiveIndex = (index, variants) => { + const numeric = Number(index); + if (!Number.isInteger(numeric) || numeric < 0) return 0; + return Math.min(numeric, Math.max(0, variants.length - 1)); +}; + +const formatChatMessage = (row) => { + if (!row) return null; + const retryVariants = parseRetryVariants(row.retry_variants, row.content); + const retryActiveIndex = clampRetryActiveIndex(row.retry_active_index, retryVariants); + return { + id: row.id, + role: row.role, + content: row.content, + retryVariants, + retryActiveIndex, + retryRetriesUsed: Number(row.retry_retries_used || 0), + retryPromptMessageId: row.retry_prompt_message_id ?? null + }; +}; + +const persistChatMessageRetryState = ({chatId, messageId, content, retryVariants, retryActiveIndex, retryRetriesUsed, retryPromptMessageId}) => { + return updateChatMessageWithRetryStateStmt.run( + content, + JSON.stringify(retryVariants || [content || ""]), + clampRetryActiveIndex(retryActiveIndex, retryVariants || [content || ""]), + Number(retryRetriesUsed || 0), + retryPromptMessageId ?? null, + messageId, + chatId + ); +}; + +const insertChatMessage = (chatId, role, content, retryState = null) => { + const normalizedContent = String(content || ""); + const payload = retryState + ? { + retryVariants: Array.isArray(retryState.retryVariants) && retryState.retryVariants.length ? retryState.retryVariants : [normalizedContent], + retryActiveIndex: retryState.retryActiveIndex ?? 0, + retryRetriesUsed: retryState.retryRetriesUsed ?? 0, + retryPromptMessageId: retryState.retryPromptMessageId ?? null + } + : null; + return insertChatMessageStmt.run( + chatId, + role, + normalizedContent, + payload ? JSON.stringify(payload.retryVariants) : null, + payload ? clampRetryActiveIndex(payload.retryActiveIndex, payload.retryVariants) : 0, + payload ? Number(payload.retryRetriesUsed || 0) : 0, + payload ? payload.retryPromptMessageId : null + ); +}; + const getChatMessageByIndex = (chatId, index) => { const safeIndex = Number(index); if (!Number.isInteger(safeIndex) || safeIndex < 0) return null; @@ -1385,7 +1559,7 @@ app.get("/chats/:id/messages", requireLogin, (req, res) => { if (!session) { return res.status(404).json({error: "Chat not found"}); } - const messages = getChatMessagesStmt.all(chatId); + const messages = getChatMessagesStmt.all(chatId).map(formatChatMessage); res.json({messages, chat: session}); }); @@ -1401,12 +1575,31 @@ app.put("/chats/:id/messages/:messageId", requireLogin, (req, res) => { if (!content) { return res.status(400).json({error: "Message content is required"}); } - const result = updateChatMessageStmt.run(content, messageId, chatId); - if (result.changes === 0) { + const existingMessage = formatChatMessage(getChatMessageByIdStmt.get(messageId, chatId)); + if (!existingMessage) { return res.status(404).json({error: "Message not found"}); } + const requestedRetryActiveIndex = Number(req.body?.retryActiveIndex); + if (existingMessage.role === "bot") { + const retryVariants = [...existingMessage.retryVariants]; + const nextActiveIndex = Number.isInteger(requestedRetryActiveIndex) + ? clampRetryActiveIndex(requestedRetryActiveIndex, retryVariants) + : existingMessage.retryActiveIndex; + retryVariants[nextActiveIndex] = content; + persistChatMessageRetryState({ + chatId, + messageId, + content, + retryVariants, + retryActiveIndex: nextActiveIndex, + retryRetriesUsed: existingMessage.retryRetriesUsed, + retryPromptMessageId: existingMessage.retryPromptMessageId + }); + } else { + updateChatMessageStmt.run(content, messageId, chatId); + } touchChatSessionStmt.run(chatId, user); - const message = getChatMessageByIdStmt.get(messageId, chatId); + const message = formatChatMessage(getChatMessageByIdStmt.get(messageId, chatId)); res.json({message}); }); @@ -1425,9 +1618,31 @@ app.put("/chats/:id/messages/by-index/:index", requireLogin, (req, res) => { if (!target) { return res.status(404).json({error: "Message not found"}); } - updateChatMessageStmt.run(content, target.id, chatId); + const existingMessage = formatChatMessage(getChatMessageByIdStmt.get(target.id, chatId)); + if (!existingMessage) { + return res.status(404).json({error: "Message not found"}); + } + const requestedRetryActiveIndex = Number(req.body?.retryActiveIndex); + if (existingMessage.role === "bot") { + const retryVariants = [...existingMessage.retryVariants]; + const nextActiveIndex = Number.isInteger(requestedRetryActiveIndex) + ? clampRetryActiveIndex(requestedRetryActiveIndex, retryVariants) + : existingMessage.retryActiveIndex; + retryVariants[nextActiveIndex] = content; + persistChatMessageRetryState({ + chatId, + messageId: target.id, + content, + retryVariants, + retryActiveIndex: nextActiveIndex, + retryRetriesUsed: existingMessage.retryRetriesUsed, + retryPromptMessageId: existingMessage.retryPromptMessageId + }); + } else { + updateChatMessageStmt.run(content, target.id, chatId); + } touchChatSessionStmt.run(chatId, user); - const message = getChatMessageByIdStmt.get(target.id, chatId); + const message = formatChatMessage(getChatMessageByIdStmt.get(target.id, chatId)); res.json({message}); }); @@ -1769,7 +1984,12 @@ app.post("/personas/market/:id/chat", requireLogin, async (req, res) => { if (!opener || containsUserVoiceInRoleplayOpener(opener, userPersona)) { return res.json({persona, chat, existing: false, generatedInitialMessage: false}); } - insertChatMessageStmt.run(chat.id, "bot", opener); + insertChatMessage(chat.id, "bot", opener, { + retryVariants: [opener], + retryActiveIndex: 0, + retryRetriesUsed: 0, + retryPromptMessageId: null + }); touchChatSessionStmt.run(chat.id, user); return res.json({persona, chat: getChatSessionStmt.get(chat.id, user), existing: false, generatedInitialMessage: true, opener}); } catch { @@ -1830,7 +2050,12 @@ app.post("/roleplays/start", requireLogin, async (req, res) => { if (!opener || containsUserVoiceInRoleplayOpener(opener, userPersona)) { return res.status(500).json({error: "Failed to generate the character opener"}); } - insertChatMessageStmt.run(chat.id, "bot", opener); + insertChatMessage(chat.id, "bot", opener, { + retryVariants: [opener], + retryActiveIndex: 0, + retryRetriesUsed: 0, + retryPromptMessageId: null + }); touchChatSessionStmt.run(chat.id, user); return res.json({chat: getChatSessionStmt.get(chat.id, user), existing: false, generatedInitialMessage: true, opener}); } catch { @@ -1861,7 +2086,7 @@ app.post("/chat", requireLogin, async (req, res) => { const history = getRecentChatMessagesStmt.all(targetChatId); const conversation = [...history].reverse(); conversation.push({role: "user", content: message}); - insertChatMessageStmt.run(targetChatId, "user", message); + insertChatMessage(targetChatId, "user", message); touchChatSessionStmt.run(targetChatId, user); const messagesPayload = []; if (activePersona) { @@ -1894,7 +2119,12 @@ app.post("/chat", requireLogin, async (req, res) => { })) ); const fullReply = await generateModelReply(selectedModel, messagesPayload); - insertChatMessageStmt.run(targetChatId, "bot", fullReply); + insertChatMessage(targetChatId, "bot", fullReply, { + retryVariants: [fullReply], + retryActiveIndex: 0, + retryRetriesUsed: 0, + retryPromptMessageId: null + }); touchChatSessionStmt.run(targetChatId, user); console.log("Reply length:", fullReply.length); res.json({reply: fullReply}); @@ -1922,55 +2152,27 @@ app.post("/chats/:id/messages/:messageId/retry", requireLogin, async (req, res) 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) - }); + const retryResult = await buildRetryPayload({chatId, user, session, selectedModel, targetMessage}); + if (retryResult.error) { + return res.status(400).json({error: retryResult.error}); } - 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); + const {fullReply, promptMessageId} = retryResult; + const existingMessage = formatChatMessage(targetMessage); + const retryVariants = [...existingMessage.retryVariants, fullReply]; + const retryActiveIndex = retryVariants.length - 1; + const retryRetriesUsed = existingMessage.retryRetriesUsed + 1; + persistChatMessageRetryState({ + chatId, + messageId, + content: fullReply, + retryVariants, + retryActiveIndex, + retryRetriesUsed, + retryPromptMessageId: promptMessageId + }); touchChatSessionStmt.run(chatId, user); - return res.json({message: {id: messageId, role: "bot", content: fullReply}, promptMessageId: promptMessage.id}); + return res.json({message: formatChatMessage(getChatMessageByIdStmt.get(messageId, chatId)), promptMessageId}); } catch (err) { console.error("Retry error:", err); return res.status(500).json({error: "Failed to regenerate message"}); @@ -1994,55 +2196,27 @@ app.post("/chats/:id/messages/by-index/:index/retry", requireLogin, async (req, 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) - }); + const retryResult = await buildRetryPayload({chatId, user, session, selectedModel, targetMessage}); + if (retryResult.error) { + return res.status(400).json({error: retryResult.error}); } - 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); + const {fullReply, promptMessageId} = retryResult; + const existingMessage = formatChatMessage(targetMessage); + const retryVariants = [...existingMessage.retryVariants, fullReply]; + const retryActiveIndex = retryVariants.length - 1; + const retryRetriesUsed = existingMessage.retryRetriesUsed + 1; + persistChatMessageRetryState({ + chatId, + messageId: targetMessage.id, + content: fullReply, + retryVariants, + retryActiveIndex, + retryRetriesUsed, + retryPromptMessageId: promptMessageId + }); touchChatSessionStmt.run(chatId, user); - return res.json({message: {id: targetMessage.id, role: "bot", content: fullReply}, promptMessageId: promptMessage.id}); + return res.json({message: formatChatMessage(getChatMessageByIdStmt.get(targetMessage.id, chatId)), promptMessageId}); } catch (err) { console.error("Retry error:", err); return res.status(500).json({error: "Failed to regenerate message"});