Merge pull request #777 from elammertsma/dev

Automatic chat naming, chat renaming, name in breadcrumbs
This commit is contained in:
Rohan Verma 2026-02-05 10:47:41 -08:00 committed by GitHub
commit dbc9a4e0cd
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
18 changed files with 354 additions and 75 deletions

View file

@ -104,3 +104,33 @@ SUMMARY_PROMPT = (
SUMMARY_PROMPT_TEMPLATE = PromptTemplate( SUMMARY_PROMPT_TEMPLATE = PromptTemplate(
input_variables=["document"], template=SUMMARY_PROMPT 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 # Update thread's updated_at timestamp
thread.updated_at = datetime.now(UTC) thread.updated_at = datetime.now(UTC)
# Auto-generate title from first user message if title is still default # Note: Title generation now happens in stream_new_chat.py after the first response
if thread.title == "New Chat" and role_str == "user": # using LLM to generate a descriptive title (with truncation as fallback)
# 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 ""
)
await session.commit() await session.commit()
await session.refresh(db_message) 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 # Error Part
# ========================================================================= # =========================================================================

View file

@ -32,6 +32,7 @@ from app.services.chat_session_state_service import (
clear_ai_responding, clear_ai_responding,
set_ai_responding, set_ai_responding,
) )
from app.prompts import TITLE_GENERATION_PROMPT_TEMPLATE
from app.services.connector_service import ConnectorService from app.services.connector_service import ConnectorService
from app.services.new_streaming_service import VercelStreamingService from app.services.new_streaming_service import VercelStreamingService
from app.utils.content_utils import bootstrap_history_from_db from app.utils.content_utils import bootstrap_history_from_db
@ -1208,6 +1209,59 @@ async def stream_new_chat(
if completion_event: if completion_event:
yield 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:
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 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
)
# Finish the step and message # Finish the step and message
yield streaming_service.format_finish_step() yield streaming_service.format_finish_step()
yield streaming_service.format_finish() yield streaming_service.format_finish()

View file

@ -437,7 +437,10 @@ export default function NewChatPage() {
let isNewThread = false; let isNewThread = false;
if (!currentThreadId) { if (!currentThreadId) {
try { 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; currentThreadId = newThread.id;
setThreadId(currentThreadId); setThreadId(currentThreadId);
// Set currentThread so ChatHeader can show share button immediately // Set currentThread so ChatHeader can show share button immediately
@ -827,6 +830,26 @@ export default function NewChatPage() {
break; 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)],
});
// Invalidate thread detail for breadcrumb update
queryClient.invalidateQueries({
queryKey: ["threads", String(searchSpaceId), "detail", String(titleData.threadId)],
});
}
break;
}
case "error": case "error":
throw new Error(parsed.errorText || "Server error"); throw new Error(parsed.errorText || "Server error");
} }

View file

@ -5,6 +5,7 @@ import { ArrowLeft, ChevronRight, X } from "lucide-react";
import { AnimatePresence, motion } from "motion/react"; import { AnimatePresence, motion } from "motion/react";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { APP_VERSION } from "@/lib/env-config";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
export interface SettingsNavItem { export interface SettingsNavItem {
@ -148,6 +149,11 @@ export function UserSettingsSidebar({
); );
})} })}
</nav> </nav>
{/* Version display */}
<div className="mt-auto border-t px-6 py-3">
<p className="text-xs text-muted-foreground/50">v{APP_VERSION}</p>
</div>
</aside> </aside>
</> </>
); );

View file

