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}` }
}
}