Allow message retries for "pending" status, refactor message context handling, remove workspace import/export routes, and add SQLite-backed rate limiting.
This commit is contained in:
@@ -77,7 +77,8 @@ export function createComposerController({
|
|||||||
const visible = () => state.activeChatId === chatId;
|
const visible = () => state.activeChatId === chatId;
|
||||||
let userCard, loadingMsg, rendering, completedReply = null;
|
let userCard, loadingMsg, rendering, completedReply = null;
|
||||||
let completedThinking = "", completedSources = null;
|
let completedThinking = "", completedSources = null;
|
||||||
const userMessage = {role: "user", content: message, attachments};
|
const userMessage = {role: "user", content: message, attachments,
|
||||||
|
...(retryMessage?.id ? {id: retryMessage.id, deliveryStatus: retryMessage.deliveryStatus, errorMessage: retryMessage.errorMessage} : {})};
|
||||||
if (!retryMessage) {
|
if (!retryMessage) {
|
||||||
msgInput.value = "";
|
msgInput.value = "";
|
||||||
state.chatDrafts.set(chatId ?? "new", "");
|
state.chatDrafts.set(chatId ?? "new", "");
|
||||||
@@ -135,7 +136,9 @@ export function createComposerController({
|
|||||||
if (loadingBody) setMessageContent(loadingBody, text);
|
if (loadingBody) setMessageContent(loadingBody, text);
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
await api.stream("/chat/stream", {message, attachments: attachments.map(item => item.id), model, chatId, webSearch, reasoning}, {
|
await api.stream("/chat/stream", {message, attachments: attachments.map(item => item.id), model, chatId,
|
||||||
|
...(retryMessage?.id && ["failed", "pending"].includes(retryMessage.deliveryStatus) ? {userMessageId: retryMessage.id} : {}),
|
||||||
|
webSearch, reasoning}, {
|
||||||
...rendering,
|
...rendering,
|
||||||
onStatus: ({status, sourceCount, searchState}) => {
|
onStatus: ({status, sourceCount, searchState}) => {
|
||||||
if (status === "accepted" || !visible() || !loadingLabel) return;
|
if (status === "accepted" || !visible() || !loadingLabel) return;
|
||||||
|
|||||||
@@ -52,7 +52,23 @@ export function createMemoryPanel({state}) {
|
|||||||
summary.input.rows = 6;
|
summary.input.rows = 6;
|
||||||
const summaryHelp = document.createElement("p"); summaryHelp.className = "subtitle";
|
const summaryHelp = document.createElement("p"); summaryHelp.className = "subtitle";
|
||||||
summaryHelp.textContent = "Correct outdated information here. Future automatic summaries build on your corrections. Clear the field and save to remove the summary.";
|
summaryHelp.textContent = "Correct outdated information here. Future automatic summaries build on your corrections. Clear the field and save to remove the summary.";
|
||||||
panel.content.append(summary.wrapper, summaryHelp, actionButton("Save summary", () => run(put(`/chats/${id}/summary`, {summary: summary.input.value, previousSummary: result.summary}), "summary")));
|
panel.content.append(summary.wrapper, summaryHelp,
|
||||||
|
actionButton("Save summary", () => run(put(`/chats/${id}/summary`, {summary: summary.input.value, previousSummary: result.summary}), "summary")));
|
||||||
|
if (result.summary) {
|
||||||
|
const forget = actionButton("Forget running summary", () => run(put(`/chats/${id}/summary`, {summary: "", previousSummary: result.summary}), "summary"));
|
||||||
|
forget.title = "Remove the automatic summary from conversation memory";
|
||||||
|
panel.content.append(forget);
|
||||||
|
const sourceHeading = document.createElement("h4"); sourceHeading.textContent = "Messages included in this summary";
|
||||||
|
panel.content.append(sourceHeading);
|
||||||
|
for (const source of result.summarySources || []) {
|
||||||
|
const item = document.createElement("details"); item.className = "memory-summary-source";
|
||||||
|
const label = document.createElement("summary");
|
||||||
|
const excerpt = source.content.length > 160 ? `${source.content.slice(0, 157)}...` : source.content;
|
||||||
|
label.textContent = `#${source.id} · ${source.role === "user" ? "You" : "Assistant"}: ${excerpt}`;
|
||||||
|
const content = document.createElement("p"); content.textContent = source.content;
|
||||||
|
item.append(label, content); panel.content.append(item);
|
||||||
|
}
|
||||||
|
}
|
||||||
const heading = document.createElement("h4"); heading.textContent = "Saved facts";
|
const heading = document.createElement("h4"); heading.textContent = "Saved facts";
|
||||||
const hint = document.createElement("p"); hint.className = "subtitle";
|
const hint = document.createElement("p"); hint.className = "subtitle";
|
||||||
hint.textContent = `The ${context.memoryFactLimit} most recently updated facts are included in context. Edit or remove facts that are no longer accurate.`;
|
hint.textContent = `The ${context.memoryFactLimit} most recently updated facts are included in context. Edit or remove facts that are no longer accurate.`;
|
||||||
|
|||||||
@@ -249,12 +249,12 @@ export function createMessageRenderer({
|
|||||||
});
|
});
|
||||||
actions.appendChild(copyBtn);
|
actions.appendChild(copyBtn);
|
||||||
|
|
||||||
if (message.deliveryStatus === "failed" && isMessageUser) {
|
if (["failed", "pending"].includes(message.deliveryStatus) && isMessageUser) {
|
||||||
const retryFailedBtn = document.createElement("button");
|
const retryFailedBtn = document.createElement("button");
|
||||||
retryFailedBtn.type = "button";
|
retryFailedBtn.type = "button";
|
||||||
retryFailedBtn.className = "msg-action-btn";
|
retryFailedBtn.className = "msg-action-btn";
|
||||||
retryFailedBtn.dataset.messageAction = "retry-failed";
|
retryFailedBtn.dataset.messageAction = "retry-failed";
|
||||||
retryFailedBtn.textContent = "Retry";
|
retryFailedBtn.textContent = message.deliveryStatus === "pending" ? "Resume" : "Retry";
|
||||||
retryFailedBtn.disabled = state.isProcessing;
|
retryFailedBtn.disabled = state.isProcessing;
|
||||||
retryFailedBtn.addEventListener("click", () => { void sendMessage({ retryMessage: message }); });
|
retryFailedBtn.addEventListener("click", () => { void sendMessage({ retryMessage: message }); });
|
||||||
actions.appendChild(retryFailedBtn);
|
actions.appendChild(retryFailedBtn);
|
||||||
|
|||||||
+6
-2
@@ -12,10 +12,12 @@ import {createConfig} from "./config.js";
|
|||||||
import {createDatabase} from "./db/index.js";
|
import {createDatabase} from "./db/index.js";
|
||||||
import {createRepositories} from "./db/repositories.js";
|
import {createRepositories} from "./db/repositories.js";
|
||||||
import {createAuthRateLimiters, createChatRateLimiters} from "./middleware/rateLimiters.js";
|
import {createAuthRateLimiters, createChatRateLimiters} from "./middleware/rateLimiters.js";
|
||||||
|
import {createSqliteRateLimitStore} from "./middleware/rateLimit.js";
|
||||||
import {createModelService} from "./services/modelService.js";
|
import {createModelService} from "./services/modelService.js";
|
||||||
import {createChatService} from "./services/chatService.js";
|
import {createChatService} from "./services/chatService.js";
|
||||||
import {createAuthRouter} from "./routes/auth.js";
|
import {createAuthRouter} from "./routes/auth.js";
|
||||||
import {createChatsRouter} from "./routes/chats.js";
|
import {createChatsRouter} from "./routes/chats.js";
|
||||||
|
import {createWorkspaceTransferRouter} from "./routes/workspaceTransfer.js";
|
||||||
import {createPagesRouter} from "./routes/pages.js";
|
import {createPagesRouter} from "./routes/pages.js";
|
||||||
import {createSettingsRouter} from "./routes/settings.js";
|
import {createSettingsRouter} from "./routes/settings.js";
|
||||||
import {createHealthRouter, createSystemRouter} from "./routes/system.js";
|
import {createHealthRouter, createSystemRouter} from "./routes/system.js";
|
||||||
@@ -272,8 +274,9 @@ export function createApp(options = {}) {
|
|||||||
const attachmentService = createAttachmentService(db, config, modelService, visionDebugService);
|
const attachmentService = createAttachmentService(db, config, modelService, visionDebugService);
|
||||||
const chatService = createChatService(repositories, modelService, config, undefined, attachmentService, visionDebugService);
|
const chatService = createChatService(repositories, modelService, config, undefined, attachmentService, visionDebugService);
|
||||||
const FileStore = FileStoreFactory(session);
|
const FileStore = FileStoreFactory(session);
|
||||||
const authRateLimiters = createAuthRateLimiters(config);
|
const rateLimitStore = createSqliteRateLimitStore(db);
|
||||||
const chatRateLimiters = createChatRateLimiters(config);
|
const authRateLimiters = createAuthRateLimiters(config, rateLimitStore);
|
||||||
|
const chatRateLimiters = createChatRateLimiters(config, rateLimitStore);
|
||||||
closeHandlers.push(
|
closeHandlers.push(
|
||||||
...authRateLimiters.map((limiter) => limiter.close),
|
...authRateLimiters.map((limiter) => limiter.close),
|
||||||
...chatRateLimiters.map((limiter) => limiter.close),
|
...chatRateLimiters.map((limiter) => limiter.close),
|
||||||
@@ -334,6 +337,7 @@ export function createApp(options = {}) {
|
|||||||
if (config.visionDebug) app.use(createVisionDebugRouter({visionDebugService, repositories}));
|
if (config.visionDebug) app.use(createVisionDebugRouter({visionDebugService, repositories}));
|
||||||
app.use(createSettingsRouter({repositories}));
|
app.use(createSettingsRouter({repositories}));
|
||||||
app.use(createChatsRouter({repositories, chatService, modelService, config, chatRateLimiters, attachmentService}));
|
app.use(createChatsRouter({repositories, chatService, modelService, config, chatRateLimiters, attachmentService}));
|
||||||
|
app.use(createWorkspaceTransferRouter({repositories, chatService, config, attachmentService}));
|
||||||
app.use(createSystemRouter({modelService, config}));
|
app.use(createSystemRouter({modelService, config}));
|
||||||
app.use(notFoundHandler);
|
app.use(notFoundHandler);
|
||||||
app.use(errorHandler);
|
app.use(errorHandler);
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ function pruneBucket(bucket, windowMs, now) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createRateLimiter({windowMs, max, keyGenerator, message, cleanupIntervalMs = 60 * 1000}) {
|
export function createRateLimiter({windowMs, max, keyGenerator, message, cleanupIntervalMs = 60 * 1000, store = null}) {
|
||||||
const hits = new Map();
|
const hits = new Map();
|
||||||
let cleanupTimer = null;
|
let cleanupTimer = null;
|
||||||
|
|
||||||
@@ -22,7 +22,7 @@ export function createRateLimiter({windowMs, max, keyGenerator, message, cleanup
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (cleanupIntervalMs > 0) {
|
if (!store && cleanupIntervalMs > 0) {
|
||||||
cleanupTimer = setInterval(cleanup, cleanupIntervalMs);
|
cleanupTimer = setInterval(cleanup, cleanupIntervalMs);
|
||||||
// Der Aufräumtimer allein soll den Node-Prozess beim Herunterfahren nicht offen halten.
|
// Der Aufräumtimer allein soll den Node-Prozess beim Herunterfahren nicht offen halten.
|
||||||
cleanupTimer.unref();
|
cleanupTimer.unref();
|
||||||
@@ -35,6 +35,10 @@ export function createRateLimiter({windowMs, max, keyGenerator, message, cleanup
|
|||||||
}
|
}
|
||||||
|
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
|
if (store) {
|
||||||
|
if (!store.consume(key, now, windowMs, max)) return res.status(429).json({error: message});
|
||||||
|
return next();
|
||||||
|
}
|
||||||
const bucket = hits.get(key) || [];
|
const bucket = hits.get(key) || [];
|
||||||
pruneBucket(bucket, windowMs, now);
|
pruneBucket(bucket, windowMs, now);
|
||||||
if (!bucket.length) {
|
if (!bucket.length) {
|
||||||
@@ -59,3 +63,24 @@ export function createRateLimiter({windowMs, max, keyGenerator, message, cleanup
|
|||||||
|
|
||||||
return limiter;
|
return limiter;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SQLite is already shared by all application instances and serializes this
|
||||||
|
// short transaction, so the check-and-increment remains atomic across workers.
|
||||||
|
export function createSqliteRateLimitStore(db) {
|
||||||
|
db.exec(`CREATE TABLE IF NOT EXISTS rate_limit_hits (
|
||||||
|
bucket_key TEXT NOT NULL,
|
||||||
|
hit_at INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS rate_limit_hits_expiry ON rate_limit_hits(hit_at);`);
|
||||||
|
db.pragma("busy_timeout = 5000");
|
||||||
|
const prune = db.prepare("DELETE FROM rate_limit_hits WHERE bucket_key = ? AND hit_at <= ?");
|
||||||
|
const count = db.prepare("SELECT COUNT(*) AS count FROM rate_limit_hits WHERE bucket_key = ?");
|
||||||
|
const insert = db.prepare("INSERT INTO rate_limit_hits (bucket_key, hit_at) VALUES (?, ?)");
|
||||||
|
const consume = db.transaction((key, now, windowMs, max) => {
|
||||||
|
prune.run(key, now - windowMs);
|
||||||
|
if (count.get(key).count >= max) return false;
|
||||||
|
insert.run(key, now);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
return {consume};
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,12 +3,13 @@
|
|||||||
*/
|
*/
|
||||||
import {createRateLimiter} from "./rateLimit.js";
|
import {createRateLimiter} from "./rateLimit.js";
|
||||||
|
|
||||||
export function createAuthRateLimiters(config) {
|
export function createAuthRateLimiters(config, store = null) {
|
||||||
return [
|
return [
|
||||||
createRateLimiter({
|
createRateLimiter({
|
||||||
windowMs: config.authRateLimitWindowMs,
|
windowMs: config.authRateLimitWindowMs,
|
||||||
max: config.authRateLimitMaxPerIp,
|
max: config.authRateLimitMaxPerIp,
|
||||||
cleanupIntervalMs: config.rateLimitCleanupIntervalMs,
|
cleanupIntervalMs: config.rateLimitCleanupIntervalMs,
|
||||||
|
store,
|
||||||
keyGenerator: (req) => `auth:ip:${req.ip}`,
|
keyGenerator: (req) => `auth:ip:${req.ip}`,
|
||||||
message: "Too many authentication attempts from this IP. Please try again later."
|
message: "Too many authentication attempts from this IP. Please try again later."
|
||||||
}),
|
}),
|
||||||
@@ -16,6 +17,7 @@ export function createAuthRateLimiters(config) {
|
|||||||
windowMs: config.authRateLimitWindowMs,
|
windowMs: config.authRateLimitWindowMs,
|
||||||
max: config.authRateLimitMaxPerAccount,
|
max: config.authRateLimitMaxPerAccount,
|
||||||
cleanupIntervalMs: config.rateLimitCleanupIntervalMs,
|
cleanupIntervalMs: config.rateLimitCleanupIntervalMs,
|
||||||
|
store,
|
||||||
keyGenerator: (req) => {
|
keyGenerator: (req) => {
|
||||||
const username = String(req.body?.username || "").trim().toLowerCase();
|
const username = String(req.body?.username || "").trim().toLowerCase();
|
||||||
return username ? `auth:user:${username}` : null;
|
return username ? `auth:user:${username}` : null;
|
||||||
@@ -25,12 +27,13 @@ export function createAuthRateLimiters(config) {
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createChatRateLimiters(config) {
|
export function createChatRateLimiters(config, store = null) {
|
||||||
return [
|
return [
|
||||||
createRateLimiter({
|
createRateLimiter({
|
||||||
windowMs: config.chatRateLimitWindowMs,
|
windowMs: config.chatRateLimitWindowMs,
|
||||||
max: config.chatRateLimitMaxPerIp,
|
max: config.chatRateLimitMaxPerIp,
|
||||||
cleanupIntervalMs: config.rateLimitCleanupIntervalMs,
|
cleanupIntervalMs: config.rateLimitCleanupIntervalMs,
|
||||||
|
store,
|
||||||
keyGenerator: (req) => `chat:ip:${req.ip}`,
|
keyGenerator: (req) => `chat:ip:${req.ip}`,
|
||||||
message: "Too many chat requests from this IP. Please slow down."
|
message: "Too many chat requests from this IP. Please slow down."
|
||||||
}),
|
}),
|
||||||
@@ -38,6 +41,7 @@ export function createChatRateLimiters(config) {
|
|||||||
windowMs: config.chatRateLimitWindowMs,
|
windowMs: config.chatRateLimitWindowMs,
|
||||||
max: config.chatRateLimitMaxPerAccount,
|
max: config.chatRateLimitMaxPerAccount,
|
||||||
cleanupIntervalMs: config.rateLimitCleanupIntervalMs,
|
cleanupIntervalMs: config.rateLimitCleanupIntervalMs,
|
||||||
|
store,
|
||||||
keyGenerator: (req) => req.session.user ? `chat:user:${req.session.user}` : null,
|
keyGenerator: (req) => req.session.user ? `chat:user:${req.session.user}` : null,
|
||||||
message: "Too many chat requests for this account. Please slow down."
|
message: "Too many chat requests for this account. Please slow down."
|
||||||
})
|
})
|
||||||
|
|||||||
+11
-127
@@ -5,11 +5,8 @@ import express from "express";
|
|||||||
import {createTextStreamEvents} from "../services/streamEvents.js";
|
import {createTextStreamEvents} from "../services/streamEvents.js";
|
||||||
import {asyncHandler} from "../middleware/asyncHandler.js";
|
import {asyncHandler} from "../middleware/asyncHandler.js";
|
||||||
import {generationCancellation} from "../middleware/generationCancellation.js";
|
import {generationCancellation} from "../middleware/generationCancellation.js";
|
||||||
import multer from "multer";
|
|
||||||
import {requireLogin} from "../middleware/auth.js";
|
import {requireLogin} from "../middleware/auth.js";
|
||||||
import {validateBody} from "../middleware/validate.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";
|
import {getPersonality, listPersonalities, DEFAULT_PERSONALITY_ID} from "../services/personalityRegistry.js";
|
||||||
|
|
||||||
const createChatValidator = (body) => ({
|
const createChatValidator = (body) => ({
|
||||||
@@ -36,11 +33,13 @@ const sendMessageValidator = (body) => {
|
|||||||
if (!Array.isArray(attachments) || attachments.some(id => typeof id !== "string" || !/^[a-f0-9-]{36}$/.test(id))) return {error: "Invalid attachment IDs"};
|
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 message = String(body?.message || "").trim() || (attachments.length ? "Describe the attached images." : "");
|
||||||
const chatId = Number(body?.chatId);
|
const chatId = Number(body?.chatId);
|
||||||
|
const userMessageId = body?.userMessageId == null ? null : Number(body.userMessageId);
|
||||||
|
if (userMessageId != null && (!Number.isInteger(userMessageId) || userMessageId < 1)) return {error: "Invalid message ID"};
|
||||||
if (!chatId) return {error: "chatId is required"};
|
if (!chatId) return {error: "chatId is required"};
|
||||||
if (!message) return {error: "message is required"};
|
if (!message) return {error: "message is required"};
|
||||||
const preferences = searchPreferencesValidator(body);
|
const preferences = searchPreferencesValidator(body);
|
||||||
if (preferences.error) return preferences;
|
if (preferences.error) return preferences;
|
||||||
return {value: {message, chatId, attachments, model: String(body?.model || "qwen3:14b"), ...preferences.value}};
|
return {value: {message, chatId, attachments, userMessageId, model: String(body?.model || "qwen3:14b"), ...preferences.value}};
|
||||||
};
|
};
|
||||||
|
|
||||||
const organizationValidator = (body) => ({
|
const organizationValidator = (body) => ({
|
||||||
@@ -124,29 +123,9 @@ const userPersonaValidator = (body) => {
|
|||||||
return {value: {userPersonaId}};
|
return {value: {userPersonaId}};
|
||||||
};
|
};
|
||||||
|
|
||||||
export function createChatsRouter({repositories, chatService, modelService, config, chatRateLimiters, attachmentService}) {
|
export function createChatsRouter({repositories, chatService, modelService, config, chatRateLimiters}) {
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
router.use(requireLogin);
|
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) => {
|
router.use(["/chats/:id/messages/by-index/:index", "/chats/:id/messages/:messageId"], (req, res, next) => {
|
||||||
const chatId = Number(req.params.id);
|
const chatId = Number(req.params.id);
|
||||||
const messageId = req.params.messageId ? Number(req.params.messageId) : chatService.getChatMessageByIndex(chatId, req.params.index)?.id;
|
const messageId = req.params.messageId ? Number(req.params.messageId) : chatService.getChatMessageByIndex(chatId, req.params.index)?.id;
|
||||||
@@ -496,8 +475,12 @@ export function createChatsRouter({repositories, chatService, modelService, conf
|
|||||||
if (!chat) return res.status(404).json({error: "Chat not found"});
|
if (!chat) return res.status(404).json({error: "Chat not found"});
|
||||||
const payload = chatService.buildMessagesPayload({user: req.session.user, chatId, session: chat});
|
const payload = chatService.buildMessagesPayload({user: req.session.user, chatId, session: chat});
|
||||||
const characters = payload.messagesPayload.reduce((total, message) => total + String(message.content || "").length, 0);
|
const characters = payload.messagesPayload.reduce((total, message) => total + String(message.content || "").length, 0);
|
||||||
|
const summaryMessageId = Number(chat.context_summary_message_id || 0);
|
||||||
|
const summarySources = summaryMessageId ? chatService.listActiveBranchMessages(chatId)
|
||||||
|
.filter(message => message.id <= summaryMessageId && message.deliveryStatus === "complete")
|
||||||
|
.map(({id, role, content}) => ({id, role, content})) : [];
|
||||||
return res.json({memories: repositories.listChatMemories(chatId, req.session.user), summary: chat.context_summary || "",
|
return res.json({memories: repositories.listChatMemories(chatId, req.session.user), summary: chat.context_summary || "",
|
||||||
summaryMessageId: chat.context_summary_message_id || 0, summaryMaxChars: config.chatSummaryMaxChars,
|
summaryMessageId, summarySources, summaryMaxChars: config.chatSummaryMaxChars,
|
||||||
context: {estimatedTokens: Math.ceil(characters / 4), contextLength: chat.context_length || null,
|
context: {estimatedTokens: Math.ceil(characters / 4), contextLength: chat.context_length || null,
|
||||||
recentMessageLimit: config.chatHistoryLimit, memoryFactLimit: 30}});
|
recentMessageLimit: config.chatHistoryLimit, memoryFactLimit: 30}});
|
||||||
});
|
});
|
||||||
@@ -566,6 +549,7 @@ export function createChatsRouter({repositories, chatService, modelService, conf
|
|||||||
chatId: req.validatedBody.chatId,
|
chatId: req.validatedBody.chatId,
|
||||||
message: req.validatedBody.message,
|
message: req.validatedBody.message,
|
||||||
attachments: req.validatedBody.attachments,
|
attachments: req.validatedBody.attachments,
|
||||||
|
userMessageId: req.validatedBody.userMessageId,
|
||||||
model: req.validatedBody.model,
|
model: req.validatedBody.model,
|
||||||
webSearch: req.validatedBody.webSearch,
|
webSearch: req.validatedBody.webSearch,
|
||||||
reasoning: req.validatedBody.reasoning
|
reasoning: req.validatedBody.reasoning
|
||||||
@@ -599,6 +583,7 @@ export function createChatsRouter({repositories, chatService, modelService, conf
|
|||||||
chatId: req.validatedBody.chatId,
|
chatId: req.validatedBody.chatId,
|
||||||
message: req.validatedBody.message,
|
message: req.validatedBody.message,
|
||||||
attachments: req.validatedBody.attachments,
|
attachments: req.validatedBody.attachments,
|
||||||
|
userMessageId: req.validatedBody.userMessageId,
|
||||||
model: req.validatedBody.model,
|
model: req.validatedBody.model,
|
||||||
...createTextStreamEvents(sendEvent),
|
...createTextStreamEvents(sendEvent),
|
||||||
webSearch: req.validatedBody.webSearch,
|
webSearch: req.validatedBody.webSearch,
|
||||||
@@ -708,107 +693,6 @@ export function createChatsRouter({repositories, chatService, modelService, conf
|
|||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
|
|
||||||
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)}));
|
router.get("/chat/history", (req, res) => res.json({history: repositories.listChats(req.session.user)}));
|
||||||
|
|
||||||
return router;
|
return router;
|
||||||
|
|||||||
@@ -0,0 +1,92 @@
|
|||||||
|
import express from "express";
|
||||||
|
import multer from "multer";
|
||||||
|
import {requireLogin} from "../middleware/auth.js";
|
||||||
|
import {asyncHandler} from "../middleware/asyncHandler.js";
|
||||||
|
import {validateWorkspaceImport} from "../services/workspaceImportService.js";
|
||||||
|
import {createWorkspaceArchive, parseWorkspaceArchive, MAX_ARCHIVE_BYTES} from "../services/workspaceArchive.js";
|
||||||
|
|
||||||
|
export function createWorkspaceTransferRouter({repositories, chatService, 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.get("/exports/chats/:id", (req, res) => {
|
||||||
|
const chatId = Number(req.params.id), 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});
|
||||||
|
const markdown = format === "md";
|
||||||
|
const body = markdown
|
||||||
|
? [`# ${chat.title}`, "", ...messages.flatMap(message => [`## ${message.role === "user" ? "You" : "Assistant"}`, "", message.content, ""])].join("\n")
|
||||||
|
: [`Chat: ${chat.title}`, "", ...messages.flatMap(message => [`${message.role === "user" ? "You" : "Assistant"}:`, message.content, ""])].join("\n");
|
||||||
|
res.type(markdown ? "text/markdown" : "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), 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)}));
|
||||||
|
return res.json({imported: repositories.importWorkspace(req.session.user, validation.workspace), 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});
|
||||||
|
});
|
||||||
|
return router;
|
||||||
|
}
|
||||||
+30
-53
@@ -10,9 +10,6 @@ import {
|
|||||||
containsUserVoiceInRoleplayOpener
|
containsUserVoiceInRoleplayOpener
|
||||||
} from "./personaService.js";
|
} from "./personaService.js";
|
||||||
import {
|
import {
|
||||||
buildContextSummaryPrompt,
|
|
||||||
buildMemoryPrompt,
|
|
||||||
buildPersonaStatePrompt,
|
|
||||||
clampRetryActiveIndex,
|
clampRetryActiveIndex,
|
||||||
formatChatMessage as formatBaseChatMessage,
|
formatChatMessage as formatBaseChatMessage,
|
||||||
formatMessagesForSummary,
|
formatMessagesForSummary,
|
||||||
@@ -21,10 +18,10 @@ import {
|
|||||||
} from "./chatMessageHelpers.js";
|
} from "./chatMessageHelpers.js";
|
||||||
import {createHash} from "node:crypto";
|
import {createHash} from "node:crypto";
|
||||||
|
|
||||||
import { SearXNGSearchProvider, WebSearchError, buildWebSearchContext, decideWebSearch, retrieveWebSearch } from "./webSearchService.js";
|
import { SearXNGSearchProvider, WebSearchError, decideWebSearch, retrieveWebSearch } from "./webSearchService.js";
|
||||||
import { supportsReasoning } from "../../public/js/modelCapabilities.js";
|
import { supportsReasoning } from "../../public/js/modelCapabilities.js";
|
||||||
import {VISION_EVIDENCE_GUIDANCE} from "./visionAnalysis.js";
|
import {VISION_EVIDENCE_GUIDANCE} from "./visionAnalysis.js";
|
||||||
import {getPersonalitySystemPrompt} from "./personalityRegistry.js";
|
import {createConversationContextBuilder} from "./conversationContext.js";
|
||||||
|
|
||||||
export function createChatService(repositories, modelService, config, webSearchProvider = new SearXNGSearchProvider(config), attachmentService = null, visionDebugService = null) {
|
export function createChatService(repositories, modelService, config, webSearchProvider = new SearXNGSearchProvider(config), attachmentService = null, visionDebugService = null) {
|
||||||
const formatChatMessage = (row, related = null) => {
|
const formatChatMessage = (row, related = null) => {
|
||||||
@@ -298,45 +295,7 @@ export function createChatService(repositories, modelService, config, webSearchP
|
|||||||
return repositories.getActiveUserPersona(user);
|
return repositories.getActiveUserPersona(user);
|
||||||
};
|
};
|
||||||
|
|
||||||
const buildConversationContext = ({ user, chatId, session = null, message, upToMessageId = null, sources = null, historyOverride = null, visionSelection = null, contextSummaryOverride = undefined }) => {
|
const buildConversationContext = createConversationContextBuilder({repositories, attachmentService, now});
|
||||||
const activeSession = session || repositories.getChat(chatId, user);
|
|
||||||
if (!activeSession) return null;
|
|
||||||
const activePersona = repositories.getAssistantPersonaForChat(chatId, user);
|
|
||||||
const activeUserPersona = getUserPersonaForSession(chatId, user, activeSession, activePersona);
|
|
||||||
const history = historyOverride || (upToMessageId == null
|
|
||||||
? repositories.getRecentChatMessages(chatId).reverse()
|
|
||||||
: repositories.getRecentChatMessagesUpToId(chatId, upToMessageId).reverse());
|
|
||||||
const conversation = message == null ? history : [...history, { role: "user", content: message }];
|
|
||||||
const messagesPayload = [];
|
|
||||||
// Result material remains a separate untrusted user-context message. The
|
|
||||||
// grounding rule is added after mutable persona/chat prompts so it remains
|
|
||||||
// the final trusted instruction governing the supplied references.
|
|
||||||
const webSearchContext = sources === null ? null : buildWebSearchContext(sources, now());
|
|
||||||
if (activeSession.system_instruction) {
|
|
||||||
messagesPayload.push({ role: "system", content: `CHAT-SPECIFIC INSTRUCTIONS:\n${activeSession.system_instruction}` });
|
|
||||||
}
|
|
||||||
messagesPayload.push({role: "system", content: getPersonalitySystemPrompt(activeSession.personality_id)});
|
|
||||||
const contextSummaryPrompt = buildContextSummaryPrompt(contextSummaryOverride === undefined
|
|
||||||
? (upToMessageId == null ? activeSession.context_summary : null) : contextSummaryOverride);
|
|
||||||
if (contextSummaryPrompt) messagesPayload.push({ role: "system", content: contextSummaryPrompt });
|
|
||||||
const memoryPrompt = buildMemoryPrompt(repositories.listChatMemories(chatId, user));
|
|
||||||
if (memoryPrompt) messagesPayload.push({ role: "system", content: memoryPrompt });
|
|
||||||
const personaStatePrompt = buildPersonaStatePrompt(repositories.getPersonaChatState(chatId, user));
|
|
||||||
if (personaStatePrompt) messagesPayload.push({ role: "system", content: `Conversation continuity notes (context only):\n${personaStatePrompt}` });
|
|
||||||
const conversationMessages = conversation.map((entry) => ({
|
|
||||||
role: entry.role === "bot" ? "assistant" : "user",
|
|
||||||
content: entry.content
|
|
||||||
}));
|
|
||||||
messagesPayload.push(...conversationMessages.slice(0, -1));
|
|
||||||
const currentQuestion = conversation.at(-1)?.content || "";
|
|
||||||
const visionContext = attachmentService?.context(visionSelection || attachmentService.selectImages(
|
|
||||||
message == null ? history : [...history, {role: "user", content: message}], currentQuestion), currentQuestion);
|
|
||||||
if (visionContext) messagesPayload.push(...visionContext);
|
|
||||||
if (webSearchContext) messagesPayload.push(webSearchContext[0]);
|
|
||||||
if (webSearchContext?.[1]) messagesPayload.push(webSearchContext[1]);
|
|
||||||
if (conversationMessages.length) messagesPayload.push(conversationMessages.at(-1));
|
|
||||||
return { session: activeSession, activePersona, activeUserPersona, messagesPayload };
|
|
||||||
};
|
|
||||||
|
|
||||||
const buildRetryPayload = async ({ chatId, user, session, selectedModel, targetMessage, retryStyle = null, signal = null, generation = {}, webSearch = true, reasoning = false, onChunk = null, onThinking = null, onStatus = null, requestId = null }) => {
|
const buildRetryPayload = async ({ chatId, user, session, selectedModel, targetMessage, retryStyle = null, signal = null, generation = {}, webSearch = true, reasoning = false, onChunk = null, onThinking = null, onStatus = null, requestId = null }) => {
|
||||||
let thinking = "";
|
let thinking = "";
|
||||||
@@ -432,7 +391,7 @@ export function createChatService(repositories, modelService, config, webSearchP
|
|||||||
return { fullReply, thinking, sources, searchState, promptMessageId: promptMessage.id };
|
return { fullReply, thinking, sources, searchState, promptMessageId: promptMessage.id };
|
||||||
};
|
};
|
||||||
|
|
||||||
const generateChatTurn = ({ user, chatId, message, model, attachments = [], reuseChatId = null, webSearch = true, reasoning = false, onStatus = null, onChunk = null, onThinking = null, signal = null, parentMessageId = undefined, requestId = null }) =>
|
const generateChatTurn = ({ user, chatId, message, model, attachments = [], reuseChatId = null, userMessageId: resumeMessageId = null, webSearch = true, reasoning = false, onStatus = null, onChunk = null, onThinking = null, signal = null, parentMessageId = undefined, requestId = null }) =>
|
||||||
withChatGenerationLock(chatId, async () => {
|
withChatGenerationLock(chatId, async () => {
|
||||||
await (summaryQueues.get(chatId) || Promise.resolve());
|
await (summaryQueues.get(chatId) || Promise.resolve());
|
||||||
const session = repositories.getChat(chatId, user);
|
const session = repositories.getChat(chatId, user);
|
||||||
@@ -442,7 +401,18 @@ export function createChatService(repositories, modelService, config, webSearchP
|
|||||||
session.context_summary = null;
|
session.context_summary = null;
|
||||||
session.context_summary_message_id = 0;
|
session.context_summary_message_id = 0;
|
||||||
}
|
}
|
||||||
const effectiveParentId = parentMessageId === undefined
|
const resumedMessage = resumeMessageId == null ? null : repositories.getChatMessage(chatId, resumeMessageId);
|
||||||
|
if (resumeMessageId != null && (!resumedMessage || resumedMessage.role !== "user" || !["pending", "failed"].includes(resumedMessage.delivery_status))) {
|
||||||
|
return {error: "This message cannot be resumed", status: 409};
|
||||||
|
}
|
||||||
|
if (resumedMessage?.delivery_status === "pending") {
|
||||||
|
const createdAt = Date.parse(`${String(resumedMessage.created_at).replace(" ", "T")}Z`);
|
||||||
|
const staleAfterMs = Number(config.modelMaxGenerationMs || 600000) + 60000;
|
||||||
|
if (Number.isFinite(createdAt) && new Date(now()).getTime() - createdAt < staleAfterMs) {
|
||||||
|
return {error: "This generation may still be running. Wait a little longer before resuming it.", status: 409};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const effectiveParentId = resumedMessage ? resumedMessage.parent_message_id : parentMessageId === undefined
|
||||||
? session.active_leaf_message_id ?? repositories.getLatestChatMessage(chatId)?.id ?? null
|
? session.active_leaf_message_id ?? repositories.getLatestChatMessage(chatId)?.id ?? null
|
||||||
: parentMessageId;
|
: parentMessageId;
|
||||||
if (effectiveParentId != null && !repositories.getChatMessage(chatId, effectiveParentId)) {
|
if (effectiveParentId != null && !repositories.getChatMessage(chatId, effectiveParentId)) {
|
||||||
@@ -451,9 +421,16 @@ export function createChatService(repositories, modelService, config, webSearchP
|
|||||||
const { model: effectiveModel, generation } = getGenerationSettings(session, model);
|
const { model: effectiveModel, generation } = getGenerationSettings(session, model);
|
||||||
const useReasoning = reasoning === true && supportsReasoning(effectiveModel);
|
const useReasoning = reasoning === true && supportsReasoning(effectiveModel);
|
||||||
signal?.throwIfAborted();
|
signal?.throwIfAborted();
|
||||||
const insert = () => repositories.insertBranchChatMessage(chatId, "user", message, null, null, "pending", null, effectiveParentId);
|
let userMessageId;
|
||||||
const inserted = attachmentService ? attachmentService.createMessage(attachments, user, chatId, insert, reuseChatId) : insert();
|
if (resumedMessage) {
|
||||||
const userMessageId = Number(inserted?.lastInsertRowid);
|
userMessageId = Number(resumedMessage.id);
|
||||||
|
repositories.updateChatMessageDelivery(chatId, userMessageId, "pending", null);
|
||||||
|
repositories.updateChatActiveLeaf(chatId, user, userMessageId);
|
||||||
|
} else {
|
||||||
|
const insert = () => repositories.insertBranchChatMessage(chatId, "user", message, null, null, "pending", null, effectiveParentId);
|
||||||
|
const inserted = attachmentService ? attachmentService.createMessage(attachments, user, chatId, insert, reuseChatId) : insert();
|
||||||
|
userMessageId = Number(inserted?.lastInsertRowid);
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
let sources = null;
|
let sources = null;
|
||||||
let searchState = "skipped";
|
let searchState = "skipped";
|
||||||
@@ -677,11 +654,11 @@ export function createChatService(repositories, modelService, config, webSearchP
|
|||||||
},
|
},
|
||||||
persistChatMessageRetryState,
|
persistChatMessageRetryState,
|
||||||
buildMessagesPayload: buildConversationContext,
|
buildMessagesPayload: buildConversationContext,
|
||||||
async sendChatMessage({ user, chatId, message, model, attachments = [], webSearch = true, reasoning = false, signal = null, requestId = null }) {
|
async sendChatMessage({ user, chatId, message, model, attachments = [], userMessageId = null, webSearch = true, reasoning = false, signal = null, requestId = null }) {
|
||||||
return generateChatTurn({ user, chatId, message, model, attachments, webSearch, reasoning, signal, requestId });
|
return generateChatTurn({ user, chatId, message, model, attachments, userMessageId, webSearch, reasoning, signal, requestId });
|
||||||
},
|
},
|
||||||
async streamChatMessage({ user, chatId, message, model, attachments = [], webSearch = true, reasoning = false, onStatus, onChunk, onThinking, signal = null, requestId = null }) {
|
async streamChatMessage({ user, chatId, message, model, attachments = [], userMessageId = null, webSearch = true, reasoning = false, onStatus, onChunk, onThinking, signal = null, requestId = null }) {
|
||||||
return generateChatTurn({ user, chatId, message, model, attachments, webSearch, reasoning, onStatus, onChunk, onThinking, signal, requestId });
|
return generateChatTurn({ user, chatId, message, model, attachments, userMessageId, webSearch, reasoning, onStatus, onChunk, onThinking, signal, requestId });
|
||||||
},
|
},
|
||||||
isGenerating: (chatId) => chatGenerationQueues.has(chatId),
|
isGenerating: (chatId) => chatGenerationQueues.has(chatId),
|
||||||
isRegeneratingMessage: (chatId, messageId) => regeneratingMessages.get(chatId) === messageId,
|
isRegeneratingMessage: (chatId, messageId) => regeneratingMessages.get(chatId) === messageId,
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import {
|
||||||
|
buildContextSummaryPrompt,
|
||||||
|
buildMemoryPrompt,
|
||||||
|
buildPersonaStatePrompt
|
||||||
|
} from "./chatMessageHelpers.js";
|
||||||
|
import {buildWebSearchContext} from "./webSearchService.js";
|
||||||
|
import {getPersonalitySystemPrompt} from "./personalityRegistry.js";
|
||||||
|
|
||||||
|
export function createConversationContextBuilder({repositories, attachmentService, now}) {
|
||||||
|
const getUserPersonaForSession = (chatId, user, activePersona) => {
|
||||||
|
const chatPersona = repositories.getUserPersonaForChat(chatId, user);
|
||||||
|
if (chatPersona || activePersona) return chatPersona || null;
|
||||||
|
return repositories.getActiveUserPersona(user);
|
||||||
|
};
|
||||||
|
|
||||||
|
return ({
|
||||||
|
user,
|
||||||
|
chatId,
|
||||||
|
session = null,
|
||||||
|
message,
|
||||||
|
upToMessageId = null,
|
||||||
|
sources = null,
|
||||||
|
historyOverride = null,
|
||||||
|
visionSelection = null,
|
||||||
|
contextSummaryOverride = undefined
|
||||||
|
}) => {
|
||||||
|
const activeSession = session || repositories.getChat(chatId, user);
|
||||||
|
if (!activeSession) return null;
|
||||||
|
const activePersona = repositories.getAssistantPersonaForChat(chatId, user);
|
||||||
|
const activeUserPersona = getUserPersonaForSession(chatId, user, activePersona);
|
||||||
|
const history = historyOverride || (upToMessageId == null
|
||||||
|
? repositories.getRecentChatMessages(chatId).reverse()
|
||||||
|
: repositories.getRecentChatMessagesUpToId(chatId, upToMessageId).reverse());
|
||||||
|
const conversation = message == null ? history : [...history, {role: "user", content: message}];
|
||||||
|
const messagesPayload = [];
|
||||||
|
// Search results stay untrusted user context, followed by their grounding rule.
|
||||||
|
const webSearchContext = sources === null ? null : buildWebSearchContext(sources, now());
|
||||||
|
if (activeSession.system_instruction) messagesPayload.push({
|
||||||
|
role: "system",
|
||||||
|
content: `CHAT-SPECIFIC INSTRUCTIONS:\n${activeSession.system_instruction}`
|
||||||
|
});
|
||||||
|
messagesPayload.push({role: "system", content: getPersonalitySystemPrompt(activeSession.personality_id)});
|
||||||
|
const summary = buildContextSummaryPrompt(contextSummaryOverride === undefined
|
||||||
|
? (upToMessageId == null ? activeSession.context_summary : null) : contextSummaryOverride);
|
||||||
|
if (summary) messagesPayload.push({role: "system", content: summary});
|
||||||
|
const memory = buildMemoryPrompt(repositories.listChatMemories(chatId, user));
|
||||||
|
if (memory) messagesPayload.push({role: "system", content: memory});
|
||||||
|
const personaState = buildPersonaStatePrompt(repositories.getPersonaChatState(chatId, user));
|
||||||
|
if (personaState) messagesPayload.push({
|
||||||
|
role: "system",
|
||||||
|
content: `Conversation continuity notes (context only):\n${personaState}`
|
||||||
|
});
|
||||||
|
const conversationMessages = conversation.map(entry => ({
|
||||||
|
role: entry.role === "bot" ? "assistant" : "user",
|
||||||
|
content: entry.content
|
||||||
|
}));
|
||||||
|
messagesPayload.push(...conversationMessages.slice(0, -1));
|
||||||
|
const currentQuestion = conversation.at(-1)?.content || "";
|
||||||
|
const selectedImages = visionSelection || attachmentService?.selectImages(
|
||||||
|
message == null ? history : [...history, {role: "user", content: message}], currentQuestion);
|
||||||
|
const visionContext = attachmentService?.context(selectedImages, currentQuestion);
|
||||||
|
if (visionContext) messagesPayload.push(...visionContext);
|
||||||
|
if (webSearchContext) messagesPayload.push(webSearchContext[0]);
|
||||||
|
if (webSearchContext?.[1]) messagesPayload.push(webSearchContext[1]);
|
||||||
|
if (conversationMessages.length) messagesPayload.push(conversationMessages.at(-1));
|
||||||
|
return {session: activeSession, activePersona, activeUserPersona, messagesPayload};
|
||||||
|
};
|
||||||
|
}
|
||||||
+4
-1
@@ -235,9 +235,12 @@ test("conversation memory exposes context estimates and supports validated summa
|
|||||||
const repo = server.app.locals.repositories;
|
const repo = server.app.locals.repositories;
|
||||||
const chat = repo.createChat("memory-editor", "Memory");
|
const chat = repo.createChat("memory-editor", "Memory");
|
||||||
repo.createChatMemory("memory-editor", chat, "Prefers concise answers.");
|
repo.createChatMemory("memory-editor", chat, "Prefers concise answers.");
|
||||||
repo.updateChatContextSummary(chat.id, "memory-editor", "Old summary", 0);
|
const userMessage = repo.insertChatMessage(chat.id, "user", "I prefer short answers.");
|
||||||
|
const assistantMessage = repo.insertChatMessage(chat.id, "bot", "I will keep them concise.", null, null, "complete", null, Number(userMessage.lastInsertRowid));
|
||||||
|
repo.updateChatContextSummary(chat.id, "memory-editor", "Old summary", Number(assistantMessage.lastInsertRowid));
|
||||||
const loaded = (await request(server.baseUrl, `/chats/${chat.id}/memories`, {jar})).json;
|
const loaded = (await request(server.baseUrl, `/chats/${chat.id}/memories`, {jar})).json;
|
||||||
assert.equal(loaded.summary, "Old summary"); assert.ok(loaded.context.estimatedTokens > 0);
|
assert.equal(loaded.summary, "Old summary"); assert.ok(loaded.context.estimatedTokens > 0);
|
||||||
|
assert.deepEqual(loaded.summarySources.map(message => message.content), ["I prefer short answers.", "I will keep them concise."]);
|
||||||
const update = body => request(server.baseUrl, `/chats/${chat.id}/summary`, {method: "PUT", jar, body});
|
const update = body => request(server.baseUrl, `/chats/${chat.id}/summary`, {method: "PUT", jar, body});
|
||||||
assert.equal((await update({summary: "Corrected summary", previousSummary: "Old summary"})).status, 200);
|
assert.equal((await update({summary: "Corrected summary", previousSummary: "Old summary"})).status, 200);
|
||||||
assert.equal(repo.getChat(chat.id, "memory-editor").context_summary, "Corrected summary");
|
assert.equal(repo.getChat(chat.id, "memory-editor").context_summary, "Corrected summary");
|
||||||
|
|||||||
@@ -75,7 +75,7 @@ async function setup(t, {visionResult = null, replyResult = null, visionModel =
|
|||||||
const image = format => sharp({create: {width: 20, height: 10, channels: 3, background: "blue"}}).toFormat(format).toBuffer();
|
const image = format => sharp({create: {width: 20, height: 10, channels: 3, background: "blue"}}).toFormat(format).toBuffer();
|
||||||
const chat = async () => (await (await api("/chats", {title: "Image test"})).json()).chat.id;
|
const chat = async () => (await (await api("/chats", {title: "Image test"})).json()).chat.id;
|
||||||
return {app, api, upload, image, register, directory, requests, order, searchQueries, chat,
|
return {app, api, upload, image, register, directory, requests, order, searchQueries, chat,
|
||||||
failVision: () => { visionFailure = true; }, holdVision: () => { holdVision = true; return visionStarted; },
|
failVision: () => { visionFailure = true; }, recoverVision: () => { visionFailure = false; }, holdVision: () => { holdVision = true; return visionStarted; },
|
||||||
get visionAborted() { return visionAborted; }};
|
get visionAborted() { return visionAborted; }};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -239,6 +239,26 @@ test("vision failure prevents an ungrounded answer and malformed JSON falls back
|
|||||||
assert.throws(() => parseVisionAnalysis(""));
|
assert.throws(() => parseVisionAnalysis(""));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("resuming a failed image turn reuses its branch node and attachment", async t => {
|
||||||
|
const s = await setup(t);
|
||||||
|
const attachment = await (await s.upload(await s.image("png"))).json();
|
||||||
|
const chatId = await s.chat();
|
||||||
|
s.failVision();
|
||||||
|
const failedWire = await (await s.api("/chat/stream", {chatId, message: "Describe the image", attachments: [attachment.id]})).text();
|
||||||
|
assert.match(failedWire, /event: error/);
|
||||||
|
const failedMessages = (await (await s.api(`/chats/${chatId}/messages`)).json()).messages;
|
||||||
|
const interrupted = failedMessages.find(message => message.role === "user");
|
||||||
|
assert.equal(interrupted.deliveryStatus, "failed");
|
||||||
|
s.recoverVision();
|
||||||
|
const resumedWire = await (await s.api("/chat/stream", {chatId, message: interrupted.content,
|
||||||
|
attachments: [attachment.id], userMessageId: interrupted.id})).text();
|
||||||
|
assert.match(resumedWire, /event: done/);
|
||||||
|
const messages = (await (await s.api(`/chats/${chatId}/messages`)).json()).messages;
|
||||||
|
assert.equal(messages.filter(message => message.role === "user").length, 1);
|
||||||
|
assert.equal(messages[0].attachments[0].id, attachment.id);
|
||||||
|
assert.equal(messages[0].deliveryStatus, "complete");
|
||||||
|
});
|
||||||
|
|
||||||
test("browser disconnect cancels in-progress vision and never starts the final model", async t => {
|
test("browser disconnect cancels in-progress vision and never starts the final model", async t => {
|
||||||
const s = await setup(t);
|
const s = await setup(t);
|
||||||
const attachment = await (await s.upload(await s.image("png"))).json();
|
const attachment = await (await s.upload(await s.image("png"))).json();
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import test from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import Database from "better-sqlite3";
|
||||||
|
import {createRateLimiter, createSqliteRateLimitStore} from "../src/middleware/rateLimit.js";
|
||||||
|
|
||||||
|
test("SQLite rate limit store shares rolling-window hits between limiter instances", () => {
|
||||||
|
const db = new Database(":memory:");
|
||||||
|
const firstStore = createSqliteRateLimitStore(db);
|
||||||
|
const secondStore = createSqliteRateLimitStore(db);
|
||||||
|
const options = {windowMs: 1000, max: 2, keyGenerator: () => "account:test", message: "limited", cleanupIntervalMs: 0};
|
||||||
|
const first = createRateLimiter({...options, store: firstStore});
|
||||||
|
const second = createRateLimiter({...options, store: secondStore});
|
||||||
|
const hit = limiter => {
|
||||||
|
let allowed = false, status = null;
|
||||||
|
limiter({}, {status(value) {status = value; return this;}, json() {}}, () => {allowed = true;});
|
||||||
|
return {allowed, status};
|
||||||
|
};
|
||||||
|
|
||||||
|
assert.deepEqual(hit(first), {allowed: true, status: null});
|
||||||
|
assert.deepEqual(hit(second), {allowed: true, status: null});
|
||||||
|
assert.deepEqual(hit(first), {allowed: false, status: 429});
|
||||||
|
assert.equal(firstStore.consume("account:test", Date.now() + 1001, 1000, 2), true);
|
||||||
|
first.close(); second.close(); db.close();
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user