add more features

This commit is contained in:
2026-04-10 00:34:05 +02:00
parent 6e2aa2b353
commit 67d8693654
6 changed files with 792 additions and 64 deletions
-1
View File
@@ -216,7 +216,6 @@
<div class="input-shell input-shell-focus"> <div class="input-shell input-shell-focus">
<div class="input-meta"> <div class="input-meta">
<p class="composer-hint">Enter to send · Shift+Enter for new line</p> <p class="composer-hint">Enter to send · Shift+Enter for new line</p>
<p id="appNotice" class="status inline-status hidden" aria-live="polite"></p>
</div> </div>
<div id="quickPrompts" class="quick-prompts" aria-label="Suggested starters"></div> <div id="quickPrompts" class="quick-prompts" aria-label="Suggested starters"></div>
<div class="input-container"> <div class="input-container">
+4
View File
@@ -108,6 +108,10 @@
<span>You in this roleplay</span> <span>You in this roleplay</span>
<select id="marketPreviewUserPersonaSelect"></select> <select id="marketPreviewUserPersonaSelect"></select>
</label> </label>
<label id="marketPreviewScenarioField" class="field market-preview-scenario-field hidden">
<span>Scenario (optional)</span>
<textarea id="marketPreviewScenario" rows="3" maxlength="240" placeholder="Set the opening scene you want the AI character to start with."></textarea>
</label>
<button id="marketPreviewConfirm" type="button" class="primary-action">Take persona</button> <button id="marketPreviewConfirm" type="button" class="primary-action">Take persona</button>
</div> </div>
</div> </div>
+53 -8
View File
@@ -18,6 +18,8 @@ const marketPreviewDetails = $("marketPreviewDetails");
const marketPreviewExampleDialogues = $("marketPreviewExampleDialogues"); const marketPreviewExampleDialogues = $("marketPreviewExampleDialogues");
const marketPreviewUserPersonaField = $("marketPreviewUserPersonaField"); const marketPreviewUserPersonaField = $("marketPreviewUserPersonaField");
const marketPreviewUserPersonaSelect = $("marketPreviewUserPersonaSelect"); const marketPreviewUserPersonaSelect = $("marketPreviewUserPersonaSelect");
const marketPreviewScenarioField = $("marketPreviewScenarioField");
const marketPreviewScenario = $("marketPreviewScenario");
const marketPreviewConfirm = $("marketPreviewConfirm"); const marketPreviewConfirm = $("marketPreviewConfirm");
const marketPreviewClose = $("marketPreviewClose"); const marketPreviewClose = $("marketPreviewClose");
const marketPersonaModal = $("marketPersonaModal"); const marketPersonaModal = $("marketPersonaModal");
@@ -112,10 +114,45 @@ function formatPersonaField(value) {
return value && value.trim() ? value.trim() : "Not provided."; 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() { function closeMarketPreview() {
marketPreviewModal.classList.add("hidden"); marketPreviewModal.classList.add("hidden");
pendingMarketPersona = null; pendingMarketPersona = null;
marketPreviewUserPersonaSelect.innerHTML = ""; marketPreviewUserPersonaSelect.innerHTML = "";
marketPreviewScenario.value = "";
} }
function populateUserPersonaChoices() { function populateUserPersonaChoices() {
@@ -151,14 +188,15 @@ function openMarketPreview(persona) {
pendingMarketPersona = persona; pendingMarketPersona = persona;
marketPreviewName.textContent = persona.name; marketPreviewName.textContent = persona.name;
marketPreviewMeta.textContent = buildPersonaMeta(persona); marketPreviewMeta.textContent = buildPersonaMeta(persona);
marketPreviewPronouns.textContent = formatPersonaField(persona.pronouns); setCollapsiblePreviewText(marketPreviewPronouns, persona.pronouns, 80);
marketPreviewAppearance.textContent = formatPersonaField(persona.appearance); setCollapsiblePreviewText(marketPreviewAppearance, persona.appearance);
marketPreviewBackground.textContent = formatPersonaField(persona.background); setCollapsiblePreviewText(marketPreviewBackground, persona.background);
marketPreviewDetails.textContent = formatPersonaField(persona.details); setCollapsiblePreviewText(marketPreviewDetails, persona.details);
marketPreviewExampleDialogues.textContent = formatPersonaField(persona.example_dialogues); setCollapsiblePreviewText(marketPreviewExampleDialogues, persona.example_dialogues);
const isAssistant = persona.persona_type === "assistant"; const isAssistant = persona.persona_type === "assistant";
marketPreviewUserPersonaField.classList.toggle("hidden", !isAssistant); marketPreviewUserPersonaField.classList.toggle("hidden", !isAssistant);
marketPreviewScenarioField.classList.toggle("hidden", !isAssistant);
marketPreviewConfirm.textContent = isAssistant ? "Start roleplay" : "Collect persona"; marketPreviewConfirm.textContent = isAssistant ? "Start roleplay" : "Collect persona";
if (isAssistant) { if (isAssistant) {
populateUserPersonaChoices(); populateUserPersonaChoices();
@@ -303,7 +341,7 @@ async function collectMarketPersona(marketId) {
setMarketStatus("User Persona collected.", "success"); setMarketStatus("User Persona collected.", "success");
} }
async function startMarketPersonaChat(marketId, userPersonaSelection) { async function startMarketPersonaChat(marketId, userPersonaSelection, scenarioPrompt = "") {
if (!userPersonaSelection) { if (!userPersonaSelection) {
setMarketStatus("Choose who you are before starting roleplay.", "error"); setMarketStatus("Choose who you are before starting roleplay.", "error");
return; return;
@@ -315,7 +353,10 @@ async function startMarketPersonaChat(marketId, userPersonaSelection) {
setMarketStatus("Starting AI Character chat..."); setMarketStatus("Starting AI Character chat...");
const userPersonaId = userPersonaSelection === "self" ? null : Number(userPersonaSelection); 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) { if (res.error || !res.chat) {
setMarketStatus(res.error || "Unable to start chat.", "error"); setMarketStatus(res.error || "Unable to start chat.", "error");
return; return;
@@ -385,7 +426,11 @@ marketPreviewConfirm.addEventListener("click", async () => {
if (!pendingMarketPersona) return; if (!pendingMarketPersona) return;
const marketId = pendingMarketPersona.id; const marketId = pendingMarketPersona.id;
if (pendingMarketPersona.persona_type === "assistant") { if (pendingMarketPersona.persona_type === "assistant") {
await startMarketPersonaChat(marketId, marketPreviewUserPersonaSelect.value || "self"); await startMarketPersonaChat(
marketId,
marketPreviewUserPersonaSelect.value || "self",
marketPreviewScenario.value || ""
);
closeMarketPreview(); closeMarketPreview();
return; return;
} }
+322 -23
View File
@@ -10,6 +10,7 @@ const registerUsernameInput = $("registerUsername"), registerPasswordInput = $("
const loginSubmit = $("loginSubmit"), registerSubmit = $("registerSubmit"); const loginSubmit = $("loginSubmit"), registerSubmit = $("registerSubmit");
const chatList = $("chatList"), chatSearchInput = $("chatSearch"), newChatBtn = $("newChat"); const chatList = $("chatList"), chatSearchInput = $("chatSearch"), newChatBtn = $("newChat");
const startRoleplayBtn = $("startRoleplay"); const startRoleplayBtn = $("startRoleplay");
const chatListLoading = $("chatListLoading"), chatListLoadingText = $("chatListLoadingText");
const renameChatBtn = $("renameChat"), clearChatBtn = $("clearChat"), exportChatBtn = $("exportChat"); const renameChatBtn = $("renameChat"), clearChatBtn = $("clearChat"), exportChatBtn = $("exportChat");
const activeChatTitle = $("activeChatTitle"), sessionUser = $("sessionUser"); const activeChatTitle = $("activeChatTitle"), sessionUser = $("sessionUser");
const contextChipButton = $("contextChipButton"), contextPopover = $("contextPopover"), contextSummaryLabel = $("contextSummaryLabel"); const contextChipButton = $("contextChipButton"), contextPopover = $("contextPopover"), contextSummaryLabel = $("contextSummaryLabel");
@@ -46,6 +47,8 @@ let roleplayPresetCharacterId = null;
let noticeTimer = null; let noticeTimer = null;
let workspaceMode = localStorage.getItem("krishd-workspace-mode") || "basic"; let workspaceMode = localStorage.getItem("krishd-workspace-mode") || "basic";
let onboardingStep = 1, onboardingIntent = "ask"; let onboardingStep = 1, onboardingIntent = "ask";
let chatLoadingDepth = 0;
let messageRetryState = new Map();
const onboardingPrompts = { const onboardingPrompts = {
ask: ["Explain this topic in simple terms.", "Compare these options and recommend one."], 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"; 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) { function applyWorkspaceMode(nextMode, persist = true) {
workspaceMode = nextMode === "advanced" ? "advanced" : "basic"; workspaceMode = nextMode === "advanced" ? "advanced" : "basic";
document.body.dataset.workspaceMode = workspaceMode; document.body.dataset.workspaceMode = workspaceMode;
@@ -245,16 +263,6 @@ function updateRoleplaySuggestions() {
}); });
} }
function getQuickPromptsForChat(chat) { 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 []; return [];
} }
function renderQuickPrompts() { function renderQuickPrompts() {
@@ -295,7 +303,195 @@ function updateSendState() {
sendBtn.disabled = isProcessing || !hasText; 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"); const msgDiv = document.createElement("div");
msgDiv.className = `msg ${isUser ? "user" : "bot"}${isLoading ? " loading" : ""}`; msgDiv.className = `msg ${isUser ? "user" : "bot"}${isLoading ? " loading" : ""}`;
if (isLoading) { if (isLoading) {
@@ -312,12 +508,78 @@ function addMessage(content, isUser = false, isLoading = false) {
</div> </div>
</div>`; </div>`;
} else { } 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"; header.className = "msg-header"; body.className = "msg-content";
actions.className = "msg-actions";
const activeChat = getChatById(activeChatId); const activeChat = getChatById(activeChatId);
header.textContent = isUser ? "You" : (activeChat?.assistant_persona_name || "Assistant"); header.textContent = isMessageUser ? "You" : (activeChat?.assistant_persona_name || "Assistant");
setMessageContent(body, content); 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); msgDiv.append(header, body);
if (actions.childElementCount) {
msgDiv.appendChild(actions);
}
} }
messagesDiv.appendChild(msgDiv); messagesDiv.appendChild(msgDiv);
messagesDiv.scrollTop = messagesDiv.scrollHeight; messagesDiv.scrollTop = messagesDiv.scrollHeight;
@@ -330,7 +592,10 @@ function renderMessages() {
if (!currentMessages.length && (!chat || chat.assistant_persona_id)) { if (!currentMessages.length && (!chat || chat.assistant_persona_id)) {
renderMessageEmptyState(); renderMessageEmptyState();
} else { } 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(); updateChatParticipants();
updateChatActionState(); updateChatActionState();
@@ -889,22 +1154,33 @@ function showAuthScreen(target) {
} }
async function setActiveChat(id) { async function setActiveChat(id) {
setChatLoading(true, "Opening chat...");
activeChatId = id; activeChatId = id;
messageRetryState = new Map();
updateWorkspaceCopy(); renderChatList(); updateChatActionState(); updateWorkspaceCopy(); renderChatList(); updateChatActionState();
const res = await get(`/chats/${id}/messages`); 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); const existing = getChatById(id);
if (res.chat && existing) Object.assign(existing, res.chat); if (res.chat && existing) Object.assign(existing, res.chat);
currentMessages = res.messages || []; renderMessages(); currentMessages = res.messages || []; renderMessages();
updateSendState(); updateSendState();
setChatLoading(false);
} }
async function loadChatSessions() { async function loadChatSessions() {
setChatLoading(true, "Loading chats...");
const res = await get("/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(); chatSessions = res.chats || []; renderChatList();
if (chatSessions.length) { if (chatSessions.length) {
const preferredChatId = (requestedChatId && getChatById(requestedChatId)) ? requestedChatId : activeChatId && getChatById(activeChatId) ? activeChatId : chatSessions[0].id; const preferredChatId = (requestedChatId && getChatById(requestedChatId)) ? requestedChatId : activeChatId && getChatById(activeChatId) ? activeChatId : chatSessions[0].id;
setChatLoading(false);
return setActiveChat(preferredChatId); return setActiveChat(preferredChatId);
} }
activeChatId = null; activeChatId = null;
@@ -912,21 +1188,29 @@ async function loadChatSessions() {
renderMessages(); renderMessages();
updateWorkspaceCopy(); updateWorkspaceCopy();
updateSendState(); updateSendState();
setChatLoading(false);
return setNotice("Pick a starting action to begin.", "success"); return setNotice("Pick a starting action to begin.", "success");
} }
async function createNewChat() { async function createNewChat() {
setChatLoading(true, "Creating chat...");
const res = await post("/chats", {title: "New 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"); chatSessions.unshift(res.chat); await loadSummary(); renderChatList(); await setActiveChat(res.chat.id); setNotice("New chat created.", "success");
msgInput.focus(); msgInput.focus();
setChatLoading(false);
} }
async function startRoleplay() { async function startRoleplay() {
setChatLoading(true, "Starting roleplay...");
const assistantPersonaId = Number(roleplayCharacterSelect.value); const assistantPersonaId = Number(roleplayCharacterSelect.value);
const userPersonaId = roleplayUserPersonaSelect.value ? Number(roleplayUserPersonaSelect.value) : null; const userPersonaId = roleplayUserPersonaSelect.value ? Number(roleplayUserPersonaSelect.value) : null;
const scenarioPrompt = roleplayScenarioInput?.value.trim() || ""; const scenarioPrompt = roleplayScenarioInput?.value.trim() || "";
if (!assistantPersonaId) { if (!assistantPersonaId) {
setChatLoading(false);
return setRoleplayStarterNotice("Choose a character to start the roleplay.", "error"); return setRoleplayStarterNotice("Choose a character to start the roleplay.", "error");
} }
roleplayStarterConfirm.disabled = true; roleplayStarterConfirm.disabled = true;
@@ -939,12 +1223,14 @@ async function startRoleplay() {
}); });
roleplayStarterConfirm.disabled = false; roleplayStarterConfirm.disabled = false;
if (res.error || !res.chat) { if (res.error || !res.chat) {
setChatLoading(false);
return setRoleplayStarterNotice(res.error || "Unable to start roleplay.", "error"); return setRoleplayStarterNotice(res.error || "Unable to start roleplay.", "error");
} }
closeRoleplayStarter(); closeRoleplayStarter();
await loadChatSessions(); await loadChatSessions();
await setActiveChat(res.chat.id); await setActiveChat(res.chat.id);
setNotice(res.generatedInitialMessage ? "Roleplay started with a fresh scene." : "Roleplay reopened.", "success"); setNotice(res.generatedInitialMessage ? "Roleplay started with a fresh scene." : "Roleplay reopened.", "success");
setChatLoading(false);
} }
async function renameChat(id) { async function renameChat(id) {
@@ -978,6 +1264,7 @@ async function clearChat(id) {
if (!confirmed) return; if (!confirmed) return;
const res = await post(`/chats/${id}/clear`, {}); const res = await post(`/chats/${id}/clear`, {});
if (res.error) return setNotice(res.error, "error"); if (res.error) return setNotice(res.error, "error");
messageRetryState = new Map();
currentMessages = []; renderMessages(); await loadSummary(); setNotice("Chat cleared.", "success"); currentMessages = []; renderMessages(); await loadSummary(); setNotice("Chat cleared.", "success");
} }
@@ -992,8 +1279,12 @@ function exportCurrentChat() {
} }
async function deleteChat(id) { async function deleteChat(id) {
setChatLoading(true, "Deleting chat...");
const chat = getChatById(id); const chat = getChatById(id);
if (!chat) return; if (!chat) {
setChatLoading(false);
return;
}
const confirmed = await confirmPopup({ const confirmed = await confirmPopup({
eyebrow: "Conversation", eyebrow: "Conversation",
title: "Delete chat", title: "Delete chat",
@@ -1001,14 +1292,22 @@ async function deleteChat(id) {
confirmLabel: "Delete", confirmLabel: "Delete",
danger: true danger: true
}); });
if (!confirmed) return; if (!confirmed) {
setChatLoading(false);
return;
}
const res = await del(`/chats/${id}`); 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); chatSessions = chatSessions.filter((item) => item.id !== id);
if (activeChatId === id) { activeChatId = null; currentMessages = []; renderMessages(); } if (activeChatId === id) { activeChatId = null; currentMessages = []; renderMessages(); }
messageRetryState = new Map();
await loadSummary(); renderChatList(); await loadSummary(); renderChatList();
if (chatSessions.length) await setActiveChat(chatSessions[0].id); else await createNewChat(); if (chatSessions.length) await setActiveChat(chatSessions[0].id); else await createNewChat();
setNotice("Chat deleted.", "success"); setNotice("Chat deleted.", "success");
setChatLoading(false);
} }
async function displayModels() { async function displayModels() {
@@ -1073,8 +1372,7 @@ async function sendMessage() {
const res = await post("/chat", {message, model: modelSelect.value, chatId: activeChatId}); const res = await post("/chat", {message, model: modelSelect.value, chatId: activeChatId});
if (messagesDiv.contains(loadingMsg)) messagesDiv.removeChild(loadingMsg); if (messagesDiv.contains(loadingMsg)) messagesDiv.removeChild(loadingMsg);
if (res.reply) { if (res.reply) {
currentMessages.push({role: "bot", content: res.reply}); await setActiveChat(activeChatId);
addMessage(res.reply);
await loadSummary(); await loadSummary();
markOnboardingCompleted(); markOnboardingCompleted();
maybeShowPersonaNudge(); maybeShowPersonaNudge();
@@ -1138,6 +1436,7 @@ registerForm.addEventListener("submit", (event) => { event.preventDefault(); voi
$("logout").addEventListener("click", async () => { $("logout").addEventListener("click", async () => {
await post("/logout", {}); chatDiv.style.display = "none"; authDiv.style.display = "grid"; await post("/logout", {}); chatDiv.style.display = "none"; authDiv.style.display = "grid";
activeChatId = null; currentUsername = ""; currentSummary = null; chatSessions = []; currentMessages = []; assistantPersonas = []; userPersonas = []; activeChatId = null; currentUsername = ""; currentSummary = null; chatSessions = []; currentMessages = []; assistantPersonas = []; userPersonas = [];
messageRetryState = new Map();
activeUserPersonaId = null; publishedPersonaIds = new Set(); messagesDiv.innerHTML = ""; renderChatList(); updateChatActionState(); activeUserPersonaId = null; publishedPersonaIds = new Set(); messagesDiv.innerHTML = ""; renderChatList(); updateChatActionState();
updateComposerPlaceholder(); updateComposerPlaceholder();
updateSendState(); updateSendState();
+48
View File
@@ -1264,6 +1264,35 @@ select:focus {
opacity: 0.86; 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 { .typing-indicator {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -1508,6 +1537,10 @@ select:focus {
width: 100%; width: 100%;
} }
.market-preview-scenario-field {
width: 100%;
}
.persona-item-actions button { .persona-item-actions button {
padding: 8px 11px; padding: 8px 11px;
font-size: 0.84rem; font-size: 0.84rem;
@@ -1729,6 +1762,21 @@ select:focus {
white-space: pre-wrap; 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 { .settings-shell {
width: min(1180px, 100%); width: min(1180px, 100%);
margin: 0 auto; margin: 0 auto;
+365 -32
View File
@@ -448,10 +448,10 @@ const insertChatMessageStmt = db.prepare(
); );
const getChatMessagesStmt = db.prepare(` const getChatMessagesStmt = db.prepare(`
SELECT role, content SELECT id, role, content
FROM chat_messages FROM chat_messages
WHERE chat_id = ? WHERE chat_id = ?
ORDER BY datetime(created_at) ORDER BY id
` `
) )
; ;
@@ -459,12 +459,72 @@ const getRecentChatMessagesStmt = db.prepare(`
SELECT role, content SELECT role, content
FROM chat_messages FROM chat_messages
WHERE chat_id = ? WHERE chat_id = ?
ORDER BY datetime(created_at) DESC ORDER BY id DESC
LIMIT 20 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( const deleteChatMessagesStmt = db.prepare(
"DELETE FROM chat_messages WHERE chat_id = ?" "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.", "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.", "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.", "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.", "Prefer a concrete opening beat over vague exposition.",
"Include a subtle hook, tension, invitation, or problem that gives the user something to respond to.", "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.`, `Address ${userName} naturally within the scene.`,
"The message should establish the situation immediately instead of explaining setup out of character.", "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.", "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.", "End with a line, question, action, or reveal that gives the user an obvious way to answer.",
`Keep this scene continuity in mind: ${sceneSummary}` `Keep this scene continuity in mind: ${sceneSummary}`
].join("\n"); ].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 createPersonaChat = (user, persona, userPersonaId = null, scenarioPrompt = null, scenarioSummary = null) => {
const chatId = insertChatSessionStmt.run(user, persona.name, persona.id, userPersonaId, scenarioPrompt, scenarioSummary).lastInsertRowid; const chatId = insertChatSessionStmt.run(user, persona.name, persona.id, userPersonaId, scenarioPrompt, scenarioSummary).lastInsertRowid;
return getChatSessionStmt.get(chatId, user); return getChatSessionStmt.get(chatId, user);
@@ -1270,6 +1388,80 @@ app.get("/chats/:id/messages", requireLogin, (req, res) => {
res.json({messages, chat: session}); 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) => { app.post("/chats/:id/clear", requireLogin, (req, res) => {
const user = req.session.user; const user = req.session.user;
const chatId = Number(req.params.id); const chatId = Number(req.params.id);
@@ -1511,7 +1703,7 @@ app.post("/personas/market/:id/collect", requireLogin, (req, res) => {
res.json({persona, activeUserPersonaId}); 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 user = req.session.user;
const marketId = Number(req.params.id); const marketId = Number(req.params.id);
const userPersonaId = req.body?.userPersonaId ? Number(req.body.userPersonaId) : null; 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); updateChatSessionSceneStmt.run(scenarioPrompt, sceneSummary, chat.id, user);
chat = getChatSessionStmt.get(chat.id, user); chat = getChatSessionStmt.get(chat.id, user);
Promise.resolve(generateModelReply("mistral:latest", [ try {
{role: "system", content: buildPersonaPrompt(persona)}, const opener = await generateRoleplayOpener({
...(userPersona ? [{role: "system", content: buildUserPersonaPrompt(userPersona)}] : []), selectedModel: "mistral:latest",
{role: "system", content: buildRoleplayDirectionPrompt({assistantPersona: persona, userPersona, scenarioPrompt, sceneSummary})}, assistantPersona: persona,
{role: "user", content: buildRoleplayOpenerPrompt({assistantPersona: persona, userPersona, scenarioPrompt, sceneSummary})} userPersona,
])).then((opener) => { scenarioPrompt,
sceneSummary
});
if (!opener || containsUserVoiceInRoleplayOpener(opener, userPersona)) {
return res.json({persona, chat, existing: false, generatedInitialMessage: false});
}
insertChatMessageStmt.run(chat.id, "bot", opener); insertChatMessageStmt.run(chat.id, "bot", opener);
touchChatSessionStmt.run(chat.id, user); touchChatSessionStmt.run(chat.id, user);
res.json({persona, chat: getChatSessionStmt.get(chat.id, user), existing: false, generatedInitialMessage: true, opener}); return res.json({persona, chat: getChatSessionStmt.get(chat.id, user), existing: false, generatedInitialMessage: true, opener});
}).catch(() => { } catch {
res.json({persona, chat, existing: false, generatedInitialMessage: false}); return res.json({persona, chat, existing: false, generatedInitialMessage: false});
}); }
}); });
app.post("/roleplays/start", requireLogin, async (req, res) => { 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); updateChatSessionSceneStmt.run(scenarioPrompt, sceneSummary, chat.id, user);
chat = getChatSessionStmt.get(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 { try {
const opener = await generateModelReply(selectedModel, messagesPayload); const opener = await generateRoleplayOpener({
if (!opener) { selectedModel,
assistantPersona,
userPersona,
scenarioPrompt,
sceneSummary
});
if (!opener || containsUserVoiceInRoleplayOpener(opener, userPersona)) {
return res.status(500).json({error: "Failed to generate the character opener"}); return res.status(500).json({error: "Failed to generate the character opener"});
} }
insertChatMessageStmt.run(chat.id, "bot", 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) => { app.get("/session", (req, res) => {
if (req.session.user) res.json({user: req.session.user}); if (req.session.user) res.json({user: req.session.user});
else res.json({user: null}); else res.json({user: null});