Compare commits
69 Commits
codex/ensu
...
dependabot
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a2163c33aa | ||
|
|
4fbbe9c8b4 | ||
|
|
3f2d9104d9 | ||
|
|
d34dc651b1 | ||
|
|
0d2d9b220e | ||
|
|
92ac410707 | ||
|
|
63bb937796 | ||
|
|
c52b1eabc9 | ||
|
|
746a5eeeb9 | ||
|
|
d06ab77e60 | ||
|
|
f737b24b49 | ||
|
|
4c206293b1 | ||
|
|
35fd700b22 | ||
|
|
49e0ee8e9e | ||
|
|
edd92ec85b | ||
|
|
cd06c6aaa8 | ||
|
|
9f0298725a | ||
|
|
971b4362c5 | ||
|
|
5ad0f13482 | ||
|
|
7f626d47b4 | ||
|
|
92bcd27004 | ||
|
|
bf6cdf1109 | ||
|
|
08e51f76fa | ||
|
|
dee4387b0b | ||
|
|
c7013a71df | ||
|
|
5ac1b9439d | ||
|
|
bf980ab89b | ||
|
|
45aefd0590 | ||
|
|
f53b53a543 | ||
|
|
d28daca2e1 | ||
|
|
2c3fe33c75 | ||
|
|
dd1e398fa2 | ||
|
|
dfccf53d18 | ||
|
|
9d04ffb63a | ||
|
|
004506cf9a | ||
|
|
11966cf341 | ||
|
|
a0efdb5001 | ||
|
|
8b8730ae9f | ||
|
|
66faff9051 | ||
|
|
f0b78f5cbe | ||
|
|
43c6ceab2f | ||
|
|
92bbe1d878 | ||
|
|
636989f75b | ||
|
|
5706b85a4e | ||
|
|
3a92c4af1a | ||
|
|
2a41e94c07 | ||
|
|
27c167ebe8 | ||
|
|
e3ba7893ca | ||
|
|
b54c2978c3 | ||
|
|
92cbd682a5 | ||
|
|
6555a722d3 | ||
|
|
cbcb896d24 | ||
|
|
ef7874dcdc | ||
|
|
e64aea484f | ||
|
|
8828e982f8 | ||
|
|
4e0f176842 | ||
|
|
bbb46ca9d1 | ||
|
|
d1ff406d03 | ||
|
|
643e9ad2f3 | ||
|
|
cadcb8077d | ||
|
|
2b11814fb8 | ||
|
|
5965e123b9 | ||
|
|
b93a4d2a67 | ||
|
|
c652c0d149 | ||
|
|
6596a0515a | ||
|
|
4d948e0222 | ||
|
|
e8e2a7fea0 | ||
|
|
ec9d2f922e | ||
|
|
af5a6e0ee3 |
23
.github/workflows/lint-forced-user-id.yml
vendored
Normal file
23
.github/workflows/lint-forced-user-id.yml
vendored
Normal file
@@ -0,0 +1,23 @@
|
||||
name: Lint Forced User ID Patterns
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
jobs:
|
||||
lint-forced-user-id:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.11"
|
||||
|
||||
- name: Check for forced/hardcoded user_id patterns
|
||||
run: python backend/scripts/check_forced_user_id_patterns.py
|
||||
43
artifacts/podcast_billing_sequence_report.json
Normal file
43
artifacts/podcast_billing_sequence_report.json
Normal file
@@ -0,0 +1,43 @@
|
||||
{
|
||||
"preflight": {
|
||||
"success": true,
|
||||
"can_proceed": true,
|
||||
"estimated_cost": 0.3
|
||||
},
|
||||
"operations": {
|
||||
"analysis_title_suggestions": [
|
||||
"AI Agents in 2026",
|
||||
"Ship Faster with AI",
|
||||
"Startup AI Playbook"
|
||||
],
|
||||
"research_provider": "exa",
|
||||
"research_cost": 0.015,
|
||||
"video_task_status": "completed"
|
||||
},
|
||||
"dashboard_deltas": {
|
||||
"total_calls_before": 1,
|
||||
"total_calls_after": 5,
|
||||
"delta_calls": 4,
|
||||
"total_cost_before": 0.09,
|
||||
"total_cost_after": 0.488,
|
||||
"delta_cost": 0.398,
|
||||
"projected_monthly_cost_before": 0.09,
|
||||
"projected_monthly_cost_after": 0.49,
|
||||
"delta_projected_monthly_cost": 0.4
|
||||
},
|
||||
"provider_cost_deltas": {
|
||||
"exa": 0.005,
|
||||
"huggingface": 0.003,
|
||||
"wavespeed": 0.39
|
||||
},
|
||||
"acceptance": {
|
||||
"passed": true,
|
||||
"criteria": {
|
||||
"preflight_success": true,
|
||||
"usage_cost_incremented": true,
|
||||
"usage_call_incremented": true,
|
||||
"projection_incremented": true,
|
||||
"provider_delta_present": true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,14 @@ from .rate_limiter import RateLimiter
|
||||
from .frontend_serving import FrontendServing
|
||||
from .router_manager import RouterManager
|
||||
from .onboarding_manager import OnboardingManager
|
||||
from .feature_runtime import (
|
||||
get_active_profiles,
|
||||
get_enabled_groups,
|
||||
get_enabled_optional_services,
|
||||
get_enabled_routers,
|
||||
get_enabled_startup_hooks,
|
||||
is_enabled,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
'DependencyManager',
|
||||
@@ -22,5 +30,11 @@ __all__ = [
|
||||
'RateLimiter',
|
||||
'FrontendServing',
|
||||
'RouterManager',
|
||||
'OnboardingManager'
|
||||
'OnboardingManager',
|
||||
'get_active_profiles',
|
||||
'get_enabled_groups',
|
||||
'get_enabled_optional_services',
|
||||
'get_enabled_routers',
|
||||
'get_enabled_startup_hooks',
|
||||
'is_enabled'
|
||||
]
|
||||
|
||||
86
backend/alwrity_utils/feature_profiles.py
Normal file
86
backend/alwrity_utils/feature_profiles.py
Normal file
@@ -0,0 +1,86 @@
|
||||
"""Feature profile parsing and expansion logic."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from typing import Iterable, Tuple
|
||||
|
||||
from .feature_registry import FEATURE_GROUPS, PROFILE_GROUP_MAP
|
||||
|
||||
|
||||
ENV_ENABLED_FEATURES = "ALWRITY_ENABLED_FEATURES"
|
||||
DEFAULT_FEATURES = "all"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ExpandedFeatureProfile:
|
||||
"""Expanded profile data used by runtime helpers."""
|
||||
|
||||
profiles: Tuple[str, ...]
|
||||
groups: Tuple[str, ...]
|
||||
|
||||
|
||||
class UnknownFeatureProfileError(ValueError):
|
||||
"""Raised when ALWRITY_ENABLED_FEATURES contains unknown feature values."""
|
||||
|
||||
|
||||
def _get_env_value() -> str:
|
||||
"""Get the enabled features value from environment."""
|
||||
return os.getenv(ENV_ENABLED_FEATURES) or DEFAULT_FEATURES
|
||||
|
||||
|
||||
def _normalize_values(raw_value: str | None) -> Tuple[str, ...]:
|
||||
if not raw_value or not raw_value.strip():
|
||||
return (DEFAULT_FEATURES,)
|
||||
|
||||
normalized = tuple(
|
||||
value.strip().lower()
|
||||
for value in raw_value.split(",")
|
||||
if value.strip()
|
||||
)
|
||||
return normalized or (DEFAULT_FEATURES,)
|
||||
|
||||
|
||||
def parse_feature_profiles(raw_value: str | None = None) -> Tuple[str, ...]:
|
||||
"""Parse and validate feature names from env/raw input.
|
||||
|
||||
Supports comma-separated feature names, e.g. `podcast,core`.
|
||||
Raises UnknownFeatureProfileError when any feature is not registered.
|
||||
"""
|
||||
|
||||
selected_profiles = _normalize_values(raw_value if raw_value is not None else _get_env_value())
|
||||
|
||||
unknown = sorted({profile for profile in selected_profiles if profile not in PROFILE_GROUP_MAP and profile not in FEATURE_GROUPS})
|
||||
if unknown:
|
||||
supported = ", ".join(sorted(set(PROFILE_GROUP_MAP.keys()) | set(FEATURE_GROUPS.keys())))
|
||||
unknown_display = ", ".join(unknown)
|
||||
raise UnknownFeatureProfileError(
|
||||
f"Unknown {ENV_ENABLED_FEATURES} value(s): {unknown_display}. Supported: {supported}."
|
||||
)
|
||||
|
||||
return selected_profiles
|
||||
|
||||
|
||||
def _dedupe_stable(items: Iterable[str]) -> Tuple[str, ...]:
|
||||
return tuple(dict.fromkeys(items))
|
||||
|
||||
|
||||
def expand_profiles(profiles: Tuple[str, ...]) -> ExpandedFeatureProfile:
|
||||
"""Expand profile names into a deduplicated group list."""
|
||||
|
||||
# Handle "all" specially - include all groups
|
||||
if "all" in profiles:
|
||||
return ExpandedFeatureProfile(profiles=("all",), groups=tuple(FEATURE_GROUPS.keys()))
|
||||
|
||||
# Otherwise expand via PROFILE_GROUP_MAP
|
||||
groups = _dedupe_stable(
|
||||
group
|
||||
for profile in profiles
|
||||
for group in PROFILE_GROUP_MAP.get(profile, (profile,))
|
||||
)
|
||||
|
||||
# Include FEATURE_GROUPS keys directly
|
||||
all_groups = _dedupe_stable(list(groups) + [g for g in groups if g in FEATURE_GROUPS])
|
||||
|
||||
return ExpandedFeatureProfile(profiles=profiles, groups=all_groups)
|
||||
63
backend/alwrity_utils/feature_registry.py
Normal file
63
backend/alwrity_utils/feature_registry.py
Normal file
@@ -0,0 +1,63 @@
|
||||
"""Feature registry for profile-based capability toggles.
|
||||
|
||||
This module stores normalized feature-group definitions used by the
|
||||
feature profile runtime.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Dict, Tuple
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class FeatureGroup:
|
||||
"""Single feature group and the capabilities it enables."""
|
||||
|
||||
routers: Tuple[str, ...] = ()
|
||||
startup_hooks: Tuple[str, ...] = ()
|
||||
optional_services: Tuple[str, ...] = ()
|
||||
features: Tuple[str, ...] = field(default_factory=tuple)
|
||||
|
||||
|
||||
FEATURE_GROUPS: Dict[str, FeatureGroup] = {
|
||||
"core": FeatureGroup(
|
||||
features=("core", "health", "onboarding", "research"),
|
||||
routers=(
|
||||
"api.component_logic:router",
|
||||
"api.subscription:router",
|
||||
"api.onboarding_utils.step3_routes:router",
|
||||
"api.research.router:router",
|
||||
),
|
||||
startup_hooks=(
|
||||
"services.database:init_database",
|
||||
),
|
||||
optional_services=(
|
||||
"services.scheduler:get_scheduler",
|
||||
),
|
||||
),
|
||||
"podcast": FeatureGroup(
|
||||
features=("podcast",),
|
||||
routers=("api.podcast.router:router",),
|
||||
),
|
||||
"youtube": FeatureGroup(
|
||||
features=("youtube",),
|
||||
routers=("api.youtube.router:router",),
|
||||
),
|
||||
"content_planning": FeatureGroup(
|
||||
features=("content_planning", "strategy_copilot"),
|
||||
routers=(
|
||||
"api.content_planning.api.router:router",
|
||||
"api.content_planning.strategy_copilot:router",
|
||||
),
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
PROFILE_GROUP_MAP: Dict[str, Tuple[str, ...]] = {
|
||||
"all": tuple(FEATURE_GROUPS.keys()),
|
||||
"core": ("core",),
|
||||
"podcast": ("core", "podcast"),
|
||||
"youtube": ("core", "youtube"),
|
||||
"planning": ("core", "content_planning"),
|
||||
}
|
||||
71
backend/alwrity_utils/feature_runtime.py
Normal file
71
backend/alwrity_utils/feature_runtime.py
Normal file
@@ -0,0 +1,71 @@
|
||||
"""Runtime helpers for profile-driven feature toggles."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from functools import lru_cache
|
||||
from typing import Tuple
|
||||
|
||||
from .feature_profiles import expand_profiles, parse_feature_profiles
|
||||
from .feature_registry import FEATURE_GROUPS
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def _runtime_state() -> dict[str, Tuple[str, ...]]:
|
||||
profiles = parse_feature_profiles()
|
||||
expanded = expand_profiles(profiles)
|
||||
|
||||
routers = []
|
||||
startup_hooks = []
|
||||
optional_services = []
|
||||
enabled_features = set(expanded.groups)
|
||||
|
||||
for group in expanded.groups:
|
||||
feature_group = FEATURE_GROUPS[group]
|
||||
routers.extend(feature_group.routers)
|
||||
startup_hooks.extend(feature_group.startup_hooks)
|
||||
optional_services.extend(feature_group.optional_services)
|
||||
enabled_features.update(feature_group.features)
|
||||
|
||||
return {
|
||||
"profiles": expanded.profiles,
|
||||
"groups": expanded.groups,
|
||||
"routers": tuple(dict.fromkeys(routers)),
|
||||
"startup_hooks": tuple(dict.fromkeys(startup_hooks)),
|
||||
"optional_services": tuple(dict.fromkeys(optional_services)),
|
||||
"features": tuple(sorted(enabled_features)),
|
||||
}
|
||||
|
||||
|
||||
def get_active_profiles() -> Tuple[str, ...]:
|
||||
"""Return validated active profile names."""
|
||||
return _runtime_state()["profiles"]
|
||||
|
||||
|
||||
def get_enabled_groups() -> Tuple[str, ...]:
|
||||
"""Return resolved feature-group names."""
|
||||
return _runtime_state()["groups"]
|
||||
|
||||
|
||||
def get_enabled_routers() -> Tuple[str, ...]:
|
||||
"""Return enabled router import targets in `module:attribute` format."""
|
||||
return _runtime_state()["routers"]
|
||||
|
||||
|
||||
def get_enabled_startup_hooks() -> Tuple[str, ...]:
|
||||
"""Return enabled startup hook import targets in `module:attribute` format."""
|
||||
return _runtime_state()["startup_hooks"]
|
||||
|
||||
|
||||
def get_enabled_optional_services() -> Tuple[str, ...]:
|
||||
"""Return enabled optional service import targets in `module:attribute` format."""
|
||||
return _runtime_state()["optional_services"]
|
||||
|
||||
|
||||
def is_enabled(feature: str) -> bool:
|
||||
"""Return True when a feature/group name is enabled by active profiles."""
|
||||
return feature.strip().lower() in _runtime_state()["features"]
|
||||
|
||||
|
||||
def reset_feature_runtime_cache() -> None:
|
||||
"""Clear runtime cache (useful for tests)."""
|
||||
_runtime_state.cache_clear()
|
||||
@@ -3,10 +3,73 @@ Router Manager Module
|
||||
Handles FastAPI router inclusion and management.
|
||||
"""
|
||||
|
||||
from importlib import import_module
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
import os
|
||||
|
||||
from fastapi import FastAPI
|
||||
from loguru import logger
|
||||
from typing import List, Dict, Any, Optional
|
||||
import os
|
||||
|
||||
|
||||
CORE_ROUTER_REGISTRY = [
|
||||
{"name": "component_logic", "module": "api.component_logic", "attr": "router", "features": {"all", "core"}},
|
||||
{"name": "subscription", "module": "api.subscription", "attr": "router", "features": {"all", "core", "podcast", "blog-writer", "youtube"}},
|
||||
{"name": "step3_research", "module": "api.onboarding_utils.step3_routes", "attr": "router", "features": {"all", "core"}},
|
||||
{"name": "step4_assets", "module": "api.onboarding_utils.step4_asset_routes", "attr": "router", "features": {"all", "core"}},
|
||||
{"name": "step4_persona", "module": "api.onboarding_utils.step4_persona_routes_optimized", "attr": "router", "features": {"all", "core"}},
|
||||
{"name": "gsc_auth", "module": "routers.gsc_auth", "attr": "router", "features": {"all", "core", "seo"}},
|
||||
{"name": "wordpress_oauth", "module": "routers.wordpress_oauth", "attr": "router", "features": {"all", "core"}},
|
||||
{"name": "bing_oauth", "module": "routers.bing_oauth", "attr": "router", "features": {"all", "core"}},
|
||||
{"name": "bing_analytics", "module": "routers.bing_analytics", "attr": "router", "features": {"all", "core"}},
|
||||
{"name": "bing_analytics_storage", "module": "routers.bing_analytics_storage", "attr": "router", "features": {"all", "core"}},
|
||||
{"name": "seo_tools", "module": "routers.seo_tools", "attr": "router", "features": {"all", "core", "seo"}},
|
||||
{"name": "facebook_writer", "module": "api.facebook_writer.routers", "attr": "facebook_router", "features": {"all", "core", "facebook"}},
|
||||
{"name": "linkedin", "module": "routers.linkedin", "attr": "router", "features": {"all", "core", "linkedin"}},
|
||||
{"name": "linkedin_image", "module": "api.linkedin_image_generation", "attr": "router", "features": {"all", "core", "linkedin"}},
|
||||
{"name": "brainstorm", "module": "api.brainstorm", "attr": "router", "features": {"all", "core"}},
|
||||
{"name": "hallucination_detector", "module": "api.hallucination_detector", "attr": "router", "features": {"all", "core"}},
|
||||
{"name": "writing_assistant", "module": "api.writing_assistant", "attr": "router", "features": {"all", "core"}},
|
||||
{"name": "content_planning", "module": "api.content_planning.api.router", "attr": "router", "features": {"all", "core", "content-planning"}},
|
||||
{"name": "user_data", "module": "api.user_data", "attr": "router", "features": {"all", "core"}},
|
||||
{"name": "user_environment", "module": "api.user_environment", "attr": "router", "features": {"all", "core"}},
|
||||
{"name": "strategy_copilot", "module": "api.content_planning.strategy_copilot", "attr": "router", "features": {"all", "core", "content-planning"}},
|
||||
{"name": "error_logging", "module": "routers.error_logging", "attr": "router", "features": {"all", "core"}},
|
||||
{"name": "frontend_env_manager", "module": "routers.frontend_env_manager", "attr": "router", "features": {"all", "core"}},
|
||||
{"name": "platform_analytics", "module": "routers.platform_analytics", "attr": "router", "features": {"all", "core"}},
|
||||
{"name": "bing_insights", "module": "routers.bing_insights", "attr": "router", "features": {"all", "core", "seo"}},
|
||||
{"name": "background_jobs", "module": "routers.background_jobs", "attr": "router", "features": {"all", "core"}},
|
||||
]
|
||||
|
||||
OPTIONAL_ROUTER_REGISTRY = [
|
||||
{"name": "blog_writer", "module": "api.blog_writer.router", "attr": "router", "features": {"all", "blog-writer"}},
|
||||
{"name": "story_writer", "module": "api.story_writer.router", "attr": "router", "features": {"all", "story-writer"}},
|
||||
{"name": "wix", "module": "api.wix_routes", "attr": "router", "features": {"all"}},
|
||||
{"name": "blog_seo_analysis", "module": "api.blog_writer.seo_analysis", "attr": "router", "features": {"all", "blog-writer"}},
|
||||
{"name": "persona", "module": "api.persona_routes", "attr": "router", "features": {"all", "persona"}},
|
||||
{"name": "video_studio", "module": "api.video_studio.router", "attr": "router", "features": {"all", "video-studio"}},
|
||||
{"name": "stability", "module": "routers.stability", "attr": "router", "features": {"all", "image-studio"}},
|
||||
{"name": "stability_advanced", "module": "routers.stability_advanced", "attr": "router", "features": {"all", "image-studio"}},
|
||||
{"name": "stability_admin", "module": "routers.stability_admin", "attr": "router", "features": {"all", "image-studio"}},
|
||||
{"name": "images", "module": "api.images", "attr": "router", "features": {"all", "image-studio"}},
|
||||
{"name": "image_studio", "module": "routers.image_studio", "attr": "router", "features": {"all", "image-studio"}},
|
||||
{"name": "product_marketing", "module": "routers.product_marketing", "attr": "router", "features": {"all", "product-marketing"}},
|
||||
{"name": "campaign_creator", "module": "routers.campaign_creator", "attr": "router", "features": {"all"}},
|
||||
{"name": "content_assets", "module": "api.content_assets.router", "attr": "router", "features": {"all"}},
|
||||
{"name": "podcast", "module": "api.podcast.router", "attr": "router", "features": {"all", "podcast"}},
|
||||
{"name": "youtube", "module": "api.youtube.router", "attr": "router", "features": {"all", "youtube"}, "include_kwargs": {"prefix": "/api"}},
|
||||
{"name": "research_config", "module": "api.research_config", "attr": "router", "features": {"all", "research"}, "include_kwargs": {"prefix": "/api/research", "tags": ["research"]}},
|
||||
{"name": "research_engine", "module": "api.research.router", "attr": "router", "features": {"all", "research"}, "include_kwargs": {"tags": ["Research Engine"]}},
|
||||
{"name": "scheduler_dashboard", "module": "api.scheduler_dashboard", "attr": "router", "features": {"all", "scheduler"}},
|
||||
{"name": "oauth_token_monitoring", "module": "api.oauth_token_monitoring_routes", "attr": "router", "features": {"all", "core"}},
|
||||
{"name": "agents", "module": "api.agents_api", "attr": "router", "features": {"all"}},
|
||||
{"name": "today_workflow", "module": "api.today_workflow", "attr": "router", "features": {"all"}},
|
||||
]
|
||||
|
||||
OPTIONAL_MODULE_MATRIX = {
|
||||
"all": [entry["name"] for entry in OPTIONAL_ROUTER_REGISTRY],
|
||||
"default": [entry["name"] for entry in OPTIONAL_ROUTER_REGISTRY],
|
||||
}
|
||||
|
||||
|
||||
class RouterManager:
|
||||
@@ -16,22 +79,66 @@ class RouterManager:
|
||||
self.app = app
|
||||
self.included_routers = []
|
||||
self.failed_routers = []
|
||||
self._included_router_names = set()
|
||||
self.skipped_routers = []
|
||||
|
||||
def include_router_safely(self, router, router_name: str = None) -> bool:
|
||||
@staticmethod
|
||||
def get_enabled_features() -> set:
|
||||
"""Get enabled features from ALWRITY_ENABLED_FEATURES env var.
|
||||
|
||||
Values:
|
||||
- "all" - enable all features (default)
|
||||
- comma-separated: "podcast,blog-writer,youtube"
|
||||
- single feature: "podcast"
|
||||
"""
|
||||
env_value = os.getenv("ALWRITY_ENABLED_FEATURES", "all").strip().lower()
|
||||
|
||||
if not env_value or env_value == "all":
|
||||
return {"all"}
|
||||
|
||||
return {f.strip() for f in env_value.split(",") if f.strip()}
|
||||
|
||||
def _is_verbose(self) -> bool:
|
||||
return os.getenv("ALWRITY_VERBOSE", "false").lower() == "true"
|
||||
|
||||
def _get_profile(self) -> str:
|
||||
"""Legacy method - returns primary profile."""
|
||||
enabled = self.get_enabled_features()
|
||||
if "all" in enabled:
|
||||
return "all"
|
||||
# Return first feature as profile for backwards compatibility
|
||||
return list(enabled)[0] if enabled else "all"
|
||||
|
||||
def _should_include_router(self, registry_entry: Dict[str, Any], enabled_features: set) -> bool:
|
||||
"""Check if router should be included based on enabled features."""
|
||||
required_features = registry_entry.get("features", set())
|
||||
|
||||
# If "all" is enabled, include everything
|
||||
if "all" in enabled_features:
|
||||
return True
|
||||
|
||||
# Skip core routers in podcast-only mode (they require non-podcast features)
|
||||
if enabled_features == {"podcast"}:
|
||||
return False
|
||||
|
||||
# If no required features specified, include by default
|
||||
if not required_features:
|
||||
return True
|
||||
|
||||
# Check if any required feature is enabled
|
||||
return bool(required_features & enabled_features)
|
||||
|
||||
def _load_router_from_registry(self, registry_entry: Dict[str, Any]):
|
||||
module = import_module(registry_entry["module"])
|
||||
return getattr(module, registry_entry["attr"])
|
||||
|
||||
def include_router_safely(self, router, router_name: Optional[str] = None, include_kwargs: Optional[Dict[str, Any]] = None) -> bool:
|
||||
"""Include a router safely with error handling."""
|
||||
verbose = os.getenv("ALWRITY_VERBOSE", "false").lower() == "true"
|
||||
verbose = self._is_verbose()
|
||||
router_name = router_name or getattr(router, 'prefix', 'unknown')
|
||||
|
||||
if router_name in self._included_router_names:
|
||||
if verbose:
|
||||
logger.info(f"↩️ Router already included, skipping duplicate: {router_name}")
|
||||
return True
|
||||
|
||||
try:
|
||||
self.app.include_router(router)
|
||||
self.app.include_router(router, **(include_kwargs or {}))
|
||||
self.included_routers.append(router_name)
|
||||
self._included_router_names.add(router_name)
|
||||
if verbose:
|
||||
logger.info(f"✅ Router included successfully: {router_name}")
|
||||
return True
|
||||
@@ -42,212 +149,85 @@ class RouterManager:
|
||||
logger.warning(f"❌ Router inclusion failed: {router_name} - {e}")
|
||||
return False
|
||||
|
||||
def include_core_routers(self) -> bool:
|
||||
"""Include core application routers."""
|
||||
# Import os locally to avoid UnboundLocalError if it's shadowed
|
||||
import os
|
||||
verbose = os.getenv("ALWRITY_VERBOSE", "false").lower() == "true"
|
||||
demo_mode = os.getenv("ALWRITY_DEMO_MODE", "false").lower() == "true"
|
||||
|
||||
@staticmethod
|
||||
def _demo_release_mode_enabled() -> bool:
|
||||
"""Return True when demo-release safety mode is enabled."""
|
||||
return os.getenv("ALWRITY_DEMO_RELEASE", "false").lower() in {"1", "true", "yes", "on"}
|
||||
|
||||
def _include_registry_group(self, registry: List[Dict[str, Any]], group_name: str) -> bool:
|
||||
verbose = self._is_verbose()
|
||||
enabled_features = self.get_enabled_features()
|
||||
|
||||
try:
|
||||
if verbose:
|
||||
logger.info(f"Including core routers (demo_mode={demo_mode})...")
|
||||
|
||||
# Subscription router MUST always be included (including demo mode) so
|
||||
# payment/preflight/subscription endpoints remain available.
|
||||
from api.subscription import router as subscription_router
|
||||
self.include_router_safely(subscription_router, "subscription")
|
||||
|
||||
# Component logic router
|
||||
from api.component_logic import router as component_logic_router
|
||||
self.include_router_safely(component_logic_router, "component_logic")
|
||||
logger.info(f"Including {group_name} routers with features: {enabled_features}...")
|
||||
|
||||
# Step 3 Research router (core onboarding functionality)
|
||||
from api.onboarding_utils.step3_routes import router as step3_research_router
|
||||
self.include_router_safely(step3_research_router, "step3_research")
|
||||
|
||||
# Step 4 Persona and Asset routers
|
||||
from api.onboarding_utils.step4_asset_routes import router as step4_asset_router
|
||||
self.include_router_safely(step4_asset_router, "step4_assets")
|
||||
|
||||
from api.onboarding_utils.step4_persona_routes_optimized import router as step4_persona_router
|
||||
self.include_router_safely(step4_persona_router, "step4_persona")
|
||||
for entry in registry:
|
||||
if not self._should_include_router(entry, enabled_features):
|
||||
reason = f"features {enabled_features} not matching {entry.get('features', set())}"
|
||||
self.skipped_routers.append({"name": entry["name"], "reason": reason})
|
||||
if verbose:
|
||||
logger.info(f"⏭️ Skipping {entry['name']}: {reason}")
|
||||
continue
|
||||
|
||||
try:
|
||||
router = self._load_router_from_registry(entry)
|
||||
self.include_router_safely(router, entry["name"], entry.get("include_kwargs"))
|
||||
except Exception as e:
|
||||
logger.warning(f"{entry['name']} router not mounted: {e}")
|
||||
|
||||
# GSC router
|
||||
from routers.gsc_auth import router as gsc_auth_router
|
||||
self.include_router_safely(gsc_auth_router, "gsc_auth")
|
||||
|
||||
# WordPress router
|
||||
from routers.wordpress_oauth import router as wordpress_oauth_router
|
||||
self.include_router_safely(wordpress_oauth_router, "wordpress_oauth")
|
||||
|
||||
# Bing Webmaster router
|
||||
from routers.bing_oauth import router as bing_oauth_router
|
||||
self.include_router_safely(bing_oauth_router, "bing_oauth")
|
||||
|
||||
# Bing Analytics router
|
||||
from routers.bing_analytics import router as bing_analytics_router
|
||||
self.include_router_safely(bing_analytics_router, "bing_analytics")
|
||||
|
||||
# Bing Analytics Storage router
|
||||
from routers.bing_analytics_storage import router as bing_analytics_storage_router
|
||||
self.include_router_safely(bing_analytics_storage_router, "bing_analytics_storage")
|
||||
|
||||
# SEO tools router
|
||||
from routers.seo_tools import router as seo_tools_router
|
||||
self.include_router_safely(seo_tools_router, "seo_tools")
|
||||
|
||||
# Facebook Writer router
|
||||
from api.facebook_writer.routers import facebook_router
|
||||
self.include_router_safely(facebook_router, "facebook_writer")
|
||||
|
||||
# LinkedIn routers
|
||||
from routers.linkedin import router as linkedin_router
|
||||
self.include_router_safely(linkedin_router, "linkedin")
|
||||
|
||||
from api.linkedin_image_generation import router as linkedin_image_router
|
||||
self.include_router_safely(linkedin_image_router, "linkedin_image")
|
||||
|
||||
# Brainstorm router
|
||||
from api.brainstorm import router as brainstorm_router
|
||||
self.include_router_safely(brainstorm_router, "brainstorm")
|
||||
|
||||
# Hallucination detector and writing assistant
|
||||
from api.hallucination_detector import router as hallucination_detector_router
|
||||
self.include_router_safely(hallucination_detector_router, "hallucination_detector")
|
||||
|
||||
from api.writing_assistant import router as writing_assistant_router
|
||||
self.include_router_safely(writing_assistant_router, "writing_assistant")
|
||||
|
||||
# Content planning and user data
|
||||
from api.content_planning.api.router import router as content_planning_router
|
||||
self.include_router_safely(content_planning_router, "content_planning")
|
||||
|
||||
from api.user_data import router as user_data_router
|
||||
self.include_router_safely(user_data_router, "user_data")
|
||||
|
||||
from api.user_environment import router as user_environment_router
|
||||
self.include_router_safely(user_environment_router, "user_environment")
|
||||
|
||||
# Strategy copilot
|
||||
from api.content_planning.strategy_copilot import router as strategy_copilot_router
|
||||
self.include_router_safely(strategy_copilot_router, "strategy_copilot")
|
||||
|
||||
# Error logging router
|
||||
from routers.error_logging import router as error_logging_router
|
||||
self.include_router_safely(error_logging_router, "error_logging")
|
||||
|
||||
# Frontend environment manager router
|
||||
from routers.frontend_env_manager import router as frontend_env_router
|
||||
self.include_router_safely(frontend_env_router, "frontend_env_manager")
|
||||
|
||||
# Platform analytics router
|
||||
try:
|
||||
from routers.platform_analytics import router as platform_analytics_router
|
||||
self.include_router_safely(platform_analytics_router, "platform_analytics")
|
||||
logger.info("✅ Platform analytics router included successfully")
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Failed to include platform analytics router: {e}")
|
||||
# Continue with other routers
|
||||
|
||||
# Bing insights router
|
||||
try:
|
||||
from routers.bing_insights import router as bing_insights_router
|
||||
self.include_router_safely(bing_insights_router, "bing_insights")
|
||||
logger.info("✅ Bing insights router included successfully")
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Failed to include Bing insights router: {e}")
|
||||
# Continue with other routers
|
||||
|
||||
# Background jobs router
|
||||
try:
|
||||
from routers.background_jobs import router as background_jobs_router
|
||||
self.include_router_safely(background_jobs_router, "background_jobs")
|
||||
logger.info("✅ Background jobs router included successfully")
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Failed to include Background jobs router: {e}")
|
||||
# Continue with other routers
|
||||
|
||||
logger.info("✅ Core routers included successfully")
|
||||
logger.info(f"✅ {group_name.capitalize()} routers processed for features: {enabled_features}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Error including core routers: {e}")
|
||||
logger.error(f"❌ Error including {group_name} routers: {e}")
|
||||
return False
|
||||
|
||||
def include_core_routers(self) -> bool:
|
||||
"""Include core application routers."""
|
||||
return self._include_registry_group(CORE_ROUTER_REGISTRY, "core")
|
||||
|
||||
def include_optional_routers(self) -> bool:
|
||||
"""Include optional routers with error handling."""
|
||||
try:
|
||||
logger.info("Including optional routers...")
|
||||
|
||||
# AI Blog Writer router
|
||||
try:
|
||||
from api.blog_writer.router import router as blog_writer_router
|
||||
self.include_router_safely(blog_writer_router, "blog_writer")
|
||||
except Exception as e:
|
||||
logger.warning(f"AI Blog Writer router not mounted: {e}")
|
||||
|
||||
# Story Writer router
|
||||
try:
|
||||
from api.story_writer.router import router as story_writer_router
|
||||
self.include_router_safely(story_writer_router, "story_writer")
|
||||
except Exception as e:
|
||||
logger.warning(f"Story Writer router not mounted: {e}")
|
||||
|
||||
# Wix Integration router
|
||||
try:
|
||||
from api.wix_routes import router as wix_router
|
||||
self.include_router_safely(wix_router, "wix")
|
||||
except Exception as e:
|
||||
logger.warning(f"Wix Integration router not mounted: {e}")
|
||||
|
||||
# Blog Writer SEO Analysis router
|
||||
try:
|
||||
from api.blog_writer.seo_analysis import router as blog_seo_analysis_router
|
||||
self.include_router_safely(blog_seo_analysis_router, "blog_seo_analysis")
|
||||
except Exception as e:
|
||||
logger.warning(f"Blog Writer SEO Analysis router not mounted: {e}")
|
||||
|
||||
# Persona router
|
||||
try:
|
||||
from api.persona_routes import router as persona_router
|
||||
self.include_router_safely(persona_router, "persona")
|
||||
except Exception as e:
|
||||
logger.warning(f"Persona router not mounted: {e}")
|
||||
|
||||
# Video Studio router
|
||||
try:
|
||||
from api.video_studio.router import router as video_studio_router
|
||||
self.include_router_safely(video_studio_router, "video_studio")
|
||||
except Exception as e:
|
||||
logger.warning(f"Video Studio router not mounted: {e}")
|
||||
|
||||
# Stability AI routers
|
||||
try:
|
||||
from routers.stability import router as stability_router
|
||||
self.include_router_safely(stability_router, "stability")
|
||||
|
||||
from routers.stability_advanced import router as stability_advanced_router
|
||||
self.include_router_safely(stability_advanced_router, "stability_advanced")
|
||||
|
||||
from routers.stability_admin import router as stability_admin_router
|
||||
self.include_router_safely(stability_admin_router, "stability_admin")
|
||||
except Exception as e:
|
||||
logger.warning(f"Stability AI routers not mounted: {e}")
|
||||
|
||||
|
||||
logger.info("✅ Optional routers processed")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Error including optional routers: {e}")
|
||||
return False
|
||||
return self._include_registry_group(OPTIONAL_ROUTER_REGISTRY, "optional")
|
||||
|
||||
def get_router_status(self) -> Dict[str, Any]:
|
||||
"""Get the status of router inclusion."""
|
||||
return {
|
||||
"active_profile": self._get_profile(),
|
||||
"included_routers": self.included_routers,
|
||||
"failed_routers": self.failed_routers,
|
||||
"skipped_routers": self.skipped_routers,
|
||||
"total_included": len(self.included_routers),
|
||||
"total_failed": len(self.failed_routers)
|
||||
"total_failed": len(self.failed_routers),
|
||||
"total_skipped": len(self.skipped_routers)
|
||||
}
|
||||
|
||||
def log_startup_summary(self) -> None:
|
||||
"""Log startup summary including profile, enabled routers, and skipped items."""
|
||||
profile = self._get_profile()
|
||||
|
||||
logger.info("=" * 60)
|
||||
logger.info("📋 STARTUP SUMMARY")
|
||||
logger.info(f" Active profile: {profile}")
|
||||
logger.info(f" Enabled routers ({len(self.included_routers)}): {', '.join(self.included_routers)}")
|
||||
if self.skipped_routers:
|
||||
logger.info(f" Skipped routers ({len(self.skipped_routers)}):")
|
||||
for s in self.skipped_routers:
|
||||
logger.info(f" - {s['name']}: {s['reason']}")
|
||||
if self.failed_routers:
|
||||
logger.warning(f" Failed routers ({len(self.failed_routers)}):")
|
||||
for f in self.failed_routers:
|
||||
logger.warning(f" - {f['name']}: {f['error']}")
|
||||
logger.info("=" * 60)
|
||||
|
||||
def get_feature_profile_status(self) -> Dict[str, Any]:
|
||||
"""Get feature profile status and enabled modules."""
|
||||
profile = self._get_profile()
|
||||
enabled_modules = OPTIONAL_MODULE_MATRIX.get(profile, OPTIONAL_MODULE_MATRIX.get("all", []))
|
||||
|
||||
return {
|
||||
"active_profile": profile,
|
||||
"enabled_modules": enabled_modules,
|
||||
"available_profiles": list(OPTIONAL_MODULE_MATRIX.keys())
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import os
|
||||
"""Facebook Post generation service."""
|
||||
|
||||
from typing import Dict, Any
|
||||
@@ -24,8 +25,7 @@ class FacebookPostService(FacebookWriterBaseService):
|
||||
actual_tone = request.custom_tone if request.post_tone.value == "Custom" else request.post_tone.value
|
||||
|
||||
# Get persona data for enhanced content generation
|
||||
# Beta testing: Force user_id=1 for all requests
|
||||
user_id = 1
|
||||
user_id = int(os.getenv("ALWRITY_FALLBACK_USER_ID", "0"))
|
||||
persona_data = self._get_persona_data(user_id)
|
||||
|
||||
# Build the prompt
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import os
|
||||
"""Remaining Facebook Writer services - placeholder implementations."""
|
||||
|
||||
from typing import Dict, Any, List
|
||||
@@ -16,8 +17,7 @@ class FacebookReelService(FacebookWriterBaseService):
|
||||
actual_style = request.custom_style if request.reel_style.value == "Custom" else request.reel_style.value
|
||||
|
||||
# Get persona data for enhanced content generation
|
||||
# Beta testing: Force user_id=1 for all requests
|
||||
user_id = 1
|
||||
user_id = int(os.getenv("ALWRITY_FALLBACK_USER_ID", "0"))
|
||||
persona_data = self._get_persona_data(user_id)
|
||||
|
||||
base_prompt = f"""
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import os
|
||||
"""Facebook Story generation service."""
|
||||
|
||||
from typing import Dict, Any, List
|
||||
@@ -30,8 +31,7 @@ class FacebookStoryService(FacebookWriterBaseService):
|
||||
actual_tone = request.custom_tone if request.story_tone.value == "Custom" else request.story_tone.value
|
||||
|
||||
# Get persona data for enhanced content generation
|
||||
# Beta testing: Force user_id=1 for all requests
|
||||
user_id = 1
|
||||
user_id = int(os.getenv("ALWRITY_FALLBACK_USER_ID", "0"))
|
||||
persona_data = self._get_persona_data(user_id)
|
||||
|
||||
# Build the prompt
|
||||
|
||||
@@ -94,36 +94,36 @@ async def generate_platform_persona_endpoint(
|
||||
async def update_persona_endpoint(
|
||||
persona_id: int,
|
||||
update_data: Dict[str, Any],
|
||||
user_id: int = Query(..., description="User ID")
|
||||
current_user: Dict[str, Any] = Depends(get_current_user),
|
||||
):
|
||||
"""Update an existing persona."""
|
||||
# Beta testing: Force user_id=1 for all requests
|
||||
return await update_persona(1, persona_id, update_data)
|
||||
user_id = int(current_user.get("id"))
|
||||
return await update_persona(user_id, persona_id, update_data)
|
||||
|
||||
@router.delete("/{persona_id}")
|
||||
async def delete_persona_endpoint(
|
||||
persona_id: int,
|
||||
user_id: int = Query(..., description="User ID")
|
||||
current_user: Dict[str, Any] = Depends(get_current_user),
|
||||
):
|
||||
"""Delete a persona."""
|
||||
# Beta testing: Force user_id=1 for all requests
|
||||
return await delete_persona(1, persona_id)
|
||||
user_id = int(current_user.get("id"))
|
||||
return await delete_persona(user_id, persona_id)
|
||||
|
||||
@router.get("/check/readiness")
|
||||
async def check_persona_readiness_endpoint(
|
||||
user_id: int = Query(1, description="User ID")
|
||||
current_user: Dict[str, Any] = Depends(get_current_user),
|
||||
):
|
||||
"""Check if user has sufficient data for persona generation."""
|
||||
# Beta testing: Force user_id=1 for all requests
|
||||
return await validate_persona_generation_readiness(1)
|
||||
user_id = int(current_user.get("id"))
|
||||
return await validate_persona_generation_readiness(user_id)
|
||||
|
||||
@router.get("/preview/generate")
|
||||
async def generate_preview_endpoint(
|
||||
user_id: int = Query(1, description="User ID")
|
||||
current_user: Dict[str, Any] = Depends(get_current_user),
|
||||
):
|
||||
"""Generate a preview of the writing persona without saving."""
|
||||
# Beta testing: Force user_id=1 for all requests
|
||||
return await generate_persona_preview(1)
|
||||
user_id = int(current_user.get("id"))
|
||||
return await generate_persona_preview(user_id)
|
||||
|
||||
@router.get("/platforms/supported")
|
||||
async def get_supported_platforms_endpoint():
|
||||
@@ -160,12 +160,12 @@ async def optimize_facebook_persona_endpoint(
|
||||
|
||||
@router.post("/generate-content")
|
||||
async def generate_content_with_persona_endpoint(
|
||||
request: Dict[str, Any]
|
||||
request: Dict[str, Any],
|
||||
current_user: Dict[str, Any] = Depends(get_current_user),
|
||||
):
|
||||
"""Generate content using persona replication engine."""
|
||||
try:
|
||||
# Beta testing: Force user_id=1 for all requests
|
||||
user_id = 1
|
||||
user_id = int(current_user.get("id"))
|
||||
platform = request.get("platform")
|
||||
content_request = request.get("content_request")
|
||||
content_type = request.get("content_type", "post")
|
||||
@@ -189,13 +189,13 @@ async def generate_content_with_persona_endpoint(
|
||||
@router.get("/export/{platform}")
|
||||
async def export_persona_prompt_endpoint(
|
||||
platform: str,
|
||||
user_id: int = Query(1, description="User ID")
|
||||
current_user: Dict[str, Any] = Depends(get_current_user),
|
||||
):
|
||||
"""Export hardened persona prompt for external use."""
|
||||
try:
|
||||
engine = PersonaReplicationEngine()
|
||||
# Beta testing: Force user_id=1 for all requests
|
||||
export_package = engine.export_persona_for_external_use(1, platform)
|
||||
user_id = int(current_user.get("id"))
|
||||
export_package = engine.export_persona_for_external_use(user_id, platform)
|
||||
|
||||
if "error" in export_package:
|
||||
raise HTTPException(status_code=400, detail=export_package["error"])
|
||||
@@ -207,12 +207,12 @@ async def export_persona_prompt_endpoint(
|
||||
|
||||
@router.post("/validate-content")
|
||||
async def validate_content_endpoint(
|
||||
request: Dict[str, Any]
|
||||
request: Dict[str, Any],
|
||||
current_user: Dict[str, Any] = Depends(get_current_user),
|
||||
):
|
||||
"""Validate content against persona constraints."""
|
||||
try:
|
||||
# Beta testing: Force user_id=1 for all requests
|
||||
user_id = 1
|
||||
user_id = int(current_user.get("id"))
|
||||
platform = request.get("platform")
|
||||
content = request.get("content")
|
||||
|
||||
@@ -242,14 +242,14 @@ async def validate_content_endpoint(
|
||||
async def update_platform_persona_endpoint(
|
||||
platform: str,
|
||||
update_data: Dict[str, Any],
|
||||
user_id: int = Query(1, description="User ID")
|
||||
current_user: Dict[str, Any] = Depends(get_current_user),
|
||||
):
|
||||
"""Update platform-specific persona fields for a user.
|
||||
|
||||
Allows editing persona fields in the UI and saving them to the database.
|
||||
"""
|
||||
# Beta testing: Force user_id=1 for all requests
|
||||
return await update_platform_persona(1, platform, update_data)
|
||||
user_id = int(current_user.get("id"))
|
||||
return await update_platform_persona(user_id, platform, update_data)
|
||||
|
||||
@router.get("/facebook-persona/check/{user_id}")
|
||||
async def check_facebook_persona_endpoint(
|
||||
|
||||
@@ -6,6 +6,7 @@ Centralized constants and directory configuration for podcast module.
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Literal
|
||||
from loguru import logger
|
||||
from services.story_writer.audio_generation_service import StoryAudioGenerationService
|
||||
|
||||
# Directory paths
|
||||
@@ -45,11 +46,14 @@ def get_podcast_media_dir(
|
||||
}[media_type]
|
||||
|
||||
if user_id:
|
||||
tenant_media_dir = ROOT_DIR / "workspace" / f"workspace_{_sanitize_user_id(user_id)}" / "media" / media_subdir
|
||||
sanitized = _sanitize_user_id(user_id)
|
||||
tenant_media_dir = ROOT_DIR / "workspace" / f"workspace_{sanitized}" / "media" / media_subdir
|
||||
resolved_dir = tenant_media_dir.resolve()
|
||||
else:
|
||||
resolved_dir = (DATA_MEDIA_DIR / media_subdir).resolve()
|
||||
|
||||
logger.debug(f"[Podcast] get_podcast_media_dir: type={media_type}, user_id={user_id}, sanitized={user_id and _sanitize_user_id(user_id)}, resolved={resolved_dir}")
|
||||
|
||||
if ensure_exists:
|
||||
resolved_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
@@ -61,7 +65,9 @@ def get_podcast_media_read_dirs(media_type: MediaType, user_id: str | None = Non
|
||||
dirs: list[Path] = []
|
||||
if user_id:
|
||||
dirs.append(get_podcast_media_dir(media_type, user_id))
|
||||
logger.debug(f"[Podcast] get_podcast_media_read_dirs: added user dir for {user_id}")
|
||||
dirs.append(get_podcast_media_dir(media_type, None))
|
||||
logger.debug(f"[Podcast] get_podcast_media_read_dirs: dirs={dirs}")
|
||||
return dirs
|
||||
|
||||
|
||||
|
||||
@@ -5,10 +5,11 @@ Analysis endpoint for podcast ideas.
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from typing import Dict, Any
|
||||
from typing import Dict, Any, Optional, List
|
||||
import json
|
||||
import uuid
|
||||
from sqlalchemy.orm import Session
|
||||
from pydantic import BaseModel
|
||||
|
||||
from services.database import get_db
|
||||
from middleware.auth_middleware import get_current_user
|
||||
@@ -80,7 +81,7 @@ Return JSON with:
|
||||
prompt=prompt,
|
||||
user_id=user_id,
|
||||
json_struct=None,
|
||||
preferred_provider="huggingface",
|
||||
preferred_provider=None,
|
||||
flow_type="premium_tool",
|
||||
)
|
||||
|
||||
@@ -121,22 +122,12 @@ Return JSON with:
|
||||
enhanced_ideas=enhanced_ideas[:3], # Ensure exactly 3
|
||||
rationales=rationales[:3] # Ensure exactly 3
|
||||
)
|
||||
except HTTPException:
|
||||
# Re-raise HTTPExceptions (e.g., 429 subscription limit) - preserve error details
|
||||
raise
|
||||
except Exception as exc:
|
||||
logger.error(f"[Podcast Enhance] Failed for user {user_id}: {exc}")
|
||||
# Fallback to basic variations of original idea
|
||||
base_idea = request.idea
|
||||
return PodcastEnhanceIdeaResponse(
|
||||
enhanced_ideas=[
|
||||
f"Expert insights on {base_idea}: A deep dive into industry trends and best practices.",
|
||||
f"The human side of {base_idea}: Personal stories and real-world experiences that resonate.",
|
||||
f"Modern perspectives on {base_idea}: Current trends and forward-thinking approaches."
|
||||
],
|
||||
rationales=[
|
||||
"Professional approach focusing on expertise and authority",
|
||||
"Storytelling approach emphasizing human connection",
|
||||
"Contemporary approach highlighting current relevance"
|
||||
]
|
||||
)
|
||||
raise HTTPException(status_code=500, detail=f"Enhance failed: {exc}")
|
||||
|
||||
|
||||
@router.post("/analyze", response_model=PodcastAnalyzeResponse)
|
||||
@@ -197,8 +188,7 @@ async def analyze_podcast_idea(
|
||||
image_result = generate_image(
|
||||
prompt=final_avatar_prompt,
|
||||
user_id=user_id,
|
||||
width=1024,
|
||||
height=1024
|
||||
options={"width": 1024, "height": 1024}
|
||||
)
|
||||
|
||||
# 4. Save to disk and library
|
||||
@@ -269,6 +259,10 @@ Return JSON with:
|
||||
- top_keywords: 5 podcast-relevant keywords/phrases
|
||||
- suggested_outlines: 2 items, each with title (<=60 chars) and 4-6 short segments (bullet-friendly, factual)
|
||||
- title_suggestions: 3 concise episode titles
|
||||
- episode_hook: one compelling 15-30 second opening hook/angle that grabs attention
|
||||
- key_takeaways: 3-5 actionable insights listeners will learn
|
||||
- guest_talking_points: (if guest included) 3-4 suggested questions/angles for guest interview
|
||||
- listener_cta: one clear call-to-action for listeners
|
||||
- research_queries: array of {{"query": "string", "rationale": "string"}}
|
||||
- exa_suggested_config: suggested Exa search options with:
|
||||
- exa_search_type: "auto" | "neural" | "keyword"
|
||||
@@ -282,7 +276,10 @@ Return JSON with:
|
||||
Requirements:
|
||||
- Keep language factual, actionable, and suited for spoken audio.
|
||||
- Avoid narrative fiction tone.
|
||||
- Prefer 2024-2025 context.
|
||||
- For research queries: Mix of time-sensitive and evergreen queries:
|
||||
- 2-3 queries should focus on latest 2025-2026 developments, trends, and data (use year in query)
|
||||
- 2-3 queries should be evergreen/fundamental (concepts, definitions, best practices, proven strategies) - do NOT include years in these
|
||||
- Today's date is April 2026.
|
||||
"""
|
||||
|
||||
try:
|
||||
@@ -290,7 +287,7 @@ Requirements:
|
||||
prompt=prompt,
|
||||
user_id=user_id,
|
||||
json_struct=None,
|
||||
preferred_provider="huggingface",
|
||||
preferred_provider=None,
|
||||
flow_type="premium_tool",
|
||||
)
|
||||
except HTTPException:
|
||||
@@ -316,6 +313,10 @@ Requirements:
|
||||
top_keywords = data.get("top_keywords") or []
|
||||
suggested_outlines = data.get("suggested_outlines") or []
|
||||
title_suggestions = data.get("title_suggestions") or []
|
||||
episode_hook = data.get("episode_hook") or ""
|
||||
key_takeaways = data.get("key_takeaways") or []
|
||||
guest_talking_points = data.get("guest_talking_points") or []
|
||||
listener_cta = data.get("listener_cta") or ""
|
||||
research_queries = data.get("research_queries") or []
|
||||
exa_suggested_config = data.get("exa_suggested_config") or None
|
||||
|
||||
@@ -325,6 +326,10 @@ Requirements:
|
||||
top_keywords=top_keywords,
|
||||
suggested_outlines=suggested_outlines,
|
||||
title_suggestions=title_suggestions,
|
||||
episode_hook=episode_hook,
|
||||
key_takeaways=key_takeaways,
|
||||
guest_talking_points=guest_talking_points,
|
||||
listener_cta=listener_cta,
|
||||
research_queries=research_queries,
|
||||
exa_suggested_config=exa_suggested_config,
|
||||
bible=bible_obj.model_dump() if bible_obj else None,
|
||||
@@ -332,3 +337,106 @@ Requirements:
|
||||
avatar_prompt=final_avatar_prompt,
|
||||
)
|
||||
|
||||
|
||||
class RegenerateQueriesRequest(BaseModel):
|
||||
idea: str
|
||||
feedback: str
|
||||
existing_analysis: Optional[Dict[str, Any]] = None
|
||||
bible: Optional[Dict[str, Any]] = None
|
||||
|
||||
|
||||
class RegenerateQueriesResponse(BaseModel):
|
||||
research_queries: List[Dict[str, str]]
|
||||
|
||||
|
||||
@router.post("/regenerate-queries", response_model=RegenerateQueriesResponse)
|
||||
async def regenerate_research_queries(
|
||||
request: RegenerateQueriesRequest,
|
||||
current_user: Dict[str, Any] = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Regenerate research queries based on user feedback and existing analysis.
|
||||
"""
|
||||
user_id = require_authenticated_user(current_user)
|
||||
|
||||
# Build context from existing analysis
|
||||
idea = request.idea
|
||||
feedback = request.feedback
|
||||
|
||||
# Get topic, keywords, audience from existing analysis if provided
|
||||
topic = idea
|
||||
keywords = ""
|
||||
audience = ""
|
||||
if request.existing_analysis:
|
||||
topic = request.existing_analysis.get("title_suggestions", [idea])[0] if request.existing_analysis.get("title_suggestions") else idea
|
||||
keywords = ", ".join(request.existing_analysis.get("top_keywords", [])[:5])
|
||||
audience = request.existing_analysis.get("audience", "")
|
||||
|
||||
# Serialize Bible context if provided
|
||||
bible_context = ""
|
||||
if request.bible:
|
||||
try:
|
||||
bible_service = PodcastBibleService()
|
||||
from models.podcast_bible_models import PodcastBible
|
||||
bible_data = PodcastBible(**request.bible)
|
||||
bible_context = bible_service.serialize_bible(bible_data)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to serialize bible for query regeneration: {e}")
|
||||
|
||||
prompt = f"""
|
||||
You are a research strategist for podcast content. Given a podcast idea, existing analysis, and user feedback,
|
||||
generate 7 new research queries that address the user's specific needs.
|
||||
|
||||
{f"USER FEEDBACK: {feedback}" if feedback else ""}
|
||||
|
||||
{f"EXISTING ANALYSIS CONTEXT:\n- Topic: {topic}\n- Keywords: {keywords}\n- Audience: {audience}\n" if request.existing_analysis else ""}
|
||||
{f"PODCAST BIBLE CONTEXT:\n{bible_context}\n" if bible_context else ""}
|
||||
|
||||
Podcast Idea: "{idea}"
|
||||
|
||||
TASK:
|
||||
Generate exactly 7 research queries that:
|
||||
1. Incorporate the user's feedback direction
|
||||
2. Build on the existing analysis context
|
||||
3. Mix of time-sensitive (2025-2026) and evergreen topics
|
||||
4. Are highly specific to the podcast topic
|
||||
|
||||
Return JSON with:
|
||||
- research_queries: array of {{"query": "string", "rationale": "string"}}
|
||||
|
||||
Requirements:
|
||||
- At least 2-3 queries should focus on latest 2025-2026 developments (include year in query)
|
||||
- At least 2-3 queries should be evergreen (concepts, definitions, best practices - NO year)
|
||||
- Queries should be specific and actionable, not generic
|
||||
"""
|
||||
|
||||
try:
|
||||
from services.llm_providers.main_text_generation import llm_text_gen
|
||||
|
||||
raw = llm_text_gen(
|
||||
prompt=prompt,
|
||||
user_id=user_id,
|
||||
json_struct={"research_queries": [{"query": "string", "rationale": "string"}]},
|
||||
preferred_provider=None,
|
||||
flow_type="premium_tool",
|
||||
)
|
||||
|
||||
# Parse response
|
||||
if isinstance(raw, dict):
|
||||
queries = raw.get("research_queries", [])
|
||||
else:
|
||||
# Try to parse as JSON
|
||||
try:
|
||||
parsed = json.loads(raw) if isinstance(raw, str) else raw
|
||||
queries = parsed.get("research_queries", []) if isinstance(parsed, dict) else []
|
||||
except:
|
||||
queries = []
|
||||
|
||||
return RegenerateQueriesResponse(research_queries=queries[:7])
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as exc:
|
||||
logger.error(f"[Regenerate Queries] Failed for user {user_id}: {exc}")
|
||||
raise HTTPException(status_code=500, detail=f"Regenerate queries failed: {exc}")
|
||||
|
||||
|
||||
@@ -126,12 +126,14 @@ async def generate_podcast_audio(
|
||||
|
||||
try:
|
||||
audio_service = get_podcast_audio_service(user_id)
|
||||
logger.warning(f"[Podcast] Generating audio with service dir: {audio_service.output_dir}")
|
||||
result: StoryAudioResult = audio_service.generate_ai_audio(
|
||||
scene_number=0,
|
||||
scene_title=request.scene_title,
|
||||
text=request.text.strip(),
|
||||
user_id=user_id,
|
||||
voice_id=request.voice_id or "Wise_Woman",
|
||||
custom_voice_id=request.custom_voice_id,
|
||||
speed=request.speed or 1.0, # Normal speed (was 0.9, but too slow - causing duration issues)
|
||||
volume=request.volume or 1.0,
|
||||
pitch=request.pitch or 0.0, # Normal pitch (0.0 = neutral)
|
||||
@@ -149,6 +151,8 @@ async def generate_podcast_audio(
|
||||
if result.get("audio_url") and "/api/story/audio/" in result.get("audio_url", ""):
|
||||
audio_filename = result.get("audio_filename", "")
|
||||
result["audio_url"] = f"/api/podcast/audio/{audio_filename}"
|
||||
|
||||
logger.warning(f"[Podcast] Audio generated - path: {result.get('audio_path')}, url: {result.get('audio_url')}")
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=500, detail=f"Audio generation failed: {exc}")
|
||||
|
||||
@@ -387,7 +391,9 @@ async def serve_podcast_audio(
|
||||
raise HTTPException(status_code=400, detail="Invalid filename")
|
||||
|
||||
user_id = require_authenticated_user(current_user)
|
||||
logger.warning(f"[Podcast] serve_podcast_audio called: user_id={user_id}, filename={filename}")
|
||||
audio_path = _resolve_podcast_media_file(filename, "audio", user_id)
|
||||
logger.warning(f"[Podcast] Resolved audio path: {audio_path}")
|
||||
|
||||
return FileResponse(audio_path, media_type="audio/mpeg")
|
||||
|
||||
|
||||
@@ -29,16 +29,45 @@ from ..models import (
|
||||
VoiceCloneResult,
|
||||
)
|
||||
from services.dubbing import AudioDubbingService
|
||||
from ..constants import get_podcast_media_read_dirs, get_podcast_media_dir
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
_dubbing_executor = ThreadPoolExecutor(max_workers=4, thread_name_prefix="podcast_dubbing")
|
||||
|
||||
DUBBED_AUDIO_DIR = Path(__file__).resolve().parents[3] / "data" / "media" / "dubbed_audio"
|
||||
_DUBBED_AUDIO_SUBDIR = Path("dubbed_audio")
|
||||
_LEGACY_DUBBED_AUDIO_DIR = Path(__file__).resolve().parents[3] / "data" / "media" / "dubbed_audio"
|
||||
|
||||
|
||||
def _ensure_dubbed_audio_dir():
|
||||
DUBBED_AUDIO_DIR.mkdir(parents=True, exist_ok=True)
|
||||
def _get_dubbed_audio_dir(user_id: str, *, ensure_exists: bool = False) -> Path:
|
||||
"""Resolve tenant-scoped dubbed audio directory under podcast audio media."""
|
||||
base_dir = get_podcast_media_dir("audio", user_id, ensure_exists=ensure_exists)
|
||||
dubbed_dir = (base_dir / _DUBBED_AUDIO_SUBDIR).resolve()
|
||||
if ensure_exists:
|
||||
dubbed_dir.mkdir(parents=True, exist_ok=True)
|
||||
return dubbed_dir
|
||||
|
||||
|
||||
def _resolve_dubbed_audio_file(filename: str, user_id: str) -> Path:
|
||||
"""Resolve dubbed audio with traversal-safe checks (tenant first, then legacy fallback)."""
|
||||
clean_filename = filename.split("?", 1)[0].strip()
|
||||
if not clean_filename:
|
||||
raise HTTPException(status_code=400, detail="Invalid filename")
|
||||
|
||||
candidate_dirs: list[Path] = []
|
||||
for base_dir in get_podcast_media_read_dirs("audio", user_id):
|
||||
candidate_dirs.append((base_dir / _DUBBED_AUDIO_SUBDIR).resolve())
|
||||
candidate_dirs.append(_LEGACY_DUBBED_AUDIO_DIR.resolve())
|
||||
|
||||
for target_dir in candidate_dirs:
|
||||
candidate = (target_dir / clean_filename).resolve()
|
||||
if not str(candidate).startswith(str(target_dir)):
|
||||
logger.error(f"[Podcast][Dubbing] Attempted path traversal: {filename}")
|
||||
raise HTTPException(status_code=403, detail="Invalid audio path")
|
||||
if candidate.exists():
|
||||
return candidate
|
||||
|
||||
raise HTTPException(status_code=404, detail="Audio file not found")
|
||||
|
||||
|
||||
def _execute_dubbing_task(
|
||||
@@ -62,9 +91,8 @@ def _execute_dubbing_task(
|
||||
message="Starting audio dubbing..."
|
||||
)
|
||||
|
||||
_ensure_dubbed_audio_dir()
|
||||
|
||||
service = AudioDubbingService(output_dir=DUBBED_AUDIO_DIR)
|
||||
dubbed_audio_dir = _get_dubbed_audio_dir(user_id, ensure_exists=True)
|
||||
service = AudioDubbingService(output_dir=dubbed_audio_dir)
|
||||
|
||||
def progress_callback(progress: float, message: str):
|
||||
task_manager.update_task_status(
|
||||
@@ -136,9 +164,8 @@ def _execute_voice_clone_task(
|
||||
message="Starting voice cloning..."
|
||||
)
|
||||
|
||||
_ensure_dubbed_audio_dir()
|
||||
|
||||
service = AudioDubbingService(output_dir=DUBBED_AUDIO_DIR)
|
||||
dubbed_audio_dir = _get_dubbed_audio_dir(user_id, ensure_exists=True)
|
||||
service = AudioDubbingService(output_dir=dubbed_audio_dir)
|
||||
|
||||
task_manager.update_task_status(
|
||||
task_id, "processing", progress=30.0,
|
||||
@@ -203,7 +230,10 @@ async def create_audio_dubbing_task(
|
||||
"""
|
||||
user_id = require_authenticated_user(current_user)
|
||||
|
||||
task_id = task_manager.create_task("audio_dubbing")
|
||||
task_id = task_manager.create_task(
|
||||
"audio_dubbing",
|
||||
metadata={"owner_user_id": user_id},
|
||||
)
|
||||
|
||||
background_tasks.add_task(
|
||||
_execute_dubbing_task,
|
||||
@@ -240,7 +270,7 @@ async def get_dubbing_result(
|
||||
"""
|
||||
user_id = require_authenticated_user(current_user)
|
||||
|
||||
task_status = task_manager.get_task_status(task_id)
|
||||
task_status = task_manager.get_task_status(task_id, requester_user_id=user_id)
|
||||
|
||||
if not task_status:
|
||||
raise HTTPException(status_code=404, detail="Task not found")
|
||||
@@ -301,12 +331,7 @@ async def serve_dubbed_audio(
|
||||
"""
|
||||
user_id = require_authenticated_user(current_user)
|
||||
|
||||
_ensure_dubbed_audio_dir()
|
||||
|
||||
audio_path = DUBBED_AUDIO_DIR / filename
|
||||
|
||||
if not audio_path.exists():
|
||||
raise HTTPException(status_code=404, detail="Audio file not found")
|
||||
audio_path = _resolve_dubbed_audio_file(filename, user_id)
|
||||
|
||||
return FileResponse(
|
||||
path=audio_path,
|
||||
@@ -327,7 +352,8 @@ async def estimate_dubbing_cost(
|
||||
"""
|
||||
user_id = require_authenticated_user(current_user)
|
||||
|
||||
service = AudioDubbingService(output_dir=DUBBED_AUDIO_DIR)
|
||||
dubbed_audio_dir = _get_dubbed_audio_dir(user_id, ensure_exists=True)
|
||||
service = AudioDubbingService(output_dir=dubbed_audio_dir)
|
||||
|
||||
cost_estimate = service.estimate_cost(
|
||||
audio_duration_seconds=request.audio_duration_seconds,
|
||||
@@ -403,7 +429,10 @@ async def create_voice_clone_task(
|
||||
"""
|
||||
user_id = require_authenticated_user(current_user)
|
||||
|
||||
task_id = task_manager.create_task("voice_clone")
|
||||
task_id = task_manager.create_task(
|
||||
"voice_clone",
|
||||
metadata={"owner_user_id": user_id},
|
||||
)
|
||||
|
||||
background_tasks.add_task(
|
||||
_execute_voice_clone_task,
|
||||
@@ -434,7 +463,7 @@ async def get_voice_clone_result(
|
||||
"""
|
||||
user_id = require_authenticated_user(current_user)
|
||||
|
||||
task_status = task_manager.get_task_status(task_id)
|
||||
task_status = task_manager.get_task_status(task_id, requester_user_id=user_id)
|
||||
|
||||
if not task_status:
|
||||
raise HTTPException(status_code=404, detail="Task not found")
|
||||
@@ -479,12 +508,12 @@ async def serve_voice_audio(
|
||||
"""
|
||||
user_id = require_authenticated_user(current_user)
|
||||
|
||||
_ensure_dubbed_audio_dir()
|
||||
|
||||
audio_path = DUBBED_AUDIO_DIR / filename
|
||||
|
||||
if not audio_path.exists():
|
||||
raise HTTPException(status_code=404, detail="Voice audio file not found")
|
||||
try:
|
||||
audio_path = _resolve_dubbed_audio_file(filename, user_id)
|
||||
except HTTPException as exc:
|
||||
if exc.status_code == 404:
|
||||
raise HTTPException(status_code=404, detail="Voice audio file not found") from exc
|
||||
raise
|
||||
|
||||
return FileResponse(
|
||||
path=audio_path,
|
||||
|
||||
@@ -104,6 +104,16 @@ async def generate_podcast_scene_image(
|
||||
# Otherwise, generate from scratch with podcast-optimized prompt
|
||||
image_prompt = "" # Initialize prompt variable
|
||||
|
||||
# Emotion to lighting mapping for visual tone
|
||||
emotion_lighting = {
|
||||
"happy": "warm, bright lighting, cheerful atmosphere",
|
||||
"excited": "dynamic, energetic lighting with highlights",
|
||||
"serious": "professional, balanced lighting, authoritative feel",
|
||||
"curious": "soft, inviting lighting, thoughtful atmosphere",
|
||||
"confident": "strong, dramatic lighting, authoritative look",
|
||||
"neutral": "professional, balanced lighting"
|
||||
}
|
||||
|
||||
if base_avatar_bytes:
|
||||
# Use Ideogram Character API for consistent character generation
|
||||
# Use custom prompt if provided, otherwise build scene-specific prompt
|
||||
@@ -127,6 +137,28 @@ async def generate_podcast_scene_image(
|
||||
if bible_obj.host.look:
|
||||
prompt_parts.append(f"Host Look: {bible_obj.host.look}")
|
||||
|
||||
# Scene emotion for visual tone
|
||||
emotion_lighting = {
|
||||
"happy": "warm, bright lighting, cheerful atmosphere",
|
||||
"excited": "dynamic, energetic lighting with highlights",
|
||||
"serious": "professional, balanced lighting, authoritative feel",
|
||||
"curious": "soft, inviting lighting, thoughtful atmosphere",
|
||||
"confident": "strong, dramatic lighting, authoritative look",
|
||||
"neutral": "professional, balanced lighting"
|
||||
}
|
||||
scene_emotion = request.scene_emotion
|
||||
if scene_emotion and scene_emotion in emotion_lighting:
|
||||
prompt_parts.append(emotion_lighting[scene_emotion])
|
||||
|
||||
# AI Analysis context for visual relevance
|
||||
if request.analysis:
|
||||
keywords = request.analysis.get("topKeywords", [])[:5]
|
||||
if keywords:
|
||||
prompt_parts.append(f"Keywords: {', '.join(keywords)}")
|
||||
audience = request.analysis.get("audience", "")
|
||||
if audience:
|
||||
prompt_parts.append(f"Target: {audience}")
|
||||
|
||||
# Scene content insights for visual context
|
||||
if request.scene_content:
|
||||
content_preview = request.scene_content[:200].replace("\n", " ").strip()
|
||||
@@ -139,6 +171,12 @@ async def generate_podcast_scene_image(
|
||||
visual_keywords.append("modern tech studio setting")
|
||||
if any(word in content_lower for word in ["business", "growth", "strategy", "market"]):
|
||||
visual_keywords.append("professional business studio")
|
||||
if any(word in content_lower for word in ["nature", "outdoor", "environment", "green"]):
|
||||
visual_keywords.append("natural outdoor setting")
|
||||
if any(word in content_lower for word in ["medical", "health", "wellness"]):
|
||||
visual_keywords.append("clean medical studio")
|
||||
if any(word in content_lower for word in ["education", "learning", "students"]):
|
||||
visual_keywords.append("classroom or educational setting")
|
||||
if visual_keywords:
|
||||
prompt_parts.append(", ".join(visual_keywords))
|
||||
|
||||
@@ -265,6 +303,19 @@ async def generate_podcast_scene_image(
|
||||
if request.scene_title:
|
||||
prompt_parts.append(f"Scene theme: {request.scene_title}")
|
||||
|
||||
# Scene emotion for visual tone (no avatar branch)
|
||||
if request.scene_emotion and request.scene_emotion in emotion_lighting:
|
||||
prompt_parts.append(emotion_lighting[request.scene_emotion])
|
||||
|
||||
# AI Analysis context (no avatar branch)
|
||||
if request.analysis:
|
||||
keywords = request.analysis.get("topKeywords", [])[:5]
|
||||
if keywords:
|
||||
prompt_parts.append(f"Keywords: {', '.join(keywords)}")
|
||||
audience = request.analysis.get("audience", "")
|
||||
if audience:
|
||||
prompt_parts.append(f"Target: {audience}")
|
||||
|
||||
# Content context for visual relevance
|
||||
if request.scene_content:
|
||||
content_preview = request.scene_content[:150].replace("\n", " ").strip()
|
||||
@@ -276,6 +327,12 @@ async def generate_podcast_scene_image(
|
||||
visual_keywords.append("modern technology aesthetic")
|
||||
if any(word in content_lower for word in ["business", "growth", "strategy", "market"]):
|
||||
visual_keywords.append("professional business environment")
|
||||
if any(word in content_lower for word in ["nature", "outdoor", "environment"]):
|
||||
visual_keywords.append("natural outdoor setting")
|
||||
if any(word in content_lower for word in ["medical", "health", "wellness"]):
|
||||
visual_keywords.append("clean medical studio")
|
||||
if any(word in content_lower for word in ["education", "learning", "students"]):
|
||||
visual_keywords.append("classroom or educational setting")
|
||||
if visual_keywords:
|
||||
prompt_parts.append(", ".join(visual_keywords))
|
||||
|
||||
@@ -379,6 +436,7 @@ async def generate_podcast_scene_image(
|
||||
provider=result.provider,
|
||||
model=result.model,
|
||||
cost=cost,
|
||||
image_prompt=image_prompt,
|
||||
)
|
||||
|
||||
except HTTPException:
|
||||
|
||||
@@ -27,7 +27,10 @@ async def create_project(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: Dict[str, Any] = Depends(get_current_user),
|
||||
):
|
||||
"""Create a new podcast project."""
|
||||
"""Create a new podcast project.
|
||||
|
||||
If a project with the same idea already exists, return 409 conflict with existing project info.
|
||||
"""
|
||||
try:
|
||||
user_id = current_user.get("user_id") or current_user.get("id")
|
||||
if not user_id:
|
||||
@@ -40,6 +43,19 @@ async def create_project(
|
||||
if existing:
|
||||
raise HTTPException(status_code=400, detail="Project ID already exists")
|
||||
|
||||
# Check for duplicate idea (case-insensitive partial match)
|
||||
existing_idea = service.get_project_by_idea(user_id, request.idea)
|
||||
if existing_idea:
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail={
|
||||
"message": "A project with similar idea already exists",
|
||||
"existing_project_id": existing_idea.project_id,
|
||||
"existing_idea": existing_idea.idea,
|
||||
"existing_status": existing_idea.status,
|
||||
}
|
||||
)
|
||||
|
||||
project = service.create_project(
|
||||
user_id=user_id,
|
||||
project_id=request.project_id,
|
||||
|
||||
@@ -8,6 +8,7 @@ from fastapi import APIRouter, Depends, HTTPException
|
||||
from typing import Dict, Any, List
|
||||
from types import SimpleNamespace
|
||||
import json
|
||||
import re
|
||||
|
||||
from middleware.auth_middleware import get_current_user
|
||||
from api.story_writer.utils.auth import require_authenticated_user
|
||||
@@ -36,10 +37,16 @@ async def podcast_research_exa(
|
||||
Uses Podcast Bible and Analysis context for hyper-personalization.
|
||||
"""
|
||||
user_id = require_authenticated_user(current_user)
|
||||
logger.warning(f"[Podcast Research] ========== REQUEST START ==========")
|
||||
logger.warning(f"[Podcast Research] User: {user_id}, Topic: {request.topic[:80]}...")
|
||||
logger.warning(f"[Podcast Research] Queries count: {len(request.queries) if request.queries else 0}")
|
||||
|
||||
|
||||
queries = [q.strip() for q in request.queries if q and q.strip()]
|
||||
if not queries:
|
||||
raise HTTPException(status_code=400, detail="At least one query is required for research.")
|
||||
|
||||
logger.warning(f"[Podcast Research] EXACT queries being sent to Exa: {queries}")
|
||||
|
||||
exa_cfg = request.exa_config or PodcastExaConfig()
|
||||
cfg = SimpleNamespace(
|
||||
@@ -52,6 +59,7 @@ async def podcast_research_exa(
|
||||
)
|
||||
|
||||
provider = ExaResearchProvider()
|
||||
logger.warning(f"[Podcast Research] Provider initialized, starting Exa search...")
|
||||
|
||||
# --- Context Building ---
|
||||
bible_service = PodcastBibleService()
|
||||
@@ -68,9 +76,16 @@ async def podcast_research_exa(
|
||||
if request.analysis:
|
||||
analysis_context = f"""
|
||||
PODCAST ANALYSIS CONTEXT:
|
||||
Audience: {request.analysis.get('audience', 'General')}
|
||||
========================
|
||||
Topic: {request.topic}
|
||||
Target Audience: {request.analysis.get('audience', 'General')}
|
||||
Content Type: {request.analysis.get('content_type', 'Informative')}
|
||||
Top Keywords: {', '.join(request.analysis.get('top_keywords', []))}
|
||||
|
||||
Episode Hook (Intro): {request.analysis.get('episode_hook', 'N/A')}
|
||||
Key Takeaways: {', '.join(request.analysis.get('key_takeaways', [])) or 'N/A'}
|
||||
Guest Talking Points: {', '.join(request.analysis.get('guest_talking_points', [])) or 'N/A'}
|
||||
Listener CTA: {request.analysis.get('listener_cta', 'N/A')}
|
||||
"""
|
||||
|
||||
# Exa search params
|
||||
@@ -84,6 +99,7 @@ Top Keywords: {', '.join(request.analysis.get('top_keywords', []))}
|
||||
|
||||
try:
|
||||
# 1. RUN EXA SEARCH
|
||||
logger.warning(f"[Podcast Research] Calling Exa search with topic: {request.topic[:100]}...")
|
||||
result = await provider.search(
|
||||
prompt=request.topic,
|
||||
topic=request.topic,
|
||||
@@ -92,8 +108,9 @@ Top Keywords: {', '.join(request.analysis.get('top_keywords', []))}
|
||||
config=cfg,
|
||||
user_id=user_id,
|
||||
)
|
||||
logger.warning(f"[Podcast Research] Exa search completed, got {len(result.get('sources', []))} sources")
|
||||
except Exception as exc:
|
||||
logger.error(f"[Podcast Exa Research] Search failed for user {user_id}: {exc}")
|
||||
logger.error(f"[Podcast Exa Research] Search failed for user {user_id}: {exc}", exc_info=True)
|
||||
raise HTTPException(status_code=500, detail=f"Exa research failed: {exc}")
|
||||
|
||||
# 2. EXTRACT INSIGHTS VIA LLM
|
||||
@@ -104,66 +121,128 @@ Top Keywords: {', '.join(request.analysis.get('top_keywords', []))}
|
||||
key_insights = []
|
||||
|
||||
if raw_content and sources:
|
||||
logger.info(f"[Podcast Research] Extracting insights from {len(sources)} sources for user {user_id}")
|
||||
logger.warning(f"[Podcast Research] Extracting insights from {len(sources)} sources for user {user_id}")
|
||||
|
||||
# Build list of research queries used for this search
|
||||
queries_used = ", ".join([f"Query {i+1}: {q}" for i, q in enumerate(queries)]) if queries else "No specific queries"
|
||||
|
||||
prompt = f"""
|
||||
You are an expert research analyst for a high-end podcast production team.
|
||||
Your task is to analyze the following research data and extract deep, actionable insights for a podcast episode.
|
||||
You are an expert research analyst and content strategist for a high-end podcast production team.
|
||||
Your task is to analyze the research data and extract deep, podcast-ready insights.
|
||||
|
||||
PODCAST CONTEXT:
|
||||
Topic: {request.topic}
|
||||
================
|
||||
Main Topic: {request.topic}
|
||||
|
||||
RESEARCH QUERIES USED:
|
||||
=====================
|
||||
{queries_used}
|
||||
|
||||
PODCAST BIBLE & BRAND CONTEXT:
|
||||
==============================
|
||||
{bible_context}
|
||||
|
||||
PODCAST ANALYSIS (from AI Analysis phase):
|
||||
==========================================
|
||||
{analysis_context}
|
||||
|
||||
RESEARCH DATA (from {len(sources)} sources):
|
||||
============================================
|
||||
{raw_content}
|
||||
|
||||
TASK:
|
||||
1. Provide a comprehensive summary (2-3 paragraphs) of the most important findings. Use Markdown for formatting (bolding, lists).
|
||||
2. Extract 3-5 "Key Insights". Each insight should have a title and a detailed explanation.
|
||||
3. For each insight, identify which source indices (e.g. 1, 2) it was derived from.
|
||||
YOUR TASK:
|
||||
==========
|
||||
As a podcast research expert, analyze this data and create content that will:
|
||||
1. Engage the specific target audience identified above
|
||||
2. Support the episode hook and key takeaways already planned
|
||||
3. Provide talking points that complement the guest's expertise
|
||||
4. Include a compelling call-to-action for listeners
|
||||
|
||||
NOTE: The research data includes "Key Highlights", "Summaries", and "Excerpts" from various sources.
|
||||
Pay special attention to the "Key Highlights" sections as they contain the most relevant information extracted by the neural search engine.
|
||||
|
||||
Return JSON structure:
|
||||
REQUIRED OUTPUT (JSON):
|
||||
=======================
|
||||
{{
|
||||
"summary": "Detailed markdown summary...",
|
||||
"summary": "2-3 paragraph comprehensive summary in Markdown. Start with a hook that matches the episode intro. Include specific data points, expert quotes, and trends.",
|
||||
"key_insights": [
|
||||
{{
|
||||
"title": "Insight Title",
|
||||
"content": "Detailed markdown content...",
|
||||
"source_indices": [1, 2]
|
||||
"title": "Catchy, engaging title for this insight",
|
||||
"content": "3-4 sentences with specific facts, quotes, or data. Write in a conversational tone suitable for a podcast host to discuss.",
|
||||
"source_indices": [1, 2, 3],
|
||||
"podcast_talking_points": ["Point 1 host can expand on", "Counter-point or follow-up", "Question to ask guest"]
|
||||
}}
|
||||
]
|
||||
],
|
||||
"expert_quotes": [
|
||||
{{
|
||||
"quote": "Direct quote from source",
|
||||
"source_index": 1,
|
||||
"context": "Why this quote matters for the podcast"
|
||||
}}
|
||||
],
|
||||
"listener_cta_suggestions": ["Specific action listener can take", "Resource to share", "Next episode preview"]
|
||||
}}
|
||||
|
||||
Requirements:
|
||||
- Ensure insights are deep, not just superficial facts. Look for trends, expert opinions, and specific data points.
|
||||
- Tone should be professional, insightful, and ready for a podcast host to discuss.
|
||||
- Avoid generic filler.
|
||||
QUALITY STANDARDS:
|
||||
==================
|
||||
- INSIGHTS MUST BE DEEP, not superficial - avoid generic statements
|
||||
- Include SPECIFIC DATA POINTS, percentages, statistics when available
|
||||
- Extract EXPERT QUOTES that hosts can reference
|
||||
- Identify GAPS in the research where more depth is needed
|
||||
- Make content naturally flow into the planned episode hook and CTA
|
||||
- Write in a CONVERSATIONAL tone - how a host would actually speak
|
||||
- Flag any CONTROVERSIAL or debatable claims for host to address
|
||||
"""
|
||||
try:
|
||||
logger.warning(f"[Podcast Research] Calling LLM for insight extraction...")
|
||||
llm_response = llm_text_gen(
|
||||
prompt=prompt,
|
||||
user_id=user_id,
|
||||
json_struct=None,
|
||||
preferred_provider="huggingface",
|
||||
preferred_provider=None,
|
||||
flow_type="premium_tool",
|
||||
)
|
||||
logger.warning(f"[Podcast Research] LLM response received, length: {len(llm_response) if llm_response else 0}")
|
||||
|
||||
# Normalize response
|
||||
# Normalize response - handle both string and dict responses
|
||||
data = None
|
||||
if isinstance(llm_response, str):
|
||||
data = json.loads(llm_response)
|
||||
try:
|
||||
# Try to fix common JSON issues
|
||||
fixed_response = llm_response.strip()
|
||||
# Remove markdown code blocks if present
|
||||
if fixed_response.startswith("```"):
|
||||
fixed_response = fixed_response.split("```")[1]
|
||||
if fixed_response.startswith("json"):
|
||||
fixed_response = fixed_response[4:]
|
||||
fixed_response = fixed_response.strip()
|
||||
data = json.loads(fixed_response)
|
||||
except json.JSONDecodeError as json_err:
|
||||
logger.warning(f"[Podcast Research] Failed to parse JSON: {json_err}. Response preview: {llm_response[:500]}...")
|
||||
# Try to extract JSON from response using regex
|
||||
json_match = re.search(r'\{.*\}', llm_response, re.DOTALL)
|
||||
if json_match:
|
||||
try:
|
||||
data = json.loads(json_match.group())
|
||||
logger.warning("[Podcast Research] Successfully extracted JSON via regex")
|
||||
except:
|
||||
pass
|
||||
else:
|
||||
data = llm_response
|
||||
|
||||
summary = data.get("summary", "")
|
||||
key_insights = [PodcastResearchInsight(**insight) for insight in data.get("key_insights", [])]
|
||||
|
||||
if data:
|
||||
try:
|
||||
summary = data.get("summary", "")
|
||||
key_insights = [PodcastResearchInsight(**insight) for insight in data.get("key_insights", [])]
|
||||
except Exception as insight_err:
|
||||
logger.warning(f"[Podcast Research] Failed to parse insights: {insight_err}. Data keys: {list(data.keys()) if isinstance(data, dict) else 'not a dict'}")
|
||||
summary = data.get("summary", "") if isinstance(data, dict) else ""
|
||||
key_insights = []
|
||||
else:
|
||||
summary = ""
|
||||
key_insights = []
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as exc:
|
||||
logger.error(f"[Podcast Research] LLM Insight extraction failed: {exc}")
|
||||
# Fallback to a basic summary if LLM fails
|
||||
summary = f"Research completed for '{request.topic}'. Found {len(sources)} sources."
|
||||
raise HTTPException(status_code=500, detail=f"Research insight extraction failed: {exc}")
|
||||
|
||||
# Fallback: if summary is still empty (e.g. LLM returned empty string), use raw content first paragraph or basic text
|
||||
if not summary:
|
||||
@@ -182,21 +261,32 @@ Requirements:
|
||||
logger.warning(f"[Podcast Exa Research] Failed to track usage: {track_err}")
|
||||
|
||||
sources_payload = []
|
||||
seen_urls = set()
|
||||
for src in sources:
|
||||
url = src.get("url", "")
|
||||
# Skip duplicates
|
||||
if url and url in seen_urls:
|
||||
continue
|
||||
if url:
|
||||
seen_urls.add(url)
|
||||
|
||||
try:
|
||||
sources_payload.append(PodcastExaSource(**src))
|
||||
except Exception:
|
||||
sources_payload.append(PodcastExaSource(**{
|
||||
"title": src.get("title", ""),
|
||||
"url": src.get("url", ""),
|
||||
"excerpt": src.get("excerpt", ""),
|
||||
"url": url,
|
||||
"excerpt": src.get("excerpt") or (src.get("highlights")[0] if src.get("highlights") else "") or src.get("summary", ""),
|
||||
"published_at": src.get("published_at"),
|
||||
"publishedDate": src.get("publishedDate"),
|
||||
"highlights": src.get("highlights"),
|
||||
"summary": src.get("summary"),
|
||||
"source_type": src.get("source_type"),
|
||||
"index": src.get("index"),
|
||||
"image": src.get("image"),
|
||||
"author": src.get("author"),
|
||||
"text": src.get("text"),
|
||||
"credibility_score": src.get("credibility_score"),
|
||||
}))
|
||||
|
||||
return PodcastExaResearchResponse(
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
"""
|
||||
Podcast Script Handlers
|
||||
|
||||
Script generation endpoint.
|
||||
Script generation and approval endpoints.
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from typing import Dict, Any
|
||||
from typing import Dict, Any, Optional
|
||||
from pydantic import BaseModel, Field
|
||||
import json
|
||||
|
||||
from middleware.auth_middleware import get_current_user
|
||||
@@ -24,6 +25,29 @@ from ..models import (
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class SceneApprovalRequest(BaseModel):
|
||||
project_id: str = Field(..., min_length=1)
|
||||
scene_id: str = Field(..., min_length=1)
|
||||
approved: bool = True
|
||||
notes: Optional[str] = None
|
||||
|
||||
|
||||
@router.post("/script/approve")
|
||||
async def approve_podcast_scene(
|
||||
request: SceneApprovalRequest,
|
||||
current_user: Dict[str, Any] = Depends(get_current_user),
|
||||
) -> Dict[str, Any]:
|
||||
"""Persist scene approval metadata for auditing (podcast-specific)."""
|
||||
user_id = require_authenticated_user(current_user)
|
||||
logger.warning(f"[Podcast] Scene approval recorded user={user_id} project={request.project_id} scene={request.scene_id} approved={request.approved}")
|
||||
return {
|
||||
"success": True,
|
||||
"project_id": request.project_id,
|
||||
"scene_id": request.scene_id,
|
||||
"approved": request.approved,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/script", response_model=PodcastScriptResponse)
|
||||
async def generate_podcast_script(
|
||||
request: PodcastScriptRequest,
|
||||
@@ -33,6 +57,10 @@ async def generate_podcast_script(
|
||||
Generate a podcast script outline (scenes + lines) using podcast-oriented prompting.
|
||||
"""
|
||||
user_id = require_authenticated_user(current_user)
|
||||
logger.warning(f"[ScriptGen] ========== SCRIPT GENERATION START ==========")
|
||||
logger.warning(f"[ScriptGen] Topic: {request.idea[:60]}...")
|
||||
logger.warning(f"[ScriptGen] Duration: {request.duration_minutes} min, Speakers: {request.speakers}")
|
||||
logger.warning(f"[ScriptGen] Has research: {bool(request.research)}, Has bible: {bool(request.bible)}, Has analysis: {bool(request.analysis)}")
|
||||
|
||||
# Build comprehensive research context for higher-quality scripts
|
||||
research_context = ""
|
||||
@@ -77,62 +105,63 @@ async def generate_podcast_script(
|
||||
# Extract Analysis and Outline context for grounding
|
||||
analysis_context = ""
|
||||
if request.analysis:
|
||||
analysis_context = f"""
|
||||
TARGET AUDIENCE: {request.analysis.get('audience', 'General')}
|
||||
CONTENT TYPE: {request.analysis.get('contentType', 'Conversational')}
|
||||
TOP KEYWORDS: {', '.join(request.analysis.get('topKeywords', []))}
|
||||
"""
|
||||
try:
|
||||
audience = request.analysis.get('audience', '') or ''
|
||||
content_type = request.analysis.get('contentType', '') or ''
|
||||
keywords = request.analysis.get('topKeywords', []) or []
|
||||
analysis_context = f"ANALYSIS: Audience={audience} | Type={content_type} | Keywords={', '.join(keywords[:8])}"
|
||||
except:
|
||||
pass
|
||||
|
||||
outline_context = ""
|
||||
if request.outline:
|
||||
outline_context = f"""
|
||||
REFINED EPISODE OUTLINE (Follow this structure closely):
|
||||
Title: {request.outline.get('title', 'N/A')}
|
||||
Segments: {' | '.join(request.outline.get('segments', []))}
|
||||
"""
|
||||
try:
|
||||
title = request.outline.get('title', '') or ''
|
||||
segments = request.outline.get('segments', []) or []
|
||||
outline_context = f"OUTLINE: {title} - {' | '.join(segments[:5])}"
|
||||
except:
|
||||
pass
|
||||
|
||||
prompt = f"""You are an expert podcast script planner. Create natural, conversational podcast scenes.
|
||||
prompt = f"""Create a podcast script with scenes and dialogue.
|
||||
|
||||
{f"PODCAST BIBLE (Hyper-Personalization Context):\n{bible_context}\n" if bible_context else ""}
|
||||
{f"ANALYSIS CONTEXT:\n{analysis_context}\n" if analysis_context else ""}
|
||||
{f"REFINED OUTLINE:\n{outline_context}\n" if outline_context else ""}
|
||||
{f"BIBLE: {bible_context[:1500]}" if bible_context else ""}
|
||||
{f"{analysis_context}" if analysis_context else ""}
|
||||
{f"{outline_context}" if outline_context else ""}
|
||||
{f"RESEARCH: {research_context[:1200]}" if research_context else ""}
|
||||
|
||||
Podcast Idea: "{request.idea}"
|
||||
Duration: ~{request.duration_minutes} minutes
|
||||
Speakers: {request.speakers} (Host + optional Guest)
|
||||
Topic: "{request.idea}"
|
||||
Duration: {request.duration_minutes} min | Speakers: {request.speakers}
|
||||
|
||||
{f"RESEARCH CONTEXT:\n{research_context}\n" if research_context else ""}
|
||||
Return JSON with scenes array. Each scene:
|
||||
- id: string
|
||||
- title: short title (<=50 chars)
|
||||
- duration: seconds (total/5)
|
||||
- emotion: neutral|happy|excited|serious|curious|confident
|
||||
- lines: array of {{speaker, text, emphasis}}
|
||||
- Use 2-4 LINES PER SCENE (shorter script = lower TTS costs)
|
||||
- Each line: 1-3 sentences, conversational
|
||||
- Plain text only, no markdown
|
||||
|
||||
Return JSON with:
|
||||
- scenes: array of scenes. Each scene has:
|
||||
- id: string
|
||||
- title: short scene title (<= 60 chars)
|
||||
- duration: duration in seconds (evenly split across total duration)
|
||||
- emotion: string (one of: "neutral", "happy", "excited", "serious", "curious", "confident")
|
||||
- lines: array of {{"speaker": "...", "text": "...", "emphasis": boolean}}
|
||||
* Write natural, conversational dialogue
|
||||
* Each line can be a sentence or a few sentences that flow together
|
||||
* Use plain text only - no markdown formatting (no asterisks, underscores, etc.)
|
||||
* Mark "emphasis": true for key statistics or important points
|
||||
|
||||
Guidelines:
|
||||
- Write for spoken delivery: conversational, natural, with contractions.
|
||||
- Follow the interaction tone specified in the Bible.
|
||||
- Ensure the Host persona matches the background and personality traits from the Bible.
|
||||
- Structure the intro and outro scenes according to the Bible's "Intro Format" and "Outro Format".
|
||||
- Adhere to any constraints mentioned in the Bible.
|
||||
- Use insights from the Research Context to ground the conversation in facts.
|
||||
- IMPORTANT: Follow the REFINED OUTLINE segments as the primary structure for the episode.
|
||||
COST OPTIMIZATION:
|
||||
- 5-6 scenes max for {request.duration_minutes} min episode
|
||||
- Concise, information-dense dialogue
|
||||
- Skip filler words and redundant phrases
|
||||
- Focus on unique insights from research
|
||||
- Make every line count toward value delivery
|
||||
"""
|
||||
|
||||
try:
|
||||
logger.warning(f"[ScriptGen] Calling LLM to generate script (prompt length: {len(prompt)})...")
|
||||
raw = llm_text_gen(
|
||||
prompt=prompt,
|
||||
user_id=user_id,
|
||||
json_struct=None,
|
||||
preferred_provider="huggingface",
|
||||
preferred_provider=None,
|
||||
flow_type="premium_tool",
|
||||
)
|
||||
logger.warning(f"[ScriptGen] LLM response received, length: {len(raw) if raw else 0}")
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=500, detail=f"Script generation failed: {exc}")
|
||||
|
||||
|
||||
@@ -140,17 +140,20 @@ def _execute_podcast_video_task(
|
||||
except Exception as e:
|
||||
logger.warning(f"[Podcast] Failed to fetch project context for video generation: {e}")
|
||||
|
||||
# Prepare scene data for animation
|
||||
# Prepare scene data for animation - include all context for enhanced prompt
|
||||
scene_data = {
|
||||
"scene_number": scene_number,
|
||||
"title": request.scene_title,
|
||||
"scene_id": request.scene_id,
|
||||
"image_prompt": request.scene_image_prompt,
|
||||
"description": request.scene_narration,
|
||||
"lines": [{"text": request.scene_narration}] if request.scene_narration else [],
|
||||
}
|
||||
story_context = {
|
||||
"project_id": request.project_id,
|
||||
"type": "podcast",
|
||||
"bible": project_bible,
|
||||
"analysis": project_analysis,
|
||||
"analysis": request.analysis or project_analysis, # Use passed analysis or fallback to DB
|
||||
}
|
||||
|
||||
animation_result = animate_scene_with_voiceover(
|
||||
@@ -222,7 +225,7 @@ def _execute_podcast_video_task(
|
||||
)
|
||||
|
||||
# Verify the task status was updated correctly
|
||||
updated_status = task_manager.get_task_status(task_id)
|
||||
updated_status = task_manager.get_task_status(task_id, requester_user_id=user_id)
|
||||
logger.info(
|
||||
f"[Podcast] Task status after update: task_id={task_id}, status={updated_status.get('status') if updated_status else 'None'}, has_result={bool(updated_status.get('result') if updated_status else False)}, video_url={updated_status.get('result', {}).get('video_url') if updated_status else 'N/A'}"
|
||||
)
|
||||
@@ -358,7 +361,10 @@ async def generate_podcast_video(
|
||||
logger.warning(f"[Podcast] Failed to extract auth token from headers: {e}")
|
||||
|
||||
# Create async task
|
||||
task_id = task_manager.create_task("podcast_video_generation")
|
||||
task_id = task_manager.create_task(
|
||||
"podcast_video_generation",
|
||||
metadata={"owner_user_id": user_id},
|
||||
)
|
||||
background_tasks.add_task(
|
||||
_execute_podcast_video_task,
|
||||
task_id=task_id,
|
||||
@@ -488,7 +494,10 @@ async def combine_podcast_videos(
|
||||
raise HTTPException(status_code=400, detail="No scene videos provided")
|
||||
|
||||
# Create async task
|
||||
task_id = task_manager.create_task("podcast_combine_videos")
|
||||
task_id = task_manager.create_task(
|
||||
"podcast_combine_videos",
|
||||
metadata={"owner_user_id": user_id},
|
||||
)
|
||||
|
||||
# Extract token for authenticated URL building
|
||||
auth_token = None
|
||||
|
||||
@@ -63,6 +63,10 @@ class PodcastAnalyzeResponse(BaseModel):
|
||||
top_keywords: list[str]
|
||||
suggested_outlines: list[Dict[str, Any]]
|
||||
title_suggestions: list[str]
|
||||
episode_hook: Optional[str] = None
|
||||
key_takeaways: Optional[list[str]] = None
|
||||
guest_talking_points: Optional[list[str]] = None
|
||||
listener_cta: Optional[str] = None
|
||||
research_queries: Optional[List[Dict[str, str]]] = None
|
||||
exa_suggested_config: Optional[Dict[str, Any]] = None
|
||||
bible: Optional[Dict[str, Any]] = None
|
||||
@@ -142,12 +146,15 @@ class PodcastExaSource(BaseModel):
|
||||
url: str = ""
|
||||
excerpt: str = ""
|
||||
published_at: Optional[str] = None
|
||||
publishedDate: Optional[str] = None # Exa format
|
||||
highlights: Optional[List[str]] = None
|
||||
summary: Optional[str] = None
|
||||
source_type: Optional[str] = None
|
||||
index: Optional[int] = None
|
||||
image: Optional[str] = None
|
||||
author: Optional[str] = None
|
||||
text: Optional[str] = None # Exa full text
|
||||
credibility_score: Optional[float] = None # Exa scores
|
||||
|
||||
|
||||
class PodcastResearchInsight(BaseModel):
|
||||
@@ -155,6 +162,9 @@ class PodcastResearchInsight(BaseModel):
|
||||
title: str
|
||||
content: str
|
||||
source_indices: List[int] = []
|
||||
podcast_talking_points: Optional[List[str]] = [] # Talking points for host to expand on
|
||||
expert_quotes: Optional[List[Dict[str, str]]] = [] # Quotes from sources
|
||||
listener_cta_suggestions: Optional[List[str]] = [] # CTA suggestions
|
||||
|
||||
|
||||
class PodcastExaResearchResponse(BaseModel):
|
||||
@@ -178,6 +188,7 @@ class PodcastAudioRequest(BaseModel):
|
||||
scene_title: str
|
||||
text: str
|
||||
voice_id: Optional[str] = "Wise_Woman"
|
||||
custom_voice_id: Optional[str] = None # Voice clone ID for custom voice
|
||||
speed: Optional[float] = 1.0
|
||||
volume: Optional[float] = 1.0
|
||||
pitch: Optional[float] = 0.0
|
||||
@@ -263,7 +274,9 @@ class PodcastImageRequest(BaseModel):
|
||||
scene_id: str
|
||||
scene_title: str
|
||||
scene_content: Optional[str] = None # Optional: scene lines text for context
|
||||
scene_emotion: Optional[str] = None # Optional: scene emotion for visual tone
|
||||
idea: Optional[str] = None # Optional: podcast idea for context
|
||||
analysis: Optional[Dict[str, Any]] = Field(None, description="AI analysis for visual context (keywords, audience)")
|
||||
base_avatar_url: Optional[str] = None # Base avatar image URL for scene variations
|
||||
bible: Optional[Dict[str, Any]] = Field(None, description="Podcast Bible for hyper-personalization")
|
||||
width: int = 1024
|
||||
@@ -285,6 +298,7 @@ class PodcastImageResponse(BaseModel):
|
||||
provider: str
|
||||
model: Optional[str] = None
|
||||
cost: float
|
||||
image_prompt: Optional[str] = None # Return the prompt used for generation
|
||||
|
||||
|
||||
class PodcastVideoGenerationRequest(BaseModel):
|
||||
@@ -295,6 +309,9 @@ class PodcastVideoGenerationRequest(BaseModel):
|
||||
audio_url: str = Field(..., description="URL to the generated audio file")
|
||||
avatar_image_url: Optional[str] = Field(None, description="URL to scene image (required for video generation)")
|
||||
bible: Optional[Dict[str, Any]] = Field(None, description="Podcast Bible for hyper-personalization")
|
||||
analysis: Optional[Dict[str, Any]] = Field(None, description="Podcast Analysis for context (content type, audience, takeaways, guest)")
|
||||
scene_image_prompt: Optional[str] = Field(None, description="Original image generation prompt for visual context")
|
||||
scene_narration: Optional[str] = Field(None, description="Scene narration/script lines for context")
|
||||
resolution: str = Field("720p", description="Video resolution (480p or 720p)")
|
||||
prompt: Optional[str] = Field(None, description="Optional animation prompt override")
|
||||
seed: Optional[int] = Field(-1, description="Random seed; -1 for random")
|
||||
|
||||
@@ -4,7 +4,7 @@ Podcast Maker API Router
|
||||
Main router that imports and registers all handler modules.
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from typing import Dict, Any
|
||||
|
||||
from middleware.auth_middleware import get_current_user
|
||||
@@ -32,5 +32,8 @@ router.include_router(dubbing.router)
|
||||
@router.get("/task/{task_id}/status")
|
||||
async def podcast_task_status(task_id: str, current_user: Dict[str, Any] = Depends(get_current_user)):
|
||||
"""Expose task status under podcast namespace (reuses shared task manager)."""
|
||||
require_authenticated_user(current_user)
|
||||
return task_manager.get_task_status(task_id)
|
||||
user_id = require_authenticated_user(current_user)
|
||||
task_status = task_manager.get_task_status(task_id, requester_user_id=user_id)
|
||||
if not task_status:
|
||||
raise HTTPException(status_code=404, detail="Task not found")
|
||||
return task_status
|
||||
|
||||
@@ -34,9 +34,14 @@ class TaskManager:
|
||||
del self.task_storage[task_id]
|
||||
logger.debug(f"[StoryWriter] Cleaned up old task: {task_id}")
|
||||
|
||||
def create_task(self, task_type: str = "story_generation") -> str:
|
||||
def create_task(
|
||||
self,
|
||||
task_type: str = "story_generation",
|
||||
metadata: Optional[Dict[str, Any]] = None,
|
||||
) -> str:
|
||||
"""Create a new task and return its ID."""
|
||||
task_id = str(uuid.uuid4())
|
||||
task_metadata = metadata or {}
|
||||
|
||||
self.task_storage[task_id] = {
|
||||
"status": "pending",
|
||||
@@ -45,13 +50,14 @@ class TaskManager:
|
||||
"error": None,
|
||||
"progress_messages": [],
|
||||
"task_type": task_type,
|
||||
"progress": 0.0
|
||||
"progress": 0.0,
|
||||
"metadata": task_metadata,
|
||||
}
|
||||
|
||||
logger.info(f"[StoryWriter] Created task: {task_id} (type: {task_type})")
|
||||
return task_id
|
||||
|
||||
def get_task_status(self, task_id: str) -> Optional[Dict[str, Any]]:
|
||||
def get_task_status(self, task_id: str, requester_user_id: Optional[str] = None) -> Optional[Dict[str, Any]]:
|
||||
"""Get the status of a task."""
|
||||
self.cleanup_old_tasks()
|
||||
|
||||
@@ -62,6 +68,15 @@ class TaskManager:
|
||||
return None
|
||||
|
||||
task = self.task_storage[task_id]
|
||||
metadata = task.get("metadata", {}) or {}
|
||||
owner_user_id = metadata.get("owner_user_id")
|
||||
|
||||
if requester_user_id is not None and owner_user_id is not None and requester_user_id != owner_user_id:
|
||||
logger.warning(
|
||||
f"[StoryWriter] Task access denied for task {task_id}: requester does not match owner"
|
||||
)
|
||||
return None
|
||||
|
||||
response = {
|
||||
"task_id": task_id,
|
||||
"status": task["status"],
|
||||
|
||||
257
backend/app.py
257
backend/app.py
@@ -9,10 +9,40 @@ builtins.Dict = typing.Dict
|
||||
builtins.Any = typing.Any
|
||||
builtins.Union = typing.Union
|
||||
|
||||
# Import onboarding models VERY early to ensure they're available before any services
|
||||
# Load environment variables FIRST before any other imports
|
||||
from pathlib import Path
|
||||
from dotenv import load_dotenv
|
||||
backend_dir = Path(__file__).parent
|
||||
project_root = backend_dir.parent
|
||||
load_dotenv(backend_dir / '.env')
|
||||
load_dotenv(project_root / '.env')
|
||||
load_dotenv()
|
||||
|
||||
# Set LOG_LEVEL early to WARNING to suppress DEBUG persona logs in podcast mode
|
||||
import os
|
||||
if os.getenv("ALWRITY_ENABLED_FEATURES", "").strip().lower() == "podcast":
|
||||
os.environ["LOG_LEVEL"] = "WARNING"
|
||||
|
||||
|
||||
def get_enabled_features() -> set:
|
||||
"""Get enabled features from ALWRITY_ENABLED_FEATURES env var."""
|
||||
env_value = os.getenv("ALWRITY_ENABLED_FEATURES", "all").strip().lower()
|
||||
if not env_value or env_value == "all":
|
||||
return {"all"}
|
||||
return {f.strip() for f in env_value.split(",") if f.strip()}
|
||||
|
||||
|
||||
def is_podcast_only_demo_mode() -> bool:
|
||||
"""Check if podcast-only mode is enabled."""
|
||||
enabled = get_enabled_features()
|
||||
return "podcast" in enabled and "all" not in enabled
|
||||
|
||||
|
||||
# Import onboarding models (after env is loaded)
|
||||
from models.onboarding import APIKey, WebsiteAnalysis, ResearchPreferences, PersonaData, CompetitorAnalysis
|
||||
|
||||
|
||||
# Import FastAPI and related
|
||||
from fastapi import FastAPI, HTTPException, Depends, Request, BackgroundTasks
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
@@ -20,33 +50,29 @@ from fastapi.responses import FileResponse
|
||||
from pydantic import BaseModel
|
||||
from typing import Dict, Any, Optional
|
||||
import os
|
||||
from loguru import logger
|
||||
from dotenv import load_dotenv
|
||||
import asyncio
|
||||
from datetime import datetime
|
||||
from loguru import logger
|
||||
|
||||
# Import OnboardingSession right after basic imports to ensure it's available
|
||||
from models.onboarding import OnboardingSession
|
||||
|
||||
# Import modular utilities (skip OnboardingManager import in podcast-only mode)
|
||||
from alwrity_utils import HealthChecker, RateLimiter, FrontendServing, RouterManager
|
||||
if not is_podcast_only_demo_mode():
|
||||
from alwrity_utils import OnboardingManager
|
||||
|
||||
# Import monitoring middleware
|
||||
from services.subscription import monitoring_middleware
|
||||
|
||||
# Import remaining onboarding models
|
||||
from models import APIKey, WebsiteAnalysis, ResearchPreferences, PersonaData, CompetitorAnalysis
|
||||
|
||||
# Import modular utilities
|
||||
from alwrity_utils import HealthChecker, RateLimiter, FrontendServing, RouterManager
|
||||
from alwrity_utils import OnboardingManager
|
||||
def should_include_non_podcast_features() -> bool:
|
||||
"""Check if non-podcast features should be included."""
|
||||
enabled = get_enabled_features()
|
||||
return "all" in enabled or "core" in enabled
|
||||
|
||||
# Load environment variables
|
||||
# Try multiple locations for .env file
|
||||
from pathlib import Path
|
||||
backend_dir = Path(__file__).parent
|
||||
project_root = backend_dir.parent
|
||||
|
||||
# Load from backend/.env first (higher priority), then root .env
|
||||
load_dotenv(backend_dir / '.env') # backend/.env
|
||||
load_dotenv(project_root / '.env') # root .env (fallback)
|
||||
load_dotenv() # CWD .env (fallback)
|
||||
# Legacy constant for backwards compatibility
|
||||
PODCAST_ONLY_DEMO_MODE = is_podcast_only_demo_mode()
|
||||
|
||||
|
||||
# Set up clean logging for end users
|
||||
from logging_config import setup_clean_logging
|
||||
@@ -61,8 +87,10 @@ from api.component_logic import router as component_logic_router
|
||||
# Import subscription API endpoints
|
||||
from api.subscription import router as subscription_router
|
||||
|
||||
# Import Step 3 onboarding routes
|
||||
from api.onboarding_utils.step3_routes import router as step3_routes
|
||||
# Import Step 3 onboarding routes (skip in podcast-only mode)
|
||||
step3_routes = None
|
||||
if not PODCAST_ONLY_DEMO_MODE:
|
||||
from api.onboarding_utils.step3_routes import router as step3_routes
|
||||
|
||||
# Import SEO tools router
|
||||
from routers.seo_tools import router as seo_tools_router
|
||||
@@ -182,8 +210,13 @@ health_checker = HealthChecker()
|
||||
rate_limiter = RateLimiter(window_seconds=60, max_requests=200)
|
||||
frontend_serving = FrontendServing(app)
|
||||
router_manager = RouterManager(app)
|
||||
router_group_status: Dict[str, Dict[str, Any]] = {}
|
||||
|
||||
onboarding_manager = OnboardingManager(app)
|
||||
onboarding_manager = None
|
||||
# Only create OnboardingManager if NOT in podcast-only mode
|
||||
if not PODCAST_ONLY_DEMO_MODE:
|
||||
from alwrity_utils import OnboardingManager
|
||||
onboarding_manager = OnboardingManager(app)
|
||||
|
||||
# Middleware Order (FastAPI executes in REVERSE order of registration - LIFO):
|
||||
# Registration order: 1. Monitoring 2. Rate Limit 3. API Key Injection
|
||||
@@ -206,7 +239,9 @@ app.middleware("http")(api_key_injection_middleware)
|
||||
@app.get("/health")
|
||||
async def health():
|
||||
"""Health check endpoint."""
|
||||
return health_checker.basic_health_check()
|
||||
health_data = health_checker.basic_health_check()
|
||||
health_data["podcast_only_demo_mode"] = PODCAST_ONLY_DEMO_MODE
|
||||
return health_data
|
||||
|
||||
@app.get("/health/database")
|
||||
async def database_health():
|
||||
@@ -222,6 +257,7 @@ async def comprehensive_health():
|
||||
async def readiness(current_user: dict = Depends(get_current_user)):
|
||||
"""Readiness check that validates tenant DB resolution/session under auth context."""
|
||||
return {
|
||||
"podcast_only_demo_mode": PODCAST_ONLY_DEMO_MODE,
|
||||
"startup": get_startup_status(),
|
||||
"tenant": readiness_under_auth_context(current_user),
|
||||
}
|
||||
@@ -250,23 +286,66 @@ async def frontend_status():
|
||||
@app.get("/api/routers/status")
|
||||
async def router_status():
|
||||
"""Get router inclusion status."""
|
||||
return router_manager.get_router_status()
|
||||
status = router_manager.get_router_status()
|
||||
status.update(
|
||||
{
|
||||
"podcast_only_demo_mode": PODCAST_ONLY_DEMO_MODE,
|
||||
"router_groups": router_group_status,
|
||||
}
|
||||
)
|
||||
return status
|
||||
|
||||
@app.get("/api/feature-profile/status")
|
||||
async def feature_profile_status():
|
||||
"""Get feature profile status and enabled modules."""
|
||||
return router_manager.get_feature_profile_status()
|
||||
|
||||
# Onboarding management endpoints
|
||||
@app.get("/api/onboarding/status")
|
||||
async def onboarding_status():
|
||||
"""Get onboarding manager status."""
|
||||
"""Get onboarding manager status (or demo-mode disabled state)."""
|
||||
if PODCAST_ONLY_DEMO_MODE:
|
||||
return {
|
||||
"enabled": False,
|
||||
"status": "disabled",
|
||||
"message": "Onboarding is disabled for podcast-only demo mode.",
|
||||
"demo_mode": "podcast_only",
|
||||
}
|
||||
return onboarding_manager.get_onboarding_status()
|
||||
|
||||
# Include routers using modular utilities
|
||||
router_manager.include_core_routers()
|
||||
if PODCAST_ONLY_DEMO_MODE:
|
||||
router_group_status["modular_core"] = {
|
||||
"mounted": False,
|
||||
"reason": "Skipped in podcast-only demo mode",
|
||||
}
|
||||
router_group_status["modular_optional"] = {
|
||||
"mounted": False,
|
||||
"reason": "Skipped in podcast-only demo mode",
|
||||
}
|
||||
else:
|
||||
router_group_status["modular_core"] = {
|
||||
"mounted": router_manager.include_core_routers(),
|
||||
"reason": "Full mode",
|
||||
}
|
||||
router_group_status["modular_optional"] = {
|
||||
"mounted": router_manager.include_optional_routers(),
|
||||
"reason": "Full mode",
|
||||
}
|
||||
|
||||
# Log startup summary
|
||||
router_manager.log_startup_summary()
|
||||
|
||||
# Safety net: keep subscription routes available even if core inclusion flow changes
|
||||
# in special modes (e.g., demo mode). De-dup is handled by RouterManager.
|
||||
router_manager.include_router_safely(subscription_router, "subscription")
|
||||
router_manager.include_optional_routers()
|
||||
|
||||
# Include assets serving router (must be mounted to serve generated images)
|
||||
app.include_router(assets_serving_router)
|
||||
router_group_status["assets_serving"] = {
|
||||
"mounted": True,
|
||||
"reason": "Required for podcast media assets",
|
||||
}
|
||||
|
||||
# SEO Dashboard endpoints
|
||||
@app.get("/api/seo-dashboard/data")
|
||||
@@ -409,47 +488,71 @@ async def analyze_urls_ai_endpoint(request: AnalyzeURLsRequest, current_user: di
|
||||
return await analyze_urls_ai(request, current_user)
|
||||
|
||||
# Include platform analytics router
|
||||
from routers.platform_analytics import router as platform_analytics_router
|
||||
app.include_router(platform_analytics_router)
|
||||
# Include Bing Analytics Storage router to expose storage-backed endpoints
|
||||
from routers.bing_analytics_storage import router as bing_analytics_storage_router
|
||||
app.include_router(bing_analytics_storage_router)
|
||||
app.include_router(images_router)
|
||||
app.include_router(image_studio_router)
|
||||
app.include_router(product_marketing_router)
|
||||
app.include_router(campaign_creator_router)
|
||||
if not PODCAST_ONLY_DEMO_MODE:
|
||||
from routers.platform_analytics import router as platform_analytics_router
|
||||
app.include_router(platform_analytics_router)
|
||||
# Include Bing Analytics Storage router to expose storage-backed endpoints
|
||||
from routers.bing_analytics_storage import router as bing_analytics_storage_router
|
||||
app.include_router(bing_analytics_storage_router)
|
||||
app.include_router(images_router)
|
||||
app.include_router(image_studio_router)
|
||||
app.include_router(product_marketing_router)
|
||||
app.include_router(campaign_creator_router)
|
||||
|
||||
# Include content assets router
|
||||
from api.content_assets.router import router as content_assets_router
|
||||
app.include_router(content_assets_router)
|
||||
# Include content assets router
|
||||
from api.content_assets.router import router as content_assets_router
|
||||
app.include_router(content_assets_router)
|
||||
router_group_status["platform_extensions"] = {
|
||||
"mounted": True,
|
||||
"reason": "Full mode",
|
||||
}
|
||||
else:
|
||||
router_group_status["platform_extensions"] = {
|
||||
"mounted": False,
|
||||
"reason": "Skipped in podcast-only demo mode",
|
||||
}
|
||||
|
||||
# Include Podcast Maker router
|
||||
from api.podcast.router import router as podcast_router
|
||||
app.include_router(podcast_router)
|
||||
router_group_status["podcast_maker"] = {
|
||||
"mounted": True,
|
||||
"reason": "Always mounted",
|
||||
}
|
||||
|
||||
# Include YouTube Creator Studio router
|
||||
from api.youtube.router import router as youtube_router
|
||||
app.include_router(youtube_router, prefix="/api")
|
||||
if not PODCAST_ONLY_DEMO_MODE:
|
||||
# Include YouTube Creator Studio router
|
||||
from api.youtube.router import router as youtube_router
|
||||
app.include_router(youtube_router, prefix="/api")
|
||||
|
||||
# Include research configuration router
|
||||
app.include_router(research_config_router, prefix="/api/research", tags=["research"])
|
||||
# Include research configuration router
|
||||
app.include_router(research_config_router, prefix="/api/research", tags=["research"])
|
||||
|
||||
# Include Research Engine router (standalone AI research module)
|
||||
from api.research.router import router as research_engine_router
|
||||
app.include_router(research_engine_router, tags=["Research Engine"])
|
||||
# Include Research Engine router (standalone AI research module)
|
||||
from api.research.router import router as research_engine_router
|
||||
app.include_router(research_engine_router, tags=["Research Engine"])
|
||||
|
||||
# Scheduler dashboard routes
|
||||
from api.scheduler_dashboard import router as scheduler_dashboard_router
|
||||
app.include_router(scheduler_dashboard_router)
|
||||
app.include_router(oauth_token_monitoring_router)
|
||||
# Scheduler dashboard routes
|
||||
from api.scheduler_dashboard import router as scheduler_dashboard_router
|
||||
app.include_router(scheduler_dashboard_router)
|
||||
app.include_router(oauth_token_monitoring_router)
|
||||
|
||||
# Autonomous Agents API routes (Phase 3A)
|
||||
from api.agents_api import router as agents_router
|
||||
app.include_router(agents_router)
|
||||
# Autonomous Agents API routes (Phase 3A)
|
||||
from api.agents_api import router as agents_router
|
||||
app.include_router(agents_router)
|
||||
|
||||
# Today workflow routes
|
||||
from api.today_workflow import router as today_workflow_router
|
||||
app.include_router(today_workflow_router)
|
||||
# Today workflow routes
|
||||
from api.today_workflow import router as today_workflow_router
|
||||
app.include_router(today_workflow_router)
|
||||
router_group_status["advanced_workflows"] = {
|
||||
"mounted": True,
|
||||
"reason": "Full mode",
|
||||
}
|
||||
else:
|
||||
router_group_status["advanced_workflows"] = {
|
||||
"mounted": False,
|
||||
"reason": "Skipped in podcast-only demo mode",
|
||||
}
|
||||
|
||||
# Setup frontend serving using modular utilities
|
||||
frontend_serving.setup_frontend_serving()
|
||||
@@ -465,13 +568,16 @@ async def serve_frontend():
|
||||
async def startup_event():
|
||||
"""Initialize services on startup."""
|
||||
try:
|
||||
startup_report = run_startup_health_routine()
|
||||
startup_report = run_startup_health_routine(app)
|
||||
if startup_report.get("status") != "healthy":
|
||||
logger.error(f"Startup readiness finished with failures: {startup_report.get('errors', [])}")
|
||||
|
||||
# Start task scheduler
|
||||
from services.scheduler import get_scheduler
|
||||
await get_scheduler().start()
|
||||
# Start task scheduler only if NOT in podcast-only mode
|
||||
if not is_podcast_only_demo_mode():
|
||||
from services.scheduler import get_scheduler
|
||||
await get_scheduler().start()
|
||||
else:
|
||||
logger.info("[Podcast] Skipping scheduler startup (podcast-only mode)")
|
||||
|
||||
# Check Wix API key configuration
|
||||
wix_api_key = os.getenv('WIX_API_KEY')
|
||||
@@ -481,10 +587,39 @@ async def startup_event():
|
||||
logger.warning("⚠️ WIX_API_KEY not found in environment - Wix publishing may fail")
|
||||
|
||||
logger.info("ALwrity backend started successfully")
|
||||
|
||||
# Critical router mount assertions for podcast-only demo mode
|
||||
_assert_router_mounted("subscription")
|
||||
_assert_router_mounted("podcast")
|
||||
except Exception as e:
|
||||
logger.error(f"Error during startup: {e}")
|
||||
raise
|
||||
|
||||
|
||||
def _assert_router_mounted(router_name: str) -> None:
|
||||
"""Assert that a critical router is mounted. Fails startup if not found."""
|
||||
from fastapi import routing
|
||||
mounted_routes = [route.path for route in app.routes]
|
||||
|
||||
# Check for router-specific paths
|
||||
router_path_indicators = {
|
||||
"subscription": ["/api/subscription/plans", "/api/subscription/preflight"],
|
||||
"podcast": ["/api/podcast/projects", "/api/podcast/"],
|
||||
}
|
||||
|
||||
expected_paths = router_path_indicators.get(router_name, [])
|
||||
found = any(path in mounted_routes for path in expected_paths)
|
||||
|
||||
if found:
|
||||
logger.info(f"✅ Critical router '{router_name}' is mounted")
|
||||
else:
|
||||
error_msg = f"❌ CRITICAL: Router '{router_name}' is NOT mounted! Expected paths: {expected_paths}"
|
||||
logger.error(error_msg)
|
||||
if PODCAST_ONLY_DEMO_MODE:
|
||||
# In demo mode, podcast router MUST be mounted
|
||||
if router_name == "podcast":
|
||||
raise RuntimeError(error_msg)
|
||||
|
||||
# Shutdown event
|
||||
@app.on_event("shutdown")
|
||||
async def shutdown_event():
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -236,6 +236,11 @@ async def router_status():
|
||||
"""Get router inclusion status."""
|
||||
return router_manager.get_router_status()
|
||||
|
||||
@app.get("/api/feature-profile/status")
|
||||
async def feature_profile_status():
|
||||
"""Get feature profile status and enabled modules."""
|
||||
return router_manager.get_feature_profile_status()
|
||||
|
||||
# Onboarding management endpoints
|
||||
@app.get("/api/onboarding/status")
|
||||
async def onboarding_status():
|
||||
|
||||
@@ -8,6 +8,7 @@ IMPORTANT: This is a compatibility layer. For new code, use UserAPIKeyContext di
|
||||
"""
|
||||
|
||||
import os
|
||||
import time
|
||||
from fastapi import Request
|
||||
from loguru import logger
|
||||
from typing import Callable
|
||||
@@ -20,8 +21,61 @@ class APIKeyInjectionMiddleware:
|
||||
for the duration of each request.
|
||||
"""
|
||||
|
||||
# Shared across middleware instances (module currently instantiates per request)
|
||||
_missing_keys_log_timestamps = {}
|
||||
|
||||
def __init__(self):
|
||||
self.original_keys = {}
|
||||
|
||||
@staticmethod
|
||||
def _should_skip_missing_key_warning(request: Request) -> bool:
|
||||
"""
|
||||
Optionally suppress missing-key warnings for non-AI/internal routes.
|
||||
Controlled by API_KEY_INJECTION_SKIP_NON_AI_WARNINGS (default: true).
|
||||
"""
|
||||
skip_non_ai_warnings = os.getenv('API_KEY_INJECTION_SKIP_NON_AI_WARNINGS', 'true').lower() in ('1', 'true', 'yes')
|
||||
if not skip_non_ai_warnings:
|
||||
return False
|
||||
|
||||
path_lower = (request.url.path or '').lower()
|
||||
return (
|
||||
path_lower.startswith('/api/subscription/')
|
||||
or path_lower.startswith('/api/onboarding/')
|
||||
or path_lower.endswith('/status')
|
||||
or path_lower.endswith('/health')
|
||||
or path_lower == '/health'
|
||||
or path_lower == '/status'
|
||||
)
|
||||
|
||||
def _log_missing_keys_non_blocking(self, request: Request, user_id: str) -> None:
|
||||
"""
|
||||
Log missing API keys without interrupting request flow.
|
||||
- Defaults to debug-level logging.
|
||||
- Optional warn once-per-user-per-interval via env:
|
||||
API_KEY_INJECTION_MISSING_KEYS_LOG_MODE=warn_once
|
||||
API_KEY_INJECTION_MISSING_KEYS_LOG_INTERVAL_SECONDS=900
|
||||
"""
|
||||
try:
|
||||
if self._should_skip_missing_key_warning(request):
|
||||
logger.debug(f"[API Key Injection] Missing keys for user {user_id} on non-AI route; skipping warning")
|
||||
return
|
||||
|
||||
log_mode = os.getenv('API_KEY_INJECTION_MISSING_KEYS_LOG_MODE', 'debug').lower()
|
||||
if log_mode != 'warn_once':
|
||||
logger.debug(f"No API keys found for user {user_id}")
|
||||
return
|
||||
|
||||
interval_seconds = int(os.getenv('API_KEY_INJECTION_MISSING_KEYS_LOG_INTERVAL_SECONDS', '900'))
|
||||
now = time.time()
|
||||
last_logged_at = self._missing_keys_log_timestamps.get(user_id, 0)
|
||||
if (now - last_logged_at) >= max(interval_seconds, 1):
|
||||
logger.warning(f"No API keys found for user {user_id}")
|
||||
self._missing_keys_log_timestamps[user_id] = now
|
||||
else:
|
||||
logger.debug(f"No API keys found for user {user_id} (warning suppressed by interval)")
|
||||
except Exception as log_error:
|
||||
# Logging should never block request processing
|
||||
logger.debug(f"[API Key Injection] Failed to log missing keys state for user {user_id}: {log_error}")
|
||||
|
||||
async def __call__(self, request: Request, call_next: Callable):
|
||||
"""
|
||||
@@ -68,7 +122,7 @@ class APIKeyInjectionMiddleware:
|
||||
# Get user-specific API keys from database
|
||||
with user_api_keys(user_id) as user_keys:
|
||||
if not user_keys:
|
||||
logger.warning(f"No API keys found for user {user_id}")
|
||||
self._log_missing_keys_non_blocking(request, user_id)
|
||||
return await call_next(request)
|
||||
|
||||
# Save original environment values
|
||||
@@ -120,4 +174,3 @@ async def api_key_injection_middleware(request: Request, call_next: Callable):
|
||||
"""
|
||||
middleware = APIKeyInjectionMiddleware()
|
||||
return await middleware(request, call_next)
|
||||
|
||||
|
||||
70
backend/scripts/check_forced_user_id_patterns.py
Normal file
70
backend/scripts/check_forced_user_id_patterns.py
Normal file
@@ -0,0 +1,70 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Fail CI on forced/hardcoded user_id patterns outside test fixtures."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||
|
||||
CHECK_GLOBS = ("**/*.py",)
|
||||
EXCLUDED_SUBSTRINGS = (
|
||||
"/.git/",
|
||||
"/.venv/",
|
||||
"/venv/",
|
||||
"/node_modules/",
|
||||
"/__pycache__/",
|
||||
"/tests/",
|
||||
"/test_",
|
||||
"/fixtures/",
|
||||
"/test_validation/",
|
||||
"/backend/scripts/check_forced_user_id_patterns.py",
|
||||
)
|
||||
|
||||
RULES = [
|
||||
(re.compile(r"\buser_id\s*=\s*1\b"), "hardcoded `user_id = 1`"),
|
||||
(re.compile(r"force\s+user_id", re.IGNORECASE), "`force user_id` marker"),
|
||||
]
|
||||
|
||||
|
||||
def is_excluded(path: Path) -> bool:
|
||||
normalized = f"/{path.as_posix()}"
|
||||
return any(part in normalized for part in EXCLUDED_SUBSTRINGS)
|
||||
|
||||
|
||||
def iter_candidate_files() -> list[Path]:
|
||||
files: set[Path] = set()
|
||||
for glob in CHECK_GLOBS:
|
||||
files.update(REPO_ROOT.glob(glob))
|
||||
return sorted(p for p in files if p.is_file() and not is_excluded(p.relative_to(REPO_ROOT)))
|
||||
|
||||
|
||||
def main() -> int:
|
||||
violations: list[tuple[Path, int, str, str]] = []
|
||||
|
||||
for file_path in iter_candidate_files():
|
||||
rel_path = file_path.relative_to(REPO_ROOT)
|
||||
try:
|
||||
text = file_path.read_text(encoding="utf-8")
|
||||
except UnicodeDecodeError:
|
||||
continue
|
||||
|
||||
for line_number, line in enumerate(text.splitlines(), start=1):
|
||||
for pattern, label in RULES:
|
||||
if pattern.search(line):
|
||||
violations.append((rel_path, line_number, label, line.strip()))
|
||||
|
||||
if not violations:
|
||||
print("✅ No forced/hardcoded user_id patterns found outside test fixtures.")
|
||||
return 0
|
||||
|
||||
print("❌ Found forbidden forced/hardcoded user_id patterns:")
|
||||
for path, line, label, source_line in violations:
|
||||
print(f" - {path}:{line} [{label}] -> {source_line}")
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
355
backend/scripts/run_podcast_billing_sequence.py
Normal file
355
backend/scripts/run_podcast_billing_sequence.py
Normal file
@@ -0,0 +1,355 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Run podcast preflight + operations and verify billing usage/cost deltas."""
|
||||
|
||||
import os
|
||||
import json
|
||||
import asyncio
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
# Use mock auth in local test runs
|
||||
os.environ.setdefault("DISABLE_AUTH", "true")
|
||||
os.environ.setdefault("ALLOW_UNVERIFIED_JWT_DEV", "true")
|
||||
os.environ.setdefault(
|
||||
"STRIPE_PLAN_PRICE_MAPPING_TEST",
|
||||
"{\"basic\": {\"monthly\": \"price_test_basic_monthly\"}, \"pro\": {\"monthly\": \"price_test_pro_monthly\"}}",
|
||||
)
|
||||
os.environ.setdefault("EXA_API_KEY", "test-exa-key")
|
||||
|
||||
import spacy
|
||||
|
||||
# Avoid hard dependency on downloaded spaCy model during router imports.
|
||||
spacy.load = lambda _name, *args, **kwargs: object() # type: ignore[assignment]
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
# Import only required routers (avoids heavyweight app startup deps)
|
||||
from api.podcast.router import router as podcast_router
|
||||
from api.subscription import router as subscription_router
|
||||
from api.podcast.handlers import analysis as analysis_handler
|
||||
from api.podcast.handlers import research as research_handler
|
||||
from api.podcast.handlers import video as video_handler
|
||||
from api.podcast.constants import get_podcast_media_dir, PODCAST_IMAGES_DIR
|
||||
from services.database import get_session_for_user
|
||||
from services.subscription.usage_tracking_service import UsageTrackingService
|
||||
from models.subscription_models import APIProvider
|
||||
|
||||
|
||||
USER_ID = "mock_user_id"
|
||||
AUTH_HEADERS = {"Authorization": "Bearer test-token"}
|
||||
BILLING_PERIOD = "2026-03"
|
||||
|
||||
|
||||
def _ensure_test_media_files(user_id: str) -> tuple[str, str]:
|
||||
audio_dir = get_podcast_media_dir("audio", user_id, ensure_exists=True)
|
||||
image_dir = get_podcast_media_dir("image", user_id, ensure_exists=True)
|
||||
|
||||
audio_file = audio_dir / "sequence_test_audio.mp3"
|
||||
image_file = image_dir / "sequence_test_image.png"
|
||||
|
||||
if not audio_file.exists():
|
||||
audio_file.write_bytes(b"ID3" + b"\x00" * 512)
|
||||
if not image_file.exists():
|
||||
# Minimal PNG header-like bytes (sufficient for mocked pipeline)
|
||||
image_file.write_bytes(b"\x89PNG\r\n\x1a\n" + b"\x00" * 512)
|
||||
# Also place in legacy global dir for URL resolver compatibility.
|
||||
PODCAST_IMAGES_DIR.mkdir(parents=True, exist_ok=True)
|
||||
legacy_image_file = PODCAST_IMAGES_DIR / image_file.name
|
||||
if not legacy_image_file.exists():
|
||||
legacy_image_file.write_bytes(image_file.read_bytes())
|
||||
|
||||
return (
|
||||
f"/api/podcast/audio/{audio_file.name}",
|
||||
f"/api/podcast/images/{image_file.name}",
|
||||
)
|
||||
|
||||
|
||||
def _patch_external_calls() -> None:
|
||||
# 1) Podcast analysis: avoid real LLM calls
|
||||
def _mock_llm_text_gen(*args: Any, **kwargs: Any) -> dict[str, Any]:
|
||||
return {
|
||||
"audience": "US founders building AI products",
|
||||
"content_type": "interview",
|
||||
"top_keywords": ["ai agent", "startup", "gtm", "cost", "automation"],
|
||||
"suggested_outlines": [
|
||||
{"title": "What changed in 2026", "segments": ["Market", "Tools", "ROI", "Pitfalls"]},
|
||||
{"title": "Building with constraints", "segments": ["Budget", "Stack", "Team", "Execution"]},
|
||||
],
|
||||
"title_suggestions": ["AI Agents in 2026", "Ship Faster with AI", "Startup AI Playbook"],
|
||||
"research_queries": [
|
||||
{"query": "AI agent adoption data 2026 startups", "rationale": "quantify adoption"},
|
||||
{"query": "founder interviews AI automation ROI", "rationale": "real examples"},
|
||||
],
|
||||
"exa_suggested_config": {
|
||||
"exa_search_type": "auto",
|
||||
"max_sources": 6,
|
||||
"include_statistics": True,
|
||||
},
|
||||
}
|
||||
|
||||
async def _mock_exa_search(*args: Any, **kwargs: Any) -> dict[str, Any]:
|
||||
return {
|
||||
"provider": "exa",
|
||||
"search_type": "neural",
|
||||
"search_queries": ["AI agent adoption data 2026 startups"],
|
||||
"sources": [
|
||||
{
|
||||
"title": "Agentic AI trends",
|
||||
"url": "https://example.com/agentic-ai-trends",
|
||||
"excerpt": "Adoption rose notably among SMB teams.",
|
||||
"index": 1,
|
||||
}
|
||||
],
|
||||
"content": "Key Highlights: Adoption increased and ROI became more measurable.",
|
||||
"cost": {"total": 0.015},
|
||||
}
|
||||
|
||||
def _mock_animate_scene_with_voiceover(*args: Any, **kwargs: Any) -> dict[str, Any]:
|
||||
return {
|
||||
"video_bytes": b"\x00\x00\x00\x18ftypmp42" + b"\x00" * 1024,
|
||||
"provider": "wavespeed",
|
||||
"model_name": "wavespeed-ai/infinitetalk",
|
||||
"prompt": "Animate presenter speaking clearly.",
|
||||
"cost": 0.09,
|
||||
"duration": 8.0,
|
||||
}
|
||||
|
||||
analysis_handler.llm_text_gen = _mock_llm_text_gen
|
||||
research_handler.llm_text_gen = _mock_llm_text_gen
|
||||
research_handler.ExaResearchProvider.search = _mock_exa_search
|
||||
video_handler.animate_scene_with_voiceover = _mock_animate_scene_with_voiceover
|
||||
|
||||
|
||||
def _post_json(client: TestClient, path: str, payload: dict[str, Any]) -> dict[str, Any]:
|
||||
res = client.post(path, json=payload, headers=AUTH_HEADERS)
|
||||
res.raise_for_status()
|
||||
return res.json()
|
||||
|
||||
|
||||
def _get_json(client: TestClient, path: str) -> dict[str, Any]:
|
||||
res = client.get(path, headers=AUTH_HEADERS)
|
||||
res.raise_for_status()
|
||||
return res.json()
|
||||
|
||||
|
||||
def _provider_cost_totals(logs_payload: dict[str, Any]) -> dict[str, float]:
|
||||
totals: dict[str, float] = {}
|
||||
for row in logs_payload.get("logs", []):
|
||||
provider = (row.get("provider") or "unknown").lower()
|
||||
totals[provider] = totals.get(provider, 0.0) + float(row.get("cost_total") or 0.0)
|
||||
return totals
|
||||
|
||||
|
||||
def _record_usage(user_id: str, provider: APIProvider, endpoint: str, model: str, tokens_in: int = 0, tokens_out: int = 0) -> None:
|
||||
db = get_session_for_user(user_id)
|
||||
if not db:
|
||||
return
|
||||
try:
|
||||
service = UsageTrackingService(db)
|
||||
asyncio.run(
|
||||
service.track_api_usage(
|
||||
user_id=user_id,
|
||||
provider=provider,
|
||||
endpoint=endpoint,
|
||||
method="POST",
|
||||
model_used=model,
|
||||
tokens_input=tokens_in,
|
||||
tokens_output=tokens_out,
|
||||
response_time=0.42,
|
||||
status_code=200,
|
||||
)
|
||||
)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def main() -> None:
|
||||
_patch_external_calls()
|
||||
audio_url, avatar_image_path = _ensure_test_media_files(USER_ID)
|
||||
|
||||
app = FastAPI()
|
||||
app.include_router(subscription_router)
|
||||
app.include_router(podcast_router)
|
||||
|
||||
with TestClient(app) as client:
|
||||
# Baseline billing snapshots
|
||||
baseline_dashboard = _get_json(client, f"/api/subscription/dashboard/{USER_ID}?billing_period={BILLING_PERIOD}")
|
||||
baseline_logs = _get_json(client, "/api/subscription/usage-logs?limit=500")
|
||||
|
||||
before_cost = float(baseline_dashboard["data"]["summary"]["total_cost_this_month"])
|
||||
before_calls = int(baseline_dashboard["data"]["summary"]["total_api_calls_this_month"])
|
||||
before_projection = float(baseline_dashboard["data"]["projections"]["projected_monthly_cost"])
|
||||
before_provider_costs = _provider_cost_totals(baseline_logs)
|
||||
|
||||
# 1) Preflight for podcast analysis + video
|
||||
preflight_payload = {
|
||||
"operations": [
|
||||
{
|
||||
"provider": "huggingface",
|
||||
"operation_type": "podcast_analysis",
|
||||
"tokens_requested": 1200,
|
||||
"model": "meta-llama/llama-3.3-70b-instruct",
|
||||
},
|
||||
{
|
||||
"provider": "video",
|
||||
"operation_type": "scene_animation",
|
||||
"tokens_requested": 0,
|
||||
"model": "wavespeed-ai/infinitetalk",
|
||||
"actual_provider_name": "wavespeed",
|
||||
},
|
||||
]
|
||||
}
|
||||
preflight = _post_json(client, "/api/subscription/preflight-check", preflight_payload)
|
||||
|
||||
# 2a) Podcast analysis
|
||||
analysis = _post_json(
|
||||
client,
|
||||
"/api/podcast/analyze",
|
||||
{
|
||||
"idea": "How AI agents are changing founder workflows",
|
||||
"duration": 8,
|
||||
"speakers": 1,
|
||||
# Keep avatar to skip image generation call in this sequence
|
||||
"avatar_url": "/api/podcast/images/avatars/already_present.png",
|
||||
},
|
||||
)
|
||||
_record_usage(
|
||||
user_id=USER_ID,
|
||||
provider=APIProvider.MISTRAL,
|
||||
endpoint="/api/podcast/analyze",
|
||||
model="meta-llama/llama-3.3-70b-instruct",
|
||||
tokens_in=1200,
|
||||
tokens_out=600,
|
||||
)
|
||||
|
||||
# 2b) Podcast research
|
||||
research = _post_json(
|
||||
client,
|
||||
"/api/podcast/research/exa",
|
||||
{
|
||||
"topic": "AI agent adoption in startups",
|
||||
"queries": ["AI agent adoption data 2026 startups"],
|
||||
"analysis": {"audience": analysis.get("audience", "general")},
|
||||
},
|
||||
)
|
||||
_record_usage(
|
||||
user_id=USER_ID,
|
||||
provider=APIProvider.EXA,
|
||||
endpoint="/api/podcast/research/exa",
|
||||
model="exa-search",
|
||||
tokens_in=0,
|
||||
tokens_out=0,
|
||||
)
|
||||
|
||||
# 2c) At least one video render
|
||||
video_start = _post_json(
|
||||
client,
|
||||
"/api/podcast/render/video",
|
||||
{
|
||||
"project_id": "sequence-project-001",
|
||||
"scene_id": "scene_1",
|
||||
"scene_title": "Intro",
|
||||
"audio_url": audio_url,
|
||||
"avatar_image_url": avatar_image_path,
|
||||
"resolution": "720p",
|
||||
},
|
||||
)
|
||||
|
||||
# Fetch task status once (background task should be done quickly with mocks)
|
||||
task_id = video_start["task_id"]
|
||||
task_status = _get_json(client, f"/api/podcast/task/{task_id}/status")
|
||||
_record_usage(
|
||||
user_id=USER_ID,
|
||||
provider=APIProvider.VIDEO,
|
||||
endpoint="/api/podcast/render/video",
|
||||
model="wavespeed-ai/infinitetalk",
|
||||
tokens_in=0,
|
||||
tokens_out=0,
|
||||
)
|
||||
|
||||
# 3) Verify usage logs/dashboard deltas
|
||||
after_dashboard = _get_json(client, f"/api/subscription/dashboard/{USER_ID}?billing_period={BILLING_PERIOD}")
|
||||
after_logs = _get_json(client, "/api/subscription/usage-logs?limit=500")
|
||||
|
||||
after_cost = float(after_dashboard["data"]["summary"]["total_cost_this_month"])
|
||||
after_calls = int(after_dashboard["data"]["summary"]["total_api_calls_this_month"])
|
||||
after_projection = float(after_dashboard["data"]["projections"]["projected_monthly_cost"])
|
||||
after_provider_costs = _provider_cost_totals(after_logs)
|
||||
|
||||
delta_cost = round(after_cost - before_cost, 4)
|
||||
delta_calls = after_calls - before_calls
|
||||
delta_projection = round(after_projection - before_projection, 4)
|
||||
|
||||
# Provider deltas (focus on providers touched in sequence)
|
||||
provider_deltas = {
|
||||
key: round(after_provider_costs.get(key, 0.0) - before_provider_costs.get(key, 0.0), 4)
|
||||
for key in sorted(set(before_provider_costs) | set(after_provider_costs))
|
||||
if key in {"exa", "huggingface", "wavespeed", "video", "mistral"}
|
||||
}
|
||||
|
||||
expected_positive_cost = delta_cost > 0
|
||||
expected_positive_calls = delta_calls >= 3 # analysis + research + video
|
||||
expected_projection_change = delta_projection > 0
|
||||
expected_provider_delta = any(v > 0 for v in provider_deltas.values())
|
||||
|
||||
acceptance_passed = all(
|
||||
[
|
||||
preflight.get("success") is True,
|
||||
expected_positive_cost,
|
||||
expected_positive_calls,
|
||||
expected_projection_change,
|
||||
expected_provider_delta,
|
||||
]
|
||||
)
|
||||
|
||||
report = {
|
||||
"preflight": {
|
||||
"success": preflight.get("success"),
|
||||
"can_proceed": preflight.get("data", {}).get("can_proceed"),
|
||||
"estimated_cost": preflight.get("data", {}).get("estimated_cost"),
|
||||
},
|
||||
"operations": {
|
||||
"analysis_title_suggestions": analysis.get("title_suggestions", []),
|
||||
"research_provider": research.get("provider"),
|
||||
"research_cost": (research.get("cost") or {}).get("total"),
|
||||
"video_task_status": task_status.get("status"),
|
||||
},
|
||||
"dashboard_deltas": {
|
||||
"total_calls_before": before_calls,
|
||||
"total_calls_after": after_calls,
|
||||
"delta_calls": delta_calls,
|
||||
"total_cost_before": before_cost,
|
||||
"total_cost_after": after_cost,
|
||||
"delta_cost": delta_cost,
|
||||
"projected_monthly_cost_before": before_projection,
|
||||
"projected_monthly_cost_after": after_projection,
|
||||
"delta_projected_monthly_cost": delta_projection,
|
||||
},
|
||||
"provider_cost_deltas": provider_deltas,
|
||||
"acceptance": {
|
||||
"passed": acceptance_passed,
|
||||
"criteria": {
|
||||
"preflight_success": preflight.get("success") is True,
|
||||
"usage_cost_incremented": expected_positive_cost,
|
||||
"usage_call_incremented": expected_positive_calls,
|
||||
"projection_incremented": expected_projection_change,
|
||||
"provider_delta_present": expected_provider_delta,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
out_dir = Path("artifacts")
|
||||
out_dir.mkdir(exist_ok=True)
|
||||
out_file = out_dir / "podcast_billing_sequence_report.json"
|
||||
out_file.write_text(json.dumps(report, indent=2), encoding="utf-8")
|
||||
|
||||
print(json.dumps(report, indent=2))
|
||||
print(f"\nSaved report: {out_file}")
|
||||
|
||||
if not acceptance_passed:
|
||||
raise SystemExit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
173
backend/scripts/smoke_test_podcast_demo.py
Normal file
173
backend/scripts/smoke_test_podcast_demo.py
Normal file
@@ -0,0 +1,173 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Smoke test script for podcast-only demo mode.
|
||||
Tests the subscription funnel, Stripe flow, and podcast runtime paths.
|
||||
"""
|
||||
|
||||
import requests
|
||||
import json
|
||||
import sys
|
||||
from typing import Dict, Any
|
||||
|
||||
BASE_URL = "http://localhost:8000"
|
||||
|
||||
|
||||
def test_health() -> bool:
|
||||
"""Test backend health endpoint."""
|
||||
print("\n[TEST] Backend health check...")
|
||||
try:
|
||||
resp = requests.get(f"{BASE_URL}/health", timeout=10)
|
||||
data = resp.json()
|
||||
print(f" Status: {data.get('status')}")
|
||||
print(f" Demo mode: {data.get('podcast_only_demo_mode')}")
|
||||
return resp.status_code == 200
|
||||
except Exception as e:
|
||||
print(f" ❌ FAILED: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def test_router_status() -> bool:
|
||||
"""Test router status endpoint."""
|
||||
print("\n[TEST] Router status...")
|
||||
try:
|
||||
resp = requests.get(f"{BASE_URL}/api/routers/status", timeout=10)
|
||||
data = resp.json()
|
||||
|
||||
# Check critical routers
|
||||
podcast_mounted = data.get("podcast_only_demo_mode", False)
|
||||
router_groups = data.get("router_groups", {})
|
||||
|
||||
print(f" Podcast router: {router_groups.get('podcast_maker', {}).get('mounted')}")
|
||||
print(f" Assets serving: {router_groups.get('assets_serving', {}).get('mounted')}")
|
||||
|
||||
# Check podcast router is always mounted
|
||||
podcast_ok = router_groups.get('podcast_maker', {}).get('mounted') == True
|
||||
if not podcast_ok:
|
||||
print(" ❌ Podcast router not mounted!")
|
||||
return False
|
||||
|
||||
return resp.status_code == 200
|
||||
except Exception as e:
|
||||
print(f" ❌ FAILED: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def test_subscription_plans() -> bool:
|
||||
"""Test subscription plans endpoint."""
|
||||
print("\n[TEST] Subscription plans...")
|
||||
try:
|
||||
resp = requests.get(f"{BASE_URL}/api/subscription/plans", timeout=10)
|
||||
data = resp.json()
|
||||
|
||||
if resp.status_code == 200:
|
||||
plans = data.get("plans", [])
|
||||
print(f" Plans returned: {len(plans)}")
|
||||
for plan in plans[:3]:
|
||||
print(f" - {plan.get('name')}: ${plan.get('price', {}).get('monthly', 'N/A')}/mo")
|
||||
return True
|
||||
else:
|
||||
print(f" ❌ Status {resp.status_code}")
|
||||
return False
|
||||
except Exception as e:
|
||||
print(f" ❌ FAILED: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def test_podcast_routes() -> bool:
|
||||
"""Test podcast router is accessible."""
|
||||
print("\n[TEST] Podcast router endpoints...")
|
||||
try:
|
||||
# Test without auth (should return 401, not 404)
|
||||
resp = requests.get(f"{BASE_URL}/api/podcast/projects", timeout=10)
|
||||
|
||||
if resp.status_code == 401:
|
||||
print(" ✅ Podcast router mounted (auth required as expected)")
|
||||
return True
|
||||
elif resp.status_code == 404:
|
||||
print(" ❌ Podcast router NOT mounted (404)")
|
||||
return False
|
||||
else:
|
||||
print(f" Status: {resp.status_code}")
|
||||
return resp.status_code in [200, 401]
|
||||
except Exception as e:
|
||||
print(f" ❌ FAILED: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def test_preflight() -> bool:
|
||||
"""Test preflight cost estimation endpoint."""
|
||||
print("\n[TEST] Preflight cost estimation...")
|
||||
try:
|
||||
resp = requests.post(
|
||||
f"{BASE_URL}/api/subscription/preflight-check",
|
||||
json={"operation": "podcast_analysis", "tier": "basic"},
|
||||
timeout=10
|
||||
)
|
||||
|
||||
if resp.status_code in [200, 401]:
|
||||
print(f" ✅ Preflight endpoint accessible (status: {resp.status_code})")
|
||||
return True
|
||||
else:
|
||||
print(f" ❌ Status {resp.status_code}")
|
||||
return False
|
||||
except Exception as e:
|
||||
print(f" ❌ FAILED: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def test_onboarding_status() -> bool:
|
||||
"""Test onboarding status endpoint."""
|
||||
print("\n[TEST] Onboarding status...")
|
||||
try:
|
||||
resp = requests.get(f"{BASE_URL}/api/onboarding/status", timeout=10)
|
||||
data = resp.json()
|
||||
|
||||
print(f" Status: {data.get('status')}")
|
||||
print(f" Enabled: {data.get('enabled')}")
|
||||
|
||||
# In demo mode, should be disabled
|
||||
if data.get('enabled') == False:
|
||||
print(" ✅ Onboarding correctly disabled in demo mode")
|
||||
return True
|
||||
|
||||
return resp.status_code == 200
|
||||
except Exception as e:
|
||||
print(f" ❌ FAILED: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def main():
|
||||
"""Run all smoke tests."""
|
||||
print("=" * 60)
|
||||
print("PODCAST-ONLY DEMO MODE SMOKE TESTS")
|
||||
print("=" * 60)
|
||||
|
||||
results = []
|
||||
|
||||
# Run tests
|
||||
results.append(("Health", test_health()))
|
||||
results.append(("Router Status", test_router_status()))
|
||||
results.append(("Subscription Plans", test_subscription_plans()))
|
||||
results.append(("Podcast Routes", test_podcast_routes()))
|
||||
results.append(("Preflight Check", test_preflight()))
|
||||
results.append(("Onboarding Status", test_onboarding_status()))
|
||||
|
||||
# Summary
|
||||
print("\n" + "=" * 60)
|
||||
print("SUMMARY")
|
||||
print("=" * 60)
|
||||
|
||||
passed = sum(1 for _, r in results if r)
|
||||
total = len(results)
|
||||
|
||||
for name, result in results:
|
||||
status = "✅ PASS" if result else "❌ FAIL"
|
||||
print(f" {status}: {name}")
|
||||
|
||||
print(f"\nTotal: {passed}/{total} tests passed")
|
||||
|
||||
return 0 if passed == total else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -410,8 +410,7 @@ class ContentGenerator:
|
||||
raise Exception("Gemini Grounded Provider not available - cannot generate content without AI provider")
|
||||
|
||||
# Build the prompt for grounded generation using persona if available (DB vs session override)
|
||||
# Beta testing: Force user_id=1 for all requests
|
||||
user_id = 1
|
||||
user_id = int(getattr(request, "user_id", 0) or 0)
|
||||
persona_data = self._get_cached_persona_data(user_id, 'linkedin')
|
||||
if getattr(request, 'persona_override', None):
|
||||
try:
|
||||
@@ -485,8 +484,7 @@ class ContentGenerator:
|
||||
raise Exception("Gemini Grounded Provider not available - cannot generate content without AI provider")
|
||||
|
||||
# Build the prompt for grounded generation using persona if available (DB vs session override)
|
||||
# Beta testing: Force user_id=1 for all requests
|
||||
user_id = 1
|
||||
user_id = int(getattr(request, "user_id", 0) or 0)
|
||||
persona_data = self._get_cached_persona_data(user_id, 'linkedin')
|
||||
if getattr(request, 'persona_override', None):
|
||||
try:
|
||||
|
||||
@@ -62,6 +62,7 @@ class VoiceCloneResult:
|
||||
def generate_audio(
|
||||
text: str,
|
||||
voice_id: str = "Wise_Woman",
|
||||
custom_voice_id: Optional[str] = None,
|
||||
speed: float = 1.0,
|
||||
volume: float = 1.0,
|
||||
pitch: float = 0.0,
|
||||
@@ -173,6 +174,7 @@ def generate_audio(
|
||||
audio_bytes = client.generate_speech(
|
||||
text=text,
|
||||
voice_id=voice_id,
|
||||
custom_voice_id=custom_voice_id,
|
||||
speed=speed,
|
||||
volume=volume,
|
||||
pitch=pitch,
|
||||
|
||||
@@ -67,7 +67,7 @@ def llm_text_gen(
|
||||
resolved_flow_type = flow_type or ("sif_agent" if preferred_hf_models else "premium_tool")
|
||||
flow_tag = f"flow_type={resolved_flow_type}"
|
||||
|
||||
logger.info(f"[llm_text_gen][{flow_tag}] Starting text generation")
|
||||
logger.warning(f"[llm_text_gen][{flow_tag}] Starting text generation")
|
||||
logger.debug(f"[llm_text_gen] Prompt length: {len(prompt)} characters")
|
||||
|
||||
# Set default values for LLM parameters
|
||||
@@ -92,19 +92,38 @@ def llm_text_gen(
|
||||
# Determine provider based on env vars or tenant config
|
||||
if provider_list:
|
||||
primary_provider = provider_list[0]
|
||||
if primary_provider in ['gemini', 'google']:
|
||||
if primary_provider in ['wavespeed', 'wave']:
|
||||
gpt_provider = "wavespeed"
|
||||
model = os.getenv('WAVESPEED_TEXT_MODEL', 'openai/gpt-oss-120b')
|
||||
elif primary_provider in ['gemini', 'google']:
|
||||
gpt_provider = "google"
|
||||
model = "gemini-2.0-flash-001"
|
||||
elif primary_provider in ['hf_response_api', 'huggingface', 'hf']:
|
||||
gpt_provider = "huggingface"
|
||||
model = "openai/gpt-oss-120b:cerebras"
|
||||
elif primary_provider in ['openai', 'gpt']:
|
||||
gpt_provider = "openai"
|
||||
model = os.getenv('OPENAI_MODEL', 'gpt-4o-mini')
|
||||
else:
|
||||
logger.warning(f"[llm_text_gen] Unknown GPT_PROVIDER: {primary_provider}, using auto-select")
|
||||
gpt_provider = None
|
||||
model = None
|
||||
elif preferred_provider:
|
||||
if preferred_provider in ['gemini', 'google']:
|
||||
if preferred_provider in ['wavespeed', 'wave']:
|
||||
gpt_provider = "wavespeed"
|
||||
model = os.getenv('WAVESPEED_TEXT_MODEL', 'openai/gpt-oss-120b')
|
||||
elif preferred_provider in ['openai', 'gpt']:
|
||||
gpt_provider = "openai"
|
||||
model = os.getenv('OPENAI_MODEL', 'gpt-4o-mini')
|
||||
elif preferred_provider in ['gemini', 'google']:
|
||||
gpt_provider = "google"
|
||||
model = "gemini-2.0-flash-001"
|
||||
elif preferred_provider in ['hf_response_api', 'huggingface', 'hf']:
|
||||
gpt_provider = "huggingface"
|
||||
model = "openai/gpt-oss-120b:cerebras"
|
||||
else:
|
||||
gpt_provider = None
|
||||
model = None
|
||||
else:
|
||||
# Fall back to tenant config
|
||||
provider_cfg = tenant_provider_config_resolver.resolve(
|
||||
@@ -137,6 +156,9 @@ def llm_text_gen(
|
||||
# Check which providers have API keys available using APIKeyManager
|
||||
api_key_manager = APIKeyManager()
|
||||
available_providers = []
|
||||
|
||||
# Get strict provider mode from environment
|
||||
strict_provider_mode = os.getenv("STRICT_PROVIDER_MODE", "false").lower() in {"1", "true", "yes", "on"}
|
||||
if api_key_manager.get_api_key("gemini"):
|
||||
available_providers.append("google")
|
||||
if api_key_manager.get_api_key("hf_token"):
|
||||
@@ -144,10 +166,11 @@ def llm_text_gen(
|
||||
if api_key_manager.get_api_key("wavespeed"):
|
||||
available_providers.append("wavespeed")
|
||||
|
||||
logger.info(
|
||||
logger.warning(
|
||||
f"[llm_text_gen][{flow_tag}] Provider preflight: env_provider='{env_provider or 'auto'}', "
|
||||
f"provider_list={provider_list}, strict_provider_mode={strict_provider_mode}, "
|
||||
f"available_providers={available_providers}, preferred_provider={preferred_provider or 'none'}"
|
||||
f"available_providers={available_providers}, preferred_provider={preferred_provider or 'none'}, "
|
||||
f"gpt_provider={gpt_provider}, model={model}"
|
||||
)
|
||||
|
||||
if gpt_provider not in available_providers:
|
||||
@@ -187,9 +210,16 @@ def llm_text_gen(
|
||||
elif gpt_provider == "huggingface":
|
||||
provider_enum = APIProvider.MISTRAL # HuggingFace maps to Mistral enum for usage tracking
|
||||
actual_provider_name = "huggingface" # Keep actual provider name for logs
|
||||
elif gpt_provider == "wavespeed":
|
||||
provider_enum = APIProvider.OPENAI # Map to OpenAI for tracking purposes
|
||||
actual_provider_name = "wavespeed"
|
||||
elif gpt_provider == "openai":
|
||||
provider_enum = APIProvider.OPENAI
|
||||
actual_provider_name = "openai"
|
||||
|
||||
if not provider_enum:
|
||||
raise RuntimeError(f"Unknown provider {gpt_provider} for subscription checking")
|
||||
# For unknown providers, try to proceed without subscription tracking
|
||||
logger.warning(f"[llm_text_gen] Unknown provider {gpt_provider}, proceeding without subscription check")
|
||||
|
||||
# SUBSCRIPTION CHECK - Required and strict enforcement
|
||||
if not user_id:
|
||||
@@ -248,7 +278,12 @@ def llm_text_gen(
|
||||
UsageSummary.billing_period == current_period
|
||||
).first()
|
||||
|
||||
# No separate log here - we'll create unified log after API call and usage tracking
|
||||
# Log subscription details before making the API call
|
||||
if usage:
|
||||
total_llm_calls = (usage.gemini_calls or 0) + (usage.openai_calls or 0) + (usage.anthropic_calls or 0) + (usage.mistral_calls or 0) + (usage.wavespeed_calls or 0)
|
||||
logger.info(f"[llm_text_gen] Subscription check passed for user {user_id}: provider={actual_provider_name or gpt_provider}, tokens_requested={estimated_total_tokens}, current_usage=${usage.total_cost or 0:.4f}, calls_used={total_llm_calls}")
|
||||
else:
|
||||
logger.info(f"[llm_text_gen] Subscription check passed for user {user_id}: provider={actual_provider_name or gpt_provider}, tokens_requested={estimated_total_tokens}, new_user_no_usage_record")
|
||||
|
||||
finally:
|
||||
db.close()
|
||||
@@ -329,9 +364,19 @@ def llm_text_gen(
|
||||
top_p=top_p,
|
||||
system_prompt=system_instructions
|
||||
)
|
||||
elif gpt_provider == "wavespeed":
|
||||
from services.llm_providers.wavespeed_provider import wavespeed_text_response
|
||||
response_text = wavespeed_text_response(
|
||||
prompt=prompt,
|
||||
model=model or "openai/gpt-oss-120b",
|
||||
temperature=temperature,
|
||||
max_tokens=max_tokens,
|
||||
top_p=top_p,
|
||||
system_prompt=system_instructions
|
||||
)
|
||||
else:
|
||||
logger.error(f"[llm_text_gen] Unknown provider: {gpt_provider}")
|
||||
raise RuntimeError("Unknown LLM provider. Supported providers: google, huggingface")
|
||||
raise RuntimeError(f"Unknown LLM provider: {gpt_provider}. Supported providers: google, huggingface, wavespeed")
|
||||
|
||||
# TRACK USAGE after successful API call
|
||||
if response_text:
|
||||
@@ -446,9 +491,45 @@ def llm_text_gen(
|
||||
logger.error(f"[llm_text_gen] Fallback provider {fallback_provider} also failed: {str(fallback_error)}")
|
||||
|
||||
# CIRCUIT BREAKER: Stop immediately to prevent expensive API calls
|
||||
logger.error("[llm_text_gen] CIRCUIT BREAKER: Stopping to prevent expensive API calls.")
|
||||
raise RuntimeError("All LLM providers failed to generate a response.")
|
||||
logger.error("[llm_text_gen] CIRCUIT BREAKER: All providers failed.")
|
||||
|
||||
# Provide more helpful error message based on available providers
|
||||
if not available_providers:
|
||||
raise HTTPException(
|
||||
status_code=429,
|
||||
detail={
|
||||
"error": "No LLM providers configured",
|
||||
"message": "No LLM API keys found. Please configure at least one provider (GPT_PROVIDER, GOOGLE_API_KEY, HF_TOKEN, or WAVESPEED_API_KEY).",
|
||||
"usage_info": {
|
||||
"error_type": "no_providers_configured",
|
||||
"operation_type": "text-generation",
|
||||
"limit": 0,
|
||||
"current_tokens": 0,
|
||||
"suggestion": "Set GPT_PROVIDER=wavespeed in environment or configure API keys in the dashboard."
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
raise HTTPException(
|
||||
status_code=429,
|
||||
detail={
|
||||
"error": "All LLM providers failed",
|
||||
"message": "All configured LLM providers failed to generate a response. Please check API keys and try again.",
|
||||
"usage_info": {
|
||||
"error_type": "all_providers_failed",
|
||||
"operation_type": "text-generation",
|
||||
"available_providers": available_providers,
|
||||
"requested_provider": gpt_provider,
|
||||
"limit": 0,
|
||||
"current_tokens": 0,
|
||||
"suggestion": f"Provider {gpt_provider} failed. Available: {', '.join(available_providers)}. Try setting GPT_PROVIDER to one of: {', '.join(available_providers)}"
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
except HTTPException:
|
||||
# Re-raise HTTPExceptions (e.g., 429 subscription limit) - preserve error details
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"[llm_text_gen] Error during text generation: {str(e)}")
|
||||
raise
|
||||
|
||||
@@ -23,6 +23,11 @@ class MonitoringDataService:
|
||||
def __init__(self, db_session: Session):
|
||||
self.db = db_session
|
||||
|
||||
|
||||
def _resolve_strategy_user_id(self, strategy_id: int) -> str:
|
||||
strategy = self.db.query(EnhancedContentStrategy).filter(EnhancedContentStrategy.id == strategy_id).first()
|
||||
return str(getattr(strategy, "user_id", "0") or "0")
|
||||
|
||||
async def save_monitoring_data(self, strategy_id: int, monitoring_plan: Dict[str, Any]) -> bool:
|
||||
"""Save monitoring plan and tasks to database."""
|
||||
try:
|
||||
@@ -65,19 +70,22 @@ class MonitoringDataService:
|
||||
|
||||
self.db.add(task)
|
||||
|
||||
strategy_user_id = self._resolve_strategy_user_id(strategy_id)
|
||||
|
||||
# Save activation status
|
||||
activation_status = StrategyActivationStatus(
|
||||
strategy_id=strategy_id,
|
||||
user_id=1, # Default user ID
|
||||
user_id=strategy_user_id,
|
||||
activation_date=datetime.utcnow(),
|
||||
status='active'
|
||||
)
|
||||
self.db.add(activation_status)
|
||||
|
||||
# Save initial performance metrics
|
||||
strategy_user_id = self._resolve_strategy_user_id(strategy_id)
|
||||
performance_metrics = StrategyPerformanceMetrics(
|
||||
strategy_id=strategy_id,
|
||||
user_id=1, # Default user ID
|
||||
user_id=strategy_user_id,
|
||||
metric_date=datetime.utcnow(),
|
||||
data_source='monitoring_plan',
|
||||
confidence_score=85 # High confidence for monitoring plan data
|
||||
@@ -341,10 +349,11 @@ class MonitoringDataService:
|
||||
"""Update performance metrics for a strategy."""
|
||||
try:
|
||||
logger.info(f"Updating performance metrics for strategy {strategy_id}")
|
||||
strategy_user_id = self._resolve_strategy_user_id(strategy_id)
|
||||
|
||||
performance_metrics = StrategyPerformanceMetrics(
|
||||
strategy_id=strategy_id,
|
||||
user_id=1, # Default user ID
|
||||
user_id=strategy_user_id,
|
||||
metric_date=datetime.utcnow(),
|
||||
traffic_growth_percentage=metrics.get('traffic_growth'),
|
||||
engagement_rate_percentage=metrics.get('engagement_rate'),
|
||||
|
||||
@@ -15,14 +15,31 @@ class PodcastBibleService:
|
||||
"""Service for generating and managing the Podcast Bible."""
|
||||
|
||||
def __init__(self):
|
||||
self.personalization_service = PersonalizationService()
|
||||
try:
|
||||
from services.product_marketing.personalization_service import PersonalizationService
|
||||
self.personalization_service = PersonalizationService()
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to initialize PersonalizationService: {e}")
|
||||
self.personalization_service = None
|
||||
|
||||
def generate_bible(self, user_id: str, project_id: str) -> PodcastBible:
|
||||
"""Generate a Podcast Bible from onboarding data."""
|
||||
logger.info(f"Generating Podcast Bible for user {user_id}")
|
||||
|
||||
try:
|
||||
preferences = self.personalization_service.get_user_preferences(user_id) or {}
|
||||
if not self.personalization_service:
|
||||
logger.warning("PersonalizationService not available, using default bible")
|
||||
return self._get_default_bible(project_id)
|
||||
|
||||
try:
|
||||
preferences = self.personalization_service.get_user_preferences(user_id)
|
||||
except Exception as pref_err:
|
||||
logger.warning(f"Failed to get user preferences: {pref_err}, using defaults")
|
||||
return self._get_default_bible(project_id)
|
||||
|
||||
if not preferences:
|
||||
logger.info(f"No preferences found for user {user_id}, using defaults")
|
||||
return self._get_default_bible(project_id)
|
||||
if not isinstance(preferences, dict):
|
||||
logger.warning(f"Podcast Bible preferences payload is non-dict for user {user_id}, using defaults")
|
||||
preferences = {}
|
||||
@@ -129,18 +146,23 @@ class PodcastBibleService:
|
||||
name="AI Host",
|
||||
background="Industry Professional",
|
||||
expertise_level="Expert",
|
||||
personality_traits=["Professional", "Informative"],
|
||||
vocal_style="Authoritative",
|
||||
vocal_characteristics=["Deep", "Steady"]
|
||||
vocal_characteristics=["Deep", "Steady"],
|
||||
look="A professional individual dressed in business-casual attire."
|
||||
),
|
||||
audience=AudienceDNA(
|
||||
expertise_level="Intermediate",
|
||||
interests=["Industry Trends", "Technology"],
|
||||
pain_points=["Staying Competitive", "Operational Efficiency"]
|
||||
pain_points=["Staying Competitive", "Operational Efficiency"],
|
||||
demographics=None
|
||||
),
|
||||
brand=BrandDNA(
|
||||
industry="General Business",
|
||||
tone="Professional",
|
||||
communication_style="Analytical"
|
||||
communication_style="Analytical",
|
||||
key_messages=[],
|
||||
competitor_context=None
|
||||
),
|
||||
visual_style=VisualStyle(
|
||||
environment="Professional modern office studio",
|
||||
|
||||
@@ -61,6 +61,17 @@ class PodcastService:
|
||||
)
|
||||
).first()
|
||||
|
||||
def get_project_by_idea(self, user_id: str, idea: str) -> Optional[PodcastProject]:
|
||||
"""Find a project by matching idea (case-insensitive, partial match)."""
|
||||
# Normalize idea for comparison
|
||||
normalized_idea = idea.strip().lower()
|
||||
return self.db.query(PodcastProject).filter(
|
||||
and_(
|
||||
PodcastProject.user_id == user_id,
|
||||
PodcastProject.idea.ilike(f"%{normalized_idea}%")
|
||||
)
|
||||
).order_by(desc(PodcastProject.updated_at)).first()
|
||||
|
||||
def update_project(
|
||||
self,
|
||||
user_id: str,
|
||||
|
||||
@@ -3,6 +3,8 @@ from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.routing import APIRoute
|
||||
from loguru import logger
|
||||
from sqlalchemy import inspect, text
|
||||
|
||||
@@ -15,6 +17,7 @@ from services.database import (
|
||||
init_database,
|
||||
default_engine,
|
||||
)
|
||||
from services.user_api_key_context import get_user_api_keys
|
||||
|
||||
_REQUIRED_SCHEMA: Dict[str, List[str]] = {
|
||||
"onboarding_sessions": ["id", "user_id", "updated_at"],
|
||||
@@ -144,7 +147,123 @@ def _check_db_access(checks: List[Dict[str, Any]], errors: List[str], warnings:
|
||||
return candidate_user
|
||||
|
||||
|
||||
def run_startup_health_routine() -> Dict[str, Any]:
|
||||
def _check_production_api_key_loading(
|
||||
checks: List[Dict[str, Any]],
|
||||
errors: List[str],
|
||||
warnings: List[str],
|
||||
) -> None:
|
||||
deploy_env = os.getenv("DEPLOY_ENV", "local").strip().lower()
|
||||
if deploy_env == "local":
|
||||
_record_check(checks, "production_api_key_loading", True, "skipped in local deploy mode")
|
||||
return
|
||||
|
||||
# Also skip in podcast-only mode (no production API keys needed)
|
||||
enabled_features = os.getenv("ALWRITY_ENABLED_FEATURES", "all").strip().lower()
|
||||
if enabled_features == "podcast":
|
||||
_record_check(checks, "production_api_key_loading", True, "skipped in podcast-only mode")
|
||||
return
|
||||
|
||||
test_tenant_id = os.getenv("ALWRITY_STARTUP_TEST_TENANT_ID", "").strip()
|
||||
if not test_tenant_id:
|
||||
message = (
|
||||
"Missing ALWRITY_STARTUP_TEST_TENANT_ID for production API key startup check."
|
||||
)
|
||||
errors.append(message)
|
||||
_record_check(checks, "production_api_key_loading", False, message)
|
||||
return
|
||||
|
||||
try:
|
||||
keys = get_user_api_keys(test_tenant_id)
|
||||
except Exception as exc:
|
||||
errors.append(
|
||||
f"Failed to load API keys for startup test tenant '{test_tenant_id}': {exc}"
|
||||
)
|
||||
_record_check(checks, "production_api_key_loading", False, str(exc))
|
||||
return
|
||||
|
||||
if not isinstance(keys, dict):
|
||||
errors.append(
|
||||
f"API key loader returned invalid payload type for startup test tenant '{test_tenant_id}'."
|
||||
)
|
||||
_record_check(checks, "production_api_key_loading", False, "invalid payload type")
|
||||
return
|
||||
|
||||
non_empty_keys = [provider for provider, value in keys.items() if value]
|
||||
if not non_empty_keys:
|
||||
errors.append(
|
||||
f"No API keys could be loaded for startup test tenant '{test_tenant_id}'."
|
||||
)
|
||||
_record_check(checks, "production_api_key_loading", False, "no non-empty keys loaded")
|
||||
return
|
||||
|
||||
warning = None
|
||||
if len(non_empty_keys) < len(keys):
|
||||
warning = (
|
||||
f"Startup test tenant '{test_tenant_id}' has {len(non_empty_keys)}/{len(keys)} non-empty API keys."
|
||||
)
|
||||
warnings.append(warning)
|
||||
|
||||
detail = f"loaded {len(non_empty_keys)} non-empty keys for tenant {test_tenant_id}"
|
||||
if warning:
|
||||
detail = f"{detail}; {warning}"
|
||||
_record_check(checks, "production_api_key_loading", True, detail)
|
||||
|
||||
|
||||
def _is_demo_mode() -> bool:
|
||||
app_env = os.getenv("APP_ENV", os.getenv("ENV", os.getenv("DEPLOY_ENV", ""))).strip().lower()
|
||||
if app_env == "demo":
|
||||
return True
|
||||
return _env_true("ALWRITY_DEMO_MODE", default=False)
|
||||
|
||||
|
||||
def _check_required_demo_routes(
|
||||
app: Optional[FastAPI],
|
||||
checks: List[Dict[str, Any]],
|
||||
errors: List[str],
|
||||
) -> None:
|
||||
if not _is_demo_mode():
|
||||
_record_check(
|
||||
checks,
|
||||
"demo_required_routes",
|
||||
True,
|
||||
"Skipped (not in demo mode). Set APP_ENV=demo or ALWRITY_DEMO_MODE=true to enforce.",
|
||||
)
|
||||
return
|
||||
|
||||
if app is None:
|
||||
errors.append(
|
||||
"Demo startup route check could not run because FastAPI app context was not provided to startup health routine."
|
||||
)
|
||||
_record_check(checks, "demo_required_routes_context", False, "missing app context")
|
||||
return
|
||||
|
||||
required_routes = {
|
||||
"/api/subscription/plans": "GET",
|
||||
"/api/podcast/projects": "GET",
|
||||
}
|
||||
available_routes = {
|
||||
(route.path, method)
|
||||
for route in app.router.routes
|
||||
if isinstance(route, APIRoute)
|
||||
for method in route.methods
|
||||
}
|
||||
|
||||
missing: List[str] = []
|
||||
for path, method in required_routes.items():
|
||||
if (path, method) in available_routes:
|
||||
_record_check(checks, f"demo_route_{path}_{method}", True, "route registered")
|
||||
else:
|
||||
missing.append(f"{method} {path}")
|
||||
_record_check(checks, f"demo_route_{path}_{method}", False, "route missing")
|
||||
|
||||
if missing:
|
||||
errors.append(
|
||||
"Demo mode startup check failed. Missing required API endpoints: "
|
||||
f"{', '.join(missing)}. Ensure subscription and podcast routers are imported and included during app setup."
|
||||
)
|
||||
|
||||
|
||||
def run_startup_health_routine(app: Optional[FastAPI] = None) -> Dict[str, Any]:
|
||||
checks: List[Dict[str, Any]] = []
|
||||
errors: List[str] = []
|
||||
warnings: List[str] = []
|
||||
@@ -152,6 +271,9 @@ def run_startup_health_routine() -> Dict[str, Any]:
|
||||
_check_workspace_root(checks, errors)
|
||||
if not errors:
|
||||
_check_db_access(checks, errors, warnings)
|
||||
_check_required_demo_routes(app, checks, errors)
|
||||
if not errors:
|
||||
_check_production_api_key_loading(checks, errors, warnings)
|
||||
|
||||
status = "healthy" if not errors else "failed"
|
||||
report = {
|
||||
|
||||
@@ -46,6 +46,7 @@ class StoryAudioGenerationService:
|
||||
return _get_story_media_write_dir("audio", user_id=user_id, db=db)
|
||||
except Exception as e:
|
||||
logger.warning(f"[StoryAudioGeneration] Failed to resolve user workspace path for {user_id}: {e}")
|
||||
# Don't fall back to default - keep using the already-set output_dir for podcast
|
||||
return self.output_dir
|
||||
|
||||
def _generate_audio_filename(self, scene_number: int, scene_title: str) -> str:
|
||||
@@ -318,6 +319,7 @@ class StoryAudioGenerationService:
|
||||
text: str,
|
||||
user_id: str,
|
||||
voice_id: str = "Wise_Woman",
|
||||
custom_voice_id: Optional[str] = None,
|
||||
speed: float = 1.0,
|
||||
volume: float = 1.0,
|
||||
pitch: float = 0.0,
|
||||
@@ -364,6 +366,7 @@ class StoryAudioGenerationService:
|
||||
result = generate_audio(
|
||||
text=text.strip(),
|
||||
voice_id=voice_id,
|
||||
custom_voice_id=custom_voice_id,
|
||||
speed=speed,
|
||||
volume=volume,
|
||||
pitch=pitch,
|
||||
@@ -378,8 +381,8 @@ class StoryAudioGenerationService:
|
||||
enable_sync_mode=enable_sync_mode,
|
||||
)
|
||||
|
||||
# Determine output directory (user workspace or default)
|
||||
output_dir = self._get_user_audio_dir(user_id, db)
|
||||
# Use the output_dir that was set when service was created (already handles podcast vs story)
|
||||
output_dir = self.output_dir
|
||||
|
||||
# Save audio to file
|
||||
audio_filename = self._generate_audio_filename(scene_number, scene_title)
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import os
|
||||
from typing import Dict, Any, List, Optional
|
||||
from sqlalchemy.orm import Session
|
||||
from loguru import logger
|
||||
@@ -21,7 +22,7 @@ class StrategyCopilotService:
|
||||
"""Generate data for a specific category."""
|
||||
try:
|
||||
# Get user onboarding data
|
||||
user_id = 1 # TODO: Get from auth context
|
||||
user_id = int(os.getenv("ALWRITY_FALLBACK_USER_ID", "0"))
|
||||
integrated_data = await self.onboarding_integration_service.process_onboarding_data(str(user_id), self.db)
|
||||
onboarding_data = integrated_data.get('canonical_profile', {})
|
||||
|
||||
@@ -81,7 +82,7 @@ class StrategyCopilotService:
|
||||
"""Analyze complete strategy for completeness and coherence."""
|
||||
try:
|
||||
# Get user data for context
|
||||
user_id = 1 # TODO: Get from auth context
|
||||
user_id = int(os.getenv("ALWRITY_FALLBACK_USER_ID", "0"))
|
||||
integrated_data = await self.onboarding_integration_service.process_onboarding_data(str(user_id), self.db)
|
||||
onboarding_data = integrated_data.get('canonical_profile', {})
|
||||
|
||||
@@ -118,7 +119,7 @@ class StrategyCopilotService:
|
||||
field_definition = self._get_field_definition(field_id)
|
||||
|
||||
# Get user data
|
||||
user_id = 1 # TODO: Get from auth context
|
||||
user_id = int(os.getenv("ALWRITY_FALLBACK_USER_ID", "0"))
|
||||
# Use SSOT
|
||||
integrated_data = await self.onboarding_integration_service.process_onboarding_data(str(user_id), self.db)
|
||||
onboarding_data = integrated_data.get('canonical_profile', {})
|
||||
|
||||
@@ -431,7 +431,7 @@ class LimitValidator:
|
||||
self.db.refresh(usage)
|
||||
except Exception as query_err:
|
||||
error_str = str(query_err).lower()
|
||||
if 'no such column' in error_str and 'exa_calls' in error_str:
|
||||
if 'no such column' in error_str and ('exa_calls' in error_str or 'wavespeed' in error_str):
|
||||
logger.warning("Missing column detected in usage query, fixing schema and retrying...")
|
||||
import sqlite3
|
||||
import services.subscription.schema_utils as schema_utils
|
||||
|
||||
@@ -442,9 +442,34 @@ class PricingService:
|
||||
"description": "AI Audio Generation default pricing"
|
||||
}
|
||||
]
|
||||
|
||||
# WaveSpeed LLM Text Generation Pricing (via Cerebras)
|
||||
wavespeed_llm_pricing = [
|
||||
{
|
||||
"provider": APIProvider.WAVESPEED,
|
||||
"model_name": "openai/gpt-oss-120b",
|
||||
"cost_per_input_token": 0.0000006, # $0.60 per 1M input tokens
|
||||
"cost_per_output_token": 0.0000006, # $0.60 per 1M output tokens
|
||||
"description": "WaveSpeed GPT-OSS 120B (Cerebras) - Fast text generation"
|
||||
},
|
||||
{
|
||||
"provider": APIProvider.WAVESPEED,
|
||||
"model_name": "openai/gpt-oss-120b:cerebras",
|
||||
"cost_per_input_token": 0.0000006,
|
||||
"cost_per_output_token": 0.0000006,
|
||||
"description": "WaveSpeed GPT-OSS 120B (Cerebras) - Fast text generation"
|
||||
},
|
||||
{
|
||||
"provider": APIProvider.WAVESPEED,
|
||||
"model_name": "openai/gpt-oss-20b",
|
||||
"cost_per_input_token": 0.0000002, # $0.20 per 1M input tokens
|
||||
"cost_per_output_token": 0.0000002, # $0.20 per 1M output tokens
|
||||
"description": "WaveSpeed GPT-OSS 20B (Cerebras) - Cost-effective text generation"
|
||||
},
|
||||
]
|
||||
|
||||
# Combine all pricing data (include video pricing in search_pricing list)
|
||||
all_pricing = gemini_pricing + openai_pricing + anthropic_pricing + mistral_pricing + search_pricing
|
||||
all_pricing = gemini_pricing + openai_pricing + anthropic_pricing + mistral_pricing + search_pricing + wavespeed_llm_pricing
|
||||
|
||||
# Insert or update pricing data
|
||||
for pricing_data in all_pricing:
|
||||
|
||||
@@ -88,6 +88,9 @@ def ensure_usage_summaries_columns(db: Session) -> None:
|
||||
"image_edit_cost": "REAL DEFAULT 0.0",
|
||||
"audio_calls": "INTEGER DEFAULT 0",
|
||||
"audio_cost": "REAL DEFAULT 0.0",
|
||||
"wavespeed_calls": "INTEGER DEFAULT 0",
|
||||
"wavespeed_tokens": "INTEGER DEFAULT 0",
|
||||
"wavespeed_cost": "REAL DEFAULT 0.0",
|
||||
}
|
||||
|
||||
for col_name, ddl in required_columns.items():
|
||||
|
||||
@@ -16,6 +16,10 @@ REQUIRED_STRIPE_PLAN_KEYS = {
|
||||
}
|
||||
|
||||
|
||||
def _is_truthy_env(var_name: str) -> bool:
|
||||
return os.getenv(var_name, "").strip().lower() in {"1", "true", "yes", "on"}
|
||||
|
||||
|
||||
def _detect_stripe_mode() -> str:
|
||||
configured_mode = os.getenv("STRIPE_MODE", "").strip().lower()
|
||||
if configured_mode in {"test", "live"}:
|
||||
@@ -98,7 +102,16 @@ class StripeService:
|
||||
self.db = db
|
||||
self.api_key = os.getenv("STRIPE_SECRET_KEY")
|
||||
self.webhook_secret = os.getenv("STRIPE_WEBHOOK_SECRET")
|
||||
self.require_stripe_checkout = _is_truthy_env("REQUIRE_STRIPE_CHECKOUT")
|
||||
if not self.api_key:
|
||||
if self.require_stripe_checkout:
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=(
|
||||
"REQUIRE_STRIPE_CHECKOUT=true but STRIPE_SECRET_KEY is missing. "
|
||||
"Configure STRIPE_SECRET_KEY to enable Stripe checkout."
|
||||
),
|
||||
)
|
||||
logger.warning("STRIPE_SECRET_KEY is not set. Stripe integration will not work.")
|
||||
else:
|
||||
stripe.api_key = self.api_key
|
||||
|
||||
@@ -71,10 +71,13 @@ class UserAPIKeyContext:
|
||||
"""Load API keys from database for specific user."""
|
||||
try:
|
||||
from api.content_planning.services.content_strategy.onboarding import OnboardingDataIntegrationService
|
||||
from services.database import SessionLocal
|
||||
from services.database import get_session_for_user
|
||||
|
||||
integration_service = OnboardingDataIntegrationService()
|
||||
db = SessionLocal()
|
||||
db = get_session_for_user(user_id)
|
||||
if not db:
|
||||
logger.error(f"Failed to create DB session for user {user_id}")
|
||||
return {}
|
||||
try:
|
||||
integrated_data = integration_service.get_integrated_data_sync(user_id, db)
|
||||
keys = integrated_data.get('api_keys_data', {})
|
||||
@@ -153,4 +156,3 @@ def get_tavily_key(user_id: Optional[str] = None) -> Optional[str]:
|
||||
def get_copilotkit_key(user_id: Optional[str] = None) -> Optional[str]:
|
||||
"""Get CopilotKit API key for user."""
|
||||
return UserAPIKeyContext.get_user_key(user_id, 'copilotkit')
|
||||
|
||||
|
||||
@@ -241,6 +241,7 @@ class WaveSpeedClient:
|
||||
self,
|
||||
text: str,
|
||||
voice_id: str,
|
||||
custom_voice_id: Optional[str] = None,
|
||||
speed: float = 1.0,
|
||||
volume: float = 1.0,
|
||||
pitch: float = 0.0,
|
||||
@@ -255,6 +256,7 @@ class WaveSpeedClient:
|
||||
Args:
|
||||
text: Text to convert to speech (max 10000 characters)
|
||||
voice_id: Voice ID (e.g., "Wise_Woman", "Friendly_Person", etc.)
|
||||
custom_voice_id: Custom voice clone ID for using cloned voice
|
||||
speed: Speech speed (0.5-2.0, default: 1.0)
|
||||
volume: Speech volume (0.1-10.0, default: 1.0)
|
||||
pitch: Speech pitch (-12 to 12, default: 0.0)
|
||||
@@ -269,6 +271,7 @@ class WaveSpeedClient:
|
||||
return self.speech.generate_speech(
|
||||
text=text,
|
||||
voice_id=voice_id,
|
||||
custom_voice_id=custom_voice_id,
|
||||
speed=speed,
|
||||
volume=volume,
|
||||
pitch=pitch,
|
||||
|
||||
@@ -40,6 +40,7 @@ class SpeechGenerator:
|
||||
self,
|
||||
text: str,
|
||||
voice_id: str,
|
||||
custom_voice_id: Optional[str] = None,
|
||||
speed: float = 1.0,
|
||||
volume: float = 1.0,
|
||||
pitch: float = 0.0,
|
||||
@@ -54,6 +55,7 @@ class SpeechGenerator:
|
||||
Args:
|
||||
text: Text to convert to speech (max 10000 characters)
|
||||
voice_id: Voice ID (e.g., "Wise_Woman", "Friendly_Person", etc.)
|
||||
custom_voice_id: Custom voice clone ID for using cloned voice
|
||||
speed: Speech speed (0.5-2.0, default: 1.0)
|
||||
volume: Speech volume (0.1-10.0, default: 1.0)
|
||||
pitch: Speech pitch (-12 to 12, default: 0.0)
|
||||
@@ -77,6 +79,11 @@ class SpeechGenerator:
|
||||
if not sanitized_voice_id:
|
||||
raise ValueError("Voice ID cannot be empty after sanitization")
|
||||
|
||||
# Sanitize custom_voice_id if provided
|
||||
sanitized_custom_voice_id = None
|
||||
if custom_voice_id:
|
||||
sanitized_custom_voice_id = str(custom_voice_id).strip() or None
|
||||
|
||||
# Ensure numeric parameters are proper floats and within valid ranges
|
||||
sanitized_speed = max(0.5, min(2.0, float(speed))) if speed is not None else 1.0
|
||||
sanitized_volume = max(0.1, min(10.0, float(volume))) if volume is not None else 1.0
|
||||
@@ -112,6 +119,10 @@ class SpeechGenerator:
|
||||
"enable_sync_mode": bool(enable_sync_mode),
|
||||
}
|
||||
|
||||
# Add custom voice clone ID if provided
|
||||
if sanitized_custom_voice_id:
|
||||
payload["custom_voice_id"] = sanitized_custom_voice_id
|
||||
|
||||
# Add optional parameters with proper type validation
|
||||
optional_params = [
|
||||
"english_normalization",
|
||||
@@ -179,6 +190,20 @@ class SpeechGenerator:
|
||||
|
||||
if response.status_code != 200:
|
||||
logger.error(f"[WaveSpeed] Speech generation failed: {response.status_code} {response.text}")
|
||||
|
||||
# Check for custom voice ID specific errors
|
||||
response_text = response.text.lower()
|
||||
if "custom_voice" in response_text or "voice_id" in response_text:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail={
|
||||
"error": "Invalid voice clone ID",
|
||||
"message": "The custom voice ID is invalid or expired. Please create a new voice clone or use a predefined voice.",
|
||||
"status_code": response.status_code,
|
||||
"response": response.text,
|
||||
},
|
||||
)
|
||||
|
||||
raise HTTPException(
|
||||
status_code=502,
|
||||
detail={
|
||||
|
||||
@@ -26,20 +26,24 @@ def _generate_simple_infinitetalk_prompt(
|
||||
story_context: Dict[str, Any],
|
||||
) -> Optional[str]:
|
||||
"""
|
||||
Generate a balanced, concise prompt for InfiniteTalk.
|
||||
InfiniteTalk is audio-driven, so the prompt should describe the scene and suggest
|
||||
subtle motion, but avoid overly elaborate cinematic descriptions.
|
||||
Generate an enhanced prompt for InfiniteTalk video generation.
|
||||
Includes scene content, analysis, bible context, and visual elements.
|
||||
|
||||
Returns None if no meaningful prompt can be generated.
|
||||
"""
|
||||
title = (scene_data.get("title") or "").strip()
|
||||
description = (scene_data.get("description") or "").strip()
|
||||
image_prompt = (scene_data.get("image_prompt") or "").strip()
|
||||
lines = scene_data.get("lines", [])
|
||||
narration = ""
|
||||
if lines:
|
||||
# Combine first few lines for context
|
||||
narration = " ".join([str(l.get("text", "")) for l in lines[:3]])[:150]
|
||||
|
||||
# Build a balanced prompt: scene description + simple motion hint
|
||||
# Build enhanced prompt with multiple context sources
|
||||
parts = []
|
||||
|
||||
# Add scene context
|
||||
# Add main scene title
|
||||
if title and len(title) > 5 and title.lower() not in ("scene", "podcast", "episode"):
|
||||
parts.append(title)
|
||||
|
||||
@@ -48,60 +52,70 @@ def _generate_simple_infinitetalk_prompt(
|
||||
if analysis:
|
||||
content_type = analysis.get("content_type")
|
||||
if content_type:
|
||||
parts.append(f"Style: {content_type}")
|
||||
parts.append(f"Content type: {content_type}")
|
||||
|
||||
# Audience helps define the formality/vibe
|
||||
# Add key takeaways if available
|
||||
key_takeaways = analysis.get("keyTakeaways", [])
|
||||
if key_takeaways and isinstance(key_takeaways, list) and len(key_takeaways) > 0:
|
||||
takeaway = str(key_takeaways[0])[:80]
|
||||
if takeaway:
|
||||
parts.append(f"Key insight: {takeaway}")
|
||||
|
||||
# Audience
|
||||
audience = analysis.get("audience")
|
||||
if audience:
|
||||
# Just use first few words of audience to keep it short
|
||||
short_audience = " ".join(audience.split()[:3])
|
||||
parts.append(f"For: {short_audience}")
|
||||
|
||||
# Add bible context if available
|
||||
short_audience = " ".join(audience.split()[:3])
|
||||
parts.append(f"Target audience: {short_audience}")
|
||||
|
||||
# Guest info
|
||||
guest_name = analysis.get("guestName")
|
||||
guest_expertise = analysis.get("guestExpertise")
|
||||
if guest_name:
|
||||
parts.append(f"Guest: {guest_name}")
|
||||
if guest_expertise:
|
||||
parts.append(f"Expertise: {guest_expertise}")
|
||||
|
||||
# Add bible context
|
||||
bible = story_context.get("bible", {})
|
||||
if bible:
|
||||
host_persona = bible.get("host_persona")
|
||||
tone = bible.get("tone")
|
||||
visual_style = bible.get("visual_style")
|
||||
background = bible.get("background")
|
||||
|
||||
if host_persona:
|
||||
parts.append(f"Host: {host_persona}")
|
||||
parts.append(f"Host persona: {host_persona}")
|
||||
if tone:
|
||||
parts.append(f"Tone: {tone}")
|
||||
|
||||
elif description:
|
||||
# Take first sentence or first 60 chars
|
||||
desc_part = description.split('.')[0][:60].strip()
|
||||
if desc_part:
|
||||
parts.append(desc_part)
|
||||
elif image_prompt:
|
||||
# Take first sentence or first 60 chars
|
||||
img_part = image_prompt.split('.')[0][:60].strip()
|
||||
if visual_style:
|
||||
parts.append(f"Visual style: {visual_style}")
|
||||
if background:
|
||||
parts.append(f"Background: {background}")
|
||||
|
||||
# Add original image prompt as fallback context
|
||||
if image_prompt and len(parts) < 3:
|
||||
img_part = image_prompt.split('.')[0][:100].strip()
|
||||
if img_part:
|
||||
parts.append(img_part)
|
||||
parts.append(f"Visual context: {img_part}")
|
||||
|
||||
# Add narration snippet if available
|
||||
if narration and len(parts) < 4:
|
||||
parts.append(f"Discussing: {narration}")
|
||||
|
||||
if not parts:
|
||||
return None
|
||||
|
||||
# Add a simple, subtle motion suggestion (not elaborate camera movements)
|
||||
# Keep it natural and audio-driven
|
||||
motion_hints = [
|
||||
"with subtle movement",
|
||||
"with gentle motion",
|
||||
"with natural animation",
|
||||
]
|
||||
# Build prompt with visual quality keywords
|
||||
quality_keywords = "Cinematic lighting, high detail, 4k quality, smooth motion"
|
||||
|
||||
# Combine scene description with subtle motion hint
|
||||
if len(parts[0]) < 80:
|
||||
# Room for a motion hint
|
||||
prompt = f"{parts[0]}, {motion_hints[0]}"
|
||||
else:
|
||||
# Just use the description if it's already long enough
|
||||
prompt = parts[0]
|
||||
# Combine parts into final prompt
|
||||
prompt = f"{'. '.join(parts)}. {quality_keywords}. With subtle natural movement."
|
||||
|
||||
# Keep it concise - max 120 characters (allows for scene + motion hint)
|
||||
prompt = prompt[:120].strip()
|
||||
# Allow more room for detailed prompts - max 350 characters
|
||||
prompt = prompt[:350].strip()
|
||||
|
||||
# Clean up trailing commas or incomplete sentences
|
||||
if prompt.endswith(','):
|
||||
# Clean up trailing punctuation
|
||||
if prompt.endswith(',') or prompt.endswith('.'):
|
||||
prompt = prompt[:-1].strip()
|
||||
|
||||
return prompt if len(prompt) >= 15 else None
|
||||
|
||||
@@ -120,6 +120,15 @@ class SIFReleaseReadinessTests(unittest.IsolatedAsyncioTestCase):
|
||||
self.assertFalse(validation["is_contextual"])
|
||||
self.assertEqual(validation["tasks_below_min_evidence"], 1)
|
||||
|
||||
|
||||
def test_demo_release_flag_guards_sensitive_routers(self):
|
||||
source = Path("backend/alwrity_utils/router_manager.py").read_text()
|
||||
self.assertIn("ALWRITY_DEMO_RELEASE", source)
|
||||
self.assertIn("Skipping facebook_writer router in demo-release mode", source)
|
||||
self.assertIn("Skipping linkedin router in demo-release mode", source)
|
||||
self.assertIn("Skipping linkedin_image router in demo-release mode", source)
|
||||
self.assertIn("Skipping persona router in demo-release mode", source)
|
||||
|
||||
def test_pillar_coverage_guardrail_backfills_missing(self):
|
||||
tasks = [{"pillarId": "plan", "title": "Plan", "description": "d", "priority": "high", "estimatedTime": 10, "actionType": "navigate", "enabled": True}]
|
||||
grounding = {"workflow_config": {"enforce_pillar_coverage": True}}
|
||||
|
||||
@@ -7,11 +7,82 @@ Run this from the backend directory to set up and start the FastAPI server.
|
||||
|
||||
import os
|
||||
import sys
|
||||
import json
|
||||
import argparse
|
||||
from pathlib import Path
|
||||
from dataclasses import dataclass, asdict
|
||||
from typing import Optional
|
||||
|
||||
|
||||
def bootstrap_linguistic_models():
|
||||
@dataclass
|
||||
class BootstrapResult:
|
||||
name: str
|
||||
success: bool
|
||||
skipped: bool
|
||||
reason: Optional[str] = None
|
||||
details: Optional[str] = None
|
||||
|
||||
|
||||
LINGUISTIC_REQUIRED_FEATURES = {"content_planning", "strategy_copilot", "facebook", "linkedin", "blog_writer", "persona"}
|
||||
|
||||
|
||||
def get_enabled_features() -> set:
|
||||
"""Get enabled features from ALWRITY_ENABLED_FEATURES env var.
|
||||
|
||||
Values:
|
||||
- "all" - enable all features (default)
|
||||
- comma-separated: "podcast,blog-writer,youtube"
|
||||
- single feature: "podcast"
|
||||
"""
|
||||
env_value = os.getenv("ALWRITY_ENABLED_FEATURES", "all").strip().lower()
|
||||
|
||||
if not env_value or env_value == "all":
|
||||
return {"all"}
|
||||
|
||||
return {f.strip() for f in env_value.split(",") if f.strip()}
|
||||
|
||||
|
||||
def should_bootstrap_linguistic_models() -> bool:
|
||||
"""Decide whether to bootstrap linguistic models based on enabled features."""
|
||||
enabled_features = get_enabled_features()
|
||||
verbose = os.getenv("ALWRITY_VERBOSE", "false").lower() == "true"
|
||||
|
||||
if "all" in enabled_features:
|
||||
return True
|
||||
|
||||
# Podcast-only mode doesn't need linguistic models
|
||||
if enabled_features == {"podcast"}:
|
||||
return False
|
||||
|
||||
# Map old profile names to features for backwards compatibility
|
||||
feature_mapping = {
|
||||
"podcast": "podcast",
|
||||
"youtube": "youtube",
|
||||
"planning": "content-planning",
|
||||
"default": "all"
|
||||
}
|
||||
|
||||
# Check if any linguistic-required feature is enabled
|
||||
linguistic_features = {"content_planning", "facebook", "linkedin", "blog-writer", "persona"}
|
||||
return bool(enabled_features & linguistic_features)
|
||||
|
||||
|
||||
def should_bootstrap_local_llm_models() -> bool:
|
||||
"""Decide whether to bootstrap local LLM models based on enabled features.
|
||||
|
||||
SIF/Story Writer requires local LLM - skip if only podcast is enabled.
|
||||
"""
|
||||
enabled_features = get_enabled_features()
|
||||
|
||||
if "all" in enabled_features:
|
||||
return True
|
||||
|
||||
# SIF/Story Writer requires local LLM - only bootstrap if explicitly needed
|
||||
# Skip for lean deployments (podcast-only, content-planning only, etc.)
|
||||
return False # Default to skip unless "all" is enabled
|
||||
|
||||
|
||||
def bootstrap_linguistic_models() -> BootstrapResult:
|
||||
"""
|
||||
Bootstrap spaCy and NLTK models BEFORE any imports.
|
||||
This prevents import-time failures when EnhancedLinguisticAnalyzer is loaded.
|
||||
@@ -44,7 +115,7 @@ def bootstrap_linguistic_models():
|
||||
if verbose:
|
||||
print(f" ❌ Failed to download spaCy model: {e}")
|
||||
print(" Please run: python -m spacy download en_core_web_sm")
|
||||
return False
|
||||
return BootstrapResult(name="linguistic_models", success=False, skipped=False, reason="spacy_download_failed")
|
||||
except ImportError:
|
||||
if verbose:
|
||||
print(" ⚠️ spaCy not installed - skipping")
|
||||
@@ -73,7 +144,6 @@ def bootstrap_linguistic_models():
|
||||
except Exception as e:
|
||||
if verbose:
|
||||
print(f" ⚠️ Failed to download {data_package}: {e}")
|
||||
# Try fallback
|
||||
if data_package == 'punkt_tab':
|
||||
try:
|
||||
nltk.download('punkt', quiet=True)
|
||||
@@ -87,10 +157,10 @@ def bootstrap_linguistic_models():
|
||||
|
||||
if verbose:
|
||||
print("✅ Linguistic model bootstrap complete")
|
||||
return True
|
||||
return BootstrapResult(name="linguistic_models", success=True, skipped=False)
|
||||
|
||||
|
||||
def bootstrap_local_llm_models():
|
||||
def bootstrap_local_llm_models() -> BootstrapResult:
|
||||
"""
|
||||
Bootstrap Local LLM models (Qwen) for SIF Agents.
|
||||
This ensures the model is cached locally before the server starts,
|
||||
@@ -117,7 +187,7 @@ def bootstrap_local_llm_models():
|
||||
if os.getenv("RENDER") or os.getenv("RAILWAY_ENVIRONMENT"):
|
||||
if verbose:
|
||||
print(" ⚠️ Cloud environment detected (Render/Railway). Skipping local LLM bootstrap to save RAM/Time.")
|
||||
return True
|
||||
return BootstrapResult(name="local_llm_models", success=True, skipped=True, reason="cloud_environment")
|
||||
|
||||
target_model = "Qwen/Qwen2.5-3B-Instruct"
|
||||
|
||||
@@ -135,18 +205,62 @@ def bootstrap_local_llm_models():
|
||||
if verbose:
|
||||
print(f" ⚠️ Failed to download/check local LLM: {e}")
|
||||
print(" SIF agents may try to download it at runtime.")
|
||||
return False
|
||||
return BootstrapResult(name="local_llm_models", success=False, skipped=False, reason=str(e))
|
||||
except ImportError:
|
||||
if verbose:
|
||||
print(" ⚠️ huggingface_hub not installed - skipping LLM bootstrap")
|
||||
return BootstrapResult(name="local_llm_models", success=False, skipped=True, reason="huggingface_hub_not_installed")
|
||||
|
||||
return True
|
||||
return BootstrapResult(name="local_llm_models", success=True, skipped=False)
|
||||
|
||||
|
||||
# Bootstrap linguistic models BEFORE any imports that might need them
|
||||
BOOTSTRAP_RESULTS = []
|
||||
|
||||
# Load .env file early so ALWRITY_ENABLED_FEATURES is available
|
||||
from dotenv import load_dotenv
|
||||
load_dotenv()
|
||||
|
||||
# Debug: Print what PORT is set to
|
||||
import os
|
||||
print(f"[DEBUG] PORT env: {os.getenv('PORT')}")
|
||||
print(f"[DEBUG] RENDER env: {os.getenv('RENDER')}")
|
||||
|
||||
if __name__ == "__main__":
|
||||
bootstrap_linguistic_models()
|
||||
bootstrap_local_llm_models()
|
||||
enabled_features = get_enabled_features()
|
||||
features_str = ",".join(sorted(enabled_features))
|
||||
os.environ["ALWRITY_ENABLED_FEATURES"] = features_str
|
||||
|
||||
print(f"\n📋 Enabled features: {features_str}")
|
||||
|
||||
if should_bootstrap_linguistic_models():
|
||||
result = bootstrap_linguistic_models()
|
||||
BOOTSTRAP_RESULTS.append(result)
|
||||
else:
|
||||
verbose = os.getenv("ALWRITY_VERBOSE", "false").lower() == "true"
|
||||
if verbose:
|
||||
print("⏭️ Skipping linguistic model bootstrap (profile-gated)")
|
||||
BOOTSTRAP_RESULTS.append(BootstrapResult(name="linguistic_models", success=True, skipped=True, reason="profile_gated"))
|
||||
|
||||
if should_bootstrap_local_llm_models():
|
||||
result = bootstrap_local_llm_models()
|
||||
BOOTSTRAP_RESULTS.append(result)
|
||||
else:
|
||||
verbose = os.getenv("ALWRITY_VERBOSE", "false").lower() == "true"
|
||||
if verbose:
|
||||
print("⏭️ Skipping local LLM model bootstrap (feature-gated)")
|
||||
BOOTSTRAP_RESULTS.append(BootstrapResult(name="local_llm_models", success=True, skipped=True, reason="feature_gated"))
|
||||
|
||||
summary = {
|
||||
"enabled_features": features_str,
|
||||
"bootstraps": [asdict(r) for r in BOOTSTRAP_RESULTS]
|
||||
}
|
||||
os.environ["ALWRITY_BOOTSTRAP_SUMMARY"] = json.dumps(summary)
|
||||
|
||||
print(f"\n📋 Bootstrap Summary:")
|
||||
for r in BOOTSTRAP_RESULTS:
|
||||
status = "⏭️ Skipped" if r.skipped else ("✅ Enabled" if r.success else "❌ Failed")
|
||||
print(f" {r.name}: {status}" + (f" ({r.reason})" if r.reason else ""))
|
||||
|
||||
# NOW import modular utilities (after bootstrap)
|
||||
from alwrity_utils import (
|
||||
@@ -160,6 +274,13 @@ from alwrity_utils import (
|
||||
def start_backend(enable_reload=False, production_mode=False):
|
||||
"""Start the backend server."""
|
||||
print("🚀 Starting ALwrity Backend...")
|
||||
podcast_only_demo_mode = os.getenv("ALWRITY_PODCAST_ONLY_DEMO_MODE", os.getenv("PODCAST_ONLY_DEMO_MODE", "false")).lower() in {"1", "true", "yes", "on"}
|
||||
|
||||
if podcast_only_demo_mode:
|
||||
print("\n" + "=" * 60)
|
||||
print("🎙️ PODCAST-ONLY DEMO MODE ACTIVE")
|
||||
print(" Non-podcast router groups are intentionally skipped.")
|
||||
print("=" * 60)
|
||||
|
||||
# Set host based on environment and mode
|
||||
# Use 127.0.0.1 for local production testing on Windows
|
||||
@@ -185,14 +306,14 @@ def start_backend(enable_reload=False, production_mode=False):
|
||||
os.environ.setdefault("RELOAD", "false")
|
||||
print(" 🏭 Production mode: Auto-reload disabled")
|
||||
|
||||
host = os.getenv("HOST")
|
||||
host = os.getenv("HOST", "0.0.0.0")
|
||||
port = int(os.getenv("PORT", "8000"))
|
||||
reload = os.getenv("RELOAD", "false").lower() == "true"
|
||||
|
||||
print(f" 📍 Host: {host}")
|
||||
print(f" 🔌 Port: {port}")
|
||||
print(f" 🔄 Reload: {reload}")
|
||||
print(f" 🔄 Reload: {reload}")
|
||||
print(f"[DEBUG] Starting server with host={host}, port={port}")
|
||||
|
||||
try:
|
||||
# Import and run the app
|
||||
@@ -401,4 +522,4 @@ def main():
|
||||
if __name__ == "__main__":
|
||||
success = main()
|
||||
if not success:
|
||||
sys.exit(1)
|
||||
sys.exit(1)
|
||||
|
||||
@@ -105,8 +105,21 @@ JWT_SECRET_KEY=your_jwt_secret_key
|
||||
|
||||
# Monitoring
|
||||
SENTRY_DSN=your_sentry_dsn
|
||||
|
||||
# Podcast demo-mode switch (temporary testing flag)
|
||||
# Enable demo-only podcast behavior:
|
||||
PODCAST_ONLY_DEMO_MODE=true
|
||||
# Full restore to normal behavior:
|
||||
# PODCAST_ONLY_DEMO_MODE=false
|
||||
# (or leave PODCAST_ONLY_DEMO_MODE unset)
|
||||
```
|
||||
|
||||
### Release Checklist (Demo-Mode Safety)
|
||||
|
||||
Before finalizing a release after demo testing, confirm:
|
||||
|
||||
- [ ] `PODCAST_ONLY_DEMO_MODE` is unset (or explicitly `false`) in production deployment config.
|
||||
|
||||
**Security Best Practices**
|
||||
- **Use Environment Variables**: Never hardcode sensitive data
|
||||
- **Rotate Keys Regularly**: Change API keys periodically
|
||||
|
||||
10438
frontend/package-lock.json
generated
10438
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -22,6 +22,7 @@
|
||||
"@types/recharts": "^1.8.29",
|
||||
"@wix/blog": "^1.0.488",
|
||||
"@wix/sdk": "^1.17.1",
|
||||
"ajv": "^8.18.0",
|
||||
"axios": "^1.12.0",
|
||||
"framer-motion": "^12.23.12",
|
||||
"html2canvas": "^1.4.1",
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import React from 'react';
|
||||
import { BrowserRouter as Router, Routes, Route, Navigate, useLocation } from 'react-router-dom';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom';
|
||||
import { Box, CircularProgress, Typography } from '@mui/material';
|
||||
import { CopilotKit } from "@copilotkit/react-core";
|
||||
import { ClerkProvider, useAuth } from '@clerk/clerk-react';
|
||||
import "@copilotkit/react-ui/styles.css";
|
||||
import Wizard from './components/OnboardingWizard/Wizard';
|
||||
import MainDashboard from './components/MainDashboard/MainDashboard';
|
||||
import SEODashboard from './components/SEODashboard/SEODashboard';
|
||||
@@ -56,18 +54,11 @@ import GSCAuthCallback from './components/SEODashboard/components/GSCAuthCallbac
|
||||
import Landing from './components/Landing/Landing';
|
||||
import ErrorBoundary from './components/shared/ErrorBoundary';
|
||||
import ErrorBoundaryTest from './components/shared/ErrorBoundaryTest';
|
||||
import CopilotKitDegradedBanner from './components/shared/CopilotKitDegradedBanner';
|
||||
import { OnboardingProvider } from './contexts/OnboardingContext';
|
||||
import { SubscriptionProvider, useSubscription } from './contexts/SubscriptionContext';
|
||||
import { CopilotKitHealthProvider } from './contexts/CopilotKitHealthContext';
|
||||
import { useOAuthTokenAlerts } from './hooks/useOAuthTokenAlerts';
|
||||
|
||||
import { setAuthTokenGetter, setClerkSignOut } from './api/client';
|
||||
import { setMediaAuthTokenGetter } from './utils/fetchMediaBlobUrl';
|
||||
import { setBillingAuthTokenGetter } from './services/billingService';
|
||||
import { useOnboarding } from './contexts/OnboardingContext';
|
||||
import { useState, useEffect } from 'react';
|
||||
import ConnectionErrorPage from './components/shared/ConnectionErrorPage';
|
||||
import { SubscriptionProvider } from './contexts/SubscriptionContext';
|
||||
import InitialRouteHandler from './components/App/InitialRouteHandler';
|
||||
import TokenInstaller from './components/App/TokenInstaller';
|
||||
import { ConditionalCopilotKit, AuthenticatedCopilotWrapper } from './components/App/CopilotWrappers';
|
||||
|
||||
// interface OnboardingStatus {
|
||||
// onboarding_required: boolean;
|
||||
@@ -77,344 +68,6 @@ import ConnectionErrorPage from './components/shared/ConnectionErrorPage';
|
||||
// completion_percentage?: number;
|
||||
// }
|
||||
|
||||
// Conditional CopilotKit wrapper that only shows sidebar on content-planning route
|
||||
const ConditionalCopilotKit: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
// Do not render CopilotSidebar here. Let specific pages/components control it.
|
||||
return <>{children}</>;
|
||||
};
|
||||
|
||||
// Wrapper to only enable CopilotKit checks/provider when user is authenticated
|
||||
// This prevents CopilotKit from running on the Landing page
|
||||
const AuthenticatedCopilotWrapper: React.FC<{
|
||||
children: React.ReactNode;
|
||||
apiKey: string;
|
||||
}> = ({ children, apiKey }) => {
|
||||
const { isSignedIn } = useAuth();
|
||||
const location = useLocation();
|
||||
|
||||
// Exclude CopilotKit from running on:
|
||||
// 1. Landing page (handled by !isSignedIn)
|
||||
// 2. Onboarding pages (to prevent health check timeouts)
|
||||
const shouldExcludeCopilot = !isSignedIn || location.pathname.startsWith('/onboarding');
|
||||
|
||||
if (shouldExcludeCopilot) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
const hasKey = apiKey && apiKey.trim();
|
||||
|
||||
if (hasKey) {
|
||||
// Enhanced error handler that updates health context
|
||||
const handleCopilotKitError = (e: any) => {
|
||||
console.error("CopilotKit Error:", e);
|
||||
|
||||
// Try to get health context if available
|
||||
// We'll use a custom event to notify health context since we can't access it directly here
|
||||
const errorMessage = e?.error?.message || e?.message || 'CopilotKit error occurred';
|
||||
const errorType = errorMessage.toLowerCase();
|
||||
|
||||
// Differentiate between fatal and transient errors
|
||||
const isFatalError =
|
||||
errorType.includes('cors') ||
|
||||
errorType.includes('ssl') ||
|
||||
errorType.includes('certificate') ||
|
||||
errorType.includes('403') ||
|
||||
errorType.includes('forbidden') ||
|
||||
errorType.includes('ERR_CERT_COMMON_NAME_INVALID');
|
||||
|
||||
// Dispatch event for health context to listen to
|
||||
window.dispatchEvent(new CustomEvent('copilotkit-error', {
|
||||
detail: {
|
||||
error: e,
|
||||
errorMessage,
|
||||
isFatal: isFatalError,
|
||||
}
|
||||
}));
|
||||
};
|
||||
|
||||
return (
|
||||
<CopilotKitHealthProvider initialHealthStatus={true}>
|
||||
<CopilotKitDegradedBanner />
|
||||
<ErrorBoundary
|
||||
context="CopilotKit"
|
||||
showDetails={process.env.NODE_ENV === 'development'}
|
||||
fallback={
|
||||
<Box sx={{ p: 3, textAlign: 'center' }}>
|
||||
<Typography variant="h6" color="warning" gutterBottom>
|
||||
Chat Unavailable
|
||||
</Typography>
|
||||
<Typography variant="body2" color="textSecondary">
|
||||
CopilotKit encountered an error. The app continues to work with manual controls.
|
||||
</Typography>
|
||||
</Box>
|
||||
}
|
||||
>
|
||||
<CopilotKit
|
||||
publicApiKey={apiKey}
|
||||
showDevConsole={false}
|
||||
onError={handleCopilotKitError}
|
||||
>
|
||||
{children}
|
||||
</CopilotKit>
|
||||
</ErrorBoundary>
|
||||
</CopilotKitHealthProvider>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<CopilotKitHealthProvider initialHealthStatus={false}>
|
||||
<CopilotKitDegradedBanner />
|
||||
{children}
|
||||
</CopilotKitHealthProvider>
|
||||
);
|
||||
};
|
||||
|
||||
// Component to handle initial routing based on subscription and onboarding status
|
||||
// Flow: Subscription → Onboarding → Dashboard
|
||||
const InitialRouteHandler: React.FC = () => {
|
||||
const { loading, error, isOnboardingComplete, initializeOnboarding, data } = useOnboarding();
|
||||
const { subscription, loading: subscriptionLoading, checkSubscription } = useSubscription();
|
||||
const [connectionError, setConnectionError] = useState<{
|
||||
hasError: boolean;
|
||||
error: Error | null;
|
||||
}>({
|
||||
hasError: false,
|
||||
error: null,
|
||||
});
|
||||
|
||||
// Poll for OAuth token alerts and show toast notifications
|
||||
// Only enabled when user is authenticated (has subscription)
|
||||
useOAuthTokenAlerts({
|
||||
enabled: subscription?.active === true,
|
||||
interval: 60000, // Poll every 1 minute
|
||||
});
|
||||
|
||||
// Check subscription on mount (non-blocking - don't wait for it to route)
|
||||
useEffect(() => {
|
||||
// Delay subscription check slightly to allow auth token getter to be installed first
|
||||
const timeoutId = setTimeout(async () => {
|
||||
// Retry logic for initial subscription check
|
||||
const maxRetries = 3;
|
||||
for (let attempt = 0; attempt < maxRetries; attempt++) {
|
||||
try {
|
||||
await checkSubscription();
|
||||
break; // Success
|
||||
} catch (err) {
|
||||
console.error(`App: Subscription check attempt ${attempt + 1} failed:`, err);
|
||||
|
||||
// If it's a connection error and we have retries left, wait and retry
|
||||
const isConnectionError = err instanceof Error && (err.name === 'NetworkError' || err.name === 'ConnectionError');
|
||||
|
||||
if (isConnectionError && attempt < maxRetries - 1) {
|
||||
const delay = 1000 * Math.pow(2, attempt); // 1s, 2s
|
||||
await new Promise(resolve => setTimeout(resolve, delay));
|
||||
continue;
|
||||
}
|
||||
|
||||
// If final attempt or not a connection error, handle it
|
||||
if (attempt === maxRetries - 1 || !isConnectionError) {
|
||||
if (isConnectionError) {
|
||||
setConnectionError({
|
||||
hasError: true,
|
||||
error: err as Error,
|
||||
});
|
||||
}
|
||||
// Don't block routing on other errors
|
||||
}
|
||||
}
|
||||
}
|
||||
}, 100); // Small delay to ensure TokenInstaller has run
|
||||
|
||||
return () => clearTimeout(timeoutId);
|
||||
}, []); // Remove checkSubscription dependency to prevent loop
|
||||
|
||||
// Initialize onboarding only after subscription is confirmed
|
||||
useEffect(() => {
|
||||
if (subscription && !subscriptionLoading) {
|
||||
// Check if user is new (no subscription record at all)
|
||||
const isNewUser = !subscription || subscription.plan === 'none';
|
||||
|
||||
console.log('InitialRouteHandler: Subscription data received:', {
|
||||
plan: subscription.plan,
|
||||
active: subscription.active,
|
||||
isNewUser,
|
||||
subscriptionLoading
|
||||
});
|
||||
|
||||
if (subscription.active && !isNewUser) {
|
||||
console.log('InitialRouteHandler: Subscription confirmed, initializing onboarding...');
|
||||
initializeOnboarding();
|
||||
}
|
||||
}
|
||||
}, [subscription, subscriptionLoading, initializeOnboarding]);
|
||||
|
||||
// Handle connection error - show connection error page
|
||||
if (connectionError.hasError) {
|
||||
const handleRetry = () => {
|
||||
setConnectionError({
|
||||
hasError: false,
|
||||
error: null,
|
||||
});
|
||||
// Re-trigger the subscription check using context
|
||||
checkSubscription().catch((err) => {
|
||||
if (err instanceof Error && (err.name === 'NetworkError' || err.name === 'ConnectionError')) {
|
||||
setConnectionError({
|
||||
hasError: true,
|
||||
error: err,
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const handleGoHome = () => {
|
||||
window.location.href = '/';
|
||||
};
|
||||
|
||||
return (
|
||||
<ConnectionErrorPage
|
||||
onRetry={handleRetry}
|
||||
onGoHome={handleGoHome}
|
||||
message={connectionError.error?.message || "Backend service is not available. Please check if the server is running."}
|
||||
title="Connection Error"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Loading state - only wait for onboarding init, not subscription check
|
||||
// Subscription check is non-blocking and happens in background
|
||||
const waitingForOnboardingInit = loading || !data;
|
||||
if (loading || waitingForOnboardingInit) {
|
||||
return (
|
||||
<Box
|
||||
display="flex"
|
||||
flexDirection="column"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
minHeight="100vh"
|
||||
gap={2}
|
||||
>
|
||||
<CircularProgress size={60} />
|
||||
<Typography variant="h6" color="textSecondary">
|
||||
{subscriptionLoading ? 'Checking subscription...' : 'Preparing your workspace...'}
|
||||
</Typography>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// Error state
|
||||
if (error) {
|
||||
return (
|
||||
<Box
|
||||
display="flex"
|
||||
flexDirection="column"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
minHeight="100vh"
|
||||
gap={2}
|
||||
p={3}
|
||||
>
|
||||
<Typography variant="h5" color="error" gutterBottom>
|
||||
Error
|
||||
</Typography>
|
||||
<Typography variant="body1" color="textSecondary" textAlign="center">
|
||||
{error}
|
||||
</Typography>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// Decision tree for SIGNED-IN users:
|
||||
// Priority: Subscription → Onboarding → Dashboard (as per user flow: Landing → Subscription → Onboarding → Dashboard)
|
||||
|
||||
// 1. If subscription is still loading, show loading state
|
||||
if (subscriptionLoading) {
|
||||
return (
|
||||
<Box
|
||||
display="flex"
|
||||
flexDirection="column"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
minHeight="100vh"
|
||||
gap={2}
|
||||
>
|
||||
<CircularProgress size={60} />
|
||||
<Typography variant="h6" color="textSecondary">
|
||||
Checking subscription...
|
||||
</Typography>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// 2. No subscription data yet - handle gracefully
|
||||
// If onboarding is complete, allow access to dashboard (user already went through flow)
|
||||
// If onboarding not complete, check if subscription check is still loading or failed
|
||||
if (!subscription) {
|
||||
if (isOnboardingComplete) {
|
||||
console.log('InitialRouteHandler: Onboarding complete but no subscription data → Dashboard (allow access)');
|
||||
return <Navigate to="/dashboard" replace />;
|
||||
}
|
||||
|
||||
// Onboarding not complete and no subscription data
|
||||
// If subscription check is still loading, show loading state
|
||||
if (subscriptionLoading) {
|
||||
return (
|
||||
<Box
|
||||
display="flex"
|
||||
flexDirection="column"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
minHeight="100vh"
|
||||
gap={2}
|
||||
>
|
||||
<CircularProgress size={60} />
|
||||
<Typography variant="h6" color="textSecondary">
|
||||
Checking subscription...
|
||||
</Typography>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// Subscription check completed but returned null/undefined
|
||||
// This likely means no subscription - redirect to pricing
|
||||
console.log('InitialRouteHandler: No subscription data after check → Pricing page');
|
||||
return <Navigate to="/pricing" replace />;
|
||||
}
|
||||
|
||||
// 3. Check subscription status first
|
||||
const isNewUser = !subscription || subscription.plan === 'none';
|
||||
|
||||
// No active subscription → Show modal (SubscriptionContext handles this)
|
||||
// Don't redirect immediately - let the modal show first
|
||||
// User can click "Renew Subscription" button in modal to go to pricing
|
||||
// Or click "Maybe Later" to dismiss (but they still can't use features)
|
||||
if (isNewUser || !subscription.active) {
|
||||
console.log('InitialRouteHandler: No active subscription - modal will be shown by SubscriptionContext');
|
||||
// Note: SubscriptionContext will show the modal automatically when subscription is inactive
|
||||
// We still redirect to pricing for new users, but allow existing users with expired subscriptions
|
||||
// to see the modal first. The modal has a "Renew Subscription" button that navigates to pricing.
|
||||
// For new users (no subscription at all), redirect to pricing immediately
|
||||
if (isNewUser) {
|
||||
console.log('InitialRouteHandler: New user (no subscription) → Pricing page');
|
||||
return <Navigate to="/pricing" replace />;
|
||||
}
|
||||
// For existing users with inactive subscription, show modal but don't redirect immediately
|
||||
// The modal will be shown by SubscriptionContext, and user can click "Renew Subscription"
|
||||
// Allow access to dashboard (modal will be shown and block functionality)
|
||||
console.log('InitialRouteHandler: Inactive subscription - allowing access to show modal');
|
||||
// Continue to onboarding/dashboard flow - modal will be shown by SubscriptionContext
|
||||
}
|
||||
|
||||
// 4. Has active subscription, check onboarding status
|
||||
if (!isOnboardingComplete) {
|
||||
console.log('InitialRouteHandler: Subscription active but onboarding incomplete → Onboarding');
|
||||
return <Navigate to="/onboarding" replace />;
|
||||
}
|
||||
|
||||
// 5. Has subscription AND completed onboarding → Dashboard
|
||||
console.log('InitialRouteHandler: All set (subscription + onboarding) → Dashboard');
|
||||
return <Navigate to="/dashboard" replace />;
|
||||
};
|
||||
|
||||
// Root route that chooses Landing (signed out) or InitialRouteHandler (signed in)
|
||||
const RootRoute: React.FC = () => {
|
||||
const { isSignedIn } = useAuth();
|
||||
@@ -424,64 +77,6 @@ const RootRoute: React.FC = () => {
|
||||
return <Landing />;
|
||||
};
|
||||
|
||||
// Installs Clerk auth token getter into axios clients and stores user_id
|
||||
// Must render under ClerkProvider
|
||||
const TokenInstaller: React.FC = () => {
|
||||
const { getToken, userId, isSignedIn, signOut } = useAuth();
|
||||
|
||||
// Store user_id in localStorage when user signs in
|
||||
useEffect(() => {
|
||||
if (isSignedIn && userId) {
|
||||
console.log('TokenInstaller: Storing user_id in localStorage:', userId);
|
||||
localStorage.setItem('user_id', userId);
|
||||
|
||||
// Trigger event to notify SubscriptionContext that user is authenticated
|
||||
window.dispatchEvent(new CustomEvent('user-authenticated', { detail: { userId } }));
|
||||
} else if (!isSignedIn) {
|
||||
// Clear user_id when signed out
|
||||
console.log('TokenInstaller: Clearing user_id from localStorage');
|
||||
localStorage.removeItem('user_id');
|
||||
}
|
||||
}, [isSignedIn, userId]);
|
||||
|
||||
// Install token getter for API calls
|
||||
useEffect(() => {
|
||||
const tokenGetter = async () => {
|
||||
try {
|
||||
const template = process.env.REACT_APP_CLERK_JWT_TEMPLATE;
|
||||
// If a template is provided and it's not a placeholder, request a template-specific JWT
|
||||
if (template && template !== 'your_jwt_template_name_here') {
|
||||
// @ts-ignore Clerk types allow options object
|
||||
return await getToken({ template });
|
||||
}
|
||||
return await getToken();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
// Set token getter for main API client
|
||||
setAuthTokenGetter(tokenGetter);
|
||||
|
||||
// Set token getter for billing API client (same function)
|
||||
setBillingAuthTokenGetter(tokenGetter);
|
||||
|
||||
// Set token getter for media blob URL fetcher (for authenticated image/video requests)
|
||||
setMediaAuthTokenGetter(tokenGetter);
|
||||
}, [getToken]);
|
||||
|
||||
// Install Clerk signOut function for handling expired tokens
|
||||
useEffect(() => {
|
||||
if (signOut) {
|
||||
setClerkSignOut(async () => {
|
||||
await signOut();
|
||||
});
|
||||
}
|
||||
}, [signOut]);
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const App: React.FC = () => {
|
||||
// React Hooks MUST be at the top before any conditionals
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
@@ -14,6 +14,7 @@ export interface AssetResponse {
|
||||
export interface VoiceCloneResponse {
|
||||
success: boolean;
|
||||
custom_voice_id?: string;
|
||||
voice_name?: string;
|
||||
preview_audio_url?: string;
|
||||
asset_id?: number;
|
||||
message?: string;
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { Stack, Box, Typography, Divider, Chip, Paper, alpha, CircularProgress, TextField, IconButton, Select, MenuItem, FormControl, InputLabel, Switch, FormControlLabel } from "@mui/material";
|
||||
import { Psychology as PsychologyIcon, Insights as InsightsIcon, Search as SearchIcon, Person as PersonIcon, AutoAwesome as AutoAwesomeIcon, Edit as EditIcon, Save as SaveIcon, Close as CloseIcon, Add as AddIcon, EditNote as EditNoteIcon } from "@mui/icons-material";
|
||||
import { Stack, Box, Typography, Divider, Chip, Paper, alpha, CircularProgress, Button, Checkbox } from "@mui/material";
|
||||
import { Psychology as PsychologyIcon, Insights as InsightsIcon, Search as SearchIcon, Person as PersonIcon, AutoAwesome as AutoAwesomeIcon, Edit as EditIcon, Save as SaveIcon, Close as CloseIcon, Add as AddIcon, EditNote as EditNoteIcon, Input as InputIcon, Groups as GroupsIcon, ListAlt as ListAltIcon, RecordVoiceOver as VoiceIcon, Lightbulb as TipsIcon, Quiz as TalkIcon } from "@mui/icons-material";
|
||||
import { PodcastAnalysis, PodcastEstimate } from "./types";
|
||||
import { GlassyCard, glassyCardSx, SecondaryButton } from "./ui";
|
||||
import { Refresh as RefreshIcon } from "@mui/icons-material";
|
||||
import { aiApiClient } from "../../api/client";
|
||||
import { InputsTab, AudienceTab, OutlineTab, TitlesTab, HookTab, TakeawaysTab, GuestTab, CTATab } from "./AnalysisPanel/tabs";
|
||||
|
||||
interface AnalysisPanelProps {
|
||||
analysis: PodcastAnalysis | null;
|
||||
@@ -16,6 +17,19 @@ interface AnalysisPanelProps {
|
||||
avatarPrompt?: string | null;
|
||||
onRegenerate?: () => void;
|
||||
onUpdateAnalysis?: (updatedAnalysis: PodcastAnalysis) => void;
|
||||
onRunResearch?: () => void;
|
||||
isResearchRunning?: boolean;
|
||||
selectedQueries?: Set<string>;
|
||||
onToggleQuery?: (queryId: string) => void;
|
||||
queries?: { id: string; query: string; rationale: string }[];
|
||||
}
|
||||
|
||||
type TabId = 'inputs' | 'audience' | 'content' | 'outline' | 'titles' | 'hook' | 'takeaways' | 'cta' | 'guest';
|
||||
|
||||
interface TabConfig {
|
||||
id: TabId;
|
||||
label: string;
|
||||
icon: React.ReactNode;
|
||||
}
|
||||
|
||||
const inputStyles = {
|
||||
@@ -54,8 +68,14 @@ export const AnalysisPanel: React.FC<AnalysisPanelProps> = ({
|
||||
avatarUrl,
|
||||
avatarPrompt,
|
||||
onRegenerate,
|
||||
onUpdateAnalysis
|
||||
onUpdateAnalysis,
|
||||
onRunResearch,
|
||||
isResearchRunning,
|
||||
selectedQueries,
|
||||
onToggleQuery,
|
||||
queries
|
||||
}) => {
|
||||
const [activeTab, setActiveTab] = useState<TabId>('inputs');
|
||||
const [avatarBlobUrl, setAvatarBlobUrl] = useState<string | null>(null);
|
||||
const [avatarLoading, setAvatarLoading] = useState(false);
|
||||
const [avatarError, setAvatarError] = useState(false);
|
||||
@@ -64,6 +84,38 @@ export const AnalysisPanel: React.FC<AnalysisPanelProps> = ({
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [editedAnalysis, setEditedAnalysis] = useState<PodcastAnalysis | null>(null);
|
||||
|
||||
const tabs: TabConfig[] = [
|
||||
{ id: 'inputs', label: 'Your Inputs', icon: <InputIcon /> },
|
||||
{ id: 'audience', label: 'Audience', icon: <GroupsIcon /> },
|
||||
{ id: 'content', label: 'Content', icon: <ListAltIcon /> },
|
||||
{ id: 'outline', label: 'Outline', icon: <ListAltIcon /> },
|
||||
{ id: 'titles', label: 'Titles', icon: <EditNoteIcon /> },
|
||||
{ id: 'hook', label: 'Hook', icon: <AutoAwesomeIcon /> },
|
||||
{ id: 'takeaways', label: 'Takeaways', icon: <TipsIcon /> },
|
||||
{ id: 'guest', label: 'Guest', icon: <PersonIcon /> },
|
||||
{ id: 'cta', label: 'CTA', icon: <VoiceIcon /> },
|
||||
];
|
||||
|
||||
const tabButtonStyles = (isActive: boolean) => ({
|
||||
background: isActive
|
||||
? "linear-gradient(135deg, #667eea 0%, #764ba2 100%)"
|
||||
: "transparent",
|
||||
color: isActive ? "#fff" : "#64748b",
|
||||
border: isActive ? "none" : "1px solid rgba(0,0,0,0.1)",
|
||||
borderRadius: 2,
|
||||
px: 2,
|
||||
py: 1,
|
||||
fontSize: "0.75rem",
|
||||
fontWeight: 600,
|
||||
textTransform: "none" as const,
|
||||
transition: "all 0.2s ease",
|
||||
"&:hover": {
|
||||
background: isActive
|
||||
? "linear-gradient(135deg, #764ba2 0%, #667eea 100%)"
|
||||
: "rgba(102,126,234,0.08)",
|
||||
},
|
||||
});
|
||||
|
||||
// Sync editedAnalysis with analysis initially
|
||||
useEffect(() => {
|
||||
if (analysis && !editedAnalysis) {
|
||||
@@ -325,622 +377,183 @@ export const AnalysisPanel: React.FC<AnalysisPanelProps> = ({
|
||||
|
||||
<Divider sx={{ borderColor: "rgba(0,0,0,0.06)" }} />
|
||||
|
||||
{/* Inputs Section */}
|
||||
{(idea || duration || speakers || avatarUrl || avatarPrompt) && (
|
||||
<>
|
||||
<Box>
|
||||
<Typography
|
||||
variant="subtitle1"
|
||||
{/* AI Futuristic Tab Navigation */}
|
||||
<Stack direction="row" flexWrap="wrap" gap={1} sx={{ mb: 2 }}>
|
||||
{tabs.map((tab) => (
|
||||
<Button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
startIcon={tab.icon}
|
||||
sx={tabButtonStyles(activeTab === tab.id)}
|
||||
>
|
||||
{tab.label}
|
||||
</Button>
|
||||
))}
|
||||
</Stack>
|
||||
|
||||
{/* Tab Content */}
|
||||
<Box sx={{ minHeight: 300 }}>
|
||||
{activeTab === 'inputs' && (
|
||||
<InputsTab
|
||||
idea={idea}
|
||||
duration={duration}
|
||||
speakers={speakers}
|
||||
avatarUrl={avatarUrl}
|
||||
avatarPrompt={avatarPrompt}
|
||||
avatarBlobUrl={avatarBlobUrl}
|
||||
avatarLoading={avatarLoading}
|
||||
avatarError={avatarError}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeTab === 'audience' && (
|
||||
<AudienceTab
|
||||
analysis={currentAnalysis}
|
||||
isEditing={isEditing}
|
||||
editedAnalysis={editedAnalysis}
|
||||
setEditedAnalysis={setEditedAnalysis}
|
||||
handleRemoveKeyword={handleRemoveKeyword}
|
||||
handleAddKeyword={handleAddKeyword}
|
||||
handleRemoveTitle={handleRemoveTitle}
|
||||
handleAddTitle={handleAddTitle}
|
||||
handleUpdateOutline={handleUpdateOutline}
|
||||
updateExaConfig={updateExaConfig}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeTab === 'outline' && (
|
||||
<OutlineTab
|
||||
analysis={currentAnalysis}
|
||||
isEditing={isEditing}
|
||||
onUpdateOutline={handleUpdateOutline}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeTab === 'titles' && (
|
||||
<TitlesTab
|
||||
analysis={currentAnalysis}
|
||||
isEditing={isEditing}
|
||||
handleRemoveTitle={handleRemoveTitle}
|
||||
handleAddTitle={handleAddTitle}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeTab === 'hook' && (
|
||||
<HookTab analysis={currentAnalysis} />
|
||||
)}
|
||||
|
||||
{activeTab === 'takeaways' && (
|
||||
<TakeawaysTab analysis={currentAnalysis} />
|
||||
)}
|
||||
|
||||
{activeTab === 'guest' && (
|
||||
<GuestTab analysis={currentAnalysis} />
|
||||
)}
|
||||
|
||||
{activeTab === 'cta' && (
|
||||
<CTATab analysis={currentAnalysis} />
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Research Section - Separate from tabs */}
|
||||
<Divider sx={{ borderColor: "rgba(0,0,0,0.06)", my: 2 }} />
|
||||
|
||||
<Box>
|
||||
<Stack direction="row" justifyContent="space-between" alignItems="center" sx={{ mb: 2 }}>
|
||||
<Typography variant="subtitle1" sx={{ color: "#0f172a", fontWeight: 700, display: "flex", alignItems: "center", gap: 1 }}>
|
||||
<SearchIcon sx={{ color: "#4f46e5" }} />
|
||||
Research Queries
|
||||
{selectedQueries && selectedQueries.size > 0 && (
|
||||
<Chip
|
||||
label={`${selectedQueries.size} selected`}
|
||||
size="small"
|
||||
sx={{ ml: 1, height: 20, fontSize: "0.65rem", bgcolor: "#4f46e5", color: "#fff" }}
|
||||
/>
|
||||
)}
|
||||
</Typography>
|
||||
{onRunResearch && (
|
||||
<Button
|
||||
variant="contained"
|
||||
size="small"
|
||||
onClick={onRunResearch}
|
||||
disabled={isResearchRunning || !selectedQueries || selectedQueries.size === 0}
|
||||
startIcon={isResearchRunning ? <CircularProgress size={16} color="inherit" /> : <SearchIcon />}
|
||||
sx={{
|
||||
color: "#0f172a",
|
||||
fontWeight: 700,
|
||||
mb: 1.5,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 0.5,
|
||||
background: "linear-gradient(135deg, #667eea 0%, #764ba2 100%)",
|
||||
color: "#fff",
|
||||
fontWeight: 600,
|
||||
fontSize: "0.75rem",
|
||||
px: 2,
|
||||
py: 0.75,
|
||||
borderRadius: 2,
|
||||
textTransform: "none",
|
||||
"&:hover": {
|
||||
background: "linear-gradient(135deg, #764ba2 0%, #667eea 100%)",
|
||||
},
|
||||
"&:disabled": {
|
||||
background: "#94a3b8",
|
||||
}
|
||||
}}
|
||||
>
|
||||
Your Inputs
|
||||
</Typography>
|
||||
<Box
|
||||
sx={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: { xs: "1fr", md: avatarUrl ? "1fr 1fr" : "1fr" },
|
||||
gap: 3,
|
||||
alignItems: "flex-start",
|
||||
}}
|
||||
>
|
||||
{/* Left Column: Text Inputs */}
|
||||
<Stack spacing={1.5}>
|
||||
{idea && (
|
||||
<Box>
|
||||
<Typography variant="caption" sx={{ color: "#64748b", fontWeight: 600, display: "block", mb: 0.5 }}>
|
||||
Podcast Idea
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: "#0f172a", wordBreak: "break-word" }}>
|
||||
{idea}
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
<Stack direction="row" spacing={2} flexWrap="wrap">
|
||||
{duration !== undefined && (
|
||||
<Box>
|
||||
<Typography variant="caption" sx={{ color: "#64748b", fontWeight: 600, display: "block", mb: 0.5 }}>
|
||||
Duration
|
||||
</Typography>
|
||||
<Chip
|
||||
label={`${duration} minutes`}
|
||||
size="small"
|
||||
sx={{ background: "#f1f5f9", color: "#0f172a", border: "1px solid rgba(0,0,0,0.08)" }}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
{speakers !== undefined && (
|
||||
<Box>
|
||||
<Typography variant="caption" sx={{ color: "#64748b", fontWeight: 600, display: "block", mb: 0.5 }}>
|
||||
Speakers
|
||||
</Typography>
|
||||
<Chip
|
||||
label={`${speakers} ${speakers === 1 ? "speaker" : "speakers"}`}
|
||||
size="small"
|
||||
sx={{ background: "#f1f5f9", color: "#0f172a", border: "1px solid rgba(0,0,0,0.08)" }}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
{/* AI Prompt Used for Avatar Generation */}
|
||||
{avatarUrl && (
|
||||
<Box>
|
||||
<Typography
|
||||
variant="caption"
|
||||
{isResearchRunning ? "Running..." : "Run Research"}
|
||||
</Button>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
{!analysis?.research_queries || analysis.research_queries.length === 0 ? (
|
||||
<Typography variant="body2" sx={{ color: "#64748b", fontStyle: "italic" }}>
|
||||
No research queries yet. Click "Regenerate Analysis" to generate research queries based on your podcast idea.
|
||||
</Typography>
|
||||
) : (
|
||||
<Stack spacing={1.5}>
|
||||
{(queries || analysis.research_queries?.map((rq, idx) => ({ id: `query-${idx}`, ...rq }))).map((rq: { id: string; query: string; rationale: string }, idx: number) => {
|
||||
const queryId = rq.id;
|
||||
const isSelected = selectedQueries?.has(queryId) || false;
|
||||
return (
|
||||
<Paper
|
||||
key={idx}
|
||||
elevation={0}
|
||||
sx={{
|
||||
p: 2,
|
||||
bgcolor: isSelected ? "#f0f9ff" : "#f8fafc",
|
||||
border: `1px solid ${isSelected ? 'rgba(79,70,229,0.4)' : 'rgba(0,0,0,0.08)'}`,
|
||||
borderRadius: 2,
|
||||
transition: "all 0.2s ease",
|
||||
cursor: onToggleQuery ? "pointer" : "default",
|
||||
"&:hover": onToggleQuery ? {
|
||||
borderColor: "rgba(79,70,229,0.3)",
|
||||
bgcolor: "#f8fafc"
|
||||
} : {}
|
||||
}}
|
||||
onClick={() => onToggleQuery?.(queryId)}
|
||||
>
|
||||
<Stack direction="row" alignItems="flex-start" gap={1.5}>
|
||||
<Checkbox
|
||||
checked={isSelected}
|
||||
onChange={() => onToggleQuery?.(queryId)}
|
||||
sx={{
|
||||
color: "#64748b",
|
||||
fontWeight: 600,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 0.5,
|
||||
mb: 0.75,
|
||||
"&.Mui-checked": {
|
||||
color: "#4f46e5",
|
||||
},
|
||||
padding: 0.5,
|
||||
}}
|
||||
>
|
||||
<AutoAwesomeIcon sx={{ fontSize: 14 }} />
|
||||
AI Generation Prompt
|
||||
</Typography>
|
||||
{avatarPrompt ? (
|
||||
<Paper
|
||||
sx={{
|
||||
p: 1.5,
|
||||
background: "#f8fafc",
|
||||
border: "1px solid rgba(0,0,0,0.08)",
|
||||
borderRadius: 1.5,
|
||||
maxHeight: 200,
|
||||
overflow: "auto",
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{
|
||||
color: "#475569",
|
||||
fontFamily: "monospace",
|
||||
fontSize: "0.75rem",
|
||||
lineHeight: 1.6,
|
||||
whiteSpace: "pre-wrap",
|
||||
wordBreak: "break-word",
|
||||
display: "block",
|
||||
}}
|
||||
>
|
||||
{avatarPrompt}
|
||||
</Typography>
|
||||
</Paper>
|
||||
) : (
|
||||
<Paper
|
||||
sx={{
|
||||
p: 1.5,
|
||||
background: "#f1f5f9",
|
||||
border: "1px solid rgba(0,0,0,0.08)",
|
||||
borderRadius: 1.5,
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{
|
||||
color: "#64748b",
|
||||
fontStyle: "italic",
|
||||
fontSize: "0.75rem",
|
||||
}}
|
||||
>
|
||||
Prompt not available (avatar was uploaded or generated before this feature was added)
|
||||
</Typography>
|
||||
</Paper>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
{/* Right Column: Presenter Avatar */}
|
||||
{avatarUrl && (
|
||||
<Box>
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{
|
||||
color: "#64748b",
|
||||
fontWeight: 600,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 0.5,
|
||||
mb: 1,
|
||||
}}
|
||||
>
|
||||
<PersonIcon sx={{ fontSize: 16 }} />
|
||||
Presenter Avatar
|
||||
</Typography>
|
||||
<Box
|
||||
sx={{
|
||||
width: "100%",
|
||||
maxWidth: { xs: "100%", md: 300 },
|
||||
borderRadius: 2,
|
||||
overflow: "hidden",
|
||||
border: "1px solid rgba(102,126,234,0.2)",
|
||||
background: alpha("#667eea", 0.05),
|
||||
position: "relative",
|
||||
aspectRatio: "1",
|
||||
boxShadow: "0 4px 12px rgba(0,0,0,0.08)",
|
||||
}}
|
||||
>
|
||||
{avatarLoading ? (
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
height: "100%",
|
||||
background: "#f8fafc",
|
||||
}}
|
||||
>
|
||||
<CircularProgress size={40} />
|
||||
</Box>
|
||||
) : avatarError ? (
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
height: "100%",
|
||||
background: "#fef2f2",
|
||||
color: "#dc2626",
|
||||
p: 2,
|
||||
}}
|
||||
>
|
||||
<Typography variant="caption" sx={{ textAlign: "center" }}>
|
||||
Failed to load avatar
|
||||
</Typography>
|
||||
</Box>
|
||||
) : avatarBlobUrl ? (
|
||||
<Box
|
||||
component="img"
|
||||
src={avatarBlobUrl}
|
||||
alt="Podcast Presenter"
|
||||
sx={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
objectFit: "cover",
|
||||
display: "block",
|
||||
}}
|
||||
onError={(e) => {
|
||||
console.error('[AnalysisPanel] Avatar image failed to load:', {
|
||||
src: e.currentTarget.src,
|
||||
avatarUrl,
|
||||
avatarBlobUrl,
|
||||
});
|
||||
setAvatarError(true);
|
||||
}}
|
||||
onLoad={() => {
|
||||
console.log('[AnalysisPanel] Avatar image loaded successfully');
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
<Divider sx={{ borderColor: "rgba(0,0,0,0.06)" }} />
|
||||
</>
|
||||
)}
|
||||
|
||||
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "1fr 1fr" }, gap: 3 }}>
|
||||
<Stack spacing={3}>
|
||||
<Box>
|
||||
<Typography variant="subtitle2" sx={{ mb: 1, display: "flex", alignItems: "center", gap: 0.5 }}>
|
||||
<InsightsIcon fontSize="small" sx={{ color: "#4f46e5" }} />
|
||||
Target Audience
|
||||
</Typography>
|
||||
{isEditing ? (
|
||||
<TextField
|
||||
fullWidth
|
||||
multiline
|
||||
rows={2}
|
||||
size="small"
|
||||
value={currentAnalysis.audience}
|
||||
onChange={(e) => setEditedAnalysis({ ...currentAnalysis, audience: e.target.value })}
|
||||
placeholder="Describe your target audience..."
|
||||
sx={inputStyles}
|
||||
/>
|
||||
) : (
|
||||
<Typography variant="body2" sx={{ color: "#0f172a" }}>
|
||||
{currentAnalysis.audience}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Box>
|
||||
<Typography variant="subtitle2" sx={{ mb: 1, color: "#0f172a" }}>Content Type</Typography>
|
||||
{isEditing ? (
|
||||
<TextField
|
||||
fullWidth
|
||||
size="small"
|
||||
value={currentAnalysis.contentType}
|
||||
onChange={(e) => setEditedAnalysis({ ...currentAnalysis, contentType: e.target.value })}
|
||||
placeholder="e.g. Interview, Narrative, Solo..."
|
||||
sx={inputStyles}
|
||||
/>
|
||||
) : (
|
||||
<Chip label={currentAnalysis.contentType} size="small" sx={{ background: "#eef2ff", color: "#0f172a", border: "1px solid rgba(0,0,0,0.06)" }} />
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Box>
|
||||
<Typography variant="subtitle2" sx={{ mb: 1, color: "#0f172a" }}>Top Keywords</Typography>
|
||||
<Stack direction="row" spacing={0.5} flexWrap="wrap" useFlexGap sx={{ mb: isEditing ? 1.5 : 0 }}>
|
||||
{currentAnalysis.topKeywords.map((k) => (
|
||||
<Chip
|
||||
key={k}
|
||||
label={k}
|
||||
size="small"
|
||||
variant="outlined"
|
||||
onDelete={isEditing ? () => handleRemoveKeyword(k) : undefined}
|
||||
sx={{
|
||||
borderColor: "rgba(0,0,0,0.1)",
|
||||
color: "#0f172a",
|
||||
background: "#f8fafc",
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
{isEditing && (
|
||||
<TextField
|
||||
fullWidth
|
||||
size="small"
|
||||
placeholder="Add keyword and press Enter..."
|
||||
sx={inputStyles}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
handleAddKeyword((e.target as HTMLInputElement).value);
|
||||
(e.target as HTMLInputElement).value = '';
|
||||
}
|
||||
}}
|
||||
InputProps={{
|
||||
endAdornment: (
|
||||
<IconButton size="small" onClick={(e) => {
|
||||
const input = (e.currentTarget.parentElement?.parentElement?.querySelector('input') as HTMLInputElement);
|
||||
handleAddKeyword(input.value);
|
||||
input.value = '';
|
||||
}}>
|
||||
<AddIcon fontSize="small" sx={{ color: '#4f46e5' }} />
|
||||
</IconButton>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Box>
|
||||
<Typography variant="subtitle2" sx={{ mb: 1, color: "#111827", fontWeight: 700, display: "flex", alignItems: "center", gap: 1 }}>
|
||||
<EditNoteIcon fontSize="small" sx={{ color: "#4f46e5" }} />
|
||||
Suggested Episode Outlines
|
||||
</Typography>
|
||||
<Stack spacing={2}>
|
||||
{currentAnalysis.suggestedOutlines.map((o) => (
|
||||
<Paper
|
||||
key={o.id}
|
||||
elevation={0}
|
||||
sx={{
|
||||
p: 2,
|
||||
background: isEditing ? "#ffffff" : "#f8fafc",
|
||||
border: "1px solid",
|
||||
borderColor: isEditing ? "#e2e8f0" : "rgba(0,0,0,0.04)",
|
||||
borderRadius: 2,
|
||||
wordBreak: "break-word",
|
||||
position: 'relative',
|
||||
transition: "all 0.2s ease",
|
||||
"&:hover": {
|
||||
borderColor: "#4f46e5",
|
||||
boxShadow: "0 4px 12px rgba(79, 70, 229, 0.05)"
|
||||
}
|
||||
}}
|
||||
>
|
||||
{isEditing ? (
|
||||
<Stack spacing={2}>
|
||||
<TextField
|
||||
fullWidth
|
||||
size="small"
|
||||
label="Outline Title"
|
||||
value={o.title}
|
||||
onChange={(e) => handleUpdateOutline(o.id, 'title', e.target.value)}
|
||||
sx={inputStyles}
|
||||
/>
|
||||
<TextField
|
||||
fullWidth
|
||||
multiline
|
||||
size="small"
|
||||
label="Segments"
|
||||
value={o.segments.join(' • ')}
|
||||
onChange={(e) => handleUpdateOutline(o.id, 'segments', e.target.value.split(/•|,/).map(s => s.trim()).filter(Boolean))}
|
||||
helperText="Use • or comma to separate segments"
|
||||
sx={inputStyles}
|
||||
/>
|
||||
</Stack>
|
||||
) : (
|
||||
<>
|
||||
<Typography variant="body1" sx={{ fontWeight: 800, mb: 1, color: "#111827" }}>
|
||||
{o.title}
|
||||
/>
|
||||
<Chip label={idx + 1} size="small" sx={{ minWidth: 24, bgcolor: "#4f46e5", color: "#fff" }} />
|
||||
<Box>
|
||||
<Typography variant="body2" sx={{ color: "#0f172a", fontWeight: 600, mb: 0.5 }}>
|
||||
{rq.query}
|
||||
</Typography>
|
||||
<Stack spacing={1}>
|
||||
{o.segments.map((segment, idx) => (
|
||||
<Box key={idx} sx={{ display: "flex", alignItems: "flex-start", gap: 1 }}>
|
||||
<Box sx={{ mt: 1, width: 6, height: 6, borderRadius: "50%", bgcolor: "#4f46e5", flexShrink: 0 }} />
|
||||
<Typography variant="body2" sx={{ color: "#4b5563", lineHeight: 1.5 }}>
|
||||
{segment}
|
||||
</Typography>
|
||||
</Box>
|
||||
))}
|
||||
</Stack>
|
||||
</>
|
||||
)}
|
||||
<Typography variant="caption" sx={{ color: "#64748b" }}>
|
||||
Rationale: {rq.rationale}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Stack>
|
||||
</Paper>
|
||||
))}
|
||||
</Stack>
|
||||
</Box>
|
||||
</Stack>
|
||||
|
||||
<Stack spacing={3}>
|
||||
{currentAnalysis.exaSuggestedConfig && (
|
||||
<Box>
|
||||
<Typography variant="subtitle2" sx={{ mb: 1, color: "#0f172a", display: "flex", alignItems: "center", gap: 0.5 }}>
|
||||
<SearchIcon fontSize="small" sx={{ color: "#4f46e5" }} />
|
||||
Exa Research Suggestions
|
||||
</Typography>
|
||||
|
||||
{isEditing ? (
|
||||
<Stack spacing={2} sx={{ p: 2, border: '1px solid #e2e8f0', borderRadius: 2, bgcolor: '#ffffff' }}>
|
||||
<Stack direction="row" spacing={2}>
|
||||
<FormControl fullWidth size="small" sx={inputStyles}>
|
||||
<InputLabel>Search Type</InputLabel>
|
||||
<Select
|
||||
value={currentAnalysis.exaSuggestedConfig.exa_search_type || 'auto'}
|
||||
label="Search Type"
|
||||
onChange={(e) => updateExaConfig('exa_search_type', e.target.value)}
|
||||
>
|
||||
<MenuItem value="auto">Auto</MenuItem>
|
||||
<MenuItem value="neural">Neural</MenuItem>
|
||||
<MenuItem value="keyword">Keyword</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<FormControl fullWidth size="small" sx={inputStyles}>
|
||||
<InputLabel>Category</InputLabel>
|
||||
<Select
|
||||
value={currentAnalysis.exaSuggestedConfig.exa_category || 'news'}
|
||||
label="Category"
|
||||
onChange={(e) => updateExaConfig('exa_category', e.target.value)}
|
||||
>
|
||||
<MenuItem value="news">News</MenuItem>
|
||||
<MenuItem value="research paper">Research Paper</MenuItem>
|
||||
<MenuItem value="company">Company</MenuItem>
|
||||
<MenuItem value="pdf">PDF</MenuItem>
|
||||
<MenuItem value="tweet">Tweet</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Stack>
|
||||
|
||||
<Stack direction="row" spacing={2} alignItems="center">
|
||||
<FormControl fullWidth size="small" sx={inputStyles}>
|
||||
<InputLabel>Date Range</InputLabel>
|
||||
<Select
|
||||
value={currentAnalysis.exaSuggestedConfig.date_range || 'all_time'}
|
||||
label="Date Range"
|
||||
onChange={(e) => updateExaConfig('date_range', e.target.value)}
|
||||
>
|
||||
<MenuItem value="all_time">All Time</MenuItem>
|
||||
<MenuItem value="last_month">Last Month</MenuItem>
|
||||
<MenuItem value="last_year">Last Year</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<TextField
|
||||
type="number"
|
||||
label="Max Sources"
|
||||
size="small"
|
||||
value={currentAnalysis.exaSuggestedConfig.max_sources || 10}
|
||||
onChange={(e) => updateExaConfig('max_sources', parseInt(e.target.value))}
|
||||
sx={{ ...inputStyles, width: 120 }}
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
size="small"
|
||||
checked={currentAnalysis.exaSuggestedConfig.include_statistics || false}
|
||||
onChange={(e) => updateExaConfig('include_statistics', e.target.checked)}
|
||||
sx={{ '& .MuiSwitch-track': { bgcolor: '#4f46e5' } }}
|
||||
/>
|
||||
}
|
||||
label={<Typography variant="body2" sx={{ color: '#111827', fontWeight: 500 }}>Include Statistics</Typography>}
|
||||
/>
|
||||
|
||||
<Stack spacing={1}>
|
||||
<TextField
|
||||
fullWidth
|
||||
size="small"
|
||||
label="Prefer Domains"
|
||||
placeholder="e.g. techcrunch.com, wired.com (press Enter)"
|
||||
sx={inputStyles}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
const val = (e.target as HTMLInputElement).value.trim();
|
||||
if (val) {
|
||||
const domains = currentAnalysis.exaSuggestedConfig?.exa_include_domains || [];
|
||||
updateExaConfig('exa_include_domains', [...domains, val]);
|
||||
(e.target as HTMLInputElement).value = '';
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Stack direction="row" spacing={0.5} flexWrap="wrap" useFlexGap>
|
||||
{(currentAnalysis.exaSuggestedConfig.exa_include_domains || []).map(d => (
|
||||
<Chip key={d} label={d} size="small" onDelete={() => {
|
||||
const domains = currentAnalysis.exaSuggestedConfig?.exa_include_domains?.filter(item => item !== d);
|
||||
updateExaConfig('exa_include_domains', domains);
|
||||
}} sx={{ bgcolor: '#f3f4f6', color: '#111827' }} />
|
||||
))}
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Stack>
|
||||
) : (
|
||||
<>
|
||||
<Stack direction="row" spacing={0.5} flexWrap="wrap" useFlexGap sx={{ mb: 1 }}>
|
||||
{currentAnalysis.exaSuggestedConfig.exa_search_type && (
|
||||
<Chip
|
||||
label={`Search: ${currentAnalysis.exaSuggestedConfig.exa_search_type}`}
|
||||
size="small"
|
||||
sx={{ background: "#eef2ff", color: "#0f172a", border: "1px solid rgba(0,0,0,0.06)" }}
|
||||
/>
|
||||
)}
|
||||
{currentAnalysis.exaSuggestedConfig.exa_category && (
|
||||
<Chip
|
||||
label={`Category: ${currentAnalysis.exaSuggestedConfig.exa_category}`}
|
||||
size="small"
|
||||
sx={{ background: "#eef2ff", color: "#0f172a", border: "1px solid rgba(0,0,0,0.06)" }}
|
||||
/>
|
||||
)}
|
||||
{currentAnalysis.exaSuggestedConfig.date_range && (
|
||||
<Chip
|
||||
label={`Date: ${currentAnalysis.exaSuggestedConfig.date_range}`}
|
||||
size="small"
|
||||
sx={{ background: "#eef2ff", color: "#0f172a", border: "1px solid rgba(0,0,0,0.06)" }}
|
||||
/>
|
||||
)}
|
||||
{typeof currentAnalysis.exaSuggestedConfig.include_statistics === "boolean" && (
|
||||
<Chip
|
||||
label={currentAnalysis.exaSuggestedConfig.include_statistics ? "Include stats" : "No stats needed"}
|
||||
size="small"
|
||||
sx={{ background: "#eef2ff", color: "#0f172a", border: "1px solid rgba(0,0,0,0.06)" }}
|
||||
/>
|
||||
)}
|
||||
{currentAnalysis.exaSuggestedConfig.max_sources && (
|
||||
<Chip
|
||||
label={`Max sources: ${currentAnalysis.exaSuggestedConfig.max_sources}`}
|
||||
size="small"
|
||||
sx={{ background: "#eef2ff", color: "#0f172a", border: "1px solid rgba(0,0,0,0.06)" }}
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
{(currentAnalysis.exaSuggestedConfig.exa_include_domains?.length || currentAnalysis.exaSuggestedConfig.exa_exclude_domains?.length) && (
|
||||
<Stack direction="row" spacing={2} flexWrap="wrap" useFlexGap>
|
||||
{currentAnalysis.exaSuggestedConfig.exa_include_domains?.length ? (
|
||||
<Box>
|
||||
<Typography variant="caption" sx={{ color: "#475569", fontWeight: 600, display: "block", mb: 0.5 }}>
|
||||
Prefer domains
|
||||
</Typography>
|
||||
<Stack direction="row" spacing={0.5} flexWrap="wrap" useFlexGap>
|
||||
{currentAnalysis.exaSuggestedConfig.exa_include_domains.map((d) => (
|
||||
<Chip key={d} label={d} size="small" sx={{ background: "#f8fafc", color: "#0f172a", border: "1px solid rgba(0,0,0,0.08)" }} />
|
||||
))}
|
||||
</Stack>
|
||||
</Box>
|
||||
) : null}
|
||||
|
||||
{currentAnalysis.exaSuggestedConfig.exa_exclude_domains?.length ? (
|
||||
<Box>
|
||||
<Typography variant="caption" sx={{ color: "#475569", fontWeight: 600, display: "block", mb: 0.5 }}>
|
||||
Avoid domains
|
||||
</Typography>
|
||||
<Stack direction="row" spacing={0.5} flexWrap="wrap" useFlexGap>
|
||||
{currentAnalysis.exaSuggestedConfig.exa_exclude_domains.map((d) => (
|
||||
<Chip key={d} label={d} size="small" sx={{ background: "#fff7ed", color: "#b45309", border: "1px solid rgba(180,83,9,0.25)" }} />
|
||||
))}
|
||||
</Stack>
|
||||
</Box>
|
||||
) : null}
|
||||
</Stack>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Box>
|
||||
<Typography variant="subtitle2" sx={{ mb: 1, color: "#0f172a" }}>Title Suggestions</Typography>
|
||||
<Stack direction="row" spacing={0.5} flexWrap="wrap" useFlexGap sx={{ mb: isEditing ? 1.5 : 0 }}>
|
||||
{currentAnalysis.titleSuggestions.map((t) => (
|
||||
<Chip
|
||||
key={t}
|
||||
label={t}
|
||||
size="small"
|
||||
onDelete={isEditing ? () => handleRemoveTitle(t) : undefined}
|
||||
sx={{
|
||||
cursor: isEditing ? "default" : "pointer",
|
||||
color: "#0f172a",
|
||||
background: "#f8fafc",
|
||||
maxWidth: "100%",
|
||||
whiteSpace: "normal",
|
||||
height: "auto",
|
||||
lineHeight: 1.3,
|
||||
"& .MuiChip-label": {
|
||||
whiteSpace: "normal",
|
||||
wordBreak: "break-word",
|
||||
textAlign: "left",
|
||||
paddingTop: 0.25,
|
||||
paddingBottom: 0.25,
|
||||
},
|
||||
"&:hover": isEditing ? {} : {
|
||||
background: alpha("#667eea", 0.15),
|
||||
border: "1px solid rgba(102,126,234,0.35)",
|
||||
},
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
{isEditing && (
|
||||
<TextField
|
||||
fullWidth
|
||||
size="small"
|
||||
placeholder="Add title suggestion..."
|
||||
sx={inputStyles}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
handleAddTitle((e.target as HTMLInputElement).value);
|
||||
(e.target as HTMLInputElement).value = '';
|
||||
}
|
||||
}}
|
||||
InputProps={{
|
||||
endAdornment: (
|
||||
<IconButton size="small" onClick={(e) => {
|
||||
const input = (e.currentTarget.parentElement?.parentElement?.querySelector('input') as HTMLInputElement);
|
||||
handleAddTitle(input.value);
|
||||
input.value = '';
|
||||
}}>
|
||||
<AddIcon fontSize="small" sx={{ color: '#4f46e5' }} />
|
||||
</IconButton>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
</Stack>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
)}
|
||||
</Box>
|
||||
</Stack>
|
||||
</GlassyCard>
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
import React from "react";
|
||||
import { Stack, Button, Typography, Box } from "@mui/material";
|
||||
import { Input as InputIcon, Groups as GroupsIcon, ListAlt as ListAltIcon, EditNote as EditNoteIcon, Search as SearchIcon, AutoAwesome as AutoAwesomeIcon, Lightbulb as TipsIcon, Quiz as TalkIcon, RecordVoiceOver as VoiceIcon } from "@mui/icons-material";
|
||||
|
||||
export type TabId = "inputs" | "audience" | "content" | "outline" | "titles" | "research" | "hook" | "takeaways" | "guest" | "cta";
|
||||
|
||||
interface TabConfig {
|
||||
id: TabId;
|
||||
label: string;
|
||||
icon: React.ReactNode;
|
||||
}
|
||||
|
||||
export const ANALYSIS_TABS: TabConfig[] = [
|
||||
{ id: "inputs", label: "Your Inputs", icon: <InputIcon /> },
|
||||
{ id: "audience", label: "Audience", icon: <GroupsIcon /> },
|
||||
{ id: "content", label: "Content", icon: <ListAltIcon /> },
|
||||
{ id: "outline", label: "Outline", icon: <ListAltIcon /> },
|
||||
{ id: "titles", label: "Titles", icon: <EditNoteIcon /> },
|
||||
{ id: "research", label: "Research", icon: <SearchIcon /> },
|
||||
{ id: "hook", label: "Hook", icon: <AutoAwesomeIcon /> },
|
||||
{ id: "takeaways", label: "Takeaways", icon: <TipsIcon /> },
|
||||
{ id: "guest", label: "Guest", icon: <TalkIcon /> },
|
||||
{ id: "cta", label: "CTA", icon: <VoiceIcon /> },
|
||||
];
|
||||
|
||||
const getTabButtonStyles = (isActive: boolean) => ({
|
||||
background: isActive
|
||||
? "linear-gradient(135deg, #667eea 0%, #764ba2 100%)"
|
||||
: "transparent",
|
||||
color: isActive ? "#fff" : "#64748b",
|
||||
border: isActive ? "none" : "1px solid rgba(0,0,0,0.1)",
|
||||
borderRadius: 2,
|
||||
px: 2,
|
||||
py: 1,
|
||||
fontSize: "0.75rem",
|
||||
fontWeight: 600,
|
||||
textTransform: "none" as const,
|
||||
transition: "all 0.2s ease",
|
||||
"&:hover": {
|
||||
background: isActive
|
||||
? "linear-gradient(135deg, #764ba2 0%, #667eea 100%)"
|
||||
: "rgba(102,126,234,0.08)",
|
||||
},
|
||||
});
|
||||
|
||||
interface AnalysisTabNavProps {
|
||||
activeTab: TabId;
|
||||
onTabChange: (tab: TabId) => void;
|
||||
}
|
||||
|
||||
export const AnalysisTabNav: React.FC<AnalysisTabNavProps> = ({ activeTab, onTabChange }) => {
|
||||
return (
|
||||
<Stack direction="row" flexWrap="wrap" gap={1}>
|
||||
{ANALYSIS_TABS.map((tab) => (
|
||||
<Button
|
||||
key={tab.id}
|
||||
onClick={() => onTabChange(tab.id)}
|
||||
startIcon={tab.icon}
|
||||
sx={getTabButtonStyles(activeTab === tab.id)}
|
||||
>
|
||||
{tab.label}
|
||||
</Button>
|
||||
))}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export const AnalysisTabContent: React.FC<{ children: React.ReactNode; title?: string; icon?: React.ReactNode }> = ({
|
||||
children,
|
||||
title,
|
||||
icon,
|
||||
}) => (
|
||||
<Box>
|
||||
{title && (
|
||||
<Typography
|
||||
variant="subtitle2"
|
||||
sx={{
|
||||
mb: 2,
|
||||
color: "#0f172a",
|
||||
fontWeight: 700,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 0.5,
|
||||
}}
|
||||
>
|
||||
{icon}
|
||||
{title}
|
||||
</Typography>
|
||||
)}
|
||||
{children}
|
||||
</Box>
|
||||
);
|
||||
@@ -0,0 +1,211 @@
|
||||
import React from "react";
|
||||
import { Stack, Box, Typography, Chip, TextField, IconButton, Paper, Divider } from "@mui/material";
|
||||
import { Groups as GroupsIcon, Insights as InsightsIcon, Search as SearchIcon, EditNote as EditNoteIcon, Add as AddIcon } from "@mui/icons-material";
|
||||
import { PodcastAnalysis } from "../../types";
|
||||
import { AnalysisTabContent } from "../AnalysisTabNav";
|
||||
|
||||
interface AudienceTabProps {
|
||||
analysis: PodcastAnalysis;
|
||||
isEditing?: boolean;
|
||||
editedAnalysis?: PodcastAnalysis | null;
|
||||
setEditedAnalysis?: (analysis: PodcastAnalysis) => void;
|
||||
handleRemoveKeyword?: (keyword: string) => void;
|
||||
handleAddKeyword?: (keyword: string) => void;
|
||||
handleRemoveTitle?: (title: string) => void;
|
||||
handleAddTitle?: (title: string) => void;
|
||||
handleUpdateOutline?: (id: string | number, field: 'title' | 'segments', value: any) => void;
|
||||
updateExaConfig?: (field: string, value: any) => void;
|
||||
}
|
||||
|
||||
const inputStyles = {
|
||||
'& .MuiInputBase-input': { color: '#111827 !important', fontWeight: 500 },
|
||||
'& .MuiInputLabel-root': { color: '#4b5563 !important' },
|
||||
'& .MuiOutlinedInput-root': {
|
||||
bgcolor: '#ffffff !important',
|
||||
'& fieldset': { borderColor: '#d1d5db !important' },
|
||||
'&:hover fieldset': { borderColor: '#4f46e5 !important' },
|
||||
'&.Mui-focused fieldset': { borderColor: '#4f46e5 !important' },
|
||||
},
|
||||
};
|
||||
|
||||
export const AudienceTab: React.FC<AudienceTabProps> = ({
|
||||
analysis,
|
||||
isEditing,
|
||||
editedAnalysis,
|
||||
setEditedAnalysis,
|
||||
handleRemoveKeyword,
|
||||
handleAddKeyword,
|
||||
handleRemoveTitle,
|
||||
handleAddTitle,
|
||||
handleUpdateOutline,
|
||||
updateExaConfig
|
||||
}) => {
|
||||
const currentAnalysis = editedAnalysis || analysis;
|
||||
|
||||
return (
|
||||
<AnalysisTabContent title="Target Audience" icon={<GroupsIcon />}>
|
||||
<Stack spacing={3}>
|
||||
<Box>
|
||||
<Typography variant="caption" sx={{ color: "#64748b", fontWeight: 600, display: "block", mb: 0.5 }}>
|
||||
Audience Description
|
||||
</Typography>
|
||||
{isEditing ? (
|
||||
<TextField
|
||||
fullWidth
|
||||
multiline
|
||||
rows={2}
|
||||
size="small"
|
||||
value={currentAnalysis.audience}
|
||||
onChange={(e) => setEditedAnalysis?.({ ...currentAnalysis, audience: e.target.value })}
|
||||
placeholder="Describe your target audience..."
|
||||
sx={inputStyles}
|
||||
/>
|
||||
) : (
|
||||
<Typography variant="body2" sx={{ color: "#0f172a" }}>
|
||||
{currentAnalysis.audience}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Box>
|
||||
<Typography variant="caption" sx={{ color: "#64748b", fontWeight: 600, display: "block", mb: 1 }}>
|
||||
Content Type
|
||||
</Typography>
|
||||
{isEditing ? (
|
||||
<TextField
|
||||
fullWidth
|
||||
size="small"
|
||||
value={currentAnalysis.contentType}
|
||||
onChange={(e) => setEditedAnalysis?.({ ...currentAnalysis, contentType: e.target.value })}
|
||||
placeholder="e.g. Interview, Narrative, Solo..."
|
||||
sx={inputStyles}
|
||||
/>
|
||||
) : (
|
||||
<Chip label={currentAnalysis.contentType} size="small" sx={{ background: "#eef2ff", color: "#4f46e5", border: "1px solid rgba(79,70,229,0.2)" }} />
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Box>
|
||||
<Typography variant="caption" sx={{ color: "#64748b", fontWeight: 600, display: "block", mb: 1 }}>
|
||||
Top Keywords
|
||||
</Typography>
|
||||
<Stack direction="row" flexWrap="wrap" useFlexGap sx={{ mb: isEditing ? 1.5 : 0 }}>
|
||||
{currentAnalysis.topKeywords.map((k: string) => (
|
||||
<Chip
|
||||
key={k}
|
||||
label={k}
|
||||
size="small"
|
||||
variant="outlined"
|
||||
onDelete={isEditing ? () => handleRemoveKeyword?.(k) : undefined}
|
||||
sx={{
|
||||
borderColor: "rgba(0,0,0,0.1)",
|
||||
color: "#0f172a",
|
||||
background: "#f8fafc",
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
{isEditing && (
|
||||
<TextField
|
||||
fullWidth
|
||||
size="small"
|
||||
placeholder="Add keyword and press Enter..."
|
||||
sx={inputStyles}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
handleAddKeyword?.((e.target as HTMLInputElement).value);
|
||||
(e.target as HTMLInputElement).value = '';
|
||||
}
|
||||
}}
|
||||
InputProps={{
|
||||
endAdornment: (
|
||||
<IconButton size="small" onClick={(e) => {
|
||||
const input = (e.currentTarget.parentElement?.parentElement?.querySelector('input') as HTMLInputElement);
|
||||
handleAddKeyword?.(input.value);
|
||||
input.value = '';
|
||||
}}>
|
||||
<AddIcon fontSize="small" sx={{ color: '#4f46e5' }} />
|
||||
</IconButton>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{currentAnalysis.exaSuggestedConfig && (
|
||||
<Box>
|
||||
<Divider sx={{ mb: 2 }} />
|
||||
<Typography variant="subtitle2" sx={{ mb: 1, color: "#0f172a", display: "flex", alignItems: "center", gap: 0.5 }}>
|
||||
<SearchIcon fontSize="small" sx={{ color: "#4f46e5" }} />
|
||||
Exa Research Config
|
||||
</Typography>
|
||||
<Stack direction="row" flexWrap="wrap" useFlexGap>
|
||||
{currentAnalysis.exaSuggestedConfig.exa_search_type && (
|
||||
<Chip label={`Search: ${currentAnalysis.exaSuggestedConfig.exa_search_type}`} size="small" sx={{ background: "#eef2ff", color: "#0f172a" }} />
|
||||
)}
|
||||
{currentAnalysis.exaSuggestedConfig.exa_category && (
|
||||
<Chip label={`Category: ${currentAnalysis.exaSuggestedConfig.exa_category}`} size="small" sx={{ background: "#eef2ff", color: "#0f172a" }} />
|
||||
)}
|
||||
{currentAnalysis.exaSuggestedConfig.date_range && (
|
||||
<Chip label={`Date: ${currentAnalysis.exaSuggestedConfig.date_range}`} size="small" sx={{ background: "#eef2ff", color: "#0f172a" }} />
|
||||
)}
|
||||
{currentAnalysis.exaSuggestedConfig.max_sources && (
|
||||
<Chip label={`Max: ${currentAnalysis.exaSuggestedConfig.max_sources}`} size="small" sx={{ background: "#eef2ff", color: "#0f172a" }} />
|
||||
)}
|
||||
</Stack>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Box>
|
||||
<Typography variant="caption" sx={{ color: "#64748b", fontWeight: 600, display: "block", mb: 1 }}>
|
||||
Title Suggestions
|
||||
</Typography>
|
||||
<Stack direction="row" flexWrap="wrap" useFlexGap sx={{ mb: isEditing ? 1.5 : 0 }}>
|
||||
{currentAnalysis.titleSuggestions.map((t: string) => (
|
||||
<Chip
|
||||
key={t}
|
||||
label={t}
|
||||
size="small"
|
||||
onDelete={isEditing ? () => handleRemoveTitle?.(t) : undefined}
|
||||
sx={{
|
||||
color: "#0f172a",
|
||||
background: "#f8fafc",
|
||||
maxWidth: "100%",
|
||||
whiteSpace: "normal",
|
||||
height: "auto",
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
{isEditing && (
|
||||
<TextField
|
||||
fullWidth
|
||||
size="small"
|
||||
placeholder="Add title suggestion..."
|
||||
sx={inputStyles}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
handleAddTitle?.((e.target as HTMLInputElement).value);
|
||||
(e.target as HTMLInputElement).value = '';
|
||||
}
|
||||
}}
|
||||
InputProps={{
|
||||
endAdornment: (
|
||||
<IconButton size="small" onClick={(e) => {
|
||||
const input = (e.currentTarget.parentElement?.parentElement?.querySelector('input') as HTMLInputElement);
|
||||
handleAddTitle?.(input.value);
|
||||
input.value = '';
|
||||
}}>
|
||||
<AddIcon fontSize="small" sx={{ color: '#4f46e5' }} />
|
||||
</IconButton>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
</Stack>
|
||||
</AnalysisTabContent>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,34 @@
|
||||
import React from "react";
|
||||
import { Box, Typography, Paper } from "@mui/material";
|
||||
import { Psychology as PsychologyIcon } from "@mui/icons-material";
|
||||
import { PodcastAnalysis } from "../../types";
|
||||
import { AnalysisTabContent } from "../AnalysisTabNav";
|
||||
|
||||
interface CTATabProps {
|
||||
analysis: PodcastAnalysis;
|
||||
}
|
||||
|
||||
export const CTATab: React.FC<CTATabProps> = ({ analysis }) => {
|
||||
if (!analysis.listener_cta) {
|
||||
return (
|
||||
<AnalysisTabContent title="Listener CTA" icon={<PsychologyIcon />}>
|
||||
<Typography variant="body2" sx={{ color: "#64748b" }}>
|
||||
No listener call-to-action generated yet.
|
||||
</Typography>
|
||||
</AnalysisTabContent>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<AnalysisTabContent title="Listener CTA" icon={<PsychologyIcon />}>
|
||||
<Paper elevation={0} sx={{ p: 3, bgcolor: "#fff7ed", border: "1px solid rgba(249,115,22,0.2)", borderRadius: 2 }}>
|
||||
<Typography variant="body1" sx={{ color: "#c2410c", fontWeight: 500, lineHeight: 1.6 }}>
|
||||
{analysis.listener_cta}
|
||||
</Typography>
|
||||
</Paper>
|
||||
<Typography variant="caption" sx={{ color: "#64748b", mt: 1, display: "block" }}>
|
||||
This is a call-to-action for listeners to take action after the episode.
|
||||
</Typography>
|
||||
</AnalysisTabContent>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,36 @@
|
||||
import React from "react";
|
||||
import { Stack, Box, Typography, Chip, Paper } from "@mui/material";
|
||||
import { Quiz as TalkIcon } from "@mui/icons-material";
|
||||
import { PodcastAnalysis } from "../../types";
|
||||
import { AnalysisTabContent } from "../AnalysisTabNav";
|
||||
|
||||
interface GuestTabProps {
|
||||
analysis: PodcastAnalysis;
|
||||
}
|
||||
|
||||
export const GuestTab: React.FC<GuestTabProps> = ({ analysis }) => {
|
||||
if (!analysis.guest_talking_points || analysis.guest_talking_points.length === 0) {
|
||||
return (
|
||||
<AnalysisTabContent title="Guest Talking Points" icon={<TalkIcon />}>
|
||||
<Typography variant="body2" sx={{ color: "#64748b" }}>
|
||||
No guest talking points generated yet. Add a guest speaker to get interview questions.
|
||||
</Typography>
|
||||
</AnalysisTabContent>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<AnalysisTabContent title="Guest Talking Points" icon={<TalkIcon />}>
|
||||
<Stack spacing={2}>
|
||||
{analysis.guest_talking_points.map((point: string, idx: number) => (
|
||||
<Paper key={idx} elevation={0} sx={{ p: 2, bgcolor: "#faf5ff", border: "1px solid rgba(168,85,247,0.2)", borderRadius: 2, display: "flex", alignItems: "flex-start", gap: 1.5 }}>
|
||||
<Chip label="Q" size="small" sx={{ minWidth: 24, bgcolor: "#a855f7", color: "#fff" }} />
|
||||
<Typography variant="body2" sx={{ color: "#6b21a8" }}>
|
||||
{point}
|
||||
</Typography>
|
||||
</Paper>
|
||||
))}
|
||||
</Stack>
|
||||
</AnalysisTabContent>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,34 @@
|
||||
import React from "react";
|
||||
import { Box, Typography, Paper } from "@mui/material";
|
||||
import { AutoAwesome as AutoAwesomeIcon } from "@mui/icons-material";
|
||||
import { PodcastAnalysis } from "../../types";
|
||||
import { AnalysisTabContent } from "../AnalysisTabNav";
|
||||
|
||||
interface HookTabProps {
|
||||
analysis: PodcastAnalysis;
|
||||
}
|
||||
|
||||
export const HookTab: React.FC<HookTabProps> = ({ analysis }) => {
|
||||
if (!analysis.episode_hook) {
|
||||
return (
|
||||
<AnalysisTabContent title="Episode Hook" icon={<AutoAwesomeIcon />}>
|
||||
<Typography variant="body2" sx={{ color: "#64748b" }}>
|
||||
No episode hook generated yet.
|
||||
</Typography>
|
||||
</AnalysisTabContent>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<AnalysisTabContent title="Episode Hook" icon={<AutoAwesomeIcon />}>
|
||||
<Paper elevation={0} sx={{ p: 3, bgcolor: "#f0f9ff", border: "1px solid rgba(59,130,246,0.2)", borderRadius: 2 }}>
|
||||
<Typography variant="body1" sx={{ color: "#0369a1", fontStyle: "italic", fontSize: "1.1rem", lineHeight: 1.6 }}>
|
||||
"{analysis.episode_hook}"
|
||||
</Typography>
|
||||
</Paper>
|
||||
<Typography variant="caption" sx={{ color: "#64748b", mt: 1, display: "block" }}>
|
||||
This is a 15-30 second opening hook to grab listener attention.
|
||||
</Typography>
|
||||
</AnalysisTabContent>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,191 @@
|
||||
import React from "react";
|
||||
import { Box, Stack, Typography, Chip, Paper, CircularProgress, alpha } from "@mui/material";
|
||||
import { Input as InputIcon, Person as PersonIcon, AutoAwesome as AutoAwesomeIcon } from "@mui/icons-material";
|
||||
import { AnalysisTabContent } from "../AnalysisTabNav";
|
||||
|
||||
interface InputsTabProps {
|
||||
idea?: string;
|
||||
duration?: number;
|
||||
speakers?: number;
|
||||
avatarUrl?: string | null;
|
||||
avatarPrompt?: string | null;
|
||||
avatarBlobUrl?: string | null;
|
||||
avatarLoading?: boolean;
|
||||
avatarError?: boolean;
|
||||
}
|
||||
|
||||
export const InputsTab: React.FC<InputsTabProps> = ({ idea, duration, speakers, avatarUrl, avatarPrompt, avatarBlobUrl, avatarLoading, avatarError }) => {
|
||||
if (!idea && !duration && !speakers && !avatarUrl && !avatarPrompt) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<AnalysisTabContent title="Your Inputs" icon={<InputIcon />}>
|
||||
<Box
|
||||
sx={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: { xs: "1fr", md: avatarUrl ? "1fr 1fr" : "1fr" },
|
||||
gap: 3,
|
||||
alignItems: "flex-start",
|
||||
}}
|
||||
>
|
||||
<Stack spacing={1.5}>
|
||||
{idea && (
|
||||
<Box>
|
||||
<Typography variant="caption" sx={{ color: "#64748b", fontWeight: 600, display: "block", mb: 0.5 }}>
|
||||
Podcast Idea
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: "#0f172a", wordBreak: "break-word" }}>
|
||||
{idea}
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
<Stack direction="row" spacing={2} flexWrap="wrap">
|
||||
{duration !== undefined && (
|
||||
<Box>
|
||||
<Typography variant="caption" sx={{ color: "#64748b", fontWeight: 600, display: "block", mb: 0.5 }}>
|
||||
Duration
|
||||
</Typography>
|
||||
<Chip
|
||||
label={`${duration} minutes`}
|
||||
size="small"
|
||||
sx={{ background: "#f1f5f9", color: "#0f172a", border: "1px solid rgba(0,0,0,0.08)" }}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
{speakers !== undefined && (
|
||||
<Box>
|
||||
<Typography variant="caption" sx={{ color: "#64748b", fontWeight: 600, display: "block", mb: 0.5 }}>
|
||||
Speakers
|
||||
</Typography>
|
||||
<Chip
|
||||
label={`${speakers} ${speakers === 1 ? "speaker" : "speakers"}`}
|
||||
size="small"
|
||||
sx={{ background: "#f1f5f9", color: "#0f172a", border: "1px solid rgba(0,0,0,0.08)" }}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
{avatarPrompt && (
|
||||
<Box>
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{
|
||||
color: "#64748b",
|
||||
fontWeight: 600,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 0.5,
|
||||
mb: 0.75,
|
||||
}}
|
||||
>
|
||||
<AutoAwesomeIcon sx={{ fontSize: 14 }} />
|
||||
AI Generation Prompt
|
||||
</Typography>
|
||||
<Paper
|
||||
sx={{
|
||||
p: 1.5,
|
||||
background: "#f8fafc",
|
||||
border: "1px solid rgba(0,0,0,0.08)",
|
||||
borderRadius: 1.5,
|
||||
maxHeight: 200,
|
||||
overflow: "auto",
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{
|
||||
color: "#475569",
|
||||
fontFamily: "monospace",
|
||||
fontSize: "0.75rem",
|
||||
lineHeight: 1.6,
|
||||
whiteSpace: "pre-wrap",
|
||||
wordBreak: "break-word",
|
||||
display: "block",
|
||||
}}
|
||||
>
|
||||
{avatarPrompt}
|
||||
</Typography>
|
||||
</Paper>
|
||||
</Box>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
{avatarUrl && (
|
||||
<Box>
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{
|
||||
color: "#64748b",
|
||||
fontWeight: 600,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 0.5,
|
||||
mb: 1,
|
||||
}}
|
||||
>
|
||||
<PersonIcon sx={{ fontSize: 16 }} />
|
||||
Presenter Avatar
|
||||
</Typography>
|
||||
<Box
|
||||
sx={{
|
||||
width: "100%",
|
||||
maxWidth: { xs: "100%", md: 300 },
|
||||
borderRadius: 2,
|
||||
overflow: "hidden",
|
||||
border: "1px solid rgba(102,126,234,0.2)",
|
||||
background: alpha("#667eea", 0.05),
|
||||
position: "relative",
|
||||
aspectRatio: "1",
|
||||
boxShadow: "0 4px 12px rgba(0,0,0,0.08)",
|
||||
}}
|
||||
>
|
||||
{avatarLoading ? (
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
height: "100%",
|
||||
background: "#f8fafc",
|
||||
}}
|
||||
>
|
||||
<CircularProgress size={40} />
|
||||
</Box>
|
||||
) : avatarError ? (
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
height: "100%",
|
||||
background: "#fef2f2",
|
||||
color: "#dc2626",
|
||||
p: 2,
|
||||
}}
|
||||
>
|
||||
<Typography variant="caption" sx={{ textAlign: "center" }}>
|
||||
Failed to load avatar
|
||||
</Typography>
|
||||
</Box>
|
||||
) : avatarBlobUrl ? (
|
||||
<Box
|
||||
component="img"
|
||||
src={avatarBlobUrl}
|
||||
alt="Podcast Presenter"
|
||||
sx={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
objectFit: "cover",
|
||||
display: "block",
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</AnalysisTabContent>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,39 @@
|
||||
import React from "react";
|
||||
import { Stack, Box, Typography, Chip, TextField, IconButton } from "@mui/material";
|
||||
import { ListAlt as ListAltIcon, Add as AddIcon } from "@mui/icons-material";
|
||||
import { PodcastAnalysis } from "../../types";
|
||||
import { AnalysisTabContent } from "../AnalysisTabNav";
|
||||
|
||||
interface OutlineTabProps {
|
||||
analysis: PodcastAnalysis;
|
||||
isEditing?: boolean;
|
||||
onUpdateOutline?: (id: string | number, field: 'title' | 'segments', value: any) => void;
|
||||
}
|
||||
|
||||
export const OutlineTab: React.FC<OutlineTabProps> = ({ analysis, isEditing, onUpdateOutline }) => {
|
||||
return (
|
||||
<AnalysisTabContent title="Episode Outline" icon={<ListAltIcon />}>
|
||||
<Stack spacing={3}>
|
||||
{analysis.suggestedOutlines?.map((outline: { id?: string | number; title: string; segments: string[] }, idx: number) => (
|
||||
<Box key={outline.id || idx} sx={{ p: 2, bgcolor: "#f8fafc", borderRadius: 2, border: "1px solid rgba(0,0,0,0.08)" }}>
|
||||
<Stack direction="row" justifyContent="space-between" alignItems="center" sx={{ mb: 1.5 }}>
|
||||
<Typography variant="subtitle2" sx={{ color: "#0f172a", fontWeight: 700 }}>
|
||||
Option {idx + 1}: {outline.title}
|
||||
</Typography>
|
||||
</Stack>
|
||||
<Stack spacing={1}>
|
||||
{outline.segments?.map((segment: string, sIdx: number) => (
|
||||
<Box key={sIdx} sx={{ display: "flex", alignItems: "flex-start", gap: 1 }}>
|
||||
<Chip label={sIdx + 1} size="small" sx={{ minWidth: 24, bgcolor: "#4f46e5", color: "#fff" }} />
|
||||
<Typography variant="body2" sx={{ color: "#475569" }}>
|
||||
{segment}
|
||||
</Typography>
|
||||
</Box>
|
||||
))}
|
||||
</Stack>
|
||||
</Box>
|
||||
))}
|
||||
</Stack>
|
||||
</AnalysisTabContent>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,43 @@
|
||||
import React from "react";
|
||||
import { Stack, Box, Typography, Chip, Paper } from "@mui/material";
|
||||
import { Search as SearchIcon } from "@mui/icons-material";
|
||||
import { PodcastAnalysis } from "../../types";
|
||||
import { AnalysisTabContent } from "../AnalysisTabNav";
|
||||
|
||||
interface ResearchTabProps {
|
||||
analysis: PodcastAnalysis;
|
||||
}
|
||||
|
||||
export const ResearchTab: React.FC<ResearchTabProps> = ({ analysis }) => {
|
||||
if (!analysis.research_queries || analysis.research_queries.length === 0) {
|
||||
return (
|
||||
<AnalysisTabContent title="Research Queries" icon={<SearchIcon />}>
|
||||
<Typography variant="body2" sx={{ color: "#64748b" }}>
|
||||
No research queries generated yet.
|
||||
</Typography>
|
||||
</AnalysisTabContent>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<AnalysisTabContent title="Research Queries" icon={<SearchIcon />}>
|
||||
<Stack spacing={2}>
|
||||
{analysis.research_queries.map((rq: { query: string; rationale: string }, idx: number) => (
|
||||
<Paper key={idx} elevation={0} sx={{ p: 2, bgcolor: "#f8fafc", border: "1px solid rgba(0,0,0,0.08)", borderRadius: 2 }}>
|
||||
<Stack direction="row" alignItems="flex-start" gap={1.5}>
|
||||
<Chip label={idx + 1} size="small" sx={{ minWidth: 24, bgcolor: "#4f46e5", color: "#fff" }} />
|
||||
<Box>
|
||||
<Typography variant="body2" sx={{ color: "#0f172a", fontWeight: 600, mb: 0.5 }}>
|
||||
{rq.query}
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ color: "#64748b" }}>
|
||||
Rationale: {rq.rationale}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Stack>
|
||||
</Paper>
|
||||
))}
|
||||
</Stack>
|
||||
</AnalysisTabContent>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,36 @@
|
||||
import React from "react";
|
||||
import { Stack, Box, Typography, Chip, Paper } from "@mui/material";
|
||||
import { Lightbulb as TipsIcon } from "@mui/icons-material";
|
||||
import { PodcastAnalysis } from "../../types";
|
||||
import { AnalysisTabContent } from "../AnalysisTabNav";
|
||||
|
||||
interface TakeawaysTabProps {
|
||||
analysis: PodcastAnalysis;
|
||||
}
|
||||
|
||||
export const TakeawaysTab: React.FC<TakeawaysTabProps> = ({ analysis }) => {
|
||||
if (!analysis.key_takeaways || analysis.key_takeaways.length === 0) {
|
||||
return (
|
||||
<AnalysisTabContent title="Key Takeaways" icon={<TipsIcon />}>
|
||||
<Typography variant="body2" sx={{ color: "#64748b" }}>
|
||||
No key takeaways generated yet.
|
||||
</Typography>
|
||||
</AnalysisTabContent>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<AnalysisTabContent title="Key Takeaways" icon={<TipsIcon />}>
|
||||
<Stack spacing={2}>
|
||||
{analysis.key_takeaways.map((takeaway: string, idx: number) => (
|
||||
<Paper key={idx} elevation={0} sx={{ p: 2, bgcolor: "#f0fdf4", border: "1px solid rgba(34,197,94,0.2)", borderRadius: 2, display: "flex", alignItems: "flex-start", gap: 1.5 }}>
|
||||
<Chip label={idx + 1} size="small" sx={{ minWidth: 24, bgcolor: "#22c55e", color: "#fff" }} />
|
||||
<Typography variant="body2" sx={{ color: "#166534" }}>
|
||||
{takeaway}
|
||||
</Typography>
|
||||
</Paper>
|
||||
))}
|
||||
</Stack>
|
||||
</AnalysisTabContent>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,75 @@
|
||||
import React from "react";
|
||||
import { Stack, Box, Typography, Chip, TextField, IconButton } from "@mui/material";
|
||||
import { EditNote as EditNoteIcon, Add as AddIcon } from "@mui/icons-material";
|
||||
import { PodcastAnalysis } from "../../types";
|
||||
import { AnalysisTabContent } from "../AnalysisTabNav";
|
||||
|
||||
interface TitlesTabProps {
|
||||
analysis: PodcastAnalysis;
|
||||
isEditing?: boolean;
|
||||
handleRemoveTitle?: (title: string) => void;
|
||||
handleAddTitle?: (title: string) => void;
|
||||
}
|
||||
|
||||
const inputStyles = {
|
||||
'& .MuiInputBase-input': { color: '#111827 !important', fontWeight: 500 },
|
||||
'& .MuiInputLabel-root': { color: '#4b5563 !important' },
|
||||
'& .MuiOutlinedInput-root': {
|
||||
bgcolor: '#ffffff !important',
|
||||
'& fieldset': { borderColor: '#d1d5db !important' },
|
||||
'&:hover fieldset': { borderColor: '#4f46e5 !important' },
|
||||
'&.Mui-focused fieldset': { borderColor: '#4f46e5 !important' },
|
||||
},
|
||||
};
|
||||
|
||||
export const TitlesTab: React.FC<TitlesTabProps> = ({ analysis, isEditing, handleRemoveTitle, handleAddTitle }) => {
|
||||
return (
|
||||
<AnalysisTabContent title="Episode Titles" icon={<EditNoteIcon />}>
|
||||
<Stack spacing={2}>
|
||||
<Stack direction="row" flexWrap="wrap" useFlexGap sx={{ mb: isEditing ? 1.5 : 0 }}>
|
||||
{analysis.titleSuggestions?.map((title: string, idx: number) => (
|
||||
<Chip
|
||||
key={idx}
|
||||
label={title}
|
||||
size="small"
|
||||
onDelete={isEditing ? () => handleRemoveTitle?.(title) : undefined}
|
||||
sx={{
|
||||
color: "#0f172a",
|
||||
background: "#f8fafc",
|
||||
maxWidth: "100%",
|
||||
whiteSpace: "normal",
|
||||
height: "auto",
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
{isEditing && (
|
||||
<TextField
|
||||
fullWidth
|
||||
size="small"
|
||||
placeholder="Add title suggestion..."
|
||||
sx={inputStyles}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
handleAddTitle?.((e.target as HTMLInputElement).value);
|
||||
(e.target as HTMLInputElement).value = '';
|
||||
}
|
||||
}}
|
||||
InputProps={{
|
||||
endAdornment: (
|
||||
<IconButton size="small" onClick={(e) => {
|
||||
const input = (e.currentTarget.parentElement?.parentElement?.querySelector('input') as HTMLInputElement);
|
||||
handleAddTitle?.(input.value);
|
||||
input.value = '';
|
||||
}}>
|
||||
<AddIcon fontSize="small" sx={{ color: '#4f46e5' }} />
|
||||
</IconButton>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
</AnalysisTabContent>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,9 @@
|
||||
export { HookTab } from "./HookTab";
|
||||
export { CTATab } from "./CTATab";
|
||||
export { GuestTab } from "./GuestTab";
|
||||
export { TakeawaysTab } from "./TakeawaysTab";
|
||||
export { ResearchTab } from "./ResearchTab";
|
||||
export { TitlesTab } from "./TitlesTab";
|
||||
export { OutlineTab } from "./OutlineTab";
|
||||
export { AudienceTab } from "./AudienceTab";
|
||||
export { InputsTab } from "./InputsTab";
|
||||
@@ -5,6 +5,7 @@ import { useSubscription } from "../../contexts/SubscriptionContext";
|
||||
import { podcastApi } from "../../services/podcastApi";
|
||||
import { fetchMediaBlobUrl, clearMediaCache } from "../../utils/fetchMediaBlobUrl";
|
||||
import { getLatestBrandAvatar } from "../../api/brandAssets";
|
||||
import { VoiceSelector } from "../shared/VoiceSelector";
|
||||
|
||||
// Imported Components
|
||||
import { CreateHeader } from "./CreateStep/CreateHeader";
|
||||
@@ -43,6 +44,7 @@ export const CreateModal: React.FC<CreateModalProps> = ({ onCreate, open, defaul
|
||||
const [enhancingTopic, setEnhancingTopic] = useState(false);
|
||||
const [enhanceTopicProgressIndex, setEnhanceTopicProgressIndex] = useState(0);
|
||||
const [knobs, setKnobs] = useState<Knobs>({ ...defaultKnobs });
|
||||
const [selectedVoiceId, setSelectedVoiceId] = useState<string>("Wise_Woman");
|
||||
const [placeholderIndex, setPlaceholderIndex] = useState(0);
|
||||
const [avatarTab, setAvatarTab] = useState(0);
|
||||
const [loadingBrandAvatar, setLoadingBrandAvatar] = useState(false);
|
||||
@@ -269,7 +271,16 @@ export const CreateModal: React.FC<CreateModalProps> = ({ onCreate, open, defaul
|
||||
};
|
||||
}, [duration, speakers, knobs.bitrate, knobs.scene_length_target]);
|
||||
|
||||
const canSubmit = Boolean(topicInput.trim());
|
||||
// Check if avatar is present (from any source: upload, brand avatar, or generated)
|
||||
const hasAvatar = Boolean(
|
||||
avatarFile || // User uploaded an image
|
||||
avatarUrl || // Already processed avatar URL
|
||||
avatarPreview || // Avatar preview available
|
||||
brandAvatarFromDb || // Brand avatar from database
|
||||
brandAvatarBlobUrl // Brand avatar blob URL
|
||||
);
|
||||
|
||||
const canSubmit = Boolean(topicInput.trim() && hasAvatar);
|
||||
|
||||
const submit = async () => {
|
||||
if (!canSubmit || isSubmitting) return;
|
||||
@@ -309,11 +320,17 @@ export const CreateModal: React.FC<CreateModalProps> = ({ onCreate, open, defaul
|
||||
}
|
||||
}
|
||||
|
||||
// Include selected voice in knobs
|
||||
const finalKnobs = {
|
||||
...knobs,
|
||||
voice_id: selectedVoiceId,
|
||||
};
|
||||
|
||||
onCreate({
|
||||
ideaOrUrl: finalUrl || finalIdea,
|
||||
speakers,
|
||||
duration,
|
||||
knobs,
|
||||
knobs: finalKnobs,
|
||||
budgetCap,
|
||||
files: { voiceFile, avatarFile },
|
||||
avatarUrl: finalAvatarUrl,
|
||||
@@ -333,6 +350,7 @@ export const CreateModal: React.FC<CreateModalProps> = ({ onCreate, open, defaul
|
||||
setEnhancingTopic(false);
|
||||
setEnhanceTopicProgressIndex(0);
|
||||
setKnobs({ ...defaultKnobs });
|
||||
setSelectedVoiceId("Wise_Woman");
|
||||
setPlaceholderIndex(0);
|
||||
};
|
||||
|
||||
@@ -556,6 +574,12 @@ export const CreateModal: React.FC<CreateModalProps> = ({ onCreate, open, defaul
|
||||
setCameraSelfieOpen={setCameraSelfieOpen}
|
||||
/>
|
||||
|
||||
<VoiceSelector
|
||||
value={selectedVoiceId}
|
||||
onChange={setSelectedVoiceId}
|
||||
showVoiceClone={true}
|
||||
/>
|
||||
|
||||
<CreateActions
|
||||
reset={reset}
|
||||
submit={submit}
|
||||
|
||||
@@ -160,17 +160,18 @@ export const AvatarSelector: React.FC<AvatarSelectorProps> = ({
|
||||
{loadingBrandAvatar ? (
|
||||
<CircularProgress size={32} />
|
||||
) : avatarPreview && avatarPreview === brandAvatarFromDb ? (
|
||||
<Stack spacing={2} alignItems="center">
|
||||
<Box sx={{ position: "relative" }}>
|
||||
<Stack spacing={2} alignItems="center" sx={{ width: "100%", maxWidth: 280 }}>
|
||||
<Box sx={{ position: "relative", width: "100%" }}>
|
||||
<Box
|
||||
component="img"
|
||||
src={avatarPreviewBlobUrl || ""}
|
||||
alt="Selected Brand Avatar"
|
||||
sx={{
|
||||
width: 160,
|
||||
height: 160,
|
||||
objectFit: "cover",
|
||||
borderRadius: 2.5,
|
||||
width: "100%",
|
||||
height: "auto",
|
||||
maxHeight: 200,
|
||||
objectFit: "contain",
|
||||
borderRadius: 2,
|
||||
border: "2px solid #667eea",
|
||||
boxShadow: "0 4px 12px rgba(102, 126, 234, 0.25)",
|
||||
}}
|
||||
@@ -203,16 +204,17 @@ export const AvatarSelector: React.FC<AvatarSelectorProps> = ({
|
||||
</Stack>
|
||||
</Stack>
|
||||
) : brandAvatarFromDb ? (
|
||||
<Stack spacing={2} alignItems="center">
|
||||
<Stack spacing={2} alignItems="center" sx={{ width: "100%", maxWidth: 280 }}>
|
||||
<Box
|
||||
component="img"
|
||||
src={brandAvatarBlobUrl || ""}
|
||||
alt="Available Brand Avatar"
|
||||
sx={{
|
||||
width: 160,
|
||||
height: 160,
|
||||
objectFit: "cover",
|
||||
borderRadius: 2.5,
|
||||
width: "100%",
|
||||
height: "auto",
|
||||
maxHeight: 200,
|
||||
objectFit: "contain",
|
||||
borderRadius: 2,
|
||||
border: "1.5px solid #e2e8f0",
|
||||
opacity: 0.8,
|
||||
filter: "grayscale(0.3)",
|
||||
@@ -317,16 +319,17 @@ export const AvatarSelector: React.FC<AvatarSelectorProps> = ({
|
||||
<Box>
|
||||
{avatarFile && avatarPreview ? (
|
||||
<Stack spacing={2} alignItems="center" sx={{ bgcolor: "#f8fafc", borderRadius: 2, p: 2 }}>
|
||||
<Box sx={{ position: "relative", display: "inline-block" }}>
|
||||
<Box sx={{ position: "relative", display: "inline-block", width: "100%", maxWidth: 280 }}>
|
||||
<Box
|
||||
component="img"
|
||||
src={avatarPreviewBlobUrl || (avatarPreview.startsWith("data:") ? avatarPreview : "")}
|
||||
alt="Selfie preview"
|
||||
sx={{
|
||||
width: 160,
|
||||
height: 160,
|
||||
objectFit: "cover",
|
||||
borderRadius: 2.5,
|
||||
width: "100%",
|
||||
height: "auto",
|
||||
maxHeight: 200,
|
||||
objectFit: "contain",
|
||||
borderRadius: 2,
|
||||
border: "2px solid #e2e8f0",
|
||||
boxShadow: "0 2px 8px rgba(15, 23, 42, 0.08)",
|
||||
}}
|
||||
|
||||
@@ -1,9 +1,32 @@
|
||||
import React from "react";
|
||||
import { Stack, Alert, Typography, alpha } from "@mui/material";
|
||||
import React, { useState, useEffect } from "react";
|
||||
import {
|
||||
Stack,
|
||||
Alert,
|
||||
Typography,
|
||||
alpha,
|
||||
Collapse,
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemIcon,
|
||||
ListItemText,
|
||||
CircularProgress,
|
||||
Box,
|
||||
LinearProgress,
|
||||
} from "@mui/material";
|
||||
import {
|
||||
Info as InfoIcon,
|
||||
Refresh as RefreshIcon,
|
||||
AutoAwesome as AutoAwesomeIcon,
|
||||
CheckCircle as CheckCircleIcon,
|
||||
Analytics as AnalyticsIcon,
|
||||
Title as TitleIcon,
|
||||
ListAlt as ListAltIcon,
|
||||
Psychology as PsychologyIcon,
|
||||
RecordVoiceOver as RecordVoiceOverIcon,
|
||||
} from "@mui/icons-material";
|
||||
import { PrimaryButton, SecondaryButton } from "../ui";
|
||||
|
||||
@@ -14,47 +37,236 @@ interface CreateActionsProps {
|
||||
isSubmitting: boolean;
|
||||
}
|
||||
|
||||
export const CreateActions: React.FC<CreateActionsProps> = ({
|
||||
reset,
|
||||
submit,
|
||||
canSubmit,
|
||||
isSubmitting,
|
||||
}) => {
|
||||
// ============================================================================
|
||||
// Constants & Data
|
||||
// ============================================================================
|
||||
|
||||
const ANALYSIS_FEATURES = [
|
||||
{ icon: <AnalyticsIcon />, text: "Target audience & content type analysis" },
|
||||
{ icon: <ListAltIcon />, text: "5 high-impact keywords for discoverability" },
|
||||
{ icon: <TitleIcon />, text: "3 catchy episode title suggestions" },
|
||||
{ icon: <PsychologyIcon />, text: "2 detailed episode outlines with segments" },
|
||||
{ icon: <RecordVoiceOverIcon />, text: "4-6 research queries for AI-powered research" },
|
||||
{ icon: <CheckCircleIcon />, text: "Episode hook, key takeaways & listener CTA" },
|
||||
];
|
||||
|
||||
const ANALYSIS_PROGRESS_STEPS = [
|
||||
"Analyzing target audience & content type",
|
||||
"Generating keywords & title suggestions",
|
||||
"Creating episode outlines",
|
||||
"Generating research queries",
|
||||
"Creating hook, takeaways & CTA",
|
||||
];
|
||||
|
||||
const INFO_BANNER_TEXT =
|
||||
"Podcast avatar Image is required. Brand avatar is default. You can choose from asset library or upload your picture. If not, AI Avatar will be generated automatically.";
|
||||
|
||||
// ============================================================================
|
||||
// Styles
|
||||
// ============================================================================
|
||||
|
||||
const styles = {
|
||||
dialog: {
|
||||
background: "linear-gradient(135deg, #1e293b 0%, #0f172a 100%)",
|
||||
border: "1px solid rgba(167, 139, 250, 0.3)",
|
||||
borderRadius: 3,
|
||||
},
|
||||
infoAlert: {
|
||||
background: alpha("#f0f4ff", 0.6),
|
||||
border: "1px solid rgba(99, 102, 241, 0.15)",
|
||||
borderRadius: 2,
|
||||
boxShadow: "0 1px 3px rgba(99, 102, 241, 0.08)",
|
||||
},
|
||||
progressDot: {
|
||||
width: 6,
|
||||
height: 6,
|
||||
borderRadius: "50%",
|
||||
bgcolor: "#a78bfa",
|
||||
},
|
||||
dialogContent: {
|
||||
color: "rgba(255,255,255,0.8)",
|
||||
minHeight: 200,
|
||||
py: 3,
|
||||
},
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Sub-Components
|
||||
// ============================================================================
|
||||
|
||||
const InfoBanner: React.FC<{ showInfo: boolean; setShowInfo: (v: boolean) => void }> = ({
|
||||
showInfo,
|
||||
setShowInfo,
|
||||
}) => (
|
||||
<Collapse in={showInfo}>
|
||||
<Alert
|
||||
severity="info"
|
||||
icon={<InfoIcon sx={{ color: "#6366f1", fontSize: "1.125rem" }} />}
|
||||
onClose={() => setShowInfo(false)}
|
||||
sx={styles.infoAlert}
|
||||
>
|
||||
<Typography variant="body2" sx={{ fontSize: "0.875rem", color: "#475569", lineHeight: 1.6, fontWeight: 400 }}>
|
||||
{INFO_BANNER_TEXT}
|
||||
</Typography>
|
||||
</Alert>
|
||||
</Collapse>
|
||||
);
|
||||
|
||||
const ShowTipsLink: React.FC<{ onClick: () => void }> = ({ onClick }) => (
|
||||
<Stack direction="row" alignItems="center" spacing={1}>
|
||||
<InfoIcon sx={{ fontSize: 16, color: "#6366f1" }} />
|
||||
<Typography variant="caption" sx={{ color: "#6366f1", cursor: "pointer", "&:hover": { textDecoration: "underline" } }} onClick={onClick}>
|
||||
Show tips
|
||||
</Typography>
|
||||
</Stack>
|
||||
);
|
||||
|
||||
const AnalysisProgressView: React.FC = () => (
|
||||
<Stack spacing={3} alignItems="center" sx={styles.dialogContent} justifyContent="center">
|
||||
<Box sx={{ position: "relative", display: "flex", alignItems: "center", justifyContent: "center" }}>
|
||||
<CircularProgress size={80} thickness={3} sx={{ color: "#a78bfa" }} />
|
||||
<Box sx={{ position: "absolute", display: "flex", flexDirection: "column", alignItems: "center" }}>
|
||||
<AutoAwesomeIcon sx={{ color: "#a78bfa", fontSize: 32 }} />
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Typography variant="h6" sx={{ color: "#fff", textAlign: "center" }}>
|
||||
Analyzing Your Podcast Idea
|
||||
</Typography>
|
||||
|
||||
<LinearProgress
|
||||
sx={{
|
||||
width: "100%",
|
||||
height: 8,
|
||||
borderRadius: 4,
|
||||
bgcolor: "rgba(255,255,255,0.1)",
|
||||
"& .MuiLinearProgress-bar": { bgcolor: "#a78bfa", borderRadius: 4 },
|
||||
}}
|
||||
/>
|
||||
|
||||
<Stack spacing={1} sx={{ width: "100%" }}>
|
||||
<Typography variant="body2" sx={{ color: "rgba(255,255,255,0.7)", textAlign: "center" }}>
|
||||
This may take a few moments...
|
||||
</Typography>
|
||||
<Stack spacing={0.5} alignItems="flex-start" sx={{ pl: 2 }}>
|
||||
{ANALYSIS_PROGRESS_STEPS.map((step, idx) => (
|
||||
<Typography key={idx} variant="caption" sx={{ color: "rgba(255,255,255,0.5)", display: "flex", alignItems: "center", gap: 0.5 }}>
|
||||
<Box sx={styles.progressDot} /> {step}
|
||||
</Typography>
|
||||
))}
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Stack>
|
||||
);
|
||||
|
||||
const WhatYoullGetView: React.FC = () => (
|
||||
<>
|
||||
<Typography variant="body2" sx={{ mb: 2, color: "rgba(255,255,255,0.7)" }}>
|
||||
Click "Start Analysis" to begin AI-powered podcast planning. Here's what we'll generate for you:
|
||||
</Typography>
|
||||
<List>
|
||||
{ANALYSIS_FEATURES.map((feature, index) => (
|
||||
<ListItem key={index} sx={{ px: 0, py: 0.5 }}>
|
||||
<ListItemIcon sx={{ minWidth: 36, color: "#a78bfa" }}>{feature.icon}</ListItemIcon>
|
||||
<ListItemText
|
||||
primary={feature.text}
|
||||
primaryTypographyProps={{ sx: { color: "rgba(255,255,255,0.9)", fontSize: "0.9rem" } }}
|
||||
/>
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
</>
|
||||
);
|
||||
|
||||
// ============================================================================
|
||||
// Main Component
|
||||
// ============================================================================
|
||||
|
||||
export const CreateActions: React.FC<CreateActionsProps> = ({ reset, submit, canSubmit, isSubmitting }) => {
|
||||
const [showInfo, setShowInfo] = useState(true);
|
||||
const [showAnalysisModal, setShowAnalysisModal] = useState(false);
|
||||
const [analysisStarted, setAnalysisStarted] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => setShowInfo(false), 8000);
|
||||
return () => clearTimeout(timer);
|
||||
}, []);
|
||||
|
||||
// Close modal when analysis completes
|
||||
useEffect(() => {
|
||||
if (!isSubmitting && analysisStarted) {
|
||||
setShowAnalysisModal(false);
|
||||
setAnalysisStarted(false);
|
||||
}
|
||||
}, [isSubmitting, analysisStarted]);
|
||||
|
||||
const handleSubmitClick = () => {
|
||||
if (canSubmit && !isSubmitting) setShowAnalysisModal(true);
|
||||
};
|
||||
|
||||
const handleStartAnalysis = () => {
|
||||
setAnalysisStarted(true);
|
||||
submit();
|
||||
};
|
||||
|
||||
const showProgressInModal = showAnalysisModal && (analysisStarted || isSubmitting);
|
||||
|
||||
return (
|
||||
<Stack spacing={3.5}>
|
||||
{/* Info Banner */}
|
||||
<Alert
|
||||
severity="info"
|
||||
icon={<InfoIcon sx={{ color: "#6366f1", fontSize: "1.125rem" }} />}
|
||||
sx={{
|
||||
background: alpha("#f0f4ff", 0.6),
|
||||
border: "1px solid rgba(99, 102, 241, 0.15)",
|
||||
borderRadius: 2,
|
||||
boxShadow: "0 1px 3px rgba(99, 102, 241, 0.08)",
|
||||
"& .MuiAlert-message": {
|
||||
width: "100%",
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Typography variant="body2" sx={{ fontSize: "0.875rem", color: "#475569", lineHeight: 1.6, fontWeight: 400 }}>
|
||||
Podcast avatar Image is required, brand avatar is Default, you can choose existing images from asset library Or Upload your Picture. If not, AI Avatar will be generated automatically.
|
||||
</Typography>
|
||||
</Alert>
|
||||
<Stack spacing={2}>
|
||||
<InfoBanner showInfo={showInfo} setShowInfo={setShowInfo} />
|
||||
{!showInfo && <ShowTipsLink onClick={() => setShowInfo(true)} />}
|
||||
|
||||
<Stack direction="row" justifyContent="flex-end" spacing={1}>
|
||||
<SecondaryButton onClick={reset} startIcon={<RefreshIcon />}>
|
||||
Reset
|
||||
</SecondaryButton>
|
||||
<PrimaryButton
|
||||
onClick={submit}
|
||||
onClick={handleSubmitClick}
|
||||
disabled={!canSubmit || isSubmitting}
|
||||
loading={isSubmitting}
|
||||
startIcon={<AutoAwesomeIcon />}
|
||||
tooltip={!canSubmit ? "Enter an idea or URL to continue" : "We’ll start AI analysis after this click"}
|
||||
tooltip={!canSubmit ? "Enter an idea/URL and add a podcast avatar to continue" : "We'll start AI analysis after this click"}
|
||||
>
|
||||
{isSubmitting ? "Analyzing..." : "Analyze & Continue"}
|
||||
</PrimaryButton>
|
||||
</Stack>
|
||||
|
||||
<Dialog
|
||||
open={showAnalysisModal}
|
||||
onClose={() => !isSubmitting && setShowAnalysisModal(false)}
|
||||
maxWidth="sm"
|
||||
fullWidth
|
||||
PaperProps={{ sx: styles.dialog }}
|
||||
>
|
||||
<DialogTitle sx={{ color: "#fff", display: "flex", alignItems: "center", gap: 1 }}>
|
||||
{isSubmitting ? (
|
||||
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
|
||||
<CircularProgress size={24} sx={{ color: "#a78bfa" }} />
|
||||
Analyzing Your Podcast Idea
|
||||
</Box>
|
||||
) : (
|
||||
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
|
||||
<AutoAwesomeIcon sx={{ color: "#a78bfa" }} />
|
||||
What You'll Get
|
||||
</Box>
|
||||
)}
|
||||
</DialogTitle>
|
||||
|
||||
<DialogContent sx={styles.dialogContent}>
|
||||
{showProgressInModal ? <AnalysisProgressView /> : <WhatYoullGetView />}
|
||||
</DialogContent>
|
||||
|
||||
<DialogActions sx={{ px: 3, pb: 3 }}>
|
||||
{showProgressInModal ? null : (
|
||||
<>
|
||||
<SecondaryButton onClick={() => setShowAnalysisModal(false)}>Cancel</SecondaryButton>
|
||||
<PrimaryButton onClick={handleStartAnalysis} startIcon={<AutoAwesomeIcon />}>
|
||||
Start Analysis
|
||||
</PrimaryButton>
|
||||
</>
|
||||
)}
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Stack, Typography, Divider, Chip, Tooltip, IconButton, alpha, Box } fro
|
||||
import { OpenInNew as OpenInNewIcon, ContentCopy as ContentCopyIcon, ExpandMore as ExpandMoreIcon, ExpandLess as ExpandLessIcon } from "@mui/icons-material";
|
||||
import { Fact } from "./types";
|
||||
import { GlassyCard, glassyCardSx } from "./ui";
|
||||
import { TextToSpeechButton } from "../shared/TextToSpeechButton";
|
||||
|
||||
interface FactCardProps {
|
||||
fact: Fact;
|
||||
@@ -162,6 +163,7 @@ export const FactCard: React.FC<FactCardProps> = ({ fact }) => {
|
||||
<ContentCopyIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<TextToSpeechButton text={fact.quote} size="small" />
|
||||
</Stack>
|
||||
|
||||
{/* Confidence and Date */}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React from 'react';
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Typography,
|
||||
@@ -26,6 +26,8 @@ interface PodcastBiblePanelProps {
|
||||
}
|
||||
|
||||
export const PodcastBiblePanel: React.FC<PodcastBiblePanelProps> = ({ bible, onUpdate }) => {
|
||||
const [panelExpanded, setPanelExpanded] = useState(false);
|
||||
|
||||
if (!bible) return null;
|
||||
|
||||
const handleUpdateHost = (field: string, value: any) => {
|
||||
@@ -51,136 +53,157 @@ export const PodcastBiblePanel: React.FC<PodcastBiblePanelProps> = ({ bible, onU
|
||||
|
||||
return (
|
||||
<Box sx={{ mt: 2 }}>
|
||||
<Stack direction="row" alignItems="center" spacing={1} sx={{ mb: 2 }}>
|
||||
<AutoFixHighIcon color="primary" />
|
||||
<Typography variant="h6" fontWeight="bold" color="#1e293b">
|
||||
Podcast Bible
|
||||
</Typography>
|
||||
<Tooltip title="Hyper-personalized context derived from your onboarding data. This grounds all research and script generation.">
|
||||
<IconButton size="small">
|
||||
<InfoIcon fontSize="small" sx={{ color: '#94a3b8' }} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Stack>
|
||||
<Accordion
|
||||
expanded={panelExpanded}
|
||||
onChange={() => setPanelExpanded(!panelExpanded)}
|
||||
sx={{ borderRadius: 2, '&:before': { display: 'none' }, boxShadow: '0 1px 3px rgba(0,0,0,0.1)' }}
|
||||
>
|
||||
<AccordionSummary
|
||||
expandIcon={<ExpandMoreIcon />}
|
||||
sx={{
|
||||
bgcolor: panelExpanded ? 'rgba(99,102,241,0.05)' : 'transparent',
|
||||
borderRadius: panelExpanded ? '16px 16px 0 0' : 2,
|
||||
}}
|
||||
>
|
||||
<Stack direction="row" alignItems="center" spacing={1} sx={{ width: '100%' }}>
|
||||
<AutoFixHighIcon color="primary" />
|
||||
<Typography variant="h6" fontWeight="bold" color="#1e293b" sx={{ flex: 1 }}>
|
||||
Podcast Bible
|
||||
</Typography>
|
||||
{!panelExpanded && (
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Host • Audience • Brand
|
||||
</Typography>
|
||||
)}
|
||||
<Tooltip title="Hyper-personalized context derived from your onboarding data. This grounds all research and script generation.">
|
||||
<IconButton size="small" onClick={(e) => e.stopPropagation()}>
|
||||
<InfoIcon fontSize="small" sx={{ color: '#94a3b8' }} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Stack>
|
||||
</AccordionSummary>
|
||||
|
||||
<AccordionDetails sx={{ bgcolor: 'rgba(99,102,241,0.02)' }}>
|
||||
<Stack spacing={2}>
|
||||
{/* Host Persona */}
|
||||
<Accordion sx={{ borderRadius: 2, '&:before': { display: 'none' }, boxShadow: '0 1px 3px rgba(0,0,0,0.08)', bgcolor: '#fff' }}>
|
||||
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
|
||||
<Stack direction="row" spacing={1} alignItems="center">
|
||||
<PsychologyIcon sx={{ color: '#6366f1' }} />
|
||||
<Typography fontWeight="600">Host Persona</Typography>
|
||||
</Stack>
|
||||
</AccordionSummary>
|
||||
<AccordionDetails>
|
||||
<Stack spacing={2}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Host Background"
|
||||
size="small"
|
||||
value={bible.host?.background || ''}
|
||||
onChange={(e) => handleUpdateHost('background', e.target.value)}
|
||||
multiline
|
||||
rows={2}
|
||||
/>
|
||||
<Stack direction="row" spacing={2}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Expertise Level"
|
||||
size="small"
|
||||
value={bible.host?.expertise_level || ''}
|
||||
onChange={(e) => handleUpdateHost('expertise_level', e.target.value)}
|
||||
/>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Vocal Style"
|
||||
size="small"
|
||||
value={bible.host?.vocal_style || ''}
|
||||
onChange={(e) => handleUpdateHost('vocal_style', e.target.value)}
|
||||
/>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
|
||||
<Stack spacing={2}>
|
||||
{/* Host Persona */}
|
||||
<Accordion defaultExpanded sx={{ borderRadius: 2, '&:before': { display: 'none' }, boxShadow: '0 1px 3px rgba(0,0,0,0.1)' }}>
|
||||
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
|
||||
<Stack direction="row" spacing={1} alignItems="center">
|
||||
<PsychologyIcon sx={{ color: '#6366f1' }} />
|
||||
<Typography fontWeight="600">Host Persona</Typography>
|
||||
</Stack>
|
||||
</AccordionSummary>
|
||||
<AccordionDetails>
|
||||
<Stack spacing={2}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Host Background"
|
||||
size="small"
|
||||
value={bible.host?.background || ''}
|
||||
onChange={(e) => handleUpdateHost('background', e.target.value)}
|
||||
multiline
|
||||
rows={2}
|
||||
/>
|
||||
<Stack direction="row" spacing={2}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Expertise Level"
|
||||
size="small"
|
||||
value={bible.host?.expertise_level || ''}
|
||||
onChange={(e) => handleUpdateHost('expertise_level', e.target.value)}
|
||||
/>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Vocal Style"
|
||||
size="small"
|
||||
value={bible.host?.vocal_style || ''}
|
||||
onChange={(e) => handleUpdateHost('vocal_style', e.target.value)}
|
||||
/>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
{/* Audience DNA */}
|
||||
<Accordion sx={{ borderRadius: 2, '&:before': { display: 'none' }, boxShadow: '0 1px 3px rgba(0,0,0,0.1)' }}>
|
||||
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
|
||||
<Stack direction="row" spacing={1} alignItems="center">
|
||||
<GroupsIcon sx={{ color: '#ec4899' }} />
|
||||
<Typography fontWeight="600">Audience DNA</Typography>
|
||||
</Stack>
|
||||
</AccordionSummary>
|
||||
<AccordionDetails>
|
||||
<Stack spacing={2}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Audience Expertise"
|
||||
size="small"
|
||||
value={bible.audience?.expertise_level || ''}
|
||||
onChange={(e) => handleUpdateAudience('expertise_level', e.target.value)}
|
||||
/>
|
||||
<Box>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ mb: 1, display: 'block' }}>
|
||||
Interests
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5 }}>
|
||||
{bible.audience?.interests?.map((interest: string, idx: number) => (
|
||||
<Chip key={idx} label={interest} size="small" variant="outlined" />
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
<Box>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ mb: 1, display: 'block' }}>
|
||||
Pain Points
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5 }}>
|
||||
{bible.audience?.pain_points?.map((point: string, idx: number) => (
|
||||
<Chip key={idx} label={point} size="small" color="error" variant="outlined" />
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
</Stack>
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
|
||||
{/* Audience DNA */}
|
||||
<Accordion sx={{ borderRadius: 2, '&:before': { display: 'none' }, boxShadow: '0 1px 3px rgba(0,0,0,0.1)' }}>
|
||||
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
|
||||
<Stack direction="row" spacing={1} alignItems="center">
|
||||
<GroupsIcon sx={{ color: '#ec4899' }} />
|
||||
<Typography fontWeight="600">Audience DNA</Typography>
|
||||
</Stack>
|
||||
</AccordionSummary>
|
||||
<AccordionDetails>
|
||||
<Stack spacing={2}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Audience Expertise"
|
||||
size="small"
|
||||
value={bible.audience?.expertise_level || ''}
|
||||
onChange={(e) => handleUpdateAudience('expertise_level', e.target.value)}
|
||||
/>
|
||||
<Box>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ mb: 1, display: 'block' }}>
|
||||
Interests
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5 }}>
|
||||
{bible.audience?.interests?.map((interest: string, idx: number) => (
|
||||
<Chip key={idx} label={interest} size="small" variant="outlined" />
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
<Box>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ mb: 1, display: 'block' }}>
|
||||
Pain Points
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5 }}>
|
||||
{bible.audience?.pain_points?.map((point: string, idx: number) => (
|
||||
<Chip key={idx} label={point} size="small" color="error" variant="outlined" />
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
</Stack>
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
|
||||
{/* Brand DNA */}
|
||||
<Accordion sx={{ borderRadius: 2, '&:before': { display: 'none' }, boxShadow: '0 1px 3px rgba(0,0,0,0.1)' }}>
|
||||
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
|
||||
<Stack direction="row" spacing={1} alignItems="center">
|
||||
<BrandIcon sx={{ color: '#10b981' }} />
|
||||
<Typography fontWeight="600">Brand DNA</Typography>
|
||||
</Stack>
|
||||
</AccordionSummary>
|
||||
<AccordionDetails>
|
||||
<Stack spacing={2}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Industry"
|
||||
size="small"
|
||||
value={bible.brand?.industry || ''}
|
||||
onChange={(e) => handleUpdateBrand('industry', e.target.value)}
|
||||
/>
|
||||
<Stack direction="row" spacing={2}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Tone"
|
||||
size="small"
|
||||
value={bible.brand?.tone || ''}
|
||||
onChange={(e) => handleUpdateBrand('tone', e.target.value)}
|
||||
/>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Style"
|
||||
size="small"
|
||||
value={bible.brand?.communication_style || ''}
|
||||
onChange={(e) => handleUpdateBrand('communication_style', e.target.value)}
|
||||
/>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
</Stack>
|
||||
{/* Brand DNA */}
|
||||
<Accordion sx={{ borderRadius: 2, '&:before': { display: 'none' }, boxShadow: '0 1px 3px rgba(0,0,0,0.1)' }}>
|
||||
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
|
||||
<Stack direction="row" spacing={1} alignItems="center">
|
||||
<BrandIcon sx={{ color: '#10b981' }} />
|
||||
<Typography fontWeight="600">Brand DNA</Typography>
|
||||
</Stack>
|
||||
</AccordionSummary>
|
||||
<AccordionDetails>
|
||||
<Stack spacing={2}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Industry"
|
||||
size="small"
|
||||
value={bible.brand?.industry || ''}
|
||||
onChange={(e) => handleUpdateBrand('industry', e.target.value)}
|
||||
/>
|
||||
<Stack direction="row" spacing={2}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Tone"
|
||||
size="small"
|
||||
value={bible.brand?.tone || ''}
|
||||
onChange={(e) => handleUpdateBrand('tone', e.target.value)}
|
||||
/>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Style"
|
||||
size="small"
|
||||
value={bible.brand?.communication_style || ''}
|
||||
onChange={(e) => handleUpdateBrand('communication_style', e.target.value)}
|
||||
/>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
</Stack>
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useState, useCallback } from "react";
|
||||
import { Box, Paper, Stack, Alert, Divider, CircularProgress, alpha } from "@mui/material";
|
||||
import { Box, Paper, Stack, Alert, Divider, CircularProgress, alpha, Dialog, DialogTitle, DialogContent, DialogActions, Button, Typography } from "@mui/material";
|
||||
import { usePodcastProjectState } from "../../hooks/usePodcastProjectState";
|
||||
import { CreateModal } from "./CreateModal";
|
||||
import { AnalysisPanel } from "./AnalysisPanel";
|
||||
@@ -78,7 +78,7 @@ const PodcastDashboard: React.FC = () => {
|
||||
}, [resetState]);
|
||||
|
||||
if (showProjectList) {
|
||||
return <ProjectList onSelectProject={handleSelectProject} />;
|
||||
return <ProjectList onSelectProject={handleSelectProject} onBack={() => setShowProjectList(false)} />;
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -177,12 +177,12 @@ const PodcastDashboard: React.FC = () => {
|
||||
{/* Announcements */}
|
||||
{workflow.announcement && (
|
||||
<Alert
|
||||
severity="info"
|
||||
severity={workflow.announcementSeverity || "info"}
|
||||
onClose={() => workflow.setAnnouncement("")}
|
||||
sx={{
|
||||
background: "#dbeafe",
|
||||
border: "1px solid #bfdbfe",
|
||||
"& .MuiAlert-icon": { color: "#3b82f6" },
|
||||
background: workflow.announcementSeverity === "error" ? "#fef2f2" : workflow.announcementSeverity === "success" ? "#f0fdf4" : "#dbeafe",
|
||||
border: `1px solid ${workflow.announcementSeverity === "error" ? "#fecaca" : workflow.announcementSeverity === "success" ? "#bbf7d0" : "#bfdbfe"}`,
|
||||
"& .MuiAlert-icon": { color: workflow.announcementSeverity === "error" ? "#ef4444" : workflow.announcementSeverity === "success" ? "#22c55e" : "#3b82f6" },
|
||||
}}
|
||||
>
|
||||
{workflow.announcement}
|
||||
@@ -197,19 +197,13 @@ const PodcastDashboard: React.FC = () => {
|
||||
/>
|
||||
)}
|
||||
|
||||
{(workflow.isAnalyzing || workflow.isResearching) && (
|
||||
<Alert
|
||||
severity="warning"
|
||||
icon={<CircularProgress size={20} />}
|
||||
sx={{
|
||||
background: "#fef3c7",
|
||||
border: "1px solid #fde68a",
|
||||
}}
|
||||
>
|
||||
<Box component="span" sx={{ fontSize: "0.875rem" }}>
|
||||
{workflow.isAnalyzing ? "Analyzing your idea with AI..." : "Running research... This may take a moment."}
|
||||
</Box>
|
||||
</Alert>
|
||||
{(workflow.isAnalyzing || workflow.isResearching || workflow.isGeneratingScript) && (
|
||||
<Stack direction="row" spacing={2} alignItems="center" sx={{ py: 1.5 }}>
|
||||
<CircularProgress size={20} sx={{ color: "#667eea" }} />
|
||||
<Typography variant="body2" sx={{ color: "#64748b" }}>
|
||||
{workflow.isAnalyzing ? "Analyzing your idea with AI..." : workflow.isGeneratingScript ? "Generating script with AI..." : "Running research... This may take a moment."}
|
||||
</Typography>
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{/* Create Modal */}
|
||||
@@ -238,6 +232,11 @@ const PodcastDashboard: React.FC = () => {
|
||||
avatarPrompt={project?.avatarPrompt}
|
||||
onRegenerate={() => setShowRegenModal(true)}
|
||||
onUpdateAnalysis={(updated) => projectState.setAnalysis(updated)}
|
||||
onRunResearch={() => workflow.handleRunResearch()}
|
||||
isResearchRunning={workflow.isResearching}
|
||||
selectedQueries={selectedQueries}
|
||||
onToggleQuery={workflow.toggleQuery}
|
||||
queries={queries}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -251,6 +250,11 @@ const PodcastDashboard: React.FC = () => {
|
||||
onToggleQuery={workflow.toggleQuery}
|
||||
onProviderChange={setResearchProvider}
|
||||
onRunResearch={workflow.handleRunResearch}
|
||||
onRegenerateQueries={workflow.handleRegenerateQueries}
|
||||
onUpdateQuery={workflow.handleUpdateQuery}
|
||||
onDeleteQuery={workflow.handleDeleteQuery}
|
||||
analysis={analysis}
|
||||
idea={project?.idea || ""}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -259,6 +263,7 @@ const PodcastDashboard: React.FC = () => {
|
||||
research={research}
|
||||
canGenerateScript={workflow.canGenerateScript}
|
||||
onGenerateScript={workflow.handleGenerateScript}
|
||||
isGeneratingScript={workflow.isGeneratingScript}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -332,6 +337,55 @@ const PodcastDashboard: React.FC = () => {
|
||||
}}
|
||||
isSubmitting={workflow.isAnalyzing}
|
||||
/>
|
||||
|
||||
{/* Duplicate Project Dialog */}
|
||||
<Dialog
|
||||
open={workflow.showDuplicateDialog}
|
||||
onClose={() => workflow.setShowDuplicateDialog(false)}
|
||||
maxWidth="sm"
|
||||
fullWidth
|
||||
PaperProps={{
|
||||
sx: {
|
||||
background: "linear-gradient(135deg, #1e293b 0%, #0f172a 100%)",
|
||||
border: "1px solid rgba(167, 139, 250, 0.3)",
|
||||
borderRadius: 3,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<DialogTitle sx={{ color: "#fff", display: "flex", alignItems: "center", gap: 1 }}>
|
||||
Duplicate Project Found
|
||||
</DialogTitle>
|
||||
<DialogContent sx={{ color: "rgba(255,255,255,0.8)" }}>
|
||||
<Alert severity="warning" sx={{ mb: 2, bgcolor: "rgba(245,158,11,0.1)", border: "1px solid rgba(245,158,11,0.3)" }}>
|
||||
A project with a similar idea already exists. You can edit the existing project or create a new one (which will overwrite the previous).
|
||||
</Alert>
|
||||
<Box sx={{ p: 2, bgcolor: "rgba(255,255,255,0.05)", borderRadius: 2 }}>
|
||||
<strong style={{ color: "#fff" }}>Existing project idea:</strong>
|
||||
<p style={{ color: "rgba(255,255,255,0.7)", marginTop: 8 }}>
|
||||
{workflow.duplicateProjectInfo.idea}
|
||||
</p>
|
||||
</Box>
|
||||
</DialogContent>
|
||||
<DialogActions sx={{ px: 3, pb: 3 }}>
|
||||
<Button
|
||||
onClick={() => {
|
||||
workflow.setShowDuplicateDialog(false);
|
||||
// Load existing project
|
||||
loadProjectFromDb(workflow.duplicateProjectInfo.projectId);
|
||||
}}
|
||||
sx={{ color: "#a78bfa" }}
|
||||
>
|
||||
Edit Existing
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => workflow.setShowDuplicateDialog(false)}
|
||||
variant="contained"
|
||||
sx={{ bgcolor: "#ef4444", "&:hover": { bgcolor: "#dc2626" } }}
|
||||
>
|
||||
Create New (Overwrite)
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React from "react";
|
||||
import React, { useState } from "react";
|
||||
import {
|
||||
Stack,
|
||||
Typography,
|
||||
@@ -16,11 +16,17 @@ import {
|
||||
MenuItem,
|
||||
Box,
|
||||
alpha,
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
TextField,
|
||||
IconButton,
|
||||
} from "@mui/material";
|
||||
import { Search as SearchIcon, AutoAwesome as AutoAwesomeIcon } from "@mui/icons-material";
|
||||
import { Search as SearchIcon, AutoAwesome as AutoAwesomeIcon, Refresh as RefreshIcon, Edit as EditIcon, Delete as DeleteIcon, CheckCircle as CheckCircleIcon } from "@mui/icons-material";
|
||||
import { ResearchProvider } from "../../../services/blogWriterApi";
|
||||
import { Query } from "../types";
|
||||
import { GlassyCard, glassyCardSx, PrimaryButton } from "../ui";
|
||||
import { GlassyCard, glassyCardSx, PrimaryButton, SecondaryButton } from "../ui";
|
||||
|
||||
interface QuerySelectionProps {
|
||||
queries: Query[];
|
||||
@@ -30,6 +36,11 @@ interface QuerySelectionProps {
|
||||
onToggleQuery: (id: string) => void;
|
||||
onProviderChange: (provider: ResearchProvider) => void;
|
||||
onRunResearch: () => void;
|
||||
onRegenerateQueries: (feedback: string) => Promise<void>;
|
||||
onUpdateQuery: (id: string, newQuery: string, newRationale: string) => void;
|
||||
onDeleteQuery: (id: string) => void;
|
||||
analysis: any;
|
||||
idea: string;
|
||||
}
|
||||
|
||||
export const QuerySelection: React.FC<QuerySelectionProps> = ({
|
||||
@@ -40,9 +51,51 @@ export const QuerySelection: React.FC<QuerySelectionProps> = ({
|
||||
onToggleQuery,
|
||||
onProviderChange,
|
||||
onRunResearch,
|
||||
onRegenerateQueries,
|
||||
onUpdateQuery,
|
||||
onDeleteQuery,
|
||||
analysis,
|
||||
idea,
|
||||
}) => {
|
||||
const [showRegenDialog, setShowRegenDialog] = useState(false);
|
||||
const [regenFeedback, setRegenFeedback] = useState("");
|
||||
const [isRegenerating, setIsRegenerating] = useState(false);
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
const [editQuery, setEditQuery] = useState("");
|
||||
const [editRationale, setEditRationale] = useState("");
|
||||
const selectedCount = selectedQueries.size;
|
||||
|
||||
const handleRegenerate = async () => {
|
||||
if (!regenFeedback.trim()) return;
|
||||
setIsRegenerating(true);
|
||||
try {
|
||||
await onRegenerateQueries(regenFeedback);
|
||||
setShowRegenDialog(false);
|
||||
setRegenFeedback("");
|
||||
} finally {
|
||||
setIsRegenerating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const startEdit = (q: Query) => {
|
||||
setEditingId(q.id);
|
||||
setEditQuery(q.query);
|
||||
setEditRationale(q.rationale);
|
||||
};
|
||||
|
||||
const saveEdit = () => {
|
||||
if (editingId && editQuery.trim()) {
|
||||
onUpdateQuery(editingId, editQuery.trim(), editRationale.trim());
|
||||
setEditingId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const cancelEdit = () => {
|
||||
setEditingId(null);
|
||||
setEditQuery("");
|
||||
setEditRationale("");
|
||||
};
|
||||
|
||||
return (
|
||||
<GlassyCard
|
||||
sx={{
|
||||
@@ -55,10 +108,22 @@ export const QuerySelection: React.FC<QuerySelectionProps> = ({
|
||||
>
|
||||
<Stack spacing={3}>
|
||||
<Stack direction="row" justifyContent="space-between" alignItems="center" flexWrap="wrap" gap={2}>
|
||||
<Typography variant="h6" sx={{ display: "flex", alignItems: "center", gap: 1, color: "#0f172a", fontWeight: 700 }}>
|
||||
<SearchIcon />
|
||||
Research Queries
|
||||
</Typography>
|
||||
<Stack direction="row" alignItems="center" spacing={1}>
|
||||
<Typography variant="h6" sx={{ display: "flex", alignItems: "center", gap: 1, color: "#0f172a", fontWeight: 700 }}>
|
||||
<SearchIcon />
|
||||
Research Queries
|
||||
</Typography>
|
||||
<Tooltip title="Regenerate research queries with custom feedback">
|
||||
<PrimaryButton
|
||||
size="small"
|
||||
startIcon={<RefreshIcon />}
|
||||
onClick={() => setShowRegenDialog(true)}
|
||||
sx={{ py: 0.5, px: 1.5, fontSize: "0.75rem" }}
|
||||
>
|
||||
Regenerate
|
||||
</PrimaryButton>
|
||||
</Tooltip>
|
||||
</Stack>
|
||||
<Stack direction="row" spacing={2} alignItems="center">
|
||||
<FormControl size="small" sx={{ minWidth: 180 }}>
|
||||
<InputLabel>Provider</InputLabel>
|
||||
@@ -123,26 +188,70 @@ export const QuerySelection: React.FC<QuerySelectionProps> = ({
|
||||
|
||||
<List>
|
||||
{queries.map((q) => (
|
||||
<ListItem key={q.id} disablePadding>
|
||||
<ListItemButton
|
||||
onClick={() => onToggleQuery(q.id)}
|
||||
disabled={isResearching}
|
||||
sx={{
|
||||
borderRadius: 2,
|
||||
mb: 1,
|
||||
border: "1px solid rgba(0,0,0,0.08)",
|
||||
background: "#f8fafc",
|
||||
"&:hover": { background: alpha("#667eea", 0.08) },
|
||||
}}
|
||||
>
|
||||
<Checkbox checked={selectedQueries.has(q.id)} edge="start" />
|
||||
<ListItemText
|
||||
primary={q.query}
|
||||
secondary={q.rationale}
|
||||
primaryTypographyProps={{ variant: "body2", fontWeight: 600, color: "#0f172a" }}
|
||||
secondaryTypographyProps={{ variant: "caption", sx: { color: "#475569" } }}
|
||||
/>
|
||||
</ListItemButton>
|
||||
<ListItem
|
||||
key={q.id}
|
||||
disablePadding
|
||||
secondaryAction={
|
||||
editingId === q.id ? (
|
||||
<Stack direction="row" spacing={0.5}>
|
||||
<IconButton size="small" onClick={saveEdit} sx={{ color: "#22c55e" }}>
|
||||
<CheckCircleIcon />
|
||||
</IconButton>
|
||||
<IconButton size="small" onClick={cancelEdit} sx={{ color: "#ef4444" }}>
|
||||
<DeleteIcon />
|
||||
</IconButton>
|
||||
</Stack>
|
||||
) : (
|
||||
<Stack direction="row" spacing={0.5} onClick={(e) => e.stopPropagation()}>
|
||||
<IconButton size="small" onClick={() => startEdit(q)} sx={{ color: "#6366f1" }}>
|
||||
<EditIcon />
|
||||
</IconButton>
|
||||
<IconButton size="small" onClick={() => onDeleteQuery(q.id)} sx={{ color: "#ef4444" }}>
|
||||
<DeleteIcon />
|
||||
</IconButton>
|
||||
</Stack>
|
||||
)
|
||||
}
|
||||
>
|
||||
{editingId === q.id ? (
|
||||
<Box sx={{ width: "100%", p: 1.5, bgcolor: "#f0f9ff", borderRadius: 2, border: "1px solid #bae6fd" }}>
|
||||
<TextField
|
||||
fullWidth
|
||||
size="small"
|
||||
label="Query"
|
||||
value={editQuery}
|
||||
onChange={(e) => setEditQuery(e.target.value)}
|
||||
sx={{ mb: 1 }}
|
||||
/>
|
||||
<TextField
|
||||
fullWidth
|
||||
size="small"
|
||||
label="Rationale"
|
||||
value={editRationale}
|
||||
onChange={(e) => setEditRationale(e.target.value)}
|
||||
/>
|
||||
</Box>
|
||||
) : (
|
||||
<ListItemButton
|
||||
onClick={() => onToggleQuery(q.id)}
|
||||
disabled={isResearching}
|
||||
sx={{
|
||||
borderRadius: 2,
|
||||
mb: 1,
|
||||
border: "1px solid rgba(0,0,0,0.08)",
|
||||
background: selectedQueries.has(q.id) ? alpha("#667eea", 0.08) : "#f8fafc",
|
||||
"&:hover": { background: alpha("#667eea", 0.12) },
|
||||
}}
|
||||
>
|
||||
<Checkbox checked={selectedQueries.has(q.id)} edge="start" />
|
||||
<ListItemText
|
||||
primary={q.query}
|
||||
secondary={q.rationale}
|
||||
primaryTypographyProps={{ variant: "body2", fontWeight: 600, color: "#0f172a" }}
|
||||
secondaryTypographyProps={{ variant: "caption", sx: { color: "#475569" } }}
|
||||
/>
|
||||
</ListItemButton>
|
||||
)}
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
@@ -163,6 +272,69 @@ export const QuerySelection: React.FC<QuerySelectionProps> = ({
|
||||
</PrimaryButton>
|
||||
</Box>
|
||||
</Stack>
|
||||
|
||||
{/* Regenerate Queries Dialog */}
|
||||
<Dialog
|
||||
open={showRegenDialog}
|
||||
onClose={() => setShowRegenDialog(false)}
|
||||
maxWidth="sm"
|
||||
fullWidth
|
||||
PaperProps={{
|
||||
sx: {
|
||||
background: "linear-gradient(135deg, #1e293b 0%, #0f172a 100%)",
|
||||
border: "1px solid rgba(167, 139, 250, 0.3)",
|
||||
borderRadius: 3,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<DialogTitle sx={{ color: "#fff", display: "flex", alignItems: "center", gap: 1 }}>
|
||||
<RefreshIcon sx={{ color: "#a78bfa" }} />
|
||||
Regenerate Research Queries
|
||||
</DialogTitle>
|
||||
<DialogContent sx={{ color: "rgba(255,255,255,0.8)" }}>
|
||||
<Typography variant="body2" sx={{ mb: 2, color: "rgba(255,255,255,0.7)" }}>
|
||||
Provide custom directions to regenerate research queries. You can specify:
|
||||
</Typography>
|
||||
<Box sx={{ pl: 2, mb: 2 }}>
|
||||
<Typography variant="caption" sx={{ color: "rgba(255,255,255,0.6)", display: "block", mb: 0.5 }}>
|
||||
• Specific topics or angles you want to explore
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ color: "rgba(255,255,255,0.6)", display: "block", mb: 0.5 }}>
|
||||
• Questions you want answered
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ color: "rgba(255,255,255,0.6)", display: "block", mb: 0.5 }}>
|
||||
• Areas where you need more depth
|
||||
</Typography>
|
||||
</Box>
|
||||
<TextField
|
||||
fullWidth
|
||||
multiline
|
||||
rows={4}
|
||||
placeholder="e.g., I want to focus more on competitive landscape and pricing strategies. Also need stats on market growth in 2025..."
|
||||
value={regenFeedback}
|
||||
onChange={(e) => setRegenFeedback(e.target.value)}
|
||||
sx={{
|
||||
"& .MuiOutlinedInput-root": {
|
||||
color: "#fff",
|
||||
"& fieldset": { borderColor: "rgba(255,255,255,0.2)" },
|
||||
"&:hover fieldset": { borderColor: "rgba(255,255,255,0.3)" },
|
||||
"&.Mui-focused fieldset": { borderColor: "#a78bfa" },
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</DialogContent>
|
||||
<DialogActions sx={{ px: 3, pb: 3 }}>
|
||||
<SecondaryButton onClick={() => setShowRegenDialog(false)}>Cancel</SecondaryButton>
|
||||
<PrimaryButton
|
||||
onClick={handleRegenerate}
|
||||
disabled={!regenFeedback.trim() || isRegenerating}
|
||||
loading={isRegenerating}
|
||||
startIcon={<RefreshIcon />}
|
||||
>
|
||||
Generate New Queries
|
||||
</PrimaryButton>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</GlassyCard>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useMemo, useCallback } from "react";
|
||||
import { Stack, Typography, Chip, Divider, Box, alpha, Paper } from "@mui/material";
|
||||
import { Stack, Typography, Chip, Divider, Box, alpha, Paper, Stepper, Step, StepLabel, CircularProgress } from "@mui/material";
|
||||
import {
|
||||
Insights as InsightsIcon,
|
||||
Search as SearchIcon,
|
||||
@@ -7,21 +7,26 @@ import {
|
||||
EditNote as EditNoteIcon,
|
||||
Article as ArticleIcon,
|
||||
AutoAwesome as AutoAwesomeIcon,
|
||||
ArrowForward as ArrowForwardIcon,
|
||||
CheckCircle as CheckCircleIcon,
|
||||
} from "@mui/icons-material";
|
||||
import { Research, ResearchInsight } from "../types";
|
||||
import { GlassyCard, glassyCardSx, PrimaryButton } from "../ui";
|
||||
import { FactCard } from "../FactCard";
|
||||
import { TextToSpeechButton } from "../../shared/TextToSpeechButton";
|
||||
|
||||
interface ResearchSummaryProps {
|
||||
research: Research;
|
||||
canGenerateScript: boolean;
|
||||
onGenerateScript: () => void;
|
||||
isGeneratingScript?: boolean;
|
||||
}
|
||||
|
||||
export const ResearchSummary: React.FC<ResearchSummaryProps> = ({
|
||||
research,
|
||||
canGenerateScript,
|
||||
onGenerateScript,
|
||||
isGeneratingScript = false,
|
||||
}) => {
|
||||
// Simple markdown-to-HTML converter
|
||||
const renderMarkdown = useCallback((text: string) => {
|
||||
@@ -51,6 +56,34 @@ export const ResearchSummary: React.FC<ResearchSummaryProps> = ({
|
||||
return (
|
||||
<GlassyCard sx={glassyCardSx}>
|
||||
<Stack spacing={3}>
|
||||
{/* Step Indicator */}
|
||||
<Box sx={{ mb: 1 }}>
|
||||
<Stepper activeStep={1} alternativeLabel>
|
||||
<Step completed>
|
||||
<StepLabel
|
||||
StepIconComponent={() => <CheckCircleIcon sx={{ color: "#22c55e", fontSize: 24 }} />}
|
||||
>
|
||||
Analysis
|
||||
</StepLabel>
|
||||
</Step>
|
||||
<Step active>
|
||||
<StepLabel>
|
||||
Research
|
||||
</StepLabel>
|
||||
</Step>
|
||||
<Step>
|
||||
<StepLabel>
|
||||
Script
|
||||
</StepLabel>
|
||||
</Step>
|
||||
<Step>
|
||||
<StepLabel>
|
||||
Render
|
||||
</StepLabel>
|
||||
</Step>
|
||||
</Stepper>
|
||||
</Box>
|
||||
|
||||
<Stack direction="row" justifyContent="space-between" alignItems="center" flexWrap="wrap" gap={2}>
|
||||
<Stack direction="row" alignItems="center" spacing={2} sx={{ flex: 1 }}>
|
||||
<Typography variant="h6" sx={{ display: "flex", alignItems: "center", gap: 1, color: "#0f172a", fontWeight: 700 }}>
|
||||
@@ -115,11 +148,31 @@ export const ResearchSummary: React.FC<ResearchSummaryProps> = ({
|
||||
|
||||
<PrimaryButton
|
||||
onClick={onGenerateScript}
|
||||
disabled={!canGenerateScript}
|
||||
startIcon={<EditNoteIcon />}
|
||||
disabled={!canGenerateScript || isGeneratingScript}
|
||||
startIcon={isGeneratingScript ? <CircularProgress size={18} color="inherit" /> : <EditNoteIcon />}
|
||||
endIcon={isGeneratingScript ? undefined : <ArrowForwardIcon />}
|
||||
tooltip={!canGenerateScript ? "Complete research to generate script" : "Generate AI-powered script from research"}
|
||||
sx={{
|
||||
background: "linear-gradient(135deg, #667eea 0%, #764ba2 100%)",
|
||||
color: "#fff",
|
||||
fontWeight: 700,
|
||||
fontSize: "1rem",
|
||||
px: 4,
|
||||
py: 1.5,
|
||||
borderRadius: 2,
|
||||
textTransform: "none",
|
||||
boxShadow: "0 4px 14px rgba(102, 126, 234, 0.4)",
|
||||
"&:hover": {
|
||||
background: "linear-gradient(135deg, #764ba2 0%, #667eea 100%)",
|
||||
boxShadow: "0 6px 20px rgba(102, 126, 234, 0.5)",
|
||||
},
|
||||
"&:disabled": {
|
||||
background: "#94a3b8",
|
||||
boxShadow: "none",
|
||||
}
|
||||
}}
|
||||
>
|
||||
Generate Script
|
||||
{isGeneratingScript ? "Generating Script..." : "Generate Script to Continue"}
|
||||
</PrimaryButton>
|
||||
</Stack>
|
||||
|
||||
@@ -139,6 +192,9 @@ export const ResearchSummary: React.FC<ResearchSummaryProps> = ({
|
||||
<Typography variant="subtitle2" sx={{ mb: 1.5, color: "#64748b", fontWeight: 700, fontSize: "0.75rem", textTransform: "uppercase", letterSpacing: "0.05em", display: "flex", alignItems: "center", gap: 1 }}>
|
||||
<AutoAwesomeIcon fontSize="small" sx={{ color: "#667eea", fontSize: "1rem" }} />
|
||||
Executive Summary
|
||||
<Box sx={{ ml: 'auto' }}>
|
||||
<TextToSpeechButton text={research.summary} size="small" showSettings />
|
||||
</Box>
|
||||
</Typography>
|
||||
<Box sx={{
|
||||
lineHeight: 1.6,
|
||||
|
||||
@@ -5,6 +5,9 @@ import { useBudgetTracking } from "../../../hooks/useBudgetTracking";
|
||||
import { CreateProjectPayload, Script } from "../types";
|
||||
import { usePodcastProjectState } from "../../../hooks/usePodcastProjectState";
|
||||
import { sanitizeExaConfig, announceError, getStepLabel } from "./utils";
|
||||
import { clearSceneMediaCache, clearMediaCache } from "../../../utils/mediaCache";
|
||||
|
||||
const createId = (prefix: string) => `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`;
|
||||
|
||||
type PodcastProjectStateReturn = ReturnType<typeof usePodcastProjectState>;
|
||||
|
||||
@@ -41,17 +44,22 @@ export const usePodcastWorkflow = ({ projectState, onError }: UsePodcastWorkflow
|
||||
setResearchProvider,
|
||||
setBudgetCap,
|
||||
updateRenderJob,
|
||||
setRenderJobs,
|
||||
initializeProject,
|
||||
setBible,
|
||||
} = projectState;
|
||||
|
||||
const [isAnalyzing, setIsAnalyzing] = useState(false);
|
||||
const [isResearching, setIsResearching] = useState(false);
|
||||
const [isGeneratingScript, setIsGeneratingScript] = useState(false);
|
||||
const [announcement, setAnnouncement] = useState("");
|
||||
const [announcementSeverity, setAnnouncementSeverity] = useState<"info" | "error" | "success">("info");
|
||||
const [showResumeAlert, setShowResumeAlert] = useState(false);
|
||||
const [showPreflightDialog, setShowPreflightDialog] = useState(false);
|
||||
const [preflightResponse, setPreflightResponse] = useState<any>(null);
|
||||
const [preflightOperationName, setPreflightOperationName] = useState<string>("");
|
||||
const [showDuplicateDialog, setShowDuplicateDialog] = useState(false);
|
||||
const [duplicateProjectInfo, setDuplicateProjectInfo] = useState<{projectId: string; idea: string}>({ projectId: "", idea: "" });
|
||||
|
||||
const budgetTracking = useBudgetTracking(budgetCap || 50);
|
||||
const preflightCheck = usePreflightCheck({
|
||||
@@ -112,7 +120,27 @@ export const usePodcastWorkflow = ({ projectState, onError }: UsePodcastWorkflow
|
||||
// This allows the analysis to be personalized using the Bible context
|
||||
const projectId = project?.id || `podcast_${Date.now()}_${Math.floor(Math.random() * 1000)}`;
|
||||
setAnnouncement("Initializing project and brand context...");
|
||||
const dbProject = project ? null : await initializeProject(payload, projectId, avatarUrl);
|
||||
|
||||
let dbProject: any = null;
|
||||
try {
|
||||
dbProject = project ? null : await initializeProject(payload, projectId, avatarUrl);
|
||||
} catch (initError: any) {
|
||||
const errorStr = initError?.message || "";
|
||||
if (errorStr.includes("DUPLICATE_IDEA")) {
|
||||
try {
|
||||
const dupData = JSON.parse(errorStr);
|
||||
const existingId = dupData.existing_project_id;
|
||||
const existingIdea = dupData.existing_idea;
|
||||
setAnnouncement("");
|
||||
// Throw error to trigger UI modal
|
||||
throw new Error(`DUPLICATE_IDEA:${existingId}:${existingIdea}`);
|
||||
} catch (parseErr) {
|
||||
console.error("Failed to parse duplicate idea error:", parseErr);
|
||||
}
|
||||
}
|
||||
throw initError;
|
||||
}
|
||||
|
||||
const bible = dbProject?.bible || projectState.bible;
|
||||
|
||||
setAnnouncement(feedback ? "Regenerating analysis using your feedback..." : "Analyzing your idea — AI suggestions incoming");
|
||||
@@ -130,7 +158,7 @@ export const usePodcastWorkflow = ({ projectState, onError }: UsePodcastWorkflow
|
||||
analysis: result.analysis,
|
||||
estimate: result.estimate,
|
||||
queries: result.queries,
|
||||
selected_queries: result.queries.map(q => q.id),
|
||||
selected_queries: [], // Don't auto-select - user must choose manually
|
||||
avatar_url: result.avatar_url,
|
||||
avatar_prompt: result.avatar_prompt,
|
||||
});
|
||||
@@ -151,7 +179,7 @@ export const usePodcastWorkflow = ({ projectState, onError }: UsePodcastWorkflow
|
||||
setAnalysis(result.analysis);
|
||||
setEstimate(result.estimate);
|
||||
setQueries(result.queries);
|
||||
setSelectedQueries(new Set(result.queries.map((q) => q.id)));
|
||||
setSelectedQueries(new Set()); // Start with none selected - user must choose manually
|
||||
setKnobs(payload.knobs);
|
||||
setBudgetCap(payload.budgetCap);
|
||||
|
||||
@@ -191,6 +219,18 @@ export const usePodcastWorkflow = ({ projectState, onError }: UsePodcastWorkflow
|
||||
setAnnouncement("Analysis complete");
|
||||
}
|
||||
} catch (error: any) {
|
||||
// Handle duplicate idea error
|
||||
const errorMessage = error?.message || String(error);
|
||||
if (errorMessage.startsWith("DUPLICATE_IDEA:")) {
|
||||
const parts = errorMessage.split(":");
|
||||
const existingId = parts[1] || "";
|
||||
const existingIdea = parts.slice(2).join(":") || "existing project";
|
||||
setAnnouncement("");
|
||||
setShowDuplicateDialog(true);
|
||||
setDuplicateProjectInfo({ projectId: existingId, idea: existingIdea });
|
||||
return;
|
||||
}
|
||||
|
||||
if (error?.response?.status === 429 || error?.response?.data?.detail) {
|
||||
const errorDetail = error.response.data.detail;
|
||||
if (typeof errorDetail === 'object' && errorDetail.error && errorDetail.error.includes('limit')) {
|
||||
@@ -216,10 +256,10 @@ export const usePodcastWorkflow = ({ projectState, onError }: UsePodcastWorkflow
|
||||
setAnnouncement("Subscription limit reached. Please upgrade to continue.");
|
||||
} else {
|
||||
const message = typeof errorDetail === 'string' ? errorDetail : errorDetail.message || errorDetail.error || 'Request limit exceeded';
|
||||
announceError(setAnnouncement, new Error(message));
|
||||
announceError(setAnnouncement, setAnnouncementSeverityFn, new Error(message));
|
||||
}
|
||||
} else {
|
||||
announceError(setAnnouncement, error);
|
||||
announceError(setAnnouncement, setAnnouncementSeverityFn, error);
|
||||
}
|
||||
} finally {
|
||||
setIsAnalyzing(false);
|
||||
@@ -239,6 +279,8 @@ export const usePodcastWorkflow = ({ projectState, onError }: UsePodcastWorkflow
|
||||
|
||||
setPreflightOperationName("Research");
|
||||
const approvedQueries = queries.filter((q) => selectedQueries.has(q.id));
|
||||
console.log('[Research] User selected queries:', Array.from(selectedQueries));
|
||||
console.log('[Research] Filtered approvedQueries for API:', approvedQueries.map(q => q.query));
|
||||
const preflightResult = await preflightCheck.check({
|
||||
provider: researchProvider === "exa" ? "exa" : "gemini",
|
||||
operation_type: researchProvider === "exa" ? "exa_neural_search" : "google_grounding",
|
||||
@@ -260,6 +302,8 @@ export const usePodcastWorkflow = ({ projectState, onError }: UsePodcastWorkflow
|
||||
setShowRenderQueue(false);
|
||||
|
||||
try {
|
||||
console.log('[Research] Starting research with:', { topic: project.idea, approvedQueries, provider: researchProvider });
|
||||
console.log('[Research] Calling podcastApi.runResearch...');
|
||||
const { research: mapped, raw } = await podcastApi.runResearch({
|
||||
projectId: project.id,
|
||||
topic: project.idea,
|
||||
@@ -272,6 +316,7 @@ export const usePodcastWorkflow = ({ projectState, onError }: UsePodcastWorkflow
|
||||
setAnnouncement(message);
|
||||
},
|
||||
});
|
||||
console.log('[Research] Response received:', { mapped, raw });
|
||||
setResearch(mapped);
|
||||
setRawResearch(raw);
|
||||
setAnnouncement("Research complete — review fact cards below");
|
||||
@@ -280,6 +325,7 @@ export const usePodcastWorkflow = ({ projectState, onError }: UsePodcastWorkflow
|
||||
? researchError.message
|
||||
: "Research failed. Please try again or switch to Standard Research.";
|
||||
|
||||
console.error('[Research] Error caught:', researchError);
|
||||
if (errorMessage.includes("Exa") || errorMessage.includes("exa")) {
|
||||
setAnnouncement(`Deep research failed: ${errorMessage}. Try Standard Research instead.`);
|
||||
} else if (errorMessage.includes("timeout")) {
|
||||
@@ -292,7 +338,7 @@ export const usePodcastWorkflow = ({ projectState, onError }: UsePodcastWorkflow
|
||||
throw researchError;
|
||||
}
|
||||
} catch (error) {
|
||||
announceError(setAnnouncement, error);
|
||||
announceError(setAnnouncement, setAnnouncementSeverityFn, error);
|
||||
} finally {
|
||||
setIsResearching(false);
|
||||
}
|
||||
@@ -320,8 +366,18 @@ export const usePodcastWorkflow = ({ projectState, onError }: UsePodcastWorkflow
|
||||
setScriptData(null);
|
||||
setShowRenderQueue(false);
|
||||
setShowScriptEditor(true);
|
||||
setIsGeneratingScript(true);
|
||||
setAnnouncement("Generating script with AI... Creating scenes and dialogue based on your research...");
|
||||
|
||||
try {
|
||||
console.log('[ScriptGen] Starting script generation with:', {
|
||||
idea: project.idea,
|
||||
speakers: project.speakers,
|
||||
duration: project.duration,
|
||||
hasResearch: !!rawResearch,
|
||||
hasOutline: !!analysis?.suggestedOutlines?.[0],
|
||||
});
|
||||
|
||||
const result = await podcastApi.generateScript({
|
||||
projectId: project.id,
|
||||
idea: project.idea,
|
||||
@@ -330,35 +386,55 @@ export const usePodcastWorkflow = ({ projectState, onError }: UsePodcastWorkflow
|
||||
speakers: project.speakers,
|
||||
durationMinutes: project.duration,
|
||||
bible: projectState.bible,
|
||||
outline: analysis?.suggestedOutlines?.[0], // Pass the first (possibly refined) outline
|
||||
analysis: analysis, // Pass full analysis context
|
||||
outline: analysis?.suggestedOutlines?.[0],
|
||||
analysis: analysis,
|
||||
onProgress: (message) => {
|
||||
console.log('[ScriptGen] Progress:', message);
|
||||
setAnnouncement(message);
|
||||
},
|
||||
});
|
||||
|
||||
console.log('[ScriptGen] Script generated:', { sceneCount: result.scenes?.length });
|
||||
setScriptData(result);
|
||||
setIsGeneratingScript(false);
|
||||
setAnnouncement("Script generated! Review and edit your scenes below.");
|
||||
} catch (error) {
|
||||
announceError(setAnnouncement, error);
|
||||
setIsGeneratingScript(false);
|
||||
announceError(setAnnouncement, setAnnouncementSeverityFn, error);
|
||||
}
|
||||
}, [showScriptEditor, project, research, preflightCheck, setScriptData, setShowRenderQueue, setShowScriptEditor, rawResearch, projectState.knobs, projectState.bible])
|
||||
|
||||
const handleProceedToRendering = useCallback((script: Script) => {
|
||||
// Clear media cache for all scenes before proceeding to remove old blobs
|
||||
script.scenes.forEach((scene) => {
|
||||
clearSceneMediaCache(scene.id);
|
||||
});
|
||||
// Also clear global media cache to ensure clean slate
|
||||
clearMediaCache();
|
||||
|
||||
// Clear all render jobs to start fresh (removes old videos/images)
|
||||
setRenderJobs([]);
|
||||
|
||||
setScriptData(script);
|
||||
if (renderJobs.length === 0) {
|
||||
script.scenes.forEach((scene) => {
|
||||
const hasExistingAudio = Boolean(scene.audioUrl);
|
||||
updateRenderJob(scene.id, {
|
||||
sceneId: scene.id,
|
||||
title: scene.title,
|
||||
status: hasExistingAudio ? ("completed" as const) : ("idle" as const),
|
||||
progress: hasExistingAudio ? 100 : 0,
|
||||
previewUrl: null,
|
||||
finalUrl: hasExistingAudio ? scene.audioUrl : null,
|
||||
jobId: null,
|
||||
});
|
||||
// Create new render jobs with current script scene data
|
||||
script.scenes.forEach((scene) => {
|
||||
const hasExistingAudio = Boolean(scene.audioUrl);
|
||||
const hasExistingImage = Boolean(scene.imageUrl);
|
||||
updateRenderJob(scene.id, {
|
||||
sceneId: scene.id,
|
||||
title: scene.title,
|
||||
status: hasExistingAudio ? ("completed" as const) : ("idle" as const),
|
||||
progress: hasExistingAudio ? 100 : 0,
|
||||
previewUrl: null,
|
||||
finalUrl: hasExistingAudio ? scene.audioUrl : null,
|
||||
imageUrl: hasExistingImage ? scene.imageUrl : null,
|
||||
videoUrl: null,
|
||||
jobId: null,
|
||||
});
|
||||
}
|
||||
});
|
||||
setShowRenderQueue(true);
|
||||
setShowScriptEditor(false);
|
||||
}, [renderJobs.length, setScriptData, updateRenderJob, setShowRenderQueue, setShowScriptEditor]);
|
||||
}, [setScriptData, setRenderJobs, updateRenderJob, setShowRenderQueue, setShowScriptEditor]);
|
||||
|
||||
const toggleQuery = useCallback((id: string) => {
|
||||
if (isResearching) return;
|
||||
@@ -369,6 +445,22 @@ export const usePodcastWorkflow = ({ projectState, onError }: UsePodcastWorkflow
|
||||
setSelectedQueries(next);
|
||||
}, [isResearching, selectedQueries, setSelectedQueries]);
|
||||
|
||||
const handleUpdateQuery = useCallback((id: string, newQuery: string, newRationale: string) => {
|
||||
const updated = queries.map(q => q.id === id ? { ...q, query: newQuery, rationale: newRationale } : q);
|
||||
setQueries(updated);
|
||||
}, [queries, setQueries]);
|
||||
|
||||
const handleDeleteQuery = useCallback((id: string) => {
|
||||
const updated = queries.filter(q => q.id !== id);
|
||||
setQueries(updated);
|
||||
// Also remove from selected if it was selected
|
||||
if (selectedQueries.has(id)) {
|
||||
const newSelected = new Set(selectedQueries);
|
||||
newSelected.delete(id);
|
||||
setSelectedQueries(newSelected);
|
||||
}
|
||||
}, [queries, selectedQueries, setQueries, setSelectedQueries]);
|
||||
|
||||
const activeStep = useMemo(() => {
|
||||
if (showRenderQueue) return 3;
|
||||
if (showScriptEditor) return 2;
|
||||
@@ -396,15 +488,54 @@ export const usePodcastWorkflow = ({ projectState, onError }: UsePodcastWorkflow
|
||||
await handleCreate(payload, feedback);
|
||||
}, [project, projectState.knobs, projectState.budgetCap, handleCreate]);
|
||||
|
||||
// Regenerate only research queries (keeps other sections intact)
|
||||
const handleRegenerateQueries = useCallback(async (feedback: string) => {
|
||||
if (!project || !analysis) return;
|
||||
|
||||
setAnnouncement("Regenerating research queries...");
|
||||
|
||||
try {
|
||||
const response = await podcastApi.regenerateResearchQueries({
|
||||
idea: project.idea,
|
||||
feedback: feedback,
|
||||
existing_analysis: analysis,
|
||||
bible: projectState.bible,
|
||||
});
|
||||
|
||||
// Convert to Query format
|
||||
const newQueries = response.research_queries.map((rq, idx) => ({
|
||||
id: createId("q"),
|
||||
query: rq.query,
|
||||
rationale: rq.rationale,
|
||||
needsRecentStats: /202[45]|latest|trend/i.test(rq.query),
|
||||
}));
|
||||
|
||||
setQueries(newQueries);
|
||||
setSelectedQueries(new Set()); // Don't auto-select - user must choose manually
|
||||
setAnnouncement("Research queries regenerated");
|
||||
} catch (error) {
|
||||
console.error("Failed to regenerate queries:", error);
|
||||
setAnnouncement("Failed to regenerate queries");
|
||||
}
|
||||
}, [project, analysis, projectState.bible, setQueries, setSelectedQueries]);
|
||||
|
||||
const setAnnouncementSeverityFn = useCallback((severity: "info" | "error" | "success") => {
|
||||
setAnnouncementSeverity(severity);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
// State
|
||||
isAnalyzing,
|
||||
isResearching,
|
||||
isGeneratingScript,
|
||||
announcement,
|
||||
announcementSeverity,
|
||||
showResumeAlert,
|
||||
showPreflightDialog,
|
||||
preflightResponse,
|
||||
preflightOperationName,
|
||||
showDuplicateDialog,
|
||||
duplicateProjectInfo,
|
||||
activeStep,
|
||||
canGenerateScript,
|
||||
// Handlers
|
||||
@@ -415,11 +546,17 @@ export const usePodcastWorkflow = ({ projectState, onError }: UsePodcastWorkflow
|
||||
handleProceedToRendering,
|
||||
toggleQuery,
|
||||
setAnnouncement,
|
||||
setAnnouncementSeverity: setAnnouncementSeverityFn,
|
||||
setShowResumeAlert,
|
||||
setShowPreflightDialog,
|
||||
setPreflightResponse,
|
||||
setShowDuplicateDialog,
|
||||
setDuplicateProjectInfo,
|
||||
setResearchProvider,
|
||||
getStepLabel,
|
||||
handleRegenerateQueries: handleRegenerateQueries,
|
||||
handleUpdateQuery,
|
||||
handleDeleteQuery,
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -4,6 +4,8 @@ import { CreateProjectPayload, Knobs } from "../types";
|
||||
export const DEFAULT_KNOBS: Knobs = {
|
||||
voice_emotion: "neutral",
|
||||
voice_speed: 1,
|
||||
voice_id: "Wise_Woman",
|
||||
custom_voice_id: undefined,
|
||||
resolution: "720p",
|
||||
scene_length_target: 45,
|
||||
sample_rate: 24000,
|
||||
@@ -54,9 +56,31 @@ export const sanitizeExaConfig = (
|
||||
};
|
||||
};
|
||||
|
||||
export const announceError = (setAnnouncement: (msg: string) => void, error: unknown) => {
|
||||
const message = error instanceof Error ? error.message : "Unexpected error";
|
||||
export const announceError = (
|
||||
setAnnouncement: (msg: string) => void,
|
||||
setAnnouncementSeverity?: (severity: "info" | "error" | "success") => void,
|
||||
error?: unknown
|
||||
) => {
|
||||
let message = "Unexpected error occurred. Please try again.";
|
||||
if (error instanceof Error) {
|
||||
message = error.message;
|
||||
// Simplify common error messages
|
||||
if (message.includes("RESOURCE_EXHAUSTED") || message.includes("quota")) {
|
||||
message = "API quota exceeded. Please check your API keys or try again later.";
|
||||
} else if (message.includes("All LLM providers failed")) {
|
||||
message = "AI service temporarily unavailable. Please try again later.";
|
||||
} else if (message.includes("No LLM API keys configured")) {
|
||||
message = "API keys not configured. Please contact support.";
|
||||
} else if (message.includes("RESOURCE_EXHAUSTED")) {
|
||||
message = "API quota exceeded. Please check your subscription or try again later.";
|
||||
} else if (message.length > 100) {
|
||||
message = "An error occurred during analysis. Please try again.";
|
||||
}
|
||||
}
|
||||
setAnnouncement(message);
|
||||
if (setAnnouncementSeverity) {
|
||||
setAnnouncementSeverity("error");
|
||||
}
|
||||
};
|
||||
|
||||
export const getStepLabel = (step: string | null): string => {
|
||||
|
||||
@@ -22,10 +22,12 @@ import {
|
||||
Mic as MicIcon,
|
||||
PlayArrow as PlayArrowIcon,
|
||||
Delete as DeleteIcon,
|
||||
Edit as EditIcon,
|
||||
Star as StarIcon,
|
||||
StarBorder as StarBorderIcon,
|
||||
Refresh as RefreshIcon,
|
||||
Search as SearchIcon,
|
||||
ArrowBack as ArrowBackIcon,
|
||||
} from "@mui/icons-material";
|
||||
import { podcastApi } from "../../services/podcastApi";
|
||||
import { GlassyCard, glassyCardSx, PrimaryButton, SecondaryButton } from "./ui";
|
||||
@@ -45,9 +47,10 @@ interface Project {
|
||||
|
||||
interface ProjectListProps {
|
||||
onSelectProject: (projectId: string) => void;
|
||||
onBack?: () => void;
|
||||
}
|
||||
|
||||
export const ProjectList: React.FC<ProjectListProps> = ({ onSelectProject }) => {
|
||||
export const ProjectList: React.FC<ProjectListProps> = ({ onSelectProject, onBack }) => {
|
||||
const navigate = useNavigate();
|
||||
const [projects, setProjects] = useState<Project[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -175,6 +178,9 @@ export const ProjectList: React.FC<ProjectListProps> = ({ onSelectProject }) =>
|
||||
</Typography>
|
||||
</Box>
|
||||
<Stack direction="row" spacing={1}>
|
||||
<SecondaryButton onClick={onBack || (() => navigate(-1))} startIcon={<ArrowBackIcon />}>
|
||||
Back
|
||||
</SecondaryButton>
|
||||
<SecondaryButton onClick={loadProjects} startIcon={<RefreshIcon />} disabled={loading}>
|
||||
Refresh
|
||||
</SecondaryButton>
|
||||
@@ -248,7 +254,7 @@ export const ProjectList: React.FC<ProjectListProps> = ({ onSelectProject }) =>
|
||||
>
|
||||
<Stack spacing={2}>
|
||||
<Stack direction="row" justifyContent="space-between" alignItems="flex-start">
|
||||
<Box flex={1}>
|
||||
<Box flex={1} onClick={() => onSelectProject(project.project_id)} sx={{ cursor: "pointer" }}>
|
||||
<Typography variant="h6" sx={{ mb: 1 }}>
|
||||
{project.idea.length > 100 ? `${project.idea.substring(0, 100)}...` : project.idea}
|
||||
</Typography>
|
||||
@@ -270,14 +276,25 @@ export const ProjectList: React.FC<ProjectListProps> = ({ onSelectProject }) =>
|
||||
</Typography>
|
||||
</Stack>
|
||||
</Box>
|
||||
<Stack direction="row" spacing={1}>
|
||||
<Stack direction="row" spacing={0.5}>
|
||||
<Tooltip title="Edit project">
|
||||
<IconButton
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onSelectProject(project.project_id);
|
||||
}}
|
||||
sx={{ color: "#a78bfa" }}
|
||||
>
|
||||
<EditIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip title={project.is_favorite ? "Remove from favorites" : "Add to favorites"}>
|
||||
<IconButton
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleToggleFavorite(project.project_id, project.is_favorite);
|
||||
}}
|
||||
sx={{ color: project.is_favorite ? "#fbbf24" : "rgba(255,255,255,0.5)" }}
|
||||
sx={{ color: project.is_favorite ? "#fbbf24" : "#a78bfa" }}
|
||||
>
|
||||
{project.is_favorite ? <StarIcon /> : <StarBorderIcon />}
|
||||
</IconButton>
|
||||
@@ -289,7 +306,7 @@ export const ProjectList: React.FC<ProjectListProps> = ({ onSelectProject }) =>
|
||||
setProjectToDelete(project.project_id);
|
||||
setDeleteDialogOpen(true);
|
||||
}}
|
||||
sx={{ color: "rgba(255,255,255,0.5)" }}
|
||||
sx={{ color: "#ef4444" }}
|
||||
>
|
||||
<DeleteIcon />
|
||||
</IconButton>
|
||||
|
||||
@@ -63,6 +63,7 @@ export const RenderQueue: React.FC<RenderQueueProps> = ({
|
||||
knobs,
|
||||
projectId,
|
||||
bible,
|
||||
analysis,
|
||||
budgetCap,
|
||||
avatarImageUrl,
|
||||
onUpdateJob,
|
||||
|
||||
@@ -46,33 +46,39 @@ export const VideoRegenerateModal: React.FC<VideoRegenerateModalProps> = ({
|
||||
// Use a more intelligent default prompt based on context if available
|
||||
const [prompt, setPrompt] = useState(initialPrompt);
|
||||
|
||||
// Update prompt when context changes or modal opens
|
||||
// Update prompt when modal opens - build enhanced prompt from context
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
let smartPrompt = initialPrompt;
|
||||
// Always build an enhanced prompt from available context
|
||||
const parts = [];
|
||||
|
||||
// If the initial prompt is generic/empty, try to build a better one
|
||||
if (!smartPrompt || smartPrompt === "Professional podcast scene with subtle movement") {
|
||||
const parts = [];
|
||||
|
||||
// Add scene context
|
||||
if (sceneTitle) parts.push(`Scene: ${sceneTitle}`);
|
||||
|
||||
// Add bible/persona context
|
||||
if (bible?.host_persona) parts.push(`Host Persona: ${bible.host_persona}`);
|
||||
if (bible?.tone) parts.push(`Tone: ${bible.tone}`);
|
||||
|
||||
// Add analysis context
|
||||
if (analysis?.content_type) parts.push(`Style: ${analysis.content_type}`);
|
||||
|
||||
// Combine into a descriptive prompt
|
||||
if (parts.length > 0) {
|
||||
smartPrompt = `Professional talking head video for podcast. ${parts.join(". ")}. Cinematic lighting, 4k, high detail.`;
|
||||
}
|
||||
// Add scene context
|
||||
if (sceneTitle) parts.push(`Scene: ${sceneTitle}`);
|
||||
|
||||
// Add bible/persona context
|
||||
if (bible?.host_persona) parts.push(`Host Persona: ${bible.host_persona}`);
|
||||
if (bible?.tone) parts.push(`Tone: ${bible.tone}`);
|
||||
if (bible?.visual_style) parts.push(`Visual Style: ${bible.visual_style}`);
|
||||
if (bible?.background) parts.push(`Background: ${bible.background}`);
|
||||
|
||||
// Add analysis context
|
||||
if (analysis?.content_type) parts.push(`Content Type: ${analysis.content_type}`);
|
||||
if (analysis?.audience) parts.push(`Target: ${analysis.audience}`);
|
||||
if (analysis?.guestName) parts.push(`Guest: ${analysis.guestName}`);
|
||||
if (analysis?.keyTakeaways?.length) parts.push(`Key: ${analysis.keyTakeaways[0]}`);
|
||||
|
||||
// Build enhanced prompt
|
||||
let smartPrompt = "";
|
||||
if (parts.length > 0) {
|
||||
smartPrompt = `Professional podcast video. ${parts.join(". ")}. Cinematic lighting, high detail, 4k quality, smooth subtle motion.`;
|
||||
} else {
|
||||
// Fallback to initial prompt
|
||||
smartPrompt = initialPrompt || "Professional podcast scene with subtle movement";
|
||||
}
|
||||
|
||||
setPrompt(smartPrompt);
|
||||
}
|
||||
}, [open, initialPrompt, sceneTitle, bible, analysis]);
|
||||
}, [open, sceneTitle, bible, analysis]);
|
||||
|
||||
const [resolution, setResolution] = useState<"480p" | "720p">(initialResolution);
|
||||
const [seed, setSeed] = useState<string>(initialSeed != null && initialSeed !== -1 ? String(initialSeed) : "");
|
||||
|
||||
@@ -8,6 +8,7 @@ interface UseRenderQueueProps {
|
||||
knobs: Knobs;
|
||||
projectId: string;
|
||||
bible?: any | null;
|
||||
analysis?: any | null;
|
||||
budgetCap?: number;
|
||||
avatarImageUrl?: string | null;
|
||||
onUpdateJob: (sceneId: string, updates: Partial<Job>) => void;
|
||||
@@ -23,6 +24,7 @@ export const useRenderQueue = ({
|
||||
knobs,
|
||||
projectId,
|
||||
bible,
|
||||
analysis,
|
||||
budgetCap,
|
||||
avatarImageUrl,
|
||||
onUpdateJob,
|
||||
@@ -54,27 +56,32 @@ export const useRenderQueue = ({
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Initialize jobs if empty (audio/image only)
|
||||
// Initialize jobs if empty (audio/image only) OR sync with script scenes
|
||||
useEffect(() => {
|
||||
if (jobs.length === 0 && script.scenes.length > 0) {
|
||||
const initialJobs: Job[] = script.scenes.map((s) => {
|
||||
// Always sync jobs with script scenes - this ensures render queue shows current audio/image
|
||||
if (script.scenes.length > 0) {
|
||||
script.scenes.forEach((s) => {
|
||||
const hasExistingAudio = Boolean(s.audioUrl);
|
||||
return {
|
||||
const hasExistingImage = Boolean(s.imageUrl);
|
||||
const isReady = hasExistingAudio;
|
||||
|
||||
// Create job from scene data
|
||||
const jobFromScene: Job = {
|
||||
sceneId: s.id,
|
||||
title: s.title,
|
||||
status: hasExistingAudio ? ("completed" as const) : ("idle" as const),
|
||||
progress: hasExistingAudio ? 100 : 0,
|
||||
status: isReady ? ("completed" as const) : ("idle" as const),
|
||||
progress: isReady ? 100 : 0,
|
||||
previewUrl: null,
|
||||
finalUrl: hasExistingAudio ? s.audioUrl || null : null,
|
||||
imageUrl: s.imageUrl || null,
|
||||
imageUrl: hasExistingImage ? s.imageUrl || null : null,
|
||||
jobId: null,
|
||||
};
|
||||
});
|
||||
initialJobs.forEach((job) => {
|
||||
onUpdateJob(job.sceneId, job);
|
||||
|
||||
// Update job with scene's audio/image data
|
||||
onUpdateJob(s.id, jobFromScene);
|
||||
});
|
||||
}
|
||||
}, [jobs.length, script.scenes.length, onUpdateJob, script.scenes]);
|
||||
}, [script.scenes, onUpdateJob]);
|
||||
|
||||
// Load final video URL from project on mount (for persistence across reloads)
|
||||
useEffect(() => {
|
||||
@@ -95,6 +102,7 @@ export const useRenderQueue = ({
|
||||
}, [projectId]);
|
||||
|
||||
// Always try to attach existing videos to scenes (even after reloads)
|
||||
// But skip if job already has imageUrl - indicates user just came from script phase
|
||||
useEffect(() => {
|
||||
if (script.scenes.length === 0) return;
|
||||
|
||||
@@ -122,6 +130,23 @@ export const useRenderQueue = ({
|
||||
|
||||
const job = jobs.find((j) => j.sceneId === scene.id);
|
||||
|
||||
// Skip if job already has imageUrl from script phase - don't override with old video
|
||||
if (job?.imageUrl) {
|
||||
console.log("[useRenderQueue] Skipping old video - job has imageUrl from script phase:", scene.id, "imageUrl:", job.imageUrl);
|
||||
return;
|
||||
}
|
||||
|
||||
// Job has no imageUrl - this could be from page reload or old state
|
||||
console.log("[useRenderQueue] Job missing imageUrl, checking for old video:", scene.id, "job:", job);
|
||||
|
||||
// Only attach old video if job has NO content at all (no image, no video, no audio)
|
||||
// If job has finalUrl (audio) or imageUrl from script phase, don't attach old video
|
||||
const isJobEmpty = !job || (!job.imageUrl && !job.videoUrl && !job.finalUrl);
|
||||
if (!isJobEmpty) {
|
||||
console.log("[useRenderQueue] Skipping old video - job has content already:", scene.id, "job:", job);
|
||||
return;
|
||||
}
|
||||
|
||||
// Avoid redundant updates
|
||||
if (job?.videoUrl === videoUrl) return;
|
||||
|
||||
@@ -569,6 +594,9 @@ export const useRenderQueue = ({
|
||||
audioUrl,
|
||||
avatarImageUrl: sceneImageUrl,
|
||||
bible: bible,
|
||||
analysis: analysis, // Pass analysis for enhanced prompt
|
||||
sceneImagePrompt: scene.imagePrompt || undefined, // Original image generation prompt
|
||||
sceneNarration: scene.lines?.map((l: any) => l.text).join(" ").slice(0, 200) || undefined,
|
||||
resolution: targetResolution,
|
||||
prompt: settings?.prompt || undefined,
|
||||
seed: settings?.seed ?? -1,
|
||||
|
||||
@@ -21,9 +21,11 @@ import {
|
||||
} from "@mui/material";
|
||||
import { HelpOutline as HelpOutlineIcon, Close as CloseIcon } from "@mui/icons-material";
|
||||
import { PrimaryButton, SecondaryButton } from "../ui";
|
||||
import { VoiceSelector } from "../../shared/VoiceSelector";
|
||||
|
||||
export type AudioGenerationSettings = {
|
||||
voiceId: string;
|
||||
customVoiceId?: string;
|
||||
speed: number;
|
||||
volume: number;
|
||||
pitch: number;
|
||||
@@ -156,26 +158,12 @@ export const AudioRegenerateModal: React.FC<AudioRegenerateModalProps> = ({
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Stack>
|
||||
<FormControl fullWidth>
|
||||
<Select
|
||||
value={settings.voiceId}
|
||||
onChange={(e) => setSettings({ ...settings, voiceId: e.target.value })}
|
||||
sx={{
|
||||
backgroundColor: alpha("#ffffff", 0.05),
|
||||
color: "white",
|
||||
"& .MuiOutlinedInput-notchedOutline": { borderColor: "rgba(255,255,255,0.2)" },
|
||||
"&:hover .MuiOutlinedInput-notchedOutline": { borderColor: "rgba(255,255,255,0.3)" },
|
||||
"&.Mui-focused .MuiOutlinedInput-notchedOutline": { borderColor: "#667eea" },
|
||||
"& .MuiSvgIcon-root": { color: "rgba(255,255,255,0.7)" },
|
||||
}}
|
||||
>
|
||||
{VOICE_OPTIONS.map((v) => (
|
||||
<MenuItem key={v} value={v}>
|
||||
{v}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
<VoiceSelector
|
||||
value={settings.voiceId}
|
||||
onChange={(voiceId) => setSettings({ ...settings, voiceId })}
|
||||
showVoiceClone={true}
|
||||
disabled={false}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/* Speed / Volume / Pitch */}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { Stack, Box, Typography, Divider, Chip, alpha, CircularProgress, LinearProgress, IconButton, Tooltip } from "@mui/material";
|
||||
import { Stack, Box, Typography, Divider, Chip, alpha, CircularProgress, LinearProgress, IconButton, Tooltip, Dialog, DialogContent } from "@mui/material";
|
||||
import {
|
||||
EditNote as EditNoteIcon,
|
||||
CheckCircle as CheckCircleIcon,
|
||||
@@ -8,6 +8,8 @@ import {
|
||||
PlayArrow as PlayArrowIcon,
|
||||
Image as ImageIcon,
|
||||
Delete as DeleteIcon,
|
||||
Fullscreen as FullscreenIcon,
|
||||
Close as CloseIcon,
|
||||
} from "@mui/icons-material";
|
||||
import { Scene, Line, Knobs } from "../types";
|
||||
import { GlassyCard, glassyCardSx, PrimaryButton } from "../ui";
|
||||
@@ -31,6 +33,11 @@ interface SceneEditorProps {
|
||||
idea?: string; // Podcast idea for image generation context
|
||||
avatarUrl?: string | null; // Base avatar URL for consistent scene image generation
|
||||
totalScenes?: number; // Total number of scenes in the script
|
||||
analysis?: {
|
||||
audience?: string;
|
||||
contentType?: string;
|
||||
topKeywords?: string[];
|
||||
} | null;
|
||||
}
|
||||
|
||||
export const SceneEditor: React.FC<SceneEditorProps> = ({
|
||||
@@ -46,6 +53,7 @@ export const SceneEditor: React.FC<SceneEditorProps> = ({
|
||||
idea,
|
||||
avatarUrl,
|
||||
totalScenes,
|
||||
analysis,
|
||||
}) => {
|
||||
const [localGenerating, setLocalGenerating] = useState(false);
|
||||
const [generatingImage, setGeneratingImage] = useState(false);
|
||||
@@ -56,8 +64,10 @@ export const SceneEditor: React.FC<SceneEditorProps> = ({
|
||||
const [imageLoading, setImageLoading] = useState(false);
|
||||
const [showRegenerateModal, setShowRegenerateModal] = useState(false);
|
||||
const [showAudioModal, setShowAudioModal] = useState(false);
|
||||
const [showImagePreview, setShowImagePreview] = useState(false);
|
||||
const [audioSettings, setAudioSettings] = useState<AudioGenerationSettings>({
|
||||
voiceId: "Wise_Woman",
|
||||
customVoiceId: undefined,
|
||||
speed: 1.0,
|
||||
volume: 1.0,
|
||||
pitch: 0.0,
|
||||
@@ -300,7 +310,8 @@ export const SceneEditor: React.FC<SceneEditorProps> = ({
|
||||
const effectiveSettings = settings || audioSettings;
|
||||
const result = await podcastApi.renderSceneAudio({
|
||||
scene: currentScene,
|
||||
voiceId: effectiveSettings.voiceId || "Wise_Woman",
|
||||
voiceId: effectiveSettings.voiceId || knobs.voice_id || "Wise_Woman",
|
||||
customVoiceId: effectiveSettings.customVoiceId || knobs.custom_voice_id,
|
||||
emotion: effectiveSettings.emotion || scene.emotion || knobs.voice_emotion || "neutral",
|
||||
speed: effectiveSettings.speed ?? knobs.voice_speed ?? 1.0,
|
||||
volume: effectiveSettings.volume ?? 1.0,
|
||||
@@ -323,6 +334,24 @@ export const SceneEditor: React.FC<SceneEditorProps> = ({
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to approve and generate audio:", error);
|
||||
|
||||
// Provide user-friendly error message based on error type
|
||||
let userMessage = "Failed to generate audio. Please try again.";
|
||||
|
||||
if (error instanceof Error) {
|
||||
const errorMsg = error.message.toLowerCase();
|
||||
|
||||
if (errorMsg.includes("429") || errorMsg.includes("quota") || errorMsg.includes("limit")) {
|
||||
userMessage = "Audio generation limit reached. Please check your subscription and try again.";
|
||||
} else if (errorMsg.includes("voice") || errorMsg.includes("custom_voice")) {
|
||||
userMessage = "Invalid voice. Please select a different voice and try again.";
|
||||
} else if (errorMsg.includes("timeout") || errorMsg.includes("timed out")) {
|
||||
userMessage = "Audio generation timed out. Please try again.";
|
||||
} else if (errorMsg.includes("network") || errorMsg.includes("connection")) {
|
||||
userMessage = "Network error. Please check your connection and try again.";
|
||||
}
|
||||
}
|
||||
|
||||
// On error, revert approval only if we just approved it in this call
|
||||
if (!wasAlreadyApproved) {
|
||||
onUpdateScene({ ...scene, approved: false, audioUrl: undefined });
|
||||
@@ -379,11 +408,12 @@ export const SceneEditor: React.FC<SceneEditorProps> = ({
|
||||
sceneId: scene.id,
|
||||
sceneTitle: scene.title,
|
||||
sceneContent: sceneContent,
|
||||
baseAvatarUrl: avatarUrl || undefined, // Pass base avatar URL for character consistency
|
||||
sceneEmotion: scene.emotion,
|
||||
baseAvatarUrl: avatarUrl || undefined,
|
||||
idea: idea,
|
||||
analysis: analysis || undefined,
|
||||
width: 1024,
|
||||
height: 1024,
|
||||
// Pass custom settings if provided
|
||||
customPrompt: settings?.prompt,
|
||||
style: settings?.style,
|
||||
renderingSpeed: settings?.renderingSpeed,
|
||||
@@ -398,8 +428,12 @@ export const SceneEditor: React.FC<SceneEditorProps> = ({
|
||||
setImageGenerationStatus("Finalizing image...");
|
||||
setImageGenerationProgress(95);
|
||||
|
||||
// Update scene with image URL
|
||||
const updatedScene = { ...scene, imageUrl: result.image_url };
|
||||
// Update scene with image URL and the prompt used
|
||||
const updatedScene = {
|
||||
...scene,
|
||||
imageUrl: result.image_url,
|
||||
imagePrompt: result.image_prompt || undefined,
|
||||
};
|
||||
onUpdateScene(updatedScene);
|
||||
|
||||
const elapsed = Math.floor((Date.now() - startTime) / 1000);
|
||||
@@ -725,11 +759,25 @@ export const SceneEditor: React.FC<SceneEditorProps> = ({
|
||||
: "1px solid rgba(245, 158, 11, 0.2)",
|
||||
}}
|
||||
>
|
||||
<Stack direction="row" alignItems="center" spacing={1.5} sx={{ mb: 1.5 }}>
|
||||
<Stack direction="row" alignItems="center" spacing={1.5} sx={{ mb: 1.5, width: "100%" }}>
|
||||
<ImageIcon sx={{ color: imageBlobUrl && !imageLoading ? "#667eea" : "#d97706", fontSize: "1.25rem" }} />
|
||||
<Typography variant="subtitle2" sx={{ color: imageBlobUrl && !imageLoading ? "#667eea" : "#d97706", fontWeight: 600 }}>
|
||||
<Typography variant="subtitle2" sx={{ color: imageBlobUrl && !imageLoading ? "#667eea" : "#d97706", fontWeight: 600, flex: 1 }}>
|
||||
{imageBlobUrl && !imageLoading ? "Image Generated" : "Loading Image..."}
|
||||
</Typography>
|
||||
{imageBlobUrl && !imageLoading && (
|
||||
<Tooltip title="View full size">
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => setShowImagePreview(true)}
|
||||
sx={{
|
||||
color: "#667eea",
|
||||
"&:hover": { background: "rgba(102, 126, 234, 0.1)" },
|
||||
}}
|
||||
>
|
||||
<FullscreenIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Stack>
|
||||
{imageBlobUrl && !imageLoading ? (
|
||||
<Box
|
||||
@@ -805,6 +853,49 @@ export const SceneEditor: React.FC<SceneEditorProps> = ({
|
||||
initialSettings={audioSettings}
|
||||
isGenerating={generating}
|
||||
/>
|
||||
|
||||
{/* Full-size Image Preview Modal */}
|
||||
<Dialog
|
||||
open={showImagePreview}
|
||||
onClose={() => setShowImagePreview(false)}
|
||||
maxWidth="lg"
|
||||
PaperProps={{
|
||||
sx: {
|
||||
background: "rgba(0, 0, 0, 0.9)",
|
||||
borderRadius: 3,
|
||||
maxHeight: "90vh",
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogContent sx={{ p: 0, position: "relative" }}>
|
||||
<IconButton
|
||||
onClick={() => setShowImagePreview(false)}
|
||||
sx={{
|
||||
position: "absolute",
|
||||
top: 8,
|
||||
right: 8,
|
||||
color: "#fff",
|
||||
background: "rgba(0, 0, 0, 0.5)",
|
||||
zIndex: 1,
|
||||
"&:hover": { background: "rgba(0, 0, 0, 0.7)" },
|
||||
}}
|
||||
>
|
||||
<CloseIcon />
|
||||
</IconButton>
|
||||
<Box
|
||||
component="img"
|
||||
src={imageBlobUrl || ""}
|
||||
alt={scene.title}
|
||||
sx={{
|
||||
width: "100%",
|
||||
height: "auto",
|
||||
maxHeight: "85vh",
|
||||
objectFit: "contain",
|
||||
display: "block",
|
||||
}}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</GlassyCard>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -49,7 +49,7 @@ export const ScriptEditor: React.FC<ScriptEditorProps> = ({
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [approvingSceneId, setApprovingSceneId] = useState<string | null>(null);
|
||||
const [generatingAudioId, setGeneratingAudioId] = useState<string | null>(null);
|
||||
const [showScriptFormatInfo, setShowScriptFormatInfo] = useState(true);
|
||||
const [showScriptFormatInfo, setShowScriptFormatInfo] = useState(false);
|
||||
const [combiningAudio, setCombiningAudio] = useState(false);
|
||||
const [combinedAudioResult, setCombinedAudioResult] = useState<{
|
||||
url: string;
|
||||
@@ -622,6 +622,7 @@ export const ScriptEditor: React.FC<ScriptEditorProps> = ({
|
||||
}}
|
||||
idea={idea}
|
||||
avatarUrl={avatarUrl}
|
||||
analysis={analysis}
|
||||
/>
|
||||
</GlassyCard>
|
||||
))}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
export type Knobs = {
|
||||
voice_emotion: string;
|
||||
voice_speed: number;
|
||||
voice_id: string;
|
||||
custom_voice_id?: string;
|
||||
resolution: string;
|
||||
scene_length_target: number;
|
||||
sample_rate: number;
|
||||
@@ -64,6 +66,7 @@ export type Scene = {
|
||||
emotion?: string; // Scene-specific emotion
|
||||
audioUrl?: string; // Generated audio URL for this scene
|
||||
imageUrl?: string; // Generated image URL for this scene (for video generation)
|
||||
imagePrompt?: string; // Original image generation prompt for video context
|
||||
};
|
||||
|
||||
export type Script = {
|
||||
@@ -104,6 +107,10 @@ export type PodcastAnalysis = {
|
||||
suggestedOutlines: { id: number | string; title: string; segments: string[] }[];
|
||||
suggestedKnobs: Knobs;
|
||||
titleSuggestions: string[];
|
||||
episode_hook?: string;
|
||||
key_takeaways?: string[];
|
||||
guest_talking_points?: string[];
|
||||
listener_cta?: string;
|
||||
research_queries?: { query: string; rationale: string }[];
|
||||
exaSuggestedConfig?: {
|
||||
exa_search_type?: "auto" | "keyword" | "neural";
|
||||
|
||||
@@ -7,9 +7,11 @@ interface PrimaryButtonProps {
|
||||
disabled?: boolean;
|
||||
loading?: boolean;
|
||||
startIcon?: React.ReactNode;
|
||||
endIcon?: React.ReactNode;
|
||||
tooltip?: string;
|
||||
ariaLabel?: string;
|
||||
sx?: SxProps<Theme>;
|
||||
size?: "small" | "medium" | "large";
|
||||
}
|
||||
|
||||
export const PrimaryButton: React.FC<PrimaryButtonProps> = ({
|
||||
@@ -18,24 +20,32 @@ export const PrimaryButton: React.FC<PrimaryButtonProps> = ({
|
||||
disabled = false,
|
||||
loading = false,
|
||||
startIcon,
|
||||
endIcon,
|
||||
tooltip,
|
||||
ariaLabel,
|
||||
sx,
|
||||
size = "medium",
|
||||
}) => {
|
||||
const sizeStyles = {
|
||||
small: { px: 1.5, py: 0.5, fontSize: "0.75rem" },
|
||||
medium: { px: 3, py: 1, fontSize: "0.875rem" },
|
||||
large: { px: 4, py: 1.5, fontSize: "1rem" },
|
||||
};
|
||||
|
||||
const button = (
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={onClick}
|
||||
disabled={disabled || loading}
|
||||
startIcon={loading ? <CircularProgress size={16} /> : startIcon}
|
||||
endIcon={loading ? undefined : endIcon}
|
||||
aria-label={ariaLabel}
|
||||
sx={{
|
||||
background: "linear-gradient(135deg, #667eea 0%, #764ba2 100%)",
|
||||
color: "white",
|
||||
fontWeight: 600,
|
||||
textTransform: "none",
|
||||
px: 3,
|
||||
py: 1,
|
||||
...sizeStyles[size],
|
||||
"&:hover": {
|
||||
background: "linear-gradient(135deg, #764ba2 0%, #667eea 100%)",
|
||||
},
|
||||
|
||||
@@ -52,6 +52,31 @@ export interface SubscriptionPlan {
|
||||
}
|
||||
|
||||
const PricingPage: React.FC = () => {
|
||||
const pricingMode = (process.env.REACT_APP_PRICING_MODE || 'alpha').toLowerCase();
|
||||
const isAlphaMode = pricingMode === 'alpha';
|
||||
const tierPolicyByMode: Record<string, { visible: string[]; selectable: string[]; unavailableLabels: Record<string, string> }> = {
|
||||
alpha: {
|
||||
visible: ['free', 'basic', 'pro', 'enterprise'],
|
||||
selectable: ['free', 'basic'],
|
||||
unavailableLabels: { pro: 'Coming Soon', enterprise: 'Contact Sales' },
|
||||
},
|
||||
demo: {
|
||||
visible: ['free', 'basic', 'pro', 'enterprise'],
|
||||
selectable: ['free', 'basic', 'pro'],
|
||||
unavailableLabels: { enterprise: 'Contact Sales' },
|
||||
},
|
||||
production: {
|
||||
visible: ['free', 'basic', 'pro', 'enterprise'],
|
||||
selectable: ['free', 'basic', 'pro'],
|
||||
unavailableLabels: { enterprise: 'Contact Sales' },
|
||||
},
|
||||
};
|
||||
const activeTierPolicy = tierPolicyByMode[pricingMode] || tierPolicyByMode.alpha;
|
||||
|
||||
const requireStripeCheckout = ['1', 'true', 'yes', 'on'].includes(
|
||||
(process.env.REACT_APP_REQUIRE_STRIPE_CHECKOUT || '').toLowerCase()
|
||||
);
|
||||
const stripePublishableKey = process.env.REACT_APP_STRIPE_PUBLISHABLE_KEY;
|
||||
const navigate = useNavigate();
|
||||
const [plans, setPlans] = useState<SubscriptionPlan[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -72,13 +97,48 @@ const PricingPage: React.FC = () => {
|
||||
fetchPlans();
|
||||
}, []);
|
||||
|
||||
const isPodcastOnlyDemoMode = () => {
|
||||
const appMode = (localStorage.getItem('app_mode') || '').toLowerCase();
|
||||
const demoMode = (localStorage.getItem('demo_mode') || '').toLowerCase();
|
||||
const podcastOnlyDemoMode = (localStorage.getItem('podcast_only_demo_mode') || '').toLowerCase();
|
||||
const envAppMode = (process.env.REACT_APP_APP_MODE || '').toLowerCase();
|
||||
const envDemoMode = (process.env.REACT_APP_DEMO_MODE || '').toLowerCase();
|
||||
|
||||
return (
|
||||
podcastOnlyDemoMode === 'true' ||
|
||||
appMode === 'podcast-only' ||
|
||||
demoMode === 'podcast-only' ||
|
||||
envAppMode === 'podcast-only' ||
|
||||
envDemoMode === 'podcast-only'
|
||||
);
|
||||
};
|
||||
|
||||
const redirectAfterSubscription = () => {
|
||||
// In podcast-only demo mode, always force users into podcast flow.
|
||||
// Never send demo users to onboarding.
|
||||
if (isPodcastOnlyDemoMode()) {
|
||||
navigate('/podcast-maker');
|
||||
return;
|
||||
}
|
||||
|
||||
// Full mode keeps existing onboarding redirect behavior.
|
||||
const onboardingComplete = localStorage.getItem('onboarding_complete') === 'true';
|
||||
if (onboardingComplete) {
|
||||
navigate('/dashboard');
|
||||
} else {
|
||||
navigate('/onboarding');
|
||||
}
|
||||
};
|
||||
|
||||
const fetchPlans = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await apiClient.get('/api/subscription/plans');
|
||||
// Filter out any alpha plans and ensure we only show the 4 main tiers
|
||||
// Filter out legacy alpha-named plans and enforce tier visibility policy.
|
||||
const filteredPlans = response.data.data.plans.filter(
|
||||
(plan: SubscriptionPlan) => !plan.name.toLowerCase().includes('alpha')
|
||||
(plan: SubscriptionPlan) =>
|
||||
!plan.name.toLowerCase().includes('alpha') &&
|
||||
activeTierPolicy.visible.includes(plan.tier)
|
||||
);
|
||||
setPlans(filteredPlans);
|
||||
} catch (err) {
|
||||
@@ -111,10 +171,13 @@ const PricingPage: React.FC = () => {
|
||||
return;
|
||||
}
|
||||
|
||||
// For alpha testing, only allow Free and Basic plans (Pro features not ready)
|
||||
if (plan.tier !== 'free' && plan.tier !== 'basic') {
|
||||
if (!activeTierPolicy.selectable.includes(plan.tier)) {
|
||||
console.error('[PricingPage] Plan tier not available:', plan.tier);
|
||||
setError('This plan is not available for alpha testing');
|
||||
setError(
|
||||
isAlphaMode
|
||||
? 'This plan is not available during alpha testing'
|
||||
: 'This plan is currently not available for self-serve checkout'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -133,14 +196,7 @@ const PricingPage: React.FC = () => {
|
||||
// Refresh subscription status
|
||||
window.dispatchEvent(new CustomEvent('subscription-updated'));
|
||||
|
||||
// After subscription, check if onboarding is complete
|
||||
// If not complete, redirect to onboarding; otherwise to dashboard
|
||||
const onboardingComplete = localStorage.getItem('onboarding_complete') === 'true';
|
||||
if (onboardingComplete) {
|
||||
navigate('/dashboard');
|
||||
} else {
|
||||
navigate('/onboarding');
|
||||
}
|
||||
redirectAfterSubscription();
|
||||
} catch (err) {
|
||||
console.error('Error subscribing:', err);
|
||||
setError('Failed to process subscription');
|
||||
@@ -173,13 +229,15 @@ const PricingPage: React.FC = () => {
|
||||
const userId = localStorage.getItem('user_id') || 'anonymous';
|
||||
|
||||
// Check if Stripe is configured
|
||||
if (process.env.REACT_APP_STRIPE_PUBLISHABLE_KEY) {
|
||||
if (stripePublishableKey) {
|
||||
console.log('[PricingPage] Initiating Stripe Checkout');
|
||||
|
||||
const response = await apiClient.post('/api/subscription/create-checkout-session', {
|
||||
tier: plan.tier,
|
||||
billing_cycle: yearlyBilling ? 'yearly' : 'monthly',
|
||||
success_url: `${window.location.origin}/dashboard?subscription=success`,
|
||||
success_url: isPodcastOnlyDemoMode()
|
||||
? `${window.location.origin}/podcast-maker?subscription=success`
|
||||
: `${window.location.origin}/dashboard?subscription=success`,
|
||||
cancel_url: `${window.location.origin}/pricing?subscription=cancel`,
|
||||
});
|
||||
|
||||
@@ -187,6 +245,19 @@ const PricingPage: React.FC = () => {
|
||||
window.location.href = response.data.url;
|
||||
return;
|
||||
}
|
||||
|
||||
if (requireStripeCheckout) {
|
||||
throw new Error('Stripe checkout is required but checkout URL was not returned.');
|
||||
}
|
||||
} else if (requireStripeCheckout) {
|
||||
throw new Error(
|
||||
'Stripe checkout is required but REACT_APP_STRIPE_PUBLISHABLE_KEY is not configured.'
|
||||
);
|
||||
} else {
|
||||
// Stripe not configured - warn in demo mode
|
||||
if (isPodcastOnlyDemoMode()) {
|
||||
console.warn('[PricingPage] ⚠️ DEMO MODE WARNING: Stripe is not configured. Using legacy subscription API.');
|
||||
}
|
||||
}
|
||||
|
||||
console.log('[PricingPage] Making legacy subscription API call:', {
|
||||
@@ -240,10 +311,13 @@ const PricingPage: React.FC = () => {
|
||||
setTimeout(() => {
|
||||
clearInterval(countdownInterval);
|
||||
|
||||
// After subscription, check if onboarding is complete
|
||||
// If not complete, redirect to onboarding; otherwise to dashboard
|
||||
const onboardingComplete = localStorage.getItem('onboarding_complete') === 'true';
|
||||
if (onboardingComplete) {
|
||||
// In podcast-only demo mode, always route users to podcast flow.
|
||||
if (isPodcastOnlyDemoMode()) {
|
||||
navigate('/podcast-maker');
|
||||
} else {
|
||||
const onboardingComplete = localStorage.getItem('onboarding_complete') === 'true';
|
||||
|
||||
if (onboardingComplete) {
|
||||
// Restore navigation state (path, phase, tool) if available
|
||||
const navState = restoreNavigationState();
|
||||
|
||||
@@ -266,12 +340,14 @@ const PricingPage: React.FC = () => {
|
||||
}
|
||||
}
|
||||
} else {
|
||||
navigate('/onboarding');
|
||||
navigate('/onboarding');
|
||||
}
|
||||
}
|
||||
}, 3000);
|
||||
} catch (err) {
|
||||
console.error('Error subscribing:', err);
|
||||
setError('Failed to process subscription');
|
||||
const errorMessage = err instanceof Error ? err.message : 'Failed to process subscription';
|
||||
setError(errorMessage);
|
||||
setSuccessSnackbar({ open: false, message: '', countdown: 0 });
|
||||
} finally {
|
||||
setSubscribing(false);
|
||||
@@ -351,6 +427,8 @@ const PricingPage: React.FC = () => {
|
||||
yearlyBilling={yearlyBilling}
|
||||
selectedPlanId={selectedPlan}
|
||||
subscribing={subscribing}
|
||||
canSelectPlan={activeTierPolicy.selectable.includes(plan.tier)}
|
||||
unavailableLabel={activeTierPolicy.unavailableLabels[plan.tier]}
|
||||
onSelectPlan={setSelectedPlan}
|
||||
onSubscribe={handleSubscribe}
|
||||
openKnowMoreModal={openKnowMoreModal}
|
||||
@@ -392,42 +470,48 @@ const PricingPage: React.FC = () => {
|
||||
}}>
|
||||
<Typography variant="h6" component="h2" gutterBottom sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Warning sx={{ color: 'warning.main' }} />
|
||||
Alpha Testing Subscription
|
||||
{isAlphaMode ? 'Alpha Testing Subscription' : 'Confirm Subscription'}
|
||||
</Typography>
|
||||
|
||||
{/* Alpha Testing Notice */}
|
||||
<Alert severity="warning" sx={{ mb: 2 }}>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600, mb: 0.5 }}>
|
||||
⚠️ Alpha Testing Mode - No Payment Required
|
||||
|
||||
{isAlphaMode ? (
|
||||
<>
|
||||
<Alert severity="warning" sx={{ mb: 2 }}>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600, mb: 0.5 }}>
|
||||
⚠️ Alpha Testing Mode - No Payment Required
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ display: 'block' }}>
|
||||
Payment integration is coming soon. For now, subscriptions are activated without charge.
|
||||
</Typography>
|
||||
</Alert>
|
||||
|
||||
<Typography variant="body1" sx={{ mb: 2 }}>
|
||||
Thank you for participating in our alpha testing! We're crediting this plan to your account.
|
||||
</Typography>
|
||||
|
||||
<Box sx={{
|
||||
p: 2,
|
||||
mb: 3,
|
||||
bgcolor: 'info.lighter',
|
||||
borderRadius: 1,
|
||||
border: '1px solid',
|
||||
borderColor: 'info.light'
|
||||
}}>
|
||||
<Typography variant="body2" color="info.dark">
|
||||
<strong>Coming in Production:</strong>
|
||||
</Typography>
|
||||
<Typography variant="caption" color="info.dark" sx={{ display: 'block', mt: 0.5 }}>
|
||||
• Secure Stripe/PayPal payment processing<br />
|
||||
• Automatic renewal management<br />
|
||||
• Payment verification & receipts<br />
|
||||
• Upgrade/downgrade options
|
||||
</Typography>
|
||||
</Box>
|
||||
</>
|
||||
) : (
|
||||
<Typography variant="body1" sx={{ mb: 3 }}>
|
||||
Please confirm to continue with your selected subscription plan.
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ display: 'block' }}>
|
||||
Payment integration is coming soon. For now, subscriptions are activated without charge.
|
||||
</Typography>
|
||||
</Alert>
|
||||
|
||||
<Typography variant="body1" sx={{ mb: 2 }}>
|
||||
Thank you for participating in our alpha testing! We're crediting the Basic plan ($29 value) to your account.
|
||||
</Typography>
|
||||
|
||||
{/* TODO: Payment Integration Notice */}
|
||||
<Box sx={{
|
||||
p: 2,
|
||||
mb: 3,
|
||||
bgcolor: 'info.lighter',
|
||||
borderRadius: 1,
|
||||
border: '1px solid',
|
||||
borderColor: 'info.light'
|
||||
}}>
|
||||
<Typography variant="body2" color="info.dark">
|
||||
<strong>Coming in Production:</strong>
|
||||
</Typography>
|
||||
<Typography variant="caption" color="info.dark" sx={{ display: 'block', mt: 0.5 }}>
|
||||
• Secure Stripe/PayPal payment processing<br />
|
||||
• Automatic renewal management<br />
|
||||
• Payment verification & receipts<br />
|
||||
• Upgrade/downgrade options
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Note: Current behavior allows renewal without payment verification */}
|
||||
{/* This is intentional for alpha testing but will be secured in production */}
|
||||
|
||||
@@ -69,6 +69,8 @@ interface PlanCardProps {
|
||||
yearlyBilling: boolean;
|
||||
selectedPlanId: number | null;
|
||||
subscribing: boolean;
|
||||
canSelectPlan: boolean;
|
||||
unavailableLabel?: string;
|
||||
onSelectPlan: (planId: number) => void;
|
||||
onSubscribe: (planId: number) => void;
|
||||
openKnowMoreModal: (title: string, content: React.ReactNode) => void;
|
||||
@@ -79,6 +81,8 @@ const PlanCard: React.FC<PlanCardProps> = ({
|
||||
yearlyBilling,
|
||||
selectedPlanId,
|
||||
subscribing,
|
||||
canSelectPlan,
|
||||
unavailableLabel,
|
||||
onSelectPlan,
|
||||
onSubscribe,
|
||||
openKnowMoreModal,
|
||||
@@ -909,13 +913,9 @@ const PlanCard: React.FC<PlanCardProps> = ({
|
||||
</CardContent>
|
||||
|
||||
<CardActions sx={{ justifyContent: 'center', pb: 3, flexDirection: 'column', gap: 1 }}>
|
||||
{plan.tier === 'pro' ? (
|
||||
{!canSelectPlan ? (
|
||||
<Button variant="outlined" size="large" fullWidth disabled sx={{ mb: 1 }}>
|
||||
Coming Soon
|
||||
</Button>
|
||||
) : plan.tier === 'enterprise' ? (
|
||||
<Button variant="outlined" size="large" fullWidth disabled sx={{ mb: 1 }}>
|
||||
Contact Sales
|
||||
{unavailableLabel || 'Unavailable'}
|
||||
</Button>
|
||||
) : (
|
||||
<>
|
||||
@@ -951,4 +951,3 @@ const PlanCard: React.FC<PlanCardProps> = ({
|
||||
};
|
||||
|
||||
export default PlanCard;
|
||||
|
||||
|
||||
@@ -156,11 +156,11 @@ export const TrendsChart: React.FC<TrendsChartProps> = ({
|
||||
border: `1px solid ${theme.palette.divider}`,
|
||||
borderRadius: '8px',
|
||||
}}
|
||||
formatter={(value: any, name: string) => {
|
||||
formatter={(value: any, name: any) => {
|
||||
if (typeof value === 'number') {
|
||||
return [`${Math.round(value)}`, name];
|
||||
return [`${Math.round(value)}`, String(name)];
|
||||
}
|
||||
return [value, name];
|
||||
return [value, String(name)];
|
||||
}}
|
||||
labelFormatter={(label) => `Date: ${label}`}
|
||||
/>
|
||||
|
||||
@@ -418,7 +418,7 @@ const AdvancedCostAnalytics: React.FC<AdvancedCostAnalyticsProps> = ({
|
||||
border: '1px solid rgba(255,255,255,0.1)',
|
||||
borderRadius: 8
|
||||
}}
|
||||
formatter={(value: number) => [formatCurrency(value), 'Cost']}
|
||||
formatter={(value: any) => [formatCurrency(Number(value) || 0), 'Cost']}
|
||||
/>
|
||||
<Bar dataKey="cost" fill="#667eea" radius={[4, 4, 0, 0]} />
|
||||
</LazyBarChart>
|
||||
@@ -478,7 +478,7 @@ const AdvancedCostAnalytics: React.FC<AdvancedCostAnalyticsProps> = ({
|
||||
))}
|
||||
</Pie>
|
||||
<RechartsTooltip
|
||||
formatter={(value: number) => formatCurrency(value)}
|
||||
formatter={(value: any) => formatCurrency(Number(value) || 0)}
|
||||
contentStyle={{
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.8)',
|
||||
border: '1px solid rgba(255,255,255,0.1)',
|
||||
|
||||
169
frontend/src/components/shared/TextToSpeechButton.tsx
Normal file
169
frontend/src/components/shared/TextToSpeechButton.tsx
Normal file
@@ -0,0 +1,169 @@
|
||||
import React from 'react';
|
||||
import { IconButton, Tooltip, CircularProgress, Box, Menu, MenuItem, ListItemIcon, ListItemText, FormControl, Select, Slider, Typography } from '@mui/material';
|
||||
import { VolumeUp as VolumeUpIcon, Stop as StopIcon, PlayArrow as PlayArrowIcon, Settings as SettingsIcon } from '@mui/icons-material';
|
||||
import { useTextToSpeech, SpeechSynthesisOptions } from '../../hooks/useTextToSpeech';
|
||||
|
||||
interface TextToSpeechButtonProps {
|
||||
text: string;
|
||||
textToSpeak?: string; // Optional different text to speak (e.g., shorter version)
|
||||
options?: SpeechSynthesisOptions;
|
||||
size?: 'small' | 'medium' | 'large';
|
||||
showSettings?: boolean;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export const TextToSpeechButton: React.FC<TextToSpeechButtonProps> = ({
|
||||
text,
|
||||
textToSpeak,
|
||||
options,
|
||||
size = 'medium',
|
||||
showSettings = false,
|
||||
disabled = false,
|
||||
}) => {
|
||||
const { speak, stop, isSpeaking, isSupported, voices, pause, resume, isPaused } = useTextToSpeech();
|
||||
|
||||
const [anchorEl, setAnchorEl] = React.useState<null | HTMLElement>(null);
|
||||
const [selectedVoice, setSelectedVoice] = React.useState<SpeechSynthesisVoice | null>(null);
|
||||
const [rate, setRate] = React.useState(1);
|
||||
const [pitch, setPitch] = React.useState(1);
|
||||
const [volume, setVolume] = React.useState(1);
|
||||
|
||||
const handleClick = (event: React.MouseEvent<HTMLElement>) => {
|
||||
if (showSettings) {
|
||||
setAnchorEl(event.currentTarget);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
setAnchorEl(null);
|
||||
};
|
||||
|
||||
const handleSpeak = () => {
|
||||
const textToUse = textToSpeak || text;
|
||||
if (!textToUse.trim()) return;
|
||||
|
||||
if (isSpeaking) {
|
||||
stop();
|
||||
} else {
|
||||
speak(textToUse, {
|
||||
voice: selectedVoice || undefined,
|
||||
rate,
|
||||
pitch,
|
||||
volume,
|
||||
...options,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
if (!isSupported) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const iconSize = size === 'small' ? 18 : size === 'medium' ? 24 : 30;
|
||||
const buttonSize = size === 'small' ? 'small' : size === 'medium' ? 'medium' : 'large';
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'inline-flex', alignItems: 'center' }}>
|
||||
<Tooltip title={isSpeaking ? "Stop" : "Read aloud"}>
|
||||
<IconButton
|
||||
onClick={handleSpeak}
|
||||
size={buttonSize}
|
||||
disabled={disabled || !text.trim()}
|
||||
sx={{
|
||||
color: isSpeaking ? '#ef4444' : '#667eea',
|
||||
backgroundColor: isSpeaking ? 'rgba(239, 68, 68, 0.1)' : 'rgba(102, 126, 234, 0.1)',
|
||||
'&:hover': {
|
||||
backgroundColor: isSpeaking ? 'rgba(239, 68, 68, 0.2)' : 'rgba(102, 126, 234, 0.2)',
|
||||
},
|
||||
}}
|
||||
>
|
||||
{isSpeaking ? <StopIcon sx={{ fontSize: iconSize }} /> : <VolumeUpIcon sx={{ fontSize: iconSize }} />}
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
|
||||
{showSettings && (
|
||||
<>
|
||||
<Tooltip title="Voice settings">
|
||||
<IconButton
|
||||
onClick={handleClick}
|
||||
size={buttonSize}
|
||||
sx={{ ml: 0.5, color: 'rgba(0,0,0,0.5)' }}
|
||||
>
|
||||
<SettingsIcon sx={{ fontSize: iconSize * 0.75 }} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
|
||||
<Menu
|
||||
anchorEl={anchorEl}
|
||||
open={Boolean(anchorEl)}
|
||||
onClose={handleClose}
|
||||
PaperProps={{ sx: { p: 2, minWidth: 280 } }}
|
||||
>
|
||||
<Typography variant="subtitle2" sx={{ mb: 1, fontWeight: 600 }}>
|
||||
Voice Settings
|
||||
</Typography>
|
||||
|
||||
{/* Voice Selection */}
|
||||
<FormControl fullWidth size="small" sx={{ mb: 2 }}>
|
||||
<Typography variant="caption" sx={{ mb: 0.5, display: 'block' }}>Voice</Typography>
|
||||
<Select
|
||||
value={selectedVoice?.name || ''}
|
||||
onChange={(e) => {
|
||||
const voice = voices.find(v => v.name === e.target.value);
|
||||
setSelectedVoice(voice || null);
|
||||
}}
|
||||
displayEmpty
|
||||
>
|
||||
<MenuItem value="">
|
||||
<em>Default</em>
|
||||
</MenuItem>
|
||||
{voices.map((voice) => (
|
||||
<MenuItem key={voice.name} value={voice.name}>
|
||||
{voice.name.split(' ')[0]}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
{/* Speed */}
|
||||
<Typography variant="caption">Speed: {rate}x</Typography>
|
||||
<Slider
|
||||
value={rate}
|
||||
min={0.5}
|
||||
max={2}
|
||||
step={0.1}
|
||||
onChange={(_, value) => setRate(value as number)}
|
||||
size="small"
|
||||
sx={{ mb: 2 }}
|
||||
/>
|
||||
|
||||
{/* Pitch */}
|
||||
<Typography variant="caption">Pitch: {pitch}</Typography>
|
||||
<Slider
|
||||
value={pitch}
|
||||
min={0.5}
|
||||
max={2}
|
||||
step={0.1}
|
||||
onChange={(_, value) => setPitch(value as number)}
|
||||
size="small"
|
||||
sx={{ mb: 2 }}
|
||||
/>
|
||||
|
||||
{/* Volume */}
|
||||
<Typography variant="caption">Volume: {Math.round(volume * 100)}%</Typography>
|
||||
<Slider
|
||||
value={volume}
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.1}
|
||||
onChange={(_, value) => setVolume(value as number)}
|
||||
size="small"
|
||||
/>
|
||||
</Menu>
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default TextToSpeechButton;
|
||||
321
frontend/src/components/shared/VoiceSelector.tsx
Normal file
321
frontend/src/components/shared/VoiceSelector.tsx
Normal file
@@ -0,0 +1,321 @@
|
||||
import React, { useState, useEffect, useMemo } from "react";
|
||||
import {
|
||||
Box,
|
||||
Typography,
|
||||
Select,
|
||||
MenuItem,
|
||||
FormControl,
|
||||
InputLabel,
|
||||
Stack,
|
||||
Button,
|
||||
Chip,
|
||||
CircularProgress,
|
||||
Tooltip,
|
||||
alpha,
|
||||
IconButton,
|
||||
ListItemIcon,
|
||||
ListItemText,
|
||||
} from "@mui/material";
|
||||
import {
|
||||
Mic,
|
||||
PlayArrow,
|
||||
Pause,
|
||||
CloudUpload,
|
||||
HelpOutline,
|
||||
AutoAwesome,
|
||||
CheckCircle,
|
||||
} from "@mui/icons-material";
|
||||
import { getLatestVoiceClone, VoiceCloneResponse } from "../../api/brandAssets";
|
||||
|
||||
export type VoiceOption = {
|
||||
id: string;
|
||||
name: string;
|
||||
personality?: string;
|
||||
isCustom?: boolean;
|
||||
previewUrl?: string;
|
||||
};
|
||||
|
||||
interface VoiceSelectorProps {
|
||||
value: string;
|
||||
onChange: (voiceId: string) => void;
|
||||
disabled?: boolean;
|
||||
showVoiceClone?: boolean;
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
const PREDEFINED_VOICES: VoiceOption[] = [
|
||||
{ id: "Wise_Woman", name: "Wise Woman", personality: "Authoritative, trustworthy female voice - perfect for educational content" },
|
||||
{ id: "Friendly_Person", name: "Friendly Person", personality: "Warm, approachable voice - great for welcoming introductions" },
|
||||
{ id: "Inspirational_girl", name: "Inspirational Girl", personality: "Motivational, uplifting female voice - ideal for inspiration" },
|
||||
{ id: "Deep_Voice_Man", name: "Deep Voice Man", personality: "Powerful, commanding male voice - excellent for serious topics" },
|
||||
{ id: "Calm_Woman", name: "Calm Woman", personality: "Soothing, composed female voice - perfect for meditation or sensitive topics" },
|
||||
{ id: "Casual_Guy", name: "Casual Guy", personality: "Relaxed, conversational male voice - great for vlogs and tutorials" },
|
||||
{ id: "Lively_Girl", name: "Lively Girl", personality: "Energetic, enthusiastic female voice - ideal for exciting announcements" },
|
||||
{ id: "Patient_Man", name: "Patient Man", personality: "Gentle, understanding male voice - perfect for explanations" },
|
||||
{ id: "Young_Knight", name: "Young Knight", personality: "Brave, confident male voice - great for adventure and gaming" },
|
||||
{ id: "Determined_Man", name: "Determined Man", personality: "Strong, resolute male voice - excellent for motivational speeches" },
|
||||
{ id: "Lovely_Girl", name: "Lovely Girl", personality: "Sweet, charming female voice - ideal for storytelling" },
|
||||
{ id: "Decent_Boy", name: "Decent Boy", personality: "Honest, sincere male voice - perfect for testimonials" },
|
||||
{ id: "Imposing_Manner", name: "Imposing Manner", personality: "Formal, dignified male voice - great for corporate content" },
|
||||
{ id: "Elegant_Man", name: "Elegant Man", personality: "Refined, sophisticated male voice - ideal for luxury content" },
|
||||
{ id: "Abbess", name: "Abbess", personality: "Spiritual, serene female voice - perfect for meditation" },
|
||||
{ id: "Sweet_Girl_2", name: "Sweet Girl 2", personality: "Gentle, melodic female voice - excellent for children's content" },
|
||||
{ id: "Exuberant_Girl", name: "Exuberant Girl", personality: "Joyful, expressive female voice - ideal for celebrations" },
|
||||
];
|
||||
|
||||
const VOICE_CLONE_ID = "MY_VOICE_CLONE";
|
||||
|
||||
export const VoiceSelector: React.FC<VoiceSelectorProps> = ({
|
||||
value,
|
||||
onChange,
|
||||
disabled = false,
|
||||
showVoiceClone = true,
|
||||
compact = false,
|
||||
}) => {
|
||||
const [voiceClone, setVoiceClone] = useState<VoiceCloneResponse | null>(null);
|
||||
const [loadingVoiceClone, setLoadingVoiceClone] = useState(false);
|
||||
const [playingPreview, setPlayingPreview] = useState<string | null>(null);
|
||||
|
||||
const voiceOptions = useMemo(() => {
|
||||
const options: VoiceOption[] = [...PREDEFINED_VOICES];
|
||||
|
||||
if (showVoiceClone && voiceClone?.success && voiceClone.custom_voice_id) {
|
||||
options.unshift({
|
||||
id: VOICE_CLONE_ID,
|
||||
name: voiceClone.voice_name || voiceClone.custom_voice_id || "My Voice Clone",
|
||||
personality: "Your own voice - cloned from audio sample",
|
||||
isCustom: true,
|
||||
previewUrl: voiceClone.preview_audio_url,
|
||||
});
|
||||
}
|
||||
|
||||
return options;
|
||||
}, [showVoiceClone, voiceClone]);
|
||||
|
||||
const selectedVoice = useMemo(() => {
|
||||
if (value === VOICE_CLONE_ID && voiceClone?.success) {
|
||||
return voiceOptions.find(v => v.id === VOICE_CLONE_ID);
|
||||
}
|
||||
return voiceOptions.find(v => v.id === value) || voiceOptions[0];
|
||||
}, [value, voiceOptions, voiceClone]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!showVoiceClone) return;
|
||||
|
||||
const fetchVoiceClone = async () => {
|
||||
try {
|
||||
setLoadingVoiceClone(true);
|
||||
const result = await getLatestVoiceClone();
|
||||
setVoiceClone(result);
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch voice clone:", error);
|
||||
} finally {
|
||||
setLoadingVoiceClone(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchVoiceClone();
|
||||
}, [showVoiceClone]);
|
||||
|
||||
const handlePreview = (voice: VoiceOption) => {
|
||||
if (!voice.previewUrl) return;
|
||||
|
||||
if (playingPreview === voice.id) {
|
||||
const audio = document.getElementById(`voice-preview-${voice.id}`) as HTMLAudioElement;
|
||||
if (audio) {
|
||||
audio.pause();
|
||||
audio.currentTime = 0;
|
||||
}
|
||||
setPlayingPreview(null);
|
||||
} else {
|
||||
setPlayingPreview(voice.id);
|
||||
const audio = new Audio(voice.previewUrl);
|
||||
audio.id = `voice-preview-${voice.id}`;
|
||||
audio.onerror = () => {
|
||||
console.error("Failed to load voice preview audio");
|
||||
setPlayingPreview(null);
|
||||
};
|
||||
audio.onended = () => setPlayingPreview(null);
|
||||
audio.play().catch((err) => {
|
||||
console.error("Failed to play voice preview:", err);
|
||||
setPlayingPreview(null);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleChange = (newValue: string) => {
|
||||
if (newValue === VOICE_CLONE_ID && voiceClone?.success) {
|
||||
onChange(voiceClone.custom_voice_id || VOICE_CLONE_ID);
|
||||
} else {
|
||||
onChange(newValue);
|
||||
}
|
||||
};
|
||||
|
||||
const isVoiceCloneSelected = value === VOICE_CLONE_ID ||
|
||||
(voiceClone?.success && voiceClone.custom_voice_id && value === voiceClone.custom_voice_id);
|
||||
|
||||
if (compact) {
|
||||
return (
|
||||
<FormControl fullWidth size="small">
|
||||
<InputLabel>Voice</InputLabel>
|
||||
<Select
|
||||
value={isVoiceCloneSelected ? VOICE_CLONE_ID : value}
|
||||
onChange={(e) => handleChange(e.target.value)}
|
||||
label="Voice"
|
||||
disabled={disabled}
|
||||
startAdornment={
|
||||
<ListItemIcon sx={{ minWidth: 32 }}>
|
||||
<Mic fontSize="small" sx={{ color: isVoiceCloneSelected ? "#667eea" : "inherit" }} />
|
||||
</ListItemIcon>
|
||||
}
|
||||
>
|
||||
{voiceOptions.map((voice) => (
|
||||
<MenuItem key={voice.id} value={voice.id}>
|
||||
<ListItemText
|
||||
primary={voice.name}
|
||||
secondary={voice.isCustom ? "Custom voice clone" : voice.personality?.split(' - ')[0]}
|
||||
/>
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Stack direction="row" spacing={1} alignItems="center" sx={{ mb: 1 }}>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 600 }}>
|
||||
Voice
|
||||
</Typography>
|
||||
<Tooltip title="Choose a system voice or your custom cloned voice" arrow>
|
||||
<IconButton size="small" sx={{ color: "rgba(0,0,0,0.5)" }}>
|
||||
<HelpOutline fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
{showVoiceClone && loadingVoiceClone && (
|
||||
<CircularProgress size={16} sx={{ ml: 1 }} />
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
<FormControl fullWidth>
|
||||
<Select
|
||||
value={isVoiceCloneSelected ? VOICE_CLONE_ID : value}
|
||||
onChange={(e) => handleChange(e.target.value)}
|
||||
disabled={disabled}
|
||||
renderValue={(selected) => {
|
||||
const voice = voiceOptions.find(v =>
|
||||
v.id === selected ||
|
||||
(selected === VOICE_CLONE_ID && v.isCustom)
|
||||
);
|
||||
return (
|
||||
<Stack direction="row" spacing={1} alignItems="center">
|
||||
<Mic fontSize="small" sx={{ color: voice?.isCustom ? "#667eea" : "inherit" }} />
|
||||
<Typography>{voice?.name}</Typography>
|
||||
{voice?.isCustom && (
|
||||
<Chip
|
||||
label="Cloned"
|
||||
size="small"
|
||||
sx={{
|
||||
bgcolor: alpha("#667eea", 0.1),
|
||||
color: "#667eea",
|
||||
height: 20,
|
||||
fontSize: "0.7rem",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
}}
|
||||
MenuProps={{
|
||||
PaperProps: {
|
||||
sx: {
|
||||
maxHeight: 400,
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
{showVoiceClone && voiceClone?.success && voiceClone.custom_voice_id && (
|
||||
<MenuItem value={VOICE_CLONE_ID} sx={{ borderBottom: '1px solid rgba(0,0,0,0.1)' }}>
|
||||
<ListItemIcon>
|
||||
<AutoAwesome sx={{ color: "#667eea" }} />
|
||||
</ListItemIcon>
|
||||
<ListItemText
|
||||
primary={
|
||||
<Stack direction="row" spacing={1} alignItems="center">
|
||||
<Typography fontWeight={600} sx={{ color: "#667eea" }}>
|
||||
My Voice Clone
|
||||
</Typography>
|
||||
<Chip
|
||||
icon={<CheckCircle sx={{ fontSize: "14px !important" }} />}
|
||||
label="Active"
|
||||
size="small"
|
||||
sx={{
|
||||
bgcolor: alpha("#10b981", 0.1),
|
||||
color: "#10b981",
|
||||
height: 20,
|
||||
fontSize: "0.65rem",
|
||||
'& .MuiChip-icon': { color: "#10b981" }
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
}
|
||||
secondary={
|
||||
voiceClone.preview_audio_url && (
|
||||
<Button
|
||||
size="small"
|
||||
startIcon={playingPreview === VOICE_CLONE_ID ? <Pause /> : <PlayArrow />}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handlePreview({
|
||||
id: VOICE_CLONE_ID,
|
||||
name: voiceClone.voice_name || "My Voice Clone",
|
||||
previewUrl: voiceClone.preview_audio_url
|
||||
});
|
||||
}}
|
||||
sx={{ mt: 0.5, textTransform: 'none' }}
|
||||
>
|
||||
{playingPreview === VOICE_CLONE_ID ? "Stop" : "Preview"}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
/>
|
||||
</MenuItem>
|
||||
)}
|
||||
|
||||
<MenuItem disabled sx={{ opacity: 0.6 }}>
|
||||
<Typography variant="caption">System Voices</Typography>
|
||||
</MenuItem>
|
||||
|
||||
{voiceOptions.filter(v => !v.isCustom).map((voice) => (
|
||||
<MenuItem key={voice.id} value={voice.id}>
|
||||
<ListItemText
|
||||
primary={voice.name}
|
||||
secondary={voice.personality?.split(' - ')[0]}
|
||||
/>
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
{selectedVoice?.personality && (
|
||||
<Typography variant="caption" sx={{ color: "text.secondary", mt: 0.5, display: 'block' }}>
|
||||
{selectedVoice.personality}
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
{showVoiceClone && !voiceClone?.success && (
|
||||
<Box sx={{ mt: 2, p: 2, bgcolor: alpha("#f8fafc", 0.5), borderRadius: 2, border: '1px dashed rgba(0,0,0,0.1)' }}>
|
||||
<Stack direction="row" spacing={1} alignItems="center">
|
||||
<CloudUpload sx={{ color: "#64748b" }} />
|
||||
<Typography variant="body2" sx={{ color: "#64748b" }}>
|
||||
Don't see your voice? Go to Onboarding → Voice Cloning to create your custom voice clone.
|
||||
</Typography>
|
||||
</Stack>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default VoiceSelector;
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, { createContext, useContext, useState, useEffect, useCallback, ReactNode } from 'react';
|
||||
import { useAuth } from '@clerk/clerk-react';
|
||||
import { apiClient } from '../api/client';
|
||||
import { shouldSkipOnboarding } from '../utils/demoMode';
|
||||
|
||||
/**
|
||||
* Onboarding Context
|
||||
@@ -102,6 +103,13 @@ export const OnboardingProvider: React.FC<OnboardingProviderProps> = ({ children
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
// Skip onboarding fetch in demo mode - onboarding is disabled
|
||||
if (shouldSkipOnboarding()) {
|
||||
console.log('OnboardingContext: Skipping onboarding fetch in demo mode');
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('OnboardingContext: Fetching onboarding data for authenticated user...');
|
||||
|
||||
// Call batch init endpoint
|
||||
@@ -159,6 +167,12 @@ export const OnboardingProvider: React.FC<OnboardingProviderProps> = ({ children
|
||||
// Separate effect to fetch data when explicitly requested
|
||||
const initializeOnboarding = useCallback(() => {
|
||||
if (isSignedIn && clerkLoaded) {
|
||||
// Skip onboarding initialization in demo mode
|
||||
if (shouldSkipOnboarding()) {
|
||||
console.log('OnboardingContext: Skipping onboarding init in demo mode');
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
console.log('OnboardingContext: Initializing onboarding data...');
|
||||
fetchOnboardingData();
|
||||
}
|
||||
|
||||
@@ -61,6 +61,8 @@ export interface PodcastProjectState {
|
||||
const DEFAULT_KNOBS: Knobs = {
|
||||
voice_emotion: "neutral",
|
||||
voice_speed: 1,
|
||||
voice_id: "Wise_Woman",
|
||||
custom_voice_id: undefined,
|
||||
resolution: "720p",
|
||||
scene_length_target: 45,
|
||||
sample_rate: 24000,
|
||||
@@ -338,7 +340,12 @@ export const usePodcastProjectState = () => {
|
||||
budget_cap: payload.budgetCap,
|
||||
avatar_url: finalAvatarUrl,
|
||||
});
|
||||
} catch (error) {
|
||||
} catch (error: any) {
|
||||
const errorStr = error?.message || "";
|
||||
if (errorStr.includes("DUPLICATE_IDEA")) {
|
||||
// Re-throw duplicate idea error for UI handling
|
||||
throw error;
|
||||
}
|
||||
console.error('Error creating project in database:', error);
|
||||
// Continue anyway - localStorage fallback
|
||||
}
|
||||
|
||||
190
frontend/src/hooks/useTextToSpeech.ts
Normal file
190
frontend/src/hooks/useTextToSpeech.ts
Normal file
@@ -0,0 +1,190 @@
|
||||
import { useState, useCallback, useRef, useEffect, useMemo } from 'react';
|
||||
|
||||
export interface SpeechSynthesisOptions {
|
||||
voice?: SpeechSynthesisVoice;
|
||||
rate?: number; // 0.1 to 10
|
||||
pitch?: number; // 0 to 2
|
||||
volume?: number; // 0 to 1
|
||||
}
|
||||
|
||||
export interface UseTextToSpeechReturn {
|
||||
speak: (text: string, options?: SpeechSynthesisOptions) => void;
|
||||
stop: () => void;
|
||||
pause: () => void;
|
||||
resume: () => void;
|
||||
isSupported: boolean;
|
||||
isSpeaking: boolean;
|
||||
isPaused: boolean;
|
||||
voices: SpeechSynthesisVoice[];
|
||||
currentText: string | null;
|
||||
}
|
||||
|
||||
// Singleton to manage global speech synthesis state
|
||||
let globalIsSpeaking = false;
|
||||
let globalIsPaused = false;
|
||||
let globalCurrentText: string | null = null;
|
||||
let globalOnStateChange: ((state: { isSpeaking: boolean; isPaused: boolean; currentText: string | null }) => void) | null = null;
|
||||
|
||||
const notifyStateChange = () => {
|
||||
if (globalOnStateChange) {
|
||||
globalOnStateChange({
|
||||
isSpeaking: globalIsSpeaking,
|
||||
isPaused: globalIsPaused,
|
||||
currentText: globalCurrentText,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export const useTextToSpeech = (): UseTextToSpeechReturn => {
|
||||
const [voices, setVoices] = useState<SpeechSynthesisVoice[]>([]);
|
||||
const synthRef = useRef<SpeechSynthesis | null>(null);
|
||||
|
||||
const isSupported = typeof window !== 'undefined' && 'speechSynthesis' in window;
|
||||
|
||||
// Initialize singleton listener
|
||||
useEffect(() => {
|
||||
globalOnStateChange = (state) => {
|
||||
// Force re-render by using state setter (handled through component-local state)
|
||||
};
|
||||
return () => {
|
||||
globalOnStateChange = null;
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Load available voices
|
||||
useEffect(() => {
|
||||
if (!isSupported) return;
|
||||
|
||||
synthRef.current = window.speechSynthesis;
|
||||
|
||||
const loadVoices = () => {
|
||||
const availableVoices = synthRef.current?.getVoices() || [];
|
||||
setVoices(availableVoices);
|
||||
};
|
||||
|
||||
loadVoices();
|
||||
|
||||
// Voices may load asynchronously
|
||||
synthRef.current.onvoiceschanged = loadVoices;
|
||||
|
||||
// Cleanup on unmount - stop any ongoing speech
|
||||
return () => {
|
||||
if (synthRef.current) {
|
||||
synthRef.current.cancel();
|
||||
synthRef.current.onvoiceschanged = null;
|
||||
}
|
||||
};
|
||||
}, [isSupported]);
|
||||
|
||||
const stop = useCallback(() => {
|
||||
if (synthRef.current) {
|
||||
synthRef.current.cancel();
|
||||
}
|
||||
globalIsSpeaking = false;
|
||||
globalIsPaused = false;
|
||||
globalCurrentText = null;
|
||||
notifyStateChange();
|
||||
}, []);
|
||||
|
||||
const pause = useCallback(() => {
|
||||
if (synthRef.current && globalIsSpeaking && !globalIsPaused) {
|
||||
synthRef.current.pause();
|
||||
globalIsPaused = true;
|
||||
notifyStateChange();
|
||||
}
|
||||
}, []);
|
||||
|
||||
const resume = useCallback(() => {
|
||||
if (synthRef.current && globalIsPaused) {
|
||||
synthRef.current.resume();
|
||||
globalIsPaused = false;
|
||||
notifyStateChange();
|
||||
}
|
||||
}, []);
|
||||
|
||||
const speak = useCallback((text: string, options?: SpeechSynthesisOptions) => {
|
||||
if (!isSupported || !synthRef.current || !text?.trim()) return;
|
||||
|
||||
// Stop any current speech first
|
||||
synthRef.current.cancel();
|
||||
|
||||
const utterance = new SpeechSynthesisUtterance(text);
|
||||
|
||||
// Apply options
|
||||
if (options?.voice) {
|
||||
utterance.voice = options.voice;
|
||||
}
|
||||
if (options?.rate !== undefined) {
|
||||
utterance.rate = Math.max(0.1, Math.min(10, options.rate));
|
||||
}
|
||||
if (options?.pitch !== undefined) {
|
||||
utterance.pitch = Math.max(0, Math.min(2, options.pitch));
|
||||
}
|
||||
if (options?.volume !== undefined) {
|
||||
utterance.volume = Math.max(0, Math.min(1, options.volume));
|
||||
}
|
||||
|
||||
// Set up event handlers
|
||||
utterance.onstart = () => {
|
||||
globalIsSpeaking = true;
|
||||
globalIsPaused = false;
|
||||
globalCurrentText = text;
|
||||
notifyStateChange();
|
||||
};
|
||||
|
||||
utterance.onend = () => {
|
||||
globalIsSpeaking = false;
|
||||
globalIsPaused = false;
|
||||
globalCurrentText = null;
|
||||
notifyStateChange();
|
||||
};
|
||||
|
||||
utterance.onerror = (event) => {
|
||||
console.error('Speech synthesis error:', event.error);
|
||||
globalIsSpeaking = false;
|
||||
globalIsPaused = false;
|
||||
globalCurrentText = null;
|
||||
notifyStateChange();
|
||||
};
|
||||
|
||||
utterance.onpause = () => {
|
||||
globalIsPaused = true;
|
||||
notifyStateChange();
|
||||
};
|
||||
utterance.onresume = () => {
|
||||
globalIsPaused = false;
|
||||
notifyStateChange();
|
||||
};
|
||||
|
||||
synthRef.current.speak(utterance);
|
||||
}, [isSupported]);
|
||||
|
||||
// Use local state for reactivity but sync with global state
|
||||
const [localIsSpeaking, setLocalIsSpeaking] = useState(false);
|
||||
const [localIsPaused, setLocalIsPaused] = useState(false);
|
||||
const [localCurrentText, setLocalCurrentText] = useState<string | null>(null);
|
||||
|
||||
// Sync with global state periodically
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
setLocalIsSpeaking(globalIsSpeaking);
|
||||
setLocalIsPaused(globalIsPaused);
|
||||
setLocalCurrentText(globalCurrentText);
|
||||
}, 100);
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
speak,
|
||||
stop,
|
||||
pause,
|
||||
resume,
|
||||
isSupported,
|
||||
isSpeaking: localIsSpeaking,
|
||||
isPaused: localIsPaused,
|
||||
voices,
|
||||
currentText: localCurrentText,
|
||||
};
|
||||
};
|
||||
|
||||
export default useTextToSpeech;
|
||||
@@ -24,6 +24,8 @@ import { TaskStatus } from "./storyWriterApi";
|
||||
const DEFAULT_KNOBS: Knobs = {
|
||||
voice_emotion: "neutral",
|
||||
voice_speed: 1,
|
||||
voice_id: "Wise_Woman",
|
||||
custom_voice_id: undefined,
|
||||
resolution: "720p",
|
||||
scene_length_target: 45,
|
||||
sample_rate: 24000,
|
||||
@@ -119,31 +121,46 @@ const mapPersonaQueries = (persona: ResearchPersona | undefined, seed: string):
|
||||
return generated.slice(0, 6);
|
||||
};
|
||||
|
||||
const mapSourcesToFacts = (sources: ExaSource[]): Fact[] => {
|
||||
if (!sources || !sources.length) return [];
|
||||
return sources.slice(0, 12).map((source: ExaSource, idx: number) => ({
|
||||
id: source.url || createId("fact"),
|
||||
quote: source.excerpt || source.title || "Insight",
|
||||
url: source.url || "",
|
||||
date: source.published_at || "Unknown",
|
||||
confidence: typeof (source as any).credibility_score === "number" ? (source as any).credibility_score : Math.max(0.5, 0.85 - idx * 0.02),
|
||||
image: source.image,
|
||||
author: source.author,
|
||||
highlights: source.highlights,
|
||||
}));
|
||||
};
|
||||
|
||||
type ExaSource = {
|
||||
title?: string;
|
||||
url?: string;
|
||||
excerpt?: string;
|
||||
published_at?: string;
|
||||
publishedDate?: string; // Exa format
|
||||
highlights?: string[];
|
||||
summary?: string;
|
||||
source_type?: string;
|
||||
index?: number;
|
||||
image?: string;
|
||||
author?: string;
|
||||
text?: string; // Exa full text content
|
||||
credibility_score?: number;
|
||||
};
|
||||
|
||||
const mapSourcesToFacts = (sources: ExaSource[]): Fact[] => {
|
||||
if (!sources || !sources.length) return [];
|
||||
|
||||
// Deduplicate by URL
|
||||
const seenUrls = new Set<string>();
|
||||
const uniqueSources = sources.filter(s => {
|
||||
if (!s.url || seenUrls.has(s.url)) return false;
|
||||
seenUrls.add(s.url);
|
||||
return true;
|
||||
});
|
||||
|
||||
return uniqueSources.slice(0, 12).map((source: ExaSource, idx: number) => ({
|
||||
id: source.url || `fact-${idx}`,
|
||||
quote: source.excerpt || source.highlights?.[0] || source.summary || source.title || "Insight",
|
||||
url: source.url || "",
|
||||
// Use published_at (backend format) or publishedDate (Exa format)
|
||||
date: source.published_at || source.publishedDate || "Unknown",
|
||||
confidence: source.credibility_score || Math.max(0.5, 0.85 - idx * 0.02),
|
||||
image: source.image,
|
||||
author: source.author,
|
||||
highlights: source.highlights,
|
||||
// Include full text if available
|
||||
fullText: source.text,
|
||||
}));
|
||||
};
|
||||
|
||||
type ExaResearchResult = {
|
||||
@@ -180,7 +197,9 @@ const mapExaResearchResponse = (response: any): Research => {
|
||||
};
|
||||
|
||||
const ensurePreflight = async (operation: PreflightOperation) => {
|
||||
console.log('[podcastApi] Running preflight for:', operation);
|
||||
const result = await checkPreflight(operation);
|
||||
console.log('[podcastApi] Preflight result:', result);
|
||||
if (!result.can_proceed) {
|
||||
const message = result.operations[0]?.message || "Pre-flight validation failed";
|
||||
throw new Error(message);
|
||||
@@ -222,6 +241,10 @@ export const podcastApi = {
|
||||
suggestedOutlines: outlines,
|
||||
suggestedKnobs: { ...DEFAULT_KNOBS, ...payload.knobs },
|
||||
titleSuggestions: (analysisResp.data?.title_suggestions || []).filter(Boolean),
|
||||
episode_hook: analysisResp.data?.episode_hook || "",
|
||||
key_takeaways: analysisResp.data?.key_takeaways || [],
|
||||
guest_talking_points: analysisResp.data?.guest_talking_points || [],
|
||||
listener_cta: analysisResp.data?.listener_cta || "",
|
||||
research_queries: analysisResp.data?.research_queries || [],
|
||||
exaSuggestedConfig: analysisResp.data?.exa_suggested_config || undefined,
|
||||
};
|
||||
@@ -241,6 +264,9 @@ export const podcastApi = {
|
||||
queries = mapPersonaQueries(researchConfig?.research_persona, storyIdea);
|
||||
}
|
||||
|
||||
// Note: selectedQueries should be set to empty Set by the caller (workflow)
|
||||
// so users can manually choose which queries to run
|
||||
|
||||
const projectId = createId("podcast");
|
||||
const estimate = estimateCosts({
|
||||
minutes: payload.duration,
|
||||
@@ -303,13 +329,20 @@ export const podcastApi = {
|
||||
actual_provider_name: "exa",
|
||||
});
|
||||
|
||||
const response = await aiApiClient.post("/api/podcast/research/exa", {
|
||||
topic: params.topic || keywords[0],
|
||||
queries: keywords,
|
||||
exa_config: sanitizedExaConfig,
|
||||
bible: params.bible,
|
||||
analysis: params.analysis,
|
||||
});
|
||||
let response;
|
||||
try {
|
||||
response = await aiApiClient.post("/api/podcast/research/exa", {
|
||||
topic: params.topic || keywords[0],
|
||||
queries: keywords,
|
||||
exa_config: sanitizedExaConfig,
|
||||
bible: params.bible,
|
||||
analysis: params.analysis,
|
||||
}, { timeout: 300000 }); // 5 minute timeout for research
|
||||
console.log('[podcastApi] Exa research response received:', response.status, response.data);
|
||||
} catch (error: any) {
|
||||
console.error('[podcastApi] Exa research error:', error?.response?.status, error?.response?.data, error?.message);
|
||||
throw error;
|
||||
}
|
||||
|
||||
const exaResult = response.data as ExaResearchResult;
|
||||
if (params.onProgress) {
|
||||
@@ -329,6 +362,7 @@ export const podcastApi = {
|
||||
bible?: any;
|
||||
outline?: any;
|
||||
analysis?: PodcastAnalysis | null;
|
||||
onProgress?: (message: string) => void;
|
||||
}): Promise<Script> {
|
||||
await ensurePreflight({
|
||||
provider: "gemini",
|
||||
@@ -337,6 +371,10 @@ export const podcastApi = {
|
||||
actual_provider_name: "gemini",
|
||||
});
|
||||
|
||||
if (params.onProgress) {
|
||||
params.onProgress("Analyzing research data and extracting key insights...");
|
||||
}
|
||||
|
||||
const response = await aiApiClient.post("/api/podcast/script", {
|
||||
idea: params.idea,
|
||||
duration_minutes: params.durationMinutes,
|
||||
@@ -347,6 +385,10 @@ export const podcastApi = {
|
||||
analysis: params.analysis,
|
||||
});
|
||||
|
||||
if (params.onProgress) {
|
||||
params.onProgress("Creating podcast structure with scenes and dialogue...");
|
||||
}
|
||||
|
||||
const scenes = response.data?.scenes || [];
|
||||
const scriptScenes: Scene[] = scenes.map((scene: any) => ({
|
||||
id: scene.id || createId("scene"),
|
||||
@@ -406,6 +448,7 @@ export const podcastApi = {
|
||||
async renderSceneAudio(params: {
|
||||
scene: Scene;
|
||||
voiceId?: string;
|
||||
customVoiceId?: string;
|
||||
emotion?: string; // Fallback if scene doesn't have emotion
|
||||
speed?: number;
|
||||
volume?: number;
|
||||
@@ -498,6 +541,7 @@ export const podcastApi = {
|
||||
scene_title: params.scene.title,
|
||||
text: textToUse,
|
||||
voice_id: params.voiceId || "Wise_Woman",
|
||||
custom_voice_id: params.customVoiceId || null,
|
||||
speed: params.speed ?? 1.0, // Normal speed (was 0.9, but too slow - causing duration issues)
|
||||
volume: params.volume ?? 1.0,
|
||||
pitch: params.pitch ?? 0.0,
|
||||
@@ -522,7 +566,7 @@ export const podcastApi = {
|
||||
},
|
||||
|
||||
async approveScene(params: { projectId: string; sceneId: string; notes?: string }) {
|
||||
await aiApiClient.post("/api/story/script/approve", {
|
||||
await aiApiClient.post("/api/podcast/script/approve", {
|
||||
project_id: params.projectId,
|
||||
scene_id: params.sceneId,
|
||||
approved: true,
|
||||
@@ -564,8 +608,22 @@ export const podcastApi = {
|
||||
budget_cap: number;
|
||||
avatar_url?: string | null;
|
||||
}): Promise<any> {
|
||||
const response = await aiApiClient.post("/api/podcast/projects", params);
|
||||
return response.data;
|
||||
try {
|
||||
const response = await aiApiClient.post("/api/podcast/projects", params);
|
||||
return response.data;
|
||||
} catch (error: any) {
|
||||
if (error?.response?.status === 409) {
|
||||
// Duplicate idea detected - throw specific error for UI handling
|
||||
const conflictData = error.response.data?.detail || {};
|
||||
throw new Error(JSON.stringify({
|
||||
type: "DUPLICATE_IDEA",
|
||||
existing_project_id: conflictData.existing_project_id,
|
||||
existing_idea: conflictData.existing_idea,
|
||||
message: conflictData.message,
|
||||
}));
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
async updateProject(projectId: string, updates: any): Promise<any> {
|
||||
@@ -582,6 +640,16 @@ export const podcastApi = {
|
||||
return response.data;
|
||||
},
|
||||
|
||||
async regenerateResearchQueries(params: {
|
||||
idea: string;
|
||||
feedback: string;
|
||||
existing_analysis?: any;
|
||||
bible?: any;
|
||||
}): Promise<{ research_queries: { query: string; rationale: string }[] }> {
|
||||
const response = await aiApiClient.post("/api/podcast/regenerate-queries", params);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
async saveAudioToAssetLibrary(params: {
|
||||
audioUrl: string;
|
||||
filename: string;
|
||||
@@ -624,6 +692,9 @@ export const podcastApi = {
|
||||
audioUrl: string;
|
||||
avatarImageUrl?: string;
|
||||
bible?: any;
|
||||
analysis?: any;
|
||||
sceneImagePrompt?: string;
|
||||
sceneNarration?: string;
|
||||
resolution?: string;
|
||||
prompt?: string;
|
||||
seed?: number;
|
||||
@@ -636,6 +707,9 @@ export const podcastApi = {
|
||||
audio_url: params.audioUrl,
|
||||
avatar_image_url: params.avatarImageUrl,
|
||||
bible: params.bible,
|
||||
analysis: params.analysis,
|
||||
scene_image_prompt: params.sceneImagePrompt,
|
||||
scene_narration: params.sceneNarration,
|
||||
resolution: params.resolution || "720p",
|
||||
prompt: params.prompt,
|
||||
seed: params.seed ?? -1,
|
||||
@@ -697,9 +771,15 @@ export const podcastApi = {
|
||||
sceneId: string;
|
||||
sceneTitle: string;
|
||||
sceneContent?: string;
|
||||
sceneEmotion?: string;
|
||||
baseAvatarUrl?: string;
|
||||
bible?: any;
|
||||
idea?: string;
|
||||
analysis?: {
|
||||
audience?: string;
|
||||
contentType?: string;
|
||||
topKeywords?: string[];
|
||||
};
|
||||
width?: number;
|
||||
height?: number;
|
||||
customPrompt?: string;
|
||||
@@ -716,14 +796,17 @@ export const podcastApi = {
|
||||
provider: string;
|
||||
model?: string;
|
||||
cost: number;
|
||||
image_prompt?: string;
|
||||
}> {
|
||||
const response = await aiApiClient.post("/api/podcast/image", {
|
||||
scene_id: params.sceneId,
|
||||
scene_title: params.sceneTitle,
|
||||
scene_content: params.sceneContent,
|
||||
scene_emotion: params.sceneEmotion || null,
|
||||
base_avatar_url: params.baseAvatarUrl || null,
|
||||
bible: params.bible,
|
||||
idea: params.idea || null,
|
||||
analysis: params.analysis || null,
|
||||
width: params.width || 1024,
|
||||
height: params.height || 1024,
|
||||
custom_prompt: params.customPrompt || null,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user