@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.
@@ -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
- direct = str(payload.get("video_url") or payload.get("url") or payload.get("output") or "").strip()
324
- if direct:
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."},
@@ -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 / "skills", STORAGE / "metadata_cleaner", STORAGE / "metadata_cleaner" / "outputs", STORAGE / "music", STORAGE / "voice_previews", STORAGE / "python_editor", NICHES_DATA]:
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.51",
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
@@ -16,3 +16,4 @@ ytmusicapi>=1.12,<2
16
16
  pushtunes>=2.15,<3
17
17
  matplotlib>=3.7,<4
18
18
  kagglehub>=0.3,<1
19
+ supabase>=2.4,<3
@@ -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
  ![Upload files](https://github.com/user-attachments/assets/6cc83262-b92c-4f74-86c3-a45c9c0456f8)
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)