Added chat renaming and auto naming by the active LLM

This commit is contained in:
Eric Lammertsma 2026-02-03 20:47:18 -05:00
parent b176599c64
commit d761ca1992
12 changed files with 287 additions and 36 deletions

View file

@ -104,3 +104,33 @@ SUMMARY_PROMPT = (
SUMMARY_PROMPT_TEMPLATE = PromptTemplate(
input_variables=["document"], template=SUMMARY_PROMPT
)
# =============================================================================
# Chat Title Generation Prompt
# =============================================================================
TITLE_GENERATION_PROMPT = """Generate a concise, descriptive title for the following conversation.
<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
- Do NOT use quotes, punctuation, or formatting
- Do NOT include words like "Chat about" or "Discussion of"
- Return ONLY the title, nothing else
</rules>
<user_query>
{user_query}
</user_query>
<assistant_response>
{assistant_response}
</assistant_response>
Title:"""
TITLE_GENERATION_PROMPT_TEMPLATE = PromptTemplate(
input_variables=["user_query", "assistant_response"],
template=TITLE_GENERATION_PROMPT,
)

View file

@ -886,30 +886,8 @@ async def append_message(
# Update thread's updated_at timestamp
thread.updated_at = datetime.now(UTC)
# Auto-generate title from first user message if title is still default
if thread.title == "New Chat" and role_str == "user":
# Extract text content for title
content = message.content
if isinstance(content, str):
title_text = content
elif isinstance(content, list):
# Find first text content
title_text = ""
for part in content:
if isinstance(part, dict) and part.get("type") == "text":
title_text = part.get("text", "")
break
elif isinstance(part, str):
title_text = part
break
else:
title_text = str(content)
# Truncate title
if title_text:
thread.title = title_text[:100] + (
"..." if len(title_text) > 100 else ""
)
# Note: Title generation now happens in stream_new_chat.py after the first response
# using LLM to generate a descriptive title (with truncation as fallback)
await session.commit()
await session.refresh(db_message)

View file

@ -479,6 +479,31 @@ class VercelStreamingService:
},
)
def format_thread_title_update(self, thread_id: int, title: str) -> str:
"""
Format a thread title update notification (SurfSense specific).
This is sent after the first response in a thread to update the
auto-generated title based on the conversation content.
Args:
thread_id: The ID of the thread being updated
title: The new title for the thread
Returns:
str: SSE formatted thread title update data part
Example output:
data: {"type":"data-thread-title-update","data":{"threadId":123,"title":"New Title"}}
"""
return self.format_data(
"thread-title-update",
{
"threadId": thread_id,
"title": title,
},
)
# =========================================================================
# Error Part
# =========================================================================

View file

@ -32,6 +32,7 @@ from app.services.chat_session_state_service import (
clear_ai_responding,
set_ai_responding,
)
from app.prompts import TITLE_GENERATION_PROMPT_TEMPLATE
from app.services.connector_service import ConnectorService
from app.services.new_streaming_service import VercelStreamingService
from app.utils.content_utils import bootstrap_history_from_db
@ -1208,6 +1209,80 @@ async def stream_new_chat(
if completion_event:
yield completion_event
# Generate LLM title for new chats after first response
# Check if this is the first assistant response by counting existing assistant messages
from app.db import NewChatMessage, NewChatThread
from sqlalchemy import func
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:
print(f"[stream_new_chat] First response - generating title for thread {chat_id}")
print(f"[stream_new_chat] Query length: {len(user_query)}, Response length: {len(accumulated_text)}")
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]
print(f"[stream_new_chat] Calling LLM for title generation...")
title_result = await title_chain.ainvoke({
"user_query": truncated_query,
"assistant_response": truncated_response,
})
print(f"[stream_new_chat] LLM title result type: {type(title_result)}")
print(f"[stream_new_chat] LLM title result: {title_result}")
# Extract and clean the title
if title_result and hasattr(title_result, "content"):
raw_title = title_result.content.strip()
print(f"[stream_new_chat] Raw title content: '{raw_title}' (len={len(raw_title)})")
# Validate the title (1-6 words, reasonable length)
if raw_title and len(raw_title) <= 100:
# Remove any quotes or extra formatting
generated_title = raw_title.strip('"\'')
print(f"[stream_new_chat] After stripping quotes: '{generated_title}'")
else:
print(f"[stream_new_chat] Title validation failed: empty={not raw_title}, len={len(raw_title)}")
generated_title = None
else:
print(f"[stream_new_chat] No content attribute on result")
except Exception as title_error:
print(f"[stream_new_chat] Title generation failed: {title_error}")
import traceback
traceback.print_exc()
generated_title = None
# Only update if LLM succeeded (keep truncated prompt title as fallback)
if generated_title:
print(f"[stream_new_chat] Using LLM-generated title: '{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
)
else:
print(f"[stream_new_chat] LLM title generation failed, keeping truncated prompt title")
# Finish the step and message
yield streaming_service.format_finish_step()
yield streaming_service.format_finish()

