strapi-plugin-mcp-chat 0.5.0 → 0.7.0

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.
@@ -32,6 +32,44 @@ export interface RunInfo {
32
32
  let info: RunInfo = { state: 'idle', dir: null, url: null, pm: null, error: null, log: [] };
33
33
  let child: ChildProcess | null = null;
34
34
  let pollTimer: ReturnType<typeof setInterval> | null = null;
35
+ let appRootForPid: string | null = null;
36
+
37
+ // ── pidfile: rastreia o PID do dev server p/ matá-lo no shutdown e limpar
38
+ // órfãos no boot (ex.: após um crash/SIGKILL onde o destroy não rodou). ──
39
+ function pidFilePath(appRoot: string): string {
40
+ return path.join(appRoot, '.mcp-chat', 'frontend.pid');
41
+ }
42
+ function writePid(pid: number): void {
43
+ try {
44
+ if (!appRootForPid) return;
45
+ const pf = pidFilePath(appRootForPid);
46
+ fs.mkdirSync(path.dirname(pf), { recursive: true });
47
+ fs.writeFileSync(pf, String(pid), 'utf8');
48
+ } catch { /* best-effort */ }
49
+ }
50
+ function clearPid(): void {
51
+ try {
52
+ if (appRootForPid) fs.unlinkSync(pidFilePath(appRootForPid));
53
+ } catch { /* não existia */ }
54
+ }
55
+ function isAlive(pid: number): boolean {
56
+ try { process.kill(pid, 0); return true; } catch { return false; }
57
+ }
58
+ /**
59
+ * Mata um dev server órfão deixado por uma execução anterior (crash/SIGKILL não
60
+ * roda o destroy()). Chamado no bootstrap do plugin — garante "limpar ao
61
+ * desligar" mesmo quando o shutdown não foi limpo, e evita o EADDRINUSE.
62
+ */
63
+ export function cleanupStaleFrontend(appRoot: string): void {
64
+ appRootForPid = appRoot;
65
+ try {
66
+ const pf = pidFilePath(appRoot);
67
+ if (!fs.existsSync(pf)) return;
68
+ const pid = parseInt(fs.readFileSync(pf, 'utf8').trim(), 10);
69
+ if (pid && isAlive(pid)) { try { process.kill(pid, 'SIGTERM'); } catch { /* já morto */ } }
70
+ fs.unlinkSync(pf);
71
+ } catch { /* best-effort */ }
72
+ }
35
73
 
36
74
  function detectPM(dir: string): string {
37
75
  if (fs.existsSync(path.join(dir, 'bun.lockb')) || fs.existsSync(path.join(dir, 'bun.lock'))) return 'bun';
@@ -55,7 +93,7 @@ function detectFramework(dir: string): 'vite' | 'next' | 'other' {
55
93
  * - 3000 (Next default) e 1337 (Strapi).
56
94
  * Evita colisão com o admin do Strapi (que responde 426 a requests não-WS).
57
95
  */
58
- const FRONTEND_BASE_PORT = 4321;
96
+ export const FRONTEND_BASE_PORT = 4321;
59
97
 
60
98
  /** Acha uma porta TCP livre a partir de `start`, testando em 0.0.0.0 (pega
61
99
  * ocupações em `*:porta` de qualquer interface IPv4). */
@@ -101,11 +139,14 @@ export function stopFrontend(): void {
101
139
  try { child.kill('SIGTERM'); } catch { /* ignore */ }
102
140
  child = null;
103
141
  }
142
+ clearPid(); // limpa ao desligar — não deixa órfão
104
143
  if (info.state !== 'error') info.state = 'idle';
105
144
  }
106
145
 
107
146
  export async function startFrontend(_strapi: any, opts: { dir: string; url: string }): Promise<RunInfo> {
108
147
  const { dir } = opts;
148
+ // raiz da app p/ o pidfile (limpeza de órfãos no boot / shutdown).
149
+ if (_strapi?.dirs?.app?.root) appRootForPid = _strapi.dirs.app.root;
109
150
 
110
151
  // idempotente: já rodando/subindo para o mesmo dir
111
152
  if (child && info.dir === dir && ['installing', 'starting', 'running'].includes(info.state)) {
@@ -138,12 +179,14 @@ export async function startFrontend(_strapi: any, opts: { dir: string; url: stri
138
179
  const startDev = () => {
139
180
  info.state = 'starting';
140
181
  child = spawnIn(pm, devArgs);
182
+ if (child.pid) writePid(child.pid); // rastreia p/ limpar no shutdown/boot
141
183
  child.stdout?.on('data', (d) => pushLog(d));
142
184
  child.stderr?.on('data', (d) => pushLog(d));
143
185
  child.on('exit', (code) => {
144
186
  // processo morreu → frontend DOWN. Zera o child e libera o estado p/ que
145
187
  // apertar Preview de novo reinicie (em vez de ficar preso em "running").
146
188
  child = null;
189
+ clearPid();
147
190
  if (pollTimer) { clearInterval(pollTimer); pollTimer = null; }
148
191
  if (info.state === 'running') info.state = 'idle';
149
192
  else { info.state = 'error'; info.error = `dev encerrou (código ${code}). Veja o log.`; }
@@ -0,0 +1,393 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import type { Manifest } from './manifest';
4
+ import { apiUid } from './generate';
5
+
6
+ /**
7
+ * "Wire": liga o frontend à Strapi por FETCH AO VIVO (modelo live-fetch).
8
+ *
9
+ * Em vez do snapshot (integrate, que reescreve arquivos de dados), aqui:
10
+ * 1) Geramos uma camada de dados DETERMINÍSTICA e SEM dependências externas
11
+ * (só React): src/lib/strapi.ts + src/hooks/useStrapi.ts. REST puro, flat,
12
+ * sem populate/nesting — chamadas leves.
13
+ * 2) Religamos os componentes via IA, arquivo a arquivo, trocando o conteúdo
14
+ * hardcoded por leitura da Strapi COM fallback no texto original. Cada
15
+ * arquivo é salvo como .bak antes, e só é gravado se passar numa checagem de
16
+ * sanidade — se algo sair estranho, o arquivo é DEIXADO como estava. Assim o
17
+ * frontend nunca deixa de compilar por nossa causa.
18
+ *
19
+ * NUNCA toca na Strapi (só escreve no frontendDir, validado). No pior caso,
20
+ * algum componente fica sem religar (ainda hardcoded) — nada quebra.
21
+ */
22
+
23
+ const OPENAI_URL = 'https://api.openai.com/v1/chat/completions';
24
+ const MODEL = process.env.OPENAI_CHAT_MODEL || 'gpt-4o';
25
+
26
+ const SKIP_DIRS = new Set([
27
+ 'node_modules', '.git', 'dist', '.next', '.output', '.vinxi', '.tanstack',
28
+ 'build', 'coverage', '.turbo', '.cache', 'public',
29
+ ]);
30
+
31
+ export interface WireResult {
32
+ ok: boolean;
33
+ dataLayer: string[];
34
+ componentsWired: string[];
35
+ componentsSkipped: { rel: string; reason: string }[];
36
+ warnings: string[];
37
+ errors: string[];
38
+ }
39
+
40
+ // ---------------------------------------------------------------------------
41
+ // util
42
+ // ---------------------------------------------------------------------------
43
+
44
+ function ensureInside(base: string, target: string): boolean {
45
+ const b = path.resolve(base);
46
+ const t = path.resolve(target);
47
+ return t === b || t.startsWith(b + path.sep);
48
+ }
49
+
50
+ function isNext(manifest: Manifest): boolean {
51
+ return manifest.framework === 'next';
52
+ }
53
+
54
+ /** Detecta se o projeto usa o alias "@/..." (tsconfig/jsconfig paths). */
55
+ function usesAtAlias(frontendDir: string): boolean {
56
+ for (const f of ['tsconfig.json', 'tsconfig.app.json', 'jsconfig.json']) {
57
+ try {
58
+ const c = fs.readFileSync(path.join(frontendDir, f), 'utf8');
59
+ if (/"@\/\*"\s*:/.test(c)) return true;
60
+ } catch { /* ignore */ }
61
+ }
62
+ return false;
63
+ }
64
+
65
+ // ---------------------------------------------------------------------------
66
+ // 1) camada de dados (determinística, sem dependências além do React)
67
+ // ---------------------------------------------------------------------------
68
+
69
+ function strapiClientSrc(manifest: Manifest): string {
70
+ const envExpr = isNext(manifest)
71
+ ? "(typeof process !== 'undefined' ? process.env.NEXT_PUBLIC_STRAPI_URL : undefined)"
72
+ : "(import.meta as any).env?.VITE_STRAPI_URL";
73
+ return `// Gerado pelo mcp-chat — camada de acesso à Strapi (REST, flat, sem nesting).
74
+ // Não edite à mão: é regenerado ao religar o frontend.
75
+ export const STRAPI_URL =
76
+ (${envExpr} || "http://localhost:1337").replace(/\\/$/, "");
77
+
78
+ export type PreviewMode = { isPreview: boolean; status: "draft" | "published" };
79
+
80
+ /** Lê o modo de preview da URL (?preview=1 / ?status=draft). */
81
+ export function getPreviewMode(): PreviewMode {
82
+ if (typeof window === "undefined") return { isPreview: false, status: "published" };
83
+ const p = new URLSearchParams(window.location.search);
84
+ const status = p.get("status");
85
+ const isPreview = p.get("preview") === "1" || p.has("preview") || status === "draft";
86
+ return { isPreview, status: status === "draft" || isPreview ? "draft" : "published" };
87
+ }
88
+
89
+ /** Busca um singleType e devolve só os atributos (objeto). null em erro. */
90
+ export async function fetchSection<T = Record<string, any>>(
91
+ name: string,
92
+ status: "draft" | "published" = "published"
93
+ ): Promise<T | null> {
94
+ const qs = status === "draft" ? "?status=draft" : "";
95
+ try {
96
+ const res = await fetch(\`\${STRAPI_URL}/api/\${name}\${qs}\`);
97
+ if (!res.ok) return null;
98
+ const json = await res.json();
99
+ return (json?.data ?? null) as T | null;
100
+ } catch {
101
+ return null;
102
+ }
103
+ }
104
+
105
+ /** Busca uma collection (lista) pelo nome plural; ordena por sortOrder se existir. */
106
+ export async function fetchCollection<T = Record<string, any>>(
107
+ pluralName: string,
108
+ status: "draft" | "published" = "published"
109
+ ): Promise<T[]> {
110
+ const qs = new URLSearchParams({ "sort": "sortOrder:asc", "pagination[pageSize]": "100" });
111
+ if (status === "draft") qs.set("status", "draft");
112
+ try {
113
+ const res = await fetch(\`\${STRAPI_URL}/api/\${pluralName}?\${qs.toString()}\`);
114
+ if (!res.ok) return [];
115
+ const json = await res.json();
116
+ return (Array.isArray(json?.data) ? json.data : []) as T[];
117
+ } catch {
118
+ return [];
119
+ }
120
+ }
121
+ `;
122
+ }
123
+
124
+ function hooksSrc(manifest: Manifest, libImport: string): string {
125
+ const clientDirective = isNext(manifest) ? `"use client";\n\n` : '';
126
+ return `${clientDirective}// Gerado pelo mcp-chat — hooks de leitura da Strapi (sem dependências além do React).
127
+ // Em modo preview faz polling curto + refetch via postMessage (reflete edições ao vivo).
128
+ import { useEffect, useState } from "react";
129
+ import { fetchSection, fetchCollection, getPreviewMode } from "${libImport}";
130
+
131
+ export function useSection<T = Record<string, any>>(name: string): Partial<T> {
132
+ const [data, setData] = useState<Partial<T>>({});
133
+ useEffect(() => {
134
+ let alive = true;
135
+ const { isPreview, status } = getPreviewMode();
136
+ const load = () => fetchSection<T>(name, status).then((d) => { if (alive && d) setData(d as Partial<T>); });
137
+ load();
138
+ if (!isPreview) return () => { alive = false; };
139
+ const id = window.setInterval(load, 2500);
140
+ const onMsg = () => load();
141
+ window.addEventListener("message", onMsg);
142
+ return () => { alive = false; window.clearInterval(id); window.removeEventListener("message", onMsg); };
143
+ }, [name]);
144
+ return data;
145
+ }
146
+
147
+ export function useCollection<T = Record<string, any>>(pluralName: string): T[] {
148
+ const [data, setData] = useState<T[]>([]);
149
+ useEffect(() => {
150
+ let alive = true;
151
+ const { isPreview, status } = getPreviewMode();
152
+ const load = () => fetchCollection<T>(pluralName, status).then((d) => { if (alive) setData(d); });
153
+ load();
154
+ if (!isPreview) return () => { alive = false; };
155
+ const id = window.setInterval(load, 2500);
156
+ const onMsg = () => load();
157
+ window.addEventListener("message", onMsg);
158
+ return () => { alive = false; window.clearInterval(id); window.removeEventListener("message", onMsg); };
159
+ }, [pluralName]);
160
+ return data;
161
+ }
162
+ `;
163
+ }
164
+
165
+ /** Escreve a camada de dados. Determinístico e seguro (arquivos novos). */
166
+ function writeDataLayer(frontendDir: string, manifest: Manifest, dryRun?: boolean): string[] {
167
+ const written: string[] = [];
168
+ const libDir = path.join(frontendDir, 'src', 'lib');
169
+ const hooksDir = path.join(frontendDir, 'src', 'hooks');
170
+ const libFile = path.join(libDir, 'strapi.ts');
171
+ const hooksFile = path.join(hooksDir, 'useStrapi.ts');
172
+ if (!ensureInside(frontendDir, libFile) || !ensureInside(frontendDir, hooksFile)) {
173
+ throw new Error('camada de dados fora do frontendDir');
174
+ }
175
+ const libImport = usesAtAlias(frontendDir) ? '@/lib/strapi' : '../lib/strapi';
176
+ if (!dryRun) {
177
+ fs.mkdirSync(libDir, { recursive: true });
178
+ fs.mkdirSync(hooksDir, { recursive: true });
179
+ fs.writeFileSync(libFile, strapiClientSrc(manifest), 'utf8');
180
+ fs.writeFileSync(hooksFile, hooksSrc(manifest, libImport), 'utf8');
181
+ }
182
+ written.push('src/lib/strapi.ts', 'src/hooks/useStrapi.ts');
183
+ return written;
184
+ }
185
+
186
+ // ---------------------------------------------------------------------------
187
+ // 2) religação dos componentes (IA, com .bak + checagem de sanidade)
188
+ // ---------------------------------------------------------------------------
189
+
190
+ const CODE_EXT = new Set(['.tsx', '.jsx']);
191
+ const MAX_COMPONENT_CHARS = 16000;
192
+
193
+ function walkComponents(dir: string, base: string, out: string[]) {
194
+ let entries: fs.Dirent[];
195
+ try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; }
196
+ for (const e of entries) {
197
+ if (e.name.startsWith('.')) continue;
198
+ const full = path.join(dir, e.name);
199
+ if (e.isDirectory()) {
200
+ if (SKIP_DIRS.has(e.name) || e.name === 'ui') continue; // pula primitivos shadcn/ui
201
+ walkComponents(full, base, out);
202
+ } else if (CODE_EXT.has(path.extname(e.name))) {
203
+ out.push(path.relative(base, full));
204
+ }
205
+ }
206
+ }
207
+
208
+ /** pluralName REAL da content-type (Strapi sabe pluralizar); fallback +s. */
209
+ function resolvePlural(strapi: any, singularName: string): string {
210
+ const real = strapi?.contentTypes?.[apiUid(singularName)]?.info?.pluralName;
211
+ return real || `${singularName}s`;
212
+ }
213
+
214
+ /** Resumo do modelo de conteúdo (tipos + campos) para orientar a IA. */
215
+ function contentModelSummary(strapi: any, manifest: Manifest): string {
216
+ const lines: string[] = [];
217
+ for (const ct of manifest.contentTypes) {
218
+ const fields = Object.keys(ct.attributes || {}).join(', ');
219
+ if (ct.kind === 'singleType') {
220
+ lines.push(`singleType "${ct.singularName}" (useSection("${ct.singularName}")) campos: ${fields}`);
221
+ } else {
222
+ const plural = resolvePlural(strapi, ct.singularName);
223
+ lines.push(`collection "${ct.singularName}" (useCollection("${plural}")) campos: ${fields}`);
224
+ }
225
+ }
226
+ return lines.join('\n');
227
+ }
228
+
229
+ function wirePrompt(rel: string, source: string, model: string, hooksImport: string, seedSnippet: string): string {
230
+ return `Você religa um componente React para ler o conteúdo da Strapi, SEM quebrar nada.
231
+
232
+ MODELO DE CONTEÚDO (use EXATAMENTE estes nomes de hook/campo):
233
+ ${model}
234
+
235
+ DADOS SEMEADOS (para casar o texto hardcoded com o campo certo):
236
+ ${seedSnippet}
237
+
238
+ REGRAS (siga à risca):
239
+ - Importe os hooks de "${hooksImport}" (ex.: import { useSection, useCollection } from "${hooksImport}";).
240
+ - Dentro do componente, chame os hooks necessários (ex.: const hero = useSection("hero-section-content");).
241
+ - Troque CADA texto hardcoded que casa com um campo por { obj.campo ?? "TEXTO ORIGINAL" } — SEMPRE mantenha o texto original como fallback no ?? .
242
+ - Para listas (arrays hardcoded de objetos), troque o array por useCollection(...) e itere sobre ele; mantenha ícones/imagens/classes/animacoes/layout EXATAMENTE como estão (não são conteúdo).
243
+ - NÃO altere imports de ícones/assets, JSX estrutural, classes Tailwind, hooks de animação, nada que não seja texto/dado.
244
+ - NÃO invente campos nem textos. Se um trecho não casa com nenhum campo, deixe como está.
245
+ - Mantenha o arquivo VÁLIDO e COMPLETO (TypeScript/TSX que compila). Responda com JSON: {"code":"<arquivo .tsx completo>"} e NADA além disso.
246
+
247
+ ARQUIVO: ${rel}
248
+ \`\`\`tsx
249
+ ${source}
250
+ \`\`\``;
251
+ }
252
+
253
+ /**
254
+ * Validação de sintaxe REAL via esbuild (presente em projetos Strapi/Vite).
255
+ * Retorna a mensagem de erro se NÃO parsear, '' se parsear, ou null se o esbuild
256
+ * não estiver disponível (aí caímos só na checagem heurística).
257
+ */
258
+ function syntaxError(code: string): string | null {
259
+ let esbuild: any;
260
+ try {
261
+ // resolve do node_modules do host (o bundle é --packages=external).
262
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
263
+ esbuild = require('esbuild');
264
+ } catch {
265
+ return null; // sem esbuild → não dá pra validar aqui
266
+ }
267
+ try {
268
+ esbuild.transformSync(code, { loader: 'tsx', jsx: 'automatic' });
269
+ return '';
270
+ } catch (e: any) {
271
+ return (e?.message || 'erro de sintaxe').split('\n')[0];
272
+ }
273
+ }
274
+
275
+ /** Checagem de sanidade leve (sem AST): evita gravar lixo. */
276
+ function looksSane(original: string, next: string, hooksImport: string): string | null {
277
+ if (!next || next.length < 40) return 'saída vazia/curta demais';
278
+ if (next.length < original.length * 0.5) return 'saída muito menor que o original (possível truncamento)';
279
+ if (!/export\s+default|export\s+function|export\s+const/.test(next)) return 'sem export';
280
+ if (!next.includes(hooksImport)) return 'não importou os hooks';
281
+ const balanced = (s: string, a: string, b: string) =>
282
+ (s.split(a).length - 1) === (s.split(b).length - 1);
283
+ if (!balanced(next, '{', '}')) return 'chaves desbalanceadas';
284
+ if (!balanced(next, '(', ')')) return 'parênteses desbalanceados';
285
+ if (!balanced(next, '[', ']')) return 'colchetes desbalanceados';
286
+ // não pode ter sobrado cerca de código markdown
287
+ if (/```/.test(next)) return 'markdown na saída';
288
+ return null;
289
+ }
290
+
291
+ async function callOpenAI(apiKey: string, prompt: string): Promise<string> {
292
+ const res = await fetch(OPENAI_URL, {
293
+ method: 'POST',
294
+ headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json' },
295
+ body: JSON.stringify({
296
+ model: MODEL,
297
+ temperature: 0,
298
+ response_format: { type: 'json_object' },
299
+ messages: [
300
+ { role: 'system', content: 'Você religa componentes React para a Strapi e responde só com JSON {"code": "..."} válido.' },
301
+ { role: 'user', content: prompt },
302
+ ],
303
+ }),
304
+ });
305
+ if (!res.ok) throw new Error(`OpenAI: ${await res.text()}`);
306
+ const data = await res.json();
307
+ const raw = JSON.parse(data.choices?.[0]?.message?.content ?? '{}');
308
+ return typeof raw.code === 'string' ? raw.code : '';
309
+ }
310
+
311
+ // ---------------------------------------------------------------------------
312
+ // orquestração
313
+ // ---------------------------------------------------------------------------
314
+
315
+ export async function wireFrontend(
316
+ _strapi: any,
317
+ opts: { frontendDir: string; manifest: Manifest; dryRun?: boolean }
318
+ ): Promise<WireResult> {
319
+ const { frontendDir, manifest } = opts;
320
+ const result: WireResult = {
321
+ ok: false, dataLayer: [], componentsWired: [], componentsSkipped: [], warnings: [], errors: [],
322
+ };
323
+
324
+ if (!path.isAbsolute(frontendDir)) { result.errors.push('frontendDir deve ser absoluto'); return result; }
325
+
326
+ // 1) camada de dados (sempre — determinística e segura)
327
+ try {
328
+ result.dataLayer = writeDataLayer(frontendDir, manifest, opts.dryRun);
329
+ } catch (e: any) {
330
+ result.errors.push(`camada de dados: ${e?.message ?? e}`);
331
+ return result; // sem a camada, não adianta religar
332
+ }
333
+
334
+ // 2) religação dos componentes (best-effort, com .bak + sanidade)
335
+ const apiKey = process.env.OPENAI_API_KEY;
336
+ if (!apiKey) {
337
+ result.warnings.push('Sem OPENAI_API_KEY: camada de dados criada, mas os componentes NÃO foram religados (precisa da chave).');
338
+ result.ok = true;
339
+ return result;
340
+ }
341
+
342
+ const srcDir = path.join(frontendDir, 'src');
343
+ const all: string[] = [];
344
+ walkComponents(srcDir, frontendDir, all);
345
+ const components = all.filter((rel) => /(\/|^)(components|pages|app|routes)(\/|$)/.test(rel.replace(/\\/g, '/')));
346
+
347
+ const model = contentModelSummary(_strapi, manifest);
348
+ const hooksImport = usesAtAlias(frontendDir) ? '@/hooks/useStrapi' : '../hooks/useStrapi';
349
+ const seedSnippet = JSON.stringify(manifest.seed ?? []).slice(0, 6000);
350
+
351
+ for (const rel of components) {
352
+ const abs = path.join(frontendDir, rel);
353
+ if (!ensureInside(frontendDir, abs)) continue;
354
+ let source: string;
355
+ try { source = fs.readFileSync(abs, 'utf8'); } catch { continue; }
356
+ if (source.length > MAX_COMPONENT_CHARS) { result.componentsSkipped.push({ rel, reason: 'arquivo grande demais' }); continue; }
357
+ // já religado? pula.
358
+ if (source.includes('useStrapi')) { result.componentsSkipped.push({ rel, reason: 'já religado' }); continue; }
359
+
360
+ // valida: heurística + sintaxe real (esbuild). Se falhar, 1 retry pedindo correção.
361
+ const check = (code: string): string | null => {
362
+ const h = looksSane(source, code, hooksImport);
363
+ if (h) return h;
364
+ const s = syntaxError(code); // '' ok, null = sem esbuild, string = erro
365
+ return s ? `sintaxe: ${s}` : null;
366
+ };
367
+
368
+ try {
369
+ const prompt = wirePrompt(rel, source, model, hooksImport, seedSnippet);
370
+ let next = await callOpenAI(apiKey, prompt);
371
+ let bad = check(next);
372
+ if (bad) {
373
+ // retry único, devolvendo o erro pra IA corrigir.
374
+ const fix = `${prompt}\n\nA sua tentativa anterior foi REJEITADA por: ${bad}. Corrija e responda só com o JSON {"code":"..."} do arquivo completo e válido.`;
375
+ const next2 = await callOpenAI(apiKey, fix);
376
+ const bad2 = check(next2);
377
+ if (bad2) { result.componentsSkipped.push({ rel, reason: bad2 }); continue; }
378
+ next = next2;
379
+ }
380
+ if (!opts.dryRun) {
381
+ const bak = abs + '.bak';
382
+ if (!fs.existsSync(bak)) fs.writeFileSync(bak, source, 'utf8');
383
+ fs.writeFileSync(abs, next, 'utf8');
384
+ }
385
+ result.componentsWired.push(rel);
386
+ } catch (e: any) {
387
+ result.componentsSkipped.push({ rel, reason: `IA: ${e?.message ?? e}` });
388
+ }
389
+ }
390
+
391
+ result.ok = true;
392
+ return result;
393
+ }
@@ -61,6 +61,12 @@ export default {
61
61
  handler: 'frontend.integrate',
62
62
  config: { policies: [] },
63
63
  },
64
+ {
65
+ method: 'POST',
66
+ path: '/frontend/wire',
67
+ handler: 'frontend.wire',
68
+ config: { policies: [] },
69
+ },
64
70
  ],
65
71
  },
66
72
  };
@@ -25,6 +25,12 @@ type ChatInput = {
25
25
  lang?: Lang;
26
26
  /** URL da página aberta no preview — contexto do "isso aqui". */
27
27
  previewUrl?: string | null;
28
+ /**
29
+ * Status do preview atual: 'draft' (toggle de rascunho ligado) ou 'published'
30
+ * (live). A busca de texto segue ESTE modo — acha rascunho quando em draft, e
31
+ * o conteúdo no ar quando em live. Default 'draft'.
32
+ */
33
+ previewStatus?: 'draft' | 'published';
28
34
  /**
29
35
  * Política de publicação. `false` (default) = modo RASCUNHO: o agente edita o
30
36
  * draft e NÃO publica, a menos que o usuário peça explicitamente. `true` =
@@ -103,7 +109,7 @@ Be concise and actionable. ALWAYS answer in English.`,
103
109
  };
104
110
 
105
111
  export default ({ strapi }: { strapi: any }) => ({
106
- async chat({ messages, image, lang = 'pt', previewUrl, autoPublish = false }: ChatInput) {
112
+ async chat({ messages, image, lang = 'pt', previewUrl, previewStatus = 'draft', autoPublish = false }: ChatInput) {
107
113
  const apiKey = process.env.OPENAI_API_KEY;
108
114
  if (!apiKey) {
109
115
  throw new Error(
@@ -116,7 +122,7 @@ export default ({ strapi }: { strapi: any }) => ({
116
122
  const { buscarTexto, editarCampo, publicar, listarLocales, criarLocale, traduzir } =
117
123
  createContentTools(strapi);
118
124
  const LOCAL_TOOLS: Record<string, (args: any) => Promise<any>> = {
119
- buscar_texto: (a) => buscarTexto(a?.termo),
125
+ buscar_texto: (a) => buscarTexto(a?.termo, previewStatus),
120
126
  editar_campo: (a) => editarCampo(a),
121
127
  publicar: (a) => publicar(a),
122
128
  listar_locales: () => listarLocales(),