Improve chat title generation: refine model selection, ensure title consistency, and add comprehensive tests
This commit is contained in:
+1
-1
@@ -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),
|
||||
|
||||
@@ -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};
|
||||
},
|
||||
|
||||
@@ -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());
|
||||
|
||||
Reference in New Issue
Block a user