HomePilot / frontend /src /ui /voice /SettingsModal.jsx
HomePilot Deploy Bot
chore(hf): sync HomePilot to HF Space
921d377
Raw
History Blame Contribute Delete
18.1 kB
/**
* Settings Modal Component
*
* System settings modal for HomePilot Voice UI.
* Contains advanced audio settings and content preferences.
*
* Features:
* - Audio meter toggle
* - Browser voice selection
* - Language settings
* - Adult content gating (18+)
*/
import React, { useState, useEffect } from 'react';
import { X, Activity, Volume2, Globe, Settings, Shield, AlertTriangle, Users, Link2, } from 'lucide-react';
import { isAdultContentEnabled, isAgeConfirmed, setAdultContentEnabled, setAgeConfirmed, isPersonasEnabled, setPersonasEnabled, isVoiceLinkedToProject, setVoiceLinkedToProject, LS_PERSONA_CACHE, } from './personalityGating';
import TtsEngineSection from '../components/TtsEngineSection';
// Side-effect import: ensures the TTS plugin registry is populated the
// first time the System Settings modal mounts — same registry the
// Enterprise Settings voice block already uses, so both UIs are in
// sync automatically (single active-engine pointer in localStorage,
// scoped per user).
import '../tts';
import { getActiveTtsEngineId, onActiveTtsEngineChange, } from '../tts';
export default function SettingsModal({ isOpen, onClose, showAudioMeter, setShowAudioMeter, browserVoices, selectedBrowserVoice, setSelectedBrowserVoice, }) {
// Adult content state
const [adultEnabled, setAdultEnabled] = useState(false);
const [ageConfirmed, setAgeConfirmedState] = useState(false);
const [showAgeConfirmation, setShowAgeConfirmation] = useState(false);
// Personas in Voice state
const [personasEnabled, setPersonasEnabledState] = useState(false);
// Linked-to-project state
const [linkedToProject, setLinkedToProjectState] = useState(false);
// Track active TTS engine so we can hide the legacy "System Voice"
// dropdown when the user picks a non-default engine (Piper etc.) —
// prevents the confusing two-voice-pickers state.
const [ttsEngineId, setTtsEngineId] = useState(() => getActiveTtsEngineId());
useEffect(() => onActiveTtsEngineChange(setTtsEngineId), []);
// Load settings on mount
useEffect(() => {
setAdultEnabled(isAdultContentEnabled());
setAgeConfirmedState(isAgeConfirmed());
setPersonasEnabledState(isPersonasEnabled());
setLinkedToProjectState(isVoiceLinkedToProject());
}, [isOpen]);
// Handle personas toggle
const handlePersonasToggle = () => {
const next = !personasEnabled;
setPersonasEnabled(next);
setPersonasEnabledState(next);
if (!next) {
// Clear persona cache, unlink, and reset personality if it was a persona
localStorage.removeItem(LS_PERSONA_CACHE);
const pid = localStorage.getItem('homepilot_personality_id');
if (pid?.startsWith('persona:')) {
localStorage.removeItem('homepilot_personality_id');
}
setVoiceLinkedToProject(false);
setLinkedToProjectState(false);
}
};
// Handle linked-to-project toggle
const handleLinkedToggle = () => {
const next = !linkedToProject;
setVoiceLinkedToProject(next);
setLinkedToProjectState(next);
};
// Handle adult content toggle
const handleAdultToggle = () => {
if (!adultEnabled) {
// Trying to enable - check age confirmation
if (ageConfirmed) {
setAdultContentEnabled(true);
setAdultEnabled(true);
}
else {
// Show age confirmation dialog
setShowAgeConfirmation(true);
}
}
else {
// Disabling
setAdultContentEnabled(false);
setAdultEnabled(false);
}
};
// Handle age confirmation
const handleAgeConfirm = () => {
setAgeConfirmed(true);
setAgeConfirmedState(true);
setAdultContentEnabled(true);
setAdultEnabled(true);
setShowAgeConfirmation(false);
};
if (!isOpen)
return null;
const toggleOn = 'bg-white/15 border border-white/20';
const toggleOff = 'bg-[#1F1F1F] border border-transparent';
// Filter English voices for cleaner display
const englishVoices = browserVoices?.filter((v) => v.lang.startsWith('en')) || [];
// Group voices by language
const voicesByLang = browserVoices?.reduce((acc, voice) => {
const lang = voice.lang.split('-')[0];
if (!acc[lang])
acc[lang] = [];
acc[lang].push(voice);
return acc;
}, {}) || {};
return (<div className="fixed inset-0 bg-black/80 backdrop-blur-sm z-[100] flex items-center justify-center p-4 hp-fade-in">
<div className="w-full max-w-[480px] bg-[#121212] border border-white/10 rounded-[24px] shadow-2xl overflow-hidden">
{/* Header */}
<div className="flex items-center justify-between p-5 border-b border-white/10">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-full bg-white/10 flex items-center justify-center">
<Settings size={20} className="text-white/70"/>
</div>
<div>
<h2 className="text-lg font-bold text-white">System Settings</h2>
<p className="text-[11px] text-white/40">HomePilot Voice Configuration</p>
</div>
</div>
<button onClick={onClose} className="w-9 h-9 rounded-full bg-white/5 border border-white/10 flex items-center justify-center text-white/60 hover:bg-white/10 hover:text-white transition-colors">
<X size={18}/>
</button>
</div>
{/* Content */}
<div className="p-5 space-y-6 max-h-[60vh] overflow-y-auto hp-scroll">
{/* Audio Settings Section */}
<div className="space-y-4">
<div className="flex items-center gap-2 text-sm font-semibold text-white/80">
<Activity size={16} className="text-white/50"/>
<span>Audio Settings</span>
</div>
{/* Audio Level Meter Toggle */}
{setShowAudioMeter && (<div className="flex items-center justify-between p-3 rounded-[12px] bg-white/5">
<div className="flex items-center gap-3">
<Activity size={16} className="text-white/50"/>
<div>
<span className="text-[13px] text-white/80 block">Show Audio Meter</span>
<span className="text-[10px] text-white/40">Display real-time audio level in hands-free mode</span>
</div>
</div>
<button onClick={() => setShowAudioMeter(!showAudioMeter)} className={`w-12 h-6 rounded-full transition-all ${showAudioMeter ? toggleOn : toggleOff}`}>
<div className={`w-5 h-5 rounded-full bg-white shadow-sm transition-transform ${showAudioMeter ? 'translate-x-6' : 'translate-x-0.5'}`}/>
</button>
</div>)}
{/* Browser Voice Selection — only shown for the default
engine. On Piper (or any other non-default engine) the
engine owns its own voice picker below. */}
{ttsEngineId === 'web-speech-api' && browserVoices && browserVoices.length > 0 && setSelectedBrowserVoice && (<div className="space-y-2">
<div className="flex items-center gap-2 px-1">
<Volume2 size={14} className="text-white/50"/>
<span className="text-[12px] text-white/60">System Voice</span>
</div>
<select value={selectedBrowserVoice || ''} onChange={(e) => setSelectedBrowserVoice(e.target.value)} className="w-full bg-[#1a1a1a] border border-white/10 rounded-[12px] px-4 py-3 text-[13px] text-white/80 focus:outline-none focus:border-white/20 transition-colors appearance-none cursor-pointer">
<option value="">Auto (Best Match)</option>
{englishVoices.length > 0 && (<optgroup label="English Voices">
{englishVoices.map((v) => (<option key={v.voiceURI} value={v.voiceURI}>
{v.name} ({v.lang})
</option>))}
</optgroup>)}
{Object.entries(voicesByLang)
.filter(([lang]) => lang !== 'en')
.map(([lang, voices]) => (<optgroup key={lang} label={`${lang.toUpperCase()} Voices`}>
{voices.map((v) => (<option key={v.voiceURI} value={v.voiceURI}>
{v.name} ({v.lang})
</option>))}
</optgroup>))}
</select>
<p className="text-[10px] text-white/30 px-1">
{browserVoices.length} system voices available
</p>
</div>)}
{/* TTS Engine — additive plugin picker.
Shares the SAME registry used by Enterprise Settings →
Voice Assistant, so picking Piper here is reflected
there on next render (and vice versa). Uses the native
browser voice list so the Web Speech path keeps
showing the user's actually-installed voices. */}
<TtsEngineSection systemVoices={browserVoices}/>
</div>
{/* Language Section */}
<div className="space-y-3">
<div className="flex items-center gap-2 text-sm font-semibold text-white/80">
<Globe size={16} className="text-white/50"/>
<span>Language & Recognition</span>
</div>
<div className="p-3 rounded-[12px] bg-white/5">
<p className="text-[12px] text-white/50">
Voice recognition uses your browser's default language settings.
To change the recognition language, update your browser's language preferences.
</p>
</div>
</div>
{/* Content Preferences Section */}
<div className="space-y-4">
<div className="flex items-center gap-2 text-sm font-semibold text-white/80">
<Shield size={16} className="text-white/50"/>
<span>Content Preferences</span>
</div>
{/* Adult Content Toggle */}
<div className="flex items-center justify-between p-3 rounded-[12px] bg-white/5">
<div className="flex items-center gap-3">
<AlertTriangle size={16} className="text-orange-400/70"/>
<div>
<span className="text-[13px] text-white/80 block">Enable 18+ Personalities</span>
<span className="text-[10px] text-white/40">Unlock adult-only conversation modes</span>
</div>
</div>
<button onClick={handleAdultToggle} className={`w-12 h-6 rounded-full transition-all ${adultEnabled ? 'bg-orange-500/30 border border-orange-500/50' : toggleOff}`}>
<div className={`w-5 h-5 rounded-full shadow-sm transition-transform ${adultEnabled ? 'translate-x-6 bg-orange-400' : 'translate-x-0.5 bg-white'}`}/>
</button>
</div>
{adultEnabled && (<div className="p-3 rounded-[12px] bg-orange-500/10 border border-orange-500/20">
<p className="text-[11px] text-orange-300/80">
Adult personalities are now visible in the personality selector.
These modes may contain mature themes, strong language, and explicit content.
</p>
</div>)}
{/* Personas in Voice Toggle */}
<div className="flex items-center justify-between p-3 rounded-[12px] bg-white/5">
<div className="flex items-center gap-3">
<Users size={16} className="text-violet-400/70"/>
<div>
<span className="text-[13px] text-white/80 block">Enable Personas</span>
<span className="text-[10px] text-white/40">Use custom Personas as voice identities</span>
</div>
</div>
<button onClick={handlePersonasToggle} className={`w-12 h-6 rounded-full transition-all ${personasEnabled ? 'bg-violet-500/30 border border-violet-500/50' : toggleOff}`}>
<div className={`w-5 h-5 rounded-full shadow-sm transition-transform ${personasEnabled ? 'translate-x-6 bg-violet-400' : 'translate-x-0.5 bg-white'}`}/>
</button>
</div>
{personasEnabled && (<div className="p-3 rounded-[12px] bg-violet-500/10 border border-violet-500/20">
<p className="text-[11px] text-violet-300/80">
Your Personas from Settings & Projects are now available in the Personality selector.
</p>
</div>)}
{/* Link to Project Toggle — only visible when personas are enabled */}
{personasEnabled && (<>
<div className="flex items-center justify-between p-3 rounded-[12px] bg-white/5">
<div className="flex items-center gap-3">
<Link2 size={16} className="text-blue-400/70"/>
<div>
<span className="text-[13px] text-white/80 block">Link to Project</span>
<span className="text-[10px] text-white/40">Enable memory, RAG & tools for persona sessions</span>
</div>
</div>
<button onClick={handleLinkedToggle} className={`w-12 h-6 rounded-full transition-all ${linkedToProject ? 'bg-blue-500/30 border border-blue-500/50' : toggleOff}`}>
<div className={`w-5 h-5 rounded-full shadow-sm transition-transform ${linkedToProject ? 'translate-x-6 bg-blue-400' : 'translate-x-0.5 bg-white'}`}/>
</button>
</div>
{linkedToProject && (<div className="p-3 rounded-[12px] bg-blue-500/10 border border-blue-500/20">
<p className="text-[11px] text-blue-300/80">
Linked mode: Voice sessions use the persona's full project context — memory, documents, and tools persist across sessions.
</p>
</div>)}
{!linkedToProject && (<div className="p-3 rounded-[12px] bg-white/[0.03] border border-white/5">
<p className="text-[10px] text-white/30">
Unlinked: Voice is fast & ephemeral — no conversation history saved.
</p>
</div>)}
</>)}
</div>
{/* Age Confirmation Modal */}
{showAgeConfirmation && (<div className="fixed inset-0 bg-black/90 backdrop-blur-md z-[110] flex items-center justify-center p-4">
<div className="w-full max-w-[400px] bg-[#1a1a1a] border border-orange-500/30 rounded-[20px] p-6 space-y-4">
<div className="flex items-center gap-3">
<div className="w-12 h-12 rounded-full bg-orange-500/20 flex items-center justify-center">
<AlertTriangle size={24} className="text-orange-400"/>
</div>
<div>
<h3 className="text-lg font-bold text-white">Age Verification</h3>
<p className="text-[12px] text-white/50">Adult content requires confirmation</p>
</div>
</div>
<div className="space-y-3">
<p className="text-[13px] text-white/70 leading-relaxed">
You are about to enable access to adult-only personalities that may contain:
</p>
<ul className="text-[12px] text-white/60 space-y-1 pl-4">
<li>• Mature themes and conversations</li>
<li>• Strong language and profanity</li>
<li>• Sexual or romantic content</li>
<li>• Dark humor and edgy content</li>
</ul>
<p className="text-[13px] text-white/70">
By continuing, you confirm that you are <strong className="text-white">18 years or older</strong> and
consent to accessing this content.
</p>
</div>
<div className="flex gap-3 pt-2">
<button onClick={() => setShowAgeConfirmation(false)} className="flex-1 h-[44px] rounded-full bg-white/10 text-white/80 text-sm font-medium hover:bg-white/15 transition-colors">
Cancel
</button>
<button onClick={handleAgeConfirm} className="flex-1 h-[44px] rounded-full bg-orange-500 text-white text-sm font-bold hover:bg-orange-600 transition-colors">
I am 18+ · Enable
</button>
</div>
</div>
</div>)}
{/* Info Section */}
<div className="p-4 rounded-[16px] bg-gradient-to-br from-white/5 to-transparent border border-white/5">
<div className="flex items-start gap-3">
<div className="w-8 h-8 rounded-full bg-white/10 flex items-center justify-center shrink-0">
<Activity size={16} className="text-white/50"/>
</div>
<div>
<h3 className="text-[13px] font-semibold text-white/80 mb-1">Studio Quality Audio</h3>
<p className="text-[11px] text-white/40 leading-relaxed">
HomePilot Voice uses adaptive noise suppression, echo cancellation,
and automatic gain control for crystal-clear audio capture.
</p>
</div>
</div>
</div>
</div>
{/* Footer */}
<div className="p-5 border-t border-white/10">
<button onClick={onClose} className="w-full h-[48px] rounded-full bg-white text-black text-sm font-bold hover:bg-gray-200 transition-colors">
Done
</button>
</div>
</div>
</div>);
}