Add Write+Share permission and voice selection

Permission System Upgrade:
- Added SHARE permission level to database enum
- Three permission tiers now:
  * READ: View and chat only
  * WRITE: View, chat, add documents, generate podcasts
  * SHARE (Write+Share): All of WRITE + can share with others
- Only owners can grant SHARE permission
- Users with SHARE can add more collaborators

Voice Selection for Podcasts:
- Added 8 popular ElevenLabs voices to choose from
- Speaker 1 (Analytical): Brian, Adam, Charlie, Daniel
- Speaker 2 (Explanatory): Sarah, Bella, Charlotte, Emily
- Voice IDs stored in task parameters
- Passed through to audio generation
- Default: Brian + Sarah (original voices)

Voice Options:
- Brian (Default Male) - nPczCjzI2devNBz1zQrb
- Sarah (Default Female) - Xb7hH8MSUJpSbSDYk0k2
- Adam (Deep Male) - pNInz6obpgDQGcFmaJgB
- Bella (Friendly Female) - EXAVITQu4vr4xnSDxMaL
- Charlie (Casual Male) - IKne3meq5aSn9XLyUdCD
- Charlotte (Professional Female) - XB0fDUnXU5powFXDhCwa
- Daniel (British Male) - onwK4e9ZLuTAKqWW03F9
- Emily (Warm Female) - LcfcDJNUP1GQjkzn1xUU

UI Updates:
- Voice selection dropdowns in podcast generation
- Helpful descriptions for each speaker role
- Permission level shown as "Write + Share" in UI
- Help text explains each permission tier
- Share button visible for users with SHARE permission

Technical:
- Updated audio.py to accept voice parameters
- Updated background_tasks.py to pass voices
- Database enum extended
- Permission checks updated throughout

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
DJP 2025-10-01 18:15:26 -04:00
parent ec608b15fa
commit 030cd2ef30
5 changed files with 90 additions and 15 deletions

View file

