mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-06-12 20:45:20 +02:00
refactor(podcasts): replace polling tool ui with lifecycle card
This commit is contained in:
parent
6f6c056404
commit
1f9fd61c9e
4 changed files with 3 additions and 471 deletions
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
() =>
|
||||
|
|
|
|||
|
|
@ -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<typeof GeneratePodcastArgsSchema>;
|
||||
type GeneratePodcastResult = z.infer<typeof GeneratePodcastResultSchema>;
|
||||
type PodcastStatusResponse = z.infer<typeof PodcastStatusResponseSchema>;
|
||||
type PodcastTranscriptEntry = z.infer<typeof PodcastTranscriptEntrySchema>;
|
||||
|
||||
/**
|
||||
* 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 (
|
||||
<div className="my-4 max-w-lg overflow-hidden rounded-2xl border bg-muted/30 select-none">
|
||||
<div className="px-5 pt-5 pb-4">
|
||||
<p className="text-sm font-semibold text-foreground line-clamp-2">{title}</p>
|
||||
<TextShimmerLoader text="Generating podcast" size="sm" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PodcastErrorState({ title, error }: { title: string; error: string }) {
|
||||
return (
|
||||
<div className="my-4 max-w-lg overflow-hidden rounded-2xl border bg-muted/30 select-none">
|
||||
<div className="px-5 pt-5 pb-4">
|
||||
<p className="text-sm font-semibold text-destructive">Podcast Generation Failed</p>
|
||||
</div>
|
||||
<div className="mx-5 h-px bg-border/50" />
|
||||
<div className="px-5 py-4">
|
||||
<p className="text-sm font-medium text-foreground line-clamp-2">{title}</p>
|
||||
<p className="text-sm text-muted-foreground mt-1">{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AudioLoadingState({ title }: { title: string }) {
|
||||
return (
|
||||
<div className="my-4 max-w-lg overflow-hidden rounded-2xl border bg-muted/30 select-none">
|
||||
<div className="px-5 pt-5 pb-4">
|
||||
<p className="text-sm font-semibold text-foreground line-clamp-2">{title}</p>
|
||||
<TextShimmerLoader text="Loading audio" size="sm" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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<string | null>(null);
|
||||
const [transcript, setTranscript] = useState<PodcastTranscriptEntry[] | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const objectUrlRef = useRef<string | null>(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<unknown>(`/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 <AudioLoadingState title={title} />;
|
||||
}
|
||||
|
||||
if (error || !audioSrc) {
|
||||
return <PodcastErrorState title={title} error={error || "Failed to load audio"} />;
|
||||
}
|
||||
|
||||
const hasTranscript = transcript && transcript.length > 0;
|
||||
|
||||
return (
|
||||
<div className="my-4">
|
||||
<Audio
|
||||
id={`podcast-${podcastId}`}
|
||||
src={audioSrc}
|
||||
title={title}
|
||||
durationMs={durationMs}
|
||||
className={hasTranscript ? "rounded-b-none border-b-0" : undefined}
|
||||
/>
|
||||
{hasTranscript && (
|
||||
<div className="max-w-lg overflow-hidden rounded-b-2xl border border-t-0 bg-muted/30 select-none">
|
||||
<div className="mx-5 h-px bg-border/50" />
|
||||
<Accordion type="single" collapsible className="px-5">
|
||||
<AccordionItem value="transcript" className="border-b-0">
|
||||
<AccordionTrigger className="py-3 text-xs sm:text-sm font-medium text-muted-foreground hover:text-accent-foreground hover:no-underline">
|
||||
View transcript
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="pb-0">
|
||||
<div className="space-y-2 max-h-64 sm:max-h-96 overflow-y-auto select-text">
|
||||
{transcript.map((entry, idx) => (
|
||||
<div key={`${idx}-${entry.speaker_id}`} className="text-xs sm:text-sm">
|
||||
<span className="font-medium text-primary">
|
||||
Speaker {entry.speaker_id + 1}:
|
||||
</span>{" "}
|
||||
<span className="text-muted-foreground">{entry.dialog}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Polling component that checks podcast status and shows player when ready
|
||||
*/
|
||||
function PodcastStatusPoller({ podcastId, title }: { podcastId: number; title: string }) {
|
||||
const [podcastStatus, setPodcastStatus] = useState<PodcastStatusResponse | null>(null);
|
||||
const pollingRef = useRef<NodeJS.Timeout | null>(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<unknown>(`/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 <PodcastGeneratingState title={title} />;
|
||||
}
|
||||
|
||||
// Show error state
|
||||
if (podcastStatus.status === "failed") {
|
||||
return <PodcastErrorState title={title} error={podcastStatus.error || "Generation failed"} />;
|
||||
}
|
||||
|
||||
// Show player when ready
|
||||
if (podcastStatus.status === "ready") {
|
||||
return <PodcastPlayer podcastId={podcastStatus.id} title={podcastStatus.title || title} />;
|
||||
}
|
||||
|
||||
// Fallback
|
||||
return <PodcastErrorState title={title} error="Unexpected state" />;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 = ({
|
||||
args,
|
||||
result,
|
||||
status,
|
||||
}: ToolCallMessagePartProps<GeneratePodcastArgs, GeneratePodcastResult>) => {
|
||||
const title = args.podcast_title || "SurfSense Podcast";
|
||||
|
||||
// Loading state - tool is still running (agent processing)
|
||||
if (status.type === "running" || status.type === "requires-action") {
|
||||
return <PodcastGeneratingState title={title} />;
|
||||
}
|
||||
|
||||
// Incomplete/cancelled state
|
||||
if (status.type === "incomplete") {
|
||||
if (status.reason === "cancelled") {
|
||||
return (
|
||||
<div className="my-4 max-w-lg overflow-hidden rounded-2xl border bg-muted/30 select-none">
|
||||
<div className="px-5 pt-5 pb-4">
|
||||
<p className="text-sm font-semibold text-muted-foreground">Podcast Cancelled</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">Podcast generation was cancelled</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (status.reason === "error") {
|
||||
return (
|
||||
<PodcastErrorState
|
||||
title={title}
|
||||
error={typeof status.error === "string" ? status.error : "An error occurred"}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// No result yet
|
||||
if (!result) {
|
||||
return <PodcastGeneratingState title={title} />;
|
||||
}
|
||||
|
||||
// Failed result (new: "failed", legacy: "error")
|
||||
if (result.status === "failed" || result.status === "error") {
|
||||
return <PodcastErrorState title={title} error={result.error || "Generation failed"} />;
|
||||
}
|
||||
|
||||
// Pending/generating rows have a stable podcast_id, so the card can poll
|
||||
// independently while the chat stream finishes.
|
||||
if (
|
||||
(result.status === "pending" ||
|
||||
result.status === "generating" ||
|
||||
result.status === "processing") &&
|
||||
result.podcast_id
|
||||
) {
|
||||
return <PodcastStatusPoller podcastId={result.podcast_id} title={result.title || title} />;
|
||||
}
|
||||
|
||||
// Legacy duplicate/no-ID result - show a simple warning, don't create
|
||||
// another poller. The first tool call will display the podcast when ready.
|
||||
if (result.status === "generating" || result.status === "already_generating") {
|
||||
return (
|
||||
<div className="my-4 max-w-lg overflow-hidden rounded-2xl border bg-muted/30 select-none">
|
||||
<div className="px-5 pt-5 pb-4">
|
||||
<p className="text-sm font-semibold text-foreground">Podcast already in progress</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
Please wait for the current podcast to complete.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Ready with podcast_id (new: "ready", legacy: "success")
|
||||
if ((result.status === "ready" || result.status === "success") && result.podcast_id) {
|
||||
return <PodcastPlayer podcastId={result.podcast_id} title={result.title || title} />;
|
||||
}
|
||||
|
||||
// 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 (
|
||||
<div className="my-4 max-w-lg overflow-hidden rounded-2xl border bg-muted/30 select-none">
|
||||
<div className="px-5 pt-5 pb-4">
|
||||
<p className="text-sm font-semibold text-muted-foreground">Podcast Unavailable</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
This podcast was generated with an older version. Please generate a new one.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Fallback - missing required data
|
||||
return <PodcastErrorState title={title} error="Missing podcast ID" />;
|
||||
};
|
||||
|
|
@ -16,7 +16,6 @@ export {
|
|||
GenerateImageResultSchema,
|
||||
GenerateImageToolUI,
|
||||
} from "./generate-image";
|
||||
export { GeneratePodcastToolUI } from "./generate-podcast";
|
||||
export { GenerateReportToolUI } from "./generate-report";
|
||||
export { CreateGoogleDriveFileToolUI, DeleteGoogleDriveFileToolUI } from "./google-drive";
|
||||
export {
|
||||
|
|
@ -44,6 +43,7 @@ export {
|
|||
type SerializablePlan,
|
||||
type TodoStatus,
|
||||
} from "./plan";
|
||||
export { GeneratePodcastToolUI } from "./podcast";
|
||||
export {
|
||||
type ExecuteArgs,
|
||||
ExecuteArgsSchema,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue