From d761ca199211c1a35c9d8f5d7a9bcf852c1a7a35 Mon Sep 17 00:00:00 2001 From: Eric Lammertsma Date: Tue, 3 Feb 2026 20:47:18 -0500 Subject: [PATCH 1/9] Added chat renaming and auto naming by the active LLM --- surfsense_backend/app/prompts/__init__.py | 30 +++++++ .../app/routes/new_chat_routes.py | 26 +----- .../app/services/new_streaming_service.py | 25 ++++++ .../app/tasks/chat/stream_new_chat.py | 75 ++++++++++++++++ .../new-chat/[[...chat_id]]/page.tsx | 21 ++++- .../layout/providers/LayoutDataProvider.tsx | 88 ++++++++++++++++++- .../layout/ui/shell/LayoutShell.tsx | 4 + .../layout/ui/sidebar/ChatListItem.tsx | 33 ++++--- .../layout/ui/sidebar/MobileSidebar.tsx | 3 + .../components/layout/ui/sidebar/Sidebar.tsx | 4 + surfsense_web/messages/en.json | 7 ++ surfsense_web/messages/zh.json | 7 ++ 12 files changed, 287 insertions(+), 36 deletions(-) diff --git a/surfsense_backend/app/prompts/__init__.py b/surfsense_backend/app/prompts/__init__.py index 3b21cb9e1..efa31d612 100644 --- a/surfsense_backend/app/prompts/__init__.py +++ b/surfsense_backend/app/prompts/__init__.py @@ -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. + + +- 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 + + + +{user_query} + + + +{assistant_response} + + +Title:""" + +TITLE_GENERATION_PROMPT_TEMPLATE = PromptTemplate( + input_variables=["user_query", "assistant_response"], + template=TITLE_GENERATION_PROMPT, +) diff --git a/surfsense_backend/app/routes/new_chat_routes.py b/surfsense_backend/app/routes/new_chat_routes.py index 42b8a821b..06e929997 100644 --- a/surfsense_backend/app/routes/new_chat_routes.py +++ b/surfsense_backend/app/routes/new_chat_routes.py @@ -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) diff --git a/surfsense_backend/app/services/new_streaming_service.py b/surfsense_backend/app/services/new_streaming_service.py index 05dd2d4dd..57fbc9663 100644 --- a/surfsense_backend/app/services/new_streaming_service.py +++ b/surfsense_backend/app/services/new_streaming_service.py @@ -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 # ========================================================================= diff --git a/surfsense_backend/app/tasks/chat/stream_new_chat.py b/surfsense_backend/app/tasks/chat/stream_new_chat.py index 688777203..47e2a2293 100644 --- a/surfsense_backend/app/tasks/chat/stream_new_chat.py +++ b/surfsense_backend/app/tasks/chat/stream_new_chat.py @@ -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() 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 9b462fcbc..4f99f8021 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 @@ -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"); } diff --git a/surfsense_web/components/layout/providers/LayoutDataProvider.tsx b/surfsense_web/components/layout/providers/LayoutDataProvider.tsx index 2f71adad9..9a5c2ed40 100644 --- a/surfsense_web/components/layout/providers/LayoutDataProvider.tsx +++ b/surfsense_web/components/layout/providers/LayoutDataProvider.tsx @@ -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({ + {/* Rename Chat Dialog */} + + + + + + {tSidebar("rename_chat") || "Rename Chat"} + + + {tSidebar("rename_chat_description") || "Enter a new name for this conversation."} + + + setNewChatTitle(e.target.value)} + placeholder={tSidebar("chat_title_placeholder") || "Chat title"} + onKeyDown={(e) => { + if (e.key === "Enter" && !isRenamingChat && newChatTitle.trim()) { + confirmRenameChat(); + } + }} + /> + + + + + + + {/* Delete Search Space Dialog */} diff --git a/surfsense_web/components/layout/ui/shell/LayoutShell.tsx b/surfsense_web/components/layout/ui/shell/LayoutShell.tsx index 3624c90a3..8eae99b03 100644 --- a/surfsense_web/components/layout/ui/shell/LayoutShell.tsx +++ b/surfsense_web/components/layout/ui/shell/LayoutShell.tsx @@ -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} diff --git a/surfsense_web/components/layout/ui/sidebar/ChatListItem.tsx b/surfsense_web/components/layout/ui/sidebar/ChatListItem.tsx index 6db6782d0..ba2989145 100644 --- a/surfsense_web/components/layout/ui/sidebar/ChatListItem.tsx +++ b/surfsense_web/components/layout/ui/sidebar/ChatListItem.tsx @@ -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({ {t("more_options")} - - {onArchive && ( - { - e.stopPropagation(); - onArchive(); - }} - > - {archived ? ( + + {onRename && ( + { + e.stopPropagation(); + onRename(); + }} + > + + {t("rename") || "Rename"} + + )} + {onArchive && ( + { + e.stopPropagation(); + onArchive(); + }} + > + {archived ? ( <> {t("unarchive") || "Restore"} diff --git a/surfsense_web/components/layout/ui/sidebar/MobileSidebar.tsx b/surfsense_web/components/layout/ui/sidebar/MobileSidebar.tsx index 85f907611..3ed2f9cca 100644 --- a/surfsense_web/components/layout/ui/sidebar/MobileSidebar.tsx +++ b/surfsense_web/components/layout/ui/sidebar/MobileSidebar.tsx @@ -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} diff --git a/surfsense_web/components/layout/ui/sidebar/Sidebar.tsx b/surfsense_web/components/layout/ui/sidebar/Sidebar.tsx index db04bf6dc..8763056ed 100644 --- a/surfsense_web/components/layout/ui/sidebar/Sidebar.tsx +++ b/surfsense_web/components/layout/ui/sidebar/Sidebar.tsx @@ -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)} /> diff --git a/surfsense_web/messages/en.json b/surfsense_web/messages/en.json index 75b186420..b44287af5 100644 --- a/surfsense_web/messages/en.json +++ b/surfsense_web/messages/en.json @@ -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", diff --git a/surfsense_web/messages/zh.json b/surfsense_web/messages/zh.json index 81121ef3e..cbcef3691 100644 --- a/surfsense_web/messages/zh.json +++ b/surfsense_web/messages/zh.json @@ -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": "新对话", From 07f89a426882d663ff9ac2b14a84850a7a7251b0 Mon Sep 17 00:00:00 2001 From: Eric Lammertsma Date: Tue, 3 Feb 2026 20:54:21 -0500 Subject: [PATCH 2/9] Show the chat title in the breadcrumbs instead of "Chat" --- .../new-chat/[[...chat_id]]/page.tsx | 4 ++++ .../components/dashboard-breadcrumb.tsx | 16 ++++++++++++++-- .../layout/providers/LayoutDataProvider.tsx | 2 ++ 3 files changed, 20 insertions(+), 2 deletions(-) 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 4f99f8021..1a535539d 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 @@ -842,6 +842,10 @@ export default function NewChatPage() { queryClient.invalidateQueries({ queryKey: ["threads", String(searchSpaceId)], }); + // Invalidate thread detail for breadcrumb update + queryClient.invalidateQueries({ + queryKey: ["threads", String(searchSpaceId), "detail", String(titleData.threadId)], + }); } break; } diff --git a/surfsense_web/components/dashboard-breadcrumb.tsx b/surfsense_web/components/dashboard-breadcrumb.tsx index 96bd0ef30..5c6399ce0 100644 --- a/surfsense_web/components/dashboard-breadcrumb.tsx +++ b/surfsense_web/components/dashboard-breadcrumb.tsx @@ -14,6 +14,7 @@ import { } from "@/components/ui/breadcrumb"; import { searchSpacesApiService } from "@/lib/apis/search-spaces-api.service"; import { authenticatedFetch, getBearerToken } from "@/lib/auth-utils"; +import { getThreadFull } from "@/lib/chat/thread-persistence"; import { cacheKeys } from "@/lib/query-client/cache-keys"; interface BreadcrumbItemInterface { @@ -34,6 +35,16 @@ export function DashboardBreadcrumb() { enabled: !!searchSpaceId, }); + // Extract chat thread ID from pathname for chat pages + const chatThreadId = segments[2] === "new-chat" && segments[3] ? segments[3] : null; + + // Fetch thread details when on a chat page with a thread ID + const { data: threadData } = useQuery({ + queryKey: ["threads", searchSpaceId, "detail", chatThreadId], + queryFn: () => getThreadFull(Number(chatThreadId)), + enabled: !!chatThreadId && !!searchSpaceId, + }); + // State to store document title for editor breadcrumb const [documentTitle, setDocumentTitle] = useState(null); @@ -144,10 +155,11 @@ export function DashboardBreadcrumb() { } // Handle new-chat sub-sections (thread IDs) - // Don't show thread ID in breadcrumb - users identify chats by content, not by ID + // Show the chat title if available, otherwise fall back to "Chat" if (section === "new-chat") { + const chatLabel = threadData?.title || t("chat") || "Chat"; breadcrumbs.push({ - label: t("chat") || "Chat", + label: chatLabel, }); return breadcrumbs; } diff --git a/surfsense_web/components/layout/providers/LayoutDataProvider.tsx b/surfsense_web/components/layout/providers/LayoutDataProvider.tsx index 9a5c2ed40..4d8e2d23a 100644 --- a/surfsense_web/components/layout/providers/LayoutDataProvider.tsx +++ b/surfsense_web/components/layout/providers/LayoutDataProvider.tsx @@ -524,6 +524,8 @@ export function LayoutDataProvider({ queryClient.invalidateQueries({ queryKey: ["threads", searchSpaceId] }); queryClient.invalidateQueries({ queryKey: ["all-threads", searchSpaceId] }); queryClient.invalidateQueries({ queryKey: ["search-threads", searchSpaceId] }); + // Invalidate thread detail for breadcrumb update + queryClient.invalidateQueries({ queryKey: ["threads", searchSpaceId, "detail", String(chatToRename.id)] }); } catch (error) { console.error("Error renaming thread:", error); toast.error(tSidebar("error_renaming_chat") || "Failed to rename chat"); From 318ad4a4ba2ff5a6e8edf4837846c649471e9606 Mon Sep 17 00:00:00 2001 From: Eric Lammertsma Date: Tue, 3 Feb 2026 20:59:50 -0500 Subject: [PATCH 3/9] Removed excessive logging around chat title generation --- .../app/tasks/chat/stream_new_chat.py | 25 ++----------------- 1 file changed, 2 insertions(+), 23 deletions(-) diff --git a/surfsense_backend/app/tasks/chat/stream_new_chat.py b/surfsense_backend/app/tasks/chat/stream_new_chat.py index 47e2a2293..a9751e5d1 100644 --- a/surfsense_backend/app/tasks/chat/stream_new_chat.py +++ b/surfsense_backend/app/tasks/chat/stream_new_chat.py @@ -1224,9 +1224,6 @@ async def stream_new_chat( # 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 @@ -1234,39 +1231,23 @@ async def stream_new_chat( # 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) + # Validate the title (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() + except Exception: 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) @@ -1280,8 +1261,6 @@ async def stream_new_chat( 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() From 5bee04192c14f516e292f9978f1c85a30ebc9baf Mon Sep 17 00:00:00 2001 From: Eric Lammertsma Date: Tue, 3 Feb 2026 22:19:10 -0500 Subject: [PATCH 4/9] Overhauled pricing plans. --- surfsense_web/components/pricing.tsx | 5 +- .../components/pricing/pricing-section.tsx | 73 ++++++++++--------- 2 files changed, 42 insertions(+), 36 deletions(-) diff --git a/surfsense_web/components/pricing.tsx b/surfsense_web/components/pricing.tsx index 39757e91e..9d05e0262 100644 --- a/surfsense_web/components/pricing.tsx +++ b/surfsense_web/components/pricing.tsx @@ -17,6 +17,7 @@ interface PricingPlan { price: string; yearlyPrice: string; period: string; + billingText?: string; features: string[]; description: string; buttonText: string; @@ -35,7 +36,7 @@ export function Pricing({ title = "Simple, Transparent Pricing", description = "Choose the plan that works for you\nAll plans include access to our SurfSense AI workspace and community support.", }: PricingProps) { - const [isMonthly, setIsMonthly] = useState(true); + const [isMonthly, setIsMonthly] = useState(false); const isDesktop = useMediaQuery("(min-width: 768px)"); const switchRef = useRef(null); @@ -183,7 +184,7 @@ export function Pricing({

- {isNaN(Number(plan.price)) ? "" : isMonthly ? "billed monthly" : "billed annually"} + {plan.billingText ?? (isNaN(Number(plan.price)) ? "" : isMonthly ? "billed monthly" : "billed annually")}

    diff --git a/surfsense_web/components/pricing/pricing-section.tsx b/surfsense_web/components/pricing/pricing-section.tsx index fdad0796a..5b3513e51 100644 --- a/surfsense_web/components/pricing/pricing-section.tsx +++ b/surfsense_web/components/pricing/pricing-section.tsx @@ -4,44 +4,47 @@ import { Pricing } from "@/components/pricing"; const demoPlans = [ { - name: "COMMUNITY", + name: "FREE", price: "0", yearlyPrice: "0", - period: "forever", + period: "", + billingText: "Includes 30 day PRO trial", features: [ - "Community support", - "Supports 100+ LLMs", - "Supports OpenAI spec and LiteLLM", - "Supports local vLLM or Ollama setups", - "6000+ embedding models", + "Open source on GitHub", + "Upload and chat with up to 1,000 pages of content", + "Connects with 8 popular sources, like Drive and Notion.", + "Includes limited access to ChatGPT, Claude, and DeepSeek models", + "Supports 100+ more LLMs, including Gemini, Llama and many more.", "50+ File extensions supported.", - "Podcasts support with local TTS providers.", - "Connects with 15+ external sources, like Drive and Notion.", + "Generate podcasts in seconds.", "Cross-Browser Extension for dynamic webpages including authenticated content", - "Role-based access control (RBAC)", - "Collaboration and team features", + "Community support on Discord", ], - description: "Open source version with powerful features", - buttonText: "Dive In", - href: "/docs", + description: "Powerful features with some limitations", + buttonText: "Get Started", + href: "/", isPopular: false, }, { - name: "CLOUD", - price: "0", - yearlyPrice: "0", - period: "in beta", + name: "PRO", + price: "10", + yearlyPrice: "10", + period: "user / month", + billingText: "billed annually", features: [ - "Everything in Community", - "Email support", - "Get started in seconds", - "Instant access to new features", - "Easy access from anywhere", - "Remote team management and collaboration", + "Everything in Free", + "Upload and chat with up to 20,000 pages of content", + "Connects with 15+ external sources, like Slack and Airtable.", + "Includes extended access to ChatGPT, Claude, and DeepSeek models", + "Collaboration and commenting features", + "Centralized billing", + "Shared BYOK (Bring Your Own Key)", + "Team and role management", + "Priority support", ], - description: "Instant access for individuals and teams", - buttonText: "Get Started", - href: "/", + description: "The AIknowledge base for individuals and teams", + buttonText: "Upgrade", + href: "/contact", isPopular: true, }, { @@ -50,17 +53,19 @@ const demoPlans = [ yearlyPrice: "Contact Us", period: "", features: [ - "Everything in Community", - "Priority support", - "White-glove setup and deployment", - "Monthly managed updates and maintenance", + "Everything in Pro", + "Connect and chat with virtually unlimited pages of content", + "Limit models and/or providers", "On-prem or VPC deployment", "Audit logs and compliance", "SSO, OIDC & SAML", - "SLA guarantee", - "Uptime guarantee on VPC", + "Role-based access control (RBAC)", + "White-glove setup and deployment", + "Monthly managed updates and maintenance", + "SLA commitments", + "Dedicated support", ], - description: "Professional, customized setup for large organizations", + description: "Customized setup for large organizations", buttonText: "Contact Sales", href: "/contact", isPopular: false, From 40a304bd0b15ab834e0ba2523796413727e941eb Mon Sep 17 00:00:00 2001 From: Eric Lammertsma Date: Tue, 3 Feb 2026 22:49:38 -0500 Subject: [PATCH 5/9] Updated enterprise pricing plan t. --- surfsense_web/components/pricing/pricing-section.tsx | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/surfsense_web/components/pricing/pricing-section.tsx b/surfsense_web/components/pricing/pricing-section.tsx index 5b3513e51..c850852d8 100644 --- a/surfsense_web/components/pricing/pricing-section.tsx +++ b/surfsense_web/components/pricing/pricing-section.tsx @@ -49,9 +49,10 @@ const demoPlans = [ }, { name: "ENTERPRISE", - price: "Contact Us", - yearlyPrice: "Contact Us", - period: "", + price: "1000", + yearlyPrice: "1000", + period: "month", + billingText: "billed annually", features: [ "Everything in Pro", "Connect and chat with virtually unlimited pages of content", From 2711563e8be328b39fc58eca3157b5c8048948e6 Mon Sep 17 00:00:00 2001 From: Eric Lammertsma Date: Wed, 4 Feb 2026 11:03:31 -0500 Subject: [PATCH 6/9] Refined pricing plan descriptions by updating content limits and adding "planned" to future features. --- surfsense_web/components/pricing/pricing-section.tsx | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/surfsense_web/components/pricing/pricing-section.tsx b/surfsense_web/components/pricing/pricing-section.tsx index c850852d8..445969ec1 100644 --- a/surfsense_web/components/pricing/pricing-section.tsx +++ b/surfsense_web/components/pricing/pricing-section.tsx @@ -11,7 +11,7 @@ const demoPlans = [ billingText: "Includes 30 day PRO trial", features: [ "Open source on GitHub", - "Upload and chat with up to 1,000 pages of content", + "Upload and chat with 1,000+ pages of content", "Connects with 8 popular sources, like Drive and Notion.", "Includes limited access to ChatGPT, Claude, and DeepSeek models", "Supports 100+ more LLMs, including Gemini, Llama and many more.", @@ -33,13 +33,13 @@ const demoPlans = [ billingText: "billed annually", features: [ "Everything in Free", - "Upload and chat with up to 20,000 pages of content", + "Upload and chat with up to 20,000+ pages of content", "Connects with 15+ external sources, like Slack and Airtable.", "Includes extended access to ChatGPT, Claude, and DeepSeek models", "Collaboration and commenting features", - "Centralized billing", "Shared BYOK (Bring Your Own Key)", "Team and role management", + "Planned: Centralized billing", "Priority support", ], description: "The AIknowledge base for individuals and teams", @@ -58,9 +58,9 @@ const demoPlans = [ "Connect and chat with virtually unlimited pages of content", "Limit models and/or providers", "On-prem or VPC deployment", - "Audit logs and compliance", - "SSO, OIDC & SAML", - "Role-based access control (RBAC)", + "Planned: Audit logs and compliance", + "Planned: SSO, OIDC & SAML", + "Planned: Role-based access control (RBAC)", "White-glove setup and deployment", "Monthly managed updates and maintenance", "SLA commitments", From 83aa8ef6892507193976c6246dee4f7fbcbff167 Mon Sep 17 00:00:00 2001 From: Eric Lammertsma Date: Wed, 4 Feb 2026 11:30:30 -0500 Subject: [PATCH 7/9] Added build version for cloud, local and Docker installs --- .../user/settings/components/UserSettingsSidebar.tsx | 6 ++++++ surfsense_web/lib/env-config.ts | 6 ++++++ 2 files changed, 12 insertions(+) diff --git a/surfsense_web/app/dashboard/user/settings/components/UserSettingsSidebar.tsx b/surfsense_web/app/dashboard/user/settings/components/UserSettingsSidebar.tsx index b7040b4e3..3424113a9 100644 --- a/surfsense_web/app/dashboard/user/settings/components/UserSettingsSidebar.tsx +++ b/surfsense_web/app/dashboard/user/settings/components/UserSettingsSidebar.tsx @@ -5,6 +5,7 @@ import { ArrowLeft, ChevronRight, X } from "lucide-react"; import { AnimatePresence, motion } from "motion/react"; import { useTranslations } from "next-intl"; import { Button } from "@/components/ui/button"; +import { APP_VERSION } from "@/lib/env-config"; import { cn } from "@/lib/utils"; export interface SettingsNavItem { @@ -148,6 +149,11 @@ export function UserSettingsSidebar({ ); })} + + {/* Version display */} +
    +

    v{APP_VERSION}

    +
    ); diff --git a/surfsense_web/lib/env-config.ts b/surfsense_web/lib/env-config.ts index 2f9e92357..e36aff10a 100644 --- a/surfsense_web/lib/env-config.ts +++ b/surfsense_web/lib/env-config.ts @@ -9,6 +9,8 @@ * as it may prevent the sed replacement from working correctly. */ +import packageJson from "../package.json"; + // Auth type: "LOCAL" for email/password, "GOOGLE" for OAuth // Placeholder: __NEXT_PUBLIC_FASTAPI_BACKEND_AUTH_TYPE__ export const AUTH_TYPE = process.env.NEXT_PUBLIC_FASTAPI_BACKEND_AUTH_TYPE || "GOOGLE"; @@ -28,6 +30,10 @@ export const ETL_SERVICE = process.env.NEXT_PUBLIC_ETL_SERVICE || "DOCLING"; // Placeholder: __NEXT_PUBLIC_DEPLOYMENT_MODE__ export const DEPLOYMENT_MODE = process.env.NEXT_PUBLIC_DEPLOYMENT_MODE || "self-hosted"; +// App version - defaults to package.json version +// Can be overridden at build time with NEXT_PUBLIC_APP_VERSION for full git tag version +export const APP_VERSION = process.env.NEXT_PUBLIC_APP_VERSION || packageJson.version; + // Helper to check if local auth is enabled export const isLocalAuth = () => AUTH_TYPE === "LOCAL"; From a133fb41841f0a694554eb9ffe97258058556c94 Mon Sep 17 00:00:00 2001 From: Eric Lammertsma Date: Wed, 4 Feb 2026 15:30:15 -0500 Subject: [PATCH 8/9] Updated price tier page limits and removed enterprise pricing --- surfsense_web/components/pricing/pricing-section.tsx | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/surfsense_web/components/pricing/pricing-section.tsx b/surfsense_web/components/pricing/pricing-section.tsx index 445969ec1..117be15ec 100644 --- a/surfsense_web/components/pricing/pricing-section.tsx +++ b/surfsense_web/components/pricing/pricing-section.tsx @@ -11,7 +11,7 @@ const demoPlans = [ billingText: "Includes 30 day PRO trial", features: [ "Open source on GitHub", - "Upload and chat with 1,000+ pages of content", + "Upload and chat with 300+ pages of content", "Connects with 8 popular sources, like Drive and Notion.", "Includes limited access to ChatGPT, Claude, and DeepSeek models", "Supports 100+ more LLMs, including Gemini, Llama and many more.", @@ -33,7 +33,7 @@ const demoPlans = [ billingText: "billed annually", features: [ "Everything in Free", - "Upload and chat with up to 20,000+ pages of content", + "Upload and chat with 5,000+ pages of content", "Connects with 15+ external sources, like Slack and Airtable.", "Includes extended access to ChatGPT, Claude, and DeepSeek models", "Collaboration and commenting features", @@ -49,10 +49,10 @@ const demoPlans = [ }, { name: "ENTERPRISE", - price: "1000", - yearlyPrice: "1000", - period: "month", - billingText: "billed annually", + price: "Contact Us", + yearlyPrice: "Contact Us", + period: "", + billingText: "", features: [ "Everything in Pro", "Connect and chat with virtually unlimited pages of content", From 1c1dcbf47f733f36c31ebad706244f372171b7ce Mon Sep 17 00:00:00 2001 From: Eric Lammertsma Date: Wed, 4 Feb 2026 15:52:21 -0500 Subject: [PATCH 9/9] Add Gemini 3 Flash and Pro models to LLM_MODELS enum --- surfsense_web/contracts/enums/llm-models.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/surfsense_web/contracts/enums/llm-models.ts b/surfsense_web/contracts/enums/llm-models.ts index c62b2a9d6..5ff15c3df 100644 --- a/surfsense_web/contracts/enums/llm-models.ts +++ b/surfsense_web/contracts/enums/llm-models.ts @@ -178,6 +178,18 @@ export const LLM_MODELS: LLMModel[] = [ }, // Google (Gemini) + { + value: "gemini-3-flash-preview", + label: "Gemini 3 Flash", + provider: "GOOGLE", + contextWindow: "1M", + }, + { + value: "gemini-3-pro-preview", + label: "Gemini 3 Pro", + provider: "GOOGLE", + contextWindow: "1M", + }, { value: "gemini-2.5-flash", label: "Gemini 2.5 Flash",