implement more usability features

This commit is contained in:
2026-04-10 11:18:12 +02:00
parent ed9184a4d1
commit 1b57979f77
6 changed files with 496 additions and 137 deletions
+13
View File
@@ -211,6 +211,19 @@
</div>
</header>
<div id="chatActivityOverlay" class="chat-activity-overlay hidden" role="status" aria-live="polite" aria-atomic="true">
<div class="chat-activity-card">
<div class="chat-activity-mark" aria-hidden="true">
<span></span>
<span></span>
<span></span>
</div>
<p id="chatActivityEyebrow" class="eyebrow">Please wait</p>
<h3 id="chatActivityTitle">Preparing chat</h3>
<p id="chatActivityDetail" class="subtitle">The assistant is still working.</p>
</div>
</div>
<div id="messages" class="messages-panel messages-panel-focus"></div>
<div class="input-shell input-shell-focus">
+13
View File
@@ -169,6 +169,19 @@
</form>
</div>
</div>
<div id="marketActivityOverlay" class="chat-activity-overlay hidden" role="status" aria-live="polite" aria-atomic="true">
<div class="chat-activity-card">
<div class="chat-activity-mark" aria-hidden="true">
<span></span>
<span></span>
<span></span>
</div>
<p id="marketActivityEyebrow" class="eyebrow">Please wait</p>
<h3 id="marketActivityTitle">Opening roleplay</h3>
<p id="marketActivityDetail" class="subtitle">The AI character is preparing the first message.</p>
</div>
</div>
<script src="market.js" defer></script>
</body>
</html>
+34
View File
@@ -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;
}
+90 -28
View File
@@ -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});
+63
View File
@@ -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);
}
+282 -108
View File
@@ -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"});
}
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});
}
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
});
}
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});
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});
}
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
});
}
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});
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"});