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,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
+ });