798 lines
43 KiB
JavaScript
798 lines
43 KiB
JavaScript
/**
|
|
* @file Stellt Chat-Routen für Nachrichten, Streaming, Antwortvarianten, Verzweigungen und Einstellungen bereit.
|
|
*/
|
|
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";
|
|
import {validateWorkspaceImport} from "../services/workspaceImportService.js";
|
|
import {createWorkspaceArchive, parseWorkspaceArchive, MAX_ARCHIVE_BYTES} from "../services/workspaceArchive.js";
|
|
import {getPersonality, listPersonalities, DEFAULT_PERSONALITY_ID} from "../services/personalityRegistry.js";
|
|
|
|
const createChatValidator = (body) => ({
|
|
value: {
|
|
title: String(body?.title || "New chat").trim() || "New chat",
|
|
personalityId: body?.personalityId == null ? DEFAULT_PERSONALITY_ID : String(body.personalityId)
|
|
}
|
|
});
|
|
|
|
const messageValidator = (body) => {
|
|
const content = String(body?.content || "").trim();
|
|
if (!content) return {error: "Message content is required"};
|
|
return {value: {content, retryActiveIndex: body?.retryActiveIndex}};
|
|
};
|
|
|
|
const searchPreferencesValidator = (body) => {
|
|
if (body?.webSearch !== undefined && typeof body.webSearch !== "boolean") return {error: "webSearch must be a boolean"};
|
|
if (body?.reasoning !== undefined && typeof body.reasoning !== "boolean") return {error: "reasoning must be a boolean"};
|
|
return {value: {webSearch: body?.webSearch !== false, reasoning: body?.reasoning === true}};
|
|
};
|
|
|
|
const sendMessageValidator = (body) => {
|
|
const attachments = body?.attachments ?? [];
|
|
if (!Array.isArray(attachments) || attachments.some(id => typeof id !== "string" || !/^[a-f0-9-]{36}$/.test(id))) return {error: "Invalid attachment IDs"};
|
|
const message = String(body?.message || "").trim() || (attachments.length ? "Describe the attached images." : "");
|
|
const chatId = Number(body?.chatId);
|
|
if (!chatId) return {error: "chatId is required"};
|
|
if (!message) return {error: "message is required"};
|
|
const preferences = searchPreferencesValidator(body);
|
|
if (preferences.error) return preferences;
|
|
return {value: {message, chatId, attachments, model: String(body?.model || "qwen3:14b"), ...preferences.value}};
|
|
};
|
|
|
|
const organizationValidator = (body) => ({
|
|
value: {
|
|
folderName: String(body?.folderName || "").trim() || null,
|
|
isPinned: Boolean(body?.isPinned),
|
|
archived: Boolean(body?.archived)
|
|
}
|
|
});
|
|
|
|
const memoryValidator = (body) => {
|
|
const fact = String(body?.fact || "").replace(/\s+/g, " ").trim();
|
|
if (!fact) return {error: "Memory fact is required"};
|
|
if (fact.length > 800) return {error: "Memory fact must be 800 characters or fewer"};
|
|
return {value: {fact}};
|
|
};
|
|
|
|
const personaStateFields = [
|
|
"relationship_notes",
|
|
"tone",
|
|
"current_location",
|
|
"goals",
|
|
"unresolved_threads",
|
|
"boundaries"
|
|
];
|
|
|
|
const normalizeStateText = (value) => String(value ?? "").replace(/\r\n/g, "\n").trim();
|
|
|
|
const personaStateValidator = (body) => {
|
|
const value = {};
|
|
for (const field of personaStateFields) {
|
|
const normalized = normalizeStateText(body?.[field]);
|
|
if (normalized.length > 1200) {
|
|
return {error: `${field} must be 1200 characters or fewer`};
|
|
}
|
|
value[field] = normalized || null;
|
|
}
|
|
return {value};
|
|
};
|
|
|
|
const optionalInteger = (value, min, max, label) => {
|
|
if (value == null || value === "") return {value: null};
|
|
const parsed = Number(value);
|
|
if (!Number.isInteger(parsed) || parsed < min || parsed > max) return {error: `${label} must be between ${min} and ${max}`};
|
|
return {value: parsed};
|
|
};
|
|
|
|
const generationSettingsValidator = (body) => {
|
|
const temperature = body?.temperature == null || body.temperature === "" ? null : Number(body.temperature);
|
|
if (temperature != null && (!Number.isFinite(temperature) || temperature < 0 || temperature > 2)) {
|
|
return {error: "Temperature must be between 0 and 2"};
|
|
}
|
|
const contextLength = optionalInteger(body?.contextLength, 256, 131072, "Context length");
|
|
if (contextLength.error) return contextLength;
|
|
const responseLength = optionalInteger(body?.responseLength, 1, 32768, "Response length");
|
|
if (responseLength.error) return responseLength;
|
|
const systemInstruction = String(body?.systemInstruction || "").trim();
|
|
if (systemInstruction.length > 8000) return {error: "System instruction must be 8000 characters or fewer"};
|
|
const preferredModel = String(body?.preferredModel || "").trim();
|
|
if (preferredModel.length > 200) return {error: "Preferred model must be 200 characters or fewer"};
|
|
return {value: {
|
|
preferredModel: preferredModel || null,
|
|
temperature,
|
|
contextLength: contextLength.value,
|
|
responseLength: responseLength.value,
|
|
systemInstruction: systemInstruction || null
|
|
}};
|
|
};
|
|
|
|
const preferredModelValidator = (body) => {
|
|
const model = String(body?.model || "").trim();
|
|
if (!model) return {error: "Model is required"};
|
|
if (model.length > 200) return {error: "Model must be 200 characters or fewer"};
|
|
return {value: {model}};
|
|
};
|
|
|
|
const userPersonaValidator = (body) => {
|
|
if (body?.userPersonaId == null || body.userPersonaId === "") return {value: {userPersonaId: null}};
|
|
const userPersonaId = Number(body.userPersonaId);
|
|
if (!Number.isInteger(userPersonaId) || userPersonaId < 1) return {error: "Invalid user persona"};
|
|
return {value: {userPersonaId}};
|
|
};
|
|
|
|
export function createChatsRouter({repositories, chatService, modelService, config, chatRateLimiters, attachmentService}) {
|
|
const router = express.Router();
|
|
router.use(requireLogin);
|
|
const receiveArchive = multer({storage: multer.memoryStorage(), limits: {fileSize: MAX_ARCHIVE_BYTES, files: 1, parts: 2}}).single("archive");
|
|
const readArchive = (req, res, next) => receiveArchive(req, res, error => {
|
|
if (error) return res.status(error.code === "LIMIT_FILE_SIZE" ? 413 : 400).json({error: "Invalid or oversized workspace archive"});
|
|
try {
|
|
if (!req.file) throw new Error("Choose a workspace archive");
|
|
req.archive = parseWorkspaceArchive(req.file.buffer);
|
|
next();
|
|
} catch (failure) { res.status(400).json({error: failure.message}); }
|
|
});
|
|
const archiveImages = (workspace, files) => {
|
|
const images = new Map();
|
|
for (const chat of workspace.chats) for (const message of chat.messages) for (const attachment of message.attachments) {
|
|
if (images.has(attachment.id)) continue;
|
|
const matches = [...files.entries()].filter(([name]) => name.startsWith(`images/${attachment.id}.`));
|
|
if (matches.length !== 1) throw new Error(`Image ${attachment.id} is missing from archive`);
|
|
const [name, buffer] = matches[0];
|
|
images.set(attachment.id, {buffer, size: buffer.length, originalname: name.split("/").at(-1)});
|
|
}
|
|
return images;
|
|
};
|
|
router.use(["/chats/:id/messages/by-index/:index", "/chats/:id/messages/:messageId"], (req, res, next) => {
|
|
const chatId = Number(req.params.id);
|
|
const messageId = req.params.messageId ? Number(req.params.messageId) : chatService.getChatMessageByIndex(chatId, req.params.index)?.id;
|
|
if (["PUT", "DELETE", "POST"].includes(req.method) && chatService.isRegeneratingMessage(chatId, messageId)) {
|
|
return res.status(409).json({error: "This response is being regenerated. Wait for it to finish."});
|
|
}
|
|
next();
|
|
});
|
|
|
|
router.get("/models", asyncHandler(async (req, res) => {
|
|
try {
|
|
return res.json(await modelService.listModels());
|
|
} catch (error) {
|
|
const mapped = modelService.mapError(error);
|
|
return res.status(mapped.status).json(mapped.body);
|
|
}
|
|
}));
|
|
|
|
router.get("/chats", (req, res) => res.json({chats: repositories.listChats(req.session.user)}));
|
|
router.get("/personalities", (_req, res) => res.json({personalities: listPersonalities(), defaultPersonalityId: DEFAULT_PERSONALITY_ID}));
|
|
router.get("/personalities/:id", (req, res) => {
|
|
const personality = listPersonalities().find(item => item.id === req.params.id);
|
|
return personality ? res.json({personality}) : res.status(404).json({error: "Personality not found"});
|
|
});
|
|
router.get("/chats/search", (req, res) => res.json({chats: repositories.searchChats(req.session.user, req.query.q || "")}));
|
|
|
|
router.post("/chats", validateBody(createChatValidator), (req, res) => {
|
|
const user = req.session.user;
|
|
const {personalityId, title} = req.validatedBody;
|
|
if (!getPersonality(personalityId)) return res.status(400).json({error: "Unknown personality"});
|
|
|
|
if (repositories.countChats(user) >= config.chatLimit) {
|
|
return res.status(400).json({error: `Maximum of ${config.chatLimit} active chats reached. Archive or delete an active chat to create a new one.`});
|
|
}
|
|
|
|
const chat = repositories.createChat(user, title);
|
|
repositories.updateChatPersonality(chat.id, user, personalityId);
|
|
return res.json({chat: repositories.getChat(chat.id, user), existing: false});
|
|
});
|
|
|
|
router.put("/chats/:id/personality", (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"});
|
|
const personalityId = String(req.body?.personalityId || "");
|
|
if (!getPersonality(personalityId)) return res.status(400).json({error: "Unknown personality"});
|
|
repositories.updateChatPersonality(chatId, req.session.user, personalityId);
|
|
return res.json({chat: repositories.getChat(chatId, req.session.user), personality: listPersonalities().find(item => item.id === personalityId)});
|
|
});
|
|
|
|
router.put("/chats/:id", validateBody((body) => {
|
|
const title = String(body?.title || "").trim();
|
|
return title ? {value: {title}} : {error: "Title is required"};
|
|
}), (req, res) => {
|
|
const chatId = Number(req.params.id);
|
|
const result = repositories.updateChatTitle(chatId, req.session.user, req.validatedBody.title);
|
|
if (result.changes === 0) return res.status(404).json({error: "Chat not found"});
|
|
return res.json({chat: {id: chatId, title: req.validatedBody.title}});
|
|
});
|
|
|
|
router.put("/chats/:id/organization", validateBody(organizationValidator), (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"});
|
|
if (chat.archived_at && !req.validatedBody.archived && repositories.countChats(req.session.user) >= config.chatLimit) {
|
|
return res.status(400).json({error: `Maximum of ${config.chatLimit} active chats reached. Archive another chat before restoring this one.`});
|
|
}
|
|
repositories.updateChatOrganization(chatId, req.session.user, {
|
|
folderName: req.validatedBody.folderName,
|
|
isPinned: req.validatedBody.isPinned,
|
|
archivedAt: req.validatedBody.archived ? new Date().toISOString() : null
|
|
});
|
|
return res.json({chat: repositories.getChat(chatId, req.session.user)});
|
|
});
|
|
|
|
router.delete("/chats/:id", (req, res) => {
|
|
const result = repositories.deleteChat(Number(req.params.id), req.session.user);
|
|
if (result.changes === 0) return res.status(404).json({error: "Chat not found"});
|
|
return res.json({message: "Chat deleted"});
|
|
});
|
|
|
|
router.get("/chats/:id/messages", (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"});
|
|
const options = {};
|
|
for (const key of ["limit", "before", "leaf", "around"]) {
|
|
if (req.query[key] === undefined) continue;
|
|
const value = Number(req.query[key]);
|
|
if (!Number.isSafeInteger(value) || value < 1 || (key === "limit" && value > 100)) {
|
|
return res.status(400).json({error: `Invalid history ${key}`});
|
|
}
|
|
options[key] = value;
|
|
}
|
|
const result = chatService.getHistoryPage(chatId, options);
|
|
if (result.error) return res.status(result.status).json(result);
|
|
return res.json({...result, chat});
|
|
});
|
|
|
|
router.get("/chats/:id/branches", (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"});
|
|
const after = Number(req.query.after || 0);
|
|
if (!Number.isSafeInteger(after) || after < 0) return res.status(400).json({error: "Invalid branch cursor"});
|
|
const nodes = repositories.listBranchNodes(chatId, {after});
|
|
const hasMore = nodes.length > 200;
|
|
if (hasMore) nodes.pop();
|
|
return res.json({nodes, activeLeafId: chat.active_leaf_message_id || nodes.at(-1)?.id || null,
|
|
next: hasMore ? nodes.at(-1).id : null});
|
|
});
|
|
|
|
router.get("/chats/:id/messages/:messageId/versions", (req, res) => {
|
|
const chatId = Number(req.params.id);
|
|
if (!repositories.getChat(chatId, req.session.user)) return res.status(404).json({error: "Chat not found"});
|
|
const message = repositories.getChatMessage(chatId, Number(req.params.messageId));
|
|
if (!message) return res.status(404).json({error: "Message not found"});
|
|
return res.json({message: chatService.formatChatMessage(message)});
|
|
});
|
|
|
|
router.put("/chats/:id/generation-settings", validateBody(generationSettingsValidator), (req, res) => {
|
|
const chatId = Number(req.params.id);
|
|
if (!repositories.getChat(chatId, req.session.user)) return res.status(404).json({error: "Chat not found"});
|
|
repositories.updateChatGenerationSettings(chatId, req.session.user, req.validatedBody);
|
|
return res.json({chat: repositories.getChat(chatId, req.session.user)});
|
|
});
|
|
|
|
router.put("/chats/:id/model", validateBody(preferredModelValidator), (req, res) => {
|
|
const chatId = Number(req.params.id);
|
|
if (!repositories.getChat(chatId, req.session.user)) return res.status(404).json({error: "Chat not found"});
|
|
repositories.updateChatPreferredModel(chatId, req.session.user, req.validatedBody.model);
|
|
return res.json({chat: repositories.getChat(chatId, req.session.user)});
|
|
});
|
|
|
|
router.put("/chats/:id/user-persona", validateBody(userPersonaValidator), (req, res) => {
|
|
const chatId = Number(req.params.id);
|
|
const user = req.session.user;
|
|
const chat = repositories.getChat(chatId, user);
|
|
if (!chat) return res.status(404).json({error: "Chat not found"});
|
|
const {userPersonaId} = req.validatedBody;
|
|
if (userPersonaId) {
|
|
const persona = repositories.getPersona(userPersonaId, user);
|
|
if (!persona || persona.persona_type !== "user") return res.status(404).json({error: "User persona not found"});
|
|
}
|
|
repositories.updateChatUserPersona(chatId, user, userPersonaId);
|
|
if (chat.assistant_persona_id) {
|
|
const assistantPersona = repositories.getPersona(chat.assistant_persona_id, user);
|
|
const userPersona = userPersonaId ? repositories.getPersona(userPersonaId, user) : null;
|
|
if (assistantPersona) {
|
|
repositories.updateChatScene(
|
|
chatId,
|
|
user,
|
|
chat.scenario_prompt,
|
|
chatService.buildRoleplaySceneSummary(assistantPersona, userPersona, chat.scenario_prompt)
|
|
);
|
|
}
|
|
}
|
|
return res.json({chat: repositories.getChat(chatId, user)});
|
|
});
|
|
|
|
router.post("/chats/:id/branches/:messageId/activate", (req, res) => {
|
|
const result = chatService.activateBranch({
|
|
user: req.session.user,
|
|
chatId: Number(req.params.id),
|
|
messageId: Number(req.params.messageId),
|
|
includeDescendants: Boolean(req.body?.includeDescendants)
|
|
});
|
|
if (result.error) return res.status(result.status).json({error: result.error});
|
|
return res.json(result);
|
|
});
|
|
|
|
router.post("/chats/:id/messages/:messageId/branch", (req, res) => {
|
|
if (req.body?.confirmed !== true) return res.status(400).json({error: "Confirmation is required before creating a branch"});
|
|
const result = chatService.createBranchedChat({
|
|
user: req.session.user,
|
|
chatId: Number(req.params.id),
|
|
messageId: Number(req.params.messageId)
|
|
});
|
|
if (result.error) return res.status(result.status).json({error: result.error});
|
|
return res.json(result);
|
|
});
|
|
|
|
router.post("/chats/:id/stop", (req, res) => {
|
|
const result = chatService.stopGeneration({user: req.session.user, chatId: Number(req.params.id)});
|
|
if (result.error) return res.status(result.status).json({error: result.error});
|
|
return res.json(result);
|
|
});
|
|
|
|
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));
|
|
if (!repositories.getChat(chatId, req.session.user)) return res.status(404).json({error: "Chat not found"});
|
|
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,
|
|
targetMessage,
|
|
content: req.validatedBody.message,
|
|
model: req.validatedBody.model,
|
|
webSearch: req.validatedBody.webSearch,
|
|
reasoning: req.validatedBody.reasoning
|
|
});
|
|
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.status(mapped.status).json(mapped.body);
|
|
}
|
|
}));
|
|
|
|
router.put("/chats/:id/messages/:messageId", validateBody(messageValidator), (req, res) => {
|
|
const chatId = Number(req.params.id);
|
|
const messageId = Number(req.params.messageId);
|
|
const chat = repositories.getChat(chatId, req.session.user);
|
|
if (!chat) return res.status(404).json({error: "Chat not found"});
|
|
const existingMessage = chatService.formatChatMessage(repositories.getChatMessage(chatId, messageId));
|
|
if (!existingMessage) return res.status(404).json({error: "Message not found"});
|
|
|
|
if (existingMessage.role === "bot") {
|
|
const retryVariants = [...existingMessage.retryVariants];
|
|
const requestedIndex = Number(req.validatedBody.retryActiveIndex);
|
|
const activeIndex = Number.isInteger(requestedIndex)
|
|
? Number(req.validatedBody.retryActiveIndex)
|
|
: existingMessage.retryActiveIndex;
|
|
if (activeIndex < 0 || activeIndex >= retryVariants.length) {
|
|
return res.status(400).json({error: "retryActiveIndex is out of range"});
|
|
}
|
|
retryVariants[activeIndex] = req.validatedBody.content;
|
|
chatService.persistChatMessageRetryState({
|
|
chatId,
|
|
messageId,
|
|
content: req.validatedBody.content,
|
|
retryVariants,
|
|
retryActiveIndex: activeIndex,
|
|
retryRetriesUsed: existingMessage.retryRetriesUsed,
|
|
retryPromptMessageId: existingMessage.retryPromptMessageId
|
|
});
|
|
} else {
|
|
repositories.updateChatMessage(chatId, messageId, req.validatedBody.content);
|
|
}
|
|
|
|
repositories.touchChat(chatId, req.session.user);
|
|
repositories.updateChatContextSummary(chatId, req.session.user, null, 0);
|
|
return res.json({message: chatService.formatChatMessage(repositories.getChatMessage(chatId, messageId))});
|
|
});
|
|
|
|
router.put("/chats/:id/messages/by-index/:index", validateBody(messageValidator), (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"});
|
|
const target = chatService.getChatMessageByIndex(chatId, req.params.index);
|
|
if (!target) return res.status(404).json({error: "Message not found"});
|
|
const existingMessage = chatService.formatChatMessage(repositories.getChatMessage(chatId, target.id));
|
|
if (!existingMessage) return res.status(404).json({error: "Message not found"});
|
|
|
|
if (existingMessage.role === "bot") {
|
|
const retryVariants = [...existingMessage.retryVariants];
|
|
const requestedIndex = Number(req.validatedBody.retryActiveIndex);
|
|
const activeIndex = Number.isInteger(requestedIndex)
|
|
? Number(req.validatedBody.retryActiveIndex)
|
|
: existingMessage.retryActiveIndex;
|
|
if (activeIndex < 0 || activeIndex >= retryVariants.length) {
|
|
return res.status(400).json({error: "retryActiveIndex is out of range"});
|
|
}
|
|
retryVariants[activeIndex] = req.validatedBody.content;
|
|
chatService.persistChatMessageRetryState({
|
|
chatId,
|
|
messageId: target.id,
|
|
content: req.validatedBody.content,
|
|
retryVariants,
|
|
retryActiveIndex: activeIndex,
|
|
retryRetriesUsed: existingMessage.retryRetriesUsed,
|
|
retryPromptMessageId: existingMessage.retryPromptMessageId
|
|
});
|
|
} else {
|
|
repositories.updateChatMessage(chatId, target.id, req.validatedBody.content);
|
|
}
|
|
|
|
repositories.touchChat(chatId, req.session.user);
|
|
repositories.updateChatContextSummary(chatId, req.session.user, null, 0);
|
|
return res.json({message: chatService.formatChatMessage(repositories.getChatMessage(chatId, target.id))});
|
|
});
|
|
|
|
router.delete("/chats/:id/messages/:messageId", (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"});
|
|
const target = repositories.getChatMessage(chatId, Number(req.params.messageId));
|
|
if (!target) return res.status(404).json({error: "Message not found"});
|
|
if (chat.active_leaf_message_id === target.id) repositories.updateChatActiveLeaf(chatId, req.session.user, target.parent_message_id);
|
|
const result = repositories.deleteChatMessage(chatId, target.id);
|
|
if (result.changes === 0) return res.status(404).json({error: "Message not found"});
|
|
repositories.touchChat(chatId, req.session.user);
|
|
repositories.updateChatContextSummary(chatId, req.session.user, null, 0);
|
|
return res.json({message: "Message deleted"});
|
|
});
|
|
|
|
router.delete("/chats/:id/messages/by-index/:index", (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"});
|
|
const target = chatService.getChatMessageByIndex(chatId, req.params.index);
|
|
if (!target) return res.status(404).json({error: "Message not found"});
|
|
if (chat.active_leaf_message_id === target.id) repositories.updateChatActiveLeaf(chatId, req.session.user, target.parent_message_id);
|
|
repositories.deleteChatMessage(chatId, target.id);
|
|
repositories.touchChat(chatId, req.session.user);
|
|
repositories.updateChatContextSummary(chatId, req.session.user, null, 0);
|
|
return res.json({message: "Message deleted"});
|
|
});
|
|
|
|
router.post("/chats/:id/clear", (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"});
|
|
repositories.clearChatMessages(chatId);
|
|
repositories.updateChatActiveLeaf(chatId, req.session.user, null);
|
|
repositories.updateChatContextSummary(chatId, req.session.user, null, 0);
|
|
repositories.touchChat(chatId, req.session.user);
|
|
return res.json({message: "Chat cleared"});
|
|
});
|
|
|
|
router.get("/chats/:id/memories", (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"});
|
|
const payload = chatService.buildMessagesPayload({user: req.session.user, chatId, session: chat});
|
|
const characters = payload.messagesPayload.reduce((total, message) => total + String(message.content || "").length, 0);
|
|
return res.json({memories: repositories.listChatMemories(chatId, req.session.user), summary: chat.context_summary || "",
|
|
summaryMessageId: chat.context_summary_message_id || 0, summaryMaxChars: config.chatSummaryMaxChars,
|
|
context: {estimatedTokens: Math.ceil(characters / 4), contextLength: chat.context_length || null,
|
|
recentMessageLimit: config.chatHistoryLimit, memoryFactLimit: 30}});
|
|
});
|
|
|
|
router.put("/chats/:id/summary", (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"});
|
|
if (chatService.isGenerating(chatId)) return res.status(409).json({error: "Wait for the response to finish before editing its summary."});
|
|
const summary = req.body?.summary;
|
|
if (typeof summary !== "string" || summary.length > config.chatSummaryMaxChars) {
|
|
return res.status(400).json({error: `Summary must be text of at most ${config.chatSummaryMaxChars} characters.`});
|
|
}
|
|
if (req.body.previousSummary !== (chat.context_summary || "")) return res.status(409).json({error: "The summary changed. Reopen memory to review it before saving."});
|
|
repositories.updateChatContextSummary(chatId, req.session.user, summary.trim() || null, chat.context_summary_message_id);
|
|
return res.json({summary: summary.trim()});
|
|
});
|
|
|
|
router.post("/chats/:id/memories", validateBody(memoryValidator), (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"});
|
|
return res.json({memory: repositories.createChatMemory(req.session.user, chat, req.validatedBody.fact)});
|
|
});
|
|
|
|
router.put("/chats/:id/memories/:memoryId", validateBody(memoryValidator), (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"});
|
|
const result = repositories.updateChatMemory(chatId, Number(req.params.memoryId), req.session.user, req.validatedBody.fact);
|
|
if (result.changes === 0) return res.status(404).json({error: "Memory not found"});
|
|
return res.json({memory: repositories.getChatMemory(chatId, Number(req.params.memoryId), req.session.user)});
|
|
});
|
|
|
|
router.delete("/chats/:id/memories/:memoryId", (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"});
|
|
const result = repositories.deleteChatMemory(chatId, Number(req.params.memoryId), req.session.user);
|
|
if (result.changes === 0) return res.status(404).json({error: "Memory not found"});
|
|
return res.json({message: "Memory deleted"});
|
|
});
|
|
|
|
router.get("/chats/:id/persona-state", (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"});
|
|
return res.json({state: repositories.getPersonaChatState(chatId, req.session.user)});
|
|
});
|
|
|
|
router.put("/chats/:id/persona-state", validateBody(personaStateValidator), (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"});
|
|
const state = repositories.updatePersonaChatState(req.session.user, chat, req.validatedBody);
|
|
repositories.touchChat(chatId, req.session.user);
|
|
return res.json({state});
|
|
});
|
|
|
|
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,
|
|
message: req.validatedBody.message,
|
|
attachments: req.validatedBody.attachments,
|
|
model: req.validatedBody.model,
|
|
webSearch: req.validatedBody.webSearch,
|
|
reasoning: req.validatedBody.reasoning
|
|
});
|
|
if (result.error) return res.status(result.status).json({error: result.error, ...(result.searchState ? {searchState: result.searchState} : {})});
|
|
return res.json({reply: result.reply, searchState: result.searchState, ...(result.sources ? {sources: result.sources} : {})});
|
|
} catch (error) {
|
|
const mapped = modelService.mapError(error);
|
|
return res.status(mapped.status).json(mapped.body);
|
|
}
|
|
}));
|
|
|
|
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",
|
|
Connection: "keep-alive",
|
|
"X-Accel-Buffering": "no"
|
|
});
|
|
|
|
const sendEvent = (event, data) => {
|
|
if (res.destroyed) return;
|
|
res.write(`event: ${event}\n`);
|
|
res.write(`data: ${JSON.stringify(data)}\n\n`);
|
|
};
|
|
|
|
try {
|
|
const result = await chatService.streamChatMessage({
|
|
user: req.session.user,
|
|
requestId: req.id,
|
|
chatId: req.validatedBody.chatId,
|
|
message: req.validatedBody.message,
|
|
attachments: req.validatedBody.attachments,
|
|
model: req.validatedBody.model,
|
|
...createTextStreamEvents(sendEvent),
|
|
webSearch: req.validatedBody.webSearch,
|
|
reasoning: req.validatedBody.reasoning,
|
|
onStatus: (status) => sendEvent("status", status),
|
|
signal: req.generationSignal
|
|
});
|
|
if (result.error) {
|
|
sendEvent("error", {error: result.error, status: result.status, requestId: req.id, ...(result.searchState ? {searchState: result.searchState} : {})});
|
|
} else {
|
|
sendEvent("done", {reply: result.reply, searchState: result.searchState, ...(result.sources ? {sources: result.sources} : {})});
|
|
}
|
|
} catch (error) {
|
|
const mapped = modelService.mapError(error);
|
|
sendEvent("error", {...mapped.body, status: mapped.status, requestId: req.id});
|
|
} finally {
|
|
res.end();
|
|
}
|
|
}));
|
|
|
|
router.post("/chats/:id/messages/:messageId/versions/:index", (req, res) => {
|
|
const chatId = Number(req.params.id), messageId = Number(req.params.messageId);
|
|
if (!repositories.getChat(chatId, req.session.user)) return res.status(404).json({error: "Chat not found"});
|
|
const message = repositories.getChatMessage(chatId, messageId);
|
|
if (!message || message.role !== "bot") return res.status(404).json({error: "Assistant message not found"});
|
|
if (chatService.isGenerating(chatId)) return res.status(409).json({error: "Wait for generation to finish"});
|
|
const index = Number(req.params.index);
|
|
const versions = repositories.listMessageVersions(messageId);
|
|
if (!Number.isInteger(index) || index < 0 || index >= versions.length) return res.status(400).json({error: "Version is out of range"});
|
|
repositories.saveMessageVersion(chatId, messageId, null, index);
|
|
repositories.updateChatContextSummary(chatId, req.session.user, null, 0);
|
|
res.json({message: chatService.formatChatMessage(repositories.getChatMessage(chatId, messageId))});
|
|
});
|
|
|
|
router.post(["/chats/:id/messages/:messageId/retry", "/chats/:id/messages/:messageId/retry/stream"], ...chatRateLimiters, generationCancellation, validateBody(searchPreferencesValidator), asyncHandler(async (req, res) => {
|
|
const chatId = Number(req.params.id);
|
|
const messageId = Number(req.params.messageId);
|
|
const chat = repositories.getChat(chatId, req.session.user);
|
|
if (!chat) return res.status(404).json({error: "Chat not found"});
|
|
const targetMessage = repositories.getChatMessage(chatId, messageId);
|
|
if (!targetMessage) return res.status(404).json({error: "Message not found"});
|
|
if (targetMessage.role !== "bot") return res.status(400).json({error: "Only assistant messages can be regenerated"});
|
|
|
|
const streaming = req.path.endsWith("/stream");
|
|
const sendEvent = (event, data) => {
|
|
if (!res.destroyed) res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
|
|
};
|
|
if (streaming) {
|
|
res.writeHead(200, {"Content-Type": "text/event-stream", "Cache-Control": "no-cache, no-transform", Connection: "keep-alive", "X-Accel-Buffering": "no"});
|
|
}
|
|
try {
|
|
const result = await chatService.retryMessage({
|
|
user: req.session.user,
|
|
requestId: req.id,
|
|
chatId,
|
|
selectedModel: String(req.body?.model || "qwen3:14b"),
|
|
targetMessage,
|
|
retryStyle: String(req.body?.style || "").trim() || null,
|
|
webSearch: req.validatedBody.webSearch,
|
|
reasoning: req.validatedBody.reasoning,
|
|
signal: req.generationSignal,
|
|
...(streaming ? {
|
|
...createTextStreamEvents(sendEvent),
|
|
onStatus: status => sendEvent("status", status)
|
|
} : {})
|
|
});
|
|
if (streaming) {
|
|
sendEvent(result.error ? "error" : "done", result.error ? {error: result.error, status: result.status, requestId: req.id, searchState: result.searchState} : {...result, reply: result.message.content});
|
|
return;
|
|
}
|
|
if (result.error) return res.status(result.status).json({error: result.error});
|
|
return res.json(result);
|
|
} catch (error) {
|
|
const mapped = modelService.mapError(error);
|
|
if (streaming) { sendEvent("error", {...mapped.body, status: mapped.status, requestId: req.id}); return; }
|
|
return res.status(mapped.status).json(mapped.body);
|
|
} finally {
|
|
if (streaming) res.end();
|
|
}
|
|
}));
|
|
|
|
router.post("/chats/:id/messages/by-index/:index/retry", ...chatRateLimiters, generationCancellation, validateBody(searchPreferencesValidator), 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"});
|
|
const targetMessage = chatService.getChatMessageByIndex(chatId, req.params.index);
|
|
if (!targetMessage) return res.status(404).json({error: "Message not found"});
|
|
if (targetMessage.role !== "bot") return res.status(400).json({error: "Only assistant messages can be regenerated"});
|
|
|
|
try {
|
|
const result = await chatService.retryMessage({
|
|
signal: req.generationSignal,
|
|
user: req.session.user,
|
|
requestId: req.id,
|
|
chatId,
|
|
selectedModel: String(req.body?.model || "qwen3:14b"),
|
|
targetMessage,
|
|
retryStyle: String(req.body?.style || "").trim() || null,
|
|
webSearch: req.validatedBody.webSearch,
|
|
reasoning: req.validatedBody.reasoning
|
|
});
|
|
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.status(mapped.status).json(mapped.body);
|
|
}
|
|
}));
|
|
|
|
router.get("/exports/chats/:id", (req, res) => {
|
|
const chatId = Number(req.params.id);
|
|
const format = String(req.query.format || "txt").toLowerCase();
|
|
const chat = repositories.getChat(chatId, req.session.user);
|
|
if (!chat) return res.status(404).json({error: "Chat not found"});
|
|
const messages = repositories.listChatMessages(chatId).map(chatService.formatChatMessage);
|
|
if (format === "json") {
|
|
return res.json({chat, messages});
|
|
}
|
|
if (format === "md") {
|
|
const body = [`# ${chat.title}`, "", ...messages.flatMap((message) => [`## ${message.role === "user" ? "You" : "Assistant"}`, "", message.content, ""])].join("\n");
|
|
res.type("text/markdown");
|
|
return res.send(body);
|
|
}
|
|
const body = [`Chat: ${chat.title}`, "", ...messages.flatMap((message) => [`${message.role === "user" ? "You" : "Assistant"}:`, message.content, ""])].join("\n");
|
|
res.type("text/plain");
|
|
return res.send(body);
|
|
});
|
|
|
|
router.get("/exports/workspace", (req, res) => res.json({workspace: repositories.exportWorkspace(req.session.user)}));
|
|
router.get("/exports/workspace/archive", asyncHandler(async (req, res) => {
|
|
try {
|
|
const workspace = repositories.exportWorkspace(req.session.user);
|
|
const images = new Map();
|
|
for (const chat of workspace.chats) for (const message of chat.messages) for (const attachment of message.attachments) {
|
|
if (images.has(attachment.id)) continue;
|
|
const row = attachmentService.owned(attachment.id, req.session.user);
|
|
images.set(attachment.id, [`images/${row.id}.${row.mime_type.split("/")[1]}`, await attachmentService.storage.read(row.storage_key)]);
|
|
}
|
|
const archive = createWorkspaceArchive(workspace, [...images.values()]);
|
|
res.set({"Content-Type": "application/gzip", "Content-Disposition": 'attachment; filename="workspace-backup.tar.gz"', "Cache-Control": "private, no-store"});
|
|
return res.send(archive);
|
|
} catch (error) {
|
|
return res.status(error.message.includes("250 MB") ? 413 : 500).json({error: "Workspace archive could not be created"});
|
|
}
|
|
}));
|
|
router.post("/imports/workspace/archive/preview", readArchive, (req, res) => {
|
|
const validation = validateWorkspaceImport(req.archive.workspace, repositories.exportWorkspace(req.session.user));
|
|
if (validation.ok) {
|
|
try { archiveImages(validation.workspace, req.archive.images); }
|
|
catch (error) { validation.ok = false; validation.errors.push(error.message); }
|
|
}
|
|
return res.status(validation.ok ? 200 : 400).json({ok: validation.ok, errors: validation.errors, warnings: validation.warnings, preview: validation.preview});
|
|
});
|
|
router.post("/imports/workspace/archive", readArchive, asyncHandler(async (req, res) => {
|
|
const validation = validateWorkspaceImport(req.archive.workspace, repositories.exportWorkspace(req.session.user));
|
|
if (!validation.ok) return res.status(400).json({error: "Workspace import validation failed", errors: validation.errors});
|
|
if (validation.preview.duplicates.length && req.body?.allowDuplicates !== "true") {
|
|
return res.status(409).json({error: "Workspace import contains duplicate records", warnings: validation.warnings, preview: validation.preview});
|
|
}
|
|
let images;
|
|
try { images = archiveImages(validation.workspace, req.archive.images); }
|
|
catch (error) { return res.status(400).json({error: error.message}); }
|
|
const restored = [];
|
|
try {
|
|
const ids = new Map();
|
|
for (const [oldId, file] of images) {
|
|
const uploaded = await attachmentService.upload(file, req.session.user, {forImport: true});
|
|
restored.push(uploaded.id);
|
|
ids.set(oldId, uploaded.id);
|
|
}
|
|
for (const chat of validation.workspace.chats) for (const message of chat.messages) {
|
|
message.attachments = message.attachments.map(attachment => ({id: ids.get(attachment.id)}));
|
|
}
|
|
const imported = repositories.importWorkspace(req.session.user, validation.workspace);
|
|
return res.json({imported, preview: validation.preview});
|
|
} catch (error) {
|
|
for (const id of restored) await attachmentService.remove(id, req.session.user).catch(() => {});
|
|
return res.status(error.status || 400).json({error: error.message || "Workspace archive import failed"});
|
|
}
|
|
}));
|
|
router.post("/imports/workspace/preview", (req, res) => {
|
|
const validation = validateWorkspaceImport(req.body?.workspace, repositories.exportWorkspace(req.session.user));
|
|
return res.status(validation.ok ? 200 : 400).json({
|
|
ok: validation.ok,
|
|
errors: validation.errors,
|
|
warnings: validation.warnings,
|
|
preview: validation.preview
|
|
});
|
|
});
|
|
router.post("/imports/workspace", (req, res) => {
|
|
const validation = validateWorkspaceImport(req.body?.workspace, repositories.exportWorkspace(req.session.user));
|
|
if (!validation.ok) {
|
|
return res.status(400).json({
|
|
error: "Workspace import validation failed",
|
|
errors: validation.errors,
|
|
warnings: validation.warnings,
|
|
preview: validation.preview
|
|
});
|
|
}
|
|
if (validation.preview.duplicates.length && !req.body?.allowDuplicates) {
|
|
return res.status(409).json({
|
|
error: "Workspace import contains duplicate records",
|
|
errors: [],
|
|
warnings: validation.warnings,
|
|
preview: validation.preview
|
|
});
|
|
}
|
|
return res.json({imported: repositories.importWorkspace(req.session.user, validation.workspace), preview: validation.preview});
|
|
});
|
|
|
|
router.get("/chat/history", (req, res) => res.json({history: repositories.listChats(req.session.user)}));
|
|
|
|
return router;
|
|
}
|