strapi-plugin-mcp-chat 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +265 -0
  3. package/admin/src/components/AdminOverlays.tsx +190 -0
  4. package/admin/src/components/FloatingChat.tsx +370 -0
  5. package/admin/src/components/PreviewPanel.tsx +188 -0
  6. package/admin/src/index.tsx +49 -0
  7. package/admin/src/pages/App.tsx +14 -0
  8. package/admin/src/pages/HomePage.tsx +333 -0
  9. package/admin/src/pages/ProvisionPage.tsx +391 -0
  10. package/admin/src/pluginId.ts +1 -0
  11. package/dist/server/index.js +3511 -0
  12. package/package.json +77 -0
  13. package/server/src/content-tools.ts +520 -0
  14. package/server/src/controllers/audio.ts +45 -0
  15. package/server/src/controllers/chat.ts +22 -0
  16. package/server/src/controllers/frontend.ts +310 -0
  17. package/server/src/index.ts +43 -0
  18. package/server/src/mcp/index.ts +24 -0
  19. package/server/src/mcp/tools/buscar-texto.ts +28 -0
  20. package/server/src/mcp/tools/criar-locale.ts +30 -0
  21. package/server/src/mcp/tools/editar-campo.ts +39 -0
  22. package/server/src/mcp/tools/habilitar-i18n.ts +33 -0
  23. package/server/src/mcp/tools/index.ts +17 -0
  24. package/server/src/mcp/tools/listar-locales.ts +27 -0
  25. package/server/src/mcp/tools/publicar.ts +31 -0
  26. package/server/src/mcp/tools/traduzir.ts +36 -0
  27. package/server/src/mcp/types.ts +11 -0
  28. package/server/src/mcp-client.ts +96 -0
  29. package/server/src/provision/adapters.ts +91 -0
  30. package/server/src/provision/enable-i18n.ts +129 -0
  31. package/server/src/provision/generate.ts +216 -0
  32. package/server/src/provision/infer.ts +495 -0
  33. package/server/src/provision/integrate.ts +963 -0
  34. package/server/src/provision/link.ts +203 -0
  35. package/server/src/provision/manifest.ts +281 -0
  36. package/server/src/provision/orchestrate.ts +236 -0
  37. package/server/src/provision/permissions.ts +58 -0
  38. package/server/src/provision/runner.ts +176 -0
  39. package/server/src/provision/seed.ts +115 -0
  40. package/server/src/provision/translate.ts +153 -0
  41. package/server/src/provision/types-gen.ts +117 -0
  42. package/server/src/provision/write.ts +136 -0
  43. package/server/src/register.ts +17 -0
  44. package/server/src/routes/index.ts +66 -0
  45. package/server/src/services/audio.ts +53 -0
  46. package/server/src/services/chat.ts +263 -0
@@ -0,0 +1,263 @@
1
+ /**
2
+ * Serviço de chat: roda um loop de agente com a API da OpenAI (Chat Completions).
3
+ *
4
+ * Ferramentas da IA:
5
+ * - CONTEÚDO (in-process, via Document Service): `buscar_texto` acha onde uma
6
+ * palavra está (inclusive aninhada em componentes/dynamic zones), `editar_campo`
7
+ * troca o valor pelo `path`, `publicar` publica. São as MESMAS funções
8
+ * registradas no MCP nativo da Strapi (server/src/mcp.ts) — aqui chamadas
9
+ * direto, sem HTTP nem token.
10
+ * - BROWSER (opcional): Playwright MCP, só se PLAYWRIGHT_MCP_URL existir.
11
+ *
12
+ * Suporta entrada multimodal (frame da tela vai como imagem) e idioma (pt | en).
13
+ * Usa OPENAI_API_KEY. Modelo via OPENAI_CHAT_MODEL (default gpt-4o).
14
+ */
15
+
16
+ import { McpClient } from '../mcp-client';
17
+ import { createContentTools, openAiToolSpecs } from '../content-tools';
18
+ import { enableI18n } from '../provision/enable-i18n';
19
+
20
+ type ChatMessage = { role: 'user' | 'assistant'; content: string };
21
+ type Lang = 'pt' | 'en';
22
+ type ChatInput = {
23
+ messages: ChatMessage[];
24
+ image?: string | null;
25
+ lang?: Lang;
26
+ /** URL da página aberta no preview — contexto do "isso aqui". */
27
+ previewUrl?: string | null;
28
+ };
29
+
30
+ const MODEL = process.env.OPENAI_CHAT_MODEL || 'gpt-4o';
31
+ const MAX_TURNS = 10;
32
+ const OPENAI_URL = 'https://api.openai.com/v1/chat/completions';
33
+
34
+ const SYSTEM: Record<Lang, string> = {
35
+ pt: `Você é um assistente embutido no admin do Strapi 5 deste projeto. Você NÃO é só um guia: você consegue EDITAR e PUBLICAR conteúdo de verdade através das ferramentas.
36
+
37
+ Ferramentas de conteúdo:
38
+ - buscar_texto({termo}): procura uma palavra ou frase em TODOS os content-types, single types, COMPONENTES e DYNAMIC ZONES (recursivo, por substring). Retorna uma lista; cada item tem uid, documentId, "path" (caminho até o campo, ex.: ["dynamic_zone",2,"heading"]), campo e valor_atual. Use SEMPRE isto primeiro — NÃO peça ao usuário onde está, ache sozinho. Busque um trecho distintivo e NÃO inclua rótulos que o preview adiciona, como "(Draft)"/"(Rascunho)".
39
+ - editar_campo({uid, documentId, path, novo_valor}): troca o valor de um campo (salva como rascunho). Passe o "path" EXATAMENTE como veio de buscar_texto.
40
+ - publicar({uid, documentId}): publica a entrada, deixando a mudança visível no site.
41
+
42
+ Fluxo padrão quando o usuário pede uma mudança no site (por texto, voz ou mostrando a tela):
43
+ 1. Use buscar_texto com um trecho distintivo do texto a alterar (sem rótulos de status).
44
+ 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. publicar a entrada.
47
+ 5. Confirme em 1 frase o que foi alterado e publicado (content-type, campo, antes → depois).
48
+
49
+ Ferramentas de tradução / idiomas (i18n):
50
+ - listar_locales(): mostra os idiomas configurados e o default.
51
+ - criar_locale({code}): cria um idioma (code ISO, ex.: "pt-BR"). Idempotente.
52
+ - traduzir({target_locales, source_locale?, uid?, documentId?, publish?}): traduz o conteúdo localizado para um ou MAIS idiomas. Cria os locales se faltarem, traduz campo a campo (textos longos são divididos e remontados — não estoura) e publica. Sem uid/documentId, traduz TODAS as páginas.
53
+ - habilitar_i18n({uid?, campos?}): liga a tradução em content-types ainda não localizadas (a Strapi reinicia). OMITA uid para habilitar em TODAS de uma vez. NUNCA adivinhe uids — para o site inteiro, sempre sem uid.
54
+
55
+ Fluxo quando o usuário pede tradução (ex.: "quero o site todo em pt-BR"):
56
+ 1. Chame traduzir com target_locales (lista de códigos). Não precisa criar o locale antes — traduzir já cria.
57
+ 2. Se traduzir disser que NENHUMA content-type é localizada (i18n desligado), chame habilitar_i18n SEM uid (habilita todas de uma vez), avise que a Strapi vai reiniciar e que é só repetir o pedido após o restart. NÃO chame habilitar_i18n uid por uid nem invente nomes.
58
+ 3. Após o restart, ao repetir, traduzir funciona e localiza tudo.
59
+ 4. Confirme em 1 frase: idiomas, quantos documentos e campos foram traduzidos/publicados (use o resumo retornado, não despeje o conteúdo).
60
+
61
+ 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
+
63
+ Seja objetivo e acionável. Responda SEMPRE em português.`,
64
+ en: `You are an assistant embedded in this project's Strapi 5 admin. You are NOT just a guide: you can actually EDIT and PUBLISH content through your tools.
65
+
66
+ Content tools:
67
+ - buscar_texto({termo}): searches a word or phrase across ALL content-types, single types, COMPONENTS and DYNAMIC ZONES (recursive, substring). Returns a list; each item has uid, documentId, "path" (the path to the field, e.g. ["dynamic_zone",2,"heading"]), field and current value. ALWAYS use this first — do NOT ask the user where it is, find it yourself. Search a distinctive snippet and do NOT include labels the preview adds, like "(Draft)".
68
+ - editar_campo({uid, documentId, path, novo_valor}): replaces a field value (saved as draft). Pass the "path" EXACTLY as returned by buscar_texto.
69
+ - publicar({uid, documentId}): publishes the entry, making the change visible on the site.
70
+
71
+ Default flow when the user asks for a site change (by text, voice or by showing their screen):
72
+ 1. Use buscar_texto with a distinctive snippet of the text to change (no status labels).
73
+ 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. publicar the entry.
76
+ 5. Confirm in one sentence what was changed and published (content-type, field, before → after).
77
+
78
+ Translation / language tools (i18n):
79
+ - listar_locales(): shows configured languages and the default.
80
+ - criar_locale({code}): creates a language (ISO code, e.g. "pt-BR"). Idempotent.
81
+ - traduzir({target_locales, source_locale?, uid?, documentId?, publish?}): translates localized content into one or MORE languages. It creates missing locales, translates field by field (long text is split and reassembled — never overflows) and publishes. Without uid/documentId it translates ALL pages.
82
+ - habilitar_i18n({uid?, campos?}): enables translation on content-types not localized yet (Strapi restarts). OMIT uid to enable ALL at once. NEVER guess uids — for the whole site, always call it without uid.
83
+
84
+ Flow when the user asks for translation (e.g. "I want the whole site in pt-BR"):
85
+ 1. Call traduzir with target_locales (list of codes). No need to create the locale first — traduzir creates it.
86
+ 2. If traduzir says NO content-type is localized (i18n off), call habilitar_i18n WITHOUT uid (enables all at once), warn that Strapi will restart and that they just need to repeat the request after the restart. Do NOT call habilitar_i18n per-uid or invent names.
87
+ 3. After the restart, repeating the request makes traduzir localize everything.
88
+ 4. Confirm in one sentence: languages, how many documents and fields were translated/published (use the returned summary, don't dump the content).
89
+
90
+ 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
+
92
+ Be concise and actionable. ALWAYS answer in English.`,
93
+ };
94
+
95
+ export default ({ strapi }: { strapi: any }) => ({
96
+ async chat({ messages, image, lang = 'pt', previewUrl }: ChatInput) {
97
+ const apiKey = process.env.OPENAI_API_KEY;
98
+ if (!apiKey) {
99
+ throw new Error(
100
+ 'OPENAI_API_KEY não configurada no .env do Strapi. Adicione e reinicie.'
101
+ );
102
+ }
103
+ const language: Lang = lang === 'en' ? 'en' : 'pt';
104
+
105
+ // ── Ferramentas de conteúdo (in-process; as MESMAS registradas no MCP nativo) ──
106
+ const { buscarTexto, editarCampo, publicar, listarLocales, criarLocale, traduzir } =
107
+ createContentTools(strapi);
108
+ const LOCAL_TOOLS: Record<string, (args: any) => Promise<any>> = {
109
+ buscar_texto: (a) => buscarTexto(a?.termo),
110
+ editar_campo: (a) => editarCampo(a),
111
+ publicar: (a) => publicar(a),
112
+ listar_locales: () => listarLocales(),
113
+ criar_locale: (a) => criarLocale(a),
114
+ traduzir: (a) => traduzir(a),
115
+ habilitar_i18n: async (a) => enableI18n({ strapi, uid: a?.uid, campos: a?.campos }),
116
+ };
117
+ const localToolSpecs = openAiToolSpecs;
118
+
119
+ // ── Playwright MCP (CONTROLE DE BROWSER) — só se PLAYWRIGHT_MCP_URL existir ──
120
+ const mcpByTool: Record<string, McpClient> = {};
121
+ const mcpTools: any[] = [];
122
+ if (process.env.PLAYWRIGHT_MCP_URL) {
123
+ try {
124
+ const client = new McpClient(process.env.PLAYWRIGHT_MCP_URL, 'playwright');
125
+ await client.init();
126
+ const list = await client.listTools();
127
+ for (const t of list) {
128
+ if (mcpByTool[t.name]) continue;
129
+ mcpByTool[t.name] = client;
130
+ mcpTools.push(t);
131
+ }
132
+ strapi.log.info(`[mcp-chat] MCP "playwright" ok: ${list.length} tools`);
133
+ } catch (e: any) {
134
+ strapi.log.warn(`[mcp-chat] MCP "playwright" indisponível: ${e?.message || e}`);
135
+ }
136
+ }
137
+
138
+ const tools: any[] = [
139
+ ...localToolSpecs,
140
+ ...mcpTools.map((t) => ({
141
+ type: 'function',
142
+ function: {
143
+ name: t.name,
144
+ description: t.description || t.name,
145
+ parameters: t.inputSchema || { type: 'object', properties: {} },
146
+ },
147
+ })),
148
+ ];
149
+
150
+ // Adendo de browser: só quando o Playwright MCP expôs suas tools.
151
+ const hasBrowser = mcpTools.some((t) => String(t.name).startsWith('browser_'));
152
+ // O navegador da IA opera o ADMIN DA STRAPI (o backend) — é onde o conteúdo
153
+ // muda de verdade. Não é o iframe de preview do frontend.
154
+ const adminBase = process.env.STRAPI_ADMIN_URL || 'http://localhost:1337/admin';
155
+ const BROWSER_NOTE: Record<Lang, string> = {
156
+ 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
+ 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
+ };
159
+ const systemContent = SYSTEM[language] + (hasBrowser ? BROWSER_NOTE[language] : '');
160
+
161
+ // ── Monta a conversa; anexa imagem da tela à última mensagem do usuário ──
162
+ const convo: any[] = [{ role: 'system', content: systemContent }];
163
+ const pageNote =
164
+ previewUrl
165
+ ? language === 'en'
166
+ ? `\n\n[context: the user is viewing the page ${previewUrl} in the preview right now — assume "this/here" refers to what's on that page]`
167
+ : `\n\n[contexto: o usuário está vendo a página ${previewUrl} no preview agora — assuma que "isso/aqui" se refere ao que está nessa página]`
168
+ : '';
169
+ messages.forEach((m, i) => {
170
+ const isLastUser = i === messages.length - 1 && m.role === 'user';
171
+ if (isLastUser) {
172
+ const text = (m.content || '') + pageNote;
173
+ if (image) {
174
+ convo.push({
175
+ role: 'user',
176
+ content: [
177
+ { type: 'text', text: text || '(veja minha tela)' },
178
+ { type: 'image_url', image_url: { url: image } },
179
+ ],
180
+ });
181
+ } else {
182
+ convo.push({ role: 'user', content: text || m.content });
183
+ }
184
+ } else {
185
+ convo.push({ role: m.role, content: m.content });
186
+ }
187
+ });
188
+
189
+ const callOpenAI = async (body: any) => {
190
+ const res = await fetch(OPENAI_URL, {
191
+ method: 'POST',
192
+ headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json' },
193
+ body: JSON.stringify(body),
194
+ });
195
+ if (!res.ok) throw new Error(`OpenAI chat: ${await res.text()}`);
196
+ return res.json() as Promise<any>;
197
+ };
198
+
199
+ // ── Loop de agente ────────────────────────────────────────────────────────
200
+ let didWrite = false;
201
+ for (let turn = 0; turn < MAX_TURNS; turn++) {
202
+ const data = await callOpenAI({
203
+ model: MODEL,
204
+ max_tokens: 2048,
205
+ messages: convo,
206
+ ...(tools.length > 0 ? { tools, tool_choice: 'auto' } : {}),
207
+ });
208
+
209
+ const msg = data.choices?.[0]?.message;
210
+ if (!msg) throw new Error('OpenAI: resposta sem message.');
211
+ convo.push(msg);
212
+
213
+ if (msg.tool_calls && msg.tool_calls.length > 0) {
214
+ for (const call of msg.tool_calls) {
215
+ const name = call.function?.name;
216
+ let content: string;
217
+ try {
218
+ const argsStr = call.function?.arguments || '{}';
219
+ const args = argsStr ? JSON.parse(argsStr) : {};
220
+ const local = LOCAL_TOOLS[name];
221
+ const owner = mcpByTool[name];
222
+ let r: any;
223
+ if (local) {
224
+ r = await local(args);
225
+ if (['editar_campo', 'publicar', 'criar_locale', 'traduzir', 'habilitar_i18n'].includes(name))
226
+ didWrite = true;
227
+ strapi.log.info(`[mcp-chat] tool ${name} -> ${JSON.stringify(r).slice(0, 200)}`);
228
+ } else if (owner) {
229
+ r = await owner.callTool(name, args);
230
+ } else {
231
+ r = { erro: `tool ${name} indisponível` };
232
+ }
233
+ content = typeof r === 'string' ? r : JSON.stringify(r);
234
+ } catch (e: any) {
235
+ content = `Erro ao chamar a tool ${name}: ${e?.message || e}`;
236
+ }
237
+ convo.push({ role: 'tool', tool_call_id: call.id, content });
238
+ }
239
+ continue;
240
+ }
241
+
242
+ const text = (typeof msg.content === 'string' ? msg.content : '').trim();
243
+ return {
244
+ reply: text || '(sem resposta)',
245
+ model: MODEL,
246
+ lang: language,
247
+ didWrite,
248
+ toolsAvailable: tools.length,
249
+ };
250
+ }
251
+
252
+ return {
253
+ reply:
254
+ language === 'en'
255
+ ? '(agent turn limit reached)'
256
+ : '(limite de turnos do agente atingido)',
257
+ model: MODEL,
258
+ lang: language,
259
+ didWrite,
260
+ toolsAvailable: tools.length,
261
+ };
262
+ },
263
+ });