diff --git a/docs/api/rest.md b/docs/api/rest.md index c86ae9d..692ba06 100644 --- a/docs/api/rest.md +++ b/docs/api/rest.md @@ -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" }` diff --git a/server/src/routes.ts b/server/src/routes.ts index 62497e9..6ec214f 100644 --- a/server/src/routes.ts +++ b/server/src/routes.ts @@ -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() }); diff --git a/web/src/App.tsx b/web/src/App.tsx index 392002f..098bbf2 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -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(null); const contextMenuRef = useRef(null); const selectedItemRef = useRef(null); + const pendingTitleGenerationRef = useRef>(new Set()); const searchRunAbortRef = useRef(null); const searchRunCounterRef = useRef(0); const [contextMenu, setContextMenu] = useState(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( diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index 598cb6f..d86abeb 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -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" }); }