make some overall QoL improvements

This commit is contained in:
Frederik Spirgi
2026-09-30 11:05:13 +02:00
parent d15f7867d5
commit c69070a6e6
18 changed files with 256 additions and 78 deletions
+79 -3
View File
@@ -2,9 +2,11 @@
* @file Stellt Chat-Routen für Nachrichten, Streaming, Antwortvarianten, Verzweigungen und Einstellungen bereit.
*/
import express from "express";
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";
const createChatValidator = (body) => ({
value: {
@@ -105,9 +107,29 @@ const preferredModelValidator = (body) => {
return {value: {model}};
};
export function createChatsRouter({repositories, chatService, modelService, config, chatRateLimiters}) {
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;
@@ -144,14 +166,14 @@ export function createChatsRouter({repositories, chatService, modelService, conf
return res.json({chat, existing: Boolean(existingChat)});
} catch (error) {
if (error.message === "CHAT_LIMIT_REACHED") {
return res.status(400).json({error: `Maximum of ${config.chatLimit} chats reached. Please delete an old chat to create a new one.`});
return res.status(400).json({error: `Maximum of ${config.chatLimit} active chats reached. Archive or delete an active chat to create a new one.`});
}
throw error;
}
}
if (repositories.countChats(user) >= config.chatLimit) {
return res.status(400).json({error: `Maximum of ${config.chatLimit} chats reached. Please delete an old chat to create a new one.`});
return res.status(400).json({error: `Maximum of ${config.chatLimit} active chats reached. Archive or delete an active chat to create a new one.`});
}
return res.json({chat: repositories.createChat(user, title), existing: false});
@@ -171,6 +193,9 @@ export function createChatsRouter({repositories, chatService, modelService, conf
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,
@@ -602,6 +627,57 @@ export function createChatsRouter({repositories, chatService, modelService, conf
});
router.get("/exports/workspace", (req, res) => res.json({workspace: repositories.exportWorkspace(req.session.user)}));
router.get("/exports/workspace/archive", 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, 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({