From 7b5f76576f0bc611b1a764411ab163883eacb22d Mon Sep 17 00:00:00 2001 From: Cosmo Date: Thu, 30 Apr 2026 20:58:11 +0000 Subject: [PATCH] refactor: tool plugin registry - each tool in separate file --- app/api/voice/chat/route.ts | 3 +- lib/tools/_http.ts | 54 +++++++++++++ lib/tools/_registry.ts | 44 ++++++++++ lib/tools/_types.ts | 8 ++ lib/tools/calendar.ts | 156 ++++++++++++++++++++++++++++++++++++ lib/tools/notes.ts | 22 +++++ lib/tools/timers.ts | 111 +++++++++++++++++++++++++ lib/tools/transport.ts | 38 +++++++++ lib/tools/weather.ts | 31 +++++++ 9 files changed, 465 insertions(+), 2 deletions(-) create mode 100644 lib/tools/_http.ts create mode 100644 lib/tools/_registry.ts create mode 100644 lib/tools/_types.ts create mode 100644 lib/tools/calendar.ts create mode 100644 lib/tools/notes.ts create mode 100644 lib/tools/timers.ts create mode 100644 lib/tools/transport.ts create mode 100644 lib/tools/weather.ts diff --git a/app/api/voice/chat/route.ts b/app/api/voice/chat/route.ts index cea0839..c496ae4 100644 --- a/app/api/voice/chat/route.ts +++ b/app/api/voice/chat/route.ts @@ -7,8 +7,7 @@ import { HttpsProxyAgent } from 'https-proxy-agent' import { voiceBus } from '@/lib/voice-bus' import { systemPrompt } from '@/lib/voice-prompts' -import { TOOL_SCHEMAS } from '@/lib/voice-tool-schemas' -import { executeTool } from '@/lib/voice-executors' +import { TOOL_SCHEMAS, executeTool } from '@/lib/tools/_registry' import { cleanForSpeech, stripFillers, isResetCommand } from '@/lib/voice-text' import { loadHistory, saveHistory, resetHistory, diff --git a/lib/tools/_http.ts b/lib/tools/_http.ts new file mode 100644 index 0000000..b6624eb --- /dev/null +++ b/lib/tools/_http.ts @@ -0,0 +1,54 @@ +/** + * Shared HTTP helpers for tool executors. + * Calls loopback tablet API routes with Bearer + x-voice-internal headers. + */ + +export class ToolHttpError extends Error { + constructor(public status: number, public body: string) { + super(`tool_http_${status}`) + } +} + +const baseUrl = () => `http://localhost:${process.env.PORT || '3000'}` + +export function headers(): Record { + const key = process.env.VOICE_API_KEY || '' + return { + Authorization: `Bearer ${key}`, + 'x-voice-internal': key, + 'Content-Type': 'application/json', + } +} + +export async function tabletGet(path: string, params?: Record): Promise { + 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() +} + +export async function tabletJson( + method: 'POST' | 'PUT' | 'DELETE', + path: string, + body?: any, + params?: Record, +): Promise { + 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() +} diff --git a/lib/tools/_registry.ts b/lib/tools/_registry.ts new file mode 100644 index 0000000..2303725 --- /dev/null +++ b/lib/tools/_registry.ts @@ -0,0 +1,44 @@ +/** + * Tool plugin registry. + * Aggregates all VoiceTool instances, exposes TOOL_SCHEMAS array + * and executeTool dispatcher. + */ + +import type { AgentId, VoiceTool } from './_types' +import { ToolHttpError } from './_http' + +import { tool as weather } from './weather' +import { tool as transport } from './transport' +import { tools as calendarTools } from './calendar' +import { tools as timerTools } from './timers' +import { tool as notes } from './notes' + +const ALL_TOOLS: VoiceTool[] = [ + weather, + transport, + ...calendarTools, + ...timerTools, + notes, +] + +export const TOOL_SCHEMAS = ALL_TOOLS.map((t) => t.schema) + +export async function executeTool( + name: string, + args: Record, + agent: AgentId, +): Promise | { error: string }> { + const t = ALL_TOOLS.find((t) => t.schema.function.name === name) + if (!t) return { error: `unknown tool: ${name}` } + try { + return await t.execute(args || {}, 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}` } + } +} diff --git a/lib/tools/_types.ts b/lib/tools/_types.ts new file mode 100644 index 0000000..7acdb65 --- /dev/null +++ b/lib/tools/_types.ts @@ -0,0 +1,8 @@ +import type { GroqTool } from '../voice-tool-schemas' + +export type AgentId = 'cosmo' | 'lusya' + +export interface VoiceTool { + schema: GroqTool + execute: (args: Record, agent: AgentId) => Promise +} diff --git a/lib/tools/calendar.ts b/lib/tools/calendar.ts new file mode 100644 index 0000000..f4299de --- /dev/null +++ b/lib/tools/calendar.ts @@ -0,0 +1,156 @@ +import type { VoiceTool } from './_types' +import { tabletGet, tabletJson } from './_http' + +const getTodayEvents: VoiceTool = { + schema: { + type: 'function', + function: { + name: 'get_today_events', + description: + 'События из календаря (Даниил + Света). Вернёт id события, title, start, end, ' + + 'owner («daniil» или «sveta»). ВАЖНО: для update_event / delete_event сначала ' + + 'вызывай этот tool чтобы получить event_id.', + parameters: { + type: 'object', + properties: { + range: { + type: 'string', + enum: ['today', 'week', 'month'], + description: 'today (по умолчанию), week (7 дней) или month (текущий месяц)', + }, + }, + }, + }, + }, + + async execute(args) { + const range = (args?.range as string) || 'today' + return tabletGet('/api/voice/tools/events', { range }) + }, +} + +const createEvent: VoiceTool = { + schema: { + type: 'function', + function: { + name: 'create_event', + description: + 'Создать событие в Google Calendar. ВАЖНО: параметр owner обязателен. ' + + 'Если пользователь не сказал чей это календарь — СПРОСИ у него ' + + '(«в твой календарь или в Светин?») и только потом вызывай tool. Не угадывай.', + parameters: { + 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'], + }, + }, + }, + + async execute(args) { + const title = String(args?.title || '').trim() + const date = String(args?.date || '').trim() + if (!title || !date) return { error: 'title and date required' } + const payload: Record = { + title, + date, + owner: args?.owner || 'daniil', + all_day: !!args?.all_day, + } + if (!payload.all_day) { + payload.start_time = args?.start_time || '' + if (args?.end_time !== undefined) payload.end_time = args.end_time + } + return tabletJson('POST', '/api/voice/tools/events', payload) + }, +} + +const updateEvent: VoiceTool = { + schema: { + type: 'function', + function: { + name: 'update_event', + description: + 'Изменить существующее событие. Сначала обязательно вызови get_today_events ' + + 'чтобы получить event_id и owner нужного события. Передавай только те поля ' + + 'которые меняешь.', + parameters: { + 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'], + }, + }, + }, + + async execute(args) { + const event_id = String(args?.event_id || '').trim() + const owner = String(args?.owner || '').trim() + if (!event_id || !owner) return { error: 'event_id and owner required' } + const payload: Record = { event_id, owner } + for (const k of ['title', 'date', 'start_time', 'end_time', 'all_day']) { + if (args?.[k] !== undefined) payload[k] = args[k] + } + return tabletJson('PUT', '/api/voice/tools/events', payload) + }, +} + +const deleteEvent: VoiceTool = { + schema: { + type: 'function', + function: { + name: 'delete_event', + description: + 'Удалить событие из календаря. Сначала вызови get_today_events чтобы найти ' + + 'event_id и определить owner. Подтверди удаление с пользователем если событие ' + + 'важное (встреча, врач, работа).', + parameters: { + type: 'object', + properties: { + event_id: { type: 'string' }, + owner: { type: 'string', enum: ['daniil', 'sveta'] }, + }, + required: ['event_id', 'owner'], + }, + }, + }, + + async execute(args) { + const event_id = String(args?.event_id || '').trim() + const owner = String(args?.owner || 'daniil').trim() + if (!event_id) return { error: 'event_id required' } + return tabletJson('DELETE', '/api/voice/tools/events', undefined, { event_id, owner }) + }, +} + +export const tools: VoiceTool[] = [getTodayEvents, createEvent, updateEvent, deleteEvent] diff --git a/lib/tools/notes.ts b/lib/tools/notes.ts new file mode 100644 index 0000000..f3a3879 --- /dev/null +++ b/lib/tools/notes.ts @@ -0,0 +1,22 @@ +import type { VoiceTool } from './_types' +import { tabletGet } from './_http' + +export const tool: VoiceTool = { + schema: { + type: 'function', + function: { + name: 'get_notes', + description: + 'Список заметок и списков покупок с планшета. Для «что мне купить», ' + + '«что в списке», «какие записи».', + parameters: { + type: 'object', + properties: {}, + }, + }, + }, + + async execute() { + return tabletGet('/api/voice/tools/notes') + }, +} diff --git a/lib/tools/timers.ts b/lib/tools/timers.ts new file mode 100644 index 0000000..37523a9 --- /dev/null +++ b/lib/tools/timers.ts @@ -0,0 +1,111 @@ +import type { VoiceTool, AgentId } from './_types' +import { tabletJson } from './_http' + +const setTimer: VoiceTool = { + schema: { + type: 'function', + function: { + name: 'set_timer', + description: + 'Запустить таймер на планшете. Показывает обратный отсчёт с названием и звенит ' + + 'по окончании. Используй для «поставь таймер на 10 минут», «напомни через час», ' + + '«засеки 5 минут для чайника».', + parameters: { + type: 'object', + properties: { + seconds: { + type: 'integer', + description: 'Длительность в секундах (1..86400)', + minimum: 1, + maximum: 86400, + }, + label: { + type: 'string', + description: 'Короткое название таймера (например «Чайник», «Паста»)', + }, + }, + required: ['seconds', 'label'], + }, + }, + }, + + async execute(args, agent: AgentId) { + const seconds = Number(args?.seconds || 0) + const label = String(args?.label || 'Таймер') + if (!Number.isFinite(seconds) || seconds < 1) return { error: 'seconds must be positive' } + return tabletJson('POST', '/api/voice/timer', { + action: 'start', + seconds, + label, + agent, + }) + }, +} + +const cancelTimer: VoiceTool = { + schema: { + type: 'function', + function: { + name: 'cancel_timer', + description: + 'Отменить активный таймер по его названию. Для «отмени таймер чайник», ' + + '«убери таймер пасты», «останови отсчёт».', + parameters: { + type: 'object', + properties: { + label: { + type: 'string', + description: 'Название таймера (примерное совпадение — можно частично).', + }, + }, + required: ['label'], + }, + }, + }, + + async execute(args) { + const label = String(args?.label || '').trim() + if (!label) return { error: 'label required' } + return tabletJson('POST', '/api/voice/timer', { action: 'cancel', label }) + }, +} + +const adjustTimer: VoiceTool = { + schema: { + type: 'function', + function: { + name: 'adjust_timer', + description: + 'Изменить оставшееся время таймера. Для «добавь ещё 5 минут», «убавь на минуту», ' + + '«накинь времени чайнику». Положительный delta_seconds = добавить, отрицательный = уменьшить.', + parameters: { + type: 'object', + properties: { + label: { + type: 'string', + description: 'Название таймера для которого меняем время.', + }, + delta_seconds: { + type: 'integer', + description: 'Секунды (+ добавить, - уменьшить). Например 300 = +5 минут, -60 = -1 минута.', + }, + }, + required: ['label', 'delta_seconds'], + }, + }, + }, + + async execute(args) { + const label = String(args?.label || '').trim() + const delta = Number(args?.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 const tools: VoiceTool[] = [setTimer, cancelTimer, adjustTimer] diff --git a/lib/tools/transport.ts b/lib/tools/transport.ts new file mode 100644 index 0000000..4114156 --- /dev/null +++ b/lib/tools/transport.ts @@ -0,0 +1,38 @@ +import type { VoiceTool } from './_types' +import { tabletGet } from './_http' + +export const tool: VoiceTool = { + schema: { + type: 'function', + function: { + name: 'get_transport', + description: + 'Расписание ближайших трамваев на остановке Ул. Антонова-Овсеенко. ' + + 'Для вопросов «когда следующий 23-й», «что ближайшее в центр», «пора идти на остановку».', + parameters: { + 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». Пусто = все маршруты.', + }, + }, + }, + }, + }, + + async execute(args) { + const q: Record = {} + if (args?.direction) q.direction = String(args.direction) + if (args?.routes) q.routes = String(args.routes) + return tabletGet('/api/voice/tools/transport', q) + }, +} diff --git a/lib/tools/weather.ts b/lib/tools/weather.ts new file mode 100644 index 0000000..f4d4409 --- /dev/null +++ b/lib/tools/weather.ts @@ -0,0 +1,31 @@ +import type { VoiceTool } from './_types' +import { tabletGet } from './_http' + +export const tool: VoiceTool = { + schema: { + type: 'function', + function: { + name: 'get_weather', + description: + 'Получить текущую погоду и короткий прогноз для города. ' + + 'Для вопросов вроде «какая сегодня погода», «холодно ли на улице», «нужен ли зонт». ' + + 'По умолчанию — Санкт-Петербург.', + parameters: { + type: 'object', + properties: { + city: { + type: 'string', + description: + 'Город на русском или шорткод (spb, msk, sochi, ekb, kzn, nsk, krd). ' + + 'По умолчанию Санкт-Петербург.', + }, + }, + }, + }, + }, + + async execute(args) { + const city = (args?.city as string) || '' + return tabletGet('/api/voice/tools/weather', city ? { city } : undefined) + }, +}