strapi-plugin-mcp-chat 0.1.0 → 0.5.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/README.md +91 -9
- package/admin/src/components/AdminOverlays.tsx +35 -1
- package/admin/src/components/ErrorBoundary.tsx +29 -0
- package/admin/src/components/FloatingChat.tsx +22 -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/index.tsx +39 -5
- package/admin/src/pages/HomePage.tsx +55 -24
- package/admin/src/pages/ProvisionPage.tsx +54 -59
- package/dist/server/index.js +358 -200
- package/package.json +1 -1
- package/server/src/content-tools.ts +42 -8
- package/server/src/controllers/chat.ts +2 -2
- package/server/src/controllers/frontend.ts +7 -2
- package/server/src/index.ts +6 -1
- package/server/src/mcp/define.ts +72 -0
- package/server/src/mcp/index.ts +19 -6
- package/server/src/mcp/tools/buscar-texto.ts +18 -24
- package/server/src/mcp/tools/criar-locale.ts +20 -26
- package/server/src/mcp/tools/editar-campo.ts +29 -35
- package/server/src/mcp/tools/habilitar-i18n.ts +23 -29
- package/server/src/mcp/tools/listar-locales.ts +17 -23
- package/server/src/mcp/tools/publicar.ts +21 -27
- package/server/src/mcp/tools/traduzir.ts +26 -32
- package/server/src/mcp/types.ts +12 -9
- package/server/src/mcp-client.ts +15 -3
- package/server/src/provision/integrate.ts +15 -1
- package/server/src/provision/write.ts +92 -0
- package/server/src/services/chat.ts +56 -14
|
@@ -1,36 +1,30 @@
|
|
|
1
1
|
import { z } from '@strapi/utils';
|
|
2
|
-
import
|
|
2
|
+
import { defineTool } from '../define';
|
|
3
3
|
import { createContentTools } from '../../content-tools';
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
const r = await createContentTools(strapi).traduzir(args);
|
|
30
|
-
return { content: [{ type: 'text', text: JSON.stringify(r) }], structuredContent: r };
|
|
31
|
-
},
|
|
32
|
-
});
|
|
5
|
+
export default defineTool({
|
|
6
|
+
name: 'mcp_chat_traduzir',
|
|
7
|
+
title: 'Translate localized content',
|
|
8
|
+
description:
|
|
9
|
+
'Translate localized content into one or more languages. Creates missing locales, translates field by field (long text is split and reassembled, never overflows) and publishes (only on content-types with Draft & Publish). Without uid/documentId, translates ALL localized content-types. Handles many locales at once.',
|
|
10
|
+
resolveInputSchema: () =>
|
|
11
|
+
z.object({
|
|
12
|
+
target_locales: z.array(z.string()).min(1),
|
|
13
|
+
source_locale: z.string().optional(),
|
|
14
|
+
uid: z.string().optional(),
|
|
15
|
+
documentId: z.string().optional(),
|
|
16
|
+
publish: z.boolean().optional(),
|
|
17
|
+
}),
|
|
18
|
+
resolveOutputSchema: () =>
|
|
19
|
+
z.object({
|
|
20
|
+
ok: z.boolean().optional(),
|
|
21
|
+
source: z.string().optional(),
|
|
22
|
+
por_locale: z.array(z.any()).optional(),
|
|
23
|
+
erro: z.string().optional(),
|
|
24
|
+
}),
|
|
25
|
+
auth: { policies: [{ action: 'plugin::content-manager.explorer.update' }] },
|
|
26
|
+
createHandler: (strapi: any) => async ({ args }) => {
|
|
27
|
+
const r = await createContentTools(strapi).traduzir(args);
|
|
28
|
+
return { content: [{ type: 'text', text: JSON.stringify(r) }], structuredContent: r };
|
|
33
29
|
},
|
|
34
|
-
};
|
|
35
|
-
|
|
36
|
-
export default tool;
|
|
30
|
+
});
|
package/server/src/mcp/types.ts
CHANGED
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Tipos
|
|
3
|
-
*
|
|
4
|
-
*
|
|
2
|
+
* Tipos do MCP do plugin. As tools agora são DEFINIÇÕES puras criadas com
|
|
3
|
+
* `defineTool` (ver ./define.ts) e registradas a partir de um array — alinhado
|
|
4
|
+
* à direção do PR #26603 (`ai.mcp.defineTool`). Re-exportamos os tipos de
|
|
5
|
+
* `./define` aqui por conveniência/compatibilidade.
|
|
5
6
|
*/
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
7
|
+
export type {
|
|
8
|
+
McpToolDef,
|
|
9
|
+
McpResourceDef,
|
|
10
|
+
McpPromptDef,
|
|
11
|
+
McpToolResult,
|
|
12
|
+
McpAuth,
|
|
13
|
+
RegisterTool,
|
|
14
|
+
} from './define';
|
package/server/src/mcp-client.ts
CHANGED
|
@@ -14,6 +14,18 @@ const baseHeaders = {
|
|
|
14
14
|
Accept: 'application/json, text/event-stream',
|
|
15
15
|
};
|
|
16
16
|
|
|
17
|
+
/** fetch com timeout (AbortController) — nenhum endpoint MCP pode pendurar uma
|
|
18
|
+
* request do Strapi indefinidamente. */
|
|
19
|
+
const fetchT = async (url: string, opts: any, timeoutMs = 8000): Promise<Response> => {
|
|
20
|
+
const ctrl = new AbortController();
|
|
21
|
+
const t = setTimeout(() => ctrl.abort(), timeoutMs);
|
|
22
|
+
try {
|
|
23
|
+
return await fetch(url, { ...opts, signal: ctrl.signal });
|
|
24
|
+
} finally {
|
|
25
|
+
clearTimeout(t);
|
|
26
|
+
}
|
|
27
|
+
};
|
|
28
|
+
|
|
17
29
|
const parseSse = (text: string): any => {
|
|
18
30
|
const dataLines = text
|
|
19
31
|
.split('\n')
|
|
@@ -50,7 +62,7 @@ export class McpClient {
|
|
|
50
62
|
}
|
|
51
63
|
|
|
52
64
|
async init(): Promise<void> {
|
|
53
|
-
const res = await
|
|
65
|
+
const res = await fetchT(this.url, {
|
|
54
66
|
method: 'POST',
|
|
55
67
|
headers: this.headers(),
|
|
56
68
|
body: JSON.stringify({
|
|
@@ -67,7 +79,7 @@ export class McpClient {
|
|
|
67
79
|
this.sessionId = res.headers.get('mcp-session-id') || undefined;
|
|
68
80
|
await res.text();
|
|
69
81
|
// Notifica que o handshake terminou (sem corpo de resposta relevante).
|
|
70
|
-
await
|
|
82
|
+
await fetchT(this.url, {
|
|
71
83
|
method: 'POST',
|
|
72
84
|
headers: this.headers(),
|
|
73
85
|
body: JSON.stringify({ jsonrpc: '2.0', method: 'notifications/initialized' }),
|
|
@@ -75,7 +87,7 @@ export class McpClient {
|
|
|
75
87
|
}
|
|
76
88
|
|
|
77
89
|
private async rpc(method: string, params: any, id: number): Promise<any> {
|
|
78
|
-
const res = await
|
|
90
|
+
const res = await fetchT(this.url, {
|
|
79
91
|
method: 'POST',
|
|
80
92
|
headers: this.headers(),
|
|
81
93
|
body: JSON.stringify({ jsonrpc: '2.0', id, method, params }),
|
|
@@ -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
|
}
|
|
@@ -49,6 +49,58 @@ function isDev(): boolean {
|
|
|
49
49
|
return process.env.NODE_ENV === 'development';
|
|
50
50
|
}
|
|
51
51
|
|
|
52
|
+
// Tipos de atributo que a Strapi 5 aceita num schema.json. Um type fora desta
|
|
53
|
+
// lista faz a Strapi recusar o boot — então validamos ANTES de escrever.
|
|
54
|
+
const KNOWN_ATTR_TYPES = new Set([
|
|
55
|
+
'string', 'text', 'richtext', 'blocks', 'email', 'password', 'uid', 'enumeration',
|
|
56
|
+
'json', 'integer', 'biginteger', 'decimal', 'float', 'date', 'time', 'datetime',
|
|
57
|
+
'timestamp', 'boolean', 'media', 'relation', 'component', 'dynamiczone',
|
|
58
|
+
]);
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Valida o conteúdo de um schema.json gerado contra o formato que a Strapi 5
|
|
62
|
+
* exige. Retorna a lista de erros (vazia = ok). É a trava que garante que uma
|
|
63
|
+
* provisão NUNCA escreva um schema que impeça o Strapi de bootar.
|
|
64
|
+
*/
|
|
65
|
+
function validateApi(api: GeneratedApi): string[] {
|
|
66
|
+
const errs: string[] = [];
|
|
67
|
+
const rel = Object.keys(api.files).find((r) => r.endsWith('schema.json'));
|
|
68
|
+
if (!rel) {
|
|
69
|
+
errs.push(`${api.singularName}: schema.json ausente nos arquivos gerados`);
|
|
70
|
+
return errs;
|
|
71
|
+
}
|
|
72
|
+
let schema: any;
|
|
73
|
+
try {
|
|
74
|
+
schema = JSON.parse(api.files[rel]);
|
|
75
|
+
} catch (e: any) {
|
|
76
|
+
errs.push(`${api.singularName}: schema.json não é JSON válido (${e?.message ?? e})`);
|
|
77
|
+
return errs;
|
|
78
|
+
}
|
|
79
|
+
if (schema?.kind !== 'collectionType' && schema?.kind !== 'singleType') {
|
|
80
|
+
errs.push(`${api.singularName}: "kind" inválido (${schema?.kind})`);
|
|
81
|
+
}
|
|
82
|
+
if (!schema?.info?.singularName || !schema?.info?.pluralName) {
|
|
83
|
+
errs.push(`${api.singularName}: info.singularName/pluralName obrigatórios`);
|
|
84
|
+
}
|
|
85
|
+
const attrs = schema?.attributes;
|
|
86
|
+
if (!attrs || typeof attrs !== 'object') {
|
|
87
|
+
errs.push(`${api.singularName}: "attributes" ausente ou inválido`);
|
|
88
|
+
} else {
|
|
89
|
+
for (const [name, a] of Object.entries(attrs) as any[]) {
|
|
90
|
+
if (!a || typeof a !== 'object' || !a.type) {
|
|
91
|
+
errs.push(`${api.singularName}.${name}: atributo sem "type"`);
|
|
92
|
+
} else if (!KNOWN_ATTR_TYPES.has(a.type)) {
|
|
93
|
+
errs.push(`${api.singularName}.${name}: type desconhecido "${a.type}"`);
|
|
94
|
+
} else if (a.type === 'relation' && !a.target) {
|
|
95
|
+
errs.push(`${api.singularName}.${name}: relation sem "target"`);
|
|
96
|
+
} else if (a.type === 'component' && !a.component) {
|
|
97
|
+
errs.push(`${api.singularName}.${name}: component sem "component"`);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
return errs;
|
|
102
|
+
}
|
|
103
|
+
|
|
52
104
|
/**
|
|
53
105
|
* Escreve as content-types geradas em src/api. Aditivo e idempotente: o que já
|
|
54
106
|
* existe é preservado e reportado em `skipped`.
|
|
@@ -80,6 +132,46 @@ export function writeApis(
|
|
|
80
132
|
return result;
|
|
81
133
|
}
|
|
82
134
|
|
|
135
|
+
// ── Validação ALL-OR-NOTHING (antes de tocar no disco) ──────────────────────
|
|
136
|
+
// Só serão escritas as apis que ainda não existem (trava aditiva). Validamos
|
|
137
|
+
// TODAS elas; se UMA for inválida, não escrevemos NENHUMA — assim uma provisão
|
|
138
|
+
// jamais pode deixar o Strapi com um schema quebrado e sem bootar.
|
|
139
|
+
const toWrite = apis.filter((api) => !fs.existsSync(path.join(opts.apiRoot, api.singularName)));
|
|
140
|
+
const knownSingulars = new Set<string>([
|
|
141
|
+
...apis.map((a) => a.singularName),
|
|
142
|
+
...(fs.existsSync(opts.apiRoot)
|
|
143
|
+
? fs.readdirSync(opts.apiRoot, { withFileTypes: true }).filter((d) => d.isDirectory()).map((d) => d.name)
|
|
144
|
+
: []),
|
|
145
|
+
]);
|
|
146
|
+
const validationErrors: string[] = [];
|
|
147
|
+
for (const api of toWrite) {
|
|
148
|
+
validationErrors.push(...validateApi(api));
|
|
149
|
+
// relações: o target (api::<s>.<s>) precisa existir (gerado agora ou já no disco)
|
|
150
|
+
const rel = Object.keys(api.files).find((r) => r.endsWith('schema.json'));
|
|
151
|
+
if (rel) {
|
|
152
|
+
try {
|
|
153
|
+
const attrs = JSON.parse(api.files[rel])?.attributes || {};
|
|
154
|
+
for (const [name, a] of Object.entries(attrs) as any[]) {
|
|
155
|
+
if (a?.type === 'relation' && typeof a.target === 'string') {
|
|
156
|
+
const tgt = a.target.split('::')[1]?.split('.')[0];
|
|
157
|
+
if (tgt && !knownSingulars.has(tgt)) {
|
|
158
|
+
validationErrors.push(`${api.singularName}.${name}: relation aponta para "${a.target}" inexistente`);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
} catch {
|
|
163
|
+
/* já reportado por validateApi */
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
if (validationErrors.length) {
|
|
168
|
+
result.errors.push(
|
|
169
|
+
'Schema gerado inválido — nada foi escrito (provisão abortada com segurança):',
|
|
170
|
+
...validationErrors
|
|
171
|
+
);
|
|
172
|
+
return result;
|
|
173
|
+
}
|
|
174
|
+
|
|
83
175
|
for (const api of apis) {
|
|
84
176
|
const apiDir = path.join(opts.apiRoot, api.singularName);
|
|
85
177
|
|
|
@@ -25,6 +25,12 @@ type ChatInput = {
|
|
|
25
25
|
lang?: Lang;
|
|
26
26
|
/** URL da página aberta no preview — contexto do "isso aqui". */
|
|
27
27
|
previewUrl?: string | null;
|
|
28
|
+
/**
|
|
29
|
+
* Política de publicação. `false` (default) = modo RASCUNHO: o agente edita o
|
|
30
|
+
* draft e NÃO publica, a menos que o usuário peça explicitamente. `true` =
|
|
31
|
+
* auto-publicar após cada edição (comportamento "live" antigo).
|
|
32
|
+
*/
|
|
33
|
+
autoPublish?: boolean;
|
|
28
34
|
};
|
|
29
35
|
|
|
30
36
|
const MODEL = process.env.OPENAI_CHAT_MODEL || 'gpt-4o';
|
|
@@ -42,9 +48,9 @@ Ferramentas de conteúdo:
|
|
|
42
48
|
Fluxo padrão quando o usuário pede uma mudança no site (por texto, voz ou mostrando a tela):
|
|
43
49
|
1. Use buscar_texto com um trecho distintivo do texto a alterar (sem rótulos de status).
|
|
44
50
|
2. Se houver mais de um resultado, escolha o mais provável pelo contexto (e diga qual escolheu); se ambíguo de verdade, pergunte.
|
|
45
|
-
3. editar_campo passando o mesmo uid, documentId e path do resultado, com o novo valor.
|
|
46
|
-
4.
|
|
47
|
-
5. Confirme em 1 frase o que foi alterado
|
|
51
|
+
3. editar_campo passando o mesmo uid, documentId e path do resultado, com o novo valor. Isso salva como RASCUNHO (não publica).
|
|
52
|
+
4. Decida se publica ou não conforme a POLÍTICA DE PUBLICAÇÃO indicada mais abaixo.
|
|
53
|
+
5. Confirme em 1 frase o que foi alterado (content-type, campo, antes → depois) e se ficou como rascunho ou foi publicado.
|
|
48
54
|
|
|
49
55
|
Ferramentas de tradução / idiomas (i18n):
|
|
50
56
|
- listar_locales(): mostra os idiomas configurados e o default.
|
|
@@ -58,6 +64,8 @@ Fluxo quando o usuário pede tradução (ex.: "quero o site todo em pt-BR"):
|
|
|
58
64
|
3. Após o restart, ao repetir, traduzir funciona e localiza tudo.
|
|
59
65
|
4. Confirme em 1 frase: idiomas, quantos documentos e campos foram traduzidos/publicados (use o resumo retornado, não despeje o conteúdo).
|
|
60
66
|
|
|
67
|
+
Draft & Publish: cada resultado de buscar_texto traz "draftAndPublish". Se for false, aquele tipo NÃO tem rascunho no Strapi — a edição já é o conteúdo vivo e NÃO há o que publicar; nesse caso, ao confirmar, avise que "esse conteúdo não tem rascunho, a alteração já está no ar" e NÃO chame publicar.
|
|
68
|
+
|
|
61
69
|
Se o usuário compartilhar a tela, uma imagem é anexada à última mensagem — use-a para entender exatamente o que ele está vendo e qual texto quer trocar.
|
|
62
70
|
|
|
63
71
|
Seja objetivo e acionável. Responda SEMPRE em português.`,
|
|
@@ -71,9 +79,9 @@ Content tools:
|
|
|
71
79
|
Default flow when the user asks for a site change (by text, voice or by showing their screen):
|
|
72
80
|
1. Use buscar_texto with a distinctive snippet of the text to change (no status labels).
|
|
73
81
|
2. If there is more than one result, pick the most likely from context (and say which); if truly ambiguous, ask.
|
|
74
|
-
3. editar_campo passing the same uid, documentId and path from the result, with the new value.
|
|
75
|
-
4.
|
|
76
|
-
5. Confirm in one sentence what was changed
|
|
82
|
+
3. editar_campo passing the same uid, documentId and path from the result, with the new value. This saves a DRAFT (does not publish).
|
|
83
|
+
4. Decide whether to publish based on the PUBLISH POLICY stated below.
|
|
84
|
+
5. Confirm in one sentence what was changed (content-type, field, before → after) and whether it stayed a draft or was published.
|
|
77
85
|
|
|
78
86
|
Translation / language tools (i18n):
|
|
79
87
|
- listar_locales(): shows configured languages and the default.
|
|
@@ -87,13 +95,15 @@ Flow when the user asks for translation (e.g. "I want the whole site in pt-BR"):
|
|
|
87
95
|
3. After the restart, repeating the request makes traduzir localize everything.
|
|
88
96
|
4. Confirm in one sentence: languages, how many documents and fields were translated/published (use the returned summary, don't dump the content).
|
|
89
97
|
|
|
98
|
+
Draft & Publish: each buscar_texto result includes "draftAndPublish". If it is false, that type has NO draft in Strapi — 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.
|
|
99
|
+
|
|
90
100
|
If the user shares their screen, an image is attached to the last message — use it to understand exactly what they see and which text they want to change.
|
|
91
101
|
|
|
92
102
|
Be concise and actionable. ALWAYS answer in English.`,
|
|
93
103
|
};
|
|
94
104
|
|
|
95
105
|
export default ({ strapi }: { strapi: any }) => ({
|
|
96
|
-
async chat({ messages, image, lang = 'pt', previewUrl }: ChatInput) {
|
|
106
|
+
async chat({ messages, image, lang = 'pt', previewUrl, autoPublish = false }: ChatInput) {
|
|
97
107
|
const apiKey = process.env.OPENAI_API_KEY;
|
|
98
108
|
if (!apiKey) {
|
|
99
109
|
throw new Error(
|
|
@@ -122,7 +132,11 @@ export default ({ strapi }: { strapi: any }) => ({
|
|
|
122
132
|
if (process.env.PLAYWRIGHT_MCP_URL) {
|
|
123
133
|
try {
|
|
124
134
|
const client = new McpClient(process.env.PLAYWRIGHT_MCP_URL, 'playwright');
|
|
125
|
-
|
|
135
|
+
// init com timeout: um Playwright MCP fora do ar não pode travar o chat.
|
|
136
|
+
await Promise.race([
|
|
137
|
+
client.init(),
|
|
138
|
+
new Promise((_, rej) => setTimeout(() => rej(new Error('timeout')), 4000)),
|
|
139
|
+
]);
|
|
126
140
|
const list = await client.listTools();
|
|
127
141
|
for (const t of list) {
|
|
128
142
|
if (mcpByTool[t.name]) continue;
|
|
@@ -156,7 +170,17 @@ export default ({ strapi }: { strapi: any }) => ({
|
|
|
156
170
|
pt: `\n\nVocê também controla um navegador real via ferramentas browser_* (Playwright), apontado para o ADMIN DA STRAPI em ${adminBase} (o backend — é aqui que o conteúdo muda de verdade, NÃO no site público). Pode navegar (browser_navigate), clicar, digitar, rolar, tirar seus próprios screenshots (browser_take_screenshot) e inspecionar console/erros. Prefira sempre suas ferramentas diretas (buscar_texto/editar_campo/publicar) para alterar conteúdo; use o navegador para VERIFICAR no admin que a edição/publicação ficou correta, ou para fluxos da UI que as ferramentas diretas não cobrem.`,
|
|
157
171
|
en: `\n\nYou also control a real browser via browser_* tools (Playwright), pointed at the STRAPI ADMIN at ${adminBase} (the backend — 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.`,
|
|
158
172
|
};
|
|
159
|
-
|
|
173
|
+
// ── Política de publicação (draft-first por padrão) ───────────────────────
|
|
174
|
+
const PUBLISH_POLICY: Record<Lang, string> = {
|
|
175
|
+
pt: autoPublish
|
|
176
|
+
? `\n\nPOLÍTICA DE PUBLICAÇÃO: AUTO-PUBLICAR está LIGADO. Depois de editar_campo, chame publicar para deixar a mudança no ar. Em traduzir, use publish:true (default).`
|
|
177
|
+
: `\n\nPOLÍTICA DE PUBLICAÇÃO: MODO RASCUNHO (auto-publicar DESLIGADO). NÃO chame publicar a menos que o usuário peça explicitamente ("publica", "põe no ar", "publish"). Depois de editar_campo, PARE e avise que a alteração foi salva como RASCUNHO para revisão (ela já aparece no preview em modo rascunho, mas ainda não no site público). Em traduzir, passe publish:false. Se o usuário pedir para publicar, aí sim use publicar (ou traduzir com publish:true).`,
|
|
178
|
+
en: autoPublish
|
|
179
|
+
? `\n\nPUBLISH POLICY: AUTO-PUBLISH is ON. After editar_campo, call publicar to make the change live. For traduzir, use publish:true (default).`
|
|
180
|
+
: `\n\nPUBLISH 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).`,
|
|
181
|
+
};
|
|
182
|
+
const systemContent =
|
|
183
|
+
SYSTEM[language] + (hasBrowser ? BROWSER_NOTE[language] : '') + PUBLISH_POLICY[language];
|
|
160
184
|
|
|
161
185
|
// ── Monta a conversa; anexa imagem da tela à última mensagem do usuário ──
|
|
162
186
|
const convo: any[] = [{ role: 'system', content: systemContent }];
|
|
@@ -187,11 +211,23 @@ export default ({ strapi }: { strapi: any }) => ({
|
|
|
187
211
|
});
|
|
188
212
|
|
|
189
213
|
const callOpenAI = async (body: any) => {
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
214
|
+
// Timeout: uma chamada lenta da OpenAI não pode prender a request do admin.
|
|
215
|
+
const ctrl = new AbortController();
|
|
216
|
+
const timer = setTimeout(() => ctrl.abort(), 60000);
|
|
217
|
+
let res: Response;
|
|
218
|
+
try {
|
|
219
|
+
res = await fetch(OPENAI_URL, {
|
|
220
|
+
method: 'POST',
|
|
221
|
+
headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json' },
|
|
222
|
+
body: JSON.stringify(body),
|
|
223
|
+
signal: ctrl.signal,
|
|
224
|
+
});
|
|
225
|
+
} catch (e: any) {
|
|
226
|
+
if (e?.name === 'AbortError') throw new Error('OpenAI chat: tempo limite excedido (60s).');
|
|
227
|
+
throw e;
|
|
228
|
+
} finally {
|
|
229
|
+
clearTimeout(timer);
|
|
230
|
+
}
|
|
195
231
|
if (!res.ok) throw new Error(`OpenAI chat: ${await res.text()}`);
|
|
196
232
|
return res.json() as Promise<any>;
|
|
197
233
|
};
|
|
@@ -234,6 +270,12 @@ export default ({ strapi }: { strapi: any }) => ({
|
|
|
234
270
|
} catch (e: any) {
|
|
235
271
|
content = `Erro ao chamar a tool ${name}: ${e?.message || e}`;
|
|
236
272
|
}
|
|
273
|
+
// Cap defensivo: um resultado gigante não pode estourar o contexto do
|
|
274
|
+
// modelo nem inflar memória/latência.
|
|
275
|
+
const MAX_TOOL_CHARS = 12000;
|
|
276
|
+
if (content.length > MAX_TOOL_CHARS) {
|
|
277
|
+
content = content.slice(0, MAX_TOOL_CHARS) + `\n…[resultado truncado: ${content.length} chars]`;
|
|
278
|
+
}
|
|
237
279
|
convo.push({ role: 'tool', tool_call_id: call.id, content });
|
|
238
280
|
}
|
|
239
281
|
continue;
|