fix podcast generation

This commit is contained in:
thierryverse 2025-11-11 04:02:04 +02:00
parent 678d8fbbcd
commit 55e5b45a42
26 changed files with 477 additions and 223 deletions

View file

@ -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

View file

@ -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")

View file

@ -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
""")

View file

@ -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 = [

View file

@ -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_instructions>
{user_prompt}
</user_instructions>
"""
return f"""
Today's date: {datetime.datetime.now().strftime("%Y-%m-%d")}
<podcast_generation_system>
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}
<input>
- '<source_content>': 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.

View file

@ -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()

View file

@ -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,
}

View file

@ -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 (
<SidebarProvider open={open} onOpenChange={setOpen}>
<SidebarProvider
className="h-full bg-red-600 overflow-hidden"
open={open}
onOpenChange={setOpen}
>
{/* Use AppSidebarProvider which fetches user, search space, and recent chats */}
<AppSidebarProvider
searchSpaceId={searchSpaceId}
navSecondary={translatedNavSecondary}
navMain={translatedNavMain}
/>
<SidebarInset>
<header className="sticky top-0 z-50 flex h-16 shrink-0 items-center gap-2 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60 border-b">
<div className="flex items-center justify-between w-full gap-2 px-4">
<div className="flex items-center gap-2">
<SidebarTrigger className="-ml-1" />
<Separator orientation="vertical" className="h-6" />
<DashboardBreadcrumb />
</div>
<div className="flex items-center gap-2">
<LanguageSwitcher />
<ThemeTogglerComponent />
</div>
<SidebarInset className="h-full ">
<main className="flex h-full">
<div className="flex grow flex-col h-full border-r">
<header className="sticky top-0 z-50 flex h-16 shrink-0 items-center gap-2 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60 border-b">
<div className="flex items-center justify-between w-full gap-2 px-4">
<div className="flex items-center gap-2">
<SidebarTrigger className="-ml-1" />
<Separator orientation="vertical" className="h-6" />
<DashboardBreadcrumb />
</div>
<div className="flex items-center gap-2">
<LanguageSwitcher />
<ThemeTogglerComponent />
<button
type="button"
onClick={() =>
setChatUIState((prev) => ({
...prev,
isChatPannelOpen: !isChatPannelOpen,
}))
}
className={cn(" shrink-0 rounded-full w-fit hover:bg-muted")}
>
<PanelRight className="h-4 w-4" />
</button>
</div>
</div>
</header>
<div className="grow flex-1 overflow-auto">{children}</div>
</div>
</header>
{children}
<ChatPanelContainer />
</main>
</SidebarInset>
</SidebarProvider>
);

View file

@ -42,9 +42,9 @@ export default function DashboardLayout({ children }: DashboardLayoutProps) {
}
return (
<>
<div className="h-full flex flex-col ">
<AnnouncementBanner />
{children}
</>
<div className="flex-1 min-h-0">{children}</div>
</div>
);
}

View file

@ -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 (
<html lang="en" suppressHydrationWarning>
<body className={cn(roboto.className, "bg-white dark:bg-black antialiased h-full w-full")}>
<body className={cn(roboto.className, "bg-white dark:bg-black antialiased h-full w-full ")}>
<LocaleProvider>
<I18nProvider>
<ThemeProvider
@ -99,7 +100,9 @@ export default function RootLayout({
defaultTheme="light"
>
<RootProvider>
{children}
<ReactQueryClientProvider>
<div className=" h-[100dvh] w-[100vw] overflow-hidden">{children}</div>
</ReactQueryClientProvider>
<Toaster />
</RootProvider>
</ThemeProvider>

View file

@ -10,7 +10,7 @@ export function AnnouncementBanner() {
if (!isVisible) return null;
return (
<div className="relative bg-gradient-to-r from-blue-600 to-blue-500 dark:from-blue-700 dark:to-blue-600 border-b border-blue-700 dark:border-blue-800">
<div className="relative h-[3rem] flex items-center justify-center border bg-gradient-to-r from-blue-600 to-blue-500 dark:from-blue-700 dark:to-blue-600 border-b border-blue-700 dark:border-blue-800">
<div className="container mx-auto px-4">
<div className="flex items-center justify-center gap-3 py-2.5">
<Info className="h-4 w-4 text-blue-50 flex-shrink-0" />

View file

@ -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<ChatInterfaceContext | null>(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<ChatDetails | null>(null);
const [isChatPannelOpen, setIsChatPannelOpen] = useState(false);
const [podcast, setPodcast] = useState<PodcastItem | null>(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 (
<chatInterfaceContext.Provider value={contextValue}>
<LlamaIndexChatSection handler={handler} className="flex h-full">
<div className="flex gap-4 flex-1 w-full">
<div className="flex grow-1 flex-col">
<ChatMessagesUI />
<div className="border-t p-4">
<ChatInputUI
onDocumentSelectionChange={onDocumentSelectionChange}
selectedDocuments={selectedDocuments}
onConnectorSelectionChange={onConnectorSelectionChange}
selectedConnectors={selectedConnectors}
searchMode={searchMode}
onSearchModeChange={onSearchModeChange}
/>
</div>
</div>
<ChatPanelContainer />
<LlamaIndexChatSection handler={handler} className="flex h-full">
<div className="flex grow-1 flex-col">
<ChatMessagesUI />
<div className="border-t p-4">
<ChatInputUI
onDocumentSelectionChange={onDocumentSelectionChange}
selectedDocuments={selectedDocuments}
onConnectorSelectionChange={onConnectorSelectionChange}
selectedConnectors={selectedConnectors}
searchMode={searchMode}
onSearchModeChange={onSearchModeChange}
/>
</div>
</LlamaIndexChatSection>
</chatInterfaceContext.Provider>
</div>
</LlamaIndexChatSection>
);
}

View file

@ -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 ? (
<div
className={cn(
"border rounded-2xl shrink-0 bg-sidebar flex flex-col h-full transition-all",
isChatPannelOpen ? "w-72" : "w-14"
"shrink-0 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60 flex flex-col h-full transition-all",
isChatPannelOpen ? "w-64" : "w-0"
)}
>
<div
className={cn(
"w-full border-b p-2 flex items-center transition-all ",
isChatPannelOpen ? "justify-end" : " justify-center "
)}
>
<button
type="button"
onClick={() => setIsChatPannelOpen(!isChatPannelOpen)}
className={cn(" shrink-0 rounded-full p-2 w-fit hover:bg-muted")}
>
<PanelRight className="h-5 w-5" strokeWidth={1.5} />
</button>
</div>
{isChatLoading || chatError ? (
<div className="border-b p-2">
{isChatLoading ? (
<div title="Loading chat" className="flex items-center justify-center h-full">
<LoaderIcon strokeWidth={1.5} className="h-5 w-5 animate-spin" />
</div>
) : chatError ? (
<div title="Failed to load chat" className="flex items-center justify-center h-full">
<TriangleAlert strokeWidth={1.5} className="h-5 w-5 text-red-600" />
</div>
) : null}
</div>
) : null}
<div className="border-b rounded-lg grow-1">
<ChatPanelView generatePodcast={generatePodcast} />
</div>
{!isChatLoading && !chatError && activeChatState?.chatDetails && (
<ChatPanelView generatePodcast={handleGeneratePodcast} />
)}
</div>
) : null;
}

View file

@ -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) {
<div className="w-full">
<div
className={cn(
"w-full cursor-pointer h-full p-4 border-b",
"w-full cursor-pointer p-4 border-b",
!isChatPannelOpen && "flex items-center justify-center"
)}
title={podcastIsStale ? "Regenerate Podcast" : "Generate Podcast"}
@ -99,7 +102,12 @@ export function ChatPanelView(props: ChatPanelViewProps) {
<button
title={podcastIsStale ? "Regenerate Podcast" : "Generate Podcast"}
type="button"
onClick={() => 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 ? (
<div
className={cn(
"w-full h-full border-b",
"w-full border-b",
!isChatPannelOpen && "flex items-center justify-center p-4"
)}
>
@ -126,7 +134,7 @@ export function ChatPanelView(props: ChatPanelViewProps) {
<button
title="Play Podcast"
type="button"
onClick={() => setIsChatPannelOpen(true)}
onClick={() => setChatUIState((prev) => ({ ...prev, isChatPannelOpen: true }))}
className="p-2 rounded-full hover:bg-muted transition-colors text-green-600 dark:text-green-500"
>
<Play strokeWidth={1} className="h-5 w-5" />

View file

@ -1,9 +1,10 @@
"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 { chatInterfaceContext } from "../ChatInterface";
import { activeChatAtom } from "@/stores/chat/active-chat.atom";
import type { GeneratePodcastRequest } from "./ChatPanelContainer";
interface ConfigModalProps {
@ -11,15 +12,14 @@ interface ConfigModalProps {
}
export function ConfigModal(props: ConfigModalProps) {
const context = useContext(chatInterfaceContext);
const { data: activeChatState } = useAtomValue(activeChatAtom);
const chatDetails = activeChatState?.chatDetails;
const podcast = activeChatState?.podcast;
if (!context) {
throw new Error("chatInterfaceContext must be used within a ChatProvider");
}
const { chatDetails } = context;
const { generatePodcast } = props;
const [podcastTitle, setPodcastTitle] = useState(chatDetails?.title);
const [podcastTitle, setPodcastTitle] = useState(podcast?.title || chatDetails?.title);
const handleGeneratePost = useCallback(async () => {
if (!chatDetails) return;
@ -27,9 +27,10 @@ export function ConfigModal(props: ConfigModalProps) {
type: "CHAT",
ids: [chatDetails.id],
search_space_id: chatDetails.search_space_id,
podcast_title: podcastTitle,
podcast_title: podcastTitle || podcast?.title || chatDetails.title,
});
}, [chatDetails, podcastTitle]);
return (
<Popover>
<PopoverTrigger
@ -50,7 +51,10 @@ export function ConfigModal(props: ConfigModalProps) {
id="prompt"
defaultValue={podcastTitle}
className="w-full rounded-md border border-slate-400/40 p-2"
onChange={(e) => setPodcastTitle(e.target.value)}
onChange={(e) => {
e.stopPropagation();
setPodcastTitle(e.target.value);
}}
></textarea>
<button

View file

@ -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}

View file

@ -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<ChatDetails | null> => {
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;
}
};

View file

@ -0,0 +1,52 @@
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 { 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 ${authToken}`,
"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");
}
} catch (error) {
console.error("Error generating podcast:", error);
}
};

View file

@ -0,0 +1,3 @@
export const cacheKeys = {
activeChat: (chatId: string) => ["activeChat", chatId],
};

View file

@ -0,0 +1,3 @@
import { QueryClient } from "@tanstack/react-query";
export const queryClient = new QueryClient();

View file

@ -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 (
<QueryClientAtomProvider client={queryClient}>
{children}
<ReactQueryDevtools initialIsOpen={false} />
</QueryClientAtomProvider>
);
}

View file

@ -45,6 +45,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 +63,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",

View file

@ -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

View file

@ -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<string | null>(null);
export const activeChatAtom = atomWithQuery<ActiveChatState>((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 };
},
};
});

View file

@ -0,0 +1,9 @@
import { atom } from "jotai";
type ChatUIState = {
isChatPannelOpen: boolean;
};
export const chatUIAtom = atom<ChatUIState>({
isChatPannelOpen: false,
});

View file

@ -0,0 +1,3 @@
import { atom } from "jotai";
export const activeSearchSpaceIdAtom = atom<string | null>(null);