From 72d5c8b2dce0e7956195fd04ec3fa086edf3cee6 Mon Sep 17 00:00:00 2001 From: fretuks Date: Fri, 16 Oct 2026 16:15:55 +0200 Subject: [PATCH] Improve chat title generation: refine model selection, ensure title consistency, and add comprehensive tests --- src/config.js | 2 +- src/services/chatService.js | 16 +++++++++++---- test/app.test.js | 40 +++++++++++++++++++++++++++++++++++++ 3 files changed, 53 insertions(+), 5 deletions(-) diff --git a/src/config.js b/src/config.js index a063d6f..6745838 100644 --- a/src/config.js +++ b/src/config.js @@ -78,7 +78,7 @@ export function createConfig(overrides = {}) { contextCompactionMinMessages: positiveInt(process.env.CONTEXT_COMPACTION_MIN_MESSAGES, 4), contextCompactionMaxChars: positiveInt(process.env.CONTEXT_COMPACTION_MAX_CHARS, 8000), 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", modelRequestTimeoutMs: positiveInt(process.env.MODEL_REQUEST_TIMEOUT_MS, 180000), modelConnectTimeoutMs: positiveInt(process.env.MODEL_CONNECT_TIMEOUT_MS, 30000), diff --git a/src/services/chatService.js b/src/services/chatService.js index 2aed6a7..bb02153 100644 --- a/src/services/chatService.js +++ b/src/services/chatService.js @@ -20,6 +20,7 @@ import {createHash} from "node:crypto"; import { SearXNGSearchProvider, WebSearchError, decideWebSearch, retrieveWebSearch } from "./webSearchService.js"; import { supportsReasoning } from "../../public/js/modelCapabilities.js"; +import {titleFromFirstMessage} from "../../public/js/chatTitle.js"; import {VISION_EVIDENCE_GUIDANCE} from "./visionAnalysis.js"; import {createConversationContextBuilder} from "./conversationContext.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 { signal?.throwIfAborted(); } - return "New chat"; + return titleFromFirstMessage(message); }; const persistChatMessageRetryState = ({ chatId, messageId, content, retryVariants, retryActiveIndex, retryRetriesUsed, retryPromptMessageId }) => { @@ -734,13 +735,20 @@ export function createChatService(repositories, modelService, config, webSearchP }, persistChatMessageRetryState, buildMessagesPayload: buildConversationContext, - async generateInitialChatTitle({user, chatId, message, signal = null}) { + async generateInitialChatTitle({user, chatId, message, model, signal = null}) { const session = repositories.getChat(chatId, user); 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}; } - 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); return {title}; }, diff --git a/test/app.test.js b/test/app.test.js index d792a93..67baadd 100644 --- a/test/app.test.js +++ b/test/app.test.js @@ -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/); }); +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 => { const server = await startTestServer(); t.after(() => server.close());