@ -60,3 +60,19 @@ INFO:mcp.server.lowlevel.server:Warning: PydanticDeprecatedSince20: The `parse_o
INFO: 127.0.0.1:59500 - "DELETE /mcp HTTP/1.1" 307 Temporary Redirect
INFO:mcp.server.streamable_http:Terminating session: b505497d41044d28bb59b2684753a6fc
INFO: 127.0.0.1:59500 - "DELETE /mcp/ HTTP/1.1" 200 OK
INFO: 127.0.0.1:60605 - "POST /mcp HTTP/1.1" 307 Temporary Redirect
INFO:mcp.server.streamable_http_manager:Created new transport with session ID: 1bb3384b1c43444e8c5092b7426d62a4
INFO: 127.0.0.1:60605 - "POST /mcp/ HTTP/1.1" 200 OK
INFO: 127.0.0.1:60608 - "GET /mcp HTTP/1.1" 307 Temporary Redirect
INFO: 127.0.0.1:60609 - "POST /mcp HTTP/1.1" 307 Temporary Redirect
INFO: 127.0.0.1:60608 - "GET /mcp/ HTTP/1.1" 200 OK
INFO: 127.0.0.1:60609 - "POST /mcp/ HTTP/1.1" 202 Accepted
INFO: 127.0.0.1:60611 - "POST /mcp HTTP/1.1" 307 Temporary Redirect
INFO: 127.0.0.1:60611 - "POST /mcp/ HTTP/1.1" 200 OK
INFO:mcp.server.lowlevel.server:Processing request of type CallToolRequest
INFO:httpx:HTTP Request: POST https://api.cloud.llamaindex.ai/api/v1/pipelines/884e242c-86dd-4824-8347-e6dfb91d98dc/retrieve "HTTP/1.1 200 OK"
INFO:httpx:HTTP Request: POST https://api.openai.com/v1/responses "HTTP/1.1 200 OK"
INFO:mcp.server.lowlevel.server:Warning: PydanticDeprecatedSince20: The `parse_obj` method is deprecated; use `model_validate` instead. Deprecated in Pydantic V2.0 to be removed in V3.0. See Pydantic V2 Migration Guide at https://errors.pydantic.dev/2.11/migration/
INFO: 127.0.0.1:60625 - "DELETE /mcp HTTP/1.1" 307 Temporary Redirect
INFO:mcp.server.streamable_http:Terminating session: 1bb3384b1c43444e8c5092b7426d62a4
INFO: 127.0.0.1:60625 - "DELETE /mcp/ HTTP/1.1" 200 OK

View file

@ -83,19 +83,23 @@ class PodcastGenerator(BaseModel):
)
return MultiTurnConversation.model_validate_json(response.message.content)
async def _conversation_audio(self, conversation: MultiTurnConversation) -> str:
async def _conversation_audio(self, conversation: MultiTurnConversation, voice1_id: str = None, voice2_id: str = None) -> str:
# Default voices if not specified
voice1_id = voice1_id or "nPczCjzI2devNBz1zQrb"
voice2_id = voice2_id or "Xb7hH8MSUJpSbSDYk0k2"
files: List[str] = []
for turn in conversation.conversation:
if turn.speaker == "speaker1":
speech_iterator = self.client.text_to_speech.convert(
voice_id="nPczCjzI2devNBz1zQrb",
voice_id=voice1_id,
text=turn.content,
output_format="mp3_22050_32",
model_id="eleven_turbo_v2_5",
)
else:
speech_iterator = self.client.text_to_speech.convert(
voice_id="Xb7hH8MSUJpSbSDYk0k2",
voice_id=voice2_id,
text=turn.content,
output_format="mp3_22050_32",
model_id="eleven_turbo_v2_5",
@ -127,9 +131,9 @@ class PodcastGenerator(BaseModel):
return output_path
async def create_conversation(self, file_transcript: str):
async def create_conversation(self, file_transcript: str, voice1_id: str = None, voice2_id: str = None):
conversation = await self._conversation_script(file_transcript=file_transcript)
podcast_file = await self._conversation_audio(conversation=conversation)
podcast_file = await self._conversation_audio(conversation=conversation, voice1_id=voice1_id, voice2_id=voice2_id)
return podcast_file

View file

@ -217,6 +217,8 @@ async def execute_podcast_task(task_id: int):
target_length = params.get('target_length', 10)
custom_theme = params.get('custom_theme')
custom_prompt = params.get('custom_prompt')
voice1_id = params.get('voice1_id')
voice2_id = params.get('voice2_id')
try:
documents = get_notebook_documents(notebook_id)
@ -246,8 +248,12 @@ async def execute_podcast_task(task_id: int):
# Generate script
script = await generate_podcast_script_from_outline(outline, combined_content)
# Generate audio
audio_file = await PODCAST_GEN.create_conversation(file_transcript=script)
# Generate audio with selected voices
audio_file = await PODCAST_GEN.create_conversation(
file_transcript=script,
voice1_id=voice1_id,
voice2_id=voice2_id
)
# Save to notebook
save_notebook_podcast(notebook_id, audio_file)

View file

@ -20,6 +20,7 @@ SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
class PermissionLevel(enum.Enum):
READ = "read"
WRITE = "write"
SHARE = "share" # Write + Share permissions
ADMIN = "admin"

View file

@ -78,9 +78,10 @@ if not is_owner and not is_shared:
finally:
db.close()
# Determine read-only mode
# Determine permissions
is_read_only = is_shared and permission == PermissionLevel.READ
can_edit = is_owner or (is_shared and permission in [PermissionLevel.WRITE, PermissionLevel.ADMIN])
can_edit = is_owner or (is_shared and permission in [PermissionLevel.WRITE, PermissionLevel.SHARE, PermissionLevel.ADMIN])
can_share = is_owner or (is_shared and permission in [PermissionLevel.SHARE, PermissionLevel.ADMIN])
# Header
col_header1, col_header2 = st.columns([3, 1])
@ -120,7 +121,7 @@ if can_edit:
st.rerun()
with col3:
if is_owner and st.button("📤 Share Notebook", use_container_width=True):
if can_share and st.button("📤 Share Notebook", use_container_width=True):
st.session_state.sharing_notebook_id = notebook.id
st.rerun()
@ -184,8 +185,8 @@ if st.session_state.get("adding_docs_to_notebook") == notebook.id:
st.markdown("---")
# Share notebook section (owner only)
if is_owner and st.session_state.get("sharing_notebook_id") == notebook.id:
# Share notebook section (owner or users with SHARE permission)
if can_share and st.session_state.get("sharing_notebook_id") == notebook.id:
st.subheader("📤 Share Notebook")
# Show existing shares first
@ -216,10 +217,20 @@ if is_owner and st.session_state.get("sharing_notebook_id") == notebook.id:
# Add new share
st.markdown("**Add new collaborator:**")
share_email = st.text_input("Enter email to share with:")
# Only owners can grant SHARE permission
if is_owner:
permission_options = [PermissionLevel.READ, PermissionLevel.WRITE, PermissionLevel.SHARE]
help_text = "Read: View only | Write: View + Edit + Add Docs | Share: Write + Can share with others"
else:
permission_options = [PermissionLevel.READ, PermissionLevel.WRITE]
help_text = "Read: View only | Write: View + Edit + Add Docs"
permission = st.selectbox(
"Permission Level:",
options=[PermissionLevel.READ, PermissionLevel.WRITE],
format_func=lambda x: x.value.capitalize()
options=permission_options,
format_func=lambda x: "Write + Share" if x == PermissionLevel.SHARE else x.value.capitalize(),
help=help_text
)
col_share1, col_share2 = st.columns(2)
@ -308,6 +319,41 @@ if st.session_state.get("generating_podcast") == notebook.id:
help="Specific instructions for how to structure the conversation"
)
# Voice selection
st.markdown("---")
st.markdown("**🎙️ Voice Selection:**")
# Popular ElevenLabs voices
VOICE_OPTIONS = {
"Brian (Default Male)": "nPczCjzI2devNBz1zQrb",
"Sarah (Default Female)": "Xb7hH8MSUJpSbSDYk0k2",
"Adam (Deep Male)": "pNInz6obpgDQGcFmaJgB",
"Bella (Friendly Female)": "EXAVITQu4vr4xnSDxMaL",
"Charlie (Casual Male)": "IKne3meq5aSn9XLyUdCD",
"Charlotte (Professional Female)": "XB0fDUnXU5powFXDhCwa",
"Daniel (British Male)": "onwK4e9ZLuTAKqWW03F9",
"Emily (Warm Female)": "LcfcDJNUP1GQjkzn1xUU"
}
col_v1, col_v2 = st.columns(2)
with col_v1:
voice1_name = st.selectbox(
"Speaker 1 (Analytical):",
options=list(VOICE_OPTIONS.keys()),
index=0,
help="Usually asks questions and probes deeper"
)
voice1_id = VOICE_OPTIONS[voice1_name]
with col_v2:
voice2_name = st.selectbox(
"Speaker 2 (Explanatory):",
options=list(VOICE_OPTIONS.keys()),
index=1,
help="Usually explains concepts and provides context"
)
voice2_id = VOICE_OPTIONS[voice2_name]
col_p1, col_p2 = st.columns(2)
with col_p1:
if st.button("Generate in Background", type="primary", key="generate_podcast_btn"):
@ -316,7 +362,9 @@ if st.session_state.get("generating_podcast") == notebook.id:
params = {
'target_length': target_length,
'custom_theme': custom_theme if custom_theme else None,
'custom_prompt': custom_prompt if custom_prompt else None
'custom_prompt': custom_prompt if custom_prompt else None,
'voice1_id': voice1_id,
'voice2_id': voice2_id
}
# Start background task