mememechez's picture
Deploy final cleaned source code
ca28016
raw
history blame
28.5 kB
"use client";
import { Button } from '@/components/ui/button';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Textarea } from '@/components/ui/textarea';
import { Bot, Paperclip, SendHorizonal, MessageSquarePlus } from 'lucide-react';
import React, { useEffect, useRef, useState } from 'react';
import { ChatMessage, LoadingMessage, ImageLoadingMessage } from './chat-message';
import { useIsMobile } from '@/hooks/use-mobile';
import type { Message } from '@/app/page';
type ChatPanelProps = {
messages: Message[];
onSendMessage: (input: string, temperature: number, file: File | null) => Promise<void>;
isLoading: boolean;
isChatSelected: boolean;
onNewChat: () => void;
onConsciousnessDimensionSelect: (dimension: string) => void;
selectedConsciousnessDimension: string;
temperature: number;
isWelcomeMode?: boolean;
consciousnessColor?: string;
onImageGeneratingChange?: (isGenerating: boolean) => void;
isImageGenerating?: boolean;
onOpenEditor?: (b64: string, prompt?: string) => void;
imageProgress?: number;
imageElapsed?: number;
imageStatus?: string;
};
export function ChatPanel({
messages,
onSendMessage,
isLoading,
isChatSelected,
onNewChat,
onConsciousnessDimensionSelect,
selectedConsciousnessDimension,
temperature,
isWelcomeMode = false,
consciousnessColor = 'text-blue-600',
onImageGeneratingChange,
isImageGenerating,
onOpenEditor,
imageProgress = 0,
imageElapsed = 0,
imageStatus = 'starting',
}: ChatPanelProps) {
const isMobile = useIsMobile();
const [input, setInput] = useState('');
const [file, setFile] = useState<File | null>(null);
const [imageMode, setImageMode] = useState<boolean>(false);
const [safeMode, setSafeMode] = useState<boolean>(true);
const scrollAreaRef = useRef<HTMLDivElement>(null);
const messagesEndRef = useRef<HTMLDivElement>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
// Compute backend/proxy base. Always use same-origin Next API proxy in the browser
// to avoid CORS/mixed-origin issues (e.g., when dev host is 127.0.0.2).
// Fallback to direct backend only during SSR where window is not available.
function getApiUrl(path: string): string {
const p = path.startsWith('/api') ? path : `/api${path}`;
if (typeof window !== 'undefined') {
return p;
}
const direct = process.env.NEXT_PUBLIC_BACKEND_URL || '';
return direct ? `${direct}${path}` : p;
}
// Compute backend base with runtime fallback when env is missing
function getBackendBase(): string {
const fromEnv = process.env.NEXT_PUBLIC_BACKEND_URL || '';
if (fromEnv) return fromEnv;
if (typeof window !== 'undefined') {
if (window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1') {
return 'http://localhost:5000';
}
}
return '';
}
// Speech mode state
const [speechMode, setSpeechMode] = useState<boolean>(false);
const [isRecording, setIsRecording] = useState<boolean>(false);
const mediaStreamRef = useRef<MediaStream | null>(null);
const audioCtxRef = useRef<AudioContext | null>(null);
const sourceRef = useRef<MediaStreamAudioSourceNode | null>(null);
const processorRef = useRef<ScriptProcessorNode | null>(null);
const analyserRef = useRef<AnalyserNode | null>(null);
const pcmChunksRef = useRef<Float32Array[]>([]);
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
const recChunksRef = useRef<BlobPart[]>([]);
const levelsRef = useRef<number[]>([0,0,0,0,0]);
const [, forceRender] = useState(0); // force rerender for animation
const lastActiveRef = useRef<number>(Date.now());
const startedAtRef = useRef<number>(Date.now());
const SILENCE_MS = 1500;
const MIN_CAPTURE_MS = 800;
const SILENCE_THRESHOLD = 0.008; // less aggressive
// Track last-spoken assistant message to avoid repeated TTS
const lastSpokenIdRef = useRef<string | null>(null);
const ttsAudioRef = useRef<HTMLAudioElement | null>(null);
// Auto-scroll to bottom when messages change
useEffect(() => {
const scrollToBottom = () => {
if (messagesEndRef.current) {
messagesEndRef.current.scrollIntoView({ behavior: 'smooth' });
}
};
// Scroll immediately
scrollToBottom();
// Also scroll after a short delay to handle any dynamic content loading
const timeoutId = setTimeout(scrollToBottom, 100);
return () => clearTimeout(timeoutId);
}, [messages, isLoading]);
// Focus textarea when not loading
useEffect(() => {
if (!isLoading && textareaRef.current) {
textareaRef.current.focus();
}
}, [isLoading]);
// Animated waveform (5 bars) + silence detection
useEffect(() => {
let rafId: number;
const tick = () => {
if (isRecording && analyserRef.current) {
const analyser = analyserRef.current;
const arr = new Uint8Array(analyser.fftSize);
analyser.getByteTimeDomainData(arr);
// RMS amplitude
let sum = 0;
for (let i = 0; i < arr.length; i++) {
const v = (arr[i] - 128) / 128;
sum += v * v;
}
const rms = Math.sqrt(sum / arr.length);
const level = Math.min(1, rms * 4);
const prev = levelsRef.current.slice();
prev.shift();
prev.push(level);
levelsRef.current = prev;
forceRender((x) => x + 1);
// Silence detection
if (rms > SILENCE_THRESHOLD) {
lastActiveRef.current = Date.now();
} else if (
Date.now() - lastActiveRef.current > SILENCE_MS &&
Date.now() - startedAtRef.current > MIN_CAPTURE_MS
) {
// Auto stop after 1.5s silence
stopRecording();
}
}
rafId = requestAnimationFrame(tick);
};
rafId = requestAnimationFrame(tick);
return () => cancelAnimationFrame(rafId);
}, [isRecording]);
const startRecording = async () => {
try {
const stream = await navigator.mediaDevices.getUserMedia({
audio: {
echoCancellation: true,
noiseSuppression: true,
autoGainControl: true,
channelCount: 1,
} as any,
} as any);
mediaStreamRef.current = stream;
const audioCtx = new (window.AudioContext || (window as any).webkitAudioContext)();
audioCtxRef.current = audioCtx;
if (audioCtx.state === 'suspended') {
try { await audioCtx.resume(); } catch {}
}
const source = audioCtx.createMediaStreamSource(stream);
sourceRef.current = source;
const analyser = audioCtx.createAnalyser();
analyser.fftSize = 1024;
analyserRef.current = analyser;
// MediaRecorder fallback (WebM/Opus)
try {
const mr = new MediaRecorder(stream, { mimeType: 'audio/webm;codecs=opus' } as any);
mediaRecorderRef.current = mr;
recChunksRef.current = [];
mr.ondataavailable = (ev) => {
if (ev.data && ev.data.size > 0) recChunksRef.current.push(ev.data);
};
mr.start(250); // gather in 250ms chunks
} catch {}
// Sink to keep processing running while staying silent
const silentGain = audioCtx.createGain();
silentGain.gain.value = 0.0;
pcmChunksRef.current = [];
const now = Date.now();
lastActiveRef.current = now;
startedAtRef.current = now;
const processor = audioCtx.createScriptProcessor(4096, 1, 1);
processorRef.current = processor;
processor.onaudioprocess = (e: AudioProcessingEvent) => {
if (!isRecording) return;
const input = e.inputBuffer.getChannelData(0);
// Copy the buffer since input is a live view
const buf = new Float32Array(input.length);
buf.set(input);
pcmChunksRef.current.push(buf);
};
// Connect graph: source -> analyser (for levels) and -> processor (for capture)
source.connect(analyser);
source.connect(processor);
processor.connect(silentGain);
silentGain.connect(audioCtx.destination);
setIsRecording(true);
} catch (err) {
console.error('Mic access failed:', err);
setSpeechMode(false);
}
};
const stopRecording = async () => {
if (!isRecording) return;
setIsRecording(false);
// stop tracks
mediaStreamRef.current?.getTracks().forEach((t) => t.stop());
mediaStreamRef.current = null;
// disconnect audio graph
try {
processorRef.current?.disconnect();
analyserRef.current?.disconnect();
sourceRef.current?.disconnect();
if (mediaRecorderRef.current && mediaRecorderRef.current.state !== 'inactive') {
mediaRecorderRef.current.stop();
}
await audioCtxRef.current?.close();
} catch {}
const sampleRate = audioCtxRef.current?.sampleRate || 48000;
processorRef.current = null;
analyserRef.current = null;
sourceRef.current = null;
audioCtxRef.current = null;
// Build mono WAV from Float32 chunks using native sample rate
const floatData = concatFloat32(pcmChunksRef.current);
let wavBlob: Blob;
if (!floatData || floatData.length === 0) {
// Fallback: decode MediaRecorder chunks to PCM via AudioContext
try {
const webmBlob = new Blob(recChunksRef.current, { type: 'audio/webm' });
recChunksRef.current = [];
const arr = await webmBlob.arrayBuffer();
const decodeCtx = new (window.AudioContext || (window as any).webkitAudioContext)();
const audioBuf = await decodeCtx.decodeAudioData(arr);
const ch0 = audioBuf.getChannelData(0);
const copy = new Float32Array(ch0.length);
copy.set(ch0);
wavBlob = encodeWAV(copy, 1, audioBuf.sampleRate || sampleRate);
try { await decodeCtx.close(); } catch {}
} catch (e) {
console.warn('No audio captured (empty buffer) - aborting ASR request');
return;
}
} else {
wavBlob = encodeWAV(floatData, 1, sampleRate);
}
pcmChunksRef.current = [];
mediaRecorderRef.current = null;
// Send to ASR backend
const base64 = await blobToBase64(wavBlob);
try {
const res = await fetch(getApiUrl('/asr/transcribe'), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ audio_base64: base64, language: 'en', vad: false, beam_size: 5 }),
});
const ct = res.headers.get('content-type') || '';
const payload = ct.includes('application/json') ? await res.json() : { success: false, error: `${res.status} ${await res.text()}` };
if (payload?.success) {
const primary = (payload?.text ?? '').toString();
const fallback = Array.isArray(payload?.segments) ? payload.segments.map((s: any) => s?.text || '').join(' ') : '';
const text = (primary || fallback).trim();
if (text.length > 0) {
setInput(text);
// Auto-send when in speech mode
if (speechMode) {
const messageToSend = imageMode
? `[[IMAGE_MODE]] ${text}${safeMode ? ' [[SAFE_MODE=ON]]' : ' [[SAFE_MODE=OFF]]'}`
: text;
await onSendMessage(messageToSend, temperature, file);
setInput('');
setFile(null);
if (fileInputRef.current) fileInputRef.current.value = '';
}
} else {
console.warn('ASR returned empty text', payload);
}
} else {
console.warn('ASR failed:', payload?.error || `status ${res.status}`);
}
} catch (e) {
console.error('ASR request failed:', e);
}
};
const toggleSpeech = async () => {
const next = !speechMode;
setSpeechMode(next);
if (next) {
await startRecording();
} else if (isRecording) {
await stopRecording();
}
};
// Auto TTS of assistant replies in speech mode
useEffect(() => {
if (!speechMode) return;
if (!messages || messages.length === 0) return;
const last = messages[messages.length - 1];
if (last.role !== 'assistant' || !last.content?.trim()) return;
// Compose a simple id from content + index
const msgId = `${messages.length}-${last.content.length}`;
if (lastSpokenIdRef.current === msgId) return;
lastSpokenIdRef.current = msgId;
const runTts = async () => {
try {
const res = await fetch(getApiUrl('/tts/synthesize'), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text: last.content }),
});
const ct = res.headers.get('content-type') || '';
const payload = ct.includes('application/json') ? await res.json() : { success: false, error: `${res.status} ${await res.text()}` };
if (payload?.success && payload?.audio_base64_wav) {
const audio = new Audio(`data:audio/wav;base64,${payload.audio_base64_wav}`);
ttsAudioRef.current?.pause();
ttsAudioRef.current = audio;
audio.play().catch(() => {});
} else {
console.warn('TTS failed:', payload?.error || `status ${res.status}`);
}
} catch (e) {
console.error('TTS request failed:', e);
}
};
runTts();
}, [messages, speechMode]);
// Helpers: concat, WAV encode, base64
function concatFloat32(chunks: Float32Array[]): Float32Array {
let length = 0;
for (const c of chunks) length += c.length;
const out = new Float32Array(length);
let o = 0;
for (const c of chunks) { out.set(c, o); o += c.length; }
return out;
}
function encodeWAV(samples: Float32Array, numChannels: number, sampleRate: number): Blob {
// Convert float to 16-bit PCM
const pcm = new Int16Array(samples.length);
for (let i = 0; i < samples.length; i++) {
let s = Math.max(-1, Math.min(1, samples[i]));
pcm[i] = s < 0 ? s * 0x8000 : s * 0x7fff;
}
const byteRate = (sampleRate * numChannels * 16) / 8;
const blockAlign = (numChannels * 16) / 8;
const buffer = new ArrayBuffer(44 + pcm.byteLength);
const view = new DataView(buffer);
let offset = 0;
// RIFF header
writeString(view, offset, 'RIFF'); offset += 4;
view.setUint32(offset, 36 + pcm.byteLength, true); offset += 4;
writeString(view, offset, 'WAVE'); offset += 4;
writeString(view, offset, 'fmt '); offset += 4;
view.setUint32(offset, 16, true); offset += 4; // fmt chunk size
view.setUint16(offset, 1, true); offset += 2; // PCM
view.setUint16(offset, numChannels, true); offset += 2;
view.setUint32(offset, sampleRate, true); offset += 4;
view.setUint32(offset, byteRate, true); offset += 4;
view.setUint16(offset, blockAlign, true); offset += 2;
view.setUint16(offset, 16, true); offset += 2;
writeString(view, offset, 'data'); offset += 4;
view.setUint32(offset, pcm.byteLength, true); offset += 4;
// PCM data
const pcmView = new DataView(buffer, 44);
for (let i = 0; i < pcm.length; i++) {
pcmView.setInt16(i * 2, pcm[i], true);
}
return new Blob([buffer], { type: 'audio/wav' });
}
function writeString(view: DataView, offset: number, str: string) {
for (let i = 0; i < str.length; i++) view.setUint8(offset + i, str.charCodeAt(i));
}
function blobToBase64(blob: Blob): Promise<string> {
return new Promise((resolve) => {
const reader = new FileReader();
reader.onloadend = () => {
const res = (reader.result as string) || '';
const base64 = res.split(',')[1] || '';
resolve(base64);
};
reader.readAsDataURL(blob);
});
}
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const selectedFile = e.target.files?.[0];
if (selectedFile) {
setFile(selectedFile);
}
};
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
if (!input.trim() || isLoading) return;
// Only attach IMAGE_MODE when the toggle is ON. No auto intent here.
const messageToSend = imageMode
? `[[IMAGE_MODE]] ${input}${safeMode ? ' [[SAFE_MODE=ON]]' : ' [[SAFE_MODE=OFF]]'}`
: input;
// Notify image generation start/end to parent
if (imageMode) onImageGeneratingChange?.(true);
await onSendMessage(messageToSend, temperature, file);
if (imageMode) onImageGeneratingChange?.(false);
setInput('');
setFile(null);
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
};
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSubmit(e as any);
}
};
const removeFile = () => {
setFile(null);
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
};
if (isWelcomeMode) {
// Welcome mode - just show the input form
return (
<div className="space-y-4">
{/* File attachment display */}
{file && (
<div className="flex items-center gap-2 p-2 bg-muted rounded-md">
<Paperclip className="h-4 w-4" />
<span className="text-sm flex-1 truncate">{file.name}</span>
<Button
size="sm"
variant="ghost"
onClick={removeFile}
className="h-6 w-6 p-0"
>
×
</Button>
</div>
)}
{/* Input form */}
<form onSubmit={handleSubmit} className="space-y-2">
<div className="relative flex gap-2 items-stretch">
{/* Mic/Wave button (opposite side of send) */}
<Button
type="button"
variant={speechMode ? 'default' : 'outline'}
size="sm"
onClick={toggleSpeech}
disabled={isLoading}
className={`shrink-0 h-auto min-h-[60px] ${speechMode ? 'bg-blue-600 text-white' : ''}`}
title={speechMode ? (isRecording ? 'Stop & Transcribe' : 'Speech Mode On') : 'Enable Speech Mode'}
>
{speechMode ? (
<div className="flex items-center gap-2">
<span className="text-xs">{isRecording ? 'Listening' : 'Ready'}</span>
<div className="flex items-end gap-[2px]">
{levelsRef.current.map((lvl, i) => (
<div key={i} className="w-[3px] bg-current" style={{ height: `${Math.max(8, 8 + lvl * 20)}px`, transition: 'height 80ms linear' }} />
))}
</div>
</div>
) : (
<span className="text-xs">Speech</span>
)}
</Button>
<input
ref={fileInputRef}
type="file"
onChange={handleFileChange}
className="hidden"
accept=".txt,.md,.json,.py,.js,.ts,.tsx,.jsx,.csv,.xml,.html,.css"
/>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => fileInputRef.current?.click()}
disabled={isLoading}
className="shrink-0 h-auto min-h-[60px]"
>
<Paperclip className="h-4 w-4" />
</Button>
<div className="relative flex-1">
<Textarea
ref={textareaRef}
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Type your message or use Speech..."
className="min-h-[60px] max-h-[200px] resize-none pr-20"
disabled={isLoading}
/>
{/* Generate Image bullet toggle (restored) */}
<button
type="button"
aria-pressed={imageMode}
onClick={() => setImageMode(v => !v)}
className={`absolute bottom-2 right-12 h-8 w-8 rounded-full border ${imageMode ? 'bg-emerald-500 text-white border-emerald-600' : 'bg-transparent text-foreground/80 border-border'} shadow-sm backdrop-blur-md`}
title="Generate Image"
>
</button>
<Button
type="submit"
size="sm"
className="absolute bottom-2 right-2 h-8 w-8 p-0"
disabled={!input.trim() || isLoading}
>
<SendHorizonal className="h-4 w-4" />
</Button>
</div>
</div>
<div className="flex justify-center">
<div className="text-xs text-muted-foreground">
Consciousness: <span className="font-medium capitalize">{selectedConsciousnessDimension}</span>
</div>
</div>
</form>
</div>
);
}
// Regular chat mode - full height with fixed input at bottom
return (
<div className={`h-full flex flex-col ${isMobile ? 'mobile-chat-container' : ''}`}>
{/* Chat Messages Area - Takes remaining space */}
<div className="flex-1 min-h-0 overflow-hidden">
<ScrollArea ref={scrollAreaRef} className={`h-full auto-scroll ${isMobile ? 'mobile-scroll-fix' : ''}`}>
<div className="flex flex-col gap-4 p-4 pb-8">
{messages.length === 0 && (
<div className="flex flex-col items-center justify-center h-full text-center py-12">
<Bot className="h-12 w-12 text-muted-foreground mb-4" />
<h3 className="text-lg font-medium mb-2">Welcome to Aether AI™</h3>
<p className="text-muted-foreground max-w-md mb-4">
Start a conversation with our 5D consciousness AI. Use the settings buttons above to configure your experience.
</p>
{!isChatSelected && (
<Button onClick={onNewChat} variant="default">
<MessageSquarePlus className="h-4 w-4 mr-2" />
Start New Chat
</Button>
)}
</div>
)}
{messages.map((message, index) => (
<ChatMessage
key={index}
message={message}
onConsciousnessDimensionSelect={onConsciousnessDimensionSelect}
selectedConsciousnessDimension={selectedConsciousnessDimension}
onOpenEditor={onOpenEditor}
/>
))}
{isLoading && !isImageGenerating && <LoadingMessage />}
{isImageGenerating && <ImageLoadingMessage progress={imageProgress} elapsedSeconds={imageElapsed} status={imageStatus} />}
{/* Invisible element to scroll to */}
<div ref={messagesEndRef} />
</div>
</ScrollArea>
</div>
{/* Chat Input Area */}
<div className={`p-4 flex-shrink-0 ${isMobile ? 'mobile-input-area' : ''}`}>
{/* File attachment display */}
{file && (
<div className="flex items-center gap-2 p-2 mb-2 bg-muted rounded-md mx-auto max-w-2xl">
<Paperclip className="h-4 w-4" />
<span className="text-sm flex-1 truncate">{file.name}</span>
<Button
size="sm"
variant="ghost"
onClick={removeFile}
className="h-6 w-6 p-0"
>
×
</Button>
</div>
)}
{/* Input form */}
<form onSubmit={handleSubmit} className="max-w-2xl mx-auto">
<div className="relative flex gap-2 items-stretch">
{/* Mic/Wave button (opposite side of send) */}
<Button
type="button"
variant={speechMode ? 'default' : 'outline'}
size="sm"
onClick={toggleSpeech}
disabled={isLoading}
className={`shrink-0 h-auto min-h-[60px] ${speechMode ? 'bg-blue-600 text-white' : ''}`}
title={speechMode ? (isRecording ? 'Stop & Transcribe' : 'Speech Mode On') : 'Enable Speech Mode'}
>
{speechMode ? (
<div className="flex items-center gap-2">
<span className="text-xs">{isRecording ? 'Listening' : 'Ready'}</span>
<div className="flex items-end gap-[2px]">
{levelsRef.current.map((lvl, i) => (
<div key={i} className="w-[3px] bg-current" style={{ height: `${Math.max(8, 8 + lvl * 20)}px`, transition: 'height 80ms linear' }} />
))}
</div>
</div>
) : (
<span className="text-xs">Speech</span>
)}
</Button>
<input
ref={fileInputRef}
type="file"
onChange={handleFileChange}
className="hidden"
accept=".txt,.md,.json,.py,.js,.ts,.tsx,.jsx,.csv,.xml,.html,.css"
/>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => fileInputRef.current?.click()}
disabled={isLoading}
className="shrink-0 h-auto min-h-[60px]"
>
<Paperclip className="h-4 w-4" />
</Button>
<div className="relative flex-1">
<Textarea
ref={textareaRef}
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={speechMode ? (isRecording ? 'Listening...' : 'Tap Speech to speak, or type here...') : 'Type your message...'}
className="min-h-[60px] max-h-[200px] resize-none pr-20"
disabled={isLoading}
/>
{/* Generate Image bullet toggle (restored) */}
<button
type="button"
aria-pressed={imageMode}
onClick={() => setImageMode(v => !v)}
className={`absolute bottom-2 right-12 h-8 w-8 rounded-full border ${imageMode ? 'bg-emerald-500 text-white border-emerald-600' : 'bg-transparent text-foreground/80 border-border'} shadow-sm backdrop-blur-md`}
title="Generate Image"
>
</button>
<Button
type="submit"
size="sm"
className="absolute bottom-2 right-2 h-8 w-8 p-0"
disabled={!input.trim() || isLoading}
>
<SendHorizonal className="h-4 w-4" />
</Button>
</div>
</div>
{/* Status row with Image Mode and Safe toggle remains */}
<div className="flex justify-between items-center mt-2">
<div className="text-xs text-muted-foreground">
Consciousness: <span className={`font-medium capitalize ${consciousnessColor}`}>{selectedConsciousnessDimension}</span>
</div>
<div className="text-xs text-muted-foreground flex items-center gap-4">
<div className="flex items-center gap-1">
<span>Image mode:</span>
<span className={`font-medium ${imageMode ? 'text-emerald-500' : 'text-muted-foreground'}`}>{imageMode ? 'On' : 'Off'}</span>
</div>
{imageMode && (
<div className="flex items-center gap-2">
<span>Safe:</span>
<button
type="button"
aria-pressed={safeMode}
onClick={() => setSafeMode(v => !v)}
className={`relative inline-flex h-5 w-10 items-center rounded-full transition-colors duration-200 ${safeMode ? 'bg-blue-600' : 'bg-gray-500/40'}`}
title="Toggle safety"
>
<span className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform duration-200 ${safeMode ? 'translate-x-5' : 'translate-x-1'}`}/>
</button>
</div>
)}
</div>
</div>
</form>
</div>
</div>
);
}