feat(voice): SSE bridge + Siri-blob overlay for wake-word script
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:
Cosmo
2026-04-23 12:36:26 +00:00
parent 9fec9bca99
commit 51c3d6016a
6 changed files with 307 additions and 2 deletions

View 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 })
}

View 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',
},
})
}

View File

@@ -11,6 +11,7 @@ import CalendarTab from '@/components/CalendarTab'
import NotesTab from '@/components/NotesTab'
import TransportWidget from '@/components/TransportWidget'
import WeatherAnimation from '@/components/WeatherAnimation'
import VoiceOverlay from '@/components/VoiceOverlay'
type Tab = 'home' | 'devices' | 'calendar' | 'notes' | 'settings'
@@ -1107,6 +1108,8 @@ function HomePageInner() {
)}
</AnimatePresence>
<VoiceOverlay />
<Sidebar active={tab} onChange={setTab} />
{/* Night-shift warm tint overlay */}