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.
- package/admin/src/components/AdminOverlays.tsx +8 -18
- package/admin/src/components/FloatingChat.tsx +70 -3
- package/dist/server/index.js +943 -335
- package/package.json +1 -1
- package/server/src/content-tools.ts +4 -2
- package/server/src/controllers/chat.ts +2 -2
- package/server/src/controllers/frontend.ts +35 -0
- package/server/src/index.ts +8 -1
- package/server/src/provision/infer.ts +92 -20
- package/server/src/provision/link.ts +232 -35
- package/server/src/provision/orchestrate.ts +17 -3
- package/server/src/provision/runner.ts +44 -1
- package/server/src/provision/wire.ts +393 -0
- package/server/src/routes/index.ts +6 -0
- package/server/src/services/chat.ts +8 -2
|
@@ -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
|
+
}
|
|
@@ -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(),
|