Files
KrisHD_AI/src/services/chatService.js
T

674 lines
37 KiB
JavaScript

import {
buildPersonaPrompt,
buildRoleplayDirectionPrompt,
buildRoleplayOpenerPrompt,
buildRoleplayReplyGuardPrompt,
buildRoleplaySceneSummary,
buildUserPersonaPrompt,
containsUserVoiceInRoleplayOpener
} from "./personaService.js";
import {
buildContextSummaryPrompt,
buildMemoryPrompt,
buildPersonaStatePrompt,
clampRetryActiveIndex,
formatChatMessage as formatBaseChatMessage,
formatMessagesForSummary,
parseSources,
retryStylePrompts
} from "./chatMessageHelpers.js";
import {SearXNGSearchProvider, WebSearchError, buildWebSearchContext, retrieveWebSearch} from "./webSearchService.js";
import {supportsReasoning} from "../../public/app/modelCapabilities.js";
export function createChatService(repositories, modelService, config, webSearchProvider = new SearXNGSearchProvider(config), attachmentService = null) {
const formatChatMessage = row => {
const message = formatBaseChatMessage(row);
if (message?.role === "bot") {
const versions = repositories.listMessageVersions(row.id);
message.versionCount = versions.length || 1;
message.versionIndex = versions.length ? Number(row.retry_active_index || 0) : 0;
if (versions.length) message.responseVersions = versions.map(version => ({
content: version.content, thinking: version.thinking,
sources: parseSources(version.sources), modelName: version.model_name, createdAt: version.created_at
}));
}
const attachments = row && attachmentService?.list(row.id);
return message && attachments?.length ? {...message, attachments} : message;
};
const chatGenerationQueues = new Map();
const summaryQueues = new Map();
const activeGenerations = new Map();
const regeneratingMessages = new Map();
const maxRetries = Number(config.chatMaxRetries ?? 5);
const now = typeof config.now === "function" ? config.now : () => new Date();
const insertChatMessage = (chatId, role, content, retryState = null, modelName = null, deliveryStatus = "complete", errorMessage = null) => {
const normalizedContent = String(content || "");
const payload = retryState
? {
retryVariants: Array.isArray(retryState.retryVariants) && retryState.retryVariants.length ? retryState.retryVariants : [normalizedContent],
retryActiveIndex: clampRetryActiveIndex(retryState.retryActiveIndex ?? 0, retryState.retryVariants || [normalizedContent]),
retryRetriesUsed: Number(retryState.retryRetriesUsed || 0),
retryPromptMessageId: retryState.retryPromptMessageId ?? null
}
: {
retryVariants: [normalizedContent],
retryActiveIndex: 0,
retryRetriesUsed: 0,
retryPromptMessageId: null
};
return repositories.insertChatMessage(chatId, role, normalizedContent, payload, modelName, deliveryStatus, errorMessage);
};
const withChatGenerationLock = (chatId, operation) => {
const previous = chatGenerationQueues.get(chatId) || Promise.resolve();
const current = previous.catch(() => undefined).then(operation);
chatGenerationQueues.set(chatId, current);
return current.finally(() => {
if (chatGenerationQueues.get(chatId) === current) chatGenerationQueues.delete(chatId);
});
};
const withActiveGeneration = async (chatId, externalSignal, operation) => {
const controller = new AbortController();
const abortFromCaller = () => controller.abort(externalSignal?.reason);
if (externalSignal?.aborted) abortFromCaller();
else externalSignal?.addEventListener("abort", abortFromCaller, {once: true});
activeGenerations.set(chatId, controller);
try {
return await operation(controller.signal);
} finally {
if (activeGenerations.get(chatId) === controller) activeGenerations.delete(chatId);
externalSignal?.removeEventListener("abort", abortFromCaller);
}
};
const getGenerationSettings = (session, requestedModel) => ({
model: requestedModel || session.preferred_model || "qwen3:14b",
generation: {
temperature: session.temperature ?? undefined,
contextLength: session.context_length ?? undefined,
responseLength: session.response_length ?? undefined
}
});
const persistChatMessageRetryState = ({chatId, messageId, content, retryVariants, retryActiveIndex, retryRetriesUsed, retryPromptMessageId}) => {
const versioned = repositories.listMessageVersions(messageId).length > 0;
if (versioned) repositories.saveMessageVersion(chatId, messageId, null, retryActiveIndex);
repositories.updateChatMessageRetryState(chatId, messageId, {
content,
retryVariants: retryVariants || [content || ""],
retryActiveIndex: clampRetryActiveIndex(retryActiveIndex, retryVariants || [content || ""]),
retryRetriesUsed: Number(retryRetriesUsed || 0),
retryPromptMessageId: retryPromptMessageId ?? null
});
if (versioned) repositories.saveMessageVersion(chatId, messageId, null, retryActiveIndex);
};
const getChatMessageByIndex = (chatId, index) => {
const safeIndex = Number(index);
if (!Number.isInteger(safeIndex) || safeIndex < 0) return null;
return repositories.getChatMessageByIndex(chatId, safeIndex) || null;
};
const clampSummary = (value) => {
const normalized = String(value || "").trim();
if (normalized.length <= config.chatSummaryMaxChars) return normalized;
return `${normalized.slice(0, Math.max(0, config.chatSummaryMaxChars - 3)).trimEnd()}...`;
};
const maybeUpdateContextSummary = async ({user, chatId, selectedModel}) => {
if (config.chatSummaryUpdateEveryMessages <= 0) return;
const session = repositories.getChat(chatId, user);
if (!session) return;
const allMessages = repositories.listActiveBranchMessages(chatId)
.filter((message) => !message.delivery_status || message.delivery_status === "complete");
const keepRecent = Math.max(0, config.chatHistoryLimit);
const cutoffIndex = allMessages.length - keepRecent - 1;
if (cutoffIndex < 0) return;
const cutoffMessage = allMessages[cutoffIndex];
const summarizable = allMessages
.filter((message) => message.id > Number(session.context_summary_message_id || 0) && message.id <= cutoffMessage.id);
if (summarizable.length < config.chatSummaryUpdateEveryMessages) return;
const previousSummary = String(session.context_summary || "").trim();
const summary = await modelService.generateReply(selectedModel, [
{
role: "system",
content: [
"You maintain a compact running summary for a chat application.",
"Merge the previous summary and new transcript into one concise continuity summary.",
"Preserve durable facts, decisions, goals, relationships, unresolved threads, locations, preferences, and roleplay continuity.",
"Do not invent details. Do not include generic filler. Keep it under 12 bullet points."
].join("\n")
},
{
role: "user",
content: [
previousSummary ? `Previous summary:\n${previousSummary}` : "Previous summary: none",
"",
"New transcript to compress:",
formatMessagesForSummary(summarizable)
].join("\n")
}
]);
const latestSummarized = summarizable[summarizable.length - 1];
if (!latestSummarized || !summary) return;
repositories.updateChatContextSummary(chatId, user, clampSummary(summary), latestSummarized.id);
};
const updateContextSummaryWithoutBreakingReply = async ({user, chatId, selectedModel}) => {
try {
await maybeUpdateContextSummary({user, chatId, selectedModel});
} catch (error) {
console.warn("CHAT SUMMARY UPDATE FAILED", chatId, error?.message || error);
}
};
const scheduleContextSummaryUpdate = ({user, chatId, selectedModel}) => {
const previous = summaryQueues.get(chatId) || Promise.resolve();
const current = previous
.catch(() => undefined)
.then(() => updateContextSummaryWithoutBreakingReply({user, chatId, selectedModel}));
summaryQueues.set(chatId, current);
void current.finally(() => {
if (summaryQueues.get(chatId) === current) summaryQueues.delete(chatId);
});
};
const getOrCreatePersonaChat = (user, persona, userPersonaId = null, scenarioPrompt = null, scenarioSummary = null) => {
const existing = repositories.getChatByParticipants(user, persona.id, userPersonaId);
if (existing) return existing;
if (repositories.countChats(user) >= config.chatLimit) {
throw new Error("CHAT_LIMIT_REACHED");
}
return repositories.createChat(user, persona.name, persona.id, userPersonaId, scenarioPrompt, scenarioSummary);
};
const generateRoleplayOpener = async ({selectedModel = "qwen3:14b", assistantPersona, userPersona, scenarioPrompt, sceneSummary, onThinking = null, onChunk = null, signal = null, generation = {}, reasoning = false}) => {
const baseMessages = [
{role: "system", content: buildPersonaPrompt(assistantPersona)},
...(userPersona ? [{role: "system", content: buildUserPersonaPrompt(userPersona)}] : []),
{role: "system", content: buildRoleplayDirectionPrompt({assistantPersona, userPersona, scenarioPrompt, sceneSummary})},
{role: "user", content: buildRoleplayOpenerPrompt({assistantPersona, userPersona, scenarioPrompt, sceneSummary})}
];
const generate = messages => onChunk
? modelService.streamReply(selectedModel, messages, onChunk, {onThinking, signal, generation, reasoning})
: modelService.generateReply(selectedModel, messages, {onThinking, signal, generation, reasoning});
let opener = await generate(baseMessages);
if (!containsUserVoiceInRoleplayOpener(opener, userPersona)) {
return opener;
}
onThinking?.("", "");
onChunk?.("", "");
return generate([
...baseMessages,
{role: "assistant", content: opener || ""},
{
role: "user",
content: "Rewrite this opening. Hard rule: only the assistant character may speak or act. Never write or imply user speech, thoughts, feelings, reactions, decisions, actions, or labels like 'User:'/'You:'. Stop before the user's reply."
}
]);
};
const generateValidatedRoleplayReply = async ({selectedModel, messagesPayload, userPersona, signal = null, generation = {}, onThinking = null, onChunk = null, reasoning = false}) => {
const generate = (messages) => onChunk
? modelService.streamReply(selectedModel, messages, onChunk, {signal, generation, onThinking, reasoning})
: modelService.generateReply(selectedModel, messages, {signal, generation, onThinking, reasoning});
let reply = await generate(messagesPayload);
if (reply && !containsUserVoiceInRoleplayOpener(reply, userPersona)) {
return reply;
}
onThinking?.("", "");
onChunk?.("", "");
reply = await generate([
...messagesPayload,
{role: "assistant", content: reply || ""},
{
role: "user",
content: "Rewrite your last reply. Hard rule: only the assistant character may speak or act. Never write or imply user speech, thoughts, feelings, reactions, decisions, or actions. Stop before the user's reply."
}
]);
if (!reply || containsUserVoiceInRoleplayOpener(reply, userPersona)) {
return null;
}
return reply;
};
const appendRetryStyleInstruction = (messagesPayload, retryStyle) => {
if (!retryStyle || !retryStylePrompts[retryStyle]) return messagesPayload;
return [...messagesPayload, {role: "user", content: retryStylePrompts[retryStyle]}];
};
const getUserPersonaForSession = (chatId, user, session, activePersona) => {
const chatPersona = repositories.getUserPersonaForChat(chatId, user);
if (chatPersona || activePersona) return chatPersona || null;
return repositories.getActiveUserPersona(user);
};
const buildConversationContext = ({user, chatId, session = null, message, upToMessageId = null, sources = null}) => {
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 = 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}`});
}
if (activePersona) messagesPayload.push({role: "system", content: buildPersonaPrompt(activePersona)});
if (activeUserPersona) messagesPayload.push({role: "system", content: buildUserPersonaPrompt(activeUserPersona)});
if (activePersona && activeSession.scenario_summary) {
messagesPayload.push({
role: "system",
content: buildRoleplayDirectionPrompt({
assistantPersona: activePersona,
userPersona: activeUserPersona,
scenarioPrompt: activeSession.scenario_prompt,
sceneSummary: activeSession.scenario_summary
})
});
}
const contextSummaryPrompt = buildContextSummaryPrompt(upToMessageId == null ? activeSession.context_summary : null);
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: personaStatePrompt});
if (activePersona) messagesPayload.push({role: "system", content: buildRoleplayReplyGuardPrompt()});
const conversationMessages = conversation.map((entry) => ({
role: entry.role === "bot" ? "assistant" : "user",
content: entry.content
}));
messagesPayload.push(...conversationMessages.slice(0, -1));
const visionContext = attachmentService?.context(history, conversation.at(-1)?.content || "");
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 = false, reasoning = false, onChunk = null, onThinking = null, onStatus = null}) => {
let thinking = "";
const captureThinking = (chunk, fullThinking) => {
if (!reasoning) return;
thinking = fullThinking;
onThinking?.(chunk, fullThinking);
};
onStatus?.({status: "accepted"});
const parentMessage = targetMessage.parent_message_id
? repositories.getChatMessage(chatId, targetMessage.parent_message_id)
: null;
const promptMessage = parentMessage?.role === "user"
? parentMessage
: repositories.listChatBranchThroughMessage(chatId, targetMessage.id).slice(0, -1).reverse().find(entry => entry.role === "user");
const activePersona = repositories.getAssistantPersonaForChat(chatId, user);
const activeUserPersona = getUserPersonaForSession(chatId, user, session, activePersona);
if (!promptMessage) {
if (!activePersona || !session.scenario_summary) {
return {error: "No previous user prompt found for retry"};
}
const opener = await generateRoleplayOpener({
onThinking: captureThinking, onChunk, signal, generation, reasoning,
selectedModel,
assistantPersona: activePersona,
userPersona: activeUserPersona,
scenarioPrompt: session.scenario_prompt,
sceneSummary: session.scenario_summary
});
if (!opener || containsUserVoiceInRoleplayOpener(opener, activeUserPersona)) {
return {error: "Failed to regenerate message"};
}
return {fullReply: opener, thinking, promptMessageId: null};
}
const imageHistory = repositories.getRecentChatMessagesUpToId(chatId, promptMessage.id).reverse();
if (attachmentService?.hasImages(imageHistory)) onStatus?.({status: "analyzing"});
await attachmentService?.analyzeContext(imageHistory, promptMessage.content, signal);
let sources = null;
let searchState = "skipped";
if (webSearch) {
const search = await retrieveWebSearch(promptMessage.content, webSearchProvider, {
signal,
referenceText: attachmentService?.searchContext(imageHistory, promptMessage.content) || "",
onSearching: () => onStatus?.({status: "searching"})
});
sources = search.sources;
searchState = search.state;
}
onStatus?.({status: "generating", searchState, sources});
const {messagesPayload} = buildConversationContext({
user,
chatId,
session,
message: null,
upToMessageId: promptMessage.id,
sources
});
const styledPayload = appendRetryStyleInstruction(messagesPayload, retryStyle);
const fullReply = activePersona
? await generateValidatedRoleplayReply({selectedModel, messagesPayload: styledPayload, userPersona: activeUserPersona, signal, generation, onThinking: captureThinking, onChunk, reasoning})
: onChunk
? await modelService.streamReply(selectedModel, styledPayload, onChunk, {signal, generation, onThinking: captureThinking, reasoning})
: await modelService.generateReply(selectedModel, styledPayload, {signal, generation, onThinking: captureThinking, reasoning});
if (!fullReply) return {error: "Failed to regenerate message"};
return {fullReply, thinking, sources, searchState, promptMessageId: promptMessage.id};
};
const generateChatTurn = ({user, chatId, message, model, attachments = [], reuseChatId = null, webSearch = false, reasoning = false, onStatus = null, onChunk = null, onThinking = null, signal = null, parentMessageId = undefined}) =>
withChatGenerationLock(chatId, async () => {
await (summaryQueues.get(chatId) || Promise.resolve());
const session = repositories.getChat(chatId, user);
if (!session) return {error: "Chat not found", status: 404};
if (parentMessageId !== undefined) {
repositories.updateChatContextSummary(chatId, user, null, 0);
session.context_summary = null;
session.context_summary_message_id = 0;
}
const effectiveParentId = parentMessageId === undefined
? session.active_leaf_message_id ?? repositories.getLatestChatMessage(chatId)?.id ?? null
: parentMessageId;
if (effectiveParentId != null && !repositories.getChatMessage(chatId, effectiveParentId)) {
return {error: "Branch point not found", status: 404};
}
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);
try {
let sources = null;
let searchState = "skipped";
let thinking = "";
const captureThinking = (chunk, fullThinking) => {
if (!useReasoning) return;
thinking = fullThinking;
onThinking?.(chunk, fullThinking);
};
let payload;
const fullReply = await withActiveGeneration(chatId, signal, async (activeSignal) => {
activeSignal.throwIfAborted();
onStatus?.({status: "accepted"});
const imageHistory = repositories.getRecentChatMessagesUpToId(chatId, userMessageId).reverse();
if (attachmentService?.hasImages(imageHistory)) {
onStatus?.({status: "analyzing"});
await attachmentService.analyzeContext(imageHistory, message, activeSignal);
}
if (webSearch) {
const search = await retrieveWebSearch(message, webSearchProvider, {
referenceText: attachmentService?.searchContext(imageHistory, message) || "",
signal: activeSignal,
onSearching: () => onStatus?.({status: "searching"})
});
sources = search.sources;
searchState = search.state;
if (config.structuredRequestLoggingEnabled) {
console.log(JSON.stringify({
event: "web_search",
webSearchRequested: true,
explicitSearchRequested: search.decision.explicitSearchIntent,
automaticSearchDecision: search.decision.automaticSearchDecision,
searchNeeded: search.decision.shouldSearch,
searchState,
queryCount: search.queries.length,
candidateCount: search.candidates.length,
selectedCount: sources?.length || 0,
webContextSupplied: sources !== null,
model: effectiveModel
}));
}
}
onStatus?.({status: "generating", searchState, ...(sources !== null ? {sourceCount: sources.length} : {})});
payload = buildConversationContext({user, chatId, session, message: null, sources});
return payload.activePersona
? generateValidatedRoleplayReply({
selectedModel: effectiveModel,
messagesPayload: payload.messagesPayload,
userPersona: payload.activeUserPersona,
signal: activeSignal,
generation,
onThinking: captureThinking,
onChunk,
reasoning: useReasoning
})
: onChunk
? modelService.streamReply(effectiveModel, payload.messagesPayload, onChunk, {signal: activeSignal, generation, onThinking: captureThinking, reasoning: useReasoning})
: modelService.generateReply(effectiveModel, payload.messagesPayload, {signal: activeSignal, generation, onThinking: captureThinking, reasoning: useReasoning});
});
if (!fullReply) {
repositories.failChatTurn(chatId, userMessageId, "Failed to generate a valid character reply", webSearch ? userMessageId : effectiveParentId);
return {error: "Failed to generate a valid character reply", status: 500};
}
const retryState = {
retryVariants: [fullReply],
retryActiveIndex: 0,
retryRetriesUsed: 0,
retryPromptMessageId: null
};
repositories.completeChatTurn(chatId, userMessageId, fullReply, retryState, effectiveModel, sources, thinking);
repositories.touchChat(chatId, user);
scheduleContextSummaryUpdate({user, chatId, selectedModel: effectiveModel});
return {reply: fullReply, userMessageId, searchState, ...(sources ? {sources} : {})};
} catch (error) {
repositories.failChatTurn(chatId, userMessageId, String(error?.message || "Model request failed").slice(0, 500), webSearch ? userMessageId : effectiveParentId);
repositories.touchChat(chatId, user);
if (error instanceof WebSearchError) return {error: error.message, status: error.status, searchState: "failed"};
throw error;
}
});
const listActiveBranchMessages = (chatId) => repositories.listActiveBranchMessages(chatId).map((row) => {
const message = formatChatMessage(row);
const siblings = repositories.listSiblingChatMessages(chatId, row.role, row.parent_message_id);
const siblingIndex = siblings.findIndex((sibling) => sibling.id === row.id);
return {
...message,
siblingIndex,
siblingCount: siblings.length,
previousSiblingId: siblingIndex > 0 ? siblings[siblingIndex - 1].id : null,
nextSiblingId: siblingIndex >= 0 && siblingIndex < siblings.length - 1 ? siblings[siblingIndex + 1].id : null
};
});
const cloneChatThroughMessage = ({user, sourceChat, messageId = null}) => {
if (repositories.countChats(user) >= config.chatLimit) {
return {error: `Maximum of ${config.chatLimit} chats reached. Please delete an old chat to create a new one.`, status: 400};
}
const sourceMessages = messageId == null
? []
: repositories.listChatBranchThroughMessage(sourceChat.id, messageId);
if (messageId != null && !sourceMessages.some((message) => message.id === messageId)) {
return {error: "Message not found", status: 404};
}
const sourceTitle = String(sourceChat.stored_title || sourceChat.title || "Chat").trim();
const clonedChat = repositories.createChat(
user,
`${sourceTitle} (branch)`,
sourceChat.assistant_persona_id ?? null,
sourceChat.user_persona_id ?? null,
sourceChat.scenario_prompt ?? null,
sourceChat.scenario_summary ?? null,
{folderName: sourceChat.folder_name ?? null}
);
try {
repositories.updateChatBranchSource(clonedChat.id, user, sourceChat.id, messageId);
repositories.updateChatGenerationSettings(clonedChat.id, user, {
preferredModel: sourceChat.preferred_model ?? null,
temperature: sourceChat.temperature ?? null,
contextLength: sourceChat.context_length ?? null,
responseLength: sourceChat.response_length ?? null,
systemInstruction: sourceChat.system_instruction ?? null
});
let parentMessageId = null;
for (const sourceMessage of sourceMessages) {
const inserted = repositories.insertBranchChatMessage(
clonedChat.id,
sourceMessage.role,
sourceMessage.content,
{
retryVariants: [sourceMessage.content],
retryActiveIndex: 0,
retryRetriesUsed: 0,
retryPromptMessageId: null
},
sourceMessage.model_name ?? null,
"complete",
null,
parentMessageId
);
parentMessageId = Number(inserted.lastInsertRowid);
attachmentService?.copy(sourceMessage.id, parentMessageId);
if (sourceMessage.sources) repositories.updateChatMessageSources(clonedChat.id, parentMessageId, parseSources(sourceMessage.sources));
if (sourceMessage.thinking) repositories.updateChatMessageThinking(clonedChat.id, parentMessageId, sourceMessage.thinking);
}
} catch (error) {
repositories.deleteChat(clonedChat.id, user);
throw error;
}
return {
chat: repositories.getChat(clonedChat.id, user),
messages: listActiveBranchMessages(clonedChat.id),
sourceChatId: sourceChat.id,
branchedFromMessageId: messageId
};
};
return {
formatChatMessage,
getChatMessageByIndex,
getOrCreatePersonaChat,
buildRoleplaySceneSummary,
generateRoleplayOpener,
insertChatMessage,
containsUserVoiceInRoleplayOpener,
persistChatMessageRetryState,
buildMessagesPayload: buildConversationContext,
async sendChatMessage({user, chatId, message, model, attachments = [], webSearch = false, reasoning = false}) {
return generateChatTurn({user, chatId, message, model, attachments, webSearch, reasoning});
},
async streamChatMessage({user, chatId, message, model, attachments = [], webSearch = false, reasoning = false, onStatus, onChunk, onThinking, signal = null}) {
return generateChatTurn({user, chatId, message, model, attachments, webSearch, reasoning, onStatus, onChunk, onThinking, signal});
},
isGenerating: (chatId) => chatGenerationQueues.has(chatId),
isRegeneratingMessage: (chatId, messageId) => regeneratingMessages.get(chatId) === messageId,
async retryMessage({user, chatId, selectedModel, targetMessage, retryStyle = null, webSearch = false, reasoning = false, onChunk = null, onThinking = null, onStatus = null, signal = null}) {
if (chatGenerationQueues.has(chatId)) return {error: "A response is already being generated", status: 409};
regeneratingMessages.set(chatId, targetMessage.id);
return withChatGenerationLock(chatId, async () => {
await (summaryQueues.get(chatId) || Promise.resolve());
const session = repositories.getChat(chatId, user);
if (!session) return {error: "Chat not found", status: 404};
const freshTarget = repositories.getChatMessage(chatId, targetMessage.id);
const existingMessage = formatChatMessage(freshTarget);
if (!existingMessage) return {error: "Message not found", status: 404};
if (existingMessage.role !== "bot") return {error: "Only assistant messages can be regenerated", status: 400};
if (existingMessage.versionCount - 1 >= maxRetries) {
return {error: `Maximum of ${maxRetries} retries reached`, status: 400};
}
const {model, generation} = getGenerationSettings(session, selectedModel);
return withActiveGeneration(chatId, signal, async (activeSignal) => {
activeSignal.throwIfAborted();
const retryResult = await buildRetryPayload({
chatId, user, session, selectedModel: model, targetMessage: freshTarget, retryStyle,
signal: activeSignal, generation, webSearch, reasoning: reasoning === true && supportsReasoning(model), onChunk, onThinking, onStatus
});
if (retryResult.error) return {error: retryResult.error, status: 400};
activeSignal.throwIfAborted();
repositories.saveMessageVersion(chatId, freshTarget.id, {
content: retryResult.fullReply, thinking: retryResult.thinking,
sources: retryResult.sources, modelName: model
});
repositories.updateChatContextSummary(chatId, user, null, 0);
repositories.touchChat(chatId, user);
const message = formatChatMessage(repositories.getChatMessage(chatId, freshTarget.id));
return {message, promptMessageId: retryResult.promptMessageId, retryStyle};
});
}).catch(error => {
if (error instanceof WebSearchError) return {error: error.message, status: error.status, searchState: "failed"};
throw error;
}).finally(() => regeneratingMessages.delete(chatId));
},
async editAndResendMessage({user, chatId, targetMessage, content, model, webSearch = false, onChunk = null, signal = null}) {
if (!targetMessage || targetMessage.role !== "user") return {error: "Only user messages can be edited and resent", status: 400};
const sourceChat = repositories.getChat(chatId, user);
if (!sourceChat) return {error: "Chat not found", status: 404};
const cloned = cloneChatThroughMessage({
user,
sourceChat,
messageId: targetMessage.parent_message_id ?? null
});
if (cloned.error) return cloned;
const generated = await generateChatTurn({
user,
chatId: cloned.chat.id,
attachments: attachmentService?.list(targetMessage.id).map(item => item.id) || [],
reuseChatId: chatId,
message: content,
model,
webSearch,
onChunk,
signal
});
if (generated.error) return {...generated, chat: cloned.chat};
return {
...generated,
chat: repositories.getChat(cloned.chat.id, user),
messages: listActiveBranchMessages(cloned.chat.id),
sourceChatId: chatId,
branchedFromMessageId: targetMessage.id
};
},
createBranchedChat({user, chatId, messageId}) {
const sourceChat = repositories.getChat(chatId, user);
if (!sourceChat) return {error: "Chat not found", status: 404};
if (!repositories.getChatMessage(chatId, messageId)) return {error: "Message not found", status: 404};
return cloneChatThroughMessage({user, sourceChat, messageId});
},
activateBranch({user, chatId, messageId, includeDescendants = false}) {
const session = repositories.getChat(chatId, user);
if (!session) return {error: "Chat not found", status: 404};
const message = repositories.getChatMessage(chatId, messageId);
if (!message) return {error: "Message not found", status: 404};
const leaf = includeDescendants ? repositories.getLatestDescendantMessage(chatId, messageId) : message;
repositories.updateChatActiveLeaf(chatId, user, leaf?.id || messageId);
repositories.updateChatContextSummary(chatId, user, null, 0);
return {messages: listActiveBranchMessages(chatId)};
},
listActiveBranchMessages,
stopGeneration({user, chatId}) {
if (!repositories.getChat(chatId, user)) return {error: "Chat not found", status: 404};
const controller = activeGenerations.get(chatId);
if (!controller) return {stopped: false};
controller.abort();
return {stopped: true};
}
};
}