diff --git a/RUNBOOK.md b/RUNBOOK.md new file mode 100644 index 0000000..f3174c5 --- /dev/null +++ b/RUNBOOK.md @@ -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-истории | + +Каждая часть независима — можно катить и откатывать по отдельности. diff --git a/agent/.gitignore b/agent/.gitignore new file mode 100644 index 0000000..0fd61db --- /dev/null +++ b/agent/.gitignore @@ -0,0 +1,4 @@ +# Веса дообученной модели (~115 МБ) — не в git. Переносятся на мини-ПК отдельно +# (scp) и монтируются томом в контейнер. См. RUNBOOK.md, раздел «Агент». +joint_model/ +__pycache__/ diff --git a/agent/Dockerfile b/agent/Dockerfile new file mode 100644 index 0000000..6243c6f --- /dev/null +++ b/agent/Dockerfile @@ -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"] diff --git a/agent/joint_model_def.py b/agent/joint_model_def.py new file mode 100644 index 0000000..25964c3 --- /dev/null +++ b/agent/joint_model_def.py @@ -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 diff --git a/agent/predict.py b/agent/predict.py new file mode 100644 index 0000000..1308aad --- /dev/null +++ b/agent/predict.py @@ -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() diff --git a/agent/requirements.txt b/agent/requirements.txt new file mode 100644 index 0000000..70f43eb --- /dev/null +++ b/agent/requirements.txt @@ -0,0 +1,4 @@ +# Инференс joint-модели агента (CPU). Совпадает с tablet-agent/requirements.txt. +torch>=2.2 +transformers>=4.40 +sentencepiece diff --git a/agent/tablet_map.py b/agent/tablet_map.py new file mode 100644 index 0000000..3f1d88f --- /dev/null +++ b/agent/tablet_map.py @@ -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)}") diff --git a/app/api/voice/chat/route.ts b/app/api/voice/chat/route.ts index 66ab87e..0daf089 100644 --- a/app/api/voice/chat/route.ts +++ b/app/api/voice/chat/route.ts @@ -9,6 +9,7 @@ import { fetch as undiciFetch, ProxyAgent } from 'undici' import { voiceBus } from '@/lib/voice-bus' import { systemPrompt } from '@/lib/voice-prompts' import { TOOL_SCHEMAS, executeTool } from '@/lib/tools/_registry' +import { tryLocalAgent } from '@/lib/agent-local' import { cleanForSpeech, stripFillers, isResetCommand } from '@/lib/voice-text' import { loadHistory, saveHistory, resetHistory, HistoryMessage } from '@/lib/voice-history' import { promises as nodeFs } from 'node:fs' @@ -146,6 +147,23 @@ export async function POST(req: Request) { const newTurns: HistoryMessage[] = [{ role: 'user', content: userText }] 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 ======== if (provider === 'groq') { const groqModel = settings.groqModel || 'llama-3.3-70b-versatile' diff --git a/app/globals.css b/app/globals.css index 9ca48ca..395f7e5 100644 --- a/app/globals.css +++ b/app/globals.css @@ -8,29 +8,29 @@ Tokens — dark (default) —————————————————————————————— */ :root { - /* Surfaces */ - --bg: #0b0b14; - --bg-secondary: #101020; - --surface-1: #15152a; - --surface-2: #1c1c36; - --surface-3: #242444; - --surface-hover: #1e1e3c; + /* Surfaces — глубокий индиго с холодным подтоном (тема «Космо») */ + --bg: #07070f; + --bg-secondary: #0c0c1c; + --surface-1: #14142c; + --surface-2: #1c1c3e; + --surface-3: #26264f; + --surface-hover: #22224a; /* Borders */ - --border-subtle: rgba(255, 255, 255, 0.06); - --border-strong: rgba(255, 255, 255, 0.12); + --border-subtle: rgba(255, 255, 255, 0.07); + --border-strong: rgba(150, 160, 255, 0.18); --hairline: rgba(255, 255, 255, 0.08); /* Text */ - --text-primary: rgba(255, 255, 255, 0.95); - --text-secondary: rgba(255, 255, 255, 0.6); - --text-tertiary: rgba(255, 255, 255, 0.38); + --text-primary: rgba(255, 255, 255, 0.96); + --text-secondary: rgba(200, 205, 240, 0.62); + --text-tertiary: rgba(190, 195, 235, 0.4); - /* Accents — UI */ - --accent: #818cf8; - --accent-strong: #6366f1; - --accent-secondary: #22d3ee; - --accent-glow: rgba(129, 140, 248, 0.22); + /* Accents — UI (ярче и насыщеннее для смелого визуала) */ + --accent: #8b93ff; + --accent-strong: #6d5efc; + --accent-secondary: #2ee6f6; + --accent-glow: rgba(139, 120, 252, 0.32); /* Data palette — semantic, theme-aware */ --data-cool: #38bdf8; /* небо, влажность, охлаждение */ @@ -52,11 +52,12 @@ --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)); - /* Brand gradients */ - --gradient-primary: linear-gradient(135deg, #6366f1, #8b5cf6); - --gradient-warm: linear-gradient(135deg, #f59e0b, #ef4444); - --gradient-cool: linear-gradient(135deg, #06b6d4, #3b82f6); - --gradient-green: linear-gradient(135deg, #10b981, #34d399); + /* Brand gradients — многоступенчатые, живые (смелый визуал) */ + --gradient-primary: linear-gradient(135deg, #6d5efc 0%, #a855f7 50%, #2ee6f6 100%); + --gradient-warm: linear-gradient(135deg, #fbbf24 0%, #fb7185 100%); + --gradient-cool: linear-gradient(135deg, #22d3ee 0%, #6366f1 100%); + --gradient-green: linear-gradient(135deg, #10b981 0%, #5eead4 100%); + --gradient-aurora: linear-gradient(120deg, #6d5efc 0%, #a855f7 40%, #2ee6f6 100%); /* Shadows */ --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.4); @@ -219,19 +220,32 @@ html, body { filter: blur(80px); } .bg-ambient::before { - width: 560px; height: 560px; - background: radial-gradient(circle, var(--accent-glow) 0%, transparent 70%); - top: -180px; right: -80px; + width: 680px; height: 680px; + background: radial-gradient(circle, var(--accent-glow) 0%, transparent 68%); + top: -200px; right: -100px; animation: float1 22s ease-in-out infinite; } .bg-ambient::after { - width: 480px; height: 480px; - background: radial-gradient(circle, rgba(139, 92, 246, 0.14) 0%, transparent 70%); - bottom: -140px; left: -60px; + width: 600px; height: 600px; + background: radial-gradient(circle, rgba(46, 230, 246, 0.16) 0%, transparent 68%); + bottom: -180px; left: -80px; animation: float2 27s ease-in-out infinite; } .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 { @@ -280,7 +294,16 @@ html, body { content: ''; position: absolute; 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; } @@ -292,6 +315,23 @@ html, body { } .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 */ .divider { height: 1px; diff --git a/app/page.tsx b/app/page.tsx index 816bfff..28fdb83 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -239,7 +239,7 @@ function LockScreen({ onUnlock }: { onUnlock: () => void }) { position: 'fixed', inset: 0, zIndex: 200, background: '#0c0c18', display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 40, }}> -
+
{time.toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' })} @@ -1126,7 +1126,7 @@ function HomePageInner() { return (
-
+
{screensaverActive && ( diff --git a/lib/agent-local.ts b/lib/agent-local.ts new file mode 100644 index 0000000..8e420c8 --- /dev/null +++ b/lib/agent-local.ts @@ -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 + score: number + tool: { tool: string; args: Record } | null +} + +async function queryAgent(text: string): Promise { + 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 { + 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 } +} diff --git a/lib/tools/_registry.ts b/lib/tools/_registry.ts index f7aeb3d..05e2407 100644 --- a/lib/tools/_registry.ts +++ b/lib/tools/_registry.ts @@ -14,6 +14,7 @@ import { tools as timerTools } from './timers' import { tool as notes } from './notes' import { tools as smartHomeTools } from './smart-home' import { tools as spotifyTools } from './spotify' +import { tools as lightTools } from './lights' const ALL_TOOLS: VoiceTool[] = [ weather, @@ -23,6 +24,7 @@ const ALL_TOOLS: VoiceTool[] = [ notes, ...smartHomeTools, ...spotifyTools, + ...lightTools, ] export const TOOL_SCHEMAS = ALL_TOOLS.map((t) => t.schema) diff --git a/lib/tools/lights.ts b/lib/tools/lights.ts new file mode 100644 index 0000000..7cfab00 --- /dev/null +++ b/lib/tools/lights.ts @@ -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 = { + bedside: 'light.bedside', // «Свет у кровати» + reading: 'light.reading', // «Лампа для чтения» + default: 'light.bedside', // если лампа не названа — эта +} + +function lightEntities(): Record { + 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 = { 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]