feat: podcast demo mode with ALWRITY_ENABLED_FEATURES support
- Add ALWRITY_ENABLED_FEATURES env var for feature gating - Podcast-only mode: skip LLM bootstrap, scheduler, persona services - Enhance video generation prompt with scene context, analysis, narration - Add voice cloning support via custom_voice_id in WaveSpeed - Add text-to-speech for research results (browser speechSynthesis) - Fix render queue to sync images from script phase - Add WaveSpeed LLM pricing (gpt-oss-120b) - Fix podcast bible generation error handling - Refactor RouterManager for feature-based router loading
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import React from "react";
|
||||
import React, { useState } from "react";
|
||||
import {
|
||||
Stack,
|
||||
Typography,
|
||||
@@ -16,11 +16,17 @@ import {
|
||||
MenuItem,
|
||||
Box,
|
||||
alpha,
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
TextField,
|
||||
IconButton,
|
||||
} from "@mui/material";
|
||||
import { Search as SearchIcon, AutoAwesome as AutoAwesomeIcon } from "@mui/icons-material";
|
||||
import { Search as SearchIcon, AutoAwesome as AutoAwesomeIcon, Refresh as RefreshIcon, Edit as EditIcon, Delete as DeleteIcon, CheckCircle as CheckCircleIcon } from "@mui/icons-material";
|
||||
import { ResearchProvider } from "../../../services/blogWriterApi";
|
||||
import { Query } from "../types";
|
||||
import { GlassyCard, glassyCardSx, PrimaryButton } from "../ui";
|
||||
import { GlassyCard, glassyCardSx, PrimaryButton, SecondaryButton } from "../ui";
|
||||
|
||||
interface QuerySelectionProps {
|
||||
queries: Query[];
|
||||
@@ -30,6 +36,11 @@ interface QuerySelectionProps {
|
||||
onToggleQuery: (id: string) => void;
|
||||
onProviderChange: (provider: ResearchProvider) => void;
|
||||
onRunResearch: () => void;
|
||||
onRegenerateQueries: (feedback: string) => Promise<void>;
|
||||
onUpdateQuery: (id: string, newQuery: string, newRationale: string) => void;
|
||||
onDeleteQuery: (id: string) => void;
|
||||
analysis: any;
|
||||
idea: string;
|
||||
}
|
||||
|
||||
export const QuerySelection: React.FC<QuerySelectionProps> = ({
|
||||
@@ -40,9 +51,51 @@ export const QuerySelection: React.FC<QuerySelectionProps> = ({
|
||||
onToggleQuery,
|
||||
onProviderChange,
|
||||
onRunResearch,
|
||||
onRegenerateQueries,
|
||||
onUpdateQuery,
|
||||
onDeleteQuery,
|
||||
analysis,
|
||||
idea,
|
||||
}) => {
|
||||
const [showRegenDialog, setShowRegenDialog] = useState(false);
|
||||
const [regenFeedback, setRegenFeedback] = useState("");
|
||||
const [isRegenerating, setIsRegenerating] = useState(false);
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
const [editQuery, setEditQuery] = useState("");
|
||||
const [editRationale, setEditRationale] = useState("");
|
||||
const selectedCount = selectedQueries.size;
|
||||
|
||||
const handleRegenerate = async () => {
|
||||
if (!regenFeedback.trim()) return;
|
||||
setIsRegenerating(true);
|
||||
try {
|
||||
await onRegenerateQueries(regenFeedback);
|
||||
setShowRegenDialog(false);
|
||||
setRegenFeedback("");
|
||||
} finally {
|
||||
setIsRegenerating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const startEdit = (q: Query) => {
|
||||
setEditingId(q.id);
|
||||
setEditQuery(q.query);
|
||||
setEditRationale(q.rationale);
|
||||
};
|
||||
|
||||
const saveEdit = () => {
|
||||
if (editingId && editQuery.trim()) {
|
||||
onUpdateQuery(editingId, editQuery.trim(), editRationale.trim());
|
||||
setEditingId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const cancelEdit = () => {
|
||||
setEditingId(null);
|
||||
setEditQuery("");
|
||||
setEditRationale("");
|
||||
};
|
||||
|
||||
return (
|
||||
<GlassyCard
|
||||
sx={{
|
||||
@@ -55,10 +108,22 @@ export const QuerySelection: React.FC<QuerySelectionProps> = ({
|
||||
>
|
||||
<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" alignItems="center" spacing={1}>
|
||||
<Typography variant="h6" sx={{ display: "flex", alignItems: "center", gap: 1, color: "#0f172a", fontWeight: 700 }}>
|
||||
<SearchIcon />
|
||||
Research Queries
|
||||
</Typography>
|
||||
<Tooltip title="Regenerate research queries with custom feedback">
|
||||
<PrimaryButton
|
||||
size="small"
|
||||
startIcon={<RefreshIcon />}
|
||||
onClick={() => setShowRegenDialog(true)}
|
||||
sx={{ py: 0.5, px: 1.5, fontSize: "0.75rem" }}
|
||||
>
|
||||
Regenerate
|
||||
</PrimaryButton>
|
||||
</Tooltip>
|
||||
</Stack>
|
||||
<Stack direction="row" spacing={2} alignItems="center">
|
||||
<FormControl size="small" sx={{ minWidth: 180 }}>
|
||||
<InputLabel>Provider</InputLabel>
|
||||
@@ -123,26 +188,70 @@ export const QuerySelection: React.FC<QuerySelectionProps> = ({
|
||||
|
||||
<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
|
||||
key={q.id}
|
||||
disablePadding
|
||||
secondaryAction={
|
||||
editingId === q.id ? (
|
||||
<Stack direction="row" spacing={0.5}>
|
||||
<IconButton size="small" onClick={saveEdit} sx={{ color: "#22c55e" }}>
|
||||
<CheckCircleIcon />
|
||||
</IconButton>
|
||||
<IconButton size="small" onClick={cancelEdit} sx={{ color: "#ef4444" }}>
|
||||
<DeleteIcon />
|
||||
</IconButton>
|
||||
</Stack>
|
||||
) : (
|
||||
<Stack direction="row" spacing={0.5} onClick={(e) => e.stopPropagation()}>
|
||||
<IconButton size="small" onClick={() => startEdit(q)} sx={{ color: "#6366f1" }}>
|
||||
<EditIcon />
|
||||
</IconButton>
|
||||
<IconButton size="small" onClick={() => onDeleteQuery(q.id)} sx={{ color: "#ef4444" }}>
|
||||
<DeleteIcon />
|
||||
</IconButton>
|
||||
</Stack>
|
||||
)
|
||||
}
|
||||
>
|
||||
{editingId === q.id ? (
|
||||
<Box sx={{ width: "100%", p: 1.5, bgcolor: "#f0f9ff", borderRadius: 2, border: "1px solid #bae6fd" }}>
|
||||
<TextField
|
||||
fullWidth
|
||||
size="small"
|
||||
label="Query"
|
||||
value={editQuery}
|
||||
onChange={(e) => setEditQuery(e.target.value)}
|
||||
sx={{ mb: 1 }}
|
||||
/>
|
||||
<TextField
|
||||
fullWidth
|
||||
size="small"
|
||||
label="Rationale"
|
||||
value={editRationale}
|
||||
onChange={(e) => setEditRationale(e.target.value)}
|
||||
/>
|
||||
</Box>
|
||||
) : (
|
||||
<ListItemButton
|
||||
onClick={() => onToggleQuery(q.id)}
|
||||
disabled={isResearching}
|
||||
sx={{
|
||||
borderRadius: 2,
|
||||
mb: 1,
|
||||
border: "1px solid rgba(0,0,0,0.08)",
|
||||
background: selectedQueries.has(q.id) ? alpha("#667eea", 0.08) : "#f8fafc",
|
||||
"&:hover": { background: alpha("#667eea", 0.12) },
|
||||
}}
|
||||
>
|
||||
<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>
|
||||
@@ -163,6 +272,69 @@ export const QuerySelection: React.FC<QuerySelectionProps> = ({
|
||||
</PrimaryButton>
|
||||
</Box>
|
||||
</Stack>
|
||||
|
||||
{/* Regenerate Queries Dialog */}
|
||||
<Dialog
|
||||
open={showRegenDialog}
|
||||
onClose={() => setShowRegenDialog(false)}
|
||||
maxWidth="sm"
|
||||
fullWidth
|
||||
PaperProps={{
|
||||
sx: {
|
||||
background: "linear-gradient(135deg, #1e293b 0%, #0f172a 100%)",
|
||||
border: "1px solid rgba(167, 139, 250, 0.3)",
|
||||
borderRadius: 3,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<DialogTitle sx={{ color: "#fff", display: "flex", alignItems: "center", gap: 1 }}>
|
||||
<RefreshIcon sx={{ color: "#a78bfa" }} />
|
||||
Regenerate Research Queries
|
||||
</DialogTitle>
|
||||
<DialogContent sx={{ color: "rgba(255,255,255,0.8)" }}>
|
||||
<Typography variant="body2" sx={{ mb: 2, color: "rgba(255,255,255,0.7)" }}>
|
||||
Provide custom directions to regenerate research queries. You can specify:
|
||||
</Typography>
|
||||
<Box sx={{ pl: 2, mb: 2 }}>
|
||||
<Typography variant="caption" sx={{ color: "rgba(255,255,255,0.6)", display: "block", mb: 0.5 }}>
|
||||
• Specific topics or angles you want to explore
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ color: "rgba(255,255,255,0.6)", display: "block", mb: 0.5 }}>
|
||||
• Questions you want answered
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ color: "rgba(255,255,255,0.6)", display: "block", mb: 0.5 }}>
|
||||
• Areas where you need more depth
|
||||
</Typography>
|
||||
</Box>
|
||||
<TextField
|
||||
fullWidth
|
||||
multiline
|
||||
rows={4}
|
||||
placeholder="e.g., I want to focus more on competitive landscape and pricing strategies. Also need stats on market growth in 2025..."
|
||||
value={regenFeedback}
|
||||
onChange={(e) => setRegenFeedback(e.target.value)}
|
||||
sx={{
|
||||
"& .MuiOutlinedInput-root": {
|
||||
color: "#fff",
|
||||
"& fieldset": { borderColor: "rgba(255,255,255,0.2)" },
|
||||
"&:hover fieldset": { borderColor: "rgba(255,255,255,0.3)" },
|
||||
"&.Mui-focused fieldset": { borderColor: "#a78bfa" },
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</DialogContent>
|
||||
<DialogActions sx={{ px: 3, pb: 3 }}>
|
||||
<SecondaryButton onClick={() => setShowRegenDialog(false)}>Cancel</SecondaryButton>
|
||||
<PrimaryButton
|
||||
onClick={handleRegenerate}
|
||||
disabled={!regenFeedback.trim() || isRegenerating}
|
||||
loading={isRegenerating}
|
||||
startIcon={<RefreshIcon />}
|
||||
>
|
||||
Generate New Queries
|
||||
</PrimaryButton>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</GlassyCard>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useMemo, useCallback } from "react";
|
||||
import { Stack, Typography, Chip, Divider, Box, alpha, Paper } from "@mui/material";
|
||||
import { Stack, Typography, Chip, Divider, Box, alpha, Paper, Stepper, Step, StepLabel, CircularProgress } from "@mui/material";
|
||||
import {
|
||||
Insights as InsightsIcon,
|
||||
Search as SearchIcon,
|
||||
@@ -7,21 +7,26 @@ import {
|
||||
EditNote as EditNoteIcon,
|
||||
Article as ArticleIcon,
|
||||
AutoAwesome as AutoAwesomeIcon,
|
||||
ArrowForward as ArrowForwardIcon,
|
||||
CheckCircle as CheckCircleIcon,
|
||||
} from "@mui/icons-material";
|
||||
import { Research, ResearchInsight } from "../types";
|
||||
import { GlassyCard, glassyCardSx, PrimaryButton } from "../ui";
|
||||
import { FactCard } from "../FactCard";
|
||||
import { TextToSpeechButton } from "../../shared/TextToSpeechButton";
|
||||
|
||||
interface ResearchSummaryProps {
|
||||
research: Research;
|
||||
canGenerateScript: boolean;
|
||||
onGenerateScript: () => void;
|
||||
isGeneratingScript?: boolean;
|
||||
}
|
||||
|
||||
export const ResearchSummary: React.FC<ResearchSummaryProps> = ({
|
||||
research,
|
||||
canGenerateScript,
|
||||
onGenerateScript,
|
||||
isGeneratingScript = false,
|
||||
}) => {
|
||||
// Simple markdown-to-HTML converter
|
||||
const renderMarkdown = useCallback((text: string) => {
|
||||
@@ -51,6 +56,34 @@ export const ResearchSummary: React.FC<ResearchSummaryProps> = ({
|
||||
return (
|
||||
<GlassyCard sx={glassyCardSx}>
|
||||
<Stack spacing={3}>
|
||||
{/* Step Indicator */}
|
||||
<Box sx={{ mb: 1 }}>
|
||||
<Stepper activeStep={1} alternativeLabel>
|
||||
<Step completed>
|
||||
<StepLabel
|
||||
StepIconComponent={() => <CheckCircleIcon sx={{ color: "#22c55e", fontSize: 24 }} />}
|
||||
>
|
||||
Analysis
|
||||
</StepLabel>
|
||||
</Step>
|
||||
<Step active>
|
||||
<StepLabel>
|
||||
Research
|
||||
</StepLabel>
|
||||
</Step>
|
||||
<Step>
|
||||
<StepLabel>
|
||||
Script
|
||||
</StepLabel>
|
||||
</Step>
|
||||
<Step>
|
||||
<StepLabel>
|
||||
Render
|
||||
</StepLabel>
|
||||
</Step>
|
||||
</Stepper>
|
||||
</Box>
|
||||
|
||||
<Stack direction="row" justifyContent="space-between" alignItems="center" flexWrap="wrap" gap={2}>
|
||||
<Stack direction="row" alignItems="center" spacing={2} sx={{ flex: 1 }}>
|
||||
<Typography variant="h6" sx={{ display: "flex", alignItems: "center", gap: 1, color: "#0f172a", fontWeight: 700 }}>
|
||||
@@ -115,11 +148,31 @@ export const ResearchSummary: React.FC<ResearchSummaryProps> = ({
|
||||
|
||||
<PrimaryButton
|
||||
onClick={onGenerateScript}
|
||||
disabled={!canGenerateScript}
|
||||
startIcon={<EditNoteIcon />}
|
||||
disabled={!canGenerateScript || isGeneratingScript}
|
||||
startIcon={isGeneratingScript ? <CircularProgress size={18} color="inherit" /> : <EditNoteIcon />}
|
||||
endIcon={isGeneratingScript ? undefined : <ArrowForwardIcon />}
|
||||
tooltip={!canGenerateScript ? "Complete research to generate script" : "Generate AI-powered script from research"}
|
||||
sx={{
|
||||
background: "linear-gradient(135deg, #667eea 0%, #764ba2 100%)",
|
||||
color: "#fff",
|
||||
fontWeight: 700,
|
||||
fontSize: "1rem",
|
||||
px: 4,
|
||||
py: 1.5,
|
||||
borderRadius: 2,
|
||||
textTransform: "none",
|
||||
boxShadow: "0 4px 14px rgba(102, 126, 234, 0.4)",
|
||||
"&:hover": {
|
||||
background: "linear-gradient(135deg, #764ba2 0%, #667eea 100%)",
|
||||
boxShadow: "0 6px 20px rgba(102, 126, 234, 0.5)",
|
||||
},
|
||||
"&:disabled": {
|
||||
background: "#94a3b8",
|
||||
boxShadow: "none",
|
||||
}
|
||||
}}
|
||||
>
|
||||
Generate Script
|
||||
{isGeneratingScript ? "Generating Script..." : "Generate Script to Continue"}
|
||||
</PrimaryButton>
|
||||
</Stack>
|
||||
|
||||
@@ -139,6 +192,9 @@ export const ResearchSummary: React.FC<ResearchSummaryProps> = ({
|
||||
<Typography variant="subtitle2" sx={{ mb: 1.5, color: "#64748b", fontWeight: 700, fontSize: "0.75rem", textTransform: "uppercase", letterSpacing: "0.05em", display: "flex", alignItems: "center", gap: 1 }}>
|
||||
<AutoAwesomeIcon fontSize="small" sx={{ color: "#667eea", fontSize: "1rem" }} />
|
||||
Executive Summary
|
||||
<Box sx={{ ml: 'auto' }}>
|
||||
<TextToSpeechButton text={research.summary} size="small" showSettings />
|
||||
</Box>
|
||||
</Typography>
|
||||
<Box sx={{
|
||||
lineHeight: 1.6,
|
||||
|
||||
@@ -5,6 +5,9 @@ import { useBudgetTracking } from "../../../hooks/useBudgetTracking";
|
||||
import { CreateProjectPayload, Script } from "../types";
|
||||
import { usePodcastProjectState } from "../../../hooks/usePodcastProjectState";
|
||||
import { sanitizeExaConfig, announceError, getStepLabel } from "./utils";
|
||||
import { clearSceneMediaCache, clearMediaCache } from "../../../utils/mediaCache";
|
||||
|
||||
const createId = (prefix: string) => `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`;
|
||||
|
||||
type PodcastProjectStateReturn = ReturnType<typeof usePodcastProjectState>;
|
||||
|
||||
@@ -41,18 +44,22 @@ export const usePodcastWorkflow = ({ projectState, onError }: UsePodcastWorkflow
|
||||
setResearchProvider,
|
||||
setBudgetCap,
|
||||
updateRenderJob,
|
||||
setRenderJobs,
|
||||
initializeProject,
|
||||
setBible,
|
||||
} = projectState;
|
||||
|
||||
const [isAnalyzing, setIsAnalyzing] = useState(false);
|
||||
const [isResearching, setIsResearching] = useState(false);
|
||||
const [isGeneratingScript, setIsGeneratingScript] = useState(false);
|
||||
const [announcement, setAnnouncement] = useState("");
|
||||
const [announcementSeverity, setAnnouncementSeverity] = useState<"info" | "error" | "success">("info");
|
||||
const [showResumeAlert, setShowResumeAlert] = useState(false);
|
||||
const [showPreflightDialog, setShowPreflightDialog] = useState(false);
|
||||
const [preflightResponse, setPreflightResponse] = useState<any>(null);
|
||||
const [preflightOperationName, setPreflightOperationName] = useState<string>("");
|
||||
const [showDuplicateDialog, setShowDuplicateDialog] = useState(false);
|
||||
const [duplicateProjectInfo, setDuplicateProjectInfo] = useState<{projectId: string; idea: string}>({ projectId: "", idea: "" });
|
||||
|
||||
const budgetTracking = useBudgetTracking(budgetCap || 50);
|
||||
const preflightCheck = usePreflightCheck({
|
||||
@@ -113,7 +120,27 @@ export const usePodcastWorkflow = ({ projectState, onError }: UsePodcastWorkflow
|
||||
// This allows the analysis to be personalized using the Bible context
|
||||
const projectId = project?.id || `podcast_${Date.now()}_${Math.floor(Math.random() * 1000)}`;
|
||||
setAnnouncement("Initializing project and brand context...");
|
||||
const dbProject = project ? null : await initializeProject(payload, projectId, avatarUrl);
|
||||
|
||||
let dbProject: any = null;
|
||||
try {
|
||||
dbProject = project ? null : await initializeProject(payload, projectId, avatarUrl);
|
||||
} catch (initError: any) {
|
||||
const errorStr = initError?.message || "";
|
||||
if (errorStr.includes("DUPLICATE_IDEA")) {
|
||||
try {
|
||||
const dupData = JSON.parse(errorStr);
|
||||
const existingId = dupData.existing_project_id;
|
||||
const existingIdea = dupData.existing_idea;
|
||||
setAnnouncement("");
|
||||
// Throw error to trigger UI modal
|
||||
throw new Error(`DUPLICATE_IDEA:${existingId}:${existingIdea}`);
|
||||
} catch (parseErr) {
|
||||
console.error("Failed to parse duplicate idea error:", parseErr);
|
||||
}
|
||||
}
|
||||
throw initError;
|
||||
}
|
||||
|
||||
const bible = dbProject?.bible || projectState.bible;
|
||||
|
||||
setAnnouncement(feedback ? "Regenerating analysis using your feedback..." : "Analyzing your idea — AI suggestions incoming");
|
||||
@@ -131,7 +158,7 @@ export const usePodcastWorkflow = ({ projectState, onError }: UsePodcastWorkflow
|
||||
analysis: result.analysis,
|
||||
estimate: result.estimate,
|
||||
queries: result.queries,
|
||||
selected_queries: result.queries.map(q => q.id),
|
||||
selected_queries: [], // Don't auto-select - user must choose manually
|
||||
avatar_url: result.avatar_url,
|
||||
avatar_prompt: result.avatar_prompt,
|
||||
});
|
||||
@@ -152,7 +179,7 @@ export const usePodcastWorkflow = ({ projectState, onError }: UsePodcastWorkflow
|
||||
setAnalysis(result.analysis);
|
||||
setEstimate(result.estimate);
|
||||
setQueries(result.queries);
|
||||
setSelectedQueries(new Set(result.queries.map((q) => q.id)));
|
||||
setSelectedQueries(new Set()); // Start with none selected - user must choose manually
|
||||
setKnobs(payload.knobs);
|
||||
setBudgetCap(payload.budgetCap);
|
||||
|
||||
@@ -192,6 +219,18 @@ export const usePodcastWorkflow = ({ projectState, onError }: UsePodcastWorkflow
|
||||
setAnnouncement("Analysis complete");
|
||||
}
|
||||
} catch (error: any) {
|
||||
// Handle duplicate idea error
|
||||
const errorMessage = error?.message || String(error);
|
||||
if (errorMessage.startsWith("DUPLICATE_IDEA:")) {
|
||||
const parts = errorMessage.split(":");
|
||||
const existingId = parts[1] || "";
|
||||
const existingIdea = parts.slice(2).join(":") || "existing project";
|
||||
setAnnouncement("");
|
||||
setShowDuplicateDialog(true);
|
||||
setDuplicateProjectInfo({ projectId: existingId, idea: existingIdea });
|
||||
return;
|
||||
}
|
||||
|
||||
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')) {
|
||||
@@ -240,6 +279,8 @@ export const usePodcastWorkflow = ({ projectState, onError }: UsePodcastWorkflow
|
||||
|
||||
setPreflightOperationName("Research");
|
||||
const approvedQueries = queries.filter((q) => selectedQueries.has(q.id));
|
||||
console.log('[Research] User selected queries:', Array.from(selectedQueries));
|
||||
console.log('[Research] Filtered approvedQueries for API:', approvedQueries.map(q => q.query));
|
||||
const preflightResult = await preflightCheck.check({
|
||||
provider: researchProvider === "exa" ? "exa" : "gemini",
|
||||
operation_type: researchProvider === "exa" ? "exa_neural_search" : "google_grounding",
|
||||
@@ -261,6 +302,8 @@ export const usePodcastWorkflow = ({ projectState, onError }: UsePodcastWorkflow
|
||||
setShowRenderQueue(false);
|
||||
|
||||
try {
|
||||
console.log('[Research] Starting research with:', { topic: project.idea, approvedQueries, provider: researchProvider });
|
||||
console.log('[Research] Calling podcastApi.runResearch...');
|
||||
const { research: mapped, raw } = await podcastApi.runResearch({
|
||||
projectId: project.id,
|
||||
topic: project.idea,
|
||||
@@ -273,6 +316,7 @@ export const usePodcastWorkflow = ({ projectState, onError }: UsePodcastWorkflow
|
||||
setAnnouncement(message);
|
||||
},
|
||||
});
|
||||
console.log('[Research] Response received:', { mapped, raw });
|
||||
setResearch(mapped);
|
||||
setRawResearch(raw);
|
||||
setAnnouncement("Research complete — review fact cards below");
|
||||
@@ -281,6 +325,7 @@ export const usePodcastWorkflow = ({ projectState, onError }: UsePodcastWorkflow
|
||||
? researchError.message
|
||||
: "Research failed. Please try again or switch to Standard Research.";
|
||||
|
||||
console.error('[Research] Error caught:', researchError);
|
||||
if (errorMessage.includes("Exa") || errorMessage.includes("exa")) {
|
||||
setAnnouncement(`Deep research failed: ${errorMessage}. Try Standard Research instead.`);
|
||||
} else if (errorMessage.includes("timeout")) {
|
||||
@@ -321,8 +366,18 @@ export const usePodcastWorkflow = ({ projectState, onError }: UsePodcastWorkflow
|
||||
setScriptData(null);
|
||||
setShowRenderQueue(false);
|
||||
setShowScriptEditor(true);
|
||||
setIsGeneratingScript(true);
|
||||
setAnnouncement("Generating script with AI... Creating scenes and dialogue based on your research...");
|
||||
|
||||
try {
|
||||
console.log('[ScriptGen] Starting script generation with:', {
|
||||
idea: project.idea,
|
||||
speakers: project.speakers,
|
||||
duration: project.duration,
|
||||
hasResearch: !!rawResearch,
|
||||
hasOutline: !!analysis?.suggestedOutlines?.[0],
|
||||
});
|
||||
|
||||
const result = await podcastApi.generateScript({
|
||||
projectId: project.id,
|
||||
idea: project.idea,
|
||||
@@ -331,35 +386,55 @@ export const usePodcastWorkflow = ({ projectState, onError }: UsePodcastWorkflow
|
||||
speakers: project.speakers,
|
||||
durationMinutes: project.duration,
|
||||
bible: projectState.bible,
|
||||
outline: analysis?.suggestedOutlines?.[0], // Pass the first (possibly refined) outline
|
||||
analysis: analysis, // Pass full analysis context
|
||||
outline: analysis?.suggestedOutlines?.[0],
|
||||
analysis: analysis,
|
||||
onProgress: (message) => {
|
||||
console.log('[ScriptGen] Progress:', message);
|
||||
setAnnouncement(message);
|
||||
},
|
||||
});
|
||||
|
||||
console.log('[ScriptGen] Script generated:', { sceneCount: result.scenes?.length });
|
||||
setScriptData(result);
|
||||
setIsGeneratingScript(false);
|
||||
setAnnouncement("Script generated! Review and edit your scenes below.");
|
||||
} catch (error) {
|
||||
setIsGeneratingScript(false);
|
||||
announceError(setAnnouncement, setAnnouncementSeverityFn, error);
|
||||
}
|
||||
}, [showScriptEditor, project, research, preflightCheck, setScriptData, setShowRenderQueue, setShowScriptEditor, rawResearch, projectState.knobs, projectState.bible])
|
||||
|
||||
const handleProceedToRendering = useCallback((script: Script) => {
|
||||
// Clear media cache for all scenes before proceeding to remove old blobs
|
||||
script.scenes.forEach((scene) => {
|
||||
clearSceneMediaCache(scene.id);
|
||||
});
|
||||
// Also clear global media cache to ensure clean slate
|
||||
clearMediaCache();
|
||||
|
||||
// Clear all render jobs to start fresh (removes old videos/images)
|
||||
setRenderJobs([]);
|
||||
|
||||
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,
|
||||
});
|
||||
// Create new render jobs with current script scene data
|
||||
script.scenes.forEach((scene) => {
|
||||
const hasExistingAudio = Boolean(scene.audioUrl);
|
||||
const hasExistingImage = Boolean(scene.imageUrl);
|
||||
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,
|
||||
imageUrl: hasExistingImage ? scene.imageUrl : null,
|
||||
videoUrl: null,
|
||||
jobId: null,
|
||||
});
|
||||
}
|
||||
});
|
||||
setShowRenderQueue(true);
|
||||
setShowScriptEditor(false);
|
||||
}, [renderJobs.length, setScriptData, updateRenderJob, setShowRenderQueue, setShowScriptEditor]);
|
||||
}, [setScriptData, setRenderJobs, updateRenderJob, setShowRenderQueue, setShowScriptEditor]);
|
||||
|
||||
const toggleQuery = useCallback((id: string) => {
|
||||
if (isResearching) return;
|
||||
@@ -370,6 +445,22 @@ export const usePodcastWorkflow = ({ projectState, onError }: UsePodcastWorkflow
|
||||
setSelectedQueries(next);
|
||||
}, [isResearching, selectedQueries, setSelectedQueries]);
|
||||
|
||||
const handleUpdateQuery = useCallback((id: string, newQuery: string, newRationale: string) => {
|
||||
const updated = queries.map(q => q.id === id ? { ...q, query: newQuery, rationale: newRationale } : q);
|
||||
setQueries(updated);
|
||||
}, [queries, setQueries]);
|
||||
|
||||
const handleDeleteQuery = useCallback((id: string) => {
|
||||
const updated = queries.filter(q => q.id !== id);
|
||||
setQueries(updated);
|
||||
// Also remove from selected if it was selected
|
||||
if (selectedQueries.has(id)) {
|
||||
const newSelected = new Set(selectedQueries);
|
||||
newSelected.delete(id);
|
||||
setSelectedQueries(newSelected);
|
||||
}
|
||||
}, [queries, selectedQueries, setQueries, setSelectedQueries]);
|
||||
|
||||
const activeStep = useMemo(() => {
|
||||
if (showRenderQueue) return 3;
|
||||
if (showScriptEditor) return 2;
|
||||
@@ -397,6 +488,37 @@ export const usePodcastWorkflow = ({ projectState, onError }: UsePodcastWorkflow
|
||||
await handleCreate(payload, feedback);
|
||||
}, [project, projectState.knobs, projectState.budgetCap, handleCreate]);
|
||||
|
||||
// Regenerate only research queries (keeps other sections intact)
|
||||
const handleRegenerateQueries = useCallback(async (feedback: string) => {
|
||||
if (!project || !analysis) return;
|
||||
|
||||
setAnnouncement("Regenerating research queries...");
|
||||
|
||||
try {
|
||||
const response = await podcastApi.regenerateResearchQueries({
|
||||
idea: project.idea,
|
||||
feedback: feedback,
|
||||
existing_analysis: analysis,
|
||||
bible: projectState.bible,
|
||||
});
|
||||
|
||||
// Convert to Query format
|
||||
const newQueries = response.research_queries.map((rq, idx) => ({
|
||||
id: createId("q"),
|
||||
query: rq.query,
|
||||
rationale: rq.rationale,
|
||||
needsRecentStats: /202[45]|latest|trend/i.test(rq.query),
|
||||
}));
|
||||
|
||||
setQueries(newQueries);
|
||||
setSelectedQueries(new Set()); // Don't auto-select - user must choose manually
|
||||
setAnnouncement("Research queries regenerated");
|
||||
} catch (error) {
|
||||
console.error("Failed to regenerate queries:", error);
|
||||
setAnnouncement("Failed to regenerate queries");
|
||||
}
|
||||
}, [project, analysis, projectState.bible, setQueries, setSelectedQueries]);
|
||||
|
||||
const setAnnouncementSeverityFn = useCallback((severity: "info" | "error" | "success") => {
|
||||
setAnnouncementSeverity(severity);
|
||||
}, []);
|
||||
@@ -405,12 +527,15 @@ export const usePodcastWorkflow = ({ projectState, onError }: UsePodcastWorkflow
|
||||
// State
|
||||
isAnalyzing,
|
||||
isResearching,
|
||||
isGeneratingScript,
|
||||
announcement,
|
||||
announcementSeverity,
|
||||
showResumeAlert,
|
||||
showPreflightDialog,
|
||||
preflightResponse,
|
||||
preflightOperationName,
|
||||
showDuplicateDialog,
|
||||
duplicateProjectInfo,
|
||||
activeStep,
|
||||
canGenerateScript,
|
||||
// Handlers
|
||||
@@ -425,8 +550,13 @@ export const usePodcastWorkflow = ({ projectState, onError }: UsePodcastWorkflow
|
||||
setShowResumeAlert,
|
||||
setShowPreflightDialog,
|
||||
setPreflightResponse,
|
||||
setShowDuplicateDialog,
|
||||
setDuplicateProjectInfo,
|
||||
setResearchProvider,
|
||||
getStepLabel,
|
||||
handleRegenerateQueries: handleRegenerateQueries,
|
||||
handleUpdateQuery,
|
||||
handleDeleteQuery,
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -4,6 +4,8 @@ import { CreateProjectPayload, Knobs } from "../types";
|
||||
export const DEFAULT_KNOBS: Knobs = {
|
||||
voice_emotion: "neutral",
|
||||
voice_speed: 1,
|
||||
voice_id: "Wise_Woman",
|
||||
custom_voice_id: undefined,
|
||||
resolution: "720p",
|
||||
scene_length_target: 45,
|
||||
sample_rate: 24000,
|
||||
|
||||
Reference in New Issue
Block a user