diff --git a/app/api/voice/chat/route.ts b/app/api/voice/chat/route.ts index 35942d1..9be54b1 100644 --- a/app/api/voice/chat/route.ts +++ b/app/api/voice/chat/route.ts @@ -2,6 +2,8 @@ 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 { voiceBus } from '@/lib/voice-bus' import { systemPrompt } from '@/lib/voice-prompts' @@ -12,15 +14,12 @@ import { HistoryMessage, } 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_TOOL_ROUNDS = 4 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' -const AI_PROXY_KEY = process.env.AI_PROXY_KEY || 'review-bot-proxy-d3ff719d7c87e529909c09fadcbf2748' - -// Rate limit +// In-memory rate-limit per IP / cookie (host один — Docker контейнер). const rateBuckets = new Map() function rateLimit(key: string): boolean { const now = Date.now() @@ -41,92 +40,28 @@ function sweep() { for (const [k, v] of rateBuckets) if (v.resetAt <= now) rateBuckets.delete(k) } -// Convert OpenAI-style tool schemas to Anthropic format -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: {} }, - })) +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') + 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 -// 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 { - 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) { +function emitVoice(event: string, agent: 'cosmo' | 'lusya', text?: string) { voiceBus.emit('voice', { - event, agent, text, + 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) @@ -154,84 +89,104 @@ export async function POST(req: Request) { return NextResponse.json({ text: msg, reset: true }) } + // Загружаем историю и строим messages для Groq (OpenAI-compatible format) const history = await loadHistory(agent) - const sysPrompt = systemPrompt(agent) - const anthropicTools = toAnthropicTools(TOOL_SCHEMAS as any[]) - // Build messages array - const messages: any[] = [ - ...historyToAnthropicMessages(history), + // Системный prompt + история + новый user message + const apiMessages: any[] = [ + { role: 'system', content: systemPrompt(agent) }, + ...history, { role: 'user', content: userText }, ] let finalText = '' - // Track new turns for history saving - const newTurns: HistoryMessage[] = [{ role: 'user', content: userText }] + 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 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( `[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 || [] - const stopReason: string = resp.stop_reason || 'end_turn' + // Добавляем assistant message в messages + apiMessages.push(msg) - if (stopReason === 'tool_use') { - const toolUseBlocks = content.filter((b: any) => b.type === 'tool_use') - const textBlocks = content.filter((b: any) => b.type === 'text') - const partialText = textBlocks.map((b: any) => b.text).join('') + if (choice.finish_reason === 'tool_calls' && msg.tool_calls?.length) { + const toolCalls = msg.tool_calls - // Add assistant message to messages - 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) }, - })), - }) + // 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 - // Execute tools and collect results - 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({ + // Выполняем все 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), - tool_call_id: tb.id, }) } - - // Add tool results as user message - messages.push({ role: 'user', content: toolResults }) continue } - // end_turn — final response - const textBlocks = content.filter((b: any) => b.type === 'text') - finalText = textBlocks.map((b: any) => b.text).join('') - - newTurns.push({ role: 'assistant', content: finalText }) + // Финальный ответ + finalText = msg.content || '' 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 }) + console.error('[voice/chat] groq error:', errStr) + + // tool_use_failed: модель неправильно сформировала tool call — повторить без tools + 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()) { @@ -240,25 +195,15 @@ export async function POST(req: Request) { return NextResponse.json({ text: msg }, { status: 200 }) } - // Save history - const updatedHistory: HistoryMessage[] = [...history, ...newTurns] + // Сохраняем новые 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) - // Filter any tool call artifacts from text - 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('