open-notebook/pages/5_🎙️_Podcasts.py
Luis Novo b7e656a319
Version 1 (#160)
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
2025-10-18 12:46:22 -03:00

1357 lines
55 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import asyncio
from datetime import datetime
from typing import Any
import streamlit as st
from api.models_service import models_service
from api.podcast_api_service import podcast_api_service
from open_notebook.database.repository import repo_query
from pages.stream_app.utils import setup_page
setup_page("🎙️ Podcasts", only_check_mandatory_models=False)
# Service instance is imported above
@st.dialog("Confirm Delete Episode")
def confirm_delete_episode(episode_id, episode_name):
st.warning(f"Are you sure you want to delete episode **{episode_name}**?")
st.write("This action will:")
st.write("• Delete the episode from the database")
st.write("• Delete the audio file from disk (if it exists)")
st.error("**This action cannot be undone!**")
col_confirm1, col_confirm2 = st.columns(2)
with col_confirm1:
if st.button("✅ Yes, Delete", type="primary"):
success = delete_episode(episode_id)
if success:
st.success("Episode deleted successfully!")
st.rerun()
else:
st.error("Failed to delete episode")
with col_confirm2:
if st.button("❌ Cancel"):
st.rerun()
@st.dialog("Confirm Delete Speaker Profile")
def confirm_delete_speaker_profile(profile_id, profile_name):
st.warning(f"Are you sure you want to delete speaker profile **{profile_name}**?")
# Check usage before allowing deletion
speaker_profiles = fetch_speaker_profiles()
episode_profiles = fetch_episode_profiles()
usage_map = analyze_speaker_usage(speaker_profiles, episode_profiles)
usage_count = usage_map.get(profile_name, 0)
if usage_count > 0:
st.error("❌ **Cannot delete this speaker profile!**")
st.write(
f"This speaker profile is currently used by **{usage_count} episode profile(s)**."
)
st.write("**To delete this speaker profile:**")
st.write("1. First update or delete the episode profiles that use this speaker")
st.write("2. Then return here to delete the speaker profile")
# Show which episodes use this speaker
using_episodes = []
for episode in episode_profiles:
if episode.get("speaker_config") == profile_name:
using_episodes.append(episode.get("name", "Unknown"))
if using_episodes:
st.write("**Episodes using this speaker:**")
for ep_name in using_episodes:
st.write(f"{ep_name}")
if st.button("❌ Close"):
st.rerun()
return
# Safe to delete - no usage
st.write("✅ This speaker profile is not used by any episode profiles.")
st.write("This action cannot be undone.")
col_confirm1, col_confirm2 = st.columns(2)
with col_confirm1:
if st.button("✅ Yes, Delete", type="primary"):
success = delete_speaker_profile(profile_id)
if success:
st.success("Speaker profile deleted!")
st.rerun()
else:
st.error("Failed to delete speaker profile")
with col_confirm2:
if st.button("❌ Cancel"):
st.rerun()
@st.dialog("Confirm Delete Episode Profile")
def confirm_delete_episode_profile(profile_id, profile_name):
st.warning(f"Are you sure you want to delete episode profile **{profile_name}**?")
st.write("This action cannot be undone.")
col_confirm1, col_confirm2 = st.columns(2)
with col_confirm1:
if st.button("✅ Yes, Delete", type="primary"):
success = delete_episode_profile(profile_id)
if success:
st.success("Episode profile deleted!")
st.rerun()
else:
st.error("Failed to delete episode profile")
with col_confirm2:
if st.button("❌ Cancel"):
st.rerun()
@st.fragment
def speaker_management_fragment():
"""Fragment for managing speakers within the dialog"""
st.subheader("🎙️ Speakers (1-4 speakers)")
# Add Speaker button at top
if st.button(" Add Speaker", disabled=len(st.session_state.dialog_speakers) >= 4):
st.session_state.dialog_speakers.append(
{"name": "", "voice_id": "", "backstory": "", "personality": ""}
)
st.rerun(scope="fragment")
# Display current speakers with individual delete buttons
for i, speaker in enumerate(st.session_state.dialog_speakers):
with st.container(border=True):
col_header, col_delete = st.columns([4, 1])
with col_header:
st.write(f"**Speaker {i + 1}:**")
with col_delete:
# Individual delete button for each speaker
if st.button(
"🗑️",
key=f"delete_speaker_{i}",
help=f"Delete Speaker {i + 1}",
disabled=len(st.session_state.dialog_speakers) <= 1,
):
st.session_state.dialog_speakers.pop(i)
st.rerun(scope="fragment")
col_spk1, col_spk2 = st.columns(2)
with col_spk1:
speaker["name"] = st.text_input(
"Name*",
value=speaker.get("name", ""),
key=f"dialog_speaker_{i}_name",
)
speaker["voice_id"] = st.text_input(
"Voice ID*",
value=speaker.get("voice_id", ""),
key=f"dialog_speaker_{i}_voice",
)
with col_spk2:
speaker["backstory"] = st.text_area(
"Backstory*",
value=speaker.get("backstory", ""),
key=f"dialog_speaker_{i}_backstory",
)
speaker["personality"] = st.text_area(
"Personality*",
value=speaker.get("personality", ""),
key=f"dialog_speaker_{i}_personality",
)
@st.dialog("Configure Speaker Profile", width="large")
def speaker_configuration_dialog(mode="create", profile_id=None, episode_context=None):
"""Unified dialog for speaker profile create/edit/select"""
# Handle mode switching from select to create
if st.session_state.get("switch_to_create", False):
mode = "create"
del st.session_state.switch_to_create
# Initialize session state for dialog
if "dialog_speakers" not in st.session_state:
st.session_state.dialog_speakers = [
{"name": "", "voice_id": "", "backstory": "", "personality": ""}
]
if mode == "create":
st.subheader("🎤 Create New Speaker Profile")
elif mode == "edit":
st.subheader("✏️ Edit Speaker Profile")
# Load existing profile data
if profile_id and "dialog_loaded" not in st.session_state:
speaker_profiles = fetch_speaker_profiles()
profile = next((p for p in speaker_profiles if p["id"] == profile_id), None)
if profile:
st.session_state.dialog_loaded = True
st.session_state.dialog_speakers = profile.get("speakers", [])
st.session_state.dialog_name = profile.get("name", "")
st.session_state.dialog_description = profile.get("description", "")
st.session_state.dialog_tts_provider = profile.get("tts_provider", "")
st.session_state.dialog_tts_model = profile.get("tts_model", "")
elif mode == "select":
st.subheader("⚙️ Configure Speaker for Episode")
# Fetch available speaker profiles
speaker_profiles = fetch_speaker_profiles()
if not speaker_profiles:
st.warning("No speaker profiles available. Create one first.")
if st.button("✅ Create New Speaker Profile"):
# Clear current session state and switch to create mode
for key in [
"dialog_speakers",
"dialog_name",
"dialog_description",
"dialog_tts_provider",
"dialog_tts_model",
"dialog_loaded",
]:
if key in st.session_state:
del st.session_state[key]
st.session_state.switch_to_create = True
st.rerun()
return
# Show current episode info if available
if episode_context:
episode_profiles = fetch_episode_profiles()
current_episode = next(
(ep for ep in episode_profiles if ep["id"] == episode_context), None
)
if current_episode:
st.info(
f"Configuring speaker for episode profile: **{current_episode.get('name', 'Unknown')}**"
)
# Speaker selection
speaker_names = [sp["name"] for sp in speaker_profiles]
current_speaker = None
current_idx = 0
# Try to get current speaker from episode if available
if episode_context:
episode_profiles = fetch_episode_profiles()
current_episode = next(
(ep for ep in episode_profiles if ep["id"] == episode_context), None
)
if current_episode:
current_speaker = current_episode.get("speaker_config")
if current_speaker in speaker_names:
current_idx = speaker_names.index(current_speaker)
selected_speaker = st.selectbox(
"Select Speaker Profile*",
speaker_names,
index=current_idx,
help="Choose an existing speaker profile or create a new one below",
)
# Show selected speaker details
if selected_speaker:
selected_profile = next(
(sp for sp in speaker_profiles if sp["name"] == selected_speaker), None
)
if selected_profile:
with st.expander(f"🎤 Preview: {selected_speaker}", expanded=True):
st.write(
f"**Description:** {selected_profile.get('description', 'N/A')}"
)
st.write(
f"**TTS:** {selected_profile.get('tts_provider', 'N/A')}/{selected_profile.get('tts_model', 'N/A')}"
)
speakers = selected_profile.get("speakers", [])
st.write(f"**Speakers ({len(speakers)}):**")
for i, speaker in enumerate(speakers, 1):
st.caption(
f"{i}. {speaker.get('name', 'Unknown')} - {speaker.get('voice_id', 'N/A')}"
)
# Action buttons
col1, col2, col3 = st.columns(3)
with col1:
if st.button("✅ Assign Speaker", type="primary"):
if episode_context and selected_speaker:
# Update episode profile with selected speaker
episode_profiles = fetch_episode_profiles()
current_episode = next(
(ep for ep in episode_profiles if ep["id"] == episode_context),
None,
)
if current_episode:
# Preserve all existing data, just update speaker_config
updated_data = {
"name": current_episode.get("name", ""),
"description": current_episode.get("description", ""),
"speaker_config": selected_speaker,
"outline_provider": current_episode.get(
"outline_provider", ""
),
"outline_model": current_episode.get("outline_model", ""),
"transcript_provider": current_episode.get(
"transcript_provider", ""
),
"transcript_model": current_episode.get(
"transcript_model", ""
),
"default_briefing": current_episode.get(
"default_briefing", ""
),
"num_segments": current_episode.get("num_segments", 5),
}
success = update_episode_profile(episode_context, updated_data)
if success:
st.success(
f"Speaker '{selected_speaker}' assigned to episode!"
)
st.rerun()
else:
st.error("Failed to assign speaker")
else:
st.error("Please select a speaker profile")
with col2:
if st.button(" Create New Speaker"):
# Clear current session state and switch to create mode
for key in [
"dialog_speakers",
"dialog_name",
"dialog_description",
"dialog_tts_provider",
"dialog_tts_model",
"dialog_loaded",
]:
if key in st.session_state:
del st.session_state[key]
# Store episode context for later assignment
st.session_state.pending_episode_assignment = episode_context
st.session_state.switch_to_create = True
st.rerun()
with col3:
if st.button("❌ Cancel"):
st.rerun()
return
# TTS Provider/Model selection outside form for reactivity
col1, col2 = st.columns(2)
with col1:
tts_provider = st.selectbox(
"TTS Provider*", list(tts_provider_models.keys()), key="dialog_tts_provider"
)
with col2:
tts_model = st.selectbox(
"TTS Model*", tts_provider_models[tts_provider], key="dialog_tts_model"
)
# Speakers configuration section using fragment
speaker_management_fragment()
with st.form("speaker_config_dialog_form"):
col3, col4 = st.columns(2)
with col3:
sp_name = st.text_input(
"Profile Name*",
value=st.session_state.get("dialog_name", ""),
placeholder="e.g., tech_experts",
)
with col4:
sp_description = st.text_area(
"Description",
value=st.session_state.get("dialog_description", ""),
placeholder="Brief description of this speaker configuration",
)
# Submit buttons
col7, col8 = st.columns(2)
with col7:
submit_label = "💾 Save Changes" if mode == "edit" else "✅ Create Profile"
if st.form_submit_button(submit_label):
# Validate speakers
valid_speakers = []
for speaker in st.session_state.dialog_speakers:
if (
speaker.get("name")
and speaker.get("voice_id")
and speaker.get("backstory")
and speaker.get("personality")
):
valid_speakers.append(speaker)
if sp_name and valid_speakers:
profile_data = {
"name": sp_name,
"description": sp_description,
"tts_provider": tts_provider,
"tts_model": tts_model,
"speakers": valid_speakers,
}
if mode == "create":
success = create_speaker_profile(profile_data)
if success:
st.success("Speaker profile created successfully!")
# Auto-assign to episode if created from episode context
pending_episode = st.session_state.get(
"pending_episode_assignment"
)
if pending_episode:
episode_profiles = fetch_episode_profiles()
current_episode = next(
(
ep
for ep in episode_profiles
if ep["id"] == pending_episode
),
None,
)
if current_episode:
# Update episode with new speaker
updated_data = {
"name": current_episode.get("name", ""),
"description": current_episode.get(
"description", ""
),
"speaker_config": sp_name, # Assign the newly created speaker
"outline_provider": current_episode.get(
"outline_provider", ""
),
"outline_model": current_episode.get(
"outline_model", ""
),
"transcript_provider": current_episode.get(
"transcript_provider", ""
),
"transcript_model": current_episode.get(
"transcript_model", ""
),
"default_briefing": current_episode.get(
"default_briefing", ""
),
"num_segments": current_episode.get(
"num_segments", 5
),
}
assign_success = update_episode_profile(
pending_episode, updated_data
)
if assign_success:
st.success(
f"Speaker '{sp_name}' automatically assigned to episode!"
)
# Clear pending assignment
del st.session_state.pending_episode_assignment
# Clear session state
for key in [
"dialog_speakers",
"dialog_name",
"dialog_description",
"dialog_tts_provider",
"dialog_tts_model",
"dialog_loaded",
]:
if key in st.session_state:
del st.session_state[key]
st.rerun()
else:
st.error("Failed to create speaker profile")
elif mode == "edit":
success = update_speaker_profile(profile_id, profile_data)
if success:
st.success("Speaker profile updated successfully!")
# Clear session state
for key in [
"dialog_speakers",
"dialog_name",
"dialog_description",
"dialog_tts_provider",
"dialog_tts_model",
"dialog_loaded",
]:
if key in st.session_state:
del st.session_state[key]
st.rerun()
else:
st.error("Failed to update speaker profile")
else:
st.error(
"Please fill in all required fields (*) for at least one speaker"
)
with col8:
if st.form_submit_button("❌ Cancel"):
# Clear session state
for key in [
"dialog_speakers",
"dialog_name",
"dialog_description",
"dialog_tts_provider",
"dialog_tts_model",
"dialog_loaded",
]:
if key in st.session_state:
del st.session_state[key]
st.rerun()
def get_status_emoji(status: str) -> str:
"""Get emoji for job status"""
status_map = {
"completed": "",
"running": "🔄",
"processing": "🔄",
"failed": "",
"error": "",
"pending": "",
"submitted": "",
}
return status_map.get(status, "")
def format_relative_time(created_str: str) -> str:
"""Format creation time as relative time"""
try:
# Parse ISO format datetime
if created_str.endswith("Z"):
created_str = created_str[:-1] + "+00:00"
created = datetime.fromisoformat(created_str)
# Simple relative time calculation
now = datetime.now(created.tzinfo)
diff = now - created
if diff.days > 0:
return f"{diff.days} day{'s' if diff.days > 1 else ''} ago"
elif diff.seconds > 3600:
hours = diff.seconds // 3600
return f"{hours} hour{'s' if hours > 1 else ''} ago"
elif diff.seconds > 60:
minutes = diff.seconds // 60
return f"{minutes} minute{'s' if minutes > 1 else ''} ago"
else:
return "Just now"
except Exception:
return "Unknown"
def fetch_episodes():
"""Fetch episodes from API"""
try:
return podcast_api_service.get_episodes()
except Exception as e:
st.error(f"Error fetching episodes: {str(e)}")
return []
def fetch_episode_profiles():
"""Fetch episode profiles from API"""
try:
return podcast_api_service.get_episode_profiles()
except Exception as e:
st.error(f"Error fetching episode profiles: {str(e)}")
return []
def fetch_speaker_profiles():
"""Fetch speaker profiles from API"""
try:
return podcast_api_service.get_speaker_profiles()
except Exception as e:
st.error(f"Error fetching speaker profiles: {str(e)}")
return []
def create_episode_profile(profile_data):
"""Create new episode profile"""
return podcast_api_service.create_episode_profile(profile_data)
def update_episode_profile(profile_id, profile_data):
"""Update episode profile"""
return podcast_api_service.update_episode_profile(profile_id, profile_data)
def delete_episode_profile(profile_id):
"""Delete episode profile"""
return podcast_api_service.delete_episode_profile(profile_id)
def duplicate_episode_profile(profile_id):
"""Duplicate episode profile"""
return podcast_api_service.duplicate_episode_profile(profile_id)
def create_speaker_profile(profile_data):
"""Create new speaker profile"""
return podcast_api_service.create_speaker_profile(profile_data)
def update_speaker_profile(profile_id, profile_data):
"""Update speaker profile"""
return podcast_api_service.update_speaker_profile(profile_id, profile_data)
def delete_speaker_profile(profile_id):
"""Delete speaker profile"""
return podcast_api_service.delete_speaker_profile(profile_id)
def duplicate_speaker_profile(profile_id):
"""Duplicate speaker profile"""
return podcast_api_service.duplicate_speaker_profile(profile_id)
def delete_episode(episode_id):
"""Delete podcast episode and its audio file"""
return podcast_api_service.delete_episode(episode_id)
def analyze_speaker_usage(speakers, episodes):
"""Analyze which speaker profiles are used by episode profiles"""
usage_map = {}
for speaker in speakers:
speaker_name = speaker.get("name", "")
usage_map[speaker_name] = 0
for episode in episodes:
speaker_config = episode.get("speaker_config", "")
if speaker_config in usage_map:
usage_map[speaker_config] += 1
return usage_map
def render_speaker_info_inline(speaker_config, speaker_profiles):
"""Render speaker information inline within episode profile cards"""
if not speaker_config:
st.warning("⚠️ No speaker profile assigned")
return
# Find the matching speaker profile
speaker_profile = None
for profile in speaker_profiles:
if profile.get("name") == speaker_config:
speaker_profile = profile
break
if not speaker_profile:
st.error(f"❌ Speaker profile '{speaker_config}' not found")
return
# Display speaker info
st.write(f"**🎤 Speaker Profile:** {speaker_config}")
st.write(
f"**TTS:** {speaker_profile.get('tts_provider', 'N/A')}/{speaker_profile.get('tts_model', 'N/A')}"
)
speakers = speaker_profile.get("speakers", [])
if speakers:
st.write(f"**Speakers ({len(speakers)}):**")
for i, speaker in enumerate(speakers, 1):
st.caption(
f"{i}. {speaker.get('name', 'Unknown')} - {speaker.get('voice_id', 'N/A')}"
)
def render_episode_profiles_section():
"""Render episode profiles in the main area"""
st.subheader("📺 Episode Profiles")
# Fetch data
episode_profiles = fetch_episode_profiles()
speaker_profiles = fetch_speaker_profiles()
# Create new episode profile section
with st.expander(" Create New Episode Profile", expanded=False):
# AI Model Configuration outside form for reactivity
st.subheader("🤖 AI Model Configuration")
col_ai1, col_ai2 = st.columns(2)
with col_ai1:
outline_provider = st.selectbox(
"Outline Provider*",
list(transcript_provider_models.keys()),
key="new_outline_provider",
)
outline_model = st.selectbox(
"Outline Model*",
transcript_provider_models[outline_provider],
key="new_outline_model",
)
with col_ai2:
transcript_provider = st.selectbox(
"Transcript Provider*",
list(transcript_provider_models.keys()),
key="new_transcript_provider",
)
transcript_model = st.selectbox(
"Transcript Model*",
transcript_provider_models[transcript_provider],
key="new_transcript_model",
)
with st.form("create_episode_profile"):
col1, col2 = st.columns(2)
with col1:
ep_name = st.text_input(
"Profile Name*", placeholder="e.g., tech_discussion"
)
ep_description = st.text_area(
"Description", placeholder="Brief description of this profile"
)
ep_segments = st.number_input(
"Number of Segments", min_value=3, max_value=20, value=5
)
with col2:
# Speaker config dropdown
speaker_names = (
[sp["name"] for sp in speaker_profiles] if speaker_profiles else []
)
if speaker_names:
ep_speaker_config = st.selectbox(
"Speaker Configuration*", speaker_names
)
else:
st.warning(
"No speaker profiles available. Create a speaker profile first."
)
ep_speaker_config = None
# Default briefing
ep_briefing = st.text_area(
"Default Briefing*",
placeholder="Enter the default briefing template for this episode type...",
height=150,
)
submitted = st.form_submit_button("Create Episode Profile")
if submitted:
if ep_name and ep_speaker_config and ep_briefing:
success = create_episode_profile(
{
"name": ep_name,
"description": ep_description,
"speaker_config": ep_speaker_config,
"outline_provider": outline_provider,
"outline_model": outline_model,
"transcript_provider": transcript_provider,
"transcript_model": transcript_model,
"default_briefing": ep_briefing,
"num_segments": ep_segments,
}
)
if success:
st.success("Episode profile created successfully!")
st.rerun()
else:
st.error("Failed to create episode profile")
else:
st.error("Please fill in all required fields (*)")
# Display existing episode profiles
if episode_profiles:
st.write(f"**{len(episode_profiles)} Episode Profile(s):**")
for profile in episode_profiles:
with st.container(border=True):
col_info, col_actions = st.columns([3, 1])
with col_info:
st.subheader(profile.get("name", "Unknown"))
st.write(f"**Description:** {profile.get('description', 'N/A')}")
st.write(f"**Segments:** {profile.get('num_segments', 'N/A')}")
st.write(
f"**Outline Model:** {profile.get('outline_provider', 'N/A')}/{profile.get('outline_model', 'N/A')}"
)
st.write(
f"**Transcript Model:** {profile.get('transcript_provider', 'N/A')}/{profile.get('transcript_model', 'N/A')}"
)
# Inline speaker information
st.divider()
render_speaker_info_inline(
profile.get("speaker_config"), speaker_profiles
)
with col_actions:
if st.button(
"⚙️ Configure Speaker",
key=f"config_speaker_{profile['id']}",
help="Configure speaker profile",
):
speaker_configuration_dialog(
"select", episode_context=profile["id"]
)
if st.button("✏️ Edit", key=f"edit_ep_{profile['id']}"):
st.session_state[f"edit_episode_{profile['id']}"] = True
st.rerun()
if st.button("📋 Duplicate", key=f"dup_ep_{profile['id']}"):
success = duplicate_episode_profile(profile["id"])
if success:
st.success("Profile duplicated!")
st.rerun()
if st.button("🗑️ Delete", key=f"del_ep_{profile['id']}"):
confirm_delete_episode_profile(profile["id"], profile["name"])
# Show briefing
st.text_area(
"Default Briefing:",
value=profile.get("default_briefing", ""),
height=100,
disabled=True,
key=f"briefing_display_{profile['id']}",
)
# Edit form (if in edit mode)
if st.session_state.get(f"edit_episode_{profile['id']}", False):
st.subheader("✏️ Edit Episode Profile")
# AI models outside form for reactivity
col5, col6 = st.columns(2)
with col5:
current_outline_provider = profile.get(
"outline_provider",
list(transcript_provider_models.keys())[0],
)
outline_idx = (
list(transcript_provider_models.keys()).index(
current_outline_provider
)
if current_outline_provider in transcript_provider_models
else 0
)
edit_outline_provider = st.selectbox(
"Outline Provider",
list(transcript_provider_models.keys()),
index=outline_idx,
key=f"edit_outline_provider_{profile['id']}",
)
current_outline_model = profile.get("outline_model", "")
outline_model_idx = 0
if (
current_outline_model
in transcript_provider_models[edit_outline_provider]
):
outline_model_idx = transcript_provider_models[
edit_outline_provider
].index(current_outline_model)
edit_outline_model = st.selectbox(
"Outline Model",
transcript_provider_models[edit_outline_provider],
index=outline_model_idx,
key=f"edit_outline_model_{profile['id']}",
)
with col6:
current_transcript_provider = profile.get(
"transcript_provider",
list(transcript_provider_models.keys())[0],
)
transcript_idx = (
list(transcript_provider_models.keys()).index(
current_transcript_provider
)
if current_transcript_provider in transcript_provider_models
else 0
)
edit_transcript_provider = st.selectbox(
"Transcript Provider",
list(transcript_provider_models.keys()),
index=transcript_idx,
key=f"edit_transcript_provider_{profile['id']}",
)
current_transcript_model = profile.get("transcript_model", "")
transcript_model_idx = 0
if (
current_transcript_model
in transcript_provider_models[edit_transcript_provider]
):
transcript_model_idx = transcript_provider_models[
edit_transcript_provider
].index(current_transcript_model)
edit_transcript_model = st.selectbox(
"Transcript Model",
transcript_provider_models[edit_transcript_provider],
index=transcript_model_idx,
key=f"edit_transcript_model_{profile['id']}",
)
with st.form(f"edit_episode_form_{profile['id']}"):
# Form fields with current values
edit_name = st.text_input(
"Profile Name", value=profile.get("name", "")
)
edit_description = st.text_area(
"Description", value=profile.get("description", "")
)
edit_segments = st.number_input(
"Segments",
min_value=3,
max_value=20,
value=profile.get("num_segments", 5),
)
# Speaker config
speaker_names = (
[sp["name"] for sp in speaker_profiles]
if speaker_profiles
else []
)
current_speaker = profile.get("speaker_config", "")
speaker_idx = (
speaker_names.index(current_speaker)
if current_speaker in speaker_names
else 0
)
edit_speaker_config = st.selectbox(
"Speaker Configuration", speaker_names, index=speaker_idx
)
edit_briefing = st.text_area(
"Default Briefing",
value=profile.get("default_briefing", ""),
height=150,
)
col7, col8 = st.columns(2)
with col7:
if st.form_submit_button("💾 Save Changes"):
success = update_episode_profile(
profile["id"],
{
"name": edit_name,
"description": edit_description,
"speaker_config": edit_speaker_config,
"outline_provider": edit_outline_provider,
"outline_model": edit_outline_model,
"transcript_provider": edit_transcript_provider,
"transcript_model": edit_transcript_model,
"default_briefing": edit_briefing,
"num_segments": edit_segments,
},
)
if success:
st.success("Profile updated!")
st.session_state[
f"edit_episode_{profile['id']}"
] = False
st.rerun()
with col8:
if st.form_submit_button("❌ Cancel"):
st.session_state[f"edit_episode_{profile['id']}"] = (
False
)
st.rerun()
else:
st.info("No episode profiles found. Create your first episode profile above.")
def render_speaker_profiles_sidebar():
"""Render speaker profiles in the sidebar with usage indicators"""
st.subheader("🎤 Speaker Profiles")
# New Speaker Profile button
if st.button(" New Speaker Profile", use_container_width=True):
speaker_configuration_dialog("create")
# Fetch speaker profiles and episode profiles for usage analysis
speaker_profiles = fetch_speaker_profiles()
episode_profiles = fetch_episode_profiles()
if not speaker_profiles:
st.info("No speaker profiles found. Create your first speaker profile above.")
return
# Analyze usage
usage_map = analyze_speaker_usage(speaker_profiles, episode_profiles)
st.write(f"**{len(speaker_profiles)} Speaker Profile(s):**")
for profile in speaker_profiles:
profile_name = profile.get("name", "Unknown")
usage_count = usage_map.get(profile_name, 0)
# Usage indicator
if usage_count > 0:
usage_indicator = f"✅ Used ({usage_count})"
else:
usage_indicator = "⭕ Unused"
with st.expander(f"🎤 {profile_name} {usage_indicator}", expanded=False):
# Speaker profile summary
st.write(profile.get("description", "N/A"))
st.caption(
f"**TTS:** {profile.get('tts_provider', 'N/A')}/{profile.get('tts_model', 'N/A')}"
)
speakers = profile.get("speakers", [])
# st.write(f"**Speakers:** {len(speakers)}")
for i, speaker in enumerate(speakers): # Show first 2 speakers only
st.markdown(
f"- {speaker.get('name', 'Unknown')} ({speaker.get('voice_id', 'N/A')})\n"
)
# if len(speakers) > 2:
# st.caption(f"... and {len(speakers) - 2} more")
# Action buttons
col1, col2, col3 = st.columns(3)
with col1:
if st.button("✏️", key=f"edit_sp_sidebar_{profile['id']}", help="Edit"):
speaker_configuration_dialog("edit", profile["id"])
with col2:
if st.button(
"📋", key=f"dup_sp_sidebar_{profile['id']}", help="Duplicate"
):
success = duplicate_speaker_profile(profile["id"])
if success:
st.success("Profile duplicated!")
st.rerun()
with col3:
if st.button("🗑️", key=f"del_sp_sidebar_{profile['id']}", help="Delete"):
confirm_delete_speaker_profile(profile["id"], profile["name"])
# Main page title
st.title("🎙️ Podcast Generator")
st.markdown("Manage your podcast episodes and configurations")
# Create tabs
episodes_tab, templates_tab = st.tabs(["Episodes", "Templates"])
with episodes_tab:
st.header("📻 Episodes")
existing_episodes = asyncio.run(
repo_query("select count() from podcast_episode group all")
)
existing_episodes_count = existing_episodes[0]["count"]
if existing_episodes_count > 0:
st.warning(
f"**Please Decide:** Found {existing_episodes_count} episode(s) from the old podcast implementation."
)
c1, c2 = st.columns(2)
if c1.button("Migrate them"):
asyncio.run(
repo_query(
"INSERT into episode (select audio_file, created, instructions as briefing, text as content, {} as episode_profile, {} as speaker_profile, name from podcast_episode);"
)
)
asyncio.run(repo_query("DELETE from podcast_episode;"))
st.rerun()
if c2.button("Delete them"):
asyncio.run(repo_query("DELETE from podcast_episode;"))
st.rerun()
st.divider()
# Refresh button
col1, col2 = st.columns([1, 4])
with col1:
if st.button("🔄 Refresh", help="Refresh episode status"):
st.rerun()
# Fetch and display episodes
episodes = fetch_episodes()
if not episodes:
st.info("No episodes found. Generate your first episode in the chat interface!")
else:
st.write(f"Found {len(episodes)} episode(s)")
# Group episodes by status
status_groups: dict[str, list[dict[str, Any]]] = {"running": [], "completed": [], "failed": [], "pending": []}
for episode in episodes:
status = episode.get("job_status", "unknown")
if status in ["running", "processing"]:
status_groups["running"].append(episode)
elif status == "completed":
status_groups["completed"].append(episode)
elif status in ["failed", "error"]:
status_groups["failed"].append(episode)
else:
status_groups["pending"].append(episode)
# Display running episodes first
if status_groups["running"]:
st.subheader("🔄 Currently Processing")
for episode in status_groups["running"]:
with st.container(border=True):
col1, col2, col3 = st.columns([3, 1, 1])
with col1:
st.markdown(f"**{episode['name']}**")
st.caption(
f"Profile: {episode['episode_profile'].get('name', 'Unknown')}"
)
with col2:
if episode.get("created"):
st.caption(
f"Started: {format_relative_time(episode['created'])}"
)
with col3:
st.markdown(
f"{get_status_emoji(episode.get('job_status', 'unknown'))} Processing..."
)
# Display completed episodes
if status_groups["completed"]:
st.subheader("✅ Completed Episodes")
for episode in status_groups["completed"]:
with st.container(border=True):
col1, col2, col3 = st.columns([3, 1, 1])
with col1:
st.markdown(f"**{episode['name']}**")
st.caption(
f"Profile: {episode['episode_profile'].get('name', 'Unknown')}"
)
if episode.get("created"):
st.caption(
f"Created: {format_relative_time(episode['created'])}"
)
with col2:
st.markdown(f"{get_status_emoji('completed')} Complete")
with col3:
if st.button(
"🗑️ Delete",
key=f"del_episode_{episode['id']}",
help="Delete episode and audio file",
):
confirm_delete_episode(episode["id"], episode["name"])
# Audio player
if episode.get("audio_file"):
try:
st.audio(episode["audio_file"], format="audio/mpeg")
except Exception as e:
st.error(f"Could not load audio: {str(e)}")
# Episode details in separate expanders
with st.expander(f"🎭 Profiles - {episode['name']}", expanded=False):
if episode.get("briefing"):
st.text_area(
"Briefing Used:",
value=episode["briefing"],
height=100,
disabled=True,
key=f"briefing_{episode['id']}",
)
# Show episode profile info
if episode.get("episode_profile"):
st.subheader("📺 Episode Profile")
ep_profile = episode["episode_profile"]
st.write(f"**Name:** {ep_profile.get('name', 'Unknown')}")
st.write(
f"**Description:** {ep_profile.get('description', 'N/A')}"
)
st.write(
f"**Segments:** {ep_profile.get('num_segments', 'N/A')}"
)
st.write(
f"**Outline Model:** {ep_profile.get('outline_provider', 'N/A')}/{ep_profile.get('outline_model', 'N/A')}"
)
st.write(
f"**Transcript Model:** {ep_profile.get('transcript_provider', 'N/A')}/{ep_profile.get('transcript_model', 'N/A')}"
)
# Show speaker configuration
if episode.get("speaker_profile"):
st.subheader("🎤 Speaker Profile")
sp_profile = episode["speaker_profile"]
st.write(f"**Name:** {sp_profile.get('name', 'Unknown')}")
st.write(
f"**Description:** {sp_profile.get('description', 'N/A')}"
)
st.write(
f"**TTS Provider:** {sp_profile.get('tts_provider', 'N/A')}/{sp_profile.get('tts_model', 'N/A')}"
)
speakers = sp_profile.get("speakers", [])
st.write(f"**Speakers ({len(speakers)}):**")
for i, speaker in enumerate(speakers, 1):
st.markdown(f"**{i}. {speaker.get('name', 'Unknown')}**")
st.write(
f" - Voice: {speaker.get('voice_id', 'Unknown')}"
)
st.write(
f" - Personality: {speaker.get('personality', 'N/A')}"
)
if speaker.get("backstory"):
st.write(f" - Background: {speaker['backstory']}")
# Show transcript if available
if episode.get("transcript"):
with st.expander(
f"📄 Transcript - {episode['name']}", expanded=False
):
transcript_data = episode["transcript"]
if (
isinstance(transcript_data, dict)
and "transcript" in transcript_data
):
st.json(transcript_data["transcript"])
else:
st.json(transcript_data)
# Show outline if available
if episode.get("outline"):
with st.expander(
f"📋 Outline - {episode['name']}", expanded=False
):
st.json(episode["outline"])
# Display failed episodes
if status_groups["failed"]:
st.subheader("❌ Failed Episodes")
for episode in status_groups["failed"]:
with st.container(border=True):
col1, col2 = st.columns([3, 1])
with col1:
st.markdown(f"**{episode['name']}**")
st.caption(
f"Profile: {episode['episode_profile'].get('name', 'Unknown')}"
)
if episode.get("created"):
st.caption(
f"Created: {format_relative_time(episode['created'])}"
)
with col2:
st.markdown(f"{get_status_emoji('failed')} Failed")
# Show error information
st.error(
"Episode generation failed. Check the logs for more details."
)
# Display pending episodes
if status_groups["pending"]:
st.subheader("⏳ Pending Episodes")
for episode in status_groups["pending"]:
with st.container(border=True):
col1, col2 = st.columns([3, 1])
with col1:
st.markdown(f"**{episode['name']}**")
st.caption(
f"Profile: {episode['episode_profile'].get('name', 'Unknown')}"
)
if episode.get("created"):
st.caption(
f"Created: {format_relative_time(episode['created'])}"
)
with col2:
st.markdown(f"{get_status_emoji('pending')} Pending")
# Get available providers and models using API service
# Load available models
text_to_speech_models = models_service.get_all_models(model_type="text_to_speech")
text_models = models_service.get_all_models(model_type="language")
# Build provider-model mappings
tts_provider_models: dict[str, list[str]] = {}
for model in text_to_speech_models:
if model.provider not in tts_provider_models:
tts_provider_models[model.provider] = []
tts_provider_models[model.provider].append(model.name)
transcript_provider_models: dict[str, list[str]] = {}
for model in text_models:
if model.provider not in transcript_provider_models:
transcript_provider_models[model.provider] = []
transcript_provider_models[model.provider].append(model.name)
# Check if we have required models
if len(text_to_speech_models) == 0:
st.error("No text-to-speech models found. Please set one up in the Models page.")
st.stop()
if len(text_models) == 0:
st.error("No language models found. Please set one up in the Models page.")
st.stop()
with templates_tab:
# Header section with explanatory content
st.header("📺 Episode Templates")
# Explanatory header about relationships and workflow
st.markdown("""
#### Understanding Episode Profiles and Speaker Profiles
**Episode profiles** define the format and AI models for podcast generation, including:
- Number of segments, outline and transcript AI models
- Default briefing templates
**Speaker profiles** define the voices and personalities that will be used, including:
- TTS provider and model settings
- Individual speaker configurations (name, voice ID, personality, backstory)
**Important**: Episode profiles reference speaker profiles by name. You can either:
1. **Recommended workflow**: Create speaker profiles first, then create episode profiles that use them
2. **Alternative**: Create episode profiles and add speaker profiles on-demand via configuration dialogs (coming in later phases)
""")
st.divider()
old_profiles = asyncio.run(repo_query("select * from podcast_config"))
if old_profiles:
st.warning(
"Found old podcast profiles. You will need to recreate them on the new configuration format. They won't be migrated automatically. You can copy what you need from here and delete them when you are done."
)
with st.expander("Old Profiles"):
st.json(old_profiles)
st.write(
"When you are done creating your new profiles, you can safely delete the old ones"
)
if st.button("Delete old profiles"):
asyncio.run(repo_query("delete from podcast_config"))
st.success("Old profiles deleted!")
st.rerun()
st.divider()
# Main layout: Episode profiles (main area) + Speaker profiles (sidebar)
col_main, col_sidebar = st.columns([3, 1])
with col_main:
render_episode_profiles_section()
with col_sidebar:
render_speaker_profiles_sidebar()