add generation cancellation middleware and refactor chat/message workflows to support HTTP client disconnections
This commit is contained in:
+59
-32
@@ -115,7 +115,10 @@ export function createChatService(repositories, modelService, config, webSearchP
|
||||
else externalSignal?.addEventListener("abort", abortFromCaller, { once: true });
|
||||
activeGenerations.set(chatId, controller);
|
||||
try {
|
||||
return await operation(controller.signal);
|
||||
controller.signal.throwIfAborted();
|
||||
const result = await operation(controller.signal);
|
||||
controller.signal.throwIfAborted();
|
||||
return result;
|
||||
} finally {
|
||||
if (activeGenerations.get(chatId) === controller) activeGenerations.delete(chatId);
|
||||
externalSignal?.removeEventListener("abort", abortFromCaller);
|
||||
@@ -238,6 +241,7 @@ export function createChatService(repositories, modelService, config, webSearchP
|
||||
? modelService.streamReply(selectedModel, messages, onChunk, { onThinking, signal, generation, reasoning })
|
||||
: modelService.generateReply(selectedModel, messages, { onThinking, signal, generation, reasoning });
|
||||
let opener = await generate(baseMessages);
|
||||
signal?.throwIfAborted();
|
||||
if (!containsUserVoiceInRoleplayOpener(opener, userPersona)) {
|
||||
return opener;
|
||||
}
|
||||
@@ -658,10 +662,31 @@ export function createChatService(repositories, modelService, config, webSearchP
|
||||
generateRoleplayOpener,
|
||||
insertChatMessage,
|
||||
containsUserVoiceInRoleplayOpener,
|
||||
async initializeRoleplay({user, chatId, assistantPersona, userPersona, scenarioPrompt, sceneSummary, selectedModel, signal = null}) {
|
||||
if (chatGenerationQueues.has(chatId)) return {error: "A response is already being generated", status: 409};
|
||||
return withChatGenerationLock(chatId, () => withActiveGeneration(chatId, signal, async activeSignal => {
|
||||
let chat = repositories.getChat(chatId, user);
|
||||
if (!chat) return {error: "Chat not found", status: 404};
|
||||
// Check inside the same lock that protects generation and persistence.
|
||||
if (repositories.listChatMessages(chatId).length) return {chat, existing: true, generatedInitialMessage: false};
|
||||
repositories.updateChatScene(chatId, user, scenarioPrompt, sceneSummary);
|
||||
chat = repositories.getChat(chatId, user);
|
||||
const {model, generation} = getGenerationSettings(chat, selectedModel);
|
||||
const opener = await generateRoleplayOpener({selectedModel: model, generation,
|
||||
assistantPersona, userPersona, scenarioPrompt, sceneSummary, signal: activeSignal});
|
||||
activeSignal.throwIfAborted();
|
||||
if (!opener || containsUserVoiceInRoleplayOpener(opener, userPersona)) {
|
||||
return {chat, existing: false, generatedInitialMessage: false, degraded: true};
|
||||
}
|
||||
insertChatMessage(chatId, "bot", opener, null, model);
|
||||
repositories.touchChat(chatId, user);
|
||||
return {chat: repositories.getChat(chatId, user), existing: false, generatedInitialMessage: true, opener};
|
||||
}));
|
||||
},
|
||||
persistChatMessageRetryState,
|
||||
buildMessagesPayload: buildConversationContext,
|
||||
async sendChatMessage({ user, chatId, message, model, attachments = [], webSearch = false, reasoning = false, requestId = null }) {
|
||||
return generateChatTurn({ user, chatId, message, model, attachments, webSearch, reasoning, requestId });
|
||||
async sendChatMessage({ user, chatId, message, model, attachments = [], webSearch = false, reasoning = false, signal = null, requestId = null }) {
|
||||
return generateChatTurn({ user, chatId, message, model, attachments, webSearch, reasoning, signal, requestId });
|
||||
},
|
||||
async streamChatMessage({ user, chatId, message, model, attachments = [], webSearch = false, reasoning = false, onStatus, onChunk, onThinking, signal = null, requestId = null }) {
|
||||
return generateChatTurn({ user, chatId, message, model, attachments, webSearch, reasoning, onStatus, onChunk, onThinking, signal, requestId });
|
||||
@@ -706,37 +731,39 @@ export function createChatService(repositories, modelService, config, webSearchP
|
||||
throw error;
|
||||
}).finally(() => regeneratingMessages.delete(chatId));
|
||||
},
|
||||
async editAndResendMessage({ user, chatId, targetMessage, content, model, webSearch = false, onChunk = null, signal = null, requestId = null }) {
|
||||
async editAndResendMessage({ user, chatId, targetMessage, content, model, webSearch = false, reasoning = false, onChunk = null, signal = null, requestId = null }) {
|
||||
if (!targetMessage || targetMessage.role !== "user") return { error: "Only user messages can be edited and resent", status: 400 };
|
||||
const sourceChat = repositories.getChat(chatId, user);
|
||||
if (!sourceChat) return { error: "Chat not found", status: 404 };
|
||||
const cloned = cloneChatThroughMessage({
|
||||
user,
|
||||
sourceChat,
|
||||
messageId: targetMessage.parent_message_id ?? null
|
||||
});
|
||||
if (cloned.error) return cloned;
|
||||
if (chatGenerationQueues.has(chatId)) return { error: "A response is already being generated", status: 409 };
|
||||
return withChatGenerationLock(chatId, () => withActiveGeneration(chatId, signal, async activeSignal => {
|
||||
const sourceChat = repositories.getChat(chatId, user);
|
||||
if (!sourceChat) return { error: "Chat not found", status: 404 };
|
||||
const freshTarget = repositories.getChatMessage(chatId, targetMessage.id);
|
||||
if (!freshTarget) return { error: "Message not found", status: 404 };
|
||||
const cloned = cloneChatThroughMessage({user, sourceChat, messageId: freshTarget.parent_message_id ?? null});
|
||||
if (cloned.error) return cloned;
|
||||
|
||||
const generated = await generateChatTurn({
|
||||
user,
|
||||
chatId: cloned.chat.id,
|
||||
attachments: attachmentService?.list(targetMessage.id).map(item => item.id) || [],
|
||||
reuseChatId: chatId,
|
||||
message: content,
|
||||
model,
|
||||
webSearch,
|
||||
onChunk,
|
||||
signal,
|
||||
requestId
|
||||
});
|
||||
if (generated.error) return { ...generated, chat: cloned.chat };
|
||||
return {
|
||||
...generated,
|
||||
chat: repositories.getChat(cloned.chat.id, user),
|
||||
messages: listActiveBranchMessages(cloned.chat.id),
|
||||
sourceChatId: chatId,
|
||||
branchedFromMessageId: targetMessage.id
|
||||
};
|
||||
const generated = await generateChatTurn({
|
||||
user,
|
||||
chatId: cloned.chat.id,
|
||||
attachments: attachmentService?.list(freshTarget.id).map(item => item.id) || [],
|
||||
reuseChatId: chatId,
|
||||
message: content,
|
||||
model,
|
||||
webSearch,
|
||||
reasoning,
|
||||
onChunk,
|
||||
signal: activeSignal,
|
||||
requestId
|
||||
});
|
||||
if (generated.error) return { ...generated, chat: cloned.chat };
|
||||
return {
|
||||
...generated,
|
||||
chat: repositories.getChat(cloned.chat.id, user),
|
||||
messages: listActiveBranchMessages(cloned.chat.id),
|
||||
sourceChatId: chatId,
|
||||
branchedFromMessageId: freshTarget.id
|
||||
};
|
||||
}));
|
||||
},
|
||||
createBranchedChat({ user, chatId, messageId }) {
|
||||
const sourceChat = repositories.getChat(chatId, user);
|
||||
|
||||
@@ -13,6 +13,14 @@ const optionalText = (value) => {
|
||||
return normalized || null;
|
||||
};
|
||||
const normalizeKey = (value) => normalizeText(value).toLowerCase();
|
||||
const optionalNumber = (value) => value == null || normalizeText(value) === "" || typeof value === "boolean"
|
||||
? null : Number.isFinite(Number(value)) ? Number(value) : null;
|
||||
const optionalInteger = (value) => Number.isSafeInteger(optionalNumber(value)) ? Number(value) : null;
|
||||
const sourceId = (value) => optionalInteger(value) > 0 ? Number(value) : null;
|
||||
const personaReferences = (record) => ({
|
||||
assistant_persona_id: sourceId(record.assistant_persona_id ?? record.assistantPersonaId),
|
||||
user_persona_id: sourceId(record.user_persona_id ?? record.userPersonaId)
|
||||
});
|
||||
|
||||
function countBy(items, getKey) {
|
||||
const counts = new Map();
|
||||
@@ -40,6 +48,7 @@ function validatePersona(persona, index, errors) {
|
||||
if (!name) errors.push(`Persona ${index + 1} is missing a name.`);
|
||||
if (!VALID_PERSONA_TYPES.has(personaType)) errors.push(`Persona ${index + 1} has invalid persona_type.`);
|
||||
return {
|
||||
sourceId: sourceId(persona.id),
|
||||
name,
|
||||
pronouns: optionalText(persona.pronouns),
|
||||
appearance: optionalText(persona.appearance),
|
||||
@@ -60,6 +69,7 @@ function validateTemplate(template, index, errors) {
|
||||
if (!name) errors.push(`Template ${index + 1} is missing a name.`);
|
||||
if (!promptText) errors.push(`Template ${index + 1} is missing prompt_text.`);
|
||||
return {
|
||||
persona_id: sourceId(template.persona_id ?? template.personaId),
|
||||
name,
|
||||
description: optionalText(template.description),
|
||||
category: optionalText(template.category),
|
||||
@@ -84,7 +94,8 @@ function validateMessage(message, chatIndex, messageIndex, errors) {
|
||||
? message.retry_variants.map((item) => String(item ?? "")).filter(Boolean)
|
||||
: [content].filter(Boolean);
|
||||
return {
|
||||
sourceId: Number.isInteger(Number(message.id)) ? Number(message.id) : null,
|
||||
// Synthetic IDs keep ID-less legacy messages addressable within this chat.
|
||||
sourceId: sourceId(message.id) ?? -(messageIndex + 1),
|
||||
role: role === "assistant" ? "bot" : role,
|
||||
content,
|
||||
model_name: optionalText(message.model_name || message.modelName),
|
||||
@@ -102,12 +113,8 @@ function validateMessage(message, chatIndex, messageIndex, errors) {
|
||||
retryRetriesUsed: Number.isInteger(Number(message.retryRetriesUsed ?? message.retry_retries_used))
|
||||
? Number(message.retryRetriesUsed ?? message.retry_retries_used)
|
||||
: 0,
|
||||
retryPromptMessageId: Number.isInteger(Number(message.retryPromptMessageId ?? message.retry_prompt_message_id))
|
||||
? Number(message.retryPromptMessageId ?? message.retry_prompt_message_id)
|
||||
: null,
|
||||
parentMessageId: Number.isInteger(Number(message.parentMessageId ?? message.parent_message_id))
|
||||
? Number(message.parentMessageId ?? message.parent_message_id)
|
||||
: null,
|
||||
retryPromptMessageId: sourceId(message.retryPromptMessageId ?? message.retry_prompt_message_id),
|
||||
parentMessageId: sourceId(message.parentMessageId ?? message.parent_message_id),
|
||||
deliveryStatus: ["pending", "complete", "failed"].includes(message.deliveryStatus ?? message.delivery_status)
|
||||
? (message.deliveryStatus ?? message.delivery_status)
|
||||
: "complete",
|
||||
@@ -127,7 +134,7 @@ function validateMemory(memory, chatIndex, memoryIndex, errors) {
|
||||
}
|
||||
const fact = normalizeText(memory.fact);
|
||||
if (!fact) errors.push(`Chat ${chatIndex + 1}, memory ${memoryIndex + 1} is missing fact.`);
|
||||
return {fact};
|
||||
return {fact, ...personaReferences(memory)};
|
||||
}
|
||||
|
||||
function validatePersonaState(state, chatIndex, errors) {
|
||||
@@ -137,6 +144,7 @@ function validatePersonaState(state, chatIndex, errors) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
...personaReferences(state),
|
||||
relationship_notes: optionalText(state.relationship_notes || state.relationshipNotes),
|
||||
tone: optionalText(state.tone),
|
||||
current_location: optionalText(state.current_location || state.currentLocation),
|
||||
@@ -153,9 +161,39 @@ function validateChat(chat, index, errors) {
|
||||
}
|
||||
const title = normalizeText(chat.stored_title || chat.title || "Imported chat") || "Imported chat";
|
||||
const messages = asArray(chat.messages).map((message, messageIndex) => validateMessage(message, index, messageIndex, errors)).filter(Boolean);
|
||||
const legacyChain = asArray(chat.messages).every(message => message &&
|
||||
!Object.hasOwn(message, "parent_message_id") && !Object.hasOwn(message, "parentMessageId"));
|
||||
if (legacyChain) {
|
||||
for (const [messageIndex, message] of messages.entries()) {
|
||||
message.parentMessageId = messages[messageIndex - 1]?.sourceId ?? null;
|
||||
}
|
||||
}
|
||||
for (const id of duplicateNames(messages, message => message.sourceId)) {
|
||||
errors.push(`Chat ${index + 1} has duplicate message ID: ${id}`);
|
||||
}
|
||||
// Two-pass persistence accepts forward references, but recursive history must stay acyclic.
|
||||
const byId = new Map(messages.map(message => [message.sourceId, message]));
|
||||
const visited = new Set();
|
||||
for (const message of messages) {
|
||||
const chain = new Set();
|
||||
let current = message;
|
||||
while (current && !visited.has(current.sourceId)) {
|
||||
if (chain.has(current.sourceId)) {
|
||||
errors.push(`Chat ${index + 1} has a cyclic message chain.`);
|
||||
break;
|
||||
}
|
||||
chain.add(current.sourceId);
|
||||
current = byId.get(current.parentMessageId);
|
||||
}
|
||||
for (const id of chain) visited.add(id);
|
||||
}
|
||||
const memories = asArray(chat.memories).map((memory, memoryIndex) => validateMemory(memory, index, memoryIndex, errors)).filter(Boolean);
|
||||
const personaState = validatePersonaState(chat.persona_state || chat.personaChatState, index, errors);
|
||||
return {
|
||||
sourceId: sourceId(chat.id),
|
||||
...personaReferences(chat),
|
||||
source_chat_id: sourceId(chat.source_chat_id ?? chat.sourceChatId),
|
||||
branched_from_message_id: sourceId(chat.branched_from_message_id ?? chat.branchedFromMessageId),
|
||||
title,
|
||||
stored_title: title,
|
||||
folder_name: optionalText(chat.folder_name || chat.folderName),
|
||||
@@ -168,13 +206,11 @@ function validateChat(chat, index, errors) {
|
||||
? Number(chat.context_summary_message_id || chat.contextSummaryMessageId)
|
||||
: 0,
|
||||
preferred_model: optionalText(chat.preferred_model || chat.preferredModel),
|
||||
temperature: Number.isFinite(Number(chat.temperature)) ? Number(chat.temperature) : null,
|
||||
context_length: Number.isInteger(Number(chat.context_length ?? chat.contextLength)) ? Number(chat.context_length ?? chat.contextLength) : null,
|
||||
response_length: Number.isInteger(Number(chat.response_length ?? chat.responseLength)) ? Number(chat.response_length ?? chat.responseLength) : null,
|
||||
temperature: optionalNumber(chat.temperature),
|
||||
context_length: optionalInteger(chat.context_length ?? chat.contextLength),
|
||||
response_length: optionalInteger(chat.response_length ?? chat.responseLength),
|
||||
system_instruction: optionalText(chat.system_instruction || chat.systemInstruction),
|
||||
active_leaf_message_id: Number.isInteger(Number(chat.active_leaf_message_id ?? chat.activeLeafMessageId))
|
||||
? Number(chat.active_leaf_message_id ?? chat.activeLeafMessageId)
|
||||
: null,
|
||||
active_leaf_message_id: sourceId(chat.active_leaf_message_id ?? chat.activeLeafMessageId),
|
||||
messages,
|
||||
memories,
|
||||
persona_state: personaState
|
||||
@@ -206,6 +242,11 @@ export function validateWorkspaceImport(workspace, existingWorkspace = null) {
|
||||
chats: asArray(workspace.chats).map((chat, index) => validateChat(chat, index, errors)).filter(Boolean),
|
||||
templates: asArray(workspace.templates).map((template, index) => validateTemplate(template, index, errors)).filter(Boolean)
|
||||
};
|
||||
for (const field of ["personas", "chats"]) {
|
||||
for (const id of duplicateNames(sanitized[field], record => record.sourceId)) {
|
||||
errors.push(`Duplicate ${field} ID: ${id}`);
|
||||
}
|
||||
}
|
||||
|
||||
const duplicates = [
|
||||
...duplicateNames(sanitized.personas, (persona) => normalizeKey(`${persona.persona_type}:${persona.name}`)).map((name) => `Duplicate persona in import: ${name}`),
|
||||
|
||||
Reference in New Issue
Block a user