New front-end Launch Chat API Manage Sources Enable re-embedding of all contents Sources can be added without a notebook now Improved settings Enable model selector on all chats Background processing for better experience Dark mode Improved Notes Improved Docs: - Remove all Streamlit references from documentation - Update deployment guides with React frontend setup - Fix Docker environment variables format (SURREAL_URL, SURREAL_PASSWORD) - Update docker image tag from :latest to :v1-latest - Change navigation references (Settings → Models to just Models) - Update development setup to include frontend npm commands - Add MIGRATION.md guide for users upgrading from Streamlit - Update quick-start guide with correct environment variables - Add port 5055 documentation for API access - Update project structure to reflect frontend/ directory - Remove outdated source-chat documentation files
397 lines
15 KiB
TypeScript
397 lines
15 KiB
TypeScript
'use client'
|
|
|
|
import { useEffect, useMemo, useState } from 'react'
|
|
import { formatDistanceToNow } from 'date-fns'
|
|
import { InfoIcon, Trash2 } from 'lucide-react'
|
|
|
|
import { resolvePodcastAssetUrl } from '@/lib/api/podcasts'
|
|
import { EpisodeStatus, PodcastEpisode } from '@/lib/types/podcasts'
|
|
import { cn } from '@/lib/utils'
|
|
import {
|
|
AlertDialog,
|
|
AlertDialogAction,
|
|
AlertDialogCancel,
|
|
AlertDialogContent,
|
|
AlertDialogDescription,
|
|
AlertDialogFooter,
|
|
AlertDialogHeader,
|
|
AlertDialogTitle,
|
|
AlertDialogTrigger,
|
|
} from '@/components/ui/alert-dialog'
|
|
import { Badge } from '@/components/ui/badge'
|
|
import { Button } from '@/components/ui/button'
|
|
import { Card, CardContent } from '@/components/ui/card'
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogDescription,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
DialogTrigger,
|
|
} from '@/components/ui/dialog'
|
|
import { ScrollArea } from '@/components/ui/scroll-area'
|
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
|
|
|
interface EpisodeCardProps {
|
|
episode: PodcastEpisode
|
|
onDelete: (episodeId: string) => Promise<void> | void
|
|
deleting?: boolean
|
|
}
|
|
|
|
const STATUS_META: Record<
|
|
EpisodeStatus | 'unknown',
|
|
{ label: string; className: string }
|
|
> = {
|
|
running: {
|
|
label: 'Processing',
|
|
className: 'bg-amber-100 text-amber-800 border-amber-200',
|
|
},
|
|
processing: {
|
|
label: 'Processing',
|
|
className: 'bg-amber-100 text-amber-800 border-amber-200',
|
|
},
|
|
completed: {
|
|
label: 'Completed',
|
|
className: 'bg-emerald-100 text-emerald-800 border-emerald-200',
|
|
},
|
|
failed: {
|
|
label: 'Failed',
|
|
className: 'bg-red-100 text-red-800 border-red-200',
|
|
},
|
|
error: {
|
|
label: 'Failed',
|
|
className: 'bg-red-100 text-red-800 border-red-200',
|
|
},
|
|
pending: {
|
|
label: 'Pending',
|
|
className: 'bg-sky-100 text-sky-800 border-sky-200',
|
|
},
|
|
submitted: {
|
|
label: 'Pending',
|
|
className: 'bg-sky-100 text-sky-800 border-sky-200',
|
|
},
|
|
unknown: {
|
|
label: 'Unknown',
|
|
className: 'bg-muted text-muted-foreground border-transparent',
|
|
},
|
|
}
|
|
|
|
function StatusBadge({ status }: { status?: EpisodeStatus | null }) {
|
|
// Don't show badge for completed episodes
|
|
if (status === 'completed') {
|
|
return null
|
|
}
|
|
|
|
const meta = STATUS_META[status ?? 'unknown']
|
|
return (
|
|
<Badge
|
|
variant="outline"
|
|
className={cn('uppercase tracking-wide text-xs', meta.className)}
|
|
>
|
|
{meta.label}
|
|
</Badge>
|
|
)
|
|
}
|
|
|
|
type OutlineSegment = {
|
|
name?: string
|
|
description?: string
|
|
size?: string
|
|
}
|
|
|
|
type OutlineData = {
|
|
segments?: OutlineSegment[]
|
|
}
|
|
|
|
type TranscriptEntry = {
|
|
speaker?: string
|
|
dialogue?: string
|
|
}
|
|
|
|
type TranscriptData = {
|
|
transcript?: TranscriptEntry[]
|
|
}
|
|
|
|
function extractOutlineSegments(outline: unknown): OutlineSegment[] {
|
|
if (outline && typeof outline === 'object' && 'segments' in outline) {
|
|
const data = outline as OutlineData
|
|
if (Array.isArray(data.segments)) {
|
|
return data.segments
|
|
}
|
|
}
|
|
return []
|
|
}
|
|
|
|
function extractTranscriptEntries(transcript: unknown): TranscriptEntry[] {
|
|
if (transcript && typeof transcript === 'object' && 'transcript' in transcript) {
|
|
const data = transcript as TranscriptData
|
|
if (Array.isArray(data.transcript)) {
|
|
return data.transcript
|
|
}
|
|
}
|
|
return []
|
|
}
|
|
|
|
export function EpisodeCard({ episode, onDelete, deleting }: EpisodeCardProps) {
|
|
const [audioSrc, setAudioSrc] = useState<string | undefined>()
|
|
const [audioError, setAudioError] = useState<string | null>(null)
|
|
const [detailsOpen, setDetailsOpen] = useState(false)
|
|
|
|
const outlineSegments = useMemo(() => extractOutlineSegments(episode.outline), [episode.outline])
|
|
const transcriptEntries = useMemo(() => extractTranscriptEntries(episode.transcript), [episode.transcript])
|
|
|
|
useEffect(() => {
|
|
let revokeUrl: string | undefined
|
|
setAudioError(null)
|
|
|
|
// If backend exposed a protected endpoint, fetch it with auth headers
|
|
const loadProtectedAudio = async () => {
|
|
// First resolve the audio URL
|
|
const directAudioUrl = await resolvePodcastAssetUrl(episode.audio_url ?? episode.audio_file)
|
|
|
|
if (!directAudioUrl || !episode.audio_url) {
|
|
setAudioSrc(directAudioUrl)
|
|
return
|
|
}
|
|
|
|
try {
|
|
let token: string | undefined
|
|
if (typeof window !== 'undefined') {
|
|
const raw = window.localStorage.getItem('auth-storage')
|
|
if (raw) {
|
|
try {
|
|
const parsed = JSON.parse(raw)
|
|
token = parsed?.state?.token
|
|
} catch (error) {
|
|
console.error('Failed to parse auth storage', error)
|
|
}
|
|
}
|
|
}
|
|
|
|
const headers: HeadersInit = {}
|
|
if (token) {
|
|
headers.Authorization = `Bearer ${token}`
|
|
}
|
|
|
|
const response = await fetch(directAudioUrl, { headers })
|
|
if (!response.ok) {
|
|
throw new Error(`Audio request failed with status ${response.status}`)
|
|
}
|
|
|
|
const blob = await response.blob()
|
|
revokeUrl = URL.createObjectURL(blob)
|
|
setAudioSrc(revokeUrl)
|
|
} catch (error) {
|
|
console.error('Unable to load podcast audio', error)
|
|
setAudioError('Audio unavailable')
|
|
setAudioSrc(undefined)
|
|
}
|
|
}
|
|
|
|
void loadProtectedAudio()
|
|
|
|
return () => {
|
|
if (revokeUrl) {
|
|
URL.revokeObjectURL(revokeUrl)
|
|
}
|
|
}
|
|
}, [episode.audio_url, episode.audio_file])
|
|
|
|
const createdLabel = episode.created
|
|
? formatDistanceToNow(new Date(episode.created), {
|
|
addSuffix: true,
|
|
})
|
|
: null
|
|
|
|
const handleDelete = () => {
|
|
void onDelete(episode.id)
|
|
}
|
|
|
|
return (
|
|
<Card className="shadow-sm">
|
|
<CardContent className="space-y-4 p-4">
|
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
|
<div className="space-y-1">
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
<h3 className="text-base font-semibold text-foreground">
|
|
{episode.name}
|
|
</h3>
|
|
<StatusBadge status={episode.job_status} />
|
|
</div>
|
|
<p className="text-xs text-muted-foreground">
|
|
Profile: {episode.episode_profile?.name ?? 'Unknown'}
|
|
{createdLabel ? ` • Created ${createdLabel}` : ''}
|
|
</p>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<Dialog open={detailsOpen} onOpenChange={setDetailsOpen}>
|
|
<DialogTrigger asChild>
|
|
<Button variant="outline" size="sm">
|
|
<InfoIcon className="mr-2 h-4 w-4" /> Details
|
|
</Button>
|
|
</DialogTrigger>
|
|
<DialogContent className="w-[min(90vw,720px)] max-h-[85vh] overflow-hidden">
|
|
<DialogHeader>
|
|
<DialogTitle>{episode.name}</DialogTitle>
|
|
<DialogDescription>
|
|
{episode.episode_profile?.name ?? 'Unknown profile'}
|
|
{createdLabel ? ` • Created ${createdLabel}` : ''}
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
<div className="space-y-4 overflow-hidden">
|
|
{audioSrc ? (
|
|
<audio controls preload="none" src={audioSrc} className="w-full" />
|
|
) : audioError ? (
|
|
<p className="text-sm text-destructive">{audioError}</p>
|
|
) : null}
|
|
|
|
<Tabs defaultValue="summary" className="h-[60vh] flex flex-col">
|
|
<TabsList className="grid w-full grid-cols-3">
|
|
<TabsTrigger value="summary">Summary</TabsTrigger>
|
|
<TabsTrigger value="outline">Outline</TabsTrigger>
|
|
<TabsTrigger value="transcript">Transcript</TabsTrigger>
|
|
</TabsList>
|
|
|
|
<TabsContent value="summary" className="flex-1 overflow-hidden">
|
|
<ScrollArea className="h-full pr-4">
|
|
<div className="space-y-6">
|
|
<section className="space-y-2">
|
|
<h4 className="text-sm font-semibold text-foreground">Episode Profile</h4>
|
|
<div className="grid gap-2 text-sm md:grid-cols-2">
|
|
<div>
|
|
<p className="text-muted-foreground">Outline Model</p>
|
|
<p>
|
|
{episode.episode_profile?.outline_provider ?? '—'} /
|
|
{' '}
|
|
{episode.episode_profile?.outline_model ?? '—'}
|
|
</p>
|
|
</div>
|
|
<div>
|
|
<p className="text-muted-foreground">Transcript Model</p>
|
|
<p>
|
|
{episode.episode_profile?.transcript_provider ?? '—'} /
|
|
{' '}
|
|
{episode.episode_profile?.transcript_model ?? '—'}
|
|
</p>
|
|
</div>
|
|
<div>
|
|
<p className="text-muted-foreground">Segments</p>
|
|
<p>{episode.episode_profile?.num_segments ?? '—'}</p>
|
|
</div>
|
|
</div>
|
|
{episode.episode_profile?.default_briefing ? (
|
|
<div className="rounded border bg-muted/30 p-3 text-xs whitespace-pre-wrap">
|
|
{episode.episode_profile.default_briefing}
|
|
</div>
|
|
) : null}
|
|
</section>
|
|
|
|
<section className="space-y-2">
|
|
<h4 className="text-sm font-semibold text-foreground">Speaker Profile</h4>
|
|
<p className="text-xs text-muted-foreground">
|
|
{episode.speaker_profile?.tts_provider ?? '—'} /{' '}
|
|
{episode.speaker_profile?.tts_model ?? '—'}
|
|
</p>
|
|
{episode.speaker_profile?.speakers?.map((speaker, index) => (
|
|
<div
|
|
key={`${speaker.name}-${index}`}
|
|
className="rounded-md border bg-muted/20 p-3 text-xs"
|
|
>
|
|
<p className="font-semibold text-foreground">{speaker.name}</p>
|
|
<p className="text-muted-foreground">Voice ID: {speaker.voice_id}</p>
|
|
<p className="mt-2 whitespace-pre-wrap text-muted-foreground">
|
|
<span className="font-semibold">Backstory:</span> {speaker.backstory}
|
|
</p>
|
|
<p className="mt-2 whitespace-pre-wrap text-muted-foreground">
|
|
<span className="font-semibold">Personality:</span> {speaker.personality}
|
|
</p>
|
|
</div>
|
|
))}
|
|
</section>
|
|
|
|
{episode.briefing ? (
|
|
<section className="space-y-2">
|
|
<h4 className="text-sm font-semibold text-foreground">Briefing</h4>
|
|
<div className="rounded border bg-muted/30 p-3 text-xs whitespace-pre-wrap">
|
|
{episode.briefing}
|
|
</div>
|
|
</section>
|
|
) : null}
|
|
</div>
|
|
</ScrollArea>
|
|
</TabsContent>
|
|
|
|
<TabsContent value="outline" className="flex-1 overflow-hidden">
|
|
<ScrollArea className="h-full pr-4">
|
|
{outlineSegments.length > 0 ? (
|
|
<div className="space-y-3">
|
|
{outlineSegments.map((segment, index) => (
|
|
<div key={index} className="rounded border bg-muted/20 p-3 text-xs space-y-1">
|
|
<div className="flex items-center justify-between gap-2">
|
|
<p className="font-semibold text-foreground">{segment.name ?? `Segment ${index + 1}`}</p>
|
|
{segment.size ? (
|
|
<Badge variant="outline" className="text-[10px] uppercase tracking-wide">{segment.size}</Badge>
|
|
) : null}
|
|
</div>
|
|
<p className="text-muted-foreground whitespace-pre-wrap">{segment.description ?? 'No description provided.'}</p>
|
|
</div>
|
|
))}
|
|
</div>
|
|
) : (
|
|
<p className="text-xs text-muted-foreground">No outline available.</p>
|
|
)}
|
|
</ScrollArea>
|
|
</TabsContent>
|
|
|
|
<TabsContent value="transcript" className="flex-1 overflow-hidden">
|
|
<ScrollArea className="h-full pr-4 space-y-3">
|
|
{transcriptEntries.length > 0 ? (
|
|
transcriptEntries.map((entry, index) => (
|
|
<div key={index} className="rounded border bg-muted/20 p-3 text-xs space-y-1">
|
|
<p className="font-semibold text-foreground">{entry.speaker ?? 'Speaker'}</p>
|
|
<p className="text-muted-foreground whitespace-pre-wrap">{entry.dialogue ?? ''}</p>
|
|
</div>
|
|
))
|
|
) : (
|
|
<p className="text-xs text-muted-foreground">No transcript available.</p>
|
|
)}
|
|
</ScrollArea>
|
|
</TabsContent>
|
|
</Tabs>
|
|
</div>
|
|
</DialogContent>
|
|
</Dialog>
|
|
<AlertDialog>
|
|
<AlertDialogTrigger asChild>
|
|
<Button variant="ghost" size="sm" className="text-destructive">
|
|
<Trash2 className="mr-2 h-4 w-4" />
|
|
Delete
|
|
</Button>
|
|
</AlertDialogTrigger>
|
|
<AlertDialogContent>
|
|
<AlertDialogHeader>
|
|
<AlertDialogTitle>Delete episode?</AlertDialogTitle>
|
|
<AlertDialogDescription>
|
|
This will remove “{episode.name}” and its audio file permanently.
|
|
</AlertDialogDescription>
|
|
</AlertDialogHeader>
|
|
<AlertDialogFooter>
|
|
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
|
<AlertDialogAction onClick={handleDelete} disabled={deleting}>
|
|
{deleting ? 'Deleting…' : 'Delete'}
|
|
</AlertDialogAction>
|
|
</AlertDialogFooter>
|
|
</AlertDialogContent>
|
|
</AlertDialog>
|
|
</div>
|
|
</div>
|
|
|
|
{audioSrc ? (
|
|
<audio controls preload="none" src={audioSrc} className="w-full" />
|
|
) : audioError ? (
|
|
<p className="text-sm text-destructive">{audioError}</p>
|
|
) : null}
|
|
</CardContent>
|
|
</Card>
|
|
)
|
|
}
|