mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-06-12 20:45:20 +02:00
feat(podcasts): add lifecycle tool ui with brief and transcript gates
This commit is contained in:
parent
a3d1fafb0b
commit
6f6c056404
7 changed files with 1012 additions and 0 deletions
396
surfsense_web/components/tool-ui/podcast/brief-review.tsx
Normal file
396
surfsense_web/components/tool-ui/podcast/brief-review.tsx
Normal 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 & 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);
|
||||
}
|
||||
194
surfsense_web/components/tool-ui/podcast/generate-podcast.tsx
Normal file
194
surfsense_web/components/tool-ui/podcast/generate-podcast.tsx
Normal 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."
|
||||
/>
|
||||
);
|
||||
};
|
||||
1
surfsense_web/components/tool-ui/podcast/index.ts
Normal file
1
surfsense_web/components/tool-ui/podcast/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { GeneratePodcastToolUI } from "./generate-podcast";
|
||||
203
surfsense_web/components/tool-ui/podcast/player.tsx
Normal file
203
surfsense_web/components/tool-ui/podcast/player.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
67
surfsense_web/components/tool-ui/podcast/review-sheet.tsx
Normal file
67
surfsense_web/components/tool-ui/podcast/review-sheet.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
33
surfsense_web/components/tool-ui/podcast/schema.ts
Normal file
33
surfsense_web/components/tool-ui/podcast/schema.ts
Normal 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}`;
|
||||
}
|
||||
118
surfsense_web/components/tool-ui/podcast/transcript-review.tsx
Normal file
118
surfsense_web/components/tool-ui/podcast/transcript-review.tsx
Normal 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 & render audio
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue