feat(voice): server-side LLM/STT — porting Python satellite into tablet
All checks were successful
Deploy / deploy (push) Successful in 5m44s
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:
159
lib/voice-executors.ts
Normal file
159
lib/voice-executors.ts
Normal 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}` }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user