@ -14,6 +14,7 @@ import {
} from "@/components/ui/breadcrumb"; } from "@/components/ui/breadcrumb";
import { searchSpacesApiService } from "@/lib/apis/search-spaces-api.service"; import { searchSpacesApiService } from "@/lib/apis/search-spaces-api.service";
import { authenticatedFetch, getBearerToken } from "@/lib/auth-utils"; import { authenticatedFetch, getBearerToken } from "@/lib/auth-utils";
import { getThreadFull } from "@/lib/chat/thread-persistence";
import { cacheKeys } from "@/lib/query-client/cache-keys"; import { cacheKeys } from "@/lib/query-client/cache-keys";
interface BreadcrumbItemInterface { interface BreadcrumbItemInterface {
@ -34,6 +35,16 @@ export function DashboardBreadcrumb() {
enabled: !!searchSpaceId, 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 // State to store document title for editor breadcrumb
const [documentTitle, setDocumentTitle] = useState<string | null>(null); const [documentTitle, setDocumentTitle] = useState<string | null>(null);
@ -144,10 +155,11 @@ export function DashboardBreadcrumb() {
} }
// Handle new-chat sub-sections (thread IDs) // 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") { if (section === "new-chat") {
const chatLabel = threadData?.title || t("chat") || "Chat";
breadcrumbs.push({ breadcrumbs.push({
label: t("chat") || "Chat", label: chatLabel,
}); });
return breadcrumbs; return breadcrumbs;
} }

View file

