refactor(podcasts): drop transcript gate, add regenerate-from-ready and voice previews

This commit is contained in:
CREDO23 2026-06-11 10:42:13 +02:00
parent ccd8209d12
commit 11a6b178a0
22 changed files with 591 additions and 347 deletions

View file

@ -26,6 +26,7 @@ import {
import type { LivePodcast } from "@/hooks/use-podcast-live";
import { podcastsApiService } from "@/lib/apis/podcasts-api.service";
import { AppError } from "@/lib/error";
import { VoicePreviewButton } from "./voice-preview-button";
// A "*" voice speaks whatever language the text is in (mirrors the backend
// catalog's ANY_LANGUAGE sentinel).
@ -274,23 +275,26 @@ export function BriefReview({ podcast, spec, onApproved }: BriefReviewProps) {
</SelectContent>
</Select>
</div>
<div className="flex w-44 flex-col gap-1.5">
<div className="flex w-52 flex-col gap-1.5">
<Label className="text-xs">Voice</Label>
<Select
value={speaker.voice_id}
onValueChange={(value) => updateSpeaker(speaker.slot, { voice_id: value })}
>
<SelectTrigger>
<SelectValue placeholder={voices === null ? "Loading…" : "Voice"} />
</SelectTrigger>
<SelectContent>
{voiceItems(voicesForLanguage, speaker.voice_id).map((voice) => (
<SelectItem key={voice.voice_id} value={voice.voice_id}>
{voice.display_name} ({voice.gender})
</SelectItem>
))}
</SelectContent>
</Select>
<div className="flex items-center gap-1">
<Select
value={speaker.voice_id}
onValueChange={(value) => updateSpeaker(speaker.slot, { voice_id: value })}
>
<SelectTrigger>
<SelectValue placeholder={voices === null ? "Loading…" : "Voice"} />
</SelectTrigger>
<SelectContent>
{voiceItems(voicesForLanguage, speaker.voice_id).map((voice) => (
<SelectItem key={voice.voice_id} value={voice.voice_id}>
{voice.display_name} ({voice.gender})
</SelectItem>
))}
</SelectContent>
</Select>
<VoicePreviewButton voiceId={speaker.voice_id} />
</div>
</div>
<Button
type="button"

View file

@ -1,12 +1,15 @@
"use client";
import type { ToolCallMessagePartProps } from "@assistant-ui/react";
import { Loader2, RotateCcw } from "lucide-react";
import { usePathname } from "next/navigation";
import { useState } from "react";
import { toast } from "sonner";
import { TextShimmerLoader } from "@/components/prompt-kit/loader";
import { Button } from "@/components/ui/button";
import type { PodcastSpec } from "@/contracts/types/podcast.types";
import { usePodcastLive } from "@/hooks/use-podcast-live";
import { type LivePodcast, usePodcastLive } from "@/hooks/use-podcast-live";
import { podcastsApiService } from "@/lib/apis/podcasts-api.service";
import { PodcastErrorState, PodcastPlayer } from "./player";
import { PodcastReviewSheet } from "./review-sheet";
import type { GeneratePodcastArgs, GeneratePodcastResult } from "./schema";
@ -69,6 +72,66 @@ function ReviewGateCard({
);
}
/**
* Regenerating discards the current audio, so a stray click is guarded by an
* inline confirm step.
*/
function RegenerateButton({ podcast }: { podcast: LivePodcast }) {
const [confirming, setConfirming] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
const regenerate = async () => {
setIsSubmitting(true);
try {
await podcastsApiService.regenerate(podcast.id);
} catch (error) {
toast.error(error instanceof Error ? error.message : "Failed to regenerate the podcast");
} finally {
setIsSubmitting(false);
setConfirming(false);
}
};
if (!confirming) {
return (
<Button
type="button"
variant="ghost"
size="sm"
className="text-muted-foreground"
onClick={() => setConfirming(true)}
>
<RotateCcw className="size-3.5" /> Regenerate
</Button>
);
}
return (
<div className="flex items-center gap-2">
<span className="text-xs text-muted-foreground">Replace this episode with a new take?</span>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => setConfirming(false)}
disabled={isSubmitting}
>
Keep it
</Button>
<Button
type="button"
variant="destructive"
size="sm"
onClick={regenerate}
disabled={isSubmitting}
>
{isSubmitting ? <Loader2 className="size-3.5 animate-spin" /> : null}
Regenerate
</Button>
</div>
);
}
/** Status-driven card for an authenticated viewer, fed by Zero push. */
function LivePodcastCard({
podcastId,
@ -102,30 +165,47 @@ function LivePodcastCard({
case "rendering":
return <WorkingState title={title} label="Rendering audio" />;
case "awaiting_brief":
case "awaiting_review": {
const isBriefGate = podcast.status === "awaiting_brief";
return (
<>
<ReviewGateCard
title={title}
heading={
isBriefGate ? "Brief ready for your review" : "Transcript ready for your review"
}
heading="Brief ready for your review"
summary={briefSummary(podcast.spec)}
buttonLabel={isBriefGate ? "Review brief" : "Review transcript"}
buttonLabel="Review brief"
onReview={() => setReviewOpen(true)}
/>
<PodcastReviewSheet podcast={podcast} open={reviewOpen} onOpenChange={setReviewOpen} />
</>
);
}
case "awaiting_review":
// Legacy rows parked at the removed transcript gate; the only way
// forward is a fresh draft.
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>
<p className="text-xs text-muted-foreground mt-0.5">
This podcast was drafted before audio rendering became automatic.
</p>
</div>
<div className="mx-5 h-px bg-border/50" />
<div className="flex justify-end px-5 py-3">
<RegenerateButton podcast={podcast} />
</div>
</div>
);
case "ready":
return (
<PodcastPlayer
podcastId={podcast.id}
title={title}
durationMs={podcast.durationSeconds ? podcast.durationSeconds * 1000 : undefined}
/>
<div>
<PodcastPlayer
podcastId={podcast.id}
title={title}
durationMs={podcast.durationSeconds ? podcast.durationSeconds * 1000 : undefined}
/>
<div className="-mt-2 mb-4 flex max-w-lg justify-end">
<RegenerateButton podcast={podcast} />
</div>
</div>
);
case "failed":
return <PodcastErrorState title={title} error={podcast.error || "Generation failed"} />;

View file

@ -9,7 +9,6 @@ import {
} from "@/components/ui/sheet";
import type { LivePodcast } from "@/hooks/use-podcast-live";
import { BriefReview } from "./brief-review";
import { TranscriptReview } from "./transcript-review";
interface PodcastReviewSheetProps {
podcast: LivePodcast;
@ -18,49 +17,34 @@ interface PodcastReviewSheetProps {
}
/**
* The podcast panel: hosts whichever gate the lifecycle is waiting on. The
* pushed status decides the content, so the same sheet serves both gates and
* simply closes once the podcast moves on.
* The podcast panel: hosts the brief gate, the only approval in the lifecycle
* after it the episode generates unattended.
*/
export function PodcastReviewSheet({ podcast, open, onOpenChange }: PodcastReviewSheetProps) {
const close = () => onOpenChange(false);
const gate =
podcast.status === "awaiting_brief" && podcast.spec ? (
<>
<SheetHeader>
<SheetTitle>Review podcast brief</SheetTitle>
<SheetDescription>
Confirm the language, voices, and length before the transcript is drafted.
</SheetDescription>
</SheetHeader>
<div className="overflow-y-auto px-4 pb-4">
<BriefReview podcast={podcast} spec={podcast.spec} onApproved={close} />
</div>
</>
) : podcast.status === "awaiting_review" ? (
<>
<SheetHeader>
<SheetTitle>Review transcript</SheetTitle>
<SheetDescription>
Approve the script to render the audio, or regenerate a fresh draft.
</SheetDescription>
</SheetHeader>
<div className="min-h-0 flex-1 px-4 pb-4">
<TranscriptReview podcast={podcast} onDecided={close} />
</div>
</>
) : (
<SheetHeader>
<SheetTitle>{podcast.title}</SheetTitle>
<SheetDescription>Nothing is awaiting review right now.</SheetDescription>
</SheetHeader>
);
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent side="right" className="flex w-full flex-col sm:max-w-xl">
{gate}
{podcast.status === "awaiting_brief" && podcast.spec ? (
<>
<SheetHeader>
<SheetTitle>Review podcast brief</SheetTitle>
<SheetDescription>
Confirm the language, voices, and length the episode generates unattended after
this.
</SheetDescription>
</SheetHeader>
<div className="overflow-y-auto px-4 pb-4">
<BriefReview podcast={podcast} spec={podcast.spec} onApproved={close} />
</div>
</>
) : (
<SheetHeader>
<SheetTitle>{podcast.title}</SheetTitle>
<SheetDescription>Nothing is awaiting review right now.</SheetDescription>
</SheetHeader>
)}
</SheetContent>
</Sheet>
);

View file

@ -1,118 +0,0 @@
"use client";
import { Loader2 } from "lucide-react";
import { useEffect, useState } from "react";
import { toast } from "sonner";
import { TextShimmerLoader } from "@/components/prompt-kit/loader";
import { Button } from "@/components/ui/button";
import type { PodcastDetail } from "@/contracts/types/podcast.types";
import type { LivePodcast } from "@/hooks/use-podcast-live";
import { podcastsApiService } from "@/lib/apis/podcasts-api.service";
import { speakerLabel } from "./schema";
interface TranscriptReviewProps {
podcast: LivePodcast;
onDecided: () => void;
}
/**
* Gate 2: a go/no-go on the drafted script before the expensive render.
* Read-only by design approve it, regenerate a fresh draft, or cancel.
*/
export function TranscriptReview({ podcast, onDecided }: TranscriptReviewProps) {
const [detail, setDetail] = useState<PodcastDetail | null>(null);
const [loadError, setLoadError] = useState<string | null>(null);
const [pendingAction, setPendingAction] = useState<"approve" | "regenerate" | "cancel" | null>(
null
);
useEffect(() => {
let cancelled = false;
setDetail(null);
setLoadError(null);
podcastsApiService
.getDetail(podcast.id)
.then((data) => {
if (!cancelled) setDetail(data);
})
.catch((error) => {
if (!cancelled) {
setLoadError(error instanceof Error ? error.message : "Failed to load the transcript");
}
});
return () => {
cancelled = true;
};
}, [podcast.id]);
const act = async (action: "approve" | "regenerate" | "cancel", run: () => Promise<unknown>) => {
setPendingAction(action);
try {
await run();
onDecided();
} catch (error) {
toast.error(error instanceof Error ? error.message : "Action failed");
} finally {
setPendingAction(null);
}
};
if (loadError) {
return <p className="text-sm text-destructive">{loadError}</p>;
}
if (!detail) {
return <TextShimmerLoader text="Loading transcript" size="sm" />;
}
const turns = detail.transcript?.turns ?? [];
return (
<div className="flex h-full flex-col gap-4">
<div className="flex-1 space-y-3 overflow-y-auto rounded-lg border bg-muted/30 p-4 select-text">
{turns.map((turn, idx) => (
<div key={`${idx}-${turn.speaker}`} className="text-sm">
<span className="font-medium text-primary">
{speakerLabel(detail.spec, turn.speaker)}:
</span>{" "}
<span className="text-muted-foreground">{turn.text}</span>
</div>
))}
{turns.length === 0 ? (
<p className="text-sm text-muted-foreground">No transcript available.</p>
) : null}
</div>
<div className="flex justify-end gap-2">
<Button
type="button"
variant="ghost"
disabled={pendingAction !== null}
onClick={() => act("cancel", () => podcastsApiService.cancel(podcast.id))}
>
{pendingAction === "cancel" ? <Loader2 className="size-4 animate-spin" /> : null}
Cancel podcast
</Button>
<Button
type="button"
variant="outline"
disabled={pendingAction !== null}
onClick={() =>
act("regenerate", () => podcastsApiService.regenerateTranscript(podcast.id))
}
>
{pendingAction === "regenerate" ? <Loader2 className="size-4 animate-spin" /> : null}
Regenerate
</Button>
<Button
type="button"
disabled={pendingAction !== null || turns.length === 0}
onClick={() => act("approve", () => podcastsApiService.approveTranscript(podcast.id))}
>
{pendingAction === "approve" ? <Loader2 className="size-4 animate-spin" /> : null}
Approve &amp; render audio
</Button>
</div>
</div>
);
}

View file

@ -0,0 +1,98 @@
"use client";
import { Loader2, Play, Square } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { podcastsApiService } from "@/lib/apis/podcasts-api.service";
// Comparing voices means replaying the same samples, so each voice is fetched
// at most once per page lifetime.
const sampleUrls = new Map<string, Promise<string>>();
// Overlapping samples are useless for comparison, so only one plays at a time.
let activeAudio: HTMLAudioElement | null = null;
let stopActive: (() => void) | null = null;
function getSampleUrl(voiceId: string): Promise<string> {
let url = sampleUrls.get(voiceId);
if (!url) {
url = podcastsApiService.previewVoice(voiceId).then((blob) => URL.createObjectURL(blob));
// A failed fetch must not poison the cache for retries.
url.catch(() => sampleUrls.delete(voiceId));
sampleUrls.set(voiceId, url);
}
return url;
}
/** Plays a short sample of `voiceId` so users pick voices by sound. */
export function VoicePreviewButton({ voiceId }: { voiceId: string }) {
const [state, setState] = useState<"idle" | "loading" | "playing">("idle");
const mountedRef = useRef(true);
useEffect(() => {
mountedRef.current = true;
return () => {
mountedRef.current = false;
if (stopActive && activeAudio?.dataset.voiceId === voiceId) {
stopActive();
}
};
}, [voiceId]);
const stop = () => {
if (stopActive) stopActive();
};
const play = async () => {
stop();
setState("loading");
try {
const url = await getSampleUrl(voiceId);
if (!mountedRef.current) return;
const audio = new Audio(url);
audio.dataset.voiceId = voiceId;
activeAudio = audio;
stopActive = () => {
audio.pause();
activeAudio = null;
stopActive = null;
if (mountedRef.current) setState("idle");
};
audio.onended = () => {
if (activeAudio === audio) {
activeAudio = null;
stopActive = null;
}
if (mountedRef.current) setState("idle");
};
await audio.play();
if (mountedRef.current) setState("playing");
} catch (error) {
if (mountedRef.current) setState("idle");
toast.error(error instanceof Error ? error.message : "Couldn't play the voice sample");
}
};
const isPlaying = state === "playing";
return (
<Button
type="button"
variant="ghost"
size="icon"
aria-label={isPlaying ? "Stop voice sample" : "Play voice sample"}
disabled={state === "loading"}
onClick={isPlaying ? stop : play}
>
{state === "loading" ? (
<Loader2 className="size-4 animate-spin" />
) : isPlaying ? (
<Square className="size-4" />
) : (
<Play className="size-4" />
)}
</Button>
);
}

View file

@ -16,18 +16,18 @@ export const podcastStatus = z.enum([
]);
export type PodcastStatus = z.infer<typeof podcastStatus>;
/** States waiting on user input before the lifecycle can proceed. */
export const GATE_STATUSES: ReadonlySet<PodcastStatus> = new Set([
"awaiting_brief",
"awaiting_review",
]);
/**
* States waiting on user input before the lifecycle can proceed. The brief is
* the only approval gate; `awaiting_review` survives in the enum for legacy
* rows but is never entered anymore.
*/
export const GATE_STATUSES: ReadonlySet<PodcastStatus> = new Set(["awaiting_brief"]);
/** States from which no further transition is possible. */
export const TERMINAL_STATUSES: ReadonlySet<PodcastStatus> = new Set([
"ready",
"failed",
"cancelled",
]);
/**
* States from which no further transition is possible. A `ready` episode is
* not terminal: it can be sent back to drafting for regeneration.
*/
export const TERMINAL_STATUSES: ReadonlySet<PodcastStatus> = new Set(["failed", "cancelled"]);
// =============================================================================
// Brief (spec) — mirror app/podcasts/schemas/spec.py

View file

@ -37,11 +37,8 @@ class PodcastsApiService {
return baseApiService.post(`${BASE}/${podcastId}/brief/approve`, podcastDetail);
};
approveTranscript = async (podcastId: number) => {
return baseApiService.post(`${BASE}/${podcastId}/transcript/approve`, podcastDetail);
};
regenerateTranscript = async (podcastId: number) => {
// Destructive: the current transcript and audio are replaced by a fresh take.
regenerate = async (podcastId: number) => {
return baseApiService.post(`${BASE}/${podcastId}/transcript/regenerate`, podcastDetail);
};
@ -53,6 +50,11 @@ class PodcastsApiService {
const qs = language ? `?${new URLSearchParams({ language })}` : "";
return baseApiService.get(`${BASE}/voices${qs}`, voiceOptionList);
};
// A short audio sample of a voice, cached server-side per voice.
previewVoice = async (voiceId: string) => {
return baseApiService.getBlob(`${BASE}/voices/${encodeURIComponent(voiceId)}/preview`);
};
}
export const podcastsApiService = new PodcastsApiService();