mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-04-25 00:36:31 +02:00
refactor: update title generation logic to improve user experience by generating titles in parallel with assistant responses
This commit is contained in:
parent
bd91b0bef2
commit
e8cf677b25
5 changed files with 120 additions and 61 deletions
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 */}
|
||||
|
|
|
|||
49
surfsense_web/hooks/use-typewriter.ts
Normal file
49
surfsense_web/hooks/use-typewriter.ts
Normal 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;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue