diff --git a/surfsense_backend/app/prompts/__init__.py b/surfsense_backend/app/prompts/__init__.py index efa31d612..98909a906 100644 --- a/surfsense_backend/app/prompts/__init__.py +++ b/surfsense_backend/app/prompts/__init__.py @@ -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. - 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} - -{assistant_response} - - Title:""" TITLE_GENERATION_PROMPT_TEMPLATE = PromptTemplate( - input_variables=["user_query", "assistant_response"], + input_variables=["user_query"], template=TITLE_GENERATION_PROMPT, ) diff --git a/surfsense_backend/app/tasks/chat/stream_new_chat.py b/surfsense_backend/app/tasks/chat/stream_new_chat.py index 8d09ff387..3f0cee145 100644 --- a/surfsense_backend/app/tasks/chat/stream_new_chat.py +++ b/surfsense_backend/app/tasks/chat/stream_new_chat.py @@ -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() diff --git a/surfsense_web/app/dashboard/[search_space_id]/new-chat/[[...chat_id]]/page.tsx b/surfsense_web/app/dashboard/[search_space_id]/new-chat/[[...chat_id]]/page.tsx index 9adf886a4..2fb2527c1 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/new-chat/[[...chat_id]]/page.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/new-chat/[[...chat_id]]/page.tsx @@ -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 diff --git a/surfsense_web/components/layout/ui/sidebar/ChatListItem.tsx b/surfsense_web/components/layout/ui/sidebar/ChatListItem.tsx index f73e48cdf..078cea34e 100644 --- a/surfsense_web/components/layout/ui/sidebar/ChatListItem.tsx +++ b/surfsense_web/components/layout/ui/sidebar/ChatListItem.tsx @@ -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({ )} > - {name} + {animatedName} {/* Actions dropdown - trigger hidden on mobile, long-press opens it instead */} diff --git a/surfsense_web/hooks/use-typewriter.ts b/surfsense_web/hooks/use-typewriter.ts new file mode 100644 index 000000000..1e1ce8b83 --- /dev/null +++ b/surfsense_web/hooks/use-typewriter.ts @@ -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 | 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; +}