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
51 lines
1.4 KiB
TypeScript
51 lines
1.4 KiB
TypeScript
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',
|
|
},
|
|
})
|
|
}
|