mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-04-30 19:36:25 +02:00
Merge pull request #777 from elammertsma/dev
Automatic chat naming, chat renaming, name in breadcrumbs
This commit is contained in:
commit
dbc9a4e0cd
18 changed files with 354 additions and 75 deletions
|
|
@ -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,
|
||||||
|
)
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
# =========================================================================
|
# =========================================================================
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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");
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
@ -57,15 +59,26 @@ export function ChatListItem({
|
||||||
<span className="sr-only">{t("more_options")}</span>
|
<span className="sr-only">{t("more_options")}</span>
|
||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="end" side="right">
|
<DropdownMenuContent align="end" side="right">
|
||||||
{onArchive && (
|
{onRename && (
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
onArchive();
|
onRename();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{archived ? (
|
<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" />
|
<RotateCcwIcon className="mr-2 h-4 w-4" />
|
||||||
<span>{t("unarchive") || "Restore"}</span>
|
<span>{t("unarchive") || "Restore"}</span>
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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)}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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";
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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": "新对话",
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue