@danhachuel/thunderbolt 0.3.38 → 0.3.40
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 -4
- package/README.md +9 -3
- package/app/main.py +117 -15
- 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.
|
|
@@ -396,6 +396,10 @@ Uma execução da etapa Vídeo tem limite de 20 minutos. Se o processo externo t
|
|
|
396
396
|
|
|
397
397
|
Se o launcher ou o worker for encerrado abruptamente, uma tarefa `doing` sem actualização durante 25 minutos é recuperada e marcada como `failed`, evitando estados indefinidos eternos. Para processar novas tarefas, deixe a aplicação iniciada com o comando normal; o painel avisa quando não existe heartbeat recente do worker.
|
|
398
398
|
|
|
399
|
+
O **Backlog Vídeos** e a lista **Automação > Automação Youtube > Vídeos cadastrados** usam o mesmo catálogo completo de `storage/state/tasks.json`. Por isso, tarefas criadas manualmente e tarefas criadas pelo worker diário aparecem nos dois locais, sem filtragem implícita por origem. O filtro do Backlog mostra os estados conhecidos e inclui automaticamente estados adicionais encontrados nos dados.
|
|
400
|
+
|
|
401
|
+
Nos dois cards, o bloco de estado apresenta o valor técnico, o rótulo legível, a barra de progresso e a mensagem de erro quando existir. O bloco **Formato** resolve nesta ordem `format`, `style_wide`, `style` e, por fim, `wide`, mantendo a apresentação de formatos como `wide`, `shorts`, `music` ou `full_ia` mesmo em tarefas antigas.
|
|
402
|
+
|
|
399
403
|
### Upload Música — JewelMusic, Pushtunes e ytmusicapi
|
|
400
404
|
|
|
401
405
|
A área **Pipeline Música > Upload Música** separa três métodos com contratos diferentes. Em **JewelMusic**, active a integração, introduza a API Key fornecida pelo dashboard da JewelMusic e confirme a Base URL oficial `https://api.jewelmusic.com` e, se necessário, configure proxy e timeout. Carregue ou seleccione um ficheiro de música, indique artista e título e clique em **Enviar música para JewelMusic**. O teste de ligação consulta `/v1/ping`; o upload envia `multipart/form-data` para `/v1/tracks/upload` com os metadados preenchidos.
|
|
@@ -465,6 +469,8 @@ O cartão mostra o nicho imediatamente abaixo do nome. Os quatro blocos compacto
|
|
|
465
469
|
|
|
466
470
|
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
471
|
|
|
472
|
+
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.
|
|
473
|
+
|
|
468
474
|
## Canais em lote por conta Google/YouTube
|
|
469
475
|
|
|
470
476
|
A subaba **Canais em lote gmail**, dentro de **Canais Youtube**, não lê e-mails, mensagens, contactos ou a caixa Gmail. O nome identifica a conta Google/Gmail que gere os canais YouTube. Configure cada conta em **Configurações > Contas Google**, preenchendo e-mail, OAuth Client ID, OAuth Client Secret e o `sessionInfo token desta conta Google`.
|
|
@@ -481,7 +487,7 @@ Ao abrir a página, o Thunderbolt não prepara dados públicos, não descarrega
|
|
|
481
487
|
|
|
482
488
|
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
489
|
|
|
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.
|
|
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.39 install`; o instalador detecta e reutiliza o que já estiver válido.
|
|
485
491
|
|
|
486
492
|
### Niche Finder Apify
|
|
487
493
|
|
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.40
|
|
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,10 +85,16 @@ 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
|
+
## Catálogo comum do Backlog e da Automação
|
|
93
|
+
|
|
94
|
+
**Backlog Vídeos** e **Automação Youtube > Vídeos cadastrados** usam o mesmo catálogo completo persistido em `storage/state/tasks.json`. Assim, todas as tarefas criadas manualmente ou pela automação diária aparecem nos dois fluxos, sem duplicação nem filtros implícitos por origem. O filtro de estado do Backlog mantém os estados conhecidos e acrescenta estados novos encontrados no storage.
|
|
95
|
+
|
|
96
|
+
Os dois cards mostram de forma consistente o estado técnico, o rótulo legível, a barra de progresso, a etapa e o formato do vídeo. São suportados estados como `to_do`, `doing`, `blocked`, `done`, `failed` e `cancelled`; formatos ausentes usam `format`, `style_wide` ou `wide` como fallback.
|
|
97
|
+
|
|
92
98
|
## Canais Youtube — edição por cartão e vídeos recentes
|
|
93
99
|
|
|
94
100
|
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**.
|
package/app/main.py
CHANGED
|
@@ -42,6 +42,7 @@ from hermes_ui.llm_providers import LLM_CARDS_KEY, LLM_ACTIVE_CARD_KEY, LLM_PROV
|
|
|
42
42
|
from hermes_ui.music import list_music_files, materialize_suno_audio, request_suno_generation, store_music_file
|
|
43
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
|
|
44
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
|
|
45
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
|
|
46
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
|
|
47
48
|
from hermes_ui.tutorials import tutorial_body, tutorial_caption, tutorial_title
|
|
@@ -3292,16 +3293,60 @@ def _render_pipeline_progress_panel() -> None:
|
|
|
3292
3293
|
_render_pipeline_progress_live()
|
|
3293
3294
|
|
|
3294
3295
|
|
|
3296
|
+
VIDEO_TASK_STATE_LABELS = {
|
|
3297
|
+
"to_do": "Pendente",
|
|
3298
|
+
"doing": "Em execução",
|
|
3299
|
+
"blocked": "Bloqueado",
|
|
3300
|
+
"done": "Concluído",
|
|
3301
|
+
"failed": "Falha",
|
|
3302
|
+
"cancelled": "Cancelado",
|
|
3303
|
+
}
|
|
3304
|
+
|
|
3305
|
+
|
|
3306
|
+
def load_video_tasks_for_catalog() -> list[dict[str, Any]]:
|
|
3307
|
+
"""Return the complete persisted task catalog shared by Backlog and Automation."""
|
|
3308
|
+
saved = read_json("tasks.json", [])
|
|
3309
|
+
if not isinstance(saved, list):
|
|
3310
|
+
return []
|
|
3311
|
+
return [task for task in saved if isinstance(task, dict) and str(task.get("id") or "").strip()]
|
|
3312
|
+
|
|
3313
|
+
|
|
3314
|
+
def _video_task_format(task: dict[str, Any]) -> str:
|
|
3315
|
+
value = task.get("format") or task.get("style_wide") or task.get("style") or "wide"
|
|
3316
|
+
return str(value).strip() or "wide"
|
|
3317
|
+
|
|
3318
|
+
|
|
3319
|
+
def _video_task_progress(task: dict[str, Any]) -> int:
|
|
3320
|
+
try:
|
|
3321
|
+
return max(0, min(100, int(task.get("progress") or 0)))
|
|
3322
|
+
except (TypeError, ValueError):
|
|
3323
|
+
return 0
|
|
3324
|
+
|
|
3325
|
+
|
|
3326
|
+
def _render_video_task_state(task: dict[str, Any]) -> None:
|
|
3327
|
+
"""Show the raw state, readable label and progress consistently in both views."""
|
|
3328
|
+
state = str(task.get("state") or "unknown").strip().lower()
|
|
3329
|
+
progress = _video_task_progress(task)
|
|
3330
|
+
st.caption("Estado")
|
|
3331
|
+
st.write(state or "—")
|
|
3332
|
+
st.caption(VIDEO_TASK_STATE_LABELS.get(state, state.replace("_", " ").capitalize() or "Desconhecido"))
|
|
3333
|
+
st.progress(progress, text=f"{progress}%")
|
|
3334
|
+
if task.get("error"):
|
|
3335
|
+
st.caption(str(task.get("error"))[:240])
|
|
3336
|
+
|
|
3337
|
+
|
|
3295
3338
|
def render_videos():
|
|
3296
3339
|
st.subheader("Backlog Videos")
|
|
3297
3340
|
st.caption("Acompanhamento dos vídeos criados, estados da pipeline e controlos de execução.")
|
|
3298
3341
|
st.caption(f"Os vídeos são guardados em `{STORAGE / 'videos'}`.")
|
|
3299
3342
|
_render_pipeline_progress_panel()
|
|
3300
|
-
tasks =
|
|
3343
|
+
tasks = load_video_tasks_for_catalog()
|
|
3301
3344
|
if not tasks:
|
|
3302
3345
|
st.info("Nenhum vídeo criado.")
|
|
3303
3346
|
return
|
|
3304
|
-
|
|
3347
|
+
known_states = ["to_do", "doing", "blocked", "done", "failed", "cancelled"]
|
|
3348
|
+
extra_states = sorted({str(task.get("state") or "unknown") for task in tasks if str(task.get("state") or "unknown") not in known_states})
|
|
3349
|
+
state_filter = st.selectbox("Filtrar por estado", ["Todos", *known_states, *extra_states], key="videos_state_filter")
|
|
3305
3350
|
for task in tasks:
|
|
3306
3351
|
if state_filter != "Todos" and task.get("state") != state_filter:
|
|
3307
3352
|
continue
|
|
@@ -3318,16 +3363,13 @@ def render_videos():
|
|
|
3318
3363
|
status = task.get('thumbnail_status', 'not_generated')
|
|
3319
3364
|
prompt_note = ' · prompt pronto' if task.get('thumbnail_prompt') else ''
|
|
3320
3365
|
st.caption(f"Thumbnail: {status}{prompt_note}")
|
|
3321
|
-
with cols[1]:
|
|
3366
|
+
with cols[1]:
|
|
3367
|
+
st.caption("Formato")
|
|
3368
|
+
st.write(_video_task_format(task))
|
|
3322
3369
|
with cols[2]:
|
|
3323
3370
|
st.write(_pipeline_stage_label(task))
|
|
3324
|
-
if str(task.get("state") or "") in {"to_do", "doing", "blocked"}:
|
|
3325
|
-
progress = _pipeline_progress_value(task)
|
|
3326
|
-
st.progress(progress, text=f"{progress}%")
|
|
3327
3371
|
with cols[3]:
|
|
3328
|
-
|
|
3329
|
-
if task.get("error"):
|
|
3330
|
-
st.caption(str(task.get("error"))[:240])
|
|
3372
|
+
_render_video_task_state(task)
|
|
3331
3373
|
|
|
3332
3374
|
with cols[4]:
|
|
3333
3375
|
state = str(task.get("state") or "")
|
|
@@ -3614,21 +3656,20 @@ def render_automation():
|
|
|
3614
3656
|
|
|
3615
3657
|
st.divider()
|
|
3616
3658
|
st.subheader("Vídeos cadastrados")
|
|
3617
|
-
tasks =
|
|
3659
|
+
tasks = load_video_tasks_for_catalog()
|
|
3618
3660
|
if not tasks:
|
|
3619
3661
|
st.info("Ainda não existem vídeos cadastrados.")
|
|
3620
3662
|
for task in tasks:
|
|
3621
3663
|
with st.container(border=True):
|
|
3622
|
-
task_cols = st.columns([2.
|
|
3664
|
+
task_cols = st.columns([2.25, 1.65, 1.15, 1.85])
|
|
3623
3665
|
with task_cols[0]:
|
|
3624
3666
|
st.write(f"**{task.get('topic', 'Sem tópico')}**")
|
|
3625
3667
|
st.caption(f"{task.get('channel_name', 'Canal')} · {task.get('id', '')}")
|
|
3626
3668
|
with task_cols[1]:
|
|
3627
|
-
|
|
3628
|
-
st.write(task.get("state", "—"))
|
|
3669
|
+
_render_video_task_state(task)
|
|
3629
3670
|
with task_cols[2]:
|
|
3630
|
-
st.caption("
|
|
3631
|
-
st.write(task
|
|
3671
|
+
st.caption("Formato")
|
|
3672
|
+
st.write(_video_task_format(task))
|
|
3632
3673
|
with task_cols[3]:
|
|
3633
3674
|
state = str(task.get("state") or "")
|
|
3634
3675
|
start_col, stop_col = st.columns(2)
|
|
@@ -5286,6 +5327,65 @@ def _render_telegram_notification_settings() -> None:
|
|
|
5286
5327
|
st.info("Telegram está desactivado. As notificações continuam disponíveis na subaba Geral.")
|
|
5287
5328
|
|
|
5288
5329
|
|
|
5330
|
+
def render_logs():
|
|
5331
|
+
"""Render the unified local activity log before the API configuration page."""
|
|
5332
|
+
st.title("Logs")
|
|
5333
|
+
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.")
|
|
5334
|
+
try:
|
|
5335
|
+
reconcile_persisted_notifications()
|
|
5336
|
+
except Exception:
|
|
5337
|
+
pass
|
|
5338
|
+
|
|
5339
|
+
initial_records = list_logs(limit=500)
|
|
5340
|
+
catalog_operations = {str(item.get("label") or "Operação") for item in notification_event_catalog()}
|
|
5341
|
+
logged_operations = {str(item.get("operation") or "Operação") for item in initial_records}
|
|
5342
|
+
operation_options = ["Todas"] + sorted(catalog_operations | logged_operations)
|
|
5343
|
+
status_options = ["Todos"] + sorted({str(item.get("status") or "Desconhecido") for item in initial_records})
|
|
5344
|
+
filter_cols = st.columns([2.2, 1.25, 1.1])
|
|
5345
|
+
with filter_cols[0]:
|
|
5346
|
+
query = st.text_input(
|
|
5347
|
+
"Filtrar operações",
|
|
5348
|
+
placeholder="Pesquisar por operação, registo, canal ou detalhes",
|
|
5349
|
+
key="logs_query_filter",
|
|
5350
|
+
)
|
|
5351
|
+
with filter_cols[1]:
|
|
5352
|
+
selected_operation = st.selectbox("Operação", operation_options, key="logs_operation_filter")
|
|
5353
|
+
with filter_cols[2]:
|
|
5354
|
+
selected_status = st.selectbox("Estado", status_options, key="logs_status_filter")
|
|
5355
|
+
|
|
5356
|
+
action_cols = st.columns([1, 1, 3])
|
|
5357
|
+
with action_cols[0]:
|
|
5358
|
+
if st.button("Actualizar logs", use_container_width=True):
|
|
5359
|
+
st.rerun()
|
|
5360
|
+
with action_cols[1]:
|
|
5361
|
+
st.metric("Registos", len(initial_records))
|
|
5362
|
+
with action_cols[2]:
|
|
5363
|
+
st.caption("São incluídos estados pendentes, em execução, concluídos, publicados, falhados, cancelados e bloqueados quando existirem.")
|
|
5364
|
+
|
|
5365
|
+
operation_filter = "" if selected_operation == "Todas" else selected_operation
|
|
5366
|
+
status_filter = "" if selected_status == "Todos" else selected_status
|
|
5367
|
+
records = list_logs(operation=operation_filter, query=query, status=status_filter, limit=500)
|
|
5368
|
+
if not records:
|
|
5369
|
+
st.info("Ainda não existem logs para os filtros seleccionados.")
|
|
5370
|
+
return
|
|
5371
|
+
rows = logs_to_rows(records)
|
|
5372
|
+
st.dataframe(
|
|
5373
|
+
rows,
|
|
5374
|
+
use_container_width=True,
|
|
5375
|
+
hide_index=True,
|
|
5376
|
+
column_config={
|
|
5377
|
+
"Operação": st.column_config.TextColumn("Operação", width="medium"),
|
|
5378
|
+
"Estado": st.column_config.TextColumn("Estado", width="small"),
|
|
5379
|
+
"Data": st.column_config.TextColumn("Data", width="small"),
|
|
5380
|
+
"Hora": st.column_config.TextColumn("Hora", width="small"),
|
|
5381
|
+
"Registo": st.column_config.TextColumn("Registo", width="medium"),
|
|
5382
|
+
"Origem": st.column_config.TextColumn("Origem", width="small"),
|
|
5383
|
+
"Progresso": st.column_config.TextColumn("Progresso", width="small"),
|
|
5384
|
+
"Detalhes": st.column_config.TextColumn("Detalhes", width="large"),
|
|
5385
|
+
},
|
|
5386
|
+
)
|
|
5387
|
+
|
|
5388
|
+
|
|
5289
5389
|
def render_notifications():
|
|
5290
5390
|
st.title("Notificações")
|
|
5291
5391
|
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.")
|
|
@@ -5729,6 +5829,7 @@ def main():
|
|
|
5729
5829
|
settings_items = [
|
|
5730
5830
|
("MCP", ":material/hub:", "MCP"),
|
|
5731
5831
|
("Notificações", ":material/notifications:", "Notificações"),
|
|
5832
|
+
("Logs", ":material/description:", "Logs"),
|
|
5732
5833
|
("Configuração API", ":material/settings:", "Configuração API"),
|
|
5733
5834
|
]
|
|
5734
5835
|
niche_finder_items = [
|
|
@@ -5861,6 +5962,7 @@ def main():
|
|
|
5861
5962
|
"Contas Google": render_google_accounts,
|
|
5862
5963
|
"Configuração API": render_settings,
|
|
5863
5964
|
"Notificações": render_notifications,
|
|
5965
|
+
"Logs": render_logs,
|
|
5864
5966
|
}
|
|
5865
5967
|
try:
|
|
5866
5968
|
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