@danhachuel/thunderbolt 0.2.75 → 0.2.77

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/app/main.py CHANGED
@@ -33,6 +33,7 @@ from hermes_ui.python_editor import AUDIO_EXTENSIONS, VIDEO_EXTENSIONS, PythonEd
33
33
  from hermes_ui.cuts import CutsError, download_direct_video_url, generate_clips, list_generated_videos as list_cut_generated_videos, list_runs as list_cut_runs, list_video_files as list_cut_video_files, manifest_bytes as cut_manifest_bytes, store_uploaded_video, zip_run as zip_cut_run
34
34
  from hermes_ui.mcp import detect_local_service, install_skill_locally, load_integrations, load_server_config, read_packaged_skill, save_server_config, update_integration
35
35
  from hermes_ui.mcp_server import server_status, start_server, stop_server
36
+ from hermes_ui.material_sources import material_api_keys, material_source_catalog, selected_material_source, update_material_api_keys
36
37
  from hermes_ui.music import list_music_files, materialize_suno_audio, request_suno_generation, store_music_file
37
38
  from hermes_ui.media_downloader import AUDIO_FORMATS, VIDEO_CONTAINERS, VIDEO_QUALITY_OPTIONS, MediaDownloadError, build_download_options, clear_media_download_history, dependency_status, download_media, list_media_downloads, media_download_file
38
39
  from hermes_ui.notifications import clear_notifications, list_notifications, mark_all_notifications_read, mark_notification_read, notification_event_catalog, notification_preferences, record_notification, reconcile_persisted_notifications, save_notification_preferences, unread_notification_count
@@ -44,6 +45,7 @@ from hermes_ui.creative_generation import CreativeGenerationError, generate_crea
44
45
  from integrations.platforms import IntegrationResult, TikTokAdapter, YouTubeAdapter, fetch_channel_videos_public
45
46
  from integrations.tiktok_public import fetch_public_tiktok_profile, normalize_tiktok_reference
46
47
  from integrations.postiz import PostizAdapter
48
+ from integrations.upload_post import UploadPostAdapter, UPLOAD_POST_PLATFORM_OPTIONS, normalize_upload_post_platforms
47
49
  from integrations.upload_routing import OFFICIAL_DAILY_LIMIT, official_upload_count, upload_with_default_route
48
50
  from integrations.youtube_direct_upload import YouTubeDirectUploader
49
51
  from integrations.youtube_direct_credentials import delete_credentials_document, direct_account_status, document_status, ensure_credentials_document, load_credentials_document, merge_credentials_document, parse_credentials_document, save_credentials_document, update_credentials_document_session_info
@@ -2721,11 +2723,13 @@ def render_upload_direct():
2721
2723
 
2722
2724
  def render_upload():
2723
2725
  st.title("Upload")
2724
- upload_tab, direct_tab, postiz_tab = st.tabs(["Upload convencional", "Upload directo", "Postiz"])
2726
+ upload_tab, direct_tab, postiz_tab, upload_post_tab = st.tabs(["Upload convencional", "Upload directo", "Postiz", "Upload-Post"])
2725
2727
  with direct_tab:
2726
2728
  render_upload_direct()
2727
2729
  with postiz_tab:
2728
2730
  render_upload_postiz()
2731
+ with upload_post_tab:
2732
+ render_upload_post()
2729
2733
  with upload_tab:
2730
2734
  render_upload_conventional()
2731
2735
 
@@ -2807,6 +2811,78 @@ def render_upload_postiz():
2807
2811
  (st.success if result.ok else st.error)(result.message)
2808
2812
 
2809
2813
 
