@danhachuel/thunderbolt 0.2.26 → 0.2.28
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 +23 -20
- package/README.md +27 -18
- package/app/main.py +219 -111
- package/hermes_ui/mcp.py +37 -0
- package/hermes_ui/mcp_server.py +400 -0
- package/hermes_ui/storage.py +7 -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.2.
|
|
5
|
+
> **Versão deste manual:** 0.2.28
|
|
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.28 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.28 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.
|
|
@@ -339,7 +339,7 @@ Set-ExecutionPolicy -Scope CurrentUser RemoteSigned
|
|
|
339
339
|
|
|
340
340
|
## 8. Configuração inicial da UI
|
|
341
341
|
|
|
342
|
-
Na primeira execução,
|
|
342
|
+
Na primeira execução, a barra lateral apresenta **Início**, **Pipeline**, **Automação**, **Niche Finder** e **Configurações**. Abra **Configurações > Configurações Técnicas** e reveja. O menu **Pipeline** contém **Criação de Vídeos**, **Criação de Músicas**, **Upload** e **Limpador de Metadados**. O menu **Configurações** contém **Canais**, **Blueprints**, **MCP** e **Configurações Técnicas**. Para YouTube, preencha primeiro o par **OAuth Client ID + OAuth Client Secret** se pretende autorizar uploads. A **YouTube Data API Key** é uma credencial Google Cloud diferente e fica numa área opcional, apenas para o método oficial de métricas; Client ID + Client Secret não formam uma API Key.
|
|
343
343
|
|
|
344
344
|
| Configuração | Finalidade |
|
|
345
345
|
|---|---|
|
|
@@ -358,29 +358,30 @@ As credenciais devem ser inseridas apenas na configuração local. A Data API Ke
|
|
|
358
358
|
|
|
359
359
|
Após iniciar a aplicação, valide o seguinte percurso:
|
|
360
360
|
|
|
361
|
-
1. **
|
|
361
|
+
1. **Início:** confirme que a UI abre, mostra o estado local, as métricas e os cards das filas do Pipeline, sem botões de acções rápidas.
|
|
362
362
|
2. **Blueprints:** coloque um JSON em `~/.thunderbolt/storage/blueprints/importados/` (Windows: `%LOCALAPPDATA%\\THUNDERBOLT\storage\blueprints\importados\`) ou use o carregador da interface.
|
|
363
363
|
3. **Brandings:** abra a subaba **Brandings** e confirme a listagem dos ficheiros JSON.
|
|
364
364
|
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.
|
|
365
|
-
5. **
|
|
366
|
-
6. **
|
|
365
|
+
5. **Pipeline > Criação de Vídeos:** teste primeiro o modo **Canal específico** e depois os modos de lote.
|
|
366
|
+
6. **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.
|
|
367
367
|
7. **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.
|
|
368
|
-
8. **Limpador de
|
|
369
|
-
9. **Configurações:** confirme que os caminhos e credenciais estão locais e não aparecem no Git.
|
|
370
|
-
10. **
|
|
368
|
+
8. **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.
|
|
369
|
+
9. **Configurações > Configurações Técnicas:** confirme que os caminhos e credenciais estão locais e não aparecem no Git.
|
|
370
|
+
10. **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`.
|
|
371
371
|
11. **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. A UI não inicia jobs em segundo plano nesta entrega.
|
|
372
|
-
12. **Teste de vozes:**
|
|
372
|
+
12. **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.
|
|
373
373
|
13. **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.
|
|
374
|
-
14. **MCP:** confirme que Short Video Maker, AutoVio, OpenMontage e OpenCut aparecem com as portas padrão editáveis. O estado **Activo** é
|
|
375
|
-
15. **
|
|
374
|
+
14. **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.
|
|
375
|
+
15. **Configurações > MCP > Servidor MCP:** abra a subaba, mantenha o host `127.0.0.1`, active **Servidor MCP ON** e clique em **Guardar e iniciar Servidor MCP**. Confirme o endpoint `/mcp` e o health endpoint `/health`. Mantenha **Permitir ferramentas de escrita** desactivado até precisar que um agente crie lotes.
|
|
376
|
+
16. **Configurações > MCP > Skill:** clique em **Guardar skill localmente** e confirme o ficheiro em `storage/skills/moneyprinterturbo-video.md`; opcionalmente use **Descarregar skill .md** para obter a cópia através do navegador.
|
|
376
377
|
|
|
377
|
-
##
|
|
378
|
+
## Pipeline: Criação de Vídeos, Criação de Músicas e Automação
|
|
378
379
|
|
|
379
380
|
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.
|
|
380
381
|
|
|
381
382
|
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. É apenas uma camada de UI nesta versão, sem execução de worker.
|
|
382
383
|
|
|
383
|
-
A área **Teste de vozes**, dentro de Configurações
|
|
384
|
+
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.
|
|
384
385
|
|
|
385
386
|
## Upload directo
|
|
386
387
|
|
|
@@ -388,17 +389,19 @@ A subaba **Upload directo** adapta o [YouTube-Video-Upload-Frontend-Api](https:/
|
|
|
388
389
|
|
|
389
390
|
## MCP e integrações externas
|
|
390
391
|
|
|
391
|
-
A
|
|
392
|
+
A subaba **Client MCP**, em **Configurações > MCP**, é um configurador local de quatro serviços opcionais. As portas iniciais são `3123` para Short Video Maker, `3001` para AutoVio, `8000` como referência editável para OpenMontage e `8787` para a API do OpenCut. A página consulta apenas `http://127.0.0.1:<porta>/` com timeout curto; não clona, instala ou inicia processos externos.
|
|
392
393
|
|
|
393
|
-
|
|
394
|
+
A subaba **Servidor MCP**, em **Configurações > MCP**, disponibiliza, após activação explícita, `http://127.0.0.1:3031/mcp` por JSON-RPC sobre HTTP POST. Para configurar um agente compatível, use esse URL como endpoint MCP. Se o host for alterado para uma interface externa, preencha primeiro um token e trate-o como segredo. O servidor disponibiliza ferramentas de leitura por padrão; a criação de lotes só é exposta quando **Permitir ferramentas de escrita** está activado.
|
|
394
395
|
|
|
395
|
-
|
|
396
|
+
A subaba **Skill**, em **Configurações > MCP**, contém as acções para guardar a skill anexada em `storage/skills/` ou descarregá-la como Markdown. Esta pasta fica fora dos estados JSON e é criada pelo instalador e pelo launcher. Os quatro repositórios continuam fora do pacote npm.
|
|
396
397
|
|
|
397
|
-
|
|
398
|
+
## 12. Limpador de Metadados
|
|
399
|
+
|
|
400
|
+
A aba **Limpador de Metadados** recebe somente vídeos externos já prontos. O ficheiro enviado é copiado para `storage/metadata_cleaner/originals/`; a aplicação não altera o original. Ao aplicar a operação, o FFmpeg remove os metadados existentes do contentor e cria uma nova versão em `storage/metadata_cleaner/outputs/` com o título, descrição, tags, idioma e outros campos preenchidos.
|
|
398
401
|
|
|
399
402
|
A descrição combina **Preview**, **Links** e **Timestamps**, seguindo a estrutura do workflow `YTBMetadataGenerator.json`. O preview recomendado tem entre 100 e 200 caracteres; os capítulos devem começar por `00:00`. O manifesto JSON descarregado contém os campos para um fluxo posterior de upload. Esta adaptação local não faz scraping automático via RSS/Apify nem publica directamente no YouTube.
|
|
400
403
|
|
|
401
|
-
Se a limpeza falhar, confirme a disponibilidade do FFmpeg em **Configurações > Caminho FFmpeg** ou execute `doctor`. O processo usa `-map_metadata -1` para remover os metadados existentes e `-c copy` para evitar uma re-encodificação desnecessária sempre que o contentor permitir.
|
|
404
|
+
Se a limpeza falhar, confirme a disponibilidade do FFmpeg em **Configurações > Configurações Técnicas > Caminho FFmpeg** ou execute `doctor`. O processo usa `-map_metadata -1` para remover os metadados existentes e `-c copy` para evitar uma re-encodificação desnecessária sempre que o contentor permitir.
|
|
402
405
|
|
|
403
406
|
## 11. Problemas frequentes
|
|
404
407
|
|
package/README.md
CHANGED
|
@@ -10,21 +10,25 @@ A primeira versão implementa a camada UI independente com:
|
|
|
10
10
|
|
|
11
11
|
| Área | Incluído |
|
|
12
12
|
|---|---|
|
|
13
|
-
|
|
|
14
|
-
| Pipeline |
|
|
13
|
+
| Início | Resumo de canais, tarefas, backlog, execução e falhas, com as filas do Pipeline inline |
|
|
14
|
+
| Pipeline | Menu expansível com Criação de Vídeos, Criação de Músicas, Upload e Limpador de Metadados |
|
|
15
15
|
| Blueprints | Leitura da pasta `storage/blueprints/`, upload/validação de JSON e criação a partir de link YouTube |
|
|
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
19
|
| Automação | Lista de vídeos e canais, selectores editáveis de Blueprint/voz padrão, Automação ON e horário diário HH:MM; UI configurável sem worker em segundo plano |
|
|
20
20
|
| Upload | YouTube via `youtube-automation-agent` adaptado internamente, OAuth directo de redundância, Upload directo experimental, TikTok, Instagram e Facebook Pages no front end |
|
|
21
21
|
| MCP | Catálogo local opcional de Short Video Maker, AutoVio, OpenMontage e OpenCut, com portas editáveis e activação |
|
|
22
|
-
| Limpador de
|
|
23
|
-
| Configurações | Provedores LLM, TTS/voz, preview de vozes, Suno, materiais, Whisper, FFmpeg, OAuth YouTube, Data API Key opcional, Upload directo, TikTok Client ID/Secret e Upload-Post |
|
|
22
|
+
| Limpador de Metadados | Upload isolado de vídeos terceiros, limpeza FFmpeg, edição de título/descrição/tags e manifesto JSON |
|
|
23
|
+
| Configurações Técnicas | Provedores LLM, TTS/voz, preview de vozes, Suno, materiais, Whisper, FFmpeg, OAuth YouTube, Data API Key opcional, Upload directo, TikTok Client ID/Secret e Upload-Post |
|
|
24
24
|
| Launcher | Execução via `npx`, instalação assistida, diagnóstico e preparação para distribuição |
|
|
25
25
|
|
|
26
26
|
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.
|
|
27
27
|
|
|
28
|
+
## Navegação da UI 0.2.28
|
|
29
|
+
|
|
30
|
+
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. Niche Finder permanece como placeholder vazio nesta versão.
|
|
31
|
+
|
|
28
32
|
## Instalação
|
|
29
33
|
|
|
30
34
|
Recomenda-se Python 3.11 ou superior e Node.js 18 ou superior.
|
|
@@ -104,9 +108,9 @@ thunderbolt
|
|
|
104
108
|
No Windows PowerShell, se `npx` for bloqueado por `npx.ps1`, use directamente `npx.cmd`:
|
|
105
109
|
|
|
106
110
|
```powershell
|
|
107
|
-
npx.cmd --yes @danhachuel/thunderbolt@0.2.
|
|
108
|
-
npx.cmd --yes @danhachuel/thunderbolt@0.2.
|
|
109
|
-
npx.cmd --yes @danhachuel/thunderbolt@0.2.
|
|
111
|
+
npx.cmd --yes @danhachuel/thunderbolt@0.2.28 install
|
|
112
|
+
npx.cmd --yes @danhachuel/thunderbolt@0.2.28 doctor
|
|
113
|
+
npx.cmd --yes @danhachuel/thunderbolt@0.2.28
|
|
110
114
|
```
|
|
111
115
|
|
|
112
116
|
Como alternativa, pode permitir scripts para o seu utilizador:
|
|
@@ -159,9 +163,9 @@ A mesma aba possui a subaba **Brandings**. No formulário **Criar blueprint a pa
|
|
|
159
163
|
|
|
160
164
|
O fluxo foi modelado a partir do blueprint de clonagem com Branding anexado, incluindo a distinção entre entrada de canal/vídeo, normalização do link, metadados de nicho/idioma, perfil do canal, estratégia de conteúdo, pesquisa, identidade visual e brand pack. Placeholders de serviços externos são tratados como configuração local; chaves presentes em workflows importados não devem ser commitadas.
|
|
161
165
|
|
|
162
|
-
## Limpador de
|
|
166
|
+
## Limpador de Metadados
|
|
163
167
|
|
|
164
|
-
A aba **Limpador de
|
|
168
|
+
A aba **Limpador de Metadados** foi adaptada do workflow `YTBMetadataGenerator.json`. Ela recebe apenas um vídeo externo já pronto, guarda uma cópia original separada, remove os metadados do contentor com FFmpeg e grava uma nova cópia com título, descrição, links, timestamps, tags e outros campos opcionais. O original nunca é alterado e os vídeos produzidos pelas páginas **Criação de Vídeos** e **Criação de Músicas** não aparecem nesta área.
|
|
165
169
|
|
|
166
170
|
A descrição segue a composição do workflow de referência: preview, secção de links e capítulos/timestamps. O resultado inclui um manifesto JSON para utilizar os metadados num fluxo posterior de upload. A versão local não usa o trigger RSS, o Apify ou a actualização automática de um vídeo YouTube; em vez disso, o utilizador fornece directamente o vídeo terceiro e controla a edição antes de publicar.
|
|
167
171
|
|
|
@@ -181,29 +185,34 @@ Use **Autorizar fallback OAuth** apenas se precisar de uma autorização separad
|
|
|
181
185
|
|
|
182
186
|
A subaba **Upload directo** adapta o [YouTube-Video-Upload-Frontend-Api](https://github.com/Nojus10/YouTube-Video-Upload-Frontend-Api). Ela usa cookies, `sessionInfo`, `INNERTUBE_API_KEY` e `DELEGATED_SESSION_ID` fornecidos manualmente pelo utilizador, cria o vídeo através do endpoint interno e envia o ficheiro em chunks de 256 KiB. Este caminho é experimental, não extrai cookies automaticamente e fica separado do agente YouTube principal.
|
|
183
187
|
|
|
184
|
-
##
|
|
188
|
+
## Pipeline: Criação de Vídeos, Criação de Músicas e Automação
|
|
185
189
|
|
|
186
|
-
Em **
|
|
190
|
+
Em **Criação de Vídeos**, o estilo visual `Pexels/Pixabay` é o modo de materiais, `full_ia` abre o selector **Estilo IA** com os 12 estilos solicitados e **Apenas Música** exige um áudio local, um upload musical ou um pedido ao endpoint Suno configurado. **Criação de Músicas** reutiliza o mesmo conteúdo e formulário com título próprio. Neste último modo, a task fica com `background_mode=none`: não são gerados fundos Pexels/Pixabay nem fundos IA.
|
|
187
191
|
|
|
188
192
|
O agente musical guarda os ficheiros em `storage/music/`, aceita formatos de áudio comuns e pode descarregar uma URL de áudio devolvida por um endpoint Suno compatível. Em **Canais**, abra **Definir Blueprint e voz padrão** no cartão do canal para guardar os defaults; em **Automação**, os mesmos dois selectores aparecem no cartão e são sincronizados. Esses valores são usados automaticamente em novas tarefas criadas para o canal. A aba **Automação** também guarda `Automação ON` e um horário diário `HH:MM` por canal e lista os vídeos cadastrados; por definição, esta entrega não executa workers de fundo.
|
|
189
193
|
|
|
190
194
|
## Teste de vozes
|
|
191
195
|
|
|
192
|
-
A área **Configurações > Teste de vozes** é isolada da pipeline.
|
|
196
|
+
A área **Configurações > Configurações Técnicas > Teste de vozes** é isolada da pipeline.
|
|
197
|
+
Permite escolher Edge/Azure Speech ou um provider HTTP configurado, seleccionar voz/ID, alterar a velocidade, sintetizar uma amostra, reproduzi-la e descarregá-la. O áudio é guardado em `storage/voice_previews/` e nunca altera vídeos ou tarefas. Se uma instalação antiga não tiver `edge-tts`, execute `npx.cmd --yes @danhachuel/thunderbolt install`; o instalador detecta a dependência em falta sem reinstalar componentes válidos.
|
|
193
198
|
|
|
194
199
|
## MCP e integrações externas opcionais
|
|
195
200
|
|
|
196
|
-
A aba **MCP**
|
|
201
|
+
A aba **MCP** está organizada em três subabas independentes. **Client MCP** lista **Short Video Maker**, **AutoVio**, **OpenMontage** e **OpenCut** com links oficiais, protocolo/capacidade conhecida, porta local editável, detecção passiva e toggle **Activo**. Os repositórios externos não são clonados, instalados ou incluídos no pacote npm do Thunderbolt.
|
|
202
|
+
|
|
203
|
+
As portas iniciais são `3123` para Short Video Maker, `3001` para a API backend do AutoVio, `8000` como referência editável para OpenMontage — que não documenta uma porta HTTP padrão — e `8787` para a API do OpenCut, cujo frontend de desenvolvimento usa `5173`. A detecção do Client MCP apenas consulta `localhost` e nunca inicia processos externos.
|
|
204
|
+
|
|
205
|
+
**Servidor MCP** activa, mediante confirmação explícita na UI, um endpoint local em `http://127.0.0.1:3031/mcp`. O endpoint usa JSON-RPC sobre HTTP POST, disponibiliza ferramentas de leitura para estado da pipeline, canais, vídeos e Blueprints e exige um token quando o host é exposto fora do computador local. As ferramentas de escrita ficam desactivadas por padrão; quando o utilizador as activa, o agente pode criar lotes de vídeos através de uma ferramenta controlada. O endpoint `/health` serve apenas para verificação de disponibilidade.
|
|
197
206
|
|
|
198
|
-
|
|
207
|
+
**Skill** contém exclusivamente os botões para guardar localmente ou descarregar `moneyprinterturbo-video.md`. A skill é guardada em `storage/skills/` e não é misturada com o catálogo de clientes MCP.
|
|
199
208
|
|
|
200
|
-
A mesma aba permite **Guardar skill localmente**, copiando `moneyprinterturbo-video.md` para `storage/skills/`, e **Descarregar skill .md** através do navegador. A skill é um recurso separado dos quatro repositórios externos.
|
|
201
209
|
|
|
202
210
|
## TikTok
|
|
203
211
|
|
|
204
|
-
A subaba **Vídeos**, dentro de **
|
|
212
|
+
A subaba **Vídeos**, dentro de **Criação de Vídeos** ou **Criação de Músicas**, mostra o backlog e permite iniciar/parar tarefas; deixou de existir como botão principal na barra lateral.
|
|
205
213
|
|
|
206
|
-
A
|
|
214
|
+
A página **Configurações Técnicas** contém apenas o TikTok Client ID e o TikTok Client Secret para TikTok.
|
|
215
|
+
Para YouTube, o bloco principal pede o OAuth Client ID e Client Secret; a Data API Key, se usada, fica numa área opcional separada. Client ID + Client Secret não formam uma Data API Key nem um token OAuth.
|
|
207
216
|
Redirect URI, scopes, autorização OAuth e tokens são geridos no TikTok for Developers Playground. O adaptador rejeita o upload quando faltam credenciais ou OAuth, em vez de indicar sucesso falso.
|
|
208
217
|
|
|
209
218
|
## Segurança
|
package/app/main.py
CHANGED
|
@@ -20,7 +20,8 @@ from hermes_ui.domain import STAGES, create_batch, create_channel, create_tasks_
|
|
|
20
20
|
from hermes_ui.storage import BLUEPRINTS, ensure_storage, list_blueprint_files, load_blueprint_file, now, read_json, write_json
|
|
21
21
|
from hermes_ui.blueprints import create_blueprint_from_link, list_branding_files, save_generated_blueprint
|
|
22
22
|
from hermes_ui.metadata_cleaner import build_description, clean_video_metadata, list_edit_records, metadata_manifest, normalize_tags, save_edit_record, store_external_video
|
|
23
|
-
from hermes_ui.mcp import detect_local_service, install_skill_locally, load_integrations, read_packaged_skill, update_integration
|
|
23
|
+
from hermes_ui.mcp import detect_local_service, install_skill_locally, load_integrations, load_server_config, read_packaged_skill, save_server_config, update_integration
|
|
24
|
+
from hermes_ui.mcp_server import server_status, start_server, stop_server
|
|
24
25
|
from hermes_ui.music import list_music_files, materialize_suno_audio, request_suno_generation, store_music_file
|
|
25
26
|
from hermes_ui.voice_preview import DEFAULT_SAMPLE, load_preview_file, synthesize_preview
|
|
26
27
|
from integrations.platforms import IntegrationResult, TikTokAdapter, YouTubeAdapter
|
|
@@ -124,6 +125,13 @@ st.markdown("""
|
|
|
124
125
|
[data-testid="stSidebar"] [data-testid="stBaseButton-primary"]:hover { background:#343434; color:#ffffff; }
|
|
125
126
|
[data-testid="stSidebar"] [data-testid="stBaseButton-primary"] span { color:#ffffff; }
|
|
126
127
|
[data-testid="stSidebar"] [data-testid="stBaseButton-secondary"] span { color:#e7edf2; }
|
|
128
|
+
[data-testid="stSidebar"] [data-testid="stExpander"] { border:0 !important; background:transparent !important; margin:0.02rem 0 !important; }
|
|
129
|
+
[data-testid="stSidebar"] [data-testid="stExpander"] details { border:0 !important; background:transparent !important; }
|
|
130
|
+
[data-testid="stSidebar"] [data-testid="stExpander"] summary { min-height:1.72rem; padding:0.10rem 0.52rem !important; border-radius:7px; color:#e7edf2; font-size:0.86rem; font-weight:650; }
|
|
131
|
+
[data-testid="stSidebar"] [data-testid="stExpander"] summary:hover { background:#1c252e; }
|
|
132
|
+
[data-testid="stSidebar"] [data-testid="stExpander"] summary p { margin:0; line-height:1; }
|
|
133
|
+
[data-testid="stSidebar"] [data-testid="stExpander"] > div { padding:0 0 0 0.42rem !important; }
|
|
134
|
+
[data-testid="stSidebar"] [data-testid="stExpander"] > div [data-testid="stButton"] button { padding-left:0.92rem; font-size:0.83rem; min-height:1.62rem; height:1.62rem; }
|
|
127
135
|
.content-card { padding: 1rem 1.1rem; border:1px solid #20384d; border-radius:14px; background:rgba(18,27,38,.92); min-height:110px; }
|
|
128
136
|
.content-label { color:#8ba6bb; font-size:.8rem; text-transform:uppercase; letter-spacing:.07em; }
|
|
129
137
|
.content-value { color:#f4f8fb; font-size:1.8rem; font-weight:700; margin-top:.3rem; }
|
|
@@ -211,27 +219,18 @@ def render_dashboard():
|
|
|
211
219
|
with col:
|
|
212
220
|
card(label, value, note)
|
|
213
221
|
st.divider()
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
if st.button("Importar canal", use_container_width=True):
|
|
227
|
-
st.session_state["page"] = "Canais"
|
|
228
|
-
st.rerun()
|
|
229
|
-
if st.button("Abrir Blueprints", use_container_width=True):
|
|
230
|
-
st.session_state["page"] = "Blueprints"
|
|
231
|
-
st.rerun()
|
|
232
|
-
if st.button("Abrir Upload", use_container_width=True):
|
|
233
|
-
st.session_state["page"] = "Upload"
|
|
234
|
-
st.rerun()
|
|
222
|
+
st.subheader("Pipeline")
|
|
223
|
+
st.caption("Filas locais e dependências da cascata")
|
|
224
|
+
queues = read_json("queues.json", {})
|
|
225
|
+
blueprint_count = len(list_blueprint_files())
|
|
226
|
+
for row_start in range(0, len(STAGES), 3):
|
|
227
|
+
queue_cols = st.columns(3)
|
|
228
|
+
for col, stage in zip(queue_cols, STAGES[row_start:row_start + 3]):
|
|
229
|
+
with col:
|
|
230
|
+
if stage == "blueprint":
|
|
231
|
+
card("Blueprints", blueprint_count, f"na biblioteca · {len(queues.get(stage, []))} tarefa(s) na fila")
|
|
232
|
+
else:
|
|
233
|
+
card(stage.title(), len(queues.get(stage, [])), "fila")
|
|
235
234
|
|
|
236
235
|
|
|
237
236
|
def render_blueprints():
|
|
@@ -536,8 +535,8 @@ def render_channels():
|
|
|
536
535
|
st.rerun()
|
|
537
536
|
|
|
538
537
|
|
|
539
|
-
def render_new_video():
|
|
540
|
-
st.title(
|
|
538
|
+
def render_new_video(page_title: str = "Criação de Vídeos"):
|
|
539
|
+
st.title(page_title)
|
|
541
540
|
create_tab, videos_tab = st.tabs(["Criar vídeo", "Vídeos"])
|
|
542
541
|
with create_tab:
|
|
543
542
|
channels = channel_options()
|
|
@@ -622,6 +621,16 @@ def render_new_video():
|
|
|
622
621
|
render_videos()
|
|
623
622
|
|
|
624
623
|
|
|
624
|
+
def render_music_creation():
|
|
625
|
+
"""Expose the complete video-creation UI under the music-oriented navigation entry without changing the original page."""
|
|
626
|
+
render_new_video(page_title="Criação de Músicas")
|
|
627
|
+
|
|
628
|
+
|
|
629
|
+
def render_niche_finder():
|
|
630
|
+
st.title("Niche Finder")
|
|
631
|
+
st.info("Esta área está reservada para o Niche Finder e será implementada numa próxima etapa.")
|
|
632
|
+
|
|
633
|
+
|
|
625
634
|
def render_videos():
|
|
626
635
|
st.subheader("Vídeos e backlog")
|
|
627
636
|
st.caption("Acompanhamento dos vídeos criados, estados da pipeline e controlos de execução.")
|
|
@@ -869,7 +878,7 @@ def render_upload_conventional():
|
|
|
869
878
|
|
|
870
879
|
|
|
871
880
|
def render_settings():
|
|
872
|
-
st.title("Configurações
|
|
881
|
+
st.title("Configurações Técnicas")
|
|
873
882
|
st.caption("Configuração do motor de vídeo e dos serviços usados pelo Thunderbolt. As credenciais ficam no storage local e não são enviadas para o GitHub.")
|
|
874
883
|
settings = read_json("settings.json", {})
|
|
875
884
|
|
|
@@ -1077,77 +1086,145 @@ def render_settings():
|
|
|
1077
1086
|
|
|
1078
1087
|
def render_mcp():
|
|
1079
1088
|
st.title("MCP")
|
|
1080
|
-
st.caption("Clientes
|
|
1081
|
-
|
|
1089
|
+
st.caption("Clientes externos, servidor MCP do Thunderbolt e a skill local ficam separados para evitar confundir funções diferentes.")
|
|
1090
|
+
|
|
1091
|
+
client_tab, server_tab, skill_tab = st.tabs(["Client MCP", "Servidor MCP", "Skill"])
|
|
1092
|
+
|
|
1093
|
+
with client_tab:
|
|
1094
|
+
st.subheader("Client MCP")
|
|
1095
|
+
st.info("Configure aqui os serviços MCP externos que o Thunderbolt pode detectar passivamente. Activar uma integração guarda apenas a preferência; não instala nem inicia processos externos.")
|
|
1096
|
+
integrations = load_integrations()
|
|
1097
|
+
for integration in integrations:
|
|
1098
|
+
integration_id = integration["id"]
|
|
1099
|
+
with st.container(border=True):
|
|
1100
|
+
header_cols = st.columns([2.6, 1.15, 1.65, 1.2])
|
|
1101
|
+
with header_cols[0]:
|
|
1102
|
+
st.write(f"**{integration['name']}**")
|
|
1103
|
+
st.caption(f"{integration['protocol']} · {integration['description']}")
|
|
1104
|
+
st.markdown(f"[Abrir repositório oficial]({integration['repository']})")
|
|
1105
|
+
with header_cols[1]:
|
|
1106
|
+
port = st.number_input(
|
|
1107
|
+
"Porta",
|
|
1108
|
+
min_value=1,
|
|
1109
|
+
max_value=65535,
|
|
1110
|
+
value=int(integration.get("port", 8000)),
|
|
1111
|
+
step=1,
|
|
1112
|
+
key=f"mcp_port_{integration_id}",
|
|
1113
|
+
)
|
|
1114
|
+
with header_cols[2]:
|
|
1115
|
+
status = detect_local_service({**integration, "port": port})
|
|
1116
|
+
if status["available"]:
|
|
1117
|
+
st.success("Disponível")
|
|
1118
|
+
else:
|
|
1119
|
+
st.caption("Não detectado")
|
|
1120
|
+
st.caption(status["message"])
|
|
1121
|
+
with header_cols[3]:
|
|
1122
|
+
active = st.toggle("Activo", value=bool(integration.get("active", False)), key=f"mcp_active_{integration_id}")
|
|
1123
|
+
if active != bool(integration.get("active", False)):
|
|
1124
|
+
update_integration(integration_id, active=active)
|
|
1125
|
+
st.rerun()
|
|
1082
1126
|
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
header_cols = st.columns([2.6, 1.15, 1.65, 1.2])
|
|
1088
|
-
with header_cols[0]:
|
|
1089
|
-
st.write(f"**{integration['name']}**")
|
|
1090
|
-
st.caption(f"{integration['protocol']} · {integration['description']}")
|
|
1091
|
-
st.markdown(f"[Abrir repositório oficial]({integration['repository']})")
|
|
1092
|
-
with header_cols[1]:
|
|
1093
|
-
port = st.number_input(
|
|
1094
|
-
"Porta",
|
|
1095
|
-
min_value=1,
|
|
1096
|
-
max_value=65535,
|
|
1097
|
-
value=int(integration.get("port", 8000)),
|
|
1098
|
-
step=1,
|
|
1099
|
-
key=f"mcp_port_{integration_id}",
|
|
1100
|
-
)
|
|
1101
|
-
with header_cols[2]:
|
|
1102
|
-
status = detect_local_service({**integration, "port": port})
|
|
1103
|
-
if status["available"]:
|
|
1104
|
-
st.success("Disponível")
|
|
1105
|
-
else:
|
|
1106
|
-
st.caption("Não detectado")
|
|
1107
|
-
st.caption(status["message"])
|
|
1108
|
-
with header_cols[3]:
|
|
1109
|
-
active = st.toggle("Activo", value=bool(integration.get("active", False)), key=f"mcp_active_{integration_id}")
|
|
1110
|
-
if active != bool(integration.get("active", False)):
|
|
1111
|
-
update_integration(integration_id, active=active)
|
|
1127
|
+
st.caption(integration.get("endpoint_note", "Porta editável para o serviço local."))
|
|
1128
|
+
if st.button("Guardar porta", key=f"mcp_save_port_{integration_id}", use_container_width=True):
|
|
1129
|
+
update_integration(integration_id, port=int(port))
|
|
1130
|
+
st.success(f"Porta de {integration['name']} guardada: {int(port)}")
|
|
1112
1131
|
st.rerun()
|
|
1113
1132
|
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
st.divider()
|
|
1121
|
-
st.subheader("Skill MoneyPrinterTurbo")
|
|
1122
|
-
st.caption("A skill anexada pode ser guardada na pasta local do Thunderbolt ou descarregada como ficheiro Markdown. Nenhum dos quatro repositórios externos é copiado para o pacote.")
|
|
1123
|
-
skill_cols = st.columns(2)
|
|
1124
|
-
with skill_cols[0]:
|
|
1125
|
-
if st.button("Guardar skill localmente", type="primary", use_container_width=True, key="mcp_install_mpt_skill"):
|
|
1133
|
+
with server_tab:
|
|
1134
|
+
st.subheader("Servidor MCP")
|
|
1135
|
+
st.caption("Servidor local do Thunderbolt para ser descoberto por um agente compatível com MCP. O endpoint usa JSON-RPC sobre HTTP POST em `/mcp`.")
|
|
1136
|
+
st.warning("Por segurança, o servidor inicia apenas quando o activar explicitamente. Por padrão fica acessível somente neste computador em 127.0.0.1:3031.")
|
|
1137
|
+
server_config = load_server_config()
|
|
1138
|
+
if server_config.get("enabled") and not server_status().get("running"):
|
|
1126
1139
|
try:
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1140
|
+
start_server(
|
|
1141
|
+
str(server_config.get("host", "127.0.0.1")),
|
|
1142
|
+
int(server_config.get("port", 3031)),
|
|
1143
|
+
str(server_config.get("auth_token", "")),
|
|
1144
|
+
bool(server_config.get("write_enabled", False)),
|
|
1145
|
+
)
|
|
1146
|
+
except Exception as exc:
|
|
1147
|
+
st.error(f"Não foi possível iniciar o Servidor MCP guardado: {exc}")
|
|
1148
|
+
|
|
1149
|
+
server_cols = st.columns([1.3, 1.4, 1.4])
|
|
1150
|
+
with server_cols[0]:
|
|
1151
|
+
server_enabled = st.checkbox("Servidor MCP ON", value=bool(server_config.get("enabled", False)), key="mcp_server_enabled")
|
|
1152
|
+
with server_cols[1]:
|
|
1153
|
+
server_host = st.text_input("Host", value=str(server_config.get("host", "127.0.0.1")), key="mcp_server_host", help="Use 127.0.0.1 para acesso local. Um host externo exige token.")
|
|
1154
|
+
with server_cols[2]:
|
|
1155
|
+
server_port = st.number_input("Porta do servidor", min_value=1, max_value=65535, value=int(server_config.get("port", 3031)), step=1, key="mcp_server_port")
|
|
1156
|
+
server_token = st.text_input("Token de acesso MCP (opcional no localhost)", value=str(server_config.get("auth_token", "")), type="password", key="mcp_server_token")
|
|
1157
|
+
write_enabled = st.checkbox("Permitir ferramentas de escrita", value=bool(server_config.get("write_enabled", False)), key="mcp_server_write", help="Desactivado por padrão. Quando activo, o agente pode criar lotes de vídeos através de uma ferramenta MCP; leituras continuam disponíveis sem esta opção.")
|
|
1158
|
+
if str(server_host).strip() not in {"127.0.0.1", "localhost", "::1"} and not str(server_token).strip():
|
|
1159
|
+
st.error("Para expor o servidor fora do computador local, defina um token de acesso MCP.")
|
|
1160
|
+
|
|
1161
|
+
action_label = "Guardar e iniciar Servidor MCP" if server_enabled else "Guardar e parar Servidor MCP"
|
|
1162
|
+
if st.button(action_label, type="primary", key="mcp_server_save", use_container_width=True):
|
|
1163
|
+
try:
|
|
1164
|
+
saved = save_server_config(enabled=server_enabled, host=server_host, port=int(server_port), auth_token=server_token, write_enabled=write_enabled)
|
|
1165
|
+
if saved["enabled"]:
|
|
1166
|
+
status = start_server(saved["host"], saved["port"], saved["auth_token"], saved["write_enabled"])
|
|
1167
|
+
st.success(f"Servidor MCP activo em `{status['endpoint']}`")
|
|
1168
|
+
else:
|
|
1169
|
+
stop_server()
|
|
1170
|
+
st.success("Servidor MCP parado.")
|
|
1171
|
+
st.rerun()
|
|
1172
|
+
except Exception as exc:
|
|
1173
|
+
st.error(f"Não foi possível guardar/iniciar o Servidor MCP: {exc}")
|
|
1174
|
+
|
|
1175
|
+
runtime = server_status()
|
|
1176
|
+
if runtime.get("running"):
|
|
1177
|
+
st.success(f"Servidor MCP activo: `{runtime['endpoint']}`")
|
|
1178
|
+
st.code(
|
|
1179
|
+
json.dumps(
|
|
1180
|
+
{
|
|
1181
|
+
"endpoint": runtime["endpoint"],
|
|
1182
|
+
"health": runtime["health_endpoint"],
|
|
1183
|
+
"transport": "Streamable HTTP / JSON-RPC POST",
|
|
1184
|
+
"write_tools": runtime["write_enabled"],
|
|
1185
|
+
},
|
|
1186
|
+
ensure_ascii=False,
|
|
1187
|
+
indent=2,
|
|
1188
|
+
),
|
|
1189
|
+
language="json",
|
|
1144
1190
|
)
|
|
1191
|
+
st.caption("Use o endpoint `/mcp` na configuração do agente. O endpoint `/health` serve apenas para verificar se o servidor está activo. Não partilhe o token.")
|
|
1145
1192
|
else:
|
|
1146
|
-
st.
|
|
1193
|
+
st.info("Servidor MCP parado. As configurações ficam guardadas localmente e só são usadas depois de clicar em Guardar e iniciar.")
|
|
1194
|
+
st.markdown("**Ferramentas disponibilizadas**")
|
|
1195
|
+
st.write("Leitura: estado da pipeline, canais, vídeos e Blueprints. Ferramentas de escrita: criação de lotes de vídeos, apenas quando a opção de escrita estiver activada pelo utilizador.")
|
|
1196
|
+
|
|
1197
|
+
with skill_tab:
|
|
1198
|
+
st.subheader("Skill")
|
|
1199
|
+
st.caption("A skill anexada pode ser guardada na pasta local do Thunderbolt ou descarregada como ficheiro Markdown. Nenhum dos quatro repositórios externos é copiado para o pacote.")
|
|
1200
|
+
skill_cols = st.columns(2)
|
|
1201
|
+
with skill_cols[0]:
|
|
1202
|
+
if st.button("Guardar skill localmente", type="primary", use_container_width=True, key="mcp_install_mpt_skill"):
|
|
1203
|
+
try:
|
|
1204
|
+
destination = install_skill_locally()
|
|
1205
|
+
st.success(f"Skill guardada em `{destination}`")
|
|
1206
|
+
except (FileNotFoundError, OSError) as exc:
|
|
1207
|
+
st.error(f"Não foi possível guardar a skill: {exc}")
|
|
1208
|
+
with skill_cols[1]:
|
|
1209
|
+
try:
|
|
1210
|
+
skill_data = read_packaged_skill()
|
|
1211
|
+
except FileNotFoundError:
|
|
1212
|
+
skill_data = None
|
|
1213
|
+
if skill_data is not None:
|
|
1214
|
+
st.download_button(
|
|
1215
|
+
"Descarregar skill .md",
|
|
1216
|
+
data=skill_data,
|
|
1217
|
+
file_name="moneyprinterturbo-video.md",
|
|
1218
|
+
mime="text/markdown",
|
|
1219
|
+
use_container_width=True,
|
|
1220
|
+
key="mcp_download_mpt_skill",
|
|
1221
|
+
)
|
|
1222
|
+
else:
|
|
1223
|
+
st.warning("A skill ainda não está disponível nesta instalação.")
|
|
1147
1224
|
|
|
1148
1225
|
|
|
1149
1226
|
def render_metadata_cleaner():
|
|
1150
|
-
st.title("Limpador de
|
|
1227
|
+
st.title("Limpador de Metadados")
|
|
1151
1228
|
st.caption("Limpeza e edição de metadados para vídeos de terceiros que já estão prontos.")
|
|
1152
1229
|
st.warning("Esta área aceita exclusivamente vídeos externos. Vídeos criados na aba Novo vídeo não são listados nem processados aqui.")
|
|
1153
1230
|
|
|
@@ -1275,43 +1352,74 @@ def render_pipeline():
|
|
|
1275
1352
|
|
|
1276
1353
|
|
|
1277
1354
|
def main():
|
|
1278
|
-
|
|
1279
|
-
("
|
|
1280
|
-
("
|
|
1281
|
-
("Blueprints", ":material/library_books:", "Blueprints"),
|
|
1282
|
-
("Canais", ":material/ondemand_video:", "Canais"),
|
|
1283
|
-
("Novo vídeo", ":material/add_circle:", "Novo vídeo"),
|
|
1284
|
-
("Automação", ":material/schedule:", "Automação"),
|
|
1355
|
+
pipeline_items = [
|
|
1356
|
+
("Criação de Vídeos", ":material/add_circle:", "Criação de Vídeos"),
|
|
1357
|
+
("Criação de Músicas", ":material/music_note:", "Criação de Músicas"),
|
|
1285
1358
|
("Upload", ":material/cloud_upload:", "Upload"),
|
|
1359
|
+
("Limpador de Metadados", ":material/edit_note:", "Limpador de Metadados"),
|
|
1360
|
+
]
|
|
1361
|
+
settings_items = [
|
|
1362
|
+
("Canais", ":material/ondemand_video:", "Canais"),
|
|
1363
|
+
("Blueprints", ":material/library_books:", "Blueprints"),
|
|
1286
1364
|
("MCP", ":material/hub:", "MCP"),
|
|
1287
|
-
("
|
|
1365
|
+
("Configurações Técnicas", ":material/settings:", "Configurações Técnicas"),
|
|
1366
|
+
]
|
|
1367
|
+
top_pages = [
|
|
1368
|
+
("Início", ":material/home:", "Início"),
|
|
1369
|
+
("Pipeline", ":material/account_tree:", "Pipeline"),
|
|
1370
|
+
("Automação", ":material/schedule:", "Automação"),
|
|
1371
|
+
("Niche Finder", ":material/search:", "Niche Finder"),
|
|
1288
1372
|
("Configurações", ":material/settings:", "Configurações"),
|
|
1289
1373
|
]
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1374
|
+
aliases = {
|
|
1375
|
+
"Dashboard": "Início",
|
|
1376
|
+
"Novo vídeo": "Criação de Vídeos",
|
|
1377
|
+
"Vídeos": "Criação de Vídeos",
|
|
1378
|
+
"Limpador de metadado": "Limpador de Metadados",
|
|
1379
|
+
"Configurações Técnicas": "Configurações Técnicas",
|
|
1380
|
+
}
|
|
1381
|
+
current_page = aliases.get(st.session_state.get("page", "Início"), st.session_state.get("page", "Início"))
|
|
1382
|
+
if current_page not in {item[0] for item in top_pages + pipeline_items + settings_items}:
|
|
1383
|
+
current_page = "Início"
|
|
1384
|
+
st.session_state["page"] = current_page
|
|
1385
|
+
|
|
1386
|
+
def navigate(target: str):
|
|
1387
|
+
st.session_state["page"] = target
|
|
1388
|
+
st.rerun()
|
|
1389
|
+
|
|
1390
|
+
def render_nav_button(target: str, icon: str, label: str, *, child: bool = False):
|
|
1391
|
+
if st.button(label, key=f"nav_{target}", icon=icon, use_container_width=True, type="primary" if current_page == target else "secondary"):
|
|
1392
|
+
navigate(target)
|
|
1393
|
+
|
|
1294
1394
|
with st.sidebar:
|
|
1295
1395
|
version_markup = f'<span class="tb-brand-version">{APP_VERSION}</span>' if APP_VERSION else ""
|
|
1296
1396
|
st.markdown(f'<div class="tb-brand"><span class="tb-brand-name">Thunderbolt</span>{version_markup}</div>', unsafe_allow_html=True)
|
|
1297
|
-
for target, icon, label in
|
|
1298
|
-
if
|
|
1299
|
-
st.
|
|
1300
|
-
|
|
1397
|
+
for target, icon, label in top_pages:
|
|
1398
|
+
if target == "Pipeline":
|
|
1399
|
+
with st.expander("Pipeline", expanded=current_page in {item[0] for item in pipeline_items}, icon=":material/account_tree:"):
|
|
1400
|
+
for child_target, child_icon, child_label in pipeline_items:
|
|
1401
|
+
render_nav_button(child_target, child_icon, child_label, child=True)
|
|
1402
|
+
elif target == "Configurações":
|
|
1403
|
+
with st.expander("Configurações", expanded=current_page in {item[0] for item in settings_items}, icon=":material/settings:"):
|
|
1404
|
+
for child_target, child_icon, child_label in settings_items:
|
|
1405
|
+
render_nav_button(child_target, child_icon, child_label, child=True)
|
|
1406
|
+
else:
|
|
1407
|
+
render_nav_button(target, icon, label)
|
|
1408
|
+
|
|
1301
1409
|
renderers = {
|
|
1302
|
-
"
|
|
1303
|
-
"
|
|
1304
|
-
"
|
|
1305
|
-
"Canais": render_channels,
|
|
1306
|
-
"Novo vídeo": render_new_video,
|
|
1410
|
+
"Início": render_dashboard,
|
|
1411
|
+
"Criação de Vídeos": render_new_video,
|
|
1412
|
+
"Criação de Músicas": render_music_creation,
|
|
1307
1413
|
"Automação": render_automation,
|
|
1414
|
+
"Niche Finder": render_niche_finder,
|
|
1308
1415
|
"Upload": render_upload,
|
|
1416
|
+
"Limpador de Metadados": render_metadata_cleaner,
|
|
1417
|
+
"Canais": render_channels,
|
|
1418
|
+
"Blueprints": render_blueprints,
|
|
1309
1419
|
"MCP": render_mcp,
|
|
1310
|
-
"
|
|
1311
|
-
"Configurações": render_settings,
|
|
1420
|
+
"Configurações Técnicas": render_settings,
|
|
1312
1421
|
}
|
|
1313
1422
|
renderers.get(current_page, render_dashboard)()
|
|
1314
1423
|
|
|
1315
|
-
|
|
1316
1424
|
if __name__ == "__main__":
|
|
1317
1425
|
main()
|
package/hermes_ui/mcp.py
CHANGED
|
@@ -9,6 +9,13 @@ from . import storage
|
|
|
9
9
|
|
|
10
10
|
|
|
11
11
|
SKILL_FILENAME = "moneyprinterturbo-video.md"
|
|
12
|
+
MCP_SERVER_DEFAULTS: dict[str, Any] = {
|
|
13
|
+
"enabled": False,
|
|
14
|
+
"host": "127.0.0.1",
|
|
15
|
+
"port": 3031,
|
|
16
|
+
"auth_token": "",
|
|
17
|
+
"write_enabled": False,
|
|
18
|
+
}
|
|
12
19
|
|
|
13
20
|
MCP_DEFAULTS: list[dict[str, Any]] = [
|
|
14
21
|
{
|
|
@@ -72,6 +79,36 @@ def _merge_defaults(saved: Any) -> list[dict[str, Any]]:
|
|
|
72
79
|
return merged
|
|
73
80
|
|
|
74
81
|
|
|
82
|
+
def load_server_config() -> dict[str, Any]:
|
|
83
|
+
saved = storage.read_json("mcp_server.json", MCP_SERVER_DEFAULTS)
|
|
84
|
+
config = {**MCP_SERVER_DEFAULTS, **(saved if isinstance(saved, dict) else {})}
|
|
85
|
+
config["enabled"] = bool(config.get("enabled", False))
|
|
86
|
+
config["write_enabled"] = bool(config.get("write_enabled", False))
|
|
87
|
+
config["host"] = str(config.get("host", "127.0.0.1") or "127.0.0.1").strip()
|
|
88
|
+
config["auth_token"] = str(config.get("auth_token", "") or "").strip()
|
|
89
|
+
try:
|
|
90
|
+
config["port"] = max(1, min(65535, int(config.get("port", 3031))))
|
|
91
|
+
except (TypeError, ValueError):
|
|
92
|
+
config["port"] = 3031
|
|
93
|
+
return config
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def save_server_config(**updates: Any) -> dict[str, Any]:
|
|
97
|
+
config = load_server_config()
|
|
98
|
+
config.update(updates)
|
|
99
|
+
config = {**MCP_SERVER_DEFAULTS, **config}
|
|
100
|
+
config["enabled"] = bool(config.get("enabled", False))
|
|
101
|
+
config["write_enabled"] = bool(config.get("write_enabled", False))
|
|
102
|
+
config["host"] = str(config.get("host", "127.0.0.1") or "127.0.0.1").strip()
|
|
103
|
+
config["auth_token"] = str(config.get("auth_token", "") or "").strip()
|
|
104
|
+
try:
|
|
105
|
+
config["port"] = max(1, min(65535, int(config.get("port", 3031))))
|
|
106
|
+
except (TypeError, ValueError):
|
|
107
|
+
config["port"] = 3031
|
|
108
|
+
storage.write_json("mcp_server.json", config)
|
|
109
|
+
return config
|
|
110
|
+
|
|
111
|
+
|
|
75
112
|
def load_integrations() -> list[dict[str, Any]]:
|
|
76
113
|
return _merge_defaults(storage.read_json("mcp_integrations.json", MCP_DEFAULTS))
|
|
77
114
|
|
|
@@ -0,0 +1,400 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import atexit
|
|
4
|
+
import json
|
|
5
|
+
import logging
|
|
6
|
+
import threading
|
|
7
|
+
from http import HTTPStatus
|
|
8
|
+
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any
|
|
11
|
+
from urllib.parse import urlparse
|
|
12
|
+
|
|
13
|
+
from . import storage
|
|
14
|
+
from .domain import create_batch, create_tasks_for_batch, pipeline_summary
|
|
15
|
+
|
|
16
|
+
LOGGER = logging.getLogger(__name__)
|
|
17
|
+
MCP_ENDPOINT_PATH = "/mcp"
|
|
18
|
+
MCP_HEALTH_PATH = "/health"
|
|
19
|
+
DEFAULT_HOST = "127.0.0.1"
|
|
20
|
+
DEFAULT_PORT = 3031
|
|
21
|
+
DEFAULT_PROTOCOL_VERSION = "2025-06-18"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _version() -> str:
|
|
25
|
+
try:
|
|
26
|
+
package = json.loads((Path(__file__).resolve().parents[1] / "package.json").read_text(encoding="utf-8"))
|
|
27
|
+
return str(package.get("version") or "")
|
|
28
|
+
except (OSError, json.JSONDecodeError):
|
|
29
|
+
return ""
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _json_text(value: Any) -> str:
|
|
33
|
+
return json.dumps(value, ensure_ascii=False, indent=2, default=str)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _channel_public(channel: dict[str, Any]) -> dict[str, Any]:
|
|
37
|
+
allowed = (
|
|
38
|
+
"id", "name", "url", "handle", "description", "thumbnail_url", "subscriber_count",
|
|
39
|
+
"video_count", "view_count", "metrics_source", "last_youtube_sync", "language",
|
|
40
|
+
"blueprint_id", "default_blueprint_id", "style_wide", "voice", "default_voice",
|
|
41
|
+
"automation_on", "automation_time", "active", "daily_limit", "backlog_total",
|
|
42
|
+
"created_at", "updated_at",
|
|
43
|
+
)
|
|
44
|
+
return {key: channel.get(key) for key in allowed if key in channel}
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _task_public(task: dict[str, Any]) -> dict[str, Any]:
|
|
48
|
+
allowed = (
|
|
49
|
+
"id", "batch_id", "creation_mode", "channel_id", "channel_name", "topic", "language",
|
|
50
|
+
"format", "style_wide", "style_ia", "music_mode", "music_source", "background_mode",
|
|
51
|
+
"blueprint_id", "voice", "automation_on", "automation_time", "stage", "state",
|
|
52
|
+
"progress", "error", "created_at", "updated_at",
|
|
53
|
+
)
|
|
54
|
+
return {key: task.get(key) for key in allowed if key in task}
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _list_blueprints() -> list[dict[str, Any]]:
|
|
58
|
+
storage.ensure_storage()
|
|
59
|
+
files = sorted(storage.BLUEPRINTS.rglob("*.json"))
|
|
60
|
+
return [
|
|
61
|
+
{
|
|
62
|
+
"id": path.stem,
|
|
63
|
+
"name": path.stem,
|
|
64
|
+
"filename": path.name,
|
|
65
|
+
"category": path.parent.name,
|
|
66
|
+
}
|
|
67
|
+
for path in files
|
|
68
|
+
]
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _tool_definitions(write_enabled: bool) -> list[dict[str, Any]]:
|
|
72
|
+
tools = [
|
|
73
|
+
{
|
|
74
|
+
"name": "thunderbolt_get_status",
|
|
75
|
+
"title": "Thunderbolt — estado da pipeline",
|
|
76
|
+
"description": "Obtém o resumo actual de canais e tarefas do Thunderbolt.",
|
|
77
|
+
"inputSchema": {"type": "object", "additionalProperties": False},
|
|
78
|
+
},
|
|
79
|
+
{
|
|
80
|
+
"name": "thunderbolt_list_channels",
|
|
81
|
+
"title": "Thunderbolt — listar canais",
|
|
82
|
+
"description": "Lista os canais registados sem devolver cookies, tokens ou outros segredos.",
|
|
83
|
+
"inputSchema": {"type": "object", "additionalProperties": False},
|
|
84
|
+
},
|
|
85
|
+
{
|
|
86
|
+
"name": "thunderbolt_list_videos",
|
|
87
|
+
"title": "Thunderbolt — listar vídeos",
|
|
88
|
+
"description": "Lista tarefas de vídeo com filtros opcionais por canal e estado.",
|
|
89
|
+
"inputSchema": {
|
|
90
|
+
"type": "object",
|
|
91
|
+
"properties": {
|
|
92
|
+
"channel_id": {"type": "string", "description": "ID do canal opcional."},
|
|
93
|
+
"state": {"type": "string", "description": "Estado opcional: to_do, doing, blocked, done, failed ou cancelled."},
|
|
94
|
+
"limit": {"type": "integer", "minimum": 1, "maximum": 100, "default": 50},
|
|
95
|
+
},
|
|
96
|
+
"additionalProperties": False,
|
|
97
|
+
},
|
|
98
|
+
},
|
|
99
|
+
{
|
|
100
|
+
"name": "thunderbolt_list_blueprints",
|
|
101
|
+
"title": "Thunderbolt — listar Blueprints",
|
|
102
|
+
"description": "Lista os Blueprints JSON disponíveis na biblioteca local, sem expor caminhos absolutos.",
|
|
103
|
+
"inputSchema": {"type": "object", "additionalProperties": False},
|
|
104
|
+
},
|
|
105
|
+
]
|
|
106
|
+
if write_enabled:
|
|
107
|
+
tools.append(
|
|
108
|
+
{
|
|
109
|
+
"name": "thunderbolt_create_video_batch",
|
|
110
|
+
"title": "Thunderbolt — criar lote de vídeos",
|
|
111
|
+
"description": "Cria um lote e as respectivas tarefas. Só aparece quando o utilizador activa ferramentas de escrita na UI.",
|
|
112
|
+
"inputSchema": {
|
|
113
|
+
"type": "object",
|
|
114
|
+
"properties": {
|
|
115
|
+
"mode": {"type": "string", "enum": ["single", "batch", "general"]},
|
|
116
|
+
"channel_ids": {"type": "array", "items": {"type": "string"}, "minItems": 1},
|
|
117
|
+
"topic": {"type": "string", "minLength": 1},
|
|
118
|
+
"quantity": {"type": "integer", "minimum": 1, "maximum": 50},
|
|
119
|
+
"language": {"type": "string"},
|
|
120
|
+
"format": {"type": "string"},
|
|
121
|
+
"style_wide": {"type": "string"},
|
|
122
|
+
"style_ia": {"type": "string"},
|
|
123
|
+
"music_mode": {"type": "boolean"},
|
|
124
|
+
"background_mode": {"type": "string"},
|
|
125
|
+
},
|
|
126
|
+
"required": ["mode", "channel_ids", "topic", "quantity"],
|
|
127
|
+
"additionalProperties": False,
|
|
128
|
+
},
|
|
129
|
+
}
|
|
130
|
+
)
|
|
131
|
+
return tools
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _tool_call(name: str, arguments: dict[str, Any], write_enabled: bool) -> Any:
|
|
135
|
+
if name == "thunderbolt_get_status":
|
|
136
|
+
return pipeline_summary()
|
|
137
|
+
if name == "thunderbolt_list_channels":
|
|
138
|
+
channels = storage.read_json("channels.json", [])
|
|
139
|
+
return [_channel_public(channel) for channel in channels if isinstance(channel, dict)]
|
|
140
|
+
if name == "thunderbolt_list_videos":
|
|
141
|
+
channel_id = str(arguments.get("channel_id", "") or "").strip()
|
|
142
|
+
state = str(arguments.get("state", "") or "").strip()
|
|
143
|
+
limit = int(arguments.get("limit", 50) or 50)
|
|
144
|
+
if limit < 1 or limit > 100:
|
|
145
|
+
raise ValueError("limit deve estar entre 1 e 100.")
|
|
146
|
+
valid_states = {"to_do", "doing", "blocked", "done", "failed", "cancelled"}
|
|
147
|
+
if state and state not in valid_states:
|
|
148
|
+
raise ValueError(f"state inválido: {state}.")
|
|
149
|
+
tasks = storage.read_json("tasks.json", [])
|
|
150
|
+
filtered = [
|
|
151
|
+
task for task in tasks
|
|
152
|
+
if isinstance(task, dict)
|
|
153
|
+
and (not channel_id or task.get("channel_id") == channel_id)
|
|
154
|
+
and (not state or task.get("state") == state)
|
|
155
|
+
]
|
|
156
|
+
return [_task_public(task) for task in filtered[:limit]]
|
|
157
|
+
if name == "thunderbolt_list_blueprints":
|
|
158
|
+
return _list_blueprints()
|
|
159
|
+
if name == "thunderbolt_create_video_batch":
|
|
160
|
+
if not write_enabled:
|
|
161
|
+
raise PermissionError("Ferramentas de escrita estão desactivadas no Servidor MCP.")
|
|
162
|
+
mode = str(arguments.get("mode", "")).strip()
|
|
163
|
+
if mode not in {"single", "batch", "general"}:
|
|
164
|
+
raise ValueError("mode deve ser single, batch ou general.")
|
|
165
|
+
channel_ids = arguments.get("channel_ids")
|
|
166
|
+
if not isinstance(channel_ids, list) or not channel_ids or not all(isinstance(item, str) and item.strip() for item in channel_ids):
|
|
167
|
+
raise ValueError("channel_ids deve conter pelo menos um ID de canal válido.")
|
|
168
|
+
topic = str(arguments.get("topic", "") or "").strip()
|
|
169
|
+
if not topic:
|
|
170
|
+
raise ValueError("topic é obrigatório.")
|
|
171
|
+
quantity = int(arguments.get("quantity", 1) or 1)
|
|
172
|
+
if quantity < 1 or quantity > 50:
|
|
173
|
+
raise ValueError("quantity deve estar entre 1 e 50.")
|
|
174
|
+
options = {
|
|
175
|
+
"language": str(arguments.get("language", "") or ""),
|
|
176
|
+
"format": str(arguments.get("format", "wide") or "wide"),
|
|
177
|
+
"style_wide": str(arguments.get("style_wide", "pexels") or "pexels"),
|
|
178
|
+
"style_ia": str(arguments.get("style_ia", "") or ""),
|
|
179
|
+
"music_mode": bool(arguments.get("music_mode", False)),
|
|
180
|
+
"background_mode": str(arguments.get("background_mode", "stock") or "stock"),
|
|
181
|
+
}
|
|
182
|
+
batch = create_batch(mode, [item.strip() for item in channel_ids], topic, quantity, options)
|
|
183
|
+
tasks = create_tasks_for_batch(batch)
|
|
184
|
+
return {"batch_id": batch["id"], "task_ids": [task["id"] for task in tasks], "created": len(tasks)}
|
|
185
|
+
raise KeyError(f"Ferramenta desconhecida: {name}")
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _rpc_error(request_id: Any, code: int, message: str) -> dict[str, Any]:
|
|
189
|
+
return {"jsonrpc": "2.0", "id": request_id, "error": {"code": code, "message": message}}
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _tool_result(value: Any, *, is_error: bool = False) -> dict[str, Any]:
|
|
193
|
+
result: dict[str, Any] = {
|
|
194
|
+
"content": [{"type": "text", "text": _json_text(value)}],
|
|
195
|
+
"isError": is_error,
|
|
196
|
+
}
|
|
197
|
+
if not is_error:
|
|
198
|
+
result["structuredContent"] = value
|
|
199
|
+
return result
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
class _MCPDispatcher:
|
|
203
|
+
def __init__(self, auth_token: str = "", write_enabled: bool = False):
|
|
204
|
+
self.auth_token = auth_token.strip()
|
|
205
|
+
self.write_enabled = bool(write_enabled)
|
|
206
|
+
|
|
207
|
+
def handle(self, request: dict[str, Any]) -> dict[str, Any] | None:
|
|
208
|
+
request_id = request.get("id")
|
|
209
|
+
if request.get("jsonrpc") != "2.0":
|
|
210
|
+
return _rpc_error(request_id, -32600, "Pedido JSON-RPC inválido.")
|
|
211
|
+
method = request.get("method")
|
|
212
|
+
params = request.get("params") or {}
|
|
213
|
+
if not isinstance(method, str) or not isinstance(params, dict):
|
|
214
|
+
return _rpc_error(request_id, -32600, "method e params inválidos.")
|
|
215
|
+
if method in {"notifications/initialized", "notifications/cancelled", "notifications/progress"} or "id" not in request:
|
|
216
|
+
return None
|
|
217
|
+
if method == "initialize":
|
|
218
|
+
requested = params.get("protocolVersion")
|
|
219
|
+
protocol_version = requested if isinstance(requested, str) and requested else DEFAULT_PROTOCOL_VERSION
|
|
220
|
+
return {
|
|
221
|
+
"jsonrpc": "2.0",
|
|
222
|
+
"id": request_id,
|
|
223
|
+
"result": {
|
|
224
|
+
"protocolVersion": protocol_version,
|
|
225
|
+
"capabilities": {"tools": {"listChanged": False}},
|
|
226
|
+
"serverInfo": {"name": "Thunderbolt MCP Server", "version": _version()},
|
|
227
|
+
"instructions": "Use tools/list para descobrir as ferramentas disponíveis. Ferramentas de escrita só aparecem quando autorizadas pelo utilizador na UI.",
|
|
228
|
+
},
|
|
229
|
+
}
|
|
230
|
+
if method == "ping":
|
|
231
|
+
return {"jsonrpc": "2.0", "id": request_id, "result": {}}
|
|
232
|
+
if method == "tools/list":
|
|
233
|
+
return {"jsonrpc": "2.0", "id": request_id, "result": {"tools": _tool_definitions(self.write_enabled)}}
|
|
234
|
+
if method == "tools/call":
|
|
235
|
+
name = params.get("name")
|
|
236
|
+
arguments = params.get("arguments") or {}
|
|
237
|
+
if not isinstance(name, str) or not isinstance(arguments, dict):
|
|
238
|
+
return _rpc_error(request_id, -32602, "name e arguments são obrigatórios em tools/call.")
|
|
239
|
+
try:
|
|
240
|
+
value = _tool_call(name, arguments, self.write_enabled)
|
|
241
|
+
return {"jsonrpc": "2.0", "id": request_id, "result": _tool_result(value)}
|
|
242
|
+
except KeyError as exc:
|
|
243
|
+
return _rpc_error(request_id, -32602, str(exc))
|
|
244
|
+
except (ValueError, PermissionError) as exc:
|
|
245
|
+
return {"jsonrpc": "2.0", "id": request_id, "result": _tool_result(str(exc), is_error=True)}
|
|
246
|
+
except Exception:
|
|
247
|
+
LOGGER.exception("Falha ao executar ferramenta MCP %s", name)
|
|
248
|
+
return {"jsonrpc": "2.0", "id": request_id, "result": _tool_result("Erro interno ao executar a ferramenta.", is_error=True)}
|
|
249
|
+
return _rpc_error(request_id, -32601, f"Método não suportado: {method}")
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
class _HTTPServer(ThreadingHTTPServer):
|
|
253
|
+
daemon_threads = True
|
|
254
|
+
allow_reuse_address = True
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
class _RequestHandler(BaseHTTPRequestHandler):
|
|
258
|
+
server: _HTTPServer
|
|
259
|
+
|
|
260
|
+
def log_message(self, format: str, *args: Any) -> None:
|
|
261
|
+
LOGGER.info("MCP HTTP %s", format % args)
|
|
262
|
+
|
|
263
|
+
def _send_json(self, payload: Any, status: int = HTTPStatus.OK) -> None:
|
|
264
|
+
body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
|
|
265
|
+
self.send_response(status)
|
|
266
|
+
self.send_header("Content-Type", "application/json; charset=utf-8")
|
|
267
|
+
self.send_header("Content-Length", str(len(body)))
|
|
268
|
+
self.send_header("Cache-Control", "no-store")
|
|
269
|
+
self.end_headers()
|
|
270
|
+
self.wfile.write(body)
|
|
271
|
+
|
|
272
|
+
def _authorized(self) -> bool:
|
|
273
|
+
token = self.server.dispatcher.auth_token
|
|
274
|
+
if not token:
|
|
275
|
+
return True
|
|
276
|
+
return self.headers.get("Authorization", "") == f"Bearer {token}" or self.headers.get("X-Thunderbolt-MCP-Token", "") == token
|
|
277
|
+
|
|
278
|
+
def do_GET(self) -> None:
|
|
279
|
+
path = urlparse(self.path).path
|
|
280
|
+
if path == MCP_HEALTH_PATH:
|
|
281
|
+
self._send_json({"ok": True, "server": "Thunderbolt MCP Server", "endpoint": MCP_ENDPOINT_PATH, "version": _version()})
|
|
282
|
+
return
|
|
283
|
+
if path == MCP_ENDPOINT_PATH:
|
|
284
|
+
self.send_response(HTTPStatus.METHOD_NOT_ALLOWED)
|
|
285
|
+
self.send_header("Allow", "POST")
|
|
286
|
+
self.end_headers()
|
|
287
|
+
return
|
|
288
|
+
self._send_json({"error": "Not found"}, HTTPStatus.NOT_FOUND)
|
|
289
|
+
|
|
290
|
+
def do_POST(self) -> None:
|
|
291
|
+
path = urlparse(self.path).path
|
|
292
|
+
if path != MCP_ENDPOINT_PATH:
|
|
293
|
+
self._send_json({"error": "Not found"}, HTTPStatus.NOT_FOUND)
|
|
294
|
+
return
|
|
295
|
+
if not self._authorized():
|
|
296
|
+
self._send_json({"error": "Unauthorized"}, HTTPStatus.UNAUTHORIZED)
|
|
297
|
+
return
|
|
298
|
+
try:
|
|
299
|
+
length = int(self.headers.get("Content-Length", "0"))
|
|
300
|
+
if length < 1 or length > 1_000_000:
|
|
301
|
+
raise ValueError("Corpo JSON inválido ou demasiado grande.")
|
|
302
|
+
payload = json.loads(self.rfile.read(length).decode("utf-8"))
|
|
303
|
+
except (ValueError, json.JSONDecodeError, UnicodeDecodeError) as exc:
|
|
304
|
+
self._send_json(_rpc_error(None, -32700, f"JSON inválido: {exc}"), HTTPStatus.BAD_REQUEST)
|
|
305
|
+
return
|
|
306
|
+
if isinstance(payload, list):
|
|
307
|
+
responses = [self.server.dispatcher.handle(item) for item in payload if isinstance(item, dict)]
|
|
308
|
+
responses = [item for item in responses if item is not None]
|
|
309
|
+
if responses:
|
|
310
|
+
self._send_json(responses)
|
|
311
|
+
else:
|
|
312
|
+
self.send_response(HTTPStatus.ACCEPTED)
|
|
313
|
+
self.end_headers()
|
|
314
|
+
return
|
|
315
|
+
if not isinstance(payload, dict):
|
|
316
|
+
self._send_json(_rpc_error(None, -32600, "O pedido MCP deve ser um objecto JSON-RPC."), HTTPStatus.BAD_REQUEST)
|
|
317
|
+
return
|
|
318
|
+
response = self.server.dispatcher.handle(payload)
|
|
319
|
+
if response is None:
|
|
320
|
+
self.send_response(HTTPStatus.ACCEPTED)
|
|
321
|
+
self.end_headers()
|
|
322
|
+
return
|
|
323
|
+
self._send_json(response)
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
class _Runtime:
|
|
327
|
+
def __init__(self) -> None:
|
|
328
|
+
self.lock = threading.RLock()
|
|
329
|
+
self.http_server: _HTTPServer | None = None
|
|
330
|
+
self.thread: threading.Thread | None = None
|
|
331
|
+
self.host = ""
|
|
332
|
+
self.port = 0
|
|
333
|
+
self.write_enabled = False
|
|
334
|
+
|
|
335
|
+
def start(self, host: str, port: int, auth_token: str = "", write_enabled: bool = False) -> dict[str, Any]:
|
|
336
|
+
host = (host or DEFAULT_HOST).strip()
|
|
337
|
+
port = int(port)
|
|
338
|
+
if not 1 <= port <= 65535:
|
|
339
|
+
raise ValueError("A porta MCP deve estar entre 1 e 65535.")
|
|
340
|
+
if host not in {"127.0.0.1", "localhost", "::1"} and not auth_token.strip():
|
|
341
|
+
raise ValueError("Defina um token MCP antes de expor o servidor fora do computador local.")
|
|
342
|
+
with self.lock:
|
|
343
|
+
if self.http_server and self.thread and self.thread.is_alive() and (self.host, self.port) == (host, port):
|
|
344
|
+
self.write_enabled = bool(write_enabled)
|
|
345
|
+
self.http_server.dispatcher.write_enabled = bool(write_enabled)
|
|
346
|
+
self.http_server.dispatcher.auth_token = auth_token.strip()
|
|
347
|
+
return self.status()
|
|
348
|
+
self.stop()
|
|
349
|
+
server = _HTTPServer((host, port), _RequestHandler)
|
|
350
|
+
server.dispatcher = _MCPDispatcher(auth_token, write_enabled)
|
|
351
|
+
thread = threading.Thread(target=server.serve_forever, name="thunderbolt-mcp", daemon=True)
|
|
352
|
+
thread.start()
|
|
353
|
+
self.http_server = server
|
|
354
|
+
self.thread = thread
|
|
355
|
+
self.host = host
|
|
356
|
+
self.port = port
|
|
357
|
+
self.write_enabled = bool(write_enabled)
|
|
358
|
+
return self.status()
|
|
359
|
+
|
|
360
|
+
def stop(self) -> None:
|
|
361
|
+
with self.lock:
|
|
362
|
+
if self.http_server:
|
|
363
|
+
self.http_server.shutdown()
|
|
364
|
+
self.http_server.server_close()
|
|
365
|
+
self.http_server = None
|
|
366
|
+
self.thread = None
|
|
367
|
+
self.host = ""
|
|
368
|
+
self.port = 0
|
|
369
|
+
self.write_enabled = False
|
|
370
|
+
|
|
371
|
+
def status(self) -> dict[str, Any]:
|
|
372
|
+
running = bool(self.http_server and self.thread and self.thread.is_alive())
|
|
373
|
+
return {
|
|
374
|
+
"running": running,
|
|
375
|
+
"host": self.host if running else "",
|
|
376
|
+
"port": self.port if running else 0,
|
|
377
|
+
"endpoint": f"http://{self.host}:{self.port}{MCP_ENDPOINT_PATH}" if running else "",
|
|
378
|
+
"health_endpoint": f"http://{self.host}:{self.port}{MCP_HEALTH_PATH}" if running else "",
|
|
379
|
+
"write_enabled": self.write_enabled if running else False,
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
_RUNTIME = _Runtime()
|
|
384
|
+
atexit.register(_RUNTIME.stop)
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
def start_server(host: str = DEFAULT_HOST, port: int = DEFAULT_PORT, auth_token: str = "", write_enabled: bool = False) -> dict[str, Any]:
|
|
388
|
+
return _RUNTIME.start(host, port, auth_token, write_enabled)
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
def stop_server() -> None:
|
|
392
|
+
_RUNTIME.stop()
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
def server_status() -> dict[str, Any]:
|
|
396
|
+
return _RUNTIME.status()
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
def tool_definitions(write_enabled: bool = False) -> list[dict[str, Any]]:
|
|
400
|
+
return _tool_definitions(write_enabled)
|
package/hermes_ui/storage.py
CHANGED
|
@@ -21,6 +21,13 @@ DEFAULTS: dict[str, Any] = {
|
|
|
21
21
|
"batches.json": [],
|
|
22
22
|
"uploads.json": [],
|
|
23
23
|
"metadata_edits.json": [],
|
|
24
|
+
"mcp_server.json": {
|
|
25
|
+
"enabled": False,
|
|
26
|
+
"host": "127.0.0.1",
|
|
27
|
+
"port": 3031,
|
|
28
|
+
"auth_token": "",
|
|
29
|
+
"write_enabled": False,
|
|
30
|
+
},
|
|
24
31
|
"mcp_integrations.json": [
|
|
25
32
|
{
|
|
26
33
|
"id": "short-video-maker",
|
package/package.json
CHANGED