@danhachuel/thunderbolt 0.3.42 → 0.3.44

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.
@@ -2,7 +2,7 @@
2
2
 
3
3
  Este manual descreve a instalação local da UI Thunderbolt, baseada no MoneyPrinterTurbo, utilizando o pacote npm `@danhachuel/thunderbolt`. O fluxo recomendado instala automaticamente o ambiente Python, as dependências da aplicação, as dependências do MoneyPrinterTurbo, o Streamlit e o suporte FFmpeg através de `imageio-ffmpeg`.
4
4
 
5
- > **Versão deste manual:** 0.3.42
5
+ > **Versão deste manual:** 0.3.44
6
6
  > **Pacote npm:** `@danhachuel/thunderbolt`
7
7
  > **Porta padrão da UI:** `localhost:3030`
8
8
  > **Repositório:** [github.com/DanHachuel/thunderbolt](https://github.com/DanHachuel/thunderbolt)
@@ -23,7 +23,7 @@ A instalação assistida cria um ambiente local separado para evitar misturar as
23
23
  | FFmpeg | Disponibilizado pelo pacote Python `imageio-ffmpeg` |
24
24
  | Porta da aplicação | `3030`, configurável com `THUNDERBOLT_PORT` |
25
25
 
26
- A aplicação não instala drivers de GPU, Docker, modelos Whisper ou credenciais externas. As chaves de API do MoneyPrinterTurbo são configuradas na aba **Configurações** e sincronizadas com o `config.toml` do clone local. O idioma da interface e o idioma dos vídeos são preferências independentes.
26
+ A aplicação não instala drivers de GPU, Docker, modelos Whisper ou credenciais externas. As chaves de API do MoneyPrinterTurbo são configuradas na aba **Configurações** e sincronizadas com o `config.toml` do clone local. O idioma da interface e o idioma dos vídeos são preferências independentes. Os providers de LLM, imagem e vídeo ficam no storage local e nunca são incluídos no pacote, nos logs ou no GitHub.
27
27
 
28
28
  O MoneyPrinterTurbo declara Python 3.11 ou superior como requisito e documenta a instalação com `uv` ou com `venv + pip`. A aplicação segue o mesmo princípio e adiciona um instalador assistido próprio.[1]
29
29
 
@@ -100,13 +100,13 @@ Execute:
100
100
  Windows PowerShell ou MobaXterm:
101
101
 
102
102
  ```powershell
103
- npx.cmd --yes @danhachuel/thunderbolt@0.3.42 install
103
+ npx.cmd --yes --prefer-online @danhachuel/thunderbolt@0.3.44 install
104
104
  ```
105
105
 
106
106
  Linux/macOS:
107
107
 
108
108
  ```bash
109
- npx --yes @danhachuel/thunderbolt@0.3.42 install
109
+ npx --yes --prefer-online @danhachuel/thunderbolt@0.3.44 install
110
110
  ```
111
111
 
112
112
  A instalação normal é **segura para actualizações**: preserva `storage`, Blueprints, Brandings, configurações e artefactos do utilizador. Remove apenas `.venv`, o clone técnico do MoneyPrinterTurbo e dependências que serão recriadas. Uma pasta antiga sem dados do utilizador, como `C:\Users\<utilizador>\AppData\Local\hermes` da tentativa incompleta, pode ser removida; uma pasta antiga que contenha Blueprints, Brandings ou storage é preservada e apenas avisada no terminal. Feche processos Python, Node, Streamlit e MobaXterm que estejam a usar as pastas antes de executar.
@@ -114,7 +114,7 @@ A instalação normal é **segura para actualizações**: preserva `storage`, Bl
114
114
  Se quiser apagar absolutamente tudo de forma intencional, use o comando destrutivo separado:
115
115
 
116
116
  ```powershell
117
- npx.cmd --yes --prefer-online @danhachuel/thunderbolt@VERSAO install --purge-data
117
+ npx.cmd --yes --prefer-online @danhachuel/thunderbolt@0.3.44 install --purge-data
118
118
  ```
119
119
 
120
120
  O parâmetro `--purge-data` apaga Blueprints, Brandings, configurações, storage e artefactos locais. Não o use numa actualização normal.
@@ -355,7 +355,37 @@ Se o PowerShell bloquear a activação do ambiente virtual, execute uma vez, com
355
355
  Set-ExecutionPolicy -Scope CurrentUser RemoteSigned
356
356
  ```
357
357
 
358
- ## 8. Configuração inicial da UI
358
+ ## 4. Configuração inicial
359
+
360
+ ### 4.1 Pools de LLM, imagem e vídeo
361
+
362
+ Em **Configuração API > API Keys**, o expander **LLM — providers e modelos** continua a guardar cartões independentes para os providers textuais. Pode manter várias API keys do mesmo provider em cartões distintos; a geração textual percorre o **pool LLM** e tenta o cartão seguinte apenas em quota, timeout, erro de transporte ou falha transitória.
363
+
364
+ O bloco **Limite LLM NVIDIA NIM** permite ligar **Activar limitador NVIDIA NIM — 40 RPM**. O valor padrão é 40 pedidos numa janela de 60 segundos. O contador é persistido localmente por cartão/chave e aplica-se à criação manual, aos workers e às automações. A opção vem desligada para não alterar instalações existentes; ligue-a quando o endpoint activo for NVIDIA NIM e a sua conta tiver esse limite.
365
+
366
+ O expander **Imagem e Video** substitui o antigo bloco isolado da Nano Banana. Os cartões disponíveis são **Nano Banana**, **Pollinations.ai**, **Agnes AI**, **Hugging Face Inference API**, **Cloudflare Workers AI**, **InferencePort Proxy**, **阿里云 (Alibaba Cloud Model Studio)**, **KIE AI** e **FAL AI**. Em cada cartão, configure a API key/token, o modelo, a Base URL, o estado **Provider activo**, a participação no **Pool Imagem**, a participação no **Pool Vídeo** e a prioridade. O botão **Testar Chamada API** faz apenas uma verificação read-only; não inicia geração de imagem ou vídeo.
367
+
368
+ | Provider | Configuração mínima | Pool suportado |
369
+ |---|---|---|
370
+ | Nano Banana | API key, modelo Gemini e parâmetros da imagem | Imagem |
371
+ | Pollinations.ai | API key/token, modelo e Base URL | Imagem e vídeo |
372
+ | Agnes AI | API key, modelo e Base URL | Imagem e vídeo |
373
+ | Hugging Face Inference API | Token, modelo e `https://router.huggingface.co/v1` | Imagem |
374
+ | Cloudflare Workers AI | Token, Account ID e modelo `@cf/...` | Imagem |
375
+ | InferencePort Proxy | Endpoint local `http://localhost:8080/v1`; chave opcional | Imagem e vídeo |
376
+ | Alibaba Cloud Model Studio | API key, modelo, região e Base URL DashScope | Imagem e vídeo |
377
+ | KIE AI | API key, modelo e Base URL | Imagem e vídeo |
378
+ | FAL AI | API key, rota/modelo de queue e Base URL | Imagem e vídeo |
379
+
380
+ Existem três pools independentes: uma falha no pool LLM não é substituída por imagem, e uma falha no pool de imagem não é encaminhada para vídeo. O selector **Provider principal de imagem** define a ordem inicial do pool de imagem. **Usar pool de vídeo externo** deve ser ligado explicitamente para usar os cartões de vídeo; quando permanece desligado, o worker continua a usar o motor local MoneyPrinterTurbo.
381
+
382
+ O failover não mascara erros de configuração. HTTP 429, timeout, HTTP 408/425, HTTP 5xx e falhas de transporte são elegíveis para o provider seguinte, com cooldown persistente por cartão. HTTP 400, 401, 403, 404 e payload inválido permanecem como erros accionáveis e não são repetidos indefinidamente.
383
+
384
+ ### 4.2 Nano Banana e thumbnails
385
+
386
+ A geração Nano Banana usa o contrato actualizado da Interactions API e não envia o campo de entrega `inline` que provocava HTTP 400. A geração de thumbnails continua separada da geração de títulos: **Gerar Thumbnail com IA** preserva o título já existente, e lotes do mesmo canal recebem uma variante de thumbnail por vídeo. A imagem final passa pelo **Pool Imagem**, mas a edição de lettering mantém a referência da imagem anterior quando o provider escolhido a suporta.
387
+
388
+ ### 4.3 Navegação da UI
359
389
 
360
390
  Na primeira execução, a barra lateral apresenta **Início**, **Niche Finder**, **Pipeline Vídeos**, **Pipeline Música**, **Automação**, **Edição**, **AI Influencers** e **Configurações**. O menu expansível **Pipeline Música** contém **Criação de Músicas** e **Upload Música**, cuja página tem as subabas **JewelMusic**, **Pushtunes** e **ytmusicapi**. O menu expansível **Pipeline Vídeos** contém **Criação de Vídeos**, **Backlog Vídeos**, **Roteiros**, **Thumbnails** e **Upload**. O menu expansível **Automação** contém a subaba **Automação Youtube**. O menu expansível **Edição** contém **Limpador de Metadados**, **Cortes** e **Editor Python**. O menu expansível **AI Influencers** contém **Personagens**, **Redes Sociais**, **Tutorial Meta** e **Tutorial Supabase**, nessa ordem. O **Tutorial Meta** apresenta o guia de configuração de Instagram e credenciais Meta para automações com n8n. O **Tutorial Supabase** apresenta o guia para criar as tabelas `plans` e `posts` e o bucket `instagram-images`, incluindo a ligação para a fonte original no GitHub; as duas frases promocionais da comunidade foram omitidas. O menu expansível **Niche Finder** contém **Niche Finder Kaggle** e **Niche Finder Apify**. A aba **Niche Finder Kaggle** mantém a análise do dataset Kaggle; **Niche Finder Apify** é uma segunda alternativa independente, com actor, dataset, parâmetros, credencial, execução e resultados próprios. O menu **Pipeline Vídeos** contém **Criação de Vídeos**, **Backlog Vídeos**, **Roteiros**, **Thumbnails** e **Upload**. O menu **Pipeline Música** contém **Criação de Músicas** e **Upload Música**. O menu **Configurações** contém **Canais Youtube**, **Blueprints Youtube**, **MCP**, **Contas Google**, **Configuração API** e **Notificações**. Abra **Configurações > Contas Google** para gerir contas Google/YouTube, cartões, documentos de Upload directo, `sessionInfo` por conta, a `INNERTUBE_API_KEY` global, contas adicionais e a configuração global do YouTube. Abra **Configurações > Configuração API** para configurar as restantes API Keys, providers, modelos, serviços e o **Teste de vozes**. A página **Configurações > Notificações** contém as subabas **Geral** e **Telegram**. A subaba Geral mantém o histórico e as preferências dos eventos locais; a subaba Telegram envia esses mesmos eventos para o Chat ID configurado. A página **Pipeline Música > Upload Música** contém as subabas **JewelMusic**, **Pushtunes** e **ytmusicapi**. Para YouTube, preencha primeiro o par **OAuth Client ID + OAuth Client Secret** em **Contas Google** se pretende autorizar uploads. A **YouTube Data API Key** é uma credencial Google Cloud diferente e fica em **Contas Google**, apenas para o método oficial de métricas; Client ID + Client Secret não formam uma API Key.
361
391
 
@@ -372,6 +402,11 @@ Na primeira execução, a barra lateral apresenta **Início**, **Niche Finder**,
372
402
  | OpenAI/ NVIDIA NIM API key | Credencial do OpenAI ou NVIDIA Build/NIM, guardada apenas no storage local |
373
403
  | OpenAI/ NVIDIA NIM Base URL | Endpoint OpenAI-compatible; por padrão `https://integrate.api.nvidia.com/v1` |
374
404
  | OpenAI/ NVIDIA NIM modelo | Selector carregado de `/models` ou campo manual de fallback; guardado como `openai_model_name` |
405
+ | Limite LLM NVIDIA NIM | **Activar limitador NVIDIA NIM — 40 RPM**, desligado por defeito e persistido por cartão/chave |
406
+ | Pool LLM textual | Cartões LLM activos, com failover limitado a quota, timeout, transporte e erros transitórios |
407
+ | Pool Imagem | Nano Banana, Pollinations.ai, Agnes AI, Hugging Face, Cloudflare, InferencePort, Alibaba Cloud, KIE AI e FAL AI marcados para imagem |
408
+ | Pool Vídeo | Pollinations.ai, Agnes AI, InferencePort, Alibaba Cloud, KIE AI e FAL AI marcados para vídeo; requer activação explícita do pool externo |
409
+ | Testar Chamada API | Verificação read-only do cartão; não inicia geração de imagem ou vídeo |
375
410
  | YouTube upload principal | Lógica do `youtube-automation-agent` adaptada e executada dentro do Thunderbolt |
376
411
  | OAuth directo de redundância | Caminho alternativo accionado automaticamente se o agente falhar |
377
412
  | TikTok Client ID/Secret | Credenciais da aplicação; Redirect URI, scopes e autorização ficam no TikTok for Developers Playground |
@@ -487,7 +522,7 @@ Ao abrir a página, o Thunderbolt não prepara dados públicos, não descarrega
487
522
 
488
523
  Os parâmetros da UI são número de clusters entre 2 e 10, suporte mínimo entre 0,01 e 0,50, país, engagement, intervalo de datas e tags, todos dentro da área principal da aba. O núcleo normaliza os dados, calcula engagement, aplica filtros, faz transformação logarítmica e standardização, executa K-Means e calcula itemsets/regras com FP-Growth. Não são apresentados resultados até ao primeiro clique em **Analisar Nichos**; o mesmo botão aplica alterações posteriores aos filtros. Os resultados são DataFrames de clusters, itemsets frequentes, regras de associação e dados analisados; o gráfico de dispersão é criado nativamente com Plotly.
489
524
 
490
- As dependências adicionais — `scikit-learn`, `mlxtend`, `plotly`, `seaborn`, `matplotlib` e `kagglehub` — são instaladas pelo procedimento normal de `npx`. Em instalações existentes, execute novamente `npx.cmd --yes @danhachuel/thunderbolt@0.3.42 install`; o instalador detecta e reutiliza o que já estiver válido.
525
+ As dependências adicionais — `scikit-learn`, `mlxtend`, `plotly`, `seaborn`, `matplotlib` e `kagglehub` — são instaladas pelo procedimento normal de `npx`. Em instalações existentes, execute novamente `npx.cmd --yes --prefer-online @danhachuel/thunderbolt@0.3.44 install`; o instalador detecta e reutiliza o que já estiver válido.
491
526
 
492
527
  ### Niche Finder Apify
493
528
 
package/README.md CHANGED
@@ -69,7 +69,7 @@ No topo da área principal da aplicação existe o menu nativo de idioma no padr
69
69
 
70
70
  A UI suporta os temas **Dark** e **Light** através do menu nativo de três pontos do Streamlit, no local original do toolbar. Não existe um selector Theme adicional dentro da página. A configuração distribuída em `.streamlit/config.toml` disponibiliza as variantes nomeadas **Dark** e **Light**, e o menu nativo continua responsável por alternar entre os modos, seguindo o padrão do [MoneyPrinterTurbo](https://github.com/harry0703/MoneyPrinterTurbo). O CSS próprio do Thunderbolt usa cores semânticas, `currentColor` e `color-mix` para acompanhar o tema activo, sem alterar a posição nem a funcionalidade do toolbar, do botão Deploy e do menu principal.
71
71
 
72
- ## Navegação da UI 0.3.42
72
+ ## Navegação da UI 0.3.44
73
73
 
74
74
  A barra lateral mantém os níveis principais, nesta ordem: **Início**, **Niche Finder**, **Pipeline**, **Pipeline TikTok**, **Automação**, **Edição**, **AI Influencers** e **Configurações**. **Pipeline Vídeos** é expansível e contém **Criação de Vídeos**, **Backlog Vídeos**, **Roteiros**, **Thumbnails** e **Upload**. **Pipeline Música** é expansível e contém **Criação de Músicas** e **Upload Música**. **Automação** também é expansível e contém **Automação Youtube**. **Edição** é expansível e contém **Limpador de Metadados**, **Cortes**, **Editor Python** e **Download Mídia**, nessa ordem. **AI Influencers** é expansível e contém **Personagens**, **Redes Sociais**, **Tutorial Meta** e **Tutorial Supabase**, nessa ordem. **Niche Finder** é expansível e contém **Niche Finder Kaggle** e **Niche Finder Apify**. **Configurações** é expansível e contém **Canais Youtube**, **Blueprints Youtube**, **MCP**, **Contas Google**, **Notificações**, **Logs** e **Configuração API**, nessa ordem. O Início reúne o dashboard e as filas do Pipeline, sem botões de acções rápidas.
75
75
 
@@ -79,13 +79,21 @@ O conteúdo interno das páginas também é localizado, não apenas a navegaçã
79
79
 
80
80
  Todas as subabas internas também são localizadas pelo helper nativo de tabs: **Blueprints/Brandings**, **Pesquisa pública/Cadastro manual/Contas cadastradas**, **Upload/Biblioteca**, **Importar do YouTube/Canais em lote gmail/Cadastro manual**, **Criar vídeo/Vídeos**, **Novo roteiro/letra/Histórico guardado**, as três abas de análise de clusters, as quatro abas de cortes, **Vídeos/Código Python**, as quatro opções de Upload, **JewelMusic/Pushtunes/ytmusicapi**, **API Keys/Teste de vozes**, **Serviços e modelos/Fontes de materiais**, **Client MCP/Servidor MCP/Skill** e **Notificações/Geral/Telegram**. As chaves técnicas e a ordem funcional permanecem inalteradas.
81
81
 
82
- Dentro de **Configurações > Contas Google**, a UI contém os cartões expansíveis de contas Google/YouTube, `sessionInfo` por conta, documentos de Upload directo, o formulário **Adicionar outra conta Gmail**, o bloco global de `INNERTUBE_API_KEY` e a configuração global do YouTube (OAuth Client ID, OAuth Client Secret e YouTube Data API Key). A página **Configuração API** contém as restantes credenciais, providers, modelos, serviços, Nano Banana, TikTok e Postiz.
82
+ Dentro de **Configurações > Contas Google**, a UI contém os cartões expansíveis de contas Google/YouTube, `sessionInfo` por conta, documentos de Upload directo, o formulário **Adicionar outra conta Gmail**, o bloco global de `INNERTUBE_API_KEY` e a configuração global do YouTube (OAuth Client ID, OAuth Client Secret e YouTube Data API Key). A página **Configuração API** contém as restantes credenciais, providers, modelos, serviços, **Imagem e Video**, TikTok e Postiz.
83
83
 
84
84
  Em **Configuração API > API Keys**, a subaba **Fontes de materiais** segue o padrão do MoneyPrinterTurbo: seleccione uma fonte — **Pexels**, **Pixabay**, **Coverr**, **WaveSpeed AI**, **LoomLoom**, **TwelveLabs** ou **Ficheiros locais** — e introduza as API keys dessa fonte. **Adicionar outra chave** cria outra linha para a mesma fonte; as listas são guardadas separadamente por fonte, deduplicadas e exportadas para o `config.toml` do motor como arrays para rotação. A subaba não expõe endpoint, proxy, qualidade, codec, FFmpeg, Whisper, directório ou filtros técnicos: esses valores permanecem internalizados e a fonte local não requer credencial.
85
85
 
86
86
  A subaba **Serviços e modelos** mantém as credenciais de providers e serviços que não são fontes de materiais. A chave `gemini_image_api_key` da Nano Banana continua separada de `gemini_api_key` do LLM textual. `INNERTUBE_API_KEY` permanece exclusivamente em **Configurações > Contas Google**, no bloco de configuração global fora dos cartões, e não é duplicada em API Keys.
87
87
 
88
- A página **AI Influencers > Tutorial Meta** apresenta o guia de configuração de uma conta Instagram profissional e das credenciais Meta para automações com n8n, distribuído localmente em `seed/references/guide-instagram.md` e com ligação para a [fonte original no GitHub](https://github.com/gyoridavid/ai_agents_az/blob/main/episode_8/guide-instagram.md). A página **Configurações > Notificações** mantém um histórico persistente de conclusões e falhas, reconcilia estados escritos por componentes locais e disponibiliza um checkbox independente para cada operação mapeada. A subaba **Geral** mantém esse centro local; a subaba **Telegram** permite configurar Bot Token, Chat ID e proxy opcional e envia as mesmas notificações pela Bot API oficial, sem polling ou mensagens recebidas. A página **Configurações > Logs**, colocada imediatamente abaixo de Notificações e acima de Configuração API, apresenta o histórico unificado de tarefas e notificações, com filtro livre por operação, filtros adicionais por operação e estado, e as colunas Operação, Estado, Data, Hora, Registo, Origem, Progresso e Detalhes.
88
+ O expander **Imagem e Video** reúne cartões independentes para **Nano Banana**, **Pollinations.ai**, **Agnes AI**, **Hugging Face Inference API**, **Cloudflare Workers AI**, **InferencePort Proxy**, **阿里云 (Alibaba Cloud Model Studio)**, **KIE AI** e **FAL AI**. Cada cartão permite configurar API key/token quando necessário, modelo, Base URL, activar o provider, escolher participação no **Pool Imagem** e/ou **Pool Vídeo**, definir prioridade e executar **Testar Chamada API**. O InferencePort usa por defeito `http://localhost:8080/v1` e não exige chave; o Cloudflare requer Account ID; os restantes providers devem ser preenchidos de acordo com a conta e endpoint disponibilizados pelo serviço.
89
+
90
+ O router mantém três pools independentes: **LLM textual**, **imagem** e **vídeo**. O failover só passa ao cartão seguinte em quota, timeout, erro de transporte ou HTTP transitório; erros de credencial, endpoint/modelo inexistente ou payload inválido ficam registados e não são mascarados por um provider diferente. O pool de vídeo externo é opcional: quando desligado, o worker continua a usar o motor local MoneyPrinterTurbo; quando ligado, usa apenas os cartões marcados para Pool Vídeo.
91
+
92
+ No bloco **Limite LLM NVIDIA NIM**, a opção **Activar limitador NVIDIA NIM — 40 RPM** reserva no máximo 40 pedidos por janela configurável para cada cartão/chave cujo endpoint seja `integrate.api.nvidia.com`. O estado do limitador é persistido localmente e partilhado entre geração manual, pipeline e automações. A opção vem desligada por defeito para preservar o comportamento anterior.
93
+
94
+ A correcção da integração Nano Banana remove o campo de entrega `inline` rejeitado pelo contrato actual da Interactions API e mantém a edição de lettering com imagem de referência quando suportada pelo provider.
95
+
96
+ A página **AI Influencers > Tutorial Meta** apresenta o guia de configuração de uma conta Instagram profissional e das credenciais Meta para automações com n8n, distribuído localmente em `seed/references/guide-instagram.md` e com ligação para a [fonte original no GitHub](https://github.com/gyoridavid/ai_agents_az/blob/main/episode_8/guide-instagram.md). A página **Configurações > Notificações** mantém um histórico persistente de conclusões e falhas, reconcilia estados escritos por componentes locais e disponibiliza um checkbox independente para cada operação mapeada. A subaba **Geral** mantém esse centro local; a subaba **Telegram** permite configurar Bot Token, Chat ID e proxy opcional e envia as mesmas notificações pela Bot API oficial, sem polling ou mensagens recebidas. A página **Configurações > Logs**, colocada imediatamente abaixo de Notificações e acima de Configuração API, apresenta o histórico unificado de tarefas e notificações, com filtro livre por operação, filtros adicionais por operação e estado, e as colunas Operação, Estado, Data, Hora, Registo, Origem, Progresso e Detalhes. A tabela mantém uma barra vertical e, quando a soma das colunas excede a largura disponível, disponibiliza também uma barra de rolagem horizontal na parte inferior para consultar integralmente células longas, sobretudo em **Detalhes**.
89
97
 
90
98
  A página **Edição > Download Mídia** utiliza a API Python do [yt-dlp](https://github.com/yt-dlp/yt-dlp) para descarregar vídeos e áudio de URLs públicas, com qualidade, contentor, formato de áudio, legendas, metadados, playlists, progresso e histórico local em `storage/downloads/` e `storage/state/media_downloads.json`. Conversão e combinação de streams podem exigir FFmpeg.
91
99
 
@@ -105,9 +113,9 @@ Abaixo do cartão, a secção **Últimos 10 vídeos publicados** fica num expand
105
113
 
106
114
  ## Criação de Vídeos — geração editorial por canal
107
115
 
108
- A aba **Criação de Vídeos** permite escrever manualmente o **Tópico ou briefing** ou usar **Gerar tópico/briefing com IA**. A geração usa o provider LLM configurado localmente em **Configuração API > API Keys > Serviços e modelos**, incorpora o Blueprint, a descrição, o idioma e a voz do canal e só ocorre depois do clique do utilizador. Se não houver credenciais ou modelo, a UI mostra uma mensagem accionável e não inventa conteúdo.
116
+ A aba **Criação de Vídeos** permite escrever manualmente o **Tópico ou briefing** ou usar **Gerar tópico/briefing com IA**. A geração usa o pool **LLM textual** configurado localmente em **Configuração API > API Keys > LLM — providers e modelos**, incorpora o Blueprint, a descrição, o idioma e a voz do canal e só ocorre depois do clique do utilizador. Se não houver credenciais ou modelo, a UI mostra uma mensagem accionável e não inventa conteúdo. Quando o limitador NVIDIA está activo, todas as chamadas LLM passam pelo mesmo contador persistente de 40 RPM.
109
117
 
110
- Entre **Canal** e **Estilo wide**, a UI mostra o Blueprint padrão resolvido do canal, a voz associada e o **Idioma** configurado nesse canal. Ao trocar o canal, o selector **Script Language** é sincronizado automaticamente com o código canónico do novo canal, incluindo a conversão de rótulos legados como `Português` ou `36 – Português (Brasil)`. O valor sincronizado é usado no roteiro, keywords, geração criativa e payload da tarefa; o idioma global só serve como fallback quando não existe canal seleccionado. Quando não existe configuração, apresenta **SEM BLUEPRINT CONFIGURADO** e mantém o idioma do canal visível. Depois do tópico e do título inicial, **Gerar Thumbnail com IA** gera apenas um briefing/prompt de thumbnail e preserva o título existente, sem chamar o gerador de títulos. Em lotes no mesmo canal, é gerada uma variante independente por vídeo e o payload correspondente é atribuído a cada task. A geração da imagem final continua disponível através do botão específico do Nano Banana; as thumbnails guardam conceito, overlay de até quatro palavras, composição, cores, prompt e sinergia com o título.
118
+ Entre **Canal** e **Estilo wide**, a UI mostra o Blueprint padrão resolvido do canal, a voz associada e o **Idioma** configurado nesse canal. Ao trocar o canal, o selector **Script Language** é sincronizado automaticamente com o código canónico do novo canal, incluindo a conversão de rótulos legados como `Português` ou `36 – Português (Brasil)`. O valor sincronizado é usado no roteiro, keywords, geração criativa e payload da tarefa; o idioma global só serve como fallback quando não existe canal seleccionado. Quando não existe configuração, apresenta **SEM BLUEPRINT CONFIGURADO** e mantém o idioma do canal visível. Depois do tópico e do título inicial, **Gerar Thumbnail com IA** gera apenas um briefing/prompt de thumbnail e preserva o título existente, sem chamar o gerador de títulos. Em lotes no mesmo canal, é gerada uma variante independente por vídeo e o payload correspondente é atribuído a cada task. A geração da imagem final passa pelo **Pool Imagem**, com failover para os cartões activos elegíveis; as thumbnails guardam conceito, overlay de até quatro palavras, composição, cores, prompt e sinergia com o título.
111
119
 
112
120
  O pacote criativo é persistido na task. O título escolhido fica pré-preenchido no Upload e a thumbnail é usada quando existe um ficheiro em `artifacts.thumbnail`. Sem provider de imagem configurado, a aplicação conserva o prompt e apresenta **Prompt de thumbnail pronto — imagem pendente de provider de imagem**, sem criar um PNG falso.
113
121
 
@@ -216,9 +224,9 @@ thunderbolt
216
224
  No Windows PowerShell, se `npx` for bloqueado por `npx.ps1`, use directamente `npx.cmd`:
217
225
 
218
226
  ```powershell
219
- npx.cmd --yes @danhachuel/thunderbolt@0.3.35 install
220
- npx.cmd --yes @danhachuel/thunderbolt@0.3.35 doctor
221
- npx.cmd --yes @danhachuel/thunderbolt@0.3.35
227
+ npx.cmd --yes --prefer-online @danhachuel/thunderbolt@0.3.44 install
228
+ npx.cmd --yes --prefer-online @danhachuel/thunderbolt@0.3.44 doctor
229
+ npx.cmd --yes --prefer-online @danhachuel/thunderbolt@0.3.44
222
230
  ```
223
231
 
224
232
  Como alternativa, pode permitir scripts para o seu utilizador:
@@ -242,7 +250,7 @@ O menu expansível **Niche Finder** contém duas alternativas independentes. **N
242
250
 
243
251
  A interface apresenta dentro da aba os parâmetros da busca: número de clusters entre 2 e 10, suporte mínimo entre 0,01 e 0,50, país, categoria de engagement, intervalo de datas e tags. A página permanece sem resultados até o primeiro clique em **Analisar Nichos**; depois, se os parâmetros forem alterados, mostra os resultados anteriores e pede novo clique para aplicar os filtros actuais. O núcleo aplica normalização, filtros, `log1p`, `StandardScaler`, K-Means e FP-Growth. Os resultados aparecem em DataFrames para clusters, itemsets frequentes, regras de associação e dados analisados, acompanhados por uma visualização Plotly nativa e pesquisa de palavras nos clusters.
244
252
 
245
- **Niche Finder Apify** é a segunda alternativa e não usa o dataset, filtros, execução ou resultados Kaggle. Define três palavras-chave, período, limite de resultados, Shorts, duração, idioma de legendas e ordenação; depois de clicar em **Pesquisar no Apify**, inicia o actor `streamers~youtube-scraper`, acompanha o run, carrega o dataset, normaliza vídeos, limpa SRT, calcula VSC Ratio e tenta resumir as transcrições com o provider LLM configurado. Os resultados ficam na sessão própria `niche_apify_results`, o histórico pequeno fica em `storage/state/niche_apify_runs.json` e existem exportações JSON/CSV. Configure o **Apify API Token** em **Configuração API > API Keys > Serviços e modelos**. As dependências adicionais são instaladas pelo fluxo normal do pacote: `requests`, `pandas` e os componentes já existentes da análise. Em instalações existentes, execute novamente `npx.cmd --yes @danhachuel/thunderbolt@0.3.35 install`; o instalador detecta e reutiliza componentes já válidos.
253
+ **Niche Finder Apify** é a segunda alternativa e não usa o dataset, filtros, execução ou resultados Kaggle. Define três palavras-chave, período, limite de resultados, Shorts, duração, idioma de legendas e ordenação; depois de clicar em **Pesquisar no Apify**, inicia o actor `streamers~youtube-scraper`, acompanha o run, carrega o dataset, normaliza vídeos, limpa SRT, calcula VSC Ratio e tenta resumir as transcrições com o provider LLM configurado. Os resultados ficam na sessão própria `niche_apify_results`, o histórico pequeno fica em `storage/state/niche_apify_runs.json` e existem exportações JSON/CSV. Configure o **Apify API Token** em **Configuração API > API Keys > Serviços e modelos**. As dependências adicionais são instaladas pelo fluxo normal do pacote: `requests`, `pandas` e os componentes já existentes da análise. Em instalações existentes, execute novamente `npx.cmd --yes --prefer-online @danhachuel/thunderbolt@0.3.44 install`; o instalador detecta e reutiliza componentes já válidos.
246
254
 
247
255
  ## Editor Python baseado no PYEdit
248
256
 
package/app/main.py CHANGED
@@ -39,12 +39,13 @@ from hermes_ui.mcp import detect_local_service, install_skill_locally, load_inte
39
39
  from hermes_ui.mcp_server import server_status, start_server, stop_server
40
40
  from hermes_ui.material_sources import apply_material_source_cards_to_settings, ensure_material_source_cards, material_source_catalog, material_source_definition, new_material_card, normalize_material_card, selected_material_source
41
41
  from hermes_ui.llm_providers import LLM_CARDS_KEY, LLM_ACTIVE_CARD_KEY, LLM_PROVIDER_CATALOG, apply_llm_cards_to_settings, ensure_llm_provider_cards, new_llm_card, normalize_llm_card, provider_definition, test_llm_provider_card, stamp_test_result
42
+ from hermes_ui.media_providers import MEDIA_CARDS_KEY, MEDIA_IMAGE_ACTIVE_CARD_KEY, MEDIA_VIDEO_ACTIVE_CARD_KEY, apply_media_provider_cards_to_settings, ensure_media_provider_cards, media_provider_catalog, media_provider_definition, new_media_card, normalize_media_card
42
43
  from hermes_ui.music import list_music_files, materialize_suno_audio, request_suno_generation, store_music_file
43
44
  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
44
45
  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
45
46
  from hermes_ui.logs import list_logs, logs_to_rows
46
47
  from hermes_ui.languages import LANGUAGE_CODES, VIDEO_LANGUAGE_CODES, LANGUAGE_FLAG_DATA_URIS, language_code, language_label, ui_language_menu_label, ui_text, video_language_label, video_language_options
47
- from hermes_ui.api_key_tests import test_apify_credentials, test_kaggle_credentials, test_material_source_credentials, test_nano_banana_credentials, test_postiz_credentials, test_telegram_credentials, test_tiktok_credentials, test_upload_post_credentials, test_voice_provider
48
+ from hermes_ui.api_key_tests import test_apify_credentials, test_kaggle_credentials, test_material_source_credentials, test_media_provider_card, test_nano_banana_credentials, test_postiz_credentials, test_telegram_credentials, test_tiktok_credentials, test_upload_post_credentials, test_voice_provider
48
49
  from hermes_ui.tutorials import tutorial_body, tutorial_caption, tutorial_title
49
50
 
50
51
  from hermes_ui.script_documents import list_script_documents, read_script_document, save_script_document, script_storage_path
@@ -5083,6 +5084,139 @@ def render_llm_provider_cards(settings: dict[str, Any], *, embedded: bool = Fals
5083
5084
  st.rerun()
5084
5085
 
5085
5086
 
5087
+ def _media_card_config_status(card: dict[str, Any]) -> tuple[str, str]:
5088
+ definition = media_provider_definition(card.get("provider"))
5089
+ if definition.local:
5090
+ if not str(card.get("base_url") or "").strip():
5091
+ return "missing", "Endpoint local em falta"
5092
+ return "local", "Local / sem API key"
5093
+ if definition.requires_api_key and not str(card.get("api_key") or "").strip():
5094
+ return "missing", "Missing key"
5095
+ if not str(card.get("base_url") or "").strip():
5096
+ return "missing", "Missing Base URL"
5097
+ if not str(card.get("model") or "").strip() and card.get("supports_image") and card.get("provider") not in {"cloudflare_workers_ai"}:
5098
+ return "missing", "Missing model"
5099
+ return "ready", "Configured"
5100
+
5101
+
5102
+ def _persist_media_cards(settings: dict[str, Any], cards: list[dict[str, Any]], image_active_id: str = "", video_active_id: str = "") -> dict[str, Any]:
5103
+ updated = apply_media_provider_cards_to_settings(settings, cards, image_active_id, video_active_id)
5104
+ write_json("settings.json", updated)
5105
+ settings.update(updated)
5106
+ return updated
5107
+
5108
+
5109
+ def _render_media_provider_card(settings: dict[str, Any], cards: list[dict[str, Any]], index: int, *, embedded: bool = False) -> None:
5110
+ card = normalize_media_card(cards[index], index)
5111
+ cards[index] = card
5112
+ card_id = str(card["id"])
5113
+ definition = media_provider_definition(card.get("provider"))
5114
+ with st.container(border=True):
5115
+ header_cols = st.columns([3.2, 1.2])
5116
+ with header_cols[0]:
5117
+ st.subheader(definition.label)
5118
+ if definition.description:
5119
+ st.caption(definition.description)
5120
+ with header_cols[1]:
5121
+ status_kind, status_label = _media_card_config_status(card)
5122
+ _api_status_badge(status_label, status_kind)
5123
+ card_form = nullcontext() if embedded else st.form(f"media_card_form_{card_id}")
5124
+ with card_form:
5125
+ key_col, model_col = st.columns(2)
5126
+ with key_col:
5127
+ api_key = str(card.get("api_key") or "")
5128
+ if definition.requires_api_key:
5129
+ api_key = st.text_input("API key", value=api_key, type="password", key=f"media_card_{card_id}_api_key")
5130
+ else:
5131
+ st.caption("Este provider não exige API key.")
5132
+ api_key = ""
5133
+ with model_col:
5134
+ model = st.text_input("Modelo", value=str(card.get("model") or ""), help="ID do modelo ou rota usada pelo provider.", key=f"media_card_{card_id}_model")
5135
+ base_url = st.text_input("Base URL", value=str(card.get("base_url") or definition.default_base_url), key=f"media_card_{card_id}_base_url")
5136
+ extra_values: dict[str, str] = {}
5137
+ if definition.extra_fields:
5138
+ extra_cols = st.columns(len(definition.extra_fields))
5139
+ for extra_col, field_name in zip(extra_cols, definition.extra_fields):
5140
+ with extra_col:
5141
+ extra_values[field_name] = st.text_input(field_name.replace("_", " ").title(), value=str(card.get(field_name) or ""), key=f"media_card_{card_id}_{field_name}")
5142
+ status_cols = st.columns(4)
5143
+ with status_cols[0]:
5144
+ enabled = st.checkbox("Provider activo", value=bool(card.get("enabled", True)), key=f"media_card_{card_id}_enabled")
5145
+ with status_cols[1]:
5146
+ supports_image = st.checkbox("Pool Imagem", value=bool(card.get("supports_image", definition.supports_image)), key=f"media_card_{card_id}_image")
5147
+ with status_cols[2]:
5148
+ supports_video = st.checkbox("Pool Vídeo", value=bool(card.get("supports_video", definition.supports_video)), key=f"media_card_{card_id}_video")
5149
+ with status_cols[3]:
5150
+ priority = st.number_input("Prioridade", min_value=0, max_value=999, value=int(card.get("priority", index)), step=1, key=f"media_card_{card_id}_priority")
5151
+ action_cols = st.columns(3)
5152
+ with action_cols[0]:
5153
+ test_clicked = st.form_submit_button("Testar Chamada API", use_container_width=True, key=f"media_card_{card_id}_test")
5154
+ with action_cols[1]:
5155
+ save_clicked = st.form_submit_button("Salvar", type="primary", use_container_width=True, key=f"media_card_{card_id}_save")
5156
+ with action_cols[2]:
5157
+ remove_clicked = st.form_submit_button("Remover provider", use_container_width=True, key=f"media_card_{card_id}_remove")
5158
+ edited = dict(card)
5159
+ edited.update({"api_key": str(api_key or "").strip(), "model": str(model or "").strip(), "base_url": str(base_url or "").strip(), "enabled": bool(enabled), "supports_image": bool(supports_image), "supports_video": bool(supports_video), "priority": int(priority), **extra_values})
5160
+ cards[index] = edited
5161
+ if test_clicked:
5162
+ result = test_media_provider_card(edited)
5163
+ edited["test_result"] = stamp_test_result(result)
5164
+ _persist_media_cards(settings, cards, str(settings.get(MEDIA_IMAGE_ACTIVE_CARD_KEY) or ""), str(settings.get(MEDIA_VIDEO_ACTIVE_CARD_KEY) or ""))
5165
+ elif save_clicked:
5166
+ _persist_media_cards(settings, cards, str(settings.get(MEDIA_IMAGE_ACTIVE_CARD_KEY) or ""), str(settings.get(MEDIA_VIDEO_ACTIVE_CARD_KEY) or ""))
5167
+ st.success("Cartão de imagem/vídeo guardado.")
5168
+ st.rerun()
5169
+ elif remove_clicked:
5170
+ remaining = [item for item in cards if str(item.get("id")) != card_id]
5171
+ _persist_media_cards(settings, remaining, str(settings.get(MEDIA_IMAGE_ACTIVE_CARD_KEY) or ""), str(settings.get(MEDIA_VIDEO_ACTIVE_CARD_KEY) or ""))
5172
+ st.success("Provider de imagem/vídeo removido.")
5173
+ st.rerun()
5174
+ saved_test = edited.get("test_result") or card.get("test_result")
5175
+ if isinstance(saved_test, dict) and saved_test.get("message"):
5176
+ if saved_test.get("status") == "success":
5177
+ st.success("Último teste: API Key OK")
5178
+ else:
5179
+ st.error(f"Último teste: {saved_test['message']}")
5180
+
5181
+
5182
+ def render_media_provider_cards(settings: dict[str, Any], *, embedded: bool = False) -> None:
5183
+ migrated, changed = ensure_media_provider_cards(settings)
5184
+ cards = [dict(item) for item in migrated.get(MEDIA_CARDS_KEY, [])]
5185
+ if changed:
5186
+ settings.update(migrated)
5187
+ write_json("settings.json", settings)
5188
+ with st.expander("Imagem e Video", expanded=False):
5189
+ st.caption("Configure providers de imagem e vídeo em cartões independentes. O router usa apenas o pool correspondente e faz failover entre providers activos.")
5190
+ image_cards = [card for card in cards if card.get("supports_image")]
5191
+ video_cards = [card for card in cards if card.get("supports_video")]
5192
+ selector_cols = st.columns(3)
5193
+ image_options = [""] + [str(card.get("id")) for card in image_cards]
5194
+ video_options = [""] + [str(card.get("id")) for card in video_cards]
5195
+ with selector_cols[0]:
5196
+ image_active_id = st.selectbox("Provider principal de imagem", image_options, index=image_options.index(str(settings.get(MEDIA_IMAGE_ACTIVE_CARD_KEY) or "")) if str(settings.get(MEDIA_IMAGE_ACTIVE_CARD_KEY) or "") in image_options else 0, format_func=lambda value: "Automático / primeiro activo" if not value else next((media_provider_definition(card.get("provider")).label for card in image_cards if str(card.get("id")) == value), value), key="media_image_active_selector")
5197
+ with selector_cols[1]:
5198
+ video_active_id = st.selectbox("Provider principal de vídeo", video_options, index=video_options.index(str(settings.get(MEDIA_VIDEO_ACTIVE_CARD_KEY) or "")) if str(settings.get(MEDIA_VIDEO_ACTIVE_CARD_KEY) or "") in video_options else 0, format_func=lambda value: "Não usar pool externo" if not value else next((media_provider_definition(card.get("provider")).label for card in video_cards if str(card.get("id")) == value), value), key="media_video_active_selector")
5199
+ with selector_cols[2]:
5200
+ video_pool_enabled = st.checkbox("Usar pool de vídeo externo", value=bool(settings.get("media_video_pool_enabled", False)), key="media_video_pool_enabled_ui")
5201
+ if st.button("Salvar selecção dos pools", use_container_width=True, key="save_media_pool_selection") if not embedded else st.form_submit_button("Salvar selecção dos pools", use_container_width=True, key="save_media_pool_selection"):
5202
+ settings["media_video_pool_enabled"] = bool(video_pool_enabled)
5203
+ _persist_media_cards(settings, cards, image_active_id, video_active_id)
5204
+ st.success("Selecção dos pools guardada.")
5205
+ st.rerun()
5206
+ for index in range(len(cards)):
5207
+ _render_media_provider_card(settings, cards, index, embedded=embedded)
5208
+ st.divider()
5209
+ st.markdown("**Adicionar provider de imagem/vídeo**")
5210
+ provider_codes = [item["code"] for item in media_provider_catalog()]
5211
+ provider_to_add = st.selectbox("Provider de media", provider_codes, format_func=lambda value: media_provider_definition(value).label, key="media_new_provider_choice")
5212
+ add_clicked = st.form_submit_button("Adicionar provider de imagem/vídeo", use_container_width=True, key="add_media_provider_card") if embedded else st.button("Adicionar provider de imagem/vídeo", use_container_width=True, key="add_media_provider_card")
5213
+ if add_clicked:
5214
+ cards.append(new_media_card(provider_to_add, card_id=f"media-{provider_to_add}-{uuid.uuid4().hex[:8]}"))
5215
+ _persist_media_cards(settings, cards, image_active_id, video_active_id)
5216
+ st.success("Novo provider de imagem/vídeo adicionado.")
5217
+ st.rerun()
5218
+
5219
+
5086
5220
  def render_settings():
5087
5221
  st.title("Configuração API")
5088
5222
  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.")
@@ -5143,23 +5277,18 @@ def render_settings():
5143
5277
 
5144
5278
  render_llm_provider_cards(settings, embedded=True)
5145
5279
 
5146
- with st.expander("Nano Banana — geração de thumbnails", expanded=False):
5147
- 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.")
5148
- nano_cols = st.columns(2)
5149
- with nano_cols[0]:
5150
- 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.")
5151
- 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)
5152
- with nano_cols[1]:
5153
- 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)
5154
- 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)
5155
- _render_credential_status(gemini_image_api_key)
5156
- _render_api_test_control(
5157
- settings,
5158
- "nano_banana",
5159
- lambda: test_nano_banana_credentials(gemini_image_api_key, gemini_image_model),
5160
- widget_key="api_test_nano_banana",
5161
- )
5162
-
5280
+ with st.container(border=True):
5281
+ st.markdown("### Limite LLM NVIDIA NIM")
5282
+ st.caption("Quando ligado, limita apenas cartões cujo endpoint é integrate.api.nvidia.com a 40 pedidos por janela de 60 segundos, partilhados entre UI, pipeline e automações.")
5283
+ rpm_cols = st.columns(3)
5284
+ with rpm_cols[0]:
5285
+ llm_rpm_limit_enabled = st.checkbox("Activar limitador NVIDIA NIM 40 RPM", value=bool(settings.get("llm_rpm_limit_enabled", False)), key="settings_llm_rpm_limit_enabled")
5286
+ with rpm_cols[1]:
5287
+ llm_rpm_limit = st.number_input("Pedidos por janela", min_value=1, max_value=1000, value=int(settings.get("llm_rpm_limit", 40)), step=1, key="settings_llm_rpm_limit")
5288
+ with rpm_cols[2]:
5289
+ llm_rpm_window_seconds = st.number_input("Janela (segundos)", min_value=1, max_value=3600, value=int(settings.get("llm_rpm_window_seconds", 60)), step=1, key="settings_llm_rpm_window_seconds")
5290
+
5291
+ render_media_provider_cards(settings, embedded=True)
5163
5292
 
5164
5293
  with st.expander("Voz, TTS e música — Azure Speech, restantes serviços e Suno", expanded=False):
5165
5294
  cols = st.columns(2)
@@ -5292,7 +5421,7 @@ def render_settings():
5292
5421
  "moneyprinter_path": moneyprinter_path,
5293
5422
  "kaggle_username": kaggle_username.strip(), "kaggle_api_key": kaggle_api_key.strip(), "kaggle_kernel_slug": kaggle_kernel_slug.strip() or "thunderbolt-niche-finder",
5294
5423
  "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),
5295
- "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,
5424
+ "llm_rpm_limit_enabled": bool(llm_rpm_limit_enabled), "llm_rpm_limit": int(llm_rpm_limit), "llm_rpm_window_seconds": int(llm_rpm_window_seconds),
5296
5425
  "azure_speech_key": azure_speech_key, "azure_speech_region": azure_speech_region,
5297
5426
  "siliconflow_tts_api_key": siliconflow_tts_api_key, "minimax_tts_api_key": minimax_tts_api_key,
5298
5427
  "minimax_tts_base_url": minimax_tts_base_url, "minimax_tts_model_id": minimax_tts_model_id, "minimax_tts_voice_id": minimax_tts_voice_id,
@@ -5468,18 +5597,21 @@ def render_logs():
5468
5597
  st.dataframe(
5469
5598
  rows,
5470
5599
  use_container_width=True,
5600
+ height=520,
5471
5601
  hide_index=True,
5472
5602
  column_config={
5473
- "Operação": st.column_config.TextColumn("Operação", width="medium"),
5474
- "Estado": st.column_config.TextColumn("Estado", width="small"),
5475
- "Data": st.column_config.TextColumn("Data", width="small"),
5476
- "Hora": st.column_config.TextColumn("Hora", width="small"),
5477
- "Registo": st.column_config.TextColumn("Registo", width="medium"),
5478
- "Origem": st.column_config.TextColumn("Origem", width="small"),
5479
- "Progresso": st.column_config.TextColumn("Progresso", width="small"),
5480
- "Detalhes": st.column_config.TextColumn("Detalhes", width="large"),
5603
+ # Fixed minimum widths keep the long Detalhes column navigable on narrow screens.
5604
+ "Operação": st.column_config.TextColumn("Operação", width=190),
5605
+ "Estado": st.column_config.TextColumn("Estado", width=115),
5606
+ "Data": st.column_config.TextColumn("Data", width=105),
5607
+ "Hora": st.column_config.TextColumn("Hora", width=105),
5608
+ "Registo": st.column_config.TextColumn("Registo", width=280),
5609
+ "Origem": st.column_config.TextColumn("Origem", width=130),
5610
+ "Progresso": st.column_config.TextColumn("Progresso", width=100),
5611
+ "Detalhes": st.column_config.TextColumn("Detalhes", width=760),
5481
5612
  },
5482
5613
  )
5614
+ st.caption("Quando a tabela exceder a largura disponível, utilize a barra de rolagem horizontal na parte inferior para consultar todo o conteúdo das células.")
5483
5615
 
5484
5616
 
5485
5617
  def render_notifications():
@@ -9,7 +9,7 @@ from __future__ import annotations
9
9
 
10
10
  import os
11
11
  from datetime import datetime, timezone
12
- from typing import Any
12
+ from typing import Any, Mapping
13
13
  from urllib.parse import quote, urlsplit, urlunsplit
14
14
 
15
15
  import requests
@@ -110,6 +110,38 @@ def test_nano_banana_credentials(api_key: str, model: str) -> dict[str, Any]:
110
110
  )
111
111
 
112
112
 
113
+ def test_media_provider_card(card: Mapping[str, Any]) -> dict[str, Any]:
114
+ """Run a bounded, non-generative check for an image/video provider card."""
115
+ source = dict(card) if isinstance(card, Mapping) else {}
116
+ provider = str(source.get("provider") or "").strip().lower()
117
+ api_key = str(source.get("api_key") or "").strip()
118
+ model = str(source.get("model") or "").strip()
119
+ base_url = str(source.get("base_url") or "").strip().rstrip("/")
120
+ api_style = str(source.get("api_style") or "").strip().lower()
121
+ if provider == "nano_banana":
122
+ return test_nano_banana_credentials(api_key, model)
123
+ if not base_url:
124
+ return _result("missing", "Complete a Base URL antes de testar.")
125
+ if provider not in {"inferenceport", "ollama", "lmstudio"} and not api_key:
126
+ return _missing("Introduza a API key/token antes de testar este provider.")
127
+ if not model and provider not in {"inferenceport", "cloudflare_workers_ai"}:
128
+ return _result("missing", "Complete o modelo antes de testar.")
129
+ headers = {"Authorization": f"Bearer {api_key}"} if api_key else {}
130
+ if provider == "cloudflare_workers_ai":
131
+ account_id = str(source.get("account_id") or "").strip()
132
+ if not account_id:
133
+ return _result("missing", "Complete o Account ID do Cloudflare antes de testar.")
134
+ endpoint = f"{base_url}/accounts/{quote(account_id, safe='')}/ai/models/search"
135
+ return _get(endpoint, headers=headers, params={"search": model or "stable-diffusion"})
136
+ if api_style in {"openai_compatible", "huggingface", "agnes", "kie"} or provider in {"pollinations", "huggingface", "agnes", "kie_ai", "inferenceport"}:
137
+ endpoint = _models_endpoint(base_url)
138
+ elif provider == "fal_ai":
139
+ endpoint = f"{base_url}/models" if base_url.endswith("/v1") else base_url
140
+ else:
141
+ endpoint = base_url
142
+ return _get(endpoint, headers=headers)
143
+
144
+
113
145
  def test_azure_speech_credentials(api_key: str, region: str) -> dict[str, Any]:
114
146
  """Validate Azure Speech by listing voices; this does not synthesize audio."""
115
147
  api_key = str(api_key or "").strip()
@@ -3,12 +3,13 @@ from __future__ import annotations
3
3
  import json
4
4
  import re
5
5
  from functools import lru_cache
6
- from pathlib import Path
7
- from typing import Any
8
6
 
9
7
  import requests
8
+ from pathlib import Path
9
+ from typing import Any
10
10
 
11
11
  from .llm_providers import active_llm_card, provider_definition
12
+ from .provider_routing import ProviderRoutingError, route_llm_json
12
13
 
13
14
 
14
15
  ROOT = Path(__file__).resolve().parents[1]
@@ -86,31 +87,22 @@ def _json_content(payload: dict[str, Any]) -> dict[str, Any]:
86
87
 
87
88
 
88
89
  def _chat_json(settings: dict[str, Any], system_prompt: str, user_prompt: str) -> dict[str, Any]:
89
- _provider, api_key, base_url, model = _provider_config(settings)
90
- headers = {"Content-Type": "application/json"}
91
- if api_key:
92
- headers["Authorization"] = f"Bearer {api_key}"
93
- body = {
94
- "model": model,
95
- "messages": [
96
- {"role": "system", "content": system_prompt},
97
- {"role": "user", "content": user_prompt},
98
- ],
99
- "response_format": {"type": "json_object"},
100
- }
101
- endpoint = f"{base_url}/chat/completions"
102
- try:
103
- response = requests.post(endpoint, headers=headers, json=body, timeout=120)
104
- except requests.RequestException as exc:
105
- raise CreativeGenerationError(f"Não foi possível contactar o provider LLM: {exc}") from exc
106
- if response.status_code >= 400:
107
- detail = response.text[:500].replace(api_key, "[REDACTED]") if api_key else response.text[:500]
108
- raise CreativeGenerationError(f"O provider LLM devolveu HTTP {response.status_code}: {detail}")
90
+ # Keep the actionable legacy validation for an entirely unconfigured setup;
91
+ # the router still skips incomplete cards when a configured fallback exists.
92
+ cards = settings.get("llm_provider_cards") if isinstance(settings, dict) else None
93
+ if not isinstance(cards, list) or not any(
94
+ isinstance(card, dict)
95
+ and bool(card.get("enabled", True))
96
+ and str(card.get("api_key") or "").strip()
97
+ and str(card.get("model") or card.get("model_name") or "").strip()
98
+ for card in cards
99
+ ):
100
+ _provider_config(settings)
109
101
  try:
110
- payload = response.json()
111
- except ValueError as exc:
112
- raise CreativeGenerationError("O provider LLM devolveu uma resposta que não é JSON.") from exc
113
- return _json_content(payload)
102
+ routed = route_llm_json(settings, system_prompt, user_prompt)
103
+ except ProviderRoutingError as exc:
104
+ raise CreativeGenerationError(str(exc)) from exc
105
+ return _json_content(routed.payload)
114
106
 
115
107
 
116
108
  def channel_context(channel: dict[str, Any], blueprint: dict[str, Any] | None = None) -> dict[str, Any]:
@@ -235,8 +235,14 @@ _API_KEY_EXPANDER_TRANSLATIONS: dict[str, dict[str, str]] = {
235
235
  for _language_code, _api_key_values in _API_KEY_EXPANDER_TRANSLATIONS.items():
236
236
  UI_TRANSLATIONS[_language_code].update(_api_key_values)
237
237
 
238
+ # This expander is intentionally kept as a stable product label while the
239
+ # provider cards themselves expose their capability labels in the current UI.
240
+ for _language_code in UI_TRANSLATIONS:
241
+ UI_TRANSLATIONS[_language_code].setdefault("Imagem e Video", "Imagem e Video")
238
242
 
239
- # Labels introduced by the reorganised sidebar. They are merged into the
243
+
244
+ # Labels introduced by the reorganised sidebar.
245
+ # They are merged into the
240
246
  # existing UI translation index so every navigation control uses the same
241
247
  # translation path as the legacy pages.
242
248
  UI_NAV_TRANSLATIONS: dict[str, dict[str, str]] = {
@@ -968,7 +974,7 @@ _CONTENT_TRANSLATION_ROWS = (
968
974
  ("Niche Finder — execução remota no Kaggle", "Niche Finder — execução remota no Kaggle", "Niche Finder — remote execution on Kaggle", "Niche Finder — Kaggle 远程执行", "Niche Finder — Remoteausführung auf Kaggle", "Niche Finder — chạy từ xa trên Kaggle", "Niche Finder — Kaggle'da uzaktan çalıştırma", "Niche Finder — удалённый запуск на Kaggle", "Niche Finder — ejecución remota en Kaggle", "Niche Finder — eksekusi jarak jauh di Kaggle", "Niche Finder — esecuzione remota su Kaggle"),
969
975
  ("O dataset permanece no Kaggle. O Thunderbolt usa estas credenciais apenas para publicar/executar a kernel e obter os resultados pequenos da análise.", "O dataset permanece no Kaggle. O Thunderbolt usa estas credenciais apenas para publicar/executar a kernel e obter os resultados pequenos da análise.", "The dataset remains on Kaggle. Thunderbolt uses these credentials only to publish/run the kernel and retrieve small analysis results.", "数据集保留在 Kaggle。Thunderbolt 仅使用这些凭证发布/运行 kernel 并获取小型分析结果。", "Der Datensatz bleibt auf Kaggle. Thunderbolt verwendet diese Zugangsdaten nur zum Veröffentlichen/Ausführen des Kernels und zum Abrufen kleiner Analyseergebnisse.", "Dataset vẫn ở Kaggle. Thunderbolt chỉ dùng thông tin xác thực để xuất bản/chạy kernel và lấy kết quả phân tích nhỏ.", "Veri kümesi Kaggle'da kalır. Thunderbolt bu kimlik bilgilerini yalnızca kernel'ı yayınlamak/çalıştırmak ve küçük analiz sonuçlarını almak için kullanır.", "Датасет остаётся на Kaggle. Thunderbolt использует эти данные только для публикации/запуска kernel и получения небольших результатов анализа.", "El dataset permanece en Kaggle. Thunderbolt usa estas credenciales solo para publicar/ejecutar el kernel y obtener pequeños resultados del análisis.", "Dataset tetap di Kaggle. Thunderbolt menggunakan kredensial ini hanya untuk menerbitkan/menjalankan kernel dan mengambil hasil analisis kecil.", "Il dataset resta su Kaggle. Thunderbolt usa queste credenziali solo per pubblicare/eseguire il kernel e ottenere piccoli risultati dell'analisi."),
970
976
  ("Niche Finder — execução através da Apify", "Niche Finder — execução através da Apify", "Niche Finder — execution through Apify", "Niche Finder — 通过 Apify 执行", "Niche Finder — Ausführung über Apify", "Niche Finder — chạy qua Apify", "Niche Finder — Apify üzerinden çalıştırma", "Niche Finder — запуск через Apify", "Niche Finder — ejecución mediante Apify", "Niche Finder — eksekusi melalui Apify", "Niche Finder — esecuzione tramite Apify"),
971
- ("Nano Banana — geração de thumbnails", "Nano Banana — geração de thumbnails", "Nano Banana — thumbnail generation", "Nano Banana — 缩略图生成", "Nano Banana — Thumbnail-Generierung", "Nano Banana tạo thumbnail", "Nano Banana — küçük resim oluşturma", "Nano Banana — создание миниатюр", "Nano Banana — generación de miniaturas", "Nano Banana — pembuatan thumbnail", "Nano Banana — generazione miniature"),
977
+ ("Imagem e Video", "Imagem e Video", "Image and Video", "图像和视频", "Bild und Video", "Hình ảnh video", "Görüntü ve Video", "Изображение и видео", "Imagen y vídeo", "Gambar dan Video", "Immagine e Video"),
972
978
  ("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.", "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.", "Nano Banana generates the final thumbnail image from the selected variant. The key is stored locally and is separate from the Gemini key used as a text LLM.", "Nano Banana 根据所选变体生成最终缩略图。密钥仅保存在本地存储中,与用于文本 LLM 的 Gemini 密钥分开。", "Nano Banana erzeugt das endgültige Thumbnail aus der ausgewählten Variante. Der Schlüssel wird nur lokal gespeichert und ist vom Gemini-Schlüssel für das Text-LLM getrennt.", "Nano Banana tạo ảnh thumbnail cuối từ biến thể đã chọn. Key chỉ lưu cục bộ và tách biệt với key Gemini dùng cho LLM văn bản.", "Nano Banana, seçilen varyanttan son küçük resim görselini oluşturur. Anahtar yalnızca yerel depolamada tutulur ve metin LLM'i için kullanılan Gemini anahtarından ayrıdır.", "Nano Banana создаёт финальное изображение миниатюры из выбранного варианта. Ключ хранится локально отдельно от ключа Gemini для текстовой LLM.", "Nano Banana genera la miniatura final a partir de la variante elegida. La clave se guarda localmente y es distinta de la clave de Gemini del LLM de texto.", "Nano Banana menghasilkan gambar thumbnail akhir dari varian yang dipilih. Kunci disimpan secara lokal dan terpisah dari kunci Gemini untuk LLM teks.", "Nano Banana genera l'immagine finale della miniatura dalla variante scelta. La chiave è salvata localmente ed è separata da quella Gemini usata per l'LLM testuale."),
973
979
  ("LLM — providers e modelos", "LLM — providers e modelos", "LLM — providers and models", "LLM — 服务商和模型", "LLM — Anbieter und Modelle", "LLM — provider và mô hình", "LLM — sağlayıcılar ve modeller", "LLM — провайдеры и модели", "LLM — proveedores y modelos", "LLM — penyedia dan model", "LLM — provider e modelli"),
974
980
  ("OpenAI/ NVIDIA NIM — API key, Base URL e modelo", "OpenAI/ NVIDIA NIM — API key, Base URL e modelo", "OpenAI/NVIDIA NIM — API key, Base URL and model", "OpenAI/NVIDIA NIM — API 密钥、基础 URL 和模型", "OpenAI/NVIDIA NIM — API-Schlüssel, Basis-URL und Modell", "OpenAI/NVIDIA NIM — API key, Base URL và mô hình", "OpenAI/NVIDIA NIM — API anahtarı, Temel URL ve model", "OpenAI/NVIDIA NIM — API-ключ, базовый URL и модель", "OpenAI/NVIDIA NIM — clave API, URL base y modelo", "OpenAI/NVIDIA NIM — kunci API, URL Dasar, dan model", "OpenAI/NVIDIA NIM — API key, URL di base e modello"),
@@ -397,6 +397,10 @@ def test_llm_provider_card(card: Mapping[str, Any]) -> dict[str, Any]:
397
397
  }
398
398
 
399
399
 
400
+ # This callable is a production diagnostic helper, not a pytest test function.
401
+ test_llm_provider_card.__test__ = False
402
+
403
+
400
404
  def stamp_test_result(result: Mapping[str, Any]) -> dict[str, Any]:
401
405
  status = "success" if result.get("ok") else "error"
402
406
  return {