feat(podcasts): add lifecycle tool ui with brief and transcript gates

This commit is contained in:
CREDO23 2026-06-11 10:04:51 +02:00
parent a3d1fafb0b
commit 6f6c056404
7 changed files with 1012 additions and 0 deletions

View file

@ -0,0 +1,396 @@
"use client";
import { Loader2, Plus, Trash2 } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import {
MAX_SPEAKERS,
type PodcastSpec,
type PodcastStyle,
podcastStyle,
type SpeakerRole,
speakerRole,
type VoiceOption,
} from "@/contracts/types/podcast.types";
import type { LivePodcast } from "@/hooks/use-podcast-live";
import { podcastsApiService } from "@/lib/apis/podcasts-api.service";
import { AppError } from "@/lib/error";
// A "*" voice speaks whatever language the text is in (mirrors the backend
// catalog's ANY_LANGUAGE sentinel).
const ANY_LANGUAGE = "*";
function speaks(voice: VoiceOption, language: string): boolean {
if (voice.language === ANY_LANGUAGE) return true;
return primary(voice.language) === primary(language);
}
function primary(language: string): string {
return language.split("-", 1)[0].trim().toLowerCase();
}
interface BriefReviewProps {
podcast: LivePodcast;
spec: PodcastSpec;
onApproved: () => void;
}
/**
* Gate 1: the pre-filled brief as a near-confirmation. One-click approve is
* the easy path; every field stays overridable and saves through the
* version-guarded PATCH so concurrent edits surface instead of clobbering.
*/
export function BriefReview({ podcast, spec, onApproved }: BriefReviewProps) {
const [draft, setDraft] = useState<PodcastSpec>(spec);
const [voices, setVoices] = useState<VoiceOption[] | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
// A pushed spec change (saved edit or concurrent editor) resets the form to
// the authoritative version.
// biome-ignore lint/correctness/useExhaustiveDependencies: reset only when the server version moves
useEffect(() => {
setDraft(spec);
}, [podcast.specVersion]);
useEffect(() => {
let cancelled = false;
podcastsApiService
.listVoices()
.then((catalog) => {
if (!cancelled) setVoices(catalog);
})
.catch(() => {
if (!cancelled) setVoices([]);
});
return () => {
cancelled = true;
};
}, []);
const languages = useMemo(() => {
const tags = new Set<string>();
for (const voice of voices ?? []) {
if (voice.language !== ANY_LANGUAGE) tags.add(voice.language);
}
tags.add(draft.language);
return [...tags].sort();
}, [voices, draft.language]);
const voicesForLanguage = useMemo(
() => (voices ?? []).filter((voice) => speaks(voice, draft.language)),
[voices, draft.language]
);
const isDirty = useMemo(() => JSON.stringify(draft) !== JSON.stringify(spec), [draft, spec]);
const setLanguage = (language: string) => {
setDraft((current) => {
const candidates = (voices ?? []).filter((voice) => speaks(voice, language));
// Voices that can't render the new language are remapped so the saved
// spec never pairs a language with an incompatible voice.
const speakers = current.speakers.map((speaker, index) => {
const stillValid = candidates.some((voice) => voice.voice_id === speaker.voice_id);
const fallback = candidates[index % Math.max(candidates.length, 1)];
return stillValid || !fallback ? speaker : { ...speaker, voice_id: fallback.voice_id };
});
return { ...current, language, speakers };
});
};
const updateSpeaker = (slot: number, change: Partial<PodcastSpec["speakers"][number]>) => {
setDraft((current) => ({
...current,
speakers: current.speakers.map((speaker) =>
speaker.slot === slot ? { ...speaker, ...change } : speaker
),
}));
};
const addSpeaker = () => {
setDraft((current) => {
if (current.speakers.length >= MAX_SPEAKERS) return current;
const slot = Math.max(...current.speakers.map((s) => s.slot)) + 1;
const voice =
voicesForLanguage[current.speakers.length % Math.max(voicesForLanguage.length, 1)];
return {
...current,
speakers: [
...current.speakers,
{
slot,
name: `Speaker ${current.speakers.length + 1}`,
role: "guest" as SpeakerRole,
voice_id: voice?.voice_id ?? current.speakers[0].voice_id,
},
],
};
});
};
const removeSpeaker = (slot: number) => {
setDraft((current) => {
if (current.speakers.length <= 1) return current;
return {
...current,
speakers: current.speakers.filter((speaker) => speaker.slot !== slot),
};
});
};
const saveIfDirty = async (): Promise<boolean> => {
if (!isDirty) return true;
try {
await podcastsApiService.updateSpec(podcast.id, draft, podcast.specVersion);
return true;
} catch (error) {
if (error instanceof AppError && error.status === 409) {
toast.warning("The brief changed elsewhere — reloaded the latest version.");
setDraft(spec);
} else {
toast.error(error instanceof Error ? error.message : "Failed to save the brief");
}
return false;
}
};
const handleSave = async () => {
setIsSubmitting(true);
try {
if (await saveIfDirty()) {
toast.success("Brief saved.");
}
} finally {
setIsSubmitting(false);
}
};
const handleApprove = async () => {
setIsSubmitting(true);
try {
if (!(await saveIfDirty())) return;
await podcastsApiService.approveBrief(podcast.id);
onApproved();
} catch (error) {
toast.error(error instanceof Error ? error.message : "Failed to approve the brief");
} finally {
setIsSubmitting(false);
}
};
return (
<div className="flex flex-col gap-6">
<div className="grid grid-cols-2 gap-4">
<div className="flex flex-col gap-2">
<Label htmlFor="podcast-language">Language</Label>
<Select value={draft.language} onValueChange={setLanguage}>
<SelectTrigger id="podcast-language">
<SelectValue placeholder="Language" />
</SelectTrigger>
<SelectContent>
{languages.map((tag) => (
<SelectItem key={tag} value={tag}>
{languageLabel(tag)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="podcast-style">Style</Label>
<Select
value={draft.style}
onValueChange={(value) =>
setDraft((current) => ({ ...current, style: value as PodcastStyle }))
}
>
<SelectTrigger id="podcast-style">
<SelectValue placeholder="Style" />
</SelectTrigger>
<SelectContent>
{podcastStyle.options.map((style) => (
<SelectItem key={style} value={style}>
{capitalize(style)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="flex flex-col gap-3">
<div className="flex items-center justify-between">
<Label>Speakers</Label>
<Button
type="button"
variant="ghost"
size="sm"
onClick={addSpeaker}
disabled={draft.speakers.length >= MAX_SPEAKERS}
>
<Plus className="size-4" /> Add speaker
</Button>
</div>
{draft.speakers.map((speaker) => (
<div key={speaker.slot} className="flex items-end gap-2 rounded-lg border p-3">
<div className="flex flex-1 flex-col gap-1.5">
<Label htmlFor={`speaker-name-${speaker.slot}`} className="text-xs">
Name
</Label>
<Input
id={`speaker-name-${speaker.slot}`}
value={speaker.name}
maxLength={120}
onChange={(e) => updateSpeaker(speaker.slot, { name: e.target.value })}
/>
</div>
<div className="flex w-28 flex-col gap-1.5">
<Label className="text-xs">Role</Label>
<Select
value={speaker.role}
onValueChange={(value) =>
updateSpeaker(speaker.slot, { role: value as SpeakerRole })
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{speakerRole.options.map((role) => (
<SelectItem key={role} value={role}>
{capitalize(role)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex w-44 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>
<Button
type="button"
variant="ghost"
size="icon"
aria-label={`Remove ${speaker.name}`}
onClick={() => removeSpeaker(speaker.slot)}
disabled={draft.speakers.length <= 1}
>
<Trash2 className="size-4" />
</Button>
</div>
))}
</div>
<div className="grid grid-cols-2 gap-4">
<div className="flex flex-col gap-2">
<Label htmlFor="podcast-min-minutes">Min length (minutes)</Label>
<Input
id="podcast-min-minutes"
type="number"
min={1}
value={draft.duration.min_minutes}
onChange={(e) =>
setDraft((current) => ({
...current,
duration: { ...current.duration, min_minutes: Number(e.target.value) || 1 },
}))
}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="podcast-max-minutes">Max length (minutes)</Label>
<Input
id="podcast-max-minutes"
type="number"
min={draft.duration.min_minutes}
value={draft.duration.max_minutes}
onChange={(e) =>
setDraft((current) => ({
...current,
duration: {
...current.duration,
max_minutes: Number(e.target.value) || current.duration.min_minutes,
},
}))
}
/>
</div>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="podcast-focus">Focus (optional)</Label>
<Textarea
id="podcast-focus"
placeholder="What should the episode emphasise?"
maxLength={2000}
value={draft.focus ?? ""}
onChange={(e) => setDraft((current) => ({ ...current, focus: e.target.value || null }))}
/>
</div>
<div className="flex justify-end gap-2">
{isDirty ? (
<Button type="button" variant="outline" onClick={handleSave} disabled={isSubmitting}>
Save changes
</Button>
) : null}
<Button
type="button"
onClick={handleApprove}
disabled={isSubmitting || draft.duration.max_minutes < draft.duration.min_minutes}
>
{isSubmitting ? <Loader2 className="size-4 animate-spin" /> : null}
Approve &amp; draft transcript
</Button>
</div>
</div>
);
}
/** The current selection stays listed even when it no longer matches the
* language filter, so the Select never renders an orphaned value. */
function voiceItems(candidates: VoiceOption[], selectedId: string): VoiceOption[] {
if (candidates.some((voice) => voice.voice_id === selectedId)) return candidates;
return [
{ voice_id: selectedId, display_name: selectedId, language: "", gender: "unknown" },
...candidates,
];
}
function languageLabel(tag: string): string {
try {
const label = new Intl.DisplayNames(["en"], { type: "language" }).of(tag);
return label && label !== tag ? `${label} (${tag})` : tag;
} catch {
return tag;
}
}
function capitalize(value: string): string {
return value.charAt(0).toUpperCase() + value.slice(1);
}

View file

@ -0,0 +1,194 @@
"use client";
import type { ToolCallMessagePartProps } from "@assistant-ui/react";
import { usePathname } from "next/navigation";
import { useState } from "react";
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 { PodcastErrorState, PodcastPlayer } from "./player";
import { PodcastReviewSheet } from "./review-sheet";
import type { GeneratePodcastArgs, GeneratePodcastResult } from "./schema";
function WorkingState({ title, label }: { title: string; label: 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={label} size="sm" />
</div>
</div>
);
}
function NoticeState({ title, message }: { title: string; message: 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-muted-foreground">{title}</p>
<p className="text-xs text-muted-foreground mt-0.5">{message}</p>
</div>
</div>
);
}
function briefSummary(spec: PodcastSpec | null): string | null {
if (!spec) return null;
const speakers = spec.speakers.length === 1 ? "1 speaker" : `${spec.speakers.length} speakers`;
return `${spec.language} · ${speakers} · ${spec.duration.min_minutes}${spec.duration.max_minutes} min`;
}
function ReviewGateCard({
title,
heading,
summary,
buttonLabel,
onReview,
}: {
title: string;
heading: string;
summary: string | null;
buttonLabel: string;
onReview: () => void;
}) {
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">{heading}</p>
</div>
<div className="mx-5 h-px bg-border/50" />
<div className="flex items-center justify-between gap-3 px-5 py-4">
<p className="text-xs text-muted-foreground">{summary}</p>
<Button type="button" size="sm" onClick={onReview}>
{buttonLabel}
</Button>
</div>
</div>
);
}
/** Status-driven card for an authenticated viewer, fed by Zero push. */
function LivePodcastCard({
podcastId,
fallbackTitle,
}: {
podcastId: number;
fallbackTitle: string;
}) {
const { podcast, isLoading } = usePodcastLive(podcastId);
const [reviewOpen, setReviewOpen] = useState(false);
if (!podcast) {
if (isLoading) {
return <WorkingState title={fallbackTitle} label="Loading podcast" />;
}
return (
<NoticeState
title="Podcast Unavailable"
message="This podcast no longer exists or you don't have access to it."
/>
);
}
const title = podcast.title || fallbackTitle;
switch (podcast.status) {
case "pending":
return <WorkingState title={title} label="Preparing brief" />;
case "drafting":
return <WorkingState title={title} label="Drafting transcript" />;
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"
}
summary={briefSummary(podcast.spec)}
buttonLabel={isBriefGate ? "Review brief" : "Review transcript"}
onReview={() => setReviewOpen(true)}
/>
<PodcastReviewSheet podcast={podcast} open={reviewOpen} onOpenChange={setReviewOpen} />
</>
);
}
case "ready":
return (
<PodcastPlayer
podcastId={podcast.id}
title={title}
durationMs={podcast.durationSeconds ? podcast.durationSeconds * 1000 : undefined}
/>
);
case "failed":
return <PodcastErrorState title={title} error={podcast.error || "Generation failed"} />;
case "cancelled":
return <NoticeState title="Podcast Cancelled" message="This podcast was cancelled." />;
}
}
/**
* Tool UI for `generate_podcast`. The tool only prepares the podcast (it
* returns with the brief awaiting review), so this card follows the lifecycle
* by Zero push and opens the review panel at each gate. Public shared chats
* have no Zero session; their snapshots only ever contain finished episodes,
* so the player renders directly against the share-token endpoints.
*/
export const GeneratePodcastToolUI = ({
args,
result,
status,
}: ToolCallMessagePartProps<GeneratePodcastArgs, GeneratePodcastResult>) => {
const pathname = usePathname();
const isPublicRoute = !!pathname?.startsWith("/public/");
const title = args.podcast_title || "SurfSense Podcast";
if (status.type === "running" || status.type === "requires-action") {
return <WorkingState title={title} label="Preparing podcast" />;
}
if (status.type === "incomplete") {
if (status.reason === "cancelled") {
return <NoticeState title="Podcast Cancelled" message="Podcast preparation was cancelled." />;
}
if (status.reason === "error") {
return (
<PodcastErrorState
title={title}
error={typeof status.error === "string" ? status.error : "An error occurred"}
/>
);
}
}
if (!result) {
return <WorkingState title={title} label="Preparing podcast" />;
}
if (result.podcast_id) {
if (isPublicRoute) {
return <PodcastPlayer podcastId={result.podcast_id} title={result.title || title} />;
}
return <LivePodcastCard podcastId={result.podcast_id} fallbackTitle={result.title || title} />;
}
if (result.status === "failed" || result.status === "error") {
return <PodcastErrorState title={title} error={result.error || "Generation failed"} />;
}
// Legacy saved chats: results identified only by a Celery task id can't be
// recovered through the lifecycle API.
return (
<NoticeState
title="Podcast Unavailable"
message="This podcast was generated with an older version. Please generate a new one."
/>
);
};

View file

@ -0,0 +1 @@
export { GeneratePodcastToolUI } from "./generate-podcast";

View file

@ -0,0 +1,203 @@
"use client";
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 { podcastsApiService } from "@/lib/apis/podcasts-api.service";
import { authenticatedFetch } from "@/lib/auth-utils";
import { BACKEND_URL } from "@/lib/env-config";
import { speakerLabel } from "./schema";
// Public snapshots predate the transcript.turns shape and keep their own.
const publicPodcastDetailsSchema = z.object({
podcast_transcript: z.array(z.object({ speaker_id: z.number(), dialog: z.string() })).nullish(),
});
interface TranscriptLine {
label: string;
text: string;
}
export 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>
);
}
/**
* Streams the rendered episode and shows its transcript. Works in two modes:
* authenticated (lifecycle stream + detail endpoints) and public shared chat
* (share-token snapshot endpoints), detected from the route.
*/
export 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 [transcriptLines, setTranscriptLines] = useState<TranscriptLine[] | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const objectUrlRef = useRef<string | null>(null);
useEffect(() => {
return () => {
if (objectUrlRef.current) {
URL.revokeObjectURL(objectUrlRef.current);
}
};
}, []);
const loadPodcast = useCallback(async () => {
setIsLoading(true);
setError(null);
try {
if (objectUrlRef.current) {
URL.revokeObjectURL(objectUrlRef.current);
objectUrlRef.current = null;
}
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 60000);
try {
let audioBlob: Blob;
let lines: TranscriptLine[] = [];
if (shareToken) {
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;
const parsed = publicPodcastDetailsSchema.safeParse(details);
lines = (parsed.success ? (parsed.data.podcast_transcript ?? []) : []).map((entry) => ({
label: `Speaker ${entry.speaker_id + 1}`,
text: entry.dialog,
}));
} else {
const [audioResponse, detail] = await Promise.all([
authenticatedFetch(`${BACKEND_URL}/api/v1/podcasts/${podcastId}/stream`, {
method: "GET",
signal: controller.signal,
}),
podcastsApiService.getDetail(podcastId),
]);
if (!audioResponse.ok) {
throw new Error(`Failed to load audio: ${audioResponse.status}`);
}
audioBlob = await audioResponse.blob();
lines = (detail.transcript?.turns ?? []).map((turn) => ({
label: speakerLabel(detail.spec, turn.speaker),
text: turn.text,
}));
}
const objectUrl = URL.createObjectURL(audioBlob);
objectUrlRef.current = objectUrl;
setAudioSrc(objectUrl);
setTranscriptLines(lines);
} 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]);
useEffect(() => {
loadPodcast();
}, [loadPodcast]);
if (isLoading) {
return <AudioLoadingState title={title} />;
}
if (error || !audioSrc) {
return <PodcastErrorState title={title} error={error || "Failed to load audio"} />;
}
const hasTranscript = transcriptLines && transcriptLines.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">
{transcriptLines.map((line, idx) => (
<div key={`${idx}-${line.label}`} className="text-xs sm:text-sm">
<span className="font-medium text-primary">{line.label}:</span>{" "}
<span className="text-muted-foreground">{line.text}</span>
</div>
))}
</div>
</AccordionContent>
</AccordionItem>
</Accordion>
</div>
) : null}
</div>
);
}

View file

@ -0,0 +1,67 @@
"use client";
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} 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;
open: boolean;
onOpenChange: (open: boolean) => void;
}
/**
* 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.
*/
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}
</SheetContent>
</Sheet>
);
}

