feat(podcasts): model registry integration, credential passthrough & new features (#632)

* feat(podcasts): integrate model registry for profiles and credential passthrough

Replace loose provider/model string fields with record<model> references
in podcast profiles, enabling credential passthrough to podcast-creator.

Backend:
- EpisodeProfile: outline_llm, transcript_llm (record<model>) replace
  outline_provider/outline_model strings. New language field (BCP 47).
- SpeakerProfile: voice_model (record<model>) replaces tts_provider/
  tts_model strings. Per-speaker voice_model override support.
- Migration 14: schema changes making legacy fields optional, adding new
  record<model> fields.
- Data migration (migration.py): auto-converts legacy profiles to model
  registry references on startup. Idempotent.
- podcast_commands.py: resolves credentials for ALL profiles before
  calling podcast-creator.
- New /api/languages endpoint (pycountry + babel) with BCP 47 locale
  codes (pt-BR, en-US, etc.).

Frontend:
- Episode/speaker profile forms use ModelSelector instead of manual
  provider/model dropdowns.
- Language dropdown with BCP 47 codes in episode profile form.
- Per-speaker TTS voice model override in speaker profile form.
- "Templates" tab renamed to "Profiles".
- Setup required badge on unconfigured profiles.
- i18n updated across all 8 locales.

Closes #486, closes #552

* fix(i18n): remove unused legacy podcast provider/model keys

Remove 10 orphaned i18n keys across all 8 locales that were left behind
after replacing manual provider/model dropdowns with ModelSelector.

* fix: address review violations in podcast model registry

- P1: Remove profiles with failed model resolution from dicts to prevent
  podcast-creator validation errors on unrelated profiles
- P2: Use centralized QUERY_KEYS.languages instead of inline key
- P3: Fix ISO 639-1 → BCP 47 in model field description and CLAUDE.md
- P3: Update "templates" → "profiles" in locale string values (all 8)

* chore: bump version to 1.8.0
This commit is contained in:
Luis Novo 2026-02-27 11:06:47 -03:00 committed by GitHub
parent ce64a5f20c
commit eac837d555
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
38 changed files with 1246 additions and 714 deletions

View file

@ -218,4 +218,4 @@ See dedicated CLAUDE.md files for detailed guidance:
--- ---
**Last Updated**: February 2026 | **Project Version**: 1.7.4 **Last Updated**: February 2026 | **Project Version**: 1.8.0

View file

@ -17,6 +17,7 @@ FastAPI application serving three architectural layers: routes (HTTP endpoints),
- Load .env environment variables - Load .env environment variables
- Initialize CORS middleware + password auth middleware - Initialize CORS middleware + password auth middleware
- Run database migrations via AsyncMigrationManager on lifespan startup - Run database migrations via AsyncMigrationManager on lifespan startup
- Run podcast profile data migration (legacy string to model registry conversion)
- Register all routers - Register all routers
**Key services**: **Key services**:
@ -62,6 +63,7 @@ FastAPI application serving three architectural layers: routes (HTTP endpoints),
- **routers/transformations.py**: POST /transformations - **routers/transformations.py**: POST /transformations
- **routers/insights.py**: GET /sources/{source_id}/insights - **routers/insights.py**: GET /sources/{source_id}/insights
- **routers/auth.py**: POST /auth/password (password-based auth) - **routers/auth.py**: POST /auth/password (password-based auth)
- **routers/languages.py**: GET /languages (available podcast languages via pycountry+babel)
- **routers/commands.py**: GET /commands/{command_id} (job status tracking) - **routers/commands.py**: GET /commands/{command_id} (job status tracking)
## Common Patterns ## Common Patterns

View file

@ -32,6 +32,7 @@ from api.routers import (
embedding_rebuild, embedding_rebuild,
episode_profiles, episode_profiles,
insights, insights,
languages,
models, models,
notebooks, notebooks,
notes, notes,
@ -97,6 +98,15 @@ async def lifespan(app: FastAPI):
# Fail fast - don't start the API with an outdated database schema # Fail fast - don't start the API with an outdated database schema
raise RuntimeError(f"Failed to run database migrations: {str(e)}") from e raise RuntimeError(f"Failed to run database migrations: {str(e)}") from e
# Run podcast profile data migration (legacy strings -> Model registry)
try:
from open_notebook.podcasts.migration import migrate_podcast_profiles
await migrate_podcast_profiles()
except Exception as e:
logger.warning(f"Podcast profile migration encountered errors: {e}")
# Non-fatal: profiles can be migrated manually via UI
logger.success("API initialization completed successfully") logger.success("API initialization completed successfully")
# Yield control to the application # Yield control to the application
@ -269,6 +279,7 @@ app.include_router(speaker_profiles.router, prefix="/api", tags=["speaker-profil
app.include_router(chat.router, prefix="/api", tags=["chat"]) app.include_router(chat.router, prefix="/api", tags=["chat"])
app.include_router(source_chat.router, prefix="/api", tags=["source-chat"]) app.include_router(source_chat.router, prefix="/api", tags=["source-chat"])
app.include_router(credentials.router, prefix="/api", tags=["credentials"]) app.include_router(credentials.router, prefix="/api", tags=["credentials"])
app.include_router(languages.router, prefix="/api", tags=["languages"])
@app.get("/") @app.get("/")

View file

@ -1,4 +1,4 @@
from typing import List from typing import List, Optional
from fastapi import APIRouter, HTTPException from fastapi import APIRouter, HTTPException
from loguru import logger from loguru import logger
@ -14,12 +14,34 @@ class EpisodeProfileResponse(BaseModel):
name: str name: str
description: str description: str
speaker_config: str speaker_config: str
outline_provider: str outline_llm: Optional[str] = None
outline_model: str transcript_llm: Optional[str] = None
transcript_provider: str language: Optional[str] = None
transcript_model: str
default_briefing: str default_briefing: str
num_segments: int num_segments: int
# Legacy fields (for display/migration awareness)
outline_provider: Optional[str] = None
outline_model: Optional[str] = None
transcript_provider: Optional[str] = None
transcript_model: Optional[str] = None
def _profile_to_response(profile: EpisodeProfile) -> EpisodeProfileResponse:
return EpisodeProfileResponse(
id=str(profile.id),
name=profile.name,
description=profile.description or "",
speaker_config=profile.speaker_config,
outline_llm=profile.outline_llm,
transcript_llm=profile.transcript_llm,
language=profile.language,
default_briefing=profile.default_briefing,
num_segments=profile.num_segments,
outline_provider=profile.outline_provider,
outline_model=profile.outline_model,
transcript_provider=profile.transcript_provider,
transcript_model=profile.transcript_model,
)
@router.get("/episode-profiles", response_model=List[EpisodeProfileResponse]) @router.get("/episode-profiles", response_model=List[EpisodeProfileResponse])
@ -27,23 +49,7 @@ async def list_episode_profiles():
"""List all available episode profiles""" """List all available episode profiles"""
try: try:
profiles = await EpisodeProfile.get_all(order_by="name asc") profiles = await EpisodeProfile.get_all(order_by="name asc")
return [_profile_to_response(p) for p in profiles]
return [
EpisodeProfileResponse(
id=str(profile.id),
name=profile.name,
description=profile.description or "",
speaker_config=profile.speaker_config,
outline_provider=profile.outline_provider,
outline_model=profile.outline_model,
transcript_provider=profile.transcript_provider,
transcript_model=profile.transcript_model,
default_briefing=profile.default_briefing,
num_segments=profile.num_segments,
)
for profile in profiles
]
except Exception as e: except Exception as e:
logger.error(f"Failed to fetch episode profiles: {e}") logger.error(f"Failed to fetch episode profiles: {e}")
raise HTTPException( raise HTTPException(
@ -62,18 +68,7 @@ async def get_episode_profile(profile_name: str):
status_code=404, detail=f"Episode profile '{profile_name}' not found" status_code=404, detail=f"Episode profile '{profile_name}' not found"
) )
return EpisodeProfileResponse( return _profile_to_response(profile)
id=str(profile.id),
name=profile.name,
description=profile.description or "",
speaker_config=profile.speaker_config,
outline_provider=profile.outline_provider,
outline_model=profile.outline_model,
transcript_provider=profile.transcript_provider,
transcript_model=profile.transcript_model,
default_briefing=profile.default_briefing,
num_segments=profile.num_segments,
)
except HTTPException: except HTTPException:
raise raise
@ -88,14 +83,18 @@ class EpisodeProfileCreate(BaseModel):
name: str = Field(..., description="Unique profile name") name: str = Field(..., description="Unique profile name")
description: str = Field("", description="Profile description") description: str = Field("", description="Profile description")
speaker_config: str = Field(..., description="Reference to speaker profile name") speaker_config: str = Field(..., description="Reference to speaker profile name")
outline_provider: str = Field(..., description="AI provider for outline generation") outline_llm: Optional[str] = Field(None, description="Model record ID for outline")
outline_model: str = Field(..., description="AI model for outline generation") transcript_llm: Optional[str] = Field(
transcript_provider: str = Field( None, description="Model record ID for transcript"
..., description="AI provider for transcript generation"
) )
transcript_model: str = Field(..., description="AI model for transcript generation") language: Optional[str] = Field(None, description="Podcast language code")
default_briefing: str = Field(..., description="Default briefing template") default_briefing: str = Field(..., description="Default briefing template")
num_segments: int = Field(default=5, description="Number of podcast segments") num_segments: int = Field(default=5, description="Number of podcast segments")
# Legacy fields (accepted but not required)
outline_provider: Optional[str] = None
outline_model: Optional[str] = None
transcript_provider: Optional[str] = None
transcript_model: Optional[str] = None
@router.post("/episode-profiles", response_model=EpisodeProfileResponse) @router.post("/episode-profiles", response_model=EpisodeProfileResponse)
@ -106,28 +105,19 @@ async def create_episode_profile(profile_data: EpisodeProfileCreate):
name=profile_data.name, name=profile_data.name,
description=profile_data.description, description=profile_data.description,
speaker_config=profile_data.speaker_config, speaker_config=profile_data.speaker_config,
outline_llm=profile_data.outline_llm,
transcript_llm=profile_data.transcript_llm,
language=profile_data.language,
default_briefing=profile_data.default_briefing,
num_segments=profile_data.num_segments,
outline_provider=profile_data.outline_provider, outline_provider=profile_data.outline_provider,
outline_model=profile_data.outline_model, outline_model=profile_data.outline_model,
transcript_provider=profile_data.transcript_provider, transcript_provider=profile_data.transcript_provider,
transcript_model=profile_data.transcript_model, transcript_model=profile_data.transcript_model,
default_briefing=profile_data.default_briefing,
num_segments=profile_data.num_segments,
) )
await profile.save() await profile.save()
return _profile_to_response(profile)
return EpisodeProfileResponse(
id=str(profile.id),
name=profile.name,
description=profile.description or "",
speaker_config=profile.speaker_config,
outline_provider=profile.outline_provider,
outline_model=profile.outline_model,
transcript_provider=profile.transcript_provider,
transcript_model=profile.transcript_model,
default_briefing=profile.default_briefing,
num_segments=profile.num_segments,
)
except Exception as e: except Exception as e:
logger.error(f"Failed to create episode profile: {e}") logger.error(f"Failed to create episode profile: {e}")
@ -147,31 +137,21 @@ async def update_episode_profile(profile_id: str, profile_data: EpisodeProfileCr
status_code=404, detail=f"Episode profile '{profile_id}' not found" status_code=404, detail=f"Episode profile '{profile_id}' not found"
) )
# Update fields
profile.name = profile_data.name profile.name = profile_data.name
profile.description = profile_data.description profile.description = profile_data.description
profile.speaker_config = profile_data.speaker_config profile.speaker_config = profile_data.speaker_config
profile.outline_llm = profile_data.outline_llm
profile.transcript_llm = profile_data.transcript_llm
profile.language = profile_data.language
profile.default_briefing = profile_data.default_briefing
profile.num_segments = profile_data.num_segments
profile.outline_provider = profile_data.outline_provider profile.outline_provider = profile_data.outline_provider
profile.outline_model = profile_data.outline_model profile.outline_model = profile_data.outline_model
profile.transcript_provider = profile_data.transcript_provider profile.transcript_provider = profile_data.transcript_provider
profile.transcript_model = profile_data.transcript_model profile.transcript_model = profile_data.transcript_model
profile.default_briefing = profile_data.default_briefing
profile.num_segments = profile_data.num_segments
await profile.save() await profile.save()
return _profile_to_response(profile)
return EpisodeProfileResponse(
id=str(profile.id),
name=profile.name,
description=profile.description or "",
speaker_config=profile.speaker_config,
outline_provider=profile.outline_provider,
outline_model=profile.outline_model,
transcript_provider=profile.transcript_provider,
transcript_model=profile.transcript_model,
default_briefing=profile.default_briefing,
num_segments=profile.num_segments,
)
except HTTPException: except HTTPException:
raise raise
@ -219,33 +199,23 @@ async def duplicate_episode_profile(profile_id: str):
status_code=404, detail=f"Episode profile '{profile_id}' not found" status_code=404, detail=f"Episode profile '{profile_id}' not found"
) )
# Create duplicate with modified name
duplicate = EpisodeProfile( duplicate = EpisodeProfile(
name=f"{original.name} - Copy", name=f"{original.name} - Copy",
description=original.description, description=original.description,
speaker_config=original.speaker_config, speaker_config=original.speaker_config,
outline_llm=original.outline_llm,
transcript_llm=original.transcript_llm,
language=original.language,
default_briefing=original.default_briefing,
num_segments=original.num_segments,
outline_provider=original.outline_provider, outline_provider=original.outline_provider,
outline_model=original.outline_model, outline_model=original.outline_model,
transcript_provider=original.transcript_provider, transcript_provider=original.transcript_provider,
transcript_model=original.transcript_model, transcript_model=original.transcript_model,
default_briefing=original.default_briefing,
num_segments=original.num_segments,
) )
await duplicate.save() await duplicate.save()
return _profile_to_response(duplicate)
return EpisodeProfileResponse(
id=str(duplicate.id),
name=duplicate.name,
description=duplicate.description or "",
speaker_config=duplicate.speaker_config,
outline_provider=duplicate.outline_provider,
outline_model=duplicate.outline_model,
transcript_provider=duplicate.transcript_provider,
transcript_model=duplicate.transcript_model,
default_briefing=duplicate.default_briefing,
num_segments=duplicate.num_segments,
)
except HTTPException: except HTTPException:
raise raise

83
api/routers/languages.py Normal file
View file

@ -0,0 +1,83 @@
from typing import List
import pycountry
from babel import Locale
from babel.core import get_global
from fastapi import APIRouter
from pydantic import BaseModel
router = APIRouter()
# Additional regional variants for languages where the distinction matters
# (TTS accent, vocabulary, spelling differences)
_EXTRA_VARIANTS = [
"pt_PT",
"en_GB",
"en_AU",
"en_IN",
"es_MX",
"es_AR",
"es_CO",
"fr_CA",
"fr_CH",
"zh_TW",
"zh_HK",
"de_AT",
"de_CH",
"ar_SA",
"nl_BE",
]
class LanguageResponse(BaseModel):
code: str
name: str
@router.get("/languages", response_model=List[LanguageResponse])
async def list_languages():
"""List available languages as BCP 47 locale codes (e.g. pt-BR, en-US)."""
likely_subtags = get_global("likely_subtags")
languages = []
seen = set()
# 1. For each language, resolve its default locale via CLDR likely subtags
for lang in pycountry.languages:
if not hasattr(lang, "alpha_2"):
continue
code = lang.alpha_2
likely = likely_subtags.get(code)
if likely:
try:
loc = Locale.parse(likely)
if loc.territory:
bcp47 = f"{loc.language}-{loc.territory}"
display = loc.get_display_name("en")
if bcp47 not in seen:
seen.add(bcp47)
languages.append(LanguageResponse(code=bcp47, name=display))
continue
except Exception:
pass
# Fallback: bare language code
if code not in seen:
seen.add(code)
languages.append(LanguageResponse(code=code, name=lang.name))
# 2. Add important regional variants
for locale_str in _EXTRA_VARIANTS:
try:
loc = Locale.parse(locale_str)
bcp47 = f"{loc.language}-{loc.territory}"
if bcp47 not in seen:
seen.add(bcp47)
display = loc.get_display_name("en")
languages.append(LanguageResponse(code=bcp47, name=display))
except Exception:
pass
languages.sort(key=lambda x: x.name)
return languages

View file

@ -1,4 +1,4 @@
from typing import Any, Dict, List from typing import Any, Dict, List, Optional
from fastapi import APIRouter, HTTPException from fastapi import APIRouter, HTTPException
from loguru import logger from loguru import logger
@ -13,9 +13,23 @@ class SpeakerProfileResponse(BaseModel):
id: str id: str
name: str name: str
description: str description: str
tts_provider: str voice_model: Optional[str] = None
tts_model: str
speakers: List[Dict[str, Any]] speakers: List[Dict[str, Any]]
# Legacy fields (for display/migration awareness)
tts_provider: Optional[str] = None
tts_model: Optional[str] = None
def _profile_to_response(profile: SpeakerProfile) -> SpeakerProfileResponse:
return SpeakerProfileResponse(
id=str(profile.id),
name=profile.name,
description=profile.description or "",
voice_model=profile.voice_model,
speakers=profile.speakers,
tts_provider=profile.tts_provider,
tts_model=profile.tts_model,
)
@router.get("/speaker-profiles", response_model=List[SpeakerProfileResponse]) @router.get("/speaker-profiles", response_model=List[SpeakerProfileResponse])
@ -23,19 +37,7 @@ async def list_speaker_profiles():
"""List all available speaker profiles""" """List all available speaker profiles"""
try: try:
profiles = await SpeakerProfile.get_all(order_by="name asc") profiles = await SpeakerProfile.get_all(order_by="name asc")
return [_profile_to_response(p) for p in profiles]
return [
SpeakerProfileResponse(
id=str(profile.id),
name=profile.name,
description=profile.description or "",
tts_provider=profile.tts_provider,
tts_model=profile.tts_model,
speakers=profile.speakers,
)
for profile in profiles
]
except Exception as e: except Exception as e:
logger.error(f"Failed to fetch speaker profiles: {e}") logger.error(f"Failed to fetch speaker profiles: {e}")
raise HTTPException( raise HTTPException(
@ -54,14 +56,7 @@ async def get_speaker_profile(profile_name: str):
status_code=404, detail=f"Speaker profile '{profile_name}' not found" status_code=404, detail=f"Speaker profile '{profile_name}' not found"
) )
return SpeakerProfileResponse( return _profile_to_response(profile)
id=str(profile.id),
name=profile.name,
description=profile.description or "",
tts_provider=profile.tts_provider,
tts_model=profile.tts_model,
speakers=profile.speakers,
)
except HTTPException: except HTTPException:
raise raise
@ -75,11 +70,13 @@ async def get_speaker_profile(profile_name: str):
class SpeakerProfileCreate(BaseModel): class SpeakerProfileCreate(BaseModel):
name: str = Field(..., description="Unique profile name") name: str = Field(..., description="Unique profile name")
description: str = Field("", description="Profile description") description: str = Field("", description="Profile description")
tts_provider: str = Field(..., description="TTS provider") voice_model: Optional[str] = Field(None, description="Model record ID for TTS")
tts_model: str = Field(..., description="TTS model name")
speakers: List[Dict[str, Any]] = Field( speakers: List[Dict[str, Any]] = Field(
..., description="Array of speaker configurations" ..., description="Array of speaker configurations"
) )
# Legacy fields (accepted but not required)
tts_provider: Optional[str] = None
tts_model: Optional[str] = None
@router.post("/speaker-profiles", response_model=SpeakerProfileResponse) @router.post("/speaker-profiles", response_model=SpeakerProfileResponse)
@ -89,21 +86,14 @@ async def create_speaker_profile(profile_data: SpeakerProfileCreate):
profile = SpeakerProfile( profile = SpeakerProfile(
name=profile_data.name, name=profile_data.name,
description=profile_data.description, description=profile_data.description,
voice_model=profile_data.voice_model,
speakers=profile_data.speakers,
tts_provider=profile_data.tts_provider, tts_provider=profile_data.tts_provider,
tts_model=profile_data.tts_model, tts_model=profile_data.tts_model,
speakers=profile_data.speakers,
) )
await profile.save() await profile.save()
return _profile_to_response(profile)
return SpeakerProfileResponse(
id=str(profile.id),
name=profile.name,
description=profile.description or "",
tts_provider=profile.tts_provider,
tts_model=profile.tts_model,
speakers=profile.speakers,
)
except Exception as e: except Exception as e:
logger.error(f"Failed to create speaker profile: {e}") logger.error(f"Failed to create speaker profile: {e}")
@ -123,23 +113,15 @@ async def update_speaker_profile(profile_id: str, profile_data: SpeakerProfileCr
status_code=404, detail=f"Speaker profile '{profile_id}' not found" status_code=404, detail=f"Speaker profile '{profile_id}' not found"
) )
# Update fields
profile.name = profile_data.name profile.name = profile_data.name
profile.description = profile_data.description profile.description = profile_data.description
profile.voice_model = profile_data.voice_model
profile.speakers = profile_data.speakers
profile.tts_provider = profile_data.tts_provider profile.tts_provider = profile_data.tts_provider
profile.tts_model = profile_data.tts_model profile.tts_model = profile_data.tts_model
profile.speakers = profile_data.speakers
await profile.save() await profile.save()
return _profile_to_response(profile)
return SpeakerProfileResponse(
id=str(profile.id),
name=profile.name,
description=profile.description or "",
tts_provider=profile.tts_provider,
tts_model=profile.tts_model,
speakers=profile.speakers,
)
except HTTPException: except HTTPException:
raise raise
@ -187,25 +169,17 @@ async def duplicate_speaker_profile(profile_id: str):
status_code=404, detail=f"Speaker profile '{profile_id}' not found" status_code=404, detail=f"Speaker profile '{profile_id}' not found"
) )
# Create duplicate with modified name
duplicate = SpeakerProfile( duplicate = SpeakerProfile(
name=f"{original.name} - Copy", name=f"{original.name} - Copy",
description=original.description, description=original.description,
voice_model=original.voice_model,
speakers=original.speakers,
tts_provider=original.tts_provider, tts_provider=original.tts_provider,
tts_model=original.tts_model, tts_model=original.tts_model,
speakers=original.speakers,
) )
await duplicate.save() await duplicate.save()
return _profile_to_response(duplicate)
return SpeakerProfileResponse(
id=str(duplicate.id),
name=duplicate.name,
description=duplicate.description or "",
tts_provider=duplicate.tts_provider,
tts_model=duplicate.tts_model,
speakers=duplicate.speakers,
)
except HTTPException: except HTTPException:
raise raise

View file

@ -16,7 +16,7 @@
- **`process_source_command`**: Ingests content through `source_graph`, creates embeddings (optional), and generates insights. Retries on transaction conflicts (exp. jitter, max 15×, 1-120s). - **`process_source_command`**: Ingests content through `source_graph`, creates embeddings (optional), and generates insights. Retries on transaction conflicts (exp. jitter, max 15×, 1-120s).
- **`run_transformation_command`**: Runs a transformation on an existing source to generate an insight. Executes the transformation graph (LLM call) then creates insight via `create_insight_command`. Used by `POST /sources/{id}/insights` API endpoint. Retry: 5 attempts, exponential jitter 1-60s. - **`run_transformation_command`**: Runs a transformation on an existing source to generate an insight. Executes the transformation graph (LLM call) then creates insight via `create_insight_command`. Used by `POST /sources/{id}/insights` API endpoint. Retry: 5 attempts, exponential jitter 1-60s.
- **`generate_podcast_command`**: Creates podcasts via `podcast-creator` library using stored episode/speaker profiles. - **`generate_podcast_command`**: Creates podcasts via podcast-creator library. Resolves model registry references and credentials for all profiles before invoking podcast-creator. Validates that outline_llm, transcript_llm, and voice_model are configured.
- **`process_text_command`** (example): Test fixture for text operations (uppercase, lowercase, reverse, word_count). - **`process_text_command`** (example): Test fixture for text operations (uppercase, lowercase, reverse, word_count).
- **`analyze_data_command`** (example): Test fixture for numeric aggregations. - **`analyze_data_command`** (example): Test fixture for numeric aggregations.
@ -43,7 +43,7 @@
- **source_commands**: `ensure_record_id()` wraps command IDs for DB storage; transaction conflicts trigger exponential backoff retry. ValueError exceptions are permanent (not retried). - **source_commands**: `ensure_record_id()` wraps command IDs for DB storage; transaction conflicts trigger exponential backoff retry. ValueError exceptions are permanent (not retried).
- **embedding_commands**: Content type detection uses file extension as primary source, heuristics as fallback. Chunks >1800 chars trigger secondary splitting. Empty/whitespace-only content returns ValueError (not retried). - **embedding_commands**: Content type detection uses file extension as primary source, heuristics as fallback. Chunks >1800 chars trigger secondary splitting. Empty/whitespace-only content returns ValueError (not retried).
- **rebuild_embeddings_command**: Returns "jobs_submitted" not "processed_items" - embedding is async. Individual commands handle failures with their own retries. - **rebuild_embeddings_command**: Returns "jobs_submitted" not "processed_items" - embedding is async. Individual commands handle failures with their own retries.
- **podcast_commands**: Profiles loaded from SurrealDB by name (must exist); briefing can be extended with suffix. Episode records created mid-execution. - **podcast_commands**: Profiles loaded from SurrealDB by name; model configs (credentials) resolved for ALL profiles before podcast-creator validation. Validates outline_llm/transcript_llm/voice_model are set. Episode records created mid-execution.
- **Example commands**: Accept optional `delay_seconds` for testing async behavior; not for production. - **Example commands**: Accept optional `delay_seconds` for testing async behavior; not for production.
## Code Example ## Code Example

