improve reasoning toggle tests and refactor model capability checks

This commit is contained in:
2026-09-22 10:30:03 +02:00
parent a8e348e0c7
commit 3b6c8969cc
15 changed files with 201 additions and 23 deletions
+25 -8
View File
@@ -12,7 +12,7 @@ import {
buildMemoryPrompt,
buildPersonaStatePrompt,
clampRetryActiveIndex,
formatChatMessage,
formatChatMessage as formatBaseChatMessage,
formatMessagesForSummary,
parseSources,
retryStylePrompts
@@ -21,7 +21,12 @@ import {
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)) {
export function createChatService(repositories, modelService, config, webSearchProvider = new SearXNGSearchProvider(config), attachmentService = null) {
const formatChatMessage = row => {
const message = formatBaseChatMessage(row);
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();
@@ -272,6 +277,8 @@ export function createChatService(repositories, modelService, config, webSearchP
content: entry.content
}));
messagesPayload.push(...conversationMessages.slice(0, -1));
const visionContext = attachmentService?.context(history);
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));
@@ -307,6 +314,7 @@ export function createChatService(repositories, modelService, config, webSearchP
return {fullReply: opener, thinking, promptMessageId: null};
}
await attachmentService?.analyze(promptMessage.id, promptMessage.content, signal);
const {messagesPayload} = buildConversationContext({
user,
chatId,
@@ -327,7 +335,7 @@ export function createChatService(repositories, modelService, config, webSearchP
return {fullReply, thinking, promptMessageId: promptMessage.id};
};
const generateChatTurn = ({user, chatId, message, model, webSearch = false, reasoning = false, onStatus = null, onChunk = null, onThinking = null, signal = null, parentMessageId = undefined}) =>
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);
@@ -346,7 +354,8 @@ export function createChatService(repositories, modelService, config, webSearchP
const {model: effectiveModel, generation} = getGenerationSettings(session, model);
const useReasoning = reasoning === true && supportsReasoning(effectiveModel);
signal?.throwIfAborted();
const inserted = repositories.insertBranchChatMessage(chatId, "user", message, null, null, "pending", null, effectiveParentId);
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;
@@ -361,8 +370,13 @@ export function createChatService(repositories, modelService, config, webSearchP
const fullReply = await withActiveGeneration(chatId, signal, async (activeSignal) => {
activeSignal.throwIfAborted();
onStatus?.({status: "accepted"});
if (attachments.length) {
onStatus?.({status: "analyzing"});
await attachmentService.analyze(userMessageId, message, activeSignal);
}
if (webSearch) {
const search = await retrieveWebSearch(message, webSearchProvider, {
referenceText: attachments.length ? attachmentService.searchContext(userMessageId) : "",
signal: activeSignal,
onSearching: () => onStatus?.({status: "searching"})
});
@@ -489,6 +503,7 @@ export function createChatService(repositories, modelService, config, webSearchP
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);
}
@@ -515,11 +530,11 @@ export function createChatService(repositories, modelService, config, webSearchP
containsUserVoiceInRoleplayOpener,
persistChatMessageRetryState,
buildMessagesPayload: buildConversationContext,
async sendChatMessage({user, chatId, message, model, webSearch = false, reasoning = false}) {
return generateChatTurn({user, chatId, message, model, webSearch, reasoning});
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, webSearch = false, reasoning = false, onStatus, onChunk, onThinking, signal = null}) {
return generateChatTurn({user, chatId, message, model, webSearch, reasoning, onStatus, onChunk, onThinking, signal});
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});
},
async retryMessage({user, chatId, selectedModel, targetMessage, retryStyle = null}) {
return withChatGenerationLock(chatId, async () => {
@@ -574,6 +589,8 @@ export function createChatService(repositories, modelService, config, webSearchP
const generated = await generateChatTurn({
user,
chatId: cloned.chat.id,
attachments: attachmentService?.list(targetMessage.id).map(item => item.id) || [],
reuseChatId: chatId,
message: content,
model,
webSearch,
+3 -1
View File
@@ -1,4 +1,5 @@
import {supportsReasoning} from "../../public/app/modelCapabilities.js";
import {AttachmentError} from "./attachmentService.js";
class ModelServiceError extends Error {
constructor(code, message, status = 500) {
@@ -196,7 +197,7 @@ export function createModelService(config, overrides = {}) {
const response = await fetchImpl(`${config.modelApiBaseUrl}/chat`, {
method: "POST",
headers: {"Content-Type": "application/json"},
body: JSON.stringify(buildRequestBody(model, messagesPayload, options.generation, options.reasoning)),
body: JSON.stringify({...buildRequestBody(model, messagesPayload, options.generation, options.reasoning), ...(options.format ? {format: options.format} : {})}),
signal
});
@@ -332,6 +333,7 @@ export function createModelService(config, overrides = {}) {
};
},
mapError(error) {
if (error instanceof AttachmentError) return {status: error.status, body: {error: error.message}};
if (error instanceof ModelServiceError) {
return {status: error.status, body: {error: error.message, code: error.code}};
}
+5 -3
View File
@@ -106,14 +106,16 @@ export function buildWebSearchQueries(message, decision = decideWebSearch(messag
}
/** Retrieve before generation; only try a second date query when the first has no usable evidence. */
export async function retrieveWebSearch(message, provider, {signal, onSearching = () => {}} = {}) {
export async function retrieveWebSearch(message, provider, {signal, onSearching = () => {}, referenceText = ""} = {}) {
const decision = decideWebSearch(message);
if (!decision.shouldSearch) return {decision, state: "skipped", queries: [], candidates: [], sources: null};
onSearching();
const queries = [];
let candidates = [];
let sources = [];
for (const query of buildWebSearchQueries(message, decision)) {
// Image evidence may clarify "is this accurate?"; it must never alter the user's tool decision.
const searchMessage = referenceText ? `${message} ${referenceText}` : message;
for (const query of buildWebSearchQueries(searchMessage, decision)) {
queries.push(query);
try {
const results = await provider.search(query, {
@@ -125,7 +127,7 @@ export async function retrieveWebSearch(message, provider, {signal, onSearching
const merged = [...results, ...candidates].sort((a, b) =>
Number(Boolean(b?.snippet || b?.content)) - Number(Boolean(a?.snippet || a?.content)));
candidates = normalizeSearchCandidates(merged, MAX_CANDIDATES * 2);
sources = selectSearchResults(message, candidates, decision);
sources = selectSearchResults(searchMessage, candidates, decision);
if (sources.length) break;
} catch (error) {
if (signal?.aborted || queries.length === 1) throw error;
+1
View File
@@ -86,6 +86,7 @@ function validateMessage(message, chatIndex, messageIndex, errors) {
content,
model_name: optionalText(message.model_name || message.modelName),
thinking: optionalText(message.thinking),
attachments: asArray(message.attachments).filter(item => item && typeof item.id === "string" && /^[a-f0-9-]{36}$/.test(item.id)).slice(0, 100).map(item => ({id: item.id})),
...(message.sources ? {sources: parseSources(message.sources)} : {}),
retryVariants,
retryActiveIndex: Number.isInteger(Number(message.retryActiveIndex ?? message.retry_active_index))