strapi-plugin-mcp-chat 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +265 -0
  3. package/admin/src/components/AdminOverlays.tsx +190 -0
  4. package/admin/src/components/FloatingChat.tsx +370 -0
  5. package/admin/src/components/PreviewPanel.tsx +188 -0
  6. package/admin/src/index.tsx +49 -0
  7. package/admin/src/pages/App.tsx +14 -0
  8. package/admin/src/pages/HomePage.tsx +333 -0
  9. package/admin/src/pages/ProvisionPage.tsx +391 -0
  10. package/admin/src/pluginId.ts +1 -0
  11. package/dist/server/index.js +3511 -0
  12. package/package.json +77 -0
  13. package/server/src/content-tools.ts +520 -0
  14. package/server/src/controllers/audio.ts +45 -0
  15. package/server/src/controllers/chat.ts +22 -0
  16. package/server/src/controllers/frontend.ts +310 -0
  17. package/server/src/index.ts +43 -0
  18. package/server/src/mcp/index.ts +24 -0
  19. package/server/src/mcp/tools/buscar-texto.ts +28 -0
  20. package/server/src/mcp/tools/criar-locale.ts +30 -0
  21. package/server/src/mcp/tools/editar-campo.ts +39 -0
  22. package/server/src/mcp/tools/habilitar-i18n.ts +33 -0
  23. package/server/src/mcp/tools/index.ts +17 -0
  24. package/server/src/mcp/tools/listar-locales.ts +27 -0
  25. package/server/src/mcp/tools/publicar.ts +31 -0
  26. package/server/src/mcp/tools/traduzir.ts +36 -0
  27. package/server/src/mcp/types.ts +11 -0
  28. package/server/src/mcp-client.ts +96 -0
  29. package/server/src/provision/adapters.ts +91 -0
  30. package/server/src/provision/enable-i18n.ts +129 -0
  31. package/server/src/provision/generate.ts +216 -0
  32. package/server/src/provision/infer.ts +495 -0
  33. package/server/src/provision/integrate.ts +963 -0
  34. package/server/src/provision/link.ts +203 -0
  35. package/server/src/provision/manifest.ts +281 -0
  36. package/server/src/provision/orchestrate.ts +236 -0
  37. package/server/src/provision/permissions.ts +58 -0
  38. package/server/src/provision/runner.ts +176 -0
  39. package/server/src/provision/seed.ts +115 -0
  40. package/server/src/provision/translate.ts +153 -0
  41. package/server/src/provision/types-gen.ts +117 -0
  42. package/server/src/provision/write.ts +136 -0
  43. package/server/src/register.ts +17 -0
  44. package/server/src/routes/index.ts +66 -0
  45. package/server/src/services/audio.ts +53 -0
  46. package/server/src/services/chat.ts +263 -0
