strapi-plugin-mcp-chat 0.5.0 → 0.6.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.
@@ -197,26 +197,16 @@ export const AdminOverlays = () => {
197
197
  <FloatingChat
198
198
  previewOn={previewOn}
199
199
  previewUrl={liveHref}
200
+ draft={draftPreview}
200
201
  onTogglePreview={() => setPreviewOn((v) => !v)}
201
- onReply={async (didWrite) => {
202
+ onReply={(didWrite) => {
202
203
  if (!previewOn) return;
203
- // Houve edição no Strapi: re-sincroniza o snapshot do frontend para o
204
- // preview refletir (a fonte da verdade é o Strapi). Se não for snapshot
205
- // ou não houver provisão, o integrate é no-op e recarregamos.
206
- if (didWrite) {
207
- try {
208
- setRunError(false);
209
- setRunText('Sincronizando alterações…');
210
- setRunLoading(true);
211
- const { post } = getFetchClient();
212
- await post('/mcp-chat/frontend/integrate', {});
213
- } catch {
214
- /* sem integração: segue só com reload */
215
- } finally {
216
- setRunLoading(false);
217
- }
218
- }
219
- reload();
204
+ // Modelo LIVE-FETCH: a Strapi é a fonte da verdade e o front SEMPRE
205
+ // busca dela. A edição do chat foi gravada na Strapi (no campo
206
+ // exato, via editar_campo) aqui recarregamos o preview para o
207
+ // front re-buscar (em draft quando o toggle de rascunho está ligado).
208
+ // NÃO reescrevemos arquivos do frontend (nada de snapshot/integrate).
209
+ if (didWrite) reload();
220
210
  }}
221
211
  />
222
212
  </>
@@ -13,6 +13,8 @@ type Pos = { x: number; y: number };
13
13
  type Props = {
14
14
  previewOn: boolean;
15
15
  previewUrl: string;
16
+ /** modo do preview: true = rascunho (draft), false = publicado (live). */
17
+ draft?: boolean;
16
18
  onTogglePreview: () => void;
17
19
  /** chamado após uma resposta; didWrite indica que houve edição no Strapi. */
18
20
  onReply: (didWrite: boolean) => void;
@@ -28,6 +30,7 @@ const STR: Record<Lang, Record<string, string>> = {
28
30
  rec: '🎤 Enviar áudio', recStop: '⏹ Parar áudio', recTitle: 'Gravar áudio e enviar (transcreve e manda)',
29
31
  previewOn: '🖼 Preview: ON', previewOff: '🖼 Preview: OFF', previewTitle: 'Abrir/fechar o preview do site ao lado da Strapi',
30
32
  voiceOn: '🔊 Voz: ON', voiceOff: '🔈 Voz: OFF', voiceTitle: 'Ler as respostas em voz alta (TTS)',
33
+ stopVoice: '⏹ Parar voz', stopVoiceTitle: 'Parar a voz que está tocando',
31
34
  pubOn: '🚀 Publicar: ON', pubOff: '📝 Rascunho', pubTitle: 'Auto-publicar OFF = a IA só salva rascunho (você revisa e publica). ON = publica direto no site.',
32
35
  shareOn: '🛑 Parar tela', shareOff: '🖥 Compart. tela', shareTitle: 'Compartilhar a tela com a IA',
33
36
  langTitle: 'Idioma do chat e da voz (PT-BR ↔ English)',
@@ -45,6 +48,7 @@ const STR: Record<Lang, Record<string, string>> = {
45
48
  rec: '🎤 Send audio', recStop: '⏹ Stop audio', recTitle: 'Record audio and send (transcribes and sends)',
46
49
  previewOn: '🖼 Preview: ON', previewOff: '🖼 Preview: OFF', previewTitle: 'Toggle the site preview next to Strapi',
47
50
  voiceOn: '🔊 Voice: ON', voiceOff: '🔈 Voice: OFF', voiceTitle: 'Read replies out loud (TTS)',
51
+ stopVoice: '⏹ Stop voice', stopVoiceTitle: 'Stop the voice currently playing',
48
52
  pubOn: '🚀 Publish: ON', pubOff: '📝 Draft', pubTitle: 'Auto-publish OFF = the AI only saves a draft (you review and publish). ON = publishes straight to the site.',
49
53
  shareOn: '🛑 Stop screen', shareOff: '🖥 Share screen', shareTitle: 'Share your screen with the AI',
50
54
  langTitle: 'Chat and voice language (PT-BR ↔ English)',
@@ -59,7 +63,7 @@ const STR: Record<Lang, Record<string, string>> = {
59
63
  },
60
64
  };
61
65
 
62
- export const FloatingChat = ({ previewOn, previewUrl, onTogglePreview, onReply }: Props) => {
66
+ export const FloatingChat = ({ previewOn, previewUrl, draft = false, onTogglePreview, onReply }: Props) => {
63
67
  const [open, setOpen] = useState(false);
64
68
  const [pos, setPos] = useState<Pos | null>(null); // null = ancorado à direita
65
69
  const [messages, setMessages] = useState<Msg[]>([]);
@@ -67,6 +71,7 @@ export const FloatingChat = ({ previewOn, previewUrl, onTogglePreview, onReply }
67
71
  const [loading, setLoading] = useState(false);
68
72
  const [sharing, setSharing] = useState(false);
69
73
  const [voiceOn, setVoiceOn] = useState(false);
74
+ const [speaking, setSpeaking] = useState(false);
70
75
  const [recording, setRecording] = useState(false);
71
76
  const [autoPublish, setAutoPublish] = useState<boolean>(() => {
72
77
  try { return localStorage.getItem('mcp-chat-autopublish') === '1'; } catch { return false; }
@@ -87,6 +92,7 @@ export const FloatingChat = ({ previewOn, previewUrl, onTogglePreview, onReply }
87
92
  const chunksRef = useRef<Blob[]>([]);
88
93
  const bodyRef = useRef<HTMLDivElement | null>(null);
89
94
  const dragRef = useRef<{ dx: number; dy: number } | null>(null);
95
+ const audioRef = useRef<HTMLAudioElement | null>(null);
90
96
 
91
97
  // ── Drag ────────────────────────────────────────────────────────────────
92
98
  const onHeaderDown = (e: React.MouseEvent) => {
@@ -121,6 +127,38 @@ export const FloatingChat = ({ previewOn, previewUrl, onTogglePreview, onReply }
121
127
  };
122
128
  }, []);
123
129
 
130
+ // ── Logout: limpa a conversa e esconde o chat ──────────────────────────────
131
+ // O overlay vive num React root próprio (fora da árvore do admin), então não dá
132
+ // pra usar useAuth. Detectamos a sessão pela ROTA: a tela de login/logout do
133
+ // Strapi é /admin/auth/... Ao cair nela (logout), zeramos a conversa, encerramos
134
+ // áudio/gravação/compartilhamento e escondemos o FAB — o chat só existe logado.
135
+ const [loggedOut, setLoggedOut] = useState(() => /\/auth(\/|$)/.test(window.location.pathname));
136
+ useEffect(() => {
137
+ const check = () => {
138
+ const out = /\/auth(\/|$)/.test(window.location.pathname);
139
+ setLoggedOut((prev) => {
140
+ if (out && !prev) {
141
+ // acabou de deslogar → limpa tudo que é da sessão.
142
+ setMessages([]);
143
+ setInput('');
144
+ setError(null);
145
+ setOpen(false);
146
+ stopTTS();
147
+ try { streamRef.current?.getTracks().forEach((t) => t.stop()); } catch { /* noop */ }
148
+ try { recorderRef.current?.stop(); } catch { /* noop */ }
149
+ setSharing(false);
150
+ setRecording(false);
151
+ }
152
+ return out;
153
+ });
154
+ };
155
+ check();
156
+ const id = window.setInterval(check, 1000);
157
+ window.addEventListener('popstate', check);
158
+ return () => { window.clearInterval(id); window.removeEventListener('popstate', check); };
159
+ // eslint-disable-next-line react-hooks/exhaustive-deps
160
+ }, []);
161
+
124
162
  // ── Screenshare ───────────────────────────────────────────────────────────
125
163
  const startShare = async () => {
126
164
  setError(null);
@@ -159,15 +197,34 @@ export const FloatingChat = ({ previewOn, previewUrl, onTogglePreview, onReply }
159
197
  };
160
198
 
161
199
  // ── TTS ────────────────────────────────────────────────────────────────────
200
+ // Para qualquer áudio em reprodução (botão ⏹ ou ao iniciar uma nova fala).
201
+ const stopTTS = () => {
202
+ const a = audioRef.current;
203
+ if (a) {
204
+ try { a.pause(); a.currentTime = 0; } catch { /* noop */ }
205
+ audioRef.current = null;
206
+ }
207
+ setSpeaking(false);
208
+ };
209
+
162
210
  const playTTS = async (text: string) => {
163
211
  try {
212
+ stopTTS(); // não sobrepõe falas
164
213
  const { post } = getFetchClient();
165
214
  const { data } = await post('/mcp-chat/tts', { text });
166
215
  const audio = new Audio(`data:${data.content_type};base64,${data.audio_base64}`);
167
- await audio.play().catch(() => undefined);
168
- } catch { /* voz é opcional */ }
216
+ audioRef.current = audio;
217
+ const done = () => { if (audioRef.current === audio) audioRef.current = null; setSpeaking(false); };
218
+ audio.addEventListener('ended', done);
219
+ audio.addEventListener('error', done);
220
+ setSpeaking(true);
221
+ await audio.play().catch(() => done());
222
+ } catch { setSpeaking(false); /* voz é opcional */ }
169
223
  };
170
224
 
225
+ // para a voz se o componente desmontar.
226
+ useEffect(() => () => stopTTS(), []);
227
+
171
228
  // ── Enviar ──────────────────────────────────────────────────────────────────
172
229
  const sendMessage = async (text: string) => {
173
230
  if (!text || loading) return;
@@ -185,6 +242,8 @@ export const FloatingChat = ({ previewOn, previewUrl, onTogglePreview, onReply }
185
242
  // Página que o usuário está olhando no preview (se aberto). Dá à IA o
186
243
  // contexto do "isso aqui" sem precisar varrer o site inteiro.
187
244
  previewUrl: previewOn ? previewUrl : null,
245
+ // A busca de texto segue o modo do preview: draft -> rascunhos, live -> publicado.
246
+ previewStatus: draft ? 'draft' : 'published',
188
247
  // Draft-first: por padrão a IA só salva rascunho; só publica com isto ON.
189
248
  autoPublish,
190
249
  });
@@ -262,6 +321,9 @@ export const FloatingChat = ({ previewOn, previewUrl, onTogglePreview, onReply }
262
321
  fontSize: 12, cursor: 'pointer', whiteSpace: 'nowrap',
263
322
  });
264
323
 
324
+ // deslogado: o chat não existe (já foi limpo no efeito acima).
325
+ if (loggedOut) return null;
326
+
265
327
  if (!open) {
266
328
  return (
267
329
  <button
@@ -337,6 +399,11 @@ export const FloatingChat = ({ previewOn, previewUrl, onTogglePreview, onReply }
337
399
  title={t.voiceTitle}>
338
400
  {voiceOn ? t.voiceOn : t.voiceOff}
339
401
  </button>
402
+ {speaking && (
403
+ <button style={btn(true)} onClick={stopTTS} title={t.stopVoiceTitle}>
404
+ {t.stopVoice}
405
+ </button>
406
+ )}
340
407
  <button style={btn(autoPublish)} onClick={() => setAutoPublish((v) => !v)}
341
408
  title={t.pubTitle}>
342
409
  {autoPublish ? t.pubOn : t.pubOff}