feat(voice): server-side LLM/STT — porting Python satellite into tablet
All checks were successful
Deploy / deploy (push) Successful in 5m44s

Шаг 1 миграции голосового стека из home-voice-assistant в сам tablet:

- /api/voice/chat — Claude Haiku 4.5 с tool-loop (max 4 раунда), prompt
  caching на system + старой истории, история в /data/voice-history/.
  Эмитит command/response/error в voice-bus → орб моргает как раньше.
- /api/voice/stt — Groq whisper-large-v3-turbo, multipart или raw audio.
- lib/voice-text.ts — порт clean_for_speech (без pymorphy3, время в
  именительном падеже) и strip_fillers + RESET_PATTERNS.
- lib/voice-executors.ts — tool executors через loopback fetch на
  существующие /api/voice/tools/* и /api/voice/timer.
- Поддержка ANTHROPIC_PROXY/GROQ_PROXY (fallback на HTTPS_PROXY).

После деплоя нужны GROQ_API_KEY и ANTHROPIC_API_KEY в tablet.env.
Шаги 2 (push-to-talk в браузере) и 3 (wake-word) — отдельно.
This commit is contained in:
Cosmo
2026-04-27 08:24:19 +00:00
parent a97dd11f25
commit eeac2eefb3
10 changed files with 1215 additions and 4 deletions

159
lib/voice-executors.ts Normal file
View File

@@ -0,0 +1,159 @@
/**
* Tool executors. Серверная сторона вызывает tools через loopback на
* /api/voice/tools/* и /api/voice/timer (тот же Next.js process).
* Bearer = VOICE_API_KEY, плюс x-voice-internal для middleware bypass.
*
* Порт satellite/tools.py.
*/
const baseUrl = () => `http://localhost:${process.env.PORT || '3000'}`
function headers(): Record<string, string> {
const key = process.env.VOICE_API_KEY || ''
return {
Authorization: `Bearer ${key}`,
'x-voice-internal': key,
'Content-Type': 'application/json',
}
}
async function tabletGet(path: string, params?: Record<string, string>): Promise<any> {
const url = new URL(`${baseUrl()}${path}`)
if (params) {
for (const [k, v] of Object.entries(params)) {
if (v !== undefined && v !== '') url.searchParams.set(k, v)
}
}
const r = await fetch(url, { headers: headers(), cache: 'no-store' })
if (!r.ok) throw new ToolHttpError(r.status, await r.text().catch(() => ''))
return r.json()
}
async function tabletJson(method: 'POST' | 'PUT' | 'DELETE', path: string, body?: any, params?: Record<string, string>): Promise<any> {
const url = new URL(`${baseUrl()}${path}`)
if (params) {
for (const [k, v] of Object.entries(params)) {
if (v !== undefined && v !== '') url.searchParams.set(k, v)
}
}
const r = await fetch(url, {
method,
headers: headers(),
body: body !== undefined ? JSON.stringify(body) : undefined,
})
if (!r.ok) throw new ToolHttpError(r.status, await r.text().catch(() => ''))
return r.json()
}
class ToolHttpError extends Error {
constructor(public status: number, public body: string) {
super(`tool_http_${status}`)
}
}
type AgentId = 'cosmo' | 'lusya'
type ToolResult = Record<string, any> | { error: string }
const EXECUTORS: Record<string, (params: any, agent: AgentId) => Promise<ToolResult>> = {
async get_weather(params) {
const city = (params?.city as string) || ''
return tabletGet('/api/voice/tools/weather', city ? { city } : undefined)
},
async get_transport(params) {
const q: Record<string, string> = {}
if (params?.direction) q.direction = String(params.direction)
if (params?.routes) q.routes = String(params.routes)
return tabletGet('/api/voice/tools/transport', q)
},
async get_today_events(params) {
const range = (params?.range as string) || 'today'
return tabletGet('/api/voice/tools/events', { range })
},
async create_event(params) {
const title = String(params?.title || '').trim()
const date = String(params?.date || '').trim()
if (!title || !date) return { error: 'title and date required' }
const payload: Record<string, any> = {
title,
date,
owner: params?.owner || 'daniil',
all_day: !!params?.all_day,
}
if (!payload.all_day) {
payload.start_time = params?.start_time || ''
if (params?.end_time !== undefined) payload.end_time = params.end_time
}
return tabletJson('POST', '/api/voice/tools/events', payload)
},
async update_event(params) {
const event_id = String(params?.event_id || '').trim()
const owner = String(params?.owner || '').trim()
if (!event_id || !owner) return { error: 'event_id and owner required' }
const payload: Record<string, any> = { event_id, owner }
for (const k of ['title', 'date', 'start_time', 'end_time', 'all_day']) {
if (params?.[k] !== undefined) payload[k] = params[k]
}
return tabletJson('PUT', '/api/voice/tools/events', payload)
},
async delete_event(params) {
const event_id = String(params?.event_id || '').trim()
const owner = String(params?.owner || 'daniil').trim()
if (!event_id) return { error: 'event_id required' }
return tabletJson('DELETE', '/api/voice/tools/events', undefined, { event_id, owner })
},
async get_notes() {
return tabletGet('/api/voice/tools/notes')
},
async set_timer(params, agent) {
const seconds = Number(params?.seconds || 0)
const label = String(params?.label || 'Таймер')
if (!Number.isFinite(seconds) || seconds < 1) return { error: 'seconds must be positive' }
return tabletJson('POST', '/api/voice/timer', {
action: 'start',
seconds,
label,
agent,
})
},
async cancel_timer(params) {
const label = String(params?.label || '').trim()
if (!label) return { error: 'label required' }
return tabletJson('POST', '/api/voice/timer', { action: 'cancel', label })
},
async adjust_timer(params) {
const label = String(params?.label || '').trim()
const delta = Number(params?.delta_seconds || 0)
if (!label) return { error: 'label required' }
if (!Number.isFinite(delta) || delta === 0) return { error: 'delta_seconds must be non-zero' }
return tabletJson('POST', '/api/voice/timer', {
action: 'adjust',
label,
delta_seconds: delta,
})
},
}
export async function executeTool(name: string, params: any, agent: AgentId): Promise<ToolResult> {
const fn = EXECUTORS[name]
if (!fn) return { error: `unknown tool: ${name}` }
try {
return await fn(params || {}, agent)
} catch (e) {
if (e instanceof ToolHttpError) {
return { error: `tool_http_${e.status}` }
}
if (e instanceof TypeError && /fetch/i.test(e.message)) {
return { error: 'tool_network_error' }
}
return { error: `tool_exception: ${(e as Error).message}` }
}
}

