Files
smart-home-tablet/app/api/voice/chat/route.ts
Cosmo 04b7d1f104
All checks were successful
Deploy / deploy (push) Successful in 2m47s
feat: switch from Anthropic to Groq API (llama-3.3-70b-versatile)
- route.ts: replace @anthropic-ai/sdk with groq-sdk, rewrite chat loop
- voice-tool-schemas.ts: convert from Anthropic format to OpenAI/Groq function tools
- voice-history.ts: extend HistoryMessage type to include tool role, simplify cache stubs

No prompt caching (Groq does not support it), tool calling preserved.
2026-04-30 20:43:30 +00:00

189 lines
6.4 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 { voiceBus } from '@/lib/voice-bus'
import { systemPrompt } from '@/lib/voice-prompts'
import { TOOL_SCHEMAS } from '@/lib/voice-tool-schemas'
import { executeTool } from '@/lib/voice-executors'
import { cleanForSpeech, stripFillers, isResetCommand } from '@/lib/voice-text'
import {
loadHistory, saveHistory, resetHistory,
HistoryMessage,
} from '@/lib/voice-history'
const MODEL = process.env.GROQ_MODEL || 'llama-3.3-70b-versatile'
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)
// In-memory rate-limit per IP / cookie (host один — Docker контейнер).
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)
}
let _client: Groq | null = null
function client(): Groq {
if (_client) return _client
const apiKey = process.env.GROQ_API_KEY
if (!apiKey) throw new Error('GROQ_API_KEY not set')
_client = new Groq({ apiKey })
return _client
}
function emitVoice(event: string, agent: 'cosmo' | 'lusya', text?: string) {
voiceBus.emit('voice', {
event,
agent,
text,
timestamp: new Date().toISOString(),
})
}
type AgentId = 'cosmo' | 'lusya'
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: string = 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 })
}
// Загружаем историю и строим messages для Groq (OpenAI-compatible format)
const history = await loadHistory(agent)
// Системный prompt + история + новый user message
const apiMessages: any[] = [
{ role: 'system', content: systemPrompt(agent) },
...history,
{ role: 'user', content: userText },
]
let finalText = ''
const historyStartLen = apiMessages.length // позиция после которой добавляем новые turns
// Защита от tool-cycling
let lastToolSig = ''
try {
const c = client()
for (let round = 0; round < MAX_TOOL_ROUNDS; round++) {
const t0 = Date.now()
const resp = await c.chat.completions.create({
model: MODEL,
max_tokens: MAX_TOKENS,
messages: apiMessages,
tools: TOOL_SCHEMAS as any,
tool_choice: 'auto',
})
const choice = resp.choices[0]
const msg = choice.message
const usage = resp.usage as any
console.log(
`[voice/chat] ${agent} round ${round + 1} ${Date.now() - t0}ms · ` +
`stop=${choice.finish_reason} · in=${usage?.prompt_tokens} out=${usage?.completion_tokens}`
)
// Добавляем assistant message в messages
apiMessages.push(msg)
if (choice.finish_reason === 'tool_calls' && msg.tool_calls?.length) {
const toolCalls = msg.tool_calls
// Loop-guard: сигнатура текущего раунда
const sig = toolCalls
.map((tc: any) => `${tc.function.name}:${tc.function.arguments}`)
.sort()
.join('|')
if (sig === lastToolSig) {
console.warn('[voice/chat] tool cycle detected, breaking loop')
finalText += '\nНе получилось выполнить запрос.'
break
}
lastToolSig = sig
// Выполняем все tool calls и добавляем результаты
for (const tc of toolCalls) {
console.log(`[voice/chat] tool ${tc.function.name}(${tc.function.arguments.slice(0, 200)})`)
let args: any = {}
try { args = JSON.parse(tc.function.arguments) } catch (_) {}
const result = await executeTool(tc.function.name, args, agent)
apiMessages.push({
role: 'tool',
tool_call_id: tc.id,
content: JSON.stringify(result),
})
}
continue
}
// Финальный ответ
finalText = msg.content || ''
break
}
} catch (e: any) {
console.error('[voice/chat] groq error:', e?.message || e)
const msg = 'Что-то сломалось.'
emitVoice('error', agent, msg)
return NextResponse.json({ error: 'llm_failed', detail: String(e?.message || e), text: msg }, { status: 502 })
}
if (!finalText.trim()) {
const msg = 'Не получил ответ.'
emitVoice('error', agent, msg)
return NextResponse.json({ text: msg }, { status: 200 })
}
// Сохраняем новые turns в историю (без системного prompt'а)
const newTurns = apiMessages.slice(historyStartLen)
const updatedHistory: HistoryMessage[] = [
...history,
...newTurns.map((m: any) => ({ role: m.role, content: m.content ?? null, tool_calls: m.tool_calls, tool_call_id: m.tool_call_id } as HistoryMessage)),
]
await saveHistory(agent, updatedHistory)
const cleaned = cleanForSpeech(stripFillers(finalText))
emitVoice('response', agent, cleaned)
return NextResponse.json({ text: cleaned })
}