View file

@ -437,7 +437,10 @@ export default function NewChatPage() {
let isNewThread = false;
if (!currentThreadId) {
try {
const newThread = await createThread(searchSpaceId, "New Chat");
// 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);
currentThreadId = newThread.id;
setThreadId(currentThreadId);
// Set currentThread so ChatHeader can show share button immediately
@ -827,6 +830,22 @@ export default function NewChatPage() {
break;
}
case "data-thread-title-update": {
// Handle thread title update from LLM-generated title
const titleData = parsed.data as { threadId: number; title: string };
if (titleData?.title && titleData?.threadId === currentThreadId) {
// Update current thread state with new title
setCurrentThread((prev) =>
prev ? { ...prev, title: titleData.title } : prev
);
// Invalidate thread list to refresh sidebar
queryClient.invalidateQueries({
queryKey: ["threads", String(searchSpaceId)],
});
}
break;
}
case "error":
throw new Error(parsed.errorText || "Server error");
}

View file

@ -2,7 +2,7 @@
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useAtomValue, useSetAtom } from "jotai";
import { AlertTriangle, Inbox, LogOut, SquareLibrary, Trash2 } from "lucide-react";
import { AlertTriangle, Inbox, LogOut, PencilIcon, SquareLibrary, Trash2 } from "lucide-react";
import { useParams, usePathname, useRouter } from "next/navigation";
import { useTranslations } from "next-intl";
import { useTheme } from "next-themes";
@ -21,6 +21,7 @@ import {
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { isPageLimitExceededMetadata } from "@/contracts/types/inbox.types";
import { useInbox } from "@/hooks/use-inbox";
import { searchSpacesApiService } from "@/lib/apis/search-spaces-api.service";
@ -207,6 +208,12 @@ export function LayoutDataProvider({
const [chatToDelete, setChatToDelete] = useState<{ id: number; name: string } | null>(null);
const [isDeletingChat, setIsDeletingChat] = useState(false);
// Rename dialog state
const [showRenameChatDialog, setShowRenameChatDialog] = useState(false);
const [chatToRename, setChatToRename] = useState<{ id: number; name: string } | null>(null);
const [newChatTitle, setNewChatTitle] = useState("");
const [isRenamingChat, setIsRenamingChat] = useState(false);
// Delete/Leave search space dialog state
const [showDeleteSearchSpaceDialog, setShowDeleteSearchSpaceDialog] = useState(false);
const [showLeaveSearchSpaceDialog, setShowLeaveSearchSpaceDialog] = useState(false);
@ -421,6 +428,12 @@ export function LayoutDataProvider({
setShowDeleteChatDialog(true);
}, []);
const handleChatRename = useCallback((chat: ChatItem) => {
setChatToRename({ id: chat.id, name: chat.name });
setNewChatTitle(chat.name);
setShowRenameChatDialog(true);
}, []);
const handleChatArchive = useCallback(
async (chat: ChatItem) => {
const newArchivedState = !chat.archived;
@ -501,6 +514,27 @@ export function LayoutDataProvider({
}
}, [chatToDelete, queryClient, searchSpaceId, router, currentChatId]);
// Rename handler
const confirmRenameChat = useCallback(async () => {
if (!chatToRename || !newChatTitle.trim()) return;
setIsRenamingChat(true);
try {
await updateThread(chatToRename.id, { title: newChatTitle.trim() });
toast.success(tSidebar("chat_renamed") || "Chat renamed");
queryClient.invalidateQueries({ queryKey: ["threads", searchSpaceId] });
queryClient.invalidateQueries({ queryKey: ["all-threads", searchSpaceId] });
queryClient.invalidateQueries({ queryKey: ["search-threads", searchSpaceId] });
} catch (error) {
console.error("Error renaming thread:", error);
toast.error(tSidebar("error_renaming_chat") || "Failed to rename chat");
} finally {
setIsRenamingChat(false);
setShowRenameChatDialog(false);
setChatToRename(null);
setNewChatTitle("");
}
}, [chatToRename, newChatTitle, queryClient, searchSpaceId, tSidebar]);
// Page usage
const pageUsage = user
? {
@ -529,6 +563,7 @@ export function LayoutDataProvider({
activeChatId={currentChatId}
onNewChat={handleNewChat}
onChatSelect={handleChatSelect}
onChatRename={handleChatRename}
onChatDelete={handleChatDelete}
onChatArchive={handleChatArchive}
onViewAllSharedChats={handleViewAllSharedChats}
@ -620,6 +655,57 @@ export function LayoutDataProvider({
</DialogContent>
</Dialog>
{/* Rename Chat Dialog */}
<Dialog open={showRenameChatDialog} onOpenChange={setShowRenameChatDialog}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<PencilIcon className="h-5 w-5" />
<span>{tSidebar("rename_chat") || "Rename Chat"}</span>
</DialogTitle>
<DialogDescription>
{tSidebar("rename_chat_description") || "Enter a new name for this conversation."}
</DialogDescription>
</DialogHeader>
<Input
value={newChatTitle}
onChange={(e) => setNewChatTitle(e.target.value)}
placeholder={tSidebar("chat_title_placeholder") || "Chat title"}
onKeyDown={(e) => {
if (e.key === "Enter" && !isRenamingChat && newChatTitle.trim()) {
confirmRenameChat();
}
}}
/>
<DialogFooter className="flex gap-2 sm:justify-end">
<Button
variant="outline"
onClick={() => setShowRenameChatDialog(false)}
disabled={isRenamingChat}
>
{tCommon("cancel")}
</Button>
<Button
onClick={confirmRenameChat}
disabled={isRenamingChat || !newChatTitle.trim()}
className="gap-2"
>
{isRenamingChat ? (
<>
<span className="h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent" />
{tSidebar("renaming") || "Renaming..."}
</>
) : (
<>
<PencilIcon className="h-4 w-4" />
{tSidebar("rename") || "Rename"}
</>
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Delete Search Space Dialog */}
<Dialog open={showDeleteSearchSpaceDialog} onOpenChange={setShowDeleteSearchSpaceDialog}>
<DialogContent className="sm:max-w-md">

View file

@ -54,6 +54,7 @@ interface LayoutShellProps {
activeChatId?: number | null;
onNewChat: () => void;
onChatSelect: (chat: ChatItem) => void;
onChatRename?: (chat: ChatItem) => void;
onChatDelete?: (chat: ChatItem) => void;
onChatArchive?: (chat: ChatItem) => void;
onViewAllSharedChats?: () => void;
@ -90,6 +91,7 @@ export function LayoutShell({
activeChatId,
onNewChat,
onChatSelect,
onChatRename,
onChatDelete,
onChatArchive,
onViewAllSharedChats,
@ -147,6 +149,7 @@ export function LayoutShell({
activeChatId={activeChatId}
onNewChat={onNewChat}
onChatSelect={onChatSelect}
onChatRename={onChatRename}
onChatDelete={onChatDelete}
onChatArchive={onChatArchive}
onViewAllSharedChats={onViewAllSharedChats}
@ -215,6 +218,7 @@ export function LayoutShell({
activeChatId={activeChatId}
onNewChat={onNewChat}
onChatSelect={onChatSelect}
onChatRename={onChatRename}
onChatDelete={onChatDelete}
onChatArchive={onChatArchive}
onViewAllSharedChats={onViewAllSharedChats}

View file

@ -1,6 +1,6 @@
"use client";
import { ArchiveIcon, MessageSquare, MoreHorizontal, RotateCcwIcon, Trash2 } from "lucide-react";
import { ArchiveIcon, MessageSquare, MoreHorizontal, PencilIcon, RotateCcwIcon, Trash2 } from "lucide-react";
import { useTranslations } from "next-intl";
import { Button } from "@/components/ui/button";
import {
@ -17,6 +17,7 @@ interface ChatListItemProps {
isActive?: boolean;
archived?: boolean;
onClick?: () => void;
onRename?: () => void;
onArchive?: () => void;
onDelete?: () => void;
}
@ -26,6 +27,7 @@ export function ChatListItem({
isActive,
archived,
onClick,
onRename,
onArchive,
onDelete,
}: ChatListItemProps) {
@ -57,15 +59,26 @@ export function ChatListItem({
<span className="sr-only">{t("more_options")}</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" side="right">
{onArchive && (
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
onArchive();
}}
>
{archived ? (
<DropdownMenuContent align="end" side="right">
{onRename && (
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
onRename();
}}
>
<PencilIcon className="mr-2 h-4 w-4" />
<span>{t("rename") || "Rename"}</span>
</DropdownMenuItem>
)}
{onArchive && (
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
onArchive();
}}
>
{archived ? (
<>
<RotateCcwIcon className="mr-2 h-4 w-4" />
<span>{t("unarchive") || "Restore"}</span>

View file

@ -24,6 +24,7 @@ interface MobileSidebarProps {
activeChatId?: number | null;
onNewChat: () => void;
onChatSelect: (chat: ChatItem) => void;
onChatRename?: (chat: ChatItem) => void;
onChatDelete?: (chat: ChatItem) => void;
onChatArchive?: (chat: ChatItem) => void;
onViewAllSharedChats?: () => void;
@ -64,6 +65,7 @@ export function MobileSidebar({
activeChatId,
onNewChat,
onChatSelect,
onChatRename,
onChatDelete,
onChatArchive,
onViewAllSharedChats,
@ -142,6 +144,7 @@ export function MobileSidebar({
onOpenChange(false);
}}
onChatSelect={handleChatSelect}
onChatRename={onChatRename}
onChatDelete={onChatDelete}
onChatArchive={onChatArchive}
onViewAllSharedChats={onViewAllSharedChats}

View file

@ -25,6 +25,7 @@ interface SidebarProps {
activeChatId?: number | null;
onNewChat: () => void;
onChatSelect: (chat: ChatItem) => void;
onChatRename?: (chat: ChatItem) => void;
onChatDelete?: (chat: ChatItem) => void;
onChatArchive?: (chat: ChatItem) => void;
onViewAllSharedChats?: () => void;
@ -51,6 +52,7 @@ export function Sidebar({
activeChatId,
onNewChat,
onChatSelect,
onChatRename,
onChatDelete,
onChatArchive,
onViewAllSharedChats,
@ -163,6 +165,7 @@ export function Sidebar({
isActive={chat.id === activeChatId}
archived={chat.archived}
onClick={() => onChatSelect(chat)}
onRename={() => onChatRename?.(chat)}
onArchive={() => onChatArchive?.(chat)}
onDelete={() => onChatDelete?.(chat)}
/>
@ -215,6 +218,7 @@ export function Sidebar({
isActive={chat.id === activeChatId}
archived={chat.archived}
onClick={() => onChatSelect(chat)}
onRename={() => onChatRename?.(chat)}
onArchive={() => onChatArchive?.(chat)}
onDelete={() => onChatDelete?.(chat)}
/>

View file

@ -676,6 +676,13 @@
"unarchive": "Restore",
"chat_archived": "Chat archived",
"chat_unarchived": "Chat restored",
"chat_renamed": "Chat renamed",
"error_renaming_chat": "Failed to rename chat",
"rename": "Rename",
"rename_chat": "Rename Chat",
"rename_chat_description": "Enter a new name for this conversation.",
"chat_title_placeholder": "Chat title",
"renaming": "Renaming...",
"no_archived_chats": "No archived chats",
"error_archiving_chat": "Failed to archive chat",
"new_chat": "New chat",

View file

@ -661,6 +661,13 @@
"unarchive": "恢复",
"chat_archived": "对话已归档",
"chat_unarchived": "对话已恢复",
"chat_renamed": "对话已重命名",
"error_renaming_chat": "重命名对话失败",
"rename": "重命名",
"rename_chat": "重命名对话",
"rename_chat_description": "为此对话输入新名称。",
"chat_title_placeholder": "对话标题",
"renaming": "重命名中...",
"no_archived_chats": "暂无已归档对话",
"error_archiving_chat": "归档对话失败",
"new_chat": "新对话",