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