strapi-plugin-mcp-chat 0.1.0 → 0.3.1
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/README.md +60 -4
- package/admin/src/components/AdminOverlays.tsx +35 -1
- package/admin/src/components/FloatingChat.tsx +14 -0
- package/admin/src/components/LangSwitcher.tsx +24 -0
- package/admin/src/components/Onboarding.tsx +192 -0
- package/admin/src/components/PreviewPanel.tsx +22 -1
- package/admin/src/components/StackLogos.tsx +60 -0
- package/admin/src/i18n.ts +214 -0
- package/admin/src/pages/HomePage.tsx +55 -24
- package/admin/src/pages/ProvisionPage.tsx +54 -59
- package/dist/server/index.js +60 -16
- package/package.json +1 -1
- package/server/src/content-tools.ts +26 -4
- package/server/src/controllers/chat.ts +2 -2
- package/server/src/provision/integrate.ts +15 -1
- package/server/src/services/chat.ts +28 -8
|
@@ -2,6 +2,9 @@ import { useRef, useState, useEffect } from 'react';
|
|
|
2
2
|
import { Box, Flex, Typography, Button, Loader, Textarea } from '@strapi/design-system';
|
|
3
3
|
import { useFetchClient } from '@strapi/strapi/admin';
|
|
4
4
|
import { Link } from 'react-router-dom';
|
|
5
|
+
import { useLang, makeT } from '../i18n';
|
|
6
|
+
import { LangSwitcher } from '../components/LangSwitcher';
|
|
7
|
+
import { StackLogos } from '../components/StackLogos';
|
|
5
8
|
|
|
6
9
|
/**
|
|
7
10
|
* Provisão de frontend em duas etapas (cenário Figma/Lovable, sem manifest):
|
|
@@ -41,6 +44,8 @@ const POLL_TIMEOUT_MS = 120000;
|
|
|
41
44
|
const ProvisionPage = () => {
|
|
42
45
|
const { post, get } = useFetchClient();
|
|
43
46
|
const inputRef = useRef<HTMLInputElement | null>(null);
|
|
47
|
+
const [lang] = useLang();
|
|
48
|
+
const t = makeT(lang);
|
|
44
49
|
|
|
45
50
|
const [file, setFile] = useState<File | null>(null);
|
|
46
51
|
const [phase, setPhase] = useState<Phase>('idle');
|
|
@@ -96,11 +101,11 @@ const ProvisionPage = () => {
|
|
|
96
101
|
setFilesAnalyzed(res.filesAnalyzed || []);
|
|
97
102
|
setManifestText(res.manifest ? JSON.stringify(res.manifest, null, 2) : '');
|
|
98
103
|
if (!res.ok && res.errors?.length) {
|
|
99
|
-
setError(
|
|
104
|
+
setError(`${t('prov.analyzeWarn')}\n• ${res.errors.join('\n• ')}`);
|
|
100
105
|
}
|
|
101
106
|
setPhase('review');
|
|
102
107
|
} catch (e: any) {
|
|
103
|
-
setError(errDetail(e, '
|
|
108
|
+
setError(errDetail(e, t('prov.analyzeFail')));
|
|
104
109
|
setPhase('error');
|
|
105
110
|
}
|
|
106
111
|
};
|
|
@@ -111,7 +116,7 @@ const ProvisionPage = () => {
|
|
|
111
116
|
try {
|
|
112
117
|
manifest = JSON.parse(manifestText);
|
|
113
118
|
} catch {
|
|
114
|
-
setError('
|
|
119
|
+
setError(t('prov.invalidJson'));
|
|
115
120
|
return;
|
|
116
121
|
}
|
|
117
122
|
setError(null);
|
|
@@ -126,7 +131,7 @@ const ProvisionPage = () => {
|
|
|
126
131
|
setPhase('done-noreload');
|
|
127
132
|
}
|
|
128
133
|
} catch (e: any) {
|
|
129
|
-
setError(errDetail(e, '
|
|
134
|
+
setError(errDetail(e, t('prov.provisionFail')));
|
|
130
135
|
setPhase('error');
|
|
131
136
|
}
|
|
132
137
|
};
|
|
@@ -135,7 +140,7 @@ const ProvisionPage = () => {
|
|
|
135
140
|
const startedAt = Date.now();
|
|
136
141
|
while (!stopRef.current) {
|
|
137
142
|
if (Date.now() - startedAt > POLL_TIMEOUT_MS) {
|
|
138
|
-
setError('
|
|
143
|
+
setError(t('prov.provisionFail'));
|
|
139
144
|
setPhase('error');
|
|
140
145
|
return;
|
|
141
146
|
}
|
|
@@ -160,17 +165,12 @@ const ProvisionPage = () => {
|
|
|
160
165
|
try {
|
|
161
166
|
const { data } = await post('/mcp-chat/frontend/integrate', {});
|
|
162
167
|
if (data.ok) {
|
|
163
|
-
setIntegrateMsg(
|
|
164
|
-
`✅ Religado! Arquivos atualizados: ${data.filesRewritten.join(', ')}. ` +
|
|
165
|
-
`Recarregue o preview para ver os dados do Strapi. (Original salvo como .bak.)`
|
|
166
|
-
);
|
|
168
|
+
setIntegrateMsg(t('prov.relinkOk', { files: data.filesRewritten.join(', ') }));
|
|
167
169
|
} else {
|
|
168
|
-
setIntegrateMsg(
|
|
169
|
-
`⚠️ Não consegui religar: ${(data.errors || []).join('; ') || 'sem arquivo de dados'}.`
|
|
170
|
-
);
|
|
170
|
+
setIntegrateMsg(t('prov.relinkFail', { err: (data.errors || []).join('; ') || t('prov.noData') }));
|
|
171
171
|
}
|
|
172
172
|
} catch (e: any) {
|
|
173
|
-
setIntegrateMsg(`⚠️ ${errDetail(e, '
|
|
173
|
+
setIntegrateMsg(`⚠️ ${errDetail(e, t('prov.relinkErr'))}`);
|
|
174
174
|
} finally {
|
|
175
175
|
setIntegrating(false);
|
|
176
176
|
}
|
|
@@ -194,15 +194,15 @@ const ProvisionPage = () => {
|
|
|
194
194
|
<Box padding={6} background="neutral100" style={{ minHeight: '100vh' }}>
|
|
195
195
|
<Flex justifyContent="space-between" alignItems="center" paddingBottom={4}>
|
|
196
196
|
<Box>
|
|
197
|
-
<Typography variant="alpha" tag="h1">
|
|
198
|
-
<Typography variant="pi" textColor="neutral600">
|
|
199
|
-
Suba o .zip do seu frontend (Figma/Lovable, Next ou TanStack) — a IA infere o
|
|
200
|
-
modelo de conteúdo, você revisa, e o plugin cria tudo no Strapi.
|
|
201
|
-
</Typography>
|
|
197
|
+
<Typography variant="alpha" tag="h1">{t('prov.title')}</Typography>
|
|
198
|
+
<Typography variant="pi" textColor="neutral600">{t('prov.subtitle')}</Typography>
|
|
202
199
|
</Box>
|
|
203
|
-
<
|
|
204
|
-
<
|
|
205
|
-
|
|
200
|
+
<Flex gap={2} alignItems="center">
|
|
201
|
+
<LangSwitcher />
|
|
202
|
+
<Link to="..">
|
|
203
|
+
<Button variant="tertiary">{t('prov.back')}</Button>
|
|
204
|
+
</Link>
|
|
205
|
+
</Flex>
|
|
206
206
|
</Flex>
|
|
207
207
|
|
|
208
208
|
<Box
|
|
@@ -213,12 +213,17 @@ const ProvisionPage = () => {
|
|
|
213
213
|
style={{ maxWidth: 820, margin: '0 auto' }}
|
|
214
214
|
>
|
|
215
215
|
<Flex direction="column" alignItems="stretch" gap={4}>
|
|
216
|
+
{/* Stacks suportados */}
|
|
217
|
+
<Box>
|
|
218
|
+
<Typography variant="sigma" textColor="neutral600" tag="div">{t('prov.supported')}</Typography>
|
|
219
|
+
<Box paddingTop={2}><StackLogos /></Box>
|
|
220
|
+
</Box>
|
|
221
|
+
|
|
222
|
+
<Box height="1px" background="neutral200" />
|
|
223
|
+
|
|
216
224
|
{/* Seleção do arquivo */}
|
|
217
|
-
<Typography variant="delta" tag="h2">
|
|
218
|
-
<Typography textColor="neutral600">
|
|
219
|
-
Não precisa de <code>strapi.manifest.json</code>: se ele não existir, a IA cria um
|
|
220
|
-
analisando os dados do código (ex.: <code>src/data/*.ts</code>).
|
|
221
|
-
</Typography>
|
|
225
|
+
<Typography variant="delta" tag="h2">{t('prov.step1')}</Typography>
|
|
226
|
+
<Typography textColor="neutral600">{t('prov.step1desc')}</Typography>
|
|
222
227
|
|
|
223
228
|
<input
|
|
224
229
|
ref={inputRef}
|
|
@@ -236,27 +241,25 @@ const ProvisionPage = () => {
|
|
|
236
241
|
|
|
237
242
|
<Flex gap={2} alignItems="center">
|
|
238
243
|
<Button variant="secondary" onClick={() => inputRef.current?.click()} disabled={busy}>
|
|
239
|
-
|
|
244
|
+
{t('prov.selectFile')}
|
|
240
245
|
</Button>
|
|
241
246
|
<Typography textColor={file ? 'neutral800' : 'neutral500'}>
|
|
242
|
-
{file ? file.name : '
|
|
247
|
+
{file ? file.name : t('prov.noFile')}
|
|
243
248
|
</Typography>
|
|
244
249
|
</Flex>
|
|
245
250
|
|
|
246
251
|
{(phase === 'idle' || phase === 'analyzing') && (
|
|
247
252
|
<Box paddingTop={2}>
|
|
248
253
|
<Button onClick={analyze} loading={phase === 'analyzing'} disabled={!file || busy}>
|
|
249
|
-
|
|
254
|
+
{t('prov.analyze')}
|
|
250
255
|
</Button>
|
|
251
256
|
</Box>
|
|
252
257
|
)}
|
|
253
258
|
|
|
254
259
|
{phase === 'analyzing' && (
|
|
255
260
|
<Flex gap={3} alignItems="center" background="primary100" padding={4} hasRadius>
|
|
256
|
-
<Loader small>
|
|
257
|
-
<Typography textColor="primary700">
|
|
258
|
-
Lendo o código e inferindo o modelo de conteúdo (content-types + seed)…
|
|
259
|
-
</Typography>
|
|
261
|
+
<Loader small>{t('prov.analyzing')}</Loader>
|
|
262
|
+
<Typography textColor="primary700">{t('prov.analyzingDesc')}</Typography>
|
|
260
263
|
</Flex>
|
|
261
264
|
)}
|
|
262
265
|
|
|
@@ -264,23 +267,21 @@ const ProvisionPage = () => {
|
|
|
264
267
|
{phase === 'review' && (
|
|
265
268
|
<>
|
|
266
269
|
<Box height="1px" background="neutral200" />
|
|
267
|
-
<Typography variant="delta" tag="h2">
|
|
270
|
+
<Typography variant="delta" tag="h2">{t('prov.step2')}</Typography>
|
|
268
271
|
<Flex gap={2} alignItems="center" wrap="wrap">
|
|
269
272
|
<Box background={inferred ? 'warning100' : 'success100'} padding={2} hasRadius>
|
|
270
273
|
<Typography variant="pi" textColor={inferred ? 'warning700' : 'success700'}>
|
|
271
|
-
{inferred ? '
|
|
274
|
+
{inferred ? t('prov.inferred') : t('prov.fromManifest')} • {t('prov.framework')}: {framework}
|
|
272
275
|
</Typography>
|
|
273
276
|
</Box>
|
|
274
277
|
{filesAnalyzed.length > 0 && (
|
|
275
278
|
<Typography variant="pi" textColor="neutral600">
|
|
276
|
-
|
|
279
|
+
{t('prov.analyzed')}: {filesAnalyzed.slice(0, 6).join(', ')}
|
|
277
280
|
{filesAnalyzed.length > 6 ? ` +${filesAnalyzed.length - 6}` : ''}
|
|
278
281
|
</Typography>
|
|
279
282
|
)}
|
|
280
283
|
</Flex>
|
|
281
|
-
<Typography variant="pi" textColor="neutral600">
|
|
282
|
-
Edite o JSON se quiser ajustar nomes, tipos ou o conteúdo semeado antes de criar.
|
|
283
|
-
</Typography>
|
|
284
|
+
<Typography variant="pi" textColor="neutral600">{t('prov.editJson')}</Typography>
|
|
284
285
|
<Textarea
|
|
285
286
|
name="manifest"
|
|
286
287
|
value={manifestText}
|
|
@@ -289,9 +290,9 @@ const ProvisionPage = () => {
|
|
|
289
290
|
/>
|
|
290
291
|
<Flex gap={2}>
|
|
291
292
|
<Button onClick={provision} disabled={!manifestText.trim()}>
|
|
292
|
-
|
|
293
|
+
{t('prov.provision')}
|
|
293
294
|
</Button>
|
|
294
|
-
<Button variant="tertiary" onClick={reset}>
|
|
295
|
+
<Button variant="tertiary" onClick={reset}>{t('prov.restart')}</Button>
|
|
295
296
|
</Flex>
|
|
296
297
|
</>
|
|
297
298
|
)}
|
|
@@ -300,15 +301,10 @@ const ProvisionPage = () => {
|
|
|
300
301
|
{phase === 'provisioning' && (
|
|
301
302
|
<Flex direction="column" gap={2} background="primary100" padding={4} hasRadius>
|
|
302
303
|
<Flex gap={3} alignItems="center">
|
|
303
|
-
<Loader small>
|
|
304
|
-
<Typography fontWeight="bold" textColor="primary700">
|
|
305
|
-
Configurando tudo — isso leva alguns segundos
|
|
306
|
-
</Typography>
|
|
304
|
+
<Loader small>{t('prov.provisioning')}</Loader>
|
|
305
|
+
<Typography fontWeight="bold" textColor="primary700">{t('prov.provisioningTitle')}</Typography>
|
|
307
306
|
</Flex>
|
|
308
|
-
<Typography variant="pi" textColor="neutral700">
|
|
309
|
-
A Strapi está reiniciando para reconhecer as content-types, depois semeia o
|
|
310
|
-
conteúdo, libera leitura pública e liga o preview. Não feche esta página.
|
|
311
|
-
</Typography>
|
|
307
|
+
<Typography variant="pi" textColor="neutral700">{t('prov.provisioningDesc')}</Typography>
|
|
312
308
|
</Flex>
|
|
313
309
|
)}
|
|
314
310
|
|
|
@@ -322,24 +318,24 @@ const ProvisionPage = () => {
|
|
|
322
318
|
{phase === 'ready' && done && (
|
|
323
319
|
<Box background="success100" padding={4} hasRadius>
|
|
324
320
|
<Typography variant="beta" textColor="success700" tag="div">
|
|
325
|
-
|
|
321
|
+
{t('prov.doneTitle')}
|
|
326
322
|
</Typography>
|
|
327
323
|
<Box paddingTop={3}>
|
|
328
324
|
<Typography variant="pi" textColor="neutral700" tag="div">
|
|
329
|
-
|
|
325
|
+
{t('prov.typesCreated')} {done.contentTypes.join(', ')}
|
|
330
326
|
</Typography>
|
|
331
327
|
{done.seedCreated.length > 0 && (
|
|
332
328
|
<Typography variant="pi" textColor="neutral700" tag="div">
|
|
333
|
-
|
|
329
|
+
{t('prov.seeded')} {done.seedCreated.map((s) => `${s.uid} (${s.count})`).join(', ')}
|
|
334
330
|
</Typography>
|
|
335
331
|
)}
|
|
336
332
|
<Typography variant="pi" textColor="neutral700" tag="div">
|
|
337
|
-
|
|
333
|
+
{t('prov.frontendAt')} <code>{done.frontendDir}</code>
|
|
338
334
|
</Typography>
|
|
339
335
|
</Box>
|
|
340
336
|
<Box paddingTop={3}>
|
|
341
337
|
<Typography variant="pi" textColor="neutral700" tag="div">
|
|
342
|
-
|
|
338
|
+
{t('prov.runFrontend')}
|
|
343
339
|
</Typography>
|
|
344
340
|
<Box background="neutral0" padding={2} hasRadius marginTop={1}
|
|
345
341
|
style={{ fontFamily: 'monospace', fontSize: 12 }}>
|
|
@@ -348,12 +344,11 @@ const ProvisionPage = () => {
|
|
|
348
344
|
</Box>
|
|
349
345
|
<Box paddingTop={3}>
|
|
350
346
|
<Typography variant="pi" textColor="neutral700" tag="div">
|
|
351
|
-
|
|
352
|
-
Strapi, mantendo as imagens. Os componentes não mudam.
|
|
347
|
+
{t('prov.relinkDesc')}
|
|
353
348
|
</Typography>
|
|
354
349
|
<Box paddingTop={1}>
|
|
355
350
|
<Button onClick={integrate} loading={integrating} variant="default">
|
|
356
|
-
|
|
351
|
+
{t('prov.relink')}
|
|
357
352
|
</Button>
|
|
358
353
|
</Box>
|
|
359
354
|
{integrateMsg && (
|
|
@@ -367,9 +362,9 @@ const ProvisionPage = () => {
|
|
|
367
362
|
|
|
368
363
|
<Flex gap={2} paddingTop={3}>
|
|
369
364
|
<a href={done.previewUrl} target="_blank" rel="noreferrer">
|
|
370
|
-
<Button variant="success">
|
|
365
|
+
<Button variant="success">{t('prov.open')} {done.previewUrl} ↗</Button>
|
|
371
366
|
</a>
|
|
372
|
-
<Button variant="tertiary" onClick={reset}>
|
|
367
|
+
<Button variant="tertiary" onClick={reset}>{t('prov.provisionAnother')}</Button>
|
|
373
368
|
</Flex>
|
|
374
369
|
</Box>
|
|
375
370
|
)}
|
package/dist/server/index.js
CHANGED
|
@@ -36,12 +36,12 @@ module.exports = __toCommonJS(index_exports);
|
|
|
36
36
|
// server/src/controllers/chat.ts
|
|
37
37
|
var chat_default = ({ strapi }) => ({
|
|
38
38
|
async message(ctx) {
|
|
39
|
-
const { messages, image, lang, previewUrl } = ctx.request.body || {};
|
|
39
|
+
const { messages, image, lang, previewUrl, autoPublish } = ctx.request.body || {};
|
|
40
40
|
if (!Array.isArray(messages) || messages.length === 0) {
|
|
41
41
|
return ctx.badRequest('Campo "messages" (array) \xE9 obrigat\xF3rio.');
|
|
42
42
|
}
|
|
43
43
|
try {
|
|
44
|
-
const result = await strapi.plugin("mcp-chat").service("chat").chat({ messages, image, lang, previewUrl });
|
|
44
|
+
const result = await strapi.plugin("mcp-chat").service("chat").chat({ messages, image, lang, previewUrl, autoPublish });
|
|
45
45
|
ctx.body = result;
|
|
46
46
|
} catch (e) {
|
|
47
47
|
strapi.log.error(`[mcp-chat] ${e?.message || e}`);
|
|
@@ -1712,6 +1712,18 @@ export function __getLocale(): string {
|
|
|
1712
1712
|
} catch {}
|
|
1713
1713
|
return __defaultLocale;
|
|
1714
1714
|
}
|
|
1715
|
+
/** Status ativo: ?preview=1 ou ?status=draft na URL \u2192 rascunho (preview do
|
|
1716
|
+
* mcp-chat em modo Draft). Caso contr\xE1rio, publicado. S\xF3 no cliente; no SSR
|
|
1717
|
+
* cai para "published" (ver a nota de draft preview no README). */
|
|
1718
|
+
export function __getStatus(): "draft" | "published" {
|
|
1719
|
+
try {
|
|
1720
|
+
if (typeof window !== "undefined") {
|
|
1721
|
+
const sp = new URL(window.location.href).searchParams;
|
|
1722
|
+
if (sp.get("preview") === "1" || sp.get("status") === "draft") return "draft";
|
|
1723
|
+
}
|
|
1724
|
+
} catch {}
|
|
1725
|
+
return "published";
|
|
1726
|
+
}
|
|
1715
1727
|
|
|
1716
1728
|
${mapperCode}
|
|
1717
1729
|
|
|
@@ -1719,11 +1731,13 @@ const __store: Record<string, any> = {};
|
|
|
1719
1731
|
export function hydrate(d: any) { if (d) for (const k of Object.keys(d)) __store[k] = d[k]; }
|
|
1720
1732
|
|
|
1721
1733
|
export async function loadAllData(opts: { locale?: string; status?: "draft" | "published" } = {}) {
|
|
1734
|
+
// Sem status expl\xEDcito, herda do flag de preview na URL (?preview=1 \u2192 draft).
|
|
1735
|
+
const __opts = { locale: opts.locale, status: opts.status || __getStatus() };
|
|
1722
1736
|
const raw: Record<string, any> = {};
|
|
1723
1737
|
await Promise.all(
|
|
1724
1738
|
__cts.map(async (c: any) => {
|
|
1725
1739
|
try {
|
|
1726
|
-
raw[c.s] = c.k === "singleType" ? await fetchSingle(c.s,
|
|
1740
|
+
raw[c.s] = c.k === "singleType" ? await fetchSingle(c.s, __opts) : await fetchCollection(c.p, __opts);
|
|
1727
1741
|
} catch {
|
|
1728
1742
|
raw[c.s] = c.k === "singleType" ? null : [];
|
|
1729
1743
|
}
|
|
@@ -2458,6 +2472,7 @@ function createContentTools(strapi) {
|
|
|
2458
2472
|
(ct) => ct.uid?.startsWith("api::")
|
|
2459
2473
|
);
|
|
2460
2474
|
const attrsOf = (uid) => strapi.contentTypes?.[uid]?.attributes || strapi.components?.[uid]?.attributes || {};
|
|
2475
|
+
const hasDraftAndPublish = (uid) => strapi.contentTypes?.[uid]?.options?.draftAndPublish === true;
|
|
2461
2476
|
const buildPopulate = (attributes, seen = /* @__PURE__ */ new Set()) => {
|
|
2462
2477
|
const populate = {};
|
|
2463
2478
|
for (const [name, a] of Object.entries(attributes)) {
|
|
@@ -2517,6 +2532,7 @@ function createContentTools(strapi) {
|
|
|
2517
2532
|
} catch {
|
|
2518
2533
|
continue;
|
|
2519
2534
|
}
|
|
2535
|
+
const dp = hasDraftAndPublish(ct.uid);
|
|
2520
2536
|
for (const e of entries) {
|
|
2521
2537
|
walkFind(e, attributes, [], needle, (path9, campo, valor) => {
|
|
2522
2538
|
matches.push({
|
|
@@ -2525,7 +2541,10 @@ function createContentTools(strapi) {
|
|
|
2525
2541
|
documentId: e.documentId,
|
|
2526
2542
|
path: path9,
|
|
2527
2543
|
campo,
|
|
2528
|
-
valor_atual: valor.length > 300 ? valor.slice(0, 300) + "\u2026" : valor
|
|
2544
|
+
valor_atual: valor.length > 300 ? valor.slice(0, 300) + "\u2026" : valor,
|
|
2545
|
+
// draftAndPublish=false → não há rascunho; a edição já é o conteúdo
|
|
2546
|
+
// vivo e não há o que publicar (a IA deve avisar o usuário).
|
|
2547
|
+
draftAndPublish: dp
|
|
2529
2548
|
});
|
|
2530
2549
|
});
|
|
2531
2550
|
}
|
|
@@ -2572,7 +2591,7 @@ function createContentTools(strapi) {
|
|
|
2572
2591
|
const loc = locale ? { locale } : {};
|
|
2573
2592
|
if (p.length === 1 && ad && TEXTUAL.includes(ad.type)) {
|
|
2574
2593
|
const updated2 = await strapi.documents(uid).update({ documentId, ...loc, data: { [topAttr]: novo_valor } });
|
|
2575
|
-
return { ok: true, uid, documentId: updated2?.documentId || documentId, path: p, novo_valor, locale };
|
|
2594
|
+
return { ok: true, uid, documentId: updated2?.documentId || documentId, path: p, novo_valor, locale, draftAndPublish: hasDraftAndPublish(uid) };
|
|
2576
2595
|
}
|
|
2577
2596
|
const populate = buildPopulate(attributes);
|
|
2578
2597
|
const entry = await strapi.documents(uid).findOne({ documentId, status: "draft", ...loc, populate });
|
|
@@ -2586,13 +2605,22 @@ function createContentTools(strapi) {
|
|
|
2586
2605
|
cur[p[p.length - 1]] = novo_valor;
|
|
2587
2606
|
const data = { [topAttr]: sanitizeAttr(entry[topAttr], ad) };
|
|
2588
2607
|
const updated = await strapi.documents(uid).update({ documentId, ...loc, data });
|
|
2589
|
-
return { ok: true, uid, documentId: updated?.documentId || documentId, path: p, novo_valor, locale };
|
|
2608
|
+
return { ok: true, uid, documentId: updated?.documentId || documentId, path: p, novo_valor, locale, draftAndPublish: hasDraftAndPublish(uid) };
|
|
2590
2609
|
};
|
|
2591
2610
|
const publicar = async ({
|
|
2592
2611
|
uid,
|
|
2593
2612
|
documentId,
|
|
2594
2613
|
locale
|
|
2595
2614
|
}) => {
|
|
2615
|
+
if (!hasDraftAndPublish(uid)) {
|
|
2616
|
+
return {
|
|
2617
|
+
ok: true,
|
|
2618
|
+
uid,
|
|
2619
|
+
documentId,
|
|
2620
|
+
status: "no-draft-publish",
|
|
2621
|
+
nota: "Esta content-type n\xE3o tem Draft & Publish; n\xE3o h\xE1 rascunho a publicar \u2014 a altera\xE7\xE3o j\xE1 est\xE1 no ar."
|
|
2622
|
+
};
|
|
2623
|
+
}
|
|
2596
2624
|
await strapi.documents(uid).publish({ documentId, ...locale ? { locale } : {} });
|
|
2597
2625
|
return { ok: true, uid, documentId, status: "published", locale };
|
|
2598
2626
|
};
|
|
@@ -2722,7 +2750,7 @@ function createContentTools(strapi) {
|
|
|
2722
2750
|
if (!Object.keys(data).length) continue;
|
|
2723
2751
|
await strapi.documents(ct.uid).update({ documentId: e.documentId, locale: tgt, data });
|
|
2724
2752
|
documentos += 1;
|
|
2725
|
-
if (publish) {
|
|
2753
|
+
if (publish && hasDraftAndPublish(ct.uid)) {
|
|
2726
2754
|
await strapi.documents(ct.uid).publish({ documentId: e.documentId, locale: tgt });
|
|
2727
2755
|
publicados += 1;
|
|
2728
2756
|
}
|
|
@@ -2779,7 +2807,7 @@ var openAiToolSpecs = [
|
|
|
2779
2807
|
type: "function",
|
|
2780
2808
|
function: {
|
|
2781
2809
|
name: "publicar",
|
|
2782
|
-
description: 'Publica a entrada (torna a altera\xE7\xE3o vis\xEDvel no site p\xFAblico). Passe "locale" para publicar um idioma espec\xEDfico, ou "*" para todos.',
|
|
2810
|
+
description: 'Publica a entrada (torna a altera\xE7\xE3o vis\xEDvel no site p\xFAblico). Passe "locale" para publicar um idioma espec\xEDfico, ou "*" para todos. Se a content-type N\xC3O tiver Draft & Publish, n\xE3o h\xE1 o que publicar: retorna status "no-draft-publish" (a edi\xE7\xE3o j\xE1 est\xE1 no ar) \u2014 avise o usu\xE1rio em vez de tentar publicar de novo.',
|
|
2783
2811
|
parameters: {
|
|
2784
2812
|
type: "object",
|
|
2785
2813
|
properties: {
|
|
@@ -2955,9 +2983,9 @@ Ferramentas de conte\xFAdo:
|
|
|
2955
2983
|
Fluxo padr\xE3o quando o usu\xE1rio pede uma mudan\xE7a no site (por texto, voz ou mostrando a tela):
|
|
2956
2984
|
1. Use buscar_texto com um trecho distintivo do texto a alterar (sem r\xF3tulos de status).
|
|
2957
2985
|
2. Se houver mais de um resultado, escolha o mais prov\xE1vel pelo contexto (e diga qual escolheu); se amb\xEDguo de verdade, pergunte.
|
|
2958
|
-
3. editar_campo passando o mesmo uid, documentId e path do resultado, com o novo valor.
|
|
2959
|
-
4.
|
|
2960
|
-
5. Confirme em 1 frase o que foi alterado
|
|
2986
|
+
3. editar_campo passando o mesmo uid, documentId e path do resultado, com o novo valor. Isso salva como RASCUNHO (n\xE3o publica).
|
|
2987
|
+
4. Decida se publica ou n\xE3o conforme a POL\xCDTICA DE PUBLICA\xC7\xC3O indicada mais abaixo.
|
|
2988
|
+
5. Confirme em 1 frase o que foi alterado (content-type, campo, antes \u2192 depois) e se ficou como rascunho ou foi publicado.
|
|
2961
2989
|
|
|
2962
2990
|
Ferramentas de tradu\xE7\xE3o / idiomas (i18n):
|
|
2963
2991
|
- listar_locales(): mostra os idiomas configurados e o default.
|
|
@@ -2971,6 +2999,8 @@ Fluxo quando o usu\xE1rio pede tradu\xE7\xE3o (ex.: "quero o site todo em pt-BR"
|
|
|
2971
2999
|
3. Ap\xF3s o restart, ao repetir, traduzir funciona e localiza tudo.
|
|
2972
3000
|
4. Confirme em 1 frase: idiomas, quantos documentos e campos foram traduzidos/publicados (use o resumo retornado, n\xE3o despeje o conte\xFAdo).
|
|
2973
3001
|
|
|
3002
|
+
Draft & Publish: cada resultado de buscar_texto traz "draftAndPublish". Se for false, aquele tipo N\xC3O tem rascunho no Strapi \u2014 a edi\xE7\xE3o j\xE1 \xE9 o conte\xFAdo vivo e N\xC3O h\xE1 o que publicar; nesse caso, ao confirmar, avise que "esse conte\xFAdo n\xE3o tem rascunho, a altera\xE7\xE3o j\xE1 est\xE1 no ar" e N\xC3O chame publicar.
|
|
3003
|
+
|
|
2974
3004
|
Se o usu\xE1rio compartilhar a tela, uma imagem \xE9 anexada \xE0 \xFAltima mensagem \u2014 use-a para entender exatamente o que ele est\xE1 vendo e qual texto quer trocar.
|
|
2975
3005
|
|
|
2976
3006
|
Seja objetivo e acion\xE1vel. Responda SEMPRE em portugu\xEAs.`,
|
|
@@ -2984,9 +3014,9 @@ Content tools:
|
|
|
2984
3014
|
Default flow when the user asks for a site change (by text, voice or by showing their screen):
|
|
2985
3015
|
1. Use buscar_texto with a distinctive snippet of the text to change (no status labels).
|
|
2986
3016
|
2. If there is more than one result, pick the most likely from context (and say which); if truly ambiguous, ask.
|
|
2987
|
-
3. editar_campo passing the same uid, documentId and path from the result, with the new value.
|
|
2988
|
-
4.
|
|
2989
|
-
5. Confirm in one sentence what was changed
|
|
3017
|
+
3. editar_campo passing the same uid, documentId and path from the result, with the new value. This saves a DRAFT (does not publish).
|
|
3018
|
+
4. Decide whether to publish based on the PUBLISH POLICY stated below.
|
|
3019
|
+
5. Confirm in one sentence what was changed (content-type, field, before \u2192 after) and whether it stayed a draft or was published.
|
|
2990
3020
|
|
|
2991
3021
|
Translation / language tools (i18n):
|
|
2992
3022
|
- listar_locales(): shows configured languages and the default.
|
|
@@ -3000,12 +3030,14 @@ Flow when the user asks for translation (e.g. "I want the whole site in pt-BR"):
|
|
|
3000
3030
|
3. After the restart, repeating the request makes traduzir localize everything.
|
|
3001
3031
|
4. Confirm in one sentence: languages, how many documents and fields were translated/published (use the returned summary, don't dump the content).
|
|
3002
3032
|
|
|
3033
|
+
Draft & Publish: each buscar_texto result includes "draftAndPublish". If it is false, that type has NO draft in Strapi \u2014 the edit IS the live content and there is nothing to publish; in that case, when confirming, warn that "this content has no draft, the change is already live" and do NOT call publicar.
|
|
3034
|
+
|
|
3003
3035
|
If the user shares their screen, an image is attached to the last message \u2014 use it to understand exactly what they see and which text they want to change.
|
|
3004
3036
|
|
|
3005
3037
|
Be concise and actionable. ALWAYS answer in English.`
|
|
3006
3038
|
};
|
|
3007
3039
|
var chat_default2 = ({ strapi }) => ({
|
|
3008
|
-
async chat({ messages, image, lang = "pt", previewUrl }) {
|
|
3040
|
+
async chat({ messages, image, lang = "pt", previewUrl, autoPublish = false }) {
|
|
3009
3041
|
const apiKey = process.env.OPENAI_API_KEY;
|
|
3010
3042
|
if (!apiKey) {
|
|
3011
3043
|
throw new Error(
|
|
@@ -3062,7 +3094,19 @@ Voc\xEA tamb\xE9m controla um navegador real via ferramentas browser_* (Playwrig
|
|
|
3062
3094
|
|
|
3063
3095
|
You also control a real browser via browser_* tools (Playwright), pointed at the STRAPI ADMIN at ${adminBase} (the backend \u2014 this is where content actually changes, NOT the public site). You can navigate (browser_navigate), click, type, scroll, take your own screenshots (browser_take_screenshot) and inspect console/errors. Always prefer your direct tools (buscar_texto/editar_campo/publicar) to change content; use the browser to VERIFY in the admin that the edit/publish landed, or for admin UI flows the direct tools don't cover.`
|
|
3064
3096
|
};
|
|
3065
|
-
const
|
|
3097
|
+
const PUBLISH_POLICY = {
|
|
3098
|
+
pt: autoPublish ? `
|
|
3099
|
+
|
|
3100
|
+
POL\xCDTICA DE PUBLICA\xC7\xC3O: AUTO-PUBLICAR est\xE1 LIGADO. Depois de editar_campo, chame publicar para deixar a mudan\xE7a no ar. Em traduzir, use publish:true (default).` : `
|
|
3101
|
+
|
|
3102
|
+
POL\xCDTICA DE PUBLICA\xC7\xC3O: MODO RASCUNHO (auto-publicar DESLIGADO). N\xC3O chame publicar a menos que o usu\xE1rio pe\xE7a explicitamente ("publica", "p\xF5e no ar", "publish"). Depois de editar_campo, PARE e avise que a altera\xE7\xE3o foi salva como RASCUNHO para revis\xE3o (ela j\xE1 aparece no preview em modo rascunho, mas ainda n\xE3o no site p\xFAblico). Em traduzir, passe publish:false. Se o usu\xE1rio pedir para publicar, a\xED sim use publicar (ou traduzir com publish:true).`,
|
|
3103
|
+
en: autoPublish ? `
|
|
3104
|
+
|
|
3105
|
+
PUBLISH POLICY: AUTO-PUBLISH is ON. After editar_campo, call publicar to make the change live. For traduzir, use publish:true (default).` : `
|
|
3106
|
+
|
|
3107
|
+
PUBLISH POLICY: DRAFT MODE (auto-publish OFF). Do NOT call publicar unless the user explicitly asks ("publish", "make it live", "publica"). After editar_campo, STOP and tell them the change was saved as a DRAFT for review (it already shows in the preview when in draft mode, but not on the public site yet). For traduzir, pass publish:false. If the user asks to publish, then use publicar (or traduzir with publish:true).`
|
|
3108
|
+
};
|
|
3109
|
+
const systemContent = SYSTEM[language] + (hasBrowser ? BROWSER_NOTE[language] : "") + PUBLISH_POLICY[language];
|
|
3066
3110
|
const convo = [{ role: "system", content: systemContent }];
|
|
3067
3111
|
const pageNote = previewUrl ? language === "en" ? `
|
|
3068
3112
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "strapi-plugin-mcp-chat",
|
|
3
|
-
"version": "0.1
|
|
3
|
+
"version": "0.3.1",
|
|
4
4
|
"description": "AI chat inside the Strapi 5 admin that reads and edits your content (incl. components & dynamic zones) via MCP, with voice and a side-by-side live preview.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"strapi",
|
|
@@ -37,6 +37,12 @@ export function createContentTools(strapi: any) {
|
|
|
37
37
|
strapi.components?.[uid]?.attributes ||
|
|
38
38
|
{}) as Record<string, any>;
|
|
39
39
|
|
|
40
|
+
// Draft & Publish é por content-type e vem DESLIGADO por padrão (docs Strapi 5).
|
|
41
|
+
// `publish()`/`unpublish()` SÓ existem quando está ligado — chamar sem D&P
|
|
42
|
+
// lança erro. Por isso checamos antes de publicar.
|
|
43
|
+
const hasDraftAndPublish = (uid: string): boolean =>
|
|
44
|
+
strapi.contentTypes?.[uid]?.options?.draftAndPublish === true;
|
|
45
|
+
|
|
40
46
|
// Populate profundo: components (simples/repetíveis), dynamic zones (com `on`
|
|
41
47
|
// por componente) e mídia/relações. `seen` evita recursão infinita.
|
|
42
48
|
const buildPopulate = (attributes: Record<string, any>, seen = new Set<string>()): any => {
|
|
@@ -112,6 +118,7 @@ export function createContentTools(strapi: any) {
|
|
|
112
118
|
} catch {
|
|
113
119
|
continue;
|
|
114
120
|
}
|
|
121
|
+
const dp = hasDraftAndPublish(ct.uid);
|
|
115
122
|
for (const e of entries) {
|
|
116
123
|
walkFind(e, attributes, [], needle, (path, campo, valor) => {
|
|
117
124
|
matches.push({
|
|
@@ -121,6 +128,9 @@ export function createContentTools(strapi: any) {
|
|
|
121
128
|
path,
|
|
122
129
|
campo,
|
|
123
130
|
valor_atual: valor.length > 300 ? valor.slice(0, 300) + '…' : valor,
|
|
131
|
+
// draftAndPublish=false → não há rascunho; a edição já é o conteúdo
|
|
132
|
+
// vivo e não há o que publicar (a IA deve avisar o usuário).
|
|
133
|
+
draftAndPublish: dp,
|
|
124
134
|
});
|
|
125
135
|
});
|
|
126
136
|
}
|
|
@@ -179,7 +189,7 @@ export function createContentTools(strapi: any) {
|
|
|
179
189
|
const updated = await strapi
|
|
180
190
|
.documents(uid)
|
|
181
191
|
.update({ documentId, ...loc, data: { [topAttr]: novo_valor } });
|
|
182
|
-
return { ok: true, uid, documentId: updated?.documentId || documentId, path: p, novo_valor, locale };
|
|
192
|
+
return { ok: true, uid, documentId: updated?.documentId || documentId, path: p, novo_valor, locale, draftAndPublish: hasDraftAndPublish(uid) };
|
|
183
193
|
}
|
|
184
194
|
|
|
185
195
|
// Campo aninhado → busca profunda, muta no caminho, sanitiza e regrava o
|
|
@@ -196,7 +206,7 @@ export function createContentTools(strapi: any) {
|
|
|
196
206
|
cur[p[p.length - 1] as any] = novo_valor;
|
|
197
207
|
const data = { [topAttr]: sanitizeAttr(entry[topAttr], ad) };
|
|
198
208
|
const updated = await strapi.documents(uid).update({ documentId, ...loc, data });
|
|
199
|
-
return { ok: true, uid, documentId: updated?.documentId || documentId, path: p, novo_valor, locale };
|
|
209
|
+
return { ok: true, uid, documentId: updated?.documentId || documentId, path: p, novo_valor, locale, draftAndPublish: hasDraftAndPublish(uid) };
|
|
200
210
|
};
|
|
201
211
|
|
|
202
212
|
const publicar = async ({
|
|
@@ -209,6 +219,17 @@ export function createContentTools(strapi: any) {
|
|
|
209
219
|
/** Locale a publicar; "*" publica todos os locales disponíveis. */
|
|
210
220
|
locale?: string;
|
|
211
221
|
}) => {
|
|
222
|
+
// Best practice Strapi 5: publish() só existe com Draft & Publish ligado;
|
|
223
|
+
// chamar sem D&P lança erro. Sem D&P não há rascunho — a edição já é o vivo.
|
|
224
|
+
if (!hasDraftAndPublish(uid)) {
|
|
225
|
+
return {
|
|
226
|
+
ok: true,
|
|
227
|
+
uid,
|
|
228
|
+
documentId,
|
|
229
|
+
status: 'no-draft-publish',
|
|
230
|
+
nota: 'Esta content-type não tem Draft & Publish; não há rascunho a publicar — a alteração já está no ar.',
|
|
231
|
+
};
|
|
232
|
+
}
|
|
212
233
|
await strapi.documents(uid).publish({ documentId, ...(locale ? { locale } : {}) });
|
|
213
234
|
return { ok: true, uid, documentId, status: 'published', locale };
|
|
214
235
|
};
|
|
@@ -374,7 +395,8 @@ export function createContentTools(strapi: any) {
|
|
|
374
395
|
// upsert idempotente da versão do locale
|
|
375
396
|
await strapi.documents(ct.uid).update({ documentId: e.documentId, locale: tgt, data });
|
|
376
397
|
documentos += 1;
|
|
377
|
-
|
|
398
|
+
// Só publica se a CT tiver Draft & Publish (senão publish() lança erro).
|
|
399
|
+
if (publish && hasDraftAndPublish(ct.uid)) {
|
|
378
400
|
await strapi.documents(ct.uid).publish({ documentId: e.documentId, locale: tgt });
|
|
379
401
|
publicados += 1;
|
|
380
402
|
}
|
|
@@ -439,7 +461,7 @@ export const openAiToolSpecs = [
|
|
|
439
461
|
type: 'function',
|
|
440
462
|
function: {
|
|
441
463
|
name: 'publicar',
|
|
442
|
-
description: 'Publica a entrada (torna a alteração visível no site público). Passe "locale" para publicar um idioma específico, ou "*" para todos.',
|
|
464
|
+
description: 'Publica a entrada (torna a alteração visível no site público). Passe "locale" para publicar um idioma específico, ou "*" para todos. Se a content-type NÃO tiver Draft & Publish, não há o que publicar: retorna status "no-draft-publish" (a edição já está no ar) — avise o usuário em vez de tentar publicar de novo.',
|
|
443
465
|
parameters: {
|
|
444
466
|
type: 'object',
|
|
445
467
|
properties: {
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
export default ({ strapi }: { strapi: any }) => ({
|
|
6
6
|
async message(ctx: any) {
|
|
7
|
-
const { messages, image, lang, previewUrl } = ctx.request.body || {};
|
|
7
|
+
const { messages, image, lang, previewUrl, autoPublish } = ctx.request.body || {};
|
|
8
8
|
if (!Array.isArray(messages) || messages.length === 0) {
|
|
9
9
|
return ctx.badRequest('Campo "messages" (array) é obrigatório.');
|
|
10
10
|
}
|
|
@@ -12,7 +12,7 @@ export default ({ strapi }: { strapi: any }) => ({
|
|
|
12
12
|
const result = await strapi
|
|
13
13
|
.plugin('mcp-chat')
|
|
14
14
|
.service('chat')
|
|
15
|
-
.chat({ messages, image, lang, previewUrl });
|
|
15
|
+
.chat({ messages, image, lang, previewUrl, autoPublish });
|
|
16
16
|
ctx.body = result;
|
|
17
17
|
} catch (e: any) {
|
|
18
18
|
strapi.log.error(`[mcp-chat] ${e?.message || e}`);
|
|
@@ -558,6 +558,18 @@ export function __getLocale(): string {
|
|
|
558
558
|
} catch {}
|
|
559
559
|
return __defaultLocale;
|
|
560
560
|
}
|
|
561
|
+
/** Status ativo: ?preview=1 ou ?status=draft na URL → rascunho (preview do
|
|
562
|
+
* mcp-chat em modo Draft). Caso contrário, publicado. Só no cliente; no SSR
|
|
563
|
+
* cai para "published" (ver a nota de draft preview no README). */
|
|
564
|
+
export function __getStatus(): "draft" | "published" {
|
|
565
|
+
try {
|
|
566
|
+
if (typeof window !== "undefined") {
|
|
567
|
+
const sp = new URL(window.location.href).searchParams;
|
|
568
|
+
if (sp.get("preview") === "1" || sp.get("status") === "draft") return "draft";
|
|
569
|
+
}
|
|
570
|
+
} catch {}
|
|
571
|
+
return "published";
|
|
572
|
+
}
|
|
561
573
|
|
|
562
574
|
${mapperCode}
|
|
563
575
|
|
|
@@ -565,11 +577,13 @@ const __store: Record<string, any> = {};
|
|
|
565
577
|
export function hydrate(d: any) { if (d) for (const k of Object.keys(d)) __store[k] = d[k]; }
|
|
566
578
|
|
|
567
579
|
export async function loadAllData(opts: { locale?: string; status?: "draft" | "published" } = {}) {
|
|
580
|
+
// Sem status explícito, herda do flag de preview na URL (?preview=1 → draft).
|
|
581
|
+
const __opts = { locale: opts.locale, status: opts.status || __getStatus() };
|
|
568
582
|
const raw: Record<string, any> = {};
|
|
569
583
|
await Promise.all(
|
|
570
584
|
__cts.map(async (c: any) => {
|
|
571
585
|
try {
|
|
572
|
-
raw[c.s] = c.k === "singleType" ? await fetchSingle(c.s,
|
|
586
|
+
raw[c.s] = c.k === "singleType" ? await fetchSingle(c.s, __opts) : await fetchCollection(c.p, __opts);
|
|
573
587
|
} catch {
|
|
574
588
|
raw[c.s] = c.k === "singleType" ? null : [];
|
|
575
589
|
}
|