/** * @file Koordiniert Chat-Antworten, Modellkontext, Websuche, Anhänge, Antwortvarianten und Gesprächsverzweigungen. */ import { buildPersonaPrompt, buildRoleplayDirectionPrompt, buildRoleplayOpenerPrompt, buildRoleplaySceneSummary, buildUserPersonaPrompt, containsUserVoiceInRoleplayOpener } from "./personaService.js"; import { clampRetryActiveIndex, formatChatMessage as formatBaseChatMessage, formatMessagesForSummary, parseSources, retryStylePrompts } from "./chatMessageHelpers.js"; import {createHash} from "node:crypto"; import { SearXNGSearchProvider, WebSearchError, decideWebSearch, retrieveWebSearch } from "./webSearchService.js"; import { supportsReasoning } from "../../public/js/modelCapabilities.js"; import {VISION_EVIDENCE_GUIDANCE} from "./visionAnalysis.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) => { const message = formatBaseChatMessage(row); if (message?.role === "bot") { const versions = related?.versions ? related.versions.get(row.id) || [] : 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 && (related?.attachments ? related.attachments.get(row.id) : 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 visionGuidanceSha256 = createHash("sha256").update(VISION_EVIDENCE_GUIDANCE).digest("hex"); const beginVisionDiagnostics = ({requestId, user, chatId, triggeringMessageId, userQuestion, selectedModel, reasoningRequested, webSearchRequested, visionSelection}) => { if (!visionDebugService?.enabled || !visionSelection || !attachmentService.hasImages(visionSelection)) return; visionDebugService.begin({requestId, user, chatId, triggeringMessageId, userQuestion, selectedModel, reasoningRequested, webSearchRequested}); const decision = decideWebSearch(userQuestion, webSearchRequested); visionDebugService.recordSearch(requestId, { requested: webSearchRequested, needed: decision.needsSearch, explicitSearchIntent: decision.explicitSearchIntent, state: webSearchRequested ? (decision.shouldSearch ? "pending" : "skipped") : "disabled" }); }; const recordQwenDiagnostics = ({requestId, messagesPayload, visionSelection, model, generation, reasoning}) => { if (!visionDebugService?.enabled || !requestId) return; const evidenceMessage = messagesPayload.find(message => message.role === "user" && message.content.startsWith("")); const metadata = typeof modelService.getRequestMetadata === "function" ? modelService.getRequestMetadata(model, generation, reasoning) : {model, think: reasoning === true && supportsReasoning(model), stream: true, temperature: generation.temperature ?? null, numCtx: generation.contextLength ?? null, numPredict: generation.responseLength ?? null, keepAlive: config.ollamaKeepAlive ?? "30m"}; visionDebugService.recordQwen(requestId, { imageEvidence: evidenceMessage?.content || null, visionGuidanceSha256, visionEvidenceIncluded: Boolean(evidenceMessage), imageCount: (visionSelection?.currentMessageImages.length || 0) + (visionSelection?.historicalImages.length || 0), currentImageLabels: visionSelection?.currentMessageImages.map(image => image.label) || [], ...metadata }); }; 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(); // Ein fehlgeschlagener Vorgänger darf die Warteschlange dieses Chats nicht blockieren. const current = previous.catch(() => undefined).then(operation); chatGenerationQueues.set(chatId, current); return current.finally(() => { // Nur den eigenen Eintrag entfernen: Inzwischen könnte bereits ein Folgeauftrag warten. if (chatGenerationQueues.get(chatId) === current) chatGenerationQueues.delete(chatId); }); }; const withActiveGeneration = async (chatId, externalSignal, operation) => { const controller = new AbortController(); // Verbindungsabbruch und manuelles Stoppen wirken auf denselben Generierungsauftrag. const abortFromCaller = () => controller.abort(externalSignal?.reason); if (externalSignal?.aborted) abortFromCaller(); else externalSignal?.addEventListener("abort", abortFromCaller, { once: true }); activeGenerations.set(chatId, controller); try { controller.signal.throwIfAborted(); const result = await operation(controller.signal); controller.signal.throwIfAborted(); return result; } 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 generateChatTitle = async (message, model, signal) => { signal?.throwIfAborted(); try { const response = await modelService.generateReply(model, [ {role: "system", content: [ "Create a short, useful title for a chat from the user's first message.", "Capture the main subject or goal, not the wording of the request.", "Use the same language as the user. Do not answer the message.", "Return only JSON in the form {\"title\":\"...\"}.", "The title must be 2 to 8 words and no more than 70 characters. Do not use quotes, markdown, emojis, or a trailing period." ].join("\n")}, {role: "user", content: String(message).slice(0, 6000)} ], { signal: signal ? AbortSignal.any([signal, AbortSignal.timeout(10000)]) : AbortSignal.timeout(10000), reasoning: false, generation: {temperature: 0.2, responseLength: 80}, format: {type: "object", properties: {title: {type: "string"}}, required: ["title"], additionalProperties: false} }); signal?.throwIfAborted(); const title = String(JSON.parse(response)?.title || "").replace(/[\r\n]+/g, " ").replace(/["“”']/g, "").replace(/\s+/g, " ").trim(); if (title && title.length <= 70 && title.split(/\s+/).length <= 8) return title; } catch (error) { signal?.throwIfAborted(); } return "New chat"; }; 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); // Die neuesten Nachrichten bleiben vollständig im Kontext; nur ältere werden zusammengefasst. 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(config.contextSummaryModel || "nomic-embed-text:latest", [ { 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; const current = repositories.getChat(chatId, user); // A manual correction or branch switch during generation wins over the stale summary. if (!current || current.context_summary !== session.context_summary || current.context_summary_message_id !== session.context_summary_message_id) 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); signal?.throwIfAborted(); 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 = 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 = ""; 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(); const visionSelection = attachmentService?.selectImages(imageHistory, promptMessage.content); beginVisionDiagnostics({requestId, user, chatId, triggeringMessageId: promptMessage.id, userQuestion: promptMessage.content, selectedModel, reasoningRequested: reasoning, webSearchRequested: webSearch, visionSelection}); if (visionSelection && attachmentService.hasImages(visionSelection)) onStatus?.({ status: "analyzing" }); if (visionSelection) await attachmentService.analyzeContext(visionSelection, promptMessage.content, signal, {requestId, chatId, triggeringMessageId: promptMessage.id}); let sources = null; let searchState = "skipped"; if (webSearch) { const visualReference = visionSelection ? attachmentService.searchContext(visionSelection, promptMessage.content) : ""; try { const search = await retrieveWebSearch(promptMessage.content, webSearchProvider, { signal, referenceText: visualReference, modelService, model: selectedModel, history: imageHistory.slice(0, -1), onSearching: () => { visionDebugService?.recordSearch(requestId, {requested: true, state: "searching", visualReference}); onStatus?.({ status: "searching" }); } }); sources = search.sources; searchState = search.state; visionDebugService?.recordSearch(requestId, {requested: true, needed: search.decision.needsSearch, explicitSearchIntent: search.decision.explicitSearchIntent, state: search.state, visualReference, queries: search.queries, candidateCount: search.candidates.length, selectedCount: search.sources?.length || 0, selectedSources: search.sources || []}); } catch (error) { visionDebugService?.recordSearch(requestId, {requested: true, state: "failed", visualReference}); visionDebugService?.fail(requestId, error); throw error; } } onStatus?.({ status: "generating", searchState, sources }); const { messagesPayload } = buildConversationContext({ user, chatId, session, message: null, upToMessageId: promptMessage.id, historyOverride: imageHistory, visionSelection, sources }); const styledPayload = appendRetryStyleInstruction(messagesPayload, retryStyle); recordQwenDiagnostics({requestId, messagesPayload: styledPayload, visionSelection, model: selectedModel, generation, reasoning}); 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" }; visionDebugService?.complete(requestId, {finalAnswer: fullReply, visibleReasoning: reasoning ? thinking : null}); return { fullReply, thinking, sources, searchState, promptMessageId: promptMessage.id }; }; 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); 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 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)) { return { error: "Branch point not found", status: 404 }; } const { model: effectiveModel, generation } = getGenerationSettings(session, model); const useReasoning = reasoning === true && supportsReasoning(effectiveModel); signal?.throwIfAborted(); 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"; 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(); const visionSelection = attachmentService?.selectImages(imageHistory, message); beginVisionDiagnostics({requestId, user, chatId, triggeringMessageId: userMessageId, userQuestion: message, selectedModel: effectiveModel, reasoningRequested: useReasoning, webSearchRequested: webSearch, visionSelection}); if (visionSelection && attachmentService.hasImages(visionSelection)) { onStatus?.({ status: "analyzing" }); await attachmentService.analyzeContext(visionSelection, message, activeSignal, {requestId, chatId, triggeringMessageId: userMessageId}); } if (webSearch) { const visualReference = visionSelection ? attachmentService.searchContext(visionSelection, message) : ""; let search; try { search = await retrieveWebSearch(message, webSearchProvider, { referenceText: visualReference, modelService, model: effectiveModel, history: imageHistory.slice(0, -1), signal: activeSignal, onSearching: () => { visionDebugService?.recordSearch(requestId, {requested: true, state: "searching", visualReference}); onStatus?.({ status: "searching" }); } }); } catch (error) { visionDebugService?.recordSearch(requestId, {requested: true, state: "failed", visualReference}); visionDebugService?.fail(requestId, error); throw error; } sources = search.sources; searchState = search.state; visionDebugService?.recordSearch(requestId, {requested: true, needed: search.decision.needsSearch, explicitSearchIntent: search.decision.explicitSearchIntent, state: search.state, visualReference, queries: search.queries, candidateCount: search.candidates.length, selectedCount: search.sources?.length || 0, selectedSources: search.sources || []}); if (config.structuredRequestLoggingEnabled) { console.log(JSON.stringify({ event: "web_search", requestId, 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, upToMessageId: userMessageId, historyOverride: imageHistory, visionSelection, contextSummaryOverride: session.context_summary, sources }); recordQwenDiagnostics({requestId, messagesPayload: payload.messagesPayload, visionSelection, model: effectiveModel, generation, reasoning: useReasoning}); 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 }; } visionDebugService?.complete(requestId, {finalAnswer: fullReply, visibleReasoning: useReasoning ? thinking : null}); 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) { visionDebugService?.fail(requestId, 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 formatHistory = (chatId, rows) => { const metadata = repositories.getHistoryMetadata(chatId, rows.map(row => row.id)); return rows.map(row => ({...formatChatMessage(row, metadata), ...metadata.siblings.get(row.id)})); }; const listActiveBranchMessages = chatId => formatHistory(chatId, repositories.listActiveBranchMessages(chatId)); const getHistoryPage = (chatId, options) => { const result = repositories.getActiveBranchPage(chatId, options); if (result.error) return result; const {rows, ...details} = result; return {...details, messages: formatHistory(chatId, rows)}; }; const cloneChatThroughMessage = ({ user, sourceChat, messageId = null }) => { if (repositories.countChats(user) >= config.chatLimit) { return { error: `Maximum of ${config.chatLimit} active chats reached. Archive or delete an active 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 } ); repositories.updateChatPersonality(clonedChat.id, user, sourceChat.personality_id || "general-assistant"); 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, async initializeRoleplay({user, chatId, assistantPersona, userPersona, scenarioPrompt, sceneSummary, selectedModel, signal = null}) { if (chatGenerationQueues.has(chatId)) return {error: "A response is already being generated", status: 409}; return withChatGenerationLock(chatId, () => withActiveGeneration(chatId, signal, async activeSignal => { let chat = repositories.getChat(chatId, user); if (!chat) return {error: "Chat not found", status: 404}; // Check inside the same lock that protects generation and persistence. if (repositories.listChatMessages(chatId).length) return {chat, existing: true, generatedInitialMessage: false}; repositories.updateChatScene(chatId, user, scenarioPrompt, sceneSummary); chat = repositories.getChat(chatId, user); const {model, generation} = getGenerationSettings(chat, selectedModel); const opener = await generateRoleplayOpener({selectedModel: model, generation, assistantPersona, userPersona, scenarioPrompt, sceneSummary, signal: activeSignal}); activeSignal.throwIfAborted(); if (!opener || containsUserVoiceInRoleplayOpener(opener, userPersona)) { return {chat, existing: false, generatedInitialMessage: false, degraded: true}; } insertChatMessage(chatId, "bot", opener, null, model); repositories.touchChat(chatId, user); return {chat: repositories.getChat(chatId, user), existing: false, generatedInitialMessage: true, opener}; })); }, persistChatMessageRetryState, buildMessagesPayload: buildConversationContext, async generateInitialChatTitle({user, chatId, message, model, signal = null}) { const session = repositories.getChat(chatId, user); if (!session) return {error: "Chat not found", status: 404}; if (session.assistant_persona_id || session.title !== "New chat" || repositories.getLatestChatMessage(chatId)) { return {title: session.title}; } const title = await generateChatTitle(message, config.titleModel || "nomic-embed-text:latest", signal); if (title !== "New chat") repositories.updateChatTitle(chatId, user, title); return {title}; }, 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 = [], 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, async retryMessage({ user, chatId, selectedModel, targetMessage, retryStyle = null, webSearch = true, reasoning = false, onChunk = null, onThinking = null, onStatus = null, signal = null, requestId = 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, requestId }); 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 = true, reasoning = false, onChunk = null, signal = null, requestId = null }) { if (!targetMessage || targetMessage.role !== "user") return { error: "Only user messages can be edited and resent", status: 400 }; if (chatGenerationQueues.has(chatId)) return { error: "A response is already being generated", status: 409 }; return withChatGenerationLock(chatId, () => withActiveGeneration(chatId, signal, async activeSignal => { const sourceChat = repositories.getChat(chatId, user); if (!sourceChat) return { error: "Chat not found", status: 404 }; const freshTarget = repositories.getChatMessage(chatId, targetMessage.id); if (!freshTarget) return { error: "Message not found", status: 404 }; const cloned = cloneChatThroughMessage({user, sourceChat, messageId: freshTarget.parent_message_id ?? null}); if (cloned.error) return cloned; const generated = await generateChatTurn({ user, chatId: cloned.chat.id, attachments: attachmentService?.list(freshTarget.id).map(item => item.id) || [], reuseChatId: chatId, message: content, model, webSearch, reasoning, onChunk, signal: activeSignal, requestId }); 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: freshTarget.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 getHistoryPage(chatId); }, listActiveBranchMessages, getHistoryPage, 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 }; } }; }