/** * @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; }