feat: follow-up dialog window — VAD stays active 8s after agent response
All checks were successful
Deploy / deploy (push) Successful in 1m37s

This commit is contained in:
Cosmo
2026-05-01 13:47:51 +00:00
parent 8886d1d907
commit d30ed1bac1
2 changed files with 49 additions and 6 deletions

View File

@@ -38,6 +38,8 @@ export default function VoiceController() {
const wakeRef = useRef<WakeWordDetector | null>(null)
const vadRef = useRef<any>(null)
const busyRef = useRef(false)
const followUpRef = useRef(false)
const followUpTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
useEffect(() => {
vlog('[VoiceController] mounted, state=idle, ждём тап на микрофон')
@@ -46,6 +48,8 @@ export default function VoiceController() {
// (НЕ destroy — иначе следующий wake снова будет ждать 1-2с на инициализацию).
const onCancel = () => {
vlog('[voice] cancel — пауза VAD')
followUpRef.current = false
if (followUpTimerRef.current) { clearTimeout(followUpTimerRef.current); followUpTimerRef.current = null }
try { vadRef.current?.pause?.() } catch {}
busyRef.current = false
try { wakeRef.current?.resume?.() } catch {}
@@ -54,8 +58,36 @@ export default function VoiceController() {
}
window.addEventListener('voice-cancel', onCancel)
const onFollowUp = () => {
if (!wakeRef.current) return // ассистент выключен
vlog('[voice] follow-up window открыто (8s)')
followUpRef.current = true
// Очистить предыдущий таймер если был
if (followUpTimerRef.current) clearTimeout(followUpTimerRef.current)
// Запустить VAD сразу без wake word
if (vadRef.current) {
try { vadRef.current.start() } catch {}
}
setState('recording')
emitLocal('wake', AGENT)
// Через 8 секунд — если никто не заговорил — вернуться к wake
followUpTimerRef.current = setTimeout(() => {
if (followUpRef.current) {
followUpRef.current = false
vlog('[voice] follow-up timeout — возврат к wake word')
try { vadRef.current?.pause?.() } catch {}
try { wakeRef.current?.resume?.() } catch {}
setState('listening')
emitLocal('idle', AGENT)
}
}, 8000)
}
window.addEventListener('voice-follow-up', onFollowUp)
return () => {
window.removeEventListener('voice-cancel', onCancel)
window.removeEventListener('voice-follow-up', onFollowUp)
if (followUpTimerRef.current) clearTimeout(followUpTimerRef.current)
try { vadRef.current?.destroy?.() } catch {}
try { wakeRef.current?.stop?.() } catch {}
vadRef.current = null
@@ -96,12 +128,19 @@ export default function VoiceController() {
emitLocal('error', AGENT, 'Не получилось')
} finally {
busyRef.current = false
// VAD на паузу — переиспользуем при следующем wake (без re-init).
// Если активен follow-up режим — НЕ переключаемся на wake, ответ сам откроет новое окно
if (!followUpRef.current) {
try { vadRef.current?.pause?.() } catch {}
// Wake возобновляем — снова слушаем фоном.
try { wakeRef.current?.resume?.() } catch {}
setState((s) => (s === 'busy' ? 'listening' : s))
}
// Сбросить follow-up флаг — новое окно откроется после TTS
followUpRef.current = false
if (followUpTimerRef.current) {
clearTimeout(followUpTimerRef.current)
followUpTimerRef.current = null
}
}
}
// Однократная инициализация VAD. Создаётся в paused-состоянии и переиспользуется

View File

@@ -172,7 +172,11 @@ export default function VoiceOverlay() {
setText(evt.text || '')
clearDismiss()
if (evt.text) {
playTTS(evt.text, currentAgent, () => scheduleDismiss(4000))
playTTS(evt.text, currentAgent, () => {
// Сигнализируем VoiceController что можно слушать follow-up
window.dispatchEvent(new CustomEvent('voice-follow-up'))
scheduleDismiss(9000)
})
} else {
scheduleDismiss(4000)
}