revert: voice/chat back to Groq (ai-proxy not headless-compatible)
All checks were successful
Deploy / deploy (push) Successful in 1m46s

This commit is contained in:
Cosmo
2026-05-01 12:08:16 +00:00
parent ea096a855b
commit a8a6de1246

View File

@@ -2,6 +2,8 @@ export const dynamic = 'force-dynamic'
export const runtime = 'nodejs' export const runtime = 'nodejs'
import { NextResponse } from 'next/server' import { NextResponse } from 'next/server'
import Groq from 'groq-sdk'
import { HttpsProxyAgent } from 'https-proxy-agent'
import { voiceBus } from '@/lib/voice-bus' import { voiceBus } from '@/lib/voice-bus'
import { systemPrompt } from '@/lib/voice-prompts' import { systemPrompt } from '@/lib/voice-prompts'
@@ -12,15 +14,12 @@ import {
HistoryMessage, HistoryMessage,
} from '@/lib/voice-history' } from '@/lib/voice-history'
const MODEL = process.env.CLAUDE_MODEL || 'claude-haiku-4-5' const MODEL = process.env.GROQ_MODEL || 'llama-3.3-70b-versatile'
const MAX_TOKENS = parseInt(process.env.VOICE_MAX_TOKENS || '300', 10) const MAX_TOKENS = parseInt(process.env.VOICE_MAX_TOKENS || '300', 10)
const MAX_TOOL_ROUNDS = 4 const MAX_TOOL_ROUNDS = 4
const RATE_LIMIT_PER_MINUTE = parseInt(process.env.VOICE_RATE_LIMIT || '20', 10) const RATE_LIMIT_PER_MINUTE = parseInt(process.env.VOICE_RATE_LIMIT || '20', 10)
const AI_PROXY_URL = process.env.AI_PROXY_URL || 'http://192.168.31.103:3301' // In-memory rate-limit per IP / cookie (host один — Docker контейнер).
const AI_PROXY_KEY = process.env.AI_PROXY_KEY || 'review-bot-proxy-d3ff719d7c87e529909c09fadcbf2748'
// Rate limit
const rateBuckets = new Map<string, { count: number; resetAt: number }>() const rateBuckets = new Map<string, { count: number; resetAt: number }>()
function rateLimit(key: string): boolean { function rateLimit(key: string): boolean {
const now = Date.now() const now = Date.now()
@@ -41,92 +40,28 @@ function sweep() {
for (const [k, v] of rateBuckets) if (v.resetAt <= now) rateBuckets.delete(k) for (const [k, v] of rateBuckets) if (v.resetAt <= now) rateBuckets.delete(k)
} }
// Convert OpenAI-style tool schemas to Anthropic format let _client: Groq | null = null
function toAnthropicTools(tools: any[]): any[] { function client(): Groq {
return tools.map(t => ({ if (_client) return _client
name: t.function.name, const apiKey = process.env.GROQ_API_KEY
description: t.function.description || '', if (!apiKey) throw new Error('GROQ_API_KEY not set')
input_schema: t.function.parameters || { type: 'object', properties: {} }, const proxyUrl = process.env.GROQ_PROXY
})) const httpAgent = proxyUrl ? new HttpsProxyAgent(proxyUrl) : undefined
_client = new Groq({ apiKey, httpAgent } as any)
return _client
} }
// Convert history (OpenAI format) to Anthropic messages format function emitVoice(event: string, agent: 'cosmo' | 'lusya', text?: string) {
// History may contain tool calls — need to convert
function historyToAnthropicMessages(history: HistoryMessage[]): any[] {
const result: any[] = []
for (const msg of history) {
if ((msg.role as string) === 'system') continue // skip, goes in system field
if (msg.role === 'user') {
result.push({ role: 'user', content: msg.content || '' })
} else if (msg.role === 'assistant') {
if (msg.tool_calls && msg.tool_calls.length > 0) {
// Assistant with tool calls
const content: any[] = []
if (msg.content) content.push({ type: 'text', text: msg.content })
for (const tc of msg.tool_calls) {
let input: any = {}
try { input = JSON.parse(tc.function.arguments) } 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') {
// Tool result goes as user message
const last = result[result.length - 1]
const toolResultBlock = {
type: 'tool_result',
tool_use_id: msg.tool_call_id,
content: msg.content || '',
}
if (last && last.role === 'user' && Array.isArray(last.content)) {
last.content.push(toolResultBlock)
} else {
result.push({ role: 'user', content: [toolResultBlock] })
}
}
}
return result
}
async function claudeRequest(model: string, system: string, messages: any[], tools?: any[]): Promise<any> {
const body: any = {
model,
max_tokens: MAX_TOKENS,
system,
messages,
}
if (tools && tools.length > 0) {
body.tools = tools
}
const res = await fetch(`${AI_PROXY_URL}/v1/messages`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Proxy-Key': AI_PROXY_KEY,
'anthropic-version': '2023-06-01',
},
body: JSON.stringify(body),
})
if (!res.ok) {
const err = await res.text()
throw new Error(`claude_proxy_${res.status}: ${err}`)
}
return res.json()
}
type AgentId = 'cosmo' | 'lusya'
function emitVoice(event: string, agent: AgentId, text?: string) {
voiceBus.emit('voice', { voiceBus.emit('voice', {
event, agent, text, event,
agent,
text,
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
}) })
} }
type AgentId = 'cosmo' | 'lusya'
export async function POST(req: Request) { export async function POST(req: Request) {
const cookie = req.headers.get('cookie') || '' const cookie = req.headers.get('cookie') || ''
const tokenMatch = cookie.match(/auth_token=([a-f0-9]{32,})/i) const tokenMatch = cookie.match(/auth_token=([a-f0-9]{32,})/i)
@@ -154,84 +89,104 @@ export async function POST(req: Request) {
return NextResponse.json({ text: msg, reset: true }) return NextResponse.json({ text: msg, reset: true })
} }
// Загружаем историю и строим messages для Groq (OpenAI-compatible format)
const history = await loadHistory(agent) const history = await loadHistory(agent)
const sysPrompt = systemPrompt(agent)
const anthropicTools = toAnthropicTools(TOOL_SCHEMAS as any[])
// Build messages array // Системный prompt + история + новый user message
const messages: any[] = [ const apiMessages: any[] = [
...historyToAnthropicMessages(history), { role: 'system', content: systemPrompt(agent) },
...history,
{ role: 'user', content: userText }, { role: 'user', content: userText },
] ]
let finalText = '' let finalText = ''
// Track new turns for history saving const historyStartLen = apiMessages.length // позиция после которой добавляем новые turns
const newTurns: HistoryMessage[] = [{ role: 'user', content: userText }]
// Защита от tool-cycling
let lastToolSig = ''
try { try {
const c = client()
for (let round = 0; round < MAX_TOOL_ROUNDS; round++) { for (let round = 0; round < MAX_TOOL_ROUNDS; round++) {
const t0 = Date.now() const t0 = Date.now()
const resp = await claudeRequest(MODEL, sysPrompt, messages, anthropicTools) 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( console.log(
`[voice/chat] ${agent} round ${round + 1} ${Date.now() - t0}ms · ` + `[voice/chat] ${agent} round ${round + 1} ${Date.now() - t0}ms · ` +
`stop=${resp.stop_reason} · in=${resp.usage?.input_tokens} out=${resp.usage?.output_tokens}` `stop=${choice.finish_reason} · in=${usage?.prompt_tokens} out=${usage?.completion_tokens}`
) )
const content: any[] = resp.content || [] // Добавляем assistant message в messages
const stopReason: string = resp.stop_reason || 'end_turn' apiMessages.push(msg)
if (stopReason === 'tool_use') { if (choice.finish_reason === 'tool_calls' && msg.tool_calls?.length) {
const toolUseBlocks = content.filter((b: any) => b.type === 'tool_use') const toolCalls = msg.tool_calls
const textBlocks = content.filter((b: any) => b.type === 'text')
const partialText = textBlocks.map((b: any) => b.text).join('')
// Add assistant message to messages // Loop-guard: сигнатура текущего раунда
messages.push({ role: 'assistant', content }) const sig = toolCalls
newTurns.push({ .map((tc: any) => `${tc.function.name}:${tc.function.arguments}`)
role: 'assistant', .sort()
content: partialText || null, .join('|')
tool_calls: toolUseBlocks.map((b: any) => ({ if (sig === lastToolSig) {
id: b.id, console.warn('[voice/chat] tool cycle detected, breaking loop')
function: { name: b.name, arguments: JSON.stringify(b.input) }, finalText += '\nНе получилось выполнить запрос.'
})), break
}) }
lastToolSig = sig
// Execute tools and collect results // Выполняем все tool calls и добавляем результаты
const toolResults: any[] = [] for (const tc of toolCalls) {
for (const tb of toolUseBlocks) { console.log(`[voice/chat] tool ${tc.function.name}(${tc.function.arguments.slice(0, 200)})`)
console.log(`[voice/chat] tool ${tb.name}(${JSON.stringify(tb.input).slice(0, 200)})`) let args: any = {}
const result = await executeTool(tb.name, tb.input || {}, agent) try { args = JSON.parse(tc.function.arguments) } catch (_) {}
toolResults.push({ const result = await executeTool(tc.function.name, args, agent)
type: 'tool_result', apiMessages.push({
tool_use_id: tb.id,
content: JSON.stringify(result),
})
newTurns.push({
role: 'tool', role: 'tool',
tool_call_id: tc.id,
content: JSON.stringify(result), content: JSON.stringify(result),
tool_call_id: tb.id,
}) })
} }
// Add tool results as user message
messages.push({ role: 'user', content: toolResults })
continue continue
} }
// end_turn — final response // Финальный ответ
const textBlocks = content.filter((b: any) => b.type === 'text') finalText = msg.content || ''
finalText = textBlocks.map((b: any) => b.text).join('')
newTurns.push({ role: 'assistant', content: finalText })
break break
} }
} catch (e: any) { } catch (e: any) {
const errStr = String(e?.message || e) const errStr = String(e?.message || e)
console.error('[voice/chat] claude error:', errStr) console.error('[voice/chat] groq error:', errStr)
const msg = 'Что-то сломалось.'
emitVoice('error', agent, msg) // tool_use_failed: модель неправильно сформировала tool call — повторить без tools
return NextResponse.json({ error: 'llm_failed', detail: errStr, text: msg }, { status: 502 }) if (errStr.includes('tool_use_failed') || errStr.includes('Failed to call a function')) {
try {
const c2 = client()
const fallback = await c2.chat.completions.create({
model: MODEL,
max_tokens: MAX_TOKENS,
messages: apiMessages.slice(0, historyStartLen + 1),
})
finalText = fallback.choices[0]?.message?.content || ''
console.log('[voice/chat] tool_use_failed fallback ok')
} catch (e2) {
console.error('[voice/chat] fallback failed:', e2)
finalText = 'Не удалось выполнить запрос.'
}
} else {
const msg = 'Что-то сломалось.'
emitVoice('error', agent, msg)
return NextResponse.json({ error: 'llm_failed', detail: errStr, text: msg }, { status: 502 })
}
} }
if (!finalText.trim()) { if (!finalText.trim()) {
@@ -240,25 +195,15 @@ export async function POST(req: Request) {
return NextResponse.json({ text: msg }, { status: 200 }) return NextResponse.json({ text: msg }, { status: 200 })
} }
// Save history // Сохраняем новые turns в историю (без системного prompt'а)
const updatedHistory: HistoryMessage[] = [...history, ...newTurns] 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) await saveHistory(agent, updatedHistory)
// Filter any tool call artifacts from text const cleaned = cleanForSpeech(stripFillers(finalText))
const filteredText = finalText
.split('\n')
.filter(line => {
const l = line.trim()
return !(
/^(get_|set_|control_|create_|update_|delete_|cancel_)[a-z_]+\s/.test(l) ||
l.startsWith('<function') ||
l.startsWith('function=')
)
})
.join('\n')
.trim()
const cleaned = cleanForSpeech(stripFillers(filteredText || finalText))
emitVoice('response', agent, cleaned) emitVoice('response', agent, cleaned)
return NextResponse.json({ text: cleaned }) return NextResponse.json({ text: cleaned })
} }