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
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Raul Balestra
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,265 @@
1
+ # strapi-plugin-mcp-chat
2
+
3
+ > AI chat inside the **Strapi 5** admin that actually **reads and edits your content** — including fields nested in **components and dynamic zones** — through **MCP**. Comes with **voice** (speech-to-text / text-to-speech) and a **side-by-side live preview** of your frontend.
4
+
5
+ Ask in plain language ("change the homepage hero title to …") and the assistant finds the text across all content-types, edits it, and publishes — then reloads the preview on the very page you were looking at.
6
+
7
+ https://github.com/raulbalestra/strapi-plugin-mcp-chat
8
+
9
+ ---
10
+
11
+ ## Features
12
+
13
+ - 🤖 **AI chat in the admin** — a floating widget on every screen + a full-page view. Runs an agent loop on the OpenAI Chat Completions API.
14
+ - ✏️ **Edits real content via MCP + Document Service** — `buscar_texto` finds a phrase across **all** content-types, single types, **components and dynamic zones** (recursive); `editar_campo` updates it (preserving the other components); `publicar` publishes.
15
+ - 🎙️ **Voice** — record a request (Whisper STT) and hear replies (OpenAI TTS).
16
+ - 👁️ **Side-by-side preview panel** — the plugin's own docked iframe that shrinks the admin and shows your frontend; reloads after each edit and **stays on the same page + scroll position** (with the optional preview bridge below). This is a custom panel, *not* Strapi's official Live Preview (which is a Growth/Enterprise feature) — it works on any plan, including Community, and complements the official Preview if you have it configured.
17
+ - 🖥️ **Optional browser control** — if a [Playwright MCP](https://github.com/microsoft/playwright-mcp) server is reachable, the agent can drive a real browser to verify changes.
18
+ - 🧱 **Frontend provisioning** — upload your frontend (Next.js or TanStack Start) with a `strapi.manifest.json`; the plugin validates it, creates all content-types, seeds content and wires the preview. Never runs code from the upload — it acts only on the validated manifest.
19
+ - 🌍 **Translate every page to any language** — create locales and translate all localized content via Strapi 5's native i18n, with **no length limits and no context blowups** (see below).
20
+ - 🌐 Bilingual UI/prompts (PT / EN).
21
+
22
+ ## Requirements
23
+
24
+ - **Strapi `>= 5.47.0`** — required for the built-in [native MCP server](https://docs.strapi.io/cms/features/strapi-mcp-server) that this plugin consumes.
25
+ - An **OpenAI API key** (used server-side only).
26
+
27
+ ## Install
28
+
29
+ ```bash
30
+ # Straight from GitHub (not on npm yet):
31
+ npm install github:raulbalestra/strapi-plugin-mcp-chat
32
+ ```
33
+
34
+ > Or just try the ready-to-run [Launchpad demo](https://github.com/raulbalestra/launchpad-mcp-chat) (the plugin is vendored there).
35
+
36
+ ### 1. Enable this plugin
37
+
38
+ `config/plugins.ts` (or `.js`):
39
+
40
+ ```ts
41
+ export default () => ({
42
+ 'mcp-chat': {
43
+ enabled: true,
44
+ },
45
+ });
46
+ ```
47
+
48
+ On `register()`, the plugin registers its content tools (`mcp_chat_buscar_texto`,
49
+ `mcp_chat_editar_campo`, `mcp_chat_publicar`) into Strapi's native MCP server via
50
+ `strapi.ai.mcp.registerTool`. The in-admin chat calls the same functions in-process —
51
+ **no admin token or HTTP round-trip needed for the chat to work.**
52
+
53
+ ### 2. Raise the body size limit + allow the preview iframe
54
+
55
+ The chat can send a screenshot of your screen (base64) in the request body, so the
56
+ default ~100 kb limit must be raised. If you use the live preview, also allow the
57
+ frontend origin to be framed. `config/middlewares.ts`:
58
+
59
+ ```ts
60
+ export default [
61
+ 'strapi::logger',
62
+ 'strapi::errors',
63
+ {
64
+ name: 'strapi::security',
65
+ config: {
66
+ contentSecurityPolicy: {
67
+ useDefaults: true,
68
+ directives: {
69
+ 'frame-src': ["'self'", process.env.CLIENT_URL],
70
+ 'img-src': ["'self'", 'data:', 'blob:', 'market-assets.strapi.io'],
71
+ 'media-src': ["'self'", 'data:', 'blob:'],
72
+ upgradeInsecureRequests: null,
73
+ },
74
+ },
75
+ },
76
+ },
77
+ 'strapi::cors',
78
+ 'strapi::poweredBy',
79
+ 'strapi::query',
80
+ { name: 'strapi::body', config: { jsonLimit: '15mb', formLimit: '15mb', textLimit: '15mb' } },
81
+ 'strapi::session',
82
+ 'strapi::favicon',
83
+ 'strapi::public',
84
+ ];
85
+ ```
86
+
87
+ ### 3. Environment variables
88
+
89
+ ```bash
90
+ # Required — the chat and voice features fail without it.
91
+ OPENAI_API_KEY=sk-...
92
+
93
+ # Optional.
94
+ OPENAI_CHAT_MODEL=gpt-4o # default: gpt-4o
95
+ PLAYWRIGHT_MCP_URL=http://localhost:8931/mcp # enables browser control
96
+ STRAPI_ADMIN_URL=http://localhost:1337/admin # used by browser control
97
+ CLIENT_URL=http://localhost:3000 # frontend origin (for the preview iframe CSP)
98
+ ```
99
+
100
+ > **The OpenAI key lives only in the server `.env`** and is never exposed to the
101
+ > browser. The chat endpoints require an authenticated admin session.
102
+
103
+ ### 4. (Optional) Expose the tools to external MCP clients
104
+
105
+ The plugin always registers its tools; if you also enable Strapi's native MCP server,
106
+ those tools become available to **external MCP clients** (e.g. Cursor) at
107
+ `/mcp`. In `config/server.ts`:
108
+
109
+ ```ts
110
+ export default ({ env }) => ({
111
+ // ...your existing server config
112
+ mcp: { enabled: true }, // serves /mcp (Streamable HTTP, Admin-token authenticated)
113
+ });
114
+ ```
115
+
116
+ External clients authenticate with an **Admin token** (Settings → Admin Tokens); the
117
+ MCP session is scoped to that token's permissions. The in-admin chat does **not** need
118
+ this — it's only for letting other AI clients use the same tools.
119
+
120
+ ### 5. Rebuild & run
121
+
122
+ ```bash
123
+ npm run build && npm run develop
124
+ ```
125
+
126
+ The floating chat appears on every admin screen, and **MCP Chat** is added to the menu.
127
+ Set your frontend URL once in the preview panel's address bar — it's remembered in `localStorage`.
128
+
129
+ ## Optional: live-preview bridge (stay on the page + keep scroll)
130
+
131
+ The preview iframe is cross-origin, so the admin can't see where you navigated inside
132
+ it. Drop this tiny client component into **your frontend** and it will (a) report the
133
+ current URL to the admin so reloads return to the same page, and (b) save/restore the
134
+ scroll position per page. For **Next.js**, create `components/preview-bridge.tsx`:
135
+
136
+ ```tsx
137
+ 'use client';
138
+ import { usePathname } from 'next/navigation';
139
+ import { useEffect } from 'react';
140
+
141
+ export function PreviewBridge() {
142
+ const pathname = usePathname();
143
+ useEffect(() => {
144
+ if (typeof window === 'undefined' || window.self === window.top) return; // only inside an iframe
145
+ const key = `preview-scroll:${pathname}`;
146
+ try { window.parent.postMessage({ type: 'preview:location', href: window.location.href }, '*'); } catch {}
147
+ const saved = sessionStorage.getItem(key);
148
+ if (saved != null) {
149
+ const y = parseInt(saved, 10) || 0;
150
+ [0, 60, 180, 400, 800].forEach((t) => setTimeout(() => window.scrollTo(0, y), t));
151
+ }
152
+ const save = () => { try { sessionStorage.setItem(key, String(window.scrollY)); } catch {} };
153
+ window.addEventListener('scroll', save, { passive: true });
154
+ window.addEventListener('beforeunload', save);
155
+ return () => { save(); window.removeEventListener('scroll', save); window.removeEventListener('beforeunload', save); };
156
+ }, [pathname]);
157
+ return null;
158
+ }
159
+ ```
160
+
161
+ Then render `<PreviewBridge />` once in your root layout. The same idea works in any
162
+ framework — just post `{ type: 'preview:location', href: location.href }` to `window.parent`.
163
+
164
+ ## How it works
165
+
166
+ ```
167
+ register() ─► strapi.ai.mcp.registerTool ─► native MCP server (/mcp)
168
+ mcp_chat_buscar_texto / _editar_campo / _publicar
169
+ (also available to external MCP clients, e.g. Cursor)
170
+
171
+ Admin (floating chat / full page)
172
+ └─ POST /mcp-chat/message ─► chat service (agent loop, OpenAI)
173
+ ├─ content tools ─► same functions, called IN-PROCESS (no HTTP, no token)
174
+ │ buscar_texto → deep, recursive search (components + dynamic zones), returns a `path`
175
+ │ editar_campo → edits the field at that `path`, re-saving the whole top attribute
176
+ │ publicar → publishes the entry
177
+ └─ (optional) browser_* ─► Playwright MCP
178
+ └─ POST /mcp-chat/stt · /mcp-chat/tts ─► Whisper / OpenAI TTS
179
+ ```
180
+
181
+ The plugin **extends** Strapi's native MCP server: in `register()` it calls
182
+ `strapi.ai.mcp.registerTool` to add its deep search/edit/publish tools, so any MCP
183
+ client gets them. The same functions are shared with the in-admin chat, which calls
184
+ them in-process — so the chat needs no admin token and no HTTP round-trip.
185
+
186
+ `buscar_texto` returns matches with a `path` like `["dynamic_zone", 0, "heading"]`.
187
+ `editar_campo` takes that same `path`, deep-fetches the entry, mutates the leaf, and
188
+ writes the whole top-level attribute back — keeping component `id`s (so they're updated
189
+ in place, not recreated) and reducing media/relations to ids.
190
+
191
+ > **Note:** `blocks`-type rich text is intentionally **not** edited (it's structured JSON);
192
+ > string / text / richtext fields at any depth are.
193
+
194
+ ## Frontend provisioning
195
+
196
+ Bring a "blessed-stack" frontend (Next.js or TanStack Start) carrying a
197
+ `strapi.manifest.json`. The plugin **never executes code from the upload** — it
198
+ reads and validates the manifest (Zod) and, from it, provisions the backend:
199
+
200
+ ```
201
+ upload → validate manifest → extract to ../<frontend>
202
+ → generate src/api/**/schema.json (additive) → Strapi restarts
203
+ → seed content (Document Service) → wire .env + types + preview
204
+ ```
205
+
206
+ Safety rails: schema generation runs **only in `develop`** (a Content-Type
207
+ Builder limitation); generation is **additive** (never drops/alters an existing
208
+ type); the frontend always lands in a **sibling folder**, never inside Strapi's
209
+ `src/`. Ready-to-use starters live in [`starters/`](./starters). The manifest can
210
+ also be **inferred from the frontend code** (e.g. Figma/Lovable exports).
211
+
212
+ ## Translation (i18n)
213
+
214
+ Ask the chat to translate and it creates the locales and translates every
215
+ localized field, using Strapi 5's **native i18n** — without the two failure modes
216
+ of typical translation plugins:
217
+
218
+ - **Long text doesn't overflow** — each value is split per paragraph (a giant
219
+ paragraph falls back to sentences) under a token budget, translated chunk by
220
+ chunk and reassembled in order, so a field of any size works.
221
+ - **Many locales don't blow up** — each locale is an **independent, resumable
222
+ pass** (idempotent locale creation + per-locale upsert), so 10, 30, 50
223
+ languages run in sequence without accumulating context.
224
+
225
+ Tools (in the chat and via the native MCP server): `criar_locale`,
226
+ `listar_locales`, `traduzir`, `habilitar_i18n`, plus a `locale` option on
227
+ `editar_campo`/`publicar`. In a manifest, mark `localized: true` on the
228
+ content-type and the fields to translate; the generator emits
229
+ `pluginOptions.i18n.localized` at both the content-type and attribute level.
230
+ Validated end-to-end against a real Strapi 5 (14 locales, long multi-chunk text
231
+ and translation inside repeatable components).
232
+
233
+ ## Strapi 5 conventions
234
+
235
+ The plugin follows the documented Strapi 5 plugin APIs:
236
+
237
+ - **Server** — the entry (`server/src/index.ts`) exports the documented shape
238
+ (`register` / `bootstrap` / `destroy` / `config` / `controllers` / `services` / `routes`).
239
+ Routes are declared with `type: 'admin'`, so the chat/STT/TTS endpoints require an
240
+ authenticated admin session.
241
+ - **MCP** — tools are registered with `strapi.ai.mcp.registerTool` during `register()`
242
+ (the documented extension point), using `z` from `@strapi/utils` for the schemas and
243
+ `auth.policies` (content-manager read/update/publish) for RBAC. They're organized in a
244
+ modular `server/src/mcp/` (one file per tool in `tools/`, aggregated and looped) following
245
+ the structure from [Paul Bratslavsky's MCP tool-extension example](https://github.com/PaulBratslavsky/strapi-mcp-demo-and-tool-extension).
246
+ - **Admin** — `register()` uses only documented APIs (`app.addMenuLink`, `app.registerPlugin`).
247
+
248
+ One intentional deviation: the **global floating chat** is mounted via its own React root
249
+ in `bootstrap()`. Strapi's documented injection zones are Content-Manager-specific, and there
250
+ is no official zone for an admin-wide overlay — so this is the only way to render a widget on
251
+ every screen. It's isolated and idempotent (guarded by an element id) and contained to that
252
+ single spot.
253
+
254
+ ## Security
255
+
256
+ - The OpenAI key is read from the server environment only.
257
+ - Chat / STT / TTS routes are admin-authenticated.
258
+ - The registered MCP tools enforce `auth.policies` (content-manager read/update/publish).
259
+ When exposed to external MCP clients, the session is scoped to the connecting Admin
260
+ token's permissions — scope the token to only what those clients should change.
261
+ - The agent can edit and publish content — give the plugin only to trusted editors.
262
+
263
+ ## License
264
+
265
+ [MIT](./LICENSE) © Raul Balestra
@@ -0,0 +1,190 @@
1
+ /**
2
+ * Wrapper das sobreposições globais do admin: o chat flutuante e o painel
3
+ * grande de live preview. Mantém o estado do preview compartilhado entre os dois
4
+ * (o botão 🖼 do chat abre/fecha o painel grande na UI da Strapi).
5
+ *
6
+ * O iframe do preview costuma ser de outra origem (site x admin), então o admin
7
+ * não consegue ler diretamente para onde o usuário navegou dentro dele. A página
8
+ * do site se reporta via postMessage (ver PreviewBridge, no frontend); aqui
9
+ * escutamos essa mensagem para que ao RECARREGAR o preview a gente volte para a
10
+ * MESMA página que estava aberta — e não para a home.
11
+ *
12
+ * A URL do site é configurável: começa em localStorage (lembrada entre sessões)
13
+ * ou no fallback abaixo, e pode ser trocada na barra do painel. A origem aceita
14
+ * no postMessage é derivada dessa URL, então funciona para qualquer frontend.
15
+ */
16
+ import { useEffect, useRef, useState } from 'react';
17
+ import { getFetchClient } from '@strapi/strapi/admin';
18
+ import { FloatingChat } from './FloatingChat';
19
+ import { PreviewPanel } from './PreviewPanel';
20
+
21
+ // Fallback caso não haja nada salvo. Troque pela URL do seu frontend ou apenas
22
+ // edite na barra do painel — o valor fica salvo em localStorage.
23
+ const FALLBACK_PREVIEW_URL = 'http://localhost:3000';
24
+ const LS_KEY = 'mcp-chat-preview-url';
25
+
26
+ const initialPreviewUrl = (): string => {
27
+ try {
28
+ return localStorage.getItem(LS_KEY) || FALLBACK_PREVIEW_URL;
29
+ } catch {
30
+ return FALLBACK_PREVIEW_URL;
31
+ }
32
+ };
33
+
34
+ const originOf = (u: string): string | null => {
35
+ try {
36
+ return new URL(u).origin;
37
+ } catch {
38
+ return null;
39
+ }
40
+ };
41
+
42
+ export const AdminOverlays = () => {
43
+ const [previewOn, setPreviewOn] = useState(false);
44
+ // `src` do iframe: muda só em navegação manual (barra de URL) ou no reload.
45
+ const [previewSrc, setPreviewSrc] = useState(initialPreviewUrl);
46
+ // URL "viva" — acompanha a navegação dentro do iframe (p/ a barra e o reload).
47
+ const [liveHref, setLiveHref] = useState(initialPreviewUrl);
48
+ const liveRef = useRef(initialPreviewUrl());
49
+ const srcRef = useRef(initialPreviewUrl());
50
+ const [iframeKey, setIframeKey] = useState(0);
51
+
52
+ // Auto-run do frontend provisionado SEMPRE que o preview é ligado.
53
+ const [runLoading, setRunLoading] = useState(false);
54
+ const [runText, setRunText] = useState('');
55
+ const [runError, setRunError] = useState(false);
56
+
57
+ // Escuta a página do site reportando sua URL atual. Aceita apenas mensagens
58
+ // vindas da origem do site atualmente carregado no preview.
59
+ useEffect(() => {
60
+ const onMsg = (e: MessageEvent) => {
61
+ const allowed = originOf(srcRef.current);
62
+ if (!allowed || e.origin !== allowed) return;
63
+ const d: any = e.data;
64
+ if (d && d.type === 'preview:location' && typeof d.href === 'string') {
65
+ liveRef.current = d.href;
66
+ setLiveHref(d.href);
67
+ }
68
+ };
69
+ window.addEventListener('message', onMsg);
70
+ return () => window.removeEventListener('message', onMsg);
71
+ }, []);
72
+
73
+ // Reload: recarrega a página em que o usuário REALMENTE está (não a home).
74
+ // O scroll é restaurado pelo PreviewBridge no lado do site.
75
+ const reload = () => {
76
+ const target = liveRef.current || FALLBACK_PREVIEW_URL;
77
+ srcRef.current = target;
78
+ setPreviewSrc(target);
79
+ setIframeKey((k) => k + 1);
80
+ };
81
+
82
+ // SEMPRE que o preview é LIGADO: pede ao backend para rodar o frontend
83
+ // provisionado (instala + sobe o dev server) e mostra "carregando" até ele
84
+ // responder. O backend é idempotente: se já estiver no ar, não duplica; se
85
+ // tiver caído, reinicia. Se não houver nada provisionado, é no-op (modo manual).
86
+ useEffect(() => {
87
+ if (!previewOn) return;
88
+ let cancelled = false;
89
+ const { post, get } = getFetchClient();
90
+ const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
91
+
92
+ (async () => {
93
+ try {
94
+ setRunError(false);
95
+ setRunText('Iniciando o frontend…');
96
+ let st: any;
97
+ try {
98
+ const { data } = await post('/mcp-chat/frontend/run', {});
99
+ st = data;
100
+ } catch {
101
+ return; // nada provisionado → modo manual
102
+ }
103
+ if (!st || (st.state === 'idle' && !st.url)) return;
104
+
105
+ setRunLoading(true);
106
+ const startedAt = Date.now();
107
+ while (!cancelled && st && st.state !== 'running' && st.state !== 'error') {
108
+ if (Date.now() - startedAt > 180000) { st = { state: 'error', error: 'tempo esgotado' }; break; }
109
+ setRunText(st.state === 'installing' ? 'Instalando dependências…' : 'Subindo o dev server…');
110
+ await sleep(1500);
111
+ try {
112
+ const { data } = await get('/mcp-chat/frontend/run-status');
113
+ st = data;
114
+ } catch { /* reiniciando: continua */ }
115
+ }
116
+ if (cancelled) return;
117
+
118
+ if (st.state === 'running' && st.url) {
119
+ navigate(st.url);
120
+ setRunLoading(false);
121
+ } else if (st.state === 'error') {
122
+ setRunError(true);
123
+ setRunText('Falha ao iniciar o frontend: ' + (st.error || 'veja o terminal'));
124
+ } else {
125
+ setRunLoading(false);
126
+ }
127
+ } catch {
128
+ setRunLoading(false);
129
+ }
130
+ })();
131
+
132
+ return () => { cancelled = true; };
133
+ }, [previewOn]);
134
+
135
+ // Navegação manual pela barra de URL (e persiste a escolha).
136
+ const navigate = (v: string) => {
137
+ srcRef.current = v;
138
+ liveRef.current = v;
139
+ setPreviewSrc(v);
140
+ setLiveHref(v);
141
+ setIframeKey((k) => k + 1);
142
+ try {
143
+ localStorage.setItem(LS_KEY, v);
144
+ } catch {
145
+ /* noop */
146
+ }
147
+ };
148
+
149
+ return (
150
+ <>
151
+ <PreviewPanel
152
+ open={previewOn}
153
+ src={previewSrc}
154
+ displayUrl={liveHref}
155
+ onUrl={navigate}
156
+ iframeKey={iframeKey}
157
+ onReload={reload}
158
+ onClose={() => setPreviewOn(false)}
159
+ loading={runLoading}
160
+ loadingText={runText}
161
+ loadingError={runError}
162
+ />
163
+ <FloatingChat
164
+ previewOn={previewOn}
165
+ previewUrl={liveHref}
166
+ onTogglePreview={() => setPreviewOn((v) => !v)}
167
+ onReply={async (didWrite) => {
168
+ if (!previewOn) return;
169
+ // Houve edição no Strapi: re-sincroniza o snapshot do frontend para o
170
+ // preview refletir (a fonte da verdade é o Strapi). Se não for snapshot
171
+ // ou não houver provisão, o integrate é no-op e só recarregamos.
172
+ if (didWrite) {
173
+ try {
174
+ setRunError(false);
175
+ setRunText('Sincronizando alterações…');
176
+ setRunLoading(true);
177
+ const { post } = getFetchClient();
178
+ await post('/mcp-chat/frontend/integrate', {});
179
+ } catch {
180
+ /* sem integração: segue só com reload */
181
+ } finally {
182
+ setRunLoading(false);
183
+ }
184
+ }
185
+ reload();
186
+ }}
187
+ />
188
+ </>
189
+ );
190
+ };