From be0a731738a138d7cfe6165a3d37e69ec66d1679 Mon Sep 17 00:00:00 2001 From: "d.klimov" Date: Wed, 22 Jul 2026 23:49:50 +0300 Subject: [PATCH] =?UTF-8?q?voice:=20=D1=83=D1=81=D0=B8=D0=BB=D0=B5=D0=BD?= =?UTF-8?q?=D0=B8=D0=B5=20=D1=83=D0=BC=D0=BD=D0=BE=D0=B3=D0=BE=20=D0=B4?= =?UTF-8?q?=D0=BE=D0=BC=D0=B0=20=E2=80=94=20=D0=BA=D0=BE=D0=BD=D1=82=D0=B5?= =?UTF-8?q?=D0=BA=D1=81=D1=82,=20=D0=BF=D1=80=D0=BE=D0=BC=D0=BF=D1=82,=20?= =?UTF-8?q?=D0=BD=D0=BE=D0=B2=D1=8B=D0=B5=20tools?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - промпт: перечислены ВСЕ 15+ tools, разрешено уверенно управлять домом - route.ts: живой контекст (время + снимок устройств из HA) в системный промпт для LLM-пути (defensive, с таймаутом) - новые tools: control_climate (кондей/термостат), control_tv, set_scene (night/movie/morning/away), all_off, ha_service (универсальный HA с whitelist) - env: CLIMATE_ENTITY, TV_ENTITY (см. RUNBOOK) --- RUNBOOK.md | 18 +++++++ app/api/voice/chat/route.ts | 50 ++++++++++++++++-- lib/tools/_registry.ts | 8 +++ lib/tools/climate.ts | 71 +++++++++++++++++++++++++ lib/tools/ha-generic.ts | 54 +++++++++++++++++++ lib/tools/scenes.ts | 100 ++++++++++++++++++++++++++++++++++++ lib/tools/tv.ts | 70 +++++++++++++++++++++++++ lib/voice-prompts.ts | 32 ++++++++++-- 8 files changed, 395 insertions(+), 8 deletions(-) create mode 100644 lib/tools/climate.ts create mode 100644 lib/tools/ha-generic.ts create mode 100644 lib/tools/scenes.ts create mode 100644 lib/tools/tv.ts diff --git a/RUNBOOK.md b/RUNBOOK.md index f3174c5..3ef6cae 100644 --- a/RUNBOOK.md +++ b/RUNBOOK.md @@ -176,8 +176,26 @@ cp "/d/Digital home/tablet-wake/out/cosmo.onnx" \ AGENT_URL=http://tablet-agent:8091 # включает локального агента AGENT_THRESHOLD=0.7 # порог уверенности (опц.) LIGHT_ENTITIES_JSON={"bedside":"...","reading":"...","default":"..."} +CLIMATE_ENTITY=climate.thermostat # кондиционер/термостат (control_climate) +TV_ENTITY=media_player.tv # телевизор (control_tv) ``` +## Расширенный умный дом (облачный LLM) + +Промпт теперь перечисляет ВСЕ tools и разрешает уверенно управлять домом; в +системный промпт каждый LLM-запрос инжектится живой снимок (время + состояния +устройств из `/api/ha`), поэтому модель понимает, что включено и что есть в доме. + +Новые tools: `control_climate` (климат), `control_tv` (телевизор), `set_scene` +(сцены night/movie/morning/away), `all_off` (выключить всё), `ha_service` +(универсальный вызов сервиса HA по entity_id из снимка; домены light/switch/fan/ +climate/media_player/cover/scene/humidifier/vacuum). Реальные entity_id ламп/ТВ/ +климата задаются переменными выше; без них команды проходят как mock (безопасно). + +> Локальный агент (fast-path) новые интенты climate/tv/scene/all_off пока не +> знает — эти команды обрабатывает облачный LLM. Чтобы они шли и локально, +> нужно дообучить `tablet-agent` (добавить интенты) и передеплоить модель. + --- ## Проверка (после деплоя) diff --git a/app/api/voice/chat/route.ts b/app/api/voice/chat/route.ts index 0daf089..c8b6c39 100644 --- a/app/api/voice/chat/route.ts +++ b/app/api/voice/chat/route.ts @@ -117,6 +117,45 @@ function emitVoice(event: string, agent: AgentId, text?: string) { voiceBus.emit('voice', { event, agent, text, timestamp: new Date().toISOString() }) } +// Живой контекст для системного промпта: текущее время + снимок устройств из HA. +// Defensive: любая ошибка/таймаут → только время, без падения запроса. +async function buildLiveContext(): Promise { + const now = new Date() + const timeStr = now.toLocaleString('ru-RU', { + weekday: 'long', day: 'numeric', month: 'long', hour: '2-digit', minute: '2-digit', + }) + try { + const base = `http://localhost:${process.env.PORT || '3000'}` + const r = await fetch(`${base}/api/ha`, { + cache: 'no-store', + headers: { 'x-voice-internal': process.env.VOICE_API_KEY || '' }, + signal: AbortSignal.timeout(1200), + }) + if (!r.ok) return `\n\nСейчас: ${timeStr}.` + const d = await r.json() + const states = d.states || {} + const s = d.sensors || {} + const lines: string[] = [] + for (const [id, st] of Object.entries(states)) { + const name = st.attributes?.friendly_name || id + let extra = '' + if (st.attributes?.preset_mode) extra += `, режим ${st.attributes.preset_mode}` + if (st.attributes?.current_temperature != null) extra += `, ${st.attributes.current_temperature}°` + lines.push(` ${name} (${id}): ${st.state}${extra}`) + } + const sensorLine = [ + s.temperature != null ? `дома ${s.temperature}°` : '', + s.humidity != null ? `влажность ${s.humidity}%` : '', + s.pm25 != null ? `PM2.5 ${s.pm25}` : '', + ].filter(Boolean).join(', ') + if (!lines.length && !sensorLine) return `\n\nСейчас: ${timeStr}.` + return `\n\nСнимок дома (${timeStr}):\n${sensorLine ? ' ' + sensorLine + '\n' : ''}${lines.join('\n')}\n` + + `Для устройств без специального tool используй ha_service с entity_id выше.` + } catch { + return `\n\nСейчас: ${timeStr}.` + } +} + export async function POST(req: Request) { const cookie = req.headers.get('cookie') || '' const tokenMatch = cookie.match(/auth_token=([a-f0-9]{32,})/i) @@ -164,6 +203,11 @@ export async function POST(req: Request) { console.log('[voice/chat] local-agent skip:', (e as Error).message) } + // Живой контекст (время + снимок устройств) — только для LLM-пути (fast-path + // выше уже вышел). Даёт модели понимание, что включено и что вообще есть в + // доме, чтобы рассуждать про умный дом и не переспрашивать очевидное. + const fullSys = sysPrompt + (await buildLiveContext()) + // ======== GROQ ======== if (provider === 'groq') { const groqModel = settings.groqModel || 'llama-3.3-70b-versatile' @@ -175,7 +219,7 @@ export async function POST(req: Request) { return m }) const groqMessages: any[] = [ - { role: 'system', content: sysPrompt }, + { role: 'system', content: fullSys }, ...normalizedHistory, { role: 'user', content: userText }, ] @@ -218,7 +262,7 @@ export async function POST(req: Request) { try { const fb = await groqClient().chat.completions.create({ model: groqModel, max_tokens: MAX_TOKENS, - messages: [{ role: 'system', content: sysPrompt }, { role: 'user', content: userText }], + messages: [{ role: 'system', content: fullSys }, { role: 'user', content: userText }], }) finalText = fb.choices[0]?.message?.content || 'Не удалось выполнить запрос.' newTurns.push({ role: 'assistant', content: finalText }) @@ -237,7 +281,7 @@ export async function POST(req: Request) { try { for (let round = 0; round < MAX_TOOL_ROUNDS; round++) { const t0 = Date.now() - const resp = await claudeRequest(sysPrompt, messages, anthropicTools) + const resp = await claudeRequest(fullSys, messages, anthropicTools) console.log(`[voice/chat] claude ${agent} r${round+1} ${Date.now()-t0}ms stop=${resp.stop_reason} in=${resp.usage?.input_tokens} out=${resp.usage?.output_tokens}`) const content: any[] = resp.content || [] const stopReason = resp.stop_reason || 'end_turn' diff --git a/lib/tools/_registry.ts b/lib/tools/_registry.ts index 05e2407..3c54591 100644 --- a/lib/tools/_registry.ts +++ b/lib/tools/_registry.ts @@ -15,6 +15,10 @@ 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' +import { tools as climateTools } from './climate' +import { tools as tvTools } from './tv' +import { tools as sceneTools } from './scenes' +import { tools as haGenericTools } from './ha-generic' const ALL_TOOLS: VoiceTool[] = [ weather, @@ -25,6 +29,10 @@ const ALL_TOOLS: VoiceTool[] = [ ...smartHomeTools, ...spotifyTools, ...lightTools, + ...climateTools, + ...tvTools, + ...sceneTools, + ...haGenericTools, ] export const TOOL_SCHEMAS = ALL_TOOLS.map((t) => t.schema) diff --git a/lib/tools/climate.ts b/lib/tools/climate.ts new file mode 100644 index 0000000..37bfdba --- /dev/null +++ b/lib/tools/climate.ts @@ -0,0 +1,71 @@ +import type { VoiceTool } from './_types' +import { tabletJson } from './_http' + +/** + * Кондиционер / термостат через Home Assistant (домен climate). + * Entity задаётся CLIMATE_ENTITY (по умолчанию climate.thermostat). + */ +const ENTITY = () => process.env.CLIMATE_ENTITY || 'climate.thermostat' + +const HVAC: Record = { + heat: 'heat', 'тепло': 'heat', 'нагрев': 'heat', 'обогрев': 'heat', + cool: 'cool', 'холод': 'cool', 'охлаждение': 'cool', 'прохлада': 'cool', + auto: 'auto', 'авто': 'auto', + off: 'off', 'выкл': 'off', +} + +const controlClimate: VoiceTool = { + schema: { + type: 'function', + function: { + name: 'control_climate', + description: + 'Управление кондиционером/термостатом: задать температуру, режим или включить/выключить. ' + + 'Используй для «сделай теплее/прохладнее», «поставь 22 градуса», «включи кондиционер», ' + + '«режим охлаждения».', + parameters: { + type: 'object', + properties: { + action: { + type: 'string', + enum: ['set_temperature', 'set_mode', 'turn_on', 'turn_off'], + description: 'Что сделать', + }, + temperature: { + type: 'number', + description: 'Целевая температура °C при action=set_temperature (16-30)', + }, + mode: { + type: 'string', + description: 'Режим при action=set_mode: heat, cool, auto, off', + }, + }, + required: ['action'], + }, + }, + }, + + async execute(args) { + const action = String(args?.action || '') + const entity_id = ENTITY() + let payload: Record + + if (action === 'set_temperature') { + const t = Number(args?.temperature) + if (!Number.isFinite(t)) return { error: 'temperature required' } + payload = { domain: 'climate', service: 'set_temperature', entity_id, temperature: Math.max(16, Math.min(30, Math.round(t))) } + } else if (action === 'set_mode') { + const hvac = HVAC[String(args?.mode || '').toLowerCase().trim()] || 'auto' + payload = { domain: 'climate', service: 'set_hvac_mode', entity_id, hvac_mode: hvac } + } else if (action === 'turn_on' || action === 'turn_off') { + payload = { domain: 'climate', service: action, entity_id } + } else { + return { error: `unknown action: ${action}` } + } + + await tabletJson('POST', '/api/voice/tools/smart-home', payload) + return { success: true, action, ...(payload.temperature ? { temperature: payload.temperature } : {}), ...(payload.hvac_mode ? { mode: payload.hvac_mode } : {}) } + }, +} + +export const tools: VoiceTool[] = [controlClimate] diff --git a/lib/tools/ha-generic.ts b/lib/tools/ha-generic.ts new file mode 100644 index 0000000..3ae565d --- /dev/null +++ b/lib/tools/ha-generic.ts @@ -0,0 +1,54 @@ +import type { VoiceTool } from './_types' +import { tabletJson } from './_http' + +/** + * Универсальный вызов сервиса Home Assistant — «escape hatch» для устройств, + * под которые нет отдельного tool. Модель берёт entity_id из снимка состояния + * дома (он инжектится в системный промпт) и вызывает нужный сервис. + * + * Guardrails: разрешены только безопасные бытовые домены. Никаких automation/ + * script/shell/notify и т.п. — только прямое управление устройствами. + */ +const ALLOWED_DOMAINS = new Set([ + 'light', 'switch', 'fan', 'climate', 'media_player', 'cover', 'scene', 'humidifier', 'vacuum', +]) + +const haService: VoiceTool = { + schema: { + type: 'function', + function: { + name: 'ha_service', + description: + 'Универсальное управление устройством Home Assistant, если нет специального tool. ' + + 'Передай domain, service и entity_id (возьми из снимка состояния дома). Примеры: ' + + 'domain=switch service=turn_on entity_id=switch.kettle; ' + + 'domain=cover service=open_cover entity_id=cover.blinds. ' + + 'Разрешённые домены: light, switch, fan, climate, media_player, cover, scene, humidifier, vacuum.', + parameters: { + type: 'object', + properties: { + domain: { type: 'string', description: 'Домен HA (light, switch, fan, climate, media_player, cover, scene, humidifier, vacuum)' }, + service: { type: 'string', description: 'Сервис, напр. turn_on, turn_off, toggle, open_cover, set_percentage' }, + entity_id: { type: 'string', description: 'entity_id устройства из снимка состояния дома' }, + data: { type: 'object', description: 'Доп. параметры сервиса (напр. {"brightness_pct": 50})' }, + }, + required: ['domain', 'service', 'entity_id'], + }, + }, + }, + + async execute(args) { + const domain = String(args?.domain || '').trim() + const service = String(args?.service || '').trim() + const entity_id = String(args?.entity_id || '').trim() + if (!ALLOWED_DOMAINS.has(domain)) return { error: `domain not allowed: ${domain}` } + if (!service || !entity_id) return { error: 'domain, service, entity_id required' } + if (entity_id.split('.')[0] !== domain) return { error: 'entity_id domain must match domain' } + + const extra = (args?.data && typeof args.data === 'object') ? args.data : {} + await tabletJson('POST', '/api/voice/tools/smart-home', { domain, service, entity_id, ...extra }) + return { success: true, domain, service, entity_id } + }, +} + +export const tools: VoiceTool[] = [haService] diff --git a/lib/tools/scenes.ts b/lib/tools/scenes.ts new file mode 100644 index 0000000..1626b9c --- /dev/null +++ b/lib/tools/scenes.ts @@ -0,0 +1,100 @@ +import type { VoiceTool } from './_types' +import { tabletJson } from './_http' + +/** + * Сцены (макросы над несколькими устройствами) и «выключить всё». + * Entity-конфиг переиспользует те же переменные, что control_light/tv/climate. + */ + +function lightIds(): string[] { + const def = ['light.bedside', 'light.reading'] + try { + const raw = process.env.LIGHT_ENTITIES_JSON + if (raw) { + const m = JSON.parse(raw) as Record + const ids = Object.entries(m).filter(([k]) => k !== 'default').map(([, v]) => v) + return ids.length ? Array.from(new Set(ids)) : def + } + } catch {} + return def +} +const tvId = () => process.env.TV_ENTITY || 'media_player.tv' +const purifierId = 'fan.air_purifier' + +async function ha(domain: string, service: string, entity_id: string, extra: Record = {}) { + try { + await tabletJson('POST', '/api/voice/tools/smart-home', { domain, service, entity_id, ...extra }) + } catch { /* устройство может быть mock — не валим сцену */ } +} + +async function lightsOff() { await Promise.all(lightIds().map(id => ha('light', 'turn_off', id))) } +async function lightsOn(pct?: number) { + await Promise.all(lightIds().map(id => ha('light', 'turn_on', id, pct != null ? { brightness_pct: pct } : {}))) +} + +const setScene: VoiceTool = { + schema: { + type: 'function', + function: { + name: 'set_scene', + description: + 'Активировать сцену — одной командой меняет несколько устройств. ' + + 'night (ночь: свет выкл, очиститель в ночной режим), movie (кино: свет приглушить, ТВ вкл), ' + + 'morning (утро: свет вкл, очиститель авто), away (ушёл: всё выключить). ' + + 'Используй для «включи сцену ночь», «режим кино», «я ушёл».', + parameters: { + type: 'object', + properties: { + scene: { type: 'string', enum: ['night', 'movie', 'morning', 'away'], description: 'Какая сцена' }, + }, + required: ['scene'], + }, + }, + }, + + async execute(args) { + const scene = String(args?.scene || '') + switch (scene) { + case 'night': + await lightsOff() + await ha('fan', 'set_preset_mode', purifierId, { preset_mode: 'Night' }) + break + case 'movie': + await lightsOn(20) + await ha('media_player', 'turn_on', tvId()) + await ha('fan', 'set_preset_mode', purifierId, { preset_mode: 'Night' }) + break + case 'morning': + await lightsOn() + await ha('fan', 'set_preset_mode', purifierId, { preset_mode: 'Auto' }) + break + case 'away': + await lightsOff() + await ha('media_player', 'turn_off', tvId()) + await ha('fan', 'turn_off', purifierId) + break + default: + return { error: `unknown scene: ${scene}` } + } + return { success: true, scene } + }, +} + +const allOff: VoiceTool = { + schema: { + type: 'function', + function: { + name: 'all_off', + description: 'Выключить всё: весь свет, телевизор, очиститель. Для «выключи всё», «отключи всё дома».', + parameters: { type: 'object', properties: {} }, + }, + }, + async execute() { + await lightsOff() + await ha('media_player', 'turn_off', tvId()) + await ha('fan', 'turn_off', purifierId) + return { success: true, turned_off: 'lights, tv, purifier' } + }, +} + +export const tools: VoiceTool[] = [setScene, allOff] diff --git a/lib/tools/tv.ts b/lib/tools/tv.ts new file mode 100644 index 0000000..ec90cd1 --- /dev/null +++ b/lib/tools/tv.ts @@ -0,0 +1,70 @@ +import type { VoiceTool } from './_types' +import { tabletJson } from './_http' + +/** + * Телевизор через Home Assistant (домен media_player). + * Entity задаётся TV_ENTITY (по умолчанию media_player.tv). + */ +const ENTITY = () => process.env.TV_ENTITY || 'media_player.tv' + +const controlTv: VoiceTool = { + schema: { + type: 'function', + function: { + name: 'control_tv', + description: + 'Управление телевизором: включить/выключить, громкость, пауза/воспроизведение, звук. ' + + 'Используй для «включи телевизор», «выключи ТВ», «сделай телевизор тише», «поставь на паузу».', + parameters: { + type: 'object', + properties: { + action: { + type: 'string', + enum: ['turn_on', 'turn_off', 'volume', 'play', 'pause', 'mute'], + description: 'Что сделать', + }, + volume: { + type: 'number', + description: 'Громкость 0-100 при action=volume', + }, + }, + required: ['action'], + }, + }, + }, + + async execute(args) { + const action = String(args?.action || '') + const entity_id = ENTITY() + let payload: Record + + switch (action) { + case 'turn_on': + case 'turn_off': + payload = { domain: 'media_player', service: action, entity_id } + break + case 'play': + payload = { domain: 'media_player', service: 'media_play', entity_id } + break + case 'pause': + payload = { domain: 'media_player', service: 'media_pause', entity_id } + break + case 'mute': + payload = { domain: 'media_player', service: 'volume_mute', entity_id, is_volume_muted: true } + break + case 'volume': { + const v = Number(args?.volume) + if (!Number.isFinite(v)) return { error: 'volume required' } + payload = { domain: 'media_player', service: 'volume_set', entity_id, volume_level: Math.max(0, Math.min(1, v / 100)) } + break + } + default: + return { error: `unknown action: ${action}` } + } + + await tabletJson('POST', '/api/voice/tools/smart-home', payload) + return { success: true, action, ...(action === 'volume' ? { volume: args?.volume } : {}) } + }, +} + +export const tools: VoiceTool[] = [controlTv] diff --git a/lib/voice-prompts.ts b/lib/voice-prompts.ts index b8aa2cf..ff6307d 100644 --- a/lib/voice-prompts.ts +++ b/lib/voice-prompts.ts @@ -25,8 +25,26 @@ const COSMO = `Ты — Cosmo, домашний голосовой ассист результата. Не пересказывай сырые данные дословно — дай человеческую сводку. 5. Если подходящего tool нет — честно скажи «так я не умею», а не притворяйся. -Доступные tools: get_weather, get_transport, get_today_events, create_event, -update_event, delete_event, get_notes, set_timer, cancel_timer, adjust_timer. +Доступные tools (вызывай их для действий и актуальных данных): +- Погода / транспорт: get_weather, get_transport +- Календарь: get_today_events, create_event, update_event, delete_event +- Заметки и списки покупок: get_notes +- Таймеры: set_timer, cancel_timer, adjust_timer +- Умный дом: get_smart_home_state (температура, влажность, PM2.5, состояние устройств), + control_air_purifier (очиститель: turn_on / turn_off / set_mode; режимы Auto, Night, High), + control_light (свет: turn_on / turn_off; device — «свет у кровати» / «лампа для чтения»; brightness_pct 0-100), + control_climate (кондиционер/термостат: set_temperature °C, set_mode heat/cool/auto/off), + control_tv (телевизор: turn_on / turn_off / volume / play / pause), + set_scene (сцены-макросы: «ночь», «кино», «утро», «ушёл из дома»), + all_off (выключить всё), ha_service (универсальный вызов сервиса Home Assistant) +- Музыка: get_now_playing, control_spotify (play / pause / next / previous / volume; query — что включить) + +Умным домом управляй уверенно: просят включить/выключить свет, очиститель, +кондиционер, телевизор, поставить сцену или музыку — сразу вызывай нужный tool, +не отказывайся. «Что включено дома», «какая температура» — get_smart_home_state. +Если ниже дан снимок состояния дома — опирайся на него, чтобы не переспрашивать +очевидное. Для нестандартного устройства используй ha_service с entity_id из +снимка (домены light, switch, fan, climate, media_player, cover, scene). Работа с календарём: - У Даниила и Светы разные календари. Параметр owner обязательный. @@ -49,10 +67,14 @@ const LUSYA = `Ты — Люся, домашний голосовой ассис - Если не знаешь — скажи коротко. ЖЁСТКИЕ ПРАВИЛА про tools: -1. Действия (таймер, события) — только через вызов tool. Без tool действие не произошло. -2. Не говори «поставила/отменила/изменила», если ты не вызвала соответствующий tool. -3. Информацию (погода, транспорт, события) — всегда через tool, не выдумывай. +1. Действия (таймер, события, свет, очиститель, климат, музыка, сцены) — только + через вызов tool. Без tool действие не произошло. +2. Не говори «поставила/отменила/включила», если ты не вызвала соответствующий tool. +3. Информацию (погода, транспорт, события, состояние дома) — всегда через tool, не выдумывай. 4. Tool → результат → короткий ответ человеческим языком. +5. Умным домом управляй уверенно: control_light, control_air_purifier, + control_climate, control_tv, control_spotify, set_scene, all_off; состояние — + get_smart_home_state. Не отказывайся. Если ниже есть снимок дома — используй его. Календарь: - Свой = Светин, ещё есть календарь Данила. Для create_event уточняй