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,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 };
|