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:
ajaysi
2026-04-03 06:59:59 +05:30
parent c52b1eabc9
commit 63bb937796
58 changed files with 3568 additions and 1597 deletions

View File

@@ -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>
);
};

View File

@@ -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,

View File

@@ -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,
};
};

View File

@@ -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,