2814
+ def render_upload_post():
2815
+ st.subheader("Upload-Post")
2816
+ st.caption("Envie um vídeo para uma ou mais plataformas ligadas ao seu perfil Upload-Post. A API key é lida da Configuração API e nunca é mostrada nesta aba.")
2817
+ settings = read_json("settings.json", {})
2818
+ uploader = UploadPostAdapter(settings)
2819
+ status = uploader.status()
2820
+ if not status.ok:
2821
+ st.warning(status.message)
2822
+ if not uploader.enabled:
2823
+ st.info("Active Upload-Post em Configuração API > API Keys > Serviços e modelos e guarde a configuração.")
2824
+ elif not uploader.api_key:
2825
+ st.info("Introduza a API key do Upload-Post em Configuração API > API Keys > Serviços e modelos.")
2826
+ return
2827
+
2828
+ configured_platforms = normalize_upload_post_platforms(uploader.platforms)
2829
+ selected_platforms = st.multiselect(
2830
+ "Plataformas Upload-Post",
2831
+ list(UPLOAD_POST_PLATFORM_OPTIONS),
2832
+ default=configured_platforms,
2833
+ format_func=lambda value: "Facebook Pages" if value == "facebook" else "X (Twitter)" if value == "x" else value.title(),
2834
+ key="upload_post_platforms_selector",
2835
+ help="Seleccione uma ou mais plataformas já ligadas ao perfil Upload-Post.",
2836
+ )
2837
+ async_upload = st.checkbox(
2838
+ "Processar em segundo plano",
2839
+ value=False,
2840
+ key="upload_post_async_upload",
2841
+ help="Envia async_upload=true para a API e mostra o request ID devolvido, quando existir.",
2842
+ )
2843
+ if not selected_platforms:
2844
+ st.info("Seleccione pelo menos uma plataforma antes de publicar.")
2845
+
2846
+ tasks = [task for task in read_json("tasks.json", []) if task.get("state") == "done" or task.get("artifacts", {}).get("video")]
2847
+ if not tasks:
2848
+ st.info("Não há vídeos prontos para enviar pelo Upload-Post.")
2849
+ return
2850
+ for task in tasks:
2851
+ artifacts = task.get("artifacts", {}) or {}
2852
+ video_path = artifacts.get("video", "")
2853
+ with st.container(border=True):
2854
+ st.write(f"**{task.get('topic', 'Vídeo Thunderbolt')}** — {uploader.username}")
2855
+ st.caption(video_path or "Sem caminho de vídeo registado")
2856
+ title = st.text_input("Título Upload-Post", value=task.get("title") or task.get("topic", "Vídeo Thunderbolt"), key=f"upload_post_title_{task['id']}")
2857
+ description = st.text_area("Descrição Upload-Post", value=task.get("description", ""), key=f"upload_post_description_{task['id']}", height=90)
2858
+ if st.button("Enviar vídeo pelo Upload-Post", type="primary", key=f"upload_post_send_{task['id']}", disabled=not selected_platforms):
2859
+ result = uploader.upload_video(
2860
+ video_path,
2861
+ title=title,
2862
+ description=description,
2863
+ user=uploader.username,
2864
+ platforms=selected_platforms,
2865
+ async_upload=async_upload,
2866
+ )
2867
+ record = {
2868
+ "id": uuid.uuid4().hex,
2869
+ "task_id": task.get("id"),
2870
+ "destination": "Upload-Post",
2871
+ "target": {"username": uploader.username, "platforms": selected_platforms},
2872
+ "status": "published" if result.ok else "failed",
2873
+ "message": result.message,
2874
+ "data": result.data,
2875
+ "created_at": now(),
2876
+ }
2877
+ uploads = read_json("uploads.json", [])
2878
+ uploads.append(record)
2879
+ write_json("uploads.json", uploads)
2880
+ reconcile_persisted_notifications()
2881
+ (st.success if result.ok else st.error)(result.message)
2882
+ if result.ok and result.data.get("request_id"):
2883
+ st.caption(f"Request ID Upload-Post: {result.data['request_id']}")
2884
+
2885
+
2810
2886
  UPLOAD_DESTINATION_TARGET_KEYS = {
2811
2887
  "TikTok": "tiktok_accounts",
2812
2888
  "Instagram": "instagram_profiles",
@@ -3238,6 +3314,51 @@ def render_google_accounts():
3238
3314
  st.success("Configuração global do YouTube guardada em Contas Google.")
3239
3315
  st.rerun()
3240
3316
 
3317
+ def render_material_source_api_keys(settings: dict[str, Any]) -> None:
3318
+ st.subheader("Fontes de materiais")
3319
+ st.caption("Seleccione a fonte que será usada na pipeline e guarde uma ou mais API keys. Qualidade, endpoints e parâmetros internos são definidos pelo Thunderbolt.")
3320
+ source_catalog = material_source_catalog()
3321
+ source_codes = [item["code"] for item in source_catalog] + ["local"]
3322
+ source_labels = {item["code"]: item["label"] for item in source_catalog} | {"local": "Ficheiros locais"}
3323
+ source_help = {item["code"]: item["description"] for item in source_catalog} | {"local": "Usar materiais já existentes no storage local; não requer API key."}
3324
+ selected_source = st.selectbox(
3325
+ "Fonte de materiais",
3326
+ source_codes,
3327
+ index=source_codes.index(selected_material_source(settings)) if selected_material_source(settings) in source_codes else 0,
3328
+ format_func=lambda value: source_labels.get(value, value),
3329
+ key="material_source_selector",
3330
+ )
3331
+ st.caption(source_help.get(selected_source, ""))
3332
+ current_keys = material_api_keys(settings, selected_source)
3333
+ row_key = f"material_source_key_rows_{selected_source}"
3334
+ row_count = max(len(current_keys), int(st.session_state.get(row_key, len(current_keys) or 1)))
3335
+ if selected_source == "local":
3336
+ st.info("A fonte local não usa API key. Os materiais devem existir na pasta configurada do storage.")
3337
+ if st.button("Guardar fonte local", type="primary", key="save_local_material_source"):
3338
+ settings["video_source"] = selected_source
3339
+ write_json("settings.json", settings)
3340
+ st.success("Fonte de materiais guardada: ficheiros locais.")
3341
+ st.rerun()
3342
+ return
3343
+ with st.form(f"material_api_keys_form_{selected_source}"):
3344
+ key_values: list[str] = []
3345
+ for index in range(row_count):
3346
+ key_values.append(st.text_input(f"API Key {index + 1}", value=current_keys[index] if index < len(current_keys) else "", type="password", key=f"material_api_key_{selected_source}_{index}"))
3347
+ save_keys = st.form_submit_button("Guardar fonte e chaves", type="primary", use_container_width=True)
3348
+ add_key = st.form_submit_button("Adicionar outra chave", use_container_width=True)
3349
+ if add_key:
3350
+ st.session_state[row_key] = row_count + 1
3351
+ st.rerun()
3352
+ if save_keys:
3353
+ update_material_api_keys(settings, selected_source, key_values)
3354
+ settings["video_source"] = selected_source
3355
+ write_json("settings.json", settings)
3356
+ st.session_state[row_key] = max(1, len(material_api_keys(settings, selected_source)))
3357
+ st.success(f"Fonte {source_labels.get(selected_source, selected_source)} guardada com {len(material_api_keys(settings, selected_source))} chave(s).")
3358
+ st.rerun()
3359
+ st.caption(f"{len(current_keys)} chave(s) actualmente guardada(s) para {source_labels.get(selected_source, selected_source)}. As chaves são mantidas no storage local e usadas com rotação interna.")
3360
+
3361
+
3241
3362
  def render_settings():
3242
3363
  st.title("Configuração API")
3243
3364
  st.caption("Configuração das APIs, providers, serviços e ferramentas técnicas usados pelo Thunderbolt. As credenciais ficam no storage local e não são enviadas para o GitHub.")
@@ -3255,221 +3376,191 @@ def render_settings():
3255
3376
  api_keys_tab, voice_test_tab = st.tabs(["API Keys", "Teste de vozes"])
3256
3377
 
3257
3378
  with api_keys_tab:
3258
- with st.form("settings_form"):
3259
- st.subheader("API Keys")
3260
- port = st.number_input("Porta Streamlit", 1, 65535, int(settings.get("port", 3030)))
3261
- moneyprinter_path = st.text_input("Pasta do motor de vídeo", settings.get("moneyprinter_path", ""), key="settings_moneyprinter_path")
3262
- with st.expander("Niche Finder execução remota no Kaggle", expanded=True):
3263
- st.caption("O dataset permanece no Kaggle. O Thunderbolt usa estas credenciais apenas para publicar/executar a kernel e obter os resultados pequenos da análise.")
3264
- kaggle_cols = st.columns(3)
3265
- with kaggle_cols[0]:
3266
- kaggle_username = text_setting("Kaggle Username", "kaggle_username", help_text="Nome de utilizador da sua conta Kaggle, sem @ e sem URL.")
3267
- with kaggle_cols[1]:
3268
- kaggle_api_key = text_setting("Kaggle API Key", "kaggle_api_key", secret=True, help_text="Chave criada em Kaggle > Settings > API. Nunca é incluída no notebook ou no GitHub.")
3269
- with kaggle_cols[2]:
3270
- kaggle_kernel_slug = text_setting("Slug da kernel", "kaggle_kernel_slug", help_text="Identificador da kernel remota, por exemplo thunderbolt-niche-finder.")
3271
-
3272
- with st.expander("Niche Finder execução através da Apify", expanded=True):
3273
- st.caption("O token fica guardado apenas no storage local. A aba Niche Finder Apify só usa este serviço depois de clicar no botão de pesquisa.")
3274
- apify_cols = st.columns(4)
3275
- with apify_cols[0]:
3276
- apify_api_token = text_setting("Apify API Token", "apify_api_token", secret=True, help_text="Token pessoal da Apify. Não é incluído no workflow, logs ou GitHub.")
3277
- with apify_cols[1]:
3278
- apify_actor_id = text_setting("Apify Actor ID", "apify_actor_id", help_text="Por padrão: streamers~youtube-scraper.")
3279
- with apify_cols[2]:
3280
- apify_poll_interval = st.number_input("Intervalo de consulta (s)", min_value=1, max_value=120, value=int(settings.get("apify_poll_interval_seconds", 10)), step=1)
3281
- with apify_cols[3]:
3282
- apify_run_timeout = st.number_input("Limite da execução (s)", min_value=30, max_value=7200, value=int(settings.get("apify_run_timeout_seconds", 900)), step=30)
3283
-
3284
- with st.expander("Serviço, materiais e rede"):
3285
- cols = st.columns(2)
3286
- with cols[0]:
3287
- log_level = st.selectbox("Log level", ["DEBUG", "INFO", "WARNING", "ERROR"], index=["DEBUG", "INFO", "WARNING", "ERROR"].index(settings.get("log_level", "DEBUG")) if settings.get("log_level", "DEBUG") in ["DEBUG", "INFO", "WARNING", "ERROR"] else 0)
3288
- listen_host = text_setting("API listen host", "listen_host")
3289
- listen_port = st.number_input("API listen port", 1, 65535, int(settings.get("listen_port", 8080)))
3290
- video_source = st.selectbox("Fonte de materiais", ["pexels", "pixabay", "coverr", "loomloom", "local"], index=["pexels", "pixabay", "coverr", "loomloom", "local"].index(settings.get("video_source", "pexels")) if settings.get("video_source", "pexels") in ["pexels", "pixabay", "coverr", "loomloom", "local"] else 0)
3291
- with cols[1]:
3292
- endpoint = text_setting("Endpoint público", "endpoint")
3293
- proxy_http = text_setting("Proxy HTTP", "proxy_http")
3294
- proxy_https = text_setting("Proxy HTTPS", "proxy_https")
3295
- match_materials_to_script = st.checkbox("Alinhar materiais ao roteiro", bool(settings.get("match_materials_to_script", False)))
3296
-
3297
- with st.expander("Nano Banana geração de thumbnails", expanded=True):
3298
- st.caption("A Nano Banana gera a imagem final das thumbnails a partir da variante escolhida. A chave é guardada apenas no storage local e é distinta da chave do Gemini usado como LLM textual.")
3299
- nano_cols = st.columns(2)
3300
- with nano_cols[0]:
3301
- gemini_image_api_key = text_setting("Nano Banana API key", "gemini_image_api_key", secret=True, help_text="Chave criada no Google AI Studio para a API Gemini. Nunca é incluída no código, logs ou pacote.")
3302
- gemini_image_model = st.selectbox("Modelo Nano Banana", ["gemini-3.1-flash-image", "gemini-3-pro-image", "gemini-2.5-flash-image"], index=["gemini-3.1-flash-image", "gemini-3-pro-image", "gemini-2.5-flash-image"].index(str(settings.get("gemini_image_model") or "gemini-3.1-flash-image")) if str(settings.get("gemini_image_model") or "gemini-3.1-flash-image") in {"gemini-3.1-flash-image", "gemini-3-pro-image", "gemini-2.5-flash-image"} else 0)
3303
- with nano_cols[1]:
3304
- gemini_image_aspect_ratio = st.selectbox("Proporção da thumbnail", ["16:9", "9:16", "1:1", "4:5"], index=["16:9", "9:16", "1:1", "4:5"].index(str(settings.get("gemini_image_aspect_ratio") or "16:9")) if str(settings.get("gemini_image_aspect_ratio") or "16:9") in {"16:9", "9:16", "1:1", "4:5"} else 0)
3305
- gemini_image_size = st.selectbox("Tamanho da imagem", ["1K", "2K", "4K"], index=["1K", "2K", "4K"].index(str(settings.get("gemini_image_size") or "1K")) if str(settings.get("gemini_image_size") or "1K") in {"1K", "2K", "4K"} else 0)
3306
-
3307
- with st.expander("LLM — providers e modelos", expanded=True):
3308
- provider_options = ["moonshot", "shengsuanyun", "openai", "gemini", "deepseek", "qwen", "azure", "volcengine", "grok", "minimax", "mimo", "cloudflare", "modelscope", "aihubmix", "aimlapi", "evolink", "ollama", "oneapi", "litellm", "groq", "pollinations"]
3309
- llm_provider = st.selectbox("LLM provider", provider_options, index=provider_options.index(settings.get("llm_provider", "moonshot")) if settings.get("llm_provider", "moonshot") in provider_options else 0)
3310
- st.markdown("**OpenAI/ NVIDIA NIM — API key, Base URL e modelo**")
3311
- st.caption("O provider interno continua a ser `openai`, mas pode usar qualquer endpoint OpenAI-compatible. Para NVIDIA NIM, a Base URL predefinida é `https://integrate.api.nvidia.com/v1`; o selector consulta `/models` e deixa um campo manual como fallback.")
3312
- openai_cols = st.columns(3)
3313
- with openai_cols[0]:
3314
- openai_api_key = text_setting("OpenAI/ NVIDIA NIM API key", "openai_api_key", secret=True, help_text="API key do OpenAI ou do NVIDIA Build/NIM. A credencial fica apenas no storage local.")
3315
- with openai_cols[1]:
3316
- openai_base_url = st.text_input("OpenAI/ NVIDIA NIM Base URL", value=str(settings.get("openai_base_url", "") or DEFAULT_NVIDIA_NIM_BASE_URL), help="Ex.: https://integrate.api.nvidia.com/v1. O Thunderbolt acrescenta /models para descobrir os modelos.", key="settings_openai_base_url")
3317
- with openai_cols[2]:
3318
- cached_catalog = st.session_state.get("openai_model_catalog", {})
3319
- catalog_key = f"{openai_base_url.strip()}::{hashlib.sha256(openai_api_key.encode('utf-8')).hexdigest()}"
3320
- cached_models = list(cached_catalog.get("models", [])) if cached_catalog.get("key") == catalog_key else []
3321
- current_model_name = str(settings.get("openai_model_name", "") or "")
3322
- manual_option = "__manual_model__"
3323
- if cached_models:
3324
- model_options = [manual_option, *cached_models]
3325
- model_index = model_options.index(current_model_name) if current_model_name in model_options else 0
3326
- selected_model = st.selectbox("Modelo OpenAI/ NVIDIA NIM", model_options, index=model_index, format_func=lambda value: "Escrever modelo manualmente" if value == manual_option else value, key="settings_openai_model_select")
3327
- if selected_model == manual_option:
3328
- openai_model_name = st.text_input("Modelo manual", value=current_model_name if current_model_name not in cached_models else "", help="Ex.: nvidia_nim/minimaxai/minimax-m3", key="settings_openai_model_manual")
3379
+ api_service_tab, material_sources_tab = st.tabs(["Serviços e modelos", "Fontes de materiais"])
3380
+ with api_service_tab:
3381
+ with st.form("settings_form"):
3382
+ st.subheader("API Keys")
3383
+ port = st.number_input("Porta Streamlit", 1, 65535, int(settings.get("port", 3030)))
3384
+ moneyprinter_path = st.text_input("Pasta do motor de vídeo", settings.get("moneyprinter_path", ""), key="settings_moneyprinter_path")
3385
+ with st.expander("Niche Finder — execução remota no Kaggle", expanded=True):
3386
+ st.caption("O dataset permanece no Kaggle. O Thunderbolt usa estas credenciais apenas para publicar/executar a kernel e obter os resultados pequenos da análise.")
3387
+ kaggle_cols = st.columns(3)
3388
+ with kaggle_cols[0]:
3389
+ kaggle_username = text_setting("Kaggle Username", "kaggle_username", help_text="Nome de utilizador da sua conta Kaggle, sem @ e sem URL.")
3390
+ with kaggle_cols[1]:
3391
+ kaggle_api_key = text_setting("Kaggle API Key", "kaggle_api_key", secret=True, help_text="Chave criada em Kaggle > Settings > API. Nunca é incluída no notebook ou no GitHub.")
3392
+ with kaggle_cols[2]:
3393
+ kaggle_kernel_slug = text_setting("Slug da kernel", "kaggle_kernel_slug", help_text="Identificador da kernel remota, por exemplo thunderbolt-niche-finder.")
3394
+
3395
+ with st.expander("Niche Finder — execução através da Apify", expanded=True):
3396
+ st.caption("O token fica guardado apenas no storage local. A aba Niche Finder Apify só usa este serviço depois de clicar no botão de pesquisa.")
3397
+ apify_cols = st.columns(4)
3398
+ with apify_cols[0]:
3399
+ apify_api_token = text_setting("Apify API Token", "apify_api_token", secret=True, help_text="Token pessoal da Apify. Não é incluído no workflow, logs ou GitHub.")
3400
+ with apify_cols[1]:
3401
+ apify_actor_id = text_setting("Apify Actor ID", "apify_actor_id", help_text="Por padrão: streamers~youtube-scraper.")
3402
+ with apify_cols[2]:
3403
+ apify_poll_interval = st.number_input("Intervalo de consulta (s)", min_value=1, max_value=120, value=int(settings.get("apify_poll_interval_seconds", 10)), step=1)
3404
+ with apify_cols[3]:
3405
+ apify_run_timeout = st.number_input("Limite da execução (s)", min_value=30, max_value=7200, value=int(settings.get("apify_run_timeout_seconds", 900)), step=30)
3406
+
3407
+ with st.expander("Nano Banana — geração de thumbnails", expanded=True):
3408
+ st.caption("A Nano Banana gera a imagem final das thumbnails a partir da variante escolhida. A chave é guardada apenas no storage local e é distinta da chave do Gemini usado como LLM textual.")
3409
+ nano_cols = st.columns(2)
3410
+ with nano_cols[0]:
3411
+ gemini_image_api_key = text_setting("Nano Banana API key", "gemini_image_api_key", secret=True, help_text="Chave criada no Google AI Studio para a API Gemini. Nunca é incluída no código, logs ou pacote.")
3412
+ gemini_image_model = st.selectbox("Modelo Nano Banana", ["gemini-3.1-flash-image", "gemini-3-pro-image", "gemini-2.5-flash-image"], index=["gemini-3.1-flash-image", "gemini-3-pro-image", "gemini-2.5-flash-image"].index(str(settings.get("gemini_image_model") or "gemini-3.1-flash-image")) if str(settings.get("gemini_image_model") or "gemini-3.1-flash-image") in {"gemini-3.1-flash-image", "gemini-3-pro-image", "gemini-2.5-flash-image"} else 0)
3413
+ with nano_cols[1]:
3414
+ gemini_image_aspect_ratio = st.selectbox("Proporção da thumbnail", ["16:9", "9:16", "1:1", "4:5"], index=["16:9", "9:16", "1:1", "4:5"].index(str(settings.get("gemini_image_aspect_ratio") or "16:9")) if str(settings.get("gemini_image_aspect_ratio") or "16:9") in {"16:9", "9:16", "1:1", "4:5"} else 0)
3415
+ gemini_image_size = st.selectbox("Tamanho da imagem", ["1K", "2K", "4K"], index=["1K", "2K", "4K"].index(str(settings.get("gemini_image_size") or "1K")) if str(settings.get("gemini_image_size") or "1K") in {"1K", "2K", "4K"} else 0)
3416
+
3417
+ with st.expander("LLM — providers e modelos", expanded=True):
3418
+ provider_options = ["moonshot", "shengsuanyun", "openai", "gemini", "deepseek", "qwen", "azure", "volcengine", "grok", "minimax", "mimo", "cloudflare", "modelscope", "aihubmix", "aimlapi", "evolink", "ollama", "oneapi", "litellm", "groq", "pollinations"]
3419
+ llm_provider = st.selectbox("LLM provider", provider_options, index=provider_options.index(settings.get("llm_provider", "moonshot")) if settings.get("llm_provider", "moonshot") in provider_options else 0)
3420
+ st.markdown("**OpenAI/ NVIDIA NIM — API key, Base URL e modelo**")
3421
+ st.caption("O provider interno continua a ser `openai`, mas pode usar qualquer endpoint OpenAI-compatible. Para NVIDIA NIM, a Base URL predefinida é `https://integrate.api.nvidia.com/v1`; o selector consulta `/models` e deixa um campo manual como fallback.")
3422
+ openai_cols = st.columns(3)
3423
+ with openai_cols[0]:
3424
+ openai_api_key = text_setting("OpenAI/ NVIDIA NIM API key", "openai_api_key", secret=True, help_text="API key do OpenAI ou do NVIDIA Build/NIM. A credencial fica apenas no storage local.")
3425
+ with openai_cols[1]:
3426
+ openai_base_url = st.text_input("OpenAI/ NVIDIA NIM Base URL", value=str(settings.get("openai_base_url", "") or DEFAULT_NVIDIA_NIM_BASE_URL), help="Ex.: https://integrate.api.nvidia.com/v1. O Thunderbolt acrescenta /models para descobrir os modelos.", key="settings_openai_base_url")
3427
+ with openai_cols[2]:
3428
+ cached_catalog = st.session_state.get("openai_model_catalog", {})
3429
+ catalog_key = f"{openai_base_url.strip()}::{hashlib.sha256(openai_api_key.encode('utf-8')).hexdigest()}"
3430
+ cached_models = list(cached_catalog.get("models", [])) if cached_catalog.get("key") == catalog_key else []
3431
+ current_model_name = str(settings.get("openai_model_name", "") or "")
3432
+ manual_option = "__manual_model__"
3433
+ if cached_models:
3434
+ model_options = [manual_option, *cached_models]
3435
+ model_index = model_options.index(current_model_name) if current_model_name in model_options else 0
3436
+ selected_model = st.selectbox("Modelo OpenAI/ NVIDIA NIM", model_options, index=model_index, format_func=lambda value: "Escrever modelo manualmente" if value == manual_option else value, key="settings_openai_model_select")
3437
+ if selected_model == manual_option:
3438
+ openai_model_name = st.text_input("Modelo manual", value=current_model_name if current_model_name not in cached_models else "", help="Ex.: nvidia_nim/minimaxai/minimax-m3", key="settings_openai_model_manual")
3439
+ else:
3440
+ openai_model_name = selected_model
3329
3441
  else:
3330
- openai_model_name = selected_model
3442
+ openai_model_name = st.text_input("Modelo OpenAI/ NVIDIA NIM", value=current_model_name, help="Pode escrever um ID manualmente se o endpoint não disponibilizar /models.", key="settings_openai_model_name")
3443
+ refresh_openai_models = st.form_submit_button("Consultar/actualizar modelos NIM", use_container_width=True)
3444
+ if cached_catalog.get("key") == catalog_key and cached_catalog.get("error"):
3445
+ st.warning(str(cached_catalog["error"]))
3446
+ elif cached_models:
3447
+ st.caption(f"{len(cached_models)} modelo(s) carregado(s) a partir de {openai_base_url.rstrip('/')}/models.")
3331
3448
  else:
3332
- openai_model_name = st.text_input("Modelo OpenAI/ NVIDIA NIM", value=current_model_name, help="Pode escrever um ID manualmente se o endpoint não disponibilizar /models.", key="settings_openai_model_name")
3333
- refresh_openai_models = st.form_submit_button("Consultar/actualizar modelos NIM", use_container_width=True)
3334
- if cached_catalog.get("key") == catalog_key and cached_catalog.get("error"):
3335
- st.warning(str(cached_catalog["error"]))
3336
- elif cached_models:
3337
- st.caption(f"{len(cached_models)} modelo(s) carregado(s) a partir de {openai_base_url.rstrip('/')}/models.")
3338
- else:
3339
- st.info("Preencha a API key e clique em Consultar/actualizar modelos NIM para carregar os IDs disponíveis.")
3340
- llm_fields = [
3341
- ("Moonshot / Kimi", "moonshot", True), ("Shengsuan Cloud", "shengsuanyun", True),
3342
- ("Google Gemini", "gemini", True), ("DeepSeek", "deepseek", True), ("Alibaba Qwen", "qwen", True),
3343
- ("Azure OpenAI", "azure", True), ("VolcEngine Ark", "volcengine", True), ("xAI Grok", "grok", True),
3344
- ("MiniMax", "minimax", True), ("Xiaomi MiMo", "mimo", True), ("Cloudflare AI Gateway", "cloudflare", True),
3345
- ("ModelScope", "modelscope", True), ("AIHubMix", "aihubmix", True), ("AIML API", "aimlapi", True),
3346
- ("EvoLink", "evolink", True), ("Ollama", "ollama", False), ("OneAPI", "oneapi", True),
3347
- ("LiteLLM", "litellm", False), ("Groq", "groq", True), ("Pollinations AI", "pollinations", True),
3348
- ]
3349
- for label, prefix, has_key in llm_fields:
3350
- st.markdown(f"**{label}**")
3351
- cols = st.columns(3)
3449
+ st.info("Preencha a API key e clique em Consultar/actualizar modelos NIM para carregar os IDs disponíveis.")
3450
+ llm_fields = [
3451
+ ("Moonshot / Kimi", "moonshot", True), ("Shengsuan Cloud", "shengsuanyun", True),
3452
+ ("Google Gemini", "gemini", True), ("DeepSeek", "deepseek", True), ("Alibaba Qwen", "qwen", True),
3453
+ ("Azure OpenAI", "azure", True), ("VolcEngine Ark", "volcengine", True), ("xAI Grok", "grok", True),
3454
+ ("MiniMax", "minimax", True), ("Xiaomi MiMo", "mimo", True), ("Cloudflare AI Gateway", "cloudflare", True),
3455
+ ("ModelScope", "modelscope", True), ("AIHubMix", "aihubmix", True), ("AIML API", "aimlapi", True),
3456
+ ("EvoLink", "evolink", True), ("Ollama", "ollama", False), ("OneAPI", "oneapi", True),
3457
+ ("LiteLLM", "litellm", False), ("Groq", "groq", True), ("Pollinations AI", "pollinations", True),
3458
+ ]
3459
+ for label, prefix, has_key in llm_fields:
3460
+ st.markdown(f"**{label}**")
3461
+ cols = st.columns(3)
3462
+ with cols[0]:
3463
+ if has_key:
3464
+ settings[f"{prefix}_api_key"] = text_setting("API key", f"{prefix}_api_key", secret=True)
3465
+ else:
3466
+ settings[f"{prefix}_api_key"] = settings.get(f"{prefix}_api_key", "")
3467
+ with cols[1]:
3468
+ settings[f"{prefix}_base_url"] = text_setting("Base URL", f"{prefix}_base_url")
3469
+ with cols[2]:
3470
+ settings[f"{prefix}_model_name"] = text_setting("Model", f"{prefix}_model_name")
3471
+
3472
+ with st.expander("Voz, TTS e música — Azure Speech, restantes serviços e Suno", expanded=True):
3473
+ cols = st.columns(2)
3352
3474
  with cols[0]:
3353
- if has_key:
3354
- settings[f"{prefix}_api_key"] = text_setting("API key", f"{prefix}_api_key", secret=True)
3355
- else:
3356
- settings[f"{prefix}_api_key"] = settings.get(f"{prefix}_api_key", "")
3475
+ azure_speech_key = text_setting("Azure Speech key", "azure_speech_key", secret=True)
3476
+ azure_speech_region = text_setting("Azure Speech region", "azure_speech_region")
3477
+ siliconflow_tts_api_key = text_setting("SiliconFlow TTS API key", "siliconflow_tts_api_key", secret=True)
3478
+ minimax_tts_api_key = text_setting("MiniMax TTS API key", "minimax_tts_api_key", secret=True)
3479
+ minimax_tts_base_url = text_setting("MiniMax TTS Base URL", "minimax_tts_base_url")
3480
+ minimax_tts_model_id = text_setting("MiniMax TTS model", "minimax_tts_model_id")
3481
+ minimax_tts_voice_id = text_setting("MiniMax TTS voice ID", "minimax_tts_voice_id")
3357
3482
  with cols[1]:
3358
- settings[f"{prefix}_base_url"] = text_setting("Base URL", f"{prefix}_base_url")
3359
- with cols[2]:
3360
- settings[f"{prefix}_model_name"] = text_setting("Model", f"{prefix}_model_name")
3361
-
3362
- with st.expander("Voz, TTS e música — Azure Speech, restantes serviços e Suno", expanded=True):
3363
- cols = st.columns(2)
3364
- with cols[0]:
3365
- azure_speech_key = text_setting("Azure Speech key", "azure_speech_key", secret=True)
3366
- azure_speech_region = text_setting("Azure Speech region", "azure_speech_region")
3367
- siliconflow_tts_api_key = text_setting("SiliconFlow TTS API key", "siliconflow_tts_api_key", secret=True)
3368
- minimax_tts_api_key = text_setting("MiniMax TTS API key", "minimax_tts_api_key", secret=True)
3369
- minimax_tts_base_url = text_setting("MiniMax TTS Base URL", "minimax_tts_base_url")
3370
- minimax_tts_model_id = text_setting("MiniMax TTS model", "minimax_tts_model_id")
3371
- minimax_tts_voice_id = text_setting("MiniMax TTS voice ID", "minimax_tts_voice_id")
3372
- with cols[1]:
3373
- elevenlabs_api_key = text_setting("ElevenLabs API key", "elevenlabs_api_key", secret=True)
3374
- elevenlabs_model_id = text_setting("ElevenLabs model", "elevenlabs_model_id")
3375
- chatterbox_base_url = text_setting("Chatterbox Base URL", "chatterbox_base_url")
3376
- chatterbox_api_key = text_setting("Chatterbox API key", "chatterbox_api_key", secret=True)
3377
- chatterbox_model_id = text_setting("Chatterbox model", "chatterbox_model_id")
3378
- sonilo_api_key = text_setting("Sonilo API key", "sonilo_api_key", secret=True)
3379
- sonilo_base_url = text_setting("Sonilo Base URL", "sonilo_base_url")
3380
- st.markdown("**Suno agente musical opcional**")
3381
- suno_api_key = text_setting("Suno API key", "suno_api_key", secret=True)
3382
- suno_api_base_url = text_setting("Suno API Base URL", "suno_api_base_url", help_text="Use o endpoint compatível fornecido pelo seu acesso Suno; não é inventado pelo Thunderbolt.")
3383
- suno_api_endpoint = text_setting("Suno API endpoint", "suno_api_endpoint", help_text="Ex.: /api/generate")
3384
-
3385
- with st.expander("Vídeo, materiais, Whisper e FFmpeg"):
3386
- cols = st.columns(2)
3387
- with cols[0]:
3388
- pexels_api_keys = text_setting("Pexels API keys", "pexels_api_keys", secret=True, help_text="Separe várias chaves por vírgula para rotação.")
3389
- pixabay_api_keys = text_setting("Pixabay API keys", "pixabay_api_keys", secret=True)
3390
- coverr_api_keys = text_setting("Coverr API keys", "coverr_api_keys", secret=True)
3391
- twelvelabs_api_keys = text_setting("TwelveLabs API keys", "twelvelabs_api_keys", secret=True)
3392
- material_directory = text_setting("Pasta de materiais", "material_directory")
3393
- with cols[1]:
3394
- subtitle_provider = st.selectbox("Subtitle provider", ["edge", "whisper", ""], index=["edge", "whisper", ""].index(settings.get("subtitle_provider", "edge")) if settings.get("subtitle_provider", "edge") in ["edge", "whisper", ""] else 0)
3395
- ffmpeg_path = text_setting("Caminho FFmpeg", "ffmpeg_path")
3396
- video_codec = text_setting("Codec de vídeo", "video_codec")
3397
- whisper_model_size = text_setting("Whisper model", "whisper_model_size")
3398
- whisper_device = st.selectbox("Whisper device", ["cpu", "cuda"], index=0 if settings.get("whisper_device", "cpu") == "cpu" else 1)
3399
- whisper_compute_type = text_setting("Whisper compute type", "whisper_compute_type")
3400
-
3401
- with st.expander("TikTok for Developers — Client ID e Client Secret", expanded=True):
3402
- st.caption("Apenas as credenciais da aplicação ficam nesta UI. Redirect URI, scopes, autorização e tokens são geridos no TikTok for Developers Playground.")
3403
- tiktok_client_key = text_setting("TikTok Client ID", "tiktok_client_key", secret=True)
3404
- tiktok_client_secret = text_setting("TikTok Client Secret", "tiktok_client_secret", secret=True)
3405
-
3406
- with st.expander("Publicação através do Upload-Post"):
3407
- upload_post_enabled = st.checkbox("Activar Upload-Post", bool(settings.get("upload_post_enabled", False)))
3408
- upload_post_api_key = text_setting("Upload-Post API key", "upload_post_api_key", secret=True)
3409
- upload_post_username = text_setting("Upload-Post username", "upload_post_username")
3410
- upload_post_platforms = text_setting("Plataformas Upload-Post", "upload_post_platforms")
3411
- upload_post_auto_upload = st.checkbox("Publicar automaticamente após gerar", bool(settings.get("upload_post_auto_upload", False)))
3412
-
3413
- with st.expander("Postiz — API key, integração e MCP", expanded=True):
3414
- st.caption("O Thunderbolt é o cliente. A API key é enviada exclusivamente ao servidor Postiz configurado; não é colocada em URLs, logs ou repositório.")
3415
- postiz_enabled = st.checkbox("Activar Postiz como fallback final", bool(settings.get("postiz_enabled", False)))
3416
- postiz_mode = st.selectbox("Modo de ligação", ["api", "mcp"], index=0 if settings.get("postiz_mode", "api") != "mcp" else 1, help="API é o modo determinístico de upload. MCP fica disponível para uma ligação compatível com Streamable HTTP.")
3417
- postiz_cols = st.columns(2)
3418
- with postiz_cols[0]:
3419
- postiz_api_key = text_setting("Postiz API key", "postiz_api_key", secret=True, help_text="API key criada nas definições do Postiz. A API HTTP usa o valor bruto no cabeçalho Authorization.")
3420
- postiz_base_url = text_setting("Postiz Public API Base URL", "postiz_base_url", help_text="Cloud: https://api.postiz.com/public/v1 · Self-hosted: https://seu-servidor/api/public/v1")
3421
- postiz_integration_id = text_setting("Postiz integração padrão", "postiz_integration_id", help_text="ID do canal/integração devolvido por GET /integrations.")
3422
- with postiz_cols[1]:
3423
- postiz_mcp_url = text_setting("Postiz MCP URL", "postiz_mcp_url", help_text="Cloud: https://api.postiz.com/mcp · o cliente acrescenta a API key conforme o modo escolhido.")
3424
- postiz_auto_publish = st.checkbox("Permitir publicação imediata no Postiz", bool(settings.get("postiz_auto_publish", False)))
3425
- st.caption("No Upload, a aba Postiz permite carregar as integrações e enviar vídeos manualmente. No fluxo recomendado, Postiz só é tentado depois da API Oficial e do Upload directo.")
3426
-
3427
- if refresh_openai_models:
3428
- try:
3429
- discovered_models = fetch_openai_compatible_models(openai_api_key, openai_base_url)
3430
- st.session_state["openai_model_catalog"] = {"key": catalog_key, "models": discovered_models, "error": ""}
3431
- st.success(f"{len(discovered_models)} modelo(s) carregado(s) do endpoint OpenAI-compatible.")
3432
- st.rerun()
3433
- except ModelDiscoveryError as exc:
3434
- st.session_state["openai_model_catalog"] = {"key": catalog_key, "models": [], "error": str(exc)}
3435
- st.rerun()
3483
+ elevenlabs_api_key = text_setting("ElevenLabs API key", "elevenlabs_api_key", secret=True)
3484
+ elevenlabs_model_id = text_setting("ElevenLabs model", "elevenlabs_model_id")
3485
+ chatterbox_base_url = text_setting("Chatterbox Base URL", "chatterbox_base_url")
3486
+ chatterbox_api_key = text_setting("Chatterbox API key", "chatterbox_api_key", secret=True)
3487
+ chatterbox_model_id = text_setting("Chatterbox model", "chatterbox_model_id")
3488
+ sonilo_api_key = text_setting("Sonilo API key", "sonilo_api_key", secret=True)
3489
+ sonilo_base_url = text_setting("Sonilo Base URL", "sonilo_base_url")
3490
+ st.markdown("**Suno agente musical opcional**")
3491
+ suno_api_key = text_setting("Suno API key", "suno_api_key", secret=True)
3492
+ suno_api_base_url = text_setting("Suno API Base URL", "suno_api_base_url", help_text="Use o endpoint compatível fornecido pelo seu acesso Suno; não é inventado pelo Thunderbolt.")
3493
+ suno_api_endpoint = text_setting("Suno API endpoint", "suno_api_endpoint", help_text="Ex.: /api/generate")
3494
+
3495
+ with st.expander("TikTok for Developers — Client ID e Client Secret", expanded=True):
3496
+ st.caption("Apenas as credenciais da aplicação ficam nesta UI. Redirect URI, scopes, autorização e tokens são geridos no TikTok for Developers Playground.")
3497
+ tiktok_client_key = text_setting("TikTok Client ID", "tiktok_client_key", secret=True)
3498
+ tiktok_client_secret = text_setting("TikTok Client Secret", "tiktok_client_secret", secret=True)
3499
+
3500
+ with st.expander("Publicação através do Upload-Post"):
3501
+ upload_post_enabled = st.checkbox("Activar Upload-Post", bool(settings.get("upload_post_enabled", False)))
3502
+ upload_post_api_key = text_setting("Upload-Post API key", "upload_post_api_key", secret=True)
3503
+ upload_post_username = text_setting("Upload-Post username", "upload_post_username")
3504
+ upload_post_platforms = text_setting("Plataformas Upload-Post", "upload_post_platforms")
3505
+ upload_post_auto_upload = st.checkbox("Publicar automaticamente após gerar", bool(settings.get("upload_post_auto_upload", False)))
3506
+
3507
+ with st.expander("Postiz API key, integração e MCP", expanded=True):
3508
+ st.caption("O Thunderbolt é o cliente. A API key é enviada exclusivamente ao servidor Postiz configurado; não é colocada em URLs, logs ou repositório.")
3509
+ postiz_enabled = st.checkbox("Activar Postiz como fallback final", bool(settings.get("postiz_enabled", False)))
3510
+ postiz_mode = st.selectbox("Modo de ligação", ["api", "mcp"], index=0 if settings.get("postiz_mode", "api") != "mcp" else 1, help="API é o modo determinístico de upload. MCP fica disponível para uma ligação compatível com Streamable HTTP.")
3511
+ postiz_cols = st.columns(2)
3512
+ with postiz_cols[0]:
3513
+ postiz_api_key = text_setting("Postiz API key", "postiz_api_key", secret=True, help_text="API key criada nas definições do Postiz. A API HTTP usa o valor bruto no cabeçalho Authorization.")
3514
+ postiz_base_url = text_setting("Postiz Public API Base URL", "postiz_base_url", help_text="Cloud: https://api.postiz.com/public/v1 · Self-hosted: https://seu-servidor/api/public/v1")
3515
+ postiz_integration_id = text_setting("Postiz integração padrão", "postiz_integration_id", help_text="ID do canal/integração devolvido por GET /integrations.")
3516
+ with postiz_cols[1]:
3517
+ postiz_mcp_url = text_setting("Postiz MCP URL", "postiz_mcp_url", help_text="Cloud: https://api.postiz.com/mcp · o cliente acrescenta a API key conforme o modo escolhido.")
3518
+ postiz_auto_publish = st.checkbox("Permitir publicação imediata no Postiz", bool(settings.get("postiz_auto_publish", False)))
3519
+ st.caption("No Upload, a aba Postiz permite carregar as integrações e enviar vídeos manualmente. No fluxo recomendado, Postiz é tentado depois da API Oficial e do Upload directo.")
3520
+
3521
+ if refresh_openai_models:
3522
+ try:
3523
+ discovered_models = fetch_openai_compatible_models(openai_api_key, openai_base_url)
3524
+ st.session_state["openai_model_catalog"] = {"key": catalog_key, "models": discovered_models, "error": ""}
3525
+ st.success(f"{len(discovered_models)} modelo(s) carregado(s) do endpoint OpenAI-compatible.")
3526
+ st.rerun()
3527
+ except ModelDiscoveryError as exc:
3528
+ st.session_state["openai_model_catalog"] = {"key": catalog_key, "models": [], "error": str(exc)}
3529
+ st.rerun()
3436
3530
 
3437
- save_all_settings = st.form_submit_button("Guardar configurações do Thunderbolt", type="primary")
3438
- if save_all_settings:
3439
- settings.update({
3440
- "port": port, "moneyprinter_path": moneyprinter_path,
3441
- "kaggle_username": kaggle_username.strip(), "kaggle_api_key": kaggle_api_key.strip(), "kaggle_kernel_slug": kaggle_kernel_slug.strip() or "thunderbolt-niche-finder",
3442
- "apify_api_token": apify_api_token.strip(), "apify_actor_id": apify_actor_id.strip() or DEFAULT_ACTOR_ID, "apify_poll_interval_seconds": int(apify_poll_interval), "apify_run_timeout_seconds": int(apify_run_timeout),
3443
- "log_level": log_level, "listen_host": listen_host, "listen_port": listen_port, "video_source": video_source,
3444
- "endpoint": endpoint, "proxy_http": proxy_http, "proxy_https": proxy_https, "match_materials_to_script": match_materials_to_script,
3445
- "llm_provider": llm_provider, "openai_api_key": openai_api_key, "openai_base_url": openai_base_url, "openai_model_name": openai_model_name,
3446
- "gemini_image_api_key": gemini_image_api_key, "gemini_image_model": gemini_image_model, "gemini_image_aspect_ratio": gemini_image_aspect_ratio, "gemini_image_size": gemini_image_size,
3447
- "azure_speech_key": azure_speech_key, "azure_speech_region": azure_speech_region,
3448
- "siliconflow_tts_api_key": siliconflow_tts_api_key, "minimax_tts_api_key": minimax_tts_api_key,
3449
- "minimax_tts_base_url": minimax_tts_base_url, "minimax_tts_model_id": minimax_tts_model_id, "minimax_tts_voice_id": minimax_tts_voice_id,
3450
- "elevenlabs_api_key": elevenlabs_api_key, "elevenlabs_model_id": elevenlabs_model_id,
3451
- "pexels_api_keys": pexels_api_keys, "pixabay_api_keys": pixabay_api_keys, "coverr_api_keys": coverr_api_keys, "twelvelabs_api_keys": twelvelabs_api_keys,
3452
- "chatterbox_base_url": chatterbox_base_url, "chatterbox_api_key": chatterbox_api_key, "chatterbox_model_id": chatterbox_model_id,
3453
- "sonilo_api_key": sonilo_api_key, "sonilo_base_url": sonilo_base_url, "suno_api_key": suno_api_key, "suno_api_base_url": suno_api_base_url, "suno_api_endpoint": suno_api_endpoint, "subtitle_provider": subtitle_provider,
3454
- "ffmpeg_path": ffmpeg_path, "video_codec": video_codec, "material_directory": material_directory,
3455
- "whisper_model_size": whisper_model_size, "whisper_device": whisper_device, "whisper_compute_type": whisper_compute_type,
3456
- "tiktok_client_key": tiktok_client_key, "tiktok_client_secret": tiktok_client_secret,
3457
- "upload_post_enabled": upload_post_enabled, "upload_post_api_key": upload_post_api_key,
3458
- "upload_post_username": upload_post_username, "upload_post_platforms": upload_post_platforms,
3459
- "upload_post_auto_upload": upload_post_auto_upload,
3460
- "postiz_enabled": postiz_enabled, "postiz_api_key": postiz_api_key, "postiz_base_url": postiz_base_url.strip() or "https://api.postiz.com/public/v1",
3461
- "postiz_mcp_url": postiz_mcp_url.strip() or "https://api.postiz.com/mcp", "postiz_mode": postiz_mode,
3462
- "postiz_integration_id": postiz_integration_id.strip(), "postiz_auto_publish": bool(postiz_auto_publish),
3463
- })
3464
- write_json("settings.json", settings)
3465
- try:
3466
- synced = sync_moneyprinter_config(settings, moneyprinter_path)
3467
- if synced:
3468
- st.success(f"Configurações guardadas e sincronizadas com {synced}")
3469
- else:
3470
- st.success("Configurações guardadas localmente. Indique uma pasta válida do motor de vídeo para sincronizar config.toml.")
3471
- except Exception as exc:
3472
- st.warning(f"Configurações locais guardadas, mas não foi possível sincronizar config.toml: {exc}")
3531
+ save_all_settings = st.form_submit_button("Guardar configurações do Thunderbolt", type="primary")
3532
+ if save_all_settings:
3533
+ settings.update({
3534
+ "port": port, "moneyprinter_path": moneyprinter_path,
3535
+ "kaggle_username": kaggle_username.strip(), "kaggle_api_key": kaggle_api_key.strip(), "kaggle_kernel_slug": kaggle_kernel_slug.strip() or "thunderbolt-niche-finder",
3536
+ "apify_api_token": apify_api_token.strip(), "apify_actor_id": apify_actor_id.strip() or DEFAULT_ACTOR_ID, "apify_poll_interval_seconds": int(apify_poll_interval), "apify_run_timeout_seconds": int(apify_run_timeout),
3537
+ "llm_provider": llm_provider, "openai_api_key": openai_api_key, "openai_base_url": openai_base_url, "openai_model_name": openai_model_name,
3538
+ "gemini_image_api_key": gemini_image_api_key, "gemini_image_model": gemini_image_model, "gemini_image_aspect_ratio": gemini_image_aspect_ratio, "gemini_image_size": gemini_image_size,
3539
+ "azure_speech_key": azure_speech_key, "azure_speech_region": azure_speech_region,
3540
+ "siliconflow_tts_api_key": siliconflow_tts_api_key, "minimax_tts_api_key": minimax_tts_api_key,
3541
+ "minimax_tts_base_url": minimax_tts_base_url, "minimax_tts_model_id": minimax_tts_model_id, "minimax_tts_voice_id": minimax_tts_voice_id,
3542
+ "elevenlabs_api_key": elevenlabs_api_key, "elevenlabs_model_id": elevenlabs_model_id,
3543
+ "chatterbox_base_url": chatterbox_base_url, "chatterbox_api_key": chatterbox_api_key, "chatterbox_model_id": chatterbox_model_id,
3544
+ "sonilo_api_key": sonilo_api_key, "sonilo_base_url": sonilo_base_url, "suno_api_key": suno_api_key, "suno_api_base_url": suno_api_base_url, "suno_api_endpoint": suno_api_endpoint,
3545
+ "tiktok_client_key": tiktok_client_key, "tiktok_client_secret": tiktok_client_secret,
3546
+ "upload_post_enabled": upload_post_enabled, "upload_post_api_key": upload_post_api_key,
3547
+ "upload_post_username": upload_post_username, "upload_post_platforms": upload_post_platforms,
3548
+ "upload_post_auto_upload": upload_post_auto_upload,
3549
+ "postiz_enabled": postiz_enabled, "postiz_api_key": postiz_api_key, "postiz_base_url": postiz_base_url.strip() or "https://api.postiz.com/public/v1",
3550
+ "postiz_mcp_url": postiz_mcp_url.strip() or "https://api.postiz.com/mcp", "postiz_mode": postiz_mode,
3551
+ "postiz_integration_id": postiz_integration_id.strip(), "postiz_auto_publish": bool(postiz_auto_publish),
3552
+ })
3553
+ write_json("settings.json", settings)
3554
+ try:
3555
+ synced = sync_moneyprinter_config(settings, moneyprinter_path)
3556
+ if synced:
3557
+ st.success(f"Configurações guardadas e sincronizadas com {synced}")
3558
+ else:
3559
+ st.success("Configurações guardadas localmente. Indique uma pasta válida do motor de vídeo para sincronizar config.toml.")
3560
+ except Exception as exc:
3561
+ st.warning(f"Configurações locais guardadas, mas não foi possível sincronizar config.toml: {exc}")
3562
+ with material_sources_tab:
3563
+ render_material_source_api_keys(settings)
3473
3564
 
3474
3565
  with voice_test_tab:
3475
3566
  st.subheader("Teste de vozes")