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:
+30
-53
@@ -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,
|
||||
|
||||
@@ -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};
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user