add response versioning support in messages for regeneration improvements

This commit is contained in:
2026-09-22 14:48:13 +02:00
parent 7602692341
commit 72e429b1b3
15 changed files with 591 additions and 86 deletions
+71 -34
View File
@@ -24,12 +24,22 @@ import {supportsReasoning} from "../../public/app/modelCapabilities.js";
export function createChatService(repositories, modelService, config, webSearchProvider = new SearXNGSearchProvider(config), attachmentService = null) {
const formatChatMessage = row => {
const message = formatBaseChatMessage(row);
if (message?.role === "bot") {
const versions = repositories.listMessageVersions(row.id);
message.versionCount = versions.length || 1;
message.versionIndex = versions.length ? Number(row.retry_active_index || 0) : 0;
if (versions.length) message.responseVersions = versions.map(version => ({
content: version.content, thinking: version.thinking,
sources: parseSources(version.sources), modelName: version.model_name, createdAt: version.created_at
}));
}
const attachments = row && attachmentService?.list(row.id);
return message && attachments?.length ? {...message, attachments} : message;
};
const chatGenerationQueues = new Map();
const summaryQueues = new Map();
const activeGenerations = new Map();
const regeneratingMessages = new Map();
const maxRetries = Number(config.chatMaxRetries ?? 5);
const now = typeof config.now === "function" ? config.now : () => new Date();
@@ -84,6 +94,8 @@ export function createChatService(repositories, modelService, config, webSearchP
});
const persistChatMessageRetryState = ({chatId, messageId, content, retryVariants, retryActiveIndex, retryRetriesUsed, retryPromptMessageId}) => {
const versioned = repositories.listMessageVersions(messageId).length > 0;
if (versioned) repositories.saveMessageVersion(chatId, messageId, null, retryActiveIndex);
repositories.updateChatMessageRetryState(chatId, messageId, {
content,
retryVariants: retryVariants || [content || ""],
@@ -91,6 +103,7 @@ export function createChatService(repositories, modelService, config, webSearchP
retryRetriesUsed: Number(retryRetriesUsed || 0),
retryPromptMessageId: retryPromptMessageId ?? null
});
if (versioned) repositories.saveMessageVersion(chatId, messageId, null, retryActiveIndex);
};
const getChatMessageByIndex = (chatId, index) => {
@@ -174,7 +187,7 @@ export function createChatService(repositories, modelService, config, webSearchP
return repositories.createChat(user, persona.name, persona.id, userPersonaId, scenarioPrompt, scenarioSummary);
};
const generateRoleplayOpener = async ({selectedModel = "qwen3:14b", assistantPersona, userPersona, scenarioPrompt, sceneSummary, onThinking = null}) => {
const generateRoleplayOpener = async ({selectedModel = "qwen3:14b", assistantPersona, userPersona, scenarioPrompt, sceneSummary, onThinking = null, onChunk = null, signal = null, generation = {}, reasoning = false}) => {
const baseMessages = [
{role: "system", content: buildPersonaPrompt(assistantPersona)},
...(userPersona ? [{role: "system", content: buildUserPersonaPrompt(userPersona)}] : []),
@@ -182,20 +195,24 @@ export function createChatService(repositories, modelService, config, webSearchP
{role: "user", content: buildRoleplayOpenerPrompt({assistantPersona, userPersona, scenarioPrompt, sceneSummary})}
];
let opener = await modelService.generateReply(selectedModel, baseMessages, {onThinking});
const generate = messages => onChunk
? modelService.streamReply(selectedModel, messages, onChunk, {onThinking, signal, generation, reasoning})
: modelService.generateReply(selectedModel, messages, {onThinking, signal, generation, reasoning});
let opener = await generate(baseMessages);
if (!containsUserVoiceInRoleplayOpener(opener, userPersona)) {
return opener;
}
onThinking?.("", "");
return modelService.generateReply(selectedModel, [
onChunk?.("", "");
return generate([
...baseMessages,
{role: "assistant", content: opener || ""},
{
role: "user",
content: "Rewrite this opening. Hard rule: only the assistant character may speak or act. Never write or imply user speech, thoughts, feelings, reactions, decisions, actions, or labels like 'User:'/'You:'. Stop before the user's reply."
}
], {onThinking});
]);
};
const generateValidatedRoleplayReply = async ({selectedModel, messagesPayload, userPersona, signal = null, generation = {}, onThinking = null, onChunk = null, reasoning = false}) => {
@@ -285,13 +302,20 @@ export function createChatService(repositories, modelService, config, webSearchP
return {session: activeSession, activePersona, activeUserPersona, messagesPayload};
};
const buildRetryPayload = async ({chatId, user, session, selectedModel, targetMessage, retryStyle = null, signal = null, generation = {}}) => {
const buildRetryPayload = async ({chatId, user, session, selectedModel, targetMessage, retryStyle = null, signal = null, generation = {}, webSearch = false, reasoning = false, onChunk = null, onThinking = null, onStatus = null}) => {
let thinking = "";
const captureThinking = (chunk, fullThinking) => {
if (!reasoning) return;
thinking = fullThinking;
onThinking?.(chunk, fullThinking);
};
onStatus?.({status: "accepted"});
const parentMessage = targetMessage.parent_message_id
? repositories.getChatMessage(chatId, targetMessage.parent_message_id)
: null;
const promptMessage = parentMessage?.role === "user"
? parentMessage
: repositories.getPreviousUserMessage(chatId, targetMessage.id);
: repositories.listChatBranchThroughMessage(chatId, targetMessage.id).slice(0, -1).reverse().find(entry => entry.role === "user");
const activePersona = repositories.getAssistantPersonaForChat(chatId, user);
const activeUserPersona = getUserPersonaForSession(chatId, user, session, activePersona);
@@ -299,9 +323,8 @@ export function createChatService(repositories, modelService, config, webSearchP
if (!activePersona || !session.scenario_summary) {
return {error: "No previous user prompt found for retry"};
}
let thinking = "";
const opener = await generateRoleplayOpener({
onThinking: (_chunk, fullThinking) => { thinking = fullThinking; },
onThinking: captureThinking, onChunk, signal, generation, reasoning,
selectedModel,
assistantPersona: activePersona,
userPersona: activeUserPersona,
@@ -314,25 +337,38 @@ export function createChatService(repositories, modelService, config, webSearchP
return {fullReply: opener, thinking, promptMessageId: null};
}
if (attachmentService?.list(promptMessage.id).length) onStatus?.({status: "analyzing"});
await attachmentService?.analyze(promptMessage.id, promptMessage.content, signal);
let sources = null;
let searchState = "skipped";
if (webSearch) {
const search = await retrieveWebSearch(promptMessage.content, webSearchProvider, {
signal,
referenceText: attachmentService?.searchContext(promptMessage.id) || "",
onSearching: () => onStatus?.({status: "searching"})
});
sources = search.sources;
searchState = search.state;
}
onStatus?.({status: "generating", searchState, sources});
const {messagesPayload} = buildConversationContext({
user,
chatId,
session,
message: null,
upToMessageId: promptMessage.id,
sources: targetMessage.sources ? parseSources(targetMessage.sources) : null
sources
});
const styledPayload = appendRetryStyleInstruction(messagesPayload, retryStyle);
let thinking = "";
const onThinking = (_chunk, fullThinking) => { thinking = fullThinking; };
const fullReply = activePersona
? await generateValidatedRoleplayReply({selectedModel, messagesPayload: styledPayload, userPersona: activeUserPersona, signal, generation, onThinking})
: await modelService.generateReply(selectedModel, styledPayload, {signal, generation, onThinking});
? await generateValidatedRoleplayReply({selectedModel, messagesPayload: styledPayload, userPersona: activeUserPersona, signal, generation, onThinking: captureThinking, onChunk, reasoning})
: onChunk
? await modelService.streamReply(selectedModel, styledPayload, onChunk, {signal, generation, onThinking: captureThinking, reasoning})
: await modelService.generateReply(selectedModel, styledPayload, {signal, generation, onThinking: captureThinking, reasoning});
if (!fullReply) return {error: "Failed to regenerate message"};
return {fullReply, thinking, promptMessageId: promptMessage.id};
return {fullReply, thinking, sources, searchState, promptMessageId: promptMessage.id};
};
const generateChatTurn = ({user, chatId, message, model, attachments = [], reuseChatId = null, webSearch = false, reasoning = false, onStatus = null, onChunk = null, onThinking = null, signal = null, parentMessageId = undefined}) =>
@@ -536,8 +572,13 @@ export function createChatService(repositories, modelService, config, webSearchP
async streamChatMessage({user, chatId, message, model, attachments = [], webSearch = false, reasoning = false, onStatus, onChunk, onThinking, signal = null}) {
return generateChatTurn({user, chatId, message, model, attachments, webSearch, reasoning, onStatus, onChunk, onThinking, signal});
},
async retryMessage({user, chatId, selectedModel, targetMessage, retryStyle = null}) {
isGenerating: (chatId) => chatGenerationQueues.has(chatId),
isRegeneratingMessage: (chatId, messageId) => regeneratingMessages.get(chatId) === messageId,
async retryMessage({user, chatId, selectedModel, targetMessage, retryStyle = null, webSearch = false, reasoning = false, onChunk = null, onThinking = null, onStatus = null, signal = null}) {
if (chatGenerationQueues.has(chatId)) return {error: "A response is already being generated", status: 409};
regeneratingMessages.set(chatId, targetMessage.id);
return withChatGenerationLock(chatId, async () => {
await (summaryQueues.get(chatId) || Promise.resolve());
const session = repositories.getChat(chatId, user);
if (!session) return {error: "Chat not found", status: 404};
@@ -545,35 +586,31 @@ export function createChatService(repositories, modelService, config, webSearchP
const existingMessage = formatChatMessage(freshTarget);
if (!existingMessage) return {error: "Message not found", status: 404};
if (existingMessage.role !== "bot") return {error: "Only assistant messages can be regenerated", status: 400};
const siblings = repositories.listSiblingChatMessages(chatId, "bot", freshTarget.parent_message_id);
if (siblings.length - 1 >= maxRetries) {
if (existingMessage.versionCount - 1 >= maxRetries) {
return {error: `Maximum of ${maxRetries} retries reached`, status: 400};
}
const {model, generation} = getGenerationSettings(session, selectedModel);
return withActiveGeneration(chatId, null, async (activeSignal) => {
return withActiveGeneration(chatId, signal, async (activeSignal) => {
activeSignal.throwIfAborted();
const retryResult = await buildRetryPayload({
chatId, user, session, selectedModel: model, targetMessage: freshTarget, retryStyle,
signal: activeSignal, generation
signal: activeSignal, generation, webSearch, reasoning: reasoning === true && supportsReasoning(model), onChunk, onThinking, onStatus
});
if (retryResult.error) return {error: retryResult.error, status: 400};
const retryVariants = [...siblings.map((sibling) => sibling.content), retryResult.fullReply];
const retryState = {
retryVariants,
retryActiveIndex: retryVariants.length - 1,
retryRetriesUsed: siblings.length,
retryPromptMessageId: retryResult.promptMessageId
};
const inserted = repositories.insertBranchChatMessage(
chatId, "bot", retryResult.fullReply, retryState, model, "complete", null, freshTarget.parent_message_id
);
if (freshTarget.sources) repositories.updateChatMessageSources(chatId, Number(inserted.lastInsertRowid), parseSources(freshTarget.sources));
if (retryResult.thinking) repositories.updateChatMessageThinking(chatId, Number(inserted.lastInsertRowid), retryResult.thinking);
activeSignal.throwIfAborted();
repositories.saveMessageVersion(chatId, freshTarget.id, {
content: retryResult.fullReply, thinking: retryResult.thinking,
sources: retryResult.sources, modelName: model
});
repositories.updateChatContextSummary(chatId, user, null, 0);
repositories.touchChat(chatId, user);
const message = formatChatMessage(repositories.getChatMessage(chatId, Number(inserted.lastInsertRowid)));
return {message, promptMessageId: retryResult.promptMessageId, retryStyle, branchedFromMessageId: freshTarget.id};
const message = formatChatMessage(repositories.getChatMessage(chatId, freshTarget.id));
return {message, promptMessageId: retryResult.promptMessageId, retryStyle};
});
});
}).catch(error => {
if (error instanceof WebSearchError) return {error: error.message, status: error.status, searchState: "failed"};
throw error;
}).finally(() => regeneratingMessages.delete(chatId));
},
async editAndResendMessage({user, chatId, targetMessage, content, model, webSearch = false, onChunk = null, signal = null}) {
if (!targetMessage || targetMessage.role !== "user") return {error: "Only user messages can be edited and resent", status: 400};