100
lib/voice-history.ts Normal file
View File

@@ -0,0 +1,100 @@
/**
* История диалога per-agent per-day. Файлы в /data/voice-history/{agent}-{date}.json.
* /data — это volume контейнера (на хосте /opt/digital-home/smart-home-tablet-data/).
*/
import { promises as fs } from 'node:fs'
import path from 'node:path'
const DATA_DIR = process.env.VOICE_HISTORY_DIR || '/data/voice-history'
const MAX_HISTORY = parseInt(process.env.VOICE_MAX_HISTORY || '40', 10)
export type HistoryMessage = {
role: 'user' | 'assistant'
content: any
}
function todayIso(): string {
return new Date().toISOString().slice(0, 10)
}
function historyPath(agent: string): string {
return path.join(DATA_DIR, `${agent}-${todayIso()}.json`)
}
export async function loadHistory(agent: string): Promise<HistoryMessage[]> {
try {
const raw = await fs.readFile(historyPath(agent), 'utf-8')
const parsed = JSON.parse(raw)
return Array.isArray(parsed) ? parsed : []
} catch (e: any) {
if (e?.code === 'ENOENT') return []
console.warn('[voice/history] read failed:', e?.message || e)
return []
}
}
export async function saveHistory(agent: string, history: HistoryMessage[]): Promise<void> {
try {
await fs.mkdir(DATA_DIR, { recursive: true })
const trimmed = history.slice(-MAX_HISTORY)
await fs.writeFile(historyPath(agent), JSON.stringify(trimmed, null, 2), 'utf-8')
} catch (e: any) {
console.warn('[voice/history] write failed:', e?.message || e)
}
}
export async function resetHistory(agent: string): Promise<void> {
try {
await fs.unlink(historyPath(agent))
} catch (e: any) {
if (e?.code !== 'ENOENT') console.warn('[voice/history] reset failed:', e?.message || e)
}
}
/**
* Убирает cache_control из блоков (для записи в историю — следующий turn пересчитает границу).
*/
export function stripCacheControl(content: any): any {
if (Array.isArray(content)) {
return content.map((b) => {
if (b && typeof b === 'object' && 'cache_control' in b) {
const { cache_control: _ignore, ...rest } = b
return rest
}
return b
})
}
return content
}
/**
* Граница prompt-кеша: всё, кроме последних N сообщений, помечаем cache_control
* на последнем блоке последнего «старого» сообщения. Даёт cache hit на каждом turn.
*/
export function buildMessagesWithCache(history: HistoryMessage[], cacheTailUncached = 2): HistoryMessage[] {
if (history.length <= cacheTailUncached) {
return history.map((m) => ({ role: m.role, content: m.content }))
}
const cacheBoundary = history.length - cacheTailUncached
return history.map((msg, i) => {
if (i === cacheBoundary - 1) {
return { role: msg.role, content: wrapLastBlockWithCache(msg.content) }
}
return { role: msg.role, content: msg.content }
})
}
function wrapLastBlockWithCache(content: any): any {
if (typeof content === 'string') {
return [{ type: 'text', text: content, cache_control: { type: 'ephemeral' } }]
}
if (Array.isArray(content) && content.length) {
const out = [...content]
const last = out[out.length - 1]
if (last && typeof last === 'object') {
out[out.length - 1] = { ...last, cache_control: { type: 'ephemeral' } }
}
return out
}
return content
}

