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 })
|
||||
}
|
||||
Reference in New Issue
Block a user