mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-06-22 21:28:12 +02:00
refactor(podcasts): drop transcript gate, add regenerate-from-ready and voice previews
This commit is contained in:
parent
ccd8209d12
commit
11a6b178a0
22 changed files with 591 additions and 347 deletions
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"} />;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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 & render audio
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue