strapi-plugin-mcp-chat 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +265 -0
- package/admin/src/components/AdminOverlays.tsx +190 -0
- package/admin/src/components/FloatingChat.tsx +370 -0
- package/admin/src/components/PreviewPanel.tsx +188 -0
- package/admin/src/index.tsx +49 -0
- package/admin/src/pages/App.tsx +14 -0
- package/admin/src/pages/HomePage.tsx +333 -0
- package/admin/src/pages/ProvisionPage.tsx +391 -0
- package/admin/src/pluginId.ts +1 -0
- package/dist/server/index.js +3511 -0
- package/package.json +77 -0
- package/server/src/content-tools.ts +520 -0
- package/server/src/controllers/audio.ts +45 -0
- package/server/src/controllers/chat.ts +22 -0
- package/server/src/controllers/frontend.ts +310 -0
- package/server/src/index.ts +43 -0
- package/server/src/mcp/index.ts +24 -0
- package/server/src/mcp/tools/buscar-texto.ts +28 -0
- package/server/src/mcp/tools/criar-locale.ts +30 -0
- package/server/src/mcp/tools/editar-campo.ts +39 -0
- package/server/src/mcp/tools/habilitar-i18n.ts +33 -0
- package/server/src/mcp/tools/index.ts +17 -0
- package/server/src/mcp/tools/listar-locales.ts +27 -0
- package/server/src/mcp/tools/publicar.ts +31 -0
- package/server/src/mcp/tools/traduzir.ts +36 -0
- package/server/src/mcp/types.ts +11 -0
- package/server/src/mcp-client.ts +96 -0
- package/server/src/provision/adapters.ts +91 -0
- package/server/src/provision/enable-i18n.ts +129 -0
- package/server/src/provision/generate.ts +216 -0
- package/server/src/provision/infer.ts +495 -0
- package/server/src/provision/integrate.ts +963 -0
- package/server/src/provision/link.ts +203 -0
- package/server/src/provision/manifest.ts +281 -0
- package/server/src/provision/orchestrate.ts +236 -0
- package/server/src/provision/permissions.ts +58 -0
- package/server/src/provision/runner.ts +176 -0
- package/server/src/provision/seed.ts +115 -0
- package/server/src/provision/translate.ts +153 -0
- package/server/src/provision/types-gen.ts +117 -0
- package/server/src/provision/write.ts +136 -0
- package/server/src/register.ts +17 -0
- package/server/src/routes/index.ts +66 -0
- package/server/src/services/audio.ts +53 -0
- package/server/src/services/chat.ts +263 -0
package/package.json
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "strapi-plugin-mcp-chat",
|
|
3
|
+
"version": "0.1.0",
|
|
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
|
+
"keywords": [
|
|
6
|
+
"strapi",
|
|
7
|
+
"strapi-plugin",
|
|
8
|
+
"mcp",
|
|
9
|
+
"model-context-protocol",
|
|
10
|
+
"ai",
|
|
11
|
+
"chatgpt",
|
|
12
|
+
"openai",
|
|
13
|
+
"live-preview",
|
|
14
|
+
"content-management"
|
|
15
|
+
],
|
|
16
|
+
"homepage": "https://github.com/raulbalestra/strapi-plugin-mcp-chat#readme",
|
|
17
|
+
"bugs": {
|
|
18
|
+
"url": "https://github.com/raulbalestra/strapi-plugin-mcp-chat/issues"
|
|
19
|
+
},
|
|
20
|
+
"repository": {
|
|
21
|
+
"type": "git",
|
|
22
|
+
"url": "git+https://github.com/raulbalestra/strapi-plugin-mcp-chat.git"
|
|
23
|
+
},
|
|
24
|
+
"license": "MIT",
|
|
25
|
+
"author": "Raul Balestra",
|
|
26
|
+
"strapi": {
|
|
27
|
+
"kind": "plugin",
|
|
28
|
+
"name": "mcp-chat",
|
|
29
|
+
"displayName": "MCP Chat",
|
|
30
|
+
"description": "Chat with an AI that sees and operates your Strapi via MCP."
|
|
31
|
+
},
|
|
32
|
+
"scripts": {
|
|
33
|
+
"build:server": "esbuild server/src/index.ts --bundle --platform=node --format=cjs --target=node18 --packages=external --outfile=dist/server/index.js",
|
|
34
|
+
"build": "npm run build:server",
|
|
35
|
+
"prepublishOnly": "npm run build:server"
|
|
36
|
+
},
|
|
37
|
+
"exports": {
|
|
38
|
+
"./package.json": "./package.json",
|
|
39
|
+
"./strapi-admin": {
|
|
40
|
+
"source": "./admin/src/index.tsx",
|
|
41
|
+
"import": "./admin/src/index.tsx",
|
|
42
|
+
"require": "./admin/src/index.tsx",
|
|
43
|
+
"default": "./admin/src/index.tsx"
|
|
44
|
+
},
|
|
45
|
+
"./strapi-server": {
|
|
46
|
+
"source": "./server/src/index.ts",
|
|
47
|
+
"import": "./dist/server/index.js",
|
|
48
|
+
"require": "./dist/server/index.js",
|
|
49
|
+
"default": "./dist/server/index.js"
|
|
50
|
+
}
|
|
51
|
+
},
|
|
52
|
+
"files": [
|
|
53
|
+
"dist",
|
|
54
|
+
"admin",
|
|
55
|
+
"server",
|
|
56
|
+
"README.md",
|
|
57
|
+
"LICENSE"
|
|
58
|
+
],
|
|
59
|
+
"peerDependencies": {
|
|
60
|
+
"@strapi/strapi": "^5.0.0",
|
|
61
|
+
"react": "^18.0.0 || ^19.0.0",
|
|
62
|
+
"react-dom": "^18.0.0 || ^19.0.0",
|
|
63
|
+
"react-router-dom": "^6.0.0",
|
|
64
|
+
"@strapi/design-system": "^2.0.0"
|
|
65
|
+
},
|
|
66
|
+
"devDependencies": {
|
|
67
|
+
"esbuild": "^0.25.0",
|
|
68
|
+
"typescript": "^5"
|
|
69
|
+
},
|
|
70
|
+
"engines": {
|
|
71
|
+
"node": ">=18.0.0 <=22.x.x",
|
|
72
|
+
"npm": ">=6.0.0"
|
|
73
|
+
},
|
|
74
|
+
"dependencies": {
|
|
75
|
+
"jszip": "^3.10.1"
|
|
76
|
+
}
|
|
77
|
+
}
|
|
@@ -0,0 +1,520 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Ferramentas de conteúdo (Document Service) — a "carne" do plugin.
|
|
3
|
+
*
|
|
4
|
+
* Mesma implementação usada em DOIS lugares:
|
|
5
|
+
* 1. registrada no MCP NATIVO da Strapi (server/src/mcp.ts → strapi.ai.mcp),
|
|
6
|
+
* ficando disponível para qualquer cliente MCP (Cursor, etc.);
|
|
7
|
+
* 2. chamada in-process pelo chat do admin (server/src/services/chat.ts).
|
|
8
|
+
*
|
|
9
|
+
* Diferencial em relação às tools genéricas do MCP: busca/edição RECURSIVA de
|
|
10
|
+
* texto aninhado em componentes e dynamic zones, devolvendo um `path` que a
|
|
11
|
+
* edição aplica preservando os demais componentes.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { translateText } from './provision/translate';
|
|
15
|
+
|
|
16
|
+
const TEXTUAL = ['string', 'text', 'richtext'];
|
|
17
|
+
|
|
18
|
+
export type EditarCampoArgs = {
|
|
19
|
+
uid: string;
|
|
20
|
+
documentId: string;
|
|
21
|
+
path?: (string | number)[];
|
|
22
|
+
campo?: string;
|
|
23
|
+
novo_valor: string;
|
|
24
|
+
/** Grava numa versão de locale específica (i18n). Omitido = locale default. */
|
|
25
|
+
locale?: string;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export function createContentTools(strapi: any) {
|
|
29
|
+
const apiContentTypes = () =>
|
|
30
|
+
Object.values(strapi.contentTypes as Record<string, any>).filter((ct: any) =>
|
|
31
|
+
ct.uid?.startsWith('api::')
|
|
32
|
+
);
|
|
33
|
+
|
|
34
|
+
// Schema de atributos de um content-type OU componente, pelo uid.
|
|
35
|
+
const attrsOf = (uid: string): Record<string, any> =>
|
|
36
|
+
(strapi.contentTypes?.[uid]?.attributes ||
|
|
37
|
+
strapi.components?.[uid]?.attributes ||
|
|
38
|
+
{}) as Record<string, any>;
|
|
39
|
+
|
|
40
|
+
// Populate profundo: components (simples/repetíveis), dynamic zones (com `on`
|
|
41
|
+
// por componente) e mídia/relações. `seen` evita recursão infinita.
|
|
42
|
+
const buildPopulate = (attributes: Record<string, any>, seen = new Set<string>()): any => {
|
|
43
|
+
const populate: any = {};
|
|
44
|
+
for (const [name, a] of Object.entries(attributes) as any[]) {
|
|
45
|
+
if (a.type === 'component' && a.component) {
|
|
46
|
+
const sub = seen.has(a.component)
|
|
47
|
+
? {}
|
|
48
|
+
: buildPopulate(attrsOf(a.component), new Set(seen).add(a.component));
|
|
49
|
+
populate[name] = Object.keys(sub).length ? { populate: sub } : true;
|
|
50
|
+
} else if (a.type === 'dynamiczone') {
|
|
51
|
+
const on: any = {};
|
|
52
|
+
for (const comp of a.components || []) {
|
|
53
|
+
const sub = seen.has(comp)
|
|
54
|
+
? {}
|
|
55
|
+
: buildPopulate(attrsOf(comp), new Set(seen).add(comp));
|
|
56
|
+
on[comp] = Object.keys(sub).length ? { populate: sub } : true;
|
|
57
|
+
}
|
|
58
|
+
populate[name] = { on };
|
|
59
|
+
} else if (a.type === 'media' || a.type === 'relation') {
|
|
60
|
+
populate[name] = true;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return populate;
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
const walkFind = (
|
|
67
|
+
node: any,
|
|
68
|
+
attributes: Record<string, any>,
|
|
69
|
+
basePath: (string | number)[],
|
|
70
|
+
needle: string,
|
|
71
|
+
collect: (path: (string | number)[], campo: string, valor: string) => void
|
|
72
|
+
) => {
|
|
73
|
+
if (!node || typeof node !== 'object') return;
|
|
74
|
+
for (const [name, a] of Object.entries(attributes) as any[]) {
|
|
75
|
+
const v = node[name];
|
|
76
|
+
if (v == null) continue;
|
|
77
|
+
const path = [...basePath, name];
|
|
78
|
+
if (TEXTUAL.includes(a.type)) {
|
|
79
|
+
if (typeof v === 'string' && v.toLowerCase().includes(needle)) {
|
|
80
|
+
collect(path, name, v);
|
|
81
|
+
}
|
|
82
|
+
} else if (a.type === 'component' && a.component) {
|
|
83
|
+
const sub = attrsOf(a.component);
|
|
84
|
+
if (a.repeatable && Array.isArray(v)) {
|
|
85
|
+
v.forEach((item, i) => walkFind(item, sub, [...path, i], needle, collect));
|
|
86
|
+
} else {
|
|
87
|
+
walkFind(v, sub, path, needle, collect);
|
|
88
|
+
}
|
|
89
|
+
} else if (a.type === 'dynamiczone' && Array.isArray(v)) {
|
|
90
|
+
v.forEach((item, i) => {
|
|
91
|
+
if (item?.__component) {
|
|
92
|
+
walkFind(item, attrsOf(item.__component), [...path, i], needle, collect);
|
|
93
|
+
}
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
const buscarTexto = async (termo: string) => {
|
|
100
|
+
const needle = String(termo || '').toLowerCase().trim();
|
|
101
|
+
if (!needle) return { erro: 'termo vazio' };
|
|
102
|
+
const matches: any[] = [];
|
|
103
|
+
for (const ct of apiContentTypes() as any[]) {
|
|
104
|
+
const attributes = ct.attributes || {};
|
|
105
|
+
const populate = buildPopulate(attributes);
|
|
106
|
+
let entries: any[] = [];
|
|
107
|
+
try {
|
|
108
|
+
const res = await strapi
|
|
109
|
+
.documents(ct.uid)
|
|
110
|
+
.findMany({ status: 'draft', populate, limit: 200 });
|
|
111
|
+
entries = Array.isArray(res) ? res : res ? [res] : [];
|
|
112
|
+
} catch {
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
for (const e of entries) {
|
|
116
|
+
walkFind(e, attributes, [], needle, (path, campo, valor) => {
|
|
117
|
+
matches.push({
|
|
118
|
+
uid: ct.uid,
|
|
119
|
+
tipo: ct.info?.displayName || ct.uid,
|
|
120
|
+
documentId: e.documentId,
|
|
121
|
+
path,
|
|
122
|
+
campo,
|
|
123
|
+
valor_atual: valor.length > 300 ? valor.slice(0, 300) + '…' : valor,
|
|
124
|
+
});
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
return { total: matches.length, resultados: matches };
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
// Converte um nó populado de volta a forma gravável: preserva `id` (p/ Strapi
|
|
132
|
+
// atualizar o componente no lugar), mídia/relações viram id(s).
|
|
133
|
+
const sanitizeNode = (node: any, attributes: Record<string, any>): any => {
|
|
134
|
+
if (node == null) return node;
|
|
135
|
+
const out: any = {};
|
|
136
|
+
if (node.id != null) out.id = node.id;
|
|
137
|
+
for (const [name, a] of Object.entries(attributes) as any[]) {
|
|
138
|
+
const v = node[name];
|
|
139
|
+
if (v === undefined) continue;
|
|
140
|
+
out[name] = sanitizeAttr(v, a);
|
|
141
|
+
}
|
|
142
|
+
return out;
|
|
143
|
+
};
|
|
144
|
+
const sanitizeAttr = (value: any, a: any): any => {
|
|
145
|
+
if (value == null) return value;
|
|
146
|
+
if (a.type === 'component' && a.component) {
|
|
147
|
+
const sub = attrsOf(a.component);
|
|
148
|
+
return a.repeatable && Array.isArray(value)
|
|
149
|
+
? value.map((it) => sanitizeNode(it, sub))
|
|
150
|
+
: sanitizeNode(value, sub);
|
|
151
|
+
}
|
|
152
|
+
if (a.type === 'dynamiczone' && Array.isArray(value)) {
|
|
153
|
+
return value.map((it) => ({
|
|
154
|
+
__component: it.__component,
|
|
155
|
+
...sanitizeNode(it, attrsOf(it.__component)),
|
|
156
|
+
}));
|
|
157
|
+
}
|
|
158
|
+
if (a.type === 'media') {
|
|
159
|
+
return Array.isArray(value) ? value.map((m) => m?.id).filter(Boolean) : value?.id ?? null;
|
|
160
|
+
}
|
|
161
|
+
if (a.type === 'relation') {
|
|
162
|
+
return Array.isArray(value) ? value.map((r) => r?.id).filter(Boolean) : value?.id ?? null;
|
|
163
|
+
}
|
|
164
|
+
return value;
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
const editarCampo = async ({ uid, documentId, path, campo, novo_valor, locale }: EditarCampoArgs) => {
|
|
168
|
+
const p = Array.isArray(path) && path.length ? path : campo ? [campo] : null;
|
|
169
|
+
if (!p) return { erro: 'informe "path" (array) ou "campo"' };
|
|
170
|
+
const attributes = strapi.contentTypes?.[uid]?.attributes || {};
|
|
171
|
+
const topAttr = p[0] as string;
|
|
172
|
+
const ad = attributes[topAttr];
|
|
173
|
+
// `locale` só entra no payload quando informado — sem ele o Document Service
|
|
174
|
+
// usa o locale default, preservando 100% o comportamento anterior.
|
|
175
|
+
const loc = locale ? { locale } : {};
|
|
176
|
+
|
|
177
|
+
// Campo simples no topo → update direto.
|
|
178
|
+
if (p.length === 1 && ad && TEXTUAL.includes(ad.type)) {
|
|
179
|
+
const updated = await strapi
|
|
180
|
+
.documents(uid)
|
|
181
|
+
.update({ documentId, ...loc, data: { [topAttr]: novo_valor } });
|
|
182
|
+
return { ok: true, uid, documentId: updated?.documentId || documentId, path: p, novo_valor, locale };
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// Campo aninhado → busca profunda, muta no caminho, sanitiza e regrava o
|
|
186
|
+
// atributo de topo inteiro (preservando os outros componentes).
|
|
187
|
+
const populate = buildPopulate(attributes);
|
|
188
|
+
const entry = await strapi.documents(uid).findOne({ documentId, status: 'draft', ...loc, populate });
|
|
189
|
+
if (!entry) return { erro: 'entrada não encontrada' };
|
|
190
|
+
let cur: any = entry;
|
|
191
|
+
for (let i = 0; i < p.length - 1; i++) {
|
|
192
|
+
if (cur == null) break;
|
|
193
|
+
cur = cur[p[i] as any];
|
|
194
|
+
}
|
|
195
|
+
if (cur == null) return { erro: `caminho inválido: ${p.join('.')}` };
|
|
196
|
+
cur[p[p.length - 1] as any] = novo_valor;
|
|
197
|
+
const data = { [topAttr]: sanitizeAttr(entry[topAttr], ad) };
|
|
198
|
+
const updated = await strapi.documents(uid).update({ documentId, ...loc, data });
|
|
199
|
+
return { ok: true, uid, documentId: updated?.documentId || documentId, path: p, novo_valor, locale };
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
const publicar = async ({
|
|
203
|
+
uid,
|
|
204
|
+
documentId,
|
|
205
|
+
locale,
|
|
206
|
+
}: {
|
|
207
|
+
uid: string;
|
|
208
|
+
documentId: string;
|
|
209
|
+
/** Locale a publicar; "*" publica todos os locales disponíveis. */
|
|
210
|
+
locale?: string;
|
|
211
|
+
}) => {
|
|
212
|
+
await strapi.documents(uid).publish({ documentId, ...(locale ? { locale } : {}) });
|
|
213
|
+
return { ok: true, uid, documentId, status: 'published', locale };
|
|
214
|
+
};
|
|
215
|
+
|
|
216
|
+
// ── i18n: locales ─────────────────────────────────────────────────────────
|
|
217
|
+
const i18nLocalesSvc = () => strapi.plugin?.('i18n')?.service?.('locales');
|
|
218
|
+
const isoLocalesSvc = () => strapi.plugin?.('i18n')?.service?.('iso-locales');
|
|
219
|
+
|
|
220
|
+
const listarLocales = async () => {
|
|
221
|
+
const svc = i18nLocalesSvc();
|
|
222
|
+
if (!svc) return { erro: 'plugin i18n indisponível' };
|
|
223
|
+
const def = await svc.getDefaultLocale();
|
|
224
|
+
const all = (await svc.find()) || [];
|
|
225
|
+
return {
|
|
226
|
+
default: def,
|
|
227
|
+
locales: all.map((l: any) => ({ code: l.code, name: l.name, isDefault: l.code === def })),
|
|
228
|
+
};
|
|
229
|
+
};
|
|
230
|
+
|
|
231
|
+
const criarLocale = async ({ code, name }: { code: string; name?: string }) => {
|
|
232
|
+
const svc = i18nLocalesSvc();
|
|
233
|
+
const iso = isoLocalesSvc();
|
|
234
|
+
if (!svc || !iso) return { erro: 'plugin i18n indisponível' };
|
|
235
|
+
const wanted = String(code || '').trim();
|
|
236
|
+
if (!wanted) return { erro: 'informe o "code" do locale (ex.: "pt-BR")' };
|
|
237
|
+
// Anti-alucinação: só aceita códigos da lista ISO oficial do próprio i18n.
|
|
238
|
+
const list: { code: string; name: string }[] = iso.getIsoLocales();
|
|
239
|
+
const match = list.find((l) => l.code.toLowerCase() === wanted.toLowerCase());
|
|
240
|
+
if (!match) return { erro: `código de locale inválido: "${wanted}" (não está na lista ISO do Strapi)` };
|
|
241
|
+
// Idempotente: se já existe, não recria.
|
|
242
|
+
const existing = await svc.findByCode(match.code);
|
|
243
|
+
if (existing) return { ok: true, code: match.code, name: existing.name, existed: true };
|
|
244
|
+
const created = await svc.create({ code: match.code, name: name || match.name });
|
|
245
|
+
return { ok: true, code: created.code, name: created.name, existed: false };
|
|
246
|
+
};
|
|
247
|
+
|
|
248
|
+
// ── i18n: tradução (o diferencial) ─────────────────────────────────────────
|
|
249
|
+
type TransfCtx = { apiKey: string; src: string; tgt: string; bump: (chunks: number) => void };
|
|
250
|
+
|
|
251
|
+
// Traduz E sanitiza um valor de atributo em uma só passada (espelha
|
|
252
|
+
// sanitizeAttr, mas assíncrono e traduzindo os textos). Texto → traduzido;
|
|
253
|
+
// componente/dz → recursivo; mídia/relação → id(s); escalar → cópia.
|
|
254
|
+
const translateAttrValue = async (value: any, a: any, ctx: TransfCtx): Promise<any> => {
|
|
255
|
+
if (value == null) return value;
|
|
256
|
+
if (TEXTUAL.includes(a.type)) {
|
|
257
|
+
const { text, chunks } = await translateText(ctx.apiKey, value, ctx.src, ctx.tgt, a.type);
|
|
258
|
+
ctx.bump(chunks);
|
|
259
|
+
return text;
|
|
260
|
+
}
|
|
261
|
+
if (a.type === 'component' && a.component) {
|
|
262
|
+
const sub = attrsOf(a.component);
|
|
263
|
+
return a.repeatable && Array.isArray(value)
|
|
264
|
+
? Promise.all(value.map((it) => translateNodeSanitized(it, sub, ctx)))
|
|
265
|
+
: translateNodeSanitized(value, sub, ctx);
|
|
266
|
+
}
|
|
267
|
+
if (a.type === 'dynamiczone' && Array.isArray(value)) {
|
|
268
|
+
return Promise.all(
|
|
269
|
+
value.map(async (it) => ({
|
|
270
|
+
__component: it.__component,
|
|
271
|
+
...(await translateNodeSanitized(it, attrsOf(it.__component), ctx)),
|
|
272
|
+
}))
|
|
273
|
+
);
|
|
274
|
+
}
|
|
275
|
+
return sanitizeAttr(value, a);
|
|
276
|
+
};
|
|
277
|
+
const translateNodeSanitized = async (node: any, attributes: Record<string, any>, ctx: TransfCtx): Promise<any> => {
|
|
278
|
+
if (node == null) return node;
|
|
279
|
+
const out: any = {};
|
|
280
|
+
if (node.id != null) out.id = node.id;
|
|
281
|
+
for (const [name, a] of Object.entries(attributes) as any[]) {
|
|
282
|
+
if (node[name] === undefined) continue;
|
|
283
|
+
out[name] = await translateAttrValue(node[name], a, ctx);
|
|
284
|
+
}
|
|
285
|
+
return out;
|
|
286
|
+
};
|
|
287
|
+
|
|
288
|
+
const isLocalizedCT = (ct: any) => ct?.pluginOptions?.i18n?.localized === true;
|
|
289
|
+
const isLocalizedAttr = (a: any) => a?.pluginOptions?.i18n?.localized === true;
|
|
290
|
+
|
|
291
|
+
const traduzir = async ({
|
|
292
|
+
target_locales,
|
|
293
|
+
source_locale,
|
|
294
|
+
uid,
|
|
295
|
+
documentId,
|
|
296
|
+
publish = true,
|
|
297
|
+
}: {
|
|
298
|
+
target_locales: string | string[];
|
|
299
|
+
source_locale?: string;
|
|
300
|
+
uid?: string;
|
|
301
|
+
documentId?: string;
|
|
302
|
+
publish?: boolean;
|
|
303
|
+
}) => {
|
|
304
|
+
const apiKey = process.env.OPENAI_API_KEY;
|
|
305
|
+
if (!apiKey) return { erro: 'OPENAI_API_KEY não configurada no .env do Strapi.' };
|
|
306
|
+
const svc = i18nLocalesSvc();
|
|
307
|
+
const iso = isoLocalesSvc();
|
|
308
|
+
if (!svc || !iso) return { erro: 'plugin i18n indisponível' };
|
|
309
|
+
|
|
310
|
+
const targets = (Array.isArray(target_locales) ? target_locales : [target_locales]).filter(Boolean);
|
|
311
|
+
if (!targets.length) return { erro: 'informe target_locales (ex.: ["pt-BR","es"])' };
|
|
312
|
+
|
|
313
|
+
const src = source_locale || (await svc.getDefaultLocale());
|
|
314
|
+
const cts = (apiContentTypes() as any[]).filter(
|
|
315
|
+
(ct) => isLocalizedCT(ct) && (!uid || ct.uid === uid)
|
|
316
|
+
);
|
|
317
|
+
if (!cts.length) {
|
|
318
|
+
return {
|
|
319
|
+
erro: uid
|
|
320
|
+
? `content-type "${uid}" não é localizada (i18n desligado). Habilite i18n nos campos antes de traduzir.`
|
|
321
|
+
: 'nenhuma content-type localizada encontrada. Habilite i18n (pluginOptions.i18n.localized) nos campos a traduzir.',
|
|
322
|
+
};
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
const isoList: { code: string; name: string }[] = iso.getIsoLocales();
|
|
326
|
+
const nameOf = (code: string) =>
|
|
327
|
+
isoList.find((l) => l.code.toLowerCase() === code.toLowerCase())?.name || code;
|
|
328
|
+
|
|
329
|
+
const por_locale: any[] = [];
|
|
330
|
+
// Dor 2: um passe INDEPENDENTE por locale. Nada acumula entre eles → resumível.
|
|
331
|
+
for (const rawTgt of targets) {
|
|
332
|
+
const created = await criarLocale({ code: rawTgt });
|
|
333
|
+
if ((created as any).erro) {
|
|
334
|
+
por_locale.push({ locale: rawTgt, erro: (created as any).erro });
|
|
335
|
+
continue;
|
|
336
|
+
}
|
|
337
|
+
const tgt = (created as any).code as string;
|
|
338
|
+
let documentos = 0;
|
|
339
|
+
let campos = 0;
|
|
340
|
+
let chunks = 0;
|
|
341
|
+
let publicados = 0;
|
|
342
|
+
|
|
343
|
+
for (const ct of cts) {
|
|
344
|
+
const attributes = ct.attributes || {};
|
|
345
|
+
const populate = buildPopulate(attributes);
|
|
346
|
+
let res: any;
|
|
347
|
+
try {
|
|
348
|
+
res = await strapi
|
|
349
|
+
.documents(ct.uid)
|
|
350
|
+
.findMany({ status: 'draft', locale: src, populate, limit: 1000 });
|
|
351
|
+
} catch {
|
|
352
|
+
continue;
|
|
353
|
+
}
|
|
354
|
+
let entries: any[] = Array.isArray(res) ? res : res ? [res] : [];
|
|
355
|
+
if (documentId) entries = entries.filter((e) => e.documentId === documentId);
|
|
356
|
+
|
|
357
|
+
for (const e of entries) {
|
|
358
|
+
const ctx: TransfCtx = {
|
|
359
|
+
apiKey,
|
|
360
|
+
src: nameOf(src),
|
|
361
|
+
tgt: nameOf(tgt),
|
|
362
|
+
bump: (c) => {
|
|
363
|
+
campos += 1;
|
|
364
|
+
chunks += c;
|
|
365
|
+
},
|
|
366
|
+
};
|
|
367
|
+
const data: any = {};
|
|
368
|
+
for (const [name, a] of Object.entries(attributes) as any[]) {
|
|
369
|
+
if (!isLocalizedAttr(a)) continue; // só campos localizados; resto é compartilhado
|
|
370
|
+
if (e[name] == null) continue;
|
|
371
|
+
data[name] = await translateAttrValue(e[name], a, ctx);
|
|
372
|
+
}
|
|
373
|
+
if (!Object.keys(data).length) continue;
|
|
374
|
+
// upsert idempotente da versão do locale
|
|
375
|
+
await strapi.documents(ct.uid).update({ documentId: e.documentId, locale: tgt, data });
|
|
376
|
+
documentos += 1;
|
|
377
|
+
if (publish) {
|
|
378
|
+
await strapi.documents(ct.uid).publish({ documentId: e.documentId, locale: tgt });
|
|
379
|
+
publicados += 1;
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
por_locale.push({ locale: tgt, documentos, campos, chunks, publicados });
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
// Só números — nunca o conteúdo traduzido → não estoura o contexto do chat.
|
|
387
|
+
return { ok: true, source: src, por_locale };
|
|
388
|
+
};
|
|
389
|
+
|
|
390
|
+
return { buscarTexto, editarCampo, publicar, listarLocales, criarLocale, traduzir };
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/** Specs no formato de tools da OpenAI (usado pelo agent loop do chat). */
|
|
394
|
+
export const openAiToolSpecs = [
|
|
395
|
+
{
|
|
396
|
+
type: 'function',
|
|
397
|
+
function: {
|
|
398
|
+
name: 'buscar_texto',
|
|
399
|
+
description:
|
|
400
|
+
'Procura uma palavra/frase em TODOS os content-types, single types, COMPONENTES e DYNAMIC ZONES do Strapi (substring, recursivo). Cada resultado traz uid, documentId, "path" (ex.: ["dynamic_zone",2,"heading"]), campo e valor_atual. Passe esse "path" para editar_campo.',
|
|
401
|
+
parameters: {
|
|
402
|
+
type: 'object',
|
|
403
|
+
properties: {
|
|
404
|
+
termo: {
|
|
405
|
+
type: 'string',
|
|
406
|
+
description:
|
|
407
|
+
'trecho distintivo do texto a localizar; NÃO inclua rótulos de status do preview, como "(Draft)"/"(Rascunho)"',
|
|
408
|
+
},
|
|
409
|
+
},
|
|
410
|
+
required: ['termo'],
|
|
411
|
+
},
|
|
412
|
+
},
|
|
413
|
+
},
|
|
414
|
+
{
|
|
415
|
+
type: 'function',
|
|
416
|
+
function: {
|
|
417
|
+
name: 'editar_campo',
|
|
418
|
+
description:
|
|
419
|
+
'Altera o valor de um campo (salva como rascunho). Use o "path" retornado por buscar_texto para campos aninhados; para campo simples no topo, pode usar "campo". Passe "locale" para gravar numa versão de idioma específica.',
|
|
420
|
+
parameters: {
|
|
421
|
+
type: 'object',
|
|
422
|
+
properties: {
|
|
423
|
+
uid: { type: 'string' },
|
|
424
|
+
documentId: { type: 'string' },
|
|
425
|
+
path: {
|
|
426
|
+
type: 'array',
|
|
427
|
+
description: 'caminho até o campo, exatamente como veio de buscar_texto',
|
|
428
|
+
items: { type: ['string', 'number'] },
|
|
429
|
+
},
|
|
430
|
+
campo: { type: 'string', description: 'alternativa ao path (campo simples no topo)' },
|
|
431
|
+
novo_valor: { type: 'string' },
|
|
432
|
+
locale: { type: 'string', description: 'opcional; código do locale (ex.: "pt-BR")' },
|
|
433
|
+
},
|
|
434
|
+
required: ['uid', 'documentId', 'novo_valor'],
|
|
435
|
+
},
|
|
436
|
+
},
|
|
437
|
+
},
|
|
438
|
+
{
|
|
439
|
+
type: 'function',
|
|
440
|
+
function: {
|
|
441
|
+
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.',
|
|
443
|
+
parameters: {
|
|
444
|
+
type: 'object',
|
|
445
|
+
properties: {
|
|
446
|
+
uid: { type: 'string' },
|
|
447
|
+
documentId: { type: 'string' },
|
|
448
|
+
locale: { type: 'string', description: 'opcional; código do locale ou "*" para todos' },
|
|
449
|
+
},
|
|
450
|
+
required: ['uid', 'documentId'],
|
|
451
|
+
},
|
|
452
|
+
},
|
|
453
|
+
},
|
|
454
|
+
{
|
|
455
|
+
type: 'function',
|
|
456
|
+
function: {
|
|
457
|
+
name: 'listar_locales',
|
|
458
|
+
description: 'Lista os locales (idiomas) configurados no Strapi e qual é o default.',
|
|
459
|
+
parameters: { type: 'object', properties: {} },
|
|
460
|
+
},
|
|
461
|
+
},
|
|
462
|
+
{
|
|
463
|
+
type: 'function',
|
|
464
|
+
function: {
|
|
465
|
+
name: 'criar_locale',
|
|
466
|
+
description:
|
|
467
|
+
'Cria um locale (idioma) no Strapi. O "code" precisa ser um código ISO válido (ex.: "pt-BR", "es", "fr"). Idempotente: se já existir, não recria.',
|
|
468
|
+
parameters: {
|
|
469
|
+
type: 'object',
|
|
470
|
+
properties: {
|
|
471
|
+
code: { type: 'string', description: 'código ISO do locale, ex.: "pt-BR"' },
|
|
472
|
+
name: { type: 'string', description: 'opcional; nome exibido (default = nome ISO)' },
|
|
473
|
+
},
|
|
474
|
+
required: ['code'],
|
|
475
|
+
},
|
|
476
|
+
},
|
|
477
|
+
},
|
|
478
|
+
{
|
|
479
|
+
type: 'function',
|
|
480
|
+
function: {
|
|
481
|
+
name: 'traduzir',
|
|
482
|
+
description:
|
|
483
|
+
'Traduz o conteúdo localizado para um ou mais idiomas. Cria os locales se preciso, traduz campo a campo (textos longos são divididos e remontados — nunca estoura) e publica. Sem uid/documentId, traduz TODAS as content-types localizadas (todas as páginas). Funciona para muitos locales de uma vez.',
|
|
484
|
+
parameters: {
|
|
485
|
+
type: 'object',
|
|
486
|
+
properties: {
|
|
487
|
+
target_locales: {
|
|
488
|
+
type: 'array',
|
|
489
|
+
items: { type: 'string' },
|
|
490
|
+
description: 'lista de códigos de destino, ex.: ["pt-BR","es","fr"]',
|
|
491
|
+
},
|
|
492
|
+
source_locale: { type: 'string', description: 'idioma de origem; default = locale default do Strapi' },
|
|
493
|
+
uid: { type: 'string', description: 'opcional; restringe a uma content-type (ex.: api::home.home)' },
|
|
494
|
+
documentId: { type: 'string', description: 'opcional; restringe a um documento' },
|
|
495
|
+
publish: { type: 'boolean', description: 'publicar após traduzir (default true)' },
|
|
496
|
+
},
|
|
497
|
+
required: ['target_locales'],
|
|
498
|
+
},
|
|
499
|
+
},
|
|
500
|
+
},
|
|
501
|
+
{
|
|
502
|
+
type: 'function',
|
|
503
|
+
function: {
|
|
504
|
+
name: 'habilitar_i18n',
|
|
505
|
+
description:
|
|
506
|
+
'Habilita i18n (tradução) em content-types que ainda não são localizadas: marca a CT e seus campos textuais/componentes como localizados. Necessário antes de traduzir conteúdo provisionado sem i18n. OMITA "uid" (ou passe "*") para habilitar em TODAS as content-types de uma vez (recomendado para "traduzir o site inteiro") — um único restart. Edita o schema e a Strapi reinicia (em desenvolvimento). NÃO adivinhe uids.',
|
|
507
|
+
parameters: {
|
|
508
|
+
type: 'object',
|
|
509
|
+
properties: {
|
|
510
|
+
uid: { type: 'string', description: 'opcional; ex.: api::home-content.home-content. Omita p/ TODAS.' },
|
|
511
|
+
campos: {
|
|
512
|
+
type: 'array',
|
|
513
|
+
items: { type: 'string' },
|
|
514
|
+
description: 'opcional; campos a localizar (default = todos os textuais/componentes)',
|
|
515
|
+
},
|
|
516
|
+
},
|
|
517
|
+
},
|
|
518
|
+
},
|
|
519
|
+
},
|
|
520
|
+
];
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* audio controller — STT (upload de áudio) e TTS (texto -> áudio).
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { readFileSync } from 'fs';
|
|
6
|
+
|
|
7
|
+
export default ({ strapi }: { strapi: any }) => ({
|
|
8
|
+
async stt(ctx: any) {
|
|
9
|
+
const files = ctx.request.files || {};
|
|
10
|
+
const file = files.audio || files.file;
|
|
11
|
+
if (!file) return ctx.badRequest('Envie um arquivo de áudio no campo "audio".');
|
|
12
|
+
|
|
13
|
+
const f = Array.isArray(file) ? file[0] : file;
|
|
14
|
+
const buffer = f.filepath ? readFileSync(f.filepath) : f.buffer;
|
|
15
|
+
const mimetype = f.mimetype || f.type || 'audio/webm';
|
|
16
|
+
// Query primeiro (sempre chega); body multipart como fallback.
|
|
17
|
+
const language = ctx.query?.language || ctx.request.body?.language;
|
|
18
|
+
|
|
19
|
+
try {
|
|
20
|
+
const result = await strapi
|
|
21
|
+
.plugin('mcp-chat')
|
|
22
|
+
.service('audio')
|
|
23
|
+
.transcribe(buffer, mimetype, language);
|
|
24
|
+
ctx.body = result;
|
|
25
|
+
} catch (e: any) {
|
|
26
|
+
strapi.log.error(`[mcp-chat:stt] ${e?.message || e}`);
|
|
27
|
+
return ctx.internalServerError(e?.message || 'Erro na transcrição.');
|
|
28
|
+
}
|
|
29
|
+
},
|
|
30
|
+
|
|
31
|
+
async tts(ctx: any) {
|
|
32
|
+
const { text, voice } = ctx.request.body || {};
|
|
33
|
+
if (!text) return ctx.badRequest('Campo "text" é obrigatório.');
|
|
34
|
+
try {
|
|
35
|
+
const result = await strapi
|
|
36
|
+
.plugin('mcp-chat')
|
|
37
|
+
.service('audio')
|
|
38
|
+
.synthesize(text, voice);
|
|
39
|
+
ctx.body = result;
|
|
40
|
+
} catch (e: any) {
|
|
41
|
+
strapi.log.error(`[mcp-chat:tts] ${e?.message || e}`);
|
|
42
|
+
return ctx.internalServerError(e?.message || 'Erro na síntese de voz.');
|
|
43
|
+
}
|
|
44
|
+
},
|
|
45
|
+
});
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* chat controller
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
export default ({ strapi }: { strapi: any }) => ({
|
|
6
|
+
async message(ctx: any) {
|
|
7
|
+
const { messages, image, lang, previewUrl } = ctx.request.body || {};
|
|
8
|
+
if (!Array.isArray(messages) || messages.length === 0) {
|
|
9
|
+
return ctx.badRequest('Campo "messages" (array) é obrigatório.');
|
|
10
|
+
}
|
|
11
|
+
try {
|
|
12
|
+
const result = await strapi
|
|
13
|
+
.plugin('mcp-chat')
|
|
14
|
+
.service('chat')
|
|
15
|
+
.chat({ messages, image, lang, previewUrl });
|
|
16
|
+
ctx.body = result;
|
|
17
|
+
} catch (e: any) {
|
|
18
|
+
strapi.log.error(`[mcp-chat] ${e?.message || e}`);
|
|
19
|
+
return ctx.internalServerError(e?.message || 'Erro ao processar o chat.');
|
|
20
|
+
}
|
|
21
|
+
},
|
|
22
|
+
});
|