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/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
|
+
};
|