diff --git a/app/api/voice/chat/route.ts b/app/api/voice/chat/route.ts index f939e93..ea175f0 100644 --- a/app/api/voice/chat/route.ts +++ b/app/api/voice/chat/route.ts @@ -18,6 +18,31 @@ import { const MODEL = process.env.ANTHROPIC_MODEL || 'claude-haiku-4-5' const MAX_TOKENS = parseInt(process.env.VOICE_MAX_TOKENS || '300', 10) const MAX_TOOL_ROUNDS = 4 +const RATE_LIMIT_PER_MINUTE = parseInt(process.env.VOICE_RATE_LIMIT || '20', 10) + +// In-memory rate-limit per IP / cookie (host один — Docker контейнер). +// Защита от случайного бесконечного цикла или утечки PIN: даже если +// auth_token утечёт, вызов /api/voice/chat будет ограничен. +const rateBuckets = new Map() +function rateLimit(key: string): boolean { + const now = Date.now() + const b = rateBuckets.get(key) + if (!b || b.resetAt <= now) { + rateBuckets.set(key, { count: 1, resetAt: now + 60_000 }) + return true + } + if (b.count >= RATE_LIMIT_PER_MINUTE) return false + b.count++ + return true +} +// Гигиена: чистим старые бакеты периодически (раз в 5 минут максимум). +let lastSweep = 0 +function sweep() { + const now = Date.now() + if (now - lastSweep < 5 * 60_000) return + lastSweep = now + for (const [k, v] of rateBuckets) if (v.resetAt <= now) rateBuckets.delete(k) +} let _client: Anthropic | null = null function client(): Anthropic { @@ -44,11 +69,23 @@ function emitVoice(event: string, agent: 'cosmo' | 'lusya', text?: string) { type AgentId = 'cosmo' | 'lusya' export async function POST(req: Request) { + // Rate-limit по auth_token (или x-voice-internal — для loopback'а от tools). + // Идентифицируем клиента: cookie auth_token > x-voice-internal > IP > 'anon'. + const cookie = req.headers.get('cookie') || '' + const tokenMatch = cookie.match(/auth_token=([a-f0-9]{32,})/i) + const internal = req.headers.get('x-voice-internal') || '' + const fwd = req.headers.get('x-forwarded-for') || '' + const ratekey = tokenMatch?.[1] || (internal ? 'internal' : '') || fwd.split(',')[0].trim() || 'anon' + sweep() + if (!rateLimit(ratekey)) { + return NextResponse.json({ error: 'rate_limited' }, { status: 429 }) + } + const body = await req.json().catch(() => null) if (!body || typeof body.text !== 'string' || !body.text.trim()) { return NextResponse.json({ error: 'text required' }, { status: 400 }) } - const userText: string = body.text.trim() + const userText: string = body.text.trim().slice(0, 4000) // защита от gigantic prompts const agent: AgentId = body.agent === 'lusya' ? 'lusya' : 'cosmo' // Echo command в орб @@ -78,6 +115,9 @@ export async function POST(req: Request) { let finalText = '' const initialUserIdx = history.length - 1 + // Защита от tool-cycling: запоминаем последний (name, args) — если LLM + // дважды подряд просит одно и то же, прерываем цикл. + let lastToolSig = '' try { const c = client() @@ -109,6 +149,18 @@ export async function POST(req: Request) { apiMessages.push({ role: 'assistant', content: resp.content as any }) if (resp.stop_reason === 'tool_use' && toolUses.length) { + // Сигнатура текущего раунда — для loop-guard. + const sig = toolUses + .map((t) => `${t.name}:${JSON.stringify(t.input)}`) + .sort() + .join('|') + if (sig === lastToolSig) { + console.warn('[voice/chat] tool cycle detected, breaking loop') + finalText += '\nНе получилось выполнить запрос.' + break + } + lastToolSig = sig + const toolResults: Anthropic.ToolResultBlockParam[] = [] for (const tu of toolUses) { console.log(`[voice/chat] tool ${tu.name}(${JSON.stringify(tu.input).slice(0, 200)})`) diff --git a/components/CountdownCard.tsx b/components/CountdownCard.tsx deleted file mode 100644 index 5f97576..0000000 --- a/components/CountdownCard.tsx +++ /dev/null @@ -1,198 +0,0 @@ -'use client' - -import { useEffect, useState } from 'react' -import { motion, AnimatePresence } from 'framer-motion' -import { Sparkles } from 'lucide-react' - -interface Countdown { - id: string - label: string - date: string - emoji?: string - color?: string - note?: string -} - -interface Computed { - id: string - label: string - emoji?: string - days: number - accent: string - dateStr: string -} - -const ROTATE_MS = 8_000 - -function resolveColor(c: Countdown): string { - if (!c.color) return 'var(--data-violet)' - if (c.color.startsWith('#')) return c.color - // токен вида "data-rose" → "var(--data-rose)" - return `var(--${c.color})` -} - -function formatDate(iso: string): string { - try { - return new Date(iso + 'T00:00:00').toLocaleDateString('ru-RU', { - day: 'numeric', month: 'long', - }) - } catch { - return iso - } -} - -function daysFromNow(iso: string): number { - const target = new Date(iso + 'T00:00:00').getTime() - const today = new Date() - today.setHours(0, 0, 0, 0) - return Math.ceil((target - today.getTime()) / 86_400_000) -} - -function pluralizeDays(n: number): string { - const a = Math.abs(n) - if (a % 10 === 1 && a % 100 !== 11) return 'день' - if ([2, 3, 4].includes(a % 10) && ![12, 13, 14].includes(a % 100)) return 'дня' - return 'дней' -} - -export default function CountdownCard() { - const [items, setItems] = useState([]) - const [idx, setIdx] = useState(0) - const [loading, setLoading] = useState(true) - - useEffect(() => { - let cancelled = false - fetch('/api/countdowns') - .then(r => r.json()) - .then(d => { - if (cancelled) return - const list: Countdown[] = d.countdowns || [] - const computed = list - .map(c => ({ - id: c.id, - label: c.label, - emoji: c.emoji, - days: daysFromNow(c.date), - accent: resolveColor(c), - dateStr: formatDate(c.date), - })) - .filter(c => c.days >= 0) // прошедшие скрываем - .sort((a, b) => a.days - b.days) - setItems(computed) - }) - .catch(() => {}) - .finally(() => { if (!cancelled) setLoading(false) }) - return () => { cancelled = true } - }, []) - - useEffect(() => { - if (items.length <= 1) return - const t = setInterval(() => setIdx(i => (i + 1) % items.length), ROTATE_MS) - return () => clearInterval(t) - }, [items.length]) - - if (loading) { - return ( -
- … -
- ) - } - - if (items.length === 0) { - return ( -
-
- - Отсчёт -
-
- Добавь в настройках — отпуск, др, дедлайн. -
-
- ) - } - - const current = items[Math.min(idx, items.length - 1)] - const imminent = current.days <= 3 - const soon = current.days <= 7 - - return ( -
-
- - Отсчёт - {items.length > 1 && ( -
- {items.map((_, i) => ( -
- ))} -
- )} -
- - - -
-
- {current.days === 0 ? 'сегодня' : current.days} -
- {current.days > 0 && ( -
- {pluralizeDays(current.days)} -
- )} -
-
- {current.emoji && {current.emoji}} - - {current.label} - -
-
- {current.dateStr} -
-
-
-
- ) -} diff --git a/components/FocusCard.tsx b/components/FocusCard.tsx deleted file mode 100644 index 778934e..0000000 --- a/components/FocusCard.tsx +++ /dev/null @@ -1,426 +0,0 @@ -'use client' - -import { useEffect, useMemo, useState } from 'react' -import { motion, AnimatePresence } from 'framer-motion' -import { - Umbrella, Wind, ThermometerSun, TramFront, Calendar as CalendarIcon, - Receipt, Sparkles, Moon, Sun, -} from 'lucide-react' - -// —————————————————————————————— -// Types -// —————————————————————————————— - -export interface FocusWeather { - temp: string - desc: string - feelsLike?: string -} - -export interface FocusTram { - route: string - minutes: number - direction: string -} - -export interface FocusEvent { - id: string - title: string - start: string - allDay?: boolean - ownerName?: string - color?: string -} - -export interface FocusCountdown { - label: string - date: string // ISO YYYY-MM-DD -} - -export interface FocusBill { - title: string - amount: string - daysLeft: number -} - -interface Props { - weather: FocusWeather | null - tramNext?: FocusTram | null - nextEvent?: FocusEvent | null - countdowns?: FocusCountdown[] - bills?: FocusBill[] -} - -// —————————————————————————————— -// Focus state machine -// —————————————————————————————— - -type FocusKind = - | { kind: 'morning-outfit'; tempNow: string; feels: string; advice: string; Icon: any; accent: string } - | { kind: 'tram-imminent'; route: string; minutes: number; direction: string } - | { kind: 'event-upcoming'; title: string; inMinutes: number; owner?: string; color?: string } - | { kind: 'countdown'; label: string; days: number } - | { kind: 'bill-due'; title: string; amount: string; daysLeft: number } - | { kind: 'night'; hour: number } - | { kind: 'quiet'; greeting: string } - -function pickFocus(p: Props, hour: number): FocusKind { - // 1. Bill due today / tomorrow — всегда приоритет - const bill = p.bills?.find(b => b.daysLeft <= 1) - if (bill) return { kind: 'bill-due', ...bill } - - // 2. Ближайшее событие ≤30 минут - if (p.nextEvent && !p.nextEvent.allDay) { - const start = new Date(p.nextEvent.start).getTime() - const inMin = Math.round((start - Date.now()) / 60_000) - if (inMin >= -5 && inMin <= 30) { - return { - kind: 'event-upcoming', - title: p.nextEvent.title, - inMinutes: inMin, - owner: p.nextEvent.ownerName, - color: p.nextEvent.color, - } - } - } - - // 3. Трамвай в рабочий час, ≤3 мин - const rushHour = (hour >= 7 && hour <= 10) || (hour >= 17 && hour <= 20) - if (rushHour && p.tramNext && p.tramNext.minutes >= 0 && p.tramNext.minutes <= 3) { - return { kind: 'tram-imminent', ...p.tramNext } - } - - // 4. Утро (7-10) → одевалка - if (hour >= 7 && hour < 11 && p.weather) { - const t = parseInt(p.weather.temp, 10) - const descLower = p.weather.desc?.toLowerCase() || '' - const rain = /дожд|ливен|грозa|морос/.test(descLower) - const snow = /снег|метел/.test(descLower) - const advice = - rain ? 'возьми зонт' : - snow ? 'шапка и зимняя обувь' : - t <= -10 ? 'пуховик, шапка, перчатки' : - t < 0 ? 'шапка и перчатки' : - t < 7 ? 'тёплая куртка' : - t < 15 ? 'лёгкая куртка' : - t < 22 ? 'свитер или рубашка' : 'футболка' - const Icon = rain ? Umbrella : snow ? Wind : t < 0 ? Wind : ThermometerSun - const accent = - rain ? 'var(--data-cool)' : - snow ? 'var(--data-info)' : - t < 0 ? 'var(--data-info)' : - t >= 22 ? 'var(--data-warm)' : 'var(--data-warm)' - return { - kind: 'morning-outfit', - tempNow: p.weather.temp, - feels: p.weather.feelsLike || '', - advice, Icon, accent, - } - } - - // 5. Ближайший countdown (≤14 дней) - const cd = (p.countdowns || []) - .map(c => { - const target = new Date(c.date + 'T00:00:00').getTime() - const days = Math.ceil((target - Date.now()) / 86_400_000) - return { label: c.label, days } - }) - .filter(c => c.days >= 0 && c.days <= 14) - .sort((a, b) => a.days - b.days)[0] - if (cd) return { kind: 'countdown', ...cd } - - // 6. Ночь - if (hour >= 22 || hour < 5) return { kind: 'night', hour } - - // 7. Тихо — приветствие - const greeting = - hour >= 5 && hour < 12 ? 'Доброе утро' : - hour >= 12 && hour < 17 ? 'Добрый день' : - hour >= 17 && hour < 22 ? 'Добрый вечер' : 'Доброй ночи' - return { kind: 'quiet', greeting } -} - -// —————————————————————————————— -// Presentations -// —————————————————————————————— - -function Eyebrow({ children }: { children: React.ReactNode }) { - return ( -
- {children} -
- ) -} - -function FocusShell({ - eyebrow, - accent, - children, -}: { - eyebrow: React.ReactNode - accent: string - children: React.ReactNode -}) { - return ( -
- {/* Top eyebrow */} -
- {eyebrow} -
- {children} -
- ) -} - -// —————————————————————————————— -// Individual states -// —————————————————————————————— - -function MorningOutfit(f: Extract) { - return ( - - - Собираясь на улицу - } - > -
-
- {f.tempNow}° -
- {f.feels && ( -
- ощущается {f.feels}° -
- )} -
-
- {f.advice} -
-
- ) -} - -function TramImminent(f: Extract) { - const accent = 'var(--data-hot)' - return ( - - - Трамвай подходит - } - > -
-
- {f.minutes <= 0 ? 'сейчас' : f.minutes} -
- {f.minutes > 0 && ( -
мин
- )} -
-
- Маршрут {f.route} · {f.direction} -
-
- ) -} - -function EventUpcoming(f: Extract) { - const accent = f.color || 'var(--accent)' - const timeWord = f.inMinutes < 0 ? 'сейчас' : f.inMinutes === 0 ? 'сейчас' : `через ${f.inMinutes}м` - return ( - - - {timeWord} - } - > -
- {f.title} -
- {f.owner && ( -
- {f.owner} -
- )} -
- ) -} - -function CountdownView(f: Extract) { - const accent = f.days <= 3 ? 'var(--data-hot)' : f.days <= 7 ? 'var(--data-warm)' : 'var(--data-violet)' - const word = - f.days === 0 ? 'сегодня' : - f.days === 1 ? 'завтра' : - f.days < 5 ? `${f.days} дня` : `${f.days} дней` - return ( - - - До события - } - > -
- {f.days === 0 ? 'сегодня' : f.days} - {f.days > 0 && - {f.days < 5 ? 'дня' : 'дней'} - } -
-
- {f.label} -
-
- ) -} - -function BillDue(f: Extract) { - const accent = 'var(--data-danger)' - const word = f.daysLeft === 0 ? 'сегодня' : f.daysLeft === 1 ? 'завтра' : `через ${f.daysLeft}д` - return ( - - - К оплате {word} - } - > -
- {f.amount} -
-
- {f.title} -
-
- ) -} - -function NightView() { - const accent = 'var(--data-violet)' - return ( - - - Тихое время - } - > -
-
- Спокойной ночи -
-
- Дом в режиме ожидания. Касание — разблокировать. -
-
-
- ) -} - -function QuietView({ greeting, weather }: { greeting: string; weather: FocusWeather | null }) { - const accent = 'var(--accent)' - const [now, setNow] = useState(() => new Date()) - useEffect(() => { - const t = setInterval(() => setNow(new Date()), 30_000) - return () => clearInterval(t) - }, []) - return ( - - - {now.toLocaleDateString('ru-RU', { weekday: 'long', day: 'numeric', month: 'long' })} - } - > -
- {greeting} -
- {weather && ( -
-
- {weather.temp}° -
-
- {weather.desc} -
-
- )} -
- ) -} - -// —————————————————————————————— -// Root -// —————————————————————————————— - -export default function FocusCard(props: Props) { - const [hour, setHour] = useState(() => new Date().getHours()) - useEffect(() => { - const t = setInterval(() => setHour(new Date().getHours()), 60_000) - return () => clearInterval(t) - }, []) - - const focus = useMemo(() => pickFocus(props, hour), [props, hour]) - - const key = focus.kind + ( - focus.kind === 'morning-outfit' ? focus.advice : - focus.kind === 'tram-imminent' ? `${focus.route}-${focus.minutes}` : - focus.kind === 'event-upcoming' ? focus.title : - focus.kind === 'countdown' ? focus.label : - focus.kind === 'bill-due' ? focus.title : - focus.kind === 'quiet' ? focus.greeting : '' - ) - - return ( - - - {focus.kind === 'morning-outfit' && } - {focus.kind === 'tram-imminent' && } - {focus.kind === 'event-upcoming' && } - {focus.kind === 'countdown' && } - {focus.kind === 'bill-due' && } - {focus.kind === 'night' && } - {focus.kind === 'quiet' && } - - - ) -} diff --git a/components/VoiceController.tsx b/components/VoiceController.tsx index 69bb185..c514ddf 100644 --- a/components/VoiceController.tsx +++ b/components/VoiceController.tsx @@ -16,6 +16,8 @@ import { useEffect, useRef, useState } from 'react' import { Mic, MicOff } from 'lucide-react' import { WakeWordDetector } from '@/lib/wake-word' +import { floatToWav } from '@/lib/audio-wav' +import { vlog, vwarn, verror } from '@/lib/debug' type Agent = 'cosmo' | 'lusya' type ControllerState = 'idle' | 'loading' | 'listening' | 'recording' | 'busy' | 'error' @@ -31,35 +33,6 @@ function emitLocal(event: string, agent: Agent, text?: string) { ) } -function floatToWav(audio: Float32Array, sampleRate = 16000): Blob { - const numSamples = audio.length - const buffer = new ArrayBuffer(44 + numSamples * 2) - const view = new DataView(buffer) - writeStr(view, 0, 'RIFF') - view.setUint32(4, 36 + numSamples * 2, true) - writeStr(view, 8, 'WAVE') - writeStr(view, 12, 'fmt ') - view.setUint32(16, 16, true) - view.setUint16(20, 1, true) - view.setUint16(22, 1, true) - view.setUint32(24, sampleRate, true) - view.setUint32(28, sampleRate * 2, true) - view.setUint16(32, 2, true) - view.setUint16(34, 16, true) - writeStr(view, 36, 'data') - view.setUint32(40, numSamples * 2, true) - let offset = 44 - for (let i = 0; i < numSamples; i++, offset += 2) { - const s = Math.max(-1, Math.min(1, audio[i])) - view.setInt16(offset, s < 0 ? s * 0x8000 : s * 0x7fff, true) - } - return new Blob([buffer], { type: 'audio/wav' }) -} - -function writeStr(view: DataView, offset: number, s: string) { - for (let i = 0; i < s.length; i++) view.setUint8(offset + i, s.charCodeAt(i)) -} - export default function VoiceController() { const [state, setState] = useState('idle') const wakeRef = useRef(null) @@ -67,12 +40,12 @@ export default function VoiceController() { const busyRef = useRef(false) useEffect(() => { - console.log('[VoiceController] mounted, state=idle, ждём тап на микрофон') + vlog('[VoiceController] mounted, state=idle, ждём тап на микрофон') // Кнопка X в overlay шлёт voice-cancel → ставим VAD на паузу // (НЕ destroy — иначе следующий wake снова будет ждать 1-2с на инициализацию). const onCancel = () => { - console.log('[voice] cancel — пауза VAD') + vlog('[voice] cancel — пауза VAD') try { vadRef.current?.pause?.() } catch {} busyRef.current = false try { wakeRef.current?.resume?.() } catch {} @@ -119,7 +92,7 @@ export default function VoiceController() { }) if (!chatResp.ok) throw new Error(`chat ${chatResp.status}`) } catch (e) { - console.error('[voice] pipeline error:', e) + verror('[voice] pipeline error:', e) emitLocal('error', AGENT, 'Не получилось') } finally { busyRef.current = false @@ -154,16 +127,16 @@ export default function VoiceController() { }) vadRef.current = vad // Не вызываем start — ждём пока wake-word триггернёт. - console.log('[voice] VAD preloaded (paused)') + vlog('[voice] VAD preloaded (paused)') } catch (e: any) { - console.error('[voice] VAD init failed:', e?.name, e?.message, e) + verror('[voice] VAD init failed:', e?.name, e?.message, e) // Не вырубаем wake — может на ручной trigger ещё попробуем emitLocal('error', AGENT, `VAD: ${e?.message?.slice(0, 60) || 'init'}`) } } const onWakeDetected = async (score: number) => { - console.log(`[wake] cosmo score=${score.toFixed(3)}`) + vlog(`[wake] cosmo score=${score.toFixed(3)}`) if (busyRef.current) return // Пауза wake чтобы VAD-инициализация и команда не триггерили wake снова на эхе. try { wakeRef.current?.pause?.() } catch {} @@ -190,9 +163,9 @@ export default function VoiceController() { } const ctx: AudioContext | undefined = w.__voicePlaybackCtx if (ctx && ctx.state === 'suspended') await ctx.resume() - console.log('[voice] playback AudioContext state=', ctx?.state) + vlog('[voice] playback AudioContext state=', ctx?.state) } catch (e: any) { - console.warn('[voice] AudioContext init failed:', e?.message) + vwarn('[voice] AudioContext init failed:', e?.message) } // 1. Запрос разрешения на микрофон отдельно @@ -200,7 +173,7 @@ export default function VoiceController() { const probe = await navigator.mediaDevices.getUserMedia({ audio: true }) probe.getTracks().forEach((t) => t.stop()) } catch (e: any) { - console.error('[voice] mic permission failed:', e?.name, e?.message) + verror('[voice] mic permission failed:', e?.name, e?.message) setState('error') emitLocal('error', AGENT, e?.name === 'NotAllowedError' ? 'Нет доступа к микрофону' : 'Микрофон не открылся') return @@ -220,12 +193,12 @@ export default function VoiceController() { if (s > maxScore) maxScore = s scoreCount++ if (scoreCount % 25 === 0) { - console.log(`[wake] alive · max score за окно=${maxScore.toFixed(3)} · scoreCount=${scoreCount}`) + vlog(`[wake] alive · max score за окно=${maxScore.toFixed(3)} · scoreCount=${scoreCount}`) maxScore = 0 } - if (s > 0.15) console.log(`[wake] score=${s.toFixed(3)}`) + if (s > 0.15) vlog(`[wake] score=${s.toFixed(3)}`) }, - onError: (e) => console.warn('[wake] error', e), + onError: (e) => vwarn('[wake] error', e), }) await wake.start() wakeRef.current = wake @@ -233,7 +206,7 @@ export default function VoiceController() { // VAD НЕ прелоадим — его второй getUserMedia мешает wake-word audio. // Грузится при первом wake (~1-2с), но дальше переиспользуется (см. handleSpeechEnd). } catch (e: any) { - console.error('[wake] init failed:', e) + verror('[wake] init failed:', e) setState('error') emitLocal('error', AGENT, `Wake: ${e?.message?.slice(0, 60) || 'init'}`) } @@ -252,7 +225,7 @@ export default function VoiceController() { // Долгий тап = ручной триггер (как раньше push-to-talk). Короткий — toggle вкл/выкл. // Для простоты сейчас: короткий тап в idle = активация; короткий тап в active = выкл. const onTap = async () => { - console.log(`[VoiceController] tap! state=${state}`) + vlog(`[VoiceController] tap! state=${state}`) if (state === 'idle' || state === 'error') { await start() } else if (state === 'listening') { diff --git a/components/VoiceOverlay.tsx b/components/VoiceOverlay.tsx index fe87065..f0a8b27 100644 --- a/components/VoiceOverlay.tsx +++ b/components/VoiceOverlay.tsx @@ -2,6 +2,7 @@ import { useEffect, useRef, useState } from 'react' import { motion, AnimatePresence } from 'framer-motion' import { X } from 'lucide-react' +import { vwarn } from '@/lib/debug' type VoiceState = 'idle' | 'wake' | 'listening' | 'command' | 'response' | 'error' type Agent = 'cosmo' | 'lusya' @@ -112,7 +113,7 @@ export default function VoiceOverlay() { source.start() return } catch (e: any) { - console.warn('[voice] AudioContext playback failed, fallback to