Files
smart-home-tablet/components/VoiceOverlay.tsx
Cosmo e96e7a1342
All checks were successful
Deploy / deploy (push) Successful in 3m18s
feat(voice): tool endpoints, timer widget, clean Siri-style overlay
Adds the infrastructure for Claude tool use + visual timer.

Tablet API surface (all bearer-authed with VOICE_API_KEY, middleware bypassed):
- /api/voice/tools/weather    — current + short forecast via Open-Meteo
- /api/voice/tools/transport  — tram arrivals by direction / route filter
- /api/voice/tools/events     — Google Calendar today/week
- /api/voice/tools/notes      — notes + shopping lists
- /api/voice/timer            — start (with seconds+label), cancel; GET list (cookie ok)
  Active timers persisted at /data/tablet-timers.json

UI:
- VoiceOverlay stripped to minimal Siri look: no agent emoji/name, just the
  pulsing orb (3-layer radial gradient, independent breath animations),
  subtle status label on wake only, transcription/response text centered.
  Agents distinguished by orb color (Cosmo indigo/violet, Люся pink).
- TimerWidget: bottom-right chip stack with countdown, progress bar, turns
  amber in last 10s. On expiry, fires fullscreen alarm overlay with beep
  (WebAudio osc) + Остановить button.

Other:
- lib/timers.ts — persistent timer store in /data
- lib/voice-tools.ts — shared bearer-auth helper
- middleware — bypass list now covers /api/voice/tools/* and /api/voice/timer
2026-04-23 13:33:31 +00:00

271 lines
8.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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
}
// Per-agent accent pair (inner core / outer halo). Минималистично, без имён.
const AGENT_COLORS: Record<Agent, { core: string; halo: string }> = {
cosmo: { core: '#a5b4fc', halo: '#7c3aed' },
lusya: { core: '#fbcfe8', halo: '#ec4899' },
}
const STATUS_LABEL: Record<Exclude<VoiceState, 'idle'>, string> = {
wake: 'слушаю',
command: '',
response: '',
error: '',
}
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 audioRef = useRef<HTMLAudioElement | null>(null)
const audioUrlRef = useRef<string | null>(null)
const clearDismiss = () => {
if (dismissTimer.current) {
clearTimeout(dismissTimer.current)
dismissTimer.current = null
}
}
const scheduleDismiss = (ms: number) => {
clearDismiss()
dismissTimer.current = setTimeout(() => setState('idle'), ms)
}
const stopAudio = () => {
if (audioRef.current) {
try {
audioRef.current.pause()
audioRef.current.src = ''
} catch {}
audioRef.current = null
}
if (audioUrlRef.current) {
URL.revokeObjectURL(audioUrlRef.current)
audioUrlRef.current = null
}
}
const playTTS = async (textToSpeak: string, agentId: Agent) => {
stopAudio()
if (!textToSpeak) return
try {
const r = await fetch('/api/voice/tts', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text: textToSpeak, agent: agentId }),
})
if (!r.ok) return
const blob = await r.blob()
const url = URL.createObjectURL(blob)
audioUrlRef.current = url
const audio = new Audio(url)
audio.onended = () => {
if (audioUrlRef.current === url) {
URL.revokeObjectURL(url)
audioUrlRef.current = null
}
}
audioRef.current = audio
await audio.play().catch(() => {})
} catch {}
}
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)
const currentAgent: Agent = evt.agent ?? agent
if (evt.agent) setAgent(evt.agent)
if (evt.event === 'wake') {
stopAudio()
setState('wake')
setText('')
scheduleDismiss(20000)
} else if (evt.event === 'command') {
setState('command')
setText(evt.text || '')
scheduleDismiss(30000)
} else if (evt.event === 'response') {
setState('response')
setText(evt.text || '')
if (evt.text) playTTS(evt.text, currentAgent)
scheduleDismiss(Math.max(6000, (evt.text?.length || 0) * 80))
} else if (evt.event === 'error') {
setState('error')
setText(evt.text || 'Ошибка')
if (evt.text) playTTS(evt.text, currentAgent)
scheduleDismiss(5000)
} 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()
stopAudio()
if (retry) clearTimeout(retry)
es?.close()
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
const isActive = state !== 'idle'
const colors = AGENT_COLORS[agent]
const status = state !== 'idle' ? STATUS_LABEL[state] : ''
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.82)',
backdropFilter: 'blur(28px)',
WebkitBackdropFilter: 'blur(28px)' as any,
display: 'flex', flexDirection: 'column',
alignItems: 'center', justifyContent: 'center',
gap: 30, padding: 40,
pointerEvents: 'none',
}}
>
<SiriOrb core={colors.core} halo={colors.halo} state={state} />
{/* Subtle status (только "слушаю" — для остальных текст сам говорит за себя) */}
{status && (
<motion.div
key={state}
initial={{ opacity: 0, y: -4 }}
animate={{ opacity: 0.55, y: 0 }}
transition={{ duration: 0.3 }}
style={{
fontSize: 13, color: 'rgba(255,255,255,0.6)',
fontWeight: 600, letterSpacing: '0.15em',
textTransform: 'uppercase',
}}
>
{status}
</motion.div>
)}
{/* Текст — распознанный / ответ */}
{text && (
<motion.div
key={text.slice(0, 40)}
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.35 }}
style={{
maxWidth: 760, textAlign: 'center',
fontSize: state === 'command' ? 20 : 24,
fontWeight: 500,
color: state === 'error' ? '#fca5a5' :
state === 'command' ? 'rgba(255,255,255,0.55)' :
'rgba(255,255,255,0.95)',
letterSpacing: '-0.3px', lineHeight: 1.4,
}}
>
{text}
</motion.div>
)}
</motion.div>
)}
</AnimatePresence>
)
}
function SiriOrb({ core, halo, state }: { core: string; halo: string; state: VoiceState }) {
const isIntense = state === 'wake'
const isResponding = state === 'response'
return (
<div style={{ position: 'relative', width: 240, height: 240 }}>
{/* Outer halo — медленное дыхание */}
<motion.div
animate={{
scale: isIntense ? [1, 1.2, 1] : [1, 1.08, 1],
opacity: isIntense ? [0.55, 0.2, 0.55] : [0.35, 0.15, 0.35],
}}
transition={{
duration: isIntense ? 1.6 : 3.2,
repeat: Infinity,
ease: 'easeInOut',
}}
style={{
position: 'absolute', inset: 0, borderRadius: '50%',
background: `radial-gradient(circle, ${halo}55 0%, transparent 72%)`,
filter: 'blur(32px)',
}}
/>
{/* Inner ring — быстрее, с подкрученным blur */}
<motion.div
animate={{
scale: isIntense ? [1, 1.1, 1] : isResponding ? [1, 1.04, 1] : 1,
rotate: isIntense ? [0, 10, -8, 0] : 0,
}}
transition={{
duration: 1.3,
repeat: Infinity,
ease: 'easeInOut',
}}
style={{
position: 'absolute', inset: 40, borderRadius: '50%',
background: `radial-gradient(circle at 40% 30%, ${core} 0%, ${halo} 60%, transparent 85%)`,
filter: 'blur(16px)',
boxShadow: `0 0 80px ${halo}66, 0 0 40px ${core}55`,
}}
/>
{/* Bright core — тонкий highlight */}
<motion.div
animate={{
scale: isIntense ? [1, 0.85, 1] : 1,
opacity: isIntense ? [0.9, 0.7, 0.9] : 0.85,
}}
transition={{ duration: 0.9, repeat: Infinity, ease: 'easeInOut' }}
style={{
position: 'absolute', inset: 90, borderRadius: '50%',
background: `radial-gradient(circle at 45% 35%, rgba(255,255,255,0.9) 0%, ${core} 50%, transparent 100%)`,
filter: 'blur(8px)',
}}
/>
</div>
)
}