refactor: update title generation logic to improve user experience by generating titles in parallel with assistant responses

This commit is contained in:
Anish Sarkar 2026-03-09 01:28:53 +05:30
parent bd91b0bef2
commit e8cf677b25
5 changed files with 120 additions and 61 deletions

View file

@ -109,12 +109,12 @@ SUMMARY_PROMPT_TEMPLATE = PromptTemplate(
# Chat Title Generation Prompt
# =============================================================================
TITLE_GENERATION_PROMPT = """Generate a concise, descriptive title for the following conversation.
TITLE_GENERATION_PROMPT = """Generate a concise, descriptive title for the following user query.
<rules>
- The title MUST be between 1 and 6 words
- The title MUST be on a single line
- Capture the main topic or intent of the conversation
- Capture the main topic or intent of the query
- Do NOT use quotes, punctuation, or formatting
- Do NOT include words like "Chat about" or "Discussion of"
- Return ONLY the title, nothing else
@ -124,13 +124,9 @@ TITLE_GENERATION_PROMPT = """Generate a concise, descriptive title for the follo
{user_query}
</user_query>
<assistant_response>
{assistant_response}
</assistant_response>
Title:"""
TITLE_GENERATION_PROMPT_TEMPLATE = PromptTemplate(
input_variables=["user_query", "assistant_response"],
input_variables=["user_query"],
template=TITLE_GENERATION_PROMPT,
)

View file

@ -1366,6 +1366,38 @@ async def stream_new_chat(
del mentioned_documents, mentioned_surfsense_docs, recent_reports
del langchain_messages, final_query
# Check if this is the first assistant response so we can generate
# a title in parallel with the agent stream (better UX than waiting
# until after the full response).
assistant_count_result = await session.execute(
select(func.count(NewChatMessage.id)).filter(
NewChatMessage.thread_id == chat_id,
NewChatMessage.role == "assistant",
)
)
is_first_response = (assistant_count_result.scalar() or 0) == 0
title_task: asyncio.Task[str | None] | None = None
if is_first_response:
async def _generate_title() -> str | None:
try:
title_chain = TITLE_GENERATION_PROMPT_TEMPLATE | llm
title_result = await title_chain.ainvoke(
{"user_query": user_query[:500]}
)
if title_result and hasattr(title_result, "content"):
raw_title = title_result.content.strip()
if raw_title and len(raw_title) <= 100:
return raw_title.strip("\"'")
except Exception:
pass
return None
title_task = asyncio.create_task(_generate_title())
title_emitted = False
_t_stream_start = time.perf_counter()
_first_event_logged = False
async for sse in _stream_agent_events(
@ -1390,6 +1422,23 @@ async def stream_new_chat(
_first_event_logged = True
yield sse
# Inject title update mid-stream as soon as the background task finishes
if title_task is not None and title_task.done() and not title_emitted:
generated_title = title_task.result()
if generated_title:
async with shielded_async_session() as title_session:
title_thread_result = await title_session.execute(
select(NewChatThread).filter(NewChatThread.id == chat_id)
)
title_thread = title_thread_result.scalars().first()
if title_thread:
title_thread.title = generated_title
await title_session.commit()
yield streaming_service.format_thread_title_update(
chat_id, generated_title
)
title_emitted = True
_perf_log.info(
"[stream_new_chat] Agent stream completed in %.3fs (chat_id=%s)",
time.perf_counter() - _t_stream_start,
@ -1398,62 +1447,28 @@ async def stream_new_chat(
log_system_snapshot("stream_new_chat_END")
if stream_result.is_interrupted:
if title_task is not None and not title_task.done():
title_task.cancel()
yield streaming_service.format_finish_step()
yield streaming_service.format_finish()
yield streaming_service.format_done()
return
accumulated_text = stream_result.accumulated_text
assistant_count_result = await session.execute(
select(func.count(NewChatMessage.id)).filter(
NewChatMessage.thread_id == chat_id,
NewChatMessage.role == "assistant",
)
)
assistant_message_count = assistant_count_result.scalar() or 0
# Only generate title on the first response (no prior assistant messages)
if assistant_message_count == 0:
generated_title = None
try:
# Generate title using the same LLM
title_chain = TITLE_GENERATION_PROMPT_TEMPLATE | llm
# Truncate inputs to avoid context length issues
truncated_query = user_query[:500]
truncated_response = accumulated_text[:1000]
title_result = await title_chain.ainvoke(
{
"user_query": truncated_query,
"assistant_response": truncated_response,
}
)
# Extract and clean the title
if title_result and hasattr(title_result, "content"):
raw_title = title_result.content.strip()
# Validate the title (reasonable length)
if raw_title and len(raw_title) <= 100:
# Remove any quotes or extra formatting
generated_title = raw_title.strip("\"'")
except Exception:
generated_title = None
# Only update if LLM succeeded (keep truncated prompt title as fallback)
# If the title task didn't finish during streaming, await it now
if title_task is not None and not title_emitted:
generated_title = await title_task
if generated_title:
# Fetch thread and update title
thread_result = await session.execute(
select(NewChatThread).filter(NewChatThread.id == chat_id)
)
thread = thread_result.scalars().first()
if thread:
thread.title = generated_title
await session.commit()
# Notify frontend of the title update
yield streaming_service.format_thread_title_update(
chat_id, generated_title
async with shielded_async_session() as title_session:
title_thread_result = await title_session.execute(
select(NewChatThread).filter(NewChatThread.id == chat_id)
)
title_thread = title_thread_result.scalars().first()
if title_thread:
title_thread.title = generated_title
await title_session.commit()
yield streaming_service.format_thread_title_update(
chat_id, generated_title
)
# Finish the step and message
yield streaming_service.format_finish_step()

View file

@ -465,10 +465,7 @@ export default function NewChatPage() {
let isNewThread = false;
if (!currentThreadId) {
try {
// Create thread with truncated prompt as initial title
const initialTitle =
userQuery.trim().slice(0, 100) + (userQuery.trim().length > 100 ? "..." : "");
const newThread = await createThread(searchSpaceId, initialTitle);
const newThread = await createThread(searchSpaceId, "New Chat");
currentThreadId = newThread.id;
setThreadId(currentThreadId);
// Set currentThread so share button in header appears immediately

View file

@ -20,6 +20,7 @@ import {
} from "@/components/ui/dropdown-menu";
import { useLongPress } from "@/hooks/use-long-press";
import { useIsMobile } from "@/hooks/use-mobile";
import { useTypewriter } from "@/hooks/use-typewriter";
import { cn } from "@/lib/utils";
interface ChatListItemProps {
@ -44,6 +45,7 @@ export function ChatListItem({
const t = useTranslations("sidebar");
const isMobile = useIsMobile();
const [dropdownOpen, setDropdownOpen] = useState(false);
const animatedName = useTypewriter(name);
const { handlers: longPressHandlers, wasLongPress } = useLongPress(
useCallback(() => setDropdownOpen(true), [])
@ -69,7 +71,7 @@ export function ChatListItem({
)}
>
<MessageSquare className="h-4 w-4 shrink-0 text-muted-foreground" />
<span className="w-[calc(100%-3rem)] ">{name}</span>
<span className="w-[calc(100%-3rem)] ">{animatedName}</span>
</button>
{/* Actions dropdown - trigger hidden on mobile, long-press opens it instead */}

View file

@ -0,0 +1,49 @@
import { useEffect, useRef, useState } from "react";
/**
* Animates text changes with a typewriter reveal effect, but only when
* transitioning away from the `skipFor` placeholder (default "New Chat").
* All other text values are shown instantly without animation.
*/
export function useTypewriter(text: string, speed = 35, skipFor = "New Chat"): string {
const [displayed, setDisplayed] = useState(text);
const prevTextRef = useRef(text);
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
useEffect(() => {
if (intervalRef.current) {
clearInterval(intervalRef.current);
intervalRef.current = null;
}
const prevText = prevTextRef.current;
prevTextRef.current = text;
const shouldAnimate = prevText === skipFor && text !== skipFor && !!text;
if (!shouldAnimate) {
setDisplayed(text);
return;
}
let i = 0;
setDisplayed("");
intervalRef.current = setInterval(() => {
i++;
setDisplayed(text.slice(0, i));
if (i >= text.length) {
if (intervalRef.current) clearInterval(intervalRef.current);
intervalRef.current = null;
}
}, speed);
return () => {
if (intervalRef.current) {
clearInterval(intervalRef.current);
intervalRef.current = null;
}
};
}, [text, speed, skipFor]);
return displayed;
}