strapi-plugin-mcp-chat 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +265 -0
- package/admin/src/components/AdminOverlays.tsx +190 -0
- package/admin/src/components/FloatingChat.tsx +370 -0
- package/admin/src/components/PreviewPanel.tsx +188 -0
- package/admin/src/index.tsx +49 -0
- package/admin/src/pages/App.tsx +14 -0
- package/admin/src/pages/HomePage.tsx +333 -0
- package/admin/src/pages/ProvisionPage.tsx +391 -0
- package/admin/src/pluginId.ts +1 -0
- package/dist/server/index.js +3511 -0
- package/package.json +77 -0
- package/server/src/content-tools.ts +520 -0
- package/server/src/controllers/audio.ts +45 -0
- package/server/src/controllers/chat.ts +22 -0
- package/server/src/controllers/frontend.ts +310 -0
- package/server/src/index.ts +43 -0
- package/server/src/mcp/index.ts +24 -0
- package/server/src/mcp/tools/buscar-texto.ts +28 -0
- package/server/src/mcp/tools/criar-locale.ts +30 -0
- package/server/src/mcp/tools/editar-campo.ts +39 -0
- package/server/src/mcp/tools/habilitar-i18n.ts +33 -0
- package/server/src/mcp/tools/index.ts +17 -0
- package/server/src/mcp/tools/listar-locales.ts +27 -0
- package/server/src/mcp/tools/publicar.ts +31 -0
- package/server/src/mcp/tools/traduzir.ts +36 -0
- package/server/src/mcp/types.ts +11 -0
- package/server/src/mcp-client.ts +96 -0
- package/server/src/provision/adapters.ts +91 -0
- package/server/src/provision/enable-i18n.ts +129 -0
- package/server/src/provision/generate.ts +216 -0
- package/server/src/provision/infer.ts +495 -0
- package/server/src/provision/integrate.ts +963 -0
- package/server/src/provision/link.ts +203 -0
- package/server/src/provision/manifest.ts +281 -0
- package/server/src/provision/orchestrate.ts +236 -0
- package/server/src/provision/permissions.ts +58 -0
- package/server/src/provision/runner.ts +176 -0
- package/server/src/provision/seed.ts +115 -0
- package/server/src/provision/translate.ts +153 -0
- package/server/src/provision/types-gen.ts +117 -0
- package/server/src/provision/write.ts +136 -0
- package/server/src/register.ts +17 -0
- package/server/src/routes/index.ts +66 -0
- package/server/src/services/audio.ts +53 -0
- package/server/src/services/chat.ts +263 -0
|
@@ -0,0 +1,370 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Chat flutuante global do admin: aparece em TODAS as telas (montado no body),
|
|
3
|
+
* ancorado à direita por padrão e arrastável pela barra de título.
|
|
4
|
+
* Autossuficiente (HTML/CSS puro, sem providers do admin). Autentica com o
|
|
5
|
+
* mesmo JWT do admin via getFetchClient.
|
|
6
|
+
*/
|
|
7
|
+
import { useEffect, useRef, useState, useCallback } from 'react';
|
|
8
|
+
import { getFetchClient } from '@strapi/strapi/admin';
|
|
9
|
+
|
|
10
|
+
type Msg = { role: 'user' | 'assistant'; content: string; image?: string | null };
|
|
11
|
+
type Pos = { x: number; y: number };
|
|
12
|
+
|
|
13
|
+
type Props = {
|
|
14
|
+
previewOn: boolean;
|
|
15
|
+
previewUrl: string;
|
|
16
|
+
onTogglePreview: () => void;
|
|
17
|
+
/** chamado após uma resposta; didWrite indica que houve edição no Strapi. */
|
|
18
|
+
onReply: (didWrite: boolean) => void;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
const W = 380;
|
|
22
|
+
|
|
23
|
+
type Lang = 'pt' | 'en';
|
|
24
|
+
|
|
25
|
+
const STR: Record<Lang, Record<string, string>> = {
|
|
26
|
+
pt: {
|
|
27
|
+
fab: 'Abrir MCP Chat', minimize: 'Minimizar',
|
|
28
|
+
rec: '🎤 Enviar áudio', recStop: '⏹ Parar áudio', recTitle: 'Gravar áudio e enviar (transcreve e manda)',
|
|
29
|
+
previewOn: '🖼 Preview: ON', previewOff: '🖼 Preview: OFF', previewTitle: 'Abrir/fechar o preview do site ao lado da Strapi',
|
|
30
|
+
voiceOn: '🔊 Voz: ON', voiceOff: '🔈 Voz: OFF', voiceTitle: 'Ler as respostas em voz alta (TTS)',
|
|
31
|
+
shareOn: '🛑 Parar tela', shareOff: '🖥 Compart. tela', shareTitle: 'Compartilhar a tela com a IA',
|
|
32
|
+
langTitle: 'Idioma do chat e da voz (PT-BR ↔ English)',
|
|
33
|
+
seeingScreen: '• vendo sua tela ', voiceStatus: '• voz ON',
|
|
34
|
+
empty: 'Escreva, fale (🎤) ou compartilhe a tela. Ex.: “troque o texto X por Y e publique”.',
|
|
35
|
+
you: 'Você', ai: 'IA', processing: 'Processando…',
|
|
36
|
+
placeholder: 'Escreva… (Cmd/Ctrl+Enter)', sendBtn: 'Enviar',
|
|
37
|
+
errShare: 'Não foi possível iniciar o compartilhamento de tela.',
|
|
38
|
+
errMic: 'Não foi possível acessar o microfone.',
|
|
39
|
+
errStt: 'Erro na transcrição.', errAudioEmpty: 'Não consegui entender o áudio.',
|
|
40
|
+
errChat: 'Erro ao falar com a IA.',
|
|
41
|
+
},
|
|
42
|
+
en: {
|
|
43
|
+
fab: 'Open MCP Chat', minimize: 'Minimize',
|
|
44
|
+
rec: '🎤 Send audio', recStop: '⏹ Stop audio', recTitle: 'Record audio and send (transcribes and sends)',
|
|
45
|
+
previewOn: '🖼 Preview: ON', previewOff: '🖼 Preview: OFF', previewTitle: 'Toggle the site preview next to Strapi',
|
|
46
|
+
voiceOn: '🔊 Voice: ON', voiceOff: '🔈 Voice: OFF', voiceTitle: 'Read replies out loud (TTS)',
|
|
47
|
+
shareOn: '🛑 Stop screen', shareOff: '🖥 Share screen', shareTitle: 'Share your screen with the AI',
|
|
48
|
+
langTitle: 'Chat and voice language (PT-BR ↔ English)',
|
|
49
|
+
seeingScreen: '• seeing your screen ', voiceStatus: '• voice ON',
|
|
50
|
+
empty: 'Type, speak (🎤) or share your screen. E.g.: “replace text X with Y and publish”.',
|
|
51
|
+
you: 'You', ai: 'AI', processing: 'Processing…',
|
|
52
|
+
placeholder: 'Type… (Cmd/Ctrl+Enter)', sendBtn: 'Send',
|
|
53
|
+
errShare: 'Could not start screen sharing.',
|
|
54
|
+
errMic: 'Could not access the microphone.',
|
|
55
|
+
errStt: 'Transcription error.', errAudioEmpty: 'I could not understand the audio.',
|
|
56
|
+
errChat: 'Error talking to the AI.',
|
|
57
|
+
},
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
export const FloatingChat = ({ previewOn, previewUrl, onTogglePreview, onReply }: Props) => {
|
|
61
|
+
const [open, setOpen] = useState(false);
|
|
62
|
+
const [pos, setPos] = useState<Pos | null>(null); // null = ancorado à direita
|
|
63
|
+
const [messages, setMessages] = useState<Msg[]>([]);
|
|
64
|
+
const [input, setInput] = useState('');
|
|
65
|
+
const [loading, setLoading] = useState(false);
|
|
66
|
+
const [sharing, setSharing] = useState(false);
|
|
67
|
+
const [voiceOn, setVoiceOn] = useState(false);
|
|
68
|
+
const [recording, setRecording] = useState(false);
|
|
69
|
+
const [lang, setLang] = useState<Lang>(() => {
|
|
70
|
+
try { return (localStorage.getItem('mcp-chat-lang') as Lang) || 'en'; } catch { return 'en'; }
|
|
71
|
+
});
|
|
72
|
+
const t = STR[lang];
|
|
73
|
+
useEffect(() => { try { localStorage.setItem('mcp-chat-lang', lang); } catch { /* noop */ } }, [lang]);
|
|
74
|
+
const [error, setError] = useState<string | null>(null);
|
|
75
|
+
|
|
76
|
+
const videoRef = useRef<HTMLVideoElement | null>(null);
|
|
77
|
+
const streamRef = useRef<MediaStream | null>(null);
|
|
78
|
+
const recorderRef = useRef<MediaRecorder | null>(null);
|
|
79
|
+
const chunksRef = useRef<Blob[]>([]);
|
|
80
|
+
const bodyRef = useRef<HTMLDivElement | null>(null);
|
|
81
|
+
const dragRef = useRef<{ dx: number; dy: number } | null>(null);
|
|
82
|
+
|
|
83
|
+
// ── Drag ────────────────────────────────────────────────────────────────
|
|
84
|
+
const onHeaderDown = (e: React.MouseEvent) => {
|
|
85
|
+
const startX = pos?.x ?? window.innerWidth - W - 24;
|
|
86
|
+
const startY = pos?.y ?? 88;
|
|
87
|
+
dragRef.current = { dx: e.clientX - startX, dy: e.clientY - startY };
|
|
88
|
+
e.preventDefault();
|
|
89
|
+
};
|
|
90
|
+
useEffect(() => {
|
|
91
|
+
const move = (e: MouseEvent) => {
|
|
92
|
+
if (!dragRef.current) return;
|
|
93
|
+
const x = Math.max(0, Math.min(window.innerWidth - 60, e.clientX - dragRef.current.dx));
|
|
94
|
+
const y = Math.max(0, Math.min(window.innerHeight - 40, e.clientY - dragRef.current.dy));
|
|
95
|
+
setPos({ x, y });
|
|
96
|
+
};
|
|
97
|
+
const up = () => { dragRef.current = null; };
|
|
98
|
+
window.addEventListener('mousemove', move);
|
|
99
|
+
window.addEventListener('mouseup', up);
|
|
100
|
+
return () => { window.removeEventListener('mousemove', move); window.removeEventListener('mouseup', up); };
|
|
101
|
+
}, []);
|
|
102
|
+
|
|
103
|
+
// ── Auto-scroll ───────────────────────────────────────────────────────────
|
|
104
|
+
useEffect(() => {
|
|
105
|
+
if (bodyRef.current) bodyRef.current.scrollTop = bodyRef.current.scrollHeight;
|
|
106
|
+
}, [messages, loading]);
|
|
107
|
+
|
|
108
|
+
// ── Screenshare ───────────────────────────────────────────────────────────
|
|
109
|
+
const startShare = async () => {
|
|
110
|
+
setError(null);
|
|
111
|
+
try {
|
|
112
|
+
const stream = await (navigator.mediaDevices as any).getDisplayMedia({ video: true });
|
|
113
|
+
streamRef.current = stream;
|
|
114
|
+
if (videoRef.current) {
|
|
115
|
+
videoRef.current.srcObject = stream;
|
|
116
|
+
await videoRef.current.play().catch(() => undefined);
|
|
117
|
+
}
|
|
118
|
+
stream.getVideoTracks()[0].addEventListener('ended', stopShare);
|
|
119
|
+
setSharing(true);
|
|
120
|
+
} catch {
|
|
121
|
+
setError(t.errShare);
|
|
122
|
+
}
|
|
123
|
+
};
|
|
124
|
+
const stopShare = () => {
|
|
125
|
+
streamRef.current?.getTracks().forEach((t) => t.stop());
|
|
126
|
+
streamRef.current = null;
|
|
127
|
+
if (videoRef.current) videoRef.current.srcObject = null;
|
|
128
|
+
setSharing(false);
|
|
129
|
+
};
|
|
130
|
+
const captureFrame = (): string | null => {
|
|
131
|
+
const video = videoRef.current;
|
|
132
|
+
if (!sharing || !video || !video.videoWidth || !video.videoHeight) return null;
|
|
133
|
+
const canvas = document.createElement('canvas');
|
|
134
|
+
// Reduz a resolução e usa JPEG comprimido para o payload não estourar o
|
|
135
|
+
// limite do corpo da requisição (evita o erro 413 "request entity too large").
|
|
136
|
+
const scale = Math.min(1, 1100 / video.videoWidth);
|
|
137
|
+
canvas.width = Math.round(video.videoWidth * scale);
|
|
138
|
+
canvas.height = Math.round(video.videoHeight * scale);
|
|
139
|
+
const ctx = canvas.getContext('2d');
|
|
140
|
+
if (!ctx) return null;
|
|
141
|
+
ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
|
|
142
|
+
return canvas.toDataURL('image/jpeg', 0.7);
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
// ── TTS ────────────────────────────────────────────────────────────────────
|
|
146
|
+
const playTTS = async (text: string) => {
|
|
147
|
+
try {
|
|
148
|
+
const { post } = getFetchClient();
|
|
149
|
+
const { data } = await post('/mcp-chat/tts', { text });
|
|
150
|
+
const audio = new Audio(`data:${data.content_type};base64,${data.audio_base64}`);
|
|
151
|
+
await audio.play().catch(() => undefined);
|
|
152
|
+
} catch { /* voz é opcional */ }
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
// ── Enviar ──────────────────────────────────────────────────────────────────
|
|
156
|
+
const sendMessage = async (text: string) => {
|
|
157
|
+
if (!text || loading) return;
|
|
158
|
+
setError(null);
|
|
159
|
+
const image = captureFrame();
|
|
160
|
+
const next: Msg[] = [...messages, { role: 'user', content: text, image }];
|
|
161
|
+
setMessages(next);
|
|
162
|
+
setLoading(true);
|
|
163
|
+
try {
|
|
164
|
+
const { post } = getFetchClient();
|
|
165
|
+
const { data } = await post('/mcp-chat/message', {
|
|
166
|
+
messages: next.map((m) => ({ role: m.role, content: m.content })),
|
|
167
|
+
image,
|
|
168
|
+
lang,
|
|
169
|
+
// Página que o usuário está olhando no preview (se aberto). Dá à IA o
|
|
170
|
+
// contexto do "isso aqui" sem precisar varrer o site inteiro.
|
|
171
|
+
previewUrl: previewOn ? previewUrl : null,
|
|
172
|
+
});
|
|
173
|
+
const reply = data?.reply || '(sem resposta)';
|
|
174
|
+
setMessages((cur) => [...cur, { role: 'assistant', content: reply }]);
|
|
175
|
+
onReply(!!data?.didWrite);
|
|
176
|
+
if (voiceOn) playTTS(reply);
|
|
177
|
+
} catch (e: any) {
|
|
178
|
+
setError(e?.response?.data?.error?.message || e?.message || t.errChat);
|
|
179
|
+
} finally {
|
|
180
|
+
setLoading(false);
|
|
181
|
+
}
|
|
182
|
+
};
|
|
183
|
+
const send = () => {
|
|
184
|
+
const text = input.trim();
|
|
185
|
+
if (!text) return;
|
|
186
|
+
setInput('');
|
|
187
|
+
sendMessage(text);
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
// ── STT ──────────────────────────────────────────────────────────────────────
|
|
191
|
+
const startRecording = async () => {
|
|
192
|
+
setError(null);
|
|
193
|
+
try {
|
|
194
|
+
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
|
195
|
+
const mimeType = MediaRecorder.isTypeSupported('audio/webm')
|
|
196
|
+
? 'audio/webm'
|
|
197
|
+
: MediaRecorder.isTypeSupported('audio/mp4') ? 'audio/mp4' : '';
|
|
198
|
+
const recorder = mimeType ? new MediaRecorder(stream, { mimeType }) : new MediaRecorder(stream);
|
|
199
|
+
chunksRef.current = [];
|
|
200
|
+
recorder.ondataavailable = (e) => { if (e.data.size > 0) chunksRef.current.push(e.data); };
|
|
201
|
+
recorder.onstop = async () => {
|
|
202
|
+
stream.getTracks().forEach((t) => t.stop());
|
|
203
|
+
const blob = new Blob(chunksRef.current, { type: recorder.mimeType || 'audio/webm' });
|
|
204
|
+
await transcribeAndSend(blob);
|
|
205
|
+
};
|
|
206
|
+
recorderRef.current = recorder;
|
|
207
|
+
recorder.start();
|
|
208
|
+
setRecording(true);
|
|
209
|
+
} catch {
|
|
210
|
+
setError(t.errMic);
|
|
211
|
+
}
|
|
212
|
+
};
|
|
213
|
+
const stopRecording = () => { recorderRef.current?.stop(); setRecording(false); };
|
|
214
|
+
const transcribeAndSend = async (blob: Blob) => {
|
|
215
|
+
setLoading(true);
|
|
216
|
+
try {
|
|
217
|
+
const { post } = getFetchClient();
|
|
218
|
+
const ext = blob.type.includes('mp4') ? 'mp4' : 'webm';
|
|
219
|
+
const form = new FormData();
|
|
220
|
+
form.append('audio', blob, `audio.${ext}`);
|
|
221
|
+
form.append('language', lang);
|
|
222
|
+
// Idioma também na query: campos multipart nem sempre chegam em
|
|
223
|
+
// ctx.request.body, mas a query string nunca se perde. Garante que o
|
|
224
|
+
// Whisper transcreve no idioma escolhido (PT ou EN), não outro.
|
|
225
|
+
const { data } = await post(`/mcp-chat/stt?language=${lang}`, form);
|
|
226
|
+
const text = (data?.text || '').trim();
|
|
227
|
+
setLoading(false);
|
|
228
|
+
if (text) sendMessage(text);
|
|
229
|
+
else setError(t.errAudioEmpty);
|
|
230
|
+
} catch (e: any) {
|
|
231
|
+
setLoading(false);
|
|
232
|
+
setError(e?.response?.data?.error?.message || t.errStt);
|
|
233
|
+
}
|
|
234
|
+
};
|
|
235
|
+
|
|
236
|
+
// ── Estilos ──────────────────────────────────────────────────────────────────
|
|
237
|
+
const anchor: React.CSSProperties = pos
|
|
238
|
+
? { left: pos.x, top: pos.y }
|
|
239
|
+
: { right: 24, top: 88 };
|
|
240
|
+
|
|
241
|
+
const btn = (active = false): React.CSSProperties => ({
|
|
242
|
+
border: '1px solid #dcdce4', background: active ? '#4945ff' : '#fff',
|
|
243
|
+
color: active ? '#fff' : '#32324d', borderRadius: 6, padding: '4px 8px',
|
|
244
|
+
fontSize: 12, cursor: 'pointer', whiteSpace: 'nowrap',
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
if (!open) {
|
|
248
|
+
return (
|
|
249
|
+
<button
|
|
250
|
+
onClick={() => setOpen(true)}
|
|
251
|
+
title={t.fab}
|
|
252
|
+
style={{
|
|
253
|
+
position: 'fixed', right: 24, bottom: 24, zIndex: 2147483000,
|
|
254
|
+
width: 56, height: 56, borderRadius: '50%', border: 'none',
|
|
255
|
+
background: '#4945ff', color: '#fff', fontSize: 24, cursor: 'pointer',
|
|
256
|
+
boxShadow: '0 4px 16px rgba(0,0,0,0.25)',
|
|
257
|
+
}}
|
|
258
|
+
>💬</button>
|
|
259
|
+
);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
return (
|
|
263
|
+
<div
|
|
264
|
+
style={{
|
|
265
|
+
position: 'fixed', ...anchor, width: W, zIndex: 2147483000,
|
|
266
|
+
display: 'flex', flexDirection: 'column',
|
|
267
|
+
maxHeight: 'calc(100vh - 110px)',
|
|
268
|
+
background: '#fff', border: '1px solid #dcdce4', borderRadius: 8,
|
|
269
|
+
boxShadow: '0 8px 30px rgba(0,0,0,0.25)', overflow: 'hidden',
|
|
270
|
+
fontFamily: 'inherit',
|
|
271
|
+
}}
|
|
272
|
+
>
|
|
273
|
+
<video ref={videoRef} autoPlay muted style={{ display: 'none' }} />
|
|
274
|
+
|
|
275
|
+
{/* Header / drag handle */}
|
|
276
|
+
<div
|
|
277
|
+
onMouseDown={onHeaderDown}
|
|
278
|
+
style={{
|
|
279
|
+
cursor: 'move', background: '#181826', color: '#fff',
|
|
280
|
+
padding: '8px 10px', display: 'flex', alignItems: 'center',
|
|
281
|
+
justifyContent: 'space-between', userSelect: 'none',
|
|
282
|
+
}}
|
|
283
|
+
>
|
|
284
|
+
<div style={{ display: 'flex', flexDirection: 'column' }}>
|
|
285
|
+
<strong style={{ fontSize: 13 }}>MCP Chat</strong>
|
|
286
|
+
<span style={{ fontSize: 10, opacity: 0.7 }}>
|
|
287
|
+
{sharing ? t.seeingScreen : ''}{voiceOn ? t.voiceStatus : ''}
|
|
288
|
+
</span>
|
|
289
|
+
</div>
|
|
290
|
+
<div style={{ display: 'flex', gap: 6, alignItems: 'center' }}>
|
|
291
|
+
{/* Seletor de idioma — sempre visível no cabeçalho */}
|
|
292
|
+
<button
|
|
293
|
+
onMouseDown={(e) => e.stopPropagation()}
|
|
294
|
+
onClick={() => setLang((l) => (l === 'pt' ? 'en' : 'pt'))}
|
|
295
|
+
title={t.langTitle}
|
|
296
|
+
style={{
|
|
297
|
+
border: '1px solid #4a4a6a', background: '#2a2a45', color: '#fff',
|
|
298
|
+
borderRadius: 6, padding: '2px 8px', cursor: 'pointer', fontSize: 12, whiteSpace: 'nowrap',
|
|
299
|
+
}}
|
|
300
|
+
>
|
|
301
|
+
{lang === 'pt' ? '🌐 PT-BR' : '🌐 English'}
|
|
302
|
+
</button>
|
|
303
|
+
<button onClick={() => setOpen(false)} title={t.minimize}
|
|
304
|
+
style={{ ...btn(), padding: '2px 8px' }}>—</button>
|
|
305
|
+
</div>
|
|
306
|
+
</div>
|
|
307
|
+
|
|
308
|
+
{/* Toolbar */}
|
|
309
|
+
<div style={{ display: 'flex', gap: 6, padding: 8, borderBottom: '1px solid #eaeaef', flexWrap: 'wrap' }}>
|
|
310
|
+
<button style={btn(recording)} onClick={recording ? stopRecording : startRecording}
|
|
311
|
+
title={t.recTitle}>
|
|
312
|
+
{recording ? t.recStop : t.rec}
|
|
313
|
+
</button>
|
|
314
|
+
<button style={btn(previewOn)} onClick={onTogglePreview}
|
|
315
|
+
title={t.previewTitle}>
|
|
316
|
+
{previewOn ? t.previewOn : t.previewOff}
|
|
317
|
+
</button>
|
|
318
|
+
<button style={btn(voiceOn)} onClick={() => setVoiceOn((v) => !v)}
|
|
319
|
+
title={t.voiceTitle}>
|
|
320
|
+
{voiceOn ? t.voiceOn : t.voiceOff}
|
|
321
|
+
</button>
|
|
322
|
+
<button style={btn(sharing)} onClick={sharing ? stopShare : startShare}
|
|
323
|
+
title={t.shareTitle}>
|
|
324
|
+
{sharing ? t.shareOn : t.shareOff}
|
|
325
|
+
</button>
|
|
326
|
+
</div>
|
|
327
|
+
|
|
328
|
+
{/* Mensagens */}
|
|
329
|
+
<div ref={bodyRef} style={{ flex: 1, overflowY: 'auto', padding: 10, minHeight: 200, background: '#f6f6f9' }}>
|
|
330
|
+
{messages.length === 0 && (
|
|
331
|
+
<p style={{ color: '#8e8ea9', fontSize: 13 }}>{t.empty}</p>
|
|
332
|
+
)}
|
|
333
|
+
{messages.map((m, i) => (
|
|
334
|
+
<div key={i} style={{
|
|
335
|
+
marginBottom: 8, padding: 8, borderRadius: 6,
|
|
336
|
+
background: m.role === 'user' ? '#eaf0ff' : '#fff',
|
|
337
|
+
border: '1px solid #eaeaef',
|
|
338
|
+
}}>
|
|
339
|
+
<div style={{ fontSize: 10, fontWeight: 700, color: m.role === 'user' ? '#4945ff' : '#666687', marginBottom: 2 }}>
|
|
340
|
+
{m.role === 'user' ? t.you : t.ai}
|
|
341
|
+
</div>
|
|
342
|
+
<div style={{ fontSize: 13, whiteSpace: 'pre-wrap', color: '#32324d' }}>{m.content}</div>
|
|
343
|
+
{m.image && <img src={m.image} alt="screen" style={{ maxWidth: '100%', borderRadius: 4, marginTop: 6, border: '1px solid #ddd' }} />}
|
|
344
|
+
</div>
|
|
345
|
+
))}
|
|
346
|
+
{loading && <p style={{ color: '#8e8ea9', fontSize: 13 }}>{t.processing}</p>}
|
|
347
|
+
</div>
|
|
348
|
+
|
|
349
|
+
{error && (
|
|
350
|
+
<div style={{ background: '#fcecea', color: '#d02b20', padding: '6px 10px', fontSize: 12 }}>{error}</div>
|
|
351
|
+
)}
|
|
352
|
+
|
|
353
|
+
{/* Input */}
|
|
354
|
+
<div style={{ display: 'flex', gap: 6, padding: 8, borderTop: '1px solid #eaeaef' }}>
|
|
355
|
+
<textarea
|
|
356
|
+
value={input}
|
|
357
|
+
placeholder={t.placeholder}
|
|
358
|
+
onChange={(e) => setInput(e.target.value)}
|
|
359
|
+
onKeyDown={(e) => { if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) send(); }}
|
|
360
|
+
rows={2}
|
|
361
|
+
style={{ flex: 1, resize: 'none', border: '1px solid #dcdce4', borderRadius: 6, padding: 6, fontSize: 13, fontFamily: 'inherit' }}
|
|
362
|
+
/>
|
|
363
|
+
<button onClick={send} disabled={loading || !input.trim()}
|
|
364
|
+
style={{ ...btn(true), opacity: loading || !input.trim() ? 0.5 : 1, padding: '0 12px' }}>
|
|
365
|
+
{loading ? '…' : t.sendBtn}
|
|
366
|
+
</button>
|
|
367
|
+
</div>
|
|
368
|
+
</div>
|
|
369
|
+
);
|
|
370
|
+
};
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Painel de live preview DOCADO (side-by-side) com a UI da Strapi.
|
|
3
|
+
*
|
|
4
|
+
* Em vez de flutuar por cima, ele ENCOLHE o app do admin (#strapi) para a
|
|
5
|
+
* esquerda e ocupa toda a coluna da direita, em altura cheia. Assim o
|
|
6
|
+
* Content Manager (incluindo Salvar/Publicar) continua 100% visível e usável.
|
|
7
|
+
*
|
|
8
|
+
* O truque para encolher tudo — inclusive o menu lateral fixo da Strapi — é
|
|
9
|
+
* aplicar `transform` no #strapi: isso o torna o bloco-contêiner dos filhos
|
|
10
|
+
* `position: fixed`, então eles passam a respeitar a largura reduzida.
|
|
11
|
+
*
|
|
12
|
+
* A largura é ajustável arrastando a divisória na borda esquerda do painel.
|
|
13
|
+
*/
|
|
14
|
+
import { useEffect, useRef, useState } from 'react';
|
|
15
|
+
|
|
16
|
+
type Props = {
|
|
17
|
+
open: boolean;
|
|
18
|
+
/** src do iframe — muda só em navegação manual ou reload. */
|
|
19
|
+
src: string;
|
|
20
|
+
/** URL exibida na barra — acompanha a navegação dentro do iframe. */
|
|
21
|
+
displayUrl: string;
|
|
22
|
+
onUrl: (v: string) => void;
|
|
23
|
+
iframeKey: number;
|
|
24
|
+
onReload: () => void;
|
|
25
|
+
onClose: () => void;
|
|
26
|
+
/** mostra um overlay de carregamento sobre o iframe (ex.: subindo o dev server). */
|
|
27
|
+
loading?: boolean;
|
|
28
|
+
loadingText?: string;
|
|
29
|
+
loadingError?: boolean;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
const MIN_W = 320;
|
|
33
|
+
const clampW = (w: number) => Math.max(MIN_W, Math.min(window.innerWidth - 360, w));
|
|
34
|
+
|
|
35
|
+
export const PreviewPanel = ({ open, src, displayUrl, onUrl, iframeKey, onReload, onClose, loading, loadingText, loadingError }: Props) => {
|
|
36
|
+
const [width, setWidth] = useState(() => Math.round(window.innerWidth * 0.42));
|
|
37
|
+
const [draftUrl, setDraftUrl] = useState(displayUrl);
|
|
38
|
+
const [resizing, setResizing] = useState(false);
|
|
39
|
+
const dragging = useRef(false);
|
|
40
|
+
|
|
41
|
+
useEffect(() => setDraftUrl(displayUrl), [displayUrl]);
|
|
42
|
+
|
|
43
|
+
// Encolhe / restaura o app do admin enquanto o painel está aberto.
|
|
44
|
+
useEffect(() => {
|
|
45
|
+
const root = document.getElementById('strapi') as HTMLElement | null;
|
|
46
|
+
if (!root) return;
|
|
47
|
+
if (open) {
|
|
48
|
+
root.style.width = `calc(100vw - ${width}px)`;
|
|
49
|
+
root.style.maxWidth = `calc(100vw - ${width}px)`;
|
|
50
|
+
root.style.transform = 'translateZ(0)';
|
|
51
|
+
root.style.overflow = 'hidden';
|
|
52
|
+
} else {
|
|
53
|
+
root.style.width = '';
|
|
54
|
+
root.style.maxWidth = '';
|
|
55
|
+
root.style.transform = '';
|
|
56
|
+
root.style.overflow = '';
|
|
57
|
+
}
|
|
58
|
+
return () => {
|
|
59
|
+
root.style.width = '';
|
|
60
|
+
root.style.maxWidth = '';
|
|
61
|
+
root.style.transform = '';
|
|
62
|
+
root.style.overflow = '';
|
|
63
|
+
};
|
|
64
|
+
}, [open, width]);
|
|
65
|
+
|
|
66
|
+
// Arrastar a divisória para redimensionar.
|
|
67
|
+
useEffect(() => {
|
|
68
|
+
const onMove = (e: MouseEvent) => {
|
|
69
|
+
if (!dragging.current) return;
|
|
70
|
+
setWidth(clampW(window.innerWidth - e.clientX));
|
|
71
|
+
};
|
|
72
|
+
const onUp = () => {
|
|
73
|
+
if (!dragging.current) return;
|
|
74
|
+
dragging.current = false;
|
|
75
|
+
setResizing(false);
|
|
76
|
+
document.body.style.userSelect = '';
|
|
77
|
+
};
|
|
78
|
+
window.addEventListener('mousemove', onMove);
|
|
79
|
+
window.addEventListener('mouseup', onUp);
|
|
80
|
+
return () => {
|
|
81
|
+
window.removeEventListener('mousemove', onMove);
|
|
82
|
+
window.removeEventListener('mouseup', onUp);
|
|
83
|
+
};
|
|
84
|
+
}, []);
|
|
85
|
+
|
|
86
|
+
if (!open) return null;
|
|
87
|
+
|
|
88
|
+
const hdrBtn: React.CSSProperties = {
|
|
89
|
+
border: '1px solid #4a4a6a', background: '#2a2a45', color: '#fff',
|
|
90
|
+
borderRadius: 6, padding: '4px 9px', cursor: 'pointer', fontSize: 12,
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
return (
|
|
94
|
+
<div
|
|
95
|
+
style={{
|
|
96
|
+
position: 'fixed', top: 0, right: 0, bottom: 0, width,
|
|
97
|
+
zIndex: 2147482000, background: '#fff',
|
|
98
|
+
borderLeft: '1px solid #dcdce4', boxShadow: '-6px 0 24px rgba(0,0,0,0.14)',
|
|
99
|
+
display: 'flex', flexDirection: 'column',
|
|
100
|
+
}}
|
|
101
|
+
>
|
|
102
|
+
{/* Divisória de redimensionamento (borda esquerda) */}
|
|
103
|
+
<div
|
|
104
|
+
onMouseDown={(e) => {
|
|
105
|
+
dragging.current = true;
|
|
106
|
+
setResizing(true);
|
|
107
|
+
document.body.style.userSelect = 'none';
|
|
108
|
+
e.preventDefault();
|
|
109
|
+
}}
|
|
110
|
+
title="Arraste para ajustar a largura"
|
|
111
|
+
style={{
|
|
112
|
+
position: 'absolute', left: -4, top: 0, bottom: 0, width: 8,
|
|
113
|
+
cursor: 'col-resize', zIndex: 2,
|
|
114
|
+
}}
|
|
115
|
+
/>
|
|
116
|
+
|
|
117
|
+
{/* Header */}
|
|
118
|
+
<div
|
|
119
|
+
style={{
|
|
120
|
+
display: 'flex', gap: 8, alignItems: 'center', padding: '8px 10px',
|
|
121
|
+
background: '#181826', color: '#fff', userSelect: 'none',
|
|
122
|
+
}}
|
|
123
|
+
>
|
|
124
|
+
<strong style={{ fontSize: 13, whiteSpace: 'nowrap' }}>🖼 Preview</strong>
|
|
125
|
+
<input
|
|
126
|
+
value={draftUrl}
|
|
127
|
+
onChange={(e) => setDraftUrl(e.target.value)}
|
|
128
|
+
onKeyDown={(e) => { if (e.key === 'Enter') onUrl(draftUrl); }}
|
|
129
|
+
onBlur={() => onUrl(draftUrl)}
|
|
130
|
+
style={{
|
|
131
|
+
flex: 1, fontSize: 12, padding: '4px 8px', borderRadius: 6,
|
|
132
|
+
border: '1px solid #4a4a6a', background: '#0f0f1a', color: '#fff',
|
|
133
|
+
}}
|
|
134
|
+
/>
|
|
135
|
+
<button onClick={onReload} title="Recarregar" style={hdrBtn}>↻</button>
|
|
136
|
+
<button onClick={onClose} title="Fechar" style={hdrBtn}>✕</button>
|
|
137
|
+
</div>
|
|
138
|
+
|
|
139
|
+
{/* Iframe */}
|
|
140
|
+
<div style={{ flex: 1, position: 'relative' }}>
|
|
141
|
+
<iframe
|
|
142
|
+
key={iframeKey}
|
|
143
|
+
src={src}
|
|
144
|
+
title="Live Preview do site"
|
|
145
|
+
allow="clipboard-read; clipboard-write"
|
|
146
|
+
style={{ width: '100%', height: '100%', border: 'none', display: 'block' }}
|
|
147
|
+
/>
|
|
148
|
+
{/* máscara durante o resize p/ o iframe não engolir o mouse */}
|
|
149
|
+
{resizing && <div style={{ position: 'absolute', inset: 0 }} />}
|
|
150
|
+
|
|
151
|
+
{/* overlay de carregamento (subindo o dev server do frontend) */}
|
|
152
|
+
{loading && (
|
|
153
|
+
<div
|
|
154
|
+
style={{
|
|
155
|
+
position: 'absolute', inset: 0, background: '#0f0f1a',
|
|
156
|
+
color: '#fff', display: 'flex', flexDirection: 'column',
|
|
157
|
+
alignItems: 'center', justifyContent: 'center', gap: 16, padding: 24,
|
|
158
|
+
}}
|
|
159
|
+
>
|
|
160
|
+
{loadingError ? (
|
|
161
|
+
<div style={{ fontSize: 40 }}>⚠️</div>
|
|
162
|
+
) : (
|
|
163
|
+
<>
|
|
164
|
+
<div
|
|
165
|
+
style={{
|
|
166
|
+
width: 40, height: 40, borderRadius: '50%',
|
|
167
|
+
border: '4px solid #3a3a55', borderTopColor: '#7b79ff',
|
|
168
|
+
animation: 'mcpspin 0.9s linear infinite',
|
|
169
|
+
}}
|
|
170
|
+
/>
|
|
171
|
+
<style>{'@keyframes mcpspin{to{transform:rotate(360deg)}}'}</style>
|
|
172
|
+
</>
|
|
173
|
+
)}
|
|
174
|
+
<div style={{ fontSize: 14, fontWeight: 600, textAlign: 'center' }}>
|
|
175
|
+
{loadingText || 'Iniciando o frontend…'}
|
|
176
|
+
</div>
|
|
177
|
+
{!loadingError && (
|
|
178
|
+
<div style={{ fontSize: 12, color: '#9a9ab5', textAlign: 'center', maxWidth: 360 }}>
|
|
179
|
+
Instalando dependências e subindo o dev server. Na primeira vez pode
|
|
180
|
+
levar um pouco mais.
|
|
181
|
+
</div>
|
|
182
|
+
)}
|
|
183
|
+
</div>
|
|
184
|
+
)}
|
|
185
|
+
</div>
|
|
186
|
+
</div>
|
|
187
|
+
);
|
|
188
|
+
};
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { createRoot } from 'react-dom/client';
|
|
2
|
+
import { PLUGIN_ID } from './pluginId';
|
|
3
|
+
import { AdminOverlays } from './components/AdminOverlays';
|
|
4
|
+
|
|
5
|
+
const PluginIcon = () => (
|
|
6
|
+
<svg width="1em" height="1em" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
7
|
+
<path
|
|
8
|
+
d="M4 4h16a1 1 0 0 1 1 1v11a1 1 0 0 1-1 1H9l-5 4V5a1 1 0 0 1 1-1Z"
|
|
9
|
+
fill="currentColor"
|
|
10
|
+
/>
|
|
11
|
+
</svg>
|
|
12
|
+
);
|
|
13
|
+
|
|
14
|
+
export default {
|
|
15
|
+
register(app: any) {
|
|
16
|
+
app.addMenuLink({
|
|
17
|
+
to: `plugins/${PLUGIN_ID}`,
|
|
18
|
+
icon: PluginIcon,
|
|
19
|
+
intlLabel: { id: `${PLUGIN_ID}.plugin.name`, defaultMessage: 'MCP Chat' },
|
|
20
|
+
Component: async () => {
|
|
21
|
+
const { App } = await import('./pages/App');
|
|
22
|
+
return App;
|
|
23
|
+
},
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
app.registerPlugin({
|
|
27
|
+
id: PLUGIN_ID,
|
|
28
|
+
name: PLUGIN_ID,
|
|
29
|
+
});
|
|
30
|
+
},
|
|
31
|
+
|
|
32
|
+
bootstrap() {
|
|
33
|
+
// Chat flutuante GLOBAL (presente em todas as telas do admin).
|
|
34
|
+
//
|
|
35
|
+
// Nota de conformidade: as injection zones documentadas do Strapi 5 são
|
|
36
|
+
// específicas do Content Manager (ex.: editView/right-links) — não há uma
|
|
37
|
+
// zona oficial para um overlay global em todo o admin. Portanto montamos um
|
|
38
|
+
// React root próprio, de forma isolada e idempotente (guardado por id, sem
|
|
39
|
+
// tocar na árvore do admin). É o único desvio das APIs documentadas e está
|
|
40
|
+
// contido a este único ponto. `register()` acima usa só APIs oficiais
|
|
41
|
+
// (addMenuLink + registerPlugin).
|
|
42
|
+
const ID = 'mcp-chat-fab-root';
|
|
43
|
+
if (document.getElementById(ID)) return;
|
|
44
|
+
const el = document.createElement('div');
|
|
45
|
+
el.id = ID;
|
|
46
|
+
document.body.appendChild(el);
|
|
47
|
+
createRoot(el).render(<AdminOverlays />);
|
|
48
|
+
},
|
|
49
|
+
};
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { Routes, Route } from 'react-router-dom';
|
|
2
|
+
import { HomePage } from './HomePage';
|
|
3
|
+
import { ProvisionPage } from './ProvisionPage';
|
|
4
|
+
|
|
5
|
+
const App = () => {
|
|
6
|
+
return (
|
|
7
|
+
<Routes>
|
|
8
|
+
<Route index element={<HomePage />} />
|
|
9
|
+
<Route path="provision" element={<ProvisionPage />} />
|
|
10
|
+
</Routes>
|
|
11
|
+
);
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export { App };
|