voice: локальный агент (fast-path), tool control_light, смелая тема, RUNBOOK
All checks were successful
Deploy / deploy (push) Successful in 3m43s
All checks were successful
Deploy / deploy (push) Successful in 3m43s
- lib/agent-local.ts + route.ts: команды-действия исполняет локальная intent-служба (под AGENT_URL, с fallback на LLM); инфо-запросы остаются у LLM - lib/tools/lights.ts: голосовое управление светом через HA (control_light) - app/globals.css + page.tsx: космическая indigo/aurora тема (токены, kiosk-safe) - agent/: sidecar-служба (Dockerfile, predict.py), веса монтируются томом - RUNBOOK.md: вывод в продакшен на мини-ПК
This commit is contained in:
213
RUNBOOK.md
Normal file
213
RUNBOOK.md
Normal file
@@ -0,0 +1,213 @@
|
|||||||
|
# RUNBOOK — вывод голосового помощника в продакшен
|
||||||
|
|
||||||
|
Пошаговая инструкция, чтобы всё завелось на мини-ПК (где развёрнут
|
||||||
|
`https://tablet.digital-home.site`). Рассчитана на то, что её выполняет
|
||||||
|
Claude-сессия **на мини-ПК** (`cosmo@192.168.31.60`), с пометками, что делается
|
||||||
|
на рабочей машине (Windows), а что — на мини-ПК.
|
||||||
|
|
||||||
|
## TL;DR — что это добавляет
|
||||||
|
|
||||||
|
1. **Локальный агент (intent+slots)** — sidecar-служба, распознаёт команды
|
||||||
|
офлайн за ~2 мс и исполняет действия (таймеры, свет, музыка, очиститель),
|
||||||
|
минуя облачный LLM. Инфо-запросы (погода/календарь) по-прежнему у LLM.
|
||||||
|
Модель обучена: **интент 40/40, слоты 40/40**.
|
||||||
|
2. **Голосовое управление светом** — новый tool `control_light` (2 лампы).
|
||||||
|
3. **Смелый визуал** — обновлённая тема (космический индиго/aurora), деплоится
|
||||||
|
с обычным билдом.
|
||||||
|
4. **Wake «Эй, космо»** — пайплайн переобучения готов и проверен end-to-end;
|
||||||
|
боевая модель требует досбора данных (см. раздел D).
|
||||||
|
|
||||||
|
Всё под фиче-флагами: без `AGENT_URL` планшет работает ровно как раньше →
|
||||||
|
безопасно для kiosk.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Изменения в репозитории (уже в коде)
|
||||||
|
|
||||||
|
| Файл | Что |
|
||||||
|
|---|---|
|
||||||
|
| `lib/tools/lights.ts` | новый tool `control_light` (свет через HA) |
|
||||||
|
| `lib/tools/_registry.ts` | регистрация `control_light` |
|
||||||
|
| `lib/agent-local.ts` | быстрый путь: вызов агент-службы + исполнение действий |
|
||||||
|
| `app/api/voice/chat/route.ts` | вставка fast-path (под `AGENT_URL`, с fallback) |
|
||||||
|
| `app/globals.css`, `app/page.tsx` | смелый визуал (токены/aurora) |
|
||||||
|
| `agent/` | sidecar агента: `Dockerfile`, `predict.py`, `joint_model_def.py`, `tablet_map.py`, `requirements.txt` |
|
||||||
|
|
||||||
|
⚠️ **Эти изменения надо закоммитить и запушить** (с рабочей машины), чтобы мини-ПК
|
||||||
|
их подтянул `git pull`. Веса моделей в git НЕ идут (переносятся отдельно, ниже).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Архитектура продакшена
|
||||||
|
|
||||||
|
```
|
||||||
|
Планшет (Android, kiosk-браузер)
|
||||||
|
├─ wake «Эй, космо» (public/wake/cosmo.onnx, WASM) ─┐
|
||||||
|
└─ UI (Next.js) │
|
||||||
|
▼
|
||||||
|
tablet-контейнер (мини-ПК, network coolify) ── /api/voice/chat
|
||||||
|
│ fast-path: AGENT_URL задан?
|
||||||
|
│ ├─ да → POST http://tablet-agent:8091/predict
|
||||||
|
│ │ интент-действие + score≥0.7 → executeTool → короткий ответ
|
||||||
|
│ └─ нет/инфо/none → облачный LLM (Anthropic/Groq) как раньше
|
||||||
|
▼
|
||||||
|
tablet-agent-контейнер (sidecar, network coolify)
|
||||||
|
└─ predict.py --serve (joint_model смонтирован томом)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## A. Агент (sidecar) — главный шаг
|
||||||
|
|
||||||
|
### A1. Перенести модель на мини-ПК (с рабочей машины)
|
||||||
|
|
||||||
|
Веса `joint_model/` (~115 МБ) в git нет. Скопировать на мини-ПК:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# на рабочей машине (Windows, Git Bash):
|
||||||
|
scp -i ~/.ssh/id_ed25519 -r "/d/Digital home/tablet-agent/joint_model" \
|
||||||
|
cosmo@192.168.31.60:/opt/digital-home/agent-model
|
||||||
|
```
|
||||||
|
|
||||||
|
Должно получиться `/opt/digital-home/agent-model/joint.pt` (+ tokenizer, config).
|
||||||
|
|
||||||
|
> Переобучить модель заново (если правил `tablet-agent/templates.txt`):
|
||||||
|
> `cd tablet-agent && python train_joint.py && python test_joint.py`, затем scp.
|
||||||
|
|
||||||
|
### A2. Собрать и запустить sidecar (на мини-ПК)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /opt/digital-home/smart-home-tablet
|
||||||
|
git pull origin main # подтянуть код (после пуша с рабочей машины)
|
||||||
|
|
||||||
|
docker build -t tablet-agent:latest ./agent
|
||||||
|
docker rm -f tablet-agent || true
|
||||||
|
docker run -d --name tablet-agent \
|
||||||
|
--network coolify \
|
||||||
|
--restart unless-stopped \
|
||||||
|
-v /opt/digital-home/agent-model:/app/joint_model \
|
||||||
|
tablet-agent:latest
|
||||||
|
|
||||||
|
# проверка службы:
|
||||||
|
docker exec tablet-agent python -c "import urllib.request,json; \
|
||||||
|
r=urllib.request.urlopen(urllib.request.Request('http://127.0.0.1:8091/predict', \
|
||||||
|
data=json.dumps({'text':'поставь таймер на 5 минут'}).encode(), \
|
||||||
|
headers={'Content-Type':'application/json'})); print(r.read().decode())"
|
||||||
|
# ждём: {"intent":"timer_set","slots":{"duration":"5 минут"},"score":...,"tool":{...}}
|
||||||
|
```
|
||||||
|
|
||||||
|
### A3. Включить fast-path в планшете
|
||||||
|
|
||||||
|
Добавить в `/opt/digital-home/tablet.env`:
|
||||||
|
|
||||||
|
```
|
||||||
|
AGENT_URL=http://tablet-agent:8091
|
||||||
|
AGENT_THRESHOLD=0.7
|
||||||
|
```
|
||||||
|
|
||||||
|
Пере-задеплоить планшет (push любой коммит ИЛИ вручную пере-`docker run` по
|
||||||
|
`deploy.yml`). После рестарта `route.ts` увидит `AGENT_URL` и начнёт использовать
|
||||||
|
агента.
|
||||||
|
|
||||||
|
> Откат агента: убрать `AGENT_URL` из `tablet.env`, пере-деплой. Мгновенно вернёт
|
||||||
|
> облачный LLM на все команды. Контейнер `tablet-agent` можно оставить.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## B. Свет — реальные entity Home Assistant
|
||||||
|
|
||||||
|
`control_light` по умолчанию шлёт на `light.bedside` / `light.reading` (заглушки).
|
||||||
|
Указать реальные entity_id твоих ламп в `/opt/digital-home/tablet.env`:
|
||||||
|
|
||||||
|
```
|
||||||
|
LIGHT_ENTITIES_JSON={"bedside":"light.ВАШ_СВЕТ_У_КРОВАТИ","reading":"light.ВАША_ЛАМПА_ДЛЯ_ЧТЕНИЯ","default":"light.ВАШ_СВЕТ_У_КРОВАТИ"}
|
||||||
|
```
|
||||||
|
|
||||||
|
Найти id ламп: HA → Developer Tools → States, отфильтровать `light.`. Ключи
|
||||||
|
`bedside`/`reading` соответствуют фразам «свет у кровати» / «лампа для чтения»
|
||||||
|
(маппинг в `lib/tools/lights.ts`, `DEVICE_ALIASES`). Пере-деплой планшета.
|
||||||
|
|
||||||
|
Без этой переменной команды света пройдут как mock (HA вернёт success, лампа не
|
||||||
|
переключится) — то есть безопасно, но не управляет реальным светом.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## C. Дизайн (смелый визуал)
|
||||||
|
|
||||||
|
Ничего дополнительно — тема живёт в `app/globals.css` (токены) и деплоится
|
||||||
|
обычным билдом. Раскладка/логика не менялись, kiosk не затронут. Если визуал не
|
||||||
|
зайдёт — откат = `git revert` коммита с globals.css.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## D. Wake «Эй, космо» — статус и деплой
|
||||||
|
|
||||||
|
Проект `tablet-wake` (на рабочей машине). Пайплайн переобучения **готов и
|
||||||
|
проверен end-to-end**: smoke-модель разделяет фразу (pos 0.96 / neg 0.01) через
|
||||||
|
тот же WASM-рантайм, что у планшета. Но это **не боевая модель** — нужен досбор
|
||||||
|
данных:
|
||||||
|
|
||||||
|
1. Реальная речь в негативах (корпус Golos) — главный убийца ложняков.
|
||||||
|
2. Фон Android-мика (запись, где планшет стоит).
|
||||||
|
3. Held-out тест своим голосом + подбор порога.
|
||||||
|
|
||||||
|
Полный прогон (на рабочей машине) — см. `tablet-wake/README.md`. Итог —
|
||||||
|
`tablet-wake/out/cosmo.onnx`.
|
||||||
|
|
||||||
|
**Деплой wake-модели** (когда готова): заменить файл в репозитории и запушить —
|
||||||
|
инференс планшета НЕ меняется (окно 25 сохранено), только веса:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# на рабочей машине:
|
||||||
|
cp "/d/Digital home/tablet-wake/out/cosmo.onnx" \
|
||||||
|
"/d/Digital home/smart-home-tablet/public/wake/cosmo.onnx"
|
||||||
|
# затем commit + push → Gitea пересоберёт планшет
|
||||||
|
```
|
||||||
|
|
||||||
|
Порог срабатывания подстраивается в клиенте (`WAKE_THRESHOLD` в
|
||||||
|
`components/VoiceController.tsx`). Также обнови фразу-подпись в UI: сейчас
|
||||||
|
«Космо» → «Эй, космо».
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Сводка новых переменных `tablet.env`
|
||||||
|
|
||||||
|
```
|
||||||
|
AGENT_URL=http://tablet-agent:8091 # включает локального агента
|
||||||
|
AGENT_THRESHOLD=0.7 # порог уверенности (опц.)
|
||||||
|
LIGHT_ENTITIES_JSON={"bedside":"...","reading":"...","default":"..."}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Проверка (после деплоя)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# планшет жив:
|
||||||
|
curl -sk -o /dev/null -w "%{http_code}\n" https://tablet.digital-home.site/
|
||||||
|
|
||||||
|
# агент отвечает изнутри сети:
|
||||||
|
docker exec tablet-yfh53kixpwkjlo4zibglx4n2 \
|
||||||
|
wget -qO- --post-data='{"text":"выключи свет у кровати"}' \
|
||||||
|
--header='Content-Type: application/json' http://tablet-agent:8091/predict
|
||||||
|
|
||||||
|
# логи планшета: fast-path пишет "[voice/chat] local-agent intent=..."
|
||||||
|
docker logs --tail 50 tablet-yfh53kixpwkjlo4zibglx4n2 | grep local-agent
|
||||||
|
```
|
||||||
|
|
||||||
|
Живой тест голосом: сказать «поставь таймер на 5 минут» (действие → агент,
|
||||||
|
мгновенно), затем «какая погода» (инфо → LLM). В логах у первой команды —
|
||||||
|
`local-agent`, у второй — обычный LLM-раунд.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Откат всего
|
||||||
|
|
||||||
|
| Что | Откат |
|
||||||
|
|---|---|
|
||||||
|
| Агент | убрать `AGENT_URL` из `tablet.env`, пере-деплой |
|
||||||
|
| Свет | убрать `control_light` из `_registry.ts` ИЛИ просто не задавать `LIGHT_ENTITIES_JSON` |
|
||||||
|
| Дизайн | `git revert` коммита globals.css |
|
||||||
|
| Wake | вернуть прежний `public/wake/cosmo.onnx` из git-истории |
|
||||||
|
|
||||||
|
Каждая часть независима — можно катить и откатывать по отдельности.
|
||||||
4
agent/.gitignore
vendored
Normal file
4
agent/.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
# Веса дообученной модели (~115 МБ) — не в git. Переносятся на мини-ПК отдельно
|
||||||
|
# (scp) и монтируются томом в контейнер. См. RUNBOOK.md, раздел «Агент».
|
||||||
|
joint_model/
|
||||||
|
__pycache__/
|
||||||
29
agent/Dockerfile
Normal file
29
agent/Dockerfile
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
# Sidecar-служба локального агента (intent+slots) для планшета.
|
||||||
|
# route.ts зовёт её по HTTP (AGENT_URL) и исполняет команды-действия офлайн.
|
||||||
|
#
|
||||||
|
# Веса дообученной модели (joint_model/, ~115 МБ) в git НЕ идут и в образ НЕ
|
||||||
|
# пекутся — они МОНТИРУЮТСЯ томом в /app/joint_model при запуске (см. RUNBOOK).
|
||||||
|
# Базовая ruBERT-tiny2 запекается в образ при сборке, чтобы на старте не ходить
|
||||||
|
# в интернет.
|
||||||
|
FROM python:3.12-slim
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
ENV HF_HUB_DISABLE_XET=1
|
||||||
|
|
||||||
|
# CPU-torch из cpu-индекса (без CUDA — образ легче).
|
||||||
|
RUN pip install --no-cache-dir --index-url https://download.pytorch.org/whl/cpu torch \
|
||||||
|
&& pip install --no-cache-dir transformers sentencepiece
|
||||||
|
|
||||||
|
# Запекаем базовую модель в кэш образа (runtime без сети).
|
||||||
|
RUN python -c "from transformers import AutoModel, AutoTokenizer; \
|
||||||
|
AutoModel.from_pretrained('cointegrated/rubert-tiny2'); \
|
||||||
|
AutoTokenizer.from_pretrained('cointegrated/rubert-tiny2')"
|
||||||
|
|
||||||
|
COPY joint_model_def.py tablet_map.py predict.py ./
|
||||||
|
|
||||||
|
ENV THRESHOLD=0.7
|
||||||
|
EXPOSE 8091
|
||||||
|
|
||||||
|
# Модель ждём в /app/joint_model (том). predict.py --serve поднимает HTTP:
|
||||||
|
# POST /predict {"text": "..."} -> {intent, slots, score, tool}
|
||||||
|
CMD ["python", "predict.py", "--serve", "--port", "8091"]
|
||||||
178
agent/joint_model_def.py
Normal file
178
agent/joint_model_def.py
Normal file
@@ -0,0 +1,178 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Joint-модель планшета: один энкодер ruBERT-tiny2, две головы — интент и слоты.
|
||||||
|
|
||||||
|
Адаптация из smart-chat/mlx-chat/ft под домен smart-home-tablet. Машинерия
|
||||||
|
(энкодер + две головы, сегментация, сбор слота срезом исходной строки) — та же,
|
||||||
|
что в оригинале; изменены только INTENTS/SLOTS под инструменты планшета.
|
||||||
|
|
||||||
|
Головы читают один и тот же last_hidden_state, форвард один — скорость на
|
||||||
|
уровне обычного классификатора (~1-2 мс на CPU):
|
||||||
|
intent — по [CLS], классификация всей фразы;
|
||||||
|
slots — по каждому токену, BIO-теги (извлечение куска текста).
|
||||||
|
|
||||||
|
Значение слота достаём по символьным offsets ИСХОДНОЙ строки, а не склейкой
|
||||||
|
subword-токенов: «Биг сити лайф» иначе пришлось бы собирать из кусков.
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
|
||||||
|
import torch
|
||||||
|
import torch.nn as nn
|
||||||
|
from transformers import AutoModel, AutoTokenizer
|
||||||
|
|
||||||
|
BASE = "cointegrated/rubert-tiny2"
|
||||||
|
|
||||||
|
# --- Интенты: закрытые enum-действия закодированы в САМ интент -----------------
|
||||||
|
# Принцип: BIO-слоты хороши для свободного текста (город, трек, длительность),
|
||||||
|
# но плохи для выбора из фиксированного набора (направление транспорта,
|
||||||
|
# действие с музыкой, режим). Такие enum надёжнее сделать отдельными интентами,
|
||||||
|
# чем тащить отдельной головой. Поэтому 14 «широких» tools планшета развёрнуты
|
||||||
|
# в узкие интенты. Карта интент → tool: см. tablet_map.py.
|
||||||
|
# none — ПОСЛЕДНИМ: predict() возвращает cfg["intents"][-1] на пустой вход.
|
||||||
|
INTENTS = [
|
||||||
|
"weather_get", # get_weather (+ city)
|
||||||
|
"transport_to_center", # get_transport{to_center}
|
||||||
|
"transport_from_center", # get_transport{from_center}
|
||||||
|
"transport_all", # get_transport{all}
|
||||||
|
"calendar_today", # get_today_events{today}
|
||||||
|
"calendar_week", # get_today_events{week}
|
||||||
|
"calendar_create", # create_event (+ title,date,time,owner)
|
||||||
|
"timer_set", # set_timer (+ duration)
|
||||||
|
"timer_add", # adjust_timer{+} (+ duration)
|
||||||
|
"timer_subtract", # adjust_timer{-} (+ duration)
|
||||||
|
"timer_cancel", # cancel_timer
|
||||||
|
"notes_get", # get_notes
|
||||||
|
"home_state", # get_smart_home_state
|
||||||
|
"purifier_on", # control_air_purifier{turn_on}
|
||||||
|
"purifier_off", # control_air_purifier{turn_off}
|
||||||
|
"purifier_mode", # control_air_purifier{set_mode} (+ mode)
|
||||||
|
"light_on", # control_light{on} (+ device) — НОВЫЙ tool
|
||||||
|
"light_off", # control_light{off} (+ device) — НОВЫЙ tool
|
||||||
|
"music_play", # control_spotify{play + query} (+ query)
|
||||||
|
"music_pause", # control_spotify{pause}
|
||||||
|
"music_resume", # control_spotify{play}
|
||||||
|
"music_next", # control_spotify{next}
|
||||||
|
"music_prev", # control_spotify{previous}
|
||||||
|
"music_volume", # control_spotify{volume} (+ level)
|
||||||
|
"now_playing", # get_now_playing
|
||||||
|
"none", # посторонняя речь → fallback на облачный LLM
|
||||||
|
]
|
||||||
|
|
||||||
|
# --- Слоты: только СВОБОДНЫЙ текст (закрытые значения ушли в интенты) ----------
|
||||||
|
SLOTS = [
|
||||||
|
"city", # город для погоды: «в Питере», «Чаща»
|
||||||
|
"title", # название события: «встреча с врачом»
|
||||||
|
"date", # дата события: «25 июля», «завтра»
|
||||||
|
"time", # время события: «13 часов», «в 15:30»
|
||||||
|
"owner", # чей календарь: «Дани», «Свете»
|
||||||
|
"duration", # длительность таймера: «15 минут», «полтора часа»
|
||||||
|
"mode", # режим очистителя: «ночной», «авто»
|
||||||
|
"device", # какая лампа: «свет у кровати», «лампу для чтения»
|
||||||
|
"query", # что включить: «Ваню Дмитриенко», «Биг сити лайф»
|
||||||
|
"level", # громкость: «50», «30 процентов»
|
||||||
|
]
|
||||||
|
TAGS = ["O"] + [f"{p}-{s}" for s in SLOTS for p in ("B", "I")]
|
||||||
|
|
||||||
|
# Режем текст на слова И цифры И знаки препинания по отдельности — ровно так же,
|
||||||
|
# как при обучении (gen_slots.seg). Разбор целиком делает модель: «13:30.» →
|
||||||
|
# токены «13», «:», «30», «.», которым она сама ставит теги. Никакой
|
||||||
|
# постобработки регуляркой. Граница буква/цифра тоже режет («15минут» слитно).
|
||||||
|
TOKEN_RE = re.compile(r"[^\W\d_]+|\d+|[^\w\s]")
|
||||||
|
|
||||||
|
|
||||||
|
def segment(text):
|
||||||
|
"""Строка → [(токен, начало, конец)] с позициями в исходной строке.
|
||||||
|
|
||||||
|
Позиции нужны, чтобы собрать значение слота срезом ИСХОДНОГО текста:
|
||||||
|
«свет у кровати» — три токена, склеивать вручную = гадать про пробелы.
|
||||||
|
"""
|
||||||
|
return [(m.group(), m.start(), m.end()) for m in TOKEN_RE.finditer(text)]
|
||||||
|
|
||||||
|
|
||||||
|
class JointClassifier(nn.Module):
|
||||||
|
def __init__(self, base=BASE, n_intents=len(INTENTS), n_tags=len(TAGS)):
|
||||||
|
super().__init__()
|
||||||
|
self.bert = AutoModel.from_pretrained(base)
|
||||||
|
h = self.bert.config.hidden_size
|
||||||
|
self.dropout = nn.Dropout(0.1)
|
||||||
|
self.intent_head = nn.Linear(h, n_intents)
|
||||||
|
self.slot_head = nn.Linear(h, n_tags)
|
||||||
|
|
||||||
|
def forward(self, input_ids, attention_mask):
|
||||||
|
h = self.bert(input_ids=input_ids, attention_mask=attention_mask).last_hidden_state
|
||||||
|
h = self.dropout(h)
|
||||||
|
return self.intent_head(h[:, 0]), self.slot_head(h)
|
||||||
|
|
||||||
|
|
||||||
|
def save(model, tok, path):
|
||||||
|
os.makedirs(path, exist_ok=True)
|
||||||
|
torch.save(model.state_dict(), os.path.join(path, "joint.pt"))
|
||||||
|
tok.save_pretrained(path)
|
||||||
|
with open(os.path.join(path, "joint_config.json"), "w", encoding="utf-8") as f:
|
||||||
|
json.dump({"base": BASE, "intents": INTENTS, "tags": TAGS}, f,
|
||||||
|
ensure_ascii=False, indent=2)
|
||||||
|
|
||||||
|
|
||||||
|
def load(path, device="cpu"):
|
||||||
|
with open(os.path.join(path, "joint_config.json"), encoding="utf-8") as f:
|
||||||
|
cfg = json.load(f)
|
||||||
|
tok = AutoTokenizer.from_pretrained(path)
|
||||||
|
model = JointClassifier(cfg["base"], len(cfg["intents"]), len(cfg["tags"]))
|
||||||
|
model.load_state_dict(torch.load(os.path.join(path, "joint.pt"), map_location=device))
|
||||||
|
return model.to(device).eval(), tok, cfg
|
||||||
|
|
||||||
|
|
||||||
|
@torch.no_grad()
|
||||||
|
def predict(model, tok, cfg, text, device="cpu"):
|
||||||
|
"""Строка → (intent, {slot: значение}, score).
|
||||||
|
|
||||||
|
score — вероятность интента (softmax по голове интента). Потребитель
|
||||||
|
(route.ts) сравнивает с порогом: score < 0.7 → трактовать как none и падать
|
||||||
|
на облачный LLM.
|
||||||
|
|
||||||
|
Сегментация на инференсе — ровно как при обучении (segment + split_words),
|
||||||
|
иначе «13:30» рвётся иначе и теги съезжают.
|
||||||
|
"""
|
||||||
|
segs = segment(text)
|
||||||
|
if not segs:
|
||||||
|
return cfg["intents"][-1], {}, 1.0
|
||||||
|
words = [s[0] for s in segs]
|
||||||
|
enc = tok([words], is_split_into_words=True, truncation=True,
|
||||||
|
max_length=48, return_tensors="pt")
|
||||||
|
word_ids = enc.word_ids(0)
|
||||||
|
feed = {k: v.to(device) for k, v in enc.items()}
|
||||||
|
intent_logits, slot_logits = model(feed["input_ids"], feed["attention_mask"])
|
||||||
|
|
||||||
|
probs = torch.softmax(intent_logits[0], -1)
|
||||||
|
best = int(probs.argmax())
|
||||||
|
intent, score = cfg["intents"][best], float(probs[best])
|
||||||
|
tag_ids = slot_logits[0].argmax(-1).tolist()
|
||||||
|
|
||||||
|
# тег на слово — с первого subword, хвост игнорируем (у него при обучении -100)
|
||||||
|
word_tags, prev = {}, None
|
||||||
|
for tid, wid in zip(tag_ids, word_ids):
|
||||||
|
if wid is not None and wid != prev:
|
||||||
|
word_tags[wid] = cfg["tags"][tid]
|
||||||
|
prev = wid
|
||||||
|
|
||||||
|
# BIO-склейка: значение слота — срез исходного текста от начала первого
|
||||||
|
# токена спана до конца последнего.
|
||||||
|
slots, cur, start, end = {}, None, 0, 0
|
||||||
|
for i, (_, s, e) in enumerate(segs):
|
||||||
|
tag = word_tags.get(i, "O")
|
||||||
|
if tag.startswith("B-"):
|
||||||
|
if cur:
|
||||||
|
slots.setdefault(cur, text[start:end])
|
||||||
|
cur, start, end = tag[2:], s, e
|
||||||
|
elif tag.startswith("I-") and cur == tag[2:]:
|
||||||
|
end = e
|
||||||
|
else:
|
||||||
|
if cur:
|
||||||
|
slots.setdefault(cur, text[start:end])
|
||||||
|
cur = None
|
||||||
|
if cur:
|
||||||
|
slots.setdefault(cur, text[start:end])
|
||||||
|
|
||||||
|
slots = {k: v for k, v in slots.items() if v}
|
||||||
|
return intent, slots, score
|
||||||
104
agent/predict.py
Normal file
104
agent/predict.py
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Инференс joint-модели планшета: CLI и HTTP-служба.
|
||||||
|
|
||||||
|
Разовый разбор:
|
||||||
|
python predict.py "поставь таймер на 15 минут"
|
||||||
|
python predict.py "включи Ваню Дмитриенко"
|
||||||
|
|
||||||
|
HTTP-служба (для app/api/voice/chat/route.ts как локальный «мозг»):
|
||||||
|
python predict.py --serve --port 8091
|
||||||
|
# POST /predict {"text": "..."} → {intent, slots, score, tool}
|
||||||
|
# score < THRESHOLD → intent принудительно none (порог на стороне службы,
|
||||||
|
# но потребитель всё равно решает сам падать ли на облачный LLM).
|
||||||
|
|
||||||
|
Порог THRESHOLD=0.7 по умолчанию (переопределяется env).
|
||||||
|
"""
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
import joint_model_def as jm
|
||||||
|
import tablet_map
|
||||||
|
|
||||||
|
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
MODEL_DIR = os.path.join(HERE, "joint_model")
|
||||||
|
THRESHOLD = float(os.environ.get("THRESHOLD", "0.7"))
|
||||||
|
|
||||||
|
_model = _tok = _cfg = None
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_loaded():
|
||||||
|
global _model, _tok, _cfg
|
||||||
|
if _model is None:
|
||||||
|
_model, _tok, _cfg = jm.load(MODEL_DIR)
|
||||||
|
jm.predict(_model, _tok, _cfg, "прогрев") # первый форвард медленнее
|
||||||
|
|
||||||
|
|
||||||
|
def analyze(text):
|
||||||
|
_ensure_loaded()
|
||||||
|
intent, slots, score = jm.predict(_model, _tok, _cfg, text)
|
||||||
|
if score < THRESHOLD:
|
||||||
|
intent = "none"
|
||||||
|
tool = tablet_map.to_tool_call(intent, slots) if intent != "none" else None
|
||||||
|
return {"intent": intent, "slots": slots, "score": round(score, 3), "tool": tool}
|
||||||
|
|
||||||
|
|
||||||
|
def serve(port):
|
||||||
|
from http.server import BaseHTTPRequestHandler, HTTPServer
|
||||||
|
|
||||||
|
class Handler(BaseHTTPRequestHandler):
|
||||||
|
def _send(self, code, obj):
|
||||||
|
body = json.dumps(obj, ensure_ascii=False).encode("utf-8")
|
||||||
|
self.send_response(code)
|
||||||
|
self.send_header("Content-Type", "application/json; charset=utf-8")
|
||||||
|
self.send_header("Content-Length", str(len(body)))
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(body)
|
||||||
|
|
||||||
|
def do_GET(self):
|
||||||
|
if self.path == "/health":
|
||||||
|
self._send(200, {"ok": True})
|
||||||
|
else:
|
||||||
|
self._send(404, {"error": "not found"})
|
||||||
|
|
||||||
|
def do_POST(self):
|
||||||
|
if self.path != "/predict":
|
||||||
|
return self._send(404, {"error": "not found"})
|
||||||
|
n = int(self.headers.get("Content-Length", 0))
|
||||||
|
try:
|
||||||
|
data = json.loads(self.rfile.read(n) or b"{}")
|
||||||
|
text = (data.get("text") or "").strip()
|
||||||
|
except Exception:
|
||||||
|
return self._send(400, {"error": "bad json"})
|
||||||
|
if not text:
|
||||||
|
return self._send(400, {"error": "empty text"})
|
||||||
|
self._send(200, analyze(text))
|
||||||
|
|
||||||
|
def log_message(self, *a):
|
||||||
|
pass # тихо
|
||||||
|
|
||||||
|
_ensure_loaded()
|
||||||
|
print(f"tablet-agent слушает http://127.0.0.1:{port} (порог {THRESHOLD})")
|
||||||
|
HTTPServer(("127.0.0.1", port), Handler).serve_forever()
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
ap = argparse.ArgumentParser()
|
||||||
|
ap.add_argument("text", nargs="*", help="запрос для разбора")
|
||||||
|
ap.add_argument("--serve", action="store_true", help="поднять HTTP-службу")
|
||||||
|
ap.add_argument("--port", type=int, default=8091)
|
||||||
|
args = ap.parse_args()
|
||||||
|
|
||||||
|
if args.serve:
|
||||||
|
serve(args.port)
|
||||||
|
return
|
||||||
|
|
||||||
|
if not args.text:
|
||||||
|
print("Использование: python predict.py \"текст запроса\"")
|
||||||
|
sys.exit(1)
|
||||||
|
print(json.dumps(analyze(" ".join(args.text)), ensure_ascii=False, indent=2))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
4
agent/requirements.txt
Normal file
4
agent/requirements.txt
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
# Инференс joint-модели агента (CPU). Совпадает с tablet-agent/requirements.txt.
|
||||||
|
torch>=2.2
|
||||||
|
transformers>=4.40
|
||||||
|
sentencepiece
|
||||||
155
agent/tablet_map.py
Normal file
155
agent/tablet_map.py
Normal file
@@ -0,0 +1,155 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Карта: интент joint-модели → вызов tool планшета (smart-home-tablet).
|
||||||
|
|
||||||
|
Модель отдаёт (intent, slots) — «что сделать» и «куски текста параметров».
|
||||||
|
Приведение к боевому tool-call (направление, действие, режим в enum; «15 минут»
|
||||||
|
→ секунды; «Дани» → daniil) — здесь, на стороне потребителя. Модель конвенций
|
||||||
|
приложения не знает, она видела только текст запроса.
|
||||||
|
|
||||||
|
Это референс для интеграции в app/api/voice/chat/route.ts. На проде логика
|
||||||
|
переписывается на TypeScript, но соответствие интент→tool берётся отсюда.
|
||||||
|
"""
|
||||||
|
import re
|
||||||
|
|
||||||
|
# intent → (tool_name, фиксированные аргументы). Слоты домешиваются отдельно.
|
||||||
|
INTENT_TO_TOOL = {
|
||||||
|
"weather_get": ("get_weather", {}),
|
||||||
|
"transport_to_center": ("get_transport", {"direction": "to_center"}),
|
||||||
|
"transport_from_center": ("get_transport", {"direction": "from_center"}),
|
||||||
|
"transport_all": ("get_transport", {"direction": "all"}),
|
||||||
|
"calendar_today": ("get_today_events", {"range": "today"}),
|
||||||
|
"calendar_week": ("get_today_events", {"range": "week"}),
|
||||||
|
"calendar_create": ("create_event", {}),
|
||||||
|
"timer_set": ("set_timer", {}),
|
||||||
|
"timer_add": ("adjust_timer", {"sign": +1}),
|
||||||
|
"timer_subtract": ("adjust_timer", {"sign": -1}),
|
||||||
|
"timer_cancel": ("cancel_timer", {}),
|
||||||
|
"notes_get": ("get_notes", {}),
|
||||||
|
"home_state": ("get_smart_home_state", {}),
|
||||||
|
"purifier_on": ("control_air_purifier", {"action": "turn_on"}),
|
||||||
|
"purifier_off": ("control_air_purifier", {"action": "turn_off"}),
|
||||||
|
"purifier_mode": ("control_air_purifier", {"action": "set_mode"}),
|
||||||
|
"light_on": ("control_light", {"action": "turn_on"}), # НОВЫЙ tool
|
||||||
|
"light_off": ("control_light", {"action": "turn_off"}), # НОВЫЙ tool
|
||||||
|
"music_play": ("control_spotify", {"action": "play"}),
|
||||||
|
"music_pause": ("control_spotify", {"action": "pause"}),
|
||||||
|
"music_resume": ("control_spotify", {"action": "play"}),
|
||||||
|
"music_next": ("control_spotify", {"action": "next"}),
|
||||||
|
"music_prev": ("control_spotify", {"action": "previous"}),
|
||||||
|
"music_volume": ("control_spotify", {"action": "volume"}),
|
||||||
|
"now_playing": ("get_now_playing", {}),
|
||||||
|
}
|
||||||
|
|
||||||
|
OWNER_MAP = {
|
||||||
|
"дани": "daniil", "даниил": "daniil", "даниила": "daniil",
|
||||||
|
"даниилу": "daniil", "даня": "daniil", "мне": "daniil", "мой": "daniil",
|
||||||
|
"света": "sveta", "свете": "sveta", "светы": "sveta", "свету": "sveta",
|
||||||
|
}
|
||||||
|
|
||||||
|
MODE_MAP = {
|
||||||
|
"авто": "Auto", "автоматический": "Auto",
|
||||||
|
"ночной": "Night", "ночь": "Night",
|
||||||
|
"высокий": "High", "максимальный": "High", "турбо": "High",
|
||||||
|
"максимум": "High", "тихий": "Night", "низкий": "Night",
|
||||||
|
}
|
||||||
|
|
||||||
|
_NUM_WORDS = {
|
||||||
|
"полминуты": 30, "полчаса": 1800, "минуту": 60, "минуты": 60, "минут": 60,
|
||||||
|
"полторы": 90, "полтора": 5400, "пару": 120, "несколько": 180,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def parse_duration_seconds(text):
|
||||||
|
"""«15 минут» → 900, «полтора часа» → 5400, «30 секунд» → 30.
|
||||||
|
|
||||||
|
Грубый разбор для set/adjust таймера. На проде можно взять готовую
|
||||||
|
JS-либу разбора длительности; здесь — референс.
|
||||||
|
"""
|
||||||
|
t = text.lower().strip()
|
||||||
|
if t in ("полчаса",):
|
||||||
|
return 1800
|
||||||
|
if t in ("полминуты",):
|
||||||
|
return 30
|
||||||
|
if t in ("полтора часа", "полтора",):
|
||||||
|
return 5400
|
||||||
|
if t in ("полторы минуты",):
|
||||||
|
return 90
|
||||||
|
m = re.search(r"(\d+)", t)
|
||||||
|
n = int(m.group(1)) if m else 1
|
||||||
|
if "сек" in t:
|
||||||
|
return n
|
||||||
|
if "час" in t:
|
||||||
|
return n * 3600
|
||||||
|
if "мин" in t:
|
||||||
|
return n * 60
|
||||||
|
return n * 60 # по умолчанию минуты
|
||||||
|
|
||||||
|
|
||||||
|
def to_tool_call(intent, slots):
|
||||||
|
"""(intent, slots) → {tool, args} или None для none/неизвестного."""
|
||||||
|
if intent not in INTENT_TO_TOOL:
|
||||||
|
return None
|
||||||
|
tool, args = INTENT_TO_TOOL[intent]
|
||||||
|
args = dict(args)
|
||||||
|
|
||||||
|
if intent == "weather_get" and "city" in slots:
|
||||||
|
# снять предлог: «в Питере» → «Питере» (город приложение нормализует само)
|
||||||
|
args["city"] = re.sub(r"^в\s+", "", slots["city"], flags=re.I)
|
||||||
|
|
||||||
|
elif intent == "calendar_create":
|
||||||
|
if "title" in slots:
|
||||||
|
args["title"] = slots["title"]
|
||||||
|
if "date" in slots:
|
||||||
|
args["date"] = slots["date"] # «25 июля»/«завтра» → YYYY-MM-DD на стороне приложения
|
||||||
|
if "time" in slots:
|
||||||
|
args["start_time"] = slots["time"]
|
||||||
|
args["owner"] = OWNER_MAP.get(slots.get("owner", "").lower().strip(), "daniil")
|
||||||
|
|
||||||
|
elif intent in ("timer_set",):
|
||||||
|
if "duration" in slots:
|
||||||
|
args["seconds"] = parse_duration_seconds(slots["duration"])
|
||||||
|
args.setdefault("label", "таймер")
|
||||||
|
|
||||||
|
elif intent in ("timer_add", "timer_subtract"):
|
||||||
|
sign = args.pop("sign", 1)
|
||||||
|
secs = parse_duration_seconds(slots.get("duration", "1 минута"))
|
||||||
|
args["delta_seconds"] = sign * secs
|
||||||
|
args.setdefault("label", "таймер")
|
||||||
|
|
||||||
|
elif intent == "purifier_mode":
|
||||||
|
raw = slots.get("mode", "").lower().strip()
|
||||||
|
# точное совпадение, затем подстрока (страховка от «ночной режим» и т.п.)
|
||||||
|
mode = MODE_MAP.get(raw)
|
||||||
|
if mode is None:
|
||||||
|
mode = next((v for k, v in MODE_MAP.items() if k in raw), "Auto")
|
||||||
|
args["mode"] = mode
|
||||||
|
|
||||||
|
elif intent in ("light_on", "light_off"):
|
||||||
|
if "device" in slots:
|
||||||
|
args["device"] = slots["device"] # приложение сопоставит с entity HA
|
||||||
|
|
||||||
|
elif intent == "music_play":
|
||||||
|
if "query" in slots:
|
||||||
|
args["query"] = slots["query"]
|
||||||
|
|
||||||
|
elif intent == "music_volume":
|
||||||
|
m = re.search(r"(\d+)", slots.get("level", ""))
|
||||||
|
if m:
|
||||||
|
args["volume"] = int(m.group(1))
|
||||||
|
|
||||||
|
return {"tool": tool, "args": args}
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
# быстрая проверка карты без модели
|
||||||
|
demo = [
|
||||||
|
("timer_set", {"duration": "15 минут"}),
|
||||||
|
("timer_add", {"duration": "5 минут"}),
|
||||||
|
("calendar_create", {"title": "встречу", "date": "25 июля",
|
||||||
|
"time": "13 часов", "owner": "Дани"}),
|
||||||
|
("purifier_mode", {"mode": "ночной"}),
|
||||||
|
("music_volume", {"level": "50 процентов"}),
|
||||||
|
("weather_get", {"city": "в Питере"}),
|
||||||
|
]
|
||||||
|
for intent, slots in demo:
|
||||||
|
print(f"{intent:16s} {slots} -> {to_tool_call(intent, slots)}")
|
||||||
@@ -9,6 +9,7 @@ import { fetch as undiciFetch, ProxyAgent } from 'undici'
|
|||||||
import { voiceBus } from '@/lib/voice-bus'
|
import { voiceBus } from '@/lib/voice-bus'
|
||||||
import { systemPrompt } from '@/lib/voice-prompts'
|
import { systemPrompt } from '@/lib/voice-prompts'
|
||||||
import { TOOL_SCHEMAS, executeTool } from '@/lib/tools/_registry'
|
import { TOOL_SCHEMAS, executeTool } from '@/lib/tools/_registry'
|
||||||
|
import { tryLocalAgent } from '@/lib/agent-local'
|
||||||
import { cleanForSpeech, stripFillers, isResetCommand } from '@/lib/voice-text'
|
import { cleanForSpeech, stripFillers, isResetCommand } from '@/lib/voice-text'
|
||||||
import { loadHistory, saveHistory, resetHistory, HistoryMessage } from '@/lib/voice-history'
|
import { loadHistory, saveHistory, resetHistory, HistoryMessage } from '@/lib/voice-history'
|
||||||
import { promises as nodeFs } from 'node:fs'
|
import { promises as nodeFs } from 'node:fs'
|
||||||
@@ -146,6 +147,23 @@ export async function POST(req: Request) {
|
|||||||
const newTurns: HistoryMessage[] = [{ role: 'user', content: userText }]
|
const newTurns: HistoryMessage[] = [{ role: 'user', content: userText }]
|
||||||
let finalText = ''
|
let finalText = ''
|
||||||
|
|
||||||
|
// ======== Локальный агент (fast-path) ========
|
||||||
|
// Если задан AGENT_URL — команды-действия исполняет локальная intent-служба
|
||||||
|
// за ~2 мс, минуя облачный LLM. Инфо-запросы и всё неуверенное падают в LLM
|
||||||
|
// ниже. Любая ошибка → тоже fallback. Без AGENT_URL — поведение не меняется.
|
||||||
|
try {
|
||||||
|
const fast = await tryLocalAgent(userText, agent)
|
||||||
|
if (fast) {
|
||||||
|
const assistant: HistoryMessage = { role: 'assistant', content: fast.text }
|
||||||
|
await saveHistory(agent, [...history, ...newTurns, assistant])
|
||||||
|
emitVoice('response', agent, fast.text)
|
||||||
|
console.log(`[voice/chat] local-agent ${agent} intent=${fast.intent}`)
|
||||||
|
return NextResponse.json({ text: fast.text, via: 'local-agent' })
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.log('[voice/chat] local-agent skip:', (e as Error).message)
|
||||||
|
}
|
||||||
|
|
||||||
// ======== GROQ ========
|
// ======== GROQ ========
|
||||||
if (provider === 'groq') {
|
if (provider === 'groq') {
|
||||||
const groqModel = settings.groqModel || 'llama-3.3-70b-versatile'
|
const groqModel = settings.groqModel || 'llama-3.3-70b-versatile'
|
||||||
|
|||||||
100
app/globals.css
100
app/globals.css
@@ -8,29 +8,29 @@
|
|||||||
Tokens — dark (default)
|
Tokens — dark (default)
|
||||||
—————————————————————————————— */
|
—————————————————————————————— */
|
||||||
:root {
|
:root {
|
||||||
/* Surfaces */
|
/* Surfaces — глубокий индиго с холодным подтоном (тема «Космо») */
|
||||||
--bg: #0b0b14;
|
--bg: #07070f;
|
||||||
--bg-secondary: #101020;
|
--bg-secondary: #0c0c1c;
|
||||||
--surface-1: #15152a;
|
--surface-1: #14142c;
|
||||||
--surface-2: #1c1c36;
|
--surface-2: #1c1c3e;
|
||||||
--surface-3: #242444;
|
--surface-3: #26264f;
|
||||||
--surface-hover: #1e1e3c;
|
--surface-hover: #22224a;
|
||||||
|
|
||||||
/* Borders */
|
/* Borders */
|
||||||
--border-subtle: rgba(255, 255, 255, 0.06);
|
--border-subtle: rgba(255, 255, 255, 0.07);
|
||||||
--border-strong: rgba(255, 255, 255, 0.12);
|
--border-strong: rgba(150, 160, 255, 0.18);
|
||||||
--hairline: rgba(255, 255, 255, 0.08);
|
--hairline: rgba(255, 255, 255, 0.08);
|
||||||
|
|
||||||
/* Text */
|
/* Text */
|
||||||
--text-primary: rgba(255, 255, 255, 0.95);
|
--text-primary: rgba(255, 255, 255, 0.96);
|
||||||
--text-secondary: rgba(255, 255, 255, 0.6);
|
--text-secondary: rgba(200, 205, 240, 0.62);
|
||||||
--text-tertiary: rgba(255, 255, 255, 0.38);
|
--text-tertiary: rgba(190, 195, 235, 0.4);
|
||||||
|
|
||||||
/* Accents — UI */
|
/* Accents — UI (ярче и насыщеннее для смелого визуала) */
|
||||||
--accent: #818cf8;
|
--accent: #8b93ff;
|
||||||
--accent-strong: #6366f1;
|
--accent-strong: #6d5efc;
|
||||||
--accent-secondary: #22d3ee;
|
--accent-secondary: #2ee6f6;
|
||||||
--accent-glow: rgba(129, 140, 248, 0.22);
|
--accent-glow: rgba(139, 120, 252, 0.32);
|
||||||
|
|
||||||
/* Data palette — semantic, theme-aware */
|
/* Data palette — semantic, theme-aware */
|
||||||
--data-cool: #38bdf8; /* небо, влажность, охлаждение */
|
--data-cool: #38bdf8; /* небо, влажность, охлаждение */
|
||||||
@@ -52,11 +52,12 @@
|
|||||||
--data-rose-bg: color-mix(in srgb, var(--data-rose) 12%, var(--surface-2));
|
--data-rose-bg: color-mix(in srgb, var(--data-rose) 12%, var(--surface-2));
|
||||||
--data-violet-bg: color-mix(in srgb, var(--data-violet) 12%, var(--surface-2));
|
--data-violet-bg: color-mix(in srgb, var(--data-violet) 12%, var(--surface-2));
|
||||||
|
|
||||||
/* Brand gradients */
|
/* Brand gradients — многоступенчатые, живые (смелый визуал) */
|
||||||
--gradient-primary: linear-gradient(135deg, #6366f1, #8b5cf6);
|
--gradient-primary: linear-gradient(135deg, #6d5efc 0%, #a855f7 50%, #2ee6f6 100%);
|
||||||
--gradient-warm: linear-gradient(135deg, #f59e0b, #ef4444);
|
--gradient-warm: linear-gradient(135deg, #fbbf24 0%, #fb7185 100%);
|
||||||
--gradient-cool: linear-gradient(135deg, #06b6d4, #3b82f6);
|
--gradient-cool: linear-gradient(135deg, #22d3ee 0%, #6366f1 100%);
|
||||||
--gradient-green: linear-gradient(135deg, #10b981, #34d399);
|
--gradient-green: linear-gradient(135deg, #10b981 0%, #5eead4 100%);
|
||||||
|
--gradient-aurora: linear-gradient(120deg, #6d5efc 0%, #a855f7 40%, #2ee6f6 100%);
|
||||||
|
|
||||||
/* Shadows */
|
/* Shadows */
|
||||||
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.4);
|
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.4);
|
||||||
@@ -219,19 +220,32 @@ html, body {
|
|||||||
filter: blur(80px);
|
filter: blur(80px);
|
||||||
}
|
}
|
||||||
.bg-ambient::before {
|
.bg-ambient::before {
|
||||||
width: 560px; height: 560px;
|
width: 680px; height: 680px;
|
||||||
background: radial-gradient(circle, var(--accent-glow) 0%, transparent 70%);
|
background: radial-gradient(circle, var(--accent-glow) 0%, transparent 68%);
|
||||||
top: -180px; right: -80px;
|
top: -200px; right: -100px;
|
||||||
animation: float1 22s ease-in-out infinite;
|
animation: float1 22s ease-in-out infinite;
|
||||||
}
|
}
|
||||||
.bg-ambient::after {
|
.bg-ambient::after {
|
||||||
width: 480px; height: 480px;
|
width: 600px; height: 600px;
|
||||||
background: radial-gradient(circle, rgba(139, 92, 246, 0.14) 0%, transparent 70%);
|
background: radial-gradient(circle, rgba(46, 230, 246, 0.16) 0%, transparent 68%);
|
||||||
bottom: -140px; left: -60px;
|
bottom: -180px; left: -80px;
|
||||||
animation: float2 27s ease-in-out infinite;
|
animation: float2 27s ease-in-out infinite;
|
||||||
}
|
}
|
||||||
.light .bg-ambient::after {
|
.light .bg-ambient::after {
|
||||||
background: radial-gradient(circle, rgba(99, 102, 241, 0.1) 0%, transparent 70%);
|
background: radial-gradient(circle, rgba(8, 145, 178, 0.12) 0%, transparent 70%);
|
||||||
|
}
|
||||||
|
/* Третий, центральный ореол — глубина «космоса» */
|
||||||
|
.bg-ambient .aurora-3 {
|
||||||
|
position: absolute;
|
||||||
|
width: 520px; height: 520px;
|
||||||
|
border-radius: 50%;
|
||||||
|
filter: blur(90px);
|
||||||
|
background: radial-gradient(circle, rgba(168, 85, 247, 0.14) 0%, transparent 70%);
|
||||||
|
top: 30%; left: 40%;
|
||||||
|
animation: float1 31s ease-in-out infinite reverse;
|
||||||
|
}
|
||||||
|
.light .bg-ambient .aurora-3 {
|
||||||
|
background: radial-gradient(circle, rgba(124, 58, 237, 0.08) 0%, transparent 70%);
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes float1 {
|
@keyframes float1 {
|
||||||
@@ -280,7 +294,16 @@ html, body {
|
|||||||
content: '';
|
content: '';
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
background: radial-gradient(circle at top right, var(--accent-glow), transparent 60%);
|
background: radial-gradient(circle at top right, var(--accent-glow), transparent 58%);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.focus-card-accent::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
height: 3px;
|
||||||
|
background: var(--gradient-aurora);
|
||||||
|
opacity: 0.9;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -292,6 +315,23 @@ html, body {
|
|||||||
}
|
}
|
||||||
.glass-card:hover { background: var(--surface-hover); }
|
.glass-card:hover { background: var(--surface-hover); }
|
||||||
|
|
||||||
|
/* ——— Смелые опциональные утилиты (opt-in, раскладку не меняют) ——— */
|
||||||
|
/* Светящаяся рамка-акцент для избранных карточек */
|
||||||
|
.card-glow {
|
||||||
|
box-shadow: var(--shadow-md),
|
||||||
|
0 0 0 1px color-mix(in srgb, var(--accent) 35%, transparent),
|
||||||
|
0 0 28px -6px var(--accent-glow);
|
||||||
|
}
|
||||||
|
/* Верхний блик — тонкая градиентная кромка сверху */
|
||||||
|
.sheen-top { box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.06); }
|
||||||
|
/* Крупные числа в цвете aurora-градиента */
|
||||||
|
.num-aurora {
|
||||||
|
background: var(--gradient-aurora);
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
background-clip: text;
|
||||||
|
-webkit-text-fill-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
/* Hairline divider helper */
|
/* Hairline divider helper */
|
||||||
.divider {
|
.divider {
|
||||||
height: 1px;
|
height: 1px;
|
||||||
|
|||||||
@@ -239,7 +239,7 @@ function LockScreen({ onUnlock }: { onUnlock: () => void }) {
|
|||||||
position: 'fixed', inset: 0, zIndex: 200, background: '#0c0c18',
|
position: 'fixed', inset: 0, zIndex: 200, background: '#0c0c18',
|
||||||
display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 40,
|
display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 40,
|
||||||
}}>
|
}}>
|
||||||
<div className="bg-ambient" />
|
<div className="bg-ambient"><div className="aurora-3" /></div>
|
||||||
<div style={{ textAlign: 'center', position: 'relative', zIndex: 1 }}>
|
<div style={{ textAlign: 'center', position: 'relative', zIndex: 1 }}>
|
||||||
<div style={{ fontSize: 64, fontWeight: 800, color: 'var(--text-primary)', letterSpacing: '-3px', fontVariantNumeric: 'tabular-nums' }}>
|
<div style={{ fontSize: 64, fontWeight: 800, color: 'var(--text-primary)', letterSpacing: '-3px', fontVariantNumeric: 'tabular-nums' }}>
|
||||||
{time.toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' })}
|
{time.toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' })}
|
||||||
@@ -1126,7 +1126,7 @@ function HomePageInner() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={getWeatherBgClass(weather?.desc || null)} style={{ display: 'flex', height: '100dvh', width: '100%', background: 'var(--bg)', overflow: 'hidden', position: 'relative' }}>
|
<div className={getWeatherBgClass(weather?.desc || null)} style={{ display: 'flex', height: '100dvh', width: '100%', background: 'var(--bg)', overflow: 'hidden', position: 'relative' }}>
|
||||||
<div className="bg-ambient" />
|
<div className="bg-ambient"><div className="aurora-3" /></div>
|
||||||
|
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
{screensaverActive && (
|
{screensaverActive && (
|
||||||
|
|||||||
94
lib/agent-local.ts
Normal file
94
lib/agent-local.ts
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
/**
|
||||||
|
* Быстрый путь через локальный агент (tablet-agent) — sidecar-служба
|
||||||
|
* intent+slots на мини-ПК. Распознаёт команды офлайн за ~2 мс и исполняет
|
||||||
|
* их напрямую, минуя облачный LLM.
|
||||||
|
*
|
||||||
|
* Включается переменной окружения AGENT_URL (напр. http://agent:8091).
|
||||||
|
* Если она не задана — tryLocalAgent сразу возвращает null и планшет работает
|
||||||
|
* ровно как раньше (облачный LLM). Любая ошибка службы → тоже null → fallback.
|
||||||
|
*
|
||||||
|
* Разделение ответственности:
|
||||||
|
* • Команды-ДЕЙСТВИЯ (таймер/свет/музыка/очиститель/событие) — исполняет
|
||||||
|
* локальный агент и отвечает коротким подтверждением. Быстро, офлайн.
|
||||||
|
* • Запросы ИНФОРМАЦИИ (погода/календарь/что играет/состояние дома) и всё
|
||||||
|
* неуверенное/none — проваливаются в облачный LLM: он лучше формулирует
|
||||||
|
* ответ по данным.
|
||||||
|
*/
|
||||||
|
import { executeTool } from './tools/_registry'
|
||||||
|
import type { AgentId } from './tools/_types'
|
||||||
|
|
||||||
|
const THRESHOLD = parseFloat(process.env.AGENT_THRESHOLD || '0.7')
|
||||||
|
|
||||||
|
// Интенты-действия, которые локальный агент исполняет сам (короткое подтверждение).
|
||||||
|
// Информационные интенты (weather_get, transport_*, calendar_today/week,
|
||||||
|
// notes_get, home_state, now_playing) сюда НЕ входят — их формулирует LLM.
|
||||||
|
const ACTION_INTENTS = new Set([
|
||||||
|
'timer_set', 'timer_add', 'timer_subtract', 'timer_cancel',
|
||||||
|
'purifier_on', 'purifier_off', 'purifier_mode',
|
||||||
|
'light_on', 'light_off',
|
||||||
|
'music_play', 'music_pause', 'music_resume', 'music_next', 'music_prev', 'music_volume',
|
||||||
|
'calendar_create',
|
||||||
|
])
|
||||||
|
|
||||||
|
interface AgentResponse {
|
||||||
|
intent: string
|
||||||
|
slots: Record<string, string>
|
||||||
|
score: number
|
||||||
|
tool: { tool: string; args: Record<string, any> } | null
|
||||||
|
}
|
||||||
|
|
||||||
|
async function queryAgent(text: string): Promise<AgentResponse | null> {
|
||||||
|
const base = process.env.AGENT_URL
|
||||||
|
if (!base) return null
|
||||||
|
const r = await fetch(`${base.replace(/\/$/, '')}/predict`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ text }),
|
||||||
|
signal: AbortSignal.timeout(1500),
|
||||||
|
})
|
||||||
|
if (!r.ok) return null
|
||||||
|
return r.json()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Короткие естественные подтверждения по интенту. Используют слоты, где уместно.
|
||||||
|
function confirmText(intent: string, slots: Record<string, string>): string {
|
||||||
|
const dur = slots.duration ? ` на ${slots.duration}` : ''
|
||||||
|
switch (intent) {
|
||||||
|
case 'timer_set': return `Поставила таймер${dur}.`
|
||||||
|
case 'timer_add': return `Добавила${slots.duration ? ' ' + slots.duration : ' время'} к таймеру.`
|
||||||
|
case 'timer_subtract': return `Убавила${slots.duration ? ' ' + slots.duration : ''} у таймера.`
|
||||||
|
case 'timer_cancel': return `Таймер отменила.`
|
||||||
|
case 'purifier_on': return `Включаю очиститель.`
|
||||||
|
case 'purifier_off': return `Выключаю очиститель.`
|
||||||
|
case 'purifier_mode': return `Переключаю режим очистителя${slots.mode ? ' на ' + slots.mode : ''}.`
|
||||||
|
case 'light_on': return `Включаю ${slots.device || 'свет'}.`
|
||||||
|
case 'light_off': return `Выключаю ${slots.device || 'свет'}.`
|
||||||
|
case 'music_play': return `Включаю${slots.query ? ' ' + slots.query : ' музыку'}.`
|
||||||
|
case 'music_pause': return `Поставила на паузу.`
|
||||||
|
case 'music_resume': return `Продолжаю.`
|
||||||
|
case 'music_next': return `Следующий трек.`
|
||||||
|
case 'music_prev': return `Предыдущий трек.`
|
||||||
|
case 'music_volume': return `Готово, громкость изменила.`
|
||||||
|
case 'calendar_create': return `Добавила событие${slots.title ? ' «' + slots.title + '»' : ''}${slots.date ? ' на ' + slots.date : ''}.`
|
||||||
|
default: return `Готово.`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Пытается обработать запрос локальным агентом.
|
||||||
|
* Возвращает { text } если команда-действие исполнена, иначе null (→ LLM).
|
||||||
|
*/
|
||||||
|
export async function tryLocalAgent(
|
||||||
|
text: string,
|
||||||
|
agent: AgentId,
|
||||||
|
): Promise<{ text: string; intent: string } | null> {
|
||||||
|
const res = await queryAgent(text)
|
||||||
|
if (!res) return null
|
||||||
|
if (res.score < THRESHOLD) return null
|
||||||
|
if (!ACTION_INTENTS.has(res.intent)) return null // инфо-запросы → LLM
|
||||||
|
if (!res.tool) return null
|
||||||
|
|
||||||
|
const result = await executeTool(res.tool.tool, res.tool.args, agent)
|
||||||
|
if (result && (result as any).error) return null // ошибка tool → пусть LLM разберётся/озвучит
|
||||||
|
return { text: confirmText(res.intent, res.slots || {}), intent: res.intent }
|
||||||
|
}
|
||||||
@@ -14,6 +14,7 @@ import { tools as timerTools } from './timers'
|
|||||||
import { tool as notes } from './notes'
|
import { tool as notes } from './notes'
|
||||||
import { tools as smartHomeTools } from './smart-home'
|
import { tools as smartHomeTools } from './smart-home'
|
||||||
import { tools as spotifyTools } from './spotify'
|
import { tools as spotifyTools } from './spotify'
|
||||||
|
import { tools as lightTools } from './lights'
|
||||||
|
|
||||||
const ALL_TOOLS: VoiceTool[] = [
|
const ALL_TOOLS: VoiceTool[] = [
|
||||||
weather,
|
weather,
|
||||||
@@ -23,6 +24,7 @@ const ALL_TOOLS: VoiceTool[] = [
|
|||||||
notes,
|
notes,
|
||||||
...smartHomeTools,
|
...smartHomeTools,
|
||||||
...spotifyTools,
|
...spotifyTools,
|
||||||
|
...lightTools,
|
||||||
]
|
]
|
||||||
|
|
||||||
export const TOOL_SCHEMAS = ALL_TOOLS.map((t) => t.schema)
|
export const TOOL_SCHEMAS = ALL_TOOLS.map((t) => t.schema)
|
||||||
|
|||||||
102
lib/tools/lights.ts
Normal file
102
lib/tools/lights.ts
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
import type { VoiceTool } from './_types'
|
||||||
|
import { tabletJson } from './_http'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Управление светом (лампами) через Home Assistant.
|
||||||
|
*
|
||||||
|
* Голосовой агент (tablet-agent) распознаёт интенты light_on/light_off и слот
|
||||||
|
* device («свет у кровати», «лампу для чтения»). Здесь имя лампы сопоставляется
|
||||||
|
* с entity_id Home Assistant.
|
||||||
|
*
|
||||||
|
* Реальные entity_id задаются переменной окружения LIGHT_ENTITIES_JSON (в
|
||||||
|
* tablet.env), например:
|
||||||
|
* LIGHT_ENTITIES_JSON={"bedside":"light.yeelight_bedside","reading":"light.reading_lamp","default":"light.bedside"}
|
||||||
|
* Ключи — короткие имена (см. DEVICE_ALIASES ниже). Пока переменная не задана,
|
||||||
|
* используются заглушки light.* — команды проходят как mock (HA вернёт success).
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Короткое имя лампы → entity_id. Переопределяется LIGHT_ENTITIES_JSON.
|
||||||
|
const DEFAULT_ENTITIES: Record<string, string> = {
|
||||||
|
bedside: 'light.bedside', // «Свет у кровати»
|
||||||
|
reading: 'light.reading', // «Лампа для чтения»
|
||||||
|
default: 'light.bedside', // если лампа не названа — эта
|
||||||
|
}
|
||||||
|
|
||||||
|
function lightEntities(): Record<string, string> {
|
||||||
|
const raw = process.env.LIGHT_ENTITIES_JSON
|
||||||
|
if (raw) {
|
||||||
|
try {
|
||||||
|
return { ...DEFAULT_ENTITIES, ...JSON.parse(raw) }
|
||||||
|
} catch {
|
||||||
|
// некорректный JSON — падаем на дефолты
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return DEFAULT_ENTITIES
|
||||||
|
}
|
||||||
|
|
||||||
|
// Разговорные имена ламп → короткий ключ. Слот device приходит куском текста.
|
||||||
|
const DEVICE_ALIASES: Array<[RegExp, string]> = [
|
||||||
|
[/крова|спальн|ночник|прикроват/i, 'bedside'],
|
||||||
|
[/чтени|настольн|рабоч/i, 'reading'],
|
||||||
|
]
|
||||||
|
|
||||||
|
function resolveEntity(device?: string): string {
|
||||||
|
const map = lightEntities()
|
||||||
|
if (device) {
|
||||||
|
for (const [re, key] of DEVICE_ALIASES) {
|
||||||
|
if (re.test(device) && map[key]) return map[key]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return map.default || 'light.bedside'
|
||||||
|
}
|
||||||
|
|
||||||
|
const controlLight: VoiceTool = {
|
||||||
|
schema: {
|
||||||
|
type: 'function',
|
||||||
|
function: {
|
||||||
|
name: 'control_light',
|
||||||
|
description:
|
||||||
|
'Управление светом (лампами): включить или выключить. ' +
|
||||||
|
'Лампы: «свет у кровати», «лампа для чтения». Если лампа не названа — ' +
|
||||||
|
'используется лампа по умолчанию. Используй для команд вроде «включи свет», ' +
|
||||||
|
'«выключи лампу для чтения», «зажги свет у кровати».',
|
||||||
|
parameters: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
action: {
|
||||||
|
type: 'string',
|
||||||
|
enum: ['turn_on', 'turn_off'],
|
||||||
|
description: 'Включить или выключить',
|
||||||
|
},
|
||||||
|
device: {
|
||||||
|
type: 'string',
|
||||||
|
description:
|
||||||
|
'Какая лампа: «свет у кровати», «лампа для чтения». ' +
|
||||||
|
'Опционально — без неё берётся лампа по умолчанию.',
|
||||||
|
},
|
||||||
|
brightness_pct: {
|
||||||
|
type: 'number',
|
||||||
|
description: 'Яркость 0-100 (опционально, только при turn_on)',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: ['action'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
async execute(args) {
|
||||||
|
const action = String(args?.action || '')
|
||||||
|
if (action !== 'turn_on' && action !== 'turn_off') {
|
||||||
|
return { error: `unknown action: ${action}` }
|
||||||
|
}
|
||||||
|
const entity_id = resolveEntity(args?.device as string | undefined)
|
||||||
|
const payload: Record<string, any> = { domain: 'light', service: action, entity_id }
|
||||||
|
if (action === 'turn_on' && typeof args?.brightness_pct === 'number') {
|
||||||
|
payload.brightness_pct = Math.max(0, Math.min(100, Math.round(args.brightness_pct)))
|
||||||
|
}
|
||||||
|
await tabletJson('POST', '/api/voice/tools/smart-home', payload)
|
||||||
|
return { success: true, action, entity_id, ...(args?.device ? { device: args.device } : {}) }
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export const tools: VoiceTool[] = [controlLight]
|
||||||
Reference in New Issue
Block a user