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) — отдельно.
160 lines
5.3 KiB
TypeScript
160 lines
5.3 KiB
TypeScript
/**
|
|
* 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}` }
|
|
}
|
|
}
|