767 lines
44 KiB
JavaScript
767 lines
44 KiB
JavaScript
/**
|
|
* @file Koordiniert Chat-Antworten, Modellkontext, Websuche, Anhänge, Antwortvarianten und Gesprächsverzweigungen.
|
|
*/
|
|
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 {createHash} from "node:crypto";
|
|
|
|
import { SearXNGSearchProvider, WebSearchError, buildWebSearchContext, decideWebSearch, retrieveWebSearch } from "./webSearchService.js";
|
|
import { supportsReasoning } from "../../public/js/modelCapabilities.js";
|
|
import {VISION_EVIDENCE_GUIDANCE} from "./visionAnalysis.js";
|
|
|
|
export function createChatService(repositories, modelService, config, webSearchProvider = new SearXNGSearchProvider(config), attachmentService = null, visionDebugService = 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 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("<image_evidence>"));
|
|
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 {
|
|
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);
|
|
// 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(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, 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}` });
|
|
}
|
|
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(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: 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 currentQuestion = conversation.at(-1)?.content || "";
|
|
const visionContext = attachmentService?.context(visionSelection || attachmentService.selectImages(
|
|
message == null ? history : [...history, {role: "user", content: message}], currentQuestion), currentQuestion);
|
|
if (visionContext) messagesPayload.push(...visionContext);
|
|
if (webSearchContext) messagesPayload.push(webSearchContext[0]);
|
|
if (webSearchContext?.[1]) messagesPayload.push(webSearchContext[1]);
|
|
if (conversationMessages.length) messagesPayload.push(conversationMessages.at(-1));
|
|
return { session: activeSession, activePersona, activeUserPersona, messagesPayload };
|
|
};
|
|
|
|
const buildRetryPayload = async ({ chatId, user, session, selectedModel, targetMessage, retryStyle = null, signal = null, generation = {}, webSearch = false, 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,
|
|
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, webSearch = false, 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 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();
|
|
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,
|
|
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 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, requestId = null }) {
|
|
return generateChatTurn({ user, chatId, message, model, attachments, webSearch, reasoning, requestId });
|
|
},
|
|
async streamChatMessage({ user, chatId, message, model, attachments = [], webSearch = false, reasoning = false, onStatus, onChunk, onThinking, signal = null, requestId = null }) {
|
|
return generateChatTurn({ user, chatId, message, model, attachments, 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 = false, 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 = false, onChunk = null, signal = null, requestId = 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,
|
|
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: 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 };
|
|
}
|
|
};
|
|
}
|