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
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tradução resiliente a campos longos (a "Dor 1": EN→pt-BR estoura).
|
|
3
|
+
*
|
|
4
|
+
* NENHUM campo é enviado inteiro ao LLM. `splitForTranslation` quebra o valor em
|
|
5
|
+
* pedaços abaixo de um teto de tokens (por parágrafo; parágrafos gigantes caem
|
|
6
|
+
* para sentenças), cada pedaço é traduzido isolado e remontado na ordem. Assim a
|
|
7
|
+
* tradução funciona para texto de qualquer tamanho.
|
|
8
|
+
*
|
|
9
|
+
* Funções puras (split/join) ficam testáveis sem rede; só `translateChunk` toca
|
|
10
|
+
* a OpenAI, reusando o mesmo padrão `fetch` do services/chat.ts.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
const OPENAI_URL = 'https://api.openai.com/v1/chat/completions';
|
|
14
|
+
const MODEL = process.env.OPENAI_CHAT_MODEL || 'gpt-4o';
|
|
15
|
+
|
|
16
|
+
/** Estimativa grosseira de tokens (~4 chars/token) — suficiente para chunking. */
|
|
17
|
+
export const approxTokens = (s: string): number => Math.ceil((s || '').length / 4);
|
|
18
|
+
|
|
19
|
+
/** Teto de tokens de ENTRADA por pedaço. A saída pode crescer ~2x; o teto já
|
|
20
|
+
* deixa folga para isso dentro do limite do modelo. */
|
|
21
|
+
export const MAX_CHUNK_TOKENS = 1200;
|
|
22
|
+
|
|
23
|
+
const splitParagraphs = (text: string): string[] => text.split(/\n{2,}/);
|
|
24
|
+
|
|
25
|
+
/** Quebra em sentenças preservando a pontuação. */
|
|
26
|
+
const splitSentences = (text: string): string[] =>
|
|
27
|
+
text.match(/[^.!?]+(?:[.!?]+|$)/g) || [text];
|
|
28
|
+
|
|
29
|
+
export interface SplitResult {
|
|
30
|
+
chunks: string[];
|
|
31
|
+
/** Remonta os pedaços traduzidos na ordem original. */
|
|
32
|
+
join: (translated: string[]) => string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Divide um valor textual em pedaços traduzíveis. Curto → 1 pedaço. Longo →
|
|
37
|
+
* agrupa parágrafos inteiros sob o teto; parágrafo isolado acima do teto é
|
|
38
|
+
* subdividido em sentenças. A remontagem junta por linha dupla (fronteira de
|
|
39
|
+
* parágrafo), preservando a estrutura do texto.
|
|
40
|
+
*/
|
|
41
|
+
export function splitForTranslation(value: string, _type?: string): SplitResult {
|
|
42
|
+
const text = String(value ?? '');
|
|
43
|
+
if (approxTokens(text) <= MAX_CHUNK_TOKENS) {
|
|
44
|
+
return { chunks: [text], join: (t) => t[0] ?? '' };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// 1) segmentos = parágrafos; parágrafo grande → sentenças
|
|
48
|
+
const segs: string[] = [];
|
|
49
|
+
for (const para of splitParagraphs(text)) {
|
|
50
|
+
if (approxTokens(para) <= MAX_CHUNK_TOKENS) {
|
|
51
|
+
segs.push(para);
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
let buf = '';
|
|
55
|
+
for (const sent of splitSentences(para)) {
|
|
56
|
+
if (buf && approxTokens(buf + sent) > MAX_CHUNK_TOKENS) {
|
|
57
|
+
segs.push(buf);
|
|
58
|
+
buf = '';
|
|
59
|
+
}
|
|
60
|
+
buf += sent;
|
|
61
|
+
}
|
|
62
|
+
if (buf) segs.push(buf);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// 2) empacota segmentos em pedaços sob o teto (juntando por linha dupla)
|
|
66
|
+
const chunks: string[] = [];
|
|
67
|
+
let buf: string[] = [];
|
|
68
|
+
let bufTok = 0;
|
|
69
|
+
for (const seg of segs) {
|
|
70
|
+
const t = approxTokens(seg);
|
|
71
|
+
if (buf.length && bufTok + t > MAX_CHUNK_TOKENS) {
|
|
72
|
+
chunks.push(buf.join('\n\n'));
|
|
73
|
+
buf = [];
|
|
74
|
+
bufTok = 0;
|
|
75
|
+
}
|
|
76
|
+
buf.push(seg);
|
|
77
|
+
bufTok += t;
|
|
78
|
+
}
|
|
79
|
+
if (buf.length) chunks.push(buf.join('\n\n'));
|
|
80
|
+
|
|
81
|
+
return { chunks, join: (t) => t.join('\n\n') };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Traduz UM pedaço via OpenAI, preservando marcação/placeholders. */
|
|
85
|
+
export async function translateChunk(
|
|
86
|
+
apiKey: string,
|
|
87
|
+
text: string,
|
|
88
|
+
sourceLang: string,
|
|
89
|
+
targetLang: string
|
|
90
|
+
): Promise<string> {
|
|
91
|
+
if (!text || !text.trim()) return text;
|
|
92
|
+
const body = {
|
|
93
|
+
model: MODEL,
|
|
94
|
+
temperature: 0,
|
|
95
|
+
max_tokens: Math.min(4000, approxTokens(text) * 3 + 256),
|
|
96
|
+
messages: [
|
|
97
|
+
{
|
|
98
|
+
role: 'system',
|
|
99
|
+
content:
|
|
100
|
+
`You are a professional translator. Translate the user's text from ${sourceLang} to ${targetLang}. ` +
|
|
101
|
+
'Preserve EXACTLY all markdown, HTML tags, URLs, and placeholders such as {name}, :slug, %s, {{var}}. ' +
|
|
102
|
+
'Keep line breaks. Do NOT add quotes, notes, or explanations. Return ONLY the translated text.',
|
|
103
|
+
},
|
|
104
|
+
{ role: 'user', content: text },
|
|
105
|
+
],
|
|
106
|
+
};
|
|
107
|
+
const res = await fetch(OPENAI_URL, {
|
|
108
|
+
method: 'POST',
|
|
109
|
+
headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json' },
|
|
110
|
+
body: JSON.stringify(body),
|
|
111
|
+
});
|
|
112
|
+
if (!res.ok) throw new Error(`OpenAI translate: ${await res.text()}`);
|
|
113
|
+
const data: any = await res.json();
|
|
114
|
+
return (data.choices?.[0]?.message?.content ?? '').trim();
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Executa `fn` sobre `items` com no máximo `limit` em paralelo (resolve
|
|
118
|
+
* rate-limit com muitos pedaços/locales). Preserva a ordem na saída. */
|
|
119
|
+
export async function mapPool<T, R>(
|
|
120
|
+
items: T[],
|
|
121
|
+
limit: number,
|
|
122
|
+
fn: (item: T, index: number) => Promise<R>
|
|
123
|
+
): Promise<R[]> {
|
|
124
|
+
const out = new Array<R>(items.length);
|
|
125
|
+
let i = 0;
|
|
126
|
+
const workers = Array.from({ length: Math.max(1, Math.min(limit, items.length)) }, async () => {
|
|
127
|
+
while (i < items.length) {
|
|
128
|
+
const idx = i++;
|
|
129
|
+
out[idx] = await fn(items[idx], idx);
|
|
130
|
+
}
|
|
131
|
+
});
|
|
132
|
+
await Promise.all(workers);
|
|
133
|
+
return out;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Traduz um valor textual completo: split → traduz pedaços (pool) → remonta.
|
|
138
|
+
* Retorna o texto traduzido e quantos pedaços foram usados (para métrica).
|
|
139
|
+
*/
|
|
140
|
+
export async function translateText(
|
|
141
|
+
apiKey: string,
|
|
142
|
+
value: string,
|
|
143
|
+
sourceLang: string,
|
|
144
|
+
targetLang: string,
|
|
145
|
+
type?: string
|
|
146
|
+
): Promise<{ text: string; chunks: number }> {
|
|
147
|
+
if (typeof value !== 'string' || !value.trim()) return { text: value, chunks: 0 };
|
|
148
|
+
const { chunks, join } = splitForTranslation(value, type);
|
|
149
|
+
const translated = await mapPool(chunks, 4, (c) =>
|
|
150
|
+
c.trim() ? translateChunk(apiKey, c, sourceLang, targetLang) : Promise.resolve(c)
|
|
151
|
+
);
|
|
152
|
+
return { text: join(translated), chunks: chunks.length };
|
|
153
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
Manifest,
|
|
3
|
+
ManifestContentType,
|
|
4
|
+
ManifestAttribute,
|
|
5
|
+
} from './manifest';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Gera tipos TypeScript a partir do manifest, para o frontend consumir o
|
|
9
|
+
* @strapi/client com type-safety. Determinístico (não precisa do schema vivo),
|
|
10
|
+
* o que mantém o gerador testável e desacoplado do boot da Strapi.
|
|
11
|
+
*
|
|
12
|
+
* Mapeia os tipos do manifest para TS, respeitando required (opcional vs não),
|
|
13
|
+
* relações (objeto único vs array) e mídia.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
function toPascal(singular: string): string {
|
|
17
|
+
return singular
|
|
18
|
+
.split('-')
|
|
19
|
+
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
|
|
20
|
+
.join('');
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function scalarToTs(type: string): string {
|
|
24
|
+
switch (type) {
|
|
25
|
+
case 'string':
|
|
26
|
+
case 'text':
|
|
27
|
+
case 'richtext':
|
|
28
|
+
case 'blocks':
|
|
29
|
+
case 'email':
|
|
30
|
+
case 'uid':
|
|
31
|
+
case 'date':
|
|
32
|
+
case 'datetime':
|
|
33
|
+
case 'time':
|
|
34
|
+
return 'string';
|
|
35
|
+
case 'integer':
|
|
36
|
+
case 'biginteger':
|
|
37
|
+
case 'float':
|
|
38
|
+
case 'decimal':
|
|
39
|
+
return 'number';
|
|
40
|
+
case 'boolean':
|
|
41
|
+
return 'boolean';
|
|
42
|
+
case 'json':
|
|
43
|
+
return 'unknown';
|
|
44
|
+
default:
|
|
45
|
+
return 'unknown';
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** tipo TS do atributo + se é opcional (sem `required`). */
|
|
50
|
+
function attrToTs(
|
|
51
|
+
attr: ManifestAttribute,
|
|
52
|
+
pascalOf: (singular: string) => string
|
|
53
|
+
): { tsType: string; optional: boolean } {
|
|
54
|
+
switch (attr.type) {
|
|
55
|
+
case 'enumeration':
|
|
56
|
+
return {
|
|
57
|
+
tsType: attr.enum.map((e) => JSON.stringify(e)).join(' | '),
|
|
58
|
+
optional: !attr.required,
|
|
59
|
+
};
|
|
60
|
+
case 'media':
|
|
61
|
+
return {
|
|
62
|
+
tsType: attr.multiple ? 'StrapiMedia[]' : 'StrapiMedia',
|
|
63
|
+
optional: !attr.required,
|
|
64
|
+
};
|
|
65
|
+
case 'relation': {
|
|
66
|
+
const target = pascalOf(attr.target);
|
|
67
|
+
const many = attr.relation === 'oneToMany' || attr.relation === 'manyToMany';
|
|
68
|
+
return { tsType: many ? `${target}[]` : target, optional: true };
|
|
69
|
+
}
|
|
70
|
+
case 'uid':
|
|
71
|
+
return { tsType: 'string', optional: !attr.required };
|
|
72
|
+
default:
|
|
73
|
+
return {
|
|
74
|
+
tsType: scalarToTs(attr.type),
|
|
75
|
+
optional: !(attr as any).required,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function buildInterface(ct: ManifestContentType): string {
|
|
81
|
+
const name = ct.displayName
|
|
82
|
+
? toPascal(ct.singularName)
|
|
83
|
+
: toPascal(ct.singularName);
|
|
84
|
+
const lines: string[] = [`export interface ${name} {`];
|
|
85
|
+
// campos padrão da Strapi 5
|
|
86
|
+
lines.push(' documentId: string;');
|
|
87
|
+
for (const [key, attr] of Object.entries(ct.attributes)) {
|
|
88
|
+
const { tsType, optional } = attrToTs(attr, toPascal);
|
|
89
|
+
lines.push(` ${key}${optional ? '?' : ''}: ${tsType};`);
|
|
90
|
+
}
|
|
91
|
+
lines.push(' createdAt: string;');
|
|
92
|
+
lines.push(' updatedAt: string;');
|
|
93
|
+
lines.push(' publishedAt?: string;');
|
|
94
|
+
lines.push('}');
|
|
95
|
+
return lines.join('\n');
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const PREAMBLE = `// Tipos gerados automaticamente a partir do strapi.manifest.json.
|
|
99
|
+
// NÃO edite à mão — rode o link novamente para regenerar.
|
|
100
|
+
|
|
101
|
+
export interface StrapiMedia {
|
|
102
|
+
id: number;
|
|
103
|
+
documentId: string;
|
|
104
|
+
url: string;
|
|
105
|
+
alternativeText?: string;
|
|
106
|
+
width?: number;
|
|
107
|
+
height?: number;
|
|
108
|
+
mime?: string;
|
|
109
|
+
name?: string;
|
|
110
|
+
}
|
|
111
|
+
`;
|
|
112
|
+
|
|
113
|
+
/** Gera o conteúdo do arquivo de tipos (ex.: strapi-types.ts) do frontend. */
|
|
114
|
+
export function generateTypes(manifest: Manifest): string {
|
|
115
|
+
const interfaces = manifest.contentTypes.map(buildInterface).join('\n\n');
|
|
116
|
+
return `${PREAMBLE}\n${interfaces}\n`;
|
|
117
|
+
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import type { GeneratedApi } from './generate';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Writer: grava no disco os arquivos que o gerador produziu.
|
|
7
|
+
*
|
|
8
|
+
* Três travas de segurança, nessa ordem:
|
|
9
|
+
* 1. DEV-ONLY — só escreve em NODE_ENV=development (o Content-Type Builder da
|
|
10
|
+
* Strapi também só opera em dev; gerar schema em prod é proibido).
|
|
11
|
+
* 2. ADITIVO — se a pasta da api já existe, a content-type inteira é PULADA.
|
|
12
|
+
* Nunca sobrescrevemos nem alteramos types existentes → zero risco de perda
|
|
13
|
+
* de dados.
|
|
14
|
+
* 3. DRY-RUN — calcula o plano completo sem tocar no disco.
|
|
15
|
+
*
|
|
16
|
+
* A reinicialização (para a Strapi reconhecer os novos types) é responsabilidade
|
|
17
|
+
* separada — ver requestReload().
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
export interface WriteOptions {
|
|
21
|
+
/** caminho absoluto para src/api da app Strapi. */
|
|
22
|
+
apiRoot: string;
|
|
23
|
+
/** se true, não escreve nada — só devolve o plano. */
|
|
24
|
+
dryRun?: boolean;
|
|
25
|
+
/**
|
|
26
|
+
* pula a trava dev-only. Use APENAS em testes; em produção o controller
|
|
27
|
+
* nunca passa isto.
|
|
28
|
+
*/
|
|
29
|
+
allowOutsideDev?: boolean;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface SkippedApi {
|
|
33
|
+
singularName: string;
|
|
34
|
+
reason: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface WriteResult {
|
|
38
|
+
ok: boolean;
|
|
39
|
+
dryRun: boolean;
|
|
40
|
+
/** caminhos (relativos a apiRoot) que foram/serão escritos. */
|
|
41
|
+
planned: string[];
|
|
42
|
+
written: string[];
|
|
43
|
+
/** content-types puladas por já existirem (proteção aditiva). */
|
|
44
|
+
skipped: SkippedApi[];
|
|
45
|
+
errors: string[];
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function isDev(): boolean {
|
|
49
|
+
return process.env.NODE_ENV === 'development';
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Escreve as content-types geradas em src/api. Aditivo e idempotente: o que já
|
|
54
|
+
* existe é preservado e reportado em `skipped`.
|
|
55
|
+
*/
|
|
56
|
+
export function writeApis(
|
|
57
|
+
apis: GeneratedApi[],
|
|
58
|
+
opts: WriteOptions
|
|
59
|
+
): WriteResult {
|
|
60
|
+
const result: WriteResult = {
|
|
61
|
+
ok: false,
|
|
62
|
+
dryRun: !!opts.dryRun,
|
|
63
|
+
planned: [],
|
|
64
|
+
written: [],
|
|
65
|
+
skipped: [],
|
|
66
|
+
errors: [],
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
// Trava 1: dev-only
|
|
70
|
+
if (!opts.allowOutsideDev && !isDev()) {
|
|
71
|
+
result.errors.push(
|
|
72
|
+
'Geração de content-types só é permitida em desenvolvimento (NODE_ENV=development). ' +
|
|
73
|
+
'Em produção, gere os types em dev e faça deploy do código.'
|
|
74
|
+
);
|
|
75
|
+
return result;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (!path.isAbsolute(opts.apiRoot)) {
|
|
79
|
+
result.errors.push(`apiRoot deve ser um caminho absoluto: ${opts.apiRoot}`);
|
|
80
|
+
return result;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
for (const api of apis) {
|
|
84
|
+
const apiDir = path.join(opts.apiRoot, api.singularName);
|
|
85
|
+
|
|
86
|
+
// Trava 2: aditivo — nunca toca em api existente
|
|
87
|
+
if (fs.existsSync(apiDir)) {
|
|
88
|
+
result.skipped.push({
|
|
89
|
+
singularName: api.singularName,
|
|
90
|
+
reason: `já existe em ${path.relative(opts.apiRoot, apiDir)} — preservado`,
|
|
91
|
+
});
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
for (const [rel, content] of Object.entries(api.files)) {
|
|
96
|
+
const full = path.join(opts.apiRoot, rel);
|
|
97
|
+
|
|
98
|
+
// defesa extra: o caminho final tem que ficar DENTRO de apiRoot
|
|
99
|
+
const normalized = path.normalize(full);
|
|
100
|
+
if (
|
|
101
|
+
normalized !== opts.apiRoot &&
|
|
102
|
+
!normalized.startsWith(opts.apiRoot + path.sep)
|
|
103
|
+
) {
|
|
104
|
+
result.errors.push(`caminho fora de apiRoot bloqueado: ${rel}`);
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
result.planned.push(rel);
|
|
109
|
+
|
|
110
|
+
if (opts.dryRun) continue;
|
|
111
|
+
|
|
112
|
+
try {
|
|
113
|
+
fs.mkdirSync(path.dirname(full), { recursive: true });
|
|
114
|
+
fs.writeFileSync(full, content, 'utf8');
|
|
115
|
+
result.written.push(rel);
|
|
116
|
+
} catch (e: any) {
|
|
117
|
+
result.errors.push(`falha ao escrever ${rel}: ${e?.message ?? e}`);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
result.ok = result.errors.length === 0;
|
|
123
|
+
return result;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Pede para a Strapi recarregar, de modo que os novos content-types passem a
|
|
128
|
+
* existir. Em dev, escrever em src/ já dispara o watcher; chamar reload() torna
|
|
129
|
+
* o efeito determinístico. Isolado aqui para o controller decidir quando chamar
|
|
130
|
+
* (normalmente após responder ao cliente, para não cortar a resposta).
|
|
131
|
+
*/
|
|
132
|
+
export function requestReload(strapi: any): void {
|
|
133
|
+
if (typeof strapi?.reload === 'function') {
|
|
134
|
+
strapi.reload();
|
|
135
|
+
}
|
|
136
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { registerMcpTools } from './mcp';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* register() do plugin: estende o MCP nativo da Strapi registrando as tools
|
|
5
|
+
* de conteúdo. Precisa rodar antes de o MCP server iniciar (ordem de boot:
|
|
6
|
+
* register → bootstrap → MCP start).
|
|
7
|
+
*/
|
|
8
|
+
export default ({ strapi }: { strapi: any }) => {
|
|
9
|
+
// Blindado: a API do MCP nativo ainda evolui entre versões da Strapi. Uma
|
|
10
|
+
// mudança de assinatura aqui NÃO pode derrubar o boot do plugin (a provisão de
|
|
11
|
+
// frontend não depende do MCP). Se falhar, só avisamos.
|
|
12
|
+
try {
|
|
13
|
+
registerMcpTools(strapi);
|
|
14
|
+
} catch (e: any) {
|
|
15
|
+
strapi.log.warn(`[mcp-chat] registro do MCP falhou (seguindo sem ele): ${e?.message ?? e}`);
|
|
16
|
+
}
|
|
17
|
+
};
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Rotas do plugin (tipo admin: exigem um admin autenticado).
|
|
3
|
+
* Ficam montadas sob /mcp-chat.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export default {
|
|
7
|
+
admin: {
|
|
8
|
+
type: 'admin',
|
|
9
|
+
routes: [
|
|
10
|
+
{
|
|
11
|
+
method: 'POST',
|
|
12
|
+
path: '/message',
|
|
13
|
+
handler: 'chat.message',
|
|
14
|
+
config: { policies: [] },
|
|
15
|
+
},
|
|
16
|
+
{
|
|
17
|
+
method: 'POST',
|
|
18
|
+
path: '/stt',
|
|
19
|
+
handler: 'audio.stt',
|
|
20
|
+
config: { policies: [] },
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
method: 'POST',
|
|
24
|
+
path: '/tts',
|
|
25
|
+
handler: 'audio.tts',
|
|
26
|
+
config: { policies: [] },
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
method: 'POST',
|
|
30
|
+
path: '/frontend/analyze',
|
|
31
|
+
handler: 'frontend.analyze',
|
|
32
|
+
config: { policies: [] },
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
method: 'POST',
|
|
36
|
+
path: '/frontend/provision',
|
|
37
|
+
handler: 'frontend.provision',
|
|
38
|
+
config: { policies: [] },
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
method: 'GET',
|
|
42
|
+
path: '/frontend/status',
|
|
43
|
+
handler: 'frontend.status',
|
|
44
|
+
config: { policies: [] },
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
method: 'POST',
|
|
48
|
+
path: '/frontend/run',
|
|
49
|
+
handler: 'frontend.run',
|
|
50
|
+
config: { policies: [] },
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
method: 'GET',
|
|
54
|
+
path: '/frontend/run-status',
|
|
55
|
+
handler: 'frontend.runStatus',
|
|
56
|
+
config: { policies: [] },
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
method: 'POST',
|
|
60
|
+
path: '/frontend/integrate',
|
|
61
|
+
handler: 'frontend.integrate',
|
|
62
|
+
config: { policies: [] },
|
|
63
|
+
},
|
|
64
|
+
],
|
|
65
|
+
},
|
|
66
|
+
};
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Serviço de áudio: STT (Whisper) e TTS (OpenAI), via fetch (Node 18+ tem
|
|
3
|
+
* FormData/Blob/fetch globais).
|
|
4
|
+
* Usa OPENAI_API_KEY (a mesma chave usada no chat).
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
export default ({ strapi }: { strapi: any }) => ({
|
|
8
|
+
async transcribe(buffer: Buffer, mimetype: string, language?: string) {
|
|
9
|
+
const key = process.env.OPENAI_API_KEY;
|
|
10
|
+
if (!key) throw new Error('OPENAI_API_KEY não configurada no .env (necessária p/ áudio).');
|
|
11
|
+
|
|
12
|
+
const ext = mimetype.includes('mp4')
|
|
13
|
+
? 'mp4'
|
|
14
|
+
: mimetype.includes('ogg')
|
|
15
|
+
? 'ogg'
|
|
16
|
+
: mimetype.includes('wav')
|
|
17
|
+
? 'wav'
|
|
18
|
+
: 'webm';
|
|
19
|
+
|
|
20
|
+
const form = new FormData();
|
|
21
|
+
form.append('file', new Blob([buffer], { type: mimetype }), `audio.${ext}`);
|
|
22
|
+
form.append('model', 'whisper-1');
|
|
23
|
+
// Só aceita um código válido (en|pt). Forçar o idioma ERRADO faz o Whisper
|
|
24
|
+
// transcrever no idioma errado; em caso de dúvida, deixa em branco para
|
|
25
|
+
// auto-detecção (confiável para áudios claros).
|
|
26
|
+
const raw = Array.isArray(language) ? language[0] : language;
|
|
27
|
+
const lang = raw === 'en' || raw === 'pt' ? raw : undefined;
|
|
28
|
+
if (lang) form.append('language', lang);
|
|
29
|
+
|
|
30
|
+
const res = await fetch('https://api.openai.com/v1/audio/transcriptions', {
|
|
31
|
+
method: 'POST',
|
|
32
|
+
headers: { Authorization: `Bearer ${key}` },
|
|
33
|
+
body: form,
|
|
34
|
+
});
|
|
35
|
+
if (!res.ok) throw new Error(`OpenAI STT: ${await res.text()}`);
|
|
36
|
+
const data: any = await res.json();
|
|
37
|
+
return { text: data.text as string };
|
|
38
|
+
},
|
|
39
|
+
|
|
40
|
+
async synthesize(text: string, voice = 'echo') {
|
|
41
|
+
const key = process.env.OPENAI_API_KEY;
|
|
42
|
+
if (!key) throw new Error('OPENAI_API_KEY não configurada no .env (necessária p/ áudio).');
|
|
43
|
+
|
|
44
|
+
const res = await fetch('https://api.openai.com/v1/audio/speech', {
|
|
45
|
+
method: 'POST',
|
|
46
|
+
headers: { Authorization: `Bearer ${key}`, 'Content-Type': 'application/json' },
|
|
47
|
+
body: JSON.stringify({ model: 'tts-1', input: text, voice, response_format: 'mp3' }),
|
|
48
|
+
});
|
|
49
|
+
if (!res.ok) throw new Error(`OpenAI TTS: ${await res.text()}`);
|
|
50
|
+
const buffer = Buffer.from(await res.arrayBuffer());
|
|
51
|
+
return { audio_base64: buffer.toString('base64'), content_type: 'audio/mpeg' };
|
|
52
|
+
},
|
|
53
|
+
});
|