"use client"; import { makeAssistantToolUI } from "@assistant-ui/react"; import { AlertCircleIcon, Loader2Icon, MicIcon } from "lucide-react"; import { useCallback, useEffect, useRef, useState } from "react"; import { z } from "zod"; import { Audio } from "@/components/tool-ui/audio"; 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({ status: z.enum(["processing", "already_generating", "success", "error"]), task_id: z.string().nullish(), podcast_id: z.number().nullish(), title: z.string().nullish(), transcript_entries: z.number().nullish(), message: z.string().nullish(), error: z.string().nullish(), }); const TaskStatusResponseSchema = z.object({ status: z.enum(["processing", "success", "error"]), podcast_id: z.number().nullish(), title: z.string().nullish(), transcript_entries: z.number().nullish(), state: z.string().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 TaskStatusResponse = z.infer; type PodcastTranscriptEntry = z.infer; /** * Parse and validate task status response */ function parseTaskStatusResponse(data: unknown): TaskStatusResponse { const result = TaskStatusResponseSchema.safeParse(data); if (!result.success) { console.warn("Invalid task status response:", result.error.issues); return { status: "error", error: "Invalid response from server" }; } 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...
); } /** * Podcast Player Component - Fetches audio and transcript with authentication */ function PodcastPlayer({ podcastId, title, description, durationMs, }: { podcastId: number; title: string; description: string; durationMs?: number; }) { 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 { // Fetch audio blob and podcast details in parallel const [audioResponse, rawPodcastDetails] = 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}`); } const audioBlob = await audioResponse.blob(); // Create object URL from blob const objectUrl = URL.createObjectURL(audioBlob); objectUrlRef.current = objectUrl; setAudioSrc(objectUrl); // Parse and validate podcast details, then set transcript 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]); // Load podcast when component mounts useEffect(() => { loadPodcast(); }, [loadPodcast]); if (isLoading) { return ; } if (error || !audioSrc) { return ; } return (
); } /** * Polling component that checks task status and shows player when complete */ function PodcastTaskPoller({ taskId, title }: { taskId: string; title: string }) { const [taskStatus, setTaskStatus] = useState({ status: "processing" }); const pollingRef = useRef(null); // Set active podcast state when this component mounts useEffect(() => { setActivePodcastTaskId(taskId); // Clear when component unmounts return () => { // Only clear if this task is still the active one clearActivePodcastTaskId(); }; }, [taskId]); // Poll for task status useEffect(() => { const pollStatus = async () => { try { const rawResponse = await baseApiService.get( `/api/v1/podcasts/task/${taskId}/status` ); const response = parseTaskStatusResponse(rawResponse); setTaskStatus(response); // Stop polling if task is complete or errored if (response.status !== "processing") { if (pollingRef.current) { clearInterval(pollingRef.current); pollingRef.current = null; } // Clear the active podcast state when task completes clearActivePodcastTaskId(); } } catch (err) { console.error("Error polling task 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); } }; }, [taskId]); // Show loading state while processing if (taskStatus.status === "processing") { return ; } // Show error state if (taskStatus.status === "error") { return ; } // Show player when complete if (taskStatus.status === "success" && taskStatus.podcast_id) { 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 ; } // Error result if (result.status === "error") { return ; } // Already generating - show simple warning, don't create another poller // The FIRST tool call will display the podcast when ready if (result.status === "already_generating") { return (

Podcast already in progress

Please wait for the current podcast to complete.

); } // Processing - poll for completion if (result.status === "processing" && result.task_id) { return ; } // Success with podcast_id (direct result, not via polling) if (result.status === "success" && result.podcast_id) { return ( ); } // Fallback - missing required data return ; }, });