Chat title generation

This commit is contained in:
2026-02-14 21:27:44 -08:00
parent 7ef2825c16
commit 684d441763
4 changed files with 144 additions and 0 deletions

View File

@@ -41,6 +41,25 @@ Content type:
- Body: `{ "title"?: string }` - Body: `{ "title"?: string }`
- Response: `{ "chat": ChatSummary }` - Response: `{ "chat": ChatSummary }`
### `PATCH /v1/chats/:chatId`
- Body: `{ "title": string }`
- Response: `{ "chat": ChatSummary }`
- Not found: `404 { "message": "chat not found" }`
### `POST /v1/chats/title/suggest`
- Body:
```json
{
"chatId": "chat-id",
"content": "user request text"
}
```
- Response: `{ "chat": ChatSummary }`
Behavior notes:
- If the chat already has a non-empty title, server returns the existing chat unchanged.
- Server always uses OpenAI `gpt-4.1-mini` to generate a one-line title (up to ~4 words), updates the chat title, and returns the updated chat.
### `DELETE /v1/chats/:chatId` ### `DELETE /v1/chats/:chatId`
- Response: `{ "deleted": true }` - Response: `{ "deleted": true }`
- Not found: `404 { "message": "chat not found" }` - Not found: `404 { "message": "chat not found" }`

View File

