refactor: tool plugin registry - each tool in separate file
All checks were successful
Deploy / deploy (push) Successful in 1m25s
All checks were successful
Deploy / deploy (push) Successful in 1m25s
This commit is contained in:
@@ -7,8 +7,7 @@ import { HttpsProxyAgent } from 'https-proxy-agent'
|
|||||||
|
|
||||||
import { voiceBus } from '@/lib/voice-bus'
|
import { voiceBus } from '@/lib/voice-bus'
|
||||||
import { systemPrompt } from '@/lib/voice-prompts'
|
import { systemPrompt } from '@/lib/voice-prompts'
|
||||||
import { TOOL_SCHEMAS } from '@/lib/voice-tool-schemas'
|
import { TOOL_SCHEMAS, executeTool } from '@/lib/tools/_registry'
|
||||||
import { executeTool } from '@/lib/voice-executors'
|
|
||||||
import { cleanForSpeech, stripFillers, isResetCommand } from '@/lib/voice-text'
|
import { cleanForSpeech, stripFillers, isResetCommand } from '@/lib/voice-text'
|
||||||
import {
|
import {
|
||||||
loadHistory, saveHistory, resetHistory,
|
loadHistory, saveHistory, resetHistory,
|
||||||
|
|||||||
54
lib/tools/_http.ts
Normal file
54
lib/tools/_http.ts
Normal file
@@ -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<string, string> {
|
||||||
|
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<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()
|
||||||
|
}
|
||||||
|
|
||||||
|
export 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()
|
||||||
|
}
|
||||||
44
lib/tools/_registry.ts
Normal file
44
lib/tools/_registry.ts
Normal file
@@ -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<string, any>,
|
||||||
|
agent: AgentId,
|
||||||
|
): Promise<Record<string, any> | { 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}` }
|
||||||
|
}
|
||||||
|
}
|
||||||
8
lib/tools/_types.ts
Normal file
8
lib/tools/_types.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
import type { GroqTool } from '../voice-tool-schemas'
|
||||||
|
|
||||||
|
export type AgentId = 'cosmo' | 'lusya'
|
||||||
|
|
||||||
|
export interface VoiceTool {
|
||||||
|
schema: GroqTool
|
||||||
|
execute: (args: Record<string, any>, agent: AgentId) => Promise<any>
|
||||||
|
}
|
||||||
156
lib/tools/calendar.ts
Normal file
156
lib/tools/calendar.ts
Normal file
@@ -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<string, any> = {
|
||||||
|
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<string, any> = { 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]
|
||||||
22
lib/tools/notes.ts
Normal file
22
lib/tools/notes.ts
Normal file
@@ -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')
|
||||||
|
},
|
||||||
|
}
|
||||||
111
lib/tools/timers.ts
Normal file
111
lib/tools/timers.ts
Normal file
@@ -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]
|
||||||
38
lib/tools/transport.ts
Normal file
38
lib/tools/transport.ts
Normal file
@@ -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<string, string> = {}
|
||||||
|
if (args?.direction) q.direction = String(args.direction)
|
||||||
|
if (args?.routes) q.routes = String(args.routes)
|
||||||
|
return tabletGet('/api/voice/tools/transport', q)
|
||||||
|
},
|
||||||
|
}
|
||||||
31
lib/tools/weather.ts
Normal file
31
lib/tools/weather.ts
Normal file
@@ -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)
|
||||||
|
},
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user