@@ -0,0 +1,333 @@
1
+ import { useRef, useState } from 'react';
2
+ import { Box, Flex, Typography, Button, Textarea, TextInput } from '@strapi/design-system';
3
+ import { useFetchClient } from '@strapi/strapi/admin';
4
+ import { Link } from 'react-router-dom';
5
+
6
+ type Msg = { role: 'user' | 'assistant'; content: string; image?: string | null };
7
+
8
+ const FALLBACK_PREVIEW_URL = 'http://localhost:3000';
9
+ const DEFAULT_PREVIEW_URL = (() => {
10
+ try {
11
+ return localStorage.getItem('mcp-chat-preview-url') || FALLBACK_PREVIEW_URL;
12
+ } catch {
13
+ return FALLBACK_PREVIEW_URL;
14
+ }
15
+ })();
16
+
17
+ const HomePage = () => {
18
+ const { post } = useFetchClient();
19
+
20
+ const [messages, setMessages] = useState<Msg[]>([]);
21
+ const [input, setInput] = useState('');
22
+ const [loading, setLoading] = useState(false);
23
+ const [sharing, setSharing] = useState(false);
24
+ const [error, setError] = useState<string | null>(null);
25
+
26
+ // Live preview
27
+ const [previewOn, setPreviewOn] = useState(false);
28
+ const [previewUrl, setPreviewUrl] = useState(DEFAULT_PREVIEW_URL);
29
+ const [iframeKey, setIframeKey] = useState(0);
30
+
31
+ // Áudio
32
+ const [voiceOn, setVoiceOn] = useState(false);
33
+ const [recording, setRecording] = useState(false);
34
+
35
+ const videoRef = useRef<HTMLVideoElement | null>(null);
36
+ const streamRef = useRef<MediaStream | null>(null);
37
+ const recorderRef = useRef<MediaRecorder | null>(null);
38
+ const chunksRef = useRef<Blob[]>([]);
39
+
40
+ // ── Screenshare ───────────────────────────────────────────────────────────
41
+ const startShare = async () => {
42
+ setError(null);
43
+ try {
44
+ const stream = await (navigator.mediaDevices as any).getDisplayMedia({ video: true });
45
+ streamRef.current = stream;
46
+ if (videoRef.current) {
47
+ videoRef.current.srcObject = stream;
48
+ await videoRef.current.play().catch(() => undefined);
49
+ }
50
+ stream.getVideoTracks()[0].addEventListener('ended', stopShare);
51
+ setSharing(true);
52
+ } catch {
53
+ setError('Não foi possível iniciar o compartilhamento de tela.');
54
+ }
55
+ };
56
+ const stopShare = () => {
57
+ streamRef.current?.getTracks().forEach((t) => t.stop());
58
+ streamRef.current = null;
59
+ if (videoRef.current) videoRef.current.srcObject = null;
60
+ setSharing(false);
61
+ };
62
+ const captureFrame = (): string | null => {
63
+ const video = videoRef.current;
64
+ if (!sharing || !video || !video.videoWidth || !video.videoHeight) return null;
65
+ const canvas = document.createElement('canvas');
66
+ const maxW = 1280;
67
+ const scale = Math.min(1, maxW / video.videoWidth);
68
+ canvas.width = Math.round(video.videoWidth * scale);
69
+ canvas.height = Math.round(video.videoHeight * scale);
70
+ const ctx = canvas.getContext('2d');
71
+ if (!ctx) return null;
72
+ ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
73
+ return canvas.toDataURL('image/png');
74
+ };
75
+
76
+ // ── TTS playback ──────────────────────────────────────────────────────────
77
+ const playTTS = async (text: string) => {
78
+ try {
79
+ const { data } = await post('/mcp-chat/tts', { text });
80
+ const audio = new Audio(`data:${data.content_type};base64,${data.audio_base64}`);
81
+ await audio.play().catch(() => undefined);
82
+ } catch {
83
+ /* silencioso — voz é opcional */
84
+ }
85
+ };
86
+
87
+ // ── Enviar mensagem (texto vindo do input ou da transcrição) ───────────────
88
+ const sendMessage = async (text: string) => {
89
+ if (!text || loading) return;
90
+ setError(null);
91
+
92
+ const image = captureFrame();
93
+ const next: Msg[] = [...messages, { role: 'user', content: text, image }];
94
+ setMessages(next);
95
+ setLoading(true);
96
+
97
+ try {
98
+ const payload = {
99
+ messages: next.map((m) => ({ role: m.role, content: m.content })),
100
+ image,
101
+ };
102
+ const { data } = await post('/mcp-chat/message', payload);
103
+ const reply = data?.reply || '(sem resposta)';
104
+ setMessages((cur) => [...cur, { role: 'assistant', content: reply }]);
105
+ if (previewOn) setIframeKey((k) => k + 1);
106
+ if (voiceOn) playTTS(reply);
107
+ } catch (e: any) {
108
+ const detail =
109
+ e?.response?.data?.error?.message || e?.message || 'Erro ao falar com a IA.';
110
+ setError(detail);
111
+ } finally {
112
+ setLoading(false);
113
+ }
114
+ };
115
+
116
+ const send = () => {
117
+ const text = input.trim();
118
+ if (!text) return;
119
+ setInput('');
120
+ sendMessage(text);
121
+ };
122
+
123
+ // ── STT: gravar microfone -> transcrever -> enviar ─────────────────────────
124
+ const startRecording = async () => {
125
+ setError(null);
126
+ try {
127
+ const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
128
+ const mimeType = MediaRecorder.isTypeSupported('audio/webm')
129
+ ? 'audio/webm'
130
+ : MediaRecorder.isTypeSupported('audio/mp4')
131
+ ? 'audio/mp4'
132
+ : '';
133
+ const recorder = mimeType ? new MediaRecorder(stream, { mimeType }) : new MediaRecorder(stream);
134
+ chunksRef.current = [];
135
+ recorder.ondataavailable = (e) => {
136
+ if (e.data.size > 0) chunksRef.current.push(e.data);
137
+ };
138
+ recorder.onstop = async () => {
139
+ stream.getTracks().forEach((t) => t.stop());
140
+ const blob = new Blob(chunksRef.current, { type: recorder.mimeType || 'audio/webm' });
141
+ await transcribeAndSend(blob);
142
+ };
143
+ recorderRef.current = recorder;
144
+ recorder.start();
145
+ setRecording(true);
146
+ } catch {
147
+ setError('Não foi possível acessar o microfone.');
148
+ }
149
+ };
150
+ const stopRecording = () => {
151
+ recorderRef.current?.stop();
152
+ setRecording(false);
153
+ };
154
+ const transcribeAndSend = async (blob: Blob) => {
155
+ setLoading(true);
156
+ try {
157
+ const ext = blob.type.includes('mp4') ? 'mp4' : 'webm';
158
+ const form = new FormData();
159
+ form.append('audio', blob, `audio.${ext}`);
160
+ const { data } = await post('/mcp-chat/stt', form);
161
+ const text = (data?.text || '').trim();
162
+ setLoading(false);
163
+ if (text) sendMessage(text);
164
+ else setError('Não consegui entender o áudio.');
165
+ } catch (e: any) {
166
+ setLoading(false);
167
+ setError(e?.response?.data?.error?.message || 'Erro na transcrição.');
168
+ }
169
+ };
170
+
171
+ // ── UI ──────────────────────────────────────────────────────────────────
172
+ const chatPanel = (
173
+ <Flex direction="column" alignItems="stretch" gap={4} height="100%">
174
+ <Flex justifyContent="space-between" alignItems="center">
175
+ <Box>
176
+ <Typography variant="beta" tag="h1">MCP Chat</Typography>
177
+ <Typography variant="pi" textColor="neutral600">
178
+ IA via MCP {sharing ? '• vendo sua tela' : ''} {voiceOn ? '• voz ON' : ''}
179
+ </Typography>
180
+ </Box>
181
+ <Flex gap={1}>
182
+ <Button
183
+ size="S"
184
+ variant={voiceOn ? 'success-light' : 'tertiary'}
185
+ onClick={() => setVoiceOn((v) => !v)}
186
+ >
187
+ {voiceOn ? 'Voz: ON' : 'Voz: OFF'}
188
+ </Button>
189
+ <Button
190
+ size="S"
191
+ variant={sharing ? 'danger-light' : 'secondary'}
192
+ onClick={sharing ? stopShare : startShare}
193
+ >
194
+ {sharing ? 'Parar tela' : 'Compartilhar tela'}
195
+ </Button>
196
+ </Flex>
197
+ </Flex>
198
+
199
+ <Box
200
+ background="neutral0"
201
+ hasRadius
202
+ shadow="tableShadow"
203
+ padding={4}
204
+ grow={1}
205
+ style={{ overflowY: 'auto', minHeight: 240 }}
206
+ >
207
+ {messages.length === 0 && (
208
+ <Typography textColor="neutral500">
209
+ Escreva, fale (🎤) ou compartilhe a tela. Ex.: “Quais content-types existem?”.
210
+ </Typography>
211
+ )}
212
+ <Flex direction="column" alignItems="stretch" gap={3}>
213
+ {messages.map((m, i) => (
214
+ <Box
215
+ key={i}
216
+ padding={3}
217
+ hasRadius
218
+ background={m.role === 'user' ? 'primary100' : 'neutral100'}
219
+ >
220
+ <Typography variant="sigma" textColor={m.role === 'user' ? 'primary600' : 'neutral600'}>
221
+ {m.role === 'user' ? 'Você' : 'IA'}
222
+ </Typography>
223
+ <Box paddingTop={1}>
224
+ <Typography style={{ whiteSpace: 'pre-wrap' }}>{m.content}</Typography>
225
+ </Box>
226
+ {m.image && (
227
+ <Box paddingTop={2}>
228
+ <img
229
+ src={m.image}
230
+ alt="tela compartilhada"
231
+ style={{ maxWidth: '100%', borderRadius: 4, border: '1px solid #ddd' }}
232
+ />
233
+ </Box>
234
+ )}
235
+ </Box>
236
+ ))}
237
+ {loading && <Typography textColor="neutral500">Processando…</Typography>}
238
+ </Flex>
239
+ </Box>
240
+
241
+ {error && (
242
+ <Box background="danger100" padding={3} hasRadius>
243
+ <Typography textColor="danger600">{error}</Typography>
244
+ </Box>
245
+ )}
246
+
247
+ <Flex gap={2} alignItems="flex-end">
248
+ <Button
249
+ variant={recording ? 'danger-light' : 'tertiary'}
250
+ onClick={recording ? stopRecording : startRecording}
251
+ >
252
+ {recording ? '⏹ Parar' : '🎤 Falar'}
253
+ </Button>
254
+ <Box grow={1}>
255
+ <Textarea
256
+ name="message"
257
+ placeholder="Escreva… (Cmd/Ctrl+Enter envia)"
258
+ value={input}
259
+ onChange={(e: any) => setInput(e.target.value)}
260
+ onKeyDown={(e: any) => {
261
+ if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) send();
262
+ }}
263
+ />
264
+ </Box>
265
+ <Button onClick={send} loading={loading} disabled={!input.trim()}>
266
+ Enviar
267
+ </Button>
268
+ </Flex>
269
+ </Flex>
270
+ );
271
+
272
+ const previewPanel = (
273
+ <Flex direction="column" alignItems="stretch" gap={2} height="100%">
274
+ <Flex gap={2} alignItems="center">
275
+ <Box grow={1}>
276
+ <TextInput
277
+ aria-label="URL do preview"
278
+ value={previewUrl}
279
+ onChange={(e: any) => setPreviewUrl(e.target.value)}
280
+ />
281
+ </Box>
282
+ <Button size="S" variant="tertiary" onClick={() => setIframeKey((k) => k + 1)}>
283
+ Recarregar
284
+ </Button>
285
+ </Flex>
286
+ <Box
287
+ background="neutral0"
288
+ hasRadius
289
+ shadow="tableShadow"
290
+ grow={1}
291
+ style={{ overflow: 'hidden', minHeight: 400 }}
292
+ >
293
+ <iframe
294
+ key={iframeKey}
295
+ src={previewUrl}
296
+ title="Live Preview"
297
+ style={{ width: '100%', height: '100%', border: 'none', minHeight: 400 }}
298
+ />
299
+ </Box>
300
+ </Flex>
301
+ );
302
+
303
+ return (
304
+ <Box padding={6} background="neutral100" style={{ minHeight: '100vh' }}>
305
+ <video ref={videoRef} autoPlay muted style={{ display: 'none' }} />
306
+
307
+ <Flex justifyContent="flex-end" gap={2} paddingBottom={4}>
308
+ <Link to="provision">
309
+ <Button variant="secondary">Provisionar frontend</Button>
310
+ </Link>
311
+ <Button
312
+ variant={previewOn ? 'success-light' : 'default'}
313
+ onClick={() => setPreviewOn((v) => !v)}
314
+ >
315
+ {previewOn ? 'Live Preview: ON' : 'Live Preview: OFF'}
316
+ </Button>
317
+ </Flex>
318
+
319
+ {previewOn ? (
320
+ <Flex alignItems="stretch" gap={4} style={{ height: 'calc(100vh - 140px)' }}>
321
+ <Box style={{ flex: '1 1 0', minWidth: 0 }}>{chatPanel}</Box>
322
+ <Box style={{ flex: '1 1 0', minWidth: 0 }}>{previewPanel}</Box>
323
+ </Flex>
324
+ ) : (
325
+ <Box style={{ maxWidth: 900, margin: '0 auto', height: 'calc(100vh - 140px)' }}>
326
+ {chatPanel}
327
+ </Box>
328
+ )}
329
+ </Box>
330
+ );
331
+ };
332
+
333
+ export { HomePage };