add response versioning support in messages for regeneration improvements
This commit is contained in:
+71
-34
@@ -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};
|
||||
|
||||
@@ -86,6 +86,10 @@ function validateMessage(message, chatIndex, messageIndex, errors) {
|
||||
content,
|
||||
model_name: optionalText(message.model_name || message.modelName),
|
||||
thinking: optionalText(message.thinking),
|
||||
responseVersions: asArray(message.responseVersions).filter(version => version && typeof version.content === "string").slice(0, 100).map(version => ({
|
||||
content: version.content, thinking: optionalText(version.thinking), sources: parseSources(version.sources),
|
||||
modelName: optionalText(version.modelName), createdAt: optionalText(version.createdAt)
|
||||
})),
|
||||
attachments: asArray(message.attachments).filter(item => item && typeof item.id === "string" && /^[a-f0-9-]{36}$/.test(item.id)).slice(0, 100).map(item => ({id: item.id})),
|
||||
...(message.sources ? {sources: parseSources(message.sources)} : {}),
|
||||
retryVariants,
|
||||
|
||||
Reference in New Issue
Block a user