@danhachuel/thunderbolt 0.2.32 → 0.2.34
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 +15 -9
- package/README.md +15 -9
- package/app/main.py +62 -66
- package/app/modules/niche_finder/data_loader.py +1 -1
- package/hermes_ui/automation_worker.py +273 -0
- package/package.json +1 -1
- package/scripts/cli.mjs +31 -2
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.2.
|
|
5
|
+
> **Versão deste manual:** 0.2.34
|
|
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.2.
|
|
103
|
+
npx.cmd --yes @danhachuel/thunderbolt@0.2.34 install
|
|
104
104
|
```
|
|
105
105
|
|
|
106
106
|
Linux/macOS:
|
|
107
107
|
|
|
108
108
|
```bash
|
|
109
|
-
npx --yes @danhachuel/thunderbolt@0.2.
|
|
109
|
+
npx --yes @danhachuel/thunderbolt@0.2.34 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.
|
|
@@ -220,6 +220,12 @@ Depois de instalar e diagnosticar:
|
|
|
220
220
|
npx --yes @danhachuel/thunderbolt
|
|
221
221
|
```
|
|
222
222
|
|
|
223
|
+
O comando normal inicia a UI e o worker local de Automação. O worker verifica o relógio local do computador e cria os lotes dos canais com **Automação ON** quando o horário `HH:MM` coincide. Para executar apenas o worker, sem abrir a UI:
|
|
224
|
+
|
|
225
|
+
```bash
|
|
226
|
+
npx --yes @danhachuel/thunderbolt worker
|
|
227
|
+
```
|
|
228
|
+
|
|
223
229
|
Abra no navegador:
|
|
224
230
|
|
|
225
231
|
```text
|
|
@@ -365,14 +371,14 @@ Após iniciar a aplicação, valide o seguinte percurso:
|
|
|
365
371
|
2. **Blueprints:** coloque um JSON em `~/.thunderbolt/storage/blueprints/importados/` (Windows: `%LOCALAPPDATA%\\THUNDERBOLT\storage\blueprints\importados\`) ou use o carregador da interface.
|
|
366
372
|
3. **Brandings:** abra a subaba **Brandings** e confirme a listagem dos ficheiros JSON.
|
|
367
373
|
4. **Canais:** em **Importar do YouTube**, use o método **Página pública — sem API Key** com um URL `/channel/UC...`, um handle ou uma subpágina `/videos`; o parser resolve o ID, consulta a página pública e tenta o RSS quando necessário. Confirme que o resultado abre o formulário de revisão sem Data API Key. Se o canal não existir ou não fornecer metadados, confirme a mensagem clara e que o formulário de uma pesquisa anterior desaparece. A Data API é opcional e fica separada; em **Cadastro manual**, preencha os dados sem qualquer consulta externa.
|
|
368
|
-
5. **Niche Finder:** abra a página na sidebar
|
|
374
|
+
5. **Niche Finder:** abra a página na sidebar, defina os parâmetros dentro do conteúdo principal e confirme que não há preparação automática. Antes do clique, não deve existir download de dataset nem análise. Clique em **Analisar Nichos** para iniciar a preparação dos dados e a análise; depois altere país, engagement, datas e tags e clique novamente para aplicar os novos parâmetros.
|
|
369
375
|
6. **Pipeline > Criação de Vídeos:** teste primeiro o modo **Canal específico** e depois os modos de lote.
|
|
370
376
|
7. **Criação de Vídeos > Vídeos:** verifique o estado `to_do` e os botões **Iniciar** e **Parar** dentro da subaba. Confirme também que **Criação de Músicas** mostra o mesmo fluxo com título próprio.
|
|
371
377
|
8. **Upload:** configure o OAuth Client ID e Secret, autorize primeiro o **youtube-automation-agent** na própria aba, confirme o estado **pronto para publicar**, preencha título/descrição/tags e publique um MP4 real. O botão **Autorizar fallback OAuth** existe apenas para redundância; a Data API Key, se configurada, é exclusivamente para consultas oficiais públicas e nunca substitui OAuth.
|
|
372
378
|
9. **Pipeline > Limpador de Metadados:** suba um vídeo de terceiro, preencha título, preview, links, timestamps e tags, aplique a limpeza e descarregue a cópia limpa e o manifesto JSON. O original fica preservado e vídeos das páginas de criação não são aceites nesta área.
|
|
373
379
|
10. **Configurações > Configurações Técnicas:** confirme que os caminhos e credenciais estão locais e não aparecem no Git.
|
|
374
380
|
11. **Criação de Vídeos:** confirme que `Pexels/Pixabay` substitui o label antigo, que `Estilo IA` aparece apenas em `full_ia` e que `Apenas Música` exige áudio e guarda `background_mode=none`.
|
|
375
|
-
12. **Automação:** no cartão de cada canal, escolha o **Blueprint padrão** e a **Voz padrão**, clique em `Guardar` e confirme que o resumo do cartão se actualiza. Configure também **Automação ON** e um horário `HH:MM`; confirme a lista de vídeos cadastrados. Os mesmos defaults aparecem em **Canais** e são usados em novas tarefas.
|
|
381
|
+
12. **Automação:** no cartão de cada canal, escolha o **Blueprint padrão** e a **Voz padrão**, clique em `Guardar` e confirme que o resumo do cartão se actualiza. Configure também **Automação ON** e um horário `HH:MM`; confirme a lista de vídeos cadastrados. Os mesmos defaults aparecem em **Canais** e são usados em novas tarefas. Inicie o Thunderbolt pelo launcher, confirme o aviso verde **Worker activo** e verifique que o relógio apresentado corresponde ao computador. O worker cria no máximo um lote por canal por dia quando o horário local coincide.
|
|
376
382
|
13. **Configurações > Configurações Técnicas > Teste de vozes:** teste Edge/Azure ou provider configurado e confirme reprodução/download sem criação de tarefa.
|
|
377
383
|
14. **Upload directo:** configure manualmente cookies, sessionInfo e INNERTUBE_API_KEY, atribua o `DELEGATED_SESSION_ID` no canal e teste apenas com um vídeo de validação. O método envia chunks de 256 KiB e não extrai cookies do navegador.
|
|
378
384
|
15. **Configurações > MCP > Client MCP:** confirme que Short Video Maker, AutoVio, OpenMontage e OpenCut aparecem com as portas padrão editáveis. O estado **Activo** é uma preferência local; a detecção deve indicar **Não detectado** quando os serviços externos não estiverem instalados ou iniciados.
|
|
@@ -383,17 +389,17 @@ Após iniciar a aplicação, valide o seguinte percurso:
|
|
|
383
389
|
|
|
384
390
|
A página **Niche Finder** integra a lógica adaptada do projecto open source [johanfortus/Niche-Finder](https://github.com/johanfortus/Niche-Finder), cujo projecto original usa K-Means e FP-Growth sobre o dataset público [Trending Youtube Video Statistics (113 Countries)](https://www.kaggle.com/datasets/asaniczka/trending-youtube-videos-113-countries). No Thunderbolt não existe Flask, rota HTTP adicional, template HTML, JavaScript D3 ou segundo processo; toda a análise é síncrona no Streamlit.
|
|
385
391
|
|
|
386
|
-
Ao abrir a página, o Thunderbolt prepara
|
|
392
|
+
Ao abrir a página, o Thunderbolt não prepara dados públicos, não descarrega o dataset e não inicia a análise. A instalação das dependências continua automática, mas a operação é manual: os parâmetros ficam dentro da própria aba e o utilizador deve clicar em **Analisar Nichos**. Só depois desse clique o KaggleHub prepara ou reutiliza a cache local; não há upload de CSV, botão de download manual ou selector de ficheiros.
|
|
387
393
|
|
|
388
|
-
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. 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.
|
|
394
|
+
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.
|
|
389
395
|
|
|
390
|
-
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.2.
|
|
396
|
+
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.2.34 install`; o instalador detecta e reutiliza o que já estiver válido.
|
|
391
397
|
|
|
392
398
|
## Pipeline: Criação de Vídeos, Criação de Músicas e Automação
|
|
393
399
|
|
|
394
400
|
O modo `Pexels/Pixabay` representa materiais de stock. Ao seleccionar `full_ia`, o selector `Estilo IA` apresenta os 12 estilos disponíveis. Ao seleccionar `Apenas Música`, a UI obriga a escolher uma música existente, carregar um ficheiro ou solicitar uma música a um endpoint Suno configurado. O vídeo recebe `background_mode=none`, sem fundo Pexels/Pixabay ou IA.
|
|
395
401
|
|
|
396
|
-
A aba **Automação** lista os canais e vídeos, permite escolher e guardar **Blueprint padrão** e **Voz padrão** por canal, 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** e são copiados para novas tarefas.
|
|
402
|
+
A aba **Automação** lista os canais e vídeos, permite escolher e guardar **Blueprint padrão** e **Voz padrão** por canal, 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** e são copiados para novas tarefas. O launcher inicia o worker local, que consulta o relógio do computador, cria lotes agendados na fila e evita duplicar o mesmo canal no mesmo dia.
|
|
397
403
|
|
|
398
404
|
A área **Teste de vozes**, dentro de **Configurações > Configurações Técnicas**, é 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.
|
|
399
405
|
|
package/README.md
CHANGED
|
@@ -16,8 +16,8 @@ A primeira versão implementa a camada UI independente com:
|
|
|
16
16
|
| Brandings | Subaba própria dentro de Blueprints, upload/listagem de Brandings e criação conjunta com Blueprint |
|
|
17
17
|
| Canais | Subabas de importação pública sem API Key, Data API opcional e cadastro manual independente |
|
|
18
18
|
| Criação de Vídeos / Criação de Músicas | Subabas Criar vídeo e Vídeos; lotes; 51 rótulos de idioma; Pexels/Pixabay, full IA com Estilo IA e Apenas Música com agente musical; a segunda página reutiliza o mesmo fluxo com título próprio |
|
|
19
|
-
| Automação | Lista de vídeos e canais, selectores editáveis de Blueprint/voz padrão, Automação ON
|
|
20
|
-
| Niche Finder |
|
|
19
|
+
| Automação | Lista de vídeos e canais, selectores editáveis de Blueprint/voz padrão, Automação ON, horário diário HH:MM e worker local baseado no relógio do computador |
|
|
20
|
+
| Niche Finder | Parâmetros dentro da própria aba, início manual após clique, K-Means, FP-Growth, regras de associação, clusters e gráfico Plotly nativo |
|
|
21
21
|
| Upload | YouTube via `youtube-automation-agent` adaptado internamente, OAuth directo de redundância, Upload directo experimental, TikTok, Instagram e Facebook Pages no front end |
|
|
22
22
|
| MCP | Catálogo local opcional de Short Video Maker, AutoVio, OpenMontage e OpenCut, com portas editáveis e activação |
|
|
23
23
|
| Limpador de Metadados | Upload isolado de vídeos terceiros, limpeza FFmpeg, edição de título/descrição/tags e manifesto JSON |
|
|
@@ -26,7 +26,7 @@ A primeira versão implementa a camada UI independente com:
|
|
|
26
26
|
|
|
27
27
|
Os adaptadores do MoneyPrinterTurbo e de publicação nas plataformas são ligados pelas configurações locais e pelos pontos de integração em `integrations/`. A UI não inventa dados quando um serviço externo ou credencial não está disponível.
|
|
28
28
|
|
|
29
|
-
## Navegação da UI 0.2.
|
|
29
|
+
## Navegação da UI 0.2.34
|
|
30
30
|
|
|
31
31
|
A barra lateral mantém apenas os cinco níveis principais, nesta ordem: **Início**, **Pipeline**, **Automação**, **Niche Finder** e **Configurações**. **Pipeline** é expansível e contém **Criação de Vídeos**, **Criação de Músicas**, **Upload** e **Limpador de Metadados**. **Configurações** é expansível e contém **Canais**, **Blueprints**, **MCP** e **Configurações Técnicas**. O Início reúne o dashboard e as filas do Pipeline, sem botões de acções rápidas.
|
|
32
32
|
|
|
@@ -63,6 +63,12 @@ node scripts/cli.mjs --check
|
|
|
63
63
|
node scripts/cli.mjs
|
|
64
64
|
```
|
|
65
65
|
|
|
66
|
+
O comando normal inicia a UI e o worker local de Automação. O worker verifica o relógio local do computador e coloca na fila os lotes de canais com **Automação ON** quando o horário `HH:MM` coincide. Para executar apenas o worker, sem abrir a UI:
|
|
67
|
+
|
|
68
|
+
```bash
|
|
69
|
+
node scripts/cli.mjs worker
|
|
70
|
+
```
|
|
71
|
+
|
|
66
72
|
O pacote está publicado no npm como `@danhachuel/thunderbolt` e pode ser executado directamente via `npx`:
|
|
67
73
|
|
|
68
74
|
```bash
|
|
@@ -109,9 +115,9 @@ thunderbolt
|
|
|
109
115
|
No Windows PowerShell, se `npx` for bloqueado por `npx.ps1`, use directamente `npx.cmd`:
|
|
110
116
|
|
|
111
117
|
```powershell
|
|
112
|
-
npx.cmd --yes @danhachuel/thunderbolt@0.2.
|
|
113
|
-
npx.cmd --yes @danhachuel/thunderbolt@0.2.
|
|
114
|
-
npx.cmd --yes @danhachuel/thunderbolt@0.2.
|
|
118
|
+
npx.cmd --yes @danhachuel/thunderbolt@0.2.34 install
|
|
119
|
+
npx.cmd --yes @danhachuel/thunderbolt@0.2.34 doctor
|
|
120
|
+
npx.cmd --yes @danhachuel/thunderbolt@0.2.34
|
|
115
121
|
```
|
|
116
122
|
|
|
117
123
|
Como alternativa, pode permitir scripts para o seu utilizador:
|
|
@@ -131,11 +137,11 @@ Após `install`, o launcher usa o ambiente virtual instalado em `~/.thunderbolt/
|
|
|
131
137
|
|
|
132
138
|
## Niche Finder
|
|
133
139
|
|
|
134
|
-
A página **Niche Finder** integra a lógica do projecto open source [johanfortus/Niche-Finder](https://github.com/johanfortus/Niche-Finder) directamente no processo Streamlit. O Thunderbolt não inicia Flask, não abre uma porta adicional e não copia templates HTML ou D3. Ao abrir a página, o
|
|
140
|
+
A página **Niche Finder** integra a lógica do projecto open source [johanfortus/Niche-Finder](https://github.com/johanfortus/Niche-Finder) directamente no processo Streamlit. O Thunderbolt não inicia Flask, não abre uma porta adicional e não copia templates HTML ou D3. Ao abrir a página, o Thunderbolt não descarrega dados, não prepara o dataset e não executa análises. A instalação das dependências continua automática, mas a operação é manual: o utilizador define os parâmetros dentro da própria aba e clica em **Analisar Nichos**. Só nesse momento o KaggleHub prepara ou reutiliza a cache local do dataset e a análise é executada.
|
|
135
141
|
|
|
136
|
-
A interface apresenta
|
|
142
|
+
A interface apresenta dentro da aba os parâmetros da busca: número de clusters entre 2 e 10, suporte mínimo entre 0,01 e 0,50, país, categoria de engagement, intervalo de datas e tags. A página permanece sem resultados até o primeiro clique em **Analisar Nichos**; depois, se os parâmetros forem alterados, mostra os resultados anteriores e pede novo clique para aplicar os filtros actuais. O núcleo aplica normalização, filtros, `log1p`, `StandardScaler`, K-Means e FP-Growth. Os resultados aparecem em DataFrames para clusters, itemsets frequentes, regras de associação e dados analisados, acompanhados por uma visualização Plotly nativa e pesquisa de palavras nos clusters.
|
|
137
143
|
|
|
138
|
-
As dependências adicionais são instaladas pelo fluxo normal do pacote: `scikit-learn`, `mlxtend`, `plotly`, `seaborn`, `matplotlib` e `kagglehub`. Em instalações existentes, execute novamente `npx.cmd --yes @danhachuel/thunderbolt@0.2.
|
|
144
|
+
As dependências adicionais são instaladas pelo fluxo normal do pacote: `scikit-learn`, `mlxtend`, `plotly`, `seaborn`, `matplotlib` e `kagglehub`. Em instalações existentes, execute novamente `npx.cmd --yes @danhachuel/thunderbolt@0.2.34 install`; o instalador detecta e reutiliza componentes já válidos.
|
|
139
145
|
|
|
140
146
|
## Armazenamento local
|
|
141
147
|
|
package/app/main.py
CHANGED
|
@@ -2,7 +2,7 @@ from __future__ import annotations
|
|
|
2
2
|
|
|
3
3
|
import json
|
|
4
4
|
import re
|
|
5
|
-
from datetime import date
|
|
5
|
+
from datetime import date, datetime
|
|
6
6
|
import sys
|
|
7
7
|
from pathlib import Path
|
|
8
8
|
|
|
@@ -18,9 +18,10 @@ except (OSError, json.JSONDecodeError):
|
|
|
18
18
|
APP_VERSION = ""
|
|
19
19
|
|
|
20
20
|
from hermes_ui.domain import STAGES, create_batch, create_channel, create_tasks_for_batch, delete_channel, pipeline_summary, set_channel_defaults, transition_task, update_channel
|
|
21
|
+
from hermes_ui.automation_worker import load_worker_status
|
|
21
22
|
from hermes_ui.storage import BLUEPRINTS, ensure_storage, list_blueprint_files, load_blueprint_file, now, read_json, write_json
|
|
22
23
|
from app.modules.niche_finder.core import NicheAnalysisError, run_niche_analysis
|
|
23
|
-
from app.modules.niche_finder.data_loader import DatasetError, download_kaggle_dataset
|
|
24
|
+
from app.modules.niche_finder.data_loader import DatasetError, download_kaggle_dataset
|
|
24
25
|
from hermes_ui.blueprints import create_blueprint_from_link, list_branding_files, save_generated_blueprint
|
|
25
26
|
from hermes_ui.metadata_cleaner import build_description, clean_video_metadata, list_edit_records, metadata_manifest, normalize_tags, save_edit_record, store_external_video
|
|
26
27
|
from hermes_ui.mcp import detect_local_service, install_skill_locally, load_integrations, load_server_config, read_packaged_skill, save_server_config, update_integration
|
|
@@ -629,79 +630,56 @@ def render_music_creation():
|
|
|
629
630
|
render_new_video(page_title="Criação de Músicas")
|
|
630
631
|
|
|
631
632
|
|
|
632
|
-
@st.cache_data(show_spinner=False)
|
|
633
|
-
def _cached_niche_dataset(path_str: str, modified_ns: int):
|
|
634
|
-
return load_dataframe(path_str)
|
|
635
|
-
|
|
636
|
-
|
|
637
633
|
@st.cache_data(show_spinner=False)
|
|
638
634
|
def _cached_niche_download():
|
|
635
|
+
"""Download or reuse the dataset only after the user submits the form."""
|
|
639
636
|
return str(download_kaggle_dataset())
|
|
640
637
|
|
|
641
638
|
|
|
642
|
-
def _load_automatic_niche_dataset():
|
|
643
|
-
dataset_path = _cached_niche_download()
|
|
644
|
-
path = Path(dataset_path)
|
|
645
|
-
return dataset_path, _cached_niche_dataset(dataset_path, path.stat().st_mtime_ns)
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
def _niche_tag_options(frame) -> list[str]:
|
|
649
|
-
tags: set[str] = set()
|
|
650
|
-
for value in frame.get("video_tags", []):
|
|
651
|
-
text = str(value or "").replace('"', "").replace("'", "")
|
|
652
|
-
for tag in re.split(r"[|,]", text.strip("[]")):
|
|
653
|
-
tag = re.sub(r"\s+", " ", tag).strip().lower()
|
|
654
|
-
if tag and tag not in {"nan", "none", "null"}:
|
|
655
|
-
tags.add(tag)
|
|
656
|
-
return sorted(tags)
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
def _niche_date_bounds(frame):
|
|
660
|
-
dates = frame["publish_date"].dropna().astype(str)
|
|
661
|
-
if dates.empty:
|
|
662
|
-
return None, None
|
|
663
|
-
return dates.min(), dates.max()
|
|
664
|
-
|
|
665
|
-
|
|
666
639
|
def render_niche_finder():
|
|
667
640
|
st.title("Niche Finder")
|
|
668
|
-
st.caption("Busca
|
|
669
|
-
st.info("
|
|
670
|
-
|
|
671
|
-
try:
|
|
672
|
-
with st.spinner("A preparar automaticamente o Niche Finder…"):
|
|
673
|
-
dataset_path, frame = _load_automatic_niche_dataset()
|
|
674
|
-
except (DatasetError, OSError) as exc:
|
|
675
|
-
st.error("Não foi possível preparar automaticamente o Niche Finder neste momento. Verifique a ligação à Internet e tente novamente.")
|
|
676
|
-
with st.expander("Detalhes técnicos"):
|
|
677
|
-
st.caption(str(exc))
|
|
678
|
-
return
|
|
679
|
-
|
|
680
|
-
signature = f"{dataset_path}:{Path(dataset_path).stat().st_mtime_ns}"
|
|
681
|
-
if st.session_state.get("niche_results_signature") != signature:
|
|
682
|
-
st.session_state.pop("niche_results", None)
|
|
683
|
-
st.session_state["niche_results_signature"] = signature
|
|
641
|
+
st.caption("Busca de padrões, nichos e tags para orientar canais faceless.")
|
|
642
|
+
st.info("A instalação das dependências é automática. A operação não é: defina os parâmetros abaixo e clique em **Analisar Nichos** para iniciar.")
|
|
684
643
|
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
644
|
+
default_start = date(2023, 9, 20)
|
|
645
|
+
default_end = date.today()
|
|
646
|
+
country_options = [
|
|
647
|
+
"Todos", "US", "BR", "GB", "CA", "AU", "DE", "FR", "ES", "IT", "JP", "KR", "IN", "MX", "AR", "PT",
|
|
648
|
+
]
|
|
649
|
+
with st.container(border=True):
|
|
689
650
|
st.subheader("Parâmetros da busca")
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
651
|
+
st.caption("Estes controlos pertencem a esta aba. Nenhum dataset é descarregado e nenhuma análise é executada enquanto não clicar no botão.")
|
|
652
|
+
with st.form("niche_finder_parameters", clear_on_submit=False):
|
|
653
|
+
parameter_cols = st.columns(3)
|
|
654
|
+
with parameter_cols[0]:
|
|
655
|
+
n_clusters = st.slider("Número de Clusters", 2, 10, 5, key="niche_n_clusters")
|
|
656
|
+
min_support = st.slider("Suporte Mínimo", 0.01, 0.5, 0.05, 0.01, format="%.2f", key="niche_min_support")
|
|
657
|
+
with parameter_cols[1]:
|
|
658
|
+
country = st.selectbox("País", country_options, key="niche_country")
|
|
659
|
+
engagement = st.selectbox("Engagement", ["Todos", "High", "Moderate", "Low"], key="niche_engagement")
|
|
660
|
+
with parameter_cols[2]:
|
|
661
|
+
start_date = st.date_input("Data inicial", value=default_start, min_value=date(2020, 1, 1), max_value=default_end, key="niche_start_date")
|
|
662
|
+
end_date = st.date_input("Data final", value=default_end, min_value=date(2020, 1, 1), max_value=default_end, key="niche_end_date")
|
|
663
|
+
tags_text = st.text_input("Tags opcionais", key="niche_tags_text", placeholder="Ex.: history, facts, documentary")
|
|
664
|
+
analyse = st.form_submit_button("Analisar Nichos", type="primary", use_container_width=True)
|
|
665
|
+
|
|
666
|
+
current_parameters = {
|
|
667
|
+
"n_clusters": n_clusters,
|
|
668
|
+
"min_support": min_support,
|
|
669
|
+
"country": country,
|
|
670
|
+
"engagement": engagement,
|
|
671
|
+
"start_date": start_date.isoformat() if start_date else None,
|
|
672
|
+
"end_date": end_date.isoformat() if end_date else None,
|
|
673
|
+
"tags": [tag.strip() for tag in re.split(r"[,|]", tags_text) if tag.strip()],
|
|
674
|
+
}
|
|
699
675
|
results = st.session_state.get("niche_results")
|
|
700
|
-
if
|
|
676
|
+
if analyse:
|
|
701
677
|
try:
|
|
702
678
|
if start_date and end_date and start_date > end_date:
|
|
703
679
|
raise NicheAnalysisError("A data inicial não pode ser posterior à data final.")
|
|
704
|
-
with st.spinner("A
|
|
680
|
+
with st.spinner("A preparar os dados apenas porque solicitou a análise…"):
|
|
681
|
+
dataset_path = _cached_niche_download()
|
|
682
|
+
with st.spinner("A executar a análise dos nichos…"):
|
|
705
683
|
results = run_niche_analysis(
|
|
706
684
|
str(dataset_path),
|
|
707
685
|
n_clusters=n_clusters,
|
|
@@ -710,15 +688,23 @@ def render_niche_finder():
|
|
|
710
688
|
end_date=end_date.isoformat() if end_date else None,
|
|
711
689
|
country=country,
|
|
712
690
|
engagement=engagement,
|
|
713
|
-
tags=
|
|
691
|
+
tags=current_parameters["tags"],
|
|
714
692
|
)
|
|
715
693
|
st.session_state["niche_results"] = results
|
|
694
|
+
st.session_state["niche_last_parameters"] = current_parameters
|
|
695
|
+
st.success("Análise concluída.")
|
|
716
696
|
except (NicheAnalysisError, DatasetError, OSError) as exc:
|
|
717
|
-
st.error("Não foi possível concluir a análise
|
|
697
|
+
st.error("Não foi possível concluir a análise solicitada com os parâmetros actuais.")
|
|
718
698
|
with st.expander("Detalhes técnicos"):
|
|
719
699
|
st.caption(str(exc))
|
|
720
700
|
return
|
|
721
701
|
|
|
702
|
+
if results is None:
|
|
703
|
+
st.caption("Ainda não existe uma análise nesta sessão. Ajuste os parâmetros e clique em **Analisar Nichos**.")
|
|
704
|
+
return
|
|
705
|
+
if st.session_state.get("niche_last_parameters") != current_parameters:
|
|
706
|
+
st.warning("Os resultados apresentados pertencem à última análise executada. Clique em **Analisar Nichos** para aplicar os parâmetros actuais.")
|
|
707
|
+
|
|
722
708
|
summary = results.get("summary", {})
|
|
723
709
|
metric_cols = st.columns(4)
|
|
724
710
|
for col, (label, value) in zip(metric_cols, [("Registos analisados", summary.get("rows_filtered", 0)), ("Clusters", summary.get("cluster_count", 0)), ("Itemsets frequentes", summary.get("frequent_item_count", 0)), ("Regras de associação", summary.get("association_rule_count", 0))]):
|
|
@@ -788,8 +774,18 @@ def render_videos():
|
|
|
788
774
|
|
|
789
775
|
def render_automation():
|
|
790
776
|
st.title("Automação")
|
|
791
|
-
st.caption("Agendamento diário da geração por canal.
|
|
792
|
-
|
|
777
|
+
st.caption("Agendamento diário da geração por canal. O worker verifica o relógio local do computador e coloca os lotes agendados na fila.")
|
|
778
|
+
worker_status = load_worker_status()
|
|
779
|
+
local_now = datetime.now().astimezone()
|
|
780
|
+
if worker_status.get("alive"):
|
|
781
|
+
st.success(f"Worker activo · relógio local: {local_now.strftime('%d/%m/%Y %H:%M:%S %Z')}")
|
|
782
|
+
else:
|
|
783
|
+
st.warning("Worker de automação não está activo. Inicie o Thunderbolt pelo launcher (`npx.cmd --yes @danhachuel/thunderbolt`) para activar as verificações horárias.")
|
|
784
|
+
last_tick = worker_status.get("last_tick_local")
|
|
785
|
+
if last_tick:
|
|
786
|
+
st.caption(f"Última verificação do worker: {last_tick}")
|
|
787
|
+
if worker_status.get("last_error"):
|
|
788
|
+
st.error(f"Último erro do worker: {worker_status['last_error']}")
|
|
793
789
|
channels = read_json("channels.json", [])
|
|
794
790
|
if not channels:
|
|
795
791
|
st.info("Nenhum canal cadastrado para configurar.")
|
|
@@ -103,7 +103,7 @@ def _download_with_kagglehub() -> Path | None:
|
|
|
103
103
|
|
|
104
104
|
|
|
105
105
|
def download_kaggle_dataset() -> Path:
|
|
106
|
-
"""Prepare and cache the public Niche-Finder dataset
|
|
106
|
+
"""Prepare and cache the public Niche-Finder dataset after an explicit user action."""
|
|
107
107
|
ensure_data_dir()
|
|
108
108
|
if _has_usable_cached_dataset():
|
|
109
109
|
return DEFAULT_DATASET_PATH
|
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import os
|
|
5
|
+
import time
|
|
6
|
+
from datetime import datetime, tzinfo
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from . import storage
|
|
11
|
+
from .domain import create_batch, create_tasks_for_batch
|
|
12
|
+
|
|
13
|
+
WORKER_STATE_FILE = "automation_worker.json"
|
|
14
|
+
LOCK_FILENAME = "automation_worker.lock"
|
|
15
|
+
DEFAULT_INTERVAL_SECONDS = 10
|
|
16
|
+
DEFAULT_STATE: dict[str, Any] = {
|
|
17
|
+
"worker_started_at": None,
|
|
18
|
+
"worker_pid": None,
|
|
19
|
+
"last_heartbeat_local": None,
|
|
20
|
+
"last_tick_local": None,
|
|
21
|
+
"last_error": "",
|
|
22
|
+
"last_runs": {},
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _local_now() -> datetime:
|
|
27
|
+
"""Return the local wall-clock time of the computer running Thunderbolt."""
|
|
28
|
+
return datetime.now().astimezone()
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _local_iso(value: datetime) -> str:
|
|
32
|
+
return value.astimezone().isoformat(timespec="seconds")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _state() -> dict[str, Any]:
|
|
36
|
+
saved = storage.read_json(WORKER_STATE_FILE, DEFAULT_STATE)
|
|
37
|
+
if not isinstance(saved, dict):
|
|
38
|
+
saved = {}
|
|
39
|
+
merged = {**DEFAULT_STATE, **saved}
|
|
40
|
+
if not isinstance(merged.get("last_runs"), dict):
|
|
41
|
+
merged["last_runs"] = {}
|
|
42
|
+
return merged
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def load_worker_status() -> dict[str, Any]:
|
|
46
|
+
"""Read status displayed by the Automação page without starting a worker."""
|
|
47
|
+
status = _state()
|
|
48
|
+
heartbeat = str(status.get("last_heartbeat_local") or "")
|
|
49
|
+
status["alive"] = False
|
|
50
|
+
if heartbeat:
|
|
51
|
+
try:
|
|
52
|
+
heartbeat_at = datetime.fromisoformat(heartbeat)
|
|
53
|
+
status["alive"] = (datetime.now().astimezone() - heartbeat_at.astimezone()).total_seconds() <= 30
|
|
54
|
+
except ValueError:
|
|
55
|
+
status["alive"] = False
|
|
56
|
+
return status
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _write_status(status: dict[str, Any]) -> None:
|
|
60
|
+
storage.write_json(WORKER_STATE_FILE, {**DEFAULT_STATE, **status})
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _pid_alive(pid: int) -> bool:
|
|
64
|
+
if pid <= 0:
|
|
65
|
+
return False
|
|
66
|
+
try:
|
|
67
|
+
os.kill(pid, 0)
|
|
68
|
+
except ProcessLookupError:
|
|
69
|
+
return False
|
|
70
|
+
except PermissionError:
|
|
71
|
+
return True
|
|
72
|
+
except OSError:
|
|
73
|
+
return False
|
|
74
|
+
return True
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _acquire_lock() -> Path | None:
|
|
78
|
+
lock_path = storage.STORAGE / LOCK_FILENAME
|
|
79
|
+
lock_path.parent.mkdir(parents=True, exist_ok=True)
|
|
80
|
+
try:
|
|
81
|
+
descriptor = os.open(lock_path, os.O_CREAT | os.O_EXCL | os.O_WRONLY)
|
|
82
|
+
except FileExistsError:
|
|
83
|
+
try:
|
|
84
|
+
old_pid = int(lock_path.read_text(encoding="utf-8").strip())
|
|
85
|
+
except (OSError, ValueError):
|
|
86
|
+
old_pid = 0
|
|
87
|
+
if _pid_alive(old_pid):
|
|
88
|
+
return None
|
|
89
|
+
try:
|
|
90
|
+
lock_path.unlink()
|
|
91
|
+
except OSError:
|
|
92
|
+
return None
|
|
93
|
+
try:
|
|
94
|
+
descriptor = os.open(lock_path, os.O_CREAT | os.O_EXCL | os.O_WRONLY)
|
|
95
|
+
except FileExistsError:
|
|
96
|
+
return None
|
|
97
|
+
try:
|
|
98
|
+
os.write(descriptor, str(os.getpid()).encode("ascii"))
|
|
99
|
+
finally:
|
|
100
|
+
os.close(descriptor)
|
|
101
|
+
return lock_path
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _release_lock(lock_path: Path | None) -> None:
|
|
105
|
+
if lock_path is not None:
|
|
106
|
+
try:
|
|
107
|
+
lock_path.unlink()
|
|
108
|
+
except OSError:
|
|
109
|
+
pass
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _valid_schedule(value: Any) -> bool:
|
|
113
|
+
text = str(value or "").strip()
|
|
114
|
+
if len(text) != 5 or text[2] != ":":
|
|
115
|
+
return False
|
|
116
|
+
try:
|
|
117
|
+
hour, minute = (int(part) for part in text.split(":"))
|
|
118
|
+
except ValueError:
|
|
119
|
+
return False
|
|
120
|
+
return 0 <= hour <= 23 and 0 <= minute <= 59
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _daily_quantity(channel: dict[str, Any]) -> int:
|
|
124
|
+
try:
|
|
125
|
+
return max(1, min(100, int(channel.get("daily_limit", 1))))
|
|
126
|
+
except (TypeError, ValueError):
|
|
127
|
+
return 1
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _automation_topic(channel: dict[str, Any]) -> str:
|
|
131
|
+
topic = str(channel.get("automation_topic") or "").strip()
|
|
132
|
+
return topic or f"Geração automática — {channel.get('name') or 'Canal'}"
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _batch_for_day(channel_id: str, day: str) -> dict[str, Any] | None:
|
|
136
|
+
batches = storage.read_json("batches.json", [])
|
|
137
|
+
if not isinstance(batches, list):
|
|
138
|
+
return None
|
|
139
|
+
for batch in reversed(batches):
|
|
140
|
+
if not isinstance(batch, dict) or channel_id not in (batch.get("channel_ids") or []):
|
|
141
|
+
continue
|
|
142
|
+
options = batch.get("options") or {}
|
|
143
|
+
if options.get("automation_worker") is True and options.get("automation_date") == day:
|
|
144
|
+
return batch
|
|
145
|
+
return None
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _tasks_for_batch(batch_id: str) -> list[dict[str, Any]]:
|
|
149
|
+
tasks = storage.read_json("tasks.json", [])
|
|
150
|
+
return [task for task in tasks if isinstance(task, dict) and task.get("batch_id") == batch_id]
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _create_channel_batch(channel: dict[str, Any], when: datetime) -> dict[str, Any]:
|
|
154
|
+
channel_id = str(channel["id"])
|
|
155
|
+
date_key = when.date().isoformat()
|
|
156
|
+
style_wide = str(channel.get("style_wide") or "pexels")
|
|
157
|
+
music_mode = style_wide == "music"
|
|
158
|
+
options = {
|
|
159
|
+
"language": channel.get("language") or "Português",
|
|
160
|
+
"format": "wide",
|
|
161
|
+
"style_wide": style_wide,
|
|
162
|
+
"style_ia": channel.get("style_ia") or "",
|
|
163
|
+
"music_mode": music_mode,
|
|
164
|
+
"background_mode": "none" if music_mode else ("ai" if style_wide == "full_ia" else "stock"),
|
|
165
|
+
"music_path": channel.get("music_path") or "",
|
|
166
|
+
"music_source": channel.get("music_source") or "",
|
|
167
|
+
"automation_worker": True,
|
|
168
|
+
"automation_date": date_key,
|
|
169
|
+
"automation_scheduled_at": _local_iso(when),
|
|
170
|
+
}
|
|
171
|
+
batch = create_batch(
|
|
172
|
+
"single",
|
|
173
|
+
[channel_id],
|
|
174
|
+
_automation_topic(channel),
|
|
175
|
+
_daily_quantity(channel),
|
|
176
|
+
options,
|
|
177
|
+
)
|
|
178
|
+
tasks = create_tasks_for_batch(batch)
|
|
179
|
+
return {"batch": batch, "tasks": tasks, "channel_id": channel_id}
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def run_once(when: datetime | None = None) -> dict[str, Any]:
|
|
183
|
+
"""Run one deterministic local-time tick; safe to call from tests or a worker loop."""
|
|
184
|
+
current = when.astimezone() if when is not None and when.tzinfo else (when or _local_now())
|
|
185
|
+
current_minute = current.strftime("%H:%M")
|
|
186
|
+
day = current.date().isoformat()
|
|
187
|
+
status = _state()
|
|
188
|
+
status["last_tick_local"] = _local_iso(current)
|
|
189
|
+
status["last_heartbeat_local"] = _local_iso(current)
|
|
190
|
+
status["last_error"] = ""
|
|
191
|
+
created: list[dict[str, Any]] = []
|
|
192
|
+
skipped: list[str] = []
|
|
193
|
+
channels = storage.read_json("channels.json", [])
|
|
194
|
+
if not isinstance(channels, list):
|
|
195
|
+
channels = []
|
|
196
|
+
try:
|
|
197
|
+
for channel in channels:
|
|
198
|
+
if not isinstance(channel, dict):
|
|
199
|
+
continue
|
|
200
|
+
channel_id = str(channel.get("id") or "")
|
|
201
|
+
if not channel_id or not bool(channel.get("active", True)) or not bool(channel.get("automation_on", False)):
|
|
202
|
+
continue
|
|
203
|
+
if not _valid_schedule(channel.get("automation_time")) or str(channel.get("automation_time")).strip() != current_minute:
|
|
204
|
+
continue
|
|
205
|
+
existing = _batch_for_day(channel_id, day)
|
|
206
|
+
if existing:
|
|
207
|
+
existing_tasks = _tasks_for_batch(str(existing.get("id") or ""))
|
|
208
|
+
if not existing_tasks:
|
|
209
|
+
existing_tasks = create_tasks_for_batch(existing)
|
|
210
|
+
skipped.append(channel_id)
|
|
211
|
+
continue
|
|
212
|
+
created.append(_create_channel_batch(channel, current))
|
|
213
|
+
for item in created:
|
|
214
|
+
batch = item["batch"]
|
|
215
|
+
status["last_runs"][item["channel_id"]] = {
|
|
216
|
+
"date": day,
|
|
217
|
+
"time": current_minute,
|
|
218
|
+
"batch_id": batch["id"],
|
|
219
|
+
"task_ids": [task["id"] for task in item["tasks"]],
|
|
220
|
+
}
|
|
221
|
+
_write_status(status)
|
|
222
|
+
return {
|
|
223
|
+
"ok": True,
|
|
224
|
+
"local_time": _local_iso(current),
|
|
225
|
+
"scheduled_time": current_minute,
|
|
226
|
+
"created": created,
|
|
227
|
+
"skipped": skipped,
|
|
228
|
+
}
|
|
229
|
+
except Exception as exc:
|
|
230
|
+
status["last_error"] = str(exc)
|
|
231
|
+
_write_status(status)
|
|
232
|
+
return {"ok": False, "local_time": _local_iso(current), "error": str(exc), "created": created}
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def run_worker(interval_seconds: int = DEFAULT_INTERVAL_SECONDS) -> None:
|
|
236
|
+
storage.ensure_storage()
|
|
237
|
+
lock_path = _acquire_lock()
|
|
238
|
+
if lock_path is None:
|
|
239
|
+
raise RuntimeError("Já existe um worker de automação activo para este storage do Thunderbolt.")
|
|
240
|
+
started = _local_now()
|
|
241
|
+
status = _state()
|
|
242
|
+
status.update({"worker_started_at": _local_iso(started), "worker_pid": os.getpid(), "last_error": ""})
|
|
243
|
+
_write_status(status)
|
|
244
|
+
try:
|
|
245
|
+
while True:
|
|
246
|
+
result = run_once()
|
|
247
|
+
if not result.get("ok"):
|
|
248
|
+
print(f"Thunderbolt worker: erro: {result.get('error')}", flush=True)
|
|
249
|
+
elif result.get("created"):
|
|
250
|
+
print(f"Thunderbolt worker: {len(result['created'])} canal(is) agendado(s) às {result['scheduled_time']}.", flush=True)
|
|
251
|
+
time.sleep(max(2, int(interval_seconds)))
|
|
252
|
+
finally:
|
|
253
|
+
current = _state()
|
|
254
|
+
current["worker_pid"] = None
|
|
255
|
+
current["last_heartbeat_local"] = None
|
|
256
|
+
_write_status(current)
|
|
257
|
+
_release_lock(lock_path)
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def main() -> None:
|
|
261
|
+
parser = argparse.ArgumentParser(description="Worker local de automação diária do Thunderbolt")
|
|
262
|
+
parser.add_argument("--once", action="store_true", help="Executa apenas um tick usando o relógio local")
|
|
263
|
+
parser.add_argument("--interval", type=int, default=DEFAULT_INTERVAL_SECONDS, help="Intervalo de verificação em segundos")
|
|
264
|
+
args = parser.parse_args()
|
|
265
|
+
if args.once:
|
|
266
|
+
storage.ensure_storage()
|
|
267
|
+
print(run_once(), flush=True)
|
|
268
|
+
return
|
|
269
|
+
run_worker(args.interval)
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
if __name__ == "__main__":
|
|
273
|
+
main()
|
package/package.json
CHANGED
package/scripts/cli.mjs
CHANGED
|
@@ -133,10 +133,39 @@ if (!existsSync(main)) {
|
|
|
133
133
|
process.exit(1);
|
|
134
134
|
}
|
|
135
135
|
ensureRuntimeStorage();
|
|
136
|
+
const storageDir = process.env.THUNDERBOLT_STORAGE_DIR || join(thunderboltHome, "storage");
|
|
137
|
+
const runtimeEnv = {
|
|
138
|
+
...process.env,
|
|
139
|
+
THUNDERBOLT_STORAGE_DIR: storageDir,
|
|
140
|
+
STREAMLIT_BROWSER_GATHER_USAGE_STATS: "false",
|
|
141
|
+
STREAMLIT_SERVER_HEADLESS: "true",
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
if (args[0] === "worker" || args.includes("--worker")) {
|
|
145
|
+
run(python, ["-m", "hermes_ui.automation_worker", ...args.filter((arg) => arg !== "worker" && arg !== "--worker")], "worker de automação");
|
|
146
|
+
}
|
|
147
|
+
|
|
136
148
|
const port = process.env.THUNDERBOLT_PORT || process.env.HERMES_PORT || "3030";
|
|
149
|
+
const worker = spawn(python, ["-m", "hermes_ui.automation_worker"], {
|
|
150
|
+
cwd: root,
|
|
151
|
+
stdio: "inherit",
|
|
152
|
+
env: runtimeEnv,
|
|
153
|
+
windowsHide: false,
|
|
154
|
+
});
|
|
137
155
|
const child = spawn(python, ["-m", "streamlit", "run", main, "--server.port", port, "--server.address", "localhost"], {
|
|
138
156
|
cwd: root,
|
|
139
157
|
stdio: "inherit",
|
|
140
|
-
env:
|
|
158
|
+
env: runtimeEnv,
|
|
159
|
+
windowsHide: false,
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
const stopWorker = () => {
|
|
163
|
+
if (!worker.killed) worker.kill();
|
|
164
|
+
};
|
|
165
|
+
process.on("SIGINT", stopWorker);
|
|
166
|
+
process.on("SIGTERM", stopWorker);
|
|
167
|
+
child.on("exit", (code, signal) => {
|
|
168
|
+
stopWorker();
|
|
169
|
+
process.exit(code ?? (signal ? 1 : 0));
|
|
141
170
|
});
|
|
142
|
-
|
|
171
|
+
worker.on("error", (error) => console.error(`Thunderbolt worker: ${error.message}`));
|