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:
100
lib/voice-history.ts
Normal file
100
lib/voice-history.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* История диалога per-agent per-day. Файлы в /data/voice-history/{agent}-{date}.json.
|
||||
* /data — это volume контейнера (на хосте /opt/digital-home/smart-home-tablet-data/).
|
||||
*/
|
||||
import { promises as fs } from 'node:fs'
|
||||
import path from 'node:path'
|
||||
|
||||
const DATA_DIR = process.env.VOICE_HISTORY_DIR || '/data/voice-history'
|
||||
const MAX_HISTORY = parseInt(process.env.VOICE_MAX_HISTORY || '40', 10)
|
||||
|
||||
export type HistoryMessage = {
|
||||
role: 'user' | 'assistant'
|
||||
content: any
|
||||
}
|
||||
|
||||
function todayIso(): string {
|
||||
return new Date().toISOString().slice(0, 10)
|
||||
}
|
||||
|
||||
function historyPath(agent: string): string {
|
||||
return path.join(DATA_DIR, `${agent}-${todayIso()}.json`)
|
||||
}
|
||||
|
||||
export async function loadHistory(agent: string): Promise<HistoryMessage[]> {
|
||||
try {
|
||||
const raw = await fs.readFile(historyPath(agent), 'utf-8')
|
||||
const parsed = JSON.parse(raw)
|
||||
return Array.isArray(parsed) ? parsed : []
|
||||
} catch (e: any) {
|
||||
if (e?.code === 'ENOENT') return []
|
||||
console.warn('[voice/history] read failed:', e?.message || e)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveHistory(agent: string, history: HistoryMessage[]): Promise<void> {
|
||||
try {
|
||||
await fs.mkdir(DATA_DIR, { recursive: true })
|
||||
const trimmed = history.slice(-MAX_HISTORY)
|
||||
await fs.writeFile(historyPath(agent), JSON.stringify(trimmed, null, 2), 'utf-8')
|
||||
} catch (e: any) {
|
||||
console.warn('[voice/history] write failed:', e?.message || e)
|
||||
}
|
||||
}
|
||||
|
||||
export async function resetHistory(agent: string): Promise<void> {
|
||||
try {
|
||||
await fs.unlink(historyPath(agent))
|
||||
} catch (e: any) {
|
||||
if (e?.code !== 'ENOENT') console.warn('[voice/history] reset failed:', e?.message || e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Убирает cache_control из блоков (для записи в историю — следующий turn пересчитает границу).
|
||||
*/
|
||||
export function stripCacheControl(content: any): any {
|
||||
if (Array.isArray(content)) {
|
||||
return content.map((b) => {
|
||||
if (b && typeof b === 'object' && 'cache_control' in b) {
|
||||
const { cache_control: _ignore, ...rest } = b
|
||||
return rest
|
||||
}
|
||||
return b
|
||||
})
|
||||
}
|
||||
return content
|
||||
}
|
||||
|
||||
/**
|
||||
* Граница prompt-кеша: всё, кроме последних N сообщений, помечаем cache_control
|
||||
* на последнем блоке последнего «старого» сообщения. Даёт cache hit на каждом turn.
|
||||
*/
|
||||
export function buildMessagesWithCache(history: HistoryMessage[], cacheTailUncached = 2): HistoryMessage[] {
|
||||
if (history.length <= cacheTailUncached) {
|
||||
return history.map((m) => ({ role: m.role, content: m.content }))
|
||||
}
|
||||
const cacheBoundary = history.length - cacheTailUncached
|
||||
return history.map((msg, i) => {
|
||||
if (i === cacheBoundary - 1) {
|
||||
return { role: msg.role, content: wrapLastBlockWithCache(msg.content) }
|
||||
}
|
||||
return { role: msg.role, content: msg.content }
|
||||
})
|
||||
}
|
||||
|
||||
function wrapLastBlockWithCache(content: any): any {
|
||||
if (typeof content === 'string') {
|
||||
return [{ type: 'text', text: content, cache_control: { type: 'ephemeral' } }]
|
||||
}
|
||||
if (Array.isArray(content) && content.length) {
|
||||
const out = [...content]
|
||||
const last = out[out.length - 1]
|
||||
if (last && typeof last === 'object') {
|
||||
out[out.length - 1] = { ...last, cache_control: { type: 'ephemeral' } }
|
||||
}
|
||||
return out
|
||||
}
|
||||
return content
|
||||
}
|
||||
Reference in New Issue
Block a user