feat(voice): tool endpoints, timer widget, clean Siri-style overlay
All checks were successful
Deploy / deploy (push) Successful in 3m18s
All checks were successful
Deploy / deploy (push) Successful in 3m18s
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
This commit is contained in:
@@ -12,16 +12,27 @@ interface VoiceEvent {
|
||||
timestamp: string
|
||||
}
|
||||
|
||||
const AGENT_STYLE: Record<Agent, { primary: string; secondary: string; name: string; emoji: string }> = {
|
||||
cosmo: { primary: '#818cf8', secondary: '#a855f7', name: 'Cosmo', emoji: '🦞' },
|
||||
lusya: { primary: '#ec4899', secondary: '#f43f5e', name: 'Люся', emoji: '👩' },
|
||||
// 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) {
|
||||
@@ -34,9 +45,6 @@ export default function VoiceOverlay() {
|
||||
dismissTimer.current = setTimeout(() => setState('idle'), ms)
|
||||
}
|
||||
|
||||
const audioRef = useRef<HTMLAudioElement | null>(null)
|
||||
const audioUrlRef = useRef<string | null>(null)
|
||||
|
||||
const stopAudio = () => {
|
||||
if (audioRef.current) {
|
||||
try {
|
||||
@@ -60,10 +68,7 @@ export default function VoiceOverlay() {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ text: textToSpeak, agent: agentId }),
|
||||
})
|
||||
if (!r.ok) {
|
||||
console.warn('TTS endpoint error:', r.status)
|
||||
return
|
||||
}
|
||||
if (!r.ok) return
|
||||
const blob = await r.blob()
|
||||
const url = URL.createObjectURL(blob)
|
||||
audioUrlRef.current = url
|
||||
@@ -75,12 +80,8 @@ export default function VoiceOverlay() {
|
||||
}
|
||||
}
|
||||
audioRef.current = audio
|
||||
await audio.play().catch(err => {
|
||||
console.warn('Audio autoplay blocked:', err)
|
||||
})
|
||||
} catch (err) {
|
||||
console.warn('TTS fetch failed:', err)
|
||||
}
|
||||
await audio.play().catch(() => {})
|
||||
} catch {}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
@@ -98,7 +99,6 @@ export default function VoiceOverlay() {
|
||||
if (evt.agent) setAgent(evt.agent)
|
||||
|
||||
if (evt.event === 'wake') {
|
||||
// Barge-in: cut any ongoing TTS when user speaks again
|
||||
stopAudio()
|
||||
setState('wake')
|
||||
setText('')
|
||||
@@ -140,12 +140,12 @@ export default function VoiceOverlay() {
|
||||
if (retry) clearTimeout(retry)
|
||||
es?.close()
|
||||
}
|
||||
// agent is intentionally omitted — we always read from ref via the evt
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
|
||||
const isActive = state !== 'idle'
|
||||
const style = AGENT_STYLE[agent]
|
||||
const colors = AGENT_COLORS[agent]
|
||||
const status = state !== 'idle' ? STATUS_LABEL[state] : ''
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
@@ -157,100 +157,112 @@ export default function VoiceOverlay() {
|
||||
transition={{ duration: 0.35 }}
|
||||
style={{
|
||||
position: 'fixed', inset: 0, zIndex: 300,
|
||||
background: 'rgba(5, 5, 15, 0.78)',
|
||||
backdropFilter: 'blur(24px)',
|
||||
WebkitBackdropFilter: 'blur(24px)' as any,
|
||||
background: 'rgba(5, 5, 15, 0.82)',
|
||||
backdropFilter: 'blur(28px)',
|
||||
WebkitBackdropFilter: 'blur(28px)' as any,
|
||||
display: 'flex', flexDirection: 'column',
|
||||
alignItems: 'center', justifyContent: 'center',
|
||||
gap: 36, padding: 40,
|
||||
gap: 30, padding: 40,
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
>
|
||||
<SiriBlob color={style.primary} color2={style.secondary} state={state} />
|
||||
<SiriOrb core={colors.core} halo={colors.halo} state={state} />
|
||||
|
||||
<div style={{ textAlign: 'center', maxWidth: 760 }}>
|
||||
<div style={{
|
||||
fontSize: 12, color: 'rgba(255,255,255,0.45)', fontWeight: 700,
|
||||
letterSpacing: '0.22em', textTransform: 'uppercase', marginBottom: 14,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 10,
|
||||
}}>
|
||||
<span style={{ fontSize: 18 }}>{style.emoji}</span>
|
||||
{style.name}
|
||||
{state !== 'wake' && (
|
||||
<span style={{
|
||||
display: 'inline-block', width: 6, height: 6, borderRadius: '50%',
|
||||
background: style.primary,
|
||||
marginLeft: 4,
|
||||
}} />
|
||||
)}
|
||||
<span style={{ letterSpacing: '0.1em' }}>
|
||||
{state === 'wake' ? '· слушает' : state === 'command' ? '· распознал' : state === 'response' ? '· отвечает' : state === 'error' ? '· ошибка' : ''}
|
||||
</span>
|
||||
</div>
|
||||
{/* 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>
|
||||
)}
|
||||
|
||||
<div style={{
|
||||
fontSize: state === 'wake' ? 36 : 26,
|
||||
fontWeight: 700,
|
||||
color: state === 'error' ? '#fca5a5' : 'rgba(255,255,255,0.96)',
|
||||
letterSpacing: '-0.5px', lineHeight: 1.35,
|
||||
minHeight: 48,
|
||||
}}>
|
||||
{state === 'wake' ? 'Слушаю…' : (text || '…')}
|
||||
</div>
|
||||
</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 SiriBlob({ color, color2, state }: { color: string; color2: string; state: VoiceState }) {
|
||||
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: 220, height: 220 }}>
|
||||
{/* Outer pulsing ring */}
|
||||
<div style={{ position: 'relative', width: 240, height: 240 }}>
|
||||
{/* Outer halo — медленное дыхание */}
|
||||
<motion.div
|
||||
animate={{
|
||||
scale: isIntense ? [1, 1.25, 1] : [1, 1.08, 1],
|
||||
opacity: isIntense ? [0.5, 0.15, 0.5] : [0.35, 0.1, 0.35],
|
||||
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.4 : 3,
|
||||
duration: isIntense ? 1.6 : 3.2,
|
||||
repeat: Infinity,
|
||||
ease: 'easeInOut',
|
||||
}}
|
||||
style={{
|
||||
position: 'absolute', inset: 0, borderRadius: '50%',
|
||||
background: `radial-gradient(circle, ${color}55 0%, transparent 70%)`,
|
||||
filter: 'blur(24px)',
|
||||
background: `radial-gradient(circle, ${halo}55 0%, transparent 72%)`,
|
||||
filter: 'blur(32px)',
|
||||
}}
|
||||
/>
|
||||
{/* Inner core */}
|
||||
{/* Inner ring — быстрее, с подкрученным blur */}
|
||||
<motion.div
|
||||
animate={{
|
||||
scale: isIntense ? [1, 1.08, 1] : 1,
|
||||
scale: isIntense ? [1, 1.1, 1] : isResponding ? [1, 1.04, 1] : 1,
|
||||
rotate: isIntense ? [0, 10, -8, 0] : 0,
|
||||
}}
|
||||
transition={{
|
||||
duration: 1.2,
|
||||
duration: 1.3,
|
||||
repeat: Infinity,
|
||||
ease: 'easeInOut',
|
||||
}}
|
||||
style={{
|
||||
position: 'absolute', inset: 50, borderRadius: '50%',
|
||||
background: `radial-gradient(circle, ${color} 0%, ${color2} 55%, transparent 80%)`,
|
||||
filter: 'blur(14px)',
|
||||
boxShadow: `0 0 80px ${color}66, 0 0 40px ${color}44`,
|
||||
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 center dot */}
|
||||
{/* Bright core — тонкий highlight */}
|
||||
<motion.div
|
||||
animate={{ scale: isIntense ? [1, 0.88, 1] : 1 }}
|
||||
transition={{ duration: 0.8, repeat: Infinity, ease: 'easeInOut' }}
|
||||
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: 88, borderRadius: '50%',
|
||||
background: `radial-gradient(circle, white 0%, ${color} 60%, transparent 100%)`,
|
||||
filter: 'blur(6px)',
|
||||
opacity: 0.9,
|
||||
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>
|
||||
|
||||
Reference in New Issue
Block a user