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

View File

@@ -172,7 +172,11 @@ export default function VoiceOverlay() {
setText(evt.text || '') setText(evt.text || '')
clearDismiss() clearDismiss()
if (evt.text) { 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 { } else {
scheduleDismiss(4000) scheduleDismiss(4000)
} }