'use client'; 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, getSelectableProvidersWithVoices } from '@/lib/audio/voice-resolver'; import { playBrowserTTSPreview } from '@/lib/audio/browser-tts-preview'; import { useAllVoiceProfiles } from '@/lib/audio/voxcpm-voices'; import { resolveAgentVoiceOptions } from '@/lib/audio/agent-voice'; import { VOXCPM_AUTO_VOICE_ID, VOXCPM_TTS_PROVIDER_ID } from '@/lib/audio/voxcpm'; import { Sparkles, ChevronDown, ChevronUp, Shuffle, Volume2, VolumeX, Loader2, 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, autoVoiceLabel?: 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) || // Auto Voice is shown by its localized label, not voice.name — match it too. (voice.id === VOXCPM_AUTO_VOICE_ID && matchesVoiceQuery(autoVoiceLabel, 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 ttsProvidersConfig = useSettingsStore((s) => s.ttsProvidersConfig); const agentVoiceOverrides = useSettingsStore((s) => s.agentVoiceOverrides); const setAgentVoiceOverride = useSettingsStore((s) => s.setAgentVoiceOverride); const resolved = resolveAgentVoice(agent, agentIndex, availableProviders, agentVoiceOverrides); 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, t('settings.voxcpmAutoVoice')), })) .filter(({ groups }) => groups.length > 0); const displayName = (() => { if (!resolved) return t('agentBar.noVoice'); 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 = await resolveAgentVoiceOptions(agent, { providerId, providerConfig: { ...providerConfig, modelId: modelId || providerConfig?.modelId }, voiceId, language: locale, }); 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, // Managed providers resolve their base URL server-side; only send // the client's own base URL (custom providers). ttsBaseUrl: 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]); // Disabled (TTS off) OR no enabled provider ⇒ render the same muted, // non-interactive pill — don't silently hide the control (#665). 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, t('settings.voxcpmAutoVoice')), })) .filter(({ groups }) => groups.length > 0); const displayName = (() => { // No enabled provider ⇒ no valid voice; show the placeholder, not a stale // voice name from a now-disabled provider (#665). if (availableProviders.length === 0) return t('agentBar.noVoice'); 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 = await resolveAgentVoiceOptions(undefined, { providerId, providerConfig: { ...providerConfig, modelId: modelId || providerConfig?.modelId }, voiceId, language: locale, }); 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, // Managed providers resolve their base URL server-side; only send // the client's own base URL (custom providers). ttsBaseUrl: 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]); // Disabled (TTS off) OR no enabled provider ⇒ render the same muted, // non-interactive pill — don't silently hide the control (#665). 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 agentMode = useSettingsStore((s) => s.agentMode); const setAgentMode = useSettingsStore((s) => s.setAgentMode); const setAgentSelectionIsUserSet = useSettingsStore((s) => s.setAgentSelectionIsUserSet); const ttsProvidersConfig = useSettingsStore((s) => s.ttsProvidersConfig); const ttsEnabled = useSettingsStore((s) => s.ttsEnabled); const [open, setOpen] = useState(false); const [browserVoices, setBrowserVoices] = useState([]); const { profiles: voiceProfiles } = useAllVoiceProfiles(); 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'); // Single source of truth for selectable provider+voice options (enabled // providers + opt-in browser-native), shared with discussion TTS (#665). const availableProviders = getSelectableProvidersWithVoices( ttsProvidersConfig, voiceProfiles, browserVoices, ); 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') => { // Clicking the already-active tab is a visual no-op; it must not convert // stage-derived defaults into a "user choice". if (mode === agentMode) return; // An explicit choice — restoreAgentSelection keeps it across classrooms. setAgentSelectionIsUserSet(true); 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'], ); } else { // Auto mode plays the current classroom's generated agents — leaving the // preset ids selected would desync playback from the toggle (UI says // Auto, discussion still uses preset agents) and persist an auto // selection that can never validate on restore. When no classroom's // agents are loaded (fresh home), an empty selection falls back to the // stage-derived defaults on the next classroom load. setSelectedAgentIds(allAgents.filter((a) => a.isGenerated).map((a) => a.id)); } }; const toggleAgent = (agentId: string) => { const agent = agents.find((a) => a.id === agentId); if (agent?.role === 'teacher') return; setAgentSelectionIsUserSet(true); 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}
)}
)} )} {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)}
); }; return (
{!open && ( {t('agentBar.configTooltip')} )} {open && (
{/* Teacher — always visible */} {teacherAgent && (
{getAgentName(teacherAgent)}
{getAgentName(teacherAgent)}
)} {/* Mode tabs */}
{agentMode === 'preset' ? (
{agents .filter((a) => a.role !== 'teacher') .map((agent, idx) => renderAgentRow(agent, idx + 1, false))}
) : (

{t('settings.agentModeAutoDesc')}

{t('agentBar.voiceAutoAssign')}

)}
)}
); }