68
lib/voice-prompts.ts Normal file
View File

@@ -0,0 +1,68 @@
/**
* System prompts для Cosmo и Люси. Порт llm_claude.py.
* {today} подставляется через formatPrompt() — текущая дата ISO.
*/
const COSMO = `Ты — Cosmo, домашний голосовой ассистент Даниила (Санкт-Петербург).
Стиль:
- Короткие ответы: 1-2 предложения, редко 3. Это голосовой канал — многословность утомляет.
- Разговорный русский, без канцелярита, без формальных оборотов («здравствуйте», «уважаемый»).
- Обращение на «ты».
- Не предваряй ответ фразами-заполнителями («сейчас посмотрю», «минутку», «проверяю») — сразу отвечай.
- Без эмодзи, маркированных списков, код-блоков — всё будет зачитано.
- Если не знаешь — скажи коротко, не оправдывайся.
ЖЁСТКИЕ ПРАВИЛА про tools:
1. Любое ДЕЙСТВИЕ (поставить/отменить/изменить таймер, что-то включить/выключить)
делается ТОЛЬКО через вызов tool. Без tool действие не произошло.
2. Никогда не говори «поставил», «отменил», «удалил», «добавил», «изменил»,
если ты в этом же turn'e не вызвал соответствующий tool. Это галлюцинация,
пользователь потом обнаружит что ничего не изменилось и не будет тебе доверять.
3. Любая АКТУАЛЬНАЯ ИНФОРМАЦИЯ (погода, транспорт, события в календаре,
содержимое заметок) — всегда через tool. Не выдумывай числа и факты.
4. Порядок: сначала tool → потом в том же turn'e сформулируй ответ на основе
результата. Не пересказывай сырые данные дословно — дай человеческую сводку.
5. Если подходящего tool нет — честно скажи «так я не умею», а не притворяйся.
Доступные tools: get_weather, get_transport, get_today_events, create_event,
update_event, delete_event, get_notes, set_timer, cancel_timer, adjust_timer.
Работа с календарём:
- У Даниила и Светы разные календари. Параметр owner обязательный.
- Если пользователь не уточнил чей календарь — СПРОСИ прежде чем вызывать
create_event. Не угадывай даже если контекст намекает.
- Для изменения или удаления события сначала вызови get_today_events
(можно с range=week/month), найди нужное событие по названию и времени,
потом действуй с его event_id и owner.
- Даты в формате YYYY-MM-DD (2026-04-24), времена HH:MM (14:30).
«завтра» = сегодня+1 по дате, «послезавтра» = +2. Сегодня {today}.
Контекст: Даниил — разработчик, живёт в СПб с женой Светой.`
const LUSYA = `Ты — Люся, домашний голосовой ассистент Светы (Санкт-Петербург).
Стиль:
- Тёплый, заботливый, чуть эмоциональный, но лаконичный. 1-2 предложения.
- Обращение на «ты».
- Без эмодзи, списков, код-блоков — это голос.
- Если не знаешь — скажи коротко.
ЖЁСТКИЕ ПРАВИЛА про tools:
1. Действия (таймер, события) — только через вызов tool. Без tool действие не произошло.
2. Не говори «поставила/отменила/изменила», если ты не вызвала соответствующий tool.
3. Информацию (погода, транспорт, события) — всегда через tool, не выдумывай.
4. Tool → результат → короткий ответ человеческим языком.
Календарь:
- Свой = Светин, ещё есть календарь Данила. Для create_event уточняй
в какой календарь, если неясно.
- Для update_event / delete_event: сначала get_today_events, найди по
названию, потом действуй.
- Даты YYYY-MM-DD, время HH:MM. Сегодня {today}.`
export function systemPrompt(agent: 'cosmo' | 'lusya'): string {
const today = new Date().toISOString().slice(0, 10)
const tpl = agent === 'lusya' ? LUSYA : COSMO
return tpl.replace('{today}', today)
}

