feat(voice): SSE bridge + Siri-blob overlay for wake-word script
All checks were successful
Deploy / deploy (push) Successful in 3m12s
All checks were successful
Deploy / deploy (push) Successful in 3m12s
Adds the tablet side of voice assistant integration. External Python script (openWakeWord + Groq STT + OpenClaw) will POST state transitions to /api/voice/event with a bearer token, and the tablet shows a fullscreen overlay with Siri-style animated blob + current agent + recognized text / response text. - lib/voice-bus.ts — in-process EventEmitter singleton, preserved across hot reloads via globalThis - app/api/voice/event — POST, bearer-auth via VOICE_API_KEY env, validates event kind, broadcasts on voiceBus - app/api/voice/stream — GET, SSE endpoint, per-connection listener with 15s keep-alive ping and abort-signal cleanup - components/VoiceOverlay — full-screen overlay, 3-layer pulsing Siri blob, per-agent palette (cosmo indigo/violet, lusya pink/rose), auto-dismiss timeouts (wake=20s safety, response=6s, error=4s), auto-reconnect on SSE drop - middleware bypasses /api/voice/event so the script does not need a user auth cookie - VoiceOverlay mounted in HomePageInner outside tab routing so it appears on every view
This commit is contained in:
37
app/api/voice/event/route.ts
Normal file
37
app/api/voice/event/route.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
export const dynamic = 'force-dynamic'
|
||||||
|
export const runtime = 'nodejs'
|
||||||
|
|
||||||
|
import { NextResponse } from 'next/server'
|
||||||
|
import { voiceBus, VoiceEventPayload } from '@/lib/voice-bus'
|
||||||
|
|
||||||
|
const ALLOWED_EVENTS = new Set(['wake', 'command', 'response', 'idle', 'error'])
|
||||||
|
|
||||||
|
export async function POST(req: Request) {
|
||||||
|
const expected = process.env.VOICE_API_KEY
|
||||||
|
if (!expected) {
|
||||||
|
return NextResponse.json({ error: 'voice_not_configured' }, { status: 503 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const auth = req.headers.get('authorization') || ''
|
||||||
|
const token = auth.replace(/^Bearer\s+/i, '').trim()
|
||||||
|
if (token !== expected) {
|
||||||
|
return NextResponse.json({ error: 'unauthorized' }, { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await req.json().catch(() => null)
|
||||||
|
if (!body || typeof body.event !== 'string' || !ALLOWED_EVENTS.has(body.event)) {
|
||||||
|
return NextResponse.json({ error: 'invalid_event' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const agent = body.agent === 'lusya' ? 'lusya' : body.agent === 'cosmo' ? 'cosmo' : undefined
|
||||||
|
|
||||||
|
const evt: VoiceEventPayload = {
|
||||||
|
event: body.event,
|
||||||
|
agent,
|
||||||
|
text: typeof body.text === 'string' ? body.text.slice(0, 2000) : undefined,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
}
|
||||||
|
|
||||||
|
voiceBus.emit('voice', evt)
|
||||||
|
return NextResponse.json({ ok: true })
|
||||||
|
}
|
||||||
50
app/api/voice/stream/route.ts
Normal file
50
app/api/voice/stream/route.ts
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
export const dynamic = 'force-dynamic'
|
||||||
|
export const runtime = 'nodejs'
|
||||||
|
|
||||||
|
import { voiceBus, VoiceEventPayload } from '@/lib/voice-bus'
|
||||||
|
|
||||||
|
export async function GET(req: Request) {
|
||||||
|
const encoder = new TextEncoder()
|
||||||
|
|
||||||
|
const stream = new ReadableStream({
|
||||||
|
start(controller) {
|
||||||
|
let closed = false
|
||||||
|
const safeEnqueue = (chunk: string) => {
|
||||||
|
if (closed) return
|
||||||
|
try { controller.enqueue(encoder.encode(chunk)) } catch { closed = true }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initial hello — so client sees stream is alive
|
||||||
|
safeEnqueue(`: connected ${new Date().toISOString()}\n\n`)
|
||||||
|
|
||||||
|
const onEvent = (evt: VoiceEventPayload) => {
|
||||||
|
safeEnqueue(`data: ${JSON.stringify(evt)}\n\n`)
|
||||||
|
}
|
||||||
|
voiceBus.on('voice', onEvent)
|
||||||
|
|
||||||
|
// Keep-alive ping every 15s so proxies/browsers don't drop the connection
|
||||||
|
const pingTimer = setInterval(() => {
|
||||||
|
safeEnqueue(`: ping\n\n`)
|
||||||
|
}, 15000)
|
||||||
|
|
||||||
|
const abort = () => {
|
||||||
|
if (closed) return
|
||||||
|
closed = true
|
||||||
|
voiceBus.off('voice', onEvent)
|
||||||
|
clearInterval(pingTimer)
|
||||||
|
try { controller.close() } catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
req.signal.addEventListener('abort', abort)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return new Response(stream, {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'text/event-stream; charset=utf-8',
|
||||||
|
'Cache-Control': 'no-cache, no-transform',
|
||||||
|
'Connection': 'keep-alive',
|
||||||
|
'X-Accel-Buffering': 'no',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@ import CalendarTab from '@/components/CalendarTab'
|
|||||||
import NotesTab from '@/components/NotesTab'
|
import NotesTab from '@/components/NotesTab'
|
||||||
import TransportWidget from '@/components/TransportWidget'
|
import TransportWidget from '@/components/TransportWidget'
|
||||||
import WeatherAnimation from '@/components/WeatherAnimation'
|
import WeatherAnimation from '@/components/WeatherAnimation'
|
||||||
|
import VoiceOverlay from '@/components/VoiceOverlay'
|
||||||
|
|
||||||
type Tab = 'home' | 'devices' | 'calendar' | 'notes' | 'settings'
|
type Tab = 'home' | 'devices' | 'calendar' | 'notes' | 'settings'
|
||||||
|
|
||||||
@@ -1107,6 +1108,8 @@ function HomePageInner() {
|
|||||||
)}
|
)}
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
|
|
||||||
|
<VoiceOverlay />
|
||||||
|
|
||||||
<Sidebar active={tab} onChange={setTab} />
|
<Sidebar active={tab} onChange={setTab} />
|
||||||
|
|
||||||
{/* Night-shift warm tint overlay */}
|
{/* Night-shift warm tint overlay */}
|
||||||
|
|||||||
201
components/VoiceOverlay.tsx
Normal file
201
components/VoiceOverlay.tsx
Normal file
@@ -0,0 +1,201 @@
|
|||||||
|
'use client'
|
||||||
|
import { useEffect, useRef, useState } from 'react'
|
||||||
|
import { motion, AnimatePresence } from 'framer-motion'
|
||||||
|
|
||||||
|
type VoiceState = 'idle' | 'wake' | 'command' | 'response' | 'error'
|
||||||
|
type Agent = 'cosmo' | 'lusya'
|
||||||
|
|
||||||
|
interface VoiceEvent {
|
||||||
|
event: VoiceState
|
||||||
|
agent?: Agent
|
||||||
|
text?: string
|
||||||
|
timestamp: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const AGENT_STYLE: Record<Agent, { primary: string; secondary: string; name: string; emoji: string }> = {
|
||||||
|
cosmo: { primary: '#818cf8', secondary: '#a855f7', name: 'Cosmo', emoji: '🦞' },
|
||||||
|
lusya: { primary: '#ec4899', secondary: '#f43f5e', name: 'Люся', emoji: '👩' },
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function VoiceOverlay() {
|
||||||
|
const [state, setState] = useState<VoiceState>('idle')
|
||||||
|
const [agent, setAgent] = useState<Agent>('cosmo')
|
||||||
|
const [text, setText] = useState('')
|
||||||
|
const dismissTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||||
|
|
||||||
|
const clearDismiss = () => {
|
||||||
|
if (dismissTimer.current) {
|
||||||
|
clearTimeout(dismissTimer.current)
|
||||||
|
dismissTimer.current = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const scheduleDismiss = (ms: number) => {
|
||||||
|
clearDismiss()
|
||||||
|
dismissTimer.current = setTimeout(() => setState('idle'), ms)
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let es: EventSource | null = null
|
||||||
|
let retry: ReturnType<typeof setTimeout> | null = null
|
||||||
|
let closedByUs = false
|
||||||
|
|
||||||
|
const connect = () => {
|
||||||
|
es = new EventSource('/api/voice/stream')
|
||||||
|
|
||||||
|
es.onmessage = (e) => {
|
||||||
|
try {
|
||||||
|
const evt: VoiceEvent = JSON.parse(e.data)
|
||||||
|
if (evt.agent) setAgent(evt.agent)
|
||||||
|
|
||||||
|
if (evt.event === 'wake') {
|
||||||
|
setState('wake')
|
||||||
|
setText('')
|
||||||
|
scheduleDismiss(20000) // safety net: 20s max without command
|
||||||
|
} else if (evt.event === 'command') {
|
||||||
|
setState('command')
|
||||||
|
setText(evt.text || '')
|
||||||
|
scheduleDismiss(30000)
|
||||||
|
} else if (evt.event === 'response') {
|
||||||
|
setState('response')
|
||||||
|
setText(evt.text || '')
|
||||||
|
scheduleDismiss(6000)
|
||||||
|
} else if (evt.event === 'error') {
|
||||||
|
setState('error')
|
||||||
|
setText(evt.text || 'Ошибка')
|
||||||
|
scheduleDismiss(4000)
|
||||||
|
} else if (evt.event === 'idle') {
|
||||||
|
clearDismiss()
|
||||||
|
setState('idle')
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
es.onerror = () => {
|
||||||
|
if (closedByUs) return
|
||||||
|
es?.close()
|
||||||
|
retry = setTimeout(connect, 3000)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
connect()
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
closedByUs = true
|
||||||
|
clearDismiss()
|
||||||
|
if (retry) clearTimeout(retry)
|
||||||
|
es?.close()
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const isActive = state !== 'idle'
|
||||||
|
const style = AGENT_STYLE[agent]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AnimatePresence>
|
||||||
|
{isActive && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
transition={{ duration: 0.35 }}
|
||||||
|
style={{
|
||||||
|
position: 'fixed', inset: 0, zIndex: 300,
|
||||||
|
background: 'rgba(5, 5, 15, 0.78)',
|
||||||
|
backdropFilter: 'blur(24px)',
|
||||||
|
WebkitBackdropFilter: 'blur(24px)' as any,
|
||||||
|
display: 'flex', flexDirection: 'column',
|
||||||
|
alignItems: 'center', justifyContent: 'center',
|
||||||
|
gap: 36, padding: 40,
|
||||||
|
pointerEvents: 'none',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SiriBlob color={style.primary} color2={style.secondary} state={state} />
|
||||||
|
|
||||||
|
<div style={{ textAlign: 'center', maxWidth: 760 }}>
|
||||||
|
<div style={{
|
||||||
|
fontSize: 12, color: 'rgba(255,255,255,0.45)', fontWeight: 700,
|
||||||
|
letterSpacing: '0.22em', textTransform: 'uppercase', marginBottom: 14,
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 10,
|
||||||
|
}}>
|
||||||
|
<span style={{ fontSize: 18 }}>{style.emoji}</span>
|
||||||
|
{style.name}
|
||||||
|
{state !== 'wake' && (
|
||||||
|
<span style={{
|
||||||
|
display: 'inline-block', width: 6, height: 6, borderRadius: '50%',
|
||||||
|
background: style.primary,
|
||||||
|
marginLeft: 4,
|
||||||
|
}} />
|
||||||
|
)}
|
||||||
|
<span style={{ letterSpacing: '0.1em' }}>
|
||||||
|
{state === 'wake' ? '· слушает' : state === 'command' ? '· распознал' : state === 'response' ? '· отвечает' : state === 'error' ? '· ошибка' : ''}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{
|
||||||
|
fontSize: state === 'wake' ? 36 : 26,
|
||||||
|
fontWeight: 700,
|
||||||
|
color: state === 'error' ? '#fca5a5' : 'rgba(255,255,255,0.96)',
|
||||||
|
letterSpacing: '-0.5px', lineHeight: 1.35,
|
||||||
|
minHeight: 48,
|
||||||
|
}}>
|
||||||
|
{state === 'wake' ? 'Слушаю…' : (text || '…')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SiriBlob({ color, color2, state }: { color: string; color2: string; state: VoiceState }) {
|
||||||
|
const isIntense = state === 'wake'
|
||||||
|
return (
|
||||||
|
<div style={{ position: 'relative', width: 220, height: 220 }}>
|
||||||
|
{/* Outer pulsing ring */}
|
||||||
|
<motion.div
|
||||||
|
animate={{
|
||||||
|
scale: isIntense ? [1, 1.25, 1] : [1, 1.08, 1],
|
||||||
|
opacity: isIntense ? [0.5, 0.15, 0.5] : [0.35, 0.1, 0.35],
|
||||||
|
}}
|
||||||
|
transition={{
|
||||||
|
duration: isIntense ? 1.4 : 3,
|
||||||
|
repeat: Infinity,
|
||||||
|
ease: 'easeInOut',
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
position: 'absolute', inset: 0, borderRadius: '50%',
|
||||||
|
background: `radial-gradient(circle, ${color}55 0%, transparent 70%)`,
|
||||||
|
filter: 'blur(24px)',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{/* Inner core */}
|
||||||
|
<motion.div
|
||||||
|
animate={{
|
||||||
|
scale: isIntense ? [1, 1.08, 1] : 1,
|
||||||
|
}}
|
||||||
|
transition={{
|
||||||
|
duration: 1.2,
|
||||||
|
repeat: Infinity,
|
||||||
|
ease: 'easeInOut',
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
position: 'absolute', inset: 50, borderRadius: '50%',
|
||||||
|
background: `radial-gradient(circle, ${color} 0%, ${color2} 55%, transparent 80%)`,
|
||||||
|
filter: 'blur(14px)',
|
||||||
|
boxShadow: `0 0 80px ${color}66, 0 0 40px ${color}44`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{/* Bright center dot */}
|
||||||
|
<motion.div
|
||||||
|
animate={{ scale: isIntense ? [1, 0.88, 1] : 1 }}
|
||||||
|
transition={{ duration: 0.8, repeat: Infinity, ease: 'easeInOut' }}
|
||||||
|
style={{
|
||||||
|
position: 'absolute', inset: 88, borderRadius: '50%',
|
||||||
|
background: `radial-gradient(circle, white 0%, ${color} 60%, transparent 100%)`,
|
||||||
|
filter: 'blur(6px)',
|
||||||
|
opacity: 0.9,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
14
lib/voice-bus.ts
Normal file
14
lib/voice-bus.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import { EventEmitter } from 'events'
|
||||||
|
|
||||||
|
export type VoiceEventPayload = {
|
||||||
|
event: 'wake' | 'command' | 'response' | 'idle' | 'error'
|
||||||
|
agent?: 'cosmo' | 'lusya'
|
||||||
|
text?: string
|
||||||
|
timestamp: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Singleton across hot-reloads in dev, preserved via global.
|
||||||
|
const globalForBus = globalThis as unknown as { __voiceBus?: EventEmitter }
|
||||||
|
export const voiceBus: EventEmitter = globalForBus.__voiceBus ?? new EventEmitter()
|
||||||
|
voiceBus.setMaxListeners(32)
|
||||||
|
if (!globalForBus.__voiceBus) globalForBus.__voiceBus = voiceBus
|
||||||
@@ -4,8 +4,8 @@ import type { NextRequest } from 'next/server'
|
|||||||
export async function middleware(request: NextRequest) {
|
export async function middleware(request: NextRequest) {
|
||||||
const { pathname } = request.nextUrl
|
const { pathname } = request.nextUrl
|
||||||
|
|
||||||
// Only protect API routes (except /api/auth)
|
// Only protect API routes (except /api/auth and /api/voice/event which has its own bearer auth)
|
||||||
if (!pathname.startsWith('/api/') || pathname.startsWith('/api/auth')) {
|
if (!pathname.startsWith('/api/') || pathname.startsWith('/api/auth') || pathname === '/api/voice/event') {
|
||||||
return NextResponse.next()
|
return NextResponse.next()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user