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:
64
app/api/voice/timer/route.ts
Normal file
64
app/api/voice/timer/route.ts
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
export const dynamic = 'force-dynamic'
|
||||||
|
export const runtime = 'nodejs'
|
||||||
|
|
||||||
|
import { NextResponse } from 'next/server'
|
||||||
|
import { voiceBus } from '@/lib/voice-bus'
|
||||||
|
import { addTimer, removeTimer, listActive } from '@/lib/timers'
|
||||||
|
|
||||||
|
function bearerOk(req: Request): boolean {
|
||||||
|
const expected = process.env.VOICE_API_KEY
|
||||||
|
if (!expected) return false
|
||||||
|
const auth = req.headers.get('authorization') || ''
|
||||||
|
const token = auth.replace(/^Bearer\s+/i, '').trim()
|
||||||
|
return token === expected
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(req: Request) {
|
||||||
|
// Browser (cookie auth via middleware) will reach here — listing is public to logged-in user.
|
||||||
|
// Script with bearer can also GET it.
|
||||||
|
return NextResponse.json({ timers: listActive() })
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(req: Request) {
|
||||||
|
if (!bearerOk(req)) {
|
||||||
|
return NextResponse.json({ error: 'unauthorized' }, { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await req.json().catch(() => null)
|
||||||
|
if (!body || typeof body.action !== 'string') {
|
||||||
|
return NextResponse.json({ error: 'action required' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (body.action === 'start') {
|
||||||
|
const seconds = Number(body.seconds)
|
||||||
|
const label = typeof body.label === 'string' ? body.label.slice(0, 80) : 'Таймер'
|
||||||
|
const agent = body.agent === 'lusya' ? 'lusya' : 'cosmo'
|
||||||
|
if (!Number.isFinite(seconds) || seconds < 1 || seconds > 24 * 3600) {
|
||||||
|
return NextResponse.json({ error: 'seconds must be 1..86400' }, { status: 400 })
|
||||||
|
}
|
||||||
|
const endsAt = new Date(Date.now() + seconds * 1000).toISOString()
|
||||||
|
const t = addTimer({ label, endsAt, agent })
|
||||||
|
voiceBus.emit('voice', {
|
||||||
|
event: 'timer_start',
|
||||||
|
timer: t,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
})
|
||||||
|
return NextResponse.json({ timer: t })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (body.action === 'cancel') {
|
||||||
|
const id = typeof body.id === 'string' ? body.id : ''
|
||||||
|
if (!id) return NextResponse.json({ error: 'id required' }, { status: 400 })
|
||||||
|
const ok = removeTimer(id)
|
||||||
|
if (ok) {
|
||||||
|
voiceBus.emit('voice', {
|
||||||
|
event: 'timer_cancel',
|
||||||
|
id,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return NextResponse.json({ cancelled: ok })
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ error: `unknown action: ${body.action}` }, { status: 400 })
|
||||||
|
}
|
||||||
29
app/api/voice/tools/events/route.ts
Normal file
29
app/api/voice/tools/events/route.ts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
export const dynamic = 'force-dynamic'
|
||||||
|
export const runtime = 'nodejs'
|
||||||
|
|
||||||
|
import { NextResponse } from 'next/server'
|
||||||
|
import { isBearerAuthorized, unauthorized } from '@/lib/voice-tools'
|
||||||
|
|
||||||
|
export async function GET(req: Request) {
|
||||||
|
if (!isBearerAuthorized(req)) return unauthorized()
|
||||||
|
|
||||||
|
const { searchParams } = new URL(req.url)
|
||||||
|
const range = searchParams.get('range') || 'today' // today | week
|
||||||
|
|
||||||
|
const baseUrl = `http://localhost:${process.env.PORT || '3000'}`
|
||||||
|
const r = await fetch(`${baseUrl}/api/calendar?range=${encodeURIComponent(range)}`, {
|
||||||
|
cache: 'no-store',
|
||||||
|
headers: { cookie: '' },
|
||||||
|
}).catch(() => null)
|
||||||
|
|
||||||
|
if (!r || !r.ok) return NextResponse.json({ events: [], error: 'unreachable' }, { status: 502 })
|
||||||
|
const j = await r.json()
|
||||||
|
const events = (j.events || []).map((e: any) => ({
|
||||||
|
title: e.title,
|
||||||
|
start: e.start,
|
||||||
|
end: e.end,
|
||||||
|
all_day: e.allDay,
|
||||||
|
owner: e.ownerName || e.owner,
|
||||||
|
}))
|
||||||
|
return NextResponse.json({ events })
|
||||||
|
}
|
||||||
30
app/api/voice/tools/notes/route.ts
Normal file
30
app/api/voice/tools/notes/route.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
export const dynamic = 'force-dynamic'
|
||||||
|
export const runtime = 'nodejs'
|
||||||
|
|
||||||
|
import { NextResponse } from 'next/server'
|
||||||
|
import { isBearerAuthorized, unauthorized } from '@/lib/voice-tools'
|
||||||
|
|
||||||
|
export async function GET(req: Request) {
|
||||||
|
if (!isBearerAuthorized(req)) return unauthorized()
|
||||||
|
|
||||||
|
const baseUrl = `http://localhost:${process.env.PORT || '3000'}`
|
||||||
|
const r = await fetch(`${baseUrl}/api/notes`, {
|
||||||
|
cache: 'no-store',
|
||||||
|
headers: { cookie: '' },
|
||||||
|
}).catch(() => null)
|
||||||
|
|
||||||
|
if (!r || !r.ok) return NextResponse.json({ notes: [] }, { status: 502 })
|
||||||
|
const j = await r.json()
|
||||||
|
// Strip some fields to keep payload small for LLM context
|
||||||
|
const notes = (j.notes || []).slice(0, 10).map((n: any) => ({
|
||||||
|
id: n.id,
|
||||||
|
type: n.type,
|
||||||
|
title: n.title,
|
||||||
|
pin_date: n.pinDate,
|
||||||
|
items: n.type === 'shopping'
|
||||||
|
? (n.items || []).map((i: any) => ({ text: i.text, done: !!i.done }))
|
||||||
|
: undefined,
|
||||||
|
text: n.type === 'note' ? (n.text || '').slice(0, 500) : undefined,
|
||||||
|
}))
|
||||||
|
return NextResponse.json({ notes })
|
||||||
|
}
|
||||||
58
app/api/voice/tools/transport/route.ts
Normal file
58
app/api/voice/tools/transport/route.ts
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
export const dynamic = 'force-dynamic'
|
||||||
|
export const runtime = 'nodejs'
|
||||||
|
|
||||||
|
import { NextResponse } from 'next/server'
|
||||||
|
import { isBearerAuthorized, unauthorized } from '@/lib/voice-tools'
|
||||||
|
|
||||||
|
// Hardcoded for now — same as TransportWidget. Future: read from /data/tablet-config.json.
|
||||||
|
const STOPS: Record<string, { id: string; name: string; direction: string }> = {
|
||||||
|
to_center: { id: '16226', name: 'Ул. Антонова-Овсеенко', direction: 'в центр (к Новочеркасской)' },
|
||||||
|
from_center: { id: '16354', name: 'Ул. Антонова-Овсеенко', direction: 'от центра (к Большевиков)' },
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(req: Request) {
|
||||||
|
if (!isBearerAuthorized(req)) return unauthorized()
|
||||||
|
|
||||||
|
const { searchParams } = new URL(req.url)
|
||||||
|
const dirRaw = (searchParams.get('direction') || 'all').toLowerCase()
|
||||||
|
const routesRaw = searchParams.get('routes') || '' // comma-separated
|
||||||
|
|
||||||
|
const dirsToQuery: { id: string; name: string; direction: string }[] =
|
||||||
|
dirRaw === 'to_center' ? [STOPS.to_center] :
|
||||||
|
dirRaw === 'from_center' ? [STOPS.from_center] :
|
||||||
|
[STOPS.to_center, STOPS.from_center]
|
||||||
|
|
||||||
|
const routeFilter = new Set(
|
||||||
|
routesRaw.split(',').map(r => r.trim()).filter(Boolean)
|
||||||
|
)
|
||||||
|
|
||||||
|
const baseUrl = `http://localhost:${process.env.PORT || '3000'}`
|
||||||
|
|
||||||
|
const results = await Promise.all(
|
||||||
|
dirsToQuery.map(async (d) => {
|
||||||
|
const r = await fetch(`${baseUrl}/api/transport?stopId=${d.id}`, {
|
||||||
|
cache: 'no-store',
|
||||||
|
headers: { cookie: '' },
|
||||||
|
}).catch(() => null)
|
||||||
|
if (!r || !r.ok) return { direction: d.direction, stop_id: d.id, arrivals: [] }
|
||||||
|
const j = await r.json()
|
||||||
|
let arrivals = (j.arrivals || []) as Array<{ route: string; minutes: number; wheelchair?: boolean }>
|
||||||
|
if (routeFilter.size > 0) {
|
||||||
|
arrivals = arrivals.filter(a => routeFilter.has(a.route))
|
||||||
|
}
|
||||||
|
arrivals = arrivals.sort((a, b) => a.minutes - b.minutes).slice(0, 5)
|
||||||
|
return {
|
||||||
|
direction: d.direction,
|
||||||
|
stop_id: d.id,
|
||||||
|
stop_name: d.name,
|
||||||
|
arrivals: arrivals.map(a => ({
|
||||||
|
route: a.route,
|
||||||
|
minutes: a.minutes,
|
||||||
|
wheelchair: !!a.wheelchair,
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
return NextResponse.json({ results })
|
||||||
|
}
|
||||||
84
app/api/voice/tools/weather/route.ts
Normal file
84
app/api/voice/tools/weather/route.ts
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
export const dynamic = 'force-dynamic'
|
||||||
|
export const runtime = 'nodejs'
|
||||||
|
|
||||||
|
import { NextResponse } from 'next/server'
|
||||||
|
import { isBearerAuthorized, unauthorized } from '@/lib/voice-tools'
|
||||||
|
|
||||||
|
const CITIES: Record<string, { name: string; lat: string; lon: string }> = {
|
||||||
|
spb: { name: 'Санкт-Петербург', lat: '59.9343', lon: '30.3351' },
|
||||||
|
msk: { name: 'Москва', lat: '55.7558', lon: '37.6173' },
|
||||||
|
nsk: { name: 'Новосибирск', lat: '55.0084', lon: '82.9357' },
|
||||||
|
ekb: { name: 'Екатеринбург', lat: '56.8389', lon: '60.6057' },
|
||||||
|
kzn: { name: 'Казань', lat: '55.7887', lon: '49.1221' },
|
||||||
|
sochi: { name: 'Сочи', lat: '43.5855', lon: '39.7231' },
|
||||||
|
krd: { name: 'Краснодар', lat: '45.0355', lon: '38.9753' },
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveCity(raw: string | null): { lat: string; lon: string; name: string } {
|
||||||
|
if (!raw) return CITIES.spb
|
||||||
|
const q = raw.toLowerCase().trim()
|
||||||
|
for (const c of Object.values(CITIES)) {
|
||||||
|
if (c.name.toLowerCase().includes(q) || q.includes(c.name.toLowerCase())) return c
|
||||||
|
}
|
||||||
|
// common shorthands
|
||||||
|
if (q.includes('питер') || q.includes('спб') || q.includes('петерб')) return CITIES.spb
|
||||||
|
if (q.includes('москв') || q.includes('мск')) return CITIES.msk
|
||||||
|
if (q.includes('сочи')) return CITIES.sochi
|
||||||
|
if (q.includes('казан')) return CITIES.kzn
|
||||||
|
return CITIES.spb
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(req: Request) {
|
||||||
|
if (!isBearerAuthorized(req)) return unauthorized()
|
||||||
|
|
||||||
|
const { searchParams } = new URL(req.url)
|
||||||
|
const city = resolveCity(searchParams.get('city'))
|
||||||
|
|
||||||
|
// Call our own /api/weather internally — its code already knows Open-Meteo.
|
||||||
|
// Use loopback so we don't need auth forwarding; weather route doesn't require auth actually
|
||||||
|
// (it just reads query and calls upstream).
|
||||||
|
const baseUrl = `http://localhost:${process.env.PORT || '3000'}`
|
||||||
|
const r = await fetch(`${baseUrl}/api/weather?lat=${city.lat}&lon=${city.lon}`, {
|
||||||
|
cache: 'no-store',
|
||||||
|
headers: { cookie: '' }, // bypass middleware (we're public internally)
|
||||||
|
}).catch(() => null)
|
||||||
|
|
||||||
|
// Fallback: hit Open-Meteo directly if our own endpoint didn't respond
|
||||||
|
if (!r || !r.ok) {
|
||||||
|
const meteo = await fetch(
|
||||||
|
`https://api.open-meteo.com/v1/forecast?latitude=${city.lat}&longitude=${city.lon}` +
|
||||||
|
`¤t=temperature_2m,apparent_temperature,relative_humidity_2m,wind_speed_10m,weather_code` +
|
||||||
|
`&timezone=auto`,
|
||||||
|
{ cache: 'no-store' }
|
||||||
|
)
|
||||||
|
if (!meteo.ok) {
|
||||||
|
return NextResponse.json({ error: 'weather_unavailable' }, { status: 502 })
|
||||||
|
}
|
||||||
|
const j = await meteo.json()
|
||||||
|
const cur = j?.current || {}
|
||||||
|
return NextResponse.json({
|
||||||
|
city: city.name,
|
||||||
|
temp: Math.round(cur.temperature_2m ?? 0),
|
||||||
|
feels_like: Math.round(cur.apparent_temperature ?? 0),
|
||||||
|
humidity: Math.round(cur.relative_humidity_2m ?? 0),
|
||||||
|
wind_mps: Math.round(((cur.wind_speed_10m ?? 0) / 3.6) * 10) / 10,
|
||||||
|
weather_code: cur.weather_code,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const d = await r.json()
|
||||||
|
return NextResponse.json({
|
||||||
|
city: city.name,
|
||||||
|
temp: d.temp,
|
||||||
|
feels_like: d.feelsLike,
|
||||||
|
humidity: d.humidity,
|
||||||
|
wind: d.windSpeed,
|
||||||
|
desc: d.desc,
|
||||||
|
forecast: (d.forecast || []).slice(0, 5).map((day: any) => ({
|
||||||
|
date: day.date,
|
||||||
|
max: day.maxTemp,
|
||||||
|
min: day.minTemp,
|
||||||
|
desc: day.desc,
|
||||||
|
})),
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ import NotesTab from '@/components/NotesTab'
|
|||||||
import TransportWidget from '@/components/TransportWidget'
|
import TransportWidget from '@/components/TransportWidget'
|
||||||
import WeatherAnimation from '@/components/WeatherAnimation'
|
import WeatherAnimation from '@/components/WeatherAnimation'
|
||||||
import VoiceOverlay from '@/components/VoiceOverlay'
|
import VoiceOverlay from '@/components/VoiceOverlay'
|
||||||
|
import TimerWidget from '@/components/TimerWidget'
|
||||||
|
|
||||||
type Tab = 'home' | 'devices' | 'calendar' | 'notes' | 'settings'
|
type Tab = 'home' | 'devices' | 'calendar' | 'notes' | 'settings'
|
||||||
|
|
||||||
@@ -1109,6 +1110,7 @@ function HomePageInner() {
|
|||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
|
|
||||||
<VoiceOverlay />
|
<VoiceOverlay />
|
||||||
|
<TimerWidget />
|
||||||
|
|
||||||
<Sidebar active={tab} onChange={setTab} />
|
<Sidebar active={tab} onChange={setTab} />
|
||||||
|
|
||||||
|
|||||||
264
components/TimerWidget.tsx
Normal file
264
components/TimerWidget.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -12,16 +12,27 @@ interface VoiceEvent {
|
|||||||
timestamp: string
|
timestamp: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const AGENT_STYLE: Record<Agent, { primary: string; secondary: string; name: string; emoji: string }> = {
|
// Per-agent accent pair (inner core / outer halo). Минималистично, без имён.
|
||||||
cosmo: { primary: '#818cf8', secondary: '#a855f7', name: 'Cosmo', emoji: '🦞' },
|
const AGENT_COLORS: Record<Agent, { core: string; halo: string }> = {
|
||||||
lusya: { primary: '#ec4899', secondary: '#f43f5e', name: 'Люся', emoji: '👩' },
|
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() {
|
export default function VoiceOverlay() {
|
||||||
const [state, setState] = useState<VoiceState>('idle')
|
const [state, setState] = useState<VoiceState>('idle')
|
||||||
const [agent, setAgent] = useState<Agent>('cosmo')
|
const [agent, setAgent] = useState<Agent>('cosmo')
|
||||||
const [text, setText] = useState('')
|
const [text, setText] = useState('')
|
||||||
|
|
||||||
const dismissTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
|
const dismissTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||||
|
const audioRef = useRef<HTMLAudioElement | null>(null)
|
||||||
|
const audioUrlRef = useRef<string | null>(null)
|
||||||
|
|
||||||
const clearDismiss = () => {
|
const clearDismiss = () => {
|
||||||
if (dismissTimer.current) {
|
if (dismissTimer.current) {
|
||||||
@@ -34,9 +45,6 @@ export default function VoiceOverlay() {
|
|||||||
dismissTimer.current = setTimeout(() => setState('idle'), ms)
|
dismissTimer.current = setTimeout(() => setState('idle'), ms)
|
||||||
}
|
}
|
||||||
|
|
||||||
const audioRef = useRef<HTMLAudioElement | null>(null)
|
|
||||||
const audioUrlRef = useRef<string | null>(null)
|
|
||||||
|
|
||||||
const stopAudio = () => {
|
const stopAudio = () => {
|
||||||
if (audioRef.current) {
|
if (audioRef.current) {
|
||||||
try {
|
try {
|
||||||
@@ -60,10 +68,7 @@ export default function VoiceOverlay() {
|
|||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ text: textToSpeak, agent: agentId }),
|
body: JSON.stringify({ text: textToSpeak, agent: agentId }),
|
||||||
})
|
})
|
||||||
if (!r.ok) {
|
if (!r.ok) return
|
||||||
console.warn('TTS endpoint error:', r.status)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const blob = await r.blob()
|
const blob = await r.blob()
|
||||||
const url = URL.createObjectURL(blob)
|
const url = URL.createObjectURL(blob)
|
||||||
audioUrlRef.current = url
|
audioUrlRef.current = url
|
||||||
@@ -75,12 +80,8 @@ export default function VoiceOverlay() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
audioRef.current = audio
|
audioRef.current = audio
|
||||||
await audio.play().catch(err => {
|
await audio.play().catch(() => {})
|
||||||
console.warn('Audio autoplay blocked:', err)
|
} catch {}
|
||||||
})
|
|
||||||
} catch (err) {
|
|
||||||
console.warn('TTS fetch failed:', err)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -98,7 +99,6 @@ export default function VoiceOverlay() {
|
|||||||
if (evt.agent) setAgent(evt.agent)
|
if (evt.agent) setAgent(evt.agent)
|
||||||
|
|
||||||
if (evt.event === 'wake') {
|
if (evt.event === 'wake') {
|
||||||
// Barge-in: cut any ongoing TTS when user speaks again
|
|
||||||
stopAudio()
|
stopAudio()
|
||||||
setState('wake')
|
setState('wake')
|
||||||
setText('')
|
setText('')
|
||||||
@@ -140,12 +140,12 @@ export default function VoiceOverlay() {
|
|||||||
if (retry) clearTimeout(retry)
|
if (retry) clearTimeout(retry)
|
||||||
es?.close()
|
es?.close()
|
||||||
}
|
}
|
||||||
// agent is intentionally omitted — we always read from ref via the evt
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const isActive = state !== 'idle'
|
const isActive = state !== 'idle'
|
||||||
const style = AGENT_STYLE[agent]
|
const colors = AGENT_COLORS[agent]
|
||||||
|
const status = state !== 'idle' ? STATUS_LABEL[state] : ''
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
@@ -157,100 +157,112 @@ export default function VoiceOverlay() {
|
|||||||
transition={{ duration: 0.35 }}
|
transition={{ duration: 0.35 }}
|
||||||
style={{
|
style={{
|
||||||
position: 'fixed', inset: 0, zIndex: 300,
|
position: 'fixed', inset: 0, zIndex: 300,
|
||||||
background: 'rgba(5, 5, 15, 0.78)',
|
background: 'rgba(5, 5, 15, 0.82)',
|
||||||
backdropFilter: 'blur(24px)',
|
backdropFilter: 'blur(28px)',
|
||||||
WebkitBackdropFilter: 'blur(24px)' as any,
|
WebkitBackdropFilter: 'blur(28px)' as any,
|
||||||
display: 'flex', flexDirection: 'column',
|
display: 'flex', flexDirection: 'column',
|
||||||
alignItems: 'center', justifyContent: 'center',
|
alignItems: 'center', justifyContent: 'center',
|
||||||
gap: 36, padding: 40,
|
gap: 30, padding: 40,
|
||||||
pointerEvents: 'none',
|
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 }}>
|
{/* Subtle status (только "слушаю" — для остальных текст сам говорит за себя) */}
|
||||||
<div style={{
|
{status && (
|
||||||
fontSize: 12, color: 'rgba(255,255,255,0.45)', fontWeight: 700,
|
<motion.div
|
||||||
letterSpacing: '0.22em', textTransform: 'uppercase', marginBottom: 14,
|
key={state}
|
||||||
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 10,
|
initial={{ opacity: 0, y: -4 }}
|
||||||
}}>
|
animate={{ opacity: 0.55, y: 0 }}
|
||||||
<span style={{ fontSize: 18 }}>{style.emoji}</span>
|
transition={{ duration: 0.3 }}
|
||||||
{style.name}
|
style={{
|
||||||
{state !== 'wake' && (
|
fontSize: 13, color: 'rgba(255,255,255,0.6)',
|
||||||
<span style={{
|
fontWeight: 600, letterSpacing: '0.15em',
|
||||||
display: 'inline-block', width: 6, height: 6, borderRadius: '50%',
|
textTransform: 'uppercase',
|
||||||
background: style.primary,
|
}}
|
||||||
marginLeft: 4,
|
>
|
||||||
}} />
|
{status}
|
||||||
)}
|
</motion.div>
|
||||||
<span style={{ letterSpacing: '0.1em' }}>
|
)}
|
||||||
{state === 'wake' ? '· слушает' : state === 'command' ? '· распознал' : state === 'response' ? '· отвечает' : state === 'error' ? '· ошибка' : ''}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style={{
|
{/* Текст — распознанный / ответ */}
|
||||||
fontSize: state === 'wake' ? 36 : 26,
|
{text && (
|
||||||
fontWeight: 700,
|
<motion.div
|
||||||
color: state === 'error' ? '#fca5a5' : 'rgba(255,255,255,0.96)',
|
key={text.slice(0, 40)}
|
||||||
letterSpacing: '-0.5px', lineHeight: 1.35,
|
initial={{ opacity: 0, y: 8 }}
|
||||||
minHeight: 48,
|
animate={{ opacity: 1, y: 0 }}
|
||||||
}}>
|
transition={{ duration: 0.35 }}
|
||||||
{state === 'wake' ? 'Слушаю…' : (text || '…')}
|
style={{
|
||||||
</div>
|
maxWidth: 760, textAlign: 'center',
|
||||||
</div>
|
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>
|
</motion.div>
|
||||||
)}
|
)}
|
||||||
</AnimatePresence>
|
</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 isIntense = state === 'wake'
|
||||||
|
const isResponding = state === 'response'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ position: 'relative', width: 220, height: 220 }}>
|
<div style={{ position: 'relative', width: 240, height: 240 }}>
|
||||||
{/* Outer pulsing ring */}
|
{/* Outer halo — медленное дыхание */}
|
||||||
<motion.div
|
<motion.div
|
||||||
animate={{
|
animate={{
|
||||||
scale: isIntense ? [1, 1.25, 1] : [1, 1.08, 1],
|
scale: isIntense ? [1, 1.2, 1] : [1, 1.08, 1],
|
||||||
opacity: isIntense ? [0.5, 0.15, 0.5] : [0.35, 0.1, 0.35],
|
opacity: isIntense ? [0.55, 0.2, 0.55] : [0.35, 0.15, 0.35],
|
||||||
}}
|
}}
|
||||||
transition={{
|
transition={{
|
||||||
duration: isIntense ? 1.4 : 3,
|
duration: isIntense ? 1.6 : 3.2,
|
||||||
repeat: Infinity,
|
repeat: Infinity,
|
||||||
ease: 'easeInOut',
|
ease: 'easeInOut',
|
||||||
}}
|
}}
|
||||||
style={{
|
style={{
|
||||||
position: 'absolute', inset: 0, borderRadius: '50%',
|
position: 'absolute', inset: 0, borderRadius: '50%',
|
||||||
background: `radial-gradient(circle, ${color}55 0%, transparent 70%)`,
|
background: `radial-gradient(circle, ${halo}55 0%, transparent 72%)`,
|
||||||
filter: 'blur(24px)',
|
filter: 'blur(32px)',
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
{/* Inner core */}
|
{/* Inner ring — быстрее, с подкрученным blur */}
|
||||||
<motion.div
|
<motion.div
|
||||||
animate={{
|
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={{
|
transition={{
|
||||||
duration: 1.2,
|
duration: 1.3,
|
||||||
repeat: Infinity,
|
repeat: Infinity,
|
||||||
ease: 'easeInOut',
|
ease: 'easeInOut',
|
||||||
}}
|
}}
|
||||||
style={{
|
style={{
|
||||||
position: 'absolute', inset: 50, borderRadius: '50%',
|
position: 'absolute', inset: 40, borderRadius: '50%',
|
||||||
background: `radial-gradient(circle, ${color} 0%, ${color2} 55%, transparent 80%)`,
|
background: `radial-gradient(circle at 40% 30%, ${core} 0%, ${halo} 60%, transparent 85%)`,
|
||||||
filter: 'blur(14px)',
|
filter: 'blur(16px)',
|
||||||
boxShadow: `0 0 80px ${color}66, 0 0 40px ${color}44`,
|
boxShadow: `0 0 80px ${halo}66, 0 0 40px ${core}55`,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
{/* Bright center dot */}
|
{/* Bright core — тонкий highlight */}
|
||||||
<motion.div
|
<motion.div
|
||||||
animate={{ scale: isIntense ? [1, 0.88, 1] : 1 }}
|
animate={{
|
||||||
transition={{ duration: 0.8, repeat: Infinity, ease: 'easeInOut' }}
|
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={{
|
style={{
|
||||||
position: 'absolute', inset: 88, borderRadius: '50%',
|
position: 'absolute', inset: 90, borderRadius: '50%',
|
||||||
background: `radial-gradient(circle, white 0%, ${color} 60%, transparent 100%)`,
|
background: `radial-gradient(circle at 45% 35%, rgba(255,255,255,0.9) 0%, ${core} 50%, transparent 100%)`,
|
||||||
filter: 'blur(6px)',
|
filter: 'blur(8px)',
|
||||||
opacity: 0.9,
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
57
lib/timers.ts
Normal file
57
lib/timers.ts
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
import * as fs from 'fs'
|
||||||
|
import * as path from 'path'
|
||||||
|
|
||||||
|
const DATA_DIR = fs.existsSync('/data') ? '/data' : '/tmp'
|
||||||
|
const TIMERS_PATH = path.join(DATA_DIR, 'tablet-timers.json')
|
||||||
|
|
||||||
|
export interface Timer {
|
||||||
|
id: string
|
||||||
|
label: string
|
||||||
|
startedAt: string // ISO
|
||||||
|
endsAt: string // ISO
|
||||||
|
agent?: 'cosmo' | 'lusya'
|
||||||
|
}
|
||||||
|
|
||||||
|
function load(): Timer[] {
|
||||||
|
try {
|
||||||
|
if (fs.existsSync(TIMERS_PATH)) {
|
||||||
|
return JSON.parse(fs.readFileSync(TIMERS_PATH, 'utf-8'))
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
function save(list: Timer[]) {
|
||||||
|
try {
|
||||||
|
fs.writeFileSync(TIMERS_PATH, JSON.stringify(list, null, 2))
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mutative helpers, used by timer API route
|
||||||
|
export function listActive(): Timer[] {
|
||||||
|
const now = Date.now()
|
||||||
|
// Drop any that expired over 30 min ago — stale garbage
|
||||||
|
const list = load().filter(t => new Date(t.endsAt).getTime() > now - 30 * 60 * 1000)
|
||||||
|
save(list)
|
||||||
|
return list
|
||||||
|
}
|
||||||
|
|
||||||
|
export function addTimer(t: Omit<Timer, 'id' | 'startedAt'>): Timer {
|
||||||
|
const full: Timer = {
|
||||||
|
id: Date.now().toString(36) + Math.random().toString(36).slice(2, 6),
|
||||||
|
startedAt: new Date().toISOString(),
|
||||||
|
...t,
|
||||||
|
}
|
||||||
|
const list = load()
|
||||||
|
list.push(full)
|
||||||
|
save(list)
|
||||||
|
return full
|
||||||
|
}
|
||||||
|
|
||||||
|
export function removeTimer(id: string): boolean {
|
||||||
|
const list = load()
|
||||||
|
const next = list.filter(t => t.id !== id)
|
||||||
|
if (next.length === list.length) return false
|
||||||
|
save(next)
|
||||||
|
return true
|
||||||
|
}
|
||||||
19
lib/voice-tools.ts
Normal file
19
lib/voice-tools.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
/**
|
||||||
|
* Helper для /api/voice/tools/* — общий bearer-check и forwarding к внутренним endpoint'ам.
|
||||||
|
* Позволяет голосовому скрипту вызывать tools через один и тот же токен (VOICE_API_KEY).
|
||||||
|
*/
|
||||||
|
|
||||||
|
export function isBearerAuthorized(req: Request): boolean {
|
||||||
|
const expected = process.env.VOICE_API_KEY
|
||||||
|
if (!expected) return false
|
||||||
|
const auth = req.headers.get('authorization') || ''
|
||||||
|
const token = auth.replace(/^Bearer\s+/i, '').trim()
|
||||||
|
return token === expected
|
||||||
|
}
|
||||||
|
|
||||||
|
export function unauthorized() {
|
||||||
|
return new Response(JSON.stringify({ error: 'unauthorized' }), {
|
||||||
|
status: 401,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -4,8 +4,13 @@ import type { NextRequest } from 'next/server'
|
|||||||
export async function middleware(request: NextRequest) {
|
export async function middleware(request: NextRequest) {
|
||||||
const { pathname } = request.nextUrl
|
const { pathname } = request.nextUrl
|
||||||
|
|
||||||
// Only protect API routes (except /api/auth and /api/voice/event which has its own bearer auth)
|
// Only protect API routes. /api/voice/event, /api/voice/tools/*, /api/voice/timer
|
||||||
if (!pathname.startsWith('/api/') || pathname.startsWith('/api/auth') || pathname === '/api/voice/event') {
|
// have their own bearer-token auth (VOICE_API_KEY) and bypass the cookie check.
|
||||||
|
const isVoiceBearer =
|
||||||
|
pathname === '/api/voice/event' ||
|
||||||
|
pathname.startsWith('/api/voice/tools/') ||
|
||||||
|
pathname === '/api/voice/timer'
|
||||||
|
if (!pathname.startsWith('/api/') || pathname.startsWith('/api/auth') || isVoiceBearer) {
|
||||||
return NextResponse.next()
|
return NextResponse.next()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user