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:
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 { 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)
|
||||
|
||||
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