AI podcast maker performance optimizations
This commit is contained in:
@@ -0,0 +1,56 @@
|
||||
import React from "react";
|
||||
import { Stack, Typography, Chip, Divider } from "@mui/material";
|
||||
import { Insights as InsightsIcon } from "@mui/icons-material";
|
||||
import { PodcastEstimate } from "../types";
|
||||
import { GlassyCard, glassyCardSx } from "../ui";
|
||||
|
||||
interface EstimateCardProps {
|
||||
estimate: PodcastEstimate;
|
||||
}
|
||||
|
||||
export const EstimateCard: React.FC<EstimateCardProps> = ({ estimate }) => {
|
||||
return (
|
||||
<GlassyCard
|
||||
sx={{
|
||||
...glassyCardSx,
|
||||
background: "#ffffff",
|
||||
border: "1px solid rgba(0,0,0,0.06)",
|
||||
boxShadow: "0 10px 28px rgba(15,23,42,0.06)",
|
||||
color: "#0f172a",
|
||||
}}
|
||||
aria-label="estimate"
|
||||
>
|
||||
<Stack spacing={2}>
|
||||
<Typography variant="h6" sx={{ display: "flex", alignItems: "center", gap: 1, color: "#0f172a", fontWeight: 700 }}>
|
||||
<InsightsIcon />
|
||||
Estimated Cost
|
||||
</Typography>
|
||||
<Typography variant="h4" sx={{ color: "#4f46e5", fontWeight: 800 }}>
|
||||
${estimate.total.toFixed(2)}
|
||||
</Typography>
|
||||
<Divider sx={{ borderColor: "rgba(0,0,0,0.08)" }} />
|
||||
<Stack direction="row" spacing={2} flexWrap="wrap" useFlexGap>
|
||||
<Chip
|
||||
label={`Voice: $${estimate.ttsCost.toFixed(2)}`}
|
||||
size="small"
|
||||
title="Voice narration cost"
|
||||
sx={{ background: "#eef2ff", color: "#0f172a", border: "1px solid rgba(0,0,0,0.06)" }}
|
||||
/>
|
||||
<Chip
|
||||
label={`Visuals: $${estimate.avatarCost.toFixed(2)}`}
|
||||
size="small"
|
||||
title="Avatar/video cost"
|
||||
sx={{ background: "#eef2ff", color: "#0f172a", border: "1px solid rgba(0,0,0,0.06)" }}
|
||||
/>
|
||||
<Chip
|
||||
label={`Research: $${estimate.researchCost.toFixed(2)}`}
|
||||
size="small"
|
||||
title="Research and fact-checking cost"
|
||||
sx={{ background: "#eef2ff", color: "#0f172a", border: "1px solid rgba(0,0,0,0.06)" }}
|
||||
/>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</GlassyCard>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import React from "react";
|
||||
import { Box, Stack, Typography } from "@mui/material";
|
||||
import {
|
||||
Mic as MicIcon,
|
||||
Info as InfoIcon,
|
||||
AutoAwesome as AutoAwesomeIcon,
|
||||
LibraryMusic as LibraryMusicIcon,
|
||||
} from "@mui/icons-material";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { PrimaryButton, SecondaryButton } from "../ui";
|
||||
import HeaderControls from "../../shared/HeaderControls";
|
||||
|
||||
interface HeaderProps {
|
||||
onShowProjects: () => void;
|
||||
onNewEpisode: () => void;
|
||||
}
|
||||
|
||||
export const Header: React.FC<HeaderProps> = ({ onShowProjects, onNewEpisode }) => {
|
||||
const navigate = useNavigate();
|
||||
|
||||
return (
|
||||
<Stack direction="row" justifyContent="space-between" alignItems="flex-start" flexWrap="wrap" gap={2}>
|
||||
<Box>
|
||||
<Typography
|
||||
variant="h3"
|
||||
sx={{
|
||||
color: "#1e293b",
|
||||
fontWeight: 800,
|
||||
mb: 0.5,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 1.5,
|
||||
}}
|
||||
>
|
||||
<MicIcon fontSize="large" sx={{ color: "#667eea" }} />
|
||||
AI Podcast Maker
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Create professional podcast episodes with AI-powered research, smart scriptwriting, and natural voice narration
|
||||
</Typography>
|
||||
</Box>
|
||||
<Stack direction="row" spacing={1} alignItems="center">
|
||||
<HeaderControls colorMode="light" showAlerts={true} showUser={true} />
|
||||
<SecondaryButton onClick={() => window.open("/docs", "_blank")} startIcon={<InfoIcon />}>
|
||||
Help
|
||||
</SecondaryButton>
|
||||
<SecondaryButton
|
||||
onClick={() => navigate("/asset-library?source_module=podcast_maker&asset_type=audio")}
|
||||
startIcon={<LibraryMusicIcon />}
|
||||
tooltip="View all podcast episodes in Asset Library"
|
||||
>
|
||||
My Episodes
|
||||
</SecondaryButton>
|
||||
<SecondaryButton
|
||||
onClick={onShowProjects}
|
||||
startIcon={<MicIcon />}
|
||||
tooltip="View and resume saved projects"
|
||||
>
|
||||
My Projects
|
||||
</SecondaryButton>
|
||||
<PrimaryButton onClick={onNewEpisode} startIcon={<AutoAwesomeIcon />}>
|
||||
New Episode
|
||||
</PrimaryButton>
|
||||
</Stack>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
import React from "react";
|
||||
import { Box, Paper, Stepper, Step, StepLabel, Typography, alpha } from "@mui/material";
|
||||
import {
|
||||
Psychology as PsychologyIcon,
|
||||
Search as SearchIcon,
|
||||
EditNote as EditNoteIcon,
|
||||
PlayArrow as PlayArrowIcon,
|
||||
CheckCircle as CheckCircleIcon,
|
||||
} from "@mui/icons-material";
|
||||
|
||||
interface ProgressStepperProps {
|
||||
activeStep: number;
|
||||
completedSteps?: number[]; // Steps that have been completed (have data)
|
||||
onStepClick?: (stepIndex: number) => void;
|
||||
}
|
||||
|
||||
const steps = [
|
||||
{ label: "Analysis", icon: <PsychologyIcon />, description: "AI analyzes your idea" },
|
||||
{ label: "Research", icon: <SearchIcon />, description: "Gather facts and citations" },
|
||||
{ label: "Script", icon: <EditNoteIcon />, description: "Edit and approve scenes" },
|
||||
{ label: "Render", icon: <PlayArrowIcon />, description: "Generate audio files" },
|
||||
];
|
||||
|
||||
export const ProgressStepper: React.FC<ProgressStepperProps> = ({ activeStep, completedSteps = [], onStepClick }) => {
|
||||
if (activeStep < 0) return null;
|
||||
|
||||
const handleStepClick = (stepIndex: number) => {
|
||||
// Allow navigation to any completed step (has data), not just steps before active step
|
||||
const isCompleted = completedSteps.includes(stepIndex);
|
||||
if (isCompleted && onStepClick) {
|
||||
onStepClick(stepIndex);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Paper
|
||||
sx={{
|
||||
p: 2.5,
|
||||
background: "#f8fafc",
|
||||
border: "1px solid rgba(0,0,0,0.08)",
|
||||
borderRadius: 2,
|
||||
}}
|
||||
>
|
||||
<Stepper activeStep={activeStep} orientation="horizontal">
|
||||
{steps.map((step, index) => {
|
||||
const isCompleted = completedSteps.includes(index);
|
||||
const isClickable = isCompleted && onStepClick !== undefined;
|
||||
|
||||
return (
|
||||
<Step key={step.label} completed={isCompleted}>
|
||||
<StepLabel
|
||||
onClick={() => handleStepClick(index)}
|
||||
sx={{
|
||||
cursor: isClickable ? "pointer" : "default",
|
||||
"&:hover": isClickable
|
||||
? {
|
||||
"& .MuiStepLabel-label": {
|
||||
color: "#667eea",
|
||||
},
|
||||
}
|
||||
: {},
|
||||
}}
|
||||
StepIconComponent={({ active, completed }) => (
|
||||
<Box
|
||||
sx={{
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: "50%",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
background: completed
|
||||
? "linear-gradient(135deg, #667eea 0%, #764ba2 100%)"
|
||||
: active
|
||||
? alpha("#667eea", 0.15)
|
||||
: "#e2e8f0",
|
||||
border: active ? "2px solid #667eea" : "1px solid rgba(0,0,0,0.1)",
|
||||
color: completed || active ? "#fff" : "#64748b",
|
||||
transition: "all 0.2s ease",
|
||||
...(isClickable && {
|
||||
cursor: "pointer",
|
||||
"&:hover": {
|
||||
transform: "scale(1.05)",
|
||||
boxShadow: "0 2px 8px rgba(102, 126, 234, 0.3)",
|
||||
},
|
||||
}),
|
||||
}}
|
||||
>
|
||||
{completed ? <CheckCircleIcon /> : step.icon}
|
||||
</Box>
|
||||
)}
|
||||
>
|
||||
<Typography variant="subtitle2">{step.label}</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{step.description}
|
||||
</Typography>
|
||||
</StepLabel>
|
||||
</Step>
|
||||
);
|
||||
})}
|
||||
</Stepper>
|
||||
</Paper>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
import React from "react";
|
||||
import {
|
||||
Stack,
|
||||
Typography,
|
||||
Chip,
|
||||
Tooltip,
|
||||
Alert,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemButton,
|
||||
ListItemText,
|
||||
Checkbox,
|
||||
FormControl,
|
||||
InputLabel,
|
||||
Select,
|
||||
MenuItem,
|
||||
Box,
|
||||
alpha,
|
||||
} from "@mui/material";
|
||||
import { Search as SearchIcon, AutoAwesome as AutoAwesomeIcon } from "@mui/icons-material";
|
||||
import { ResearchProvider } from "../../../services/blogWriterApi";
|
||||
import { Query } from "../types";
|
||||
import { GlassyCard, glassyCardSx, PrimaryButton } from "../ui";
|
||||
|
||||
interface QuerySelectionProps {
|
||||
queries: Query[];
|
||||
selectedQueries: Set<string>;
|
||||
researchProvider: ResearchProvider;
|
||||
isResearching: boolean;
|
||||
onToggleQuery: (id: string) => void;
|
||||
onProviderChange: (provider: ResearchProvider) => void;
|
||||
onRunResearch: () => void;
|
||||
}
|
||||
|
||||
export const QuerySelection: React.FC<QuerySelectionProps> = ({
|
||||
queries,
|
||||
selectedQueries,
|
||||
researchProvider,
|
||||
isResearching,
|
||||
onToggleQuery,
|
||||
onProviderChange,
|
||||
onRunResearch,
|
||||
}) => {
|
||||
const selectedCount = selectedQueries.size;
|
||||
|
||||
return (
|
||||
<GlassyCard
|
||||
sx={{
|
||||
...glassyCardSx,
|
||||
background: "#ffffff",
|
||||
border: "1px solid rgba(0,0,0,0.06)",
|
||||
boxShadow: "0 10px 28px rgba(15,23,42,0.06)",
|
||||
color: "#0f172a",
|
||||
}}
|
||||
>
|
||||
<Stack spacing={3}>
|
||||
<Stack direction="row" justifyContent="space-between" alignItems="center" flexWrap="wrap" gap={2}>
|
||||
<Typography variant="h6" sx={{ display: "flex", alignItems: "center", gap: 1, color: "#0f172a", fontWeight: 700 }}>
|
||||
<SearchIcon />
|
||||
Research Queries
|
||||
</Typography>
|
||||
<Stack direction="row" spacing={2} alignItems="center">
|
||||
<FormControl size="small" sx={{ minWidth: 180 }}>
|
||||
<InputLabel>Provider</InputLabel>
|
||||
<Select
|
||||
value={researchProvider}
|
||||
onChange={(e) => onProviderChange(e.target.value as ResearchProvider)}
|
||||
label="Provider"
|
||||
disabled={isResearching}
|
||||
size="small"
|
||||
sx={{
|
||||
backgroundColor: "#f8fafc",
|
||||
"&:hover": {
|
||||
backgroundColor: "#f1f5f9",
|
||||
},
|
||||
}}
|
||||
>
|
||||
<MenuItem value="google">
|
||||
<Stack direction="row" spacing={1} alignItems="center">
|
||||
<SearchIcon fontSize="small" />
|
||||
<span>Standard Research</span>
|
||||
</Stack>
|
||||
</MenuItem>
|
||||
<MenuItem value="exa">
|
||||
<Stack direction="row" spacing={1} alignItems="center">
|
||||
<AutoAwesomeIcon fontSize="small" />
|
||||
<span>Deep Research</span>
|
||||
</Stack>
|
||||
</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<Chip
|
||||
label={`${selectedCount} / ${queries.length} selected`}
|
||||
size="small"
|
||||
color={selectedCount > 0 ? "primary" : "default"}
|
||||
/>
|
||||
</Stack>
|
||||
</Stack>
|
||||
|
||||
<Tooltip
|
||||
title={
|
||||
researchProvider === "google"
|
||||
? "Standard Research: Fast, fact-checked results with source citations"
|
||||
: "Deep Research: Comprehensive analysis with competitor insights and trending topics"
|
||||
}
|
||||
arrow
|
||||
>
|
||||
<Alert
|
||||
severity="info"
|
||||
sx={{
|
||||
background: "#e0f2fe",
|
||||
border: "1px solid #bae6fd",
|
||||
color: "#0f172a",
|
||||
}}
|
||||
>
|
||||
<Typography variant="caption" sx={{ color: "#0f172a" }}>
|
||||
{researchProvider === "google"
|
||||
? "Select at least one query (recommended: 3+ for balanced coverage). Standard research provides fact-checked results with source citations."
|
||||
: "Select queries for deep research. This mode provides comprehensive analysis with competitor insights and trending topics."}
|
||||
</Typography>
|
||||
</Alert>
|
||||
</Tooltip>
|
||||
|
||||
<List>
|
||||
{queries.map((q) => (
|
||||
<ListItem key={q.id} disablePadding>
|
||||
<ListItemButton
|
||||
onClick={() => onToggleQuery(q.id)}
|
||||
disabled={isResearching}
|
||||
sx={{
|
||||
borderRadius: 2,
|
||||
mb: 1,
|
||||
border: "1px solid rgba(0,0,0,0.08)",
|
||||
background: "#f8fafc",
|
||||
"&:hover": { background: alpha("#667eea", 0.08) },
|
||||
}}
|
||||
>
|
||||
<Checkbox checked={selectedQueries.has(q.id)} edge="start" />
|
||||
<ListItemText
|
||||
primary={q.query}
|
||||
secondary={q.rationale}
|
||||
primaryTypographyProps={{ variant: "body2", fontWeight: 600, color: "#0f172a" }}
|
||||
secondaryTypographyProps={{ variant: "caption", sx: { color: "#475569" } }}
|
||||
/>
|
||||
</ListItemButton>
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
|
||||
<Box sx={{ display: "flex", justifyContent: "flex-end" }}>
|
||||
<PrimaryButton
|
||||
onClick={onRunResearch}
|
||||
disabled={selectedCount === 0 || isResearching}
|
||||
loading={isResearching}
|
||||
startIcon={<SearchIcon />}
|
||||
tooltip={
|
||||
selectedCount === 0
|
||||
? "Select at least one query to run research"
|
||||
: `Run research with ${selectedCount} selected ${selectedCount === 1 ? "query" : "queries"}`
|
||||
}
|
||||
>
|
||||
{isResearching ? "Running Research..." : "Run Research"}
|
||||
</PrimaryButton>
|
||||
</Box>
|
||||
</Stack>
|
||||
</GlassyCard>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
import React from "react";
|
||||
import { Stack, Typography, Chip, Divider, Box, alpha } from "@mui/material";
|
||||
import {
|
||||
Insights as InsightsIcon,
|
||||
Search as SearchIcon,
|
||||
AttachMoney as AttachMoneyIcon,
|
||||
EditNote as EditNoteIcon,
|
||||
} from "@mui/icons-material";
|
||||
import { Research } from "../types";
|
||||
import { GlassyCard, glassyCardSx, PrimaryButton } from "../ui";
|
||||
import { FactCard } from "../FactCard";
|
||||
|
||||
interface ResearchSummaryProps {
|
||||
research: Research;
|
||||
canGenerateScript: boolean;
|
||||
onGenerateScript: () => void;
|
||||
}
|
||||
|
||||
export const ResearchSummary: React.FC<ResearchSummaryProps> = ({
|
||||
research,
|
||||
canGenerateScript,
|
||||
onGenerateScript,
|
||||
}) => {
|
||||
return (
|
||||
<GlassyCard sx={glassyCardSx}>
|
||||
<Stack spacing={3}>
|
||||
<Stack direction="row" justifyContent="space-between" alignItems="flex-start" flexWrap="wrap" gap={2}>
|
||||
<Box sx={{ flex: 1 }}>
|
||||
<Typography variant="h6" sx={{ display: "flex", alignItems: "center", gap: 1, mb: 1 }}>
|
||||
<InsightsIcon />
|
||||
Research Summary
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 2, lineHeight: 1.7 }}>
|
||||
{research.summary}
|
||||
</Typography>
|
||||
|
||||
{/* Research Metadata */}
|
||||
<Stack direction="row" spacing={2} flexWrap="wrap" useFlexGap sx={{ mb: 2 }}>
|
||||
{research.searchQueries && research.searchQueries.length > 0 && (
|
||||
<Chip
|
||||
icon={<SearchIcon />}
|
||||
label={`${research.searchQueries.length} search${research.searchQueries.length > 1 ? "es" : ""}`}
|
||||
size="small"
|
||||
sx={{
|
||||
background: alpha("#667eea", 0.1),
|
||||
color: "#667eea",
|
||||
fontWeight: 600,
|
||||
border: "1px solid rgba(102, 126, 234, 0.2)",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{research.searchType && (
|
||||
<Chip
|
||||
label={`${research.searchType.charAt(0).toUpperCase() + research.searchType.slice(1)} search`}
|
||||
size="small"
|
||||
sx={{
|
||||
background: alpha("#10b981", 0.1),
|
||||
color: "#059669",
|
||||
fontWeight: 600,
|
||||
border: "1px solid rgba(16, 185, 129, 0.2)",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{research.sourceCount !== undefined && (
|
||||
<Chip
|
||||
label={`${research.sourceCount} source${research.sourceCount !== 1 ? "s" : ""}`}
|
||||
size="small"
|
||||
sx={{
|
||||
background: alpha("#6366f1", 0.1),
|
||||
color: "#4f46e5",
|
||||
fontWeight: 600,
|
||||
border: "1px solid rgba(99, 102, 241, 0.2)",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{research.cost !== undefined && (
|
||||
<Chip
|
||||
icon={<AttachMoneyIcon sx={{ fontSize: "0.875rem !important" }} />}
|
||||
label={`$${research.cost.toFixed(3)}`}
|
||||
size="small"
|
||||
sx={{
|
||||
background: alpha("#f59e0b", 0.1),
|
||||
color: "#d97706",
|
||||
fontWeight: 600,
|
||||
border: "1px solid rgba(245, 158, 11, 0.2)",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
{/* Search Queries Used */}
|
||||
{research.searchQueries && research.searchQueries.length > 0 && (
|
||||
<Box sx={{ mb: 2 }}>
|
||||
<Typography variant="subtitle2" sx={{ mb: 1, color: "#0f172a", fontWeight: 600 }}>
|
||||
Search Queries Used
|
||||
</Typography>
|
||||
<Stack direction="row" spacing={1} flexWrap="wrap" useFlexGap>
|
||||
{research.searchQueries.map((query, idx) => (
|
||||
<Chip
|
||||
key={idx}
|
||||
label={query}
|
||||
size="small"
|
||||
variant="outlined"
|
||||
sx={{
|
||||
borderColor: "rgba(102, 126, 234, 0.3)",
|
||||
color: "#475569",
|
||||
background: alpha("#f8fafc", 0.8),
|
||||
fontSize: "0.8125rem",
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
<PrimaryButton
|
||||
onClick={onGenerateScript}
|
||||
disabled={!canGenerateScript}
|
||||
startIcon={<EditNoteIcon />}
|
||||
tooltip={!canGenerateScript ? "Complete research to generate script" : "Generate AI-powered script from research"}
|
||||
>
|
||||
Generate Script
|
||||
</PrimaryButton>
|
||||
</Stack>
|
||||
|
||||
{research.factCards.length > 0 && (
|
||||
<>
|
||||
<Divider sx={{ borderColor: "rgba(0,0,0,0.08)" }} />
|
||||
<Stack direction="row" justifyContent="space-between" alignItems="center" sx={{ mb: 1.5 }}>
|
||||
<Typography variant="subtitle2" sx={{ color: "#0f172a", fontWeight: 600 }}>
|
||||
Research Sources & Facts ({research.factCards.length})
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ color: "#64748b" }}>
|
||||
Click any card to view source details
|
||||
</Typography>
|
||||
</Stack>
|
||||
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr", lg: "1fr 1fr 1fr" }, gap: 2 }}>
|
||||
{research.factCards.map((fact) => (
|
||||
<FactCard key={fact.id} fact={fact} />
|
||||
))}
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
</GlassyCard>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
export { Header } from "./Header";
|
||||
export { ProgressStepper } from "./ProgressStepper";
|
||||
export { EstimateCard } from "./EstimateCard";
|
||||
export { QuerySelection } from "./QuerySelection";
|
||||
export { ResearchSummary } from "./ResearchSummary";
|
||||
export { usePodcastWorkflow } from "./usePodcastWorkflow";
|
||||
export * from "./utils";
|
||||
|
||||
@@ -0,0 +1,302 @@
|
||||
import { useState, useEffect, useMemo, useCallback } from "react";
|
||||
import { ResearchProvider, ResearchConfig } from "../../../services/blogWriterApi";
|
||||
import { podcastApi } from "../../../services/podcastApi";
|
||||
import { usePreflightCheck } from "../../../hooks/usePreflightCheck";
|
||||
import { useBudgetTracking } from "../../../hooks/useBudgetTracking";
|
||||
import { CreateProjectPayload, Query, Research, Script, Job } from "../types";
|
||||
import { usePodcastProjectState } from "../../../hooks/usePodcastProjectState";
|
||||
import { sanitizeExaConfig, announceError, getStepLabel } from "./utils";
|
||||
|
||||
type PodcastProjectStateReturn = ReturnType<typeof usePodcastProjectState>;
|
||||
|
||||
interface UsePodcastWorkflowProps {
|
||||
projectState: PodcastProjectStateReturn;
|
||||
onError: (message: string) => void;
|
||||
}
|
||||
|
||||
export const usePodcastWorkflow = ({ projectState, onError }: UsePodcastWorkflowProps) => {
|
||||
const {
|
||||
project,
|
||||
analysis,
|
||||
queries,
|
||||
selectedQueries,
|
||||
research,
|
||||
rawResearch,
|
||||
researchProvider,
|
||||
showScriptEditor,
|
||||
showRenderQueue,
|
||||
currentStep,
|
||||
renderJobs,
|
||||
budgetCap,
|
||||
setProject,
|
||||
setAnalysis,
|
||||
setQueries,
|
||||
setSelectedQueries,
|
||||
setResearch,
|
||||
setRawResearch,
|
||||
setEstimate,
|
||||
setScriptData,
|
||||
setShowScriptEditor,
|
||||
setShowRenderQueue,
|
||||
setKnobs,
|
||||
setResearchProvider,
|
||||
setBudgetCap,
|
||||
updateRenderJob,
|
||||
initializeProject,
|
||||
} = projectState;
|
||||
|
||||
const [isAnalyzing, setIsAnalyzing] = useState(false);
|
||||
const [isResearching, setIsResearching] = useState(false);
|
||||
const [announcement, setAnnouncement] = useState("");
|
||||
const [showResumeAlert, setShowResumeAlert] = useState(false);
|
||||
const [showPreflightDialog, setShowPreflightDialog] = useState(false);
|
||||
const [preflightResponse, setPreflightResponse] = useState<any>(null);
|
||||
const [preflightOperationName, setPreflightOperationName] = useState<string>("");
|
||||
|
||||
const budgetTracking = useBudgetTracking(budgetCap || 50);
|
||||
const preflightCheck = usePreflightCheck({
|
||||
onBlocked: (response) => {
|
||||
setPreflightResponse(response);
|
||||
setShowPreflightDialog(true);
|
||||
},
|
||||
});
|
||||
|
||||
// Update budget cap when project state changes
|
||||
useEffect(() => {
|
||||
if (budgetCap) {
|
||||
budgetTracking.setBudgetCap(budgetCap);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [budgetCap]);
|
||||
|
||||
// Check if we have a saved project on mount
|
||||
useEffect(() => {
|
||||
if (project && currentStep && currentStep !== "create") {
|
||||
setShowResumeAlert(true);
|
||||
setTimeout(() => setShowResumeAlert(false), 5000);
|
||||
}
|
||||
}, []); // Only on mount
|
||||
|
||||
useEffect(() => {
|
||||
if (announcement) {
|
||||
const t = setTimeout(() => setAnnouncement(""), 4000);
|
||||
return () => clearTimeout(t);
|
||||
}
|
||||
return undefined;
|
||||
}, [announcement]);
|
||||
|
||||
const handleCreate = useCallback(async (payload: CreateProjectPayload) => {
|
||||
if (isAnalyzing) return;
|
||||
setResearch(null);
|
||||
setRawResearch(null);
|
||||
setScriptData(null);
|
||||
setShowScriptEditor(false);
|
||||
setShowRenderQueue(false);
|
||||
try {
|
||||
setIsAnalyzing(true);
|
||||
setAnnouncement("Analyzing your idea — AI suggestions incoming");
|
||||
const result = await podcastApi.createProject(payload);
|
||||
await initializeProject(payload, result.projectId);
|
||||
setProject({ id: result.projectId, idea: payload.ideaOrUrl, duration: payload.duration, speakers: payload.speakers });
|
||||
setAnalysis(result.analysis);
|
||||
setEstimate(result.estimate);
|
||||
setQueries(result.queries);
|
||||
setSelectedQueries(new Set(result.queries.map((q) => q.id)));
|
||||
setKnobs(payload.knobs);
|
||||
setBudgetCap(payload.budgetCap);
|
||||
setAnnouncement("Analysis complete");
|
||||
} catch (error: any) {
|
||||
if (error?.response?.status === 429 || error?.response?.data?.detail) {
|
||||
const errorDetail = error.response.data.detail;
|
||||
if (typeof errorDetail === 'object' && errorDetail.error && errorDetail.error.includes('limit')) {
|
||||
const usageInfo = errorDetail.usage_info || {};
|
||||
const blockedResponse = {
|
||||
can_proceed: false,
|
||||
estimated_cost: 0,
|
||||
operations: [{
|
||||
provider: errorDetail.provider || 'huggingface',
|
||||
operation_type: 'ai_text_generation',
|
||||
cost: 0,
|
||||
allowed: false,
|
||||
limit_info: usageInfo.limit_info || null,
|
||||
message: errorDetail.message || errorDetail.error || 'Subscription limit exceeded',
|
||||
}],
|
||||
total_cost: 0,
|
||||
usage_summary: usageInfo.usage_summary || null,
|
||||
cached: false,
|
||||
};
|
||||
setPreflightResponse(blockedResponse);
|
||||
setPreflightOperationName('Podcast Analysis');
|
||||
setShowPreflightDialog(true);
|
||||
setAnnouncement("Subscription limit reached. Please upgrade to continue.");
|
||||
} else {
|
||||
const message = typeof errorDetail === 'string' ? errorDetail : errorDetail.message || errorDetail.error || 'Request limit exceeded';
|
||||
announceError(setAnnouncement, new Error(message));
|
||||
}
|
||||
} else {
|
||||
announceError(setAnnouncement, error);
|
||||
}
|
||||
} finally {
|
||||
setIsAnalyzing(false);
|
||||
}
|
||||
}, [isAnalyzing, setResearch, setRawResearch, setScriptData, setShowScriptEditor, setShowRenderQueue, initializeProject, setProject, setAnalysis, setEstimate, setQueries, setSelectedQueries, setKnobs, setBudgetCap]);
|
||||
|
||||
const handleRunResearch = useCallback(async () => {
|
||||
if (isResearching) return;
|
||||
if (!project) {
|
||||
setAnnouncement("Create a project first.");
|
||||
return;
|
||||
}
|
||||
if (selectedQueries.size === 0) {
|
||||
setAnnouncement("Select at least one query to research.");
|
||||
return;
|
||||
}
|
||||
|
||||
setPreflightOperationName("Research");
|
||||
const approvedQueries = queries.filter((q) => selectedQueries.has(q.id));
|
||||
const preflightResult = await preflightCheck.check({
|
||||
provider: researchProvider === "exa" ? "exa" : "gemini",
|
||||
operation_type: researchProvider === "exa" ? "exa_neural_search" : "google_grounding",
|
||||
tokens_requested: researchProvider === "exa" ? 0 : 1200,
|
||||
actual_provider_name: researchProvider || "exa",
|
||||
});
|
||||
|
||||
if (!preflightResult.can_proceed) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setIsResearching(true);
|
||||
setAnnouncement(`Starting ${researchProvider === "exa" ? "deep" : "standard"} research — this may take a moment...`);
|
||||
setResearch(null);
|
||||
setRawResearch(null);
|
||||
setScriptData(null);
|
||||
setShowScriptEditor(false);
|
||||
setShowRenderQueue(false);
|
||||
|
||||
try {
|
||||
const { research: mapped, raw } = await podcastApi.runResearch({
|
||||
projectId: project.id,
|
||||
topic: project.idea,
|
||||
approvedQueries,
|
||||
provider: researchProvider,
|
||||
exaConfig: sanitizeExaConfig(analysis?.exaSuggestedConfig),
|
||||
onProgress: (message) => {
|
||||
setAnnouncement(message);
|
||||
},
|
||||
});
|
||||
setResearch(mapped);
|
||||
setRawResearch(raw);
|
||||
setAnnouncement("Research complete — review fact cards below");
|
||||
} catch (researchError) {
|
||||
const errorMessage = researchError instanceof Error
|
||||
? researchError.message
|
||||
: "Research failed. Please try again or switch to Standard Research.";
|
||||
|
||||
if (errorMessage.includes("Exa") || errorMessage.includes("exa")) {
|
||||
setAnnouncement(`Deep research failed: ${errorMessage}. Try Standard Research instead.`);
|
||||
} else if (errorMessage.includes("timeout")) {
|
||||
setAnnouncement("Research timed out. Please try again with fewer queries.");
|
||||
} else {
|
||||
setAnnouncement(`Research failed: ${errorMessage}`);
|
||||
}
|
||||
|
||||
console.error("Research error:", researchError);
|
||||
throw researchError;
|
||||
}
|
||||
} catch (error) {
|
||||
announceError(setAnnouncement, error);
|
||||
} finally {
|
||||
setIsResearching(false);
|
||||
}
|
||||
}, [isResearching, project, selectedQueries, queries, researchProvider, preflightCheck, analysis, setResearch, setRawResearch, setScriptData, setShowScriptEditor, setShowRenderQueue]);
|
||||
|
||||
const handleGenerateScript = useCallback(async () => {
|
||||
if (showScriptEditor) return;
|
||||
if (!project || !research) {
|
||||
setAnnouncement("Project or research missing — cannot generate script");
|
||||
return;
|
||||
}
|
||||
|
||||
setPreflightOperationName("Script Generation");
|
||||
const preflightResult = await preflightCheck.check({
|
||||
provider: "gemini",
|
||||
operation_type: "script_generation",
|
||||
tokens_requested: 2000,
|
||||
actual_provider_name: "gemini",
|
||||
});
|
||||
|
||||
if (!preflightResult.can_proceed) {
|
||||
return;
|
||||
}
|
||||
|
||||
setScriptData(null);
|
||||
setShowRenderQueue(false);
|
||||
setShowScriptEditor(true);
|
||||
}, [showScriptEditor, project, research, preflightCheck, setScriptData, setShowRenderQueue, setShowScriptEditor]);
|
||||
|
||||
const handleProceedToRendering = useCallback((script: Script) => {
|
||||
setScriptData(script);
|
||||
if (renderJobs.length === 0) {
|
||||
script.scenes.forEach((scene) => {
|
||||
const hasExistingAudio = Boolean(scene.audioUrl);
|
||||
updateRenderJob(scene.id, {
|
||||
sceneId: scene.id,
|
||||
title: scene.title,
|
||||
status: hasExistingAudio ? ("completed" as const) : ("idle" as const),
|
||||
progress: hasExistingAudio ? 100 : 0,
|
||||
previewUrl: null,
|
||||
finalUrl: hasExistingAudio ? scene.audioUrl : null,
|
||||
jobId: null,
|
||||
});
|
||||
});
|
||||
}
|
||||
setShowRenderQueue(true);
|
||||
setShowScriptEditor(false);
|
||||
}, [renderJobs.length, setScriptData, updateRenderJob, setShowRenderQueue, setShowScriptEditor]);
|
||||
|
||||
const toggleQuery = useCallback((id: string) => {
|
||||
if (isResearching) return;
|
||||
const current = selectedQueries;
|
||||
const next = new Set<string>(current);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
setSelectedQueries(next);
|
||||
}, [isResearching, selectedQueries, setSelectedQueries]);
|
||||
|
||||
const activeStep = useMemo(() => {
|
||||
if (showRenderQueue) return 3;
|
||||
if (showScriptEditor) return 2;
|
||||
if (research) return 1;
|
||||
if (analysis) return 0;
|
||||
return -1;
|
||||
}, [showRenderQueue, showScriptEditor, research, analysis]);
|
||||
|
||||
const canGenerateScript = Boolean(project && research && rawResearch);
|
||||
|
||||
return {
|
||||
// State
|
||||
isAnalyzing,
|
||||
isResearching,
|
||||
announcement,
|
||||
showResumeAlert,
|
||||
showPreflightDialog,
|
||||
preflightResponse,
|
||||
preflightOperationName,
|
||||
activeStep,
|
||||
canGenerateScript,
|
||||
// Handlers
|
||||
handleCreate,
|
||||
handleRunResearch,
|
||||
handleGenerateScript,
|
||||
handleProceedToRendering,
|
||||
toggleQuery,
|
||||
setAnnouncement,
|
||||
setShowResumeAlert,
|
||||
setShowPreflightDialog,
|
||||
setPreflightResponse,
|
||||
setResearchProvider,
|
||||
getStepLabel,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import { ResearchConfig, DateRange } from "../../../services/blogWriterApi";
|
||||
import { CreateProjectPayload, Knobs } from "../types";
|
||||
|
||||
export const DEFAULT_KNOBS: Knobs = {
|
||||
voice_emotion: "neutral",
|
||||
voice_speed: 1,
|
||||
resolution: "720p",
|
||||
scene_length_target: 45,
|
||||
sample_rate: 24000,
|
||||
bitrate: "standard",
|
||||
};
|
||||
|
||||
export const allowedDateRanges: DateRange[] = [
|
||||
"last_week",
|
||||
"last_month",
|
||||
"last_3_months",
|
||||
"last_6_months",
|
||||
"last_year",
|
||||
"all_time",
|
||||
];
|
||||
|
||||
export const sanitizeExaConfig = (
|
||||
exa?: CreateProjectPayload["knobs"] & any & { exa_suggested_config?: any } | any
|
||||
): ResearchConfig | undefined => {
|
||||
if (!exa) return undefined;
|
||||
const cfg = exa as {
|
||||
exa_search_type?: "auto" | "keyword" | "neural";
|
||||
exa_category?: string;
|
||||
exa_include_domains?: string[];
|
||||
exa_exclude_domains?: string[];
|
||||
max_sources?: number;
|
||||
include_statistics?: boolean;
|
||||
date_range?: string;
|
||||
};
|
||||
|
||||
const searchType: ResearchConfig["exa_search_type"] =
|
||||
cfg.exa_search_type && ["auto", "keyword", "neural"].includes(cfg.exa_search_type)
|
||||
? cfg.exa_search_type
|
||||
: undefined;
|
||||
|
||||
const dateRange: DateRange | undefined = cfg.date_range && allowedDateRanges.includes(cfg.date_range as DateRange)
|
||||
? (cfg.date_range as DateRange)
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
provider: "exa",
|
||||
exa_search_type: searchType,
|
||||
exa_category: cfg.exa_category,
|
||||
exa_include_domains: cfg.exa_include_domains,
|
||||
exa_exclude_domains: cfg.exa_exclude_domains,
|
||||
max_sources: cfg.max_sources,
|
||||
include_statistics: cfg.include_statistics,
|
||||
date_range: dateRange,
|
||||
};
|
||||
};
|
||||
|
||||
export const announceError = (setAnnouncement: (msg: string) => void, error: unknown) => {
|
||||
const message = error instanceof Error ? error.message : "Unexpected error";
|
||||
setAnnouncement(message);
|
||||
};
|
||||
|
||||
export const getStepLabel = (step: string | null): string => {
|
||||
switch (step) {
|
||||
case "analysis":
|
||||
return "Analysis";
|
||||
case "research":
|
||||
return "Research";
|
||||
case "script":
|
||||
return "Script Editing";
|
||||
case "render":
|
||||
return "Rendering";
|
||||
default:
|
||||
return "Unknown";
|
||||
}
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user