@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.
@@ -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.38
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.38 install
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.38 install
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.38 install`; o instalador detecta e reutiliza o que já estiver válido.
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.38
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**, **Configuração API** e **Notificações**. O Início reúne o dashboard e as filas do Pipeline, sem botões de acções rápidas.
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 = read_json("tasks.json", [])
3343
+ tasks = load_video_tasks_for_catalog()
3301
3344
  if not tasks:
3302
3345
  st.info("Nenhum vídeo criado.")
3303
3346
  return
3304
- state_filter = st.selectbox("Filtrar por estado", ["Todos", "to_do", "doing", "blocked", "done", "failed", "cancelled"], key="videos_state_filter")
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]: st.write(task.get("format", "wide"))
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
- st.write(task.get("state", "—"))
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 = read_json("tasks.json", [])
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.45, 1.15, 1.15, 1.85])
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
- st.caption("Estado")
3628
- st.write(task.get("state", "—"))
3669
+ _render_video_task_state(task)
3629
3670
  with task_cols[2]:
3630
- st.caption("Estilo")
3631
- st.write(task.get("style_wide", "—"))
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danhachuel/thunderbolt",
3
- "version": "0.3.38",
3
+ "version": "0.3.40",
4
4
  "description": "Thunderbolt — interface local para operação de canais faceless e motor MoneyPrinterTurbo",
5
5
  "license": "MIT",
6
6
  "main": "scripts/cli.mjs",