Files
smart-home-tablet/lib/voice-history.ts
Cosmo 05b300d472
All checks were successful
Deploy / deploy (push) Successful in 1m47s
chore(voice): security, cleanup, resilience
Безопасность:
- Rate-limit на /api/voice/chat (20/мин per cookie/IP, env VOICE_RATE_LIMIT).
  Защищает от случайных циклов и утечки PIN.
- Усечение user prompt'а до 4000 символов в /api/voice/chat.
- Tool-loop защита от циклов: если LLM дважды просит тот же tool с теми же
  args — прерываем (раньше мог уйти в бесконечный цикл при tool error'ах).

Чистка кода:
- lib/debug.ts — vlog/vwarn/verror гейтят браузерные логи за
  NEXT_PUBLIC_VOICE_DEBUG=1 (или localStorage 'voice-debug=1').
  Серверные console.log оставлены — полезны в Docker logs.
- lib/audio-wav.ts — вынесена дублированная floatToWav из VoiceController.
- Удалены orphan компоненты FocusCard.tsx и CountdownCard.tsx
  (не подключены, отвергнуты по UX-фидбеку).

Resilience:
- WakeWordDetector: drop-on-busy в onChunk — на медленных устройствах
  (Android, бюджетный CPU) backlog inference больше не копится.
- voice-history fallback на /tmp/voice-history если /data не примонтирован
  (локальная разработка / нестандартная конфигурация).
2026-04-27 12:44:18 +00:00

109 lines
3.8 KiB
TypeScript

/**
* История диалога per-agent per-day. Файлы в /data/voice-history/{agent}-{date}.json.
* /data — это volume контейнера (на хосте /opt/digital-home/smart-home-tablet-data/).
*
* Fallback: если /data не существует (локальная разработка) — пишем в /tmp/voice-history.
*/
import { promises as fs } from 'node:fs'
import { existsSync } from 'node:fs'
import path from 'node:path'
const PRIMARY_DIR = process.env.VOICE_HISTORY_DIR || '/data/voice-history'
const DATA_DIR = (() => {
// Проверяем существование родителя (/data) — без него запись упадёт ENOENT.
const parent = path.dirname(PRIMARY_DIR)
return existsSync(parent) ? PRIMARY_DIR : '/tmp/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
}