diff --git a/app/api/voice/chat/route.ts b/app/api/voice/chat/route.ts index ea175f0..51bf127 100644 --- a/app/api/voice/chat/route.ts +++ b/app/api/voice/chat/route.ts @@ -2,8 +2,7 @@ export const dynamic = 'force-dynamic' export const runtime = 'nodejs' import { NextResponse } from 'next/server' -import Anthropic from '@anthropic-ai/sdk' -import { ProxyAgent } from 'undici' +import Groq from 'groq-sdk' import { voiceBus } from '@/lib/voice-bus' import { systemPrompt } from '@/lib/voice-prompts' @@ -12,17 +11,15 @@ import { executeTool } from '@/lib/voice-executors' import { cleanForSpeech, stripFillers, isResetCommand } from '@/lib/voice-text' import { loadHistory, saveHistory, resetHistory, - buildMessagesWithCache, stripCacheControl, HistoryMessage, + HistoryMessage, } from '@/lib/voice-history' -const MODEL = process.env.ANTHROPIC_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) // In-memory rate-limit per IP / cookie (host один — Docker контейнер). -// Защита от случайного бесконечного цикла или утечки PIN: даже если -// auth_token утечёт, вызов /api/voice/chat будет ограничен. const rateBuckets = new Map() function rateLimit(key: string): boolean { const now = Date.now() @@ -35,7 +32,6 @@ function rateLimit(key: string): boolean { b.count++ return true } -// Гигиена: чистим старые бакеты периодически (раз в 5 минут максимум). let lastSweep = 0 function sweep() { const now = Date.now() @@ -44,16 +40,12 @@ function sweep() { for (const [k, v] of rateBuckets) if (v.resetAt <= now) rateBuckets.delete(k) } -let _client: Anthropic | null = null -function client(): Anthropic { +let _client: Groq | null = null +function client(): Groq { if (_client) return _client - const apiKey = process.env.ANTHROPIC_API_KEY - if (!apiKey) throw new Error('ANTHROPIC_API_KEY not set') - const proxy = process.env.ANTHROPIC_PROXY || process.env.HTTPS_PROXY || '' - const fetchOptions = proxy - ? ({ dispatcher: new ProxyAgent(proxy) } as any) - : undefined - _client = new Anthropic({ apiKey, fetchOptions }) + const apiKey = process.env.GROQ_API_KEY + if (!apiKey) throw new Error('GROQ_API_KEY not set') + _client = new Groq({ apiKey }) return _client } @@ -69,8 +61,6 @@ function emitVoice(event: string, agent: 'cosmo' | 'lusya', text?: string) { type AgentId = 'cosmo' | 'lusya' export async function POST(req: Request) { - // Rate-limit по auth_token (или x-voice-internal — для loopback'а от tools). - // Идентифицируем клиента: cookie auth_token > x-voice-internal > IP > 'anon'. 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') || '' @@ -85,13 +75,11 @@ export async function POST(req: Request) { 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) // защита от gigantic prompts + const userText: string = body.text.trim().slice(0, 4000) const agent: AgentId = body.agent === 'lusya' ? 'lusya' : 'cosmo' - // Echo command в орб emitVoice('command', agent, userText) - // Reset-команда — стираем историю и отвечаем шаблонно if (isResetCommand(userText)) { await resetHistory(agent) const msg = 'Начинаю новую сессию.' @@ -99,59 +87,52 @@ export async function POST(req: Request) { return NextResponse.json({ text: msg, reset: true }) } - // Загружаем историю и добавляем новый user-turn + // Загружаем историю и строим messages для Groq (OpenAI-compatible format) const history = await loadHistory(agent) - history.push({ role: 'user', content: userText }) - const systemBlocks: Anthropic.TextBlockParam[] = [ - { - type: 'text', - text: systemPrompt(agent), - cache_control: { type: 'ephemeral' }, - }, + // Системный prompt + история + новый user message + const apiMessages: any[] = [ + { role: 'system', content: systemPrompt(agent) }, + ...history, + { role: 'user', content: userText }, ] - const apiMessages: Anthropic.MessageParam[] = buildMessagesWithCache(history) as any - let finalText = '' - const initialUserIdx = history.length - 1 - // Защита от tool-cycling: запоминаем последний (name, args) — если LLM - // дважды подряд просит одно и то же, прерываем цикл. + 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.messages.create({ + const resp = await c.chat.completions.create({ model: MODEL, max_tokens: MAX_TOKENS, - system: systemBlocks, messages: apiMessages, - tools: TOOL_SCHEMAS, + 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=${usage?.input_tokens} out=${usage?.output_tokens} ` + - `cache_r=${usage?.cache_read_input_tokens || 0} cache_w=${usage?.cache_creation_input_tokens || 0}` + `stop=${choice.finish_reason} · in=${usage?.prompt_tokens} out=${usage?.completion_tokens}` ) - // Разбираем content на text + tool_use - const toolUses: Anthropic.ToolUseBlock[] = [] - for (const block of resp.content) { - if (block.type === 'text') finalText += block.text - else if (block.type === 'tool_use') toolUses.push(block) - } + // Добавляем assistant message в messages + apiMessages.push(msg) - // Сохраняем assistant turn в API messages как есть (важно для tool_use_id) - apiMessages.push({ role: 'assistant', content: resp.content as any }) + if (choice.finish_reason === 'tool_calls' && msg.tool_calls?.length) { + const toolCalls = msg.tool_calls - if (resp.stop_reason === 'tool_use' && toolUses.length) { - // Сигнатура текущего раунда — для loop-guard. - const sig = toolUses - .map((t) => `${t.name}:${JSON.stringify(t.input)}`) + // Loop-guard: сигнатура текущего раунда + const sig = toolCalls + .map((tc: any) => `${tc.function.name}:${tc.function.arguments}`) .sort() .join('|') if (sig === lastToolSig) { @@ -161,25 +142,27 @@ export async function POST(req: Request) { } lastToolSig = sig - const toolResults: Anthropic.ToolResultBlockParam[] = [] - for (const tu of toolUses) { - console.log(`[voice/chat] tool ${tu.name}(${JSON.stringify(tu.input).slice(0, 200)})`) - const result = await executeTool(tu.name, tu.input, agent) - toolResults.push({ - type: 'tool_result', - tool_use_id: tu.id, + // Выполняем все 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), }) } - apiMessages.push({ role: 'user', content: toolResults }) continue } - // end_turn / max_tokens / stop_sequence — финальный ответ готов + // Финальный ответ + finalText = msg.content || '' break } } catch (e: any) { - console.error('[voice/chat] anthropic error:', e?.message || e) + 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 }) @@ -191,15 +174,13 @@ export async function POST(req: Request) { return NextResponse.json({ text: msg }, { status: 200 }) } - // Сохраняем все turn'ы после initial user (включая tool_use / tool_result) - const newTurns = apiMessages.slice(initialUserIdx + 1) - for (const turn of newTurns) { - history.push({ - role: turn.role as 'user' | 'assistant', - content: stripCacheControl(turn.content), - } as HistoryMessage) - } - await saveHistory(agent, history) + // Сохраняем новые 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) diff --git a/lib/voice-history.ts b/lib/voice-history.ts index c480421..83a35a0 100644 --- a/lib/voice-history.ts +++ b/lib/voice-history.ts @@ -3,6 +3,8 @@ * /data — это volume контейнера (на хосте /opt/digital-home/smart-home-tablet-data/). * * Fallback: если /data не существует (локальная разработка) — пишем в /tmp/voice-history. + * + * Формат обновлён под Groq/OpenAI: role может быть 'user' | 'assistant' | 'tool'. */ import { promises as fs } from 'node:fs' import { existsSync } from 'node:fs' @@ -10,15 +12,16 @@ 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' + role: 'user' | 'assistant' | 'tool' content: any + tool_calls?: any[] + tool_call_id?: string } function todayIso(): string { @@ -60,7 +63,8 @@ export async function resetHistory(agent: string): Promise { } /** - * Убирает cache_control из блоков (для записи в историю — следующий turn пересчитает границу). + * Заглушка для обратной совместимости — убирает cache_control из блоков. + * В Groq-режиме cache_control не используется, функция — no-op. */ export function stripCacheControl(content: any): any { if (Array.isArray(content)) { @@ -76,33 +80,9 @@ export function stripCacheControl(content: any): any { } /** - * Граница prompt-кеша: всё, кроме последних N сообщений, помечаем cache_control - * на последнем блоке последнего «старого» сообщения. Даёт cache hit на каждом turn. + * Заглушка для обратной совместимости. + * В Groq-режиме prompt-кеширование не используется — просто возвращаем историю как есть. */ -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 +export function buildMessagesWithCache(history: HistoryMessage[], _cacheTailUncached = 2): HistoryMessage[] { + return history.map((m) => ({ role: m.role, content: m.content })) } diff --git a/lib/voice-tool-schemas.ts b/lib/voice-tool-schemas.ts index 5afa2f9..ce7e2c0 100644 --- a/lib/voice-tool-schemas.ts +++ b/lib/voice-tool-schemas.ts @@ -1,203 +1,248 @@ /** - * Tool schemas для Anthropic API. Порт TOOL_SCHEMAS из satellite/tools.py. - * Формат — Anthropic native tools (name + description + input_schema). + * Tool schemas для Groq API (OpenAI-compatible format). + * Было: Anthropic.Tool[] (input_schema) → Стало: Groq/OpenAI function tools (parameters). */ -import type Anthropic from '@anthropic-ai/sdk' -export const TOOL_SCHEMAS: Anthropic.Tool[] = [ +export interface GroqTool { + type: 'function' + function: { + name: string + description: string + parameters: { + type: string + properties: Record + required?: string[] + } + } +} + +export const TOOL_SCHEMAS: GroqTool[] = [ { - name: 'get_weather', - description: - 'Получить текущую погоду и короткий прогноз для города. ' + - 'Для вопросов вроде «какая сегодня погода», «холодно ли на улице», «нужен ли зонт». ' + - 'По умолчанию — Санкт-Петербург.', - input_schema: { - type: 'object', - properties: { - city: { - type: 'string', - description: - 'Город на русском или шорткод (spb, msk, sochi, ekb, kzn, nsk, krd). ' + - 'По умолчанию Санкт-Петербург.', + type: 'function', + function: { + name: 'get_weather', + description: + 'Получить текущую погоду и короткий прогноз для города. ' + + 'Для вопросов вроде «какая сегодня погода», «холодно ли на улице», «нужен ли зонт». ' + + 'По умолчанию — Санкт-Петербург.', + parameters: { + type: 'object', + properties: { + city: { + type: 'string', + description: + 'Город на русском или шорткод (spb, msk, sochi, ekb, kzn, nsk, krd). ' + + 'По умолчанию Санкт-Петербург.', + }, }, }, }, }, { - name: 'get_transport', - description: - 'Расписание ближайших трамваев на остановке Ул. Антонова-Овсеенко. ' + - 'Для вопросов «когда следующий 23-й», «что ближайшее в центр», «пора идти на остановку».', - input_schema: { - type: 'object', - properties: { - direction: { - type: 'string', - enum: ['to_center', 'from_center', 'all'], - description: - 'to_center = в центр (к Новочеркасской), ' + - 'from_center = от центра (к Большевиков), all = оба направления', - }, - routes: { - type: 'string', - description: - 'Фильтр маршрутов через запятую, например «23» или «23,27». Пусто = все маршруты.', + type: 'function', + function: { + name: 'get_transport', + description: + 'Расписание ближайших трамваев на остановке Ул. Антонова-Овсеенко. ' + + 'Для вопросов «когда следующий 23-й», «что ближайшее в центр», «пора идти на остановку».', + parameters: { + type: 'object', + properties: { + direction: { + type: 'string', + enum: ['to_center', 'from_center', 'all'], + description: + 'to_center = в центр (к Новочеркасской), ' + + 'from_center = от центра (к Большевиков), all = оба направления', + }, + routes: { + type: 'string', + description: + 'Фильтр маршрутов через запятую, например «23» или «23,27». Пусто = все маршруты.', + }, }, }, }, }, { - name: 'get_today_events', - description: - 'События из календаря (Даниил + Света). Вернёт id события, title, start, end, ' + - 'owner («daniil» или «sveta»). ВАЖНО: для update_event / delete_event сначала ' + - 'вызывай этот tool чтобы получить event_id.', - input_schema: { - type: 'object', - properties: { - range: { - type: 'string', - enum: ['today', 'week', 'month'], - description: 'today (по умолчанию), week (7 дней) или month (текущий месяц)', + type: 'function', + function: { + name: 'get_today_events', + description: + 'События из календаря (Даниил + Света). Вернёт id события, title, start, end, ' + + 'owner («daniil» или «sveta»). ВАЖНО: для update_event / delete_event сначала ' + + 'вызывай этот tool чтобы получить event_id.', + parameters: { + type: 'object', + properties: { + range: { + type: 'string', + enum: ['today', 'week', 'month'], + description: 'today (по умолчанию), week (7 дней) или month (текущий месяц)', + }, }, }, }, }, { - name: 'create_event', - description: - 'Создать событие в Google Calendar. ВАЖНО: параметр owner обязателен. ' + - 'Если пользователь не сказал чей это календарь — СПРОСИ у него ' + - '(«в твой календарь или в Светин?») и только потом вызывай tool. Не угадывай.', - input_schema: { - type: 'object', - properties: { - title: { type: 'string', description: 'Название события' }, - date: { type: 'string', description: 'Дата в формате YYYY-MM-DD' }, - start_time: { - type: 'string', - description: 'Время начала в формате HH:MM (24-часовой). Обязательно если all_day=false.', - }, - end_time: { - type: 'string', - description: 'Время окончания в формате HH:MM. По умолчанию start_time + 1 час.', - }, - all_day: { - type: 'boolean', - description: 'Событие на весь день без времени. По умолчанию false.', - }, - owner: { - type: 'string', - enum: ['daniil', 'sveta'], - description: 'Чей это календарь — Даниила или Светы', + type: 'function', + function: { + name: 'create_event', + description: + 'Создать событие в Google Calendar. ВАЖНО: параметр owner обязателен. ' + + 'Если пользователь не сказал чей это календарь — СПРОСИ у него ' + + '(«в твой календарь или в Светин?») и только потом вызывай tool. Не угадывай.', + parameters: { + type: 'object', + properties: { + title: { type: 'string', description: 'Название события' }, + date: { type: 'string', description: 'Дата в формате YYYY-MM-DD' }, + start_time: { + type: 'string', + description: 'Время начала в формате HH:MM (24-часовой). Обязательно если all_day=false.', + }, + end_time: { + type: 'string', + description: 'Время окончания в формате HH:MM. По умолчанию start_time + 1 час.', + }, + all_day: { + type: 'boolean', + description: 'Событие на весь день без времени. По умолчанию false.', + }, + owner: { + type: 'string', + enum: ['daniil', 'sveta'], + description: 'Чей это календарь — Даниила или Светы', + }, }, + required: ['title', 'date', 'owner'], }, - required: ['title', 'date', 'owner'], }, }, { - name: 'update_event', - description: - 'Изменить существующее событие. Сначала обязательно вызови get_today_events ' + - 'чтобы получить event_id и owner нужного события. Передавай только те поля ' + - 'которые меняешь.', - input_schema: { - type: 'object', - properties: { - event_id: { type: 'string' }, - owner: { - type: 'string', - enum: ['daniil', 'sveta'], - description: 'Чей календарь (из get_today_events)', + type: 'function', + function: { + name: 'update_event', + description: + 'Изменить существующее событие. Сначала обязательно вызови get_today_events ' + + 'чтобы получить event_id и owner нужного события. Передавай только те поля ' + + 'которые меняешь.', + parameters: { + type: 'object', + properties: { + event_id: { type: 'string' }, + owner: { + type: 'string', + enum: ['daniil', 'sveta'], + description: 'Чей календарь (из get_today_events)', + }, + title: { type: 'string' }, + date: { type: 'string', description: 'YYYY-MM-DD' }, + start_time: { type: 'string', description: 'HH:MM' }, + end_time: { type: 'string', description: 'HH:MM' }, + all_day: { type: 'boolean' }, }, - title: { type: 'string' }, - date: { type: 'string', description: 'YYYY-MM-DD' }, - start_time: { type: 'string', description: 'HH:MM' }, - end_time: { type: 'string', description: 'HH:MM' }, - all_day: { type: 'boolean' }, + required: ['event_id', 'owner'], }, - required: ['event_id', 'owner'], }, }, { - name: 'delete_event', - description: - 'Удалить событие из календаря. Сначала вызови get_today_events чтобы найти ' + - 'event_id и определить owner. Подтверди удаление с пользователем если событие ' + - 'важное (встреча, врач, работа).', - input_schema: { - type: 'object', - properties: { - event_id: { type: 'string' }, - owner: { type: 'string', enum: ['daniil', 'sveta'] }, + type: 'function', + function: { + name: 'delete_event', + description: + 'Удалить событие из календаря. Сначала вызови get_today_events чтобы найти ' + + 'event_id и определить owner. Подтверди удаление с пользователем если событие ' + + 'важное (встреча, врач, работа).', + parameters: { + type: 'object', + properties: { + event_id: { type: 'string' }, + owner: { type: 'string', enum: ['daniil', 'sveta'] }, + }, + required: ['event_id', 'owner'], }, - required: ['event_id', 'owner'], }, }, { - name: 'get_notes', - description: - 'Список заметок и списков покупок с планшета. Для «что мне купить», ' + - '«что в списке», «какие записи».', - input_schema: { type: 'object', properties: {} }, - }, - { - name: 'set_timer', - description: - 'Запустить таймер на планшете. Показывает обратный отсчёт с названием и звенит ' + - 'по окончании. Используй для «поставь таймер на 10 минут», «напомни через час», ' + - '«засеки 5 минут для чайника».', - input_schema: { - type: 'object', - properties: { - seconds: { - type: 'integer', - description: 'Длительность в секундах (1..86400)', - minimum: 1, - maximum: 86400, - }, - label: { - type: 'string', - description: 'Короткое название таймера (например «Чайник», «Паста»)', - }, + type: 'function', + function: { + name: 'get_notes', + description: + 'Список заметок и списков покупок с планшета. Для «что мне купить», ' + + '«что в списке», «какие записи».', + parameters: { + type: 'object', + properties: {}, }, - required: ['seconds', 'label'], }, }, { - name: 'cancel_timer', - description: - 'Отменить активный таймер по его названию. Для «отмени таймер чайник», ' + - '«убери таймер пасты», «останови отсчёт».', - input_schema: { - type: 'object', - properties: { - label: { - type: 'string', - description: 'Название таймера (примерное совпадение — можно частично).', + type: 'function', + function: { + name: 'set_timer', + description: + 'Запустить таймер на планшете. Показывает обратный отсчёт с названием и звенит ' + + 'по окончании. Используй для «поставь таймер на 10 минут», «напомни через час», ' + + '«засеки 5 минут для чайника».', + parameters: { + type: 'object', + properties: { + seconds: { + type: 'integer', + description: 'Длительность в секундах (1..86400)', + minimum: 1, + maximum: 86400, + }, + label: { + type: 'string', + description: 'Короткое название таймера (например «Чайник», «Паста»)', + }, }, + required: ['seconds', 'label'], }, - required: ['label'], }, }, { - name: 'adjust_timer', - description: - 'Изменить оставшееся время таймера. Для «добавь ещё 5 минут», «убавь на минуту», ' + - '«накинь времени чайнику». Положительный delta_seconds = добавить, отрицательный = уменьшить.', - input_schema: { - type: 'object', - properties: { - label: { - type: 'string', - description: 'Название таймера для которого меняем время.', - }, - delta_seconds: { - type: 'integer', - description: 'Секунды (+ добавить, - уменьшить). Например 300 = +5 минут, -60 = -1 минута.', + type: 'function', + function: { + name: 'cancel_timer', + description: + 'Отменить активный таймер по его названию. Для «отмени таймер чайник», ' + + '«убери таймер пасты», «останови отсчёт».', + parameters: { + type: 'object', + properties: { + label: { + type: 'string', + description: 'Название таймера (примерное совпадение — можно частично).', + }, }, + required: ['label'], + }, + }, + }, + { + type: 'function', + function: { + name: 'adjust_timer', + description: + 'Изменить оставшееся время таймера. Для «добавь ещё 5 минут», «убавь на минуту», ' + + '«накинь времени чайнику». Положительный delta_seconds = добавить, отрицательный = уменьшить.', + parameters: { + type: 'object', + properties: { + label: { + type: 'string', + description: 'Название таймера для которого меняем время.', + }, + delta_seconds: { + type: 'integer', + description: 'Секунды (+ добавить, - уменьшить). Например 300 = +5 минут, -60 = -1 минута.', + }, + }, + required: ['label', 'delta_seconds'], }, - required: ['label', 'delta_seconds'], }, }, ]