- route.ts: replace @anthropic-ai/sdk with groq-sdk, rewrite chat loop
- voice-tool-schemas.ts: convert from Anthropic format to OpenAI/Groq function tools
- voice-history.ts: extend HistoryMessage type to include tool role, simplify cache stubs
No prompt caching (Groq does not support it), tool calling preserved.
docker --env-file не поддерживает многострочные значения и не парсит
кавычки. Сырой JSON service-account ломается на newline'ах в private_key
поле → docker пытается парсить '-----END PRIVATE KEY-----' как имя
переменной и валится с 'contains whitespaces'.
Решение: base64 GOOGLE_SA_JSON_B64 (одна строка ASCII, никаких кавычек).
Старая GOOGLE_SA_JSON оставлена как fallback. Третий fallback на файл —
для локальной разработки.
Service Account ключ больше не распространяется через git/image —
читается из env GOOGLE_SA_JSON, которая выставлена в /opt/digital-home/
tablet.env на сервере.
- google-sa.json удалён из git tracking (git rm --cached)
- Добавлен в .gitignore + .dockerignore
- На сервере после git pull файл будет удалён с диска;
/api/calendar читает credentials из env (env-first fallback уже был
в коде). Это критично если bundle/image куда-то утечёт — ключ Google
больше не лежит внутри.
Безопасность:
- Rate-limit на /api/voice/chat (20/мин per cookie/IP, env VOICE_RATE_LIMIT).
Защищает от случайных циклов и утечки PIN.
- Усечение user prompt'а до 4000 символов в /api/voice/chat.
- Tool-loop защита от циклов: если LLM дважды просит тот же tool с теми же
args — прерываем (раньше мог уйти в бесконечный цикл при tool error'ах).
Чистка кода:
- lib/debug.ts — vlog/vwarn/verror гейтят браузерные логи за
NEXT_PUBLIC_VOICE_DEBUG=1 (или localStorage 'voice-debug=1').
Серверные console.log оставлены — полезны в Docker logs.
- lib/audio-wav.ts — вынесена дублированная floatToWav из VoiceController.
- Удалены orphan компоненты FocusCard.tsx и CountdownCard.tsx
(не подключены, отвергнуты по UX-фидбеку).
Resilience:
- WakeWordDetector: drop-on-busy в onChunk — на медленных устройствах
(Android, бюджетный CPU) backlog inference больше не копится.
- voice-history fallback на /tmp/voice-history если /data не примонтирован
(локальная разработка / нестандартная конфигурация).
VAD на iPad валился с «no available backend found / RangeError».
onnxruntime-web 1.24 ships только threaded WASM сборки, которые
требуют SharedArrayBuffer. iOS Safari его не даёт без cross-origin
isolation — отсюда и ошибка.
Добавляем заголовки:
Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: credentialless
credentialless (а не require-corp) выбрали чтобы не ломать внешние
ресурсы без CORP-заголовка (Google Fonts, wttr.in). Поддержка:
Chrome 96+, Firefox 110+, Safari 16.4+.
iOS Safari блокирует <audio>.play() даже после silent-WAV unlock —
каждый new Audio() считается новым элементом без gesture.
Решение: при тапе кнопки в VoiceController создаём общий
AudioContext (под user-gesture) и пробуждаем его. VoiceOverlay
теперь играет TTS через этот ctx (decodeAudioData + BufferSource).
HTMLAudioElement остаётся fallback'ом если ctx недоступен.
decodeAudioData в Safari исторически callback, в Chrome — Promise:
используем оба варианта.
Android Chrome требует user-gesture для <audio>.play(). Wake-word
триггерит TTS «сам», без тапа, поэтому play() тихо отвергался.
При тапе на кнопку микрофона теперь проигрываем 1мс silent WAV →
браузер помечает страницу как разрешённую для autoplay в текущей
сессии. Дальше TTS-ответы Cosmo/Lusya играют без проблем.
В VoiceOverlay логируем причину если play() всё ещё отвергнут.
Параллельный getUserMedia от MicVAD конфликтует со stream'ом wake-word —
видимо Chrome применяет AGC/NS по-разному и wake получает «глухое» аудио.
Score упал с 0.988 до 0.093 — wake перестал срабатывать.
Возвращаемся: VAD создаётся ПОСЛЕ первого wake (~1-2с пауза),
но cancel/onSpeechEnd теперь только pause (не destroy), так что
повторные wake мгновенные.
В логах было видно: между wake-trigger и реальным VAD recording
проходило 1-2с (Loading VAD... → finished loading → started micVAD).
Каждый cancel дополнительно destroy'ил VAD, и следующий wake снова
ждал инициализацию.
Теперь:
- VAD создаётся один раз в paused-режиме сразу после wake.start()
(в фоне, не блокирует UI).
- На каждый wake → vad.start() мгновенно.
- onSpeechEnd → vad.pause() (был implicit pause; явно ставим).
- voice-cancel → vad.pause(), а не destroy. Wake продолжает слушать.
- destroy только при полном выключении ассистента.
Из ORT-ошибки:
onnx::Flatten_0 index 1 Got: 16 Expected: 25.
Стоковые openWakeWord модели тренируются на окне 16, наша cosmo.onnx —
на 25. Меняем EMB_WINDOW. Окно для embedding-буфера тоже подвинули,
чтобы хватало для classifier'а.
В overlay появляется крестик в правом верхнем углу. Тап = эмитит
voice-cancel → VoiceController прерывает активный VAD-захват и сам
overlay закрывается. Wake-word, если был активен, продолжает слушать
в фоне.
Текущая сборка ~7 минут — npm ci качает все 183 пакета с нуля + next
build без incremental cache на каждом push.
- Dockerfile: BuildKit cache mount на /root/.npm (npm install) и
.next/cache (Next.js incremental TS/swc/webpack cache).
- workflow: DOCKER_BUILDKIT=1 чтобы cache mounts работали.
- .dockerignore: убираем node_modules/.next/.git/docs из контекста —
меньше копирования + чище инвалидация слоёв.
- Поднимаем NODE_OPTIONS heap до 4G в build-стадии (TS падал с OOM).
Первый билд после этого коммита заполнит cache — второй и дальше
будут значительно быстрее (deps install ~5-10с вместо 60-90с,
next build тоже ускорится за счёт incremental).
openWakeWord pipeline на onnxruntime-web прямо на планшете. Цепочка:
mic (16kHz, AudioWorklet) → melspectrogram.onnx → embedding_model.onnx
(sliding 76-frame window, stride 8) → cosmo.onnx → score 0..1.
Триггер при score≥0.5 → запускается тот же VAD-flow что и push-to-talk.
- public/wake/ — cosmo.onnx (custom-trained на голос Даниила) +
melspectrogram.onnx + embedding_model.onnx (~2.9MB вместе).
- lib/wake-word.ts — WakeWordDetector class. ort грузится через
<script src=/vad/ort.wasm.min.js> на клиенте — обход проблемы next-swc
с парсингом import.meta.url в onnxruntime-web .mjs билдах.
- VoiceController: тап = активация (нужен для AudioContext user-gesture),
далее непрерывное слушание wake-word; на детект → MicVAD флоу.
Долгий тап = выкл. Ручной тап остаётся как fallback.
После деплоя Python-агент на .103 не нужен — можно архивировать
home-voice-assistant. На .103 остаётся только ElevenLabs прокси :8888.
В public/vad/ были только asyncify-варианты, а onnxruntime-web по дефолту
просит ort-wasm-simd-threaded.{mjs,wasm} → 404 → MicVAD init falls.
- Положили ort-wasm-simd-threaded.{mjs,wasm} рядом.
- ortConfig forces numThreads=1, чтобы не требовать SharedArrayBuffer
(нет COOP/COEP headers и не хотим их вешать на весь сайт).
- Раздельный getUserMedia probe перед VAD init, чтобы отличить отказ
по микрофону от ошибки VAD/wasm в UI-сообщении.
Шаг 2 миграции: убираем зависимость от Python-агента для базового
голосового сценария. Тап на круглую кнопку-микрофон в правом нижнем
углу → MicVAD (Silero v5) ловит речь → автостоп по тишине → /api/voice/stt
→ /api/voice/chat → ответ через SSE и TTS как раньше.
- components/VoiceController.tsx — push-to-talk UI + MicVAD orchestration
- VoiceOverlay теперь слушает window CustomEvent('voice-local'), чтобы
орб моргал ещё до round-trip на сервер (wake/listening мгновенно).
- public/vad/ — silero v5/legacy onnx + ort wasm + audio worklet,
раздаются через baseAssetPath: '/vad/' (не зависит от внешнего CDN,
важно если планшет без интернета или с RU-блоком).
Что осталось от home-voice-assistant: только wake-word. После Шага 3
(onnxruntime-web + перенос openwakeword .onnx) Python-агент уйдёт целиком.
Шаг 1 миграции голосового стека из home-voice-assistant в сам tablet:
- /api/voice/chat — Claude Haiku 4.5 с tool-loop (max 4 раунда), prompt
caching на system + старой истории, история в /data/voice-history/.
Эмитит command/response/error в voice-bus → орб моргает как раньше.
- /api/voice/stt — Groq whisper-large-v3-turbo, multipart или raw audio.
- lib/voice-text.ts — порт clean_for_speech (без pymorphy3, время в
именительном падеже) и strip_fillers + RESET_PATTERNS.
- lib/voice-executors.ts — tool executors через loopback fetch на
существующие /api/voice/tools/* и /api/voice/timer.
- Поддержка ANTHROPIC_PROXY/GROQ_PROXY (fallback на HTTPS_PROXY).
После деплоя нужны GROQ_API_KEY и ANTHROPIC_API_KEY в tablet.env.
Шаги 2 (push-to-talk в браузере) и 3 (wake-word) — отдельно.
Focus/Countdown не лежат по high-density. На Home вернул старый
weather-hero: WeatherAnimation (фон + иконка), feelsLike/humidity/wind,
76px display-цифра (чуть крупнее прежних 64). FocusCard и CountdownCard
файлы оставляем для будущего, но на главной не подключаем. Убрал
сопутствующие state/fetch (tramNext, countdowns, nextEvent).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Tablet touch targets were cramped (30px +/- buttons). Swap to modal UX:
- New TimerModal component handles both 'control existing' and
'create new'. 84px countdown, 56px min button height, 3×2 adjust
grid (-5m, -1m, -10s, +10s, +1m, +5m), big destructive cancel/stop.
Create view has 7 duration presets (1..60 min), label input with
shortcut chips (Чайник, Паста, Яйца…), gradient 'Запустить' CTA.
- TimerHomeWidget cards are now full tap targets — open control modal
on press. + button in header opens create modal. Inline buttons
removed. Subtle hint text 'тап чтобы изменить'.
- Ticking countdown inside modal stays fresh via shared timers state:
while modal is open we look up the current timer in active[] by id
and re-pass to modal on every tick. When timer disappears
(cancelled / expired >30s), modal auto-closes.
Голосовой ассистент теперь может создавать, изменять и удалять события
в календарях Даниила и Светы.
- POST /api/voice/tools/events — create (title, date, start_time, end_time,
all_day, owner). Маппит owner (daniil/sveta) в calendar_id и проксирует
в /api/calendar POST.
- PUT — update (event_id, owner, fields). Передаёт только изменённые
поля + нужный calendarId.
- DELETE ?event_id=X&owner=Y — удаление.
- GET — теперь возвращает id события и owner (daniil/sveta), чтобы
скрипт мог их передать в update/delete.
- range=month поддержан с year/month query params.
Все три метода под bearer auth (VOICE_API_KEY), как остальные voice tools.
Loopback к /api/calendar идёт через internalHeaders() x-voice-internal.
Bug: после перезагрузки страницы оверлей «Таймер прозвенел» открывался
снова и снова. Две причины:
- dismissTimer в TimerWidget удалял таймер только из локального
useState, но /data/tablet-timers.json оставался нетронутым. После
reload таймер возвращался в список и firedRef (которая пустая после
reload) снова триггерила alarm.
- lib/timers.ts держал просроченные таймеры 30 минут, давая им шанс
повторно сработать при каждом reload в этом окне.
Фикс:
- dismissTimer теперь POST /api/voice/timer {action:cancel, id} через
cookie auth (endpoint с прошлого коммита принимает и cookie, и bearer).
- Retention в listActive снижена до 30 секунд — этого хватает чтобы
клиент увидел свежий звонок; старше = самоудаление.
- TimerWidget клиентский фильтр тоже 30 секунд.
Two fixes:
1) Overlay was hiding mid-TTS because dismiss timer used
text.length * 80ms — ElevenLabs speaks slower, so the audio got
cut off. Now scheduleDismiss is only called from playTTS's
onEnded callback (plus 4s lingering after audio finishes).
2) After response, the Python script silently re-entered record()
for follow-ups but the overlay disappeared, so the user had to
re-wake every turn. Added a new 'listening' event — Python
emits it just before each followup record(), tablet shows the
orb pulsing at medium intensity with 'жду' status and the last
response text preserved below.
Safety: any state now arms a 60s auto-close in case Python dies
and never emits idle.
UI:
- Replace Notes column on Home bento with TimerHomeWidget. Shows all
active timers as stacked cards with big 30px countdowns, per-timer
+1/-1 minute buttons and cancel. Colors: indigo default, amber in
last 10s, red when expired. Empty state suggests voice command.
- Existing chip TimerWidget (bottom-right) kept for ambient view on
other tabs — redundant on Home, but harmless.
API:
- /api/voice/timer accepts cookie OR bearer (browser widget cancel
works with user's auth_token cookie; Python script uses bearer).
- New action 'adjust' — shifts endsAt by delta_seconds. Clamps so
endsAt never goes into the past.
- Cancel now supports {label} in addition to {id} (fuzzy substring
match, most-recently-started wins). Emits timer_cancel with id+label
so clients can refresh.
- findByLabel / adjustTimer helpers in lib/timers.ts.
Tool endpoints (events, notes, transport, weather) call other /api/*
routes via loopback (http://localhost:3000). Those routes are
middleware-protected — cookie-less loopbacks were getting 401, which
surfaced to the voice agent as get_today_events → tool_http_502.
Add internal header bypass: middleware lets the request through when
x-voice-internal matches VOICE_API_KEY. Only our own tool endpoints
use this header, from inside the same container, so the blast radius
is limited to loopback traffic.
- middleware.ts: check x-voice-internal before cookie
- lib/voice-tools.ts: internalHeaders() helper
- app/api/voice/tools/{weather,transport,events,notes}: use it
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
ElevenLabs Cloudflare returns 302 to a region-restricted help page
when requested from a Russian IP. Tablet host (.60) is in RU, so the
Stage 2 call was failing with 502 upstream.
Fix: use https-proxy-agent when ELEVENLABS_PROXY (or generic HTTPS_PROXY
/ HTTP_PROXY) env var is set. Tinyproxy on .103 (non-RU egress host)
acts as the tunnel.
- package.json: add https-proxy-agent ^7.0.6
- app/api/voice/tts: switch from global fetch to node:https with
explicit Agent (either direct or HttpsProxyAgent). Still streams
MP3 back via Readable.toWeb so Next.js Response pipes it to the
browser as audio arrives.
Operational: set ELEVENLABS_PROXY=http://192.168.31.103:8888 in
tablet.env after bringing tinyproxy up on .103.
Stage 2 of voice integration — centralizes TTS on the tablet so the
Python satellite no longer needs ElevenLabs credentials or mpv.
- app/api/voice/tts — POST {text, agent}, proxies to ElevenLabs
streaming endpoint with flash_v2_5 default, returns audio/mpeg.
Per-agent voice id via COSMO_TTS_VOICE / LUSYA_TTS_VOICE env.
- VoiceOverlay — on response/error events fetches TTS and plays via
HTMLAudioElement; on wake event stops playback (barge-in). Dismiss
timer extended by text length so long responses do not cut off.
- Autoplay caveat: browser may block first playback until user taps
anywhere on the page (FKB: enable Force Autoplay to bypass).
Adds the tablet side of voice assistant integration. External Python
script (openWakeWord + Groq STT + OpenClaw) will POST state transitions
to /api/voice/event with a bearer token, and the tablet shows a
fullscreen overlay with Siri-style animated blob + current agent +
recognized text / response text.
- lib/voice-bus.ts — in-process EventEmitter singleton, preserved
across hot reloads via globalThis
- app/api/voice/event — POST, bearer-auth via VOICE_API_KEY env,
validates event kind, broadcasts on voiceBus
- app/api/voice/stream — GET, SSE endpoint, per-connection listener
with 15s keep-alive ping and abort-signal cleanup
- components/VoiceOverlay — full-screen overlay, 3-layer pulsing
Siri blob, per-agent palette (cosmo indigo/violet, lusya pink/rose),
auto-dismiss timeouts (wake=20s safety, response=6s, error=4s),
auto-reconnect on SSE drop
- middleware bypasses /api/voice/event so the script does not need
a user auth cookie
- VoiceOverlay mounted in HomePageInner outside tab routing so it
appears on every view
Google Calendar API rejects all-day events where start.date == end.date
(end is exclusive). POST/PUT were sending the same date for both,
producing Invalid start time when toggling Весь день on edit.
- Added nextDayISO helper (UTC-safe +1d arithmetic)
- all-day: start = { date }, end = { date: nextDay, dateTime: null }
- timed: also explicitly nulls start.date/end.date so patching a
timed-only event over a previously all-day one doesnt leave stale
date fields that also trigger Invalid start time
- AnimatePresence mode=wait keeps the DOM empty while the outgoing tab
finishes its exit transition (200ms) before mounting the incoming
tab. On touch devices this shows as a brief black frame — reported
as экран остается черным.
- Switch to mode=sync: outgoing fades out while incoming fades in,
no gap. initial=false suppresses the enter animation on first render.
- Drop the y: 12 → -8 slide (cheap jank on hydration), keep just
opacity. Duration 200ms → 150ms for snappier feel.
- WeatherDayModal now accepts the full forecast array and an onChange
callback; supports horizontal drag (framer-motion) plus prev/next
chevrons and a dot-indicator. Drag > 60px switches day; style uses
semantic tokens (shadow-xl, surface-1).
- NotesTab list items wrap each note in a motion.button with drag=x,
constrained to -80px. Below it a gradient+trash reveal layer. Drag
past 60px opens the existing confirmDelete modal.
- HomePageInner adds a night-shift overlay (fixed, mixBlendMode multiply,
rgba(255,120,40,0.12)) active 22:00-06:00, auto-checked each minute,
fades in/out over 800ms. No user toggle yet — fully automatic.
CSS grid items default min-width to min-content; the tram widget 3-col
subgrid plus its баbadges and long затем text forced its cell wider
than 1.1fr, collapsing the outer layout on tablet. Fixes:
- outer bento row → gridTemplateColumns: minmax(0, 1fr) minmax(0, 1.1fr)
- events+notes row same treatment
- TransportWidget inner subgrids: 58px → 52px badge, 1fr → minmax(0, 1fr)
- Cell: minWidth: 0, overflow: hidden, затем text trimmed with ellipsis
and short м suffix (5м instead of 5 мин)
- big number 32→28px, badge 22→20px to fit in denser columns
The big Доброе утро line on Home ate one row for marginal value.
Moved it into TopBar as a center column via 3-col grid (1fr auto 1fr),
so it appears on all tabs and leaves the Home body denser.
- Removes the date label from the greeting row (user request — тoo dense
on tablet and redundant with TopBar clock).
- Scrolling: drops overflowY: auto on the inner Events/Notes cards and
removes flex: 1 / minHeight: 0 from their grid. On iPad-class touch
nested scroll containers fought the root scroll; now only the root
scrolls, which the browser handles natively.
- TopBar: replaces hardcoded rgba(255,255,255,X) on sensor icons, chip
background, chip border and header bottom-border with semantic tokens
(--text-tertiary, --surface-2, --border-subtle, --hairline) so the
thermometer/humidity/wind icons are visible in light theme.