diff --git a/app/api/voice/chat/route.ts b/app/api/voice/chat/route.ts new file mode 100644 index 0000000..f939e93 --- /dev/null +++ b/app/api/voice/chat/route.ts @@ -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 }) +} diff --git a/app/api/voice/stt/route.ts b/app/api/voice/stt/route.ts new file mode 100644 index 0000000..bf335d2 --- /dev/null +++ b/app/api/voice/stt/route.ts @@ -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 }) + } +} diff --git a/lib/voice-executors.ts b/lib/voice-executors.ts new file mode 100644 index 0000000..2a6aef3 --- /dev/null +++ b/lib/voice-executors.ts @@ -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 { + 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): Promise { + 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): Promise { + 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 | { error: string } + +const EXECUTORS: Record Promise> = { + 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 = {} + 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 = { + 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 = { 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 { + 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}` } + } +} diff --git a/lib/voice-history.ts b/lib/voice-history.ts new file mode 100644 index 0000000..71e8280 --- /dev/null +++ b/lib/voice-history.ts @@ -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 { + 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 { + 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 { + 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 +} diff --git a/lib/voice-prompts.ts b/lib/voice-prompts.ts new file mode 100644 index 0000000..b8aa2cf --- /dev/null +++ b/lib/voice-prompts.ts @@ -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) +} diff --git a/lib/voice-text.ts b/lib/voice-text.ts new file mode 100644 index 0000000..78b96a1 --- /dev/null +++ b/lib/voice-text.ts @@ -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() +} diff --git a/lib/voice-tool-schemas.ts b/lib/voice-tool-schemas.ts new file mode 100644 index 0000000..5afa2f9 --- /dev/null +++ b/lib/voice-tool-schemas.ts @@ -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'], + }, + }, +] diff --git a/package-lock.json b/package-lock.json index cce65ae..a1fef48 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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" + } } } } diff --git a/package.json b/package.json index 2942fdc..2e6f66f 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/tsconfig.json b/tsconfig.json index e7ff90f..dc2c68e 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,5 +1,6 @@ { "compilerOptions": { + "target": "es2020", "lib": ["dom", "dom.iterable", "esnext"], "allowJs": true, "skipLibCheck": true,