import { useState, useEffect, useRef, useCallback } from 'react'; import { motion, AnimatePresence } from 'motion/react'; import { Checkbox } from '@/components/ui/checkbox'; import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; import { cn } from '@/lib/utils'; import { useI18n } from '@/lib/hooks/use-i18n'; import { useSettingsStore } from '@/lib/store/settings'; import { useAgentRegistry } from '@/lib/orchestration/registry/store'; import { resolveAgentVoice, getAvailableProvidersWithVoices } from '@/lib/audio/voice-resolver'; import { playBrowserTTSPreview } from '@/lib/audio/browser-tts-preview'; import { getVoxCPMProviderOptions, useVoxCPMVoiceProfiles } from '@/lib/audio/voxcpm-voices'; import { VOXCPM_AUTO_VOICE_ID, VOXCPM_TTS_PROVIDER_ID } from '@/lib/audio/voxcpm'; import { Sparkles, ChevronDown, ChevronUp, Shuffle, Volume2, VolumeX, Loader2, MessageSquare, Minus, Plus, Search, } from 'lucide-react'; import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; import type { AgentConfig } from '@/lib/orchestration/registry/types'; import type { TTSProviderId } from '@/lib/audio/types'; import type { ProviderWithVoices } from '@/lib/audio/voice-resolver'; function matchesVoiceQuery(value: string | undefined, query: string): boolean { return !!value?.toLowerCase().includes(query); } function getFilteredModelGroups(provider: ProviderWithVoices, query: string) { const normalizedQuery = query.trim().toLowerCase(); if (!normalizedQuery) return provider.modelGroups; return provider.modelGroups .map((group) => { const groupMatches = matchesVoiceQuery(provider.providerName, normalizedQuery) || matchesVoiceQuery(provider.providerId, normalizedQuery) || matchesVoiceQuery(group.modelName, normalizedQuery) || matchesVoiceQuery(group.modelId, normalizedQuery); const voices = group.voices.filter( (voice) => groupMatches || matchesVoiceQuery(voice.name, normalizedQuery) || matchesVoiceQuery(voice.id, normalizedQuery) || matchesVoiceQuery(voice.language, normalizedQuery), ); return { ...group, voices }; }) .filter((group) => group.voices.length > 0); } function isNonPreviewableVoice(providerId: TTSProviderId, voiceId: string): boolean { return providerId === VOXCPM_TTS_PROVIDER_ID && voiceId === VOXCPM_AUTO_VOICE_ID; } function AgentVoicePill({ agent, agentIndex, availableProviders, disabled, }: { agent: AgentConfig; agentIndex: number; availableProviders: ProviderWithVoices[]; disabled?: boolean; }) { const { t, locale } = useI18n(); const updateAgent = useAgentRegistry((s) => s.updateAgent); const ttsProvidersConfig = useSettingsStore((s) => s.ttsProvidersConfig); const resolved = resolveAgentVoice(agent, agentIndex, availableProviders); const [popoverOpen, setPopoverOpen] = useState(false); const [voiceQuery, setVoiceQuery] = useState(''); const [previewingId, setPreviewingId] = useState(null); const previewCancelRef = useRef<(() => void) | null>(null); const previewAudioRef = useRef(null); const previewAbortRef = useRef(null); const visibleProviderGroups = availableProviders .map((provider) => ({ provider, groups: getFilteredModelGroups(provider, voiceQuery), })) .filter(({ groups }) => groups.length > 0); const displayName = (() => { for (const p of availableProviders) { if (p.providerId === resolved.providerId) { const v = p.voices.find((voice) => voice.id === resolved.voiceId); if (v) return v.id === VOXCPM_AUTO_VOICE_ID ? t('settings.voxcpmAutoVoice') : v.name; } } return resolved.voiceId; })(); const stopPreview = useCallback(() => { previewCancelRef.current?.(); previewCancelRef.current = null; previewAbortRef.current?.abort(); previewAbortRef.current = null; if (previewAudioRef.current) { previewAudioRef.current.pause(); previewAudioRef.current.src = ''; previewAudioRef.current = null; } setPreviewingId(null); }, []); const handlePreview = useCallback( async (providerId: TTSProviderId, voiceId: string, modelId?: string) => { const key = `${providerId}::${voiceId}`; if (previewingId === key) { stopPreview(); return; } stopPreview(); setPreviewingId(key); const previewText = t('settings.ttsTestTextDefault'); if (providerId === 'browser-native-tts') { const { promise, cancel } = playBrowserTTSPreview({ text: previewText, voice: voiceId }); previewCancelRef.current = cancel; try { await promise; } catch { // ignore abort } setPreviewingId(null); return; } // Server TTS try { const controller = new AbortController(); previewAbortRef.current = controller; const providerConfig = ttsProvidersConfig[providerId]; const providerOptions = providerId === 'voxcpm-tts' ? { ...(providerConfig?.providerOptions || {}), ...(await getVoxCPMProviderOptions(voiceId, { agentName: agent.name, role: agent.role, persona: agent.persona, locale, })), } : undefined; const res = await fetch('/api/generate/tts', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ text: previewText, audioId: 'voice-preview', ttsProviderId: providerId, ttsModelId: modelId || providerConfig?.modelId, ttsVoice: voiceId, ttsSpeed: 1, ttsApiKey: providerConfig?.apiKey, ttsBaseUrl: providerConfig?.serverBaseUrl || providerConfig?.baseUrl || providerConfig?.customDefaultBaseUrl, ttsProviderOptions: providerOptions, }), signal: controller.signal, }); if (!res.ok) throw new Error('TTS error'); const data = await res.json(); if (!data.base64) throw new Error('No audio'); const audio = new Audio(`data:audio/${data.format || 'mp3'};base64,${data.base64}`); previewAudioRef.current = audio; audio.addEventListener('ended', () => setPreviewingId(null)); audio.addEventListener('error', () => setPreviewingId(null)); await audio.play(); } catch { setPreviewingId(null); } }, [ agent.name, agent.persona, agent.role, locale, previewingId, stopPreview, t, ttsProvidersConfig, ], ); // Cleanup on unmount useEffect(() => () => stopPreview(), [stopPreview]); if (disabled) { return (
e.stopPropagation()} onPointerDown={(e) => e.stopPropagation()} className="flex items-center gap-1.5 h-6 w-[100px] rounded-full bg-muted/40 px-2.5 text-[11px] text-muted-foreground/30 shrink-0 cursor-not-allowed" > {displayName}
); } return ( { setPopoverOpen(open); if (!open) { setVoiceQuery(''); stopPreview(); } }} > e.stopPropagation()} onPointerDown={(e) => e.stopPropagation()} >
setVoiceQuery(e.target.value)} autoFocus aria-label={t('agentBar.searchVoice')} placeholder={t('agentBar.searchVoice')} className="h-8 w-full rounded-md border border-input bg-background pl-8 pr-3 text-sm outline-none transition-colors placeholder:text-muted-foreground/50 focus:border-primary/50 focus:ring-2 focus:ring-primary/10" />
{visibleProviderGroups.length === 0 && (
{t('agentBar.noMatchingVoices')}
)} {visibleProviderGroups.map(({ provider, groups }) => groups.map((group) => (
{group.modelId ? `${provider.providerName} · ${group.modelName}` : provider.providerName}
{group.voices.map((voice) => { const isActive = resolved.providerId === provider.providerId && resolved.voiceId === voice.id && (resolved.modelId || '') === (group.modelId || ''); const previewKey = `${provider.providerId}::${voice.id}`; const isPreviewing = previewingId === previewKey; const canPreview = !isNonPreviewableVoice(provider.providerId, voice.id); return (
{canPreview && ( )}
); })}
)), )}
); } /** * Teacher voice pill — reads/writes global ttsProviderId + ttsVoice (single source of truth). * This ensures lecture and discussion use the same voice for the teacher. */ function TeacherVoicePill({ availableProviders, disabled, }: { availableProviders: ProviderWithVoices[]; disabled?: boolean; }) { const { t, locale } = useI18n(); const ttsProviderId = useSettingsStore((s) => s.ttsProviderId); const ttsVoice = useSettingsStore((s) => s.ttsVoice); const setTTSProvider = useSettingsStore((s) => s.setTTSProvider); const setTTSVoice = useSettingsStore((s) => s.setTTSVoice); const setTTSProviderConfig = useSettingsStore((s) => s.setTTSProviderConfig); const ttsProvidersConfig = useSettingsStore((s) => s.ttsProvidersConfig); const [popoverOpen, setPopoverOpen] = useState(false); const [voiceQuery, setVoiceQuery] = useState(''); const [previewingId, setPreviewingId] = useState(null); const previewCancelRef = useRef<(() => void) | null>(null); const previewAudioRef = useRef(null); const previewAbortRef = useRef(null); const visibleProviderGroups = availableProviders .map((provider) => ({ provider, groups: getFilteredModelGroups(provider, voiceQuery), })) .filter(({ groups }) => groups.length > 0); const displayName = (() => { for (const p of availableProviders) { if (p.providerId === ttsProviderId) { const v = p.voices.find((voice) => voice.id === ttsVoice); if (v) return v.id === VOXCPM_AUTO_VOICE_ID ? t('settings.voxcpmAutoVoice') : v.name; } } return ttsVoice || 'default'; })(); const stopPreview = useCallback(() => { previewCancelRef.current?.(); previewCancelRef.current = null; previewAbortRef.current?.abort(); previewAbortRef.current = null; if (previewAudioRef.current) { previewAudioRef.current.pause(); previewAudioRef.current.src = ''; previewAudioRef.current = null; } setPreviewingId(null); }, []); const handlePreview = useCallback( async (providerId: TTSProviderId, voiceId: string, modelId?: string) => { const key = `${providerId}::${voiceId}`; if (previewingId === key) { stopPreview(); return; } stopPreview(); setPreviewingId(key); const previewText = t('settings.ttsTestTextDefault'); if (providerId === 'browser-native-tts') { const { promise, cancel } = playBrowserTTSPreview({ text: previewText, voice: voiceId }); previewCancelRef.current = cancel; try { await promise; } catch { // ignore abort } setPreviewingId(null); return; } try { const controller = new AbortController(); previewAbortRef.current = controller; const providerConfig = ttsProvidersConfig[providerId]; const providerOptions = providerId === 'voxcpm-tts' ? { ...(providerConfig?.providerOptions || {}), ...(await getVoxCPMProviderOptions(voiceId, { agentName: 'Teacher', role: 'teacher', locale, })), } : undefined; const res = await fetch('/api/generate/tts', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ text: previewText, audioId: 'voice-preview', ttsProviderId: providerId, ttsModelId: modelId || providerConfig?.modelId, ttsVoice: voiceId, ttsSpeed: 1, ttsApiKey: providerConfig?.apiKey, ttsBaseUrl: providerConfig?.serverBaseUrl || providerConfig?.baseUrl || providerConfig?.customDefaultBaseUrl, ttsProviderOptions: providerOptions, }), signal: controller.signal, }); if (!res.ok) throw new Error('TTS error'); const data = await res.json(); if (!data.base64) throw new Error('No audio'); const audio = new Audio(`data:audio/${data.format || 'mp3'};base64,${data.base64}`); previewAudioRef.current = audio; audio.addEventListener('ended', () => setPreviewingId(null)); audio.addEventListener('error', () => setPreviewingId(null)); await audio.play(); } catch { setPreviewingId(null); } }, [locale, previewingId, stopPreview, t, ttsProvidersConfig], ); useEffect(() => () => stopPreview(), [stopPreview]); if (disabled) { return (
e.stopPropagation()} onPointerDown={(e) => e.stopPropagation()} className="flex items-center gap-1.5 h-6 w-[100px] rounded-full bg-muted/40 px-2.5 text-[11px] text-muted-foreground/30 shrink-0 cursor-not-allowed" > {displayName}
); } return ( { setPopoverOpen(open); if (!open) { setVoiceQuery(''); stopPreview(); } }} > e.stopPropagation()} onPointerDown={(e) => e.stopPropagation()} >
setVoiceQuery(e.target.value)} autoFocus aria-label={t('agentBar.searchVoice')} placeholder={t('agentBar.searchVoice')} className="h-8 w-full rounded-md border border-input bg-background pl-8 pr-3 text-sm outline-none transition-colors placeholder:text-muted-foreground/50 focus:border-primary/50 focus:ring-2 focus:ring-primary/10" />
{visibleProviderGroups.length === 0 && (
{t('agentBar.noMatchingVoices')}
)} {visibleProviderGroups.map(({ provider, groups }) => groups.map((group) => (
{group.modelId ? `${provider.providerName} · ${group.modelName}` : provider.providerName}
{group.voices.map((voice) => { const currentModelId = ttsProvidersConfig[ttsProviderId]?.modelId || ''; const isActive = ttsProviderId === provider.providerId && ttsVoice === voice.id && currentModelId === (group.modelId || ''); const previewKey = `${provider.providerId}::${voice.id}`; const isPreviewing = previewingId === previewKey; const canPreview = !isNonPreviewableVoice(provider.providerId, voice.id); return (
{canPreview && ( )}
); })}
)), )}
); } export function AgentBar() { const { t } = useI18n(); const { listAgents } = useAgentRegistry(); const selectedAgentIds = useSettingsStore((s) => s.selectedAgentIds); const setSelectedAgentIds = useSettingsStore((s) => s.setSelectedAgentIds); const maxTurns = useSettingsStore((s) => s.maxTurns); const setMaxTurns = useSettingsStore((s) => s.setMaxTurns); const agentMode = useSettingsStore((s) => s.agentMode); const setAgentMode = useSettingsStore((s) => s.setAgentMode); const ttsProvidersConfig = useSettingsStore((s) => s.ttsProvidersConfig); const ttsEnabled = useSettingsStore((s) => s.ttsEnabled); const [open, setOpen] = useState(false); const [browserVoices, setBrowserVoices] = useState([]); const { profiles: voxcpmProfiles } = useVoxCPMVoiceProfiles(); const containerRef = useRef(null); // Load browser native TTS voices useEffect(() => { if (typeof window === 'undefined' || !window.speechSynthesis) return; const loadVoices = () => setBrowserVoices(speechSynthesis.getVoices()); loadVoices(); speechSynthesis.addEventListener('voiceschanged', loadVoices); return () => speechSynthesis.removeEventListener('voiceschanged', loadVoices); }, []); const allAgents = listAgents(); const agents = allAgents.filter((a) => !a.isGenerated); const teacherAgent = agents.find((a) => a.role === 'teacher'); const selectedAgents = agents.filter((a) => selectedAgentIds.includes(a.id)); const nonTeacherSelected = selectedAgents.filter((a) => a.role !== 'teacher'); const serverProviders = getAvailableProvidersWithVoices(ttsProvidersConfig, voxcpmProfiles); const availableProviders: ProviderWithVoices[] = [ ...serverProviders, ...(browserVoices.length > 0 ? [ { providerId: 'browser-native-tts' as TTSProviderId, providerName: 'Browser Native', voices: browserVoices.map((v) => ({ id: v.voiceURI, name: v.name })), modelGroups: [ { modelId: '', modelName: 'Browser Native', voices: browserVoices.map((v) => ({ id: v.voiceURI, name: v.name })), }, ], }, ] : []), ]; const showVoice = availableProviders.length > 0; useEffect(() => { if (!open) return; const handler = (e: MouseEvent) => { const target = e.target as Node; if (containerRef.current && containerRef.current.contains(target)) return; // Don't close if clicking inside a Radix portal (Popover, Select, etc.) if ((target as Element).closest?.('[data-radix-popper-content-wrapper]')) return; setOpen(false); }; document.addEventListener('mousedown', handler); return () => document.removeEventListener('mousedown', handler); }, [open]); const handleModeChange = (mode: 'preset' | 'auto') => { setAgentMode(mode); if (mode === 'preset') { // Remove stale auto-generated agent IDs that may linger from a previous auto classroom const presetIds = selectedAgentIds.filter((id) => agents.some((a) => a.id === id)); const hasTeacher = presetIds.some((id) => { const a = agents.find((agent) => agent.id === id); return a?.role === 'teacher'; }); if (!hasTeacher && teacherAgent) { presetIds.unshift(teacherAgent.id); } setSelectedAgentIds( presetIds.length > 0 ? presetIds : ['default-1', 'default-2', 'default-3'], ); } }; const toggleAgent = (agentId: string) => { const agent = agents.find((a) => a.id === agentId); if (agent?.role === 'teacher') return; if (selectedAgentIds.includes(agentId)) { setSelectedAgentIds(selectedAgentIds.filter((id) => id !== agentId)); } else { setSelectedAgentIds([...selectedAgentIds, agentId]); } }; const getAgentName = (agent: { id: string; name: string }) => { const key = `settings.agentNames.${agent.id}`; const translated = t(key); return translated !== key ? translated : agent.name; }; const getAgentRole = (agent: { role: string }) => { const key = `settings.agentRoles.${agent.role}`; const translated = t(key); return translated !== key ? translated : agent.role; }; const avatarRow = (
{teacherAgent && (
{getAgentName(teacherAgent)}
)} {agentMode === 'auto' ? ( <>
{agents.find((a) => a.role === 'assistant') && (
a.role === 'assistant')!.avatar} alt="" className="size-full object-cover" />
)}
) : ( <> {nonTeacherSelected.length > 0 && (
{nonTeacherSelected.slice(0, 4).map((agent) => (
{getAgentName(agent)}
))} {nonTeacherSelected.length > 4 && (
+{nonTeacherSelected.length - 4}
)}
)} )} {showVoice && (ttsEnabled ? ( ) : ( ))}
); const renderAgentRow = (agent: AgentConfig, agentIndex: number, isTeacher: boolean) => { const isSelected = isTeacher || selectedAgentIds.includes(agent.id); return (
toggleAgent(agent.id)} className={cn( 'w-full flex items-center gap-2 px-2.5 py-1.5 rounded-lg transition-colors', isTeacher ? 'bg-primary/5' : 'cursor-pointer', !isTeacher && isSelected && 'bg-primary/5', !isTeacher && !isSelected && 'hover:bg-muted/50', )} >
{getAgentName(agent)}
{getAgentName(agent)} {getAgentRole(agent)} {showVoice && ( )}
); }; return (
{!open && ( {t('agentBar.configTooltip')} )} {open && (
{/* Teacher — always visible */} {teacherAgent && (
{getAgentName(teacherAgent)}
{getAgentName(teacherAgent)} {showVoice && ( )}
)} {/* Mode tabs */}
{agentMode === 'preset' ? (
{agents .filter((a) => a.role !== 'teacher') .map((agent, idx) => renderAgentRow(agent, idx + 1, false))}
) : (

{t('settings.agentModeAutoDesc')}

{t('agentBar.voiceAutoAssign')}

)} {/* Max turns — compact stepper */}
{t('settings.maxTurns')}
{ const raw = e.target.value.replace(/\D/g, ''); if (!raw) { setMaxTurns(''); return; } const v = Math.min(20, Math.max(1, parseInt(raw))); setMaxTurns(String(v)); }} onBlur={() => { if (!maxTurns || parseInt(maxTurns) < 1) setMaxTurns('1'); }} onClick={(e) => e.stopPropagation()} className="w-5 h-5 text-[11px] font-medium tabular-nums text-center bg-transparent outline-none border-none" />
)}
); }