diff --git a/app/api/voice/event/route.ts b/app/api/voice/event/route.ts new file mode 100644 index 0000000..7a4e8db --- /dev/null +++ b/app/api/voice/event/route.ts @@ -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 }) +} diff --git a/app/api/voice/stream/route.ts b/app/api/voice/stream/route.ts new file mode 100644 index 0000000..ec41ec0 --- /dev/null +++ b/app/api/voice/stream/route.ts @@ -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', + }, + }) +} diff --git a/app/page.tsx b/app/page.tsx index dca5107..30cec75 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -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() { )} + + {/* Night-shift warm tint overlay */} diff --git a/components/VoiceOverlay.tsx b/components/VoiceOverlay.tsx new file mode 100644 index 0000000..5dd141c --- /dev/null +++ b/components/VoiceOverlay.tsx @@ -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 = { + cosmo: { primary: '#818cf8', secondary: '#a855f7', name: 'Cosmo', emoji: '🦞' }, + lusya: { primary: '#ec4899', secondary: '#f43f5e', name: 'Люся', emoji: '👩' }, +} + +export default function VoiceOverlay() { + const [state, setState] = useState('idle') + const [agent, setAgent] = useState('cosmo') + const [text, setText] = useState('') + const dismissTimer = useRef | 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 | 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 ( + + {isActive && ( + + + +
+
+ {style.emoji} + {style.name} + {state !== 'wake' && ( + + )} + + {state === 'wake' ? '· слушает' : state === 'command' ? '· распознал' : state === 'response' ? '· отвечает' : state === 'error' ? '· ошибка' : ''} + +
+ +
+ {state === 'wake' ? 'Слушаю…' : (text || '…')} +
+
+
+ )} +
+ ) +} + +function SiriBlob({ color, color2, state }: { color: string; color2: string; state: VoiceState }) { + const isIntense = state === 'wake' + return ( +
+ {/* Outer pulsing ring */} + + {/* Inner core */} + + {/* Bright center dot */} + +
+ ) +} diff --git a/lib/voice-bus.ts b/lib/voice-bus.ts new file mode 100644 index 0000000..4920196 --- /dev/null +++ b/lib/voice-bus.ts @@ -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 diff --git a/middleware.ts b/middleware.ts index d7af8ea..a35f75b 100644 --- a/middleware.ts +++ b/middleware.ts @@ -4,8 +4,8 @@ import type { NextRequest } from 'next/server' export async function middleware(request: NextRequest) { const { pathname } = request.nextUrl - // Only protect API routes (except /api/auth) - if (!pathname.startsWith('/api/') || pathname.startsWith('/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') || pathname === '/api/voice/event') { return NextResponse.next() }