improve reasoning toggle tests and refactor model capability checks
This commit is contained in:
+11
-1
@@ -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}));
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
@@ -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}),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}};
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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))
|
||||
|
||||
Reference in New Issue
Block a user