strapi-plugin-mcp-chat 0.3.1 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +31 -5
- package/admin/src/components/AdminOverlays.tsx +8 -18
- package/admin/src/components/ErrorBoundary.tsx +29 -0
- package/admin/src/components/FloatingChat.tsx +78 -3
- package/admin/src/index.tsx +39 -5
- package/dist/server/index.js +734 -397
- package/package.json +1 -1
- package/server/src/content-tools.ts +20 -6
- package/server/src/controllers/chat.ts +2 -2
- package/server/src/controllers/frontend.ts +7 -2
- package/server/src/index.ts +14 -2
- package/server/src/mcp/define.ts +72 -0
- package/server/src/mcp/index.ts +19 -6
- package/server/src/mcp/tools/buscar-texto.ts +18 -24
- package/server/src/mcp/tools/criar-locale.ts +20 -26
- package/server/src/mcp/tools/editar-campo.ts +29 -35
- package/server/src/mcp/tools/habilitar-i18n.ts +23 -29
- package/server/src/mcp/tools/listar-locales.ts +17 -23
- package/server/src/mcp/tools/publicar.ts +21 -27
- package/server/src/mcp/tools/traduzir.ts +26 -32
- package/server/src/mcp/types.ts +12 -9
- package/server/src/mcp-client.ts +15 -3
- package/server/src/provision/infer.ts +92 -20
- package/server/src/provision/link.ts +232 -35
- package/server/src/provision/orchestrate.ts +4 -3
- package/server/src/provision/runner.ts +44 -1
- package/server/src/provision/write.ts +92 -0
- package/server/src/services/chat.ts +36 -8
package/README.md
CHANGED
|
@@ -294,11 +294,16 @@ The plugin follows the documented Strapi 5 plugin APIs:
|
|
|
294
294
|
(`register` / `bootstrap` / `destroy` / `config` / `controllers` / `services` / `routes`).
|
|
295
295
|
Routes are declared with `type: 'admin'`, so the chat/STT/TTS endpoints require an
|
|
296
296
|
authenticated admin session.
|
|
297
|
-
- **MCP** — tools are
|
|
298
|
-
(
|
|
299
|
-
`
|
|
300
|
-
|
|
301
|
-
|
|
297
|
+
- **MCP** — tools are **defined** as pure objects with a local typed `defineTool` helper
|
|
298
|
+
(`server/src/mcp/define.ts`) and **registered from an array** via
|
|
299
|
+
`strapi.ai.mcp.registerTool` during `register()`, using `z` from `@strapi/utils` for the
|
|
300
|
+
schemas and `auth.policies` (content-manager read/update/publish) for RBAC. The
|
|
301
|
+
`defineTool`/`defineResource`/`definePrompt` identity functions infer each handler's
|
|
302
|
+
`args` from its input schema (no `any`) and keep the definitions side-effect-free —
|
|
303
|
+
mirroring the direction of Strapi's [PR #26603](https://github.com/strapi/strapi/pull/26603)
|
|
304
|
+
(`ai.mcp.defineTool` + the `import { ai } from "@strapi/strapi"` namespace). When that API
|
|
305
|
+
ships stable, migrating is a one-line import swap; until then the plugin stays on the
|
|
306
|
+
released `registerTool` so it runs on any Strapi ≥ 5.47 (no experimental build required).
|
|
302
307
|
- **Admin** — `register()` uses only documented APIs (`app.addMenuLink`, `app.registerPlugin`).
|
|
303
308
|
|
|
304
309
|
One intentional deviation: the **global floating chat** is mounted via its own React root
|
|
@@ -316,6 +321,27 @@ single spot.
|
|
|
316
321
|
token's permissions — scope the token to only what those clients should change.
|
|
317
322
|
- The agent can edit and publish content — give the plugin only to trusted editors.
|
|
318
323
|
|
|
324
|
+
## Reliability — never degrades or breaks the host Strapi
|
|
325
|
+
|
|
326
|
+
This plugin is built to be a good citizen: installing it must never slow down or take
|
|
327
|
+
down the host app. Concrete guarantees:
|
|
328
|
+
|
|
329
|
+
- **Can't crash boot.** `register()` degrades gracefully if the native MCP server / i18n /
|
|
330
|
+
OpenAI key are absent (logs a warning, disables the feature). MCP tools register inside a
|
|
331
|
+
per-tool `try/catch`, so a single bad tool can never abort the others or the boot.
|
|
332
|
+
`destroy()` is best-effort.
|
|
333
|
+
- **Can't blank the admin.** The global overlay (floating chat + preview) mounts inside a
|
|
334
|
+
React **error boundary** in its own root, after an SSR/double-mount guard; any render
|
|
335
|
+
error just hides the overlay, leaving the Strapi admin fully intact. Lingering preview
|
|
336
|
+
layout styles are reset on load, and screen/mic capture is stopped on unmount.
|
|
337
|
+
- **Provisioning is fail-safe.** Generated schemas are **validated before any file is
|
|
338
|
+
written** (kind/attributes/known types/relation targets) and writes are **all-or-nothing**
|
|
339
|
+
— a malformed manifest can never leave Strapi with a broken, unbootable schema. Generation
|
|
340
|
+
stays additive (never touches existing types), dev-only, with hardened zip-slip protection.
|
|
341
|
+
- **Won't become a bottleneck.** Every outbound call (OpenAI, MCP) has a **timeout** so a
|
|
342
|
+
slow upstream can't hold a request open; recursive content search has depth caps and a
|
|
343
|
+
result cap; tool outputs are size-capped before going back to the model.
|
|
344
|
+
|
|
319
345
|
## License
|
|
320
346
|
|
|
321
347
|
[MIT](./LICENSE) © Raul Balestra
|
|
@@ -197,26 +197,16 @@ export const AdminOverlays = () => {
|
|
|
197
197
|
<FloatingChat
|
|
198
198
|
previewOn={previewOn}
|
|
199
199
|
previewUrl={liveHref}
|
|
200
|
+
draft={draftPreview}
|
|
200
201
|
onTogglePreview={() => setPreviewOn((v) => !v)}
|
|
201
|
-
onReply={
|
|
202
|
+
onReply={(didWrite) => {
|
|
202
203
|
if (!previewOn) return;
|
|
203
|
-
//
|
|
204
|
-
//
|
|
205
|
-
//
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
setRunText('Sincronizando alterações…');
|
|
210
|
-
setRunLoading(true);
|
|
211
|
-
const { post } = getFetchClient();
|
|
212
|
-
await post('/mcp-chat/frontend/integrate', {});
|
|
213
|
-
} catch {
|
|
214
|
-
/* sem integração: segue só com reload */
|
|
215
|
-
} finally {
|
|
216
|
-
setRunLoading(false);
|
|
217
|
-
}
|
|
218
|
-
}
|
|
219
|
-
reload();
|
|
204
|
+
// Modelo LIVE-FETCH: a Strapi é a fonte da verdade e o front SEMPRE
|
|
205
|
+
// busca dela. A edição do chat já foi gravada na Strapi (no campo
|
|
206
|
+
// exato, via editar_campo) — aqui só recarregamos o preview para o
|
|
207
|
+
// front re-buscar (em draft quando o toggle de rascunho está ligado).
|
|
208
|
+
// NÃO reescrevemos arquivos do frontend (nada de snapshot/integrate).
|
|
209
|
+
if (didWrite) reload();
|
|
220
210
|
}}
|
|
221
211
|
/>
|
|
222
212
|
</>
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Error boundary que ISOLA as sobreposições do plugin (chat + preview) do resto
|
|
3
|
+
* do admin do Strapi. Se algo dentro renderizar com erro, capturamos aqui e
|
|
4
|
+
* renderizamos `null` — o overlay some, mas o admin do Strapi continua intacto.
|
|
5
|
+
* Sem isto, um erro de render num root React próprio poderia quebrar a página.
|
|
6
|
+
*/
|
|
7
|
+
import { Component, type ReactNode } from 'react';
|
|
8
|
+
|
|
9
|
+
type Props = { children: ReactNode };
|
|
10
|
+
type State = { failed: boolean };
|
|
11
|
+
|
|
12
|
+
export class ErrorBoundary extends Component<Props, State> {
|
|
13
|
+
state: State = { failed: false };
|
|
14
|
+
|
|
15
|
+
static getDerivedStateFromError(): State {
|
|
16
|
+
return { failed: true };
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
componentDidCatch(error: unknown) {
|
|
20
|
+
// Apenas loga; nunca propaga para o admin.
|
|
21
|
+
// eslint-disable-next-line no-console
|
|
22
|
+
console.error('[mcp-chat] overlay desativado após erro de render:', error);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
render() {
|
|
26
|
+
if (this.state.failed) return null;
|
|
27
|
+
return this.props.children;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
@@ -13,6 +13,8 @@ type Pos = { x: number; y: number };
|
|
|
13
13
|
type Props = {
|
|
14
14
|
previewOn: boolean;
|
|
15
15
|
previewUrl: string;
|
|
16
|
+
/** modo do preview: true = rascunho (draft), false = publicado (live). */
|
|
17
|
+
draft?: boolean;
|
|
16
18
|
onTogglePreview: () => void;
|
|
17
19
|
/** chamado após uma resposta; didWrite indica que houve edição no Strapi. */
|
|
18
20
|
onReply: (didWrite: boolean) => void;
|
|
@@ -28,6 +30,7 @@ const STR: Record<Lang, Record<string, string>> = {
|
|
|
28
30
|
rec: '🎤 Enviar áudio', recStop: '⏹ Parar áudio', recTitle: 'Gravar áudio e enviar (transcreve e manda)',
|
|
29
31
|
previewOn: '🖼 Preview: ON', previewOff: '🖼 Preview: OFF', previewTitle: 'Abrir/fechar o preview do site ao lado da Strapi',
|
|
30
32
|
voiceOn: '🔊 Voz: ON', voiceOff: '🔈 Voz: OFF', voiceTitle: 'Ler as respostas em voz alta (TTS)',
|
|
33
|
+
stopVoice: '⏹ Parar voz', stopVoiceTitle: 'Parar a voz que está tocando',
|
|
31
34
|
pubOn: '🚀 Publicar: ON', pubOff: '📝 Rascunho', pubTitle: 'Auto-publicar OFF = a IA só salva rascunho (você revisa e publica). ON = publica direto no site.',
|
|
32
35
|
shareOn: '🛑 Parar tela', shareOff: '🖥 Compart. tela', shareTitle: 'Compartilhar a tela com a IA',
|
|
33
36
|
langTitle: 'Idioma do chat e da voz (PT-BR ↔ English)',
|
|
@@ -45,6 +48,7 @@ const STR: Record<Lang, Record<string, string>> = {
|
|
|
45
48
|
rec: '🎤 Send audio', recStop: '⏹ Stop audio', recTitle: 'Record audio and send (transcribes and sends)',
|
|
46
49
|
previewOn: '🖼 Preview: ON', previewOff: '🖼 Preview: OFF', previewTitle: 'Toggle the site preview next to Strapi',
|
|
47
50
|
voiceOn: '🔊 Voice: ON', voiceOff: '🔈 Voice: OFF', voiceTitle: 'Read replies out loud (TTS)',
|
|
51
|
+
stopVoice: '⏹ Stop voice', stopVoiceTitle: 'Stop the voice currently playing',
|
|
48
52
|
pubOn: '🚀 Publish: ON', pubOff: '📝 Draft', pubTitle: 'Auto-publish OFF = the AI only saves a draft (you review and publish). ON = publishes straight to the site.',
|
|
49
53
|
shareOn: '🛑 Stop screen', shareOff: '🖥 Share screen', shareTitle: 'Share your screen with the AI',
|
|
50
54
|
langTitle: 'Chat and voice language (PT-BR ↔ English)',
|
|
@@ -59,7 +63,7 @@ const STR: Record<Lang, Record<string, string>> = {
|
|
|
59
63
|
},
|
|
60
64
|
};
|
|
61
65
|
|
|
62
|
-
export const FloatingChat = ({ previewOn, previewUrl, onTogglePreview, onReply }: Props) => {
|
|
66
|
+
export const FloatingChat = ({ previewOn, previewUrl, draft = false, onTogglePreview, onReply }: Props) => {
|
|
63
67
|
const [open, setOpen] = useState(false);
|
|
64
68
|
const [pos, setPos] = useState<Pos | null>(null); // null = ancorado à direita
|
|
65
69
|
const [messages, setMessages] = useState<Msg[]>([]);
|
|
@@ -67,6 +71,7 @@ export const FloatingChat = ({ previewOn, previewUrl, onTogglePreview, onReply }
|
|
|
67
71
|
const [loading, setLoading] = useState(false);
|
|
68
72
|
const [sharing, setSharing] = useState(false);
|
|
69
73
|
const [voiceOn, setVoiceOn] = useState(false);
|
|
74
|
+
const [speaking, setSpeaking] = useState(false);
|
|
70
75
|
const [recording, setRecording] = useState(false);
|
|
71
76
|
const [autoPublish, setAutoPublish] = useState<boolean>(() => {
|
|
72
77
|
try { return localStorage.getItem('mcp-chat-autopublish') === '1'; } catch { return false; }
|
|
@@ -87,6 +92,7 @@ export const FloatingChat = ({ previewOn, previewUrl, onTogglePreview, onReply }
|
|
|
87
92
|
const chunksRef = useRef<Blob[]>([]);
|
|
88
93
|
const bodyRef = useRef<HTMLDivElement | null>(null);
|
|
89
94
|
const dragRef = useRef<{ dx: number; dy: number } | null>(null);
|
|
95
|
+
const audioRef = useRef<HTMLAudioElement | null>(null);
|
|
90
96
|
|
|
91
97
|
// ── Drag ────────────────────────────────────────────────────────────────
|
|
92
98
|
const onHeaderDown = (e: React.MouseEvent) => {
|
|
@@ -113,6 +119,46 @@ export const FloatingChat = ({ previewOn, previewUrl, onTogglePreview, onReply }
|
|
|
113
119
|
if (bodyRef.current) bodyRef.current.scrollTop = bodyRef.current.scrollHeight;
|
|
114
120
|
}, [messages, loading]);
|
|
115
121
|
|
|
122
|
+
// ── Cleanup ao desmontar: encerra captura de tela / microfone (sem vazar) ──
|
|
123
|
+
useEffect(() => {
|
|
124
|
+
return () => {
|
|
125
|
+
try { streamRef.current?.getTracks().forEach((t) => t.stop()); } catch { /* noop */ }
|
|
126
|
+
try { recorderRef.current?.stop(); } catch { /* noop */ }
|
|
127
|
+
};
|
|
128
|
+
}, []);
|
|
129
|
+
|
|
130
|
+
// ── Logout: limpa a conversa e esconde o chat ──────────────────────────────
|
|
131
|
+
// O overlay vive num React root próprio (fora da árvore do admin), então não dá
|
|
132
|
+
// pra usar useAuth. Detectamos a sessão pela ROTA: a tela de login/logout do
|
|
133
|
+
// Strapi é /admin/auth/... Ao cair nela (logout), zeramos a conversa, encerramos
|
|
134
|
+
// áudio/gravação/compartilhamento e escondemos o FAB — o chat só existe logado.
|
|
135
|
+
const [loggedOut, setLoggedOut] = useState(() => /\/auth(\/|$)/.test(window.location.pathname));
|
|
136
|
+
useEffect(() => {
|
|
137
|
+
const check = () => {
|
|
138
|
+
const out = /\/auth(\/|$)/.test(window.location.pathname);
|
|
139
|
+
setLoggedOut((prev) => {
|
|
140
|
+
if (out && !prev) {
|
|
141
|
+
// acabou de deslogar → limpa tudo que é da sessão.
|
|
142
|
+
setMessages([]);
|
|
143
|
+
setInput('');
|
|
144
|
+
setError(null);
|
|
145
|
+
setOpen(false);
|
|
146
|
+
stopTTS();
|
|
147
|
+
try { streamRef.current?.getTracks().forEach((t) => t.stop()); } catch { /* noop */ }
|
|
148
|
+
try { recorderRef.current?.stop(); } catch { /* noop */ }
|
|
149
|
+
setSharing(false);
|
|
150
|
+
setRecording(false);
|
|
151
|
+
}
|
|
152
|
+
return out;
|
|
153
|
+
});
|
|
154
|
+
};
|
|
155
|
+
check();
|
|
156
|
+
const id = window.setInterval(check, 1000);
|
|
157
|
+
window.addEventListener('popstate', check);
|
|
158
|
+
return () => { window.clearInterval(id); window.removeEventListener('popstate', check); };
|
|
159
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
160
|
+
}, []);
|
|
161
|
+
|
|
116
162
|
// ── Screenshare ───────────────────────────────────────────────────────────
|
|
117
163
|
const startShare = async () => {
|
|
118
164
|
setError(null);
|
|
@@ -151,15 +197,34 @@ export const FloatingChat = ({ previewOn, previewUrl, onTogglePreview, onReply }
|
|
|
151
197
|
};
|
|
152
198
|
|
|
153
199
|
// ── TTS ────────────────────────────────────────────────────────────────────
|
|
200
|
+
// Para qualquer áudio em reprodução (botão ⏹ ou ao iniciar uma nova fala).
|
|
201
|
+
const stopTTS = () => {
|
|
202
|
+
const a = audioRef.current;
|
|
203
|
+
if (a) {
|
|
204
|
+
try { a.pause(); a.currentTime = 0; } catch { /* noop */ }
|
|
205
|
+
audioRef.current = null;
|
|
206
|
+
}
|
|
207
|
+
setSpeaking(false);
|
|
208
|
+
};
|
|
209
|
+
|
|
154
210
|
const playTTS = async (text: string) => {
|
|
155
211
|
try {
|
|
212
|
+
stopTTS(); // não sobrepõe falas
|
|
156
213
|
const { post } = getFetchClient();
|
|
157
214
|
const { data } = await post('/mcp-chat/tts', { text });
|
|
158
215
|
const audio = new Audio(`data:${data.content_type};base64,${data.audio_base64}`);
|
|
159
|
-
|
|
160
|
-
|
|
216
|
+
audioRef.current = audio;
|
|
217
|
+
const done = () => { if (audioRef.current === audio) audioRef.current = null; setSpeaking(false); };
|
|
218
|
+
audio.addEventListener('ended', done);
|
|
219
|
+
audio.addEventListener('error', done);
|
|
220
|
+
setSpeaking(true);
|
|
221
|
+
await audio.play().catch(() => done());
|
|
222
|
+
} catch { setSpeaking(false); /* voz é opcional */ }
|
|
161
223
|
};
|
|
162
224
|
|
|
225
|
+
// para a voz se o componente desmontar.
|
|
226
|
+
useEffect(() => () => stopTTS(), []);
|
|
227
|
+
|
|
163
228
|
// ── Enviar ──────────────────────────────────────────────────────────────────
|
|
164
229
|
const sendMessage = async (text: string) => {
|
|
165
230
|
if (!text || loading) return;
|
|
@@ -177,6 +242,8 @@ export const FloatingChat = ({ previewOn, previewUrl, onTogglePreview, onReply }
|
|
|
177
242
|
// Página que o usuário está olhando no preview (se aberto). Dá à IA o
|
|
178
243
|
// contexto do "isso aqui" sem precisar varrer o site inteiro.
|
|
179
244
|
previewUrl: previewOn ? previewUrl : null,
|
|
245
|
+
// A busca de texto segue o modo do preview: draft -> rascunhos, live -> publicado.
|
|
246
|
+
previewStatus: draft ? 'draft' : 'published',
|
|
180
247
|
// Draft-first: por padrão a IA só salva rascunho; só publica com isto ON.
|
|
181
248
|
autoPublish,
|
|
182
249
|
});
|
|
@@ -254,6 +321,9 @@ export const FloatingChat = ({ previewOn, previewUrl, onTogglePreview, onReply }
|
|
|
254
321
|
fontSize: 12, cursor: 'pointer', whiteSpace: 'nowrap',
|
|
255
322
|
});
|
|
256
323
|
|
|
324
|
+
// deslogado: o chat não existe (já foi limpo no efeito acima).
|
|
325
|
+
if (loggedOut) return null;
|
|
326
|
+
|
|
257
327
|
if (!open) {
|
|
258
328
|
return (
|
|
259
329
|
<button
|
|
@@ -329,6 +399,11 @@ export const FloatingChat = ({ previewOn, previewUrl, onTogglePreview, onReply }
|
|
|
329
399
|
title={t.voiceTitle}>
|
|
330
400
|
{voiceOn ? t.voiceOn : t.voiceOff}
|
|
331
401
|
</button>
|
|
402
|
+
{speaking && (
|
|
403
|
+
<button style={btn(true)} onClick={stopTTS} title={t.stopVoiceTitle}>
|
|
404
|
+
{t.stopVoice}
|
|
405
|
+
</button>
|
|
406
|
+
)}
|
|
332
407
|
<button style={btn(autoPublish)} onClick={() => setAutoPublish((v) => !v)}
|
|
333
408
|
title={t.pubTitle}>
|
|
334
409
|
{autoPublish ? t.pubOn : t.pubOff}
|
package/admin/src/index.tsx
CHANGED
|
@@ -1,6 +1,25 @@
|
|
|
1
1
|
import { createRoot } from 'react-dom/client';
|
|
2
2
|
import { PLUGIN_ID } from './pluginId';
|
|
3
3
|
import { AdminOverlays } from './components/AdminOverlays';
|
|
4
|
+
import { ErrorBoundary } from './components/ErrorBoundary';
|
|
5
|
+
|
|
6
|
+
// Flag de módulo: trava extra contra duplo-mount (além do guard por id no DOM).
|
|
7
|
+
let mounted = false;
|
|
8
|
+
|
|
9
|
+
/** Limpa estilos inline que o PreviewPanel possa ter deixado no #strapi caso uma
|
|
10
|
+
* sessão anterior tenha sido encerrada de forma abrupta — garante que o admin
|
|
11
|
+
* nunca apareça encolhido/quebrado ao carregar. */
|
|
12
|
+
const resetStrapiRootStyles = () => {
|
|
13
|
+
try {
|
|
14
|
+
const root = document.getElementById('strapi') as HTMLElement | null;
|
|
15
|
+
if (!root) return;
|
|
16
|
+
for (const p of ['width', 'maxWidth', 'transform', 'overflow'] as const) {
|
|
17
|
+
root.style[p] = '';
|
|
18
|
+
}
|
|
19
|
+
} catch {
|
|
20
|
+
/* noop */
|
|
21
|
+
}
|
|
22
|
+
};
|
|
4
23
|
|
|
5
24
|
const PluginIcon = () => (
|
|
6
25
|
<svg width="1em" height="1em" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
@@ -39,11 +58,26 @@ export default {
|
|
|
39
58
|
// tocar na árvore do admin). É o único desvio das APIs documentadas e está
|
|
40
59
|
// contido a este único ponto. `register()` acima usa só APIs oficiais
|
|
41
60
|
// (addMenuLink + registerPlugin).
|
|
61
|
+
// Guarda de ambiente: só roda no browser (nunca em SSR/headless sem DOM).
|
|
62
|
+
if (typeof document === 'undefined' || typeof window === 'undefined') return;
|
|
42
63
|
const ID = 'mcp-chat-fab-root';
|
|
43
|
-
if (document.getElementById(ID)) return;
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
64
|
+
if (mounted || document.getElementById(ID)) return;
|
|
65
|
+
mounted = true;
|
|
66
|
+
try {
|
|
67
|
+
resetStrapiRootStyles();
|
|
68
|
+
const el = document.createElement('div');
|
|
69
|
+
el.id = ID;
|
|
70
|
+
document.body.appendChild(el);
|
|
71
|
+
// ErrorBoundary garante que um erro de render do overlay nunca derrube o admin.
|
|
72
|
+
createRoot(el).render(
|
|
73
|
+
<ErrorBoundary>
|
|
74
|
+
<AdminOverlays />
|
|
75
|
+
</ErrorBoundary>
|
|
76
|
+
);
|
|
77
|
+
} catch (e) {
|
|
78
|
+
// Em último caso, falhar em montar o overlay não pode quebrar o admin.
|
|
79
|
+
// eslint-disable-next-line no-console
|
|
80
|
+
console.error('[mcp-chat] falha ao montar overlays (admin segue normal):', e);
|
|
81
|
+
}
|
|
48
82
|
},
|
|
49
83
|
};
|