feat(voice): server-side LLM/STT — porting Python satellite into tablet
All checks were successful
Deploy / deploy (push) Successful in 5m44s
All checks were successful
Deploy / deploy (push) Successful in 5m44s
Шаг 1 миграции голосового стека из home-voice-assistant в сам tablet: - /api/voice/chat — Claude Haiku 4.5 с tool-loop (max 4 раунда), prompt caching на system + старой истории, история в /data/voice-history/. Эмитит command/response/error в voice-bus → орб моргает как раньше. - /api/voice/stt — Groq whisper-large-v3-turbo, multipart или raw audio. - lib/voice-text.ts — порт clean_for_speech (без pymorphy3, время в именительном падеже) и strip_fillers + RESET_PATTERNS. - lib/voice-executors.ts — tool executors через loopback fetch на существующие /api/voice/tools/* и /api/voice/timer. - Поддержка ANTHROPIC_PROXY/GROQ_PROXY (fallback на HTTPS_PROXY). После деплоя нужны GROQ_API_KEY и ANTHROPIC_API_KEY в tablet.env. Шаги 2 (push-to-talk в браузере) и 3 (wake-word) — отдельно.
This commit is contained in:
155
app/api/voice/chat/route.ts
Normal file
155
app/api/voice/chat/route.ts
Normal file
@@ -0,0 +1,155 @@
|
||||
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 { voiceBus } from '@/lib/voice-bus'
|
||||
import { systemPrompt } from '@/lib/voice-prompts'
|
||||
import { TOOL_SCHEMAS } from '@/lib/voice-tool-schemas'
|
||||
import { executeTool } from '@/lib/voice-executors'
|
||||
import { cleanForSpeech, stripFillers, isResetCommand } from '@/lib/voice-text'
|
||||
import {
|
||||
loadHistory, saveHistory, resetHistory,
|
||||
buildMessagesWithCache, stripCacheControl, 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_TOOL_ROUNDS = 4
|
||||
|
||||
let _client: Anthropic | null = null
|
||||
function client(): Anthropic {
|
||||
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 })
|
||||
return _client
|
||||
}
|
||||
|
||||
function emitVoice(event: string, agent: 'cosmo' | 'lusya', text?: string) {
|
||||
voiceBus.emit('voice', {
|
||||
event,
|
||||
agent,
|
||||
text,
|
||||
timestamp: new Date().toISOString(),
|
||||
})
|
||||
}
|
||||
|
||||
type AgentId = 'cosmo' | 'lusya'
|
||||
|
||||
export async function POST(req: Request) {
|
||||
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()
|
||||
const agent: AgentId = body.agent === 'lusya' ? 'lusya' : 'cosmo'
|
||||
|
||||
// Echo command в орб
|
||||
emitVoice('command', agent, userText)
|
||||
|
||||
// Reset-команда — стираем историю и отвечаем шаблонно
|
||||
if (isResetCommand(userText)) {
|
||||
await resetHistory(agent)
|
||||
const msg = 'Начинаю новую сессию.'
|
||||
emitVoice('response', agent, msg)
|
||||
return NextResponse.json({ text: msg, reset: true })
|
||||
}
|
||||
|
||||
// Загружаем историю и добавляем новый user-turn
|
||||
const history = await loadHistory(agent)
|
||||
history.push({ role: 'user', content: userText })
|
||||
|
||||
const systemBlocks: Anthropic.TextBlockParam[] = [
|
||||
{
|
||||
type: 'text',
|
||||
text: systemPrompt(agent),
|
||||
cache_control: { type: 'ephemeral' },
|
||||
},
|
||||
]
|
||||
|
||||
const apiMessages: Anthropic.MessageParam[] = buildMessagesWithCache(history) as any
|
||||
|
||||
let finalText = ''
|
||||
const initialUserIdx = history.length - 1
|
||||
|
||||
try {
|
||||
const c = client()
|
||||
for (let round = 0; round < MAX_TOOL_ROUNDS; round++) {
|
||||
const t0 = Date.now()
|
||||
const resp = await c.messages.create({
|
||||
model: MODEL,
|
||||
max_tokens: MAX_TOKENS,
|
||||
system: systemBlocks,
|
||||
messages: apiMessages,
|
||||
tools: TOOL_SCHEMAS,
|
||||
})
|
||||
|
||||
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}`
|
||||
)
|
||||
|
||||
// Разбираем 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 turn в API messages как есть (важно для tool_use_id)
|
||||
apiMessages.push({ role: 'assistant', content: resp.content as any })
|
||||
|
||||
if (resp.stop_reason === 'tool_use' && toolUses.length) {
|
||||
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,
|
||||
content: JSON.stringify(result),
|
||||
})
|
||||
}
|
||||
apiMessages.push({ role: 'user', content: toolResults })
|
||||
continue
|
||||
}
|
||||
|
||||
// end_turn / max_tokens / stop_sequence — финальный ответ готов
|
||||
break
|
||||
}
|
||||
} catch (e: any) {
|
||||
console.error('[voice/chat] anthropic 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 })
|
||||
}
|
||||
|
||||
if (!finalText.trim()) {
|
||||
const msg = 'Не получил ответ.'
|
||||
emitVoice('error', agent, msg)
|
||||
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)
|
||||
|
||||
const cleaned = cleanForSpeech(stripFillers(finalText))
|
||||
emitVoice('response', agent, cleaned)
|
||||
return NextResponse.json({ text: cleaned })
|
||||
}
|
||||
71
app/api/voice/stt/route.ts
Normal file
71
app/api/voice/stt/route.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
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 { toFile } from 'groq-sdk/uploads'
|
||||
|
||||
const STT_MODEL = process.env.GROQ_STT_MODEL || 'whisper-large-v3-turbo'
|
||||
|
||||
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 proxy = process.env.GROQ_PROXY || process.env.HTTPS_PROXY || ''
|
||||
const httpAgent = proxy ? new HttpsProxyAgent(proxy) : undefined
|
||||
_client = new Groq({ apiKey, httpAgent })
|
||||
return _client
|
||||
}
|
||||
|
||||
// Принимает либо multipart/form-data с полем "file",
|
||||
// либо raw audio в теле (Content-Type: audio/* — например audio/webm).
|
||||
// Возвращает {text: string}.
|
||||
export async function POST(req: Request) {
|
||||
let audio: { name: string; data: Buffer; mime: string }
|
||||
|
||||
const ct = req.headers.get('content-type') || ''
|
||||
|
||||
try {
|
||||
if (ct.startsWith('multipart/form-data')) {
|
||||
const fd = await req.formData()
|
||||
const file = fd.get('file')
|
||||
if (!(file instanceof Blob)) {
|
||||
return NextResponse.json({ error: 'file field required' }, { status: 400 })
|
||||
}
|
||||
const ab = await file.arrayBuffer()
|
||||
audio = {
|
||||
name: (file as any).name || 'audio.webm',
|
||||
data: Buffer.from(ab),
|
||||
mime: file.type || 'audio/webm',
|
||||
}
|
||||
} else {
|
||||
const ab = await req.arrayBuffer()
|
||||
if (!ab.byteLength) {
|
||||
return NextResponse.json({ error: 'empty body' }, { status: 400 })
|
||||
}
|
||||
audio = {
|
||||
name: 'audio.webm',
|
||||
data: Buffer.from(ab),
|
||||
mime: ct || 'audio/webm',
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
return NextResponse.json({ error: 'failed_to_read_body' }, { status: 400 })
|
||||
}
|
||||
|
||||
try {
|
||||
const file = await toFile(audio.data, audio.name, { type: audio.mime })
|
||||
const result = await client().audio.transcriptions.create({
|
||||
file,
|
||||
model: STT_MODEL,
|
||||
language: 'ru',
|
||||
})
|
||||
const text = (result as any).text || ''
|
||||
return NextResponse.json({ text })
|
||||
} catch (e: any) {
|
||||
console.error('[voice/stt] groq error:', e?.message || e)
|
||||
return NextResponse.json({ error: 'stt_failed', detail: String(e?.message || e) }, { status: 502 })
|
||||
}
|
||||
}
|
||||
159
lib/voice-executors.ts
Normal file
159
lib/voice-executors.ts
Normal file
@@ -0,0 +1,159 @@
|
||||
/**
|
||||
* Tool executors. Серверная сторона вызывает tools через loopback на
|
||||
* /api/voice/tools/* и /api/voice/timer (тот же Next.js process).
|
||||
* Bearer = VOICE_API_KEY, плюс x-voice-internal для middleware bypass.
|
||||
*
|
||||
* Порт satellite/tools.py.
|
||||
*/
|
||||
|
||||
const baseUrl = () => `http://localhost:${process.env.PORT || '3000'}`
|
||||
|
||||
function headers(): Record<string, string> {
|
||||
const key = process.env.VOICE_API_KEY || ''
|
||||
return {
|
||||
Authorization: `Bearer ${key}`,
|
||||
'x-voice-internal': key,
|
||||
'Content-Type': 'application/json',
|
||||
}
|
||||
}
|
||||
|
||||
async function tabletGet(path: string, params?: Record<string, string>): Promise<any> {
|
||||
const url = new URL(`${baseUrl()}${path}`)
|
||||
if (params) {
|
||||
for (const [k, v] of Object.entries(params)) {
|
||||
if (v !== undefined && v !== '') url.searchParams.set(k, v)
|
||||
}
|
||||
}
|
||||
const r = await fetch(url, { headers: headers(), cache: 'no-store' })
|
||||
if (!r.ok) throw new ToolHttpError(r.status, await r.text().catch(() => ''))
|
||||
return r.json()
|
||||
}
|
||||
|
||||
async function tabletJson(method: 'POST' | 'PUT' | 'DELETE', path: string, body?: any, params?: Record<string, string>): Promise<any> {
|
||||
const url = new URL(`${baseUrl()}${path}`)
|
||||
if (params) {
|
||||
for (const [k, v] of Object.entries(params)) {
|
||||
if (v !== undefined && v !== '') url.searchParams.set(k, v)
|
||||
}
|
||||
}
|
||||
const r = await fetch(url, {
|
||||
method,
|
||||
headers: headers(),
|
||||
body: body !== undefined ? JSON.stringify(body) : undefined,
|
||||
})
|
||||
if (!r.ok) throw new ToolHttpError(r.status, await r.text().catch(() => ''))
|
||||
return r.json()
|
||||
}
|
||||
|
||||
class ToolHttpError extends Error {
|
||||
constructor(public status: number, public body: string) {
|
||||
super(`tool_http_${status}`)
|
||||
}
|
||||
}
|
||||
|
||||
type AgentId = 'cosmo' | 'lusya'
|
||||
type ToolResult = Record<string, any> | { error: string }
|
||||
|
||||
const EXECUTORS: Record<string, (params: any, agent: AgentId) => Promise<ToolResult>> = {
|
||||
async get_weather(params) {
|
||||
const city = (params?.city as string) || ''
|
||||
return tabletGet('/api/voice/tools/weather', city ? { city } : undefined)
|
||||
},
|
||||
|
||||
async get_transport(params) {
|
||||
const q: Record<string, string> = {}
|
||||
if (params?.direction) q.direction = String(params.direction)
|
||||
if (params?.routes) q.routes = String(params.routes)
|
||||
return tabletGet('/api/voice/tools/transport', q)
|
||||
},
|
||||
|
||||
async get_today_events(params) {
|
||||
const range = (params?.range as string) || 'today'
|
||||
return tabletGet('/api/voice/tools/events', { range })
|
||||
},
|
||||
|
||||
async create_event(params) {
|
||||
const title = String(params?.title || '').trim()
|
||||
const date = String(params?.date || '').trim()
|
||||
if (!title || !date) return { error: 'title and date required' }
|
||||
const payload: Record<string, any> = {
|
||||
title,
|
||||
date,
|
||||
owner: params?.owner || 'daniil',
|
||||
all_day: !!params?.all_day,
|
||||
}
|
||||
if (!payload.all_day) {
|
||||
payload.start_time = params?.start_time || ''
|
||||
if (params?.end_time !== undefined) payload.end_time = params.end_time
|
||||
}
|
||||
return tabletJson('POST', '/api/voice/tools/events', payload)
|
||||
},
|
||||
|
||||
async update_event(params) {
|
||||
const event_id = String(params?.event_id || '').trim()
|
||||
const owner = String(params?.owner || '').trim()
|
||||
if (!event_id || !owner) return { error: 'event_id and owner required' }
|
||||
const payload: Record<string, any> = { event_id, owner }
|
||||
for (const k of ['title', 'date', 'start_time', 'end_time', 'all_day']) {
|
||||
if (params?.[k] !== undefined) payload[k] = params[k]
|
||||
}
|
||||
return tabletJson('PUT', '/api/voice/tools/events', payload)
|
||||
},
|
||||
|
||||
async delete_event(params) {
|
||||
const event_id = String(params?.event_id || '').trim()
|
||||
const owner = String(params?.owner || 'daniil').trim()
|
||||
if (!event_id) return { error: 'event_id required' }
|
||||
return tabletJson('DELETE', '/api/voice/tools/events', undefined, { event_id, owner })
|
||||
},
|
||||
|
||||
async get_notes() {
|
||||
return tabletGet('/api/voice/tools/notes')
|
||||
},
|
||||
|
||||
async set_timer(params, agent) {
|
||||
const seconds = Number(params?.seconds || 0)
|
||||
const label = String(params?.label || 'Таймер')
|
||||
if (!Number.isFinite(seconds) || seconds < 1) return { error: 'seconds must be positive' }
|
||||
return tabletJson('POST', '/api/voice/timer', {
|
||||
action: 'start',
|
||||
seconds,
|
||||
label,
|
||||
agent,
|
||||
})
|
||||
},
|
||||
|
||||
async cancel_timer(params) {
|
||||
const label = String(params?.label || '').trim()
|
||||
if (!label) return { error: 'label required' }
|
||||
return tabletJson('POST', '/api/voice/timer', { action: 'cancel', label })
|
||||
},
|
||||
|
||||
async adjust_timer(params) {
|
||||
const label = String(params?.label || '').trim()
|
||||
const delta = Number(params?.delta_seconds || 0)
|
||||
if (!label) return { error: 'label required' }
|
||||
if (!Number.isFinite(delta) || delta === 0) return { error: 'delta_seconds must be non-zero' }
|
||||
return tabletJson('POST', '/api/voice/timer', {
|
||||
action: 'adjust',
|
||||
label,
|
||||
delta_seconds: delta,
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
export async function executeTool(name: string, params: any, agent: AgentId): Promise<ToolResult> {
|
||||
const fn = EXECUTORS[name]
|
||||
if (!fn) return { error: `unknown tool: ${name}` }
|
||||
try {
|
||||
return await fn(params || {}, agent)
|
||||
} catch (e) {
|
||||
if (e instanceof ToolHttpError) {
|
||||
return { error: `tool_http_${e.status}` }
|
||||
}
|
||||
if (e instanceof TypeError && /fetch/i.test(e.message)) {
|
||||
return { error: 'tool_network_error' }
|
||||
}
|
||||
return { error: `tool_exception: ${(e as Error).message}` }
|
||||
}
|
||||
}
|
||||
100
lib/voice-history.ts
Normal file
100
lib/voice-history.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* История диалога per-agent per-day. Файлы в /data/voice-history/{agent}-{date}.json.
|
||||
* /data — это volume контейнера (на хосте /opt/digital-home/smart-home-tablet-data/).
|
||||
*/
|
||||
import { promises as fs } from 'node:fs'
|
||||
import path from 'node:path'
|
||||
|
||||
const DATA_DIR = process.env.VOICE_HISTORY_DIR || '/data/voice-history'
|
||||
const MAX_HISTORY = parseInt(process.env.VOICE_MAX_HISTORY || '40', 10)
|
||||
|
||||
export type HistoryMessage = {
|
||||
role: 'user' | 'assistant'
|
||||
content: any
|
||||
}
|
||||
|
||||
function todayIso(): string {
|
||||
return new Date().toISOString().slice(0, 10)
|
||||
}
|
||||
|
||||
function historyPath(agent: string): string {
|
||||
return path.join(DATA_DIR, `${agent}-${todayIso()}.json`)
|
||||
}
|
||||
|
||||
export async function loadHistory(agent: string): Promise<HistoryMessage[]> {
|
||||
try {
|
||||
const raw = await fs.readFile(historyPath(agent), 'utf-8')
|
||||
const parsed = JSON.parse(raw)
|
||||
return Array.isArray(parsed) ? parsed : []
|
||||
} catch (e: any) {
|
||||
if (e?.code === 'ENOENT') return []
|
||||
console.warn('[voice/history] read failed:', e?.message || e)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveHistory(agent: string, history: HistoryMessage[]): Promise<void> {
|
||||
try {
|
||||
await fs.mkdir(DATA_DIR, { recursive: true })
|
||||
const trimmed = history.slice(-MAX_HISTORY)
|
||||
await fs.writeFile(historyPath(agent), JSON.stringify(trimmed, null, 2), 'utf-8')
|
||||
} catch (e: any) {
|
||||
console.warn('[voice/history] write failed:', e?.message || e)
|
||||
}
|
||||
}
|
||||
|
||||
export async function resetHistory(agent: string): Promise<void> {
|
||||
try {
|
||||
await fs.unlink(historyPath(agent))
|
||||
} catch (e: any) {
|
||||
if (e?.code !== 'ENOENT') console.warn('[voice/history] reset failed:', e?.message || e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Убирает cache_control из блоков (для записи в историю — следующий turn пересчитает границу).
|
||||
*/
|
||||
export function stripCacheControl(content: any): any {
|
||||
if (Array.isArray(content)) {
|
||||
return content.map((b) => {
|
||||
if (b && typeof b === 'object' && 'cache_control' in b) {
|
||||
const { cache_control: _ignore, ...rest } = b
|
||||
return rest
|
||||
}
|
||||
return b
|
||||
})
|
||||
}
|
||||
return content
|
||||
}
|
||||
|
||||
/**
|
||||
* Граница prompt-кеша: всё, кроме последних N сообщений, помечаем cache_control
|
||||
* на последнем блоке последнего «старого» сообщения. Даёт cache hit на каждом turn.
|
||||
*/
|
||||
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
|
||||
}
|
||||
68
lib/voice-prompts.ts
Normal file
68
lib/voice-prompts.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* System prompts для Cosmo и Люси. Порт llm_claude.py.
|
||||
* {today} подставляется через formatPrompt() — текущая дата ISO.
|
||||
*/
|
||||
|
||||
const COSMO = `Ты — Cosmo, домашний голосовой ассистент Даниила (Санкт-Петербург).
|
||||
|
||||
Стиль:
|
||||
- Короткие ответы: 1-2 предложения, редко 3. Это голосовой канал — многословность утомляет.
|
||||
- Разговорный русский, без канцелярита, без формальных оборотов («здравствуйте», «уважаемый»).
|
||||
- Обращение на «ты».
|
||||
- Не предваряй ответ фразами-заполнителями («сейчас посмотрю», «минутку», «проверяю») — сразу отвечай.
|
||||
- Без эмодзи, маркированных списков, код-блоков — всё будет зачитано.
|
||||
- Если не знаешь — скажи коротко, не оправдывайся.
|
||||
|
||||
ЖЁСТКИЕ ПРАВИЛА про tools:
|
||||
1. Любое ДЕЙСТВИЕ (поставить/отменить/изменить таймер, что-то включить/выключить)
|
||||
делается ТОЛЬКО через вызов tool. Без tool действие не произошло.
|
||||
2. Никогда не говори «поставил», «отменил», «удалил», «добавил», «изменил»,
|
||||
если ты в этом же turn'e не вызвал соответствующий tool. Это галлюцинация,
|
||||
пользователь потом обнаружит что ничего не изменилось и не будет тебе доверять.
|
||||
3. Любая АКТУАЛЬНАЯ ИНФОРМАЦИЯ (погода, транспорт, события в календаре,
|
||||
содержимое заметок) — всегда через tool. Не выдумывай числа и факты.
|
||||
4. Порядок: сначала tool → потом в том же turn'e сформулируй ответ на основе
|
||||
результата. Не пересказывай сырые данные дословно — дай человеческую сводку.
|
||||
5. Если подходящего tool нет — честно скажи «так я не умею», а не притворяйся.
|
||||
|
||||
Доступные tools: get_weather, get_transport, get_today_events, create_event,
|
||||
update_event, delete_event, get_notes, set_timer, cancel_timer, adjust_timer.
|
||||
|
||||
Работа с календарём:
|
||||
- У Даниила и Светы разные календари. Параметр owner обязательный.
|
||||
- Если пользователь не уточнил чей календарь — СПРОСИ прежде чем вызывать
|
||||
create_event. Не угадывай даже если контекст намекает.
|
||||
- Для изменения или удаления события сначала вызови get_today_events
|
||||
(можно с range=week/month), найди нужное событие по названию и времени,
|
||||
потом действуй с его event_id и owner.
|
||||
- Даты в формате YYYY-MM-DD (2026-04-24), времена HH:MM (14:30).
|
||||
«завтра» = сегодня+1 по дате, «послезавтра» = +2. Сегодня {today}.
|
||||
|
||||
Контекст: Даниил — разработчик, живёт в СПб с женой Светой.`
|
||||
|
||||
const LUSYA = `Ты — Люся, домашний голосовой ассистент Светы (Санкт-Петербург).
|
||||
|
||||
Стиль:
|
||||
- Тёплый, заботливый, чуть эмоциональный, но лаконичный. 1-2 предложения.
|
||||
- Обращение на «ты».
|
||||
- Без эмодзи, списков, код-блоков — это голос.
|
||||
- Если не знаешь — скажи коротко.
|
||||
|
||||
ЖЁСТКИЕ ПРАВИЛА про tools:
|
||||
1. Действия (таймер, события) — только через вызов tool. Без tool действие не произошло.
|
||||
2. Не говори «поставила/отменила/изменила», если ты не вызвала соответствующий tool.
|
||||
3. Информацию (погода, транспорт, события) — всегда через tool, не выдумывай.
|
||||
4. Tool → результат → короткий ответ человеческим языком.
|
||||
|
||||
Календарь:
|
||||
- Свой = Светин, ещё есть календарь Данила. Для create_event уточняй
|
||||
в какой календарь, если неясно.
|
||||
- Для update_event / delete_event: сначала get_today_events, найди по
|
||||
названию, потом действуй.
|
||||
- Даты YYYY-MM-DD, время HH:MM. Сегодня {today}.`
|
||||
|
||||
export function systemPrompt(agent: 'cosmo' | 'lusya'): string {
|
||||
const today = new Date().toISOString().slice(0, 10)
|
||||
const tpl = agent === 'lusya' ? LUSYA : COSMO
|
||||
return tpl.replace('{today}', today)
|
||||
}
|
||||
147
lib/voice-text.ts
Normal file
147
lib/voice-text.ts
Normal file
@@ -0,0 +1,147 @@
|
||||
/**
|
||||
* Подготовка текста для TTS и распознавание команд сброса.
|
||||
* Порт satellite/text.py + satellite/llm.py (RESET_PATTERNS, FILLER_PATTERNS).
|
||||
*
|
||||
* Отличие от Python-версии: время «HH:MM» раскрывается в слова всегда в
|
||||
* именительном падеже (без pymorphy3). Звучит чуть менее естественно в
|
||||
* конструкциях вроде «встретимся в 14:30», но без зависимости от морфологии.
|
||||
*/
|
||||
|
||||
const RESET_PATTERNS = new RegExp(
|
||||
'(начни|начать|создай|открой|давай).{0,10}(новую|новый|чистую|чистый).{0,10}' +
|
||||
'(сессию|сессия|диалог|разговор|чат)' +
|
||||
'|' +
|
||||
'(сбрось|очисти|обнови).{0,10}(сессию|диалог|разговор|чат|историю|контекст)',
|
||||
'i'
|
||||
)
|
||||
|
||||
const FILLER_PATTERNS = new RegExp(
|
||||
'(?:(?:сейчас посмотрю|дай мне секунду|дай секунду|проверяю|загружаю|узнаю' +
|
||||
'|смотрю|одну секунду|я сейчас посмотрю|я проверю|попробую другой источник' +
|
||||
'|нужны конкретные числа|дай мне загрузить)[^.!?]*[.!?]?\\s*)+',
|
||||
'gi'
|
||||
)
|
||||
|
||||
export function isResetCommand(text: string): boolean {
|
||||
return RESET_PATTERNS.test(text)
|
||||
}
|
||||
|
||||
export function stripFillers(text: string): string {
|
||||
return text.replace(FILLER_PATTERNS, '').trim()
|
||||
}
|
||||
|
||||
// ── числа в слова (рус, именительный, кардинальные 0..99) ─────────────
|
||||
const ONES_M = ['', 'один', 'два', 'три', 'четыре', 'пять', 'шесть', 'семь', 'восемь', 'девять']
|
||||
const ONES_F = ['', 'одна', 'две', 'три', 'четыре', 'пять', 'шесть', 'семь', 'восемь', 'девять']
|
||||
const TEENS = ['десять', 'одиннадцать', 'двенадцать', 'тринадцать', 'четырнадцать', 'пятнадцать',
|
||||
'шестнадцать', 'семнадцать', 'восемнадцать', 'девятнадцать']
|
||||
const TENS = ['', '', 'двадцать', 'тридцать', 'сорок', 'пятьдесят', 'шестьдесят', 'семьдесят',
|
||||
'восемьдесят', 'девяносто']
|
||||
|
||||
function numToWords(n: number, gender: 'm' | 'f' = 'm'): string {
|
||||
if (n === 0) return 'ноль'
|
||||
if (n < 0 || n > 99) return String(n)
|
||||
const ones = gender === 'f' ? ONES_F : ONES_M
|
||||
if (n < 10) return ones[n]
|
||||
if (n < 20) return TEENS[n - 10]
|
||||
const t = Math.floor(n / 10)
|
||||
const o = n % 10
|
||||
return o === 0 ? TENS[t] : `${TENS[t]} ${ones[o]}`
|
||||
}
|
||||
|
||||
function hoursWord(n: number): string {
|
||||
const last2 = n % 100
|
||||
const last1 = n % 10
|
||||
if (last2 >= 11 && last2 <= 14) return 'часов'
|
||||
if (last1 === 1) return 'час'
|
||||
if (last1 >= 2 && last1 <= 4) return 'часа'
|
||||
return 'часов'
|
||||
}
|
||||
|
||||
function minutesWord(n: number): string {
|
||||
const last2 = n % 100
|
||||
const last1 = n % 10
|
||||
if (last2 >= 11 && last2 <= 14) return 'минут'
|
||||
if (last1 === 1) return 'минута'
|
||||
if (last1 >= 2 && last1 <= 4) return 'минуты'
|
||||
return 'минут'
|
||||
}
|
||||
|
||||
function formatTime(h: number, mm: number): string {
|
||||
let out = `${numToWords(h, 'm')} ${hoursWord(h)}`
|
||||
if (mm > 0) out += ` ${numToWords(mm, 'f')} ${minutesWord(mm)}`
|
||||
return out
|
||||
}
|
||||
|
||||
// ── единицы измерения со слэшем ───────────────────────────────────────
|
||||
const UNIT_SLASH: Array<[RegExp, string]> = [
|
||||
[/\bкм\s*\/\s*ч\b/gi, 'километров в час'],
|
||||
[/\bм\s*\/\s*с\b/gi, 'метров в секунду'],
|
||||
[/\bкм\s*\/\s*с\b/gi, 'километров в секунду'],
|
||||
[/\bмб\s*\/\s*с\b/gi, 'мегабит в секунду'],
|
||||
[/\bгб\s*\/\s*с\b/gi, 'гигабит в секунду'],
|
||||
[/\bруб\s*\/\s*мес\b/gi, 'рублей в месяц'],
|
||||
[/\bр\s*\/\s*мес\b/gi, 'рублей в месяц'],
|
||||
]
|
||||
|
||||
const ABBR: Array<[RegExp, string]> = [
|
||||
[/\bт\.\s*е\./gi, 'то есть'],
|
||||
[/\bт\.\s*к\./gi, 'так как'],
|
||||
[/\bт\.\s*д\./gi, 'так далее'],
|
||||
[/\bт\.\s*п\./gi, 'тому подобное'],
|
||||
[/\bи\s*т\.\s*д\./gi, 'и так далее'],
|
||||
[/\bи\s*т\.\s*п\./gi, 'и тому подобное'],
|
||||
]
|
||||
|
||||
export function cleanForSpeech(input: string): string {
|
||||
let t = input
|
||||
// markdown — emojis: supplementary planes + symbols + dingbats + misc-symbols
|
||||
t = t.replace(/[\u{1F000}-\u{1FFFF}☀-➿]/gu, '')
|
||||
t = t.replace(/\*+/g, '')
|
||||
t = t.replace(/#+\s/g, '')
|
||||
t = t.replace(/^- /gm, '')
|
||||
t = t.replace(/\[.*?\]\(.*?\)/g, '')
|
||||
t = t.replace(/\n+/g, '. ')
|
||||
|
||||
for (const [pat, repl] of UNIT_SLASH) t = t.replace(pat, repl)
|
||||
|
||||
// знаки перед числом
|
||||
t = t.replace(/(^|\s)\+(\d)/g, '$1плюс $2')
|
||||
t = t.replace(/(^|\s)-(\d)/g, '$1минус $2')
|
||||
t = t.replace(/±(\d)/g, 'плюс-минус $1')
|
||||
|
||||
// время HH:MM (опционально с предлогом — предлог сохраняем как есть, время в номинативе)
|
||||
t = t.replace(
|
||||
/(?:\b(с|со|до|от|после|около|к|ко|в|во|на|через|за|перед|между|о|об|при)\s+)?(\d{1,2}):(\d{2})\b/gi,
|
||||
(_m, prep: string | undefined, hStr: string, mmStr: string) => {
|
||||
const h = parseInt(hStr, 10)
|
||||
const mm = parseInt(mmStr, 10)
|
||||
if (h < 0 || h > 23 || mm < 0 || mm > 59) return _m
|
||||
const words = formatTime(h, mm)
|
||||
return prep ? `${prep} ${words}` : words
|
||||
}
|
||||
)
|
||||
|
||||
// дроби и слэш
|
||||
t = t.replace(/(\d+)\s*\/\s*(\d+)/g, '$1 из $2')
|
||||
t = t.replace(/\//g, ' или ')
|
||||
|
||||
// символы
|
||||
t = t.replace(/°\s*C\b/gi, ' градусов')
|
||||
t = t.replace(/°\s*F\b/gi, ' градусов Фаренгейта')
|
||||
t = t.replace(/°/g, ' градусов')
|
||||
t = t.replace(/%/g, ' процентов')
|
||||
t = t.replace(/№/g, ' номер ')
|
||||
t = t.replace(/&/g, ' и ')
|
||||
t = t.replace(/@/g, ' собака ')
|
||||
t = t.replace(/×/g, ' на ')
|
||||
|
||||
for (const [pat, repl] of ABBR) t = t.replace(pat, repl)
|
||||
|
||||
// схлопывание
|
||||
t = t.replace(/([:;,!?])\s*\./g, '$1')
|
||||
t = t.replace(/\.\s*\.+/g, '.')
|
||||
t = t.replace(/\s+/g, ' ')
|
||||
t = t.replace(/(\d+)\.(\s)/g, '$1$2')
|
||||
return t.trim()
|
||||
}
|
||||
203
lib/voice-tool-schemas.ts
Normal file
203
lib/voice-tool-schemas.ts
Normal file
@@ -0,0 +1,203 @@
|
||||
/**
|
||||
* Tool schemas для Anthropic API. Порт TOOL_SCHEMAS из satellite/tools.py.
|
||||
* Формат — Anthropic native tools (name + description + input_schema).
|
||||
*/
|
||||
import type Anthropic from '@anthropic-ai/sdk'
|
||||
|
||||
export const TOOL_SCHEMAS: Anthropic.Tool[] = [
|
||||
{
|
||||
name: 'get_weather',
|
||||
description:
|
||||
'Получить текущую погоду и короткий прогноз для города. ' +
|
||||
'Для вопросов вроде «какая сегодня погода», «холодно ли на улице», «нужен ли зонт». ' +
|
||||
'По умолчанию — Санкт-Петербург.',
|
||||
input_schema: {
|
||||
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». Пусто = все маршруты.',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
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 (текущий месяц)',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
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: 'Чей это календарь — Даниила или Светы',
|
||||
},
|
||||
},
|
||||
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)',
|
||||
},
|
||||
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'],
|
||||
},
|
||||
},
|
||||
{
|
||||
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'] },
|
||||
},
|
||||
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: 'Короткое название таймера (например «Чайник», «Паста»)',
|
||||
},
|
||||
},
|
||||
required: ['seconds', 'label'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'cancel_timer',
|
||||
description:
|
||||
'Отменить активный таймер по его названию. Для «отмени таймер чайник», ' +
|
||||
'«убери таймер пасты», «останови отсчёт».',
|
||||
input_schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
label: {
|
||||
type: 'string',
|
||||
description: 'Название таймера (примерное совпадение — можно частично).',
|
||||
},
|
||||
},
|
||||
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 минута.',
|
||||
},
|
||||
},
|
||||
required: ['label', 'delta_seconds'],
|
||||
},
|
||||
},
|
||||
]
|
||||
310
package-lock.json
generated
310
package-lock.json
generated
@@ -8,14 +8,17 @@
|
||||
"name": "smart-home-tablet",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"@anthropic-ai/sdk": "^0.65.0",
|
||||
"clsx": "^2.1.1",
|
||||
"framer-motion": "^11.1.7",
|
||||
"googleapis": "^171.4.0",
|
||||
"groq-sdk": "^0.36.0",
|
||||
"https-proxy-agent": "^7.0.6",
|
||||
"lucide-react": "^0.376.0",
|
||||
"next": "14.2.3",
|
||||
"react": "^18",
|
||||
"react-dom": "^18"
|
||||
"react-dom": "^18",
|
||||
"undici": "^7.16.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20",
|
||||
@@ -40,6 +43,35 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/@anthropic-ai/sdk": {
|
||||
"version": "0.65.0",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.65.0.tgz",
|
||||
"integrity": "sha512-zIdPOcrCVEI8t3Di40nH4z9EoeyGZfXbYSvWdDLsB/KkaSYMnEgC7gmcgWu83g2NTn1ZTpbMvpdttWDGGIk6zw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"json-schema-to-ts": "^3.1.1"
|
||||
},
|
||||
"bin": {
|
||||
"anthropic-ai-sdk": "bin/cli"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"zod": "^3.25.0 || ^4.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"zod": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/runtime": {
|
||||
"version": "7.29.2",
|
||||
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz",
|
||||
"integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@jridgewell/gen-mapping": {
|
||||
"version": "0.3.13",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
|
||||
@@ -287,12 +319,21 @@
|
||||
"version": "20.19.39",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.39.tgz",
|
||||
"integrity": "sha512-orrrD74MBUyK8jOAD/r0+lfa1I2MO6I+vAkmAWzMYbCcgrN4lCrmK52gRFQq/JRxfYPfonkr4b0jcY7Olqdqbw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": "~6.21.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/node-fetch": {
|
||||
"version": "2.6.13",
|
||||
"resolved": "https://registry.npmjs.org/@types/node-fetch/-/node-fetch-2.6.13.tgz",
|
||||
"integrity": "sha512-QGpRVpzSaUs30JBSGPjOg4Uveu384erbHBoT1zeONvyCfwQxIkUshLAOqN/k9EjGviPRmWTTe6aH2qySWKTVSw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*",
|
||||
"form-data": "^4.0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/prop-types": {
|
||||
"version": "15.7.15",
|
||||
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
|
||||
@@ -321,6 +362,18 @@
|
||||
"@types/react": "^18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/abort-controller": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz",
|
||||
"integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"event-target-shim": "^5.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.5"
|
||||
}
|
||||
},
|
||||
"node_modules/agent-base": {
|
||||
"version": "7.1.4",
|
||||
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
|
||||
@@ -330,6 +383,18 @@
|
||||
"node": ">= 14"
|
||||
}
|
||||
},
|
||||
"node_modules/agentkeepalive": {
|
||||
"version": "4.6.0",
|
||||
"resolved": "https://registry.npmjs.org/agentkeepalive/-/agentkeepalive-4.6.0.tgz",
|
||||
"integrity": "sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"humanize-ms": "^1.2.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 8.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/any-promise": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz",
|
||||
@@ -358,6 +423,12 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/asynckit": {
|
||||
"version": "0.4.0",
|
||||
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
|
||||
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/autoprefixer": {
|
||||
"version": "10.5.0",
|
||||
"resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.5.0.tgz",
|
||||
@@ -626,6 +697,18 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/combined-stream": {
|
||||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
|
||||
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"delayed-stream": "~1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/commander": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz",
|
||||
@@ -682,6 +765,15 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/delayed-stream": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
|
||||
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/didyoumean": {
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz",
|
||||
@@ -756,6 +848,21 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/es-set-tostringtag": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
|
||||
"integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0",
|
||||
"get-intrinsic": "^1.2.6",
|
||||
"has-tostringtag": "^1.0.2",
|
||||
"hasown": "^2.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/escalade": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
|
||||
@@ -766,6 +873,15 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/event-target-shim": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz",
|
||||
"integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/extend": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz",
|
||||
@@ -848,6 +964,50 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/form-data": {
|
||||
"version": "4.0.5",
|
||||
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz",
|
||||
"integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"asynckit": "^0.4.0",
|
||||
"combined-stream": "^1.0.8",
|
||||
"es-set-tostringtag": "^2.1.0",
|
||||
"hasown": "^2.0.2",
|
||||
"mime-types": "^2.1.12"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/form-data-encoder": {
|
||||
"version": "1.7.2",
|
||||
"resolved": "https://registry.npmjs.org/form-data-encoder/-/form-data-encoder-1.7.2.tgz",
|
||||
"integrity": "sha512-qfqtYan3rxrnCk1VYaA4H+Ms9xdpPqvLZa6xmMgFvhO32x7/3J/ExcTd6qpxM0vH2GdMI+poehyBZvqfMTto8A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/formdata-node": {
|
||||
"version": "4.4.1",
|
||||
"resolved": "https://registry.npmjs.org/formdata-node/-/formdata-node-4.4.1.tgz",
|
||||
"integrity": "sha512-0iirZp3uVDjVGt9p49aTaqjk84TrglENEDuqfdlZQ1roC9CWlPk6Avf8EEnZNcAqPonwkG35x4n3ww/1THYAeQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"node-domexception": "1.0.0",
|
||||
"web-streams-polyfill": "4.0.0-beta.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 12.20"
|
||||
}
|
||||
},
|
||||
"node_modules/formdata-node/node_modules/web-streams-polyfill": {
|
||||
"version": "4.0.0-beta.3",
|
||||
"resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-4.0.0-beta.3.tgz",
|
||||
"integrity": "sha512-QW95TCTaHmsYfHDybGMwO5IJIM93I/6vTRk+daHTWFPhwh+C8Cg7j7XyKrwrj8Ib6vYXe0ocYNrmzY4xAAN6ug==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 14"
|
||||
}
|
||||
},
|
||||
"node_modules/formdata-polyfill": {
|
||||
"version": "4.0.10",
|
||||
"resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz",
|
||||
@@ -1076,6 +1236,56 @@
|
||||
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/groq-sdk": {
|
||||
"version": "0.36.0",
|
||||
"resolved": "https://registry.npmjs.org/groq-sdk/-/groq-sdk-0.36.0.tgz",
|
||||
"integrity": "sha512-wvxl7i6QWxLcIfM00mQQybYk15OAXJG0NBBQuMDHrQ2vi68uz2RqFTBKUNfEOVz8Lwy4eAgQIPBEFW5P3cXybA==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@types/node": "^18.11.18",
|
||||
"@types/node-fetch": "^2.6.4",
|
||||
"abort-controller": "^3.0.0",
|
||||
"agentkeepalive": "^4.2.1",
|
||||
"form-data-encoder": "1.7.2",
|
||||
"formdata-node": "^4.3.2",
|
||||
"node-fetch": "^2.6.7"
|
||||
}
|
||||
},
|
||||
"node_modules/groq-sdk/node_modules/@types/node": {
|
||||
"version": "18.19.130",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.130.tgz",
|
||||
"integrity": "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": "~5.26.4"
|
||||
}
|
||||
},
|
||||
"node_modules/groq-sdk/node_modules/node-fetch": {
|
||||
"version": "2.7.0",
|
||||
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",
|
||||
"integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"whatwg-url": "^5.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "4.x || >=6.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"encoding": "^0.1.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"encoding": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/groq-sdk/node_modules/undici-types": {
|
||||
"version": "5.26.5",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz",
|
||||
"integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/has-symbols": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
|
||||
@@ -1088,6 +1298,21 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/has-tostringtag": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
|
||||
"integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"has-symbols": "^1.0.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/hasown": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz",
|
||||
@@ -1113,6 +1338,15 @@
|
||||
"node": ">= 14"
|
||||
}
|
||||
},
|
||||
"node_modules/humanize-ms": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/humanize-ms/-/humanize-ms-1.2.1.tgz",
|
||||
"integrity": "sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ms": "^2.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/is-binary-path": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
|
||||
@@ -1200,6 +1434,19 @@
|
||||
"bignumber.js": "^9.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/json-schema-to-ts": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/json-schema-to-ts/-/json-schema-to-ts-3.1.1.tgz",
|
||||
"integrity": "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.18.3",
|
||||
"ts-algebra": "^2.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16"
|
||||
}
|
||||
},
|
||||
"node_modules/jwa": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz",
|
||||
@@ -1295,6 +1542,27 @@
|
||||
"node": ">=8.6"
|
||||
}
|
||||
},
|
||||
"node_modules/mime-db": {
|
||||
"version": "1.52.0",
|
||||
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
|
||||
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/mime-types": {
|
||||
"version": "2.1.35",
|
||||
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
|
||||
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"mime-db": "1.52.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/motion-dom": {
|
||||
"version": "11.18.1",
|
||||
"resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-11.18.1.tgz",
|
||||
@@ -2161,6 +2429,18 @@
|
||||
"node": ">=8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/tr46": {
|
||||
"version": "0.0.3",
|
||||
"resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
|
||||
"integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/ts-algebra": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/ts-algebra/-/ts-algebra-2.0.0.tgz",
|
||||
"integrity": "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/ts-interface-checker": {
|
||||
"version": "0.1.13",
|
||||
"resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz",
|
||||
@@ -2188,11 +2468,19 @@
|
||||
"node": ">=14.17"
|
||||
}
|
||||
},
|
||||
"node_modules/undici": {
|
||||
"version": "7.25.0",
|
||||
"resolved": "https://registry.npmjs.org/undici/-/undici-7.25.0.tgz",
|
||||
"integrity": "sha512-xXnp4kTyor2Zq+J1FfPI6Eq3ew5h6Vl0F/8d9XU5zZQf1tX9s2Su1/3PiMmUANFULpmksxkClamIZcaUqryHsQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=20.18.1"
|
||||
}
|
||||
},
|
||||
"node_modules/undici-types": {
|
||||
"version": "6.21.0",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
|
||||
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/update-browserslist-db": {
|
||||
@@ -2247,6 +2535,22 @@
|
||||
"engines": {
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/webidl-conversions": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
|
||||
"integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==",
|
||||
"license": "BSD-2-Clause"
|
||||
},
|
||||
"node_modules/whatwg-url": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
|
||||
"integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tr46": "~0.0.3",
|
||||
"webidl-conversions": "^3.0.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,14 +9,17 @@
|
||||
"lint": "next lint"
|
||||
},
|
||||
"dependencies": {
|
||||
"@anthropic-ai/sdk": "^0.65.0",
|
||||
"clsx": "^2.1.1",
|
||||
"framer-motion": "^11.1.7",
|
||||
"googleapis": "^171.4.0",
|
||||
"groq-sdk": "^0.36.0",
|
||||
"https-proxy-agent": "^7.0.6",
|
||||
"lucide-react": "^0.376.0",
|
||||
"next": "14.2.3",
|
||||
"react": "^18",
|
||||
"react-dom": "^18"
|
||||
"react-dom": "^18",
|
||||
"undici": "^7.16.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20",
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "es2020",
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
|
||||
Reference in New Issue
Block a user