diff --git a/surfsense_backend/app/agents/new_chat/chat_deepagent.py b/surfsense_backend/app/agents/new_chat/chat_deepagent.py
index 931b31e63..1c97a05ad 100644
--- a/surfsense_backend/app/agents/new_chat/chat_deepagent.py
+++ b/surfsense_backend/app/agents/new_chat/chat_deepagent.py
@@ -2,7 +2,7 @@
SurfSense deep agent implementation.
This module provides the factory function for creating SurfSense deep agents
-with knowledge base search capability.
+with knowledge base search and podcast generation capabilities.
"""
from collections.abc import Sequence
@@ -15,6 +15,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.agents.new_chat.context import SurfSenseContextSchema
from app.agents.new_chat.knowledge_base import create_search_knowledge_base_tool
+from app.agents.new_chat.podcast import create_generate_podcast_tool
from app.agents.new_chat.system_prompt import build_surfsense_system_prompt
from app.services.connector_service import ConnectorService
@@ -29,12 +30,14 @@ def create_surfsense_deep_agent(
db_session: AsyncSession,
connector_service: ConnectorService,
checkpointer: Checkpointer,
+ user_id: str | None = None,
user_instructions: str | None = None,
enable_citations: bool = True,
+ enable_podcast: bool = True,
additional_tools: Sequence[BaseTool] | None = None,
):
"""
- Create a SurfSense deep agent with knowledge base search capability.
+ Create a SurfSense deep agent with knowledge base search and podcast generation capabilities.
Args:
llm: ChatLiteLLM instance
@@ -43,10 +46,13 @@ def create_surfsense_deep_agent(
connector_service: Initialized connector service
checkpointer: LangGraph checkpointer for conversation state persistence.
Use AsyncPostgresSaver for production or MemorySaver for testing.
+ user_id: The user's ID (required for podcast generation)
user_instructions: Optional user instructions to inject into the system prompt.
These will be added to the system prompt to customize agent behavior.
enable_citations: Whether to include citation instructions in the system prompt (default: True).
When False, the agent will not be instructed to add citations to responses.
+ enable_podcast: Whether to include the podcast generation tool (default: True).
+ When True and user_id is provided, the agent can generate podcasts.
additional_tools: Optional sequence of additional tools to inject into the agent.
The search_knowledge_base tool will always be included.
@@ -62,6 +68,16 @@ def create_surfsense_deep_agent(
# Combine search tool with any additional tools
tools = [search_tool]
+
+ # Add podcast tool if enabled and user_id is provided
+ if enable_podcast and user_id:
+ podcast_tool = create_generate_podcast_tool(
+ search_space_id=search_space_id,
+ db_session=db_session,
+ user_id=str(user_id),
+ )
+ tools.append(podcast_tool)
+
if additional_tools:
tools.extend(additional_tools)
diff --git a/surfsense_backend/app/agents/new_chat/podcast.py b/surfsense_backend/app/agents/new_chat/podcast.py
new file mode 100644
index 000000000..d57d0fb21
--- /dev/null
+++ b/surfsense_backend/app/agents/new_chat/podcast.py
@@ -0,0 +1,174 @@
+"""
+Podcast generation tool for the new chat agent.
+
+This module provides a factory function for creating the generate_podcast tool
+that submits a Celery task for background podcast generation. The frontend
+polls for completion and auto-updates when the podcast is ready.
+
+Duplicate request prevention:
+- Only one podcast can be generated at a time per search space
+- Uses Redis to track active podcast tasks
+- Returns a friendly message if a podcast is already being generated
+"""
+
+import os
+from typing import Any
+
+import redis
+from langchain_core.tools import tool
+from sqlalchemy.ext.asyncio import AsyncSession
+
+# Redis connection for tracking active podcast tasks
+# Uses the same Redis instance as Celery
+REDIS_URL = os.getenv("CELERY_BROKER_URL", "redis://localhost:6379/0")
+_redis_client: redis.Redis | None = None
+
+
+def get_redis_client() -> redis.Redis:
+ """Get or create Redis client for podcast task tracking."""
+ global _redis_client
+ if _redis_client is None:
+ _redis_client = redis.from_url(REDIS_URL, decode_responses=True)
+ return _redis_client
+
+
+def get_active_podcast_key(search_space_id: int) -> str:
+ """Generate Redis key for tracking active podcast task."""
+ return f"podcast:active:{search_space_id}"
+
+
+def get_active_podcast_task(search_space_id: int) -> str | None:
+ """Check if there's an active podcast task for this search space."""
+ try:
+ client = get_redis_client()
+ return client.get(get_active_podcast_key(search_space_id))
+ except Exception:
+ # If Redis is unavailable, allow the request (fail open)
+ return None
+
+
+def set_active_podcast_task(search_space_id: int, task_id: str) -> None:
+ """Mark a podcast task as active for this search space."""
+ try:
+ client = get_redis_client()
+ # Set with 30-minute expiry as safety net (podcast should complete before this)
+ client.setex(get_active_podcast_key(search_space_id), 1800, task_id)
+ except Exception as e:
+ print(f"[generate_podcast] Warning: Could not set active task in Redis: {e}")
+
+
+def clear_active_podcast_task(search_space_id: int) -> None:
+ """Clear the active podcast task for this search space."""
+ try:
+ client = get_redis_client()
+ client.delete(get_active_podcast_key(search_space_id))
+ except Exception as e:
+ print(f"[generate_podcast] Warning: Could not clear active task in Redis: {e}")
+
+
+def create_generate_podcast_tool(
+ search_space_id: int,
+ db_session: AsyncSession,
+ user_id: str,
+):
+ """
+ Factory function to create the generate_podcast tool with injected dependencies.
+
+ Args:
+ search_space_id: The user's search space ID
+ db_session: Database session (not used - Celery creates its own)
+ user_id: The user's ID (as string)
+
+ Returns:
+ A configured tool function for generating podcasts
+ """
+
+ @tool
+ async def generate_podcast(
+ source_content: str,
+ podcast_title: str = "SurfSense Podcast",
+ user_prompt: str | None = None,
+ ) -> dict[str, Any]:
+ """
+ Generate a podcast from the provided content.
+
+ Use this tool when the user asks to create, generate, or make a podcast.
+ Common triggers include phrases like:
+ - "Give me a podcast about this"
+ - "Create a podcast from this conversation"
+ - "Generate a podcast summary"
+ - "Make a podcast about..."
+ - "Turn this into a podcast"
+
+ The tool will start generating a podcast in the background.
+ The podcast will be available once generation completes.
+
+ IMPORTANT: Only one podcast can be generated at a time. If a podcast
+ is already being generated, this tool will return a message asking
+ the user to wait.
+
+ Args:
+ source_content: The text content to convert into a podcast.
+ This can be a summary, research findings, or any text
+ the user wants transformed into an audio podcast.
+ podcast_title: Title for the podcast (default: "SurfSense Podcast")
+ user_prompt: Optional instructions for podcast style, tone, or format.
+ For example: "Make it casual and fun" or "Focus on the key insights"
+
+ Returns:
+ A dictionary containing:
+ - status: "processing" (task submitted), "already_generating", or "error"
+ - task_id: The Celery task ID for polling status (if processing)
+ - title: The podcast title
+ - message: Status message for the user
+ """
+ try:
+ # Check if a podcast is already being generated for this search space
+ active_task_id = get_active_podcast_task(search_space_id)
+ if active_task_id:
+ print(f"[generate_podcast] Blocked duplicate request. Active task: {active_task_id}")
+ return {
+ "status": "already_generating",
+ "task_id": active_task_id,
+ "title": podcast_title,
+ "message": "A podcast is already being generated. Please wait for it to complete before requesting another one.",
+ }
+
+ # Import Celery task here to avoid circular imports
+ from app.tasks.celery_tasks.podcast_tasks import (
+ generate_content_podcast_task,
+ )
+
+ # Submit Celery task for background processing
+ task = generate_content_podcast_task.delay(
+ source_content=source_content,
+ search_space_id=search_space_id,
+ user_id=str(user_id),
+ podcast_title=podcast_title,
+ user_prompt=user_prompt,
+ )
+
+ # Mark this task as active
+ set_active_podcast_task(search_space_id, task.id)
+
+ print(f"[generate_podcast] Submitted Celery task: {task.id}")
+
+ # Return immediately with task_id for polling
+ return {
+ "status": "processing",
+ "task_id": task.id,
+ "title": podcast_title,
+ "message": "Podcast generation started. This may take a few minutes.",
+ }
+
+ except Exception as e:
+ error_message = str(e)
+ print(f"[generate_podcast] Error submitting task: {error_message}")
+ return {
+ "status": "error",
+ "error": error_message,
+ "title": podcast_title,
+ "task_id": None,
+ }
+
+ return generate_podcast
diff --git a/surfsense_backend/app/agents/new_chat/system_prompt.py b/surfsense_backend/app/agents/new_chat/system_prompt.py
index 65a5b1203..f725be684 100644
--- a/surfsense_backend/app/agents/new_chat/system_prompt.py
+++ b/surfsense_backend/app/agents/new_chat/system_prompt.py
@@ -121,7 +121,8 @@ Today's date (UTC): {resolved_today}
{user_section}
You have access to the following tools:
-- search_knowledge_base: Search the user's personal knowledge base for relevant information.
+
+1. search_knowledge_base: Search the user's personal knowledge base for relevant information.
- Args:
- query: The search query - be specific and include key terms
- top_k: Number of results to retrieve (default: 10)
@@ -129,6 +130,21 @@ You have access to the following tools:
- end_date: Optional ISO date/datetime (e.g. "2025-12-19" or "2025-12-19T23:59:59+00:00")
- connectors_to_search: Optional list of connector enums to search. If omitted, searches all.
- Returns: Formatted string with relevant documents and their content
+
+2. generate_podcast: Generate an audio podcast from provided content.
+ - Use this when the user asks to create, generate, or make a podcast.
+ - Trigger phrases: "give me a podcast about", "create a podcast", "generate a podcast", "make a podcast", "turn this into a podcast"
+ - Args:
+ - source_content: The text content to convert into a podcast. This MUST be comprehensive and include:
+ * If discussing the current conversation: Include a detailed summary of the FULL chat history (all user questions and your responses)
+ * If based on knowledge base search: Include the key findings and insights from the search results
+ * You can combine both: conversation context + search results for richer podcasts
+ * The more detailed the source_content, the better the podcast quality
+ - podcast_title: Optional title for the podcast (default: "SurfSense Podcast")
+ - user_prompt: Optional instructions for podcast style/format (e.g., "Make it casual and fun")
+ - Returns: A task_id for tracking. The podcast will be generated in the background.
+ - IMPORTANT: Only one podcast can be generated at a time. If a podcast is already being generated, the tool will return status "already_generating".
+ - After calling this tool, inform the user that podcast generation has started and they will see the player when it's ready (takes 3-5 minutes).
- User: "Fetch all my notes and what's in them?"
@@ -136,6 +152,16 @@ You have access to the following tools:
- User: "What did I discuss on Slack last week about the React migration?"
- Call: `search_knowledge_base(query="React migration", connectors_to_search=["SLACK_CONNECTOR"], start_date="YYYY-MM-DD", end_date="YYYY-MM-DD")`
+
+- User: "Give me a podcast about AI trends based on what we discussed"
+ - First search for relevant content, then call: `generate_podcast(source_content="Based on our conversation and search results: [detailed summary of chat + search findings]", podcast_title="AI Trends Podcast")`
+
+- User: "Create a podcast summary of this conversation"
+ - Call: `generate_podcast(source_content="Complete conversation summary:\n\nUser asked about [topic 1]:\n[Your detailed response]\n\nUser then asked about [topic 2]:\n[Your detailed response]\n\n[Continue for all exchanges in the conversation]", podcast_title="Conversation Summary")`
+
+- User: "Make a podcast about quantum computing"
+ - First search: `search_knowledge_base(query="quantum computing")`
+ - Then: `generate_podcast(source_content="Key insights about quantum computing from the knowledge base:\n\n[Comprehensive summary of all relevant search results with key facts, concepts, and findings]", podcast_title="Quantum Computing Explained")`
{citation_section}
"""
diff --git a/surfsense_backend/app/routes/podcasts_routes.py b/surfsense_backend/app/routes/podcasts_routes.py
index deb9d9744..904de20a3 100644
--- a/surfsense_backend/app/routes/podcasts_routes.py
+++ b/surfsense_backend/app/routes/podcasts_routes.py
@@ -444,3 +444,66 @@ async def get_podcast_by_chat_id(
raise HTTPException(
status_code=500, detail=f"Error fetching podcast: {e!s}"
) from e
+
+
+@router.get("/podcasts/task/{task_id}/status")
+async def get_podcast_task_status(
+ task_id: str,
+ user: User = Depends(current_active_user),
+):
+ """
+ Get the status of a podcast generation task.
+ Used by new-chat frontend to poll for completion.
+
+ Returns:
+ - status: "processing" | "success" | "error"
+ - podcast_id: (only if status == "success")
+ - title: (only if status == "success")
+ - error: (only if status == "error")
+ """
+ try:
+ from celery.result import AsyncResult
+
+ from app.celery_app import celery_app
+
+ result = AsyncResult(task_id, app=celery_app)
+
+ if result.ready():
+ # Task completed
+ if result.successful():
+ task_result = result.result
+ if isinstance(task_result, dict):
+ if task_result.get("status") == "success":
+ return {
+ "status": "success",
+ "podcast_id": task_result.get("podcast_id"),
+ "title": task_result.get("title"),
+ "transcript_entries": task_result.get("transcript_entries"),
+ }
+ else:
+ return {
+ "status": "error",
+ "error": task_result.get("error", "Unknown error"),
+ }
+ else:
+ return {
+ "status": "error",
+ "error": "Unexpected task result format",
+ }
+ else:
+ # Task failed
+ return {
+ "status": "error",
+ "error": str(result.result) if result.result else "Task failed",
+ }
+ else:
+ # Task still processing
+ return {
+ "status": "processing",
+ "state": result.state,
+ }
+
+ except Exception as e:
+ raise HTTPException(
+ status_code=500, detail=f"Error checking task status: {e!s}"
+ ) from e
diff --git a/surfsense_backend/app/tasks/celery_tasks/podcast_tasks.py b/surfsense_backend/app/tasks/celery_tasks/podcast_tasks.py
index 65cdb886b..1abfba193 100644
--- a/surfsense_backend/app/tasks/celery_tasks/podcast_tasks.py
+++ b/surfsense_backend/app/tasks/celery_tasks/podcast_tasks.py
@@ -11,6 +11,11 @@ from app.celery_app import celery_app
from app.config import config
from app.tasks.podcast_tasks import generate_chat_podcast
+# Import for content-based podcast (new-chat)
+from app.agents.podcaster.graph import graph as podcaster_graph
+from app.agents.podcaster.state import State as PodcasterState
+from app.db import Podcast
+
logger = logging.getLogger(__name__)
if sys.platform.startswith("win"):
@@ -86,3 +91,149 @@ async def _generate_chat_podcast(
except Exception as e:
logger.error(f"Error generating podcast from chat: {e!s}")
raise
+
+
+# =============================================================================
+# Content-based podcast generation (for new-chat)
+# =============================================================================
+
+
+def _clear_active_podcast_redis_key(search_space_id: int) -> None:
+ """Clear the active podcast task key from Redis when task completes."""
+ import os
+
+ import redis
+
+ try:
+ redis_url = os.getenv("CELERY_BROKER_URL", "redis://localhost:6379/0")
+ client = redis.from_url(redis_url, decode_responses=True)
+ key = f"podcast:active:{search_space_id}"
+ client.delete(key)
+ logger.info(f"Cleared active podcast key for search_space_id={search_space_id}")
+ except Exception as e:
+ logger.warning(f"Could not clear active podcast key: {e}")
+
+
+@celery_app.task(name="generate_content_podcast", bind=True)
+def generate_content_podcast_task(
+ self,
+ source_content: str,
+ search_space_id: int,
+ user_id: str,
+ podcast_title: str = "SurfSense Podcast",
+ user_prompt: str | None = None,
+) -> dict:
+ """
+ Celery task to generate podcast from source content (for new-chat).
+
+ Unlike generate_chat_podcast which requires a chat_id, this task
+ generates a podcast directly from provided content.
+
+ Args:
+ source_content: The text content to convert into a podcast
+ search_space_id: ID of the search space
+ user_id: ID of the user (as string)
+ podcast_title: Title for the podcast
+ user_prompt: Optional instructions for podcast style/tone
+
+ Returns:
+ dict with podcast_id on success, or error info on failure
+ """
+ loop = asyncio.new_event_loop()
+ asyncio.set_event_loop(loop)
+
+ try:
+ result = loop.run_until_complete(
+ _generate_content_podcast(
+ source_content,
+ search_space_id,
+ user_id,
+ podcast_title,
+ user_prompt,
+ )
+ )
+ loop.run_until_complete(loop.shutdown_asyncgens())
+ return result
+ except Exception as e:
+ logger.error(f"Error generating content podcast: {e!s}")
+ return {"status": "error", "error": str(e)}
+ finally:
+ # Always clear the active podcast key when task completes (success or failure)
+ _clear_active_podcast_redis_key(search_space_id)
+ asyncio.set_event_loop(None)
+ loop.close()
+
+
+async def _generate_content_podcast(
+ source_content: str,
+ search_space_id: int,
+ user_id: str,
+ podcast_title: str = "SurfSense Podcast",
+ user_prompt: str | None = None,
+) -> dict:
+ """Generate content-based podcast with new session."""
+ async with get_celery_session_maker()() as session:
+ try:
+ # Configure the podcaster graph
+ graph_config = {
+ "configurable": {
+ "podcast_title": podcast_title,
+ "user_id": str(user_id),
+ "search_space_id": search_space_id,
+ "user_prompt": user_prompt,
+ }
+ }
+
+ # Initialize the podcaster state with the source content
+ initial_state = PodcasterState(
+ source_content=source_content,
+ db_session=session,
+ )
+
+ # Run the podcaster graph
+ result = await podcaster_graph.ainvoke(initial_state, config=graph_config)
+
+ # Extract results
+ podcast_transcript = result.get("podcast_transcript", [])
+ file_path = result.get("final_podcast_file_path", "")
+
+ # Convert transcript to serializable format
+ serializable_transcript = []
+ for entry in podcast_transcript:
+ if hasattr(entry, "speaker_id"):
+ serializable_transcript.append({
+ "speaker_id": entry.speaker_id,
+ "dialog": entry.dialog
+ })
+ else:
+ serializable_transcript.append({
+ "speaker_id": entry.get("speaker_id", 0),
+ "dialog": entry.get("dialog", "")
+ })
+
+ # Save podcast to database
+ podcast = Podcast(
+ title=podcast_title,
+ podcast_transcript=serializable_transcript,
+ file_location=file_path,
+ search_space_id=search_space_id,
+ chat_id=None, # No chat_id for new-chat podcasts
+ chat_state_version=None,
+ )
+ session.add(podcast)
+ await session.commit()
+ await session.refresh(podcast)
+
+ logger.info(f"Successfully generated content podcast: {podcast.id}")
+
+ return {
+ "status": "success",
+ "podcast_id": podcast.id,
+ "title": podcast_title,
+ "transcript_entries": len(serializable_transcript),
+ }
+
+ except Exception as e:
+ logger.error(f"Error in _generate_content_podcast: {e!s}")
+ await session.rollback()
+ raise
diff --git a/surfsense_backend/app/tasks/chat/stream_new_chat.py b/surfsense_backend/app/tasks/chat/stream_new_chat.py
index 40f0176d4..159ca9c9a 100644
--- a/surfsense_backend/app/tasks/chat/stream_new_chat.py
+++ b/surfsense_backend/app/tasks/chat/stream_new_chat.py
@@ -5,6 +5,7 @@ This module streams responses from the deep agent using the Vercel AI SDK
Data Stream Protocol (SSE format).
"""
+import json
from collections.abc import AsyncGenerator
from uuid import UUID
@@ -78,13 +79,15 @@ async def stream_new_chat(
# Get the PostgreSQL checkpointer for persistent conversation memory
checkpointer = await get_checkpointer()
- # Create the deep agent with checkpointer
+ # Create the deep agent with checkpointer with podcast capability
agent = create_surfsense_deep_agent(
llm=llm,
search_space_id=search_space_id,
db_session=session,
connector_service=connector_service,
checkpointer=checkpointer,
+ user_id=str(user_id),
+ enable_podcast=True,
)
# Build input with message history from frontend
@@ -182,22 +185,72 @@ async def stream_new_chat(
f"Searching knowledge base: {query[:100]}{'...' if len(query) > 100 else ''}",
"info",
)
+ elif tool_name == "generate_podcast":
+ title = (
+ tool_input.get("podcast_title", "SurfSense Podcast")
+ if isinstance(tool_input, dict)
+ else "SurfSense Podcast"
+ )
+ yield streaming_service.format_terminal_info(
+ f"Generating podcast: {title}",
+ "info",
+ )
elif event_type == "on_tool_end":
run_id = event.get("run_id", "")
- tool_output = event.get("data", {}).get("output", "")
+ tool_name = event.get("name", "unknown_tool")
+ raw_output = event.get("data", {}).get("output", "")
+
+ # Extract content from ToolMessage if needed
+ # LangGraph may return a ToolMessage object instead of raw dict
+ if hasattr(raw_output, "content"):
+ # It's a ToolMessage object - extract the content
+ content = raw_output.content
+ # If content is a string that looks like JSON, try to parse it
+ if isinstance(content, str):
+ try:
+ tool_output = json.loads(content)
+ except (json.JSONDecodeError, TypeError):
+ tool_output = {"result": content}
+ elif isinstance(content, dict):
+ tool_output = content
+ else:
+ tool_output = {"result": str(content)}
+ elif isinstance(raw_output, dict):
+ tool_output = raw_output
+ else:
+ tool_output = {"result": str(raw_output) if raw_output else "completed"}
tool_call_id = f"call_{run_id[:32]}" if run_id else "call_unknown"
- # Don't stream the full output (can be very large), just acknowledge
- yield streaming_service.format_tool_output_available(
- tool_call_id,
- {"status": "completed", "result_length": len(str(tool_output))},
- )
-
- yield streaming_service.format_terminal_info(
- "Knowledge base search completed", "success"
- )
+ # Handle different tool outputs
+ if tool_name == "generate_podcast":
+ # Stream the full podcast result so frontend can render the audio player
+ yield streaming_service.format_tool_output_available(
+ tool_call_id,
+ tool_output if isinstance(tool_output, dict) else {"result": tool_output},
+ )
+ # Send appropriate terminal message based on status
+ if isinstance(tool_output, dict) and tool_output.get("status") == "success":
+ yield streaming_service.format_terminal_info(
+ f"Podcast generated successfully: {tool_output.get('title', 'Podcast')}",
+ "success",
+ )
+ else:
+ error_msg = tool_output.get("error", "Unknown error") if isinstance(tool_output, dict) else "Unknown error"
+ yield streaming_service.format_terminal_info(
+ f"Podcast generation failed: {error_msg}",
+ "error",
+ )
+ else:
+ # Don't stream the full output for other tools (can be very large), just acknowledge
+ yield streaming_service.format_tool_output_available(
+ tool_call_id,
+ {"status": "completed", "result_length": len(str(tool_output))},
+ )
+ yield streaming_service.format_terminal_info(
+ "Knowledge base search completed", "success"
+ )
# Handle chain/agent end to close any open text blocks
elif event_type in ("on_chain_end", "on_agent_end"):
diff --git a/surfsense_web/app/dashboard/[search_space_id]/new-chat/[[...chat_id]]/page.tsx b/surfsense_web/app/dashboard/[search_space_id]/new-chat/[[...chat_id]]/page.tsx
index 86ed4974f..cd28a26fa 100644
--- a/surfsense_web/app/dashboard/[search_space_id]/new-chat/[[...chat_id]]/page.tsx
+++ b/surfsense_web/app/dashboard/[search_space_id]/new-chat/[[...chat_id]]/page.tsx
@@ -4,6 +4,7 @@ import { AssistantRuntimeProvider, useLocalRuntime } from "@assistant-ui/react";
import { useParams } from "next/navigation";
import { useMemo } from "react";
import { Thread } from "@/components/assistant-ui/thread";
+import { GeneratePodcastToolUI } from "@/components/tool-ui/generate-podcast";
import { createNewChatAdapter } from "@/lib/chat/new-chat-transport";
export default function NewChatPage() {
@@ -38,6 +39,8 @@ export default function NewChatPage() {
return (
+ {/* Register tool UI components */}
+