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
+11 -1
View File
@@ -16,6 +16,8 @@ import {createPagesRouter} from "./routes/pages.js";
import {createPersonasRouter} from "./routes/personas.js";
import {createSettingsRouter} from "./routes/settings.js";
import {createSystemRouter} from "./routes/system.js";
import {createAttachmentService} from "./services/attachmentService.js";
import {createUploadsRouter} from "./routes/uploads.js";
export function createCloseHandler(resources) {
let closed = false;
@@ -245,13 +247,15 @@ export function createApp(options = {}) {
const db = createDatabase(config);
const repositories = createRepositories(db, config);
const modelService = options.modelService || createModelService(config, options.modelOverrides);
const chatService = createChatService(repositories, modelService, config);
const attachmentService = createAttachmentService(db, config, modelService);
const chatService = createChatService(repositories, modelService, config, undefined, attachmentService);
const FileStore = FileStoreFactory(session);
const authRateLimiters = createAuthRateLimiters(config);
const chatRateLimiters = createChatRateLimiters(config);
closeHandlers.push(
...authRateLimiters.map((limiter) => limiter.close),
...chatRateLimiters.map((limiter) => limiter.close),
() => attachmentService.close(),
() => db.close()
);
@@ -271,11 +275,16 @@ export function createApp(options = {}) {
}));
app.use(sameOriginProtection(config));
app.use(enforceSessionVersion(repositories));
app.use((req, res, next) => {
if (["DELETE", "POST"].includes(req.method)) res.on("finish", () => { void attachmentService.cleanup(); });
next();
});
app.locals.config = config;
app.locals.db = db;
app.locals.repositories = repositories;
app.locals.modelService = modelService;
app.locals.attachmentService = attachmentService;
app.locals.observability = observability;
app.locals.close = createCloseHandler(closeHandlers);
@@ -290,6 +299,7 @@ export function createApp(options = {}) {
fallthrough: config.staticFallthrough
}));
app.use(createAuthRouter({repositories, authRateLimiters, config}));
app.use(createUploadsRouter(attachmentService, config));
app.use(createSettingsRouter({repositories}));
app.use(createChatsRouter({repositories, chatService, modelService, config, chatRateLimiters}));
app.use(createPersonasRouter({repositories, chatService, modelService, config}));
+5
View File
@@ -29,6 +29,11 @@ export function createConfig(overrides = {}) {
rootDir,
publicDir: path.resolve(rootDir, "public"),
dataDir,
uploadDir: process.env.UPLOAD_DIR || (process.env.NODE_ENV === "production" ? "/app/data/uploads" : path.resolve(dataDir, "uploads")),
uploadMaxSizeMb: Math.max(1, int(process.env.UPLOAD_MAX_SIZE_MB, 10)),
uploadMaxFiles: Math.max(1, int(process.env.UPLOAD_MAX_FILES, 5)),
uploadMaxPixels: Math.max(1, int(process.env.UPLOAD_MAX_PIXELS, 30000000)),
visionModel: process.env.VISION_MODEL || "gemma3:4b",
sessionsDir,
port: int(process.env.PORT, 3000),
dbPath: process.env.DB_PATH || path.resolve(dataDir, "app.db"),
+2
View File
@@ -1,6 +1,7 @@
import fs from "fs";
import Database from "better-sqlite3";
import {initializeSchema, runMigrations} from "./schema.js";
import {initializeAttachments} from "./attachments.js";
export function createDatabase(config) {
if (config.dbPath !== ":memory:") {
@@ -10,5 +11,6 @@ export function createDatabase(config) {
const db = new Database(config.dbPath);
initializeSchema(db);
runMigrations(db, config);
initializeAttachments(db);
return db;
}
+8
View File
@@ -1,3 +1,5 @@
import {attachmentMetadata} from "./attachments.js";
const parseJsonArray = (value) => {
if (!value) return [];
try {
@@ -869,6 +871,11 @@ export function createRepositories(db, config) {
if (message.sourceId != null) importedMessageIds.set(message.sourceId, Number(result.lastInsertRowid));
if (message.sources) statements.updateChatMessageSources.run(JSON.stringify(message.sources), result.lastInsertRowid, chatId);
if (message.thinking) statements.updateChatMessageThinking.run(message.thinking, result.lastInsertRowid, chatId);
for (const [position, attachment] of (message.attachments || []).slice(0, config.uploadMaxFiles || 5).entries()) {
// JSON backups contain references, not files. Only restore existing, owned images.
db.prepare("INSERT OR IGNORE INTO message_attachments(message_id,attachment_id,position) SELECT ?,id,? FROM attachments WHERE id=? AND username=? AND status='attached'")
.run(result.lastInsertRowid, position, attachment.id, username);
}
}
statements.updateChatGenerationSettings.run(
chat.preferred_model ?? null,
@@ -1271,6 +1278,7 @@ export function createRepositories(db, config) {
persona_state: this.getPersonaChatState(chat.id, username),
messages: this.listChatMessages(chat.id).map((message) => ({
...message,
attachments: db.prepare("SELECT a.* FROM attachments a JOIN message_attachments m ON a.id=m.attachment_id WHERE m.message_id=? ORDER BY m.position").all(message.id).map(attachmentMetadata),
retryVariants: parseJsonArray(message.retry_variants)
}))
})),
+6 -2
View File
@@ -17,13 +17,15 @@ const messageValidator = (body) => {
};
const sendMessageValidator = (body) => {
const message = String(body?.message || "").trim();
const attachments = body?.attachments ?? [];
if (!Array.isArray(attachments) || attachments.some(id => typeof id !== "string" || !/^[a-f0-9-]{36}$/.test(id))) return {error: "Invalid attachment IDs"};
const message = String(body?.message || "").trim() || (attachments.length ? "Describe the attached images." : "");
const chatId = Number(body?.chatId);
if (!chatId) return {error: "chatId is required"};
if (!message) return {error: "message is required"};
if (body.webSearch !== undefined && typeof body.webSearch !== "boolean") return {error: "webSearch must be a boolean"};
if (body.reasoning !== undefined && typeof body.reasoning !== "boolean") return {error: "reasoning must be a boolean"};
return {value: {message, chatId, model: String(body?.model || "qwen3:14b"), webSearch: body.webSearch === true, reasoning: body.reasoning === true}};
return {value: {message, chatId, attachments, model: String(body?.model || "qwen3:14b"), webSearch: body.webSearch === true, reasoning: body.reasoning === true}};
};
const organizationValidator = (body) => ({
@@ -413,6 +415,7 @@ export function createChatsRouter({repositories, chatService, modelService, conf
user: req.session.user,
chatId: req.validatedBody.chatId,
message: req.validatedBody.message,
attachments: req.validatedBody.attachments,
model: req.validatedBody.model,
webSearch: req.validatedBody.webSearch,
reasoning: req.validatedBody.reasoning
@@ -449,6 +452,7 @@ export function createChatsRouter({repositories, chatService, modelService, conf
user: req.session.user,
chatId: req.validatedBody.chatId,
message: req.validatedBody.message,
attachments: req.validatedBody.attachments,
model: req.validatedBody.model,
onChunk: (chunk, fullReply) => sendEvent("chunk", {chunk, fullReply}),
onThinking: (chunk, fullThinking) => sendEvent("thinking", {chunk, fullThinking}),
+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))