diff --git a/surfsense_backend/alembic/versions/6_change_podcast_content_to_transcript.py b/surfsense_backend/alembic/versions/6_change_podcast_content_to_transcript.py new file mode 100644 index 000000000..991948f3a --- /dev/null +++ b/surfsense_backend/alembic/versions/6_change_podcast_content_to_transcript.py @@ -0,0 +1,44 @@ +"""Change podcast_content to podcast_transcript with JSON type + +Revision ID: 6 +Revises: 5 +Create Date: 2023-08-15 00:00:00.000000 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects.postgresql import JSON + + +# revision identifiers, used by Alembic. +revision: str = '6' +down_revision: Union[str, None] = '5' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # Drop the old column and create a new one with the new name and type + # We need to do this because PostgreSQL doesn't support direct column renames with type changes + op.add_column('podcasts', sa.Column('podcast_transcript', JSON, nullable=False, server_default='{}')) + + # Copy data from old column to new column + # Convert text to JSON by storing it as a JSON string value + op.execute("UPDATE podcasts SET podcast_transcript = jsonb_build_object('text', podcast_content) WHERE podcast_content != ''") + + # Drop the old column + op.drop_column('podcasts', 'podcast_content') + + +def downgrade() -> None: + # Add back the original column + op.add_column('podcasts', sa.Column('podcast_content', sa.Text(), nullable=False, server_default='')) + + # Copy data from JSON column back to text column + # Extract the 'text' field if it exists, otherwise use empty string + op.execute("UPDATE podcasts SET podcast_content = COALESCE((podcast_transcript->>'text'), '')") + + # Drop the new column + op.drop_column('podcasts', 'podcast_transcript') \ No newline at end of file diff --git a/surfsense_backend/alembic/versions/7_remove_is_generated_column.py b/surfsense_backend/alembic/versions/7_remove_is_generated_column.py new file mode 100644 index 000000000..c5d25ad70 --- /dev/null +++ b/surfsense_backend/alembic/versions/7_remove_is_generated_column.py @@ -0,0 +1,28 @@ +"""Remove is_generated column from podcasts table + +Revision ID: 7 +Revises: 6 +Create Date: 2023-08-15 01:00:00.000000 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '7' +down_revision: Union[str, None] = '6' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # Drop the is_generated column + op.drop_column('podcasts', 'is_generated') + + +def downgrade() -> None: + # Add back the is_generated column with its original constraints + op.add_column('podcasts', sa.Column('is_generated', sa.Boolean(), nullable=False, server_default='false')) \ No newline at end of file diff --git a/surfsense_backend/app/agents/podcaster/graph.py b/surfsense_backend/app/agents/podcaster/graph.py index f4604a7c8..d102432ef 100644 --- a/surfsense_backend/app/agents/podcaster/graph.py +++ b/surfsense_backend/app/agents/podcaster/graph.py @@ -6,18 +6,26 @@ from .state import State from .nodes import create_merged_podcast_audio, create_podcast_transcript -# Define a new graph -workflow = StateGraph(State, config_schema=Configuration) -# Add the node to the graph -workflow.add_node("create_podcast_transcript", create_podcast_transcript) -workflow.add_node("create_merged_podcast_audio", create_merged_podcast_audio) +def build_graph(): + + # Define a new graph + workflow = StateGraph(State, config_schema=Configuration) -# Set the entrypoint as `call_model` -workflow.add_edge("__start__", "create_podcast_transcript") -workflow.add_edge("create_podcast_transcript", "create_merged_podcast_audio") -workflow.add_edge("create_merged_podcast_audio", "__end__") + # Add the node to the graph + workflow.add_node("create_podcast_transcript", create_podcast_transcript) + workflow.add_node("create_merged_podcast_audio", create_merged_podcast_audio) -# Compile the workflow into an executable graph -graph = workflow.compile() -graph.name = "Surfsense Podcaster" # This defines the custom name in LangSmith + # Set the entrypoint as `call_model` + workflow.add_edge("__start__", "create_podcast_transcript") + workflow.add_edge("create_podcast_transcript", "create_merged_podcast_audio") + workflow.add_edge("create_merged_podcast_audio", "__end__") + + # Compile the workflow into an executable graph + graph = workflow.compile() + graph.name = "Surfsense Podcaster" # This defines the custom name in LangSmith + + return graph + +# Compile the graph once when the module is loaded +graph = build_graph() diff --git a/surfsense_backend/app/agents/podcaster/nodes.py b/surfsense_backend/app/agents/podcaster/nodes.py index 810307ec2..19a233a6c 100644 --- a/surfsense_backend/app/agents/podcaster/nodes.py +++ b/surfsense_backend/app/agents/podcaster/nodes.py @@ -28,7 +28,7 @@ async def create_podcast_transcript(state: State, config: RunnableConfig) -> Dic # Create the messages messages = [ SystemMessage(content=prompt), - HumanMessage(content=state.source_content) + HumanMessage(content=f"{state.source_content}") ] # Generate the podcast transcript diff --git a/surfsense_backend/app/agents/podcaster/prompts.py b/surfsense_backend/app/agents/podcaster/prompts.py index 2b4bdcfec..c08d38e31 100644 --- a/surfsense_backend/app/agents/podcaster/prompts.py +++ b/surfsense_backend/app/agents/podcaster/prompts.py @@ -106,6 +106,6 @@ Output: }} -Transform the source material into a lively and engaging podcast conversation. Craft dialogue that showcases authentic host chemistry and natural interaction (including occasional disagreement, building on points, or asking follow-up questions). Use varied speech patterns reflecting real human conversation, ensuring the final script effectively educates *and* entertains the listener while keeping within a 3-minute audio duration. +Transform the source material into a lively and engaging podcast conversation. Craft dialogue that showcases authentic host chemistry and natural interaction (including occasional disagreement, building on points, or asking follow-up questions). Use varied speech patterns reflecting real human conversation, ensuring the final script effectively educates *and* entertains the listener while keeping within a 5-minute audio duration. """ \ No newline at end of file diff --git a/surfsense_backend/app/db.py b/surfsense_backend/app/db.py index b4ee3e790..7327c3a0c 100644 --- a/surfsense_backend/app/db.py +++ b/surfsense_backend/app/db.py @@ -110,8 +110,7 @@ class Podcast(BaseModel, TimestampMixin): __tablename__ = "podcasts" title = Column(String, nullable=False, index=True) - is_generated = Column(Boolean, nullable=False, default=False) - podcast_content = Column(Text, nullable=False, default="") + podcast_transcript = Column(JSON, nullable=False, default={}) file_location = Column(String(500), nullable=False, default="") search_space_id = Column(Integer, ForeignKey("searchspaces.id", ondelete='CASCADE'), nullable=False) diff --git a/surfsense_backend/app/routes/podcasts_routes.py b/surfsense_backend/app/routes/podcasts_routes.py index 7ac1da1ba..bc82e21d0 100644 --- a/surfsense_backend/app/routes/podcasts_routes.py +++ b/surfsense_backend/app/routes/podcasts_routes.py @@ -1,12 +1,16 @@ -from fastapi import APIRouter, Depends, HTTPException +from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.future import select from sqlalchemy.exc import IntegrityError, SQLAlchemyError from typing import List -from app.db import get_async_session, User, SearchSpace, Podcast -from app.schemas import PodcastCreate, PodcastUpdate, PodcastRead +from app.db import get_async_session, User, SearchSpace, Podcast, Chat +from app.schemas import PodcastCreate, PodcastUpdate, PodcastRead, PodcastGenerateRequest from app.users import current_active_user from app.utils.check_ownership import check_ownership +from app.tasks.podcast_tasks import generate_chat_podcast +from fastapi.responses import StreamingResponse +import os +from pathlib import Path router = APIRouter() @@ -119,4 +123,121 @@ async def delete_podcast( raise he except SQLAlchemyError: await session.rollback() - raise HTTPException(status_code=500, detail="Database error occurred while deleting podcast") \ No newline at end of file + raise HTTPException(status_code=500, detail="Database error occurred while deleting podcast") + +async def generate_chat_podcast_with_new_session( + chat_id: int, + search_space_id: int, + podcast_title: str = "SurfSense Podcast" +): + """Create a new session and process chat podcast generation.""" + from app.db import async_session_maker + + async with async_session_maker() as session: + try: + await generate_chat_podcast(session, chat_id, search_space_id, podcast_title) + except Exception as e: + import logging + logging.error(f"Error generating podcast from chat: {str(e)}") + +@router.post("/podcasts/generate/") +async def generate_podcast( + request: PodcastGenerateRequest, + session: AsyncSession = Depends(get_async_session), + user: User = Depends(current_active_user), + fastapi_background_tasks: BackgroundTasks = BackgroundTasks() +): + try: + # Check if the user owns the search space + await check_ownership(session, SearchSpace, request.search_space_id, user) + + if request.type == "CHAT": + # Verify that all chat IDs belong to this user and search space + query = select(Chat).filter( + Chat.id.in_(request.ids), + Chat.search_space_id == request.search_space_id + ).join(SearchSpace).filter(SearchSpace.user_id == user.id) + + result = await session.execute(query) + valid_chats = result.scalars().all() + valid_chat_ids = [chat.id for chat in valid_chats] + + # If any requested ID is not in valid IDs, raise error immediately + if len(valid_chat_ids) != len(request.ids): + raise HTTPException( + status_code=403, + detail="One or more chat IDs do not belong to this user or search space" + ) + + # Only add a single task with the first chat ID + for chat_id in valid_chat_ids: + fastapi_background_tasks.add_task( + generate_chat_podcast_with_new_session, + chat_id, + request.search_space_id, + request.podcast_title + ) + + return { + "message": "Podcast generation started", + } + except HTTPException as he: + raise he + except IntegrityError as e: + await session.rollback() + raise HTTPException(status_code=400, detail="Podcast generation failed due to constraint violation") + except SQLAlchemyError as e: + await session.rollback() + raise HTTPException(status_code=500, detail="Database error occurred while generating podcast") + except Exception as e: + await session.rollback() + raise HTTPException(status_code=500, detail=f"An unexpected error occurred: {str(e)}") + +@router.get("/podcasts/{podcast_id}/stream") +async def stream_podcast( + podcast_id: int, + session: AsyncSession = Depends(get_async_session), + user: User = Depends(current_active_user) +): + """Stream a podcast audio file.""" + try: + # Get the podcast and check if user has access + result = await session.execute( + select(Podcast) + .join(SearchSpace) + .filter(Podcast.id == podcast_id, SearchSpace.user_id == user.id) + ) + podcast = result.scalars().first() + + if not podcast: + raise HTTPException( + status_code=404, + detail="Podcast not found or you don't have permission to access it" + ) + + # Get the file path + file_path = podcast.file_location + + # Check if the file exists + if not os.path.isfile(file_path): + raise HTTPException(status_code=404, detail="Podcast audio file not found") + + # Define a generator function to stream the file + def iterfile(): + with open(file_path, mode="rb") as file_like: + yield from file_like + + # Return a streaming response with appropriate headers + return StreamingResponse( + iterfile(), + media_type="audio/mpeg", + headers={ + "Accept-Ranges": "bytes", + "Content-Disposition": f"inline; filename={Path(file_path).name}" + } + ) + + except HTTPException as he: + raise he + except Exception as e: + raise HTTPException(status_code=500, detail=f"Error streaming podcast: {str(e)}") \ No newline at end of file diff --git a/surfsense_backend/app/schemas/__init__.py b/surfsense_backend/app/schemas/__init__.py index 07adf24de..21688dfb0 100644 --- a/surfsense_backend/app/schemas/__init__.py +++ b/surfsense_backend/app/schemas/__init__.py @@ -10,7 +10,7 @@ from .documents import ( DocumentRead, ) from .chunks import ChunkBase, ChunkCreate, ChunkUpdate, ChunkRead -from .podcasts import PodcastBase, PodcastCreate, PodcastUpdate, PodcastRead +from .podcasts import PodcastBase, PodcastCreate, PodcastUpdate, PodcastRead, PodcastGenerateRequest from .chats import ChatBase, ChatCreate, ChatUpdate, ChatRead, AISDKChatRequest from .search_source_connector import SearchSourceConnectorBase, SearchSourceConnectorCreate, SearchSourceConnectorUpdate, SearchSourceConnectorRead @@ -39,6 +39,7 @@ __all__ = [ "PodcastCreate", "PodcastUpdate", "PodcastRead", + "PodcastGenerateRequest", "ChatBase", "ChatCreate", "ChatUpdate", diff --git a/surfsense_backend/app/schemas/chats.py b/surfsense_backend/app/schemas/chats.py index ad7829b26..f5eefc532 100644 --- a/surfsense_backend/app/schemas/chats.py +++ b/surfsense_backend/app/schemas/chats.py @@ -1,8 +1,10 @@ from typing import Any, Dict, List, Optional -from pydantic import BaseModel -from sqlalchemy import JSON -from .base import IDModel, TimestampModel + from app.db import ChatType +from pydantic import BaseModel + +from .base import IDModel, TimestampModel + class ChatBase(BaseModel): type: ChatType diff --git a/surfsense_backend/app/schemas/podcasts.py b/surfsense_backend/app/schemas/podcasts.py index fbec5482b..4132fb211 100644 --- a/surfsense_backend/app/schemas/podcasts.py +++ b/surfsense_backend/app/schemas/podcasts.py @@ -1,10 +1,10 @@ from pydantic import BaseModel +from typing import Any, List, Literal from .base import IDModel, TimestampModel class PodcastBase(BaseModel): title: str - is_generated: bool = False - podcast_content: str = "" + podcast_transcript: List[Any] file_location: str = "" search_space_id: int @@ -16,4 +16,10 @@ class PodcastUpdate(PodcastBase): class PodcastRead(PodcastBase, IDModel, TimestampModel): class Config: - from_attributes = True \ No newline at end of file + from_attributes = True + +class PodcastGenerateRequest(BaseModel): + type: Literal["DOCUMENT", "CHAT"] + ids: List[int] + search_space_id: int + podcast_title: str = "SurfSense Podcast" \ No newline at end of file diff --git a/surfsense_backend/app/tasks/podcast_tasks.py b/surfsense_backend/app/tasks/podcast_tasks.py new file mode 100644 index 000000000..e148f5465 --- /dev/null +++ b/surfsense_backend/app/tasks/podcast_tasks.py @@ -0,0 +1,94 @@ +from sqlalchemy.ext.asyncio import AsyncSession +from app.schemas import PodcastGenerateRequest +from typing import List +from sqlalchemy import select +from app.db import Chat, Podcast +from app.agents.podcaster.graph import graph as podcaster_graph +from surfsense_backend.app.agents.podcaster.state import State + + +async def generate_document_podcast( + session: AsyncSession, + document_id: int, + search_space_id: int, + user_id: int +): + # TODO: Need to fetch the document chunks, then concatenate them and pass them to the podcast generation model + pass + + + +async def generate_chat_podcast( + session: AsyncSession, + chat_id: int, + search_space_id: int, + podcast_title: str +): + # Fetch the chat with the specified ID + query = select(Chat).filter( + Chat.id == chat_id, + Chat.search_space_id == search_space_id + ) + + result = await session.execute(query) + chat = result.scalars().first() + + if not chat: + raise ValueError(f"Chat with id {chat_id} not found in search space {search_space_id}") + + # Create chat history structure + chat_history_str = "" + + for message in chat.messages: + if message["role"] == "user": + chat_history_str += f"{message['content']}" + elif message["role"] == "assistant": + # Last annotation type will always be "ANSWER" here + answer_annotation = message["annotations"][-1] + answer_text = "" + if answer_annotation["type"] == "ANSWER": + answer_text = answer_annotation["content"] + # If content is a list, join it into a single string + if isinstance(answer_text, list): + answer_text = "\n".join(answer_text) + chat_history_str += f"{answer_text}" + + chat_history_str += "" + + # Pass it to the SurfSense Podcaster + config = { + "configurable": { + "podcast_title" : "Surfsense", + } + } + # Initialize state with database session and streaming service + initial_state = State( + source_content=chat_history_str, + ) + + # Run the graph directly + result = await podcaster_graph.ainvoke(initial_state, config=config) + + # Convert podcast transcript entries to serializable format + serializable_transcript = [] + for entry in result["podcast_transcript"]: + serializable_transcript.append({ + "speaker_id": entry.speaker_id, + "dialog": entry.dialog + }) + + # Create a new podcast entry + podcast = Podcast( + title=f"{podcast_title}", + podcast_transcript=serializable_transcript, + file_location=result["final_podcast_file_path"], + search_space_id=search_space_id + ) + + # Add to session and commit + session.add(podcast) + await session.commit() + await session.refresh(podcast) + + return podcast + 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 c481bd6ec..6501ca684 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 @@ -3,7 +3,7 @@ import { useState, useEffect } from 'react'; import { motion, AnimatePresence } from 'framer-motion'; import { useSearchParams } from 'next/navigation'; -import { MessageCircleMore, Search, Calendar, Tag, Trash2, ExternalLink, MoreHorizontal } from 'lucide-react'; +import { MessageCircleMore, Search, Calendar, Tag, Trash2, ExternalLink, MoreHorizontal, Radio, CheckCircle, Circle, Podcast } from 'lucide-react'; import { format } from 'date-fns'; // UI Components @@ -42,6 +42,9 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select"; +import { Checkbox } from "@/components/ui/checkbox"; +import { Label } from "@/components/ui/label"; +import { toast } from "sonner"; interface Chat { created_at: string; @@ -92,6 +95,18 @@ export default function ChatsPageClient({ searchSpaceId }: ChatsPageClientProps) const [chatToDelete, setChatToDelete] = useState<{ id: number, title: string } | null>(null); const [isDeleting, setIsDeleting] = useState(false); + // New state for podcast generation + const [selectedChats, setSelectedChats] = useState([]); + const [selectionMode, setSelectionMode] = useState(false); + const [podcastDialogOpen, setPodcastDialogOpen] = useState(false); + const [podcastTitle, setPodcastTitle] = useState(""); + const [isGeneratingPodcast, setIsGeneratingPodcast] = useState(false); + + // New state for individual podcast generation + const [currentChatIndex, setCurrentChatIndex] = useState(0); + const [podcastTitles, setPodcastTitles] = useState<{[key: number]: string}>({}); + const [processingChat, setProcessingChat] = useState(null); + const chatsPerPage = 9; const searchParams = useSearchParams(); @@ -234,6 +249,177 @@ export default function ChatsPageClient({ searchSpaceId }: ChatsPageClientProps) // Get unique chat types for filter dropdown const chatTypes = ['all', ...Array.from(new Set(chats.map(chat => chat.type)))]; + // Generate individual podcasts from selected chats + const handleGeneratePodcast = async () => { + if (selectedChats.length === 0) { + toast.error("Please select at least one chat"); + return; + } + + const currentChatId = selectedChats[currentChatIndex]; + const currentTitle = podcastTitles[currentChatId] || podcastTitle; + + if (!currentTitle.trim()) { + toast.error("Please enter a podcast title"); + return; + } + + setIsGeneratingPodcast(true); + try { + const token = localStorage.getItem('surfsense_bearer_token'); + if (!token) { + toast.error("Authentication error. Please log in again."); + setIsGeneratingPodcast(false); + return; + } + + // Create payload for single chat + const payload = { + type: "CHAT", + ids: [currentChatId], // Single chat ID + search_space_id: parseInt(searchSpaceId), + podcast_title: currentTitle + }; + + const response = await fetch(`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/podcasts/generate/`, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(payload) + }); + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})); + throw new Error(errorData.detail || "Failed to generate podcast"); + } + + const data = await response.json(); + toast.success(`Podcast "${currentTitle}" generation started!`); + + // Move to the next chat or finish + if (currentChatIndex < selectedChats.length - 1) { + // Set up for next chat + setCurrentChatIndex(currentChatIndex + 1); + + // Find the next chat from the chats array + const nextChatId = selectedChats[currentChatIndex + 1]; + const nextChat = chats.find(chat => chat.id === nextChatId) || null; + setProcessingChat(nextChat); + + // Default title for the next chat + if (!podcastTitles[nextChatId]) { + setPodcastTitle(nextChat?.title || `Podcast from Chat ${nextChatId}`); + } else { + setPodcastTitle(podcastTitles[nextChatId]); + } + + setIsGeneratingPodcast(false); + } else { + // All done + finishPodcastGeneration(); + } + } catch (error) { + console.error('Error generating podcast:', error); + toast.error(error instanceof Error ? error.message : 'Failed to generate podcast'); + setIsGeneratingPodcast(false); + } + }; + + // Helper to finish the podcast generation process + const finishPodcastGeneration = () => { + toast.success("All podcasts are being generated! Check the podcasts tab to see them when ready."); + setPodcastDialogOpen(false); + setSelectedChats([]); + setSelectionMode(false); + setCurrentChatIndex(0); + setPodcastTitles({}); + setProcessingChat(null); + setPodcastTitle(""); + setIsGeneratingPodcast(false); + }; + + // Start podcast generation flow + const startPodcastGeneration = () => { + if (selectedChats.length === 0) { + toast.error("Please select at least one chat"); + return; + } + + // Reset the state for podcast generation + setCurrentChatIndex(0); + setPodcastTitles({}); + + // Set up for the first chat + const firstChatId = selectedChats[0]; + const firstChat = chats.find(chat => chat.id === firstChatId) || null; + setProcessingChat(firstChat); + + // Set default title for the first chat + setPodcastTitle(firstChat?.title || `Podcast from Chat ${firstChatId}`); + setPodcastDialogOpen(true); + }; + + // Update the title for the current chat + const updateCurrentChatTitle = (title: string) => { + const currentChatId = selectedChats[currentChatIndex]; + setPodcastTitle(title); + setPodcastTitles(prev => ({ + ...prev, + [currentChatId]: title + })); + }; + + // Skip generating a podcast for the current chat + const skipCurrentChat = () => { + if (currentChatIndex < selectedChats.length - 1) { + // Move to the next chat + setCurrentChatIndex(currentChatIndex + 1); + + // Find the next chat + const nextChatId = selectedChats[currentChatIndex + 1]; + const nextChat = chats.find(chat => chat.id === nextChatId) || null; + setProcessingChat(nextChat); + + // Set default title for the next chat + if (!podcastTitles[nextChatId]) { + setPodcastTitle(nextChat?.title || `Podcast from Chat ${nextChatId}`); + } else { + setPodcastTitle(podcastTitles[nextChatId]); + } + } else { + // All done (all skipped) + finishPodcastGeneration(); + } + }; + + // Toggle chat selection + const toggleChatSelection = (chatId: number) => { + setSelectedChats(prev => + prev.includes(chatId) + ? prev.filter(id => id !== chatId) + : [...prev, chatId] + ); + }; + + // Select all visible chats + const selectAllVisibleChats = () => { + const visibleChatIds = currentChats.map(chat => chat.id); + setSelectedChats(prev => { + const allSelected = visibleChatIds.every(id => prev.includes(id)); + return allSelected + ? prev.filter(id => !visibleChatIds.includes(id)) // Deselect all visible if all are selected + : [...new Set([...prev, ...visibleChatIds])]; // Add all visible, ensuring no duplicates + }); + }; + + // Cancel selection mode + const cancelSelectionMode = () => { + setSelectionMode(false); + setSelectedChats([]); + }; + return ( -
- +
+ {selectionMode ? ( + <> + + + + + ) : ( + <> + + + + )}
@@ -334,44 +564,69 @@ export default function ChatsPageClient({ searchSpaceId }: ChatsPageClientProps) animate="animate" exit="exit" transition={{ duration: 0.2, delay: index * 0.05 }} - className="overflow-hidden hover:shadow-md transition-shadow" + className={`overflow-hidden hover:shadow-md transition-shadow + ${selectionMode && selectedChats.includes(chat.id) + ? 'ring-2 ring-primary ring-offset-2' : ''}`} + onClick={() => selectionMode ? toggleChatSelection(chat.id) : null} >
-
- {chat.title || `Chat ${chat.id}`} - - - - {format(new Date(chat.created_at), 'MMM d, yyyy')} - - +
+ {selectionMode && ( +
+ {selectedChats.includes(chat.id) + ? + : } +
+ )} +
+ {chat.title || `Chat ${chat.id}`} + + + + {format(new Date(chat.created_at), 'MMM d, yyyy')} + + +
- - - - - - window.location.href = `/dashboard/${chat.search_space_id}/researcher/${chat.id}`}> - - View Chat - - - { - setChatToDelete({ id: chat.id, title: chat.title || `Chat ${chat.id}` }); - setDeleteDialogOpen(true); - }} - > - - Delete Chat - - - + {!selectionMode && ( + + + + + + window.location.href = `/dashboard/${chat.search_space_id}/researcher/${chat.id}`}> + + View Chat + + { + setSelectedChats([chat.id]); + setPodcastTitle(chat.title || `Chat ${chat.id}`); + setPodcastDialogOpen(true); + }} + > + + Generate Podcast + + + { + e.stopPropagation(); + setChatToDelete({ id: chat.id, title: chat.title || `Chat ${chat.id}` }); + setDeleteDialogOpen(true); + }} + > + + Delete Chat + + + + )}
@@ -505,6 +760,104 @@ export default function ChatsPageClient({ searchSpaceId }: ChatsPageClientProps) + + {/* Podcast Generation Dialog */} + { + if (!isOpen) { + // Cancel the process if dialog is closed + setPodcastDialogOpen(false); + setSelectedChats([]); + setSelectionMode(false); + setCurrentChatIndex(0); + setPodcastTitles({}); + setProcessingChat(null); + setPodcastTitle(""); + } else { + setPodcastDialogOpen(true); + } + }} + > + + + + + Generate Podcast {currentChatIndex + 1} of {selectedChats.length} + + + {selectedChats.length > 1 ? ( + <>Creating individual podcasts for each selected chat. Currently processing: {processingChat?.title || `Chat ${selectedChats[currentChatIndex]}`} + ) : ( + <>Create a podcast from this chat. The podcast will be available in the podcasts section once generated. + )} + + + +
+
+ + updateCurrentChatTitle(e.target.value)} + /> +
+ + {selectedChats.length > 1 && ( +
+
+
+ )} +
+ + + {selectedChats.length > 1 && !isGeneratingPodcast && ( + + )} + + + +
+
); } \ No newline at end of file diff --git a/surfsense_web/app/dashboard/[search_space_id]/layout.tsx b/surfsense_web/app/dashboard/[search_space_id]/layout.tsx index 7449e10b5..a3c344aaf 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/layout.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/layout.tsx @@ -73,6 +73,13 @@ export default function DashboardLayout({ }, ], }, + { + title: "Podcasts", + url: `/dashboard/${search_space_id}/podcasts`, + icon: "Podcast", + items: [ + ], + } // TODO: Add research synthesizer's // { // title: "Research Synthesizer's", diff --git a/surfsense_web/app/dashboard/[search_space_id]/podcasts/page.tsx b/surfsense_web/app/dashboard/[search_space_id]/podcasts/page.tsx new file mode 100644 index 000000000..394177c88 --- /dev/null +++ b/surfsense_web/app/dashboard/[search_space_id]/podcasts/page.tsx @@ -0,0 +1,22 @@ +import { Suspense } from 'react'; +import PodcastsPageClient from './podcasts-client'; + +interface PageProps { + params: { + search_space_id: string; + }; +} + +export default async function PodcastsPage({ params }: PageProps) { + // Access dynamic route parameters + // Need to await params before accessing its properties in an async component + const { search_space_id: searchSpaceId } = await Promise.resolve(params); + + return ( + +
+
}> + + + ); +} 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 new file mode 100644 index 000000000..cacee7061 --- /dev/null +++ b/surfsense_web/app/dashboard/[search_space_id]/podcasts/podcasts-client.tsx @@ -0,0 +1,787 @@ +'use client'; + +import { useState, useEffect, useRef } from 'react'; +import { motion, AnimatePresence } from 'framer-motion'; +import { format } from 'date-fns'; +import { + Search, Calendar, Trash2, MoreHorizontal, Podcast, + Play, Pause, SkipForward, SkipBack, Volume2, VolumeX +} from 'lucide-react'; + +// UI Components +import { Input } from '@/components/ui/input'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'; +import { Slider } from '@/components/ui/slider'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, + DropdownMenuSeparator +} from '@/components/ui/dropdown-menu'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { toast } from "sonner"; + +interface Podcast { + id: number; + title: string; + created_at: string; + file_location: string; + podcast_transcript: any[]; + search_space_id: number; +} + +interface PodcastsPageClientProps { + searchSpaceId: string; +} + +const pageVariants = { + initial: { opacity: 0 }, + enter: { opacity: 1, transition: { duration: 0.3, ease: 'easeInOut' } }, + exit: { opacity: 0, transition: { duration: 0.3, ease: 'easeInOut' } } +}; + +const podcastCardVariants = { + initial: { y: 20, opacity: 0 }, + animate: { y: 0, opacity: 1 }, + exit: { y: -20, opacity: 0 } +}; + +const MotionCard = motion(Card); + +export default function PodcastsPageClient({ searchSpaceId }: PodcastsPageClientProps) { + const [podcasts, setPodcasts] = useState([]); + const [filteredPodcasts, setFilteredPodcasts] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + const [searchQuery, setSearchQuery] = useState(''); + const [sortOrder, setSortOrder] = useState('newest'); + const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); + const [podcastToDelete, setPodcastToDelete] = useState<{ id: number, title: string } | null>(null); + const [isDeleting, setIsDeleting] = useState(false); + + // Audio player state + const [currentPodcast, setCurrentPodcast] = useState(null); + const [audioSrc, setAudioSrc] = useState(undefined); + const [isAudioLoading, setIsAudioLoading] = useState(false); + 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 audioRef = useRef(null); + const currentObjectUrlRef = useRef(null); + + // Add podcast image URL constant + const PODCAST_IMAGE_URL = "https://static.vecteezy.com/system/resources/thumbnails/002/157/611/small_2x/illustrations-concept-design-podcast-channel-free-vector.jpg"; + + // Fetch podcasts from API + useEffect(() => { + const fetchPodcasts = async () => { + try { + setIsLoading(true); + + // Get token from localStorage + const token = localStorage.getItem('surfsense_bearer_token'); + + if (!token) { + setError('Authentication token not found. Please log in again.'); + setIsLoading(false); + return; + } + + // Fetch all podcasts for this search space + const response = await fetch( + `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/podcasts/`, + { + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json', + }, + cache: 'no-store', + } + ); + + if (!response.ok) { + const errorData = await response.json().catch(() => null); + throw new Error(`Failed to fetch podcasts: ${response.status} ${errorData?.detail || ''}`); + } + + const data: Podcast[] = await response.json(); + setPodcasts(data); + setFilteredPodcasts(data); + setError(null); + } catch (error) { + console.error('Error fetching podcasts:', error); + setError(error instanceof Error ? error.message : 'Unknown error occurred'); + setPodcasts([]); + setFilteredPodcasts([]); + } finally { + setIsLoading(false); + } + }; + + fetchPodcasts(); + }, [searchSpaceId]); + + // Filter and sort podcasts based on search query and sort order + useEffect(() => { + let result = [...podcasts]; + + // Filter by search term + if (searchQuery) { + const query = searchQuery.toLowerCase(); + result = result.filter(podcast => + podcast.title.toLowerCase().includes(query) + ); + } + + // Filter by search space + result = result.filter(podcast => + podcast.search_space_id === parseInt(searchSpaceId) + ); + + // Sort podcasts + result.sort((a, b) => { + const dateA = new Date(a.created_at).getTime(); + const dateB = new Date(b.created_at).getTime(); + + return sortOrder === 'newest' ? dateB - dateA : dateA - dateB; + }); + + setFilteredPodcasts(result); + }, [podcasts, searchQuery, sortOrder, searchSpaceId]); + + // Cleanup object URL on unmount or when currentPodcast changes + useEffect(() => { + return () => { + if (currentObjectUrlRef.current) { + URL.revokeObjectURL(currentObjectUrlRef.current); + currentObjectUrlRef.current = null; + } + }; + }, []); + + // Audio player time update handler + const handleTimeUpdate = () => { + if (audioRef.current) { + setCurrentTime(audioRef.current.currentTime); + } + }; + + // Audio player metadata loaded handler + const handleMetadataLoaded = () => { + if (audioRef.current) { + setDuration(audioRef.current.duration); + } + }; + + // Play/pause toggle + const togglePlayPause = () => { + if (audioRef.current) { + if (isPlaying) { + audioRef.current.pause(); + } else { + audioRef.current.play(); + } + setIsPlaying(!isPlaying); + } + }; + + // Seek to position + const handleSeek = (value: number[]) => { + if (audioRef.current) { + audioRef.current.currentTime = value[0]; + setCurrentTime(value[0]); + } + }; + + // Volume change + const handleVolumeChange = (value: number[]) => { + if (audioRef.current) { + const newVolume = value[0]; + audioRef.current.volume = newVolume; + setVolume(newVolume); + + if (newVolume === 0) { + setIsMuted(true); + } else if (isMuted) { + setIsMuted(false); + } + } + }; + + // Toggle mute + const toggleMute = () => { + if (audioRef.current) { + audioRef.current.muted = !isMuted; + setIsMuted(!isMuted); + } + }; + + // Skip forward 10 seconds + const skipForward = () => { + if (audioRef.current) { + audioRef.current.currentTime = Math.min(audioRef.current.duration, audioRef.current.currentTime + 10); + } + }; + + // Skip backward 10 seconds + const skipBackward = () => { + if (audioRef.current) { + audioRef.current.currentTime = Math.max(0, audioRef.current.currentTime - 10); + } + }; + + // Format time in MM:SS + const formatTime = (time: number) => { + const minutes = Math.floor(time / 60); + const seconds = Math.floor(time % 60); + return `${minutes}:${seconds < 10 ? '0' : ''}${seconds}`; + }; + + // Play podcast - Fetch blob and set object URL + const playPodcast = async (podcast: Podcast) => { + // If the same podcast is selected, just toggle play/pause + if (currentPodcast && currentPodcast.id === podcast.id) { + togglePlayPause(); + return; + } + + // Revoke previous object URL if exists + if (currentObjectUrlRef.current) { + URL.revokeObjectURL(currentObjectUrlRef.current); + currentObjectUrlRef.current = null; + } + + // Reset player state and show loading + setCurrentPodcast(podcast); + setAudioSrc(undefined); + setCurrentTime(0); + setDuration(0); + setIsPlaying(false); + setIsAudioLoading(true); + + try { + const token = localStorage.getItem('surfsense_bearer_token'); + if (!token) { + toast.error('Authentication token not found.'); + setIsAudioLoading(false); + return; + } + + const response = await fetch( + `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/podcasts/${podcast.id}/stream`, + { + headers: { + 'Authorization': `Bearer ${token}`, + }, + } + ); + + 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); + + // Let the audio element load the new src + setTimeout(() => { + if (audioRef.current) { + audioRef.current.load(); + audioRef.current.play() + .then(() => { + setIsPlaying(true); + }) + .catch(error => { + console.error('Error playing audio:', error); + toast.error('Failed to play audio.'); + setIsPlaying(false); + }); + } + }, 50); + + } catch (error) { + console.error('Error fetching or playing podcast:', error); + toast.error(error instanceof Error ? error.message : 'Failed to load podcast audio.'); + setCurrentPodcast(null); + } finally { + setIsAudioLoading(false); + } + }; + + // Function to handle podcast deletion + const handleDeletePodcast = async () => { + if (!podcastToDelete) return; + + setIsDeleting(true); + try { + const token = localStorage.getItem('surfsense_bearer_token'); + if (!token) { + setIsDeleting(false); + return; + } + + const response = await fetch(`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/podcasts/${podcastToDelete.id}`, { + method: 'DELETE', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json', + } + }); + + if (!response.ok) { + throw new Error(`Failed to delete podcast: ${response.statusText}`); + } + + // Close dialog and refresh podcasts + setDeleteDialogOpen(false); + setPodcastToDelete(null); + + // Update local state by removing the deleted podcast + setPodcasts(prevPodcasts => prevPodcasts.filter(podcast => podcast.id !== podcastToDelete.id)); + + // If the current playing podcast is deleted, stop playback + if (currentPodcast && currentPodcast.id === podcastToDelete.id) { + if (audioRef.current) { + audioRef.current.pause(); + } + setCurrentPodcast(null); + setIsPlaying(false); + } + + toast.success('Podcast deleted successfully'); + } catch (error) { + console.error('Error deleting podcast:', error); + toast.error(error instanceof Error ? error.message : 'Failed to delete podcast'); + } finally { + setIsDeleting(false); + } + }; + + return ( + +
+
+

