strapi-plugin-mcp-chat 0.1.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/LICENSE +21 -0
- package/README.md +265 -0
- package/admin/src/components/AdminOverlays.tsx +190 -0
- package/admin/src/components/FloatingChat.tsx +370 -0
- package/admin/src/components/PreviewPanel.tsx +188 -0
- package/admin/src/index.tsx +49 -0
- package/admin/src/pages/App.tsx +14 -0
- package/admin/src/pages/HomePage.tsx +333 -0
- package/admin/src/pages/ProvisionPage.tsx +391 -0
- package/admin/src/pluginId.ts +1 -0
- package/dist/server/index.js +3511 -0
- package/package.json +77 -0
- package/server/src/content-tools.ts +520 -0
- package/server/src/controllers/audio.ts +45 -0
- package/server/src/controllers/chat.ts +22 -0
- package/server/src/controllers/frontend.ts +310 -0
- package/server/src/index.ts +43 -0
- package/server/src/mcp/index.ts +24 -0
- package/server/src/mcp/tools/buscar-texto.ts +28 -0
- package/server/src/mcp/tools/criar-locale.ts +30 -0
- package/server/src/mcp/tools/editar-campo.ts +39 -0
- package/server/src/mcp/tools/habilitar-i18n.ts +33 -0
- package/server/src/mcp/tools/index.ts +17 -0
- package/server/src/mcp/tools/listar-locales.ts +27 -0
- package/server/src/mcp/tools/publicar.ts +31 -0
- package/server/src/mcp/tools/traduzir.ts +36 -0
- package/server/src/mcp/types.ts +11 -0
- package/server/src/mcp-client.ts +96 -0
- package/server/src/provision/adapters.ts +91 -0
- package/server/src/provision/enable-i18n.ts +129 -0
- package/server/src/provision/generate.ts +216 -0
- package/server/src/provision/infer.ts +495 -0
- package/server/src/provision/integrate.ts +963 -0
- package/server/src/provision/link.ts +203 -0
- package/server/src/provision/manifest.ts +281 -0
- package/server/src/provision/orchestrate.ts +236 -0
- package/server/src/provision/permissions.ts +58 -0
- package/server/src/provision/runner.ts +176 -0
- package/server/src/provision/seed.ts +115 -0
- package/server/src/provision/translate.ts +153 -0
- package/server/src/provision/types-gen.ts +117 -0
- package/server/src/provision/write.ts +136 -0
- package/server/src/register.ts +17 -0
- package/server/src/routes/index.ts +66 -0
- package/server/src/services/audio.ts +53 -0
- package/server/src/services/chat.ts +263 -0
|
@@ -0,0 +1,495 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { validateManifest, type Manifest, FRAMEWORKS } from './manifest';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Inferência de manifest a partir do CÓDIGO do frontend.
|
|
7
|
+
*
|
|
8
|
+
* Cenário-alvo: frontends gerados por Figma/Lovable não trazem um
|
|
9
|
+
* strapi.manifest.json — os dados ficam hardcoded (ex.: src/data/site.ts). Aqui
|
|
10
|
+
* varremos esses arquivos, mandamos para a IA (mesma OpenAI do chat) e pedimos
|
|
11
|
+
* que ela projete o modelo de conteúdo (content-types + seed). O resultado é
|
|
12
|
+
* SEMPRE validado pelo mesmo schema Zod do contrato — nada entra na provisão sem
|
|
13
|
+
* passar pela validação (e tentamos de novo, devolvendo os erros, se falhar).
|
|
14
|
+
*
|
|
15
|
+
* Nunca executa o código do frontend: só LÊ arquivos de texto.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
const OPENAI_URL = 'https://api.openai.com/v1/chat/completions';
|
|
19
|
+
const MODEL = process.env.OPENAI_CHAT_MODEL || 'gpt-4o';
|
|
20
|
+
|
|
21
|
+
const SKIP_DIRS = new Set([
|
|
22
|
+
'node_modules', '.git', 'dist', '.next', '.output', '.vinxi', '.tanstack',
|
|
23
|
+
'build', 'coverage', '.turbo', '.cache', 'public',
|
|
24
|
+
]);
|
|
25
|
+
const CODE_EXT = new Set(['.ts', '.tsx', '.js', '.jsx', '.mjs']);
|
|
26
|
+
const MAX_FILES = 18;
|
|
27
|
+
const MAX_TOTAL_CHARS = 60000;
|
|
28
|
+
const MAX_FILE_CHARS = 12000;
|
|
29
|
+
|
|
30
|
+
export interface InferResult {
|
|
31
|
+
ok: boolean;
|
|
32
|
+
/** manifest validado (só presente se ok). */
|
|
33
|
+
manifest?: Manifest;
|
|
34
|
+
/** manifest cru retornado pela IA (para exibir mesmo se inválido). */
|
|
35
|
+
rawManifest?: any;
|
|
36
|
+
/** true se gerado pela IA; false se já existia no projeto. */
|
|
37
|
+
inferred: boolean;
|
|
38
|
+
filesAnalyzed: string[];
|
|
39
|
+
framework: string;
|
|
40
|
+
warnings: string[];
|
|
41
|
+
errors: string[];
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// ---------------------------------------------------------------------------
|
|
45
|
+
// coleta de arquivos candidatos
|
|
46
|
+
// ---------------------------------------------------------------------------
|
|
47
|
+
|
|
48
|
+
/** pontuação heurística: caminhos "de dados" valem mais. */
|
|
49
|
+
function score(rel: string): number {
|
|
50
|
+
const p = rel.toLowerCase();
|
|
51
|
+
let s = 0;
|
|
52
|
+
if (/(^|\/)(data|content|mocks?|seeds?|fixtures?)(\/|\.)/.test(p)) s += 10;
|
|
53
|
+
if (/(site|config|constants|catalog|products?|services?|posts?|items?)/.test(p)) s += 4;
|
|
54
|
+
if (p.startsWith('src/')) s += 2;
|
|
55
|
+
if (p.endsWith('.tsx') || p.endsWith('.jsx')) s -= 1; // componentes valem menos
|
|
56
|
+
return s;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function walk(dir: string, base: string, out: string[]) {
|
|
60
|
+
let entries: fs.Dirent[];
|
|
61
|
+
try {
|
|
62
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
63
|
+
} catch {
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
for (const e of entries) {
|
|
67
|
+
if (e.name.startsWith('.') && e.name !== '.') continue;
|
|
68
|
+
const full = path.join(dir, e.name);
|
|
69
|
+
if (e.isDirectory()) {
|
|
70
|
+
if (SKIP_DIRS.has(e.name)) continue;
|
|
71
|
+
walk(full, base, out);
|
|
72
|
+
} else if (CODE_EXT.has(path.extname(e.name))) {
|
|
73
|
+
out.push(path.relative(base, full));
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
interface CollectResult {
|
|
79
|
+
files: { rel: string; content: string }[];
|
|
80
|
+
tree: string[];
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Coleta os arquivos de código mais promissores (com conteúdo) + árvore. */
|
|
84
|
+
function collectFiles(frontendDir: string): CollectResult {
|
|
85
|
+
const all: string[] = [];
|
|
86
|
+
walk(frontendDir, frontendDir, all);
|
|
87
|
+
|
|
88
|
+
const tree = all.slice().sort();
|
|
89
|
+
// candidatos com conteúdo de array/objeto exportado, por pontuação
|
|
90
|
+
const ranked = all
|
|
91
|
+
.map((rel) => ({ rel, s: score(rel) }))
|
|
92
|
+
.filter((x) => x.s > 0)
|
|
93
|
+
.sort((a, b) => b.s - a.s);
|
|
94
|
+
|
|
95
|
+
const files: { rel: string; content: string }[] = [];
|
|
96
|
+
let total = 0;
|
|
97
|
+
for (const { rel } of ranked) {
|
|
98
|
+
if (files.length >= MAX_FILES || total >= MAX_TOTAL_CHARS) break;
|
|
99
|
+
try {
|
|
100
|
+
let content = fs.readFileSync(path.join(frontendDir, rel), 'utf8');
|
|
101
|
+
// só interessa se houver dados estruturados ou tipos
|
|
102
|
+
if (!/export\s+(const|default|type|interface)/.test(content)) continue;
|
|
103
|
+
if (content.length > MAX_FILE_CHARS) content = content.slice(0, MAX_FILE_CHARS) + '\n/* …truncado… */';
|
|
104
|
+
files.push({ rel, content });
|
|
105
|
+
total += content.length;
|
|
106
|
+
} catch {
|
|
107
|
+
/* ignore */
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
return { files, tree };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// ---------------------------------------------------------------------------
|
|
114
|
+
// textos soltos no JSX (headings, botões, labels) — agrupados por arquivo
|
|
115
|
+
// ---------------------------------------------------------------------------
|
|
116
|
+
|
|
117
|
+
const MAX_TEXT_FILES = 25;
|
|
118
|
+
const MAX_TEXTS_PER_FILE = 60;
|
|
119
|
+
|
|
120
|
+
/** Heurística: extrai strings de texto VISÍVEL de um arquivo JSX/TSX. */
|
|
121
|
+
function extractTexts(content: string): string[] {
|
|
122
|
+
const found = new Set<string>();
|
|
123
|
+
const add = (s: string) => {
|
|
124
|
+
const t = s.replace(/\s+/g, ' ').trim();
|
|
125
|
+
// precisa ter letra, tamanho razoável e PARECER texto natural (não código)
|
|
126
|
+
if (t.length < 2 || t.length > 140) return;
|
|
127
|
+
if (!/[A-Za-zÀ-ÿ]/.test(t)) return;
|
|
128
|
+
// descarta fragmentos de código que vazam entre > < (mas mantém preços com $)
|
|
129
|
+
if (/[{}<>()[\];=`|]|\$\{|=>|&&|\|\||https?:|@\//.test(t)) return;
|
|
130
|
+
if (/\b(return|const|let|var|function|map|filter|import|export|className|props)\b/.test(t)) return;
|
|
131
|
+
if (/\w\.\w/.test(t)) return; // acesso a propriedade (p.before, a.com)
|
|
132
|
+
if (/^[a-z]+([A-Z][a-z]+)+$/.test(t)) return; // camelCase (identificador)
|
|
133
|
+
// precisa ter ao menos uma "palavra" de verdade (3+ letras)
|
|
134
|
+
if (!/[A-Za-zÀ-ÿ]{3,}/.test(t)) return;
|
|
135
|
+
found.add(t);
|
|
136
|
+
};
|
|
137
|
+
// nós de texto JSX: >Texto<
|
|
138
|
+
for (const m of content.matchAll(/>\s*([^<>{}\n][^<>{}]*?)\s*</g)) add(m[1]);
|
|
139
|
+
// props textuais comuns
|
|
140
|
+
for (const m of content.matchAll(/\b(?:placeholder|title|alt|label|aria-label)\s*=\s*"([^"]+)"/g)) add(m[1]);
|
|
141
|
+
return [...found].slice(0, MAX_TEXTS_PER_FILE);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
interface PageTexts {
|
|
145
|
+
rel: string;
|
|
146
|
+
texts: string[];
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** Varre rotas/componentes e extrai os textos visíveis, agrupados por arquivo. */
|
|
150
|
+
function collectPageTexts(frontendDir: string): PageTexts[] {
|
|
151
|
+
const all: string[] = [];
|
|
152
|
+
walk(frontendDir, frontendDir, all);
|
|
153
|
+
const out: PageTexts[] = [];
|
|
154
|
+
// prioriza rotas/páginas; depois componentes
|
|
155
|
+
const ranked = all
|
|
156
|
+
.filter((rel) => /\.(tsx|jsx)$/.test(rel))
|
|
157
|
+
.filter((rel) => !/\/(ui)\//.test(rel)) // pula shadcn/ui primitivos
|
|
158
|
+
.sort((a, b) => {
|
|
159
|
+
const pa = /routes?\/|pages?\//.test(a) ? 0 : 1;
|
|
160
|
+
const pb = /routes?\/|pages?\//.test(b) ? 0 : 1;
|
|
161
|
+
return pa - pb;
|
|
162
|
+
});
|
|
163
|
+
for (const rel of ranked) {
|
|
164
|
+
if (out.length >= MAX_TEXT_FILES) break;
|
|
165
|
+
try {
|
|
166
|
+
const texts = extractTexts(fs.readFileSync(path.join(frontendDir, rel), 'utf8'));
|
|
167
|
+
if (texts.length) out.push({ rel, texts });
|
|
168
|
+
} catch {
|
|
169
|
+
/* ignore */
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
return out;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// ---------------------------------------------------------------------------
|
|
176
|
+
// modelagem DETERMINÍSTICA dos textos de página (sem IA — nunca falha, escala)
|
|
177
|
+
// ---------------------------------------------------------------------------
|
|
178
|
+
|
|
179
|
+
const RESERVED = new Set([
|
|
180
|
+
'id', 'documentId', 'createdAt', 'updatedAt', 'publishedAt',
|
|
181
|
+
'createdBy', 'updatedBy', 'locale', 'localizations',
|
|
182
|
+
]);
|
|
183
|
+
// teto de single-types de texto; o excedente vai para uma coleção flat (escala infinita).
|
|
184
|
+
const MAX_PAGE_TYPES = 45;
|
|
185
|
+
|
|
186
|
+
/** "Crafted spaces, end-to-end." -> "craftedSpacesEndToEnd" (chave de campo válida). */
|
|
187
|
+
function toFieldKey(text: string): string {
|
|
188
|
+
const words = text
|
|
189
|
+
.toLowerCase()
|
|
190
|
+
.replace(/[^a-z0-9]+/g, ' ')
|
|
191
|
+
.trim()
|
|
192
|
+
.split(/\s+/)
|
|
193
|
+
.filter(Boolean)
|
|
194
|
+
.slice(0, 6);
|
|
195
|
+
if (!words.length) return 'text';
|
|
196
|
+
let key = words[0] + words.slice(1).map((w) => w[0].toUpperCase() + w.slice(1)).join('');
|
|
197
|
+
key = key.slice(0, 44).replace(/^[^a-zA-Z]+/, '');
|
|
198
|
+
if (!key) key = 'text';
|
|
199
|
+
if (RESERVED.has(key)) key = key + 'Field';
|
|
200
|
+
return key;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** src/routes/index.tsx -> "home-content"; components/Footer.tsx -> "footer-content". */
|
|
204
|
+
function toPageName(rel: string): string {
|
|
205
|
+
let base = rel.replace(/\\/g, '/').split('/').pop() || 'page';
|
|
206
|
+
base = base.replace(/\.(tsx|jsx|ts|js)$/, '');
|
|
207
|
+
base = base.replace(/^\$+/, '').replace(/\$/g, '');
|
|
208
|
+
if (/^index$/.test(base)) base = 'home';
|
|
209
|
+
else if (/^__?root$/.test(base)) base = 'layout';
|
|
210
|
+
const kebab = base
|
|
211
|
+
.replace(/([a-z0-9])([A-Z])/g, '$1-$2')
|
|
212
|
+
.replace(/[^a-zA-Z0-9]+/g, '-')
|
|
213
|
+
.toLowerCase()
|
|
214
|
+
.replace(/^-+|-+$/g, '')
|
|
215
|
+
.replace(/^[^a-z]+/, '');
|
|
216
|
+
return ((kebab || 'page') + '-content').slice(0, 48);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Constrói content-types determinísticos a partir dos textos extraídos:
|
|
221
|
+
* - 1 singleType por página (campos = chaves derivadas do texto, seed = texto exato);
|
|
222
|
+
* - se passar do teto de types, o excedente vira UMA coleção flat (page/key/value),
|
|
223
|
+
* garantindo que TUDO entra independente do tamanho.
|
|
224
|
+
*/
|
|
225
|
+
function buildPageContentTypes(
|
|
226
|
+
pageTexts: PageTexts[],
|
|
227
|
+
budget: number
|
|
228
|
+
): { contentTypes: any[]; seed: any[] } {
|
|
229
|
+
const contentTypes: any[] = [];
|
|
230
|
+
const seed: any[] = [];
|
|
231
|
+
const usedNames = new Set<string>();
|
|
232
|
+
const overflow: { page: string; value: string }[] = [];
|
|
233
|
+
|
|
234
|
+
const allowed = Math.max(0, Math.min(budget, MAX_PAGE_TYPES));
|
|
235
|
+
|
|
236
|
+
pageTexts.forEach((p, idx) => {
|
|
237
|
+
if (idx >= allowed) {
|
|
238
|
+
for (const t of p.texts) overflow.push({ page: toPageName(p.rel), value: t });
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
let name = toPageName(p.rel);
|
|
242
|
+
while (usedNames.has(name)) name = name.replace(/(-\d+)?-content$/, '') + `-${idx}-content`;
|
|
243
|
+
usedNames.add(name);
|
|
244
|
+
|
|
245
|
+
const attributes: Record<string, any> = {};
|
|
246
|
+
const entry: Record<string, any> = {};
|
|
247
|
+
const usedKeys = new Set<string>();
|
|
248
|
+
for (const text of p.texts) {
|
|
249
|
+
let key = toFieldKey(text);
|
|
250
|
+
let k = key;
|
|
251
|
+
let n = 2;
|
|
252
|
+
while (usedKeys.has(k)) k = `${key}${n++}`.slice(0, 46);
|
|
253
|
+
usedKeys.add(k);
|
|
254
|
+
attributes[k] = { type: text.length > 80 ? 'text' : 'string' };
|
|
255
|
+
entry[k] = text;
|
|
256
|
+
}
|
|
257
|
+
if (!Object.keys(attributes).length) return;
|
|
258
|
+
contentTypes.push({
|
|
259
|
+
singularName: name,
|
|
260
|
+
displayName: name.replace(/-/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase()),
|
|
261
|
+
kind: 'singleType',
|
|
262
|
+
draftAndPublish: true,
|
|
263
|
+
attributes,
|
|
264
|
+
});
|
|
265
|
+
seed.push({ singularName: name, entries: [entry] });
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
// excedente → coleção flat (escala sem limite de types)
|
|
269
|
+
if (overflow.length) {
|
|
270
|
+
contentTypes.push({
|
|
271
|
+
singularName: 'page-text',
|
|
272
|
+
displayName: 'Page Text',
|
|
273
|
+
kind: 'collectionType',
|
|
274
|
+
draftAndPublish: true,
|
|
275
|
+
attributes: {
|
|
276
|
+
page: { type: 'string' },
|
|
277
|
+
value: { type: 'text' },
|
|
278
|
+
},
|
|
279
|
+
});
|
|
280
|
+
seed.push({ singularName: 'page-text', entries: overflow });
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
return { contentTypes, seed };
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// ---------------------------------------------------------------------------
|
|
287
|
+
// framework (determinístico, a partir do package.json)
|
|
288
|
+
// ---------------------------------------------------------------------------
|
|
289
|
+
|
|
290
|
+
function detectFramework(frontendDir: string): string {
|
|
291
|
+
try {
|
|
292
|
+
const pkg = JSON.parse(fs.readFileSync(path.join(frontendDir, 'package.json'), 'utf8'));
|
|
293
|
+
const deps = { ...(pkg.dependencies || {}), ...(pkg.devDependencies || {}) };
|
|
294
|
+
if (deps.next) return 'next';
|
|
295
|
+
if (deps['@tanstack/react-start']) return 'tanstack';
|
|
296
|
+
// Vite/React puro (Lovable/Figma) → tanstack é o adapter Vite-compatível mais próximo
|
|
297
|
+
if (deps.vite) return 'tanstack';
|
|
298
|
+
} catch {
|
|
299
|
+
/* ignore */
|
|
300
|
+
}
|
|
301
|
+
return 'tanstack';
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// ---------------------------------------------------------------------------
|
|
305
|
+
// prompt
|
|
306
|
+
// ---------------------------------------------------------------------------
|
|
307
|
+
|
|
308
|
+
function buildPrompt(framework: string, c: CollectResult, name: string): string {
|
|
309
|
+
const filesBlock = c.files
|
|
310
|
+
.map((f) => `--- ARQUIVO: ${f.rel} ---\n${f.content}`)
|
|
311
|
+
.join('\n\n');
|
|
312
|
+
return `Você é um arquiteto de conteúdo Strapi 5. Analise o código de um frontend e projete o modelo de conteúdo.
|
|
313
|
+
|
|
314
|
+
Gere um JSON "strapi.manifest.json" com ESTE formato:
|
|
315
|
+
{
|
|
316
|
+
"manifestVersion": 1,
|
|
317
|
+
"name": "${name}",
|
|
318
|
+
"framework": "${framework}",
|
|
319
|
+
"strapiVersion": "^5.47",
|
|
320
|
+
"contentTypes": [
|
|
321
|
+
{
|
|
322
|
+
"singularName": "kebab-case", // ex.: "produto", "service", "blog-post"
|
|
323
|
+
"displayName": "Nome legível",
|
|
324
|
+
"kind": "collectionType" | "singleType", // listas = collectionType; config/único = singleType
|
|
325
|
+
"draftAndPublish": true,
|
|
326
|
+
"attributes": {
|
|
327
|
+
"campo": { "type": "string|text|richtext|integer|decimal|boolean|date|datetime|email|json|uid|enumeration|media|relation", ... }
|
|
328
|
+
// uid: { "type":"uid", "targetField":"umCampoString" }
|
|
329
|
+
// enumeration: { "type":"enumeration", "enum":["a","b"] }
|
|
330
|
+
// media: { "type":"media", "multiple":false, "allowedTypes":["images"] }
|
|
331
|
+
// relation: { "type":"relation", "relation":"manyToOne|oneToMany|manyToMany|oneToOne", "target":"singularName-de-outro-type" }
|
|
332
|
+
},
|
|
333
|
+
"preview": { "route": "/rota/:slug" } // opcional; só se houver página de detalhe
|
|
334
|
+
}
|
|
335
|
+
],
|
|
336
|
+
"seed": [
|
|
337
|
+
{ "singularName": "...", "entries": [ { ...dados-extraídos-do-código... } ] }
|
|
338
|
+
],
|
|
339
|
+
"env": ["${framework === 'next' ? 'NEXT_PUBLIC_STRAPI_URL' : 'VITE_STRAPI_URL'}", "STRAPI_API_TOKEN", "PREVIEW_SECRET"]
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
REGRAS:
|
|
343
|
+
- Crie uma content-type para cada COLEÇÃO de dados (arrays de objetos). Use os MESMOS nomes de campo do código.
|
|
344
|
+
- Dados de "configuração do site" (objeto único: nome, telefone, etc.) → singleType.
|
|
345
|
+
- Campos string longos/descrições → "text" ou "richtext". Listas de strings → "json".
|
|
346
|
+
- Use "date"/"datetime" SOMENTE para datas ISO completas (YYYY-MM-DD). Datas parciais como "2025-04" ou textos livres → use "string" (senão o seed falha).
|
|
347
|
+
- Imagens (imports de assets ou caminhos) → "media" (NÃO coloque o valor da imagem no seed; omita o campo no seed).
|
|
348
|
+
- Em "seed", extraia o conteúdo REAL hardcoded no código, omitindo campos de mídia e relações.
|
|
349
|
+
- Foque APENAS em coleções/objetos de dados — NÃO precisa modelar textos soltos de UI (isso é tratado à parte).
|
|
350
|
+
- NÃO invente. singularName kebab-case, sem repetir. Relações só apontam para types definidos por você.
|
|
351
|
+
- Se não houver coleções de dados, devolva contentTypes: [] e seed: [].
|
|
352
|
+
- Responda APENAS com o JSON, nada de markdown.
|
|
353
|
+
|
|
354
|
+
Árvore de arquivos do projeto:
|
|
355
|
+
${c.tree.slice(0, 200).join('\n')}
|
|
356
|
+
|
|
357
|
+
Arquivos de dados (coleções):
|
|
358
|
+
${filesBlock}`;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
// ---------------------------------------------------------------------------
|
|
362
|
+
// inferência
|
|
363
|
+
// ---------------------------------------------------------------------------
|
|
364
|
+
|
|
365
|
+
export async function inferManifest(
|
|
366
|
+
strapi: any,
|
|
367
|
+
frontendDir: string,
|
|
368
|
+
opts: { name: string }
|
|
369
|
+
): Promise<InferResult> {
|
|
370
|
+
const framework = detectFramework(frontendDir);
|
|
371
|
+
const result: InferResult = {
|
|
372
|
+
ok: false,
|
|
373
|
+
inferred: true,
|
|
374
|
+
filesAnalyzed: [],
|
|
375
|
+
framework,
|
|
376
|
+
warnings: [],
|
|
377
|
+
errors: [],
|
|
378
|
+
};
|
|
379
|
+
|
|
380
|
+
// 1) já existe manifest no projeto? então não infere.
|
|
381
|
+
const existing = path.join(frontendDir, 'strapi.manifest.json');
|
|
382
|
+
if (fs.existsSync(existing)) {
|
|
383
|
+
try {
|
|
384
|
+
const raw = JSON.parse(fs.readFileSync(existing, 'utf8'));
|
|
385
|
+
const v = validateManifest(raw);
|
|
386
|
+
result.inferred = false;
|
|
387
|
+
result.rawManifest = raw;
|
|
388
|
+
if (v.ok) {
|
|
389
|
+
result.ok = true;
|
|
390
|
+
result.manifest = v.data;
|
|
391
|
+
} else {
|
|
392
|
+
result.errors.push(...(v.errors ?? []));
|
|
393
|
+
}
|
|
394
|
+
return result;
|
|
395
|
+
} catch (e: any) {
|
|
396
|
+
result.errors.push(`manifest existente ilegível: ${e?.message ?? e}`);
|
|
397
|
+
return result;
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
// 2) coleta de arquivos de dados + textos soltos do JSX
|
|
402
|
+
const collected = collectFiles(frontendDir);
|
|
403
|
+
const pageTexts = collectPageTexts(frontendDir);
|
|
404
|
+
result.filesAnalyzed = [
|
|
405
|
+
...collected.files.map((f) => f.rel),
|
|
406
|
+
...pageTexts.map((p) => p.rel),
|
|
407
|
+
];
|
|
408
|
+
if (collected.files.length === 0 && pageTexts.length === 0) {
|
|
409
|
+
result.errors.push(
|
|
410
|
+
'Não encontrei dados nem textos para analisar. Adicione um strapi.manifest.json manualmente.'
|
|
411
|
+
);
|
|
412
|
+
return result;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
const envList = [
|
|
416
|
+
framework === 'next' ? 'NEXT_PUBLIC_STRAPI_URL' : 'VITE_STRAPI_URL',
|
|
417
|
+
'STRAPI_API_TOKEN',
|
|
418
|
+
'PREVIEW_SECRET',
|
|
419
|
+
];
|
|
420
|
+
|
|
421
|
+
// 3) DADOS via IA (best-effort, NÃO bloqueia). Falha aqui não impede a extração
|
|
422
|
+
// determinística dos textos abaixo.
|
|
423
|
+
let dataCts: any[] = [];
|
|
424
|
+
let dataSeed: any[] = [];
|
|
425
|
+
const apiKey = process.env.OPENAI_API_KEY;
|
|
426
|
+
if (apiKey && collected.files.length) {
|
|
427
|
+
const callOpenAI = async (messages: any[]) => {
|
|
428
|
+
const res = await fetch(OPENAI_URL, {
|
|
429
|
+
method: 'POST',
|
|
430
|
+
headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json' },
|
|
431
|
+
body: JSON.stringify({ model: MODEL, temperature: 0, response_format: { type: 'json_object' }, messages }),
|
|
432
|
+
});
|
|
433
|
+
if (!res.ok) throw new Error(`OpenAI: ${await res.text()}`);
|
|
434
|
+
return res.json() as Promise<any>;
|
|
435
|
+
};
|
|
436
|
+
const messages: any[] = [
|
|
437
|
+
{ role: 'system', content: 'Você projeta modelos de conteúdo Strapi 5 e responde só com JSON válido.' },
|
|
438
|
+
{ role: 'user', content: buildPrompt(framework, collected, opts.name) },
|
|
439
|
+
];
|
|
440
|
+
try {
|
|
441
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
442
|
+
const data = await callOpenAI(messages);
|
|
443
|
+
const raw = JSON.parse(data.choices?.[0]?.message?.content ?? '{}');
|
|
444
|
+
const candidate = {
|
|
445
|
+
manifestVersion: 1, name: opts.name, framework,
|
|
446
|
+
strapiVersion: '^5.47',
|
|
447
|
+
contentTypes: Array.isArray(raw.contentTypes) ? raw.contentTypes : [],
|
|
448
|
+
seed: Array.isArray(raw.seed) ? raw.seed : [],
|
|
449
|
+
env: envList,
|
|
450
|
+
};
|
|
451
|
+
if (!candidate.contentTypes.length) break; // sem coleções de dados
|
|
452
|
+
const v = validateManifest(candidate);
|
|
453
|
+
if (v.ok) { dataCts = v.data.contentTypes as any[]; dataSeed = (v.data.seed as any[]) ?? []; break; }
|
|
454
|
+
if (attempt === 0) {
|
|
455
|
+
messages.push({ role: 'assistant', content: JSON.stringify(raw) });
|
|
456
|
+
messages.push({ role: 'user', content: 'JSON REJEITADO pela validação:\n' + (v.errors ?? []).join('\n') + '\nCorrija e responda só com o JSON.' });
|
|
457
|
+
} else {
|
|
458
|
+
result.warnings.push('Modelo de dados (IA) inválido — seguindo só com os textos.');
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
} catch (e: any) {
|
|
462
|
+
result.warnings.push(`IA de dados indisponível (seguindo só com os textos): ${e?.message ?? e}`);
|
|
463
|
+
}
|
|
464
|
+
} else if (!apiKey) {
|
|
465
|
+
result.warnings.push('Sem OPENAI_API_KEY: modelando os TEXTOS (determinístico); coleções de dados não inferidas.');
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
// 4) TEXTOS via extração DETERMINÍSTICA (garantido, sem IA, escala em qualquer tamanho)
|
|
469
|
+
const budget = 60 - dataCts.length - 1; // teto de content-types do manifest
|
|
470
|
+
const page = buildPageContentTypes(pageTexts, budget);
|
|
471
|
+
|
|
472
|
+
// 5) merge + validação resiliente (nunca aborta)
|
|
473
|
+
const finalManifest: any = {
|
|
474
|
+
manifestVersion: 1, name: opts.name, framework, strapiVersion: '^5.47',
|
|
475
|
+
contentTypes: [...dataCts, ...page.contentTypes],
|
|
476
|
+
seed: [...dataSeed, ...page.seed],
|
|
477
|
+
env: envList,
|
|
478
|
+
};
|
|
479
|
+
result.rawManifest = finalManifest;
|
|
480
|
+
let v = validateManifest(finalManifest);
|
|
481
|
+
if (!v.ok) {
|
|
482
|
+
// fallback: só os textos (determinístico — sempre válido)
|
|
483
|
+
result.warnings.push('Manifest combinado inválido; caindo para só-textos. ' + (v.errors ?? []).join('; '));
|
|
484
|
+
const pageOnly = { ...finalManifest, contentTypes: page.contentTypes, seed: page.seed };
|
|
485
|
+
result.rawManifest = pageOnly;
|
|
486
|
+
v = validateManifest(pageOnly);
|
|
487
|
+
}
|
|
488
|
+
if (v.ok) {
|
|
489
|
+
result.ok = true;
|
|
490
|
+
result.manifest = v.data;
|
|
491
|
+
} else {
|
|
492
|
+
result.errors.push(...(v.errors ?? []));
|
|
493
|
+
}
|
|
494
|
+
return result;
|
|
495
|
+
}
|