"use client"; import type { ToolCallMessagePartProps } from "@assistant-ui/react"; import { useParams, usePathname } from "next/navigation"; import { useCallback, useEffect, useRef, useState } from "react"; import { z } from "zod"; import { TextShimmerLoader } from "@/components/prompt-kit/loader"; import { Audio } from "@/components/tool-ui/audio"; import { Accordion, AccordionContent, AccordionItem, AccordionTrigger, } from "@/components/ui/accordion"; 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, }; } function PodcastGeneratingState({ title }: { title: string }) { return (

{title}

); } function PodcastErrorState({ title, error }: { title: string; error: string }) { return (

Podcast Generation Failed

{title}

{error}

); } function AudioLoadingState({ title }: { title: string }) { return (

{title}

); } function PodcastPlayer({ podcastId, title, durationMs, }: { podcastId: number; title: 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 ; } const hasTranscript = transcript && transcript.length > 0; return (