@@ -7,6 +7,7 @@ import { env } from "./env.js";
import { runMultiplex } from "./llm/multiplexer.js"; import { runMultiplex } from "./llm/multiplexer.js";
import { runMultiplexStream } from "./llm/streaming.js"; import { runMultiplexStream } from "./llm/streaming.js";
import { getModelCatalogSnapshot } from "./llm/model-catalog.js"; import { getModelCatalogSnapshot } from "./llm/model-catalog.js";
import { openaiClient } from "./llm/providers.js";
import { exaClient } from "./search/exa.js"; import { exaClient } from "./search/exa.js";
type IncomingChatMessage = { type IncomingChatMessage = {
@@ -100,6 +101,33 @@ function parseAnswerText(answerResponse: any) {
return null; return null;
} }
function normalizeSuggestedTitle(raw: string, fallback: string) {
const oneLine = raw
.replace(/\r?\n+/g, " ")
.replace(/^['"`\s]+|['"`\s]+$/g, "")
.replace(/\s+/g, " ")
.trim();
const fromRaw = oneLine || fallback;
const words = fromRaw.split(/\s+/).filter(Boolean);
return words.slice(0, 4).join(" ").slice(0, 64).trim() || fallback;
}
async function generateChatTitle(content: string) {
const systemPrompt =
"You create short chat titles. Return exactly one line, maximum 4 words, no quotes, no trailing punctuation.";
const userPrompt = `User request:\n${content}\n\nTitle:`;
const response = await openaiClient().chat.completions.create({
model: "gpt-4.1-mini",
temperature: 0,
max_completion_tokens: 20,
messages: [
{ role: "system", content: systemPrompt },
{ role: "user", content: userPrompt },
],
});
return response.choices?.[0]?.message?.content ?? "";
}
function normalizeUrlForMatch(input: string | null | undefined) { function normalizeUrlForMatch(input: string | null | undefined) {
if (!input) return ""; if (!input) return "";
try { try {
@@ -159,6 +187,56 @@ export async function registerRoutes(app: FastifyInstance) {
return { chat }; return { chat };
}); });
app.patch("/v1/chats/:chatId", async (req) => {
requireAdmin(req);
const Params = z.object({ chatId: z.string() });
const Body = z.object({ title: z.string().trim().min(1) });
const { chatId } = Params.parse(req.params);
const body = Body.parse(req.body ?? {});
const updated = await prisma.chat.updateMany({
where: { id: chatId },
data: { title: body.title },
});
if (updated.count === 0) return app.httpErrors.notFound("chat not found");
const chat = await prisma.chat.findUnique({
where: { id: chatId },
select: { id: true, title: true, createdAt: true, updatedAt: true },
});
if (!chat) return app.httpErrors.notFound("chat not found");
return { chat };
});
app.post("/v1/chats/title/suggest", async (req) => {
requireAdmin(req);
const Body = z.object({
chatId: z.string(),
content: z.string().trim().min(1),
});
const body = Body.parse(req.body ?? {});
const existing = await prisma.chat.findUnique({
where: { id: body.chatId },
select: { id: true, title: true, createdAt: true, updatedAt: true },
});
if (!existing) return app.httpErrors.notFound("chat not found");
if (existing.title?.trim()) return { chat: existing };
const fallback = body.content.split(/\r?\n/)[0]?.trim().slice(0, 48) || "New chat";
const suggestedRaw = await generateChatTitle(body.content);
const title = normalizeSuggestedTitle(suggestedRaw, fallback);
const chat = await prisma.chat.update({
where: { id: body.chatId },
data: { title },
select: { id: true, title: true, createdAt: true, updatedAt: true },
});
return { chat };
});
app.delete("/v1/chats/:chatId", async (req) => { app.delete("/v1/chats/:chatId", async (req) => {
requireAdmin(req); requireAdmin(req);
const Params = z.object({ chatId: z.string() }); const Params = z.object({ chatId: z.string() });

View File

@@ -18,6 +18,7 @@ import {
listSearches, listSearches,
runCompletionStream, runCompletionStream,
runSearchStream, runSearchStream,
suggestChatTitle,
type ModelCatalogResponse, type ModelCatalogResponse,
type Provider, type Provider,
type ChatDetail, type ChatDetail,
@@ -269,6 +270,7 @@ export default function App() {
const transcriptEndRef = useRef<HTMLDivElement>(null); const transcriptEndRef = useRef<HTMLDivElement>(null);
const contextMenuRef = useRef<HTMLDivElement>(null); const contextMenuRef = useRef<HTMLDivElement>(null);
const selectedItemRef = useRef<SidebarSelection | null>(null); const selectedItemRef = useRef<SidebarSelection | null>(null);
const pendingTitleGenerationRef = useRef<Set<string>>(new Set());
const searchRunAbortRef = useRef<AbortController | null>(null); const searchRunAbortRef = useRef<AbortController | null>(null);
const searchRunCounterRef = useRef(0); const searchRunCounterRef = useRef(0);
const [contextMenu, setContextMenu] = useState<ContextMenuState | null>(null); const [contextMenu, setContextMenu] = useState<ContextMenuState | null>(null);
@@ -580,6 +582,10 @@ export default function App() {
const chat = await createChat(); const chat = await createChat();
chatId = chat.id; chatId = chat.id;
setDraftKind(null); setDraftKind(null);
setChats((current) => {
const withoutExisting = current.filter((existing) => existing.id !== chat.id);
return [chat, ...withoutExisting];
});
setSelectedItem({ kind: "chat", id: chatId }); setSelectedItem({ kind: "chat", id: chatId });
setPendingChatState((current) => (current ? { ...current, chatId } : current)); setPendingChatState((current) => (current ? { ...current, chatId } : current));
setSelectedChat({ setSelectedChat({
@@ -618,6 +624,31 @@ export default function App() {
throw new Error("No model available for selected provider"); throw new Error("No model available for selected provider");
} }
const chatSummary = chats.find((chat) => chat.id === chatId);
const hasExistingTitle = Boolean(selectedChat?.id === chatId ? selectedChat.title?.trim() : chatSummary?.title?.trim());
if (!hasExistingTitle && !pendingTitleGenerationRef.current.has(chatId)) {
pendingTitleGenerationRef.current.add(chatId);
void suggestChatTitle({ chatId, content })
.then((updatedChat) => {
setChats((current) =>
current.map((chat) => {
if (chat.id !== updatedChat.id) return chat;
return { ...chat, title: updatedChat.title, updatedAt: updatedChat.updatedAt };
})
);
setSelectedChat((current) => {
if (!current || current.id !== updatedChat.id) return current;
return { ...current, title: updatedChat.title, updatedAt: updatedChat.updatedAt };
});
})
.catch(() => {
// ignore title suggestion errors so chat flow is not interrupted
})
.finally(() => {
pendingTitleGenerationRef.current.delete(chatId);
});
}
let streamErrorMessage: string | null = null; let streamErrorMessage: string | null = null;
await runCompletionStream( await runCompletionStream(

View File

@@ -178,6 +178,22 @@ export async function getChat(chatId: string) {
return data.chat; return data.chat;
} }
export async function updateChatTitle(chatId: string, title: string) {
const data = await api<{ chat: ChatSummary }>(`/v1/chats/${chatId}`, {
method: "PATCH",
body: JSON.stringify({ title }),
});
return data.chat;
}
export async function suggestChatTitle(body: { chatId: string; content: string }) {
const data = await api<{ chat: ChatSummary }>("/v1/chats/title/suggest", {
method: "POST",
body: JSON.stringify(body),
});
return data.chat;
}
export async function deleteChat(chatId: string) { export async function deleteChat(chatId: string) {
await api<{ deleted: true }>(`/v1/chats/${chatId}`, { method: "DELETE" }); await api<{ deleted: true }>(`/v1/chats/${chatId}`, { method: "DELETE" });
} }