diff --git a/surfsense_backend/.env.example b/surfsense_backend/.env.example index 349cb0307..2a0351d7b 100644 --- a/surfsense_backend/.env.example +++ b/surfsense_backend/.env.example @@ -1,5 +1,12 @@ DATABASE_URL=postgresql+asyncpg://postgres:postgres@localhost:5432/surfsense +SECRET_KEY=SECRET +NEXT_FRONTEND_URL=http://localhost:3000 + +#Celery Config +CELERY_BROKER_URL=redis://localhost:6379/0 +CELERY_RESULT_BACKEND=redis://localhost:6379/0 + #Celery Config CELERY_BROKER_URL=redis://localhost:6379/0 CELERY_RESULT_BACKEND=redis://localhost:6379/0 diff --git a/surfsense_backend/alembic/versions/32_add_podcast_staleness_detection.py b/surfsense_backend/alembic/versions/32_add_podcast_staleness_detection.py deleted file mode 100644 index d63e6cba2..000000000 --- a/surfsense_backend/alembic/versions/32_add_podcast_staleness_detection.py +++ /dev/null @@ -1,50 +0,0 @@ -"""Add podcast staleness detection columns to chats and podcasts tables - -This feature allows the system to detect when a podcast is outdated compared to the current state of the chat it was generated from, enabling users to regenerate podcasts when needed. - -Revision ID: 32 -Revises: 31 -""" - -from collections.abc import Sequence - -import sqlalchemy as sa - -from alembic import op - -# revision identifiers -revision: str = "32" -down_revision: str | None = "31" -branch_labels: str | Sequence[str] | None = None -depends_on: str | Sequence[str] | None = None - - -def upgrade() -> None: - """Add state_version, chat_state_version, and chat_id to chats and podcasts tables.""" - - # Add state_version column to chats table with default value of 1 - op.add_column( - "chats", - sa.Column("state_version", sa.BigInteger(), nullable=False, server_default="1"), - ) - - # Add chat_state_version column to podcasts table (nullable, set when podcast is generated) - op.add_column( - "podcasts", sa.Column("chat_state_version", sa.BigInteger(), nullable=True) - ) - - # Add chat_id column to podcasts table (nullable, set when podcast is generated from a chat) - op.add_column("podcasts", sa.Column("chat_id", sa.Integer(), nullable=True)) - - -def downgrade() -> None: - """Remove state_version, chat_state_version, and chat_id columns.""" - - # Remove chat_state_version from podcasts table - op.drop_column("podcasts", "chat_state_version") - - # Remove chat_id from podcasts table - op.drop_column("podcasts", "chat_id") - - # Remove state_version from chats table - op.drop_column("chats", "state_version") diff --git a/surfsense_backend/alembic/versions/34_add_podcast_staleness_detection.py b/surfsense_backend/alembic/versions/34_add_podcast_staleness_detection.py new file mode 100644 index 000000000..4991cd58e --- /dev/null +++ b/surfsense_backend/alembic/versions/34_add_podcast_staleness_detection.py @@ -0,0 +1,60 @@ +"""Add podcast staleness detection columns to chats and podcasts tables + +This feature allows the system to detect when a podcast is outdated compared to +the current state of the chat it was generated from, enabling users to regenerate +podcasts when needed. + +Revision ID: 34 +Revises: 33 +""" + +from collections.abc import Sequence + +from alembic import op + +# revision identifiers +revision: str = "34" +down_revision: str | None = "33" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + """Add columns only if they don't already exist (safe for re-runs).""" + + # Add 'state_version' column to chats table (default 1) + op.execute(""" + ALTER TABLE chats + ADD COLUMN IF NOT EXISTS state_version BIGINT DEFAULT 1 NOT NULL + """) + + # Add 'chat_state_version' column to podcasts table + op.execute(""" + ALTER TABLE podcasts + ADD COLUMN IF NOT EXISTS chat_state_version BIGINT + """) + + # Add 'chat_id' column to podcasts table + op.execute(""" + ALTER TABLE podcasts + ADD COLUMN IF NOT EXISTS chat_id INTEGER + """) + + +def downgrade() -> None: + """Remove columns only if they exist.""" + + op.execute(""" + ALTER TABLE podcasts + DROP COLUMN IF EXISTS chat_state_version + """) + + op.execute(""" + ALTER TABLE podcasts + DROP COLUMN IF EXISTS chat_id + """) + + op.execute(""" + ALTER TABLE chats + DROP COLUMN IF EXISTS state_version + """) diff --git a/surfsense_backend/app/agents/podcaster/nodes.py b/surfsense_backend/app/agents/podcaster/nodes.py index bce9882d6..51d748a3e 100644 --- a/surfsense_backend/app/agents/podcaster/nodes.py +++ b/surfsense_backend/app/agents/podcaster/nodes.py @@ -29,6 +29,7 @@ async def create_podcast_transcript( configuration = Configuration.from_runnable_config(config) user_id = configuration.user_id search_space_id = configuration.search_space_id + podcast_title = configuration.podcast_title # Get user's long context LLM llm = await get_user_long_context_llm(state.db_session, user_id, search_space_id) @@ -37,8 +38,8 @@ async def create_podcast_transcript( print(error_message) raise RuntimeError(error_message) - # Get the prompt - prompt = get_podcast_generation_prompt() + # Get the prompt with podcast_title as user_prompt + prompt = get_podcast_generation_prompt(user_prompt=podcast_title) # Create the messages messages = [ diff --git a/surfsense_backend/app/agents/podcaster/prompts.py b/surfsense_backend/app/agents/podcaster/prompts.py index a3d6c3147..3e0981f32 100644 --- a/surfsense_backend/app/agents/podcaster/prompts.py +++ b/surfsense_backend/app/agents/podcaster/prompts.py @@ -1,11 +1,19 @@ import datetime -def get_podcast_generation_prompt(): +def get_podcast_generation_prompt(user_prompt: str | None = None): + user_prompt_section = "" + if user_prompt: + user_prompt_section = f""" + +{user_prompt} + +""" + return f""" Today's date: {datetime.datetime.now().strftime("%Y-%m-%d")} -You are a master podcast scriptwriter, adept at transforming diverse input content into a lively, engaging, and natural-sounding conversation between two distinct podcast hosts. Your primary objective is to craft authentic, flowing dialogue that captures the spontaneity and chemistry of a real podcast discussion, completely avoiding any hint of robotic scripting or stiff formality. Think dynamic interplay, not just information delivery. +You are a master podcast scriptwriter, adept at transforming diverse input content into a lively, engaging, and natural-sounding conversation between two distinct podcast hosts. Your primary objective is to craft authentic, flowing dialogue that captures the spontaneity and chemistry of a real podcast discussion, completely avoiding any hint of robotic scripting or stiff formality. Think dynamic interplay, not just information delivery.{user_prompt_section} - '': A block of text containing the information to be discussed in the podcast. This could be research findings, an article summary, a detailed outline, user chat history related to the topic, or any other relevant raw information. The content might be unstructured but serves as the factual basis for the podcast dialogue. diff --git a/surfsense_backend/app/tasks/celery_tasks/podcast_tasks.py b/surfsense_backend/app/tasks/celery_tasks/podcast_tasks.py index 59a3bb2b1..1e938d829 100644 --- a/surfsense_backend/app/tasks/celery_tasks/podcast_tasks.py +++ b/surfsense_backend/app/tasks/celery_tasks/podcast_tasks.py @@ -46,7 +46,7 @@ def generate_chat_podcast_task( Args: chat_id: ID of the chat to generate podcast from search_space_id: ID of the search space - podcast_title: Title for the podcast + podcast_title: Title for the podcast (used as user prompt for generation) user_id: ID of the user """ loop = asyncio.new_event_loop() diff --git a/surfsense_backend/app/tasks/podcast_tasks.py b/surfsense_backend/app/tasks/podcast_tasks.py index 51e750f4d..ea125fa11 100644 --- a/surfsense_backend/app/tasks/podcast_tasks.py +++ b/surfsense_backend/app/tasks/podcast_tasks.py @@ -96,7 +96,7 @@ async def generate_chat_podcast( config = { "configurable": { - "podcast_title": "SurfSense", + "podcast_title": podcast_title, "user_id": str(user_id), "search_space_id": search_space_id, } diff --git a/surfsense_web/app/dashboard/[search_space_id]/client-layout.tsx b/surfsense_web/app/dashboard/[search_space_id]/client-layout.tsx index 55da177a4..e1e51c601 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/client-layout.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/client-layout.tsx @@ -1,10 +1,12 @@ "use client"; -import { Loader2 } from "lucide-react"; +import { useAtom } from "jotai"; +import { Loader2, PanelRight } from "lucide-react"; import { usePathname, useRouter } from "next/navigation"; import { useTranslations } from "next-intl"; import type React from "react"; import { useEffect, useMemo, useState } from "react"; +import { ChatPanelContainer } from "@/components/chat/ChatPanel/ChatPanelContainer"; import { DashboardBreadcrumb } from "@/components/dashboard-breadcrumb"; import { LanguageSwitcher } from "@/components/LanguageSwitcher"; import { AppSidebarProvider } from "@/components/sidebar/AppSidebarProvider"; @@ -13,6 +15,8 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/com import { Separator } from "@/components/ui/separator"; import { SidebarInset, SidebarProvider, SidebarTrigger } from "@/components/ui/sidebar"; import { useLLMPreferences } from "@/hooks/use-llm-configs"; +import { cn } from "@/lib/utils"; +import { chatUIAtom } from "@/stores/chat/chat-ui.atom"; export function DashboardClientLayout({ children, @@ -30,6 +34,10 @@ export function DashboardClientLayout({ const pathname = usePathname(); const searchSpaceIdNum = Number(searchSpaceId); + const [chatUIState, setChatUIState] = useAtom(chatUIAtom); + + const { isChatPannelOpen } = chatUIState; + const { loading, error, isOnboardingComplete } = useLLMPreferences(searchSpaceIdNum); const [hasCheckedOnboarding, setHasCheckedOnboarding] = useState(false); @@ -129,28 +137,49 @@ export function DashboardClientLayout({ } return ( - + {/* Use AppSidebarProvider which fetches user, search space, and recent chats */} - -
-
-
- - - -
-
- - -
+ +
+
+
+
+
+ + + +
+
+ + + +
+
+
+
{children}
-
- {children} + +
); diff --git a/surfsense_web/app/dashboard/layout.tsx b/surfsense_web/app/dashboard/layout.tsx index 9b396e990..01436aff9 100644 --- a/surfsense_web/app/dashboard/layout.tsx +++ b/surfsense_web/app/dashboard/layout.tsx @@ -42,9 +42,9 @@ export default function DashboardLayout({ children }: DashboardLayoutProps) { } return ( - <> +
- {children} - +
{children}
+
); } diff --git a/surfsense_web/app/layout.tsx b/surfsense_web/app/layout.tsx index 06f894094..c7bfb6058 100644 --- a/surfsense_web/app/layout.tsx +++ b/surfsense_web/app/layout.tsx @@ -6,6 +6,7 @@ import { I18nProvider } from "@/components/providers/I18nProvider"; import { ThemeProvider } from "@/components/theme/theme-provider"; import { Toaster } from "@/components/ui/sonner"; import { LocaleProvider } from "@/contexts/LocaleContext"; +import { ReactQueryClientProvider } from "@/lib/query-client/query-client.provider"; import { cn } from "@/lib/utils"; const roboto = Roboto({ @@ -89,7 +90,7 @@ export default function RootLayout({ // Locale state is managed by LocaleContext and persisted in localStorage return ( - + - {children} + +
{children}
+
diff --git a/surfsense_web/components/announcement-banner.tsx b/surfsense_web/components/announcement-banner.tsx index e9647085f..673bda51d 100644 --- a/surfsense_web/components/announcement-banner.tsx +++ b/surfsense_web/components/announcement-banner.tsx @@ -10,7 +10,7 @@ export function AnnouncementBanner() { if (!isVisible) return null; return ( -
+
diff --git a/surfsense_web/components/chat/ChatInterface.tsx b/surfsense_web/components/chat/ChatInterface.tsx index c36725133..2b167ddeb 100644 --- a/surfsense_web/components/chat/ChatInterface.tsx +++ b/surfsense_web/components/chat/ChatInterface.tsx @@ -1,16 +1,13 @@ "use client"; import { type ChatHandler, ChatSection as LlamaIndexChatSection } from "@llamaindex/chat-ui"; +import { useSetAtom } from "jotai"; import { useParams } from "next/navigation"; -import { createContext, useCallback, useEffect, useState } from "react"; -import type { ChatDetails } from "@/app/dashboard/[search_space_id]/chats/chats-client"; -import type { PodcastItem } from "@/app/dashboard/[search_space_id]/podcasts/podcasts-client"; -import type { ResearchMode } from "@/components/chat"; +import { useEffect } from "react"; import { ChatInputUI } from "@/components/chat/ChatInputGroup"; import { ChatMessagesUI } from "@/components/chat/ChatMessages"; -import { useChatAPI } from "@/hooks/use-chat"; import type { Document } from "@/hooks/use-documents"; -import { usePodcast } from "@/hooks/use-podcast"; +import { activeChatIdAtom } from "@/stores/chat/active-chat.atom"; import { ChatPanelContainer } from "./ChatPanel/ChatPanelContainer"; interface ChatInterfaceProps { @@ -23,17 +20,6 @@ interface ChatInterfaceProps { onSearchModeChange?: (mode: "DOCUMENTS" | "CHUNKS") => void; } -interface ChatInterfaceContext { - isChatPannelOpen: boolean; - setIsChatPannelOpen: (value: boolean) => void; - chat_id: string; - chatDetails: ChatDetails | null; - podcast: PodcastItem | null; - setPodcast: (podcast: PodcastItem | null) => void; -} - -export const chatInterfaceContext = createContext(null); - export default function ChatInterface({ handler, onDocumentSelectionChange, @@ -44,69 +30,29 @@ export default function ChatInterface({ onSearchModeChange, }: ChatInterfaceProps) { const { chat_id, search_space_id } = useParams(); - const [chatDetails, setChatDetails] = useState(null); - const [isChatPannelOpen, setIsChatPannelOpen] = useState(false); - const [podcast, setPodcast] = useState(null); - const contextValue = { - isChatPannelOpen, - setIsChatPannelOpen, - chat_id: typeof chat_id === "string" ? chat_id : chat_id ? chat_id[0] : "", - podcast, - setPodcast, - chatDetails, - }; - - const { getPodcastByChatId } = usePodcast(); - - const { fetchChatDetails } = useChatAPI({ - token: localStorage?.getItem("surfsense_bearer_token"), - search_space_id: search_space_id as string, - }); - - const getPodcast = useCallback( - async (id: string) => { - const podcast = await getPodcastByChatId(Number(id)); - setPodcast(podcast); - }, - [getPodcastByChatId] - ); - - const getChat = useCallback( - async (id: string) => { - const chat = await fetchChatDetails(id); - setChatDetails(chat); - }, - [fetchChatDetails] - ); + const setActiveChatIdState = useSetAtom(activeChatIdAtom); useEffect(() => { const id = typeof chat_id === "string" ? chat_id : chat_id ? chat_id[0] : ""; if (!id) return; - getChat(id); - getPodcast(id); + setActiveChatIdState(id); }, [chat_id, search_space_id]); return ( - - -
-
- -
- -
-
- + +
+ +
+
- - +
+
); } diff --git a/surfsense_web/components/chat/ChatPanel/ChatPanelContainer.tsx b/surfsense_web/components/chat/ChatPanel/ChatPanelContainer.tsx index 66dbc088a..4648ae9c9 100644 --- a/surfsense_web/components/chat/ChatPanel/ChatPanelContainer.tsx +++ b/surfsense_web/components/chat/ChatPanel/ChatPanelContainer.tsx @@ -1,8 +1,11 @@ -import { PanelRight } from "lucide-react"; -import { useActionState, useContext, useTransition } from "react"; +"use client"; +import { useAtom, useAtomValue } from "jotai"; +import { LoaderIcon, PanelRight, TriangleAlert } from "lucide-react"; import { toast } from "sonner"; +import { generatePodcast } from "@/lib/apis/podcast-apis"; import { cn } from "@/lib/utils"; -import { chatInterfaceContext } from "../ChatInterface"; +import { activeChatAtom, activeChatIdAtom } from "@/stores/chat/active-chat.atom"; +import { chatUIAtom } from "@/stores/chat/chat-ui.atom"; import { ChatPanelView } from "./ChatPanelView"; export interface GeneratePodcastRequest { @@ -13,68 +16,52 @@ export interface GeneratePodcastRequest { } export function ChatPanelContainer() { - const context = useContext(chatInterfaceContext); + const { + data: activeChatState, + isLoading: isChatLoading, + error: chatError, + } = useAtomValue(activeChatAtom); + const activeChatIdState = useAtomValue(activeChatIdAtom); + const authToken = localStorage.getItem("surfsense_bearer_token"); + const { isChatPannelOpen } = useAtomValue(chatUIAtom); - if (!context) { - throw new Error("chatInterfaceContext must be used within a ChatProvider"); - } - - const { isChatPannelOpen, setIsChatPannelOpen, chat_id: chatId } = context; - - const generatePodcast = async (request: GeneratePodcastRequest) => { + const handleGeneratePodcast = async (request: GeneratePodcastRequest) => { try { - const { podcast_title = "SurfSense Podcast" } = request; - - const response = await fetch( - `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/podcasts/generate/`, - { - method: "POST", - headers: { - Authorization: `Bearer ${localStorage.getItem("surfsense_bearer_token")}`, - "Content-Type": "application/json", - }, - body: JSON.stringify({ ...request, podcast_title }), - } - ); - - if (!response.ok) { - const errorData = await response.json().catch(() => ({})); - throw new Error(errorData.detail || "Failed to generate podcast"); + if (!authToken) { + throw new Error("Authentication error. Please log in again."); } - + await generatePodcast(request, authToken); toast.success(`Podcast generation started!`); } catch (error) { + toast.error("Error generating podcast. Please log in again."); console.error("Error generating podcast:", error); - console.log(error); - } finally { } }; - return chatId && chatId !== "" ? ( + return activeChatIdState ? (
-
- -
+ {isChatLoading || chatError ? ( +
+ {isChatLoading ? ( +
+ +
+ ) : chatError ? ( +
+ +
+ ) : null} +
+ ) : null} -
- -
+ {!isChatLoading && !chatError && activeChatState?.chatDetails && ( + + )}
) : null; } diff --git a/surfsense_web/components/chat/ChatPanel/ChatPanelView.tsx b/surfsense_web/components/chat/ChatPanel/ChatPanelView.tsx index dc18afd0d..0f18a7e99 100644 --- a/surfsense_web/components/chat/ChatPanel/ChatPanelView.tsx +++ b/surfsense_web/components/chat/ChatPanel/ChatPanelView.tsx @@ -1,9 +1,11 @@ "use client"; +import { useAtom, useAtomValue } from "jotai"; import { AlertCircle, Pencil, Play, Podcast, RefreshCw } from "lucide-react"; import { useCallback, useContext, useTransition } from "react"; import { cn } from "@/lib/utils"; -import { chatInterfaceContext } from "../ChatInterface"; +import { activeChatAtom } from "@/stores/chat/active-chat.atom"; +import { chatUIAtom } from "@/stores/chat/chat-ui.atom"; import { getPodcastStalenessMessage, isPodcastStale } from "../PodcastUtils"; import type { GeneratePodcastRequest } from "./ChatPanelContainer"; import { ConfigModal } from "./ConfigModal"; @@ -14,12 +16,13 @@ interface ChatPanelViewProps { } export function ChatPanelView(props: ChatPanelViewProps) { - const context = useContext(chatInterfaceContext); - if (!context) { - throw new Error("chatInterfaceContext must be used within a ChatProvider"); - } + const [chatUIState, setChatUIState] = useAtom(chatUIAtom); + const { data: activeChatState } = useAtomValue(activeChatAtom); + + const { isChatPannelOpen } = chatUIState; + const podcast = activeChatState?.podcast; + const chatDetails = activeChatState?.chatDetails; - const { isChatPannelOpen, setIsChatPannelOpen, chatDetails, podcast } = context; const { generatePodcast } = props; // Check if podcast is stale @@ -40,7 +43,7 @@ export function ChatPanelView(props: ChatPanelViewProps) {
setIsChatPannelOpen(!isChatPannelOpen)} + onClick={() => + setChatUIState((prev) => ({ + ...prev, + isChatPannelOpen: !isChatPannelOpen, + })) + } className={cn( "p-2 rounded-full hover:bg-muted transition-colors", podcastIsStale && "text-amber-600 dark:text-amber-500" @@ -116,7 +124,7 @@ export function ChatPanelView(props: ChatPanelViewProps) { {podcast ? (
@@ -126,7 +134,7 @@ export function ChatPanelView(props: ChatPanelViewProps) {