Main #15

Merged
Fretux merged 14 commits from main into dev 2026-10-12 08:59:14 +02:00
17 changed files with 620 additions and 172 deletions
Showing only changes of commit 1c0f06716e - Show all commits
+12 -1
View File
@@ -29,6 +29,7 @@ on Node 22 and 24.
- `npm run dev`: run the server with Node watch mode.
- `npm run lint`: syntax-check the backend and browser modules.
- `npm test`: run API tests with Node's built-in test runner.
- `npm run test:browser`: serve the navigation regression harness locally; open the printed URL in a browser. The same cases run with a lightweight DOM fixture in `npm test`.
- `npm run user:role -- <username> <user|admin>`: assign or revoke a role from the server console.
## Administrative access
@@ -75,7 +76,7 @@ Duplicate registrations, including concurrent inserts, return HTTP 409 with
- `MODEL_UNLOAD_AFTER_MS`: delay before idle local request bookkeeping is cleared. Model unloading is managed by Ollama through `OLLAMA_KEEP_ALIVE`.
- `MODELS_CACHE_TTL_MS`: model list cache TTL.
- `AUTH_RATE_LIMIT_*`: rate-limit window and thresholds for registration/login.
- `CHAT_RATE_LIMIT_*`: rate-limit window and thresholds for chat and retry endpoints.
- `CHAT_RATE_LIMIT_*`: shared rate-limit window and thresholds for chat, retry, roleplay openers, marketplace openers, and edit/resend endpoints.
- `RATE_LIMIT_CLEANUP_INTERVAL_MS`: interval for pruning stale in-memory rate-limit buckets.
- `DROP_LEGACY_CHATS`: when `true`, migrate any legacy `chats` table into `chat_sessions` / `chat_messages` and remove the old table.
@@ -107,6 +108,11 @@ accepts older JSON exports, which contain attachment references only and reconne
images only when those IDs already belong to the importing account. Archives are
limited to 250 MB uncompressed, and each image must meet the destination's upload limit.
Imports remap persona, chat and message IDs, including template, memory, continuity,
branch, retry and summary references. References absent from the import are left
unassigned. Legacy histories without parent fields are restored as ordered message
chains; explicit branch roots remain separate. Unset generation settings remain unset.
`CHAT_LIMIT` defaults to 10 active chats per account. Archived chats remain available
without using an active slot; restoring one requires a free slot.
@@ -215,6 +221,11 @@ request still uses `stream: true` and the existing capability-checked reasoning
Stop/disconnect cancels the current vision or final generation request. Logging includes
IDs, sizes, types and cache events only, never images/Base64/private analysis.
Roleplay and marketplace initialization use the same chat generation lock and
cancellation tracking as other replies. Concurrent initializations return HTTP 409
and save at most one opener. Edit/resend holds the source chat's lock while generating
its branch, and Stop can cancel it through the source chat or the new branch.
Dependencies added: `multer` (bounded multipart handling), `sharp` (content detection,
dimensions and full image decoding). Install through the normal `npm install`/`npm ci`
workflow, including Sharp's platform-specific optional dependencies in Docker. The
+2 -1
View File
@@ -11,7 +11,8 @@
"dev": "node --env-file-if-exists=.env --watch server.js",
"user:role": "node --env-file-if-exists=.env scripts/set-role.js",
"lint": "eslint .",
"test": "node --test"
"test": "node --test",
"test:browser": "node scripts/browser-tests.js"
},
"dependencies": {
"bcrypt": "^6.0.0",
+28 -22
View File
@@ -40,6 +40,25 @@ export function createConversationController({
async function setActiveChat(id, {preserveComposer = false, matchMessageId = null} = {}) {
const openRequest = ++state.chatOpenRequest;
if (isMobileLayout()) closeMobileSidebar();
setChatLoading(true, "Opening chat...");
setChatActivity(true, {
eyebrow: "Opening chat", title: "Loading conversation",
detail: "Fetching messages and restoring the current scene."
});
try {
let res = await get(`/chats/${id}/messages`);
if (openRequest !== state.chatOpenRequest) return;
if (res.error) return setNotice(res.error, "error");
if (matchMessageId && !res.messages?.some(message => message.id === matchMessageId)) {
const activated = await post(`/chats/${id}/branches/${matchMessageId}/activate`, {includeDescendants: true});
if (openRequest !== state.chatOpenRequest) return;
if (activated.error) return setNotice(activated.error, "error");
res = await get(`/chats/${id}/messages`);
if (openRequest !== state.chatOpenRequest) return;
if (res.error) return setNotice(res.error, "error");
}
// Commit identity, content and drafts together, only for the latest successful load.
if (!preserveComposer && id !== state.activeChatId) {
if (state.activeChatId != null) {
state.chatDrafts.set(state.activeChatId, msgInput.value);
@@ -53,42 +72,29 @@ export function createConversationController({
resizeComposerInput();
}
}
if (isMobileLayout()) closeMobileSidebar();
setChatLoading(true, "Opening chat...");
setChatActivity(true, {
eyebrow: "Opening chat",
title: "Loading conversation",
detail: "Fetching messages and restoring the current scene."
});
const existing = getChatById(id);
if (res.chat && existing) Object.assign(existing, res.chat);
state.activeChatId = id;
state.currentMessages = res.messages || [];
history.replaceState(null, "", `/app/chats/${id}`);
state.messageRetryState = new Map();
updateWorkspaceCopy(); renderChatList(); updateChatActionState();
let res = await get(`/chats/${id}/messages`);
if (openRequest !== state.chatOpenRequest) { setChatLoading(false); setChatActivity(false); return; }
if (res.error) {
setChatLoading(false);
setChatActivity(false);
return setNotice(res.error, "error");
}
if (matchMessageId && !res.messages?.some(message => message.id === matchMessageId)) {
const activated = await post(`/chats/${id}/branches/${matchMessageId}/activate`, {includeDescendants: true});
if (!activated.error) res = await get(`/chats/${id}/messages`);
if (openRequest !== state.chatOpenRequest) { setChatLoading(false); setChatActivity(false); return; }
}
const existing = getChatById(id);
if (res.chat && existing) Object.assign(existing, res.chat);
populateGenerationSettings(res.chat || existing);
state.currentMessages = res.messages || []; renderMessages();
renderMessages();
if (matchMessageId) requestAnimationFrame(() => {
if (openRequest !== state.chatOpenRequest) return;
const target = [...messagesDiv.querySelectorAll("[data-message-id]")]
.find(node => Number(node.dataset.messageId) === Number(matchMessageId));
if (target) { target.scrollIntoView({block: "center"}); updateJumpToLatest(); }
});
updateSendState();
} finally {
if (openRequest === state.chatOpenRequest) {
setChatLoading(false);
setChatActivity(false);
}
}
}
async function loadChatSessions() {
setChatLoading(true, "Loading chats...");
+1 -1
View File
@@ -554,7 +554,7 @@ async function startMarketPersonaChat(marketId, userPersonaSelection, scenarioPr
const res = await post(`/personas/market/${marketId}/chat`, {
userPersonaId,
scenarioPrompt: scenarioPrompt.trim()
});
}, {timeoutMs: 660000});
if (res.error || !res.chat) {
setMarketActivity(false);
setMarketStatus(res.error || "Unable to start chat.", "error");
+1 -1
View File
@@ -385,7 +385,7 @@ export function createPersonaController({
userPersonaId,
scenarioPrompt,
model: modelSelect.value
});
}, {timeoutMs: 660000});
roleplayStarterConfirm.disabled = false;
if (res.error || !res.chat) {
setChatLoading(false);
+7 -5
View File
@@ -496,6 +496,7 @@ async function editChatMessage(messageId, fallbackIndex = -1) {
const target = await resolveMessageTarget(messageId, fallbackIndex);
if (!target) return setNotice("Message is not ready yet. Try again.", "error");
const { index, message } = target;
const sourceChatId = conversation.activeChatId;
const nextContent = await promptPopup({
eyebrow: message.role === "user" ? "Your message" : "Assistant message",
title: "Edit message",
@@ -513,8 +514,8 @@ async function editChatMessage(messageId, fallbackIndex = -1) {
description: "This creates a new chat from the messages before this one and sends your edited message there. The original chat stays unchanged.",
confirmLabel: "Create branch"
});
if (!confirmed) return;
const sourceChatId = conversation.activeChatId;
if (!confirmed || conversation.isProcessing) return;
conversation.regenerationChatId = sourceChatId;
setLoadingState(true, {
eyebrow: "Branching",
title: "Editing and resending",
@@ -526,11 +527,12 @@ async function editChatMessage(messageId, fallbackIndex = -1) {
message: content,
model: modelSelect.value,
confirmed: true
});
}, {timeoutMs: 660000});
if (res.error) return setNotice(res.error, "error");
await openCreatedChat(res.chat);
return setNotice("Created and opened the edited chat branch.", "success");
if (conversation.activeChatId === sourceChatId) await openCreatedChat(res.chat);
return setNotice("Created the edited chat branch.", "success");
} finally {
conversation.regenerationChatId = null;
setLoadingState(false);
}
}
+10
View File
@@ -0,0 +1,10 @@
/** Local-only browser regression harness; serves no account data or workspace files. */
import express from "express";
import {fileURLToPath} from "node:url";
const app = express();
app.use("/public/js", express.static(fileURLToPath(new URL("../public/js", import.meta.url))));
app.use("/browser-tests", express.static(fileURLToPath(new URL("../browser-tests", import.meta.url))));
const server = app.listen(0, "127.0.0.1", () => {
console.log(`Navigation tests: http://127.0.0.1:${server.address().port}/browser-tests/conversation.html`);
});
+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({
+39 -12
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,27 +731,28 @@ 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 };
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 cloned = cloneChatThroughMessage({
user,
sourceChat,
messageId: targetMessage.parent_message_id ?? null
});
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) || [],
attachments: attachmentService?.list(freshTarget.id).map(item => item.id) || [],
reuseChatId: chatId,
message: content,
model,
webSearch,
reasoning,
onChunk,
signal,
signal: activeSignal,
requestId
});
if (generated.error) return { ...generated, chat: cloned.chat };
@@ -735,8 +761,9 @@ export function createChatService(repositories, modelService, config, webSearchP
chat: repositories.getChat(cloned.chat.id, user),
messages: listActiveBranchMessages(cloned.chat.id),
sourceChatId: chatId,
branchedFromMessageId: targetMessage.id
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}`),
+22
View File
@@ -0,0 +1,22 @@
import test from "node:test";
import assert from "node:assert/strict";
test("conversation navigation", async t => {
const original = Object.fromEntries(["document", "window", "history", "location"].map(key => [key, globalThis[key]]));
const elements = new Map();
globalThis.document = {
getElementById(id) {
if (!elements.has(id)) elements.set(id, {value: "", textContent: "", querySelectorAll: () => []});
return elements.get(id);
},
querySelectorAll: () => [], querySelector: () => null
};
globalThis.location = {pathname: "/app/chats/1", search: ""};
globalThis.window = {location: globalThis.location};
globalThis.history = {replaceState(_data, _unused, path) { globalThis.location.pathname = path; }};
t.after(() => { for (const [key, value] of Object.entries(original)) globalThis[key] = value; });
const {registerConversationTests} = await import("../browser-tests/conversationCases.js");
const cases = [];
registerConversationTests((name, run) => cases.push({name, run}), assert);
for (const {name, run} of cases) await t.test(name, run);
});
+169
View File
@@ -0,0 +1,169 @@
import test from "node:test";
import assert from "node:assert/strict";
import {createApp} from "../src/app.js";
const deferred = () => {
let resolve;
const promise = new Promise(done => { resolve = done; });
return {promise, resolve};
};
async function fixture(t, config = {}) {
const calls = [];
const gates = [];
let nextGate = null;
const modelService = {
async generateReply(model, messages, options) {
calls.push({model, messages, options});
const gate = nextGate;
nextGate = null;
if (gate) {
gate.started.resolve();
const abort = () => { gate.aborted.resolve(); };
options.signal.addEventListener("abort", abort, {once: true});
// Intentionally allow a late model result, exercising the commit-time abort check.
try { await gate.release.promise; }
finally { options.signal.removeEventListener("abort", abort); }
}
if (options.reasoning) options.onThinking?.("Reasoning", "Reasoning");
return "The guide waits beside the station.";
},
async streamReply(model, messages, onChunk, options) {
const reply = await this.generateReply(model, messages, options);
onChunk(reply, reply);
return reply;
},
mapError(error) { return {status: 500, body: {error: error.message}}; }
};
const app = createApp({modelService, config: {dbPath: ":memory:", testMode: true, sessionSecret: "generation-tests",
chatSummaryUpdateEveryMessages: 0, eventLoopLagMonitorEnabled: false, structuredRequestLoggingEnabled: false,
slowRequestLoggingEnabled: false, ...config}});
const server = app.listen(0, "127.0.0.1");
await new Promise(resolve => server.once("listening", resolve));
t.after(async () => {
for (const gate of gates) gate.release.resolve();
await new Promise(resolve => { server.closeAllConnections(); server.close(resolve); });
await app.locals.close();
});
const base = `http://127.0.0.1:${server.address().port}`;
let cookie = "";
const api = async (path, body, signal) => {
const response = await fetch(base + path, {method: body ? "POST" : "GET", signal,
headers: {"Content-Type": "application/json", Cookie: cookie}, body: body ? JSON.stringify(body) : undefined});
if (response.headers.get("set-cookie")) cookie = response.headers.get("set-cookie").split(";")[0];
return response;
};
assert.equal((await api("/register", {username: "generator", password: "test-password"})).status, 200);
const persona = (await (await api("/personas", {name: "Guide", personaType: "assistant"})).json()).persona;
const published = await (await api(`/personas/${persona.id}/publish`, {})).json();
const marketId = published.persona.id;
const chat = (await (await api("/chats", {title: "Regular chat"})).json()).chat;
const repo = app.locals.repositories;
const promptId = Number(repo.insertBranchChatMessage(chat.id, "user", "Original question").lastInsertRowid);
const answerId = Number(repo.insertBranchChatMessage(chat.id, "bot", "Original answer", null, null, "complete", null, promptId).lastInsertRowid);
return {api, repo, calls, persona, chat, promptId, answerId, marketId,
roleplay: {path: "/roleplays/start", body: {assistantPersonaId: persona.id, scenarioPrompt: "First scene"}},
market: {path: `/personas/market/${marketId}/chat`, body: {scenarioPrompt: "First scene"}},
edit: {path: `/chats/${chat.id}/messages/${promptId}/edit-resend`, body: {chatId: chat.id, message: "Edited question", confirmed: true, reasoning: true, model: "qwen3:14b"}},
hold() {
const gate = {started: deferred(), release: deferred(), aborted: deferred()};
gates.push(gate); nextGate = gate;
return gate;
}};
}
for (const limitedBy of ["chatRateLimitMaxPerAccount", "chatRateLimitMaxPerIp"]) {
test(`roleplay, marketplace and edit/resend share normal chat's ${limitedBy} bucket`, {timeout: 10000}, async t => {
const s = await fixture(t, {[limitedBy]: 1});
assert.equal((await s.api("/chat", {chatId: s.chat.id, message: "Use the budget"})).status, 200);
const before = s.repo.exportWorkspace("generator");
const attempts = [s.roleplay, s.roleplay, s.market, s.edit];
const responses = await Promise.all(attempts.map(({path, body}) => s.api(path, body)));
assert.deepEqual(responses.map(response => response.status), [429, 429, 429, 429]);
assert.equal(s.calls.length, 1);
assert.deepEqual(s.repo.exportWorkspace("generator").chats, before.chats);
assert.deepEqual(s.repo.exportWorkspace("generator").personas, before.personas);
});
}
for (const kind of ["roleplay", "market", "edit"]) {
test(`${kind} consumes the shared generation budget`, {timeout: 10000}, async t => {
const s = await fixture(t, {chatRateLimitMaxPerAccount: 1});
const operation = s[kind];
assert.equal((await s.api(operation.path, operation.body)).status, 200);
assert.equal((await s.api("/chat", {chatId: s.chat.id, message: "Over budget"})).status, 429);
assert.equal(s.calls.length, 1);
});
}
for (const kind of ["roleplay", "market"]) {
test(`concurrent ${kind} starts save exactly one opener and preserve the first scene`, {timeout: 10000}, async t => {
const s = await fixture(t);
const gate = s.hold();
const operation = s[kind];
const first = s.api(operation.path, operation.body);
await gate.started.promise;
assert.equal((await s.api(operation.path, {...operation.body, scenarioPrompt: "Overlapping scene"})).status, 409);
// The other opener route must use the very same lock.
const openingChat = s.repo.listChats("generator").find(chat => chat.id !== s.chat.id);
assert.equal((await s.api("/roleplays/start", {assistantPersonaId: openingChat.assistant_persona_id})).status, 409);
gate.release.resolve();
const response = await first;
assert.equal(response.status, 200);
const result = await response.json();
assert.equal(result.generatedInitialMessage, true);
assert.equal(result.chat.scenario_prompt, "First scene");
assert.equal(s.repo.listChatMessages(result.chat.id).length, 1);
assert.equal((await (await s.api(operation.path, operation.body)).json()).generatedInitialMessage, false);
assert.equal(s.calls.length, 1);
});
}
for (const kind of ["roleplay", "market", "edit"]) {
for (const cancellation of ["stop", "disconnect"]) {
test(`${kind} ${cancellation} aborts the model, prevents late saves and releases the lock`, {timeout: 10000}, async t => {
const s = await fixture(t);
const gate = s.hold();
const operation = s[kind];
const controller = new AbortController();
const pending = s.api(operation.path, operation.body, controller.signal);
await gate.started.promise;
const generatedChat = s.repo.listChats("generator").find(chat => chat.id !== s.chat.id);
const stopChatId = kind === "edit" ? s.chat.id : generatedChat.id;
if (cancellation === "stop") {
assert.equal((await (await s.api(`/chats/${stopChatId}/stop`, {})).json()).stopped, true);
} else {
const rejected = assert.rejects(pending, {name: "AbortError"});
controller.abort();
await rejected;
}
await gate.aborted.promise;
gate.release.resolve();
if (cancellation === "stop") await (await pending).json();
// Wait for the disconnected handler to finish before checking its database effects.
for (let attempt = 0; attempt < 100; attempt++) {
if (!(await (await s.api(`/chats/${stopChatId}/stop`, {})).json()).stopped) break;
await new Promise(resolve => setTimeout(resolve, 5));
}
assert.equal(s.repo.listChatMessages(generatedChat.id).filter(message => message.role === "bot").length, 0);
assert.equal(s.repo.listChatMessages(s.chat.id).filter(message => message.role === "bot").length, 1);
assert.equal((await s.api(operation.path, operation.body)).status, 200);
assert.equal(s.calls.length, 2);
});
}
}
test("edit/resend locks its source before cloning and shares exclusion with regeneration", {timeout: 10000}, async t => {
const s = await fixture(t);
const gate = s.hold();
const first = s.api(s.edit.path, s.edit.body);
await gate.started.promise;
assert.equal((await s.api(s.edit.path, s.edit.body)).status, 409);
assert.equal((await s.api(`/chats/${s.chat.id}/messages/${s.answerId}/retry`, {})).status, 409);
assert.equal(s.repo.listChats("generator").length, 2);
gate.release.resolve();
const result = await (await first).json();
assert.equal(result.messages.at(-1).thinking, "Reasoning");
assert.equal(s.calls[0].options.reasoning, true);
assert.equal(s.repo.listChatMessages(s.chat.id).length, 2);
});
+163
View File
@@ -0,0 +1,163 @@
import test from "node:test";
import assert from "node:assert/strict";
import {createDatabase} from "../src/db/index.js";
import {createRepositories} from "../src/db/repositories.js";
import {createConfig} from "../src/config.js";
import {validateWorkspaceImport} from "../src/services/workspaceImportService.js";
import {createWorkspaceArchive, parseWorkspaceArchive} from "../src/services/workspaceArchive.js";
function fixture(t) {
const config = createConfig({dbPath: ":memory:", testMode: true});
const db = createDatabase(config);
t.after(() => db.close());
const repo = createRepositories(db, config);
for (const user of ["source", "destination", "second-copy"]) repo.insertUser(user, "unused");
return {db, repo};
}
function restore(repo, workspace, user = "destination") {
const validation = validateWorkspaceImport(workspace);
assert.deepEqual(validation.errors, []);
repo.importWorkspace(user, validation.workspace);
return repo.exportWorkspace(user);
}
for (const format of ["JSON", "archive"]) {
test(`${format} export/import round trips remap personas, branches, prompts, state, memories and versions`, t => {
const {db, repo} = fixture(t);
// Identical display names must not be used as relationship keys.
const assistant = repo.createPersona("source", {name: "Alex", details: "Guide"}, "assistant");
const userPersona = repo.createPersona("source", {name: "Alex", details: "Explorer"}, "user");
const original = repo.createChat("source", "Original", assistant.id, userPersona.id, "At the station", "Meeting");
const prompt = Number(repo.insertBranchChatMessage(original.id, "user", "Question").lastInsertRowid);
const answer = Number(repo.insertBranchChatMessage(original.id, "bot", "Answer", {
retryVariants: ["Answer"], retryPromptMessageId: prompt
}, "qwen3:14b", "complete", null, prompt).lastInsertRowid);
repo.updateChatMessageThinking(original.id, answer, "Reasoning");
repo.updateChatMessageSources(original.id, answer, [{title: "Source", url: "https://example.org/", snippet: "Evidence"}]);
repo.saveMessageVersion(original.id, answer, {content: "Revised", thinking: "Revised reasoning", sources: [], modelName: "qwen3:14b"});
repo.updateChatContextSummary(original.id, "source", "Summary", answer);
repo.updatePersonaChatState("source", original, {tone: "Curious", current_location: "Station"});
repo.createChatMemory("source", original, "Remember the station");
repo.createPromptTemplate("source", {name: "Guide prompt", personaId: assistant.id, promptText: "Begin here"});
const branch = repo.createChat("source", "Branch", assistant.id, userPersona.id);
repo.updateChatBranchSource(branch.id, "source", original.id, answer);
repo.updateChatGenerationSettings(branch.id, "source", {temperature: 0, contextLength: 4096, responseLength: 256});
repo.insertBranchChatMessage(branch.id, "bot", "Another root");
let workspace = repo.exportWorkspace("source");
if (format === "archive") workspace = parseWorkspaceArchive(createWorkspaceArchive(workspace, [])).workspace;
else workspace = JSON.parse(JSON.stringify(workspace));
// A branch can be exported before its source chat.
workspace.chats.sort((a, b) => b.id - a.id);
for (const destination of ["destination", "second-copy"]) {
const restored = restore(repo, workspace, destination);
const copied = restored.chats.find(chat => chat.stored_title === "Original");
const copiedBranch = restored.chats.find(chat => chat.stored_title === "Branch");
const copiedAssistant = restored.personas.find(persona => persona.persona_type === "assistant");
const copiedUser = restored.personas.find(persona => persona.persona_type === "user");
assert.notEqual(copied.id, original.id);
assert.notEqual(copiedAssistant.id, assistant.id);
assert.notEqual(copiedUser.id, userPersona.id);
for (const record of [copied, copiedBranch, copied.memories[0], copied.persona_state]) {
assert.equal(record.assistant_persona_id, copiedAssistant.id);
assert.equal(record.user_persona_id, copiedUser.id);
}
const question = copied.messages.find(message => message.role === "user");
const reply = copied.messages.find(message => message.role === "bot");
assert.equal(reply.parent_message_id, question.id);
assert.equal(reply.retry_prompt_message_id, question.id);
assert.equal(copied.active_leaf_message_id, reply.id);
assert.equal(copied.context_summary_message_id, reply.id);
assert.equal(copied.context_summary, "Summary");
assert.equal(copiedBranch.source_chat_id, copied.id);
assert.equal(copiedBranch.branched_from_message_id, reply.id);
assert.deepEqual(repo.listActiveBranchMessages(copied.id).map(message => message.content), ["Question", "Revised"]);
assert.deepEqual(reply.responseVersions, workspace.chats.find(chat => chat.stored_title === "Original").messages.find(message => message.role === "bot").responseVersions);
assert.equal(reply.thinking, "Revised reasoning");
assert.equal(copied.temperature, null);
assert.equal(copied.context_length, null);
assert.equal(copied.response_length, null);
assert.equal(copiedBranch.temperature, 0);
assert.equal(copiedBranch.context_length, 4096);
assert.equal(copiedBranch.response_length, 256);
assert.equal(copied.persona_state.tone, "Curious");
assert.equal(copied.memories[0].fact, "Remember the station");
assert.equal(restored.templates.find(template => template.name === "Guide prompt").persona_id, copiedAssistant.id);
assert.deepEqual(db.pragma("foreign_key_check"), []);
workspace = restored;
}
});
}
test("legacy two-message histories with and without IDs restore a complete active chain", t => {
const {repo} = fixture(t);
for (const withIds of [false, true]) {
const title = `Legacy ${withIds}`;
const restored = restore(repo, {chats: [{title, temperature: null, messages: [
{role: "user", content: "Question", ...(withIds ? {id: 41} : {})},
{role: "assistant", content: "Answer", ...(withIds ? {id: 42} : {})}
]}]});
const chat = restored.chats.find(item => item.stored_title === title);
assert.deepEqual(repo.listActiveBranchMessages(chat.id).map(message => message.content), ["Question", "Answer"]);
assert.equal(chat.messages[1].parent_message_id, chat.messages[0].id);
assert.equal(chat.active_leaf_message_id, chat.messages[1].id);
assert.equal(chat.temperature, null);
}
});
test("explicit roots stay separate and unmapped references never bind existing records", t => {
const {repo} = fixture(t);
const foreignPersona = repo.createPersona("source", {name: "Other account"}, "assistant");
const foreignChat = repo.createChat("source", "Other chat");
const foreignMessage = Number(repo.insertChatMessage(foreignChat.id, "user", "Private").lastInsertRowid);
const restored = restore(repo, {templates: [{name: "Detached", prompt_text: "Hello", persona_id: foreignPersona.id}], chats: [{
title: "Roots", assistant_persona_id: foreignPersona.id, source_chat_id: foreignChat.id, branched_from_message_id: foreignMessage,
active_leaf_message_id: 20, messages: [
{id: 10, role: "user", content: "Root one", parent_message_id: null},
{id: 20, role: "bot", content: "Root two", parent_message_id: null, retry_prompt_message_id: foreignMessage}
]
}]});
const chat = restored.chats[0];
assert.equal(chat.assistant_persona_id, null);
assert.equal(chat.source_chat_id, null);
assert.equal(chat.branched_from_message_id, null);
assert.equal(chat.messages[1].retry_prompt_message_id, null);
assert.ok(chat.messages.every(message => message.parent_message_id === null));
assert.deepEqual(repo.listActiveBranchMessages(chat.id).map(message => message.content), ["Root two"]);
assert.equal(restored.templates.find(template => template.name === "Detached").persona_id, null);
});
test("message references are remapped even when the parent is imported later", t => {
const {repo} = fixture(t);
const restored = restore(repo, {chats: [{title: "Forward reference", active_leaf_message_id: 20, messages: [
{id: 20, role: "bot", content: "Answer", parentMessageId: 10, retryPromptMessageId: 10},
{id: 10, role: "user", content: "Question", parentMessageId: null}
]}]});
const [answer, prompt] = restored.chats[0].messages;
assert.equal(answer.parent_message_id, prompt.id);
assert.equal(answer.retry_prompt_message_id, prompt.id);
assert.equal(restored.chats[0].active_leaf_message_id, answer.id);
});
test("optional generation settings preserve unset values and numeric zero", () => {
for (const value of [null, undefined, "", " ", 0, "0", 0.7]) {
const {workspace} = validateWorkspaceImport({chats: [{title: "Settings", temperature: value, context_length: value, response_length: value}]});
const chat = workspace.chats[0];
const expected = value === 0 || value === "0" ? 0 : value === 0.7 ? 0.7 : null;
assert.equal(chat.temperature, expected);
assert.equal(chat.context_length, expected === 0.7 ? null : expected);
assert.equal(chat.response_length, expected === 0.7 ? null : expected);
}
});
test("ambiguous IDs and cyclic message chains fail validation", () => {
for (const messages of [
[{id: 1}, {id: 1}],
[{id: 1, parent_message_id: 2}, {id: 2, parent_message_id: 1}]
]) {
const validation = validateWorkspaceImport({chats: [{messages: messages.map(message => ({role: "user", content: "Text", ...message}))}]});
assert.equal(validation.ok, false);
assert.match(validation.errors.join(" "), /duplicate message ID|cyclic message chain/);
}
});