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.
@@ -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(`Aviso da análise:\n• ${res.errors.join('\n• ')}`);
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, 'Falha ao analisar o projeto.'));
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('O manifest não é um JSON válido. Corrija a sintaxe.');
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, 'Falha na provisão.'));
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('A provisão demorou mais que o esperado. Verifique o terminal da Strapi.');
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, 'Falha ao religar.')}`);
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">Provisionar frontend</Typography>
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
- <Link to="..">
204
- <Button variant="tertiary">← Voltar ao chat</Button>
205
- </Link>
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">1. Escolha o .zip do frontend</Typography>
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
- Selecionar arquivo…
244
+ {t('prov.selectFile')}
240
245
  </Button>
241
246
  <Typography textColor={file ? 'neutral800' : 'neutral500'}>
242
- {file ? file.name : 'Nenhum arquivo selecionado'}
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
- Analisar projeto
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>Analisando…</Loader>
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">2. Revise o modelo de conteúdo</Typography>
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 ? '🤖 Inferido pela IA' : '✓ Manifest do projeto'} • framework: {framework}
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
- Analisou: {filesAnalyzed.slice(0, 6).join(', ')}
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
- Provisionar
293
+ {t('prov.provision')}
293
294
  </Button>
294
- <Button variant="tertiary" onClick={reset}>Recomeçar</Button>
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>Provisionando…</Loader>
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
- ✅ Tudo pronto! Você já pode ver o preview.
321
+ {t('prov.doneTitle')}
326
322
  </Typography>
327
323
  <Box paddingTop={3}>
328
324
  <Typography variant="pi" textColor="neutral700" tag="div">
329
- Content-types criadas: {done.contentTypes.join(', ')}
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
- Conteúdo semeado: {done.seedCreated.map((s) => `${s.uid} (${s.count})`).join(', ')}
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
- Frontend em: <code>{done.frontendDir}</code>
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
- Para ver o preview, rode o frontend (uma vez):
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
- Religar o frontend ao Strapi (snapshot): troca os dados hardcoded pelos do
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
- Religar dados ao Strapi
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">Abrir {done.previewUrl} ↗</Button>
365
+ <Button variant="success">{t('prov.open')} {done.previewUrl} ↗</Button>
371
366
  </a>
372
- <Button variant="tertiary" onClick={reset}>Provisionar outro</Button>
367
+ <Button variant="tertiary" onClick={reset}>{t('prov.provisionAnother')}</Button>
373
368
  </Flex>
374
369
  </Box>
375
370
  )}
@@ -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, opts) : await fetchCollection(c.p, opts);
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. publicar a entrada.
2960
- 5. Confirme em 1 frase o que foi alterado e publicado (content-type, campo, antes \u2192 depois).
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. publicar the entry.
2989
- 5. Confirm in one sentence what was changed and published (content-type, field, before \u2192 after).
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 systemContent = SYSTEM[language] + (hasBrowser ? BROWSER_NOTE[language] : "");
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.0",
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
- if (publish) {
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, opts) : await fetchCollection(c.p, opts);
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
  }