View file

@ -8,7 +8,12 @@ from surreal_commands import CommandInput, CommandOutput, command
from open_notebook.config import DATA_FOLDER from open_notebook.config import DATA_FOLDER
from open_notebook.database.repository import ensure_record_id, repo_query from open_notebook.database.repository import ensure_record_id, repo_query
from open_notebook.podcasts.models import EpisodeProfile, PodcastEpisode, SpeakerProfile from open_notebook.podcasts.models import (
EpisodeProfile,
PodcastEpisode,
SpeakerProfile,
_resolve_model_config,
)
try: try:
from podcast_creator import configure, create_podcast from podcast_creator import configure, create_podcast
@ -79,7 +84,41 @@ async def generate_podcast_command(
logger.info(f"Loaded episode profile: {episode_profile.name}") logger.info(f"Loaded episode profile: {episode_profile.name}")
logger.info(f"Loaded speaker profile: {speaker_profile.name}") logger.info(f"Loaded speaker profile: {speaker_profile.name}")
# 3. Load all profiles and configure podcast-creator # 2. Validate that model registry fields are populated
if not episode_profile.outline_llm:
raise ValueError(
f"Episode profile '{episode_profile.name}' has no outline model configured. "
"Please update the profile to select an outline model."
)
if not episode_profile.transcript_llm:
raise ValueError(
f"Episode profile '{episode_profile.name}' has no transcript model configured. "
"Please update the profile to select a transcript model."
)
if not speaker_profile.voice_model:
raise ValueError(
f"Speaker profile '{speaker_profile.name}' has no voice model configured. "
"Please update the profile to select a voice model."
)
# 3. Resolve model configs with credentials
outline_provider, outline_model_name, outline_config = (
await episode_profile.resolve_outline_config()
)
transcript_provider, transcript_model_name, transcript_config = (
await episode_profile.resolve_transcript_config()
)
tts_provider, tts_model_name, tts_config = (
await speaker_profile.resolve_tts_config()
)
logger.info(
f"Resolved models - outline: {outline_provider}/{outline_model_name}, "
f"transcript: {transcript_provider}/{transcript_model_name}, "
f"tts: {tts_provider}/{tts_model_name}"
)
# 4. Load all profiles and configure podcast-creator
episode_profiles = await repo_query("SELECT * FROM episode_profile") episode_profiles = await repo_query("SELECT * FROM episode_profile")
speaker_profiles = await repo_query("SELECT * FROM speaker_profile") speaker_profiles = await repo_query("SELECT * FROM speaker_profile")
@ -91,12 +130,74 @@ async def generate_podcast_command(
profile["name"]: profile for profile in speaker_profiles profile["name"]: profile for profile in speaker_profiles
} }
# 4. Generate briefing # 5. Inject resolved model configs into profile dicts
# Resolve ALL episode profiles (podcast-creator validates all).
# Remove profiles that fail resolution to prevent validation errors.
for ep_name in list(episode_profiles_dict.keys()):
ep_dict = episode_profiles_dict[ep_name]
try:
if ep_dict.get("outline_llm"):
prov, model, conf = await _resolve_model_config(
str(ep_dict["outline_llm"])
)
ep_dict["outline_provider"] = prov
ep_dict["outline_model"] = model
ep_dict["outline_config"] = conf
if ep_dict.get("transcript_llm"):
prov, model, conf = await _resolve_model_config(
str(ep_dict["transcript_llm"])
)
ep_dict["transcript_provider"] = prov
ep_dict["transcript_model"] = model
ep_dict["transcript_config"] = conf
except Exception as e:
logger.warning(
f"Failed to resolve models for episode profile '{ep_name}', "
f"removing from config to prevent validation errors: {e}"
)
del episode_profiles_dict[ep_name]
# Resolve TTS for ALL speaker profiles (podcast-creator validates all).
# Remove profiles that fail resolution to prevent validation errors.
for sp_name in list(speaker_profiles_dict.keys()):
sp_dict = speaker_profiles_dict[sp_name]
if sp_dict.get("voice_model"):
try:
prov, model, conf = await _resolve_model_config(
str(sp_dict["voice_model"])
)
sp_dict["tts_provider"] = prov
sp_dict["tts_model"] = model
sp_dict["tts_config"] = conf
except Exception as e:
logger.warning(
f"Failed to resolve TTS for speaker profile '{sp_name}', "
f"removing from config to prevent validation errors: {e}"
)
del speaker_profiles_dict[sp_name]
continue
# Per-speaker TTS overrides
for speaker in sp_dict.get("speakers", []):
if speaker.get("voice_model"):
try:
prov, model, conf = await _resolve_model_config(
str(speaker["voice_model"])
)
speaker["tts_provider"] = prov
speaker["tts_model"] = model
speaker["tts_config"] = conf
except Exception as e:
logger.warning(
f"Failed to resolve per-speaker TTS for '{speaker.get('name')}': {e}"
)
# 6. Generate briefing
briefing = episode_profile.default_briefing briefing = episode_profile.default_briefing
if input_data.briefing_suffix: if input_data.briefing_suffix:
briefing += f"\n\nAdditional instructions: {input_data.briefing_suffix}" briefing += f"\n\nAdditional instructions: {input_data.briefing_suffix}"
# Create the a record for the episose and associate with the ongoing command # Create the record for the episode and associate with the ongoing command
episode = PodcastEpisode( episode = PodcastEpisode(
name=input_data.episode_name, name=input_data.episode_name,
episode_profile=full_model_dump(episode_profile.model_dump()), episode_profile=full_model_dump(episode_profile.model_dump()),
@ -119,13 +220,13 @@ async def generate_podcast_command(
logger.info(f"Generated briefing (length: {len(briefing)} chars)") logger.info(f"Generated briefing (length: {len(briefing)} chars)")
# 5. Create output directory # 7. Create output directory
output_dir = Path(f"{DATA_FOLDER}/podcasts/episodes/{input_data.episode_name}") output_dir = Path(f"{DATA_FOLDER}/podcasts/episodes/{input_data.episode_name}")
output_dir.mkdir(parents=True, exist_ok=True) output_dir.mkdir(parents=True, exist_ok=True)
logger.info(f"Created output directory: {output_dir}") logger.info(f"Created output directory: {output_dir}")
# 6. Generate podcast using podcast-creator # 8. Generate podcast using podcast-creator
logger.info("Starting podcast generation with podcast-creator...") logger.info("Starting podcast generation with podcast-creator...")
result = await create_podcast( result = await create_podcast(

View file

@ -94,13 +94,14 @@ Speaker 1: "Expert Alex"
├─ Expertise: "Deep knowledge of alignment research" ├─ Expertise: "Deep knowledge of alignment research"
├─ Personality: "Rigorous, academic, patient with explanation" ├─ Personality: "Rigorous, academic, patient with explanation"
├─ Accent: (Optional) "British English" ├─ Accent: (Optional) "British English"
└─ TTS Voice: "OpenAI Onyx" (or ElevenLabs, Google, etc.) └─ Voice Model: Selected from model registry (e.g., OpenAI TTS)
└─ Optional per-speaker override of the episode's default voice model
Speaker 2: "Researcher Sam" Speaker 2: "Researcher Sam"
├─ Expertise: "Field observer, pragmatic perspective" ├─ Expertise: "Field observer, pragmatic perspective"
├─ Personality: "Curious, asks clarifying questions" ├─ Personality: "Curious, asks clarifying questions"
├─ Accent: "American English" ├─ Accent: "American English"
└─ TTS Voice: "ElevenLabs - thoughtful" └─ Voice Model: Selected from model registry (e.g., ElevenLabs TTS)
``` ```
### Stage 4: Outline Generation ### Stage 4: Outline Generation
@ -147,10 +148,10 @@ Alex: "Exactly. And that's where the three approaches come in..."
### Stage 6: Text-to-Speech ### Stage 6: Text-to-Speech
System converts dialogue to audio: System converts dialogue to audio using the voice models configured in the model registry. Credentials are automatically resolved from each model's configuration.
``` ```
Alex's text → OpenAI TTS → Alex's voice (audio file) Alex's text → Voice model (from registry) → Alex's voice (audio file)
Sam's text → ElevenLabs TTS → Sam's voice (audio file) Sam's text → Voice model (from registry) → Sam's voice (audio file)
Audio files → Mix together → Final podcast MP3 Audio files → Mix together → Final podcast MP3
``` ```
@ -181,7 +182,7 @@ When podcast generation fails (e.g., wrong model configured, API key expired, pr
| Error | What to Do | | Error | What to Do |
|-------|-----------| |-------|-----------|
| Invalid API key | Check Settings -> Credentials for the TTS and language model providers | | Invalid API key | Check Settings -> Credentials for the TTS and language model providers |
| Model not found | Verify the model name in your episode profile exists and is correctly configured | | Model not found | Verify the model exists in the model registry and has valid credentials configured |
| Rate limit exceeded | Wait a few minutes and retry | | Rate limit exceeded | Wait a few minutes and retry |
| Provider unavailable | Check provider status page; retry later | | Provider unavailable | Check provider status page; retry later |
@ -314,7 +315,7 @@ New team member listens, gets context faster than reading 100 documents
4. Decide on podcast 4. Decide on podcast
├─→ Create speaker profiles ├─→ Create speaker profiles
├─→ Define episode profile ├─→ Define episode profile
├─→ Choose TTS provider ├─→ Configure voice models (from model registry)
└─→ Generate podcast └─→ Generate podcast
5. Listen while commuting/exercising 5. Listen while commuting/exercising

View file

@ -74,7 +74,7 @@ An episode profile defines the structure and tone.
**Option A: Use Preset Profile** **Option A: Use Preset Profile**
``` ```
Open Notebook provides templates: Open Notebook provides preset profiles:
Academic Presentation (Monologue) Academic Presentation (Monologue)
├─ 1 speaker ├─ 1 speaker
@ -140,22 +140,22 @@ Speakers are the "voice" of your podcast.
**Option A: Use Preset Speakers** **Option A: Use Preset Speakers**
``` ```
Open Notebook provides templates: Open Notebook provides preset profiles:
"Expert Alex" "Expert Alex"
- Expertise: Deep knowledge - Expertise: Deep knowledge
- Personality: Rigorous, patient - Personality: Rigorous, patient
- TTS: OpenAI (clear voice) - Voice Model: Selected from model registry
"Curious Sam" "Curious Sam"
- Expertise: Curious newcomer - Expertise: Curious newcomer
- Personality: Asks questions - Personality: Asks questions
- TTS: Google (natural voice) - Voice Model: Selected from model registry
"Skeptic Jordan" "Skeptic Jordan"
- Expertise: Critical perspective - Expertise: Critical perspective
- Personality: Challenges assumptions - Personality: Challenges assumptions
- TTS: ElevenLabs (warm voice) - Voice Model: Selected from model registry
For your first podcast: Use presets For your first podcast: Use presets
For custom podcast: Create your own For custom podcast: Create your own
@ -179,15 +179,17 @@ Personality:
explains clearly, asks good questions" explains clearly, asks good questions"
Voice Configuration: Voice Configuration:
- TTS Provider: OpenAI / Google / ElevenLabs / Local - Voice Model: Select from model registry (e.g., OpenAI TTS, Google TTS, ElevenLabs)
- Voice selection: Choose from available voices - Voice: Choose from available voices for the selected model
- Accent (optional): British / American / etc. - Per-speaker override: Each speaker can optionally use a different voice model
Credentials are automatically resolved from the model configuration.
Example: Example:
Name: Dr. Research Expert Name: Dr. Research Expert
Expertise: AI safety alignment research Expertise: AI safety alignment research
Personality: Rigorous, academic but accessible Personality: Rigorous, academic but accessible
Voice: ElevenLabs - professional male voice Voice Model: ElevenLabs TTS (from registry), Voice: professional male
``` ```
### Step 6: Generate Podcast ### Step 6: Generate Podcast
@ -463,7 +465,7 @@ Rule: 3-5 sources per podcast
**Solutions**: **Solutions**:
``` ```
1. Choose different TTS providers (OpenAI + Google) 1. Choose different voice models from the registry for each speaker
2. Choose very different voice options 2. Choose very different voice options
3. Increase personality differences in profile 3. Increase personality differences in profile
4. Try different speaker count (2 vs 3 vs 4) 4. Try different speaker count (2 vs 3 vs 4)

View file

@ -200,8 +200,8 @@ Inside a notebook, switch to Podcasts:
│ Episode Profile: [Select ▼] │ │ Episode Profile: [Select ▼] │
│ │ │ │
│ Speakers: │ │ Speakers: │
│ ├─ Host: Alex (OpenAI) │ ├─ Host: Alex (voice model)
│ └─ Guest: Sam (Google) │ └─ Guest: Sam (voice model)
│ │ │ │
│ Include: │ │ Include: │
│ ☑ Paper.pdf │ │ ☑ Paper.pdf │

View file

@ -1,18 +1,29 @@
'use client' 'use client'
import { useState } from 'react' import { useMemo, useState } from 'react'
import { AlertTriangle } from 'lucide-react'
import { AppShell } from '@/components/layout/AppShell' import { AppShell } from '@/components/layout/AppShell'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { EpisodesTab } from '@/components/podcasts/EpisodesTab' import { EpisodesTab } from '@/components/podcasts/EpisodesTab'
import { TemplatesTab } from '@/components/podcasts/TemplatesTab' import { TemplatesTab } from '@/components/podcasts/TemplatesTab'
import { Mic, LayoutTemplate } from 'lucide-react' import { Mic, LayoutTemplate } from 'lucide-react'
import { useTranslation } from '@/lib/hooks/use-translation' import { useTranslation } from '@/lib/hooks/use-translation'
import { useEpisodeProfiles, useSpeakerProfiles } from '@/lib/hooks/use-podcasts'
import { needsModelSetup } from '@/lib/types/podcasts'
export default function PodcastsPage() { export default function PodcastsPage() {
const { t } = useTranslation() const { t } = useTranslation()
const [activeTab, setActiveTab] = useState<'episodes' | 'templates'>('episodes') const [activeTab, setActiveTab] = useState<'episodes' | 'templates'>('episodes')
const { episodeProfiles } = useEpisodeProfiles()
const { speakerProfiles } = useSpeakerProfiles(episodeProfiles)
const hasUnconfiguredProfiles = useMemo(() => {
return episodeProfiles.some(needsModelSetup) || speakerProfiles.some(needsModelSetup)
}, [episodeProfiles, speakerProfiles])
return ( return (
<AppShell> <AppShell>
<div className="flex-1 overflow-y-auto"> <div className="flex-1 overflow-y-auto">
@ -24,6 +35,16 @@ export default function PodcastsPage() {
</p> </p>
</header> </header>
{hasUnconfiguredProfiles ? (
<Alert className="bg-amber-50 text-amber-900 border-amber-200">
<AlertTriangle className="h-4 w-4" />
<AlertTitle>{t.podcasts.setupRequired}</AlertTitle>
<AlertDescription>
{t.podcasts.setupRequiredDesc}
</AlertDescription>
</Alert>
) : null}
<Tabs <Tabs
value={activeTab} value={activeTab}
onValueChange={(value) => setActiveTab(value as 'episodes' | 'templates')} onValueChange={(value) => setActiveTab(value as 'episodes' | 'templates')}

View file

@ -1,13 +1,14 @@
'use client' 'use client'
import { useMemo, useState } from 'react' import { useMemo, useState } from 'react'
import { Copy, Edit3, MoreVertical, Trash2, Users } from 'lucide-react' import { AlertTriangle, Copy, Edit3, MoreVertical, Trash2, Users } from 'lucide-react'
import { EpisodeProfile, SpeakerProfile } from '@/lib/types/podcasts' import { EpisodeProfile, SpeakerProfile, needsModelSetup } from '@/lib/types/podcasts'
import { import {
useDeleteEpisodeProfile, useDeleteEpisodeProfile,
useDuplicateEpisodeProfile, useDuplicateEpisodeProfile,
} from '@/lib/hooks/use-podcasts' } from '@/lib/hooks/use-podcasts'
import { useModels } from '@/lib/hooks/use-models'
import { EpisodeProfileFormDialog } from '@/components/podcasts/forms/EpisodeProfileFormDialog' import { EpisodeProfileFormDialog } from '@/components/podcasts/forms/EpisodeProfileFormDialog'
import { import {
AlertDialog, AlertDialog,
@ -41,7 +42,6 @@ import { useTranslation } from '@/lib/hooks/use-translation'
interface EpisodeProfilesPanelProps { interface EpisodeProfilesPanelProps {
episodeProfiles: EpisodeProfile[] episodeProfiles: EpisodeProfile[]
speakerProfiles: SpeakerProfile[] speakerProfiles: SpeakerProfile[]
modelOptions: Record<string, string[]>
} }
function findSpeakerSummary( function findSpeakerSummary(
@ -54,7 +54,6 @@ function findSpeakerSummary(
export function EpisodeProfilesPanel({ export function EpisodeProfilesPanel({
episodeProfiles, episodeProfiles,
speakerProfiles, speakerProfiles,
modelOptions,
}: EpisodeProfilesPanelProps) { }: EpisodeProfilesPanelProps) {
const { t } = useTranslation() const { t } = useTranslation()
const [createOpen, setCreateOpen] = useState(false) const [createOpen, setCreateOpen] = useState(false)
@ -62,6 +61,15 @@ export function EpisodeProfilesPanel({
const deleteProfile = useDeleteEpisodeProfile() const deleteProfile = useDeleteEpisodeProfile()
const duplicateProfile = useDuplicateEpisodeProfile() const duplicateProfile = useDuplicateEpisodeProfile()
const { data: models = [] } = useModels()
const modelNameMap = useMemo(() => {
const map: Record<string, string> = {}
for (const m of models) {
map[m.id] = `${m.provider} / ${m.name}`
}
return map
}, [models])
const sortedProfiles = useMemo( const sortedProfiles = useMemo(
() => () =>
@ -102,14 +110,23 @@ export function EpisodeProfilesPanel({
speakerProfiles, speakerProfiles,
profile.speaker_config profile.speaker_config
) )
const unconfigured = needsModelSetup(profile)
return ( return (
<Card key={profile.id} className="shadow-sm"> <Card key={profile.id} className="shadow-sm">
<CardHeader className="flex flex-col gap-2 md:flex-row md:items-start md:justify-between"> <CardHeader className="flex flex-col gap-2 md:flex-row md:items-start md:justify-between">
<div> <div>
<CardTitle className="text-lg font-semibold"> <div className="flex items-center gap-2">
{profile.name} <CardTitle className="text-lg font-semibold">
</CardTitle> {profile.name}
</CardTitle>
{unconfigured ? (
<Badge variant="outline" className="text-amber-600 border-amber-300 text-xs">
<AlertTriangle className="h-3 w-3 mr-1" />
{t.podcasts.setupRequired}
</Badge>
) : null}
</div>
<CardDescription className="text-sm text-muted-foreground"> <CardDescription className="text-sm text-muted-foreground">
{profile.description || t.podcasts.noDescription} {profile.description || t.podcasts.noDescription}
</CardDescription> </CardDescription>
@ -183,7 +200,11 @@ export function EpisodeProfilesPanel({
{t.podcasts.outlineModel} {t.podcasts.outlineModel}
</p> </p>
<p className="text-foreground"> <p className="text-foreground">
{profile.outline_provider} / {profile.outline_model} {profile.outline_llm
? (modelNameMap[profile.outline_llm] ?? profile.outline_llm)
: (profile.outline_provider && profile.outline_model
? `${profile.outline_provider} / ${profile.outline_model}`
: t.podcasts.notConfigured)}
</p> </p>
</div> </div>
<div> <div>
@ -191,7 +212,11 @@ export function EpisodeProfilesPanel({
{t.podcasts.transcriptModel} {t.podcasts.transcriptModel}
</p> </p>
<p className="text-foreground"> <p className="text-foreground">
{profile.transcript_provider} / {profile.transcript_model} {profile.transcript_llm
? (modelNameMap[profile.transcript_llm] ?? profile.transcript_llm)
: (profile.transcript_provider && profile.transcript_model
? `${profile.transcript_provider} / ${profile.transcript_model}`
: t.podcasts.notConfigured)}
</p> </p>
</div> </div>
<div> <div>
@ -200,6 +225,14 @@ export function EpisodeProfilesPanel({
</p> </p>
<p className="text-foreground">{profile.num_segments}</p> <p className="text-foreground">{profile.num_segments}</p>
</div> </div>
{profile.language ? (
<div>
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
{t.podcasts.language}
</p>
<p className="text-foreground">{profile.language}</p>
</div>
) : null}
<div> <div>
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground"> <p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
{t.podcasts.speakerProfile} {t.podcasts.speakerProfile}
@ -207,7 +240,11 @@ export function EpisodeProfilesPanel({
<div className="flex items-center gap-2 text-foreground"> <div className="flex items-center gap-2 text-foreground">
<Users className="h-4 w-4" /> <Users className="h-4 w-4" />
<span>{profile.speaker_config}</span> <span>{profile.speaker_config}</span>
{speakerSummary ? ( {speakerSummary?.voice_model ? (
<Badge variant="outline" className="text-xs">
{modelNameMap[speakerSummary.voice_model] ?? speakerSummary.voice_model}
</Badge>
) : speakerSummary?.tts_provider ? (
<Badge variant="outline" className="text-xs"> <Badge variant="outline" className="text-xs">
{speakerSummary.tts_provider} / {speakerSummary.tts_model} {speakerSummary.tts_provider} / {speakerSummary.tts_model}
</Badge> </Badge>
@ -238,7 +275,6 @@ export function EpisodeProfilesPanel({
open={createOpen} open={createOpen}
onOpenChange={setCreateOpen} onOpenChange={setCreateOpen}
speakerProfiles={speakerProfiles} speakerProfiles={speakerProfiles}
modelOptions={modelOptions}
/> />
<EpisodeProfileFormDialog <EpisodeProfileFormDialog
@ -250,7 +286,6 @@ export function EpisodeProfilesPanel({
} }
}} }}
speakerProfiles={speakerProfiles} speakerProfiles={speakerProfiles}
modelOptions={modelOptions}
initialData={editProfile ?? undefined} initialData={editProfile ?? undefined}
/> />
</div> </div>

View file

@ -1,13 +1,14 @@
'use client' 'use client'
import { useMemo, useState } from 'react' import { useMemo, useState } from 'react'
import { Copy, Edit3, MoreVertical, Trash2, Volume2 } from 'lucide-react' import { AlertTriangle, Copy, Edit3, MoreVertical, Trash2, Volume2 } from 'lucide-react'
import { SpeakerProfile } from '@/lib/types/podcasts' import { SpeakerProfile, needsModelSetup } from '@/lib/types/podcasts'
import { import {
useDeleteSpeakerProfile, useDeleteSpeakerProfile,
useDuplicateSpeakerProfile, useDuplicateSpeakerProfile,
} from '@/lib/hooks/use-podcasts' } from '@/lib/hooks/use-podcasts'
import { useModels } from '@/lib/hooks/use-models'
import { SpeakerProfileFormDialog } from '@/components/podcasts/forms/SpeakerProfileFormDialog' import { SpeakerProfileFormDialog } from '@/components/podcasts/forms/SpeakerProfileFormDialog'
import { import {
AlertDialog, AlertDialog,
@ -40,13 +41,11 @@ import { useTranslation } from '@/lib/hooks/use-translation'
interface SpeakerProfilesPanelProps { interface SpeakerProfilesPanelProps {
speakerProfiles: SpeakerProfile[] speakerProfiles: SpeakerProfile[]
modelOptions: Record<string, string[]>
usage: Record<string, number> usage: Record<string, number>
} }
export function SpeakerProfilesPanel({ export function SpeakerProfilesPanel({
speakerProfiles, speakerProfiles,
modelOptions,
usage, usage,
}: SpeakerProfilesPanelProps) { }: SpeakerProfilesPanelProps) {
const { t } = useTranslation() const { t } = useTranslation()
@ -55,10 +54,19 @@ export function SpeakerProfilesPanel({
const deleteProfile = useDeleteSpeakerProfile() const deleteProfile = useDeleteSpeakerProfile()
const duplicateProfile = useDuplicateSpeakerProfile() const duplicateProfile = useDuplicateSpeakerProfile()
const { data: models = [] } = useModels()
const modelNameMap = useMemo(() => {
const map: Record<string, string> = {}
for (const m of models) {
map[m.id] = `${m.provider} / ${m.name}`
}
return map
}, [models])
const sortedProfiles = useMemo( const sortedProfiles = useMemo(
() => () =>
[...speakerProfiles].sort((a, b) => a.name.localeCompare(b.name, 'en')), [...speakerProfiles].sort((a, b) => a.name.localeCompare(b.name, 'en')),
[speakerProfiles] [speakerProfiles]
) )
@ -83,21 +91,34 @@ export function SpeakerProfilesPanel({
{sortedProfiles.map((profile) => { {sortedProfiles.map((profile) => {
const usageCount = usage[profile.name] ?? 0 const usageCount = usage[profile.name] ?? 0
const deleteDisabled = usageCount > 0 const deleteDisabled = usageCount > 0
const unconfigured = needsModelSetup(profile)
return ( return (
<Card key={profile.id} className="shadow-sm"> <Card key={profile.id} className="shadow-sm">
<CardHeader className="flex flex-col gap-2"> <CardHeader className="flex flex-col gap-2">
<div className="flex items-center justify-between gap-2"> <div className="flex items-center justify-between gap-2">
<div> <div>
<CardTitle className="text-lg font-semibold"> <div className="flex items-center gap-2">
{profile.name} <CardTitle className="text-lg font-semibold">
</CardTitle> {profile.name}
</CardTitle>
{unconfigured ? (
<Badge variant="outline" className="text-amber-600 border-amber-300 text-xs">
<AlertTriangle className="h-3 w-3 mr-1" />
{t.podcasts.setupRequired}
</Badge>
) : null}
</div>
<CardDescription className="text-sm text-muted-foreground"> <CardDescription className="text-sm text-muted-foreground">
{profile.description || t.podcasts.noDescription} {profile.description || t.podcasts.noDescription}
</CardDescription> </CardDescription>
</div> </div>
<Badge variant="outline" className="text-xs"> <Badge variant="outline" className="text-xs">
{profile.tts_provider} / {profile.tts_model} {profile.voice_model
? (modelNameMap[profile.voice_model] ?? profile.voice_model)
: (profile.tts_provider
? `${profile.tts_provider} / ${profile.tts_model}`
: t.podcasts.notConfigured)}
</Badge> </Badge>
</div> </div>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
@ -126,9 +147,16 @@ export function SpeakerProfilesPanel({
{speaker.name} {speaker.name}
</span> </span>
</div> </div>
<span className="text-xs text-muted-foreground"> <div className="flex items-center gap-2">
{t.podcasts.voiceId}: {speaker.voice_id} <span className="text-xs text-muted-foreground">
</span> {t.podcasts.voiceId}: {speaker.voice_id}
</span>
{speaker.voice_model ? (
<Badge variant="secondary" className="text-xs">
{modelNameMap[speaker.voice_model] ?? speaker.voice_model}
</Badge>
) : null}
</div>
</div> </div>
<p className="mt-2 text-xs text-muted-foreground whitespace-pre-wrap"> <p className="mt-2 text-xs text-muted-foreground whitespace-pre-wrap">
<span className="font-semibold">{t.podcasts.backstory}:</span> {speaker.backstory} <span className="font-semibold">{t.podcasts.backstory}:</span> {speaker.backstory}
@ -219,7 +247,6 @@ export function SpeakerProfilesPanel({
mode="create" mode="create"
open={createOpen} open={createOpen}
onOpenChange={setCreateOpen} onOpenChange={setCreateOpen}
modelOptions={modelOptions}
/> />
<SpeakerProfileFormDialog <SpeakerProfileFormDialog
@ -230,7 +257,6 @@ export function SpeakerProfilesPanel({
setEditProfile(null) setEditProfile(null)
} }
}} }}
modelOptions={modelOptions}
initialData={editProfile ?? undefined} initialData={editProfile ?? undefined}
/> />
</div> </div>

View file

@ -1,29 +1,14 @@
'use client' 'use client'
import { useMemo } from 'react'
import { AlertCircle, Lightbulb, Loader2 } from 'lucide-react' import { AlertCircle, Lightbulb, Loader2 } from 'lucide-react'
import { EpisodeProfilesPanel } from '@/components/podcasts/EpisodeProfilesPanel' import { EpisodeProfilesPanel } from '@/components/podcasts/EpisodeProfilesPanel'
import { SpeakerProfilesPanel } from '@/components/podcasts/SpeakerProfilesPanel' import { SpeakerProfilesPanel } from '@/components/podcasts/SpeakerProfilesPanel'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert' import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { useEpisodeProfiles, useSpeakerProfiles } from '@/lib/hooks/use-podcasts' import { useEpisodeProfiles, useSpeakerProfiles } from '@/lib/hooks/use-podcasts'
import { useModels } from '@/lib/hooks/use-models'
import { Model } from '@/lib/types/models'
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '@/components/ui/accordion' import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '@/components/ui/accordion'
import { useTranslation } from '@/lib/hooks/use-translation' import { useTranslation } from '@/lib/hooks/use-translation'
function modelsByProvider(models: Model[], type: Model['type']) {
return models
.filter((model) => model.type === type)
.reduce<Record<string, string[]>>((acc, model) => {
if (!acc[model.provider]) {
acc[model.provider] = []
}
acc[model.provider].push(model.name)
return acc
}, {})
}
export function TemplatesTab() { export function TemplatesTab() {
const { t } = useTranslation() const { t } = useTranslation()
const { const {
@ -39,23 +24,8 @@ export function TemplatesTab() {
error: speakerProfilesError, error: speakerProfilesError,
} = useSpeakerProfiles(episodeProfiles) } = useSpeakerProfiles(episodeProfiles)
const { const isLoading = loadingEpisodeProfiles || loadingSpeakerProfiles
data: models = [], const hasError = episodeProfilesError || speakerProfilesError
isLoading: loadingModels,
error: modelsError,
} = useModels()
const languageModelOptions = useMemo(
() => modelsByProvider(models, 'language'),
[models]
)
const ttsModelOptions = useMemo(
() => modelsByProvider(models, 'text_to_speech'),
[models]
)
const isLoading = loadingEpisodeProfiles || loadingSpeakerProfiles || loadingModels
const hasError = episodeProfilesError || speakerProfilesError || modelsError
return ( return (
<div className="space-y-6"> <div className="space-y-6">
@ -67,8 +37,8 @@ export function TemplatesTab() {
</div> </div>
<Accordion type="single" collapsible className="w-full"> <Accordion type="single" collapsible className="w-full">
<AccordionItem <AccordionItem
value="overview" value="overview"
className="overflow-hidden rounded-xl border border-border bg-muted/40 px-4" className="overflow-hidden rounded-xl border border-border bg-muted/40 px-4"
> >
<AccordionTrigger className="gap-2 py-4 text-left text-sm font-semibold"> <AccordionTrigger className="gap-2 py-4 text-left text-sm font-semibold">
@ -137,12 +107,10 @@ export function TemplatesTab() {
<SpeakerProfilesPanel <SpeakerProfilesPanel
speakerProfiles={speakerProfiles} speakerProfiles={speakerProfiles}
usage={usage} usage={usage}
modelOptions={ttsModelOptions}
/> />
<EpisodeProfilesPanel <EpisodeProfilesPanel
episodeProfiles={episodeProfiles} episodeProfiles={episodeProfiles}
speakerProfiles={speakerProfiles} speakerProfiles={speakerProfiles}
modelOptions={languageModelOptions}
/> />
</div> </div>
)} )}

View file

@ -1,6 +1,6 @@
'use client' 'use client'
import { useCallback, useEffect, useMemo } from 'react' import { useCallback, useEffect } from 'react'
import { Controller, useForm } from 'react-hook-form' import { Controller, useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod' import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod' import { z } from 'zod'
@ -9,6 +9,7 @@ import { EpisodeProfile, SpeakerProfile } from '@/lib/types/podcasts'
import { import {
useCreateEpisodeProfile, useCreateEpisodeProfile,
useUpdateEpisodeProfile, useUpdateEpisodeProfile,
useLanguages,
} from '@/lib/hooks/use-podcasts' } from '@/lib/hooks/use-podcasts'
import { useTranslation } from '@/lib/hooks/use-translation' import { useTranslation } from '@/lib/hooks/use-translation'
import { import {
@ -31,16 +32,16 @@ import {
} from '@/components/ui/select' } from '@/components/ui/select'
import { Textarea } from '@/components/ui/textarea' import { Textarea } from '@/components/ui/textarea'
import { Separator } from '@/components/ui/separator' import { Separator } from '@/components/ui/separator'
import { ModelSelector } from '@/components/common/ModelSelector'
import { TranslationKeys } from '@/lib/locales' import { TranslationKeys } from '@/lib/locales'
const episodeProfileSchema = (t: TranslationKeys) => z.object({ const episodeProfileSchema = (t: TranslationKeys) => z.object({
name: z.string().min(1, t.podcasts.nameRequired || 'Name is required'), name: z.string().min(1, t.podcasts.nameRequired || 'Name is required'),
description: z.string().optional(), description: z.string().optional(),
speaker_config: z.string().min(1, t.podcasts.profileRequired || 'Speaker profile is required'), speaker_config: z.string().min(1, t.podcasts.profileRequired || 'Speaker profile is required'),
outline_provider: z.string().min(1, t.podcasts.outlineProviderRequired || 'Outline provider is required'), outline_llm: z.string().min(1, t.podcasts.outlineModelRequired || 'Outline model is required'),
outline_model: z.string().min(1, t.podcasts.outlineModelRequired || 'Outline model is required'), transcript_llm: z.string().min(1, t.podcasts.transcriptModelRequired || 'Transcript model is required'),
transcript_provider: z.string().min(1, t.podcasts.transcriptProviderRequired || 'Transcript provider is required'), language: z.string().nullable().optional(),
transcript_model: z.string().min(1, t.podcasts.transcriptModelRequired || 'Transcript model is required'),
default_briefing: z.string().min(1, t.podcasts.defaultBriefingRequired || 'Default briefing is required'), default_briefing: z.string().min(1, t.podcasts.defaultBriefingRequired || 'Default briefing is required'),
num_segments: z.number() num_segments: z.number()
.int(t.podcasts.segmentsInteger || 'Must be an integer') .int(t.podcasts.segmentsInteger || 'Must be an integer')
@ -55,7 +56,6 @@ interface EpisodeProfileFormDialogProps {
open: boolean open: boolean
onOpenChange: (open: boolean) => void onOpenChange: (open: boolean) => void
speakerProfiles: SpeakerProfile[] speakerProfiles: SpeakerProfile[]
modelOptions: Record<string, string[]>
initialData?: EpisodeProfile initialData?: EpisodeProfile
} }
@ -64,29 +64,24 @@ export function EpisodeProfileFormDialog({
open, open,
onOpenChange, onOpenChange,
speakerProfiles, speakerProfiles,
modelOptions,
initialData, initialData,
}: EpisodeProfileFormDialogProps) { }: EpisodeProfileFormDialogProps) {
const { t } = useTranslation() const { t } = useTranslation()
const createProfile = useCreateEpisodeProfile() const createProfile = useCreateEpisodeProfile()
const updateProfile = useUpdateEpisodeProfile() const updateProfile = useUpdateEpisodeProfile()
const { data: languages = [] } = useLanguages()
const providers = useMemo(() => Object.keys(modelOptions), [modelOptions])
const getDefaults = useCallback((): EpisodeProfileFormValues => { const getDefaults = useCallback((): EpisodeProfileFormValues => {
const firstSpeaker = speakerProfiles[0]?.name ?? '' const firstSpeaker = speakerProfiles[0]?.name ?? ''
const firstProvider = providers[0] ?? ''
const firstModel = firstProvider ? modelOptions[firstProvider]?.[0] ?? '' : ''
if (initialData) { if (initialData) {
return { return {
name: initialData.name, name: initialData.name,
description: initialData.description ?? '', description: initialData.description ?? '',
speaker_config: initialData.speaker_config, speaker_config: initialData.speaker_config,
outline_provider: initialData.outline_provider, outline_llm: initialData.outline_llm ?? '',
outline_model: initialData.outline_model, transcript_llm: initialData.transcript_llm ?? '',
transcript_provider: initialData.transcript_provider, language: initialData.language ?? null,
transcript_model: initialData.transcript_model,
default_briefing: initialData.default_briefing, default_briefing: initialData.default_briefing,
num_segments: initialData.num_segments, num_segments: initialData.num_segments,
} }
@ -96,35 +91,25 @@ export function EpisodeProfileFormDialog({
name: '', name: '',
description: '', description: '',
speaker_config: firstSpeaker, speaker_config: firstSpeaker,
outline_provider: firstProvider, outline_llm: '',
outline_model: firstModel, transcript_llm: '',
transcript_provider: firstProvider, language: null,
transcript_model: firstModel,
default_briefing: '', default_briefing: '',
num_segments: 5, num_segments: 5,
} }
}, [initialData, modelOptions, providers, speakerProfiles]) }, [initialData, speakerProfiles])
const { const {
control, control,
register, register,
handleSubmit, handleSubmit,
reset, reset,
setValue,
watch,
formState: { errors }, formState: { errors },
} = useForm<EpisodeProfileFormValues>({ } = useForm<EpisodeProfileFormValues>({
resolver: zodResolver(episodeProfileSchema(t)), resolver: zodResolver(episodeProfileSchema(t)),
defaultValues: getDefaults(), defaultValues: getDefaults(),
}) })
const outlineProvider = watch('outline_provider')
const outlineModel = watch('outline_model')
const transcriptProvider = watch('transcript_provider')
const transcriptModel = watch('transcript_model')
const availableOutlineModels = modelOptions[outlineProvider] ?? []
const availableTranscriptModels = modelOptions[transcriptProvider] ?? []
useEffect(() => { useEffect(() => {
if (!open) { if (!open) {
return return
@ -132,38 +117,11 @@ export function EpisodeProfileFormDialog({
reset(getDefaults()) reset(getDefaults())
}, [open, reset, getDefaults]) }, [open, reset, getDefaults])
useEffect(() => {
if (!outlineProvider) {
return
}
const models = modelOptions[outlineProvider] ?? []
if (models.length === 0) {
setValue('outline_model', '')
return
}
if (!models.includes(outlineModel)) {
setValue('outline_model', models[0])
}
}, [outlineProvider, outlineModel, modelOptions, setValue])
useEffect(() => {
if (!transcriptProvider) {
return
}
const models = modelOptions[transcriptProvider] ?? []
if (models.length === 0) {
setValue('transcript_model', '')
return
}
if (!models.includes(transcriptModel)) {
setValue('transcript_model', models[0])
}
}, [transcriptProvider, transcriptModel, modelOptions, setValue])
const onSubmit = async (values: EpisodeProfileFormValues) => { const onSubmit = async (values: EpisodeProfileFormValues) => {
const payload = { const payload = {
...values, ...values,
description: values.description ?? '', description: values.description ?? '',
language: values.language || null,
} }
if (mode === 'create') { if (mode === 'create') {
@ -179,8 +137,7 @@ export function EpisodeProfileFormDialog({
} }
const isSubmitting = createProfile.isPending || updateProfile.isPending const isSubmitting = createProfile.isPending || updateProfile.isPending
const disableSubmit = const disableSubmit = isSubmitting || speakerProfiles.length === 0
isSubmitting || speakerProfiles.length === 0 || providers.length === 0
const isEdit = mode === 'edit' const isEdit = mode === 'edit'
return ( return (
@ -204,15 +161,6 @@ export function EpisodeProfileFormDialog({
</Alert> </Alert>
) : null} ) : null}
{providers.length === 0 ? (
<Alert className="bg-amber-50 text-amber-900 border-amber-200">
<AlertTitle>{t.podcasts.noLanguageModelsAvailable}</AlertTitle>
<AlertDescription>
{t.podcasts.noLanguageModelsDesc}
</AlertDescription>
</Alert>
) : null}
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6 pt-2"> <form onSubmit={handleSubmit(onSubmit)} className="space-y-6 pt-2">
<div className="grid gap-4 md:grid-cols-2"> <div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2"> <div className="space-y-2">
@ -292,61 +240,26 @@ export function EpisodeProfileFormDialog({
</h3> </h3>
<Separator className="mt-2" /> <Separator className="mt-2" />
</div> </div>
<div className="grid gap-4 md:grid-cols-2"> <Controller
<Controller control={control}
control={control} name="outline_llm"
name="outline_provider" render={({ field }) => (
render={({ field }) => ( <div>
<div className="space-y-2"> <ModelSelector
<Label htmlFor="outline_provider">{t.models.provider} *</Label> label={`${t.podcasts.outlineModel} *`}
<Select value={field.value} onValueChange={field.onChange}> modelType="language"
<SelectTrigger id="outline_provider"> value={field.value}
<SelectValue placeholder={t.models.selectProviderPlaceholder} /> onChange={field.onChange}
</SelectTrigger> placeholder={t.podcasts.selectOutlineModel}
<SelectContent title={t.models.provider}> />
{providers.map((provider) => ( {errors.outline_llm ? (
<SelectItem key={provider} value={provider}> <p className="text-xs text-red-600 mt-1">
<span className="capitalize">{provider}</span> {errors.outline_llm.message}
</SelectItem> </p>
))} ) : null}
</SelectContent> </div>
</Select> )}
{errors.outline_provider ? ( />
<p className="text-xs text-red-600">
{errors.outline_provider.message}
</p>
) : null}
</div>
)}
/>
<Controller
control={control}
name="outline_model"
render={({ field }) => (
<div className="space-y-2">
<Label htmlFor="outline_model">{t.common.model} *</Label>
<Select value={field.value} onValueChange={field.onChange}>
<SelectTrigger id="outline_model">
<SelectValue placeholder={t.models.selectModelPlaceholder} />
</SelectTrigger>
<SelectContent title={t.common.model}>
{availableOutlineModels.map((model) => (
<SelectItem key={model} value={model}>
{model}
</SelectItem>
))}
</SelectContent>
</Select>
{errors.outline_model ? (
<p className="text-xs text-red-600">
{errors.outline_model.message}
</p>
) : null}
</div>
)}
/>
</div>
</div> </div>
<div className="space-y-4"> <div className="space-y-4">
@ -356,61 +269,59 @@ export function EpisodeProfileFormDialog({
</h3> </h3>
<Separator className="mt-2" /> <Separator className="mt-2" />
</div> </div>
<div className="grid gap-4 md:grid-cols-2"> <Controller
<Controller control={control}
control={control} name="transcript_llm"
name="transcript_provider" render={({ field }) => (
render={({ field }) => ( <div>
<div className="space-y-2"> <ModelSelector
<Label htmlFor="transcript_provider">{t.models.provider} *</Label> label={`${t.podcasts.transcriptModel} *`}
<Select value={field.value} onValueChange={field.onChange}> modelType="language"
<SelectTrigger id="transcript_provider"> value={field.value}
<SelectValue placeholder={t.models.selectProviderPlaceholder} /> onChange={field.onChange}
</SelectTrigger> placeholder={t.podcasts.selectTranscriptModel}
<SelectContent title={t.models.provider}> />
{providers.map((provider) => ( {errors.transcript_llm ? (
<SelectItem key={provider} value={provider}> <p className="text-xs text-red-600 mt-1">
<span className="capitalize">{provider}</span> {errors.transcript_llm.message}
</SelectItem> </p>
))} ) : null}
</SelectContent> </div>
</Select> )}
{errors.transcript_provider ? ( />
<p className="text-xs text-red-600"> </div>
{errors.transcript_provider.message}
</p>
) : null}
</div>
)}
/>
<Controller <div className="space-y-4">
control={control} <div>
name="transcript_model" <h3 className="text-sm font-semibold uppercase tracking-wide text-muted-foreground">
render={({ field }) => ( {t.podcasts.podcastLanguage}
<div className="space-y-2"> </h3>
<Label htmlFor="transcript_model">{t.common.model} *</Label> <Separator className="mt-2" />
<Select value={field.value} onValueChange={field.onChange}>
<SelectTrigger id="transcript_model">
<SelectValue placeholder={t.models.selectModelPlaceholder} />
</SelectTrigger>
<SelectContent title={t.common.model}>
{availableTranscriptModels.map((model) => (
<SelectItem key={model} value={model}>
{model}
</SelectItem>
))}
</SelectContent>
</Select>
{errors.transcript_model ? (
<p className="text-xs text-red-600">
{errors.transcript_model.message}
</p>
) : null}
</div>
)}
/>
</div> </div>
<Controller
control={control}
name="language"
render={({ field }) => (
<div className="space-y-2">
<Label htmlFor="language">{t.podcasts.language}</Label>
<Select
value={field.value ?? ''}
onValueChange={(v) => field.onChange(v || null)}
>
<SelectTrigger id="language">
<SelectValue placeholder={t.podcasts.languagePlaceholder} />
</SelectTrigger>
<SelectContent title={t.podcasts.language}>
{languages.map((lang) => (
<SelectItem key={lang.code} value={lang.code}>
{lang.name} ({lang.code})
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
/>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">

View file

@ -1,6 +1,6 @@
'use client' 'use client'
import { useCallback, useEffect, useMemo } from 'react' import { useCallback, useEffect } from 'react'
import { Controller, useFieldArray, useForm } from 'react-hook-form' import { Controller, useFieldArray, useForm } from 'react-hook-form'
import type { FieldErrorsImpl } from 'react-hook-form' import type { FieldErrorsImpl } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod' import { zodResolver } from '@hookform/resolvers/zod'
@ -19,19 +19,12 @@ import {
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
} from '@/components/ui/dialog' } from '@/components/ui/dialog'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label' import { Label } from '@/components/ui/label'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Textarea } from '@/components/ui/textarea' import { Textarea } from '@/components/ui/textarea'
import { Separator } from '@/components/ui/separator' import { Separator } from '@/components/ui/separator'
import { ModelSelector } from '@/components/common/ModelSelector'
import { TranslationKeys } from '@/lib/locales' import { TranslationKeys } from '@/lib/locales'
import { useTranslation } from '@/lib/hooks/use-translation' import { useTranslation } from '@/lib/hooks/use-translation'
@ -41,13 +34,13 @@ const speakerConfigSchema = (t: TranslationKeys) => z.object({
voice_id: z.string().min(1, t.podcasts.voiceIdRequired || 'Voice ID is required'), voice_id: z.string().min(1, t.podcasts.voiceIdRequired || 'Voice ID is required'),
backstory: z.string().min(1, t.podcasts.backstoryRequired || 'Backstory is required'), backstory: z.string().min(1, t.podcasts.backstoryRequired || 'Backstory is required'),
personality: z.string().min(1, t.podcasts.personalityRequired || 'Personality is required'), personality: z.string().min(1, t.podcasts.personalityRequired || 'Personality is required'),
voice_model: z.string().nullable().optional(),
}) })
const speakerProfileSchema = (t: TranslationKeys) => z.object({ const speakerProfileSchema = (t: TranslationKeys) => z.object({
name: z.string().min(1, t.common.nameRequired || 'Name is required'), name: z.string().min(1, t.common.nameRequired || 'Name is required'),
description: z.string().optional(), description: z.string().optional(),
tts_provider: z.string().min(1, t.models.providerRequired || 'Provider is required'), voice_model: z.string().min(1, t.podcasts.voiceModelRequired || 'Voice model is required'),
tts_model: z.string().min(1, t.models.modelRequired || 'Model is required'),
speakers: z speakers: z
.array(speakerConfigSchema(t)) .array(speakerConfigSchema(t))
.min(1, t.podcasts.speakerCountMin || 'At least one speaker is required') .min(1, t.podcasts.speakerCountMin || 'At least one speaker is required')
@ -60,7 +53,6 @@ interface SpeakerProfileFormDialogProps {
mode: 'create' | 'edit' mode: 'create' | 'edit'
open: boolean open: boolean
onOpenChange: (open: boolean) => void onOpenChange: (open: boolean) => void
modelOptions: Record<string, string[]>
initialData?: SpeakerProfile initialData?: SpeakerProfile
} }
@ -69,51 +61,45 @@ const EMPTY_SPEAKER = {
voice_id: '', voice_id: '',
backstory: '', backstory: '',
personality: '', personality: '',
voice_model: null as string | null,
} }
export function SpeakerProfileFormDialog({ export function SpeakerProfileFormDialog({
mode, mode,
open, open,
onOpenChange, onOpenChange,
modelOptions,
initialData, initialData,
}: SpeakerProfileFormDialogProps) { }: SpeakerProfileFormDialogProps) {
const { t } = useTranslation() const { t } = useTranslation()
const createProfile = useCreateSpeakerProfile() const createProfile = useCreateSpeakerProfile()
const updateProfile = useUpdateSpeakerProfile() const updateProfile = useUpdateSpeakerProfile()
const providers = useMemo(() => Object.keys(modelOptions), [modelOptions])
const getDefaults = useCallback((): SpeakerProfileFormValues => { const getDefaults = useCallback((): SpeakerProfileFormValues => {
const firstProvider = providers[0] ?? ''
const firstModel = firstProvider ? modelOptions[firstProvider]?.[0] ?? '' : ''
if (initialData) { if (initialData) {
return { return {
name: initialData.name, name: initialData.name,
description: initialData.description ?? '', description: initialData.description ?? '',
tts_provider: initialData.tts_provider, voice_model: initialData.voice_model ?? '',
tts_model: initialData.tts_model, speakers: initialData.speakers?.map((speaker) => ({
speakers: initialData.speakers?.map((speaker) => ({ ...speaker })) ?? [{ ...EMPTY_SPEAKER }], ...speaker,
voice_model: speaker.voice_model ?? null,
})) ?? [{ ...EMPTY_SPEAKER }],
} }
} }
return { return {
name: '', name: '',
description: '', description: '',
tts_provider: firstProvider, voice_model: '',
tts_model: firstModel,
speakers: [{ ...EMPTY_SPEAKER }], speakers: [{ ...EMPTY_SPEAKER }],
} }
}, [initialData, modelOptions, providers]) }, [initialData])
const { const {
control, control,
register, register,
handleSubmit, handleSubmit,
reset, reset,
setValue,
watch,
formState: { errors }, formState: { errors },
} = useForm<SpeakerProfileFormValues>({ } = useForm<SpeakerProfileFormValues>({
resolver: zodResolver(speakerProfileSchema(t)), resolver: zodResolver(speakerProfileSchema(t)),
@ -129,13 +115,6 @@ export function SpeakerProfileFormDialog({
name: 'speakers', name: 'speakers',
}) })
const provider = watch('tts_provider')
const currentModel = watch('tts_model')
const availableModels = useMemo(
() => modelOptions[provider] ?? [],
[modelOptions, provider]
)
const speakersArrayError = ( const speakersArrayError = (
errors.speakers as FieldErrorsImpl<{ root?: { message?: string } }> | undefined errors.speakers as FieldErrorsImpl<{ root?: { message?: string } }> | undefined
)?.root?.message )?.root?.message
@ -147,24 +126,14 @@ export function SpeakerProfileFormDialog({
reset(getDefaults()) reset(getDefaults())
}, [open, reset, getDefaults]) }, [open, reset, getDefaults])
useEffect(() => {
if (!provider) {
return
}
const models = modelOptions[provider] ?? []
if (models.length === 0) {
setValue('tts_model', '')
return
}
if (!models.includes(currentModel)) {
setValue('tts_model', models[0])
}
}, [provider, currentModel, modelOptions, setValue])
const onSubmit = async (values: SpeakerProfileFormValues) => { const onSubmit = async (values: SpeakerProfileFormValues) => {
const payload = { const payload = {
...values, ...values,
description: values.description ?? '', description: values.description ?? '',
speakers: values.speakers.map((s) => ({
...s,
voice_model: s.voice_model || null,
})),
} }
if (mode === 'create') { if (mode === 'create') {
@ -180,7 +149,7 @@ export function SpeakerProfileFormDialog({
} }
const isSubmitting = createProfile.isPending || updateProfile.isPending const isSubmitting = createProfile.isPending || updateProfile.isPending
const disableSubmit = isSubmitting || providers.length === 0 const disableSubmit = isSubmitting
const isEdit = mode === 'edit' const isEdit = mode === 'edit'
return ( return (
@ -195,15 +164,6 @@ export function SpeakerProfileFormDialog({
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
{providers.length === 0 ? (
<Alert className="bg-amber-50 text-amber-900 border-amber-200">
<AlertTitle>{t.podcasts.noTtsModelsAvailable}</AlertTitle>
<AlertDescription>
{t.podcasts.noTtsModelsDesc}
</AlertDescription>
</Alert>
) : null}
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6 pt-2"> <form onSubmit={handleSubmit(onSubmit)} className="space-y-6 pt-2">
<div className="grid gap-4 md:grid-cols-2"> <div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2"> <div className="space-y-2">
@ -214,56 +174,6 @@ export function SpeakerProfileFormDialog({
) : null} ) : null}
</div> </div>
<div className="space-y-2">
<Label htmlFor="tts_provider">{t.models.provider} *</Label>
<Controller
control={control}
name="tts_provider"
render={({ field }) => (
<Select value={field.value} onValueChange={field.onChange}>
<SelectTrigger id="tts_provider">
<SelectValue placeholder={t.models.selectProviderPlaceholder} />
</SelectTrigger>
<SelectContent title={t.models.provider}>
{providers.map((option) => (
<SelectItem key={option} value={option}>
<span className="capitalize">{option}</span>
</SelectItem>
))}
</SelectContent>
</Select>
)}
/>
{errors.tts_provider ? (
<p className="text-xs text-red-600">{errors.tts_provider.message}</p>
) : null}
</div>
<div className="space-y-2">
<Label htmlFor="tts_model">{t.common.model} *</Label>
<Controller
control={control}
name="tts_model"
render={({ field }) => (
<Select value={field.value} onValueChange={field.onChange}>
<SelectTrigger id="tts_model">
<SelectValue placeholder={t.models.selectModelPlaceholder} />
</SelectTrigger>
<SelectContent title={t.common.model}>
{availableModels.map((model) => (
<SelectItem key={model} value={model}>
{model}
</SelectItem>
))}
</SelectContent>
</Select>
)}
/>
{errors.tts_model ? (
<p className="text-xs text-red-600">{errors.tts_model.message}</p>
) : null}
</div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="description">{t.common.description}</Label> <Label htmlFor="description">{t.common.description}</Label>
<Textarea <Textarea
@ -275,6 +185,35 @@ export function SpeakerProfileFormDialog({
</div> </div>
</div> </div>
<div className="space-y-4">
<div>
<h3 className="text-sm font-semibold uppercase tracking-wide text-muted-foreground">
{t.podcasts.voiceModel}
</h3>
<Separator className="mt-2" />
</div>
<Controller
control={control}
name="voice_model"
render={({ field }) => (
<div>
<ModelSelector
label={`${t.podcasts.voiceModel} *`}
modelType="text_to_speech"
value={field.value}
onChange={field.onChange}
placeholder={t.podcasts.selectVoiceModel}
/>
{errors.voice_model ? (
<p className="text-xs text-red-600 mt-1">
{errors.voice_model.message}
</p>
) : null}
</div>
)}
/>
</div>
<div className="space-y-4"> <div className="space-y-4">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
@ -374,6 +313,21 @@ export function SpeakerProfileFormDialog({
</p> </p>
) : null} ) : null}
</div> </div>
<Controller
control={control}
name={`speakers.${index}.voice_model` as const}
render={({ field: vmField }) => (
<div>
<ModelSelector
label={t.podcasts.perSpeakerTtsOverride}
modelType="text_to_speech"
value={vmField.value ?? ''}
onChange={(v) => vmField.onChange(v || null)}
placeholder={t.podcasts.useProfileDefault}
/>
</div>
)}
/>
</div> </div>
))} ))}

View file

@ -4,6 +4,7 @@ import {
PodcastEpisode, PodcastEpisode,
EpisodeProfile, EpisodeProfile,
SpeakerProfile, SpeakerProfile,
Language,
PodcastGenerationRequest, PodcastGenerationRequest,
PodcastGenerationResponse, PodcastGenerationResponse,
} from '@/lib/types/podcasts' } from '@/lib/types/podcasts'
@ -117,4 +118,9 @@ export const podcastsApi = {
) )
return response.data return response.data
}, },
listLanguages: async () => {
const response = await apiClient.get<Language[]>('/languages')
return response.data
},
} }

View file

@ -31,4 +31,5 @@ export const QUERY_KEYS = {
podcastEpisode: (episodeId: string) => ['podcasts', 'episodes', episodeId] as const, podcastEpisode: (episodeId: string) => ['podcasts', 'episodes', episodeId] as const,
episodeProfiles: ['podcasts', 'episode-profiles'] as const, episodeProfiles: ['podcasts', 'episode-profiles'] as const,
speakerProfiles: ['podcasts', 'speaker-profiles'] as const, speakerProfiles: ['podcasts', 'speaker-profiles'] as const,
languages: ['languages'] as const,
} }

View file

@ -16,6 +16,14 @@ import {
speakerUsageMap, speakerUsageMap,
} from '@/lib/types/podcasts' } from '@/lib/types/podcasts'
export function useLanguages() {
return useQuery({
queryKey: QUERY_KEYS.languages,
queryFn: podcastsApi.listLanguages,
staleTime: Infinity,
})
}
interface EpisodeStatusCounts { interface EpisodeStatusCounts {
total: number total: number
running: number running: number

View file

@ -534,10 +534,10 @@ export const enUS = {
loadingProfiles: "Loading episode profiles...", loadingProfiles: "Loading episode profiles...",
noProfilesFound: "No episode profiles found. Create an episode profile before generating a podcast.", noProfilesFound: "No episode profiles found. Create an episode profile before generating a podcast.",
listTitle: "Podcasts", listTitle: "Podcasts",
listDesc: "Keep track of generated episodes and manage reusable templates.", listDesc: "Keep track of generated episodes and manage reusable profiles.",
chooseAView: "Choose a view", chooseAView: "Choose a view",
episodesTab: "Episodes", episodesTab: "Episodes",
templatesTab: "Templates", templatesTab: "Profiles",
overviewTitle: "Episodes overview", overviewTitle: "Episodes overview",
overviewDesc: "Monitor podcast generation jobs and review the final artefacts.", overviewDesc: "Monitor podcast generation jobs and review the final artefacts.",
generateBtn: "Generate Podcast", generateBtn: "Generate Podcast",
@ -558,10 +558,10 @@ export const enUS = {
statusCompletedDesc: "Ready to review, download, or publish.", statusCompletedDesc: "Ready to review, download, or publish.",
statusFailedTitle: "Failed Episodes", statusFailedTitle: "Failed Episodes",
statusFailedDesc: "Episodes that encountered issues during generation.", statusFailedDesc: "Episodes that encountered issues during generation.",
templatesWorkspaceTitle: "Templates workspace", templatesWorkspaceTitle: "Profiles workspace",
templatesWorkspaceDesc: "Build reusable episode and speaker configurations for fast podcast production.", templatesWorkspaceDesc: "Build reusable episode and speaker configurations for fast podcast production.",
howTemplatesPowerTitle: "How templates power podcast generation", howTemplatesPowerTitle: "How profiles power podcast generation",
howTemplatesPowerDesc: "Templates split the podcast workflow into two reusable building blocks. Mix and match them whenever you generate a new episode.", howTemplatesPowerDesc: "Profiles split the podcast workflow into two reusable building blocks. Mix and match them whenever you generate a new episode.",
episodeProfilesSetFormat: "Episode profiles set the format", episodeProfilesSetFormat: "Episode profiles set the format",
episodeProfilesList1: "Outline the number of segments and how the story flows", episodeProfilesList1: "Outline the number of segments and how the story flows",
episodeProfilesList2: "Pick the language models used for briefing, outlining, and script writing", episodeProfilesList2: "Pick the language models used for briefing, outlining, and script writing",
@ -575,13 +575,13 @@ export const enUS = {
workflowStep2: "Build episode profiles that reference those speakers by name", workflowStep2: "Build episode profiles that reference those speakers by name",
workflowStep3: "Generate podcasts by selecting the episode profile that fits the story", workflowStep3: "Generate podcasts by selecting the episode profile that fits the story",
workflowHint: "Episode profiles reference speaker profiles by name, so starting with speakers avoids missing voice assignments later.", workflowHint: "Episode profiles reference speaker profiles by name, so starting with speakers avoids missing voice assignments later.",
failedToLoadTemplates: "Failed to load templates data", failedToLoadTemplates: "Failed to load profiles data",
failedToLoadTemplatesDesc: "Ensure the API is running and try again. Some sections may be incomplete.", failedToLoadTemplatesDesc: "Ensure the API is running and try again. Some sections may be incomplete.",
loadingTemplates: "Loading templates…", loadingTemplates: "Loading profiles…",
speakerProfilesTitle: "Speaker profiles", speakerProfilesTitle: "Speaker profiles",
speakerProfilesDesc: "Configure voices and personalities for generated episodes.", speakerProfilesDesc: "Configure voices and personalities for generated episodes.",
createSpeaker: "Create speaker", createSpeaker: "Create speaker",
noSpeakerProfiles: "No speaker profiles yet. Create one to make episode templates available.", noSpeakerProfiles: "No speaker profiles yet. Create one to make episode profiles available.",
noDescription: "No description provided.", noDescription: "No description provided.",
usedByCount_one: "Used by 1 episode", usedByCount_one: "Used by 1 episode",
usedByCount_other: "Used by {count} episodes", usedByCount_other: "Used by {count} episodes",
@ -659,12 +659,10 @@ export const enUS = {
embedded: "Embedded", embedded: "Embedded",
notEmbedded: "Not embedded", notEmbedded: "Not embedded",
noSpeakerProfilesAvailable: "No speaker profiles available", noSpeakerProfilesAvailable: "No speaker profiles available",
noLanguageModelsAvailable: "No language models available",
editEpisodeProfile: "Edit Episode Profile", editEpisodeProfile: "Edit Episode Profile",
createEpisodeProfile: "Create Episode Profile", createEpisodeProfile: "Create Episode Profile",
episodeProfileFormDesc: "Define how episodes should be generated and which speaker configuration they use by default.", episodeProfileFormDesc: "Define how episodes should be generated and which speaker configuration they use by default.",
noSpeakerProfilesDesc: "Create a speaker profile before configuring an episode profile.", noSpeakerProfilesDesc: "Create a speaker profile before configuring an episode profile.",
noLanguageModelsDesc: "Add language models in the Models section to configure outline and transcript generation.",
profileName: "Profile name", profileName: "Profile name",
profileNamePlaceholder: "e.g., Tech discussion", profileNamePlaceholder: "e.g., Tech discussion",
descriptionPlaceholder: "Short summary of when to use this profile", descriptionPlaceholder: "Short summary of when to use this profile",
@ -676,17 +674,13 @@ export const enUS = {
editSpeakerProfile: "Edit Speaker Profile", editSpeakerProfile: "Edit Speaker Profile",
createSpeakerProfile: "Create Speaker Profile", createSpeakerProfile: "Create Speaker Profile",
speakerProfileFormDesc: "Configure text-to-speech settings and define up to four speakers.", speakerProfileFormDesc: "Configure text-to-speech settings and define up to four speakers.",
noTtsModelsAvailable: "No text-to-speech models available",
noTtsModelsDesc: "Add TTS models in the Models section before creating a speaker profile.",
speakers: "Speakers", speakers: "Speakers",
speakersDesc: "Configure between one and four voices for this profile.", speakersDesc: "Configure between one and four voices for this profile.",
addSpeaker: "Add speaker", addSpeaker: "Add speaker",
speakerNumber: "Speaker {number}", speakerNumber: "Speaker {number}",
backstoryPlaceholder: "Short biography or context for the speaker", backstoryPlaceholder: "Short biography or context for the speaker",
personalityPlaceholder: "Describe style and tone", personalityPlaceholder: "Describe style and tone",
outlineProviderRequired: "Outline provider is required",
outlineModelRequired: "Outline model is required", outlineModelRequired: "Outline model is required",
transcriptProviderRequired: "Transcript provider is required",
transcriptModelRequired: "Transcript model is required", transcriptModelRequired: "Transcript model is required",
defaultBriefingRequired: "Default briefing is required", defaultBriefingRequired: "Default briefing is required",
segmentsInteger: "Must be an integer", segmentsInteger: "Must be an integer",
@ -705,6 +699,20 @@ export const enUS = {
retryStartedDesc: "A new podcast generation job has been submitted.", retryStartedDesc: "A new podcast generation job has been submitted.",
failedToRetry: "Failed to retry episode", failedToRetry: "Failed to retry episode",
errorDetails: "Error details", errorDetails: "Error details",
language: "Language",
languagePlaceholder: "Select a language (optional)",
podcastLanguage: "Podcast language",
selectOutlineModel: "Select outline model",
selectTranscriptModel: "Select transcript model",
voiceModel: "Voice model",
voiceModelRequired: "Voice model is required",
selectVoiceModel: "Select voice model",
perSpeakerTtsOverride: "Per-speaker TTS override (optional)",
useProfileDefault: "Use profile default",
setupRequired: "Setup required",
setupRequiredDesc:
"Some profiles don't have models configured yet. Edit them to select models before generating podcasts.",
notConfigured: "Not configured",
}, },
settings: { settings: {
contentProcessing: "Content Processing", contentProcessing: "Content Processing",
@ -818,7 +826,6 @@ export const enUS = {
embedding: "Embedding Models", embedding: "Embedding Models",
tts: "Text to Speech (TTS)", tts: "Text to Speech (TTS)",
stt: "Speech to Text (STT)", stt: "Speech to Text (STT)",
provider: "Provider",
apiKey: "API Key", apiKey: "API Key",
deleteSuccess: "Model deleted successfully", deleteSuccess: "Model deleted successfully",
saveSuccess: "Model saved successfully", saveSuccess: "Model saved successfully",
@ -847,9 +854,6 @@ export const enUS = {
ttsModelDesc: "Used for podcast generation", ttsModelDesc: "Used for podcast generation",
sttModelLabel: "Speech-to-Text Model", sttModelLabel: "Speech-to-Text Model",
sttModelDesc: "Used for audio transcription", sttModelDesc: "Used for audio transcription",
selectProviderPlaceholder: "Select a provider",
providerRequired: "Provider is required",
modelRequired: "Model is required",
embeddingChangeTitle: "Embedding Model Change", embeddingChangeTitle: "Embedding Model Change",
embeddingChangeConfirm: "You are about to change your embedding model from {from} to {to}.", embeddingChangeConfirm: "You are about to change your embedding model from {from} to {to}.",
rebuildRequired: "Important: Rebuild Required", rebuildRequired: "Important: Rebuild Required",

View file

@ -534,10 +534,10 @@ export const frFR = {
loadingProfiles: "Chargement des profils d'épisode...", loadingProfiles: "Chargement des profils d'épisode...",
noProfilesFound: "Aucun profil d'épisode trouvé. Créez un profil d'épisode avant de générer un podcast.", noProfilesFound: "Aucun profil d'épisode trouvé. Créez un profil d'épisode avant de générer un podcast.",
listTitle: "Podcasts", listTitle: "Podcasts",
listDesc: "Suivez les épisodes générés et gérez les modèles réutilisables.", listDesc: "Suivez les épisodes générés et gérez les profils réutilisables.",
chooseAView: "Choisir une vue", chooseAView: "Choisir une vue",
episodesTab: "Épisodes", episodesTab: "Épisodes",
templatesTab: "Modèles", templatesTab: "Profils",
overviewTitle: "Aperçu des épisodes", overviewTitle: "Aperçu des épisodes",
overviewDesc: "Surveillez les tâches de génération de podcast et consultez les artefacts finaux.", overviewDesc: "Surveillez les tâches de génération de podcast et consultez les artefacts finaux.",
generateBtn: "Générer un podcast", generateBtn: "Générer un podcast",
@ -558,10 +558,10 @@ export const frFR = {
statusCompletedDesc: "Prêts à être consultés, téléchargés ou publiés.", statusCompletedDesc: "Prêts à être consultés, téléchargés ou publiés.",
statusFailedTitle: "Épisodes échoués", statusFailedTitle: "Épisodes échoués",
statusFailedDesc: "Épisodes ayant rencontré des problèmes lors de la génération.", statusFailedDesc: "Épisodes ayant rencontré des problèmes lors de la génération.",
templatesWorkspaceTitle: "Espace de travail des modèles", templatesWorkspaceTitle: "Espace de travail des profils",
templatesWorkspaceDesc: "Créez des configurations d'épisodes et d'intervenants réutilisables pour une production rapide.", templatesWorkspaceDesc: "Créez des configurations d'épisodes et d'intervenants réutilisables pour une production rapide.",
howTemplatesPowerTitle: "Comment les modèles propulsent la génération", howTemplatesPowerTitle: "Comment les profils propulsent la génération",
howTemplatesPowerDesc: "Les modèles divisent le flux de travail en deux blocs réutilisables. Mélangez-les à chaque génération d'épisode.", howTemplatesPowerDesc: "Les profils divisent le flux de travail en deux blocs réutilisables. Mélangez-les à chaque génération d'épisode.",
episodeProfilesSetFormat: "Les profils d'épisode définissent le format", episodeProfilesSetFormat: "Les profils d'épisode définissent le format",
episodeProfilesList1: "Définissez le nombre de segments et le déroulement de l'histoire", episodeProfilesList1: "Définissez le nombre de segments et le déroulement de l'histoire",
episodeProfilesList2: "Choisissez les modèles de langue pour le briefing, le plan et l'écriture du script", episodeProfilesList2: "Choisissez les modèles de langue pour le briefing, le plan et l'écriture du script",
@ -575,13 +575,13 @@ export const frFR = {
workflowStep2: "Créez des profils d'épisodes qui référencent ces intervenants par leur nom", workflowStep2: "Créez des profils d'épisodes qui référencent ces intervenants par leur nom",
workflowStep3: "Générez des podcasts en sélectionnant le profil d'épisode adapté", workflowStep3: "Générez des podcasts en sélectionnant le profil d'épisode adapté",
workflowHint: "Les profils d'épisode référencent les intervenants par nom ; commencer par les voix évite les oublis d'attribution plus tard.", workflowHint: "Les profils d'épisode référencent les intervenants par nom ; commencer par les voix évite les oublis d'attribution plus tard.",
failedToLoadTemplates: "Échec du chargement des modèles", failedToLoadTemplates: "Échec du chargement des profils",
failedToLoadTemplatesDesc: "Vérifiez que l'API fonctionne et réessayez. Certaines sections peuvent être incomplètes.", failedToLoadTemplatesDesc: "Vérifiez que l'API fonctionne et réessayez. Certaines sections peuvent être incomplètes.",
loadingTemplates: "Chargement des modèles…", loadingTemplates: "Chargement des profils…",
speakerProfilesTitle: "Profils d'intervenants", speakerProfilesTitle: "Profils d'intervenants",
speakerProfilesDesc: "Configurez les voix et personnalités pour les épisodes générés.", speakerProfilesDesc: "Configurez les voix et personnalités pour les épisodes générés.",
createSpeaker: "Créer un intervenant", createSpeaker: "Créer un intervenant",
noSpeakerProfiles: "Aucun profil d'intervenant. Créez-en un pour activer les modèles d'épisodes.", noSpeakerProfiles: "Aucun profil d'intervenant. Créez-en un pour activer les profils d'épisodes.",
noDescription: "Aucune description fournie.", noDescription: "Aucune description fournie.",
usedByCount_one: "Utilisé par 1 épisode", usedByCount_one: "Utilisé par 1 épisode",
usedByCount_other: "Utilisé par {count} épisodes", usedByCount_other: "Utilisé par {count} épisodes",
@ -659,12 +659,10 @@ export const frFR = {
embedded: "Indexé", embedded: "Indexé",
notEmbedded: "Non indexé", notEmbedded: "Non indexé",
noSpeakerProfilesAvailable: "Aucun profil d'intervenant disponible", noSpeakerProfilesAvailable: "Aucun profil d'intervenant disponible",
noLanguageModelsAvailable: "Aucun modèle de langue disponible",
editEpisodeProfile: "Modifier le profil d'épisode", editEpisodeProfile: "Modifier le profil d'épisode",
createEpisodeProfile: "Créer un profil d'épisode", createEpisodeProfile: "Créer un profil d'épisode",
episodeProfileFormDesc: "Définissez comment les épisodes doivent être générés et quelle configuration d'intervenants ils utilisent par défaut.", episodeProfileFormDesc: "Définissez comment les épisodes doivent être générés et quelle configuration d'intervenants ils utilisent par défaut.",
noSpeakerProfilesDesc: "Créez un profil d'intervenant avant de configurer un profil d'épisode.", noSpeakerProfilesDesc: "Créez un profil d'intervenant avant de configurer un profil d'épisode.",
noLanguageModelsDesc: "Ajoutez des modèles de langue dans la section Modèles pour configurer la génération du plan et de la transcription.",
profileName: "Nom du profil", profileName: "Nom du profil",
profileNamePlaceholder: "ex: Discussion tech", profileNamePlaceholder: "ex: Discussion tech",
descriptionPlaceholder: "Bref résumé de l'usage de ce profil", descriptionPlaceholder: "Bref résumé de l'usage de ce profil",
@ -676,17 +674,13 @@ export const frFR = {
editSpeakerProfile: "Modifier le profil de l'intervenant", editSpeakerProfile: "Modifier le profil de l'intervenant",
createSpeakerProfile: "Créer un profil d'intervenant", createSpeakerProfile: "Créer un profil d'intervenant",
speakerProfileFormDesc: "Configurez les paramètres de synthèse vocale et définissez jusqu'à quatre intervenants.", speakerProfileFormDesc: "Configurez les paramètres de synthèse vocale et définissez jusqu'à quatre intervenants.",
noTtsModelsAvailable: "Aucun modèle TTS disponible",
noTtsModelsDesc: "Ajoutez des modèles TTS dans la section Modèles avant de créer un profil d'intervenant.",
speakers: "Intervenants", speakers: "Intervenants",
speakersDesc: "Configurez entre un et quatre intervenants pour ce profil.", speakersDesc: "Configurez entre un et quatre intervenants pour ce profil.",
addSpeaker: "Ajouter un intervenant", addSpeaker: "Ajouter un intervenant",
speakerNumber: "Intervenant {number}", speakerNumber: "Intervenant {number}",
backstoryPlaceholder: "Courte biographie ou contexte de l'intervenant", backstoryPlaceholder: "Courte biographie ou contexte de l'intervenant",
personalityPlaceholder: "Décrivez le style et le ton", personalityPlaceholder: "Décrivez le style et le ton",
outlineProviderRequired: "Le fournisseur du plan est requis",
outlineModelRequired: "Le modèle du plan est requis", outlineModelRequired: "Le modèle du plan est requis",
transcriptProviderRequired: "Le fournisseur de transcription est requis",
transcriptModelRequired: "Le modèle de transcription est requis", transcriptModelRequired: "Le modèle de transcription est requis",
defaultBriefingRequired: "Le briefing par défaut est requis", defaultBriefingRequired: "Le briefing par défaut est requis",
segmentsInteger: "Doit être un nombre entier", segmentsInteger: "Doit être un nombre entier",
@ -705,6 +699,19 @@ export const frFR = {
retryStartedDesc: "Un nouveau travail de génération de podcast a été soumis.", retryStartedDesc: "Un nouveau travail de génération de podcast a été soumis.",
failedToRetry: "Échec de la nouvelle tentative", failedToRetry: "Échec de la nouvelle tentative",
errorDetails: "Détails de l'erreur", errorDetails: "Détails de l'erreur",
language: "Langue",
languagePlaceholder: "Sélectionnez une langue (optionnel)",
podcastLanguage: "Langue du podcast",
selectOutlineModel: "Sélectionnez le modèle de plan",
selectTranscriptModel: "Sélectionnez le modèle de transcription",
voiceModel: "Modèle vocal",
voiceModelRequired: "Le modèle vocal est requis",
selectVoiceModel: "Sélectionnez le modèle vocal",
perSpeakerTtsOverride: "Remplacement TTS par intervenant (optionnel)",
useProfileDefault: "Utiliser le profil par défaut",
setupRequired: "Configuration requise",
setupRequiredDesc: "Certains profils n'ont pas encore de modèles configurés. Modifiez-les pour sélectionner des modèles avant de générer des podcasts.",
notConfigured: "Non configuré",
}, },
settings: { settings: {
contentProcessing: "Traitement du contenu", contentProcessing: "Traitement du contenu",
@ -818,7 +825,6 @@ export const frFR = {
embedding: "Modèles d'Embedding", embedding: "Modèles d'Embedding",
tts: "Synthèse vocale (TTS)", tts: "Synthèse vocale (TTS)",
stt: "Transcription vocale (STT)", stt: "Transcription vocale (STT)",
provider: "Fournisseur",
apiKey: "Clé API", apiKey: "Clé API",
deleteSuccess: "Modèle supprimé avec succès", deleteSuccess: "Modèle supprimé avec succès",
saveSuccess: "Modèle enregistré avec succès", saveSuccess: "Modèle enregistré avec succès",
@ -847,9 +853,6 @@ export const frFR = {
ttsModelDesc: "Utilisé pour la génération de podcasts", ttsModelDesc: "Utilisé pour la génération de podcasts",
sttModelLabel: "Modèle de Transcription Vocale (STT)", sttModelLabel: "Modèle de Transcription Vocale (STT)",
sttModelDesc: "Utilisé pour la transcription audio", sttModelDesc: "Utilisé pour la transcription audio",
selectProviderPlaceholder: "Sélectionnez un fournisseur",
providerRequired: "Le fournisseur est requis",
modelRequired: "Le modèle est requis",
embeddingChangeTitle: "Changement de modèle d'embedding", embeddingChangeTitle: "Changement de modèle d'embedding",
embeddingChangeConfirm: "Vous êtes sur le point de changer votre modèle d'embedding de {from} à {to}.", embeddingChangeConfirm: "Vous êtes sur le point de changer votre modèle d'embedding de {from} à {to}.",
rebuildRequired: "Important : Reconstruction requise", rebuildRequired: "Important : Reconstruction requise",

View file

@ -534,10 +534,10 @@ export const itIT = {
loadingProfiles: "Caricamento profili episodio...", loadingProfiles: "Caricamento profili episodio...",
noProfilesFound: "Nessun profilo episodio trovato. Crea un profilo episodio prima di generare un podcast.", noProfilesFound: "Nessun profilo episodio trovato. Crea un profilo episodio prima di generare un podcast.",
listTitle: "Podcast", listTitle: "Podcast",
listDesc: "Tieni traccia degli episodi generati e gestisci i modelli riutilizzabili.", listDesc: "Tieni traccia degli episodi generati e gestisci i profili riutilizzabili.",
chooseAView: "Scegli una vista", chooseAView: "Scegli una vista",
episodesTab: "Episodi", episodesTab: "Episodi",
templatesTab: "Modelli", templatesTab: "Profili",
overviewTitle: "Panoramica episodi", overviewTitle: "Panoramica episodi",
overviewDesc: "Monitora i lavori di generazione podcast e rivedi gli artefatti finali.", overviewDesc: "Monitora i lavori di generazione podcast e rivedi gli artefatti finali.",
generateBtn: "Genera Podcast", generateBtn: "Genera Podcast",
@ -558,10 +558,10 @@ export const itIT = {
statusCompletedDesc: "Pronti per revisione, download o pubblicazione.", statusCompletedDesc: "Pronti per revisione, download o pubblicazione.",
statusFailedTitle: "Episodi falliti", statusFailedTitle: "Episodi falliti",
statusFailedDesc: "Episodi che hanno riscontrato problemi durante la generazione.", statusFailedDesc: "Episodi che hanno riscontrato problemi durante la generazione.",
templatesWorkspaceTitle: "Area di lavoro modelli", templatesWorkspaceTitle: "Area di lavoro profili",
templatesWorkspaceDesc: "Costruisci configurazioni riutilizzabili per episodi e speaker per una produzione podcast rapida.", templatesWorkspaceDesc: "Costruisci configurazioni riutilizzabili per episodi e speaker per una produzione podcast rapida.",
howTemplatesPowerTitle: "Come i modelli potenziano la generazione podcast", howTemplatesPowerTitle: "Come i profili potenziano la generazione podcast",
howTemplatesPowerDesc: "I modelli dividono il flusso di lavoro podcast in due blocchi riutilizzabili. Combinali quando generi un nuovo episodio.", howTemplatesPowerDesc: "I profili dividono il flusso di lavoro podcast in due blocchi riutilizzabili. Combinali quando generi un nuovo episodio.",
episodeProfilesSetFormat: "I profili episodio impostano il formato", episodeProfilesSetFormat: "I profili episodio impostano il formato",
episodeProfilesList1: "Delinea il numero di segmenti e come scorre la storia", episodeProfilesList1: "Delinea il numero di segmenti e come scorre la storia",
episodeProfilesList2: "Scegli i modelli linguistici usati per briefing, outline e scrittura script", episodeProfilesList2: "Scegli i modelli linguistici usati per briefing, outline e scrittura script",
@ -575,13 +575,13 @@ export const itIT = {
workflowStep2: "Costruisci profili episodio che riferiscono quegli speaker per nome", workflowStep2: "Costruisci profili episodio che riferiscono quegli speaker per nome",
workflowStep3: "Genera podcast selezionando il profilo episodio adatto alla storia", workflowStep3: "Genera podcast selezionando il profilo episodio adatto alla storia",
workflowHint: "I profili episodio riferiscono i profili speaker per nome, quindi iniziare dagli speaker evita assegnazioni vocali mancanti.", workflowHint: "I profili episodio riferiscono i profili speaker per nome, quindi iniziare dagli speaker evita assegnazioni vocali mancanti.",
failedToLoadTemplates: "Impossibile caricare i dati dei modelli", failedToLoadTemplates: "Impossibile caricare i dati dei profili",
failedToLoadTemplatesDesc: "Assicurati che l'API sia in esecuzione e riprova. Alcune sezioni potrebbero essere incomplete.", failedToLoadTemplatesDesc: "Assicurati che l'API sia in esecuzione e riprova. Alcune sezioni potrebbero essere incomplete.",
loadingTemplates: "Caricamento modelli…", loadingTemplates: "Caricamento profili…",
speakerProfilesTitle: "Profili speaker", speakerProfilesTitle: "Profili speaker",
speakerProfilesDesc: "Configura voci e personalità per gli episodi generati.", speakerProfilesDesc: "Configura voci e personalità per gli episodi generati.",
createSpeaker: "Crea speaker", createSpeaker: "Crea speaker",
noSpeakerProfiles: "Ancora nessun profilo speaker. Creane uno per rendere disponibili i modelli episodio.", noSpeakerProfiles: "Ancora nessun profilo speaker. Creane uno per rendere disponibili i profili episodio.",
noDescription: "Nessuna descrizione fornita.", noDescription: "Nessuna descrizione fornita.",
usedByCount_one: "Usato da 1 episodio", usedByCount_one: "Usato da 1 episodio",
usedByCount_other: "Usato da {count} episodi", usedByCount_other: "Usato da {count} episodi",
@ -659,12 +659,10 @@ export const itIT = {
embedded: "Indicizzato", embedded: "Indicizzato",
notEmbedded: "Non indicizzato", notEmbedded: "Non indicizzato",
noSpeakerProfilesAvailable: "Nessun profilo speaker disponibile", noSpeakerProfilesAvailable: "Nessun profilo speaker disponibile",
noLanguageModelsAvailable: "Nessun modello linguistico disponibile",
editEpisodeProfile: "Modifica profilo episodio", editEpisodeProfile: "Modifica profilo episodio",
createEpisodeProfile: "Crea profilo episodio", createEpisodeProfile: "Crea profilo episodio",
episodeProfileFormDesc: "Definisci come devono essere generati gli episodi e quale configurazione speaker usare di default.", episodeProfileFormDesc: "Definisci come devono essere generati gli episodi e quale configurazione speaker usare di default.",
noSpeakerProfilesDesc: "Crea un profilo speaker prima di configurare un profilo episodio.", noSpeakerProfilesDesc: "Crea un profilo speaker prima di configurare un profilo episodio.",
noLanguageModelsDesc: "Aggiungi modelli linguistici nella sezione modelli per configurare la generazione di outline e trascrizione.",
profileName: "Nome profilo", profileName: "Nome profilo",
profileNamePlaceholder: "es., Discussione tech", profileNamePlaceholder: "es., Discussione tech",
descriptionPlaceholder: "Breve riepilogo di quando usare questo profilo", descriptionPlaceholder: "Breve riepilogo di quando usare questo profilo",
@ -676,17 +674,13 @@ export const itIT = {
editSpeakerProfile: "Modifica profilo speaker", editSpeakerProfile: "Modifica profilo speaker",
createSpeakerProfile: "Crea profilo speaker", createSpeakerProfile: "Crea profilo speaker",
speakerProfileFormDesc: "Configura le impostazioni text-to-speech e definisci fino a quattro speaker.", speakerProfileFormDesc: "Configura le impostazioni text-to-speech e definisci fino a quattro speaker.",
noTtsModelsAvailable: "Nessun modello text-to-speech disponibile",
noTtsModelsDesc: "Aggiungi modelli TTS nella sezione modelli prima di creare un profilo speaker.",
speakers: "Speaker", speakers: "Speaker",
speakersDesc: "Configura da una a quattro voci per questo profilo.", speakersDesc: "Configura da una a quattro voci per questo profilo.",
addSpeaker: "Aggiungi speaker", addSpeaker: "Aggiungi speaker",
speakerNumber: "Speaker {number}", speakerNumber: "Speaker {number}",
backstoryPlaceholder: "Breve biografia o contesto per lo speaker", backstoryPlaceholder: "Breve biografia o contesto per lo speaker",
personalityPlaceholder: "Descrivi stile e tono", personalityPlaceholder: "Descrivi stile e tono",
outlineProviderRequired: "Il provider outline è obbligatorio",
outlineModelRequired: "Il modello outline è obbligatorio", outlineModelRequired: "Il modello outline è obbligatorio",
transcriptProviderRequired: "Il provider trascrizione è obbligatorio",
transcriptModelRequired: "Il modello trascrizione è obbligatorio", transcriptModelRequired: "Il modello trascrizione è obbligatorio",
defaultBriefingRequired: "Il briefing predefinito è obbligatorio", defaultBriefingRequired: "Il briefing predefinito è obbligatorio",
segmentsInteger: "Deve essere un numero intero", segmentsInteger: "Deve essere un numero intero",
@ -705,6 +699,19 @@ export const itIT = {
retryStartedDesc: "Un nuovo lavoro di generazione podcast è stato inviato.", retryStartedDesc: "Un nuovo lavoro di generazione podcast è stato inviato.",
failedToRetry: "Impossibile riprovare", failedToRetry: "Impossibile riprovare",
errorDetails: "Dettagli errore", errorDetails: "Dettagli errore",
language: "Lingua",
languagePlaceholder: "Seleziona una lingua (opzionale)",
podcastLanguage: "Lingua del podcast",
selectOutlineModel: "Seleziona modello outline",
selectTranscriptModel: "Seleziona modello trascrizione",
voiceModel: "Modello vocale",
voiceModelRequired: "Il modello vocale è obbligatorio",
selectVoiceModel: "Seleziona modello vocale",
perSpeakerTtsOverride: "Override TTS per speaker (opzionale)",
useProfileDefault: "Usa predefinito del profilo",
setupRequired: "Configurazione necessaria",
setupRequiredDesc: "Alcuni profili non hanno ancora modelli configurati. Modificali per selezionare i modelli prima di generare podcast.",
notConfigured: "Non configurato",
}, },
settings: { settings: {
contentProcessing: "Elaborazione contenuti", contentProcessing: "Elaborazione contenuti",
@ -818,7 +825,6 @@ export const itIT = {
embedding: "Modelli di embedding", embedding: "Modelli di embedding",
tts: "Text to Speech (TTS)", tts: "Text to Speech (TTS)",
stt: "Speech to Text (STT)", stt: "Speech to Text (STT)",
provider: "Provider",
apiKey: "Chiave API", apiKey: "Chiave API",
deleteSuccess: "Modello eliminato con successo", deleteSuccess: "Modello eliminato con successo",
saveSuccess: "Modello salvato con successo", saveSuccess: "Modello salvato con successo",
@ -847,9 +853,6 @@ export const itIT = {
ttsModelDesc: "Usato per la generazione podcast", ttsModelDesc: "Usato per la generazione podcast",
sttModelLabel: "Modello Speech-to-Text", sttModelLabel: "Modello Speech-to-Text",
sttModelDesc: "Usato per la trascrizione audio", sttModelDesc: "Usato per la trascrizione audio",
selectProviderPlaceholder: "Seleziona un provider",
providerRequired: "Il provider è obbligatorio",
modelRequired: "Il modello è obbligatorio",
embeddingChangeTitle: "Cambio modello di embedding", embeddingChangeTitle: "Cambio modello di embedding",
embeddingChangeConfirm: "Stai per cambiare il modello di embedding da {from} a {to}.", embeddingChangeConfirm: "Stai per cambiare il modello di embedding da {from} a {to}.",
rebuildRequired: "Importante: ricostruzione richiesta", rebuildRequired: "Importante: ricostruzione richiesta",

View file

@ -534,10 +534,10 @@ export const jaJP = {
loadingProfiles: "エピソードプロファイルを読み込み中...", loadingProfiles: "エピソードプロファイルを読み込み中...",
noProfilesFound: "エピソードプロファイルが見つかりません。ポッドキャストを生成する前にエピソードプロファイルを作成してください。", noProfilesFound: "エピソードプロファイルが見つかりません。ポッドキャストを生成する前にエピソードプロファイルを作成してください。",
listTitle: "ポッドキャスト", listTitle: "ポッドキャスト",
listDesc: "生成されたエピソードを追跡し、再利用可能なテンプレートを管理します。", listDesc: "生成されたエピソードを追跡し、再利用可能なプロファイルを管理します。",
chooseAView: "表示を選択", chooseAView: "表示を選択",
episodesTab: "エピソード", episodesTab: "エピソード",
templatesTab: "テンプレート", templatesTab: "プロファイル",
overviewTitle: "エピソード概要", overviewTitle: "エピソード概要",
overviewDesc: "ポッドキャスト生成ジョブを監視し、最終成果物を確認します。", overviewDesc: "ポッドキャスト生成ジョブを監視し、最終成果物を確認します。",
generateBtn: "ポッドキャストを生成", generateBtn: "ポッドキャストを生成",
@ -558,10 +558,10 @@ export const jaJP = {
statusCompletedDesc: "確認、ダウンロード、公開の準備が整っています。", statusCompletedDesc: "確認、ダウンロード、公開の準備が整っています。",
statusFailedTitle: "失敗したエピソード", statusFailedTitle: "失敗したエピソード",
statusFailedDesc: "生成中に問題が発生したエピソード。", statusFailedDesc: "生成中に問題が発生したエピソード。",
templatesWorkspaceTitle: "テンプレートワークスペース", templatesWorkspaceTitle: "プロファイルワークスペース",
templatesWorkspaceDesc: "高速なポッドキャスト制作のための再利用可能なエピソードおよびスピーカー設定を構築します。", templatesWorkspaceDesc: "高速なポッドキャスト制作のための再利用可能なエピソードおよびスピーカー設定を構築します。",
howTemplatesPowerTitle: "テンプレートがポッドキャスト生成を強化する仕組み", howTemplatesPowerTitle: "プロファイルがポッドキャスト生成を強化する仕組み",
howTemplatesPowerDesc: "テンプレートはポッドキャストワークフローを2つの再利用可能なビルディングブロックに分割します。新しいエピソードを生成する際に自由に組み合わせてください。", howTemplatesPowerDesc: "プロファイルはポッドキャストワークフローを2つの再利用可能なビルディングブロックに分割します。新しいエピソードを生成する際に自由に組み合わせてください。",
episodeProfilesSetFormat: "エピソードプロファイルがフォーマットを設定", episodeProfilesSetFormat: "エピソードプロファイルがフォーマットを設定",
episodeProfilesList1: "セグメント数とストーリーの流れを概説", episodeProfilesList1: "セグメント数とストーリーの流れを概説",
episodeProfilesList2: "ブリーフィング、アウトライン、スクリプト作成に使用する言語モデルを選択", episodeProfilesList2: "ブリーフィング、アウトライン、スクリプト作成に使用する言語モデルを選択",
@ -575,13 +575,13 @@ export const jaJP = {
workflowStep2: "それらのスピーカーを名前で参照するエピソードプロファイルを構築", workflowStep2: "それらのスピーカーを名前で参照するエピソードプロファイルを構築",
workflowStep3: "ストーリーに合ったエピソードプロファイルを選択してポッドキャストを生成", workflowStep3: "ストーリーに合ったエピソードプロファイルを選択してポッドキャストを生成",
workflowHint: "エピソードプロファイルはスピーカープロファイルを名前で参照するため、スピーカーから始めることで後の声の割り当て漏れを防ぎます。", workflowHint: "エピソードプロファイルはスピーカープロファイルを名前で参照するため、スピーカーから始めることで後の声の割り当て漏れを防ぎます。",
failedToLoadTemplates: "テンプレートデータの読み込みに失敗しました", failedToLoadTemplates: "プロファイルデータの読み込みに失敗しました",
failedToLoadTemplatesDesc: "APIが実行中か確認して再試行してください。一部のセクションが不完全な場合があります。", failedToLoadTemplatesDesc: "APIが実行中か確認して再試行してください。一部のセクションが不完全な場合があります。",
loadingTemplates: "テンプレートを読み込み中...", loadingTemplates: "プロファイルを読み込み中...",
speakerProfilesTitle: "スピーカープロファイル", speakerProfilesTitle: "スピーカープロファイル",
speakerProfilesDesc: "生成されるエピソードの声と個性を設定します。", speakerProfilesDesc: "生成されるエピソードの声と個性を設定します。",
createSpeaker: "スピーカーを作成", createSpeaker: "スピーカーを作成",
noSpeakerProfiles: "スピーカープロファイルがまだありません。エピソードテンプレートを利用するには作成してください。", noSpeakerProfiles: "スピーカープロファイルがまだありません。エピソードプロファイルを利用するには作成してください。",
noDescription: "説明なし。", noDescription: "説明なし。",
usedByCount_one: "1つのエピソードで使用", usedByCount_one: "1つのエピソードで使用",
usedByCount_other: "{count}個のエピソードで使用", usedByCount_other: "{count}個のエピソードで使用",
@ -659,12 +659,10 @@ export const jaJP = {
embedded: "Embedding済み", embedded: "Embedding済み",
notEmbedded: "未Embedding", notEmbedded: "未Embedding",
noSpeakerProfilesAvailable: "スピーカープロファイルがありません", noSpeakerProfilesAvailable: "スピーカープロファイルがありません",
noLanguageModelsAvailable: "言語モデルがありません",
editEpisodeProfile: "エピソードプロファイルを編集", editEpisodeProfile: "エピソードプロファイルを編集",
createEpisodeProfile: "エピソードプロファイルを作成", createEpisodeProfile: "エピソードプロファイルを作成",
episodeProfileFormDesc: "エピソードの生成方法とデフォルトで使用するスピーカー設定を定義します。", episodeProfileFormDesc: "エピソードの生成方法とデフォルトで使用するスピーカー設定を定義します。",
noSpeakerProfilesDesc: "エピソードプロファイルを設定する前にスピーカープロファイルを作成してください。", noSpeakerProfilesDesc: "エピソードプロファイルを設定する前にスピーカープロファイルを作成してください。",
noLanguageModelsDesc: "アウトラインとトランスクリプト生成を設定するにはモデルセクションで言語モデルを追加してください。",
profileName: "プロファイル名", profileName: "プロファイル名",
profileNamePlaceholder: "例:テックディスカッション", profileNamePlaceholder: "例:テックディスカッション",
descriptionPlaceholder: "このプロファイルを使用する場面の簡単な説明", descriptionPlaceholder: "このプロファイルを使用する場面の簡単な説明",
@ -676,17 +674,13 @@ export const jaJP = {
editSpeakerProfile: "スピーカープロファイルを編集", editSpeakerProfile: "スピーカープロファイルを編集",
createSpeakerProfile: "スピーカープロファイルを作成", createSpeakerProfile: "スピーカープロファイルを作成",
speakerProfileFormDesc: "音声合成設定を構成し、最大4人のスピーカーを定義します。", speakerProfileFormDesc: "音声合成設定を構成し、最大4人のスピーカーを定義します。",
noTtsModelsAvailable: "音声合成モデルがありません",
noTtsModelsDesc: "スピーカープロファイルを作成する前にモデルセクションでTTSモデルを追加してください。",
speakers: "スピーカー", speakers: "スピーカー",
speakersDesc: "このプロファイルに1〜4人の声を設定します。", speakersDesc: "このプロファイルに1〜4人の声を設定します。",
addSpeaker: "スピーカーを追加", addSpeaker: "スピーカーを追加",
speakerNumber: "スピーカー{number}", speakerNumber: "スピーカー{number}",
backstoryPlaceholder: "スピーカーの短い経歴やコンテキスト", backstoryPlaceholder: "スピーカーの短い経歴やコンテキスト",
personalityPlaceholder: "スタイルとトーンを説明", personalityPlaceholder: "スタイルとトーンを説明",
outlineProviderRequired: "アウトラインプロバイダーは必須です",
outlineModelRequired: "アウトラインモデルは必須です", outlineModelRequired: "アウトラインモデルは必須です",
transcriptProviderRequired: "トランスクリプトプロバイダーは必須です",
transcriptModelRequired: "トランスクリプトモデルは必須です", transcriptModelRequired: "トランスクリプトモデルは必須です",
defaultBriefingRequired: "デフォルトブリーフィングは必須です", defaultBriefingRequired: "デフォルトブリーフィングは必須です",
segmentsInteger: "整数である必要があります", segmentsInteger: "整数である必要があります",
@ -705,6 +699,19 @@ export const jaJP = {
retryStartedDesc: "新しいポッドキャスト生成ジョブが送信されました。", retryStartedDesc: "新しいポッドキャスト生成ジョブが送信されました。",
failedToRetry: "再試行に失敗しました", failedToRetry: "再試行に失敗しました",
errorDetails: "エラー詳細", errorDetails: "エラー詳細",
language: "言語",
languagePlaceholder: "言語を選択(任意)",
podcastLanguage: "ポッドキャストの言語",
selectOutlineModel: "アウトラインモデルを選択",
selectTranscriptModel: "トランスクリプトモデルを選択",
voiceModel: "音声モデル",
voiceModelRequired: "音声モデルは必須です",
selectVoiceModel: "音声モデルを選択",
perSpeakerTtsOverride: "スピーカーごとのTTSオーバーライド任意",
useProfileDefault: "プロファイルのデフォルトを使用",
setupRequired: "設定が必要",
setupRequiredDesc: "一部のプロファイルにモデルが設定されていません。ポッドキャストを生成する前に、編集してモデルを選択してください。",
notConfigured: "未設定",
}, },
settings: { settings: {
contentProcessing: "コンテンツ処理", contentProcessing: "コンテンツ処理",
@ -818,7 +825,6 @@ export const jaJP = {
embedding: "Embeddingモデル", embedding: "Embeddingモデル",
tts: "音声合成TTS", tts: "音声合成TTS",
stt: "音声認識STT", stt: "音声認識STT",
provider: "プロバイダー",
apiKey: "APIキー", apiKey: "APIキー",
deleteSuccess: "モデルを削除しました", deleteSuccess: "モデルを削除しました",
saveSuccess: "モデルを保存しました", saveSuccess: "モデルを保存しました",
@ -847,9 +853,6 @@ export const jaJP = {
ttsModelDesc: "ポッドキャスト生成に使用", ttsModelDesc: "ポッドキャスト生成に使用",
sttModelLabel: "音声認識モデル", sttModelLabel: "音声認識モデル",
sttModelDesc: "音声の書き起こしに使用", sttModelDesc: "音声の書き起こしに使用",
selectProviderPlaceholder: "プロバイダーを選択",
providerRequired: "プロバイダーは必須です",
modelRequired: "モデルは必須です",
embeddingChangeTitle: "Embeddingモデルの変更", embeddingChangeTitle: "Embeddingモデルの変更",
embeddingChangeConfirm: "Embeddingモデルを{from}から{to}に変更しようとしています。", embeddingChangeConfirm: "Embeddingモデルを{from}から{to}に変更しようとしています。",
rebuildRequired: "重要:再構築が必要", rebuildRequired: "重要:再構築が必要",

View file

@ -534,10 +534,10 @@ export const ptBR = {
loadingProfiles: "Carregando perfis de episódio...", loadingProfiles: "Carregando perfis de episódio...",
noProfilesFound: "Nenhum perfil de episódio encontrado. Crie um perfil de episódio antes de gerar um podcast.", noProfilesFound: "Nenhum perfil de episódio encontrado. Crie um perfil de episódio antes de gerar um podcast.",
listTitle: "Podcasts", listTitle: "Podcasts",
listDesc: "Acompanhe episódios gerados e gerencie templates reutilizáveis.", listDesc: "Acompanhe episódios gerados e gerencie perfis reutilizáveis.",
chooseAView: "Escolha uma visualização", chooseAView: "Escolha uma visualização",
episodesTab: "Episódios", episodesTab: "Episódios",
templatesTab: "Templates", templatesTab: "Perfis",
overviewTitle: "Visão geral dos episódios", overviewTitle: "Visão geral dos episódios",
overviewDesc: "Monitore trabalhos de geração de podcast e revise os artefatos finais.", overviewDesc: "Monitore trabalhos de geração de podcast e revise os artefatos finais.",
generateBtn: "Gerar Podcast", generateBtn: "Gerar Podcast",
@ -558,10 +558,10 @@ export const ptBR = {
statusCompletedDesc: "Prontos para revisar, baixar ou publicar.", statusCompletedDesc: "Prontos para revisar, baixar ou publicar.",
statusFailedTitle: "Episódios com Falha", statusFailedTitle: "Episódios com Falha",
statusFailedDesc: "Episódios que encontraram problemas durante a geração.", statusFailedDesc: "Episódios que encontraram problemas durante a geração.",
templatesWorkspaceTitle: "Área de trabalho de templates", templatesWorkspaceTitle: "Área de trabalho de perfis",
templatesWorkspaceDesc: "Construa configurações de episódio e locutor reutilizáveis para produção rápida de podcasts.", templatesWorkspaceDesc: "Construa configurações de episódio e locutor reutilizáveis para produção rápida de podcasts.",
howTemplatesPowerTitle: "Como os templates potencializam a geração de podcasts", howTemplatesPowerTitle: "Como os perfis potencializam a geração de podcasts",
howTemplatesPowerDesc: "Templates dividem o fluxo de trabalho do podcast em dois blocos de construção reutilizáveis. Misture e combine-os sempre que gerar um novo episódio.", howTemplatesPowerDesc: "Os perfis dividem o fluxo de trabalho do podcast em dois blocos de construção reutilizáveis. Misture e combine-os sempre que gerar um novo episódio.",
episodeProfilesSetFormat: "Perfis de episódio definem o formato", episodeProfilesSetFormat: "Perfis de episódio definem o formato",
episodeProfilesList1: "Delineiam o número de segmentos e como a história flui", episodeProfilesList1: "Delineiam o número de segmentos e como a história flui",
episodeProfilesList2: "Escolhem os modelos de linguagem usados para briefing, outline e escrita do roteiro", episodeProfilesList2: "Escolhem os modelos de linguagem usados para briefing, outline e escrita do roteiro",
@ -575,13 +575,13 @@ export const ptBR = {
workflowStep2: "Construa perfis de episódio que referenciam esses locutores pelo nome", workflowStep2: "Construa perfis de episódio que referenciam esses locutores pelo nome",
workflowStep3: "Gere podcasts selecionando o perfil de episódio que se encaixa na história", workflowStep3: "Gere podcasts selecionando o perfil de episódio que se encaixa na história",
workflowHint: "Perfis de episódio referenciam perfis de locutor pelo nome, então começar com locutores evita atribuições de voz faltantes depois.", workflowHint: "Perfis de episódio referenciam perfis de locutor pelo nome, então começar com locutores evita atribuições de voz faltantes depois.",
failedToLoadTemplates: "Falha ao carregar dados de templates", failedToLoadTemplates: "Falha ao carregar dados de perfis",
failedToLoadTemplatesDesc: "Certifique-se de que a API está rodando e tente novamente. Algumas seções podem estar incompletas.", failedToLoadTemplatesDesc: "Certifique-se de que a API está rodando e tente novamente. Algumas seções podem estar incompletas.",
loadingTemplates: "Carregando templates…", loadingTemplates: "Carregando perfis…",
speakerProfilesTitle: "Perfis de locutor", speakerProfilesTitle: "Perfis de locutor",
speakerProfilesDesc: "Configure vozes e personalidades para episódios gerados.", speakerProfilesDesc: "Configure vozes e personalidades para episódios gerados.",
createSpeaker: "Criar locutor", createSpeaker: "Criar locutor",
noSpeakerProfiles: "Nenhum perfil de locutor ainda. Crie um para disponibilizar templates de episódio.", noSpeakerProfiles: "Nenhum perfil de locutor ainda. Crie um para disponibilizar perfis de episódio.",
noDescription: "Nenhuma descrição fornecida.", noDescription: "Nenhuma descrição fornecida.",
usedByCount_one: "Usado por 1 episódio", usedByCount_one: "Usado por 1 episódio",
usedByCount_other: "Usado por {count} episódios", usedByCount_other: "Usado por {count} episódios",
@ -659,12 +659,10 @@ export const ptBR = {
embedded: "Incorporado", embedded: "Incorporado",
notEmbedded: "Não incorporado", notEmbedded: "Não incorporado",
noSpeakerProfilesAvailable: "Nenhum perfil de locutor disponível", noSpeakerProfilesAvailable: "Nenhum perfil de locutor disponível",
noLanguageModelsAvailable: "Nenhum modelo de linguagem disponível",
editEpisodeProfile: "Editar Perfil de Episódio", editEpisodeProfile: "Editar Perfil de Episódio",
createEpisodeProfile: "Criar Perfil de Episódio", createEpisodeProfile: "Criar Perfil de Episódio",
episodeProfileFormDesc: "Defina como os episódios devem ser gerados e qual configuração de locutor usar por padrão.", episodeProfileFormDesc: "Defina como os episódios devem ser gerados e qual configuração de locutor usar por padrão.",
noSpeakerProfilesDesc: "Crie um perfil de locutor antes de configurar um perfil de episódio.", noSpeakerProfilesDesc: "Crie um perfil de locutor antes de configurar um perfil de episódio.",
noLanguageModelsDesc: "Adicione modelos de linguagem na seção Modelos para configurar a geração de outline e transcrição.",
profileName: "Nome do perfil", profileName: "Nome do perfil",
profileNamePlaceholder: "ex., Discussão tech", profileNamePlaceholder: "ex., Discussão tech",
descriptionPlaceholder: "Breve resumo de quando usar este perfil", descriptionPlaceholder: "Breve resumo de quando usar este perfil",
@ -676,17 +674,13 @@ export const ptBR = {
editSpeakerProfile: "Editar Perfil de Locutor", editSpeakerProfile: "Editar Perfil de Locutor",
createSpeakerProfile: "Criar Perfil de Locutor", createSpeakerProfile: "Criar Perfil de Locutor",
speakerProfileFormDesc: "Configure as configurações de text-to-speech e defina até quatro locutores.", speakerProfileFormDesc: "Configure as configurações de text-to-speech e defina até quatro locutores.",
noTtsModelsAvailable: "Nenhum modelo de text-to-speech disponível",
noTtsModelsDesc: "Adicione modelos TTS na seção Modelos antes de criar um perfil de locutor.",
speakers: "Locutores", speakers: "Locutores",
speakersDesc: "Configure entre uma e quatro vozes para este perfil.", speakersDesc: "Configure entre uma e quatro vozes para este perfil.",
addSpeaker: "Adicionar locutor", addSpeaker: "Adicionar locutor",
speakerNumber: "Locutor {number}", speakerNumber: "Locutor {number}",
backstoryPlaceholder: "Breve biografia ou contexto para o locutor", backstoryPlaceholder: "Breve biografia ou contexto para o locutor",
personalityPlaceholder: "Descreva estilo e tom", personalityPlaceholder: "Descreva estilo e tom",
outlineProviderRequired: "Provedor de outline é obrigatório",
outlineModelRequired: "Modelo de outline é obrigatório", outlineModelRequired: "Modelo de outline é obrigatório",
transcriptProviderRequired: "Provedor de transcrição é obrigatório",
transcriptModelRequired: "Modelo de transcrição é obrigatório", transcriptModelRequired: "Modelo de transcrição é obrigatório",
defaultBriefingRequired: "Briefing padrão é obrigatório", defaultBriefingRequired: "Briefing padrão é obrigatório",
segmentsInteger: "Deve ser um número inteiro", segmentsInteger: "Deve ser um número inteiro",
@ -705,6 +699,19 @@ export const ptBR = {
retryStartedDesc: "Um novo trabalho de geração de podcast foi enviado.", retryStartedDesc: "Um novo trabalho de geração de podcast foi enviado.",
failedToRetry: "Falha ao tentar novamente", failedToRetry: "Falha ao tentar novamente",
errorDetails: "Detalhes do erro", errorDetails: "Detalhes do erro",
language: "Idioma",
languagePlaceholder: "Selecione um idioma (opcional)",
podcastLanguage: "Idioma do podcast",
selectOutlineModel: "Selecione o modelo de roteiro",
selectTranscriptModel: "Selecione o modelo de transcrição",
voiceModel: "Modelo de voz",
voiceModelRequired: "Modelo de voz é obrigatório",
selectVoiceModel: "Selecione o modelo de voz",
perSpeakerTtsOverride: "Override de TTS por speaker (opcional)",
useProfileDefault: "Usar padrão do perfil",
setupRequired: "Configuração necessária",
setupRequiredDesc: "Alguns perfis ainda não têm modelos configurados. Edite-os para selecionar modelos antes de gerar podcasts.",
notConfigured: "Não configurado",
}, },
settings: { settings: {
contentProcessing: "Processamento de Conteúdo", contentProcessing: "Processamento de Conteúdo",
@ -818,7 +825,6 @@ export const ptBR = {
embedding: "Modelos de Embedding", embedding: "Modelos de Embedding",
tts: "Text to Speech (TTS)", tts: "Text to Speech (TTS)",
stt: "Speech to Text (STT)", stt: "Speech to Text (STT)",
provider: "Provedor",
apiKey: "Chave da API", apiKey: "Chave da API",
deleteSuccess: "Modelo excluído com sucesso", deleteSuccess: "Modelo excluído com sucesso",
saveSuccess: "Modelo salvo com sucesso", saveSuccess: "Modelo salvo com sucesso",
@ -847,9 +853,6 @@ export const ptBR = {
ttsModelDesc: "Usado para geração de podcast", ttsModelDesc: "Usado para geração de podcast",
sttModelLabel: "Modelo Speech-to-Text", sttModelLabel: "Modelo Speech-to-Text",
sttModelDesc: "Usado para transcrição de áudio", sttModelDesc: "Usado para transcrição de áudio",
selectProviderPlaceholder: "Selecione um provedor",
providerRequired: "Provedor é obrigatório",
modelRequired: "Modelo é obrigatório",
embeddingChangeTitle: "Alteração de Modelo de Embedding", embeddingChangeTitle: "Alteração de Modelo de Embedding",
embeddingChangeConfirm: "Você está prestes a alterar seu modelo de embedding de {from} para {to}.", embeddingChangeConfirm: "Você está prestes a alterar seu modelo de embedding de {from} para {to}.",
rebuildRequired: "Importante: Reconstrução Necessária", rebuildRequired: "Importante: Reconstrução Necessária",

View file

@ -534,10 +534,10 @@ export const ruRU = {
loadingProfiles: "Загрузка профилей эпизодов...", loadingProfiles: "Загрузка профилей эпизодов...",
noProfilesFound: "Профили эпизодов не найдены. Создайте профиль перед генерацией подкаста.", noProfilesFound: "Профили эпизодов не найдены. Создайте профиль перед генерацией подкаста.",
listTitle: "Подкасты", listTitle: "Подкасты",
listDesc: "Отслеживайте сгенерированные эпизоды и управляйте шаблонами.", listDesc: "Отслеживайте сгенерированные эпизоды и управляйте профилями.",
chooseAView: "Выберите представление", chooseAView: "Выберите представление",
episodesTab: "Эпизоды", episodesTab: "Эпизоды",
templatesTab: "Шаблоны", templatesTab: "Профили",
overviewTitle: "Обзор эпизодов", overviewTitle: "Обзор эпизодов",
overviewDesc: "Отслеживайте задачи генерации подкастов и просматривайте готовые материалы.", overviewDesc: "Отслеживайте задачи генерации подкастов и просматривайте готовые материалы.",
generateBtn: "Сгенерировать подкаст", generateBtn: "Сгенерировать подкаст",
@ -558,10 +558,10 @@ export const ruRU = {
statusCompletedDesc: "Готовы к просмотру, загрузке или публикации.", statusCompletedDesc: "Готовы к просмотру, загрузке или публикации.",
statusFailedTitle: "Неудачные эпизоды", statusFailedTitle: "Неудачные эпизоды",
statusFailedDesc: "Эпизоды с ошибками во время генерации.", statusFailedDesc: "Эпизоды с ошибками во время генерации.",
templatesWorkspaceTitle: "Рабочее пространство шаблонов", templatesWorkspaceTitle: "Рабочее пространство профилей",
templatesWorkspaceDesc: "Создавайте переиспользуемые конфигурации эпизодов и говорящих для быстрого производства подкастов.", templatesWorkspaceDesc: "Создавайте переиспользуемые конфигурации эпизодов и говорящих для быстрого производства подкастов.",
howTemplatesPowerTitle: "Как шаблоны ускоряют генерацию подкастов", howTemplatesPowerTitle: "Как профили ускоряют генерацию подкастов",
howTemplatesPowerDesc: "Шаблоны разделяют процесс на два переиспользуемых компонента. Комбинируйте их при генерации нового эпизода.", howTemplatesPowerDesc: "Профили разделяют процесс на два переиспользуемых компонента. Комбинируйте их при генерации нового эпизода.",
episodeProfilesSetFormat: "Профили эпизодов задают формат", episodeProfilesSetFormat: "Профили эпизодов задают формат",
episodeProfilesList1: "Определяют количество сегментов и структуру повествования", episodeProfilesList1: "Определяют количество сегментов и структуру повествования",
episodeProfilesList2: "Выбирают языковые модели для брифинга, планирования и написания сценария", episodeProfilesList2: "Выбирают языковые модели для брифинга, планирования и написания сценария",
@ -575,13 +575,13 @@ export const ruRU = {
workflowStep2: "Создайте профили эпизодов со ссылками на говорящих по имени", workflowStep2: "Создайте профили эпизодов со ссылками на говорящих по имени",
workflowStep3: "Генерируйте подкасты, выбирая подходящий профиль эпизода", workflowStep3: "Генерируйте подкасты, выбирая подходящий профиль эпизода",
workflowHint: "Профили эпизодов ссылаются на профили говорящих по имени, поэтому начинайте с говорящих, чтобы избежать пропущенных назначений голосов.", workflowHint: "Профили эпизодов ссылаются на профили говорящих по имени, поэтому начинайте с говорящих, чтобы избежать пропущенных назначений голосов.",
failedToLoadTemplates: "Не удалось загрузить данные шаблонов", failedToLoadTemplates: "Не удалось загрузить данные профилей",
failedToLoadTemplatesDesc: "Убедитесь, что API работает, и попробуйте снова. Некоторые разделы могут быть неполными.", failedToLoadTemplatesDesc: "Убедитесь, что API работает, и попробуйте снова. Некоторые разделы могут быть неполными.",
loadingTemplates: "Загрузка шаблонов…", loadingTemplates: "Загрузка профилей…",
speakerProfilesTitle: "Профили говорящих", speakerProfilesTitle: "Профили говорящих",
speakerProfilesDesc: "Настройте голоса и личности для генерируемых эпизодов.", speakerProfilesDesc: "Настройте голоса и личности для генерируемых эпизодов.",
createSpeaker: "Создать говорящего", createSpeaker: "Создать говорящего",
noSpeakerProfiles: "Пока нет профилей говорящих. Создайте один, чтобы шаблоны эпизодов стали доступны.", noSpeakerProfiles: "Пока нет профилей говорящих. Создайте один, чтобы профили эпизодов стали доступны.",
noDescription: "Описание не указано.", noDescription: "Описание не указано.",
usedByCount_one: "Используется в 1 эпизоде", usedByCount_one: "Используется в 1 эпизоде",
usedByCount_other: "Используется в {count} эпизодах", usedByCount_other: "Используется в {count} эпизодах",
@ -659,12 +659,10 @@ export const ruRU = {
embedded: "С эмбеддингом", embedded: "С эмбеддингом",
notEmbedded: "Без эмбеддинга", notEmbedded: "Без эмбеддинга",
noSpeakerProfilesAvailable: "Нет доступных профилей говорящих", noSpeakerProfilesAvailable: "Нет доступных профилей говорящих",
noLanguageModelsAvailable: "Нет доступных языковых моделей",
editEpisodeProfile: "Редактировать профиль эпизода", editEpisodeProfile: "Редактировать профиль эпизода",
createEpisodeProfile: "Создать профиль эпизода", createEpisodeProfile: "Создать профиль эпизода",
episodeProfileFormDesc: "Определите, как должны генерироваться эпизоды и какую конфигурацию говорящих использовать по умолчанию.", episodeProfileFormDesc: "Определите, как должны генерироваться эпизоды и какую конфигурацию говорящих использовать по умолчанию.",
noSpeakerProfilesDesc: "Создайте профиль говорящего перед настройкой профиля эпизода.", noSpeakerProfilesDesc: "Создайте профиль говорящего перед настройкой профиля эпизода.",
noLanguageModelsDesc: "Добавьте языковые модели в разделе «Модели» для настройки генерации плана и транскрипта.",
profileName: "Название профиля", profileName: "Название профиля",
profileNamePlaceholder: "напр., Техническая дискуссия", profileNamePlaceholder: "напр., Техническая дискуссия",
descriptionPlaceholder: "Краткое описание, когда использовать этот профиль", descriptionPlaceholder: "Краткое описание, когда использовать этот профиль",
@ -676,17 +674,13 @@ export const ruRU = {
editSpeakerProfile: "Редактировать профиль говорящего", editSpeakerProfile: "Редактировать профиль говорящего",
createSpeakerProfile: "Создать профиль говорящего", createSpeakerProfile: "Создать профиль говорящего",
speakerProfileFormDesc: "Настройте параметры озвучивания и определите до четырёх говорящих.", speakerProfileFormDesc: "Настройте параметры озвучивания и определите до четырёх говорящих.",
noTtsModelsAvailable: "Нет доступных моделей озвучивания",
noTtsModelsDesc: "Добавьте TTS-модели в разделе «Модели» перед созданием профиля говорящего.",
speakers: "Говорящие", speakers: "Говорящие",
speakersDesc: "Настройте от одного до четырёх голосов для этого профиля.", speakersDesc: "Настройте от одного до четырёх голосов для этого профиля.",
addSpeaker: "Добавить говорящего", addSpeaker: "Добавить говорящего",
speakerNumber: "Говорящий {number}", speakerNumber: "Говорящий {number}",
backstoryPlaceholder: "Краткая биография или контекст для говорящего", backstoryPlaceholder: "Краткая биография или контекст для говорящего",
personalityPlaceholder: "Опишите стиль и тон", personalityPlaceholder: "Опишите стиль и тон",
outlineProviderRequired: "Требуется провайдер плана",
outlineModelRequired: "Требуется модель плана", outlineModelRequired: "Требуется модель плана",
transcriptProviderRequired: "Требуется провайдер транскрипта",
transcriptModelRequired: "Требуется модель транскрипта", transcriptModelRequired: "Требуется модель транскрипта",
defaultBriefingRequired: "Требуется брифинг по умолчанию", defaultBriefingRequired: "Требуется брифинг по умолчанию",
segmentsInteger: "Должно быть целым числом", segmentsInteger: "Должно быть целым числом",
@ -705,6 +699,19 @@ export const ruRU = {
retryStartedDesc: "Новое задание на генерацию подкаста отправлено.", retryStartedDesc: "Новое задание на генерацию подкаста отправлено.",
failedToRetry: "Не удалось повторить", failedToRetry: "Не удалось повторить",
errorDetails: "Подробности ошибки", errorDetails: "Подробности ошибки",
language: "Язык",
languagePlaceholder: "Выберите язык (необязательно)",
podcastLanguage: "Язык подкаста",
selectOutlineModel: "Выберите модель плана",
selectTranscriptModel: "Выберите модель транскрипта",
voiceModel: "Голосовая модель",
voiceModelRequired: "Требуется голосовая модель",
selectVoiceModel: "Выберите голосовую модель",
perSpeakerTtsOverride: "Переопределение TTS для говорящего (необязательно)",
useProfileDefault: "Использовать настройки профиля",
setupRequired: "Требуется настройка",
setupRequiredDesc: "Некоторые профили ещё не имеют настроенных моделей. Отредактируйте их для выбора моделей перед генерацией подкастов.",
notConfigured: "Не настроено",
}, },
settings: { settings: {
contentProcessing: "Обработка контента", contentProcessing: "Обработка контента",
@ -818,7 +825,6 @@ export const ruRU = {
embedding: "Модели эмбеддинга", embedding: "Модели эмбеддинга",
tts: "Озвучивание (TTS)", tts: "Озвучивание (TTS)",
stt: "Распознавание речи (STT)", stt: "Распознавание речи (STT)",
provider: "Провайдер",
apiKey: "API-ключ", apiKey: "API-ключ",
deleteSuccess: "Модель успешно удалена", deleteSuccess: "Модель успешно удалена",
saveSuccess: "Модель успешно сохранена", saveSuccess: "Модель успешно сохранена",
@ -847,9 +853,6 @@ export const ruRU = {
ttsModelDesc: "Используется для генерации подкастов", ttsModelDesc: "Используется для генерации подкастов",
sttModelLabel: "Модель распознавания речи", sttModelLabel: "Модель распознавания речи",
sttModelDesc: "Используется для транскрибации аудио", sttModelDesc: "Используется для транскрибации аудио",
selectProviderPlaceholder: "Выберите провайдера",
providerRequired: "Требуется провайдер",
modelRequired: "Требуется модель",
embeddingChangeTitle: "Изменение модели эмбеддинга", embeddingChangeTitle: "Изменение модели эмбеддинга",
embeddingChangeConfirm: "Вы собираетесь изменить модель эмбеддинга с {from} на {to}.", embeddingChangeConfirm: "Вы собираетесь изменить модель эмбеддинга с {from} на {to}.",
rebuildRequired: "Важно: Требуется пересоздание", rebuildRequired: "Важно: Требуется пересоздание",

View file

@ -534,10 +534,10 @@ export const zhCN = {
loadingProfiles: "正在加载单集简介...", loadingProfiles: "正在加载单集简介...",
noProfilesFound: "未找到单集简介。在生成播客之前,请先创建一个单集简介。", noProfilesFound: "未找到单集简介。在生成播客之前,请先创建一个单集简介。",
listTitle: "播客", listTitle: "播客",
listDesc: "跟踪生成的单集并管理可重复使用的模板。", listDesc: "跟踪生成的单集并管理可重复使用的简介。",
chooseAView: "选择视图", chooseAView: "选择视图",
episodesTab: "单集", episodesTab: "单集",
templatesTab: "模板", templatesTab: "配置",
overviewTitle: "单集概览", overviewTitle: "单集概览",
overviewDesc: "监控播客生成任务并查看最终成品。", overviewDesc: "监控播客生成任务并查看最终成品。",
generateBtn: "生成播客", generateBtn: "生成播客",
@ -558,10 +558,10 @@ export const zhCN = {
statusCompletedDesc: "可以查看、下载或发布。", statusCompletedDesc: "可以查看、下载或发布。",
statusFailedTitle: "失败单集", statusFailedTitle: "失败单集",
statusFailedDesc: "在生成过程中遇到问题的单集。", statusFailedDesc: "在生成过程中遇到问题的单集。",
templatesWorkspaceTitle: "模板工作区", templatesWorkspaceTitle: "简介工作区",
templatesWorkspaceDesc: "构建可重复使用的单集和发言人配置,以实现快速的播客制作。", templatesWorkspaceDesc: "构建可重复使用的单集和发言人配置,以实现快速的播客制作。",
howTemplatesPowerTitle: "模板如何驱动播客生成", howTemplatesPowerTitle: "简介如何驱动播客生成",
howTemplatesPowerDesc: "模板将播客工作流拆分为两个可重复使用的构建块。在生成新单集时可以随时混合搭配它们。", howTemplatesPowerDesc: "简介将播客工作流拆分为两个可重复使用的构建块。在生成新单集时可以随时混合搭配它们。",
episodeProfilesSetFormat: "单集简介设定格式", episodeProfilesSetFormat: "单集简介设定格式",
episodeProfilesList1: "概述分段数量及故事流向", episodeProfilesList1: "概述分段数量及故事流向",
episodeProfilesList2: "选择用于简报、大纲和脚本编写的语言模型", episodeProfilesList2: "选择用于简报、大纲和脚本编写的语言模型",
@ -575,13 +575,13 @@ export const zhCN = {
workflowStep2: "构建按名称引用这些发言人的单集简介", workflowStep2: "构建按名称引用这些发言人的单集简介",
workflowStep3: "通过选择适合故事的单集简介来生成播客", workflowStep3: "通过选择适合故事的单集简介来生成播客",
workflowHint: "单集简介按名称引用发言人简介,因此从发言人开始可以避免以后缺少声音指派。", workflowHint: "单集简介按名称引用发言人简介,因此从发言人开始可以避免以后缺少声音指派。",
failedToLoadTemplates: "加载模板数据失败", failedToLoadTemplates: "加载简介数据失败",
failedToLoadTemplatesDesc: "请确保 API 正在运行并重试。某些部分可能不完整。", failedToLoadTemplatesDesc: "请确保 API 正在运行并重试。某些部分可能不完整。",
loadingTemplates: "正在加载模板...", loadingTemplates: "正在加载简介...",
speakerProfilesTitle: "发言人简介", speakerProfilesTitle: "发言人简介",
speakerProfilesDesc: "为生成的单集配置声音和性格。", speakerProfilesDesc: "为生成的单集配置声音和性格。",
createSpeaker: "创建发言人", createSpeaker: "创建发言人",
noSpeakerProfiles: "暂无发言人简介。创建一个以使单集模板可用。", noSpeakerProfiles: "暂无发言人简介。创建一个以使单集简介可用。",
noDescription: "未提供描述。", noDescription: "未提供描述。",
usedByCount_one: "被 1 个单集使用", usedByCount_one: "被 1 个单集使用",
usedByCount_other: "被 {count} 个单集使用", usedByCount_other: "被 {count} 个单集使用",
@ -659,12 +659,10 @@ export const zhCN = {
embedded: "已嵌入", embedded: "已嵌入",
notEmbedded: "未嵌入", notEmbedded: "未嵌入",
noSpeakerProfilesAvailable: "没有可用的发言人简介", noSpeakerProfilesAvailable: "没有可用的发言人简介",
noLanguageModelsAvailable: "没有可用的语言模型",
editEpisodeProfile: "编辑单集简介", editEpisodeProfile: "编辑单集简介",
createEpisodeProfile: "创建单集简介", createEpisodeProfile: "创建单集简介",
episodeProfileFormDesc: "定义单集生成的规则及默认使用的发言人配置。", episodeProfileFormDesc: "定义单集生成的规则及默认使用的发言人配置。",
noSpeakerProfilesDesc: "在配置单集简介之前,请先创建一个发言人简介。", noSpeakerProfilesDesc: "在配置单集简介之前,请先创建一个发言人简介。",
noLanguageModelsDesc: "在“模型”部分添加语言模型,以配置大纲和文稿生成。",
profileName: "简介名称", profileName: "简介名称",
profileNamePlaceholder: "例如:技术讨论", profileNamePlaceholder: "例如:技术讨论",
descriptionPlaceholder: "简要说明何时使用此简介", descriptionPlaceholder: "简要说明何时使用此简介",
@ -676,17 +674,13 @@ export const zhCN = {
editSpeakerProfile: "编辑发言人简介", editSpeakerProfile: "编辑发言人简介",
createSpeakerProfile: "创建发言人简介", createSpeakerProfile: "创建发言人简介",
speakerProfileFormDesc: "配置文字转语音设置并定义最多四名发言人。", speakerProfileFormDesc: "配置文字转语音设置并定义最多四名发言人。",
noTtsModelsAvailable: "没有可用的文字转语音模型",
noTtsModelsDesc: "在创建发言人简介之前,请先在“模型”部分添加 TTS 模型。",
speakers: "发言人", speakers: "发言人",
speakersDesc: "为此简介配置一到四种声音。", speakersDesc: "为此简介配置一到四种声音。",
addSpeaker: "添加发言人", addSpeaker: "添加发言人",
speakerNumber: "发言人 {number}", speakerNumber: "发言人 {number}",
backstoryPlaceholder: "发言人的简要传记或背景信息", backstoryPlaceholder: "发言人的简要传记或背景信息",
personalityPlaceholder: "描述风格和语气", personalityPlaceholder: "描述风格和语气",
outlineProviderRequired: "必须选择大纲提供商",
outlineModelRequired: "必须选择大纲模型", outlineModelRequired: "必须选择大纲模型",
transcriptProviderRequired: "必须选择文稿提供商",
transcriptModelRequired: "必须选择文稿模型", transcriptModelRequired: "必须选择文稿模型",
defaultBriefingRequired: "必须填写默认简介", defaultBriefingRequired: "必须填写默认简介",
segmentsInteger: "必须是整数", segmentsInteger: "必须是整数",
@ -705,6 +699,19 @@ export const zhCN = {
retryStartedDesc: "已提交新的播客生成任务。", retryStartedDesc: "已提交新的播客生成任务。",
failedToRetry: "重试失败", failedToRetry: "重试失败",
errorDetails: "错误详情", errorDetails: "错误详情",
language: "语言",
languagePlaceholder: "选择语言(可选)",
podcastLanguage: "播客语言",
selectOutlineModel: "选择大纲模型",
selectTranscriptModel: "选择转录模型",
voiceModel: "语音模型",
voiceModelRequired: "语音模型为必填项",
selectVoiceModel: "选择语音模型",
perSpeakerTtsOverride: "每个发言人的TTS覆盖可选",
useProfileDefault: "使用配置默认值",
setupRequired: "需要配置",
setupRequiredDesc: "部分配置尚未设置模型。请编辑它们以在生成播客之前选择模型。",
notConfigured: "未配置",
}, },
settings: { settings: {
contentProcessing: "内容处理", contentProcessing: "内容处理",
@ -818,7 +825,6 @@ export const zhCN = {
embedding: "嵌入模型", embedding: "嵌入模型",
tts: "文字转语音", tts: "文字转语音",
stt: "语音转文字", stt: "语音转文字",
provider: "服务商",
apiKey: "API 密钥", apiKey: "API 密钥",
deleteSuccess: "模型删除成功", deleteSuccess: "模型删除成功",
saveSuccess: "模型保存成功", saveSuccess: "模型保存成功",
@ -847,9 +853,6 @@ export const zhCN = {
ttsModelDesc: "用于生成播客", ttsModelDesc: "用于生成播客",
sttModelLabel: "语音转文字模型", sttModelLabel: "语音转文字模型",
sttModelDesc: "用于音频转录", sttModelDesc: "用于音频转录",
selectProviderPlaceholder: "选择服务商",
providerRequired: "服务商是必填项",
modelRequired: "模型是必填项",
embeddingChangeTitle: "嵌入模型变更", embeddingChangeTitle: "嵌入模型变更",
embeddingChangeConfirm: "您即将将嵌入模型从 {from} 更改为 {to}。", embeddingChangeConfirm: "您即将将嵌入模型从 {from} 更改为 {to}。",
rebuildRequired: "重要提示:需要重建索引", rebuildRequired: "重要提示:需要重建索引",

View file

@ -534,10 +534,10 @@ export const zhTW = {
loadingProfiles: "正在載入單集簡介...", loadingProfiles: "正在載入單集簡介...",
noProfilesFound: "未找到單集簡介。在生成播客之前,請先建立一個單集簡介。", noProfilesFound: "未找到單集簡介。在生成播客之前,請先建立一個單集簡介。",
listTitle: "播客", listTitle: "播客",
listDesc: "跟踪生成的單集並管理可重複使用的模板。", listDesc: "跟踪生成的單集並管理可重複使用的簡介。",
chooseAView: "選擇視圖", chooseAView: "選擇視圖",
episodesTab: "單集", episodesTab: "單集",
templatesTab: "模板", templatesTab: "設定檔",
overviewTitle: "單集概覽", overviewTitle: "單集概覽",
overviewDesc: "監控播客生成任務並查看最終成品。", overviewDesc: "監控播客生成任務並查看最終成品。",
generateBtn: "生成播客", generateBtn: "生成播客",
@ -558,10 +558,10 @@ export const zhTW = {
statusCompletedDesc: "可以查看、下載或發布。", statusCompletedDesc: "可以查看、下載或發布。",
statusFailedTitle: "失敗單集", statusFailedTitle: "失敗單集",
statusFailedDesc: "在生成過程中遇到問題的單集。", statusFailedDesc: "在生成過程中遇到問題的單集。",
templatesWorkspaceTitle: "模板工作區", templatesWorkspaceTitle: "簡介工作區",
templatesWorkspaceDesc: "構建可重複使用的單集和發言人設定,以實現快速的播客製作。", templatesWorkspaceDesc: "構建可重複使用的單集和發言人設定,以實現快速的播客製作。",
howTemplatesPowerTitle: "模板如何驅動播客生成", howTemplatesPowerTitle: "簡介如何驅動播客生成",
howTemplatesPowerDesc: "模板將播客工作流拆分為兩個可重複使用的構建塊。在生成新單集時可以隨時混合搭配它們。", howTemplatesPowerDesc: "簡介將播客工作流拆分為兩個可重複使用的構建塊。在生成新單集時可以隨時混合搭配它們。",
episodeProfilesSetFormat: "單集簡介設定格式", episodeProfilesSetFormat: "單集簡介設定格式",
episodeProfilesList1: "概述分段數量及故事流向", episodeProfilesList1: "概述分段數量及故事流向",
episodeProfilesList2: "選擇用於簡報、大綱和腳本編寫的語言模型", episodeProfilesList2: "選擇用於簡報、大綱和腳本編寫的語言模型",
@ -575,13 +575,13 @@ export const zhTW = {
workflowStep2: "構建按名稱引用這些發言人的單集簡介", workflowStep2: "構建按名稱引用這些發言人的單集簡介",
workflowStep3: "通過選擇適合故事的單集簡介來生成播客", workflowStep3: "通過選擇適合故事的單集簡介來生成播客",
workflowHint: "單集簡介按名稱引用發言人簡介,因此從發言人開始可以避免以後缺少聲音指派。", workflowHint: "單集簡介按名稱引用發言人簡介,因此從發言人開始可以避免以後缺少聲音指派。",
failedToLoadTemplates: "載入模板資料失敗", failedToLoadTemplates: "載入簡介資料失敗",
failedToLoadTemplatesDesc: "請確保 API 正在運行並重試。某些部分可能不完整。", failedToLoadTemplatesDesc: "請確保 API 正在運行並重試。某些部分可能不完整。",
loadingTemplates: "正在載入模板...", loadingTemplates: "正在載入簡介...",
speakerProfilesTitle: "發言人簡介", speakerProfilesTitle: "發言人簡介",
speakerProfilesDesc: "為生成的單集設定聲音和性格。", speakerProfilesDesc: "為生成的單集設定聲音和性格。",
createSpeaker: "建立發言人", createSpeaker: "建立發言人",
noSpeakerProfiles: "暫無發言人簡介。建立一個以使單集模板可用。", noSpeakerProfiles: "暫無發言人簡介。建立一個以使單集簡介可用。",
noDescription: "未提供描述。", noDescription: "未提供描述。",
usedByCount_one: "被 1 個單集使用", usedByCount_one: "被 1 個單集使用",
usedByCount_other: "被 {count} 個單集使用", usedByCount_other: "被 {count} 個單集使用",
@ -659,12 +659,10 @@ export const zhTW = {
embedded: "已嵌入", embedded: "已嵌入",
notEmbedded: "未嵌入", notEmbedded: "未嵌入",
noSpeakerProfilesAvailable: "沒有可用的發言人簡介", noSpeakerProfilesAvailable: "沒有可用的發言人簡介",
noLanguageModelsAvailable: "沒有可用的語言模型",
editEpisodeProfile: "編輯單集簡介", editEpisodeProfile: "編輯單集簡介",
createEpisodeProfile: "建立單集簡介", createEpisodeProfile: "建立單集簡介",
episodeProfileFormDesc: "定義單集生成的規則及預設使用的發言人設定。", episodeProfileFormDesc: "定義單集生成的規則及預設使用的發言人設定。",
noSpeakerProfilesDesc: "在設定單集簡介之前,請先建立一個發言人簡介。", noSpeakerProfilesDesc: "在設定單集簡介之前,請先建立一個發言人簡介。",
noLanguageModelsDesc: "在“模型”部分新增語言模型,以設定大綱和文稿生成。",
profileName: "簡介名稱", profileName: "簡介名稱",
profileNamePlaceholder: "例如:技術討論", profileNamePlaceholder: "例如:技術討論",
descriptionPlaceholder: "簡要說明何時使用此簡介", descriptionPlaceholder: "簡要說明何時使用此簡介",
@ -676,17 +674,13 @@ export const zhTW = {
editSpeakerProfile: "編輯發言人簡介", editSpeakerProfile: "編輯發言人簡介",
createSpeakerProfile: "建立發言人簡介", createSpeakerProfile: "建立發言人簡介",
speakerProfileFormDesc: "設定文字轉語音設定並定義最多四名發言人。", speakerProfileFormDesc: "設定文字轉語音設定並定義最多四名發言人。",
noTtsModelsAvailable: "沒有可用的文字轉語音模型",
noTtsModelsDesc: "在建立發言人簡介之前,請先在“模型”部分新增 TTS 模型。",
speakers: "發言人", speakers: "發言人",
speakersDesc: "為此簡介設定一到四種聲音。", speakersDesc: "為此簡介設定一到四種聲音。",
addSpeaker: "新增發言人", addSpeaker: "新增發言人",
speakerNumber: "發言人 {number}", speakerNumber: "發言人 {number}",
backstoryPlaceholder: "發言人的簡要傳記或背景資訊", backstoryPlaceholder: "發言人的簡要傳記或背景資訊",
personalityPlaceholder: "描述風格和語氣", personalityPlaceholder: "描述風格和語氣",
outlineProviderRequired: "必須選擇大綱提供商",
outlineModelRequired: "必須選擇大綱模型", outlineModelRequired: "必須選擇大綱模型",
transcriptProviderRequired: "必須選擇文稿提供商",
transcriptModelRequired: "必須選擇文稿模型", transcriptModelRequired: "必須選擇文稿模型",
defaultBriefingRequired: "必須填寫預設簡介", defaultBriefingRequired: "必須填寫預設簡介",
segmentsInteger: "必須是整數", segmentsInteger: "必須是整數",
@ -705,6 +699,19 @@ export const zhTW = {
retryStartedDesc: "已提交新的播客生成任務。", retryStartedDesc: "已提交新的播客生成任務。",
failedToRetry: "重試失敗", failedToRetry: "重試失敗",
errorDetails: "錯誤詳情", errorDetails: "錯誤詳情",
language: "語言",
languagePlaceholder: "選擇語言(可選)",
podcastLanguage: "播客語言",
selectOutlineModel: "選擇大綱模型",
selectTranscriptModel: "選擇轉錄模型",
voiceModel: "語音模型",
voiceModelRequired: "語音模型為必填項",
selectVoiceModel: "選擇語音模型",
perSpeakerTtsOverride: "每位發言人的TTS覆蓋可選",
useProfileDefault: "使用設定檔預設值",
setupRequired: "需要設定",
setupRequiredDesc: "部分設定檔尚未設定模型。請編輯它們以在生成播客之前選擇模型。",
notConfigured: "未設定",
}, },
settings: { settings: {
contentProcessing: "內容處理", contentProcessing: "內容處理",
@ -818,7 +825,6 @@ export const zhTW = {
embedding: "嵌入模型", embedding: "嵌入模型",
tts: "文字轉語音", tts: "文字轉語音",
stt: "語音轉文字", stt: "語音轉文字",
provider: "提供商",
apiKey: "API 密鑰", apiKey: "API 密鑰",
deleteSuccess: "模型刪除成功", deleteSuccess: "模型刪除成功",
saveSuccess: "模型儲存成功", saveSuccess: "模型儲存成功",
@ -847,9 +853,6 @@ export const zhTW = {
ttsModelDesc: "用於生成播客", ttsModelDesc: "用於生成播客",
sttModelLabel: "語音轉文字模型", sttModelLabel: "語音轉文字模型",
sttModelDesc: "用於音訊轉錄", sttModelDesc: "用於音訊轉錄",
selectProviderPlaceholder: "選擇提供商",
providerRequired: "提供商是必填項",
modelRequired: "模型是必填項",
embeddingChangeTitle: "嵌入模型變更", embeddingChangeTitle: "嵌入模型變更",
embeddingChangeConfirm: "您即將將嵌入模型從 {from} 更改為 {to}。", embeddingChangeConfirm: "您即將將嵌入模型從 {from} 更改為 {to}。",
rebuildRequired: "重要提示:需要重建索引", rebuildRequired: "重要提示:需要重建索引",

View file

@ -13,12 +13,16 @@ export interface EpisodeProfile {
name: string name: string
description: string description: string
speaker_config: string speaker_config: string
outline_provider: string outline_llm?: string | null
outline_model: string transcript_llm?: string | null
transcript_provider: string language?: string | null
transcript_model: string
default_briefing: string default_briefing: string
num_segments: number num_segments: number
// Legacy fields (app ignores, kept in DB for migration)
outline_provider?: string | null
outline_model?: string | null
transcript_provider?: string | null
transcript_model?: string | null
} }
export interface SpeakerVoiceConfig { export interface SpeakerVoiceConfig {
@ -26,15 +30,23 @@ export interface SpeakerVoiceConfig {
voice_id: string voice_id: string
backstory: string backstory: string
personality: string personality: string
voice_model?: string | null
} }
export interface SpeakerProfile { export interface SpeakerProfile {
id: string id: string
name: string name: string
description: string description: string
tts_provider: string voice_model?: string | null
tts_model: string
speakers: SpeakerVoiceConfig[] speakers: SpeakerVoiceConfig[]
// Legacy fields
tts_provider?: string | null
tts_model?: string | null
}
export interface Language {
code: string
name: string
} }
export interface PodcastEpisode { export interface PodcastEpisode {
@ -132,3 +144,13 @@ export function speakerUsageMap(
return usage return usage
} }
/** Check if a profile needs model configuration (missing required model references) */
export function needsModelSetup(profile: EpisodeProfile | SpeakerProfile): boolean {
if ('outline_llm' in profile) {
const ep = profile as EpisodeProfile
return !ep.outline_llm || !ep.transcript_llm
}
const sp = profile as SpeakerProfile
return !sp.voice_model
}

View file

@ -50,7 +50,7 @@ Both leverage connection context manager for lifecycle management and automatic
- `run_one_down()`: Rollback latest migration - `run_one_down()`: Rollback latest migration
- `AsyncMigrationManager`: Main orchestrator - `AsyncMigrationManager`: Main orchestrator
- Loads 12 up migrations + 12 down migrations (hard-coded in __init__; migrations 11-12 add credential table and model-credential link) - Loads 14 up migrations + 14 down migrations (hard-coded in __init__; migrations 11-12 add credential system, 13 adds model-credential link, 14 adds podcast model registry fields)
- `get_current_version()`: Query max version from _sbl_migrations table - `get_current_version()`: Query max version from _sbl_migrations table
- `needs_migration()`: Boolean check (current < total migrations available) - `needs_migration()`: Boolean check (current < total migrations available)
- `run_migration_up()`: Run all pending migrations with logging - `run_migration_up()`: Run all pending migrations with logging
@ -87,7 +87,7 @@ Both leverage connection context manager for lifecycle management and automatic
## Important Quirks & Gotchas ## Important Quirks & Gotchas
- **No connection pooling**: Each repo_* operation creates new connection; adequate for HTTP request-scoped operations but inefficient for bulk workloads - **No connection pooling**: Each repo_* operation creates new connection; adequate for HTTP request-scoped operations but inefficient for bulk workloads
- **Hard-coded migration files**: AsyncMigrationManager lists migrations 1-12 explicitly; adding new migration requires code change (not auto-discovery) - **Hard-coded migration files**: AsyncMigrationManager lists migrations 1-14 explicitly; adding new migration requires code change (not auto-discovery)
- **Record ID format inconsistency**: repo_update() accepts both `table:id` format and full RecordID; path handling can be subtle - **Record ID format inconsistency**: repo_update() accepts both `table:id` format and full RecordID; path handling can be subtle
- **ISO date parsing**: repo_update() parses `created` field from string to datetime if present; assumes ISO format - **ISO date parsing**: repo_update() parses `created` field from string to datetime if present; assumes ISO format
- **Timestamp overwrite risk**: repo_create() always sets new timestamps; can't preserve original created time on reimport - **Timestamp overwrite risk**: repo_create() always sets new timestamps; can't preserve original created time on reimport

View file

@ -115,6 +115,9 @@ class AsyncMigrationManager:
AsyncMigration.from_file( AsyncMigration.from_file(
"open_notebook/database/migrations/13.surrealql" "open_notebook/database/migrations/13.surrealql"
), ),
AsyncMigration.from_file(
"open_notebook/database/migrations/14.surrealql"
),
] ]
self.down_migrations = [ self.down_migrations = [
AsyncMigration.from_file( AsyncMigration.from_file(
@ -156,6 +159,9 @@ class AsyncMigrationManager:
AsyncMigration.from_file( AsyncMigration.from_file(
"open_notebook/database/migrations/13_down.surrealql" "open_notebook/database/migrations/13_down.surrealql"
), ),
AsyncMigration.from_file(
"open_notebook/database/migrations/14_down.surrealql"
),
] ]
self.runner = AsyncMigrationRunner( self.runner = AsyncMigrationRunner(
up_migrations=self.up_migrations, up_migrations=self.up_migrations,

View file

@ -0,0 +1,27 @@
-- Migration 14: Podcast profiles model registry integration
-- Adds record<model> references to replace loose provider/model strings
-- Adds language field to episode_profile
-- Adds per-speaker TTS override support
-- EPISODE PROFILE
-- Legacy fields: make optional (app ignores, preserved for data migration)
DEFINE FIELD OVERWRITE outline_provider ON TABLE episode_profile TYPE option<string>;
DEFINE FIELD OVERWRITE outline_model ON TABLE episode_profile TYPE option<string>;
DEFINE FIELD OVERWRITE transcript_provider ON TABLE episode_profile TYPE option<string>;
DEFINE FIELD OVERWRITE transcript_model ON TABLE episode_profile TYPE option<string>;
-- New fields: reference to Model registry
DEFINE FIELD IF NOT EXISTS outline_llm ON TABLE episode_profile TYPE option<record<model>>;
DEFINE FIELD IF NOT EXISTS transcript_llm ON TABLE episode_profile TYPE option<record<model>>;
DEFINE FIELD IF NOT EXISTS language ON TABLE episode_profile TYPE option<string>;
-- SPEAKER PROFILE
-- Legacy fields: make optional
DEFINE FIELD OVERWRITE tts_provider ON TABLE speaker_profile TYPE option<string>;
DEFINE FIELD OVERWRITE tts_model ON TABLE speaker_profile TYPE option<string>;
-- New field: reference to Model registry (profile-level)
DEFINE FIELD IF NOT EXISTS voice_model ON TABLE speaker_profile TYPE option<record<model>>;
-- Per-speaker TTS override
DEFINE FIELD IF NOT EXISTS speakers.*.voice_model ON TABLE speaker_profile TYPE option<record<model>>;

View file

@ -0,0 +1,20 @@
-- Migration 14 rollback: Remove model registry fields from podcast profiles
-- Remove new fields from episode_profile
REMOVE FIELD IF EXISTS outline_llm ON TABLE episode_profile;
REMOVE FIELD IF EXISTS transcript_llm ON TABLE episode_profile;
REMOVE FIELD IF EXISTS language ON TABLE episode_profile;
-- Restore episode_profile legacy fields as required strings
DEFINE FIELD OVERWRITE outline_provider ON TABLE episode_profile TYPE string;
DEFINE FIELD OVERWRITE outline_model ON TABLE episode_profile TYPE string;
DEFINE FIELD OVERWRITE transcript_provider ON TABLE episode_profile TYPE string;
DEFINE FIELD OVERWRITE transcript_model ON TABLE episode_profile TYPE string;
-- Remove new fields from speaker_profile
REMOVE FIELD IF EXISTS voice_model ON TABLE speaker_profile;
REMOVE FIELD IF EXISTS speakers.*.voice_model ON TABLE speaker_profile;
-- Restore speaker_profile legacy fields as required strings
DEFINE FIELD OVERWRITE tts_provider ON TABLE speaker_profile TYPE string;
DEFINE FIELD OVERWRITE tts_model ON TABLE speaker_profile TYPE string;

View file

@ -8,63 +8,96 @@ Encapsulates podcast metadata and configuration: speaker profiles (voice/persona
## Architecture Overview ## Architecture Overview
Two-tier profile system: Two-tier profile system using the **model registry** for AI model references:
- **SpeakerProfile**: TTS provider/model + 1-4 speaker configurations (name, voice_id, backstory, personality) - **SpeakerProfile**: `voice_model` (record<model> reference) + 1-4 speaker configurations (name, voice_id, backstory, personality). Per-speaker `voice_model` overrides supported.
- **EpisodeProfile**: Generation settings (outline/transcript models, segment count, briefing template) - **EpisodeProfile**: `outline_llm`/`transcript_llm` (record<model> references) for LLM selection, `language` field (BCP 47 locale code), segment count, briefing template.
- **PodcastEpisode**: Generated episode record linking profiles, content, and async job - **PodcastEpisode**: Generated episode record linking profiles, content, and async job.
All inherit from `ObjectModel` (SurrealDB base class with table_name and save/load). All inherit from `ObjectModel` (SurrealDB base class with table_name and save/load).
## Component Catalog ## Component Catalog
### SpeakerProfile ### models.py
- Validates 1-4 speakers with required fields: name, voice_id, backstory, personality
- Stores TTS provider/model (e.g., "elevenlabs", "openai")
- `get_by_name()` async query by profile name
- Raises ValueError on invalid speaker counts or missing fields
### EpisodeProfile #### `_resolve_model_config(model_id)` (module-level helper)
- Configures outline/transcript generation: provider, model, num_segments (3-20 validated) - Loads a Model record by ID, resolves its credential, returns `(provider, model_name, config_dict)` tuple.
- References speaker_config by name - Used by `resolve_outline_config()`, `resolve_transcript_config()`, `resolve_tts_config()`, and per-speaker TTS overrides in `podcast_commands.py`.
- Stores default_briefing template for episode generation - Falls back to `provision_provider_keys()` if no credential is linked.
- `get_by_name()` async query
### PodcastEpisode #### SpeakerProfile
- Stores episode_profile and speaker_profile as dicts (snapshots of config at generation time) - `voice_model`: Optional `record<model>` reference for TTS (replaces legacy `tts_provider`/`tts_model` strings).
- Optional audio_file path, transcript/outline dicts - Legacy fields `tts_provider`/`tts_model` kept as optional for migration compatibility.
- **Job tracking**: command field links to surreal-commands RecordID - `nullable_fields` ClassVar lists fields that may be null in the database.
- `get_job_status()` fetches async job status via surreal-commands library - Validates 1-4 speakers with required fields: name, voice_id, backstory, personality.
- `get_job_detail()` returns both status and error_message from the job (used for retry validation and UI error display) - Per-speaker `voice_model` override: individual speakers can reference a different TTS model.
- `_prepare_save_data()` ensures command field is always RecordID format for database - `_prepare_save_data()` converts `voice_model` (and per-speaker overrides) to RecordID before save.
- `resolve_tts_config()` resolves `voice_model` via `_resolve_model_config()`. Raises ValueError if not set.
- `get_by_name()` async query by profile name.
#### EpisodeProfile
- `outline_llm`/`transcript_llm`: Optional `record<model>` references (replace legacy `outline_provider`/`outline_model`/`transcript_provider`/`transcript_model` strings).
- `language`: Optional BCP 47 locale code for podcast language (e.g. `pt-BR`, `en-US`).
- Legacy fields kept as optional for migration compatibility.
- `nullable_fields` ClassVar lists fields that may be null in the database.
- `num_segments` validated between 3 and 20.
- References `speaker_config` by name.
- `_prepare_save_data()` converts `outline_llm`/`transcript_llm` to RecordID before save.
- `resolve_outline_config()` / `resolve_transcript_config()` resolve model references via `_resolve_model_config()`. Raise ValueError if not set.
- `get_by_name()` async query.
#### PodcastEpisode
- Stores episode_profile and speaker_profile as dicts (snapshots of config at generation time).
- Optional audio_file path, transcript/outline dicts.
- **Job tracking**: command field links to surreal-commands RecordID.
- `get_job_status()` fetches async job status via surreal-commands library.
- `get_job_detail()` returns both status and error_message from the job (used for retry validation and UI error display).
- `_prepare_save_data()` ensures command field is always RecordID format for database.
### migration.py
Data migration for podcast profiles: maps legacy provider/model strings to Model registry record IDs. Runs on API startup after SQL migrations (called from `api/main.py` lifespan).
- `_find_model_record()`: Finds an existing Model record matching provider + name + type.
- `_find_or_create_model()`: Finds existing Model record or auto-creates one linked to a provider credential.
- `migrate_podcast_profiles()`: Migrates all episode and speaker profiles. Idempotent -- skips profiles where new fields are already populated. Logs counts of migrated/skipped/failed profiles.
## Common Patterns ## Common Patterns
- **Profile snapshots**: episode_profile and speaker_profile stored as dicts to freeze config at generation time - **Model registry references**: Profile fields reference `record<model>` IDs instead of raw provider/model strings. Credentials are resolved at runtime via `_resolve_model_config()`.
- **Field validation**: Pydantic validators enforce constraints (segment count, speaker count, required fields) - **Profile snapshots**: episode_profile and speaker_profile stored as dicts on PodcastEpisode to freeze config at generation time.
- **Async database access**: `get_by_name()` queries via repo_query - **Field validation**: Pydantic validators enforce constraints (segment count, speaker count, required fields).
- **Job tracking**: command field delegates to surreal-commands; get_job_status() returns "unknown" on failure - **Async database access**: `get_by_name()` queries via repo_query.
- **Record ID handling**: ensure_record_id() converts string to RecordID before save - **Job tracking**: command field delegates to surreal-commands; get_job_status() returns "unknown" on failure.
- **Record ID handling**: `_prepare_save_data()` converts model ID strings to RecordID before save; `ensure_record_id()` handles both string and RecordID inputs.
- **nullable_fields ClassVar**: Declares fields that may be null/absent in the database, allowing ObjectModel to handle them during deserialization.
## Key Dependencies ## Key Dependencies
- `pydantic`: Field validators, ObjectModel inheritance - `pydantic`: Field validators, ObjectModel inheritance
- `surrealdb`: RecordID type for job references - `surrealdb`: RecordID type for job and model references
- `open_notebook.database.repository`: repo_query, ensure_record_id - `open_notebook.database.repository`: repo_query, ensure_record_id
- `open_notebook.domain.base`: ObjectModel base class - `open_notebook.domain.base`: ObjectModel base class
- `open_notebook.ai.models`: Model class (for `_resolve_model_config`)
- `open_notebook.ai.key_provider`: provision_provider_keys (fallback)
- `open_notebook.domain.credential`: Credential (for migration)
- `surreal_commands` (optional): get_command_status() for job status - `surreal_commands` (optional): get_command_status() for job status
## Important Quirks & Gotchas ## Important Quirks & Gotchas
- **Snapshot approach**: Episode/speaker profiles stored as dicts (not references), so profile updates don't retroactively affect past episodes - **Legacy fields preserved**: `tts_provider`/`tts_model` on SpeakerProfile and `outline_provider`/`outline_model`/`transcript_provider`/`transcript_model` on EpisodeProfile are kept as optional nullable fields for backward compatibility with the data migration. The app ignores them at runtime.
- **Job status resilience**: get_job_status() catches all exceptions and returns "unknown" (no error propagation) - **Snapshot approach**: Episode/speaker profiles stored as dicts (not references), so profile updates don't retroactively affect past episodes.
- **No automatic retries**: Podcast generation commands use `retry={"max_attempts": 1}` to prevent duplicate episode records on failure; retry is user-initiated via `POST /podcasts/episodes/{id}/retry` - **Job status resilience**: get_job_status() catches all exceptions and returns "unknown" (no error propagation).
- **validate_speakers executes late**: Validators run at instantiation; bulk inserts may not trigger full validation - **No automatic retries**: Podcast generation commands use `retry={"max_attempts": 1}` to prevent duplicate episode records on failure; retry is user-initiated via `POST /podcasts/episodes/{id}/retry`.
- **RecordID coercion**: ensure_record_id() handles both string and RecordID inputs; command field parsed during deserialization - **validate_speakers executes late**: Validators run at instantiation; bulk inserts may not trigger full validation.
- **No cascade delete**: Removing a profile doesn't cascade to episodes using it - **RecordID coercion**: `_prepare_save_data()` converts model ID strings to RecordID; command field parsed during deserialization.
- **No cascade delete**: Removing a profile doesn't cascade to episodes using it.
- **Migration is idempotent**: `migrate_podcast_profiles()` skips profiles that already have new fields populated. Safe to run multiple times.
- **Migration auto-creates models**: If a legacy provider/model string has no matching Model record but a credential exists for that provider, the migration auto-creates a Model record linked to the credential.
## How to Extend ## How to Extend
1. **Add new speaker field**: Add to required_fields list in validate_speakers() 1. **Add new speaker field**: Add to required_fields list in validate_speakers()
2. **Add episode config field**: Validate in EpisodeProfile, update briefing generation code 2. **Add episode config field**: Validate in EpisodeProfile, update briefing generation code; add to nullable_fields if optional
3. **Add job metadata**: Extend PodcastEpisode with new fields (e.g., progress tracking) 3. **Add job metadata**: Extend PodcastEpisode with new fields (e.g., progress tracking)
4. **Change job provider**: Replace surreal-commands with alternative job queue library; update get_job_status() 4. **Change job provider**: Replace surreal-commands with alternative job queue library; update get_job_status()
5. **Add new model reference field**: Add field, add to nullable_fields, add RecordID conversion in `_prepare_save_data()`, add resolve method using `_resolve_model_config()`

View file

@ -0,0 +1,189 @@
"""
Data migration for podcast profiles: maps legacy provider/model strings
to Model registry record IDs.
Runs on API startup after SQL migrations. Idempotent - skips profiles
that already have the new fields populated.
"""
from loguru import logger
from open_notebook.database.repository import repo_query
async def _find_model_record(
provider: str, model_name: str, model_type: str
) -> str | None:
"""Find an existing Model record matching provider + name + type."""
results = await repo_query(
"SELECT * FROM model WHERE provider = $provider AND name = $name AND type = $type",
{"provider": provider, "name": model_name, "type": model_type},
)
if results:
return str(results[0]["id"])
return None
async def _find_or_create_model(
provider: str, model_name: str, model_type: str
) -> str | None:
"""Find existing Model record or auto-create one linked to provider credential."""
# Try exact match first
model_id = await _find_model_record(provider, model_name, model_type)
if model_id:
return model_id
# Try to find a credential for this provider and auto-create the model
from open_notebook.domain.credential import Credential
credentials = await Credential.get_by_provider(provider)
if not credentials:
logger.warning(
f"No credential found for provider '{provider}'. "
f"Cannot auto-create model '{model_name}'. Profile needs manual migration."
)
return None
# Use the first credential for the provider
credential = credentials[0]
from open_notebook.ai.models import Model
model = Model(
name=model_name,
provider=provider,
type=model_type,
credential=str(credential.id),
)
await model.save()
logger.info(
f"Auto-created model '{model_name}' ({model_type}) "
f"linked to credential '{credential.name}'"
)
return str(model.id)
async def migrate_podcast_profiles() -> None:
"""Migrate episode and speaker profiles from legacy strings to Model record IDs.
Idempotent: skips profiles where new fields are already populated.
"""
logger.info("Starting podcast profile data migration...")
ep_migrated = 0
ep_skipped = 0
ep_failed = 0
# Migrate EpisodeProfiles
episode_profiles = await repo_query("SELECT * FROM episode_profile")
for raw in episode_profiles:
profile_name = raw.get("name", raw.get("id", "unknown"))
try:
outline_llm = raw.get("outline_llm")
transcript_llm = raw.get("transcript_llm")
needs_outline = not outline_llm
needs_transcript = not transcript_llm
if not needs_outline and not needs_transcript:
ep_skipped += 1
continue
updates = {}
if needs_outline:
outline_provider = raw.get("outline_provider")
outline_model = raw.get("outline_model")
if outline_provider and outline_model:
model_id = await _find_or_create_model(
outline_provider, outline_model, "language"
)
if model_id:
from open_notebook.database.repository import ensure_record_id
updates["outline_llm"] = ensure_record_id(model_id)
if needs_transcript:
transcript_provider = raw.get("transcript_provider")
transcript_model = raw.get("transcript_model")
if transcript_provider and transcript_model:
model_id = await _find_or_create_model(
transcript_provider, transcript_model, "language"
)
if model_id:
from open_notebook.database.repository import ensure_record_id
updates["transcript_llm"] = ensure_record_id(model_id)
if updates:
from open_notebook.database.repository import repo_update
await repo_update("episode_profile", str(raw["id"]), updates)
ep_migrated += 1
logger.info(
f"Migrated episode profile '{profile_name}': {list(updates.keys())}"
)
else:
ep_failed += 1
logger.warning(
f"Could not migrate episode profile '{profile_name}': "
"no matching models found"
)
except Exception as e:
ep_failed += 1
logger.error(f"Failed to migrate episode profile '{profile_name}': {e}")
# Migrate SpeakerProfiles
sp_migrated = 0
sp_skipped = 0
sp_failed = 0
speaker_profiles = await repo_query("SELECT * FROM speaker_profile")
for raw in speaker_profiles:
profile_name = raw.get("name", raw.get("id", "unknown"))
try:
voice_model = raw.get("voice_model")
if voice_model:
sp_skipped += 1
continue
tts_provider = raw.get("tts_provider")
tts_model = raw.get("tts_model")
if not tts_provider or not tts_model:
sp_failed += 1
logger.warning(
f"Speaker profile '{profile_name}' has no legacy TTS config"
)
continue
model_id = await _find_or_create_model(
tts_provider, tts_model, "text_to_speech"
)
if model_id:
from open_notebook.database.repository import ensure_record_id, repo_update
await repo_update(
"speaker_profile",
str(raw["id"]),
{"voice_model": ensure_record_id(model_id)},
)
sp_migrated += 1
logger.info(f"Migrated speaker profile '{profile_name}'")
else:
sp_failed += 1
logger.warning(
f"Could not migrate speaker profile '{profile_name}': "
"no matching model found"
)
except Exception as e:
sp_failed += 1
logger.error(f"Failed to migrate speaker profile '{profile_name}': {e}")
logger.info(
f"Podcast profile migration complete. "
f"Episodes: {ep_migrated} migrated, {ep_skipped} skipped, {ep_failed} failed. "
f"Speakers: {sp_migrated} migrated, {sp_skipped} skipped, {sp_failed} failed."
)

View file

@ -1,5 +1,6 @@
from typing import Any, ClassVar, Dict, List, Optional, Union from typing import Any, ClassVar, Dict, List, Optional, Tuple, Union
from loguru import logger
from pydantic import ConfigDict, Field, field_validator from pydantic import ConfigDict, Field, field_validator
from surrealdb import RecordID from surrealdb import RecordID
@ -7,6 +8,27 @@ from open_notebook.database.repository import ensure_record_id, repo_query
from open_notebook.domain.base import ObjectModel from open_notebook.domain.base import ObjectModel
async def _resolve_model_config(model_id: str) -> Tuple[str, str, dict]:
"""Load Model record, resolve credential -> (provider, model_name, config_dict).
Used by resolve_outline_config, resolve_transcript_config, resolve_tts_config,
and per-speaker TTS overrides.
"""
from open_notebook.ai.models import Model
model = await Model.get(model_id)
config: dict = {}
if model.credential:
credential = await model.get_credential_obj()
if credential:
config = credential.to_esperanto_config()
if not config:
from open_notebook.ai.key_provider import provision_provider_keys
await provision_provider_keys(model.provider)
return (model.provider, model.name, config)
class EpisodeProfile(ObjectModel): class EpisodeProfile(ObjectModel):
""" """
Episode Profile - Simplified podcast configuration. Episode Profile - Simplified podcast configuration.
@ -14,16 +36,46 @@ class EpisodeProfile(ObjectModel):
""" """
table_name: ClassVar[str] = "episode_profile" table_name: ClassVar[str] = "episode_profile"
nullable_fields: ClassVar[set[str]] = {
"description",
"outline_provider",
"outline_model",
"transcript_provider",
"transcript_model",
"outline_llm",
"transcript_llm",
"language",
}
name: str = Field(..., description="Unique profile name") name: str = Field(..., description="Unique profile name")
description: Optional[str] = Field(None, description="Profile description") description: Optional[str] = Field(None, description="Profile description")
speaker_config: str = Field(..., description="Reference to speaker profile name") speaker_config: str = Field(..., description="Reference to speaker profile name")
outline_provider: str = Field(..., description="AI provider for outline generation")
outline_model: str = Field(..., description="AI model for outline generation") # Legacy fields (kept for migration, app ignores)
transcript_provider: str = Field( outline_provider: Optional[str] = Field(
..., description="AI provider for transcript generation" None, description="[Legacy] AI provider for outline generation"
) )
transcript_model: str = Field(..., description="AI model for transcript generation") outline_model: Optional[str] = Field(
None, description="[Legacy] AI model for outline generation"
)
transcript_provider: Optional[str] = Field(
None, description="[Legacy] AI provider for transcript generation"
)
transcript_model: Optional[str] = Field(
None, description="[Legacy] AI model for transcript generation"
)
# New fields: Model registry references
outline_llm: Optional[str] = Field(
None, description="Model record ID for outline generation"
)
transcript_llm: Optional[str] = Field(
None, description="Model record ID for transcript generation"
)
language: Optional[str] = Field(
None, description="Podcast language (BCP 47 locale code, e.g. pt-BR, en-US)"
)
default_briefing: str = Field(..., description="Default briefing template") default_briefing: str = Field(..., description="Default briefing template")
num_segments: int = Field(default=5, description="Number of podcast segments") num_segments: int = Field(default=5, description="Number of podcast segments")
@ -34,6 +86,32 @@ class EpisodeProfile(ObjectModel):
raise ValueError("Number of segments must be between 3 and 20") raise ValueError("Number of segments must be between 3 and 20")
return v return v
def _prepare_save_data(self) -> dict:
data = super()._prepare_save_data()
if data.get("outline_llm"):
data["outline_llm"] = ensure_record_id(data["outline_llm"])
if data.get("transcript_llm"):
data["transcript_llm"] = ensure_record_id(data["transcript_llm"])
return data
async def resolve_outline_config(self) -> Tuple[str, str, dict]:
"""Resolve outline model -> (provider, model_name, config_dict)"""
if not self.outline_llm:
raise ValueError(
f"Episode profile '{self.name}' has no outline model configured. "
"Please update the profile to select an outline model."
)
return await _resolve_model_config(self.outline_llm)
async def resolve_transcript_config(self) -> Tuple[str, str, dict]:
"""Resolve transcript model -> (provider, model_name, config_dict)"""
if not self.transcript_llm:
raise ValueError(
f"Episode profile '{self.name}' has no transcript model configured. "
"Please update the profile to select a transcript model."
)
return await _resolve_model_config(self.transcript_llm)
@classmethod @classmethod
async def get_by_name(cls, name: str) -> Optional["EpisodeProfile"]: async def get_by_name(cls, name: str) -> Optional["EpisodeProfile"]:
"""Get episode profile by name""" """Get episode profile by name"""
@ -52,13 +130,27 @@ class SpeakerProfile(ObjectModel):
""" """
table_name: ClassVar[str] = "speaker_profile" table_name: ClassVar[str] = "speaker_profile"
nullable_fields: ClassVar[set[str]] = {
"description",
"tts_provider",
"tts_model",
"voice_model",
}
name: str = Field(..., description="Unique profile name") name: str = Field(..., description="Unique profile name")
description: Optional[str] = Field(None, description="Profile description") description: Optional[str] = Field(None, description="Profile description")
tts_provider: str = Field(
..., description="TTS provider (openai, elevenlabs, etc.)" # Legacy fields (kept for migration, app ignores)
tts_provider: Optional[str] = Field(
None, description="[Legacy] TTS provider (openai, elevenlabs, etc.)"
) )
tts_model: str = Field(..., description="TTS model name") tts_model: Optional[str] = Field(None, description="[Legacy] TTS model name")
# New field: Model registry reference
voice_model: Optional[str] = Field(
None, description="Model record ID for TTS"
)
speakers: List[Dict[str, Any]] = Field( speakers: List[Dict[str, Any]] = Field(
..., description="Array of speaker configurations" ..., description="Array of speaker configurations"
) )
@ -76,6 +168,26 @@ class SpeakerProfile(ObjectModel):
raise ValueError(f"Speaker missing required field: {field}") raise ValueError(f"Speaker missing required field: {field}")
return v return v
def _prepare_save_data(self) -> dict:
data = super()._prepare_save_data()
if data.get("voice_model"):
data["voice_model"] = ensure_record_id(data["voice_model"])
# Handle per-speaker voice_model overrides
if data.get("speakers"):
for speaker in data["speakers"]:
if speaker.get("voice_model"):
speaker["voice_model"] = ensure_record_id(speaker["voice_model"])
return data
async def resolve_tts_config(self) -> Tuple[str, str, dict]:
"""Resolve TTS model -> (provider, model_name, config_dict)"""
if not self.voice_model:
raise ValueError(
f"Speaker profile '{self.name}' has no voice model configured. "
"Please update the profile to select a voice model."
)
return await _resolve_model_config(self.voice_model)
@classmethod @classmethod
async def get_by_name(cls, name: str) -> Optional["SpeakerProfile"]: async def get_by_name(cls, name: str) -> Optional["SpeakerProfile"]:
"""Get speaker profile by name""" """Get speaker profile by name"""

View file

@ -1,6 +1,6 @@
[project] [project]
name = "open-notebook" name = "open-notebook"
version = "1.7.4" version = "1.8.0"
description = "An open source implementation of a research assistant, inspired by Google Notebook LM" description = "An open source implementation of a research assistant, inspired by Google Notebook LM"
authors = [ authors = [
{name = "Luis Novo", email = "lfnovo@gmail.com"} {name = "Luis Novo", email = "lfnovo@gmail.com"}
@ -39,6 +39,8 @@ dependencies = [
"podcast-creator>=0.11.2,<1", "podcast-creator>=0.11.2,<1",
"surreal-commands>=1.3.1,<2", "surreal-commands>=1.3.1,<2",
"numpy>=2.4.1", "numpy>=2.4.1",
"pycountry>=26.2.16",
"babel>=2.18.0",
] ]
[tool.setuptools] [tool.setuptools]

22
uv.lock
View file

@ -210,6 +210,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/54/51/321e821856452f7386c4e9df866f196720b1ad0c5ea1623ea7399969ae3b/authlib-1.6.6-py2.py3-none-any.whl", hash = "sha256:7d9e9bc535c13974313a87f53e8430eb6ea3d1cf6ae4f6efcd793f2e949143fd", size = 244005, upload-time = "2025-12-12T08:01:40.209Z" }, { url = "https://files.pythonhosted.org/packages/54/51/321e821856452f7386c4e9df866f196720b1ad0c5ea1623ea7399969ae3b/authlib-1.6.6-py2.py3-none-any.whl", hash = "sha256:7d9e9bc535c13974313a87f53e8430eb6ea3d1cf6ae4f6efcd793f2e949143fd", size = 244005, upload-time = "2025-12-12T08:01:40.209Z" },
] ]
[[package]]
name = "babel"
version = "2.18.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/7d/b2/51899539b6ceeeb420d40ed3cd4b7a40519404f9baf3d4ac99dc413a834b/babel-2.18.0.tar.gz", hash = "sha256:b80b99a14bd085fcacfa15c9165f651fbb3406e66cc603abf11c5750937c992d", size = 9959554, upload-time = "2026-02-01T12:30:56.078Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/77/f5/21d2de20e8b8b0408f0681956ca2c69f1320a3848ac50e6e7f39c6159675/babel-2.18.0-py3-none-any.whl", hash = "sha256:e2b422b277c2b9a9630c1d7903c2a00d0830c409c59ac8cae9081c92f1aeba35", size = 10196845, upload-time = "2026-02-01T12:30:53.445Z" },
]
[[package]] [[package]]
name = "backports-tarfile" name = "backports-tarfile"
version = "1.2.0" version = "1.2.0"
@ -2099,6 +2108,7 @@ version = "1.7.4"
source = { editable = "." } source = { editable = "." }
dependencies = [ dependencies = [
{ name = "ai-prompter" }, { name = "ai-prompter" },
{ name = "babel" },
{ name = "content-core" }, { name = "content-core" },
{ name = "esperanto" }, { name = "esperanto" },
{ name = "fastapi" }, { name = "fastapi" },
@ -2117,6 +2127,7 @@ dependencies = [
{ name = "loguru" }, { name = "loguru" },
{ name = "numpy" }, { name = "numpy" },
{ name = "podcast-creator" }, { name = "podcast-creator" },
{ name = "pycountry" },
{ name = "pydantic" }, { name = "pydantic" },
{ name = "python-dotenv" }, { name = "python-dotenv" },
{ name = "surreal-commands" }, { name = "surreal-commands" },
@ -2148,6 +2159,7 @@ dev = [
[package.metadata] [package.metadata]
requires-dist = [ requires-dist = [
{ name = "ai-prompter", specifier = ">=0.3,<1" }, { name = "ai-prompter", specifier = ">=0.3,<1" },
{ name = "babel", specifier = ">=2.18.0" },
{ name = "content-core", specifier = ">=1.14.1,<2" }, { name = "content-core", specifier = ">=1.14.1,<2" },
{ name = "esperanto", specifier = ">=2.19.3,<3" }, { name = "esperanto", specifier = ">=2.19.3,<3" },
{ name = "fastapi", specifier = ">=0.104.0" }, { name = "fastapi", specifier = ">=0.104.0" },
@ -2170,6 +2182,7 @@ requires-dist = [
{ name = "numpy", specifier = ">=2.4.1" }, { name = "numpy", specifier = ">=2.4.1" },
{ name = "podcast-creator", specifier = ">=0.11.2,<1" }, { name = "podcast-creator", specifier = ">=0.11.2,<1" },
{ name = "pre-commit", marker = "extra == 'dev'", specifier = ">=4.0.1" }, { name = "pre-commit", marker = "extra == 'dev'", specifier = ">=4.0.1" },
{ name = "pycountry", specifier = ">=26.2.16" },
{ name = "pydantic", specifier = ">=2.9.2" }, { name = "pydantic", specifier = ">=2.9.2" },
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0.0" }, { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0.0" },
{ name = "python-dotenv", specifier = ">=1.0.1" }, { name = "python-dotenv", specifier = ">=1.0.1" },
@ -2725,6 +2738,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/47/8d/d529b5d697919ba8c11ad626e835d4039be708a35b0d22de83a269a6682c/pyasn1_modules-0.4.2-py3-none-any.whl", hash = "sha256:29253a9207ce32b64c3ac6600edc75368f98473906e8fd1043bd6b5b1de2c14a", size = 181259, upload-time = "2025-03-28T02:41:19.028Z" }, { url = "https://files.pythonhosted.org/packages/47/8d/d529b5d697919ba8c11ad626e835d4039be708a35b0d22de83a269a6682c/pyasn1_modules-0.4.2-py3-none-any.whl", hash = "sha256:29253a9207ce32b64c3ac6600edc75368f98473906e8fd1043bd6b5b1de2c14a", size = 181259, upload-time = "2025-03-28T02:41:19.028Z" },
] ]
[[package]]
name = "pycountry"
version = "26.2.16"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/de/1d/061b9e7a48b85cfd69f33c33d2ef784a531c359399ad764243399673c8f5/pycountry-26.2.16.tar.gz", hash = "sha256:5b6027d453fcd6060112b951dd010f01f168b51b4bf8a1f1fc8c95c8d94a0801", size = 7711342, upload-time = "2026-02-17T03:42:52.367Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/9c/42/7703bd45b62fecd44cd7d3495423097e2f7d28bc2e99e7c1af68892ab157/pycountry-26.2.16-py3-none-any.whl", hash = "sha256:115c4baf7cceaa30f59a4694d79483c9167dbce7a9de4d3d571c5f3ea77c305a", size = 8044600, upload-time = "2026-02-17T03:42:49.777Z" },
]
[[package]] [[package]]
name = "pycparser" name = "pycparser"
version = "3.0" version = "3.0"