mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-06-12 20:45:20 +02:00
feat(podcasts): add revert-regeneration and surface cancel on the live card
This commit is contained in:
parent
f0fc660d70
commit
aa7f14d94f
9 changed files with 384 additions and 31 deletions
|
|
@ -221,6 +221,20 @@ async def regenerate_transcript(
|
|||
return PodcastDetail.of(podcast)
|
||||
|
||||
|
||||
@router.post("/podcasts/{podcast_id}/regenerate/revert", response_model=PodcastDetail)
|
||||
async def revert_regeneration(
|
||||
podcast_id: int,
|
||||
session: AsyncSession = Depends(get_async_session),
|
||||
user: User = Depends(current_active_user),
|
||||
):
|
||||
"""Back out of a regeneration and return to the finished episode."""
|
||||
podcast = await _load(session, user, podcast_id, Permission.PODCASTS_UPDATE)
|
||||
async with _lifecycle_errors():
|
||||
await PodcastService(session).revert_regeneration(podcast)
|
||||
await session.commit()
|
||||
return PodcastDetail.of(podcast)
|
||||
|
||||
|
||||
@router.post("/podcasts/{podcast_id}/cancel", response_model=PodcastDetail)
|
||||
async def cancel_podcast(
|
||||
podcast_id: int,
|
||||
|
|
|
|||
|
|
@ -4,7 +4,9 @@ The status drives a guarded state machine. A podcast is proposed (``PENDING``),
|
|||
gets a reviewable brief (``AWAITING_BRIEF``), is drafted into a transcript
|
||||
(``DRAFTING``), then rendered to audio (``RENDERING`` → ``READY``). ``FAILED``
|
||||
and ``CANCELLED`` are terminal; a ``READY`` episode can be sent back to the
|
||||
brief gate for regeneration. ``AWAITING_REVIEW`` is retained for legacy rows but
|
||||
brief gate for regeneration, and an in-flight regeneration can be reverted to
|
||||
``READY`` while the previous audio still exists. ``AWAITING_REVIEW`` is
|
||||
retained for legacy rows but
|
||||
never entered anymore — the brief is the only approval gate. The Python enum is
|
||||
kept in lockstep with the ``podcast_status`` Postgres type via its paired
|
||||
migration.
|
||||
|
|
|
|||
|
|
@ -21,11 +21,23 @@ _ALLOWED: dict[PodcastStatus, frozenset[PodcastStatus]] = {
|
|||
PodcastStatus.PENDING: frozenset(
|
||||
{PodcastStatus.AWAITING_BRIEF, PodcastStatus.FAILED, PodcastStatus.CANCELLED}
|
||||
),
|
||||
# The READY exits below exist for reverting a regeneration; the audio
|
||||
# guard for that lives in revert_regeneration.
|
||||
PodcastStatus.AWAITING_BRIEF: frozenset(
|
||||
{PodcastStatus.DRAFTING, PodcastStatus.FAILED, PodcastStatus.CANCELLED}
|
||||
{
|
||||
PodcastStatus.DRAFTING,
|
||||
PodcastStatus.READY,
|
||||
PodcastStatus.FAILED,
|
||||
PodcastStatus.CANCELLED,
|
||||
}
|
||||
),
|
||||
PodcastStatus.DRAFTING: frozenset(
|
||||
{PodcastStatus.RENDERING, PodcastStatus.FAILED, PodcastStatus.CANCELLED}
|
||||
{
|
||||
PodcastStatus.RENDERING,
|
||||
PodcastStatus.READY,
|
||||
PodcastStatus.FAILED,
|
||||
PodcastStatus.CANCELLED,
|
||||
}
|
||||
),
|
||||
# Never entered anymore (the transcript gate was dropped); kept with exits
|
||||
# so legacy rows aren't stranded.
|
||||
|
|
@ -140,6 +152,19 @@ class PodcastService:
|
|||
await self._session.flush()
|
||||
return podcast
|
||||
|
||||
async def revert_regeneration(self, podcast: Podcast) -> Podcast:
|
||||
"""Back out of a regeneration and fall back to the stored episode.
|
||||
|
||||
Regeneration keeps the rendered audio until a new take replaces it, so
|
||||
any point before that commit is a free change of mind. A fresh podcast
|
||||
has no regeneration to revert and is rejected.
|
||||
"""
|
||||
if not _has_episode(podcast):
|
||||
raise InvalidTransition("no finished episode to fall back to")
|
||||
self._transition(podcast, PodcastStatus.READY)
|
||||
await self._session.flush()
|
||||
return podcast
|
||||
|
||||
async def attach_audio(
|
||||
self,
|
||||
podcast: Podcast,
|
||||
|
|
@ -165,7 +190,15 @@ class PodcastService:
|
|||
return podcast
|
||||
|
||||
async def cancel(self, podcast: Podcast) -> Podcast:
|
||||
"""Cancel a non-terminal podcast at the user's request."""
|
||||
"""Cancel a podcast that has produced nothing the user could keep.
|
||||
|
||||
No user action may destroy playable audio: once an episode exists,
|
||||
backing out goes through revert_regeneration instead.
|
||||
"""
|
||||
if _has_episode(podcast):
|
||||
raise InvalidTransition(
|
||||
"a finished episode exists; revert the regeneration instead"
|
||||
)
|
||||
self._transition(podcast, PodcastStatus.CANCELLED)
|
||||
await self._session.flush()
|
||||
return podcast
|
||||
|
|
@ -183,6 +216,11 @@ def _status(podcast: Podcast) -> PodcastStatus:
|
|||
return PodcastStatus(podcast.status)
|
||||
|
||||
|
||||
def _has_episode(podcast: Podcast) -> bool:
|
||||
"""Whether finished audio is stored (``file_location`` covers legacy rows)."""
|
||||
return bool(podcast.storage_key or podcast.file_location)
|
||||
|
||||
|
||||
def read_spec(podcast: Podcast) -> PodcastSpec | None:
|
||||
"""Deserialize the stored brief, or ``None`` if not yet proposed."""
|
||||
return PodcastSpec.model_validate(podcast.spec) if podcast.spec else None
|
||||
|
|
|
|||
|
|
@ -14,7 +14,12 @@ from pathlib import Path
|
|||
from app.celery_app import celery_app
|
||||
from app.podcasts.persistence import PodcastRepository
|
||||
from app.podcasts.rendering import PodcastRenderer
|
||||
from app.podcasts.service import PodcastService, read_spec, read_transcript
|
||||
from app.podcasts.service import (
|
||||
InvalidTransition,
|
||||
PodcastService,
|
||||
read_spec,
|
||||
read_transcript,
|
||||
)
|
||||
from app.podcasts.storage import purge_audio_object, store_audio
|
||||
from app.podcasts.tts import get_text_to_speech
|
||||
from app.podcasts.voices import get_voice_catalog
|
||||
|
|
@ -65,10 +70,16 @@ async def _render_audio(podcast_id: int) -> dict:
|
|||
podcast_id=podcast_id,
|
||||
data=rendered.data,
|
||||
)
|
||||
await PodcastService(session).attach_audio(
|
||||
podcast, storage_backend=backend_name, storage_key=key
|
||||
)
|
||||
await session.commit()
|
||||
try:
|
||||
await PodcastService(session).attach_audio(
|
||||
podcast, storage_backend=backend_name, storage_key=key
|
||||
)
|
||||
await session.commit()
|
||||
except InvalidTransition:
|
||||
# A user back-out won the race (e.g. the regeneration was
|
||||
# reverted): drop the stale render and leave the row alone.
|
||||
await purge_audio_object(key)
|
||||
return {"status": "superseded", "podcast_id": podcast_id}
|
||||
|
||||
# Purge only after the new audio is committed, so a failed re-render never
|
||||
# destroys the episode the user can still play.
|
||||
|
|
|
|||
|
|
@ -1,8 +1,9 @@
|
|||
"""Cancelling a podcast: allowed while in flight, refused once terminal.
|
||||
"""Cancelling a podcast: allowed while in flight, refused once an episode exists.
|
||||
|
||||
Cancellation is a user escape hatch from any non-terminal state; a podcast that
|
||||
has already finished (READY) has no exit, so the disallowed transition surfaces
|
||||
as 409.
|
||||
Cancellation is the escape hatch for a podcast that has produced nothing yet.
|
||||
Once a finished episode exists — including during a regeneration, whose audio
|
||||
survives until a new render commits — cancel is refused (409): reverting the
|
||||
regeneration is the way back, and no user action may destroy playable audio.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
|
@ -37,3 +38,22 @@ async def test_cancel_from_a_terminal_state_conflicts(
|
|||
resp = await client.post(f"{BASE}/{podcast.id}/cancel")
|
||||
|
||||
assert resp.status_code == 409
|
||||
|
||||
|
||||
async def test_cancel_of_a_regeneration_is_rejected(
|
||||
client, db_search_space, make_podcast
|
||||
):
|
||||
# Cancelling here would destroy a playable episode; reverting the
|
||||
# regeneration is the way back.
|
||||
podcast = await make_podcast(
|
||||
search_space_id=db_search_space.id, status=PodcastStatus.READY
|
||||
)
|
||||
await client.post(f"{BASE}/{podcast.id}/transcript/regenerate")
|
||||
|
||||
resp = await client.post(f"{BASE}/{podcast.id}/cancel")
|
||||
|
||||
assert resp.status_code == 409
|
||||
# The regeneration is still revertable afterwards.
|
||||
follow_up = await client.post(f"{BASE}/{podcast.id}/regenerate/revert")
|
||||
assert follow_up.status_code == 200
|
||||
assert follow_up.json()["status"] == "ready"
|
||||
|
|
|
|||
|
|
@ -2,9 +2,11 @@
|
|||
|
||||
A user who dislikes the finished audio sends the episode back to the brief
|
||||
gate: the saved brief reopens for tweaks (voices, length, focus) and drafting
|
||||
only restarts on a fresh approval. These pin the READY -> AWAITING_BRIEF ->
|
||||
DRAFTING round trip and the 409 for regenerating from states that have nothing
|
||||
to redo.
|
||||
only restarts on a fresh approval. The whole redo can also be reverted at any
|
||||
point before the new render commits, falling back to the still-stored episode.
|
||||
These pin the READY -> AWAITING_BRIEF -> DRAFTING round trip, the revert
|
||||
fallback, and the 409s for acting from states that have nothing to redo or
|
||||
revert.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
|
@ -12,6 +14,9 @@ from __future__ import annotations
|
|||
import pytest
|
||||
|
||||
from app.podcasts.persistence import PodcastStatus
|
||||
from app.podcasts.service import PodcastService
|
||||
|
||||
from .conftest import build_transcript
|
||||
|
||||
pytestmark = pytest.mark.integration
|
||||
|
||||
|
|
@ -78,3 +83,99 @@ async def test_regenerate_from_cancelled_is_rejected(
|
|||
|
||||
assert resp.status_code == 409
|
||||
assert captured_tasks.draft == []
|
||||
|
||||
|
||||
async def test_reverting_a_regeneration_restores_the_ready_episode(
|
||||
client, db_search_space, make_podcast, captured_tasks
|
||||
):
|
||||
podcast = await make_podcast(
|
||||
search_space_id=db_search_space.id, status=PodcastStatus.READY
|
||||
)
|
||||
await client.post(f"{BASE}/{podcast.id}/transcript/regenerate")
|
||||
|
||||
resp = await client.post(f"{BASE}/{podcast.id}/regenerate/revert")
|
||||
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["status"] == "ready"
|
||||
# The episode the user could already play is untouched.
|
||||
assert body["has_audio"] is True
|
||||
assert captured_tasks.draft == []
|
||||
assert captured_tasks.render == []
|
||||
|
||||
|
||||
async def test_reverting_mid_draft_keeps_the_episode(
|
||||
client, db_search_space, make_podcast
|
||||
):
|
||||
# Changing one's mind is allowed even after the reopened brief was
|
||||
# approved: the episode survives until a new render replaces it.
|
||||
podcast = await make_podcast(
|
||||
search_space_id=db_search_space.id, status=PodcastStatus.READY
|
||||
)
|
||||
await client.post(f"{BASE}/{podcast.id}/transcript/regenerate")
|
||||
await client.post(f"{BASE}/{podcast.id}/brief/approve")
|
||||
|
||||
resp = await client.post(f"{BASE}/{podcast.id}/regenerate/revert")
|
||||
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["status"] == "ready"
|
||||
|
||||
|
||||
async def test_reverting_mid_render_keeps_the_episode(
|
||||
client, db_session, db_search_space, make_podcast
|
||||
):
|
||||
podcast = await make_podcast(
|
||||
search_space_id=db_search_space.id, status=PodcastStatus.READY
|
||||
)
|
||||
service = PodcastService(db_session)
|
||||
await service.regenerate(podcast)
|
||||
await service.begin_drafting(podcast)
|
||||
await service.attach_transcript(podcast, build_transcript())
|
||||
|
||||
resp = await client.post(f"{BASE}/{podcast.id}/regenerate/revert")
|
||||
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["status"] == "ready"
|
||||
|
||||
|
||||
async def test_reverted_episode_can_be_regenerated_again(
|
||||
client, db_search_space, make_podcast
|
||||
):
|
||||
# Reverting must not strand the episode: the user can change their mind
|
||||
# again immediately.
|
||||
podcast = await make_podcast(
|
||||
search_space_id=db_search_space.id, status=PodcastStatus.READY
|
||||
)
|
||||
await client.post(f"{BASE}/{podcast.id}/transcript/regenerate")
|
||||
await client.post(f"{BASE}/{podcast.id}/regenerate/revert")
|
||||
|
||||
resp = await client.post(f"{BASE}/{podcast.id}/transcript/regenerate")
|
||||
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["status"] == "awaiting_brief"
|
||||
|
||||
|
||||
async def test_revert_on_a_fresh_brief_gate_is_rejected(
|
||||
client, db_search_space, make_podcast
|
||||
):
|
||||
# A first-time brief has no regeneration to revert.
|
||||
podcast = await make_podcast(
|
||||
search_space_id=db_search_space.id, status=PodcastStatus.AWAITING_BRIEF
|
||||
)
|
||||
|
||||
resp = await client.post(f"{BASE}/{podcast.id}/regenerate/revert")
|
||||
|
||||
assert resp.status_code == 409
|
||||
assert resp.json()["detail"]
|
||||
|
||||
|
||||
async def test_revert_when_nothing_was_regenerated_is_rejected(
|
||||
client, db_search_space, make_podcast
|
||||
):
|
||||
podcast = await make_podcast(
|
||||
search_space_id=db_search_space.id, status=PodcastStatus.READY
|
||||
)
|
||||
|
||||
resp = await client.post(f"{BASE}/{podcast.id}/regenerate/revert")
|
||||
|
||||
assert resp.status_code == 409
|
||||
|
|
|
|||
|
|
@ -64,3 +64,37 @@ async def test_rerender_replaces_audio_and_purges_the_old_object(
|
|||
assert podcast.storage_key != old_key
|
||||
assert fake_storage.objects[podcast.storage_key] == b"merged-audio"
|
||||
assert old_key in fake_storage.deleted
|
||||
|
||||
|
||||
async def test_render_losing_to_a_user_revert_keeps_the_episode_and_leaks_nothing(
|
||||
db_session,
|
||||
db_search_space,
|
||||
make_podcast,
|
||||
bind_task_session,
|
||||
fake_tts,
|
||||
fake_merge,
|
||||
fake_storage,
|
||||
):
|
||||
# The user reverts the regeneration while the render is in flight: the
|
||||
# stale render must neither resurrect the redo nor leak the object it
|
||||
# already stored.
|
||||
podcast = await make_podcast(
|
||||
search_space_id=db_search_space.id, status=PodcastStatus.READY
|
||||
)
|
||||
old_key = podcast.storage_key
|
||||
fake_storage.objects[old_key] = b"old-audio"
|
||||
|
||||
service = PodcastService(db_session)
|
||||
await service.regenerate(podcast)
|
||||
await service.begin_drafting(podcast)
|
||||
await service.attach_transcript(podcast, build_transcript())
|
||||
await service.revert_regeneration(podcast)
|
||||
|
||||
result = await render._render_audio(podcast.id)
|
||||
|
||||
assert result["status"] == "superseded"
|
||||
assert podcast.status == PodcastStatus.READY
|
||||
assert podcast.storage_key == old_key
|
||||
assert old_key not in fake_storage.deleted
|
||||
stale_keys = [key for key in fake_storage.objects if key != old_key]
|
||||
assert all(key in fake_storage.deleted for key in stale_keys)
|
||||
|
|
|
|||
|
|
@ -1,24 +1,46 @@
|
|||
"use client";
|
||||
|
||||
import type { ToolCallMessagePartProps } from "@assistant-ui/react";
|
||||
import { Loader2, RotateCcw } from "lucide-react";
|
||||
import { Loader2, RotateCcw, Undo2, X } from "lucide-react";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import { type ReactNode, useEffect, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { TextShimmerLoader } from "@/components/prompt-kit/loader";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Button, buttonVariants } from "@/components/ui/button";
|
||||
import { type LivePodcast, usePodcastLive } from "@/hooks/use-podcast-live";
|
||||
import { podcastsApiService } from "@/lib/apis/podcasts-api.service";
|
||||
import { BriefReview } from "./brief-review";
|
||||
import { PodcastErrorState, PodcastPlayer } from "./player";
|
||||
import type { GeneratePodcastArgs, GeneratePodcastResult } from "./schema";
|
||||
|
||||
function WorkingState({ title, label }: { title: string; label: string }) {
|
||||
function WorkingState({
|
||||
title,
|
||||
label,
|
||||
action,
|
||||
}: {
|
||||
title: string;
|
||||
label: string;
|
||||
action?: ReactNode;
|
||||
}) {
|
||||
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 className="flex items-start justify-between gap-3 px-5 pt-5 pb-4">
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-semibold text-foreground line-clamp-2">{title}</p>
|
||||
<TextShimmerLoader text={label} size="sm" />
|
||||
</div>
|
||||
{action}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -97,6 +119,84 @@ function RegenerateButton({ podcast }: { podcast: LivePodcast }) {
|
|||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The way out of an in-flight generation depends on what already exists:
|
||||
* a regeneration is reverted (the stored episode survives, so no confirm),
|
||||
* while a first-time generation is cancelled (destructive, so confirmed via a
|
||||
* dialog — the card header is too cramped to host a confirmation row).
|
||||
*/
|
||||
function BackOutButton({ podcastId, hasEpisode }: { podcastId: number; hasEpisode: boolean }) {
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
const run = async (call: (id: number) => Promise<unknown>, failure: string) => {
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
await call(podcastId);
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : failure);
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (hasEpisode) {
|
||||
return (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="shrink-0 text-muted-foreground"
|
||||
disabled={isSubmitting}
|
||||
onClick={() =>
|
||||
run(podcastsApiService.revertRegeneration, "Failed to restore the current episode")
|
||||
}
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<Loader2 className="size-3.5 animate-spin" />
|
||||
) : (
|
||||
<Undo2 className="size-3.5" />
|
||||
)}
|
||||
Keep current episode
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="shrink-0 text-muted-foreground"
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
<X className="size-3.5" /> Cancel
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Cancel this podcast?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Generation stops and the podcast is discarded. This cannot be undone.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Keep going</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
className={buttonVariants({ variant: "destructive" })}
|
||||
onClick={() => run(podcastsApiService.cancel, "Failed to cancel the podcast")}
|
||||
>
|
||||
Cancel podcast
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
);
|
||||
}
|
||||
|
||||
const BACK_OUT_STATUSES = new Set(["awaiting_brief", "drafting", "rendering"]);
|
||||
|
||||
/** Status-driven card for an authenticated viewer, fed by Zero push. */
|
||||
function LivePodcastCard({
|
||||
podcastId,
|
||||
|
|
@ -107,6 +207,26 @@ function LivePodcastCard({
|
|||
}) {
|
||||
const { podcast, isLoading } = usePodcastLive(podcastId);
|
||||
|
||||
// Whether a finished episode exists decides revert-vs-cancel, and Zero
|
||||
// doesn't publish audio fields — so the in-flight states check over REST,
|
||||
// re-checking on each status change (a fresh podcast gains its episode,
|
||||
// a regeneration starts with one).
|
||||
const status = podcast?.status;
|
||||
const [hasEpisode, setHasEpisode] = useState(false);
|
||||
useEffect(() => {
|
||||
if (!status || !BACK_OUT_STATUSES.has(status)) return;
|
||||
let stale = false;
|
||||
podcastsApiService
|
||||
.getDetail(podcastId)
|
||||
.then((detail) => {
|
||||
if (!stale) setHasEpisode(detail.has_audio);
|
||||
})
|
||||
.catch(() => {});
|
||||
return () => {
|
||||
stale = true;
|
||||
};
|
||||
}, [podcastId, status]);
|
||||
|
||||
if (!podcast) {
|
||||
if (isLoading) {
|
||||
return <WorkingState title={fallbackTitle} label="Loading podcast" />;
|
||||
|
|
@ -121,13 +241,15 @@ function LivePodcastCard({
|
|||
|
||||
const title = podcast.title || fallbackTitle;
|
||||
|
||||
const backOut = <BackOutButton podcastId={podcast.id} hasEpisode={hasEpisode} />;
|
||||
|
||||
switch (podcast.status) {
|
||||
case "pending":
|
||||
return <WorkingState title={title} label="Preparing brief" />;
|
||||
case "drafting":
|
||||
return <WorkingState title={title} label="Drafting transcript" />;
|
||||
return <WorkingState title={title} label="Drafting transcript" action={backOut} />;
|
||||
case "rendering":
|
||||
return <WorkingState title={title} label="Rendering audio" />;
|
||||
return <WorkingState title={title} label="Rendering audio" action={backOut} />;
|
||||
case "awaiting_brief":
|
||||
// The gate lives right in the chat: the form is the card, so there
|
||||
// is nothing to open and nothing to dismiss.
|
||||
|
|
@ -136,12 +258,15 @@ function LivePodcastCard({
|
|||
}
|
||||
return (
|
||||
<div className="my-4 max-w-xl overflow-hidden rounded-2xl border bg-muted/30">
|
||||
<div className="px-5 pt-5 pb-3 select-none">
|
||||
<p className="text-sm font-semibold text-foreground line-clamp-2">{title}</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
Confirm the language, voices, and length — the episode generates automatically after
|
||||
you approve.
|
||||
</p>
|
||||
<div className="flex items-start justify-between gap-3 px-5 pt-5 pb-3 select-none">
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-semibold text-foreground line-clamp-2">{title}</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
Confirm the language, voices, and length — the episode generates automatically after
|
||||
you approve.
|
||||
</p>
|
||||
</div>
|
||||
{backOut}
|
||||
</div>
|
||||
<div className="mx-5 h-px bg-border/50" />
|
||||
<div className="px-5 py-4">
|
||||
|
|
|
|||
|
|
@ -43,6 +43,14 @@ class PodcastsApiService {
|
|||
return baseApiService.post(`${BASE}/${podcastId}/transcript/regenerate`, podcastDetail);
|
||||
};
|
||||
|
||||
// Backs out of a regeneration: the podcast returns to ready with its
|
||||
// existing audio untouched. 409 when there is no episode to fall back to.
|
||||
revertRegeneration = async (podcastId: number) => {
|
||||
return baseApiService.post(`${BASE}/${podcastId}/regenerate/revert`, podcastDetail);
|
||||
};
|
||||
|
||||
// Only for podcasts that have produced nothing yet; once an episode
|
||||
// exists the backend refuses (409) and revertRegeneration is the way back.
|
||||
cancel = async (podcastId: number) => {
|
||||
return baseApiService.post(`${BASE}/${podcastId}/cancel`, podcastDetail);
|
||||
};
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue