"use client"; import { makeAssistantToolUI } from "@assistant-ui/react"; import { AlertCircleIcon, MicIcon } from "lucide-react"; import { useParams, usePathname } from "next/navigation"; import { useCallback, useEffect, useRef, useState } from "react"; import { z } from "zod"; import { Audio } from "@/components/tool-ui/audio"; import { Spinner } from "@/components/ui/spinner"; import { baseApiService } from "@/lib/apis/base-api.service"; import { authenticatedFetch } from "@/lib/auth-utils"; import { clearActivePodcastTaskId, setActivePodcastTaskId } from "@/lib/chat/podcast-state"; /** * Zod schemas for runtime validation */ const GeneratePodcastArgsSchema = z.object({ source_content: z.string(), podcast_title: z.string().nullish(), user_prompt: z.string().nullish(), }); const GeneratePodcastResultSchema = z.object({ // Support both old and new status values for backwards compatibility status: z.enum([ "pending", "generating", "ready", "failed", // Legacy values from old saved chats "processing", "already_generating", "success", "error", ]), podcast_id: z.number().nullish(), task_id: z.string().nullish(), // Legacy field for old saved chats title: z.string().nullish(), transcript_entries: z.number().nullish(), message: z.string().nullish(), error: z.string().nullish(), }); const PodcastStatusResponseSchema = z.object({ status: z.enum(["pending", "generating", "ready", "failed"]), id: z.number(), title: z.string(), transcript_entries: z.number().nullish(), error: z.string().nullish(), }); const PodcastTranscriptEntrySchema = z.object({ speaker_id: z.number(), dialog: z.string(), }); const PodcastDetailsSchema = z.object({ podcast_transcript: z.array(PodcastTranscriptEntrySchema).nullish(), }); /** * Types derived from Zod schemas */ type GeneratePodcastArgs = z.infer; type GeneratePodcastResult = z.infer; type PodcastStatusResponse = z.infer; type PodcastTranscriptEntry = z.infer; /** * Parse and validate podcast status response */ function parsePodcastStatusResponse(data: unknown): PodcastStatusResponse | null { const result = PodcastStatusResponseSchema.safeParse(data); if (!result.success) { console.warn("Invalid podcast status response:", result.error.issues); return null; } return result.data; } /** * Parse and validate podcast details */ function parsePodcastDetails(data: unknown): { podcast_transcript?: PodcastTranscriptEntry[] } { const result = PodcastDetailsSchema.safeParse(data); if (!result.success) { console.warn("Invalid podcast details:", result.error.issues); return {}; } return { podcast_transcript: result.data.podcast_transcript ?? undefined, }; } /** * Loading state component shown while podcast is being generated */ function PodcastGeneratingState({ title }: { title: string }) { return (
{/* Animated rings */}

{title}

Generating podcast. This may take a few minutes.
); } /** * Error state component shown when podcast generation fails */ function PodcastErrorState({ title, error }: { title: string; error: string }) { return (

{title}

Failed to generate podcast

{error}

); } /** * Audio loading state component */ function AudioLoadingState({ title }: { title: string }) { return (

{title}

Loading audio...
); } function PodcastPlayer({ podcastId, title, description, durationMs, }: { podcastId: number; title: string; description: string; durationMs?: number; }) { const params = useParams(); const pathname = usePathname(); const isPublicRoute = pathname?.startsWith("/public/"); const shareToken = isPublicRoute && typeof params?.token === "string" ? params.token : null; const [audioSrc, setAudioSrc] = useState(null); const [transcript, setTranscript] = useState(null); const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState(null); const objectUrlRef = useRef(null); // Cleanup object URL on unmount useEffect(() => { return () => { if (objectUrlRef.current) { URL.revokeObjectURL(objectUrlRef.current); } }; }, []); // Fetch audio and podcast details (including transcript) const loadPodcast = useCallback(async () => { setIsLoading(true); setError(null); try { // Revoke previous object URL if exists if (objectUrlRef.current) { URL.revokeObjectURL(objectUrlRef.current); objectUrlRef.current = null; } const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), 60000); // 60s timeout try { let audioBlob: Blob; let rawPodcastDetails: unknown = null; if (shareToken) { // Public view - use public endpoints (baseApiService handles no-auth for /api/v1/public/) const [blob, details] = await Promise.all([ baseApiService.getBlob(`/api/v1/public/${shareToken}/podcasts/${podcastId}/stream`), baseApiService.get(`/api/v1/public/${shareToken}/podcasts/${podcastId}`), ]); audioBlob = blob; rawPodcastDetails = details; } else { // Authenticated view - fetch audio and details in parallel const [audioResponse, details] = await Promise.all([ authenticatedFetch( `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/podcasts/${podcastId}/audio`, { method: "GET", signal: controller.signal } ), baseApiService.get(`/api/v1/podcasts/${podcastId}`), ]); if (!audioResponse.ok) { throw new Error(`Failed to load audio: ${audioResponse.status}`); } audioBlob = await audioResponse.blob(); rawPodcastDetails = details; } // Create object URL from blob const objectUrl = URL.createObjectURL(audioBlob); objectUrlRef.current = objectUrl; setAudioSrc(objectUrl); // Parse and validate podcast details, then set transcript if (rawPodcastDetails) { const podcastDetails = parsePodcastDetails(rawPodcastDetails); if (podcastDetails.podcast_transcript) { setTranscript(podcastDetails.podcast_transcript); } } } finally { clearTimeout(timeoutId); } } catch (err) { console.error("Error loading podcast:", err); if (err instanceof DOMException && err.name === "AbortError") { setError("Request timed out. Please try again."); } else { setError(err instanceof Error ? err.message : "Failed to load podcast"); } } finally { setIsLoading(false); } }, [podcastId, shareToken]); // Load podcast when component mounts useEffect(() => { loadPodcast(); }, [loadPodcast]); if (isLoading) { return ; } if (error || !audioSrc) { return ; } return (
); } /** * Polling component that checks podcast status and shows player when ready */ function PodcastStatusPoller({ podcastId, title }: { podcastId: number; title: string }) { const [podcastStatus, setPodcastStatus] = useState(null); const pollingRef = useRef(null); // Set active podcast state when this component mounts useEffect(() => { setActivePodcastTaskId(String(podcastId)); // Clear when component unmounts return () => { clearActivePodcastTaskId(); }; }, [podcastId]); // Poll for podcast status useEffect(() => { const pollStatus = async () => { try { const rawResponse = await baseApiService.get(`/api/v1/podcasts/${podcastId}`); const response = parsePodcastStatusResponse(rawResponse); if (response) { setPodcastStatus(response); // Stop polling if podcast is ready or failed if (response.status === "ready" || response.status === "failed") { if (pollingRef.current) { clearInterval(pollingRef.current); pollingRef.current = null; } clearActivePodcastTaskId(); } } } catch (err) { console.error("Error polling podcast status:", err); // Don't stop polling on network errors, continue polling } }; // Initial poll pollStatus(); // Poll every 5 seconds pollingRef.current = setInterval(pollStatus, 5000); return () => { if (pollingRef.current) { clearInterval(pollingRef.current); } }; }, [podcastId]); // Show loading state while pending or generating if ( !podcastStatus || podcastStatus.status === "pending" || podcastStatus.status === "generating" ) { return ; } // Show error state if (podcastStatus.status === "failed") { return ; } // Show player when ready if (podcastStatus.status === "ready") { return ( ); } // Fallback return ; } /** * Generate Podcast Tool UI Component * * This component is registered with assistant-ui to render custom UI * when the generate_podcast tool is called by the agent. * * It polls for task completion and auto-updates when the podcast is ready. */ export const GeneratePodcastToolUI = makeAssistantToolUI< GeneratePodcastArgs, GeneratePodcastResult >({ toolName: "generate_podcast", render: function GeneratePodcastUI({ args, result, status }) { const title = args.podcast_title || "SurfSense Podcast"; // Loading state - tool is still running (agent processing) if (status.type === "running" || status.type === "requires-action") { return ; } // Incomplete/cancelled state if (status.type === "incomplete") { if (status.reason === "cancelled") { return (

Podcast generation cancelled

); } if (status.reason === "error") { return ( ); } } // No result yet if (!result) { return ; } // Failed result (new: "failed", legacy: "error") if (result.status === "failed" || result.status === "error") { return ; } // Already generating - show simple warning, don't create another poller // The FIRST tool call will display the podcast when ready // (new: "generating", legacy: "already_generating") if (result.status === "generating" || result.status === "already_generating") { return (

Podcast already in progress

Please wait for the current podcast to complete.

); } // Pending - poll for completion (new: "pending" with podcast_id) if (result.status === "pending" && result.podcast_id) { return ; } // Ready with podcast_id (new: "ready", legacy: "success") if ((result.status === "ready" || result.status === "success") && result.podcast_id) { return ( ); } // Legacy: old chats with Celery task_id (status: "processing" or "success" without podcast_id) // These can't be recovered since the old task polling endpoint no longer exists if (result.task_id && !result.podcast_id) { return (

This podcast was generated with an older version and cannot be displayed.

Please generate a new podcast to listen.

); } // Fallback - missing required data return ; }, });