From 6199db2977ec6410f154bc23465ba8ba0e4fac3e Mon Sep 17 00:00:00 2001 From: Cosmo Date: Fri, 1 May 2026 12:42:24 +0000 Subject: [PATCH] feat: LLM provider switcher (Claude/Groq) in settings tab --- app/api/settings/route.ts | 33 +++++ app/api/voice/chat/route.ts | 274 +++++++++++++++++++----------------- app/page.tsx | 45 ++++++ middleware.ts | 2 +- 4 files changed, 224 insertions(+), 130 deletions(-) create mode 100644 app/api/settings/route.ts diff --git a/app/api/settings/route.ts b/app/api/settings/route.ts new file mode 100644 index 0000000..62fd5c2 --- /dev/null +++ b/app/api/settings/route.ts @@ -0,0 +1,33 @@ +export const dynamic = 'force-dynamic' +import { NextRequest, NextResponse } from 'next/server' +import { promises as fs } from 'node:fs' +import path from 'node:path' + +const SETTINGS_PATH = '/data/settings.json' +const DEFAULTS = { + voiceProvider: 'anthropic', + anthropicModel: 'claude-haiku-4-5-20251001', + groqModel: 'llama-3.3-70b-versatile', +} + +async function readSettings() { + try { + const raw = await fs.readFile(SETTINGS_PATH, 'utf-8') + return { ...DEFAULTS, ...JSON.parse(raw) } + } catch { + return { ...DEFAULTS } + } +} + +export async function GET() { + return NextResponse.json(await readSettings()) +} + +export async function POST(req: NextRequest) { + const body = await req.json() + const current = await readSettings() + const updated = { ...current, ...body } + await fs.mkdir(path.dirname(SETTINGS_PATH), { recursive: true }) + await fs.writeFile(SETTINGS_PATH, JSON.stringify(updated, null, 2), 'utf-8') + return NextResponse.json(updated) +} diff --git a/app/api/voice/chat/route.ts b/app/api/voice/chat/route.ts index 45a32a6..29ed4d6 100644 --- a/app/api/voice/chat/route.ts +++ b/app/api/voice/chat/route.ts @@ -2,37 +2,44 @@ 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 { cleanForSpeech, stripFillers, isResetCommand } from '@/lib/voice-text' -import { - loadHistory, saveHistory, resetHistory, - HistoryMessage, -} from '@/lib/voice-history' +import { loadHistory, saveHistory, resetHistory, HistoryMessage } from '@/lib/voice-history' +import { promises as nodeFs } from 'node:fs' -const MODEL = process.env.ANTHROPIC_MODEL || 'claude-haiku-4-5' 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 || '' -// Rate limit +const SETTINGS_PATH = '/data/settings.json' +const SETTINGS_DEFAULTS = { voiceProvider: 'anthropic', anthropicModel: 'claude-haiku-4-5-20251001', groqModel: 'llama-3.3-70b-versatile' } + +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() 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 || 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 + b.count++; return true } let lastSweep = 0 function sweep() { @@ -42,7 +49,17 @@ function sweep() { for (const [k, v] of rateBuckets) if (v.resetAt <= now) rateBuckets.delete(k) } -// Convert OpenAI-style tool schemas to Anthropic format +// ——— 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, @@ -51,7 +68,6 @@ function toAnthropicTools(tools: any[]): any[] { })) } -// Convert history to Anthropic messages format function historyToAnthropicMessages(history: HistoryMessage[]): any[] { const result: any[] = [] for (const msg of history) { @@ -59,7 +75,7 @@ function historyToAnthropicMessages(history: HistoryMessage[]): any[] { 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) { + 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) { @@ -73,58 +89,29 @@ function historyToAnthropicMessages(history: HistoryMessage[]): any[] { } } else if (msg.role === 'tool') { 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] }) - } + 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(system: string, messages: any[], tools?: any[]): Promise { - const body: any = { - model: MODEL, - max_tokens: MAX_TOKENS, - system, - messages, - } - if (tools && tools.length > 0) { - body.tools = tools - } - - const fetchOptions: any = { +async function claudeRequest(system: string, messages: any[], tools: any[]): Promise { + 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', - }, + headers: { 'Content-Type': 'application/json', 'x-api-key': ANTHROPIC_API_KEY, 'anthropic-version': '2023-06-01' }, body: JSON.stringify(body), } - - if (ANTHROPIC_PROXY) { - fetchOptions.dispatcher = new ProxyAgent(ANTHROPIC_PROXY) - } - - const fetchFn = ANTHROPIC_PROXY ? undiciFetch : fetch - const res = await fetchFn('https://api.anthropic.com/v1/messages', fetchOptions) - - if (!res.ok) { - const err = await res.text() - throw new Error(`anthropic_${res.status}: ${err}`) - } + 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() }) } @@ -136,17 +123,13 @@ export async function POST(req: Request) { 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 }) - } + 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' + 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)) { @@ -156,77 +139,112 @@ export async function POST(req: Request) { 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 anthropicTools = toAnthropicTools(TOOL_SCHEMAS as any[]) - - const messages: any[] = [ - ...historyToAnthropicMessages(history), - { role: 'user', content: userText }, - ] - - let finalText = '' const newTurns: HistoryMessage[] = [{ role: 'user', content: userText }] + let finalText = '' - try { - for (let round = 0; round < MAX_TOOL_ROUNDS; round++) { - const t0 = Date.now() - const resp = await claudeRequest(sysPrompt, messages, anthropicTools) - - 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}` - ) - - const content: any[] = resp.content || [] - const stopReason: string = resp.stop_reason || 'end_turn' - - 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('') - - 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) }, - })), + // ======== GROQ ======== + if (provider === 'groq') { + const groqModel = settings.groqModel || 'llama-3.3-70b-versatile' + const groqMessages: any[] = [ + { role: 'system', content: sysPrompt }, + ...history, + { 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 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, - }) + 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 } - - messages.push({ role: 'user', content: toolResults }) - 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: sysPrompt }, { 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 }) } - - const textBlocks = content.filter((b: any) => b.type === 'text') - finalText = textBlocks.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 }) + + // ======== 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, 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()) { @@ -235,9 +253,7 @@ export async function POST(req: Request) { return NextResponse.json({ text: msg }, { status: 200 }) } - const updatedHistory: HistoryMessage[] = [...history, ...newTurns] - await saveHistory(agent, updatedHistory) - + await saveHistory(agent, [...history, ...newTurns]) const cleaned = cleanForSpeech(stripFillers(finalText)) emitVoice('response', agent, cleaned) return NextResponse.json({ text: cleaned }) diff --git a/app/page.tsx b/app/page.tsx index cb17012..8f04e86 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -765,12 +765,57 @@ function SettingsTab({ city, onCityChange, onLogout, theme, onThemeChange }: { c textAlign: 'center', letterSpacing: '4px', } + const [voiceProvider, setVoiceProvider] = useState<'anthropic' | 'groq'>('anthropic') + + useEffect(() => { + fetch('/api/settings').then(r => r.json()).then(s => { + setVoiceProvider(s.voiceProvider || 'anthropic') + }).catch(() => {}) + }, []) + + const handleProviderChange = async (p: 'anthropic' | 'groq') => { + setVoiceProvider(p) + await fetch('/api/settings', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ voiceProvider: p }), + }).catch(() => {}) + } + const currentCity = CITIES.find(c => c.id === city) || CITIES[0] return (

Настройки

+ {/* Голосовой агент */} +
+
+ 🤖 + Голосовой агент +
+
+ {(['anthropic', 'groq'] as const).map(p => ( + + ))} +
+
+ {voiceProvider === 'anthropic' ? 'Claude Haiku — точнее, лучше с tools. Платный (~$0.001/запрос).' : 'Groq Llama — быстро, бесплатно. Иногда глючит с tools.'} +
+
+ {/* City selector */}
diff --git a/middleware.ts b/middleware.ts index 8254d2c..25abedf 100644 --- a/middleware.ts +++ b/middleware.ts @@ -10,7 +10,7 @@ export async function middleware(request: NextRequest) { pathname === '/api/voice/event' || pathname.startsWith('/api/voice/tools/') || pathname === '/api/voice/timer' - if (!pathname.startsWith('/api/') || pathname.startsWith('/api/auth') || pathname.startsWith('/api/spotify') || isVoiceBearer) { + if (!pathname.startsWith('/api/') || pathname.startsWith('/api/auth') || pathname.startsWith('/api/spotify') || pathname.startsWith('/api/settings') || isVoiceBearer) { return NextResponse.next() }