Files
smart-home-tablet/app/api/voice/chat/route.ts
Cosmo 83f07fa29e
All checks were successful
Deploy / deploy (push) Successful in 1m28s
perf(voice): prompt-caching для Claude + отсев мёртвых сущностей
- claudeRequest: system разбит на кэшируемый базовый промпт (cache_control
  ephemeral) + волатильный живой контекст отдельным блоком. Схемы инструментов
  попадают в кэшируемый префикс (render order tools->system) и читаются за ~10%.
- buildLiveContext: пропускаем entity в состоянии unavailable/unknown — меньше
  токенов и шума в снимке.
- Цель: ~20k input/запрос на Haiku давал ~$0.02+/запрос; кэш стабильной части
  (инструменты+база) режет стоимость в разы, ответы не меняются.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UYsVrPyJ5phJeVsAGciS1v
2026-07-22 21:02:15 +00:00

334 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
export const dynamic = 'force-dynamic'
export const runtime = 'nodejs'
import { NextResponse } from 'next/server'
import Groq from 'groq-sdk'
import { HttpsProxyAgent } from 'https-proxy-agent'
import { fetch as undiciFetch, ProxyAgent } from 'undici'
import { voiceBus } from '@/lib/voice-bus'
import { systemPrompt } from '@/lib/voice-prompts'
import { TOOL_SCHEMAS, executeTool } from '@/lib/tools/_registry'
import { tryLocalAgent } from '@/lib/agent-local'
import { cleanForSpeech, stripFillers, isResetCommand } from '@/lib/voice-text'
import { loadHistory, saveHistory, resetHistory, HistoryMessage } from '@/lib/voice-history'
import { promises as nodeFs } from 'node:fs'
const MAX_TOKENS = parseInt(process.env.VOICE_MAX_TOKENS || '300', 10)
const MAX_TOOL_ROUNDS = 4
const RATE_LIMIT_PER_MINUTE = parseInt(process.env.VOICE_RATE_LIMIT || '20', 10)
const ANTHROPIC_API_KEY = process.env.ANTHROPIC_API_KEY || ''
const ANTHROPIC_PROXY = process.env.ANTHROPIC_PROXY || ''
const GROQ_API_KEY = process.env.GROQ_API_KEY || ''
const GROQ_PROXY = process.env.GROQ_PROXY || ''
const SETTINGS_PATH = '/data/settings.json'
const SETTINGS_DEFAULTS = { voiceProvider: 'anthropic', anthropicModel: 'claude-haiku-4-5-20251001', groqModel: 'meta-llama/llama-4-scout-17b-16e-instruct' }
async function getVoiceSettings() {
try {
const raw = await nodeFs.readFile(SETTINGS_PATH, 'utf-8')
return { ...SETTINGS_DEFAULTS, ...JSON.parse(raw) }
} catch { return { ...SETTINGS_DEFAULTS } }
}
// ——— Rate limit ———
const rateBuckets = new Map<string, { count: number; resetAt: number }>()
function rateLimit(key: string): boolean {
const now = Date.now()
const b = rateBuckets.get(key)
if (!b || b.resetAt <= now) { rateBuckets.set(key, { count: 1, resetAt: now + 60_000 }); return true }
if (b.count >= RATE_LIMIT_PER_MINUTE) return false
b.count++; return true
}
let lastSweep = 0
function sweep() {
const now = Date.now()
if (now - lastSweep < 5 * 60_000) return
lastSweep = now
for (const [k, v] of rateBuckets) if (v.resetAt <= now) rateBuckets.delete(k)
}
// ——— Groq client ———
let _groqClient: Groq | null = null
function groqClient(): Groq {
if (_groqClient) return _groqClient
if (!GROQ_API_KEY) throw new Error('GROQ_API_KEY not set')
const httpAgent = GROQ_PROXY ? new HttpsProxyAgent(GROQ_PROXY) : undefined
_groqClient = new Groq({ apiKey: GROQ_API_KEY, httpAgent } as any)
return _groqClient
}
// ——— Anthropic helpers ———
function toAnthropicTools(tools: any[]): any[] {
return tools.map(t => ({
name: t.function.name,
description: t.function.description || '',
input_schema: t.function.parameters || { type: 'object', properties: {} },
}))
}
function historyToAnthropicMessages(history: HistoryMessage[]): any[] {
const result: any[] = []
for (const msg of history) {
if ((msg.role as string) === 'system') continue
if (msg.role === 'user') {
result.push({ role: 'user', content: msg.content || '' })
} else if (msg.role === 'assistant') {
if (msg.tool_calls?.length) {
const content: any[] = []
if (msg.content) content.push({ type: 'text', text: msg.content })
for (const tc of msg.tool_calls) {
let input: any = {}
try { const _p = JSON.parse(tc.function.arguments); if (_p && typeof _p === 'object' && !Array.isArray(_p)) input = _p } catch {}
content.push({ type: 'tool_use', id: tc.id, name: tc.function.name, input })
}
result.push({ role: 'assistant', content })
} else {
result.push({ role: 'assistant', content: msg.content || '' })
}
} else if (msg.role === 'tool') {
const last = result[result.length - 1]
const block = { type: 'tool_result', tool_use_id: msg.tool_call_id, content: msg.content || '' }
if (last?.role === 'user' && Array.isArray(last.content)) last.content.push(block)
else result.push({ role: 'user', content: [block] })
}
}
return result
}
async function claudeRequest(baseSys: string, liveCtx: string, messages: any[], tools: any[]): Promise<any> {
const system: any[] = [{ type: 'text', text: baseSys, cache_control: { type: 'ephemeral' } }]
if (liveCtx) system.push({ type: 'text', text: liveCtx })
const body: any = { model: 'claude-haiku-4-5-20251001', max_tokens: MAX_TOKENS, system, messages }
if (tools.length > 0) body.tools = tools
const opts: any = {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'x-api-key': ANTHROPIC_API_KEY, 'anthropic-version': '2023-06-01' },
body: JSON.stringify(body),
}
if (ANTHROPIC_PROXY) opts.dispatcher = new ProxyAgent(ANTHROPIC_PROXY)
const res = await (ANTHROPIC_PROXY ? undiciFetch : fetch)('https://api.anthropic.com/v1/messages', opts)
if (!res.ok) { const err = await res.text(); throw new Error(`anthropic_${res.status}: ${err}`) }
return res.json()
}
type AgentId = 'cosmo' | 'lusya'
function emitVoice(event: string, agent: AgentId, text?: string) {
voiceBus.emit('voice', { event, agent, text, timestamp: new Date().toISOString() })
}
// Живой контекст для системного промпта: текущее время + снимок устройств из HA.
// Defensive: любая ошибка/таймаут → только время, без падения запроса.
async function buildLiveContext(): Promise<string> {
const now = new Date()
const timeStr = now.toLocaleString('ru-RU', {
weekday: 'long', day: 'numeric', month: 'long', hour: '2-digit', minute: '2-digit',
})
try {
const base = `http://localhost:${process.env.PORT || '3000'}`
const r = await fetch(`${base}/api/ha`, {
cache: 'no-store',
headers: { 'x-voice-internal': process.env.VOICE_API_KEY || '' },
signal: AbortSignal.timeout(1200),
})
if (!r.ok) return `\n\nСейчас: ${timeStr}.`
const d = await r.json()
const states = d.states || {}
const s = d.sensors || {}
const lines: string[] = []
for (const [id, st] of Object.entries<any>(states)) {
if (st.state === 'unavailable' || st.state === 'unknown') continue
const name = st.attributes?.friendly_name || id
let extra = ''
if (st.attributes?.preset_mode) extra += `, режим ${st.attributes.preset_mode}`
if (st.attributes?.current_temperature != null) extra += `, ${st.attributes.current_temperature}°`
lines.push(` ${name} (${id}): ${st.state}${extra}`)
}
const sensorLine = [
s.temperature != null ? `дома ${s.temperature}°` : '',
s.humidity != null ? `влажность ${s.humidity}%` : '',
s.pm25 != null ? `PM2.5 ${s.pm25}` : '',
].filter(Boolean).join(', ')
if (!lines.length && !sensorLine) return `\n\nСейчас: ${timeStr}.`
return `\n\nСнимок дома (${timeStr}):\n${sensorLine ? ' ' + sensorLine + '\n' : ''}${lines.join('\n')}\n` +
`Для устройств без специального tool используй ha_service с entity_id выше.`
} catch {
return `\n\nСейчас: ${timeStr}.`
}
}
export async function POST(req: Request) {
const cookie = req.headers.get('cookie') || ''
const tokenMatch = cookie.match(/auth_token=([a-f0-9]{32,})/i)
const internal = req.headers.get('x-voice-internal') || ''
const fwd = req.headers.get('x-forwarded-for') || ''
const ratekey = tokenMatch?.[1] || (internal ? 'internal' : '') || fwd.split(',')[0].trim() || 'anon'
sweep()
if (!rateLimit(ratekey)) return NextResponse.json({ error: 'rate_limited' }, { status: 429 })
const body = await req.json().catch(() => null)
if (!body || typeof body.text !== 'string' || !body.text.trim()) return NextResponse.json({ error: 'text required' }, { status: 400 })
const userText = body.text.trim().slice(0, 4000)
const agent: AgentId = body.agent === 'lusya' ? 'lusya' : 'cosmo'
emitVoice('command', agent, userText)
if (isResetCommand(userText)) {
await resetHistory(agent)
const msg = 'Начинаю новую сессию.'
emitVoice('response', agent, msg)
return NextResponse.json({ text: msg, reset: true })
}
const settings = await getVoiceSettings()
const provider = settings.voiceProvider || 'anthropic'
const history = await loadHistory(agent)
const sysPrompt = systemPrompt(agent)
const newTurns: HistoryMessage[] = [{ role: 'user', content: userText }]
let finalText = ''
// ======== Локальный агент (fast-path) ========
// Если задан AGENT_URL — команды-действия исполняет локальная intent-служба
// за ~2 мс, минуя облачный LLM. Инфо-запросы и всё неуверенное падают в LLM
// ниже. Любая ошибка → тоже fallback. Без AGENT_URL — поведение не меняется.
try {
const fast = await tryLocalAgent(userText, agent)
if (fast) {
const assistant: HistoryMessage = { role: 'assistant', content: fast.text }
await saveHistory(agent, [...history, ...newTurns, assistant])
emitVoice('response', agent, fast.text)
console.log(`[voice/chat] local-agent ${agent} intent=${fast.intent}`)
return NextResponse.json({ text: fast.text, via: 'local-agent' })
}
} catch (e) {
console.log('[voice/chat] local-agent skip:', (e as Error).message)
}
// Живой контекст (время + снимок устройств) — только для LLM-пути (fast-path
// выше уже вышел). Даёт модели понимание, что включено и что вообще есть в
// доме, чтобы рассуждать про умный дом и не переспрашивать очевидное.
const liveCtx = await buildLiveContext()
const fullSys = sysPrompt + liveCtx
// ======== GROQ ========
if (provider === 'groq') {
const groqModel = settings.groqModel || 'llama-3.3-70b-versatile'
// Нормализовать tool_calls в истории — Groq требует type:'function'
const normalizedHistory = history.map(m => {
if (m.role === 'assistant' && m.tool_calls?.length) {
return { ...m, tool_calls: m.tool_calls.map((tc: any) => ({ type: 'function', ...tc })) }
}
return m
})
const groqMessages: any[] = [
{ role: 'system', content: fullSys },
...normalizedHistory,
{ role: 'user', content: userText },
]
let lastToolSig = ''
try {
const c = groqClient()
for (let round = 0; round < MAX_TOOL_ROUNDS; round++) {
const t0 = Date.now()
const resp = await c.chat.completions.create({
model: groqModel, max_tokens: MAX_TOKENS,
messages: groqMessages as any,
tools: TOOL_SCHEMAS as any,
tool_choice: 'auto',
})
const choice = resp.choices[0]
const msg = choice.message
console.log(`[voice/chat] groq ${agent} r${round+1} ${Date.now()-t0}ms stop=${choice.finish_reason}`)
groqMessages.push(msg)
if (choice.finish_reason === 'tool_calls' && msg.tool_calls?.length) {
const sig = msg.tool_calls.map((tc: any) => `${tc.function.name}:${tc.function.arguments}`).sort().join('|')
if (sig === lastToolSig) { finalText = 'Не удалось выполнить запрос.'; break }
lastToolSig = sig
newTurns.push({ role: 'assistant', content: msg.content ?? null, tool_calls: msg.tool_calls })
for (const tc of msg.tool_calls) {
let args: any = {}; try { args = JSON.parse(tc.function.arguments) } catch {}
const result = await executeTool(tc.function.name, args, agent)
groqMessages.push({ role: 'tool', tool_call_id: tc.id, content: JSON.stringify(result) })
newTurns.push({ role: 'tool', content: JSON.stringify(result), tool_call_id: tc.id })
}
continue
}
finalText = msg.content || ''
newTurns.push({ role: 'assistant', content: finalText })
break
}
} catch (e: any) {
const errStr = String(e?.message || e)
console.error('[voice/chat] groq error:', errStr)
if (errStr.includes('tool_use_failed') || errStr.includes('Failed to call a function')) {
try {
const fb = await groqClient().chat.completions.create({
model: groqModel, max_tokens: MAX_TOKENS,
messages: [{ role: 'system', content: fullSys }, { role: 'user', content: userText }],
})
finalText = fb.choices[0]?.message?.content || 'Не удалось выполнить запрос.'
newTurns.push({ role: 'assistant', content: finalText })
} catch { finalText = 'Не удалось выполнить запрос.' }
} else {
const msg = 'Что-то сломалось.'
emitVoice('error', agent, msg)
return NextResponse.json({ error: 'llm_failed', detail: errStr, text: msg }, { status: 502 })
}
}
// ======== ANTHROPIC ========
} else {
const anthropicTools = toAnthropicTools(TOOL_SCHEMAS as any[])
const messages: any[] = [...historyToAnthropicMessages(history), { role: 'user', content: userText }]
try {
for (let round = 0; round < MAX_TOOL_ROUNDS; round++) {
const t0 = Date.now()
const resp = await claudeRequest(sysPrompt, liveCtx, messages, anthropicTools)
console.log(`[voice/chat] claude ${agent} r${round+1} ${Date.now()-t0}ms stop=${resp.stop_reason} in=${resp.usage?.input_tokens} out=${resp.usage?.output_tokens}`)
const content: any[] = resp.content || []
const stopReason = resp.stop_reason || 'end_turn'
if (stopReason === 'tool_use') {
const toolUseBlocks = content.filter((b: any) => b.type === 'tool_use')
const partialText = content.filter((b: any) => b.type === 'text').map((b: any) => b.text).join('')
messages.push({ role: 'assistant', content })
newTurns.push({
role: 'assistant', content: partialText || null,
tool_calls: toolUseBlocks.map((b: any) => ({ id: b.id, function: { name: b.name, arguments: JSON.stringify(b.input) } })),
})
const toolResults: any[] = []
for (const tb of toolUseBlocks) {
console.log(`[voice/chat] tool ${tb.name}(${JSON.stringify(tb.input).slice(0,200)})`)
const result = await executeTool(tb.name, tb.input || {}, agent)
toolResults.push({ type: 'tool_result', tool_use_id: tb.id, content: JSON.stringify(result) })
newTurns.push({ role: 'tool', content: JSON.stringify(result), tool_call_id: tb.id })
}
messages.push({ role: 'user', content: toolResults })
continue
}
finalText = content.filter((b: any) => b.type === 'text').map((b: any) => b.text).join('')
newTurns.push({ role: 'assistant', content: finalText })
break
}
} catch (e: any) {
const errStr = String(e?.message || e)
console.error('[voice/chat] claude error:', errStr)
const msg = 'Что-то сломалось.'
emitVoice('error', agent, msg)
return NextResponse.json({ error: 'llm_failed', detail: errStr, text: msg }, { status: 502 })
}
}
if (!finalText.trim()) {
const msg = 'Не получил ответ.'
emitVoice('error', agent, msg)
return NextResponse.json({ text: msg }, { status: 200 })
}
await saveHistory(agent, [...history, ...newTurns])
const cleaned = cleanForSpeech(stripFillers(finalText))
emitVoice('response', agent, cleaned)
return NextResponse.json({ text: cleaned })
}