@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/MANUAL-INSTALACAO.md +29 -13
- package/README.md +25 -13
- package/app/main.py +302 -211
- package/app/modules/niche_finder/apify.py +1 -1
- package/hermes_ui/creative_generation.py +3 -3
- package/hermes_ui/material_sources.py +64 -0
- package/hermes_ui/notifications.py +3 -0
- package/hermes_ui/storage.py +4 -0
- package/hermes_ui/thumbnail_generation.py +1 -1
- package/integrations/moneyprinter_config.py +9 -3
- package/integrations/postiz.py +1 -1
- package/integrations/upload_post.py +152 -0
- package/integrations/upload_routing.py +1 -1
- package/package.json +1 -1
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
|
-
|
|
3259
|
-
|
|
3260
|
-
|
|
3261
|
-
|
|
3262
|
-
|
|
3263
|
-
st.
|
|
3264
|
-
|
|
3265
|
-
|
|
3266
|
-
|
|
3267
|
-
|
|
3268
|
-
|
|
3269
|
-
|
|
3270
|
-
|
|
3271
|
-
|
|
3272
|
-
|
|
3273
|
-
|
|
3274
|
-
|
|
3275
|
-
|
|
3276
|
-
|
|
3277
|
-
|
|
3278
|
-
|
|
3279
|
-
|
|
3280
|
-
|
|
3281
|
-
|
|
3282
|
-
|
|
3283
|
-
|
|
3284
|
-
|
|
3285
|
-
|
|
3286
|
-
with
|
|
3287
|
-
|
|
3288
|
-
|
|
3289
|
-
|
|
3290
|
-
|
|
3291
|
-
|
|
3292
|
-
|
|
3293
|
-
|
|
3294
|
-
|
|
3295
|
-
|
|
3296
|
-
|
|
3297
|
-
|
|
3298
|
-
|
|
3299
|
-
|
|
3300
|
-
|
|
3301
|
-
|
|
3302
|
-
|
|
3303
|
-
|
|
3304
|
-
|
|
3305
|
-
|
|
3306
|
-
|
|
3307
|
-
|
|
3308
|
-
|
|
3309
|
-
|
|
3310
|
-
|
|
3311
|
-
|
|
3312
|
-
|
|
3313
|
-
|
|
3314
|
-
|
|
3315
|
-
|
|
3316
|
-
|
|
3317
|
-
|
|
3318
|
-
|
|
3319
|
-
|
|
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 =
|
|
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
|
-
|
|
3333
|
-
|
|
3334
|
-
|
|
3335
|
-
|
|
3336
|
-
|
|
3337
|
-
|
|
3338
|
-
|
|
3339
|
-
|
|
3340
|
-
|
|
3341
|
-
|
|
3342
|
-
|
|
3343
|
-
|
|
3344
|
-
|
|
3345
|
-
|
|
3346
|
-
|
|
3347
|
-
|
|
3348
|
-
|
|
3349
|
-
|
|
3350
|
-
|
|
3351
|
-
|
|
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
|
-
|
|
3354
|
-
|
|
3355
|
-
|
|
3356
|
-
|
|
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
|
-
|
|
3359
|
-
|
|
3360
|
-
|
|
3361
|
-
|
|
3362
|
-
|
|
3363
|
-
|
|
3364
|
-
|
|
3365
|
-
|
|
3366
|
-
|
|
3367
|
-
|
|
3368
|
-
|
|
3369
|
-
|
|
3370
|
-
|
|
3371
|
-
|
|
3372
|
-
|
|
3373
|
-
|
|
3374
|
-
|
|
3375
|
-
|
|
3376
|
-
|
|
3377
|
-
|
|
3378
|
-
|
|
3379
|
-
|
|
3380
|
-
st.
|
|
3381
|
-
|
|
3382
|
-
|
|
3383
|
-
|
|
3384
|
-
|
|
3385
|
-
|
|
3386
|
-
|
|
3387
|
-
|
|
3388
|
-
|
|
3389
|
-
|
|
3390
|
-
|
|
3391
|
-
|
|
3392
|
-
|
|
3393
|
-
|
|
3394
|
-
|
|
3395
|
-
|
|
3396
|
-
|
|
3397
|
-
|
|
3398
|
-
|
|
3399
|
-
|
|
3400
|
-
|
|
3401
|
-
|
|
3402
|
-
|
|
3403
|
-
|
|
3404
|
-
|
|
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 só é 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
|
-
|
|
3438
|
-
|
|
3439
|
-
|
|
3440
|
-
|
|
3441
|
-
|
|
3442
|
-
|
|
3443
|
-
|
|
3444
|
-
|
|
3445
|
-
|
|
3446
|
-
|
|
3447
|
-
|
|
3448
|
-
|
|
3449
|
-
|
|
3450
|
-
|
|
3451
|
-
|
|
3452
|
-
|
|
3453
|
-
|
|
3454
|
-
|
|
3455
|
-
|
|
3456
|
-
|
|
3457
|
-
|
|
3458
|
-
|
|
3459
|
-
"
|
|
3460
|
-
|
|
3461
|
-
|
|
3462
|
-
|
|
3463
|
-
|
|
3464
|
-
|
|
3465
|
-
|
|
3466
|
-
|
|
3467
|
-
|
|
3468
|
-
|
|
3469
|
-
|
|
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")
|