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),
|
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),
|
||||||
|
|||||||
@@ -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};
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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());
|
||||||
|
|||||||
Reference in New Issue
Block a user