Podcasts

+

Listen to generated podcasts.

+
+ + {/* Filter and Search Bar */} +
+
+
+ + setSearchQuery(e.target.value)} + /> +
+
+ +
+ +
+
+ + {/* Status Messages */} + {isLoading && ( +
+
+
+

Loading podcasts...

+
+
+ )} + + {error && !isLoading && ( +
+

Error loading podcasts

+

{error}

+
+ )} + + {!isLoading && !error && filteredPodcasts.length === 0 && ( +
+ +

No podcasts found

+

+ {searchQuery + ? 'Try adjusting your search filters' + : 'Generate podcasts from your chats to get started'} +

+
+ )} + + {/* Podcast Grid */} + {!isLoading && !error && filteredPodcasts.length > 0 && ( + +
+ {filteredPodcasts.map((podcast, index) => ( + +
playPodcast(podcast)} + > + {/* Podcast image */} + Podcast illustration + + {/* Overlay for better contrast with controls */} +
+ + {/* Loading indicator */} + {currentPodcast?.id === podcast.id && isAudioLoading && ( +
+
+
+ )} + + {/* Play button */} + {!(currentPodcast?.id === podcast.id && (isPlaying || isAudioLoading)) && ( + + )} + + {/* Pause button */} + {currentPodcast?.id === podcast.id && isPlaying && !isAudioLoading && ( + + )} +
+ +
+

+ {podcast.title || 'Untitled Podcast'} +

+

+ + {format(new Date(podcast.created_at), 'MMM d, yyyy')} +

+
+ + {currentPodcast?.id === podcast.id && !isAudioLoading && ( +
+
{ + if (!audioRef.current || !duration) return; + const container = e.currentTarget; + const rect = container.getBoundingClientRect(); + const x = e.clientX - rect.left; + const percentage = Math.max(0, Math.min(1, x / rect.width)); + const newTime = percentage * duration; + handleSeek([newTime]); + }} + > +
+
+
+
+
+ {formatTime(currentTime)} + {formatTime(duration)} +
+
+ )} + + {currentPodcast?.id === podcast.id && !isAudioLoading && ( +
+ + + +
+ )} + +
+ + + + + + { + setPodcastToDelete({ id: podcast.id, title: podcast.title }); + setDeleteDialogOpen(true); + }} + > + + Delete Podcast + + + +
+ + + ))} +
+ + )} + + {/* Current Podcast Player (Fixed at bottom) */} + {currentPodcast && !isAudioLoading && audioSrc && ( + +
+
+
+
+ +
+
+ +
+

{currentPodcast.title}

+ +
+
+ +
+
+ {formatTime(currentTime)} / {formatTime(duration)} +
+
+
+ +
+ + + + + + +
+ + + +
+
+
+
+
+ )} +
+ + {/* Delete Confirmation Dialog */} + + + + + + Delete Podcast + + + Are you sure you want to delete {podcastToDelete?.title}? This action cannot be undone. + + + + + + + + + + {/* Hidden audio element for playback */} +