@ -2,7 +2,7 @@
import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useAtomValue, useSetAtom } from "jotai"; 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 { useParams, usePathname, useRouter } from "next/navigation";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { useTheme } from "next-themes"; import { useTheme } from "next-themes";
@ -21,6 +21,7 @@ import {
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { isPageLimitExceededMetadata } from "@/contracts/types/inbox.types"; import { isPageLimitExceededMetadata } from "@/contracts/types/inbox.types";
import { useInbox } from "@/hooks/use-inbox"; import { useInbox } from "@/hooks/use-inbox";
import { searchSpacesApiService } from "@/lib/apis/search-spaces-api.service"; 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 [chatToDelete, setChatToDelete] = useState<{ id: number; name: string } | null>(null);
const [isDeletingChat, setIsDeletingChat] = useState(false); 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 // Delete/Leave search space dialog state
const [showDeleteSearchSpaceDialog, setShowDeleteSearchSpaceDialog] = useState(false); const [showDeleteSearchSpaceDialog, setShowDeleteSearchSpaceDialog] = useState(false);
const [showLeaveSearchSpaceDialog, setShowLeaveSearchSpaceDialog] = useState(false); const [showLeaveSearchSpaceDialog, setShowLeaveSearchSpaceDialog] = useState(false);
@ -421,6 +428,12 @@ export function LayoutDataProvider({
setShowDeleteChatDialog(true); setShowDeleteChatDialog(true);
}, []); }, []);
const handleChatRename = useCallback((chat: ChatItem) => {
setChatToRename({ id: chat.id, name: chat.name });
setNewChatTitle(chat.name);
setShowRenameChatDialog(true);
}, []);
const handleChatArchive = useCallback( const handleChatArchive = useCallback(
async (chat: ChatItem) => { async (chat: ChatItem) => {
const newArchivedState = !chat.archived; const newArchivedState = !chat.archived;
@ -501,6 +514,29 @@ export function LayoutDataProvider({
} }
}, [chatToDelete, queryClient, searchSpaceId, router, currentChatId]); }, [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] });
// 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");
} finally {
setIsRenamingChat(false);
setShowRenameChatDialog(false);
setChatToRename(null);
setNewChatTitle("");
}
}, [chatToRename, newChatTitle, queryClient, searchSpaceId, tSidebar]);
// Page usage // Page usage
const pageUsage = user const pageUsage = user
? { ? {
@ -529,6 +565,7 @@ export function LayoutDataProvider({
activeChatId={currentChatId} activeChatId={currentChatId}
onNewChat={handleNewChat} onNewChat={handleNewChat}
onChatSelect={handleChatSelect} onChatSelect={handleChatSelect}
onChatRename={handleChatRename}
onChatDelete={handleChatDelete} onChatDelete={handleChatDelete}
onChatArchive={handleChatArchive} onChatArchive={handleChatArchive}
onViewAllSharedChats={handleViewAllSharedChats} onViewAllSharedChats={handleViewAllSharedChats}
@ -620,6 +657,57 @@ export function LayoutDataProvider({
</DialogContent> </DialogContent>
</Dialog> </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 */} {/* Delete Search Space Dialog */}
<Dialog open={showDeleteSearchSpaceDialog} onOpenChange={setShowDeleteSearchSpaceDialog}> <Dialog open={showDeleteSearchSpaceDialog} onOpenChange={setShowDeleteSearchSpaceDialog}>
<DialogContent className="sm:max-w-md"> <DialogContent className="sm:max-w-md">

View file

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

View file

@ -1,6 +1,6 @@
"use client"; "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 { useTranslations } from "next-intl";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
@ -17,6 +17,7 @@ interface ChatListItemProps {
isActive?: boolean; isActive?: boolean;
archived?: boolean; archived?: boolean;
onClick?: () => void; onClick?: () => void;
onRename?: () => void;
onArchive?: () => void; onArchive?: () => void;
onDelete?: () => void; onDelete?: () => void;
} }
@ -26,6 +27,7 @@ export function ChatListItem({
isActive, isActive,
archived, archived,
onClick, onClick,
onRename,
onArchive, onArchive,
onDelete, onDelete,
}: ChatListItemProps) { }: ChatListItemProps) {
@ -58,6 +60,17 @@ export function ChatListItem({
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end" side="right"> <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 && ( {onArchive && (
<DropdownMenuItem <DropdownMenuItem
onClick={(e) => { onClick={(e) => {

View file

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

View file

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

View file

@ -17,6 +17,7 @@ interface PricingPlan {
price: string; price: string;
yearlyPrice: string; yearlyPrice: string;
period: string; period: string;
billingText?: string;
features: string[]; features: string[];
description: string; description: string;
buttonText: string; buttonText: string;
@ -35,7 +36,7 @@ export function Pricing({
title = "Simple, Transparent 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.", description = "Choose the plan that works for you\nAll plans include access to our SurfSense AI workspace and community support.",
}: PricingProps) { }: PricingProps) {
const [isMonthly, setIsMonthly] = useState(true); const [isMonthly, setIsMonthly] = useState(false);
const isDesktop = useMediaQuery("(min-width: 768px)"); const isDesktop = useMediaQuery("(min-width: 768px)");
const switchRef = useRef<HTMLButtonElement>(null); const switchRef = useRef<HTMLButtonElement>(null);
@ -183,7 +184,7 @@ export function Pricing({
</div> </div>
<p className="text-xs leading-5 text-muted-foreground"> <p className="text-xs leading-5 text-muted-foreground">
{isNaN(Number(plan.price)) ? "" : isMonthly ? "billed monthly" : "billed annually"} {plan.billingText ?? (isNaN(Number(plan.price)) ? "" : isMonthly ? "billed monthly" : "billed annually")}
</p> </p>
<ul className="mt-5 gap-2 flex flex-col"> <ul className="mt-5 gap-2 flex flex-col">

View file

@ -4,44 +4,47 @@ import { Pricing } from "@/components/pricing";
const demoPlans = [ const demoPlans = [
{ {
name: "COMMUNITY", name: "FREE",
price: "0", price: "0",
yearlyPrice: "0", yearlyPrice: "0",
period: "forever", period: "",
billingText: "Includes 30 day PRO trial",
features: [ features: [
"Community support", "Open source on GitHub",
"Supports 100+ LLMs", "Upload and chat with 300+ pages of content",
"Supports OpenAI spec and LiteLLM", "Connects with 8 popular sources, like Drive and Notion.",
"Supports local vLLM or Ollama setups", "Includes limited access to ChatGPT, Claude, and DeepSeek models",
"6000+ embedding models", "Supports 100+ more LLMs, including Gemini, Llama and many more.",
"50+ File extensions supported.", "50+ File extensions supported.",
"Podcasts support with local TTS providers.", "Generate podcasts in seconds.",
"Connects with 15+ external sources, like Drive and Notion.",
"Cross-Browser Extension for dynamic webpages including authenticated content", "Cross-Browser Extension for dynamic webpages including authenticated content",
"Role-based access control (RBAC)", "Community support on Discord",
"Collaboration and team features",
], ],
description: "Open source version with powerful features", description: "Powerful features with some limitations",
buttonText: "Dive In", buttonText: "Get Started",
href: "/docs", href: "/",
isPopular: false, isPopular: false,
}, },
{ {
name: "CLOUD", name: "PRO",
price: "0", price: "10",
yearlyPrice: "0", yearlyPrice: "10",
period: "in beta", period: "user / month",
billingText: "billed annually",
features: [ features: [
"Everything in Community", "Everything in Free",
"Email support", "Upload and chat with 5,000+ pages of content",
"Get started in seconds", "Connects with 15+ external sources, like Slack and Airtable.",
"Instant access to new features", "Includes extended access to ChatGPT, Claude, and DeepSeek models",
"Easy access from anywhere", "Collaboration and commenting features",
"Remote team management and collaboration", "Shared BYOK (Bring Your Own Key)",
"Team and role management",
"Planned: Centralized billing",
"Priority support",
], ],
description: "Instant access for individuals and teams", description: "The AIknowledge base for individuals and teams",
buttonText: "Get Started", buttonText: "Upgrade",
href: "/", href: "/contact",
isPopular: true, isPopular: true,
}, },
{ {
@ -49,18 +52,21 @@ const demoPlans = [
price: "Contact Us", price: "Contact Us",
yearlyPrice: "Contact Us", yearlyPrice: "Contact Us",
period: "", period: "",
billingText: "",
features: [ features: [
"Everything in Community", "Everything in Pro",
"Priority support", "Connect and chat with virtually unlimited pages of content",
"Limit models and/or providers",
"On-prem or VPC deployment",
"Planned: Audit logs and compliance",
"Planned: SSO, OIDC & SAML",
"Planned: Role-based access control (RBAC)",
"White-glove setup and deployment", "White-glove setup and deployment",
"Monthly managed updates and maintenance", "Monthly managed updates and maintenance",
"On-prem or VPC deployment", "SLA commitments",
"Audit logs and compliance", "Dedicated support",
"SSO, OIDC & SAML",
"SLA guarantee",
"Uptime guarantee on VPC",
], ],
description: "Professional, customized setup for large organizations", description: "Customized setup for large organizations",
buttonText: "Contact Sales", buttonText: "Contact Sales",
href: "/contact", href: "/contact",
isPopular: false, isPopular: false,

View file

@ -178,6 +178,18 @@ export const LLM_MODELS: LLMModel[] = [
}, },
// Google (Gemini) // 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", value: "gemini-2.5-flash",
label: "Gemini 2.5 Flash", label: "Gemini 2.5 Flash",

View file

@ -9,6 +9,8 @@
* as it may prevent the sed replacement from working correctly. * as it may prevent the sed replacement from working correctly.
*/ */
import packageJson from "../package.json";
// Auth type: "LOCAL" for email/password, "GOOGLE" for OAuth // Auth type: "LOCAL" for email/password, "GOOGLE" for OAuth
// Placeholder: __NEXT_PUBLIC_FASTAPI_BACKEND_AUTH_TYPE__ // Placeholder: __NEXT_PUBLIC_FASTAPI_BACKEND_AUTH_TYPE__
export const AUTH_TYPE = process.env.NEXT_PUBLIC_FASTAPI_BACKEND_AUTH_TYPE || "GOOGLE"; 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__ // Placeholder: __NEXT_PUBLIC_DEPLOYMENT_MODE__
export const DEPLOYMENT_MODE = process.env.NEXT_PUBLIC_DEPLOYMENT_MODE || "self-hosted"; 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 // Helper to check if local auth is enabled
export const isLocalAuth = () => AUTH_TYPE === "LOCAL"; export const isLocalAuth = () => AUTH_TYPE === "LOCAL";

View file

@ -676,6 +676,13 @@
"unarchive": "Restore", "unarchive": "Restore",
"chat_archived": "Chat archived", "chat_archived": "Chat archived",
"chat_unarchived": "Chat restored", "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", "no_archived_chats": "No archived chats",
"error_archiving_chat": "Failed to archive chat", "error_archiving_chat": "Failed to archive chat",
"new_chat": "New chat", "new_chat": "New chat",

View file

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