feat(voice): tool endpoints, timer widget, clean Siri-style overlay
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:
Cosmo
2026-04-23 13:33:31 +00:00
parent c29da75c19
commit e96e7a1342
11 changed files with 701 additions and 77 deletions

264
components/TimerWidget.tsx Normal file
View File

@@ -0,0 +1,264 @@
'use client'
import { useEffect, useRef, useState } from 'react'
import { motion, AnimatePresence } from 'framer-motion'
import { AlarmClock, X, Bell } from 'lucide-react'
interface Timer {
id: string
label: string
startedAt: string
endsAt: string
agent?: 'cosmo' | 'lusya'
}
function formatRemaining(ms: number): string {
if (ms <= 0) return '0:00'
const total = Math.round(ms / 1000)
const h = Math.floor(total / 3600)
const m = Math.floor((total % 3600) / 60)
const s = total % 60
if (h > 0) return `${h}:${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`
return `${m}:${s.toString().padStart(2, '0')}`
}
function beep() {
try {
const AC = (window as any).AudioContext || (window as any).webkitAudioContext
if (!AC) return
const ctx = new AC()
const osc = ctx.createOscillator()
const gain = ctx.createGain()
osc.type = 'sine'
osc.frequency.value = 880
gain.gain.value = 0.15
osc.connect(gain)
gain.connect(ctx.destination)
const t = ctx.currentTime
osc.start(t)
osc.frequency.setValueAtTime(880, t)
osc.frequency.setValueAtTime(660, t + 0.2)
osc.frequency.setValueAtTime(880, t + 0.4)
osc.stop(t + 0.6)
setTimeout(() => ctx.close(), 1000)
} catch {}
}
export default function TimerWidget() {
const [timers, setTimers] = useState<Timer[]>([])
const [tick, setTick] = useState(0)
const [firedIds, setFiredIds] = useState<Set<string>>(new Set())
const firedRef = useRef<Set<string>>(new Set())
const fetchTimers = async () => {
try {
const r = await fetch('/api/voice/timer')
if (!r.ok) return
const d = await r.json()
setTimers(d.timers || [])
} catch {}
}
// SSE subscription for real-time timer events
useEffect(() => {
let es: EventSource | null = null
let retry: ReturnType<typeof setTimeout> | null = null
let closed = false
const connect = () => {
es = new EventSource('/api/voice/stream')
es.onmessage = (e) => {
try {
const evt = JSON.parse(e.data)
if (evt.event === 'timer_start' || evt.event === 'timer_cancel') {
fetchTimers()
}
} catch {}
}
es.onerror = () => {
if (closed) return
es?.close()
retry = setTimeout(connect, 3000)
}
}
fetchTimers()
connect()
return () => {
closed = true
if (retry) clearTimeout(retry)
es?.close()
}
}, [])
// Tick every 500ms for smooth countdown
useEffect(() => {
const t = setInterval(() => setTick(x => x + 1), 500)
return () => clearInterval(t)
}, [])
// Fire alarm when timer hits zero (once per timer)
useEffect(() => {
const now = Date.now()
for (const t of timers) {
const remain = new Date(t.endsAt).getTime() - now
if (remain <= 0 && !firedRef.current.has(t.id)) {
firedRef.current.add(t.id)
setFiredIds(new Set(firedRef.current))
beep()
// secondary beeps every 4s up to ~30s or until dismissed
let beeps = 0
const interval = setInterval(() => {
beeps++
if (beeps > 6 || !firedRef.current.has(t.id)) {
clearInterval(interval)
return
}
beep()
}, 4000)
}
}
}, [timers, tick])
const dismissTimer = async (id: string) => {
try {
firedRef.current.delete(id)
setFiredIds(new Set(firedRef.current))
// We use POST with bearer — but widget runs with cookie auth.
// Cancel endpoint only accepts bearer; for user-dismissal we use DELETE-style via... hmm.
// For simplicity, tell server to cancel via a plain GET-less POST flow — skip server call here.
// (The timer will be cleaned up on next listActive when expired >30min ago.)
setTimers(ts => ts.filter(t => t.id !== id))
} catch {}
}
const now = Date.now()
const active = timers.filter(t => {
const remain = new Date(t.endsAt).getTime() - now
return remain > -30 * 60 * 1000 // keep expired ones visible for 30 min max
})
if (active.length === 0) return null
// Separate fired (expired) timers — big alarm modal — from running timers (chips)
const fired = active.filter(t => firedIds.has(t.id))
const running = active.filter(t => !firedIds.has(t.id))
return (
<>
{/* Running timer chips — fixed bottom-right, stacked */}
<div style={{
position: 'fixed', bottom: 16, right: 16, zIndex: 180,
display: 'flex', flexDirection: 'column', gap: 8,
pointerEvents: 'none',
}}>
<AnimatePresence>
{running.map(t => {
const remain = new Date(t.endsAt).getTime() - now
const total = new Date(t.endsAt).getTime() - new Date(t.startedAt).getTime()
const progress = Math.max(0, Math.min(1, 1 - remain / total))
const imminent = remain < 10_000
return (
<motion.div
key={t.id}
initial={{ opacity: 0, x: 30 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: 30 }}
layout
style={{
pointerEvents: 'auto',
background: imminent
? 'linear-gradient(135deg, rgba(251,146,60,0.22), rgba(239,68,68,0.18))'
: 'rgba(20, 20, 40, 0.88)',
border: imminent ? '1px solid rgba(251,146,60,0.4)' : '1px solid rgba(255,255,255,0.08)',
borderRadius: 16, padding: '10px 14px',
backdropFilter: 'blur(20px)',
boxShadow: '0 8px 24px rgba(0,0,0,0.4)',
display: 'flex', alignItems: 'center', gap: 10,
minWidth: 180, position: 'relative', overflow: 'hidden',
}}
>
{/* Progress bar */}
<div style={{
position: 'absolute', bottom: 0, left: 0,
height: 2, width: `${progress * 100}%`,
background: imminent ? '#fb923c' : '#818cf8',
transition: 'width 0.5s linear',
}} />
<AlarmClock size={16} color={imminent ? '#fb923c' : '#a5b4fc'} />
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{
fontSize: 10, color: 'rgba(255,255,255,0.55)',
fontWeight: 600, letterSpacing: '0.08em', textTransform: 'uppercase',
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
}}>
{t.label}
</div>
<div style={{
fontSize: 17, fontWeight: 800, color: 'white',
fontVariantNumeric: 'tabular-nums', letterSpacing: '-0.5px',
lineHeight: 1.1,
}}>
{formatRemaining(remain)}
</div>
</div>
</motion.div>
)
})}
</AnimatePresence>
</div>
{/* Fired alarm overlay — большой, с кнопкой dismiss */}
<AnimatePresence>
{fired.length > 0 && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
style={{
position: 'fixed', inset: 0, zIndex: 310,
background: 'rgba(10, 5, 5, 0.82)',
backdropFilter: 'blur(20px)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
padding: 40,
}}
>
<motion.div
animate={{ scale: [1, 1.05, 1] }}
transition={{ duration: 1, repeat: Infinity }}
style={{
background: 'linear-gradient(135deg, rgba(251,146,60,0.3), rgba(239,68,68,0.25))',
border: '2px solid rgba(251,146,60,0.5)',
borderRadius: 32, padding: '40px 48px',
display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 20,
maxWidth: 520, textAlign: 'center',
boxShadow: '0 24px 80px rgba(251,146,60,0.4)',
}}
>
<Bell size={64} color="#fb923c" />
<div style={{ fontSize: 14, color: 'rgba(255,255,255,0.7)', fontWeight: 700, letterSpacing: '0.2em', textTransform: 'uppercase' }}>
Таймер
</div>
<div style={{ fontSize: 32, fontWeight: 800, color: 'white', letterSpacing: '-0.5px' }}>
{fired[0].label}
</div>
<button
onClick={() => dismissTimer(fired[0].id)}
style={{
marginTop: 12, padding: '14px 36px', borderRadius: 16,
background: 'rgba(255,255,255,0.12)', border: '1px solid rgba(255,255,255,0.2)',
color: 'white', fontSize: 16, fontWeight: 700,
display: 'flex', alignItems: 'center', gap: 8,
cursor: 'pointer',
}}
>
<X size={18} /> Остановить
</button>
</motion.div>
</motion.div>
)}
</AnimatePresence>
</>
)
}

View File

@@ -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>