diff --git a/surfsense_web/components/assistant-ui/assistant-message.tsx b/surfsense_web/components/assistant-ui/assistant-message.tsx index fd24600c2..d084ac0fd 100644 --- a/surfsense_web/components/assistant-ui/assistant-message.tsx +++ b/surfsense_web/components/assistant-ui/assistant-message.tsx @@ -84,7 +84,7 @@ const GenerateResumeToolUI = dynamic( ); const GeneratePodcastToolUI = dynamic( () => - import("@/components/tool-ui/generate-podcast").then((m) => ({ + import("@/components/tool-ui/podcast").then((m) => ({ default: m.GeneratePodcastToolUI, })), { ssr: false } diff --git a/surfsense_web/components/public-chat/public-thread.tsx b/surfsense_web/components/public-chat/public-thread.tsx index d35193cbe..083cc5e35 100644 --- a/surfsense_web/components/public-chat/public-thread.tsx +++ b/surfsense_web/components/public-chat/public-thread.tsx @@ -17,9 +17,9 @@ import { MarkdownText } from "@/components/assistant-ui/markdown-text"; import { ReasoningMessagePart } from "@/components/assistant-ui/reasoning-message-part"; import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button"; import { GenerateImageToolUI } from "@/components/tool-ui/generate-image"; -import { GeneratePodcastToolUI } from "@/components/tool-ui/generate-podcast"; import { GenerateReportToolUI } from "@/components/tool-ui/generate-report"; import { GenerateResumeToolUI } from "@/components/tool-ui/generate-resume"; +import { GeneratePodcastToolUI } from "@/components/tool-ui/podcast"; const GenerateVideoPresentationToolUI = dynamic( () => diff --git a/surfsense_web/components/tool-ui/generate-podcast.tsx b/surfsense_web/components/tool-ui/generate-podcast.tsx deleted file mode 100644 index 2a62785e8..000000000 --- a/surfsense_web/components/tool-ui/generate-podcast.tsx +++ /dev/null @@ -1,468 +0,0 @@ -"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"; -import { BACKEND_URL } from "@/lib/env-config"; - -/** - * 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(`${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 ( -
-