implement more usability features

This commit is contained in:
2026-04-10 11:18:12 +02:00
parent ed9184a4d1
commit 1b57979f77
6 changed files with 496 additions and 137 deletions
+283 -109
View File
@@ -185,6 +185,16 @@ db.prepare(`
TEXT
NOT
NULL,
retry_variants
TEXT,
retry_active_index
INTEGER
DEFAULT 0,
retry_retries_used
INTEGER
DEFAULT 0,
retry_prompt_message_id
INTEGER,
created_at
DATETIME
DEFAULT
@@ -444,11 +454,11 @@ const clearUserPersonaFromChatsStmt = db.prepare(`
`);
const insertChatMessageStmt = db.prepare(
"INSERT INTO chat_messages (chat_id, role, content) VALUES (?, ?, ?)"
"INSERT INTO chat_messages (chat_id, role, content, retry_variants, retry_active_index, retry_retries_used, retry_prompt_message_id) VALUES (?, ?, ?, ?, ?, ?, ?)"
);
const getChatMessagesStmt = db.prepare(`
SELECT id, role, content
SELECT id, role, content, retry_variants, retry_active_index, retry_retries_used, retry_prompt_message_id
FROM chat_messages
WHERE chat_id = ?
ORDER BY id
@@ -477,7 +487,7 @@ const getRecentChatMessagesUpToIdStmt = db.prepare(`
;
const getChatMessageByIdStmt = db.prepare(`
SELECT id, role, content
SELECT id, role, content, retry_variants, retry_active_index, retry_retries_used, retry_prompt_message_id
FROM chat_messages
WHERE id = ?
AND chat_id = ?
@@ -486,7 +496,7 @@ const getChatMessageByIdStmt = db.prepare(`
;
const getChatMessageByIndexStmt = db.prepare(`
SELECT id, role, content
SELECT id, role, content, retry_variants, retry_active_index, retry_retries_used, retry_prompt_message_id
FROM chat_messages
WHERE chat_id = ?
ORDER BY id
@@ -496,7 +506,7 @@ const getChatMessageByIndexStmt = db.prepare(`
;
const getLatestChatMessageStmt = db.prepare(`
SELECT id, role, content
SELECT id, role, content, retry_variants, retry_active_index, retry_retries_used, retry_prompt_message_id
FROM chat_messages
WHERE chat_id = ?
ORDER BY id DESC
@@ -506,7 +516,7 @@ const getLatestChatMessageStmt = db.prepare(`
;
const getPreviousUserMessageStmt = db.prepare(`
SELECT id, role, content
SELECT id, role, content, retry_variants, retry_active_index, retry_retries_used, retry_prompt_message_id
FROM chat_messages
WHERE chat_id = ?
AND role = 'user'
@@ -521,6 +531,10 @@ const updateChatMessageStmt = db.prepare(
"UPDATE chat_messages SET content = ? WHERE id = ? AND chat_id = ?"
);
const updateChatMessageWithRetryStateStmt = db.prepare(
"UPDATE chat_messages SET content = ?, retry_variants = ?, retry_active_index = ?, retry_retries_used = ?, retry_prompt_message_id = ? WHERE id = ? AND chat_id = ?"
);
const deleteChatMessageStmt = db.prepare(
"DELETE FROM chat_messages WHERE id = ? AND chat_id = ?"
);
@@ -810,7 +824,7 @@ if (legacyChatsExist && migrationNeeded.count === 0) {
const sessionId = insertChatSessionStmt.run(username, "Imported chat", null, null).lastInsertRowid;
const legacyMessages = getLegacyChatsStmt.all(username);
legacyMessages.forEach(msg => {
insertChatMessageStmt.run(sessionId, msg.role, msg.content);
insertChatMessage(sessionId, msg.role, msg.content);
});
});
}
@@ -858,6 +872,26 @@ if (!hasMarketExampleDialogues) {
}
db.prepare("UPDATE persona_market SET usage_count = 0 WHERE usage_count IS NULL").run();
const chatMessageColumns = db.prepare("PRAGMA table_info(chat_messages)").all();
const hasRetryVariants = chatMessageColumns.some(column => column.name === "retry_variants");
if (!hasRetryVariants) {
db.prepare("ALTER TABLE chat_messages ADD COLUMN retry_variants TEXT").run();
}
const hasRetryActiveIndex = chatMessageColumns.some(column => column.name === "retry_active_index");
if (!hasRetryActiveIndex) {
db.prepare("ALTER TABLE chat_messages ADD COLUMN retry_active_index INTEGER DEFAULT 0").run();
}
const hasRetryRetriesUsed = chatMessageColumns.some(column => column.name === "retry_retries_used");
if (!hasRetryRetriesUsed) {
db.prepare("ALTER TABLE chat_messages ADD COLUMN retry_retries_used INTEGER DEFAULT 0").run();
}
const hasRetryPromptMessageId = chatMessageColumns.some(column => column.name === "retry_prompt_message_id");
if (!hasRetryPromptMessageId) {
db.prepare("ALTER TABLE chat_messages ADD COLUMN retry_prompt_message_id INTEGER").run();
}
db.prepare("UPDATE chat_messages SET retry_active_index = 0 WHERE retry_active_index IS NULL").run();
db.prepare("UPDATE chat_messages SET retry_retries_used = 0 WHERE retry_retries_used IS NULL").run();
const chatSessionColumns = db.prepare("PRAGMA table_info(chat_sessions)").all();
const hasAssistantPersonaLink = chatSessionColumns.some(column => column.name === "assistant_persona_id");
if (!hasAssistantPersonaLink) {
@@ -1132,6 +1166,10 @@ const buildRoleplayDirectionPrompt = ({assistantPersona, userPersona, scenarioPr
"Ground the interaction in a specific scenario, place, and immediate dramatic situation.",
"Make the assistant's first instinct, language, and priorities fit their persona exactly.",
"Use the user persona as interaction context so the assistant addresses them in a fitting way.",
"Format scene description, body language, and nonverbal actions in italics using *word*.",
"Format direct speech in double quotes like \"word\".",
"Format shouted words, sharp emphasis, or explosive expressions in bold using **word**.",
"Keep formatting readable and intentional instead of wrapping every sentence in markdown.",
"Avoid generic openings, meta commentary, and requests for permission to begin.",
"Do not write dialogue, internal thoughts, choices, or actions for the user.",
"Never output any lines like 'User:' or 'You:' and never narrate what the user does.",
@@ -1166,6 +1204,9 @@ const buildRoleplayOpenerPrompt = ({assistantPersona, userPersona, scenarioPromp
`Address ${userName} naturally within the scene.`,
"The message should establish the situation immediately instead of explaining setup out of character.",
"Use 1 to 3 short paragraphs. Sensory detail is allowed, but keep momentum.",
"Write scene description, body language, and nonverbal actions in italics using *word*.",
"Write direct speech in double quotes like \"word\".",
"Use bold markdown like **word** only for loud, forceful, or strongly emphasized expressions.",
"Write only the assistant character's words and optional self-actions.",
"Do not write any user dialogue, thoughts, feelings, choices, or actions.",
"Do not use labels such as 'User:' or 'You:'.",
@@ -1221,6 +1262,139 @@ const generateRoleplayOpener = async ({selectedModel = "mistral:latest", assista
return opener;
};
const buildRetryPayload = async ({chatId, user, session, selectedModel, targetMessage}) => {
const activePersona = getAssistantPersonaForChatStmt.get(chatId, user);
const activeUserPersona = getUserPersonaForChatStmt.get(chatId, user) || getActiveUserPersonaStmt.get(user);
const sceneSummary = session.scenario_summary;
const scenarioPrompt = session.scenario_prompt;
const promptMessage = getPreviousUserMessageStmt.get(chatId, targetMessage.id);
if (!promptMessage) {
if (!activePersona || !sceneSummary) {
return {error: "No previous user prompt found for retry"};
}
const opener = await generateRoleplayOpener({
selectedModel,
assistantPersona: activePersona,
userPersona: activeUserPersona,
scenarioPrompt,
sceneSummary
});
if (!opener || containsUserVoiceInRoleplayOpener(opener, activeUserPersona)) {
return {error: "Failed to regenerate message"};
}
return {fullReply: opener, promptMessageId: null};
}
const conversation = getRecentChatMessagesUpToIdStmt.all(chatId, promptMessage.id).reverse();
const messagesPayload = [];
if (activePersona) {
messagesPayload.push({
role: "system",
content: buildPersonaPrompt(activePersona)
});
}
if (activeUserPersona) {
messagesPayload.push({
role: "system",
content: buildUserPersonaPrompt(activeUserPersona)
});
}
if (activePersona && sceneSummary) {
messagesPayload.push({
role: "system",
content: buildRoleplayDirectionPrompt({
assistantPersona: activePersona,
userPersona: activeUserPersona,
scenarioPrompt,
sceneSummary
})
});
}
messagesPayload.push(
...conversation.map((m) => ({
role: m.role === "bot" ? "assistant" : "user",
content: m.content
}))
);
const fullReply = await generateModelReply(selectedModel, messagesPayload);
if (!fullReply) {
return {error: "Failed to regenerate message"};
}
return {fullReply, promptMessageId: promptMessage.id};
};
const parseRetryVariants = (rawValue, content) => {
if (!rawValue) return [content || ""];
try {
const parsed = JSON.parse(rawValue);
if (Array.isArray(parsed)) {
const normalized = parsed
.map(item => String(item || "").trim())
.filter(Boolean);
if (normalized.length) return normalized;
}
} catch {
// Ignore invalid stored retry history and fall back to current content.
}
return [content || ""];
};
const clampRetryActiveIndex = (index, variants) => {
const numeric = Number(index);
if (!Number.isInteger(numeric) || numeric < 0) return 0;
return Math.min(numeric, Math.max(0, variants.length - 1));
};
const formatChatMessage = (row) => {
if (!row) return null;
const retryVariants = parseRetryVariants(row.retry_variants, row.content);
const retryActiveIndex = clampRetryActiveIndex(row.retry_active_index, retryVariants);
return {
id: row.id,
role: row.role,
content: row.content,
retryVariants,
retryActiveIndex,
retryRetriesUsed: Number(row.retry_retries_used || 0),
retryPromptMessageId: row.retry_prompt_message_id ?? null
};
};
const persistChatMessageRetryState = ({chatId, messageId, content, retryVariants, retryActiveIndex, retryRetriesUsed, retryPromptMessageId}) => {
return updateChatMessageWithRetryStateStmt.run(
content,
JSON.stringify(retryVariants || [content || ""]),
clampRetryActiveIndex(retryActiveIndex, retryVariants || [content || ""]),
Number(retryRetriesUsed || 0),
retryPromptMessageId ?? null,
messageId,
chatId
);
};
const insertChatMessage = (chatId, role, content, retryState = null) => {
const normalizedContent = String(content || "");
const payload = retryState
? {
retryVariants: Array.isArray(retryState.retryVariants) && retryState.retryVariants.length ? retryState.retryVariants : [normalizedContent],
retryActiveIndex: retryState.retryActiveIndex ?? 0,
retryRetriesUsed: retryState.retryRetriesUsed ?? 0,
retryPromptMessageId: retryState.retryPromptMessageId ?? null
}
: null;
return insertChatMessageStmt.run(
chatId,
role,
normalizedContent,
payload ? JSON.stringify(payload.retryVariants) : null,
payload ? clampRetryActiveIndex(payload.retryActiveIndex, payload.retryVariants) : 0,
payload ? Number(payload.retryRetriesUsed || 0) : 0,
payload ? payload.retryPromptMessageId : null
);
};
const getChatMessageByIndex = (chatId, index) => {
const safeIndex = Number(index);
if (!Number.isInteger(safeIndex) || safeIndex < 0) return null;
@@ -1385,7 +1559,7 @@ app.get("/chats/:id/messages", requireLogin, (req, res) => {
if (!session) {
return res.status(404).json({error: "Chat not found"});
}
const messages = getChatMessagesStmt.all(chatId);
const messages = getChatMessagesStmt.all(chatId).map(formatChatMessage);
res.json({messages, chat: session});
});
@@ -1401,12 +1575,31 @@ app.put("/chats/:id/messages/:messageId", requireLogin, (req, res) => {
if (!content) {
return res.status(400).json({error: "Message content is required"});
}
const result = updateChatMessageStmt.run(content, messageId, chatId);
if (result.changes === 0) {
const existingMessage = formatChatMessage(getChatMessageByIdStmt.get(messageId, chatId));
if (!existingMessage) {
return res.status(404).json({error: "Message not found"});
}
const requestedRetryActiveIndex = Number(req.body?.retryActiveIndex);
if (existingMessage.role === "bot") {
const retryVariants = [...existingMessage.retryVariants];
const nextActiveIndex = Number.isInteger(requestedRetryActiveIndex)
? clampRetryActiveIndex(requestedRetryActiveIndex, retryVariants)
: existingMessage.retryActiveIndex;
retryVariants[nextActiveIndex] = content;
persistChatMessageRetryState({
chatId,
messageId,
content,
retryVariants,
retryActiveIndex: nextActiveIndex,
retryRetriesUsed: existingMessage.retryRetriesUsed,
retryPromptMessageId: existingMessage.retryPromptMessageId
});
} else {
updateChatMessageStmt.run(content, messageId, chatId);
}
touchChatSessionStmt.run(chatId, user);
const message = getChatMessageByIdStmt.get(messageId, chatId);
const message = formatChatMessage(getChatMessageByIdStmt.get(messageId, chatId));
res.json({message});
});
@@ -1425,9 +1618,31 @@ app.put("/chats/:id/messages/by-index/:index", requireLogin, (req, res) => {
if (!target) {
return res.status(404).json({error: "Message not found"});
}
updateChatMessageStmt.run(content, target.id, chatId);
const existingMessage = formatChatMessage(getChatMessageByIdStmt.get(target.id, chatId));
if (!existingMessage) {
return res.status(404).json({error: "Message not found"});
}
const requestedRetryActiveIndex = Number(req.body?.retryActiveIndex);
if (existingMessage.role === "bot") {
const retryVariants = [...existingMessage.retryVariants];
const nextActiveIndex = Number.isInteger(requestedRetryActiveIndex)
? clampRetryActiveIndex(requestedRetryActiveIndex, retryVariants)
: existingMessage.retryActiveIndex;
retryVariants[nextActiveIndex] = content;
persistChatMessageRetryState({
chatId,
messageId: target.id,
content,
retryVariants,
retryActiveIndex: nextActiveIndex,
retryRetriesUsed: existingMessage.retryRetriesUsed,
retryPromptMessageId: existingMessage.retryPromptMessageId
});
} else {
updateChatMessageStmt.run(content, target.id, chatId);
}
touchChatSessionStmt.run(chatId, user);
const message = getChatMessageByIdStmt.get(target.id, chatId);
const message = formatChatMessage(getChatMessageByIdStmt.get(target.id, chatId));
res.json({message});
});
@@ -1769,7 +1984,12 @@ app.post("/personas/market/:id/chat", requireLogin, async (req, res) => {
if (!opener || containsUserVoiceInRoleplayOpener(opener, userPersona)) {
return res.json({persona, chat, existing: false, generatedInitialMessage: false});
}
insertChatMessageStmt.run(chat.id, "bot", opener);
insertChatMessage(chat.id, "bot", opener, {
retryVariants: [opener],
retryActiveIndex: 0,
retryRetriesUsed: 0,
retryPromptMessageId: null
});
touchChatSessionStmt.run(chat.id, user);
return res.json({persona, chat: getChatSessionStmt.get(chat.id, user), existing: false, generatedInitialMessage: true, opener});
} catch {
@@ -1830,7 +2050,12 @@ app.post("/roleplays/start", requireLogin, async (req, res) => {
if (!opener || containsUserVoiceInRoleplayOpener(opener, userPersona)) {
return res.status(500).json({error: "Failed to generate the character opener"});
}
insertChatMessageStmt.run(chat.id, "bot", opener);
insertChatMessage(chat.id, "bot", opener, {
retryVariants: [opener],
retryActiveIndex: 0,
retryRetriesUsed: 0,
retryPromptMessageId: null
});
touchChatSessionStmt.run(chat.id, user);
return res.json({chat: getChatSessionStmt.get(chat.id, user), existing: false, generatedInitialMessage: true, opener});
} catch {
@@ -1861,7 +2086,7 @@ app.post("/chat", requireLogin, async (req, res) => {
const history = getRecentChatMessagesStmt.all(targetChatId);
const conversation = [...history].reverse();
conversation.push({role: "user", content: message});
insertChatMessageStmt.run(targetChatId, "user", message);
insertChatMessage(targetChatId, "user", message);
touchChatSessionStmt.run(targetChatId, user);
const messagesPayload = [];
if (activePersona) {
@@ -1894,7 +2119,12 @@ app.post("/chat", requireLogin, async (req, res) => {
}))
);
const fullReply = await generateModelReply(selectedModel, messagesPayload);
insertChatMessageStmt.run(targetChatId, "bot", fullReply);
insertChatMessage(targetChatId, "bot", fullReply, {
retryVariants: [fullReply],
retryActiveIndex: 0,
retryRetriesUsed: 0,
retryPromptMessageId: null
});
touchChatSessionStmt.run(targetChatId, user);
console.log("Reply length:", fullReply.length);
res.json({reply: fullReply});
@@ -1922,55 +2152,27 @@ app.post("/chats/:id/messages/:messageId/retry", requireLogin, async (req, res)
if (!latestMessage || latestMessage.id !== messageId || latestMessage.role !== "bot") {
return res.status(400).json({error: "Only the newest assistant message can be retried"});
}
const promptMessage = getPreviousUserMessageStmt.get(chatId, messageId);
if (!promptMessage) {
return res.status(400).json({error: "No previous user prompt found for retry"});
}
try {
const activePersona = getAssistantPersonaForChatStmt.get(chatId, user);
const activeUserPersona = getUserPersonaForChatStmt.get(chatId, user) || getActiveUserPersonaStmt.get(user);
const sceneSummary = session.scenario_summary;
const scenarioPrompt = session.scenario_prompt;
const conversation = getRecentChatMessagesUpToIdStmt.all(chatId, promptMessage.id).reverse();
const messagesPayload = [];
if (activePersona) {
messagesPayload.push({
role: "system",
content: buildPersonaPrompt(activePersona)
});
const retryResult = await buildRetryPayload({chatId, user, session, selectedModel, targetMessage});
if (retryResult.error) {
return res.status(400).json({error: retryResult.error});
}
if (activeUserPersona) {
messagesPayload.push({
role: "system",
content: buildUserPersonaPrompt(activeUserPersona)
});
}
if (activePersona && sceneSummary) {
messagesPayload.push({
role: "system",
content: buildRoleplayDirectionPrompt({
assistantPersona: activePersona,
userPersona: activeUserPersona,
scenarioPrompt,
sceneSummary
})
});
}
messagesPayload.push(
...conversation.map((m) => ({
role: m.role === "bot" ? "assistant" : "user",
content: m.content
}))
);
const fullReply = await generateModelReply(selectedModel, messagesPayload);
if (!fullReply) {
return res.status(500).json({error: "Failed to regenerate message"});
}
updateChatMessageStmt.run(fullReply, messageId, chatId);
const {fullReply, promptMessageId} = retryResult;
const existingMessage = formatChatMessage(targetMessage);
const retryVariants = [...existingMessage.retryVariants, fullReply];
const retryActiveIndex = retryVariants.length - 1;
const retryRetriesUsed = existingMessage.retryRetriesUsed + 1;
persistChatMessageRetryState({
chatId,
messageId,
content: fullReply,
retryVariants,
retryActiveIndex,
retryRetriesUsed,
retryPromptMessageId: promptMessageId
});
touchChatSessionStmt.run(chatId, user);
return res.json({message: {id: messageId, role: "bot", content: fullReply}, promptMessageId: promptMessage.id});
return res.json({message: formatChatMessage(getChatMessageByIdStmt.get(messageId, chatId)), promptMessageId});
} catch (err) {
console.error("Retry error:", err);
return res.status(500).json({error: "Failed to regenerate message"});
@@ -1994,55 +2196,27 @@ app.post("/chats/:id/messages/by-index/:index/retry", requireLogin, async (req,
if (!latestMessage || latestMessage.id !== targetMessage.id || latestMessage.role !== "bot") {
return res.status(400).json({error: "Only the newest assistant message can be retried"});
}
const promptMessage = getPreviousUserMessageStmt.get(chatId, targetMessage.id);
if (!promptMessage) {
return res.status(400).json({error: "No previous user prompt found for retry"});
}
try {
const activePersona = getAssistantPersonaForChatStmt.get(chatId, user);
const activeUserPersona = getUserPersonaForChatStmt.get(chatId, user) || getActiveUserPersonaStmt.get(user);
const sceneSummary = session.scenario_summary;
const scenarioPrompt = session.scenario_prompt;
const conversation = getRecentChatMessagesUpToIdStmt.all(chatId, promptMessage.id).reverse();
const messagesPayload = [];
if (activePersona) {
messagesPayload.push({
role: "system",
content: buildPersonaPrompt(activePersona)
});
const retryResult = await buildRetryPayload({chatId, user, session, selectedModel, targetMessage});
if (retryResult.error) {
return res.status(400).json({error: retryResult.error});
}
if (activeUserPersona) {
messagesPayload.push({
role: "system",
content: buildUserPersonaPrompt(activeUserPersona)
});
}
if (activePersona && sceneSummary) {
messagesPayload.push({
role: "system",
content: buildRoleplayDirectionPrompt({
assistantPersona: activePersona,
userPersona: activeUserPersona,
scenarioPrompt,
sceneSummary
})
});
}
messagesPayload.push(
...conversation.map((m) => ({
role: m.role === "bot" ? "assistant" : "user",
content: m.content
}))
);
const fullReply = await generateModelReply(selectedModel, messagesPayload);
if (!fullReply) {
return res.status(500).json({error: "Failed to regenerate message"});
}
updateChatMessageStmt.run(fullReply, targetMessage.id, chatId);
const {fullReply, promptMessageId} = retryResult;
const existingMessage = formatChatMessage(targetMessage);
const retryVariants = [...existingMessage.retryVariants, fullReply];
const retryActiveIndex = retryVariants.length - 1;
const retryRetriesUsed = existingMessage.retryRetriesUsed + 1;
persistChatMessageRetryState({
chatId,
messageId: targetMessage.id,
content: fullReply,
retryVariants,
retryActiveIndex,
retryRetriesUsed,
retryPromptMessageId: promptMessageId
});
touchChatSessionStmt.run(chatId, user);
return res.json({message: {id: targetMessage.id, role: "bot", content: fullReply}, promptMessageId: promptMessage.id});
return res.json({message: formatChatMessage(getChatMessageByIdStmt.get(targetMessage.id, chatId)), promptMessageId});
} catch (err) {
console.error("Retry error:", err);
return res.status(500).json({error: "Failed to regenerate message"});