Improve chat title generation: refine model selection, ensure title consistency, and add comprehensive tests
CI / check (22) (push) Failing after 4m53s
CI / check (24) (push) Successful in 9m43s

This commit is contained in:
2026-10-16 16:15:55 +02:00
parent 448fe53811
commit 72d5c8b2dc
3 changed files with 53 additions and 5 deletions
+1 -1
View File
@@ -78,7 +78,7 @@ export function createConfig(overrides = {}) {
contextCompactionMinMessages: positiveInt(process.env.CONTEXT_COMPACTION_MIN_MESSAGES, 4), contextCompactionMinMessages: positiveInt(process.env.CONTEXT_COMPACTION_MIN_MESSAGES, 4),
contextCompactionMaxChars: positiveInt(process.env.CONTEXT_COMPACTION_MAX_CHARS, 8000), contextCompactionMaxChars: positiveInt(process.env.CONTEXT_COMPACTION_MAX_CHARS, 8000),
chatMaxRetries: nonNegativeInt(process.env.CHAT_MAX_RETRIES, 5), chatMaxRetries: nonNegativeInt(process.env.CHAT_MAX_RETRIES, 5),
titleModel: process.env.TITLE_MODEL || "nomic-embed-text:latest", titleModel: process.env.TITLE_MODEL || null,
modelApiBaseUrl: process.env.MODEL_API_BASE_URL || "https://ai.krishd.ch/api", modelApiBaseUrl: process.env.MODEL_API_BASE_URL || "https://ai.krishd.ch/api",
modelRequestTimeoutMs: positiveInt(process.env.MODEL_REQUEST_TIMEOUT_MS, 180000), modelRequestTimeoutMs: positiveInt(process.env.MODEL_REQUEST_TIMEOUT_MS, 180000),
modelConnectTimeoutMs: positiveInt(process.env.MODEL_CONNECT_TIMEOUT_MS, 30000), modelConnectTimeoutMs: positiveInt(process.env.MODEL_CONNECT_TIMEOUT_MS, 30000),
+12 -4
View File
@@ -20,6 +20,7 @@ import {createHash} from "node:crypto";
import { SearXNGSearchProvider, WebSearchError, decideWebSearch, retrieveWebSearch } from "./webSearchService.js"; import { SearXNGSearchProvider, WebSearchError, decideWebSearch, retrieveWebSearch } from "./webSearchService.js";
import { supportsReasoning } from "../../public/js/modelCapabilities.js"; import { supportsReasoning } from "../../public/js/modelCapabilities.js";
import {titleFromFirstMessage} from "../../public/js/chatTitle.js";
import {VISION_EVIDENCE_GUIDANCE} from "./visionAnalysis.js"; import {VISION_EVIDENCE_GUIDANCE} from "./visionAnalysis.js";
import {createConversationContextBuilder} from "./conversationContext.js"; import {createConversationContextBuilder} from "./conversationContext.js";
import {COMPACTION_SCHEMA, COMPACTION_SYSTEM_PROMPT, buildCompactionRequest, estimateTokens, isCompactionEligibleMessage, normalizeCompaction} from "./contextCompaction.js"; import {COMPACTION_SCHEMA, COMPACTION_SYSTEM_PROMPT, buildCompactionRequest, estimateTokens, isCompactionEligibleMessage, normalizeCompaction} from "./contextCompaction.js";
@@ -155,7 +156,7 @@ export function createChatService(repositories, modelService, config, webSearchP
} catch { } catch {
signal?.throwIfAborted(); signal?.throwIfAborted();
} }
return "New chat"; return titleFromFirstMessage(message);
}; };
const persistChatMessageRetryState = ({ chatId, messageId, content, retryVariants, retryActiveIndex, retryRetriesUsed, retryPromptMessageId }) => { const persistChatMessageRetryState = ({ chatId, messageId, content, retryVariants, retryActiveIndex, retryRetriesUsed, retryPromptMessageId }) => {
@@ -734,13 +735,20 @@ export function createChatService(repositories, modelService, config, webSearchP
}, },
persistChatMessageRetryState, persistChatMessageRetryState,
buildMessagesPayload: buildConversationContext, buildMessagesPayload: buildConversationContext,
async generateInitialChatTitle({user, chatId, message, signal = null}) { async generateInitialChatTitle({user, chatId, message, model, signal = null}) {
const session = repositories.getChat(chatId, user); const session = repositories.getChat(chatId, user);
if (!session) return {error: "Chat not found", status: 404}; if (!session) return {error: "Chat not found", status: 404};
if (session.assistant_persona_id || session.title !== "New chat" || repositories.getLatestChatMessage(chatId)) { if (session.assistant_persona_id || session.title !== "New chat") {
return {title: session.title}; return {title: session.title};
} }
const title = await generateChatTitle(message, config.titleModel || "nomic-embed-text:latest", signal); const firstMessage = repositories.getChatMessageByIndex(chatId, 0);
if (firstMessage && (firstMessage.role !== "user" || firstMessage.content.trim() !== String(message).trim())) {
return {title: session.title};
}
const title = await generateChatTitle(message, config.titleModel || model || session.preferred_model || "qwen3:14b", signal);
const currentSession = repositories.getChat(chatId, user);
if (!currentSession) return {error: "Chat not found", status: 404};
if (currentSession.title !== "New chat") return {title: currentSession.title};
if (title !== "New chat") repositories.updateChatTitle(chatId, user, title); if (title !== "New chat") repositories.updateChatTitle(chatId, user, title);
return {title}; return {title};
}, },
+40
View File
@@ -138,6 +138,46 @@ test("CSP restricts scripts, styles, and fonts to local assets", async t => {
assert.doesNotMatch(policy, /unsafe-inline|cdnjs\.cloudflare\.com|fonts\.googleapis\.com|fonts\.gstatic\.com/); assert.doesNotMatch(policy, /unsafe-inline|cdnjs\.cloudflare\.com|fonts\.googleapis\.com|fonts\.gstatic\.com/);
}); });
test("first chat message receives a generated title after the reply is saved", async t => {
const server = await startTestServer();
t.after(() => server.close());
const jar = new CookieJar();
await request(server.baseUrl, "/register", {method: "POST", jar, body: {username: "title-owner", password: "password123"}});
const chat = (await request(server.baseUrl, "/chats", {method: "POST", jar, body: {title: "New chat"}})).json.chat;
const repo = server.app.locals.repositories;
const message = "Plan a weekend trip to Lucerne";
repo.insertChatMessage(chat.id, "user", message);
repo.insertChatMessage(chat.id, "bot", "Let's plan it.");
const usedModels = [];
t.mock.method(server.app.locals.modelService, "generateReply", async model => {
usedModels.push(model);
return JSON.stringify({title: "Weekend in Lucerne"});
});
const titled = await request(server.baseUrl, `/chats/${chat.id}/title`, {
method: "POST", jar, body: {message, model: "chosen-chat-model"}
});
assert.equal(titled.status, 200);
assert.equal(titled.json.title, "Weekend in Lucerne");
assert.equal(repo.getChat(chat.id, "title-owner").title, "Weekend in Lucerne");
assert.deepEqual(usedModels, ["chosen-chat-model"]);
const another = (await request(server.baseUrl, "/chats", {method: "POST", jar, body: {title: "New chat"}})).json.chat;
repo.insertChatMessage(another.id, "user", message);
const mismatch = await request(server.baseUrl, `/chats/${another.id}/title`, {
method: "POST", jar, body: {message: "Unrelated prompt", model: "chosen-chat-model"}
});
assert.equal(mismatch.json.title, "New chat");
assert.equal(usedModels.length, 1);
server.app.locals.modelService.generateReply.mock.mockImplementation(async () => { throw new Error("model unavailable"); });
const fallback = await request(server.baseUrl, `/chats/${another.id}/title`, {
method: "POST", jar, body: {message, model: "chosen-chat-model"}
});
assert.equal(fallback.status, 200);
assert.equal(fallback.json.title, "Plan a weekend trip to Lucerne");
});
test("personality API keeps prompts private, validates IDs, and enforces chat ownership", async t => { test("personality API keeps prompts private, validates IDs, and enforces chat ownership", async t => {
const server = await startTestServer(); const server = await startTestServer();
t.after(() => server.close()); t.after(() => server.close());