@danhachuel/thunderbolt 0.3.51 → 0.3.53
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 +21 -5
- package/README.md +15 -4
- package/THIRD-PARTY-NOTICES.md +4 -0
- package/app/influencers_ui.py +400 -0
- package/app/main.py +100 -10
- package/hermes_ui/api_key_tests.py +10 -0
- package/hermes_ui/influencers.py +569 -0
- package/hermes_ui/languages.py +5 -0
- package/hermes_ui/media_generation.py +75 -3
- package/hermes_ui/media_providers.py +10 -0
- package/hermes_ui/notifications.py +2 -0
- package/hermes_ui/storage.py +7 -1
- package/package.json +3 -1
- package/requirements.txt +1 -0
- package/seed/references/ai_influencers_schema.sql +69 -0
- package/seed/references/guide-supabase.md +9 -1
|
@@ -116,6 +116,17 @@ def _image_value(payload: Any) -> tuple[bytes | None, str]:
|
|
|
116
116
|
url = str(first.get("url") or first.get("image_url") or "").strip()
|
|
117
117
|
if url:
|
|
118
118
|
return None, url
|
|
119
|
+
output = payload.get("output")
|
|
120
|
+
if isinstance(output, list) and output:
|
|
121
|
+
output = output[0]
|
|
122
|
+
if isinstance(output, Mapping):
|
|
123
|
+
output = output.get("url") or output.get("image_url") or output.get("image")
|
|
124
|
+
if isinstance(output, str):
|
|
125
|
+
if output.startswith(("http://", "https://")):
|
|
126
|
+
return None, output
|
|
127
|
+
output_data = _decode_data(output)
|
|
128
|
+
if output_data:
|
|
129
|
+
return output_data, ""
|
|
119
130
|
result = payload.get("result")
|
|
120
131
|
if isinstance(result, Mapping):
|
|
121
132
|
data = _decode_data(result.get("image") or result.get("b64_json") or result.get("data"))
|
|
@@ -127,6 +138,44 @@ def _image_value(payload: Any) -> tuple[bytes | None, str]:
|
|
|
127
138
|
return None, ""
|
|
128
139
|
|
|
129
140
|
|
|
141
|
+
def _image_request_id(payload: Any) -> str:
|
|
142
|
+
if not isinstance(payload, Mapping):
|
|
143
|
+
return ""
|
|
144
|
+
for key in ("id", "prediction_id", "request_id", "task_id", "job_id"):
|
|
145
|
+
value = str(payload.get(key) or "").strip()
|
|
146
|
+
if value:
|
|
147
|
+
return value
|
|
148
|
+
return ""
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _poll_image(card: Mapping[str, Any], request_id: str, *, attempts: int = 24, interval_seconds: float = 5.0) -> tuple[bytes | None, str]:
|
|
152
|
+
"""Resolve a Replicate image prediction without exposing its token."""
|
|
153
|
+
style = str(card.get("api_style") or media_provider_definition(card.get("provider")).api_style)
|
|
154
|
+
base = _base_url(card)
|
|
155
|
+
endpoint = str(card.get("status_endpoint") or "").strip().replace("{id}", request_id)
|
|
156
|
+
if not endpoint:
|
|
157
|
+
if style != "replicate":
|
|
158
|
+
return None, ""
|
|
159
|
+
endpoint = f"{base}/predictions/{request_id}"
|
|
160
|
+
for index in range(max(1, attempts)):
|
|
161
|
+
try:
|
|
162
|
+
response = requests.get(endpoint, headers=_headers(card), timeout=60)
|
|
163
|
+
if response.status_code >= 400:
|
|
164
|
+
raise MediaGenerationError(f"Consulta de imagem devolveu HTTP {response.status_code}.")
|
|
165
|
+
payload = response.json()
|
|
166
|
+
except requests.RequestException as exc:
|
|
167
|
+
raise MediaGenerationError(f"Falha ao consultar a imagem: {str(exc)[:180]}") from exc
|
|
168
|
+
image_bytes, url = _image_value(payload if isinstance(payload, Mapping) else {})
|
|
169
|
+
if image_bytes or url:
|
|
170
|
+
return image_bytes, url
|
|
171
|
+
status = str((payload or {}).get("status") or "").lower() if isinstance(payload, Mapping) else ""
|
|
172
|
+
if status in {"failed", "error", "cancelled", "canceled"}:
|
|
173
|
+
raise MediaGenerationError("O provider marcou a tarefa de imagem como falhada.")
|
|
174
|
+
if index + 1 < attempts:
|
|
175
|
+
time.sleep(max(0.2, interval_seconds))
|
|
176
|
+
raise MediaGenerationError("O provider de imagem não concluiu dentro do limite de polling.")
|
|
177
|
+
|
|
178
|
+
|
|
130
179
|
def _download_or_write(image_bytes: bytes | None, url: str, destination: Path, card: Mapping[str, Any]) -> Path:
|
|
131
180
|
if image_bytes is None and url:
|
|
132
181
|
try:
|
|
@@ -151,6 +200,8 @@ def _image_endpoint(card: Mapping[str, Any]) -> str:
|
|
|
151
200
|
return explicit
|
|
152
201
|
if style in {"openai_compatible", "huggingface", "agnes", "kie"}:
|
|
153
202
|
return f"{base}/images/generations"
|
|
203
|
+
if style == "replicate":
|
|
204
|
+
return f"{base}/predictions"
|
|
154
205
|
if style == "cloudflare":
|
|
155
206
|
account_id = str(card.get("account_id") or "").strip()
|
|
156
207
|
if not account_id:
|
|
@@ -184,6 +235,9 @@ def _image_request(card: dict[str, Any], prompt: str) -> Any:
|
|
|
184
235
|
if style == "dashscope":
|
|
185
236
|
body = {"model": _model(card), "input": {"prompt": constrained_prompt}, "parameters": {"n": 1}}
|
|
186
237
|
return requests.post(endpoint, headers=_headers(card), json=body, timeout=180)
|
|
238
|
+
if style == "replicate":
|
|
239
|
+
body = {"version": _model(card), "input": {"prompt": constrained_prompt}}
|
|
240
|
+
return requests.post(endpoint, headers=_headers(card), json=body, timeout=180)
|
|
187
241
|
body = {"model": _model(card), "prompt": constrained_prompt, "n": 1, "response_format": "b64_json"}
|
|
188
242
|
return requests.post(endpoint, headers=_headers(card), json=body, timeout=180)
|
|
189
243
|
|
|
@@ -237,6 +291,11 @@ def generate_image_for_card(
|
|
|
237
291
|
except ProviderRoutingError as exc:
|
|
238
292
|
raise MediaGenerationError(str(exc)) from exc
|
|
239
293
|
image_bytes, url = _image_value(routed.payload)
|
|
294
|
+
if not image_bytes and not url:
|
|
295
|
+
request_id = _image_request_id(routed.payload)
|
|
296
|
+
style = str(routed.card.get("api_style") or media_provider_definition(routed.card.get("provider")).api_style)
|
|
297
|
+
if request_id and style == "replicate":
|
|
298
|
+
image_bytes, url = _poll_image(routed.card, request_id)
|
|
240
299
|
return _download_or_write(image_bytes, url, destination, routed.card)
|
|
241
300
|
|
|
242
301
|
|
|
@@ -294,6 +353,8 @@ def _video_endpoint(card: Mapping[str, Any]) -> str:
|
|
|
294
353
|
return f"{base}/{_model(card).lstrip('/')}"
|
|
295
354
|
if style in {"openai_compatible", "agnes", "kie"}:
|
|
296
355
|
return f"{base}/videos/generations"
|
|
356
|
+
if style == "replicate":
|
|
357
|
+
return f"{base}/predictions"
|
|
297
358
|
if style == "dashscope":
|
|
298
359
|
return f"{base}/services/aigc/video-generation/video-synthesis"
|
|
299
360
|
raise MediaGenerationError(f"O provider {card.get('provider')} não tem endpoint de vídeo configurado.")
|
|
@@ -316,12 +377,21 @@ def _video_request(card: dict[str, Any], prompt: str, image_url: str = "") -> An
|
|
|
316
377
|
if style == "fal_queue":
|
|
317
378
|
body.pop("model", None)
|
|
318
379
|
return requests.post(endpoint, headers=_headers(card, fal=True), json=body, timeout=180)
|
|
380
|
+
if style == "replicate":
|
|
381
|
+
image_input_key = str(card.get("image_input_key") or "image").strip() or "image"
|
|
382
|
+
input_payload = {"prompt": body["prompt"]}
|
|
383
|
+
if image_url:
|
|
384
|
+
input_payload[image_input_key] = image_url
|
|
385
|
+
return requests.post(endpoint, headers=_headers(card), json={"version": _model(card), "input": input_payload}, timeout=180)
|
|
319
386
|
return requests.post(endpoint, headers=_headers(card), json=body, timeout=180)
|
|
320
387
|
|
|
321
388
|
|
|
322
389
|
def _video_result(payload: Mapping[str, Any]) -> tuple[str, str]:
|
|
323
|
-
|
|
324
|
-
if
|
|
390
|
+
raw_output = payload.get("output")
|
|
391
|
+
if isinstance(raw_output, list) and raw_output:
|
|
392
|
+
raw_output = raw_output[0]
|
|
393
|
+
direct = str(payload.get("video_url") or payload.get("url") or raw_output or "").strip()
|
|
394
|
+
if direct.startswith(("http://", "https://")):
|
|
325
395
|
return direct, ""
|
|
326
396
|
result = payload.get("result")
|
|
327
397
|
if isinstance(result, Mapping):
|
|
@@ -350,6 +420,8 @@ def _poll_video(card: Mapping[str, Any], request_id: str, *, attempts: int = 24,
|
|
|
350
420
|
endpoint = explicit_status.replace("{id}", request_id)
|
|
351
421
|
elif style == "fal_queue":
|
|
352
422
|
endpoint = f"{base}/requests/{request_id}/status"
|
|
423
|
+
elif style == "replicate":
|
|
424
|
+
endpoint = f"{base}/predictions/{request_id}"
|
|
353
425
|
else:
|
|
354
426
|
endpoint = f"{base}/videos/{request_id}"
|
|
355
427
|
headers = _headers(card, fal=style == "fal_queue")
|
|
@@ -366,7 +438,7 @@ def _poll_video(card: Mapping[str, Any], request_id: str, *, attempts: int = 24,
|
|
|
366
438
|
if url:
|
|
367
439
|
return url
|
|
368
440
|
status = str((payload or {}).get("status") or "").lower() if isinstance(payload, Mapping) else ""
|
|
369
|
-
if status in {"failed", "error", "cancelled"}:
|
|
441
|
+
if status in {"failed", "error", "cancelled", "canceled"}:
|
|
370
442
|
raise ProviderCallError("O provider marcou a tarefa de vídeo como falhada.", category="provider", retryable=False)
|
|
371
443
|
if index + 1 < attempts:
|
|
372
444
|
time.sleep(max(0.2, interval_seconds))
|
|
@@ -53,6 +53,16 @@ MEDIA_PROVIDER_CATALOG: tuple[MediaProviderDefinition, ...] = (
|
|
|
53
53
|
api_style="openai_compatible",
|
|
54
54
|
description="Gateway multimodal com endpoints compatíveis e catálogo próprio.",
|
|
55
55
|
),
|
|
56
|
+
MediaProviderDefinition(
|
|
57
|
+
"replicate",
|
|
58
|
+
"Replicate",
|
|
59
|
+
default_base_url="https://api.replicate.com/v1",
|
|
60
|
+
supports_image=True,
|
|
61
|
+
supports_video=True,
|
|
62
|
+
supports_text=False,
|
|
63
|
+
api_style="replicate",
|
|
64
|
+
description="Predictions assíncronas; o campo Modelo aceita model ou model:version conforme a Replicate.",
|
|
65
|
+
),
|
|
56
66
|
MediaProviderDefinition(
|
|
57
67
|
"agnes",
|
|
58
68
|
"Agnes AI",
|
|
@@ -21,6 +21,8 @@ EVENT_CATALOG: tuple[dict[str, str], ...] = (
|
|
|
21
21
|
{"code": "script_stage_completed", "category": "Pipeline", "label": "Etapa de roteiro concluída", "description": "Quando a etapa de roteiro de uma tarefa terminar."},
|
|
22
22
|
{"code": "title_generation_completed", "category": "Pipeline", "label": "Títulos gerados", "description": "Quando o pacote de títulos terminar de ser gerado."},
|
|
23
23
|
{"code": "thumbnail_generation_completed", "category": "Pipeline", "label": "Thumbnail gerada", "description": "Quando a imagem final da thumbnail for criada."},
|
|
24
|
+
{"code": "influencer_content_completed", "category": "AI Influencers", "label": "Conteúdo de Influencer concluído", "description": "Quando uma imagem ou vídeo de AI Influencers terminar de ser gerado."},
|
|
25
|
+
{"code": "influencer_content_failed", "category": "AI Influencers", "label": "Conteúdo de Influencer falhou", "description": "Quando uma imagem ou vídeo de AI Influencers terminar com erro."},
|
|
24
26
|
{"code": "blueprint_completed", "category": "Pipeline", "label": "Blueprint criado ou importado", "description": "Quando um Blueprint for criado, importado ou guardado."},
|
|
25
27
|
{"code": "branding_completed", "category": "Pipeline", "label": "Branding criado ou importado", "description": "Quando um Branding for criado, importado ou guardado."},
|
|
26
28
|
{"code": "niche_analysis_completed", "category": "Pipeline", "label": "Análise de nicho concluída", "description": "Quando uma análise Kaggle ou Apify terminar com resultados."},
|
package/hermes_ui/storage.py
CHANGED
|
@@ -230,6 +230,12 @@ DEFAULTS: dict[str, Any] = {
|
|
|
230
230
|
"pollinations_api_key": "",
|
|
231
231
|
"pollinations_base_url": "",
|
|
232
232
|
"pollinations_model_name": "",
|
|
233
|
+
"influencer_db_backend": "Supabase",
|
|
234
|
+
"influencer_supabase_url": "",
|
|
235
|
+
"influencer_supabase_key": "",
|
|
236
|
+
"influencer_supabase_bucket": "ai-influencers",
|
|
237
|
+
"influencer_sqlite_path": "storage/state/ai_influencers.db",
|
|
238
|
+
"influencer_schema_version": 1,
|
|
233
239
|
"log_level": "DEBUG",
|
|
234
240
|
"listen_host": "127.0.0.1",
|
|
235
241
|
"listen_port": 8080,
|
|
@@ -383,7 +389,7 @@ def _migrate_settings(settings: Any) -> tuple[dict[str, Any], bool]:
|
|
|
383
389
|
|
|
384
390
|
|
|
385
391
|
def ensure_storage() -> None:
|
|
386
|
-
for path in [STATE, BLUEPRINTS / "canais", BLUEPRINTS / "nichos", BLUEPRINTS / "importados", BLUEPRINTS / "brandings", TIKTOK_PROMPT_MASTERS, MEDIA_DOWNLOADS, STORAGE / "brand", STORAGE / "scripts", STORAGE / "thumbnails", STORAGE / "videos", STORAGE / "artifacts", STORAGE / "
|
|
392
|
+
for path in [STATE, BLUEPRINTS / "canais", BLUEPRINTS / "nichos", BLUEPRINTS / "importados", BLUEPRINTS / "brandings", TIKTOK_PROMPT_MASTERS, MEDIA_DOWNLOADS, STORAGE / "brand", STORAGE / "scripts", STORAGE / "thumbnails", STORAGE / "videos", STORAGE / "artifacts", STORAGE / "python_editor", STORAGE / "influencers", STORAGE / "metadata_cleaner", STORAGE / "metadata_cleaner" / "outputs", STORAGE / "music", STORAGE / "voice_previews", STORAGE / "python_editor", NICHES_DATA]:
|
|
387
393
|
path.mkdir(parents=True, exist_ok=True)
|
|
388
394
|
seed_blueprints()
|
|
389
395
|
seed_prompt_masters()
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@danhachuel/thunderbolt",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.53",
|
|
4
4
|
"description": "Thunderbolt — interface local para operação de canais faceless e motor MoneyPrinterTurbo",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"main": "scripts/cli.mjs",
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
},
|
|
11
11
|
"files": [
|
|
12
12
|
"app/main.py",
|
|
13
|
+
"app/influencers_ui.py",
|
|
13
14
|
"app/modules/**/*.py",
|
|
14
15
|
"hermes_ui/*.py",
|
|
15
16
|
"integrations/*.py",
|
|
@@ -22,6 +23,7 @@
|
|
|
22
23
|
"seed/skills/*.md",
|
|
23
24
|
"seed/skills/*.py",
|
|
24
25
|
"seed/references/*.md",
|
|
26
|
+
"seed/references/*.sql",
|
|
25
27
|
"README.md",
|
|
26
28
|
"MANUAL-INSTALACAO.md",
|
|
27
29
|
"THIRD-PARTY-NOTICES.md",
|
package/requirements.txt
CHANGED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
-- Thunderbolt AI Influencers schema
|
|
2
|
+
-- Apply in Supabase SQL Editor. Storage bucket creation is optional and may be
|
|
3
|
+
-- performed from the dashboard with the configured bucket name.
|
|
4
|
+
|
|
5
|
+
create table if not exists public.influencers (
|
|
6
|
+
id text primary key,
|
|
7
|
+
name text not null,
|
|
8
|
+
bio text not null default '',
|
|
9
|
+
instagram_business_id text not null default '',
|
|
10
|
+
language text not null default '',
|
|
11
|
+
profile_json jsonb not null default '{}'::jsonb,
|
|
12
|
+
created_at timestamptz not null default now(),
|
|
13
|
+
updated_at timestamptz not null default now()
|
|
14
|
+
);
|
|
15
|
+
|
|
16
|
+
create table if not exists public.influencer_assets (
|
|
17
|
+
id text primary key,
|
|
18
|
+
influencer_id text not null references public.influencers(id) on delete cascade,
|
|
19
|
+
asset_type text not null check (asset_type in ('image', 'document')),
|
|
20
|
+
original_name text not null,
|
|
21
|
+
stored_path text not null,
|
|
22
|
+
public_url text not null default '',
|
|
23
|
+
mime_type text not null default 'application/octet-stream',
|
|
24
|
+
size_bytes bigint not null default 0,
|
|
25
|
+
sha256 text not null,
|
|
26
|
+
document_json jsonb,
|
|
27
|
+
created_at timestamptz not null default now(),
|
|
28
|
+
unique (influencer_id, sha256)
|
|
29
|
+
);
|
|
30
|
+
create index if not exists idx_influencer_assets_influencer on public.influencer_assets(influencer_id, created_at);
|
|
31
|
+
|
|
32
|
+
create table if not exists public.influencer_weekly_plans (
|
|
33
|
+
id text primary key,
|
|
34
|
+
influencer_id text not null references public.influencers(id) on delete cascade,
|
|
35
|
+
week text not null,
|
|
36
|
+
plan text not null default '',
|
|
37
|
+
created_at timestamptz not null default now(),
|
|
38
|
+
updated_at timestamptz not null default now(),
|
|
39
|
+
unique (influencer_id, week)
|
|
40
|
+
);
|
|
41
|
+
|
|
42
|
+
create table if not exists public.influencer_content (
|
|
43
|
+
id text primary key,
|
|
44
|
+
influencer_id text not null references public.influencers(id) on delete cascade,
|
|
45
|
+
content_type text not null check (content_type in ('image', 'video')),
|
|
46
|
+
prompt text not null default '',
|
|
47
|
+
caption text not null default '',
|
|
48
|
+
provider text not null default '',
|
|
49
|
+
model text not null default '',
|
|
50
|
+
platform text not null default '',
|
|
51
|
+
state text not null default 'queued',
|
|
52
|
+
artifact_path text not null default '',
|
|
53
|
+
provider_request_id text not null default '',
|
|
54
|
+
error text not null default '',
|
|
55
|
+
metadata_json jsonb not null default '{}'::jsonb,
|
|
56
|
+
created_at timestamptz not null default now(),
|
|
57
|
+
updated_at timestamptz not null default now()
|
|
58
|
+
);
|
|
59
|
+
create index if not exists idx_influencer_content_influencer on public.influencer_content(influencer_id, created_at);
|
|
60
|
+
create index if not exists idx_influencer_content_state on public.influencer_content(state, updated_at);
|
|
61
|
+
|
|
62
|
+
alter table public.influencers enable row level security;
|
|
63
|
+
alter table public.influencer_assets enable row level security;
|
|
64
|
+
alter table public.influencer_weekly_plans enable row level security;
|
|
65
|
+
alter table public.influencer_content enable row level security;
|
|
66
|
+
|
|
67
|
+
-- Configure least-privilege policies for the key used by Thunderbolt in your
|
|
68
|
+
-- deployment. Do not make these tables public unless that is intentional.
|
|
69
|
+
-- Example for a trusted local service_role key is deliberately omitted here.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Guia para criar e configurar uma conta Supabase
|
|
2
2
|
|
|
3
|
-
Este guia mostra como criar uma conta gratuita no Supabase e preparar as tabelas e o armazenamento necessários para utilizar a base de dados com automações n8n.
|
|
3
|
+
Este guia mostra como criar uma conta gratuita no Supabase e preparar as tabelas e o armazenamento necessários para utilizar a base de dados com automações n8n e com o módulo AI Influencers do Thunderbolt.
|
|
4
4
|
|
|
5
5
|
Comece por abrir o [Supabase](https://supabase.com/) e criar uma nova conta gratuita.
|
|
6
6
|
|
|
@@ -56,6 +56,14 @@ Depois, carregue a imagem de referência para o bucket usando o botão `Upload f
|
|
|
56
56
|
|
|
57
57
|

|
|
58
58
|
|
|
59
|
+
## 3. AI Influencers no Thunderbolt
|
|
60
|
+
|
|
61
|
+
Para usar **AI Influencers > Personagens** e **Geração de Conteúdo IA** com Supabase, aplique o ficheiro `seed/references/ai_influencers_schema.sql` no SQL Editor. Ele cria as tabelas `influencers`, `influencer_assets`, `influencer_weekly_plans` e `influencer_content`, além dos índices e da activação de RLS.
|
|
62
|
+
|
|
63
|
+
No Thunderbolt, abra **Configurações > Configuração API > API Keys > Banco de Dados Influencers**, seleccione **Supabase** e preencha o **Supabase Project URL**, a **Supabase API key** e o bucket de Storage. Crie o bucket com o mesmo nome configurado, por defeito `ai-influencers`, e confirme as políticas RLS/Storage antes de guardar imagens ou documentos. A chave não deve ser colocada no GitHub, nos workflows JSON ou em screenshots.
|
|
64
|
+
|
|
65
|
+
O Thunderbolt usa o Supabase como backend seleccionado, não como executor de n8n: os assets são enviados ao Storage, os metadados ficam nas tabelas e os estados de geração ficam em `influencer_content`. Para uma execução totalmente local, seleccione **SQLite** no mesmo expander; a alternativa cria `storage/state/ai_influencers.db` e não utiliza a conta Supabase.
|
|
66
|
+
|
|
59
67
|
A conta Supabase está pronta para ser utilizada pela automação.
|
|
60
68
|
|
|
61
69
|
Fonte original: [guide-supabase.md no GitHub](https://github.com/gyoridavid/ai_agents_az/blob/main/episode_8/guide-supabase.md)
|