@danhachuel/thunderbolt 0.3.37 → 0.3.39
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 +10 -8
- package/README.md +6 -6
- package/app/main.py +122 -175
- package/hermes_ui/logs.py +233 -0
- package/package.json +1 -1
package/MANUAL-INSTALACAO.md
CHANGED
|
@@ -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.
|
|
5
|
+
> **Versão deste manual:** 0.3.39
|
|
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)
|
|
@@ -100,13 +100,13 @@ Execute:
|
|
|
100
100
|
Windows PowerShell ou MobaXterm:
|
|
101
101
|
|
|
102
102
|
```powershell
|
|
103
|
-
npx.cmd --yes @danhachuel/thunderbolt@0.3.
|
|
103
|
+
npx.cmd --yes @danhachuel/thunderbolt@0.3.39 install
|
|
104
104
|
```
|
|
105
105
|
|
|
106
106
|
Linux/macOS:
|
|
107
107
|
|
|
108
108
|
```bash
|
|
109
|
-
npx --yes @danhachuel/thunderbolt@0.3.
|
|
109
|
+
npx --yes @danhachuel/thunderbolt@0.3.39 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.
|
|
@@ -459,11 +459,13 @@ A subaba de fontes não é um painel de tuning: endpoints, proxy, qualidade, cor
|
|
|
459
459
|
|
|
460
460
|
## Gestão de Canais, edição e vídeos recentes
|
|
461
461
|
|
|
462
|
-
Na página **Configurações > Canais Youtube**, cada canal cadastrado aparece num cartão com o botão **Editar**. O editor permite alterar o nome, URL, handle, idioma, estilo wide, **
|
|
462
|
+
Na página **Configurações > Canais Youtube**, cada canal cadastrado aparece num cartão com o botão **Editar**. O editor permite alterar o nome, URL, handle, idioma, estilo wide, **Nicho**, **Blueprint Padrão**, **Narrador/Voz Padrão**, conta Google do Upload directo, descrição e Automação ON/horário. Guardar alterações actualiza o mesmo registo local; não é necessário apagar e criar o canal novamente.
|
|
463
463
|
|
|
464
|
-
O cartão mostra o nicho
|
|
464
|
+
O cartão mostra o nicho imediatamente abaixo do nome. Os quatro blocos compactos de gestão usam os rótulos **Blueprint Padrão**, **Nicho**, **Narrador/Voz Padrão** e **Idioma**, com os botões de edição correspondentes; o idioma é apenas apresentado a partir da configuração já guardada no canal.
|
|
465
465
|
|
|
466
|
-
A secção **Últimos 10 vídeos publicados** fica abaixo das configurações do canal e não dentro de Criação de Vídeos. Clique em **Actualizar últimos 10 vídeos** para consultar o feed RSS público do YouTube sem Data API Key. Os resultados são guardados em `storage/state/channel_videos.json`. A vista **Lista
|
|
466
|
+
A secção **Últimos 10 vídeos publicados** fica abaixo das configurações do canal, dentro de um expander fechado por defeito, e não dentro de Criação de Vídeos. Clique no expander e depois em **Actualizar últimos 10 vídeos** para consultar o feed RSS público do YouTube sem Data API Key. Os resultados são guardados em `storage/state/channel_videos.json`. A única vista disponível é **Lista**, que apresenta título, data, URL, estado e botão **Editar vídeo**. A edição local permite alterar título, estado, data, URL e notas. Essas alterações são overrides de gestão local e não publicam automaticamente no YouTube.
|
|
467
|
+
|
|
468
|
+
A página **Configurações > Logs** aparece no menu entre **Notificações** e **Configuração API**. Mostra uma projecção unificada das tarefas e notificações persistentes, com filtro livre por operação, filtros por operação e estado e as colunas mínimas **Operação**, **Estado**, **Data** e **Hora**. A tabela também mostra **Registo**, **Origem**, **Progresso** e **Detalhes**; estados como pendente, em execução, concluído, publicado, falha, cancelado e bloqueado são apresentados quando existirem no storage local.
|
|
467
469
|
|
|
468
470
|
## Canais em lote por conta Google/YouTube
|
|
469
471
|
|
|
@@ -481,7 +483,7 @@ Ao abrir a página, o Thunderbolt não prepara dados públicos, não descarrega
|
|
|
481
483
|
|
|
482
484
|
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.
|
|
483
485
|
|
|
484
|
-
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.
|
|
486
|
+
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.39 install`; o instalador detecta e reutiliza o que já estiver válido.
|
|
485
487
|
|
|
486
488
|
### Niche Finder Apify
|
|
487
489
|
|
|
@@ -519,7 +521,7 @@ O modo `Pexels/Pixabay` representa materiais de stock. Ao seleccionar `full_ia`,
|
|
|
519
521
|
|
|
520
522
|
A aba **Roteiros**, colocada entre **Criação de Músicas** e **Upload**, permite seleccionar um canal opcional, um Blueprint, **Roteiro de vídeo** ou **Letra de música**, idioma, tema e estrutura. O botão **Gerar com IA a partir do Blueprint** usa o provider LLM configurado e devolve um rascunho Markdown editável; o utilizador deve rever e clicar em **Guardar documento no storage**. Os ficheiros são guardados em `storage/scripts/` e o índice em `storage/state/scripts.json`; o caminho absoluto aparece no topo da página. Na subaba **Vídeos** de **Criação de Vídeos**, a aplicação mostra a frase `Os vídeos são guardados em <storage>/videos`, que identifica a pasta local dos vídeos.
|
|
521
523
|
|
|
522
|
-
A aba **Automação Youtube**, dentro do menu expansível **Automação**, lista os canais e vídeos
|
|
524
|
+
A aba **Automação Youtube**, dentro do menu expansível **Automação**, lista os canais e vídeos e apresenta nos cards os mesmos quatro elementos do cadastro: **Idioma Padrão**, **Blueprint Padrão**, **Nicho Padrão** e **Narrador/Voz Padrão**. Blueprint e Narrador/Voz continuam editáveis no card; Idioma e Nicho são mostrados a partir da configuração guardada no canal. A aba também guarda **Automação ON** e valida horários diários no formato `HH:MM`. Os valores ficam sincronizados com o editor existente no cartão da aba **Canais Youtube** e são copiados para novas tarefas. O launcher inicia o worker local, que consulta o relógio do computador, gera um briefing, título e pacote de thumbnail específicos com o provider LLM configurado, cria o lote agendado na fila e evita duplicar o mesmo canal no mesmo dia. Se a geração não puder ser executada, o erro fica registado e o placeholder antigo não é usado.
|
|
523
525
|
|
|
524
526
|
A área **Teste de vozes**, dentro de **Configurações > Configuração API**, é isolada da pipeline. O preview pode ser reproduzido e descarregado de `storage/voice_previews/`. Caminhos vazios, directórios, ficheiros vazios e previews sem permissões de leitura são ignorados com uma mensagem clara, sem traceback. Se uma instalação antiga mostrar que `edge-tts` está em falta, execute `npx.cmd --yes @danhachuel/thunderbolt install`; o detector reinstala apenas a dependência ausente.
|
|
525
527
|
|
package/README.md
CHANGED
|
@@ -69,9 +69,9 @@ 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.
|
|
72
|
+
## Navegação da UI 0.3.39
|
|
73
73
|
|
|
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**, **
|
|
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
|
|
|
76
76
|
A subaba **Tutorial Supabase** apresenta o guia de criação das tabelas `plans` e `posts` e do bucket `instagram-images`, com ligação para a fonte original no GitHub. As duas frases promocionais da comunidade foram omitidas. Todas as subabas internas também são localizadas pelo helper nativo de tabs:
|
|
77
77
|
|
|
@@ -85,17 +85,17 @@ Em **Configuração API > API Keys**, a subaba **Fontes de materiais** segue o p
|
|
|
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.
|
|
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.
|
|
89
89
|
|
|
90
90
|
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
91
|
|
|
92
92
|
## Canais Youtube — edição por cartão e vídeos recentes
|
|
93
93
|
|
|
94
|
-
A página **Canais Youtube** mantém o cadastro e a importação existentes, mas cada cartão agora tem o botão **Editar**. O editor permite alterar nome, URL, handle, idioma, estilo wide, **
|
|
94
|
+
A página **Canais Youtube** mantém o cadastro e a importação existentes, mas cada cartão agora tem o botão **Editar**. O editor permite alterar nome, URL, handle, idioma, estilo wide, **Nicho**, **Blueprint Padrão**, **Narrador/Voz Padrão**, conta Google do Upload directo, descrição e Automação ON/horário. O nicho aparece imediatamente abaixo do nome do canal no cartão; quando não existe, a UI mostra **SEM NICHO CONFIGURADO**.
|
|
95
95
|
|
|
96
|
-
Os blocos do cartão usam a nomenclatura solicitada: **
|
|
96
|
+
Os quatro blocos compactos do cartão usam a nomenclatura solicitada: **Blueprint Padrão**, **Nicho**, **Narrador/Voz Padrão** e **Idioma**. Os botões de acção abrem o mesmo editor persistente, sem criar um segundo canal nem perder as associações existentes.
|
|
97
97
|
|
|
98
|
-
Abaixo do cartão, a secção **Últimos 10 vídeos publicados** usa o feed público RSS do YouTube, sem Data API Key. O carregamento ocorre quando se clica em **Actualizar últimos 10 vídeos**, evitando chamadas automáticas ao abrir a página. Os vídeos ficam guardados em `storage/state/channel_videos.json` e
|
|
98
|
+
Abaixo do cartão, a secção **Últimos 10 vídeos publicados** fica num expander fechado por defeito e usa o feed público RSS do YouTube, sem Data API Key. O carregamento ocorre quando se clica em **Actualizar últimos 10 vídeos**, evitando chamadas automáticas ao abrir a página. Os vídeos ficam guardados em `storage/state/channel_videos.json` e são apresentados apenas no modo **Lista**. Cada vídeo tem **Editar vídeo** para alterar localmente o título, estado, data, URL e notas. A fonte pública não substitui o vídeo nem publica alterações no YouTube; os campos editáveis são overrides locais de gestão.
|
|
99
99
|
|
|
100
100
|
## Criação de Vídeos — geração editorial por canal
|
|
101
101
|
|
package/app/main.py
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
3
|
import hashlib
|
|
4
|
-
import html
|
|
5
4
|
import json
|
|
6
5
|
import mimetypes
|
|
7
6
|
import re
|
|
@@ -43,6 +42,7 @@ from hermes_ui.llm_providers import LLM_CARDS_KEY, LLM_ACTIVE_CARD_KEY, LLM_PROV
|
|
|
43
42
|
from hermes_ui.music import list_music_files, materialize_suno_audio, request_suno_generation, store_music_file
|
|
44
43
|
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
|
|
45
44
|
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
|
+
from hermes_ui.logs import list_logs, logs_to_rows
|
|
46
46
|
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
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
48
|
from hermes_ui.tutorials import tutorial_body, tutorial_caption, tutorial_title
|
|
@@ -208,30 +208,6 @@ st.markdown("""
|
|
|
208
208
|
[data-testid="stSidebar"] [data-testid="stExpander"] summary p { margin:0; line-height:1; }
|
|
209
209
|
[data-testid="stSidebar"] [data-testid="stExpander"] > div { padding:0 0 0 0.42rem !important; }
|
|
210
210
|
[data-testid="stSidebar"] [data-testid="stExpander"] > div [data-testid="stButton"] button { padding-left:0.92rem; font-size:0.83rem; min-height:1.62rem; height:1.62rem; }
|
|
211
|
-
.tb-channel-kanban-card { box-sizing:border-box; min-height:330px; height:330px; padding:0.85rem 0.9rem; border:1px solid color-mix(in srgb, currentColor 18%, transparent); border-radius:14px; background:color-mix(in srgb, currentColor 5%, transparent); color:inherit; box-shadow:0 4px 14px color-mix(in srgb, currentColor 12%, transparent); overflow:hidden; }
|
|
212
|
-
.tb-channel-kanban-card__header { display:flex; align-items:center; gap:0.58rem; min-width:0; }
|
|
213
|
-
.tb-channel-kanban-card__avatar { width:46px; height:46px; flex:0 0 46px; border-radius:10px; object-fit:cover; background:color-mix(in srgb, var(--tb-accent) 14%, transparent); }
|
|
214
|
-
.tb-channel-kanban-card__avatar--fallback { display:flex; align-items:center; justify-content:center; font-weight:800; color:var(--tb-accent); }
|
|
215
|
-
.tb-channel-kanban-card__identity { min-width:0; flex:1 1 auto; }
|
|
216
|
-
.tb-channel-kanban-card__name { font-size:1rem; font-weight:750; line-height:1.18; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
|
|
217
|
-
.tb-channel-kanban-card__niche { margin-top:0.22rem; font-size:0.76rem; opacity:.66; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
|
|
218
|
-
.tb-channel-kanban-card__badges { display:flex; gap:0.28rem; flex:0 0 auto; align-items:center; }
|
|
219
|
-
.tb-channel-kanban-card__badge { padding:0.18rem 0.42rem; border-radius:6px; font-size:0.67rem; font-weight:750; line-height:1.1; white-space:nowrap; }
|
|
220
|
-
.tb-channel-kanban-card__badge--youtube { background:#dc2626; color:#fff; }
|
|
221
|
-
.tb-channel-kanban-card__badge--active { background:color-mix(in srgb, #22c55e 25%, transparent); color:#16a34a; border:1px solid color-mix(in srgb, #22c55e 50%, transparent); }
|
|
222
|
-
.tb-channel-kanban-card__badge--inactive { background:color-mix(in srgb, #ef4444 18%, transparent); color:#dc2626; border:1px solid color-mix(in srgb, #ef4444 45%, transparent); }
|
|
223
|
-
.tb-channel-kanban-card__rule { height:1px; margin:0.72rem 0 0.62rem; background:color-mix(in srgb, currentColor 13%, transparent); }
|
|
224
|
-
.tb-channel-kanban-card__metrics { display:grid; grid-template-columns:repeat(3, minmax(0, 1fr)); gap:0.34rem; }
|
|
225
|
-
.tb-channel-kanban-card__metric { min-width:0; min-height:67px; padding:0.48rem 0.28rem 0.36rem; border-radius:8px; text-align:center; background:color-mix(in srgb, currentColor 4%, transparent); }
|
|
226
|
-
.tb-channel-kanban-card__metric-value { font-size:1.12rem; font-weight:800; line-height:1.15; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
|
|
227
|
-
.tb-channel-kanban-card__metric-label { margin-top:0.3rem; font-size:0.66rem; opacity:.64; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
|
|
228
|
-
.tb-channel-kanban-card__status-row { display:grid; grid-template-columns:repeat(2, minmax(0, 1fr)); gap:0.34rem; margin-top:0.6rem; }
|
|
229
|
-
.tb-channel-kanban-card__status { min-width:0; padding:0.42rem 0.28rem; border-radius:8px; text-align:center; font-size:0.71rem; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
|
|
230
|
-
.tb-channel-kanban-card__status strong { font-size:0.92rem; margin-right:0.18rem; }
|
|
231
|
-
.tb-channel-kanban-card__status--doing { color:#d97706; background:color-mix(in srgb, #f59e0b 14%, transparent); border:1px solid color-mix(in srgb, #f59e0b 35%, transparent); }
|
|
232
|
-
.tb-channel-kanban-card__status--done { color:#16a34a; background:color-mix(in srgb, #22c55e 11%, transparent); border:1px solid color-mix(in srgb, #22c55e 30%, transparent); }
|
|
233
|
-
.tb-channel-kanban-card__titles { margin-top:0.58rem; font-size:0.73rem; opacity:.7; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
|
|
234
|
-
@media (max-width: 900px) { .tb-channel-kanban-card { min-height:310px; height:310px; } .tb-channel-kanban-card__name { font-size:.9rem; } }
|
|
235
211
|
.content-card { box-sizing:border-box; padding:1rem 1.1rem; border:1px solid rgba(128,128,128,.28); border-radius:14px; background:rgba(128,128,128,.10); color:inherit; min-height:110px; box-shadow:0 4px 14px rgba(0,0,0,.12); }
|
|
236
212
|
.content-card { border:1px solid color-mix(in srgb, currentColor 18%, transparent); background:color-mix(in srgb, currentColor 5%, transparent); box-shadow:0 4px 14px color-mix(in srgb, currentColor 12%, transparent); }
|
|
237
213
|
.content-label { color:inherit; opacity:.72; font-size:.8rem; text-transform:uppercase; letter-spacing:.07em; }
|
|
@@ -968,38 +944,38 @@ def render_channel_video_editor(video: dict, channel_id: str) -> None:
|
|
|
968
944
|
|
|
969
945
|
def render_channel_videos(channel: dict) -> None:
|
|
970
946
|
channel_id = str(channel.get("id") or "")
|
|
971
|
-
st.
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
else:
|
|
983
|
-
st.warning(result.message)
|
|
984
|
-
videos = st.session_state.get(f"channel_videos_{channel_id}") or channel_videos_for(channel, limit=10)
|
|
985
|
-
if not videos:
|
|
986
|
-
st.info("Ainda não existem vídeos sincronizados. Clique em **Actualizar últimos 10 vídeos**.")
|
|
987
|
-
return
|
|
988
|
-
for video in videos[:10]:
|
|
989
|
-
with st.container(border=True):
|
|
990
|
-
cols = st.columns([0.7, 3.4, 1.4, 1.1])
|
|
991
|
-
with cols[0]:
|
|
992
|
-
if video.get("thumbnail_url"):
|
|
993
|
-
st.image(video["thumbnail_url"], width=64)
|
|
947
|
+
with st.expander("Últimos 10 vídeos publicados", expanded=False):
|
|
948
|
+
st.caption("A lista usa o feed público do YouTube, sem Data API Key. Pode actualizar manualmente e editar os metadados locais apresentados.")
|
|
949
|
+
refresh_col = st.columns(1)[0]
|
|
950
|
+
with refresh_col:
|
|
951
|
+
if st.button("Actualizar últimos 10 vídeos", key=f"refresh_channel_videos_{channel_id}", use_container_width=True):
|
|
952
|
+
result = fetch_channel_videos_public(channel, limit=10)
|
|
953
|
+
if result.ok:
|
|
954
|
+
videos = _merge_channel_videos(channel_id, result.data.get("videos", []))
|
|
955
|
+
st.session_state[f"channel_videos_{channel_id}"] = videos
|
|
956
|
+
st.success(result.message)
|
|
957
|
+
st.rerun()
|
|
994
958
|
else:
|
|
995
|
-
st.
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
with
|
|
1002
|
-
|
|
959
|
+
st.warning(result.message)
|
|
960
|
+
videos = st.session_state.get(f"channel_videos_{channel_id}") or channel_videos_for(channel, limit=10)
|
|
961
|
+
if not videos:
|
|
962
|
+
st.info("Ainda não existem vídeos sincronizados. Clique em **Actualizar últimos 10 vídeos**.")
|
|
963
|
+
return
|
|
964
|
+
for video in videos[:10]:
|
|
965
|
+
with st.container(border=True):
|
|
966
|
+
cols = st.columns([0.7, 3.4, 1.4, 1.1])
|
|
967
|
+
with cols[0]:
|
|
968
|
+
if video.get("thumbnail_url"):
|
|
969
|
+
st.image(video["thumbnail_url"], width=64)
|
|
970
|
+
else:
|
|
971
|
+
st.markdown("### YT")
|
|
972
|
+
with cols[1]:
|
|
973
|
+
st.write(f"**{video.get('title', 'Vídeo sem título')}**")
|
|
974
|
+
st.caption(f"{video.get('published_at') or 'Sem data'} · {video.get('url') or 'Sem URL'}")
|
|
975
|
+
with cols[2]:
|
|
976
|
+
st.caption(str(video.get("status") or "publicado").title())
|
|
977
|
+
with cols[3]:
|
|
978
|
+
render_channel_video_editor(video, channel_id)
|
|
1003
979
|
|
|
1004
980
|
|
|
1005
981
|
def render_channel_edit_form(channel: dict, youtube_account_ids: list[str], youtube_account_labels: dict[str, str], youtube_accounts_by_id: dict[str, dict[str, Any]]) -> None:
|
|
@@ -1021,10 +997,10 @@ def render_channel_edit_form(channel: dict, youtube_account_ids: list[str], yout
|
|
|
1021
997
|
style_options = ["Pexels/Pixabay", "full_ia", "Apenas Música"]
|
|
1022
998
|
style_value = {"pexels": "Pexels/Pixabay", "music": "Apenas Música"}.get(str(channel.get("style_wide") or "pexels"), str(channel.get("style_wide") or "Pexels/Pixabay"))
|
|
1023
999
|
edited_style = st.selectbox("Estilo wide", style_options, index=style_options.index(style_value) if style_value in style_options else 0)
|
|
1024
|
-
edited_niche = st.text_input("
|
|
1000
|
+
edited_niche = st.text_input("Nicho", value=str(channel.get("niche") or channel_niche_label(channel) if channel_niche_label(channel) != "SEM NICHO CONFIGURADO" else "") )
|
|
1025
1001
|
with edit_cols[1]:
|
|
1026
|
-
edited_blueprint = st.selectbox("
|
|
1027
|
-
edited_voice = st.selectbox("Narrador", voice_options, index=voice_options.index(current_voice) if current_voice in voice_options else 0, format_func=lambda item: item or "Sem voz padrão")
|
|
1002
|
+
edited_blueprint = st.selectbox("Blueprint Padrão", blueprint_ids, index=blueprint_ids.index(current_blueprint) if current_blueprint in blueprint_ids else 0, format_func=lambda item: blueprint_labels.get(item, item or "Sem Blueprint padrão"))
|
|
1003
|
+
edited_voice = st.selectbox("Narrador/Voz Padrão", voice_options, index=voice_options.index(current_voice) if current_voice in voice_options else 0, format_func=lambda item: item or "Sem voz padrão")
|
|
1028
1004
|
edited_account = st.selectbox("Conta Google para Upload directo", account_ids, index=account_ids.index(current_account) if current_account in account_ids else 0, format_func=lambda item: youtube_account_labels.get(item, item or "Sem conta Google associada"))
|
|
1029
1005
|
edited_description = st.text_area("Descrição", value=str(channel.get("description") or ""), height=100)
|
|
1030
1006
|
edited_automation = st.toggle("Automação ON", value=bool(channel.get("automation_on", False)), key=f"edit_automation_{channel_id}")
|
|
@@ -1438,101 +1414,6 @@ def render_tiktok_prompt_masters():
|
|
|
1438
1414
|
st.error(str(exc))
|
|
1439
1415
|
|
|
1440
1416
|
|
|
1441
|
-
def _format_channel_metric(value: Any) -> str:
|
|
1442
|
-
"""Format a channel metric compactly for the Kanban card."""
|
|
1443
|
-
if value is None or value == "":
|
|
1444
|
-
return "—"
|
|
1445
|
-
try:
|
|
1446
|
-
number = int(value)
|
|
1447
|
-
except (TypeError, ValueError):
|
|
1448
|
-
return html.escape(str(value))
|
|
1449
|
-
if abs(number) >= 1_000_000:
|
|
1450
|
-
return f"{number / 1_000_000:.1f}M".replace(".0M", "M")
|
|
1451
|
-
if abs(number) >= 1_000:
|
|
1452
|
-
return f"{number / 1_000:.1f}K".replace(".0K", "K")
|
|
1453
|
-
return f"{number:,}".replace(",", ".")
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
def _channel_pipeline_counts(channel: dict[str, Any]) -> tuple[int, int, int]:
|
|
1457
|
-
"""Return in-production, completed and titled task counts for one channel."""
|
|
1458
|
-
channel_id = str(channel.get("id") or "")
|
|
1459
|
-
tasks = read_json("tasks.json", [])
|
|
1460
|
-
if not channel_id or not isinstance(tasks, list):
|
|
1461
|
-
return 0, 0, 0
|
|
1462
|
-
related = [
|
|
1463
|
-
task for task in tasks
|
|
1464
|
-
if isinstance(task, dict) and str(task.get("channel_id") or "") == channel_id
|
|
1465
|
-
]
|
|
1466
|
-
in_production = sum(1 for task in related if str(task.get("state") or "").lower() == "doing")
|
|
1467
|
-
completed = sum(1 for task in related if str(task.get("state") or "").lower() == "done")
|
|
1468
|
-
titled = sum(1 for task in related if str(task.get("title") or "").strip() or task.get("title_candidates"))
|
|
1469
|
-
return in_production, completed, titled
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
def _render_channel_kanban_card(channel: dict[str, Any]) -> None:
|
|
1473
|
-
"""Render one compact, square-style channel card for the global Kanban view."""
|
|
1474
|
-
ui_language = current_ui_language()
|
|
1475
|
-
name = html.escape(str(channel.get("name") or "Sem nome"))
|
|
1476
|
-
niche = html.escape(channel_niche_label(channel))
|
|
1477
|
-
thumbnail_url = str(channel.get("thumbnail_url") or "").strip()
|
|
1478
|
-
if thumbnail_url:
|
|
1479
|
-
avatar = f'<img class="tb-channel-kanban-card__avatar" src="{html.escape(thumbnail_url, quote=True)}" alt="">'
|
|
1480
|
-
else:
|
|
1481
|
-
avatar = '<div class="tb-channel-kanban-card__avatar tb-channel-kanban-card__avatar--fallback">YT</div>'
|
|
1482
|
-
active = bool(channel.get("active", True))
|
|
1483
|
-
active_label = ui_text("Activo" if active else "Inactivo", ui_language)
|
|
1484
|
-
active_class = "active" if active else "inactive"
|
|
1485
|
-
subscribers = _format_channel_metric(channel.get("subscriber_count"))
|
|
1486
|
-
views = _format_channel_metric(channel.get("view_count"))
|
|
1487
|
-
videos = _format_channel_metric(channel.get("video_count"))
|
|
1488
|
-
in_production, completed, titled = _channel_pipeline_counts(channel)
|
|
1489
|
-
youtube_label = ui_text("YouTube", ui_language)
|
|
1490
|
-
subscribers_label = ui_text("Inscritos", ui_language)
|
|
1491
|
-
views_label = ui_text("Visualizações", ui_language)
|
|
1492
|
-
videos_label = ui_text("Vídeos", ui_language)
|
|
1493
|
-
production_label = ui_text("Em produção", ui_language)
|
|
1494
|
-
completed_label = ui_text("Finalizados", ui_language)
|
|
1495
|
-
titles_label = ui_text("Títulos", ui_language)
|
|
1496
|
-
st.markdown(
|
|
1497
|
-
f"""
|
|
1498
|
-
<div class="tb-channel-kanban-card">
|
|
1499
|
-
<div class="tb-channel-kanban-card__header">
|
|
1500
|
-
{avatar}
|
|
1501
|
-
<div class="tb-channel-kanban-card__identity">
|
|
1502
|
-
<div class="tb-channel-kanban-card__name">{name}</div>
|
|
1503
|
-
<div class="tb-channel-kanban-card__niche">{niche}</div>
|
|
1504
|
-
</div>
|
|
1505
|
-
<div class="tb-channel-kanban-card__badges">
|
|
1506
|
-
<span class="tb-channel-kanban-card__badge tb-channel-kanban-card__badge--youtube">{youtube_label}</span>
|
|
1507
|
-
<span class="tb-channel-kanban-card__badge tb-channel-kanban-card__badge--{active_class}">{active_label}</span>
|
|
1508
|
-
</div>
|
|
1509
|
-
</div>
|
|
1510
|
-
<div class="tb-channel-kanban-card__rule"></div>
|
|
1511
|
-
<div class="tb-channel-kanban-card__metrics">
|
|
1512
|
-
<div class="tb-channel-kanban-card__metric"><div class="tb-channel-kanban-card__metric-value">{subscribers}</div><div class="tb-channel-kanban-card__metric-label">{subscribers_label}</div></div>
|
|
1513
|
-
<div class="tb-channel-kanban-card__metric"><div class="tb-channel-kanban-card__metric-value">{views}</div><div class="tb-channel-kanban-card__metric-label">{views_label}</div></div>
|
|
1514
|
-
<div class="tb-channel-kanban-card__metric"><div class="tb-channel-kanban-card__metric-value">{videos}</div><div class="tb-channel-kanban-card__metric-label">{videos_label}</div></div>
|
|
1515
|
-
</div>
|
|
1516
|
-
<div class="tb-channel-kanban-card__status-row">
|
|
1517
|
-
<div class="tb-channel-kanban-card__status tb-channel-kanban-card__status--doing"><strong>{in_production}</strong>{production_label}</div>
|
|
1518
|
-
<div class="tb-channel-kanban-card__status tb-channel-kanban-card__status--done"><strong>{completed}</strong>{completed_label}</div>
|
|
1519
|
-
</div>
|
|
1520
|
-
<div class="tb-channel-kanban-card__titles">▣ <strong>{titled}</strong> {titles_label}</div>
|
|
1521
|
-
</div>
|
|
1522
|
-
""",
|
|
1523
|
-
unsafe_allow_html=True,
|
|
1524
|
-
)
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
def render_registered_channels_kanban(channels: list[dict[str, Any]]) -> None:
|
|
1528
|
-
"""Render all registered channels in a responsive three-column Kanban grid."""
|
|
1529
|
-
for row_start in range(0, len(channels), 3):
|
|
1530
|
-
channel_columns = st.columns(3)
|
|
1531
|
-
for column, channel in zip(channel_columns, channels[row_start:row_start + 3]):
|
|
1532
|
-
with column:
|
|
1533
|
-
_render_channel_kanban_card(channel)
|
|
1534
|
-
|
|
1535
|
-
|
|
1536
1417
|
def render_channels():
|
|
1537
1418
|
st.title("Canais Youtube")
|
|
1538
1419
|
st.caption("Escolha entre importar dados públicos do YouTube ou preencher o canal manualmente.")
|
|
@@ -1552,7 +1433,7 @@ def render_channels():
|
|
|
1552
1433
|
with lookup_cols[0]:
|
|
1553
1434
|
lookup_mode = st.radio("Método de consulta", ["Página pública — sem API Key", "YouTube Data API — API Key opcional"], horizontal=True, key="youtube_channel_lookup_mode")
|
|
1554
1435
|
with lookup_cols[1]:
|
|
1555
|
-
st.
|
|
1436
|
+
st.caption("Os canais cadastrados são apresentados apenas em lista.")
|
|
1556
1437
|
col1, col2 = st.columns([1, 1])
|
|
1557
1438
|
with col1:
|
|
1558
1439
|
if st.button("Buscar no YouTube", type="primary", use_container_width=True, key="youtube_channel_lookup"):
|
|
@@ -1588,17 +1469,17 @@ def render_channels():
|
|
|
1588
1469
|
handle = st.text_input("Handle", value=imported.get("handle", ""), key="yt_import_handle")
|
|
1589
1470
|
language = st.selectbox("Idioma", list(LANGUAGE_CODES), index=list(LANGUAGE_CODES).index(language_code(imported.get("language") or "pt")), format_func=language_label, key="yt_import_language")
|
|
1590
1471
|
style = st.selectbox("Estilo wide", ["Pexels/Pixabay", "full_ia", "Apenas Música"], index=0, key="yt_import_style")
|
|
1591
|
-
blueprint = st.selectbox("Blueprint
|
|
1472
|
+
blueprint = st.selectbox("Blueprint Padrão", blueprint_ids, index=blueprint_ids.index(imported_blueprint) if imported_blueprint in blueprint_ids else 0, format_func=lambda item: blueprint_labels.get(item, item or "Sem Blueprint padrão"), key="yt_import_blueprint")
|
|
1592
1473
|
voice_options = voice_catalog(imported.get("default_voice") or imported.get("voice", ""))
|
|
1593
1474
|
current_voice = imported.get("default_voice") or imported.get("voice", "")
|
|
1594
|
-
voice = st.selectbox("Voz
|
|
1475
|
+
voice = st.selectbox("Narrador/Voz Padrão", voice_options, index=voice_options.index(current_voice) if current_voice in voice_options else 0, format_func=lambda item: item or "Sem voz padrão", key="yt_import_voice")
|
|
1595
1476
|
imported_account_id = str(imported.get("google_account_id", ""))
|
|
1596
1477
|
google_account_id = st.selectbox("Conta Google para Upload directo", youtube_account_ids, index=youtube_account_ids.index(imported_account_id) if imported_account_id in youtube_account_ids else 0, format_func=lambda item: youtube_account_labels.get(item, item), key="yt_import_google_account_id")
|
|
1597
1478
|
st.caption("O DELEGATED_SESSION_ID é lido exclusivamente do documento JSON da conta Google associada.")
|
|
1598
1479
|
automation_on = st.toggle("Automação ON", value=bool(imported.get("automation_on", False)), key="yt_import_automation_on")
|
|
1599
1480
|
automation_time = st.text_input("Horário diário (HH:MM)", value=imported.get("automation_time", "00:00"), key="yt_import_automation_time")
|
|
1600
1481
|
description = st.text_area("Descrição", value=imported.get("description", ""), key="yt_import_description")
|
|
1601
|
-
niche = st.text_input("
|
|
1482
|
+
niche = st.text_input("Nicho", value=imported.get("niche", ""), key="yt_import_niche")
|
|
1602
1483
|
metrics = st.columns(3)
|
|
1603
1484
|
with metrics[0]: subscriber_count = st.number_input("Inscritos", min_value=0, value=int(imported.get("subscriber_count") or 0), key="yt_import_subscribers")
|
|
1604
1485
|
with metrics[1]: video_count = st.number_input("Vídeos", min_value=0, value=int(imported.get("video_count") or 0), key="yt_import_videos")
|
|
@@ -1734,15 +1615,15 @@ def render_channels():
|
|
|
1734
1615
|
url = st.text_input("URL do canal", placeholder="https://youtube.com/@seucanal", key="manual_channel_url")
|
|
1735
1616
|
handle = st.text_input("Handle", placeholder="@seucanal", key="manual_channel_handle")
|
|
1736
1617
|
description = st.text_area("Descrição", key="manual_channel_description")
|
|
1737
|
-
niche = st.text_input("
|
|
1618
|
+
niche = st.text_input("Nicho", placeholder="Ex.: História militar, mistérios, ciência", key="manual_channel_niche")
|
|
1738
1619
|
language = st.selectbox("Idioma", list(LANGUAGE_CODES), index=list(LANGUAGE_CODES).index("pt"), format_func=language_label, key="manual_channel_language")
|
|
1739
1620
|
style = st.selectbox("Estilo wide", ["Pexels/Pixabay", "full_ia", "Apenas Música"], index=0, key="manual_channel_style")
|
|
1740
1621
|
manual_blueprint_items = blueprint_catalog()
|
|
1741
1622
|
manual_blueprint_ids = [item[0] for item in manual_blueprint_items]
|
|
1742
1623
|
manual_blueprint_labels = {item[0]: item[1] for item in manual_blueprint_items}
|
|
1743
|
-
blueprint = st.selectbox("Blueprint
|
|
1624
|
+
blueprint = st.selectbox("Blueprint Padrão", manual_blueprint_ids, format_func=lambda item: manual_blueprint_labels.get(item, item or "Sem Blueprint padrão"), key="manual_channel_blueprint")
|
|
1744
1625
|
voice_options = voice_catalog()
|
|
1745
|
-
voice = st.selectbox("Voz
|
|
1626
|
+
voice = st.selectbox("Narrador/Voz Padrão", voice_options, format_func=lambda item: item or "Sem voz padrão", key="manual_channel_voice")
|
|
1746
1627
|
google_account_id = st.selectbox("Conta Google para Upload directo", youtube_account_ids, format_func=lambda item: youtube_account_labels.get(item, item), key="manual_channel_google_account_id")
|
|
1747
1628
|
st.caption("O DELEGATED_SESSION_ID é lido exclusivamente do documento JSON da conta Google associada.")
|
|
1748
1629
|
automation_on = st.toggle("Automação ON", value=False, key="manual_channel_automation_on")
|
|
@@ -1788,9 +1669,6 @@ def render_channels():
|
|
|
1788
1669
|
if not channels:
|
|
1789
1670
|
st.info("Nenhum canal cadastrado.")
|
|
1790
1671
|
return
|
|
1791
|
-
if st.session_state.get("youtube_channels_view_mode", "Lista") == "Kanban":
|
|
1792
|
-
render_registered_channels_kanban(channels)
|
|
1793
|
-
return
|
|
1794
1672
|
for channel in channels:
|
|
1795
1673
|
channel_id = str(channel["id"])
|
|
1796
1674
|
edit_key = f"edit_channel_{channel_id}"
|
|
@@ -1828,22 +1706,25 @@ def render_channels():
|
|
|
1828
1706
|
render_channel_edit_form(channel, youtube_account_ids, youtube_account_labels, youtube_accounts_by_id)
|
|
1829
1707
|
else:
|
|
1830
1708
|
summary = channel_blueprint_summary(channel)
|
|
1831
|
-
|
|
1709
|
+
channel_language = language_label(channel.get("language") or "pt")
|
|
1710
|
+
block_cols = st.columns(4, gap="small")
|
|
1832
1711
|
with block_cols[0]:
|
|
1833
|
-
st.markdown(f"**
|
|
1834
|
-
if st.button("Editar
|
|
1712
|
+
st.markdown(f"**Blueprint Padrão**\n\n{summary['name']}")
|
|
1713
|
+
if st.button("Editar Blueprint", key=f"edit_prompts_{channel_id}", use_container_width=True):
|
|
1835
1714
|
st.session_state[edit_key] = True
|
|
1836
1715
|
st.rerun()
|
|
1837
1716
|
with block_cols[1]:
|
|
1838
|
-
st.markdown(f"**
|
|
1717
|
+
st.markdown(f"**Nicho**\n\n{channel_niche_label(channel)}")
|
|
1839
1718
|
if st.button("Editar Nicho", key=f"edit_niche_{channel_id}", use_container_width=True):
|
|
1840
1719
|
st.session_state[edit_key] = True
|
|
1841
1720
|
st.rerun()
|
|
1842
1721
|
with block_cols[2]:
|
|
1843
|
-
st.markdown(f"**Narrador**\n\n{summary['voice'] or 'Sem voz padrão'}")
|
|
1844
|
-
if st.button("Configurar Narrador", key=f"edit_voice_{channel_id}", use_container_width=True):
|
|
1722
|
+
st.markdown(f"**Narrador/Voz Padrão**\n\n{summary['voice'] or 'Sem voz padrão'}")
|
|
1723
|
+
if st.button("Configurar Narrador/Voz", key=f"edit_voice_{channel_id}", use_container_width=True):
|
|
1845
1724
|
st.session_state[edit_key] = True
|
|
1846
1725
|
st.rerun()
|
|
1726
|
+
with block_cols[3]:
|
|
1727
|
+
st.markdown(f"**Idioma**\n\n{channel_language}")
|
|
1847
1728
|
|
|
1848
1729
|
channel_account_ids = list(youtube_account_ids)
|
|
1849
1730
|
current_channel_account_id = str(channel.get("google_account_id", ""))
|
|
@@ -3691,20 +3572,25 @@ def render_automation():
|
|
|
3691
3572
|
with cols[1]:
|
|
3692
3573
|
st.write(f"**{channel.get('name', 'Sem nome')}**")
|
|
3693
3574
|
st.caption(channel.get("handle") or channel.get("url") or "sem URL")
|
|
3694
|
-
st.caption(f"Blueprint actual: {channel.get('default_blueprint_id') or channel.get('blueprint_id') or '—'} · Voz actual: {channel.get('default_voice') or channel.get('voice') or '—'}")
|
|
3695
3575
|
blueprint_ids, blueprint_labels, current_blueprint, voice_options, current_voice = channel_default_options(channel)
|
|
3696
|
-
default_cols = st.columns(
|
|
3576
|
+
default_cols = st.columns(4, gap="small")
|
|
3697
3577
|
with default_cols[0]:
|
|
3578
|
+
st.markdown("**Idioma Padrão**")
|
|
3579
|
+
st.caption(language_label(channel.get("language") or "pt"))
|
|
3580
|
+
with default_cols[1]:
|
|
3698
3581
|
automation_blueprint = st.selectbox(
|
|
3699
|
-
"Blueprint
|
|
3582
|
+
"Blueprint Padrão",
|
|
3700
3583
|
blueprint_ids,
|
|
3701
3584
|
index=blueprint_ids.index(current_blueprint) if current_blueprint in blueprint_ids else 0,
|
|
3702
3585
|
format_func=lambda item: blueprint_labels.get(item, item or "Sem Blueprint padrão"),
|
|
3703
3586
|
key=f"automation_blueprint_{channel_id}",
|
|
3704
3587
|
)
|
|
3705
|
-
with default_cols[
|
|
3588
|
+
with default_cols[2]:
|
|
3589
|
+
st.markdown("**Nicho Padrão**")
|
|
3590
|
+
st.caption(channel_niche_label(channel))
|
|
3591
|
+
with default_cols[3]:
|
|
3706
3592
|
automation_voice = st.selectbox(
|
|
3707
|
-
"Voz
|
|
3593
|
+
"Narrador/Voz Padrão",
|
|
3708
3594
|
voice_options,
|
|
3709
3595
|
index=voice_options.index(current_voice) if current_voice in voice_options else 0,
|
|
3710
3596
|
format_func=lambda item: item or "Sem voz padrão",
|
|
@@ -5401,6 +5287,65 @@ def _render_telegram_notification_settings() -> None:
|
|
|
5401
5287
|
st.info("Telegram está desactivado. As notificações continuam disponíveis na subaba Geral.")
|
|
5402
5288
|
|
|
5403
5289
|
|
|
5290
|
+
def render_logs():
|
|
5291
|
+
"""Render the unified local activity log before the API configuration page."""
|
|
5292
|
+
st.title("Logs")
|
|
5293
|
+
st.caption("Histórico unificado das operações do Thunderbolt. Os registos são reconstruídos a partir das tarefas e notificações persistidas no storage local.")
|
|
5294
|
+
try:
|
|
5295
|
+
reconcile_persisted_notifications()
|
|
5296
|
+
except Exception:
|
|
5297
|
+
pass
|
|
5298
|
+
|
|
5299
|
+
initial_records = list_logs(limit=500)
|
|
5300
|
+
catalog_operations = {str(item.get("label") or "Operação") for item in notification_event_catalog()}
|
|
5301
|
+
logged_operations = {str(item.get("operation") or "Operação") for item in initial_records}
|
|
5302
|
+
operation_options = ["Todas"] + sorted(catalog_operations | logged_operations)
|
|
5303
|
+
status_options = ["Todos"] + sorted({str(item.get("status") or "Desconhecido") for item in initial_records})
|
|
5304
|
+
filter_cols = st.columns([2.2, 1.25, 1.1])
|
|
5305
|
+
with filter_cols[0]:
|
|
5306
|
+
query = st.text_input(
|
|
5307
|
+
"Filtrar operações",
|
|
5308
|
+
placeholder="Pesquisar por operação, registo, canal ou detalhes",
|
|
5309
|
+
key="logs_query_filter",
|
|
5310
|
+
)
|
|
5311
|
+
with filter_cols[1]:
|
|
5312
|
+
selected_operation = st.selectbox("Operação", operation_options, key="logs_operation_filter")
|
|
5313
|
+
with filter_cols[2]:
|
|
5314
|
+
selected_status = st.selectbox("Estado", status_options, key="logs_status_filter")
|
|
5315
|
+
|
|
5316
|
+
action_cols = st.columns([1, 1, 3])
|
|
5317
|
+
with action_cols[0]:
|
|
5318
|
+
if st.button("Actualizar logs", use_container_width=True):
|
|
5319
|
+
st.rerun()
|
|
5320
|
+
with action_cols[1]:
|
|
5321
|
+
st.metric("Registos", len(initial_records))
|
|
5322
|
+
with action_cols[2]:
|
|
5323
|
+
st.caption("São incluídos estados pendentes, em execução, concluídos, publicados, falhados, cancelados e bloqueados quando existirem.")
|
|
5324
|
+
|
|
5325
|
+
operation_filter = "" if selected_operation == "Todas" else selected_operation
|
|
5326
|
+
status_filter = "" if selected_status == "Todos" else selected_status
|
|
5327
|
+
records = list_logs(operation=operation_filter, query=query, status=status_filter, limit=500)
|
|
5328
|
+
if not records:
|
|
5329
|
+
st.info("Ainda não existem logs para os filtros seleccionados.")
|
|
5330
|
+
return
|
|
5331
|
+
rows = logs_to_rows(records)
|
|
5332
|
+
st.dataframe(
|
|
5333
|
+
rows,
|
|
5334
|
+
use_container_width=True,
|
|
5335
|
+
hide_index=True,
|
|
5336
|
+
column_config={
|
|
5337
|
+
"Operação": st.column_config.TextColumn("Operação", width="medium"),
|
|
5338
|
+
"Estado": st.column_config.TextColumn("Estado", width="small"),
|
|
5339
|
+
"Data": st.column_config.TextColumn("Data", width="small"),
|
|
5340
|
+
"Hora": st.column_config.TextColumn("Hora", width="small"),
|
|
5341
|
+
"Registo": st.column_config.TextColumn("Registo", width="medium"),
|
|
5342
|
+
"Origem": st.column_config.TextColumn("Origem", width="small"),
|
|
5343
|
+
"Progresso": st.column_config.TextColumn("Progresso", width="small"),
|
|
5344
|
+
"Detalhes": st.column_config.TextColumn("Detalhes", width="large"),
|
|
5345
|
+
},
|
|
5346
|
+
)
|
|
5347
|
+
|
|
5348
|
+
|
|
5404
5349
|
def render_notifications():
|
|
5405
5350
|
st.title("Notificações")
|
|
5406
5351
|
st.caption("Centro de notificações internas persistentes do Thunderbolt. As conclusões são guardadas no storage local e aparecem quando a aplicação é actualizada.")
|
|
@@ -5844,6 +5789,7 @@ def main():
|
|
|
5844
5789
|
settings_items = [
|
|
5845
5790
|
("MCP", ":material/hub:", "MCP"),
|
|
5846
5791
|
("Notificações", ":material/notifications:", "Notificações"),
|
|
5792
|
+
("Logs", ":material/description:", "Logs"),
|
|
5847
5793
|
("Configuração API", ":material/settings:", "Configuração API"),
|
|
5848
5794
|
]
|
|
5849
5795
|
niche_finder_items = [
|
|
@@ -5976,6 +5922,7 @@ def main():
|
|
|
5976
5922
|
"Contas Google": render_google_accounts,
|
|
5977
5923
|
"Configuração API": render_settings,
|
|
5978
5924
|
"Notificações": render_notifications,
|
|
5925
|
+
"Logs": render_logs,
|
|
5979
5926
|
}
|
|
5980
5927
|
try:
|
|
5981
5928
|
reconcile_persisted_notifications()
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
"""Unified activity logs projected from Thunderbolt's persisted local state."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from datetime import datetime, timezone
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from . import storage
|
|
9
|
+
from .notifications import EVENTS_BY_CODE, list_notifications, reconcile_persisted_notifications
|
|
10
|
+
|
|
11
|
+
MAX_LOGS = 500
|
|
12
|
+
|
|
13
|
+
STATUS_LABELS = {
|
|
14
|
+
"to_do": "Pendente",
|
|
15
|
+
"pending": "Pendente",
|
|
16
|
+
"queued": "Na fila",
|
|
17
|
+
"doing": "Em execução",
|
|
18
|
+
"running": "Em execução",
|
|
19
|
+
"processing": "Em execução",
|
|
20
|
+
"done": "Concluído",
|
|
21
|
+
"completed": "Concluído",
|
|
22
|
+
"complete": "Concluído",
|
|
23
|
+
"success": "Concluído",
|
|
24
|
+
"published": "Publicado",
|
|
25
|
+
"failed": "Falha",
|
|
26
|
+
"error": "Falha",
|
|
27
|
+
"cancelled": "Cancelado",
|
|
28
|
+
"canceled": "Cancelado",
|
|
29
|
+
"blocked": "Bloqueado",
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
TASK_OPERATION_CODES = {
|
|
33
|
+
"video": "video_completed",
|
|
34
|
+
"music": "music_completed",
|
|
35
|
+
"automation": "automation_completed",
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _status_label(value: Any) -> str:
|
|
40
|
+
raw = str(value or "unknown").strip().lower()
|
|
41
|
+
return STATUS_LABELS.get(raw, raw.replace("_", " ").capitalize() or "Desconhecido")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _parse_datetime(value: Any) -> datetime:
|
|
45
|
+
text = str(value or "").strip()
|
|
46
|
+
if not text:
|
|
47
|
+
return datetime.min.replace(tzinfo=timezone.utc)
|
|
48
|
+
try:
|
|
49
|
+
parsed = datetime.fromisoformat(text.replace("Z", "+00:00"))
|
|
50
|
+
except ValueError:
|
|
51
|
+
return datetime.min.replace(tzinfo=timezone.utc)
|
|
52
|
+
if parsed.tzinfo is None:
|
|
53
|
+
return parsed.replace(tzinfo=timezone.utc)
|
|
54
|
+
return parsed.astimezone(timezone.utc)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def format_log_date_time(value: Any) -> tuple[str, str]:
|
|
58
|
+
"""Return a stable local date and time for the Logs table."""
|
|
59
|
+
parsed = _parse_datetime(value)
|
|
60
|
+
if parsed == datetime.min.replace(tzinfo=timezone.utc):
|
|
61
|
+
return "—", "—"
|
|
62
|
+
local = parsed.astimezone()
|
|
63
|
+
return local.strftime("%d/%m/%Y"), local.strftime("%H:%M:%S")
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _task_operation_code(task: dict[str, Any]) -> str:
|
|
67
|
+
if bool(task.get("music_mode")) or str(task.get("style_wide") or "").strip().lower() == "music":
|
|
68
|
+
return TASK_OPERATION_CODES["music"]
|
|
69
|
+
if str(task.get("creation_mode") or "").strip().lower() == "automation":
|
|
70
|
+
return TASK_OPERATION_CODES["automation"]
|
|
71
|
+
return TASK_OPERATION_CODES["video"]
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _operation_label(operation_code: str, fallback: str = "Operação") -> str:
|
|
75
|
+
event = EVENTS_BY_CODE.get(operation_code)
|
|
76
|
+
return str(event.get("label") if event else fallback or operation_code or "Operação")
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _task_log(task: dict[str, Any]) -> dict[str, Any] | None:
|
|
80
|
+
task_id = str(task.get("id") or "").strip()
|
|
81
|
+
if not task_id:
|
|
82
|
+
return None
|
|
83
|
+
operation_code = _task_operation_code(task)
|
|
84
|
+
state = str(task.get("state") or "unknown").strip().lower()
|
|
85
|
+
occurred_at = task.get("updated_at") or task.get("completed_at") or task.get("created_at")
|
|
86
|
+
date, time = format_log_date_time(occurred_at)
|
|
87
|
+
title = str(task.get("title") or task.get("topic") or "Actividade").strip()
|
|
88
|
+
channel = str(task.get("channel_name") or "").strip()
|
|
89
|
+
stage = str(task.get("stage") or "").strip()
|
|
90
|
+
progress = task.get("progress")
|
|
91
|
+
try:
|
|
92
|
+
progress_value = max(0, min(100, int(progress))) if progress is not None else None
|
|
93
|
+
except (TypeError, ValueError):
|
|
94
|
+
progress_value = None
|
|
95
|
+
details: list[str] = []
|
|
96
|
+
if channel:
|
|
97
|
+
details.append(channel)
|
|
98
|
+
if stage:
|
|
99
|
+
details.append(f"Etapa: {stage}")
|
|
100
|
+
if progress_value is not None:
|
|
101
|
+
details.append(f"Progresso: {progress_value}%")
|
|
102
|
+
error = str(task.get("error") or "").strip()
|
|
103
|
+
if error:
|
|
104
|
+
details.append(error[:500])
|
|
105
|
+
return {
|
|
106
|
+
"id": f"task:{task_id}",
|
|
107
|
+
"operation_code": operation_code,
|
|
108
|
+
"operation": _operation_label(operation_code),
|
|
109
|
+
"status_code": state,
|
|
110
|
+
"status": _status_label(state),
|
|
111
|
+
"occurred_at": str(occurred_at or ""),
|
|
112
|
+
"date": date,
|
|
113
|
+
"time": time,
|
|
114
|
+
"source": "Tarefas",
|
|
115
|
+
"record": title,
|
|
116
|
+
"details": " · ".join(details),
|
|
117
|
+
"progress": progress_value,
|
|
118
|
+
"task_id": task_id,
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _notification_status(entry: dict[str, Any]) -> str:
|
|
123
|
+
metadata = entry.get("metadata") if isinstance(entry.get("metadata"), dict) else {}
|
|
124
|
+
explicit = metadata.get("status") or entry.get("status")
|
|
125
|
+
if explicit:
|
|
126
|
+
return str(explicit)
|
|
127
|
+
event_type = str(entry.get("event_type") or "").lower()
|
|
128
|
+
label = str(entry.get("label") or "").lower()
|
|
129
|
+
return "failed" if event_type.endswith("_failed") or "falhou" in label or "erro" in label else "completed"
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _notification_log(entry: dict[str, Any]) -> dict[str, Any] | None:
|
|
133
|
+
entry_id = str(entry.get("id") or "").strip()
|
|
134
|
+
if not entry_id:
|
|
135
|
+
return None
|
|
136
|
+
event_type = str(entry.get("event_type") or "").strip()
|
|
137
|
+
if not event_type:
|
|
138
|
+
return None
|
|
139
|
+
status_code = _notification_status(entry).strip().lower()
|
|
140
|
+
occurred_at = entry.get("created_at") or ""
|
|
141
|
+
date, time = format_log_date_time(occurred_at)
|
|
142
|
+
metadata = entry.get("metadata") if isinstance(entry.get("metadata"), dict) else {}
|
|
143
|
+
public_metadata = [f"{key}: {value}" for key, value in metadata.items() if value not in (None, "")]
|
|
144
|
+
return {
|
|
145
|
+
"id": f"notification:{entry_id}",
|
|
146
|
+
"operation_code": event_type,
|
|
147
|
+
"operation": str(entry.get("label") or _operation_label(event_type)),
|
|
148
|
+
"status_code": status_code,
|
|
149
|
+
"status": _status_label(status_code),
|
|
150
|
+
"occurred_at": str(occurred_at),
|
|
151
|
+
"date": date,
|
|
152
|
+
"time": time,
|
|
153
|
+
"source": "Notificações",
|
|
154
|
+
"record": str(entry.get("title") or entry.get("label") or "Notificação"),
|
|
155
|
+
"details": " · ".join([str(entry.get("message") or "").strip(), *public_metadata]).strip(" ·")[:1000],
|
|
156
|
+
"progress": None,
|
|
157
|
+
"task_id": str(metadata.get("task_id") or ""),
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _should_skip_notification(entry: dict[str, Any], task_ids: set[str]) -> bool:
|
|
162
|
+
"""Avoid duplicating the canonical task completion/failure with its notification."""
|
|
163
|
+
event_type = str(entry.get("event_type") or "")
|
|
164
|
+
metadata = entry.get("metadata") if isinstance(entry.get("metadata"), dict) else {}
|
|
165
|
+
task_id = str(metadata.get("task_id") or "")
|
|
166
|
+
return bool(task_id and task_id in task_ids and event_type in {"video_completed", "music_completed", "activity_failed"})
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def list_logs(*, operation: str = "", query: str = "", status: str = "", limit: int = MAX_LOGS) -> list[dict[str, Any]]:
|
|
170
|
+
"""Return a unified, newest-first projection of local activity records."""
|
|
171
|
+
try:
|
|
172
|
+
reconcile_persisted_notifications()
|
|
173
|
+
except Exception:
|
|
174
|
+
# A diagnostic page must remain available even if one source is malformed.
|
|
175
|
+
pass
|
|
176
|
+
tasks = storage.read_json("tasks.json", [])
|
|
177
|
+
tasks = [item for item in tasks if isinstance(item, dict)] if isinstance(tasks, list) else []
|
|
178
|
+
task_ids = {str(item.get("id") or "") for item in tasks if item.get("id")}
|
|
179
|
+
records = [item for item in (_task_log(task) for task in tasks) if item]
|
|
180
|
+
notifications = list_notifications(limit=MAX_LOGS)
|
|
181
|
+
records.extend(
|
|
182
|
+
item
|
|
183
|
+
for entry in notifications
|
|
184
|
+
if not _should_skip_notification(entry, task_ids)
|
|
185
|
+
for item in [_notification_log(entry)]
|
|
186
|
+
if item
|
|
187
|
+
)
|
|
188
|
+
operation_filter = str(operation or "").strip().casefold()
|
|
189
|
+
query_filter = str(query or "").strip().casefold()
|
|
190
|
+
status_filter = str(status or "").strip().casefold()
|
|
191
|
+
if operation_filter:
|
|
192
|
+
records = [item for item in records if str(item.get("operation") or "").casefold() == operation_filter]
|
|
193
|
+
if status_filter:
|
|
194
|
+
records = [item for item in records if str(item.get("status") or "").casefold() == status_filter]
|
|
195
|
+
if query_filter:
|
|
196
|
+
records = [
|
|
197
|
+
item
|
|
198
|
+
for item in records
|
|
199
|
+
if query_filter in " ".join(str(item.get(key) or "") for key in ("operation", "status", "record", "details", "source")).casefold()
|
|
200
|
+
]
|
|
201
|
+
records.sort(key=lambda item: _parse_datetime(item.get("occurred_at")), reverse=True)
|
|
202
|
+
try:
|
|
203
|
+
requested_limit = int(limit)
|
|
204
|
+
except (TypeError, ValueError):
|
|
205
|
+
requested_limit = MAX_LOGS
|
|
206
|
+
return records[: max(1, min(requested_limit, MAX_LOGS))]
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def log_operation_options() -> list[str]:
|
|
210
|
+
records = list_logs(limit=MAX_LOGS)
|
|
211
|
+
return ["Todas"] + sorted({str(item.get("operation") or "Operação") for item in records})
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def log_status_options() -> list[str]:
|
|
215
|
+
records = list_logs(limit=MAX_LOGS)
|
|
216
|
+
return ["Todos"] + sorted({str(item.get("status") or "Desconhecido") for item in records})
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def logs_to_rows(records: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
|
220
|
+
"""Convert the internal projection into safe scalar rows for Streamlit."""
|
|
221
|
+
return [
|
|
222
|
+
{
|
|
223
|
+
"Operação": item.get("operation") or "Operação",
|
|
224
|
+
"Estado": item.get("status") or "Desconhecido",
|
|
225
|
+
"Data": item.get("date") or "—",
|
|
226
|
+
"Hora": item.get("time") or "—",
|
|
227
|
+
"Registo": item.get("record") or "—",
|
|
228
|
+
"Origem": item.get("source") or "—",
|
|
229
|
+
"Progresso": f"{item['progress']}%" if item.get("progress") is not None else "—",
|
|
230
|
+
"Detalhes": item.get("details") or "—",
|
|
231
|
+
}
|
|
232
|
+
for item in records
|
|
233
|
+
]
|
package/package.json
CHANGED