feat: LLM provider switcher (Claude/Groq) in settings tab
All checks were successful
Deploy / deploy (push) Successful in 1m27s
All checks were successful
Deploy / deploy (push) Successful in 1m27s
This commit is contained in:
33
app/api/settings/route.ts
Normal file
33
app/api/settings/route.ts
Normal 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)
|
||||||
|
}
|
||||||
@@ -2,37 +2,44 @@ 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 { fetch as undiciFetch, ProxyAgent } from 'undici'
|
import { fetch as undiciFetch, ProxyAgent } from 'undici'
|
||||||
|
|
||||||
import { voiceBus } from '@/lib/voice-bus'
|
import { voiceBus } from '@/lib/voice-bus'
|
||||||
import { systemPrompt } from '@/lib/voice-prompts'
|
import { systemPrompt } from '@/lib/voice-prompts'
|
||||||
import { TOOL_SCHEMAS, executeTool } from '@/lib/tools/_registry'
|
import { TOOL_SCHEMAS, executeTool } from '@/lib/tools/_registry'
|
||||||
import { cleanForSpeech, stripFillers, isResetCommand } from '@/lib/voice-text'
|
import { cleanForSpeech, stripFillers, isResetCommand } from '@/lib/voice-text'
|
||||||
import {
|
import { loadHistory, saveHistory, resetHistory, HistoryMessage } from '@/lib/voice-history'
|
||||||
loadHistory, saveHistory, resetHistory,
|
import { promises as nodeFs } from 'node:fs'
|
||||||
HistoryMessage,
|
|
||||||
} from '@/lib/voice-history'
|
|
||||||
|
|
||||||
const MODEL = process.env.ANTHROPIC_MODEL || 'claude-haiku-4-5'
|
|
||||||
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 ANTHROPIC_API_KEY = process.env.ANTHROPIC_API_KEY || ''
|
const ANTHROPIC_API_KEY = process.env.ANTHROPIC_API_KEY || ''
|
||||||
const ANTHROPIC_PROXY = process.env.ANTHROPIC_PROXY || ''
|
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 }>()
|
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()
|
||||||
const b = rateBuckets.get(key)
|
const b = rateBuckets.get(key)
|
||||||
if (!b || b.resetAt <= now) {
|
if (!b || b.resetAt <= now) { rateBuckets.set(key, { count: 1, resetAt: now + 60_000 }); return true }
|
||||||
rateBuckets.set(key, { count: 1, resetAt: now + 60_000 })
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
if (b.count >= RATE_LIMIT_PER_MINUTE) return false
|
if (b.count >= RATE_LIMIT_PER_MINUTE) return false
|
||||||
b.count++
|
b.count++; return true
|
||||||
return true
|
|
||||||
}
|
}
|
||||||
let lastSweep = 0
|
let lastSweep = 0
|
||||||
function sweep() {
|
function sweep() {
|
||||||
@@ -42,7 +49,17 @@ 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
|
// ——— 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[] {
|
function toAnthropicTools(tools: any[]): any[] {
|
||||||
return tools.map(t => ({
|
return tools.map(t => ({
|
||||||
name: t.function.name,
|
name: t.function.name,
|
||||||
@@ -51,7 +68,6 @@ function toAnthropicTools(tools: any[]): any[] {
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Convert history to Anthropic messages format
|
|
||||||
function historyToAnthropicMessages(history: HistoryMessage[]): any[] {
|
function historyToAnthropicMessages(history: HistoryMessage[]): any[] {
|
||||||
const result: any[] = []
|
const result: any[] = []
|
||||||
for (const msg of history) {
|
for (const msg of history) {
|
||||||
@@ -59,7 +75,7 @@ function historyToAnthropicMessages(history: HistoryMessage[]): any[] {
|
|||||||
if (msg.role === 'user') {
|
if (msg.role === 'user') {
|
||||||
result.push({ role: 'user', content: msg.content || '' })
|
result.push({ role: 'user', content: msg.content || '' })
|
||||||
} else if (msg.role === 'assistant') {
|
} else if (msg.role === 'assistant') {
|
||||||
if (msg.tool_calls && msg.tool_calls.length > 0) {
|
if (msg.tool_calls?.length) {
|
||||||
const content: any[] = []
|
const content: any[] = []
|
||||||
if (msg.content) content.push({ type: 'text', text: msg.content })
|
if (msg.content) content.push({ type: 'text', text: msg.content })
|
||||||
for (const tc of msg.tool_calls) {
|
for (const tc of msg.tool_calls) {
|
||||||
@@ -73,58 +89,29 @@ function historyToAnthropicMessages(history: HistoryMessage[]): any[] {
|
|||||||
}
|
}
|
||||||
} else if (msg.role === 'tool') {
|
} else if (msg.role === 'tool') {
|
||||||
const last = result[result.length - 1]
|
const last = result[result.length - 1]
|
||||||
const toolResultBlock = {
|
const block = { type: 'tool_result', tool_use_id: msg.tool_call_id, content: msg.content || '' }
|
||||||
type: 'tool_result',
|
if (last?.role === 'user' && Array.isArray(last.content)) last.content.push(block)
|
||||||
tool_use_id: msg.tool_call_id,
|
else result.push({ role: 'user', content: [block] })
|
||||||
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
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
async function claudeRequest(system: string, messages: any[], tools?: any[]): Promise<any> {
|
async function claudeRequest(system: string, messages: any[], tools: any[]): Promise<any> {
|
||||||
const body: any = {
|
const body: any = { model: 'claude-haiku-4-5-20251001', max_tokens: MAX_TOKENS, system, messages }
|
||||||
model: MODEL,
|
if (tools.length > 0) body.tools = tools
|
||||||
max_tokens: MAX_TOKENS,
|
const opts: any = {
|
||||||
system,
|
|
||||||
messages,
|
|
||||||
}
|
|
||||||
if (tools && tools.length > 0) {
|
|
||||||
body.tools = tools
|
|
||||||
}
|
|
||||||
|
|
||||||
const fetchOptions: any = {
|
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: { 'Content-Type': 'application/json', 'x-api-key': ANTHROPIC_API_KEY, 'anthropic-version': '2023-06-01' },
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'x-api-key': ANTHROPIC_API_KEY,
|
|
||||||
'anthropic-version': '2023-06-01',
|
|
||||||
},
|
|
||||||
body: JSON.stringify(body),
|
body: JSON.stringify(body),
|
||||||
}
|
}
|
||||||
|
if (ANTHROPIC_PROXY) opts.dispatcher = new ProxyAgent(ANTHROPIC_PROXY)
|
||||||
if (ANTHROPIC_PROXY) {
|
const res = await (ANTHROPIC_PROXY ? undiciFetch : fetch)('https://api.anthropic.com/v1/messages', opts)
|
||||||
fetchOptions.dispatcher = new ProxyAgent(ANTHROPIC_PROXY)
|
if (!res.ok) { const err = await res.text(); throw new Error(`anthropic_${res.status}: ${err}`) }
|
||||||
}
|
|
||||||
|
|
||||||
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}`)
|
|
||||||
}
|
|
||||||
return res.json()
|
return res.json()
|
||||||
}
|
}
|
||||||
|
|
||||||
type AgentId = 'cosmo' | 'lusya'
|
type AgentId = 'cosmo' | 'lusya'
|
||||||
|
|
||||||
function emitVoice(event: string, agent: AgentId, text?: string) {
|
function emitVoice(event: string, agent: AgentId, text?: string) {
|
||||||
voiceBus.emit('voice', { event, agent, text, timestamp: new Date().toISOString() })
|
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 fwd = req.headers.get('x-forwarded-for') || ''
|
||||||
const ratekey = tokenMatch?.[1] || (internal ? 'internal' : '') || fwd.split(',')[0].trim() || 'anon'
|
const ratekey = tokenMatch?.[1] || (internal ? 'internal' : '') || fwd.split(',')[0].trim() || 'anon'
|
||||||
sweep()
|
sweep()
|
||||||
if (!rateLimit(ratekey)) {
|
if (!rateLimit(ratekey)) return NextResponse.json({ error: 'rate_limited' }, { status: 429 })
|
||||||
return NextResponse.json({ error: 'rate_limited' }, { status: 429 })
|
|
||||||
}
|
|
||||||
|
|
||||||
const body = await req.json().catch(() => null)
|
const body = await req.json().catch(() => null)
|
||||||
if (!body || typeof body.text !== 'string' || !body.text.trim()) {
|
if (!body || typeof body.text !== 'string' || !body.text.trim()) return NextResponse.json({ error: 'text required' }, { status: 400 })
|
||||||
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'
|
|
||||||
|
|
||||||
|
const userText = body.text.trim().slice(0, 4000)
|
||||||
|
const agent: AgentId = body.agent === 'lusya' ? 'lusya' : 'cosmo'
|
||||||
emitVoice('command', agent, userText)
|
emitVoice('command', agent, userText)
|
||||||
|
|
||||||
if (isResetCommand(userText)) {
|
if (isResetCommand(userText)) {
|
||||||
@@ -156,68 +139,102 @@ export async function POST(req: Request) {
|
|||||||
return NextResponse.json({ text: msg, reset: true })
|
return NextResponse.json({ text: msg, reset: true })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const settings = await getVoiceSettings()
|
||||||
|
const provider = settings.voiceProvider || 'anthropic'
|
||||||
const history = await loadHistory(agent)
|
const history = await loadHistory(agent)
|
||||||
const sysPrompt = systemPrompt(agent)
|
const sysPrompt = systemPrompt(agent)
|
||||||
const anthropicTools = toAnthropicTools(TOOL_SCHEMAS as any[])
|
const newTurns: HistoryMessage[] = [{ role: 'user', content: userText }]
|
||||||
|
let finalText = ''
|
||||||
|
|
||||||
const messages: any[] = [
|
// ======== GROQ ========
|
||||||
...historyToAnthropicMessages(history),
|
if (provider === 'groq') {
|
||||||
|
const groqModel = settings.groqModel || 'llama-3.3-70b-versatile'
|
||||||
|
const groqMessages: any[] = [
|
||||||
|
{ role: 'system', content: sysPrompt },
|
||||||
|
...history,
|
||||||
{ role: 'user', content: userText },
|
{ 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 = ''
|
// ======== ANTHROPIC ========
|
||||||
const newTurns: HistoryMessage[] = [{ role: 'user', content: userText }]
|
} else {
|
||||||
|
const anthropicTools = toAnthropicTools(TOOL_SCHEMAS as any[])
|
||||||
|
const messages: any[] = [...historyToAnthropicMessages(history), { role: 'user', content: userText }]
|
||||||
try {
|
try {
|
||||||
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(sysPrompt, messages, anthropicTools)
|
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}`)
|
||||||
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 content: any[] = resp.content || []
|
||||||
const stopReason: string = resp.stop_reason || 'end_turn'
|
const stopReason = resp.stop_reason || 'end_turn'
|
||||||
|
|
||||||
if (stopReason === 'tool_use') {
|
if (stopReason === 'tool_use') {
|
||||||
const toolUseBlocks = content.filter((b: any) => b.type === 'tool_use')
|
const toolUseBlocks = content.filter((b: any) => b.type === 'tool_use')
|
||||||
const textBlocks = content.filter((b: any) => b.type === 'text')
|
const partialText = content.filter((b: any) => b.type === 'text').map((b: any) => b.text).join('')
|
||||||
const partialText = textBlocks.map((b: any) => b.text).join('')
|
|
||||||
|
|
||||||
messages.push({ role: 'assistant', content })
|
messages.push({ role: 'assistant', content })
|
||||||
newTurns.push({
|
newTurns.push({
|
||||||
role: 'assistant',
|
role: 'assistant', content: partialText || null,
|
||||||
content: partialText || null,
|
tool_calls: toolUseBlocks.map((b: any) => ({ id: b.id, function: { name: b.name, arguments: JSON.stringify(b.input) } })),
|
||||||
tool_calls: toolUseBlocks.map((b: any) => ({
|
|
||||||
id: b.id,
|
|
||||||
function: { name: b.name, arguments: JSON.stringify(b.input) },
|
|
||||||
})),
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const toolResults: any[] = []
|
const toolResults: any[] = []
|
||||||
for (const tb of toolUseBlocks) {
|
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)
|
const result = await executeTool(tb.name, tb.input || {}, agent)
|
||||||
toolResults.push({
|
toolResults.push({ type: 'tool_result', tool_use_id: tb.id, content: JSON.stringify(result) })
|
||||||
type: 'tool_result',
|
newTurns.push({ role: 'tool', content: JSON.stringify(result), tool_call_id: tb.id })
|
||||||
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 })
|
messages.push({ role: 'user', content: toolResults })
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
finalText = content.filter((b: any) => b.type === 'text').map((b: any) => b.text).join('')
|
||||||
const textBlocks = content.filter((b: any) => b.type === 'text')
|
|
||||||
finalText = textBlocks.map((b: any) => b.text).join('')
|
|
||||||
newTurns.push({ role: 'assistant', content: finalText })
|
newTurns.push({ role: 'assistant', content: finalText })
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
@@ -228,6 +245,7 @@ export async function POST(req: Request) {
|
|||||||
emitVoice('error', agent, msg)
|
emitVoice('error', agent, msg)
|
||||||
return NextResponse.json({ error: 'llm_failed', detail: errStr, text: msg }, { status: 502 })
|
return NextResponse.json({ error: 'llm_failed', detail: errStr, text: msg }, { status: 502 })
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (!finalText.trim()) {
|
if (!finalText.trim()) {
|
||||||
const msg = 'Не получил ответ.'
|
const msg = 'Не получил ответ.'
|
||||||
@@ -235,9 +253,7 @@ export async function POST(req: Request) {
|
|||||||
return NextResponse.json({ text: msg }, { status: 200 })
|
return NextResponse.json({ text: msg }, { status: 200 })
|
||||||
}
|
}
|
||||||
|
|
||||||
const updatedHistory: HistoryMessage[] = [...history, ...newTurns]
|
await saveHistory(agent, [...history, ...newTurns])
|
||||||
await saveHistory(agent, updatedHistory)
|
|
||||||
|
|
||||||
const cleaned = cleanForSpeech(stripFillers(finalText))
|
const cleaned = cleanForSpeech(stripFillers(finalText))
|
||||||
emitVoice('response', agent, cleaned)
|
emitVoice('response', agent, cleaned)
|
||||||
return NextResponse.json({ text: cleaned })
|
return NextResponse.json({ text: cleaned })
|
||||||
|
|||||||
45
app/page.tsx
45
app/page.tsx
@@ -765,12 +765,57 @@ function SettingsTab({ city, onCityChange, onLogout, theme, onThemeChange }: { c
|
|||||||
textAlign: 'center', letterSpacing: '4px',
|
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]
|
const currentCity = CITIES.find(c => c.id === city) || CITIES[0]
|
||||||
|
|
||||||
return (
|
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%' }}>
|
<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>
|
<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 */}
|
{/* City selector */}
|
||||||
<div style={{ background: 'var(--card-bg)', border: '1px solid var(--card-border)', borderRadius: 22, padding: '22px 24px' }}>
|
<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 }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 16 }}>
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ export async function middleware(request: NextRequest) {
|
|||||||
pathname === '/api/voice/event' ||
|
pathname === '/api/voice/event' ||
|
||||||
pathname.startsWith('/api/voice/tools/') ||
|
pathname.startsWith('/api/voice/tools/') ||
|
||||||
pathname === '/api/voice/timer'
|
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()
|
return NextResponse.next()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user