Allow message retries for "pending" status, refactor message context handling, remove workspace import/export routes, and add SQLite-backed rate limiting.
CI / check (22) (push) Failing after 16m26s
CI / check (24) (push) Failing after 15m49s
CI / check (22) (pull_request) Failing after 16m26s
CI / check (24) (pull_request) Failing after 15m50s

This commit is contained in:
2026-10-09 19:28:43 +02:00
parent 8666e09049
commit f67d918d09
13 changed files with 313 additions and 193 deletions
+30 -53
View File
@@ -10,9 +10,6 @@ import {
containsUserVoiceInRoleplayOpener
} from "./personaService.js";
import {
buildContextSummaryPrompt,
buildMemoryPrompt,
buildPersonaStatePrompt,
clampRetryActiveIndex,
formatChatMessage as formatBaseChatMessage,
formatMessagesForSummary,
@@ -21,10 +18,10 @@ import {
} from "./chatMessageHelpers.js";
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 {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) {
const formatChatMessage = (row, related = null) => {
@@ -298,45 +295,7 @@ export function createChatService(repositories, modelService, config, webSearchP
return repositories.getActiveUserPersona(user);
};
const buildConversationContext = ({ 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, 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 buildConversationContext = createConversationContextBuilder({repositories, attachmentService, now});
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 = "";
@@ -432,7 +391,7 @@ export function createChatService(repositories, modelService, config, webSearchP
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 () => {
await (summaryQueues.get(chatId) || Promise.resolve());
const session = repositories.getChat(chatId, user);
@@ -442,7 +401,18 @@ export function createChatService(repositories, modelService, config, webSearchP
session.context_summary = null;
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
: parentMessageId;
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 useReasoning = reasoning === true && supportsReasoning(effectiveModel);
signal?.throwIfAborted();
const insert = () => repositories.insertBranchChatMessage(chatId, "user", message, null, null, "pending", null, effectiveParentId);
const inserted = attachmentService ? attachmentService.createMessage(attachments, user, chatId, insert, reuseChatId) : insert();
const userMessageId = Number(inserted?.lastInsertRowid);
let userMessageId;
if (resumedMessage) {
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 {
let sources = null;
let searchState = "skipped";
@@ -677,11 +654,11 @@ export function createChatService(repositories, modelService, config, webSearchP
},
persistChatMessageRetryState,
buildMessagesPayload: buildConversationContext,
async sendChatMessage({ user, chatId, message, model, attachments = [], webSearch = true, reasoning = false, signal = null, requestId = null }) {
return generateChatTurn({ user, chatId, message, model, attachments, webSearch, reasoning, signal, requestId });
async sendChatMessage({ user, chatId, message, model, attachments = [], userMessageId = null, webSearch = true, reasoning = false, signal = null, requestId = null }) {
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 }) {
return generateChatTurn({ user, chatId, message, model, attachments, webSearch, reasoning, onStatus, onChunk, onThinking, signal, requestId });
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, userMessageId, webSearch, reasoning, onStatus, onChunk, onThinking, signal, requestId });
},
isGenerating: (chatId) => chatGenerationQueues.has(chatId),
isRegeneratingMessage: (chatId, messageId) => regeneratingMessages.get(chatId) === messageId,
+68
View File
@@ -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};
};
}