Chat title generation
This commit is contained in:
@@ -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" }`
|
||||||
|
|||||||
@@ -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() });
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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" });
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user