voice: локальный агент (fast-path), tool control_light, смелая тема, RUNBOOK
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:
d.klimov
2026-07-22 21:48:24 +03:00
parent d30ed1bac1
commit e6dab09c38
13 changed files with 975 additions and 32 deletions

94
lib/agent-local.ts Normal file
View 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 }
}