View file

@ -0,0 +1,33 @@
import { z } from "zod";
import type { PodcastSpec } from "@/contracts/types/podcast.types";
/**
* Tool-call contract for `generate_podcast`.
*
* The tool prepares a podcast and returns immediately with the row awaiting
* brief review; the card then follows the lifecycle by push. Legacy status
* values are accepted so old saved chats still render something sensible.
*/
export const generatePodcastArgsSchema = z.object({
source_content: z.string(),
podcast_title: z.string().nullish(),
user_prompt: z.string().nullish(),
});
export type GeneratePodcastArgs = z.infer<typeof generatePodcastArgsSchema>;
export const generatePodcastResultSchema = z.object({
status: z.string(),
podcast_id: z.number().nullish(),
task_id: z.string().nullish(), // legacy Celery id from old saved chats
title: z.string().nullish(),
message: z.string().nullish(),
error: z.string().nullish(),
});
export type GeneratePodcastResult = z.infer<typeof generatePodcastResultSchema>;
/** Display name for the speaker bound to `slot`, falling back to a number. */
export function speakerLabel(spec: PodcastSpec | null | undefined, slot: number): string {
const speaker = spec?.speakers.find((s) => s.slot === slot);
return speaker?.name ?? `Speaker ${slot + 1}`;
}

View file

@ -0,0 +1,118 @@
"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>
);
}