147
lib/voice-text.ts Normal file
View File

@@ -0,0 +1,147 @@
/**
* Подготовка текста для TTS и распознавание команд сброса.
* Порт satellite/text.py + satellite/llm.py (RESET_PATTERNS, FILLER_PATTERNS).
*
* Отличие от Python-версии: время «HH:MM» раскрывается в слова всегда в
* именительном падеже (без pymorphy3). Звучит чуть менее естественно в
* конструкциях вроде «встретимся в 14:30», но без зависимости от морфологии.
*/
const RESET_PATTERNS = new RegExp(
'(начни|начать|создай|открой|давай).{0,10}(новую|новый|чистую|чистый).{0,10}' +
'(сессию|сессия|диалог|разговор|чат)' +
'|' +
'(сбрось|очисти|обнови).{0,10}(сессию|диалог|разговор|чат|историю|контекст)',
'i'
)
const FILLER_PATTERNS = new RegExp(
'(?:(?:сейчас посмотрю|дай мне секунду|дай секунду|проверяю|загружаю|узнаю' +
'|смотрю|одну секунду|я сейчас посмотрю|я проверю|попробую другой источник' +
'|нужны конкретные числа|дай мне загрузить)[^.!?]*[.!?]?\\s*)+',
'gi'
)
export function isResetCommand(text: string): boolean {
return RESET_PATTERNS.test(text)
}
export function stripFillers(text: string): string {
return text.replace(FILLER_PATTERNS, '').trim()
}
// ── числа в слова (рус, именительный, кардинальные 0..99) ─────────────
const ONES_M = ['', 'один', 'два', 'три', 'четыре', 'пять', 'шесть', 'семь', 'восемь', 'девять']
const ONES_F = ['', 'одна', 'две', 'три', 'четыре', 'пять', 'шесть', 'семь', 'восемь', 'девять']
const TEENS = ['десять', 'одиннадцать', 'двенадцать', 'тринадцать', 'четырнадцать', 'пятнадцать',
'шестнадцать', 'семнадцать', 'восемнадцать', 'девятнадцать']
const TENS = ['', '', 'двадцать', 'тридцать', 'сорок', 'пятьдесят', 'шестьдесят', 'семьдесят',
'восемьдесят', 'девяносто']
function numToWords(n: number, gender: 'm' | 'f' = 'm'): string {
if (n === 0) return 'ноль'
if (n < 0 || n > 99) return String(n)
const ones = gender === 'f' ? ONES_F : ONES_M
if (n < 10) return ones[n]
if (n < 20) return TEENS[n - 10]
const t = Math.floor(n / 10)
const o = n % 10
return o === 0 ? TENS[t] : `${TENS[t]} ${ones[o]}`
}
function hoursWord(n: number): string {
const last2 = n % 100
const last1 = n % 10
if (last2 >= 11 && last2 <= 14) return 'часов'
if (last1 === 1) return 'час'
if (last1 >= 2 && last1 <= 4) return 'часа'
return 'часов'
}
function minutesWord(n: number): string {
const last2 = n % 100
const last1 = n % 10
if (last2 >= 11 && last2 <= 14) return 'минут'
if (last1 === 1) return 'минута'
if (last1 >= 2 && last1 <= 4) return 'минуты'
return 'минут'
}
function formatTime(h: number, mm: number): string {
let out = `${numToWords(h, 'm')} ${hoursWord(h)}`
if (mm > 0) out += ` ${numToWords(mm, 'f')} ${minutesWord(mm)}`
return out
}
// ── единицы измерения со слэшем ───────────────────────────────────────
const UNIT_SLASH: Array<[RegExp, string]> = [
[/\bкм\s*\/\s*ч\b/gi, 'километров в час'],
[/\bм\s*\/\s*с\b/gi, 'метров в секунду'],
[/\bкм\s*\/\s*с\b/gi, 'километров в секунду'],
[/\bмб\s*\/\s*с\b/gi, 'мегабит в секунду'],
[/\bгб\s*\/\s*с\b/gi, 'гигабит в секунду'],
[/\bруб\s*\/\s*мес\b/gi, 'рублей в месяц'],
[/\bр\s*\/\s*мес\b/gi, 'рублей в месяц'],
]
const ABBR: Array<[RegExp, string]> = [
[/\bт\.\s*е\./gi, 'то есть'],
[/\bт\.\s*к\./gi, 'так как'],
[/\bт\.\s*д\./gi, 'так далее'],
[/\bт\.\s*п\./gi, 'тому подобное'],
[/\bи\s*т\.\s*д\./gi, 'и так далее'],
[/\bи\s*т\.\s*п\./gi, 'и тому подобное'],
]
export function cleanForSpeech(input: string): string {
let t = input
// markdown — emojis: supplementary planes + symbols + dingbats + misc-symbols
t = t.replace(/[\u{1F000}-\u{1FFFF}-]/gu, '')
t = t.replace(/\*+/g, '')
t = t.replace(/#+\s/g, '')
t = t.replace(/^- /gm, '')
t = t.replace(/\[.*?\]\(.*?\)/g, '')
t = t.replace(/\n+/g, '. ')
for (const [pat, repl] of UNIT_SLASH) t = t.replace(pat, repl)
// знаки перед числом
t = t.replace(/(^|\s)\+(\d)/g, '$1плюс $2')
t = t.replace(/(^|\s)-(\d)/g, '$1минус $2')
t = t.replace(/±(\d)/g, 'плюс-минус $1')
// время HH:MM (опционально с предлогом — предлог сохраняем как есть, время в номинативе)
t = t.replace(
/(?:\b(с|со|до|от|после|около|к|ко|в|во|на|через|за|перед|между|о|об|при)\s+)?(\d{1,2}):(\d{2})\b/gi,
(_m, prep: string | undefined, hStr: string, mmStr: string) => {
const h = parseInt(hStr, 10)
const mm = parseInt(mmStr, 10)
if (h < 0 || h > 23 || mm < 0 || mm > 59) return _m
const words = formatTime(h, mm)
return prep ? `${prep} ${words}` : words
}
)
// дроби и слэш
t = t.replace(/(\d+)\s*\/\s*(\d+)/g, '$1 из $2')
t = t.replace(/\//g, ' или ')
// символы
t = t.replace(/°\s*C\b/gi, ' градусов')
t = t.replace(/°\s*F\b/gi, ' градусов Фаренгейта')
t = t.replace(/°/g, ' градусов')
t = t.replace(/%/g, ' процентов')
t = t.replace(/№/g, ' номер ')
t = t.replace(/&/g, ' и ')
t = t.replace(/@/g, ' собака ')
t = t.replace(/×/g, ' на ')
for (const [pat, repl] of ABBR) t = t.replace(pat, repl)
// схлопывание
t = t.replace(/([:;,!?])\s*\./g, '$1')
t = t.replace(/\.\s*\.+/g, '.')
t = t.replace(/\s+/g, ' ')
t = t.replace(/(\d+)\.(\s)/g, '$1$2')
return t.trim()
}

203
lib/voice-tool-schemas.ts Normal file
View File

@@ -0,0 +1,203 @@
/**
* Tool schemas для Anthropic API. Порт TOOL_SCHEMAS из satellite/tools.py.
* Формат — Anthropic native tools (name + description + input_schema).
*/
import type Anthropic from '@anthropic-ai/sdk'
export const TOOL_SCHEMAS: Anthropic.Tool[] = [
{
name: 'get_weather',
description:
'Получить текущую погоду и короткий прогноз для города. ' +
'Для вопросов вроде «какая сегодня погода», «холодно ли на улице», «нужен ли зонт». ' +
'По умолчанию — Санкт-Петербург.',
input_schema: {
type: 'object',
properties: {
city: {
type: 'string',
description:
'Город на русском или шорткод (spb, msk, sochi, ekb, kzn, nsk, krd). ' +
'По умолчанию Санкт-Петербург.',
},
},
},
},
{
name: 'get_transport',
description:
'Расписание ближайших трамваев на остановке Ул. Антонова-Овсеенко. ' +
'Для вопросов «когда следующий 23-й», «что ближайшее в центр», «пора идти на остановку».',
input_schema: {
type: 'object',
properties: {
direction: {
type: 'string',
enum: ['to_center', 'from_center', 'all'],
description:
'to_center = в центр (к Новочеркасской), ' +
'from_center = от центра (к Большевиков), all = оба направления',
},
routes: {
type: 'string',
description:
'Фильтр маршрутов через запятую, например «23» или «23,27». Пусто = все маршруты.',
},
},
},
},
{
name: 'get_today_events',
description:
'События из календаря (Даниил + Света). Вернёт id события, title, start, end, ' +
'owner («daniil» или «sveta»). ВАЖНО: для update_event / delete_event сначала ' +
'вызывай этот tool чтобы получить event_id.',
input_schema: {
type: 'object',
properties: {
range: {
type: 'string',
enum: ['today', 'week', 'month'],
description: 'today (по умолчанию), week (7 дней) или month (текущий месяц)',
},
},
},
},
{
name: 'create_event',
description:
'Создать событие в Google Calendar. ВАЖНО: параметр owner обязателен. ' +
'Если пользователь не сказал чей это календарь — СПРОСИ у него ' +
'(«в твой календарь или в Светин?») и только потом вызывай tool. Не угадывай.',
input_schema: {
type: 'object',
properties: {
title: { type: 'string', description: 'Название события' },
date: { type: 'string', description: 'Дата в формате YYYY-MM-DD' },
start_time: {
type: 'string',
description: 'Время начала в формате HH:MM (24-часовой). Обязательно если all_day=false.',
},
end_time: {
type: 'string',
description: 'Время окончания в формате HH:MM. По умолчанию start_time + 1 час.',
},
all_day: {
type: 'boolean',
description: 'Событие на весь день без времени. По умолчанию false.',
},
owner: {
type: 'string',
enum: ['daniil', 'sveta'],
description: 'Чей это календарь — Даниила или Светы',
},
},
required: ['title', 'date', 'owner'],
},
},
{
name: 'update_event',
description:
'Изменить существующее событие. Сначала обязательно вызови get_today_events ' +
'чтобы получить event_id и owner нужного события. Передавай только те поля ' +
'которые меняешь.',
input_schema: {
type: 'object',
properties: {
event_id: { type: 'string' },
owner: {
type: 'string',
enum: ['daniil', 'sveta'],
description: 'Чей календарь (из get_today_events)',
},
title: { type: 'string' },
date: { type: 'string', description: 'YYYY-MM-DD' },
start_time: { type: 'string', description: 'HH:MM' },
end_time: { type: 'string', description: 'HH:MM' },
all_day: { type: 'boolean' },
},
required: ['event_id', 'owner'],
},
},
{
name: 'delete_event',
description:
'Удалить событие из календаря. Сначала вызови get_today_events чтобы найти ' +
'event_id и определить owner. Подтверди удаление с пользователем если событие ' +
'важное (встреча, врач, работа).',
input_schema: {
type: 'object',
properties: {
event_id: { type: 'string' },
owner: { type: 'string', enum: ['daniil', 'sveta'] },
},
required: ['event_id', 'owner'],
},
},
{
name: 'get_notes',
description:
'Список заметок и списков покупок с планшета. Для «что мне купить», ' +
'«что в списке», «какие записи».',
input_schema: { type: 'object', properties: {} },
},
{
name: 'set_timer',
description:
'Запустить таймер на планшете. Показывает обратный отсчёт с названием и звенит ' +
'по окончании. Используй для «поставь таймер на 10 минут», «напомни через час», ' +
'«засеки 5 минут для чайника».',
input_schema: {
type: 'object',
properties: {
seconds: {
type: 'integer',
description: 'Длительность в секундах (1..86400)',
minimum: 1,
maximum: 86400,
},
label: {
type: 'string',
description: 'Короткое название таймера (например «Чайник», «Паста»)',
},
},
required: ['seconds', 'label'],
},
},
{
name: 'cancel_timer',
description:
'Отменить активный таймер по его названию. Для «отмени таймер чайник», ' +
'«убери таймер пасты», «останови отсчёт».',
input_schema: {
type: 'object',
properties: {
label: {
type: 'string',
description: 'Название таймера (примерное совпадение — можно частично).',
},
},
required: ['label'],
},
},
{
name: 'adjust_timer',
description:
'Изменить оставшееся время таймера. Для «добавь ещё 5 минут», «убавь на минуту», ' +
'«накинь времени чайнику». Положительный delta_seconds = добавить, отрицательный = уменьшить.',
input_schema: {
type: 'object',
properties: {
label: {
type: 'string',
description: 'Название таймера для которого меняем время.',
},
delta_seconds: {
type: 'integer',
description: 'Секунды (+ добавить, - уменьшить). Например 300 = +5 минут, -60 = -1 минута.',
},
},
required: ['label', 'delta_seconds'],
},
},
]