add generation cancellation middleware and refactor chat/message workflows to support HTTP client disconnections
CI / check (22) (push) Failing after 4m54s
CI / check (24) (push) Failing after 4m54s

This commit is contained in:
2026-10-05 23:21:42 +02:00
parent c48e1b17c4
commit 1c0f06716e
17 changed files with 620 additions and 172 deletions
+1 -1
View File
@@ -321,7 +321,7 @@ export function createApp(options = {}) {
if (config.visionDebug) app.use(createVisionDebugRouter({visionDebugService, repositories}));
app.use(createSettingsRouter({repositories}));
app.use(createChatsRouter({repositories, chatService, modelService, config, chatRateLimiters, attachmentService}));
app.use(createPersonasRouter({repositories, chatService, modelService, config}));
app.use(createPersonasRouter({repositories, chatService, modelService, config, chatRateLimiters}));
app.use(createSystemRouter({modelService, config}));
app.use(notFoundHandler);
app.use(errorHandler);
+34 -11
View File
@@ -201,6 +201,7 @@ export function createRepositories(db, config) {
updateChatMessageDelivery: db.prepare("UPDATE chat_messages SET delivery_status = ?, error_message = ? WHERE id = ? AND chat_id = ?"),
updateChatMessageSources: db.prepare("UPDATE chat_messages SET sources = ? WHERE id = ? AND chat_id = ?"),
updateChatMessageThinking: db.prepare("UPDATE chat_messages SET thinking = ? WHERE id = ? AND chat_id = ?"),
updateChatMessageLinks: db.prepare("UPDATE chat_messages SET parent_message_id = ?, retry_prompt_message_id = ? WHERE id = ? AND chat_id = ?"),
getChatMessages: db.prepare(`
SELECT thinking, sources, id, role, content, model_name, retry_variants, retry_active_index, retry_retries_used, retry_prompt_message_id, delivery_status, error_message, parent_message_id, created_at
FROM chat_messages
@@ -821,6 +822,8 @@ export function createRepositories(db, config) {
const importWorkspaceTransaction = db.transaction((username, workspace) => {
const imported = {personas: 0, chats: 0, templates: 0};
const personaIds = new Map();
const importedChats = [];
for (const persona of workspace.personas || []) {
const personaId = statements.insertPersona.run(
username,
@@ -832,14 +835,15 @@ export function createRepositories(db, config) {
persona.example_dialogues ?? null,
persona.persona_type || "assistant"
).lastInsertRowid;
statements.insertPersonaVersion.run(personaId, username, 1, JSON.stringify(persona));
if (persona.sourceId != null) personaIds.set(persona.sourceId, Number(personaId));
statements.insertPersonaVersion.run(personaId, username, 1, buildPersonaSnapshot(statements.getPersona.get(personaId, username)));
imported.personas += 1;
}
for (const template of workspace.templates || []) {
statements.insertPromptTemplate.run(
username,
null,
personaIds.get(template.persona_id) ?? null,
template.name,
template.description ?? null,
template.category ?? null,
@@ -851,11 +855,13 @@ export function createRepositories(db, config) {
}
for (const chat of workspace.chats || []) {
const assistantPersonaId = personaIds.get(chat.assistant_persona_id) ?? null;
const userPersonaId = personaIds.get(chat.user_persona_id) ?? null;
const chatId = statements.insertChatSession.run(
username,
chat.stored_title || chat.title || "Imported chat",
null,
null,
assistantPersonaId,
userPersonaId,
chat.folder_name ?? null,
chat.is_pinned ? 1 : 0,
chat.archived_at ?? null,
@@ -863,9 +869,8 @@ export function createRepositories(db, config) {
chat.scenario_summary ?? null
).lastInsertRowid;
const importedMessageIds = new Map();
importedChats.push({chat, chatId, messageIds: importedMessageIds});
for (const message of chat.messages || []) {
const mappedRetryPromptId = importedMessageIds.get(message.retryPromptMessageId) ?? null;
const mappedParentId = importedMessageIds.get(message.parentMessageId) ?? null;
const result = statements.insertChatMessage.run(
chatId,
message.role,
@@ -874,10 +879,10 @@ export function createRepositories(db, config) {
JSON.stringify(message.retryVariants || [message.content || ""]),
Number(message.retryActiveIndex || 0),
Number(message.retryRetriesUsed || 0),
mappedRetryPromptId,
null,
message.deliveryStatus || "complete",
message.errorMessage || null,
mappedParentId
null
);
if (message.sourceId != null) importedMessageIds.set(message.sourceId, Number(result.lastInsertRowid));
if (message.sources) statements.updateChatMessageSources.run(JSON.stringify(message.sources), result.lastInsertRowid, chatId);
@@ -893,6 +898,15 @@ export function createRepositories(db, config) {
db.prepare("UPDATE attachments SET status='attached' WHERE id=? AND username=?").run(attachment.id, username);
}
}
// Resolve links after every message has an ID, including forward references.
for (const message of chat.messages || []) {
statements.updateChatMessageLinks.run(
importedMessageIds.get(message.parentMessageId) ?? null,
importedMessageIds.get(message.retryPromptMessageId) ?? null,
importedMessageIds.get(message.sourceId),
chatId
);
}
statements.updateChatGenerationSettings.run(
chat.preferred_model ?? null,
chat.temperature ?? null,
@@ -918,15 +932,17 @@ export function createRepositories(db, config) {
for (const memory of chat.memories || []) {
const fact = String(memory?.fact || "").trim();
if (!fact) continue;
statements.insertChatMemory.run(username, chatId, null, null, fact);
statements.insertChatMemory.run(username, chatId,
memory.assistant_persona_id === undefined ? assistantPersonaId : personaIds.get(memory.assistant_persona_id) ?? null,
memory.user_persona_id === undefined ? userPersonaId : personaIds.get(memory.user_persona_id) ?? null, fact);
}
const personaState = chat.persona_state || chat.personaChatState;
if (personaState) {
statements.upsertPersonaChatState.run(
username,
chatId,
null,
null,
personaIds.get(personaState.assistant_persona_id) ?? null,
personaIds.get(personaState.user_persona_id) ?? null,
personaState.relationship_notes ?? personaState.relationshipNotes ?? null,
personaState.tone ?? null,
personaState.current_location ?? personaState.currentLocation ?? null,
@@ -938,6 +954,13 @@ export function createRepositories(db, config) {
imported.chats += 1;
}
const chatsBySourceId = new Map(importedChats.filter(({chat}) => chat.sourceId != null).map(entry => [entry.chat.sourceId, entry]));
for (const {chat, chatId} of importedChats) {
const sourceChat = chatsBySourceId.get(chat.source_chat_id);
statements.updateChatBranchSource.run(sourceChat?.chatId ?? null,
sourceChat?.messageIds.get(chat.branched_from_message_id) ?? null, chatId, username);
}
return imported;
});
+13
View File
@@ -0,0 +1,13 @@
/** Cancel generation when its HTTP client disconnects, for JSON and streamed replies. */
export function generationCancellation(req, res, next) {
const controller = new AbortController();
const onClose = () => {
if (!res.writableEnded) controller.abort();
res.removeListener("finish", onFinish);
};
const onFinish = () => res.removeListener("close", onClose);
res.once("close", onClose);
res.once("finish", onFinish);
req.generationSignal = controller.signal;
next();
}
+11 -17
View File
@@ -4,6 +4,7 @@
import express from "express";
import {createTextStreamEvents} from "../services/streamEvents.js";
import {asyncHandler} from "../middleware/asyncHandler.js";
import {generationCancellation} from "../middleware/generationCancellation.js";
import multer from "multer";
import {requireLogin} from "../middleware/auth.js";
import {validateBody} from "../middleware/validate.js";
@@ -264,7 +265,7 @@ export function createChatsRouter({repositories, chatService, modelService, conf
return res.json(result);
});
router.post("/chats/:id/messages/:messageId/edit-resend", validateBody(sendMessageValidator), asyncHandler(async (req, res) => {
router.post("/chats/:id/messages/:messageId/edit-resend", ...chatRateLimiters, generationCancellation, validateBody(sendMessageValidator), asyncHandler(async (req, res) => {
if (req.body?.confirmed !== true) return res.status(400).json({error: "Confirmation is required before creating an edited branch"});
const chatId = Number(req.params.id);
const targetMessage = repositories.getChatMessage(chatId, Number(req.params.messageId));
@@ -272,6 +273,7 @@ export function createChatsRouter({repositories, chatService, modelService, conf
if (!targetMessage) return res.status(404).json({error: "Message not found"});
try {
const result = await chatService.editAndResendMessage({
signal: req.generationSignal,
user: req.session.user,
requestId: req.id,
chatId,
@@ -448,9 +450,10 @@ export function createChatsRouter({repositories, chatService, modelService, conf
return res.json({state});
});
router.post("/chat", ...chatRateLimiters, validateBody(sendMessageValidator), asyncHandler(async (req, res) => {
router.post("/chat", ...chatRateLimiters, generationCancellation, validateBody(sendMessageValidator), asyncHandler(async (req, res) => {
try {
const result = await chatService.sendChatMessage({
signal: req.generationSignal,
user: req.session.user,
requestId: req.id,
chatId: req.validatedBody.chatId,
@@ -468,12 +471,7 @@ export function createChatsRouter({repositories, chatService, modelService, conf
}
}));
router.post("/chat/stream", ...chatRateLimiters, validateBody(sendMessageValidator), asyncHandler(async (req, res) => {
const generationController = new AbortController();
let generationFinished = false;
res.once("close", () => {
if (!generationFinished) generationController.abort();
});
router.post("/chat/stream", ...chatRateLimiters, generationCancellation, validateBody(sendMessageValidator), asyncHandler(async (req, res) => {
res.writeHead(200, {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache, no-transform",
@@ -499,7 +497,7 @@ export function createChatsRouter({repositories, chatService, modelService, conf
webSearch: req.validatedBody.webSearch,
reasoning: req.validatedBody.reasoning,
onStatus: (status) => sendEvent("status", status),
signal: generationController.signal
signal: req.generationSignal
});
if (result.error) {
sendEvent("error", {error: result.error, status: result.status, requestId: req.id, ...(result.searchState ? {searchState: result.searchState} : {})});
@@ -510,7 +508,6 @@ export function createChatsRouter({repositories, chatService, modelService, conf
const mapped = modelService.mapError(error);
sendEvent("error", {...mapped.body, status: mapped.status, requestId: req.id});
} finally {
generationFinished = true;
res.end();
}
}));
@@ -529,7 +526,7 @@ export function createChatsRouter({repositories, chatService, modelService, conf
res.json({message: chatService.formatChatMessage(repositories.getChatMessage(chatId, messageId))});
});
router.post(["/chats/:id/messages/:messageId/retry", "/chats/:id/messages/:messageId/retry/stream"], ...chatRateLimiters, asyncHandler(async (req, res) => {
router.post(["/chats/:id/messages/:messageId/retry", "/chats/:id/messages/:messageId/retry/stream"], ...chatRateLimiters, generationCancellation, asyncHandler(async (req, res) => {
const chatId = Number(req.params.id);
const messageId = Number(req.params.messageId);
const chat = repositories.getChat(chatId, req.session.user);
@@ -539,13 +536,10 @@ export function createChatsRouter({repositories, chatService, modelService, conf
if (targetMessage.role !== "bot") return res.status(400).json({error: "Only assistant messages can be regenerated"});
const streaming = req.path.endsWith("/stream");
const controller = new AbortController();
let finished = false;
const sendEvent = (event, data) => {
if (!res.destroyed) res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
};
if (streaming) {
res.once("close", () => { if (!finished) controller.abort(); });
res.writeHead(200, {"Content-Type": "text/event-stream", "Cache-Control": "no-cache, no-transform", Connection: "keep-alive", "X-Accel-Buffering": "no"});
}
try {
@@ -558,7 +552,7 @@ export function createChatsRouter({repositories, chatService, modelService, conf
retryStyle: String(req.body?.style || "").trim() || null,
webSearch: req.body?.webSearch === true,
reasoning: req.body?.reasoning === true,
signal: controller.signal,
signal: req.generationSignal,
...(streaming ? {
...createTextStreamEvents(sendEvent),
onStatus: status => sendEvent("status", status)
@@ -575,12 +569,11 @@ export function createChatsRouter({repositories, chatService, modelService, conf
if (streaming) { sendEvent("error", {...mapped.body, status: mapped.status, requestId: req.id}); return; }
return res.status(mapped.status).json(mapped.body);
} finally {
finished = true;
if (streaming) res.end();
}
}));
router.post("/chats/:id/messages/by-index/:index/retry", ...chatRateLimiters, asyncHandler(async (req, res) => {
router.post("/chats/:id/messages/by-index/:index/retry", ...chatRateLimiters, generationCancellation, asyncHandler(async (req, res) => {
const chatId = Number(req.params.id);
const chat = repositories.getChat(chatId, req.session.user);
if (!chat) return res.status(404).json({error: "Chat not found"});
@@ -590,6 +583,7 @@ export function createChatsRouter({repositories, chatService, modelService, conf
try {
const result = await chatService.retryMessage({
signal: req.generationSignal,
user: req.session.user,
requestId: req.id,
chatId,
+12 -46
View File
@@ -3,6 +3,7 @@
*/
import express from "express";
import {asyncHandler} from "../middleware/asyncHandler.js";
import {generationCancellation} from "../middleware/generationCancellation.js";
import {requireLogin} from "../middleware/auth.js";
import {validateBody} from "../middleware/validate.js";
import {normalizeOptionalText, validatePersonaPayload} from "../services/personaService.js";
@@ -47,7 +48,7 @@ const templateValidator = (body) => {
};
};
export function createPersonasRouter({repositories, chatService, modelService, config}) {
export function createPersonasRouter({repositories, chatService, modelService, config, chatRateLimiters}) {
const router = express.Router();
router.use(requireLogin);
const isAdmin = (req) => req.account?.role === "admin";
@@ -215,7 +216,7 @@ export function createPersonasRouter({repositories, chatService, modelService, c
return res.json({message: "Report submitted"});
});
router.post("/personas/market/:id/chat", asyncHandler(async (req, res) => {
router.post("/personas/market/:id/chat", ...chatRateLimiters, generationCancellation, asyncHandler(async (req, res) => {
const user = req.session.user;
const marketPersona = repositories.getMarketPersona(Number(req.params.id));
const userPersonaId = req.body?.userPersonaId ? Number(req.body.userPersonaId) : null;
@@ -251,40 +252,23 @@ export function createPersonasRouter({repositories, chatService, modelService, c
throw error;
}
if (repositories.listChatMessages(chat.id).length > 0) {
return res.json({persona, chat, existing: true, generatedInitialMessage: false});
}
repositories.updateChatScene(chat.id, user, scenarioPrompt, sceneSummary);
chat = repositories.getChat(chat.id, user);
try {
let thinking = "";
const opener = await chatService.generateRoleplayOpener({
onThinking: (_chunk, fullThinking) => { thinking = fullThinking; },
const result = await chatService.initializeRoleplay({
user, chatId: chat.id, signal: req.generationSignal,
selectedModel: "qwen3:14b",
assistantPersona: persona,
userPersona,
scenarioPrompt,
sceneSummary
});
if (!opener || chatService.containsUserVoiceInRoleplayOpener(opener, userPersona)) {
return res.json({persona, chat, existing: false, generatedInitialMessage: false});
}
const inserted = chatService.insertChatMessage(chat.id, "bot", opener, {
retryVariants: [opener],
retryActiveIndex: 0,
retryRetriesUsed: 0,
retryPromptMessageId: null
});
if (thinking) repositories.updateChatMessageThinking(chat.id, Number(inserted.lastInsertRowid), thinking);
repositories.touchChat(chat.id, user);
return res.json({persona, chat: repositories.getChat(chat.id, user), existing: false, generatedInitialMessage: true, opener});
if (result.error) return res.status(result.status).json({error: result.error});
return res.json({persona, ...result});
} catch {
return res.json({persona, chat, existing: false, generatedInitialMessage: false, degraded: true});
}
}));
router.post("/roleplays/start", validateBody(roleplayStartValidator), asyncHandler(async (req, res) => {
router.post("/roleplays/start", ...chatRateLimiters, generationCancellation, validateBody(roleplayStartValidator), asyncHandler(async (req, res) => {
const user = req.session.user;
const {assistantPersonaId, userPersonaId, scenarioPrompt, model} = req.validatedBody;
const assistantPersona = repositories.getPersona(assistantPersonaId, user);
@@ -306,35 +290,17 @@ export function createPersonasRouter({repositories, chatService, modelService, c
throw error;
}
if (repositories.listChatMessages(chat.id).length > 0) {
return res.json({chat, existing: true, generatedInitialMessage: false});
}
repositories.updateChatScene(chat.id, user, scenarioPrompt, sceneSummary);
chat = repositories.getChat(chat.id, user);
try {
let thinking = "";
const opener = await chatService.generateRoleplayOpener({
onThinking: (_chunk, fullThinking) => { thinking = fullThinking; },
const result = await chatService.initializeRoleplay({
user, chatId: chat.id, signal: req.generationSignal,
selectedModel: model,
assistantPersona,
userPersona,
scenarioPrompt,
sceneSummary
});
if (!opener || chatService.containsUserVoiceInRoleplayOpener(opener, userPersona)) {
return res.json({chat, existing: false, generatedInitialMessage: false, degraded: true});
}
const inserted = chatService.insertChatMessage(chat.id, "bot", opener, {
retryVariants: [opener],
retryActiveIndex: 0,
retryRetriesUsed: 0,
retryPromptMessageId: null
});
if (thinking) repositories.updateChatMessageThinking(chat.id, Number(inserted.lastInsertRowid), thinking);
repositories.touchChat(chat.id, user);
return res.json({chat: repositories.getChat(chat.id, user), existing: false, generatedInitialMessage: true, opener});
if (result.error) return res.status(result.status).json({error: result.error});
return res.json(result);
} catch (error) {
const mapped = modelService.mapError(error);
return res.json({
+59 -32
View File
@@ -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);
+55 -14
View File
@@ -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}`),