feat: LLM provider switcher (Claude/Groq) in settings tab
All checks were successful
Deploy / deploy (push) Successful in 1m27s

This commit is contained in:
Cosmo
2026-05-01 12:42:24 +00:00
parent f8c842b474
commit 6199db2977
4 changed files with 224 additions and 130 deletions

33
app/api/settings/route.ts Normal file
View File

@@ -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)
}

View File

@@ -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<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 || 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<any> {
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<any> {
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,68 +139,102 @@ 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 newTurns: HistoryMessage[] = [{ role: 'user', content: userText }]
let finalText = ''
const messages: any[] = [
...historyToAnthropicMessages(history),
// ======== 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 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
}
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 })
}
}
let finalText = ''
const newTurns: HistoryMessage[] = [{ role: 'user', content: userText }]
// ======== 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] ${agent} round ${round + 1} ${Date.now() - t0}ms · ` +
`stop=${resp.stop_reason} · in=${resp.usage?.input_tokens} out=${resp.usage?.output_tokens}`
)
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: string = resp.stop_reason || 'end_turn'
const stopReason = 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('')
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) },
})),
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)})`)
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,
})
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
}
const textBlocks = content.filter((b: any) => b.type === 'text')
finalText = textBlocks.map((b: any) => b.text).join('')
finalText = content.filter((b: any) => b.type === 'text').map((b: any) => b.text).join('')
newTurns.push({ role: 'assistant', content: finalText })
break
}
@@ -228,6 +245,7 @@ export async function POST(req: Request) {
emitVoice('error', agent, msg)
return NextResponse.json({ error: 'llm_failed', detail: errStr, text: msg }, { status: 502 })
}
}
if (!finalText.trim()) {
const msg = 'Не получил ответ.'
@@ -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 })

View File

@@ -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 (
<div style={{ flex: 1, overflowY: 'auto', WebkitOverflowScrolling: 'touch' as any, touchAction: 'pan-y', padding: '24px', display: 'flex', flexDirection: 'column', gap: 16, maxWidth: 560, margin: '0 auto', width: '100%' }}>
<h2 style={{ fontSize: 24, fontWeight: 800, color: 'var(--text-primary)', margin: '0 0 8px', letterSpacing: '-0.5px' }}>Настройки</h2>
{/* Голосовой агент */}
<div style={{ background: 'var(--card-bg)', border: '1px solid var(--card-border)', borderRadius: 22, padding: '22px 24px' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 16 }}>
<span style={{ fontSize: 18 }}>🤖</span>
<span style={{ fontSize: 15, fontWeight: 600, color: 'var(--text-primary)' }}>Голосовой агент</span>
</div>
<div style={{ display: 'flex', gap: 10 }}>
{(['anthropic', 'groq'] as const).map(p => (
<button
key={p}
onClick={() => handleProviderChange(p)}
style={{
flex: 1, padding: '12px 0', borderRadius: 14,
background: voiceProvider === p ? 'rgba(99,102,241,0.15)' : 'rgba(255,255,255,0.03)',
border: `1px solid ${voiceProvider === p ? 'rgba(99,102,241,0.4)' : 'rgba(255,255,255,0.07)'}`,
color: voiceProvider === p ? '#a5b4fc' : 'var(--text-secondary)',
fontSize: 13, fontWeight: 600, cursor: 'pointer', transition: 'all 0.2s ease',
}}
>
{p === 'anthropic' ? '🧠 Claude Haiku' : '⚡ Groq Llama'}
</button>
))}
</div>
<div style={{ fontSize: 11, color: 'var(--text-tertiary)', marginTop: 8, lineHeight: 1.5 }}>
{voiceProvider === 'anthropic' ? 'Claude Haiku — точнее, лучше с tools. Платный (~$0.001/запрос).' : 'Groq Llama — быстро, бесплатно. Иногда глючит с tools.'}
</div>
</div>
{/* City selector */}
<div style={{ background: 'var(--card-bg)', border: '1px solid var(--card-border)', borderRadius: 22, padding: '22px 24px' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 16 }}>

View File

@@ -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()
}