Chat title generation
This commit is contained in:
@@ -41,6 +41,25 @@ Content type:
|
||||
- Body: `{ "title"?: string }`
|
||||
- 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`
|
||||
- Response: `{ "deleted": true }`
|
||||
- Not found: `404 { "message": "chat not found" }`
|
||||
|
||||
@@ -7,6 +7,7 @@ import { env } from "./env.js";
|
||||
import { runMultiplex } from "./llm/multiplexer.js";
|
||||
import { runMultiplexStream } from "./llm/streaming.js";
|
||||
import { getModelCatalogSnapshot } from "./llm/model-catalog.js";
|
||||
import { openaiClient } from "./llm/providers.js";
|
||||
import { exaClient } from "./search/exa.js";
|
||||
|
||||
type IncomingChatMessage = {
|
||||
@@ -100,6 +101,33 @@ function parseAnswerText(answerResponse: any) {
|
||||
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) {
|
||||
if (!input) return "";
|
||||
try {
|
||||
@@ -159,6 +187,56 @@ export async function registerRoutes(app: FastifyInstance) {
|
||||
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) => {
|
||||
requireAdmin(req);
|
||||
const Params = z.object({ chatId: z.string() });
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
listSearches,
|
||||
runCompletionStream,
|
||||
runSearchStream,
|
||||
suggestChatTitle,
|
||||
type ModelCatalogResponse,
|
||||
type Provider,
|
||||
type ChatDetail,
|
||||
@@ -269,6 +270,7 @@ export default function App() {
|
||||
const transcriptEndRef = useRef<HTMLDivElement>(null);
|
||||
const contextMenuRef = useRef<HTMLDivElement>(null);
|
||||
const selectedItemRef = useRef<SidebarSelection | null>(null);
|
||||
const pendingTitleGenerationRef = useRef<Set<string>>(new Set());
|
||||
const searchRunAbortRef = useRef<AbortController | null>(null);
|
||||
const searchRunCounterRef = useRef(0);
|
||||
const [contextMenu, setContextMenu] = useState<ContextMenuState | null>(null);
|
||||
@@ -580,6 +582,10 @@ export default function App() {
|
||||
const chat = await createChat();
|
||||
chatId = chat.id;
|
||||
setDraftKind(null);
|
||||
setChats((current) => {
|
||||
const withoutExisting = current.filter((existing) => existing.id !== chat.id);
|
||||
return [chat, ...withoutExisting];
|
||||
});
|
||||
setSelectedItem({ kind: "chat", id: chatId });
|
||||
setPendingChatState((current) => (current ? { ...current, chatId } : current));
|
||||
setSelectedChat({
|
||||
@@ -618,6 +624,31 @@ export default function App() {
|
||||
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;
|
||||
|
||||
await runCompletionStream(
|
||||
|
||||
@@ -178,6 +178,22 @@ export async function getChat(chatId: string) {
|
||||
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) {
|
||||
await api<{ deleted: true }>(`/v1/chats/${chatId}`, { method: "DELETE" });
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user