When a chat is auto-named, also rename the parent session if it is still on its default 'New session' label. UPDATE is gated by an atomic WHERE clause so user renames and prior propagations are not clobbered. Publishes session_renamed via broker.publishUser; useSidebar already listens. Closes the gap where sessions auto-created from the sidebar would stay 'New session' forever. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
167 lines
4.9 KiB
TypeScript
167 lines
4.9 KiB
TypeScript
import type { InferenceContext } from './inference.js';
|
||
|
||
const NAMING_SYSTEM_PROMPT =
|
||
'You name chat sessions. Reply directly with no thinking, reasoning, or explanation. Output ONLY the title, 4 words max, no quotes, no punctuation, no prefix like "Title:".';
|
||
|
||
const MAX_TITLE_CHARS = 60;
|
||
|
||
function cleanTitle(raw: string): string {
|
||
let name = raw.trim();
|
||
const quotes = ['"', "'", '`', '‘', '’', '“', '”'];
|
||
while (name.length >= 2 && quotes.includes(name[0]!) && quotes.includes(name[name.length - 1]!)) {
|
||
name = name.slice(1, -1).trim();
|
||
}
|
||
name = name.replace(/^title\s*:\s*/i, '').trim();
|
||
if (name.length > MAX_TITLE_CHARS) {
|
||
name = name.slice(0, MAX_TITLE_CHARS).trim();
|
||
}
|
||
return name;
|
||
}
|
||
|
||
interface NamingResponse {
|
||
choices?: Array<{
|
||
message?: {
|
||
content?: string;
|
||
reasoning_content?: string;
|
||
};
|
||
}>;
|
||
}
|
||
|
||
function pickTitleSource(data: NamingResponse): string {
|
||
const choice = data.choices?.[0]?.message;
|
||
if (!choice) return '';
|
||
if (choice.content && choice.content.trim().length > 0) return choice.content;
|
||
const reasoning = choice.reasoning_content ?? '';
|
||
if (reasoning.length === 0) return '';
|
||
const lines = reasoning
|
||
.split('\n')
|
||
.map((l) => l.trim())
|
||
.filter((l) => l.length > 0);
|
||
return lines[lines.length - 1] ?? '';
|
||
}
|
||
|
||
export async function maybeAutoNameChat(
|
||
ctx: InferenceContext,
|
||
chatId: string,
|
||
sessionId: string
|
||
): Promise<void> {
|
||
const counts = await ctx.sql<{ n: number }[]>`
|
||
SELECT COUNT(*)::int AS n
|
||
FROM messages
|
||
WHERE chat_id = ${chatId}
|
||
AND role = 'assistant'
|
||
AND status = 'complete'
|
||
AND content <> ''
|
||
`;
|
||
if ((counts[0]?.n ?? 0) < 1) return;
|
||
|
||
const chatRows = await ctx.sql<
|
||
{ id: string; name: string | null; session_id: string }[]
|
||
>`
|
||
SELECT id, name, session_id FROM chats WHERE id = ${chatId}
|
||
`;
|
||
const chat = chatRows[0];
|
||
if (!chat) return;
|
||
if (chat.name !== null && chat.name !== '') return;
|
||
|
||
const sessionRows = await ctx.sql<{ model: string }[]>`
|
||
SELECT model FROM sessions WHERE id = ${sessionId}
|
||
`;
|
||
const model = sessionRows[0]?.model;
|
||
if (!model) return;
|
||
|
||
const userMsg = await ctx.sql<{ content: string }[]>`
|
||
SELECT content FROM messages
|
||
WHERE chat_id = ${chatId} AND role = 'user'
|
||
ORDER BY created_at ASC
|
||
LIMIT 1
|
||
`;
|
||
const assistantMsg = await ctx.sql<{ content: string }[]>`
|
||
SELECT content FROM messages
|
||
WHERE chat_id = ${chatId}
|
||
AND role = 'assistant'
|
||
AND status = 'complete'
|
||
AND content <> ''
|
||
ORDER BY created_at ASC
|
||
LIMIT 1
|
||
`;
|
||
if (!userMsg[0] || !assistantMsg[0]) return;
|
||
|
||
const userText = userMsg[0].content.slice(0, 2000);
|
||
const assistantText = assistantMsg[0].content.slice(0, 2000);
|
||
|
||
const body = {
|
||
model,
|
||
messages: [
|
||
{ role: 'system', content: NAMING_SYSTEM_PROMPT },
|
||
{
|
||
role: 'user',
|
||
content: `First user message: ${userText}\nFirst assistant reply: ${assistantText}`,
|
||
},
|
||
],
|
||
max_tokens: 30,
|
||
temperature: 0.3,
|
||
stream: false,
|
||
chat_template_kwargs: { enable_thinking: false },
|
||
};
|
||
|
||
const res = await fetch(`${ctx.config.LLAMA_SWAP_URL}/v1/chat/completions`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(body),
|
||
});
|
||
if (!res.ok) {
|
||
const text = await res.text().catch(() => '');
|
||
throw new Error(`naming request failed: ${res.status} ${text.slice(0, 200)}`);
|
||
}
|
||
const data = (await res.json()) as NamingResponse;
|
||
const raw = pickTitleSource(data);
|
||
const name = cleanTitle(raw);
|
||
if (!name) {
|
||
ctx.log.warn({ chatId, raw }, 'auto-name: empty title from model');
|
||
return;
|
||
}
|
||
|
||
const updated = await ctx.sql<{ id: string; name: string; session_id: string; updated_at: string }[]>`
|
||
UPDATE chats
|
||
SET name = ${name}, updated_at = clock_timestamp()
|
||
WHERE id = ${chatId}
|
||
AND (name IS NULL OR name = '')
|
||
RETURNING id, name, session_id, updated_at
|
||
`;
|
||
if (updated.length === 0) return;
|
||
|
||
ctx.publish(sessionId, {
|
||
type: 'chat_renamed',
|
||
chat_id: chatId,
|
||
name,
|
||
});
|
||
ctx.publishUser({
|
||
type: 'chat_updated',
|
||
chat_id: chatId,
|
||
session_id: sessionId,
|
||
name,
|
||
updated_at: updated[0]!.updated_at,
|
||
});
|
||
ctx.log.info({ chatId, name }, 'chat auto-named');
|
||
|
||
// Propagate to the parent session if it's still on its default name.
|
||
// The WHERE guard makes the check atomic — if the user has already
|
||
// renamed (or a prior chat already propagated), this UPDATE matches
|
||
// zero rows and we do nothing. First chat wins; manual renames win.
|
||
const renamedSession = await ctx.sql<{ id: string; name: string }[]>`
|
||
UPDATE sessions
|
||
SET name = ${name}
|
||
WHERE id = ${sessionId} AND name = 'New session'
|
||
RETURNING id, name
|
||
`;
|
||
if (renamedSession.length > 0) {
|
||
ctx.publishUser({
|
||
type: 'session_renamed',
|
||
session_id: sessionId,
|
||
name,
|
||
});
|
||
ctx.log.info({ sessionId, name }, 'session auto-named from chat');
|
||
}
|
||
}
|