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/configuration.py b/surfsense_backend/app/agents/podcaster/configuration.py index 453f12676..c7433dadc 100644 --- a/surfsense_backend/app/agents/podcaster/configuration.py +++ b/surfsense_backend/app/agents/podcaster/configuration.py @@ -18,6 +18,7 @@ class Configuration: podcast_title: str user_id: str search_space_id: int + user_prompt: str | None = None @classmethod def from_runnable_config( diff --git a/surfsense_backend/app/agents/podcaster/nodes.py b/surfsense_backend/app/agents/podcaster/nodes.py index bce9882d6..ceb1bd64b 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 + user_prompt = configuration.user_prompt # Get user's long context LLM llm = await get_user_long_context_llm(state.db_session, user_id, search_space_id) @@ -38,7 +39,7 @@ async def create_podcast_transcript( raise RuntimeError(error_message) # Get the prompt - prompt = get_podcast_generation_prompt() + prompt = get_podcast_generation_prompt(user_prompt) # Create the messages messages = [ diff --git a/surfsense_backend/app/agents/podcaster/prompts.py b/surfsense_backend/app/agents/podcaster/prompts.py index a3d6c3147..efaa79788 100644 --- a/surfsense_backend/app/agents/podcaster/prompts.py +++ b/surfsense_backend/app/agents/podcaster/prompts.py @@ -1,12 +1,23 @@ import datetime -def get_podcast_generation_prompt(): +def get_podcast_generation_prompt(user_prompt: str | None = None): 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. +{ + f''' +You **MUST** strictly adhere to the following user instruction while generating the podcast script: + +{user_prompt} + +''' + if user_prompt + else "" + } + - '': 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/db.py b/surfsense_backend/app/db.py index 12454c652..48154a417 100644 --- a/surfsense_backend/app/db.py +++ b/surfsense_backend/app/db.py @@ -9,6 +9,7 @@ from sqlalchemy import ( ARRAY, JSON, TIMESTAMP, + BigInteger, Boolean, Column, Enum as SQLAlchemyEnum, @@ -157,6 +158,7 @@ class Chat(BaseModel, TimestampMixin): title = Column(String, nullable=False, index=True) initial_connectors = Column(ARRAY(String), nullable=True) messages = Column(JSON, nullable=False) + state_version = Column(BigInteger, nullable=False, default=1) search_space_id = Column( Integer, ForeignKey("searchspaces.id", ondelete="CASCADE"), nullable=False @@ -203,6 +205,10 @@ class Podcast(BaseModel, TimestampMixin): title = Column(String, nullable=False, index=True) podcast_transcript = Column(JSON, nullable=False, default={}) file_location = Column(String(500), nullable=False, default="") + chat_id = Column( + Integer, ForeignKey("chats.id", ondelete="CASCADE"), nullable=True + ) # If generated from a chat, this will be the chat id, else null ( can be from a document or a chat ) + chat_state_version = Column(BigInteger, nullable=True) search_space_id = Column( Integer, ForeignKey("searchspaces.id", ondelete="CASCADE"), nullable=False diff --git a/surfsense_backend/app/routes/chats_routes.py b/surfsense_backend/app/routes/chats_routes.py index 609c6ae3f..ed8911260 100644 --- a/surfsense_backend/app/routes/chats_routes.py +++ b/surfsense_backend/app/routes/chats_routes.py @@ -199,6 +199,7 @@ async def read_chats( Chat.initial_connectors, Chat.search_space_id, Chat.created_at, + Chat.state_version, ) .join(SearchSpace) .filter(SearchSpace.user_id == user.id) @@ -261,7 +262,10 @@ async def update_chat( db_chat = await read_chat(chat_id, session, user) update_data = chat_update.model_dump(exclude_unset=True) for key, value in update_data.items(): + if key == "messages": + db_chat.state_version = len(update_data["messages"]) setattr(db_chat, key, value) + await session.commit() await session.refresh(db_chat) return db_chat diff --git a/surfsense_backend/app/routes/podcasts_routes.py b/surfsense_backend/app/routes/podcasts_routes.py index e37bdd190..ae1fdaeef 100644 --- a/surfsense_backend/app/routes/podcasts_routes.py +++ b/surfsense_backend/app/routes/podcasts_routes.py @@ -155,7 +155,11 @@ async def delete_podcast( async def generate_chat_podcast_with_new_session( - chat_id: int, search_space_id: int, podcast_title: str, user_id: int + chat_id: int, + search_space_id: int, + user_id: int, + podcast_title: str | None = None, + user_prompt: str | None = None, ): """Create a new session and process chat podcast generation.""" from app.db import async_session_maker @@ -163,7 +167,7 @@ async def generate_chat_podcast_with_new_session( async with async_session_maker() as session: try: await generate_chat_podcast( - session, chat_id, search_space_id, podcast_title, user_id + session, chat_id, search_space_id, user_id, podcast_title, user_prompt ) except Exception as e: import logging @@ -211,7 +215,11 @@ async def generate_podcast( # Add Celery tasks for each chat ID for chat_id in valid_chat_ids: generate_chat_podcast_task.delay( - chat_id, request.search_space_id, request.podcast_title, user.id + chat_id, + request.search_space_id, + user.id, + request.podcast_title, + request.user_prompt, ) return { @@ -287,3 +295,27 @@ async def stream_podcast( raise HTTPException( status_code=500, detail=f"Error streaming podcast: {e!s}" ) from e + + +@router.get("/podcasts/by-chat/{chat_id}", response_model=PodcastRead | None) +async def get_podcast_by_chat_id( + chat_id: int, + session: AsyncSession = Depends(get_async_session), + user: User = Depends(current_active_user), +): + try: + # Get the podcast and check if user has access + result = await session.execute( + select(Podcast) + .join(SearchSpace) + .filter(Podcast.chat_id == chat_id, SearchSpace.user_id == user.id) + ) + podcast = result.scalars().first() + + return podcast + except HTTPException as he: + raise he + except Exception as e: + raise HTTPException( + status_code=500, detail=f"Error fetching podcast: {e!s}" + ) from e diff --git a/surfsense_backend/app/schemas/chats.py b/surfsense_backend/app/schemas/chats.py index 59c274dc5..64ce73e9e 100644 --- a/surfsense_backend/app/schemas/chats.py +++ b/surfsense_backend/app/schemas/chats.py @@ -13,12 +13,14 @@ class ChatBase(BaseModel): initial_connectors: list[str] | None = None messages: list[Any] search_space_id: int + state_version: int = 1 class ChatBaseWithoutMessages(BaseModel): type: ChatType title: str search_space_id: int + state_version: int = 1 class ClientAttachment(BaseModel): diff --git a/surfsense_backend/app/schemas/podcasts.py b/surfsense_backend/app/schemas/podcasts.py index d86b3151c..b6a6a9a24 100644 --- a/surfsense_backend/app/schemas/podcasts.py +++ b/surfsense_backend/app/schemas/podcasts.py @@ -10,6 +10,7 @@ class PodcastBase(BaseModel): podcast_transcript: list[Any] file_location: str = "" search_space_id: int + chat_state_version: int | None = None class PodcastCreate(PodcastBase): @@ -28,4 +29,5 @@ class PodcastGenerateRequest(BaseModel): type: Literal["DOCUMENT", "CHAT"] ids: list[int] search_space_id: int - podcast_title: str = "SurfSense Podcast" + podcast_title: str | None = None + user_prompt: str | None = None diff --git a/surfsense_backend/app/tasks/celery_tasks/podcast_tasks.py b/surfsense_backend/app/tasks/celery_tasks/podcast_tasks.py index 59a3bb2b1..65cdb886b 100644 --- a/surfsense_backend/app/tasks/celery_tasks/podcast_tasks.py +++ b/surfsense_backend/app/tasks/celery_tasks/podcast_tasks.py @@ -38,7 +38,12 @@ def get_celery_session_maker(): @celery_app.task(name="generate_chat_podcast", bind=True) def generate_chat_podcast_task( - self, chat_id: int, search_space_id: int, podcast_title: str, user_id: int + self, + chat_id: int, + search_space_id: int, + user_id: int, + podcast_title: str | None = None, + user_prompt: str | None = None, ): """ Celery task to generate podcast from chat. @@ -46,15 +51,18 @@ def generate_chat_podcast_task( Args: chat_id: ID of the chat to generate podcast from search_space_id: ID of the search space + user_id: ID of the user, podcast_title: Title for the podcast - user_id: ID of the user + user_prompt: Optional prompt from the user to guide the podcast generation """ loop = asyncio.new_event_loop() asyncio.set_event_loop(loop) try: loop.run_until_complete( - _generate_chat_podcast(chat_id, search_space_id, podcast_title, user_id) + _generate_chat_podcast( + chat_id, search_space_id, user_id, podcast_title, user_prompt + ) ) loop.run_until_complete(loop.shutdown_asyncgens()) finally: @@ -63,13 +71,17 @@ def generate_chat_podcast_task( async def _generate_chat_podcast( - chat_id: int, search_space_id: int, podcast_title: str, user_id: int + chat_id: int, + search_space_id: int, + user_id: int, + podcast_title: str | None = None, + user_prompt: str | None = None, ): """Generate chat podcast with new session.""" async with get_celery_session_maker()() as session: try: await generate_chat_podcast( - session, chat_id, search_space_id, podcast_title, user_id + session, chat_id, search_space_id, user_id, podcast_title, user_prompt ) except Exception as e: logger.error(f"Error generating podcast from chat: {e!s}") diff --git a/surfsense_backend/app/tasks/podcast_tasks.py b/surfsense_backend/app/tasks/podcast_tasks.py index e5f828ef2..d7fee189d 100644 --- a/surfsense_backend/app/tasks/podcast_tasks.py +++ b/surfsense_backend/app/tasks/podcast_tasks.py @@ -19,8 +19,9 @@ async def generate_chat_podcast( session: AsyncSession, chat_id: int, search_space_id: int, - podcast_title: str, user_id: int, + podcast_title: str | None = None, + user_prompt: str | None = None, ): task_logger = TaskLoggingService(session, search_space_id) @@ -34,6 +35,7 @@ async def generate_chat_podcast( "search_space_id": search_space_id, "podcast_title": podcast_title, "user_id": str(user_id), + "user_prompt": user_prompt, }, ) @@ -96,9 +98,10 @@ async def generate_chat_podcast( config = { "configurable": { - "podcast_title": "SurfSense", + "podcast_title": podcast_title or "SurfSense Podcast", "user_id": str(user_id), "search_space_id": search_space_id, + "user_prompt": user_prompt, } } # Initialize state with database session and streaming service @@ -139,33 +142,49 @@ async def generate_chat_podcast( }, ) - podcast = Podcast( - title=f"{podcast_title}", - podcast_transcript=serializable_transcript, - file_location=result["final_podcast_file_path"], - search_space_id=search_space_id, + # check if podcast already exists for this chat (re-generation) + existing_podcast = await session.execute( + select(Podcast).filter(Podcast.chat_id == chat_id) ) + existing_podcast = existing_podcast.scalars().first() - # Add to session and commit - session.add(podcast) - await session.commit() - await session.refresh(podcast) + if existing_podcast: + existing_podcast.podcast_transcript = serializable_transcript + existing_podcast.file_location = result["final_podcast_file_path"] + existing_podcast.chat_state_version = chat.state_version + await session.commit() + await session.refresh(existing_podcast) + return existing_podcast + else: + podcast = Podcast( + title=f"{podcast_title}", + podcast_transcript=serializable_transcript, + file_location=result["final_podcast_file_path"], + search_space_id=search_space_id, + chat_state_version=chat.state_version, + chat_id=chat.id, + ) - # Log success - await task_logger.log_task_success( - log_entry, - f"Successfully generated podcast for chat {chat_id}", - { - "podcast_id": podcast.id, - "podcast_title": podcast_title, - "transcript_entries": len(serializable_transcript), - "file_location": result.get("final_podcast_file_path"), - "processed_messages": processed_messages, - "content_length": len(chat_history_str), - }, - ) + # Add to session and commit + session.add(podcast) + await session.commit() + await session.refresh(podcast) - return podcast + # Log success + await task_logger.log_task_success( + log_entry, + f"Successfully generated podcast for chat {chat_id}", + { + "podcast_id": podcast.id, + "podcast_title": podcast_title, + "transcript_entries": len(serializable_transcript), + "file_location": result.get("final_podcast_file_path"), + "processed_messages": processed_messages, + "content_length": len(chat_history_str), + }, + ) + + return podcast except ValueError as ve: # ValueError is already logged above for chat not found diff --git a/surfsense_web/app/dashboard/[search_space_id]/chats/chats-client.tsx b/surfsense_web/app/dashboard/[search_space_id]/chats/chats-client.tsx index cb58acf27..b1f3aaf04 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/chats/chats-client.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/chats/chats-client.tsx @@ -56,12 +56,24 @@ import { } from "@/components/ui/select"; import { cn } from "@/lib/utils"; -interface Chat { +export interface Chat { created_at: string; id: number; - type: string; + type: "DOCUMENT" | "CHAT"; title: string; search_space_id: number; + state_version: number; +} + +export interface ChatDetails { + type: "DOCUMENT" | "CHAT"; + title: string; + initial_connectors: string[]; + messages: any[]; + created_at: string; + id: number; + search_space_id: number; + state_version: number; } interface ChatsPageClientProps { 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/[search_space_id]/layout.tsx b/surfsense_web/app/dashboard/[search_space_id]/layout.tsx index 558220305..c014c6f32 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/layout.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/layout.tsx @@ -59,12 +59,6 @@ export default function DashboardLayout({ }, ], }, - { - title: "Podcasts", - url: `/dashboard/${search_space_id}/podcasts`, - icon: "Podcast", - items: [], - }, { title: "Logs", url: `/dashboard/${search_space_id}/logs`, diff --git a/surfsense_web/app/dashboard/[search_space_id]/podcasts/podcasts-client.tsx b/surfsense_web/app/dashboard/[search_space_id]/podcasts/podcasts-client.tsx index 3fa6db3ff..9f0a7be29 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/podcasts/podcasts-client.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/podcasts/podcasts-client.tsx @@ -47,13 +47,14 @@ import { } from "@/components/ui/select"; import { Slider } from "@/components/ui/slider"; -interface PodcastItem { +export interface PodcastItem { id: number; title: string; created_at: string; file_location: string; podcast_transcript: any[]; search_space_id: number; + chat_state_version: number | null; } interface PodcastsPageClientProps { 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 46c62492d..9b518017a 100644 --- a/surfsense_web/components/chat/ChatInterface.tsx +++ b/surfsense_web/components/chat/ChatInterface.tsx @@ -1,9 +1,14 @@ "use client"; import { type ChatHandler, ChatSection as LlamaIndexChatSection } from "@llamaindex/chat-ui"; +import { useSetAtom } from "jotai"; +import { useParams } from "next/navigation"; +import { useEffect } from "react"; import { ChatInputUI } from "@/components/chat/ChatInputGroup"; import { ChatMessagesUI } from "@/components/chat/ChatMessages"; import type { Document } from "@/hooks/use-documents"; +import { activeChatIdAtom } from "@/stores/chat/active-chat.atom"; +import { ChatPanelContainer } from "./ChatPanel/ChatPanelContainer"; interface ChatInterfaceProps { handler: ChatHandler; @@ -28,9 +33,18 @@ export default function ChatInterface({ topK = 10, onTopKChange, }: ChatInterfaceProps) { + const { chat_id, search_space_id } = useParams(); + const setActiveChatIdState = useSetAtom(activeChatIdAtom); + + useEffect(() => { + const id = typeof chat_id === "string" ? chat_id : chat_id ? chat_id[0] : ""; + if (!id) return; + setActiveChatIdState(id); + }, [chat_id, search_space_id]); + return ( -
+
{ + try { + 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); + } + }; + + 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 new file mode 100644 index 000000000..1d4ac4c71 --- /dev/null +++ b/surfsense_web/components/chat/ChatPanel/ChatPanelView.tsx @@ -0,0 +1,147 @@ +"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 { 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"; +import { PodcastPlayer } from "./PodcastPlayer"; + +interface ChatPanelViewProps { + generatePodcast: (request: GeneratePodcastRequest) => Promise; +} + +export function ChatPanelView(props: ChatPanelViewProps) { + const [chatUIState, setChatUIState] = useAtom(chatUIAtom); + const { data: activeChatState } = useAtomValue(activeChatAtom); + + const { isChatPannelOpen } = chatUIState; + const podcast = activeChatState?.podcast; + const chatDetails = activeChatState?.chatDetails; + + const { generatePodcast } = props; + + // Check if podcast is stale + const podcastIsStale = + podcast && chatDetails && isPodcastStale(chatDetails.state_version, podcast.chat_state_version); + + const handleGeneratePost = useCallback(async () => { + if (!chatDetails) return; + await generatePodcast({ + type: "CHAT", + ids: [chatDetails.id], + search_space_id: chatDetails.search_space_id, + podcast_title: chatDetails.title, + }); + }, [chatDetails, generatePodcast]); + + return ( +
+
+ {isChatPannelOpen ? ( +
+ {/* Show stale podcast warning if applicable */} + {podcastIsStale && ( +
+
+ +
+

Podcast is outdated

+

+ {getPodcastStalenessMessage( + chatDetails?.state_version || 0, + podcast?.chat_state_version + )} +

+
+
+
+ )} + +
{ + if (e.key === "Enter") { + e.preventDefault(); + handleGeneratePost(); + } + }} + className={cn( + "w-full space-y-3 rounded-xl p-3 transition-colors", + podcastIsStale + ? "bg-gradient-to-r from-amber-400/50 to-orange-300/50 dark:from-amber-500/30 dark:to-orange-600/30 hover:from-amber-400/60 hover:to-orange-300/60" + : "bg-gradient-to-r from-slate-400/50 to-slate-200/50 dark:from-slate-400/30 dark:to-slate-800/60 hover:from-slate-400/60 hover:to-slate-200/60" + )} + > +
+ {podcastIsStale ? ( + + ) : ( + + )} + +
+

+ {podcastIsStale ? "Regenerate Podcast" : "Generate Podcast"} +

+
+
+ ) : ( + + )} +
+ {podcast ? ( +
+ {isChatPannelOpen ? ( + + ) : podcast ? ( + + ) : null} +
+ ) : null} +
+ ); +} diff --git a/surfsense_web/components/chat/ChatPanel/ConfigModal.tsx b/surfsense_web/components/chat/ChatPanel/ConfigModal.tsx new file mode 100644 index 000000000..2ee0d30cb --- /dev/null +++ b/surfsense_web/components/chat/ChatPanel/ConfigModal.tsx @@ -0,0 +1,72 @@ +"use client"; + +import { useAtomValue } from "jotai"; +import { Pencil } from "lucide-react"; +import { useCallback, useContext, useState } from "react"; +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; +import { activeChatAtom } from "@/stores/chat/active-chat.atom"; +import type { GeneratePodcastRequest } from "./ChatPanelContainer"; + +interface ConfigModalProps { + generatePodcast: (request: GeneratePodcastRequest) => Promise; +} + +export function ConfigModal(props: ConfigModalProps) { + const { data: activeChatState } = useAtomValue(activeChatAtom); + + const chatDetails = activeChatState?.chatDetails; + const podcast = activeChatState?.podcast; + + const { generatePodcast } = props; + + const [userPromt, setUserPrompt] = useState(""); + + const handleGeneratePost = useCallback(async () => { + if (!chatDetails) return; + await generatePodcast({ + type: "CHAT", + ids: [chatDetails.id], + search_space_id: chatDetails.search_space_id, + podcast_title: podcast?.title || chatDetails.title, + user_prompt: userPromt, + }); + }, [chatDetails, userPromt]); + + return ( + + e.stopPropagation()} + > + + + e.stopPropagation()} align="end" className="bg-sidebar w-96 "> +
+ + + + + +
+
+
+ ); +} diff --git a/surfsense_web/components/chat/ChatPanel/PodcastPlayer/PodcastPlayer.tsx b/surfsense_web/components/chat/ChatPanel/PodcastPlayer/PodcastPlayer.tsx new file mode 100644 index 000000000..e17306f66 --- /dev/null +++ b/surfsense_web/components/chat/ChatPanel/PodcastPlayer/PodcastPlayer.tsx @@ -0,0 +1,321 @@ +"use client"; + +import { Pause, Play, Podcast, SkipBack, SkipForward, Volume2, VolumeX, X } from "lucide-react"; +import { motion } from "motion/react"; +import { useEffect, useRef, useState } from "react"; +import { toast } from "sonner"; +import type { PodcastItem } from "@/app/dashboard/[search_space_id]/podcasts/podcasts-client"; +import { Button } from "@/components/ui/button"; +import { Slider } from "@/components/ui/slider"; +import { PodcastPlayerCompactSkeleton } from "./PodcastPlayerCompactSkeleton"; + +interface PodcastPlayerProps { + podcast: PodcastItem | null; + isLoading?: boolean; + onClose?: () => void; + compact?: boolean; +} + +export function PodcastPlayer({ + podcast, + isLoading = false, + onClose, + compact = false, +}: PodcastPlayerProps) { + const [audioSrc, setAudioSrc] = useState(undefined); + const [isPlaying, setIsPlaying] = useState(false); + const [currentTime, setCurrentTime] = useState(0); + const [duration, setDuration] = useState(0); + const [volume, setVolume] = useState(0.7); + const [isMuted, setIsMuted] = useState(false); + const [isFetching, setIsFetching] = useState(false); + const audioRef = useRef(null); + const currentObjectUrlRef = useRef(null); + + // Cleanup object URL on unmount + useEffect(() => { + return () => { + if (currentObjectUrlRef.current) { + URL.revokeObjectURL(currentObjectUrlRef.current); + currentObjectUrlRef.current = null; + } + }; + }, []); + + // Load podcast audio when podcast changes + useEffect(() => { + if (!podcast) { + setAudioSrc(undefined); + setCurrentTime(0); + setDuration(0); + setIsPlaying(false); + setIsFetching(false); + return; + } + + const loadPodcast = async () => { + setIsFetching(true); + try { + const token = localStorage.getItem("surfsense_bearer_token"); + if (!token) { + throw new Error("Authentication token not found."); + } + + // Revoke previous object URL if exists + if (currentObjectUrlRef.current) { + URL.revokeObjectURL(currentObjectUrlRef.current); + currentObjectUrlRef.current = null; + } + + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 30000); + + try { + const response = await fetch( + `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/podcasts/${podcast.id}/stream`, + { + headers: { + Authorization: `Bearer ${token}`, + }, + signal: controller.signal, + } + ); + + if (!response.ok) { + throw new Error(`Failed to fetch audio stream: ${response.statusText}`); + } + + const blob = await response.blob(); + const objectUrl = URL.createObjectURL(blob); + currentObjectUrlRef.current = objectUrl; + setAudioSrc(objectUrl); + } catch (error) { + if (error instanceof DOMException && error.name === "AbortError") { + throw new Error("Request timed out. Please try again."); + } + throw error; + } finally { + clearTimeout(timeoutId); + } + } catch (error) { + console.error("Error fetching podcast:", error); + toast.error(error instanceof Error ? error.message : "Failed to load podcast audio."); + setAudioSrc(undefined); + } finally { + setIsFetching(false); + } + }; + + loadPodcast(); + }, [podcast]); + + const handleTimeUpdate = () => { + if (audioRef.current) { + setCurrentTime(audioRef.current.currentTime); + } + }; + + const handleMetadataLoaded = () => { + if (audioRef.current) { + setDuration(audioRef.current.duration); + } + }; + + const togglePlayPause = () => { + if (audioRef.current) { + if (isPlaying) { + audioRef.current.pause(); + } else { + audioRef.current.play(); + } + setIsPlaying(!isPlaying); + } + }; + + const handleSeek = (value: number[]) => { + if (audioRef.current) { + audioRef.current.currentTime = value[0]; + setCurrentTime(value[0]); + } + }; + + const handleVolumeChange = (value: number[]) => { + if (audioRef.current) { + const newVolume = value[0]; + audioRef.current.volume = newVolume; + setVolume(newVolume); + + if (newVolume === 0) { + audioRef.current.muted = true; + setIsMuted(true); + } else { + audioRef.current.muted = false; + setIsMuted(false); + } + } + }; + + const toggleMute = () => { + if (audioRef.current) { + const newMutedState = !isMuted; + audioRef.current.muted = newMutedState; + setIsMuted(newMutedState); + + if (!newMutedState && volume === 0) { + const restoredVolume = 0.5; + audioRef.current.volume = restoredVolume; + setVolume(restoredVolume); + } + } + }; + + const skipForward = () => { + if (audioRef.current) { + audioRef.current.currentTime = Math.min( + audioRef.current.duration, + audioRef.current.currentTime + 10 + ); + } + }; + + const skipBackward = () => { + if (audioRef.current) { + audioRef.current.currentTime = Math.max(0, audioRef.current.currentTime - 10); + } + }; + + const formatTime = (time: number) => { + const minutes = Math.floor(time / 60); + const seconds = Math.floor(time % 60); + return `${minutes}:${seconds < 10 ? "0" : ""}${seconds}`; + }; + + // Show skeleton while fetching + if (isFetching && compact) { + return ; + } + + if (!podcast || !audioSrc) { + return null; + } + + if (compact) { + return ( + <> +
+
+ + + +

{podcast.title}

+ {onClose && ( + + + + )} +
+ +
+ +
+ {formatTime(currentTime)} / {formatTime(duration)} +
+
+ +
+ + + + + + + + + + + + + + + +
+
+ + + + ); + } + + return null; +} diff --git a/surfsense_web/components/chat/ChatPanel/PodcastPlayer/PodcastPlayerCompactSkeleton.tsx b/surfsense_web/components/chat/ChatPanel/PodcastPlayer/PodcastPlayerCompactSkeleton.tsx new file mode 100644 index 000000000..d7007dadd --- /dev/null +++ b/surfsense_web/components/chat/ChatPanel/PodcastPlayer/PodcastPlayerCompactSkeleton.tsx @@ -0,0 +1,40 @@ +"use client"; + +import { Podcast } from "lucide-react"; +import { motion } from "motion/react"; + +export function PodcastPlayerCompactSkeleton() { + return ( +
+ {/* Header with icon and title */} +
+ + + + {/* Title skeleton */} +
+
+ + {/* Progress bar skeleton */} +
+
+
+
+ + {/* Controls skeleton */} +
+
+
+
+
+
+
+ ); +} diff --git a/surfsense_web/components/chat/ChatPanel/PodcastPlayer/index.ts b/surfsense_web/components/chat/ChatPanel/PodcastPlayer/index.ts new file mode 100644 index 000000000..55c19f934 --- /dev/null +++ b/surfsense_web/components/chat/ChatPanel/PodcastPlayer/index.ts @@ -0,0 +1,2 @@ +export { PodcastPlayer } from "./PodcastPlayer"; +export { PodcastPlayerCompactSkeleton } from "./PodcastPlayerCompactSkeleton"; diff --git a/surfsense_web/components/chat/PodcastUtils.ts b/surfsense_web/components/chat/PodcastUtils.ts new file mode 100644 index 000000000..5662f96b5 --- /dev/null +++ b/surfsense_web/components/chat/PodcastUtils.ts @@ -0,0 +1,43 @@ +/** + * Determines if a podcast is stale compared to the current chat state. + * A podcast is considered stale if: + * - The chat's current state_version is greater than the podcast's chat_state_version + * + * @param chatVersion - The current state_version of the chat + * @param podcastVersion - The chat_state_version stored when the podcast was generated (nullable) + * @returns true if the podcast is stale, false otherwise + */ +export function isPodcastStale( + chatVersion: number, + podcastVersion: number | null | undefined +): boolean { + // If podcast has no version, it's stale (generated before this feature) + if (!podcastVersion) { + return true; + } + // If chat version is greater than podcast version, it's stale : We can change this condition to consider staleness after a huge number of updates + return chatVersion > podcastVersion; +} + +/** + * Gets a human-readable message about podcast staleness + * + * @param chatVersion - The current state_version of the chat + * @param podcastVersion - The chat_state_version stored when the podcast was generated + * @returns A descriptive message about the podcast's staleness status + */ +export function getPodcastStalenessMessage( + chatVersion: number, + podcastVersion: number | null | undefined +): string { + if (!podcastVersion) { + return "This podcast was generated before chat updates were tracked. Consider regenerating it."; + } + + if (chatVersion > podcastVersion) { + const versionDiff = chatVersion - podcastVersion; + return `This podcast is outdated. The chat has been updated ${versionDiff} time${versionDiff > 1 ? "s" : ""} since this podcast was generated.`; + } + + return "This podcast is up to date with the current chat."; +} diff --git a/surfsense_web/components/ui/sidebar.tsx b/surfsense_web/components/ui/sidebar.tsx index d8bc4e469..3170148eb 100644 --- a/surfsense_web/components/ui/sidebar.tsx +++ b/surfsense_web/components/ui/sidebar.tsx @@ -294,7 +294,7 @@ function SidebarInset({ className, ...props }: React.ComponentProps<"main">) { data-slot="sidebar-inset" className={cn( "bg-background relative flex w-full flex-1 flex-col", - "md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow-sm md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2", + "md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-l-xl md:peer-data-[variant=inset]:shadow-sm md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2", className )} {...props} diff --git a/surfsense_web/hooks/use-chat.ts b/surfsense_web/hooks/use-chat.ts index d38b02c94..a3462203a 100644 --- a/surfsense_web/hooks/use-chat.ts +++ b/surfsense_web/hooks/use-chat.ts @@ -1,5 +1,6 @@ import type { Message } from "@ai-sdk/react"; import { useCallback, useEffect, useState } from "react"; +import type { ChatDetails } from "@/app/dashboard/[search_space_id]/chats/chats-client"; import type { ResearchMode } from "@/components/chat"; import type { Document } from "@/hooks/use-documents"; @@ -52,7 +53,7 @@ interface UseChatAPIProps { export function useChatAPI({ token, search_space_id }: UseChatAPIProps) { const fetchChatDetails = useCallback( - async (chatId: string) => { + async (chatId: string): Promise => { if (!token) return null; try { diff --git a/surfsense_web/lib/apis/chat-apis.ts b/surfsense_web/lib/apis/chat-apis.ts new file mode 100644 index 000000000..fb1c98708 --- /dev/null +++ b/surfsense_web/lib/apis/chat-apis.ts @@ -0,0 +1,28 @@ +import type { ChatDetails } from "@/app/dashboard/[search_space_id]/chats/chats-client"; + +export const fetchChatDetails = async ( + chatId: string, + authToken: string +): Promise => { + try { + const response = await fetch( + `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/chats/${Number(chatId)}`, + { + method: "GET", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${authToken}`, + }, + } + ); + + if (!response.ok) { + throw new Error(`Failed to fetch chat details: ${response.statusText}`); + } + + return await response.json(); + } catch (err) { + console.error("Error fetching chat details:", err); + return null; + } +}; diff --git a/surfsense_web/lib/apis/podcast-apis.ts b/surfsense_web/lib/apis/podcast-apis.ts new file mode 100644 index 000000000..0324d7066 --- /dev/null +++ b/surfsense_web/lib/apis/podcast-apis.ts @@ -0,0 +1,50 @@ +import type { PodcastItem } from "@/app/dashboard/[search_space_id]/podcasts/podcasts-client"; +import type { GeneratePodcastRequest } from "@/components/chat/ChatPanel/ChatPanelContainer"; + +export const getPodcastByChatId = async (chatId: string, authToken: string) => { + try { + const response = await fetch( + `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/podcasts/by-chat/${Number(chatId)}`, + { + headers: { + Authorization: `Bearer ${authToken}`, + }, + method: "GET", + } + ); + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})); + throw new Error(errorData.detail || "Failed to fetch podcast"); + } + + return (await response.json()) as PodcastItem | null; + } catch (err: any) { + console.error("Error fetching podcast:", err); + + return null; + } +}; + +export const generatePodcast = async (request: GeneratePodcastRequest, authToken: string) => { + try { + const response = await fetch( + `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/podcasts/generate/`, + { + method: "POST", + headers: { + Authorization: `Bearer ${authToken}`, + "Content-Type": "application/json", + }, + body: JSON.stringify(request), + } + ); + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})); + throw new Error(errorData.detail || "Failed to generate podcast"); + } + } catch (error) { + console.error("Error generating podcast:", error); + } +}; diff --git a/surfsense_web/lib/query-client/cache-keys.ts b/surfsense_web/lib/query-client/cache-keys.ts new file mode 100644 index 000000000..48f7e6052 --- /dev/null +++ b/surfsense_web/lib/query-client/cache-keys.ts @@ -0,0 +1,3 @@ +export const cacheKeys = { + activeChat: (chatId: string) => ["activeChat", chatId], +}; diff --git a/surfsense_web/lib/query-client/client.ts b/surfsense_web/lib/query-client/client.ts new file mode 100644 index 000000000..6c7b9ded3 --- /dev/null +++ b/surfsense_web/lib/query-client/client.ts @@ -0,0 +1,3 @@ +import { QueryClient } from "@tanstack/react-query"; + +export const queryClient = new QueryClient(); diff --git a/surfsense_web/lib/query-client/query-client.provider.tsx b/surfsense_web/lib/query-client/query-client.provider.tsx new file mode 100644 index 000000000..453b9f531 --- /dev/null +++ b/surfsense_web/lib/query-client/query-client.provider.tsx @@ -0,0 +1,13 @@ +"use client"; +import { ReactQueryDevtools } from "@tanstack/react-query-devtools"; +import { QueryClientAtomProvider } from "jotai-tanstack-query/react"; +import { queryClient } from "./client"; + +export function ReactQueryClientProvider({ children }: { children: React.ReactNode }) { + return ( + + {children} + + + ); +} diff --git a/surfsense_web/package.json b/surfsense_web/package.json index a16e149fd..8181c5422 100644 --- a/surfsense_web/package.json +++ b/surfsense_web/package.json @@ -17,7 +17,8 @@ "db:generate": "drizzle-kit generate", "db:migrate": "drizzle-kit migrate", "db:push": "drizzle-kit push", - "db:studio": "drizzle-kit studio" + "db:studio": "drizzle-kit studio", + "format:fix": "npx @biomejs/biome check --fix" }, "dependencies": { "@ai-sdk/react": "^1.2.12", @@ -45,6 +46,9 @@ "@radix-ui/react-toggle-group": "^1.1.10", "@radix-ui/react-tooltip": "^1.2.7", "@tabler/icons-react": "^3.34.1", + "@tanstack/query-core": "^5.90.7", + "@tanstack/react-query": "^5.90.7", + "@tanstack/react-query-devtools": "^5.90.2", "@tanstack/react-table": "^8.21.3", "@types/mdx": "^2.0.13", "@types/react-syntax-highlighter": "^15.5.13", @@ -60,6 +64,8 @@ "fumadocs-mdx": "^11.7.1", "fumadocs-ui": "^15.6.6", "geist": "^1.4.2", + "jotai": "^2.15.1", + "jotai-tanstack-query": "^0.11.0", "lucide-react": "^0.477.0", "motion": "^12.23.22", "next": "^15.4.4", diff --git a/surfsense_web/pnpm-lock.yaml b/surfsense_web/pnpm-lock.yaml index a163981ca..3a3c8fc82 100644 --- a/surfsense_web/pnpm-lock.yaml +++ b/surfsense_web/pnpm-lock.yaml @@ -83,6 +83,15 @@ importers: '@tabler/icons-react': specifier: ^3.34.1 version: 3.34.1(react@19.1.0) + '@tanstack/query-core': + specifier: ^5.90.7 + version: 5.90.7 + '@tanstack/react-query': + specifier: ^5.90.7 + version: 5.90.7(react@19.1.0) + '@tanstack/react-query-devtools': + specifier: ^5.90.2 + version: 5.90.2(@tanstack/react-query@5.90.7(react@19.1.0))(react@19.1.0) '@tanstack/react-table': specifier: ^8.21.3 version: 8.21.3(react-dom@19.1.0(react@19.1.0))(react@19.1.0) @@ -128,6 +137,12 @@ importers: geist: specifier: ^1.4.2 version: 1.4.2(next@15.4.4(@opentelemetry/api@1.9.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)) + jotai: + specifier: ^2.15.1 + version: 2.15.1(@types/react@19.1.8)(react@19.1.0) + jotai-tanstack-query: + specifier: ^0.11.0 + version: 0.11.0(@tanstack/query-core@5.90.7)(@tanstack/react-query@5.90.7(react@19.1.0))(jotai@2.15.1(@types/react@19.1.8)(react@19.1.0))(react@19.1.0) lucide-react: specifier: ^0.477.0 version: 0.477.0(react@19.1.0) @@ -2340,6 +2355,23 @@ packages: peerDependencies: tailwindcss: '>=3.0.0 || insiders || >=4.0.0-alpha.20 || >=4.0.0-beta.1' + '@tanstack/query-core@5.90.7': + resolution: {integrity: sha512-6PN65csiuTNfBMXqQUxQhCNdtm1rV+9kC9YwWAIKcaxAauq3Wu7p18j3gQY3YIBJU70jT/wzCCZ2uqto/vQgiQ==} + + '@tanstack/query-devtools@5.90.1': + resolution: {integrity: sha512-GtINOPjPUH0OegJExZ70UahT9ykmAhmtNVcmtdnOZbxLwT7R5OmRztR5Ahe3/Cu7LArEmR6/588tAycuaWb1xQ==} + + '@tanstack/react-query-devtools@5.90.2': + resolution: {integrity: sha512-vAXJzZuBXtCQtrY3F/yUNJCV4obT/A/n81kb3+YqLbro5Z2+phdAbceO+deU3ywPw8B42oyJlp4FhO0SoivDFQ==} + peerDependencies: + '@tanstack/react-query': ^5.90.2 + react: ^18 || ^19 + + '@tanstack/react-query@5.90.7': + resolution: {integrity: sha512-wAHc/cgKzW7LZNFloThyHnV/AX9gTg3w5yAv0gvQHPZoCnepwqCMtzbuPbb2UvfvO32XZ46e8bPOYbfZhzVnnQ==} + peerDependencies: + react: ^18 || ^19 + '@tanstack/react-table@8.21.3': resolution: {integrity: sha512-5nNMTSETP4ykGegmVkhjcS8tTLW6Vl4axfEGQN3v0zdHYbK4UfoqfPChclTrJ4EoK9QynqAu9oUf8VEmrpZ5Ww==} engines: {node: '>=12'} @@ -3818,6 +3850,37 @@ packages: resolution: {integrity: sha512-rg9zJN+G4n2nfJl5MW3BMygZX56zKPNVEYYqq7adpmMh4Jn2QNEwhvQlFy6jPVdcod7txZtKHWnyZiA3a0zP7A==} hasBin: true + jotai-tanstack-query@0.11.0: + resolution: {integrity: sha512-Ys0u0IuuS6/okUJOulFTdCVfVaeKbm1+lKVSN9zHhIxtrAXl9FM4yu7fNvxM6fSz/NCE9tZOKR0MQ3hvplaH8A==} + peerDependencies: + '@tanstack/query-core': '*' + '@tanstack/react-query': '*' + jotai: '>=2.0.0' + react: ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@tanstack/react-query': + optional: true + react: + optional: true + + jotai@2.15.1: + resolution: {integrity: sha512-yHT1HAZ3ba2Q8wgaUQ+xfBzEtcS8ie687I8XVCBinfg4bNniyqLIN+utPXWKQE93LMF5fPbQSVRZqgpcN5yd6Q==} + engines: {node: '>=12.20.0'} + peerDependencies: + '@babel/core': '>=7.0.0' + '@babel/template': '>=7.0.0' + '@types/react': '>=17.0.0' + react: '>=17.0.0' + peerDependenciesMeta: + '@babel/core': + optional: true + '@babel/template': + optional: true + '@types/react': + optional: true + react: + optional: true + js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} @@ -5228,6 +5291,9 @@ packages: tailwind-merge@3.3.1: resolution: {integrity: sha512-gBXpgUm/3rp1lMZZrM/w7D8GKqshif0zAymAhbCyIt8KMe+0v9DQ7cdYLR4FHH/cKpdTXb+A/tKKU3eolfsI+g==} + tailwind-merge@3.4.0: + resolution: {integrity: sha512-uSaO4gnW+b3Y2aWoWfFpX62vn2sR3skfhbjsEnaBI81WD1wBLlHZe5sWf0AqjksNdYTbGBEd0UasQMT3SNV15g==} + tailwindcss-animate@1.0.7: resolution: {integrity: sha512-bl6mpH3T7I3UFxuvDEXLxy/VuFxBk5bbzplh7tXI68mwMokNYd1t9qPBHlnyTwfa4JGC4zP516I1hYYtQ/vspA==} peerDependencies: @@ -7953,6 +8019,21 @@ snapshots: postcss-selector-parser: 6.0.10 tailwindcss: 4.1.11 + '@tanstack/query-core@5.90.7': {} + + '@tanstack/query-devtools@5.90.1': {} + + '@tanstack/react-query-devtools@5.90.2(@tanstack/react-query@5.90.7(react@19.1.0))(react@19.1.0)': + dependencies: + '@tanstack/query-devtools': 5.90.1 + '@tanstack/react-query': 5.90.7(react@19.1.0) + react: 19.1.0 + + '@tanstack/react-query@5.90.7(react@19.1.0)': + dependencies: + '@tanstack/query-core': 5.90.7 + react: 19.1.0 + '@tanstack/react-table@8.21.3(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': dependencies: '@tanstack/table-core': 8.21.3 @@ -8607,7 +8688,7 @@ snapshots: react: 19.1.0 react-dom: 19.1.0(react@19.1.0) react-easy-sort: 1.6.0(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - tailwind-merge: 3.3.1 + tailwind-merge: 3.4.0 transitivePeerDependencies: - '@types/react' - '@types/react-dom' @@ -9748,6 +9829,19 @@ snapshots: jiti@2.4.2: {} + jotai-tanstack-query@0.11.0(@tanstack/query-core@5.90.7)(@tanstack/react-query@5.90.7(react@19.1.0))(jotai@2.15.1(@types/react@19.1.8)(react@19.1.0))(react@19.1.0): + dependencies: + '@tanstack/query-core': 5.90.7 + jotai: 2.15.1(@types/react@19.1.8)(react@19.1.0) + optionalDependencies: + '@tanstack/react-query': 5.90.7(react@19.1.0) + react: 19.1.0 + + jotai@2.15.1(@types/react@19.1.8)(react@19.1.0): + optionalDependencies: + '@types/react': 19.1.8 + react: 19.1.0 + js-tokens@4.0.0: {} js-yaml@4.1.0: @@ -11807,6 +11901,8 @@ snapshots: tailwind-merge@3.3.1: {} + tailwind-merge@3.4.0: {} + tailwindcss-animate@1.0.7(tailwindcss@4.1.11): dependencies: tailwindcss: 4.1.11 diff --git a/surfsense_web/stores/chat/active-chat.atom.ts b/surfsense_web/stores/chat/active-chat.atom.ts new file mode 100644 index 000000000..a0d40a96c --- /dev/null +++ b/surfsense_web/stores/chat/active-chat.atom.ts @@ -0,0 +1,39 @@ +import { atom } from "jotai"; +import { atomWithQuery } from "jotai-tanstack-query"; +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 { fetchChatDetails } from "@/lib/apis/chat-apis"; +import { getPodcastByChatId } from "@/lib/apis/podcast-apis"; + +type ActiveChatState = { + chatId: string | null; + chatDetails: ChatDetails | null; + podcast: PodcastItem | null; +}; + +export const activeChatIdAtom = atom(null); + +export const activeChatAtom = atomWithQuery((get) => { + const activeChatId = get(activeChatIdAtom); + const authToken = localStorage.getItem("surfsense_bearer_token"); + + return { + queryKey: ["activeChat", activeChatId], + enabled: !!activeChatId && !!authToken, + queryFn: async () => { + if (!authToken) { + throw new Error("No authentication token found"); + } + if (!activeChatId) { + throw new Error("No active chat id found"); + } + + const [podcast, chatDetails] = await Promise.all([ + getPodcastByChatId(activeChatId, authToken), + fetchChatDetails(activeChatId, authToken), + ]); + + return { chatId: activeChatId, chatDetails, podcast }; + }, + }; +}); diff --git a/surfsense_web/stores/chat/chat-ui.atom.ts b/surfsense_web/stores/chat/chat-ui.atom.ts new file mode 100644 index 000000000..3b7e6794b --- /dev/null +++ b/surfsense_web/stores/chat/chat-ui.atom.ts @@ -0,0 +1,9 @@ +import { atom } from "jotai"; + +type ChatUIState = { + isChatPannelOpen: boolean; +}; + +export const chatUIAtom = atom({ + isChatPannelOpen: false, +}); diff --git a/surfsense_web/stores/seach-space/active-seach-space.atom.ts b/surfsense_web/stores/seach-space/active-seach-space.atom.ts new file mode 100644 index 000000000..4bccf496f --- /dev/null +++ b/surfsense_web/stores/seach-space/active-seach-space.atom.ts @@ -0,0 +1,3 @@ +import { atom } from "jotai"; + +export const activeSearchSpaceIdAtom = atom(null);